第二十四章:输入验证与净化——所有输入都是不可信的
各位同学,今天我们来聊一个老生常谈、但又永远有人踩坑的话题——输入验证。
我做了十几年安全相关的开发,见过太多因为“相信用户输入”而翻车的案例。说白了,所有输入都是不可信的。这不是偏执,这是安全工程师的基本素养。
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()或线程超时机制 - 优先用简单字符串函数:能用
strspn、strcspn解决的,就别上正则 - 测试边界:用长字符串、重复字符测试你的正则性能
24.4 数值范围检查:别让整数溢出坑了你
输入验证不只是字符串的事。数值输入同样需要严格检查。
我记得有一次,一个嵌入式设备的代码里,用户输入了一个温度值,直接赋值给 int8_t 类型的变量。结果用户输入了 200,变量溢出变成了 -56,设备直接误判为低温报警……
数值检查要关注三点:
- 类型范围:确保值在目标类型的合法范围内
- 业务范围:比如年龄不能是负数,温度不能超过物理极限
- 溢出风险:运算过程中可能产生中间溢出
// 安全的数值范围检查
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 知识体系总览
下面这张图总结了本章的核心逻辑:
24.7 实战建议总结
最后,我把自己多年积累的经验浓缩成几条建议:
| 场景 | 推荐做法 | 常见坑 |
|---|---|---|
| 字符串输入 | 白名单 + 长度限制 | 忘记检查 null 终止符 |
| 数值输入 | strtol + 范围检查 | atoi 不检测溢出 |
| 文件路径 | 禁止路径遍历字符(../) | Unicode 编码绕过 |
| SQL 查询 | 参数化查询,绝不拼接 | 转义不彻底 |
| 正则表达式 | 避免嵌套量词,设超时 | ReDoS 攻击 |
好了,关于输入验证和净化,今天就聊到这里。记住一句话:信任是安全的天敌。你的程序越不相信外部输入,它就越安全。
公众号:蓝海资料掘金营,微信deep3321