从零开始复现 CVE-2023-20073
从零开始复现 CVE-2023-20073
ZIKH26 看雪学苑 2023-08-22 17:59
本文对于CVE-2023-20073复现过程做了详尽的记录,其中包括了遇见过的各种坑和解决问题的方法及思路。
Cisco RV340,RV340W,RV345和RV345P四款型号的路由器中最新固件均存在一个未授权任意文件上传漏洞 (且目前尚未修复),攻击者可以在未授权的情况下将文件上传到/tmp/upload目录中,然后利用upload.cgi程序中存在的漏洞,最终造成存储型XSS攻击。
近一年的IP数量在2.8w左右。
环境信息
下载固件
首先在思科官网(https://software.cisco.com/download/home/286287791/type/282465789/release/1.0.03.29)中下载最新的RV340固件。
固件解压-提取文件系统
把固件拖到虚拟机里用binwalk解压,执行binwalk -Me RV34X-v1.0.03.29-2022-10-17-13-45-34-PM.img。
执行后发现没有找到解压出来的文件系统,然后看一下binwalk给的warning(如下),说是执行失败ubireader_extract_files程序。
这是因为这里的文件系统是ubi格式的,我的binwalk当初是用apt install binwalk安装的,就导致少装一些东西(尽量通过源码安装binwalk),最终就没提取出来这个ubi格式的文件系统。可以看到下面这个路径的位置只有一个0.ubi的文件,确实是没提取出来文件系统的。
解决方法:安装ubi_reader(ubi_reader工具中就包含了上面缺少的ubireader_extract_files脚本 ) ,命令如下:
安装成功后,重新执行binwalk提取文件系统,可以看到这次就成功将文件系统提取出来了(如下图)。
但是还没完,binwalk还有warning(如下图),说是原本文件中存在的软链接指向了提取目录之外,就比如当前的var目录,它指向的是我本机的/tmp目录,为了安全考虑binwalk将这种软链接都置成了/dev/null。这里放任不管的话,之后的仿真会失败,比如路由器的某个服务需要去访问var目录下的文件,但它如果是被置成/dev/null的话,目录自然是缺失的。其实这个var -> /tmp的本意是指向提取出来文件系统的/tmp,并非是我本机的/tmp,因此只要我能保留这个软链接,到时候用chroot创建一个隔离的文件系统就一切正常了。
解决方法:通过上面报错的字符串找到是出现在binwalk/build/lib/binwalk/modules/extractor.py文件(如下图),将if not …修改为if 0 and not …
然后回到binwalk主目录执行sudo python3 setup.py install重新安装一下,如此就不会再执行将软链接置成/dev/null的操作了。
对于解压ubi格式的文件系统补充两个方法,因为我们只是要文件系统,所以binwalk解压出来0.ubi文件后(用其他解压软件也能解出来0.ubi,比如7zip),可以直接用ubireader_extract_files 0.ubi命令来解压0.ubi,这样不会出现那个软链接的问题,但得安装ubi_reader。还可以使用ubidump(https://github.com/nlitsme/ubidump/blob/master/ubidump.py)对ubi文件系统进行提取,直接复制源码,然后执行python3 ubidump.py -s . 0.ubi进行提取,这两种方法都不会破坏其中的软链接。
实现宿主机与qemu的通信
因为之后需要用scp传文件以及启动服务等操作肯定是需要配置qemu模拟环境网络的,大概原理就是设置一个网桥,然后开一个接口,把这个接口给qemu,然后流量的发送都通过这个网桥,画成图的话就是下面这个样子。
具体方法:创建一个net.sh脚本,我这里的网卡是ens33,如果是eth0的话,就把出现的ens33换成eth0即可,chmod +x net.sh给文件可执行权限,然后./net.sh运行。
启动qemu模拟环境
首先用file命令查看一下busybox的文件信息(如下),这里是ARM架构 小端序,因此我们要下载对应的内核映像还有磁盘映像等文件。
访问网站(https://people.debian.org/~aurel32/qemu/armhf/)下载这三个文件。
使用wget来下载文件,命令如下:
启动脚本如下:
如果执行启动脚本的话,应该会报如下错误,这里说的是SD card size应该是2的幂,应该改成32GB。
解决方法是执行qemu-img resize debian_wheezy_armhf_standard.qcow2 32G。
再次执行启动脚本,大概要等待两分钟左右就会让输入账号和密码(如下),账号密码都是root。
进去后看到了IP,并且能正常与宿主机通信(如下图)就说明到这里都是操作正确的。
启动服务&&解决报错
先把文件系统给压缩打包,然后用scp传到qemu中,再将文件系统解压(这里发送的时候要发压缩包,不然后续有可能会缺少文件,我最初因为传的是文件夹,导致出现了错误,就在这里浪费了很多时间)。
压缩命令tar -czvf rootfs.tar.gz rootfs
传输文件命令sudo scp -r rootfs.tar.gz [email protected]:/root/rootfs.tar.gz(IP、用户名和路径都换成自己的)
解压命令tar -xzvf rootfs.tar.gz
接下来进行仿真时要先用chroot命令创建隔离的文件系统环境。但这会导致无法在隔离的文件系统中访问原本的/proc和/dev目录,因为它们是特殊的虚拟文件夹(用于提供系统信息和设备的访问)为了让qemu环境正常运行,需将原本qemu的/proc和/dev目录挂载到新创建的隔离环境中。
还记得上文提到的软链接的问题么,此时位于这个文件系统中,软链接就已经指向了正确的位置(如下)。
在/etc/init.d目录下存放了各种服务的启动和停止脚本,下面这里发现有nginx服务的脚本。
然后尝试开启nginx服务,执行命令/etc/init.d/nginx start,访问一下qemu环境的IP,看服务是否启动(如下)。
没跑起来,然后看一下报错信息(如下)。
就这里有一个报错FAILED: confd_load_schemas(addr, addrlen), Error: system call failed (24): Connection refused, in function run, line 2413也不太清楚这是什么,但是这里有一个confd,而在/etc/init.d目录下,有一个confd服务(查了一下资料,说是轻量级的配置管理工具),那就给它启起来,执行/etc/init.d/confd start。
报错信息如下:
然后这里出现的报错是cp: can’t stat ‘/etc/ssl/private/Default.pem’: No such file or directory这意味着是缺少ssl证书,搜索一下字符串/etc/ssl/private(我是放到vscode里搜的)发现大概有十几个文件吧,里面有一个文件叫做generate_default_cert,这名字一听就很正经,叫做生成默认证书。
因此执行generate_default_cert,发现还有报错,信息如下:
这里一直有一个错误是uci: Entry not found,百度一下,结果如下:
意思是uci读取的这个配置路径不存在(我是这里理解的),然后在/etc/init.d/boot文件中有两行代码,就是来创建的路径。
所以这里再执行/etc/init.d/boot boot,此刻如果你开启nginx服务的话(端口如果被占用了,执行/etc/init.d/nginx restart进行重启)应该就发现访问到路由器的首页面了,如下:
因此最终启动服务的命令如下:
如果刚开始测试的时候把环境整的乱七八糟,发现上面启动了nginx服务,访问是失败的。不用慌,接下来先确保完成下面的四个操作:
1.关掉qemu,重新进入,依次执行上面的四个命令,并确保命令是执行成功了(因为是仿真,虽然还有很多报错,但只要能启动需要的服务就是好仿真)。
2.确保binwalk解压的文件系统完整,并且软链接还在(尽可能不要解固件的时候出现warning)。
3.是否用scp传进来的是压缩过的文件系统,而不是直接传了文件系统。
4.虚拟机中能否访问成功路由器的登录界面(不是主机)。
如果这四个操作全部做过,但依然访问失败的话,那么你可以开始慌了。因为上面的四种情况导致了主机中不能成功访问路由器登录界面的情况我都遇见过。如果还是不行的话,那确实是我没遇见的情况。下面我给出我执行四条命令后的输出错误信息(此时可以访问成功登录界面),如果还是无法成功访问路由器登录界面的话,可以比对下面的错误信息,看看哪里不同,去找到相应的解决方法。
关于这个洞的成因,我也跟winmt师傅聊了一下,这里我说一下我认为导致这个漏洞能利用的三个点。
前提条件
这里存在未授权的文件上传(如下),仅仅看这里也没啥用,因为文件上传到了/tmp/upload目录下,第二就是如果没有绕过那个正则检查的话,也会将上传的文件夹全删掉。所以这里仅是个前提条件。
根本原因
根本原因是身份认证位于了漏洞发生处之后,只要system的命令执行成功,sub_115EC函数的返回值就为0,又因为我们访问的URL为/api/operations/ciscosb-file:form-file-upload,所以会进入下面的if执行sub_125A8函数(如下图)。
而sub_125A8函数中的如下位置会做身份认证,但是出现的问题就在于身份认证在漏洞触发点之后,所以做了跟没做一样,还是可以未授权文件上传。winmt师傅说可能之前那是前置操作部分,开发者想把身份认证的位置放到前置操作之后,但是没想到前置操作部分就存在了一个漏洞点。
临门一脚
就算上面两个条件都满足了,也只是可以将上传的文件移动到/tmp/www目录下,如果这下面没放什么东西的话,也不会有什么危害。但问题就在于/www/login.html和index.html两个文件软链接到了/tmp/www/login.html和/tmp/www/index.html上(如下图),只要覆盖掉/tmp/www/login.html或者/tmp/www/index.html就可以篡改掉登录首页面,从而完成了最终存储型XSS攻击。
配置文件分析
第一个红框是该文件路径,第二个红框中的代码展示的是Nginx文件上传模块,下面对代码逐一分析。
首先是location /api/operations/ciscosb-file:form-file-upload,这个location块是Nginx配置文件中用于匹配URL路径的指令,就比如访问192.168.0.1:/api/operations/ciscosb-file:form-file-upload就可以执行到下面的代码。
然后代码14到22行是很好理解的,$http_authorization为空的话返回403,非空的话就可以执行下面的代码。这里的$http_authorization是HTTP请求中Authorization的值,从如下代码可以判断出来。
upload_pass /form-file-upload转至后台处理/form-file-upload这个URL
upload_store /tmp/upload上传文件临时保存路径为/tmp/upload
upload_store_access user:rw group:rw all:rw表示上传文件的权限
upload_set_form_field设置额外的表单字段,一些变量如下。这块在最后编写EXP的时候有一个很重要的点,后面再说。
$upload_file_name文件原始名字$upload_field_name表单的name值$upload_content_type文件的类型$upload_tmp_path文件上传后的地址
upload_aggregate_form_field额外的变量,在上传成功后生成这几个字段
$upload_file_md5文件的MD5校验值$upload_file_size文件大小
upload_cleanup 400 404 499 500-505如果pass页面是以下状态码,就删除本次上传的文件
这地方的代码就很奇怪,因为这里只需要控制一下Authorization就可以将文件上传到/tmp/upload目录,也没有做sessionid的判断。可以看一下location /upload的代码(如下图),这里做了/tmp/websession/token/$cookie_sessionid文件是否存在的判断以及正则匹配的检查防止目录穿越。
说完此处的文件上传,再来看一下upload_pass /form-file-upload,它会跳转到location /form-file-upload这里的代码来执行,这里有一个uwsgi_pass 127.0.0.1:9003,它会把请求转发给uwsgi给处理。
顺便提一下,在Nginx的启动脚本中最后一句是$UWSGI start,启动的就是这个uwsgi服务,它启动后会开启下面三个进程(如下图)。
uwsgi -m –ini /etc/uwsgi/upload.ini &在这个进程中会调用upload.cgi进程(为什么是upload.cgi进程呢?因为在配置中记录了要执行的程序路径,如下),调用的方式是先fork了一个子进程,然后execvp来执行upload.cgi。
二进制程序upload.cgi分析
静态分析
接着来分析漏洞的触发点,它就位于这个/www/cgi-bin/upload.cgi二进制程序中。
因为程序去除了符号表,所以我们从_libc_start_main中寻找main函数入口(该函数的第一个参数就是main函数)。
接下来从最终的利用点进行倒着分析,这个sub_115EC函数被调用于main函数。
最终system会执行mv -f a2 v8/a3,而这三个变量都可以控制,/www/login.html软链接到了/tmp/www/login.html这个文件上,因此如果我们能把a2控制为刚上传的文件路径,v8控制为/tmp/www,a3控制为login.html就能执行mv -f /tmp/upload/xxx /www/login.html从而完成对路由器登录界面的篡改。要将v8控制为/tmp/www则要设置a1的值为Portal。
参数控制
上面对sub_115EC函数的三个实参进行了分析,下面看一下main函数中这三个参数是怎么控制的。
溯源这些值的话,v17是pathparam字段的值(根据上面的分析,将这个字段要控制为Portal),v16是file.path字段的值(这里要为刚上传的文件路径),v18是fileparam的值(这里要控制为login.html)。
动态调试解析报文字段
问题是这些要控制的值怎么来呢?
这需要分析下面的代码(此处的分析需要配合动态调试,关于如何动态调试这种cgi程序,请看“调试方法”一节)。
因为程序中读入数据的只有fread,所以假定这里是读入POST请求,接着先来写一个报文发过去调试一下,这里要控制pathparam和fileparam为上面我们指定的值,至于那个file.path是什么还不知道,这里都一起发过去试试。(由上图中通过定位boundary=字符串获取报文分隔符,结合该cgi的具体功能文件上传,可知该报文需要以表单格式multipart/form-data发送POST请求)。
我选择把断点打在了0x10EBC处,这里是刚刚执行完了fread函数。通过查看代码得知,fread函数执行完会在读入的数据末尾加一个0,下图红框中的指令就是在做这件事,分析得知R5寄存器是存放着刚读入的数据,下面来查看一下。
如此验证了猜测,这里确实是POST报文,接下来看一下后面是怎么把报文中的字段值解析出来的(补充一点,不知道为什么调试的时候是没办法用n来跳过函数的,我用的方法是打断点c过去)。
下面我直接说解析字段的结论,如果有想弄清过程的师傅可以自己调试一下。这个multipart_parser_execute函数是将POST报文进行了字段的解析,就大概是做了一个键值对出来,可能用结构体来实现的(反正调试看到的是用多个堆块通过指针的方式,将键和值做一个匹配)。
然后执行到jsonutil_get_string函数时,可以把file.pathpathparam这种字段的值给解析出来,以jsonutil_get_string(dword_2348C, &v26, “\”file.path\””, -1);为例,下面放出该函数执行前和执行后的情况。
这里可以看到确实是把file.path解析出来了,值为login.html这是因为当时发送的报文就是这么设置的(如下)。
执行流走偏
其他几个字段的解析是同理的,然后我们继续调试,结果发现会进入这个if中(如下图)最后直接返回,并没有触发到有漏洞的sub_115EC函数。
需要注意,这里的IDA显示错误了(如上图),很明显这里是在进行正则匹配,但只有规则,没有要匹配的字符串,不过GDB依然给力,可以正常显示(如下图)。
这个函数在对login.html字符串进行匹配,但在发送的报文中我们将file.path和fileparam都设置为了login.html,是匹配的哪个字段的值呢?我们通过IDA的汇编部分来寻找一下,通过下图可以看出来,R1的值是从SP,#0x478+var_460位置拿到的,其实也就是SP+0x18的位置。
然后我们往上寻找,发现在解析file.path字段时,出现了这个地址。因此得出结论是match_regex会对file.path的值进行正则匹配,函数返回值为1,于是执行流就走偏了(做一些退出的工作,就结束了,在结束前会调用system函数将/tmp/upload下的所有文件删掉)。
控制file.path字段-方法一
要想成功的话,就得让file.path为/tmp/upload/xxx,正常的序号应该是下面这样,/tmp/upload/0000000001,只需要把upload.cgi进程卡住,查看一下/tmp/upload目录下的文件即可(如下)。
现在尝试一下,我们设置file.path字段的值为/tmp/upload/0000000001再发一次报文,看看能否通过正则检查,发现函数执行后的返回值为0,如此通过了检查(如下图)。
刚刚发送的报文如下。
POC
继续调试,发现可以成功走到system函数,并执行mv命令(如下)。
此时刷新路由器登录界面,发现已经被篡改掉了(如下)。
控制file.path字段-方法二
这里是winmt师傅使用的一种比较优雅控制file.path的方法,上面提到的方法file.path字段是我们主动发过去的,其实报文会根据配置文件来自动来添加一个xxx.path,配置文件分析这里其实就说了这个地方(如下图)。
这里的$upload_file_name就是报文中Content-Disposition: form-data; name=”what”;filename=”login.html”的name字段,然后在upload_set_form_field $upload_field_name.path “$upload_tmp_path”这行代码,会把上传文件的路径记录到这个xxx.path字段,这个xxx也就是上面的name字段的值。
验证的话,只需要看一下上面那次POST报文的数据就会发现what.path字段的值就是/tmp/upload/00000000001(如下图)。
所以实际上报文也可以这么写(如下),这样不需要手动传入file.path字段,这个的优点是不知道文件的上传路径依然也能够攻击成功。这次就不再调试了,执行流什么的和上面一样。
POC
攻击效果
因为upload.cgi进程被调用是一闪而逝的,想正常查看进程号来附加进程调试是不可能的,所以下面介绍三种可以调试upload.cgi的方法。
方法1-爆破
这个原理很好理解,就是写一个shell脚本不断的去捕获upload.cgi进程号,如果捕获到了就立刻去执行gdbserver,缺点是全凭概率,大概率是捕获不到的(感觉有三成的几率用循环能捕获到该进程),并且没法控制断点位置,因为我们无法干预捕获到进程号并加载调试的时间,有可能upload.cgi都快执行完才加载上去啥的,就随机性很大,大概率看不到自己想要的,但也算是一种调试方法。
使用上面的爆破脚本开始运行,然后gdb执行target remote 192.168.45.66:9999,接着发送报文,此时会触发upload.cgi进程,如果运气好的话爆破脚本此时正好会捕捉到进程号,开启调试。
下图为爆破成功的情况。
方法2-死循环
该方法对于这种fork子进程启动cgi最稳定的调试方法 — 也是winmt最爱的调试方法。
在upload.cgi的main函数起始位置,将首次进行跳转的指令给改成跳到本条指令地址的指令,使程序陷入死循环。
上面getenv函数跳转时,正常的汇编代码应该如下:
然后现在把0x10E0C这个地址存放的指令BL getenv改成B 0x10E0C这样就可以让进程upload.cgi陷入死循环(使用插件keypatch进行修改)。
此时改完之后查看伪代码应该是这样的,如下:
然后将patch完的文件保存后,放到/www/cgi-bin目录下(记得把原本正常的upload.cgi备份),直接重启nginx服务,然后发送攻击报文的话,路由器的界面会出现502错误(如下)。
nginx服务本身会报一个Permission denied的错误,这是因为传进来的upload.cgi属于root用户,但是启动upload.cgi进程的用户是www-data(ps -ef可以查看),它的uid是33,权限不够。
解决方法是把其他用户组的访问权限设置为7,这里图方便直接执行了chmod 777 upload.cgi(之前我还纳闷为什么可以成功的单独运行 upload.cgi 程序,却服务启动的时候说权限不够,现在知道了单独运行upload.cgi是因为执行的用户本身就是root,以前对于linux上的权限设置有些一知半解,这里要特别感谢winmt师傅帮我解决了这个问题并且还要感谢我的同学timochan(https://www.timochan.cn/)帮助我彻底理解了这里)。
然后重启nginx服务,发送攻击报文,此时正常的话服务是卡住的,访问路由器登录界面没反应,并且也能看到upload.cgi的进程号。
用gdb.server + gdb实现远程调试gdbserver 下载链接(https://github.com/stayliv3/gdb-static-cross/blob/master/prebuilt/gdbserver-7.7.1-armhf-eabi5-v1-sysv)。下载后需要用scp传入到qemu中。执行命令./gdbserver 0.0.0.0:9999 –attach PID。
在宿主机中执行sudo gdb-multiarch upload.cgi这里也设置upload.cgi是为了加载出来程序的符号。
执行set endian little设置一下字节序,执行set architecture arm设置一下架构再执行target remote 192.168.45.66:9999就可以附加upload.cgi进程进行调试了。
一切正常的话,界面应该如下:
因为我们通过patch让进程陷入了死循环,所以要用set命令给改回正常的指令,查看之前备份的upload.cgi文件,发现这里原本机器码为AA FF FF EB,因此执行set *0x10e0c=0xebffffaa命令(因为小端序,这里是反着输入的)。
至此就可以正常来调试upload.cgi进程(如下)。
还有一个坑,调试的时候会发现,调试几分钟,就会出现右下角的报错(如下图)。
原因是在uwsgi的配置文件中设置了时间限制(如下图),解决方法就把这个值改的很大即可。
方法3-父进程
该方法在这里没有实验成功,但理论上可行。去找到调用upload.cgi的进程(也就是uwsgi -m –ini /etc/uwsgi/upload.ini),调试upload.cgi的父进程,等到fork创建子进程,切换到子进程,并用catch exec命令捕获新事件,在执行execvp后即可跳转到upload.cgi上调试(upload.cgi进程会替代原本的子进程 ),理论上是这个样子。失败原因:gdb加载进程调试后,似乎出现了一些问题。第一是fork后子进程没出来(gdb上看不到);第二execvp这里执行后不支持catch exec;第三是gdb挂上来后,有些指令会导致进程的崩溃。
尾声
CVE-2023-20073的复现结束了,在这个过程中有过惊喜、不解、困惑各样的情绪。还记得当时我做了两天,启动服务这块还是失败的。后来用的网上找的内核镜像和磁盘映像文件(里面有一个老版本固件解出来的文件系统),直到整个复现过程的结束,我用的都不是自己解压出来的文件系统。这个点一个星期都在抽空不断的尝试,但都没有成功。第八天,不知什么时候我尝试了scp应该传压缩后的文件系统,并且binwalk要保留文件的软链接,这一次我成功将服务启动成功,用的是我自己解压出来的最新固件。当时我给winmt说现在的心情就和中了五百万一样,可实际上我只不过是成功的解压了一个固件启动了服务而已,这种心情很奇怪,可能只有各位亲自遇见了困扰自己许久的问题最终还是被自己给解决掉才能体会到(也不希望各位花费一个星期只为体会到这种心情,哈哈~)。
如果结尾只写一句话的话,应该是 “感谢winmt和坚持的ZIKH26,他教会了我很多,不止技术,还有态度”。
看雪ID:ZIKH26
https://bbs.kanxue.com/user-home-953233.htm
*本文为看雪论坛精华文章,由 ZIKH26 原创,转载请注明来自看雪社区
#往期推荐
3、安卓加固脱壳分享
球分享
球点赞
球在看