¿Por qué el código de máquina nativo no se puede descompilar fácilmente?

16

Con lenguajes de máquina virtual basados ​​en bytecode como Java, VB.NET, C #, ActionScript 3.0, etc., a veces se escucha lo fácil que es simplemente descargar un descompilador de Internet, ejecutar el bytecode a través de él un buen momento, y a menudo, se trata de algo que no está muy lejos del código fuente original en cuestión de segundos. Supuestamente este tipo de lenguaje es particularmente vulnerable a eso.

Recientemente comencé a preguntarme por qué no escuchas más sobre esto con respecto al código binario nativo, cuando al menos sabes en qué idioma se escribió originalmente (y, por lo tanto, en qué idioma tratar de descompilar). Durante mucho tiempo, pensé que era solo porque el lenguaje de máquina nativo es mucho más loco y complejo que el típico código de bytes.

Pero, ¿cómo se ve el bytecode? Se parece a esto:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

¿Y cómo se ve el código de máquina nativo (en hexadecimal)? Por supuesto, se ve así:

1000: 2A 40 F0 14
1001: 2A 50 F1 27
1002: 4F 00 F0 F1
1003: C9 00 00 F2

Y las instrucciones provienen de un estado de ánimo algo similar:

1000: mov EAX, 20
1001: mov EBX, loc1
1002: mul EAX, EBX
1003: push ECX

Entonces, dado el lenguaje para tratar de descompilar algunos binarios nativos en, digamos C ++, ¿qué tiene de difícil? Las únicas dos ideas que se me ocurren de inmediato son: 1) realmente es mucho más intrincado que el bytecode, o 2) algo sobre el hecho de que los sistemas operativos tienden a paginar programas y dispersar sus piezas causa demasiados problemas. Si una de esas posibilidades es correcta, explique. Pero de cualquier manera, ¿por qué nunca escuchas de esto básicamente?

NOTA

Estoy a punto de aceptar una de las respuestas, pero quiero mencionar algo primero. Casi todo el mundo se está refiriendo al hecho de que diferentes partes del código fuente original podrían correlacionarse con el mismo código de máquina; se pierden nombres de variables locales, no sabe qué tipo de bucle se utilizó originalmente, etc.

Sin embargo, ejemplos como los dos que acabamos de mencionar son algo triviales a mis ojos. Sin embargo, algunas de las respuestas tienden a indicar que la diferencia entre el código de máquina y la fuente original es drásticamente mucho más que algo tan trivial.

Pero, por ejemplo, cuando se trata de cosas como nombres de variables locales y tipos de bucles, el código de bytes también pierde esta información (al menos para ActionScript 3.0). Ya he recuperado esas cosas a través de un descompilador antes, y realmente no me importaba si se llamaba strMyLocalString:Stringo no a una variable loc1. Todavía podría mirar en ese pequeño alcance local y ver cómo se está utilizando sin muchos problemas. Y un forbucle es casi exactamente lo mismo que unwhilebucle, si lo piensas. Además, incluso cuando ejecutaba la fuente a través de irrFuscator (que, a diferencia de secureSWF, no hace mucho más que aleatorizar variables de miembros y nombres de funciones), todavía parece que podría comenzar a aislar ciertas variables y funciones en clases más pequeñas, figura averiguar cómo se usan, asignarles sus propios nombres y trabajar desde allí.

Para que esto sea un gran problema, el código de la máquina necesitaría perder mucha más información que eso, y algunas de las respuestas entran en esto.

Panzercrisis
fuente
35
Es difícil hacer una vaca con hamburguesas.
Kaz Dragon
44
El problema principal es que un binario nativo retiene muy pocos metadatos sobre el programa. No retiene información sobre las clases (lo que hace que C ++ sea particularmente difícil de descompilar) y no siempre tiene información sobre las funciones; no es necesario ya que una CPU ejecuta inherentemente el código de una manera bastante lineal, una instrucción a la vez. Además, es imposible diferenciar entre código y datos ( enlace ). Para obtener más información, es posible que desee considerar la búsqueda o volver a preguntar en RE.SE .
ntoskrnl

Respuestas:

39

En cada paso de la compilación, pierde información irrecuperable. Cuanta más información pierda de la fuente original, más difícil será descompilar.

Puede crear un descompilador útil para el código de bytes porque se conserva mucha más información de la fuente original que la que se conserva al producir el código de máquina de destino final.

El primer paso de un compilador es convertir la fuente en alguna representación intermedia a menudo representada como un árbol. Tradicionalmente, este árbol no contiene información no semántica, como comentarios, espacios en blanco, etc. Una vez que se descarta, no puede recuperar la fuente original de ese árbol.

El siguiente paso es representar el árbol en alguna forma de lenguaje intermedio que facilite las optimizaciones. Hay bastantes opciones aquí y cada infraestructura de compilador tiene la suya propia. Normalmente, sin embargo, se pierde información como nombres de variables locales, grandes estructuras de flujo de control (como si usó un bucle for o while). Aquí suelen ocurrir algunas optimizaciones importantes, propagación constante, movimiento de código invariable, alineación de funciones, etc. Cada una de las cuales transforma la representación en una representación que tiene una funcionalidad equivalente pero que se ve sustancialmente diferente.

Un paso después de eso es generar las instrucciones reales de la máquina que pueden involucrar lo que se llama optimización de "mirilla" que produce una versión optimizada de patrones de instrucciones comunes.

En cada paso pierdes más y más información hasta que, al final, pierdes tanto que es imposible recuperar algo parecido al código original.

El código de bytes, por otro lado, generalmente guarda las optimizaciones interesantes y transformadoras hasta la fase JIT (el compilador justo a tiempo) cuando se produce el código de máquina de destino. El código de bytes contiene una gran cantidad de metadatos, como tipos de variables locales, estructura de clases, para permitir que el mismo código de bytes se compile en múltiples códigos de máquina de destino. Toda esta información no es necesaria en un programa C ++ y se descarta en el proceso de compilación.

Hay descompiladores para varios códigos de máquina de destino, pero a menudo no producen resultados útiles (algo que puede modificar y luego volver a compilar) ya que se pierde demasiado de la fuente original. Si tiene información de depuración para el ejecutable, puede hacer un trabajo aún mejor; pero, si tiene información de depuración, probablemente también tenga la fuente original.

chuckj
fuente
55
El hecho de que la información se mantenga para que JIT pueda funcionar mejor es clave.
btilly
¿Son entonces las DLL de C ++ fácilmente descompilables?
Panzercrisis
1
No en nada que yo considere útil.
chuckj
1
Los metadatos no son "para permitir que se compile el mismo código de bytes en múltiples objetivos", está ahí para reflexionar. La representación intermedia retargetable no necesita tener ninguno de esos metadatos.
SK-logic
2
Eso no es verdad. Gran parte de los datos están ahí para la reflexión, pero la reflexión no es el único uso. Por ejemplo, las definiciones de interfaz y clase se utilizan para crear un desplazamiento de campo definido, construir tablas virtuales, etc. en la máquina de destino, lo que les permite construirse de la manera más eficiente para la máquina de destino. Estas tablas son construidas por el compilador y / o el enlazador cuando producen código nativo. Una vez hecho esto, los datos utilizados para construirlos se descartan.
chuckj
11

La pérdida de información como lo señalan las otras respuestas es un punto, pero no es el factor decisivo. Después de todo, no espera que vuelva el programa original, solo desea cualquier representación en un lenguaje de alto nivel. Si el código está en línea, puede dejarlo, o factorizar automáticamente los cálculos comunes. En principio, puede deshacer muchas optimizaciones. Pero hay algunas operaciones que son en principio irreversibles (sin una cantidad infinita de cómputo al menos).

Por ejemplo, las ramas pueden convertirse en saltos calculados. Código como este:

select (x) {
case 1:
    // foo
    break;
case 2:
    // bar
    break;
}

podría compilarse en (lo siento, esto no es un ensamblador real):

0x1000:   jump to 0x1000 + 4*x
0x1004:   // foo
0x1008:   // bar
0x1012:   // qux

Ahora, si sabes que x puede ser 1 o 2, puedes mirar los saltos y revertir esto fácilmente. ¿Pero qué hay de la dirección 0x1012? ¿Deberías crear un case 3para eso también? Tendría que rastrear todo el programa en el peor de los casos para descubrir qué valores están permitidos. ¡Peor aún, es posible que tenga que considerar todas las posibles entradas del usuario! El núcleo del problema es que no se pueden distinguir los datos y las instrucciones.

Dicho esto, no sería del todo pesimista. Como habrás notado en el 'ensamblador' anterior, si x viene del exterior y no se garantiza que sea 1 o 2, esencialmente tienes un error que te permite saltar a cualquier parte. Pero si su programa está libre de este tipo de error, es mucho más fácil razonar. (No es casualidad que los lenguajes intermedios "seguros" como CLR IL o el bytecode de Java sean mucho más fáciles de descompilar, incluso dejando de lado los metadatos). Por lo tanto, en la práctica, debería ser posible descompilar ciertos comportamientos correctosprogramas Estoy pensando en rutinas de estilo individuales y funcionales, que no tienen efectos secundarios y entradas bien definidas. Creo que hay un par de descompiladores que pueden proporcionar pseudocódigo para funciones simples, pero no tengo mucha experiencia con tales herramientas.

jdm
fuente
9

La razón por la cual el código de la máquina no puede convertirse fácilmente en el código fuente original es porque se pierde mucha información durante la compilación. Los métodos y las clases no exportadas pueden estar en línea, los nombres de las variables locales se pierden, los nombres y las estructuras de los archivos se pierden por completo, los compiladores pueden hacer optimizaciones no obvias. Otra razón es que múltiples archivos de origen diferentes podrían producir exactamente el mismo ensamblaje.

Por ejemplo:

int DoSomething()
{
    return Add(5, 2);
}

int Add(int x, int y)
{
    return x + y;
}

int main()
{
    return DoSomething();
}

Puede compilarse para:

main:
mov eax, 7;
ret;

Mi ensamblaje está bastante oxidado, pero si el compilador puede verificar que una optimización se puede hacer con precisión, lo hará. Esto se debe a que el binario compilado no necesita conocer los nombres DoSomethingy Add, además del hecho de que el Addmétodo tiene dos parámetros con nombre, el compilador también sabe que el DoSomethingmétodo esencialmente devuelve una constante, y podría en línea tanto la llamada al método como la método en sí mismo.

El propósito del compilador es crear un ensamblaje, no una forma de agrupar archivos fuente.

Mateo
fuente
Considere cambiar la última instrucción a solo rety simplemente diga que estaba asumiendo la convención de llamadas C.
chuckj
3

Los principios generales aquí son mapeos muchos a uno y la falta de representantes canónicos.

Para un ejemplo simple del fenómeno de muchos a uno, puede pensar en lo que sucede cuando toma una función con algunas variables locales y la compila en código de máquina. Toda la información sobre las variables se pierde porque simplemente se convierten en direcciones de memoria. Algo similar sucede con los bucles. Puede tomar un bucle foro whiley si están estructurados correctamente, entonces puede obtener un código de máquina idéntico con jumpinstrucciones.

Esto también plantea la falta de representantes canónicos del código fuente original para las instrucciones del código de la máquina. Cuando intentas descompilar bucles, ¿cómo mapeas las jumpinstrucciones de vuelta a construcciones en bucle? ¿Los haces forbucles o whilebucles?

El problema se exaspera aún más por el hecho de que los compiladores modernos realizan varias formas de plegado y alineado. Entonces, para cuando llegue al código de máquina, es casi imposible saber de qué construcciones de alto nivel proviene el código de máquina de bajo nivel.

davidk01
fuente