Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@
##### Fixes :wrench:

- Fixed PNTS conversion to reject section lengths that exceed the declared tile byte length.

##### Fixes :wrench:

- Fixed potential out-of-bounds access while decoding Draco-compressed glTFs.
- Fixed potential integer overflow and out-of-bounds write while decoding glTFs using `EXT_meshopt_compression`.

### v0.64.0 - 2026-09-01

Expand Down
31 changes: 25 additions & 6 deletions CesiumGltfReader/src/decodeMeshOpt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include <cstddef>
#include <cstdint>
#include <limits>
#include <span>
#include <utility>
#include <vector>
Expand Down Expand Up @@ -122,21 +123,39 @@ void decodeMeshOpt(Model& model, CesiumGltfReader::GltfReaderResult& readGltf) {
continue;
}

const int64_t bufferSize =
static_cast<int64_t>(pBuffer->cesium.data.size());
// Phrased as a subtraction so that byteOffset + byteLength is never
// computed; that addition can itself overflow.
if (pMeshOpt->byteOffset < 0 || pMeshOpt->byteLength < 0 ||
static_cast<size_t>(pMeshOpt->byteOffset + pMeshOpt->byteLength) >
pBuffer->cesium.data.size()) {
pMeshOpt->byteOffset > bufferSize ||
pMeshOpt->byteLength > bufferSize - pMeshOpt->byteOffset) {
readGltf.warnings.emplace_back(
"The EXT_meshopt_compression extension has a bufferView that "
"extends beyond its buffer.");
continue;
}
int64_t byteLength = pMeshOpt->byteStride * pMeshOpt->count;
if (byteLength < 0) {
readGltf.warnings.emplace_back("The EXT_meshopt_compression extension "
"has a negative byte length.");

if (pMeshOpt->count <= 0 || pMeshOpt->byteStride <= 0) {
readGltf.warnings.emplace_back(
"The EXT_meshopt_compression extension has a non-positive count or "
"byteStride.");
continue;
}

// Division rather than multiplication, because signed overflow is
// undefined behavior; the product must never be computed unless it is
// already known to fit.
if (pMeshOpt->count >
std::numeric_limits<int64_t>::max() / pMeshOpt->byteStride) {
readGltf.warnings.emplace_back(
"The EXT_meshopt_compression extension has a count and byteStride "
"whose product overflows.");
continue;
}

const int64_t byteLength = pMeshOpt->count * pMeshOpt->byteStride;

std::vector<std::byte> data;
data.resize(static_cast<size_t>(byteLength));
if (decodeBufferView(
Expand Down
59 changes: 59 additions & 0 deletions CesiumGltfReader/test/TestGltfReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,65 @@ TEST_CASE("Can decompress meshes using EXT_meshopt_compression") {
}
}

namespace {
std::string meshOptExtension(
int64_t byteStride,
int64_t count,
int64_t byteOffset = 0,
int64_t byteLength = 32) {
return fmt::format(
R"({{"buffer":0,"byteOffset":{},"byteLength":{},"mode":"ATTRIBUTES","filter":"NONE","byteStride":{},"count":{}}})",
byteOffset,
byteLength,
byteStride,
count);
}

std::vector<std::byte> meshOptGltf(const std::string& extensionJson) {
// A 32-byte all-zero buffer, large enough to pass the compressed range check.
const std::string dataUri =
"data:application/octet-stream;base64," + std::string(43, 'A') + "=";
const std::string gltf = fmt::format(
R"({{"asset":{{"version":"2.0"}},"extensionsUsed":["EXT_meshopt_compression"],)"
R"("buffers":[{{"byteLength":32,"uri":"{}"}}],)"
R"("bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":32,)"
R"("extensions":{{"EXT_meshopt_compression":{}}}}}]}})",
dataUri,
extensionJson);
const std::byte* pStart = reinterpret_cast<const std::byte*>(gltf.data());
return std::vector<std::byte>(pStart, pStart + gltf.size());
}
} // namespace

TEST_CASE("Rejects malformed EXT_meshopt_compression bufferViews") {
constexpr int64_t int64Max = std::numeric_limits<int64_t>::max();
std::string extensionJson;

SUBCASE("count times byteStride overflows to a small positive value") {
// The reported proof-of-concept: 4 * (2^62 + 4) wraps to 16.
extensionJson = meshOptExtension(4, (int64_t(1) << 62) + 4);
}
SUBCASE("count times byteStride is just above int64 max") {
extensionJson = meshOptExtension(4, int64Max / 4 + 1);
}
SUBCASE("count is zero") { extensionJson = meshOptExtension(4, 0); }
SUBCASE("count is negative") { extensionJson = meshOptExtension(4, -1); }
SUBCASE("byteStride is zero") { extensionJson = meshOptExtension(0, 8); }
SUBCASE("byteStride is negative") { extensionJson = meshOptExtension(-4, 8); }
SUBCASE("compressed byteOffset plus byteLength overflows") {
extensionJson = meshOptExtension(4, 8, int64Max, int64Max);
}

GltfReader reader;
GltfReaderResult result = reader.readGltf(meshOptGltf(extensionJson));

REQUIRE(result.model);
CHECK(!result.warnings.empty());

// The bufferView must be left alone; no decoded buffer is appended.
CHECK(result.model->buffers.size() == 1);
}

TEST_CASE("Read TriangleWithoutIndices") {
std::filesystem::path gltfFile = CesiumGltfReader_TEST_DATA_DIR;
gltfFile /=
Expand Down
Loading