WHAT’S UP, DOC? AN OCCAMSEC APPROACH TO THE LOONEY TUNABLES BUG (CVE-2023-4911)
Our Approach to Exploiting CVE-2023-4911: A Deep Technical Dive into a Local Privilege Escalation bug
By Rory, Brandon, and Jack
The landscape of Linux security is forever evolving, and CVE-2023-4911 marks a watershed moment. This vulnerability, rooted deep in the GNU C Library (glibc), specifically targets the dynamic linker/loader, ld.so. As ld.so is responsible for the launching of every single dynamically linked executable on the system (including very common SUID root programs such as su), the severity and impact of this vulnerability cannot be understated. This write-up offers a detailed dissection of this vulnerability, elucidates a meticulously crafted proof-of-concept (PoC) in C, and provides best-practice mitigation measures.
DISSECTING THE VULNERABILITY
THE NUCLEUS OF THE ISSUE
The vulnerability lies in the parse_tunables function of ld.so , specifically residing in glibc/elf/dl-tunables.c . The security flaw is triggered when a malformed GLIBC_TUNABLES environment variable is passed via an execve call to an SUID binary. These GLIBC_TUNABLES are intended to be used at runtime of a dynamically linked program to alter library behaviour. Running ld.so –list-tunables in a Linux command-line prints all available tunables for that installation or distribution of Linux. The purpose of this parse_tunables function is to sanitize the runtime input tunables string (valstring ) by looping through each colon separated tunable present in the input string and removing unsafe tunables (any SXID_ERASE type tunables) by appending the current safe tunable ( SXID_IGNORE or NONE type tunables) to the resulting tunestr .
If the valstring passed to this parse_tunables function contains two SXID_IGNORE tunables (such as glibc.mem.tagging ) that are not colon separated, then on the first iteration of the function’s while (true) loop the entire valstring is copied to tunestr . On the second iteration of the function’s while (true) loop the valstring is then again appended to the end of the already full tunestr , causing ld.so to erroneously write to memory out of bounds resulting in the overflow vulnerability we are discussing now in this article.
In simplified code terms, this vulnerability could be observed in a C application if the program in question were to run something like the following:
Where TUNABLE is any tunable of type SXID_IGNORE , for example glibc.mem.tagging .
Running something close to what is shown in the above snippet causes a “Segmentation fault” error which occurs when an application or program erroneously accesses memory out of bounds.
In an even simpler form, running GLIBC_TUNABLES=glibc.mem.tagging=glibc.mem.tagging= /usr/bin/su –help in a Linux command-line on a system with a vulnerable ld.so causes the same “Segmentation fault” error to appear.
OVERRIDING STRUCT LINK_MAP
Exploiting this bugger overflow vulnerability allows us to preemptively write into regions of memory that ld.so will then allocate via a call to its internal calloc function and use shortly after to load the target dynamically linked object. The region of memory function in glibc/elf/dl-object.c struct link_map is a structure very commonly used by ld.so for managing loaded objects, many of the members of this structure are private to the implementation of ld.so (“private” meaning they cannot or perhaps should not be accessed from regular user programs) but this does not stop us from being able to write arbitrary data to this allocated region of memory.
The calloc() that ld.so uses here is not, in fact, glibc’s calloc, but instead a minimal memory allocator that uses mmap() to request pages of memory from the kernel, as this early in execution of a program, glibc has not yet been initialized. Unlike glibc’s calloc, ld.so’s version of this does not explicitly initialize the returned allocated region of memory to zero. As this does not initialize the newly allocated memory, anything we write out of bounds in our exploit via the overflow in dl-tunables.c parse_tunables, will persist when this region of memory is allocated later by ld.so.
The crown jewel of this exploit lies in manipulating the l_info memory of struct link_map which is an array of pointers, each pointing to an ElfW(Dyn) structure. This ElfW_(Dyn) structure is used to store information on different dynamic linking information which ld.so refers to when loading a dynamic object.
Elfw(Dyn) structure contains the two following members:
- ElfW(Sxword) d_tag – this is set to a constant value defined by so that represents which l_info structure this is and how the data stored in this structure should be handled. This is not important for this particular vulnerability so it may be set to any value.
- ElfW(Xword) d_val – this value represents an offset. In this case, an offset from the string table section of the executable being run. ElfW(Addr) d_ptr may also be used instead by ld.so as both of the members are contained inside a union type (named d_un) so they share the same value.
Most important, l_info[DT_RPATH] points to an ElfW(Dyn) structure that represents the library search path of the executable being run. By modifying the d_val offset member of DT_RPATH via our out of bounds write, we can force ld.so to load an arbitrary library from a directory under our control, thus granting us privileged code execution.
Determining this arbitrary directory and offset to provide to l_info[DT_RPATH] is done simply by looping through each byte in reverse order beginning at the start of the .dynstr section (string table) of the target SUID root ELF binary, as the d_val offset in this case, represents the offset from the executable’s string table section.
- 47 represents the total number of variables (or pointers) we need to pad the environment.
- 16382 represents how many offsets (ElfW(Xword) d_val member from ElfW(Dyn)) the current environment variable region should contain.
- off_env_size sums the total size of each variable – with an extra 7 bytes for padding and 1 for the final null terminator.
IMPORTANCE OF NULL POINTERS
It’s crucial to initialize all other pointers in the calloc()’s struct link_map to 0, or NULL. Failure to do so will make ld.so throw assertion errors, terminating the exploitation process.
DEPENDENCIES AND COMPILATION
- Environment: Tested successfully on Ubuntu 04 and found issues on vulnerable CentOS Stream 9.
- Dependencies: No external dependencies. Pure C
The PoC we created is written in C, which has the benefit of being very widely compatible, as it requires no additional packages or installs to run on a system.
Address Space Layout Randomization (ASLR) is a preventative security measure employed by operating systems (including Linux) which randomizes all the locations of regions of memory allocated by the operating system’s user processes and programs. This effort aims to make it more difficult for attackers to execute buffer overflow attacks, such as the one discussed here. ASLR, quite rightly, is therefore enabled by default on just about every version of every commonly used operating system however it is not a complete solution.
In summary, given enough time and/or resources, ASLR is only a temporary obstacle for an attacker. The PoC code we created in C has been designed to circumvent ASLR protections by employing multithreading logic. Each thread created in our exploit is responsible for repeatedly attempting to achieve privileged code execution using the crafted GLIBC_TUNABLES overflow environment created by our exploit. The time for this exploit to finish successfully is therefore dependent on how many threats are created for the attack, or potentially, how lucky our threads get with our set stack address when they make their calls to execve. See below for a description of what exactly each thread is doing and when.
The above code snippet is from our exploit’s main function, where num_threads is the total length of the given range. Each individual thread created here is given its own “adjust” and enter a loop of its own where it will repeatedly call execve in a new forked child process with our crafted environment variables until exploiting the vulnerability is successful, indicated by our own code being run with escalated privileges (as root user) for example, by a new command-line shell bring spawned as the root user.
The spawn_shell function calls fork() and in the new child process, calls execve() with parameters that will launch the target suid root binary with the environment crafted by our exploit.
Should spawn_shell return 0x1337 (indicating the shell was successfully spawns and has now been terminated), the exploit will send cancel requests to all currently blocked threads before sending the SIGINT signal to itself in order to terminate the exploit program.
The PoC accepts a range of “adjust” values as command-line arguments. This is crucial for fine-tuning the exploitation process, particularly in systems with ASLR enabled.
This adjust range represents how many extra bytes should be used for passing the GLIBC_TUNABLES overflow variable in order to ensure that the exploit is writing to the correct locations in memory.
EXAMPLE OUTPUT (ASLR ENABLED)
- Should ASLR be disabled, no threads are created and instead this range represents the length of the exploit’s main for loop.
MITIGATION AND COUNTERMEASURES
The immediate remediation action is to patch or upgrade from the vulnerable glibc version. This vulnerability was introduced April 2021 glibc version 2.34 (commit 2ed18c “Fix SXID_ERASE behavior to setuid programs (BZ #27471)”)
In addition, an effective preventative measure is to disable any and all unnecessary SUID root binaries to minimize potential attack surfaces – SUID root programs have long been known for providing attackers with (often unintended) methods of privilege escalation from regular users.
CVE-2023-4911 is a critical vulnerability that posts a significant risk if not addressed immediately. This comprehensive analysis should provide you with both the tools and the understanding to both exploit and defend against this vulnerability effectively.