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 */
; /* Object file type */
Elf64_Half e_type; /* Architecture */
Elf64_Half e_machine; /* Object file version */
Elf64_Word e_version; /* Entry point virtual address */
Elf64_Addr e_entry; /* Program header table file offset */
Elf64_Off e_phoff; /* Section header table file offset */
Elf64_Off e_shoff; /* Processor-specific flags */
Elf64_Word e_flags; /* ELF header size in bytes */
Elf64_Half e_ehsize; /* Program header table entry size */
Elf64_Half e_phentsize; /* Program header table entry count */
Elf64_Half e_phnum; /* Section header table entry size */
Elf64_Half e_shentsize; /* Section header table entry count */
Elf64_Half e_shnum; /* Section header string table index */
Elf64_Half e_shstrndx} 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
= ELF("./patched")
e
3]
e.Elf64_Phdr[#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
3].p_flags = 5
e.Elf64_Phdr["patched_perm") e.save(
$ 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
.
mov rax,QWORD PTR [rip+0x2de9] # 0x404058
0x0000000000401268: rdx,0xdeadbeefcafebabe
0x000000000040126f: movabs cmp rax,rdx
0x0000000000401279: je 0x4012a3 0x000000000040127c:
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 :
("[+] Here is your flag:");
putswhile ( 1 )
{
= fflush_0(stream);
v4 if ( v4 == -1 )
break;
(v4);
putchar}
(stdout);
fflush(strea fclose
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.
-T ./patched_perm
$ objdump
:
DYNAMIC SYMBOL TABLE0000000000000000 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
= ELF("./patched")
e '.dynstr').data.split(b"\0")
e.get_section_by_name(
# 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
'.dynstr').data[0x31:].split(b"\0")[0]
e.get_section_by_name(# 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
(
]
= bytearray(open("get_flag.bkp", "rb").read())
data
for patch in patches:
= patch
offset, val = val
data[offset]
open("patched", "wb").write(bytes(data))
# and for remote challenge
= remote("static-02.heroctf.fr", 6000)
p
for patch in patches:
= patch
off, val hex(off)[2:])
p.sendline(hex(val)[2:])
p.sendline(
p.interactive()