无线路由器挖洞方法大比拼:白盒 or 黑盒?

 聚焦源代码安全,网罗国内外最新资讯!

编译:奇安信代码卫士团队

ZDI 发布博客文章,比较了黑盒和白盒挖洞方法的优劣。如下是对文章的编译。

去年,我们披露了两个认证绕过漏洞 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 和利用

匿名研究员提供了一个简单的 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 变量并不会在代码中注入信心。好在,网件公司似乎在新产品和固件中已经开始远离这个充满技术负债的代码库。

推荐阅读

思科修复 SMB VPN 路由器中严重的代码执行漏洞

思科决定将不修复路由器中的这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)

国内首个专注于软件开发安全的

产品线。

    觉得不错,就点个 “在看” 或 "赞” 吧~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值