平成最後の日ということで、てきとーに何か書き残しておく。
マルウェア解析をしていると、マルウェアがマシンを破壊してもよいように、動的解析では仮想環境を使うが、それも面倒な時がある。 その為、自作x86エミュレータを暇な時にせっせと作っていたのだが、ようやく、骨子ができてきた。
作った動機
動機としては、仕事で自分用に使うという他に2つ。
- エミュレータを大量のjump文抜きで書きたい。
- native用のjvmtiみたいなものを作りたい。
1に関してだが、vmやemulatorのコードは長い関数内に大量の条件文があり、それが嫌だということ。 分岐の連続で書くと、大量のopcodeのパターンマッチとなり、最初にマッチする命令はいいが、あとに定義された命令は、当然処理は遅くなる。 (追記:gccだと-O0でswitch文内のcase文が4つ以上だとData Sectionにjump tableを構築する様です/ですので後にマッチするモノが遅くなる事はないようでした) また、可読性もよくないし、命令の網羅率(どこまで書いたか)もぱっと見わかりずらい。
その為、以下の様に実装した。
関数テーブルを用意し、各処理を書く。
opcodeがそのアドレスを指定する様に、レジスタの値を書き換える。
変更されたレジスタを絶対アドレスとしてcallする。
アセンブラで書くと以下の様。
mov rax,[_context._opcode_table] mov rbx,0x00 mov rdx,[_rip] mov bl,[rdx] shl rbx,0x03 adc rax,rbx call [rax]
[context.opcode_table]は関数テーブルの先頭を指し、ripの命令をfetchしてきて、address byte分(この場合8byte)掛けて,その関数テーブル先頭と足し合わせる。 最後のcall [rax]が絶対callとなり、別途定義している個々のopcodeの処理に移る。
なお、0x0fが付く拡張命令に関しては、[context.opcode_table]を別途設けることで、対応している。
2のjvmtiに関しては、あまり紹介記事も無いのだが、javaのinstrumentationツールで20年近く前からある。JITの特性を生かして、様々なレベルで、コールバックイベントをくれる。例えば、JITがコード生成する度、生成されたnativeの関数が呼ばれる度、などに任意にコールバックイベントをよこしてくれる。初めてこのツールを使った時、nativeのコードに対しても同じような機能があれば、と思った。
この様なinstrumentationは、javaがvmという形態だからこそできること、では無い訳で、nativeでもできる訳だが、これは、生のCPU上で実行する前提だと難しい。 例えば、各関数が呼ばれる度に何かをするということは、デバッガの助けなしにはcではできないだろう。私は、あまりデバッガは好まないので、自分で作ることにした。
今回、emulatorを作る動機として、最終目標として、既存OSを動かす、、というより、多少遅くても、細かいレベルでプログラム解析をすることがあった。 その際、ただのemulatorだと、emulatorが生成するコードに制約が無く、締まりが悪い。そこで、qemuの様に、emulatorがすぐ各命令を実行するのではなく、実行するprimitive命令を用意した。x86-64は特に命令の意味的な重なり(addがmovを含む等)が多く、コード生成型でないと綺麗に書けない気がする。
直交性のある処理をしっかり書く
書いている中で、一番重要だと思ったことは、直交的な処理(例えば、mod_reg_rm)の処理を適切に書くこと。emulatorを書くというと、個々のinstructionに目を奪われがちだが、重要なのは、それらを還元し、その共通化部分が保守性が高くかけているかということだとおもった。 この部分、最初、実は、cで書いたが、デリケートな部分でもあるので、最終的にアセンブラで書いた。
例えば、opcode 0x83の処理のコード。
_0x83_arith_imm: push rbp mov r8,_op01_f_base call _set_imm_op_base call _get_mod_op_rm call _set_scale_index_base call _fetch_displacement_by_mod call _load_rm_by_mod call _mov_res_to_arg1 call _fetch8_imm_set_to_arg2 call [_context._imm_op] call _mov_rm_to_arg1 call _mov_res_to_arg2 call _store_or_assign_arg1_by_mod pop rbp ret
個々の関数は長くなるので、割愛するが、多くのopcode(演算系,mov系,shift系)は、
- mod/reg/rmを取得
- scale index baseを計算
- displacementをrmに加算
- rmをロード
- immidiate値を取得
- 演算(opcode自体|regの代わりに)
- rmをストア
のような一連の基本的な処理の中で、一部やらない処理があるというパターンだ。そのようなパターンをdissassembleしながら見つけていくと、書き初めは重いが、後で楽になる。
host/guestアドレス変換
エミュレータで特に、面白いhost/guestアドレス変換。基本的に、ページ単位で、alignmentがズレない様に実装というオーソドックスなものになった。 なお、最初、各命令のmod=0b00/0b01/0b10の場合、つまり、メモリアクセスが発生する度に、この変換が必要だと、思ってたが、実は、rsp(esp),rip(eip)を初期状態でホストアドレスに設定しておけば、レジスタ間接のメモリアクセスは、変換が殆ど必要無い。 変換が必要なのは、
- 絶対アドレスへのアクセス
- guestアドレスがページを跨いで(別のページに相対的に)アクセス
1に関しては、x86(32)/64で絶対アドレスの頻度が変わってくる為、相対アドレスの多いx64の方が、ホストアドレスを予めレジスタ値に設定しておくインセンティブは大きいと思う。
2の場合は、例えば、ページが4096byte単位で、ゲストpage1,ゲストpage2が並んで確保されているが、ホスト上では、対応するページが並んでいないとする。 今、ゲストpage1の最終instructionを読み取り、次のinstructionに進みたいとする。そこで、hostのメモリを前提として、実装していると、次のpageがなかったり、別のpageを踏んだりする。その為、page境界の相対アドレスでは、常に、host address -> guest address -> guest別page -> guest別pageに対応するhostのpageちった処理をする必要がある。
アセンブリについて
最後に、各命令の処理を含め、重要な部分は、アセンブリで書いたが、アセンブリを書く上で、先にやっていた方がよいと思った事が2つあった。
- 機械語をそのまま書く
- 自作デバッグ関数を作る
1についてだが、私は、先に機械語を直書きして、それを実行するという書き方で、多少プログラムを書いていた。それも中々醍醐味があるのだが、書いていて辛いのが、 相対オフセットの計算を自分で数えなければならないこと。この、"数える"という作業が、間に入ってくると、はっきり申して、思考の中断である。あと、mod/reg/rmのところなど、2進数->16進数も、結構間違えたりする(これはできる人には苦にならないのかもしれないが..)。それに比べると、アセンブリ言語は楽だ。また、高級言語の生成先としてアセンブリをみるだけでなく、機械語の生成方法として使う、という観点が得られると、モチベーションの持ち方が多少変わってくる気はする。
2について、機械語を書いてよくわかったのだが、書いてすぐ検証するまでに、検証ポイントが多くなると、自然と、手が止まり、考えている時間が多くなる。そうすると、私の場合表向き全く進捗がないことが、逆に不安になったりする。人によって、トップダウンで考えて、正しい機械語を、一気に書けるタイプの人はいい。しかし、私の場合、 やはり、下らないミスも多く、やはり、間違えてもincrementalにやって行った方が効率が良いとわかった。そんなこんなで、アセンブリを書き始めた時、真っ先に、必要だと思ったのは、レジスタの中身をプリントする関数だった。これを書いた後で、開発の効率が速くなった。
print: push rbp lea r10,[_reg_size8+0x0a] mov r9,r8 call print1 mov r8,r9 sar r8,8 mov r9,r8 call print1 mov r8,r9 sar r8,8 mov r9,r8 call print1 mov r8,r9 sar r8,8 mov r9,r8 call print1 call _reg_save call _write call _reg_regain pop rbp ret print1: push rbp sub r10,1 mov r9b,r8b call print1.f1 mov byte [r10],r8b mov r8b,r9b sar r8b,4 call print1.f1 sub r10,1 mov byte [r10],r8b ;; mov byte [2+reg_size8],0x31 pop rbp ret ;; and r8b,0xf0 .f1: and r8b,0x0f cmp r8b,0x0a jae print1.more_than_0x0a jmp print1.less_than_0x0a .more_than_0x0a: add r8b,0x57 ret .less_than_0x0a: add r8b,0x30 ret _reg_save: mov r12,rax mov r13,rdi mov r14,rsi mov r15,rdx ret _reg_regain: mov rax,r12 mov rdi,r13 mov rsi,r14 mov rdx,r15 ret _write: ;; mov rax, 0x2000004 ;write mov rax, 0x0000001 ;write mov rdi, 1 ; stdout lea rsi, [_reg_size8] mov rdx, 0x0b ;; rcx,r8,r9 is another register ;; mov rdx, _reg_size8.len syscall ret
通常の言語と同じように、どこでもprintを挟めるという安心感があるとないとでは開発スタイル、速度も変わってくる。 アセンブリからprintfなども呼べるが、依存ライブラリの必要性のなさや、カスタマイズ性の高さから、アセンブリを書く場合、自分用utilを多少持っていると、捗ると思われる。