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 insecure-use-string-copy-fn happens in C and how to fix it

A high-severity vulnerability was identified in `plugin/bin/install.c` where `strcpy()` and `strncpy()` were used to handle path strings without proper bounds checking or guaranteed null-termination. The fix replaces `strcpy()` with direct character assignment and `strncpy()` with `snprintf()`, eliminating both buffer overflow and missing null-terminator risks in the plugin installation workflow.

critical

How NULL pointer dereference from unchecked malloc() happens in C and how to fix it

A critical memory safety vulnerability was discovered in `bench/tokenizer/tokenizer.c` where `malloc()` was called without checking its return value before passing the pointer to `memcpy()`. If allocation fails and `malloc()` returns NULL, the subsequent `memcpy()` writes to address zero, causing heap corruption or potential arbitrary code execution. The fix adds a single NULL check immediately after allocation, exiting cleanly on failure rather than proceeding with a dangerously invalid pointer

high

How insecure string copy functions happen in C and how to fix it

A high-severity buffer overflow vulnerability was discovered in `tools/claude-vscode-wrapper.c`, where an unbounded `strcpy()` call copied a file path into a fixed-size `MAX_PATH` buffer without any size validation. The fix replaces `strcpy()` with `snprintf()` and swaps `malloc()` for `calloc()`, ensuring both string operations and memory allocation are bounds-safe and zero-initialized.

critical

How Stack Buffer Overflows Happen in C with sprintf() and How to Fix Them

A critical stack buffer overflow was discovered in `libuv/Learn-libuv/docs/code/tty-gravity/main.c` where `sprintf()` wrote ANSI escape sequences and user-controlled variables into a fixed 500-byte buffer without any bounds checking. An attacker controlling the `pos`, `width`, or `message` variables could overflow the stack, overwrite return addresses, and potentially achieve arbitrary code execution. The fix replaces `sprintf()` with `snprintf()` and adds explicit length validation to ensure wr

high

How insecure string copy functions happen in C and how to fix them

A high-severity buffer overflow risk was discovered in `login/main.c` where `strcpy()` was used to copy the `HOME` environment variable into a fixed-size 512-byte buffer without any bounds checking. An attacker controlling the `HOME` environment variable could overflow `pwd_file_name`, potentially corrupting memory or hijacking execution. The fix replaces the two-step `strcpy`/`strcat` pattern with a single, bounds-safe `snprintf` call.

high

How c.lang.security.use-after-free.use-after-free happens in C and how to fix it

A use-after-free vulnerability was discovered in `ggml-alloc.c` where `galloc->leaf_allocs` could be referenced after being freed during graph memory reallocation. The fix nullifies the pointer immediately after `free()` and uses explicit `sizeof(struct leaf_alloc)` to prevent undefined behavior. This defensive hardening eliminates an exploit primitive in a speech-to-text processing pipeline.