在本地靶机上把“无数字字母getshell”这条路重新走了一遍,才发现这类RCE绕过题目的可怕之处不在技巧本身,而在它逼着你把语言执行机制想透。很多同学看到过滤规则拦掉了所有字母和数字,第一反应就是“命令都拼不出来,还怎么执行”。实际上恰恰相反,这类黑名单把字面量堵死了,却给符号运算留了巨大的操作空间。这篇文章我按照一次完整的复现过程,把过滤规则画像、shell通配符与特殊变量、PHP字符串运算、本地演示环境搭建、防御侧检测方案一次讲清楚。适合正在啃CTF Web题的选手、做代码审计的工程师,以及需要维护WAF规则的运维同学参考。所有验证都在本地模拟靶场和正规授权范围内完成。
1. 一次授权演练里撞见的“无数字字母”过滤规则
1.1 过滤规则的典型画像
那次遇到的模拟环境很单纯,入口是这样的一个PHP页面:
<?php if (isset($_GET['cmd'])) { $cmd = $_GET['cmd']; if (preg_match('/[a-z0-9]/i', $cmd)) { die("filtered"); } system($cmd); } ?>这个正则把大小写字母和数字全部拦掉,但允许$、(、)、;、~、%、'、"、?、*、[、]这些符号原样进入命令执行点。第一眼看上去接近“无解”,因为任何一条Unix命令都是由字母组成的,比如id、ls、cat,少了字母等于把命令的名字给没收了。
但注意,system($cmd)代表这个命令执行点是真实存在的。过滤规则能挡住我们直接在参数里写ls,却挡不住shell在运行时自己去“组合”出命令。在bash里,$0会展开成当前shell的名字,$IFS是默认分隔符,通配符?和*会按文件名规则自动补全。这些机制不会去检查请求参数里有没有字母,它们只负责在命令真正被执行之前,把字符串拼出来。
所以这道题的真正难点,不是“没有字母怎么写命令”,而是“没有字母的时候,还能不能触发这些隐式机制”。答案是可以,而且手法不止一种。
1.2 为什么“全过滤”不等于“真安全”
黑名单过滤的最大问题在于它假设了一个前提:攻击载荷里必须出现目标字符串。但命令执行环境本质上是解释器在做解析,解析过程包含海量的隐式类型转换和自动展开。换句话说,过滤规则看的是请求参数的“长相”,而system()最终执行的是解释器解析完成后的“结果”。
打个比方,门锁检查的是钥匙外形,结果有人用锡纸在锁芯里来回挑,门照样开了。符号、运算符、可变变量、通配符这些机制就相当于锡纸,它们不包含任何字母数字,却能通过运算生成字母数字。这个思路一旦建立起来,无数字字母的限制就只是纸老虎。
顺带说一句,很多代码审计报告里会把这种过滤标记为“低危”,理由是攻击者连函数名都传不进来。但实际上只要命令执行点存在,利用shell特性或者PHP动态特性拼出函数名只是时间问题,最终危害等级应该按RCE来评估。
2. 这类限制背后到底在防什么
2.1 代码过滤和WAF规则的区别
看这类问题先别急着绕,得先搞清楚过滤写在哪一层。代码层过滤往往很简单,就像上面的preg_match,它的作用域很明确:只处理进入system()之前的那个参数。WAF规则则是跑在web服务器前面的,它面对的是整个HTTP请求,包括URL、Header、请求体,过滤逻辑更依赖正则特征库。
这两种过滤的绕过空间完全不同。代码层过滤只过滤某几个输入点,其它参数、其它字段根本不管,这给了我们很大的“外带”空间:入口参数不放命令,只放一段组合逻辑,真正的命令从另一个没被过滤的字段取。而WAF虽然覆盖范围广,但它的正则很难覆盖所有动态调用写法,尤其是取反、异或、可变变量组合出来的函数名,在WAF眼里就是一串无意义的高位字节。
在真正做授权测试时,我通常第一件事就是确认过滤的作用域。如果只过滤GET参数里的cmd值,那POST体里的内容往往可以自由传递,这就是后面5.2节那个payload能够成立的关键。
2.2 过滤掉字母数字,反而暴露了入口
从防御者的视角看,这种过滤的出现通常意味着两件事:第一,这个点确实能够执行命令;第二,开发者或者规则维护者已经意识到这里有个洞,但选择了最低成本的修补方式,也就是加黑名单。加了黑名单之后,常规的id、whoami全被挡住,很多人就以为安全了。
但黑名单的缺陷也在这里:它挡住了常规路径,却把入口直接暴露给了愿意多走两步的人。看到/i这个大小写不敏感修饰符,说明对方考虑过大小写绕过;看到数字也被拦,说明对方考虑过编码绕过里的八进制、十六进制。可符号运算这条路,他们往往没有考虑。
所以与其说这类规则是防护,不如说它是一个“指示牌”:这里有命令注入,而且入口参数名我都告诉你了。真正严谨的防御方案是白名单校验加命令执行函数参数约束,而不是和攻击者玩字符捉迷藏。
3. 绕过的第一块拼图:shell通配符和特殊变量
3.1 通配符:用“模式”代替“命令”
先看一个很多人忽略的事实:system()最终把字符串交给/bin/sh -c去执行,而shell解析命令行的第一步就是做路径通配符展开。这意味着参数里写/???/???,shell会去文件系统里找匹配的路径,找到哪个就把哪个替换上去。
在常见的Linux发行版上,/???/???可能匹配到/bin/cat,也可能匹配到/bin/cut、/bin/env、/bin/awk之类的三字符命令。虽然不精确,但思路是对的:命令名不必由字母直接构成,它可以由通配符展开出来。
一个更可控的配合方式是利用shell的参数扩展和命令替换。比如${PATH}本身是一串以冒号分隔的目录路径,里面含有字母,但PATH这个变量名我们写不出来怎么办?可以通过$PATH里的内容配合${var:offset:length}提取字符,而offset和length又可以用$?这类特殊变量构造出来。一环扣一环,最后像搭积木一样把命令名还原出来。
在本地验证的时候,我经常先执行下面这类命令观察shell的展开行为:
echo /???/???输出会列出所有匹配的路径,看到那一瞬间你就明白,原来“命令名”完全可以是模糊匹配出来的结果。虽然单独的/???/???不能稳定指向某个唯一命令,但配合自建目录和同名文件,就能做到相对精确。
3.2 特殊变量:从“无数字”里算出数字
“无数字”很多时候是个伪命题,因为shell本身就有办法在没有数字字面量的情况下生成数字。$?保存上一条命令的退出码,默认是0;$#是参数个数,默认是0;$$是当前PID,通常是一个很大的数字。这些特殊变量的名字里没有字母数字,值却是纯数字。
更关键的是bash支持算术扩展和按位取反运算:
$((~$?))$?等于0,按位取反后等于-1。再用-1做算术组合,比如$((~$?*~$?))得到1,$((~$?*~$?-1))得到0,理论上可以推出任意整数。这类技巧在高阶RCE绕过里很常见,它解决的不只是“没有数字”的问题,还经常用来绕过WAF针对0x、\x、八进制这类编码特征的匹配。
明白了这一点,再看“无数字字母”这个限制,其实它只限制了我们直接写字符的能力,并没有限制解释器内部生成字符的能力。特殊变量、通配符、算数扩展,都会在我们看不见的地方把需要的字符“算”出来,这就是RCE绕过里最迷人的部分。
4. 真正的重头戏:PHP字符串运算如何“无中生有”
4.1 取反运算符:一个字节还原一条命令
在PHP里,字符串本质上就是字节数组,而大多数运算符都能作用于字节。~这个按位取反运算符,对一个字节取反后得到的是另一个字节。于是只要选好不可打印的高位字节,取反之后就能得到可打印的字母。
在本地命令行里可以直接验证:
php -r '$a=~"%9E%8C%8C%9A%8D%8B"; var_dump($a);'输出结果是string(6) "assert"。也就是说,payload里只要写~%9E%8C%8C%9A%8D%8B,PHP在运行时就会得到assert这个函数名。由于请求参数经过URL解码,%XX会被还原成对应的字节再参与取反,所以请求里看起来全是%和波浪号,实际执行时却变成了可读的英文字母。
用同样的办法,system、_GET、_POST这些字符串都可以通过取反得到。我在本文后面会放一个生成器脚本,帮助验证任意字符串对应的取反结果,不用手工去算ASCII码。
4.2 字符串异或:两个特殊字符凑出目标字母
除了取反,PHP字符串异或^也是无字母绕过里的常客。两个字符串执行异或时,是逐字节按位异或。比如一个符号字节0x23和另一个符号字节0x40异或,得到0x63,也就是字符c。写出来的payload可能是("#"^"@"),这里面没有字母数字,但运算结果直接得到了一个字母。
两个技巧搭配起来,几乎所有英文字母都能构造出来。之所以取反更省事,是因为它只需要一个源字节;而异或需要两个源字节,并且两个源字节都不能触发过滤规则。但异或的好处是构造空间更大,在不同字符集或代码页环境下,可以灵活挑选不会被过滤的符号对。
严格来说,这类“字符串运算还原字符串”的技巧,依赖的是PHP对字符串类型比较宽松的处理方式。很多从Java、C#转过来的开发者会觉得不可思议,因为强类型语言里根本不可能把字符串和运算符混在一起用。但在PHP的代码审计里,这属于必须掌握的基础特性。
4.3 可变变量:让请求参数当函数名和参数字典
取反只能还原字面量字符串,但我们还需要跑起来一条完整的调用链。PHP的可变变量机制解决的就是这个问题:$$a会根据$a的值去取一个同名变量。
经典用法是:
$_ = ~"%9E%8C%8C%9A%8D%8B"; // "assert" $__ = ~"%A0%AF%B0%AC%AB"; // "_POST" $___ = $$__; // 等价于 $_POST $_($___['_']); // 等价于 assert($_POST['_'])在这段代码里,变量名全是下划线组合,取反串全是URL编码,整个payload的字面量部分没有出现任何字母或数字。但程序运行后,$_变成了assert,$__变成了_POST,$___拿到了$_POST这个超全局数组,最后从POST参数里取出_字段的值交给assert去执行。
这就是上面说的“外带”思路。入口参数只负责组装逻辑,真正的攻击载荷放在POST体里。只要过滤规则只作用在cmd参数值上,POST体的内容就可以是任意字符串。
5. 在本地靶机完整跑一遍无数字字母getshell
5.1 搭建模拟执行点
为了把上面这些原理串起来,我建议你也在本地起一个靶机。用PHP 5.6环境的兼容性最好,因为新版PHP对动态调用和未定义常量越来越严格,演示老技术路线容易踩版本坑。
先准备两个文件,第一个是模拟执行点,也就是上面提到的那段cmd.php。第二个是一个生成器脚本,用来生成任意字符串的取反形式:
<?php function gen($str) { return "~" . urlencode(~$str); } echo "assert => " . gen("assert") . "\n"; echo "_POST => " . gen("_POST") . "\n"; ?>本地启动:
php -S 127.0.0.1:8000访问http://127.0.0.1:8000/cmd.php?cmd=id,会看到filtered,说明过滤规则生效。访问cmd.php?cmd=whoami也会被拦。到这里,常规路径已经堵死。
5.2 生成并拆解最终payload
运行生成器脚本,得到两个关键字符串:
assert对应~%9E%8C%8C%9A%8D%8B_POST对应~%A0%AF%B0%AC%AB
然后构造这样一个GET请求:
/cmd.php?cmd=$_=~%9E%8C%8C%9A%8D%8B;$__=~%A0%AF%B0%AC%AB;$___=$$__;$_($___['_']);同时在POST体里加一个字段:
_=phpinfo();整个请求在抓包工具里看是这样的:
GET /cmd.php?cmd=$_=~%9E%8C%8C%9A%8D%8B;$__=~%A0%AF%B0%AC%AB;$___=$$__;$_($___['_']); HTTP/1.1 Host: 127.0.0.1:8000 Content-Type: application/x-www-form-urlencoded _=phpinfo();逐段拆开看:
$_=~%9E%8C%8C%9A%8D%8B:把assert字符串赋给变量$_$__=~%A0%AF%B0%AC%AB:把_POST字符串赋给变量$__$___=$$__:取名为_POST的变量,也就是拿到$_POST数组$_($___['_']):调用assert(),并传入$_POST['_']的内容
整个GET参数里确实没有一个字母数字,全部由$、_、~、%、分号和单引号构成。但它在解释器里完成了三次赋值和一次动态函数调用。
如果你不想用PHP 5.6,希望在PHP 7上面跑,要注意两件事:一是assert在PHP 7里被改成了语言构造,很多版本还能接受动态调用,但行为不一致;二是未定义常量在PHP 7.2以上会报deprecated,PHP 8里未定义常量直接就是Error。所以演示环境我强烈建议固定到PHP 5.6,逻辑最干净。
5.3 复现时最容易翻车的三个细节
第一,可变变量和数组下标连写时,PHP对$$a['b']的解析顺序比较特殊,它会把$a['b']先取出来,再把这个值当作变量名。所以要表达“取名为$a的数组的第b个元素”,习惯上应该用花括号把这个可变变量明确包起来。我们这里的写法是$___=$$__;,单独一行没有歧义,但如果写成复合表达式,建议改成${$__}这种形式。
第二,要确认过滤到底作用在哪一层。我在本地靶机里故意写成只过滤$_GET['cmd'],所以POST体可以自由携带载荷。真实环境如果对全部输入统一过滤,那么前面这套写法就需要再加一层取反来还原POST键名,整体复杂度会明显上升。动手之前先花五分钟确认作用域,能省两小时无效劳动。
第三,PHP版本差异是个坑,尤其体现在assert和未定义常量上。建议在本地用固定版本测试,不要直接在线上试错。我自己的习惯是把生成器脚本、靶机页面、最终验证请求这三样都放在一个最小化目录里,改一次版本就重跑一遍回归,确保演示结果和记录一致。
6. 反推防御:如何从日志里嗅出这类攻击
6.1 请求特征
无数字字母payload在日志和数据包里非常扎眼。正常用户不会在参数里提交一大段~%9E%8C%8C%9A%8D%8B加下划线变量名,更不会在POST字段里放phpinfo();。我总结了几条最值得关注的特征:
- 参数值中高位字节集中出现,
%80到%FF区间占比极高。 - 大量波浪号
~直接跟在%XX编码串前面,这是取反运算的标志。 - 同一参数值里出现多个
$_、$__、$___连续下划线变量名,同时又没有普通字母数字。 - 一个请求里既有GET构造环境的分段逻辑,又有POST体携带真正要执行的内容,两个包配合紧密。
- 参数值中分号、单引号、括号密集出现,但没有任何可读英文单词。
把这些特征汇总到WAF或者日志搜索里,会得到很高的命中率。尤其是“取反+高位字节”这种组合,几乎不可能由正常业务产生。
6.2 加固方向
防御侧不建议继续堆黑名单,因为符号运算是解释器特性的组合,黑名单根本列不完。更实际的做法是这几条:
第一,对命令执行类函数的参数做白名单约束。比如system()、exec()、passthru()、shell_exec()这些函数,参数应该限定为固定格式的动态生成路径或者纯数字ID,不让任意字符串直接进入。
第二,在PHP运行环境里做收敛。关闭assert,把disable_functions里能加的执行类函数都加上;同时确认display_errors关闭,避免报错信息把变量结构泄漏给攻击者做下一步推断。
第三,引入RASP层的AST检测。代码层过滤看的是字符串,AST检测看的是语法结构。取反运算、可变变量、动态函数调用,这些结构即使在参数里没有字母数字,也会在语法树里留下清晰的调用链,这是正则很难覆盖的检测维度。
第四,请求日志要保存原始参数,不要只记录“是否命中规则”。很多攻击行为是缓慢探测的,第一次请求可能只是phpinfo(),第二次才尝试命令执行。原始日志能帮助安全运营人员把前后几次请求关联起来,还原整条攻击链。
我在实际维护规则的时候,收到过不少看起来像乱码的告警,人工一翻原始日志才发现全是这类无字母数字payload。把特征归纳清楚之后,这类探测在WAF层就能被拦截大半。
最后说点个人体会。第一次在本地靶机把这个payload拼通的时候,我其实栽在了一个很蠢的地方:变量名里习惯性写了个x,结果一提交就被过滤,我盯着回显看了半天才反应过来,过滤规则不看你运行时生成什么,只看你提交了什么。后来所有变量统一用下划线,问题才消失。所以做这类绕过实验,最重要的习惯是时刻把自己代入“过滤器视角”:每一个字面量都要检查,能不能通过替换成符号运算来避免。把这个习惯练出来之后,你再去看那些WAF日志里的高位字节和波浪号,一眼就能认出它们在干什么。