
Uma interrupção muito muito muito muito muito muito muito longa.
Explorando o System Management Mode com uma interrupção muito muito muito muito muito muito muito longa.
Acontece que você pode quebrar o SMM — o ambiente de execução seguro e ultraprivilegiado que roda invisivelmente em segundo plano em cada CPU x86 — com nada mais do que uma instrução de máquina absurdamente longa.
O SMM exige que todos os núcleos estejam em SMM ou fora do SMM ao mesmo tempo. Seu modelo de segurança não funciona sem isso — quando uma thread entra no SMM, ela faz todas as outras entrarem também.
Para quebrar isso, tudo o que precisamos é de alguém ocupado demais para perceber que deveria entrar no SMM.
Funciona mais ou menos assim:
core 0 - start a long instruction
|
|
|
core 1 - invite core 0 to smm
|
|
|
core 1 - enter smm
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - wait for core 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core 1 - give up
core 1 - do secret smm stuff
core 1 - finish smm
|
|
|
core 0 - join smm
Neste ponto, o core 1 está fora da SMM enquanto o core 0 está dentro, permitindo que o core 1 ataque o core 0. Eis o detalhe: para que isso funcione, precisamos de uma instrução muito, muito, muuuuito longa — mais longa do que qualquer instrução jamais deveria levar. A maioria das instruções de máquina em uma CPU moderna é rápida: add leva 1 ciclo. Para fazer o core 1 desistir de esperar pelo core 0, precisamos de uma instrução no core 0 que leve cerca de 4.000.000.000 de ciclos — mais de 1 segundo de tempo real.
O firmware x86 executa o seguinte código quando um núcleo da CPU entra na SMM:
for (Timer = StartSyncTimer ();
!IsSyncTimerTimeout (Timer, mTimeoutTicker) && SyncNeeded;
)
{
mSmmMpSyncData->AllApArrivedWithException = AllCpusInSmmExceptBlockedDisabled ();
if (mSmmMpSyncData->AllApArrivedWithException) {
break;
}
CpuPause ();
}
O código aguarda que todos os núcleos entrem no SMM, ou por até 1 segundo, o que ocorrer primeiro. Para fazer com que um núcleo execute código SMM enquanto outro núcleo permanece executando fora do SMM, precisamos que esse núcleo externo permaneça ininterrupto durante o segundo inteiro — um SMI é capturado em um limite de instrução, portanto qualquer lacuna entre duas instruções permite que o SMI pendente puxe o núcleo para o SMM. O atraso, portanto, precisa ser uma única instrução: uma operação ininterrupta que dure mais que o encontro de um segundo.
Existem muitas maneiras de alcançar a instrução proibida de 1 segundo, e a abordagem exata varia de plataforma para plataforma. Mas, em linhas gerais: encontre um endereço MMIO de alta latência e então convença a CPU a ler dele o mais lentamente possível — abuse de uma região não documentada que responde a leituras de forma extremamente lenta, use a carga mais ampla que a ISA lhe oferecer para mover o máximo de bytes possível através dela em uma única instrução, e deixe os outros núcleos disputarem o mesmo barramento para torná-la ainda mais lenta. Uma leitura, uma instrução, e a CPU fica presa segurando-a por boa parte de um segundo.
A prova de conceito fornecida é ajustada para um Zen 3 Ryzen 7 5800H, onde uma carga ampla de xmm a partir de MMIO lento em 0xfcc68860 trava por tempo suficiente para quebrar o encontro de todos os núcleos:
mov $0xfcc68860, %rsi ; the target MMIO address
vmovdqu (%rsi), %xmm0 ; the very, very long load
O PoC explora isso colocando dois núcleos um contra o outro. Um núcleo é mantido fora do SMM pela instrução longa — um loop apertado no carregamento muito lento:
/* the victim core: spin on the ~1-second load, too busy to answer the SMI */
for (;;)
asm volatile ("vmovdqu (%0), %%xmm0" :: "r"(mmio) : "xmm0");
Enquanto isso, outro núcleo arma os contadores SMI por núcleo:
#define MSR_PERF_CTL0 0xc0010200 /* AMD core perf event-select MSR */
#define MSR_PERF_CTR0 0xc0010201 /* the paired 48-bit counter */
for (int cpu = 0; cpu < ACTIVE_CPUS; cpu++) {
msr_write(cpu, MSR_PERF_CTL0, 0x43002b); /* EN | OS | USR | event 0x2b */
msr_write(cpu, MSR_PERF_CTR0, 0); /* zero the count */
}
Em seguida, dispara uma tempestade de SMIs:
asm volatile ("outb %%al, $0xb2" :: "a"(0)); /* kick port 0xb2 -> #SMI */
E lê o tally de cada core de volta:
/* ...fire the storm, then read every core's tally back... */
uint64_t delta = smi_max - smi_min;
if (delta)
puts("!!! a core ran outside SMM");
Se as contagens divergirem, isso significa que um núcleo continuou em execução fora do SMM enquanto os outros foram trazidos para dentro — ele perdeu os SMIs que os demais atenderam.