M
Overview

PicoCTF2026 - JITFP

March 19, 2026
13 min read
index

JITFP

Solver
m mohamedg
Author
syreal
Category
Reverse Engineering
Points
400
Remote
$ nc dolphin-cove.picoctf.net [PORT]
Flag
picoCTF{pr0cf5_d36ugg3r_[REDACTED]}

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.

Hints:

  • You can exfiltrate files using scp.
  • If your timing isn’t optimal, you can get a partially correct flag sometimes.
  • Your extracted flag will be in standard picoCTF flag format.

The hints are critical. “Timing isn’t optimal you can get a partially correct flag” immediately tells us there’s a time-dependent component. I wasted soooooooo much time thinking this was related to a sort of Race Condition, as we dive in, we gonna see that i was so damn wrong.

The description says ”… only functions properly on the host…” this tells us something on the remote server is a key dependancy for the binary, or something there is modifying the binary at runtime, or, both cases.


2. Initial Reconnaissance

Connecting and Exploring the Remote Host

Terminal window
sshpass -p '84b12bae' ssh -o StrictHostKeyChecking=no -p 63423 [email protected]

Once on the remote host:

Terminal window
ls -la /home/ctf-player/
-rwxr-xr-x 1 root root 15232 ad7e550b
file /home/ctf-player/ad7e550b
ELF 64-bit LSB pie executable, x86-64, dynamically linked,
interpreter /lib/ld-musl-x86_64.so.1, stripped
# (we go on some recon about the system and available binaries...)

Key findings about the remote environment:

  • Alpine Linux container with musl libc (not glibc)
  • The binary ad7e550b is a 64-bit PIE (Position-Independent Executable), stripped
  • Python 3.12 is available
  • /proc filesystem is accessible
  • No GDB, strace, or ltrace available
  • Read-only filesystem so no way to upload files except via inline Python

Exfiltrating and Inspecting the binary

Terminal window
scp -P 63423 [email protected]:/home/ctf-player/ad7e550b ./
./ad7e550b test
================================v
*********************************
Incorrect

The binary:

  1. Prints 32 = characters followed by v\n (a loading bar animation)
  2. Then prints * characters one per second (33 total on failure)
  3. Prints either “Correct” or “Incorrect”

Each * takes 1 second the binary has sleep(1) between each character check, making a full run take ~33 seconds.


3. Decompiling the Binary

Here i used IDA to decompile the file, and here is what i noticed

The main() Function

__int64 __fastcall main(int a1, char **a2, char **a3)
{
int i, j;
prctl(1499557217, -1, 0, 0, 0); // PR_SET_PTRACER = PR_SET_PTRACER_ANY
if (a1 == 2)
{
// Print loading bar: 32 '=' characters
for (i = 0; i <= 31; ++i) {
putchar(61); // '='
fflush(stdout);
}
puts("v");
// Main check loop: 33 characters
for (j = 0; j <= 32; ++j)
{
sleep(1);
if (!qword_4120[dword_4020[j]]((unsigned int)a2[1][j]))
{
sub_1932(33 - j); // Print remaining stars
puts("Incorrect");
return 1;
}
putchar(42); // '*'
fflush(stdout);
}
// Null terminator check at position 33
sleep(1);
if (a2[1][33]) {
puts("\nIncorrect");
return 1;
} else {
puts("\nCorrect");
return 0;
}
}
else {
printf("Usage: %s <flag>\n", *a2);
return 1;
}
}
  1. prctl(PR_SET_PTRACER, -1) Allows any process to ptrace/debug this binary. This is deliberately enabling another process (the JIT service) to modify it at runtime.

  2. Check loop logic:

    • For each position j (0 through 32):
      • sleep(1) Wait 1 second
      • qword_4120[dword_4020[j]](argv[1][j]) Call a function pointer from a lookup table
      • If it returns false -> print remaining stars and “Incorrect”
      • If it returns true -> print a * and continue
  3. Double indirection: dword_4020 is a permutation table that reorders which function pointer from qword_4120 is used for each character position.

  4. Anti-side-channel: sub_1932(33 - j) always prints exactly 33 - j remaining stars, so the output is always 33 stars regardless of which position fails. You can’t tell from star count alone where the check failed.

  5. Null terminator check at position 33 ensures the flag is exactly 33 characters.

The Permutation Table dword_4020

This table at offset 0x4020 maps each sequential check position to a slot in the function pointer table:

int dword_4020[33] = {
30, 22, 11, 32, 25, 4, 9, 7, 19, 23,
5, 26, 18, 27, 16, 1, 8, 15, 2, 14,
3, 13, 24, 21, 12, 17, 6, 10, 29, 28,
20, 31, 0
};

So when checking character at position j, the binary looks up qword_4120[dword_4020[j]]. For example:

  • Position 0 reads qword_4120[30]
  • Position 1 reads qword_4120[22]
  • Position 32 reads qword_4120[0]

The Function Pointer Table qword_4120

An array of 33 function pointers at offset 0x4120 in the BSS segment. This is uninitialized on disk the pointers are set at runtime by the JIT service.

The 65 Check Functions Character Validators

The binary contains 65 nearly identical functions at offsets 0x11D5 through 0x1915. Each compares its argument to a single hardcoded ASCII character:

_BOOL8 __fastcall sub_11D5(char a1) { return a1 == 97; } // 'a'
_BOOL8 __fastcall sub_11F2(char a1) { return a1 == 98; } // 'b'
_BOOL8 __fastcall sub_120F(char a1) { return a1 == 99; } // 'c'
...
_BOOL8 __fastcall sub_18DB(char a1) { return a1 == 95; } // '_'
_BOOL8 __fastcall sub_18F8(char a1) { return a1 == 123; } // '{'
_BOOL8 __fastcall sub_1915(char a1) { return a1 == 125; } // '}'

Complete mapping (function offset → character):

Terminal window
| Offset | Char | Offset | Char | Offset | Char | Offset | Char |
|--------|------|--------|------|--------|------|--------|------|
| 0x11D5 | a | 0x1283 | g | 0x1331 | m | 0x13DF | s |
| 0x11F2 | b | 0x12A0 | h | 0x134E | n | 0x13FC | t |
| 0x120F | c | 0x12BD | i | 0x136B | o | 0x1419 | u |
| 0x122C | d | 0x12DA | j | 0x1388 | p | 0x1436 | v |
| 0x1249 | e | 0x12F7 | k | 0x13A5 | q | 0x1453 | w |
| 0x1266 | f | 0x1314 | l | 0x13C2 | r | 0x1470 | x |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 0x14C7 | A | 0x1575 | G | 0x1623 | M | 0x16D1 | S |
| 0x17B9 | 0 | 0x1810 | 3 | 0x1867 | 6 | 0x18BE | 9 |
| 0x18DB | _ | 0x18F8 | { | 0x1915 | } | | |

Each function is at a stride of 0x1D (29 bytes), with the comparison byte embedded in a cmpb $XX, -0x4(%rbp) instruction at offset +9 from the function start.

The Star Printer sub_1932

int __fastcall sub_1932(int a1)
{
int i;
for (i = 0; i < a1; ++i) {
putchar(42); // '*'
fflush(stdout);
sleep(1);
}
return putchar(10); // '\n'
}

On failure at position j, it prints 33 - j remaining stars (one per second), making the total always 33 stars. This prevents timing-based side-channel attacks by observing star count.


4. After More Remote Host Exploration

Proceeded to a process inspection, and what we see ?:

/usr/sbin/sshd
ps aux
# PID 1: /sbin/docker-init -- /opt/start.sh
# PID 8: python3 /root/jitfp-service.py <--- THE DAMN JIT SERVICE

PID 8 runs /root/jitfp-service.py as root. This is the “Just-In-Time Function Patching” service that gives the challenge its name.

Note

Absolutely nothing/nobody told me that JITFP means Just-In-Time Function Patching this solely my own supposition LoL

We can’t read the service’s code, but we can observe its behavior.

Memory Layout

Terminal window
python3 -c "
import subprocess, time
proc = subprocess.Popen(['./ad7e550b', 'picoCTF{AAAAAAAAAAAAAAAAAAAAAAAA}'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
time.sleep(0.5)
with open(f'/proc/{proc.pid}/maps') as f:
for line in f:
if 'ad7e550b' in line:
print(line.strip())
proc.kill()
"

Output (these addresses change each run due to PIE):

55d3e07a4000-55d3e07a5000 r--p 00000000 ad7e550b
55d3e07a5000-55d3e07a7000 r-xp 00001000 ad7e550b
55d3e07a7000-55d3e07a8000 r--p 00003000 ad7e550b
55d3e07a8000-55d3e07a9000 rw-p 00003000 ad7e550b
  • First r--p segment = PIE load base (offset 0x0000)
  • r-xp segment = code (text segment, offset 0x1000)
  • rw-p segment = data+BSS (offset 0x3000 its virtual address is at 0x4000+)

The function pointer table qword_4120 lives in BSS at virtual offset 0x4120, so its runtime address is base + 0x4120.


5. How the JIT Service Works

We discovered so far:

  1. When the binary starts, it calls prctl(PR_SET_PTRACER, -1) making itself debuggable by any process
  2. The JIT service (/root/jitfp-service.py) detects the new process
  3. It writes function pointers into qword_4120[0..32] via /proc/pid/mem
  4. It reshuffles the entire table every ~1 second, synchronized with the binary’s sleep(1) loop
  5. The check functions’ code in the text segment is NOT modified what changes is which function pointer sits in which slot

8. Since The Table Changes Every Second…

The binary checks character j at time ~j (after j+1 calls to sleep(1)). The JIT service sets up the table so that at time j, only the entry needed for check j is correct. All other 32 entries are randomized decoys.

Therefore:

  • At t=0, flag[0] from that snapshot is the correct character for position 0
  • At t=1, flag[1] from that snapshot is the correct character for position 1
  • At t=j, flag[j] from that snapshot is the correct character for position j

The formula is: correct_flag[j] = snapshot_at_time_j[j]

THIS is why the hint says “if your timing isn’t optimal, you get a partially correct flag” if you sample at the wrong time for a given position, you get the wrong character for that position.

From that; we can extract all strings at each second, and take the diagonal :

solve.py
#!/usr/bin/env python3
"""
JITFP Solver - Time-synced sampling.
The JIT service patches check function comparison bytes just-in-time,
one per second, synced with the binary's check loop.
We sample at each second to capture the correct character for each position.
"""
import os, struct, time, subprocess, sys, select
dword_4020 = [
30, 22, 11, 32, 25, 4, 9, 7, 19, 23, 5, 26, 18, 27, 16, 1,
8, 15, 2, 14, 3, 13, 24, 21, 12, 17, 6, 10, 29, 28, 20, 31, 0
]
def log(msg):
sys.stderr.write(msg + "\n")
sys.stderr.flush()
def read_mem(pid, addr, size):
with open(f"/proc/{pid}/mem", "rb") as mem:
mem.seek(addr)
return mem.read(size)
def get_base(pid):
with open(f"/proc/{pid}/maps", "r") as f:
for line in f:
if "ad7e550b" in line:
return int(line.split("-")[0], 16)
return None
def read_cmp_byte(pid, func_addr):
"""Read the comparison byte from a check function."""
code = read_mem(pid, func_addr, 16)
for j in range(len(code) - 3):
if code[j] == 0x80 and code[j+1] == 0x7d and code[j+2] == 0xfc:
return code[j+3]
return None
dummy_flag = "picoCTF{MOHAMEDISTHEGOATHEHEHEHE}"
log(f"Starting binary with faake flag ({len(dummy_flag)} chars)")
proc = subprocess.Popen(
["./ad7e550b", dummy_flag],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
pid = proc.pid
log(f"PID: {pid}")
time.sleep(0.5)
base = get_base(pid)
if base is None:
log("No base addr!")
proc.kill()
sys.exit(1)
log(f"Base: {hex(base)}")
# First, we need to compoare on-disk vs runtime code
log("\n=== Comparing disk vs runtime ===")
with open("./ad7e550b", "rb") as f:
disk = f.read()
# Compare check functions region (0x11D5 - 0x1932)
try:
rt_checks = read_mem(pid, base + 0x11D5, 0x1932 - 0x11D5)
disk_checks = disk[0x11D5:0x1932]
check_diffs = [(0x11D5+i, disk_checks[i], rt_checks[i])
for i in range(len(disk_checks)) if disk_checks[i] != rt_checks[i]]
if check_diffs:
log(f"Check functions: {len(check_diffs)} bytes modified!")
for addr, d, r in check_diffs[:20]:
log(f" {hex(addr)}: disk=0x{d:02x} runtime=0x{r:02x}")
else:
log("Check functions: identical")
except Exception as e:
log(f"Error comparing checks: {e}")
# Compare main function (0x1982 - 0x1B1B)
try:
rt_main = read_mem(pid, base + 0x1982, 0x1B1B - 0x1982)
disk_main = disk[0x1982:0x1B1B]
main_diffs = [(0x1982+i, disk_main[i], rt_main[i])
for i in range(len(disk_main)) if disk_main[i] != rt_main[i]]
if main_diffs:
log(f"Main function: {len(main_diffs)} bytes modified!")
for addr, d, r in main_diffs[:20]:
log(f" {hex(addr)}: disk=0x{d:02x} runtime=0x{r:02x}")
else:
log("Main function: identical")
except Exception as e:
log(f"Error comparing main: {e}")
# Compare permutation table
try:
rt_perm = read_mem(pid, base + 0x4020, 33 * 4)
disk_perm = disk[0x3020:0x3020 + 33*4] # file offset = VA - 0x1000 for data segment
# Actually data is at file offset 0x3000 for VA 0x4000, so 0x4020 -> file 0x3020
if rt_perm != disk_perm:
log("Permutation table: MODIFIED!")
for i in range(33):
rv = struct.unpack("<I", rt_perm[i*4:(i+1)*4])[0]
dv = struct.unpack("<I", disk_perm[i*4:(i+1)*4])[0]
if rv != dv:
log(f" [{i}] disk={dv} runtime={rv}")
else:
log("Permutation table: identical")
except Exception as e:
log(f"Error comparing perm: {e}")
log("\n=== Initial function pointer table ===")
table_data = read_mem(pid, base + 0x4120, 33 * 8)
initial_ptrs = []
for i in range(33):
ptr = struct.unpack("<Q", table_data[i*8:(i+1)*8])[0]
initial_ptrs.append(ptr)
if ptr:
off = ptr - base
cb = read_cmp_byte(pid, ptr)
log(f" [{i:2d}] off={hex(off)} cmp={hex(cb) if cb else '?'} ('{chr(cb) if cb else '?'}')")
else:
log(f" [{i:2d}] NULL")
# Now do time-synced sampling over the execution
# The binary timeline:
# t~0: banner prints (32 '=' + 'v\n')
# t~1: sleep(1) ends, check j=0
# t~2: sleep(1) ends, check j=1
# ...
# t~33: sleep(1) ends, check j=32
# t~34: null check
log("\n=== Time-synced sampling (every 0.5s for 40s) ===")
start_time = time.time()
samples = []
for tick in range(80):
t = time.time() - start_time
try:
os.kill(pid, 0)
except ProcessLookupError:
log(f" Process exited at t={t:.1f}")
break
try:
# Read table
td = read_mem(pid, base + 0x4120, 33 * 8)
ptrs = []
for i in range(33):
ptrs.append(struct.unpack("<Q", td[i*8:(i+1)*8])[0])
# Read comparison bytes from each function
cmp_bytes = []
for i in range(33):
if ptrs[i]:
cb = read_cmp_byte(pid, ptrs[i])
cmp_bytes.append(cb)
else:
cmp_bytes.append(None)
samples.append((t, ptrs, cmp_bytes))
# Log changes from initial state
changes = []
for i in range(33):
if ptrs[i] != initial_ptrs[i]:
changes.append(f"tbl[{i}]")
if cmp_bytes[i] and initial_ptrs[i]:
init_cb = read_cmp_byte(pid, initial_ptrs[i]) if tick == 0 else None
# Just track the current comparison byte
# Build current flag from this snapshot
flag_now = []
for j in range(33):
idx = dword_4020[j]
if idx < len(ptrs) and ptrs[idx]:
cb = cmp_bytes[idx]
if cb:
flag_now.append(chr(cb))
else:
flag_now.append("?")
else:
flag_now.append("?")
flag_str = "".join(flag_now)
if tick % 2 == 0:
log(f" t={t:5.1f} flag={flag_str}")
except Exception as e:
log(f" t={t:.1f} error: {e}")
time.sleep(0.5)
log("\n=== Flag reconstruction ===")
final_flag = []
for j in range(33):
target_time = j + 1.5
best_sample = None
best_dt = float('inf')
for t, ptrs, cmp_bytes in samples:
dt = abs(t - target_time)
if dt < best_dt:
best_dt = dt
best_sample = (t, ptrs, cmp_bytes)
if best_sample:
t, ptrs, cmp_bytes = best_sample
idx = dword_4020[j]
cb = cmp_bytes[idx] if idx < len(cmp_bytes) else None
ch = chr(cb) if cb else '?'
final_flag.append(ch)
log(f" flag[{j:2d}]: '{ch}' (from sample at t={t:.1f}, target t={target_time:.1f})")
else:
final_flag.append("?")
flag_result = "".join(final_flag)
log(f"\nFinal flag: {flag_result}")
print(flag_result)
proc.kill()
proc.wait()

We just taje the diagonal then…

Reading the diagonal (flags[j][j] for each j):

j= 0: flags[0][0] = 'p' (from "phb4ky0fHt1AGfbj1AabY6I1V5MTa7fD}")
j= 1: flags[1][1] = 'i' (from "aiZQJ0wMMyVrt0kvAiYIcjepO{y5wkKC0")
j= 2: flags[2][2] = 'c' (from "7Dc4{v_wZ4T}OFCREwbTRcIa2LnCZCr72")
j= 3: flags[3][3] = 'o' (from "gDaouSiQDH2mQswb80zcfSaW6IdlhOgSB")
j= 4: flags[4][4] = 'C' (from "PtasCh}TT8NgOae4qyCiNnno6QkL_wXQR")
j= 5: flags[5][5] = 'T' (from "3ZACrT2jAgyZ{N4KjLl3LC2MdqLtWMQ1B")
j= 6: flags[6][6] = 'F' (from "j8e7ZfFTMtIB0AFvR270B2fJLy82YxqVX")
j= 7: flags[7][7] = '{' (from "9cQQhyf{gfj{ObNIc3s6RHRzCBQYJPp0{")
j= 8: flags[8][8] = 'p' (from "Mxev{5uzpx5urjrlqXLRA8P{4{54uXARV")
j= 9: flags[9][9] = 'r' (from "8MO5Ui0rxru2z1IelK6BixLoa7ht7vckd")
j=10: flags[10][10] = '0' (from "jVvJ4AR3x0073EdzRAMvpraoiT8TGcLVO")
j=11: flags[11][11] = 'c' (from "0ro_qWoaODjc_paHFMuCXvaIMAI68XBIp")
j=12: flags[12][12] = 'f' (from "fiocygF6XnG}fVYcH8L96v1KN2FIyMf{D")
j=13: flags[13][13] = '5' (from "JP5ltGesfHqlG5Bm_NEiO6xxBplxQMHnM")
j=14: flags[14][14] = '_' (from "VbEBIT7kZEfkpL_kBMmX3Rb}Ut7fsgatH")
j=15: flags[15][15] = 'd' (from "ihYQB_zUBRWVqICdOLw0hYPDWrWmTi27l")
j=16: flags[16][16] = '3' (from "7OEjZMuGanTUGWhS3fzHykwvo3XnXsfYN")
j=17: flags[17][17] = '6' (from "Kd6Vxv66u3xIwPryE6Z_978hayR4T0d7l")
j=18: flags[18][18] = 'u' (from "6dOyC8ounqpPGtN8mNuM5ADjXdmN46zlL")
j=19: flags[19][19] = 'g' (from "phzvfpSE0MY3_vhF{SlgE1HDK}BUJrzm1")
j=20: flags[20][20] = 'g' (from "e}9dl6bIuAkz7GGq7W0yg3RbS8dg}Defp")
j=21: flags[21][21] = '3' (from "iJFaMiZMP9gZ2RRcStP9J3{xTJ0kE154b")
j=22: flags[22][22] = 'r' (from "PaH9YlXq30NJ1G6mSN4_JZr9N{40BDt2p")
j=23: flags[23][23] = '_' (from "m2c9XtZNOiCemQQ}TK2QJy0__0dvOIE0i")
---
j=32: flags[32][32] = ' }' (from "k9cc4}hKi9vh{ckRYWWkpfZFSuYqsWVz}")

Concatenated: picoCTF{pr0cf5_d36ugg3r_[REDACTED]}

It matches picoCTF format perfectly.


Summary of the Attack Flow

1. SSH into remote host
2. Run binary with dummy flag → starts sleep(1) loop
3. Read /proc/pid/maps → find PIE base address
4. Every ~1 second, read qword_4120 via /proc/pid/mem
5. Decode: for each table snapshot, compute the "current flag"
6. Collect 33 snapshots (one per second, ~33 seconds total)
7. Take the DIAGONAL: character j from snapshot j
8. Result: flag

Tools & Techniques Used

TechniquePurpose
IDA Pro / Hex-RaysStatic binary reverse engineering
SCPExfiltrate binary from remote
/proc/pid/mapsFind PIE base address at runtime
/proc/pid/memRead function pointer table from process memory
Python subprocess + structAutomate process spawning and memory parsing
Time-series analysisDiscover the table reshuffling pattern
Diagonal extractionCombine time-indexed snapshots into final flag