# CVE-2026-116738, CVE-2026-116739: VMime uuencode decoder: an unsigned underflow of the read length drives an out-of-bounds write on the stack while decoding a crafted MIME body
, Post and Telecommunication Institute of Technology
**Project:** [kisli/vmime]( VMime C++ MIME and email message library, uuencode content-transfer-encoding decoder
**Affected:** v0.9.2 (latest release, tag commit `7627129f4e30064b8fb915fb6339a0aa19a743ca`) and all earlier releases. Also present on default branch master HEAD `4214d8af7c3e0d2e820483aee517113fcce8aaf9` (2026-10-17). The decoder logic is unchanged since the 2013 source reorganization.
**Class:** CWE-191 Integer Underflow, CWE-787 Out-of-bounds Write, CWE-121 Stack-based Buffer Overflow, CWE-908 Use of Uninitialized Resource (secondary)
VMime decodes the body of a MIME part with the encoder named by the part's `Content-Transfer-Encoding` header. When that header is `uuencode` or `x-uuencode`, the body passes through `vmime::utility::encoder::uuEncoder::decode`. That function reads one "line length" byte per line, then reads the encoded bytes for the line into a 64 byte stack buffer.
FINDING 1 is a stack out-of-bounds write. When the line length byte is the ASCII letter `b` (value 98), the decoder treats it as the possible start of a `begin` prelude and reads five more bytes with `inPos = in.read(inBuffer, 5)` (`src/vmime/utility/encoder/uuEncoder.cpp:199`). If those five bytes are not `egin ` the probe fails and the switch falls through, leaving `inPos` as high as 5. For a `b` length byte the per-line encoded length is `inLength = min((UUDECODE('b') * 4) / 3, 64)`, and `UUDECODE('b')` is 2, so `inLength` is 2 (`:183`). The code then reads `in.read(inBuffer + inPos, inLength - inPos)` (`:293`). With `inPos` equal to 5 and `inLength` equal to 2 the argument `inLength - inPos` is `2 - 5`, which underflows the unsigned `size_t` to `SIZE_MAX`. That value is passed as the byte count to a read into a 64 byte stack buffer. On the default Linux build with libstdc++ the read resolves to `inputStreamStringAdapter::read`, whose `std::copy` turns the underflowed count into a `memmove` of `(size_t)(-3)` bytes into `inBuffer + 5`, a stack out-of-bounds write and a crash. The whole path is reachable under the default configuration from any consumer that parses an untrusted message and extracts the part body, which is the normal way an application reads an attachment.
FINDING 2 is a secondary use of uninitialized stack memory. The decode loop writes at most `(inLength / 4) * 3` bytes into the 64 byte `outBuffer`, but then emits `out.write(outBuffer, outLength)` with `outLength` taken straight from the line length byte (`:323`). An attacker sets the length byte to `_` (value 95), which makes `outLength` 63 while the loop fills only 48 bytes, so up to 15 bytes of `outBuffer` that this iteration never wrote are copied into the decoded output. `outBuffer` is never zeroed, so on the first line those bytes are uninitialized stack.
VMime parses a message lazily. The parse step records where each part's encoded body sits in the source buffer and stores the declared encoding. The decode runs later, when the application asks for the content. The chain below is traced on the pinned master commit, with the matching v0.9.2 lines given where they differ.
### Link 1, the public entry stores the body as an encoded region plus the attacker's encoding name
A consumer parses an untrusted message with one of the public `parse` entries on `vmime::message` or `vmime::bodyPart`, which both inherit `component::parse(const string&)`. That wraps the message text in an `inputStreamStringAdapter` and a `parserInputStreamAdapter` (`src/vmime/component.cpp:193`):
shared_ptr stream =
make_shared (buffer);
shared_ptr parser =
make_shared (stream);
parseImpl(ctx, parser, position, end, newPosition);
For a part that is not multipart, `body::parseImpl` reads the `Content-Transfer-Encoding` field into an `encoding` object and stores the body as a `streamContentHandler` over a region of the source stream (`src/vmime/body.cpp:357`):
m_part->getHeader()->findField(fields::CONTENT_TRANSFER_ENCODING);
enc = vmime::encoding(encodingTypes::SEVEN_BIT);
shared_ptr contentStream =
make_shared (
parser->getUnderlyingStream(), position, length
m_contents = make_shared (contentStream, length, enc);
The encoding comes straight from the header, so the attacker chooses it. `getUnderlyingStream()` returns the `inputStreamStringAdapter` built in link 1 (`src/vmime/utility/parserInputStreamAdapter.cpp:55`).
### Link 2, extracting the body runs the decoder named by that encoding
When the application reads the body, `streamContentHandler::extract` sees that the content is encoded and calls the decoder for the stored encoding (`src/vmime/streamContentHandler.cpp:178`):
shared_ptr theDecoder = m_encoding.getEncoder();
utility::progressListenerSizeAdapter plsa(progress, getLength());
theDecoder->decode(*m_stream, os, &plsa);
`encoding::getEncoder()` resolves the name through the factory (`src/vmime/encoding.cpp:111`), and the factory registers the uuencode decoder under both `uuencode` and `x-uuencode` (`src/vmime/utility/encoder/encoderFactory.cpp:45`):
So a part with `Content-Transfer-Encoding: x-uuencode` sends its body through `uuEncoder::decode`, and `*m_stream` is the `seekableInputStreamRegionAdapter` over the attacker's bytes.
### Link 3, the per-line length and the prelude probe
`uuEncoder::decode` uses two 64 byte stack buffers (`src/vmime/utility/encoder/uuEncoder.cpp:159`, v0.9.2 `:151`):
Each loop iteration reads one line length byte, decodes it, and computes how many encoded bytes the line holds (`:182`, v0.9.2 `:172`):
const size_t outLength = UUDECODE(lengthChar);
const size_t inLength = std::min((outLength * 4) / 3, static_cast (64));
`UUDECODE(c)` is `(c - ' ') & 077`, so `outLength` is between 0 and 63 and `inLength` is between 0 and 64. The `inLength` clamp to 64 is what keeps the normal read path inside the buffer, because in the normal path `inPos` is 0.
The length byte is also matched against the uuencode `begin` and `end` markers. When it is `b` the code reads five more bytes to check for `egin ` (`:196`, v0.9.2 `:186`):
// Read 5 characters more to check for begin ("begin ...\r\n" or "begin ...\n")
// ... real "begin" line, parse mode and filename, then: continue;
break; // not a "begin" line: fall through to the per-line read
When the five bytes are not `egin ` the `if` is false and the `break` leaves the switch with `inPos` set to the number of bytes actually read, which is 5 when at least five bytes follow the `b`. For a `b` length byte `UUDECODE('b')` is `(98 - 32) & 077`, which is 2, so `inLength` is 2. Nothing resets `inPos` or re-derives `inLength` after the probe.
### Link 4, the underflow and the oversized read (the sink)
After the switch the decoder reads the line body (`:293`, v0.9.2 `:281`):
if (in.read(inBuffer + inPos, inLength - inPos) != inLength - inPos) {
With `inLength` equal to 2 and `inPos` equal to 5, `inLength - inPos` is `2 - 5`. These are `size_t`, so the result wraps to `0xFFFFFFFFFFFFFFFD`, `SIZE_MAX`. The call becomes `in.read(inBuffer + 5, 0xFFFFFFFFFFFFFFFD)`, a request to read `SIZE_MAX` bytes into a buffer that has 59 bytes left. There is no check that `inPos read(data, remaining);
readBytes = m_stream->read(data, count); // count is still ~SIZE_MAX
The underlying `inputStreamStringAdapter::read` then runs its else branch, because `m_pos + count` wraps to a value below `m_end` (`src/vmime/utility/inputStreamStringAdapter.cpp:65`):
size_t inputStreamStringAdapter::read(byte_t* const data, const size_t count) {
std::copy(m_buffer.begin() + m_pos, m_buffer.end(), data);
std::copy(m_buffer.begin() + m_pos, m_buffer.begin() + m_pos + count, data);
In the else branch the second iterator is `m_buffer.begin() + m_pos + count`, which is `m_buffer.begin() + (m_pos - 3)`, so the range runs from a pointer to a pointer three positions before it. `std::copy` for a contiguous range of a trivially copyable type in libstdc++ computes `const ptrdiff_t _Num = __last - __first;` which is `-3`, then runs `if (_Num) __builtin_memmove(__result, __first, sizeof(_Tp) * _Num);`. The guard is a nonzero test, not a positive test, so it fires, and `sizeof(char) * (ptrdiff_t)(-3)` converts to the unsigned byte count `0xFFFFFFFFFFFFFFFD`. The result is `memmove(inBuffer + 5, source, ~SIZE_MAX)`, which writes past the 64 byte `inBuffer` on the stack until it reaches an unmapped page. That is the out-of-bounds write.
### Why the clamp does not help, and the guarded contrast
The `inLength` clamp to 64 at `:183` protects only the normal path where `inPos` is 0. The `begin` probe is the one place that advances `inPos` before the read, and it is also the one case where `inLength` is tiny (2 for a `b` byte), so the probe and the clamp disagree and the subtraction underflows. The sibling `end` probe reads only three bytes and its length byte `e` decodes to `UUDECODE('e')` equal to 5, so `inLength` is 6 and `inLength - inPos` stays non-negative. That path is the contrast that shows the defect is specific to the `b` case, not a property of the whole function.
### FINDING 2, uninitialized stack bytes written to the decoded output
The decode loop fills `outBuffer` three bytes per four input bytes (`:301`), but the amount written out is the raw length byte value (`:323`, v0.9.2 `:311`):
for (size_t i = 0, j = 0 ; i >P: message with Content-Transfer-Encoding x-uuencode and body starting with letter b
P->>B: store body region plus the x-uuencode encoding
B-->>A: parse returns, nothing decoded yet
E->>D: run uuencode decoder on the body region
D->>D: length byte b, read 5 prelude bytes, inPos becomes 5, inLength is 2
D->>S: read into inBuffer plus 5 with count inLength minus inPos which underflows to SIZE_MAX
S-->>D: memmove of SIZE_MAX bytes into the 64 byte stack buffer
Note over S,D: stack out-of-bounds write and crash
The trigger is a single MIME message. The body is tagged `x-uuencode` and its first line length byte is `b`, followed by five bytes that are not `egin `, so the decoder's begin probe consumes five bytes and then reads the line body with the underflowed count. The harness below builds that message and reads the body the way a normal consumer reads an attachment.
// First body byte is 'b' (0x62), which sends the decoder into its
// "begin" prelude probe. The five bytes are not "egin ", so the
// probe fails and inPos becomes 5, while inLength for a 'b' length
// byte is only 2. The read count inLength - inPos then underflows.
body += "ZZZZZ"; // not "egin ", makes inPos == 5
body.append(512, 'A'); // bytes the oversized read runs over
"Content-Type: application/octet-stream\r\n"
"Content-Transfer-Encoding: x-uuencode\r\n"
vmime::shared_ptr msg = vmime::make_shared ();
vmime::utility::outputStreamStringAdapter os(out);
// Normal consumer path: read the (encoded) body contents.
msg->getBody()->getContents()->extract(os); // runs uuEncoder::decode
std::cout (3));
- out.write(outBuffer, outLength);
+ out.write(outBuffer, std::min(outLength, decoded));
where `decoded` is a `size_t` set to 0 before the loop.
Short term, add the `inPos > inLength` guard before the per-line read so the unsigned subtraction cannot wrap, and bound the output write to the number of bytes the loop produced. Medium term, make `inputStreamStringAdapter::read` and `seekableInputStreamRegionAdapter::read` treat a count that pushes the position past the end as a request for the remaining bytes rather than forwarding a wrapped value, and assert that a read never asks for more than its destination buffer holds. Defense in depth, have the decoders reject a line length byte that is not consistent with the bytes available, and validate `inPos Reported by HaiND (Nguyen Dinh Hai), Post and Telecommunication Institute of Technology, [github.com/Haind03](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
