这是我目前复现的第二个漏洞,也挺难的,我觉得比第一次复现的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) |
function index()
函数中的部分代码如下:
local api = node("api") --定义一个名为 api 的节点 |
entry({"api", "auth"}, call("rpc_auth"), nil).sysauth = false
表示当用户访问/api/auth
路径时,会调用rpc_auth()
函数。sysauth
是LuCI框架中的一个属性,用于控制该路由是否需要系统认证,这里设置成了false表示不需要系统认证。
rpc_auth()函数:
function rpc_auth() |
这一部分会先对HTTP请求头中的Content_Length字段也就是请求体的大小进行检查,如果没有超过1000字节便会设置响应内容类型为application/json,然后处理JSON-RPC请求。ltn12.pump.all(jsonrpc.handle(_tbl, http.source()), http.write)
中http.source()
用于读取HTTP请求体中的数据,调用jsonrpc.handle()
同时传入参数_tbl
和http.source()
对数据进行处理,然后将处理后的响应数据写入HTTP响应流中。
因为jsonrpc.handle()
和_tbl
分别来自luci.utils.jsonrpc
和luci.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, ...) |
这段代码用于处理JSON-RPC请求,具体来说,它处理了请求中的method
字段,查找并调用对应的方法,并生成响应。具体过程如下,如果从HTTP请求体中读取的JSON数据解析成功,会检查JSON-RPC请求中的method字段(表示要调用的方法名)是否为字符串(string)。
由此暂可推断出数据报的格式如下:
{ |
json.method是字符串后,接着看resolve
函数,参数method
是数据报中的method字段,mod是传入的tbl
(api.lua
中的_tbl
),tbl
是一个表,表中内容为luci.modules.noauth
模块里的函数名。
function resolve(mod, method)--mod中有 singleLogin,login,merge,checkNet |
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) |
login()
接着分析login()
其中有params.password
、params.encry
和params.limit
三个可利用字段,不过也会有tool.includeXxs()
对params.password进行检查,然后会调用tool.checkPasswd(checkStat)
。
function login(params) |
定位到tool.includeXxs(),如下:
function includeXxs(str) |
可知,tool.includeXxs(params.password)
会对params.password
进行一些与命令执行有关的危险字符过滤,但没有过滤\n
这个命令分隔符。
继续定位到tool.checkPasswd(),如下:
function checkPasswd(checkStat) |
可知,在_data
表中type
和limit
会根据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'} |
在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 |
merge()
这个函数很简单,就调用了cmd.devSta.set()
,且整个merge.params
(json.params
)参数都可控制也无任何过滤。
-- 网络合并 |
cmd.devSta.set()
中,doParams()
执行完得到的data是json.params.data
,接着会执行fetch()
。
devSta[opt[i]] = function(params) |
接着看cmd.fetch(),这个函数会调用model.fetch
(dev_sta.fetch()
)函数也就是传入其自身的第一个参数,而参数就是自身参数中的除前三个以外的参数,其中有可控data字段。
-- return fetch(model.fetch, shell, params, opt[i], params.module, data, back, ip, password) |
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) |
libuflua.so文件
libuflua.so是一个二进制文件,用IDA打开直接查找client_call函数,没有找到,有uf_client_call
函数,
shift+f12
搜索字符串,也只有uf_client_call

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

然后在IDA中定位到0xff0
地址,选中左边的地址,然后按A,就能以字符串的形式呈现了,如下
、
选中client_call
进行交叉引用跳到如下之处
通过DATA XREF
数据交叉引用提示可以知道,该数据从luaopen_libuflua
函数的起始地址偏移2C
字节处被引用,定位到该函数如下
这段代码的作用是将扩展库libuflua
注册到Lua环境中
在文件系统中搜索uf_client_call
字符串,结合出现过其的二进制文件和libuflua.so
文件所依赖的共享库,可以发现uf_client_call
函数定义在/usr/lib/libunifyframe.so
中

libunifyframe.so文件
用IDA打开libunifyframe.so
文件,对uf_client_call
函数进行分析,可知传进来的各个字段会被解析成JSON字符串,作为键值与自定义的键以键值对的形式添加到JSON对象中,然后JSON对象会被转换成JSON格式的字符串,通过uf_socket_msg_write
用socket
套接字进行数据传输。
既然这里采用
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
总线进行进程间通信)。

分析二进制文件
在unifyframe-sgi.elf文件中定位到uf_socket_msg_read
函数开始分析。uf_socket_msg_read(*v29, v31 + 1);
中的两个参数第一个是文件描述符(ida分析libunifyframe.so文件),第二个是接收数据存储的位置(这个要动态调试对比uf_socket_msg_read函数执行前后a2寄存器存储的地址里的内容)
之后解析字段、执行具体操作的两个函数分别为 parse_content
和add_pkg_cmd2_task
,如下:

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

parse_obj2_cmd
函数结束后,会执行 pkg_add_cmd(a1, v16)
,它的核心作用就是在 a1
这个数据结构中记录了 v16
的指针,使得后续操作通过 a1
访问到刚刚解析出来的各个字段。
在ufm_handle
函数中,由于我们是set
方式,因此会调用到sub_410140
函数。进入sub_410140
函数,首先sn
字段为空的条件满足,跳转到LABEL_36
,LABEL_36
处会调用到sub_40DA38
函数。
v6 = json_object_object_get(a1[22], "sn"); |
在sub_40DA38
函数中,定位到下面这一处,v5
和v6
分别是a3
和a4
,因为传入的值均为零,所以会进入else
分支,这里会将data
字段的内容拼接到两个单引号内。此处v4
字符串形如/usr/sbin/module_call set networkId_merge 'xxx'
,很显然是一个命令,并且单引号内的内容我们可控,所以我们只需要左右分别闭合单引号,中间注入恶意命令,并用分隔符隔开即可完成命令注入。
LODWORD(v5) = a3; |
接着,由之前的分析,此处v7
偏移8
的位置为0
(async
不是false
),故进入else
分支,其中会将v4
传入ufm_commit_add
函数,作为第二个参数,然后继续进入async_cmd_push_queue
函数。此处,a1
为0
,将a2
存入v4
偏移6\*8
字节处,然后跳转到LABEL_34
的位置。
if ( !a1 ) |
在LABEL_34
处,会释放一个信号量,信号量的地址是&unk_4360A8
,对该地址进行交叉引用可以定位到sub_41AFC8
函数,在该函数中会调用一个sub_41ADF0
函数。

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

poc:
{ |
仿真模拟
net.sh
!/bin/sh |
start.sh(qemu启动脚本)
!/bin/bash |
首先将文件系统传到模拟器中
scp ./squashfs-root.tar.gz root@192.168.107.135:/root/ |
设置squashfs-root作为qemu模拟器的根目录(exit 退出)
仿真系统只是切换了根目录,本质还是
qemu
虚拟机的系统,故proc
和dev
这两个重要的系统目录仍应该是这个系统本身的目录,即qemu
虚拟机的系统目录,而切换了根目录后,proc
和dev
也被切换,因此需要挂载为原先的目录
cd squashfs-root |
在进行下一步前,先了解一下路由器根文件系统下的一些重点目录:
目录 | 存储的文件 |
---|---|
bin 、sbin 目录,/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 |
接着,真实系统会根据
/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/
目录(该目录由自己创建)下即可。
攻击演示
gdbserver调试
这里是借助gdbserver通过网络进行远程调试。把下载的对应版本的gdbserver传到qemu模拟器中,在qemu中启动程序并使用gdbserver监听一个端口,然后在宿主机上远程连接即可。
直接启动程序调试 |
报错:
pwndbg> target remote 192.168.207.135:1234 |
查了一下是因为仿真环境的内核版本与gdbserver版本不匹配的问题,在启动仿真环境的时候设置-kernel
参数为vmlinux-3.2.0-4-4kc-malta
版本即可。
这个问题困扰了我不少时间,想到一开始搭建仿真环境的时候,图省事就没有重新下载内核文件用的还是上一个复现DIR815时的内核,没想到啊却因此费了很多时间和精力😅
参考文章
gdbserver 指南 - JiMoKuangXiangQu - 博客园