逆向 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 插件 完全没有提供自定义参数的入口。

深度解包后,确认从根本上就没开注入的口子。启动脚本把 envPATH 都固定死了。

明明像 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.shecho_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 的代码逻辑。

但这次其实是 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 Link
  • 00010F4C :再执行一次 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 12

GDB 解密提取脚本

应用补丁后就能正常挂上 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 编写的,跨平台交叉编译极为简单,加 GOOSGOARCH 就行。
我先是创建构建环境,尝试模拟插件版的构建结果。

虽然我不知道它是采用了什么方式编译的,参照 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

再从代码中删除日志相关的代码

diff -up
--- 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

互联网上没有有效的方子:

快速解决很简单, wsl.exe --shutdown 重启就能恢复,但此方法不可接受。
另一个办法是手动修改 /etc/resolv.conf,加上 nameserver 字段,但会撞上 sudo 提示无法解析主机 相同原因的错误。

我想只能手动重启了。