
VSL CTF 2026 - Journal
FindItV2
After building the images, I noticed a local web page with the following content.

Running history, I saw that when building the images the .git directory was copied in as well. That seemed like the next clue.

Using save to export the image and searching inside the blobs, I found the .git directory and eventually the flag in a commit two steps back.
bashdocker save ph4n10m1808/findit -o findit.tar

Flag:
bashVSL{H1d3n_1n_l4y3r_d0k3r}
Data Stolen


Based on the description, I filtered for DNS, HTTP, and ICMP to inspect the traffic.
bashNHR0NGM= a190M2M= aG4xcXUzfQ==

bashVlNMe24z dHcwcmtf dHVubjM= bDFuZ18= MTVfYzA= bW0wbl8
Flags:
bashVSL{n3tw0rk_tunn3l1ng_15_c0mm0n_4tt4ck_t3chn1qu3}
The Joy of Nostalgia
After loading the dump into Volatility, I ran the windows.cmdline and windows.consoles plugins hoping to see previously executed commands but had no luck. I also tried dumping processes with the same result.
While running windows.filescan in familiar directories looking for .reg, .hiv, and .tmp files, I found an important file in the Temp folder.

Tracing how that file was created confirmed my guess: before the registry keys were cleared, they had been manually saved with reg save.

Opening it revealed the flag.

Flag:
bashVSL{Gh05t_1n_Th3_Tr4n54ct10n}
Float Precision
We are given a file containing a matrix of float32 values. To decode it to readable ASCII, we take the LSB of the mantissa for each element, then group 8 bits into 1 byte and decode as ASCII.
pythonimport numpy as np data = np.load("image.npy") flat = data.flatten() bits = [] for f in flat: u = np.frombuffer(f.tobytes(), dtype=np.uint32)[0] bits.append(u & 1) bytes_out = [] for i in range(0, len(bits), 8): byte = bits[i:i+8] if len(byte) < 8: break value = 0 for b in byte: value = (value << 1) | b bytes_out.append(value) flag = bytes(bytes_out).decode(errors="ignore") print(flag)
Flag:
bashVSL{1EEE_754_m4nt1ss4_h1d1ng_1s_r34lly_tr1cky_112211!}
Accidental 2
In IDA I found this function. It uses a hardcoded string XORed with 5.
cstrcpy((char *)&g_C2Host, "34+41+766+2="); XorDecode(&g_C2Host, 12LL, 5LL); strcpy((char *)&g_C2Port, "1111"); XorDecode(&g_C2Port, 4LL, 5LL);
cfor ( i = 0LL; ; ++i ) { result = i; if ( i >= length ) break; *(_BYTE *)(data + i) ^= xor_key; } return result;
Alternatively you can skip the reversing and look up IOCs on VirusTotal and AnyRun, since this sample does not use any anti-sandbox measures.

Flag:
bashVSL{61.14.233.78:4444}
Accidental 1

The challenge provides an evidence set consisting of a disk image (E01) and files encrypted by ransomware — the sample is WindowsSecurityService.exe (VSL ransomware). The important file to decrypt is ProjectFinal.vsl (or other .vsl files) to recover the flag.
Sample analysis overview
In IDA we see that encryption uses AES-128 ECB. The crucial point is that the key is derived deterministically from system information, so if we can recover enough system info from the evidence we can decrypt.

Key derivation:

The key derivation algorithm can be summarized as:
bashkey = SHA256(system_info)[0:16] system_info = ComputerName|UserName|UserSID|VolumeSerial|MachineGUID|PersistPath
Because AES-128 uses a 128-bit = 16-byte key, the malware does not use all 32 bytes of the SHA-256 output but only the first half. In the code: after calling the BCrypt hash (SHA-256), it takes the result buffer and copies the first 16 bytes into the key variable, then passes it to the AES setup.

It achieves persistence by copying itself into TEMP and adding a startup entry.

Digging into the C2 logic, we see that StartC2Communication() initializes InjectIntoExplorer(). As the name suggests, this function tries to find the explorer.exe process (or falls back to svchost.exe), which is why at runtime the connection appears to come from explorer.exe. It then injects shellcode.


File header .vsl (68 bytes):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0x00 | 8 | Marker | CRYPT_V1 |
| 0x0C | 4 | Mode | 1 = full, 2 = partial |
| 0x10 | 4 | OriginalSize | Original file size |
| 0x14 | 4 | EncryptedLength | Length of encrypted block |
| 0x18 | 8 | Timestamp | FILETIME |
| 0x20 | 32 | OriginalExtension | Original extension (null-pad) |
| 0x40 | 4 | Checksum | Header checksum |
After the header comes the encrypted data (AES-128 ECB, PKCS7 padding). For files larger than 512KB, the malware only encrypts the first 512KB (mode 2); the rest is left as-is.
Collecting system info from evidence
From the analysis above we need to gather the required system information to decrypt. I stumbled here: instead of taking VolSerial from the $Boot file I took it from a registry hive, and those two sources can have different values.
After solving, I looked it up: what we need is the NTFS volume serial number. Getting it from the SYSTEM\MountedDevices registry hive is incorrect — that key holds disk signature, partition offset, volume GUID, and mapping logic, not the NTFS volume serial.

- Computer name: Registry
SYSTEM: CurrentControlSet\Control\ComputerName\ComputerName==>DESKTOP-8DLHUJ4

- User name: User folder under
C:\Users\==>employee - User SID: Value
VinSAM: SAM\Domains\Account\Users\000003E9

- Volume serial (C:): In file
$Boot==>E9F59FE0 - MachineGuid:
SOFTWARE: Microsoft\Cryptography==>dca5645e-d0dc-4f10-ac03-59fe070380cd

- Persist path: As analyzed above, the malware copies itself to
%TEMP%\WindowsSecurityService.exe==>C:\Users\employee\AppData\Local\Temp\WindowsSecurityService.exe.
Script:
pythonimport struct import hashlib import os import sys import argparse try: from Crypto.Cipher import AES from Crypto.Util.Padding import unpad except ImportError: print("[!] Error: pycryptodome not installed") print("[!] Install with: pip install pycryptodome") sys.exit(1) HEADER_SIZE = 68 MARKER = b"CRYPT_V1" class VSLHeader: def __init__(self, data): if len(data) < HEADER_SIZE: raise ValueError(f"Header too small: {len(data)} < {HEADER_SIZE}") self.marker = data[0x00:0x08] # Only 8 bytes for marker self.mode = struct.unpack('<I', data[0x0C:0x10])[0] # At offset 12 self.original_size = struct.unpack('<I', data[0x10:0x14])[0] self.encrypted_length = struct.unpack('<I', data[0x14:0x18])[0] self.timestamp = struct.unpack('<Q', data[0x18:0x20])[0] self.original_extension = data[0x20:0x40].rstrip(b'\x00').decode('utf-8', errors='ignore') self.checksum = struct.unpack('<I', data[0x40:0x44])[0] def is_valid(self): return self.marker.startswith(MARKER) def __str__(self): mode_str = {1: 'full', 2: 'partial'}.get(self.mode, 'unknown') return f"""VSL Header: Marker: {self.marker} Mode: {self.mode} ({mode_str}) Original Size: {self.original_size} bytes Encrypted Length: {self.encrypted_length} bytes Original Extension: {self.original_extension} Checksum: 0x{self.checksum:08x}""" def derive_key(computer_name, user_name, sid, volume_serial, machine_guid, persist_path): key_source = f"{computer_name}|{user_name}|{sid}|{volume_serial}|{machine_guid}|{persist_path}" print(f"[*] Key derivation source: {key_source}") key = hashlib.sha256(key_source.encode('utf-8')).digest()[:16] print(f"[*] Derived AES key: {key.hex()}") return key def decrypt_file(input_path, output_path, aes_key, iv_in_file=False, use_ecb=False): print(f"\n[*] Decrypting: {input_path}") with open(input_path, 'rb') as f: header_data = f.read(HEADER_SIZE) header = VSLHeader(header_data) if not header.is_valid(): print("[!] Invalid VSL file - marker mismatch") return False print(header) if iv_in_file: iv = f.read(16) encrypted_data = f.read(header.encrypted_length) print(f"[*] Using IV from file (16 bytes): {iv.hex()}") else: encrypted_data = f.read(header.encrypted_length) iv = b'\x00' * 16 remaining_data = f.read() if header.mode == 2 else b"" if header.mode == 2: print(f"[*] Partial encryption - {len(remaining_data)} bytes unencrypted") try: if use_ecb: cipher = AES.new(aes_key, AES.MODE_ECB) decrypted_data = cipher.decrypt(encrypted_data) print("[*] Using ECB mode") else: cipher = AES.new(aes_key, AES.MODE_CBC, iv) decrypted_data = cipher.decrypt(encrypted_data) try: decrypted_data = unpad(decrypted_data, AES.block_size) except ValueError: print("[!] Warning: Padding error - continuing anyway") if header.mode == 2: decrypted_data += remaining_data with open(output_path, 'wb') as f: f.write(decrypted_data) print(f"[+] Successfully decrypted to: {output_path}") print(f"[+] Original extension: {header.original_extension}") return True except Exception as e: print(f"[!] Decryption failed: {e}") return False def main(): parser = argparse.ArgumentParser( description="Decrypt VSL ransomware encrypted files", formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument('input', help='Encrypted .vsl file') parser.add_argument('-o', '--output', help='Output file path') parser.add_argument('--key', help='AES key in hex (32 chars)') parser.add_argument('--computer-name', help='Computer name') parser.add_argument('--user-name', help='User name') parser.add_argument('--sid', help='User SID') parser.add_argument('--volume-serial', help='Volume serial (hex)') parser.add_argument('--machine-guid', help='Machine GUID') parser.add_argument('--persist-path', help='Persistence path') parser.add_argument('--iv-in-file', action='store_true', help='Read IV from first 16 bytes after header (then ciphertext follows)') parser.add_argument('--ecb', action='store_true', help='Use ECB mode instead of CBC (no IV)') args = parser.parse_args() # Determine AES key if args.key: key_hex = ''.join(c for c in args.key.strip() if c in '0123456789abcdefABCDEF') if len(key_hex) < 32: print(f"[!] Key must be at least 32 hex digits (16 bytes), got {len(key_hex)} after removing non-hex characters") return 1 if len(key_hex) > 32: key_hex = key_hex[:32] aes_key = bytes.fromhex(key_hex) else: fields = ['computer_name', 'user_name', 'sid', 'volume_serial', 'machine_guid', 'persist_path'] if not all(getattr(args, f) for f in fields): print("[!] Provide --key OR all system info fields:") for f in fields: print(f" --{f.replace('_', '-')}") return 1 aes_key = derive_key(args.computer_name, args.user_name, args.sid, args.volume_serial, args.machine_guid, args.persist_path) output_path = args.output or os.path.splitext(args.input)[0] + '_decrypted' success = decrypt_file(args.input, output_path, aes_key, iv_in_file=args.iv_in_file, use_ecb=args.ecb) return 0 if success else 1 if __name__ == '__main__': sys.exit(main())
After decrypting ProjectFinal.vsl we get a zip file and the flag is inside.

Flag:
bashVSL{y0u_c4n_f1nd_4nd_r3c0v3ry_1t_12112}