Ingeniería inversa de NativeImage
Restaurar y proteger el código Java es un tema antiguo y frecuentemente debatido. Gracias al formato de código de bytes utilizado para almacenar los archivos de clase Java, que contiene gran cantidad de metainformación, es fácil restaurarlo a su código original. Para proteger el código Java, la industria ha adoptado diversos métodos, como la ofuscación, el cifrado de código de bytes, la protección JNI, etc. Sin embargo, independientemente del método utilizado, existen maneras de descifrarlo.
La compilación binaria siempre se ha considerado un método relativamente eficaz para proteger el código. La compilación binaria de Java se basa en la tecnología AOT (Ahead of Time), que significa precompilación.
Sin embargo, debido a la naturaleza dinámica del lenguaje Java, la compilación binaria debe gestionar cuestiones como la reflexión, el proxy dinámico, la carga de JNI, etc., lo que plantea numerosas dificultades. Por lo tanto, durante mucho tiempo se ha carecido de una herramienta madura, fiable y adaptable para la compilación AOT en Java que pueda aplicarse ampliamente en entornos de producción. (Existía una herramienta llamada Excelsior JET, pero parece que ya no se fabrica).
En mayo de 2019, Oracle lanzó GraalVM 19.0, una máquina virtual multilingüe, su primera versión lista para producción. GraalVM proporciona una herramienta NativeImage que permite la compilación AOT de programas Java. Tras varios años de desarrollo, NativeImage está muy consolidada y SpringBoot 3.0 puede usarla para compilar todo el proyecto SpringBoot en un archivo ejecutable. El archivo compilado ofrece una alta velocidad de inicio, un bajo consumo de memoria y un excelente rendimiento.
Entonces, ¿el código de los programas Java que han entrado en la era de la compilación binaria sigue siendo tan fácilmente reversible como en la era del bytecode? ¿Cuáles son las características de los archivos binarios compilados por NativeImage? ¿Es la intensidad de la compilación binaria suficiente para proteger el código importante?
Para explorar estas cuestiones, recientemente desarrollamos una herramienta de análisis NativeImage, que ha logrado cierto grado de efecto inverso.
Proyecto
https://github.com/vlinx-io/Analizador de imágenes nativo
Generando NativeImage
Primero, necesitamos generar una imagen nativa. Esta imagen nativa proviene de GraalVM. Para descargar GraalVM, vaya a https://www.graalvm.org/ Descargue la versión para Java 17. Después de la descarga, configure la variable de entorno. Dado que GraalVM contiene un JDK, puede usarlo directamente para ejecutar el comando java.
Agregue $GRAALVM_HOME/bin a la variable de entorno y luego ejecute el siguiente comando para instalar la herramienta de imagen nativa
gu install native-image
Un programa Java simple
Escriba un programa Java simple, por ejemplo:
public class Hello {
public static void main(String[] args){
System.out.println("Hello World!");
}
}
Compile y ejecute el programa Java anterior:
javac Hello.java
java -cp . Hello
Obtendrá el siguiente resultado:
Hello World!
Preparación para el entorno de compilación
Si usas Windows, primero debes instalar Visual Studio. Si usas Linux o macOS, primero debes instalar herramientas como gcc y clang.
Los usuarios de Windows deben configurar la variable de entorno de Visual Studio antes de ejecutar el comando native-image. Puede configurarlo con el siguiente comando:
"C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Auxiliary\Build\vcvars64.bat"
Si la ruta de instalación y la versión de Visual Studio son diferentes, ajuste la información de la ruta relacionada según corresponda.
Compilar con imagen nativa
Ahora use el comando native-image para compilar el programa Java anterior en un archivo binario. El formato del comando native-image es el mismo que el de Java, e incluye los parámetros -cp y -jar. Para ejecutar el programa con el comando java, utilice el mismo método para la compilación binaria: simplemente reemplace el comando de Java por native-image. Ejecute el comando de la siguiente manera.
native-image -cp . Hello
Tras un periodo de compilación, puede consumir más CPU y memoria. Puede obtener un archivo binario compilado, cuyo nombre predeterminado es la minúscula del nombre de la clase principal, que en este caso es "hello". En Windows, será "hello.exe". Utilice el comando "file" para comprobar el tipo de archivo; comprobará que se trata de un archivo binario.
file hello
hello: Mach-O 64-bit executable x86_64
Ejecute este archivo y su salida será la misma que la obtenida en el ejemplo anterior: use.java -cp. El resultado es consistente.
Hello World!
Analizando NativeImage
Analizando con IDA
Utilice IDA para abrir el saludo compilado de los pasos anteriores, haga clic en Exportaciones para ver la tabla de símbolos, puede ver el símbolo svm_code_section y su dirección es la dirección de entrada de la función principal de Java.

Navegue a esta dirección para ver el código de ensamblaje

Puedes ver que se ha convertido en una función de ensamblaje estándar, usa F5 para descompilar

Se pueden ver algunas llamadas de funciones y se pasan algunos parámetros, pero no es fácil ver la lógica.
Cuando hacemos doble clic en sub_1000C0020, echemos un vistazo dentro de la llamada de función. IDA solicita un error de análisis.

Lógica de descompilación de NativeImage
Dado que la compilación de NativeImage se basa en la compilación de JVM, también puede entenderse como la protección del código binario con una capa de VM. Por lo tanto, herramientas como IDA no pueden realizar ingeniería inversa correctamente sin la información correspondiente y medidas de procesamiento específicas.
Sin embargo, independientemente del formato, ya sea código de bytes o binario, es inevitable que existan algunos elementos básicos de la ejecución de la JVM, como la información de clase, la información de campo, la invocación de funciones y el paso de parámetros. Con esta perspectiva, la herramienta de análisis que he desarrollado puede lograr cierto nivel de efectividad de restauración y, con mejoras adicionales, alcanzar una alta precisión de restauración.
Aplicación NativeImageAnalyzer
Visita https://github.com/vlinx-io/Analizador de imágenes nativo para descargar NativeImageAnalyzer
Ejecute el siguiente comando para el análisis inverso, actualmente solo analiza la función principal de la clase principal
native-image-analyzer hello
El resultado es el siguiente
java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return
Echemos un vistazo nuevamente al código original.
public static void main(String[] args){
System.out.println("Hello World!");
}
Ahora echemos un vistazo a la definición de System.out.
public static final PrintStream out = null;
Puede ver que la variable "out" de la clase System es una variable de tipo PrintStream y es estática. Durante la compilación, NativeImage compila directamente una instancia de esta clase en una región llamada Heap, y el código binario recupera directamente esta instancia de la región Heap para su invocación. Analicemos el código original después de la restauración.
java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return
Estos java.io.PrintStream@0x554fe8 Se acaba de leer desde el área del montón. java.io.PrintStream La variable de instancia se encuentra en la dirección de memoria 0x554fe8.
我们再来看下java.io.PrintStream.writeln 函数的定义
private void writeln(String s) {
......
}
Aquí podemos ver que hay un argumento String en el writelin función, pero en el código restaurado, ¿por qué se pasan tres argumentos? Primero writeln es un método de miembro de clase que oculta solo uno this, La variable apunta al llamador, que es el primer parámetro que se pasa. java.io.PrintStream@0x554fe8 En cuanto al tercer parámetro, rcx, esto se debe a que, durante el análisis del código ensamblador, se determinó que esta función se llamaba con tres parámetros. Sin embargo, al examinar la definición, sabemos que, en realidad, esta función solo llama a dos parámetros. Este es un aspecto que necesita mejoras en esta herramienta en el futuro.
Un programa más complejo
Ahora analizaremos un programa más complejo, como es el cálculo de una secuencia de Fibonacci, con el siguiente código
class Fibonacci {
public static void main(String[] args) {
int count = Integer.parseInt(args[0]);
int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
}
}
Compilar y ejecutar
javac Fibonacci.java
native-image -cp . Fibonacci
./fibonacci 10
0 1 1 2 3 5 8 13 21 34
El código obtenido después de la restauración utilizando NativeImageAnalyzer es el siguiente
rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0
ret_1 = java.lang.StringConcatHelper.mix(1, 1)
ret_2 = java.lang.StringConcatHelper.mix(ret_1, 0)
sp_0x20 = java.io.PrintStream@0x554fe8
sp_0x18 = Class{[B}_1
tlab_0 = Class{[B}_1
tlab_0.length = ret_2<<ret_2>>32
sp_0x10 = tlab_0
ret_28 = ?java.lang.StringConcatHelper.prepend(tlab_0, " ", ret_2)
ret_29 = java.lang.StringConcatHelper.prepend(ret_28, sp_0x10, 0)
ret_30 = ?java.lang.StringConcatHelper.newString(sp_0x10, ret_29)
java.io.PrintStream.write(sp_0x20, ret_30)
if(sp_0x44>=3)
{
ret_7 = java.lang.StringConcatHelper.mix(1, 1)
tlab_1 = sp_0x18
tlab_1.length = ret_7<<ret_7>>32
sp_0x10 = " "
sp_0x8 = tlab_1
ret_22 = ?java.lang.StringConcatHelper.prepend(tlab_1, " ", ret_7)
ret_23 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_22)
rsi = ret_23
java.io.PrintStream.write(sp_0x20, ret_23)
rdi = 1
rdx = 1
rcx = 3
while(true)
{
if(sp_0x44<=rcx)
{
break
}
else
{
sp_0x34 = rcx
rdi = rdi+rdx
r9 = rdi
sp_0x30 = rdx
sp_0x2c = r9
ret_11 = java.lang.StringConcatHelper.mix(1, r9)
tlab_2 = sp_0x18
tlab_2.length = ret_11<<ret_11>>32
sp_0x8 = tlab_2
ret_17 = ?java.lang.StringConcatHelper.prepend(tlab_2, sp_0x10, ret_11)
ret_18 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_17)
rsi = ret_18
java.io.PrintStream.write(sp_0x20, ret_18)
rcx = sp_0x34+1
rdi = sp_0x30
rdx = sp_0x2c
}
}
}
java.io.PrintStream.newLine(sp_0x20, rsi)
return
Compare el código restaurado con el código original.
rdi = rdi[0]
ret_0 = java.lang.Integer.parseInt(rdi, 10)
sp_0x44 = ret_0
El correspondiente es
int count = Integer.parseInt(args[0]);
rdi es el registro utilizado para pasar el primer argumento de una función, si es Windows, entonces rdi = rdi[0], que corresponde a args[0]. Luego, llame a java.lang.Integer.parseInt para analizar y obtener un valor int, luego asigne el valor de retorno a una variable de pila sp_0x44.
int n1 = 0, n2 = 1, n3;
System.out.print(n1 + " " + n2);
Correspondiente a.
ret_1 = java.lang.StringConcatHelper.mix(1, 1)
ret_2 = java.lang.StringConcatHelper.mix(ret_1, 0)
sp_0x20 = java.io.PrintStream@0x554fe8
sp_0x18 = Class{[B}_1
tlab_0 = Class{[B}_1
tlab_0.length = ret_2<<ret_2>>32
sp_0x10 = tlab_0
ret_28 = ?java.lang.StringConcatHelper.prepend(tlab_0, " ", ret_2)
ret_29 = java.lang.StringConcatHelper.prepend(ret_28, sp_0x10, 0)
ret_30 = ?java.lang.StringConcatHelper.newString(sp_0x10, ret_29)
java.io.PrintStream.write(sp_0x20, ret_30)
En nuestro código Java, la simple operación de concatenación de cadenas se convierte en realidad en tres llamadas de función: StringConcatHelper.mix, StringConcatHelper.prepend, y StringConcatHelper.newString. Entre ellos, StringConcatHelper.mix calcula la longitud de la cadena concatenada, StringConcatHelper.prepend combina la matriz byte[] que lleva el contenido de cadena específico junto, y StringConcatHelper.newString genera un nuevo objeto String a partir de la matriz byte[].
En el código anterior, vemos dos tipos de nombres de variables, sp_0x18 y tlab_0Variables que empiezan por sp_ indican variables asignadas en la pila, mientras que las variables que comienzan con tlab_ Indican las variables asignadas a los búferes de asignación local de subprocesos. Esto es solo una explicación del origen de estos dos tipos de nombres de variable. En el código restaurado, no hay distinción entre estos dos tipos de variables. Para obtener información sobre los búferes de asignación local de subprocesos, búsquela usted mismo.
Aquí asignamos tlab_0 a Class{[B}_1. El significado de Class{[B}_1 Es una instancia del tipo byte[]. [B representa el descriptor de Java para byte[], _1 indica que es la primera variable de este tipo. Si se definen variables posteriores para el tipo correspondiente, el índice aumentará en consecuencia, como Class{[B]}_2, Class{[B]}_3, etc. La misma representación se aplica a otros tipos, como Class{java.lang.String}_1, Class{java.util.HashMap}_2, etcétera.
La lógica del código anterior explica simplemente la creación de una instancia de la matriz byte[] y su asignación a tlab0. La longitud de la matriz es ret_2 << ret_2 >> 32La razón por la que la longitud de la matriz es ret_2 << ret_2 >> 32 Esto se debe a que, al calcular la longitud de una cadena, se debe convertir la longitud del array según la codificación. Puedes consultar el código relevante en java.lang.String.java. A continuación, la función prepend combina 0, 1 y espacios en tlab0, genera un nuevo objeto String ret_30 a partir de tlab_0 y lo pasa a la función java.io.PrintStream.write para su impresión. De hecho, los parámetros restaurados por la función prepend no son muy precisos y sus posiciones también son incorrectas. Este aspecto requiere mejoras posteriores.
Tras convertir las dos líneas de código Java en lógica de ejecución real, el proceso sigue siendo bastante complejo. En el futuro, se puede simplificar mediante el análisis y la integración a partir del código restaurado.
Sigue caminando hacia adelante
for (int i = 2; i < count; ++i){
n3 = n1 + n2;
System.out.print(" " + n3);
n1 = n2;
n2 = n3;
}
System.out.println();
El correspondiente es
if(sp_0x44>=3)
{
ret_7 = java.lang.StringConcatHelper.mix(1, 1)
tlab_1 = sp_0x18
tlab_1.length = ret_7<<ret_7>>32
sp_0x10 = " "
sp_0x8 = tlab_1
ret_22 = ?java.lang.StringConcatHelper.prepend(tlab_1, " ", ret_7)
ret_23 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_22)
rsi = ret_23
java.io.PrintStream.write(sp_0x20, ret_23)
rdi = 1
rdx = 1
rcx = 3
while(true)
{
if(sp_0x44<=rcx)
{
break
}
else
{
sp_0x34 = rcx
rdi = rdi+rdx
r9 = rdi
sp_0x30 = rdx
sp_0x2c = r9
ret_11 = java.lang.StringConcatHelper.mix(1, r9)
tlab_2 = sp_0x18
tlab_2.length = ret_11<<ret_11>>32
sp_0x8 = tlab_2
ret_17 = ?java.lang.StringConcatHelper.prepend(tlab_2, sp_0x10, ret_11)
ret_18 = ?java.lang.StringConcatHelper.newString(sp_0x8, ret_17)
rsi = ret_18
java.io.PrintStream.write(sp_0x20, ret_18)
rcx = sp_0x34+1
rdi = sp_0x30
rdx = sp_0x2c
}
}
}
java.io.PrintStream.newLine(sp_0x20, rsi)
return
sp_0x44 Es el parámetro que ingresamos al programa, que es count. El bucle for en el código Java solo se ejecutará si count >= 3. En este caso, el bucle for se transforma de nuevo en un bucle while, con la misma semántica. Fuera del bucle while, el programa ejecuta la lógica donde count = 3. Si count <= 3, el programa completa la ejecución y no vuelve a entrar en el bucle while. Esto también podría ser una optimización realizada por GraalVM durante la compilación.
Veamos nuevamente la condición de salida del bucle.
if(sp_0x44<=rcx)
{
break
}
Esto corresponde a
i < count
Al mismo tiempo, rcx también se acumula durante cada proceso de iteración.
sp_0x34 = rcx
rcx = sp_0x34+1
corresponde a
++i
A continuación, veamos cómo se refleja la lógica de sumar números en el cuerpo del bucle en el código restaurado. El código original es el siguiente:
for(......){
......
n3 = n1 + n2;
n1 = n2;
n2 = n3;
......
}
El código después de la restauración es
while(true){
......
rdi = rdi+rdx -> n3 = n1 + n2
r9 = rdi -> r9 = n3
sp_0x30 = rdx -> sp_0x30 = n2
sp_0x2c = r9 -> sp_0x2c = n3
rdi = sp_0x30 -> n1 = sp_0x30 = n2
rdx = sp_0x2c -> n2 = sp_0x2c = n3
......
}
El resto del código del cuerpo del bucle realiza la concatenación de cadenas y las operaciones de salida como antes. El código restaurado refleja básicamente la lógica de ejecución del código original.
Se necesitan más mejoras
Actualmente, esta herramienta puede restaurar parcialmente el flujo de control del programa, lograr cierto nivel de análisis del flujo de datos y restaurar nombres de funciones. Para ser una herramienta completa y utilizable, aún necesita lograr lo siguiente:
Nombre de función más preciso, parámetros de función y restauración del valor de retorno de función
Información precisa de objetos y restauración de campo
Expresión más precisa e inferencia de tipo de objeto
Integración y simplificación de declaraciones
Reflexiones sobre la protección binaria
El propósito de este proyecto es explorar la viabilidad de la ingeniería inversa de NativeImage. Basándonos en los logros actuales, es factible aplicar ingeniería inversa a NativeImage, lo que también plantea mayores desafíos para la protección del código. Muchos desarrolladores creen que compilar software en binarios puede garantizar la seguridad, descuidando la protección del código binario. Para el software escrito en C/C++, muchas herramientas como IDA ya ofrecen excelentes resultados de ingeniería inversa, a veces incluso exponiendo más información que los programas Java. Incluso he visto software distribuido en formato binario sin eliminar la información de los símbolos de los nombres de las funciones, lo que equivale a ejecutarse sin código.
Todo código se compone de lógica. Mientras contenga lógica, es posible restaurarla por medios inversos. La única diferencia radica en la dificultad de la restauración. La protección del código busca maximizar la dificultad de dicha restauración.