Ingeniería inversa de NativeImage

Restaurar y proteger el código Java es un tema antiguo y que se debate con frecuencia. Debido al formato de código de bytes utilizado para almacenar archivos de clase Java, que contiene mucha metainformación, se puede restaurar fácilmente a su código original. Para proteger el código Java, la industria ha adoptado muchos métodos, como ofuscación, cifrado de código de bytes, protección JNI, etc. Sin embargo, independientemente del método utilizado, todavía existen formas y medios de descifrarlo.

La compilación binaria siempre se ha considerado un método relativamente eficaz de protección de código. La compilación binaria de Java se admite como tecnología AOT (Ahead of Time), lo que significa precompilación.

Sin embargo, debido a la naturaleza dinámica del lenguaje Java, la compilación binaria debe manejar problemas como la reflexión, el proxy dinámico, la carga JNI, etc., lo que plantea muchas dificultades. Por lo tanto, durante mucho tiempo ha faltado una herramienta madura, confiable y adaptable para la compilación AOT en Java que pueda aplicarse ampliamente en entornos de producción. (Solía haber una herramienta llamada Excelsior JET, pero parece haber sido descontinuada).

En mayo de 2019, Oracle lanzó GraalVM 19.0, una máquina virtual compatible con varios idiomas, que fue su primera versión lista para producción. GraalVM proporciona una herramienta NativeImage que puede lograr la compilación AOT de programas Java. Después de varios años de desarrollo, NativeImage ahora está muy maduro y SpringBoot 3.0 puede usarlo para compilar todo el proyecto SpringBoot en un archivo ejecutable. El archivo compilado tiene una velocidad de inicio rápida, poco uso de memoria y un rendimiento excelente.

Entonces, ¿los programas de Java que han entrado en la era de la compilación binaria siguen siendo tan fácilmente reversibles como lo eran en la era del bytecode? ¿Cuáles son las características de los archivos binarios compilados por NativeImage, y es suficiente la intensidad de la compilación binaria para proteger el código importante?

Para explorar estos problemas, recientemente desarrollamos una herramienta de análisis NativeImage, que ha logrado cierto grado de efecto inverso.

Proyecto

https://github.com/vlinx-io/NativeImageAnalyzer

Generando NativeImage

Primero, necesitamos generar una NativeImage. NativeImage proviene de GraalVM. Para descargar GraalVM, vaya ahttps://www.graalvm.org/ y 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 native-image

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!");
	}
}

Compilar y ejecutar el programa Java anterior:

javac Hello.java
java -cp . Hello

Obtendrás la siguiente salida:

Hello World!

Preparación para el entorno de compilación

Si es usuario de Windows, primero debe instalar Visual Studio. Si es usuario de Linux o macOS, debe instalar herramientas como gcc y clang de antemano.

Para los usuarios de Windows, deben configurar la variable de entorno para Visual Studio antes de ejecutar el comando de imagen nativa. Puede configurarlo usando 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 en consecuencia.

Compilar con native-image

Ahora use el comando de imagen nativa para compilar el programa Java anterior en un archivo binario. El formato del comando de imagen nativa es el mismo que el formato del comando java, y también tiene -cp, -jar. Estos parámetros, cómo usar el comando java para ejecutar el programa, usan el mismo método para la compilación binaria, solo reemplace el comando de java con imagen nativa. Ejecute el comando de la siguiente manera

native-image -cp . Hello

Después de un período de compilación, puede consumir más CPU y memoria. Puede obtener un archivo binario compilado y el nombre del archivo de salida es, por defecto, la minúscula del nombre de la clase principal, que en este caso es "hola". Si está en Windows, será "hello.exe". Utilice el comando "archivo" para verificar el tipo de este archivo, puede ver que de hecho es 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 uso anterior.java -cp. HolaEl 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. image-20230218194013099

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

image-20230218194126014

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

image-20230218194235234

Se pueden ver algunas llamadas a 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 al interior de la llamada a la función. IDA provoca un error en el análisis.

image-20230218194449494

Lógica de descompilación de NativeImage

Debido a que la compilación de NativeImage se basa en la compilación de JVM, también puede entenderse como incluir código binario con una capa de protección de VM. Por lo tanto, herramientas como IDA no pueden aplicar ingeniería inversa sin la información correspondiente y medidas de procesamiento específicas.

Sin embargo, independientemente del formato, ya sea código de bytes o formato binario, es probable que existan algunos elementos básicos de la ejecución de JVM, como información de clase, información de campo, invocación de funciones y paso de parámetros. Con base en esta mentalidad, la herramienta de análisis que he desarrollado puede lograr un cierto nivel de efecto de restauración y, con mejoras adicionales, tiene la capacidad de lograr un alto nivel de precisión de restauración.

Analizar con NativeImageAnalyzer

Visitahttps://github.com/vlinx-io/NativeImageAnalyzerpara descargar NativeImageAnalyzer

Ejecute el siguiente comando para el análisis inverso, actualmente solo analizando la función principal de la clase principal

native-image-analyzer hello

La salida es la siguiente

java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return

Echemos un vistazo al código original nuevamente.

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 'salida' de la clase Sistema es una variable de tipo PrintStream y es una variable 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 del Heap para invocarla. Echemos un vistazo al código original después de la restauración.

java.io.PrintStream.writeln(java.io.PrintStream@0x554fe8, "Hello World!", rcx)
return

Estosjava.io.PrintStream@0x554fe8Simplemente se lee desde el área de la memoriajava.io.PrintStream La variable de instancia se encuentra en la dirección de memoria 0x554fe8.

Vamos a echar un vistazojava.io.PrintStream.writelnDefinición de función

private void writeln(String s) {
		......        
}

Aquí podemos ver que hay un argumento de tipo String en elwritelinfunción, pero en el código restaurado, ¿por qué se pasan tres argumentos? Primerowritelnes un método de miembro de clase que oculta solo unothisLa variable apunta al llamador, que es el primer parámetro pasado.java.io.PrintStream@0x554fe8 En cuanto al tercer parámetro rcx, es porque durante el proceso de análisis del código ensamblador se determinó que esta función fue llamada con tres parámetros. Sin embargo, al examinar la definición, sabemos que esta función en realidad solo llama a dos parámetros. Esta también es un área que necesita mejoras para esta herramienta en el futuro.

Un programa más complejo

Ahora analizaremos un programa más complejo, como calcular 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 int valor, 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 operación simple de concatenación de cadenas se convierte en realidad en tres llamadas de función:StringConcatHelper.mix,StringConcatHelper.prependyStringConcatHelper.newString. Entre ellos,StringConcatHelper.mixcalcula la longitud de la cadena concatenadaStringConcatHelper.prependcombina la matriz de bytes que lleva el contenido de cadena específico juntos, yStringConcatHelper.newString genera un nuevo objeto String a partir de la matriz de bytes[].

En el código anterior, vemos dos tipos de nombres de variables.sp_0x18ytlab_0. variables que comienzan consp_indican variables asignadas en la pila, mientras que las variables que comienzan contlab_ indicar variables asignadas en los buffers de asignación local de subprocesos. Esta es sólo una explicación del origen de estos dos tipos de nombres de variables. En el código restaurado, no hay distinción entre estos dos tipos de variables. Para obtener información relacionada con los buffers de asignación local de subprocesos, búsquela usted mismo.

Aquí asignamostlab_0 aClass{[B}_1. El significado deClass{[B}_1 es una instancia del tipo byte[]. [B representa el descriptor de Java para el byte [], _1 indica que es la primera variable de este tipo. Si hay variables posteriores definidas para el tipo correspondiente, el índice aumentará en consecuencia, comoClass{[B]}_2,Class{[B]}_3, etc. La misma representación se aplica a otros tipos, como por ejemploClass{java.lang.String}_1,Class{java.util.HashMap}_2, etcétera.

La lógica del código anterior explica simplemente crear una instancia de matriz de bytes[] y asignarla a tlab0. La longitud de la matriz esret_2 << ret_2 >> 32. La razón por la cual la longitud de la matriz esret_2 << ret_2 >> 32 Esto se debe a que al calcular la longitud de una cadena, es necesario convertir la longitud de la matriz según la codificación. Puede consultar el código relevante en java.lang.String.java. A continuación, la función anteponer combina 0, 1 y espacios en tlab0, luego genera un nuevo objeto String ret_30 a partir de tlab_0 y lo pasa a la función java.io.PrintStream.write para imprimir la salida. En realidad, aquí los parámetros restaurados por la función anteponer no son muy precisos y sus posiciones también son incorrectas. Esta es un área que necesitará mejoras adicionales más adelante.

Después de convertir las dos líneas de código Java en lógica de ejecución real, todavía es bastante complejo. En el futuro, se puede simplificar analizando e integrando sobre la base del código restaurado actualmente.

Continúa caminando hacia adelante

for (int i = 2; i &lt; count; ++i){
  	n3 = n1 + n2;
  	System.out.print(" " + n3);
  	n1 = n2;
  	n2 = n3;
}
System.out.println();

El correspondiente es

if(sp_0x44&gt;=3)
{
	ret_7 = java.lang.StringConcatHelper.mix(1, 1)
	tlab_1 = sp_0x18
	tlab_1.length = ret_7&lt;&lt;ret_7&gt;&gt;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&lt;=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&lt;&lt;ret_11&gt;&gt;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 contar. El bucle for en el código Java solo se ejecutará si el recuento es >= 3. Aquí, el bucle for se transforma nuevamente en un bucle while, que esencialmente tiene la misma semántica. Fuera del ciclo while, el programa ejecuta la lógica donde cuenta=3. Si cuenta <= 3, el programa completa la ejecución y no volverá a ingresar al ciclo while. Esto también puede 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, echemos un vistazo a cómo la lógica de sumar números en el cuerpo del bucle se refleja 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 otro código en el cuerpo del bucle realiza operaciones de salida y concatenación de cadenas como antes. El código restaurado básicamente refleja la lógica de ejecución del código original.

Se necesitan mejoras adicionales

Actualmente, esta herramienta es capaz de restaurar parcialmente el flujo de control del programa, lograr cierto nivel de análisis del flujo de datos y restauración del nombre de la función. Para convertirse en una herramienta completa y utilizable, todavía necesita lograr lo siguiente:

Restauración de nombre de función más preciso, parámetros de función y valor de retorno de función

Información precisa del objeto y restauración del campo

Expresión más precisa e inferencia de tipo de objeto

Integración y Simplificación de Declaraciones

Pensamientos sobre la protección binaria

El propósito de este proyecto es explorar la viabilidad de la ingeniería inversa de NativeImage. Según los logros actuales, es factible realizar ingeniería inversa en 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 tienen excelentes efectos de ingeniería inversa, a veces incluso exponen más información que los programas Java. Incluso he visto algún software distribuido en forma binaria sin eliminar la información de los símbolos de los nombres de las funciones, lo que equivale a ejecutarlo desnudo.

Cualquier código está compuesto de lógica. Mientras contenga lógica, es posible restaurar su lógica por medios inversos. La única diferencia radica en la dificultad de la restauración. La protección del código es maximizar la dificultad de dicha restauración.