22、Web服务与CGI安全:CGI环境变量注入、HTTP头注入、安全的CGI编程
CGI,也就是通用网关接口。说白了,它是Web服务器和外部程序之间的一座桥。你想想看,用户点个按钮、提交个表单,服务器怎么知道该干啥?就是靠CGI把请求转给后台程序,再把结果返回给浏览器。
这座桥要是没修好,攻击者就能顺着它摸进来。我在项目中见过太多因为CGI代码写得糙,导致服务器被日穿的案例。今天咱们就把CGI安全这块掰开揉碎,好好聊聊。
核心观点:CGI程序运行在Web服务器上下文中,拥有文件读写、命令执行等权限。一旦被注入攻击,后果就是服务器沦陷。
一、CGI环境变量注入——攻击者最爱的入口
Web服务器在调用CGI程序前,会把请求信息塞进环境变量。比如QUERY_STRING、PATH_INFO、CONTENT_TYPE这些。CGI程序直接从环境变量里拿数据用,问题就出在这儿。
攻击者可以构造特殊的请求,让环境变量里带上恶意内容。如果CGI程序没做过滤,直接把这些内容拼到命令里执行,那就完蛋了。
典型案例:我曾经审计过一个用C写的CGI程序,它从QUERY_STRING里取参数,然后拼到system()调用里执行系统命令。攻击者传个?cmd=ls;rm -rf /,服务器就炸了。
看个有问题的代码示例:
// 有漏洞的CGI代码
#include <stdlib.h>
#include <string.h>
int main() {
char *query = getenv("QUERY_STRING");
char cmd[256];
// 直接拼接,没有过滤!
snprintf(cmd, sizeof(cmd), "echo %s > /tmp/result.txt", query);
system(cmd);
return 0;
}
这段代码的问题在哪?攻击者可以传hello;cat /etc/passwd,system()就会先执行echo hello,再执行cat /etc/passwd。密码文件直接暴露了。
我的建议:永远不要用system()、popen()这些函数处理用户输入。如果非要用,必须做严格的输入白名单校验。我个人习惯用execve()配合固定参数,这样用户没法注入额外命令。
二、HTTP头注入——别小看请求头里的猫腻
HTTP头注入,攻击者利用的是CGI程序对请求头处理不当的漏洞。常见的攻击点包括Content-Type、Referer、User-Agent、Cookie等。
举个例子,有些CGI程序会把User-Agent直接写进日志文件,或者拼到HTML响应里返回给用户。如果User-Agent里带了<script>alert('xss')</script>,那就变成XSS攻击了。
更危险的是,如果CGI程序把HTTP头的内容拼到Location重定向头里,攻击者可以构造%0d%0a(回车换行)来注入额外的HTTP响应头,实现HTTP响应拆分攻击。
// 有问题的HTTP头处理
char *user_agent = getenv("HTTP_USER_AGENT");
char response[1024];
// 直接把用户代理拼进响应
snprintf(response, sizeof(response),
"Content-Type: text/html\n\n"
"<html><body>你的浏览器: %s</body></html>",
user_agent);
printf("%s", response);
这段代码里,如果HTTP_USER_AGENT是Chrome<script>alert(1)</script>,浏览器就会执行这个脚本。
防御要点:对所有从环境变量读取的HTTP头数据,必须做两件事——编码输出和长度限制。我一般会限制每个头不超过256字节,超过的直接截断或拒绝。
三、安全的CGI编程——实战中的避坑指南
说了这么多漏洞,咱们聊聊怎么写出安全的CGI程序。我总结了几个关键点,都是血泪教训换来的。
3.1 输入验证是第一道防线
所有从环境变量获取的数据,都要当成不可信的。我习惯写一个统一的输入过滤函数:
// 安全的输入过滤函数
int sanitize_input(const char *input, char *output, size_t out_size) {
if (input == NULL || output == NULL || out_size == 0) {
return -1;
}
size_t j = 0;
for (size_t i = 0; input[i] != '\0' && j < out_size - 1; i++) {
// 只允许字母、数字、下划线、连字符
if (isalnum(input[i]) || input[i] == '_' || input[i] == '-') {
output[j++] = input[i];
}
// 其他字符直接跳过或替换
}
output[j] = '\0';
return 0;
}
避坑指南:我曾经遇到一个项目,开发人员觉得只过滤;和|就够了。结果攻击者用$(cat /etc/passwd)这种shell命令替换语法绕过了过滤。所以白名单才是王道,黑名单永远有漏网之鱼。
3.2 最小权限原则
CGI程序不要以root权限运行。我见过太多案例,CGI程序用root跑,一个命令注入漏洞就让攻击者拿到服务器最高权限。
正确的做法是:
- 创建专用的系统用户运行CGI程序
- 只给这个用户必要的文件读写权限
- 使用
chroot限制文件系统访问范围 - 在C代码中调用
setuid()/setgid()降权
// 降权示例
#include <unistd.h>
#include <sys/types.h>
int main() {
// 先降权到nobody用户
if (setgid(65534) != 0 || setuid(65534) != 0) {
// 降权失败,直接退出
return 1;
}
// 降权成功后才处理请求
// ... 业务逻辑 ...
return 0;
}
3.3 输出编码不能忘
CGI程序返回给浏览器的内容,如果是HTML格式,必须对特殊字符做编码。尤其是<、>、&、"、'这五个字符。
| 原始字符 | HTML实体编码 | 说明 |
|---|---|---|
| < | < | 小于号 |
| > | > | 大于号 |
| & | & | 与符号 |
| " | " | 双引号 |
| ' | ' | 单引号 |
3.4 缓冲区溢出防护
C语言的CGI程序最容易出缓冲区溢出。攻击者通过超长输入覆盖栈上的返回地址,就能劫持程序执行流程。
我的做法是:
- 所有字符串操作都用
snprintf()、strncpy()、strncat()这类带长度限制的函数 - 不要用
gets()、sprintf()、strcpy()、strcat() - 对输入长度做硬性限制,超过就拒绝
- 开启编译器的栈保护选项(
-fstack-protector-strong)
重要提醒:即使你用了snprintf(),也要检查返回值。如果返回值大于等于缓冲区大小,说明数据被截断了,这时候要主动报错,而不是继续用截断后的数据。
3.5 安全的文件操作
CGI程序经常需要读写文件。如果文件名来自用户输入,攻击者可以用../路径穿越来读写任意文件。
// 安全的文件路径处理
int safe_open_file(const char *user_input) {
char base_path[] = "/var/www/data/";
char full_path[256];
// 检查是否包含路径穿越字符
if (strstr(user_input, "..") != NULL ||
strchr(user_input, '/') != NULL) {
return -1; // 拒绝
}
// 拼接完整路径
snprintf(full_path, sizeof(full_path), "%s%s", base_path, user_input);
// 打开文件
FILE *fp = fopen(full_path, "r");
if (fp == NULL) {
return -1;
}
// ... 处理文件 ...
fclose(fp);
return 0;
}
我的经验:除了检查..和/,还要注意空字节注入。攻击者可能在文件名里塞\0来截断字符串。C语言里字符串以\0结尾,如果用户输入valid.txt\0/etc/passwd,strlen()只算到\0前面,但fopen()可能读到后面的内容。所以一定要检查输入里有没有空字节。
四、总结一下
CGI安全说白了就三件事:输入过滤、输出编码、权限控制。你想想看,攻击者能利用的无非就是这三个环节的漏洞。
我做了这么多年安全,见过太多因为CGI代码写得随意导致的安全事故。有些是命令注入,有些是XSS,有些是缓冲区溢出。但归根结底,都是因为开发者没把用户输入当回事。
记住一句话:永远不要信任任何来自客户端的数据。环境变量也好,HTTP头也好,请求体也好,统统当成有毒的来处理。做到这一点,你的CGI程序就安全了一大半。
最后送大家一个检查清单:
- ✅ 所有环境变量输入都经过白名单过滤
- ✅ 不使用
system()/popen()执行命令 - ✅ 输出到HTML的内容做了实体编码
- ✅ 程序以最低权限运行
- ✅ 所有字符串操作都限制了长度
- ✅ 文件路径做了穿越防护
- ✅ 编译时开启了安全选项
公众号:蓝海资料掘金营,微信deep3321