본문 바로가기

Cryptography/Cryptography CTF

[Dreamhack] Common things between us

문제 설명

There are common things between us... those common prime factors!

 

키워드

Common Modulus Attack, RSA

 

Common Modulus Attack
동일한 모듈러($N$) 값으로 각기 다른 공개지수 $e_1, e_2 (\text{gcd}(e_1, e_2) = 1)$를 사용하여 암호화 된 평문을 알아내는 공격이다.

$C_1 \equiv M^{e_1} \pmod{N}$
$C_2 \equiv M^{e_2} \pmod{N}$

$\text{gcd}(e_1, e_2) = 1$이므로 $s \cdot e_1 + t \cdot e_2 = 1$인 정수 $s,t$가 존재한다.
$s, t$는 Extended Euclidean Algorithm으로 구할 수 있다.

$C_1^s \equiv M^{e_1 \cdot s} \pmod{N}$
$C_2^t \equiv M^{e_2 \cdot t} \pmod{N}$

$C_1^s \times C_2^t \equiv M^{e_1 \cdot s} \times M^{e_2 \cdot t} \equiv M^{e_1 \cdot s + e_2 \cdot t} \equiv M \pmod{N}$

 

소스코드

 

from Crypto.Util.number import getPrime, GCD
import os
import random

random.seed(os.urandom(10))

with open("flag", "rb") as f:
    flag = f.read()

assert 1 < len(flag) < 128

plaintexts = [os.urandom(len(flag)) for _ in range(999)]
last_pt = list(flag)
for i in range(999):
    for j in range(len(flag)):
        last_pt[j] ^= plaintexts[i][j]
plaintexts.append(bytes(last_pt))

mods = [[] for _ in range(1000)]
select = list(range(1000))


e = 0x10001
for _ in range(1000):
    while True:
        prime = getPrime(256)
        if GCD(e, prime - 1) == 1:
            break

    random.shuffle(select)
    choices = select[:4]

    for ch in choices:
        mods[ch].append(prime)
        if len(mods[ch]) == 4:
            select.remove(ch)

mods = [a[0] * a[1] * a[2] * a[3] for a in mods]

for pt, mod in zip(plaintexts, mods):
    pt = int.from_bytes(pt, "big")
    ct = pow(pt, e, mod)

    print(ct, mod)

 

소스코드 분석

 

1. 플래그 쪼개기

plaintexts = [os.urandom(len(flag)) for _ in range(999)] 
last_pt = list(flag)

for i in range(999):
    for j in range(len(flag)):
        last_pt[j] ^= plaintexts[i][j] #

plaintexts.append(bytes(last_pt))
  • 랜덤한 평문 999개를 생성한다.
  • 999개의 랜덤한 평문과 플래그를 XOR 연산한다.
    • $P_{1000} = \text{Flag} \oplus P_1 \oplus P_2 \oplus \dots \oplus P_{999}$
  • 앞 반복문에서 생성한 $P_{1000}$을 plaintexts 리스트에 붙여넣는다.
    • plaintexts 리스트에는 1,000개의 데이터가 들어있게된다.
    • 앞의 999개 ($R_1 \dots R_{999}$): 그냥 랜덤 값, 마지막 1개 ($R_{1000}$): 플래그와 앞의 모든 랜덤 값을 합친(XOR) 값

 

2. RSA 키 생성

mods = [[] for _ in range(1000)]
select = list(range(1000))
  • mods: 1,000개의 빈 리스트를 담고 있는 리스트
  • select: 0부터 999까지의 숫자가 들어있는 리스트
e = 0x10001
for _ in range(1000):
    while True:
        prime = getPrime(256)
        if GCD(e, prime - 1) == 1:
            break

    random.shuffle(select)
    choices = select[:4]

    for ch in choices:
        mods[ch].append(prime)
        if len(mods[ch]) == 4:
            select.remove(ch)
  • 공개지수 : 0x10001
  • 반복문은 1000번 반복된다.
    • 새로운 256비트 소수 prime 하나를 생성한다.
    • e와 p-1이 서로소인지 확인한다. (RSA 조건 검사)
    • select 리스트를 무작위로 섞는다.
    • 무작위로 섞인 select 리스트 앞의 4개를 뽑아서 choices 리스트에 저장한다.
    • 뽑힌 4개의 인덱스(ch)에 해당하는 mods 리스트에 방금 만든 소수 prime을 추가한다.
      • 서로 다른 4개의 mods 인덱스가 같은 소수를 하나씩 나눠 가지게 된다.
      • 만약 어떤 인덱스(ch)의 리스트 길이가 4가 되었다면, select 리스트에서 그 인덱스를 지워버린다.
[예시]

[Loop 1회차]
1. 소수 생성: prime = $P_1$ 생성
2. 셔플 & 선택: select를 섞었더니 앞 4개가 [10, 55, 0, 999]가 나옴.
3. 할당 (mods 업데이트): mods[10] = [$P_1$], mods[55] = [$P_1$], mods[0] = [$P_1$], mods[999] = [$P_1$]
4. 제거 확인: 아무도 4개가 안 찼으므로 select에서 제거 안 함.

[Loop 2회차]
1. 소수 생성: prime = $P_2$ 생성
2. 셔플 & 선택: select를 다시 섞었더니 앞 4개가 [0, 55, 2, 3]이 나옴. (0번, 55번이 또 걸림)
3. 할당: mods[0] = [$P_1, P_2$] (누적됨), mods[55] = [$P_1, P_2$] (누적됨) , mods[2] = [$P_2$], mods[3] = [$P_2$]
4. 제거 확인: 아직 4개 꽉 찬 것 없음.

[...중략...]

[Loop N회차 (누군가 꽉 차는 순간)]
- 가정: 0번(mods[0])이 이미 [$P_1, P_2, P_{50}$] 3개를 가지고 있었음.
1. 소수 생성: prime = $P_{N}$ 생성
2. 셔플 & 선택: select에서 [0, 8, 12, 99]가 뽑힘.
3. 할당: mods[0] = [$P_1, P_2, P_{50}, P_{N}$], 나머지 8, 12, 99번도 $P_{N}$ 추가됨.
4. 제거 확인: len(mods[0])이 4가 됨. select.remove(0) 실행.이제 select 리스트에는 0이 영원히 사라짐. (더 이상 소수 안 받음)

 

 

3. 암호화 및 출력

mods = [a[0] * a[1] * a[2] * a[3] for a in mods]
  • mods 리스트에 들어있는 4개의 소수를 다 곱해서 모듈러스 $N$을 만든다.
  • 모듈러스 $N4은 1024비트 크기가 된다 (256 x 4 = 1024)
for pt, mod in zip(plaintexts, mods):
    pt = int.from_bytes(pt, "big")
    ct = pow(pt, e, mod)

    print(ct, mod)
  • plaintexts: 앞서 만든 1,000개의 평문 조각 리스트 (999개 랜덤 + 1개 XOR 합)
  • mods: 앞서 만든 1,000개의 RSA 모듈러스($N$) 리스트
  • zip: 두 리스트에서 하나씩 꺼내서 짝을 지어준다.
    • 1번째 루프: pt = 0번 평문, mod = 0번 모듈러스($N_0$)
    • 2번째 루프: pt = 1번 평문, mod = 1번 모듈러스($N_1$)
    • ... (1,000번 반복)
  • RSA는 숫자(정수)를 가지고 계산하는 수학 알고리즘입니다. 바이트 상태로는 계산할 수 없다.
  • 따라서 바이트 형태인 pt를 빅 엔디안 방식으로 정수로 변환한다.
  • pow 함수를 사용해 pt를 RSA 암호화해서 ct에 저장한다.
  • 암호문과 공개키 모듈러스를 화면에 출력한다.

 

취약점 분석

보통 RSA는 $N$을 인수분해하는 것이 불가능에 가까워야 안전하지만, 이 코드는 모듈러스들이 소수를 서로 공유하고 있기 때문에 간단한 GCD 연산만으로 모든 $N$을 쉽게 박살낼 수 있다.

 

random.shuffle(select)
    choices = select[:4]  

    for ch in choices:
        mods[ch].append(prime)
  • 정상적인 RSA:  $N_A = p_A \times q_A$$N_B = p_B \times q_B$ (소수가 겹치지 않음).
  • 이 코드의 RSA:
    • $N_A = \mathbf{p} \times q \times r \times s$
    • $N_B = \mathbf{p} \times x \times y \times z$
    • 결과: $N_A$$N_B$가 소수 $\mathbf{p}$를 공유하고 있다.

 

공격

 

공격 시나리오

 

1.  GCD로 소수 추출하기

우리는 총 1000개의 모듈러스 $N_0, N_1, \dots, N_{999}$를 가지고 있다. 이들 중 임의의 두 개, $N_i$$N_j$를 골라 GCD를 구해보자.

$\text{GCD}(N_i, N_j)$

  • CASE 1: 두 모듈러스가 공유하는 소수가 없다 -> 결과는 1
  • CASE 2: 두 모듈러스가 같은 소수 $p$를 받았다 -> 결과는 $p$

이 코드는 모든 소수를 모듈러스 4개씩과 공유하도록 짰기 때문에 1000개의 $N$을 서로서로 GCD를 해보면 모든 소인수를 찾을 수 있게된다.

 

2. 모듈러스 분해

GCD를 통해서 찾은 소수 $p$를 이용해서 $N$을 나눈다.

이 과정을 반복하면, 모든 사용자의 $N$을 구성하는 소수들을 전부 찾아낼 수 있다. 즉 소인수분해 문제가 단순 나눗셈 문제로 변하게 된다.

 

3. 개인키 복구 및 복호화

각 $i$에 대해 4개의 소수 $(p_i, q_i, r_i, s_i)$를 모두 찾았다면, 개인키를 만들 수 있다.

 

        1. 오일러 피 함수 ($\phi$) 계산

$\phi(N_i) = (p_i - 1)(q_i - 1)(r_i - 1)(s_i - 1)$

        2. 개인키 ($d$) 계산

$d_i = e^{-1} \pmod{\phi(N_i)}$

        3. 복호화

$M_i = C_i^{d_i} \pmod{N_i}$

 

이 과정을 1000개에 대해 모두 수행하여, 평문 조각들을 전부 얻어 낸다.

 

4. 최종 플래그 계산

1,000개의 조각($P_1 \dots P_{1000}$)을 다 모았으면 이 조각들을 모두 XOR 한다.

 

$\text{Result} = P_1 \oplus P_2 \oplus \dots \oplus P_{999} \oplus P_{1000}$

 

$P_{1000}$은 아까 우리가 계산해둔 식($\text{Flag} \oplus P_1 \oplus \dots \oplus P_{999}$)과 같다.

 

$\text{Result} = P_1 \oplus P_2 \oplus \dots \oplus P_{999} \oplus (\text{Flag} \oplus P_1 \oplus P_2 \oplus \dots \oplus P_{999})$

 

XOR 연산은 순서를 바꿔도 상관없고(교환법칙), 같은 것을 두 번 하면 사라진다($A \oplus A = 0$).

식에서 같은 것끼리 짝을 지어보면...

 

$\text{Result} = (P_1 \oplus P_1) \oplus (P_2 \oplus P_2) \oplus \dots \oplus (P_{999} \oplus P_{999}) \oplus \text{Flag}$

$\text{Result} = 0 \oplus 0 \oplus \dots \oplus 0 \oplus \text{Flag}$

$\text{Result} = \text{Flag}$

 

공격 코드

import sys
from math import gcd

def solve():
    filename = 'output.txt'
    
    try:
        with open(filename, 'r') as f:
            content = f.read()
    except FileNotFoundError:
        print(f"Error: {filename} not found.")
        return

    # 숫자 파싱
    tokens = content.split()
    numbers = [int(t) for t in tokens if t.isdigit()]
    
    pairs = []
    for i in range(0, len(numbers), 2):
        pairs.append((numbers[i], numbers[i+1]))
    
    # 마지막 1000개 데이터 사용
    if len(pairs) > 1000:
        pairs = pairs[-1000:]
        
    print(f"Loaded {len(pairs)} pairs.")
    mods = [p[1] for p in pairs]
    
    # 1. 공통 소인수 찾기 (Batch GCD)
    primes = set()
    n = len(mods)
    
    for i in range(n):
        if len(primes) >= 1000:
            break
        for j in range(i + 1, n):
            g = gcd(mods[i], mods[j])
            # 합성수(2개 이상의 소수 곱)를 거르기 위해 비트 길이 체크 추가
            # 소수는 256비트이므로 300비트 미만인 것만 수집
            if g > 1 and g < mods[i] and g.bit_length() < 300:
                 primes.add(g)
    
    print(f"Found {len(primes)} unique prime factors.")
    sorted_primes = sorted(list(primes))
    
    # 2. 복호화
    plaintexts = []
    e = 0x10001
    
    for i in range(len(pairs)):
        ct, N = pairs[i]
        
        # 내 모듈러스 N의 소인수 4개 찾기
        factors = []
        for p in sorted_primes:
            if N % p == 0:
                factors.append(p)
                if len(factors) == 4:
                    break
        
        if len(factors) == 4:
            p, q, r, s = factors
            phi = (p-1)*(q-1)*(r-1)*(s-1)
            d = pow(e, -1, phi)
            pt_int = pow(ct, d, N)
            
            # 바이트 변환
            byte_len = (pt_int.bit_length() + 7) // 8
            plaintexts.append(pt_int.to_bytes(byte_len, 'big'))
        else:
            # 실패 시 빈 바이트 (XOR에 영향 없음, 하지만 하나라도 실패하면 플래그 깨짐)
            plaintexts.append(b'')

    # 3. 플래그 XOR 복구
    if not plaintexts:
        return

    max_len = max(len(p) for p in plaintexts)
    final_res = bytearray(max_len)
    
    for pt in plaintexts:
        if not pt: continue
        padded = pt.rjust(max_len, b'\x00')
        for k in range(max_len):
            final_res[k] ^= padded[k]
            
    try:
        print(f"Flag: {final_res.decode('utf-8')}")
    except:
        print(f"Flag (hex): {final_res.hex()}")

if __name__ == "__main__":
    solve()

'Cryptography > Cryptography CTF' 카테고리의 다른 글

[Dreamhack] uncommon e  (0) 2026.01.27
[Dreamhack] TOP secret!  (0) 2026.01.22
[Dreamhack] Textbook-RSA  (0) 2026.01.22
[Dreamhack] safeprime  (0) 2026.01.18
[Dreamhack] Padding Miracle Attack  (0) 2025.11.17