Logo
Overview
[CLUB LEAGUE] pqs writeup

[CLUB LEAGUE] pqs writeup

October 11, 2025
6 min read

TL;DR

WOTS 재사용 취약점

Analysis

#!/usr/bin/python3
import math
import os
from hashlib import sha256
class Wots:
W = 256
def __init__(self, sk, vk):
self.sk = sk
self.vk = vk
@staticmethod
def _checksum_len(num_msg_digits, w):
max_cs = num_msg_digits * (w - 1)
return math.ceil(math.log(max_cs + 1, w))
@classmethod
def keygen(cls):
num_msg_digits = 32
t = cls._checksum_len(num_msg_digits, cls.W)
total_chains = num_msg_digits + t
sk = [os.urandom(32) for _ in range(total_chains)]
vk = [cls.hash(x, cls.W) for x in sk]
return cls(sk, vk)
@classmethod
def hash(cls, x, n):
for _ in range(n):
x = sha256(x).digest()
return x
@classmethod
def _digest_digits(cls, msg):
m_bytes = cls.hash(msg, 1)
digits = list(m_bytes)
t = cls._checksum_len(len(digits), cls.W)
cs = sum((cls.W - 1 - d) for d in digits)
cs_digits = []
for _ in range(t):
cs_digits.append(cs % cls.W)
cs //= cls.W
digits.extend(cs_digits)
return digits, t
def sign(self, msg):
digits, t = self._digest_digits(msg)
assert len(self.sk) == len(digits)
m = self.hash(msg, 1)
sig = b"".join(self.hash(x, self.W - n) for x, n in zip(self.sk, digits))
return sig
def verify(self, msg, sig):
digits, t = self._digest_digits(msg)
chain_count = len(digits)
if len(sig) != 32 * chain_count:
return False
chunks = [sig[i : i + 32] for i in range(0, len(sig), 32)]
vk = [self.hash(x, n) for x, n in zip(chunks, digits)]
return self.vk == vk
if __name__ == "__main__":
with open("flag.txt") as f:
flag = f.read().strip()
wots = Wots.keygen()
msg1 = bytes.fromhex(input("give me a message (hex): "))
sig1 = wots.sign(msg1)
assert wots.verify(msg1, sig1)
print("here is the signature (hex):", sig1.hex())
msg2 = bytes.fromhex(input("give me a new message (hex): "))
if msg1 == msg2:
print("cheater!")
exit()
sig2 = wots.sign(msg2)
print("here is the signature (hex):", sig2.hex())
if sig1 == sig2:
print("cheater!")
exit()
msg3 = bytes.fromhex(input("give me a newer message (hex): "))
if msg3 in [msg1, msg2]:
print("cheater!")
exit()
sig3 = bytes.fromhex(input("give me the signature (hex): "))
if wots.verify(msg3, sig3):
print(flag)
else:
print("nope")

pqs.py에 정의된 Winternitz One-Time Signature(WOTS)를 사용해 세 번의 메시지 입력을 받는다. 첫 두 메시지에 대해서는 서명을 직접 돌려주고, 세 번째 메시지에 대해서는 이용자가 제출한 서명의 유효성을 검증해 맞으면 플래그 출력.

핵심은 WOTS가 말 그대로 한 번만 안전하게 사용할 수 있다는 점인데, 서버는 두 번이나 임의 메시지에 대한 서명을 제공하기 때문에 재사용 공격이 가능하다.

WOTS 체인의 구조: 비밀키 조각 xix_i에 대해 해시 함수 HH를 반복 적용하여

Xi(k)=Hk(xi)X_i^{(k)} = H^{k}(x_i)

로 정의한다.

모수 W=256W = 256이므로, 실제 서명에서 사용되는 체인 값은 Xi(Wdi)X_i^{(W-d_i)} 형태가 된다. 한 번 kk가 줄어든 체인 값은 역연산이 불가능하므로, 낮은 kk 값의 체인을 한 번이라도 얻으면 그보다 더 낮은 높이의 체인을 모두 복원할 수 있다.

def sign(self, msg):
digits, t = self._digest_digits(msg)
assert len(self.sk) == len(digits)
m = self.hash(msg, 1)
sig = b"".join(self.hash(x, self.W - n) for x, n in zip(self.sk, digits))
return sig

WOTS 서명은 각 체인의 남은 높이만큼 해시를 반복하여 생성된다. 특정 메시지에 대해 얻은 서명 조각을 앞쪽(남은 체인 길이만큼)으로 더 해싱하면 동일 체인에서 더 작은 서명을 만들 수 있다.

HWdi(xi)H^{W-d_i}(x_i)를 이용해 HWdiΔ(xi)H^{W-d_i - \Delta}(x_i)를 계산하는 것과 동일하며, 여기서 Δ0\Delta \ge 0

wots = Wots.keygen()
msg1 = bytes.fromhex(input("give me a message (hex): "))
sig1 = wots.sign(msg1)
assert wots.verify(msg1, sig1)
print("here is the signature (hex):", sig1.hex())
msg2 = bytes.fromhex(input("give me a new message (hex): "))
if msg1 == msg2:
print("cheater!")
exit()
sig2 = wots.sign(msg2)
print("here is the signature (hex):", sig2.hex())
if sig1 == sig2:
print("cheater!")
exit()
msg3 = bytes.fromhex(input("give me a newer message (hex): "))
if msg3 in [msg1, msg2]:
print("cheater!")
exit()
sig3 = bytes.fromhex(input("give me the signature (hex): "))
if wots.verify(msg3, sig3):

프로토콜은 서로 다른 세 메시지 입력을 강제하지만, 첫 두 메시지에 대한 서명을 이미 공개했기 때문에 신규 메시지를 위한 체인 높이 요구량이 기존 메시지보다 작다면 해당 체인 조각을 재활용할 수 있다.

def _digest_digits(cls, msg):
m_bytes = cls.hash(msg, 1)
digits = list(m_bytes)
t = cls._checksum_len(len(digits), cls.W)
cs = sum((cls.W - 1 - d) for d in digits)
cs_digits = []
for _ in range(t):
cs_digits.append(cs % cls.W)
cs //= cls.W
digits.extend(cs_digits)
return digits, t

메시지를 32바이트 SHA-256 해시로 바꾸고, 각 바이트를 기수 256의 숫자로 취급하며 추가적으로 체크섬까지 붙인다. 덕분에 체인 개수는 32+t=3432 + t = 34이고 각 체인의 필요한 해시 높이는 0di<2560 \le d_i < 256 범위에 존재합니다. 체크섬은 cs=i=031(W1di)\mathrm{cs} = \sum_{i=0}^{31} (W-1-d_i) 로 계산되어 tt개의 추가 체인 값에 분산된다.

취약 조건: 새로운 메시지 m3m_3의 각 체인 요구량 d3[i]d_3[i]가 첫 두 메시지 m1m_1, m2m_2의 요구량 d1[i]d_1[i], d2[i]d_2[i]보다 작거나 같으면, d3[i]max(d1[i],d2[i])i{0,,33}d_3[i] \le \max(d_1[i], d_2[i]) \quad \forall i \in \{0,\dots,33\} 를 만족한다. 두 서명 중 하나에서 해당 체인 조각을 가져와 필요한 만큼만 앞쪽으로 추가 해시하면 새로운 서명을 합성할 수 있다.

Exploit

목표는 d3[i]max(d1[i],d2[i])d_3[i] \le \max(d_1[i], d_2[i])를 모든 ii에 대해 만족하는 (m1,m2,m3)(m_1, m_2, m_3) 조합을 찾는 것입니다. 각 d[i]d[i] 값은 거의 균일 분포라 가정할 수 있으므로, 두 메시지에 대해 최대값을 취했을 때의 기대치는 E[max(U1,U2)]=k=0255k(2k+1)2562177.97\mathbb{E}[\max(U_1, U_2)] = \sum_{k=0}^{255} \frac{k(2k+1)}{256^2} \approx 177.97

평균적으로 요구 높이의 70% 이상을 덮을 수 있기 때문에 전체 체인에 대해 조건을 만족시키는 확률이 무작위 탐색으로도 충분히 높ek

POC에선 os.urandom(16)으로 임의 메시지를 뽑아 Wots._digest_digits를 호출해 세 메시지의 요구량 배열을 계산하고 조건을 만족하는 조합이 나올 때까지 반복했다

조건을 만족하는 m1, m2를 확보하면 이를 그대로 원격 서비스에 전송해 각각 sig1, sig2를 받습니다.

멀티 체인 서명 조합

  1. 받은 서명을 32바이트 단위 체인 조각으로 분해
  2. 각 체인에 대해 d3[i]d1[i]d_3[i] \le d_1[i]이면 sig1의 해당 조각을 채택하고, 그렇지 않고 d3[i]d2[i]d_3[i] \le d_2[i]이면 sig2의 조각을 선택
  3. 선택한 조각을 Wots.hash(chain, original_height - target_height)만큼 추가 해싱하면 정확히 d3[i]d_3[i] 높이에 해당하는 체인 값을 얻는다

C~i=Hhid3[i](Ci)\widetilde{C}_i = H^{h_i - d_3[i]}(C_i) 로 계산, 여기서 hih_i는 선택한 원본 체인의 높이 모든 체인 조각을 순서대로 이어 붙이면 m3에 대한 위조 서명 sig3_forged가 완성

m3와 합성한 서명을 서버에 제출하면 wots.verify가 통과하고 플래그를 받을 수 있다.

조건을 만족하는 메시지 세 개 찾기

from pqs import Wots
import os
def digest(msg):
digits, _ = Wots._digest_digits(msg)
return digits
while True:
m1 = os.urandom(16)
m2 = os.urandom(16)
d1 = digest(m1)
d2 = digest(m2)
thresh = [max(a, b) for a, b in zip(d1, d2)]
m3 = os.urandom(16)
d3 = digest(m3)
if all(c <= t for c, t in zip(d3, thresh)) and len({m1, m2, m3}) == 3:
break
  • m1 = a97d6818b5d4fde820a2a19293018542
  • m2 = 510b8bff8daf959b85770d5ca66410c8
  • m3 = f006e1fa0480c576d7659f024d0bd89d

d3max(d1,d2)d_3 \preceq \max(d_1, d_2)입니다.

서명 합성 로직

chunks1 = [sig1[i:i+32] for i in range(0, len(sig1), 32)]
chunks2 = [sig2[i:i+32] for i in range(0, len(sig2), 32)]
forged_chunks = []
for target, h1, h2, c1, c2 in zip(d3, d1, d2, chunks1, chunks2):
if target <= h1:
forged_chunks.append(Wots.hash(c1, h1 - target))
elif target <= h2:
forged_chunks.append(Wots.hash(c2, h2 - target))
else:
raise AssertionError("조건 불충족")
forged_sig = b"".join(forged_chunks)

위와 같이 체인별로 조건을 체크하며 위조 서명 생성

andsopwn@meow:~/ctftemp/hspace/crypto/pqs$ python3 exploit.py
[x] Opening connection to 13.125.132.168 on port 15151
[x] Opening connection to 13.125.132.168 on port 15151: Trying 13.125.132.168
[+] Opening connection to 13.125.132.168 on port 15151: Done
[DEBUG] Received 0x19 bytes:
...
[DEBUG] Sent 0x21 bytes:
b'f006e1fa0480c576d7659f024d0bd89d\n'
[DEBUG] Received 0x1d bytes:
b'give me the signature (hex): '
[DEBUG] Sent 0x881 bytes:
b'ed12346520602a607ae4134b39c6dd1c0c780c6a772d15719d3a776134094548bd1027a1c4e8c99b1b6ea2df64369267638b314373825bd37c211a408ced6f27075c100b8a4a450124246c41d7e63a00410c066a336aaafa5eba4e4cfb42e00301c8b0e2e69d19aefd966139a5ac7a86b397719abcbf7874477fe47dc287012f55f306d6946e0702f6d608198644d2af1d0e54fe77878c8a6ab7c4dac718c8b72aeecbfed76d2b8a45acf1cd414ae3fc5fc9a4f46657c051fefbe8fbdfbfc0731c3071514d6394d489dcad40a2678af90177a2f0f44c785868982afe0b6b3a7afbdfa651a5961edc75f7c665954e90db9ef55568448717ee8b704325a92b317d2700e6b5ffe484e307b3154adbe86108d53c2d3df214f61e8c3c1890778b32cb508ad4823199f35f16eb016100753213c6a7668deeaaecf3a98d9ae18dc3a7f1f3c569c2b80542b783d30ad5afe8cfd76d2171d614b98eb3f12e0b7338ded9c27945efc4ccb9e377612777285c74220632d503902917bf9eee9330d3da32af1e3c2f8d47d21d71f6182bf1273d2fa77323ecd12126e6246a999397a56586cb87ae2eeb45d3ee26a409cfbd63d767571e88ba03ce10ce10b252c96f587f6704a4884081c12b4550ef4873d98f11223c43da8f8993750db3ce8b73b58074f7d879b3e3026f74183bbe21897d6f5bdd4efda17872309e056ceb62f785aa997d781e0c922ecf54ea37ef67c0a8e2d16d3b17a00a5e3b1aef49915eddd7de24b4574a1fda431febd4c8347bdaf48878dfc3fde8c5819adba13ea4c7f654b915fde1606cf8d028a23d2a8583a3514c9631f88e74b13ae2462c45f609f3f4c9b702efb52259d28f386033a28017302bb45d5efdb8029ff0c0feefece6d2e51612fedd8b51f32b6fb99b8deccc71291d388e2aafb26a514a483d54673ae5eb9cbb64dff6d38f9c621dcf391de3af38fa0e2ef521ea69bf6191f5af4c076423dce2be5521735db3a063045073ae33f5a1801b5dca90ef527086527839d7a921a3745bdf032287f038edf21fa8ae5fee9e59282bab53f48de598ad2113c981843ea72449e98e055a23597899cb3da889196f2082445f12c817d765d2180d0da93bd4d9c67c513972b4c26047848b7b43a55fbcb3f6ed5017d1ce743c91cd37c5fb7f236fd2551e91212f129a7093f7a33bc004bc4f76c6a92e74f7342ecde08369fe6a956d6460d147c4cf039476faf19e819ca586a7f047e2cbbb488a3f092662b06b04a737de78db704d004405d6eabaa24efde95379d92f4021b567af5ca5ffa1e16cb365fe57fdbbc4db0813749fff3c896cc650c9c4d22db85737b1c2c3510d805ab8ea0dd9c0f910cbe00a1c0e1200bcb18c019f9fa0fec315c7455e0dec193e950322fa10c02c81c294a982f82378c1f54a268e304958cfb43ccea1cb1ddc9a176b58402d328113cab760f872803ea63e0add551757feed2284ddeda678bf98c933052aefe46afee9006c087d74501e9052c69f604b8eff76f056fbc03eac3979ca\n'
[x] Receiving all data
[x] Receiving all data: 0B
[DEBUG] Received 0x29 bytes:
b'hspace{15398c41921c577a779cfac0e5af4ee1}\n'
[x] Receiving all data: 41B
[+] Receiving all data: Done (41B)
[*] Closed connection to 13.125.132.168 port 15151
hspace{15398c41921c577a779cfac0e5af4ee1}