HeroCTF v5 - sELF Control v3 (reverse)

HeroCTF v5 - sELF Control v3 (reverse)

This weekend (12 to 14 May 2023) was under the HeroCTF v5 CTF. A nice reverse challenge caught my attention (12 solves only. Its goal made be thought about patchinko.

Given the following binary, the remote server allowed you to make 5 patches on the binary itself before executing it.

something useful to understand for this challenge is that sections are the representation of the elf file from a compilation point of view, whereas segments are the representation of the elf file from the loader / memory point of view.

Both represent the same file, but not the same way at the same time.

Looking at the decompilation, it is pretty straightforward to identify the way to get the flag.

The flag.txt is read, then a verification with a qword in .bss is done, and finally the flag is printed on stdout through putchar.

However, executing the binary make me realize that we NEED to patch the binary. The loader simply won’t load it successfully.

$ ld.so ./get_flag
get_flag: error while loading shared libraries: get_flag: cannot open shared object file

This is maybe because it’s a shared object and not an executable file as assumed.

$ eu-readelf -h ./get_flag

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Ident Version:                     1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           AMD x86-64
  Version:                           1 (current)
  Entry point address:               0x4010fa
  Start of program headers:          64 (bytes into file)
  Start of section headers:          12704 (bytes into file)
  Flags:
  Size of this header:               64 (bytes)
  Size of program header entries:    56 (bytes)
  Number of program headers entries: 13
  Size of section header entries:    64 (bytes)
  Number of section headers entries: 29
  Section header string table index: 28

According to the Elf header structure (Elf64_Ehdr), the 0x10 byte define the type of the file. 3 means dynamicalled shared object (.so) whereas 2 means executable file. First patch to apply : 0x10 = 0x2

typedef struct
{
  unsigned char e_ident[EI_NIDENT];     /* Magic number and other info */
  Elf64_Half    e_type;                 /* Object file type */
  Elf64_Half    e_machine;              /* Architecture */
  Elf64_Word    e_version;              /* Object file version */
  Elf64_Addr    e_entry;                /* Entry point virtual address */
  Elf64_Off     e_phoff;                /* Program header table file offset */
  Elf64_Off     e_shoff;                /* Section header table file offset */
  Elf64_Word    e_flags;                /* Processor-specific flags */
  Elf64_Half    e_ehsize;               /* ELF header size in bytes */
  Elf64_Half    e_phentsize;            /* Program header table entry size */
  Elf64_Half    e_phnum;                /* Program header table entry count */
  Elf64_Half    e_shentsize;            /* Section header table entry size */
  Elf64_Half    e_shnum;                /* Section header table entry count */
  Elf64_Half    e_shstrndx;             /* Section header string table index */
} Elf64_Ehdr;

Trying to execute the binary again make it runs, but segfault even before the entry point. This mean that the loader struggle to load and execute the binary.

After some diffing with another valid binary, I saw a difference in the program headers (segments for muggles).

$ eu-readelf --segments <binary>
Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000400040 0x0000000000400040 0x0002d8 0x0002d8 R   0x8
  INTERP         0x000318 0x0000000000400318 0x0000000000400318 0x00001c 0x00001c R   0x1
        [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000400000 0x0000000000400000 0x000658 0x000658 R   0x1000
  LOAD           0x001000 0x0000000000401000 0x0000000000401000 0x000305 0x000305 R   0x1000
  LOAD           0x002000 0x0000000000402000 0x0000000000402000 0x0001e8 0x0001e8 R   0x1000
  LOAD           0x002e08 0x0000000000403e08 0x0000000000403e08 0x000258 0x000268 RW  0x1000
  DYNAMIC        0x002e20 0x0000000000403e20 0x0000000000403e20 0x0001d0 0x0001d0 RW  0x8
  NOTE           0x000338 0x0000000000400338 0x0000000000400338 0x000030 0x000030 R   0x8
  NOTE           0x000368 0x0000000000400368 0x0000000000400368 0x000044 0x000044 R   0x4
  GNU_PROPERTY   0x000338 0x0000000000400338 0x0000000000400338 0x000030 0x000030 R   0x8
  GNU_EH_FRAME   0x0020c8 0x00000000004020c8 0x00000000004020c8 0x000044 0x000044 R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x002e08 0x0000000000403e08 0x0000000000403e08 0x0001f8 0x0001f8 R   0x1
  
  Segment Sections...
   00
   01      [RO: .interp]
   02      [RO: .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt]
   03      [RO: .init .plt .plt.sec .text .fini]
   04      [RO: .rodata .eh_frame_hdr .eh_frame]
   05      [RELRO: .init_array .fini_array .dynamic .got] .got.plt .data .bss
   06      [RELRO: .dynamic]
   07      [RO: .note.gnu.property]
   08      [RO: .note.gnu.build-id .note.ABI-tag]
   09      [RO: .note.gnu.property]
   10      [RO: .eh_frame_hdr]
   11
   12      [RELRO: .init_array .fini_array .dynamic .got]
Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000400040 0x0000000000400040 0x0002a0 0x0002a0 R   0x8  
  INTERP         0x0002e0 0x00000000004002e0 0x00000000004002e0 0x00001c 0x00001c R   0x1  
        [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000400000 0x0000000000400000 0x000918 0x000918 R   0x1000
  LOAD           0x001000 0x0000000000401000 0x0000000000401000 0x000931 0x000931 R E 0x1000
  LOAD           0x002000 0x0000000000402000 0x0000000000402000 0x0004c8 0x0004c8 R   0x1000
  LOAD           0x0024c8 0x00000000004034c8 0x00000000004034c8 0x0002a0 0x000358 RW  0x1000
  DYNAMIC        0x0024d8 0x00000000004034d8 0x00000000004034d8 0x0001d0 0x0001d0 RW  0x8
  NOTE           0x000300 0x0000000000400300 0x0000000000400300 0x000030 0x000030 R   0x8
  NOTE           0x000330 0x0000000000400330 0x0000000000400330 0x000044 0x000044 R   0x4
  GNU_PROPERTY   0x000300 0x0000000000400300 0x0000000000400300 0x000030 0x000030 R   0x8
  GNU_EH_FRAME   0x002300 0x0000000000402300 0x0000000000402300 0x000064 0x000064 R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  
 Section to Segment mapping:
  Segment Sections...
   00
   01      [RO: .interp]
   02      [RO: .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt]
   03      [RO: .init .plt .plt.sec .text .fini]
   04      [RO: .rodata .eh_frame_hdr .eh_frame]
   05      .init_array .fini_array .dynamic .got .got.plt .data .bss
   06      .dynamic
   07      [RO: .note.gnu.property]
   08      [RO: .note.gnu.build-id .note.ABI-tag]
   09      [RO: .note.gnu.property]
   10      [RO: .eh_frame_hdr]
   11

Looking at the 4th segments (03), the one who holds all the code sections (.text, .plt, …), is only readable and not executable. In the second output, for the valid file, the segment is marked R E, but should be marked as R X. I don’t know why it is E instead.

So the segment permissions must be changed. For this I will use Hellf. According to this, RX permission will be the constant 5

from Hellf import ELF

e = ELF("./patched")

e.Elf64_Phdr[3]
#ELF Program header struct
#  p_type:       0x1
#  p_flags:      0x4
#  p_offset:     0x1000
#  p_vaddr:      0x401000
#  p_paddr:      0x401000
#  p_filesz:     0x305
#  p_memsz:      0x305
#  p_align:      0x1000

e.Elf64_Phdr[3].p_flags = 5
e.save("patched_perm")
$ ld.so ./patched_perm

[!] Process is starting...
[-] Unfortunatly, you're not allowed to get the flag :(
[1]    67129 segmentation fault (core dumped)  ld.so ./patched_perm

Noice, the elf is now running, even if It segfaults at the end. The next step is to pass the check at line 9 in the decompilation.

It should be easy, looking at the disassembly it simply does a cmp and je.

0x0000000000401268:  mov    rax,QWORD PTR [rip+0x2de9]        # 0x404058
0x000000000040126f:  movabs rdx,0xdeadbeefcafebabe
0x0000000000401279:  cmp    rax,rdx
0x000000000040127c:  je     0x4012a3

The easiest way to go through is to patch the jump if equal 0x74 (je) to transform it into a jump if not equal 0x75 (jne). First we need to find the offset of the opcode. I will use bgrep for :

gdb >  x/2xg 0x0000000000401279 # address of cmp rax,rdx
0x401279:       0x058d482574d03948      0xe8c7894800000df3

$ bgrep 4839D074 ./patched_perm
patched_perm: 00001279

However, if we check it on the remote challenge, the following message is indicating that patching the .plt or .text section is forbidden.

This pretty sucks as 5 patches aren’t enough to write 0xdeadbeefcafebabe in .bss, we can’t patch a hypothetical reference to the value as it’s embedded within the movabs instruction.

IDA doesn’t list any interesting functions that could help us. But Ghidra does.

IDA doesn’t find it as the prologue isn’t present in the function and there are no Xref to it. The function will simply write the magic value to the right address in .bss making the comparison true. We just need to call if without touching .plt and .text.

This can be achieved by registering a function inside .init_array which is called before main.

$ objdump -s -j .init_array patched_perm

Contents of section .init_array:
 403e08 d0114000 00000000 d6114000 00000000  ..@.......@.....

The output of the section shows 2 registered functions : - sub_4011D0 : that does nothing - sub_4011D6 : printing “Process is starting…”

The first function can be replaced in order to call sub_4011f0. Lucky us, we only need to change the last byte (0xd0), so the patch is 0x2e08 = 0xf0. I also used bgrep to find the offset.

$ ./patched
[!] Process is starting...
[+] Here is your flag:
^C

The verification is passed, but the flag is not displayed. This is due to the fflush_0 call :

puts("[+] Here is your flag:");
while ( 1 )
{
v4 = fflush_0(stream);
if ( v4 == -1 )
  break;
putchar(v4);
}
fflush(stdout);
fclose(strea

fflush doesn’t return a char from the stream, it only returns 1 or 0 as a success / failure status. Actually, this code will never output the flag. Another function than fflush must be called to read from the buffer. But looking at the imported function in IDA / Ghidra didn’t show such functions.

To go further, it is compulsory to understand how external resolution is made for ELF file. I won’t explain it here, but the ASCII name of the symbols hold by .dynstr is used to find the symbol in it library. If we modify a name in .dynstr we may call another function in the libc.

Sadly, there are 2 patches left, and we can’t change the name of a function with to get a function like fgets or getc.

I got a bit lost here before my friend Nofix pointed me why fflush_0 was mentioned in the decompilation instead of fflush. According to him, it means IDA found 2 entries in the import table that point to the same function. IDA will label the second with _0.

This caught my attention and I looked to the import of the elf where fflush is mentionned twice.

$ objdump -T ./patched_perm

DYNAMIC SYMBOL TABLE:
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) putchar
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.34) __libc_start_main
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) puts
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) fclose
0000000000000000  w   D  *UND*  0000000000000000  Base        __gmon_start__
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) fflush
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) fopen
0000000000000000      DF *UND*  0000000000000000 (GLIBC_2.2.5) fflush
0000000000404060 g    DO .bss   0000000000000008 (GLIBC_2.2.5) stdout

At this point I wanted to see which function were holded in .dynstr

from Hellf import ELF

e = ELF("./patched")
e.get_section_by_name('.dynstr').data.split(b"\0")

# b'__libc_start_main',
# b'getc',
# b'fopen',
# b'fclose',
# b'stdout',
# b'puts',
# b'fflush',
# b'putchar',
# b'libc.so.6',
# b'GLIBC_2.34',
# b'GLIBC_2.2.5',
# b'__gmon_start__',

getc is inside .dynstr but not mentioned in the symbols. It means that a 2 entries inside .dynsym point to the same string in .dynstr.

from switch import nsplit

len(e.get_section_by_name('.dynsym').data)
# 240
# 240 / 10 entries = 24 Elf64_Sym

# typedef struct {
#        Elf64_Word      st_name; <--- this is the offset to .dynstr
#        unsigned char   st_info;
#        unsigned char   st_other;
#        Elf64_Half      st_shndx;
#        Elf64_Addr      st_value;
#        Elf64_Xword     st_size;
# } Elf64_Sym;

for i in nsplit(e.get_section_by_name('.dynsym').data, 24):
    print(hex(i[0]))
   
# 0x0
# 0x38
# 0x1
# 0x2c
# 0x1e
# 0x61
# 0x31
# 0x18
# 0x31
# 0x25

e.get_section_by_name('.dynstr').data[0x31:].split(b"\0")[0]
#  b'fflush'

hex(e.get_section_by_name('.dynstr').data.index(b"getc"))
# '0x13'

Two entries point to 0x31 which is fflush. Just patch the one to point to getc which offset is 0x13. The patch is 0x498 = 0x13. Once the new file decompiled it’s better :

 $ ./patched   
 
[!] Process is starting...
[+] Here is your flag:    
heroctf{fakeflag}
[1]    68513 segmentation fault (core dumped)  ./patched

It does segfault but it prints the flag, who cares ? The remote server cares. If the remote process crashes the flag isn’t displayed by the server. Now, the cause of the segfault must be found.

After some debugging, I found that the segfault occurs after __libc_start_main within the exit function. I thought an entry in .fini_array was causing the crash but nop.

In fact, looking at the entry point show us that the first execute operation is mov rdx,rsp instead of push rbp like a classical prologue. push rbp is 0xa instructions before so the entry point can be changed to start there. The entry point is, according to the elf header, at the offset 0x18 of the file so the patch is 0x18 = 0xf0.

Full script :

from pwn import *

patches = [
    (0x18, 0xf0), # wrong entrypoint (missing some prolog opcode)
    (0x10, 2), # type
    (0xec, 5), # wrong perm
    (0x2e08, 0xf0), # call function deadbeef
    (0x0498, 0x13), # ffLUSH to getc
]

data = bytearray(open("get_flag.bkp", "rb").read())

for patch in patches:
    offset, val = patch
    data[offset] = val

open("patched", "wb").write(bytes(data))

# and for remote challenge

p = remote("static-02.heroctf.fr", 6000)

for patch in patches:
    off, val = patch
    p.sendline(hex(off)[2:])
    p.sendline(hex(val)[2:])

p.interactive()