JITFP
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
Once on the remote host:
ls -la /home/ctf-player/-rwxr-xr-x 1 root root 15232 ad7e550b
file /home/ctf-player/ad7e550bELF 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
ad7e550bis a 64-bit PIE (Position-Independent Executable), stripped - Python 3.12 is available
/procfilesystem 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
./ad7e550b test================================v*********************************IncorrectThe binary:
- Prints 32
=characters followed byv\n(a loading bar animation) - Then prints
*characters one per second (33 total on failure) - 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; }}-
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. -
Check loop logic:
- For each position
j(0 through 32):sleep(1)Wait 1 secondqword_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
- For each position
-
Double indirection:
dword_4020is a permutation table that reorders which function pointer fromqword_4120is used for each character position. -
Anti-side-channel:
sub_1932(33 - j)always prints exactly33 - jremaining 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. -
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):
| 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 ?:
ps aux# PID 1: /sbin/docker-init -- /opt/start.sh# PID 8: python3 /root/jitfp-service.py <--- THE DAMN JIT SERVICEPID 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
python3 -c "import subprocess, timeproc = 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 ad7e550b55d3e07a5000-55d3e07a7000 r-xp 00001000 ad7e550b55d3e07a7000-55d3e07a8000 r--p 00003000 ad7e550b55d3e07a8000-55d3e07a9000 rw-p 00003000 ad7e550b- First
r--psegment = PIE load base (offset 0x0000) r-xpsegment = code (text segment, offset 0x1000)rw-psegment = 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:
- When the binary starts, it calls
prctl(PR_SET_PTRACER, -1)making itself debuggable by any process - The JIT service (
/root/jitfp-service.py) detects the new process - It writes function pointers into
qword_4120[0..32]via/proc/pid/mem - It reshuffles the entire table every ~1 second, synchronized with the binary’s
sleep(1)loop - 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 positionj
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 :
#!/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.pidlog(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 codelog("\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 tabletry: 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 host2. Run binary with dummy flag → starts sleep(1) loop3. Read /proc/pid/maps → find PIE base address4. Every ~1 second, read qword_4120 via /proc/pid/mem5. 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 j8. Result: flagTools & Techniques Used
| Technique | Purpose |
|---|---|
| IDA Pro / Hex-Rays | Static binary reverse engineering |
| SCP | Exfiltrate binary from remote |
/proc/pid/maps | Find PIE base address at runtime |
/proc/pid/mem | Read function pointer table from process memory |
Python subprocess + struct | Automate process spawning and memory parsing |
| Time-series analysis | Discover the table reshuffling pattern |
| Diagonal extraction | Combine time-indexed snapshots into final flag |