Fuzzing WAV Decoders: Thirteen Defects in Three Implementations

A WAV file is a list of chunks that declare their own sizes, and a header that declares how much audio follows. We tested three independent decoders and uncovered 13 defects, nearly all of them a number in the file being believed.

Every other article in this series has an attacker on a network. This one does not. The attacker here is a file.

That makes the reach larger rather than smaller. An audio file arrives as an email attachment, a download, a voice message, a track streamed to a speaker, a sample loaded by a plugin, a ringtone, a file on a USB stick plugged into a car. Each of those is a decoder being handed bytes chosen by someone else, usually with no prompt, no permission and no user action beyond pressing play.

We tested three independent WAV decoders and uncovered 13 defects. One of them is in the firmware of a wireless speaker, where the 640-byte buffer and the file both arrive over the air.

WAV is a format that describes itself

A WAV file is RIFF: a sequence of chunks, each with a four-character identifier and a 32-bit length, followed by that much data. Inside, a fmt chunk declares the sample rate, channel count, bit depth and block alignment, and a data chunk carries the audio.

Almost every number that matters is in the file, and almost every number in the file is written by whoever made it. A decoder's job is to disbelieve all of them. Thirteen times across three codebases, one was believed.

The declared size nothing compares against the file

Product A reads the declared size of a metadata chunk and uses it without ever bounding it against the size of the file that contains it. The chunk says it is large, and the decoder takes it at its word.

Product C reads a frame count from the header and trusts it against no data at all. The code does derive the frame count from the actual payload, but only when the header says zero. Any non-zero value is accepted as fact.

Product B walks the chunk list without a termination condition that holds. A crafted chunk size sends the walk somewhere it does not come back from.

Product A also mishandles one of RIFF's own rules. Chunks are padded to even length, and the walker does not skip the pad byte, so a file with an odd-sized chunk desynchronises the walk from the structure it is walking.

The arithmetic between the header and the allocation

Product C produced the cleanest example in the whole series of one allocation path going wrong three separate ways.

A 64-bit frame count is masked with a constant of the wrong width, so every bit above 31 is discarded. The allocation size is computed in 64 bits and guarded only by checking that it survives the cast to a platform-sized integer, which is not the same as checking that it is correct. And a byte-swapping pass bounds itself with a length computed at 16 bits per sample while striding by the bit depth the file declared, so a file claiming a wider depth strides past the end of the buffer the bound was calculated for.

Each of those is individually a small mistake in integer handling. Together they are a buffer whose size and whose traversal disagree, with the file choosing both.

Product B is the one to look at if you build hardware. A single header field, the block alignment, drives a read into a fixed 640-byte buffer. The field is a 16-bit number in the file.

The decode loop checks the bound at the wrong rate

Two findings in Product C are the same mistake at different granularity.

One ADPCM decoder consumes more of each compressed block than the format's own validation allocated for it. Another tests its output bound once per sample pair rather than once per sample, so a loop that is checked often enough to look safe overshoots by one element every time it runs.

A bound that is tested at the wrong frequency is harder to see in review than a bound that is missing, because the code visibly contains the check.

The guard that exists, somewhere else

The clearest pattern in Product A is not absence. It is placement.

The library has a size guard that rejects a file too short to contain a header. That guard is on the function that loads from a path. The function that loads the same format from a memory buffer does not have it, and both reach the same parser.

A helper that converts two bytes into an integer performs no bounds check of its own, and the format header fields are read through it. And two places treat a structurally impossible value as a programming error, asserting rather than rejecting, which turns a malformed file into a process abort on any build that keeps assertions enabled.

That last one is a design decision more than a bug. A file saying something impossible is not a programming error. It is the normal case for a parser exposed to input, and it is the whole reason the parser has to validate at all.

Finding these with Penzzer

Select the File module and choose WAV.

Penzzer parses the format with a grammar rather than treating a file as a bag of bytes, so mutation targets the fields that actually exist: the chunk identifiers and their declared sizes, the sample rate, the channel count, the bit depth, the block alignment, the frame count, and the relationship between a declared length and the bytes that follow it. The declared size of a chunk is a field, so it varies as a field, independently of the data it describes.

That distinction is what reaches the defects above. Every one of them is a disagreement between a number the file declares and the data the file actually contains. A mutation strategy that flips random bytes mostly produces files that fail the format's own identifier checks and never reach the decoder at all. A grammar-driven one holds the file valid everywhere except the field under test, which is the only way the byte after a short chunk is ever evaluated.

WAV sits among 17 binary formats with a format grammar, alongside PNG, JPEG, TIFF, GIF, BMP, MP3, MP4, MKV, AVI, FLAC, OGG, AU, ZIP, TAR, RAR and GZIP, plus CSV and PDF handled as document formats.

What this article is, and is not

The 13 defects above are a sample of what this kind of testing surfaces, not a description of Penzzer's WAV coverage. They are what three specific decoders happened to get wrong.

The attack library spans defect classes well beyond what this article shows: memory safety, information disclosure, resource exhaustion, availability, hangs and algorithmic complexity, input validation, protocol state logic, type and format handling, and error handling. Across the 107 protocols with a protocol-aware target, a run also generates its test cases from the structure of the protocol itself, walking each field through the boundaries where parsers break.

The same limit applies in reverse. What a test looks for is a property of the test, not a claim about your device. A case that reports nothing means that shape did not provoke anything.

If your product opens files it did not create

The question is rarely whether you parse audio. It is whether you know you do.

A decoder arrives inside a media framework, a game engine, a notification sound, a firmware update that plays a chime, a Bluetooth sink, a voice assistant, a plugin host. It is usually three dependencies down from anything anyone chose, and it was often written to read files produced by a trusted encoder.

Three questions. Is every declared size compared against the bytes that are actually present, including the file's own total length? Is every value that reaches an allocation checked for the arithmetic that produced it, and not only for the result surviving a cast? And does every entry point into your parser carry the same guards, or only the one the author expected people to use?

A file format is a protocol with no peer on the other end. Nothing negotiates, nothing retries, and nothing can be asked to resend. The only thing standing between the file and the buffer is whether the decoder believed what it read.

Other Post
Uncover Hidden Vulnerabilities

Identify security flaws before attackers do, automatically and at scale with Penzzer's intelligent fuzzing engine.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.