Looney Tunables Local privilege escalation (CVE-2023-4911) workshop (for educational purposes only)
In computing, a dynamic linker is the part of an operating system that loads and links the shared libraries needed by an executable when it is executed, by copying the content of libraries from persistent storage to RAM, filling jump tables and relocating pointers.
For example, we have program which uses openssl library to calculate md5 hash:
$ head md5_hash.c
#include
#include
#include
ld.so parses the binary and tries to find library related to
$ ldd md5_hash
linux-vdso.so.1 (0x00007fffa530b000)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f19cda00000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f19cd81e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f19ce032000)
As we can see, it finds necessary crypto library at /lib/x86_64-linux-gnu/libcrypto.so.3 During program startup, it put the code of this library into the process RAM and links all references to this library.
When a program is initiated, this loader first examines the program to determine the shared libraries it requires. It then searches for these libraries, loads them into memory, and links them with the executable at runtime. In the process, the dynamic loader resolves symbol references, such as function and variable references, ensuring that everything is set for the program’s execution. Given its role, the dynamic loader is highly security-sensitive, as its code runs with elevated privileges when a local user launches a set-user-ID or set-group-ID program.
Tunables are a feature in the GNU C Library that allows application authors and distribution maintainers to alter the runtime library behavior to match their workload. These are implemented as a set of switches that may be modified in different ways. The current default method to do this is via the GLIBC_TUNABLES environment variable by setting it to a string of colon-separated name=value pairs. For example, the following example enables malloc checking and sets the malloc trim threshold to 128 bytes:
GLIBC_TUNABLES=glibc.malloc.trim_threshold=128:glibc.malloc.check=3
export GLIBC_TUNABLES
Passing --list-tunables to the dynamic loader to print all tunables with minimum and maximum values:
$ /lib64/ld-linux-x86-64.so.2 --list-tunables
glibc.rtld.nns: 0x4 (min: 0x1, max: 0x10)
glibc.elision.skip_lock_after_retries: 3 (min: 0, max: 2147483647)
glibc.malloc.trim_threshold: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.perturb: 0 (min: 0, max: 255)
glibc.cpu.x86_shared_cache_size: 0x100000 (min: 0x0, max: 0xffffffffffffffff)
glibc.pthread.rseq: 1 (min: 0, max: 1)
glibc.cpu.prefer_map_32bit_exec: 0 (min: 0, max: 1)
glibc.mem.tagging: 0 (min: 0, max: 255)
At the very beginning of its execution, ld.so calls __tunables_init() to walk through the environment (at line 279), searching for GLIBC_TUNABLES variables (at line 282); for each GLIBC_TUNABLES that it finds, it makes a copy of this variable (at line 284), calls parse_tunables() to process and sanitize this copy (at line 286), and finally replaces the original GLIBC_TUNABLES with this sanitized copy (at line 288):
// (GLIBC ld.so sources in ./glibc-2.37/elf/dl-tunables.c)
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)) // searching for GLIBC_TUNABLES variables
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 }
The first argument of parse_tunables() (tunestr) points to the soon-to-be-sanitized copy of GLIBC_TUNABLES, while the second argument (valstring) points to the original GLIBC_TUNABLES environment variable (in the stack). To sanitize the copy of GLIBC_TUNABLES (which should be of the form "tunable1=aaa:tunable2=bbb"
), parse_tunables() removes all dangerous tunables (the SXID_ERASE tunables) from tunestr, but keeps SXID_IGNORE and NONE tunables (at lines 221-235):