一、先说说这是个什么东西
想象这么一个场景:你有一台Linux服务器,上面跑着一个文件同步工具(比如Lsyncd、某个代码热重载工具,或者你自己写的监控程序)。这些工具底层都依赖Linux的inotify机制来感知文件变化------文件改了、删了、新建了,内核会主动通知程序。
但现在问题来了:你想监控的文件不在本地,而在另一台机器上 。也许是远程的开发机,也许是某个容器集群里的共享目录。你的工具只认本地的inotify,压根不知道什么叫"远程文件"。怎么办?
改代码?未必有源码。重写工具?太费劲。
这个方案的思路很巧妙:不改程序一行代码,让程序"以为"自己在用本地inotify,实际上事件是从网络上来的。就像给程序戴了一副"VR眼镜",它看到的一切都是本地化的,但背后其实是远程数据。
二、核心原理:当"中间翻译官"
整个方案的核心就一句话:用LD_PRELOAD拦截程序对inotify相关函数的调用,把网络事件"翻译"成inotify事件喂给程序。
2.1 为什么选LD_PRELOAD?
Linux的动态链接器有个很有意思的特性。当你启动一个程序时,如果环境变量里设置了LD_PRELOAD=./某个库.so,链接器会优先加载这个库 。如果你的库里定义了跟libc同名的函数(比如select、read、inotify_init),程序就会调用你的版本,而不是真正的系统版本。
这就是"截胡"。你站在程序和libc之间,程序喊"我要调用inotify_init",你先接过来,想干嘛干嘛,完了再决定要不要调用真正的版本。
代码里是这么做的:
c
static int (*func_select)(int, fd_set*, fd_set*, fd_set*, struct timeval*) = NULL;
if (!func_select) {
func_select = dlsym(RTLD_NEXT, "select");
}
dlsym(RTLD_NEXT, "select")的意思是:"帮我找到下一个 叫select的函数"。因为当前这个select是你自己定义的,RTLD_NEXT就像个指针,指向真正的libc实现。这样你就能在假select里,先干自己的事,再调真的select。
2.2 为什么要用"管道"来冒充inotify?
这是个关键设计。inotify返回给程序的是一个文件描述符(fd) ,程序用read()读它,用select()/poll()等待它。
理论上,网络socket也是一个fd,为什么不能直接把网络socket给程序当inotify fd用?
因为inotify的fd是内核特殊生成的 ,有特定的行为。而普通socket程序一读,拿到的是TCP原始数据,不是inotify_event结构体,程序直接就会崩。
所以作者想了个招:创建一对管道(pipe)。
- 管道有两个端:
pipe[0](读端)和pipe[1](写端) - 程序拿到的是
pipe[0],它以为这是inotify fd - 作者自己留着
pipe[1],有远程事件时,往里面写伪造的inotify_event数据 - 程序从
pipe[0]一读,拿到的是看起来像inotify事件的数据,浑然不觉
代码里inotify_init()的钩子就是这么干的:
c
int inotify_init(void) {
// 1. 先调用真的inotify_init,记录真实的fd(虽然后面不太用)
inotify_fd = func_init();
// 2. 创建一对管道
pipe(inotify_pipe);
// 3. 把管道的读端给程序,程序以为这是inotify fd
return inotify_pipe[0];
}
这一步堪称神来之笔。管道是用户空间完全可控的,你想写什么就写什么,完美适配"伪造事件"的需求。
三、整体流程图解
cpp
int wd_for_path(char *pathname) {
struct stat st;
if (stat(pathname, &st) == -1) {
return -1;
}
DPRINTF("SEARCH: looking for dev:%ld inode:%ld\n", st.st_dev, st.st_ino);
for(struct watch_entry *w = watches; w != watches_head; w++) {
if (w->device == st.st_dev && w->inode == st.st_ino) {
return w->wd;
}
}
errno = ENOENT;
return -1;
}
void print_inotify_event(struct inotify_event *i) {
DPRINTF("inotify_event: wd:%2d ", i->wd);
if (i->cookie > 0) {
DPRINTF("cookie:%4d; ", i->cookie);
}
DPRINTF("mask:");
if (i->mask & IN_ACCESS) DPRINTF("IN_ACCESS ");
if (i->mask & IN_ATTRIB) DPRINTF("IN_ATTRIB ");
if (i->mask & IN_CLOSE_NOWRITE) DPRINTF("IN_CLOSE_NOWRITE ");
if (i->mask & IN_CLOSE_WRITE) DPRINTF("IN_CLOSE_WRITE ");
if (i->mask & IN_CREATE) DPRINTF("IN_CREATE ");
if (i->mask & IN_DELETE) DPRINTF("IN_DELETE ");
if (i->mask & IN_DELETE_SELF) DPRINTF("IN_DELETE_SELF ");
if (i->mask & IN_IGNORED) DPRINTF("IN_IGNORED ");
if (i->mask & IN_ISDIR) DPRINTF("IN_ISDIR ");
if (i->mask & IN_MODIFY) DPRINTF("IN_MODIFY ");
if (i->mask & IN_MOVE_SELF) DPRINTF("IN_MOVE_SELF ");
if (i->mask & IN_MOVED_FROM) DPRINTF("IN_MOVED_FROM ");
if (i->mask & IN_MOVED_TO) DPRINTF("IN_MOVED_TO ");
if (i->mask & IN_OPEN) DPRINTF("IN_OPEN ");
if (i->mask & IN_Q_OVERFLOW) DPRINTF("IN_Q_OVERFLOW ");
if (i->mask & IN_UNMOUNT) DPRINTF("IN_UNMOUNT ");
if (i->len > 0) {
DPRINTF("name:%s(%d)\n", i->name, i->len);
} else {
DPRINTF("\n");
}
}
void inject_inotify_event(int pipefd, int sock) {
struct listen_event *le = listen_forwarder_read(sock);
DPRINTF("HOOK: read listen_event from socket:");
#ifdef DEBUG
listen_forwarder_print(le);
#endif
size_t event_size = sizeof(struct inotify_event) + strlen(le->file) + 1;
struct inotify_event *event = malloc(event_size);
int wd = wd_for_path(le->dir);
if (wd == -1) {
perror("wd_for_path()");
exit(1);
}
event->wd = wd;
event->cookie = 0;
event->len = strlen(le->file)+1;
event->mask = 0;
if(le->type == LISTEN_EVENT_ADDED) event->mask |= IN_CREATE;
if(le->type == LISTEN_EVENT_REMOVED) event->mask |= IN_DELETE;
if(le->type == LISTEN_EVENT_RENAMED) event->mask |= IN_MOVE_SELF;
if(le->type == LISTEN_EVENT_MODIFIED) event->mask |= IN_MODIFY;
strcpy((char*)&event->name, le->file);
DPRINTF("HOOK: injecting synthetic ");
print_inotify_event(event);
ssize_t n = write(pipefd, event, event_size);
if (n == -1) {
perror("write()");
} else {
DPRINTF ("HOOK: wrote %zd bytes of inotify_event to fd=%d\n", n, pipefd);
}
}
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout) {
static int (*func_select) (int, fd_set*, fd_set*, fd_set*, struct timeval*) = NULL;
if (! func_select) {
func_select = (int (*) (int, fd_set*, fd_set*, fd_set*, struct timeval*)) dlsym (REAL_LIBC, "select");
}
if (!inotify_pipe[0] || ! FD_ISSET(inotify_pipe[0], readfds)) {
return func_select(nfds, readfds, writefds, errorfds, timeout);
}
DPRINTF("HOOK: blocking on select() on inotify fd=%d\n", inotify_pipe[0]);
// inject the network listener socket into the read fds
FD_SET(listener_sock, readfds);
if ((int)listener_sock >= nfds) {
nfds = (int) listener_sock+1;
}
int count = func_select(nfds, readfds, writefds, errorfds, timeout);
DPRINTF("HOOK: done blocking on select(), got %d\n", count);
if (FD_ISSET(listener_sock, readfds)) {
inject_inotify_event(inotify_pipe[1], listener_sock);
FD_SET(inotify_pipe[0], readfds);
}
return count;
}
ssize_t read (int fd, void *buf, size_t count) {
static ssize_t (*func_read) (int, const void*, size_t) = NULL;
if (! func_read) {
func_read = (ssize_t (*) (int, const void*, size_t)) dlsym (REAL_LIBC, "read");
}
if (inotify_pipe[0] && fd == inotify_pipe[0]) {
DPRINTF ("HOOK: inotify read(%d, buf, %zd)\n", fd, count);
}
return func_read (fd, buf, count);
}
int inotify_init (void) {
static int (*func_init) (void) = NULL;
if (! func_init) {
func_init = (int (*) (void)) dlsym (REAL_LIBC, "inotify_init");
}
inotify_fd = func_init();
DPRINTF ("HOOK: real inotify_init() got fd=%d\n", inotify_fd);
if (pipe(inotify_pipe) != 0) {
perror("pipe()");
exit(1);
}
DPRINTF ("HOOK: replacing inotify fd with pipe=%d=>%d\n", inotify_pipe[0],inotify_pipe[1]);
return inotify_pipe[0];
}
int inotify_rm_watch (int fd, int wd) {
static int (*func_rm_watch) (int, int) = NULL;
if (! func_rm_watch) {
func_rm_watch = (int (*) (int, int)) dlsym (REAL_LIBC, "inotify_rm_watch");
}
DPRINTF ("HOOK: inotify_rm_watch on fd=%d, wd=%d\n", fd, wd);
return func_rm_watch (fd, wd);
}
int inotify_add_watch (int fd, const char *pathname, uint32_t mask) {
static int (*func_add_watch) (int, const char*, uint32_t) = NULL;
if (! func_add_watch) {
func_add_watch = (int (*) (int, const char*, uint32_t)) dlsym (REAL_LIBC, "inotify_add_watch");
}
if (! listener_sock && getenv("LISTEN_HOST") != NULL) {
int sock = listen_forwarder_connect(getenv("LISTEN_HOST"), 9400);
if (sock <= 0) {
perror("listen_forwarder_connect()");
exit(1);
}
listener_sock = sock;
}
if (fd == inotify_pipe[0]) {
fd = inotify_fd;
}
int wd;
wd = func_add_watch (fd, pathname, mask);
struct stat st;
if (stat(pathname, &st) == -1) {
perror("stat()");
exit(1);
}
DPRINTF("stat returned dev=%ld inode=%ld\n", (long)st.st_dev, (long)st.st_ino);
watches_head->wd = wd;
watches_head->device = st.st_dev;
watches_head->inode = st.st_ino;
watches_head++;
DPRINTF ("HOOK: inotify_add_watch(%d, %s, 0x%x) returned (wd=%d)\n", inotify_fd, pathname, mask, wd);
return wd;
}
If you need the complete source code, please add the WeChat number (c17865354792)
我把整个流程串一遍:
┌─────────────────────────────────────────────────────────────┐
│ 远程服务器(文件发生变化) │
│ ──产生事件──▶ [added/removed/renamed/modified] │
│ ──TCP网络──▶ 发送JSON格式事件 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 本地Linux机器(LD_PRELOAD钩子工作在这里) │
│ │
│ ① 程序启动,调用inotify_init() │
│ └─▶ 被hook拦截,创建pipe,返回pipe[0]给程序 │
│ │
│ ② 程序调用inotify_add_watch("/tmp", ...) │
│ └─▶ 被hook拦截,记录"/tmp"的dev+inode和wd映射关系 │
│ 同时建立到远程服务器的TCP连接(第一次add_watch时) │
│ │
│ ③ 程序调用select()等待文件事件 │
│ └─▶ 被hook拦截,偷偷把网络socket也加进select的监听集合 │
│ 程序以为自己在等inotify,实际同时在等网络数据 │
│ │
│ ④ 远程有事件来了! │
│ └─▶ select返回,发现网络socket可读 │
│ 读取4字节长度头 + JSON数据 + 换行符 │
│ 解析JSON,得到事件类型、目录、文件名 │
│ 根据目录找到对应的wd(watch descriptor) │
│ 构造一个inotify_event结构体 │
│ 把事件类型映射过去:added→IN_CREATE, removed→IN_DELETE │
│ 写入pipe[1] │
│ │
│ ⑤ 程序调用read(pipe[0]) │
│ └─▶ 读到伪造的inotify_event,跟真的没区别 │
│ 程序正常处理,完全不知道自己被"骗"了 │
└─────────────────────────────────────────────────────────────┘
四、关键代码拆解
4.1 网络事件读取与解析
远程事件通过TCP传输,协议很简单:4字节长度头(大端序) + JSON内容 + 换行符。
JSON格式大概长这样:["event", "added", "/tmp", "test.txt"]
listen_forwarder_read()负责从socket读取:
c
struct listen_event* listen_forwarder_read(int sockfd) {
uint32_t len;
// 先读4字节长度头
read(sockfd, &len, sizeof(len));
len = ntohl(len); // 网络字节序转主机字节序
// 再读JSON内容
char buf[MAX_EVENT_LENGTH];
while(还没读够len字节) {
read(sockfd, &buf[已读字节], 剩余字节);
}
// 最后读一个换行符做校验
read(sockfd, &nl, 1);
// 解析JSON,返回事件结构体
return listen_forwarder_parse(buf);
}
解析用的是jsmn这个轻量级JSON解析器,不依赖外部大库,适合这种嵌入式场景。
4.2 事件类型映射
远程事件只有四种:added、removed、renamed、modified。需要映射成本地的inotify mask:
c
if(le->type == LISTEN_EVENT_ADDED) event->mask |= IN_CREATE;
if(le->type == LISTEN_EVENT_REMOVED) event->mask |= IN_DELETE;
if(le->type == LISTEN_EVENT_RENAMED) event->mask |= IN_MOVE_SELF;
if(le->type == LISTEN_EVENT_MODIFIED) event->mask |= IN_MODIFY;
注意这里renamed映射成了IN_MOVE_SELF,而不是IN_MOVED_FROM/IN_MOVED_TO对。这是因为远程事件是简化模型,只告诉"重命名了",没有cookie配对机制,所以用IN_MOVE_SELF来近似表示。
4.3 select()钩子:同时监听两个来源
这是整个方案最精妙的逻辑。程序通常用select()阻塞等待inotify事件。作者hook了select(),做了三件事:
c
int select(int nfds, fd_set *readfds, ...) {
// 1. 如果程序没在等管道的读端,直接放行,不掺和
if (!FD_ISSET(inotify_pipe[0], readfds)) {
return 真正的select(nfds, readfds, ...);
}
// 2. 程序在等inotify(实际是等pipe[0])
// 偷偷把网络socket也加进去一起等
FD_SET(listener_sock, readfds);
// 3. 调用真正的select,同时等pipe[0]和网络socket
int count = 真正的select(nfds, readfds, ...);
// 4. 如果网络socket有数据,读取远程事件,写入pipe[1]
if (FD_ISSET(listener_sock, readfds)) {
inject_inotify_event(inotify_pipe[1], listener_sock);
// 确保pipe[0]在readfds里,让程序知道可读
FD_SET(inotify_pipe[0], readfds);
}
return count;
}
这里有个细节:程序调用select()时传入的readfds是按值传递的结构体(虽然指针传递,但FD_SET修改的是调用者提供的集合),所以作者可以直接修改它,把网络socket加进去。程序完全无感知。
4.4 路径到wd的映射
inotify_add_watch()返回一个wd(watch descriptor,整数)。远程事件只带了目录路径,怎么知道对应的wd是多少?
作者在inotify_add_watch()被调用时,记录了一个映射表:
c
struct watch_entry {
int wd;
dev_t device; // 设备号
ino_t inode; // inode号
};
不用路径字符串做key,而是用dev+inode这对唯一标识。因为路径可能有软链接、挂载点等别名,但dev+inode是文件系统级别的唯一ID。当远程事件来时,对事件中的目录路径调用stat()获取dev和inode,去表里查对应的wd。
五、这个方案涉及的技术领域
别看代码不算长,它横跨了好几个技术领域,我帮你总结下:
1. Linux系统编程与内核接口
- inotify机制 :Linux内核的文件系统事件通知接口,包括
inotify_init、inotify_add_watch、inotify_event结构体 - 文件描述符(fd):一切皆文件的设计哲学,管道、socket、inotify实例都是fd
- select/poll/epoll:I/O多路复用,程序等待事件的核心机制
- 管道(pipe):进程间通信的经典方式,这里被创造性地用来"伪造"内核事件流
2. 动态链接与运行时注入
- LD_PRELOAD:Linux动态链接器的预加载机制,用户空间拦截函数的黄金标准
- PLT/GOT劫持原理:程序通过过程链接表(PLT)调用外部函数,LD_PRELOAD让PLT条目指向你的实现
- dlsym(RTLD_NEXT):在拦截链中调用"下一个"实现,实现透明代理
3. 网络编程与协议设计
- TCP Socket通信 :C语言的底层socket API(
socket、connect、read) - 简单的二进制协议设计:4字节长度头 + 变长JSON体 + 分隔符
- 字节序转换 :
htonl/ntohl处理网络大端序和主机字节序
4. 数据序列化与解析
- JSON解析 :用
jsmn(无依赖的C语言JSON解析器)处理网络数据 - 字符串与结构体转换:把JSON字符串映射到C结构体,处理内存分配
5. 文件系统元数据
- stat结构体 :
st_dev(设备号)和st_ino(inode号)作为文件的唯一标识 - watch descriptor管理:用户空间维护wd与物理文件的映射关系
6. 软件调试与逆向工程思想
- 系统调用代理:不修改源码的前提下改变程序行为
- 透明中间层:对被拦截的程序保持完全兼容,行为一致
六、适用场景与局限性
适合干什么?
- 跨机器文件同步:让只支持本地inotify的工具(比如某些构建系统、热重载工具)能感知远程文件变化
- 容器/虚拟机场景:宿主机文件变化需要通知到容器内的进程
- 老旧系统兼容:给不支持远程监控的存量程序"打补丁",无需重新编译
有什么坑?
- 事件精度丢失:远程事件是简化模型(只有4种),inotify本身有十几种mask(IN_CLOSE_WRITE、IN_ATTRIB等),映射是近似的
- 重命名事件不完整 :inotify原生支持
IN_MOVED_FROM/IN_MOVED_TO配对(通过cookie关联),但远程JSON事件是单条的,只能用IN_MOVE_SELF凑合 - 网络延迟:本地inotify是内核级通知,亚毫秒级;走TCP网络再注入,延迟取决于网络质量
- 只拦截动态链接:如果程序静态链接了libc,或者直接用syscall指令绕开glibc,LD_PRELOAD就失效了
- 多线程安全 :代码里用了大量全局静态变量(
watches数组、listener_sock等),没有锁保护,多线程程序可能出问题
七、本地运行测试
终端1:启动带钩子的 inotifywait
bash
LD_PRELOAD=$PWD/inotify_hook.so inotifywait -m -r -e modify,attrib,close_write,move,create,delete /tmp
这个命令的意思:
- 加载我们的劫持库
- 监听
/tmp目录所有文件变动 - 实时打印事件
运行后,这个终端会卡住不动,正在等待文件事件。
终端2:创建/修改文件,触发监听
新开一个终端,直接输入:
bash
touch /tmp/llamas
然后你会立刻在终端1看到输出,类似:
/tmp/ CREATE llamas
再试:
bash
echo 123 >> /tmp/llamas
rm /tmp/llamas
终端1都会正常打印事件 ,说明钩子运行成功!
测试网络转发功能
如果你想测试网络发来的文件事件,需要:
终端1:启动监听
bash
LD_PRELOAD=$PWD/inotify_hook.so inotifywait -m -r /tmp
终端2:启动网络事件接收器
bash
./read_listen 127.0.0.1 9400
终端3:模拟发送文件事件
你可以写一个简单的 TCP 客户端,往 127.0.0.1:9400 发送 JSON 格式事件:
json
["ADD","/tmp","test.txt"]
或
json
["MODIFY","/tmp","test.txt"]
发送后,终端1的 inotifywait 会立刻收到伪造的文件事件!
这就是这套工具最厉害的地方:网络事件 → 伪装成本地文件事件。
总结
这个项目本质上是一个用户空间的系统调用代理层。它没有修改内核,没有改应用程序源码,纯粹利用Linux动态链接的机制,在网络事件和本地inotify之间搭了一座桥。
最精彩的设计有两个:
- 用管道冒充inotify fd:因为管道是用户空间可写的,而inotify fd是内核控制的。这一替换让"伪造事件"成为可能。
- 在select里"夹带私货":程序本来只想等inotify,hook偷偷把网络socket也塞进去一起等,远程有动静就立即注入事件。
Welcome to follow WeChat official account【程序猿编码】