TL; DR Esta es una forma de ejecutar shellcode que ya no funciona.
¿Qué es una función?
Shellcode es solo un código de máquina en lugares donde normalmente no se encuentra, como una variable de tipo char
. En C, no hay distinción entre funciones y variables. Una función es solo una variable que apunta al código ejecutable. Esto significa que, si creas una variable que apunta al código ejecutable y la llamas como si fuera una función, se ejecutará. Para ilustrar cómo es solo una variable, vea este sencillo programa:
#include <stdio.h>
#include <stdint.h>
void print_hello(void)
{
printf("Hello, world!\n");
}
void main(void)
{
uintptr_t new_print_hello;
printf("print_hello = %p\n", print_hello);
new_print_hello = (uintptr_t)print_hello;
(*(void(*)())new_print_hello)();
print_hello();
}
Cuando se compila y ejecuta, este programa da salida de la siguiente manera:
$ ./a.out
print_hello = 0x28bc4bf6da
Hello, world!
Hello, world!
Esto hace que sea fácil ver que una función no es más que una dirección en la memoria, compatible con el tipo uintptr_t
. Puede ver cómo se puede hacer referencia a una función simplemente como una variable, en este caso, imprimiendo su valor, o copiándola a otra variable de un tipo compatible y llamando a la variable como una función, aunque con un poco de magia de lanzamiento en orden para hacer feliz al compilador de C Una vez que vea cómo una función no es más que una variable que apunta a una memoria ejecutable, no es un esfuerzo ver cómo una variable que apunta a un código de bytes que usted define manualmente también puede ejecutarse.
¿Cómo funcionan las funciones?
Ahora que sabe que una función es solo una dirección en la memoria, necesita saber cómo se ejecuta realmente una función. Una vez que llama a una función, normalmente con la instrucción call
, el puntero de instrucción (que apunta a la instrucción que se está ejecutando actualmente) cambia para apuntar a la primera instrucción de la función. La ubicación justo antes de llamar a la función se guarda en la pila con call
. Una vez que se termina la función, se termina con la instrucción ret
, que la saca de la pila y la guarda de nuevo en la IP. Así que una vista (algo simplificada) es que call
empuja la IP a la pila, y ret
la devuelve.
Dependiendo de la arquitectura y el sistema operativo en el que se encuentre, los argumentos de la función pueden pasarse a los registros o a la pila, y el valor de retorno puede estar en diferentes registros, o la pila. Esto se llama la función llamada ABI , y es específica para cada tipo de sistema. Shellcode diseñado para un tipo de sistema puede no funcionar en otro, incluso si la arquitectura es la misma y el sistema operativo es diferente, o viceversa.
¿Qué hace tu shellcode?
Veamos el desmontaje del shellcode que proporcionó:
0000000000201010 <shellcode>:
201010: bb 00 00 00 00 mov ebx,0x0
201015: b8 01 00 00 00 mov eax,0x1
20101a: cd 80 int 0x80
Esto hace tres cosas. Primero, establece el ebx
en 0. En segundo lugar, establece el eax
registro en 1. Finalmente, activa la interrupción 0x80 que, en sistemas de 32 bits, es la interrupción de syscall. En el SysV que llama a ABI, el número syscall se coloca en eax
, y se pasan hasta 6 argumentos en ebx
, ecx
, edx
, esi
, edi
y ebp
. En este caso, solo se establece ebx
, lo que significa que syscall toma solo un argumento. Una vez que se llama a la interrupción 0x80, el núcleo toma el control y observa estos valores, ejecutando la llamada correcta al sistema. Los números de llamada del sistema se definen en /usr/include/asm/unistd_32.h
. Viendo eso, vemos que syscall 1 es exit()
. A partir de eso, podemos ver las tres cosas que hace este código de shell:
- Establece el primer argumento de syscall en 0 (lo que significa salida exitosa).
- Establece el número de syscall en 1, que es la llamada de salida.
- Invoca a syscall, lo que hace que el programa salga con el estado 0.
Cuando miras el panorama general, vemos que el código de shell es esencialmente equivalente a exit(0)
. No necesita ret
porque nunca se devuelve, y en su lugar hace que el programa finalice. Si deseaba que la función volviera, tendría que agregar ret
al final. Si no, al menos, usa ret
, entonces el programa se bloqueará a menos que finalice antes de que llegue al final de la función, como en su ejemplo con el exit()
syscall.
¿Qué pasa con tu código de shell?
El método para llamar a shellcode que está mostrando ya no funciona . Solía hacerlo, pero hoy en día Linux no permite que se ejecuten datos arbitrarios, por lo que es necesario realizar algunos lanzamientos arcanos. Esta técnica más antigua se explica bien en el famoso artículo Destrozando la pila por diversión y beneficio :
Lets try to modify our first example so that it overwrites the return
address, and demonstrate how we can make it execute arbitrary code. Just
before buffer1[] on the stack is SFP, and before it, the return address.
That is 4 bytes pass the end of buffer1[]. But remember that buffer1[] is
really 2 word so its 8 bytes long. So the return address is 12 bytes from
the start of buffer1[]. We'll modify the return value in such a way that the
assignment statement 'x = 1;' after the function call will be jumped. To do
so we add 8 bytes to the return address. Our code is now:
example3.c:
------------------------------------------------------------------------------
void function(int a, int b, int c) {
char buffer1[5];
char buffer2[10];
int *ret;
ret = buffer1 + 12;
(*ret) += 8;
}
void main() {
int x;
x = 0;
function(1,2,3);
x = 1;
printf("%d\n",x);
}
------------------------------------------------------------------------------
What we have done is add 12 to buffer1[]'s address. This new address is
where the return address is stored. We want to skip pass the assignment to
the printf call. How did we know to add 8 to the return address? We used a
test value first (for example 1), compiled the program, and then started gdb
La versión correcta de su código de shell para los sistemas más nuevos sería:
const char shellcode[] = “\xbb\x00\x00\x00\x00\xb8\x01\x00\x00\x00\xcd\x80”;
int main(){
int (*ret)() = (int(*)())shellcode;
ret();
}