漏洞概要

受影响版本

  • 固件版本 ≤ 1.0.168

漏洞类型

  • 授权后命令注入漏洞(CWE-77)

风险等级

  • CVSS 3.1评分:8.8(High)
  • 攻击复杂度:低
  • 影响范围:机密性/完整性/可用性全破坏

漏洞机理

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

环境搭建

固件下载地址:固件下载

小米AX9000路由器固件是AArch64el架构的,我这里因为使用的mac的m芯片,就是arm64的,加上提前安装了ubuntu、arm的虚拟机,我这里 就直接在虚拟机中模拟起手,

解压后,直接文件夹内进行挂载,

1
2
3
mount --bind /proc proc
mount --bind /dev dev
chroot . /bin/sh

启动httpd服务

这里有三个http程序,uhttpd,mihttpd,sysapihttpd。查看/etc/sysapihttpd/sysapihttpd.conf发现就是nginx,并且监听了80端口,

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

这里我们直接启动sysapihttpd即可,/etc/init.d/sysapihttpd start

Failed to connect to ubus这里有报错这里是用到了ubus总线通信,我们需要启动/sbin/ubusd &再去start http程序,启动成功,且netstat查看web端口也正常对外开放,这里的nvram我们暂时用不到,就先不管,

访问一下,成功模拟,

因为我这里前面已经设置过密码了,所有让我们直接密码登录,

由于该漏洞是需要登录授权的,所以我们得看一下后端的密码校验是怎么写的,这里需要分析一下

身份校验的过程在/usr/lib/lua/luci/dispatcher.luajsonauth函数中,其中调用了checkUser函数根据从POST报文中获取的usernamepasswordnonce(现时)字段进行身份验证。

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

到这里我们清楚了后端的鉴权方式,我们去前端js里看一下是怎么构建登录的用户名和密码的,

这里可以看到固定的用户名就是admin,

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

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

1
2
3
uci set account.common.admin=b718c7808f4a1caac7dfab110f12865807cbf40b
uci commit

登录成功,token也有了,(后面exp里面会用到)

漏洞复现

在反编译的/usr/lib/lua/luci/controller/api/xqdatacenter.lua中,可以看到 URL /api/xqdatacenter/request 相关的handler函数是tunnelRequest函数,且这里只能通过admin用户请求访问,我们默认的账户就是admin,

默认是加密混淆过的lua,我们需要用到luadec_miwifi

接着分析tunnelRequest函数,看看进行了什么操作,

加载模块: 使用 require 导入了 xiaoqiang.util.XQCryptoUtil 和 luci.util 模块。 获取表单数据: 从表单中获取名为 payload 的数据。 加密数据: 使用 binaryBase64Enc 函数对获取的 payload 数据进行 Base64 编码。 生成通信数据: 获取 THRIFT_TUNNEL_TO_DATACENTER,并用编码后的数据 L1 进行某种运算(可能是对通信数据的处理)。 执行命令: 使用 luci.util.exec 执行带有加密数据的系统命令。 **关键在于此处用的是 ****formvalue_unsafe函数** 获取payload字段内容,未过滤危险字符,

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

这里 我们跟进用法发现THRIFT_TUNNEL_TO_DATACENTER所指代的命令为thrifttunnel 0 '%s'。因此,最终所执行的完整命令是thrifttunnel 0 'base64编码的payload字段',即payload字段中被Base64编码后的Json数据会被传入thrifttunnel程序中,且option0

接着我们去找一下thrifttunnel这个文件,最终在/usr/sbin/thriftunnel二进制文件中找到了,拖进IDA分析一下,

这里的a2就是我们前面base64编码的payload字段,其作为第一个参数被传入sub_1B9B0函数中,这里的v12此时是空字符串,

进入sub_1B9B0函数后,可以发现首先将与a1Base64编码的payload字段)相关的数据作为参数传入了sub_1F1F8函数处理,并最终将其返回结果通过string::assign()赋值给了a2(即上一级的v12变量)。

sub_1F1F8进去后根据大概逻辑,其实就是对我们最开始的值,进行了解码操作,

到这里我们再回到主函数,*(a2 + 8)即传入的第一个参数option0时,(就是在lua脚本中的参数)会进入sub_1BAE0函数,这里的v12可以值得就是我们上一步进行解码后的pyload字段,回传给了v12,

这里我们继续跟进sub_1BAE0函数看看做了什么操作,在sub_1BAE0函数中,大概是做了网络套接字进行交互,这里可以看到其实就是创建了socket,然后将数据发送到localhost的9090端口,那么v2其实就是我们上一步传进来的解码后的pyload字段,

这里由于我们的数据被传到了datacenter程序进一步处理,我们继续去看datacenter文件,这里的datacenter程序一直监听着9090端口,

constructAPIMappingTable 函数的作用:

**依次调用三个类的静态方法来构建不同类型的 API 映射表(存储 API、下载 API 和插件 API),函数返回 **datacenter::PluginApiCollection::sConstructMappingTable(a1) 的结果,其实就是constructAPIMappingTable()函数里分别执行了三个类的sConstructMappingTable()函数

其中,都是通过STL map建立起了api编号(下文解释)和对应的处理函数handler间的映射关系。具体来看,有一些api是直接在datacenter中被处理的,有些是被进一步转发到了/usr/sbin/indexservice9088端口)处理,另外一些则是被转发到了/usr/sbin/plugincenter9091端口)中进一步处理。

我们这里进入到函数中可以看到,当api为629的时候对应的方法就是callPluginCenter,那么就是给到这个函数了吧,我们接着去跟进一下,

callPluginCenter 是一个高层的封装函数,理数据的准备Thrift 客户端的通信功能以及处理返回数据。ThriftClient::sCallPluginCenter 是与插件中心进行实际通信的低层函数,负责建立连接、发送请求、接收响应并返回结果。

这里就是把我们的数据转发到了本地的9091端口上,

DataCenterHandler::request函数中,在调用APIMapping::APIMapping函数建立好上述的映射关系表后,紧接着调用了APIMapping::redirectRequest函数。其中,先获取了Json对象中的api字段的值,存放在v8变量中,然后经历了一个for循环,其中有对v8值的判断比较,最后执行了一个函数指针。

我们回到正题,前面知道了当api629时,传入的payload字段的数据会被转发给plugincenter程序处理。所以我们继续跟进,最后找到了/usr/sbin/plugincenter

在main中找到datacenter::PluginApiMappingExtendCollection::sConstructMappingTable函数,仍然是通过map建立了api编号和对应handler函数的映射关系。可以看到,当api编号为629的时候,会执行到parseGetIdForVendor函数进行处理。

parseGetIdForVendor函数中,会将传入的Json数据内的appid字段作为参数传递到PluginApi::getIdForVendor函数中。

PluginApi::getIdForVendor函数中,虽然有使用 AppAccountManager::IsValidAppId(&v11, a1) 来验证传入的 appida1)是否合法,但是,如果验证失败(IsValidAppId 返回 false)则会执行一系列处理:即使应用 ID 无效,代码仍会继续执行以下步骤:构造一个命令行字符串并通过 CommonUtils::sCallSystem 执行,那么很明显,这里存在命令注入。

Poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests

server_ip = "192.168.1.193"
token = "04e524882aa6f6a1f60b2c72c52ebcec"
exp = ";ps > 1.txt ;"

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

print(res.text)
```c++
import requests

server_ip = "192.168.1.193"
token = "04e524882aa6f6a1f60b2c72c52ebcec"
exp = ";ps > 1.txt ;"

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

print(res.text)

漏洞链

大概流程:

  1. 通过鉴权后的请求(需要admin权限的token)调用/api/xqdatacenter/request。
  2. payload中的JSON数据被Base64编码后作为参数传递给thrifttunnel命令。
  3. thrifttunnel解码数据并转发到datacenter的9090端口。
  4. datacenter根据api字段的值629将请求转发到plugincenter的9091端口。
  5. plugincenter中的parseGetIdForVendor函数处理appid参数时,未正确验证并在检查失败后继续执行命令,导致注入。