一段读配置的代码,把环境变量取出来做了个小处理:
c
char *db_host = getenv("DB_HOST");
if (db_host) {
db_host[strcspn(db_host, ":")] = '\0'; /* 顺手把端口去掉 */
}
单测全过,线上也平稳跑了一阵。后来运维在同一台机器上并行起了第二个实例,第二个实例读到的 DB_HOST 变成了空字符串。
排查到最后一层,问题就在那行赋值上。strcspn 算出的位置被写成了 '\0',而那个位置属于进程的环境区,不是一块随便可以改的内存。
手册是怎么规定的?
getenv(3) 的 NOTES 里有一句几乎被所有人跳过的话:
As typically implemented, getenv() returns a pointer to a string within the environment list. The caller must take care not to modify this string, since that would change the environment of the process.
「调用者必须注意不要修改这个字符串,因为那会改变进程的环境。」
注意后半句的措辞。它说的不是「可能」,是「会」。你改的是环境列表里那一份原始数据,之后进程内任何一次 getenv 看到的都是改后的结果。
getenv 的返回类型是 char *,没有 const 修饰,这大概是它最容易被写坏的地方。类型不拦你,文档在拦你。
为什么另一个实例会受影响?
环境的共享方式决定了影响范围。
如果第二个进程是从父进程 fork 出来的,环境区是写时复制的,两个进程各自独立,改了互不相干。如果两个进程都从同一个父进程 exec 而来,或者干脆是同一个进程里并行跑的多个执行流,那么环境区是同一份,改了就是全场都改。
那次的事故属于后一种:两个实例由同一个监管进程拉起,共享父进程传下来的环境,一次改写同时污染了两边的读取结果。
手里的指针能保存多久?
同一段 NOTES 紧接着还有一句,问题更大的那种:
The implementation of getenv() is not required to be reentrant. The string pointed to by the return value of getenv() may be statically allocated, and can be modified by a subsequent call to getenv(), putenv(3), setenv(3), or unsetenv(3).
拆开看是三件事。不要求可重入。返回的字符串可能是静态分配的。后续的 getenv、putenv、setenv、unsetenv 都可能把它改掉。
所以这种写法是危险的:
c
const char *a = getenv("PATH");
const char *b = getenv("HOME");
/* 这里 a 指向的内容可能已经变了 */
printf("%s\n", a);
在多数实现里,a 指向的是环境列表内部的某一段,第二次 getenv 不会覆盖它。但「多数实现」不等于「标准保证」,尤其在 setenv 之后环境列表可能被重新分配,旧指针直接悬空。
手册在 ATTRIBUTES 里给的标注是 MT-Safe env,含义是在多线程环境下安全,前提是不和其他修改环境的函数并发。这个限定条件常被忽略:只读没问题,一边读一边 setenv 就有问题。
正确写法是什么?
标准做法只有一条:拿到的指针当只读,要改就先复制。
c
const char *raw = getenv("DB_HOST");
if (raw) {
char host[256];
snprintf(host, sizeof(host), "%s", raw);
host[strcspn(host, ":")] = '\0'; /* 改的是副本 */
}
用 snprintf 而不是 strcpy,顺带解决了长度问题。环境变量可能很长,超出缓冲区时 snprintf 会截断,并保证零结尾。
长度完全不确定就 strdup:
c
const char *raw = getenv("DB_HOST");
char *copy = strdup(raw ? raw : "");
这里的 ?: 不能省。strdup(NULL) 是未定义行为,必须先判空。
这类「返回别人的内存」的接口,我在团队文档里单列了一节,底稿是用 AI墨衍 按「返回值归谁 / 能不能改 / 能存多久」三列排的,几十个调用点一次对齐完。
返回 NULL 和返回空串是一回事吗?
不是,这两个结果的含义完全不同。
返回 NULL 表示变量根本不存在,环境列表里没有这一项。返回指向空串的指针表示变量存在,只是值恰好是空的。VAR= 这种写法就属于后者。
这个区别在配置合并时很关键。如果代码把 NULL 当成「没配置,用默认值」,把空串也一并当成「没配置」,那么显式把某变量设成空串来表达「禁用」的用法就失效了。反过来,如果不加区分地判 if (!val),两种情况都会被当成缺失。
正确写法是先判指针再判长度,两步分开:
c
const char *v = getenv("CACHE_DIR");
if (v == NULL) {
/* 变量不存在,走默认目录 */
} else if (*v == '\0') {
/* 存在但为空,按禁用处理 */
}
putenv 和 setenv 差在哪?
差别在「要不要复制」这一件事上,后果却很大。
| 函数 | 是否复制字符串 | 调用后能否释放入参 | 风险 |
|---|---|---|---|
setenv |
复制 | 可以立即释放 | 基本没有 |
putenv |
不复制,直接挂进环境 | 不能释放 | 传栈上变量会留下悬空指针 |
unsetenv |
--- | --- | 可能让之前的 getenv 结果失效 |
getenv |
不复制,直接借出 | 不必释放,也不该改 | 改它等于改进程环境 |
第二行是经典事故来源:
c
void set_host(const char *h) {
char buf[128];
snprintf(buf, sizeof(buf), "DB_HOST=%s", h);
putenv(buf); /* buf 出了函数就没了,环境里留着悬空指针 */
}
正确写法是 setenv("DB_HOST", h, 1),或者给 putenv 传一块长期有效、永不释放的内存。
setuid 程序为什么要换 secure_getenv?
手册里还有一段容易被漏掉的:
The secure_getenv() function is just like getenv() except that it returns NULL in cases where "secure execution" is required.
什么算 secure execution,手册列了三种情况:有效 UID 与真实 UID 不一致,典型就是 setuid 或 setgid 程序;可执行文件设置了有效 capability 位;进程持有非空的 permitted capability 集。
原因不难理解。环境变量是外部可控的,一个 setuid 程序如果信任了 LD_PRELOAD、LD_LIBRARY_PATH 这类变量,等于把提权入口交给了调用者。secure_getenv 在这种场景下返回 NULL,让程序走安全默认值。
手册对这个函数的定位写得很明确:
The secure_getenv() function is intended for use in general-purpose libraries to avoid vulnerabilities that could occur if set-user-ID or set-group-ID programs accidentally trusted the environment.
「给通用库用的」,因为库并不知道调用自己的程序会不会被配成 setuid。
| 检查项 | 判定 | 改法 |
|---|---|---|
对 getenv 返回值做写操作 |
危险 | 先复制到自己的缓冲区再改 |
连续两次 getenv 后仍用第一个指针 |
危险 | 各自复制,或用完再取下一个 |
用 putenv 传栈上数组 |
危险 | 换 setenv |
| 在 setuid 程序里读路径类变量 | 危险 | 换 secure_getenv |
| 只读、不保存指针、不并发改环境 | 安全 | 无需改动 |
第四行最隐蔽。程序本身没有任何毛病,毛病在于「它可能被配置成 setuid」这种部署方式。
小结
那次修复把就地改写改成复制后再处理,两个实例互相影响的问题消失了。
需要记住的是那句措辞:会改变进程的环境,不是可能改变。一个返回 char * 的函数,不等于给了你一块可写的内存。
getenv 借给你的是别人的内存,你只能看,不能改,也不该指望下一个人接手时它还长原样。