script - ¿Cómo desmontar, modificar y luego volver a montar un ejecutable de Linux?
scripts linux ejercicios resueltos (8)
@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 recompilar no es una tarea fácil.
Para agregar algunos bits a la discusión, hay un par de técnicas / herramientas que podrían ser interesantes para explorar, aunque son técnicamente complejas.
- Instrumentación estática / dinámica . Esta técnica implica analizar el formato del 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 de las herramientas que conozco son: PIN , PEBIL , DynamoRIO , DynamoRIO . Considere que configurar tales herramientas para un propósito diferente al que fueron diseñados para ellos podría ser complicado, y requiere la comprensión de los formatos ejecutables y los conjuntos de instrucciones.
- Descompilación ejecutable completa . Esta técnica intenta reconstruir una fuente de ensamblaje completo 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, se pierde información sobre diferentes módulos de origen y, posiblemente, funciones / nombres de variables.
- Descompilación retargetable . Esta técnica intenta extraer más información del ejecutable, observando las huellas digitales del compilador (es decir, los 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 los nombres de funciones / variables. Tenga en cuenta que compilar fuentes con
-g
menudo ofrece mejores resultados. Es posible que desee probar el Descompilador de Recargable .
La mayor parte de esto proviene de la evaluación de la vulnerabilidad y los campos de investigación de análisis de ejecución. Son técnicas complejas y, a menudo, las herramientas no se pueden utilizar de inmediato. Sin embargo, proporcionan una ayuda inestimable al intentar aplicar ingeniería inversa a algunos programas.
¿Hay alguna forma de hacer esto? He usado objdump pero eso no produce resultados de ensamblaje que serán aceptados por cualquier ensamblador que conozca. Me gustaría poder cambiar instrucciones dentro de un ejecutable y luego probarlo.
Hago esto con hexdump
y un editor de texto. Debe sentirse realmente cómodo con el código de la máquina y el formato de archivo almacenado, y ser flexible con lo que cuenta como "desmontar, modificar y luego volver a ensamblar".
Si puede salirse con la suya solo con hacer "cambios puntuales" (reescribir bytes, pero sin agregar ni eliminar bytes), será fácil (en términos relativos).
Realmente no desea desplazar ninguna instrucción existente, porque entonces tendría que ajustar manualmente cualquier compensación relativa efectuada dentro del código de la máquina, para saltos / ramas / cargas / almacenes en relación con el contador del programa, tanto en valores inmediatos codificados como Los computados a través de registros .
Siempre debe poder salirse con la no eliminación de 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 que realmente haya desensamblado el archivo correctamente con objdump -D
o lo que sea que primero use para entenderlo y encontrar los puntos que necesita cambiar, deberá tomar nota de las siguientes cosas para ayudarlo a localizar los bytes correctos. Modificar:
- La "dirección" (desplazamiento desde el inicio del archivo) de los bytes que necesita cambiar.
- El valor en bruto de esos bytes como son actualmente (la opción
--show-raw-insn
paraobjdump
es realmente útil aquí).
Paso 1
Vuelque la representación hexadecimal sin hexdump -Cv
archivo binario con hexdump -Cv
.
Paso 2
Abra el archivo hexdump
ed y encuentre los bytes en la dirección que desea cambiar.
Curso acelerado rápido en hexdump -Cv
salida de hexdump -Cv
:
- 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). - La columna más a la derecha (rodeada por
|
caracteres) es simplemente una representación "legible por humanos" de los bytes: el carácter ASCII que coincide con cada byte se escribe allí, con una.
para todos los bytes que no se asignan a un carácter imprimible ASCII. - 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 da la dirección de cada instrucción y muestra el hex en bruto de la instrucción según cómo se documenta que está codificada, hexdump -Cv
vuelca cada byte exactamente en el orden en que aparece en el archivo. Esto puede ser un poco confuso como primer lugar en las máquinas donde los bytes de instrucciones están en orden opuesto debido a las diferencias de endianness, lo que también puede ser desorientador cuando se espera un byte específico como una dirección específica.
Paso 3
Modifique los bytes que deben cambiarse - obviamente necesita averiguar la codificación de instrucciones de la máquina en bruto (no los mnemónicos de ensamblaje) y escribir manualmente los bytes correctos.
Nota: no es necesario cambiar la representación legible por humanos en la columna de la derecha. hexdump
lo ignorará cuando lo " hexdump
".
Etapa 4
"Deshacer" el archivo hexdump modificado utilizando hexdump -R
.
Paso 5 (control de cordura)
objdump
su nuevo archivo hexdump
ed y verifique que el desmontaje que ha cambiado parece correcto. diff
contra el objeto del original.
En serio, no te saltes este paso. Cometo errores con más frecuencia que no cuando edito manualmente el código de la máquina y así es como detecto la mayoría de ellos.
Ejemplo
Este es un ejemplo 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 la mano, y los principios fundamentales son los mismos, solo que las instrucciones son diferentes).
En mi situación, tenía que deshabilitar una comprobación específica de "no debería estar haciendo esto": en mi ejemplo binario, en objdump --show-raw-insn -d
, la línea que me importaba tenía este aspecto (uno instrucción antes y después dada para el contexto):
f40: aa1503e3 mov x3, x21
f44: 97fffeeb bl af0 <error@plt>
f48: f94013f7 ldr x23, [sp, #32]
Como puede ver, nuestro programa está saliendo "útilmente" saltando a una función de error
(que termina el programa). Inaceptable. Así que vamos a convertir esa instrucción en un no-op. Así que estamos buscando los bytes 0x97fffeeb
en la dirección / desplazamiento de archivo 0xf44
.
Aquí está la línea hexdump -Cv
que contiene ese desplazamiento.
00000f40 e3 03 15 aa eb fe ff 97 f7 13 40 f9 e8 02 40 39 |..........@...@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é desplazamiento de bytes:
00000f40 -- -- -- -- eb fe ff 97 -- -- -- -- -- -- -- -- |..........@...@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 otro desmontaje que 0xd503201f
desmonta y no se 0xd503201f
por lo que parece ser un buen candidato para mi instrucción no 0xd503201f
. hexdump
la línea en el archivo hexdump
ed en consecuencia:
00000f40 e3 03 15 aa 1f 20 03 d5 f7 13 40 f9 e8 02 40 39 |..........@...@9|
Se convirtió de nuevo en binario con hexdump -R
, se desensambló el nuevo binario con objdump --show-raw-insn -d
y se verificó 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, ya que la verificación relevante ya no hizo que el programa abortara.
Modificación del código de máquina exitosa.
!!! Advertencia !!!
¿O tuve éxito? ¿Viste lo que me perdí en este ejemplo?
Estoy seguro de que sí, ya que está preguntando cómo modificar manualmente el código de máquina de un programa, probablemente sepa 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 error! El salto a la función que sale del problema. ¡Pero como puede ver, el registro x3
estaba siendo modificado por el mov
justo arriba! De hecho, un total de cuatro (4) registros fueron modificados como parte del preámbulo para llamar al error
, y un registro fue. Aquí está el código completo de la máquina para esa rama, comenzando desde el salto condicional sobre el bloque if
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 <error@plt>
f48: f94013f7 ldr x23, [sp, #32]
¡Todo el código después de la rama fue generado por el compilador asumiendo que el estado del programa era como era antes del salto condicional ! Pero simplemente haciendo que el salto final al código de la función de error
un no-op, ¡creé una ruta de código donde llegamos a ese código con un estado de programa incoherente / incorrecto !
En mi caso, esto en realidad parecía no causar ningún problema. Así que tuve suerte. Muy afortunado: solo después de haber ejecutado mi binario modificado (que, por cierto, era un binario crítico para la seguridad : tenía la capacidad de setuid
, setgid
y cambiar 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 después!
Eso podría haber sido catastrófico: cualquiera de esos registros podría haber sido usado en un código posterior con el supuesto de que contenía un valor anterior que ahora se sobrescribió. Y soy el tipo de persona que la gente conoce por un meticuloso pensamiento cuidadoso sobre el código y como un pedante y detallista por ser siempre consciente de la seguridad informática.
¿Qué sucede si estaba llamando a una función donde los argumentos se derramaron de los registros en la pila (como es muy común, por ejemplo, x86)? ¿Qué pasaría si hubiera realmente múltiples instrucciones condicionales en el conjunto de instrucciones que precedieron al salto condicional (como es común en, por ejemplo, versiones anteriores de ARM)? ¡Habría estado en un estado aún más imprudentemente inconsistente después de haber hecho ese cambio de apariencia más simple!
Así que este es mi recordatorio de precaución: la combinación manual de binarios es, literalmente, eliminar toda seguridad entre usted y lo que la máquina y el sistema operativo permitirán. Literalmente, todos los avances que hemos realizado en nuestras herramientas para detectar automáticamente los errores de nuestros programas han desaparecido .
Entonces, ¿cómo podemos arreglar esto más adecuadamente? Sigue leyendo
Eliminando Código
Para "eliminar" de manera efectiva / lógica 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, que 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 <error@plt>
f48: f94013f7 ldr x23, [sp, #32]
Básicamente, usted "mata" el código (lo convierte en "código muerto"). Nota: Puede hacer algo similar con cadenas literales incrustadas en el binario: siempre que quiera reemplazarlo con una cadena más pequeña, casi siempre puede sobrescribir la cadena (incluido el byte nulo de terminación si es una "C- cadena ") y si es necesario sobrescribiendo el tamaño codificado de la cadena en el código de máquina que lo utiliza.
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 no operativo":
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]
Yo esperaría que eso solo desperdicie los ciclos de la CPU en relación con el salto sobre ellos, pero es más simple y por lo tanto más seguro contra los errores , porque no tiene que descubrir manualmente cómo codificar la instrucción de salto, incluido el cálculo de la dirección / desplazamiento a usar. en él, no tienes que pensar tanto para un trineo no operado.
Para ser claro, el error es fácil: me equivoqué dos (2) veces cuando codifiqué manualmente esa instrucción de rama incondicional. Y no siempre es culpa nuestra: la primera vez fue porque la documentación que tenía era obsoleta / incorrecta y dijo que un bit se ignoró en la codificación, cuando en realidad no lo estaba, así que lo puse a cero en mi primer intento.
Agregando Código
Teóricamente, podría usar esta técnica para agregar instrucciones de la máquina, pero es más compleja, y nunca tuve que hacerlo, así que no tengo un ejemplo práctico en este momento.
Desde la perspectiva de un 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 las instrucciones que así le corresponda). reemplazado en el nuevo código, a menos que no lo necesitara para su lógica adicional, 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 en.
Pero tienes que encontrar un lugar para poner ese nuevo código, y esta es la parte difícil.
Si tiene mucha suerte, simplemente puede 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 espacio que cae en una página de memoria correctamente marcada ejecutable.
En mi experiencia, hexdump -R
ignora no solo la columna que está más a la derecha, sino también la columna que está más a la izquierda, así que, literalmente, puedes 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 "texto") tendrá que encontrar y ajustar eso. En los viejos tiempos, los binarios eran simplemente código de máquina en bruto. 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 en 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 se alinea correctamente si su CPU requiere alineación de palabras para las instrucciones de la CPU). Entonces puedes sobrescribirlo.
Si realmente tiene mala suerte, no puede simplemente agregar el código y no hay espacio muerto que pueda llenar con el código de su máquina. En ese momento, básicamente tiene que estar íntimamente familiarizado con el formato ejecutable y esperar poder encontrar algo dentro de esas restricciones que sea humanamente posible realizar manualmente en un tiempo razonable y con una posibilidad razonable de no estropearlo. .
Mi "desensamblador de ensamblador ci" es el único sistema que sé que está diseñado según el principio de que sea cual sea el desmontaje, debe volver a ensamblarse en el byte para el byte mismo binario.
https://github.com/albertvanderhorst/ciasdis
Hay dos ejemplos dados de ejecutables elf con su desmontaje y reensamblaje. Originalmente, fue diseñado para poder modificar un sistema de arranque, que consiste en código, código interpretado, datos y caracteres gráficos, con tales detalles como una transición del modo real al protegido. (Lo consiguió.) Los ejemplos demuestran también la extracción de texto de los ejecutables, que se usa posteriormente para las etiquetas. El paquete debian está diseñado para Intel Pentium, pero los complementos están disponibles para Dec Alpha, 6809, 8086, etc.
La calidad del desmontaje depende de cuánto esfuerzo ponga en él. Por ejemplo, si ni siquiera proporciona la información de que se trata de un archivo elf, el desmontaje consta de bytes individuales y el reensamblaje es trivial. En los ejemplos, uso un script que extrae etiquetas, y crea un programa de ingeniería inversa realmente utilizable que es modificable. Puede insertar o eliminar algo y las etiquetas simbólicas generadas automáticamente se recalcularán.
No se hace ninguna suposición sobre el blob binario, pero, por supuesto, un desmontaje de Intel es de poca utilidad para un binario Dec Alpha.
Otra cosa que podría estar interesado en hacer:
- Instrumentación binaria - cambio de código existente
Si está interesado, consulte: Pin, Valgrind (o proyectos que hacen esto: NaCl - Cliente nativo de Google, tal vez QEmu.)
Para cambiar el código dentro de un ensamblaje binario, generalmente hay 3 formas de hacerlo.
- Si es solo una cosa trivial como una constante, simplemente cambias la ubicación con un editor hexadecimal. Asumiendo que puedes encontrarlo para empezar.
- Si necesita modificar el código, utilice LD_PRELOAD para sobrescribir alguna función en su programa. Sin embargo, eso no funciona si la función no está en las tablas de funciones.
- Hackee el código en la función que desea arreglar para que sea un salto directo a una función que cargue a través de LD_PRELOAD y luego vuelva a la misma ubicación (esta es una combinación de las dos anteriores)
Por supuesto, solo la segunda funcionará si el ensamblaje realiza algún tipo de comprobación de integridad.
Edición: Si no es obvio, entonces jugar con ensamblajes binarios es MUY de alto nivel para desarrolladores, y le será difícil preguntar al respecto aquí, a menos que sea algo realmente específico.
Puede ejecutar el ejecutable bajo la supervisión de ptrace (en otras palabras, un depurador como gdb) y de esa manera, controlar la ejecución a medida que avanza, sin modificar el archivo real. Por supuesto, requiere las habilidades de edición habituales, como encontrar dónde están las instrucciones particulares en las que desea influenciar en el ejecutable.
miasma
https://github.com/cea-sec/miasm
Esta parece ser la solución concreta más prometedora. Según la descripción del proyecto, la biblioteca puede:
- Apertura / modificación / generación de PE / ELF 32/64 LE / BE utilizando Elfesteem
- Montaje / desmontaje X86 / ARM / MIPS / SH4 / MSP430
Así que básicamente debería:
- analizar el ELF en una representación interna (desmontaje)
- modifica lo que quieras
- generar un nuevo ELF (montaje)
No creo que genere una representación de desmontaje textual, es probable que tenga que recorrer las estructuras de datos de Python.
TODO debe encontrar un ejemplo mínimo de cómo hacer todo eso usando la biblioteca. Un buen punto de partida parece ser example/disasm/full.py , que analiza un archivo ELF dado. La estructura clave de nivel superior es Container
, que lee el archivo ELF con Container.from_stream
. ¿TODO cómo volver a montarlo después? Este artículo parece hacerlo: http://www.miasm.re/blog/2016/03/24/re150_rebuild.html
Esta pregunta pregunta si hay alguna otra biblioteca de este tipo: https://reverseengineering.stackexchange.com/questions/1843/what-are-the-available-libraries-to-statically-modify-elf-executables
Preguntas relacionadas:
- https://reverseengineering.stackexchange.com/questions/185/how-do-i-add-functionality-to-an-existing-binary-executable
- https://askubuntu.com/questions/617441/how-can-edit-a-executable-file-linux
Creo que este problema no es automatizable.
Creo que el problema general no es completamente automatizable, y la solución general es básicamente equivalente a "cómo aplicar ingeniería inversa" a un binario.
Para insertar o eliminar bytes de manera significativa, deberíamos asegurarnos de que todos los saltos posibles sigan saltando a las mismas ubicaciones.
En términos formales, necesitamos extraer el gráfico de flujo de control del binario.
Sin embargo, con ramas indirectas, por ejemplo, https://en.wikipedia.org/wiki/Indirect_branch , no es fácil determinar ese gráfico, vea también: Cálculo de destino de salto indirecto
No creo que haya una 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 (por ejemplo, en formato ELF) y producir un programa de ensamblaje de origen que compile al mismo binario (o lo suficientemente similar). Para comprender las diferencias, compare la salida de GCC compilando directamente al ensamblador ( gcc -S
) con la salida de objdump en el ejecutable ( objdump -D
).
Hay dos complicaciones mayores que se me ocurren. En primer lugar, el código de la máquina en sí no es una correspondencia 1 a 1 con el código de ensamblaje, debido a cosas como las compensaciones de puntero.
Por ejemplo, considere el código C para Hello world:
int main()
{
printf("Hello, world!/n");
return 0;
}
Esto compila al 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 <printf@plt>
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 vinculador 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 en realidad no está codificada en la dirección del puntero en ese código, pero los encabezados ELF contienen una Tabla de búsqueda que reemplaza dinámicamente su dirección en tiempo de ejecución. Por lo tanto, el código desensamblado no corresponde del todo al código fuente del ensamblaje.
En resumen, el ensamblaje de origen tiene símbolos, mientras que el código de máquina compilado tiene direcciones que son difíciles de invertir.
La segunda complicación principal es que un archivo fuente de ensamblaje no puede contener toda la información que estaba presente en los encabezados del archivo ELF original, como las bibliotecas con las que se vincula 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 pueda producir simplemente un código de ensamblaje que se pueda volver a montar 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 la recompilación de toda la aplicación. Use objdump para obtener el código de ensamblaje para las funciones en las que está interesado. Conviértalo a "sintaxis de ensamblaje de origen" manualmente (y aquí, me gustaría que hubiera una herramienta que realmente produjera el desensamblaje en 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, cuidando que su nuevo código sea exactamente el mismo número 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 usando las instrucciones de NOP. Si es más largo, es posible que tenga problemas y que tenga que crear nuevas funciones y llamarlas en su lugar.