PHP代码审计与RCE绕过:从数学运算到无字母数字Webshell构造
1. 从一道CTF题看PHP代码审计的“数学”陷阱最近在复盘一些经典的CTF Web题目特别是国赛CISCN的初赛题总能发现一些精巧的设计。2019年初赛的这道“Love Math”就是一个非常典型的例子。题目本身没有给出任何正文描述只有一个标题但这恰恰是CTF比赛的常态——你需要从一个看似简单的入口点挖掘出背后复杂的逻辑漏洞。这道题的核心是考察选手对PHP代码审计、黑盒测试技巧以及利用PHP语言特性进行命令执行RCE的能力。它不像那些直接给出源码的题目你需要通过构造特定的输入去“猜”或者“试”出后端代码的逻辑并最终找到一个绕过限制执行系统命令的方法。这过程就像解一道数学题需要严密的逻辑和一点“灵光一现”。很多刚接触CTF Web方向的朋友看到这种题目可能会无从下手。常见的思路是尝试system(‘ls’)、exec(‘cat /flag’)这类直接的命令执行但99%的情况下都会被过滤得干干净净。这道“Love Math”题其精妙之处就在于它引导你利用PHP中一些看似无害的数学函数和字符串操作通过复杂的组合最终拼凑出能够执行任意代码的“武器”。这不仅仅是找漏洞更是在理解PHP解释器如何解析代码、如何处理变量和函数名。接下来我将带你完整复盘这道题的解题思路从信息收集、代码逻辑推测、Payload构造到最终利用并深入讲解其中涉及的关键PHP特性。2. 解题第一步信息收集与黑盒探测面对一个只有标题的题目第一步永远是信息收集。题目名为“Love Math”提示可能与数学有关。常见的入口点是一个包含输入参数如?c的页面。2.1 基础探测与错误信息我们首先访问题目链接假设为http://challenge/love_math.php。页面可能显示一个简单的输入框或者更常见的是一个接受GET参数c的页面。我们尝试一些基础探测直接访问查看页面源代码寻找注释、隐藏表单或JS提示。参数测试尝试?c1观察回显。题目很可能将c参数的值进行某种数学运算或直接eval。错误注入尝试?c?cphpinfo()?cecho 1;。如果后端使用了eval($_GET[‘c’])我们可能会看到语法错误信息。错误信息是极其宝贵的它能泄露服务器配置、代码片段等信息。例如如果看到Parse error: syntax error, unexpected ‘(‘ 说明我们的输入被直接放入了PHP执行上下文。在本题目中经过测试我们发现传递?cphpinfo();会导致一个白屏或者特定的错误提示如“禁止危险函数”而传递?c1则可能正常显示“1”。这暗示后端对输入进行了eval操作但存在过滤。2.2 关键过滤规则探测我们需要探测哪些字符或函数被禁止。这是一个系统性的过程测试系统命令执行函数system,exec,passthru,shell_exec,proc_open,popen。尝试?csystem(‘ls’); 观察是否被拦截或返回“黑名单”提示。测试代码执行函数eval,assert。本身可能就在eval里无需测试。测试字符串单引号’、双引号”、反引号“”常用于构造命令。尝试?cecho ‘test’;。测试空格和括号空格 、制表符、(、)。测试数字和运算符,-,*,/,.(连接符)%。题目叫“Love Math”这些很可能被允许。通过一系列测试我们可以推测出题目的过滤规则这是解题的关键。对于本题“Love Math”一个典型的过滤规则可能是禁止了所有字母a-zA-Z和反引号“”但允许数字、部分数学运算符 - * / %、点号.、括号( )、空格以及一些其他符号如_、[、]、{、}。同时黑名单了system,exec,passthru,shell_exec,proc_open,pcntl_exec,eval,assert等函数名。如果过滤了所有字母我们如何构造出像system这样的函数名来执行命令呢这就是题目的核心考点。3. 核心绕过原理PHP中的动态函数与字符串构造当字母被禁用时我们无法直接写出system(‘ls’)。但PHP提供了强大的动态特性允许我们间接地调用函数。3.1 利用$_GET、$_POST等超全局变量$_GET、$_POST、$_REQUEST这些是PHP的预定义超全局数组其键名和值来源于HTTP请求。关键在于这些变量名本身不包含字母吗不它们包含字母和下划线。但是在PHP代码的字符串上下文或某些函数内部我们可以通过复杂的方式引用它们而不直接写出字母。更重要的思路是我们可以通过一个允许的传参方式将我们想要的函数名“传递”进去。例如如果我们能控制另一个参数比如?asystemblsc... 然后在c的参数值里通过某种方式拼接或调用$_GET[‘a’]和$_GET[‘b’] 就有可能实现RCE。3.2 利用PHP的字符串解析特性与函数调用PHP中如果一个变量后面跟着圆括号PHP会尝试将其值作为函数名来调用。例如$func ‘system’; $func(‘ls’); // 这等价于 system(‘ls’);所以我们的目标从“写出system这个单词”变成了“得到一个值为’system’的字符串变量”。3.3 从数字到字符串异或XOR与位运算这是本题最核心的技巧。既然允许数字和少数符号我们能否用它们构造出任意字符串答案是肯定的通过异或运算。在PHP中两个字符串进行异或运算^运算符并不是对字符串本身操作而是先将两个字符串的每个字符转换为其ASCII码然后对对应的ASCII码进行按位异或最后将结果再转换回字符。例如echo ‘A’ ^ ‘}’; // 输出字符 ‘.’计算过程A的ASCII是65二进制01000001}的ASCII是125二进制01111101。异或运算相同为0不同为1得到二进制00111100即十进制60对应字符。更有用的是如果我们能控制一个字符串与另一个固定字符串异或就可以“生成”我们想要的字符。但这里我们被限制了字母。另一个思路是数字本身也可以参与位运算并且数字可以转换成字符。在PHP中你可以用花括号语法{表达式}来执行表达式并获取其字符串结果。但花括号可能被过滤。另一个更直接的方法是利用PHP中将数字与字符串进行异或时PHP会将数字先转换为字符串。例如999 ^ ‘xyz’会导致Notice但如果我们用更可控的方式 我们可以构造形如(‘.’ ^ ‘’)的表达式。‘.’的ASCII是46‘’是6446^64110即字符’n’。但这里用到了字母‘.’和‘’它们可能被允许点号和at符号通常不在严格字母过滤中。然而题目如果过滤了所有字母‘.’和‘’这种字面量字符写法带单引号也可能被禁。我们需要找到一种完全不使用引号包裹的字母来生成字符串的方法。3.4 终极武器利用未定义常量与_GETPHP有一个特性使用一个未定义的常量时PHP会发出一个Notice并将该常量名当作一个字符串值使用。例如echo abc; // 如果abc未定义会输出 Warning: Use of undefined constant abc - assumed ‘abc’ 并且 echo 的内容就是字符串 ‘abc’。这个特性在open_basedir绕过等场景也常用到。但这里abc本身包含字母我们无法直接写在被过滤的c参数里。但是超全局变量$_GET、$_POST等是数组。在PHP中$_GET[x]可以写作$_GET[‘x’] 也可以写作$_GET[x]不带引号。在不带引号的情况下x会被当作一个常量。如果常量x未定义它就会被当作字符串’x’。所以$_GET[x]等价于$_GET[‘x’]。这里的关键是x作为数组下标没有用引号包裹因此它避开了对字母的过滤因为它不是一个字符串字面量而是一个“令牌”。PHP在解析代码$_GET[x]时会先尝试查找常量x找不到则将其降级为字符串’x’然后去$_GET数组中查找键名为’x’的值。因此我们可以通过$_GET[a]的方式来获取另一个GET参数a的值而这个值可以包含字母比如system因为它是从HTTP请求中传来的不在当前代码的字母过滤检查范围内。4. 构造无字母数字的Webshell结合以上原理我们可以构思最终的利用链目标在eval($_GET[‘c’])中让c参数的值最终能执行system(‘ls /’)。限制c参数的值中不能出现字母和反引号。计划在c参数中利用$_GET[x]的方式引入另一个参数比如a的值作为函数名再引入另一个参数比如b的值作为命令参数。一个最简单的尝试c$_GET[a]($_GET[b])asystembls但这里c的值是$_GET[a]($_GET[b]) 其中$_GET、a、b都包含字母无法通过过滤。我们需要找到$_GET的替代写法。$_GET是一个特殊的全局变量名我们无法直接构造。但是PHP的全局变量存在于一个叫做$GLOBALS的数组中。$GLOBALS[‘_GET’]可以获取到$_GET数组。然而$GLOBALS和‘_GET’也包含字母。这时就需要用到字符串构造技术。我们可以用允许的字符如数字、点、运算符通过异或运算拼出_GET这个字符串然后将其作为变量名去访问。4.1 构造“_GET”字符串假设我们允许的字符集是数字、( ) - * / . ^ | ~等。 我们可以找到一些字符通过异或得到我们想要的字符。例如‘{‘ ^ ‘’可以得到什么{的ASCII是123是60 123^6071即大写字母G。‘~’ ^ ‘)’可以得到什么~是126)是41 126^4187即大写字母W不对我们需要的是_GET。实际上更通用的方法是编写一个小脚本遍历所有允许的字符通常是非字母数字的可见字符寻找两两异或后能产生目标字符_,G,E,T的组合。例如一个经典的构造是以下字符在默认情况下通常不被严格过滤但具体取决于题目‘_’‘!’ ^ ‘~’(33 ^ 126 95)‘G’‘{‘ ^ ‘’(123 ^ 60 71)‘E’‘{‘ ^ ‘’(123 ^ 62 69) // 注意可能被HTML编码需要确认‘T’‘!’ ^ ‘E’(33 ^ 69 84) // 这里又用到了E形成了循环依赖需要换一个。寻找这样的组合需要耐心和脚本辅助。假设我们找到了以下组合仅为示例实际需验证_ ( ! ) ^ ( ~ ) G ( { ) ^ ( ) E ( { ) ^ ( ) T ( ! ) ^ ( E ) // 不行E不是基础字符。换一个T ( ( ) ^ ( O ) // O是字母不行。更可行的方法是利用多个字符的异或或者利用PHP中.连接符拼接字符。例如先构造出_和GET再拼接。但.连接符可能被允许。实际上在真实的解题过程中选手可能会利用网上公开的“无字母数字Webshell”生成工具或Payload。一个著名的Payload是c$_GET[_]($_GET[__])_system__ls但这个Payload里$_GET有字母。我们需要把$_GET用异或构造出来。假设我们最终构造出了字符串_GET并将其赋值给一个变量。例如我们让c的值为$__‘_GET’; // 但这里单引号和字母被禁行不通。我们需要在不使用引号和字母的情况下生成字符串_GET并存储。4.2 利用变量名和赋值PHP中变量名可以包含下划线和数字但不能以数字开头。我们可以使用$后面跟一个下划线_如$_ 这是一个合法的变量名且不包含字母。 我们可以通过一系列异或运算将结果赋值给$_那么这个$_的值就是字符串_GET。但赋值操作可能被允许。假设允许。那么构造过程可能如下// 假设我们通过某种方式得到了四个字符的异或结果拼接成‘_GET’ $__ (某种运算得到‘_’) . (某种运算得到‘G’) . (某种运算得到‘E’) . (某种运算得到‘T’); // 然后 $$__ 就相当于 $‘_GET’ 也就是 $_GET 数组。 // 那么 $$__[a] 就等价于 $_GET[‘a’]这里用到了PHP的可变变量Variable variables$$a表示变量$a的值作为另一个变量的变量名。如果$a的值是‘_GET’那么$$a就是$_GET。所以最终的利用链可以设计为在c参数中构造一段代码该代码能生成字符串‘_GET’并存入一个变量比如$__。然后通过$$__[a]来获取GET参数a的值函数名通过$$__[b]获取GET参数b的值命令。最后调用函数$$__[a]($$__[b])。而构造字符串‘_GET’的过程完全可以通过对非字母数字字符如~!#$%^*()_-[]{}|;:,.?等进行异或、取反、自增等运算来实现。例如利用取反运算符~ 在PHP中~‘a’会对字符‘a’的ASCII码按位取反。但‘a’是字母。我们可以利用UTF-8编码或其他方式。一个更巧妙的方法是$_~«á»;但á也是字母。一个更可靠的、完全使用非字母数字字符生成_GET的方法非常复杂通常需要借助工具生成。一个已知的经典Payload来自P神博客利用了${~”\xa0\xb8\xba\xab”}这种形式但这用到了十六进制转义序列可能依赖于PHP版本和配置。对于本题“Love Math”一个更可能的简化场景是题目并没有过滤$、{、}、[、]、_、数字以及一些运算符。这样我们可以直接使用$_GET但需要绕过对$_GET这个单词本身的过滤不对如果过滤了字母$_GET中的G、E、T是字母会被过滤。因此我们必须使用可变变量配合字符串构造。假设我们通过异或得到了字符串‘_GET’并存储在变量$__中。那么Payload可能长这样c$__((某种运算).(某种运算).(某种运算).(某种运算));$__$$__;$__[a]($__[b])asystembls但c参数的值里出现了a和b它们是字母可能被过滤。我们需要用其他非字母的键名比如数字或下划线组合。例如用_和__作为参数名。4.3 最终Payload构造与测试经过对题目环境的实际测试这是解题不可或缺的一步我们可能发现过滤规则并非绝对禁止所有字母而是禁止了大部分函数名和命令执行的关键字。也许$_GET、$_POST这些变量名本身没有被过滤因为它们是语言结构的一部分。或者过滤规则是黑名单模式只过滤了system、exec等函数名和反引号而$_GET不在黑名单中。如果是这种情况那么Payload就简单得多c$_GET[a]($_GET[b])asystembls我们需要验证$_GET是否被允许。如果$_GET被检测到字母G、E、T而被拦截我们可以尝试大小写变换$_getPHP数组键名大小写不敏感不对$_GET是超全局变量必须大写或者使用${‘_GET’}但引号被过滤。另一种绕过方式是使用$_REQUEST它包含了$_GET、$_POST、$_COOKIE但字母更多。如果$_GET被过滤我们回到可变变量的思路。网上有一个著名的无字母数字Webshell Payload?php $_‘’.[].‘’; // 得到 ‘Array’ $__$_; // $__ ‘Array’ $___$__[1]; // $___ ‘r’ (Array的第二个字符是’r’) // ... 通过自增运算符 $___ 等操作逐步得到其他字符最终拼出 ‘_GET’ 或 ‘system’。这个Payload利用了空数组[]转换成字符串‘Array’然后通过数组下标获取其中的字母‘A’,‘r’,‘r’,‘a’,‘y’再通过PHP的自增运算符$a对字符串操作时会按字母序递增来获得其他字母。例如$a‘a’; $a;得到‘b’。但这里用到了[]和‘’单引号单引号可能被过滤。不过点号.连接符和方括号[]可能被允许。如果允许点号那么‘’.[]会产生‘Array’。但‘’是空字符串字面量包含两个单引号单引号可能被过滤。我们可以用.连接两个未定义常量来产生字符串吗a.b会产生‘ab’如果a和b是未定义常量。但a和b是字母。这条路似乎很复杂。在实际的CTF比赛中对于“Love Math”这道题经过众多选手的实践其最终Payload往往比理论推导要简单因为出题人设置的过滤规则通常会有“后门”或故意留出的缝隙。5. 实战Payload与分步解析假设我们经过黑盒测试发现过滤规则如下过滤了system、exec、passthru、shell_exec、proc_open、pcntl_exec、eval、assert等函数名的直接出现。过滤了反引号“”。过滤了$、{、}吗通常不会否则无法写任何PHP代码。允许括号( )、数学运算符 - * / . ^ | ~、数字、空格、方括号[ ]、下划线_。一个可行的Payload可能是c$_GET[func]($_GET[cmd])funcsystemcmdls但我们需要确认$_GET是否被检测。如果$_GET被匹配到并过滤我们可以尝试用$_GET的十六进制表示绕过正则匹配或者用${_GET}在PHP中${‘_GET’}和$_GET是等价的但这里用了单引号。另一种方法是利用PHP的call_user_func函数它接受函数名作为字符串参数。我们可以通过$_GET传递函数名。但call_user_func本身也可能被过滤。如果我们发现$_GET没有被过滤那么最简单的Payload就是上面那个。但题目叫“Love Math”很可能要求我们使用一些数学运算或字符串操作来构造。一个更符合题目气质的Payload可能利用了base_convert函数。base_convert可以进行进制转换它本身是一个函数名但可能不在黑名单。我们可以用数字的进制转换来生成字符串。例如system这个字符串可以看作是一个三十六进制数因为包含字母。base_convert(‘system’, 36, 10)会将其转换为十进制数。但我们需要在Payload中生成‘system’这个字符串又回到了原点。我们可以反过来base_convert(某个十进制数, 10, 36)可以将十进制数转换为三十六进制表示的字符串。如果我们能找到一个十进制数其三十六进制表示正好是‘system’那么我们就可以用纯数字来表示这个函数名。计算system在三十六进制下的值s是28y是34s是28t是29e是14m是22。 十进制值 2836^5 3436^4 2836^3 2936^2 1436^1 2236^0。这个数字非常大约1.7e9但PHP可以处理。我们可以用base_convert(1751504350, 10, 36)来得到‘system’吗不对需要精确计算。实际上‘system’的三十六进制解释为十进制是hexdec(‘73797374656d’)不对那是十六进制。三十六进制需要专门计算。一个已知的转换是base_convert(‘hex2bin’, 36, 10)和base_convert(‘system’, 36, 10)在网上有现成的结果。例如‘system’的十进制可能是‘1751504350’这个数字需要验证。如果base_convert函数可用且数字和括号允许那么我们可以这样写$fbase_convert(1751504350,10,36); // $f ‘system’ $f(‘ls’);但这里$f...中的$f是字母可能被过滤。我们可以用$_作为变量名。那么一个可能的Payload是c$_base_convert(1751504350,10,36);$_($_GET[1])1ls这里$_GET[1]中的1是数字不是字母可能绕过过滤。base_convert本身是函数名包含字母需要确认它是否被过滤。如果base_convert被过滤此路不通。另一种数学方法是利用sin、cos、tan、log、exp等数学函数但这些函数名也包含字母。考虑到题目的名字“Love Math”很可能就是希望你用base_convert或类似的数学相关函数来构造字符串。这是CTF出题中一种常见的“非预期解”或“预期解”。5.1 最终利用链的构建假设我们经过测试发现过滤规则是黑名单过滤了常见的危险函数名system, exec等但base_convert、dechex、hexdec、sqrt等数学函数没有被过滤。同时允许使用$、_、数字、括号、方括号、点号、引号单双引号可能被过滤但反斜杠转义可能允许。那么我们可以设计如下利用链使用base_convert将十进制数转换为三十六进制字符串得到函数名‘system’。使用$_GET[x]获取命令参数x是数字如1。动态调用函数。Payload示例c$_base_convert(1751504350,10,36);$_($_GET[1])1cat /flag解释$base_convert(1751504350,10,36);执行后$_的值变为字符串‘system’。$_($_GET[1])等价于system($_GET[1])。1cat /flag将$_GET[1]设置为cat /flag。5.2 绕过base_convert过滤如果base_convert也被过滤了呢我们可以用其他方式生成字符串。例如利用hex2bin函数配合dechex。hex2bin函数将十六进制字符串转换为二进制字符串即普通字符串。我们可以将‘system’转换成十六进制‘73797374656d’。 然后hex2bin(‘73797374656d’)就得到了‘system’。 那么如何得到十六进制字符串‘73797374656d’而不使用字母我们可以用数字连接而成或者用dechex函数将十进制数转换成十六进制字符串。但dechex和hex2bin也包含字母。一个更通用的方法是利用PHP的位运算和字符串连接从允许的字符集中构造出‘_GET’然后使用可变变量。这通常需要编写脚本生成Payload。对于这道题在真实的比赛环境中选手通过测试最终使用的Payload可能是c$pibase_convert(37907361743,10,36)(dechex(1598506324));$$pi{abs}($$pi{acos})abssystemacosls这个Payload看起来复杂但原理是一样的base_convert(37907361743,10,36)可能产生‘_GET’或类似的关键字符串。dechex(1598506324)可能产生另一个字符串两者拼接后得到‘_GET’。$$pi{abs}是可变变量的另一种写法花括号语法等价于$_GET[‘abs’]。最后调用system(ls)。6. 总结与经验技巧这道“Love Math”题目是PHP代码审计和RCE绕过的经典案例它考察了以下几个关键点黑盒测试能力在没有源码的情况下通过输入输出推测后端代码逻辑和过滤规则。这是实战渗透测试的基本功。PHP语言特性深度理解eval函数的危险性。未定义常量当作字符串使用的特性。可变变量$$a的用法。字符串与位运算异或、取反生成任意字符的可能性。函数动态调用$func()。base_convert等函数在构造字符串上的妙用。字符编码与运算如何利用有限的字符集数字、符号通过数学运算生成所需的字母字符串。给CTF新手的几点建议从简单开始遇到过滤先测试最直接的Payload如?cphpinfo();观察错误信息。系统化测试过滤规则编写一个简单的脚本或者手动系统性地测试哪些字符、单词被禁止。了解是黑名单还是白名单。善用错误信息PHP的错误信息如果开启会泄露很多信息包括当前执行的代码行、被过滤的函数等。掌握经典Payload像无字母数字Webshell、利用$_GET传参、base_convert构造字符串、异或构造字符串等都是CTF中的常见技巧。理解其原理而不仅仅是背诵Payload。灵活运用搜索引擎和社区很多CTF题目是改编自已知漏洞或技巧。遇到卡壳时可以尝试用题目关键词如“Love Math ctf”、“无字母数字webshell”搜索往往能找到writeup解题报告或类似题目。但一定要自己理解每一步而不是照抄。搭建环境复现在本地Docker或PHP环境中复现题目可以让你自由测试各种Payload加深理解。对于这道题你可以自己写一个包含过滤规则的eval脚本然后尝试绕过它。最后CTF解题就像解数学题需要耐心、逻辑和一点点创造力。“Love Math”这道题完美地体现了这一点——它要求你将数学运算字符编码转换、位运算与PHP语言特性结合起来最终达成代码执行的目的。这种绕过限制、达成目标的思维过程正是网络安全中漏洞挖掘与利用的核心乐趣所在。