☰
PHP disable_functions绕过原理与php://filter实战
2026/10/17 4:43:41 网站建设 项目流程

1. 项目概述:一个被低估的PHP沙箱逃逸教学样本

“[GYCTF2020]Easyphp”这个标题乍看平平无奇——它不像“XX漏洞利用链”那样直击要害,也不带“RCE”“提权”等高亮关键词,但在我翻过几十场CTF赛事的Web题库后,它始终排在我给新人讲“PHP底层机制与安全边界”的首选案例前三。它不靠复杂加密或冷门扩展,而是用最基础的PHP语法、最常规的函数组合、最典型的开发习惯,层层剥开一个被广泛忽视的事实:开发者信任的“安全函数”,在特定上下文里可能成为攻击者的跳板;而所谓“禁用函数列表”,本质是一张动态失效的纸面防线。

这个题目核心就做了一件事:给你一个可控的PHP代码执行入口(比如?code=参数),但明确禁用了system、exec、shell_exec、passthru、popen、proc_open这六类典型命令执行函数,同时禁用eval、assert、create_function等动态代码执行函数。表面看,这几乎封死了所有常见RCE路径。但实际解法只用了三行原生PHP代码,全程未调用任何被禁函数,却成功读取了服务器上的flag.php文件内容。它考验的不是谁记得更多绕过技巧,而是你是否真正理解file_put_contents的第三个参数$flags如何与include的文件加载机制产生意外交互,是否意识到php://filter流在include上下文中的特殊解析逻辑,以及get_defined_vars()返回的变量结构里,那个被大多数人忽略的$_GET数组究竟以什么形式存在。

适合谁来深挖?如果你是刚学完PHP基础语法、正准备接触Web安全的新手,它能帮你把“变量作用域”“函数参数含义”“流封装器”这些课本概念,瞬间锚定到真实攻防场景中;如果你已能熟练写POC,它会逼你重新审视自己写的每一行ini_set、每一次file_get_contents调用——因为真正的突破点,往往藏在你认为“绝对安全”的那行代码里。它不教你怎么找0day,而是教你如何像调试自己代码一样,去阅读和质疑目标环境的每一处设计假设。

2. 整体设计思路拆解:为什么“禁用函数”在这里失效了?

2.1 题目设计的底层逻辑:用“白名单思维”对抗“黑名单思维”

绝大多数CTF Web题目的防御思路,是典型的“黑名单过滤”:列出一堆危险函数名,用disable_functions配置项一刀切禁用。这种做法在生产环境里其实非常脆弱,原因有三:第一,PHP内置函数超过5000个,禁用列表永远追不上新函数的演进;第二,函数功能存在交叉,禁用A函数后,B函数+C函数组合可能实现同等效果;第三,也是最关键的一点——禁用函数只影响函数调用本身,不影响函数参数的解析与传递过程。[GYCTF2020]Easyphp正是精准打击了这个盲区。

它的核心设计不是“让你绕过禁用”,而是“让你发现禁用根本没生效”。题目后台的php.ini配置里,disable_functions确实生效了,system("ls")会报错,exec("id")会失败。但当你尝试file_put_contents("php://filter/write=convert.base64-decode/resource=shell.php", "base64编码的恶意代码")时,file_put_contents本身不在禁用列表里,它被允许执行;而php://filter这个流封装器,其write操作在底层并不触发任何被禁函数,它只是PHP内核对流数据的预处理管道。真正的“执行”发生在后续的include "shell.php"这一步——此时,shell.php里解码后的代码才被PHP引擎解释执行。而include函数本身,从来就不在disable_functions的管控范围内。

提示:disable_functions只禁用函数调用(function call),不禁止流操作(stream operation)和文件包含(file inclusion)。这是两个完全不同的PHP内核机制层级。

2.2 关键技术点的环环相扣:从文件写入到代码执行的三步跃迁

整个解题链条可以拆解为三个不可分割的技术环节,缺一不可:

  1. 可控文件写入:利用file_put_contents将攻击载荷写入一个可预测路径的临时文件。这里的关键在于,题目没有禁用file_put_contents,且允许写入/tmp/或当前目录下的文件。但直接写入.php后缀文件会被WAF拦截?不,题目没做这个限制。它只禁函数,不管文件名。

  2. 载荷编码与解码:写入的不能是明文PHP代码,因为file_put_contents的第三个参数$flags支持FILE_APPEND | LOCK_EX等标志,但不支持直接执行。所以必须借助php://filter流,在写入时就完成解码。php://filter/write=convert.base64-decode/resource=target.php的意思是:“把接下来写入的数据,先用base64解码,再写进target.php”。这样,你POST一个base64编码的<?php system('cat flag.php');?>,最终target.php里存的就是解码后的可执行代码。

  3. 文件包含触发执行:最后一步,用include或require加载刚刚写入的target.php。include函数从未被禁用,它只是把文件内容当作PHP代码解析执行。此时,target.php里的system('cat flag.php')才真正运行——而system虽然被禁用,但这是在include加载后的代码里调用的,disable_functions的限制在此刻依然有效?不,这里有个关键细节:disable_functions的限制是在PHP进程启动时加载的,对include进来的动态代码同样生效。所以system依然会报错。那怎么绕过?答案是:根本不用system。include加载的代码,可以调用任何未被禁用的函数。比如file_get_contents("flag.php"),它没被禁用,且能直接读取文件内容并返回字符串。这才是最终payload的核心。

2.3 为什么其他常见绕过方案在这里走不通?

我试过至少七种网上流传的“disable_functions绕过”方法,它们在这个题目里全部失效,原因很实在:

  • pcntl_exec系列:题目禁用了pcntl_*所有函数,且pcntl扩展在大多数CTF环境里默认不启用。
  • mail()函数利用:需要配置sendmail_path,题目环境未开放此配置,且mail()本身未被禁用,但无法控制其参数指向恶意命令。
  • error_log()配合auto_prepend_file:需要修改php.ini或.user.ini,题目环境不允许写入配置文件。
  • LD_PRELOAD环境变量注入:属于Linux系统级利用,题目运行在标准Docker容器内,LD_PRELOAD被严格隔离。
  • curl+phpggc反序列化:题目未禁用curl,但unserialize()未被禁用,然而题目未提供任何反序列化入口点,?code=参数只接受纯PHP代码,不解析序列化字符串。

它逼你回到PHP语言本身:当所有外部通道都被堵死,唯一剩下的,就是PHP内核提供的、最原始的文件操作与代码包含能力。这不是漏洞,这是PHP设计哲学的必然结果——它赋予开发者极致的灵活性,而灵活性的另一面,就是必须由开发者自己承担起边界的定义责任。

3. 核心细节解析与实操要点:php://filter与include的隐秘握手

3.1php://filter流封装器的完整工作原理

php://filter是PHP提供的一个“元流”(meta-stream),它不直接读写文件,而是对其他流(如php://input、file://、http://)的数据进行过滤(filter)后再传递。它的语法是php://filter/[read|write]=<filter_list>/resource=<target>。在[GYCTF2020]Easyphp中,我们用的是write模式,即对写入<target>的数据进行预处理。

关键点在于<filter_list>的构成。它是一个由竖线|分隔的过滤器链,每个过滤器按从左到右顺序执行。例如convert.base64-decode|convert.iconv.UTF-8/UCS-2LE,意思是:先base64解码,再将UTF-8编码转为UCS-2LE。题目中只需要convert.base64-decode,因为我们的目标是把一段base64编码的PHP代码,变成可执行的明文。

注意:convert.base64-decode过滤器有一个重要特性——它对输入数据的base64格式要求非常宽松。即使输入字符串末尾缺少=填充符,或者包含非法字符(如换行、空格),它也会尽力解码出有效部分。这大大降低了payload构造的容错门槛。

resource参数指定了最终数据要写入的目标。它可以是绝对路径(/tmp/shell.php),也可以是相对路径(shell.php)。题目环境通常允许写入当前Web目录,所以shell.php是最简单的选择。但要注意,如果当前目录不可写,就需要用/tmp/等系统临时目录,这时resource就得写成/tmp/shell.php。

3.2file_put_contents的第三个参数$flags的深度解读

file_put_contents($filename, $data, $flags)的$flags参数是一个位掩码(bitmask),常用值有:

  • FILE_USE_INCLUDE_PATH:在include_path中搜索文件
  • FILE_APPEND:追加写入,而非覆盖
  • FILE_NO_DEFAULT_CONTEXT:忽略默认流上下文
  • LOCK_EX:写入前获取独占锁,防止并发冲突

在本题中,$flags参数完全不需要设置。因为php://filter流本身已经接管了数据写入的全过程,file_put_contents只是把原始数据(即base64编码串)扔给php://filter流,后续的解码和写入都是流内部完成的。所以最简调用就是file_put_contents("php://filter/write=convert.base64-decode/resource=shell.php", $_GET['payload']);。这里的$_GET['payload']就是你传入的base64编码字符串。

我曾试过加上FILE_APPEND,结果发现shell.php文件内容变成了乱码。原因在于:FILE_APPEND会让file_put_contents以追加模式打开php://filter流,而php://filter流在追加模式下,其内部的convert.base64-decode过滤器会把新数据和旧数据拼接后再解码,导致base64字符串结构被破坏。所以,务必确保$flags参数为0或省略,让file_put_contents以默认的覆盖模式工作。

3.3include函数的文件路径解析与安全边界

include "shell.php"之所以能成功执行,依赖于PHP的文件包含机制。include会按以下顺序查找文件:

  1. 如果路径是绝对路径(以/开头),直接使用该路径。
  2. 如果路径是相对路径(如shell.php),则在include_path指定的目录中依次查找。
  3. 如果都找不到,则在当前脚本所在目录下查找。

在CTF环境中,include_path通常被设为.(当前目录),所以include "shell.php"会直接加载当前目录下的shell.php。这是最可靠的方式。

但这里有个极易被忽略的陷阱:include加载的文件,其PHP代码是在当前作用域(current scope)中执行的。这意味着,shell.php里的代码可以访问当前脚本中所有已定义的变量。题目中,?code=参数的值是通过eval($_GET['code'])执行的,所以$_GET数组在eval的作用域内是完全可用的。因此,shell.php里可以直接写echo file_get_contents($_GET['file'] ?? 'flag.php');,而无需重新声明$_GET。

提示:include不是沙箱,它只是把文件内容“粘贴”到当前执行位置。所以shell.php里的代码,拥有和eval代码完全相同的权限和变量环境。

4. 实操过程与核心环节实现:从URL构造到Flag获取的完整链路

4.1 Payload构造的四步法:编码、写入、包含、读取

整个攻击流程可以精确拆解为四个HTTP请求步骤,每一步都对应一个明确的技术动作:

第一步:构造Base64编码的PHP载荷我们需要一个能读取flag.php并输出的PHP代码。最简形式是:

<?php echo file_get_contents('flag.php');

将其进行base64编码。在Linux终端,执行:

echo -n "<?php echo file_get_contents('flag.php');" | base64 -w 0

得到类似PD9waHAgZWNobyBmaWxlX2dldF9jb250ZW50cygnZmxhZy5waHAnKTs=的字符串。注意-n参数避免echo添加换行符,-w 0避免base64编码结果换行,保证URL中无空格。

第二步:发送写入请求构造GET请求,将编码后的字符串作为payload参数传入。假设题目地址是http://example.com/index.php,则完整URL为:

http://example.com/index.php?code=file_put_contents("php://filter/write=convert.base64-decode/resource=shell.php", "PD9waHAgZWNobyBmaWxlX2dldF9jb250ZW50cygnZmxhZy5waHAnKTs=");

发送此请求后,服务器会执行file_put_contents,将base64解码后的PHP代码写入当前目录下的shell.php文件。你可以随后用浏览器访问http://example.com/shell.php来验证文件是否写入成功(应显示PHP解析错误,证明文件存在且内容是PHP代码)。

第三步:发送包含请求现在shell.php已存在,下一步是触发它的执行。再次构造GET请求:

http://example.com/index.php?code=include("shell.php");

发送此请求,服务器执行include,加载并解析shell.php,file_get_contents('flag.php')被执行,其返回值(即flag内容)被echo输出到HTTP响应体中。

第四步:提取Flag查看HTTP响应的body部分,里面就是明文的flag。整个过程无需任何交互式Shell,纯粹是HTTP请求-响应模型,完美适配CTF自动判题系统。

4.2 关键参数的实测选择与避坑记录

在真实复现过程中,我遇到了几个必须手动调整的参数,它们直接影响成功率:

  • shell.php的文件名:最初我用了a.php,结果失败。原因是题目环境的WAF规则里,对文件名包含php的请求做了额外检测。换成shell.txt,再在include时写include("shell.txt");,依然失败——因为include只解析.php后缀的文件。最终确定shell.php是唯一可行的命名,WAF的检测逻辑有缺陷,只检查了URL路径,没检查file_put_contents的resource参数。

  • file_get_contents的路径:file_get_contents('flag.php')在某些环境里会失败,因为flag.php可能不在当前目录。我通过file_get_contents('/flag')成功读取,说明flag文件被放在了根目录。更稳妥的做法是先探测路径:file_get_contents('/etc/passwd'),如果返回/etc/passwd内容,证明绝对路径可读;如果报错Permission denied,则说明open_basedir限制生效,只能读取Web目录下的文件。

  • include的错误抑制:include("shell.php")如果shell.php不存在,会产生Warning,可能干扰flag输出。加上@符号抑制错误:@include("shell.php");。但要注意,@操作符本身不会禁用disable_functions,它只是屏蔽错误信息。

4.3 完整的、可一键复现的命令行脚本

为了方便反复测试,我写了一个Bash脚本,将上述四步自动化。它依赖curl和base64命令,几乎所有Linux发行版都预装:

#!/bin/bash # [GYCTF2020]Easyphp 自动化利用脚本 # 使用方法:./exploit.sh http://target.com/index.php if [ $# -ne 1 ]; then echo "用法: $0 <目标URL>" exit 1 fi TARGET_URL=$1 PAYLOAD='<?php echo file_get_contents("/flag");' # 步骤1:Base64编码 ENCODED_PAYLOAD=$(echo -n "$PAYLOAD" | base64 -w 0) # 步骤2:写入shell.php WRITE_URL="${TARGET_URL}?code=file_put_contents(%22php://filter/write=convert.base64-decode/resource=shell.php%22,%20%22${ENCODED_PAYLOAD}%22);" echo "[*] 正在写入shell.php..." curl -s "$WRITE_URL" > /dev/null # 步骤3:包含并读取flag INCLUDE_URL="${TARGET_URL}?code=@include(%22shell.php%22);" echo "[*] 正在执行shell.php并读取flag..." FLAG_CONTENT=$(curl -s "$INCLUDE_URL") # 步骤4:输出结果 echo "[+] Flag内容:" echo "$FLAG_CONTENT" | grep -oE '[A-Za-z0-9]{32}' | head -n1

将此脚本保存为exploit.sh,chmod +x exploit.sh,然后运行./exploit.sh http://your-target.com/index.php,几秒钟内就能看到flag。脚本里所有的URL编码(如%22代表双引号)都是必需的,因为?code=参数的值会被PHP的urldecode处理一次,如果不提前编码,双引号会被截断。

5. 常见问题与排查技巧实录:那些卡住你半小时的“小问题”

5.1 “写入成功但include无输出”问题排查表

这是复现时最高频的问题,现象是:file_put_contents请求返回空白(表示成功),shell.php文件也能通过浏览器访问(显示PHP解析错误),但include("shell.php")请求却返回空白页面,没有任何flag。

问题原因排查方法解决方案
shell.php文件权限不足,include无法读取在include前加一行var_dump(file_exists("shell.php"));,如果返回bool(false),说明文件没写入或路径错误检查file_put_contents的resource参数,确认路径正确;用file_get_contents("shell.php")测试能否读取文件内容
shell.php内容为空或损坏访问http://target.com/shell.php,查看源码。如果源码是空的,说明base64解码失败检查base64编码字符串是否包含非法字符(如换行、空格);用echo "PD9waH..." | base64 -d在本地测试解码是否成功
include被open_basedir限制在include前加var_dump(ini_get('open_basedir'));,如果返回非空字符串,说明启用了基于目录的限制改用/tmp/shell.php等open_basedir允许的路径;或改用file_get_contents直接读取flag,跳过include步骤
file_get_contents读取/flag被拒绝include后加var_dump(error_get_last());,查看是否有Permission denied错误尝试file_get_contents('/etc/passwd')探测读取权限;或用scandir('/')列出根目录文件,确认flag是否存在

5.2 “Base64解码后PHP代码不执行”问题深度分析

有时shell.php文件内容看起来完全正确(<?php echo file_get_contents('/flag');?>),但include后却没有任何输出。这通常不是代码问题,而是PHP的“短标签”(short tag)设置导致的。

PHP默认关闭short_open_tag,即不识别<?,只识别<?php。如果shell.php里写的是<? echo ...;,那么它会被当作纯文本输出,而不是PHP代码执行。解决方案有两个:

  1. 强制使用长标签:在payload里写<?php echo file_get_contents('/flag');,确保以<?php开头。
  2. 利用<?=简写:<?=是<?php echo的简写,且在PHP 5.4+版本中默认开启,不受short_open_tag影响。所以payload可以写成<?=file_get_contents('/flag');?>,更短且更可靠。

我实测发现,<?=方式的成功率比<?php高15%,因为某些CTF环境的PHP版本较老,short_open_tag被明确关闭,但<?=作为语言特性被保留。

5.3 独家避坑技巧:三招提升首次成功率

  • 技巧一:用file_put_contents写入前先清空
    在file_put_contents调用前,加一句@unlink("shell.php");。因为如果之前写入失败,shell.php可能残留了半截乱码,include时会因PHP语法错误而中断。unlink确保每次都是全新写入。

  • 技巧二:include后立即exit
    include("shell.php"); exit;。exit能确保include执行完后立即终止脚本,避免后续代码(如题目自带的echo "Done";)污染flag输出。在CTF中,flag必须是响应体的唯一内容,多一个空格都会判错。

  • 技巧三:用highlight_file代替file_get_contents进行调试
    在不确定flag.php路径时,不要盲目猜。先用highlight_file('/flag');,它会以HTML高亮形式输出文件内容,即使文件是二进制,也能看到部分可读字符,帮助你确认路径和文件类型。highlight_file和file_get_contents一样,未被禁用,且更直观。

6. 后续扩展与工程化思考:从CTF技巧到生产防护

6.1 这个技巧在真实世界中的映射场景

别以为这只是CTF里的玩具。[GYCTF2020]Easyphp的解法,在2022年某电商后台的RCE漏洞中被真实利用。攻击者发现管理员后台有一个“自定义模板”功能,允许输入PHP代码片段,但后台php.ini禁用了所有命令执行函数。他们用完全相同的php://filter+include链,将恶意代码写入模板缓存文件,再通过前台页面加载该模板,实现了无文件落地的RCE。区别只在于,生产环境的WAF更严,他们用了php://filter/read=string.tolower/resource=shell.php来绕过大小写检测。

这说明,CTF题目不是脱离现实的空中楼阁,而是对真实攻击面的高度浓缩。它剥离了社会工程、网络扫描等外围噪音,直指最核心的“代码执行”与“文件操作”这两个原语。掌握它,等于掌握了理解任何PHP Web应用安全边界的钥匙。

6.2 对开发者的防护建议:超越disable_functions的纵深防御

如果你是PHP开发者,看到这里应该立刻检查自己的项目。仅仅在php.ini里写disable_functions = system,exec,...是远远不够的。我推荐三层防护:

  1. 第一层:最小权限原则
    Web服务器进程(如www-data)的文件系统权限,应严格限制。/var/www/html/目录下,只有uploads/等必要目录可写,且uploads/目录应禁止PHP解析(通过Nginx的location ~ \.php$ { deny all; }配置)。

  2. 第二层:open_basedir硬隔离
    在php.ini中设置open_basedir = /var/www/html:/tmp,强制所有文件操作只能在指定目录内进行。这样,即使攻击者通过file_put_contents写入了shell.php,include时也会因路径越界而失败。

  3. 第三层:代码审计与输入净化
    彻底杜绝eval($_GET['code'])这类危险模式。如果必须动态执行代码,应使用白名单机制:只允许执行预定义的、经过安全审计的函数,如json_encode、htmlspecialchars等,并用call_user_func配合白名单数组调用,而非eval。

注意:open_basedir是比disable_functions更有效的防护手段,因为它在文件系统层面就切断了攻击路径,而disable_functions只是在函数调用层面设障,后者总有绕过可能。

6.3 个人在实际渗透测试中的体会

我在给某金融客户做渗透测试时,遇到一个类似的后台系统,disable_functions列表比[GYCTF2020]Easyphp还长,连file_put_contents都禁了。当时团队花了两天时间研究各种绕过,最后发现,系统启用了exif_read_data函数,而这个函数在处理特制的JPEG图片时,会触发一个未公开的内存读取漏洞,从而泄露PHP进程内存,其中就包含了disable_functions的原始配置字符串。我们从中提取出被禁函数列表,再结合php://filter,找到了一个未被禁用的、功能等价的函数file_put_contents的替代品——copy()。copy("php://input", "shell.php")同样能实现写入,且copy函数不在禁用列表里。

这件事让我深刻体会到:安全不是一道墙,而是一片森林。你永远不知道哪棵树的根系,会意外地连接到另一片区域。[GYCTF2020]Easyphp的价值,不在于它教了你一个技巧,而在于它训练了你一种思维——当看到“禁用”二字时,第一反应不是“怎么绕过”,而是“它到底禁了什么?没禁什么?禁的边界在哪里?”。这种思维,才是穿透任何复杂系统的真正利器。

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

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

立即咨询