最近有一个需求是这样的:Linux 机器上有多个网络的 interface,想要让其中的一个程序使用 eth1 而不是默认的 interface,不影响其他程序。
在 Linux 上发送 TCP 数据,是通过 kernel 提供的 syscall 将数据送给 kernel,然后 kernel 负责最后的发送。而到了 kernel 这一层,TCP 就是根据五元组来确认的了:来源端口,目的端口,来源IP,目的IP,以及协议类型。所以就让“按照进程转发”这个需求变得有些复杂。
Linux 提供了 SO_MARK
可以给流量“打标签”,然后针对打了标签的流量设置 route. 我们的思路是这样的:
- 设置一个路由规则,将带了
0x10
mark 的流量通过table 100
查找路由:ip rule add fwmark 0x10 table 100
; table 100
中只有一条default
路由规则,就是通过eth1
出去:ip route add default dev eth1 table 100 scope global
;- 然后给特定程序使用
SO_MARK
打标签。这样,就可以命中我们想要的路由了。
如何给这个流量打标签,变得有趣了起来。
方法1:iptables
如果应用使用的流量有明显的特征,就可以用这种方法。比如我们的流量都是去往 3306
的,就可以针对这个端口设置一条规则:
1 |
iptables -A PREROUTING -t mangle -i bond0 -p tcp --dport 3306 -j MARK --set-mark 0x01 |
但是话说回来,既然都知道端口了,可以直接把步骤 3 去掉,在步骤 1 让去往这个端口的流量直接命中 table 100
(ip-route(8)
是支持端口的):
1 |
ip rule add pproto tcp dport 3306 lookup 100 |
所以这个方法感觉比较鸡肋。而且这种只支持程序的流量必须有一个固定的模式,假如程序请求很多其他网站并且 TCP 无法识别,就麻烦了。
方法2:LD_PRELOAD 魔法
使用这种方式,我们可以不修改程序的代码而又让进程的流量全部带上标记。
第一次见到 LD_PRELOAD
是在 jvns 的博客上,简直是一个颠覆世界观的东西,它这么简单,又这么有效!
它的原理是这样:dynamic link 的程序是在启动的时候寻找应该从哪里 load 一个 symbol 的,通过设置 LD_PRELOAD
给进程,就可以覆盖动态链接的符号的查找顺序。
ldd 展示动态链接的库:
1 2 3 4 5 6 7 |
$ ldd /usr/bin/nc linux-vdso.so.1 (0x00007ffd118ab000) libbsd.so.0 => /lib/x86_64-linux-gnu/libbsd.so.0 (0x00007f5a3fb99000) libresolv.so.2 => /lib/x86_64-linux-gnu/libresolv.so.2 (0x00007f5a3fb85000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5a3f95d000) libmd.so.0 => /lib/x86_64-linux-gnu/libmd.so.0 (0x00007f5a3f950000) /lib64/ld-linux-x86-64.so.2 (0x00007f5a3fc45000) |
所以,当我们编译一个 .so
共享库,并且通过 LD_PRELOAD
设置成共享库的路径,那么在寻找符号的时候就会先寻找 .so
里面的,这样,我们就可以通过编写同名的函数来覆盖系统的函数。
如果你用过 proxychains
,那么你应该已经用过这个 feature 了。
proxychains 是一个代理软件,一般的软件使用代理都需要设置 http_proxy
等环境变量来告诉软件代理的地址。前提是这个软件支持读这些环境变量。但是,有了 Proxychains,你不需要软件自身支持,就可以让它走代理!原理就是,软件通过 proxychains 启动,而启动的时候,proxychains 会设置软件的 LD_PRELOAD
变量,然后去替换系统的 socket
函数,这样,任何调用都会走 proxychains 的代理!source code
所以,使用这种方法,我们只要替换程序的 socket
方法,在每次创建 socket
之后,调用 setsockopt(2)
去设置上 SO_MARK
就好了。
网上已经有一个程序 App-Route-Jail 做了这件事,它的使用方法如下:
1.clone 并且编译程序:
1 2 3 4 |
git clone https://github.com/Intika-Linux-Network/App-Route-Jail.git cd App-Route-Jail chown 755 make.sh ./make.sh |
2.启动程序的时候带上 LD_PRELOAD
变量,要设置什么 mark,通过 MARK
环境变量传入
1 |
MARK=10 LD_PRELOAD=./mark.so firefox |
它的程序很简单,就是用一个新的 socket
函数,替换原来的 socket
函数:在每次创建 socket
之后,调用 setsockopt(2)
设置 mark.
等下,那既然 mark.so
里面调用的也是 socket()
,怎么避免 linker 再去找一遍这个函数在哪里,然后找到了自己,造成无限循环呢?
在 mark.c
的代码中,是用 dysym(3)
去 load 的 socket()
这个符号:
1 2 |
if (!_socket) _socket = (socket_t) dlsym(RTLD_NEXT, "socket"); |
RTLD_NEXT
就是让 dlsym
从下一个 .so
开始找:
RTLD_NEXT
Find the next occurrence of the desired symbol in the search order after the current object. This allows one to provide a wrapper around a function in another
shared object, so that, for example, the definition of a function in a preloaded shared object (see LD_PRELOAD in ld.so(8)) can find and invoke the “real” func‐
tion provided in another shared object (or for that matter, the “next” definition of the function in cases where there are multiple layers of preloading).
所以这里用的就是 glibc 提供的 socket 函数了。
这个方法也有缺点,就是它只对 dynamic link 的程序有效,像 golang 这种语言默认是全部静态编译的,连 glibc 都不用,LD_PRELOAD
自然就无效了。这种情况我们就只能修改程序的代码,让它自己标记自己的流量了。
方法3:直接在 golang 代码中直接设置 fwmark
我们需要手动创建 Dialer 来进行 tcp 连接。并且给这个 Dialer 设置一个 Control
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// setSocketMark sets packet marking on the given socket. func setSocketMark(fd, mark int) error { if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_MARK, mark); err != nil { return os.NewSyscallError("failed to set mark", err) } return nil } // use this create a new dialer dialer := &net.Dialer{ Control: func(_, _ string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { ex := setSocketMark(int(fd), 0x01) if ex != nil { fmt.Printf("net dialer set mark error: %s\n", ex) } }) }, } |
感觉使用这种方式在 Chaos Engineering 方面也会很有用,我们一直苦于如何有效对 HTTP(S) 流量进行注入而不影响其他机器的进程。使用 SO_MARK 或许可以解决。
2023年3月1日更新:这篇文章发出之后,网友评论了更多有用和有趣的方法。
方法4:通过 network namespace 配置路由
最先是在 twitter 上看 @Sleepy93216599 介绍的。不过没尝试过,就不详细展开介绍了。
方法5:通过 iptables –uid-owner 匹配
iptables 居然支持直接按照 uid
来匹配,之前一直不知道这个。这样的话只要给进程分配一个单独的 user 来运行就好了。
此外,还支持 gid, pid, sid, cmd owner 来匹配:
–uid-owner userid
Matches if the packet was created by a process with the given effective user id.
–gid-owner groupid
Matches if the packet was created by a process with the given effective group id.
–pid-owner processid
Matches if the packet was created by a process with the given process id.
–sid-owner sessionid
Matches if the packet was created by a process in the given session group.
–cmd-owner name
Matches if the packet was created by a process with the given command name. (this option is present only if iptables was compiled under a kernel supporting this feature)
方法6:使用 ptrace(2) hook 系统调用 connect(2)
这个方法简直惊为天人。
本文评论里介绍了 gg 这个工具,可以用来 hook golang 写的程序,感觉很神奇,但是没看懂怎么实现的,只有 FAQ 里面提过一次 ptrace,然后灵光一闪发现可以用 ptrace 搞事情。
后来看到 graftcp 这个工具,文档和代码写的都很好,看的让人拍大腿。简单来说,就是程序启动的时候,通过 ptrace(2) 进行跟踪,然后当程序开始创建 tcp 的时候,拦截 connect(2) 调用,获取目标地址,然后给这个程序修改成自己的地址,欺骗程序,自己拿到了真正的地址。然后恢复执行。后面程序成功创建了 tcp 连接,但是却是到代理的 tcp 连接,而不是真正到目标地址的链接。这样,代理就在这之间工作了。而这对程序是无感的。
文档还提到一个有趣的点,就是为什么不直接去 hook write(2)
和 read(2)
呢? 原因有2:
- 不方便拿到真正的目标地址(原来的方法 hook
connect(2)
) 是通过 PIPE 把地址告诉代理进程。 - 去改写程序的 buffer 内存,会造成缓冲区溢出等错误。(而原来的方法直接修改
connect(2)
的参数是在寄存器,安全的多)。 - 另外 ptrace 是有性能损耗的,直接hook一次,在连接创建阶段,显然要比每次传输数据都暂停一下,性能要高得多。