Back to posts
VSL CTF 2026 - Journal
ForensicsMediumpowershellftk-imagerwiresharkidawriteup

VSL CTF 2026 - Journal

h26v
24/01/2026
30 min read

FindItV2

After building the images, I noticed a local web page with the following content.

image

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

image

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.

bash
docker save ph4n10m1808/findit -o findit.tar

image

Flag:

bash
VSL{H1d3n_1n_l4y3r_d0k3r}

Data Stolen

image

image

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

bash
NHR0NGM= a190M2M= aG4xcXUzfQ==

image

bash
VlNMe24z dHcwcmtf dHVubjM= bDFuZ18= MTVfYzA= bW0wbl8

Flags:

bash
VSL{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.

image

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

image

Opening it revealed the flag.

image

Flag:

bash
VSL{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.

python
import 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:

bash
VSL{1EEE_754_m4nt1ss4_h1d1ng_1s_r34lly_tr1cky_112211!}

Accidental 2

In IDA I found this function. It uses a hardcoded string XORed with 5.

c
strcpy((char *)&g_C2Host, "34+41+766+2="); XorDecode(&g_C2Host, 12LL, 5LL); strcpy((char *)&g_C2Port, "1111"); XorDecode(&g_C2Port, 4LL, 5LL);
c
for ( 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.

image

Flag:

bash
VSL{61.14.233.78:4444}

Accidental 1

acc1

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.

image

Key derivation:

image

The key derivation algorithm can be summarized as:

bash
key = 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.

image

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

image

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.

image

image

File header .vsl (68 bytes):

OffsetSizeFieldDescription
0x008MarkerCRYPT_V1
0x0C4Mode1 = full, 2 = partial
0x104OriginalSizeOriginal file size
0x144EncryptedLengthLength of encrypted block
0x188TimestampFILETIME
0x2032OriginalExtensionOriginal extension (null-pad)
0x404ChecksumHeader 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.

image

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

image

  • User name: User folder under C:\Users\ ==> employee
  • User SID: Value V in SAM: SAM\Domains\Account\Users\000003E9

image

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

image

  • Persist path: As analyzed above, the malware copies itself to %TEMP%\WindowsSecurityService.exe ==> C:\Users\employee\AppData\Local\Temp\WindowsSecurityService.exe.

Script:

python
import 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.

image

Flag:

bash
VSL{y0u_c4n_f1nd_4nd_r3c0v3ry_1t_12112}