DEV Community

NULLX
NULLX

Posted on • Edited on

Ingeniería inversa EXE

Library Jailbreak

Durante el curso de varias asignaturas en la universidad hemos tenido ciertas restricciones. La mayoría de las veces, por la limitación de licencias de ciertos programas de ingeniería.

En este caso vamos a hablar sobre unas librerías que contenían funciones para la realización de diversos ejercicios. Estas librerías solo funcionaban en los ordenadores de un aula de la universidad y eran el medio para poder practicar y prepararse los exámenes. A título individual me causa curiosidad y me produce un reto el proceso de averiguar qué ocurre dentro de dicha librería para que funcione en un sitio, pero si lo intentas ejecutar en otro contexto deje de funcionar.

Impulsado por esta emoción vamos a investigar qué ocurre con estas librerías.

Vista general

En un ejercicio necesitaríamos por ejemplo las siguientes dos funciones:



int leeCelulaFotoelectrica(void);
void leeCodigoBarras(void *datos);


Enter fullscreen mode Exit fullscreen mode

De tal forma que ambas funciones serán llamadas a lo largo de la solución propuesta al enunciado concreto de un ejercicio.

Si llamamos a estas funciones en el aula, estas se comportan tal y como se describen en el enunciado. Sin embargo, si las ejecutamos en un ordenador ajeno, estas no lo hacen de forma coherente.

Se hace una prueba para detectar si existe algún tipo de comunicación a nivel de red desde el equipo origen, pero no es así, por lo que directamente vamos a analizar el código.

Análisis del código fuente

En este caso el fichero a analizar es un ".lib", una librería estática de Windows para su uso en C/C++.

El programa seleccionado para realizar ingeniería inversa ha sido Ghidra, un kit de herramientas de ingeniería inversa desarrollado y mantenido por la NSA (National Security Agency de Estados Unidos).

Es importante entender que cuando un programa escrito en C es compilado para producir un archivo binario, el proceso de ingeniería inversa rara vez logra recrear el código fuente original de manera idéntica. En su lugar, el objetivo es generar un código equivalente que funcione de manera similar. Esto se debe a las transformaciones que ocurren durante la compilación, donde el código fuente es convertido en instrucciones que la máquina puede ejecutar directamente.

Al intentar analizar directamente una librería estática, como en este caso, nos encontramos con desafíos significativos. La principal dificultad es identificar la sección específica del código que contiene las condiciones que limitan el uso de la librería fuera del aula. La complejidad aumenta por el hecho de que la librería contiene una gran cantidad de componentes adicionales, no relacionados directamente con las funciones de interés. También el proceso de compilación introduce numerosos símbolos y referencias que pueden dificultar la búsqueda del fragmento de código relevante.

Una forma sencilla pero efectiva e ingeniosa para llegar al código a analizar es embebiendo dicha librería dentro de un ejecutable. De tal forma que dicho ejecutable será el producto de este código:



#include <stdio.h>

int leeCelulaFotoelectrica(void);
void leeCodigoBarras(void *datos);

int main() {
    int r = leeCelulaFotoelectrica();
    printf("num = %d\n", r);

    return 0;
}


Enter fullscreen mode Exit fullscreen mode

Nota: A menudo los compiladores realizan optimizaciones complejas, es por ello que utilizamos la variable r de retorno de forma que se garantiza que el compilador no descarte la llamada a leeCelulaFotoelectrica() por considerarla innecesaria, manteniendo así la lógica deseada del programa.

Ahora cuando analicemos ejecutable derivado de este programa, solo tendremos que buscar el main, pues es ahí donde lo primero que se hace es llamar a la función que queremos inspeccionar.

Para encontrar el entrypoint del programa debemos empezar por encontrar la función entry, que es la función de entrada que define el compilador.

Interfaz de usuario gráfica, Aplicación Descripción generada<br>
automáticamente

Esta imagen nos muestra el código ensamblador de la función entry. Esta realiza una operación MOV, SUB y luego hace una llamada a una función __scrt_common_main. Este ejecutable es un binario de debug, es decir que contiene símbolos y funciones de ayuda que Visual Studio inyecta para manejar el flujo del programa. Es por eso que de aquí en adelante vamos a ver diferentes fragmentos de código que no son de nuestro programa.

Interfaz de usuario gráfica, Texto, Aplicación Descripción generada<br>
automáticamente

Se puede ver que __scrt_common_main llama a su vez a __scrt_common_main_seh.

Interfaz de usuario gráfica, Texto, Aplicación, Correo electrónico<br>
Descripción generada automáticamente

Parece que nos vamos acercando: podemos ver la llamada a
invoke_main().

Interfaz de usuario gráfica, Texto, Aplicación Descripción generada<br>
automáticamente

Podemos ver la recolección y elaboración de los parámetros típicos de un programa en C/C++: argc y argv.



int main(int argc, char *argv[]);


Enter fullscreen mode Exit fullscreen mode

Interfaz de usuario gráfica, Aplicación Descripción generada        |<br>
| automáticamente



 #include <stdio.h>                                                    

 int main() {                                                          
     int r = leeCelulaFotoelectrica();                                 
     printf("num = %d\n", r);                                          

     return 0;                                                         
}


Enter fullscreen mode Exit fullscreen mode

En este caso la función leeCelulaFotoelectrica se ha renombrado a FUN_1400010f0, y printf a FUN_140001070, al igual que la variable local r, que ahora es uVar1.

Ahora sabemos que dentro de la función FUN_1400010f0 está la guinda del pastel.

Aquí está la lógica de la función leeCelulaFotoelectrica. Un análisis rápido parece indicar que hay una serie de condiciones iniciales que, si no se cumplen, el flujo del programa salta (instrucción goto) a la etiqueta LAB_1400012f3. Ésta está declarada así:

Y como podemos ver, no hace nada en especial (la línea 75 forma parte de las funciones de ayuda mencionadas anteriormente), simplemente retorna de la función.

Analizando la última imagen más a fondo, debemos evitar la ejecución de las instrucciones goto de las líneas 29 y 38. Para ello tenemos que, de alguna forma, evitar que las condiciones que lo permiten lo hagan.

Iremos condición a condición analizando qué cambiar para que no se cumplan y poder llegar a la línea verde discontinua. A partir de dicha línea comienza la lógica real de la función y es la que nos interesa que se ejecute.

Primera condición



GetLocalTime(&local_250);
if ((0x7e7 < local_250.wYear) ||
    (((0x7e6 < local_250.wYear && (9 < local_250.wMonth)) ||
    (DVar2 = GetEnvironmentVariableW(L"JDIR",local_118,0x100), DVar2 == 0))))
goto LAB_1400012f3;


Enter fullscreen mode Exit fullscreen mode

Teniendo en cuenta que la función GetLocalTime nos da información sobre
la fecha local del sistema, se puede traducir al siguiente pseudo
código:

Si el año es mayor a 2023
   o (el año es mayor a 2022 y el mes es mayor a 9)
   o (la variable de entorno JDIR no está definida)
entonces
   Ir a LAB_1400012f3
Enter fullscreen mode Exit fullscreen mode

¿Qué hacer para que no lleguemos a cumplir esas condiciones? Al ser una sentencia de OR no debe de cumplirse ninguna de las condiciones; entonces:

  1. Establecer la fecha del ordenador con fecha anterior a 2023, por
    ejemplo, en 2021.

  2. Crear la variable de entorno JDIR.

Aunque si nos damos cuenta, con que se cumpla la segunda de las propuestas, ya valdría (dependiendo del año en que se lea).

Segunda condición



hModule = GetModuleHandleW(L"ntdll");
pFVar4 = GetProcAddress(hModule,"RtlGetVersion");
if (pFVar4 != (FARPROC)0x0) {
    local_238 = 0x11c;
    (*pFVar4)(&local_238);
    if (0x2540c2e65 <
        (longlong)
        (((ulonglong)local_234 * 1000 + (ulonglong)local_230) * 1000000 + (ulonglong)local_22c))
    goto LAB_1400012f3;
}


Enter fullscreen mode Exit fullscreen mode

Según la documentación oficial, la función GetProcAddress devuelve la dirección de la función que pedimos por parámetros (RtlGetVersion). Si la dirección pedida no se encuentra devuelve nulo47.

Para evitar que se cumpla el primer if, que evalúa la dirección descrita, es posible inducir un error en la búsqueda de la función deseada. Esto se puede conseguir con la modificación de un único carácter en la cadena literal L"RtlGetVersion", lo que resultaría en un fallo en la búsqueda y, por tanto, la devolución de NULL. El literal modificado sería L"RtlGetVersien".

Esta solución se ha escogido dado que la modificación de una cadena dentro de un binario en comparación con otras más complejas (inyección de código, desensamblado y modificación...) es una tarea relativamente sencilla y fácil de automatizar.

Ya hemos solucionado el problema de forma conceptual. Ahora tocaría crear un programa al que pudiéramos pasarle la librería y fuera capaz de devolvérnosla libre de restricciones.

Para poder visualizar de mejor forma cómo es el flujo del código véase la siguiente imagen.

Solución

En los binarios compilados, normalmente existen secciones dedicadas para alojar constantes y variables, entre ellas: .data, .rsrc, .text... pero nosotros no vamos a hacer distinción. Vamos a escanear el binario completo en búsqueda de una secuencia de bytes concreta (que representen RtlGetVersion), y de encontrarla vamos a reemplazar uno de esos bytes por otro.

En la primera condición que hemos resuelto antes, se ha propuesto crear la variable de entorno JDIR, pues necesita encontrarla para el correcto funcionamiento. En vez de hacer al usuario cambiar la variable de entorno, ¿por qué no cambiar también el literal JDIR por otro que exista en todos los ordenadores?

Procedimiento:

  1. Búsqueda de los literales RtlGetVersion y cambiarlos por
    RtlGetVersien.

  2. Búsqueda de los literales JDIR y cambiarlos por PATH.

Este ejecutable viene en el formato PE (Portable Executable en inglés) y es común encontrarse literales string codificadas en UTF-16 debido a la naturaleza del ecosistema de Windows. Debido principalmente a que UTF-16 permite una representación estándar de caracteres para casi todos los sistemas de escritura, hace más fácil la internacionalización de aplicaciones.

Programación

Aunque la solución se pueda programar en cualquier lenguaje de propósito general, con el objetivo de seguir explorando las tecnologías web se propone hacerlo en TypeScript. Aunque dada la sencillez del código es fácilmente extrapolable a cualquier lenguaje.



function findMatches(buffer: Buffer, match: Buffer) {
    const matches = [];
    let matchIndex = 0;

    while (true) {
        const index = buffer.indexOf(match, matchIndex);
        if (index === -1) break;

        matches.push(index);
        matchIndex = index + 1;
    }

    return matches;
}

function replace(origBuff: Buffer, replBuff: Buffer, startIdx: number) {
    for (let i = 0; i < replBuff.length; i++) {
        origBuff[startIdx + i] = replBuff[i];
    }

    return origBuff;
}


Enter fullscreen mode Exit fullscreen mode

Con la ayuda estas funciones, realizaremos el reemplazo de las cadenas de texto mencionadas previamente.



let matches = findMatches(libBuffer, Buffer.from("RtlGetVersion"));
for (const matchIndex of matches) {
    replace(libBuffer, Buffer.from("RtlGetVersien"), matchIndex);
}

matches = findMatches(libBuffer, Buffer.from("J\0D\0I\0R\0"));
for (const matchIndex of matches) {
    replace(libBuffer, Buffer.from("P\0A\0T\0H\0"), matchIndex);
}


Enter fullscreen mode Exit fullscreen mode

Top comments (0)