Skip to content

Deeply nested multipart: quadratic parse cost and SIGSEGV from unguarded recursion #366

Closed

Description

@DrNbnonono
Image

Describe the bug

vmime::message::parse() on a deeply nested multipart/mixed message
does two things:

  1. 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).
  2. 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

  1. Build current master (tested 4214d8a) as a static library with
    messaging, SASL, TLS, and filesystem features off.

  2. 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.

  3. Run three depths on the default stack (ulimit -s = 8192):

    ./msl-051-repro 253
    ./msl-051-repro 998
    ./msl-051-repro 4033
    
  4. Repeat 4033 with a larger stack:

    bash -c 'ulimit -s 16384; ./msl-051-repro 4033'
    

Expected behavior

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.

Output listings

$ ulimit -s
8192
$ ./msl-051-repro 253
depth   253 :       22.3 ms  (1048560 bytes)
$ ./msl-051-repro 998
depth   998 :      448.9 ms  (1048560 bytes)
$ ./msl-051-repro 4033
segmentation fault (core dumped)
$ echo $?
139
$ bash -c 'ulimit -s 16384; ./msl-051-repro 4033'
depth  4033 :    19753.1 ms  (1048560 bytes)

Environment

  • OS: GNU/Linux
  • Distro: Ubuntu
  • Distro version: 24.04.5
  • 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.

Drnbnonono

Activity

  1. added a commit that references this issue on Oct 23, 2026
    08b8fc3
  2. vincent-richard commented on Oct 23, 2026

    @vincent-richard
    Member

    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.

    The fix is in #367:

    • 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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions