fddR1p Reverse Engineering itc{flags} Official Writeup

the player gets two files. vault, and flag.enc sitting next to it. no source, no hints, nothing runs remotely. you run this on your own box.

$ file ./vault
./vault: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=5096f9ab0498f1820b69be07edd118e551594f09,
for GNU/Linux 3.2.0, stripped

$ checksec ./vault
vault checksec
file + checksec: stripped PIE, full RELRO, canary, NX, PIE, SHSTK, IBT — nothing loose

stripped, PIE, every mitigation on. strings ./vault gives you nothing. no /proc, no TracerPid, no flag format, no hint text anywhere. whatever this thing checks, it isn’t leaving the check lying around in plaintext for you to grep.

so run it. no debugger. see what happens.

$ ./vault &
[1] 1161

$ cat /proc/1161/status | grep -i TracerPid
TracerPid already points somewhere
vault (1161) already has a tracer — 1162 — and we never attached anything

vault has been alive for maybe half a second and something already owns its ptrace slot. not us. not a debugger we launched. TracerPid: 1162. before gdb ever enters the picture the binary has already told us the shape of the problem. the interesting state lives in a second process, and that process got there first.

that’s the whole challenge, honestly. the rest is just proving it.

the two gates in front of the door#

pulled vault into IDA to see what runs before that second process shows up. two functions gate everything, and neither behaves the way strings made you expect.

sub_144E — gate1
_BOOL8 sub_144E()
{
  int v1; // [rsp+Ch] [rbp-154h]
  FILE *stream; // [rsp+10h] [rbp-150h]
  size_t n; // [rsp+18h] [rbp-148h]
  char s[16]; // [rsp+20h] [rbp-140h] BYREF
  char filename[32]; // [rsp+30h] [rbp-130h] BYREF
  char s1[264]; // [rsp+50h] [rbp-110h] BYREF
  unsigned __int64 v7; // [rsp+158h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  sub_13A9("u*(59u)?6<u).;./)", 17, 90, filename);
  stream = fopen(filename, "r");
  if ( !stream )
    return 1;

  sub_13A9(&unk_2028, 10, 90, s);
  n = strlen(s);
  v1 = 0;
  while ( fgets(s1, 256, stream) )
  {
    if ( !strncmp(s1, s, n) )
    {
      v1 = atoi(&s1[n]);
      break;
    }
  }

  fclose(stream);
  return v1 != 0;
}
gate1 decompile
IDA decompile of sub_144E, matching address 0x144E exactly

that’s why strings came up empty. the path and the field name are both byte arrays run through sub_13A9, a one line xor loop, key 90 (0x5a). do it by hand and "u*(59u)?6<u).;./)" XOR 0x5a gives /proc/self/status. the second blob gives TracerPid:. gate1 is the classic tracer pid self check. open your own /proc/self/status, look for the TracerPid: line, fail if it’s nonzero. dressed up so a lazy static pass doesn’t hand you the trick for free.

on its own this is patchable in about ten seconds. break after the call, force the return register, done. we did exactly that earlier while building this thing. but gate1 doesn’t get called alone.

sub_15FC — gate2
_BOOL8 sub_15FC()
{
  return sub_144E() || (unsigned int)sub_159F(&loc_1608, &loc_1618 - &loc_1608) != 199568121;
}
gate2 decompile
gate2 compiles down to one line — call gate1, then hash the bytes of that call and compare

one line, and it’s meaner than it looks. gate2 calls gate1, then hashes the raw bytes of that call plus the branch that follows it, using the addresses of the call site itself, and checks the result against a fixed constant, 199568121. convert that to hex and it’s 0x0BE52AF9.

sub_159F — checksum_range
__int64 __fastcall sub_159F(__int64 a1, unsigned __int64 a2)
{
  unsigned int v3; // [rsp+1Ch] [rbp-14h]
  unsigned __int64 i; // [rsp+20h] [rbp-10h]

  v3 = -2128831035;
  for ( i = 0; i < a2; ++i )
    v3 = 16777619 * (*(unsigned __int8 *)(a1 + i) ^ v3);

  return v3;
}
checksum_range decompile
textbook FNV-1a: -2128831035 as unsigned is the standard offset basis, 16777619 is the FNV prime

plain FNV-1a. nothing hidden in the algorithm itself. the sixteen bytes it hashes are gate1’s call instruction and the test/je that reads its result. this is a self integrity check sitting directly on top of the anti debug check. plant a software breakpoint anywhere inside that sixteen byte window to patch gate1’s result, and gdb’s int3 (0xCC) is now physically sitting in the process’s memory. checksum_range reads it as data, not as a debugger convenience. the checksum comes out wrong, gate2 fails, and you get no message telling you why. same silent exit either gate trips.

clear both gates however you like, and vault moves on to the part that actually matters.

a process that fexecve’s from nowhere#

sub_1661 — spawn_guardian_fileless
__int64 __fastcall sub_1661(int a1, __int64 a2)
{
  unsigned int i; // [rsp+18h] [rbp-98h]
  int fd; // [rsp+1Ch] [rbp-94h]
  unsigned int v5; // [rsp+24h] [rbp-8Ch]
  _BYTE *ptr; // [rsp+28h] [rbp-88h]
  char *envp; // [rsp+30h] [rbp-80h] BYREF
  int pipedes[2]; // [rsp+38h] [rbp-78h] BYREF
  char *argv[5]; // [rsp+40h] [rbp-70h] BYREF
  char buf; // [rsp+6Bh] [rbp-45h]
  int v11; // [rsp+6Ch] [rbp-44h]
  char s[16]; // [rsp+70h] [rbp-40h] BYREF
  char v13[40]; // [rsp+80h] [rbp-30h] BYREF
  unsigned __int64 v14; // [rsp+A8h] [rbp-8h]

  v14 = __readfsqword(0x28u);
  v11 = 398731935;
  ptr = malloc((unsigned int)n);
  if ( !ptr )
    return 0xFFFFFFFFLL;

  for ( i = 0; i < (unsigned int)n; ++i )
    ptr[i] = s[(i & 3) - 4] ^ byte_4040[i];

  fd = memfd_create(&unk_2082, 1);
  if ( fd >= 0 )
  {
    if ( write(fd, ptr, (unsigned int)n) == (unsigned int)n )
    {
      free(ptr);
      v5 = fork();
      if ( !v5 )
      {
        snprintf(s, 0x10u, "%d", a1);
        snprintf(v13, 0x20u, "%lx", a2);
        argv[0] = (char *)&unk_2082;
        argv[1] = s;
        argv[2] = v13;
        argv[3] = nullptr;
        envp = nullptr;
        fexecve(fd, argv, &envp);
        _exit(1);
      }

      close(pipedes[0]);
      prctl(1499557217, v5, 0, 0, 0);
      write(pipedes[1], "x", 1);
      close(pipedes[1]);
      close(fd);
      return v5;
    }
    else
    {
      free(ptr);
      close(fd);
      return 0xFFFFFFFFLL;
    }
  }
  else
  {
    free(ptr);
    return 0xFFFFFFFFLL;
  }
}
spawn_guardian_fileless decompile
malloc, xor-decode, memfd_create, write, fork, fexecve — nothing here ever touches a filesystem path

read this one slowly. it explains why there’s no second binary sitting next to vault on disk, and why extracting one the way you’d unpack a normal dropper doesn’t work here.

ptr[i] = s[(i & 3) - 4] ^ byte_4040[i] looks broken on first read, indexing s with what looks like a negative offset before s is even filled. it isn’t a bug. it’s a decompiler artifact. the compiler reused one stack slot for two different lifetimes. early in the function those four bytes are a constant xor key. later, snprintf(s, 0x10u, "%d", a1) overwrites that same physical memory with the pid string handed to the child as argv[1]. IDA can’t separate “the constant” from “the four bytes s aliases into” because at the machine code level they are the same address. it isn’t lying to you. it’s showing both readings of one reused slot.

so the actual sequence: decode a payload with a real, not sequential index, xor key onto the heap. memfd_create an anonymous, nameless file descriptor. write the decoded bytes into it. fork, and have the child fexecve straight from that fd. the parent whitelists the child with prctl(PR_SET_PTRACER, ...). Yama’s restricted ptrace mode normally refuses anyone but a process’s actual parent from tracing it, so vault has to explicitly authorize this one child before its seize attempt is allowed to succeed.

nothing named guardian, or anything at all, ever touches a filesystem path. the payload exists as bytes inside vault’s own heap, then as an anonymous memfd, then as a running, unnamed process. that’s it.

trying the obvious move, and losing#

first instinct: attach gdb to vault directly, same as any other crackme.

$ gdb -q -p 1161
gdb attach to vault denied
already traced by process 1162 — Operation not permitted

warning: process 1161 is already traced by process 1162. ptrace: Operation not permitted. not a race condition, not bad luck. the kernel is telling you something structural. Linux allows exactly one tracer per process. no exceptions, not even for root, not without the explicit whitelist we just read inside spawn_guardian_fileless. the fileless child took the slot the moment vault cleared both gates, and once it holds that slot, nothing takes it back while that child is alive. not a fresh gdb, not a second PTRACE_SEIZE.

so vault itself is a dead end now, permanently, by design, for the rest of its life. stop looking at the process everyone assumes is the target and go after the one actually holding the leash.

$ gdb -q -p 1162

that one attaches clean. nobody’s contesting that slot.

the heap doesn’t lie, even when the .rodata does#

pwndbg> heap
guardian heap layout
two allocated chunks: 0x290 (glibc bookkeeping) and 0x30 (the interesting one)

two live chunks besides the top chunk. the 0x290 one is too big for anything this tiny process would explicitly ask for. arena bookkeeping, ignore it. the 0x30 chunk is the one that matters. a 64 byte malloc request becomes exactly a 0x30 flagged chunk once glibc adds its header and rounds to its alignment granularity, and sixty four bytes is a suspiciously specific size for a process whose entire job is holding key material.

pwndbg> x/64xb 0x641ff3bfe290
key chunk raw bytes
0x30-size chunk header, then 32 bytes of dense, unstructured data — no ASCII, no null terminator
00000000c1 6a 5f a0 dc 9a fa 63 7d 6f 53 51 1f 46 fa 85.j_....c}oSQ.F..0000001093 47 ae 8d 6c 7a 54 56 8b 61 4d 98 27 65 6f 26.G..lzTV.aM.'eo&

thirty two raw bytes, high entropy, nothing printable in the whole run. this was never meant to be read with x/s. that’s vault’s real AES-256 key, sitting in the clear on a process no debugger except the one you just attached is ever getting near.

and here is the part I have to be straight about, because an earlier build of this challenge had a real hole right here. cp /proc/<guardian_pid>/exe pulls a working copy of the guardian binary straight out of memory. no ptrace involved, /proc/<pid>/exe is a readable symlink for anyone with same-uid permission. run objdump -s -j .rodata on it:

0000000001 00 02 00 00 00 00 00 00 00 00 00 00 00 00 00................00000010e4 03 a5 bf 36 93 6f 3a 94 8c 29 51 f5 ab 1b 40....6.o:..)Q...@000000207a 69 76 00 57 b3 8d ac db 5e e3 35 f3 a8 d8 e3ziv.W....^.5....

thirty two more high entropy bytes, sitting flat in .rodata, fully recoverable without ever attaching a debugger. just cp and objdump against a frozen copy of the file. compare it against the real key above. completely different values, nothing shared. that’s on purpose. it’s half the key. vault holds the other half at runtime, and the guardian only completes the full key after it successfully seizes the real, live vault process and reads its half straight out of vault’s own memory with process_vm_readv, gated by the exact same Yama policy that blocked our first gdb -p attempt. no live authorized seize, no second half, no working key.

if reading .rodata feels like a win, check whether what you’re holding actually decrypts anything before you call it done.

turning bytes into a flag#

solve.py
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = bytes.fromhex("c16a5fa0dc9afa637d6f53511f46fa859347ae8d6c7a54568b614d9827656f26")
data = open("flag.enc", "rb").read()
print(AESGCM(key).decrypt(data[:12], data[12:], None))

flag.enc is a 12 byte nonce followed by AES-256-GCM ciphertext with the tag appended. nothing clever about the file format, all the real work already happened by the time you get here. run it and it either decrypts clean or throws InvalidTag if the key is wrong. GCM authenticates, so there’s no partial credit and no garbage output that almost looks right. that’s deliberate. the crypto here isn’t gating a comparison the way most “hardened” crackmes fake it, there’s no if (key_correct) print(flag) branch to patch around anywhere. the only way to the plaintext is actually recovering the real key from the one process allowed to hold it.

flag: itc{fddr1p_s31z3_th3_s1bl1ng_dr41n_1ts_h34p}

further reading#