首页
社区
课程
招聘
[原创]CVE-2023-38902的详细研究
发表于: 2024-2-3 14:17 17620

[原创]CVE-2023-38902的详细研究

2024-2-3 14:17
17620

前言

这是我收获的第一个 CVE 编号,在复现了 winmt 师傅的 CVE-2023-34644 后,他告诉我最新的固件虽然做了一些简单的处理,导致无法在未授权的情况下 RCE ,但因为没有从根源上对命令执行点做限制,所以在授权后,仍然可以进行 RCE 。我对最新的固件进行了分析,完整记录了授权后的 RCE 漏洞从分析到利用的过程。从提交漏洞到现在也有半年的时间了,并且某厂商官网也已经发布了最新的固件,现将该文章分享出来,供大家进行学习和研究。

PS:本文记录的部分内容和之前发布过的复现 CVE-2023-34644 文章中的部分内容有相似之处,因为对前期的 lua 文件分析基本一致。为了保证读任何一篇单独的文章都较为通顺和连贯,因此就保留了两篇文章中相似的部分。

仿真环境搭建

仿真环境搭建请参考 https://bbs.kanxue.com/thread-277386.htm#msg_header_h2_4

该文章详细记录了某厂商路由器的仿真过程

qemu 的启动脚本如下

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
sudo qemu-system-mipsel \
    -cpu 74Kf \
    -M malta \
    -kernel vmlinux-3.2.0-4-4kc-malta \
    -hda debian_squeeze_mipsel_standard.qcow2 \
    -append "root=/dev/sda1 console=tty0" \
    -net nic \
    -net tap,ifname=tap0,script=no,downscript=no \
    -nographic

其中的 vmlinux-3.2.0-4-4kc-malta debian_squeeze_mipsel_standard.qcow2 文件从74dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3k6h3!0H3L8r3g2Q4x3X3g2V1k6h3u0A6j5h3&6Q4x3X3g2G2M7X3N6Q4x3V1k6Q4y4@1g2S2N6i4u0W2L8o6x3J5i4K6u0r3M7h3g2E0N6g2)9J5c8X3#2A6M7s2y4W2L8q4)9J5c8R3`.`. 进行下载

在执行 qemu 启动脚本之前,执行下面的脚本,创建一个网桥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh
#sudo ifconfig eth0 down                 # 首先关闭宿主机网卡接口
sudo brctl addbr br0                     # 添加一座名为 br0 的网桥
sudo brctl addif br0 ens33                # 在 br0 中添加一个接口
sudo brctl stp br0 off                   # 如果只有一个网桥,则关闭生成树协议
sudo brctl setfd br0 1                   # 设置 br0 的转发延迟
sudo brctl sethello br0 1                # 设置 br0 的 hello 时间
sudo ifconfig br0 0.0.0.0 promisc up     # 启用 br0 接口
sudo ifconfig ens33 0.0.0.0 promisc up    # 启用网卡接口
sudo dhclient br0                        # 从 dhcp 服务器获得 br0 的 IP 地址
sudo brctl show br0                      # 查看虚拟网桥列表
sudo brctl showstp br0                   # 查看 br0 的各接口信息
sudo tunctl -t tap0 -u root              # 创建一个 tap0 接口,只允许 root 用户访问
sudo brctl addif br0 tap0                # 在虚拟网桥中增加一个 tap0 接口
sudo ifconfig tap0 0.0.0.0 promisc up    # 启用 tap0 接口
sudo brctl showstp br0

漏洞分析

lua文件调用链分析

新版本219调用链分析

usr/lib/lua/luci/modules/cmd.lua 文件中有如下代码,容易让初学者搞混,所以在此简单说明一下

1
2
3
4
5
6
7
8
9
10
11
12
13
local opt = {"add", "del", "update", "get", "set", "clear", 'doc'}
acConfig, devConfig, devSta, devCap = {}, {}, {}, {}
for i = 1, #opt do
......
    devSta[opt[i]] = function(params)
        local model = require "dev_sta"
        params.method = opt[i]
        params.cfg_cmd = "dev_sta"
        local data, back, ip, password, shell = doParams(params)
        return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
    end
......   
end

首先是先定义了一个表 opt 里面装了字符串 add del upload 等字符串,然后又定义了四张空表 acConfig devConfig devSta devCap ,接下来是一个 for 循环来遍历 opt 表。

devSta[opt[i]] = function(params) 这行代码为例,假设现在 opt[i] 是元素 addfunction(params) 这里是声明了一个匿名函数,因为函数也是一个变量,这个变量被直接存储到了 devSta 表中,以键值的形式存在,键就是字符串 add 而值就是这个函数,之后调用这个函数的话可以直接写 devSta["add"]()

1
2
3
4
5
6
7
8
function hello()
        local model = require "dev_sta"
        params.method = opt[i]
        params.cfg_cmd = "dev_sta"
        local data, back, ip, password, shell = doParams(params)
        return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
end
devSta["add"] = hello --假设此时遍历到了opt表中的add元素

为什么特别说明这里呢?因为我在开始分析的时候,我一直以为这里是匹配到对应的键值后直接去执行函数,导致在此处执行了 doParams fetch 函数(实际上通过上面的分析也知道,这里只是定义了这些函数,并没有进行调用)

下面开始正式从入口分析 /api/cmd 的这条链,在 /usr/lib/lua/luci/controller/eweb/api.lua 文件中存在 entry({"api", "cmd"}, call("rpc_cmd"), nil) 这行代码,意味着授权后访问 /api/cmd 路径时,可以调用 rpc_cmd 函数

1
2
3
4
5
6
7
8
function rpc_cmd()
    local jsonrpc = require "luci.utils.jsonrpc"
    local http = require "luci.http"
    local ltn12 = require "luci.ltn12"
    local _tbl = require "luci.modules.cmd"
    http.prepare_content("application/json")
    ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
end

通过分析 rpc_cmd 函数得知 _tbl 已经包含了 cmd.lua 中所有变量的定义(上文已经分析过了),主要是 ac_config dev_config dev_sta 这三个表包含了 add del update get set clear doc 这些操作,而 devCap 表只有 get ,相关代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
local opt = {"add", "del", "update", "get", "set", "clear", 'doc'}
acConfig, devConfig, devSta, devCap = {}, {}, {}, {}
 
for i = 1, #opt do
    acConfig[opt[i]] = function(params)
        local model = require "ac_config"
        params.method = opt[i]
        params.cfg_cmd = "ac_config"
        local data, back, ip, password, shell = doParams(params)
        return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
    end
 
    devConfig[opt[i]] = function(params)
        local model = require "dev_config"
        params.method = opt[i]
        params.cfg_cmd = "dev_config"
        local data, back, ip, password, shell = doParams(params)
        return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
    end
 
    devSta[opt[i]] = function(params)
        local model = require "dev_sta"
        params.method = opt[i]
        params.cfg_cmd = "dev_sta"
        local data, back, ip, password, shell = doParams(params)
        return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
    end
    if opt[i] == "get" then
        devCap[opt[i]] = function(params)
            local model = require "dev_cap"
            params.method = opt[i]
            params.cfg_cmd = "dev_cap"
            local data, back, ip, password, shell = doParams(params)
            return fetch(model.fetch, shell, params, opt[i], params.module, ip, password)
        end
    end
    if opt[i] == "doc" then
        syshell = function(params)
            local tool = require "luci.utils.tool"
            return tool.doc(params)
        end
    end
end

然后来看 rpc_cmd 函数中的这行代码 ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)

jsonrpc.handle 函数的参数是 _tbl ,看下 luci.utils.jsonrpc 文件中的 handle 函数,发现又将参数 tbl 传给了 resolve ,同时传入的还有报文中的 method 字段

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

resolve 函数主要是将 mod 表中存放键值对中的函数提取出来,假设 methoddevCap.get ,那么下面的代码最后可以将匿名函数 devCap["get"] 赋值给 mod 并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function resolve(mod, method)
    local path = luci.util.split(method, ".")
    for j = 1, #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 {}) 发现,将刚刚解析的返回值 methodproxy 函数当做参数,这里的 method 又传入了 luci.util 文件中的 copcall 函数

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

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

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

终于在 coxpcall 函数内部发现调用了 f

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

oldpcall(coroutine.create, f) 这行代码的目的是在一个新的协程中运行函数 f 。至此开始执行上面提到的匿名函数,重新回顾一下它的代码,该函数调用了 doParamsjson 数据进行解析,随后调用了 fetch 函数

1
2
3
4
5
6
devSta[opt[i]] = function(params)
    local model = require "dev_sta"
    params.method = opt[i]
    params.cfg_cmd = "dev_sta"
    local data, back, ip, password, shell = doParams(params)
    return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)

这个 fetch 函数在 cmd.lua 文件中已经定义了,这里调用了 fn 也就是 fetch 函数传入进来的第一个参数

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

fetch 函数的第一个参数为 model.fetchmodelrequire "dev_cap.lua" 后的结果,所以在 cmd.luafetch 函数内部调用了 dev_sta.lua 文件中定义的 fetch 函数,该函数定义如下,能够看到最后是调用了 /usr/lib/lua/libuflua.so 文件中的 client_call 函数

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

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

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

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

219版本之前的调用链

该调用链是 winmt 师傅在 CVE-2023-34644 利用的,在 219 之前该调用链可以通杀该厂商大部分设备。下面介绍的这条调用链所出示的代码均来自某型号的 204 版本。

/usr/lib/lua/luci/controller/eweb/api.lua 文件中,配置了路由 entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false

这意味着当用户访问 /api/auth 路径时,将调用 rpc_auth 。在 luci 框架中 sysauth 属性控制是否需要进行系统级的用户认证才能访问该路由,这里的 sysauth 属性为 false ,表示无需进行系统认证即可访问。

rpc_auth 函数首先引入了一些模块,然后获取 HTTP_CONTENT_LENGTH 的长度是否大于 1000 字节,如果不大于的话会将准备 HTTP 响应的类型设置为 application/json ,下面的 handle 函数第一个参数 _tbl 传入的是 luci.modules.noauth 文件返回的内容

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

到了 handle 函数内部后的流程与分析最新版的步骤一样,就不再赘述,最后的结果就是能在这里触发noauth 文件中的 merge 函数(前提是报文中要设置 method 字段的值为 merge

noauth 的文件中定义了 merge 函数


[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!

收藏
免费 13
支持
分享
最新回复 (3)
雪    币: 2575
活跃值: (502)
能力值: ( LV6,RANK:85 )
在线值:
发帖
回帖
粉丝
2
厉害的,学习
2024-2-3 15:51
0
雪    币: 3961
活跃值: (31421)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
感谢分享
2024-2-4 09:32
1
雪    币: 3037
活跃值: (3270)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
4
学习
2024-2-14 12:12
0
游客
登录 | 注册 方可回帖
返回