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:String
o 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 for
bucle es casi exactamente lo mismo que unwhile
bucle, 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.
fuente
Respuestas:
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.
fuente
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:
podría compilarse en (lo siento, esto no es un ensamblador real):
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 3
para 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.
fuente
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:
Puede compilarse para:
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
DoSomething
yAdd
, además del hecho de que elAdd
método tiene dos parámetros con nombre, el compilador también sabe que elDoSomething
mé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.
fuente
ret
y simplemente diga que estaba asumiendo la convención de llamadas C.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
for
owhile
y si están estructurados correctamente, entonces puede obtener un código de máquina idéntico conjump
instrucciones.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
jump
instrucciones de vuelta a construcciones en bucle? ¿Los hacesfor
bucles owhile
bucles?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.
fuente