← Writeups

The guard that was there eight times and missing once

28 Aug 2026 fuzzinglibE57Formate57point-cloudaflcwe-476null-dereference

TL;DR: Fuzzing libE57Format — the E57 3D point-cloud reader used by CloudCompare, AliceVision, FreeCAD and PDAL — turned up a null-pointer dereference while parsing the XML section of a crafted file. Reported 25 August, fixed and merged upstream by the 28th as 93ddd12f. Three things about getting there were more interesting than the bug itself.

The bug

CompressedVectorNodeImpl holds a prototype_ (the record layout) and a codecs_ vector. Both are attached after construction, via setPrototype() and setCodecs(), so both are legitimately null for part of the object’s life.

The file knows this. Every use of those members is guarded:

if ( prototype_ ) { ... }      // :52
if ( codecs_ )    { ... }      // :97
if ( prototype_ ) { ... }      // :178
if ( codecs_ )    { ... }      // :184
                               // ...and at 227, 231, 244, 253

Eight guards. And then, in isTypeEquivalent():

// Prototypes and codecs must match ???
if ( !prototype_->isTypeEquivalent( cvi->prototype_ ) )

No check — and note it isn’t the receiver that bites, it’s the argument. cvi->prototype_ is a null shared_ptr from a CompressedVector element that a crafted XML section left without a prototype, and StructureNodeImpl::isTypeEquivalent promptly does ni->type() on it.

The author’s own //??? comments sit two functions away, on getPrototype() and getCodecs(): “check defined”. The concern was noted and never followed through.

The fix extends the existing idiom rather than inventing a new one — a missing prototype means “not equivalent”, the same answer a mismatch already gives:

if ( !prototype_ || !cvi->prototype_ ) { return ( false ); }

The corpus had to build itself

The upstream test-data repository ships fifteen .e57 files. They exercise a narrow slice of the format: mostly float and double cartesian points. A campaign seeded with just those ran for an hour and forty minutes and found nothing.

E57’s interesting property is that an XML section describes the layout of the binary section. Every binary field has an attacker-controlled XML twin — the decoder derives its bit widths from XML-declared minima and maxima via bitsNeeded(min, max). That’s the surface, and the shipped files barely touch it.

So the corpus was generated by driving libE57Format’s own Writer API across the axes the samples miss: every node type, bit widths chosen to land on 1, 8, 15, 17, 31 and 33 bits, cartesian versus spherical geometry, invalid-state fields, intensity/colour/row-column/return/timestamp combinations, zero-point and four-thousand-point scans, and Image2D sections.

Two constraints shaped that, and both were discovered by reading the library’s validation rather than by guessing after the errors appeared. pointRangeNodeType cannot be Integer — the API forbids it outright — so bit width has to be driven through ScaledInteger’s scale, since the underlying integer range is (max - min) / scale. And optional-field bounds must be declared or the writer rejects the values; those declared ranges are themselves part of what the decoder reads back.

Union coverage went from 17,856 tuples to 24,039. The crash arrived about nine hours in.

The reproducer wasn’t real yet

This is the part worth carrying to other targets.

E57 files are written in 1024-byte pages: 1020 bytes of data plus a four-byte CRC-32C. The harness ran with ChecksumNone, because with verification on, every mutation dies at the page check before reaching a decoder.

That’s the right call for throughput — and it means the crash the fuzzer produced was not a real-world bug. Under ChecksumAll, which is what every actual caller uses by default, the file is cleanly rejected as corrupt. Reported as-is, the maintainer could not have reproduced it.

Repairing the page checksums fixes that, and the repaired file still crashes with verification enabled. One wrinkle on the way: the first repair attempt wrote the CRC little-endian and the file stayed rejected. Rather than guess, I validated the routine against a known-good file — 366 of 366 pages matched once it was stored big-endian.

The general lesson: if your harness relaxes an integrity check to make fuzzing possible, every crash it finds is a candidate, not a finding, until you restore the check. Two lines in a triage checklist, and the difference between a credible report and a wasted maintainer’s afternoon.

One guard with no reproducer

The patch also guards codecs_, one line below the crashing site, which had the identical gap. There was no reproducer for it at the time — it went in purely because the sibling had the same shape.

Over the following days the campaign produced fourteen more crashes. All the same root cause, and one of them reached codecs_, arriving via VectorNodeImpl::isTypeEquivalent rather than StructureNodeImpl because codecs_ is a vector node. A fix targeting only the crash that had been observed would have left it open.

Not a vulnerability

Andy Maloney’s response to the report:

I consider these kinds of issues (malformed file causing a crash) bugs, not security issues.

That’s his call, and a defensible one for a file-format library. No advisory, no CVE, no embargo — so this is published as an ordinary bug-fix note rather than a security advisory. He wrote a test case, attached it to the pull request, and merged the fix the same day.

Reported to merged in three days.