如何把远程文件变化“骗“成本地inotify事件:一个LD_PRELOAD钩子

一、先说说这是个什么东西

想象这么一个场景:你有一台Linux服务器,上面跑着一个文件同步工具(比如Lsyncd、某个代码热重载工具,或者你自己写的监控程序)。这些工具底层都依赖Linux的inotify机制来感知文件变化------文件改了、删了、新建了,内核会主动通知程序。

但现在问题来了:你想监控的文件不在本地,而在另一台机器上 。也许是远程的开发机,也许是某个容器集群里的共享目录。你的工具只认本地的inotify,压根不知道什么叫"远程文件"。怎么办?

改代码?未必有源码。重写工具?太费劲。

这个方案的思路很巧妙:不改程序一行代码,让程序"以为"自己在用本地inotify,实际上事件是从网络上来的。就像给程序戴了一副"VR眼镜",它看到的一切都是本地化的,但背后其实是远程数据。


二、核心原理:当"中间翻译官"

整个方案的核心就一句话:LD_PRELOAD拦截程序对inotify相关函数的调用,把网络事件"翻译"成inotify事件喂给程序

2.1 为什么选LD_PRELOAD?

Linux的动态链接器有个很有意思的特性。当你启动一个程序时,如果环境变量里设置了LD_PRELOAD=./某个库.so,链接器会优先加载这个库 。如果你的库里定义了跟libc同名的函数(比如selectreadinotify_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 事件类型映射

远程事件只有四种:addedremovedrenamedmodified。需要映射成本地的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()获取devinode,去表里查对应的wd


五、这个方案涉及的技术领域

别看代码不算长,它横跨了好几个技术领域,我帮你总结下:

1. Linux系统编程与内核接口

  • inotify机制 :Linux内核的文件系统事件通知接口,包括inotify_initinotify_add_watchinotify_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(socketconnectread
  • 简单的二进制协议设计: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之间搭了一座桥。

最精彩的设计有两个:

  1. 用管道冒充inotify fd:因为管道是用户空间可写的,而inotify fd是内核控制的。这一替换让"伪造事件"成为可能。
  2. 在select里"夹带私货":程序本来只想等inotify,hook偷偷把网络socket也塞进去一起等,远程有动静就立即注入事件。

Welcome to follow WeChat official account【程序猿编码

相关推荐
Ws_1 小时前
WPF 面试题 + 参考答案,偏 C# 桌面端开发高频。
开发语言·c#·wpf
星空椰10 小时前
Python 面向对象高级:继承与类定义详解
开发语言·python
段一凡-华北理工大学10 小时前
2026 高炉炼铁智能化技术全景与演进路径~系列文章11:演进路径与行业未来
大数据·网络·人工智能·算法·工业智能体·高炉炼铁智能化
白露与泡影10 小时前
2026大厂Java面试题大全!牛客网最新版
java·开发语言
凯瑟琳.奥古斯特10 小时前
高阶子查询题目精炼
开发语言·数据库·python·职场和发展·数据库开发
雪度娃娃10 小时前
转向现代C++——在意为改写的函数添加 override
开发语言·c++
喵星人工作室11 小时前
C++火影忍者1.1.2
开发语言·c++
basketball61612 小时前
C++ 中的 ptrdiff_t 详解
开发语言·c++
星恒随风12 小时前
C语言数据结构排序算法详解(下):冒泡排序、快速排序、归并排序和计数排序
c语言·数据结构·笔记·学习·排序算法