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

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 的实际配置代码:

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 把数据传给 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() 实现得非常简洁:

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 会话》


项目地址github.com/baithinking...

相关推荐
plainGeekDev2 小时前
AlertDialog → DialogFragment
android·java·kotlin
流星白龙2 小时前
【MySQL高阶】13.其他存储引擎
android·数据库·mysql
Lyyaoo.2 小时前
【MySQL】SQL优化
android·sql·mysql
ImTryCatchException2 小时前
Android 性能优化实战手册:从理论到落地的完整方法论
android·性能优化
sun0077002 小时前
qnx网络相关模块,全链路,硬件网卡 → 用户态驱动 (.so) → io‑pkt/io‑sock(用户态 TCP/IP + 转发 + 控制)
android
赏金术士3 小时前
Android app 项目:模块打包 AAR 教程
android·热修复·tinker·aar打包
ImTryCatchException3 小时前
React Native 嵌入现有 Android 项目:踩坑记录与解决方案
android·react native·react.js
曼岛_3 小时前
[安卓逆向]在Android Studio中编写SO文件并测试调用 (四)
android·ide·android studio
ImTryCatchException4 小时前
Android 卡顿诊断 SDK:从痛点出发的设计思考
android·gitee