Challenge
TL;DR
We exploit the tag equation to build a membership oracle and then do a binary search over the tiny keyspace (permutations of m0leCon: 7! = 5040).
For a fixed tag T* and fixed additional data (AAD), each candidate key yields a magic nonce that would make the tag check succeed when the ciphertext is empty.
By submitting many nonces in one query (the service splits the nonce input into 16‑byte blocks), we learn if the real key is inside a tested half — repeat until only one candidate remains, then craft the real ciphertext + tag for b"next round please" to clear the round. Five rounds → flag.
Challenge recap
- Endpoint: nc guess_me.challs.m0lecon.it 12164
- Rounds: 5
- Input per attempt: nonce, additional_data, ciphertext, tag (all hex)
- Primitive: a public 10‑round permutation F (nibble S‑box + bit permutation) and a PRF built from AES-ECB_k(SHA256(index)) masks with F in between the XOR halves.
- Key derivation: key = SHA256(permutation("m0leCon"))[:16] (so 7! = 5040 keys).
Design analysis
Focus on the tag path when we use a fixed AAD and empty ciphertext. The server effectively checks
expected_tag(nonce) = F(nonce ⊕ K) ⊕ D
with
- F public and invertible;
- K = PRF(1338, key, pad(AAD)) ⊕ M1_-1;
- D = M2_-1;
- (M1_i || M2_i) = AES_k( SHA256(i) ) (two ECB blocks).
Therefore, for any fixed 16‑byte target tag T*, the set of nonces that satisfy the check is
nonce = F^{-1}(T* ⊕ D) ⊕ K
Given the true key is one of 5040 candidates, we can precompute this nonce for each candidate key.
AAD detail. The service uses proper PKCS#7 for the message, but a broken pad for AAD. For AAD = b"pretty please" (len 13), the service pads it to 16 with 03 03 03. We mirror this exactly.
Turning it into a membership oracle
- We send many nonces at once by concatenating them; the service consumes the nonce input in 16‑byte chunks.
- We keep AAD = b"pretty please", empty ciphertext, and set tag = T* (e.g., all‑zero).
- Server answers:
- “Tag is invalid” if none of the nonces match the real key
- “This message does not seem ok :(” if at least one matches (decryption/pad then fails on purpose — we only need this bit).
We can therefore binary search the 5040 candidates: ~log2(5040) ≈ 12 oracle queries per round.
When only one candidate remains, we craft the real ciphertext and tag for the plaintext b"next round please" and clear the round.
Implementation
- Python 3.11+
- pycryptodome for AES
#!/usr/bin/env python3
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from hashlib import sha256
from hmac import compare_digest
from random import shuffle
import os
flag = os.environ.get("FLAG", "ptm{REDACTED}")
BLOCK_SIZE = 16
NUM_BITS = BLOCK_SIZE * 8
SBOX = (0xC, 0x5, 0x6, 0xB, 0x9, 0x0, 0xA, 0xD, 0x3, 0xE, 0xF, 0x8, 0x4, 0x7, 0x1, 0x2)
BIT_PERM = tuple((idx * 7) % NUM_BITS for idx in range(NUM_BITS))
def _pad_pkcs7(data, block_size = BLOCK_SIZE):
return data + bytes([(block_size - len(data)) % block_size]) * ((block_size - len(data)) % block_size)
def _xor_bytes(left, right):
return bytes(a ^ b for a, b in zip(left, right))
def _unpad_pkcs7(data, block_size = BLOCK_SIZE):
d = data[-1]
assert d <= len(data)
assert all([x==d for x in data[-d:]])
return data[:-d]
def _perm(data):
state = data
for _ in range(10):
sbox_out = bytearray(len(state))
for idx, value in enumerate(state):
sbox_out[idx] = (SBOX[value >> 4] << 4) | SBOX[value & 0x0F]
bits = []
for value in sbox_out:
for shift in range(8):
bits.append((value >> (7 - shift)) & 0x01)
permuted_bits = [0] * NUM_BITS
for idx, bit in enumerate(bits):
permuted_bits[BIT_PERM[idx]] = bit
state_out = bytearray(len(state))
for idx in range(len(state)):
byte = 0
for shift in range(8):
byte = (byte << 1) | permuted_bits[idx * 8 + shift]
state_out[idx] = byte
state = bytes(state_out)
return state
def _prf(block_index, key, data):
cipher = AES.new(key, AES.MODE_ECB)
mask = cipher.encrypt(sha256(block_index.to_bytes(4, 'big', signed=True)).digest())
result = _xor_bytes(data, mask[:BLOCK_SIZE])
result = _perm(result)
result = _xor_bytes(result, mask[-BLOCK_SIZE:])
return result
def enc_msg(key, nonce, message):
padded = _pad_pkcs7(message)
blocks = [padded[i : i + BLOCK_SIZE] for i in range(0, len(padded), BLOCK_SIZE)]
ciphertext_blocks = []
for idx, block in enumerate(blocks):
keystream = _prf(idx, key, nonce)
ciphertext_blocks.append(_xor_bytes(block, keystream))
return b"".join(ciphertext_blocks)
def enc_tag(key, nonce, additional_data, ciphertext):
ad_padded = _pad_pkcs7(nonce + additional_data)
ct_padded = _pad_pkcs7(ciphertext)
ad_blocks = [ad_padded[i : i + BLOCK_SIZE] for i in range(0, len(ad_padded), BLOCK_SIZE)]
ct_blocks = [ct_padded[i : i + BLOCK_SIZE] for i in range(0, len(ct_padded), BLOCK_SIZE)]
tag = ad_blocks[0]
for idx, block in enumerate(ad_blocks[1:], start=1):
keystream = _prf(idx + 1337, key, block)
tag = _xor_bytes(tag, keystream)
for idx, block in enumerate(ct_blocks):
keystream = _prf(idx + 31337, key, block)
tag = _xor_bytes(tag, keystream)
return _prf(-1, key, tag)
def encrypt(key, nonce, message, additional_data):
ciphertext = enc_msg(key, nonce, message)
tag = enc_tag(key, nonce, additional_data, ciphertext)
return ciphertext, tag
def decrypt(key, nonce, ciphertext, additional_data, tag):
assert len(key) == BLOCK_SIZE
assert len(nonce) == BLOCK_SIZE
assert len(ciphertext) % BLOCK_SIZE == 0
assert len(tag) == BLOCK_SIZE
expected_tag = enc_tag(key, nonce, additional_data, ciphertext)
if not compare_digest(expected_tag, tag):
return False
blocks = [ciphertext[i : i + BLOCK_SIZE] for i in range(0, len(ciphertext), BLOCK_SIZE)]
plaintext_blocks = []
for idx, block in enumerate(blocks):
keystream = _prf(idx, key, nonce)
plaintext_blocks.append(_xor_bytes(block, keystream))
plaintext_padded = b"".join(plaintext_blocks)
try:
plaintext = _unpad_pkcs7(plaintext_padded)
except:
return b"Invalid padding"
return plaintext
if __name__ == "__main__":
for r in range(5):
base = list("m0leCon")
shuffle(base)
key = bytes(sha256("".join(base).encode()).digest())[:BLOCK_SIZE]
for _ in range(16):
nonces = bytes.fromhex(input("Enter nonce (hex): ").strip())
nonces = [nonces[i:i+BLOCK_SIZE] for i in range(0, len(nonces), BLOCK_SIZE)]
additional_data = bytes.fromhex(input("Enter additional_data (hex): ").strip())
ciphertext = bytes.fromhex(input("Enter ciphertext (hex): ").strip())
tag = bytes.fromhex(input("Enter tag (hex): ").strip())
decs = [decrypt(key, nonce, ciphertext, additional_data, tag) for nonce in nonces]
auth = any(decs)
if auth:
if additional_data != b"pretty please":
print("Can you at least say 'please' next time?")
exit()
else:
if all([dec == b"next round please" for dec in decs]):
print("There you go!")
break
else:
print("This message does not seem ok :(")
else:
print("Tag is invalid")
else:
print("Better luck next time!")
exit()
print(flag)
Run script
python3 -m pip install pycryptodome
python3 solve_guess_me.py
Flag
Flag: ptm{7his_ch4ll3ng3_is_s0_2020}
'WriteUp > Crypto' 카테고리의 다른 글
| Augury - BuckeyeCTF 2025 (0) | 2025.11.09 |
|---|---|
| [Crypto] EZ-Des - Dreamhack (0) | 2025.10.21 |