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:
- Do the arithmetic in a wider type (compute the product/sum in
uint64_t), or better, rewrite the comparison so it can’t overflow:offset > data_size || length > data_size - offsetinstead ofoffset + length > data_size. - Bound the inputs themselves before using them (cap
layerCount, rejectimage_count > UINT32_MAX/ an implementation limit). - Make the allocation fallible (
try_resize()→ return an error) instead of letting a huge request terminate the process.
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
a + b > limitis not a bounds check whenaandbare attacker-controlled and the type can wrap. Prefer subtraction (b > limit - a) or a wider type.- Multiplication is the same trap for allocations —
count = a * b * cfrom untrusted fields is an allocation bomb waiting for an overflow. - Grep the whole file once you find one. Both the level check and the SGD check had the identical wrap; the DFD one was safe only by luck. One pattern, multiple sites.