RISC-V での簡単なアンチ逆アセンブル

Posted by 中川 on 2019-08-22
リバースエンジニアリング

はじめに

FFRI リサーチエンジニアの中川です。今回は小ネタではありますが、RISC-V において使えるアンチ逆アセンブルについて簡単に紹介します。

RISC-V について知らない方は、以前公開した記事に簡単にまとめましたので、そちらをご参照ください。

RISC-VのC拡張について

アンチ逆アセンブルを紹介する前に、RISC-V の C 拡張について簡単に説明します。

RISC-V の命令長は基本 32bit ですが、頻繁に使われる命令に関しては同等の操作を行える 16bit の縮約命令を利用することができます (ただし、C 拡張をサポートしている場合に限られます)。addi sp,sp,-176 というスタックポインタを操作する命令を例にとって見てみます。以下のようなアセンブリを書いてコンパイルします。

1
2
3
4
5
// test.s
_start:
addi sp, sp, -176
.option norvc
addi sp, sp, -176

.option norvc というディレクティブがありますが、これはアセンブラに縮約命令を使わないように指示するディレクティブです。これが指定される前と後で命令長が変わることを逆アセンブル結果を見て確認してみましょう。

1
2
3
4
5
6
7
8
9
10
$ gcc -c hoge.s
$ objdump -d hoge.o
hoge.o: file format elf64-littleriscv


Disassembly of section .text:

0000000000000000 <_start>:
0: 7171 addi sp,sp,-176
2: f5010113 addi sp,sp,-176

2 列目を見ると、同じ addi sp,sp,-176 という命令であるにもかかわらず、命令長が違っているのがわかります。

実際に実アプリケーションでどの程度、この縮約命令が使われているのかも見てみましょう。以下には、Fedora 30 に含まれる ls のバイナリを逆アセンブルした結果を示します。

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
# objdump -d /bin/ls | less
...
00000000000040f0 <main@@Base>:
40f0: 7171 addi sp,sp,-176
40f2: f506 sd ra,168(sp)
40f4: f122 sd s0,160(sp)
40f6: ed26 sd s1,152(sp)
40f8: e94a sd s2,144(sp)
40fa: e54e sd s3,136(sp)
40fc: e152 sd s4,128(sp)
40fe: fcd6 sd s5,120(sp)
4100: f8da sd s6,112(sp)
4102: f4de sd s7,104(sp)
4104: f0e2 sd s8,96(sp)
4106: ece6 sd s9,88(sp)
4108: e8ea sd s10,80(sp)
410a: e4ee sd s11,72(sp)
410c: 00019797 auipc a5,0x19
4110: 5847b783 ld a5,1412(a5) # 1d690 <__stack_chk_guard@GLIBC_2.27>
4114: 639c ld a5,0(a5)
4116: 892a mv s2,a0
4118: 6188 ld a0,0(a1)
411a: fc3e sd a5,56(sp)
411c: 8a2e mv s4,a1
...

2 列目を見ると、ほとんどが 16bit の命令になっていて、一部 32bit の命令になっているぐらいだということがわかります。このように、縮約命令があることで、非常に効果的に命令バイトサイズを削減できます。どの程度命令サイズを削減できるのかについては、文献 [1] に詳しく記載があるので、興味のある方はそちらも合わせてご参照ください。

この C 拡張を利用し、アンチ逆アセンブルを実現してみます。

アンチ逆アセンブルの方針

RISC-V のアンチ逆アセンブルについて説明する前に、x86 で利用されているアンチ逆アセンブルについて紹介します。

x86 のように可変長の命令セットの場合、複数通りに命令を解釈できるバイト列が存在します。x86 でアンチ逆アセンブルを実現する場合にはこの性質を利用します。具体例を見たほうが早いので、Practical Malware Analysis [2] に掲載されている例を出しますと

1
2
3
4
5
6
  jmp short near ptr loc_2+1 ; 次のcall命令の先頭から 1 バイト進めたアドレスにジャンプ
loc_2:
call near ptr 15FF1A71h
or [ecx], dl
inc eax
db 0

一見問題なく逆アセンブルできているように見えますが、謎の jmp 命令が見えます。このジャンプ先ですが、次の call 命令の先頭から 1 バイト先のアドレスになっています。そのため、上記の逆アセンブル結果で見えている call or inc などの命令は実行されず、実際に実行されるアセンブリは以下のようなものになります。

1
2
3
4
5
  jmp short loc_3
db 0E8h ; 逆アセンブル結果を騙すための不要なデータ (以下ではガベージバイトと呼ぶ) が埋め込まれており、逆アセンブル結果がおかしくなっていた
loc_3:
push 2Ah ; 第一引数をpushして
call Sleep 1 ; Sleep関数をcall

このように、実行させたい命令が埋め込まれている別の命令を挿入し、ガベージバイト分だけジャンプすることにより、逆アセンブラを騙すことができます。

RISC-V でも、32bit 命令の上位 16bit に縮約命令として実行させたい命令を埋め込みジャンプすることで、同じように逆アセンブラを騙すことができます。

RISC-V でのアンチ逆アセンブル

今回は以下のコードにアンチ逆アセンブルを施します。

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

extern void fizzbuzz(int in);

int main() {
for (int i = 1; i < 100; i++) {
printf("%d ", i);
fizzbuzz(i);
}
}
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
// fizzbuzz.s
.globl fizzbuzz
fizzbuzz:
li a5,15
remw a5,a0,a5
beqz a5,.puts_fizz_buzz
li a5,5
remw a5,a0,a5
beqz a5,.puts_buzz
li a5,3
remw a0,a0,a5
bnez a0,.puts_empty
lla a0,.fizz_str
tail puts@plt
.puts_buzz:
lla a0,.buzz_str
tail puts@plt
.puts_fizz_buzz:
lla a0,.fizz_buzz_str
tail puts@plt
.puts_empty:
lla a0,.empty_str
tail puts@plt
.fizz_buzz_str:
.string "Fizz Buzz"
.zero 6
.buzz_str:
.string "Buzz"
.zero 3
.fizz_str:
.string "Fizz"
.zero 3
.empty_str:
.string ""
.zero 7

アセンブリで書いた FizzBuzz です。「FizzBuzz って何?」という人のために簡単に説明をしておくと、与えられた数字が 3 で割り切れる場合には Fizz、5 で割り切れる場合には Buzz、両方で割り切れる場合には FizzBuzz と表示されるプログラムのことです。

コンパイルして実行すると以下のような表示が出ます。

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
$ gcc fizzbuzz.s main.c -o fizzbuzz.out
$ ./fizzbuzz.out
1
2
3 Fizz
4
5 Buzz
6 Fizz
7
8
9 Fizz
10 Buzz
11
12 Fizz
13
14
15 Fizz Buzz
16
17
18 Fizz
19
20 Buzz
21 Fizz
22
23
24 Fizz
25 Buzz
26
27 Fizz
28
29
30 Fizz Buzz
31
32
33 Fizz

まず、アンチ逆アセンブルの対象とする命令を決めます。このままだとどの命令が 16bit の縮約命令になっているのかが判断できないので、実行バイナリを objdump した結果を見ます。

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
0000000000000676 <fizzbuzz>:
676: 47bd li a5,15 ; <-- 今回アンチ逆アセンブルの対象とする 16bit 命令
678: 02f567bb remw a5,a0,a5
67c: c78d beqz a5,6a6 <.puts_fizz_buzz>
67e: 4795 li a5,5
680: 02f567bb remw a5,a0,a5
684: cb99 beqz a5,69a <.puts_buzz>
686: 478d li a5,3
688: 02f5653b remw a0,a0,a5
68c: e11d bnez a0,6b2 <.puts_empty>
68e: 00000517 auipc a0,0x0
692: 04850513 addi a0,a0,72 # 6d6 <.fizz_str>
696: ebbff06f j 550 <puts@plt>

000000000000069a <.puts_buzz>:
69a: 00000517 auipc a0,0x0
69e: 03450513 addi a0,a0,52 # 6ce <.buzz_str>
6a2: eafff06f j 550 <puts@plt>

00000000000006a6 <.puts_fizz_buzz>:
6a6: 00000517 auipc a0,0x0
6aa: 01850513 addi a0,a0,24 # 6be <.fizz_buzz_str>
6ae: ea3ff06f j 550 <puts@plt>

00000000000006b2 <.puts_empty>:
6b2: 00000517 auipc a0,0x0
6b6: 02c50513 addi a0,a0,44 # 6de <.empty_str>
6ba: e97ff06f j 550 <puts@plt>

li a5,15 という 16bit の命令が見えます。これを 32bit の別の命令に埋め込み、その上位 16bit にジャンプするようにしてみます。

li a5,15 のオペコード (0x47bd) が上位 16bit に埋め込まれた命令を探してみると beq s10, s11, pc + 3168 (命令オペコードが0x47bd00e3) などが見つかります。そこで、beq s10, s11, pc + 3168 を埋め込み、合わせてその直前に 4 バイトのジャンプ命令を埋め込みます。

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
        .globl  fizzbuzz
fizzbuzz:
.half 0xa011 ; j pc + 0x4のオペコード
.word 0x47bd00e3 ; li a5,15が上位16bitに埋め込まれている
remw a5,a0,a5
beqz a5,.puts_fizz_buzz
li a5,5
remw a5,a0,a5
beqz a5,.puts_buzz
li a5,3
remw a0,a0,a5
bnez a0,.puts_empty
lla a0,.fizz_str
tail puts@plt
.puts_buzz:
lla a0,.buzz_str
tail puts@plt
.puts_fizz_buzz:
lla a0,.fizz_buzz_str
tail puts@plt
.puts_empty:
lla a0,.empty_str
tail puts@plt
.fizz_buzz_str:
.string "Fizz Buzz"
.zero 6
.buzz_str:
.string "Buzz"
.zero 3
.fizz_str:
.string "Fizz"
.zero 3
.empty_str:
.string ""
.zero 7

コンパイルしてできた実行ファイルを objdump で逆アセンブルすると

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
00000000000104a0 <fizzbuzz>:
104a0: a011 j 104a4 <fizzbuzz+0x4>
104a2: 47bd00e3 beq s10,s11,11102 <__FRAME_END__+0xaa6> ; li a5,15 命令が上位 16bit に埋め込まれている
104a6: 02f567bb remw a5,a0,a5
104aa: c78d beqz a5,104d4 <.puts_fizz_buzz>
104ac: 4795 li a5,5
104ae: 02f567bb remw a5,a0,a5
104b2: cb99 beqz a5,104c8 <.puts_buzz>
104b4: 478d li a5,3
104b6: 02f5653b remw a0,a0,a5
104ba: e11d bnez a0,104e0 <.puts_empty>
104bc: 00000517 auipc a0,0x0
104c0: 04850513 addi a0,a0,72 # 10504 <.fizz_str>
104c4: f0dff06f j 103d0 <puts@plt>

00000000000104c8 <.puts_buzz>:
104c8: 00000517 auipc a0,0x0
104cc: 03450513 addi a0,a0,52 # 104fc <.buzz_str>
104d0: f01ff06f j 103d0 <puts@plt>

00000000000104d4 <.puts_fizz_buzz>:
104d4: 00000517 auipc a0,0x0
104d8: 01850513 addi a0,a0,24 # 104ec <.fizz_buzz_str>
104dc: ef5ff06f j 103d0 <puts@plt>

00000000000104e0 <.puts_empty>:
104e0: 00000517 auipc a0,0x0
104e4: 02c50513 addi a0,a0,44 # 1050c <.empty_str>
104e8: ee9ff06f j 103d0 <puts@plt>

00000000000104ec <.fizz_buzz_str>:
104ec: 6946 ld s2,80(sp)
104ee: 7a7a ld s4,440(sp)
104f0: 4220 lw s0,64(a2)
104f2: 7a75 lui s4,0xffffd
104f4: 007a c.slli zero,0x1e
104f6: 0000 unimp
104f8: 0000 unimp

li a5,15 命令が消えてしまい、 jbeq 命令の2つになっているのがわかります。このように逆アセンブル結果を騙すことができます。

今回は 4 バイトの相対ジャンプ命令を埋め込み、32bit 命令の上位 16bit にジャンプする形式を取りましたが、不要な命令をジャンプ命令と実行させたい命令との間に何命令か追加することにより、解析をより困難にすることも可能です。

まとめ

今回は RISC-V で縮約命令を使ったアンチ逆アセンブルについて紹介しました。今回紹介したアンチ逆アセンブルは、耐解析の技術の一つとして今後利用できるのではないかと思われます。また、今回は 16bit 命令を 32bit 命令の上位に埋め込むことを実行しましたが、32bit の命令を 2 つ連結させ、下位 16bit をガベージバイトとし、32bit + 16bit の命令として解釈させるというやり方もあり得ると思います。別のアンチ逆アセンブル手法の考案に関しては今後の課題です。

参考文献

[1] The Renewed Case for the Reduced Instruction Set Computer: Avoiding ISA Bloat with Macro-Op Fusion for RISC-V, https://people.eecs.berkeley.edu/~krste/papers/EECS-2016-130.pdf
[2] Practical malware analysis: the hands-on guide to dissecting malicious software. No Starch Press, 2012.