x86のデバッグレジスタを使ってみたらQEMUが固まる

はじめに

江添さんのブログ「OpenBSD、1985年に追加されたIntelの最新の誇大広告された機能を使わないことにより脆弱性を華麗に回避」で、x86にデバッグレジスタというものがあることを今更知りました。それを使って見ようとQEMUにFreeDOS入れて触ってみたらQEMUが固まった、という話です。

デバッグレジスタとは

デバッグする時に、プログラムの指定した場所に来たら動作を止めたり(ブレークポイント)、メモリの監視をして、ある場所の値の読み書きを検知(ウォッチポイント)したい時がよくあります。これをハードウェア的に支援するのがX86のデバッグレジスタです。デバッグレジスタはDR0からDR7まで8本ありますが、DR4DR5は使いません。DR0からDR3の4本は、監視対象のアドレスを入れます。DR7はデバッグコントロールレジスタと呼ばれ、どのような条件で止めるかを指定します。DR6はデバッグステータスレジスタで、どのブレークポイントで止まったか等が指定されます。

これらのレジスタへの読み書きは特権命令となっており、通常のOSでは読み書きが許されません。ちょっとやってみましょう。デバッグレジスタDR7に`

int
main(void) {
  __asm__("movq %dr7, %rax");
}

これをMac上のg++でコンパイル、実行してみましょう。

$ g++ test.cpp
$ ./a.out
zsh: segmentation fault  ./a.out

つれないお返事です。

ついでに、Windows環境のDEBUGでやってみま・・・あれ?DEBUGが無い!

DEBUGとはMSDOS時代から連綿とサポートされて来た、Microsoft純正のデバッガですが、なんとWindows7から搭載されなくなったそうです。わりと便利なのに残念。まぁどうせ、DEBUGで実行したとしてもSIGSEGVかSIGILLが出るだけでしょうが。

QEMU+FreeDOSの環境構築

現代のOSを使っていると、ユーザから直接特権命令を触ることはできません。触れなくもないですが、かなり面倒なことになります。

「俺も特権命令を使ってみたい!でも面倒なことはしたくない!」

そういう場合は、リングプロテクションの無いOSを使うのが最も簡単です。具体的にはDOSを使います。QEMUというプロセッサエミュレータを使い、FreeDOSというMS-DOS互換のフリーDOS実装を使って遊んでみましょう。

QEMUのインストール

brewで一発です。

$ brew instal qemu

FreeDOSのダウンロード

FreeDOS本家からISOイメージを取ってきます。「CDROM “standard” installer」を落とすのが良いでしょう。本稿執筆時点のバージョンは1.2です。

ディスクイメージの作成

FreeDOSをインストールするハードディスクのイメージを作りましょう。qemu-imgを使います。サイズは200MBもあれば良いと思います。

$ qemu-img create -f raw freedos.img 200M 

FreeDOSのインストール

ダウンロードしたISOイメージと先程作成したディスクイメージを使ってQEMUを起動します。

$ qemu-system-i386 freedos.img -cdrom FD12CD.iso -boot d

これは、「i386の石をエミュレートし、freedos.imgをハードディスクイメージ(Cドライブ)とし、FD12CD.isoをCD-ROM(Dドライブ)に入れた状態で、Dドライブから起動しなさい(-boot d)」という意味です。

あとは本家サイトの説明に従ってインストールするだけです。基本的に「Y」とか「1」とか「ESC」とか押すだけです。

インストールの終了後は

$ qemu-system-i386 freedos.img 

でFreeDOSが実行できます。起動後、どういうオプションで起動するか1〜4で聞かれますが、なんでも良いです。なにもドライバを読み込まない4で起動するといろいろ安定しているという噂ですが、僕の環境では1で問題が起きたことはありません。起動するとプロンプトが出てきます。

image0.png

おおー、昔懐かしいDOS画面!

DEBUGの使い方

さて、早速DEBUGコマンドを使ってみましょう。コマンドプロンプトでdebugと打ち込むとDEBUG.COMが起動します。

C:\> debug
-

この「-」がDEBUGのプロンプトです。例えば現在のレジスタの状態を表示してみましょうか。「r」 と打ち込んでみます(大文字小文字は区別されません)。

image1.png

AXやらBXが全て0になっていますね。コードセグメントは083Fです。 Rの後ろに特定のレジスタを指定すると、そのレジスタの中身が表示されます。これで例えばeaxのような32bitレジスタの値も表示できます。

さて、何かコードを書いてみましょう。例えばAXに1を代入してみましょうか。プログラムを書くのは「a」です。通常、オフセットアドレス100から書き込みますので「a 100」とします。

- a 100
083F:0100 

と、コードセグメント083F、オフセットアドレス0100のところに何か書けるようになりました。mov ax,11と入力し、改行を二回入れてみましょう。

- a 100
083F:0100 mov ax,1
083F:0103
-

さて、プログラムが入力されたかどうか、ダンプしてみましょう。ダンプコマンドは「u」です。100から102までダンプしてみます。

- u 100 102
083F:0100 B80100 MOV AX,0001
-

ちゃんと入力されたようです。

次は実行してみましょう。オフセットアドレス100から103まで実行します。コマンドは「g=100 103」です。スタートアドレスは「=」で指定します。キーボードが正しく設定されていないと「=」が見つからないので注意しましょう。うちのMacの場合は「~」のキーに「=」がいました。

image2.png

正しくMOV AX,1が実行され、AXに1が代入されました。

では、いよいよデバッグレジスタを使ってみましょう。DR7EAXの値に読み出し、その値を表示させます。

image3.png

EAXの値として「00000400」が入りました。これがDR7の持つデフォルトの値です。特権命令である、「デバッグレジスタへの読み書き」がちゃんとできました。これで我々も特権階級の仲間入りです。

デバッグレジスタの使用

注意 少なくともうちの環境では、以下のコードを実行するとQEMUが固まり、Macも操作不能になります。実行は自己責任でお願いします。

デバッグレジスタによりブレークポイントをいれるためには

  • コントロールレジスタCR4の当該フラグを立てる
  • デバッグレジスタDR0に、止めたい場所のリニアアドレスを代入する
  • デバッグコントロールレジスタDR7に、DR0で指定されたアドレスに来たら止めることを指示する

という作業をする必要があります。

コントロールレジスタのデバッグフラグを立てる

x86には制御レジスタ(Control Register)としてCR0からCR4が存在します。このうち、CPUの拡張機能の利用を指示するのがCR4です。それぞれのビットが何を意味するのかは、例えばOS Project Wikiを参照していただくことにして、とりあえずビットの3番(0スタートなのに注意)がDebug Extensionです。これを有効にしましょう。

mov eax,cr4
bts eax,3
mov cr4,eax

CR4の値をEAXに読み込んで、3ビット目を立てて、その値をCR4に返しています。これでDebug Extensionが有効になります。

デバッグレジスタに止めたい場所のリニアアドレスをいれる

今回はとりあえずDR0を使うことにしましょう。ただし、コードを入力している段階では止めたい場所のアドレスがわからないため、とりあえず0を代入して、後で書き直すことにします。

mov eax,0
mov dr0,eax

デバッグコントロールレジスタにフラグをセット

デバッグレジスタの7番、DR7は、デバッグコントロールレジスタです。どのビットが何を意味するのかは、例えばosdev.orgのWikiを参照していただくことにして、とりあえず0番目のビットを立てれば、DR0が示すアドレスの命令が実行されるタイミングで止まります。

mov eax,dr7
bts eax,0
mov dr7,eax

DR7の値をEAXに代入し、0ビット目を立ててからDR7に書き戻しているだけです。DR7には、4つのデバッグレジスタごとに止める条件を「メモリの読み書きにする」もしくは「そのアドレスを実行しようとした時」を選ぶビットがありますが、デフォルト(00)では「そのアドレスの命令を実行しようとしたとき」が条件となります。

リニアアドレスの計算

ここまでの内容を入力してみましょう。ついでにnopをいくつか入れて、最後にint 3を入れておきます。int 3まで来るとプログラムが止まるため、いちいち実行アドレスの終了アドレスを指定しなくて良いので便利です。

入力した画面がこちらです。

image4.png

さて、このオフセットアドレス0120のnopにブレークポイントを置くことにしましょう。いま、セグメンテーションによるアドレスが表示されています。この「0B12:0120」のアドレスをリニアアドレスに変換しなければいけません。このうち「0B12」がセグメントアドレス、「0120」がオフセットアドレスで、セグメントアドレスは環境や実行状況により異なります。

リニアアドレスの計算はいろいろ面倒で、僕もきちんと理解している自信が無いのですが、とりあえず今回はセグメントを16倍してオフセットに足せばOKです。

(0x0b120+0x0120).to_s(16)
=> "b240"

というわけで、先程仮に0を代入していたところにb240を代入しましょう。オフセット10Bのところを書き直します。

-a 10B
0B12:010B mov eax,b240 (改行)
0B12:0111 (改行)
-

これでプログラムが完成しました。表示させて見ましょう。

image5.png

これが、セグメントアドレス「0B12」において、オフセットアドレス「0120」にブレークポイントを置いたコード・・・のはずです。

「はず」というのは、これをそのまま実行すると、QEMUがホストのMacごと固まるからです。

もし、Macで以下を実行しようとする人がいる場合は、そのMacに別のマシンからSSHでリモートログインしておいてください。

- g=100  # オフセットアドレス0100から実行 (固まる)

実行後、全く操作できなくなりますが、リモートログインしたマシンから

$ ps aux |grep qemu
$ kill -KILL (番号)

でqemuのプロセス番号を調べて殺せば復活できます。

まとめ

「QEMUでデバッグレジスタを使って特権階級の仲間入りだぜ」とかやろうとしたらQEMUが固まりました。僕が何か間違ってるのか、それともQEMUが何かおかしいのかはわかりません。だれか詳しい方のフォローをお待ちしております。

どうでもいいですが、中学〜高校生の頃、仲間とMS-DOSでゲームを作って遊んでいました。当時、「アセンブラ2を使えない」というのがわりとコンプレックスでした。当時のマシンは非力で、ライブラリも整備されていなかったため、アセンブラを使わないと画像表示などは遅くて使い物になりません。僕が使っていたのはQuick Basic、そしてC言語に移行しましたが、アセンブラは使えなかったので、他の仲間がアセンブラでバリバリゲームエンジンを組んでいました。何度かアセンブラに挑戦しましたが、VRAMにちっちゃい線を描画するところまでで挫折しました。

アセンブリを「読む」ようになったのは修士の頃で、DEC Alphaのマシンを与えられてからです。C/C++でプログラムを組んだのですが、どうも性能が思ったよりよかったり悪かったりしたのでアセンブリを出力して見ると、コンパイラがそれはそれは頑張って最適化しているのを見て感心したのを覚えています。以来、コンパイラの最適化能力に興味を持ち、アセンブリを読むようになりました。アセンブリを「書く」ようになったのはつい最近です。気がつくと当時中1だった仲間がバリバリアセンブリを書いているのを羨ましそうに見ていた時から、もう○○年も過ぎていました。

こうしてエミュレータでDOS動かしてDEBUGを使っていると、当時を思い出してなんとも言えない気分になります・・・

参考

  1. おそらく普段つかうアセンブリと代入の順番が逆なのに注意。 

  2. たまに「アセンブラ」か「アセンブリ」か、という話題になります。言語としては「アセンブリ」、アセンブルするのが「アセンブラ」というのが一般的ですが、たとえばIBMなんかは「アセンブラ言語」と呼んでおり、どちらでもかまわないようです。僕は今は「アセンブリ言語」と呼びますが、当時は言語のことを「アセンブラ」と呼んでいたので、本稿では一貫性のない使い方になっています。