77、SQL注入防御:参数化查询、输入验证、白名单过滤、C语言安全编码实践
说实话,SQL注入这玩意儿,我入行头三年就栽过跟头。那时候刚接手一个嵌入式数据库项目,用户输入个用户名,我直接拼SQL字符串就往上怼。结果测试同事在用户名框里输入了 ' OR 1=1 --,整个用户表全给拉出来了。嗯,从那以后,我对SQL注入的敬畏心就刻进骨子里了。
今天咱们就聊聊,在C语言里怎么把SQL注入防得死死的。说白了就三板斧:参数化查询、输入验证、白名单过滤。这三招用好了,你的数据库基本就焊死了。
一、SQL注入到底是怎么发生的?
先看个反面教材。我见过不少新手这么写:
char sql[256];
sprintf(sql, "SELECT * FROM users WHERE name='%s' AND pwd='%s'", username, password);
db_execute(sql);
你想想看,如果用户输入的 username 是 admin' --,那SQL就变成了:
SELECT * FROM users WHERE name='admin' --' AND pwd='xxx'
-- 后面的全成了注释。攻击者连密码都不用知道,直接以admin身份登录。这就是SQL注入的典型套路——把用户输入当成了SQL代码来执行。
二、第一道防线:参数化查询
我个人习惯,只要涉及数据库操作,第一选择就是参数化查询。这玩意儿是防御SQL注入最根本的手段。
在C语言里,不同的嵌入式数据库API略有差异,但思路完全一致。以SQLite为例:
sqlite3_stmt *stmt;
const char *sql = "SELECT * FROM users WHERE name=? AND pwd=?";
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
sqlite3_bind_text(stmt, 1, username, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, password, -1, SQLITE_STATIC);
while (sqlite3_step(stmt) == SQLITE_ROW) {
// 处理结果
}
sqlite3_finalize(stmt);
看到 ? 占位符了吗?数据库引擎会把 ? 位置的内容当作纯数据,而不是SQL代码。哪怕你传进去 ' OR 1=1 --,它也只是个普通的字符串值,不会触发任何注入。
三、第二道防线:输入验证
参数化查询虽然能防住注入,但你不能保证所有场景都能用参数化。比如动态表名、动态列名,这些没法用 ? 占位符。这时候就需要输入验证上场了。
输入验证的核心思路:只接受符合预期格式的数据。我常用的验证手段有:
- 类型检查:如果字段应该是整数,就用
strtol()转换并检查返回值 - 长度限制:用户名不超过32字符,密码不超过64字符
- 字符集白名单:只允许字母、数字、下划线
- 格式校验:邮箱、IP地址、日期等用正则或自定义函数校验
看个实际例子:
int validate_username(const char *input) {
if (input == NULL) return -1;
size_t len = strlen(input);
if (len == 0 || len > 32) return -1; // 长度限制
for (size_t i = 0; i < len; i++) {
char c = input[i];
// 只允许字母、数字、下划线
if (!isalnum(c) && c != '_') {
return -1; // 非法字符
}
}
return 0; // 验证通过
}
我曾经在一个工控项目中,遇到过有人试图在设备ID字段里塞入 ; DROP TABLE logs; --。幸好输入验证直接把它拦在了门外,返回了"非法字符"错误。嗯,这种时候你就会觉得,多写几行验证代码真值。
四、第三道防线:白名单过滤
白名单过滤,说白了就是只允许你明确说"可以"的东西。跟黑名单不同,黑名单是"不允许这些",白名单是"只允许这些"。后者安全得多。
白名单最典型的应用场景是动态表名和列名。比如你的系统允许用户按不同字段排序:
const char *valid_columns[] = {"id", "name", "age", "create_time"};
int num_columns = 4;
const char* get_safe_column(const char *user_input) {
for (int i = 0; i < num_columns; i++) {
if (strcmp(user_input, valid_columns[i]) == 0) {
return valid_columns[i]; // 返回白名单中的值
}
}
return NULL; // 不在白名单中,拒绝
}
使用时:
const char *col = get_safe_column(user_sort_field);
if (col == NULL) {
// 记录日志,返回错误
log_security_event("非法排序字段尝试: %s", user_sort_field);
return -1;
}
// 安全拼接SQL
char sql[128];
snprintf(sql, sizeof(sql), "SELECT * FROM items ORDER BY %s", col);
五、知识体系总览
下面这张图,是我自己总结的SQL注入防御知识体系。你可以把它当成一个检查清单,每次写数据库代码时对照着看:
六、C语言安全编码实践清单
最后,我把自己多年积累的安全编码习惯整理成了一张表。每次写数据库代码前,我都会过一遍:
| 实践项 | 具体做法 | 避坑提醒 |
|---|---|---|
| 使用参数化查询 | 所有SQL语句都用 ? 占位符 + sqlite3_bind_* 系列函数 |
别偷懒用 sprintf 拼SQL,哪怕只是拼接一个字段 |
| 输入长度检查 | 每个字符串输入都检查长度,拒绝超长输入 | 我曾经遇到过缓冲区溢出 + SQL注入的组合攻击,教训深刻 |
| 字符集白名单 | 只允许字母、数字、下划线等安全字符 | 别用黑名单过滤,总有你没想到的特殊字符 |
| 错误信息处理 | 数据库错误不要直接返回给用户,记录日志即可 | 攻击者能从错误信息中推断数据库结构 |
| 最小权限原则 | 数据库连接只用必要的权限,比如只读账号不要给写权限 | 即使被注入,攻击者也做不了太多破坏 |
| 日志记录 | 记录所有可疑的SQL操作,包括非法输入尝试 | 日志要包含时间、来源IP、操作内容,方便事后追溯 |
好了,SQL注入防御的核心内容就这些。说白了就是三句话:能用参数化就用参数化,不能用的就做输入验证,再不行就上白名单。这三层叠在一起,你的数据库基本就固若金汤了。
下次写数据库代码的时候,不妨多花几分钟想想:我这个输入,有没有可能被攻击者利用?如果答案是"有可能",那就老老实实把防御代码加上。安全这事儿,从来都是防患于未然。