Swift bajo el microscopio: instrumentación dinámica en la práctica
Artículo sobre la instrumentación dinámica de Swift. Explica los pasos para realizar el análisis dinámico de una aplicación basada en Swift, y abarca el name mangling, la ABI de Swift y la extracción de argumentos de funciones en Swift.
Introducción
Los expertos en seguridad pueden examinar a fondo el comportamiento de los programas durante la ejecución para identificar fallos de seguridad y actividad maliciosa. Incluso en las aplicaciones de iOS, como investigador de seguridad, puede interceptar e inspeccionar llamadas al sistema, rastrear asignaciones de memoria e inyectar código personalizado en procesos en ejecución. De este modo se descubren vulnerabilidades ocultas y se identifican y neutralizan con rapidez (juego de palabras intencionado, «swiftly») las amenazas emergentes.
Qué es la instrumentación dinámica
A diferencia de los métodos tradicionales de análisis estático, que examinan el código antes de su ejecución, la instrumentación dinámica funciona en tiempo real. Establece una conexión con los programas mientras se ejecutan para monitorizar, ajustar y mejorar su comportamiento.
Los investigadores pueden monitorizar eventos importantes como las llamadas al sistema, los accesos a memoria y las interacciones de red insertando con cuidado código de instrumentación especializado, también conocido como sondas o hooks, en el binario o el bytecode de la aplicación objetivo al inicio de la operación.
Estos hooks permiten la monitorización, el perfilado, la depuración y el análisis de seguridad en tiempo real mientras se ejecuta el programa instrumentado, ya sea liberando datos o desencadenando acciones en respuesta a situaciones predeterminadas. Piense en ellos como puntos de interrupción que inserta en su código y que provocan el inicio de una devolución de llamada cuando el código llega al punto de interrupción. El paso final consiste en analizar los datos recopilados para obtener información sobre el comportamiento de la aplicación en tiempo de ejecución. Cabe mencionar que este análisis no se limita al análisis de seguridad, sino que, según sus objetivos, puede abarcar la depuración y el perfilado.
Manos a la obra
El flujo completo del proceso puede describirse brevemente de la siguiente manera:
1. Encontrar la aplicación objetivo.
2. Identificar los puntos de monitorización, es decir, los hooks.
3. Escribir el código de instrumentación con cualquier framework de instrumentación.
4. Inyectar el código de instrumentación.
5. Y, por último, analizar los resultados y las trazas de pila.
Identificación de los hooks
Una aplicación tendrá miles de funciones. Solo unas pocas resultan interesantes para los investigadores de seguridad, por lo que el primer paso de todo el proceso es identificar las funciones que deben monitorizarse. Después necesitamos encontrar los símbolos que se utilizarán para el hooking.
Podemos conseguirlo con la herramienta de línea de comandos nm, creada específicamente para listar los símbolos de los archivos objeto.
Supongamos que queremos instrumentar una dummyFunction, dentro de DummyModule.swift:
public class Dummy {
public class AnotherDummyClass {
public func dummyFunction(intDummyArg: Int, stringDummyArg: String, booleanDummyArg: Bool) -> String {
return "I am just a dummy function."
}
}
}
Compilamos y ejecutamos el comando nm -g DummyModule, y nuestro símbolo tiene este aspecto:
$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF
Name mangling en Swift
La resolución de símbolos es una de las tareas del enlazador en el diseño de compiladores; hace coincidir los símbolos declarados en un archivo con las referencias a esos símbolos en otro archivo para resolver las referencias simbólicas entre archivos objeto.
El name mangling no es necesario en un lenguaje como C, ya que solo puede existir una función o un dato con un nombre determinado (un símbolo). Las cosas se complican en los lenguajes que permiten la sobrecarga y el uso de plantillas con selectores similares en la misma clase y con firmas distintas.
Por ejemplo:
int add(int a, int b)
float add(float a, float b)
El compilador aplica mangling a los símbolos, es decir, asigna a las funciones identificadores únicos que el enlazador comprenderá.
La primera idea intuitiva es utilizar la firma completa: add(int, int)->int, pero esto supondría mucho código adicional en el enlazador y confusión cuando varios nombres de tipo se corresponden con el mismo tipo subyacente, como unsigned y unsigned int.
Como el name mangling en Swift no es el tema principal de este artículo, intentamos explicar algunas de sus reglas a través del ejemplo anterior de
dummyFunction:
public class Dummy {
public class AnotherDummyClass {
public func dummyFunction(intDummyArg: Int, stringDummyArg: String, booleanDummyArg: Bool) -> String {
return "I am just a dummy function."
}
}
}
Su símbolo con mangling es:
$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF:
$s: prefijo global de los símbolos de Swift;11DummyModule: el nombre del módulo, de 11 caracteres de longitud;0A0C: el identificador empieza por0porque se usará una sustitución; la palabraDummyse usará en las apariciones siguientes como el carácterA. ComoDummyes el último identificador, terminamos su versión con mangling con otro0y, por último,Csignifica que se trata de unaClass;07AnotherA5ClassC:0significa que el identificador tiene sustitución de palabras,Anothertiene 7 caracteres,Aes la sustitución deDummy,Classtiene 5 caracteres y, de forma similar al identificador anterior,Csignifica que se trata de una clase;13dummyFunction: el nombre de la funcióndummyFunction, de 13 caracteres de longitud;03intA3Arg:0para la sustitución, int tiene 3 caracteres,Asustituye aDummyy, por último, 3 caracteres paraArg;06stringaH0: similar al argumento anterior; la única diferencia es que se sustituiráArg;007booleanaH0: similar al argumento anterior;SS: tipo de retornoSwift.String;Si: tipo de argumentoSwift.Int;SS: tipo de argumentoSwift.String;Sb: tipo de argumentoSwift.Bool;F: el último símbolo indica que se trata de un símbolo de unaFunction.
A continuación se muestra la salida de swift-demangle, un programa diseñado para revertir el mangling de los símbolos de Swift.

Preparar la aplicación
Con frameworks como Frida, preparamos la aplicación para la instrumentación incorporando las bibliotecas necesarias y habilitando FridaGadget. La siguiente configuración permite inyectar código personalizado y monitorizar el comportamiento de la aplicación.
- Descargue FridaGadet.dylib correspondiente a su arquitectura desde la página principal de versiones;
- Mueva FridaGadget.dylib a la carpeta
Frameworksde su aplicación; - Inserte un comando de carga para el gadget;
insert_dylib: «una utilidad de línea de comandos para insertar un comando de carga de dylib en un binario Mach-O»; - Al ejecutar la aplicación, es normal que se quede bloqueada; está esperando a que se conecte el cliente de Frida;
- Aquí pueden utilizarse varios enfoques:
- Para prototipos rápidos:
frida-ps - Para los desarrolladores que monitorizan su aplicación, puede usarse un sencillo script de Python para conectarse a la aplicación;
- Para prototipos rápidos:
import frida
def on_frida_message(message, data):
# Callback to execute when a frida-message is received.
device_id = "your-device-id"
frida_device = frida.get_device(device_id)
frida_session = frida_device.attach("gadget")
script_path = "path/to/instrument.js"
with open(script_path, "r") as f:
frida_script = frida_session.create_script(f.read())
frida_script.on("message", on_frida_message)
frida_script.load()
- Según el caso de uso, el cuerpo de la devolución de llamada
on_frida_messagepuede ir desde simplemente imprimir en el terminal o guardar las trazas de pila en un archivo hasta pasarlas por reglas de seguridad para su análisis; - La pieza que falta del rompecabezas es el contenido de
instrument.js. ¿Qué vamos a hacer exactamente cuando se intercepte nuestra función? Para responder a esa pregunta, tenemos que comprender algunos puntos.
Código de instrumentación
La ABI de Swift: Application Binary Interface
En tiempo de ejecución, los binarios de los programas de Swift interactúan con otras bibliotecas y componentes mediante una ABI, una «Application Binary Interface». Es la especificación que deben cumplir las entidades binarias compiladas de forma independiente para poder enlazarse y ejecutarse juntas.
Estas entidades binarias deben ponerse de acuerdo en muchos detalles de bajo nivel: cómo se llama a las funciones, cómo se representan sus datos en memoria e incluso dónde están sus metadatos y cómo acceder a ellos. Las funciones también deben saber cómo llamarse entre sí, lo que implica aspectos como la disposición de la pila de llamadas, qué registros se conservan y las convenciones de propiedad.
Convención de llamada
A continuación se muestra un fragmento de la tabla de uso de registros para ARM64 y x86-64; para la lista completa y más detalles, consulte el repositorio de Swift en GitHub:
ARM64
| Registro | Especial | Propósito | Swift |
|---|---|---|---|
| x0 | Argumento entero 1 (1.er valor de retorno) | ||
| x1 | Argumento entero 2 (2.º valor de retorno) | ||
| x2 - x7 | Argumentos enteros 3-8 | ||
| x8 | Registro de ubicación del resultado indirecto | ||
| x16 | ip0 | Registros temporales | |
| x17 | ip1 | ||
| x18 | RESERVADO, NO USAR | ||
| x19 | Registro guardado por el llamado | self | |
| .. | .. | ..... | .. |
X86-64
| Registro | Propósito | Swift |
|---|---|---|
| rax | Valor de retorno; también, para var-args, número de registros xmm utilizados | |
| rbx | Registro guardado por el llamado | |
| rdi | Argumento entero 1 | |
| rsi | Argumento entero 2 | |
| rdx | Argumento entero 3 (2.º valor de retorno) | |
| rcx | Argumento entero 4 (3.er valor de retorno) | |
| .. | ..... | .. |
Argumentos
Ya sabemos dónde residirán los parámetros de una función; intentemos extraerlos.
Booleanos
Los booleanos pueden obtenerse leyendo directamente el registro que contiene el argumento.
/** Get the boolean argument of a function.
*
* @param context Frida context giving access to register values.
* @param argIndex Argument index to determine which offset is the arg pointer.
* @returns The boolean value of the argument as an integer.
*/
function GetSwiftBoolArgument(context, argIndex, swiftRegisterShiftingIndex) {
argIndex+=swiftRegisterShiftingIndex
let register = getSwiftArgumentCorrespondingRegisterForARM64(context, argIndex);
return Boolean(register.and(0x1).toInt32())
}
Enteros
Igual que los booleanos, los enteros se obtienen simplemente accediendo al valor del registro.
/** Get the integer argument of a function.
*
* @param context Frida context giving access to register values.
* @param argIndex Argument index to determine which offset is the arg pointer.
* @returns The integer value of the argument.
*/
function GetSwiftIntArgument(context, argIndex, swiftRegisterShiftingIndex) {
argIndex+=swiftRegisterShiftingIndex
let register = getSwiftArgumentCorrespondingRegisterForARM64(context, argIndex);
return register.toInt32();
}
Cadenas
Cuando se pasan cadenas de Swift a una función, pueden pasarse de dos maneras según su tamaño. Si el tamaño es inferior a 16 bytes, la cadena se pasa en la pila; en caso contrario, se pasa en el heap. Aun así, sea cual sea el tamaño, el propio objeto seguirá la estructura que se muestra en el siguiente dibujo.

Menos de 16 bytes
La cadena puede tener una longitud máxima de 16 bytes. En las arquitecturas de 64 bits, los registros son de 64 bits, lo que significa que necesitamos 2 registros para contener la cadena.
Este dato cambia por completo la comprensión de la tabla de convención de llamada que vimos antes. Si una función recibe 2 argumentos, donde el primero es de tipo Swift.String y el segundo de tipo Swift.Int, ¿se aplica la regla únicamente a los argumentos enteros?
```
rdi: Integer argument 1
rsi: Integer argument 2
```
Si la regla no se aplica únicamente a los argumentos enteros, ¿usamos el primer registro para almacenar la cadena? ¿Desplazamos? Si tenemos más de un argumento, ¿se desplazan todos?
Para responder a esta pregunta, basta con ejecutar una aplicación, conectarse con LLDB y echar un vistazo a los valores de los registros.
- Primero, ejecutamos
lldbsobre el módulo de ejemplo que vimos en la sección del mangling;
-> ~ lldb DummyModule
(lldb) target create "DummyModule"
- Establecemos un punto de interrupción en la función «dummyFunction»;
(lldb) breakpoint set --file main.swift --line 37
Breakpoint 1: where = DummyModule`$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF + 58 address = 0x00000000000c446a
- Ejecutamos el programa;
(lldb) run
Process 3162136 launched: '/home/haddadi/Documents/swift-nio/.build/install/DummyModule' (x86_64)
Process 3162136 stopped
* thread #1, name = 'DummyModule', stop reason = breakpoint 1.1
frame #0: 0x000055555561846a DummyModule`$s11DummyModule0A0C07AnotherA5ClassC13dummyFunction03intA3Arg06stringaH007booleanaH0SSSi_SSSbtF at main.swift:37:20
- El primer argumento es de tipo entero y se supone que está en el registro «rdi»;
(lldb) register read rdi -f d
rdi = 42
- El segundo argumento es de tipo cadena, en el segundo registro, «rsi»;
(lldb) register read rsi -f s
rsi = "42"
- El siguiente registro, «rdx»;
(lldb) register read rdx -f s
rdx = ""
(lldb) register read rdx -f b
rdx = 1110001000000000000000000000000000000000000000000000000000000000
El primer byte 11100010 contiene metadatos sobre nuestro objeto:
b63:isImmortal; indica si el runtime de Swift debe omitir ARC. Las cadenas pequeñas son simples valores, siempre inmortales;b62: (grande)isBridged/ (pequeña)isASCII;b61:isSmall: bit dedicado para denotar las cadenas pequeñas;b60:isForeign: es decir, si es bajo, no puede proporcionar acceso a UTF-8 contiguo;- Los últimos 4 bits representan el recuento: 0010 => 2.
Para confirmar la hipótesis, leeremos en formato booleano -f B el valor del registro rcx, responsable de contener el Integer argument 4 (consulte la tabla anterior).
(lldb) register read rcx -f B
rcx = true

Más de 16 bytes
El literal de cadena se asigna en el heap. Los registros correspondientes (según el índice de este argumento de cadena) contienen los metadatos de la cadena y el puntero al literal en el heap. Los 8 bytes de _object se almacenan en el segundo registro, siguiendo el dibujo que aparece a continuación.

Esto se traduce en
/** Extract the string value of the argument; case of strings with length > 16 bytes.
*
* @param secondRegister The second register used to hold the _object value.
* @returns The string value of argument.
*/
function GetSwiftLargeStringArgument(secondRegister) {
const ptr2hex = '0x' + secondRegister.toString(16);
let ptr2value = BigInt(ptr2hex);
// low 56 bits (check drawing above)
let strAddress = '0x' + (ptr2value & 0xFFFFFFFFFFFFFFn).toString(16);
let strPtr = new NativePointer(strAddress);
let cstrPtr = strPtr.add(32); // Skip the offset (check drawing above)
const message = cstrPtr.readCString() ?? "";
return message
}
Esta distinción en el tratamiento según el tamaño de la cadena es esencial para extraer los datos con exactitud durante la instrumentación. Al comprender y aprovechar estas convenciones, los investigadores pueden leer y manipular de forma eficaz los argumentos de tipo cadena, lo que les permite obtener una visión más profunda del comportamiento de la aplicación en tiempo de ejecución.
Conclusión
En conclusión, la instrumentación dinámica de Swift ofrece un enfoque de vanguardia para la automatización de la seguridad, ya que permite analizar y controlar en tiempo real el comportamiento del software.
En los párrafos anteriores intentamos explicar los pasos principales de la instrumentación dinámica y adaptarla al caso de uso de Swift, repasando el name mangling, la ABI de Swift, dónde se almacenan los argumentos de funciones de tipos primitivos y, sobre todo, cómo.
En el siguiente artículo ampliaremos el análisis a los argumentos de tipos no primitivos y a cómo tratar las funciones que se ejecutan en el runtime de Objective-C, tomando como ejemplo UIKit y AppKit.