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

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

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.
_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;
}
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.
_BOOL8 sub_15FC()
{
return sub_144E() || (unsigned int)sub_159F(&loc_1608, &loc_1618 - &loc_1608) != 199568121;
}
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.
__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;
}
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#
__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;
}
}
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

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

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

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:
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#
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#
- ptrace(2) man page, the
PTRACE_SEIZE/PTRACE_O_EXITKILLsemantics: man7.org/linux/man-pages/man2/ptrace.2.html - Yama LSM and
PR_SET_PTRACER: kernel.org/doc/html/latest/admin-guide/LSM/Yama.html memfd_create(2), fileless execution primitives: man7.org/linux/man-pages/man2/memfd_create.2.htmlprocess_vm_readv(2), cross-process memory reads and their permission model: man7.org/linux/man-pages/man2/process_vm_readv.2.html