一、输入验证与净化:守住程序的第一道门
说实话,我做了十几年安全审计,见过最多的漏洞,根源都在输入上。你想想看,程序就像一座城堡,输入就是城门。城门没守好,什么妖魔鬼怪都能进来。
今天咱们就聊聊输入验证与净化。这五个知识点,是我个人觉得最核心的:用户输入验证原则、SQL注入防御、命令注入防御、路径遍历防御、还有正则表达式的安全使用。
核心观点:永远不要信任任何外部输入。这不是偏激,是血的教训。
1.1 用户输入验证原则
我个人的习惯是:先验证,再使用。这个顺序千万别搞反了。
验证的核心原则其实就两条:
- 白名单优先于黑名单——允许什么,比禁止什么更重要
- 验证在服务端做——客户端的验证只是用户体验,不是安全
我的经验:曾经有个项目,前端做了很漂亮的输入校验,后端觉得"前端都验过了,我就不验了"。结果被人直接curl发包,绕过了所有前端校验。嗯,从那以后,我坚持服务端必须重新验证一遍。
常见的验证手段包括:
// 长度验证
if (input.length() > MAX_LENGTH) {
throw std::invalid_argument("输入过长");
}
// 类型验证
if (!std::isdigit(input[0])) {
throw std::invalid_argument("必须为数字开头");
}
// 格式验证(白名单)
const std::string allowed = "abcdefghijklmnopqrstuvwxyz0123456789_";
for (char c : input) {
if (allowed.find(c) == std::string::npos) {
throw std::invalid_argument("包含非法字符");
}
}
1.2 SQL注入防御
SQL注入,老生常谈的问题了。但你知道吗?我在2023年的审计中,依然能看到新项目犯这个错。
防御SQL注入,最有效的手段就是参数化查询。说白了,就是把数据和SQL语句分开,让数据库知道"这是数据,不是代码"。
警告:千万不要自己拼SQL字符串!哪怕你觉得"我做了转义",也架不住各种编码绕过。我见过太多自认为安全的转义方案被攻破的例子。
正确的做法:
// 错误示范 ❌
std::string sql = "SELECT * FROM users WHERE name = '" + userName + "'";
// 正确示范 ✅(使用参数化查询)
// 以MySQL Connector/C++为例
sql::PreparedStatement* pstmt = conn->prepareStatement(
"SELECT * FROM users WHERE name = ?"
);
pstmt->setString(1, userName);
sql::ResultSet* res = pstmt->executeQuery();
| 防御方式 | 安全等级 | 推荐度 |
|---|---|---|
| 参数化查询 | 高 | ⭐⭐⭐⭐⭐ |
| 存储过程 | 中高 | ⭐⭐⭐⭐ |
| 转义函数 | 低 | ⭐⭐ |
| 拼字符串 | 无 | ❌ |
1.3 命令注入防御
命令注入比SQL注入更危险。为什么?因为SQL注入最多拖库,命令注入能直接拿下服务器。
我记得有个案例:某系统用system()调用ping命令,参数是用户输入的IP。攻击者输入了 127.0.0.1; rm -rf /,结果你懂的。
防御原则:
- 能不调shell就不调——用系统API替代
- 必须调shell时,用白名单——只允许特定命令和参数
- 绝对不要用system()、popen()等函数直接拼接用户输入
// 错误示范 ❌
std::string cmd = "ping -c 1 " + userInput;
system(cmd.c_str());
// 正确示范 ✅
// 方案一:使用系统API(推荐)
// Windows: IcmpSendEcho
// Linux: 使用socket构造ICMP包
// 方案二:必须用shell时,做白名单验证
std::string allowedIP = "192.168.1.";
if (userInput.find(allowedIP) != 0) {
throw std::invalid_argument("IP不在允许范围内");
}
// 然后再调用,但依然有风险,只是降低了
核心原则:命令注入的防御,本质上是"最小权限"原则的体现。不给程序执行不该执行的命令的能力。
1.4 路径遍历防御
路径遍历,也叫目录穿越。攻击者通过 ../ 跳出限制目录,读取或写入不该碰的文件。
我曾经审计过一个文件下载功能,代码大概是这样的:
// 错误示范 ❌
std::string filePath = "/var/www/files/" + userInput;
std::ifstream file(filePath);
攻击者输入 ../../etc/passwd,就能读到系统密码文件。你说危不危险?
防御方法:
- 规范化路径——用realpath()或std::filesystem::canonical()解析出真实路径
- 检查路径前缀——确认解析后的路径在允许的目录下
- 拒绝特殊字符——如
..、/、\等
// 正确示范 ✅
#include <filesystem>
std::string baseDir = "/var/www/files/";
std::string fullPath = baseDir + userInput;
// 规范化路径
std::filesystem::path canonicalPath =
std::filesystem::canonical(fullPath);
// 检查是否在允许目录下
std::string canonicalStr = canonicalPath.string();
if (canonicalStr.find(baseDir) != 0) {
throw std::invalid_argument("非法路径");
}
避坑指南:我曾经见过一个项目,自己写了个"规范化"函数,结果没处理好符号链接。攻击者创建了个符号链接指向 /etc,照样绕过去了。所以,用标准库函数,别自己造轮子。
1.5 正则表达式的安全使用
正则表达式是个好东西,但用不好就是灾难。最常见的两个问题:
- ReDoS(正则表达式拒绝服务攻击)——构造特殊输入让正则引擎陷入灾难性回溯
- 匹配范围过大——本意是匹配邮箱,结果什么都能匹配上
为什么会发生ReDoS?说白了,就是正则引擎在匹配失败时,需要回溯尝试所有可能的分支。如果正则写得不好,输入又精心构造,回溯次数会呈指数级增长。
// 危险的正则 ❌(容易触发ReDoS)
std::regex dangerous("^(a+)+$");
// 输入 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" 会让引擎回溯到死
// 安全的写法 ✅
std::regex safe("^a+$");
// 没有嵌套量词,回溯可控
正则安全使用原则:
- 避免嵌套量词——如
(a+)+、(a*)*、(a|b)+等 - 设置超时——很多正则引擎支持超时设置,防止无限回溯
- 优先用简单字符串操作——能用find()解决的,别用正则
- 测试边界输入——用长字符串、特殊字符测试正则性能
我的建议:正则表达式不是万能的。很多场景下,手写一个简单的状态机,比正则更安全、更高效。我在做网络协议解析时,就经常这么干。
好了,以上就是输入验证与净化的核心内容。记住一句话:输入是漏洞的源头,守住了输入,就守住了一半的安全。
公众号:蓝海资料掘金营,微信deep3321