Part 1: Initial recon
The easiest flag was found within the slot machine's reels. It replaced the symbols for a "minor jackpot". To obtain the flag, one could either film the spinning reels with a slow-motion camera or spin repeatedly, capturing images when parts of the flag appeared to reconstruct the whole image.
Part 2: Stealing the firmware
A "Maintenance sheet" was affixed to the machine, displaying dates and signatures of prior maintenance personnel. These entries featured humorous, fictitious names and hidden easter eggs. Notably, a QR code was present, encoding a string beginning with "WL-". Below it, the URL "http://wonderlight.ctf" was visible. The use of the ".ctf" top-level domain for competition websites helped participants identify this as part of the challenge. The URL directed to the website of Wonderlight, the fictional manufacturer of the slot machine.
Search for slot machine models using their model number, which begins with "WL-", as indicated in the placeholder text. This model number corresponds to the code in the QR code. Entering this number will lead to the specification page containing a flag.
Firmware downloads from this page are password-protected, but the security is superficial. Client-side validation allows bypassing the password check to access an unauthenticated download link. Alternatively, the password, found within the page's source code, can be used. Downloading the .tar.gz archive provides an AppImage and a README file. The README includes keybind information, troubleshooting steps, and another flag.
Part 3: Reverse Engineering
Participants could extract the AppImage using `./slot-machine –appimage-extract` to access the executable for disassembly/decompilation. The inclusion of debug symbols for ease resulted in a large binary (approximately 260MB). Consequently, some participants reported hour-long analysis times or out-of-memory errors during decompilation, regrettably hindering the progress of some skilled players.
The reversing section of the track involved two distinct challenges. This post will detail the two reverse-engineering flags embedded in the binary. However, participants had the option to bypass these initial flags and directly focus on reverse-engineering the random number generators (RNGs) to obtain the main flags.
During the CTF, upon capturing a flag, players received forum messages from fictional team personas. These messages subtly prompted them to extract the frontend.
Tauri embeds brotli-compressed frontend web assets directly into the executable. This presents a challenge because brotli lacks a header indicating length. Furthermore, asset locations within the executable lack direct cross-references (XREFs), necessitating retrieval of their index and length from a hashmap using the asset name. While static extraction via scripting might be feasible, a GDB script was used during challenge review. Notably, multiple approaches exist for solving the flags, particularly the reverse engineering challenges.
To extract the game's assets, I set a breakpoint on the function responsible for fetching them. By dereferencing the fat pointer passed to this function, I accessed the memory region containing the assets and dumped its contents to a file. This process was automated in a loop as I navigated the application, allowing me to dump all asset files. These files were subsequently decompressed using a simple Python script. The initial reverse engineering flag was discovered within a hidden input field on the index page's HTML.
The second flag involved a native `get_secret_flag()` handler that returned the flag. Forum hints pointed to Javascript and Rust communication via these handlers. Significant obfuscation and anti debugging measures made unintended solutions difficult. The intended solution involved modifying web assets and patching the executable to use them, triggering the `get_secret_flag()` handler. Reportedly, only one team solved this by using an `LD_PRELOAD` hook to access the Javascript debug console, whichI found was a very interesting approach
Part 3.1: Obfuscation
Although the intention wasn't for participants to completely reverse-engineer it, I thoroughly enjoyed creating that aspect and believe documenting the process would be valuable.
At program startup, an array of keys was created using a simple seeded Linear Feedback Shift Register (LFSR) random number generator. Subsequently, when the GPIO initialization handler ran, these initial keys were XORed with additional keys, a process further detailed in the anti-debugging section. Finally, the `get_secret_flag()` handler XORed the result with a set of random keys, culminating in a single key. This final key was then used to decrypt the flag. The decryption process employed a custom Feistel Network block cipher with Electronic Codebook (ECB) mode and PKCS7 padding, where the Feistel rounds utilized Galois Field multiplications between the internal state and subkeys. The security of this custom cipher was not analyzed, as it was intended solely for obfuscation.
My implementation effectively used Rust's procedural macros. This feature enables writing Rust code that generates code within the codebase. As a result, the flag could be present in plaintext during development but is not included in the final binary. Additionally, the encryption code is also excluded from the final output. The macro handles the generation of parts of the obfuscation keys and encrypts the flag during compilation. Furthermore, it produces the necessary decryption code.
Part 3.2: Anti-debugging
The anti-debugging technique, while straightforward, proved effective, with only one known team successfully identifying and patching it. Rather than causing a program crash upon debugger detection, the method subtly corrupted the program's state. This manipulation ensured that decryption produced incorrect results, despite the program following the standard control flow from user mode.
During GPIO initialization, an unexpected modification occurred in the key array. This initialization path includes a self-tracing mechanism using the `ptrace` syscall, which fails if a debugger is already attached. The failure indicates debugging activity. By manipulating the sign bit of the `ptrace` return value with bitwise operations, we can obtain 0x00 if the program is being debugged and 0xFF otherwise.
To further obscure the anti-debugging mechanism, each key in the array undergoes a transformation: `key ^= key_modifier & mask`
Here, `key` represents an element within the array, `key_modifier` is its index, and `mask` is a predefined value. This technique ensures that the same code executes regardless of whether the program is being traced. However, the key array is only modified during non-debugged execution, effectively concealing the anti-debugging measures from participants. Given the extensive codebase and the CTF's 48-hour timeframe, this approach proved highly successful, particularly because participants were unaware of any anti-debugging implementation.
Part 4: RNG cloning
To obtain the main flags, the process involved gathering sufficient information to replicate the Random Number Generator (RNG) state. Subsequently, minimal bets were placed on losing spins, followed by wagering all funds on a winning bet. Upon reaching 20 times the initial cash (or 200 times for the final flag), the machine awarded a flag and prompted a move to a higher stake. Each game session concluded when either the 10-minute time limit expired, all funds were depleted, or the badge was removed.
The initial random number generation (RNG) starts at the beginning of the track and doesn't need reversing. It uses a ChaCha20-based algorithm but is always seeded with 0 at the start, resulting in the same sequence of spins each time. To exploit this, play a game, record the winning spins (e.g., 4, 8, 11, 12), then start a new game and bet everything on those identified winning spins.
The second random number generator (RNG) also employs the ChaCha20 algorithm but is flawed due to a common error: it is seeded with the UNIX timestamp. The on-screen display shows the time down to the second, and a background spinsleep thread used for synchronization provides the player with this seed. Furthermore, the RNG advances by three numbers each time the reels spin (because three reels are displayed) and by one every second (this increment is discarded). Several teams discovered this vulnerability through extensive trial and error without reverse engineering. Those teams exploited it by manipulating their computer's clock to replicate game states and predict winning spins. In my case, I wrote a separate exploit program to predict the upcoming symbols. This exploit was very fun to see exploited in a live setting
The third random number generator failed, producing nine pages of hexadecimal "Debug output." Although seeded correctly with /dev/urandom, the RNG employed the MT19937 algorithm (like C's random), which is vulnerable to a documented state cloning attack. The challenge was that the lengthy debug output, displayed only on-screen, couldn't be directly copied. Attackers would need to use Optical Character Recognition (OCR) and custom error-correction code, as any mistake would prevent the attack from working. Sufficient output was provided to clone the state, along with extra data to verify success before the main game. Keep in mind that each game had a 10-minute time limit, which could matter here.
The last RNG was custom-made, simple but significantly biased. To mitigate accidental wins, the required winning amount was increased from $10,000,000 to $100,000,000. Despite optimal play having a 0.3% win probability per game, one team still won by chance, which was amusing. Additionally, another team discovered an 80-spin cycle in the MSB of the output, allowing them to exploit a bias on the lowest valued symbol to obtain the flag. Only one team solved it the intended way. Although the cycle bias was not intentional, the team that discovered it still fully deserved their points. Regarding the team that won by chance, we anticipated a relatively high probability of this occurring, likely by a non-top-tier team less inclined to random gambling. Therefore, while a cool moment, it didn't affect cash prizes.
The intended solution approach was similar to that of MT19937, as both RNG states could be cloned given sufficient output. However, a key difference was the partial output in this case. While the third flag provided hexadecimal output convertible to full bytes, the fourth RNG only output slot machine symbols, representing a range of possible byte values. For example, a cherry symbol indicated a byte value between 0 and 127.
The 40-bit game state was scrambled using a XORShift LFSR, outputting 8 bits per symbol across five-symbol chunks, representing a partial state. Rarer symbols provide more state information. An exploit could spin, calculate possible states per five-symbol chunk by multiplying symbol ranges, and assess brute-force feasibility. Upon finding a rare sequence, the exploit could filter non-matching seeds and prompt further spins until the possible states are sufficiently reduced. My exploit could also proceed with multiple possible states, running them in parallel to predict winning spins across all remaining states.
It’s important to note here that there was a 10 minute limit here, so exploit code performance mattered. The faster your exploit code, the less lucky you need to be during exploitation to get rare symbols. My code used a threadpool using rayon and was written in Rust, compiled in release, and I could fairly consistently get the flag using my laptop.
One team successfully completed the entire track and produced a write-up, which is available at this link:
https://ptrstr.github.io/posts/nsec-2025-hit-the-jackpot/
Part 2: NorthSec 2025: My Experience as a Challenge Designer
Philippe Dugre
Based in the Montreal area, Philippe Dugre is a white-hat hacker with expertise in memory corruption exploits and cryptography. Having spent years competing and refining his skills, he now dedicates himself to fostering the development of aspiring experts, notably through his involvement in NorthSec.