GNU/Linux >> Tutoriales Linux >  >> Linux

¿Cómo desensamblar, modificar y luego volver a ensamblar un ejecutable de Linux?

No creo que haya ninguna manera confiable de hacer esto. Los formatos de código de máquina son muy complicados, más complicados que los archivos de ensamblaje. Realmente no es posible tomar un binario compilado (digamos, en formato ELF) y producir un programa ensamblador fuente que compilará el mismo binario (o lo suficientemente similar). Para comprender las diferencias, compare la salida de la compilación GCC directamente con el ensamblador (gcc -S ) frente a la salida de objdump en el ejecutable (objdump -D ).

Hay dos complicaciones principales que se me ocurren. En primer lugar, el código de máquina en sí mismo no es una correspondencia 1 a 1 con el código ensamblador, debido a cosas como las compensaciones de puntero.

Por ejemplo, considere el código C para Hola mundo:

int main()
{
    printf("Hello, world!\n");
    return 0;
}

Esto compila el código ensamblador x86:

.LC0:
    .string "hello"
    .text
<snip>
    movl    $.LC0, %eax
    movl    %eax, (%esp)
    call    printf

Donde .LCO es una constante con nombre y printf es un símbolo en una tabla de símbolos de biblioteca compartida. Compare con la salida de objdump:

80483cd:       b8 b0 84 04 08          mov    $0x80484b0,%eax
80483d2:       89 04 24                mov    %eax,(%esp)
80483d5:       e8 1a ff ff ff          call   80482f4 <[email protected]>

En primer lugar, la constante .LC0 ahora es solo un desplazamiento aleatorio en la memoria en algún lugar; sería difícil crear un archivo fuente de ensamblaje que contenga esta constante en el lugar correcto, ya que el ensamblador y el enlazador son libres de elegir ubicaciones para estas constantes.

En segundo lugar, no estoy completamente seguro de esto (y depende de cosas como el código independiente de la posición), pero creo que la referencia a printf no está codificada en la dirección del puntero en ese código, pero los encabezados ELF contienen un tabla de búsqueda que reemplaza dinámicamente su dirección en tiempo de ejecución. Por lo tanto, el código desensamblado no se corresponde exactamente con el código ensamblador fuente.

En resumen, el ensamblado de origen tiene símbolos mientras que el código de máquina compilado tiene direcciones que son difíciles de revertir.

La segunda complicación importante es que un archivo de origen de ensamblado no puede contener toda la información que estaba presente en los encabezados del archivo ELF original, como qué bibliotecas vincular dinámicamente y otros metadatos colocados allí por el compilador original. Sería difícil reconstruir esto.

Como dije, es posible que una herramienta especial pueda manipular toda esta información, pero es poco probable que uno simplemente pueda producir un código ensamblador que pueda volver a ensamblarse en el ejecutable.

Si está interesado en modificar solo una pequeña sección del ejecutable, le recomiendo un enfoque mucho más sutil que volver a compilar toda la aplicación. Use objdump para obtener el código ensamblador para la(s) función(es) que le interesan. Conviértalo a "sintaxis ensambladora de origen" a mano (y aquí, desearía que hubiera una herramienta que realmente produjera el desensamblaje con la misma sintaxis que la entrada) , y modifíquelo como desee. Cuando haya terminado, vuelva a compilar solo esas funciones y use objdump para averiguar el código de máquina para su programa modificado. Luego, use un editor hexadecimal para pegar manualmente el nuevo código de máquina en la parte superior de la parte correspondiente del programa original, teniendo cuidado de que su nuevo código tenga exactamente la misma cantidad de bytes que el código anterior (o todas las compensaciones serían incorrectas). ). Si el nuevo código es más corto, puede rellenarlo con las instrucciones NOP. Si es más largo, es posible que tenga problemas y que deba crear nuevas funciones y llamarlas en su lugar.


Hago esto con hexdump y un editor de texto. Tienes que ser realmente cómodo con el código de máquina y el formato de archivo que lo almacena, y flexible con lo que cuenta como "desmontar, modificar y luego volver a montar".

Si puede salirse con la suya haciendo solo "cambios puntuales" (reescribiendo bytes, pero sin agregar ni eliminar bytes), será fácil (en términos relativos).

de verdad no desea desplazar ninguna instrucción existente, porque entonces tendría que ajustar manualmente cualquier desplazamiento relativo efectuado dentro del código de la máquina, para saltos/ramificaciones/cargas/almacenamiento en relación con el contador del programa, ambos en código inmediato valores y los calculados a través de registros .

Siempre debería poder salirse con la suya sin eliminar bytes. Agregar bytes puede ser necesario para modificaciones más complejas y se vuelve mucho más difícil.

Paso 0 (preparación)

Después de haber realmente desarmó el archivo correctamente con objdump -D o lo que sea que use normalmente primero para comprenderlo y encontrar los puntos que necesita cambiar, deberá tomar nota de las siguientes cosas para ayudarlo a ubicar los bytes correctos para modificar:

  1. La "dirección" (compensada desde el inicio del archivo) de los bytes que necesita cambiar.
  2. El valor bruto de esos bytes tal como son actualmente (el --show-raw-insn opción a objdump es realmente útil aquí).

También deberá verificar si hexdump -R funciona en su sistema. De lo contrario, para el resto de estos pasos, use el xxd comando o similar en lugar de hexdump en todos los pasos a continuación (consulte la documentación de cualquier herramienta que use, solo explico hexdump por ahora en esta respuesta porque esa es la que conozco).

Paso 1

Volcar la representación hexadecimal sin procesar del archivo binario con hexdump -Cv .

Paso 2

Abre el hexdump editado y busque los bytes en la dirección que desea cambiar.

Curso intensivo rápido en hexdump -Cv salida:

  1. La columna más a la izquierda son las direcciones de los bytes (en relación con el inicio del propio archivo binario, al igual que objdump proporciona).
  2. La columna más a la derecha (rodeada por | caracteres) es solo una representación "legible por humanos" de los bytes:el carácter ASCII que coincide con cada byte está escrito allí, con un . reemplazando todos los bytes que no se asignan a un carácter imprimible ASCII.
  3. Lo importante está en el medio:cada byte como dos dígitos hexadecimales separados por espacios, 16 bytes por línea.

Cuidado:a diferencia de objdump -D , que le brinda la dirección de cada instrucción y muestra el hexadecimal sin procesar de la instrucción en función de cómo está documentada como codificada, hexdump -Cv vuelca cada byte exactamente en el orden en que aparece en el archivo. Esto puede ser un poco confuso ya que primero en máquinas donde los bytes de instrucción están en orden opuesto debido a las diferencias de endianness, lo que también puede desorientar cuando espera un byte específico como una dirección específica.

Paso 3

Modifique los bytes que deben cambiar; obviamente, debe descubrir la codificación de instrucciones de la máquina sin procesar (no los mnemónicos de ensamblaje) y escribir manualmente los bytes correctos.

Nota:no necesita cambiar la representación legible por humanos en la columna más a la derecha. hexdump lo ignorará cuando lo "desdescargues".

Paso 4

"Des-volcar" el archivo de volcado hexadecimal modificado usando hexdump -R .

Paso 5 (verificación de cordura)

objdump tu nuevo deshexdump ed y verifique que el desensamblado que cambió se vea correcto. diff contra el objdump del original.

En serio, no te saltes este paso. La mayoría de las veces cometo errores cuando edito manualmente el código de máquina y así es como atrapo la mayoría de ellos.

Ejemplo

Aquí hay un ejemplo práctico de la vida real de cuando modifiqué un binario ARMv8 (little endian) recientemente. (Lo sé, la pregunta está etiquetada como x86 , pero no tengo un ejemplo de x86 a mano, y los principios fundamentales son los mismos, solo que las instrucciones son diferentes).

En mi situación, necesitaba deshabilitar una verificación manual específica de "no deberías estar haciendo esto":en mi binario de ejemplo, en objdump --show-raw-insn -d mostrar la línea que me importaba se veía así (una instrucción antes y después dada para el contexto):

     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Como puede ver, nuestro programa está saliendo de forma "útil" saltando a un error función (que termina el programa). Inaceptable. Así que vamos a convertir esa instrucción en una no operación. Entonces estamos buscando los bytes 0x97fffeeb en la dirección/desplazamiento de archivo 0xf44 .

Aquí está el hexdump -Cv línea que contiene ese desplazamiento.

00000f40  e3 03 15 aa eb fe ff 97  f7 13 40 f9 e8 02 40 39  |[email protected]@9|

Observe cómo se invierten realmente los bytes relevantes (la codificación little endian en la arquitectura se aplica a las instrucciones de la máquina como a cualquier otra cosa) y cómo esto se relaciona de manera poco intuitiva con qué byte está en qué byte compensado:

00000f40  -- -- -- -- eb fe ff 97  -- -- -- -- -- -- -- --  |[email protected]@9|
                      ^
                      This is offset f44, holding the least significant byte
                      So the *instruction as a whole* is at the expected offset,
                      just the bytes are flipped around. Of course, whether the
                      order matches or not will vary with the architecture.

De todos modos, sé por mirar otros desmontajes que 0xd503201f se desmonta a nop así que parece un buen candidato para mi instrucción no operativa. Modifiqué la línea en el hexdump ed archivo en consecuencia:

00000f40  e3 03 15 aa 1f 20 03 d5  f7 13 40 f9 e8 02 40 39  |[email protected]@9|

Convertido de nuevo a binario con hexdump -R , desensambló el nuevo binario con objdump --show-raw-insn -d y comprobé que el cambio era correcto:

     f40:   aa1503e3    mov x3, x21
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

Luego ejecuté el binario y obtuve el comportamiento que quería:la verificación relevante ya no hizo que el programa abortara.

Modificación del código de máquina exitosa.

!!! ¡¡¡Advertencia!!!

¿O tuve éxito? ¿Detectaste lo que me perdí en este ejemplo?

Estoy seguro de que lo hizo, dado que está preguntando cómo modificar manualmente el código de máquina de un programa, presumiblemente sabe lo que está haciendo. Pero para el beneficio de cualquier lector que pueda estar leyendo para aprender, elaboraré:

Solo cambié la última instrucción en la rama de caso de error! El salto a la función que sale del programa. Pero como puede ver, registre x3 estaba siendo modificado por mov ¡justo arriba! De hecho, un total de cuatro (4) los registros se modificaron como parte del preámbulo para llamar a error , y un registro fue. Aquí está el código de máquina completo para esa rama, comenzando desde el salto condicional sobre el if bloque y terminando donde va el salto si el condicional if no se toma:

     f2c:   350000e8    cbnz    w8, f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Todo el código después de la bifurcación fue generado por el compilador asumiendo que el estado del programa era como era antes del salto condicional ! Pero simplemente dando el salto final al error código de función no operativo, creé una ruta de código donde llegamos a ese código con un estado de programa inconsistente/incorrecto !

En mi caso, esto en realidad parecía no causar ningún problema. Así que tuve suerte. Muy suerte:solo después de que ya ejecuté mi binario modificado (que, por cierto, era un binario crítico para la seguridad :tenía la capacidad de setuid , setgid y cambie el contexto de SELinux !) ¡Me di cuenta de que olvidé rastrear las rutas de código de si esos cambios de registro afectaron las rutas de código que vinieron más tarde!

Eso podría haber sido catastrófico:¡cualquiera de esos registros podría haberse usado en un código posterior con la suposición de que contenía un valor anterior que ahora se sobrescribió! Y soy el tipo de persona que la gente conoce por su pensamiento meticuloso y cuidadoso sobre el código y como un pedante y estricto por ser siempre consciente de la seguridad informática.

¿Qué pasa si estoy llamando a una función donde los argumentos se derraman de los registros a la pila (como es muy común, por ejemplo, en x86)? ¿Qué pasaría si en realidad hubiera varias instrucciones condicionales en el conjunto de instrucciones que precedieron al salto condicional (como es común, por ejemplo, en versiones anteriores de ARM)? ¡Habría estado en un estado aún más temerariamente inconsistente después de haber hecho ese cambio aparentemente tan simple!

Este es mi recordatorio de advertencia: Jugar manualmente con binarios es literalmente quitar todas seguridad entre usted y lo que la máquina y el sistema operativo permitirán. Literalmente todos los avances que hemos hecho en nuestras herramientas para detectar automáticamente los errores de nuestros programas, desaparecieron .

Entonces, ¿cómo solucionamos esto de manera más adecuada? Sigue leyendo.

Eliminar código

Para efectivamente /lógicamente "eliminar" más de una instrucción, puede reemplazar la primera instrucción que desea "eliminar" con un salto incondicional a la primera instrucción al final de las instrucciones "eliminadas". Para este binario ARMv8, se veía así:

     f2c:   14000007    b   f48
     f30:   b0000002    adrp    x2, 1000
     f34:   91128442    add x2, x2, #0x4a1
     f38:   320003e0    orr w0, wzr, #0x1
     f3c:   2a1f03e1    mov w1, wzr
     f40:   aa1503e3    mov x3, x21
     f44:   97fffeeb    bl  af0 <[email protected]>
     f48:   f94013f7    ldr x23, [sp, #32]

Básicamente, "matas" el código (lo conviertes en "código muerto"). Nota al margen:puede hacer algo similar con cadenas literales incrustadas en el binario:siempre que desee reemplazarlo con una cadena más pequeña, casi siempre puede sobrescribir la cadena (incluido el byte nulo de terminación si es un "C- string") y, si es necesario, sobrescribir el tamaño codificado de forma rígida de la cadena en el código de máquina que lo usa.

También puede reemplazar todas las instrucciones no deseadas con no-ops. En otras palabras, podemos convertir el código no deseado en lo que se llama un "trineo sin operaciones":

     f2c:   d503201f    nop
     f30:   d503201f    nop
     f34:   d503201f    nop
     f38:   d503201f    nop
     f3c:   d503201f    nop
     f40:   d503201f    nop
     f44:   d503201f    nop
     f48:   f94013f7    ldr x23, [sp, #32]

Esperaría que eso sea solo desperdiciar ciclos de CPU en relación con saltar sobre ellos, pero es más simple y por lo tanto más seguro contra errores , porque no es necesario que descubra manualmente cómo codificar la instrucción de salto, incluido el desfase/dirección que se usará en ella; no tiene que pensar tanto para un trineo sin operaciones.

Para ser claros, el error es fácil:metí la pata dos (2) veces al codificar manualmente esa instrucción de bifurcación incondicional. Y no siempre es culpa nuestra:la primera vez fue porque la documentación que tenía estaba desactualizada/errónea y decía que se ignoró un bit en la codificación, cuando en realidad no era así, así que lo configuré a cero en mi primer intento.

Agregar código

podrías teóricamente usa esta técnica para agregar instrucciones de máquina también, pero es más complejo y nunca he tenido que hacerlo, así que no tengo un ejemplo resuelto en este momento.

Desde la perspectiva del código de máquina, es bastante fácil:elija una instrucción en el lugar donde desea agregar el código y conviértala en una instrucción de salto al nuevo código que necesita agregar (no olvide agregar la(s) instrucción(es) que desee). reemplazado en el nuevo código, a menos que no lo necesite para su lógica agregada, y para volver a la instrucción a la que desea regresar al final de la adición). Básicamente, estás "empalmando" el nuevo código.

Pero tienes que encontrar un lugar para colocar ese nuevo código, y esta es la parte difícil.

Si eres realmente por suerte, puede simplemente agregar el nuevo código de máquina al final del archivo, y "simplemente funcionará":el nuevo código se cargará junto con el resto en las mismas instrucciones de máquina esperadas, en su espacio de direcciones que cae en una página de memoria correctamente marcada como ejecutable.

En mi experiencia hexdump -R ignora no solo la columna más a la derecha sino también la columna más a la izquierda, por lo que literalmente podría poner cero direcciones para todas las líneas agregadas manualmente y funcionará.

Si tiene menos suerte, después de agregar el código, tendrá que ajustar algunos valores de encabezado dentro del mismo archivo:si el cargador de su sistema operativo espera que el binario contenga metadatos que describan el tamaño de la sección ejecutable (por razones históricas a menudo llamada la sección de "texto"), tendrá que encontrarla y ajustarla. En los viejos tiempos, los archivos binarios eran solo código de máquina sin procesar; hoy en día, el código de máquina está envuelto en un montón de metadatos (por ejemplo, ELF en Linux y algunos otros).

Si aún tiene un poco de suerte, es posible que tenga algún punto "muerto" en el archivo que se cargue correctamente como parte del binario con las mismas compensaciones relativas que el resto del código que ya está en el archivo (y eso el punto muerto puede ajustarse a su código y está correctamente alineado si su CPU requiere alineación de palabras para las instrucciones de la CPU). Entonces puedes sobrescribirlo.

Si tiene mucha mala suerte, no puede simplemente agregar código y no hay espacio muerto que pueda llenar con su código de máquina. En ese punto, básicamente debe estar íntimamente familiarizado con el formato ejecutable y esperar que pueda encontrar algo dentro de esas restricciones que sea humanamente factible de realizar manualmente dentro de una cantidad razonable de tiempo y con una posibilidad razonable de no estropearlo. .


@mgiuca ha abordado correctamente esta respuesta desde un punto de vista técnico. De hecho, desensamblar un programa ejecutable en una fuente de ensamblaje fácil de volver a compilar no es una tarea fácil.

Para agregar algo a la discusión, hay un par de técnicas/herramientas que podrían ser interesantes de explorar, aunque son técnicamente complejas.

  1. Instrumentación estática/dinámica . Esta técnica implica analizar el formato ejecutable, insertar/eliminar/reemplazar instrucciones de ensamblaje específicas para un propósito determinado, corregir todas las referencias a variables/funciones en el ejecutable y emitir un nuevo ejecutable modificado. Algunas herramientas que conozco son:PIN, Hijacker, PEBIL, DynamoRIO. Tenga en cuenta que configurar tales herramientas para un propósito diferente al que fueron diseñadas puede ser complicado y requiere comprensión tanto de los formatos ejecutables como de los conjuntos de instrucciones.
  2. Descompilación completa del ejecutable . Esta técnica intenta reconstruir una fuente de ensamblaje completa a partir de un ejecutable. Es posible que desee echar un vistazo al Desensamblador en línea, que intenta hacer el trabajo. De todos modos, pierde información sobre diferentes módulos de origen y posiblemente funciones/nombres de variables.
  3. Descompilación redireccionable . Esta técnica intenta extraer más información del ejecutable, observando las huellas dactilares del compilador (es decir, patrones de código generados por compiladores conocidos) y otras cosas deterministas. El objetivo principal es reconstruir el código fuente de nivel superior, como la fuente C, a partir de un ejecutable. Esto a veces puede recuperar información sobre funciones/nombres de variables. Considere que compilar fuentes con -g a menudo ofrece mejores resultados. Es posible que desee probar Retargetable Decompiler.

La mayor parte de esto proviene de los campos de investigación de evaluación de vulnerabilidades y análisis de ejecución. Son técnicas complejas y, a menudo, las herramientas no se pueden usar de inmediato. Sin embargo, brindan una ayuda invaluable cuando se intenta aplicar ingeniería inversa a algún software.


Linux
  1. Cómo administrar y enumerar servicios en Linux

  2. Cómo instalar y probar Ansible en Linux

  3. Linux:¿cómo verificar que una distribución de Linux sea segura y no tenga código malicioso?

  4. Cómo compilar e instalar software desde el código fuente en Linux

  5. ¿Cómo desensamblar un ejecutable binario en Linux para obtener el código de ensamblaje?

¿Cómo instalar y usar Linux Screen?

Cómo cambiar el nombre de archivos y directorios en Linux

Cómo comprimir archivos y directorios en Linux

Cómo hacer un archivo ejecutable en Linux

Cómo instalar y usar PuTTY en Linux

Cómo instalar y usar phpMyAdmin en Linux