ReRop
10 minutes to read
We are given a 64-bit binary called rerop
:
$ file rerop
rerop: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=0f6c70533a1090f9215451cd4d03a4bd6f387264, for GNU/Linux 3.2.0, not stripped
$ ./rerop
Enter the flag: HTB{asdf}
Nope
Reverse engineering
If we open the binary in IDA, we will see the following main
function:
int __fastcall main(int argc, const char** argv, const char** envp) {
printf("Enter the flag: ");
fgets(buf, 64, stdin);
buf[j_strcspn_ifunc(buf, "\n")] = 0;
check(&data_0);
puts(buf);
return 0;
}
It looks pretty simple, right? It only takes use input to buf
(a global variable at 0x4c7820
), and then calls check
with data
(another global variable at 0x4c5100
) as an argument; notice that the binary is not PIE, so all addresses are static regardless of ASLR. This is check
:
void check() {
;
}
Even simpler! No way this is the function… Let’s analyze the assembly code:
$ objdump -M intel --disassemble-symbols=check rerop
rerop: file format elf64-x86-64
Disassembly of section .text:
00000000004017b5 <check>:
4017b5: f3 0f 1e fa endbr64
4017b9: 48 8d 27 lea rsp, [rdi]
4017bc: c3 ret
4017bd: 90 nop
4017be: 0f 0b ud2
Alright, so this function simply takes the first argument ($rdi
) and copies that to $rsp
. And here comes the magic: the ret
instruction, which
Transfers program control to a return address located on the top of the stack
However, the top of the stack ($rsp
) has been replaced by $rdi
, so the program is actually returning to an address inside of the data
buffer.
Let’s take a look at this buffer with xxd
:
$ xxd -e -g 8 -s 0xc4100 -c 8 rerop | head -20
000c4100: 0000000000450ec7 ..E.....
000c4108: 0000000000000065 e.......
000c4110: 0000000000401eef ..@.....
000c4118: 0000000000000000 ........
000c4120: 0000000000409f1e ..@.....
000c4128: 0000000000000001 ........
000c4130: 0000000000458142 B.E.....
000c4138: 0000000000000000 ........
000c4140: 000000000041aab6 ..A.....
000c4148: 0000000000451fe0 ..E.....
000c4150: 0000000000450ec7 ..E.....
000c4158: 0000000000001198 ........
000c4160: 0000000000452000 . E.....
000c4168: 0000000000458142 B.E.....
000c4170: 0000000000000000 ........
000c4178: 0000000000401eef ..@.....
000c4180: 00000000004c7820 xL.....
000c4188: 0000000000450ec7 ..E.....
000c4190: 0000000000000019 ........
000c4198: 0000000000451ff0 ..E.....
We need to subtract 0x4c5100 - 0x401000
to get the actual offset within the ELF file. As can be seen, we only have addresses within the binary (0x4.....
) and other numbers.
Return-Oriented Programming
If you are not familiar with Return-Oriented Programming (ROP), this might be a little weird. This technique is mainly used in binary exploitation (pwn) in order to achieve arbitrary code execution when there are no executable memory addresses to place custom shellcode. The idea of this technique is to reuse instructions from the binary or shared libraries to execute the parts needed to get the desired result. For this, the control flow of the program must be controlled, so that the program can be redirected to anywhere.
The use ROP depends on gadgets. These are sequences of instructions that end typically in ret
(others can end in jmp
or call
). For example, pop rdi; ret
is a very useful gadget, because it takes the next value from the stack and puts it in $rdi
(the first argument of a function); and then returns to the next address within the stack. By chaining several gadget address on the stack (known as ROP chain), one can achieve almost arbitrary code execution (depending on the gadgets available).
ROP chain
Going back to the ROP chain we have in data
, we need to find what gadgets are being used. For this, we can use the following Python code using pwntools
:
from pwn import asm, context, disasm, ELF
context.binary = ELF('rerop', checksec=False)
elf = context.binary.data
rop_chain = [
int.from_bytes(elf[i : i + 8], 'little')
for i in range(0x4c5100 - 0x401000, 0x4c6400 - 0x401000 + 8, 8)
]
ret = asm('ret')
for i, addr in enumerate(rop_chain):
print()
print(hex(8 * i), '->', hex(addr))
if 0x401000 <= addr < 0x498000:
ret_index = elf[addr - 0x400000:].index(ret)
print(disasm(elf[addr - 0x400000 : addr - 0x400000 + ret_index + 1]))
With this, we take the contents of data
(from 0x4c5100
to 0x4c6400
), parse it as 8-byte elements and try to disassemble the addresses as long as they belong to an executable area of memory (from 0x401000
to 0x49793d
).
This is how the ROP chain starts:
$ python3 solve.py
0x0 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x8 -> 0x65
0x10 -> 0x401eef
0: 5f pop rdi
1: c3 ret
0x18 -> 0x0
0x20 -> 0x409f1e
0: 5e pop rsi
1: c3 ret
0x28 -> 0x1
0x30 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x38 -> 0x0
0x40 -> 0x41aab6
0: 0f 05 syscall
2: c3 ret
0x48 -> 0x451fe0
0: 48 89 c7 mov rdi, rax
3: c3 ret
0x50 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x58 -> 0x1198
0x60 -> 0x452000
0: 48 89 c6 mov rsi, rax
3: 48 31 db xor rbx, rbx
6: 48 85 ff test rdi, rdi
9: 48 0f 48 de cmovs rbx, rsi
d: 48 01 dc add rsp, rbx
10: c3 ret
0x68 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x70 -> 0x0
If we follow the ROP chain execution, the program will execute a syscall
instruction with:
$rax = 0x65
$rdi = 0
$rsi = 1
$rdx = 0
So it will be equivalent to sys_ptrace(0, 1, 0)
, or in other words: ptrace(PTRACE_TRACEME, 1, NULL)
, which tells if the current process is being debugged. This is not relevant for us because we are analyzing this in a static way.
After the syscall
instruction, we have $rdi = $rax
(so the return value of sys_ptrace
) and $rax = 0x1198
. Next, we get
$rsi = $rax
$rbx = 0
- If
$rdi
is non-zero, then$rbx = $rsi
(see CMOVcc — Conditional Move) $rsp = $rsp + $rbx
The above set of instructions means that if sys_ptrace
returns something that is non-zero, the stack pointer is increased by 0x1198
, so it moves to:
0x1200 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1208 -> 0x706f4e6d31335b1b
0x1210 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1218 -> 0x4c57e8
0x1220 -> 0x419ad8
0: 48 89 02 mov QWORD PTR [rdx], rax
3: c3 ret
0x1228 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1230 -> 0xa6d305b1b65
0x1238 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1240 -> 0x4c57f0
0x1248 -> 0x419ad8
0: 48 89 02 mov QWORD PTR [rdx], rax
3: c3 ret
0x1250 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1258 -> 0x1
0x1260 -> 0x401eef
0: 5f pop rdi
1: c3 ret
0x1268 -> 0x1
0x1270 -> 0x409f1e
0: 5e pop rsi
1: c3 ret
0x1278 -> 0x4c57e8
0x1280 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1288 -> 0xe
0x1290 -> 0x41aab6
0: 0f 05 syscall
2: c3 ret
0x1298 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x12a0 -> 0x3c
0x12a8 -> 0x401eef
0: 5f pop rdi
1: c3 ret
0x12b0 -> 0x1
0x12b8 -> 0x41aab6
0: 0f 05 syscall
2: c3 ret
0x12c0 -> 0x4c57e8
0x12c8 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x12d0 -> 0xe
0x12d8 -> 0x41aab6
0: 0f 05 syscall
2: c3 ret
This is not relevant at all, but the reader might want to check that it calls sys_write
to print "Nope"
and then call sys_exit
.
If the sys_ptrace
instruction returns zero, then the ROP chain continues to:
0x78 -> 0x401eef
0: 5f pop rdi
1: c3 ret
0x80 -> 0x4c7820
0x88 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x90 -> 0x19
0x98 -> 0x451ff0
0: 48 01 c7 add rdi, rax
3: c3 ret
0xa0 -> 0x451fe8
0: 48 89 f8 mov rax, rdi
3: c3 ret
0xa8 -> 0x45202f
0: 48 0f b6 00 movzx rax, BYTE PTR [rax]
4: c3 ret
0xb0 -> 0x451fe0
0: 48 89 c7 mov rdi, rax
3: c3 ret
0xb8 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0xc0 -> 0x19
0xc8 -> 0x451ff0
0: 48 01 c7 add rdi, rax
3: c3 ret
0xd0 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0xd8 -> 0x5
0xe0 -> 0x451ff8
0: 48 31 c7 xor rdi, rax
3: c3 ret
0xe8 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0xf0 -> 0x6e
0xf8 -> 0x451fec
0: 48 29 c7 sub rdi, rax
3: c3 ret
0x100 -> 0x452011
0: be 01 00 00 00 mov esi, 0x1
5: 48 85 ff test rdi, rdi
8: 48 0f 45 d6 cmovne rdx, rsi
c: c3 ret
...
The above is just an example of a pattern that repeats several times. As can be seen, it sets:
$rdi = 0x4c7820
(the address ofbuf
, where we have our user input)$rax = 0x19
$rdi = $rdi + $rax
$rax = $rdi
$rax = [$rax]
(this is like taking the byte atbuf[0x19]
)$rdi = $rax
$rax = 0x19
$rdi = $rdi + $rax
$rax = 0x5
$rdi = $rdi ^ $rax
$rax = 0x6e
$rdi = $rdi - $rax
$rsi = 0x1
- If
$rdi
is non-zero, then$rdx = $rsi
(see CMOVcc — Conditional Move)
This list of gadgets can be summarized as:
((buf[0x19] + 0x19) ^ 0x5) - 0x6e == 0
If $rdx
is set to 1
(the value of $rsi
), at the end of the checks the ROP chain print "Nope"
again with these gadgets:
0x10c8 -> 0x452034
0: 49 89 d0 mov r8, rdx
3: c3 ret
0x10d0 -> 0x452038
0: 4c 89 c2 mov rdx, r8
3: c3 ret
0x10d8 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x10e0 -> 0x110
0x10e8 -> 0x45201e
0: 48 89 c6 mov rsi, rax
3: 48 31 db xor rbx, rbx
6: 48 85 d2 test rdx, rdx
9: 48 0f 45 de cmovne rbx, rsi
d: 48 01 dc add rsp, rbx
10: c3 ret
If not, the ROP chain will print "Correct Flag!"
with a similar procedure and then run sys_exit
:
0x10f0 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x10f8 -> 0x726f436d32335b1b
0x1100 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1108 -> 0x4c57e8
0x1110 -> 0x419ad8
0: 48 89 02 mov QWORD PTR [rdx], rax
3: c3 ret
0x1118 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1120 -> 0x616c462074636572
0x1128 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1130 -> 0x4c57f0
0x1138 -> 0x419ad8
0: 48 89 02 mov QWORD PTR [rdx], rax
3: c3 ret
0x1140 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1148 -> 0xa6d305b1b2167
0x1150 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1158 -> 0x4c57f8
0x1160 -> 0x419ad8
0: 48 89 02 mov QWORD PTR [rdx], rax
3: c3 ret
0x1168 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1170 -> 0x0
0x1178 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x1180 -> 0x4c5800
0x1188 -> 0x419ad8
0: 48 89 02 mov QWORD PTR [rdx], rax
3: c3 ret
0x1190 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x1198 -> 0x1
0x11a0 -> 0x401eef
0: 5f pop rdi
1: c3 ret
0x11a8 -> 0x1
0x11b0 -> 0x409f1e
0: 5e pop rsi
1: c3 ret
0x11b8 -> 0x4c57e8
0x11c0 -> 0x458142
0: 5a pop rdx
1: c3 ret
0x11c8 -> 0x18
0x11d0 -> 0x41aab6
0: 0f 05 syscall
2: c3 ret
0x11d8 -> 0x450ec7
0: 58 pop rax
1: c3 ret
0x11e0 -> 0x3c
0x11e8 -> 0x401eef
0: 5f pop rdi
1: c3 ret
0x11f0 -> 0x0
0x11f8 -> 0x41aab6
0: 0f 05 syscall
2: c3 ret
Solution
Taking into account that there are several checks of the form
((buf[k] + a) ^ b) - c == 0
We can parse the ROP chain to take the k
, a
, b
and c
values and find the expected value of buf[k]
:
buf[k] = (b ^ c) - a
We can use the address of movzx rax, BYTE PTR [rax]; ret
(0x45202f
) as a reference to all checks and parse from there. Next, we simply find the values of the flag:
flag = bytearray(rop_chain.count(0x45202f))
for i, addr in enumerate(rop_chain):
if addr == 0x45202f:
k, a, b, c = rop_chain[i - 3], rop_chain[i + 3], rop_chain[i + 6], rop_chain[i + 9]
# ((flag[k] + a) ^ b) - c == 0
flag[k] = (b ^ c) - a
print()
print(flag.decode())
Flag
In the end, we can print the flag:
$ python3 solve.py
...
HTB{W4iT_W4S_Th@t_PWN_0R_R3V}
$ ./rerop
Enter the flag: HTB{W4iT_W4S_Th@t_PWN_0R_R3V}
Correct Flag!