无线路由器挖洞方法大比拼:白盒 or 黑盒?
编译:奇安信代码卫士团队
去年,我们披露了两个认证绕过漏洞 ZDI-20-1176 (ZDI-CAN-10754) 和 ZDI-20-1451 (ZDI-CAN-11355),它们影响多款网件 (NETGEAR) 产品。这两个漏洞都位于 mini_httpd web服务器中。这些漏洞是由匿名研究员以及 1sd3d (Viettel Cyber Security) 分别发现的,它们的根因类似且相互之间距离非常近。然而,这两名研究员在两组不同的路由器中发现了这些漏洞且利用方式不同。鉴于此,对比两人发现并利用的不同方法是一件很有意思的事。
按照 GNU 通用公共许可证(GPL)的要求,网件公司已发布固件源代码。只要分析网件提供的固件的 GPL 发布即可直接理解这两个漏洞。本文分析了网件 R6 120 路由器的 GPL 固件版本 1.0.0.72。该版本可从网件网站获取。
从源代码可知,该 Web 服务器基于 mini_httpd open-source project.1.24 版本。这些漏洞位于网件固定的代码中,因此并不影响上游的开源 Web 服务器。
main () 函数位于 mini_http.c 中,用于设置 Berkeley 样式的套接字、SSL 和listen-loop。为处理并发的 HTTP 请求,当接收到 TCP 连接时,Web 服务器会 fork 自身,从而再子进程中单独处理每个连接。如下是对 GPL 固件源代码 mini_http 函数 main() 编辑后的版本:
558 int main(int argc, char **argv)
// ...
1095 /* Main loop. */
1096 for (;;)
1097 {
// ...
1149
1150 /* Accept the new connection. */
1151 sz = sizeof(usa);
1152 if (listen4_fd != -1 && FD_ISSET(listen4_fd, &lfdset))
1153 conn_fd = accept(listen4_fd, &usa.sa, &sz); // [ZDI] Accepting an IPv4 connection
1154 else if (listen6_fd != -1 && FD_ISSET(listen6_fd, &lfdset))
1155 conn_fd = accept(listen6_fd, &usa.sa, &sz); // [ZDI] Accepting an IPv6 connection
1156 else
// ...
1178
1179 /* Fork a sub-process to handle the connection. */
1180
// ...
1217 r = fork();
1218 if (r < 0)
1219 {
1220 #ifdef SYSLOG
1221 syslog(LOG_CRIT, "fork - %m");
1222 perror("fork");
1223 #endif
1224 exit(1);
1225 } else if (r == 0)
1226 {
1227 /* Child process. */
1228 client_addr = usa;
1229 if (listen4_fd != -1)
1230 (void)close(listen4_fd);
1231 if (listen6_fd != -1)
1232 (void)close(listen6_fd);
1233 SC_CFPRINTF("=====go to Handle_request!\n");
1234 //log_debug("=====go to Handle_request!\n");
1235 handle_request(); // [ZDI] forked child process proceeds to handle the connection in handle_request()
1236 #ifdef IP_ASSIGN_CHK
1237 /* after get the last file of warning_pg.htm, we can stop dnshj */
1238 if (access("/tmp/stop_conflict_warning", F_OK) == 0)
1239 {
1240 unlink("/tmp/lan_ip_auto_changed");
1241 unlink("/tmp/stop_conflict_warning");
1242 system("/usr/sbin/rc dnshj stop");
1243 }
1244 #endif
1245 exit(0);
1246 }
handle_request() 函数始于第1502行,之后接管并在 fork 后处理所有的 HTTP 处理:
1499 /* This runs in a child process, and exits when done, so cleanup is
1500 ** not needed.
1501 */
1502 static void handle_request(void)
1503 {
1504 char *method_str;
1505 char *line;
1506 char *cp;
1507 int r, file_len, i;
// ...
1530
1531 /* Initialize the request variables. */
1532 remoteuser = (char *)0;
1533 method = METHOD_UNKNOWN;
1534 path = (char *)0;
1535 file = (char *)0;
1536 pathinfo = (char *)0;
1537 query = "";
1538 protocol = (char *)0;
1539 status = 0;
1540 bytes = -1;
1541 req_hostname = (char *)0;
1542
1543 authorization = (char *)0;
1544 content_type = (char *)0;
1545 content_length = -1;
1546 cookie = (char *)0;
1547 host = (char *)0;
1548 if_modified_since = (time_t) - 1;
1549 referrer = "";
1550 useragent = "";
1551 #ifdef SC_BUILD
1552 accept_language = "";
1553 need_auth = 1; /* all of files need auth check by default */
1554 #endif
// ...
1607 /* Parse the first line of the request. */
1608 method_str = get_request_line();
1609 if (method_str == (char *)0)
1610 send_error(400, "Bad Request", "", "Can't parse request.");
1611 path = strpbrk(method_str, " \t\012\015");
// ...
1720 // qqq
1721 /* Follow Netgear request, if router just done factory reset, iphone should
1722 * show WiFi connection icon without redirect to browsers . Bollen_Chen*/
1723 if(host && (*nvram_safe_get("config_state") == 'b' || *nvram_safe_get("config_state") == 'c')
1724 && is_captive_detecting(host, useragent))
1725 {
1726 for_captive=1;
1727 protocol = strpbrk(path, " \t\012\015");
1728 send_error(200, "OK", "", "Success");
1729 }
1730
// ...
2093 /*No login required */
2094 if (*nvram_safe_get("config_state") == 'b' /*blank state */
2095 // || strstr(path,"BRS_top.html") /*Genie Wizard auto refresh timer*/
2096 // || strstr(path,"BRS_netgear_success.html") /*This page will link to NTGR page, should not require username/password.*/
2097 /*reboot after restore, stay in NEEDNOTAUTH state, but after timeout, require login */
2098 || (*nvram_safe_get("need_not_login") == '1'))
2099 {
2100 SC_CFPRINTF("Genie Wizard, set start_in_blankstate = 1\n");
2101 nvram_set("need_not_login", "0");
2102 nvram_set("start_in_blankstate", "1"); /*do not reset this value until timeout or log out */
2103 }
2104
2105 SC_CFPRINTF("path is <%s>, need_auth = %d\n", path, need_auth);
2106 if (path_exist(path, no_check_passwd_paths, method_str) || // [ZDI] ZDI-CAN-10754
2107 /* for "htpwd_recovery.cgi", POST should not auth, GET need auth */
2108 (strstr(path, "htpwd_recovery.cgi") && strcasecmp(method_str, get_method_str(METHOD_POST)) == 0)
2109 #ifdef PNPX
2110 || (strstr(path, "PNPX_GetShareFolderList")) // [ZDI] ZDI-CAN-11355
2111 #endif
2112 #ifdef SSO
2113 || ( *nvram_safe_get("config_state") == 'c' && strstr(path, "sso"))
2114 #endif
2115 )
2116 {
2117 need_auth = 0;
2118 /* for hi-jack page, should allow 2 user access at same time. */
2119 someone_in_use = 0;
2120 if (strstr(path, "currentsetting.htm") != NULL)
2121 {
2122 for_setupwizard = 1;
2123 }
2124 }
// ...
4443 static char *get_request_line(void)
4444 {
4445 int i;
4446 char c;
4447
4448 for (i = request_idx; request_idx < request_len; ++request_idx)
4449 {
4450 c = request[request_idx];
4451 if (c == '\012' || c == '\015')
4452 {
4453 request[request_idx] = '\0';
4454 ++request_idx;
4455 if (c == '\015' && request_idx < request_len && request[request_idx] == '\012')
4456 {
4457 request[request_idx] = '\0';
4458 ++request_idx;
4459 }
4460 return &(request[i]);
4461 }
4462 }
4463 return (char *)0;
4464 }
该函数首先初始化了一些变量,接着使用 helper 函数 get_request_line() 读入来自第1608行套接字的 HTTP 请求的请求行。Handle_request()函数随后使用 strpbrk() 函数将 HTTP 请求方法从请求行中分割。余下的请求行部分存储在第1611行代码的变量 path 中,该函数继续处理该请求路径及该请求。
从第2106行代码开始就变得耐人寻味了。多行条件if 语句首先检查该 path 是否匹配数组 no_check_passwd_paths 中的某个字符串,如第409行 path_exists() 中定义的那样(在 sc_util.c中定义)。If 语句还检查变量 path 中是否包含子字符串 “PNPX_GetShareFolderList”。如果满足其中某个条件,则变量 need_auth 设为0。Need_auth 变量做的并非完全如声称的那样。当该变量被设为0时,认证将被跳过。如下代码片段展示了 no_check_passwd_paths 字符串数组时如何定义的:
406 /* Ron */
407
408 /* Request variables. */
409 static char *no_check_passwd_paths[] = { "currentsetting.htm", "update_setting.htm",
410 "debuginfo.htm", "important_update.htm", "MNU_top.htm",
411 // "warning_pg.htm","debug.htm",
412 "warning_pg.htm", "POT.htm",
413 "multi_login.html", "401_recovery.htm", "401_access_denied.htm",
414 #ifdef SSO
415 "sso.html","sso_loading.html","BRS_sso_redirect.html","BRS_sso_hijack.html",
416 #endif
417 "BRS_netgear_success.html", "BRS_top.html", "BRS_miiicasa_success.html",
418 "tc_exist_unit_hijack.htm","BRS_data_detail.htm","BRS_full_tcn.htm","BRS_hijack_success.htm",
419 NULL
420 };
现在,目光如炬的读者应该已经发现了该漏洞。从 main() 到 handle_request(),该程序从未处理一种情况:请求参数是请求行的一部分的情况。如果攻击者发送具有请求参数的 HTTP 请求且其中包含 no_check_passwd_paths 数组中的任意字符串,那么攻击者能够满足第2106行定义的 if条件,从而绕过认证。
匿名研究员提供了一个简单的 PoC,演示漏洞ZDI-20-1176:
http://<router ip>/passwordrecovered.htm&next_file=update_setting.htm
该 PoC 可使攻击者在无需认证的情况下查看认证后页面 passwordrecovered.htm。仅需导航至浏览器中的上述地址即可测试该 PoC。
最后,这名研究员提供了另外一个 PoC,可导致攻击者查看路由器的管理员密码,获得对设备的完整控制权限。
对于 ZDI-20-1451而言,研究员 (1sd3d)注意到程序实际上并未解析 path 变量中的 HTTP 版本,而只要在请求中将 strstr() 添加到该 HTTP 版本末尾,strstr() 并满足第2110行定义的 if 条件就会匹配 “PNPX_GetShareFolderList”,从而绕过认证。
GET /passwordrecovered.htm HTTP/1.1PNPX_GetShareFolderList\r\n
随后,1sd3d 结合利用一个认证后命令注入漏洞ZDI-20-1423 (ZDI-CAN-11653)获得对设备的完全控制权限。
匿名研究员通过白盒代码审计方法找到了漏洞,而 1sd3d 通过黑盒方法,使用 Ghidra 及其反编译器进行逆向后找到了漏洞。我们可借此猜测他们为何以不同的方法利用漏洞并在不同的路由器系列中发现了这些漏洞。
ZDI-20-1451 易受攻击的代码封装在 #ifdef PNPX 预处理器程序指令中。如果通过白盒代码审计的方法,则难以了解 PNPX 指令是否在编译时间定义。易受攻击的代码很可能并未编译到最终固件中。实际上,该代码确实并没有编译到网件 R6 120 无线路由器的固件中。
因此,通过写脚本查看 ZDI-20-1176 的易受攻击源代码模式,是通过 GPL 版本源代码查找可利用固件的更可靠方法。因此很自然地,这名匿名研究员选择利用未封装到任何预处理器程序指令的 no_check_passwd_paths 数组,实施利用。
而黑盒的方式,我们所看到的就是 CPU 所看到的。然而,goto 语句、德·摩根定律以及缺少变量名称经常会遮蔽反编译代码中漏洞的逻辑。从研究员的反编译代码中可获知,ZDI-20-1451 确实是更容易被发现的漏洞。
非常具有唯一性的 “PNPX_GetShareFolderList” 字符串使得我们更容易在不同设备的固件中找到同样的漏洞。通过 strings 运行二进制并查找该字符串应该会获得足够的准确度。编写脚本,在反汇编程序中搜索 ZDI-20-1176 无疑要求具有某些脚本向导。
每种挖洞方法都有优势和劣势。在本案例中,它们的利用方法殊途同归。它说明没有一种方法具有绝对优势。然而,很可能只需一种方法就能够让你的下一次猎洞旅程走得更远。话虽如此,在长期来看,熟练掌握这两种方法只会让你受益良多。
在瞬息万变、推陈出新、时时刻刻都是最后期限的产品开发世界中,网件公司的开发人员本应把代码审计工作做得更好。代码后半部分中本地向量no_need_check_password_page 的声明以及 need_auth 变量并不会在代码中注入信心。好在,网件公司似乎在新产品和固件中已经开始远离这个充满技术负债的代码库。
思科决定将不修复路由器中的这70多个漏洞
D-Link 修复VPN路由器中的多个远程命令注入漏洞,还有一个未修复
https://www.zerodayinitiative.com/blog/2021/3/11/the-battle-between-white-box-and-black-box-bug-hunting-in-wireless-routers
题图:Pixabay License
本文由奇安信代码卫士编译,不代表奇安信观点。转载请注明“转自奇安信代码卫士 https://codesafe.qianxin.com”。
奇安信代码卫士 (codesafe)
国内首个专注于软件开发安全的
产品线。