用 LD_PRELOAD 改写名称解析:一次 MTR 与 Go 运行时调试复盘

用 LD_PRELOAD 改写名称解析:一次 MTR 与 Go 运行时调试复盘

保留 laixintao 的完整调试记录:从替换 getnameinfo 到日志文件描述符被关闭,再到 fork 后 Go 共享库卡住。示例是受控进程的概念验证,不是可部署插件。

LD_PRELOAD 替换名称解析后,MTR 的 fork 和文件描述符处理引发日志消失及 Go 共享库卡住的原因示意。
同一个符号替换,为何在两个宿主中表现不同。原创示意,非运行结果。

从名称显示需求出发

本文保留 laixintao 的完整中文调试复盘。作者希望 MTR 路径显示内部设备名称,因此在自有受控进程中用 LD_PRELOAD 提供 getnameinfo 实现。最有价值的部分是宿主程序差异:简单 traceroute 试验成功,并不能证明嵌入 MTR 后仍成立。

以下截图和终端输出均来自原作者,不是本次运行结果。本文没有加载共享库、编译代码、发送网络探测或访问作者的内部 IP 信息平台。

POC 不能当作完整函数实现

C 示例仅用 strncpy 写固定名字。它没有处理空 host、长度不足、终止字符、服务名、flags 和错误返回等约定。如果缓冲区长度不够,strncpy 不保证补上 NUL;若调用者只要求服务名,host 也可能不应被直接写入。

Go 摘录同样需要核对 ABI:原文将长度参数写成 C.size_t,不能假设它与目标系统头文件要求的 socklen_t 宽度和约定一致,独立 Go 文件还应具备 package main。这些代码用于说明符号覆盖思路,不是经过验证的 libc 替代实现。

把 fork 和运行时限制留在结论里

作者定位到 MTR 在名称解析子进程中关闭文件描述符,导致预先打开的日志文件失效;随后又遇到 Go 共享库在 fork 后调用 HTTP 逻辑卡住。这个过程说明,宿主的进程模型、文件描述符策略和被嵌入语言运行时必须一起分析。

原文引用的 Go issue #15538是历史问题线索。不能仅凭 issue 的关闭状态认定所有版本都已安全支持任意 fork。作者最后转为 C 并在内部发布,但完整查询服务代码没有公开,所以本文不把结尾改写成读者可直接部署的插件教程。

加载库只应针对自己拥有或获授权的具体进程,不应写入全局预加载配置,也不应加载来源不明的库。以下保留原文全部推理和失败现场,供静态阅读。

原文正文

我和我的同事们排查网路问题非常喜欢 MTR,它是 traceroute 和 ping 的结合,可以快速告诉我们一个网络包的路径。是哪一跳丢包,或者延迟太高。

这些路径使用 IP 地址的形式表示的。没有人能记住这么多 IP 地址,所以我们需要有意义的名字。我在公司里写了一个平台,集成了其他的二十多个系统,给一个 IP,能查询出来这个 IP 对应的网络设备,容器,物理机,虚拟机等等。

复制 IP 到这个系统中查看结果,还是有些不方便,于是就想能不能让 MTR 直接展示设备的名字。

原作者 MTR 界面显示设备名称,原图已遮蔽敏感信息
原图:原作者 MTR 界面显示设备名称,原图已遮蔽敏感信息;来源:www.kawabangga.com,归属原作者/原站。

最终效果如图,可以展示 mtr 路径中所有的网络设备的名字。敏感信息已经隐藏。

MTR 支持 DNS PTR 反查,如果查到记录,会优先展示名字。这些名字在公网上通常没有什么意义。我们的内网 DNS 没有支持 PTR,所以这个 PTR 记录在默认的情况下也没有什么用。如果通过 DNS 系统来支持 PTR 记录的话,成本就有些大了,得对 DNS 做一些改造,DNS 又是一个比较重要的系统。那能不能有一个影响比较小的旁路系统来做到这个 feature 呢?

看了下 MTR 的代码,MTR 对 DNS PTR 的支持是通过 libc 的函数 getnameinfo(3) 来实现的。那么我就可以用 LD_PRELOAD 这个 hack,自己写一个 getnameinfo(3) 编译成 so,告诉 MTR 在寻找 getnameinfo(3) 的时候,先寻找我的 so 文件。这样,我就可以自己定义 getnameinfo(3) 的行为了。(就像魔法一样)

其实,proxychains1 程序也是用这种方式工作的,你只要在运行的命令前面添加 proxychains,proxychains 就会对后面运行的命令注入 LD_PRELOAD 环境变量,从而让程序调用的 socket API 是 proxychains 定义的,然后 proxychains 就会对 socket 做一些代理转发。

POC

可以写一个最简单的程序验证这样是否可行。

我们写一个最简单的函数,声明和 getnameinfo(3) 一模一样。

#include <stdio.h>
#include <netdb.h>
#include <string.h>
#include <sys/socket.h>

int getnameinfo(const struct sockaddr *__restrict addr, socklen_t addrlen,
                char *__restrict host, socklen_t hostlen,
                char *__restrict serv, socklen_t servlen, int flags) {

    strncpy(host, "kawabangga.com", hostlen);

    return 0;
}

不过,这个函数无论对于什么 ip,都会返回 kawabangga.com. 然后编译,运行 traceroute 程序。

编译命令:gcc -shared -fPIC -o libmylib.so mylib.c -ldl

此历史命令会向外部地址 1.1.1.1 发送 traceroute 网络探测。只有在你明确拥有该目标或已获准对该目标进行探测时才运行;优先将地址替换为自己拥有且获准探测的目标。以下输出仅为原作者记录,不是本次审查结果。


$ LD_PRELOAD=./libmylib.so traceroute 1.1.1.1
traceroute to 1.1.1.1 (kawabangga.com), 30 hops max, 60 byte packets
 1  kawabangga.com (kawabangga.com)  0.248 ms  0.386 ms  0.360 ms
 2  kawabangga.com (kawabangga.com)  5.223 ms  5.412 ms  5.375 ms
 3  kawabangga.com (kawabangga.com)  8.764 ms  9.206 ms  10.454 ms
 4  kawabangga.com (kawabangga.com)  10.403 ms  10.001 ms  10.321 ms
 5  kawabangga.com (kawabangga.com)  11.578 ms  11.826 ms kawabangga.com (kawabangga.com)  13.214 ms
 6  kawabangga.com (kawabangga.com)  12.858 ms kawabangga.com (kawabangga.com)  14.859 ms kawabangga.com (kawabangga.com)  13.905 ms
 7  kawabangga.com (kawabangga.com)  13.204 ms  8.645 ms kawabangga.com (kawabangga.com)  10.036 ms
 8  kawabangga.com (kawabangga.com)  10.166 ms  8.282 ms  9.155 ms
 9  kawabangga.com (kawabangga.com)  7.035 ms kawabangga.com (kawabangga.com)  8.533 ms kawabangga.com (kawabangga.com)  21.939 ms
10  kawabangga.com (kawabangga.com)  8.789 ms  7.832 ms  8.259 ms

可以看到,traceroute 显示每一跳的名字都是 kawabangga.com 了。

用 Go 语言 POC

我比较倾向于用 Go 语言来实现逻辑,而不是用 C 语言。

Go 语言也是支持编译到 shared lib 的2。hello world 代码如下:


/*
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>

*/
import "C"
import (
        "unsafe"
)

//export getnameinfo
func getnameinfo(sa *C.struct_sockaddr, salen C.socklen_t, host *C.char, hostlen C.size_t, serv *C.char, servlen C.size_t, flags C.int) C.int {
        hostStr := "foobar"

        hostCString := C.CString(hostStr)
        defer C.free(unsafe.Pointer(hostCString))
        C.strncpy(host, hostCString, hostlen)

        servStr := "80"
        servCString := C.CString(servStr)
        defer C.free(unsafe.Pointer(servCString))
        C.strncpy(serv, servCString, servlen)

        return C.int(0)
}

func main() {}

编译命令是:go build -o libmylib.so -buildmode=c-shared mylib.go

程序运行的命令一样,也可以看到 getnameinfo(3) 被成功 hook 了。

剩下的只需要在程序里面写逻辑代码就可以了,应该很简单(实际发现不简单)。

遇到问题 1: log 不打印

因为 traceroute 和 mtr 这种程序都是往 stdout 打印的,我开发代码又需要用 print 来调试,所以为了不干扰正常输出,就把日志打印到一个文件,通过 ENV 来控制日志是否需要打印,以及打印的日志路径。

结果就遇到了问题:日志打印在 traceroute 中是正常的,但是在 mtr 中看不到日志。

因为程序是「寄生」在 mtr 的代码中的,而且在 traceroute 中没有问题,所以应该和 mtr 的代码有关。

去看了一下代码,发现 mtr 和 traceroute 不一样的地方是:mtr 是用了异步的方式来执行 getnameinfo 函数,因为这个函数可能使用 DNS PTR 记录,涉及到网络请求,耗时可能很长。所以在调用的时候,mtr 会 fork 一个进程,专门执行这个函数。fork 出来的进程使用 PIPE 和主进程通信,并且 fork 之后就把除了 stdin, stdout, stderr 和 PIPE 之外的 fd 都关闭了。跟着关闭的,也包括我们的日志文件 fd。

解决办法就是修改了一下程序,不写日志到文件了,而是写到 stderr。在 debug 的时候,就 mtr 2>/tmp/stderr.log 这样,就可以了。

问题2: Golang 程序卡住

之前的 POC 代码运行正常,我把它改成通过 HTTP 请求 IP 信息服务的时候,居然就出问题了。mtr 显示的是 IP 而不是名字。从现象看,是函数执行失败了。

但是失败在哪里呢?

经过一段时间的排查,发现了这么几个现象:

  1. 每次程序运行之后,都有一些 mtr 进程残留在系统中没有结束;
  2. traceroute 还是正常的,但是 mtr 每次都会出问题;
  3. 不断加 print 来 debug,发现程序的问题出现在发送 HTTP 请求的地方,但是把这个地方的代码改成直接返回固定字符串,程序就正常了;

使用 gdb 去 debug,backtrace 如下,也看不出什么信息。

原作者在 MTR 卡住时采集的 GDB 线程回溯截图
原图:原作者在 MTR 卡住时采集的 GDB 线程回溯截图;来源:www.kawabangga.com,归属原作者/原站。

Golang 程序的 backtrace

看起来这个线程好像没有什么事情可以做。

花了一天排查无果,问了朋友,最后发现这个问题:Goroutines cause deadlocks after fork() when run in shared library #155383,而且开发人员的回复是:This is to be expected. It’s almost impossible for multithreaded Go runtime to handle arbitrary forks.

而 mtr 正好执行了 fork,所以这算是一个 Golang 的 runtime 问题——如果以 shared-lib 的方式运行,那么主程序是不能 fork 的,如果 fork,Go runtime 中的 goroutine 管理与多线程模型,fork 后线程状态的不一致可能会导致无法正常恢复,从而触发死锁。

最后,我把程序的逻辑用 C 语言实现了一下,就没有问题了。把它打包成 deb 包发布到了内网中。打包推荐用 nfpm4,非常方便,传统的用 apt 工具链打包太复杂了。

  1. Proxychains 项目:https://github.com/rofl0r/proxychains-ng,我的博客:编译安装proxychains4 ↩︎
  2. Fun building shared libraries in Go, https://medium.com/@walkert/fun-building-shared-libraries-in-go-639500a6a669 ↩︎
  3. https://github.com/golang/go/issues/15538 ↩︎
  4. https://github.com/goreleaser/nfpm ↩︎


本文中文翻译与技术整理及原站配图依据另行取得的授权使用;原文及原图的原有权利仍属于原权利人。

原文作者:laixintao,发布于 2025 年 1 月 8 日。来源:卡瓦邦噶!。

原文没有注明 Go、MTR 或系统版本。C 和 Go 代码是作者的概念验证,不是完整的 getnameinfo 实现或可部署插件。Linux 手册要求调用者提供 host/service 缓冲区并按 socklen_t 等签名传参;本文的审查注释说明了示例缺少的边界处理。Go issue #15538 是历史线索,关闭状态不等于证明任意版本的 fork 风险已解决。

原文页面没有声明开放的正文或图片许可。两张原站截图保留原作者署名和已有敏感信息遮蔽;本稿不对它们或博客正文另行授予开放许可。原创技术示意图由未完纪绘制。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容