目前业界的移动端爬虫在前端数据提取的部分大致有两套方案,一套是网络抓包、另一套是逆向Hook。无论是哪一种方案,都必须要解决一个问题,那就是网络要稳定。在一个较为狭小的空间内,如果是只有几十台设备,那一两台AP一般还能顶住。可如果有数百台设备,无脑堆AP的话就会出现各种问题(过载、负载不均、信道干扰、网段可用IP不足)等问题。
即使网络本身没有问题,在对抓包数据进行审计的时候,通常也需要维护一个设备号到IP的映射关系。随着设备的增多,映射关系的维护也是一个麻烦的事情,而且IP漂移的情况也不容忽视,管理起来也十分费劲。
既然无线网络不可靠,那么我们就考虑使用有线网络。传统手机一般都内置了 USB 网络共享功能,也就是手机通过USB和PC连接后,PC就可以使用手机的流量。但我们显然需要的应当是相反的功能,希望手机能够通过USB使用PC的流量出口。而这就是 Gnirehtet 项目解决的痛点。
Gnirehtet 是 Romain Vimont (rom1v)于2017年发起的开源项目,有 Rust 和 Java 的双重实现。这位法国老哥曾供职于 Genymobile 公司做设备监控和群控相关的工作,现在在 VideoLabs 做 VLC 相关的开发。除了 Gnirehtet 这个项目,他的 scrcpy 项目也非常棒( 50k+ star),目前我们也在内部系统中集成并魔改了这个项目用于设备投屏监控以及远程操作。
这个项目一个最大的坑就是他的读法,由于 rom1v 取 tethering 逆序之意,因此从音节上看可读性并不好。因此我私下一直都只叫他V**项目 :)
Gnirehtet 项目主要分为三块:Apk(部署在手机端),RelayServer(部署在主机端),CommandLine。
初始化完成后,手机端的通知栏会出现V**选项。
同时,手机的 ifconfig 中也会看到多了一个 tun0 的通道:
$ adb shell
cereus:/ $ ifconfig
lo Link encap:UNSPEC
inet addr:127.0.0.1 Mask:255.0.0.0
inet6 addr: ::1/128 Scope: Host
UP LOOPBACK RUNNING MTU:65536 Metric:1
RX packets:125860 errors:0 dropped:0 overruns:0 frame:0
TX packets:125860 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1
RX bytes:2585242418 TX bytes:2585242418
tun0 Link encap:UNSPEC
inet addr:10.0.0.2 P-t-P:10.0.0.2 Mask:255.255.255.255
inet6 addr: fe80::76d3:d75a:4a1b:a574/64 Scope: Link
UP POINTOPOINT RUNNING MTU:16384 Metric:1
RX packets:336 errors:0 dropped:0 overruns:0 frame:0
TX packets:412 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:500
RX bytes:130951 TX bytes:71517
由于 Gnihretet 这个项目应该没被系统性地使用过,因此该项目在大规模应用时会出现很多问题。截至 v2.5 版本,依然有很多坑或bug。以下问题的解决方案中有一部分已经提给 rom1v 进行修复,但是截至目前还没有发布新的 Release。
在 CommandLine 向手机安装 Apk 的时候,有概率会出现命令卡在 `Checking gnirehtet client...
之后,这是因为在 com.genymobile.gnirehtet.Main
在用 dumpsys package com.genymobile.gnirehtet
检测是否安装 Apk 时,在 fork 子进程的时候,未及时将新子进程写在缓冲区的数据读出,从而主进程 wait 子进程结束、而子进程 wait 缓冲区 flush, 导致死锁。
解决方案就是主进程保证先读出子进程的写缓冲区,再 wait 子进程结束。
List<String> command = createAdbCommand(serial, "shell", "dumpsys", "package", "com.genymobile.gnirehtet");
Log.d(TAG, "Execute: " + command);
Process process = new ProcessBuilder(command).start();
- int exitCode = process.waitFor();
- if (exitCode != 0) {
- throw new CommandExecutionException(command, exitCode);
- }
- Scanner scanner = new Scanner(process.getInputStream());
- // read the versionCode of the installed package
- Pattern pattern = Pattern.compile("^ versionCode=(\\p{Digit}+).*");
- while (scanner.hasNextLine()) {
- Matcher matcher = pattern.matcher(scanner.nextLine());
- if (matcher.matches()) {
- String installedVersionCode = matcher.group(1);
- return !REQUIRED_APK_VERSION_CODE.equals(installedVersionCode);
+ try {
+ Scanner scanner = new Scanner(process.getInputStream());
+ // read the versionCode of the installed package
+ Pattern pattern = Pattern.compile("^ versionCode=(\\p{Digit}+).*");
+ while (scanner.hasNextLine()) {
+ Matcher matcher = pattern.matcher(scanner.nextLine());
+ if (matcher.matches()) {
+ String installedVersionCode = matcher.group(1);
+ return !REQUIRED_APK_VERSION_CODE.equals(installedVersionCode);
+ }
+ }
+ } finally {
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ // Overwrite any pending exception, the command just failed
+ throw new CommandExecutionException(command, exitCode);
}
}
由于 Gnirehtet 需要维护并模拟所有手机到外网的长链接,因此在 RelayServer 内部维护了很多 StreamBuffer 和 DatagramBuffer 等的缓冲区用于进行数据拷贝。这就导致当连接数和设备数比较大时,很容易出现 OOM 等问题。
解决方案如下:
当设备连接时间变长后,如果有些连接回收的不及时,Router 维护的 Connection 表就会出现如下问题:
解决方案如下:
前文提到,Gnirehtet 的 RelayServer 是通过 adb 的 track-devices 协议从 adb 获取设备的上下线信息的。相关逻辑在 com.genymobile.gnirehtet.AdbMonitor 中。
但是 rom1v 并没有很好处理好异常情况。当大量设备同时批量上下线时,AdbMonitor 维护的状态就会有问题,并且接受 track-devices 信息的地方无法从异常解析中恢复,这就导致后续设备上下线的信息都被丢弃了。
解决方案:
static String readPacket(ByteBuffer input) {
if (input.remaining() < LENGTH_FIELD_SIZE) {
Log.i(TAG, "No field size found");
return null;
}
// each packet contains 4 bytes representing the String length in hexa, followed by a list of device states, one per line;
if (input.remaining() < length) {
// not enough data
input.rewind();
- return null;
+ throw new RuntimeException("Not Enough data");
}
input.get(BUFFER, 0, length);
return new String(BUFFER, 0, length, StandardCharsets.UTF_8);
}
由于安卓系统的特性,正在跑的 V** 客户端总是会因为各种原因被Kill 。无论是系统内存不足、还是用户主动清理后台、还是因为手机的省电策略,客户端都有可能挂掉。
简单的解决方法是手动在 ”最近活动“ 中将 Gnirehtet 的客户端用小锁锁住。这样虽然也不能完全保证不被杀,但是生存的概率还是大了很多。
如果嫌手动搞麻烦,也可以用 adb 工具直接设置 settings:
$ adb shell settings get system locked_apps
[{"u":0,"pkgs":[]},{"u":-100,"pkgs":["com.jeejen.family.miui"]}]
$ adb shell settings put system locked_apps '"[{\"u\":0,\"pkgs\":[\"com.genymobile.gnirehtet\"]},{\"u\":-100,\"pkgs\":[\"com.jeejen.family.miui\"]}]"'
$ adb reboot
重启后,就会发现客户端一样加了锁。
以下是我们在生产环境使用时,出于性能提升或审计需要实现的一些功能。虽然不是必须的功能,但也算是一种不错的实践。
默认的 gnirehtet.apk 在桌面时没有图标的,因此有时候比较难判断是否安装过,也不方便手动启动。通过修改 AndroidManifest.xml 文件,将android:icon=”@null"
修改为一个指定图片即可。
Gnirehtet 的 Apk 中将 DNS 服务器指定为 8.8.8.8 ,当然 Gnirehtet 也提供了指定 DNS server 的参数,但是在流量较大的情况下,如果能使用到本地的 DNS 缓存服务就更好了。
做法如下:
com.genymobile.gnirehtet.relay.AbstractConnection
中,仿照 主机 127.0.0.1 映射 10.0.2.2 的逻辑,将 主机的 127.0.0.53 映射到 10.0.2.53 。-d 10.0.2.53
参数即可。最后看一下本地缓存命中率:
$ systemd-resolve --statistics
DNSSEC supported by current servers: no
Transactions
Current Transactions: 0
Total Transactions: 40246262
Cache
Current Cache Size: 823
Cache Hits: 31190533
Cache Misses: 9319409
DNSSEC Verdicts
Secure: 0
Insecure: 0
Bogus: 0
Indeterminate: 0
在我们的量级下、默认缓存配置一般都能缓存 3/4 左右的请求。虽然实际使用体感上看不出啥变化,但从理论上应该还是有点用的 :)
由于各种原因,Gnirehtet 有跪的可能,因此需要在主机上检测手机网络到底与主机通不通。但是由于 Gnirehtet 会丢弃 ICMP 协议,因此 ping 是 ping 不到东西的。
因此我们在所有设备的 /data/local/tmp/ 下安装了 busybox ,使用 nc 命令与尝试与主机的某个端口通信。如果 socket 能连接上,则说明 v** 工作正常。
$ adb shell /data/local/tmp/busybox nc -vzw1 10.0.2.2 5037
10.0.2.2 (10.0.2.2:5037) open
-v 参数保证有回显, -z 参数表示只检测连通性不处理IO,-w1 表示最多等待 1s 超时。
10.0.2.2 是在 Gnirehtet 的 RelayServer 中硬编码的、在手机中代指主机的IP地址。
5037 是 adb 的端口,这个这个可以改成主机上任意一个可用的端口。只是借来探测连通信,不会发送实际数据。
出于审计(抓包统计)需要,我们总是想知道,某一个流量是来自哪一个手机设备。在 WIFI 网络方案中,我们(虽然愚蠢但是)可以手动维护设备ID与网络IP的关系。但是在 Gnirehtet 方案中,连接在同一主机的设备是公用同一出口IP的,无法进行区分。
为了解决这个问题,我们借鉴并扩展了 HAProxy 的 proxy-protocol 协议,仿照 py-proxy-protocol 的实现,在 TCP 建立连接后发送一小段数据包用于透传设备序列号。具体分为以下步骤:
android.permission.READ_PHONE_STATE
权限,读取设备序列号。(申请方式随 Android 版本而不同)。无论如何,总会有一些未知的 bug 或者无法恢复的情况,因此我们也做了V**相关的兜底策略:
这样的步骤下来,基本上能解决 99% 的问题了 :)
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有