本篇将介绍除网上zip slip漏洞外的另一种解法,针对所给附件docker-compose.yml环境与当时比赛现场环境不一致的情况

环境搭建

附件给了docker-compose.yml,直接docker-compose up -d即可,mac上要把5000改成其他,否则会和airdrop冲突

但要注意,本题直接用附件所给的docker-compose启动会和比赛时的环境不一致,也是为什么会有第二种解法的原因,具体将在攻击部分讲解

攻击

通过浏览整个项目的文件结构可以发现,有两个web部分,一个是php,一个是flask,而根据docker-compose.yml可知对外提供能访问到的是flask,所以先看flask的逻辑

先看路由,发现大部分路由都需要经过login_required

image-20260324133410454

跟进这个装饰器,发现它通过另一个is_logged_in()判断是否已登录

image-20260324133524188

image-20260326204429843

这个is_logged_in()只会检查cookie里的visited是否为yes,以及user是否有值,所以不需要什么绕过,只要有值就行

另外,还有一个硬编码过的2层md5密码,其实明文是secret,用hashcat可以很容易跑出来

image-20260326205150603

hashcat -m 2600 -a 0 hash rockyou.txt

登录逻辑大概就是这样,不过比赛时我居然在类似下面这样的好多地方纠结了好久为什么没有SSTI ?太久没碰CTF导致的……

image-20260414224354352

顺便补一下上面这样的构造并不会造成SSTI,下面这样才会,原理:https://xz.aliyun.com/news/3311

image-20260414224638233

既然不存在直接的利用点,那就只能回到逻辑分析,粗略浏览路由后会发现主要路由只有/plugin/upload/about/board尽管有写库的功能,但是sqlite,所以先不管

先看/about,主要逻辑就是下面这段

image-20260415124751514

先对上传后缀做过滤,再从数据库里拿出信息给flask渲染,其中avatar_url还会经过一个fetch_remote_avatar_info的函数,跟进

image-20260415124951757

就是很简单的curl,本身没有利用点。但问题就在于它另一个php服务存在于一个环境里,由此可以引出SSRF,并且php自带了一个phpinfo.php,所以来到about页面将头像地址改成php服务地址试试

image-20260416005631468

成功访问,但没有发现什么特别的东西,而且目前还没发现进一步利用到RCE的点,所以继续看另一个路由

image-20260415192425778

可以看到仅支持上传.zip文件,并将上传上来的zip文件名做secure_filename防止路径穿越(注意这里只过滤上传的zip文件本身,不会检查zip文件里的文件)后,最终交给safe_upload函数,跟进一下

image-20260415192453723

遍历zip文件中的文件,将文件名与dest_dir合并后赋值到target,如果是文件夹则确保或创建该文件夹存在,否则将文件写到完整的target路径里。

其实这是个很类似解压到操作,但又不是标准的解压方法,真正使用zipfile解压zip的方法应该是下面这样

with zipfile.Zipfile("example.zip","r") as zips:
    zipf.extractall("/tmp")

但这道题里的这种"解压"实质上是一种文件迁移,将zip的内容读出来再写入,而不是真正地去解压它

操作的空间就在这里,os.path.join对路径的处理并不会做像secure_filename那样过滤路径回退的操作,甚至会优先解析绝对路径(如下target3),如下

image-20260415193846381

由此可知,可以通过让被压缩的文件名带有/../这样的格式,达到任意目录写文件的目的。并且由于flask没开热更新,所以肯定不是覆写.py来拿webshell,只能写php webshell

所以只要构造一个文件名为’/var/www/html/shell.php’的文件就能成功写入,但linux和macos都不支持直接创建带/文件名的文件,所以我们只能用zipfile这个库直接创建一个带有这样文件的压缩包

import zipfile
with zipfile.ZipFile("eval.zip","w") as f:
    f.writestr("/var/www/html/shell.php","<?php system('ls');?>")

image-20260415195724244

赛事环境与附件所给不一致导致的利用失败

此时如果直接上传这个文件,理论上应该是可以正常解压最后利用ssrf getshell才对,但情况并不是这样。而是出现了解压失败的报错

image-20260415202040773

参考网上许多赛后复盘wp后发现,当时比赛的环境确实只要上传上面生成的压缩包即可

image-20260415201803986

https://rycarl.cn/index.php/2026/03/23/2026%E9%95%BF%E5%9F%8E%E6%9D%AFawdp%E5%8D%8A%E5%86%B3%E8%B5%9Bwp%E8%A5%BF%E5%8D%97%E5%9C%B0%E5%8C%BAweb/#FIX-2

image-20260415201911751

https://blog.luc1f3r.top/zh-cn/posts/writeup/ciscn--ccb-%E5%8D%8A%E5%86%B3%E8%B5%9B---2026-writeup/#break-1

说明两者环境应该有区别,经过一番排查后后发现了原因

image-20260415202310365

容器内发现/var/www/html无法写文件

image-20260415202333523

在docker-compose.yml中规定了只有tmpfs下的这些路径可写,其他均为只读,因此直接写/var/www/html的路就被堵死了

针对不同环境的第二种解法

当然不能就这样结束了,感觉这道题应该是考虑到线下无法上网查资料的原因考虑所以刻意降低了利用难度

也许接下来这种解法才是出题者的本意

我们可以从给的附件里看到一个php.ini-development文件,而且在第一种打法里也不用审它。在赛后知道要用SSRF触发后,想起之前在日xujcoj的时候碰到过类似的SSRF利用场景(原来友商的平台才是最好的靶场),所以下意识搜了一下opcache,结果发现还真开了

image-20260415203406296

image-20260415202909785

opcache原本是为php适应高并发访问而设计。传统的php网页是当次访问当次执行,意思就是只要刷新一次页面,重发一次请求就要从头执行一次你访问的这个php页面的代码,这也是为什么不做任何优化的wordpress站点为什么会慢到起飞。而opcache就是为了解决这个问题,将php先编译成一种叫opcode的东西,写进.bin文件里缓存起来,就能大大提高执行速度

file_cache="/tmp"意味着将编译后的缓存文件存储在 /tmp 目录中,file_cache_only=1意味着强制且仅使用文件系统作为缓存,不使用共享内存。

并且/tmp就正好在tmpfs上(算是可以辅佐证明作者原本可能是想这样出的证据?)

而且opcache为了防止缓存过期,会校验.bin文件头部记录的时间戳和原本.php文件修改的时间是否一致(还会校验文件大小是否一致),而正好作者又留了一个date.php来告诉你index.php的创建时间(算不算又是一个证据?)

image-20260415214400562

所以如果我们能够将自己在本地准备好的的.bin缓存上传覆盖掉已经存在的index.php.bin文件,就能getshell

怎么准备一个.bin呢?最好的办法就是起一个一模一样的环境,在本地docker-compose up前先写个webshell然后把.bin拷贝出来。但如果直接重构docker-compose又会破坏题目环境,因为我们要知道靶机里的index.php创建时间戳才能构造恶意index.php.bin,所以可以用Dockerfile同款镜像现搓一个,先起个容器

image-20260415223030927

接着看一下靶机环境里index.php的时间戳

image-20260415223424578

看一下附件给的index.php文件大小

image-20260415233604359

进入刚创建的容器,新建一个和index.php大小一样的webshell,不够的填充凑数就行

image-20260415234109591

printf "%-54s" '<?php system($_GET["cmd"]); ?>' > /var/www/html/index.php

接着修改时间戳

image-20260415234129482

touch -d @1776262902 /var/www/html/index.php

启动临时服务

image-20260415230012815

php -S 0.0.0.0:8989     -d zend_extension=opcache     -d opcache.enable=1     -d opcache.file_cache=/tmp  
   -d opcache.file_cache_only=1     -d opcache.file_update_protection=0     -t .

image-20260415230026966

访问一遍才会编译,才能见到缓存文件

image-20260415230131558

Screenshot 2026-04-15 at 10.20.10\u202fPM

可以看到缓存路径有一串随机字符,这串随机字符叫system_id,它由环境(如php版本号,Zend Extension Build ID,Zend Bin ID,编译器相关标志等,通过phpinfo也能获取到这些东西)决定,只要你使用的镜像和远端环境的一致(也就是Dockerfile里的php:8.2.6-apache),那么这串随机字符也会是一模一样的(但要注意不同的芯片架构也会影响,所以如果在比赛现场用apple silicon结果可能会不一致,建议还是备一台amd64设备)。

回到第一种解法的打包上传步骤,把正确的路径填进去

image-20260415232020042

image-20260415232031018

最后上传解压,即可成功利用

image-20260415233940227

修复

很容易发现两种解法最终都要利用到save_upload函数的路径穿越漏洞,并且在题目文件其实还有一个函数被定义了但一直没被利用到

image-20260416000653209

而且会很凑巧的发现它和safe_upload传入的两个参数名都一模一样,所以直接把save_upload改成这个就行了

至于有些师傅在自己的复盘里有提到服务异常的问题,虽然我也没修上但当时我解决了前几次服务异常的问题,这里也放上我的重启脚本供大家参考

#/bin/bash
mv index.py /app/index.py
kill $(ps -ef|grep "python"|awk -F' ' '{print $2}')
python3 /app/index.py #这里直接抄附件里给的supervisord.conf即可