01 agosto 2012

Si lo ves, está muerto.
Jesús de Marcelo Rodao.

Así describía Jesús de Marcelo Rodao, autor del libro "Piratas Cibernéticos" (un libro introductorio con el que empecé en mis tiempos de preadolescente, años ha, a profundizar en el asunto de los ordenadores) la máxima de todo diseñador de virus. Si su producto puede verse, también puede borrarse. O analizarse, en este caso. Aplicado a la infección de binarios, donde estamos modificando un fichero, las cosas se vuelven un poco más complicadas. Hemos inyectado "algo" y ese "algo" puede quedar a la vista haciendo sencillamente un volcado hexadecimal del fichero infectado. También es cierto que nadie depura un ELF a palo seco leyendo largas listas de números en hexadecimal sin una herramienta que sirva para interpretar todo ese soplido de bytes.

A lo largo de esta entrega introduciré algunas técnicas que se podrían utilizar para ocultar el código inyectado, o al menos hacerlo más difícil de detectar y para la siguiente, una vez detectado, hacerlo realmente complicado de depurar. Intentaré aportar algunas pruebas de concepto para ilustrar igualmente la viabilidad de alguna de ellas.

Técnicas "en frío": ocultándonos dentro del fichero
En algunos de los ejemplos anteriores, hemos visto que la salida de objdump podría no mostrar el contenido del código inyectado debido a que este código no salía en ninguna sección. Esto puede ser una forma muy ingenua de stealth: el código vírico se esconde en un sitio donde los desensambladores ordinarios no buscan. Digo ingenua, porque un antivirus normalillo buscaría código no sólo en las secciones, si no también en los segmentos. Otros desensambladores un poco más sofisticados como el que trae IDA de serie no tendrían problema en sacar a la luz el código inyectado, basta con que analicen el binario a partir de las cabeceras de programa.

En la época del DOS, cuando parecía razonable que el usuario medio disfrutase de un ordenador sin protección de memoria de ningún tipo, una buena fracción de las técnicas de stealth de los virus de aquel entonces consistía en engancharse a llamadas del sistema operativo (tipo leer/escribir fichero), haciendo que las porciones infectadas quedasen ocultas al resto de programas. Esto hoy por hoy no es tan fácil, aunque no es imposible. En el userland se pueden hacer todo tipo de guarrerías para engancharnos a estas llamadas mediante técnicas como el library preloading, que NighterMan ya examinó a fondo en su rootkit lsnake (al que por cierto, seguimos esperando a que llegue a liberar algún día ;) o incluso intentar infectar la libc redirigiendo símbolos. El problema es que para esto último necesitamos ser root, y puede pasar mucho tiempo desde la infección hasta de que alcancemos tales privilegios.

Sin embargo, existen técnicas que han sobrevivido a la evolución de los ordenadores personales y no requieren mayores permisos que los necesarios para leerse a sí mismos. Hablo, evidentemente, de las técnicas de cifrado, polimorfismo y metamorfismo.

Cifrado con clave variable

Si los binarios infectados deben llevarse el código vírico consigo, lo mínimo que debemos intentar es hacerlo difícil de leer.

La forma más sencilla es aplicar un XOR en todos los bytes del código, con una clave fija o variable, de forma que las rutinas propias del virus dejen de ser visibles, aunque esto se puede complicar en teoría tanto como se quiera. Los pros: a primera vista, parece que el binario infectado no hace nada raro, no hay extrañas llamadas al sistema ya que todo está oculto en el cifrado. Los contras: cifrados muy complejos pueden incrementar innecesariamente el tamaño del código y hacerlo lento de descifrar, además de que obliga a tener una rutina de desencriptado siempre igual que puede utilizarse como firma por parte del antivirus para detectar binarios infectados. Si la rutina es siempre la misma, ¿qué más da que el resto del código esté cifrado?

Además, como problema añadido, tenemos que modificar un segmento de código ejecutable. Esto puede hacerse de dos formas distintas, ambas relativamente problemáticas. La más simple consiste en la definición de un segmento con los permisos de lectura, escritura y ejecución activados todos a la vez (algo que, como dije antes, es demasiado llamativo). Una variante más compleja, pero más discreta, constiría en tener una parte ejecutable que ubicase en memoria una página también ejecutable (o varias) donde se iría colocando el código descifrado (el cual podría encontrarse en un segmento de datos) para después saltar a él. Esto seguiría una estructura análoga a la técnica de infección segmentada: buscamos el relleno de la región e intentamos inyectar la rutina de descifrado. Pero a la vez, tendríamos que fusilar el PT_NOTE con nuestro código cifrado.

La prueba de concepto siguiente va a incluir como algoritmo de cifrado algo bastante inocentón: un PRNG bastante pobre (como LFSR) generará, a partir de cierta semilla, todo un chorro de bytes que XORearemos después contra el código, teniendo así el código cifrado. Es una forma de cifrar fácil y que a la vez se le puede escapar a ciertas aplicaciones que prueban de forma sistemática cifrados más simples. Más que un cifrado es un scrambling, pero al menos le dará chicha y podremos decir que el código está de alguna manera "oculto".

Esta prueba de concepto tiene una estructura diferente a las anteriores. Debido a que una parte del código tiene que encargarse de descifrar la otra, tenemos que separar el código en dos, una con las funciones y el código necesario para cifrar / descifrar, y otra con toda la utilería necesaria para inyectar y ejecutar lo que nos interese. Como el algoritmo de cifrado también forma parte del proceso de inyección, deberíamos dejar visible el código de la primera parte para el resto. Esto nos lleva a la introducción de un tercer marcador, un "marcador medio" con la cadena ASCII "GURB" (también realmente difícil de encontrar por casualidad en un fichero binario) que separe estas dos partes. Las funciones pertenecientes al algoritmo de cifrado se encasquetarán en esta primera parte forzándolas a ubicarse en la sección .code_bottom.

En líneas generales, el proceso de infección comenzará con la ejecución de nuestro código a partir de _start y consistirá en:
  1. Detectar los marcadores del código.
  2. Abrir el binario víctima.
  3. Localizar el PT_NOTE y el relleno del segmento de código.
  4. Copiar todo hasta el marcador medio (es decir, la primera parte que incluye la rutina de descifrado).
  5. Copiar en el PT_NOTE todo el código cifrándolo en el proceso (ambas partes)
  6. Modificar el binario para almacenar todos los parámetros del código encriptado (clave y demás).
La clave la generamos de una forma suficientemente aleatoria leyendo el número de ticks de reloj que han pasado desde el arranque del ordenador gracias a la llamada al sistema times.

Una vez que todo el código ha sido inyectado, tanto en el relleno como en PT_NOTE, necesitamos almacenar esta información en la parte no cifrada. Utilizando técnicas similares a las de los ejemplos anteriores, tanto la clave como la dirección de carga del código cifrado y como la de retorno son guardadas como instrucciones push al principio del código.  Estas instrucciones servirán para construir una pila que se utilizará para llamar a insitu_decrypt, función que tomará los datos de la dirección virtual y de la clave para restaurar el código inyectado, dejándolo listo para saltar a él:
asm (".section .code_bottom, \"xa\"");
asm (".long " STRINGIFY (BOTTOM_MARKER));
asm ("crypto_entry:");
asm ("  push $" STRINGIFY (NOT_INFECTED_MAGIC)); /* Push de la dirección de retorno */
asm ("  pusha");
asm ("  push $" STRINGIFY (NOT_INFECTED_MAGIC)); /* Push de la dirección virtual a descifrar */
asm ("  push $" STRINGIFY (NOT_INFECTED_MAGIC)); /* Push de la semilla */
asm ("  call insitu_decrypt");
asm ("  add $4, %esp");
asm ("  pop %eax");
asm ("  addl $" STRINGIFY (PAYLOAD_EP_OFFSET) ", %eax");
asm ("  call *%eax");
asm ("  popa");
asm ("  ret");
asm ("decrypted_entry:");
asm ("  jmp payload"); /* Este es el punto de entrada del código desencriptado */
Una diferencia importante con otros ejemplos es que en esta implementación el programa no comprueba la dirección de retorno salvada para saber si está dentro de un binario o no, simplemente porque no lo sabe. Esta información va a estar en una zona desconocida a priori por el grueso del código inyectado (los parámetros se guardan en el relleno como parte de la rutina de descifrado), por lo que la única forma de saberlo, si no queremos complicarnos más de lo debido, es saltar a una función diferente del _start desde la rutina de descifrado.

Esta es la razón principal de la existencia de jmp payload (en una posición que llamé decrypted_entry). Esta posición, que se encuentra PAYLOAD_EP_OFFSET bytes desde el inicio del código, salta directamente a la función donde se activa la carga útil en el código recién descifrado, que hará cosas distintas (o no) a infectar otros ejecutables. En nuestro caso nos conformaremos con un buen mensaje grosero y en mayúsculas, como de costumbre :P

El código fuente puede examinarse aquí:



Es importante que, para que funcione correctamente, compilemos con -O1, ya que en otro caso se introducirían llamadas a __i686.get_pc_thunk_bx (la cual no está disponible en .code_bottom).

Esta implementación, bastante liosa como consecuencia de hacer nuestro código más complicado de leer, ha terminado incluyendo hasta tres puntos de entrada diferentes: el punto de entrada original (el del _start) cuando se le llama solo, el punto de entrada de la rutina de descifrado (crypto_entry, al principio de .code_bottom) y el punto de entrada una vez descifrado, que ejecuta el código dentro del binario infectado (el decrypted_entry). Este tipo de programas con tantas formas de ser ejecutado es característico de las técnicas donde haya que manipular el código ejecutable, y no hará más que complicarse a medida que vayamos profundizando en mecanismos más avanzados para protegernos de la depuración en frío.

Código polimórfico
Con la técnica anterior hemos podido esconder el grueso del código. Pero todavía hay una pequeña rutina que permanece igual en todas las inyecciones, y esto hace realmente fácil la detección. En la práctica, esto es lo que hace que un virus sea detectable: una firma de bytes (cuanto más grande, menor es la posibilidad de un falso positivo) que aparece siempre en todos los binarios infectados.

Hace 20 años el célebre Dark Avenger, tras las predicciones sobre el polimorfismo de Fred Cohen y la llegada de la primera implementación práctica al mundo (el 1260 en el año 1990), decidió escribir el MtE (acrónimo de MuTation Engine) distribuido como un conjunto de funciones en un fichero objeto capaces de encriptar un segmento de código y generar rutinas de desencriptado diferentes con trucos como introducir basura, ofuscar asignaciones y demás. Tras haber colgado este material en su propia BBS, el resultado fue toda una familia de virus con millones de mutaciones posibles que puso en jaque a las compañías de antivirus que hasta entonces se habían limitado a reconocer cadenas propias de cada espécimen.

MtE fue un motor bastante sofisticado en su tiempo y a día de hoy sigue siendo una pieza digna de estudio, es capaz de reordenar los bloques funcionales del código que genera (haciendo, por lo tanto, que el propio esqueleto de esta rutina variable) y en la práctica el único byte contante se correspondía a  una instrucción de salto que podía cargarse en cualquier desplazamiento. Desde entonces, nuevos motores salieron a la luz, tales como el TPE, el NED o el DAME (implicando más y más código malicioso que hacía uso de ellos), convirtiendo el mundo de la detección de virus informáticos en un escenario más interesante.

Los pros de esta técnica son obvios: es más difícil que nos detecten, y si el motor polimórfico es bueno, las heurísticas aplicadas para reconocernos serán muy susceptibles a sufrir falsos positivos (y negativos). Los contras, sin embargo, son mucho mayores. Es muy complicado hacer un buen motor polimórfico. Y no sólo complicado desde el punto de vista algorítmico, si no también en lo que a economía de espacio se refiere (nuestro código ha crecido de forma espectacular) lo cual puede hacer imposible ciertos tipos de infección.

No es objeto de este artículo emular las proezas computacionales de los autores de tales motores, aunque me limitaré a hacer una pequeña demostración práctica de hasta que punto la complejidad del código se dispara tan sólo añadiendo una versión light de esta característica: ofuscaremos la forma en la que se meten los parámetros en la pila de una forma similar a la del MtE, añadiendo basura inofensiva en el resto del código.

Dicho así parece relativamente fácil de escribir. Sin embargo, como cada vez que jugamos a nivel de lenguaje máquina, las cosas siempre acaban por torcerse, viéndomos obligados a prestar atención a los siguientes puntos si no queremos convertir nuestra rutina de desencriptado en un inútil cúmulo de bytes:
  1. La arquitectura x86 no es muy RISC que digamos, las instrucciones miden distinto y no podemos simplemente tomar el código y rellenarlo de basura aleatoria. Si no queremos escribir todo un desensamblador para esta tarea (lo cual sería DEMASIADO largo y se acercaría más al metamorfismo que al polimorfismo) vamos a tener que escribirnos un array de estructuras con todas las instrucciones que componen la rutina de desencriptado con campos tales como "tipo de relleno", "parámetros del relleno", etc.
  2. Los saltos y las llamadas a funciones van a ser otra fuente de problemas. Si rellenamos el código con basura los desplazamientos cambiarán, por lo que tenemos que modificarlos acorde a estos cambios. Esto implica que los saltos deben aparecen en esa lista de una forma especial, con un campo extra apuntando a la instrucción a la que se debe saltar.
  3. No podemos meter basura por todas partes. Si ponemos algo justo después de una comparación los flags del procesador pueden cambiar, haciendo que la operación condicional que viene después se comporte de forma incorrecta.
Teniendo en cuenta estos puntos, uno se encuentra con un descomunal pedazo de código de más de 500 líneas en C, sólo para hacer un código ligeramente polimórfico, el cual eleva el tamaño total del código a inyectar por encima de la barrera psicológica de los 4 KiB. Si bien muchas de las estrategias que hemos estudiado antes siguen siendo posibles, encontrar (por ejemplo) un binario donde podamos aplicar la infección segmentada va a ser algo especialmente improbable.

Aún así, echemos un vistazo a los detalles técnicos de esta prueba. Su funcionamiento interno se basa en abstraer las instrucciones que deben ser ejecutadas en un array de estructuras polymorphic_action, ayudándome a clasificar cada una de ellas en:
  • Instrucciones normales: código máquina que se copia "tal cual", con la posibilidad de acoplársele basura.
  • Instrucciones fijas: código máquina que no se puede rodear de basura (debido a que hace operaciones delicadas con las banderas)
  • Instrucciones de PUSH: conjunto de instrucciones "ofuscables" en función de una estrategia elegida aleatoriamente para pasar los parámetros de la rutina de desencriptado de la forma más ilegible posible.
  • Llamadas a funciones: que pueden darse de dos formas (elegidas aleatoriamente, por supuesto), el call estándar y una versión más liosa que involucra registros de propósito general y operaciones en la pila.
  • Y saltos condicionales que, del mismo modo que las llamadas a funciones, deben tratarse por separado ya que los desplazamientos que llevan a las direcciones de salto deben calcularse una vez que contemos con todos los tamaños que cada instrucción tendrá en el código final.
Todos los parámetros aleatorios se generan gracias al LFSR que hemos utilizado en el ejemplo anterior. En el caso de la basura que se le puede acoplar a las instrucciones normales tenemos sumas y restas sobre un registro cualquiera que se anulan entre sí, parejas de operaciones XOR que también se anulan, rotaciones de bits y push/pops sobre un mismo registro de propósito general.  Aunque esto redunda en un efecto nulo sobre los valores de estos registros, debemos tener en cuenta que los flags del procesador sí cambiarán a consecuencia de todas estas instrucciones. Las instrucciones rellenadas con basura se eligen aleatoriamente, hasta llenar por completo el tamaño máximo disponible o sencillamente no tener más instrucciones que rellenar.

Las instrucciones PUSH tienen más chicha: el motor tomará el parámetro que se quiere meter en la pila, le aplicará una lista variable de operaciones aleatorias (como suma, resta, XOR y rotaciones) y escribirá las instrucciones que deben realizarse para deshacerlas sobre un registro igualmente aleatorio. El resultado no es sólo que es difícil de depurar, si no que además esta zona del código se vuelve totalmente caótica y difícil de identificar.

Por último, las llamadas y los saltos se ajustan en función de las estrategias elegidas (ya que de ellas depende el tamaño del código), calculándose los desplazamientos justo antes de generar el código que realmente compone esta rutina de desencriptado.

He elegido la zona del relleno del segmento de texto como lugar idóneo donde inyectar la rutina mutada, la cual se generará en función del espacio disponible en este lugar del ejecutable. El código fuente de esta entrañable monstruosidad se puede encontrar aquí:


Incluyo también un enlace a varios binarios infectados por este mismo código. Realmente se trata del mismo ejecutable infectado con mutaciones distintas. El código generado, el cual se encuentra en 0x804d1ac para este ejecutable, comienza de formas tan dispares como:
uname.infected.1:
=> 0x804d1ac:   push   $0x80492e4
   0x804d1b1:   pusha  
   0x804d1b2:   push   %ecx
   0x804d1b3:   pop    %ecx
   0x804d1b4:   push   $0x8046000
   0x804d1b9:   mov    $0x666da0f2,%ebx
   0x804d1be:   add    $0xffffff99,%ebx
   0x804d1c4:   sub    $0xffffffcd,%ebx
   0x804d1ca:   ror    $0x6d,%ebx
   0x804d1cd:   rol    $0x6d,%ebx
   0x804d1d0:   push   %ebx
   0x804d1d1:   call   0x804d20b
uname.infected.2:
=> 0x804d1ac:   mov    $0xa6020124,%ecx
   0x804d1b1:   ror    $0xb6,%ecx
   0x804d1b4:   sub    $0xffffffb4,%ecx
   0x804d1ba:   push   %ecx
   0x804d1bb:   pusha  
   0x804d1bc:   ror    $0x69,%ebx
   0x804d1bf:   rol    $0x69,%ebx
   0x804d1c2:   mov    $0xcc010073,%edi
   0x804d1c7:   add    $0x19,%edi
   0x804d1cd:   rol    $0xcc,%edi
   0x804d1d0:   ror    $0x66,%edi
   0x804d1d3:   xor    $0x33,%edi
uname.infected.3:
=> 0x804d1ac:   mov    $0x49298080,%ebx
   0x804d1b1:   ror    $0x36,%ebx
   0x804d1b4:   ror    $0xb6,%ebx
   0x804d1b7:   sub    $0xffffffb4,%ebx
   0x804d1bd:   push   %ebx
   0x804d1be:   pusha  
   0x804d1bf:   ror    $0xe9,%ebx
   0x804d1c2:   rol    $0xe9,%ebx
   0x804d1c5:   mov    $0x80cc1fd2,%edx
   0x804d1ca:   add    $0x64,%edx
   0x804d1d0:   sub    $0x25,%edx
   0x804d1d6:   ror    $0x2c,%edx
Todas ellas funcionales, descifrando el mismo código encriptado con claves diferentes en cada caso.

Código metamórfico

Con el polimorfismo llevado al extremo surgieron ideas si cabe más sorprendentes y complejas, que dieron nacimiento al concepto del código metamórfico.

Lo llamo "código metamórfico", pero el metamorfismo no es en realidad una propiedad del código, si no del motor que lo implementa. De la misma forma que los virus polimórficos son capaces de "mutar" la rutina de cifrado, los virus metamórficos ni siquiera necesitaría cifrar el grueso del código ya que están dotados de suficiente inteligencia como para interpretar su propio código, reescribirlo e incluso modificar su comportamiento. A diferencia del polimorfismo, donde el virus contiene cierta información a partir de la cual genera código, el metamorfismo confiere la habilidad de "entenderse" a sí mismo, pasar a una representación interna, aplicar transformaciones sobre dicha representación y obtener un código funcional totalmente distinto.

Los virus metamórficos son los más complicados de detectar, aunque afortunadamente también son los más complicados de programar y suelen quedar relegados a meros experimentos de laboratorio. La  única implementación práctica que conozco de esta técnica se da en el W32/Simile (y también Linux/Simile), un complejísimo virus metamórfico capaz de infectar binarios tanto de Windows como de Linux. Para hacernos una idea del trabajo invertido en esta creación, el 90% del tamaño del código se corresponde con el motor metamórfico, creado a partir de cerca de 14.000 líneas en ensamblador.

Evidentemente no voy a escribir un motor metamórfico como ejemplo, ya que eso me llevaría tantas o más horas que escribir toda esta saga de artículos desde cero. Pero la amenaza está ahí, y aunque las implementaciones existentes no son especialmente dañinas, la posibilidad de que algún genio actúe del lado del mal siempre existe.

Y en la siguiente entrega...

Hemos visto un par de técnicas para no dejarnos ver, pero tarde o temprano nuestro código ha de aparecer en claro en algún punto de la memoria ya que tendrá que ser ejecutado CPU. En la siguiente entrega de esta saga hablaré de cómo detectar el depurador y ocultarnos ante él. Convertiremos el inicial subidón de alegría del hacker de turno por haber sorteado nuestros algoritmos en un auténtico infierno con el depurador con los trucos que veremos en el siguiente capítulo.



Artículo cortesía de BatchDrake

1 comments :

UnkIE dijo...

no me funcione, me dice: segmentation core-fault