Excel

Reading Excel Macros Without Running Them

An .xlsm is three file formats stacked inside each other, and none of them is deflate. Here is how AdaptivMapr reads the VBA project out of a workbook — deterministically, for free, and without ever executing a line — plus why a password-locked project is not encrypted and why a partial macro strip is worse than none.

The AdaptivMapr TeamEngine10 min read

A partner sends you an .xlsm. Somewhere inside it is code that Excel will happily run the moment somebody clicks “Enable Content”. Before that file goes anywhere near a pipeline, two questions need answers: is there executable content in here, and what does it reach for. Both can be answered without running a single instruction — and running it is the one thing you must never do to find out.

This is how AdaptivMapr reads macros. It is a reader, end to end. There is no eval, no interpreter, no sandbox execution in the scanning path; every function treats macro bytes as data.

An .xlsm is three formats, stacked

The outer layer is familiar: an .xlsm (and .xltm) is an ordinary OOXML zip, the same as an .xlsx. The difference is one extra part — xl/vbaProject.bin — plus a relationship pointing at it and a macro-enabled content type on the workbook. Open the zip and you can see the part; you still cannot read a line of code, because that part is itself two more formats:

  1. CFB / OLE2 ([MS-CFB]). vbaProject.bin is a whole miniature filesystem: a sector allocation table, a directory tree, and a separate “mini” allocator for small streams. You have to implement the allocator to get path → bytes out of it.
  2. VBA compression ([MS-OVBA] §2.4.1). Module source is stored in a custom LZ-ish container with position-dependent copy tokens. It is not deflate. The browser and Workers DecompressionStream cannot read it, which surprises everyone exactly once; the decompressor has to be written against the spec.
  3. The VBA/dir record stream — itself compressed — which names each module and records the offset in its stream where the source begins. Everything before that offset is compiled p-code, not text.

We implement all three by hand, with no dependency, the same way the rest of the codebase hand-rolls its ZIP and OOXML handling. A VBA parser off a package registry would pull a large surface into an edge Worker bundle to read a handful of structures — and it would be one more piece of code with an appetite for the same bytes an attacker controls.

Two bugs that only a real file finds

Format work is humbling. Two examples from a single customer project of nineteen modules that read back as binary junk:

PROJECTVERSION lies about its size. The dir stream is a walk of size-prefixed records. The PROJECTVERSION record (0x0009) declares a size of 4 and then carries 6 bytes. A walker that trusts the declared size desynchronises on the very next record, gives up, and falls back to “every stream is a module whose source starts at offset 0” — which points the decompressor at the compiled p-code sitting in front of the real source. The p-code decompresses “successfully” into garbage. The file was fine the whole time; the reader was wrong, and the user was told their macros were corrupt.

A container that decompresses is not necessarily the right container. The fix for the above is to accept a decompression result only when it looks like module text — no control bytes — and otherwise keep scanning for the container that yields text. Format readers need a plausibility check, not just a success flag.

What the report contains

The scan is deterministic and free. No model, no network, no persistence — substring and structure work over bytes we already hold. It runs with the metered AI layers switched off entirely, and it adds no billing surface. It returns:

  • Modules — names and source, as recovered from the container.
  • Entry points — the handlers Excel invokes on its own: Auto_Open, Auto_Close, Workbook_Open, Workbook_BeforeClose, Worksheet_Change and friends. This is the difference between “code exists in this file” and “code runs when this file is opened”, which is the distinction that actually matters to a reviewer.
  • Risky-call signals, each with a severity: shelling out, network access, dynamic dispatch, native API declarations, auto-run handlers. Note the pedantic detail — VBA allows Shell "cmd.exe", vbHide with no parentheses, and that statement form is how it is almost always written, so a detector that only looks for Shell( misses the real cases.
  • Excel 4.0 (XLM) macro sheets. These are a separate, still-live macro mechanism that does not live in vbaProject.bin at all — they sit in the worksheet layer, and a VBA-only reader misses them completely.
  • The locked flag — see below, because it is the one field on this report that is routinely misread.

“Locked” is a UI setting, not encryption

A VBA project can carry a DPB password entry. Excel honours it by refusing to show you the project in its editor. That is the entire mechanism. The module source is stored exactly as it would be without the password, and any reader that walks the container recovers it in full — ours included, and we report it either way.

A locked VBA project is a closed door with no wall around it. Telling a customer their macros are “protected” because that flag is set would be the most dangerous sentence on the page.

So the flag is surfaced as what it is — a UI lock — and never as protection. If your threat model assumed otherwise, this is a good day to find that out.

Fail-soft, in the safe direction

Every layer returns null or a partial result with a warning rather than throwing. A corrupt, truncated or deliberately hostile container must degrade the file to “macros present, source unavailable” — it must never take down the parse of the workbook it came from, and it must never take down the request.

There is one rule inside that which is worth stating on its own, because it is the single genuinely dangerous failure mode this module has: a vbaProject.bin that we cannot parse is still reported as macro-bearing. Never as clean. A reader giving up is not evidence of absence, and a scanner that reports “no macros found” when it means “I could not read this” is worse than no scanner at all.

Writing files: three-quarters of a strip is worse than none

The reading problem has a writing twin. When AdaptivMapr generates a workbook from a customer template, the output is macro-free by default — a template that arrives as an .xlsm does not silently carry its VBA project into the file we hand back.

Stripping correctly means doing all four of these:

  1. drop the xl/vbaProject.bin part;
  2. drop the relationship that points at it;
  3. demote the workbook’s content type from macro-enabled;
  4. warn the caller that it happened.

Do three of the four and you have produced the exact state Excel complains about — a dangling relationship, or a package whose content type promises a macro project that is not there. That is what triggers the repair prompt, and it is why the strip is written as one operation rather than four call sites that could drift.

Keeping macros is possible, and the mechanism is the consent: asking for output: "xlsm" is the round trip — template sheets, rewritten data, and the source template’s VBA project intact. Nothing ships executable content unless the caller named the macro-enabled format, and the file must then be served with the macro-enabled content type and an .xlsm name, because macro bytes under an .xlsx name is precisely the format/extension conflict Excel refuses to open. On the rows-to-file surfaces, where there is no source workbook at all, xlsm is simply refused: the best that path could produce is a file named .xlsm that declares itself plain inside, which is the mismatch this whole effort exists to remove.

One format we decline by name: .xlsb. It is a real workbook, but a binary one — BIFF12 parts inside the zip rather than XML — and rather than half-parse it we refuse it up front with advice to re-save as .xlsx or .xlsm.

And when you want prose about what the code does

There is an opt-in, metered endpoint that will explain a macro in English. It is deliberately separate from the free deterministic scan, and it has two rails that are worth copying if you build something similar:

  • The source is fenced as untrusted data in the prompt, with an explicit rule in the system message that content inside the fence is data and never instructions. Macro source is attacker-authored by definition.
  • The model is forbidden from issuing a safety verdict. It describes; it does not certify. Otherwise a sufficiently well-commented piece of malware can launder “this file is safe” through us, which is a laundering service, not a security feature.

The deterministic report is the security artefact. The prose is a convenience on top of it, and the ordering is not negotiable.

The short version

You can tell a customer exactly what executable content their workbook carries — modules, auto-run entry points, what it shells out to, whether there is a 1990s XLM macro sheet hiding in the worksheet layer — without ever letting a line of it run, and without spending a token. It takes three format implementations and a stubborn refusal to report “clean” when you mean “unreadable”.

Excel and macro-enabled workbooks as inputs are covered in the reshape capability, the supported formats and their limits are in the API docs, and the rest of what we do and do not claim is on the about page.

The AdaptivMapr TeamEngine
  • Excel
  • VBA
  • macros
  • file formats
  • security

Every technical claim here is written against the code that ships. If something has drifted, tell us at hello@adaptivmapr.com and we will correct the post rather than quietly delete it.

Try it on your own file

Map the file. Keep the records.

Schema-only sends headers and up to three clamped sample rows — nothing else leaves you. Top up a prepaid wallet from about $10 and run a map.

$10 minimum to start · pay only for what you map · PHI under BAA coverage