Hubo un tiempo en el que tenía mucho rato libre y nada mejor que hacer que aprender las complejidades de los archivos ejecutables, así que me adentré en la madriguera del conejo hasta que logré crear la aplicación de Windows más pequeña posible usando solo NASM y alrededor de 400 líneas de código.
Fue un buen ejercicio - también me llevó mucho tiempo - que me ayudó a descubrir la estructura de un ejecutable portátil, conocimiento que me resultó útil más tarde, cuando empecé a probar la ingeniería inversa o cuando tuve que crear algunas herramientas de construcción en particular.
Eventualmente, terminé con un fragmento de código muy pequeño y quizás excesivamente documentado que muestra un mensaje de "Hola mundo"; el binario resultante no supera los 2,50 KB. Lo mismo creado con Microsoft Visual Studio (modo Release) tiene 9 KB. Por supuesto, eso se debe a que MSVC agrega sus propias instrucciones de inicio
Como dije, el código está muy comentado; a algunos puede que no les plazca. Esto es para que puedas entender qué hace exactamente cada línea. Es solo un fichero, pero podéis mover las constantes, definiciones y macros dentro de otro archivo que se puede incluir en el código fuente principal.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ;;
;; EJECUTABLE PORTÁTIL EN LENGUAJE ENSAMBLADOR ;;
;; ;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; CONSTANTES ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; Número de bytes en la última página de 512 bytes que cargará DOS, que es el tamaño
; de todo el bloque de DOS, que no puede exceder de 512 bytes
$LAST_PAGE_BYTES equ EXE_HEADER - DOS_HEADER
; El contenido de cada sección comienza en una dirección múltiplo de este valor.
; En sistemas x86 es igual al tamaño de una página de memoria
$SECT_ALIGN equ 4096
; Cada sección de la imagen se alinea con este límite (un tamaño de sector de disco)
$FILE_ALIGN equ 512
; Dirección absoluta preferida donde se cargará la aplicación en la memoria (4 MB)
$PREFERRED_ADDRESS equ 4194304
; Número de secciones dentro de la imagen
$SECTIONS_COUNT equ 4
; Tamaño real de los encabezados
$HEADERS_SIZE equ HEADERS_END - DOS_HEADER
; El tamaño real de la sección de código
$CODE_SIZE equ CODE_END - CODE
; Tamaño real de la sección de datos
$DATA_SIZE equ DATA_END - INIT_DATA
; Tamaño real de las secciones de importados
$IMPORTS_SIZE equ IMPORTS_END - IMPORT_DATA
; Tamaño de la tabla de elementos importados
$IMPORTS_TABLE_SIZE equ IMPORT_DATA_END - IMPORT_DATA_START
; Tamaño de los datos no inicializados en la memoria
$UDATA_SIZE equ 16
;; DEFINICIONES
; Alinea con el límite especificado
%define Round(Number, Boundary) (Number + Boundary - 1)/Boundary * Boundary
; Divide el inicio de la sección por la alineación
%define Divide(BaseAddress) (BaseAddress - CODE)/$FILE_ALIGN
; Calcula la dirección virtual (RVA) relativa al principio de las secciones
%define RVA(BaseAddress) Divide(BaseAddress) * $SECT_ALIGN + $SECT_ALIGN
; Obtener la RVA de la dirección base especificada
%define RVA(Adress, BaseAddress) RVA(BaseAddress) + (Adress - BaseAddress)
; Tamaño de la imagen en la memoria (todo alineado a $SECT_ALIGN)
%define ImageSize RVA(IMAGE_END) + Round($UDATA_SIZE, $SECT_ALIGN)
;; MACROS
; Agrega el valor en el primer argumento tantas veces como especifique el segundo
%macro Fill 2
times %1 db %2
%endmacro
;; ENCABEZADOS EJECUTABLES
; Cualquier archivo PE válido comenzará con el encabezado DOS, por compatibilidad con versiones anteriores
DOS_HEADER:
; Al principio añadiremos la firma MZ (Mark Zbikowski) para mantener la
; compatibilidad con MS-D0S (16 bytes). Si la aplicación se ejecuta en DOS,
; mostrará un mensaje diciendo que la aplicación solo es compatible con Windows
.MZSignature db "MZ"
; El número de bytes en la última página de 512 bytes que cargará DOS. La última
; página no suele contener exactamente 512 bytes, por lo que especificaremos el
; tamaño exacto aquí, que es el tamaño del encabezado de DOS más la parte de
; código que se ejecutará en DOS para mostrar el mensaje de incompatibilidad
.LastPageBytes dw $LAST_PAGE_BYTES
; Páginas que se cargarán en la memoria DOS. Se trata de una sola página desde
; la que se cargarán solo los bytes especificados en LastPageBytes. El tamaño
; del módulo en DOS se determina mediante la siguiente fórmula:
; ((TotalPages * 512) - (DOSHeaderSize * 16)) - LastPageBytes
.TotalPages dw 1
; Número de reasignaciones de memoria. No hay reubicación de memoria, por lo que
; el número de reasignaciones es 0. En DOS podemos especificar una dirección
; exacta para diferentes segmentos del archivo, pero en este caso no es necesario
.Relocations dw 0
; Tamaño de encabezado MZ en párrafos (16 bytes). Inmediatamente después de esto
; la siguiente parte es el DOS con código de 16 bytes que se ejecuta cuando la
; aplicación está abierta en DOS;
.DOSHeaderSize dw 4
; Cantidad mínima de párrafos que se deben cargar en la memoria además del código.
; Si en la memoria no hay al menos tantos párrafos como se especifica aquí,
; el programa no se ejecutará
.MinimumParagraphs dw 0
; Máximo de párrafos que pueden ser cargados por DOS en la memoria, además del
; código. El sistema proporcionará tanta memoria adicional como sea necesario.
; No lo limitaremos
.MaximumParagraphs dw 65535
; Valor inicial del registro que almacena el segmento de las pilas de memoria. Este
; valor se añade al segmento donde se carga el programa y el resultado se almacena
; en el registro SS
.SSRegister dw 0
; Valor inicial del registro que contiene el puntero de pila (su tamaño inicial)
.SPRegister dw 0x00B8
; Checksum. Suma de comprobación invertida de todas las palabras del archivo.
; Por lo general, Por lo general, el cargador lo ignora
.Checksum dw 0
; Valor del puntero de instrucción inicial
.IPRegister dw 0
; Valor inicial (relativo) del segmento de código
.CSRegister dw 0
; La dirección de la tabla de reasignaciones (si existe). Esta dirección es
; relativa al principio del fichero. Como este archivo no se ejecutará en
; DOS, no hay reubicaciones. Especificamos esto asignándole el valor '40h'
.RealocationsTable dw 0x0040
; Número de superposiciones. o usaremos superposiciones. Este es el único
; y principal programa que se carga en la memoria
.Overlays dw 0
; Rellena con 8 bytes
.RezervedWords dq 0
; Identificador OEM
.OEMIdentifier dw 0
; Información OEM
.OEMInfo dw 0
; Agrega 10 bytes nulos
Fill 20, 0
; Dirección de encabezado del PE, donde se ejecutará la aplicación en modo Windows
.PEHeader dd EXE_HEADER
; Una trozo de código para mostrar un mensaje sobre la incompatibilidad en el modo DOS
DOS_PROGRAM:
; Mover segmento de código en segmento de datos
push cs
; Almacena los datos después del código, para ahorrar espacio
pop ds
; Carga el puntero al mensaje que se mostrará en la pantalla
mov dx, DOSMessage - DOS_PROGRAM
; Este es el argumento para la operación de salida del interruptor 21h
mov ah, 9
; Llama al interruptor y muestra el mensaje
int 21h
; Argumento de salida para el mismo interruptor
mov ax, 0x4C01
; Salida
int 21h
; Mensaje a mostrar en DOS cuando se ejecuta este programa
DOSMessage:
db "Este programa no fue creado para su sistema!"
db 0Dh, 0Dh, 0Ah, '$'
; Rellena con ceros hasta el total de 64 bytes de esta parte de DOS
Fill 64-$+DOS_PROGRAM, 0
; Aquí definiremos el encabezado PE, que valida el archivo como ejecutable portátil
EXE_HEADER:
; Al igual que en el encabezado de DOS, comenzamos con una firma que lo identifica como PE
.PESignature db "PE", 0, 0
; TheEl tipo de procesador para el que va destinado este binario, en nuestro caso Intel i386
.Microprocessor dw 0x014C
; Número de secciones en este fichero
.SectionsCount dw $SECTIONS_COUNT
; Fecha y hora de creación del encabezado
.CreationDateHour dd __POSIX_TIME__
; Puntero a la tabla de símbolos. Los símbolos ayudarán al compilador a identificar
; diferentes elementos dentro del código fuente. No se necesita ahora
.SymbolsTable dd 0
; Cuántos símbolos tenemos en la tabla. No hay tabla de símbolos
.SymbolsCount dd 0
; El tamaño del encabezado adicional debajo de este
.OptionalHeaderSize dw SECTIONS_HEADER_TABLE - OPTIONAL_HEADER
; Características del archivo (ver referencia)
.Characteristics dw 0x0002|0x0004|0x0008|0x0100|0x0200
; Encabezado opcional (de hecho, obligatorio) definirá la estructura del ejecutable
OPTIONAL_HEADER:
; Al igual que antes, comenzamos con un número que identifique este encabezado
.OptHeaderSignature dw 0x010B
; La versión principal del vinculador. Este programa no tiene tal cosa
.LinkerMajorVersion db 0
; La versión menor del enlazador
.LinkerMinorVersion db 0
; El tamaño de las secciones de código de la imagen
.CodeSize dd Round($CODE_SIZE, $SECT_ALIGN)
; El tamaño de las secciones de datos inicializadas
.DataSize dd Round($DATA_SIZE, $SECT_ALIGN)
; El tamaño de las secciones de datos no inicializados (BSS). Estas secciones no ocupan
; ningún espacio dentro del archivo, pero el sistema asignará la cantidad de memoria
; necesaria al ejecutar el programa
.UdataSize dd Round($UDATA_SIZE, $SECT_ALIGN)
; La dirección relativa del punto de entrada de la aplicación. Como se encuentra en la
; primera sección de la memoria y comienza en $SECT_ALIGN, la dirección del punto
; de entrada es la misma $SECT_ALIGN
.EntryPoint dd RVA(CODE)
; La dirección virtual relativa (RVA) de la sección de código
.CodeBase dd RVA(CODE)
; La RVA de la sección de datos
.DataBase dd RVA(INIT_DATA)
; La dirección preferida en la que se va a cargar la imagen. Para la imagen EXE, esto es
; 0x00400000, para un fichero DLL es 0x1000000. Encima de esta dirección se agrega el
; código y la base de datos anterior
.MemoryImageBase dd $PREFERRED_ADDRESS
; Alineación de secciones. El valor predeterminado es 0x1000 (4096). Cada sección comienza
; en una dirección virtual múltiplo de este valor
.SectionAlignment dd $SECT_ALIGN
; Alineación de archivos. El valor recomendado es 0x0200. Cada sección dentro del archivo
; está alineada con este valor
.FileAlignment dd $FILE_ALIGN
; Indica la versión principal del sistema operativo mínimo aceptado. En nuestro caso,
; esto puede ejecutarse en Windows 95 y versiones más recientes
.MajorOSVersion dw 4
; La versión secundaria del sistema operativo
.MinorOSVersion dw 0
; Versión principal de la imagen
.MajorImageVersion dw 0
; Versión secundaria de la imagen
.MinorImageVersion dw 0
; La versión principal del subsistema. El valor mínimo aceptado es 3
.MajorSubsysVersion dw 3
; La versión secundaria del subsistema. El valor mínimo aceptado es 10
.MinorSubsysVersion dw 10
; Versión WIN32, siempre 0
.Win32Version dd 0
; Tamaño de la imagen del archivo, incluso encabezados y alineado con $SECT_ALIGN
.ImgSize dd ImageSize
; Tamaño de todos los encabezados, incluso los encabezados de las secciones, alineados
; con $FILE_ALIGN
.HeadersSize dd Round($HEADERS_SIZE, $FILE_ALIGN)
; Suma de comprobación que valida la integridad de la imagen. Por lo general, 0
.Checksum dd 0
; El subsistema utilizado para la interfaz de usuario, en nuestro caso, la consola
.InterfaceSubsystem dw 3
; Características del DLL. No es DLL, por lo que 0
.DLLAttributes dw 0
; La cantidad de memoria reservada por el sistema para la pila
.ReservedStack dd 4096
; La cantidad inicial de memoria reservada por el sistema para la pila local
.LocalStack dd 4096
; Tamaño inicial de la memoria libre
.ReservedFreeMemory dd 65536
; Memoria inicial libre para la imagen
.FreeMemory dd 0
; Puntero de depuración, obsoleto
.DebuggingPointer dd 0
; Cantidad de entradas en el DATA_TABLE. 16 se usa la mayoría de las veces
.DataDirectories dd 16
; Dirección de la tabla de elementos exportados. Esto no es un archivo DLL, no se exporta nada
.ExportTable dd 0
; El tamaño de la tabla de elementos exportados
.ExportTableSize dd 0
; La dirección de la tabla de elementos importados, donde tenemos saltos elementos de
; otros módulos
.ImportTable dd RVA(KERNEL32_ITABLE, IMPORT_DATA)
; Tamaño de la tabla de elementos importados
.ImportTableSize dd $IMPORTS_TABLE_SIZE
; 112 bytes reservados
Fill 112, 0
; Esta tabla contiene los encabezados de las secciones dentro de esta imagen y sus atributos
SECTIONS_HEADER_TABLE:
; El encabezado de la sección de código, donde se encuentra el código ejecutable.
; Por lo general, esta sección se llama texto, pero la llamaremos 'code'
CODE_SECTION_HEADER:
; Nombre de esta sección. No puede tener más de 8 caracteres
.Name db ".code", 0, 0, 0
; Tamaño virtual (en memoria) de esta sección. Si es mayor que el tamaño del disco,
; se rellenará con 0
.SectionSize dd Round($CODE_SIZE, $SECT_ALIGN)
; RVA de esta sección
.SectionStart dd RVA(CODE)
; Tamaño real alineado de esta sección
.RealSize dd Round($CODE_SIZE, $SECT_ALIGN)
; Dirección de inicio real
.StartAddress dd CODE
; Dirección de la tabla de reubicación
.RelocationsTable dd 0
; Puntero de fichero al principio de las entradas de número de línea de la sección.
; Si no hay números de línea COFF, este valor es cero
.LineNumbers dd 0
; Total dereubicaciones
.RelocationsTotal dw 0
; El número de entradas de número de línea para la sección
.LineNumbersTotal dw 0
; Características de esta sección. Se puede leer, escribir y ejecutar
.Characteristics dd 0x00000020|0x20000000|0x40000000|0x80000000
; Encabezado de la sección de importaciones (consulta el primer encabezado para comentarios)
IMPORT_SECTION_HEADER:
.Name db ".idat", 0, 0, 0
.SectionSize dd Round($IMPORTS_SIZE, $SECT_ALIGN)
.SectionStart dd RVA(IMPORT_DATA)
.RealSize dd Round($IMPORTS_SIZE, $FILE_ALIGN)
.StartAddress dd IMPORT_DATA
.RelocationsTable dd 0
.LineNumbers dd 0
.RelocationsTotal dw 0
.LineNumbersTotal dw 0
.Characteristics dd 0x00000040|0x40000000|0x80000000
; Encabezado de sección de datos inicializados
DATA_SECTION_HEADER:
.Name db ".data", 0, 0, 0
.SectionSize dd Round($DATA_SIZE, $SECT_ALIGN)
.SectionStart dd RVA(INIT_DATA)
.RealSize dd Round($DATA_SIZE, $FILE_ALIGN)
.StartAddress dd INIT_DATA
.RelocationsTable dd 0
.LineNumbers dd 0
.RelocationsTotal dw 0
.LineNumbersTotal dw 0
.Characteristics dd 0x00000040|0x40000000|0x80000000
; Encabezado de sección de datos no inicializados
NULL_DATA_HEADER:
.Name db ".null", 0, 0, 0
.SectionSize dd Round($UDATA_SIZE, $SECT_ALIGN)
.SectionStart dd RVA(NULL_DATA)
.RealSize dd 0
.StartAddress dd 0
.RelocationsTable dd 0
.LineNumbers dd 0
.RelocationsTotal dw 0
.LineNumbersTotal dw 0
.Characteristics dd 0x00000080|0x40000000|0x80000000
;; Aquí es donde terminan todas las declaraciones de encabezados
HEADERS_END:
;; Alineación con el tamaño de la página
align $FILE_ALIGN
;; SECCIÓN DE CÓDIGO
; Para procesadores de 32 bits
use32
; El RVA de las variables globales no inicializadas, de la sección BSS. La primera variable
; contendrá el controlador del dispositivo de salida
OutputHandler equ RVA(NULL_DATA) + 0
; Esto almacenará la cantidad de caracteres que genera WriteConsoleW
WrittenChars equ OutputHandler + 4
; Todo el código que se va a ejecutar está aquí
CODE:
; En primer lugar, necesitaremos el controlador del dispositivo de salida
call GetOutputHandler
; Agregua el mensaje que se mostrará a la pila
push dword $PREFERRED_ADDRESS + RVA(HelloMessage, INIT_DATA)
; La cantidad de caracteres que se mostrarán
push 13
; Llama a la función que mostrará el texto
call ShowText
; Termina la aplicación correctamente
jmp CloseApplication
; Esta parte del código obtendrá un controlador y lo almacenará en OutputHandler
GetOutputHandler:
; El argumento que indica lo que queremos, que es un dispositivo de salida
push -11
; Ahora, llama a la función desde Kernel32.dll
call dword [$PREFERRED_ADDRESS + RVA(F_GetStdHandle, IMPORT_DATA)]
; Si el valor invertido es menor que 1, tenemos un problema y la aplicación debe salir
cmp eax, 1
; Guarda el registro BX para restaurarlo en caso de éxito
push ebx
; Si hay un error, forzaremos el final del proceso y el código de salida será 1
mov ebx, 1
; Salto a la función que finaliza la aplicación
jl CloseApplication
; Restaura el registro BX, antes de recibir el código de salida
pop ebx
; Guarda el controlador en BSS
mov dword [$PREFERRED_ADDRESS + OutputHandler], eax
; Vuelve al lugar desde el que se ha llamado a esta función
ret 4
; Esta función mostrará un texto en la consola
ShowText:
; Guarda el registro EBP para restaurarlo más tarde
push ebp
; Mueve ESP en EBP para leer los argumentos recibidos
mov ebp, esp
; Argumento NULL reservado para la función WriteConsoleW
push 0
; Aquí es donde se guardará la cantidad de caracteres escritos
push dword $PREFERRED_ADDRESS + WrittenChars
; La cantidad de caracteres que se van a escribir, del segundo argumento
push dword [ebp + 8]
; El puntero al texto, del primer argumento de la pila
push dword [ebp + 12]
; El controlador al dispositivo de salida
push dword [$PREFERRED_ADDRESS + OutputHandler]
; Llama a la función que mostrará el texto
call dword [$PREFERRED_ADDRESS + RVA(F_WriteConsoleW, IMPORT_DATA)]
; Restaura EBP
pop ebp
; Vuelve y al mismo tiempo restaura la pila
ret 8
; Esta función finalizará la solicitud y devolverá el código de salida en EBX
CloseApplication:
; Almacene el valor BX en la pila, es el código de salida
push ebx
; Llame a la función que terminará el proceso con el código en EBX
call dword [$PREFERRED_ADDRESS + RVA(F_ExitProcess, IMPORT_DATA)]
; Si esto no funcionó, la pila se restaura y simplemente regresamos
pop ebx
; Regresa
ret
; La sección de código está alineada a 512 bytes
align $FILE_ALIGN
CODE_END:
; Aquí tenemos todos los datos y funciones importadas de librerías dinámicas
IMPORT_DATA:
; Esta es la biblioteca principal de Windows y proporcionará funciones esenciales
KERNEL32_LIBRARY db 'kernel32.dll', 0
IMPORT_DATA_START:
; Aquí describimos las características de la tabla de importaciones para la librería KERNEL32.DLL
KERNEL32_ITABLE:
; RVA de la tabla de búsqueda de importaciones
.originalfthk dd 0
; Marca de fecha de hora sin enlazar, se actualizará a la marca de fecha de fecha de la DLL
.timedate dd 0
; Índice de la primera referencia de la cadena de transitarios
.forwarder dd 0
; Nombre de la biblioteca que se va a importar
.name dd RVA(KERNEL32_LIBRARY, IMPORT_DATA)
; RVA de la tabla de direcciones de importaciones
.firstthunk dd RVA(KERNEL32_IMPORTED_FUNCTIONS, IMPORT_DATA)
; El final de la tabla para la librería KERNEL32.DLL
Fill 20, 0
; Aquí tenemos las direcciones de las funciones importadas
KERNEL32_IMPORTED_FUNCTIONS:
; Función para obtener el controlador para el dispositivo estándar especificado
F_GetStdHandle: dd RVA(I_GetStdHandle, IMPORT_DATA)
; Función que muestra un texto en la consola
F_WriteConsoleW: dd RVA(I_WriteConsoleW, IMPORT_DATA)
; Esta función cierra el proceso y devuelve un código de salida
F_ExitProcess dd RVA(I_ExitProcess, IMPORT_DATA)
; Byte reservado
ReservedBytes dd 0
; Nombres de los elementos importados de la biblioteca anterior
KERNEL32_IMPORTED_ELEMENTS:
I_GetStdHandle:
dw 0
db 'GetStdHandle', 0
align 2
I_WriteConsoleW:
dw 0
db 'WriteConsoleW', 0
align 2
I_ExitProcess:
dw 0
db 'ExitProcess', 0
align 2
IMPORT_DATA_END:
align $FILE_ALIGN
IMPORTS_END:
; Datos inicializados, normalmente constantes como mensajes de texto estáticos
INIT_DATA:
; Mensaje de hola para mostrar
HelloMessage: dw __utf16__("¡Hola mundo!"), 0Ah
; Alineación a 512 bytes
align $FILE_ALIGN
DATA_END:
; Sección BSS, con datos no inicializados
NULL_DATA:
; Fin de la imagen
IMAGE_END:
Construirlo es muy sencillo. Solo aseguraros de tener NASM instalado y agregado a la ruta del entorno. Ejecutad el siguiente comando
nasm main.asm -f bin -o app.exeEsto creará un ejecutable x86 para procesadores de 32 bits. Algún día intentaré crear la versión de 64 bits y quiero agregar algunas funcionalidades más (como obtener los argumentos de la línea de comandos). Veamos a dónde va esto.
Podéis obtener más información sobre el formato PE aquí .