検索エンジンがまともに機能してた時代ならそろそろタイトル変えたんだろうけど、もはや誰もリーチしないから読まれることも無いのでこのまま行く。

CtHelper.exe は mfc42.dll がリンクされているので MFC のバージョンは 4.2 あるいは 6.0 なので Visual C/C++ 5.0 あるいは 6.0 のはずである。 しかし CtHelper.exe の実行コードの中に明らかに人が書いたコードではないコンパイラが勝手に生成したコードが存在するのだよね。

このコンパイラが勝手に足す命令のせいで Ghidra が混乱し解析結果がおかしな疑似 C コードになるという話を前回したのでそれについて解説する。

コンパイラすなわち cl.exe が勝手に生成したコードの正体は GuardStack とよばれるスタック破壊検知で /GS スイッチ (S は大文字) によって有効になる。 /Gs スイッチ (s は小文字) と混同しやすいけどこっちは指定したスタックサイズを超えた時つまりスタック枯渇検知であって別物である。

令和最新版 Unix C がちょっと書けるだけの俺による Visual C/C++ GuardStack で学ぶスタック破壊検知

/GS でできること

IA32 前提に手短に説明すると

  • C Runtime (CRT) に __security_cookie というグローバル変数が定義されている
  • この __security_cookie は CRT のエントリポイント内で __security_init_cookie 関数によって初期化される
  • コンパイラは以下のコードを実行コードに静的にインジェクションする
    • リターンアドレスの直前(つまりスタックの底)にカナリアと呼ばれるローカル変数の宣言
    • 関数の先頭でカナリア変数に __security_cookie の値とスタックフレームのアドレスを XOR した値をセットするコード
    • 関数からリターンする直前に CRT の __security_check_cookie 関数を呼び出すコード
  • __security_check_cookie はカナリア変数とスタックフレームのアドレスを XOR した結果が __security_cookie の値と一致するか検証する
  • 一致しなければスタックが破壊されてると判定し CRT の __report_gsfailure を呼んでアプリケーション強制終了

という仕掛け、これはバッファオーバーフローを利用しリターンアドレスを書き換えて任意のコードを実行するような攻撃に有効である。

なお IT 因習村にいたら一度は聞く「この変数を消すと動かなくなるから消すてはならない」とコメントの書かれた祠ならぬソースコードがあるという怪談話、このケースに GuardStack は有効ではない。 こちらを検知したいのであれば /RTCs スイッチが有効である。

/RTCs でできること

こちらのメカニズムは

  • 全ての変数の前後に 4byte アライメントでカナリアを置き すべて 0xcc で埋める
  • 関数からリターンする直前に /GS による検査に先立ってカナリアがすべて 0xcc で埋まってるかを CRT の _RTC_CheckStackVars が検査
  • 一致しなければスタック破壊と判断しアプリケーション強制終了

という仕掛け。

ちなみにこの怪談話、俺は実際にそういうコメントが書かれたプログラムに遭遇したことがあるので都市伝説ではない。Oracle Pro*C で書かれたバッチ処理プログラムだったかな。 オーバーランしてる部分を修正しテストもしてお化け退治しましたって提出したら、レビュアーがなんで消したんだお化けが出るぞーって発狂しだしてクソほど笑った。 いや笑えねえよレビューもできねえ無能のくせにレビュアーとか死ね(直球)。

どちらも性能オーバーヘッドがあるし、アプリケーション強制終了機能によってサービス拒否も成立するから使いどころには注意である。 なので assert と同様にデバッグでのみ使うのが本来は望ましい (/RTCs じゃ最適化も効かないし) のだが、 /GS はリリースビルドでも有効することが一般的である。

ちなみになぜカナリアと呼ばれるかというとかつて炭鉱などでカナリアが有毒ガス発生検知に使われてたからである、鳴き止んだら逃げないと死ゾ。

また Microsoft では人柱版をカナリアリリースと呼んだりもするけど、いつの間にかドッグフーディングって言わなくなったな。 AI 任せのお祈りコーディングで人様どころか犬に食わせられない品質になったからですかね、月初の定例パッチがここ数年にわたってほぼ毎回不具合見つかって月末に再リリースなのふざけんなよ。

そいやかつて保土ヶ谷駅付近のとんでもない傾斜地にペットフード工場の試験動物がぎゅう詰めで飼われてて、満員の東海道線で押し潰されてる自分の姿と重ね心が痛んだのだがあれ消費者への宣伝のつもりだったと聞いて戦慄した、昭和ヤバい。

2005 以降は末尾 *_s の境界チェック機能つきのセキュア関数へ置き換えないとコンパイルエラーになる

末尾 *_s の境界機能チェック機能つき関数は ISO/IEC TR 24731-1 Bounds-checking interfaces で提唱された機能のサブセットのはずである、だいぶ差異があるのでもしかしたらオリジンは違うかもしれない。

これ総スカンだったんだよね、当時の流れは今は存在しないチラシの裏の過去記事を読んでもらうといいが、要約すると

  • O では strlcpy などのセキュア関数を先行して実装していたため、車輪の再発明はやめろというお気持ち表明 (せやな)
  • そもそも固定長使うなすべてを動的アロケーションしろカスどもと無茶をいう GNU コミュニティ (わからない文化が違う)
    • のちに glibc に実装済の動的アロケーション関数を TR 24731-2 として標準化を提案
  • そもそも C の文字列が諸悪の根源なので Managed String Library を採用しろと突然暴れだした CERT/CC (あーあ壊れちゃった)
  • GCC の同様のスタック保護機能 Stack Smashing Protection (SSP) は手動によるコード書換でなくコンパイラが libssp に実装された *_chk 末尾の関数に自動的に置換してる (スマートやね)

とまぁバイク小屋の屋根のペンキの色で派手に揉めたのである。

当時俺も N 向けに TR 24731-1 の実装を書いたものの完全に無視された記憶、TR 24731-2 は glibc 互換関数だよでいくつかつっこんだけど。

どちらも C11 に採用されはしたものの 1 を実装するのは Visual C/C++ 以外は OpenWatcom くらいのはずである、このあたりから C 標準は STDC_LIB_EXT1 なんて事前定義マクロ作って実装しなくてもよいとかやりだしたんだよな。

つか GCC の libssp のように /GS オプション付きなら自動で置き換えてくれりゃいいんですわこんなの。

GCC の SSP (aka ProPolice) のお話はまたいずれ

俺はずっと綴りがミツバチが樹液などから作る巣を雑菌から守る樹脂である Propolis (ギリシャ語で都市防衛) だと思ってたんだけど 改めて昔話書こうと思って調べたら Propolice つまりプロ警察だったことに 1/4 世紀経ってようやく気づいた。

あと日本 IBM 東京基礎研の江藤博明氏が実装した SSP より先に EGCS に先行実装があったのは覚えていたのだが、書いたのが Linux Security Module の作者のクリスピン・コーワンで セキュリティ重視の Linux distro である Immunix なんてのがあってそいつに採用されてたのは知らんかった 淫夢 NIX とはたまげたなぁ…

6.0 で GuardStack 機能が使えてる謎は未解決

そもそもこの /GS は 2002 以降、/RTCs は 2005 以降に実装されたものなので、6.0 では使えないはずなんだわ。 2002 以降でビルドしてるなら CRT は msvcrt70.dll 以降 MFC は mfc70.dll 以降がリンクされるはずだし。 それに *_s つきのセキュア関数への置換もやってないからコンパイルエラーになるはず。

可能性として考えたのは 6.0 の IDE と MFC にコンパイラだけ Visual C++ 2003 Toolkit のフランケンシュタインで開発してるのかなと思ったが、普通やらんよな企業では。 これ機能制限された Standard Edition しか買えない層が、無償公開された Visual C++ 2003 Toolkit にパス通せばオミットされてる最適化も可能!ってやってた貧民的プログラミング。 でも 6.0 の CRT しか無いし /GZ に必要なシンボル無いからリンク時にエラーになるわ。

というわけでなぜ 6.0 で GuardStack が使えてるかは謎である CRT に不足する __security_cookie まわりだけ自分で実装足したのかな。 そんなにセキュリティ意識が高いならこんなあちこちで境界チェックすらやってねえコード書かんぞクソが。

Ghidra で GuardStack の実装を読む

Ghidra Project の Issue にはすでに #2743 として報告されていて修正済になってるのだが最新版の 12.1.4 でもまだちょっと動作が変である。 まぁ NSA というならず者国家アメリカの漆黒の闇のような機関とはオープンソースでも関りたくないから報告はしない。

まず何も情報を足さない疑似コード出力、これは過去回で復元した CCtHelperDlg::OnInitDialog の解析結果。

undefined4 __fastcall FUN_01003684(CDialog *param_1)

{
  int iVar1;
  HANDLE pvVar2;
  CDialog *local_c;
  CDialog *local_8;
  
  local_c = param_1;
  local_8 = param_1;
  CDialog::OnInitDialog(param_1);
  FUN_01001fa8((int)param_1);
  iVar1 = FUN_010034ee(param_1);
  if (iVar1 < 0) {
    FUN_010032d6(param_1);
  }
  FUN_01002532();
  FUN_01003124();
  FUN_01002def();
  local_c = (CDialog *)0x0;
  local_8 = (CDialog *)0x0;
  pvVar2 = CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,FUN_01001ed1,&local_c,0,(LPDWORD)&local_8);
  *(HANDLE *)(param_1 + 0x9cc) = pvVar2;
  return 1;
}

ここだけ読むと FUN_010034ee は int を返す関数のはずなのだが

void __fastcall FUN_010034ee(void *param_1)

{
...
  undefined4 extraout_EDX;
...
  uint local_8;

  local_8 = DAT_010063dc ^ (uint)&stack0xfffffffc;

...

  FUN_01004100(local_8 ^ (uint)&stack0xfffffffc,extraout_EDX);
  return;
}

と返り値無しになってるんだよな、そして突如として現れるコンパイラが生成したコードである。

すでに GuardStack がどのようなコードをインジェクションするのか解説済だから

  • uint local_8 … カナリヤ変数
  • DAT_010063dc … __security_cookie
  • stack0xfffffffc … スタックフレーム
  • FUN_01004100 … __security_check_cookie

であることはすんなりお判りいただけたかと思う、可読性の為に右クリックメニューから名前を変更しておこう。

void __fastcall FUN_010034ee(void *param_1)

{
...
  undefined4 extraout_EDX;
...
  uint canary;

  canary = __security_cookie ^ (uint)&stack0xfffffffc;

...

  __security_check_cookie(canary ^ (uint)&stack0xfffffffc,extraout_EDX);
  return;
}

しかし extraout_EDX ってなんだこれ、名前からEDX レジスタ上の剰余っぽいけど。

とりあえず __security_check_cookie の実装をみる。

void __fastcall __security_check_cookie(int param_1,undefined4 param_2)

{
  if (param_1 == __security_cookie) {
    return;
  }
  FUN_01004898(param_1,param_2);
  return;
}

FUN_01004898 は __report_gsfailure であるからこれも名前変えておこう。

こいつの実装については例外として投げるスタックトレースのためにレジスタ上の値を集めたりしてるんだが

void __fastcall __report_gsfailure(int param_1,undefined4 param_2)

{
...
  HANDLE hProcess;
...
  UINT uExitCode;
...
  uExitCode = 0xc0000409;
  hProcess = GetCurrentProcess();
  TerminateProcess(hProcess,uExitCode);
  return;
}

プロセスを終了させる事だけ知ってればいい、なのでこの関数からはどこにも戻らない。

そもそも __security_check_cookie と __report_gsfailureプロトタイプは

void __fastcall __security_check_cookie(uintptr_t);
void __fastcall __report_gsfailure(uintptr_t);

なので引数はふたつ取らないからこれも修正する、ついでに __report_gsfailure は Function Attributs の No Return にチェックも入れておく。 コメントがつくだけで疑似ソースコードに変化は無いけど。

そして関数名 FUN_010034ee の上で右クリックし Edit Function Signature を選択して戻り値を返すことを Ghidra に教える。 本当はこれ CCtHelperDlg のメンバ関数でもあるので念のため __fastcall から __thiscall にもしておく。

その結果以下のように疑似コードが変化した。

int __thiscall FUN_010034ee(void *this)

{
...
  int extraout_EAX;
  int iVar3;
...
  uint canary;
  
  canary = __security_cookie ^ (uint)&stack0xfffffffc;

...

  __security_check_cookie(canary ^ (uint)&stack0xfffffffc);
  return extraout_EAX;
}

うーん、extraout_EAX って何だよ。

さっきの Issue に __security_check_cookie は Function Attributes の In Line にチェック入れるといいとあったのでやってみた。

int __thiscall FUN_010034ee(void *this)

{
...
  int iVar2;
  int iVar3;
...
  uint canary;
  
  canary = __security_cookie ^ (uint)&stack0xfffffffc;

...

  return (-(uint)(*(int *)((int)this + 0x470) != 0) & 0x7fffbffb) + 0x80004005;
}

グエーさらに意味不明になってしまったンゴ。

  • 0x80004005 は エラー番号の E_FAIL くさい

くらいしかわかんねえ、0x470 は CCtHelperDlg のコンストラクタでメンバ変数を初期化してる部分

CDialog * __thiscall FUN_01001e51(void *this,CWnd *param_1)

{
  CDialog::CDialog(this,0x66,param_1);
...
  memset((void *)((int)this + 0x60),0,0x410);
  return this;
}

と関係がありそうである、このメンバ変数はアタッチされてるデバイスの情報とデタッチ時の通知ハンドルを格納してる。

class CCtHelperDlg : public CDialog
{
...
private
	struct AttachedDevice {
		BYTE deviceDescription[0x100];
		HDEVNOTIFY deviceDetachedNotification;
	} attachedDevices[4];
...

みたいな感じだろうと思われるのだが、この後ろに続くメンバ変数なんかな。

とりあえずこのメンバ変数に unknown と名前をつけ、this の型情報を正しく与えるようにしたらこうなった。

int __thiscall CCtHelperDlg::FUN_010032d6(CCtHelperDlg *this)

{
  HRESULT HVar1;
  int iVar2;
  int local_84;
...
  uint canary;
  
  canary = __security_cookie ^ (uint)&stack0xfffffffc;
...
  local_84 = -0x7fffbffb;
...
  iVar2 = -0x7fffbffb;
  HVar1 = CoCreateInstance((IID *)&DAT_01001600,(LPUNKNOWN)0x0,1,(IID *)&DAT_01001610,&local_70);
  iVar2 = -0x7fffbffb;
  if (-1 < HVar1) {
    iVar2 = local_84;
    if (this->unknown != 0) {
      local_84 = 0;
      iVar2 = local_84;
    }
  }
  CoUninitialize();
  return iVal2;
}

今度は 0x80004005 つまり E_FAIL が失踪してしまったのだが、よく考えると -0x7fffbffb は 0x80004005 と同じビットだったわ。

カナリア変数は消せなかったけど Ghidra に __security_check_cookie を疑似 C コードを出力させないようにすることには成功した。

ノイズが消滅したので結論としては

  • CoCreateInstance に成功しかつメンバ変数 unknown が非ゼロなら 0 を返す
  • それ以外は E_FAIL を返す

といなる、ということで前回の復元コードは

HRESULT CCtHelperDlg::TraverseAttachedCtDevices()
{
	unknown = 0;
	if (SUCCEEDED(CoInitialize(NULL))) {
...
		CoUninitialize();
	}
	return (unknown != 0) ? S_OK : E_FAIL;
}

と書き直した、戻り値が HRESULT なので呼出元も

	if (TraverseAttachedCtDevices() < 0)

も

	if (FAILED(TraverseAttachedCtDevices()))

となる。

GuardStack の存在と HRESULT が signed long なせいで普段見慣れてるエラーコードに見えなかったせいで意味不明なコードだったけどようやく腑に落ちた。

残る問題はこのメンバ変数 unknown がどこからも値セットされてそうもない(コンストラクタで初期化もしてない)件だがそれはまたどっかで ちゃんとやってたわ、ロードした DLL の数だわこれ。