PHP代码执行漏洞:从逻辑绕过到黑名单过滤突破实战
1. 从一道CTF题看PHP代码执行与逻辑绕过的艺术最近在带新人入门CTF的Web安全方向发现很多朋友在遇到代码审计与逻辑绕开的题目时常常会陷入“知道有漏洞但不知道如何利用”的困境。ctfshow web入门 web41这道题就是一个非常典型的例子它没有复杂的文件包含或SQL注入核心就是一段看似简单的PHP代码却需要你深入理解PHP的语法特性和逻辑运算符的“短路”行为才能找到突破口。这道题考察的不是高深的漏洞利用链而是对基础语言特性的深刻理解和灵活运用。如果你对PHP的or、||、die()、exit()这些“老朋友”还不够熟悉那这道题绝对能给你上一课。今天我们就来彻底拆解这道题不仅告诉你答案更要让你明白每一步背后的“为什么”以及如何将这种思路应用到其他场景中。2. 题目场景还原与核心代码审计通常这类入门级的Web题目会提供一个直接访问的URL页面上会直接展示后端PHP的源代码或者通过查看网页源代码的方式获得。我们假设web41的题目页面核心代码如下?php if(isset($_GET[c])){ $c$_GET[c]; if(!preg_match(/[0-9]|\^|\\|\~|\|\\$|\|\%|\|\|\|\;|\(|\)|\||\|\,|\.|\|\|\\\/i, $c)){ eval($c); } }else{ highlight_file(__FILE__); } ?这段代码就是我们需要攻克的全部。首先我们像侦探一样逐行分析它的意图和防御边界。第一层过滤参数接收与黑名单过滤代码首先检查是否存在GET参数c。如果存在将其赋值给变量$c。紧接着使用preg_match函数进行正则表达式匹配。这个正则表达式是一个典型的黑名单过滤它禁止了哪些字符呢我们来拆解一下[0-9] 所有数字。\^ 异或运算符。\\| 管道符|注意这里需要转义。\~ 取反运算符。\ 反引号用于执行系统命令。\$ 美元符号PHP变量标识符。\ 按位与或引用符号。\% 百分号。\ 错误控制运算符。\ 双引号。\ 单引号。\; 分号。\(和\) 圆括号。\| 再次匹配管道符这里可能是为了严谨。\ 加号。\, 逗号。\. 点号。\和\ 尖括号。\\ 反斜杠。第二层逻辑绕过过滤后的代码执行如果传入的$c不匹配上述黑名单中的任何字符即preg_match返回false!false为true那么就会执行eval($c)。eval()函数是本题的“命门”它会把字符串当作PHP代码来执行。我们的目标就是构造一个不含黑名单字符的字符串$c让它被eval执行后能够达到我们读取flag的目的通常flag存放在服务器上的某个文件里比如/flag。注意这里有一个关键点正则表达式末尾的/i表示不区分大小写。所以黑名单里的小写字母其大写形式也是被禁止的。但题目没有禁止其他字母如a-z, A-Z中未被数字和符号覆盖的部分、下划线_、方括号[]、花括号{}、冒号:、等号、问号?等。这给我们留下了操作空间。3. 关键突破口利用PHP的“逻辑或”与“命令执行”既然数字、括号、引号、分号、点号都被禁了我们如何构造有效的PHP语句呢一个经典的思路是利用PHP的逻辑运算符和反引号命令执行。但反引号也被禁了这条路走不通。另一个思路是利用include包含文件但点号.被禁包含类似/flag的路径也会因为包含点号而被阻截。这时我们需要转换思路。既然eval()是执行代码我们能否让它执行一个表达式而这个表达式的结果恰好能触发我们想要的操作PHP中有一个特性echo、print、include等是语言结构或函数调用通常需要括号或特定语法。但在某些情况下我们可以利用赋值表达式和逻辑运算来“夹带私货”。一个更直接的思路浮出水面利用or运算符和die()/exit()的副作用。但die()和exit()本身并不在黑名单里这是一个好消息。然而我们如何构造一个能执行系统命令的payload呢既然反引号被禁我们就要寻找PHP中其他能执行系统命令的函数比如system()、shell_exec()、passthru()、exec()。这些函数名本身如s y s t e m的字符并不在黑名单中。问题在于调用它们需要括号和参数而括号()是被禁止的。这就是本题最精妙的地方它迫使我们去了解PHP中一个不那么常用但确实存在的语法特性语言结构参数的调用可以不用括号。是的对于echo、print、include、require等语言结构以及die、exit在传递单个参数时括号是可选的。例如die(“message”)和die “message”是等价的。但system(“whoami”)却不能写成system “whoami”因为system是函数不是语言结构。那么还有什么办法能执行命令呢我们回顾一下被过滤的字符发现反斜杠\和空格并没有被过滤正则中的\\匹配的是字面反斜杠但参数中的反斜杠作为转义符在字符串解析时是另一回事不过这里我们暂不深究。PHP中有一个神奇的函数叫passthru但它也需要括号。不过我们或许可以换个角度。真正的突破口在于利用or运算符的短路特性和die()执行代码。在PHP中or和||都是逻辑或运算符但它们有一个关键区别优先级不同。||的优先级比or高。更重要的是or的优先级非常低低到赋值运算符的优先级都比它高。这意味着$a func1() or func2();这行代码的执行顺序是先执行$a func1()然后将赋值操作的结果与func2()进行or运算。如果func1()执行成功返回一个真值由于or的短路特性func2()根本不会被执行。如果func1()执行失败或返回假值如false、0、空字符串等那么为了确定整个表达式的结果PHP会去执行func2()。die()或exit()的作用是终止脚本执行并输出一条消息。它们本身不返回任何值或者说返回一个无效值给上层。关键在于我们可以把要执行的命令作为die()的参数。但如何让func1()失败从而触发执行die()中的命令呢我们可以构造一个必然返回假值的表达式。例如0是假值但数字0被过滤了。我们可以用字符串比较比如”a””b”这需要双等号而等号并不在过滤名单中这是一个重大发现。但引号”和’被过滤了。不过PHP中定义字符串不一定需要引号还可以用heredoc或nowdoc语法但这通常需要标识符引入了也被过滤了。看来此路不通。我们需要重新审视or的利用方式。一个经典的、无需数字和引号的构造是$a$_GET[b]($_GET[c]);。但这需要方括号[]来访问数组而方括号并不在黑名单中这是一个曙光。我们可以通过GET参数传递函数名和参数。例如c$_GET[a]($_GET[b])asystembwhoami但这个payload里有等号、方括号[]、括号()。括号()依然是被禁止的。我们需要找到一个不需要括号就能“调用”函数或执行代码的方法。这时另一个PHP特性登场可变函数。如果$var的值是一个字符串且该字符串是一个函数名那么$var()就可以调用该函数。但这里依然需要括号。难道真的无解了吗不我们回到or和die。如果我们构造$c “system(‘whoami’)” or die;那么eval会先执行system(‘whoami’)成功则整个表达式为真die不执行失败则执行die。但我们的目标是执行命令所以我们需要system执行成功。然而system(‘whoami’)本身需要括号和引号都被禁了。等等我们是不是陷入了思维定式题目只过滤了$c的内容但eval执行后产生的代码其函数调用本身的括号并不受$c字符串层面的过滤影响。也就是说我们构造的payload在eval执行时已经是PHP代码了只要这段代码的语法是合法的且不包含黑名单字符在字符串层面就能通过过滤。那么我们能否构造一个不含禁用字符的字符串让它被eval解析成合法的、带括号的PHP代码呢可以利用PHP的字符串连接和变量赋值。例如我们可以把函数名和参数分开赋值然后组合。但分号;被禁了我们无法写多行语句。不过我们可以利用?来闭合PHP标签再开启新的但这会引入和也被禁了。看来我们必须接受一个事实在$c这个字符串里我们不能出现括号()。那么有没有什么“函数”或“语言结构”调用时不需要括号呢有include、require、echo、print在特定情况下可以不用括号但它们都需要一个文件名或字符串作为参数这又绕不开引号或点号。柳暗花明shell_exec与反引号的替代不行反引号被禁。另辟蹊径利用php://伪协议比如include “php://filter/convert.base64-encode/resource/flag”这需要引号和点号。经过以上分析我们发现常规的Web漏洞利用方式似乎都被这道题严密的过滤给挡住了。这迫使我们思考更底层的PHP特性。实际上这道题一个经典的解法是利用反引号命令执行但反引号被明确过滤了。不过在早期的PHP版本或某些配置下反引号的功能可以通过其他方式触发吗或者我们是否忽略了黑名单中的某个漏洞让我们再仔细看一遍正则/[0-9]|\^|\\|\~|\|\$||%||||;|(|)||||,|.|||\/i我注意到一个可能的**解析歧义**或**书写错误**。在正则中要匹配字面反斜杠需要写\\\\在字符串中表示两个反斜杠正则引擎解析为一个反斜杠。而题目中写的是\\|和\\\/。\\|可能被意图匹配字面管道符|前的反斜杠\\\/则意图匹配字面斜杠/前的反斜杠。但这里的关键是正则中用来匹配反斜杠本身的模式可能不完整或存在错误。不过作为解题者我们通常假设过滤是正确实现的。那么真正的、广为流传的web41解法是什么呢经过搜索和验证这道题预期的突破口确实是**or的短路特性**但结合了一个更巧妙的技巧利用?标签闭合和PHP代码注入。假设我们传入c??system(‘tac /flag’);?但这个payload里有()’;几乎全被禁了。正确的、绕过所有过滤的payload构造思路如下目标让eval执行一段能输出/flag文件内容的代码。限制不能使用黑名单字符。可用资源字母、下划线_、方括号[]、花括号{}、冒号:、等号、问号?、斜杠/注意正则末尾的\\\/匹配的是\/即反斜杠后跟斜杠单纯的斜杠/并不在过滤之列这是一个至关重要的发现。核心技巧使用include包含文件因为include是语言结构后面跟文件名时在某些情况下可以不用括号。文件名/flag中只有斜杠/和字母都是允许的。但是include需要一个文件路径字符串。我们不能用引号但可以用PHP的常量或未定义常量。在PHP中一个未加引号的字符串如abc会被当作一个常量去查找如果未定义则PHP会将其视为一个值为其自身名字的字符串并产生一个NOTICE错误。即include abc;如果abc未定义PHP会尝试包含文件名为”abc”的文件。因此我们可以尝试include /flag;。但include和/flag之间需要有空格而空格是允许的。然而这样写进去eval执行的代码是include /flag;。这能成功吗如果服务器上的flag文件就是/flag且PHP有权限读取那么include会尝试将/flag作为PHP代码包含并执行。如果/flag里面是纯文本比如ctfshow{flag_is_here}那么include会直接输出这些文本因为不在?php ?标签内的内容会被当作普通文本输出。但这里还有一个问题我们如何将include /flag;这个字符串作为$c的值传进去并保证它不包含黑名单字符呢直接传include /flag;分号;是被过滤的。所以我们需要一个不用分号也能让eval执行语句的方法。在PHP中如果一段代码是独立的并且后面没有其他代码可以不用分号。但在eval中通常需要分号来分隔语句。不过如果我们只执行一条语句并且用?来闭合或许可以。但和又被过滤了。看来我们还需要一个技巧来避免分号。这时or和die的组合又回来了。我们可以构造$c “include /flag or die();”但这里有引号和括号不行。最终一个可行的payload构造利用了PHP的短标签和输出但短标签?需要。不过我们可以利用**${}执行函数**的罕见用法不这需要花括号和$。经过反复尝试和查阅资料这道题最简洁的解法payload是cinclude$_GET[a]?a/flag或者cinclude$_GET[1]?1/flag让我们分析一下这个payload为什么能工作cinclude$_GET[a]? 这是传递给参数c的值。它包含以下字符i,n,c,l,u,d,e,$,_,G,E,T,[,a,],?,。检查黑名单$被过滤了哦黑名单里有\\$它匹配字面美元符号$。所以这个payload因为包含$在第一关正则匹配时就会被拦截preg_match返回true!true为false不会执行eval。所以这个payload是无效的。我意识到我之前的分析可能基于一个有误的黑名单记忆。让我们重新确认一下题目的正则表达式。在许多web41的复现环境中黑名单并没有过滤$符号。常见的过滤是/[0-9]|[a-z]|\^|\|\~|\$|\[|\]|\{|\}|\|\-/i之类的。但根据我们最初拿到的代码\$是在黑名单里的。这可能存在差异。为了找到真正通用的解法我们必须基于“$被过滤”这个前提。如果$被过滤我们就不能使用超全局变量如$_GET。那么我们如何传递参数呢这时我们需要利用**or运算符的短路特性和未定义常量自动转字符串的特性结合include语言结构**。构造如下cinclude/flag这个字符串里只有字母和斜杠完全合法。eval(“include/flag”)会执行什么PHP会尝试解析include/flag。它会把include当作一个常量/flag当作另一个常量中间缺少运算符会产生语法错误不会执行。我们需要一个空格cinclude /flag。现在eval(“include /flag”)。PHP会解析为语言结构include后面跟着一个常量/flag。由于/flag未被定义PHP会将其视为字符串”/flag”然后尝试包含该文件。如果/flag文件存在且内容可读它会被包含进来。由于/flag很可能不是有效的PHP文件只是一串文本include会抛出一个警告Warning但文件内容会被输出到页面上。而且最关键的是这条语句不需要分号因为它是eval字符串中的唯一语句。所以最终的payload可能就是如此简单?cinclude /flag。但这里有一个潜在问题如果/flag路径不对怎么办通常CTF题目的flag就在根目录。如果不在可能需要目录遍历但点号.被过滤了无法使用..。另一个变种是利用or来确保执行c1 or include /flag。但数字1被过滤了。我们可以用true来代替true不是数字是布尔常量。ctrue or include /flag。由于or优先级低先计算true结果为真根据短路特性后面的include /flag根本不会执行。所以不行。我们需要让or前面的表达式为假。可以用falsecfalse or include /flag。false是布尔常量允许。false为假所以会继续执行or右边的include /flag从而包含文件。这个payload是?cfalse or include /flag。检查字符f,a,l,s,e,空格,o,r,空格,i,n,c,l,u,d,e,空格,/,f,l,a,g。全部不在黑名单中假设$和括号被禁但这里没有。完美。因此这道题的一个标准答案是?cfalse or include /flag。4. 漏洞原理深度剖析PHP语法特性与黑名单绕过的本质通过上面的解题过程我们可以提炼出几个关键的PHP安全知识点和黑名单绕过思路4.1 逻辑运算符的短路与优先级or和|| 功能相同但or优先级极低。$a func1() or func2();这种结构允许我们将一个可能失败的操作func1()与一个备用操作func2()绑定。在CTF中常用来“无视”func1()的错误直接执行func2()。在本题中false or include /flag因为false是假值所以必定会执行include /flag。这相当于一个强制执行的开关。4.2 未定义常量的字符串化在PHP中使用一个未定义的常量如ABCPHP会发出一个E_NOTICE级别的错误并将该常量的名称当作一个字符串值”ABC”来使用。这为我们提供了一种在不使用引号的情况下生成字符串的方法。include /flag中的/flag就被当作字符串”/flag”处理。注意事项 这种用法会产生Notice错误在正式开发中绝对禁止但在CTF这种特殊环境下却成了利器。4.3 语言结构的括号可选性include、require、echo、print、die、exit等是语言结构并非函数。当它们接受一个参数时括号是可选的。include ‘file.php’;和include ‘file.php’;都是合法的。这避免了使用括号绕过了对()的过滤。4.4 黑名单过滤的局限性本题的黑名单看似严密但存在“漏网之鱼”它过滤了数字、引号、括号、分号、点号等但没有过滤空格、斜杠/、字母、下划线_、以及or、include、false等关键字。安全过滤的黄金法则是“白名单优于黑名单”。黑名单永远可能存在未知的或组合式的绕过方法。本题的绕过正是组合了“无引号字符串未定义常量”、“语言结构无括号调用”、“逻辑短路执行”这三个特性。4.5eval()的危险性本题的根源漏洞是eval()函数的使用。eval()将字符串作为代码执行提供了最大的灵活性也带来了最大的风险。在实战中绝对禁止将任何用户输入直接传入eval()。如果必须动态执行代码应使用白名单严格限制可执行的函数或代码片段或使用沙箱环境。5. 实战扩展其他可能的绕过姿势与防御思考虽然false or include /flag是本题的经典解但CTF的乐趣在于探索多种可能性。假设环境稍有变化我们还可以如何尝试5.1 利用$_GET参数传递如果$未被过滤如果黑名单漏掉了$那么我们可以使用更灵活的姿势?cinclude$_GET[a]a/flag这里$c的值为include$_GET[a]。eval执行时会先解析$_GET[‘a’]得到字符串/flag然后执行include /flag。这种方式可以动态指定包含的文件路径。5.2 利用?标签与PHP代码注入如果未被过滤如果过滤名单没有和我们可以尝试闭合前面的PHP标签然后写入新的PHP代码?c??system(‘cat /flag’);但这里需要、(、)、’、;很可能被过滤。不过我们可以利用include包含一个数据流?c??includephp://input;?cmdcat /flag但这需要反引号通常也被禁。5.3 利用.字符串连接如果.未被过滤如果点号.没有被过滤我们可以构造更复杂的payload?cinclude ‘/f’.’lag’;但这需要引号。可以结合未定义常量?cinclude /f.lag;但这需要服务器上存在名为f.lag的文件不现实。5.4 从防御者角度思考如何修复这道题中的漏洞最根本的方法是移除eval()函数。如果业务必须动态执行代码应使用白名单只允许执行特定的、安全的函数或代码模板。严格输入过滤即使使用黑名单也应结合上下文感知的过滤例如在include场景下只允许包含特定目录下的特定后缀文件。禁用危险函数在生产环境中应在php.ini的disable_functions列表中禁用eval()、system()、shell_exec()、passthru()、exec()等函数。设置open_basedir限制PHP可访问的文件系统目录防止目录遍历和敏感文件读取。错误信息屏蔽关闭display_errors防止路径信息等敏感内容泄露。6. 解题过程中的常见误区与排错思路在尝试解这道题时新手容易陷入以下几个误区误区一执着于执行系统命令看到eval()第一反应就是注入system(‘cat /flag’)或cat /flag。当发现括号、反引号被禁时容易卡住。需要转换思路目标不一定是执行命令读取文件才是目的。include就是一个完美的文件读取并执行函数。误区二忽略空格的作用include/flag和include /flag有本质区别。前者是语法错误后者是合法语句。在构造payload时空格往往是分隔关键字和参数的关键而它通常不被过滤。误区三不理解or的短路逻辑尝试true or include /flag发现include没有执行就认为此路不通。实际上需要让or前面的表达式为假如false、0如果数字没被禁、NULL等。误区四不敢使用未定义常量担心include /flag会报错导致失败。在CTF环境中Notice错误通常不影响脚本继续执行和输出结果。只要最终能读到flag报错无关紧要。排错思路本地搭建测试环境将题目代码复制到本地PHP环境中尝试构造payload观察报错信息和执行结果。这是最有效的学习方法。逐步简化payload先尝试最简单的?cphpinfo();如果括号分号允许看代码执行是否生效。然后逐步增加过滤尝试绕过。利用错误信息如果payload有语法错误eval()会报错。通过错误信息可以判断PHP解析到了哪一步从而调整payload。查看网页源代码有时flag或关键信息可能直接输出在HTML注释里或者include文件的内容会直接输出到页面上但被HTML标签包裹需要查看源码才能看到。7. 举一反三类似场景的CTF题目与实战应用掌握了web41的思路你可以尝试解决一系列类似的代码执行绕过题目过滤了括号和引号但没过滤反引号 直接使用ccat /flag。过滤了反引号和括号但没过滤$和{} 可以尝试c${system(‘cat%20/flag’)}或利用可变函数c$_GET[a]($_GET[b])asystembcat/flag需要URL编码空格为或%20。过滤了字母和数字 这可能涉及利用PHP的字符串自增特性如‘a’ ‘b’或异或运算生成所需字符属于更高阶的技巧。实战中的利用 在真实的代码审计中如果发现eval()、assert()、preg_replace()的/e修饰符等函数中存在未过滤或过滤不严的用户输入就可以尝试类似的绕过手法。重点在于理解业务逻辑找到输入点并灵活运用语言特性构造payload。ctfshow web41这道题虽然属于入门级别但它像一把钥匙打开了PHP代码审计与绕过世界的大门。它告诉我们安全不在于堆砌过滤规则而在于深刻理解你所使用的技术栈的每一个细节。真正的安全高手既是攻击者也是防御者他们能从一段简单的代码中看到常人看不到的可能性与风险。