[{"content":"","date":"29 March 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":" CODEGATE 2026 Quals - Cobweb # Category: Web Challenge: Cobweb Description: I wanted to create a web application.. but I don't know how to use web frameworks. So I decided to use pure C to make a web application! Solver: exploit_admin_post_xss.py Transport helper: solve.py TL;DL # The challenge looks like a stored-XSS task at first, but that is only half right. The actual entry point is a one-byte stack overwrite in edit_post. If I make the escaped content length land exactly on 0x6000, the trailing NUL from html_escape() zeros the low byte of the saved user_id local. That pushes the request into the admin SQL branch, rewrites my post as user_id = 0, and then the admin-only render path decodes the escaped content back into raw HTML. Only after that ownership flip does the stored script become real JavaScript in the bot\u0026rsquo;s browser.\nOverview # The challenge description ended up being more honest than it first sounded. This really is a tiny web application written directly in C, and it behaves like one. Every control-port connection spawns a fresh HTTP server on a random port, prints that port, and tears the whole database down when the run ends.\nThat wrapper behavior mattered immediately for two reasons:\nevery connection starts from a clean database I cannot hard-code the real HTTP port because it changes every run The second infrastructure detail mattered even more once I started sending real requests: the server only performs one recv() per request. That means normal HTTP clients can make the service look buggy in the wrong way. A large form body can be split across packets, and the server will happily parse the first chunk as if it were the whole request.\nThat is why I kept raw-socket helpers around for the full solve. With this service, transport is part of the bug surface. Treating it like a normal web server would have hidden the real application behavior.\nAnalysis # The first thing I checked was the obvious web idea: stored XSS. There is a report feature, there is an admin bot, and the bot carries the flag in a cookie. That is exactly the kind of surface where I want to test a simple \u0026lt;script\u0026gt; payload first before inventing something more exotic.\nThat idea failed for a real reason, not because I tested it badly. Both create and edit escape post content before it reaches storage. Once I verified that in the code and in live behavior, plain stored XSS stopped being the main path.\nThere was another bug that looked promising for a while: the request parser uses plain strtok() in threaded code, so there is a genuine parser race. I reproduced that locally and kept the notes because it is a real bug, but it never became the route to the flag. The reason I moved away from it is practical. /report is POST-only, and path-steering alone was not giving me a clean way to turn the bot\u0026rsquo;s visit into the action I needed. It was interesting evidence, but it was not carrying the solve forward.\nThe real pivot came from looking at the escaping path more carefully. If stored XSS was dead at insert time, the next question was whether anything later turned escaped content back into HTML. That is what made me compare the normal post-render path against the admin-owned post-render path instead of staring at the parser race forever.\nTwo details lined up there:\nadmin-owned posts are rendered differently html_escape() has an off-by-one at the output boundary The bug in html_escape() is small but precise. When it handles \u0026quot; it writes \u0026amp;quot;, and if the escaped output lands exactly on the destination limit, it still writes the terminating NUL one byte past the end. In edit_post, that one byte lands on the low byte of the saved user_id local. So a normal user id like:\n1 0x00000001 becomes:\n1 0x00000000 At first that looks like a cute one-byte corruption with unclear value. The reason I kept pulling on it is that user_id is not just checked for authorization. It is used to choose which SQL update query runs. Once that low byte becomes zero, the handler stops acting like a normal user edit and takes the admin branch, which also forces user_id = 0 on the stored post.\nThat was the moment the challenge finally clicked for me. I was not trying to turn a one-byte overwrite into control flow hijack. I was using a one-byte overwrite to cross a trust boundary inside the application\u0026rsquo;s own logic.\nThe next question was what I gained by making the post admin-owned. That answer was even better than expected. Normal posts store escaped content and display it safely. Admin-owned posts go through an entity-decoding path before being inserted into the page. So the exact payload that was harmless as stored text for a normal post becomes live HTML once I force the post into the admin render path.\nThat is why the final technique is a two-stage chain instead of \u0026ldquo;just XSS\u0026rdquo;:\nuse the off-by-one to force an ownership change let the admin renderer resurrect the escaped script The last difficulty was delivery. The off-by-one only happens if the escaped content length is exactly 0x6000, and the server\u0026rsquo;s one-recv() request handling makes large form submissions unreliable if they are encoded naively. The fix was pragmatic:\ncompute the exact escaped length offline keep form encoding minimal leave quotes raw so the body does not triple in size send synchronized edit bursts over separate sockets until one full request lands cleanly It is not pretty, but it is the first version that behaved the same way remotely and locally.\nExploit # The final exploit flow was:\nConnect to the control port and recover the real ephemeral HTTP port. Register and log in as a normal user. Create a seed post so I have a stable post id. Build a second-stage edit body whose escaped length is exactly 0x6000. Send that edit request in synchronized bursts until the post flips into the admin-owned render path. Re-fetch the post and confirm that raw \u0026lt;script\u0026gt; now appears in the HTML instead of literal escaped text. Report the post. Let the bot visit the now-admin-owned post, execute the revived script, and submit document.cookie back into the same post. Fetch the post again and extract the flag=... cookie value from the stored content. I wrote the exploit this way because each checkpoint proves something different. Seeing raw \u0026lt;script\u0026gt; in the post page proves the off-by-one and ownership flip worked. Seeing the flag cookie later proves the bot really executed the revived script. Splitting the chain that way made debugging much easier than treating it as one black-box web exploit.\nVerification # The older local notes for cobweb turned out to be stale once I tested the updated public hosts. Running the current exploit against the new control endpoints and got:\ncodegate2026{edaa67b3a065abe46f5d64ea9338d0b0622000c646b47abf49c7e3d3d09419a53d5ae63dcfb496935cfc9099e2b3d1d1bc3c787e933e5e2175cca4a50cfe864f0e23bf14d3ec3409} 43.203.149.201:9883 still timed out from this environment, so the infrastructure is not perfectly uniform, but the exploit path itself is now fully confirmed.\nFor the successful runs, the decisive progression was:\n1 2 3 4 [+] created post 1 [+] admin-owned raw script confirmed in post HTML [+] report submitted codegate2026{...} The service is clearly instance-specific, so I am recording one representative fresh rerun flag below.\nFinal flag:\n1 codegate2026{edaa67b3a065abe46f5d64ea9338d0b0622000c646b47abf49c7e3d3d09419a53d5ae63dcfb496935cfc9099e2b3d1d1bc3c787e933e5e2175cca4a50cfe864f0e23bf14d3ec3409} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/cobweb/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - Cobweb # Category: Web Challenge: Cobweb Description: I wanted to create a web application.. but I don't know how to use web frameworks. So I decided to use pure C to make a web application! Solver: exploit_admin_post_xss.py Transport helper: solve.py TL;DL # The challenge looks like a stored-XSS task at first, but that is only half right. The actual entry point is a one-byte stack overwrite in edit_post. If I make the escaped content length land exactly on 0x6000, the trailing NUL from html_escape() zeros the low byte of the saved user_id local. That pushes the request into the admin SQL branch, rewrites my post as user_id = 0, and then the admin-only render path decodes the escaped content back into raw HTML. Only after that ownership flip does the stored script become real JavaScript in the bot’s browser.\n","title":"Cobweb","type":"writeups"},{"content":"","date":"29 March 2026","externalUrl":null,"permalink":"/categories/codegate2026/","section":"Categories","summary":"","title":"Codegate2026","type":"categories"},{"content":" CODEGATE 2026 Quals - CogwartsLang # Category: Reverse Engineering Challenge: CogwartsLang Solver: solve.grim TL;DL # The language syntax is mostly decoration. The real challenge is the oracle host module loaded by harness. Once I understood that the important state lived in the host and not in the source language, the solve became a timing problem: reconstruct the oracle\u0026rsquo;s arithmetic, identify the exact checkpoint and ticket values, and call the host functions in the right order without accidentally burning extra ticks.\nOverview # The execution model makes the attack surface very clear:\n1 2 3 /home/cogwarts/bin/harness \u0026#34;$TMP\u0026#34; \\ --host /home/cogwarts/bin/liboracle_host.so \\ --host /home/cogwarts/bin/libstdlib_host.so That immediately told me what not to spend too much time on. The only thing I control is the submitted source file. The harness and both host libraries are fixed. So if I want the flag, the important question is not \u0026ldquo;what cute thing can I do with the language syntax?\u0026rdquo; but \u0026ldquo;what does the oracle host expect, and how can I drive it precisely?\u0026rdquo;\nThat was an important correction early on because the challenge presentation makes it very tempting to overfocus on the language itself. In practice, the language is just the surface I use to call the host.\nAnalysis # The binaries were not stripped, which made the first pass much friendlier than I expected. harness accepts a one-argument solve[x], and the language exposes host_import and host_call. Once I noticed those primitives, I stopped treating the sugared oracle[...] syntax as something sacred. I wanted direct host interaction, because that was where the real state lived.\nThe first useful move was to wrap the oracle host locally and log what it was initialized with. That immediately exposed two constants:\nseed = 0x5f64d765889c6342 input_hash = 0xeacadd96dae055b8 The input_hash result was especially informative because it did not change when I changed the submitted source. That ruled out a whole family of wrong ideas. The challenge was not hashing my specific grimoire and expecting me to manipulate that derived value. The important state was already fixed in the host. My script only needed to drive the host into the success condition.\nOnce I shifted to that mindset, the meaningful host commands were easy to isolate: seed, tick, checkpoint, ticket, and witness. Reconstructing the host state structure showed that success is essentially a state-machine condition: set the witness bit and all three checkpoint bits while staying inside the ticket validity window.\nThe next part that cost time was arithmetic fidelity. The oracle logic uses Murmur-style mixing constants, which at first glance look like ordinary 64-bit math. My first reconstruction treated it that way and produced values that were plausible but consistently wrong. The missing detail was truncation. Several parts of the implementation fall through 32-bit registers before widening again. Once I mirrored those truncations correctly, the checkpoint and ticket values stopped drifting and started matching the host\u0026rsquo;s actual expectations.\nThat is also why I chose to model the host logic directly instead of trying to brute-force the command values. The values are not huge by cryptographic standards, but the timing interactions make blind search the wrong tool. Reverse the math once, then use the exact answers.\nThe last real obstacle was timing. Using the sugared oracle[...] form caused extra host imports and consumed ticks in places I did not want. That made otherwise correct checkpoint and witness values fail because I was arriving at them in the wrong host state. This was the final pivot of the solve: import the oracle once with host_import[\u0026quot;oracle\u0026quot;], keep the handle, and use raw host_call() so every tick spent is one I intended to spend.\nThat explains why the final grimoire looks more awkward than elegant. The repeated seed calls are not decorative. They are there because I needed the oracle state machine at a very specific tick count before I invoked the meaningful commands.\nExploit # The final solve script is short, but every line is there for a reason:\nImport the oracle exactly once. Burn 57 dummy seed calls to advance the internal tick counter to the right state. Call checkpoint for index 2 with 652393318. Call checkpoint for index 1 with 2916723419. Call checkpoint for index 0 with 984171264. Call ticket with 917138306. Call witness with 3074120555. I arrived at that exact order because the host state is doing two things at once:\nvalidating the numeric relationships enforcing when those relationships are allowed to become true So the solve is not just \u0026ldquo;find the right constants.\u0026rdquo; It is \u0026ldquo;find the right constants and spend the right number of ticks before using them.\u0026rdquo;\nVerification # I reran the solve locally on March 29, 2026 through the shipped harness and host libraries, and it still reached the success path:\n1 Success! codegate2026{fake_flag} That local rerun is enough to confirm that the call order, arithmetic, and timing are still right. I did not have a fresh public remote endpoint available in the repo during this rewrite pass, so the real flag below is still the one from the earlier successful remote submission of the same solve.grim.\nFinal flag:\n1 codegate2026{f384dc82142a7d21afd1e10b7f55be4d6798d7973720d536a903d64469d91074f25f04345e9375f8dfe647aa33e367006adc198362eb40f0a94a27f26be6b509fee2d0c33e63} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/cogwartslang/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - CogwartsLang # Category: Reverse Engineering Challenge: CogwartsLang Solver: solve.grim TL;DL # The language syntax is mostly decoration. The real challenge is the oracle host module loaded by harness. Once I understood that the important state lived in the host and not in the source language, the solve became a timing problem: reconstruct the oracle’s arithmetic, identify the exact checkpoint and ticket values, and call the host functions in the right order without accidentally burning extra ticks.\n","title":"Cogwartslang","type":"writeups"},{"content":" CODEGATE 2026 Quals - comqtt # Category: Pwn Challenge: mqtt / comqtt Solver: solve.py TL;DL # The broker has a retained-message deletion bug that leaves a stale tail entry behind after compaction. On the next retained insert, that stale slot frees a payload pointer that a live retained entry still references. Because each client runs in its own thread and glibc tcache is per-thread, that one mistake becomes a cross-thread tcache-dup primitive. I used it first to build an arbitrary-read oracle, then to dump the live libc image, resolve system() from the in-memory ELF data, and finally overwrite free@GOT with the real runtime address instead of guessing a libc version.\nOverview # The first thing I had to stop getting wrong was the network layout. The public port is not the MQTT broker. It is an admin console that prints:\n1 Broker port : \u0026lt;ephemeral_port\u0026gt; The admin socket stays open while the real broker runs elsewhere. That means I have two different channels to think about:\nthe admin socket, which gives me the broker port and later returns the command output the broker port, where all heap corruption happens through MQTT traffic That split shaped the exploit from the start. Any time I forgot that the admin side and the broker side were different processes and different sockets, I ended up debugging the wrong thing.\nThe binary itself is a non-PIE 64-bit ELF with NX, a canary, and partial RELRO. That already pushed me toward heap corruption and GOT overwrite rather than trying to invent a stack bug that was not there.\nAnalysis # The root bug sits in retained-message deletion. When the broker deletes a retained topic, it frees the payload, decrements the retained count, and copies the last retained entry over the deleted slot. The old tail slot is never cleared. On the next retained insert, that stale slot is treated like reusable metadata and its payload pointer gets freed again even though a live retained entry still points to it.\nBy itself, that is a use-after-free with a stale metadata reference. The reason it becomes a real exploit primitive is the threading model. Each MQTT client is handled in its own detached thread, and glibc tcache is per-thread. That means the same small chunk can be freed into two different tcaches:\nonce in thread A again in thread B After that, both threads can allocate the same address from their own bins. That is the core of the exploit.\nI did not try to jump straight to code execution from there. The first thing I wanted was a leak. With modern glibc, safe-linking makes blind poisoning much less pleasant, and I needed to know exactly what process I was corrupting. Replaying the freed retained payload gives back the first qword of a tcache entry, which is enough to recover the safe-linking mask for that chunk. That made later poisoning controlled instead of hopeful.\nOnce I had the mask, I redirected one duplicated small chunk onto retained metadata for a topic I kept named LEAK. That was a deliberate design choice. I wanted a primitive that fit the broker\u0026rsquo;s normal behavior. If retained metadata for LEAK points to an arbitrary address and size, then a normal subscribe to LEAK turns the broker into an arbitrary-read oracle. That is much easier to debug than a one-shot smash straight into the GOT.\nThe most annoying failure in the whole solve came after that stage. My first exploit guessed a specific Ubuntu glibc 2.39 point release and computed system() from a leaked function pointer using hard-coded offsets. That worked locally and still failed remotely. This is exactly the kind of pwn failure I distrust most, because everything before the final overwrite looks healthy. The heap corruption works, the leaks look real, and only the last jump target is wrong.\nThat is why I changed techniques. Instead of arguing with the remote libc version, I used the arbitrary-read primitive properly. After leaking one GOT entry, I dumped the live mapped libc image from memory, searched that dump for the ELF header, walked the dynamic table, and resolved system from the actual dynsym data in the running process.\nThat was the right pivot because it removed the last brittle assumption from the exploit. From that point on, the final overwrite targeted the real system() of the real process I was currently exploiting.\nThe other practical issue was thread lifetime. Once two tcaches share corrupted state, closing helper connections too aggressively is a good way to crash the exploit during thread teardown instead of during the interesting part. The final solver keeps several corrupted client trios alive on purpose. It looks messy, but that mess is there because it matched the service\u0026rsquo;s behavior better than trying to clean up politely.\nOne rerun detail was worth keeping in the writeup because it reflects a real implementation edge. Running the solver locally against deploy/mqtt hit the fallback libc-dump path and later timed out during an arbitrary-read round. Running the same logic against the packaged ubuntu-server wrapper succeeded immediately. That reinforced the earlier lesson: for this challenge, matching the intended runtime environment matters.\nExploit # The final flow was:\nConnect to the admin console and parse the ephemeral broker port. Seed retained topics so the metadata layout becomes predictable. Use multiple client threads to duplicate one small chunk across two tcaches. Leak the safe-linking mask from the freed retained payload. Poison the duplicated chunk onto retained metadata for topic LEAK. Subscribe to LEAK and use the broker as an arbitrary-read primitive. Leak read@GOT, then dump the live libc image and resolve system() from the in-memory ELF structures. Run a second corruption round against the GOT window. Overwrite only free@GOT with the resolved system() address. Send a normal non-retained publish whose payload is cat /home/ctf/flag. Let the broker free that temporary payload and read the command output back on the admin socket. I like this endgame because it is boring in the right way. Once free@GOT points to system, I do not need a fancy trigger. The broker already allocates and frees temporary publish payloads during ordinary operation. Using a normal publish as the trigger keeps the exploit aligned with the program\u0026rsquo;s real control flow instead of forcing an unnatural crashy path.\nVerification # The packaged local reproduction still works and returns the placeholder flag:\n1 codegate2026{fake_flag} For the live service, I had a successful fresh rerun on March 29, 2026 that produced:\n1 codegate2026{07fd7ea9d9e17fd79f4f6274a3e421904edf570d193e6610dc3bec7e80490fa1c2bad262e3a08434c800c0a25cc5875de4a3298221442c071275} I also had a later rerun time out in the arbitrary-read stage after the quiet-output cleanup, which is worth mentioning because it reflects real exploit fragility rather than a documentation issue. The working path is solid, but it is still a multithreaded heap exploit against a network service, not a one-packet toy.\nFinal flag:\n1 codegate2026{07fd7ea9d9e17fd79f4f6274a3e421904edf570d193e6610dc3bec7e80490fa1c2bad262e3a08434c800c0a25cc5875de4a3298221442c071275} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/comqtt/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - comqtt # Category: Pwn Challenge: mqtt / comqtt Solver: solve.py TL;DL # The broker has a retained-message deletion bug that leaves a stale tail entry behind after compaction. On the next retained insert, that stale slot frees a payload pointer that a live retained entry still references. Because each client runs in its own thread and glibc tcache is per-thread, that one mistake becomes a cross-thread tcache-dup primitive. I used it first to build an arbitrary-read oracle, then to dump the live libc image, resolve system() from the in-memory ELF data, and finally overwrite free@GOT with the real runtime address instead of guessing a libc version.\n","title":"Comqtt","type":"writeups"},{"content":"","date":"29 March 2026","externalUrl":null,"permalink":"/tags/ctf/","section":"Tags","summary":"","title":"CTF","type":"tags"},{"content":" CODEGATE 2026 Quals - Greybox # Category: Reverse Engineering Challenge: Oh! My Greybox Keeps Running! Files: deploy/prob, deploy/target Solver: solver.py TL;DL # The binary hides a small VM behind fake FILE state and libc teardown machinery. The hard part is not the arithmetic. The hard part is recognizing that the weird runtime wrapper is only there to make the VM harder to spot. Once I aligned the handler table correctly and confirmed how the scheduler dispatches handlers, the shortest reliable solve was to record one concrete trace, replay that trace symbolically, and let z3 recover the 64-byte accepted input.\nOverview # This challenge looked small enough that I expected either a packed checker or a very compact VM, and strings pushed me toward the second explanation immediately:\n1 2 3 4 5 6 Sucess! Flag is codegate2026{%.*s} Wrong! ./target Input: Input length must be 64bytes... That single format string matters a lot. It means the binary is not printing a hidden flag from data or code. It is printing the accepted 64-byte input back inside the flag format. Once I knew that, the whole problem became \u0026ldquo;recover the exact accepted input\u0026rdquo; rather than \u0026ldquo;find some secret string in memory.\u0026rdquo;\nThe handout included only the stripped ELF, a target blob, and the Docker setup. So my first goal was not to understand every libc trick around it. It was to find the real execution engine hidden inside the wrapper.\nAnalysis # main itself is very small. It reads 64 bytes, then hands control to a much larger helper that loads ./target, builds a pair of internal state objects, and initializes a 19-entry function table. My first pass over that function table was not productive at all. It looked like broken disassembly: overlapping handlers, strange fallthrough, and code that did not make semantic sense.\nThat turned out to be my mistake, not the binary\u0026rsquo;s. The jump table was real, but several handlers were being decoded from the wrong byte boundary. Once I corrected the alignment, the whole VM snapped into focus. The \u0026ldquo;greybox\u0026rdquo; feeling of impossible control flow was mostly an artifact of reading the dispatcher one byte off.\nThe next question was why the handlers were not called in a normal loop. The answer is the challenge gimmick: the binary builds fake FILE-like objects and lets libc teardown paths drive the scheduler. I confirmed that with a failing run under strace, because the process kept doing libc-flavored cleanup work long after main should have been finished. That was enough for me. I did not need to reverse every fake FILE field in detail. I only needed to follow execution until I understood the dispatch rule and the state transitions.\nThat was a deliberate choice. I could have spent a lot more time explaining every part of the fake runtime, but that would not have moved me toward the accepted input any faster. Once I could see the scheduler clearly, the VM itself was much more ordinary than the wrapper tried to suggest.\nThe key dispatch rule came from tracing the scheduler in GDB:\n1 handler = target[pc] + carry - 3 The carry bit depends on which of the two fake states is active, so the state alternation is predictable. From there the handlers were small and readable: register moves, immediate loads, input-word loads and stores, arithmetic and logical ops, shifts, compare-not-equal, branches, and a finish handler.\nThe next important question was whether I needed full path exploration. If the executed handler sequence depended heavily on the input, a one-trace solve would have been fragile. What made this challenge pleasant is that the control-flow skeleton is effectively fixed for the interesting path. The input changes values in registers, but not the overall sequence of handlers I needed to model. That is why I chose a trace-and-replay solve rather than trying to symbolically model the entire scheduler from scratch.\nOnce I trusted that choice, the rest of the design followed naturally:\nrun the VM concretely once record the exact handler trace rebuild only that trace symbolically That is much cleaner than trying to derive one huge symbolic model from disassembly alone. It also let me avoid over-engineering the solver. I did not need a full VM lifter. I only needed a faithful replay of the executed path.\nI also added printable constraints first because the successful input is printed directly back as the flag body. That was not required for correctness, but it was a practical choice. If several satisfying assignments existed, I wanted the one that turned into a sensible printable flag string.\nExploit # The final solver does exactly the minimum I found trustworthy:\nExecute the checker once with zero input. Record the executed (pc, state, handler) trace and the concrete branch outcomes. Rebuild that same trace symbolically using sixteen unknown 32-bit words. Emit SMT-LIB and let z3 solve it instead of hand-writing one giant formula. Ask for a printable model first, then relax that constraint only if necessary. Pack the model back into 64 bytes and verify it against the original program before printing. I liked this approach because it matches the actual challenge structure. The binary is trying hard to hide a small deterministic computation inside a noisy runtime shell. Replaying the real executed path is a direct answer to that design. I am not fighting the wrapper on its terms. I am stepping around it.\nVerification # I reran solver.py on March 29, 2026 after cleaning up the default output path. The solver now prints only the final recovered flag, and it still self-verifies the candidate against the original binary before returning success.\nThe rerun produced:\n1 codegate2026{4h!_C0ngr47u147i0ns!_L37_m3_kn0w_why_7his_gr3y_b0x_d03s_n07_3nd!} Final flag:\n1 codegate2026{4h!_C0ngr47u147i0ns!_L37_m3_kn0w_why_7his_gr3y_b0x_d03s_n07_3nd!} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/greybox/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - Greybox # Category: Reverse Engineering Challenge: Oh! My Greybox Keeps Running! Files: deploy/prob, deploy/target Solver: solver.py TL;DL # The binary hides a small VM behind fake FILE state and libc teardown machinery. The hard part is not the arithmetic. The hard part is recognizing that the weird runtime wrapper is only there to make the VM harder to spot. Once I aligned the handler table correctly and confirmed how the scheduler dispatches handlers, the shortest reliable solve was to record one concrete trace, replay that trace symbolically, and let z3 recover the 64-byte accepted input.\n","title":"Greybox","type":"writeups"},{"content":" Reverse engineering, binary exploitation, and CTF solve notes of k1nt4r0u. I treat this blog like a field notebook: challenge writeups, debugging trails, and the tricks worth keeping after the competition ends.\nBrowse writeups About me Focus Reversing Practice Pwn / RE CTFs Output Writeups and notes ","date":"29 March 2026","externalUrl":null,"permalink":"/","section":"Home","summary":" Reverse engineering, binary exploitation, and CTF solve notes of k1nt4r0u. I treat this blog like a field notebook: challenge writeups, debugging trails, and the tricks worth keeping after the competition ends.\n","title":"Home","type":"page"},{"content":" CODEGATE 2026 Quals - oldschool # Category: Reverse / AEG Challenge: oldschool Description: Back to the past Solver: solver.py Client helper: drive_client.py TL;DL # The provided Go client is only a courier. The real challenge is the ELF it downloads every round. Each round binary checks sha256(input[:4]), uses those same four bytes to decrypt a 7-instruction VM program, runs the remaining 60 bytes through that VM, applies one more generated bytewise transform, and compares the result against a target buffer in .rodata. I solved it by separating the stable part from the unstable part: recover the 4-byte prefix statically, invert the VM cleanly, and let one or two GDB probes reveal the final generated stage instead of trying to re-lift that tail by hand every round.\nOverview # The handout looked almost intentionally unhelpful. There was no obvious challenge binary, only a Go client and the same file again in the archive. That immediately told me where to start: before doing any reversing on the per-round binaries, I needed to understand how the client asked for them and how it submitted answers.\nThat was a good first move because the client was not hiding anything clever. The useful functions were easy to find, the protobuf message types were obvious, and the framing was simple: 1 byte type + 4 byte big-endian length + protobuf payload. Once I traced RequestChallenge and SubmitAnswer, the server interaction became mechanical. The only thing I really needed from the client was the dropped ELF path and the final success/failure messages.\nRunning the official client against the service made the actual challenge finally appear: prob1.bin, prob2.bin, and so on. At that point the job stopped being \u0026ldquo;reverse a Go client\u0026rdquo; and became \u0026ldquo;reverse twenty related ELFs quickly enough that the transport never becomes the hard part.\u0026rdquo;\nAnalysis # The first useful binary made the overall shape obvious. It reads exactly 64 bytes, hashes part of the input, transforms the rest through a tiny custom VM, runs one more stage, then compares against a 60-byte target in .rodata.\nThe first important correction was noticing that the SHA-256 check only covers the first 4 input bytes. That same 4-byte prefix is also reused to decrypt the embedded program seed into the real 7-instruction VM program. That split is what made the whole challenge manageable:\nthe prefix decides the VM program the suffix is the data the program transforms Once I saw that, brute-forcing the prefix stopped sounding ridiculous. I was not brute-forcing a whole answer. I was only testing four independent bytes against very strong structural filters. For each byte position, I kept only values that decrypted instructions with sane properties: valid opcode, non-zero repeat count, valid next-PC, and valid table selector. That collapses the search space very quickly. The embedded SHA-256 digest then removes the last ambiguity.\nThat is why I chose a structural search for the prefix instead of trying to symbolically solve the whole binary end to end. The binary itself was already telling me how to prune the search, and the prefix space was tiny compared to the full 64-byte input.\nOnce the prefix was fixed, the VM was much less intimidating than it first looked. The instruction families are all table-based and all invertible once the right tables are loaded from .rodata: a bytewise transform table, a 60-byte permutation, a 256-byte substitution table, and a slightly stateful opcode whose behavior still depends only on fixed data plus the recovered prefix.\nMy first real mistake came from overfitting to one sample. I initially treated some of the tables as if their absolute addresses mattered. That happened to work on the first binary I was staring at, then failed immediately on the next one. The real invariant was not address identity. It was section-relative layout inside .rodata. Switching to pyelftools and reading everything by .rodata offset fixed that class of mistakes for good.\nThe second wrong turn was more subtle. After reversing one round, I thought the final post-VM stage was simple enough to just lift directly and reuse. That also broke on fresh rounds. The binaries all come from the same template, but the last transform is generated differently enough that hard-coding it is exactly the kind of shortcut this challenge punishes.\nThat was the point where the solve became much cleaner. Instead of insisting on a full static lift, I asked what I actually needed. By that point I already trusted my model of the prefix recovery and the VM itself. The only unknown was the last 60-byte transform right before the final memcmp(). So I stopped reversing there and used the binary itself as the oracle for the unstable part.\nThe GDB probe is small but decisive. I run the binary under gdb --batch, break at the final memcmp() when rdx == 0x3c, and dump the 60-byte buffer passed in $rdi. For one or two chosen payloads, that gives me clean (pre_final, post_final) pairs. Once I have those pairs, the inference problem is tiny compared to the original reversing problem. For each modulo-4 byte class, I search over a narrow family of transforms: xor, add, or sub, optionally combined with a rotate under a small modular condition. That search is cheap, and it matched every round cleanly.\nThat is also why I kept a local validator in the solver. This challenge is the kind where a nearly-correct model looks convincing right up until the server rejects it. I wanted the script to prove the recovered answer against the binary locally before it ever went back to the official client.\nExploit # The final workflow was:\nRequest the next round with the official client and wait for the dropped ELF. Parse .rodata with pyelftools instead of relying on absolute addresses. Recover the 4-byte prefix by decrypting candidate VM programs and filtering on instruction structure. Keep the candidate whose prefix digest matches the embedded SHA-256 value. Emulate the 7-instruction VM forward and backward. Run one or two chosen payloads under GDB and dump the final compare buffer at memcmp(). Infer the generated final-stage transform from those buffer pairs. Invert the whole pipeline, validate the recovered answer locally, then feed it back to the official client. I deliberately kept the network layer boring after that. drive_client.py does not try to replace the official client or speak protobuf directly. It just watches the existing client output for binary_path=..., solves the dropped ELF, and writes the answer back. That was a pragmatic choice. Once the protobuf path was already working, there was no reason to introduce a second custom transport layer and risk a self-inflicted bug.\nVerification # I reran the full service flow on March 29, 2026 with drive_client.py and let it solve all 20 rounds again. The method did not need any structural change; only the final flag changed, which is exactly what I want to see from an AEG-style solve.\nThe fresh rerun ended with:\n1 codegate2026{77d3a1094cb1b78aa8f41e542f2cd47a34638c0d2c18fe2a6c2158af8e6958a88fd8ca793f4c2f4643363c13417cfcfcad1650bfa737c85fa7e3080d7d1900f3f137eda4955dd2c3} Final flag:\n1 codegate2026{77d3a1094cb1b78aa8f41e542f2cd47a34638c0d2c18fe2a6c2158af8e6958a88fd8ca793f4c2f4643363c13417cfcfcad1650bfa737c85fa7e3080d7d1900f3f137eda4955dd2c3} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/oldschool/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - oldschool # Category: Reverse / AEG Challenge: oldschool Description: Back to the past Solver: solver.py Client helper: drive_client.py TL;DL # The provided Go client is only a courier. The real challenge is the ELF it downloads every round. Each round binary checks sha256(input[:4]), uses those same four bytes to decrypt a 7-instruction VM program, runs the remaining 60 bytes through that VM, applies one more generated bytewise transform, and compares the result against a target buffer in .rodata. I solved it by separating the stable part from the unstable part: recover the 4-byte prefix statically, invert the VM cleanly, and let one or two GDB probes reveal the final generated stage instead of trying to re-lift that tail by hand every round.\n","title":"Oldschool","type":"writeups"},{"content":"","date":"29 March 2026","externalUrl":null,"permalink":"/tags/re/","section":"Tags","summary":"","title":"RE","type":"tags"},{"content":"","date":"29 March 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" CODEGATE 2026 Quals - tinyIRC # Category: Pwn Challenge: tinyIRC Remote launcher: nc 15.165.70.236 20998 Solver: solve.py TL;DL # The wrapper port is not the IRC service. It prints the real port, keeps the wrapper process attached to the child, and becomes the side channel that later carries the leak and the flag. Inside the IRC server, QUIT clears a client slot while the recv loop is still using the stale pointer, and a reused slot can come back with a negative input_len. That negative length becomes a reusable cross-slot overwrite. I used it first to turn memmove() into printf() for a same-process libc leak, then to replace strtok@got with system() and run cat /home/ctf/flag \u0026gt;\u0026amp;2.\nOverview # The most important thing to understand first is that 20998 is not the actual IRC port. Connecting there starts the real server on a random port and prints a line like:\n1 tinyIRC server listening on port \u0026lt;random_port\u0026gt; That sounds like a wrapper nuisance, but it is actually part of the exploit surface. The wrapper socket stays open, and later it becomes the place where the leaked libc address and the final flag come back. So I treated it as a control channel from the beginning rather than as a throwaway launcher.\nThe binary itself is a non-PIE 64-bit ELF with NX, a canary, CET, and partial RELRO. That combination already pushed me away from any fantasy about an easy stack overwrite. If I was going to get code execution, it was much more likely to come from a stable logic bug plus a GOT pivot than from fighting the mitigations head on.\nAnalysis # Each IRC client lives in a fixed-size slot in .bss. The fields that matter are the input buffer and the input length. Once I mapped those, the bug in the QUIT path became the center of the challenge.\nThe problem is not that QUIT merely disconnects a client. The real issue is timing inside the recv loop. After one full IRC line is parsed, disconnect() clears the client slot immediately, but the surrounding loop keeps running with the pointer it already had. That means the rest of the loop is now operating on stale state that no longer matches what the connection manager thinks is in that slot.\nMy first question was whether that only bought me a crash or a one-shot disconnect bug. It turned out to be much better than that because reconnecting into the same slot does not fully reinitialize the structure. In particular, a negative input_len can survive across reuse.\nThat made the technique choice much clearer. I did not need to force control flow directly. I needed to turn stale slot reuse into a stable write primitive.\nThe useful magic value was slot 1 with len = -111. That offset is not arbitrary. It lines up so that writes through slot 1 walk back into slot 0\u0026rsquo;s header:\n1 slot1.buffer - 111 = slot0.len So one carefully sized packet sent through the recycled slot can repair slot 1 just enough to keep it usable while also overwriting slot0.len with the next negative value I want. That is what makes the exploit chain reusable instead of one-shot.\nThe next thing I had to learn the hard way was that the primitive does not behave like a tiny arbitrary write. Short writes are unreliable because the recv loop checks whether len + recv_len exceeds the buffer limit before copying, and a negative len looks enormous in that arithmetic. The workaround was to stop thinking in terms of small surgical writes. Each exploit stage became a broad overwrite that starts near the target and stretches forward into the real buffer.\nThat shaped the first stage. I chose memmove@got as the first target because the server naturally calls memmove() inside the recv path, and I already had a convenient output channel on the wrapper socket. Replacing memmove with printf@plt lets me turn a normal server action into a format-string leak without restarting the child. I also patched strtok@got to a tiny helper so the parser survived long enough to use the leak.\nThat decision was much better than trying to jump straight to system(). I needed a libc address from the same child process first, and the printf() pivot gave me one in a way that fit the service\u0026rsquo;s normal behavior.\nThe actual leak became clean once I mapped the positional-argument layout. After I knew which overwritten qwords showed up as which printf arguments, I could plant fprintf@got in a controlled slot and recover the live libc address with a single format string.\nThen the second stage reused the same negative-length primitive, this time starting near strtok@got. Once libc was known, strtok -\u0026gt; system was the neatest endgame because the call site was already there. I only had to make sure the first argument was a command string:\n1 cat /home/ctf/flag \u0026gt;\u0026amp;2 Sending it to stderr mattered because stderr was still attached to the wrapper socket I had kept alive from the beginning.\nThe part that made this challenge feel real instead of toy-like was process lifetime. The exploit is easy to describe if each stage gets a fresh process. The actual challenge is keeping the same child alive through the leak and the final pivot. That is why the solver is organized around one long-lived instance instead of many short disconnected attempts.\nExploit # The final order was:\nConnect to the wrapper and read the real IRC port. Keep that wrapper socket open because it will later carry the leak and the flag. Open the victim connection that will trigger both corruption stages. Recycle a helper slot until it comes back with len = -111. Use that helper slot to set slot0.len = -0xD7. Perform the broad overwrite starting at memmove@got and pivot memmove() into printf(). Leak fprintf@libc, compute the libc base, then compute system(). Re-arm the helper path and set slot0.len = -0xC7. Perform the second broad overwrite starting at strtok@got. Trigger system(\u0026quot;cat /home/ctf/flag \u0026gt;\u0026amp;2\u0026quot;) and read the result on the wrapper socket. I wrapped the whole exploit in retries because the transport still has timing edges, but the exploit chain itself is not guesswork once the same child survives both stages.\nVerification # I reran solve.py on March 29, 2026 with the quieter default output path. The service still behaves like a per-instance challenge, so I am treating the value below as a fresh rerun example rather than pretending there is one eternal tinyIRC flag.\nThe successful rerun printed:\n1 codegate2026{382ade6995beaf7de132a74d99285e638c92a0f0231e1ca091c39ace85450036e6d5b15634e078d01ab1bee515893575dccc097c02f509ee52e271ce9b95f36b85f26013f452cd76} Final flag:\n1 codegate2026{382ade6995beaf7de132a74d99285e638c92a0f0231e1ca091c39ace85450036e6d5b15634e078d01ab1bee515893575dccc097c02f509ee52e271ce9b95f36b85f26013f452cd76} ","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/codegate2026/tinyirc/writeup/","section":"Writeups","summary":"CODEGATE 2026 Quals - tinyIRC # Category: Pwn Challenge: tinyIRC Remote launcher: nc 15.165.70.236 20998 Solver: solve.py TL;DL # The wrapper port is not the IRC service. It prints the real port, keeps the wrapper process attached to the child, and becomes the side channel that later carries the leak and the flag. Inside the IRC server, QUIT clears a client slot while the recv loop is still using the stale pointer, and a reused slot can come back with a negative input_len. That negative length becomes a reusable cross-slot overwrite. I used it first to turn memmove() into printf() for a same-process libc leak, then to replace strtok@got with system() and run cat /home/ctf/flag \u003e\u00262.\n","title":"Tinyirc","type":"writeups"},{"content":"Collected challenge writeups, reverse engineering notes, and short postmortems.\n","date":"29 March 2026","externalUrl":null,"permalink":"/writeups/","section":"Writeups","summary":"Collected challenge writeups, reverse engineering notes, and short postmortems.\n","title":"Writeups","type":"writeups"},{"content":"","date":"19 March 2026","externalUrl":null,"permalink":"/categories/dicectf-2026/","section":"Categories","summary":"","title":"DiceCTF 2026","type":"categories"},{"content":" First reaction # The challenge description tells you, very politely, not to solve it the obvious way:\ndon\u0026rsquo;t interpret the puzzle, it will OOM your computer\nThat was accurate. The provided interpreter can start reducing the source program, but doing the whole thing through Church-encoded lambda calculus is hopelessly slow. So from the beginning, the real solve was always going to be static analysis.\nRecognizing the language # The first definitions in flag_riddle.txt give the theme away:\n1 2 3 真以矛盾而为矛矣 假以矛盾而为盾矣 正以人而为人矣 Those are just Church booleans and the identity function written with Chinese tokens. Once that clicked, the file stopped looking like an unknown esolang and started looking like a parser problem.\nThe core grammar is compact:\nToken Meaning 以...而为 function definition 于 application 为 binding 矣 end of statement Confirming it in the binary # I still checked the interpreter in IDA to make sure the source-language guess matched reality.\nThe decompilation confirmed a normal lambda-calculus interpreter with three node types:\nType Meaning 0 variable reference 1 lambda abstraction 2 application The output path was also revealing. The interpreter walks a Church-encoded linked list, converts one Church numeral at a time into an integer by counting f applications, writes the corresponding byte, then advances to the tail.\nThat was the key mental shift: I did not need to evaluate the lambda calculus directly. I only needed to recover the arithmetic expression graph that eventually produced those numerals.\nTurning the source into ordinary data # A few encodings matter:\n朝...暮 wraps binary literals 春 means bit 0 秋 means bit 1 bits are read least-significant-bit first So something like:\n1 朝秋春秋暮 represents 1 + 0 + 4 = 5.\nThe flag itself is stored as a linked list built from Church-style helpers such as 双, 有, 无, 本, 末, 在, and 用. Once I parsed the 旗 definition, I had the exact order of the variables that corresponded to flag characters.\nThe remaining work was evaluating the definitions that produced those variables.\nThe important operator mapping # The place I could have gone wrong was the arithmetic vocabulary.\nThe critical discovery was:\n销 is subtraction 次 is multiplication The sanity check that made this clear was one of the data chains:\n1 2 10! + 8! = 3669120 3669120 - 3669110 = 10 That only makes sense if 销 means subtraction. After that, the rest of the numeric expressions started falling into place.\nThe nice design choice in the challenge is that it uses huge operations like factorial without making the final values huge. Terms such as 32! / 31! collapse cleanly back to small integers, so a plain Python evaluator with big integers is enough.\nSolver # My static solver did three things:\nstrip away non-CJK wrapper text, parse each definition into a tiny expression DAG, evaluate literals, add/sub/mul/div/pow/factorial recursively with memoization. This was enough:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import math import re clean = re.sub(r\u0026#34;[^\\u2E00-\\u9FFF]\u0026#34;, \u0026#34;\u0026#34;, open(\u0026#34;flag_riddle.txt\u0026#34;, \u0026#34;r\u0026#34;).read()) data = clean[clean.index(\u0026#34;㐀为朝\u0026#34;):] flag_start = data.index(\u0026#34;旗为\u0026#34;) code_section = data[:flag_start] flag_names = re.findall(r\u0026#34;有(.)\u0026#34;, data[flag_start:]) variables = {} for stmt in code_section.split(\u0026#34;矣\u0026#34;): if \u0026#34;为\u0026#34; not in stmt: continue name, expr = stmt.split(\u0026#34;为\u0026#34;, 1) if len(name) != 1 or not expr: continue if expr.startswith(\u0026#34;朝\u0026#34;) and \u0026#34;暮\u0026#34; in expr: bits = expr[1:expr.index(\u0026#34;暮\u0026#34;)] variables[name] = (\u0026#34;lit\u0026#34;, sum((1 \u0026lt;\u0026lt; i) for i, ch in enumerate(bits) if ch == \u0026#34;秋\u0026#34;)) elif expr[0] == \u0026#34;合\u0026#34;: variables[name] = (\u0026#34;add\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;销\u0026#34;: variables[name] = (\u0026#34;sub\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;次\u0026#34;: variables[name] = (\u0026#34;mul\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;分\u0026#34;: variables[name] = (\u0026#34;div\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;幂\u0026#34;: variables[name] = (\u0026#34;pow\u0026#34;, expr[1], expr[2]) elif expr[0] == \u0026#34;阶\u0026#34;: variables[name] = (\u0026#34;fact\u0026#34;, expr[1]) cache = {} def eval_var(name): if name in cache: return cache[name] op, *args = variables[name] if op == \u0026#34;lit\u0026#34;: value = args[0] elif op == \u0026#34;add\u0026#34;: value = eval_var(args[0]) + eval_var(args[1]) elif op == \u0026#34;sub\u0026#34;: value = max(eval_var(args[0]) - eval_var(args[1]), 0) elif op == \u0026#34;mul\u0026#34;: value = eval_var(args[0]) * eval_var(args[1]) elif op == \u0026#34;div\u0026#34;: value = eval_var(args[0]) // eval_var(args[1]) elif op == \u0026#34;pow\u0026#34;: value = eval_var(args[0]) ** eval_var(args[1]) else: value = math.factorial(eval_var(args[0])) cache[name] = value return value print(\u0026#34;\u0026#34;.join(chr(eval_var(name)) for name in flag_names)) Flag # 1 dice{y0u_int3rpret3d_Th3_CJK_gr4mMaR_succ3ssfully} The program continues with extra Chinese text after the closing brace, but the ASCII substring above is the actual flag.\nTakeaway # I liked this challenge because the \u0026ldquo;esoteric interpreter\u0026rdquo; part is mostly there to scare you into doing too much work. Once the syntax and operator mapping were clear, the right move was to throw away the interpreter and treat the source as serialized arithmetic.\n","date":"19 March 2026","externalUrl":null,"permalink":"/writeups/dicectf2026/interpreter/","section":"Writeups","summary":"First reaction # The challenge description tells you, very politely, not to solve it the obvious way:\n","title":"Interpreter Required","type":"writeups"},{"content":" First look # This challenge shipped a bzImage, an initramfs.cpio.gz, and a remote VM that dropped into a BusyBox shell. That immediately made me think \u0026ldquo;driver challenge,\u0026rdquo; so I unpacked the initramfs before spending time on the remote instance.\nThe init script confirmed that instinct:\n1 2 3 4 5 if [ ! -e /dev/challenge ]; then mknod /dev/challenge c 10 123 fi chmod 666 /dev/challenge exec setsid cttyhack su -s /bin/sh ctf So the whole challenge surface was a world-writable character device exposed to an unprivileged user. At that point the job was clear: reverse the device interface, then talk to it directly.\nReversing the device # After extracting vmlinux from the kernel image, I traced the misc-device handlers and mapped the useful IOCTLs:\nIOCTL Value Meaning RESET 0x6489 Start a new maze GET_MOVES 0x80046486 Return a bitmask of valid moves GET_FLAG 0x80406487 Return the flag at the goal cell MOVE 0x40046488 Move in one of six directions The driver implements a 3D maze. The movement indices come in opposite pairs:\n0 \u0026lt;-\u0026gt; 2 1 \u0026lt;-\u0026gt; 3 4 \u0026lt;-\u0026gt; 5 Once I understood that, the kernel part of the challenge got much simpler. This was just DFS with backtracking.\nThe maze was easy # The actual search logic is standard:\nRESET the maze, ask GET_MOVES for valid directions, try each unexplored move, call GET_FLAG after each step, backtrack when stuck. So the algorithm was never the hard part.\nThe upload constraint was the real obstacle # What actually slowed me down was the environment.\nThe VM was tiny, there was no convenient upload path, and the session timed out quickly. My first attempt used a normal statically linked helper binary, and it was far too large to paste over the connection reliably.\nThat forced the real pivot in the solve: the helper had to be rebuilt as something much smaller.\nI rewrote it to avoid libc entirely and use only raw syscalls for the handful of operations I needed:\nopen ioctl write exit Then I built it with size-focused flags, stripped it aggressively, compressed it, base64-encoded it, and uploaded it through a heredoc. That was the difference between \u0026ldquo;interesting local solve\u0026rdquo; and \u0026ldquo;actually usable on remote.\u0026rdquo;\nThe final delivery path looked like this:\n1 2 3 4 5 6 cat \u0026gt; /tmp/exp.b64 \u0026lt;\u0026lt;\u0026#39;__EOF__\u0026#39; \u0026lt;base64 payload\u0026gt; __EOF__ base64 -d /tmp/exp.b64 \u0026gt; /tmp/exp chmod +x /tmp/exp /tmp/exp Once the helper was inside the VM, it could talk to /dev/challenge, explore the maze, and ask for the flag from the goal cell.\nFlag # 1 dice{twisty_rusty_kernel_maze} Takeaway # Explorer has two separate solves layered on top of each other:\nreverse the kernel driver well enough to recover the IOCTL protocol, then package that logic into a binary small enough for the hostile remote environment. The first half was normal reversing. The second half was what made the challenge memorable.\n","date":"19 March 2026","externalUrl":null,"permalink":"/writeups/dicectf2026/explorer/","section":"Writeups","summary":"First look # This challenge shipped a bzImage, an initramfs.cpio.gz, and a remote VM that dropped into a BusyBox shell. That immediately made me think “driver challenge,” so I unpacked the initramfs before spending time on the remote instance.\n","title":"Explorer","type":"writeups"},{"content":"","date":"7 March 2026","externalUrl":null,"permalink":"/categories/apoorvctf-2026/","section":"Categories","summary":"","title":"Apoorvctf 2026","type":"categories"},{"content":" First look # requiem is a stripped Rust ELF, which usually means a lot of disassembly noise before you get to the part that matters.\nRunning it gave a very suspicious three-line script:\n1 2 3 loading flag printing flag..... RETURN TO ZERO!!!!!!!! No flag ever appeared, but the message was already telling the story. Something was probably being decoded in memory and then wiped immediately.\nMaking sure the flag is local # Before digging into the binary, I wanted to know whether the program fetched the flag from outside.\nstrace answered that quickly: there was no meaningful flag file access and no network activity. That meant the flag was almost certainly embedded in the binary itself, or at least derived entirely from embedded data.\nThat narrowed the search a lot.\nThe suspicious blob next to the strings # The next useful move was checking strings with offsets. The interesting output looked like this:\n1 2 3 47000 loading flag 4851f i\u0026#39;printing flag..... 48534 RETURN TO ZERO!!!!!!!! That odd i'printing flag..... line was the clue. It meant there were printable bytes immediately before the visible string, which usually means some nearby data blob is being interpreted as text.\nDumping the surrounding .rodata region revealed a 45-byte chunk right before printing flag.....:\n1 3b2a3535282c392e3c21146a05176a08690508690b0f6b6917056b14050e126b6f0569020a69086b6914196927 That did not look random enough to be compressed and did not look structured enough to be plain text. XOR-encoded data was the obvious guess.\nFinding the decode loop # Once I looked for cross-references to that blob, the core logic showed up quickly. The important loop does exactly this:\nload one byte from the embedded blob, XOR it with 0x5a, write it to an output buffer, repeat for 0x2d bytes. In other words:\n1 flag = bytes(byte ^ 0x5A for byte in blob) The joke line at runtime also turned out to be literal. Right after decoding the buffer, the program zeroes it out byte by byte. So the challenge is not \u0026ldquo;make it print the flag,\u0026rdquo; it is \u0026ldquo;notice the decode before the wipe.\u0026rdquo;\nThe easy mistake # One small detail is easy to miss.\nThe final encoded byte, 27, sits directly in front of the printing flag..... string. If you stop the blob one byte too early, you lose the closing brace.\nThat last byte matters:\n$$ 0x27 \\oplus 0x5a = 0x7d $$and 0x7d is }.\nSo the entire 45-byte blob has to be included.\nRecovering the flag # At that point the solve is just one line of Python:\n1 2 3 4 5 blob = bytes.fromhex( \u0026#34;3b2a3535282c392e3c21146a05176a08690508690b0f6b6917056b14050e126b\u0026#34; \u0026#34;6f0569020a69086b6914196927\u0026#34; ) print(bytes(byte ^ 0x5A for byte in blob).decode()) Output:\n1 apoorvctf{N0_M0R3_R3QU13M_1N_TH15_3XP3R13NC3} Takeaway # This one looks noisy because it is a Rust binary, but the solve is tiny once the runtime hint clicks. The binary really does \u0026ldquo;return to zero.\u0026rdquo; The whole challenge is about catching the XOR decode before the program wipes its own work.\n","date":"7 March 2026","externalUrl":null,"permalink":"/writeups/apoorvctf/requiem/","section":"Writeups","summary":"First look # requiem is a stripped Rust ELF, which usually means a lot of disassembly noise before you get to the part that matters.\n","title":"Requiem","type":"writeups"},{"content":" First impression # forge looked much worse than it really was.\nThe binary is stripped, PIE, and imports a mix of ptrace, fork, prctl, mmap, RAND_bytes, EVP_sha256, and EVP_aes_256_gcm. That is exactly the kind of import table that tries to make you expect anti-debugging, runtime decryption, and maybe a second stage.\nI did chase that direction for a bit. One decoded string in .rodata even hinted at payload\u0026gt;bin, which made it look like something external might be missing.\nThe good news is that none of that turned out to matter.\nThe real pivot # The turning point was simply watching what the main loop actually did instead of what the imports suggested.\nOnce I looked at the repeated operations, the pattern was hard to miss:\npick a pivot find an inverse normalize a row eliminate that column from every other row That is Gaussian elimination, not cryptography.\nThe binary was solving a fixed linear system over bytes. The multiplication was not normal integer multiplication, though. Every product came from a 256 x 256 lookup table stored in .rodata, so the arithmetic was happening in a custom GF(256)-style field.\nThat changed the whole challenge. I no longer cared about the anti-debug path or the missing payload hint. I only needed the constants.\nRebuilding the system # The relevant data was all embedded in the main binary:\na 56 x 56 coefficient matrix a 56-byte right-hand side a 65536-byte multiplication table Together they form a 56 x 57 augmented matrix.\nSo I wrote a small Python solver that:\nreads the binary bytes, extracts the matrix and multiplication table from .rodata, performs the same row-reduction logic as the binary, finds multiplicative inverses by scanning for mul(a, x) == 1, and finally reads the last column once the matrix is reduced. That is enough because the verifier is deterministic. The flag is already baked into the embedded system.\nWhy the static route works # This is the part I liked most about the challenge: it is mostly misdirection.\nThe program wants you to spend time on the surrounding noise:\nanti-debugging OpenSSL calls process tricks a suspicious payload path But the actual answer is sitting in plain sight as a solvable algebra problem. Once I committed to that interpretation, the challenge became much smaller.\nResult # The reduced matrix decoded cleanly as ASCII:\n1 APOORVCTF{Y0u_4ctually_brOught_Y0ur_owN_Firmw4re????!!!} Takeaway # Forge is a good reminder that \u0026ldquo;lots of crypto imports\u0026rdquo; is not the same thing as \u0026ldquo;the solve is cryptography.\u0026rdquo; The real signal was the row-reduction pattern. After that, the rest of the binary was just decoration.\n","date":"7 March 2026","externalUrl":null,"permalink":"/writeups/apoorvctf/forge/","section":"Writeups","summary":"First impression # forge looked much worse than it really was.\n","title":"Forge","type":"writeups"},{"content":" First look # This one came with a very on-theme setup: a tiny CHALL.EXE DOS program and a floppy image containing the same executable. A quick strings pass already told me it was a flag checker:\n1 2 3 4 5 UCLA NetSec presents: LACTF \u0026#39;86 Flag Checker Check your Flag: Sorry, the flag must begin with \u0026#34;lactf{...\u0026#34; Sorry, that\u0026#39;s not the flag. Indeed, that\u0026#39;s the flag! So the question was not what the binary did, but whether it hid the flag in a way that was annoying enough to matter.\nWhat the checker really does # Loading the program in radare2 as 16-bit x86 made the structure fairly clear.\nThe first part is just a prefix check. The binary rejects anything that does not begin with lactf{, so there is nothing interesting there.\nThe second part is where the actual trick lives. The checker hashes the entire input into a 20-bit state:\n1 2 3 4 5 def hash_string(data): state = 0 for byte in data: state = (67 * state + byte) % (1 \u0026lt;\u0026lt; 20) return state That 20-bit value becomes the seed for a small LFSR:\n1 2 3 def lfsr_step(state): feedback = (state \u0026amp; 1) ^ ((state \u0026gt;\u0026gt; 3) \u0026amp; 1) return ((state \u0026gt;\u0026gt; 1) | (feedback \u0026lt;\u0026lt; 19)) \u0026amp; 0xFFFFF The checker advances the LFSR once per character, takes the low byte, XORs it with the candidate flag byte, and compares the result against a 73-byte constant stored in the data segment.\nSo the validation logic is:\n1 expected[i] == input[i] XOR keystream[i] At first glance that looks circular, because the input determines the seed and the seed determines the keystream that decrypts the input.\nThe weakness # The circular dependency looks clever, but the state is only 20 bits wide. That is just 2^20 possibilities, which is completely brute-forceable.\nSo instead of trying to solve the algebra directly, I treated the embedded bytes as ciphertext and tested every possible seed:\nGenerate the LFSR keystream for a candidate seed. XOR it with the stored bytes to recover a plaintext candidate. Keep only printable candidates that look like lactf{...}. Re-hash that plaintext and check whether it reproduces the same seed. That last check is what resolves the circular dependency cleanly.\nSolving it # The full brute-force script is short:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 def lfsr_step(state): feedback = (state \u0026amp; 1) ^ ((state \u0026gt;\u0026gt; 3) \u0026amp; 1) return ((state \u0026gt;\u0026gt; 1) | (feedback \u0026lt;\u0026lt; 19)) \u0026amp; 0xFFFFF def hash_string(data): state = 0 for byte in data: state = (67 * state + byte) % (1 \u0026lt;\u0026lt; 20) return state expected = bytes([ 0xb6, 0x8c, 0x95, 0x8f, 0x9b, 0x85, 0x4c, 0x5e, 0xec, 0xb6, 0xb8, 0xc0, 0x97, 0x93, 0x0b, 0x58, 0x77, 0x50, 0xb0, 0x2c, 0x7e, 0x28, 0x7a, 0xf1, 0xb6, 0x04, 0xef, 0xbe, 0x5c, 0x44, 0x78, 0xe8, 0x99, 0x81, 0x04, 0x8f, 0x03, 0x40, 0xa7, 0x3f, 0xfa, 0xb7, 0x08, 0x01, 0x63, 0x52, 0xe3, 0xad, 0xd1, 0x85, 0x9f, 0x94, 0x21, 0xd5, 0x2a, 0x5c, 0x20, 0xd4, 0x31, 0x12, 0xce, 0xaa, 0x16, 0xc7, 0xad, 0xdf, 0x29, 0x5d, 0x72, 0xfc, 0x24, 0x90, 0x2c, ]) for seed in range(1 \u0026lt;\u0026lt; 20): state = seed plain = bytearray() for byte in expected: state = lfsr_step(state) plain.append(byte ^ (state \u0026amp; 0xFF)) try: text = plain.decode(\u0026#34;ascii\u0026#34;) except UnicodeDecodeError: continue if text.startswith(\u0026#34;lactf{\u0026#34;) and text.endswith(\u0026#34;}\u0026#34;) and hash_string(plain) == seed: print(hex(seed), text) break The correct seed turned out to be 0xf3fb5, and the recovered plaintext was the flag.\nFlag # 1 lactf{3asy_3nough_7o_8rute_f0rce_bu7_n0t_ea5y_en0ugh_jus7_t0_brut3_forc3} Takeaway # The interesting part here was not the DOS binary itself. It was noticing that the fancy self-seeded stream cipher still collapsed to a tiny brute-force space. Once I stopped treating the seed dependency as a blocker, the solve became a straightforward offline search.\n","date":"7 February 2026","externalUrl":null,"permalink":"/writeups/lactf/lactf-1986/","section":"Writeups","summary":"First look # This one came with a very on-theme setup: a tiny CHALL.EXE DOS program and a floppy image containing the same executable. A quick strings pass already told me it was a flag checker:\n","title":"Lactf 1986","type":"writeups"},{"content":"","date":"7 February 2026","externalUrl":null,"permalink":"/categories/lactf-2026/","section":"Categories","summary":"","title":"LACTF 2026","type":"categories"},{"content":" Status # This page was originally just a template, and I do not have enough real solve artifacts in the repo to turn it into a proper writeup without inventing details.\nThat means:\nthere is no saved exploit script here there is no challenge file or terminal log here there are no notes describing the actual solve path I would rather leave this as an honest placeholder than fake a smooth story that never happened.\nWhat is missing # To rewrite this one properly, I would need at least one of the following:\nthe challenge file or source the final exploit or solver a few solve notes showing the main pivot the final flag or proof of success Once those exist, I can rewrite this page in the same style as the rest of the writeups.\n","date":"7 February 2026","externalUrl":null,"permalink":"/writeups/lactf/the-cat/","section":"Writeups","summary":"Status # This page was originally just a template, and I do not have enough real solve artifacts in the repo to turn it into a proper writeup without inventing details.\n","title":"The Cat","type":"writeups"},{"content":" Setup # The challenge came with a Python-based \u0026gt;\u0026lt;\u0026gt; (Fish) interpreter and a one-line Fish program that checked the input against one huge constant.\nThe hint was the important part:\n\u0026ldquo;there may be some issues with this if the collatz conjecture is disproven\u0026rdquo;\nThat line was enough to stop me from treating this like a generic esolang problem. The checker was clearly doing some arithmetic transform, and the Collatz reference suggested that the flag was being folded into a single integer rather than checked character by character.\nFirst clue # After translating the Fish program into normal logic, the checker reduced to two stages.\nFirst, it turned the input string into one big integer by reading the bytes as a big-endian base-256 number:\n$$ \\text{acc} = \\sum_{i=0}^{n} \\text{ord}(c_i) \\cdot 256^{n-i} $$So at that point the input was effectively:\n1 acc = int.from_bytes(flag.encode(), \u0026#34;big\u0026#34;) Then it ran a modified Collatz process and packed the parity decisions into another integer:\n1 2 3 4 5 6 7 8 counter = 1 while acc != 1: counter *= 2 if acc % 2 == 0: acc //= 2 else: counter += 1 acc = (acc * 3 + 1) // 2 The checker never compared the string directly. It only compared the final counter against a hardcoded target.\nThat mattered because it meant I did not need to emulate the Fish program forward. I only needed to invert the transform.\nTurning the checker around # The useful observation is that every loop iteration doubles counter, and the odd branch adds one on top of that. So if I start from the final target and walk backward, the parity of counter tells me which branch was taken.\neven counter means the forward step came from an even Collatz update odd counter means the forward step came from the modified odd update That gives a clean reverse procedure:\n1 2 3 4 5 6 7 while counter \u0026gt; 1: if counter % 2 == 0: counter //= 2 acc *= 2 else: counter = (counter - 1) // 2 acc = (acc * 2 - 1) // 3 Running that backward walk recovered the original big integer after 2999 steps.\nThat was the only real pivot in the challenge. Once the reverse direction was clear, the rest was just decoding bytes.\nRecovering the flag # After reconstructing acc, I converted it back to a byte string:\n1 2 flag = acc.to_bytes((acc.bit_length() + 7) // 8, \u0026#34;big\u0026#34;).decode(\u0026#34;ascii\u0026#34;) print(flag) That produced:\n1 lactf{7h3r3_m4y_83_50m3_155u35_w17h_7h15_1f_7h3_c011472_c0nj3c7ur3_15_d15pr0v3n} Verification # The recovered string matched the challenge theme exactly and includes the Collatz joke from the hint, which is a good sign that the reverse process is correct.\nFinal flag:\n1 lactf{7h3r3_m4y_83_50m3_155u35_w17h_7h15_1f_7h3_c011472_c0nj3c7ur3_15_d15pr0v3n} ","date":"7 February 2026","externalUrl":null,"permalink":"/writeups/lactf/the-fish/","section":"Writeups","summary":"Setup # The challenge came with a Python-based \u003e\u003c\u003e (Fish) interpreter and a one-line Fish program that checked the input against one huge constant.\n","title":"The Fish","type":"writeups"},{"content":"","date":"25 December 2025","externalUrl":null,"permalink":"/categories/cscv-2025/","section":"Categories","summary":"","title":"CSCV 2025","type":"categories"},{"content":" ReezS # First wrong turn # My first read on this binary was completely wrong. It looked like a normal flag checker, so I did what I usually do for that kind of challenge: identify the comparison logic, lift the constants, and script the inverse.\nThat script only gave me:\n1 sorry_this_is_fake_flag!!!!!!!!! That should have been a clue immediately, but I still lost a lot of time staring at the control flow.\nThe behavior that finally forced me to rethink the challenge was this:\nunder a debugger, sorry_this_is_fake_flag!!!!!!!!! was accepted running the same input normally, it failed Same input, same binary, different result. That is not a math mistake. That is environment-sensitive behavior.\nThe actual pivot # After the contest I came back to the import table, and the answer was sitting there:\nIsDebuggerPresent is imported, and the program checks it very early.\nThat explained the split behavior perfectly. The fake string was not a failed inversion of the real checker. It was bait. The binary was selecting different encoded data depending on whether a debugger was attached.\nOnce I knew that, I stopped trying to model every branch. I just took the two real encoded blocks from the debugger-only path, XORed them with the constant mask, swapped the halves into the right order, and reversed the decoded string.\nThis is the cleaned-up version of the script:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def xor_bytes(a: bytes, b: bytes) -\u0026gt; bytes: return bytes(x ^ y for x, y in zip(a, b)) def main(): factor0 = bytes([0xAA]) * 16 factor1 = bytes.fromhex(\u0026#39;939FCF9C9B9998C99DC8C9989ECFCB9A\u0026#39;) factor2 = bytes.fromhex(\u0026#39;9F9D9D9DCB989A9B999A98CF9DCFCFCF\u0026#39;) part1 = xor_bytes(factor1, factor0) part2 = xor_bytes(factor2, factor0) flag_bytes = part2 + part1 print(f\u0026#34;CSCV2025{{{flag_bytes.decode(\u0026#39;utf-8\u0026#39;)[::-1]}}}\u0026#34;) if __name__ == \u0026#39;__main__\u0026#39;: main() That recovered:\n1 CSCV2025{0ae42cb7c2316e59eee7e203102a7775} The whole solve really came down to noticing that the checker was lying differently depending on whether it saw a debugger.\nChatbot # First pass # This executable looked different right away. Opening it in IDA showed PyInstaller-style markers, so instead of treating it like a normal native binary, I treated it like a packaged Python app with a native helper library.\nThat meant the first useful step was extraction, not decompilation. I used pyinstxtractor.py to unpack the bundled files:\nI did not have decompyle3 available, so I used an online decompiler to get a readable main.py. The high-level flow was enough:\nload libnative.so optionally run an integrity check verify a token for role == VIP if that passes, call decrypt_flag_file(\u0026quot;flag.enc\u0026quot;) That last point was the real clue. The program pretends the hard part is token validation, but the Python side already tells us the flag is sitting in a local encrypted file and the decryption routine lives in the native library we already have.\nSo I stopped caring about forging a VIP token and moved straight to libnative.so.\nNative side # Inside the library, decrypt_flag_file calls recover_key:\nAnd recover_key is much simpler than the name makes it sound. It just rebuilds the original AES key from an obfuscated byte array and a short repeating mask:\nBack in decrypt_flag_file, the logic is straightforward:\nread the first 16 bytes of flag.enc as the IV, treat the rest as ciphertext, choose the AES branch based on key length, decrypt. Because the recovered key is 32 bytes long, the branch used here is AES-256-CBC.\nThat means the whole solve can be reproduced locally without ever passing the token check.\nI reimplemented the key recovery and decryption in Python:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 #!/usr/bin/env python3 from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.backends import default_backend OBF_KEY = [ 0xEE, 0x50, 0xD1, 0xAA, 0xE0, 0x97, 0x5F, 0x43, 0xDD, 0xA8, 0xAC, 0x83, 0xF0, 0x05, 0xF3, 0xFF, 0x62, 0x08, 0xF4, 0x44, 0x4B, 0x2C, 0x55, 0xEC, 0xB9, 0x65, 0x23, 0xCC, 0x25, 0x65, 0xEE, 0x70 ] MASK = [0x2a, 0x2a, 0xa, 0x9a] def recover_key(): recovered_key = bytearray(32) recovered_key[0] = 0xC4 for i in range(1, 32): mask_byte = MASK[i \u0026amp; 3] recovered_key[i] = OBF_KEY[i] ^ mask_byte return bytes(recovered_key) key = recover_key() def decrypt_flag_file(filename): with open(filename, \u0026#34;rb\u0026#34;) as f: iv = f.read(16) ct = f.read() cipher = Cipher(algorithms.AES256(key), modes.CBC(iv), backend=default_backend()) decryptor = cipher.decryptor() return decryptor.update(ct) + decryptor.finalize() def main(): decrypted_data = decrypt_flag_file(\u0026#34;flag.enc\u0026#34;) if decrypted_data: print(decrypted_data.decode(\u0026#34;utf-8\u0026#34;)) if __name__ == \u0026#34;__main__\u0026#34;: main() That decrypted the bundled file and printed:\n1 CSCV2025{reversed_vip*_chatbot_bypassed} The nice part of this challenge is that the intended story is \u0026ldquo;become VIP,\u0026rdquo; but the cleaner reversing route is just to follow the local decryption path and ignore the access-control theater entirely.\n","date":"25 December 2025","externalUrl":null,"permalink":"/writeups/cscv2025/cscv_2025_re/","section":"Writeups","summary":"ReezS # First wrong turn # My first read on this binary was completely wrong. It looked like a normal flag checker, so I did what I usually do for that kind of challenge: identify the comparison logic, lift the constants, and script the inverse.\n","title":"CSCV_2025_RE","type":"writeups"},{"content":"","date":"25 December 2025","externalUrl":null,"permalink":"/categories/picomini_by_cmu_africa/","section":"Categories","summary":"","title":"PicoMini_by_CMU_Africa","type":"categories"},{"content":"This set had two Android reversing challenges. Both of them became much easier once I stopped staring at the default UI and followed the data that the APK already exposed.\nM1n10n'5_53cr37 # First pass # I started by opening minions.apk in jadx-gui and checking MainActivity, which is usually the first useful place in beginner Android reversing.\nIn this case it was mostly noise. Nothing there explained where the flag was hidden.\nThe first real clue came from the hint:\nAny interesting source files?\nThat pushed me toward text search instead of static browsing. Searching for interesting turned up this string:\n1 android:text=\u0026#34;Look into me my Banana Value is interesting\u0026#34; So the next question became simple: where is Banana Value stored?\nPivot # A second text search for Banana found the string resource:\n1 \u0026lt;string name=\u0026#34;Banana\u0026#34;\u0026gt;OBUWG32DKRDHWMLUL53TI43OG5PWQNDSMRPXK3TSGR3DG3BRNY4V65DIGNPW2MDCGFWDGX3DGBSDG7I=\u0026lt;/string\u0026gt; That blob looked like Base32 immediately. Decoding it gave the flag directly:\n1 picoCTF{1t_w4sn7_h4rd_unr4v3l1n9_th3_m0b1l3_c0d3} Takeaway # The only thing that mattered here was not trusting the activity layout as the whole challenge. The flag was never hidden behind complex code. It was just sitting in resources with a hint pointing at it.\nPico Bank # First clue # For pico-bank.apk, I again started in MainActivity, and this time the transaction list stood out right away:\n1 2 3 4 this.transactionList.add(new Transaction(\u0026#34;Grocery Shopping\u0026#34;, \u0026#34;2023-07-21\u0026#34;, \u0026#34;$ 1110000\u0026#34;, false)); this.transactionList.add(new Transaction(\u0026#34;Electricity Bill\u0026#34;, \u0026#34;2023-07-20\u0026#34;, \u0026#34;$ 1101001\u0026#34;, false)); this.transactionList.add(new Transaction(\u0026#34;Salary\u0026#34;, \u0026#34;2023-07-18\u0026#34;, \u0026#34;$ 1100011\u0026#34;, true)); ... Those amounts were clearly not normal balances. They looked like binary.\nConverting the values to ASCII recovered the first half of the flag:\n1 picoCTF{1_l13d_4b0ut_b31ng_ That established the pattern, but the flag was incomplete, so the rest had to be somewhere else in the app.\nSecond clue # The challenge hint mentioned the OTP flow, so I searched for OTP in the decompiled sources and resources.\nThat led to:\n1 \u0026lt;string name=\u0026#34;otp_value\u0026#34;\u0026gt;9673\u0026lt;/string\u0026gt; and to the verifyOtp logic:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 public void verifyOtp(String otp) throws JSONException { String endpoint = \u0026#34;your server url/verify-otp\u0026#34;; if (getResources().getString(R.string.otp_value).equals(otp)) { Intent intent = new Intent(this, (Class\u0026lt;?\u0026gt;) MainActivity.class); startActivity(intent); finish(); } else { Toast.makeText(this, \u0026#34;Invalid OTP\u0026#34;, 0).show(); } JSONObject postData = new JSONObject(); postData.put(\u0026#34;otp\u0026#34;, otp); JsonObjectRequest jsonObjectRequest = new JsonObjectRequest(1, endpoint, postData, ...); this.requestQueue.add(jsonObjectRequest); } The important detail here was that the app still POSTed the OTP to the backend, and the backend response included the missing flag chunk. At that point the local OTP value was all I needed.\nGetting the second half # I sent the discovered OTP to the endpoint directly:\n1 2 3 4 5 import requests payload = {\u0026#34;otp\u0026#34;: 9673} r = requests.post(\u0026#34;http://saffron-estate.picoctf.net:56247/verify-otp\u0026#34;, data=payload) print(r.text) The server responded with:\n1 {\u0026#34;success\u0026#34;:true,\u0026#34;message\u0026#34;:\u0026#34;OTP verified successfully\u0026#34;,\u0026#34;flag\u0026#34;:\u0026#34;s3cur3d_m0b1l3_l0g1n_c0085c75}\u0026#34;,\u0026#34;hint\u0026#34;:\u0026#34;The other part of the flag is hidden in the app\u0026#34;} Combining both parts produced the full flag:\n1 picoCTF{1_l13d_4b0ut_b31ng_s3cur3d_m0b1l3_l0g1n_c0085c75} Final takeaway # Both APKs rewarded the same habit:\nsearch the app resources instead of only reading the main activity treat weird constants as data first, not as UI decoration follow the client/server boundary when the app hints at network validation Once those pivots were clear, neither challenge needed anything more complicated than text search, decoding, and one short request script.\n","date":"25 December 2025","externalUrl":null,"permalink":"/writeups/picomini/picomini_by_cmu_africa/","section":"Writeups","summary":"This set had two Android reversing challenges. Both of them became much easier once I stopped staring at the default UI and followed the data that the APK already exposed.\n","title":"PicoMini_by_CMU_Africa","type":"writeups"},{"content":"","date":"25 December 2025","externalUrl":null,"permalink":"/categories/wannagame-championship-2025/","section":"Categories","summary":"","title":"WannaGame Championship 2025","type":"categories"},{"content":"These are cleaned-up contest notes rather than polished full writeups. Buzzing has a complete solve path, but Checker and Dutchman_app are intentionally kept as partial notes because the missing final artifacts are not preserved in this repo. I would rather leave those gaps visible than pretend I remember more than I actually do.\nBuzzing # I started this one in the wrong direction. My first instinct was to copy the challenge out of the remote environment and reverse it locally, but that was not really necessary.\nAfter poking around the instance with basic Linux commands, the useful observation was that the restriction seemed to be tied to the literal /readflag path. In other words, eBPF was likely filtering commands that referenced that exact pathname, not blocking the underlying file from running under a different name.\nThat makes the bypass almost trivial:\n1 2 ln -s /readflag /tmp/solve /tmp/solve Running the symlinked path was enough to read /flag.\nI do not have the exact printed flag string saved in these notes, but the actual solve path was just this symlink bypass. The important idea was realizing the filter cared about the command path, not the file contents.\nChecker # This challenge gave a Windows PE wrapper:\n1 checker.exe: PE32+ executable for MS Windows 6.00 (console), x86-64, 6 sections The first useful step was reversing the wrapper itself, not the checker logic. In IDA, main asks the user to choose checker 1 or 2, maps that choice to resource IDs 101 and 102, extracts the selected resource into a file named flag_checker.exe, executes it, waits for it to finish, and then deletes it.\nThat immediately changed the plan. I did not need to understand the wrapper deeply. I just needed to catch the extracted payloads before they were removed.\nSo I broke on DeleteFileA, ran the wrapper twice with the two different options, and recovered both embedded flag_checker.exe files for offline analysis.\nChecker 1 # The first recovered checker was the one I made meaningful progress on.\nThe code looked messy enough that I initially was not sure what family of transform I was even looking at. After following cross-references and leaning on AI for algorithm identification, the checker turned out to apply several layers in sequence:\nChaCha20 an LCG-based byte mask RC4 a repeating XOR with the key skibidi The key material itself was not the hardest part. Most of it was easy to recover from constants and xrefs. The annoying piece was the LCG seed. I grabbed that dynamically by breaking immediately after the call to sub_140002370(1337) and reading rax, which gave me 0xAD66AA22.\nWith that seed, I could reverse the layers:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 from Crypto.Cipher import ARC4, ChaCha20 target_signed = [-4, 118, -44, 9, -93, -40, 80, 47, -71, -41, -70, -32, -80, 52, -78] ciphertext = bytes((x + 256) % 256 for x in target_signed) key_xor = b\u0026#34;skibidi\u0026#34; key_rc4 = bytes(range(1, 17)) key_chacha = b\u0026#34;\\xAA\u0026#34; * 32 nonce_chacha = b\u0026#34;\\x45\u0026#34; * 12 def chacha(data, key, nonce): return ChaCha20.new(key=key, nonce=nonce).decrypt(data) def lcg(data, seed): out = bytearray() state = seed \u0026amp; 0xFFFFFFFFFFFFFFFF for byte in data: mask = 0 tmp = state for _ in range(8): mask ^= tmp \u0026amp; 0xFF tmp \u0026gt;\u0026gt;= 8 out.append(byte ^ (mask \u0026amp; 0xFF)) state = (state * 0x5851F42D4C957F2D + 0x14057B7EF767814F) \u0026amp; 0xFFFFFFFFFFFFFFFF return bytes(out) def rc4(data, key): return ARC4.new(key).decrypt(data) def xor(data, key): return bytes(d ^ key[i % len(key)] for i, d in enumerate(data)) print(xor(rc4(lcg(chacha(ciphertext, key_chacha, nonce_chacha), 0xAD66AA22), key_rc4), key_xor)) That recovered:\n1 W1{Ch4ng1ng_d4t And that is where my preserved notes stop. I did not finish reconstructing parts 2 and 3 from the second checker during the event, so I am leaving this section as a partial solve rather than fabricating the missing ending.\nChecker 2 # I also recovered the second embedded checker, which appears to be responsible for the remaining parts of the flag, but these notes do not contain a finished analysis or final reconstruction. The honest state is simply: wrapper understood, payload extraction solved, checker 1 partly reversed, full flag not preserved.\nDutchman_app # This challenge unpacked into an APK, so the first pass was standard Android reversing with jadx.\nMainActivity immediately showed a few suspicious details:\na lockout stored in SharedPreferences a native library load for check_new_detection logic that appeared to reject unauthorized devices before the real app flow could continue The UnlockTime value is set to currentTimeMillis() + 180000, so getting rejected means waiting three minutes before the app will even let you try again. That made the device-gating logic worth bypassing first.\nI moved from jadx to apktool, decompiled the APK, and patched MainActivity.smali to jump over the device check. The point was not to solve the whole challenge in smali, just to keep the app alive long enough to see the next stage.\nThe patch was essentially:\n1 2 3 4 if-nez p1, :cond_11 if-nez v1, :cond_11 if-nez v3, :cond_11 if-nez v4, :cond_c with an added branch to skip the rejection path.\nThat was the meaningful pivot. After rebuilding and retrying post-contest, I could at least reach the security-key screen, which confirmed that the Java layer was only the front door and the real logic likely lived in the native library.\nAt that point I switched to the bundled .so files:\n1 2 3 4 arm64-v8a/libcheck_new_detection.so armeabi-v7a/libcheck_new_detection.so x86/libcheck_new_detection.so x86_64/libcheck_new_detection.so But the notes preserved here stop before the native analysis reaches a final key or flag.\nSo the honest state of this writeup is:\nI identified and bypassed the device-gating layer, I confirmed the native library was the next target, I do not have the rest of the solve path or final flag saved in this repo. ","date":"25 December 2025","externalUrl":null,"permalink":"/writeups/wannagame_championship_2025/writeups/","section":"Writeups","summary":"These are cleaned-up contest notes rather than polished full writeups. Buzzing has a complete solve path, but Checker and Dutchman_app are intentionally kept as partial notes because the missing final artifacts are not preserved in this repo. I would rather leave those gaps visible than pretend I remember more than I actually do.\n","title":"WannaGame Championship 2025 - Reversing Writeup","type":"writeups"},{"content":" Who I Am # I am k1nt4r0u - an information security student at Ho Chi Minh City University of Information Technology and spend most of my time around reverse engineering, binary exploitation, and CTFs.\nWhat I Do # Play CTFs for Sky1nNorth Reversing binaries to understand how they work Exploiting binary for vuln and bugs Arch Linux is my daly driver :\u0026gt; What This Blog Is For # This site is where I keep:\nCTF writeups and postmortems Reverse engineering notes Small research breadcrumbs worth keeping around References I expect to need again later Tools I Reach For # IDA Pro and Ghidra GDB and pwndbg pwntools Small scripts and command-line tooling on Linux Contact # GitHub: @k1nt4r0u CTFtime: k1n74r0u HackMD: kintarou ","externalUrl":null,"permalink":"/about/","section":"Home","summary":"Who I Am # I am k1nt4r0u - an information security student at Ho Chi Minh City University of Information Technology and spend most of my time around reverse engineering, binary exploitation, and CTFs.\n","title":"About","type":"page"},{"content":"Use these entry points to browse the archive:\nWriteups Tags Categories ","externalUrl":null,"permalink":"/archive/","section":"Home","summary":"Use these entry points to browse the archive:\nWriteups Tags Categories ","title":"Archive","type":"page"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]