Neutron, nuestro motor de IA, obtuvo un 96.75% en el benchmark CyberGym de UC Berkeley. Más información

Ingeniería

Ingeniería

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 por 0 porque se usará una sustitución; la palabra Dummy se usará en las apariciones siguientes como el carácter A. Como Dummy es el último identificador, terminamos su versión con mangling con otro 0 y, por último, C significa que se trata de una Class;
  • 07AnotherA5ClassC: 0 significa que el identificador tiene sustitución de palabras, Another tiene 7 caracteres, A es la sustitución de Dummy, Class tiene 5 caracteres y, de forma similar al identificador anterior, C significa que se trata de una clase;
  • 13dummyFunction: el nombre de la función dummyFunction, de 13 caracteres de longitud;
  • 03intA3Arg: 0 para la sustitución, int tiene 3 caracteres, A sustituye a Dummy y, por último, 3 caracteres para Arg;
  • 06stringaH0: similar al argumento anterior; la única diferencia es que se sustituirá Arg;
  • 007booleanaH0: similar al argumento anterior;
  • SS: tipo de retorno Swift.String;
  • Si: tipo de argumento Swift.Int;
  • SS: tipo de argumento Swift.String;
  • Sb: tipo de argumento Swift.Bool;
  • F: el último símbolo indica que se trata de un símbolo de una Function.

A continuación se muestra la salida de swift-demangle, un programa diseñado para revertir el mangling de los símbolos de Swift.

salida de swift-demangle

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 Frameworks de 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;
    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_message puede 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.

estructura de las cadenas de Swift

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 lldb sobre 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

lectura de registros con lldb

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.

metadatos de la cadena

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.