0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[crypto] Golden Ticket (SECCON Beginners CTF 2025) writeup

Last updated at Posted at 2025-12-09

  • Source: SECCON Beginners CTF 2025
  • Author: Yu_212

AES-CBCモードで1ブロックの暗号化と復号化を3回ずつ行うことができ、6ブロックからなるchallengeの暗号化ができるか、という問題。

golden-ticket.py
import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad


flag = os.environ.get("FLAG", "ctf4b{dummy_flag}")
iv = os.urandom(16)
key = os.urandom(16)
challenge = os.urandom(16 * 6)
ENC_TICKET = 3
DEC_TICKET = 3
GOLDEN_TICKET = 0

def menu() -> int:
    print("Your tickets:")
    if ENC_TICKET > 0:
        print(f"{ENC_TICKET} encryption ticket(s)")
    if DEC_TICKET > 0:
        print(f"{DEC_TICKET} decryption ticket(s)")
    if GOLDEN_TICKET > 0:
        print(f"{GOLDEN_TICKET} golden ticket(s)")
    print()
    print(f"1. Encrypt")
    print(f"2. Decrypt")
    print(f"3. Get ticket")
    print(f"4. Get flag")
    print(f"5. Quit")
    while True:
        i = int(input("> "))
        if 1 <= i <= 5:
            return i
        print("Invalid input!")

def consume_ticket(enc: int = 0, dec: int = 0, golden: int = 0):
    global ENC_TICKET, DEC_TICKET, GOLDEN_TICKET
    if ENC_TICKET < enc or DEC_TICKET < dec or GOLDEN_TICKET < golden:
        print("Not enough tickets.")
        exit(1)
    ENC_TICKET -= enc
    DEC_TICKET -= dec
    GOLDEN_TICKET -= golden

while True:
    i = menu()

    if i == 1:
        consume_ticket(enc=1)
        pt = bytes.fromhex(input("pt> "))
        if len(pt) > 16:
            print("Input must not be longer than 16 bytes.")
            continue
        cipher = AES.new(key, AES.MODE_CBC, iv=iv)
        print(f"ct:", cipher.encrypt(pad(pt, 16)).hex())

    if i == 2:
        consume_ticket(dec=1)
        ct = bytes.fromhex(input("ct> "))
        if len(ct) > 16:
            print("Input must not be longer than 16 bytes.")
            continue
        cipher = AES.new(key, AES.MODE_CBC, iv=iv)
        print("pt:", cipher.decrypt(pad(ct, 16)).hex())

    if i == 3:
        print("challenge:", challenge.hex())
        answer = bytes.fromhex(input("answer> "))
        if len(answer) != len(challenge) + 16:
            print("Wrong length.")
            continue
        cipher = AES.new(key, AES.MODE_CBC, iv=answer[:16])
        if cipher.decrypt(answer[16:]) == challenge:
            print("Correct!")
            GOLDEN_TICKET += 1337
        else:
            print("Wrong :(")

    if i == 4:
        consume_ticket(golden=1)
        print("flag:", flag)

    if i == 5:
        print("Bye!")
        exit(0)

脆弱性は復号化処理の部分で、本来はAES-CBCで復号してからunpadすべきだが、padしてから復号している。

    if i == 2:
        consume_ticket(dec=1)
        ct = bytes.fromhex(input("ct> "))
        if len(ct) > 16:
            print("Input must not be longer than 16 bytes.")
            continue
        cipher = AES.new(key, AES.MODE_CBC, iv=iv)
        print("pt:", cipher.decrypt(pad(ct, 16)).hex())

PKCS#7では長さが16の倍数になるように1~16byteのパディングを付加するので、暗号文として'\x10'*16を送信すると'\x10'*32になり、2ブロック分の復号結果が得られる。

ここでAES-CBCのブロックの暗号化/復号化の仕組みを考える。

image.png
image.png

暗号文の各ブロックをc0とc1、平文の各ブロックをp0,p1と置くと、以下の式が成り立つ。

\displaylines{
p0=c0⊕key⊕iv
\\
p1=c1⊕key⊕c0
}

'\x10'*16を$pad$とすると、$c0=c1=pad$であるから、ivは$p0⊕p1⊕pad$で得ることができる。

pad16 = b"\x10" * 16
pt = decrypt(pad16)
iv = strxor(strxor(pt[:16], pt[16:]), pad16) # p0 = c0^key^iv, p1=c1^key^c0, c0=c1=pad16 -> iv = p0^p1^pad16

あと暗号化3回と復号化2回が使えるので、どこかのブロックの値を固定してからAESとして正しい前後のブロックおよびivを決定していく。この問題と同じ考え方でやっていく。(計算式は下のプログラムのコメントを参照)
これには6ブロックの計算が必要だが、'\x10'*16を3ブロック目に固定することでivを求めた時の復号結果を流用することができる。

ans2 = pad16
ans3 = encrypt(strxor(strxor(cha[3], ans2), iv))[:16]   # ans3 = cha3^ans2^key, E(p) = p^key^iv -> E(cha3^ans2^iv) = ans3
ans4 = encrypt(strxor(strxor(cha[4], ans3), iv))[:16]   # ans4 = cha4^ans3^key, E(p) = p^key^iv -> E(cha4^ans3^iv) = ans4
ans5 = encrypt(strxor(strxor(cha[5], ans4), iv))[:16]   # ans5 = cha5^ans4^key, E(p) = p^key^iv -> E(cha5^ans4^iv) = ans5
ans1 = strxor(strxor(pt, cha[2]), iv)[:16]              # ans2 = cha2^ans1^key, D(c) = c^key^iv -> D(ans2)^cha2^iv = ans1
ans0 = strxor(strxor(decrypt(ans1), cha[1]), iv)[:16]   # ans1 = cha1^ans0^key, D(c) = c^key^iv -> D(ans1)^cha1^iv = ans0
ans_iv = strxor(strxor(decrypt(ans0), cha[0]), iv)[:16] # ans0 = cha0^ans_iv^key, D(c) = c^key^iv -> D(ans0)^cha0^iv = ans_iv

最終的なsolverはこうなる。

from pwn import *

io = remote("localhost", 9999)

def encrypt(pt: bytes) -> bytes:
    assert len(pt) == 16
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b"pt> ", pt.hex().encode())
    io.recvuntil(b"ct: ")
    ct = bytes.fromhex(io.recvline().strip().decode())
    return ct

def decrypt(ct: bytes) -> bytes:
    assert len(ct) == 16
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"ct> ", ct.hex().encode())
    io.recvuntil(b"pt: ")
    pt = bytes.fromhex(io.recvline().strip().decode())
    return pt

def strxor(a: bytes, b: bytes) -> bytes:
    return bytes(x ^ y for x, y in zip(a, b))


# recover iv
pad16 = b"\x10" * 16
pt = decrypt(pad16)
iv = strxor(strxor(pt[:16], pt[16:]), pad16) # p0 = c0^key^iv, p1=c1^key^c0, c0=c1=pad16 -> iv = p0^p1^pad16
print(f"iv: {iv.hex()}")

# get challenge
io.sendlineafter(b"> ", b"3")
io.recvuntil(b"challenge: ")
raw = bytes.fromhex(io.recvline().strip().decode())
cha = [raw[i:i+16] for i in range(0, len(raw), 16)]
io.sendlineafter(b"answer> ", b"00" * (16 * 7))  # dummy

# solve
ans2 = pad16
ans3 = encrypt(strxor(strxor(cha[3], ans2), iv))[:16]   # ans3 = cha3^ans2^key, E(p) = p^key^iv -> E(cha3^ans2^iv) = ans3
ans4 = encrypt(strxor(strxor(cha[4], ans3), iv))[:16]   # ans4 = cha4^ans3^key, E(p) = p^key^iv -> E(cha4^ans3^iv) = ans4
ans5 = encrypt(strxor(strxor(cha[5], ans4), iv))[:16]   # ans5 = cha5^ans4^key, E(p) = p^key^iv -> E(cha5^ans4^iv) = ans5
ans1 = strxor(strxor(pt, cha[2]), iv)[:16]              # ans2 = cha2^ans1^key, D(c) = c^key^iv -> D(ans2)^cha2^iv = ans1
ans0 = strxor(strxor(decrypt(ans1), cha[1]), iv)[:16]   # ans1 = cha1^ans0^key, D(c) = c^key^iv -> D(ans1)^cha1^iv = ans0
ans_iv = strxor(strxor(decrypt(ans0), cha[0]), iv)[:16] # ans0 = cha0^ans_iv^key, D(c) = c^key^iv -> D(ans0)^cha0^iv = ans_iv

answer = ans_iv + ans0 + ans1 + ans2 + ans3 + ans4 + ans5

# get flag
io.sendlineafter(b"> ", b"3")
io.sendlineafter(b"answer> ", answer.hex().encode())
res = io.recvline().strip()
print(res.decode())

io.sendlineafter(b"> ", b"4")
print(io.recvline().decode())

これを実行するとflagが得られた。
ctf4b{u_wi11_b3_1nv173d_t0_7h3_ch0c0l4t3_f4c70ry_1337_t1m35}

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?