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
Launch the challenge instance and SSH into the server.
Find the password checker binary on the remote host and run it under a debugger.
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Understand why the checker only works on the hostObservationI noticed the challenge description explicitly states the binary 'only functions properly on the host on which we found it,' and the challenge name includes 'JIT Function Pointers,' which suggested the password is not stored statically but is instead injected at runtime by a host-resident JIT service.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 2
Resolve each function pointer to its expected characterObservationI noticed that each comparator stub contains a hardcoded cmpb immediate (bytes 80 7d fc XX) and that the live JIT table links each input position to one comparator, which suggested that intercepting the indirect call at runtime and reading the target register would reveal the expected ASCII character for each position.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.
Because the JIT table slots are empty until the JIT service patches them at runtime, GDB cannot resolve symbolic names for those stubs before execution starts. Breakpoints set by name or by offset before starti often miss the first call or error out entirely. Breaking at the indirect call instruction itself is reliable because that address is fixed in the .text section regardless of PIE, and the target register holds the live JIT-patched pointer exactly when it is needed.
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 JIT service rotates the table entries roughly once per second in sync with the binary's sleep(1) loop, keeping only the currently active slot correct and randomizing the rest. A single early snapshot captures mostly stale or zeroed entries rather than the correct set of 33 pointers. The correct approach is to sample the table repeatedly and take the 'diagonal' - reading the pointer for position j from the snapshot taken during position j's active check window.
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 3
Submit the recovered passwordObservationI noticed that after resolving all 33 function pointers and inverting the permutation array we had every expected character in the correct input order, which meant assembling them into a single string and piping it to the binary would trigger the success path and print the flag.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.
Flag
Reveal flag
picoCTF{pr0cf5_d36ugg3r_...}
Single PIE binary with 65 comparator stubs (cmpb $0xXX,-0x4(%rbp), bytes 80 7d fc XX) and a 33-entry function pointer table patched at runtime by a host JIT service. Break at the indirect call (or read /proc/pid/mem), resolve each live function pointer to its comparator's immediate byte, invert the permutation array, and assemble the 33-character password. The suffix after pr0cf5_d36ugg3r_ is generated per instance.