はじめに
FFRI リサーチエンジニアの中川です。今回は小ネタではありますが、RISC-V において使えるアンチ逆アセンブルについて簡単に紹介します。
RISC-V について知らない方は、以前公開した記事に簡単にまとめましたので、そちらをご参照ください。
RISC-VのC拡張について
アンチ逆アセンブルを紹介する前に、RISC-V の C 拡張について簡単に説明します。
RISC-V の命令長は基本 32bit ですが、頻繁に使われる命令に関しては同等の操作を行える 16bit の縮約命令を利用することができます (ただし、C 拡張をサポートしている場合に限られます)。addi sp,sp,-176
というスタックポインタを操作する命令を例にとって見てみます。以下のようなアセンブリを書いてコンパイルします。
// test.s _start: addi sp, sp, -176 .option norvc addi sp, sp, -176
.option norvc
というディレクティブがありますが、これはアセンブラに縮約命令を使わないように指示するディレクティブです。これが指定される前と後で命令長が変わることを逆アセンブル結果を見て確認してみましょう。
$ 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 のバイナリを逆アセンブルした結果を示します。
# 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] に掲載されている例を出しますと
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
などの命令は実行されず、実際に実行されるアセンブリは以下のようなものになります。
jmp short loc_3 db 0E8h ; 逆アセンブル結果を騙すための不要なデータ (以下ではガベージバイトと呼ぶ) が埋め込まれており、逆アセンブル結果がおかしくなっていた loc_3: push 2Ah ; 第一引数をpushして call Sleep 1 ; Sleep関数をcall
このように、実行させたい命令が埋め込まれている別の命令を挿入し、ガベージバイト分だけジャンプすることにより、逆アセンブラを騙すことができます。
RISC-V でも、32bit 命令の上位 16bit に縮約命令として実行させたい命令を埋め込みジャンプすることで、同じように逆アセンブラを騙すことができます。
RISC-V でのアンチ逆アセンブル
今回は以下のコードにアンチ逆アセンブルを施します。
// main.c #include <stdio.h> extern void fizzbuzz(int in); int main() { for (int i = 1; i < 100; i++) { printf("%d ", i); fizzbuzz(i); } }
// 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 と表示されるプログラムのことです。
コンパイルして実行すると以下のような表示が出ます。
$ 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 した結果を見ます。
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 バイトのジャンプ命令を埋め込みます。
.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 で逆アセンブルすると
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
命令が消えてしまい、 j
と beq
命令の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.