第二十四章:输入验证与净化——所有输入都是不可信的

各位同学,今天我们来聊一个老生常谈、但又永远有人踩坑的话题——输入验证。

我做了十几年安全相关的开发,见过太多因为“相信用户输入”而翻车的案例。说白了,所有输入都是不可信的。这不是偏执,这是安全工程师的基本素养。

24.1 为什么输入验证如此重要?

你想想看,你的程序接收了外部数据——来自用户键盘、网络请求、文件读取、环境变量……这些数据你控制不了。攻击者可以精心构造一个字符串,让你的程序崩溃、泄露数据、甚至执行任意代码。

我记得有一次,一个同事写的日志模块,直接把用户输入的字符串拼接到SQL查询里。结果呢?一个用户在用户名里输入了 ' OR 1=1 --,整个用户表被拖走了。嗯,这就是典型的注入攻击

所以,输入验证不是“锦上添花”,而是“保命底线”。

核心原则: 永远不要信任任何外部输入。验证、净化、再使用。

24.2 白名单 vs 黑名单:两种验证哲学

验证策略分两种:白名单和黑名单。我个人习惯优先用白名单,原因很简单——你知道什么是安全的,比你知道什么是不安全的,要容易得多

24.2.1 黑名单验证

黑名单的思路是:列出所有“不允许”的内容,然后拒绝它们。

// 黑名单示例:拒绝包含 SQL 关键字的输入
int is_blacklisted(const char *input) {
    const char *bad[] = {"'", "\"", "--", "OR", "AND", "DROP", "UNION"};
    for (int i = 0; i < sizeof(bad)/sizeof(bad[0]); i++) {
        if (strstr(input, bad[i]) != NULL) {
            return 1; // 发现恶意内容
        }
    }
    return 0;
}

但问题来了——你永远无法穷举所有恶意模式。攻击者可以编码、混淆、用大小写绕过。我曾经见过一个黑名单,漏掉了 UNION ALL 这种变体,结果被利用了。

警告: 黑名单永远有遗漏的风险。不要把它作为唯一的防御手段。

24.2.2 白名单验证

白名单的思路是:只允许“确定安全”的内容,其他一律拒绝。

// 白名单示例:只允许字母、数字、下划线
int is_whitelisted(const char *input) {
    for (int i = 0; input[i] != '\0'; i++) {
        if (!isalnum(input[i]) && input[i] != '_') {
            return 0; // 发现非法字符
        }
    }
    return 1;
}

你看,这个逻辑就清晰多了。我只允许字母、数字和下划线,其他的一律挡在外面。攻击者想注入特殊字符?门都没有。

我个人在项目中,90% 的场景都用白名单。只有极少数情况(比如需要过滤 HTML 标签但又允许部分标签时)才会考虑黑名单,而且必须配合其他手段。

24.3 正则表达式安全:小心 ReDoS

正则表达式是验证利器,但用不好就是一把双刃剑。你听说过 ReDoS(正则表达式拒绝服务攻击) 吗?

攻击者可以构造一个特殊的输入,让你的正则引擎陷入灾难性的回溯,CPU 飙升,程序卡死。

举个例子,这个正则看起来没问题:

^(\w+)+$

但如果输入是 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab,会发生什么?

嗯,正则引擎会尝试所有可能的组合来匹配 (\w+)+,导致指数级回溯。我亲眼见过一个线上服务因为这个被打挂,排查了半天才发现是正则的问题。

避坑指南: 我曾经在写邮件地址验证时,用了一个嵌套量词的正则,结果测试数据一多就卡死。后来改用 strchr 配合简单检查,反而更安全。

安全使用正则的几个原则:

  • 避免嵌套量词:像 (a+)+(a*)* 这种,能不用就不用
  • 设置超时:在 C 语言中可以用 alarm() 或线程超时机制
  • 优先用简单字符串函数:能用 strspnstrcspn 解决的,就别上正则
  • 测试边界:用长字符串、重复字符测试你的正则性能

24.4 数值范围检查:别让整数溢出坑了你

输入验证不只是字符串的事。数值输入同样需要严格检查。

我记得有一次,一个嵌入式设备的代码里,用户输入了一个温度值,直接赋值给 int8_t 类型的变量。结果用户输入了 200,变量溢出变成了 -56,设备直接误判为低温报警……

数值检查要关注三点:

  1. 类型范围:确保值在目标类型的合法范围内
  2. 业务范围:比如年龄不能是负数,温度不能超过物理极限
  3. 溢出风险:运算过程中可能产生中间溢出
// 安全的数值范围检查
int safe_int_input(const char *input, int min, int max) {
    char *endptr;
    long val = strtol(input, &endptr, 10);
    
    // 检查转换是否成功
    if (*endptr != '\0' || endptr == input) {
        return -1; // 非法输入
    }
    
    // 检查范围
    if (val < min || val > max) {
        return -1; // 超出范围
    }
    
    // 检查是否溢出 long 范围
    if (val > INT_MAX || val < INT_MIN) {
        return -1;
    }
    
    return (int)val;
}
关键点: 使用 strtol 而不是 atoi,因为 strtol 可以检测转换错误和溢出。

24.5 输入净化的常见陷阱

净化(Sanitization)和验证(Validation)是两回事。验证是“检查是否合法”,净化是“把不合法变成合法”。

但净化很容易出问题。我举几个例子:

  • 转义不彻底:只转义了单引号,没转义反斜杠,结果攻击者用 \' 绕过了
  • 二次编码:输入 %27(URL 编码的单引号),如果先解码再检查,可能漏掉
  • Unicode 攻击:用全角字符、零宽空格绕过黑名单

我个人建议:能不净化就不净化,能用白名单就用白名单。如果必须净化,一定要在正确的阶段、用正确的方式做。

24.6 知识体系总览

下面这张图总结了本章的核心逻辑:

输入验证与净化知识体系 所有输入不可信 验证策略 白名单:只允许安全内容 黑名单:拒绝已知恶意内容 正则表达式安全 避免嵌套量词 (a+)+ 设置超时机制 数值范围检查 类型范围 + 业务范围 使用 strtol 避免溢出 验证 + 净化 = 安全防线

24.7 实战建议总结

最后,我把自己多年积累的经验浓缩成几条建议:

场景 推荐做法 常见坑
字符串输入 白名单 + 长度限制 忘记检查 null 终止符
数值输入 strtol + 范围检查 atoi 不检测溢出
文件路径 禁止路径遍历字符(../) Unicode 编码绕过
SQL 查询 参数化查询,绝不拼接 转义不彻底
正则表达式 避免嵌套量词,设超时 ReDoS 攻击
我的习惯: 每写一个接收外部输入的函数,我都会先问自己三个问题——这个数据从哪里来?它可能包含什么恶意内容?我有没有在入口处就把它拦住?养成这个习惯,能帮你避免 90% 的安全问题。

好了,关于输入验证和净化,今天就聊到这里。记住一句话:信任是安全的天敌。你的程序越不相信外部输入,它就越安全。


公众号:蓝海资料掘金营,微信deep3321