CVE-2023-34644复现
CVE-2023-34644
luci框架和lua文件
/etc/config/luci
通常是luci框架的配置文件,/usr/lib/lua/luci
通常是 LuCI 框架的核心文件所在的目录
Luci采用的是MVC的Web框架,即Model、View、Controller。
1 | /usr/lib/lua/luci/controller/ --控制层 |
未授权的漏洞,那么首先就要找到无鉴权的API
接口,定位到/usr/lib/lua/luci/controller/eweb/api.lua
文件。
1 | -- API集合 |
还有一些模块的定义,上述可以分为模块声明和路由定义,其中模块声明中
module("luci.controller.eweb.api", package.seeall)
:定义一个名为luci.controller.eweb.api
的模块,并导出所有函数。
路由定义中
entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false
:注册/api/auth
路径,调用rpc_auth
函数处理请求,并禁用系统认证。
根据其中调用的rpc_auth函数
1 | -- 认证模块 |
引入了必要的模块
1 | local jsonrpc = require "luci.utils.jsonrpc" |
jsonrpc
:用于处理 JSON-RPC 请求。http
:用于处理 HTTP 请求和响应。ltn12
:用于处理数据流。_tbl
:假设是一个包含无认证功能的模块(noauth
),用于处理实际的 JSON-RPC 方法。
因此定位到对应的处理文件/usr/lib/lua/luci/modules/noauth.lua
然后再看luci.utils.jsonrpc
1 | --[[ |
四个函数分别的作用是
1 | resolve(mod, method): ---解析模块 mod 中的方法 method。 |
1 | function handle(tbl, rawsource, ...) |
着重看中间部分:
- 使用
resolve
函数解析method,找到对应的 Lua 函数。 - 如果找到方法,使用
proxy
函数调用方法,并生成响应。proxy(method, json.params or {})
:调用方法并传递参数。reply(json.jsonrpc, json.id, ...)
:生成 JSON-RPC 响应。
- 如果未找到方法,生成错误响应,错误码
-32601
表示 “Method not found”。
代码细节:
resolve
函数用于解析方法名,找到对应的 Lua 函数。它将方法名按
.
分割成路径,然后逐级查找对应的表,最终找到方法。reply
函数用于生成 JSON-RPC 响应。它根据 JSON-RPC 版本和请求 ID 生成响应对象。
proxy
函数用于安全调用方法,并处理可能的错误。它使用
luci.util.copcall
捕获调用中的错误,并根据调用结果生成响应。
可以得知这里通过JSON
数据的method
字段定位并调用noauth.lua
中对应的函数,同时将Json
数据的params
字段内容作为参数传入
分析noauth.lua(寻找可疑漏洞的入口)
1 | module("luci.modules.noauth", package.seeall) |
向winmt师傅学习
在noauth.lua中,有login
,singleLogin
,merge
和checkNet
四个方法。其中,singleLogin
函数无可控制的参数,不用看;checkNet
函数中参数可控的字段只有params.host
,并拼接入了命令字符串执行,但是在之前有tool.checkIp(params.host)
对其的合法性进行了检查,无法绕过。
那么思考如果没有对host进行合法性检查有什么利用手段呢?
- 输入验证和注入攻击:
- SQL注入:如果非法地址未被正确验证并直接用于数据库查询,攻击者可能构造恶意输入来执行任意SQL代码。
- 例如:
params.host = "192.168.1.1'; DROP TABLE users; --"
- 例如:
- 命令注入:如果非法地址用于系统命令或脚本执行,攻击者可能注入恶意命令。
- 例如:
params.host = "192.168.1.1; rm -rf / --"
params.host = "192.168.1.1;rm -rf /*'#"
加个’让url闭合#注释掉之后的直到’
- 例如:
- SQL注入:如果非法地址未被正确验证并直接用于数据库查询,攻击者可能构造恶意输入来执行任意SQL代码。
- 跨站脚本攻击(XSS):
- 如果非法地址未被正确过滤并显示在网页中,攻击者可能插入恶意脚本,导致XSS攻击。
- 例如:
params.host = "<script>alert('XSS');</script>"
- 例如:
- 如果非法地址未被正确过滤并显示在网页中,攻击者可能插入恶意脚本,导致XSS攻击。
- 跨站请求伪造(CSRF):
- 如果非法地址用于生成或处理URL,攻击者可能构造恶意请求以执行CSRF攻击。
- 例如:
params.host = "example.com";
被替换为攻击者控制的域名。
- 例如:
- 如果非法地址用于生成或处理URL,攻击者可能构造恶意请求以执行CSRF攻击。
- 拒绝服务(DoS)攻击:
- 处理非法地址可能导致服务器资源耗尽,导致服务不可用。例如,处理一个非常长的字符串或复杂的正则表达式可能消耗大量CPU和内存资源。
- 信息泄露:
- 如果非法地址包含敏感信息且未被正确处理,可能导致信息泄露。
- 例如:
params.host = "192.168.1.1; echo $SECRET_KEY"
- 例如:
- 如果非法地址包含敏感信息且未被正确处理,可能导致信息泄露。
- 路径遍历攻击:
- 如果非法地址用于文件路径操作,攻击者可能利用路径遍历来访问或修改不应访问的文件。
- 例如:
params.host = "../../../../etc/passwd"
- 例如:
- 如果非法地址用于文件路径操作,攻击者可能利用路径遍历来访问或修改不应访问的文件。
这就是web吗太高级了woc
可惜对host字段进行了check
那么来看login函数,乍一看可以控制的字符十分的多,params.password,params.time,params.encry,params.limit
1 | -- 登录 |
可以看到
1 | if params.password and tool.includeXxs(params.password) then --password过滤危险字符,猜测之后有命令执行 |
这里过滤了
1 | function includeXxs(str) |
winmt师傅说少过滤了一个\n或许未来有命令注入的可能,类似;这个效果
1 | local authres, reason = tool.checkPasswd(checkStat) |
这边有个tool调用checkPasswd
1 | -- 检测密码是否正确 |
会发现encry字段和limit字段都变成了加密或者不加密,真或者假,好像变得不可控了
1 | local _check = cmd.devSta.get({module = "adminCheck", device = "pc", data = _data}) --传入的参数为json |
调用了cmd的devSta.get
1 | if opt[i] == "get" then |
它是首先会将params传入doParams解析,之后用fetch
那么来分析一下doParams函数
1 | local function doParams(params) |
而这里会把\n字符编码为\u000a,导致最后的漏洞点被补上
因此看最后一个merge函数
1 | -- 网络合并 |
可以看到没有对params有任何的处理,调用了set函数
1 | devSta['set'] = function(params) |
data字段便是我们可控的(即最初POST
报文中params
的内容)
主fetch函数的调用
1 | local function fetch(fn, shell, params, ...) |
其中的fn函数便是modle.fetch
1 | --主函数 |
可以看到其调用了libuflua的client_call函数
首先再libuflua.so中直接看是找不到client_call函数的,可能是因为该函数名称被识别为了代码段,因此放入010editor中,搜索字符串client_call,找到0xFF0位置,按A,
1 | LOAD:00000FF0 63 6C 69 65 6E 74 5F 63 61 6C+aClientCall:.ascii "client_call"<0> # DATA XREF: LOAD:off_1101C↓o |
寻找交叉调用
1 | int __fastcall luaopen_libuflua(int a1) |
这个函数使用了__fastcall
调用约定,在这种约定下,函数的前两个整数参数通过寄存器传递,其中luaL_register是一个Lua C API 函数,用于将 C 函数注册到 Lua 中。它通常用于将一组 C 函数注册为一个 Lua 库。
luaL_register
函数将 off_1101C
指向的函数库表注册到 Lua 中,并将其命名为 libuflua
1 | int __fastcall sub_A00(int a1) |
为了能顺利分析这个C函数,我们要先了解Lua栈是什么
Lua 栈是 Lua 虚拟机用来管理函数调用和数据传递的一个重要结构。它是一个后进先出(LIFO)的数据结构,专门用于在 C 和 Lua 之间传递数据。每个 Lua 状态机(
lua_State
)都有自己的栈,用于存储函数参数、返回值和临时变量。
- 压栈操作:
lua_pushnumber(lua_State* L, lua_Number n)
: 将一个数字压入栈中。lua_pushstring(lua_State* L, const char* s)
: 将一个字符串压入栈中。lua_pushboolean(lua_State* L, int b)
: 将一个布尔值压入栈中。lua_pushnil(lua_State* L)
: 将一个 nil 压入栈中。- 弹栈操作:
lua_tonumber(lua_State* L, int index)
: 将栈上指定索引处的值转换为数字。lua_tostring(lua_State* L, int index)
: 将栈上指定索引处的值转换为字符串。lua_toboolean(lua_State* L, int index)
: 将栈上指定索引处的值转换为布尔值。- 栈操作:
lua_settop(lua_State* L, int index)
: 设置栈顶索引。lua_gettop(lua_State* L)
: 获取栈顶索引。lua_remove(lua_State* L, int index)
: 移除栈上指定索引处的值。- 表操作:
lua_createtable(lua_State* L, int narr, int nrec)
: 创建一个新的表并压入栈中。lua_settable(lua_State* L, int index)
: 将栈顶的值弹出并存储在表中。lua_gettable(lua_State* L, int index)
: 获取表中的值并压入栈中。
但是可以直接理解为调用了v4 = uf_client_call(v3, v13, 0);
uf_client_call函数,因为本函数明显没有显示的漏洞点,因此就先跟进然后再返回来找
从这里跟平时的C库是一样的,说明这个函数是一个外部库定义的函数
搜索到显然是libunifyframe.so库
二进制文件分析
在分析之前附上zikh26师傅画的调用栈
uf_client_call
1 | int __fastcall uf_client_call(int a1, int a2, int a3) |
虽然这里有400行,但是或许有个思路便是从可控字段出发分析
1 | local stat = uf_call.client_call(ctype, cmd, module, param, back, ip, password, force, not_change_configId, multi) |
首先先大致分析一下每个参数是什么,ctype=2,cmd=’set’,module=”networkId_merge”,param=可控字段,只到back后面都是null
1 | case 2: |
因此v13=”devSta.set”,之后goto LABEL_22
1 | LABEL_22: |
method字段赋为’devSta.set’
1 | v20 = json_object_new_object(); |
会发现这个将a1当作地址,然后通过这个来赋值,问题就出现了,上网查了一下加上自己的猜测写下以下的理解
在lua文件中调用一个C库的函数,首先lua它的每一个协程都有自己的栈,称其为软栈,之后举个例子
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
// 共享库中的函数实现
static int my_function(lua_State *L) {
// 获取参数
int arg1 = luaL_checkinteger(L, 1);
const char *arg2 = luaL_checkstring(L, 2);
// 打印参数
printf("arg1: %d, arg2: %s\n", arg1, arg2);
// 返回结果
lua_pushstring(L, "result from C function");
return 1; // 返回一个结果
}
// 注册函数
static const struct luaL_Reg mylib[] = {
{"my_function", my_function},
{NULL, NULL}
};
// 打开库
int luaopen_mylib(lua_State *L) {
luaL_newlib(L, mylib);
return 1;
}会发现它传入的参数是一个指针,同时只有一个指针,然后之后会进行注册函数,两个NULL是哨兵,而参数的调用是用了luaL_checkinteger和luaL_checkstring函数,L是一个指向含有所有参数的内容的指针,猜测L是指向软栈,即lua栈上的内容,或者是C会将软栈上的内容copy到C的某块内存里并写下一个指针指向它,只能说形式不一定是正确的,但是效果是一定的
之后的分析就不会特别难,主要是看清if条件满不满足,大部分都是可以不用分析的
之后便分析出会发送形如{"method":"devSta.set", "params":{"module":"networkId_merge", "async":true, "data":"xxx"}}
补充一下基础的只是 what is socket?
Socket(套接字)是网络编程中用于描述计算机之间通信的端点。它提供了一种机制,使得应用程序可以通过网络传输数据。Socket 是操作系统提供的一种编程接口,用于网络通信。它可以在同一台计算机上的不同进程之间通信,也可以在不同计算机之间通信。
Socket 的类型
- 流式套接字(Stream Socket,SOCK_STREAM):
- 使用 TCP(传输控制协议)进行通信。
- 提供可靠的、面向连接的字节流服务。
- 适用于需要保证数据传输顺序和完整性的应用,例如 HTTP、FTP 等。
- 数据报套接字(Datagram Socket,SOCK_DGRAM):
- 使用 UDP(用户数据报协议)进行通信。
- 提供不可靠的、无连接的消息传递服务。
- 适用于对实时性要求较高、但对数据传输可靠性要求较低的应用,例如 DNS 查询、视频流等。
分析
1 | uf_socket_msg_write(v41, v40, v46); |
这里有一个uf_socket_msg_write,而winmt师傅猜测因为这里有write,v41是socket的标识符,v40又指向的是我们可控字段的指针,因此这里一定在进程有一个uf_socket_msg_read与它互相接收信息
再通过/etc/init.d/中也有一个unifyframe-sgi.elf的初始化文件
1 |
|
因此就此可以确认unifyframe-sgi.elf文件就是与本文件的write进行交流的文件,因此着重分析一下unifyframe-sgi.elf文件
从main函数分析起
unifyframe-sgi.elf
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
发现一样的长,一样的震撼
但是前面基本没啥用只看read之后的部分
1 | pthread_mutex_lock(sock + 5); // 上锁 |
read接收数据将数据放到addr上,之后
主要就是调用这个if
1 | if ( !parse_content((int)addr) ) |
第一个函数parse_content
parse_content
1 | int __fastcall parse_content(int a1) |
算是初步解析json数据,然后调用parse_obj2_cmd函数
parse_obj2_cmd
1 | int __fastcall parse_obj2_cmd(int json, int string_0) |
处理data的部分
1 | if ( json_object_object_get_ex(params, "data", ¶ms_module) == 1 |
这段代码从 JSON 对象 params
中提取 “data” 字段,并检查其类型是否为字符串、数组或对象。如果是字符串类型,则复制字符串并将其指针存储在指定的位置。如果内存分配失败,则跳转到错误处理代码。
json_type_null
(值为 0)json_type_boolean
(值为 1)json_type_double
(值为 2)json_type_int
(值为 3)json_type_object
(值为 4)json_type_array
(值为 5)json_type_string
(值为 6)
这边记住可控字段的偏移12
到了之后有一个uf_cmd_call
函数,乍一看感觉有命令执行点,因此跟进,寻找命令执行点
ufm_popen
1 | int __fastcall ufm_popen(const char *a1, _DWORD *a2) |
但是这个函数其实是执行不到的,让我娓娓道来
uf_cmd_call
1 | int __fastcall uf_cmd_call(int addr, int *a2) |
因此会直接跳到LABEL_86
1 | LABEL_86: |
跳到这里之后就跳过第一个if进入第二个if,然后执行goto LABEL_171; 和goto LABEL_172;
1 | LABEL_172: |
就会发现之后有一个命令执行点ufm_handle,让我们先分析分析该函数
1 | if ( !strcmp(v4, "set") || !strcmp(v4, "add") || !strcmp(v4, "del") || !strcmp(v4, "update") ) |
由于我们的是set调用所以调用sub_40FD5C函数,经过分析调用sub_40CEAC(a1, a1 + 22, 0, 0) 函数
1 | snprintf(&v63[v67], v65, " '%s'", data); |
主要的调用就是这两个,v63构成了shell
ufm_commit_add
1 | int __fastcall ufm_commit_add(int a1, int a2, unsigned __int8 a3, const char **a4) |
async_cmd_push_queue
1 | int __fastcall async_cmd_push_queue(_DWORD *a1, const char *a2, unsigned __int8 a3) |
这个函数的主要功能是将异步命令推送到队列中
详细分析
获取当前队列头指针:
1 | v20 = (_DWORD *)dword_435DE0; |
dword_435DE0
是一个全局变量,指向当前队列的头。将其值存储在v20
中。
设置新任务的 next
指针:
1 | *(_DWORD *)(v7 + 60) = &commit_task_head; |
v7
是新分配的任务结构的基地址。- 假设任务结构的第 60 字节处存储的是指向下一个任务的指针。
- 将这个位置初始化为
&commit_task_head
,表示新任务的下一个任务是队列头(初始为空)。
更新队列头指针:
1 | dword_435DE0 = v7 + 60; |
- 将全局队列头指针
dword_435DE0
更新为新任务的next
指针位置,即v7 + 60
。
保存当前队列长度:
1 | v21 = dword_4360A4; |
dword_4360A4
是当前队列的长度。将其值存储在v21
中。
更新新任务的 prev
指针:
1 | *(_DWORD *)(v7 + 64) = v20; |
- 假设任务结构的第 64 字节处存储的是指向前一个任务的指针。
- 将这个位置设置为之前的队列头,即
v20
。
更新之前头任务的 next
指针:
1 | *v20 = v7 + 60; |
- 将之前队列头任务的
next
指针更新为新任务的next
指针位置,即v7 + 60
。
更新队列长度:
1 | dword_4360A4 = v21 + 1; |
- 增加队列长度
dword_4360A4
的值。
设置任务状态:
1 | *(_BYTE *)(v7 + 32) = v3; |
- 假设任务结构的第 32 字节处存储的是任务状态。
- 将这个位置设置为参数
v3
的值。
初始化信号量(如果需要):
1 | if (!v3) |
- 如果
v3
为 0,则初始化任务结构的第 36 字节处的信号量。
解锁互斥锁:
1 | pthread_mutex_unlock(&unk_4360B8); |
- 解锁之前加的互斥锁
unk_4360B8
。
释放信号量:
1 | sem_post(&unk_4360A8); |
- 释放信号量
unk_4360A8
,表示有新任务加入队列。
返回新任务的指针:
1 | return v7; |
- 返回新分配的任务结构的基地址
v7
。
注意到最后使用sem_post
的原子操作,将信号量加上了1
。因此,应该会有其他地方在检测到信号量发生改变后,对数据进行处理。
意味着其他地方应该是有sem_wait阻塞了一个线程的执行
sem_wait
用于等待信号量。如果信号量的值为零,它会阻塞直到信号量的值大于零。sem_post
用于释放信号量,将信号量的值加一,并唤醒等待的线程(如果有)。- 主要是实现线程间的同步和互斥,确保程序安全运行
对其进行交叉引用发现
1 | void __fastcall __noreturn sub_41AFC8(int a1) |
从sub_41AFC8函数中找到他的sem_wait函数
1 | int __fastcall sub_41ADF0(_DWORD *a1) |
发现这里有个ufm_popen函数,而a1[7]显然是偏移28的位置
可以看到
1 | v19 = strdup(a2); |
在async_cmd_push_queue函数中早已将shell存到了当前的位置,因此执行该shell,且没有任何的过滤,到此二进制文件的分析就结束了,但是我还是有些许的疑问
POC
1 | { |
疑问&解决
为什么主机和qemu无法通信?
首先问题算是一种特殊情况,因为tap0分配了两个ipv4地址,导致冲突
1 | sudo ip addr show tap0 |
查看完后任意删除一个地址即可通信了,可能是重复配置的原因吧
结语
这个漏洞是我复现的第二个漏洞,收获很多,第一次感受到路由器的组成,不过还是有着许多的坎坷,感受到了winmt师傅的强大,只能说连复现都那么坎坷,那如何寻找漏洞呢,这次复现给了我一个思路便是从可控字段出发,然后再从shell执行返回,找到交汇点,看起来在iot上我还有很多的路要走
参考文章
https://zikh26.github.io/posts/e5651b4f.html