JITFP picoCTF 2026 Solution

Published: March 20, 2026

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.

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.

Walk me through it
  1. Step 1
    Understand why the checker only works on the host
    Observation
    I 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) (bytes 80 7d fc XX), where 0xXX is 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.

  2. Step 2
    Resolve each function pointer to its expected character
    Observation
    I 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 (the call rdx / call *reg that 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 the cmpb immediate at offset +12 inside that comparator (the byte after the 80 7d fc pattern). 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 char
    python
    # 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'.
    EOF
    What 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 $rdx at each call, mapping it to the matching comparator, and reading that comparator's cmpb immediate 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>/mem while 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's sleep(1) loop. So you sample the table repeatedly and take the "diagonal": the character for position j is the one read from the snapshot taken while position j was the active check (around t = 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 k validates some input index perm[k], not index k. Place each recovered character at its permuted index to reconstruct the real password order.

  3. Step 3
    Submit the recovered password
    Observation
    I 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.
    bash
    echo '<RECOVERED_33_CHAR_PASSWORD>' | ./jitfp

    Expected 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>/mem and /proc/<pid>/maps are the same interfaces a debugger uses to read another process's memory (subject to permission and the prctl(PR_SET_PTRACER) grant here). Parsing maps for the executable r-xp region 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.

Key takeaway

Secrets distributed across runtime state rather than static file data defeat purely static analysis, because the interesting information only exists in the live process after an external service patches it in. Dynamic analysis with a debugger captures the secret exactly when the program uses it, bypassing any anti-static-analysis obfuscation. This same principle appears in real-world anti-analysis techniques like runtime key generation, reflective DLL injection, and JIT-compiled malware payloads that decrypt themselves in memory.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Reverse Engineering

What to try next