逆向 Koolshare Tailscale 插件
今天在调节 Openwrt LXC 容器的 CPU Limit 限制,调低后发现 PSI 指标出现了 full stalled,不是个好信号。
拉开 top 查看,是 tailscaled 进程占用最大了,就想着换成 Smaller binaries for embedded devices · Tailscale Docs。
其实 Koolshare 插件已经采用文档所述方案打包了
启动后发现大量的 [[2026-01-27#路由器上的-tailscale-日志文件有大量-monitor-rtm_newroute-内容|路由器上的 tailscale 日志文件有大量 monitor RTM_NEWROUTE 内容]] ,老问题复现。
今天看到了有 --no-logs-no-support 这么个参数,或者 TS_NO_LOGS_NO_SUPPORT 环境变量就想着给 Koolshare 版的加上看能否解决问题。
事实证明:并不能。从产生日志的 代码 就能看出是
log.Printf直接往外吐的。
但我还是想知道这个tailscale_config在干什么。
可难就难在 Koolshare 的 Tailscale 插件 完全没有提供自定义参数的入口。
深度解包后,确认从根本上就没开注入的口子。启动脚本把
env和PATH都固定死了。
明明像 MerlinClash(现在叫 Magic Catling2 了)等 Koolshare 插件,数个简单的 Shell 脚本就搞定的,为什么 Tailscale 插件却偏偏要弄一个 /koolshare/scripes/tailscale_config 二进制可执行文件呢?甚至还禁止了调试,越不让干某事我越要干,一定要弄清楚到底藏了什么花样
梳理插件执行流程
在 WebUI 上开启插件,可以看到浏览器发出请求:
POST /_api/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
{"id":20869512,"method":"tailscale_config","params":["web_submit"],"fields":{"tailscale_enable":"1","tailscale_ipv4_enable":"1","tailscale_ipv6_enable":"0","tailscale_advertise_routes":"0","tailscale_accept_routes":"0","tailscale_exit_node":"0"}}根据 SukkaW/Koolshare-OpenWrt-API-Documents: The API documents (unofficial) for Koolshare OpenWrt httpdb 的解释,method 对应 /koolshell/scripts/ 目录下的可执行文件。合起来实际调用了这段:
/koolshare/scripts/tailscale_config 20869512 web_submit手动在终端执行一遍,确实和网页执行一致,符合预期。
ps 寻找脚本内容
日志内容与其他 Koolshare 脚本日志类似,猜测其实也是引用了 /koolshare/scripts/base.sh 的 echo_date,所以目标是寻找 shell 脚本。
先拉出 ps 来看,可惜相关进程显示为 {sh} busybox sh -c;换上 pstree,在 -c 后面显示的是一大堆空格;直接查看 /proc/<pid>/cmdline/,确实都是 0x20。也就是实际的脚本内容被隐藏了
$ /koolshare/scripts/tailscale_config 1 web_submit &
$ ps w | grep busybox
7847 admin 3871 S {sh} busybox sh -c
7867 admin 3871 S {sh} busybox sh -c
$ pstree -w 3871
-+= 03871 admin -sh
\-+- 07847 admin busybox sh -c
|--- 07868 admin tee -a /tmp/upload/tailscale_log.txt
\-+- 07867 admin busybox sh -c
\--- 07889 admin tailscaled -cleanup
$ hexdump -C /proc/7889/cmdline
00000000 62 75 73 79 62 6f 78 00 73 68 00 2d 63 00 20 20 |busybox.sh.-c. |
00000010 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
*
00001000对被重写过 argv 的命令行,替换为空格,这是 busybox ps 的代码逻辑。
- 上次注意到此现象还是某个脚本会隐藏传入的密码
- Systemd 也有代码 为了优化显示干这个
- 在 Changing argv changes the output of ps | jofra 亦有介绍
- Hiding in plain sight: Modifying process names in UNIX-like systems 恶意软件也常干这种事
但这次其实是 shc 对 argv 做
memset清空了内容
// copy argv to new location"
char **copyargs(int argc, char** argv){
char **newargv = malloc((argc+1)*sizeof(*argv));
char *from,*to;
int i,len;
for(i = 0; i<argc; i++){
from = argv[i];
len = strlen(from)+1;
to = malloc(len);
memcpy(to,from,len);
// zap old argv space
memset(from,'\\0',len);
newargv[i] = to;
argv[i] = 0;
}
newargv[argc] = 0;
return newargv;
}所以得通过其他手段获取实际运行的脚本。
strace 挂载提示无权限
安装 strace 的过程就很简单,插上 USB 参照 Entware · RMerl/asuswrt-merlin.ng Wiki 使用 amtm 安装 Entware,再 opkg install strace。
可是执行起来却在 ptrace(PTRACE_ATTACH, 22774) = -1 EPERM (Operation not permitted) 报错了,看起来是针对这种调试方式做了拦截。
IDA 静态分析
使用 IDA Pro 打开程序,寻找 execvp 在哪调用的,很快能定位到主入口。
在这里如果以
neither argv[0] nor $_ works字符串作为程序特征,很快能定位到是用了 neurobin/shc: Shell script compiler 。像是反调试、禁止 strace 都是 shc 自带的功能。
阅读反编译可以定位到是这段 shc/src/shc.c at master · neurobin/shc 在主逻辑之前拦截了 ptrace。这里对着源码看
void untraceable(char * argv0)
{
char proc[80];
int pid, mine;
switch(pid = fork()) {
case 0:
pid = getppid();
/* For problematic SunOS ptrace */
#if defined(__FreeBSD__)
sprintf(proc, "/proc/%d/mem", (int)pid);
#else
sprintf(proc, "/proc/%d/as", (int)pid);
#endif
close(0);
mine = !open(proc, O_RDWR|O_EXCL);
if (!mine && errno != EBUSY)
mine = !ptrace(PT_ATTACHEXC, pid, 0, 0);
if (mine) {
kill(pid, SIGCONT);
} else {
perror(argv0);
kill(pid, SIGKILL);
}
_exit(mine);
case -1:
break;
default:
if (pid == waitpid(pid, 0, 0))
return;
}
perror(argv0);
_exit(1);
}利用进程不能被二次 ptrace 的特性,发起 SIGKILL。所以挂上 ptrace 启动会有 Operation not permitted 错误提示。
动手修补程序
知道它是怎么做的,接下来就是在不改变相对引用地址的前提下,修补指令,绕过 kill 指令。好在 ARM 指令集都是固定长度。
00010F30 98 FE FF EB BL ptrace ; Branch with Link
00010F34 10 5F 6F E1 CLZ R5, R0 ; Count Leading Zeros
00010F38 A5 52 A0 E1 MOV R5, R5,LSR#5 ; Rd = Op2
00010F3C loc_10F3C ; CODE XREF: ...
00010F3C 00 00 55 E3 CMP R5, #0 ; Set cond. codes on Op1 - Op2
00010F40 12 10 A0 13 MOVNE R1, #0x12 ; Rd = Op2
00010F44 02 00 00 1A BNE loc_10F54 ; Branch
00010F48 07 00 A0 E1 MOV R0, R7 ; s
00010F4C 49 FE FF EB BL perror ; Branch with Link
07 00 A0 E1 MOV R0, R7 ; Rd = Op2
00010F50 09 10 A0 E3 MOV R1, #9 ; sig
12 10 A0 E3 MOV R1, #0x12 ; Rd = Op2
00010F54 loc_10F54 ; CODE XREF: ...
00010F54 06 00 A0 E1 MOV R0, R6 ; pid
00010F58 61 FE FF EB BL kill ; Branch with Link00010F4C:再执行一次MOV当做 NOP,绕过 perror 动作00010F50:把SIGKILL改为SIGCONT,避免杀死进程
对应的 patch 是这个:
This difference file was created by IDA
tailscale_config
0000000000000F4C: 49 07
0000000000000F4D: FE 00
0000000000000F4E: FF A0
0000000000000F4F: EB E1
0000000000000F50: 09 12GDB 解密提取脚本
应用补丁后就能正常挂上 gdb。更多的分析知道实际程序中,对每个字符串都做了 RC4 加密,密钥和密文都内嵌在二进制中。
在解密前后打上断点,从内存中可以发现更多指向 shc 的特征。
在执行 execvp 之前的位置打上断点,就能看到实际执行的内容。
(gdb) b *0x11284
Breakpoint 1 at 0x11284
(gdb) run
Starting program: /tmp/home/root/tailscale_config
warning: Unable to determine the number of hardware watchpoints available.
warning: Unable to determine the number of hardware breakpoints available.
[Detaching after fork from child process 3746]
Program received signal SIGCONT, Continued.
0xf7719df0 in waitpid () from /lib/libc.so.6
(gdb) c
Continuing.
Breakpoint 1, 0x00011284 in ?? ()
(gdb) x/s $r0
0x21882: "has expired!\nPlease contact your provider jahidulhamid@yahoo.com"
...
(gdb) b *0x00010cc0
Breakpoint 2 at 0x10cc0
(gdb) c
Continuing.
Breakpoint 2, 0x00010cc0 in ?? ()
(gdb) x/s $r0
0x21ee5: "#!/bin/sh\n\nsource /koolshare/scripts/base.sh\nNEW_PATH=$(echo $PATH | sed 's/:\\/opt\\/bin//g' | sed 's/:\\/opt\\/sbin//g' | sed 's/:\\/opt\\/usr\\/bin//g'| sed 's/:\\/opt\\/usr\\/sbin//g')\nexport PATH=\"${NEW_PA"...key 可以直接从二进制中提取出来,可以写个脚本做解密。但我选个更直接的办法,把 execve 参数都打印出来:
strace -f -e trace=execve -s 999999 -o strace.log ./tailscale_config这里需要 -s 999999 足够大,因为填 0 居然不行。然后再找个 Python 脚本提取输出得到:从 /koolshare/scripts/tailscale_config v2.0.3 中提取的控制脚本
在启动脚本做文本过滤
我还是不理解,既然没有用到 shc 的过期时间后禁止运行的功能,为什么还要用它呢。就为了避免脚本被修改吗,明明也没带恶意代码。
那么在输出日志前用 grep 做层过滤就能满足了。
run start-stop-daemon \
--start \
--quiet \
--make-pidfile \
--pidfile /tmp/tailscaled.pid \
--background \
--startas /bin/sh -- -c "exec /koolshare/bin/tailscaled \
-state /koolshare/configs/tailscale/tailscaled.state \
2>&1 > ${TSD_LOG}
2>&1 | grep -v -F 'ROUTE: src=, dst=2' > ${TSD_LOG}
"但实际使用还是不完美。grep 在等待下一个换行符,文件输出不完整。
尝试自行编译无日志版本
现在知道整个插件流程了,插件脚本和 Tailscale 都没什么特殊之处,那自己编译吧。
好在 Tailscale 是用 Golang 编写的,跨平台交叉编译极为简单,加 GOOS 和 GOARCH 就行。
我先是创建构建环境,尝试模拟插件版的构建结果。
虽然我不知道它是采用了什么方式编译的,参照 Koolshare 版的可用指令清单,我在 go1.25.7 下用下列命令成功编译了 Tailscale 1.92.4 版本。
git clone https://github.com/tailscale/tailscale.git --single-branch --branch v1.92.4
cd tailscale
export TAGS=$(GOARCH="" go run ./cmd/featuretags -remove taildrop,systray,syspolicy,completion -add cli)
export GOARCH=arm GOOS=linux
./build_dist.sh -ldflags "-w -s" -o tailscale.combined ./cmd/tailscaled
upx --lzma --best ./tailscale.combined输出二进制大小为 5.9 MB,估计再砍掉更多功能就能超越 Koolshare 插件的 5.4 MB 尺寸。
想到我是在一台嵌入式设备,路由器上运行 Tailscale,那还能关闭更多功能:
- funnel、serve,在内网可以 SSH 应对,而且也没需要暴露的本地服务
- drive, 路由器有什么目录好分享的,文件共享该由 NAS 负责
- web,作为 daemon 进程开后不理,无需管理页面
插件启动脚本依赖 health 的输出来检测启动成功的,cli 能构建 二合一版,osrouter 是必须部分,这些不能少。
最终我采用这个配置构建,只生成 4.17 MB。
export TAGS=$(GOARCH="" go run ./cmd/featuretags --remove ace,acme,aws,bakedroots,captiveportal,capture,clientmetrics,completion,debug,debugeventbus,debugportmapper,desktop_sessions,drive,gro,hujsonconf,iptables,identityfederation,kube,logtail,netlog,netstack,networkmanager,oauthkey,outboundproxy,serve,ssh,synology,systray,syspolicy,taildrop,tpm,wakeonlan,webclient --add cli)
./build_dist.sh -o tailscale.combined -ldflags "-w -s" ./cmd/tailscaled && upx --lzma --best ./tailscale.combined再从代码中删除日志相关的代码
--- upstream/net/netmon/netmon_linux.go 2026-02-10 17:22:33.572999994 +0800
+++ modified/net/netmon/netmon_linux.go 2026-02-10 17:21:57.260998307 +0800
@@ -200,15 +200,6 @@ func (c *nlConn) Receive() (message, err
if rmsg.Table == tsTable && dst.IsSingleIP() {
// Don't log. Spammy and normal to see a bunch of these on start-up,
// which we make ourselves.
- } else if tsaddr.IsTailscaleIP(dst.Addr()) {
- // Verbose only.
- c.logf("%s: [v1] src=%v, dst=%v, gw=%v, outif=%v, table=%v", typeStr,
- condNetAddrPrefix(src), condNetAddrPrefix(dst), condNetAddrIP(gw),
- rmsg.Attributes.OutIface, rmsg.Attributes.Table)
- } else {
- c.logf("%s: src=%v, dst=%v, gw=%v, outif=%v, table=%v", typeStr,
- condNetAddrPrefix(src), condNetAddrPrefix(dst), condNetAddrIP(gw),
- rmsg.Attributes.OutIface, rmsg.Attributes.Table)
}
if msg.Header.Type == unix.RTM_DELROUTE {
// Just logging it for now.使用这种方式,无需替换 /koolshare/scripts/tailscale_config,只需替换 /koolshare/bin/tailscale.combine
处理日志轮转
即便没了 monitor RTM_NEWROUTE 内容,日志文件还会不定期地出现 timeout 之类的日志。启动脚本以输出重定向方式写入日志文件的方式,运行时间长了以后不可避免地发生日志文件过大的问题。
但要在 Shell 里实现自动轮转,还是有些麻烦。日志还是需要看的,所以我选择 定时 重启 😏
cru a restart_tailscale "0 0 * * * /bin/sh /koolshare/scripts/tailscale_config start"WSL2 在 Windows 唤醒后 DNS 不工作了
在 Windows 11 从睡眠中唤醒后,表现是
- WSL2 中的
/etc/resolv.conf文件为空,除了注释没有有效内容。正常应该是带有从 DHCP 获得的(Windows 继承来的)nameserver字段 - DNS 解析失败,提示
Could not resolve host,或者Temporary failure in name resolution
我的 WSL2 早就设置过在 Mirrored 模式,关闭 DNS 代理,自然 generateResolvConf 也是 false
[wsl2]
dnsTunneling=false
autoProxy=false
dnsProxy=false
networkingMode=Mirrored
firewall=false互联网上没有有效的方子:
- WSL2 Network Issues and Win 10 Fast Start-Up – Stephen Rees-Carter 现象是这么个现象,但禁用快速启动的解决方式,感觉头疼医脚,不可接受
- DNS issues in WSL2 · Issue #8365 · microsoft/WSL 这是统一的 issue
- WSL2 终端无法启动 现在比以前好了,僵尸进程的问题似乎修复了
快速解决很简单, wsl.exe --shutdown 重启就能恢复,但此方法不可接受。
另一个办法是手动修改 /etc/resolv.conf,加上 nameserver 字段,但会撞上 sudo 提示无法解析主机 相同原因的错误。
我想只能手动重启了。