TL;DR
256비트 keystream, combiner z3 -> LFSR
Analysis
LFSR 구조
import osimport hashlibfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import padfrom Crypto.Random.random import getrandbits
flag = b"CTF{fake_flag}"
class LFSR: def __init__(self, key, taps): d = max(taps) assert len(key) == d, "Error: key of wrong size." self._s = key self._t = [d - t for t in taps]상태는 길이 d의 비트 리스트 self._s
taps는 탭 위치인데, self._t = [d - t for t in taps]로 한 번 뒤집는다.
e.g. d=17, taps[17,14]이면 self._t=[0,3], 실제로는 s[0], s[3] XOR
def _sum(self, L): s = 0 for x in L: s ^= x return s
def _clock(self): b = self._s[0] self._s = self._s[1:] + [self._sum(self._s[p] for p in self._t)] return b_clock은 맨 앞 비트를 출력하고, 탭 위치 비트를 XOR한 값을 뒤에 붙인다.
LFSR 길이가 , 탭 집합이 일 때
이며, 모든 연산은 에서 이뤄지기 때문에 초기 상태 비트들을 Boolean로 잡았을 때 전이식은 모두 선형 제약식으로 표현 가능
Jeff combiner
class Jeff: def __init__(self, key): assert key.bit_length() <= 102 key = [int(i) for i in list("{:0102b}".format(key))] self.LFSR = [ LFSR(key[:17], [17, 14]), LFSR(key[17:42], [25, 22]), LFSR(key[42:73], [31, 28]), LFSR(key[73:], [29, 27]), ]
def bit(self): b = [lfsr.bit() for lfsr in self.LFSR] return b[1] if b[0]==0 else (b[1] if b[2]==0 and b[3]==0 else (1 if b[1]== 0 else 0))102비트 정수 키를 이진수로 변환한 뒤 17 25 31 29비트로 나누어 각 LFSR에 배정한다. combiner 삼항식: 각 라운드에서 LFSR 출력들을 로 두면
따라서 keystream 가 총 256개 제공되므로 256개의 비선형 Boolean 방정식을 얻는다.
AES
def encrypt_flag(key): md5 = hashlib.md5() md5.update(str(key).encode('ascii')) key = md5.digest() cipher = AES.new(key, AES.MODE_ECB) ciphertext = cipher.encrypt(pad(flag, 16)) data = {} data['encrypted'] = ciphertext.hex() return data
key = getrandbits(102)J = Jeff(key)stream = [J.bit() for _ in range(256)]print(stream)print(encrypt_flag(key))output.txt 첫 줄은 256비트 keystream 배열, 두 번째 줄은 AES 결과 JSON이다.
AES 키는 102비트 정수 키를 알아내야 flag를 복호화할 수 있다. keystream이 이미 256비트로 102비트 seed보다 충분히 길기에 keystream 내부 상태를 역산하는 쪽으로 접근했다.
각 LFSR의 초기 상태 비트를 라 두고 clock을 256번 전개하면 각 LFSR 출력 비트는 모두 초기 상태 비트들의 선형식으로 표현된다.
combiner를 적용하면
LFSR 전이식은
형태이며 이를 적용해 256 step 동안 전재하면 미지수는 102비트(17+25+31+29), 256개의 식이 생긴다. 즉, 과잉 결정된 비선형 Boolean 시스템이 된다.
Exploit
import astfrom hashlib import md5from Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadfrom z3 import *
with open('output.txt') as f: stream = ast.literal_eval(f.readline()) enc = ast.literal_eval(f.readline())['encrypted']
cycles = len(stream)solver = Solver()
def xor_all(bits): out = bits[0] for b in bits[1:]: out = Xor(out, b) return outLFSR 전개 함수
def lfsr(prefix, length, taps): init = [Bool(f'{prefix}_{i}') for i in range(length)] idx = [length - t for t in taps] state = init[:] seq = [] for _ in range(cycles): seq.append(state[0]) state = state[1:] + [xor_all([state[i] for i in idx])] return init, seq
lfsrs = [ lfsr('l1', 17, [17, 14]), lfsr('l2', 25, [25, 22]), lfsr('l3', 31, [31, 28]), lfsr('l4', 29, [29, 27]),]LFSR마다 초기 상태 Bool 변수 배열과 clock 출력 시퀀스를 동시에 생성한다. 탭 인덱스를 length - t로 계산해 문제 코드와 동일하게 구현했다
combiner
for i, bit in enumerate(stream): b0, b1, b2, b3 = [seq[i] for _, seq in lfsrs] solver.add(Xor(b1, And(b0, Or(b2, b3))) == BoolVal(bool(bit)))각 라운드마다 combiner 식을 그대로 제약으로 넣는다. 관측 keystream이 0이면 BoolVal(False), 1이면 BoolVal(True)로 비교하며 매칭한다.
Key Recovery, AES Decrypti0on
solver.check()model = solver.model()key_bits = ''.join('1' if model.eval(b) else '0' for init, _ in lfsrs for b in init)key_int = int(key_bits, 2)
key_md5 = md5(str(key_int).encode()).digest()plaintext = AES.new(key_md5, AES.MODE_ECB).decrypt(bytes.fromhex(enc))print('key =', key_int)print('flag =', unpad(plaintext, 16))각 모델에서 LFSR 초기 상태를 읽어 102비트 이진 문자열을 만든다. str(key_int)를 MD5 해시로 얻은 AES 키를 사용하여 ciphertext를 복호화하고 unpad로 PKCS#7 패딩을 제거해 flag를 확인한다.
python3 solve.pykey = 2998029425760833875497006037011flag = b'CTF{LFSR_means_Losing_Faith_in_Stream_Randomness}'import astfrom hashlib import md5from Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadfrom z3 import Solver, Bool, BoolVal, Xor, And, Or
with open('output.txt') as f: stream = ast.literal_eval(f.readline()) enc = ast.literal_eval(f.readline())['encrypted']
cycles = len(stream)solver = Solver()
def xor_all(bits): out = bits[0] for b in bits[1:]: out = Xor(out, b) return out
def lfsr(prefix, length, taps): init = [Bool(f'{prefix}_{i}') for i in range(length)] idx = [length - t for t in taps] state = init[:] seq = [] for _ in range(cycles): seq.append(state[0]) state = state[1:] + [xor_all([state[i] for i in idx])] return init, seq
lfsrs = [ lfsr('l1', 17, [17, 14]), lfsr('l2', 25, [25, 22]), lfsr('l3', 31, [31, 28]), lfsr('l4', 29, [29, 27]),]
for i, bit in enumerate(stream): b0, b1, b2, b3 = [seq[i] for _, seq in lfsrs] solver.add(Xor(b1, And(b0, Or(b2, b3))) == BoolVal(bool(bit)))
solver.check()model = solver.model()key_bits = ''.join('1' if model.eval(b) else '0' for init, _ in lfsrs for b in init)key_int = int(key_bits, 2)
key_md5 = md5(str(key_int).encode()).digest()plaintext = AES.new(key_md5, AES.MODE_ECB).decrypt(bytes.fromhex(enc))print('key =', key_int)print('flag =', unpad(plaintext, 16))Appendix
import osimport hashlibfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import padfrom Crypto.Random.random import getrandbits
flag = b"CTF{fake_flag}"
class LFSR: def __init__(self, key, taps): d = max(taps) assert len(key) == d, "Error: key of wrong size." self._s = key self._t = [d - t for t in taps]
def _sum(self, L): s = 0 for x in L: s ^= x return s
def _clock(self): b = self._s[0] self._s = self._s[1:] + [self._sum(self._s[p] for p in self._t)] return b
def bit(self): return self._clock()
class Jeff: def __init__(self, key): assert key.bit_length() <= 102 key = [int(i) for i in list("{:0102b}".format(key))] self.LFSR = [ LFSR(key[:17], [17, 14]), LFSR(key[17:42], [25, 22]), LFSR(key[42:73], [31, 28]), LFSR(key[73:], [29, 27]), ]
def bit(self): b = [lfsr.bit() for lfsr in self.LFSR] return b[1] if b[0]==0 else (b[1] if b[2]==0 and b[3]==0 else (1 if b[1]== 0 else 0))
def encrypt_flag(key): md5 = hashlib.md5() md5.update(str(key).encode('ascii')) key = md5.digest() cipher = AES.new(key, AES.MODE_ECB) ciphertext = cipher.encrypt(pad(flag, 16)) data = {} data['encrypted'] = ciphertext.hex() return data
key = getrandbits(102)J = Jeff(key)stream = [J.bit() for _ in range(256)]print(stream)print(encrypt_flag(key))[0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0]{'encrypted': 'fd37825f5f21795a4f6fbb7ce812864808396198cb158e9b5ae7b44c80c6e1371a315a6f90d1fef7b3bfc64b2674db1e692f5c84b3222853351f2048a7ffe65d'}