607 words
3 minutes
EHAX CTF 2026 - Killer Queen - Cryptography Writeup

Category: Cryptography
Flag: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}

Challenge Description#

She keeps her Moët et Chandon in her pretty cabinet.

Nothing she offers is accidental. Nothing she withholds is without reason.

Recommended at the price, insatiable an appetite — wanna try?

Analysis#

unzip -l "Handout.zip"
Archive:  Handout.zip
...
  Handout/Killer-Queen/Freddie.txt
  Handout/Killer-Queen/Lyrics/ciggarettes.txt
  Handout/Killer-Queen/Lyrics/dynamite.txt
  Handout/Killer-Queen/Lyrics/fibonacci.txt
  Handout/Killer-Queen/Lyrics/laserbeam.txt
  Handout/Killer-Queen/Notes/antoinette.md
  Handout/Killer-Queen/Notes/biography.md
  Handout/Killer-Queen/Notes/curves.md
  Handout/Killer-Queen/Notes/sheet_music.md

The handout immediately looked like a guided crypto puzzle, with the notes explicitly pointing to Chebyshev composition and a two-layer lock. That matters because it shifts the approach from “break one huge primitive” to “recover the intended pipeline and replay it cleanly.”

rg -n "Chebyshev|T_m\(T_n\(x\)\)|first door|second door|SHA-256|unwrap|two voices|index" "/home/rei/Downloads/killerqueen_work/Handout/Killer-Queen"
.../Lyrics/fibonacci.txt:17:  T_m(T_n(x)) = T_{m·n}(x)
.../Notes/sheet_music.md:58: ♩  The stream is locked behind two doors.
.../Notes/sheet_music.md:59: ♩  The first door opens with the index.
.../Notes/sheet_music.md:60: ♩  The second door is built in blocks
.../Notes/sheet_music.md:63: ♩  SHA-256 derives the second key
.../Notes/sheet_music.md:72:•  The Queen speaks in two voices
.../Notes/sheet_music.md:81:You'll need to unwrap what's inside — twice.

The service behavior matched those hints exactly: one query gives two related voice values for the same index, and public iv/ciphertext are fixed for that session.

happy

printf '0\n1\n2\n3\n4\n5\nexit\n' | nc 20.244.7.184 7331
...
p          = 7073193951809819973664306187302601643156849222029017483853417297144476949947829139698672438579337709916095808633124126138796016767532929021864211602000001
v          = 5587994941705590424272649068295916108274072829805484356511387258719009236678476179597670504915988561703594735329013208151470008715612024345889541886986304
iv         = f9c60e2a62756a6ebcd59c589dfd61b6
ciphertext = ad218e83bffe076fbceeddc17f540cd3089e3c4e6309a0e63c7f7cbed1c8b0f9fd2aced60c79a00de855acb4d5047bcd

[20 left] q>   pretty_cabinet = 1
  moet_chandon   = 1

[19 left] q>   pretty_cabinet = 5587994941705590424272649068295916108274072829805484356511387258719009236678476179597670504915988561703594735329013208151470008715612024345889541886986304
  moet_chandon   = 6224016679887966716101625712054632071019252852377882598120412242331086101200983099281554722925606107557863470191553080919315116795993075331627024609220227
...

From there the final solve path was to derive material from moet_chandon(q) in index order, decrypt the AES-CBC outer layer, unpad, then do the second unwrap as a blockwise XOR stream where each 16-byte block is SHA256(str(moet_chandon(i)))[:16]. I tried heavier DLP-centric routes first, but those dead ends were useful because they confirmed the challenge was more about composition and serialization correctness than defeating the largest subgroup factor.

python3.12 "/home/rei/Downloads/killerqueen_work/Handout/Killer-Queen/solve_killerqueen.py"
[+] p bits: 512
[+] v: 11305618717155759150724013649828687503261898264977063258438514099154408236632078431105146562036594498971222989731073226625866287035766764084910955827909772
[+] iv: 83f4896579e6e4a8f29272ae5feef4ff
[+] ciphertext bytes: 48
[+] plaintext: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}
[+] FLAG: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}

Running it against the real remote session verified the whole chain end-to-end and produced the real flag in plaintext.

Solution#

# solve.py
import re
import socket
from hashlib import sha256

from Crypto.Cipher import AES


HOST = "20.244.7.184"
PORT = 7331


def must_match(pattern: str, data: str, flags: int = 0) -> re.Match[str]:
    match = re.search(pattern, data, flags)
    if match is None:
        raise ValueError(f"pattern not found: {pattern}")
    return match


def recv_until(sock: socket.socket, marker: bytes) -> bytes:
    data = b""
    while marker not in data:
        chunk = sock.recv(4096)
        if not chunk:
            break
        data += chunk
    return data


def parse_public(blob: str):
    p = int(must_match(r"^\s*p\s*=\s*(\d+)\s*$", blob, re.M).group(1))
    v = int(must_match(r"^\s*v\s*=\s*(\d+)\s*$", blob, re.M).group(1))
    iv = bytes.fromhex(must_match(r"^\s*iv\s*=\s*([0-9a-f]+)\s*$", blob, re.M).group(1))
    ciphertext = bytes.fromhex(
        must_match(r"^\s*ciphertext\s*=\s*([0-9a-f]+)\s*$", blob, re.M).group(1)
    )
    return p, v, iv, ciphertext


def parse_oracle_reply(blob: str):
    pretty = int(must_match(r"pretty_cabinet\s*=\s*(\d+)", blob).group(1))
    moet = int(must_match(r"moet_chandon\s*=\s*(\d+)", blob).group(1))
    return pretty, moet


def pkcs7_unpad(data: bytes) -> bytes:
    pad = data[-1]
    if pad < 1 or pad > 16 or data[-pad:] != bytes([pad]) * pad:
        raise ValueError("invalid PKCS#7 padding")
    return data[:-pad]


def main() -> None:
    with socket.create_connection((HOST, PORT), timeout=10) as sock:
        sock.settimeout(5)
        banner = recv_until(sock, b"q> ").decode(errors="replace")
        p, v, iv, ciphertext = parse_public(banner)

        print(f"[+] p bits: {p.bit_length()}")
        print(f"[+] v: {v}")
        print(f"[+] iv: {iv.hex()}")
        print(f"[+] ciphertext bytes: {len(ciphertext)}")

        moet_values = {}
        for q in range(1, 21):
            sock.sendall(f"{q}\n".encode())
            reply = recv_until(sock, b"q> ").decode(errors="replace")
            if "yawns" in reply or "Goodbye" in reply:
                break
            _, moet = parse_oracle_reply(reply)
            moet_values[q] = moet

    outer_key = sha256(str(moet_values[1]).encode()).digest()[:16]
    inner = AES.new(outer_key, AES.MODE_CBC, iv).decrypt(ciphertext)
    inner = pkcs7_unpad(inner)

    block_count = (len(inner) + 15) // 16
    stream = b"".join(
        sha256(str(moet_values[i]).encode()).digest()[:16]
        for i in range(1, block_count + 1)
    )

    plaintext = bytes(a ^ b for a, b in zip(inner, stream)).decode(errors="replace")
    flag_match = re.search(r"EH4X\{[^}]+\}", plaintext)
    if flag_match is None:
        raise RuntimeError(f"flag not found in plaintext: {plaintext!r}")

    print(f"[+] plaintext: {plaintext}")
    print(f"[+] FLAG: {flag_match.group(0)}")


if __name__ == "__main__":
    main()
python3.12 solve.py
[+] p bits: 512
[+] v: 11305618717155759150724013649828687503261898264977063258438514099154408236632078431105146562036594498971222989731073226625866287035766764084910955827909772
[+] iv: 83f4896579e6e4a8f29272ae5feef4ff
[+] ciphertext bytes: 48
[+] plaintext: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}
[+] FLAG: EH4X{Cav1aR_c1Gar3TT3s_Ch3bYsH3V_05091946}
EHAX CTF 2026 - Killer Queen - Cryptography Writeup
https://blog.rei.my.id/posts/62/ehax-ctf-2026-killer-queen-cryptography-writeup/
Author
Reidho Satria
Published at
2026-03-01
License
CC BY-NC-SA 4.0