CVE-2018-7034复现(TrendNet路由器登录信息泄露)
前言
该漏洞的利用是通过发送 POST 报文在正常字段 SERVICES=DEVICE.ACCOUNT 后紧跟了一个 AUTHORIZED_GROUP=1 (二者用 %0a 连接),导致 cgibin 文件解析时先识别到了第一个 = ,认为 SERVICES 是键,DEVICE.ACCOUNT%0aAUTHORIZED_GROUP=1 是值。随后又对字符串进行解码,%0a 处理为 \n,此时内存中字符串为 _POST_SERVICES=DEVICE.ACCOUNT\nAUTHORIZED_GROUP=1 ,后续在 cgibin 中认证失败,在此基础上添加了 AUTHORIZED_GROUP=-1 。

之后把整个数据发送给 php 文件进行处理, getcfg.php 文件中if($AUTHORIZED_GROUP < 0) 获取字段值时,首先解析到的是 AUTHORIZED_GROUP=1 从而通过了验证。又因为可以加载 htdocs/webinc/getcfg 目录下的任意文件,最终造成敏感文件的信息泄露
固件下载
由于平常复现漏洞的时候,师傅们一般都把固件上传到了文章中,因此每次复现的时候我都没有考虑过固件下载的问题。如果现在要我进行漏洞挖掘或者复现很新的漏洞,我确实没有把握一定能找到对应厂商发布的固件…😶🌫️,之后再复现漏洞我打算都把找固件的过程也记录一下(萌新确实是菜成这样的…)
本篇文章要复现 TrendNet 的漏洞,根据几次找固件的经验来看,应该在搜索引擎里搜 TrendNet support (因为也没人告诉过我这一点,全靠为数不多的经验猜的,不保证正确和严谨😥,至少这次是没问题的🥰)
根据 CVE-2018-7034 披露信息

可知 TRENDnet TEW-751DR v1.03B03 、TEW-752DRU v1.03B01 和 TEW733GR v1.03B01 设备存在认证绕过漏洞,通过使用 AUTHORIZED_GROUP=1 值,攻击者可以绕过认证,如通过请求 getcfg.php 进行信息泄露
我这里搜索的型号为 TEW-751DR ,尽管英语不太好,但是点进去看到 Firmware 和 Download 也知道是下载固件了


后续解压固件就不再提了,binwalk 直接解
PHP代码
htdocs/web/getcfg.php
HTTP/1.1 200 OK |
因为 php 我基本也不太懂,不过也能大概猜个意思, $_POST["SERVICES"] 是用户通过 POST 传参可控的,将这个可控的字段拼接到固定的路径里,然后检查这个文件是否存在,如果存在的话就进行加载
$GETCFG_SVC = cut($_POST["SERVICES"], $SERVICE_INDEX, ","); |
/htdocs/webinc/getcfg/ 路径下面正好有一个敏感文件是以 .xml.php 结尾,DEVICE.ACCOUNT.xml.php 该文件可以泄露用户名和密码等信息
foreach("/device/account/entry") |
所以能通过验证的话,将 SERVICES 设置为 DEVICE.ACCOUNT 就能泄露路由器的信息。if($AUTHORIZED_GROUP < 0) 这个可以通过伪造一个全局变量 $AUTHORIZED_GROUP 绕过验证。因为传入的数据都是先通过登录验证文件 htdocs/cgibin 的解析后,发送给 php 等文件进行处理,下面通过分析 cgibin 是如何设置 $AUTHORIZED_GROUP 字段,从而寻找一下利用的机会
二进制程序逆向
phpcgi_main函数分析
因为 phpcgi 软链接到了 cgibin 上,这里 *argv 取的就是 phpcgi ,所以调用了 phpcgi_main 函数
sobj_new 创建了一个结构体,用于存放之后解析出来的各个字段,sobj_add_string 函数是将 *(a2+4) 的字符串给添加到刚刚的结构体中,因为 a2 就是 main 函数的 argv ,所以 +4 就取到了第一个命令行参数(必须要给一个命令行参数,否则会进入 if(a1<2) 退出 )
最初的 qemu 用户级仿真的脚本是这样的(后续我也再根据报错和问题不断的调整)
qemu-mipsel-static -E CONTENT_TYPE="application/" -E REQUEST_METHOD="POST" -g 1234 ./phpcgi 123 |
下面是动态调试结构体的情况,可以看到 sobj_add_string 函数执行后确实把第一个命令行参数 123 给添加进去了。执行了 sobj_add_char 后又在 123 的后面添加了一个 \n
接下来是一个循环,a3 是 main 函数的 envp ,那么结合上面的分析不难猜测出这里是把所有的环境变量按照 _SERVER_+环境变量+\n 的形式添加到结构体里
调试界面可以看到确实把环境变量都添加到了这个结构体里…🤔,因为我没有用 chroot 来做调试的隔离环境,貌似把所有环境变量都遍历出来了?(emm,这里是我有意为之,我就是想看一下如果不做隔离环境,后面的分析和调试会不会出现问题)

于是乎,我重新来一次在隔离环境中的调试。我执行了命令 sudo chroot . ./qemu-mipsel-static -E REQUEST_METHOD="POST" -E CONTENT_TYPE="application/" -g 1234 ./phpcgi 123 ,但是报错 Error while loading ./phpcgi: No such file or directory ,有点奇怪,于是我直接回到了文件系统的根目录,执行命令 sudo chroot . ./qemu-mipsel-static -E REQUEST_METHOD="POST" -E CONTENT_TYPE="application/" -g 1234 ./usr/sbin/phpcgi 123 。这次一切正常,重新调试

……还是这么多环境变量,那看起来这个地方用不用 chroot 都是一样的。
总之呢,这里就确实是把我设置的环境变量给导到结构体里了

下面这部分代码先判断了 REQUEST_METHOD 的值,HEAD 或 GET 将 sub_405CF8 函数指针赋值给 v11 ,POST 则将 sub_405AC0 函数指针赋值给 v11
cgibin_parse_request函数分析
目光直接聚焦到 cgibin_parse_request 函数,它的第一个参数是放的函数指针,第二个参数是结构体。

这个 parse_uri 函数进去简单看了一下,貌似是把 REQUEST_URI 中的 ? 后面部分给存入了结构体里,本来我是没传这个环境变量的,但是我发现不传的话,就会返回 -1 (代码如下)
因此加上这个环境变量,继续重调,此时的启动脚本如下
sudo chroot . ./qemu-mipsel-static -E REQUEST_METHOD="POST" -E CONTENT_TYPE="application/" -E REQUEST_URI="wtf?COOL" -g 1234 ./usr/sbin/phpcgi 123 |
结果走了没几步发现又退出了,发现 CONTENT_TYPE 和 CONTENT_LENGTH 的值也必须存在

继续改改改,现在的脚本是这样
sudo chroot . ./qemu-mipsel-static -E REQUEST_METHOD="POST" -E CONTENT_TYPE="application/" -E CONTENT_LENGTH="26214" -E REQUEST_URI="wtf?COOL" -g 1234 ./usr/sbin/phpcgi 123 |
现在终于能来到这个位置了,想执行到第 38 行的指针,需要通过 strncasecmp 函数的检测

把目光看到 0x433014 的地址处(下图),这里很明显能看出来是三个内存单元组成了一个结构体,第一个成员是字符串常量,也就是 strncasecmp 比较时的 v14,第二个成员变量是 strncasecmp 函数的第三个参数 v12 ,如果通过了检查就执行第三个成员变量(一个函数指针),所以这部分就是在匹配 CONTENT_TYPE 中的类型来执行不同的函数

sub_40445C函数分析
当我进入 sub_40445C 函数后发现有个 strncasecmp 进行判断,通过动态调试我发现这个参数 a4 貌似没控制到(如果你好奇为什么我只进入字符串 application/ 对应的函数,其实只需要看一下另外几个函数就知道了,他们的返回值都是 -1 ,后续都直接从程序退出了)

感觉这里有一点奇怪,我就执行了 grep -r "application" ./ 命令,搜到了下面的内容,这让我猜测 qemu 启动时设置的环境变量 CONTENT_TYPE 是 application/x-www-form-urlencoded 而不是 application/ (WEB 常识没学好……)

继续重调…
sudo chroot . ./qemu-mipsel-static -E REQUEST_METHOD="POST" -E CONTENT_TYPE="application/x-www-form-urlencoded" -E CONTENT_LENGTH="26214" -E REQUEST_URI="wtf?COOL" -g 1234 ./usr/sbin/phpcgi 123 |
先分析一下这里的 select 函数,它的作用是监视多个文件描述符的状态,当有任意一个文件描述符准备就绪(可读或者可写),select 函数就会通知程序,从而避免轮询文件描述符。下面代码的 select 函数中的第一个参数 1 指的是要检查的最大文件描述符加一,因此这里只监视了文件描述符 0(标准输入流)。第二个参数放的是要监视的可读文件描述符集合, v21.__fds_bits[0] |= 1u 这里把第一个元素(文件描述符为 0 )设置为 1 ,这表示监视标准输入流中是否有数据。第三个参数和第四个参数都是 0,这表示没有要监视可写和异常的文件。第五个参数 timeout 则是该函数最长的等待时间。
v21.__fds_bits[0] |= 1u; |
我最初调试到这个 select 函数的时候,它会卡住五秒,然后返回一个 0(代表超时了),因为一旦这里返回 0 的话,后面就没办法走到 read 函数了(此处 read 函数读入的数据就是实际环境中 POST 报文的数据)。为了模拟真实的场景,我直接选择用 set $v0=1 逆天改命了一手,这样也确实能走到 read 函数。不过我后来重新思考了一波🤔, select 不就是没检测到标准输入流的数据么,可我压根还没输入数据怎么会检测到呢(我原先都是想着等 read 函数执行时,阻塞住等待输入再写入数据)
于是乎,我在 select 函数执行前就把数据写到了 qemu 这边(下图右部分),而后 select 函数执行后立刻返回了 1 (下图左部分)

这样就可以顺理执行到 read 函数,如下

注意上图中,我在启动 qemu 的时候,给的 CONTENT_LENGTH 值是 100 ,在下图中我标注了这段代码的执行流程,可见要想让 sub_40445C 函数返回 1 就得保证读入的数据长度和 CONTENT_LENGTH 一致,否则多跑一轮没有读到数据的话,就会返回 -1 (这里也卡住我了一段时间,好险…差点又放弃了😅)

如果 sub_40445C 返回 -1 的话, cgibin_parse_request 函数也返回 -1 ,这样会触发 cgibin_print_http_status(400, "unsupported HTTP request", "unsupported HTTP request") 函数,这明显是报错了🙃

因此 CONTENT_LENGTH 必须要严格和输入的数据长度一样(实际 POST 报文的发送,这个 CONTENT_LENGTH 也不需要我们去刻意控制,它自己就能取到正常的值,这里是本地搭建的环境,要刻意设置一下),cgibin_parse_request 函数返回 1 的话,就可以执行下面的内容

sess_validate 函数在验证用户的身份,如果没有通过验证返回 -1 ,这里本身就是未授权获取路由器的管理员用户名和密码,自然就没验证成功。于是乎 AUTHORIZED_GROUP=-1 就被添加到了结构体里
sprintf(v16, "AUTHORIZED_GROUP=%d", v13); |
在此之前,read 读入的数据也被解析到了结构体里,现在的结构体里存放的字符串是这样

后续调用了 xmldbc_ephp 函数,这是把上图中的字符串通过套接字传输,写到了 /var/run/xmldb_sock 文件中,然后其他程序再从该文件中读取数据,而 getcfg.php 文件在读取 $AUTHORIZED_GROUP 值的时候,先读取到了我们伪造的 AUTHORIZED_GROUP=1 继而绕过验证
为了不影响主线,上面并没有提到 _POST_SERVICES=DEVICE.ACCOUNT\nAUTHORIZED_GROUP=1 是何时被写入内存的,它具体靠 sub_403864 函数来实现
sub_403864函数分析
直接上图解,应该不难看懂
在看 sub_40445C 函数的时候,IDA 中 72 行的 sub_403864(&v16, (int)v22, v9); 调用时,我一直纳闷,sub_403864 函数明明直接就是个死循环 while ,只能 return ,如何执行到后面对字符串的解码和写入操作的。完整调试完才发现,不是在 72 行进行的解码和写入操作,而是在 80 行再次调用了 sub_403864 函数时进行的操作…🥲
sobj_unescape_uri函数分析
sobj_unescape_uri 函数看名字就是对 uri 进行解码的,这部分就是在扫描字符串中是否有 % ,如果有 % 的话,取它的下一位和下下一位,将其转换为一个十六进制数字,最后用 sobj_add_char 将这个字符添加回字符串中。比如传入的字符串是 abc%0a123 ,那么解码后则为 abc\n123
sub_405AC0函数分析
此处的 v9 就是在 phpcgi_main 函数中的那个函数指针 0x405AC0 ,
再看 sub_405AC0 函数,开始的 if(*a2) 是 0 ,直接看 else 部分(如下)

首先是往 a1 里面添加了字符串 _POST_ (这个 a1 是之前一直存放解析各种数据的结构体)
接着 sobj_add_string((_DWORD *)a1, v5) 向 a1 里面添加了 POST 内容(也就是 read 读入的数据)中 = 前面的部分(如下)

随后又向 a1 里写入了 = 和 = 后半部分以及 \n ,就不再赘述了。这个函数执行完毕后,就把 POST 报文的内容给按照一定格式解析写到了内存里

攻击效果

获取了用户名和密码,成功登录管理员后台

尾声
做这个漏洞复现的时候,感慨了好几次。好险…差一点又放弃了😅 因为网上的文章大多都千篇一律(除了 winmt 师傅😋他写的还是比较详细的),感觉就是拿 poc 打了一遍,然后过程写的省略就算了,说实话我还感觉有些驴唇不对马嘴…😶🌫️ 所以做的时候基本自己就遇见一个坑就分析一个坑,每个坑都是差点没爬出来(当时的想法是已经写的比其他人详细了,放弃这里的细节研究貌似也没啥)。好在没有得过且过,在每次想放弃的时候又坚持了一下,最终也是把自己有疑问的地方都分析清楚了