Web应用每接收一个参数,就可能引入一条被恶意构造的输入。过滤绕过的本质不是攻击者比防御者更聪明,而是防御链路中存在某一条最薄弱的判断。单层黑名单只识别固定特征,攻击者只要改变大小写、插入注释、使用编码或添加多余空白,就能让规则完全失效。要降低这种风险,必须把输入处理拆成多个独立步骤,并配合正则表达式做白名单约束。

单层过滤为什么总是被打穿
很多简单的过滤逻辑只把某些危险关键词替换为空字符串。攻击者面对这类防御时,第一反应就是让关键词不被精确匹配。比如过滤规则只拦截 <script>,攻击者可以提交 <ScRiPt>、<scr<script>ipt> 或者使用 HTML 实体编码后的 script。只要规则没有覆盖这些变体,原始过滤就会被直接绕过。这还只是字符层面的问题,如果应用在后续解析时再次解码,攻击者可以构造双重编码输入,让第一次过滤看到的是无害内容,第二次解码后才产生真实危险内容。
更深层的问题在于,单层过滤通常缺少明确的输入契约。它试图枚举所有不应该出现的字符或片段,而不是定义哪些字符才是允许的。黑名单的维护永远滞后于攻击手法,而且容易把正常业务数据误伤。例如用户昵称中确实可能出现单引号,攻击者也会利用单引号进行 SQL 注入,如果只按单引号做简单删除,业务受影响,注入仍可能通过宽字节或编码绕过。单层过滤无法同时兼顾安全性和可用性,这是它被反复打穿的根本原因。
多层过滤的正确设计顺序
多层过滤的核心思想不是堆叠多个黑名单,而是把输入从不可信状态逐步转换为符合业务约束的状态。每一层只处理一类风险,并且按照固定顺序执行。通常第一层做编码归一化,例如统一 UTF-8 编码、移除空字节、处理 URL 解码后的内容;第二层做结构化清洗,例如去掉控制字符、不可见字符和多余空白;第三层做白名单校验,使用正则表达式判断输入是否完全由允许的字符组成;第四层做长度限制,防止超长输入触发解析器异常或正则回溯问题。
顺序本身也是防御的一部分。如果在没有归一化之前就执行正则白名单,攻击者可以使用双重编码把危险内容隐藏在百分号编码中,正则只看到百分号和数字,从而放行。只有先解码并统一编码,再执行白名单,正则匹配的对象才是真实输入。另一个常见错误是先做 HTML 实体转义再做正则,这会把原本可能危险的内容改变成安全文本,但正则可能因为转义后的字符被放行,应用后续若再反转义,风险又回到输入链路。因此归一化、清洗、白名单、长度限制的先后关系不能随意调换。
下面是一段 PHP 多层过滤的示例,函数依次执行解码、空字节移除、白名单正则和长度判断。每个步骤返回失败时都应当终止处理,而不是继续带病传递。
function deepFilterInput($input) {
// 第一层:归一化URL解码
$input = rawurldecode($input);
// 第二层:统一编码并移除空字节
if (!mb_check_encoding($input, 'UTF-8')) {
return false;
}
$input = str_replace("\0", '', $input);
// 第三层:白名单正则,只允许字母、数字、下划线、短横线和点
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/D', $input)) {
return false;
}
// 第四层:长度限制
if (mb_strlen($input) > 64) {
return false;
}
return $input;
}
这段代码展示的是多层过滤的骨架。它没有试图删除任何危险关键词,而是先把输入还原成真实形态,再用白名单直接拒绝不符合格式的数据。如果业务允许中文、斜杠或 @ 符号,需要按实际契约扩展白名单字符类,但原则仍然是不接受契约之外的字符。
正则表达式在过滤中的关键用法与常见陷阱
正则表达式在多层过滤中适合做白名单校验,而不适合做危险特征枚举。用黑名单正则匹配 <script>、onerror 或者 javascript:,只要攻击者改变一个字符大小写、加入换行或使用全角符号,正则就可能失效。白名单正则把问题反过来:先定义允许的字符集合,再检查整个输入是否完全落在集合内。例如用户 ID 只允许字母和数字,那么 /^[a-zA-Z0-9]+$/ 就能直接排除包含 <、>、引号或空格的输入,无需枚举这些危险字符。
使用正则时必须注意锚定和修饰符。没有 ^ 和 $ 时,preg_match('/[a-zA-Z0-9]+/', $input) 只判断字符串中是否存在一段合法内容,攻击者可以在前后插入任意危险字符并成功通过。加上 $ 后还要注意换行绕过,因为某些语言中 $ 可以匹配字符串末尾的换行符之前。PHP 中可以用 D 修饰符让 $ 仅匹配绝对末尾,避免 abc\n<script> 一类输入被漏掉。类似地,校验邮箱或域名时也要对点号、反斜杠进行转义,防止正则元字符被误用。
另一个容易被忽略的问题是灾难性回溯。当正则包含多个重叠的量词时,例如 (a+)+$,攻击者提交一段很长的 aaaa...!,正则引擎可能尝试指数级路径,导致 CPU 长时间占用。多层过滤中的长度限制可以缓解这种风险,但根本做法是避免使用嵌套量词和过于宽松的通配符,尽量使用字符类和固定模式。下面的 JavaScript 示例展示了一个更安全的账号名校验正则,并对长度做了限制。
function isValidAccount(input) {
if (typeof input !== 'string') {
return false;
}
if (input.length > 32) {
return false;
}
// 只允许大小写字母、数字和下划线,且必须完整匹配
return /^[A-Za-z0-9_]+$/.test(input);
}
这段代码先做类型判断,再做长度限制,最后才用正则。正则没有嵌套量词,时间复杂度为线性。它使用了 ^ 和 $ 锚定,并且在 JavaScript 中 $ 不会匹配末尾换行,因此无需额外修饰符。这类写法适合放在服务端接口的入口处,与前面的 PHP 多层过滤思路保持一致。
综合防护示例与遗漏点
把多层过滤和正则组合起来之后,还要关注输出阶段。输入校验只能保证进入应用的数据符合预期,不能保证输出到 HTML、SQL 或命令行时不会产生注入。比如一个浏览记录展示功能,输入已经通过正则白名单,但输出时如果没有做 HTML 编码,仍然可能在页面中产生结构破坏。正确的做法是把输入校验和输出编码分成两个独立安全步骤,而不是混在同一个过滤函数里。
下面给出一个 Python 风格的检验流程,它同时涵盖归一化、白名单和输出编码准备,帮助理清责任边界。实际项目中还需要根据框架能力进行参数化查询、命令数组传参等处理。
import re
def normalize_and_validate(raw_value):
# 第一层:URL解码
value = raw_value
# 第二层:移除控制字符和空字节
value = re.sub(r'[\x00-\x1f\x7f]', '', value)
# 第三层:白名单只允许字母、数字、下划线、短横线、点和@
if not re.fullmatch(r'[A-Za-z0-9_\-\.@]+', value):
return None
# 第四层:长度限制
if len(value) > 128:
return None
return value
这个示例中 re.fullmatch 天然保证了完整匹配,不会出现只匹配局部而放行其余内容的问题。移除控制字符放在白名单之前,可以进一步压缩攻击面。需要特别说明的是,这里的 \x00-\x1f 反斜杠序列必须原样保留,它表示十六进制控制字符范围。如果在过滤链路中丢失反斜杠,正则语义就会完全改变。
多层过滤与正则也不是银弹。文件上传场景需要检查文件内容而不是只检查扩展名,富文本场景无法使用简单白名单,必须引入专业清洗库。每个输入点都应当有独立的契约,而不是所有参数共用同一套正则。只有把多层过滤嵌入到完整的请求处理流程中,并持续用绕过样本做回归测试,才能有效降低被穿透的概率。