gltfio: heap OOB write in cgltf_accessor_unpack_floats via meshopt-bypassed sparse accessor index
Summary
cgltf_accessor_unpack_floats writes decoded sparse accessor values at out + writer_index * floats_per_element with no bounds check on writer_index. When a sparse accessor's indices.bufferView uses EXT_meshopt_compression, cgltf_validate evaluates the compressed fallback bytes as index values (not the decoded stream), allowing decoded sparse index values >= accessor->count to bypass validation and produce a heap OOB write.
Affected code
third_party/cgltf/cgltf.h - sparse pass in cgltf_accessor_unpack_floats (line ~2436):
size_t writer_index = cgltf_component_read_index(index_data, sparse->indices_component_type);
float* writer_head = out + writer_index * floats_per_element; // NO bounds check
cgltf_element_read_float(reader_head, ..., writer_head, floats_per_element);
// OOB write if writer_index >= element_countthird_party/cgltf/cgltf.h - sparse index bounds check in cgltf_validate (line ~1625):
if (sparse->indices_buffer_view->buffer->data) // reads fallback/compressed bytes
{
cgltf_size index_bound = cgltf_calc_index_bound(sparse->indices_buffer_view, ...);
CGLTF_ASSERT_IF(index_bound >= accessor->count, cgltf_result_data_too_short);
}cgltf_calc_index_bound reads buffer_view->buffer->data (the compressed fallback stream). For a meshopt-compressed indices_buffer_view, the actual decoded indices live in buffer_view->data - set only after decodeMeshoptCompression runs, which is after cgltf_validate in ResourceLoader::loadResources:
utility::loadCgltfBuffers(...) // cgltf_load_buffers + cgltf_validate (reads fallback bytes)
utility::decodeMeshoptCompression(...) // sets buffer_view->data = decoded indices (NO re-validation)
pImpl->computeTangents(asset) // TangentsJob -> cgltf_accessor_unpack_floats -> OOB writelibs/gltfio/src/TangentsJob.cpp - write targets (lines 87–155):
unpackedNormals.reset(new float3[vertexCount]);
cgltf_accessor_unpack_floats(baseNormalsInfo, &unpackedNormals[0].x, vertexCount * 3);
// if baseNormalsInfo->is_sparse and sparse index bypass fires → OOB write past unpackedNormals
unpackedPositions.reset(new float3[vertexCount]);
cgltf_accessor_unpack_floats(basePosInfo, &unpackedPositions[0].x, vertexCount * 3);
unpackedTangents.reset(new float4[vertexCount]);
cgltf_accessor_unpack_floats(baseTangentsInfo, &unpackedTangents[0].x, vertexCount * 4);
unpackedTexCoords.reset(new float2[vertexCount]);
cgltf_accessor_unpack_floats(uvInfo, &unpackedTexCoords[0].x, vertexCount * 2);The extended path (libs/gltfio/src/extended/TangentsJobExtended.cpp line 264) also calls cgltf_accessor_unpack_floats via requiresConversion (which returns true for sparse accessors), so it is equally affected.
Trigger conditions
Craft a .glb file where:
- A mesh primitive has a vertex attribute accessor (NORMAL, POSITION, TANGENT, or TEXCOORD_0) with
accessor.sparseset - The sparse accessor's
indices.bufferViewhasEXT_meshopt_compressionapplied (mode = INDICES) - The compressed bytes, when read raw by
cgltf_calc_index_bound, produce values <accessor.count- easy to achieve since meshopt format headers start with 0xe1 and the variable-length encoding naturally produces small byte values when misinterpreted as integers - The decoded sparse index values are >=
accessor.count(=vertexCount) accessor.sparse.count >= 1
Impact
Heap OOB write with both offset and content under attacker control:
- Write offset:
(writer_index - vertexCount) * floats_per_element * sizeof(float)bytes past the allocation - attacker controlswriter_indexvia the meshopt-compressed sparse index stream - Write content: attacker-controlled floats from
sparse.values.bufferView
Any application loading untrusted .glb/.gltf files through Filament's gltfio layer is affected. The write-what-where primitive is sufficient to corrupt adjacent heap allocations or metadata.
Suggested fix
Add a bounds check on writer_index in cgltf_accessor_unpack_floats before the write:
--- a/third_party/cgltf/cgltf.h
+++ b/third_party/cgltf/cgltf.h
size_t writer_index = cgltf_component_read_index(index_data,
sparse->indices_component_type);
+ if (writer_index >= element_count) {
+ return 0;
+ }
float* writer_head = out + writer_index * floats_per_element;Alternatively, fix the root cause by running decodeMeshoptCompression before cgltf_validate in ResourceLoader.cpp, so the bounds check in cgltf_validate operates on decoded values.
Related: #10193 (meshopt bypass on triangle index accessor), #10194 (MAT4 type-width OOB in sparse pass). This is a third distinct variant - same decode-after-validate root cause, different accessor type (sparse vertex attribute indices) and different write target (cgltf_accessor_unpack_floats sparse loop).
Source: google/filament