De NX-bit gaat niet alleen over beveiliging

Deze zoektocht naar de bug begon enkele maanden geleden. Tijdens het ontwikkelen van een bare-metal hypervisor op ARM64 voor postmarketOS stuitte ik op een vreemde fout: wanneer ik de CTR_EL0-intercept inschakelde (wat het hoofddoel van de hypervisor was en dus niet overgeslagen kon worden), blokkeerde de telefoon willekeurig. Na enkele seconden trad de watchdog in werking en resette het systeem. In eerste instantie dacht ik dat het opstartproces zo vertraagd werd dat het systeem simpelweg niet genoeg tijd had om te booten, maar het uitschakelen van de watchdog hielp niet. Gelukkig had de betreffende telefoon een verwijderbare batterij, waardoor ik niet urenlang hoefde te wachten tot deze leeg was. Dus begon ik dieper te graven.

Hypothese 1: Mijn emulatie van MRS is onjuist

Op Aarch64 worden zogenaamde Special Function Registers (waar CTR_EL0 een van is) benaderd met MRS- en MSR-machine-instructies:

mrs x3, ctr_el0 // leesactie
msr ctr_el0, x3 // schrijfactie

Deze instructies verplaatsen gegevens tussen het gespecificeerde SFR en het gespecificeerde algemene register (in dit geval X3), waarbij alle andere registers ongewijzigd blijven. Een fout zou dus betekenen dat ik ofwel registers corrumpeerde die ik had moeten behouden, of het uiteindelijke outputregister niet correct beschreef.

Daarom controleerde ik eerst de exception handler trampoline:

trap_from_el1:
sub sp, sp, #256
stp x0, x1, [sp]
stp x2, x3, [sp, #16]
stp x4, x5, [sp, #32]
// ...
stp x28, x29, [sp, #224]
stp x30, xzr, [sp, #240]
mov x0, sp
bl handle_trap_from_el1
mrs x1, elr_el2
add x0, x0, x1
msr elr_el2, x0
ldp x0, x1, [sp]
ldp x2, x3, [sp, #16]
ldp x4, x5, [sp, #32]
// ...
ldp x28, x29, [sp, #224]
ldp x30, xzr, [sp, #240]
add sp, sp, #256
eret

En de stack-allocatie:

.section .data.stack
.p2align 12
.long 0
.p2align 12
stack:

Beide waren correct en vertoonden geen duidelijke problemen. Ik stapte de volledige exception handler door in QEMU en bevestigde dat deze precies deed wat verwacht werd.

Om er zeker van te zijn dat alles soepel liep op echte hardware, voegde ik debug-prints toe voor en na de exception handler. Het bleek dat de exception handler op echte hardware geen enkel register wijzigde, zelfs niet het beoogde outputregister.

De schuldige bleek deze onschuldige aanroep te zijn:

msr_accessor_sort(
  msr_accessors,
  ((uintptr_t)msr_accessors_end - (uintptr_t)msr_accessors) / sizeof(struct msr_accessor)
);

ARM is niet Icache/Dcache-coherent, wat betekent dat wijzigingen in de data niet automatisch worden doorgegeven aan de instructie-fetches: ofwel de gewijzigde data was nog niet naar het RAM geschreven, of de instructiecache hield verouderde data vast van vóór de schrijfactie. Omdat de buffer uitvoerbare machine-instructies bevatte, werkte het sorteren van de array en het later proberen uit te voeren niet. Ik verplaatste het sorteren naar de build-fase en...

De debug-prints lieten zien dat de handlers nu werkten zoals bedoeld. Maar het systeem bootte nog steeds niet.

Hypothese 2: Hardware die niet aan de specificaties voldoet

x86(-64) hardware wordt slechts door twee leveranciers geproduceerd – Intel en AMD – waardoor we consistent gedrag over verschillende systemen kunnen verwachten.

Aan de ARM-kant is de situatie anders: hoewel ARM een referentie-implementatie van de architectuur levert, zijn leveranciers vrij om deze naar wens aan te passen of zelfs eigen implementaties te maken. Dit betekent dat ARM-CPU's vaak subtiele (en minder subtiele) bugs hebben, waarvan sommige waarschijnlijk als "features" werden beschouwd door de ontwikkelaars. De volgende logische gok was dat de CPU op een bepaalde manier afweek van de specificaties en zich niet gedroeg zoals het zou moeten.

Met dat in gedachten voegde ik extra handlers toe om ervoor te zorgen dat elke exception werd geprint:

vbar_el2:
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
b trap_from_el1
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
b trap_from_el1
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7

De unknowntrap-routine zou vervolgens het X30-register (lr voor wie meer bekend is met Aarch32), ELREL2 en andere SFR's printen om de oorzaak van de exception te bepalen. Echter, niets hiervan werd geactiveerd.

De volgende aanname was dat de kernel in paniek raakte door een onjuiste afhandeling, dus gebruikte ik /proc/last_kmsg om de logs van de gecrashte kernel te lezen. Helaas waren de laatste berichten in de log:

[    3.113676]  (0)[153:init]fs_mgr: Running /system/bin/e2fsck on /dev/block/platform/mtk-msdc.0/11230000.msdc0/by-name/userdata
[    3.125072]  (0)[158:e2fsck]random: e2fsck urandom read with 12 bits of entropy available

Dit betekende dat de kernel niet netjes was gecrasht. Ik vermoedde dat het /dev/urandom-apparaat verantwoordelijk was voor de crash en patchte dit uit de kernel om die hypothese te testen. Dit zorgde ervoor dat het bootproces iets verder kwam, maar niet veel. Weer een dood spoor.

Op dat moment wist ik niet meer waar het precies misging, maar ik wist dat het iets te maken had met de intercepts, aangezien het volledig uitschakelen van de intercepts het probleem oploste. Daarom filterde ik de kernel op verdachte instructies met objdump:

$ aarch64-unknown-linux-gnu-objdump -D -b binary -m aarch64 kernel.orig | grep ctr_el0

Dit leverde 22 matches op, die ik handmatig in het binaire bestand patchte om de correcte waarde terug te geven. Na het booten van de gepatchte kernel kon hij nog steeds niet bij Android komen, maar adb shell werkte, wat betekende dat de patch (tenminste gedeeltelijk) succesvol was. Daarna kon ik de patch reduceren tot slechts een paar "hot" instructies, maar ik had nog steeds geen idee wat de werkelijke reden voor de hangups was.

Ik begon te vermoeden dat mijn hypervisor en de kernel elkaars geheugen overschreven. Daarom besloot ik de afhandeling voor mrs x3, ctr_el0 (de instructie in alle "hot" patches die er echt toe deed) in assembly te herschrijven zonder gebruik te maken van geheugen:

// bewaar X0, we gebruiken deze als scratch register
msr tpidr_el2, x0

// lees het Exception Syndrome Register; de waarde waar we in geïnteresseerd zijn is 0x6232c061
// (https://esr.arm64.dev/#0x6232c061)
mrs x0, esr_el2

// vergelijk x0 met 0x6232c061
// ARM ondersteunt geen 32-bit immediates in instructies, dus vergelijk bit-groepen één voor één
sub x0, x0, #0x61
ror x0, x0, #12
sub x0, x0, #0x32c
ror x0, x0, #12
sub x0, x0, #0x62
cbnz x0, 1f // als de ESR incorrect is, ga naar de generieke handler

// verhoog de opgeslagen PC met 4, om rekening te houden met de lengte van de instructie
mrs x0, elr_el2
add x0, x0, #4
msr elr_el2, x0

// herstel de opgeslagen X0, voer de gevraagde leesactie uit en keer terug naar de aanroeper
mrs x0, tpidr_el2
mrs x3, ctr_el0
eret

1:
// herstel x0 voordat we doorgaan naar de generieke handler
mrs x0, tpidr_el2

En... het werkte! Op dat moment wist ik dat de logica zelf correct was en dat de C-handler op någon manier problemen veroorzaakte, dus begon ik verder te bisecten.

if(esr == 0x6232c061)
{
  asm volatile("mrs %0, ctr_el0":"=r"(regs[3]));
  return 4;
}

...werkte. Ik zocht het adres van de msraccessor op die werd gebruikt voor het lezen van CTREL0, en vond dat deze zich op msr_accessors+0x28 bevond.

if(esr == 0x6232c061)
{
  regs[3] = ((uint32_t(*)(void))(msr_accessors+5/*8 bytes per element*/))();
  return 4;
}

...werkte niet. Was de relocatie de schuldige? Ik controleerde de relocatiecode en probeerde de linkage base gelijk te stellen aan het werkelijke laadadres, maar zonder succes.

Toen besloot ik het linker-script te gebruiken om dit uit te splitsen in een "nieuw" symbool:

get_ctr_el0 = msr_accessors + 0x28;
if(esr == 0x6232c061)
{
  uint32_t get_ctr_el0(void);
  regs[3] = get_ctr_el0();
  return 4;
}

En dit draaide correct. Ik had nu twee versies van semantisch equivalente code, waarvan er slechts één werkte. Tijd voor binaire bisectie!

Binaire bisectie: een eenvoudige variant van ICE

Op dat moment splitste ik de problematische code uit in een aparte functie:

static __attribute__((noinline,optimize(3))) void handle_mrs_x3_ctr_el0(uint64_t* regs)
{
  // asm volatile("mrs %0, ctr_el0":"=r"(regs[3])); // werkt
  // regs[3] = get_ctr_el0(); // werkt ook
  regs[3] = ((uint32_t(*)(void))(msr_accessors+5))(); // werkt niet
}

En ik riep deze aan vanuit de hoofdhandler:

if(esr == 0x6232c061)
{
  handle_mrs_x3_ctr_el0(regs);
  return 4;
}

Het probleem deed zich nog steeds voor, wat betekende dat ik me nu kon concentreren op één enkele functie. In eerste instantie dacht ik dat de codegrootte de schuldige kon zijn, dus voegde ik een reeks NOPs toe aan het begin van de functie, maar zonder resultaat. Toen disassemblede ik de code.

Werkende versie:

0000000040204fc0 <handle_mrs_x3_ctr_el0>:
40204fc0:   a9be7bfd        stp     x29, x30, [sp, #-32]!
40204fc4:   910003fd        mov     x29, sp
40204fc8:   f9000bf3        str     x19, [sp, #16]
40204fcc:   aa0003f3        mov     x19, x0
40204fd0:   94000ab6        bl      40207aa8 <get_ctr_el0>
40204fd4:   2a0003e0        mov     w0, w0
40204fd8:   f9000e60        str     x0, [x19, #24]
40204fdc:   f9400bf3        ldr     x19, [sp, #16]
40204fe0:   a8c27bfd        ldp     x29, x30, [sp], #32
40204fe4:   d65f03c0        ret

Defecte versie:

0000000040204fc0 <handle_mrs_x3_ctr_el0>:
40204fc0:   a9be7bfd        stp     x29, x30, [sp, #-32]!
40204fc4:   f0000001        adrp    x1, 40207000 <phys_ceiling_names>
40204fc8:   f944f821        ldr     x1, [x1, #2544]
40204fcc:   910003fd        mov     x29, sp
40204fd0:   f9000bf3        str     x19, [sp, #16]
40204fd4:   aa0003f3        mov     x19, x0
40204fd8:   9100a021        add     x1, x1, #0x28
40204fdc:   d63f0020        blr     x1
40204fe0:   2a0003e0        mov     w0, w0
40204fe4:   f9000e60        str     x0, [x19, #24]
40204fe8:   f9000bf3        ldr     x19, [sp, #16]
40204fec:   a8c27bfd        ldp     x29, x30, [sp], #32
40204ff0:   d65f03c0        ret

Ik begon de functies in assembly te unifyen, waarbij ik zorgde dat de werkende versie bleef werken en de defecte versie defect bleef, totdat ik bij dit punt kwam:

handle_mrs_x3_ctr_el0:
stp x29, x30, [sp, #-32]!
mov x29, sp
str x19, [sp, #16]
mov x19, x0
adr x0, get_ctr_el0 // laad x0 met het adres van get_ctr_el0
#if 0 // defecte versie
blr x0 // roep de functie aan op het adres opgeslagen in x0
#else // werkende versie
bl get_ctr_el0 // roep de functie get_ctr_el0 aan
#endif
mov w0, w0
str x0, [x19, #24]
ldr x19, [sp, #16]
ldp x29, x30, [sp], #32
ret

Het is duidelijk dat beide versies semantisch equivalent zijn. Dit betekende dat ofwel iemand de code op dat exacte moment onderbreekt en faalt de registers te herstellen, of dat er een microarchitecturale bug is. Ik wees de eerste mogelijkheid snel af: op ARM64 is het enige dat meer privileges heeft dan een hypervisor de TrustZone, en die had geen reden om interrupts te ontvangen. Ik ging uit van het laatste. De SoC in deze telefoon is een MediaTek MT6735 met Cortex-A53 cores. Samen met Alisa heb ik de lijst met Cortex-A53 errata doorgenomen, maar we vonden niets dat hierop leek.

En toen herinnerde ik me een bepaalde opmerking in de Linux-broncode. Op sommige x86-systemen zou speculatieve toegang tot bepaalde MMIO-registers ervoor zorgen dat het systeem uitschakelde. Ik bedacht een gedachte-experiment: als dit hier ook het geval was, hoe zou ik dat voorkomen? De makkelijkste manier om elke toegang tot een fysiek adres te voorkomen, inclusief speculatieve toegang, is door het adres nooit te mappen. Maar dat zou interfereren met het eigen gebruik van de MMIO-ruimte door de hypervisor (die uitgaat van een 1:1 mapping van de gehele adresruimte). Ik overwoog een "lazy paging"-scenario, waarbij ik geheugenbereiken die architectonisch werden benaderd pas op dat moment zou mappen (CPU's geven geen page faults bij speculatieve toegang), en toen viel het me binnen.

Hoe verschillen de twee instructies? blr x0 is dynamisch, bl getctrel0 is statisch. Wat is het verschil tussen dynamische dispatch en statische branches? Dynamische dispatch maakt gebruik van branch prediction; statische branches weten vooraf waar ze heen gaan en kunnen niet foutief worden voorspeld.

Waar zou een instructie naar kunnen mispredicten? Waarschijnlijk naar een null-pointer, 0x0. Het kan immers geen adres uit het niets creëren. Wat heb ik in mijn 1:1 mapping op 0x0? De bootrom, die tijdens de bootloader-initialisatie wordt vergrendeld.

...En toen besefte ik het. De speculatieve toegang waar ik tegen vocht waren geen data-toegangen, maar instructie-fetches. Dat betekende dat ik de pagina's alleen maar als niet-uitvoerbaar hoefde te markeren. Dat deed ik en... het systeem bootte voor het eerst in een half jaar volledig door tot Android, zonder enige patches in de kernel!

Nadat ik wist waar ik naar moest zoeken, leverde een snelle Google-zoekopdracht op "arm64 speculative instruction fetch mmio" de volgende parel uit de officiële ARM-documentatie op:

Er is hier een subtiel onderscheid dat gemakkelijk over het hoofd wordt gezien. Het markeren van een regio als 'Device' voorkomt alleen speculatieve data-toegangen. Het markeren van een regio als niet-uitvoerbaar voorkomt speculatieve instructie-toegangen. Dit betekent dat een regio als zowel 'Device' als niet-uitvoerbaar moet worden gemarkeerd om elke speculatieve toegang te voorkomen.

Meer dan alleen beveiliging

Als iemand met een hacker-achtergrond heb ik Data Execution Prevention (DEP) altijd beschouwd als een exclusieve beveiligingsmaatregel, bedacht om stack overflow-aanvallen te bestrijden. Daarom had ik me er niet mee beziggehouden: de hypervisor was nooit bedoeld voor productiegebruik, dus zou defense-in-depth overengineering zijn.

Maar het blijkt dat het op ARM helemaal niet over beveiliging gaat – in plaats daarvan is het in feite een attribuut-bit. ARM garandeert dat er voor regio's die zijn gemapt als Device-geheugen (moderne term voor MMIO) geen speculatieve toegangen zullen zijn. Dit geldt echter alleen voor data-toegangen; instructie-fetches behandelen elk uitvoerbaar geheugen als 'Normal memory'. De enige manier om te voorkomen dat een geheugenregio speculatief uitvoerbaar is, is door te voorkomen dat deze überhaupt uitvoerbaar is.

Een mogelijke workaround, voor het geval je code moet uitvoeren vanuit Device-geheugen, zou zijn om de Icache uit te schakelen voor de duur van de uitvoering. De ARM-documentatie stelt echter dat dergelijke toegangen nog steeds illegaal zijn en raadt dit af.

Bovendien, aangezien ik toch een manier moest implementeren om niet-uitvoerbaar geheugen te mappen, besloot ik al het geheugen behalve de payload als niet-uitvoerbaar te mappen. Mijn hypervisor heeft nu eindelijk ook een beetje defense-in-depth!