1. 从一个真实的渗透测试场景说起
那天下午,我正在对一个内部系统进行授权安全测试。目标是一个典型的文件上传功能,界面看起来平平无奇:一个选择文件的按钮,一个上传按钮。我随手传了个图片,成功了。接着,我尝试上传一个包含一句话木马的PHP文件,结果页面弹出了一个醒目的红色提示:“文件类型不允许!”。这在意料之中,开发者通常会通过检查文件扩展名(如.php)或MIME类型来防御。于是,我祭出了常规的绕过手段:将文件改名为shell.php.jpg,并修改了HTTP请求中的Content-Type为image/jpeg。再次上传,系统竟然提示“上传成功!”。我心里一喜,以为找到了突破口,立刻去访问上传后的文件路径。然而,浏览器返回的却是乱码——服务器并没有把这个文件当作PHP脚本来解析,它只是被当作一个普通的、内容为PHP代码的图片文件存储了起来。这意味着,虽然绕过了前端的检查,但文件并没有获得执行权限。
这种“上传成功但无法执行”的困境,在渗透测试和代码审计中非常常见。就在我思考下一步是去挖掘解析漏洞还是目录穿越时,突然想起了php://filter这个奇特的协议。它不像http://或file://那样指向一个网络或本地资源,而是PHP内置的一个“处理器”或“过滤器”,专门用于在数据流上执行各种转换操作,比如编码、解码、压缩、字符串处理等。更重要的是,在某些特定配置下,结合文件包含漏洞,php://filter能够实现一种被称为“死亡绕过”的技巧——即在不直接写入可执行文件的情况下,通过编码、转换等方式,将恶意代码“注入”到目标文件中,最终达成命令执行的目的。这听起来有点绕,但却是理解现代Web应用安全中一些深层漏洞的关键。今天,我们就来彻底拆解php://filter的机制,并一步步还原如何利用它进行“死亡绕过”。
2. 深入理解php://filter协议栈
在PHP中,php://是一个用于访问各种输入/输出流(I/O streams)的封装协议。而php://filter是其中功能最强大、也最复杂的一个子集。你可以把它想象成一个流水线上的加工站。原始数据(比如一个文件的内容)是原材料,它流经这个“过滤器”流水线,经过一系列预定义的“加工”(如编码、替换字符),最终输出处理后的成品。
2.1 核心语法与过滤器类型
php://filter的基本语法结构是:php://filter/过滤器链/resource=目标资源。其中,“过滤器链”是核心,它决定了数据要经过哪些处理。过滤器之间用竖线|连接,表示依次执行,类似于Linux管道。
PHP内置了多种过滤器,主要分为几大类:
字符串过滤器:
string.rot13: 执行ROT13编码,这是一种简单的字母替换密码。string.toupper/string.tolower: 将字符串转换为全大写或全小写。string.strip_tags: 尝试去除PHP和HTML标签。注意:这个过滤器的行为在复杂情况下可能不可靠,不应作为安全过滤的唯一手段。
转换过滤器:主要用于处理编码。
convert.base64-encode/convert.base64-decode: 进行Base64编码和解码。convert.quoted-printable-encode/convert.quoted-printable-decode: 进行Quoted-Printable编码和解码,常用于电子邮件。convert.iconv.*: 进行字符集转换,例如convert.iconv.UTF-8.UTF-16BE。
压缩过滤器:如zlib.deflate和zlib.inflate,用于压缩和解压数据。
加密过滤器:如mcrypt.*和mdecrypt.*(已废弃)。
一个简单的例子是读取一个文件并对其进行Base64编码:
$content = file_get_contents('php://filter/convert.base64-encode/resource=/etc/passwd'); echo $content; // 输出 /etc/passwd 文件的Base64编码内容在这个例子中,数据流从/etc/passwd文件流出,经过convert.base64-encode过滤器加工,然后被file_get_contents读取。
2.2 过滤器链的“栈式”执行顺序
这是理解高级利用的关键。过滤器链的执行顺序是“从左到右”吗?并不完全是。更准确地说,它类似于一个栈(Stack)的操作,遵循“后进先出”的原则,但体现在读写操作上。
对于读操作(如
file_get_contents,include):过滤器链的执行顺序是从左到右。数据从resource流出,先经过最左边的过滤器处理,再传递给下一个,最后到达你的PHP代码。- 示例:
read=convert.base64-encode|string.rot13/resource=test.txt - 过程:
test.txt内容 →convert.base64-encode→string.rot13→ 输出给PHP。
- 示例:
对于写操作(如
file_put_contents):过滤器链的执行顺序是从右到左。数据从你的PHP代码流出,先经过最右边的过滤器处理,再传递给前一个,最后写入resource。- 示例:
write=string.rot13|convert.base64-decode/resource=test.txt - 过程:PHP输出数据 →
convert.base64-decode→string.rot13→ 写入test.txt。
- 示例:
这个反向特性在构造攻击链时至关重要。例如,如果你想通过写操作,最终让文件里包含一段Base64编码后的内容,你需要将convert.base64-encode放在写链的最右侧(即最后执行)。
2.3 与文件包含漏洞的天然结合
php://filter最常见的危险暴露点是与本地文件包含(LFI)漏洞的结合。假设有一段问题代码:
// file: include.php $page = $_GET['file']; include($page . '.php');攻击者可以传入:?file=php://filter/convert.base64-encode/resource=index代码最终会执行:include(‘php://filter/convert.base64-encode/resource=index.php’)这时,include函数会尝试读取index.php文件,但数据流先经过了Base64编码。由于Base64编码后的内容不是有效的PHP代码,include会失败,但编码后的文件内容会被直接打印到页面上。这就实现了对服务器上任意PHP文件源码的读取(只要有读取权限),即使这些.php文件因为配置原因无法直接通过Web访问。
注意:
include、require等函数在包含php://filter流时,如果流的内容不是有效的PHP代码,会引发一个警告,但编码后的内容通常会作为警告信息的一部分或直接输出到页面,从而被攻击者捕获。这正是利用的起点。
3. “死亡绕过”的核心:利用过滤器链制造PHP代码
“死亡绕过”这个听起来很骇人的词,实际上描述的是一种利用php://filter过滤器链的编码转换特性,将非PHP内容(如纯文本、图片)转换为有效PHP代码的技术。它的核心应用场景通常围绕文件包含和文件写入两个点。
3.1 场景一:从日志文件、Session文件到Webshell
这是最经典的“死亡绕过”利用链。假设我们有一个文件包含漏洞:include($_GET[‘file’]);,并且我们知道服务器上一个文件的可写路径和大致内容(例如/var/log/apache2/access.log),我们能否通过包含这个日志文件来执行代码?
直接包含日志文件,由于里面是HTTP访问记录的纯文本,不是PHP代码,所以不会执行。但是,如果我们能向这个日志文件写入一个包含PHP标签的HTTP请求,然后再去包含它,不就行了吗?问题在于,很多Web服务器或应用框架会对写入日志的内容进行转义或过滤,比如将``转义为<?php,导致其失效。
这时,php://filter的转换过滤器就派上用场了。我们可以利用convert.iconv过滤器进行字符集转换。字符集转换本质上是一种字节到字节的映射,如果我们在一个字符集(如UTF-8)中构造一个特殊字节序列,当它被用另一种字符集(如US-ASCII, UTF-16BE)解码或编码时,可能会意外地产生``这样的字节序列。
一个简化示例:
- 攻击者向服务器发送一个精心构造的HTTP请求,User-Agent设置为:
<?php system(‘id’); ?> - 这个请求被记录到Apache的
access.log中,内容可能是纯文本。 - 攻击者利用LFI漏洞,尝试包含日志文件:
?file=/var/log/apache2/access.log,失败,因为日志内容是文本。 - 攻击者现在使用过滤器链进行包含:
?file=php://filter/convert.iconv.UTF-8.UTF-16BE/resource=/var/log/apache2/access.log - PHP在读取日志文件时,会尝试将文件内容从UTF-8编码转换为UTF-16BE编码。在这个转换过程中,日志文件中原本代表
<?php system(‘id’); ?>的文本字节,可能因为编码规则的差异,被错误地解释或重组,使得在输出的数据流中,偶然形成了一个有效的``标签及其后的代码。如果这个转换后的流被include函数处理,PHP引擎可能会识别并执行这段“意外生成”的代码。
这个过程高度依赖于具体的字符集、原始字节和PHP版本,需要精心构造和测试。它利用了编码转换过程中的“歧义”来“制造”出恶意代码。
3.2 场景二:配合文件写入与二次包含
另一个更直接的场景涉及文件写入。假设有一个功能允许你将php://filter链的结果写入一个文件(例如,通过file_put_contents),或者有一个文件上传点允许你控制文件的部分内容。
攻击思路:
- 目标:在一个我们已知路径的文件(比如一个图片文件
avatar.jpg,或者一个临时文件)中注入PHP代码。 - 限制:直接写入``会被过滤或转义。
- 绕过:利用
php://filter的写操作链。我们构造一个过滤器链,使得我们输入的“无害”文本,在经过链式解码/转换后,写入目标文件时,恰好变成有效的PHP代码。 - 触发:再通过另一个文件包含漏洞(LFI)去包含这个已经被“污染”的文件。
构造示例:假设我们可以控制file_put_contents的第一个参数(路径)。我们想向/tmp/test.txt写入一句话木马``。 我们不能直接写,因为<?可能被过滤。我们可以这样构造:
// 攻击者控制的输入 $malicious_payload = "PD9waHAgZXZhbCgkX1BPU1RbJ2MnXSk7Pz4="; // 这是 `` 的Base64编码 $path = "php://filter/convert.base64-decode/resource=/tmp/test.txt"; file_put_contents($path, $malicious_payload);执行后,$malicious_payload字符串在写入/tmp/test.txt之前,会先经过convert.base64-decode过滤器解码。于是,解码后的原始字节——也就是``——就被写入了/tmp/test.txt文件。这样,我们就在目标服务器上创建了一个纯文本文件,但其内容是一个完整的Webshell。
接下来,如果存在LFI漏洞,攻击者包含/tmp/test.txt,这个文件就会被当作PHP代码执行。这里的关键在于,写入操作和包含操作是分离的。写入时我们利用了过滤器的解码功能“绕过”了内容检查;包含时,该文件因为扩展名是.txt,可能不会被Web服务器直接解析,但PHP的include函数不在乎扩展名,只关心文件内容是否包含有效的PHP标签。
4. 实战演练:构造一个完整的攻击链
让我们通过一个模拟的、简化的CTF场景,将上面的知识串联起来。假设我们有一个Web应用:
- 漏洞点A(文件写入/上传):有一个“设置个性签名”的功能,签名内容会保存到服务器的一个固定文件
/tmp/signature_[userid].txt中。服务器对输入进行了严格的过滤,移除了所有<、>、?、php等字符。 - 漏洞点B(文件包含):在个人主页有一个LFI漏洞,
?page=../../tmp/signature_123会尝试包含对应用户的签名文件(自动补全.php扩展名,但我们的文件是.txt,不过include依然会尝试读取它)。
目标:在服务器上执行命令ls /。
攻击步骤:
第一步:利用过滤器链写入Webshell我们无法直接写入``。但我们可以写入它的Base64编码形式。 我们知道,Base64解码器会忽略不在其字符集(A-Za-z0-9+/=)内的字符。因此,我们可以构造一个payload,使其Base64解码后恰好是我们想要的代码。
<?php system(‘ls /’); ?>的Base64编码是:PD9waHAgc3lzdGVtKCdscyAvJyk7ID8+但是,直接写入这个字符串,解码后就能得到原语句吗?不一定。因为Base64解码以4个字符为一组,如果字符串长度不是4的倍数,需要用=填充。我们的字符串是PD9waHAgc3lzdGVtKCdscyAvJyk7ID8+,长度是36,是4的倍数,所以可以正确解码。
那么,我们如何利用“个性签名”功能写入呢?我们需要让服务器执行类似这样的代码:
file_put_contents($user_signature_path, $_POST[‘signature’]);我们无法直接控制$user_signature_path,但假设服务器愚蠢地允许签名内容包含换行,并且路径是固定的。我们更可能的情况是,我们需要找到一个能控制完整文件路径的写入点。为了演示,我们假设存在一个更脆弱的端点,允许我们指定写入路径和内容,但内容会经过过滤。
构造payload:我们无法写入<?,但我们可以写入php://filter/convert.base64-decode/resource=/tmp/shell.php作为文件名(如果服务器允许),然后将Base64编码后的代码作为文件内容。但很多情况下,路径和内容是分开的。
实际上,更常见的利用是“死亡绕过”的变种:通过控制过滤器链本身来写入文件。如果应用有这样的代码:
$data = $_GET[‘data’]; $file = $_GET[‘file’]; file_put_contents($file, $data);那么攻击者可以:
file=php://filter/convert.base64-decode/resource=/tmp/shell.php&data=PD9waHAgc3lzdGVtKCdscyAvJyk7ID8+这样,data参数中的Base64字符串在写入/tmp/shell.php前被解码,于是/tmp/shell.php文件中就包含了原始的PHP代码。
第二步:触发文件包含执行Webshell拿到LFI漏洞点:?page=../../tmp/shell服务器代码执行include(‘../../tmp/shell.php’);,成功加载并执行我们写入的Webshell,执行ls /命令,输出根目录列表。
整个攻击链的关键在于找到了一个“写入点”,并且这个写入点允许我们使用php://filter协议来指定目标文件,从而在写入过程中对内容进行解码或转换,绕过了直接的内容安全检查。然后,再利用另一个“包含点”来执行写入的恶意文件。
5. 防御之道:如何避免成为“死亡绕过”的受害者
理解了攻击原理,防御思路就清晰了。核心原则是:最小化攻击面,对用户输入进行严格、多层、上下文相关的过滤。
5.1 严格限制文件包含与文件操作的参数
- 白名单机制:对于
include、require、file_get_contents、file_put_contents等函数的路径参数,尽可能使用白名单。如果必须动态包含,只允许包含预定义的、安全的文件列表。 - 路径固定:避免用户输入直接、未经处理地拼接到文件路径中。如果需要基于用户输入,应使用映射关系(如ID到文件名),而不是直接使用输入作为路径的一部分。
- 禁用危险协议:在PHP配置文件(
php.ini)中,使用allow_url_fopen = Off和allow_url_include = Off来禁用php://、data://、http://等URL封装协议。这是最有效的一劳永逸的方法之一。在生产环境中,除非有绝对必要,否则应保持关闭。allow_url_fopen = Off allow_url_include = Off
5.2 对用户输入进行深度过滤与验证
- 上下文相关过滤:过滤不是简单地删除
<、>。对于文件路径,要检查是否包含目录遍历序列(../)、空字节(%00,在PHP老版本中可用于截断)以及php://、data://等协议字符串。 - 规范化与绝对路径:使用
realpath()函数获取规范化的绝对路径,并检查该路径是否在允许的目录范围内(例如Web根目录之外)。$user_file = $_GET[‘file’]; $base_dir = ‘/var/www/html/uploads/’; $real_path = realpath($base_dir . $user_file); if ($real_path === false || strpos($real_path, $base_dir) !== 0) { // 路径非法,拒绝访问 die(‘Access denied.’); } - 内容安全检查:对于文件上传,除了检查扩展名和MIME类型,还应该对文件内容进行安全检查(如病毒扫描、图片重渲染)。对于用户输入将要写入文件的情况,要根据文件的预期用途进行严格的过滤或转义(例如,如果写入的是HTML,就用
htmlspecialchars;如果写入的是配置文件,就严格限制字符集)。
5.3 安全的服务器与PHP配置
- 将用户上传的文件存储在Web根目录之外:这是黄金法则。这样即使攻击者上传了恶意文件,也无法通过直接的HTTP请求访问到它,必须结合服务器本身的其他漏洞(如文件包含、解析漏洞)才能触发,大大增加了攻击难度。
- 为上传文件分配随机、不可预测的文件名:避免使用用户提供的原始文件名。
- 限制PHP执行权限:通过Web服务器配置(如Apache的
.htaccess或Nginx的location规则),禁止在特定目录(如上传目录)中执行PHP脚本。- Apache示例(在上传目录的
.htaccess中):<FilesMatch “\.(php|php5|phtml|phps)$”> Order Allow,Deny Deny from all </FilesMatch> - Nginx示例(在server配置中):
location ^~ /uploads/ { location ~ \.php$ { deny all; } }
- Apache示例(在上传目录的
- 保持PHP版本更新:新版本通常会修复旧版本中协议处理器或过滤器的潜在安全问题。
5.4 开发层面的安全意识
- 避免动态包含:从根本上审视业务逻辑,是否真的需要动态包含文件?很多时候可以用路由、模板引擎等更安全的方式替代。
- 代码审计:定期对代码进行安全审计,特别关注所有包含文件操作、文件读写操作的函数调用点,检查其参数是否用户可控。
- 使用安全函数:例如,用
basename()去除路径中的目录部分,但注意它可能无法处理所有情况,仍需结合白名单。
“死亡绕过”听起来神秘,但其本质是攻击者对PHP流过滤器特性与应用程序逻辑缺陷的深度结合利用。防御的关键不在于封堵某一个特定的payload,而在于建立纵深防御体系:从协议开关、路径控制、内容过滤到服务器配置,层层设防,让攻击者即便掌握了一个漏洞点,也难以串联成有效的攻击链。作为开发者,理解这些攻击手法,不是为了实施攻击,而是为了在编写每一行代码时,都能清晰地意识到数据流动的边界与风险,从而构建出更坚固的应用。