Ghidra で C++ コードを解析する場合、非 virtual なメンバ関数は正しく復元できない事がある

前回IUnknown インタフェースを実装したクラスは

class IUnknownImpl : IUnknown {
protected:
	IUnknown* vftbl;
	LONG refcount;
	THUNK_DWORD_PTR unknown;
	IID iid;
public:
	HANDLE handle;
}

というメンバ変数を持ってるところまでは復元ができた。

このうち最初の 4 つはコンストラクタで値を初期化しているし、LONG refcount が IUnknown::AddRef IUnknown::Release で更新される以外は不変なので protected と思われる。 しかし HANDLE handle はコンストラクタでは無効な値 INVALID_HANDLE_VALUE すなわち (HANDLE)-1 が設定されるだけなんだよね、だからとりあえず public で宣言している。

Ghidra が復元したコードによるとこのハンドルの生成にはフリー関数を呼んでるのである。

extern HRESULT OpenDevice1(void*);

HRESULT SimpleFactory(THUNK_DWORD_PTR unknown, IID* iid, PDWORD_PTR prod)
{
	HRESULT ret = E_FAIL;
	if (unknown == 0 || iid == nullptr || prod == nullptr)
		return E_INVALIDARG;
	if (!memcmp(iid, &GUID_Unko1, sizeof(*iid))) {
		Unko1* unko1 = new Unko1(iid, unknown);
		if (unko1 == nullptr) {
			ret = E_OUTOFMEMORY;
		} else {
			ret = OpenDevice1(unko1);
			if (FAILED(ret)) {
				delete unko1;
				unko1 = nullptr;
			}
			*prod = (DWORD_PTR)unko1;
		}
	} else if (!memcmp(iid, &GUID_Unko2, sizeof(*iid))) {
...
	}
	return ret;
}

上記で仮に OpenDevice1 と名前をつけたコードの呼出しね。

C++ には リフレクション API が長らく存在しなかったので延々と if 文書く羽目になっとるし、C++ のコンストラクタも Java とかと違ってエラー処理は例外投げる以外に無い。 よって例外を使わない今回のケースだとハンドルの取得はコンストラクタと別関数にするしかない感じ。

この OpenDevice* には 1 から 3 まであって実装は以下の通り、OpenDevice2 は Unko2 で OpenDevice3 は Unko11 そして OpenDevice1 は Unko15 以外のハンドル初期化に呼ばれる。

HRESULT OpenDevice1(IUnknownImpl *unk)
{
  return OpenDevice(&unk->handle, NULL) ? S_OK : E_FAIL;
}

HRESULT OpenDevice2(IUnknownImpl *unk)
{
  return OpenDevice(&unk->handle, "IDrvDevice") ? S_OK : E_FAIL;
}

HRESULT OpenDevice3(IUnknownImpl *unk)
{
  return OpenDevice(&unk->handle, "IDrvALDevice") ? S_OK : E_FAIL;
}

フリー関数なのでメンバ関数の handle にアクセスできるのであれば public しかありえないのだけど、多重継承してる Unko15 だけ仮想関数テーブル経由でハンドル開いとるのだ。

	ret = (**(code **)((int)unko15->vftbl + 0x30))(unko15);
	if (FAILED(ret) {
		(**(code **)((int)unko15->vftbl + 0x44))(1);
		return E_FAIL;
	}

ということで OpenDevice* は Unko1 ~ Unko14 の非 virtual なメンバ関数なんという結論、つまり HANDLE handle も protected ということ。 でもメンバ関数なら __thiscall でレジスタ渡しになると思うんだけど __stdcall になってる謎は残るけど。

ちなみになぜこんなコードに復元されるかというと、非 virtual なメンバ関数は仮想関数テーブル経由での呼出にならないので、コンパイラはここぞとサイズ最適化で同じコードはまとめてしまうからである。

#include <iostream>

class A {
protected:
	int value = 1;
public:
	/* 非 virtual */
	int getValue() {
		return value;
	}
};
class B {
protected:
	int value = 2;
public:
	/* 非 virtual */
	int getValue() {
		return value;
	}
};
int main()
{
	A a;
	B b;

	std::cout << a.getValue() << b.getValue() << std::endl;
	return EXIT_SUCCESS;
}

というコードはサイズ最適化により C で書くと

/* クラスA */
struct A {
	int value;
};
struct A*
A_Construct(struct A* a)
{
	a->value = 1;
}
int
A_getValue(struct A* a)
{
	return a->value;
}
/* クラスB */
struct B {
	int value;
}
struct B*
B_Construct(struct B* b)
{
	b->value = 1;
}
int
B_getValue(struct B* b)
{
	return b->value;
}
/* メイン */
int
main(void)
{
	struct A a;
	struct B b;

	A_Construct(&a);
	B_Construct(&b);

	printf("%d%d\n", A_getValue(&a), B_getValue(&b));
}

にはならず

/* クラスA */
struct A {
	int value;
};
struct A*
A_Construct(struct A* a)
{
	a->value = 1;
}
/* クラスB */
struct B {
	int value;
}
struct B*
B_Construct(struct B* b)
{
	b->value = 1;
}
/* クラスAB 共通 */
int AB_getValue(void *p)
{
	return *((int *)p);
}
/* メイン */
int
main(void)
{
	struct A a;
	struct B b;

	A_Construct(&a);
	B_Construct(&b);

	printf("%d%d\n", AB_getValue(&a), AB_getValue(&b));
}

というコードに化けても合法だからである、A と B に継承関係が無くてもこれだもんな。そりゃ Ghidra も C++ の解析が苦手なわけである。

自分は秋刀魚を焼いて皿に盛る時、上半身と下半身の組み合わせが違ってたら秋刀魚に失礼だと思う感性なので「どっちでもいいだろ!」みたいな最適化は苦手。 ツナ缶なんかも DNA 検査して同じ個体に分別したくなる衝動に駆られませんか駆られませんね失礼しました。 でもバイナリ解析とかデバッグとかコードリーディングってそんな作業だよね。

つーか今時の C++ コンパイラなら鼻から悪魔が飛び出す勢いで最適化するし

/* クラスA */
struct A {
	int value;
};
/* クラスB */
struct B {
	int value;
}
/* メイン */
int
main(void)
{
	struct A a;
	struct B b;

	a.value = 1;
	a.value = 2;

	printf("%d%d\n", a.value, a.value);
}

とコンストラクタもメンバ関数もインライン化して消してしまうし、それでも飽き足らず

/* メイン */
int
main(void)
{
	printf("%d%d\n", 1, 2);
}

のように定数化までされるはずである、もはや秋刀魚の欠片も残ってねえ!

まあいいや次は上で OpenDevice と仮の名前をつけたフリー関数の調査である、引数にインタフェース名をとるから Unko* などという人様に読ませる文章では憚られる仮名から開放されて真名で解説ができるようになるんじゃねえかな。

デバイス操作は Unix では ファイルディスクリプタ、Windows ではファイルハンドル経由で行う

Unix は「全てはファイルである」という理念の元、あらゆるコンピューター資源をファイルディスクリプタとして抽象化し、システムコールの open/read/write/close で操作できるように実装されている。 ちなみにベル研が Unix の次に送り出した Plan9 ではネットワークそして GUI までもがファイルディスクリプタである、やりすぎ。

たとえば過去回で Windows だと EnumProcess 関数でプロセス一覧を取得したのを思い出してほしい。 Unix ではプロセスもまた procfs つまり Process File System 経由でファイルとして抽象化されているので /proc を readdir することでプロセス ID の一覧を取得する。 まぁ過去の脆弱性問題によりデフォルト無効になってることもあるけどなガハハ、そういう OS では libkvm や libproc などの移植性を欠く API の出番となる。

そんでUnix ではハードウェアデバイスもまたファイルディスクリプタとして抽象化され /dev/* を open/read/write/close することで操作ができる。 しかし理想は理想であって単純なファイルディスクリプタ操作だけで事が済むはずもなく、別途 ioctl や sysctl というシステムコールの出番となる。 そもそもファイルディスクリプタだって open/read/write/close で完結することはなく fstat やら fchmod やら fchown の併用になるから同じだわな。

そして Windows でも Unix 同様デバイスはファイルハンドルとして抽象化されている、ただし /dev/* のようにユーザーに見えることはない。 MS-DOS 時代の名残である con/prn/aux/nul/com*/lpt* は別だけれどね、特に nul は PowerShell でも未だに使うし。 懐かしいですよね concon 攻撃、これらの文字が含まれるリンクをクリックするだけでサービス拒否ってもう四半世紀も前になるんだなぁ。

よってデバイスを操作するためにはまず CreateFile でファイルハンドルを取得する必要がある、そのハンドルが IUnknown の実装クラスのメンバ変数の HANDLE handle である。

それでは早速 OpenDevice と仮の命名をしたコードを読んでみようか、Ghidraの復元コードのままだと文字列操作はすべてインライン展開されてワード単位で処理になるから可読性ゲロカスなのでその辺は読み易いよう書き直した。 あと境界値チェックも入れてるけど MAX_PATH ですらない 128byte 制限は特殊ファイルやしええかとそのままである。asprintf 無いとめんどくさいねん。

BOOL
OpenDevice(HANDLE* phandle, PCTSTR name)
{
	HANDLE handle = INVALID_HANDLE_VALUE;
	TCHAR path[128];

	assert(phandle != NULL);
	assert(name != NULL);

	if (_tcscpy_s(path, _T("\\\\.\\CTPROXY\\")) == 0) {
		if (name == NULL)
			name = _T("IDriver");
		if (_tcscat_s(path, name) == 0) {
			handle = CreateFile(path, GENERIC_READ|GENERIC_WRITE, FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL);
			if (handle == INVALID_HANDLE_VALUE) {
				if (_tcscpy_s(path, _T("\\.\\CTAUD9X\\")) == 0)
					handle = CreateFile(path, 0, 0, NULL, 0, 0, NULL);
					if (handle == INVALID_HANDLE_VALUE)
						return FALSE;
			}
		}
	}
	*phandle = handle;
	return TRUE;
}

デバイスハンドルのパスは \\. ではじまる DOS デバイスパスあるいは \\? ではじまる UNC パスとなる。 なぜか Windows 9x 用のドライバである CTAUD9X.VXD は \. ではじまってるけど昔はこれでもよかったんかなこれ。 そもそも NT 用ドライバ開いて失敗したら 9x にフォールバックするのでなく GetVersion 使って条件分岐しろよと思う。

まぁいいや、結論としてはデバイスファイルは上で出てきたのを合わせて

  • \.\CTPROXY\IDriver
  • \.\CTPROXY\IDrvDevice
  • \.\CTPROXY\IDrvALDevice

の 3 つということである、うーんインタフェース名と一対一じゃないのね。

DeviceIoControl のパラメーターについて

このファイルハンドルで実際に DeviceIoControl 呼び出すコードは前回読んだけど再掲。


HRESULT
Unko1_Func1_DeviceIoControl(_In_ HANDLE handle, _In_ THUNK_DWORD_PTR unknown, _In_ DWORD32 param1, _Out_ PDWORD32 param2)
{
	HRESULT ret = E_FAIL;
	struct {
		DWORD32 cls;
		DWORD32 func;
		THUNK_DWORD_PTR unknown;
		THUNK_DWORD_PTR param1;
		THUNK_DWORD_PTR value;
	} in = { 0x2, 0x0, unknown, (THUNK_DWORD_PTR)param1, 0 };
	struct {
		THUNK_DWORD_PTR status;
		THUNK_DWORD_PTR value;
	} out = { 0 };
	DWORD size = 0;
	if (DeviceIoControl(handle, 0xf7770000, &in, sizeof(in), &out, sizeof(out), &size, NULL) != FALSE) {
		assert(size == sizeof(out));
		ret = (HRESULT)out.status;
	}
	return ret;
}

class Unko1 : public IUnknownImpl {
	HRESULT Func1(_In_ DWORD32 param1, _Out_ PDWORD32 param2) {
		return Unko1_Func1_DeviceIoControl(this->handle, this->unknown, param1, param2);
	}
}

0xf7770000 というマジックナンバーは I/O 制御コードというもの、ルールは以下の通り。

  • 17-32bit 目は 0 - 0x7fff は OS が標準デバイス用に予約済で 0x8000 - 0xffff はベンダ独自のカスタムデバイス程度の意味しかないから深く考える必要はない
  • 14-15bit 目はファイルディスクリプタのアクセス権限で以下の値が指定できる
    • 0 - FILE_ANY_ACCESS … 読み込みと書き込み権限
    • 1 - FILE_READ_ACCESS … 読み込み権限
    • 2 - FILE_WRITE_ACCESS … 書き込み権限
  • 3-13bit 目はドライバのファンクションコードで 0 - 0x7ff は OS が標準デバイス用に予約済みで 0x800 - 0xfff はベンダ独自のカスタムファンクション
  • 1-2bit 目はデータ転送方式で以下の値が指定できる
    • 0 - METHOD_BUFFER … Unix でいうところの copyin/copyout ユーザスペースとカーネルでバッファコピーを行う
    • 1 - METHOD_IN_DIRECT … ユーザースペースのメモリから「出力」装置へのデータ転送に DMA(Direct Memory Access) を使う、読み込み権限が必要
    • 2 - METHOD_OUT_DIRECT … 「入力」装置からユーザスペースのデータ転送に DMA(Direct Memory Access) を使う、書き込み権限が必要
    • 3 - METHOD_NEITHER … ユーザスペースのアプリケーションとカーネルドライバで直接メモリ参照する時に指定

ということで 0xf7770000 は CT_CODE マクロを使って定義すると以下のようになる。

#define CT_DEVICE_TYPE	0xf777
#define CT_FUNCTION	0x000
#define CT_CTL_CODE	CTL_CODE(CT_DEVICE_TYPE, CT_FUNCTION, METHOD_BUFFERED, FILE_ANY_ACCESS)

本来ならこの CT_FUNCTION で呼び出すドライバの機能を識別するんだけど、このドライバは 0x000 固定になってる、0x800 じゃないのかよ。 そのかわりに入力データの先頭 32bit + 32bit がクラスID + ファンクションID という構成になっててドライバはこれで識別してるもよう。 これ WDM と VXD でコードを共通化するためにファンクション使ってないのかな、Windows 3.x ドライバ時代の話って古い本探さんと判らんわ。

データの受け渡しだけど、ユーザスペースとカーネルの間で直接メモリを参照しあうのはセキュリティ的によろしくない。 例えばカーネルに不正なアドレスを渡せば容易にシステムをクラッシュさせられるし、ユーザスペースからアクセス不可のメモリ領域も高い権限で動作するカーネルを使えば読めてしまい情報漏洩が発生する。

そのためメモリ保護の一環としてバッファコピーを行う、Unix では copyin/copyout というシステムコールを使うのだけれども Windows では METHOD_BUFFER を指定して DeviceIoControl を呼び出す。

バッファコピーは CPU コストが高いので、大量データを扱う場合は入出力装置とメモリの間で CPU を介さず専用コントローラ経由でデータ転送を行う、これが DMA である。美味しいですよね青魚。 METHOD_*_DIRECT の IN/OUT は出力装置が IN で入力装置が OUT になることに注意、まぁ copyin/copyout の向きと同じだから Unix おじさんなら間違えないか。

次回

ぜんぜん進んでないのはやる気がもげてきたせい。

そもそも当初の目的であるサスペンドの前後で E-MU サウンドデバイスを無効にするという命題が達成できるか怪しくなってきたんだよな。 というのもブラウザ開いたままでオーディオデバイスが掴みっぱなし状態だとデバイス無効にできねえんだこのドライバ。 そうなるとカーネルドライバにまで手を加えないと目標は達成できねえのよね、さすがにそこまで行くと年単位の解析作業になるな…