Funciones para seguridad y protección avanzada de android

GrapheneOS es un sistema operativo móvil centrado en la seguridad y la privacidad basado en una versión modificada de Android (AOSP). Para mejorar su protección, integra funciones de seguridad avanzadas, incluido su propio asignador de memoria para libc: malloc endurecido. Diseñado para ser tan robusto como el propio sistema operativo, este asignador busca específicamente proteger contra la corrupción de la memoria.

Este artículo técnico detalla el funcionamiento interno del malloc endurecido y los mecanismos de protección que implementa para prevenir vulnerabilidades comunes de corrupción de memoria. Está destinado a un público técnico, en particular investigadores de seguridad o desarrolladores de exploits, que deseen obtener una comprensión profunda de las partes internas del asignador.

Los análisis y pruebas de este artículo se realizaron en dos dispositivos que ejecutan GrapheneOS:

  • Pixel 4a 5G: google/bramble/bramble:14/UP1A.231105.001.B2/2025021000:user/release-keys
  • Pixel 9a: google/tegu/tegu:16/BP2A.250705.008/2025071900:user/release-keys

Los dispositivos fueron rooteados con Magisk 29 para poder utilizar Frida para observar el estado interno de malloc endurecido dentro de los procesos del sistema. El estudio se basó en el código fuente del Repositorio de GitHub de GrapheneOS (commit 7481c8857faf5c6ed8666548d9e92837693de91b).

GrapheneOS

GrapheneOS es un sistema operativo reforzado basado en Android. Como proyecto de código abierto mantenido activamente, se beneficia de actualizaciones frecuentes y la rápida aplicación de parches de seguridad. Toda la información está disponible en el Sitio web de GrapheneOS.

Para proteger eficazmente los procesos que se ejecutan en el dispositivo, GrapheneOS implementa varios mecanismos de seguridad. Las siguientes secciones describen brevemente los mecanismos específicos que contribuyen al endurecimiento de su asignador de memoria.

Espacio de dirección extendido

En los sistemas Android estándar, el espacio de direcciones para los procesos del espacio de usuario está limitado a 39 bits, que van desde 0 to 0x8000000000. En GrapheneOS, este espacio se extiende a 48 bits y, para aprovechar esta extensión, la entropía ASLR también se ha incrementado de 18 a 33 bits. Este detalle es importante ya que malloc endurecido depende en gran medida de mmap por sus estructuras internas y sus asignaciones.

tegu:/ # cat /proc/self/maps
c727739a2000-c727739a9000 rw-p 00000000 00:00 0                          [anon:.bss]
c727739a9000-c727739ad000 r--p 00000000 00:00 0                          [anon:.bss]
c727739ad000-c727739b1000 rw-p 00000000 00:00 0                          [anon:.bss]
c727739b1000-c727739b5000 r--p 00000000 00:00 0                          [anon:.bss]
c727739b5000-c727739c1000 rw-p 00000000 00:00 0                          [anon:.bss]
e5af7fa30000-e5af7fa52000 rw-p 00000000 00:00 0                          [stack]
tegu:/ # cat /proc/self/maps
d112736be000-d112736c5000 rw-p 00000000 00:00 0                          [anon:.bss]
d112736c5000-d112736c9000 r--p 00000000 00:00 0                          [anon:.bss]
d112736c9000-d112736cd000 rw-p 00000000 00:00 0                          [anon:.bss]
d112736cd000-d112736d1000 r--p 00000000 00:00 0                          [anon:.bss]
d112736d1000-d112736dd000 rw-p 00000000 00:00 0                          [anon:.bss]
ea0de59be000-ea0de59e1000 rw-p 00000000 00:00 0                          [stack]
tegu:/ # cat /proc/self/maps
d71f87043000-d71f8704a000 rw-p 00000000 00:00 0                          [anon:.bss]
d71f8704a000-d71f8704e000 r--p 00000000 00:00 0                          [anon:.bss]
d71f8704e000-d71f87052000 rw-p 00000000 00:00 0                          [anon:.bss]
d71f87052000-d71f87056000 r--p 00000000 00:00 0                          [anon:.bss]
d71f87056000-d71f87062000 rw-p 00000000 00:00 0                          [anon:.bss]
f69f7c952000-f69f7c974000 rw-p 00000000 00:00 0                          [stack]

Generación segura de aplicaciones

En Android estándar, cada aplicación se inicia a través de un fork de la zygote procesar. Este mecanismo, diseñado para acelerar el inicio, tiene una importante consecuencia de seguridad: todas las aplicaciones heredan el mismo espacio de direcciones que zygote. En la práctica, esto significa que las bibliotecas precargadas terminan en direcciones idénticas de una aplicación a otra. Para un atacante, esta previsibilidad facilita eludir la protección ASLR sin necesidad de una fuga de información previa.

Para superar esta limitación, GrapheneOS cambia fundamentalmente este proceso. En lugar de sólo un fork, se lanzan nuevas aplicaciones con exec. Este método crea un espacio de direcciones completamente nuevo y aleatorio para cada proceso, restaurando así la plena eficacia de ASLR. Ya no es posible predecir la ubicación de regiones de memoria remotas. Sin embargo, esta seguridad mejorada tiene un costo: un ligero impacto en el rendimiento del lanzamiento y un mayor consumo de memoria para cada aplicación.

tegu:/ # cat /proc/$(pidof zygote64)/maps | grep libc\.so
d6160aac0000-d6160ab19000 r--p 00000000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
d6160ab1c000-d6160abbe000 r-xp 0005c000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
d6160abc0000-d6160abc5000 r--p 00100000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
d6160abc8000-d6160abc9000 rw-p 00108000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
tegu:/ # cat /proc/$(pidof com.android.messaging)/maps | grep libc\.so
d5e4a9c68000-d5e4a9cc1000 r--p 00000000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
d5e4a9cc4000-d5e4a9d66000 r-xp 0005c000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
d5e4a9d68000-d5e4a9d6d000 r--p 00100000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
d5e4a9d70000-d5e4a9d71000 rw-p 00108000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
tegu:/ # cat /proc/$(pidof com.topjohnwu.magisk)/maps | grep libc\.so
dabc42ac5000-dabc42b1e000 r--p 00000000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
dabc42b21000-dabc42bc3000 r-xp 0005c000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
dabc42bc5000-dabc42bca000 r--p 00100000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so
dabc42bcd000-dabc42bce000 rw-p 00108000 07:f0 24                         /apex/com.android.runtime/lib64/bionic/libc.so

Extensión de etiquetado de memoria (MTE)

Extensión de etiquetado de memoria, o MTE, es una extensión de la arquitectura ARM introducida con Armv8.5. MTE tiene como objetivo evitar que un atacante explote vulnerabilidades de corrupción de memoria. Esta protección se basa en un mecanismo de etiquetado de regiones de memoria.

Durante una asignación, una etiqueta de 4 bits se asocia con la región asignada y se almacena en los bits superiores del puntero. Para acceder a los datos, tanto la dirección como la etiqueta deben ser correctas. Si la etiqueta es incorrecta, se genera una excepción. Este mecanismo permite la detección y bloqueo de vulnerabilidades como lecturas/escrituras fuera de límites y uso después de la liberación.

Por ejemplo, se podría detectar la escritura fuera de límites en el siguiente código C, dependiendo de la implementación del asignador con MTE:

char* ptr = malloc(8); 
ptr[16] = 12; // oob write, this tag is not valid for the area

Dado que MTE es una característica que ofrece la CPU, es necesario que el hardware sea compatible. Este es el caso de todos los teléfonos inteligentes Google Pixel desde el Pixel 8. Para obtener más información, consulte la Documentación ARM.

Malloc endurecido por lo tanto utiliza MTE en teléfonos inteligentes compatibles para evitar este tipo de corrupción de memoria.

Para beneficiarse de MTE, se debe compilar un binario con las banderas apropiadas. A los efectos de este artículo, se agregaron las siguientes banderas a la Application.mk archivo de nuestros binarios de prueba para habilitar MTE.

APP_CFLAGS := -fsanitize=memtag -fno-omit-frame-pointer -march=armv8-a+memtag
APP_LDFLAGS := -fsanitize=memtag -march=armv8-a+memtag

La Documentación de Android proporciona toda la información necesaria para crear una aplicación compatible con MTE.

Malloc endurecido depende en gran medida de MTE agregando etiquetas a sus asignaciones. Tenga en cuenta que solo las pequeñas asignaciones (menos de 0x20000 bytes) están etiquetadas.

Arquitectura de malloc endurecido

Para mejorar la seguridad, malloc endurecido aísla los metadatos de los datos del usuario en regiones de memoria separadas, manteniéndolos principalmente dentro de dos estructuras principales:

  • ro: la estructura principal en la .bss sección de libc.
  • allocator_state: una estructura grande que agrupa todos los metadatos para los diferentes tipos de asignación. Su región de memoria se reserva sólo una vez en el momento de la inicialización.
Arquitectura global
Estructuras principales

Similar a jemalloc, malloc endurecido divide los hilos en arenas, y cada arena gestiona sus propias asignaciones. Esto implica que la memoria asignada en una arena no puede ser administrada ni liberada por otra arena. Sin embargo, no existe una estructura de datos explícita para definir estos ámbitos; su existencia es implícita y afecta principalmente al tamaño de ciertas matrices internas.

Aunque el concepto de arena está presente en el código fuente, el análisis de los binarios libc de los dispositivos de prueba reveló que malloc endurecido fue compilado para utilizar una sola arena. Como resultado, todos los subprocesos comparten el mismo conjunto de metadatos de asignación.

Estructura ro

La estructura ro es la estructura de metadatos principal del asignador. Está contenida dentro de la .bss sección de libc y consta de los siguientes atributos:

static union {
    struct {
        void *slab_region_start;
        void *_Atomic slab_region_end;
        struct size_class *size_class_metadata[N_ARENA];
        struct region_allocator *region_allocator;
        struct region_metadata *regions[2];
#ifdef USE_PKEY
        int metadata_pkey;
#endif
#ifdef MEMTAG
        bool is_memtag_disabled;
#endif
    };
    char padding[PAGE_SIZE];
} ro __attribute__((aligned(PAGE_SIZE)));
  • slab_region_start: El inicio del área de memoria que contiene las regiones para pequeñas asignaciones.
  • slab_region_end: El final del área de memoria que contiene las regiones para pequeñas asignaciones.
  • size_class_metadata[N_ARENA]: Una matriz de punteros a los metadatos para pequeñas asignaciones, por arena.
  • region_allocator: Un puntero a la estructura de gestión para grandes asignaciones.
  • regions[2]: Un puntero a las tablas hash que hacen referencia a las grandes asignaciones.

allocator_state

Esta estructura contiene todos los metadatos utilizados para tanto pequeñas como grandes asignaciones. Se asigna solo una vez cuando el asignador se inicializa y está aislado por páginas de guarda. Su tamaño es fijo y se calcula en función del número máximo de asignaciones que el asignador puede manejar.

struct __attribute__((aligned(PAGE_SIZE))) allocator_state {
    struct size_class size_class_metadata[N_ARENA][N_SIZE_CLASSES];
    struct region_allocator region_allocator;
    // padding until next page boundary for mprotect
    struct region_metadata regions_a[MAX_REGION_TABLE_SIZE] __attribute__((aligned(PAGE_SIZE)));
    // padding until next page boundary for mprotect
    struct region_metadata regions_b[MAX_REGION_TABLE_SIZE] __attribute__((aligned(PAGE_SIZE)));
    // padding until next page boundary for mprotect
    struct slab_info_mapping slab_info_mapping[N_ARENA][N_SIZE_CLASSES];
    // padding until next page boundary for mprotect
};
  • size_class_metadata[N_ARENA][N_SIZE_CLASSES]: Una matriz de size_class estructuras que contienen los metadatos para pequeñas asignaciones para cada clase.
  • region_allocator: Los metadatos para las grandes regiones de asignaciones.
  • regions_a/b[MAX_REGION_TABLE_SIZE]: Una tabla hash que agrupa información sobre las grandes asignaciones.
  • slab_info_mapping: Los metadatos de las losas de pequeñas asignaciones.

Datos del usuario

Malloc endurecido almacena datos de usuario en dos tipos de regiones, separadas de sus metadatos:

  • Región de losas: un área muy grande reservada solo una vez en el momento de la inicialización, que contiene las losas para pequeñas asignaciones. Se inicializa en la función init_slow_path y su dirección de inicio se almacena en ro.slab_region_start.
  • Grandes regiones: áreas reservadas dinámicamente que contienen los datos para grandes asignaciones. Cada una de estas regiones contiene sólo una única gran asignación.

Asignaciones

Hay dos tipos de asignaciones en malloc endurecido: pequeñas asignaciones y grandes asignaciones.

Pequeñas asignaciones

Clases de tamaño/contenedores

Las asignaciones pequeñas se clasifican por tamaño en clases de tamaño, también conocido como contenedores. Malloc endurecido utiliza 49 de estas clases, que están indexadas por tamaño creciente y representadas por la size_class estructura:

Clase de tamañoTamaño total del contenedorTamaño disponibleRanurasTamaño de la losaLosas máximasTamaño de las cuarentenas (aleatorias / FIFO)
00x100x102560x100083886088192 / 8192
10x100x82560x100083886088192 / 8192
20x200x181280x100083886084096 / 4096
30x300x28850x100083886084096 / 4096
40x400x38640x100083886082048 / 2048
50x500x48510x100083886082048 / 2048
60x600x58420x100083886082048 / 2048
70x700x68360x100083886082048 / 2048
80x800x78640x200041943041024 / 1024
90xa00x98510x200041943041024 / 1024
100xc00xb8640x300027962021024 / 1024
110xe00xd8540x300027962021024 / 1024
120x1000xf8640x40002097152512 / 512
130x1400x138640x50001677721512 / 512
140x1800x178640x60001398101512 / 512
150x1c00x1b8640x70001198372512 / 512
160x2000x1f8640x80001048576256 / 256
170x2800x278640xa000838860256 / 256
180x3000x2f8640xc000699050256 / 256
190x3800x378640xe000599186256 / 256
200x4000x3f8640x10000524288128 / 128
210x5000x4f8160x50001677721128 / 128
220x6000x5f8160x60001398101128 / 128
230x7000x6f8160x70001198372128 / 128
240x8000x7f8160x8000104857664 / 64
250xa000x9f880x5000167772164 / 64
260xc000xbf880x6000139810164 / 64
270xe000xdf880x7000119837264 / 64
280x10000xff880x8000104857632 / 32
290x14000x13f880xa00083886032 / 32
300x18000x17f880xc00069905032 / 32
310x1c000x1bf880xe00059918632 / 32
320x20000x1ff880x1000052428816 / 16
330x28000x27f860xf00055924016 / 16
340x30000x2ff850xf00055924016 / 16
350x38000x37f840xe00059918616 / 16
360x40000x3ff840x100005242888 / 8
370x50000x4ff810x500016777218 / 8
380x60000x5ff810x600013981018 / 8
390x70000x6ff810x700011983728 / 8
400x80000x7ff810x800010485764 / 4
410xa0000x9ff810xa0008388604 / 4
420xc0000xbff810xc0006990504 / 4
430xe0000xdff810xe0005991864 / 4
440x100000xfff810x100005242882 / 2
450x140000x13ff810x140004194302 / 2
460x180000x17ff810x180003495252 / 2
470x1c0000x1bff810x1c0002995932 / 2
480x200000x1fff810x200002621441 / 1

Dentro de cada arena, un conjunto de 49 size_class entradas mantienen los metadatos para cada clase de tamaño. Para cada clase, el asignador reserva una región de memoria dedicada para almacenar sus asignaciones correspondientes. Esta región está segmentada en losas, que a su vez se subdividen en ranuras. Cada ranura corresponde a un único fragmento de memoria devuelto al usuario.

Estructuras a cargo de las asignaciones pequeñas
Pequeñas estructuras de asignación

Las regiones de todas las clases se reservan de forma contigua en la memoria cuando se inicializa el asignador. Cada región ocupa 32 GiB de memoria en un desplazamiento aleatorio dentro de un área de 64 GiB. Las áreas vacías antes y después de la región actúan como guardias alineados con las páginas de un tamaño aleatorio.

Para resumir:

  • Se asigna una región de 32 GiB por clase de tamaño.
  • Está encapsulado en un desplazamiento aleatorio dentro de una zona del doble de su tamaño (64 GiB).
  • Las zonas de 64 GiB son contiguas y están ordenadas por clase de tamaño creciente.
Segmentación de regiones para las pequeñas asignaciones
Regiones de pequeñas asignaciones

El tamaño del área de memoria contigua reservada durante la inicialización es N_ARENA * 49 * 64 GiB. En los dispositivos de prueba, que utilizan una única arena, esto equivale a 0x31000000000 bytes (~3 TB). De forma predeterminada, estas páginas están protegidas con PROT_NONE, lo que significa que no están respaldados por la memoria física. Esta protección se cambia a Leer/Escribir (RW) a pedido para páginas específicas según se necesiten asignaciones.

// CONFIG_EXTENDED_SIZE_CLASSES := true
// CONFIG_LARGE_SIZE_CLASSES := true
// CONFIG_CLASS_REGION_SIZE := 34359738368 # 32GiB
// CONFIG_N_ARENA := 1

#define CLASS_REGION_SIZE (size_t)CONFIG_CLASS_REGION_SIZE
#define REAL_CLASS_REGION_SIZE (CLASS_REGION_SIZE * 2)
#define ARENA_SIZE (REAL_CLASS_REGION_SIZE * N_SIZE_CLASSES)
static const size_t slab_region_size = ARENA_SIZE * N_ARENA; // 0x31000000000 on Pixel 4a 5G and Pixel 9a

// ...

COLD static void init_slow_path(void) {
    // ...

    // Create a big mapping with MTE enabled
    ro.slab_region_start = memory_map_tagged(slab_region_size);
    if (unlikely(ro.slab_region_start == NULL)) {
        fatal_error("failed to allocate slab region");
    }
    void *slab_region_end = (char *)ro.slab_region_start + slab_region_size;
    memory_set_name(ro.slab_region_start, slab_region_size, "malloc slab region gap");
    // ...
}

Cada clase de tamaño (o contenedor) está representada por una size_class estructura, una estructura relativamente grande que contiene toda la información relevante para esa clase.

struct __attribute__((aligned(CACHELINE_SIZE))) size_class {
    struct mutex lock;

    void *class_region_start;
    struct slab_metadata *slab_info;
    struct libdivide_u32_t size_divisor;
    struct libdivide_u64_t slab_size_divisor;

#if SLAB_QUARANTINE_RANDOM_LENGTH > 0
    void *quarantine_random[SLAB_QUARANTINE_RANDOM_LENGTH << (MAX_SLAB_SIZE_CLASS_SHIFT - MIN_SLAB_SIZE_CLASS_SHIFT)];
#endif

#if SLAB_QUARANTINE_QUEUE_LENGTH > 0
    void *quarantine_queue[SLAB_QUARANTINE_QUEUE_LENGTH << (MAX_SLAB_SIZE_CLASS_SHIFT - MIN_SLAB_SIZE_CLASS_SHIFT)];
    size_t quarantine_queue_index;
#endif

    // slabs with at least one allocated slot and at least one free slot
    //
    // LIFO doubly-linked list
    struct slab_metadata *partial_slabs;

    // slabs without allocated slots that are cached for near-term usage
    //
    // LIFO singly-linked list
    struct slab_metadata *empty_slabs;
    size_t empty_slabs_total; // length * slab_size

    // slabs without allocated slots that are purged and memory protected
    //
    // FIFO singly-linked list
    struct slab_metadata *free_slabs_head;
    struct slab_metadata *free_slabs_tail;
    struct slab_metadata *free_slabs_quarantine[FREE_SLABS_QUARANTINE_RANDOM_LENGTH];

#if CONFIG_STATS
    u64 nmalloc; // may wrap (per jemalloc API)
    u64 ndalloc; // may wrap (per jemalloc API)
    size_t allocated;
    size_t slab_allocated;
#endif

    struct random_state rng;
    size_t metadata_allocated;
    size_t metadata_count;
    size_t metadata_count_unguarded;
};

Sus principales miembros son:

  • class_region_start: dirección de inicio de la región de memoria para las losas de esta clase.
  • slab_info: puntero al comienzo de la matriz de metadatos de la losa.
  • quarantine_random, quarantine_queue: conjuntos de punteros a asignaciones actualmente en cuarentena (consulte la sección sobre cuarentenas).
  • partial_slabs: una pila de metadatos para losas parcialmente rellenas.
  • free_slabs_{head, tail}: una cola de metadatos para losas vacías.

Los metadatos de la losa se guardan en la slab_metadata estructura. Para cualquier clase de tamaño dada, estas estructuras forman una matriz contigua, accesible a través del size_class->slab_info puntero. El diseño de esta matriz de metadatos refleja directamente el diseño de las losas en su región de memoria. Este diseño permite una búsqueda directa: los metadatos de una losa se pueden encontrar simplemente usando el índice de la losa para acceder a la matriz.

struct slab_metadata {
    u64 bitmap[4];
    struct slab_metadata *next;
    struct slab_metadata *prev;
#if SLAB_CANARY
    u64 canary_value;
#endif
#ifdef SLAB_METADATA_COUNT
    u16 count;
#endif
#if SLAB_QUARANTINE
    u64 quarantine_bitmap[4];
#endif
#ifdef HAS_ARM_MTE
    // arm_mte_tags is used as a u4 array (MTE tags are 4-bit wide)
    //
    // Its size is calculated by the following formula:
    // (MAX_SLAB_SLOT_COUNT + 2) / 2
    // MAX_SLAB_SLOT_COUNT is currently 256, 2 extra slots are needed for branchless handling of
    // edge slots in tag_and_clear_slab_slot()
    //
    // It's intentionally placed at the end of struct to improve locality: for most size classes,
    // slot count is far lower than MAX_SLAB_SLOT_COUNT.
    u8 arm_mte_tags[129];
#endif
};
  • bitmap[4]: seguimiento de mapa de bits qué ranuras de la losa están en uso.
  • next, prev: punteros a los elementos siguientes/anteriores cuando la estructura pertenece a una lista enlazada (por ejemplo, en la pila de losas parcialmente utilizadas size_class->partial_slabs).
  • canary_value: valor canario agregado al final de cada ranura dentro de la losa (solo en dispositivos que no sean MTE). Este valor se verifica en free para detectar desbordamientos de búfer.
  • arm_mte_tags[129]: Etiquetas MTE actualmente en uso por ranura.

Alloc

Primero, el tamaño real a asignar se calcula agregando 8 bytes al tamaño solicitado por el usuario. Estos bytes adicionales se llenan con un canario y se colocan inmediatamente después de los datos. Una asignación se considera "pequeña" sólo si este nuevo tamaño es menor que 0x20000 bytes (131.072 bytes). A continuación, se debe recuperar una ranura libre de una losa siguiendo estos pasos:

  1. Recuperar la arena: la arena actual se obtiene del almacenamiento local del hilo.
  2. Obtener metadatos de clase de tamaño: los metadatos para la clase de tamaño correspondiente (la size_class estructura) se recupera utilizando ro.size_class_metadata[arena][size_class], donde arena es el número de la arena y size_class es el índice calculado a partir del tamaño de la asignación.
  3. Encuentra una losa con una ranura libre:
    • si existe una losa parcialmente rellena (size_class->partial_slabs != NULL), se utiliza esta losa.
    • de lo contrario, si hay al menos una losa vacía disponible (size_class->empty_slabs != NULL), se utiliza la primera losa de esta lista.
    • si no hay losa disponible, se asigna una nueva (asignando una slab_metadata estructura que utiliza la alloc_metadata() función). Entre cada losa real se reserva una losa de "guardia".
  4. Seleccione una ranura libre aleatoria: se elige una ranura libre aleatoriamente desde dentro de la losa seleccionada. Las ranuras ocupadas están marcadas por 1s en el slab_metadata->bitmap.
  5. Seleccione una etiqueta MTE: se elige una nueva etiqueta MTE para la ranura, asegurándose de que sea diferente de las etiquetas adyacentes para evitar desbordamientos lineales simples. Se excluyen las siguientes etiquetas:
    • la etiqueta de la ranura anterior.
    • la etiqueta de la siguiente ranura.
    • la etiqueta antigua de la ranura actualmente seleccionada.
    • el RESERVED_TAG (0), que se utiliza para asignaciones liberadas.
  6. Establecer protecciones:
    • En dispositivos sin MTE, el canario (que es común a todas las ranuras de la losa) se escribe en los últimos 8 bytes de la ranura.
    • En dispositivos habilitados para MTE, estos 8 bytes se establecen en 0.
  7. Devuelve la dirección de la ranura, ahora etiquetada con la etiqueta MTE.

Para una asignación pequeña, la dirección devuelta por malloc es un puntero a una ranura con una etiqueta MTE de 4 bits codificada en sus bits más significativos. Los punteros a continuación, recuperados de llamadas sucesivas a malloc(8), están ubicados en la misma losa pero con desplazamientos aleatorios y tienen diferentes etiquetas MTE.

ptr[0] = 0xa00cd70ad02a930
ptr[1] = 0xf00cd70ad02ac50
ptr[2] = 0x300cd70ad02a2f0
ptr[3] = 0x900cd70ad02a020
ptr[4] = 0x300cd70ad02ac90
ptr[5] = 0x700cd70ad02a410
ptr[6] = 0xc00cd70ad02a3c0
ptr[7] = 0x500cd70ad02a3d0
ptr[8] = 0xf00cd70ad02a860
ptr[9] = 0x600cd70ad02ad20

Si se produce un desbordamiento, un SIGSEGV/SEGV_MTESERR se genera una excepción que indica que se accedió a un área protegida por MTE con una etiqueta incorrecta. En GrapheneOS, esto hace que la aplicación finalice y envía un registro de fallas a logcat.

07-23 11:32:19.948  4169  4169 F DEBUG   : Cmdline: /data/local/tmp/bin
07-23 11:32:19.948  4169  4169 F DEBUG   : pid: 4165, tid: 4165, name: bin  >>> /data/local/tmp/bin <<<<
07-23 11:32:19.948  4169  4169 F DEBUG   : uid: 2000
07-23 11:32:19.949  4169  4169 F DEBUG   : tagged_addr_ctrl: 000000000007fff3 (PR_TAGGED_ADDR_ENABLE, PR_MTE_TCF_SYNC, mask 0xfffe)
07-23 11:32:19.949  4169  4169 F DEBUG   : pac_enabled_keys: 000000000000000f (PR_PAC_APIAKEY, PR_PAC_APIBKEY, PR_PAC_APDAKEY, PR_PAC_APDBKEY)
07-23 11:32:19.949  4169  4169 F DEBUG   : signal 11 (SIGSEGV), code 9 (SEGV_MTESERR), fault addr 0x0500d541414042c0
07-23 11:32:19.949  4169  4169 F DEBUG   :     x0  0800d541414042c0  x1  0000d84c01173140  x2  0000000000000015  x3  0000000000000014
07-23 11:32:19.949  4169  4169 F DEBUG   :     x4  0000b1492c0f16b5  x5  0300d6f2d01ea99b  x6  0000000000000029  x7  203d207972742029
07-23 11:32:19.949  4169  4169 F DEBUG   :     x8  5dde6df273e81100  x9  5dde6df273e81100  x10 0000000000001045  x11 0000000000001045
07-23 11:32:19.949  4169  4169 F DEBUG   :     x12 0000f2dbd10c1ca4  x13 0000000000000000  x14 0000000000000001  x15 0000000000000020
07-23 11:32:19.949  4169  4169 F DEBUG   :     x16 0000d84c0116e228  x17 0000d84c010faf50  x18 0000d84c1eb38000  x19 0500d541414042c0
07-23 11:32:19.949  4169  4169 F DEBUG   :     x20 0000000000001e03  x21 0000b1492c0f16e8  x22 0800d541414042c0  x23 0000000000000001
07-23 11:32:19.949  4169  4169 F DEBUG   :     x24 0000d541414042c0  x25 0000000000000000  x26 0000000000000000  x27 0000000000000000
07-23 11:32:19.949  4169  4169 F DEBUG   :     x28 0000000000000000  x29 0000f2dbd10c1f10
07-23 11:32:19.949  4169  4169 F DEBUG   :     lr  002bb1492c0f2ba0  sp  0000f2dbd10c1f10  pc  0000b1492c0f2ba4  pst 0000000060001000

Free

Para liberar una pequeña asignación, el asignador primero determina su índice de clase de tamaño a partir del puntero. Este índice le permite localizar los metadatos relevantes y la región de memoria donde residen los datos. La función slab_size_class realiza este cálculo inicial.

static struct slab_size_class_info slab_size_class(const void *p) {
    size_t offset = (const char *)p - (const char *)ro.slab_region_start;
    unsigned arena = 0;
    if (N_ARENA > 1) {
        arena = offset / ARENA_SIZE;
        offset -= arena * ARENA_SIZE;
    }
    return (struct slab_size_class_info){arena, offset / REAL_CLASS_REGION_SIZE};
}

Con este índice, ahora denominado class_id, es posible recopilar varios detalles sobre la losa que contiene la asignación:

  • size_class estructura: size_class *c = &ro.size_class_metadata[size_class_info.arena][class_id]
  • Tamaño de asignación: el tamaño de esta clase se encuentra usando la size_classes tabla de búsqueda: size_t size = size_classes[class_id]
  • Ranuras por losa: el número de ranuras se encuentra utilizando la size_class_slots tabla de búsqueda: slots = size_class_slots[class_id]
  • Tamaño de la losa: slab_size = page_align(slots * size)
  • Metadatos de losa actuales: offset = (const char *)p - (const char *)c->class_region_start index = offset / slab_size slab_metadata = c->slab_info + index

Con esta información, el asignador puede identificar la dirección base de la losa y determinar el índice y el desplazamiento de la ranura específica dentro de esa losa utilizando la función get_slab().

static void *get_slab(const struct size_class *c, size_t slab_size, const struct slab_metadata *metadata) {
    size_t index = metadata - c->slab_info;
    return (char *)c->class_region_start + (index * slab_size);
}

Luego se deduce la dirección de la ranura con la fórmula slot = (const char*)slab - p, al igual que su índice: slot_index = ((const char*)slab - slot) / slots.

Una vez identificada la ranura, se realizan una serie de comprobaciones cruciales de seguridad e integridad para validar la free operación:

  1. Alineación del puntero: el asignador verifica que el puntero esté perfectamente alineado con el inicio de una ranura. Cualquier desalineación indica algún tipo de corrupción y la operación se aborta inmediatamente.
  2. Estado de la ranura: luego verifica los metadatos de la losa para confirmar que la ranura esté marcada actualmente como "en uso".
  3. Verificación canaria: se verifica la integridad del canario de 8 bytes al final de la ranura. Una diferencia clave con el scudo es que este canario se comparte en toda la losa. Esto significa que una pérdida de memoria de una ranura podría, en teoría, permitir a un atacante falsificar un canario válido para otra ranura y evitar un bloqueo en caso de un free.
  4. Invalidación de etiqueta MTE: la etiqueta MTE de la ranura se restablece al valor reservado (0), invalidando efectivamente el puntero original y evitando el acceso al puntero colgante.
  5. Puesta a cero: la memoria de la ranura se borra por completo al ponerla a cero.

Si se detecta un canario no válido, un abort se llama con el siguiente mensaje:

07-23 02:14:09.559  7610  7610 F libc    : hardened_malloc: fatal allocator error: canary corrupted
07-23 02:14:09.559  7610  7610 F libc    : Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 7610 (bin), pid 7610 (bin)
07-23 02:14:09.775  7614  7614 F DEBUG   : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
07-23 02:14:09.775  7614  7614 F DEBUG   : Build fingerprint: 'google/bramble/bramble:14/UP1A.231105.001.B2/2025021000:user/release-keys'
07-23 02:14:09.776  7614  7614 F DEBUG   : Revision: 'MP1.0'
07-23 02:14:09.776  7614  7614 F DEBUG   : ABI: 'arm64'
07-23 02:14:09.776  7614  7614 F DEBUG   : Timestamp: 2025-07-23 02:14:09.603643955+0200
07-23 02:14:09.776  7614  7614 F DEBUG   : Process uptime: 1s
07-23 02:14:09.776  7614  7614 F DEBUG   : Cmdline: /data/local/tmp/bin
07-23 02:14:09.776  7614  7614 F DEBUG   : pid: 7610, tid: 7610, name: bin  >>> /data/local/tmp/bin <<<<
07-23 02:14:09.776  7614  7614 F DEBUG   : uid: 2000
07-23 02:14:09.776  7614  7614 F DEBUG   : signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
07-23 02:14:09.776  7614  7614 F DEBUG   : Abort message: 'hardened_malloc: fatal allocator error: canary corrupted'
07-23 02:14:09.776  7614  7614 F DEBUG   :     x0  0000000000000000  x1  0000000000001dba  x2  0000000000000006  x3  0000ea4a84242960
07-23 02:14:09.776  7614  7614 F DEBUG   :     x4  716e7360626e6b6b  x5  716e7360626e6b6b  x6  716e7360626e6b6b  x7  7f7f7f7f7f7f7f7f
07-23 02:14:09.777  7614  7614 F DEBUG   :     x8  00000000000000f0  x9  0000cf1d482da2a0  x10 0000000000000001  x11 0000cf1d48331980
07-23 02:14:09.777  7614  7614 F DEBUG   :     x12 0000000000000004  x13 0000000000000033  x14 0000cf1d482da118  x15 0000cf1d482da050
07-23 02:14:09.777  7614  7614 F DEBUG   :     x16 0000cf1d483971e0  x17 0000cf1d48383650  x18 0000cf1d6fe40000  x19 0000000000001dba
07-23 02:14:09.777  7614  7614 F DEBUG   :     x20 0000000000001dba  x21 00000000ffffffff  x22 0000cc110ff0d150  x23 0000000000000000
07-23 02:14:09.777  7614  7614 F DEBUG   :     x24 0000000000000001  x25 0000cf0f4a421300  x26 0000000000000000  x27 0000cf0f4a421328
07-23 02:14:09.777  7614  7614 F DEBUG   :     x28 0000cf0f7ba30000  x29 0000ea4a842429e0
07-23 02:14:09.777  7614  7614 F DEBUG   :     lr  0000cf1d4831a9f8  sp  0000ea4a84242940  pc  0000cf1d4831aa24  pst 0000000000001000

Finalmente, el espacio no está disponible de inmediato. En cambio, se pone en cuarentena para retrasar su reutilización, una defensa clave contra uso después de la liberación vulnerabilidades.

Cuarentenas

Cada clase de asignación utiliza un sistema de cuarentena de dos etapas para sus espacios liberados. Cuando se libera una asignación, no está inmediatamente disponible para su reutilización, sino que pasa a través de dos áreas de retención distintas:

  1. Una cuarentena aleatoria: una matriz de tamaño fijo donde las ranuras entrantes reemplazan una ranura existente elegida al azar.
  2. Una cuarentena en cola: una cola de primero en entrar, primero en salir que recibe espacios expulsados de la cuarentena aleatoria.

Cuando una ranura ingresa a la cuarentena aleatoria, sobrescribe una entrada seleccionada aleatoriamente. Esa entrada expulsada luego es empujada a la cola de cuarentena. Luego, la cola expulsa su elemento más antiguo, que finalmente queda disponible para nuevas asignaciones. Todo este proceso se gestiona dentro de cada clase size_class estructura:

struct __attribute__((aligned(CACHELINE_SIZE))) size_class {
  // ...
  #if SLAB_QUARANTINE_RANDOM_LENGTH > 0
    void *quarantine_random[SLAB_QUARANTINE_RANDOM_LENGTH << (MAX_SLAB_SIZE_CLASS_SHIFT - MIN_SLAB_SIZE_CLASS_SHIFT)];
#endif

#if SLAB_QUARANTINE_QUEUE_LENGTH > 0
    void *quarantine_queue[SLAB_QUARANTINE_QUEUE_LENGTH << (MAX_SLAB_SIZE_CLASS_SHIFT - MIN_SLAB_SIZE_CLASS_SHIFT)];
    size_t quarantine_queue_index;
#endif
  // ...
}
Inserción de vanguardia en cuarentena
Cuarentenas antes de la inserción
Inserción de après de cuarentena
Cuarentenas después de la inserción

Este diseño supone un cambio significativo respecto de los asignadores tradicionales, que utilizan una lista independiente LIFO (último en entrar, primero en salir) simple. En malloc endurecido, el último elemento liberado casi nunca es el primero en reasignarse. Para recuperar una ranura específica, un atacante debe activar lo suficiente free operaciones para recorrer con éxito su espacio objetivo tanto a través de la cuarentena aleatoria como de la cuarentena en cola. Esto añade una capa sustancial de no determinismo y complejidad a uso después de la liberación exploits, proporcionando una defensa robusta incluso en dispositivos que carecen de MTE.

Dado que el asignador no tiene una lista independiente, la forma más sencilla de forzar una reutilización es encadenar llamadas a malloc y free. El número de free operaciones requeridas dependen de los tamaños de cuarentena para esa clase de tamaño específica.

void reuse(void* target_ptr, size_t size) {
  free(target_ptr);
  for (int i = 0; ; i++) {
    void* new_ptr = malloc(size);
    if (untag(target_ptr) == untag(new_ptr)) {
      printf("REUSED [size = 0x%x] target_ptr @ %p (new_ptr == %p) try = %d\n", size, target_ptr, new_ptr, i);
      break;
    }
    free(new_ptr);
  }
}

Para una asignación de 8 bytes, ambas cuarentenas contienen 8.192 elementos. Si bien esto implica que se necesitan al menos 8.192 liberaciones, la naturaleza aleatoria de la primera etapa significa que el número real es mucho mayor. En las pruebas, se requirió un promedio de ~19.000 free operaciones para recuperar una ranura de forma fiable. La doble cuarentena convierte la reutilización predecible de la memoria en una lotería costosa y poco confiable, lo que obstaculiza gravemente un vector de explotación común.

Grandes asignaciones

Grandes estructuras de asignación
Grandes estructuras de asignación

Alloc

A diferencia de las asignaciones pequeñas, las asignaciones grandes no se clasifican por tamaño en regiones previamente reservadas. En cambio, el asignador los mapea según demanda. Este mecanismo es el único en malloc endurecido que crea asignaciones de memoria dinámicamente. El tamaño total del mapeo depende de varios factores:

  • Tamaño alineado: calculado en get_large_size_class alineando el tamaño solicitado con clases predefinidas, continuando con los pequeños tamaños de asignación.
  • Tamaño de la página de guardia: un número aleatorio de páginas que preceden y siguen a la asignación real.
static size_t get_large_size_class(size_t size) {
    if (CONFIG_LARGE_SIZE_CLASSES) {
        // Continue small size class growth pattern of power of 2 spacing classes:
        //
        // 4 KiB [20 KiB, 24 KiB, 28 KiB, 32 KiB]
        // 8 KiB [40 KiB, 48 KiB, 54 KiB, 64 KiB]
        // 16 KiB [80 KiB, 96 KiB, 112 KiB, 128 KiB]
        // 32 KiB [160 KiB, 192 KiB, 224 KiB, 256 KiB]
        // 512 KiB [2560 KiB, 3 MiB, 3584 KiB, 4 MiB]
        // 1 MiB [5 MiB, 6 MiB, 7 MiB, 8 MiB]
        // etc.
        return get_size_info(max(size, (size_t)PAGE_SIZE)).size;
    }
    return page_align(size);
}

Una vez determinados estos tamaños, el asignador crea un mapeo mediante mmap() para su total combinado. Las áreas de guardia antes y después de que se mapeen los datos PROT_NONE, mientras se mapea la región de datos en sí PROT_READ|PROT_WRITE. Este uso de páginas de protección de tamaño aleatorio significa que dos grandes asignaciones del mismo tamaño solicitado ocuparán áreas mapeadas de diferentes tamaños totales, agregando una capa de no determinismo.

void *allocate_pages_aligned(size_t usable_size, size_t alignment, size_t guard_size, const char *name) {
    //...

    // Compute real mapped size = alloc_size + 2 * guard_size
    size_t real_alloc_size;
    if (unlikely(add_guards(alloc_size, guard_size, &real_alloc_size))) {
        errno = ENOMEM;
        return NULL;
    }
    // Mapping whole region with PROT_NONE
    void *real = memory_map(real_alloc_size);
    if (unlikely(real == NULL)) {
        return NULL;
    }
    memory_set_name(real, real_alloc_size, name);

    void *usable = (char *)real + guard_size;

    size_t lead_size = align((uintptr_t)usable, alignment) - (uintptr_t)usable;
    size_t trail_size = alloc_size - lead_size - usable_size;
    void *base = (char *)usable + lead_size;

    // Change protection to usable data with PROT_RAD|PROT_WRITE
    if (unlikely(memory_protect_rw(base, usable_size))) {
        memory_unmap(real, real_alloc_size);
        return NULL;
    }

    //...
    return base;
}

Si el mapeo es exitoso, se inserta una estructura que contiene la dirección, el tamaño utilizable y el tamaño de protección en una tabla hash de regiones. Esta tabla hash se implementa utilizando dos matrices de region_metadata structs: allocator_state.regions_a y allocator_state.regions_b (referenciado como ro.regions[0] y ro.regions[1]). Estas matrices tienen un tamaño estático y están reservadas en la inicialización.

Inicialmente, solo una parte de estas matrices es accesible (marcada como Leer/Escribir); el resto está protegido con PROT_NONE. A medida que el número de grandes asignaciones activas crece y excede los espacios de metadatos disponibles, la porción accesible de las matrices se duplica. Esta expansión utiliza un sistema de dos tablas: la tabla hash actual se copia a la tabla no utilizada anteriormente, que luego se convierte en la activa. La tabla antigua se vuelve a mapear PROT_NONE para hacerlo inaccesible.

static int regions_grow(void) {
    struct region_allocator *ra = ro.region_allocator;

    if (ra->total > SIZE_MAX / sizeof(struct region_metadata) / 2) {
        return 1;
    }

    // Compute new grown size
    size_t newtotal = ra->total * 2;
    size_t newsize = newtotal * sizeof(struct region_metadata);
    size_t mask = newtotal - 1;

    if (newtotal > MAX_REGION_TABLE_SIZE) {
        return 1;
    }

    // Select new metadata array
    struct region_metadata *p = ra->regions == ro.regions[0] ?
        ro.regions[1] : ro.regions[0];

    // Enlarge new metadata elements
    if (memory_protect_rw_metadata(p, newsize)) {
        return 1;
    }

    // Copy elements to the new array
    for (size_t i = 0; i < ra->total; i++) {
        const void *q = ra->regions[i].p;
        if (q != NULL) {
            size_t index = hash_page(q) & mask;
            while (p[index].p != NULL) {
                index = (index - 1) & mask;
            }
            p[index] = ra->regions[i];
        }
    }

    memory_map_fixed(ra->regions, ra->total * sizeof(struct region_metadata));
    memory_set_name(ra->regions, ra->total * sizeof(struct region_metadata), "malloc allocator_state");
    ra->free = ra->free + ra->total;
    ra->total = newtotal;

    // Switch current metadata array/hash table
    ra->regions = p;
    return 0;
}

Finalmente, metadatos de asignación, address + size + guard size, se inserta en la tabla hash actual ro.region_allocator->regions.

Para asignaciones grandes, que no están protegidas por MTE, las páginas de protección de tamaño aleatorio son la defensa principal contra desbordamientos. Si un atacante puede eludir esta aleatorización y tiene una vulnerabilidad de lectura/escritura fuera de límites con un desplazamiento preciso, corromper los datos adyacentes sigue siendo un escenario posible, aunque complejo.

Por ejemplo, una llamada a malloc(0x28001) crea los siguientes metadatos. Un tamaño de guardia aleatorio de 0x18000 bytes fue elegido por el asignador.

large alloc @ 0xc184d36f4ac8
  ptr       : 0xbe6cadf4c000
  size      : 0x30000
  guard size: 0x18000

Al inspeccionar los mapas de memoria del proceso, podemos ver que la gran asignación (que se alinea con un tamaño de 0x30000) está colocado de forma segura entre dos PROT_NONE regiones de guardia, cada una 0x18000 bytes de tamaño.

be6cadf34000-be6cadf4c000 ---p 00000000 00:00 0
be6cadf4c000-be6cadf7c000 rw-p 00000000 00:00 0
be6cadf7c000-be6cadf94000 ---p 00000000 00:00 0

Free

Liberar una asignación grande es un proceso relativamente simple que utiliza el mismo mecanismo de cuarentena que las asignaciones pequeñas.

  1. Calcular hash de puntero: el hash del puntero se calcula para localizar sus metadatos.
  2. Recuperar metadatos: la estructura de metadatos de la asignación se recupera de la tabla hash actual (ro->region_allocator.regions).
  3. Cuarentena o desmapeo: el siguiente paso depende del tamaño de la asignación.
    • Si el tamaño es menor que 0x2000000 (32 MiB), la asignación se coloca en un sistema de cuarentena de dos etapas idéntico al de las asignaciones pequeñas (un caché de reemplazo aleatorio seguido de una cola FIFO). Esta cuarentena es global para todas las asignaciones grandes y se gestiona en ro.region_allocator.
    • Si el tamaño es 0x2000000 o mayor, o cuando una asignación se expulsa de la cuarentena, se desmapea inmediatamente de la memoria. Toda la región de memoria, incluida el área de datos y sus páginas de protección circundantes, se desmapea mediante munmap()
      • munmap((char *)usable - guard_size, usable_size + guard_size * 2);

Conclusión

Malloc endurecido es un asignador de memoria reforzado por la seguridad que implementa varios mecanismos de protección avanzados, en particular aprovechando la extensión de etiquetado de memoria (MTE) ARM para detectar y prevenir la corrupción de la memoria. Si bien ofrece una mejora con respecto al asignador scudo estándar, particularmente en comparación con uso después de la liberación vulnerabilidades, su verdadera fortaleza radica en su integración con GrapheneOS. Esta combinación logra un mayor nivel de seguridad que un dispositivo Android típico que utiliza scudo.

Además, el uso de canarios y numerosas páginas de protección complementa su arsenal, especialmente en dispositivos más antiguos sin MTE, al activar rápidamente excepciones en caso de acceso no deseado a la memoria.

Desde la perspectiva de un atacante, malloc endurecido reduce significativamente las oportunidades de explotar vulnerabilidades de corrupción de memoria:

  • Desbordamiento de montón: malloc endurecido es relativamente similar al scudo, pero agrega páginas protectoras entre losas, lo que evita que un desbordamiento se extienda de una losa a otra. Sin embargo, con MTE habilitado, la protección se vuelve mucho más granular: incluso un desbordamiento dentro de una misma losa (de una ranura a otra) se detecta y bloquea sin necesidad de comprobar los canarios, lo que hace casi imposible la explotación de este tipo de vulnerabilidad.
  • Uso después de la liberación: el mecanismo de doble cuarentena complica la reutilización de una región de memoria liberada pero no la hace del todo imposible. Sin embargo, MTE cambia radicalmente el acuerdo. El puntero y su región de memoria asociada están "etiquetados". Al ser liberada, esta etiqueta se modifica. Cualquier intento posterior de utilizar el puntero antiguo (con su etiqueta ahora no válida) muy probablemente generará una excepción, neutralizando el ataque. Para asignaciones grandes, que no están cubiertas por MTE, la estrategia es diferente: cada asignación está aislada por páginas de guarda y su ubicación en la memoria es aleatoria. Esta combinación de aislamiento y aleatorización hace que cualquier intento de reutilizar estas regiones de memoria sea difícil y poco confiable para un atacante.

Además, su implementación ha demostrado ser particularmente clara y concisa, facilitando su auditoría y mantenimiento.

Comparte este artículo

error:
1
Escanea el código