FortID CTF 2026 - DESBox
I tried to install a famous MS-DOS emulator, but ended up installing this program.
I don’t think this can run DOOM…
Source files:
I’ve structured this write-up to include my solve approach and how I was wrong in each step instead of just giving out the actual solution outright.
The challenge
We are essentially given an oracle that lets you input two 3DES keys key_a and key_b.
key_a is used to encrypt the flag.
key_b is used to decrypt any ciphertext we give it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def run_keyb():
print("Loading KEYB.COM ...")
print("Enter two different 24-byte keys as hex.\n")
key_a, key_b = read_keys()
print("\nC:\\FLAG.BIN encrypted under Key A:")
print(DES3.new(key_a, DES3.MODE_ECB).encrypt(FLAG).hex())
ct = bytes.fromhex(input("\nDebug ciphertext> ").strip())
if len(ct) == 0 or len(ct) % BLOCK_SIZE != 0:
print("Bad ciphertext size.")
sys.exit(1)
print("Debug output:")
print(DES3.new(key_b, DES3.MODE_ECB).decrypt(ct).hex())
Looks like a no-brainer. What’s stopping us from entering the same keys and decrypting the ciphertext corresponding to the flag?
Well read_keys() is what prevents us. The keys cant be the same (apart from many other checks)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def read_keys():
key_a = bytes.fromhex(input("KEY A> ").strip())
key_b = bytes.fromhex(input("KEY B> ").strip())
if len(key_a) != 24 or len(key_b) != 24:
print("Bad key size.")
exit()
if key_a == key_b:
print("Keyboard layouts must be different.")
exit()
if effective_bytes(key_a) == effective_bytes(key_b):
print("Keyboard layouts are equivalent.")
exit()
if sorted(effective_bytes(key_a)) == sorted(effective_bytes(key_b)):
print("Keyboard layouts use the same scan code multiset.")
exit()
if is_effective_complement(key_a, key_b):
print("Keyboard layouts are too symmetric.")
exit()
return load_key(key_a), load_key(key_b)
The checks are pretty self-explanatory:
- The lengths must be 24 bytes each.
- The keys cant be the same.
- Effective bytes (MSB 7 bits of each byte) of both keys cant be the same.
- Both keys cant have the same effective bytes in any order.
- One key can’t be the compliment of the other.
load_key(key) is a very interesting function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
RNG = secrets.SystemRandom()
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def permute(a):
p = list(range(len(a)))
RNG.shuffle(p)
return bytes(a[i] for i in p)
def load_key(user_key):
key = xor(permute(user_key), MASK)
return DES3.adjust_key_parity(key)
It shuffles the input key bytes, then XORs it with a random 24 byte mask (unknown)
The XOR operation is crucial for the challenge as it prevents us from collecting the ciphertext for the flag and running trial decryption locally by checking all combinations of shuffles of the key.
Goal
Try to input the key used to encrypt the flag, to decrypt the flag’s ciphertext.
Nothing prevents me from trying to decrypt the ciphertext corresponding to the flag.
Before I explain my approaches, I’ll write a primer on 3DES.
3DES primer
3DES/Triple DES (Triple Data Encryption Algorithm) is just a stronger version of DES. For the purposes of this challenge, DES can be thought of as a blackbox.
DES is a block cipher, encrypts 64bits (8 bytes) of data at once. Key size is also 8 bytes.
3DES on the other hand uses 24byte keys represented as $K1, K2, K3$ (8 bytes each)
Encryption and Decryption
The encryption algorithm is:
\(\mathrm{ciphertext}=E_{K3}(D_{K2}(E_{K1}(\mathrm{plaintext}))).\)
And the decryption algorithm compliments what was done with each key. Starting with K3.
\(\mathrm{plaintext=D_{K1}(E_{K2}(D_{K3}(c i p h e r t e x t))).}\)
$K1$ and $K2$ cannot be the same 8 bytes. Neither can $K2$ and $K3$.
If they were the same key, encryption would just degenerate to $E_{K3}(\mathrm{plaintext})$ or $E_{K1}(\mathrm{plaintext})$ respectively as the encryption and decryption cancels each other out for the same key.
Infact, DES3’s implementation in pycryptodome prevents this.
1
2
key = bytes.fromhex("111111111111111100000000000000000000000000000000")
cipher = DES3.new(key, DES3.MODE_ECB)
1
ValueError: Triple DES key degenerates to single DES
$K1$ and $K3$ can be the same (this will prove to be useful later)
Parity bits
3DES effectively uses just 168 (24*7) bits instead of 192 bits (24*8) as the last bit of each byte is reserved as a parity bit to maintain odd parity (sum of bits in each byte must be odd)
DES3.adjust_key_parity(key) takes in a key and modifies the parity bit of each byte to maintain odd parity.
Let’s go back to my approach!
Solve approaches
1. Keys with flipped LSB bits.
After studying how the parity bits work and what the adjust_key_parity() function does, I thought I could just submit two keys in such a way that:
key_b == DES3.adjust_key_parity(key_a)
If we check the read_key() function, we see that the adjust_key_parity function is only called after the checks are done.
b"\x00"*8 + b"\xFE"*8 + b"\x00"*8 and
b"\x01"*8 + b"\xFE"*8 + b"\x01"*8
both give:
1
b'\x01\x01\x01\x01\x01\x01\x01\x01\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xfe\x01\x01\x01\x01\x01\x01\x01\x01'
when put through the adjust_key_parity function. (With both keys being the same, I can decrypt the flag’s ciphertext using key_b)
…Well this does not work as the effective_bytes(key) function ANDs each byte of the key with 0xFE (essentially considering just the msb 7 bits) and then compares both keys for being the same key.
So I cannot send two keys that differ in the LSB (so that they parity adjust into the same key) due to this check.
2. Key with just one differing byte
Not only does the previous approach not work due to all the checks on both keys,
I forgot to take note of the fact that key_a and key_b are shuffled differently in the load_key() function after all the conditions are met.
So I thought maybe I’ll make the keys only differ in one byte.
For example:
b"\x00"*15 + b"\xFE"*1 + b"\x00"*8 and
b"\x00"*14 + b"\xFE"*1 + b"\x00"*9
Note that $K1$ and $K3$ are the same (which is allowed), but $K2$ just has its 0xFE byte in two different positions.
1
2
00000000000000FE
000000000000FE00
The shuffling is different, but the XOR mask is the same.
I could just hope it shuffles into the same key (since I only have to get the 0xFE to align).
This seemed like a good strategy. The probability that they do align is around $1/24^2$ (Each byte’s 0xFE byte occupies one of 24 positions)
This approach does not work because one of the conditions is that the sorted effective bytes of both keys must not be the same. Essentially one key cannot be the permutation of another.
1
2
3
if sorted(effective_bytes(key_a)) == sorted(effective_bytes(key_b)):
print("Keyboard layouts use the same scan code multiset.")
exit()
(This check is also important from the perspective of the challenge author because my key could shuffle into a key that degenerates 3DES into DES (The lone 0xFE byte can end up being in $K1$ or $K3$ making $K2=K3$ or $K1=K3$ respectively))
3. Key with just one differing bytes over multiple calls.
The run_keyb function can be called several times. This was the breakthrough for me.
Instead of trying to get key_a and key_b to match up I try to do the same across multiple calls.
Specifically,
First run_keyb() call:
Input key_a as b"\x00"*15 + b"\xFE"*1 + b"\x00"*8
Capture the ciphertext for the flag.
Input some garbage 24 bytes as key_b and anything as the ciphertext input.
Discard the received plaintext.
What I just did over here was capture the ciphertext for flag where the key is just:
$\mathrm{permute(key)} \oplus \mathrm{MASK}$
The 0xFE byte gets shuffled into one of 24 byte positions and is XORed with a fixed mask (which remains constant across run_keyb calls).
Subsequent run_keyb() calls
Enter garbage values for key_a, discard the received ciphertext.
Enter b"\xFE"*1 + b"\x00"*23 (any position of 0xFE works) for key_b
and the ciphertext for the flag received initially, to decrypt it.
There is a $1/24$ chance that key_b will permute into the same key that key_a permuted into, in the first call.
We keep running this until the decrypted ciphertext under key_b is the flag.
And that’s it!
Solve.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from pwn import *
context.log_level = 'INFO'
garbage = b"020202020202020202020202020202fe0202120202020202"
io = remote('desbox-a97c2753a69a.inst4.ctf.fortid.com', 1337, ssl=True)
io.sendlineafter(b"C:\> ", b"KEYB")
io.sendlineafter(b"KEY A> ", b"000000000000000000000000000000fe0000000000000000")
io.sendlineafter(b"KEY B> ", garbage)
io.recvuntil(b"C:\FLAG.BIN encrypted under Key A:")
io.recvline()
ciphertext = io.recvline().decode().strip()
io.sendlineafter(b"Debug ciphertext> ", ciphertext.encode())
io.recvuntil(b"Debug output:")
io.recvline()
print(bytes.fromhex(io.recvline().decode().strip()))
while True:
io.sendlineafter(b"C:\> ", b"KEYB")
io.sendlineafter(b"KEY A> ", garbage)
io.sendlineafter(b"KEY B> ", b"fe0000000000000000000000000000000000000000000000")
io.recvuntil(b"C:\FLAG.BIN encrypted under Key A:")
io.recvline()
io.recvline()
io.sendlineafter(b"Debug ciphertext> ", ciphertext.encode())
io.recvuntil(b"Debug output:")
io.recvline()
flag = bytes.fromhex(io.recvline().decode().strip())
if b"Fort" in flag:
print(flag)
break
Flag
1
FortID{C0mP11m3n75_0n_3xP101t1N6_C0mP13meN7s!!1}
Notes
- I realize that the use case of XORing the permuted key with a random MASK is not just the prevention of local trial and error decryption, it also prevents valid DES3 keys from degenerating into DES keys because (in my case) the
0xFEcould permute and end up in $K1$ or $K3$ making two adjacent keys the same.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import secrets
from Crypto.Cipher import DES3
RNG = secrets.SystemRandom()
def permute(a):
p = list(range(len(a)))
RNG.shuffle(p)
return bytes(a[i] for i in p)
def load_key(user_key):
key = permute(user_key)
return DES3.adjust_key_parity(key)
key = bytes.fromhex("000000000000000000000000000000fe0000000000000000")
cipher = DES3.new(load_key(key), DES3.MODE_ECB)
Will cause the following exception (2/3rds of the time)
1
ValueError: Triple DES key degenerates to single DES
I liked the challenge despite the relative simplicity of the solution.
Solving Crypto challenges without LLMs is very fun as you’re essentially peeling off layers of complexity with every incorrect approach that you think of, until you’re right.