我去!路由器!其三

当你看到这篇文章时,我就假装你已经有了一个提取好的固件。

在路由器的固件的分析过程中你可能会遇到两种不同系统所产生的固件,一种是基于 Linux 的带文件系统的组合压缩包,这种相对好分析一点,另外一种是基于 VxWorks 的一体化可执行文件系统压缩包,这种常见与另类的老版路由器,不是特别好分析,本文章先从简单入手。


提取固件

环境与工具准备

小白鼠固件:DI_8003-16.07.26A1.trx,6210156 字节,D-Link 的 .trx 后缀。

下载链接:https://z-l-s-f.lanzouq.com/ijdCj4f8lnfc 密码:ahmn

要动固件,第一个想到的永远是 binwalk。

这工具已经从 python 重写成 rust 了,也就是 v3,现在装起来比以前省心——不用再跟 python2 和一堆 pip 依赖搏斗。

所以安装路线是:先上 rust,再用 cargo 装 binwalk。

先装 rustup:

1
2
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"

然后一条命令搞定 binwalk 本体:

1
cargo install binwalk

装完确认一下:

1
2
zlsf@Pwncomputer:~$ binwalk --version
binwalk 3.1.1

注意:cargo install binwalk 只装那一个 rust 可执行文件,不带外部提取工具。

binwalk 自己只负责”认出”这是什么、在哪个偏移,真正解包是靠外部程序的。

所以扫不出来东西不一定是固件的问题,很可能是缺提取器——这时候去 binwalk -L 看,最后一列写着这一项要哪个工具。

1
2
3
4
5
6
7
Signature Description              Signature Name                     Extraction Utility
SquashFS file system squashfs sasquatch
JFFS2 filesystem jffs2 jefferson
UBI image ubi ubireader_extract_images
CramFS filesystem cramfs 7zz
uImage firmware image uimage Built-in
LZMA compressed data lzma Built-in

最后一列写 Built-in 的不用管,rust 版内置;写工具名的就得自己补。

下面这套是我这台机器上常用的扩展支持,按需装,不是全都要:

1
2
3
4
5
6
7
8
# 打包/解包全家桶,7zz 承担了很大一部分格式
sudo apt install p7zip-full zstd unzip tar lz4 lzop unrar cabextract

# 文件系统与分区相关
sudo apt install sleuthkit device-tree-compiler

# python 系的几个专用提取器
sudo pip3 install jefferson ubi-reader uefi_firmware

squashfs 用 sasquatch,比原版 unsquashfs 认的魔改版本多,建议直接装 onekey-sec 打的 deb,省得自己编:

1
2
3
4
curl -L -o sasquatch_1.0.deb \
"https://github.com/onekey-sec/sasquatch/releases/download/sasquatch-v4.5.1-4/sasquatch_1.0_$(dpkg --print-architecture).deb"
sudo dpkg -i sasquatch_1.0.deb
rm sasquatch_1.0.deb

顺带把熵图依赖补上,不加这个 -E 会直接报错:

1
sudo apt install libfontconfig1-dev

装完再跑 binwalk -L,最后一列基本就不是 None 了。

官方说明在 Binwalk v3 README,依赖清单也在里面,懒得一条条装可以直接跑它的 dependencies/ubuntu.sh。


无脑先跑一遍 binwalk -Me

拿到一个来路不明的固件,先把脑子关掉,跑一条:

1
binwalk -Me xxxx.bin

-e 是自动提取,-M 是 matryoshka——俄罗斯套娃,说人话就是递归:解出来的东西再扫一遍,再解一遍,一层层剥到底。

大多数没加密的固件,这一条下去就能直接给你一个能 cd 进去的文件系统。

这固件也一样,跑完的日志里一串 completed successfully:

1
2
3
4
[+] Extraction of uimage data at offset 0x4C completed successfully
[+] Extraction of lzma data at offset 0x0 completed successfully
[+] Extraction of lzma data at offset 0x404000 completed successfully
[+] Extraction of cpio data at offset 0x0 completed successfully

解出来的路径会越来越长,一层包一层,看着很有成就感:

1
2
3
4
5
6
7
extractions/DI_8003-16.07.26A1.trx.extracted/4C/
└── Linux_Kernel_Image.bin
└── Linux_Kernel_Image.bin.extracted/0/
└── decompressed.bin
└── decompressed.bin.extracted/404000/
└── decompressed.bin
└── decompressed.bin.extracted/0/ ← 真正的根文件系统

最后一层就是标准的嵌入式 Linux 目录,ls 一下:

1
bin  dev  etc  etc_ro  home  lib  media  mnt  proc  sbin  sys  tmp  usr  var

随便验一个二进制,是 MIPS 的 busybox:

1
2
3
$ file bin/busybox
bin/busybox: ELF 32-bit LSB executable, MIPS, MIPS-II version 1 (SYSV),
dynamically linked, interpreter /lib/ld-uClibc.so.0, stripped

到这儿其实就收工了,剩下的 binwalk 帮不了你,得自己进去翻 etc_ro/rcS、etc_ro/web、sbin 那些东西。

不过先别急着信这棵树。

数一下 find -type f,跟 cpio 里写的条目数根本对不上:

1
2
$ find . -type f -not -path '*.extracted/*' | wc -l
424

而 cpio 里明明白白写着 611 个条目。

把类型摊开看,就知道这 424 是拿什么凑出来的:

1
2
3
4
5
6
$ cpio -itv < decompressed.bin 2>/dev/null | awk '{print substr($0,1,1)}' | sort | uniq -c
353 - ← 普通文件
14 b ← 块设备
57 c ← 字符设备
59 d ← 目录
128 l ← 软链接

353 + 14 + 57 + 59 + 128 = 611,对上了。

bin/cat、bin/grep、bin/gunzip 这一百多个全是 -> busybox,binwalk 解的时候软链接一个都没落地,全丢了。

dev/ 下面那 71 个设备节点(14 块 + 57 字符)倒是落地了,但全变成 0 字节的普通文件,主次设备号没了。

所以 353 + 71 = 424——find -type f 数的其实是”普通文件 + 伪装成普通文件的设备节点”,软链接和目录都不算。

结论:binwalk 给你的是能看能翻的树,不是能直接拿去 chroot 或者重打包的树。

要干净的,还得自己用 cpio 解一遍:

1
cpio -idmv --no-absolute-filenames < decompressed.bin

出来就是 353 个文件加 128 个软链接,bin/cat -> busybox 这种关系保留得好好的。

另外递归还有个副作用:binwalk 连文件系统里面的每个文件也一起扫了。

这固件里塞了一堆 gif、png、jpeg,usr/sbin/jhttpd 本身就是个压缩包,binwalk 又给它解了一层,全堆在各自的 .extracted/ 里。

翻文件的时候记得 -not -path '*.extracted/*' 过滤,不然一屏全是它自己吐的中间产物。


裸扫只吐了一个 uImage

有意思的在这儿。

先不递归,只做一遍纯扫描:

1
binwalk DI_8003-16.07.26A1.trx

输出短得可疑:

1
2
3
4
5
6
7
8
9
10
11
DECIMAL       HEXADECIMAL     DESCRIPTION
-----------------------------------------------------------------------------
76 0x4C uImage firmware image, header size: 64 bytes,
data size: 6210016 bytes, compression: lzma,
CPU: MIPS32, OS: Linux, image type: OS
Kernel Image, load address: 0x80000000,
entry point: 0x803DE000,
creation time: 2016-08-03 00:25:46,
image name: "Linux Kernel Image"
-----------------------------------------------------------------------------
Analyzed 1 file for 111 file signatures (251 magic patterns) in 157.0 milliseconds

就一条,还是 uImage 的头部信息。

说好的内核呢?说好的文件系统呢?一个 SquashFS、一个 CramFS 都没有。

原因写在那行 description 里了:compression: lzma。

这固件后面那一大坨数据是整块被 LZMA 压过的,压完就是一段熵接近满值的高熵数据。

而 binwalk 干的是特征码扫描——它在文件里找 magic bytes。压缩之后,原来 hsqs(squashfs)或者 cpio 的 070701 全成了乱码,一个都认不出来。

也就是说 binwalk 没瞎,是它根本看不见里面。

再加上 binwalk3 默认只汇报最外层:扫到什么报什么,扫完这一层就结束,不会主动把压缩数据解开再扫一遍。

所以裸 binwalk 能看到的,永远只有没被压的那层壳。

想让它钻进去,只能靠 -M。

同理,要是换成一块加密过的固件,-Me 也白搭——熵图拉出来是一条平直的高线,binwalk 一个特征都吐不出来。

那种情况就别指望工具了,老老实实去找串口、找启动日志、找加密逻辑,或者干脆上夹子读 Flash。


一层层剥开

既然裸扫看不见,那就只能跟着 -M 的日志一层层往下看。

第一层,uImage 的 payload 是 LZMA:

1
2
0x0    LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes,
compressed size: 6210016 bytes, uncompressed size: 9098084 bytes

注意 compressed size: 6210016,和 uImage 头里写的 data size 一模一样,说明整个 payload 就是这一坨 LZMA。

解出来 9098084 字节,是内核本体。strings 一下能看到版本号:

1
2
Linux version 2.6.21 (root@jhl-ubuntu) (gcc version 3.4.2) #6236
Tue Aug 2 20:25:37 EDT 2016

内核 2.6.21,2016 年的东西,编译器 gcc 3.4.2——这年龄放现在算文物了。

但内核镜像里不止内核代码,-M 继续往里扫,0x404000 处又冒出一段 LZMA:

1
2
0x404000   LZMA compressed data, properties: 0x5D, dictionary size: 1048576 bytes,
compressed size: 4887396 bytes, uncompressed size: 19225088 bytes

这次字典大小从 32M 掉到 1M,压缩比也不一样,明显是另起的一包。

解开就是 cpio:

1
0x0    CPIO ASCII archive, file count: 611

611 个条目,就是前面那棵根文件系统。

所以整条链是:

1
D-Link VER2 头 → uImage 头 → LZMA(内核)→ LZMA(根文件系统)→ CPIO

一层压一层,每层都裹着,难怪裸扫什么都看不到。

顺手看一眼文件头,能对上这个结构:

1
2
3
4
5
6
7
$ xxd -l 96 DI_8003-16.07.26A1.trx
00000000: 5645 5232 3136 2e30 372e 3236 4131 0000 VER216.07.26A1..
00000010: 0000 0000 3134 3730 3138 3336 3434 0000 ....1470183644..
00000020: 0000 0000 646c 696e 0000 1f43 0000 0000 ....dlin...C....
00000030: 005e c220 0000 0000 0000 0000 623b 9c2a .^. ........b;.*
00000040: 0000 89fc 0000 0000 0000 0032 2705 1956 ...........2'..V
00000050: 7351 7fb5 57a1 3a0a 005e c1e0 8000 0000 sQ..W.:.........

开头不是标准 TRX 的 HDR0,是 D-Link 自己的 VER2 头。

0x00 是 VER2,紧接着 0x04 开始就是版本串 16.07.26A1,和文件名对得上。

0x14 处是 ASCII 的时间戳 1470183644,转一下正好是 2016-08-03。

0x24 那个 dlin 是 D-Link 的标记,四个字节,起签名作用。

0x4C 处是 2705 1956,这个就是 uImage 的魔数 0x27051956,和 binwalk 报的偏移一模一样。

也就是说前 76 个字节是 D-Link 自己加的壳,真正给 bootloader 认的 uImage 从 0x4C 才开始。

不过这个壳信息量也就那么回事:版本号、时间、机型码,没看到校验和,也没有加密标记。

所以它挡不住任何人——它只是让通用工具认不出来,不算保护。


找服务启动链

固件抠出来了,然后呢?

一个根文件系统里几百上千个文件,光扫一眼是看不出东西的。

分析文件系统的第一个目的,永远是找服务启动链——顺着它走一遍,你就知道这台设备开机之后到底跑了些什么,哪些端口在听,哪个进程在处理你的 HTTP 请求。

这条链通常就三个东西:init → 启动脚本/二进制 → 各个服务。

从 init 开始

一般 Linux 都从 init 开始启动,init 再一层层配置环境、拉起服务。

先看它是什么玩意儿:

1
2
$ ls -la init
lrwxrwxrwx 1 zlsf zlsf 7 Aug 3 2016 init -> sbin/rc

init 是个软链接,它一般是软链接,这一点很关键。

指向的东西有两种常见情况:

  1. 指向一个 shell 脚本 —— 那就是传统的 rcS 那一套,直接 cat 就能读完,最省事。
  2. 指向一个二进制 —— 那就得拖进 IDA 里啃了。

这台设备是第二种,指向 sbin/rc:

1
2
3
$ file sbin/rc
sbin/rc: ELF 32-bit LSB executable, MIPS, MIPS32 version 1 (SYSV),
dynamically linked, interpreter /lib/ld-uClibc.so.0, stripped

MIPS32,动态链接,stripped——符号表被剥了。

不过运气不错,IDB 里 738 个函数有 700 个是带名字的,main、init_main、start_jhttpd 这些都在,不然这活儿要难上不少。

(这类固件里 rc 往往和 rcS、inittab 混在一起,先确认它到底是不是脚本,别一上来就按脚本的思路读。)

顺手看一眼 sbin/init:

1
2
$ ls -la sbin/init
lrwxrwxrwx 1 zlsf zlsf 2 Aug 3 2016 sbin/init -> rc

init -> sbin/rc,sbin/init -> rc,两条路都通向同一个文件,不矛盾。


rc 是个多合一 applet 程序

把 rc 拖进 IDA,先看 main:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int __fastcall main(int argc, const char **argv, const char **envp)
{
signal(0x12, 1, envp); // SIGCHLD 先挂个空的,后面换真 handler
v5 = open("/dev/null", 2);
if ( v5 >= 3 )
close(v5);
else
{
dup(v5); // 保证 0/1/2 至少有一个是 /dev/null
dup(v5);
}
v7 = *argv;
v6 = strrchr(*argv, 0x2F); // 取 basename
if ( v6 )
v7 = (const char *)(v6 + 1);
v8 = "init";
for ( i = &off_45E014; ; v8 = *i ) // 遍历 applet 表
{
if ( !v8 )
{
printf("Unknown applet: %s\n", v7);
return 0;
}
if ( !strcmp(v7) ) // 拿 argv[0] 的 basename 去比对
break;
i += 2;
}
openlog(v7, 1, 8);
return ((int (__fastcall *)(int, const char **))i[1])(argc, argv);
}

这就是个 busybox 式的 applet 调度器。

argv[0] 的 basename 决定走哪条路,所以同一个二进制换身马甲就是另一个程序。

证据在文件系统里摆着,sbin/ 下一堆软链接全指向 rc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ ls -la sbin/ | grep -- '-> rc'
sbin/init -> rc
sbin/buttons -> rc
sbin/ddns-update -> rc
sbin/dhcpc-event -> rc
sbin/dhcpc-release -> rc
sbin/dhcpc-renew -> rc
sbin/do-ipsec -> rc
sbin/halt -> rc
sbin/ipsec_autoconn -> rc
sbin/ipsec_daemon -> rc
sbin/listen -> rc
sbin/mrcz_false -> rc
sbin/ovpn-down -> rc
sbin/ovpn-up -> rc
sbin/ppp_event -> rc
sbin/pppoe_down -> rc
sbin/reboot -> rc
sbin/redial -> rc
sbin/sched -> rc
sbin/service -> rc
sbin/simple_cache -> rc
sbin/wan_conn_state_check -> rc
sbin/wan_restart -> rc

二十几个入口,全是一个文件。


把 applet 表挖出来

main 里的 off_45E014 就是那张表。

结构是 { 名字指针, 函数指针 } 每项 8 字节,遇到名字指针为 0 就结束。

用 IDAPython 直接读:

1
2
3
4
5
6
7
8
9
10
11
import ida_bytes, ida_funcs

ea = 0x45E014
for i in range(60):
p = ida_bytes.get_dword(ea + i*8) # 名字字符串地址
f = ida_bytes.get_dword(ea + i*8 + 4) # 函数地址
if not p:
break
nm = ida_bytes.get_strlit_contents(p, -1, 0)
fn = ida_funcs.get_func_name(f)
print(i, hex(p), nm, hex(f), fn)

跑出来 29 项,编号 0~28,第 29 次循环时名字指针为 0,正好是结束标记:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
0   0x41a740  init           0x404f8c  init_main
1 0x41a748 rc 0x404070 sub_404070
2 0x41a74c ip-up 0x40da94 jhl_ipup_main
3 0x41a754 ip-down 0x40e8f8 jhl_ipdown_main
4 0x41a75c set-pppoepid 0x40ed8c jhl_set_pppoepid_main
5 0x41a76c pppoe_down 0x40ed94 jhl_pppoe_down_main
6 0x41a778 ppp_event 0x40ec34 jhl_pppevent_main
7 0x41a784 hotplug 0x404000 sub_404000
8 0x41a78c redial 0x40f9c8 jhl_redial_main
9 0x41a794 listen 0x40f240 listen_main
10 0x41a79c service 0x40bf0c service_main
11 0x41a7a4 sched 0x416374 sched_main
12 0x41a7ac buttons 0x416db0 buttons_main
13 0x41a7b4 dhcpc-event 0x40c88c jhl_dhcpc_event_main
14 0x41a7c0 dhcpc-release 0x40d6b0 jhl_dhcpc_release_main
15 0x41a7d0 dhcpc-renew 0x40d7f4 jhl_dhcpc_renew_main
16 0x41a7dc radio 0x409f24 radio_main
17 0x41a7e4 halt 0x4069d8 reboothalt_main
18 0x41a7ec reboot 0x4069d8 reboothalt_main
19 0x41a7f4 ddns-update 0x416fcc jhl_ddns_update_main
20 0x41a800 ovpn-up 0x40ed9c jhl_ovpn_up_main
21 0x41a808 ovpn-down 0x40ee5c jhl_ovpn_down_main
22 0x41a814 do-ipsec 0x418a20 ipsec_main
23 0x41a820 ipsec_daemon 0x418a38 ipsec_domain_check
24 0x41a830 ipsec_autoconn 0x419014 ipsec_net2net_autoconn
25 0x41a840 simple_cache 0x460df4 usb_simple_cache
26 0x41a850 wan_restart 0x460ad4 jhl_wan_restart_main
27 0x41a85c wan_conn_state_check 0x460de4 jhl_wan_conn_state_check
28 0x41a874 mrcz_false 0x460ed0 mrcz_false_main

几个值得注意的地方:

  • halt 和 reboot 指向同一个 reboothalt_main,靠 argv[0] 在函数里再区分一次。
  • ip-up / ip-down 是给 PPP 用的。前面翻 sbin/ 会发现这俩没有——因为它们在 init_main 里运行时才 symlink("/sbin/rc", "/etc/ppp/ip-up") 创建出来。
  • ipsec_daemon 指向 ipsec_domain_check、do-ipsec 指向 ipsec_main,名字和函数对不上,别按名字猜功能。

有两个 applet 是摆设。

radio_main 就这么长:

1
2
3
4
int radio_main()
{
return 0;
}

空的,啥也没干。 但它的软链接、它的表项都在,看着像是个正经功能。

buttons_main 好一点,但也没好到哪去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void __fastcall __noreturn buttons_main(int a1, char **a2)
{
if ( a1 >= 2 )
gpio_num = atoi(a2[1]); // 从命令行取 GPIO 号
if ( !initGpio(gpio_num) )
{
while ( 1 )
{
sleep(0x3E8); // 1000 秒
sleep(0x3E8);
}
}
exit(0xFFFFFFFF);
}

initGpio 失败就直接 exit(-1);成功的话就进一个死循环 sleep,一次 1000 秒,两行就是 2000 秒。

这压根不是按键处理逻辑,就是个占位进程——活着,但不干活。

init_main 里也确实起了两次:

1
2
xstart("buttons", 0);      // 不带参数
xstart("buttons", "10"); // 带 GPIO 10

第一个因为 argc < 2,gpio_num 保持全局默认;第二个才是真正指定 GPIO 10 的那次。

后起的那次会覆盖……不对,两个进程是同时在跑的,各自 sleep 各自的。

两个占着名字不干活的进程,这就是固件迭代留下的残骸。

还有更有意思的:反过来也对不上。

sbin/ 里有三条软链接指向 rc,但 applet 表里根本没这几个名字:

1
2
3
4
$ ls -la sbin/ | grep -E "disconnected_pppoe|ovpn-route-up|rcheck"
lrwxrwxrwx 1 zlsf zlsf 2 Aug 3 2016 sbin/disconnected_pppoe -> rc
lrwxrwxrwx 1 zlsf zlsf 2 Aug 3 2016 sbin/ovpn-route-up -> rc
lrwxrwxrwx 1 zlsf zlsf 2 Aug 3 2016 sbin/rcheck -> rc

按 main 的逻辑,argv[0] 匹配不到任何项就走这条分支:

1
2
printf("Unknown applet: %s\n", v7);
return 0;

也就是说这三个入口目前是死的——能执行,但进去就打印一句 Unknown applet 然后退出。

大概是固件版本迭代留下的残骸:脚本里还在调 rcheck、ovpn-route-up,二进制里对应的 applet 早就被砍了。

这种”名字还在、实体没了”的地方,在固件里到处都是,看着像功能,其实早就不干活了。别一看到软链接就以为它有用。

真正能跑的只有表里那 29 项,翻漏洞的时候照着它挨个看,基本不会漏。


init_main:真正的启动流程

init 走的是第 0 项,也就是 init_main,函数不大,伪代码却能拉几千行。

挑关键的分段看。

第一段:挂文件系统、建目录

1
2
3
4
5
6
7
8
9
10
mount(0x41CEF8, "/proc", "proc", 0);   // 0x41CEF8 是个空串
mount(0x41CEF8, "/tmp", "ramfs", 0);
mount(0x41CEF8, "/sys", "sysfs", 0);
stime(&unk_41B428); // 先把时间设成一个固定值
umask(0);
mkdir("/var/lock", 0x1FF);
mkdir("/var/tmp/dhcp", 0x1FF);
mkdir("/home/root", 0x1C0);
chmod("/tmp", 0x1FF);
f_write("/etc/hosts", 0, 0, 0);

/proc、/tmp(ramfs)、/sys 三件套挂上,这是嵌入式 Linux 的标准开场。

stime() 把系统时间设死在一个固定值——因为路由器没 RTC,开机时时间一定是错的,只能等 NTP 或者上层来校。

注意这里 mount 的第一个参数传的是 0x41CEF8,我专门去查了这个地址:

1
0x41cef8 bytes: b'\x00\x00\x00\x00status /etc/'

是个空字符串。所以这一堆 mount 的 source 都是空的——参数用错了,但内核不在乎,照样能挂上。

第二段:解锁功能锁

1
2
3
4
simple_unlock("cron");
simple_unlock("firewall");
simple_unlock("restrictions");
simple_unlock("nvram_commit");

这四个词很直白:cron、firewall、restrictions、nvram_commit。

也就是说这些功能平时是被锁着的,有个 simple_lock 的机制,可能在 factory / 某些型号下不开放。开机先全部解锁。

这一段后面会用到——如果哪天发现某个功能行为诡异,先回来看看这个锁。

第三段:恢复 nvram 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flash_config_read("/tmp/wayos.parm");
if ( f_exists("/tmp/wayos.parm") )
{
v11 = system("nvram restore /tmp/wayos.parm --nocommit --force") >> 8;
if ( v11 != 0xA && v11 )
{
printf("\n-----------------restore parm error[%d]\n", v11);
system("nvram defaults --yes");
}
}
else
{
system("nvram defaults --initcheck");
}
jhl_init_nvram();

从 Flash 里读一份 wayos.parm 出来,能恢复就恢复,恢复失败就落回默认值。

wayos 这个词在固件里到处都是,libnvram.so、libshared.so、jhttpd、wys、pptp 里全有。

再加上 sbin/rc 里还藏着这么几条:

1
2
3
4
$ strings -a sbin/rc | grep -i wayos
ads/wayos_ad_api.php?config
/tmp/wayos.parm
nvram restore /tmp/wayos.parm --nocommit --force

wayos_ad_api.php、guanggao_name、guanggao_url、guanggao_getinnmachine——guanggao 是”广告”的拼音。

这设备有个广告模块。 前面 init_main 里那句 nvram_set("guanggao_state", "0") 和 reset_guangao_id() 就是给它用的。

广告模块?认真的?

对本篇来说它只是个名字来源——wayos.parm 就是它自己的配置文件。

第四段:加载内核模块

1
2
3
4
setup_conntrack();
modprobe("bridge");
modprobe("mii");
modprobe("raeth");

raeth 是 Ralink/MediaTek 的有线网卡驱动,bridge 是桥接,mii 是 PHY 管理。

后面还有更直接的:

1
2
3
system("/sbin/init_mdev.sh");
system("insmod lm");
system("insmod dwc_otg");

dwc_otg 是 USB OTG 控制器驱动,lm 是 Ralink 的那个硬件加速模块(前面 lib/modules/2.6.21/kernel/arch/mips/rt2880/lm.ko 对得上)。

init_mdev.sh 值得单独看一眼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mount -t sysfs sysfs /sys
mount -t ramfs mdev /dev
mount -t usbfs usbfs /proc/bus/usb
mkdir /dev/pts
mdev -s
mknod /dev/ram b 1 1
mknod /dev/spiS0 c 217 0
mknod /dev/i2cM0 c 218 0
mknod /dev/swnat0 c 210 0
mknod /dev/rdm0 c 253 0
mknod /dev/hwnat0 c 220 0
mknod /dev/acl0 c 230 0
mknod /dev/PCM c 233 0
mknod /dev/gpio c 252 0
mknod /dev/mtr0 c 250 0
mknod /dev/I2S c 234 0
mknod /dev/video0 c 81 0
mknod /dev/ac0 c 240 0
mknod /dev/flash0 c 200 0
mknod /dev/net/tun c 10 200

echo "/sbin/mdev" > /proc/sys/kernel/hotplug

一堆自定义设备节点,hwnat0(硬件 NAT)、swnat0(软件 NAT)、raeth 相关的全在这儿。

/dev/net/tun 也有——说明设备支持 VPN,后面 start_openvpn() 对得上。

第五段:建 PPP 钩子

1
2
3
4
5
6
7
8
if ( !f_exists("/etc/ppp/ip-up") )
{
mkdir("/etc/ppp", 0x1FF);
mkdir("/tmp/ppp", 0x1FF);
symlink("/sbin/rc", "/etc/ppp/ip-up");
symlink("/sbin/rc", "/etc/ppp/ip-down");
symlink("/dev/null", "/etc/ppp/connect-errors");
}

前面 applet 表里的 ip-up / ip-down 就是在这儿现造的。

pppd 拨号成功后会去执行 /etc/ppp/ip-up,一执行就落到 rc 上,argv[0] 是 ip-up,于是走 jhl_ipup_main。

一根软链接,把 PPP 事件接回了同一个程序。

第六段:清 nvram 里的脏状态

1
2
3
4
5
6
7
8
9
10
11
for ( v18 = 0; v18 < 0x40; ++v18 )   // 最多 64 个 WAN
{
nvram_unset(jhl_nvget("wan_down", v18));
nvram_unset(jhl_nvget("wan_increase", v18));
nvram_unset(jhl_nvget("wan_pppoebr", v18));
nvram_unset(jhl_nvget("redial_pid", v18));
nvram_set(jhl_nvget("wan_time_flag", v18), "0");
...
}
for ( j = 0; j < 0x64; ++j ) // 最多 100 个 VPN
nvram_unset(jhl_vpnget("vpnredial_pid", j));

紧接着调用的 jhl_init_nvram() 干的是同一件事,只是换了一批 key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int jhl_init_nvram()
{
for ( i = 0; i < 0x14; ++i ) // 20 个 VPN
{
nvram_unset(jhl_vpnget("vpn_link", i));
nvram_unset(jhl_vpnget("vpn_run", i));
nvram_unset(jhl_vpnget("vpn_run_wan", i));
}
for ( j = 0; j < 0x40; ++j ) // 64 个 WAN
{
nvram_unset(jhl_nvget("wan_oldip", j));
nvram_unset(jhl_nvget("wan_tbm", j));
}
nvram_unset("vpn_iptables_ip");
return 0;
}

开机把所有 WAN/VPN 的临时状态清掉,防止上次异常关机留下的 xxx_pid 骗到重启逻辑。

数一下这几个上限:WAN 是 0x40 = 64,VPN 是 0x64 = 100,但 jhl_init_nvram 里 VPN 只有 0x14 = 20。

三个数不一样,可能是因为不同模块的容量上限本来就不同,也可能只是历史遗留。这种对不上的地方值得记一笔,后面测多 WAN 并发的时候大概会踩到。

第七段:主循环

清完之后进入一个状态机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
while ( 1 )
{
switch ( dword_45E154 )
{
case 0:
case 1:
case 5:
case 6:
// 停服务 → 启服务
stop_services();
...
start_syslog();
start_vlan();
start_lan();
start_jhttpd(); // 这是 web 服务启动点
start_services();
start_dhcp_server();
start_openvpn();
...
puts("system init ok");
syslog(7, "system init ok\n");
case 4:
while ( dword_45E150 == 0xFFFFFFFF )
{
check_services(); // ← 看门狗
if ( ... )
pause();
else
v45 = sub_404508(0, 1);
}
...
}
}

状态机本身不复杂,重点是它把启动拆成了可重入的几段:停服务、起服务、监控,随时可以回退到某一段重新来。

dword_45E154 是当前状态,dword_45E150 是待处理的目标状态。

那外部怎么把值塞进 dword_45E150?靠信号。

init_main 里注册了两组 handler:

1
2
3
4
5
6
7
v7 = 7; v8 = &unk_45E170;          // 8 个信号 → sub_404978
do { --v7; signal(*v8++, sub_404978, v4); } while ( v7 >= 0 );

v9 = 5; v10 = &unk_45E158; // 5 个信号 → sub_4049D4
do { --v9; signal(*v10++, sub_4049D4, v4); } while ( v9 >= 0 );

signal(0x12, handle_reap, v4); // SIGCHLD → 收尸

把两个数组读出来:

1
2
unk_45E170 = { 4, 6, 8, 13, 10, 12, 5, 19 }   → sub_404978(崩溃类信号)
unk_45E158 = { 1, 16, 17, 2, 3 } → sub_4049D4(控制类信号)

sub_404978 更简单,一路转到 sub_4046F8(-1),也就是异常退出那条路。

真正的控制逻辑在 sub_4049D4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int __fastcall sub_4049D4(unsigned int sig)
{
if ( sig == 0xF || dword_45E154 == 4 )
{
switch ( sig )
{
case 1: dword_45E150 = 0; break; // SIGHUP → 0
case 2: dword_45E150 = 1; break; // SIGINT → 1
case 3: dword_45E150 = 6; break; // SIGQUIT → 6
case 0xF: dword_45E150 = 5; break; // SIGTERM → 5(停机)
case 0x10: dword_45E150 = 3; break; // SIGUSR1 → 3
case 0x11: dword_45E150 = 2; break; // SIGUSR2 → 2
default: return;
}
}
}

对照状态机那几段,语义就清楚了:

信号 号 目标状态 干什么
SIGHUP 1 0 停机重来
SIGINT 2 1 停服务
SIGQUIT 3 6 停机
SIGTERM 15 5 停机(killall 默认发的就是这个)
SIGUSR1 16 3 exec_service
SIGUSR2 17 2 重启服务(rc restart 走这条)

那 rc start / rc stop 是什么?就是第 1 项 applet,sub_404070:

1
2
3
4
5
6
7
8
9
int __fastcall sub_404070(int argc, char **argv)
{
if ( argc < 2 ) return 0;
v2 = argv[1];
if ( !strcmp(v2, "start") ) return kill(1, 0x11); // SIGUSR2 → 状态 2
if ( !strcmp(v2, "stop") ) return kill(1, 2); // SIGINT → 状态 1
if ( !strcmp(v2, "restart") ) return kill(1, 1); // SIGHUP → 状态 0
return 0;
}

整条控制链闭环了:

1
2
3
rc start   → kill(1, SIGUSR2) → dword_45E150 = 2 → 状态机重启服务
rc stop → kill(1, SIGINT) → dword_45E150 = 1 → 状态机停服务
rc restart → kill(1, SIGHUP) → dword_45E150 = 0 → 状态机全流程重来

也就是说,Web 界面改完配置,背后就是 system("rc restart") 或者类似的东西——发个信号,PID 1 自己就把服务重拉了一遍。

这就是这类路由器改完配置不用重启设备的原理。


服务是怎么被拉起来的

init_main 里那一长串 start_xxx() 是核心。

先把它们的真实实现摆出来(都是短函数,可以放心 F5):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int *start_jhttpd()
{
xstart("jhttpd", 0);
pid_jhttpd = 0xFFFFFFFE; // -2,表示"已启动但 PID 未确认"
return &pid_jhttpd;
}

int start_jhllog()
{
xstart("jhllog", 0);
pid_jhllog = 0xFFFFFFFE;
return &pid_jhllog;
}

int start_telnetd()
{
nvram_get("telnetd_port");
return xstart("telnetd", "-p");
}

int start_sched()
{
killall("sched", 0xF); // 先全杀掉
return xstart("sched", "start", 0);
}

0xFFFFFFFE 就是 -2,这个哨兵值意思是”启动了,但还没确认到 PID”,后面由 check_services 换成真实 PID。

注意 start_sched 和别的几个不一样——它先 killall 再起,杀的是 sched,信号 0xF 是 SIGTERM。

start_telnetd 更简单,看一眼 nvram 里的端口就直接起。端口是可控的,这点先记着。

start_cron 稍微绕一点,它拼的是 eval 参数表:

1
2
3
4
5
stop_cron();
v3 = off_45E30C; v4 = off_45E310; v5 = off_45E314; v6 = dword_45E318;
if ( nvram_contains_word("log_events", "crond") )
v1 = 0; // 命中就不带 -l 参数
eval(&v3, 0, 0, 0);

把那几个地址读出来:off_45E30C = "crond"、off_45E310 = "-l"、off_45E314 = "9"。

所以正常情况下执行的是 **crond -l 9**,日志级别拉满。

但 nvram 里 log_events 只要包含 crond 这个词,-l 参数就被置空,变成裸的 crond。

upnp 也一样带开关:

1
2
3
4
5
v0 = nvram_get("upnp_waniface");
v1 = J_atoi(v0);
if ( nvram_get("upnp_enable") && !strcmp(..., "1") )
if ( jhl_get_wan_proto(v1) )
xstart("upnp", "-D", "-L"); // 带 -D -L 起

upnp_enable 必须是字符串 "1" 才起,不是 "1" 就整个跳过。

这个模式在固件里反复出现——strcmp(nvram值, "1") == 0 才开功能。写 exp 或者做配置注入的时候,这个判断方式要记住。

xstart 是 libshared.so 里的导出函数,不在 rc 本体中。

IDA 里看它是 size=0x4,也就是个跳转桩,点进去自然是空的——这不是反编译失败,是它真没有函数体。

从用法能反推:给它一个程序名和可选参数,它 fork 出一个进程去跑。

服务本体在哪?都在 /usr/sbin/:

1
2
3
4
5
$ for f in usr/sbin/*; do [ -f "$f" ] && file -b "$f" | grep -q ELF && basename "$f"; done
fw_upgrade gg_apk_clt ip iptables jhllog jhlradius jhltmg jhttpd
module_upgrade openvpn oray pppd pppoecd pptp pptpctrl pptpd
qos_time sb_fbr_check sscan upnp usb_daemon version_upgrade
wj wys wysgg xl2tpd

jhttpd、wysgg、jhllog、wys、wj、gg_apk_clt——这几个都不是原厂的常规做法,wys 和 gg 系列尤其明显。

gg 是 guanggao(广告)的缩写,前面提到的那处对上了。


广告模块:wysgg

前面 check_services 里一直在监控 wysgg 这个进程,它的启动函数值得单独看一眼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int *start_wysgg()
{
v0 = nvram_get("guanggao_enable");
if ( v0 && !strcmp(v0, "1") )
{
xstart("wysgg", 0, v1); // ← 启广告进程
v3[0] = off_45E2DC; v3[1] = dword_45E2E0; eval(v3, 0,0,0);
v4[0] = off_45E2E4; v4[1] = off_45E2E8; v4[2] = dword_45E2EC;
eval(v4, 0,0,0);
pid_wysgg = 0xFFFFFFFE;
}
else
{
v5[0] = off_45E2C8; v5[1] = off_45E2CC;
v5[2] = off_45E2D0; v5[3] = dword_45E2D4; v5[4] = dword_45E2D8;
return eval(v5, 0,0,0); // ← 只执行关闭脚本
}
}

开关是 guanggao_enable,同样必须是字符串 "1"。

把那几个地址读出来,两条分支的实际命令是这样的:

1
2
3
4
5
6
7
启用分支:
wysgg_del.sh ← 先删掉旧的定时任务
wysgg_add.sh "0-6;00:00" ← 按时间段注册新的定时器
xstart("wysgg") ← 最后才起进程

关闭分支:
wys gg set 0 ← 把广告开关置 0

去翻 usr/sbin/wysgg_add.sh,参数是这么拆的:

1
2
3
4
5
6
7
TIME=$1
TWDAY=`echo "$TIME" | cut -f1 -d';'` # "0-6"
TMIN=`echo "$TIME" | cut -f2 -d';'` # "00:00"

timer_add.sh "$TWDAY" "$TMIN" "wys gg clear" "echo"
TIMERID=$?
echo "$NAME=$TIMERID" >>$CFGFILE # CFGFILE=/root/guanggao_tid.cfg

所以 0-6;00:00 拆出来是星期 0-6 加**时刻 00:00**。

参数含义去 timer_add.sh 里对一下就更清楚了:

1
2
3
4
5
6
#usage:
# timer_add.sh wday mtime infun outfun
WDAY=$1 # "0-6"
MTIME=$2 # "00:00"
INFUN=$3 # "wys gg clear"
OUTFUN=$4 # "echo"

wday 是星期,mtime 是时刻。所以它注册的是**每周 0 到 6 天、每天 00:00 执行一次 wys gg clear**。

说白了就是每天零点清一次广告状态,白天不折腾。

配套的 wysgg_del.sh 则是把 /root/guanggao_tid.cfg 里记的 TIMERID 挨个 timer_del.sh 删掉。

真正的”加规则”动作在 wys gg add 里,这两个脚本只管定时器的增删。

写到这里已经把”带广告的路由器”这四个字坐实了。后面如果挖 Web 相关漏洞,这个模块大概率有戏——它要联网拉素材,要写规则,攻击面比正常的 Web 管理界面大得多。


控制台接管:sub_404508

init_main 里有一句看着很可疑:

1
2
3
4
5
if ( !dword_45EE2C )
{
puts("\n\nHit ENTER for console...\n");
sub_404508(1, 0);
}

dword_45EE2C 是前面 sub_4042D0() 的返回值,而 sub_4042D0 干的事情是:

1
2
3
4
5
6
7
ioctl(0, 0x5471, 0);
close(0); close(1); close(2);
setsid();
v0 = open("/dev/console", 2);
dup2(v0, 0); dup2(v1, 1); dup2(v1, 2);
...
tcsetattr(0, 0x540E, v4); // 设终端属性

接管 /dev/console。 如果这一步失败(比如设备没接串口),dword_45EE2C 才会是 0,于是走上面那句提示。

然后 sub_404508 的实现更有意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
v12[0] |= 1u;
v5 = select(1, v12, 0, 0); // 等 stdin 有输入,也就是"按回车"
if ( v5 <= 0 ) return v6;

v7 = fork();
if ( !v7 )
{
for ( j = 0; j < 0x7F; ++j ) // 把 1~126 号信号全部恢复默认
signal(j, 0);
sub_4042D0(); // 再接管一次 console
execve("/bin/login", v13, off_45E1B0); // 先试 login
execve("/bin/sh", v14, off_45E1B0); // 失败就掉到 sh
perror("/bin/sh");
exit(*_errno_location());
}

只要有人在串口上敲个回车,就掉一个 /bin/login 或者 /bin/sh 下来。

这在物理接触的场景里是常规操作,但意义在于:它不需要密码就能先拿到 shell 的入口——login 起来了才输密码,而如果 login 本身有问题,就直接进 sh 了。

execve 第二个参数用的 off_45E190、off_45E19C 这些,是 argv 数组,也就是各种登录名。

本地取 shell 的门槛,就是”物理接触 + 串口 + 敲个回车”。


jhttpd 的 Web 根

jhttpd 就是 Web 服务器,strings 里能看到它的根目录:

1
2
3
4
5
6
$ strings -a usr/sbin/jhttpd | grep -E "^/"
/lib/ld-uClibc.so.0
/etc_ro/web ← Web 根目录
/tmp/wenwang/%s
/tmp/wxwifi/%s
/auth_pic/auth.

/etc_ro/web 是 Web 根,但前面 ls etc_ro/web 是空的——页面不在这里,大概是运行时从别处挂进来,或者在 rootfs_data 那种可写分区里。


看门狗:check_services

主循环里那个 check_services() 是最值得看的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int check_services()
{
v0 = nvram_get("jhttpd_stop");
if ( v0 && !strcmp(v0) )
{
// jhttpd_stop 为真 → 不重启 jhttpd
sub_40ABCC(&pid_wysgg, "wysgg", start_wysgg);
return sub_40ABCC(&pid_jhllog, "jhllog", start_jhllog);
}
else
{
sub_40ABCC(&pid_jhttpd, "jhttpd", start_jhttpd); // ← Web 服务
sub_40ABCC(&pid_wysgg, "wysgg", start_wysgg);
return sub_40ABCC(&pid_jhllog, "jhllog", start_jhllog);
}
}

再看 sub_40ABCC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int __fastcall sub_40ABCC(int *a1, int a2, int (*a3)(void))
{
result = *a1;
if ( *a1 != 0xFFFFFFFF ) // 不是 -1
{
result = kill(*a1, 0); // 信号 0:只探活,不真杀
if ( result ) // 探活失败 = 进程没了
{
result = pidof(a2);
*a1 = result; // 先试着用 pidof 找回来
if ( result == 0xFFFFFFFF ) // 找不到
return a3(); // 重新拉起
}
}
return result;
}

逻辑很干净:

  1. 用 kill(pid, 0) 探活,信号 0 不改进程状态,只检查存在性。
  2. 进程死了就 pidof 找一遍,可能是 fork 之后 PID 变了。
  3. 还找不到就调 start_xxx() 重新拉。

kill(pid, 0) 这个技巧比 ps | grep 可靠得多,也不会被同名进程骗到。嵌入式里到处都在用。

注意 jhttpd_stop 那个分支:只要 nvram 里 jhttpd_stop 被设成真,Web 服务就不会被拉起,也不会被看门狗救回来。

init_main 开头自己也把它清过一遍(nvram_set("jhttpd_stop", "0")),开机先确保 Web 是要起来的。


完整链路

理顺一下,从内核交棒开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
kernel  root=/dev/ram0  console=ttyS1,57600n8
│
└─ exec /sbin/init (= init 软链接 → sbin/rc)
│
├─ main() argv[0] basename = "init"
│ └─ 查 off_45E014 applet 表
│ └─ init_main()
│
├─ 挂 /proc /tmp /sys,建目录,simple_unlock() 解锁
├─ 读 /tmp/wayos.parm,恢复 nvram,失败则 nvram defaults
├─ jhl_init_nvram() 清 20 VPN / 64 WAN 的脏 key
├─ modprobe bridge / mii / raeth,insmod lm / dwc_otg
├─ /sbin/init_mdev.sh 建一堆设备节点,挂 hotplug
├─ 建 /etc/ppp/ip-up、ip-down → 软链接回 /sbin/rc
├─ 清 nvram 脏状态(64 WAN / 100 VPN)
├─ 注册信号:5 个控制信号 → sub_4049D4 → 改 dword_45E150
│
└─ 状态机 while(1) switch(dword_45E154)
├─ 0/1/5/6: stop_services()
│ clear_resolv / stop_sched / stop_upnp / stop_cron
│ stop_openvpn / stop_syslog
├─ start_syslog / start_jhllog / start_vlan / start_lan
├─ start_jhttpd() → usr/sbin/jhttpd (Web)
├─ start_services()
│ ├─ create_passwd()
│ ├─ start_telnetd() (看 nvram telnetd_eas)
│ ├─ start_cron() → crond -l 9
│ ├─ start_upnp() → 看 upnp_enable=="1"
│ ├─ sub_40AB08() → 看 rstats_enable
│ └─ start_sched() → killall + xstart sched start
├─ start_dhcp_server()
├─ start_openvpn() / pppoe_srv_start()
├─ start_wysgg() → 看 guanggao_enable=="1"
│ wysgg_del.sh → wysgg_add.sh 0-6;00:00 → xstart wysgg
├─ start_xxx() × 40 (QoS、DNS、防火墙、MAC 过滤、URL 过滤……)
└─ puts("system init ok")
│
└─ 4: while(1) check_services() ← kill(pid,0) 探活,挂了重拉
pause() ← 等信号

外部控制入口就这么几根:

1
2
3
4
rc start    → kill(1, SIGUSR2) → 状态 2 → 重启服务
rc stop → kill(1, SIGINT) → 状态 1 → 停服务
rc restart → kill(1, SIGHUP) → 状态 0 → 整条重来
killall xxx → SIGTERM → 状态 5 → 停机

再加上串口上敲回车 → sub_404508 → /bin/login 或 /bin/sh。


我去!路由器!其三
https://zlsf-zl.github.io/2026/11/08/我去-路由器-其三/
作者
ZLSF
发布于
2026年11月8日
许可协议