TL;DR
AES-CBC Oracle Padding Attack
- Oracle Noise: 각 오라클 응답에 대해 2x64-bit LFSR 기반 RNG XORed
- Replay protection: 동일한 페이로드 제출 거부
Analysis
from Crypto.Cipher import AESfrom Crypto.Util.Padding import pad, unpadfrom 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- 원격 오라클에 임의 길이 payload 전송 가능.
cipher = AES.new(key,AES.MODE_CBC,iv=iv) try: pt = cipher.decrypt(ct) except: return False유효하지 않은 길이 제출 시 예외를 발생시키고 패딩 검사가 실행되지 않음. 그러나 RNG 비트는 여전히 소모되어 응답에 XOR된다.
- 알려진 LFSR의 피드백 다항식
fb1=11277095078203943143,fb2=12022921549183311343
재전송 방지로 인해 매 쿼리마다 새로운 16바이트 prefix를 생성해야함.
서비스가 주는 를 복호화하여 플래그 회수.
Exploit
1. Extract raw RNG bits
유효하지 않은 길이의 페이로드는 패딩 검사가 실행되지 않는다. 오라클의 응답은 오로지 RNG 비트에만 의존.
o = int(check_padding(key, ct))o ^= rng.bit()- 서비스가 check_padding(key, ct) 결과를 다음 코드와 같이 처리하기에 패딩 검사 결과가 항상 Flase 또는 rng.bit() 와 동일.
- Replay protection으로 인해 페이로드는 항상 유일하므로 랜덤 바이트를 매번 생성한다.
- 위 과정 반복으로 비트 수집 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비트 상태 를 유지하며 매 clock마다 형태로 갱신된다. 여기서 는 피드백의 다항식 계수, 은 bit-shift 응답에서 추출한 는 으로,
으로, 각각의 는 의 상태 비트들의 선형 결합이다. 즉, 모든 출력 비트를 선형방정식으로 표현할 수 있다. 128개의 미지수(각 LFSR 상태의 64비트)에 대해 128개 이상의 독립 방정식을 수집하면 가우스 소거법으로 초기 상태를 정확히 복원한다.
2.1 노이즈 비트 수집
31바이트 임의 바이트열(= 16바이트 IV + 15바이트 암호문)로 오라클을 질의하면 AES 복호화가 실패하여 곧바로 를 얻는다.
- 재사용 방지를 위해 모든 페이로드는 기억해 두고 다시 사용하지 않는다.
2.2 가우스 소거
각 시간 에 대해, 다음과 같은 식을 행으로 갖는 행렬을 만든다.
여기서 는 해당 시점의 LFSR 전이에서 유도된다. 이 128차 선형 시스템을 가우스 소거로 해결하 면 를 모두 복구할 수 있다.
복구된 상태를 이용해 로컬 RNG를 구성하고, 수집에 사용된 질의 수만큼 advance하여 서버와 동일한 타임스텝으로 wjdfuf한다
3. Obtain the target Ciphertext
옵션 1을 한 번 호출해 암호분 를 받는다. 이때 RNG는 소비되지 않으므로 로컬 RNG와 서버 RNG 동기화는 유지된다.
4. Oracle Noise 제거
패딩 오라클을 사용할 때마다 서버는 새로운 를 생성한다. 로컬 RNG에서 동일한 타임스텝의 비트를 통해 XOR하면 clean 패딩이 복원된다.
int(check_padding(key,ct)) ^ rng.bit()재사용 막기 위해 질의마다 무작위 가짜 IV를 붙인다. 실제 질의는
형태가 된다.
주의점
- 모든 쿼리마다 서로 다른 prefix를 붙여 원격 쿼리가 중복되지 않도록 해야한다.
- 응답 을 받으면 로컬 LFSR가 가리키는 예측 RNG 비트 와 XOR:
clean = r ^ p,clean == 1이면 패딩 검사가 성공 - 각 원격 쿼리ㅏ다 RNG가 한 비트 소비되므로 로컬 LFSR도 한 비트를 advance 시킨다.
5. CBC Padding oracle attack
평문 블록을 한 바이트씩 역으로 복원한다. 현재 위치를 라고 한다면 패딩 값은 이다. 이미 얻은 중간 값을 이용해 나머지 바이트를 패딩에 맞춰 조정한다.
그 후 가능한 바이트 후보 에 대해 을 오라클에 넣어 인 후보를 탐색한다. 오진(정확한 표현을 모르겠다)을 줄이기 위해 발견한 후보에 대해 블록을 살짝 변형(예: 한 바이트 XOR 1)한 뒤 다시 질의하여 여전히 성공하면 우연한 패딩 성공으로 간주하고버린다.
패딩 검증에 통과한 후보에서 중간값과 평문 바이트는 다음과 같이 계산된다.
는 을, 은 를 각각 이전 블록으로 사용하여 두 블록 모두를 복호화한다.
복원된 평문
946aeb016f18b1b3000305540b420089d51ad02b359fc1ad9f81feb9c49b18f2이걸 다시 서버에 제출하면 플래그를 받을 수 있다.
import osimport binasciifrom pwn import *
context.log_level = 'debug'
NUM_SAMPLES = 300
FB1 = 11277095078203943143FB2 = 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()- 노이즈 비트 수집: 31바이트 난수 페이로드로 RNG 출력 를 수집.
- 가우스 소거: 수집한 벡터로 128-변수 선형 시스템을 풀어 상태 복원.
- 패딩 오라클 보정: 로컬 RNG로 노이즈를 제거해
- 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: 3630ea06fab1a7e1f9d9d1a15a5195e13c4316d336ac1f0eb3d15a6e4efea773Flag: hspace{god_will_bless_you_XD}[*] Closed connection to 13.125.132.168 port 13372