Description
If we can crack the password checker on this remote host, we will be able to infiltrate deeper into this criminal organization. The catch is it only functions properly on the host on which we found it.
Setup
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Understand why the checker only works on the host
ObservationThe description says the binary only works properly on the host it was found on, and the name points at JIT function pointers. The password is not stored statically; a service on that host patches it in at runtime.The 64-bit PIE binary validates a 33-character password. It does NOT spawn many processes - it is a single process. It contains 65 tiny comparator functions in its .text section (one for each possible character: a-z, A-Z, 0-9, _, {, }), each of which compares the input byte against one hardcoded character. A 33-entry function pointer table (the JIT table) decides which comparator checks each position. That table is empty in the file; a companion JIT service (allowed in by prctl(PR_SET_PTRACER, -1)) patches the correct pointer into it at runtime. That is why the binary only behaves correctly on the host where the JIT service lives.Learn more
The checker uses a layer of indirection. A permutation array (a static table in the data section) maps each input position to a slot in the function pointer table; the binary then does an indirect call through that slot to validate the character. The pointer in each slot determines which of the 65 comparators runs, and therefore which character is accepted at that position.
Each comparator is essentially a single instruction that matters:
cmpb $0xXX, -0x4(%rbp)(bytes80 7d fc XX), where0xXXis the ASCII code of the character that position must contain. The function pointer points at this comparator, so resolving the pointer and reading its immediate byte tells you the expected character.The function pointer table is filled by the external JIT service via ptrace - the binary opts into being traced with
prctl(PR_SET_PTRACER, -1). On any other host (or under naive static analysis) the table is empty or wrong, which is exactly why the binary "only works where it was found." The password lives in the live process state, not in the file.Step 2Resolve each function pointer to its expected character
ObservationEach comparator stub carries a hardcoded compare immediate, and the live JIT table links every input position to one of them. Intercept the indirect call and read the target register, and each position gives up its expected character.Break at the indirect call instruction (thecall rdx/call *regthat dispatches the comparator). Each time it hits, read the target function pointer out of the register, subtract the PIE base to get the function's offset, and read thecmpbimmediate at offset +12 inside that comparator (the byte after the80 7d fcpattern). That byte is the expected character for the position being checked. Account for the permutation array: the order the comparators fire is scrambled, so map each hit back to the input index it validates before assembling the password.bash# GDB approach: break at the indirect call, read the target each hit. gdb -q ./jitfp (gdb) starti # locate the 'call rdx' (or 'call *reg') in the validation loop, then: (gdb) break *<addr_of_indirect_call> (gdb) run # at each stop, the target comparator address is in the call register: (gdb) x/4i $rdx # confirm it's a 'cmpb $0xXX,-0x4(%rbp)' stub (gdb) x/1bx $rdx+12 # the immediate 0xXX = expected ASCII charpython# Alternative: scan .text for every comparator and build a map, # then read the live function pointer table from /proc/<pid>/mem. python3 << 'EOF' import re data = open("jitfp", "rb").read() # 80 7d fc XX == cmpb $0xXX, -0x4(%rbp) mapping = {} for m in re.finditer(rb"\x80\x7d\xfc(.)", data): off = m.start() # file offset of this comparator's compare ch = m.group(1)[0] # expected ASCII byte mapping[off] = chr(ch) print(f"found {len(mapping)} comparators") # At runtime: read the 33 pointers from the JIT table in /proc/<pid>/mem, # convert each pointer to a .text offset, and look it up in 'mapping'. EOFWhat didn't work first
Tried: Set a breakpoint on the comparator functions directly (e.g. 'break comparator_0') instead of on the indirect call instruction, then read the cmpb immediate from disassembly.
The table slots are empty until the service patches them, so GDB cannot resolve symbolic names for those stubs before execution begins, and breakpoints set by name or offset miss the first call or error out. Break on the indirect call instead: its address is fixed in .text regardless of PIE, and the target register holds the patched pointer right when you need it.
Tried: Read the entire JIT function pointer table from /proc/pid/mem in a single snapshot at program start, then map all 33 pointers to their comparators at once.
The service rotates the table about once a second, in step with the binary's sleep loop, keeping only the currently active slot correct and randomizing the rest. One early snapshot gives you mostly stale or zeroed entries. Sample repeatedly and take the diagonal: read each position's pointer from the snapshot taken while that position was being checked.
Learn more
The clean, deterministic route is the debugger: an indirect call (
call rdx) hands you the live, JIT-patched target address in a register. Reading$rdxat each call, mapping it to the matching comparator, and reading that comparator'scmpbimmediate gives the correct character for that step. Because the debugger observes the table exactly when the binary uses it, you never fight the timing.The memory-reading route reads the 33 function pointers from the JIT table in
/proc/<pid>/memwhile the binary runs. The wrinkle is that the JIT service keeps only the slot needed for the current check correct and randomizes the rest, rotating roughly once per second alongside the binary'ssleep(1)loop. So you sample the table repeatedly and take the "diagonal": the character for positionjis the one read from the snapshot taken while positionjwas the active check (aroundt = j + 0.5s). The debugger approach avoids this entirely, which is why it is the more reliable solve.Either way, you must invert the permutation array: the comparator that fires at step
kvalidates some input indexperm[k], not indexk. Place each recovered character at its permuted index to reconstruct the real password order.Step 3Submit the recovered password
ObservationWith all 33 pointers resolved and the permutation inverted, every character sits in the right input order. Assemble them and pipe the string to the binary.Assemble the 33 recovered characters in the correct (de-permuted) order and feed the password to the binary. It validates and prints the flag.bashecho '<RECOVERED_33_CHAR_PASSWORD>' | ./jitfpExpected output
picoCTF{...}Learn more
This challenge combines JIT code patching, function-pointer indirection, and runtime memory inspection. The secret is never stored as a static string: it is distributed across 65 comparator stubs plus a 33-entry pointer table that only the host's JIT service fills in correctly. Static analysis sees comparators but no wiring; only the live, JIT-patched process knows which comparator guards which position.
/proc/<pid>/memand/proc/<pid>/mapsare the same interfaces a debugger uses to read another process's memory (subject to permission and theprctl(PR_SET_PTRACER)grant here). Parsingmapsfor the executabler-xpregion gives the PIE base you need to convert live pointers back into file offsets. See the Python for CTF guide for process-scripting and memory-reading patterns.
Interactive tools
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
- 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.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{...}
This flag could not be verified. The site's earlier value was not derived from the binary and no independent solve publishes it, so it has been withdrawn rather than left as a guess. What is known from the challenge itself: the input is exactly 33 bytes, so the body between the braces is 24 characters. Recover it by breaking on the indirect call and mapping each function pointer to its comparator byte.