0xmrerror@terminal:~/writeups

JITFP — Reading a Flag That Only Exists in Live Memory

The function-pointer table is empty on disk and filled at runtime by a ptrace service, so read RDX at the call instead of the table.

HardpicoCTFReverse engineering · 25 min readptraceprocfsgdbreverse-engineeringindirect-callfunction-pointerspicoctf

The name is the hint: Just In Time Function Pointers. The description says the binary "only functions properly on the host on which we found it." A stripped, statically linked musl binary called ad7e550b takes a 33-character candidate as argv[1] and validates it one character at a time. The table of function pointers that decides which character each position expects is empty in the file — not obfuscated, zero bytes, filled in at runtime by a service on the remote host. There is no static answer, so the solve has to happen inside a live process.

1 · Recon

$ file ad7e550b
ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), statically linked,
stripped, for GNU/Linux 3.2.0
$ strings ad7e550b | grep -E 'Usage|Incorrect|Correct|sleep'
Usage: %s <flag>
Incorrect
Correct
sleep

Two things to note. Incorrect appears twice — a success and a failure path, each with its own message. And sleep is imported, so the loop is paced rather than free-running. Both matter later.

The obvious first move is a timing attack: if the checker bails out at the first wrong character, correct guesses should take marginally longer. They don't. A run takes exactly 34 seconds whatever you feed it, because sleep dominates and the compare is a couple of nanoseconds either way. Shorter inputs segfault — the length gate wants all 33.

2 · The Decompiled main

Ghidra recovers the checker loop cleanly. The call into rdx is worth a second look:

// FUN_00101982 — the checker
i = 0;
while (i <= 32) {                 // 33 positions
  if (!fn_table[idx_table[i]](argv[1][i])) {  // indirect call through .bss
    return puts("Incorrect");
  }
  i++;
}

So argv[1][i] is validated by whichever function fn_table happens to point at. Two tables drive this: a static permutation in .data at vaddr 0x4020 mapping position → slot, and the pointer table itself at vaddr 0x4120.

Ghidra decompilation of the checker loop, showing the indirect call through the .bss function-pointer table

3 · 65 Comparators, and the Table That Holds Nothing

Walking .text reveals 65 stub functions, each 29 bytes, laid out end to end and all the same shape:

001011d5  bool FUN_001011d5(char p) { return p == 'a'; }
001011f2  bool FUN_001011f2(char p) { return p == 'b'; }
0010120f  bool FUN_0010120f(char p) { return p == 'c'; }

In assembly each is cmpb $imm8, -0x4(%rbp) — encoded 80 7d fc plus the character — followed by ret. All 65 are distinct and cover exactly the printable set:

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}

So nothing is encrypted or hashed. The flag is 33 choices among 65 stubs, and the only thing missing is the wiring. That's what the table at 0x4120 is for, and it is zero on disk.

$ readelf -SW ad7e550b | grep -A1 '.bss'
.bss  NOBITS  000004120  ...

NOBITS means those bytes aren't in the file at all. None of the nine .rela.dyn relocations or seven .rela.plt JUMP_SLOTs touch that range, .init_array holds a single entry that tail-jumps to empty destructor boilerplate, and no constructor fills the table. Nothing in the binary can fill it. GDB agrees at runtime: the whole .bss range dumps as zeros, RDX is 0x0 at the call, and the dispatch jumps to address zero and kills the process on the first comparison.

Ghidra memory map showing the function-pointer table sitting in the NOBITS .bss segment at vaddr 0x4120

The shortcut here is to assume slot k points at the k-th comparator in address order, apply the permutation at 0x4020, and read off a static flag in about two minutes. It doesn't work. The mapping is not the identity and nothing in the binary says what it is.

4 · The Hint in prctl

The one instruction in the binary that isn't part of the check:

prctl(0x59616d61, -1, 0, 0, 0);

0x59616d61 is PR_SET_PTRACER and the second argument is PR_SET_PTRACER_ANY — the binary is explicitly asking to be ptraced by anybody. A flag checker has no reason to do that. It's there so something else on the host can attach and write the table into its address space, and ptrace is also how you read a process's memory through /proc/<pid>/mem, which is what the challenge name is pointing at.

So the file isn't broken. Zero in .bss is the mechanism: static analysis of this binary gives you the comparators and no wiring, because the wiring only exists in a live process.

5 · Reading RDX at the Call

Skip the table. The permutation and the rotation policy can both be handed to you and you can still be stuck. The checker dispatches through call rdx at base+0x1a7d:

00101a78  movsx eax, byte ptr [rax]      // argv[1][i]
00101a7b  mov   edi, eax
00101a7d  call  rdx                 // ← the comparator for position i
00101a7f  test  eax, eax
00101a81  jnz   +0x25              // success

Whatever pointer is in RDX at the instant of that call is the answer for that position. Break there, read RDX, map the address to its stub, take the immediate. That holds whether the table is written once, rewritten every second, or holding noise in thirty-two of its thirty-three slots, which makes how often the service rewrites it irrelevant.

Assembly of the loop body: the permutation table read at 0x4020 indexing into the function-pointer table at 0x4120 to produce the pointer loaded into RDX

The load that matters, isolated:

The three instructions at the dispatch point: load argv[1][i], move it into EDI, then call rdx

Mapping address → character is a byte scan, no offsets hardcoded:

code = read_mem(pid, text_start, text_len)
for i in range(len(code) - 3):
  if code[i:i+3] == b"\x80\x7d\xfc":
      imm = code[i + 3]
      j = i - 1                                   # walk back to push rbp
      while j >= 0 and (i - j) <= 32 and code[j] != 0x55:
          j -= 1
      if j >= 0 and code[j] == 0x55:
          table[j] = chr(imm)                  # stub start → expected char

That finds all 65 stubs in the live process.

6 · Neutralising the Failure Branch

One byte makes the difference between collecting 33 characters and collecting one. The byte at base+0x1a81 is 0x75 — a short jnz with displacement +0x25. Overwriting it with 0xEB turns 75 25 into EB 25: an unconditional jmp to the identical target, which is the success path.

write_mem(pid, base + 0x1a81, b"\xEB")     # jnz short +0x25  →  jmp short +0x25

Now every comparison "passes" and the loop always reaches all 33 positions, even if a slot holds noise at the moment it's read. The check isn't weakened, it just can't abort early — which is what lets you collect 33 answers instead of the first one.

7 · The Loop

Attach, plant 0xCC on the call rdx, then per position: wait for the trap, PTRACE_GETREGS, read RDX, restore the original byte, rewind RIP to the call, single-step so the call actually executes, re-plant 0xCC, continue.

ptrace(PTRACE_ATTACH, pid, 0, 0); waitpid(pid)
write_mem(pid, bp, b"\xCC"); ptrace(PTRACE_CONT, pid, 0, 0)

for position in range(33):
    waitpid(pid)
    regs = UserRegs(); ptrace(PTRACE_GETREGS, pid, 0, ctypes.addressof(regs))
    flag.append(char_map.get(regs.rdx - text_start, "?"))

    write_mem(pid, bp, original)          # undo the trap
    regs.rip = bp
    ptrace(PTRACE_SETREGS, pid, 0, ctypes.addressof(regs))
    ptrace(PTRACE_SINGLESTEP, pid, 0, 0); waitpid(pid)
    write_mem(pid, bp, b"\xCC")                # re-arm
    ptrace(PTRACE_CONT, pid, 0, 0)

Two portability notes. Alpine has no libc.so.6, so bind to the interpreter's own libc with ctypes.CDLL(None) instead of naming the file. And only one process may ptrace a target at a time — the JIT service is itself a ptrace client — so the attach retries on EBUSY.

8 · The Flag

[*] comparators    : 65 found
[*] patched 0x587847722a81: jnz -> jmp (always take success)
  pos  0  rdx=0x5878477221d5  ->  'a'   (table 33/33 non-zero)
  pos  1  rdx=0x58784772220f  ->  'c'   (table 33/33 non-zero)
  pos  2  rdx=0x5878477221d5  ->  'a'   (table 33/33 non-zero)
  ...
  pos 32  rdx=0x587847722915  ->  '}'   (table 33/33 non-zero)

flag (33 chars) : academy{pr0cf5_d36ugg3r_86cd7eae}
could not write flag.txt: [Errno 30] Read-only file system
[*] verifying: /home/ctf-player/ad7e550b academy{pr0cf5_d36ugg3r_86cd7eae}
[*] checker output: 32 '=', 'v', 33 '*'
[*] verdict       : Correct

The verdict is the one piece of evidence here I didn't produce. The recovered flag goes back into a second, unpatched run of the binary and the binary itself says Correct — no assumed table, no supplied wiring.

The flag prefix is academy{ rather than picoCTF{: this instance is served by CyLab Academy, which mints its own flags around the same challenge binary. pr0cf5_d36ugg3r — procfs debugger — points at the /proc/<pid>/mem side of the same primitive. The trailing hex is per-deployment, so it differs between instances.

One thing that looks like a bug and isn't: the checker prints exactly 33 asterisks whether the run succeeds or fails. It's the patched branch from step 6 — successes print one, and the failure path's compensating loop never runs because every comparison takes the success path. Don't build a progress oracle on the asterisk count.

9 · Takeaway

When a checker dispatches through a function pointer, the answer is in the register at the call, not in the table. Read at the moment of use, the state you need is correct by definition — so how the table gets populated stops being the question.