[rev] DiceCTF 2026 - another-onion write-up
0123456789abcdef0123456789abcdefThe interesting part is what happens on failure.The binary does not simply print an error message and return. It crashes.That is a meaningful signal. Straightforward comparison binaries tend to fail quietly. A crash strongly suggests that the code path being executed is not a fixed, fully materialized verification routine, but something more fragile — possibly something being unpacked or transformed at runtime.That immediately points away from “plain input checker” territory and toward runtime decryption / hidden code execution.Why Static Disassembly Looked So BrokenIf you try to disassemble the original binary statically, the result looks messy enough that it is easy to wonder whether the disassembler is simply getting confused.The actual reason is simpler: the binary modifies its own .text section while running.So the code stored on disk is not the final version of the code that actually executes. What you see in the file is only the obfuscated form. The real logic is produced later, during runtime.In the sample binary, placing a breakpoint near the end of main around 0x407a57 and dumping memory at that point reveals the decrypted version of the real routine.That distinction matters:the file contains the encrypted/obfuscated coderuntime memory contains the decrypted executable codeOnce you recognize that, the rest of the binary starts making much more sense.The Runtime Decryption StubThe decrypted routine was reached through a small stub that looked like this:4071a0: mov eax, ecx
4071a2: and eax, 0x7f
4071a5: movzx eax, byte ptr [rdx+rax]
4071a9: xor byte ptr [rcx+0x4072b0], al
...
4071bc: jmp 0x4072b0This looks noisy at first, but the core logic is straightforward:Take a 128-byte repeating keyXOR-decrypt a target code region with that keyJump into the now-decrypted regionThat is a very standard obfuscation pattern in reversing challenges. XOR is especially common because it is cheap, reversible, and easy to scatter across a binary:XOR once to obfuscateXOR again with the same bytes to restoreIn this case, the decrypted region was:0x4072b0 .. 0x407955So the next question was obvious:What does this region actually do once decrypted?The “Hidden Logic” Was Mostly Just a PrinterThis is where the challenge becomes much less intimidating.Once the target region is decrypted, it does not reveal some elaborate validation engine. Most of it is just a long sequence of character-printing instructions.A representative pattern looks like this:mov edi, imm32
call putcThat is effectively:load one character, print it, repeat.So the supposedly hidden code is mostly just a printer routine emitting a string one character at a time.That output naturally splits into two parts:a fixed success messagea 32-character hexadecimal tokenAnd that is the key observation.The fixed success message is already known. Which means a large portion of the decrypted code is also already known.At that point, the challenge stops being about reconstructing mysterious logic and starts becoming a much simpler recovery problem.The Real Solve: Known Plaintext Beats the Big KeyThe challenge initially tries to frame itself as a problem about recovering a 512-byte key.But that framing is misleading.Once you know that the encrypted region eventually becomes a printer for a mostly known string, the most profitable attack surface is no longer the giant key. It is the repeating XOR layer protecting the output routine.That turns the solve into a textbook known-plaintext attack:plaintext = the decrypted code bytes we expect to seeciphertext = the encrypted bytes stored in the binaryattack = recover the repeating XOR pad using the known parts of the decrypted outputSo despite all the noise around the 512-byte key, the only thing that actually matters is the 128-byte repeating XOR pad used to decrypt the printer routine.That is the shortcut.Locating the Encrypted Printer in the Live BinaryFor the live server binary, the goal was to locate the corresponding encrypted printer routine again.This time, that could be done without fully executing the binary.The method was:Search for the unique tail byte sequence48 83 c4 08 c3 b8 10 04 40 00 c3Interpret the 4 bytes immediately before it as an addressThat produced the following address in the live binary:0x4d7720Because the layout was nearly identical to the sample binary, the encrypted printer region could be inferred as:encrypted printer start = retaddr - 0x6b0 = 0x4d7070
encrypted printer end = retaddr - 0xb = 0x4d7715So the bytes from 0x4d7070 to 0x4d7715 were treated as the encrypted code region.Reconstructing the Decrypted PrinterThe next step was to reconstruct what that region should look like after decryption.Structurally, it looked like:push raxmov rsi, [stdout]about 100 repetitions of mov edi, imm32 ; call putcAmong those printed characters:the first 68 belonged to the fixed success messagethe last 32 were the unknown tokenThat means most of the output routine was already predictable.And once most of the plaintext is known, a repeating XOR scheme becomes very fragile.There is no need to reverse everything perfectly if the binary is already leaking enough structure to let you recover the decryption material directly.Recovering the 128-Byte XOR PadAt this point, the setup was:the encrypted printer bytes were knownmost of the decrypted output was knownthe protection layer was a 128-byte repeating XORFrom there, recovery was mechanical:Read bytes from the encrypted regionXOR them with the known plaintext bytesRecover the corresponding bytes of the XOR padCheck consistency for positions sharing the same offset modulo 128In this instance, the known plaintext was sufficient to recover the entire 128-byte pad cleanly. There were no conflicting constraints, and once the full pad was known, decrypting the final unknown portion became trivial.The recovered token was:2e735210bee830357f49b1cad5d11cbeSubmissionThe token was submitted as follows:printf '2e735210bee830357f49b1cad5d11cbe\n' |
openssl s_client -quiet -connect another-onion-submission-6f6c8e7b5529.ctfi.ng:1337And the final flag was:dice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng}Why This Challenge Is NiceWhat makes this challenge good is that it deliberately tries to push the solver in the wrong direction.The 512-byte key is front and center, and the binary’s static appearance encourages you to think the right path is to understand every last part of the verification logic.But that is exactly the trap.The faster solve comes from noticing that the success-output path leaks far more structure than it should. Once you identify that weak point, the challenge becomes less about “deeply understanding everything” and more about attacking the most exploitable layer.That is a very real reversing lesson.In practice, the winning path is often not the path that explains the entire binary most thoroughly. It is the path that notices where the binary is leaking enough information to bypass the hard part entirely.This challenge is a clean example of that principle.TakeawaysThere are a few nice lessons here.1. Self-modifying code does not automatically make a challenge hardIt can make static analysis uglier, but ugliness is not the same thing as real security. Once the decrypted region is identified, the obfuscation layer can collapse quickly.2. Known plaintext is deadly against repeating XORIf the binary ever decrypts code or data that has a predictable structure, repeating XOR becomes a weak protection mechanism.3. Big keys can be a distractionThe challenge made the 512-byte key look important, but the real exploitable primitive was the 128-byte XOR pad protecting a mostly known output routine.4. You do not need complete understanding to solve a binaryIn reversing, full understanding is nice. It is not always necessary. Often the better question is:Where is the binary leaking structure I can exploit immediately?In this challenge, that was the entire game.Final Flagdice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng}