在 Android 10 及更高版本上,打开 /proc/net/tcp 的应用得到的是访问被拒,而不是 socket 表。VPN 接口并不在意:数据包照样到达,因为隧道是路由决策,不是文件权限。变化的是对更窄问题的答案:“这个连接属于哪个应用?”替代者是 ConnectivityManager.getConnectionOwnerUid,受支持的按连接查询;在旧习惯与新 API 之间如何选择,取决于你实际面对三种工作中的哪一种:接收数据包、归属某条流,还是统计流量总量。

回答所有者问题的两种方式

API 29 之前,特权进程或 root 可以读取内核的 socket 表,匹配四元组并得知所属 UID。这用一个文件就给出整机的清单。从 API 29 起,文件系统视图受到限制,受支持的途径是携带协议、本地地址和远端地址的按连接查询。

QuestionLegacy socket tablesgetConnectionOwnerUid
Who owns this flow?Scan and match the tupleAsk once per tuple
Device-wide inventory?Yes, when readableNo, one flow at a time
Historical usage?NoNo; that is NetworkStatsManager
Works without root?No on modern releasesYes, for authorized callers

这个比较不是“新旧之争”,而是三种不同的工作:过去由一个可读文件满足,现在由三个不同接口、三份不同契约分别满足。

本地地址才是应用这一端

方法签名接收协议、本地 socket 地址和远端 socket 地址。“本地”指连接中应用这一侧,而不是你检查的那一刻恰好成为包源的那一端。从远端服务器返回应用的包,其源和目的相对于该定义是颠倒的。

ConnectivityManager cm = getSystemService(ConnectivityManager.class);
int uid = cm.getConnectionOwnerUid(
    OsConstants.IPPROTO_TCP, appLocalAddress, appRemoteAddress);

这个方向错误是正常查询返回错误答案或什么都查不到的最常见原因。调用前把两端都记入日志,并拿这对地址与应用实际打开的连接核对。地址族同样重要:IPv4 映射地址与原生 IPv6 地址是不同的 tuple,同一端口上的 TCP 与 UDP 流也一样。

授权与未知结果都是契约的一部分

该查询只对已获授权的调用方开放,其中包括相应用户当前生效的 VPN 应用。无关的监控应用不能假定自己能枚举另一个配置文件的连接;未授权的调用可能抛出安全异常,而不是返回友好的错误。两种结果都要显式处理。

查不到所有者并不等于包名。流可能已在包与查询之间关闭,tuple 可能不完整,也可能调用方本就不被允许知道。缓存第一次成功的答案并稍后复用,会把新连接归到旧应用名下,这比报告未知更糟。

归属失败时,流量仍然需要决断:在策略要求处阻断,只放行你理解的范围,或者把缺口暴露出来。这种兜底属于设计,不属于注释。

计量是另一个问题

NetworkStatsManager 按自己的访问规则报告用量。它能告诉你某个 UID 在一段时间内传输了多少,却不能告诉你任意一个存活数据包属于谁。网络能力描述的是网络的属性,不是 socket 的目录。把三者混在一起,会做出看起来一致、实际上并不一致的看板。

对于“哪些应用走了隧道”这类简单需求,Android 受支持的允许与禁止应用列表已经表达了策略,无需任何抓取。把按流归属留给真正需要它的场景,并为同时处理方向、权限与未知状态的可持续实现预留预算。

按工作选工具,再测边界

如果需求是选隧道,用平台列表;如果是实时归属,用所有者查询,并把每个答不出的 tuple 视为未知;如果是历史总量,用 stats API。然后测试三个会击穿朴素实现的边界:反向的包、查询之前就关闭的流,以及不是当前 VPN 的调用方。

这一切都不需要关闭 SELinux、给手机 root,或让内核表全局可读。约束本身就是重点:平台把归属移到契约之后,让答案只提供给合法需要它的组件,而不给其他人。

相关文章