From beb296897e42419fabbe19ace1386890f7794499 Mon Sep 17 00:00:00 2001 From: Fedor Chelnokov Date: Mon, 7 Sep 2026 22:45:37 +0300 Subject: [PATCH 1/3] Fix heap-buffer-overflow in _ctmStreamReadSTRING via 32-bit length overflow A crafted .ctm file with a string length of 0xFFFFFFFF made malloc(len + 1) wrap in 32-bit to malloc(0); the subsequent read then wrote up to len attacker-controlled bytes past the 1-byte allocation. Reachable from the public ctmLoad() through the file-comment field (and later UV/attribute name fields) for all methods. Allocate with size_t so len + 1 cannot wrap, and require the read to return exactly len bytes, otherwise reject the file as CTM_BAD_FORMAT. A failed malloc now sets CTM_OUT_OF_MEMORY instead of being silently ignored. Reported by Kamal Sentassi, S9S Security Research (coordinated disclosure). Co-Authored-By: Claude Fable 5.1 --- lib/stream.c | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/stream.c b/lib/stream.c index 02f491f..efd11cf 100644 --- a/lib/stream.c +++ b/lib/stream.c @@ -135,12 +135,25 @@ void _ctmStreamReadSTRING(_CTMcontext * self, char ** aValue) // Read string if(len > 0) { - *aValue = (char *) malloc(len + 1); + // Compute the allocation size in size_t so that (len + 1) cannot wrap + // around to a tiny buffer for a crafted length such as 0xFFFFFFFF. + *aValue = (char *) malloc((size_t) len + 1); if(*aValue) { - _ctmStreamRead(self, (void *) *aValue, len); + // Only terminate if the requested bytes were really available; a short + // read on a truncated/crafted file would otherwise leave the buffer + // partially written (and, before the size_t fix above, overflow it). + if(_ctmStreamRead(self, (void *) *aValue, len) != len) + { + free(*aValue); + *aValue = (char *) 0; + self->mError = CTM_BAD_FORMAT; + return; + } (*aValue)[len] = 0; } + else + self->mError = CTM_OUT_OF_MEMORY; } } From 113be8b9fa62159a18a0471eb151e6dfbeb1456f Mon Sep 17 00:00:00 2001 From: Fedor Chelnokov Date: Mon, 7 Sep 2026 23:05:00 +0300 Subject: [PATCH 2/3] Guard string length and propagate parse errors from _ctmStreamReadSTRING The size_t cast alone does not stop the wrap on a 32-bit size_t target (e.g. the Emscripten/wasm32 build), where (size_t)len + 1 still wraps to 0 for len == 0xFFFFFFFF. Reject that length outright before allocating. Callers previously ignored the error _ctmStreamReadSTRING sets: after a rejected string, ctmLoadCustom and the RAW/MG1/MG2 decoders kept reading, so mError could be overwritten and the file processed further on garbage. Return early after each string read (freeing the temp int buffers in MG2). Co-Authored-By: Claude Fable 5.1 --- lib/compressMG1.c | 4 ++++ lib/compressMG2.c | 10 ++++++++++ lib/compressRAW.c | 4 ++++ lib/openctm.c | 2 ++ lib/stream.c | 13 ++++++++----- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/compressMG1.c b/lib/compressMG1.c index 4cc00aa..f70206f 100644 --- a/lib/compressMG1.c +++ b/lib/compressMG1.c @@ -324,6 +324,8 @@ int _ctmUncompressMesh_MG1(_CTMcontext * self) } _ctmStreamReadSTRING(self, &map->mName); _ctmStreamReadSTRING(self, &map->mFileName); + if(self->mError != CTM_NONE) + return CTM_FALSE; if(!_ctmStreamReadPackedFloats(self, map->mValues, self->mVertexCount, 2)) return CTM_FALSE; map = map->mNext; @@ -339,6 +341,8 @@ int _ctmUncompressMesh_MG1(_CTMcontext * self) return 0; } _ctmStreamReadSTRING(self, &map->mName); + if(self->mError != CTM_NONE) + return CTM_FALSE; if(!_ctmStreamReadPackedFloats(self, map->mValues, self->mVertexCount, 4)) return CTM_FALSE; map = map->mNext; diff --git a/lib/compressMG2.c b/lib/compressMG2.c index abe0759..a87845e 100644 --- a/lib/compressMG2.c +++ b/lib/compressMG2.c @@ -1280,6 +1280,11 @@ int _ctmUncompressMesh_MG2(_CTMcontext * self) } _ctmStreamReadSTRING(self, &map->mName); _ctmStreamReadSTRING(self, &map->mFileName); + if(self->mError != CTM_NONE) + { + free((void *) intUVCoords); + return CTM_FALSE; + } map->mPrecision = _ctmStreamReadFLOAT(self); if(map->mPrecision <= 0.0f) { @@ -1319,6 +1324,11 @@ int _ctmUncompressMesh_MG2(_CTMcontext * self) return CTM_FALSE; } _ctmStreamReadSTRING(self, &map->mName); + if(self->mError != CTM_NONE) + { + free((void *) intAttribs); + return CTM_FALSE; + } map->mPrecision = _ctmStreamReadFLOAT(self); if(map->mPrecision <= 0.0f) { diff --git a/lib/compressRAW.c b/lib/compressRAW.c index b3c7811..8e58978 100644 --- a/lib/compressRAW.c +++ b/lib/compressRAW.c @@ -157,6 +157,8 @@ int _ctmUncompressMesh_RAW(_CTMcontext * self) } _ctmStreamReadSTRING(self, &map->mName); _ctmStreamReadSTRING(self, &map->mFileName); + if(self->mError != CTM_NONE) + return 0; for(i = 0; i < self->mVertexCount * 2; ++ i) map->mValues[i] = _ctmStreamReadFLOAT(self); map = map->mNext; @@ -172,6 +174,8 @@ int _ctmUncompressMesh_RAW(_CTMcontext * self) return 0; } _ctmStreamReadSTRING(self, &map->mName); + if(self->mError != CTM_NONE) + return 0; for(i = 0; i < self->mVertexCount * 4; ++ i) map->mValues[i] = _ctmStreamReadFLOAT(self); map = map->mNext; diff --git a/lib/openctm.c b/lib/openctm.c index ae1c659..357f328 100644 --- a/lib/openctm.c +++ b/lib/openctm.c @@ -1234,6 +1234,8 @@ CTMEXPORT void CTMCALL ctmLoadCustom(CTMcontext aContext, CTMreadfn aReadFn, self->mAttribMapCount = _ctmStreamReadUINT(self); flags = _ctmStreamReadUINT(self); _ctmStreamReadSTRING(self, &self->mFileComment); + if(self->mError != CTM_NONE) + return; // Allocate memory for the mesh arrays self->mVertices = (CTMfloat *) malloc(self->mVertexCount * sizeof(CTMfloat) * 3); diff --git a/lib/stream.c b/lib/stream.c index efd11cf..7d52988 100644 --- a/lib/stream.c +++ b/lib/stream.c @@ -132,17 +132,20 @@ void _ctmStreamReadSTRING(_CTMcontext * self, char ** aValue) // Get string length len = _ctmStreamReadUINT(self); + // len + 1 would wrap to 0 (also for a 32-bit size_t), giving a tiny buffer + if(len == ~(CTMuint) 0) + { + self->mError = CTM_BAD_FORMAT; + return; + } + // Read string if(len > 0) { - // Compute the allocation size in size_t so that (len + 1) cannot wrap - // around to a tiny buffer for a crafted length such as 0xFFFFFFFF. *aValue = (char *) malloc((size_t) len + 1); if(*aValue) { - // Only terminate if the requested bytes were really available; a short - // read on a truncated/crafted file would otherwise leave the buffer - // partially written (and, before the size_t fix above, overflow it). + // A short read means a truncated or crafted file if(_ctmStreamRead(self, (void *) *aValue, len) != len) { free(*aValue); From 838a510daac28af2f2660f9215d580cbb341cee8 Mon Sep 17 00:00:00 2001 From: Fedor Chelnokov Date: Tue, 8 Sep 2026 10:10:29 +0300 Subject: [PATCH 3/3] Reset stale error on load, clear mesh on rejected header, stop parsing after a failure ctmLoadCustom now reads mError, so a stale error left by a previous call (without ctmGetError) would otherwise abort a valid load; reset it up front. Clear the mesh counts before returning on a rejected file comment so ctmGetInteger does not report file-supplied counts with NULL arrays. _ctmStreamReadSTRING returns immediately if an earlier read already failed, so the paired mName/mFileName reads do not proceed with a garbage length. --- lib/openctm.c | 6 +++++- lib/stream.c | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/openctm.c b/lib/openctm.c index 357f328..8310a59 100644 --- a/lib/openctm.c +++ b/lib/openctm.c @@ -1201,8 +1201,9 @@ CTMEXPORT void CTMCALL ctmLoadCustom(CTMcontext aContext, CTMreadfn aReadFn, self->mReadFn = aReadFn; self->mUserData = aUserData; - // Clear any old mesh arrays + // Clear any old mesh arrays and any stale error from a previous call _ctmClearMesh(self); + self->mError = CTM_NONE; // Read header from stream if(_ctmStreamReadUINT(self) != FOURCC("OCTM")) @@ -1235,7 +1236,10 @@ CTMEXPORT void CTMCALL ctmLoadCustom(CTMcontext aContext, CTMreadfn aReadFn, flags = _ctmStreamReadUINT(self); _ctmStreamReadSTRING(self, &self->mFileComment); if(self->mError != CTM_NONE) + { + _ctmClearMesh(self); return; + } // Allocate memory for the mesh arrays self->mVertices = (CTMfloat *) malloc(self->mVertexCount * sizeof(CTMfloat) * 3); diff --git a/lib/stream.c b/lib/stream.c index 7d52988..51c600d 100644 --- a/lib/stream.c +++ b/lib/stream.c @@ -129,6 +129,10 @@ void _ctmStreamReadSTRING(_CTMcontext * self, char ** aValue) *aValue = (char *) 0; } + // Do not keep parsing after an earlier read has already failed + if(self->mError != CTM_NONE) + return; + // Get string length len = _ctmStreamReadUINT(self);