HITCON CTF 2020 / Tenet
by umutoztunc
After spending some time on reverse engineering the binary, I got the following decompilation:
__int64 __fastcall main(int argc, char **argv, char **envp)
{
int current_step; // ebx
bool has_shellcode_started; // [rsp+1Ah] [rbp-26h]
bool sysexit_encountered; // [rsp+1Bh] [rbp-25h]
__WAIT_STATUS stat_loc; // [rsp+1Ch] [rbp-24h]
int v8; // [rsp+24h] [rbp-1Ch]
unsigned __int64 v9; // [rsp+28h] [rbp-18h]
v9 = __readfsqword(0x28u);
if ( argc != 2 )
exit(2);
setbuf(stdout, 0LL);
pid = fork();
if ( !pid )
{ // child process only
ptrace(PTRACE_TRACEME, 0LL, 0LL, 0LL);
execve(argv[1], 0LL, 0LL); // argv[1] is the path of shellcode ELF file.
failure("execve");
}
if ( ptrace(PTRACE_ATTACH, (unsigned int)pid, 0LL, 0LL) )
err(1, "ptrace");
LODWORD(stat_loc.__uptr) = 0;
HIDWORD(stat_loc.__iptr) = waitpid(pid, &stat_loc, WUNTRACED);
if ( HIDWORD(stat_loc.__iptr) != pid || LOBYTE(stat_loc.__uptr) != 127 )
failure("the first wait");
has_shellcode_started = 0;
sysexit_encountered = 0;
while ( 1 )
{
if ( ptrace(PTRACE_SINGLESTEP, (unsigned int)pid, 0LL, 0LL) )
err(1, "ptrace", argv);
HIDWORD(stat_loc.__iptr) = wait((__WAIT_STATUS)&stat_loc);
if ( !((_QWORD)stat_loc.__uptr & 0x7F) )
break;
v8 = (SLODWORD(stat_loc.__uptr) >> 8) & 0xFF;
if ( v8 != 5 )
failure2("Child dead unexpectedly.");
if ( step_count > 4095 )
failure2("Too many steps.");
if ( has_shellcode_started != 1 && get_rip_value() == 0xDEAD0080 )
{
has_shellcode_started = 1;
set_cs_and_ss();
clear_memory();
write_cookie();
}
if ( has_shellcode_started )
{
if ( next_nonsyscall_ins() )
{
sysexit_encountered = 1;
break;
}
current_step = step_count++;
nonsyscall_ins_addresses[current_step] = get_rip_value();
}
}
if ( sysexit_encountered != 1 )
failure2("...?");
if ( !is_memory_empty() )
failure2("Please swallow the cookie.");
execute_steps_backwards();
if ( !is_cookie_still_there() )
failure2("You should vomit the cookie out.");
print_flag();
return 0LL;
}
The ruby script creates an ELF file from our shellcode and passes the file to this program as a parameter. This program uses ptrace to trace our payload and manipulates its memory, registers, etc. Just before our shellcode starts, the tracer clears the memory range 0x2170000-0x2170fff
of the tracee, and writes a random 64-bit value to 0x2170000
. I will refer this value as the cookie
.
When the code reaches to our shellcode, it starts single-stepping it and logging instruction addresses except for syscall instructions. We are not allowed to step more than 4095 times.
It also expects our shellcode to finish with a sys_exit
call.
Here how it checks whether our shellcode is succcessful:
- Clear the memory range
0x2170000-0x2170fff
of tracee. - Generate a random
cookie
and write it at0x2170000
. - Single-step shellcode and log all non-syscall instructions’ addresses until
sys_exit
is encountered. - Check if
0x2170000-0x2170fff
is empty. - Execute the logged instructions backwards.
- Check if
0x2170000
still stores thecookie
. - Print flag.
So, we need to write a shellcode that will clear the cookie
when executed, and it needs to restore the cookie
when it is executed backwards.
So far so good, but there is a catch. The ruby script prepends some bytes to our shellcode before creating the ELF payload.
If we look at generated ELF file. We get this:
LOAD:00000000DEAD005C main: ; CODE XREF: start↑j
LOAD:00000000DEAD005C call init_chall
LOAD:00000000DEAD0061 mov rdi, rsp
LOAD:00000000DEAD0064 and rdi, 0FFFFFFFFFFFFF000h
LOAD:00000000DEAD006B sub rdi, 21000h ; addr
LOAD:00000000DEAD0072 mov rsi, 24000h ; len
LOAD:00000000DEAD0079 xor rax, rax
LOAD:00000000DEAD007C mov al, 0Bh
LOAD:00000000DEAD007E syscall ; LINUX - sys_munmap
LOAD:00000000DEAD0080 <our shellcode starts here>
It calls sys_munmap
on the stack, just before executing our shellcode. Which means we won’t be able to store our cookie
at stack. Also, if we look at the function init_chall
:
LOAD:00000000DEAD0002 init_chall proc near ; CODE XREF: start:main↓p
LOAD:00000000DEAD0002 push 26h ; PR_SET_NO_NEW_PRIVS
LOAD:00000000DEAD0004 pop rdi ; option
LOAD:00000000DEAD0005 push 1
LOAD:00000000DEAD0007 pop rsi ; arg2
LOAD:00000000DEAD0008 xor eax, eax
LOAD:00000000DEAD000A mov al, 9Dh
LOAD:00000000DEAD000C syscall ; LINUX - sys_prctl
LOAD:00000000DEAD000E push 16h ; PR_SET_SECCOMP
LOAD:00000000DEAD0010 pop rdi ; option
LOAD:00000000DEAD0011 lea rdx, filter
LOAD:00000000DEAD0018 push rdx
LOAD:00000000DEAD0019 push 6
LOAD:00000000DEAD001B mov rdx, rsp ; arg3
LOAD:00000000DEAD001E push 2 ; SECCOMP_MODE_FILTER
LOAD:00000000DEAD0020 pop rsi ; arg2
LOAD:00000000DEAD0021 xor eax, eax
LOAD:00000000DEAD0023 mov al, 9Dh
LOAD:00000000DEAD0025 syscall ; LINUX - sys_prctl
LOAD:00000000DEAD0027 add rsp, 10h
LOAD:00000000DEAD002B retn
LOAD:00000000DEAD002B init_chall endp
LOAD:00000000DEAD002B
LOAD:00000000DEAD002B ; ---------------------------------------------------------------------------
LOAD:00000000DEAD002C ; struct sock_filter filter
LOAD:00000000DEAD002C filter sock_filter <20h, 0, 0, 4>
LOAD:00000000DEAD002C ; DATA XREF: init_chall+F↑o
LOAD:00000000DEAD002C sock_filter <15h, 0, 2, 0C000003Eh>
LOAD:00000000DEAD002C sock_filter <20h, 0, 0, 0>
LOAD:00000000DEAD002C sock_filter <15h, 1, 0, 0Bh>
LOAD:00000000DEAD002C sock_filter <6, 0, 0, 0>
LOAD:00000000DEAD002C sock_filter <6, 0, 0, 7FFF0000h>
We see that PR_SET_NO_NEW_PRIVS
is set to 1, and seccomp is enabled with a user-defined filter.
We can use seccomp-tools
to dump that filter:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x02 0xc000003e if (A != ARCH_X86_64) goto 0004
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x15 0x01 0x00 0x0000000b if (A == munmap) goto 0005
0004: 0x06 0x00 0x00 0x00000000 return KILL
0005: 0x06 0x00 0x00 0x7fff0000 return ALLOW
As you can see, we are not allowed to use any syscall other than sys_munmap
, and we are not allowed to use 32-bit instructions and syscalls.
I was thinking about finding a place to store information and I realized that by logging our instructions, it actually stores information. So, if I write a loop and execute an instruction n times, it has to be executed n times again during the backwards execution. Thus, my idea was to run a loop for each byte of the cookie
. Each loop will be executed k times where k is the value of the corresponding byte. This way, we can increment a register in the loop and set it to zero afterwards. When it gets executed in reverse order, the register will store the corresponding byte’s initial value. Rest is creating the cookie backwards and writing it back to 0x2170000
.
I have talked to @david942j about it, and he said this wasn’t the intended solution. The intended solution applies a similar idea. However, it uses bitwise operations instead of simple increment/decrement operations which results in less steps. The only problem with my solution is that if each k value is large, we might hit the step limit. Still, I had a pretty high success rate in my local environment and I got the flag from the remote server in my first attempt.
Here is my shellcode:
mov r12, qword ptr [0x2170000]
mov qword ptr [0x2170000], r12
mov r11, r12
mov r12, r10
loop:
mov r8, r11
and r8, 0xFF
xor r9, r9
add r10, r9
shl r10, 8
loop2:
test r8, r8
jz loop2_end
inc r9
dec r8
jmp loop2
loop2_end:
shr r11, 8
test r11, r11
jnz loop
xor r9, r9
xor r10, r10
mov qword ptr [0x2170000], 0
mov rax, 60
syscall