Back to Blog
medium SEVERITY7 min read

How integer underflow in array splice operations happens in C and how to fix it

A critical integer underflow vulnerability was discovered in tree-sitter's array.h header file, where the `_array__splice()` function calculated array sizes without proper bounds checking. The vulnerable code relied on assert() statements that are disabled in release builds, allowing arithmetic underflow when `old_count > *size + new_count`, potentially causing memory corruption through out-of-bounds memcpy operations.

O
By Orbis AppSec
Published July 25, 2026Reviewed July 25, 2026

Answer Summary

This is an integer underflow vulnerability (CWE-191) in tree-sitter's C array manipulation code. The `_array__splice()` function in array.h calculated `new_size = *size + new_count - old_count` without validating that the result wouldn't underflow, and used assert() for bounds checking—which is disabled in production builds with NDEBUG defined. The fix reorders the calculation to `new_size = *size - old_count + new_count` and adds explicit runtime bounds checks before the arithmetic and memcpy operations, preventing memory corruption from invalid array indices.

Vulnerability at a Glance

cweCWE-191 (Integer Underflow) / CWE-120 (Buffer Copy without Checking Size)
fixReorder arithmetic to prevent underflow and add explicit runtime bounds validation
riskMemory corruption from out-of-bounds memcpy when splicing arrays with invalid parameters
languageC
root causeArithmetic underflow in size calculation combined with disabled assert() in release builds
vulnerabilityInteger underflow in array splice leading to buffer overflow

Introduction

In tree-sitter's core array manipulation code, we discovered a critical integer underflow vulnerability in src/tree_sitter/array.h that could lead to severe memory corruption. The _array__splice() function at line 258 calculated a new array size using the expression uint32_t new_size = *size + new_count - old_count without validating that the arithmetic wouldn't underflow. Combined with assert() statements that disappear in release builds, this created a perfect storm for exploitation.

The vulnerable code also appeared in _array__erase() at line 197, where bounds checking relied entirely on assert statements. When compiled with NDEBUG defined (standard for production releases), these safety checks vanish, allowing invalid indices to trigger out-of-bounds memmove and memcpy operations. This is particularly dangerous in a Node.js library like tree-sitter, where vulnerabilities affect all downstream consumers who depend on this package for parsing operations.

The Vulnerability Explained

Let's examine the vulnerable code in _array__splice():

static inline void *_array__splice(void *self_contents, uint32_t *size, uint32_t *capacity,
                                 size_t element_size,
                                 uint32_t index, uint32_t old_count,
                                 uint32_t new_count, const void *elements) {
  uint32_t new_size = *size + new_count - old_count;  // VULNERABLE LINE
  uint32_t old_end = index + old_count;
  uint32_t new_end = index + new_count;
  assert(old_end <= *size);  // Disabled in release builds!

The critical flaw is in the size calculation. Because all variables are unsigned 32-bit integers (uint32_t), the expression *size + new_count - old_count can underflow when old_count > *size + new_count.

Example attack scenario: Imagine an attacker controlling the splice parameters calls the function with:
- *size = 10 (current array has 10 elements)
- new_count = 5 (inserting 5 new elements)
- old_count = 20 (claiming to remove 20 elements)

The calculation becomes: 10 + 5 - 20 = -5, but since we're using unsigned integers, this wraps around to 4,294,967,291 (0xFFFFFFFB). The code then attempts to allocate and copy this massive amount of memory, causing:

  1. Memory exhaustion from attempting to allocate ~4GB
  2. Buffer overflow when memcpy writes beyond allocated bounds
  3. Heap corruption from invalid memory operations

The assert(old_end <= *size) should catch this, but it's compiled out in production builds. The similar vulnerability in _array__erase() at line 197 has the same problem:

static inline void _array__erase(void* self_contents, uint32_t *size,
                                size_t element_size, uint32_t index) {
  assert(index < *size);  // Gone in release builds
  char *contents = (char *)self_contents;
  memmove(contents + index * element_size, contents + (index + 1) * element_size,
          (*size - index - 1) * element_size);  // Out-of-bounds if index >= *size

If index >= *size, the memmove operation reads from and writes to invalid memory addresses, potentially corrupting adjacent heap structures or causing crashes that could be leveraged for code execution.

The Fix

The fix addresses both the integer underflow and the assert-only validation with two key changes:

Change 1: Reorder arithmetic to prevent underflow

// BEFORE (vulnerable):
uint32_t new_size = *size + new_count - old_count;
uint32_t old_end = index + old_count;
assert(old_end <= *size);

// AFTER (secure):
uint32_t old_end = index + old_count;
assert(old_end <= *size);
if (old_end > *size) return self_contents;  // Runtime check
uint32_t new_size = *size - old_count + new_count;  // Subtract first

By reordering the calculation to *size - old_count + new_count, we perform the subtraction first. The explicit runtime check if (old_end > *size) ensures we never reach the arithmetic if old_count would cause problems. This check remains active in all builds, unlike assert().

Change 2: Add explicit bounds validation

// In _array__erase():
assert(index < *size);
if (index >= *size) return;  // Early return prevents out-of-bounds access
char *contents = (char *)self_contents;
memmove(contents + index * element_size, ...);

The if (index >= *size) return; statement provides production-safe validation. Even when assert() is disabled, this check prevents the dangerous memmove call with invalid indices.

These changes work together to ensure that:
- Arithmetic operations never underflow
- Bounds checks remain active in release builds
- Invalid parameters cause safe early returns instead of memory corruption
- The existing test suite continues to pass, preserving intended behavior

Prevention & Best Practices

To avoid integer underflow vulnerabilities in C array manipulation code:

  1. Never rely on assert() for security-critical validation: Assert statements are debugging aids, not security controls. Always use explicit runtime checks for bounds validation.

  2. Order arithmetic operations carefully with unsigned integers: When working with unsigned types, subtract before adding to minimize underflow risk. Consider using signed integers when the value can legitimately be negative.

  3. Validate all arithmetic inputs: Before performing calculations like a + b - c, verify that c <= a + b to prevent wraparound. Check for overflow/underflow conditions explicitly.

  4. Use safe integer arithmetic libraries: Consider libraries like SafeInt (C++) or compiler built-ins like __builtin_add_overflow() that detect arithmetic errors.

  5. Enable compiler warnings: Use -Wconversion and -Wsign-conversion to catch potentially dangerous implicit conversions between signed and unsigned types.

  6. Static analysis in CI/CD: Integrate tools like Semgrep, CodeQL, or Clang Static Analyzer into your build pipeline to catch these patterns before they reach production.

  7. Follow CERT C guidelines: Specifically, CERT C rule INT30-C (ensure unsigned integer operations do not wrap) and INT32-C (ensure operations on signed integers do not result in overflow).

The OWASP Code Review Guide emphasizes that integer vulnerabilities are particularly dangerous in memory-unsafe languages like C, where they often lead to buffer overflows. Always treat size calculations as security-critical code paths.

Key Takeaways

  • The _array__splice() function calculated new_size = *size + new_count - old_count without preventing underflow, allowing wraparound to massive values when old_count exceeded the sum of other operands.

  • Assert-only validation in _array__erase() and _array__splice() disappeared in release builds, removing the only bounds checks between user input and dangerous memcpy/memmove operations.

  • Reordering arithmetic to subtract before adding (*size - old_count + new_count) prevents underflow by ensuring the subtraction happens when we know the relationship between operands.

  • Explicit runtime checks like if (old_end > *size) return self_contents; provide production-safe validation that remains active regardless of build configuration or optimization level.

  • Tree-sitter is a Node.js library, so this vulnerability affected all downstream consumers who parse code using this package—demonstrating how memory safety issues in native dependencies propagate through the ecosystem.

How Orbis AppSec Detected This

  • Source: Array manipulation parameters (index, old_count, new_count) passed to _array__splice() and _array__erase() functions in src/tree_sitter/array.h
  • Sink: Unsafe arithmetic operations at line 260 (*size + new_count - old_count) and subsequent memcpy/memmove calls at lines 202 and 271 that use the potentially underflowed size value
  • Missing control: No runtime validation that old_count <= *size + new_count before arithmetic, and bounds checks using assert() that are disabled in production builds with NDEBUG
  • CWE: CWE-191 (Integer Underflow/Wraparound) leading to CWE-120 (Buffer Copy without Checking Size of Input)
  • Fix: Added explicit runtime bounds checks before arithmetic operations and reordered size calculation to prevent underflow

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This integer underflow vulnerability in tree-sitter's array.h demonstrates why arithmetic operations on unsigned integers require the same careful validation as pointer operations in C. The combination of underflow-prone calculations and assert-only bounds checking created a critical security flaw that could lead to memory corruption in production builds.

The fix—reordering arithmetic and adding explicit runtime checks—is a template for securing similar array manipulation code. By treating size calculations as security-critical and never relying on assert() for production validation, developers can prevent this entire class of vulnerabilities.

For developers working with C arrays and dynamic memory, remember: unsigned integer arithmetic can wrap around silently, and debug-only checks provide no protection in release builds. Always validate, always check bounds explicitly, and always assume your arithmetic might be wrong.

References

Frequently Asked Questions

What is integer underflow in array operations?

Integer underflow occurs when subtracting unsigned integers produces a result that wraps around to a very large positive number. In array.h's `_array__splice()`, calculating `*size + new_count - old_count` could underflow if `old_count` is larger than the sum of the other values, resulting in a massive allocation size that causes buffer overflows.

How do you prevent integer underflow in C array manipulation?

Always validate bounds before arithmetic operations on unsigned integers. Check that subtraction operands won't underflow (ensure minuend ≥ subtrahend), reorder operations to subtract before adding when possible, and add explicit runtime checks that remain active in release builds—never rely solely on assert() for security-critical validation.

What CWE is integer underflow?

Integer underflow is classified as CWE-191 (Integer Underflow/Wraparound). When it leads to buffer operations with incorrect sizes, it also relates to CWE-120 (Buffer Copy without Checking Size of Input) and CWE-787 (Out-of-bounds Write).

Is using assert() enough to prevent integer underflow?

No. Assert statements are compiled out in release builds when NDEBUG is defined, making them unsuitable for security-critical bounds checking. Production code must use explicit runtime checks (if statements) that remain active regardless of build configuration to prevent exploitable vulnerabilities.

Can static analysis detect integer underflow vulnerabilities?

Yes. Modern static analysis tools can detect potential integer underflow by tracking arithmetic operations on unsigned integers, identifying missing bounds checks, and flagging assert()-only validation in security-critical code paths. Tools like Semgrep, CodeQL, and specialized security scanners can identify these patterns.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #577

Related Articles

high

How buffer overflow via sprintf() happens in C string formatting and how to fix it

A high-severity buffer overflow vulnerability was discovered in `bench/strbuild/strbuild.c` where `sprintf()` wrote formatted output into a 64-byte stack buffer (`line[64]`) without any bounds checking. An attacker who could influence the values in the `NAMES[]`, `c[]`, or `v[]` arrays could overflow this buffer, potentially corrupting the stack and hijacking control flow. The fix replaces `sprintf()` with `snprintf(line, sizeof(line), ...)` to enforce a strict 64-byte write limit.

critical

How buffer overflow happens in C ieee80211_input() and how to fix it

A critical buffer overflow vulnerability was discovered in `src/firmware/src/net/ieee80211.c` at line 1584, where the `ieee80211_input()` function processed raw 802.11 data frames without verifying that the incoming frame was large enough to contain a valid `ieee80211_frame` header. An attacker within wireless range could craft undersized or malformed frames to trigger memory corruption, potentially leading to remote code execution on the firmware. The fix adds a single, targeted bounds check th

critical

How buffer overflow in FuzzIxml.c sprintf() happens in C and how to fix it

A critical buffer overflow vulnerability was discovered in `fuzzer/FuzzIxml.c` where `sprintf()` wrote a PID-formatted filename into a fixed 256-byte stack buffer without any bounds checking. The fix replaces `sprintf()` with `snprintf()`, explicitly passing the buffer size to prevent any overflow. While exploitation in this specific fuzzer context requires local access, the pattern is a textbook example of CWE-120 that developers should recognize and eliminate everywhere it appears.

critical

How buffer overflow happens in C libficus.c sprintf() and how to fix it

A buffer overflow vulnerability was discovered in `runtime/ficus/impl/libficus.c` where `sprintf()` was used to write a formatted compiler version string into a fixed-size stack buffer without any bounds checking. The fix replaces both vulnerable `sprintf()` calls with `snprintf()`, passing `sizeof(cver)` as the maximum write length to ensure the buffer can never be overrun. This change eliminates the risk of stack memory corruption that could be triggered by an attacker with control over the bu

critical

How buffer overflow via strcpy() happens in C Kconfig parsing and how to fix it

A critical buffer overflow vulnerability was discovered in the Linux kernel's Kconfig build system where `strcpy()` copied user-controlled symbol values into a fixed-size buffer without bounds checking. This flaw in `scripts/kconfig/symbol.c` could allow attackers to overwrite adjacent memory when processing malicious Kconfig files. The fix replaces the unsafe `strcpy()` with `memcpy()` using explicit length calculations.

medium

How insecure update manifest parsing happens in C++ UpdateHelper.cpp and how to fix it

TrafficMonitor's software update mechanism in `UpdateHelper.cpp` fetched and parsed update manifests from remote servers without validating the version string or enforcing trusted download URLs, leaving users exposed to man-in-the-middle (MITM) attacks. An attacker on the same network could intercept the update channel and inject a malicious binary under a crafted version string or an HTTP download link pointing to attacker-controlled infrastructure. The fix adds strict version-string sanitizati