Logo
Overview
[CLUB LEAGUE] delpih writeup

[CLUB LEAGUE] delpih writeup

October 11, 2025
7 min read

TL;DR

AES-CBC Oracle Padding Attack

  1. Oracle Noise: 각 오라클 응답에 대해 2x64-bit LFSR 기반 RNG XORed
  2. Replay protection: 동일한 페이로드 제출 거부

Analysis

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from os import urandom, environ
def parity(v):
return bin(v).count("1")&1
class LFSR:
def __init__(self,fb,state):
self.fb = fb
self.state = state
def clock(self):
out = self.state&1
self.state = (parity(self.state&self.fb)<<63)|(self.state>>1)
return out
class RNG:
def __init__(self,state=None):
if state==None:
state = urandom(16)
s1 = int.from_bytes(state[:8],"big")
s2 = int.from_bytes(state[8:],"big")
self.L1 = LFSR(11277095078203943143,s1)
self.L2 = LFSR(12022921549183311343,s2)
def bit(self):
return self.L1.clock()^self.L2.clock()
def check_padding(key,ct):
iv,ct = ct[:16],ct[16:]
cipher = AES.new(key,AES.MODE_CBC,iv=iv)
try:
pt = cipher.decrypt(ct)
except:
return False
padlen = pt[-1]
return pt[-padlen:] == bytes([padlen])*padlen
key = urandom(16)
msg = urandom(32)
rng = RNG()
tried = set()
while True:
inp = input("> ")
if inp == "1":
iv = urandom(16)
cipher = AES.new(key,AES.MODE_CBC,iv=iv)
ct = iv+cipher.encrypt(msg)
print(ct.hex())
elif inp == "2":
ct = input("(hex) > ")
ct = bytes.fromhex(ct)
if ct in tried:
print("No repeat")
continue
tried.add(ct)
o = int(check_padding(key,ct))
o ^= rng.bit()
print(o)
elif inp == "3":
guess = input("(hex) > ")
guess = bytes.fromhex(guess)
if msg == guess:
flag = environ.get("CHALFLAG","hspace{this_is_not_a_real_flag}")
print(flag)
else:
print("No cookie! :(")
break
  1. 원격 오라클에 임의 길이 payload 전송 가능.
cipher = AES.new(key,AES.MODE_CBC,iv=iv)
try:
pt = cipher.decrypt(ct)
except:
return False

유효하지 않은 길이 제출 시 예외를 발생시키고 패딩 검사가 실행되지 않음. 그러나 RNG 비트는 여전히 소모되어 응답에 XOR된다.

  1. 알려진 LFSR의 피드백 다항식 fb1=11277095078203943143, fb2=12022921549183311343

재전송 방지로 인해 매 쿼리마다 새로운 16바이트 prefix를 생성해야함.

서비스가 주는 IVc1c2IV || c_1 || c_2 를 복호화하여 플래그 회수.

Exploit

1. Extract raw RNG bits

유효하지 않은 길이의 페이로드는 패딩 검사가 실행되지 않는다. \rightarrow 오라클의 응답은 오로지 RNG 비트에만 의존.

o = int(check_padding(key, ct))
o ^= rng.bit()
  1. 서비스가 check_padding(key, ct) 결과를 다음 코드와 같이 처리하기에 패딩 검사 결과가 항상 Flase 또는 rng.bit() 와 동일.
  2. Replay protection으로 인해 페이로드는 항상 유일하므로 랜덤 바이트를 매번 생성한다.
  3. 위 과정 반복으로 비트 수집 \rightarrow LFSR 조합 출력의 연속된 출력 비트

2. Recover dual-LFSR state

fb1=11277095078203943143, `fb2=12022921549183311343

class LFSR:
..
def clock(self):
out = self.state&1
self.state = (parity(self.state&self.fb)<<63)|(self.state>>1)
return out
class RNG:
..
def bit(self):
return self.L1.clock()^self.L2.clock()

RNG 출력은 다음 코드처럼 L1.clock()^L2.clock()이다.

각 LFSR은 64비트 상태 s=(s0,s1,,s63)s = (s_0, s_1, \dots, s_{63}) 를 유지하며 매 clock마다 snew=(i=063fisi)s0s1s62s_{\text{new}} = \left(\bigoplus_{i=0}^{63} f_i s_i \right) \parallel s_0 s_1 \dots s_{62} 형태로 갱신된다. 여기서 fif_i는 피드백의 다항식 계수, ()(\parallel)은 bit-shift 응답에서 추출한 ztz_tzt=t(1)t(2)z_t = \ell_t^{(1)} \oplus \ell_t^{(2)} 으로,

으로, 각각의 t(k)\ell_t^{(k)}LkL_k의 상태 비트들의 F2\mathbb{F}_2 선형 결합이다. 즉, 모든 출력 비트를 F2\mathbb{F}_2 선형방정식으로 표현할 수 있다. 128개의 미지수(각 LFSR 상태의 64비트)에 대해 128개 이상의 독립 방정식을 수집하면 가우스 소거법으로 초기 상태를 정확히 복원한다.

2.1 노이즈 비트 수집

31바이트 임의 바이트열(= 16바이트 IV + 15바이트 암호문)로 오라클을 질의하면 AES 복호화가 실패하여 곧바로 ztz_t를 얻는다.

  • 재사용 방지를 위해 모든 페이로드는 기억해 두고 다시 사용하지 않는다.

2.2 가우스 소거

각 시간 tt에 대해, 다음과 같은 식을 행으로 갖는 행렬을 만든다. zt=i=063at,isi(1)i=063bt,isi(2)z_t = \sum_{i=0}^{63} a_{t,i} s^{(1)}_i \oplus \sum_{i=0}^{63} b_{t,i} s^{(2)}_i

여기서 at,i,bt,i{0,1}a_{t,i}, b_{t,i} \in \{0,1\}는 해당 시점의 LFSR 전이에서 유도된다. 이 128차 선형 시스템을 가우스 소거로 해결하 면 s(1),s(2)s^{(1)}, s^{(2)}를 모두 복구할 수 있다.

복구된 상태를 이용해 로컬 RNG를 구성하고, 수집에 사용된 질의 수만큼 advance하여 서버와 동일한 타임스텝으로 wjdfuf한다

3. Obtain the target Ciphertext

옵션 1을 한 번 호출해 암호분 C=IVC1C2C = IV \parallel C_1 \parallel C_2 를 받는다. 이때 RNG는 소비되지 않으므로 로컬 RNG와 서버 RNG 동기화는 유지된다.

C=IVC1C2C = \text{IV} \,\|\, C_1 \,\|\, C_2

4. Oracle Noise 제거

패딩 오라클을 사용할 때마다 서버는 새로운 ztz_t를 생성한다. 로컬 RNG에서 동일한 타임스텝의 비트를 통해 XOR하면 clean 패딩이 복원된다.

int(check_padding(key,ct)) ^ rng.bit()

o^=(response)zt=1valid padding\hat{o} = (\text{response}) \oplus z_t = \mathbf{1}_{\text{valid padding}} 재사용 막기 위해 질의마다 무작위 가짜 IV를 붙인다. 실제 질의는

payload=IVrand16Bcrafted blocktarget block\text{payload} = \underbrace{\text{IV}_{\text{rand}}}_{16\text{B}} \,\|\, \text{crafted block} \,\|\, \text{target block} 형태가 된다.

주의점

  1. 모든 쿼리마다 서로 다른 prefix를 붙여 원격 쿼리가 중복되지 않도록 해야한다.
  2. 응답 rr을 받으면 로컬 LFSR가 가리키는 예측 RNG 비트 pp와 XOR: clean = r ^ p, clean == 1이면 패딩 검사가 성공
  3. 각 원격 쿼리ㅏ다 RNG가 한 비트 소비되므로 로컬 LFSR도 한 비트를 advance 시킨다.

5. CBC Padding oracle attack

평문 블록을 한 바이트씩 역으로 복원한다. 현재 위치를 ii라고 한다면 패딩 값은 p=16ip=16-i 이다. 이미 얻은 중간 값을 이용해 나머지 바이트를 패딩에 맞춰 조정한다.

crafted[j]=intermediate[j]p(j>i)\text{crafted}[j] = \text{intermediate}[j] \oplus p \quad (j > i)

그 후 가능한 바이트 후보 g{0,,255}g \in \{0,\dots,255\} 에 대해 payload=IVrand(crafted with g)Ctarget\text{payload} = \text{IV}_{\text{rand}} \,\|\, (\text{crafted with } g) \,\|\, C_\text{target} 을 오라클에 넣어 o^=1\hat{o} = 1인 후보를 탐색한다. 오진(정확한 표현을 모르겠다)을 줄이기 위해 발견한 후보에 대해 블록을 살짝 변형(예: 한 바이트 XOR 1)한 뒤 다시 질의하여 여전히 성공하면 우연한 패딩 성공으로 간주하고버린다.

패딩 검증에 통과한 후보에서 중간값과 평문 바이트는 다음과 같이 계산된다.

intermediate[i]=gpplaintext[i]=intermediate[i]prev_block[i]\begin{aligned} \text{intermediate}[i] &= g \oplus p \\ \text{plaintext}[i] &= \text{intermediate}[i] \oplus \text{prev\_block}[i] \end{aligned}

C2C_2C1C_1을, C1C_1IVIV를 각각 이전 블록으로 사용하여 두 블록 모두를 복호화한다.

복원된 평문

946aeb016f18b1b3000305540b420089d51ad02b359fc1ad9f81feb9c49b18f2

이걸 다시 서버에 제출하면 플래그를 받을 수 있다.

import os
import binascii
from pwn import *
context.log_level = 'debug'
NUM_SAMPLES = 300
FB1 = 11277095078203943143
FB2 = 12022921549183311343
class LFSR:
def __init__(self, fb, state):
self.fb = fb
self.state = state
def clock(self):
out = self.state & 1
parity = bin(self.state & self.fb).count("1") & 1
self.state = (parity << 63) | (self.state >> 1)
return out
class RNG:
def __init__(self, s1, s2):
self.l1 = LFSR(FB1, s1)
self.l2 = LFSR(FB2, s2)
def bit(self):
return self.l1.clock() ^ self.l2.clock()
def advance(self, steps):
for _ in range(steps):
self.bit()
def lfsr_rows(fb, steps):
coeffs = [1 << i for i in range(64)]
rows = []
for _ in range(steps):
rows.append(coeffs[0])
feedback = 0
for i in range(64):
if (fb >> i) & 1:
feedback ^= coeffs[i]
coeffs = coeffs[1:] + [feedback]
return rows
def solve_states(outputs):
l1_rows = lfsr_rows(FB1, len(outputs))
l2_rows = lfsr_rows(FB2, len(outputs))
rows = [l1_rows[i] | (l2_rows[i] << 64) for i in range(len(outputs))]
nvars = 128
res = outputs[:]
row_idx = 0
pivot_cols = []
for col in range(nvars):
pivot = Non
for r in range(row_idx, len(rows)):
if (rows[r] >> col) & 1:
pivot = r
break
if pivot is None:
continue
rows[row_idx], rows[pivot] = rows[pivot], rows[row_idx]
res[row_idx], res[pivot] = res[pivot], res[row_idx]
for r in range(row_idx + 1, len(rows)):
if (rows[r] >> col) & 1:
rows[r] ^= rows[row_idx]
res[r] ^= res[row_idx]
pivot_cols.append(col)
row_idx += 1
for r in range(row_idx, len(rows)):
if rows[r] == 0 and res[r]:
raise SystemExit("system inconsistent")
solution = [0] * nvars
for idx in range(len(pivot_cols) - 1, -1, -1):
col = pivot_cols[idx]
row = rows[idx]
total = res[idx]
mask = row >> (col + 1)
j = col + 1
while mask:
if mask & 1:
total ^= solution[j]
mask >>= 1
j += 1
solution[col] = total
l1_state = sum((solution[i] & 1) << i for i in range(64))
l2_state = sum((solution[64 + i] & 1) << i for i in range(64))
return l1_state, l2_state
def oracle_bit(p, payload):
# 메뉴 2: oracle raw bit
p.sendline(b"2")
p.recvuntil(b"(hex) > ")
p.sendline(binascii.hexlify(payload))
resp = p.recvline().strip()
if resp == b"No repeat":
p.recvuntil(b"> ")
return None
p.recvuntil(b"> ")
return int(resp)
def oracle_clean(p, rng, crafted_block, target_block):
while True:
iv_rand = os.urandom(16)
payload = iv_rand + crafted_block + target_block
raw = oracle_bit(p, payload)
if raw is None:
continue
bit = rng.bit()
return (raw ^ bit) == 1
def recover_block(p, rng, ct_block, prev_block):
block_size = 16
intermediate = [0] * block_size
plaintext = [0] * block_size
crafted = [0] * block_size
for index in range(block_size - 1, -1, -1):
pad = block_size - index
for j in range(index + 1, block_size):
crafted[j] = intermediate[j] ^ pad
for guess in range(256):
crafted[index] = guess
if not oracle_clean(p, rng, bytes(crafted), ct_block):
continue
# 확인 쿼리: 우연한 성공 배제
tweak = bytearray(ct_block)
tweak[index] ^= 1
if oracle_clean(p, rng, bytes(crafted), bytes(tweak)):
continue
intermediate[index] = guess ^ pad
plaintext[index] = intermediate[index] ^ prev_block[index]
break
else:
raise SystemExit("failed to recover byte")
return bytes(plaintext)
def main():
p = remote("13.125.132.168", 13372)
p.recvuntil(b"> ")
seen = set()
rng_outputs = []
while len(rng_outputs) < NUM_SAMPLES:
payload = os.urandom(31)
if payload in seen:
continue
seen.add(payload)
raw = oracle_bit(p, payload)
if raw is None:
continue
rng_outputs.append(raw)
l1_state, l2_state = solve_states(rng_outputs)
rng = RNG(l1_state, l2_state)
rng.advance(NUM_SAMPLES)
p.sendline(b"1")
ct_line = p.recvline().strip()
p.recvuntil(b"> ")
cipher = bytes.fromhex(ct_line.decode())
iv = cipher[:16]
c1 = cipher[16:32]
c2 = cipher[32:48]
p2 = recover_block(p, rng, c2, c1)
p1 = recover_block(p, rng, c1, iv)
message = p1 + p2
p.sendline(b"3")
p.recvuntil(b"(hex) > ")
p.sendline(message.hex().encode())
flag = p.recvline().strip().decode()
print("Plaintext:", message.hex())
print("Flag:", flag)
p.close()
if __name__ == "__main__":
main()
  1. 노이즈 비트 수집: 31바이트 난수 페이로드로 RNG 출력 ztz_t를 수집.
  2. 가우스 소거: 수집한 ztz_t 벡터로 128-변수 GF(2)GF(2) 선형 시스템을 풀어 L1,L2L_1, L_2 상태 복원.
  3. 패딩 오라클 보정: 로컬 RNG로 노이즈를 제거해
  4. CBC 패딩 오라클 복호화: 표준 바이트 단위 복호화 루틴으로 두 블록 평문 추출.
[DEBUG] Sent 0x61 bytes:
b'abb7c9128820cb705e9d0abbe622ee638646ca5830b0c56b95408f51ffeddd3edfaee0604ddf80793cfe19288b5964ca\n'
[DEBUG] Received 0x4 bytes:
b'0\n'
b'> '
[DEBUG] Sent 0x2 bytes:
b'3\n'
[DEBUG] Received 0x8 bytes:
b'(hex) > '
[DEBUG] Sent 0x41 bytes:
b'3630ea06fab1a7e1f9d9d1a15a5195e13c4316d336ac1f0eb3d15a6e4efea773\n'
[DEBUG] Received 0x1e bytes:
b'hspace{god_will_bless_you_XD}\n'
Plaintext: 3630ea06fab1a7e1f9d9d1a15a5195e13c4316d336ac1f0eb3d15a6e4efea773
Flag: hspace{god_will_bless_you_XD}
[*] Closed connection to 13.125.132.168 port 13372