HTB ReRop

Kaddate |

Difficulty : Hard
Category : Reversing


Even tho it's an "hard" challenge, I found it easy enough as it was very straitforward in what it was doing, with no real intention to hide. Even tho the technical realization of the solver took me a while, because I lacked of tooling.

We start with a simple main() function, that takes our input and pass it into a second function called check() :

57401f0e899b6cb91ae691e434124726.png

While the decompiler view says that the function check() does nothing, it actually changes the address where the stack points to another "stack" hardcoded within the binary. Doing this will deviate the initial workflow of the program, effectively doing some Return Oriented Programming.

33d33ab6454e1cf2dbd08430c7a04c19.png

My first call is to launch the program with gdb to check what it tries to do. And depending on its complexity, I will decide what's the best way to solve the challenge.

The first thing done by the Rop chain, is a simple anti debug mechanism that does a syscall sys_ptrace(TRACE_ME) to check if it's being debugged, if so, the program exits :

3be7b7a0bc3f751a84b29031ef61ed7a.png

A pseudo decompiled view would do something like this :

pseudo_view.c
// tells ppid to trace the program
// $16 = {0x65, 0x0, 0x1, 0x0, 0x6e}
if (sys_ptrace(PTRACE_TRACEME) == -1) {
// $53 = {0x1, 0x1, 0x4c57e8, 0xe, 0x6e}
  sys_write(stdout, "Nope", 12);
  exit();
}

At this point there are multiple options to bypass the anti-debugging checks. The simplest would be to patch some instructions, but I don’t know yet whether I would be patching a gadget that will be used later in the ROP chain. For now I’ll just patch the registers manually for the next executions.

Digging further into the program’s logic, I observed a repeating pattern: it takes a character from the user input, performs some reversible operations on it, and finally checks whether the result equals zero. If it isn’t zero, the character was incorrect. The program sets rdx to 1 when a character is incorrect, and at the end it checks whether the input was correct by inspecting rdx.

So what's the approach now ?

I almost never use z3 but I think it could be possible to solve it with z3 by adding in constrains : like that rdx should never be equal to 1.
A simpler approach to ease reversing would be to run the program under Unicorn and disassemble only the executed code to obtain a clearer trace. However, VM execution stopped when, inside the check function, the program rewrote its stack and returned. I’m not sure of the exact issue, but it was probably caused by not instantiating the ELF sections correctly.

Then how to proceed?

I decided to write a small program that walks the stack and emulates its behavior. I didn’t know how to do it easily in Python at first, but pwntools turned out to be perfect for that, combined with iced_x86 for disassembly.

Basically, the program is simple: it crawls through the data, emulates a pop() when needed, and reverses the validation checks for each character.

from pwn import *
from iced_x86 import Decoder, Formatter, FormatterSyntax, DecoderOptions, Mnemonic

def handle_exception(exc_type, exc_value, exc_traceback):
    string_output = ''.join(chr(i) for i in flag)
    print(string_output)
sys.excepthook = handle_exception

flag = [0] * 30
binary = ELF('./rerop', checksec=False)

def analyze():
    data_addr = binary.symbols.get('data')
    formatter = Formatter(FormatterSyntax.INTEL)

    data_cache = []
    while True:
        sp = u64(binary.read(data_addr, 8))
        data_addr += 8

        gadget = binary.read(sp, 10 * 8)
        decoder = Decoder(64, gadget, ip=sp, options=DecoderOptions.NONE)

        for instr in decoder:
            instr_str = formatter.format(instr)
            if instr.mnemonic == Mnemonic.INVALID:
                return
            elif instr.mnemonic == Mnemonic.POP:
                pop = binary.read(data_addr, 8)
                data_addr += 8
                value = int.from_bytes(pop, byteorder='little')
                data_cache.append(value)
                print(f"{instr.ip:016X} {instr_str}         : {value:016X}")
            elif instr.mnemonic == Mnemonic.RET:
                break
            elif instr.mnemonic == Mnemonic.CMOVNE:
                char = int('0') 
                char += data_cache[-1]
                char ^= 5
                char -= data_cache[-3]
                flag[data_cache[-3]] = char
                data_cache = []
                print(f"{instr.ip:016X} {instr_str}")
            else:
                print(f"{instr.ip:016X} {instr_str}")


if __name__ == "__main__":
    analyze()
    print(flag)

This gives us a clearer understanding of the program and makes its logic easier to analyze.

(venv) ➜  rev_rerop python3 depacker.py          
0000000000450EC7 pop rax         : 0000000000000065
0000000000401EEF pop rdi         : 0000000000000000
0000000000409F1E pop rsi         : 0000000000000001
0000000000458142 pop rdx         : 0000000000000000
000000000041AAB6 syscall
0000000000451FE0 mov rdi,rax
0000000000450EC7 pop rax         : 0000000000001198
0000000000452000 mov rsi,rax
0000000000452003 xor rbx,rbx
0000000000452006 test rdi,rdi
0000000000452009 cmovs rbx,rsi
000000000045200D add rsp,rbx
0000000000458142 pop rdx         : 0000000000000000
0000000000401EEF pop rdi         : 00000000004C7820
0000000000450EC7 pop rax         : 0000000000000019
0000000000451FF0 add rdi,rax
0000000000451FE8 mov rax,rdi
000000000045202F movzx rax,byte ptr [rax]
0000000000451FE0 mov rdi,rax
0000000000450EC7 pop rax         : 0000000000000019
0000000000451FF0 add rdi,rax
0000000000450EC7 pop rax         : 0000000000000005
0000000000451FF8 xor rdi,rax
0000000000450EC7 pop rax         : 000000000000006E
0000000000451FEC sub rdi,rax
0000000000452011 mov esi,1
0000000000452016 test rdi,rdi
0000000000452019 cmovne rdx,rsi

A pseudo decompilation of the process would look like this :

pseudo_view.c
// buf is the user input
// idx and val are both hardcoced datas that changes at each chars validation
for (int i = 0; i <= flag_len; i++) {
  res = ((buf[idx] + idx) ^ 5) - val
  if (res != 0) {
    exit();
  }
}

The operations performed on the characters are all reversible (add, xor, sub). for each characters the program executes :

- add 0x19
- xor 5
- sub 0x6e

So I hook the cmovne instruction, And then I perform the reversed operations for each characters :

- add 0x6e
- xor 5
- sub 0x19

Then the flag is revealed :) HTB{W4iT_W4S_Th@t_PWN_0R_R3V}