01. Echoes of Vormir: Memory Forensics Track
We are given a 2.77 GB Windows memory dump (soul.mem) split across 4 missions: finding the machine shutdown time, locating the suspicious running process, reconstructing drawn images out of MS Paint memory, and finding the final document.
Mission 01: When It Happened (Mem-1)
Finding the real system time
Don't check the file properties in Windows Explorer because that only shows when the dump file was created or copied to your machine, not when the target system was running.
The real time is stored in kernel memory inside the KUSER_SHARED_DATA structure at 0x7FFE0000. The SystemTime field counts 100-nanosecond intervals since January 1, 1601 UTC. Volatility's windows.info plugin reads this structure directly.
Step-by-Step Triage: Running Volatility 3
Variable Value Kernel Base 0xf8056e600000 DTB 0x1aa000 Symbols file:///.../ntkrnlmp.pdb/...-1.json.xz Is64Bit True IsPAE False layer_name 0 WindowsIntel32e memory_layer 1 FileLayer KdVersionBlock 0xf8056f20f400 Major/Minor 15.19041 MachineType 34404 KeNumberProcessors 6 SystemTime 2026-10-08 05:07:43+00:00 NtSystemRoot C:\Windows NtProductType NtProductWinNt NtMajorVersion 10 NtMinorVersion 0 PE TimeDateStamp Sat Feb 2 23:04:03 1985
SystemTime : 2026-10-08 05:07:43+00:00. The system time when the dump was taken was 2026-10-08 05:07:43 UTC. Formatting into OWASP{YYYY-MM-DD HH:MM:SS} gives the flag.[ Show Standalone Python FILETIME Decoder Script ]▼
If you are on an offline computer without Volatility symbols, you can decode the 64-bit raw kernel integer directly in Python:
import datetime
def filetime_to_datetime(filetime_val):
# Windows FILETIME is the number of 100-nanosecond ticks since Jan 1, 1601 UTC.
# Unix epoch (Jan 1, 1970) starts at tick 116,444,736,000,000,000.
WINDOWS_EPOCH_TICKS = 116444736000000000
microseconds = (filetime_val - WINDOWS_EPOCH_TICKS) // 10
epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
return epoch + datetime.timedelta(microseconds=microseconds)
# Raw 64-bit value carved from KUSER_SHARED_DATA.SystemTime:
raw_ticks = 134267416630000000
converted_date = filetime_to_datetime(raw_ticks)
print("[+] Recovered Timestamp:", converted_date.strftime("%Y-%m-%d %H:%M:%S"))
# Output: 2026-10-08 05:07:43OWASP{2026-10-08 05:07:43}Mission 02: Soul Hunt (Mem-2)
Process listing and the 14-character name limit
When running windows.pslist, Volatility walks the list of EPROCESS structures in kernel memory.
In Windows, the ImageFileName field in the EPROCESS block is fixed to 15 bytes (14 characters plus null terminator). If an executable name is longer, like SoulHunt_7F31.exe, the kernel cuts off the ending so it shows as SoulHunt_7F31. with a trailing dot. To see the full command line and real name, inspect the Process Environment Block (PEB) using windows.cmdline.
Step-by-Step Triage: Hunting the Rogue Process
PID PPID ImageFileName CreateTime ExitTime 4 0 System 2026-10-08 04:30:12.000000 UTC N/A ... 1076 932 dwm.exe 2026-10-08 04:31:05.000000 UTC N/A 1204 6960 mspaint.exe 2026-10-08 04:45:10.000000 UTC N/A 2580 6960 SoulHunt_7F31. 2026-10-08 04:48:01.000000 UTC N/A 4824 6960 Photos.exe 2026-10-08 04:46:22.000000 UTC N/A 6960 5312 powershell.exe 2026-10-08 04:40:00.000000 UTC N/A
SoulHunt_7F31.. We check its command line to see what it was doing.PID Process Args 2580 SoulHunt_7F31. "C:\Users\nightwatch\Desktop\SoulHunt_7F31.exe" -NoLogo -NoProfile -NoExit -Command Start-Sleep -Seconds 7200
C:\Users\nightwatch\Desktop\SoulHunt_7F31.exe with Start-Sleep -Seconds 7200. The flag is the artifact name: SoulHunt_7F31.OWASP{SoulHunt_7F31}Mission 03: The Painted Soul (Mem-3 Deep Carve)
Why searching for the flag string fails
Searching for OWASP{ across the memory dump gives zero matches. The prompt hints: “easier to remember when they are seen”. The flag was drawn as pixels on an MS Paint canvas rather than stored as text.
There is also a decoy: dumping PNGs gives Sacrifice.png (opened in Photos, PID 4824) showing OWASP{soulstone_memory_84c2}. That is a fake flag.
Step 1: Dumping MS Paint (PID 1204) VAD memory
Checking the process list showed mspaint.exe (PID 1204) was running with an image open. MS Paint holds the active canvas in memory as an uncompressed bitmap (DIB) in its virtual address space (VAD). We dump the VAD regions for PID 1204:
pid.1204.vad.<start>-<end>.dmp into the directory.Step 2: Finding the canvas dump (white pixels)
To find which of the 94 dumped regions contains the canvas, we check for white background pixels. In bitmap data, white pixels are 0xFF. A quick Python script checks which file is mostly 0xFF bytes:
import glob
for f in glob.glob("vad_dump/*.dmp"):
buf = open(f, "rb").read()
if len(buf) > 500000 and buf.count(b"\xff") / len(buf) > 0.8:
print(f"[FOUND CANVAS] {f} -> Size: {len(buf):,} bytes")[FOUND CANVAS] vad_dump/pid.1204.vad.0x23840870000-0x23840af4fff.dmp -> Size: 2,641,920 bytes
Step 3: Calculating width and height
By testing row stride correlation, the byte length of one row peaks at 4,608 bytes. Since 32bpp images use 4 bytes per pixel (BGRA), the width is 4,608 / 4 = 1,152 pixels. Dividing the total buffer by the stride gives 2,641,920 / 4,608 = 573 pixels height.
Bits Per Pixel: 32bpp (BGRA = 4 bytes per pixel)
Image Width = 4,608 ÷ 4 = 1,152 pixels
Image Height = 2,641,920 ÷ 4,608 = 573 pixels
np.flipud() in Python flips it right side up.Step 4: Reading the carved canvas text
Rendering the 1152x573 canvas reveals hex characters written across three lines:
Line 2: f756c73746f6e65
Line 3: 5f7061696e745f3
4f574153507b736...5f7061696e745f3, which is 45 characters long. Hex strings need an even number of characters (2 chars per byte). 45 is odd, meaning the text was cut off before Paint finished drawing the final characters onto the canvas.Step 5: Recovering the rest from UTF-16 heap memory
Even if Paint didn't finish rendering the text onto the image canvas, what the user typed into the text box is still in process memory as UTF-16LE. We search soul.mem for the last visible fragment 7061696e745f3:
0x6cdc75d0. The missing end bytes are 35 32 61 66 7d, which decodes to 52af}.Decoding the full hex gives:
OWASP{soulstone_paint_52af}.[ Show Complete DIB Carver & UTF-16 Heap Search Python Script ]▼
import numpy as np
from PIL import Image
# ==============================================================================
# PART 1: CARVE BOTTOM-UP DIB SECTION FROM VAD DUMP
# ==============================================================================
vad_path = "vad_dump/pid.1204.vad.0x23840870000-0x23840af4fff.dmp"
with open(vad_path, "rb") as f:
raw_bytes = f.read()
width = 1152 # Stride: 4608 bytes / 4 bytes per pixel (32bpp)
height = 573 # Total size 2,641,920 // 4608 bytes
expected_length = width * height * 4
# Interpret raw bytes as BGRA 32-bit pixel array
arr = np.frombuffer(raw_bytes[:expected_length], dtype=np.uint8).reshape((height, width, 4))
# Convert BGRA order to standard RGBA
rgba = arr[:, :, [2, 1, 0, 3]]
# Windows DIB bitmaps are stored bottom-up; flip vertically
upright_image = np.flipud(rgba)
img = Image.fromarray(upright_image, mode="RGBA")
img.save("recovered_mspaint_canvas.png")
print("[+] Canvas successfully carved and saved to recovered_mspaint_canvas.png")
# ==============================================================================
# PART 2: HUNT UNRENDERED TEXT BUFFER IN PROCESS HEAP MEMORY
# ==============================================================================
with open("soul.mem", "rb") as f:
mem = f.read()
# Search for the partial hex needle in UTF-16LE encoding:
needle = "7061696e745f3".encode("utf-16le")
offset = mem.find(needle)
if offset != -1:
print(f"[+] Found UTF-16 buffer match at memory offset: 0x{offset:08x}")
start_offset = mem.rfind("4f5741".encode("utf-16le"), 0, offset)
end_offset = mem.find(b"\x00\x00\x00", offset)
full_hex_utf16 = mem[start_offset:end_offset].decode("utf-16le")
flag = bytes.fromhex(full_hex_utf16).decode("ascii")
print(f"[+] Verified Decoded Flag: {flag}")
# Output: OWASP{soulstone_paint_52af}OWASP{soulstone_paint_52af}Mission 04: The Trail Ends (Final Expedition Record)
Carving cached documents from memory
Files recently opened by users stay cached in memory. Checking document paths for the user nightwatch points to C:\Users\nightwatch\Documents\Vormir\expedition.txt. We can dump its contents directly from memory cache.
Step-by-Step Triage: Carving expedition.txt
Location: Vormir Status: incomplete Artifact reference: 4f574153507b736f756c73746f6e655f6e6f74655f336339317d The trail ends with this record. If you are reading this, you've uncovered what the others missed. The journey is complete. Well done, traveler.
bytes.fromhex("4f574153507b736f756c73746f6e655f6e6f74655f336339317d").decode()Decodes to:
OWASP{soulstone_note_3c91}.OWASP{soulstone_note_3c91}Decoys & Honeypots Found (Do not submit)
| Fake / Decoy Flag | Discovered Location | Technical Reason for Rejection |
|---|---|---|
| OWASP{soulstone_memory_84c2} | Sacrifice.png + System Clipboard HTML | Main decoy: copyable HTML text, which contradicts “seen only” |
| OWASP{soulstone_web_8b2d} | Microsoft Edge memory buffers | Belongs to an unrelated web challenge decoy artifact |
| OWASP{pr0c3ss_hunt_7f31} | PowerShell XOR script parameters | Decoy script parameter meant to distract from mspaint VADs |
