본문 바로가기

2025 - 2

1주차 워게임 [EZ-Des] 공부

 

먼저, DES 가 뭔지 몰라서 찾아보았다. DES란 Data Encryption Standard. 즉 대칭키 블록 암호화 알고리즘이다. 대칭키이기 때문에 암호화와 복호화에 같은 키를 사용하고, 블록 암호여서 데이터를 64비트씩 처리한다. Feistel 네트워크라는 구조를 사용해서 섞이고 치환하고를 반복하기때문에 일반 암호보다 강력하다고 한다. 다만 키 길이가 짧아서 brute force에 취약하다는 단점이 있다.



일단 정답은 맞는데, 원래 모범적인 문제풀이는 des.py 코드를 해석한 후에 그것을 기반으로 복호화 코드를 짜서 FLAG를 얻어내는 풀이방식이기 때문에 des.py 코드를 봐야한다.

# Flask : 파이썬에서 웹 서버를 만들 수 있는 라이브러리
# → 이걸 쓰면 작은 웹 서비스를 쉽게 만들 수 있음
from flask import Flask, send_file, make_response, render_template_string

# PyCryptodome(파이썬에서 암호학 알고리즘을 쓸 수 있게 해주는 라이브러리)의 DES (암호화 알고리즘)
# → DES : 오래된 대칭키 블록 암호 (64비트/8바이트 단위로 동작)
from Crypto.Cipher import DES

# 랜덤 숫자 관련 라이브러리
# → 여기서는 블록 순서를 섞을 때 사용
import random

# 시간 관련 라이브러리
# → 여기서는 현재 시간을 seed(난수 초기값)로 사용
#    즉, 암호화할 때 쓰인 "섞는 순서"를 시간값으로 결정
import time

# 운영체제 관련 기능 (파일 다루기)
# → 파일 수정 시간 바꾸기, 파일 보내기 등에 사용
import os

# Flask 서버 객체 생성
app = Flask(__name__)

# 파일 이름, 상수 설정
FLAG_FILE = 'flag.txt'          # FLAG가 들어있는 파일 (VM에서는 진짜 FLAG, 로컬은 placeholder)
KEY_FILE = 'keys.txt'          # DES 키들이 들어있는 파일
CIPHER_FILE = 'ciphertext.txt'   # 암호문을 저장할 파일
BLOCK_SIZE = 8                  # DES는 8바이트(64bit) 블록 단위 암호
NUM_BLOCKS = 50                  # 400바이트 / 8바이트 = 50블록 → 문제 설계상 항상 50개 블록으로 고정

# FLAG 읽는 함수
def read_flag(filename=FLAG_FILE, target_len=400):
    # flag.txt를 바이너리 모드로 열어서 읽음
    with open(filename, 'rb') as f:
        flag = f.read()
    # flag가 400바이트보다 짧으면 뒤를 'A'로 채움
    # (padding : 암호화할 때 데이터 길이를 블록 크기에 맞추기 위해 채우는 것)
    # 여기서는 그냥 'A'를 쓰는 "임시 패딩 방식"
    if len(flag) < target_len:
        flag += b'A' * (target_len - len(flag))
    # 정확히 400바이트만 반환 (길이가 더 길면 잘라냄)
    return flag[:target_len]

# keys 읽는 함수
def read_keys(filename=KEY_FILE):
    with open(filename, 'r') as f:
        lines = f.readlines()
    # 각 줄을 16진수(hex)에서 실제 바이트 값으로 변환
    # 예: "4142434445464748" → b"ABCDEFGH"
    keys = [bytes.fromhex(line.strip()) for line in lines if line.strip()]
    # 검증: 키는 반드시 50개이고, 각 키는 8바이트여야 함
    assert len(keys) == NUM_BLOCKS and all(len(k) == 8 for k in keys)
    return keys

# 메시지를 블록 단위로 쪼개는 함수
def split_blocks(msg, block_size=BLOCK_SIZE):
    # 예: b"ABCDEFGH12345678" → [b"ABCDEFGH", b"12345678"]
    return [msg[i:i+block_size] for i in range(0, len(msg), block_size)]

# Triple DES (EDE 방식) 암호화 **Triple DES는 DES를 3번 겹쳐서 돌린 암호화 방식**
def triple_des_ede(block, key):
    # 일반적으로 Triple DES는 서로 다른 3개의 키를 쓰지만
    # 여기서는 같은 키를 3번 반복해서 쓰는 변형
    # EDE = Encrypt → Decrypt → Encrypt
    c1 = DES.new(key, DES.MODE_ECB).encrypt(block)
    c2 = DES.new(key, DES.MODE_ECB).decrypt(c1)
    c3 = DES.new(key, DES.MODE_ECB).encrypt(c2)
    return c3  # 최종 암호 블록 반환

# FLAG 전체를 암호화하는 함수
def encrypt_flag(plaintext, keys, seed):
    # 블록 인덱스 리스트 [0,1,2,...,49]
    idxs = list(range(NUM_BLOCKS))
    # 랜덤 시드를 고정 → 같은 seed면 항상 같은 shuffle 결과 나옴
    random.seed(seed)
    # 블록 순서를 무작위로 섞음
    random.shuffle(idxs)

    # 평문을 8바이트씩 나눔
    blocks = split_blocks(plaintext, BLOCK_SIZE)

    ciphertext_blocks = []
    # 블록마다 대응 키로 Triple DES 암호화 수행
    for i, block in enumerate(blocks):
        # 블록 i → 섞인 순서에 맞는 키 사용
        key = keys[idxs[i]]
        # 해당 키로 암호화
        ct = triple_des_ede(block, key)
        ciphertext_blocks.append(ct)

    # 블록들을 합쳐 최종 암호문 반환
    return b''.join(ciphertext_blocks)

# Flask route: 메인 페이지
@app.route('/')
def home():
    # HTML 직접 리턴 (버튼 2개: ciphertext, keys 다운로드)
    return render_template_string('''
        <h2>다운로드</h2>
        <form action="/download">
            <button type="submit">ciphertext.txt 다운로드</button>
        </form>
        <form action="/keys">
            <button type="submit">keys.txt 다운로드</button>
        </form>
    ''')

# Flask route: ciphertext 다운로드
@app.route('/download')
def download_ciphertext():
    pt = read_flag()          # FLAG 읽기
    keys = read_keys()          # 키들 읽기
    seed = int(time.time())      # 현재 시간을 초 단위 정수로 변환 → seed로 사용

    ct = encrypt_flag(pt, keys, seed) # FLAG 암호화

    tmpfile = 'ciphertext.txt'
    with open(tmpfile, 'wb') as f:
        f.write(ct)              # 암호문을 파일로 저장

    # 파일의 수정 시간(mtime)을 seed 값으로 덮어쓰기
    # → ciphertext 파일의 "속성"에 암호화 seed가 그대로 남음 (취약점!)
    os.utime(tmpfile, (seed, seed))

    # 클라이언트로 암호문 파일 전송
    resp = make_response(send_file(tmpfile, as_attachment=True))

    # 추가: HTTP 헤더에 "X-Used-Seed: <seed>" 달아줌 (사실상 seed 공개)
    resp.headers['X-Used-Seed'] = str(seed)
    return resp

# Flask route: keys 다운로드
@app.route('/keys')
def download_keys():
    # keys.txt 파일 그대로 보내줌
    return send_file(KEY_FILE, as_attachment=True)

# Flask 서버 실행
if __name__ == '__main__':
    # 서버 실행
    # host="0.0.0.0" → 외부에서도 접속 가능하게 함 (로컬 IP뿐 아니라 VM 주소에서도 열림)
    # 기본 포트는 5000
    app.run(host="0.0.0.0")


이 파일은 그냥 플래그를 암호화해서 뿌려주는 서버 코드다. Flask로 만들어져 있어서 켜면 웹페이지 뜨고, 버튼 두 개가 있다. 하나는 ciphertext.txt 다운로드, 하나는 keys.txt 다운로드.

플래그는 flag.txt에서 읽어오는데, 문제 조건 때문에 무조건 400바이트로 맞춘다. 길이가 부족하면 뒤에 A 채워서 딱 400으로 고정. 그 다음 8바이트씩 잘라서 총 50블록을 만든다.

암호화 방식은 Triple DES인데, 보통은 3개 키 쓰지만 여기선 같은 키로 E→D→E 세 번만 돌린다. 그리고 중요한 건 블록 순서를 그냥 쓰는 게 아니라 현재 시간으로 만든 seed로 random.shuffle 해서 섞어버린다.

마지막으로 ciphertext를 파일로 저장하는데, 이때 파일 수정 시간(mtime)을 seed 값으로 바꿔버린다. 그래서 사실상 암호화에 쓰인 seed가 파일 속성에 그대로 박혀 있는 상태...

정리하면, des.py는

1. flag를 읽고 > 400바이트 맞추고,
2. 키 50개 읽어서
3. 블록 순서 섞고 Triple DES로 암호화.
4. ciphertext랑 keys를 다 내놓는다.


근데 seed까지 mtime에 남겨놓는 바람에, 결과적으로는 복호화가 쉬워진다. 암호화 서버인데, 사실상 답안지도 같이 주는 셈.

# solve.py

import os          # 파일 속성(수정 시간, mtime)을 읽기 위해 사용
import random      # seed 고정 + shuffle 재현을 위해 사용
from Crypto.Cipher import DES  # DES 암복호화를 위해 PyCryptodome 라이브러리 사용

# DES는 64bit 블록 암호 → 블록 크기 8바이트
BLOCK_SIZE = 8

# FLAG 길이가 400바이트이므로 (400 / 8 = 50) 총 50개의 블록
NUM_BLOCKS = 50


# 암호문 또는 평문을 블록 단위(8바이트씩)로 잘라내는 함수
def split_blocks(msg, block_size=BLOCK_SIZE):
    # 예: b"ABCDEFGH12345678" → [b"ABCDEFGH", b"12345678"]
    return [msg[i:i+block_size] for i in range(0, len(msg), block_size)]


# Triple DES (EDE) 복호화 함수
# des.py에서 암호화 순서: Encrypt → Decrypt → Encrypt
# 따라서 복호화는 정확히 반대: Decrypt → Encrypt → Decrypt
def triple_des_ede_decrypt(block, key):
    # 1단계: Decrypt
    c1 = DES.new(key, DES.MODE_ECB).decrypt(block)
    # 2단계: Encrypt
    c2 = DES.new(key, DES.MODE_ECB).encrypt(c1)
    # 3단계: Decrypt
    c3 = DES.new(key, DES.MODE_ECB).decrypt(c2)
    return c3


# 특정 seed 값으로 복호화를 시도하는 함수
def try_seed(seed, ciphertext, keys):
    # [0, 1, 2, ..., 49] 인덱스 리스트 생성
    idxs = list(range(NUM_BLOCKS))
    # 시드를 고정해서
    random.seed(seed)
    # 암호화 때와 동일하게 순서를 섞는다
    random.shuffle(idxs)

    # 암호문을 블록 단위로 쪼갠다
    cipher_blocks = split_blocks(ciphertext)

    # 평문 블록을 저장할 공간 초기화
    plain_blocks = [b""] * NUM_BLOCKS

    # 각 암호문 블록을 대응되는 키로 복호화
    for i, block in enumerate(cipher_blocks):
        key = keys[idxs[i]]  # 암호화 때 사용된 것과 같은 키
        # Triple DES 역연산 수행
        plain_blocks[i] = triple_des_ede_decrypt(block, key)

    # 블록들을 합쳐 전체 평문 만들기
    plaintext = b"".join(plain_blocks)

    # 평문 속에서 플래그 패턴(DH{ ... })을 탐색
    start = plaintext.find(b"DH{")
    end = plaintext.find(b"}", start)
    if start != -1 and end != -1:
        # 플래그 문자열 추출해서 반환
        return plaintext[start:end+1]
    return None


def main():
    # 암호문(ciphertext.txt) 읽기 → 400바이트
    ciphertext = open("ciphertext.txt", "rb").read()

    # 키 파일(keys.txt) 읽기 → 50개, 각 줄이 hex 문자열
    keys = [bytes.fromhex(line.strip()) for line in open("keys.txt") if line.strip()]

    # ciphertext.txt 파일의 수정 시간(mtime)이 곧 seed 값
    base_seed = int(os.path.getmtime("ciphertext.txt"))
    print("[*] Base seed:", base_seed)

    # 혹시 시간차 때문에 약간 어긋날 수 있으니 ±3초 범위도 같이 시도
    for delta in range(-3, 4):
        seed = base_seed + delta
        flag = try_seed(seed, ciphertext, keys)
        if flag:
            # 플래그 찾으면 출력
            print("[+] Found FLAG with seed", seed, ":", flag.decode(errors="ignore"))
            break
    else:
        # 주변 seed에서도 못 찾았을 때
        print("[-] FLAG not found in nearby seeds")


if __name__ == "__main__":
    main()

 

 

solve.py는 결국 des.py가 한 일을 반대로 해주는 프로그램이다.

ciphertext와 keys 읽기
서버에서 받은 ciphertext.txt는 400바이트라서 8바이트씩 나누면 50블록이 된다. keys.txt에는 50개의 키가 16진수로 들어있으니, 이걸 바이트로 변환해서 준비한다.


seed 알아내기
암호화에 쓰인 seed는 ciphertext.txt의 수정 시간(mtime) 값이다. solve.py에서는 파일 속성에서 이 값을 읽어내면 된다.
블록 순서 복원. des.py는 random.seed(seed)로 시드를 고정하고 random.shuffle로 블록 순서를 섞었다. solve.py도 똑같이 시드를 고정하고 shuffle을 실행하면 암호화 때 쓰인 순서가 그대로 나온다.


복호화
각 ciphertext 블록을 대응되는 키로 Triple DES(E→D→E)의 역연산을 수행하면 원래 평문 블록이 나온다. 그 블록들을 원래 순서에 맞게 모으면 플래그가 복원된다.


정리하면 solve.py는 ciphertext 읽기, keys 읽기, seed 추출, 순서 복원, 블록 복호화, 최종 플래그 출력이라는 과정을 거쳐야 한다. 그렇게 해서 나온게 아까전의 FLAG고 정답이었던 것!



솔직히 문제를 어떻게 풀어나가야 할지는 눈치채더라도 막상 풀 능력이 없어서 못 푸는 문제가 적지 않은 것 같다... 특히나 암호문은 아직 강의 진도도 안나갔기도 하고 너무 어려워보여서 회피하는 성향이 있었는데 (특히 RSA...) 그래도 하나하나 뜯어보니까 아예 이해 못할 수준은 아니라 조금 안심된 것 같다. (쉽다는 뜻은...) 이번 라업 쓰면서 느끼는게 Python 진짜 친절하다는 것... 저 코드가 다른 언어였다면 이미 포기했을 것 같다... 이썬아 고마워~