Notes

Do not read codes but patch binary.

x86-64 emulatorを作っていて思ったこと

平成最後の日ということで、てきとーに何か書き残しておく。

マルウェア解析をしていると、マルウェアがマシンを破壊してもよいように、動的解析では仮想環境を使うが、それも面倒な時がある。 その為、自作x86エミュレータを暇な時にせっせと作っていたのだが、ようやく、骨子ができてきた。


作った動機

動機としては、仕事で自分用に使うという他に2つ。

  1. エミュレータを大量のjump文抜きで書きたい。
  2. native用のjvmtiみたいなものを作りたい。

1に関してだが、vmやemulatorのコードは長い関数内に大量の条件文があり、それが嫌だということ。 分岐の連続で書くと、大量のopcodeのパターンマッチとなり、最初にマッチする命令はいいが、あとに定義された命令は、当然処理は遅くなる。 (追記:gccだと-O0でswitch文内のcase文が4つ以上だとData Sectionにjump tableを構築する様です/ですので後にマッチするモノが遅くなる事はないようでした) また、可読性もよくないし、命令の網羅率(どこまで書いたか)もぱっと見わかりずらい。

その為、以下の様に実装した。

  1. 関数テーブルを用意し、各処理を書く。

  2. opcodeがそのアドレスを指定する様に、レジスタの値を書き換える。

  3. 変更されたレジスタを絶対アドレスとして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系)は、

  1. mod/reg/rmを取得
  2. scale index baseを計算
  3. displacementをrmに加算
  4. rmをロード
  5. immidiate値を取得
  6. 演算(opcode自体|regの代わりに)
  7. rmをストア

のような一連の基本的な処理の中で、一部やらない処理があるというパターンだ。そのようなパターンをdissassembleしながら見つけていくと、書き初めは重いが、後で楽になる。


host/guestアドレス変換

エミュレータで特に、面白いhost/guestアドレス変換。基本的に、ページ単位で、alignmentがズレない様に実装というオーソドックスなものになった。 なお、最初、各命令のmod=0b00/0b01/0b10の場合、つまり、メモリアクセスが発生する度に、この変換が必要だと、思ってたが、実は、rsp(esp),rip(eip)を初期状態でホストアドレスに設定しておけば、レジスタ間接のメモリアクセスは、変換が殆ど必要無い。 変換が必要なのは、

  1. 絶対アドレスへのアクセス
  2. 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. 機械語をそのまま書く
  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を多少持っていると、捗ると思われる。