Hey crackers, welcome to another write-up! Today I’m going to share how I solved the Pensive reverse engineering challenge from the Bugcrowd BlackHat USA CTF 2025.
The challenge came as a ZIP file containing a single binary. After doing some reversing, I realized that the program actually we need an encrypted flag — but it wasn’t provided anywhere in the files or challenge description. I reported this to the organizers, and shortly after, they updated the challenge description with the missing encrypted flag.
Initial Execution & First Clues
The first thing I did was run the binary to see what it does. Instead of any fancy output, it threw an error:
Error reading flag file: No such file or directory
It looked like the program was trying to read a file, but none was provided.
To test if it would work with a manually created file, I made a new file called flag.txt
and, for testing purposes, put my own name Noman
inside it. I picked the name flag.txt because most CTF challenges use it as the default file name for the flag.
When I ran the binary again, the output changed:
My secret: 09kz0
It was clearly reading the file content and then running it through some encryption/encoding routine to produce this “secret.”
Enough guessing — it’s Ghidra time to dissect what’s really happening inside.
Static Peek: main
and the magic
routine
undefined8 main(void)
{
byte bVar1;
FILE *__stream;
undefined8 uVar2;
char *pcVar3;
size_t sVar4;
long in_FS_OFFSET;
int local_158;
undefined4 local_148;
undefined4 local_144;
undefined4 local_140;
undefined4 local_13c;
undefined4 local_138;
undefined4 local_134;
undefined4 local_130;
undefined4 local_12c;
undefined4 local_128;
undefined4 local_124;
char local_118 [264];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
__stream = fopen("flag.txt","r");
if (__stream == (FILE *)0x0) {
perror("Error reading flag file");
uVar2 = 1;
}
else {
pcVar3 = fgets(local_118,0x100,__stream);
if (pcVar3 == (char *)0x0) {
fclose(__stream);
perror("No lines to read");
uVar2 = 1;
}
else {
fclose(__stream);
local_148 = 0x2b7a2138;
local_144 = 0x27243130;
local_140 = 0x2f3b772c;
local_13c = 0x357b7229;
local_138 = 0x2d1d2076;
local_134 = 0x252e7174;
local_130 = 0x39282637;
local_12c = 0x73237034;
local_128 = 0x2a36323a;
local_124 = 0x75333f;
printf("My secret: ");
sVar4 = strlen(local_118);
for (local_158 = 0; local_158 < (int)sVar4; local_158 = local_158 + 1) {
bVar1 = magic(&local_148,local_118[local_158]);
putchar((uint)bVar1);
}
putchar(10);
uVar2 = 0;
}
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return uVar2;
}
uint magic(long param_1,byte param_2)
{
sbyte sVar1;
uint uVar2;
int iVar3;
int iVar4;
ulong uVar5;
if (param_2 == 0x5f) {
uVar2 = *(uint *)(param_1 + 0x24) >> 0x10 ^ 0x42;
}
else if (param_2 < 0x40) {
iVar3 = param_2 - 0x16;
if (iVar3 < 0) {
iVar3 = param_2 - 0x13;
}
uVar2 = (uint)(((ulong)*(uint *)(param_1 + (long)(iVar3 >> 2) * 4) &
0xffL << ((byte)((int)(param_2 - 0x16) % 4 << 3) & 0x3f)) >>
((byte)((int)(param_2 - 0x16) % 4 << 3) & 0x3f)) ^ 0x42;
}
else if (param_2 < 0x7b) {
if ((param_2 < 0x41) || (0x60 < param_2)) {
iVar3 = 0x61;
}
else {
iVar3 = 0x41;
}
iVar4 = (uint)param_2 - iVar3;
if (iVar4 < 0) {
iVar4 = iVar4 + 3;
}
uVar2 = (uint)(((ulong)*(uint *)(param_1 + (long)(iVar4 >> 2) * 4) &
0xffL << ((byte)((int)((uint)param_2 - iVar3) % 4 << 3) & 0x3f)) >>
((byte)((int)((uint)param_2 - iVar3) % 4 << 3) & 0x3f)) ^ 0x42;
}
else {
if (param_2 == 0x7d) {
uVar5 = 0xff00;
}
else {
uVar5 = 0xff;
}
if (param_2 == 0x7d) {
sVar1 = 8;
}
else {
sVar1 = 0;
}
uVar2 = (uint)((*(uint *)(param_1 + 0x24) & uVar5) >> sVar1) ^ 0x42;
}
return uVar2;
}
Cracking it open in Ghidra, the main function was pretty straightforward:
- It tries to open flag.txt (current directory).
- Reads up to 0x100 bytes into a buffer.
- Prints My secret: and then, for each character in the buffer, calls magic(...) to transform it before printing.
So the whole “encryption” happens inside magic. That function indexes into a small table of dwords (the constants loaded into stack locals) and, depending on the input byte class (underscore, {}, digits, letters, etc.), extracts a byte, then XORs with 0x42
. TL;DR: magic
is a table-lookup + XOR toy cipher.
I didn’t fully reverse it — I mapped it 😎
Rather than untangling every branch, I took the quick-and-dirty route: build a mapping. I replaced flag.txt with a known alphabet covering the characters the flag might use:
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
Running the binary with that input produced:
My secret: zc8irsfen5ymk09w4b_o63lgudzc8irsfen5ymk09w4b_o63lgudj{v2a1xpth}q7
Perfect. That gives us a 1:1 mapping from plaintext characters → “encrypted” characters. With that, we can invert the map and instantly decode any ciphertext back to the original flag. No need to re-implement magic unless you want to.
Recovering the Flag with a Simple Python Script
Now that we have a complete mapping of encrypted character → original character, we can easily recover the real flag. Here’s a short Python script that does exactly that.
KNOWN_INPUT = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_"
KNOWN_OUTPUT = "zc8irsfen5ymk09w4b_o63lgudzc8irsfen5ymk09w4b_o63lgudj{v2a1xpth}q7"
FLAG_ENCRYPTED = "smzf}ij0o7b2ai7{0oj7{o7ojj7eabiq"
decode_map = dict(zip(KNOWN_OUTPUT, KNOWN_INPUT))
print(decode_map)
decoded = "".join(decode_map.get(c, "?") for c in FLAG_ENCRYPTED)
print("Recovered Actual Flag:", decoded)
The idea is simple: we store our known plaintext (the alphabet we used) and the output the binary gave for it. Then we use zip() to pair each encrypted character with its corresponding plaintext character, turning that into a dictionary. This dictionary acts like a translation table — for each character in the real encrypted flag, we just look it up in the dictionary to get the original character back.
Final Thoughts
I hope you enjoyed this write-up and found it helpful. Personally, I think Pensive was a nice, beginner-friendly reverse engineering challenge — it didn’t require diving too deep into complex assembly, but still taught a neat trick about using character mapping to bypass full algorithm reversing.
Happy cracking, and see you in the next writeup! 🏴☠️