一、输入验证与净化:守住程序的第一道门

说实话,我做了十几年安全审计,见过最多的漏洞,根源都在输入上。你想想看,程序就像一座城堡,输入就是城门。城门没守好,什么妖魔鬼怪都能进来。

今天咱们就聊聊输入验证与净化。这五个知识点,是我个人觉得最核心的:用户输入验证原则、SQL注入防御、命令注入防御、路径遍历防御、还有正则表达式的安全使用。

核心观点:永远不要信任任何外部输入。这不是偏激,是血的教训。

输入验证与净化 用户输入验证原则 SQL注入防御 命令注入防御 路径遍历防御 正则安全使用 白名单优先 参数化查询 禁用shell调用 规范化路径 避免ReDoS 🔒 纵深防御,层层设防

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,就能读到系统密码文件。你说危不危险?

防御方法:

  1. 规范化路径——用realpath()或std::filesystem::canonical()解析出真实路径
  2. 检查路径前缀——确认解析后的路径在允许的目录下
  3. 拒绝特殊字符——如 ../\
// 正确示范 ✅
#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+$");
// 没有嵌套量词,回溯可控

正则安全使用原则:

  1. 避免嵌套量词——如 (a+)+(a*)*(a|b)+
  2. 设置超时——很多正则引擎支持超时设置,防止无限回溯
  3. 优先用简单字符串操作——能用find()解决的,别用正则
  4. 测试边界输入——用长字符串、特殊字符测试正则性能

我的建议:正则表达式不是万能的。很多场景下,手写一个简单的状态机,比正则更安全、更高效。我在做网络协议解析时,就经常这么干。

好了,以上就是输入验证与净化的核心内容。记住一句话:输入是漏洞的源头,守住了输入,就守住了一半的安全


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