index

One Character, Millions of Players

· 4min

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 uses abv->ByteOffset() correctly, but the size uses abs->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.

CaseSize PassedNative ExpectsGuard Result
Correct896 bytesBlocked
Vulnerable409696 bytesAllowed

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 ArrayBufferView boundary via native function.
  • Heap Manipulation
    • Adjacent allocations (PartitionAlloc) become corruption targets.
  • Object Corruption
    • Overwrite metadata of neighbouring objects (e.g. typed arrays).
  • Memory Primitive Construction
    • addrof primitive
    • fakeobj primitive
    • controlled OOB read/write
  • Arbitrary Memory Access
    • Corrupt ArrayBuffer backing store pointer to gain full process memory access.
  • Code Execution
    • Overwrite function pointers in GTA V dispatch tables (rage::NativeTbl) to redirect execution flow.

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 ArrayBufferView bounds 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.