Cargar DLL desde memoria en Windows

Cargar DLL desde memoria en Windows



Descargas: cargar-dll-desde-memoria.zip.

La vasta colección de funciones que constituyen la API de Windows nos provee únicamente una función para cargar librerías de vínculos dinámicos. LoadLibrary() toma como argumento el nombre o la ruta de un archivo DLL e inicializa su código en el espacio de memoria del proceso que la ha invocado. Luego, podemos acceder a las funciones contenidas dentro de ella vía GetProcAddress(). Finalmente la función FreeLibrary() remueve los recursos reservados y la descarga de la memoria.

Pero parece no haber un método estándar para cargar una DLL si no está contenida en un archivo. Si tuviésemos el código binario de una librería en una variable, ¿cómo podríamos inicializarla y acceder a las funciones dentro de ella? Joachim Bauch desarrolló un código en C llamado MemoryModule que permite justamente esto. Provee las funciones MemoryLoadLibrary(), MemoryGetProcAddress() y MemoryFreeLibrary() para cargar y trabajar con librerías de vínculos dinámicos cuyo contenido esté almacenado en un buffer, lo que resulta ideal para programas distribuidos como “portables” que no requieran instalación. Se sabe, por ejemplo, que py2exe utiliza MemoryModule para cargar las dependencias de los archivos ejecutables en tiempo de ejecución. Si te interesa saber cómo operan estas funciones internamente, el autor escribió un artículo muy detallado sobre el tema: Loading a DLL from memory.

Ya que el código está escrito íntegramente en C y hace uso exclusivo de las funciones de la API de Windows, que alguien desarrollara una extensión para acceder a él desde Python no tardaría demasiado. Efectivamente, el módulo es pymemorymodule. Corre en las versiones 2 y 3 del lenguaje, y se instala sencillamente vía:

pip install pymemorymodule

Cargar DLL desde memoria

Antes de hacer lo propio, es necesario contar con una DLL de prueba. Aquí consideraremos el siguiente código C.

#include <Windows.h>

__declspec(dllexport) void WINAPI ShowMessage(const wchar_t *message)
{
	MessageBoxW(0, message, L"DLL", 0);
}

BOOL WINAPI DllMain(HMODULE hModule, DWORD  dwReason, LPVOID lpReserved)
{
	switch (dwReason)
	{
		case DLL_PROCESS_ATTACH:
			MessageBox(0, TEXT("Hola, mundo!"), TEXT("DLL"), 0);
			break;
		case DLL_THREAD_ATTACH:
			break;
		case DLL_THREAD_DETACH:
			break;
		case DLL_PROCESS_DETACH:
			break;
	}

	return TRUE;
}

Esta librería muestra un mensaje en pantalla al momento de su inicialización y exporta una función para desplegar cuadros de diálogo. Puede ser compilada vía Visual C++ o MinGW/mingw-w64. Consideremos el nombre del archivo como MemoryDLL.dll.

Téngase en cuenta que instalaciones de 32-bit de Python solo pueden cargar DLLs de 32-bit, e instalaciones de 64-bit únicamente DLLs de 64-bit. Y hago especial énfasis en esto último, por cuanto MemoryModule no soporta cargar librerías de 32-bit en instalaciones de 64-bit.

Ahora bien, hechas las aclaraciones, comencemos por importar las funciones mencionadas al inicio.

from pymemorymodule import MemoryLoadLibrary, MemoryFreeLibrary
from pymemorymodule import MemoryGetProcAddress

Luego, una vez ubicado MemoryDLL.dll junto a nuestro programa de Python, leemos el archivo y lo inicializamos con la función pertinente.

with open("MemoryDLL.dll", "rb") as f:
    # Cargar DLL desde la memoria.
    handle = MemoryLoadLibrary(f.read())
    # Liberar los recursos.
    MemoryFreeLibrary(handle)

(Por el momento estaremos leyendo el archivo DLL directamente para obtener su código ejecutable, en el último apartado veremos cómo prescindir de él por completo, que resulta ser el sentido último de cargar una librería desde la memoria).

Al ejecutar el código se mostrará un cuádro de diálogo con el mensaje “Hola, mundo!”, pues MemoryLoadLibrary() carga la DLL y ejecuta la función DllMain(). Si se obtiene el error OSError: [WinError 193] %1 no es una aplicación Win32 válida, esto indica precisamente que se está invocando una librería de 32-bit desde un intérprete de 64 o viceversa.

Llamar a funciones exportadas por la DLL

Para invocar una función exportada por la DLL ─ShowMessage() en nuestro caso─, primero debemos conocer el nombre que el compilador le ha otorgado. Los nombres generados por el compilador varían según la arquitectura (x86 o x64), el lenguaje (C o C++) y las convenciones de llamadas (cdecl, stdcall, etc.). Puedes utilizar el programa gratuito DLL Export Viewer para listar los nombres de las funciones exportadas por una DLL.

DLL Export Viewer

En este artículo hemos utilizado Visual C++ para compilar la DLL en ambas arquitecturas y utilizando la convención de llamada stdcall (nótese la macro WINAPI en la definición). Al compilar el código para 32-bit, la función recibe el nombre _ShowMessagea@4 (el número cuatro indica la cantidad de bytes ocupados por sus argumentos), mientras que en 64-bit el nombre de la función no sufre modificaciones. Si se compila usando la convención de llamada por defecto (cdecl), el nombre de la función se mantiene en ambas arquitecturas. Los nombres generados por funciones de C++ dentro de clases siguen una convención un tanto más compleja, que puedes conocer en la documentación de la MSDN.

Una vez conocido el nombre de la función que queremos importar, obtenemos un puntero a ella vía MemoryGetProcAddress().

with open("MemoryDLL.dll", "rb") as f:
    # Cargar DLL desde la memoria.
    handle = MemoryLoadLibrary(f.read())
    
    # Obtener un puntero a la función ShowMessage.
    print(MemoryGetProcAddress(handle, "_ShowMessage@4"))
    
    # Liberar los recursos.
    MemoryFreeLibrary(handle)

El script debería imprimir en pantalla algo similar a c_void_p(268439552). Si en su lugar se lanza OSError: [WinError 127] No se encontró el proceso especificado, asegúrate que hayas ingresado el nombre de la función correctamente.

Nos resta convertir ese puntero a una función que pueda ser llamada desde Python. Para ello debemos tener en claro la convención de llamada utilizada por nuestra función y los tipos de datos empleados por el valor de retorno y los argumentos. En nuestro caso, ShowMessage() no retorna ningún valor, por lo que su equivalente en Python será None. Su argumento es un puntero a una cadena unicode, que podemos representar vía ctypes.c_wchar_p. Y, por último, la convención de llamada utilizada es stdcall (ctypes.WINFUNCTYPE). Por ende primero importamos los recursos necesarios:

from ctypes import cast, WINFUNCTYPE, c_wchar_p

from ctypes import cast, CFUNCTYPE, c_wchar_p

Y luego realizamos la conversión vía la función ctypes.cast():

    show_message = cast(
        MemoryGetProcAddress(handle, "_ShowMessage@4"),
        # El primer argumento indica el tipo del valor de retorno,
        # seguido de los tipos correspondientes a cada uno de los
        # argumentos.
        WINFUNCTYPE(None, c_wchar_p)
    )
    show_message("Hola desde Python!")

    show_message = cast(
        MemoryGetProcAddress(handle, "ShowMessage"),
        # El primer argumento indica el tipo del valor de retorno,
        # seguido de los tipos correspondientes a cada uno de los
        # argumentos.
        WINFUNCTYPE(None, c_wchar_p)
    )
    show_message("Hola desde Python!")

    show_message = cast(
        MemoryGetProcAddress(handle, "ShowMessage"),
        # El primer argumento indica el tipo del valor de retorno,
        # seguido de los tipos correspondientes a cada uno de los
        # argumentos.
        CFUNCTYPE(None, c_wchar_p)
    )
    show_message("Hola desde Python!")

Errores como ValueError: Procedure called with not enough arguments (4 bytes missing) or wrong calling convention indican que se están aplicando WINFUNCTYPE o CFUNCTYPE a sus convenciones de llamadas contrarias.

Almacenar DLL en una variable

Si el sentido de usar MemoryModule es distribuir una aplicación de forma completamente portable, entonces tendremos que incluir el código binario de cada una de las librerías dentro de nuestro código de fuente de Python. Bien podríamos hacer esto manualmente (copiando el contenido con algún editor de texto y pegándolo en el IDE), pero es más rapido y eficiente que un script lo haga por nosotros. Como el código binario de una DLL contiene caracteres no imprimibles en demasía, es necesario primero codificarlo vía base64 y luego generar un archivo de Python que podamos importar desde nuestro programa principal.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from base64 import b64encode


def encode(filename):
    with open(filename, "rb") as f:
        content = f.read()
        return b64encode(content).decode("utf-8")


template = """\
#!/usr/bin/env python
# -*- coding: utf-8 -*-

code = "{}"
"""

with open("memorydll.py", "w", encoding="utf-8") as f:
    f.write(template.format(encode("MemoryDLL.dll")))

En este caso, el script lee el contenido de MemoryDLL.dll y genera un nuevo archivo llamado memorydll.py. Finalmente lo importamos y decodificamos:

from base64 import b64decode
from ctypes import cast, CFUNCTYPE, c_wchar_p

from pymemorymodule import MemoryLoadLibrary, MemoryFreeLibrary
from pymemorymodule import MemoryGetProcAddress

import memorydll

handle = MemoryLoadLibrary(b64decode(memorydll.code))

show_message = cast(
    MemoryGetProcAddress(handle, "ShowMessage"),
    CFUNCTYPE(None, c_wchar_p)
)
show_message("Hola desde Python!")

MemoryFreeLibrary(handle)

De esta forma no será en lo absoluto necesario que MemoryDLL.dll esté presente al correr el archivo ejecutable resultante. Puedes chequear el artículo sobre py2exe, PyInstaller y cx_Freeze para conocer cómo convertir código de fuente Python a un ejecutable nativo de Windows.

El archivo ZIP al comienzo del artículo incluye el código para cargar una DLL desde memoria empleado en este artículo y cuatro librerías compiladas con Visual C++ según arquitectura y convención de llamada.



Deja un comentario