Android VpnService:如何把所有流量导入用户态

0 阅读8分钟

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 系统会做这几件事:

  1. 创建一个 TUN 接口(比如 tun0)。
  2. 修改系统的路由表,让所有 IP 流量都走到这个 TUN 接口,而不是直接从物理网卡出去。
  3. 把 TUN 接口的文件描述符(FileDescriptor)返回给 App。

拿到这个 FileDescriptor 之后,App 就可以通过 FileInputStream 从里面读到原始的 IP 数据包,也可以通过 FileOutputStream 把修改后的数据包写回去——整个过程都在用户态完成,不需要任何特殊权限。

有一点需要强调:WeakNet 使用的 VpnService 仅用于本地网络调试。 它只在本地读取、修改、再转发数据包,不涉及远程服务器,也没有加密和隧道的概念。系统授权我们获取数据的前提是用户在弹窗里点了"确定"。

Builder 配置:搭建虚拟网络

VpnService 通过 Builder 类来配置虚拟网络参数。下面是 WeakNet 的实际配置代码:

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 网络工具的标准做法。

// 用两条 /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 接口,形成无限循环:

数据包 → TUN → 应用进程 → Socket → 系统路由 → TUN → 应用进程 → Socket → ...

这就是路由循环。一旦发生,CPU 瞬间打满,网络完全瘫痪。

Android 提供了 VpnService.protect(socket) 来解决这个问题。调用 protect() 后,系统会把这个 socket 排除在虚拟网络路由之外——它发出的数据走物理网卡,不经过 TUN 接口。

在 WeakNet 的 TcpSession.connectBlocking() 中,protect() 的调用位置非常关键——必须在 connect() 之前调用

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 类来做这件事:

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 把数据传给 PacketProcessorVpnThread 中,回调就是 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() 实现得非常简洁:

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 运行时的虚拟网络拓扑如下:

┌──────────────────────────────────┐
│  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 会话》


项目地址github.com/baithinking…