
2026/04/18 1:13
Dosbox 内から Dosbox を検出する方法について
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
DOSBox-X を特定する最も確実な方法は、容易に偽造可能な文字列ではなく、それ独自の特殊な x86 命令 opcode の取り扱い方式を利用することにあります。具体的には、エミュレータは
FE で始まる特定の命令グループを実際に搭載されたハードウェアや他のソフトウェアとは異なる方法で解釈します。標準的な CPU はこれらの特定のバイト列を実行しようとする際にクラッシュしますが、DOSBox-X はその内部ロジックが未文書化のこれらのコマンドを管理するためにプログラムされているため、それらを正しく処理します。この振る舞いは、より古くの実装から PCem で引き継がれたレガシーエラーにより同様の命令を誤って取り扱ってきた 86Box の旧バージョンなど、バグの多い代替品との区別になります。BIOS ヘッダーやメモリ内の文字列をスキャンして検出するという方法と異なり、このアプローチは CPU エミュレーション層そのものを検査します。このロジックはエミュレータのコアにハードコーディングされているため、調整可能なデータテーブルに格納されているわけではなく、将来的なバージョンが動的リロケーションなどの機能を調査する際でも安定したままです。したがって、開発者や自動化されたスクリプトは、システムがこれらの特定の自定义 opcode を実行してもエラーをトリガーしないかどうかを確認することで、本物の DOSBox-X が実行中であることを確認することができ、これにより堅牢なテスト環境を確保できます。本文
作成日:2025 年 12 月 15 日 19 時 04 分
ブログをよむような人なら、DOSBox のことはご存知ないはずがありません。DOSBox は MS-DOS エミュレータであり、それは必然的に x86 アーキテクチャのエミュレーターであることを意味します。しかし、86Box や QEMU といった一般的な x86 エミュレーターとは異なり、DOSBox では DOS 関連の要素がエミュレーションの一部として不可分です。BIOS の中断(インタラプト)や POST シークエンスは存在しますが、「メモリにマッピングされた ROM チップ」という意味での BIOS は存在しません。そもそも伝統的な意義における「DOS」自体も完全に再現されているわけではありません。しかし、DOSBox の内部で動作している限り、その点は自覚できないものです。期待される DOS API のほとんどが利用可能であり、Long File Names(長名)機能をサポートしていない古いや旧なバージョンであると報告された場合に当該機能を表示させないよう配慮が払われています。「察することを避けるべき存在」をいかにして検出するのでしょうか?
多くの MS-DOS 系環境は完全な MS-DOS のレプリカではなく、その特徴的な挙動や追加機能を調べることで、現在動作している環境を特定することができます[1]。DOSBox も例外ではないと思いませんか?「特徴(クイックス)」とは言うまでもなくバグの待機中である可能性が高いですが、「MOUNT」や「VER」などのコマンドは外部世界に情報をリークする能力を持っており、何か追加機能をどこかに隠していないでしょうか?
エイジーモード:正しい方法
はい、おっしゃる通りです。画面に向かって叫んでいる您可能想像できます:最も単純な方法は、FE00:0061 の文字列を取得すること——これは Award BIOS のバージョン文字列の一般的なアドレスであることが周知です[2]—そしてそれが "DOSBox" で始めているか確認します。しかし、それはあまりにも脆い(壊れやすい)、ご存知でしょう?非 DOSBox の BIOS にそのバージョン文字列を埋め込むことも可能ですし、逆に DOSBox を改変してモデル文字列を変更することも可能です。しかも DOSBox-X のソースコード中には、「将来の望ましい変更」としてそのような挙動への言及さえあるコメントが存在します:
/* TODO: *DO* allow dynamic relocation however if the dosbox-x.conf indicates that the user * is not interested in IBM BIOS compatibility. Also, it would be really cool if * dosbox-x.conf could override these strings and the user could enter custom BIOS * version and ID strings. Heh heh heh.. :) */
したがって、このルートは取るべきではありません!別の簡単な方法があります。例えば、Z: ドライブのシリアル番号を確認するというもの(あるいはそのドライブが存在するかどうかも含めて)です。しかし、これらもすべて比较容易に偽装可能です。いや、我々はエミュレーターの本質的な一部である何かを見つける必要があります。「これが DOSBox であることを証明する」何か。
インベントゥス・インストラクションズ
DOSBox が「MOUNT.COM」のようなコマンドを通じて外部世界とどう対話し得るかを振り返りましょう。COM ファイルは単なる機械語コードであり、つまりディスアッセンブラを介して直接実行することができます。では、DOSBox の MOUNT.COM を使い试试看しましょう:
$ ndisasm MOUNT.COM 00000000 BC0004 mov sp,0x400 00000003 BB4000 mov bx,0x40 00000006 B44A mov ah,0x4a 00000008 CD21 int byte 0x21 0000000A FE db 0xfe 0000000B 3805 cmp [di],al 0000000D 00B8004C add [bx+si+0x4c00],bh 00000011 CD21 int byte 0x21 00000013 02 db 0x02
最初の 4 行は理解しやすい:
INT 21h 機能 4Ah はスタックサイズを 0x40 パラグラフ(128 バイト)に縮小します。しかし、その後の数行は……基本的にはゴミです。「db 0xfe」とは「ここには 0xfe というバイトが存在する」という意味だけであり、通常の x86 CPU はこれを拒絶して Invalid Instruction(無効な命令)例外を発生させます。
しかし、x86 CPU を設計する場合であれば、ご自身で独自の指令を発明できます!まさにその通りで、DOSBox のソースコードを見てみましょう:
/* src/cpu/core_normal/prefix_none.h からの抜粋 */ CASE_B(0xfe) /* GRP4 Eb */ { GetRM; Bitu which=(rm>>3)&7; switch (which) { case 0x00: /* INC Eb */ RMEb(INCB); break; case 0x01: /* DEC Eb */ RMEb(DECB); break; case 0x07: /* CallBack */ { Bitu cb=Fetchw(); FillFlags(); SAVEIP; return cb; } default: E_Exit("Illegal GRP4 Call %d",(rm>>3) & 7); break; } break; }
これは opcode の「FE グループ」のデコード用コードです。
0x00 は INC、0x01 は DEC で、これらはどちらも x86 上の実在する正当な命令(opcode)です。しかし最後の 0x07 は DOSBox 独自のものです。この opcode に続く単語はどのカブバック関数を呼び出すべきかを示し、処理を破断して抜けます。したがって、前述のディスアッセンブル結果を補正すると、次のようになります:
00000000 BC0004 mov sp,0x400 00000003 BB4000 mov bx,0x40 00000006 B44A mov ah,0x4a 00000008 CD21 int byte 0x21 0000000A FE380500 CallBack 0x0005 0000000E B8004C mov ax,0x4c00 00000011 CD21 int byte 0x21
ふたけ:x86 命令符号化の藪に転び落ちる話
この草稿の第一版では、私はこう書きました:
x86 命令符号化の藪に転び落ちないように心がけます [...]
しかし、このカブバック opcode が機能する仕組みはまさに x86 opcodes の仕様に起因しています。x86 命令がどのように符号化されているかを知っていることを期待するのは公平ではないと感じます。すでに前半でアセンブリコードを示しましたが、なおかつ私の漫談が多少アクセスしやすくあるべきだと思っています。
すでにこの仕組みをご存知の方、あるいは気にされない方へは飛ばしても構いませんが、本当にご興味がある方のために、主な情報源は Intel 64 and IA-32 Architectures Software Developer's Manual の Volume 2 で、こちら にあります。以降のセクションでは章を括弧書きで引用します。
さて、機械語コードは複数の部分に分かれており、そのうち opcode 自体だけが「1.5 文字節」に過ぎません[2.1]。簡潔さのため、ここでは_opcode_、ModR/M バイト、そしてイミディエート(定数)バイトを無視するものとして説明します(これらが今回の対象だからです)。
前述のディスアッセンブル抜粋を見てみましょう:
0000000A FE380500 CallBack 0x0005 0000000E B8004C mov ax,0x4c00
これを hex(16 進数)表記に変換すると:
FE 38 05 00 00 B8 00 4C
0F というプレフィックスがない場合、opcode は単なる FE です。(2.1 セクション参照)ただしこれはグループで、「INC/DEC グループ 4」であり、実際に opcode を決定するのは次のバイトである ModR/M バイトの opcode ビットです。このバイトは以下のように分割されます:
- バイト:
(00 111 000
)0x38 - Mod:
(00
)0x00 - Opcode:
(111
)0x07 - R/M:
(000
)0x00
当方の目的においては opcode フィールドのみが重要です。したがってこれは
FE /7 と読むことができます。Opcode Extensions テーブルに従うと、(2.1 A.4.2 Table A-6 参照)実際にはこの組み合わせは存在しません。このグループでは FE /0 と FE /1 のみが存在します。しかし DOSBox が秘密の FE /7 をサポートしていることは確かで、次に進むべきことを知るためにはソースコードへの依存せざるを得ません。そしてそれが実装しているのは:
Bitu cb=Fetchw(); FillFlags(); SAVEIP; return cb;
特に重要なのは、
Fetchw() が次のワード(2 バイト)を取得して戻すことです(つまり、機械に「このカブバックを呼び出すように」と指示している)。x86 はリトルエンディアンであるため、05 00 は 00 05 として解釈されます。カブバックの処理が完了すると、次の命令が呼び出されます。それは B8 00 4C です。B8 は「MOV AX, XXXX」を表します。この命令は 16 ビットのイミディエート(定数)を扱い、ここでは 00 4C の値(リトルエンディアン表記では 4c00)となります。このように次々と処理が進みます。
さて、これが「MOUNT.COM」のような仮想プログラムの生成部分をコード上どのように表現しているかを示します:
/* src/misc/programs.cpp からの抜粋 */ static Bit8u exe_block[]={ 0xbc,0x00,0x04, // MOV SP,0x400 スタックサイズを削減 0xbb,0x40,0x00, // MOV BX,0x040 メモリ再サイズ準備 0xb4,0x4a, // MOV AH,0x4A メモリブロック再サイズ指示 0xcd,0x21, // INT 0x21 (DOS インタラプト) // ポジション 12 がカブバック番号 0xFE,0x38,0x00,0x00, // CallBack 番号を呼び出す 0xb8,0x00,0x4c, // Mov ax,4c00 (終了処理) 0xcd,0x21, // INT 0x21 (DOS インタラプト) };
便利なのは、カブバックが一般のステータスと同じ方法で返されるため、DOSBox において
FE 38 00 00 は実質的に 4 バイトの NOP(何もしない)として機能するという点です。他の x86 CPU にはそのような幸運はありません。80186 から以降では、無効な命令は「#UD」(未定義 Opcode)例外、あるいは Interrupt 06h をトリガーします。したがって我々は単に例外ハンドラを書くだけで済みます。以下のようなものです:
_catchUD: ; 現在の IP はスタックのトップにあるため、ax/bx をプッシュした後で +4 push bx push ax mov bx, sp mov bx, WORD [ss:bx+4] mov ax, bx mov bx, WORD [cs:bx] ; リトルエンディアン(例:0x38fe)としてコピーする and bh, 38h cmp bx, 38feh je .notDosbox ; ここに来た場合、本当にもっとも何かがおかしい!IVT をクリーンアップし、 ; IRET を実行して実際の #UD ハンドラを呼び出す。 ; IP を改変しないため、無効な opcode は再実行される。 push es xor ax, ax mov es, ax mov bx, [oldUDAddr] ; 以前の int 06h アドレス mov [es:18h], bx ; 06h*4 mov bx, [oldUDSeg] ; 以前の int 06h セグメント mov [es:20h], bx ; (06h*4)+2 pop es pop ax jmp .catchDone .notDosbox: ; DOSBox ではない -- IP を増殖し、AX をゼロ化する ; もちろんここには自由に処理できます。例えばグローバル変数を設定したり。 add ax, 4 mov bx, sp mov WORD [ss:bx+4], ax xor ax, ax add sp, 2 ; AX はもはや不要 .catchDone: pop bx iret
これを設定すれば、以下のようにテストできます:
mov ax, 42 db 0xfe, 0x38, 0x00, 0x00 ; 例外ハンドラがこの位置にあったか? cmp ax, 0 jz .notDosbox ; DOSBox ではない! ; DOSBox 固有のコードはここから開始! .notDosbox: ; DOSBox 以外の環境でのコードはここから開始!
必要であれば、中断 06h ベクターを一度リセットするための追加命令を加えれば、DOSBox の検出に十分な良質なチェック手法が到手できます!
DEBUG と x86
執筆の途中時点で、これをハードウェア上でもテストしてみるのが良いだろうと考えました。しかし、私の Pentium II システムは現在やや眠っており(=使用頻度が低く)、またスクリーンショットを撮影することも難しい状況です…そこで 86Box を使うことにしました。
これは予定通りには行かず:
[画像プレースホルダー:失敗または予期せぬ挙動を示すスクリーンショット]
重要なのは、これは DOSBox ではありません。ただし気にする必要はなく、MS-DOS の DEBUG プログラムを使って一つずつステップ実行し、何が起きているかを確認すればよいからです。DEBUG は非常に友好的とは言えませんが、この種のタスクに十分な能力を持っています。
t というコマンドが存在し、これによってコードを一つの命令単位でステップ実行できます(まあほぼ)。そこでカブバック命令までステップ実行し、DEBUG がここでの状況について全く無知であること、しかもそれを「適度に符号化している」にもかかわらず有効な命令として処理してしまうことに気づきます!
この時点で私は完全に混乱しました。何か秘密の未公式文書化された命令でしょうか?86Box は何らかの理由で無効な命令を無視しているのでしょうか?無効な命令例外が私の想定とは異なる方法で動作するのでしょうか?DOS ドライバによって中断がどのようにマスクされるのでしょうか?
このトラブルシューティングに費やした数日を省略させていただきます:86Box は PCem から引き継いだバグを持っており、ModR/M opcode モディファイアの 0 がでない限りすべて
FE /1 として扱われてしまうのです[3]。つまり FE /2、FE /4、そして FE /7 はすべて DEC 呼び出しとして振る舞います。幸いなことに修正は比較的簡単で、既にアップストリームでマージ済みです。
PR の通り、実際のハードウェア上でのテストについては linear に特別な感謝を申し上げます。これにより、これが単なるインテルのドキュメントのミスであるわけではないことを(少なくとも部分的には)確信できました。
完成品(?)
私が作成したサンプルプログラムを実行したい場合は、私の Git フォージ [リンク] から入手できます。コンパイルには NASM のインストールが必要です。DOSBox および DOSBox-X 上で問題なく動作します。
このプロジェクト自体は楽しいものがありましたが、私の意図は単に DOSBox を検出することではありませんでした。ちょうどそれが最も困難な部分だったのでした。NTVDM や Win9x の MS-DOS プロンプトを検出するのはもっと簡単であり、基本的に単一の「INT 2Fh」呼び出しで済みます。Linux 用の別の DOS エミュレーターである DOSEMU も存在し、なんと多くのカブバック API を実装しています(これらはすべて COM ファイルとして実装されており、例え UNIX.COM がホストシステム上で任意のコマンドを実行することを可能にしても隠れた機能というわけではありません)。もちろん、カスタム CPU 指令に比べて偽装するのは容易ですが、BIOS 文字列を変更するよりも副作用を招きやすい傾向があります。