You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Deeply nested multipart: quadratic parse cost and SIGSEGV from unguarded recursion #366
vmime::message::parse() on a deeply nested multipart/mixed message
does two things:
CPU grows faster than the nesting depth. On a fixed 1 MiB message,
253 levels took 22.3 ms and 998 levels took 448.9 ms (about 4× depth,
about 20× time).
With the default 8 MB stack, 4033 levels abort the process with
SIGSEGV (exit code 139). There is no depth limit. Raising the stack
to 16 MB lets the same message finish, in 19.8 s, so the crash is the
native stack overflowing in the recursive body::parseImpl descent.
RFC 2046 does not limit nesting depth. The input is ordinary MIME. findNextBoundaryPosition() in src/vmime/body.cpp scans the remaining
bytes of each level to find that level's closing delimiter, so the cost
is O(depth × message bytes). I did not find an existing issue for this.
To Reproduce
Build current master (tested 4214d8a) as a static library with
messaging, SASL, TLS, and filesystem features off.
Parse a 1 MiB message of nested multipart/mixed parts, one unique
boundary per level. A self-contained generator is enough: each level
adds its opening headers and its closing delimiter, and the padding
sits after the outermost close.
Run three depths on the default stack (ulimit -s = 8192):
Parsing should stay roughly linear in the message size, and a too-deep
message should fail with a parse error instead of killing the process.
A depth cap on the order of 100 levels, thrown as vmime::exceptions::parse_error, would stop the crash. Remembering how
far findNextBoundaryPosition() has already scanned would remove the
quadratic scan.
vmime: master 4214d8a (0.9.2+ development line). Latest release tag is v0.9.2 (2017). The recursive parse and the boundary scan are present on both; only master was timed.
Additional context
An equal-size message made of many sibling parts, with no extra nesting,
parses in a few milliseconds. Depth is what makes it slow and what
overflows the stack.
Hi @DrNbnonono! Thanks for the very detailed report and the reproduction steps.
Confirmed on master: with the default 8 MB stack, the parser crashes after a few thousand levels of nesting, and the parsing time grows much faster than the depth.
Aside from the missing depth limit, 3 things made the parsing time grow with the depth:
Each nested part wrapped the input stream in yet another parserInputStreamAdapter, so each level of nesting added indirections to every stream access made below it.
The boundary search did not stop at the end of the enclosing part, and was run once more after the closing delimiter. This is what you measured: every level scanned the padding, up to the end of the message.
Each rejected boundary candidate (not at the beginning of a line, or prefix of another boundary) restarted a search which read 4 KB ahead. This one remains exploitable with a depth limit alone: with boundaries a, aa, aaa, etc. nested 100 levels deep, and a body made of a, parsing a 1 MB message took 150 s.
Body parts are no longer split beyond a maximum nesting depth, which defaults to 100 (like Postfix) and can be changed with parsingContext::setMaxNestingDepth().
Boundary searches stay within the enclosing part, and their cost no longer depends on the number of false matches.
I did not go for a parse_error: the parser does not throw on malformed input elsewhere, and an exception would make the whole message unreadable. A multipart part at the maximum depth is simply not split: its body contents are kept as is, as for a non-multipart part, so nothing is lost.
Timings (1 MB message, 8 MB stack):
master
fix
your repro, 998 levels
230 ms
7 ms
your repro, 4033 levels
SIGSEGV
22 ms
padding in the innermost part, 998 levels
2.8 s
97 ms
prefix boundaries, 100 levels
150 s
86 ms
One behaviour change on malformed messages: the boundary of a multipart part is now only searched for within this part. If a sub-part has no closing delimiter, its last part extends to the end of the enclosing part, even if its boundary appears again further in the message (before, this content became epilog text).
Activity
Parser: limit body part nesting depth and bound boundary searches
vincent-richard commentedon Oct 23, 2026
Hi @DrNbnonono! Thanks for the very detailed report and the reproduction steps.
Confirmed on master: with the default 8 MB stack, the parser crashes after a few thousand levels of nesting, and the parsing time grows much faster than the depth.
Aside from the missing depth limit, 3 things made the parsing time grow with the depth:
parserInputStreamAdapter, so each level of nesting added indirections to every stream access made below it.a,aa,aaa, etc. nested 100 levels deep, and a body made ofa, parsing a 1 MB message took 150 s.The fix is in #367:
parsingContext::setMaxNestingDepth().I did not go for a
parse_error: the parser does not throw on malformed input elsewhere, and an exception would make the whole message unreadable. A multipart part at the maximum depth is simply not split: its body contents are kept as is, as for a non-multipart part, so nothing is lost.Timings (1 MB message, 8 MB stack):
One behaviour change on malformed messages: the boundary of a multipart part is now only searched for within this part. If a sub-part has no closing delimiter, its last part extends to the end of the enclosing part, even if its boundary appears again further in the message (before, this content became epilog text).