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.

To begin with, I ran file on the provided binary, which identified it as a stripped Linux (ELF) executable. I then attempted to trace the password comparison dynamically using ltrace and strace. I started with ltrace, which intercepts calls made to shared user-space libraries (like strcmp or printf from libc). It failed immediately with the error Couldn't find .dynsym or .dynstr. This confirmed the binary was statically linked, meaning the compiler burned all standard C functions directly into the executable rather than loading them dynamically at runtime. Next, I used strace, which operates at a lower level by intercepting system calls (the direct requests the program makes to the Linux kernel). strace provided a bit more context: it showed the program invoking read to capture my input and accessing /dev/urandom to pull a chunk of data. However, after that, it made zero system calls until it finally invoked write to print “Access denied.” This confirmed that the password checking logic operates entirely within user-space memory, avoiding shared library functions or kernel interactions, making it invisible to dynamic system tracing tools.

Realizing this was a dead end, I dropped the binary into Ghidra to analyze the execution flow statically. I quickly identified the entry function for the process:

image

There is a call to FUN_00404260, which takes a pointer to FUN_00401880 as its first argument (this is the process’s main function). After spending some time renaming variables and cleaning up the decompiled C pseudo-code of the main function, I realized this wasn’t a standard password check. The program was running a custom Virtual Machine.

The VM logic is stored as a constant array of raw bytes in .rodata (Read-Only Data). To execute it, the binary uses a massive switch statement inside a while loop that acts as an interpreter for each byte in the array. It reads one byte at a time, treats it as an opcode, and performs a specific action defined in the switch statement.

Because the VM is designed to work with custom opcodes in a stack architecture, there is no need for general-purpose registers. Without registers to track, I had to map out how the custom instructions defined in the switch statement manipulated the programs memory directly:

  • The Stack: The VM operates on a fixed 63-byte array (byte VMStack [63]). An integer stackPointer tracks the current top of the stack.
  • Fetching: The program bounds-checks the instruction pointer (if (0x3c < nextIP)) to ensure it doesn’t read past the 60-byte bytecode array. It fetches the next byte into currentOpcode and loops back to the top.
  • Decode/Execute: The switch(currentOpcode) block decodes the instruction to be executed, with almost every operation interacting directly with VMStack.
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

With the instruction manual for the custom CPU fully mapped out, my next step was to figure out what program the VM was actually running. By translating the raw .rodata bytecode array into human-readable assembly using my opcode table, the program naturally split into two distinct phases.

The first block of the VM explains why dynamic tracing tools failed. It uses an RND (random) opcode to pull bytes from /dev/urandom, loops a random number of times, and executes pointless bitwise operations. This is considered a noise prologue and it is used to throw off dynamic analysis and could act as a dead end for static analysis. However, looking closely at the prologue shows it never touches the input array, the index register, or the accumulator. Once I realized it was just junk, I completely ignored it.

Moving past the noise, I isolated the actual password-checking loop. 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] One really interesting feature of this loop is how it handles failures. If a byte is wrong, the VM does not exit early. It uses an ORA (bitwise OR) opcode to record the error into an accumulator register and continues checking the rest of the string. Which makes it always run for the full length of the expected input. Meaning a wrong guess on any byte takes the exact same amount of time and instructions as a wrong guess on any other byte. This constant-time execution makes brute-forcing another dead end.

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 the target file and strip out the appended junk data.

I knew SelectWindow calculated the starting offset for files under 4MB by hashing Environment.MachineName (which the logs showed was WIN10). I wrote ONE FINAL Python decryption script to handle the offset, strip the 16 bytes of random junk the malware appends to the end of the chunk, and XOR the data back to its original state:

image

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.