Vulnerability Report — CVE-2020-1321

Finding CVE-2020-1321: Fuzzing Microsoft Office's 3D Model Parser

A grammar-driven .glb fuzzing campaign found a memory-corruption bug in the shared 3D parser used by Microsoft Word and the Microsoft 3D Viewer. The same input crashed both products at matching call-site offsets. Reported to the Microsoft Security Response Center on January 30, 2020. Microsoft published the fix on June 9, 2020 as the Microsoft Office Remote Code Execution Vulnerability, graded Important, CVSS 7.8, exploitation less likely.

Discovery Context

How the bug was found.

One malformed .glb reaches both Microsoft Word and the Microsoft 3D Viewer through a shared parser.
A malformed .glb reaches both Microsoft Word and the Microsoft 3D Viewer. The two products share their 3D parsing code at the assembly level.

I found this bug several years ago and never published the technical story. This page reconstructs the research from the original fuzzer, the GLB samples it generated, the WinDbg logs, the rendering screenshots, and the disclosure correspondence I still have on disk. The bug was eventually published by Microsoft on June 9, 2020 as the Microsoft Office Remote Code Execution Vulnerability.

Microsoft Office added the ability to insert 3D models into Word, PowerPoint, and Excel documents around 2018. Under the hood, the rendering and parsing path goes through a relatively young engine that is also packaged as a standalone Windows 10 app, the Microsoft 3D Viewer. Two separate binaries - MSOSPECTRE.DLL in Office and Mira.Core.Engine.UWP.dll in 3D Viewer - share parser code at the assembly level. One malformed model can crash both.

The format itself is binary glTF: a 12-byte header, a JSON chunk, an optional BIN chunk. The JSON describes a scene graph in which almost every field is an integer index into another field. scenes reference nodes; nodes reference meshes; meshes reference accessors; accessors reference bufferViews; bufferViews reference buffers. Animations layer in samplers and channels on top. The complexity is the cross-references the parser is expected to keep consistent.

That is where grammar fuzzers earn their keep. Mutational fuzzers tend to break the references and bounce off the parser's "is this even a valid index?" early-exit checks. A grammar that respects the references but deliberately breaks the agreements between them - two accessors sharing a bufferView but disagreeing about how many elements live there - lands deeper in the parser, where arithmetic mistakes are more likely to matter. That made the target attractive: a real parser, a complex indexed format, and a path into Word documents.

How the bucket stood out

The interesting bucket showed up before the MSRC report went out on January 30, 2020. The exact samples attached to that report are no longer in this working folder. The earliest crashing GLB I still have on disk is from February 10, 2020, with additional continued-analysis variants captured around February 23 and 25.

Two things made the bucket stand out from typical Office crashes.

First, the variants in this bucket all landed on the same low-level instruction - rep movs inside VCRUNTIME140!memcpy_repmovs (or its _APP variant in 3D Viewer) - but the surrounding stacks varied with which mesh attribute the parser was setting: Mesh::SetPositions, Mesh::SetIndices, Mesh::SetUV0, Mesh::SetUV1, Mesh::SetColours, Mesh::SetJointData. That is the signature of a single arithmetic mistake in a shared upstream helper, fanned out into many sinks.

Second, the same input crashed both products at matching call-site offsets. The bug was in the shared parser, not in either application's glue. The samples in this bucket reproduced the same crash pattern in both products: winword.exe driven through gfx!Gfx::IModel3DScene::* and mso20win32client ordinals into MSOSPECTRE.DLL, and 3DViewer.exe driven through Mira.Core.Engine.UWP.dll. The shared-code reachability is what made the bug interesting beyond a one-off parser crash.

The crashing copy loop in the shared 3D parsing module, viewed in IDA Pro.
The crashing copy loop in the shared 3D parsing module. Six instructions; the bound in r14 is computed from one attacker-controlled field while the underlying buffer is sized from another.

Under PageHeap, a db rsi - 0x50 against the canonical sample shows the source pointer walking into uncommitted memory while the loop is still iterating; the bytes immediately past the readable range come back as ??. The crash signature is an out-of-bounds read driven by a length the parser trusts from JSON.

Impact Analysis
Severity
Important
Microsoft grading. CVSS 3.1 base 7.8 - AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H.
Status
Public / Patched
Patched in the Microsoft Office June 9, 2020 security update.
CVE
CVE-2020-1321
Assigned by Microsoft. Advisory published 2020-06-09.
Exploitability
Exploitation Less Likely
Microsoft's exploitability assessment on the day of publication.
The Fuzzer and Harness

A grammar tied to the glTF schema, a docx spray, and WinDbg.

The fuzzer is a custom build I wrote on top of Domato, Google Project Zero's grammar fuzzer. Domato's Grammar engine is unmodified. The interesting work is in the glTF-specific grammars, the JSON template, and the Office harness on top.

The end-to-end fuzzing pipeline, six stages from glTF schema to Word + WinDbg.
The end-to-end fuzzing pipeline. Per-section grammars derived from the Khronos glTF 2.0 JSON Schema feed a JSON template, which is wrapped in a GLB binary container, sprayed into a .docx, and observed live under WinDbg.

The per-section grammars were authored from the Khronos glTF 2.0 JSON Schema files. There is one .schema per top-level glTF object: accessor, bufferView, mesh, node, animation.channel, animation.sampler, material, texture, sampler, skin, plus root glTF.schema. The grammars carry not just the leaf field types but the index relationships between objects, so a generated document is structurally valid by construction.

The generator does not just emit one valid model and randomise leaf values. It deliberately produces cross-field inconsistency. A typical generated document carries 100 nodes, each with three animation channels (scale, rotation, translation) referencing samplers, which reference accessors with independently fuzzed count values, all backed by a small pool of bufferViews with fuzzed stride and byteLength. When two accessors backed by the same bufferView disagree about how many elements live there, you land in the arithmetic mistakes that matter for memory safety.

The Office harness

Each iteration:

  1. Generate 100 fuzzed .glb files into the OOXML scaffold's word/media/ folder.
  2. Patch word/document.xml and word/_rels/document.xml.rels to reference all 100 models with <am3d:model3d> elements.
  3. Re-zip the scaffold into a fresh .docx.
  4. Kill any leftover winword.exe, open the new file, and attach WinDbg with a logging command line of the shape .logopen /t <path>; k; r; .logclose; q; q; so every crash drops a timestamped stack and register snapshot next to the input that triggered it.

I ran Word with PageHeap enabled (gflags /p /enable winword.exe). PageHeap turns most heap-adjacent reads and writes into immediate access violations rather than silent corruption. On a closed-source target, that is the difference between a triage-able crash log and an unreproducible hang.

Triage was bucketing by top-frame patterns and minimising by hand on the JSON chunk. Because the GLB header carries lengths, I wrote a small fixer that recomputes both the JSON chunk length and the total file length after edits, so a hand-modified crashing input still loads.

The fuzzer, grammars, JSON template, GLB container, docx-spray harness, and WinDbg automation are mine. The Domato grammar engine they sit on top of is Google Project Zero's, used unmodified.

Technical Breakdown

What is happening, in detail.

The simplified crashing object reduced to its smallest reproducible form looks like this:

simplified GLB JSON chunk
"scenes": [{"nodes": [0]}],
"nodes":  [{"mesh": 0}],
"meshes": [{"primitives": [
  {"attributes": {"POSITION": 0, "NORMAL": 1}, "mode": 6}
]}],
"accessors": [
  {"name": "offset 0 position", "componentType": 5126,
   "count": 73, "type": "VEC3", "bufferView": 0, "normalized": true},
  {"name": "offset 1 normal",   "componentType": 5126,
   "count": 71, "max": [9999, 9999, 9999], "min": [-9999, -9999, -9999],
   "type": "VEC3", "bufferView": 0}
],
"bufferViews": [
  {"buffer": 0, "byteOffset": 0, "byteLength": 4081,
   "target": 34963, "stride": 6}
],
"buffers": [{"byteLength": 18000}]

Three fields disagree with each other:

  • The POSITION accessor declares count: 73 over bufferView 0.
  • The NORMAL accessor declares count: 71 over the same bufferView 0.
  • The shared bufferView declares stride: 6 and byteLength: 4081.

A VEC3 of componentType 5126 (FLOAT) is normally 12 bytes. A stride of 6 cannot physically fit it. The crash evidence is consistent with the parser deriving its copy length from one of those numbers and its bound from another. When the two numbers disagree, the SSE copy loop runs past r14, reading from (or writing to) memory the buffer was never sized for.

Two accessors with count 73 and count 71 over the same bufferView with stride 6, with the parser copying past the allocated bytes.
Two accessors backed by the same bufferView declare different count values. The bufferView declares a stride of 6, too small to physically fit a FLOAT VEC3. The parser ends up copying past the allocated region.

The surviving artifacts show the crash and the memory-corruption behaviour cleanly, but they do not include the patched binary or a binary diff. This page does not name the precise patched function or the precise integer-arithmetic mistake the patch closed; the artifacts do not prove that level of detail.

Microsoft classified CVE-2020-1321 as Remote Code Execution under CWE-119 (improper restriction of operations within the bounds of a memory buffer), CVSS 3.1 base score 7.8 (AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H), and labelled the issue Exploitation Less Likely on the day of publication. The artifacts preserved here demonstrate, narrowly:

  • Reliable crashes in two Microsoft products on attacker-supplied input.
  • An out-of-bounds read driven by a length the parser trusts from JSON, visible under PageHeap.
  • A separate stack-buffer-overrun fast-fail variant on a different sample, indicating the same underlying bug can corrupt the stack under different attribute paths.

What the artifacts do not demonstrate: a working exploit, an ASLR bypass, or a controlled-write primitive against a real target. This page does not claim those.

Patch Vulnerable copy loop in shared 3D parser
asm
loc_3ED4A0:  movups  xmm0, xmmword ptr [rbx]  movups  xmmword ptr [rbx+rax], xmm0  add     rbx, 10h  cmp     rbx, r14  jnz     short loc_3ED4A0
Variant

A separate stack fast-fail trip on a different sample.

A separate variant captured on February 25, 2020 trips c0000409, the security-check / stack-buffer-overrun fast-fail handled by ucrtbase!invoke_watson. It looked like the same family of arithmetic mistake showing up through a different corruption mode: a stack fast-fail rather than the PageHeap OOB-read crash on the canonical sample. Consistent with one underlying bug whose visible failure mode depends on which mesh attribute is currently being populated.

I did not characterise the variant beyond "second corruption mode of the same family." The folder has the WinDbg log; it does not have a per-variant root-cause derivation.

Reachability and Impact

Two products, one shared parser, one delivery vector.

Microsoft classified CVE-2020-1321 as Remote Code Execution. This page does not publish a weaponised chain; it focuses on the fuzzing method, the crash evidence, and the disclosure timeline.

One shared parser, two reachable products: Microsoft Word and Microsoft 3D Viewer.
One shared parser, two reachable products. A .docx carrying an embedded .glb reaches Word; a standalone .glb reaches 3D Viewer. Both call sites land in the same shared code.
SurfaceVerdictSource
Microsoft Word (Office 365) opening a crafted .docx with an embedded malformed .glbConfirmed (demonstrated)Local artifacts: PageHeap WinDbg logs from winword.exe against the canonical samples.
Microsoft 3D Viewer (Windows 10) opening a crafted .glb standaloneConfirmed (demonstrated)Local artifacts: matching crash in Mira.Core.Engine.UWP.dll at the same call-site offsets.
Microsoft Office 2019 (32-bit and 64-bit), Office 2016 for Mac, Office 2019 for Mac, Microsoft 365 Apps Click-to-RunPublicly affected per MSRCMSRC advisory CVE-2020-1321, June 9, 2020.
Preview Pane as an attack vectorNot an attack vectorMSRC advisory FAQ explicitly states the Preview Pane is not an attack vector.
Other Office hosts that consume <am3d:model3d> (PowerPoint, Excel)Reachability not confirmedThe 3D-model feature shipped across Office; the shared parsing module is the same. Not directly tested in the artifacts here.
Office for the Web / Office OnlineReachability not confirmedServer-side handling of 3D models is a different code path; not tested.
Office MobileReachability not confirmedDifferent binary, different platform; not tested.
LibreOffice and other third-party openers of .docxNot affectedThey do not link MSOSPECTRE.DLL or the Mira engine.
Browsers and the open-source glTF ecosystem (three.js, Babylon.js, Blender, glTF Validator)Not affectedDifferent parsers entirely. The bug lives in the Microsoft-specific shared module.

The user-interaction requirement is the standard Office one: a user opens an untrusted document containing the malformed model. Protected View applies in the usual way. Microsoft's Exploitation Less Likely rating reflects the difficulty of turning the primitive into a working exploit on a hardened modern Office build.

Disclosure Timeline

From discovery to coordinated disclosure.

  1. Pre-2020-01-30 Initial fuzzing campaign and discovery. The original samples sent in the report are not preserved on disk; the earliest crashing GLB still in the folder is dated 2020-02-10.
  2. 2020-01-30 Report sent to the Microsoft Security Response Center under a Mimecast signature. Includes the technical write-up, repro recipe, simplified crashing JSON, multiple WinDbg stacks for both Word and 3D Viewer, register dumps, and DLL/version details.
  3. 2020-02-10 to 25 Continued fuzzing; additional crash variants captured, including the stack-buffer-overrun fast-fail variant.
  4. 2020-03-22 Internal note proposing follow-up Office fuzzing vectors (other 3D formats, SVG icons, PowerPoint Morph and Zoom, the document translation flow).
  5. 2020-06-09 Microsoft publishes the security update; CVE-2020-1321 advisory released. Public credit: Menahem Breuer and Ariel Koren of Mimecast Research Labs.
  6. 2020-06-16 MSRC advisory last updated.
  7. 2026 Public reconstruction of the technical story on this page.
Takeaways

Lessons.

  1. Grammar over mutation for indexed-graph formats. Most of the Office-format bugs that get found by random byte mutation live near the byte boundaries: header lengths, sentinel values, simple parsers. The interesting bugs in formats like glTF, OOXML 3D embeddings, and PDF live in the cross-references between objects. A grammar that respects the format's structural invariants and deliberately violates the agreements between sibling fields lands deeper. CVE-2020-1321 is a clean instance: the bug needed a real scene with a real mesh and real animations referencing real accessors, and then a precise inconsistency between two fields the parser trusts to agree.
  2. Document parsers stay valuable. Every couple of years Microsoft adds a new embedded format to Office: 3D models, SVG icons, equation editors, online-source media. Every new format brings a fresh parser written under shipping pressure. The 3D pipeline shipped in Office at the same time it shipped as a standalone Windows 10 app, which doubled the reachable attack surface for the same bug.
  3. Closed-source Windows targets are still tractable. You do not need a sanitiser-instrumented build to find good Office bugs. PageHeap on the target process plus a WinDbg launch line that calls .logopen, dumps the stack and registers, and exits, produces one timestamped log per crash. Pair that with a docx-spray harness that opens 100 fuzzed models per process lifetime, and the throughput is reasonable on a single Windows VM.
  4. The workflow mattered more than the individual crash. The most useful thing in this campaign was the loop: generate, package, launch, log, iterate. Most fuzzing posts focus on the crashing input and skip the plumbing. The plumbing is what made the campaign productive. A fuzzer that finds one crash is a story; a fuzzer that finds whichever crashes the parser has, repeatedly, is a tool.

Attribution

The MSRC public advisory credits Menahem Breuer and Ariel Koren of Mimecast Research Labs. The discovery and fuzzing workflow described here - the grammars, the JSON template, the GLB container, the docx-spray harness, the WinDbg automation - were mine, captured from my own working files preserved from that period. Mimecast was the coordinated disclosure channel through which the report reached MSRC, and the public credit is shared between the two named researchers.

What this page does not include

This page is a technical reconstruction, not a release of operational material. It does not publish raw crashing .glb files, the fuzzing corpus DOCX, exact byte-level repro material, private email content, or anything that would function as exploitation guidance. The reasoning is straightforward: the bug is patched, but the value of publishing operational material from a six-year-old campaign is small and the downside is non-zero. The fuzzing method is what matters. The method is what this page publishes.