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

image-20231027191647900

之后把整个数据发送给 php 文件进行处理, getcfg.php 文件中if($AUTHORIZED_GROUP < 0) 获取字段值时,首先解析到的是 AUTHORIZED_GROUP=1 从而通过了验证。又因为可以加载 htdocs/webinc/getcfg 目录下的任意文件,最终造成敏感文件的信息泄露

固件下载

由于平常复现漏洞的时候,师傅们一般都把固件上传到了文章中,因此每次复现的时候我都没有考虑过固件下载的问题。如果现在要我进行漏洞挖掘或者复现很新的漏洞,我确实没有把握一定能找到对应厂商发布的固件…😶‍🌫️,之后再复现漏洞我打算都把找固件的过程也记录一下(萌新确实是菜成这样的…)

本篇文章要复现 TrendNet 的漏洞,根据几次找固件的经验来看,应该在搜索引擎里搜 TrendNet support (因为也没人告诉过我这一点,全靠为数不多的经验猜的,不保证正确和严谨😥,至少这次是没问题的🥰)

image-20231026101220172

根据 CVE-2018-7034 披露信息

image-20231026101606523

可知 TRENDnet TEW-751DR v1.03B03TEW-752DRU v1.03B01TEW733GR v1.03B01 设备存在认证绕过漏洞,通过使用 AUTHORIZED_GROUP=1 值,攻击者可以绕过认证,如通过请求 getcfg.php 进行信息泄露

我这里搜索的型号为 TEW-751DR ,尽管英语不太好,但是点进去看到 FirmwareDownload 也知道是下载固件了

image-20231026101730807

image-20231026101830688

后续解压固件就不再提了,binwalk 直接解

PHP代码

htdocs/web/getcfg.php

HTTP/1.1 200 OK
Content-Type: text/xml

<?echo "<?";?>xml version="1.0" encoding="utf-8"<?echo "?>";?>
<postxml>
<? include "/htdocs/phplib/trace.php";

if ($_POST["CACHE"] == "true")
{
echo dump(1, "/runtime/session/".$SESSION_UID."/postxml");
}
else
{
if($AUTHORIZED_GROUP < 0)
{
/* not a power user, return error message */
echo "\t<result>FAILED</result>\n";
echo "\t<message>Not authorized</message>\n";
}
else
{
/* cut_count() will return 0 when no or only one token. */
$SERVICE_COUNT = cut_count($_POST["SERVICES"], ",");
TRACE_debug("GETCFG: got ".$SERVICE_COUNT." service(s): ".$_POST["SERVICES"]);
$SERVICE_INDEX = 0;
while ($SERVICE_INDEX < $SERVICE_COUNT)
{
$GETCFG_SVC = cut($_POST["SERVICES"], $SERVICE_INDEX, ",");
TRACE_debug("GETCFG: serivce[".$SERVICE_INDEX."] = ".$GETCFG_SVC);
if ($GETCFG_SVC!="")
{
$file = "/htdocs/webinc/getcfg/".$GETCFG_SVC.".xml.php";
/* GETCFG_SVC will be passed to the child process. */
if (isfile($file)=="1") dophp("load", $file);
}
$SERVICE_INDEX++;
}
}
}
?></postxml>

因为 php 我基本也不太懂,不过也能大概猜个意思, $_POST["SERVICES"] 是用户通过 POST 传参可控的,将这个可控的字段拼接到固定的路径里,然后检查这个文件是否存在,如果存在的话就进行加载

$GETCFG_SVC = cut($_POST["SERVICES"], $SERVICE_INDEX, ",");			
$file = "/htdocs/webinc/getcfg/".$GETCFG_SVC.".xml.php";
if (isfile($file)=="1") dophp("load", $file);

/htdocs/webinc/getcfg/ 路径下面正好有一个敏感文件是以 .xml.php 结尾,DEVICE.ACCOUNT.xml.php 该文件可以泄露用户名和密码等信息

foreach("/device/account/entry")
{
if ($InDeX > $cnt) break;
echo "\t\t\t<entry>\n";
echo "\t\t\t\t<uid>". get("x","uid"). "</uid>\n";
echo "\t\t\t\t<name>". get("x","name"). "</name>\n";
echo "\t\t\t\t<usrid>". get("x","usrid"). "</usrid>\n";
echo "\t\t\t\t<password>". get("x","password")."</password>\n";
echo "\t\t\t\t<group>". get("x", "group"). "</group>\n";
echo "\t\t\t\t<description>".get("x","description")."</description>\n";
echo "\t\t\t</entry>\n";
}

所以能通过验证的话,将 SERVICES 设置为 DEVICE.ACCOUNT 就能泄露路由器的信息。if($AUTHORIZED_GROUP < 0) 这个可以通过伪造一个全局变量 $AUTHORIZED_GROUP 绕过验证。因为传入的数据都是先通过登录验证文件 htdocs/cgibin 的解析后,发送给 php 等文件进行处理,下面通过分析 cgibin 是如何设置 $AUTHORIZED_GROUP 字段,从而寻找一下利用的机会

二进制程序逆向

phpcgi_main函数分析

因为 phpcgi 软链接到了 cgibin 上,这里 *argv 取的就是 phpcgi ,所以调用了 phpcgi_main 函数

image-20231026133236156

sobj_new 创建了一个结构体,用于存放之后解析出来的各个字段,sobj_add_string 函数是将 *(a2+4) 的字符串给添加到刚刚的结构体中,因为 a2 就是 main 函数的 argv ,所以 +4 就取到了第一个命令行参数(必须要给一个命令行参数,否则会进入 if(a1<2) 退出 )

image-20231026133346499

最初的 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

image-20231026134500357

接下来是一个循环,a3main 函数的 envp ,那么结合上面的分析不难猜测出这里是把所有的环境变量按照 _SERVER_+环境变量+\n 的形式添加到结构体里

image-20231026135320293

调试界面可以看到确实把环境变量都添加到了这个结构体里…🤔,因为我没有用 chroot 来做调试的隔离环境,貌似把所有环境变量都遍历出来了?(emm,这里是我有意为之,我就是想看一下如果不做隔离环境,后面的分析和调试会不会出现问题)

image-20231026135935844

于是乎,我重新来一次在隔离环境中的调试。我执行了命令 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 都是一样的。

总之呢,这里就确实是把我设置的环境变量给导到结构体里了

image-20231026142111731

下面这部分代码先判断了 REQUEST_METHOD 的值,HEADGETsub_405CF8 函数指针赋值给 v11POST 则将 sub_405AC0 函数指针赋值给 v11

image-20231026142257022

cgibin_parse_request函数分析

目光直接聚焦到 cgibin_parse_request 函数,它的第一个参数是放的函数指针,第二个参数是结构体。

image-20231026143301719

这个 parse_uri 函数进去简单看了一下,貌似是把 REQUEST_URI 中的 ? 后面部分给存入了结构体里,本来我是没传这个环境变量的,但是我发现不传的话,就会返回 -1 (代码如下)

image-20231026144301176

因此加上这个环境变量,继续重调,此时的启动脚本如下

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_TYPECONTENT_LENGTH 的值也必须存在

image-20231026144935033

继续改改改,现在的脚本是这样

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 函数的检测

image-20231026150055286

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

image-20231026150428111

sub_40445C函数分析

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

image-20231026151634927

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

image-20231026152006138

继续重调…

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;
timeout.tv_usec = 0;
timeout.tv_sec = 5;
v7 = select(1, &v21, 0, 0, &timeout);

我最初调试到这个 select 函数的时候,它会卡住五秒,然后返回一个 0(代表超时了),因为一旦这里返回 0 的话,后面就没办法走到 read 函数了(此处 read 函数读入的数据就是实际环境中 POST 报文的数据)。为了模拟真实的场景,我直接选择用 set $v0=1 逆天改命了一手,这样也确实能走到 read 函数。不过我后来重新思考了一波🤔, select 不就是没检测到标准输入流的数据么,可我压根还没输入数据怎么会检测到呢(我原先都是想着等 read 函数执行时,阻塞住等待输入再写入数据)

于是乎,我在 select 函数执行前就把数据写到了 qemu 这边(下图右部分),而后 select 函数执行后立刻返回了 1 (下图左部分)

image-20231027124041484

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

image-20231027143656986

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

image-20231027141800217

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

image-20231027144737899

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

image-20231027150333144

sess_validate 函数在验证用户的身份,如果没有通过验证返回 -1 ,这里本身就是未授权获取路由器的管理员用户名和密码,自然就没验证成功。于是乎 AUTHORIZED_GROUP=-1 就被添加到了结构体里

sprintf(v16, "AUTHORIZED_GROUP=%d", v13);
sobj_add_string(v6, v16);
sobj_add_char(v6, '\n');

在此之前,read 读入的数据也被解析到了结构体里,现在的结构体里存放的字符串是这样

image-20231027151602637

后续调用了 xmldbc_ephp 函数,这是把上图中的字符串通过套接字传输,写到了 /var/run/xmldb_sock 文件中,然后其他程序再从该文件中读取数据,而 getcfg.php 文件在读取 $AUTHORIZED_GROUP 值的时候,先读取到了我们伪造的 AUTHORIZED_GROUP=1 继而绕过验证

为了不影响主线,上面并没有提到 _POST_SERVICES=DEVICE.ACCOUNT\nAUTHORIZED_GROUP=1 是何时被写入内存的,它具体靠 sub_403864 函数来实现

sub_403864函数分析

直接上图解,应该不难看懂

image-20231027165331769

在看 sub_40445C 函数的时候,IDA72 行的 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

image-20231027162050749

sub_405AC0函数分析

此处的 v9 就是在 phpcgi_main 函数中的那个函数指针 0x405AC0

image-20231027163119133 image-20231027163251517

再看 sub_405AC0 函数,开始的 if(*a2)0 ,直接看 else 部分(如下)

image-20231027163622243

首先是往 a1 里面添加了字符串 _POST_ (这个 a1 是之前一直存放解析各种数据的结构体)

接着 sobj_add_string((_DWORD *)a1, v5)a1 里面添加了 POST 内容(也就是 read 读入的数据)中 = 前面的部分(如下)

image-20231027164319210

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

image-20231027164657601

攻击效果

image-20231026102104827

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

image-20231027191932315

尾声

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

参考文章

CVE - CVE-2018-7034 (mitre.org)

一些经典IoT漏洞的分析与复现(新手向) - IOTsec-Zone