Description
If you've solved WinAntiDbg0x100 you'll discover something new in this one. Debug the executable and find the flag.
Setup
Download WinAntiDbg0x200.zip (password: picoctf) and extract the executable.
Load WinAntiDbg0x200.exe in Ghidra first to map the anti-debug checks, then open it in x32dbg (32-bit).
Note: the challenge says it requires admin privileges but you can work around this in the debugger.
wget https://artifacts.picoctf.net/c_titan/88/WinAntiDbg0x200.zip && \
unzip WinAntiDbg0x200.zipSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Map all three checks in GhidraObservationI noticed the challenge description says 'you'll discover something new in this one' compared to WinAntiDbg0x100, which suggested multiple anti-debug checks are present and that a static analysis pass in Ghidra was necessary before debugging to locate all conditional branches guarding the flag path.In Ghidra, open the binary and locate the interesting function (the one containing the flag path). You will see: one check near address ending in 16ED that requires the program to be running with debug privileges (admin), one check near 1826, and one check near 1830. Each tests a different condition; if any of them branch to the failure path, you get the oops message instead of the flag.What didn't work first
Tried: Skipping Ghidra entirely and jumping straight into x32dbg to step through the binary.
Without a static map of the three check locations, you will hit the first anti-debug branch and either fail it or patch it, not realizing two more checks follow. You end up stepping blindly and often handle the second or third check incorrectly because you did not know what register to watch or which jump direction to take.
Tried: Searching Ghidra for 'IsDebuggerPresent' and assuming that single import is the only check.
This binary has three separate checks and only one of them is the standard IsDebuggerPresent call. The other two use a custom detection function and a privilege check. If you only find the API call you miss the other two branches entirely and will still get the failure message even after patching that one.
Learn more
Multiple anti-debug checks are common in real malware: if the first check is bypassed, the second or third may still detect the debugger. Mapping all checks in Ghidra before setting breakpoints prevents being surprised mid-run when a second check fires unexpectedly.
The challenge says it requires admin privileges. That requirement is one of the anti-debug conditions (near 16ED): it checks whether the process has debug privileges and if not, prints the admin error. In the debugger you can skip this check by manipulating registers, avoiding the need to actually run as admin.
As with 0x100, ASLR means absolute addresses differ between runs, but the last four hex digits match between Ghidra and x32dbg. Use those last four digits to find each instruction in the debugger.
Step 2
Set breakpoints at each check and manipulate registersObservationI noticed the Ghidra decompiler revealed three separate TEST/conditional-jump pairs at addresses ending in 16EB, 1824, and 182E, each checking a different register (EAX or EDX), which suggested setting breakpoints at those TEST instructions and modifying the register values before each conditional jump fired.Run the binary in x32dbg until user code. Set breakpoints at the last-four-digit addresses matching the three Ghidra locations (16EB, 1824, and 182E region). Then run and handle each in turn:- At the first breakpoint (16EB region, admin privileges check): a TEST EAX,EAX followed by JNZ is used. Set EAX to 1 so the conditional jump is taken and the error path is cleared.
- At the second breakpoint (1824 region, custom detection function): a TEST EDX,EDX followed by JNZ is used. Set EDX to 0 so the jump is NOT taken - taking this jump skips the third check entirely and leads to the failure message.
- At the third breakpoint (182E region, IsDebuggerPresent): a TEST EAX,EAX followed by JZ is used. Set EAX to 0 so the jump IS taken and execution continues to the flag decryption path.
What didn't work first
Tried: Setting EAX or EDX to 1 for every check without reading the specific jump mnemonic at each one.
The three checks use different jump directions - JNZ on the first two means you need EAX=1 for check one but EDX=0 for check two. Blindly setting every register to 1 causes check two to take the wrong branch and jump directly to the failure path, skipping check three entirely and never reaching the flag decryption code.
Tried: Setting a breakpoint on the CALL instruction for the detection function rather than on the TEST instruction after the return.
The register value that matters is set by the return value of the called function, which is stored in EAX or EDX after the CALL returns. Breaking on the CALL itself means you must step into and out of the function before the result is in the register. Breaking on the TEST instruction after the CALL is the correct point to read and modify the value before the conditional jump fires.
Learn more
Each check uses a different register (EAX, EDX) and a different conditional jump direction. Reading the jump mnemonic is critical:
JZjumps when the zero flag is set (result was zero);JNZjumps when it is not zero. Getting the direction backwards causes the wrong branch to be taken even after you change the register.The method is the same as 0x100: pause at the key instruction and double-click the register in the Registers pane to change its value. The difference here is that you must track three separate conditions and handle them correctly in sequence during a single run.
Step 3
Read the flag from the logObservationI noticed that after all three register patches were applied and no conditional jump routed to the failure branch, execution reached the flag-decryption path, which uses OutputDebugString to emit the flag, and that output routes to the x32dbg Log tab rather than the console.After all three checks are handled correctly, the program follows the success path and prints the flag to the x32dbg log window. Check the log tab for the line starting with picoCTF.What didn't work first
Tried: Looking for the flag in the x32dbg Console tab or in a popup window instead of the Log tab.
The binary uses OutputDebugString to print the flag, which routes to the debugger log pane, not the console. The Console tab in x32dbg is for entering debugger commands. If you do not see the flag there, switch to the Log tab where OutputDebugString output appears.
Tried: Assuming the flag did not print because nothing appeared and restarting the whole run from scratch.
If one of the three register patches was wrong, the binary takes the failure branch and prints the oops message to the log instead of the flag. Before restarting, scroll through the Log tab to see which message appeared. That tells you which check fired incorrectly, so you can fix only that one breakpoint rather than redoing the entire session.
Learn more
Once all anti-debug checks are bypassed, the binary executes the code that displays the flag. The log pane in x32dbg captures OutputDebugString calls, which is how the flag is printed in this binary. If the flag does not appear, check whether the third breakpoint caused the wrong jump direction.
The progression from 0x100 (one check, simple EAX patch) to 0x200 (three checks, multiple registers) mirrors how real malware layering works: each layer adds cost to the analyst's time. Level 0x300 goes further by using an infinite loop and packed code that requires binary patching with Ghidra.
Interactive tools
- Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
- Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
Flag
Reveal flag
picoCTF{0x200_debug_f0r_Win_...}
Passing all three anti-debug checks in sequence prints the flag to the debugger log.