本文主要是针对于zyxel厂商的路由器的zhttpd程序进行漏洞分析,作者在挖掘zyxel设备漏洞时,翻到一篇较新的漏洞公告:
Zyxel security advisory for buffer overflow and post-authentication command injection vulnerabilities in some 4G LTE/5G NR CPE, DSL/Ethernet CPE, fiber ONTs, and WiFi extenders | Zyxel Networks
其中我关注到CVE-2024-9200这个漏洞,比较经典的ping功能产生命令注入,日期比较新,就决定简单地看看这个漏洞。然而网上搜不到该漏洞的详细信息,便自己去他们官网下载了一个新版固件来分析(型号vmg4005)。然而在自己分析的过程中,发现该漏洞调用链还是有一点点的复杂度的。
本文就是分享我分析该漏洞时遇到的一些问题和知识点,或许可以给挖掘zyxel设备或遇到zhttpd这个框架的师傅一些帮助。
1
固件仿真
可以用FirmAE直接进行模拟,启动后可能不知道管理员默认密码,FirmAE模拟时使用-d参数开启终端,使用passwd admin修改密码就可以登录web页面了。
这里值得一说的是,要分析的程序zhttpd,打开后函数是没有命名的,影响了分析,不过我在data段找到了一张表,容易看出里面存储着的就是url页面和对应的处理函数。
那么函数名这里就可以恢复了,使用ida python的脚本就行。
import idc
import idaapi
import idautils
def fix_function_names(start_addr, end_addr):
"""
从指定地址开始解析符号表,修复函数名。
"""
print(f"开始修复函数名,符号表起始地址: {hex(start_addr)},截止地址: {hex(end_addr)}")
offset = 0
while True:
# 计算当前符号表条目的地址
entry_addr = start_addr + offset
# 如果当前地址超出截止地址,停止解析
if entry_addr >= end_addr:
print("已到达符号表截止地址,停止解析。")
break
# 读取函数名字符串地址和函数地址
name_addr = idc.get_wide_dword(entry_addr) # 前4字节是函数名地址
func_addr = idc.get_wide_dword(entry_addr + 4) # 后4字节是函数地址
# 如果函数名地址或函数地址无效,跳过当前条目
if name_addr == 0 or func_addr == 0:
print(f"无效的符号表条目,地址: {hex(entry_addr)},跳过...")
offset += 8
continue
# 获取函数名字符串
func_name = idc.get_strlit_contents(name_addr, -1, idc.STRTYPE_C)
if func_name is None:
print(f"无法读取函数名字符串,地址: {hex(name_addr)},跳过...")
offset += 8
continue
# 将函数名解码为字符串,并替换非法字符
try:
func_name = func_name.decode('utf-8', errors='ignore')
# 替换非法字符为 _
illegal_chars = ['/', '-', '?', '=']
for char in illegal_chars:
func_name = func_name.replace(char, '_')
except Exception as e:
print(f"解码函数名失败,地址: {hex(name_addr)},错误: {e}")
offset += 8
continue
# 重命名函数
if idc.set_name(func_addr, func_name, idc.SN_NOWARN):
print(f"函数名已修复: {hex(func_addr)} -> {func_name}")
else:
print(f"无法重命名函数: {hex(func_addr)} -> {func_name}")
# 移动到下一个符号表条目
offset += 8
# 调用函数,指定符号表起始地址和截止地址
fix_function_names(0x573B8, 0x575E8)
2
漏洞分析
以下是从公告中获取的漏洞信息,知道了触发页面在诊断(diagnostic)中,参数就是host。
对应页面内容
点击按钮后抓包,发了三个包,第一个是put,但是参数被加密了,后面两个包是get类型,应该是获取结果。
上面的抓包获取了/cgi-bin/DAL?oid=PINGTEST这个关键信息,那么直接关键词在zhttpd中搜索即可,来到了cgi-bin_DAL这个函数。
(注:下文后面我贴的所有ida中看到的程序的代码,我都做了删改和注释,帮助快速看懂关键代码)
int __fastcall cgi_bin_DAL(int a1, unsigned __int8 a2)
{
RdmObjHandler = 0;
v34 = 0;
oid = 0;
method = 0;
oid = (char *)cg_http_vallist_getvalue((a1 + 672), (int)"oid");//获取oid的值,也就是抓包中看到的pingtest
method = (char *)cg_http_request_getmethod(a1);
v23 = json_object_new_object(v2);
if ( !strcmp(method, "POST") || !strcmp(method, "PUT") )
{
v24 = cg_string_getvalue((a1 + 4));//获取POST数据
v3 = cg_http_packet_getheaderlonglong(a1, "Content-Length");
v34 = (char *)json_tokener_parse(v24, HIDWORD(v3));
insertLoginUserInfo((int)v34, a1 + 680, a1 + 712);
RdmObjHandler = zcfgFeDalHandler((int)oid, (int)method, (int)v34, 0, (int)s);//根据oid值处理请求,具体实现在libzcfg_fe_dal.so中
}
}
zcfgFeDalHandler这个函数在libzcfg_fe_dal.so中,继续跟进代码。这里的全局变量dalHandler,跟进后可确认就是handler表,保存oid值和对应处理函数。
int __fastcall zcfgFeDalHandler(char *oid, const char *method, int a3, int a4, char *dest)
{
if ( !oid )
return 0;
v9 = 0;
v10 = 24;
while ( 1 )
{
v11 = *(&dalHandler + v10 * v9);
if ( !v11 )
{
v12 = 0;
goto LABEL_7;
}
v15 = v10;
if ( !strcmp(oid, v11) )
break;
v10 = v15;
++v9;
}
v12 = 1;
LABEL_7:
printf("handlerName=%s method=%s i=%dn", oid, method, v9);
v14 = 24 * v9;
if ( !j_parseValidateParameter(a3, method, *(&dalHandler + v14), *(&dalHandler + v14 + 4), dest) )
return -17;
if ( !*(&dalHandler + v14 + 8) )
return 0;
return (*(&dalHandler + v14 + 8))(method, a3, a4, dest);//根据oid的值在dalHandler表中匹配到的函数指针,调用对应的函数
}
表中pingtest对应如下,这里很容易判断出zcfgFeDalPingTest就是处理函数。
跟进zcfgFeDalPingTest,其中还有多处调用,这里就忽略了,直接来到最后一层关键函数zcfgFeDalIpDiagIPPingEdit。
int __fastcall zcfgFeDalIpDiagIPPingEdit(int a1)
{
v24 = 0;
string = json_object_object_get(a1, "ProtocolVersion");
v4 = string;
if ( json_object_object_get(a1, "Host") )
{
v7 = json_object_object_get(a1, "Host");
v6 = json_object_get_string(v7);
j_invalidCharFilter(v6, ')');
v5 = j_DomainFilter(v6) != 1 && j_IPFilter(v6) != 1 && j_IPv6Filter(v6) != 1;
}
if ( v5 )
{
puts("ninvalid input...");
return -5;
}
*v25 = 0;
*&v25[3] = 0;
if ( zcfgFeObjJsonGet(68592, v25, &v24) )
{
//...
}
v14 = v24;
if ( v24 )
{
v15 = json_object_new_string(v4);
json_object_object_add(v14, "ProtocolVersion", v15);
v16 = v24;
v17 = json_object_new_string(v6);
json_object_object_add(v16, "Host", v17);
v18 = v24;
v19 = json_object_new_string(v10);
json_object_object_add(v18, "DiagnosticsState", v19);
v20 = v24;
v21 = json_object_new_int(v13, 0);
json_object_object_add(v20, "NumberOfRepetitions", v21);
v22 = json_object_to_json_string(v24);
v5 = zcfgFeObjWrite(68592, v25, v22, 0x20000003, 0);//将数据写入68592标识的对象中
if ( v24 )
json_object_put(v24);
}
return v5;
}
其实到这里后,一开始,就会比较困惑,因为这就是最后一层调用,但是并没有发现ping命令执行的逻辑,仅仅做了把数据保存到json对象中,然后就啥也没干了。经过确认,其实关键函数在于这两行,他们的实现分别在libzcfg_fe_rdm_access.so和libzcfg_fe_rdm_string.so
zcfgFeObjJsonGet(68592, v25, &v24)
zcfgFeObjWrite(68592, v25, v22, 0x20000003, 0);
其实这里具体去追究他们的实现,是比较麻烦而且困难的,因为不仅抽象难懂,还反反复复涉及到了多个外部函数。可以直接猜到这里涉及到的事情。
观察他们的命名,反复出现的zcfg(Z-config)应该是个配置标识,而FE(Front End)往往指前端,那么可以推测,这里肯定是有对应的后端 (BE)的,前端把数据发送给后端,后端再进行处理。而这里的参数68592,很可能就是标识后端的某个数据对象。可能存在某种监控机制,当前端写入数据后,后端监控到该数据对象发生了改变,就进行相应的处理,比如执行ping命令。
而这两个函数也可以猜测出功能:
zcfgFeObjJsonGet(68592, ...):从后端获取与 `68592` 对应的对象数据。
zcfgFeObjWrite(68592, ...):将数据写入与 `68592` 对应的对象。
最后确实找到了进行后端处理的文件libzcfg_be.so,也找到了对应的执行ping命令的函数。
int __fastcall beIpPingDiagSet(int a1, int a2, int a3, int a4)
{
PartialObj = zcfgBeApplyObjRetrievePartialObj(a1);
if ( PartialObj )
{
v9 = json_object_object_get(PartialObj, "DiagnosticsState");
string = (const char *)json_object_get_string(v9);
v11 = strcmp(string, "Requested");
if ( v11 == 0 )
zcfgBeSaveParamValue(68592, a3, (int)"X_ZYXEL_Creator", (int)"ACS");
}
LABEL_19:
if ( !strcmp((const char *)a1, "Requested") )
{
system("killall ping 2>/dev/null");
zcfgBeSaveParamValue(68592, a3, (int)"DiagnosticsState", a1);
sprintf(s, "echo "" >%s", "/var/diagResult");
system(s);
memset(s, 0, 0x140u);
strcpy(s, "ping ");
v16 = &s[strlen(s)];
if ( strcmp((const char *)(a1 + 286), "IPv4") )
{
LABEL_33:
v19 = v16 + 2;
sprintf(
v19,
"-s %d -c %d -W %d %s >%s&",
(a1 + 568),
(a1 + 560),
v14,
(const char *)(a1 + 303), //host的值,即用户输入
"/var/diagResult");
system(s);
syslog(6, "Command %sn", s);
return 0;
}
}
return 0;
}
跟进到这,也就完成了漏洞分析了,这里分析的是新版固件,可以看到过程中,zcfgFeDalIpDiagIPPingEdit做了对host的过滤,修复了漏洞。
3
补充
zyxel的这种类型的洞,网上应该是搜不到什么的,所以这里分享出来希望能帮到后面看到文章的师傅。
处理数据的后端文件就是libzcfg_be.so,漏洞也基本都发生在这,要挖的话可以在这找到疑似危险函数,再往上回溯到对应接口验证。
前端不仅做了参数数据加密,还做了对危险字符的过滤,需要打开浏览器开发者模式,在element中的event listeners那一栏找到对应的事件,然后删掉,才能绕开前端过滤。
在逆向分析的时候,由于代码混乱,涉及到大量json数据的处理,而且根据不同的请求方法还有多个分支,所以实际分析的时候还是有很多干扰项的,明确put/post/delete/get四种请求方法的作用,抓包分析,还有一些审计伪代码的经验,都是必要的。
对于zhttpd本身,做了一下未授权接口的分析,核心逻辑在下面,可以看出,只要url包括/cgi-bin/,就需要验证身份,那么未授权接口只要不包含/cgi-bin/就行。
看雪ID:CLan_nad
https://bbs.kanxue.com/user-home-1014504.htm
#
原文始发于微信公众号(看雪学苑):zyxel路由器CVE-2024-9200漏洞调用链分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论