
很多人在做Web安全防护时可能都遇到过这类日志一段完全由符号和花括号组成的请求参数既不像正常业务数据也不像常见注入语句但后端服务器上却多了一个陌生的PHP文件。我第一次在客户环境中碰到这种请求时第一反应也是“这是不是采集器误报”。直到仔细看完那串字符的构成才发现它属于一类非常经典且难以通过简单规则识别的Webshell构造方式——没有任何字母和数字却完整执行了系统命令。这类技术看起来像是“魔改”出来的攻击手法但它背后的原理其实非常朴素一切程序最终都只是字节运算过滤规则拦住了字母和数字不代表字符就失去了表达能力。作为防守方我花了很长时间才把这类玩法的原理、常见变体和排查思路彻底梳理清楚。这篇文章就围绕“无字母数字Webshell”展开从原理层面拆解它的构造逻辑再落到流量侧和文件侧的检测方法上。不管你是刚接触安全的新人还是已经有几年防护经验的运维或开发按这条线走一遍至少能在下一次遇到“全是符号”的诡异请求时快速判断要不要报警。1. 为什么攻击者要抠字符从“字母数字被拦”说起1.1 过滤规则到底过滤了什么做过Web应用防护的人都知道很多网关和主机防护软件都有一个经典动作对请求参数做黑白名单过滤。黑名单通常包含关键字比如常见函数名、危险命令、文件操作关键字等白名单则更粗暴只允许字母、数字和少数几个符号进入后端。白名单模式看起来天衣无缝因为一旦开启理论上请求参数里连一个字母都不该出现更别提什么webshell了。可问题恰好出在这个逻辑上后端脚本语言在执行时并不会去检查“字符串原本长什么样子”它只关心运行到某个操作时内存里是什么值。所以攻击者的核心思路就变成——你拦住了我直接写字那我就用别的运算方式把字“算”出来。举个例子PHP在解析字符串时如果遇到特殊语法会先对字符做一次表达式求值再把这个求值结果当成下一个命令来处理。攻击者要做的就是把一段被过滤的字母变成一串不被过滤的符号再让符号在运行时“变回”字母。1.2 绕过的本质不是“绕过滤镜”而是“找到等价表达”很多初学者会误以为绕过滤镜就是把字符编码一下比如URL编码、Base64之类的。但真正基础的编码形式通常在防护规则里早就被考虑进去了而且解码后只要包含敏感关键字照样会被拦截。这里的核心差异是编码只是换了一种表示形式解码后还是原来的字符串而“等价表达”是让最终内存中的字符串在运行时才被生成出来静态规则很难预先判断这一串乱七八糟的符号到底会演变成什么。打个比方过滤规则就像门卫检查你的身份证你身份证上写着“张三”门卫就拦下你但你可以在门卫看不见的地方把身份证换成一张全是乱码的磁卡刷卡器读取后自动翻译成“张三”门卫根本看不懂这张卡。Webshell构造里执行的一切本质上就是这个“刷卡翻译”的过程。2. 核心原理字符限制下的“无中生有”2.1 异或运算用符号拼出可用字符首先要理解一个底层事实字符本身在计算机里存的只是一串字节数字字母“a”对应十进制97在二进制里是01100001。PHP和其他脚本语言一样支持按位异或运算符^。两个字符串做异或时会逐字节取出ASCII码做按位运算得到的值再转回字符。如果允许提交的字符集合里只有!、$、^之类的符号攻击者就可以从这些符号的ASCII码里通过异或组合出字母和数字。比如某个不可打印字符的ASCII值是255二进制11111111它和空字符0异或就得到255但如果两个符号的ASCII码分别是0x40和0x21异或出来的结果可能就是0x61也就是字母“a”。这类构造在外观上非常有辨识度一整段请求里全是可打印符号中间夹杂着^、%、引号和花括号。检测端看到这种pattern第一反应往往不是“这能执行命令”而是“这不就是乱码吗”于是大量安全告警被人工忽略。这也是这类技术多年来不断变种却依然有效的原因之一。2.2 取反运算用不可打印字符还原字母除了异或PHP还支持一元取反运算符~。取反的意思很直接把一个字节的每一位翻转1变00变1。比如十进制0的二进制是00000000取反后是11111111也就是255对应一个不可打印字符。这里有个非常巧妙的点如果把某个字符串取反得到的字符串里通常包含很多不可打印字符这些字符在HTTP请求里往往被编码成%XX的形式。而大多数过滤规则看的是“解码后的原始参数”一旦看到%80、%9F这类高位字节可能直接放行因为它们根本不在白名单里也不在黑名单关键字里。但PHP执行时会先对这些字节做取反运算重新还原成目标字符串。比如把~写在字符串前再把一段高位字节编码后的内容传给函数名位置最终运行的就是一个完全正常的函数名。这种方式比异或更简洁因为一行就能完成字符串生成但前提是中间环节允许出现高字节内容。2.3 自增变量让PHP自己“长出”大写字母异或和取反基本解决了“字母从哪里来”的问题但有些防护规则会把请求里的%和高字节字符一并拦截这时就需要另一种思路利用PHP的弱类型和变量自增。PHP中有一类特殊变量叫$_GET、$_POST、$_REQUEST它们本身就是数组。如果把$_GET放在双引号字符串里PHP会尝试把数组转换成字符串结果是字符串“Array”。接着利用字符自增的特性如果当前字符是“A”执行操作后就会变成“B”“Z”自增后会变成“AA”也就是PHP允许字符串跟随字母表规律自动增长。于是攻击者可以先构造一个包含“A”的变量然后通过一系列自增操作拼出任意大写字母组合。虽然这样写出来的payload非常长但在某些极端过滤环境下它确实能避开所有静态规则因为整个请求里看不到任何现成的字母关键字只有$_、、[]这些看起来完全无害的符号。2.4 特殊变量的组合管道式拼接无字母数字Webshell的构造很少只靠单一技巧更多的是一套组合拳。以PHP为例常见的做法是先利用$_GET或$_POST等超全局变量作为“字母种子”。再通过异或或取反生成目标函数名字符串比如assert、call_user_func_array、system等等。由于函数名是字符串运算出来的无法通过静态规则匹配接下来用一个动态调用方式把参数传进去。参数内容也可能是另一段通过异或或编码生成的字符串最终拼成完整命令。这里最值得关注的是“管道式”特征一次请求就像流水线一样上游产出字母下游消费字母。检测端如果只看单条规则很容易漏掉但如果把请求的“整体结构”拿去分析会发现它在极短的参数里塞了远超业务需求的运算密度这就是一个天然的可疑信号。3. 从原理到实战手工构造与执行流程3.1 构造一个可执行函数名为了把上面的原理串起来我们用一个常见的PHP场景来演示。假设防护规则拦截了请求参数中的字母和数字只放行符号。那么攻击者手头可用的原始字符就是类似!、(、)、^、%、{、}、$、_等。第一步要做的是选取两个或者多个符号让它们的ASCII值通过异或能算出目标字母。比如! ^ (我在本地环境实测时!的ASCII是330x21(的ASCII是400x28异或结果0x09是制表符不是想用的字母。所以需要调整组合直到算出来的值落在字母范围里。这个步骤没有捷径多数情况下是用脚本枚举所有可打印符号两两异或找出能生成字母和数字的组合表。我自己常用的做法就是把候选符号集放进去跑一遍全排列生成一张“符号组合到字符”的映射表后续构造都从表里挑。3.2 构造参数与调用链函数名构造好之后还需要解决调用参数的问题。如果目标是执行系统命令那命令本身里一定包含字母同样需要二次构造。常见的做法是先把命令字符串通过异或或取反生成到一个变量里再把这个变量作为动态函数的参数传进去。一个精简的流程可以写成用符号拼出_GET这个字符串然后通过$得到超全局变量。从请求参数中取真正的命令内容这个内容可以继续放在原请求里只是不经过过滤字母的位置。用动态函数语法执行前面生成出来的函数名把命令参数递进去。这里需要特别强调动态函数调用在PHP中非常灵活$funct如果等于字符串“system”$funct(whoami)就等于执行了system(whoami)。整个过程中静态检测规则可能在参数部分看到一个没有任何敏感词的字面量却拦不住运行时的函数调用。3.3 一个典型请求长什么样子下面给一个简化后的示例用于理解结构不直接作为可运行exp。实际场景里往往还会混合URL编码和填充噪点这里只保留核心骨架?php $_~; // 通过取反或异或生成的一段高位字节内容 $__~$_; // 取反还原成目标字符串比如system $___~; // 参数部分同样处理 $__($___); // 动态调用system(还原后的命令)在HTTP请求里这些不可打印字符会变成%80%9F之类的编码整个请求看起来就是一堆百分号和符号组合。我第一次在日志里看到时第一反应是“这像是扫描器在爆破编码”但连续出现多个类似条目后才意识到这是一段恶意脚本在尝试执行。3.4 相关payload对比表这里整理了一张常见方案的对比表方便快速把握各类玩法之间的差异构造方式原始特征典型标识主要绕过目标检测难度异或构造可打印符号^%XX%XX^%XX%XX关键字黑名单中取反构造高字节不可打印字符~%80%9F%...字母数字白名单中高自增拼接$_[]连续自增表达式全字符过滤高动态函数组合无直接敏感词动态调用结构静态特征匹配中高填充噪点大量注释/填充符长度异常流量规则检测低中从表里能看出一个规律越依赖运行时生成字符串静态检测难度越高但与此同时payload长度也会明显增加这反而成了流量侧一个可以关注的统计特征。4. 检测与排查如何把这些东西“揪出来”4.1 文件流量双视角检测思路先说结论单靠一条规则很难完全防住这类Webshell但把文件侧和流量侧的信号叠在一起就能显著提高检出率。文件侧的重点是看“新增脚本里有没有动态调用结构”。比如在服务器上设置对PHP文件的监控重点搜索preg_replace、assert、call_user_func等动态执行函数的位置同时看这些函数前面的参数是不是字符串拼接、运算或者$变量引用。一旦发现“函数名是变量”的情况就要归入可疑。流量侧则要关注请求参数的整体形态。比如正常情况下业务接口的参数名和值都有固定格式而这类攻击请求往往参数值里含有大量百分号编码的高位字节或者大面积的可打印符号异或表达式且没有业务字段特征。当这类请求同时伴随文件新增告警时基本可以锁定为一次Webshell投递尝试。4.2 绕过特征的规则示例如果要在防护设备上快速出一条临时规则可以组合以下几个条件请求参数中包含^且^两侧可见大量符号或十六进制编码。参数中出现连续自增表达式例如紧跟在变量或花括号后。参数中存在以%开头的高位字节如%80~%FF且请求正文不是上传二进制文件。同一请求中出现两个以上由动态语言函数名衍生的特征比如字符串被用~包裹再传给函数调用。这些规则单独看都会有误报但如果要求“同时命中两条以上”误报率会被压得很低。我在实际测试中用这套组合来过滤公开流量日志效果比单条高字节匹配规则要稳定得多。4.3 故障排查实录三个真实误判场景第一类误判是扫描器噪点。很多自动化扫描工具会生成大量随机编码请求里面也有%和符号但它们通常批量出现且请求路径高度相似。处理办法是把同一IP在短时间内的请求数做聚合如果数量级达到成百上千先归类为扫描噪点再单独抽几条看重放功能。第二类误判是高字节编码的业务数据。某些老旧系统在传输非UTF-8扩展字符时会把请求参数编码成高位字节看起来很像取反构造。这种情况需要结合业务字段名判断如果字段名是name、desc这类正规业务参数且值经过解码后能对应正常文本就不用优先告警。第三类误判是压缩或加密参数的合法接口。现在不少前后端分离项目会把整个请求体做加密全部参数变成一串不可读的高字节流。这种流量在网关层看起来非常像攻击但文件侧和CPU行为都没有异常。排查时需要和研发团队确认参数加密协议把这类接口在检测规则里单独加白。4.4 加固建议清单将web中间件升级到仍在维护的版本因为很多动态函数绕过问题在不维护的版本中难以彻底根治。在应用层禁用或限制执行命令的高危函数。PHP可以在配置文件中关闭显式系统调用业务不需要的功能宁可不用也不要给攻击者留着窗口。文件完整性监控必须开启尤其针对web目录下新增或修改的脚本文件。很多这样的事件在被发现时文件往往已经存在数天甚至数周。日志保留时间延长同时把关键请求日志同步到独立的日志分析系统方便事后回溯。我遇到的大多数实际案例最后定位靠的都是历史日志而不是实时告警。对上传目录执行“不解析脚本”策略让攻击者即使上传成功也最多得到一个静态文件无法触发解释器执行。5. 个人实操札记在排查这类Webshell的过程中我最深的感受是技术原理并不复杂难点在于如何在大量正常业务流量中找到那一丝不对劲的痕迹。很多攻击者并不是把payload写得特别“完美”而是把它混在噪音里让检测端无从下手。还有一点想提醒大家不要过度依赖任何一种规则哪怕是组合规则也有绕过的空间。攻击者可以改变编码方式、混入随机噪声、利用深度嵌套的括号结构都可以让特征再次模糊。比较好的做法是把“语法结构识别”“行为基线和文件监控”三层能力叠加起来构成纵深防御。我在实际使用中还有一个习惯每隔一段时间就把学习过程中整理好的构造案例拿出来做一次回归测试看看现有防护规则能不能命中那些旧样本。如果连去年的老样本都开始漏报了那说明规则池退化得厉害需要马上维护。这样坚持下来再遇到无字母数字这种变种时至少不会像第一次那样对着日志看半天也不敢下结论。