ReRop
10 minutos de lectura
Se nos proporciona un binario de 64 bits llamado 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
Ingeniería inversa
Si abrimos el binario en IDA, veremos la siguiente función main
:
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;
}
Parece bastante simple, ¿verdad? Solo toma la entrada del usuario en buf
(una variable global en 0x4c7820
), y luego llama a check
con data
(otra variable global en 0x4c5100
) como argumento; obsérvese que el binario no es PIE, así que todas las direcciones son estáticas independientemente de ASLR. Este es check
:
void check() {
;
}
¡Aún más simple! No hay forma de que esta sea la función… Analicemos el código ensamblador:
$ 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
Bien, esta función simplemente toma el primer argumento ($rdi
) y lo copia a $rsp
. Y aquí viene la magia: la instrucción ret
, que
Transfers program control to a return address located on the top of the stack
Sin embargo, la parte superior del stack ($rsp
) ha sido reemplazada por $rdi
, así que el programa en realidad está retornando a una dirección dentro del buffer data
.
Veamos este buffer con 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.....
Necesitamos restar 0x4c5100 - 0x401000
para obtener el desplazamiento real dentro del archivo ELF. Como se puede ver, solo tenemos direcciones dentro del binario (0x4.....
) y otros números.
Return-Oriented Programming
Si no estás familiarizado con Return-Oriented Programming (ROP), esto puede parecer un poco extraño. Esta técnica se utiliza principalmente en la explotación de binarios (pwn) para lograr ejecución arbitraria de código cuando no hay direcciones de memoria ejecutables para colocar shellcode personalizado. La idea de esta técnica es reutilizar instrucciones del binario o bibliotecas compartidas para ejecutar las partes necesarias y obtener el resultado deseado. Para esto, el flujo de control del programa debe estar bajo control, de forma que se pueda redirigir a cualquier parte.
El uso de ROP depende de gadgets. Estos son secuencias de instrucciones que típicamente terminan en ret
(otras pueden terminar en jmp
o call
). Por ejemplo, pop rdi; ret
es un gadget muy útil, porque toma el siguiente valor de la pila y lo coloca en $rdi
(el primer argumento de una función); y luego retorna a la siguiente dirección en la pila. Encadenando varias direcciones de gadgets en la pila (conocido como cadena ROP o ROP chain), se puede lograr ejecución de código casi arbitraria (dependiendo de los gadgets disponibles).
Cadena ROP
Volviendo a la cadena ROP que tenemos en data
, necesitamos encontrar qué gadgets se están usando. Para esto, podemos usar el siguiente código Python con 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]))
Con esto, tomamos el contenido de data
(desde 0x4c5100
hasta 0x4c6400
), lo interpretamos como elementos de 8 bytes y tratamos de desensamblar las direcciones siempre que pertenezcan a un área de memoria ejecutable (desde 0x401000
hasta 0x49793d
).
Así es como empieza la cadena ROP:
$ 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
Si seguimos la ejecución de la cadena ROP, el programa ejecutará una instrucción syscall
con:
$rax = 0x65
$rdi = 0
$rsi = 1
$rdx = 0
Así que será equivalente a sys_ptrace(0, 1, 0)
, o en otras palabras: ptrace(PTRACE_TRACEME, 1, NULL)
, lo que indica si el proceso actual está siendo depurado. Esto no es relevante para nosotros porque estamos analizando esto de forma estática.
Después de la instrucción syscall
, tenemos $rdi = $rax
(así que el valor de retorno de sys_ptrace
) y $rax = 0x1198
. Luego obtenemos:
$rsi = $rax
$rbx = 0
- Si
$rdi
es distinto de cero, entonces$rbx = $rsi
(ver CMOVcc — Conditional Move) $rsp = $rsp + $rbx
El conjunto de instrucciones anterior significa que si sys_ptrace
devuelve algo distinto de cero, el puntero de pila se incrementa en 0x1198
, así que se mueve a:
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
Esto no es relevante, pero el lector puede comprobar que llama a sys_write
para imprimir "Nope"
y luego llama a sys_exit
.
Si la instrucción sys_ptrace
devuelve cero, entonces la cadena ROP continúa a:
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
...
Lo anterior es solo un ejemplo de un patrón que se repite varias veces. Como se puede ver, establece:
$rdi = 0x4c7820
(la dirección debuf
, donde tenemos nuestra entrada de usuario)$rax = 0x19
$rdi = $rdi + $rax
$rax = $rdi
$rax = [$rax]
(esto es como tomar el byte enbuf[0x19]
)$rdi = $rax
$rax = 0x19
$rdi = $rdi + $rax
$rax = 0x5
$rdi = $rdi ^ $rax
$rax = 0x6e
$rdi = $rdi - $rax
$rsi = 0x1
- Si
$rdi
es distinto de cero, entonces$rdx = $rsi
(ver CMOVcc — Conditional Move)
Esta lista de gadgets se puede resumir como:
((buf[0x19] + 0x19) ^ 0x5) - 0x6e == 0
Si $rdx
se establece en 1
(el valor de $rsi
), al final de las comprobaciones la cadena ROP imprime "Nope"
nuevamente con estos 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
Si no, la cadena ROP imprimirá "Correct Flag!"
con un procedimiento similar y luego ejecutará 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
Solución
Teniendo en cuenta que hay varias comprobaciones de la forma
((buf[k] + a) ^ b) - c == 0
Podemos analizar la cadena ROP para obtener los valores k
, a
, b
y c
y encontrar el valor esperado de buf[k]
:
buf[k] = (b ^ c) - a
Podemos usar la dirección de movzx rax, BYTE PTR [rax]; ret
(0x45202f
) como referencia para todas las comprobaciones y analizar a partir de ahí. Luego, simplemente encontramos los valores de la 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
Al final, podemos imprimir la 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!