← Writeups

When a + b > limit isn't a bounds check: two KTX2 bugs in basis_universal

18 Jul 2026 static-analysisbasis_universalktx2integer-overflowcwe-190cwe-789

TL;DR: A review of the KTX2 container parser in basis_universal — Binomial’s GPU-texture transcoder, embedded in countless engines and asset pipelines — turned up two bugs with the same root shape: a size/bounds check written as ordinary integer arithmetic on attacker-controlled numbers that overflow. One is a 32-bit multiply that balloons into a ~16 GiB allocation (denial of service); the other is a 64-bit add that wraps and lets a wild offset sail past the bounds check into an out-of-bounds read. Reported 2026-07-18; Richard Geldreich acknowledged and pushed fixes the same day (3454230543d5).

The target

KTX2 is a GPU-texture container: a header, a level index, an optional supercompression-global-data (SGD) blob, and Zstd-compressed level payloads. ktx2_transcoder parses all of it from an untrusted file before handing decoded blocks to the GPU. It’s a great surface — attacker-controlled 32- and 64-bit size/offset fields feeding allocations and pointer math — and it isn’t in OSS-Fuzz. The two bugs came out of a static “audit every bounds check” pass, each then confirmed with a crafted file under ASan/UBSan.

Bug 1 — a multiply that overflows into a 16 GiB allocation

For supercompression formats with per-image slice descriptors, the transcoder sizes an array straight from header fields:

const uint32_t image_count =
    maximum<uint32_t>(m_header.m_layer_count, 1) *
    m_header.m_face_count * m_header.m_level_count;
m_slice_offset_len_descs.resize(image_count);

m_layer_count is a 32-bit field read from the file, with no practical upper bound, no checked multiplication, and no comparison of the required descriptor bytes against what the file actually contains. Set the little-endian layerCount at file offset 32 to 0x80000001 and image_count becomes 2,147,483,649 — a resize() of roughly 16 GiB of descriptors from a tiny input.

In an NDEBUG release build under a memory limit, that’s a deterministic abort:

elemental_vector::increase_capacity: Allocation failed!
vector::resize failed, new size 2147483649
terminate called without an active exception
Aborted

No memory corruption — just a clean denial of service for anything that transcodes attacker-supplied KTX2. Classic CWE-789 (allocation with excessive size): the number came from the file, and nothing bounded it before it became an allocation.

Bug 2 — an add that wraps past the only bounds check

The level index is validated like this:

if ((m_levels[i].m_byte_offset.get_uint64() +
     m_levels[i].m_byte_length.get_uint64()) > m_data_size)
{
    return false;   // "invalid level offset and/or length"
}

m_byte_offset and m_byte_length are packed_uint<8> — fully attacker-controlled 64-bit values. This offset + length > data_size test is the only upper bound on m_byte_offset; there is no standalone offset <= data_size check. So pick m_byte_offset = 2^64 − K and m_byte_length = K: the sum wraps to 0, sails under data_size, and an arbitrarily large offset is accepted.

That offset then goes straight into pointer arithmetic and a read:

const uint8_t *pComp_data = m_levels[level_index].m_byte_offset.get_uint64() + m_pData;
...
ZSTD_decompress(uncomp_data.data(), uncomp_size, pComp_data, comp_size);   // OOB read

With K = 0x7F000000, m_pData + offset resolves to about 2 GB below the buffer — unmapped memory — and Zstd reads comp_size bytes from it. UBSan flags the pointer overflow; ASan catches the read. It’s a DoS, and because the read bytes feed Zstd and the block transcoder, potential information disclosure too (CWE-190 → CWE-125). The same wrap exists in the SGD range check a few lines up; the DFD check survives only by accident, because its length is pinned to 44 or 60.

The common root cause

Both bugs are the same mistake in two registers. image_count = a * b * c and offset + length > data_size read like bounds logic, but they’re arithmetic on numbers an attacker chooses, in a fixed-width type that wraps. The check passes not because the values are safe but because the math overflowed. A size check is only a check if it can’t be satisfied by making the inputs bigger.

The fix pattern is the same for both:

Upstream’s 3454230543d5 does exactly this: it bounds the layer count, computes the image count in 64-bit, validates descriptor bytes before allocating, switches to try_resize(), and replaces the level/SGD checks with subtraction-based comparisons that can’t wrap.

Disclosure

Both reported by PGP-signed email on 2026-07-18. Richard Geldreich replied the same day — “Several fixes checked in… Thanks!” — with the public fix commit. Independent verification: both PoCs are rejected cleanly and all 33 valid KTX2 regression seeds pass under ASan/UBSan at the fix. No CVE requested.

Takeaways