这是我目前复现的第二个漏洞,也挺难的,我觉得比第一次复现的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 - 博客园