This year’s Hexacon featured several CTFs hosted by some of the sponsoring companies. This post is a brief writeup of my solution for the stage-one Linux challenge by Binary Gecko, a “[…] provider of comprehensive and specialized cybersecurity solutions to businesses and institutions of all sizes.”, aha.

tl;dr: Work around a bunch of anti-debug techniques to dump a second-stage payload. Use angr (after convincing it to load the malformatted dump) to solve a standard crackme that yields the flag. Then validate that it is correct, using frida to work around some more anti-debug annoyances.

Overview

We are given a static binary without any symbols or useful strings, but with an rwx segment, great. Running it just tells us to ‘Get out!’.

% readelf --segments hexalinux.bin

Elf file type is EXEC (Executable file)
Entry point 0x2000c0
There are 2 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000200000 0x0000000000200000
                 0x0000000000003a87 0x0000000000003a87  RWE    0x200000
  LOAD           0x0000000000003a87 0x0000000000a03a87 0x0000000000a03a87
                 0x0000000000004010 0x0000000000004010  RW     0x200000
% file hexalinux.bin
hexalinux.bin: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, no section header
% ./hexalinux.bin
Get out!!

Furthermore, throwing it into a disassembler shows many unprocessed regions, indicating some sort of packing.

Anti-Debug Vol. 1

Using strace shows us the first anti-debug technique: the binary checks /proc/self/status. Usually this is a check of the “TracerPid” value to detect the presence of a debugger.

% strace ./hexalinux.bin
execve("./hexalinux.bin", ["./hexalinux.bin"], 0x70bb0b5efd70 /* 43 vars */) = 0
getpid()                                = 34999
stat("/proc/34999/status", {st_mode=S_IFREG|0444, st_size=0, ...}) = 0
open("/proc/34999/status", O_RDONLY)    = 3
read(3, "Name:\thexalinux.bin\nUmask:\t0022\n"..., 4095) = 1207
close(3)                                = 0
exit(0)                                 = ?

At this point, I went searching for a gdb script I use in those situations. Using my favorite decompiler, I made a list of all the syscall addresses in the binary.

[x[0] for x in [[i[1] for i in f.instructions if str(i[0][0]) == 'syscall'] for f in bv.functions] if x]

The script simply uses them to place a breakpoint before and after each syscall instruction. Combining this with a tool to convert syscall numbers to names, we got ourselves a poor-mans strace! We can also automate the bypassing of the first anti-debug check by simply overwriting the string that was read from the status file.

Anti-Debug Vol. 2

Running the binary under our ad-hock strace shows a call to fork and then many ptrace invocations - it seems that the first process is spawning another process and then does some fun stuff to it. However, we soon exit due to some other anti-debug check.

SYS_getpid(arg1=0x0,arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x13f2
SYS_stat(arg1=0x7fffffffd340,arg2=0x7fffffffd3c0,arg3=0x7fffffffd32f,arg4=0x7fffffffd3c0,arg5=0x0) -> 0x0
SYS_open(name=0x7fffffffd340:   "/proc/5106/status",arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x3
SYS_read(fd=0x3,buf=0x7fffffffd450,n=0xfff,arg4=0xfff,arg5=0x3) -> 0x4b1
[+] remove TracerPid
SYS_close(arg1=0x3,arg2=0x7fffffffd450,arg3=0xfff,arg4=0xfff,arg5=0x0) -> 0x0
[Detaching after fork from child process 5109]
SYS_fork(arg1=0x7fffffffd450,arg2=0x7fffffffd315,arg3=0x7fffffffd45b,arg4=0xfff,arg5=0x0) -> 0x13f5
SYS_rt_sigaction(arg1=0x5,arg2=0x7fffffff6970,arg3=0x0,arg4=0x8,arg5=0x0) -> 0x0
SYS_mmap(arg1=0x0,arg2=0x100000,arg3=0x3,arg4=0x22,arg5=0xffffffff) -> 0x7ffff7ef9000
SYS_getpid(arg1=0x0,arg2=0x100000,arg3=0x3,arg4=0x22,arg5=0x0) -> 0x13f2
SYS_stat(arg1=0x7fffffff6a60,arg2=0x7fffffff6d60,arg3=0x7fffffff6a1f,arg4=0x7fffffff6d60,arg5=0x0) -> 0x0
SYS_open(name=0x7fffffff6a60:   "/proc/5106/status",arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x3
SYS_read(fd=0x3,buf=0x7fffffff72d0,n=0xfff,arg4=0xfff,arg5=0x3) -> 0x4b1
[+] remove TracerPid
SYS_close(arg1=0x3,arg2=0x7fffffff72d0,arg3=0xfff,arg4=0xfff,arg5=0x0) -> 0x0
SYS_prctl(arg1=0x4,arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x0
SYS_ptrace(arg1=0x4200,arg2=0x13f5,arg3=0x0,arg4=0x10005e,arg5=0x0) -> 0x0
SYS_ptrace(arg1=0x7,arg2=0x13f5,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x0
SYS_wait4(arg1=0x13f5,arg2=0x7fffffff69ac,arg3=0x40000001,arg4=0x0,arg5=0x0) -> 0x13f5
SYS_getpid(arg1=0x13f5,arg2=0x7ffff7ef9020,arg3=0x7f,arg4=0x0,arg5=0x0) -> 0x13f2
SYS_stat(arg1=0x7fffffff6ae0,arg2=0x7fffffff6df0,arg3=0x7fffffff6a2f,arg4=0x7fffffff6df0,arg5=0x0) -> 0x0
SYS_open(name=0x7fffffff6ae0:   "/proc/5106/status",arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x3
SYS_read(fd=0x3,buf=0x7fffffff82d0,n=0xfff,arg4=0xfff,arg5=0x3) -> 0x4b1
[+] remove TracerPid
SYS_close(arg1=0x3,arg2=0x7fffffff82d0,arg3=0xfff,arg4=0xfff,arg5=0x0) -> 0x0

Program received signal SIGTRAP, Trace/breakpoint trap.
0x0000000000201496 in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
─────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
[!] Command 'registers' failed to execute properly, reason: [Errno 13] Permission denied: '/proc/35415/maps'
─────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
[!] Command 'dereference' failed to execute properly, reason: [Errno 13] Permission denied: '/proc/35415/maps'
───────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
     0x201489                  jne    0x201002
     0x20148f                  mov    eax, DWORD PTR [rip+0x24db]        # 0x203970
     0x201495                  int3
 →   0x201496                  add    eax, 0x1
     0x201499                  cmp    eax, DWORD PTR [rip+0x24d1]        # 0x203970
     0x20149f                  jne    0x201002
     0x2014a5                  xor    eax, eax
     0x2014a7                  call   0x2026a0
     0x2014ac                  mov    r8d, eax
───────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "hexalinux_patch", stopped 0x201496 in ?? (), reason: SIGTRAP
─────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x201496 → add eax, 0x1

There are two interesting things here: we cannot access the /proc/self/maps of the debuggee and there is a breakpoint instruction which we did not put there.

The reason behind the first issue is the call to SYS_prctl that we can see in the trace above. It is made with the PR_SET_DUMPABLE parameter. Apart from the obvious effect of disabling core dumps, this affects the ownership rules of the process’ proc files, which is why our debugger cannot access them anymore. I simply used the gdb script to turn the call to prctl into a call SYS_close(-1), i.e., a no-op, and afterwards adjusted the return value to indicate success.

The second observation is more interesting. As we can see in the above trace, the process calls SYS_rt_sigaction to set the signal handler for the SIGTRAP signal to a function at 0x202070.

00202070  int64_t sigtrap_handler()

00202070  f30f1efa           endbr64
00202074  8305f518000001     add     dword [rel data_203970], 0x1
0020207b  c3                 retn     {__return_addr}

00202080  uint64_t adbg_set_sigtrap_handler()

00202080  f30f1efa           endbr64
00202084  4883ec28           sub     rsp, 0x28
00202088  31d2               xor     edx, edx  {0x0}
0020208a  bf05000000         mov     edi, 0x5
0020208f  4889e6             mov     rsi, rsp {var_28}
00202092  48c7442418ffffff…  mov     qword [rsp+0x18 {var_10}], 0xffffffffffffffff
0020209b  48c7042470202000   mov     qword [rsp {var_28}], sigtrap_handler
002020a3  48c7442408000000…  mov     qword [rsp+0x8 {var_20}], 0x4000000
002020ac  48c7442410602020…  mov     qword [rsp+0x10 {var_18}], data_202060
002020b5  e806060000         call    sigaction
002020ba  85c0               test    eax, eax
002020bc  7805               js      0x2020c3

002020be  4883c428           add     rsp, 0x28
002020c2  c3                 retn     {__return_addr}

002020c3  31ff               xor     edi, edi  {0x0}
002020c5  e816040000         call    exit

Your disassembler probably did not catch that upper function on the first round, but it simply increments the memory at 0x203970 by one. The code around the breakpoint then validates that the handler runs when executing the int3 instruction, cool. Of course the handler will not run when we are debugging, which is another thing we can fix with the script.

Anti-Debug Vol. 3

Even with all these countermeasures in place, I still crashed due to an invalid memory reference. It happened at a seemingly arbitrary point while debugging the child process (before the parent attaches to it). When executing

SYS_getpid(arg1=0x0,arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x147a
SYS_stat(arg1=0x7fffffffd350,arg2=0x7fffffffd3d0,arg3=0x7fffffffd33f,arg4=0x7fffffffd3d0,arg5=0x0) -> 0x0
SYS_open(name=0x7fffffffd350:   "/proc/5242/status"
,arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x3
SYS_read(fd=0x3,buf=0x7fffffffd460,n=0xfff,arg4=0xfff,arg5=0x3) -> 0x4b0
[+] remove TracerPid
SYS_close(arg1=0x3,arg2=0x7fffffffd460,arg3=0xfff,arg4=0xfff,arg5=0x0) -> 0x0
[Attaching after process 5242 fork to child process 5245]
[New inferior 2 (process 5245)]
[Detaching after fork from parent process 5242]
[Inferior 1 (process 5242) detached]
SYS_fork(arg1=0x7fffffffd460,arg2=0x7fffffffd325,arg3=0x7fffffffd46b,arg4=0xfff,arg5=0x0) -> 0x0
SYS_setrlimit(arg1=0x4,arg2=0x7fffffffd340,arg3=0x7fffffffd46b,arg4=0x7fffffffd340,arg5=0x0) -> 0x0
SYS_getpid(arg1=0x7fffffffe480,arg2=0x7fffffffd340,arg3=0x7fffffffd46b,arg4=0x7fffffffd340,arg5=0x0) -> 0x147d
SYS_stat(arg1=0x7fffffffd330,arg2=0x7fffffffd3b0,arg3=0x7fffffffd29f,arg4=0x7fffffffd3b0,arg5=0x0) -> 0x0
SYS_open(name=0x7fffffffd330:   "/proc/5245/status",arg2=0x0,arg3=0x0,arg4=0x0,arg5=0x0) -> 0x3
SYS_read(fd=0x3,buf=0x7fffffffd440,n=0xfff,arg4=0xfff,arg5=0x3) -> 0x4b1
[+] remove TracerPid
SYS_close(arg1=0x3,arg2=0x7fffffffd440,arg3=0xfff,arg4=0xfff,arg5=0x0) -> 0x0
SYS_setrlimit(arg1=0x4,arg2=0x7fffffffd2a0,arg3=0x7,arg4=0x7fffffffd2a0,arg5=0x0) -> 0x0

Thread 2.1 "hexalinux_patch" received signal SIGBUS, Bus error.
[...]
$rbp   : 0x89a770da45244a2e
[...]
 →   0x2003be                  mov    eax, DWORD PTR [rbp+0x0]

rbp was always holding some garbage value. At this point I got kind of annoyed and started patching the code, however, this only increased my dissatisfaction as it turned out that the binary detects code modifications and gets trapped in an endless loop, charming.

I did not investigate these two issues further, but one of the other two (yea, there are more) anti-debug measures performed by the child lead me on the right track.

Anti-Debug Vol. 4

The first thing that the child does is setting the resource limit for the maximal core dump size to zero, i.e., SYS_setrlimit(0x4...). That’s not a problem as we can bypass it by turning it into a no-op close(-1) via the debugger. However, the second activity is more interesting: the child sanitizes its environment variables on the stack, removing some variables interpreted by the dynamic linker … interesting: why care about those variables in a static binary?

Aside: (I guess) that there is a bug when sanitizing the stack:

00202100  char** adbg_sanitize_env(void* argcp)

00202100  f30f1efa           endbr64
00202104  53                 push    rbx {__saved_rbx}
00202105  488d4708           lea     rax, [rdi+0x8]
00202109  4883ec20           sub     rsp, 0x20
0020210d  0f1f00             nop     dword [rax], eax

00202110  4889c2             mov     rdx, rax
00202113  4883c008           add     rax, 0x8
00202117  48833800           cmp     qword [rax], 0x0
0020211b  75f3               jne     0x202110
...

Since rdi points to points to argc, the first loop, which is meant to skip the argument vector argv, actually skips the environment variables when the program is executed with no arguments at all (yes, argv[0] is optional).

Change of Strategy

As it didn’t look like I could debug either parent or child with any meaningful results, and reversing the binary statically didn’t look fun either, (especially due do the poking of the parent via ptrace that makes it hard to reason about the child’s control flow, some obvious second stage unpacking, and potentially self-modifying code) I decided to switch tracks.

Remember that the binary printed “Get out!” to stdout? The disassembly I was looking at did not even contain a write system call! At first, I was suspecting that the write syscall would be made from shellcode, so I wrote a small BPF program that hooks the write syscall and overwrite the code after the syscall instruction with an endless loop, i.e., jmp 0x0. This would allow me to inspect whichever process made the syscall, or at least I hoped so.

SEC("tp/syscalls/sys_enter_write")
int tp_sys_enter_write(struct trace_event_raw_sys_exit* tp)
{
  struct task_struct* task = NULL;
  struct pt_regs* regs = NULL;
  int argc = 0, i = 0, ret = 0;
  char buf[16] = { 0 };
  void *ip = 0;

  // only hook child and parent
  if (bpf_get_current_comm((void*)buf, sizeof(buf))) {
    bpf_printk("error: bpf_get_current_comm\n");
    return 0;
  }
  if (__builtin_memcmp(buf, "hexalinux", 9)) {
    return 0;
  }

  // get address of insn after `syscall`
  task = (struct task_struct*)bpf_get_current_task_btf();
  regs = (struct pt_regs*)bpf_task_pt_regs(task);
  ip = (void*)BPF_CORE_READ(regs, ip);

  bpf_printk("IP: 0x%lx\n", (uint64_t)ip);

  // 0:  e9 fb ff ff ff          jmp    0x0
  if(bpf_probe_write_user(ip, "\xE9\xFB\xFF\xFF\xFF", 5)) {
    bpf_printk("error: bpf_probe_write_user\n");
    return 0;
  }

  bpf_printk("success: hooked return address\n");

  return 0;
}

Executing the binary with the BPF program loaded lead to some surprising results (those are the logs of three distinct runs).

hexalinux.bin-5416    [004] ...21 10744.055618: bpf_trace_printk: IP: 0x7f08d2da3034
hexalinux.bin-5416    [004] ...21 10744.055622: bpf_trace_printk: error: bpf_probe_write_user
hexalinux.bin-5418    [009] ...21 10749.195558: bpf_trace_printk: IP: 0x7fd507691034
hexalinux.bin-5418    [009] ...21 10749.195561: bpf_trace_printk: error: bpf_probe_write_user
hexalinux.bin-5420    [004] ...21 10749.614267: bpf_trace_printk: IP: 0x7f4b510b8034
hexalinux.bin-5420    [004] ...21 10749.614270: bpf_trace_printk: error: bpf_probe_write_user

First, the code that makes the syscall is not writable (and thus probably not shellcode, because why bother marking SC NX?). Second, its address changes on each run. Third, the address is pretty large. Replacing the write with sending a SIGSTOP, i.e., bpf_send_signal(SIGSTOP), indeed trapped the child, which was doing the write, in an endless loop. I think this is because the signal causes the write to be aborted, and a notification is sent to the parent, which then resumes the child, which in turn restarts the syscall. However, I have not read the relevant kernel code paths, so this is just a guess.

We can now inspect the child’s mappings, and voila, there is a second stage program loaded at 0x800000000, a dynamic loader, and even a libc.

% cat /proc/5523/maps
00200000-00204000 rwxp 00000000 fe:00 3716342                            /home/archie/ctf/hex23/gecko/hexalinux.bin
00a03000-00a08000 rw-p 00003000 fe:00 3716342                            /home/archie/ctf/hex23/gecko/hexalinux.bin
026f4000-02715000 rw-p 00000000 00:00 0                                  [heap]
800000000-800001000 r--p 00000000 00:00 0
800001000-800003000 r-xp 00000000 00:00 0
800003000-800005000 r--p 00000000 00:00 0
800005000-800006000 rw-p 00000000 00:00 0
b00000000-b00001000 r--p 00000000 fe:00 2362658                          /usr/lib/ld-linux-x86-64.so.2
b00001000-b00027000 r-xp 00001000 fe:00 2362658                          /usr/lib/ld-linux-x86-64.so.2
b00027000-b00031000 r--p 00027000 fe:00 2362658                          /usr/lib/ld-linux-x86-64.so.2
b00031000-b00033000 r--p 00031000 fe:00 2362658                          /usr/lib/ld-linux-x86-64.so.2
b00033000-b00035000 rw-p 00033000 fe:00 2362658                          /usr/lib/ld-linux-x86-64.so.2
7f335122c000-7f335122e000 rw-p 00000000 00:00 0
7f335122e000-7f3351254000 r--p 00000000 fe:00 2362697                    /usr/lib/libc.so.6
7f3351254000-7f33513ae000 r-xp 00026000 fe:00 2362697                    /usr/lib/libc.so.6
7f33513ae000-7f3351402000 r--p 00180000 fe:00 2362697                    /usr/lib/libc.so.6
7f3351402000-7f3351406000 r--p 001d3000 fe:00 2362697                    /usr/lib/libc.so.6
7f3351406000-7f3351408000 rw-p 001d7000 fe:00 2362697                    /usr/lib/libc.so.6
7f3351408000-7f3351412000 rw-p 00000000 00:00 0
7fff5b6de000-7fff5b6ff000 rw-p 00000000 00:00 0                          [stack]
7fff5b79c000-7fff5b7a0000 r--p 00000000 00:00 0                          [vvar]
7fff5b7a0000-7fff5b7a2000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

Seems like either the parent or the child unpacked a second stage in the child’s address space, mapped my system’s dynamic linker and then jumped right into its entry point, instructing it to load the second stage - essentially a user-land exec, neat. The write syscall was thus made using standard libc functions, not shellcode.

Dumping the second stage yields something that readelf can understand and my disassembler can load, great.

However, before starting to reverse the second stage I wanted to coerce the dynamic linker into loading frida-gadget for me. That way I could at least do some dynamic analysis to speed up the process (Since the child gets debugged, using gdb is not an option). Since the child sanitizes its stack before it is traced by the parent, it should be possible to use gdb to skip the check and detach afterwards. To check that I could get constructor code execution, and to verify my conjecture that the dynamic linker was indeed tasked to load the second stage, I wrote a small library that checks the auxiliary vector in its constructor, and indeed, it was set up to point at the second stage’s entry point.

#include <stdio.h>
#include <sys/auxv.h>

__attribute__((constructor))
void init(void)
{
  puts("Hello World");
  printf("Client base at 0x%lx\n", getauxval(AT_ENTRY));
  getchar();
}
// Hello World
// Client base at 0x800001120

Reversing the second stage is interesting as it is written under the assumption that it is being debugged by the parent. Thus, it is not surprising that its first instruction is a breakpoint. Again, there are no symbols in this binary, but this time all function calls are made through function pointers, even the call to __libc_start_main at the entry point.

800001120  int64_t _start(int64_t arg1, int64_t arg2, int64_t arg3) __noreturn

800001120  90                 nop       // was int3
800001121  0f1efa             nop     edx, edi
800001124  31ed               xor     ebp, ebp  {0x0}
800001126  4989d1             mov     r9, rdx
800001129  5e                 pop     rsi {__return_addr}
80000112a  4889e2             mov     rdx, rsp {arg_8}
80000112d  4883e4f0           and     rsp, 0xfffffffffffffff0
800001131  50                 push    rax {var_8}
800001132  54                 push    rsp {var_8} {var_10}
800001133  4c8d05160f0000     lea     r8, [rel data_800002050]
80000113a  488d0d9f0e0000     lea     rcx, [rel data_800001fe0]
800001141  488d3dc1000000     lea     rdi, [rel main]
800001148  ff15923e0000       call    qword [rel fp___libc_start_main]

Probably those function pointers are being resolved by the parent, at least the binary did not contain any relocation information that would have allowed the dynamic linker to do so. Using frida it was easy to dump the function pointer table and to convert the addresses back to libc symbols.

Interceptor.attach(Module.findExportByName(null, "write"), {
  onEnter(args) {
    var vars = ptr("0x800004f98"); // function pointers start here

    for (let i = 0; i < 13; i++, vars = vars.add(8)) {
      let addr = vars.readPointer();
      console.log(`${vars}: ${addr}`);
      if (addr != 0) {
        console.log(" -", DebugSymbol.fromAddress(addr));
      }
    }
  },
});

However, for some weird reason a few of the function pointers did not correspond to exported libc symbols. Checking the corresponding libc offsets lead to some pretty awful-looking functions, luckily, cross-references outed them as some sort of simd versions of strlen and strcpy. This, and (the fact that the resolved addresses were already present in dumps that were taken while the program was stuck in my library’s constructor) lead me to question my earlier assumption that the parent was responsible for resolving the addresses, but I didn’t investigate this issue further, time is money.

Anyway, figuring out the symbol issue is not at all useful for solving the challenge, which turned out to be a standard crackme with the key being the correct flag.

800001209  int64_t main() __noreturn

800001209      // was int3
800001237      // PTRACE_TRACEME
800001237      if (ptrace(req: 0, pid: 0, addr: 1, data: nullptr) == -1)
800001240          puts(str: "Get out!!")
800001254      else
800001254          void* rax_3 = malloc(n: 0x64)
800001262          void* rax_4 = malloc(n: 0x64)
800001277          printf(fmt: "give me The correct flag: ")
80000128f          scanf(fmt: &fmt_%s, rax_3)
8000012a4          if (strlen(s: rax_3) != 0x3d)
8000012ad              puts(str: "NOOOO R3V3RS3R!")
8000012bb          if (*rax_3 != 0x46)  // F
800001342              label_800001342:
800001342              puts(str: "NOOOO R3V3RS3R!")
8000012ca          else  // L
8000012ca              if (*(rax_3 + 1) != 0x4c)
8000012ca                  goto label_800001342
8000012d9              if (*(rax_3 + 2) != 0x41)  // A
8000012d9                  goto label_800001342
8000012e8              if (*(rax_3 + 3) != 0x47)  // G
8000012e8                  goto label_800001342
8000012f7              if (*(rax_3 + 4) != 0x7b)  // {
8000012f7                  goto label_800001342
800001300              puts(str: "you are getting somewhere!")
800001326              strncpy(dst: rax_4, src: rax_3 + 5, n: strlen(s: rax_3))
800001334              if (*rax_4 != 0x44)  // D
800001fce                  label_800001fce:
800001fce                  puts(str: "NOOOO R3V3RS3R!")
8000013a8              else // the heavy checks come here
8000013a8                  char rdx_8 = *(rax_4 + 3) ^ *(rax_4 + 5) ^ *(rax_4 + 0xb) ^ *(rax_4 + 0xf) ^ *(rax_4 + 0x14) ^ *(rax_4 + 0x16) ^ *(rax_4 + 0x1a)
8000013dc                  if (sx.d(*(rax_4 + 0x2d) ^ rdx_8 ^ *(rax_4 + 0x24)) == zx.d(*(rax_4 + 0x32) == 0x6c))
8000013dc                      goto label_800001fce
800001401                  if (sx.d(*rax_4) - sx.d(*(rax_4 + 3)) != 0xfffffffb)
800001401                      goto label_800001fce
[continues for quite a bit ...]

Interestingly, the child was issuing a second “trace me” request to go down the familiar “Get out!” path. As the child is already being traced there is no easy way to make this request succeed (I guess even if I could get the parent to exit while keeping the child alive, systemd, who would become the new parent, would not be expecting the request.) Anyway, frida can solve that for us.

Interceptor.attach(Module.findExportByName(null, "ptrace"), {
  onLeave(ret) {
    ret.replace(0);
  },
})

It’s obvious that those constraints don’t want to be solved by hand, and thus I fried up angr to solve them for me. However, cle’s default backend uses pyelftools, which was throwing an exception due to a dynamic tag in a binary without a string table (or something along those lines). Whatever, I already knew that binaryninja was working fine and thus used the experimental binja backend of cle. From here on it is really just a few lines of code to solve the challenge.

import angr
import cle
from cle.backends.binja import BinjaBin

# use binja as default loader throws exception
b = BinjaBin(
    "8251_anonymous_dump_0x800000000.bin",
    open("8251_anonymous_dump_0x800000000.bin", "rb"),
)

l = cle.Loader(b)

p = angr.Project(l)

# start of heavy checks
s = p.factory.blank_state(addr=0x1351)

# [rbp - 8] is ptr to our data +5
s.regs.rbp = 0x10000
s.mem[s.regs.rbp - 8].uint64_t = 0x11000
s.mem[0x11000].uint8_t = 0x44

# flag should be printable ascii
for i in range(1, 0x38):
    b = s.memory.load(0x11000 + i, 1)
    s.add_constraints(b < 0x7f, b >= 0x20)

sm = p.factory.simulation_manager(s)

sm.explore(find = 0x1fb4, avoid=[0x1fc7])
ss = sm.found[0]

flag = ["F", "L", "A", "G", "{"]
for i in range(0x64):
    c = chr(ss.mem[0x11000 + i].uint8_t.concrete)
    flag.append(c)
print("".join(flag)) # FLAG{DC_I_0h1nk_y0u_mad3_4_B1G_mil3sUPn3_R3V3@S3d_K33p_G01ng}

Finally, what remains is to validate the flag against the actual binary to make sure that we don’t submit a wrong result.

Conclusion

I’m not doing many reversing challenges and thus this binary had a lot it could teach me. Having designed some challenges myself I can really appreciate the amount of work that must have gone into creating such a handcrafted payload.

Looking back at my solution process, it seems like I spent too much time reversing the binary in a top-down approach. I could maybe have switched to the bottom-up approach, i.e., starting at the write syscall, a bit earlier. Anyway, I needed at least some of the top-down knowledge to be able to run frida and to dump the child process. On the other hand, I avoided going down the rabbit hole of reversing the unpacking process statically and in detail.

I’d actually be interested in seeing other solution approaches, but I doubt that there will be many writeups for such a small CTF.