Android VpnService 详解:如何在用户态处理网络数据包
这是 WeakNet 技术博客系列的第二篇。上一篇我们看到了 WeakNet 的整体架构------从 TUN 接口到操纵管线的全链路。今天我们开始拆解最底层的基础设施:VpnService 是怎么在用户态处理网络数据包的?
如果你从未用过 VpnService,可能会觉得这需要 root 权限,或者要写内核模块。实际上完全不需要。Android 从 4.0(API 14)起就提供了 android.net.VpnService,它在 framework 层完成了所有脏活,我们只需要继承它、配置参数、拿到一个文件描述符,就能读到所有 IP 数据包。
VpnService 的工作原理
VpnService 的核心能力是创建一个虚拟的 TUN 网络接口。TUN 是一种内核级别的虚拟网络设备,它工作在 IP 层(网络层),和普通的物理网卡(eth0、wlan0)并列存在。
当 VpnService 启动后,Android 系统会做这几件事:
- 创建一个 TUN 接口(比如
tun0)。 - 修改系统的路由表,让所有 IP 流量都走到这个 TUN 接口,而不是直接从物理网卡出去。
- 把 TUN 接口的文件描述符(
FileDescriptor)返回给 App。
拿到这个 FileDescriptor 之后,App 就可以通过 FileInputStream 从里面读到原始的 IP 数据包,也可以通过 FileOutputStream 把修改后的数据包写回去------整个过程都在用户态完成,不需要任何特殊权限。
有一点需要强调:WeakNet 使用的 VpnService 仅用于本地网络调试。 它只在本地读取、修改、再转发数据包,不涉及远程服务器,也没有加密和隧道的概念。系统授权我们获取数据的前提是用户在弹窗里点了"确定"。
Builder 配置:搭建虚拟网络
VpnService 通过 Builder 类来配置虚拟网络参数。下面是 WeakNet 的实际配置代码:
kotlin
val builder = Builder()
.setSession("WeakNet")
.addAddress("10.0.0.2", 24) // 虚拟客户端 IP
.setMtu(1500) // 最大传输单元
.setBlocking(true) // 阻塞模式
.addRoute("0.0.0.0", 1) // 双路由策略(下半部分)
.addRoute("128.0.0.0", 1) // 双路由策略(上半部分)
.addDnsServer("223.5.5.5") // 阿里 DNS
.addDnsServer("119.29.29.29") // 腾讯 DNS
.addDnsServer("114.114.114.114") // 114 DNS
.establish() // 返回 ParcelFileDescriptor
逐行解释:
addAddress("10.0.0.2", 24) --- 设置虚拟客户端的 IP 地址和子网前缀长度。10.0.0.2/24 意味着 TUN 接口的 IP 是 10.0.0.2,子网掩码是 255.255.255.0,所在网段是 10.0.0.0 ~ 10.0.0.255。为什么选 10.0.0.x 而不是 192.168.x.x?因为大部分家庭路由器的 LAN 网段就是 192.168.1.x,如果虚拟网络也用这个网段,就会产生地址冲突,导致流量转发异常。10.0.0.x 网段在家庭网络中很少使用,冲突概率低得多。
setMtu(1500) --- 设置 TUN 接口的最大传输单元(Maximum Transmission Unit)。1500 是以太网标准 MTU,几乎所有网络设备都支持。更大的值可能被中间路由器分片,更小的值浪费带宽。
setBlocking(true) --- 让 TUN 文件描述符的 read() 和 write() 在没有数据时阻塞当前线程,而不是立即返回。替代方案是设置为 false 然后轮询(polling),但轮询会不断消耗 CPU,对于需要长时间运行的网络服务来说不可接受。阻塞模式下,线程在没有数据时会挂起,不占 CPU,直到有数据到来才被唤醒。
addRoute --- 路由配置,后面单独讲。
addDnsServer --- 配置 DNS 服务器。设置之后,系统发出的 DNS 查询也会被路由到 TUN 接口,WeakNet 就能在 PacketProcessor 里获取并处理 DNS 请求------这是 DNS 故障模拟的基础。
双路由策略:兼容国产 ROM 的 workaround
路由配置是 VpnService 里最容易踩坑的地方。
最直觉的写法是 addRoute("0.0.0.0", 0)------意思是把所有 IP 地址(0.0.0.0/0)都路由到 TUN 接口。这在大多数设备上工作正常,代码也简洁。
但在部分国产 ROM 上,事情就不那么美好了。某些厂商对 Android 的网络管理模块做过定制,部分版本的 ROM 在处理 /0 路由时存在兼容性问题------静默忽略。不会报错,不会崩溃,但就是不通。你配了虚拟网络,用户点了授权,结果流量根本没进 TUN 接口。
解决方案是用两条 /1 路由覆盖整个 IP 地址空间:
0.0.0.0/1覆盖0.0.0.0~127.255.255.255(IP 空间的下半部分)128.0.0.0/1覆盖128.0.0.0~255.255.255.255(IP 空间的上半部分)
两者合在一起,覆盖范围和 0.0.0.0/0 完全等价,都是所有 IP 地址。但 /1 路由的优先级更高(前缀越长优先级越高),所有 ROM 都能正确处理。这几乎成了 Android 网络工具的标准做法。
kotlin
// 用两条 /1 路由代替 0.0.0.0/0:部分厂商 ROM 对 /0 路由有兼容性问题,会直接忽略
builder.addRoute("0.0.0.0", 1)
builder.addRoute("128.0.0.0", 1)
在实际项目里,你几乎找不到不这么做的网络工具。这算是一个"行业共识"级别的 workaround。
protect():防止路由循环
获取数据包只是第一步,我们还需要把数据转发到真实网络。WeakNet 的做法是创建 SocketChannel(TCP)或 DatagramChannel(UDP),通过它们把数据发出去。
但这里有一个致命问题:我们刚才把所有 IP 流量都路由到了 TUN 接口。 如果我们用普通的 socket 发出数据,这些数据也会被路由回 TUN 接口,形成无限循环:
text
数据包 → TUN → 应用进程 → Socket → 系统路由 → TUN → 应用进程 → Socket → ...
这就是路由循环。一旦发生,CPU 瞬间打满,网络完全瘫痪。
Android 提供了 VpnService.protect(socket) 来解决这个问题。调用 protect() 后,系统会把这个 socket 排除在虚拟网络路由之外------它发出的数据走物理网卡,不经过 TUN 接口。
在 WeakNet 的 TcpSession.connectBlocking() 中,protect() 的调用位置非常关键------必须在 connect() 之前调用:
kotlin
fun connectBlocking(): Boolean {
val dstAddr = ByteUtils.ipAddressToString(destIp)
val ch: SocketChannel
try {
ch = SocketChannel.open()
ch.configureBlocking(true)
} catch (e: Exception) {
Log.w(TAG, "SocketChannel.open failed for $dstAddr:$destPort: ${e.message}")
return false
}
return try {
// protect() 必须在 connect 之前调用,防止路由循环
if (!vpnService.protect(ch.socket())) {
Log.e(TAG, "protect() FAILED for $dstAddr:$destPort")
ch.close()
return false // protect 失败必须终止,路由循环比连接失败更严重
}
Log.d(TAG, "Connecting to $dstAddr:$destPort ...")
ch.socket().connect(InetSocketAddress(dstAddr, destPort), CONNECT_TIMEOUT_MS)
_channel.set(ch)
// ... 状态转换 ...
true
} catch (e: Exception) {
try { ch.close() } catch (_: Exception) {}
false
}
}
注意那个 if (!vpnService.protect(...)) 的判断------如果 protect() 返回 false,我们直接关闭 socket 并返回失败。有人可能会想:"失败了也要试试连接嘛,万一能通呢?" 不行。 如果 protect 失败了还继续用这个 socket,就会产生路由循环。一个连不上的连接只是那个连接的问题,而路由循环会搞垮整个网络的所有流量。权衡之下,宁可放弃这一个连接。
同样的逻辑也适用于 UDP 的 DatagramChannel------每一个通过应用进程发出的 socket 都必须 protect(),无一例外。
PacketReader:从 TUN 读取原始数据包
配置好 VpnService、拿到 ParcelFileDescriptor 之后,下一步就是从 TUN 接口读数据。WeakNet 封装了一个 PacketReader 类来做这件事:
kotlin
class PacketReader(
private val vpnInput: FileInputStream,
private val onPacket: (ByteArray, Int) -> Unit,
) {
@Volatile
var running = false
private set
fun start() {
running = true
val buffer = ByteArray(VpnConfig.BUFFER_SIZE) // 32767 字节
while (running) {
try {
val length = vpnInput.read(buffer) // 阻塞等待数据
if (length < 0) {
Log.i(TAG, "TUN read returned -1, exiting")
break
}
if (length > 0) {
// buffer 被复用,必须复制有效数据,否则下一轮 read 会覆盖
val copy = buffer.copyOf(length)
onPacket(copy, length)
}
} catch (e: InterruptedIOException) {
Log.i(TAG, "PacketReader interrupted, exiting")
break
} catch (e: IOException) {
// 关闭 fd 时会触发此异常,属于正常退出路径
if (running) {
Log.w(TAG, "TUN read error: ${e.message}")
}
break
}
}
running = false
}
}
几个要点:
vpnInput.read(buffer) 会阻塞。 因为前面设置了 setBlocking(true),当 TUN 接口没有数据时,read() 会一直阻塞,不消耗 CPU。有数据到来时,read() 返回这一次读到的字节数。每次 read() 返回的恰好是一个完整的 IP 数据包------这是 TUN 设备的语义决定的。
buffer 必须复制。 buffer 是循环复用的,如果不复制就直接传给回调,下一轮 read() 会覆盖 buffer 里的内容,导致数据错乱。所以每次都用 buffer.copyOf(length) 创建一个新数组。
回调 onPacket 把数据传给 PacketProcessor。 在 VpnThread 中,回调就是 packetProcessor.processPacket(data, length)。至此,原始的 IP 数据包就进入了处理管线。
停机陷阱:Thread.interrupt() 叫不醒 TUN fd
停止服务的时候,需要让 PacketReader 退出阻塞的 read() 调用。一般思路是调用 Thread.interrupt()------但这里有个大坑。
Thread.interrupt() 对 TUN 文件描述符的 FileInputStream.read() 无效。 它不能中断阻塞在 native 层的 read() 系统调用。interrupt() 只是设置了一个 Java 层的标志位,而 TUN fd 的阻塞读是在内核空间等待数据,根本不看这个标志位。
唯一能让 read() 返回的办法:关闭文件描述符。 当 vpnInput.close() 被调用后,正在阻塞的 read() 会立即抛出 IOException,从而退出循环。
WeakNet 的 PacketReader.stop() 实现得非常简洁:
kotlin
fun stop() {
running = false
// TUN fd 的 read() 不可被 Thread.interrupt() 中断,只能通过关闭 fd 解除阻塞
try {
vpnInput.close()
} catch (_: Exception) {}
}
先设置 running = false 防止重入,然后直接 close()。如果此时 read() 正在阻塞,它会收到一个 IOException(fd 已关闭),进入 catch 分支,检查 running == false,正常退出。如果 read() 恰好不在阻塞状态(正在处理数据),下一轮 while 循环检查 running == false,也会退出。
这个模式在 Android 网络开发中很常见。虽然"靠关 fd 来停线程"听起来有点粗暴,但这是处理 TUN fd 阻塞读最可靠的方式。
虚拟网络拓扑
把上面所有内容拼起来,WeakNet 运行时的虚拟网络拓扑如下:
text
┌──────────────────────────────────┐
│ Android 设备 │
│ │
│ App (10.0.0.2) ──TUN──→ 进程 │
│ ↑ │ │
│ │ PacketProcessor │
│ │ │ │
│ │ protect()'d │
│ │ Socket │
│ └──────────── 真实网络 │
└──────────────────────────────────┘
在这个虚拟网络中:
- 虚拟网关 :
10.0.0.1,由系统自动创建,是 TUN 接口的对端地址 - 虚拟客户端 :
10.0.0.2,我们通过addAddress()配置的地址 - 子网掩码 :
255.255.255.0(/24),意味着10.0.0.0~10.0.0.255都在这个虚拟子网内
当手机上的 App 发出网络请求时(比如访问 93.184.216.34),系统查找路由表,发现目标 IP 匹配 0.0.0.0/1(或 128.0.0.0/1),于是把数据包发给 TUN 接口。应用进程从 TUN 读到这个包,解析目标地址,创建 protect() 过的 socket 连接到 93.184.216.34,把数据转发出去。远端服务器返回的响应通过这个 socket 回到应用进程,应用进程再把响应写回 TUN 接口。系统从 TUN 读到响应,交给发出请求的 App。
整个链路就这样闭环了。
小结
这一篇我们深入了解了 Android VpnService 的工作原理:它如何通过 TUN 接口获取数据包,Builder 的每个参数意味着什么,为什么需要双路由策略来兼容国产 ROM,protect() 如何避免路由循环,以及从 TUN 读取数据包的细节和停机时的陷阱。
现在我们已经知道怎么把流量"拿"进来了。但从 TUN 接口读到的只是一个 ByteArray------一堆原始字节。这堆字节是 IP 包头、TCP 包头还是 UDP 包头?源 IP 和目标 IP 在哪几个字节?TCP 的标志位怎么解析?
下一篇,我们来拆解 IP 数据包的解析------看 WeakNet 如何从原始字节中提取出 IP、TCP、UDP 的完整结构。
下一篇预告:《IP 数据包解析 --- 从原始字节到结构化的 TCP/UDP 会话》