六月开始做 EDR 开发实训,写到 ring3 网络采集器的时候卡住了。

需求听起来很简单:知道机器上每条 TCP 连接属于哪个进程。 就是做个 pid-to-connection 的映射。

但如果不能用 eBPF,也不能插内核模块,纯靠用户态能做吗?

能。内核在 /proc 里已经把答案摊在桌面上了,只不过需要用 inode 做桥梁,把两张表 JOIN 起来。

这篇文章是我在补这条「归因链路」时积累的知识。不是字典式的 Linux 教程——是从一个问题出发,把 inode → socket → /proc → fd → 权限模型这一整条线串起来。


起点:/proc/net/tcp

第一步,得先知道机器上现在有哪些 TCP 连接。

Linux 内核的 TCP 协议栈在 /proc/net/tcp 暴露了一个只读接口。每次 cat 它,内核会实时遍历所有活动 socket,把状态拍出来:

1
2
3
sl  local_address rem_address   st tx_queue rx_queue  uid  timeout inode
0: 0100007F:AA03 00000000:0000 0A 00000000:00000000 1000 0 30141
5: 8B80A8C0:0016 0180A8C0:FCE6 01 00000054:00000000 0 0 654952

第一眼看是乱码——IP 地址是十六进制还是反的。8B80A8C0 按字节倒序 → C0.A8.80.8B192.168.128.139小端序。端口 0016 转十进制 → 22(SSH)。

解码公式:/proc/net/tcp 里的 IP 和端口都是小端十六进制。本地地址 = IP:PORT 倒序解码即可。

字段里最关键的是最后一列——inode。同一个数字 654952 会同时出现在 /proc/net/tcp/proc/<pid>/fd 里。它就是连接和进程之间的桥梁。


为什么是 inode?

inode(索引节点)是文件系统里每个文件、目录、设备的底层身份标识。它不存文件名——文件名存在父目录的数据块里(”文件名 → inode号” 映射表)。inode 自己存的是元数据:权限位、时间戳、数据块位置。

但 socket 不是文件——它是内核内存里的缓冲区 + 协议栈,不落磁盘。那它哪来的 inode?

内核专门在内存里虚拟了一个文件系统,叫 sockfs(Socket File System)。每次创建 socket,sockfs 为它分配一个 inode 号。内核结构长这样:

1
2
3
4
struct socket_alloc {
struct inode vfs_inode; // VFS 层外壳:让文件系统认识它
struct socket socket; // 网络协议栈本体
};

外壳是给文件系统的,内核走同一套 VFS 接口操作一切资源。内芯才是真正处理 TCP 收发的东西。

这就是「一切皆文件」的真正含义——不是”所有东西都是磁盘文件”,而是”所有资源都挂到 VFS 上,用同一套路径解析和权限检查”。


/proc//fd:归因的最后一环

有了 socket inode 号,下一步就是找它属于谁。

每个进程的 /proc/<pid>/fd 目录是其文件描述符表的用户空间镜像。进去 ls -l

1
2
3
4
$ sudo ls -l /proc/1/fd
lrwx------ ... 0 -> /dev/null
lrwx------ ... 12 -> 'socket:[41282]'
lrwx------ ... 15 -> 'socket:[34338]'

socket:[41282]——冒号后面的数字就是 socket inode 号。跟 /proc/net/tcp 的 inode 列一比对,匹配上了——这条连接属于这个 PID。

整个归因链就是:/proc/net/tcp inode/proc/<pid>/fd 符号链接 ↔ PID。两张表 JOIN,查出一个 inode,就能确定一条连接属于哪个进程。

内核里完整的链路更长:task_struct(进程 PCB)→ files_struct(fd 数组)→ struct file(每次 open/socket 创建)→ dentry & inode(VFS 层映射)。但在 /proc 层面,socket:[inode] 三个字已经把底层细节压缩干净了。


权限从哪来?

归因拿到了 PID,下一个问题是:读取其他进程的 /proc/<pid>/fd 需要什么权限?

答案在进程凭据里。内核用 task_struct 表示每个进程,其中 cred 子结构体记录身份:

1
2
3
4
struct cred {
kuid_t uid; // Real UID:谁启动了这个进程
kuid_t euid; // Effective UID:进程当前以谁的身份提交权限
};

ruid 和 euid 平时相等。SUID 机制会打破它——比如 passwd 命令要改 /etc/shadow(只有 root 能写),内核通过 SUID 位让进程的 euid 临时变成 0(root),但 ruid 保持 1000,记录真正触发者。

关键:VFS 权限检查用的是 **euid**,不是 ruid。这个细节决定了 `/proc//fd` 能不能被读。

权限检查:匹配即停止

内核在做文件权限判断时走 generic_permission(),逻辑极简——匹配即停止

  1. 进程 euid == 文件所有者 UID → 用 User 权限位判断,结束
  2. 进程在文件的组内 → 用 Group 权限位判断,结束
  3. 前两步都没命中 → 用 Other 权限位判断

三层只走一层,不会叠加。文件所有者的权限覆盖一切——你是文件 owner,就算权限是 ---(000),Group 和 Other 的权限也完全不看。

inode 的 i_mode 字段存着所有权限位。低 9 位就是 [User(rwx)] [Group(rwx)] [Other(rwx)],比如 755 = owner 全权 + 其他人只读执行。


目录的 r 和 x,是两条内核路径

这一部分可能是整条学习链里最颠覆直觉的——目录的 rx 在内核里走的是两条完全不同的检查路径

权限 对目录 内核路径
r 读取目录下的文件名列表 getdents64()MAY_READ
x 穿透目录,访问子项 link_path_walk()MAY_EXEC

一个极端场景:目录权限 d--x--x--x(只有 x,没有 r)。

1
2
$ ls /blind_dir      # ❌ 失败——getdents64 需要 MAY_READ
$ cat /blind_dir/a.txt # ✅ 成功——link_path_walk 只需要 MAY_EXEC

你能用 cd 进去,能精确访问已知文件名的资源,但不能 ls 看列表。Shell 的通配符展开依赖 readdir(),底层用 getdents64——没有 r 权限,* 直接废掉。

这种「只可穿透、不可窥探」的目录叫盲盒目录(Execute-Only Directory),典型权限 711。核心结论:访问它下面的文件,必须提供绝对精确的文件名。通配符、Tab 补全全部失效。

在多租户隔离场景里,/var/www/userA/ 设 711,Nginx 用精确路径穿透正常服务,但被攻破的 userB 无法 ls 窥探其他租户。getdents64link_path_walk 这条内核分叉线,是这一切的底层基础。


学到了什么

从「怎么知道一条 TCP 连接属于哪个进程」出发,倒推出来的东西远远超出了预期:

  1. /proc/net/tcp 的每一列都有信息量,小端十六进制解码是基本功
  2. inode 不是磁盘文件的专利——sockfs 让 socket 也拥有了 inode 身份
  3. fd 表 + inode 是用户态归因的唯一可靠手段:两张 proc 表 JOIN
  4. euid ≠ ruid,VFS 只用 euid,SUID 位靠这个机制工作
  5. 目录的 r 和 x 是两码事getdents64 vs link_path_walk,MAY_READ vs MAY_EXEC
  6. 盲盒目录把这条内核差异用到了极致——有 x 无 r,穿透但不暴露
这些不是"学完Linux"的知识,是写采集器时被逼着走通的那条链路。下一次更新会继续往深处挖——`/proc//net/tcp`、netlink socket、以及怎么在 ring3 EDR 里把这些拼成能跑的采集器。

本文内容整理自个人知识库(Obsidian vault),每个概念的详细展开、代码片段和参考文献均已存入 wiki/ 目录。本文属于「Linux 学习合集」系列第一篇,后续随实训进度持续补充。