CTF Writeup - UIUCTF 2020 - Redd's Art

23 Jul 2020
Tags: ctf reversing bruteforce tracing

Introduction

This solution relies on pwndbg to execute relevant functions, while circumventing invalid operations. Although it was possible to solve this task by adapting the decompiled functions, I wanted to investigate an approach that relied less on reimplementing the executable’s code.

Description

Redd has an enticing deal for you. Will you take it?

The task’s executable implements a dialog which takes user input, with some branching options. Regardless of our choices, we are always given a fake flag at the end.

Analysis

The dialog had delays implemented as usleep() calls, which I patched to speed up tests:

- b55:	bf 50 c3 00 00       	mov    $0xc350,%edi
+ b55:	bf 00 00 00 00       	mov    $0x0,%edi
  b5a:	e8 91 fc ff ff       	callq  7f0 <usleep@plt>

If obfuscation is applied to executable sections, we might see some invalid instructions. When we dissassemble those sections:

objdump -d ReddsArt | grep '(bad)'

We do find those instructions:

 980:	17                   	(bad)
 981:	1e                   	(bad)
[...]

With ghidra, we find that address 0x973 is the start of this invalid block, right after the end of the previous function (stack frame cleared with ADD RSP,0x18, base pointer restored with POP RBP, jump to return address with RET):

0010096c 48 83 c4 18     ADD        RSP,0x18
00100970 5b              POP        RBX
00100971 5d              POP        RBP
00100972 c3              RET
                     DAT_00100973             XREF[5]:     00100a6f(*), 00100a76(*),
                                                           00100a90(R), 00100aa9(W),
                                                           0010168c
00100973 4b              ??         4Bh    K
00100974 56              ??         56h    V
[...]
00100980 17              ??         17h
00100981 1e              ??         1Eh
[...]
00100a58 43              ??         43h    C
00100a59 dd              ??         DDh
                     LAB_00100a5a             XREF[1]:     00101694
00100a5a 55              PUSH       RBP
00100a5b 48 89 e5        MOV        RBP,RSP
00100a5e 48 83 ec 10     SUB        RSP,0x10

By following XREF 00100a6f, we get to a function that starts right after the invalid block (base pointer saved with PUSH RBP, stack frame allocated with SUB RSP,0x10). Although ghidra didn’t decompile it, we can select the function block and apply Context Menu > Create function. Now it is decompiled to:

void FUN_00100a5a(void) {
  byte bVar1;
  int local_18;

  bVar1 = FUN_0010091a();
  local_18 = 0;
  while (local_18 < 0xe7) {
    (&DAT_00100973)[local_18] = (&DAT_00100973)[local_18] ^ bVar1;
    local_18 = local_18 + 1;
  }
  return;
}

So the invalid block is being deobfuscated by this function. With objdump --section-headers, we confirm the invalid block is part of section .text (starts at 0x810 and ends at 0x810 + 0x742 = 0xf52, so it contains 0x973..0xa59):

 13 .text         00000742  0000000000000810  0000000000000810  00000810  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

With readelf -a, we confirm that this section is part of an executable segment (flag = E) of type LOAD:

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000001f8 0x00000000000001f8  R      0x8
  INTERP         0x0000000000000238 0x0000000000000238 0x0000000000000238
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000001998 0x0000000000001998  R E    0x200000
[...]
 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash
          .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
          .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
[...]

Note that the segment isn’t writable, yet the deobfuscation function is writing to it. A segmentation fault will occur when the memory gets written. Since the program ends without SIGSEGV, we can be sure the function isn’t called in the normal execution flow.

Nevertheless, it is possible to call it directly with gdb.

Finding an address to jump from

readelf -a informs us that this is a dynamically linked position independent executable, starting at 0x810:

Entry point address:               0x810
[...]
Dynamic section at offset 0x1d98 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 [...]
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE

Therefore, we should run the process until all the shared libraries have been loaded, so no sooner than the start of main. That symbol was stripped, so we need to figure out that address. We need to map section offsets to the process address space, taking into account that the executable is position independent.

gdb disables ASLR by default, so addresses are consistent between runs. We retrieve the mappings by first running up to the first instruction (using starti or b *0 + r) then info proc map:

   Start Addr           End Addr       Size     Offset objfile
0x555555554000     0x555555556000     0x2000        0x0 ReddsArt2
0x555555755000     0x555555757000     0x2000     0x1000 ReddsArt2

While starti puts us in the dynamic linker loading routine (before CALL _dl_start), the entrypoint 0x810 puts us in the libc loading routine (before CALL __libc_start_main). We can break with b *(0x555555554000 + 0x810), but we still need to advance a bit until main is called:

0x7ffff7ddd040 <__libc_start_main+240>    call   rax       <0x555555554bea>

Enabling writes in executable segment

At runtime, the libc function mprotect can be called to change the access protections of segments, which are divided in pages. Therefore, we call it with the 4k page-aligned address we want to write to, along with bit mask 7 to set RWX:

p (int)mprotect((0x555555554000 + 0x973) - (0x555555554000 + 0x973)%4096, 4096, 7)

On success, it returns 0.

Calling deobfuscation function

Let’s jump to it by setting the instruction pointer to it’s start address, then breaking before it ends, validating if the deobfuscation worked:

set $rip = (0x555555554000 + 0xa5a)
b *(0x555555554000 + 0xab8)
c
disassemble /r (0x555555554000 + 0x973),(0x555555554000 + 0x973 + 0xe7)

We should see valid instructions:

Dump of assembler code from 0x555555554973 to 0x555555554a5a:
   0x0000555555554973:  55      push   rbp
   0x0000555555554974:  48 89 e5        mov    rbp,rsp
   0x0000555555554977:  53      push   rbx
   0x0000555555554978:  48 83 ec 28     sub    rsp,0x28
   0x000055555555497c:  48 c7 45 e8 09 00 00 00 mov    QWORD PTR [rbp-0x18],0x9
   0x0000555555554984:  48 8b 45 e8     mov    rax,QWORD PTR [rbp-0x18]
[...]

Using the Python API of gdb, we can take these instructions from the process memory and write them to a new executable.

Here’s a script with all these commands put together, that generates a new executable with the deobfuscated block. Run with gdb -x $script $executable:

import gdb
import os
import stat

# start of main
gdb.execute("b *(0x555555554000 + 0xBEA)")
gdb.execute("r")

# enable writes on obfuscated block
aligned_addr = (0x555555554000 + 0x973) - (0x555555554000 + 0x973) % 4096
gdb.execute("p (int)mprotect({}, 4096, 7)".format(aligned_addr))

# goto start of deobfuscator function
gdb.execute("set $rip = (0x555555554000 + 0xA5A)")
gdb.execute("b *(0x555555554000 + 0xAB8)")
gdb.execute("c")

# validate deobfuscated instructions
gdb.execute("disassemble /r (0x555555554000 + 0x973),(0x555555554000 + 0x973 + 0xE7)")

i = gdb.inferiors()[0]
m = i.read_memory(0x555555554000 + 0x973, 0xE7)

process_name = os.path.basename(gdb.current_progspace().filename)
with open(process_name, "rb") as f:
    process_bytes = bytearray(f.read())
process_bytes[0x973 : 0x973 + 0xE7] = m.tobytes()
process_name += "_deobfuscated"
with open(process_name, "wb") as f:
    f.write(process_bytes)
os.chmod(process_name, os.stat(process_name).st_mode | stat.S_IEXEC)

gdb.execute("q")

The invalid block was converted to a function, which is decompiled to:

void FUN_00100973(void) {
  char cVar1;
  byte bVar2;
  size_t sVar3;
  int local_2c;
  int local_28;

  cVar1 = *(char *)((long)DAT_00000009 + 9);
  local_2c = 0;
  while( true ) {
    sVar3 = strlen(PTR_s_hthzgubI_00302028);
    if (sVar3 <= (ulong)(long)local_2c) break;
    PTR_s_hthzgubI_00302028[local_2c] = PTR_s_hthzgubI_00302028[local_2c] + cVar1;
    local_2c = local_2c + 1;
  }
  bVar2 = FUN_0010091a();
  local_28 = 0;
  while( true ) {
    sVar3 = strlen(PTR_s_hthzgubI_00302028);
    if (sVar3 <= (ulong)(long)local_28) break;
    PTR_s_hthzgubI_00302028[local_28] = PTR_s_hthzgubI_00302028[local_28] ^ bVar2;
    local_28 = local_28 + 1;
  }
  return;
}

It is manipulating a string which could be the real flag. Let’s break down this function:

Deobfuscating the flag

Our approach here will be similar: we jump to the start, enable writes in the page that contains PTR_s_hthzgubI, and inspect the results at the end with a breakpoint. However, we also need to deal with the invalid read for cVar1. We have no idea which value should be here, besides that it is a char, so between 0 and 255. To handle this case:

It is important to restore the memory state when jumping back, otherwise we would be reusing the previous values of PTR_s_hthzgubI during the candidate loop! This implies making a backup of the real flag bytes.

Wrapping it all up in a gdb script, which includes the deobfuscation from the previous script:

import gdb
import os
import re

# start of main
gdb.execute("b *(0x555555554000 + 0xBEA)")
gdb.execute("r")

# enable writes on obfuscated block (0x973) and string `PTR_s_hthzgubI` (0xFC0)
aligned_addr = (0x555555554000 + 0x973) - (0x555555554000 + 0x973) % 4096
gdb.execute("p (int)mprotect({}, 4096, 7)".format(aligned_addr))

# run deobfuscator function
gdb.execute("set $rip = (0x555555554000 + 0xA5A)")
gdb.execute("b *(0x555555554000 + 0xAB8)")
gdb.execute("c")

# backup obfuscated string
i = gdb.inferiors()[0]
original_obf_str = i.read_memory(0x555555554000 + 0xFC0, 30)

# invalid address store
gdb.execute("b *(0x555555554000 + 0x97C)")

# end of deobfuscated function (before freeing stack frame)
gdb.execute("b *(0x555555554000 + 0xA52)")

# goto start of deobfuscated function
gdb.execute("set $rip = (0x555555554000 + 0x973)")

for candidate in range(0, 256):
    gdb.execute("c")

    # store our candidate
    gdb.execute("set $rax = {}".format(candidate))

    # skip invalid memory read and stores
    gdb.execute("set $rip = (0x555555554000 + 0x99A)")
    gdb.execute("c")

    # verify if candidate generates flag
    try:
        result = bytearray(i.read_memory(0x555555554000 + 0xFC0, 30))
        print("result = {}".format(result))
        if re.search("uiuctf".encode(), result):
            gdb.execute("q")
            exit()
    except Exception as e:
        print(e)
    finally:
        # undo memory changes, then try next candidate
        i.write_memory(0x555555554000 + 0xFC0, original_obf_str, 30)
        gdb.execute("set $rip = (0x555555554000 + 0x97C)")

When we run it, we get the flag:

[...]
Breakpoint 3, 0x0000555555554a52 in ?? ()
=> 0x0000555555554a52:  90      nop
result = bytearray(b'uiuctf{R_3dd$_c0Uz1n_D1$c0unT}')