SkillBit June Flash CTF Writeup

By Chase Cooper

A few weeks ago I participated in SkillBits June 2026 Flash CTF. I had work off for the evening and figured it would be a good opportunity to step away from my current research project for a short bit and do something different, not knowing the rabbit holes this would lead me down. The CTF took place on June 25th, 2026 from 5:00 PM - 7:00 PM (UTC-4). The time zone is in bold text because I missed that key detail when registering for the CTF. Around 4:45 (GMT-5), I logged onto the dashboard early, and to my surprise the CTF had started almost an hour ago. I was still able to find the first 3 flags with the time I had left, which I was happy about. Knowing I would be spending the next week and a half on vacation, I wanted to complete the remaining challenges on the trip to keep me busy during downtime. Here are my write ups for all of the challenges, starting from the easiest, working to the hardest.

Exposure (100 pts):

Forensics A coworker dropped a photo (20251023_141508.jpg) into the team chat, posted straight off their phone. The picture’s nothing special, but they weren’t thinking about what else rode along with it.

As expected being the first challenge, this was very simple. My initial thought was to run this picture through exiftool to see if the exifdata had been stripped or was still present. Here is the provided image:

20251023_141508

By running exiftool on the image, I was able to extract the flag embedded within the exifdata of the file:

image

Key Evidence (150 pts):

Forensics Our DLP team pulled a USB capture off the kiosk that staff use to unlock the records terminal. The only device on the bus was the keyboard. We need to know exactly what got typed at it.

I started this challenge by opening the provided PCAP in Wireshark to get a feel for the traffic. Since the scenario specified a USB keyboard capture, I knew I was looking for USB Interrupt IN transfers containing HID (Human Interface Device) data.

image

I used tshark to filter the PCAP for USB capture data (raw 8-byte HID reports generated by the keyboard) and extracted those hex values into a text file:

  • tshark -r keyboard.pcap -Y "usb.transfer_type == 0x01 && !(usb.capdata == 00:00:00:00:00:00:00:00)" -T fields -e usb.capdata > keystrokes.txt

image

Running the extracted hex through this USB HID Keyboard decoder found on github (https://github.com/Nissen96/USB-HID-decoders/blob/main/keyboard_decode.py) translated the raw data back into plaintext, revealing what the user typed into the kiosk:

image

Flash Sale (200 pts):

Web Exploitation FashCart just dropped their Founders Edition Hoodie. It’s members-only, it’s $200, and every one ships with an exclusive promo code we want. The catch: a fresh account starts with a $0.00 balance, and the only gift card floating around (FLASH50) is worth $50 and is limited to one redemption per account. Get yourself a hoodie.

I began this challenge by mapping out the application’s purchasing workflow and analyzing the web traffic. Knowing I needed a $200 item but only had a $50 coupon, my first instinct was to route the traffic through Caido to capture and intercept the checkout and redemption requests to attempt some parameter tampering methods:

  • I tried intercepting the POST /checkout request and modifying the request body. I attempted to edit the item price from $200.00 to $0.00 and $50.00 to see if the backend relied on user-supplied pricing. I also adjusted the request body to pass extra parameters (parameter fuzzing).
  • I attempted to purchase -1 of a different item I could afford to see if the application would calculate a negative total and credit my account balance.
  • When submitting the FLASH50 code, I attempted to pass it as an array in the request body ({"code": ["FLASH50", "FLASH50"]}). The backend properly sanitized and validated all of these attempts. The price was enforced server-side, and the coupon correctly rejected sequential duplicate submissions.

Since sequential attacks failed, I pivoted to testing how the server handled concurrent requests. Back in Caido, I isolated the specific POST request used for applying the promo code:

image

The target is the /redeem API endpoint. The request process was straightforward, it transmitted the FLASH50 code to be validated against the account’s redemption history.

Because the coupon validation checks if the code has already been used, I wanted to test for the presence of a potential Time-of-Check to Time-of-Use (TOCTOU) vulnerability (also commonly known as a race condition). If multiple requests arrive at the exact same time, the server might check the database for all of them simultaneously. Seeing the code hasn’t been used yet, it validates all of them before the database can update the used status.

To test this, I switched to Burp Suite since Caido does not have a repeater (wishing I started with Burp to begin with). I sent the /redeem request to Burp’s repeater, duplicated it 5 times, and grouped all of the requests together. Using Burp’s parallel processing feature, I sent all 6 requests over a single connection simultaneously from a fresh account.

The race condition was successful. The server failed to update the database row in time, validating all 6 simultaneous requests. The $50 credit was applied 6 times, inflating my account balance to $300.00.

image

With $300 now sitting in the wallet, I bypassed the intended $50 coupon limitation. Allowing me to successfully complete the checkout process to retrieve the flag.

image

Cogwork (250 pts):

Reverse Engineering The vault keypad doesn’t run normal code. It hands your access code to a little clockwork engine inside the firmware and only opens if the gears line up. Recover the code it’s waiting for.

I was pretty excited for this challenge because at the start of summer I took it upon me to be more proactive about learning reverse engineering in my pass time. To do so, I began reading Implementing Reverse Engineering by Jitender Narula. This book has been incredibly helpful in understanding the x86 architecture, assembly instructions, calling conventions, and many more important topics for reverse engineering x86 binaries. So I thought this would be a perfect opportunity to apply some of the things I have been learning.

Running file on the binary identified it as a stripped Linux ELF executable. Dynamic analysis using strace showed initial system calls (read for user input, and a pull from /dev/urandom), followed by a write call printing “Access denied.” The absence of system calls during the validation phase confirmed the password checking logic operates entirely within user-space memory, rendering dynamic OS tracing ineffective.

Static analysis in Ghidra revealed the binary does not use a standard password check, instead it implements a custom Virtual Machine.

The VM instructions (bytecode) are stored as a constant array of raw bytes in the .rodata section (Memory Address: 0x00485a60). The binary uses a while loop containing a massive switch statement to act as the interpreter which fetches a byte, decodes it as an opcode, and executes the corresponding instruction.

Because the VM uses a stack-based architecture rather than general-purpose registers, I mapped out the custom instruction set by analyzing each switch case and how it manipulated the 63-byte memory stack:

Opcode Name Purpose
0x10 PUSH IMM Pushes the next byte onto the stack
0x11 POP Removes the top element from the stack
0x12 DUP Duplicates the top element on the stack
0x13 LDIN Pops an index and pushes the corresponding byte from user input
0x14 LDK Pops an index and pushes the corresponding byte from KTAB
0x15 LDT Pops an index and pushes the corresponding byte from TGT
0x16 XOR Pops two values, bitwise XORs them, and pushes the result
0x17 ADD Pops two values, adds them, and pushes the result
0x18 SUB Pops two values, subtracts them, and pushes the result
0x19 ROL Rotates the top stack byte left by the next immediate byte
0x1A AND Bitwise ANDs the top stack byte with the next immediate byte
0x1B NEQ Pops two values; pushes 1 if not equal, 0 if equal
0x20 JMP Unconditional jump using the next 16-bit offset
0x21 JNZ Pops a value; jumps using next 16-bit offset if not zero
0x22 JZ Pops a value; jumps using next 16-bit offset if zero
0x30 GETI Pushes the current index register (i) to the stack
0x32 INCI Increments the index register (i = i + 1)
0x33 LEN Pushes the expected target length (0x2C / 44) to the stack
0x34 FAIL Sets the execution status flag to false (failed state)
0x35 GETA Pushes the current accumulator (acc) state to the stack
0x36 ORA Pops a value and bitwise ORs it into the accumulator
0x37 RND Pushes a random byte (from /dev/urandom or PRNG fallback)
0x3F HALT Terminates VM execution and checks the success flag

Translating the .rodata bytecode array into human-readable assembly revealed two distinct phases:

  • Phase 1: The VM uses the RND opcode to pull bytes from /dev/urandom, executing a random number of loops containing pointless bitwise operations. This acts as an anti-analysis technique to add noise to dynamic traces. Since it never touches the input array or accumulator, I ignored it entirely.

  • Phase 2: The core validation loop transforms each byte of the user input and compares it to a target table. The VM does not exit early on a failed byte. Instead, it uses a bitwise OR instruction to record mismatches into an accumulator, ensuring the loop always runs for the full length. This constant-time execution prevents timing-based side-channel attacks. By following the stack operations based on their memory address, I was able to translate the bytecode into a single formula. For each byte of the user input, the VM performs the following transformation:

    • rol8((input[i] ^ KTAB[i & 7]) + i, 3) == TGT[i]

image

Now I had to reverse the algorithm for the check.

I extracted the specific arrays (KTAB (Key Table) and TGT (Target Table)) from the binary’s .rodata. To do this, I found them in the switch statement under cases 0x14 and 0x15 to see where the memory addresses map to:

  • KTAB is located at DAT_00485a50 (Memory Address: 0x00485a50).
  • TGT is located at DAT_00485a20 (Memory Address: 0x00485a20). When Ghidra analyzes a stripped binary and finds a reference to a memory address in the .rodata section, it doesn’t know the original variable name. It creates a pointer and names it DAT_ followed by the hexadecimal address where that data begins. I double-clicked the address pointers in Ghidra’s decompiler window to jump the to the .rodata section to see the raw bytes.

For every character in the user’s input, the VM:

  1. XORed it with a repeating key from KTAB.
  2. Added the current index to the character.
  3. Rotated the bits to the left by 3.

Now all I had left was to invert the check. I wrote a python script to take the target byte, rotate it right by 3, subtract the index position i (modulo 256 to account for overflow), and XOR it against the key table:

  • ror8(TGT[i], 3) - i (mod 256) ^ KTAB[i & 7]

image

  • Because the VM takes user input, modifies it, and compares it to the TGT array to check if the input is correct, this script simply takes the TGT array and runs the VM’s logic in reverse to reconstruct the access code.

image


(I skipped the challenge worth 300pts because it requires spawning a container and I was unsure if I would have consistent internet access on this trip.)


Residual (350 pts):

Forensics A workstation was hit by a suspicious software update and several public documents were left encrypted. The response team preserved a small filesystem triage package from the host, including encrypted files and low-level filesystem evidence. Reconstruct what happened, identify what can still be trusted from the artifacts, and recover the data the attacker tried to lock away.

I began by extracting the provided compressed filesystem into a Windows 11 VM to dig through the directory tree and see what is still recoverable. Thankfully within the [root] directory lives the $MFT (Master File Table) system file, which will come in handy.

image

Navigating to the C:\Users\Public directory, every document had been encrypted and appended with a .enc extension, except for a ransom note.

image

To get a better timeline of the attack, I used KAPE (Kroll Artifact Parser and Extractor) on the [root] directory to parse the $MFT file and extract it to CSV format.

  • kape.exe --msource "C:\Users\ches\Desktop\residual\[root]" --mdest "C:\Users\ches\Desktop\ResidualCTF" --module !EZParser

Opening the parsed $MFT in Timeline Explorer, I filtered for .enc files to track the exact modification times of when the encryption happened.

image

Based on the timestamps, I searched for executables created in that window. This led me to a highly suspicious file: windows-update.bat.exe.log. Following searching deeper into the windows-update naming convention revealed two critical files scattered across the AppData directories:

  • windows-update.bat.exe.log in Users\admin\AppData\Local\Microsoft\CLR_v4.0\UsageLogs
  • windows-update.bat in Users\admin\AppData\Local\Temp

image

The .log file was a Common Language Runtime (CLR) Usage Log, which Windows automatically generates when a .NET application runs. The log explicitly listed Microsoft.PowerShell.ConsoleHost and System.Management.Automation. This proved the attacker had disguised powershell.exe as a normal application (windows.update.bat.exe) to bypass execution policies. More importantly, the lack of a CLR log for the actual ransomware payload hinted towards Reflective Assembly Loading (the malware was unpacking and running entirely in memory).

I opened windows-update.bat. It was a heavily obfuscated Batch/PowerShell polyglot script utilizing variable chunking to evade static signatures. It is a custom fileless malware packer/dropper designed to start as a standard Windows Batch file but transitions into a PowerShell script to execute its Base64 encoded .NET payload entirely in memory. The script begins with a block of set commands using heavy string obfuscation. The author is chopping up a command into 2-3 character chunks and assigning them to random variable names to bypass static antivirus signatures.

image

When the variables are strung together at the bottom of the script (and deobfuscated), it evaluates to this first command:

image

The script is copying powershell.exe into the current directory and renaming it to match the script’s own filename (outputs windows-update.bat.exe). It does this because many security tools (such as EDRs, SIEM, etc.) monitor for the powershell.exe process making suspicious memory allocations.

Next, the script uses an even more massive string of variables to launch the renamed PowerShell executable and pass it a heavily obfuscated payload. By swapping out the variables for their assigned strings, here is the deobfuscated payload pushed into the PowerShell runtime.

image

The PowerShell script revealed a fileless malware loader, it decodes a Base64 string, decrypts it using AES-CBC with hardcoded keys, decompresses it via GZip, and maps the resulting raw bytes (which form an executable) directly into RAM using [System.Reflection.Assembly]::Load().

To safely extract this without running the malware, I wrote a Python script to replicate the AES decryption and GZip decompression, dumping the payload to disk as ransom.exe:

image

Running file against ransom.exe confirmed it was a compiled .NET application, and because .NET compiles to Intermediate Language (IL) rather than native machine code, you can recover almost perfect source code using a .NET decompiler. I opened the extracted executable in dnSpy, which opened to the Assembly Manifest (the cover/metadata) of the program.

image

Notice // Entry point: WCVyAmRDdiGbyOLcEjDP.PuCSrPJKDGAYSRIuGmWU.Main

After navigating to the entry point, I was able to read the main function in the source code, which showed this binary is a Stage-Two Loader. Its purpose is to blind the system’s security software and then unpack the actual ransomware from inside its own internal resources.

  1. It patched amsi.dll in memory using VirtualProtect, blinding Windows Defender.
  2. It patched ntdll.dll to disable Event Tracing for Windows.
  3. It extracted yet another AES-encrypted executable from its own internal Manifest Resources, decrypted & decompressed it, and uses Assembly.Load().EntryPoint.Invoke to run that payload entirely in memory.
  4. At the very bottom of the script, it spawns a hidden cmd.exe process that pings a localhost address to create a slight delay, and then runs a del command to delete this executable file off the disk.

Following the logic, I found the embedded executable, updated my Python extractor with the new AES keys found in this stage, and successfully dumped the payload: ransom2.exe.

Dropping ransom2.exe into dnSpy, I had the core ransomware logic here in the main function:

image

The encryption workflow looked standard on the surface, but analyzing the underlying methods revealed a custom cryptography implementation.

  1. GenerateRandomKey() image
  2. DeriveKey() image
  3. EncryptTargetDirectory() image
  4. EncryptFile() image
  5. Blend() image
  6. SelectWindow() image
  7. MaterializeBlock() image
  8. RandomBytes() image
  • The DeriveKey function takes a random 9-character string and runs it through a chain of MD5, SHA1, and SHA512 hashes before hitting PBKDF2 with 100,000 iterations.
  • The vulnerability lies in the Blend and MaterializeBlock functions. To generate its 4MB XOR keystream, the malware relies on an AES key and a 12-byte nonce. The malware generates this nonce exactly once per execution run and reuses it globally (a nonce should be unique per file to prevent keystream reuse). Because both the key and the nonce remain static, MaterializeBlock creates the exact same 4MB keystream for every single file on the system (prepending the same 12-byte nonce to each file’s header). Furthermore, for any file larger than 4MB, the SelectWindow function defaults the keystream offset to 0.
  • If I could find the original plaintext for any encrypted file larger than 4MB, I could recover the entire master keystream (since Plaintext ⊕ Ciphertext = Keystream).

Looking back at the forensic artifacts, I noticed VESTIGE.enc.pdf was larger than 4MB. By checking the $MFT for Alternate Data Streams (ADS), I found a Zone.Identifier attached to the file containing the original download URL:

HostUrl=[https://arxiv.org/pdf/2606.20006#pdfjs.action=download](https://arxiv.org/pdf/2606.20006#pdfjs.action=download)

I downloaded the original VESTIGE.pdf. Because the malware relies on a static 4MB XOR keystream, I could recover it entirely by XORing the known plaintext against the ciphertext. Since $Plaintext \oplus Ciphertext = Keystream$, the operation will returned the keystream.

To automate this, I wrote a quick Python script that skips the 12-byte nonce prepended to the encrypted file and XORs the first 4MB of data:

image

Now that I have the master keystream in keystream.bin, I just needed to reverse the SelectWindow logic to find the correct offset for files smaller than 4MB, since they don’t start encrypting at the beginning of the file (byte 0).

Instead of storing the offset inside the file (which leaves clues), they generated the offset using something unique to the computer: Environment.MachineName (which the logs showed was WIN10).

  1. SelectWindow takes the hostname (WIN10).
  2. It hashes that name to get an integer.
  3. It uses math on that integer (usually a modulo operation like hash % (4MB - file_size)) to pick a starting index inside the 4MB keystream.

Because Environment.MachineName doesn’t change, the malware calculates the exact same starting offset for small files on WIN10, every time.

image

Because VESTIGE.enc.pdf was larger than 4MB and therefore defaulted to offset 0, I was able to successfully dump the entire 4MB master keystream. Which allowed the final python script to stepped into the shoes of the ransomware’s decryptor:

  1. It looked at Flag.enc.pdf (a small file).
  2. It ran the WIN10 machine name through the exact same logic the SelectWindow function used.
  3. This told your script: “Ah, for this computer, the malware started encrypting small files at byte X of the master keystream.”

The script applied that offset, skipped the 12-byte nonce, stripped the 16 bytes of junk data at the end, and XORed the ciphertext against the master keystream starting from that exact position. Running the script against Flag.enc.pdf combined with keystream.bin successfully stripped the obfuscation and applied the correct keystream window. Flag.pdf opened perfectly, revealing a QR code that decoded to the final flag.

image


Completing and writing about these challenges has felt incredibly rewarding. I had tons of fun and learned a lot from completing them and I hope this write up can be useful in helping someone else do the same. All of the python code used will be on my GitHub at some point. Thank you for reading.