How a single incorrect character within FiveM’s V8 runtime created a path to remote code execution.
0. Executive Summary
Today I’m writing about a memory safety vulnerability I found in FiveM’s Javascript runtime, specifically in the boundary between V8 and GTA V’s native function interface.
When a Javascript typed array (ArrayBufferView, e.g. Uint8Array) is passed into a native function, the runtime must provide both:
- A pointer to the view’s memory region.
- The exact size of that view.
Due to a one-character mistake in V8ScriptRuntime.cpp, the system uses the underlying ArrayBuffer’s length (abs) instead of the view’s length (abv).
This causes the native layer to believe it has access to more memory than actually belongs to the view.
The result is:
- A bypass of FiveM’s buffer size validation.
- An OOB write into adjacent heap memory.
- Potential memory corruption that can escalate into full code execution inside the GTA V process.
Because FiveM runs V8 directly inside the game process without a sandbox boundary, a V8 memory corruption issue becomes a full process compromise on client machines.
1. System Context - How FiveM Executes Scripts
FiveM is a modification framework for GTA V (recently acquired by Rockstar Games) that embeds the V8 Javascript engine directly inside the game client.
This allows server side scripts to execute on every connecting player’s machine.
The execution chain looks like:
Server Resource > Netcode > Client > CitizenFX Script Host > V8 Runtime > ScriptInvoker > GTA V Native Functions (
rage::NativeTbl)
Unlike a browser environment, there is:
- No renderer sandbox.
- No process isolation between script runtime and game engine.
This means that memory corruption in V8 directly affects the GTA5.exe process.
2. Vulnerability Overview
The issue exists in V8ScriptRuntime.cpp, in the arguement marshaling logic that converts Javscript values into native C-style arguments.
When a typed array is passed to a native function, the runtime handles it as follows:
else if (arg->IsArrayBufferView())
{
Local<ArrayBufferView> abv = arg.As<ArrayBufferView>();
Local<ArrayBuffer> buffer = abv->Buffer();
auto abs = buffer->GetBackingStore();
return Push(
(uint8_t*)abs->Data() + abv->ByteOffset(),
abs->ByteLength() // ← BUG: should be abv->ByteLength()
);
}This bug is subtle but critical:
abv= the view (correct logical size)abs= the backing store (entire allocation) The pointer usesabv->ByteOffset()correctly, but the size usesabs->ByteLength().
This creates a mismatch between the memory region actually passed and the size that the native function believes it can safely use.
3. Memory Model Mismatch
Javascript allows creating views over portions of an ArrayBuffer:
// 4kb backing store
const buf = new ArrayBuffer(4096)
// 8 byte view at the end of the buffer
const tinyView = new Uint8Array(buf, 4088, 8)Here:
bug.byteLength = 4096.tinyView.byteLength = 8.- Valid memory range = only 8 bytes starting at offset 4088.
However, due to the bug, the native side is told the size is 4096. Therefore instead of operating on 8 bytes, it believes it can safely operate across the full buffer range.
4. Bypassing the Size Guard
ScriptInvoker enforces a safety check to prevent buffer overflows:
arg[1]: buffer too small (8 < 24)This ensures native writes cannot exceed allocated memory.
| Case | Size Passed | Native Expects | Guard Result |
|---|---|---|---|
| Correct | 8 | 96 bytes | Blocked |
| Vulnerable | 4096 | 96 bytes | Allowed |
Because the size is inflated, the guard is bypassed entirely. This allows native functions to write beyond the actual bounds of the view.
5. Immediate Impact - OOB Write
When a native function writes output into the provided buffer, it trusts the size value completely.
Due to the mismatch, writes begin at the correct pointer (abv + offset) but extend past the real allocation. This results in OOB heap writes, corruption of adjacent allocations and undefined memory behaviour (often crashes, sometimes silent corruption).
6. Exploitation Path
The following describes the standard progression for this class of memory corruption.
- OOB Write Primitive
- Trigger a write beyond
ArrayBufferViewboundary via native function.
- Trigger a write beyond
- Heap Manipulation
- Adjacent allocations (PartitionAlloc) become corruption targets.
- Object Corruption
- Overwrite metadata of neighbouring objects (e.g. typed arrays).
- Memory Primitive Construction
addrofprimitivefakeobjprimitive- controlled OOB read/write
- Arbitrary Memory Access
- Corrupt
ArrayBufferbacking store pointer to gain full process memory access.
- Corrupt
- Code Execution
- Overwrite function pointers in GTA V dispatch tables (
rage::NativeTbl) to redirect execution flow.
- Overwrite function pointers in GTA V dispatch tables (
7. Impact
This vulnerability enables:
- Remote code execution via malicious server resources.
- Automatic exploitation on client connect (no user interaction beyond that).
- Full compromise of the GTA V process on affected clients.
Because FiveM executes V8 directly inside the game process, there is no sandbox boundary between script execution and the host application.
8. Root Cause Analysis
The issue stems from a simple but critical mismatch: pointer derived from ArrayBufferView size derived from ArrayBuffer. A single incorrect identifier (abs instead of abv) breaks the safety assumption between Javascript and native code.
What could have prevented it?
- Static analysis enforcing consistency between pointer source and size source.
- Unit tests comparing
ArrayBufferViewbounds vs native consumption. - Fuzzing native invocation paths with offset views and partial buffers.
These would likely have exposed the mismatch immediately due to consistent OOB behaviour.
9. Disclosure
This vulnerability was reported to the CitizenFX team prior to public disclosure & the issue was patched here.