AmateursCTF 2024
Writeup of pwn/linker-as-a-service from AmateursCTF 2024.
overview
The server asks for an ELF file, ensures that it’s an amd64 shared object with the provided ld.so interpreter, and then runs it. Easy shell right? But there’s a catch.
|
|
It’s run with environment variables that act as arguments to the linker.
LD_WARN (since glibc 2.1.3)
If set to a nonempty string, warn about unresolved
symbols.
LD_TRACE_LOADED_OBJECTS
If set (to any value), causes the program to list its
dynamic dependencies, as if run by ldd(1), instead of
running normally.
The linker won’t transfer control to our ELF’s entry under this configuration. Before we go looking through glibc source to scope out our attack surface, let’s briefly introduce relocations, which are hinted at in the description.
relocations
Relocations within an ELF act as instructions to the linker to modify some memory at load time. There are 3 fields for a 64 bit rela (relocation with addend) entry.
typedef struct {
Elf64_Addr r_offset;
Elf64_Xword r_info;
Elf64_Sxword r_addend;
} Elf64_Rela;
r_offset
specifies the virtual address of the memory to be modified, not including load bias. This limits us to modifying memory relative to where our ELF is mapped.
r_info
specifies both the type of the relocation and an associated symbol table index if applicable.
r_addend
specifies a constant addend to be used when computing the relocation.
Relocation types are machine dependent and can get confusing, but for this challenge we only need to use two amd64 relocations.
R_AMD64_64
is the relocation S + A
, where S
is the address of a resolved symbol from r_info
and A
is the addend.
R_AMD64_RELATIVE
is the relocation B + A
where B
is the base virtual address where the ELF was actually mapped.
This is important, since ASLR will randomize our ELF base address, so using relative relocs allows us to write to memory with valid addresses.
rtld
The ld.so we’re given is from glibc 2.36.
The main source file we’re interested in is elf/rtld.c, which contains the code for the run time dynamic linker. Most of the important logic occurs in the dl_main
function, and the first step of the process that we care about is shown in the following block of code.
/* Load all the libraries specified by DT_NEEDED entries. If LD_PRELOAD
specified some libraries to load, these are inserted before the actual
dependencies in the executable's searchlist for symbol resolution. */
{
RTLD_TIMING_VAR (start);
rtld_timer_start (&start);
_dl_map_object_deps (main_map, preloads, npreloads,
state.mode == rtld_mode_trace, 0);
rtld_timer_accum (&load_time, start);
}
ELF shared objects are required to have a dynamic segment containing a list of tags that basically tell the linker what to do. A full description for each of these tags can be found here.
DT_NEEDED
tags specify the name of a shared object dependency. Before any relocations are processed, the linker recursively resolves these dependencies and maps them into memory.
Because we were graciously provided an ld.so with debug info, we can break on the corresponding line of code in gdb and see what happens after calling _dl_map_object_deps
. I’ll also note here that for debugging this challenge, I used a small wrapper program and patchelf’d it to use the linker we were provided, which made gdb automatically resolve the debug symbols. You do, however, need to catch sys execve
and continue before setting linker breakpoints, which prevents breaking on the first run of the linker for the wrapper program itself. There’s probably a better method, so let me know if I could improve this.
|
|
We’ll run a test ELF with a DT_NEEDED
tag for libc.so.6
and print mappings.
Start Perm Path
0x0000555555554000 r-x /home/enzo/ctf/am24/linker-as-a-service/chal
0x00007ffff7de2000 r-- /home/enzo/ctf/am24/linker-as-a-service/libc.so.6
0x00007ffff7e08000 r-x /home/enzo/ctf/am24/linker-as-a-service/libc.so.6
0x00007ffff7f5d000 r-- /home/enzo/ctf/am24/linker-as-a-service/libc.so.6
0x00007ffff7fb0000 rw- /home/enzo/ctf/am24/linker-as-a-service/libc.so.6
0x00007ffff7fb6000 rw-
0x00007ffff7fc5000 r-- [vvar]
0x00007ffff7fc9000 r-x [vdso]
0x00007ffff7fcb000 r-- /home/enzo/ctf/am24/linker-as-a-service/ld-linux-x86-64.so.2
0x00007ffff7fcc000 r-x /home/enzo/ctf/am24/linker-as-a-service/ld-linux-x86-64.so.2
0x00007ffff7ff1000 r-- /home/enzo/ctf/am24/linker-as-a-service/ld-linux-x86-64.so.2
0x00007ffff7ffb000 rw- /home/enzo/ctf/am24/linker-as-a-service/ld-linux-x86-64.so.2
0x00007ffffffde000 rw- [stack]
0xffffffffff600000 --x [vsyscall]
libc.so.6
is mapped adjacent to it. This is an interesting characteristic that will be helpful later.
warn
Once the linker has mapped dependencies, its job with regards to LD_TRACE_LOADED_OBJECTS
is basically done. There is no need to perform relocations. Let’s look at the code for handling this.
if (__glibc_unlikely (state.mode != rtld_mode_normal))
{
/* We were run just to list the shared libraries. It is
important that we do this before real relocation, because the
functions we call below for output may no longer work properly
after relocation. */
We’ll first be taken down a path that prints dependencies, and at the end of the if statement exit is called. So where is our attack surface?
/* If LD_WARN is set, warn about undefined symbols. */
if (GLRO(dl_lazy) >= 0 && GLRO(dl_verbose))
{
/* We have to do symbol dependency testing. */
struct relocate_args args;
unsigned int i;
args.reloc_mode = ((GLRO(dl_lazy) ? RTLD_LAZY : 0)
| __RTLD_NOIFUNC);
LD_WARN
being set wasn’t without purpose. As we saw earlier, it warns us about unresolved symbols, and the only way the linker can do that is by processing our ELF’s relocations.
i = main_map->l_searchlist.r_nlist;
while (i-- > 0)
{
struct link_map *l = main_map->l_initfini[i];
if (l != &GL(dl_rtld_map) && ! l->l_faked)
{
args.l = l;
_dl_receive_error (print_unresolved, relocate_doit,
&args);
}
}
Now we have a clear goal: corrupt some memory in the linker using bad relocations to hijack control flow before the linker exits.
corruption
There aren’t many sanity checks on relocations. We can supply an out-of-bounds offset and the address it points to will get written. The problem here is ASLR. As we saw earlier, our ELF and the linker are mapped in separate relative spaces, so the offset between the two is randomized. A potential way around this is to use self-modifying relocs, but there was an explicit check that prevented this in the server’s ELF processing, so we’ll need to find another way.
Recall that when we set a DT_NEEDED
for libc.so.6
, it was mapped adjacent to the linker. What happens if we map our own executable, which we can do using /proc/self/exe
? Well, as expected, it will also get mapped adjacent to the linker. And our mapped self will have its relocs processed first, effectively bypassing the issue with ASLR. We now have arbitrary write into the linker’s memory.
From here, there are multiple methods to gain PC control. The author of the challenge modified the audit state of the linker, but I found a simple function pointer we can overwrite directly. The unresolved symbol warnings are performed by the call below.
_dl_receive_error(print_unresolved, relocate_doit, &args)
print_unresolved
will be assigned to a linker variable called receiver
, and _dl_lookup_symbol_x
will call _dl_signal_cexception
if the symbol can’t be resolved, which will jump to the address in receiver
. We can use a relative reloc to overwrite receiver
with the address of our own ELF in memory and supply an addend so it jumps to a location containing our shellcode. We will additionally need to supply another reloc that attempts to lookup an unresolvable symbol to trigger the exception handler. When performed successfully, the stack trace will look as follows, with rip pointing to the address of our ELF in the linker relative memory space.
elf crafting
Putting this all together into an ELF takes a little bit of work. I haven’t found an ELF library I enjoy yet, so I wrote everything from scratch in python. My solve script to generate the ELF is provided below.
|
|
And here’s a picture of it working. This was a very creative and fun challenge, huge props to the author unvariant.