[🥉 Bronze 2] ShiftRows 하나 빼면 AES가 32비트 암호 네 개로 쪼개진다 — DreamHack No shift please! 풀이

2026-08-13·1분 읽기·

[🥉 Bronze 2] ShiftRows 하나 빼면 AES가 32비트 암호 네 개로 쪼개진다 — DreamHack No shift please! 풀이

AES에서 ShiftRows만 제거하면 남은 세 연산이 전부 컬럼 안에서 끝나기 때문에 128비트 블록이 독립적인 32비트 암호 네 개로 분해된다. 서버가 enc(secret) 자체는 복호화해 주지 않지만, 4바이트 청크 하나만 뒤집으면 "다른 암호문"이 되면서 나머지 세 청크는 secret 평문 그대로 돌아온다. 질의 두 번으로 16바이트를 전부 복구했다.

문제: DreamHack — No shift please! 분류: crypto 난이도: 🥉 Bronze 2 FLAG: DH{This_is_actually_pretty_safe_for_a_4btye_cipher.._But_unsafe_ofc}

AES 라운드 함수는 SubBytes · ShiftRows · MixColumns · AddRoundKey 네 개로 이루어진다. 이 중 ShiftRows는 계산량이 가장 적다. 곱셈도 없고 테이블 조회도 없다. 바이트 자리만 옮긴다.

그래서 초심자 눈에는 제일 만만해 보인다. 이 문제는 딱 그 자리를 도려낸다.

드림핵 No shift please! 문제 페이지 — "AES에서 ShiftRows 기능을 없애봤어요" 라는 설명과 함께 VM 접속 정보(host3.dreamhack.games 14466)가 보인다. 이미 해결 처리된 상태
드림핵 No shift please! 문제 페이지 — "AES에서 ShiftRows 기능을 없애봤어요" 라는 설명과 함께 VM 접속 정보(host3.dreamhack.games 14466)가 보인다. 이미 해결 처리된 상태

문제 개요

항목내용
문제명No shift please!
난이도🥉 Bronze 2
분류crypto
제공 파일AES.py(순수 파이썬 AES-128 구현 298줄), chall.py(44줄)
서버nc host3.dreamhack.games <PORT> — 암호화·복호화 오라클
핵심 취약점ShiftRows 제거 → 컬럼 간 확산 소멸 → 32비트 블록암호 4개로 분해
풀이 시점 solver 수365명

문제 설명은 한 줄이다. "AES에서 ShiftRows 기능을 없애봤어요! 이 암호는 과연 안전할까요?"

서버는 랜덤한 16바이트 secret을 만들어 그 암호문을 먼저 알려준다. 그 다음 무제한으로 암호화·복호화를 해 준다. secret을 알아내 암호화 요청으로 되돌려주면 flag가 나온다. 당연히 enc(secret)을 그대로 복호화 요청하는 것은 막혀 있다.

받은 파일은 두 개뿐이다.

ls -la extracted && file extracted/*.py && wc -l extracted/*.py
문제 배포본 확인 — AES.py 19573바이트 298줄, chall.py 1174바이트 44줄. 둘 다 ASCII 파이썬 스크립트다
문제 배포본 확인 — AES.py 19573바이트 298줄, chall.py 1174바이트 44줄. 둘 다 ASCII 파이썬 스크립트다


🧩 배경 — ShiftRows가 하는 일

AES는 16바이트 블록을 4×4 격자로 펴 놓고 라운드마다 네 연산을 돌린다.

  • SubBytes — 바이트 하나를 S-box로 치환. 자리는 안 바뀐다.
  • ShiftRows — 행 r을 왼쪽으로 r칸 회전. 값은 안 바뀐다.
  • MixColumns — 컬럼 4바이트를 GF(2⁸) 행렬과 곱한다. 컬럼 안에서만 섞인다.
  • AddRoundKey — 라운드키와 XOR. 바이트별 연산이다.
AES ShiftRows 단계 — 0번 행은 그대로 두고 1·2·3번 행을 각각 1·2·3칸 왼쪽으로 회전시켜 컬럼끼리 바이트를 교환한다
AES ShiftRows 단계 — 0번 행은 그대로 두고 1·2·3번 행을 각각 1·2·3칸 왼쪽으로 회전시켜 컬럼끼리 바이트를 교환한다

출처: Wikimedia Commons, Matt Crypto, public domain

네 연산 중 컬럼 경계를 넘는 것은 ShiftRows 하나뿐이다. SubBytes는 제자리에서 값만 바꾸고, AddRoundKey도 그렇다. MixColumns는 이름 그대로 컬럼 내부를 섞는다.

ShiftRows가 하는 일이 그래서 중요하다. MixColumns가 컬럼 안에서 만든 변화를 다음 라운드에 다른 컬럼으로 실어 나른다. 이 둘이 번갈아 돌면서 한 바이트의 영향이 두 라운드 만에 16바이트 전체로 퍼진다. AES 설계 문서가 말하는 wide trail이 이 조합이다.

그러니 ShiftRows를 빼면 컬럼끼리 대화할 통로가 사라진다. 이론상 그렇다는 얘기고, 실제로 그런지는 코드를 봐야 안다.



🔬 코드 정찰 — 무엇이 어떻게 잘려 나갔나

chall.py는 짧다. 앞부분이 전부다.

from AES import AES_implemented
import os
 
# For real AES without modification, this challenge is unsolvable with modern technology.
# But let's remove a step.
ret = lambda x: None
AES_implemented._shift_rows = ret
AES_implemented._shift_rows_inv = ret
# Will it make a difference?
 
secret = os.urandom(16)
key = os.urandom(16)
 
flag = open("flag.txt", "r").read()
 
cipher = AES_implemented(key)
 
secret_enc = cipher.encrypt(secret)
assert cipher.decrypt(secret_enc) == secret
print(f"enc(secret) = {bytes.hex(secret_enc)}")

클래스 메서드 두 개를 아무것도 안 하는 람다로 갈아 끼운다. 상속도 서브클래싱도 아니고 클래스 속성 자체를 덮으므로 이후 만들어지는 모든 인스턴스가 영향을 받는다.

grep으로 어디가 잘렸고 원래 어디서 불리는지 한 번에 본다.

grep -n "shift_rows" extracted/chall.py extracted/AES.py
grep 결과 — chall.py 7·8행에서 _shift_rows와 _shift_rows_inv를 덮어쓰고, AES.py 249·253행 암호화 경로와 262·267행 복호화 경로에서 그 메서드가 호출된다
grep 결과 — chall.py 7·8행에서 _shift_rows와 _shift_rows_inv를 덮어쓰고, AES.py 249·253행 암호화 경로와 262·267행 복호화 경로에서 그 메서드가 호출된다

암호화 경로 두 곳(라운드 루프 안 249행, 마지막 라운드 253행), 복호화 경로 두 곳(262·267행). 전부 무력화된다. 남은 메뉴는 이렇다.

while True:
	option = int(input("[1] encrypt, [2] decrypt: "))
 
	if option == 1: # Encryption
		plaintext = bytes.fromhex(input("Input plaintext to encrypt in hex: "))
		assert len(plaintext) == 16
 
		ciphertext = cipher.encrypt(plaintext)
		print(f"enc(plaintext) = {bytes.hex(ciphertext)}")
 
		if plaintext == secret:
			print(flag)
			exit()
 
	elif option == 2: # Decryption
		ciphertext = bytes.fromhex(input("Input ciphertext to decrypt in hex: "))
		assert len(ciphertext) == 16
 
		if ciphertext == secret_enc:
			print("No way!")
			continue
 
		plaintext = cipher.decrypt(ciphertext)
		print(f"dec(ciphertext) = {bytes.hex(plaintext)}")

방어는 한 줄이다. if ciphertext == secret_enc. 16바이트가 통째로 같을 때만 막는다.

노릴 곳은 여기다. 다만 그러려면 "한 바이트만 달라도 통과한다"가 아니라 "달라진 자리 말고는 평문이 그대로 나온다"가 성립해야 한다. 그게 이 문제의 전부다.

AES.py는 진짜 AES인가

먼저 확인할 게 있다. 문제에서 준 AES.py가 어딘가 더 손대진 구현이면 뒤의 분석이 다 헛돈다. 설명에는 강의 코드와 동일하다고만 적혀 있다.

openssl과 같은 키·같은 평문으로 대조했다.

#!/usr/bin/env bash
# extracted/AES.py 가 정말 표준 AES-128-ECB 인지 openssl 로 대조한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
KEY=000102030405060708090a0b0c0d0e0f
PT=00112233445566778899aabbccddeeff
 
MINE=$(python3 -c "
import sys; sys.path.insert(0, 'extracted')
from AES import AES_implemented
print(AES_implemented(bytes.fromhex('$KEY')).encrypt(bytes.fromhex('$PT')).hex())
")
OSSL=$(printf '%s' "$PT" | xxd -r -p | openssl enc -aes-128-ecb -K "$KEY" -nopad | xxd -p)
 
openssl version
echo "key       = $KEY"
echo "plaintext = $PT"
echo "AES.py    = $MINE"
echo "openssl   = $OSSL"
if [ "$MINE" = "$OSSL" ]; then
  echo "-> 일치. 문제의 AES.py 는 손대지 않은 표준 AES-128 이다."
else
  echo "-> 불일치. 구현이 표준과 다르다."
fi
./crosscheck.sh
crosscheck.sh 실행 — OpenSSL 3.5.3과 AES.py가 같은 암호문 69c4e0d86a7b0430d8cdb78070b4c55a를 내놓아 표준 AES-128임이 확인된다
crosscheck.sh 실행 — OpenSSL 3.5.3과 AES.py가 같은 암호문 69c4e0d86a7b0430d8cdb78070b4c55a를 내놓아 표준 AES-128임이 확인된다

69c4e0d86a7b0430d8cdb78070b4c55a. FIPS-197 부록에 실린 AES-128 예제 벡터 그대로다.

AES.py는 하단에 자체 테스트도 달고 있다. PyCryptodome과 랜덤 키·평문 4096쌍을 대조하는 assert 루프인데, 그냥 실행하면 돌아간다.

python3 AES.py && echo "4096 vectors == PyCryptodome  (rc=$?)"
AES.py 자체 테스트 실행 — 랜덤 키·평문 4096쌍을 PyCryptodome과 대조하는 assert 루프가 조용히 통과하고 rc=0으로 끝난다
AES.py 자체 테스트 실행 — 랜덤 키·평문 4096쌍을 PyCryptodome과 대조하는 assert 루프가 조용히 통과하고 rc=0으로 끝난다

출력이 없는 게 정상이다. assert만 도는 루프라 통과하면 아무 말도 안 한다. 받은 구현이 표준 AES라는 건 이걸로 확정.



💣 핵심 — 컬럼이 독립이라는 것을 어떻게 확인하나

먼저, "컬럼"은 입력의 어느 바이트인가

AES.py의 상태 배열은 1차원 리스트 16칸이다. _encrypt가 시작할 때 _transpose를 한 번 거친다.

def _transpose(self, m):
    return [m[4 * j + i] for i in range(4) for j in range(4)]

인덱스 산수를 머리로 풀지 말고 그냥 물어보는 게 빠르다. 입력 자리마다 자기 번호를 넣고 전치를 돌리면 어디로 갔는지 그대로 읽힌다.

#!/usr/bin/env python3
"""AES 상태 배열의 바이트 배치 확인.
 
AES.py 의 _transpose 가 입력 16바이트를 어떤 4x4 격자로 펴는지, 그래서 "컬럼 하나"가
입력의 어느 바이트들인지 실제 함수를 호출해 확인한다. 이 배치를 잘못 잡으면
뒤집을 4바이트를 엉뚱하게 고르게 된다.
"""
import os
import sys
 
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted"))
from AES import AES_implemented
 
cipher = AES_implemented(bytes(16))
 
# 입력 바이트를 '자기 인덱스' 로 채워 두면 어디로 갔는지 그대로 읽힌다.
state = cipher._transpose(list(range(16)))
 
print("_transpose(list(range(16))) =", state)
print()
print("state[4*r + c] 로 본 4x4 격자 (칸 값 = 입력 바이트 번호)")
print("        c=0  c=1  c=2  c=3")
for r in range(4):
    row = "  ".join(f"{state[4 * r + c]:3d}" for c in range(4))
    print(f"  r={r}   {row}")
print()
 
for c in range(4):
    idx = [state[4 * r + c] for r in range(4)]
    print(f"  컬럼 {c} = 입력 바이트 {idx}  -> 연속 구간 [{idx[0]}:{idx[-1] + 1}]")
print()
 
# 라운드키도 _expand_key 끝에서 같은 전치를 거친다. 즉 키 바이트도 같은 규칙으로 컬럼에 들어간다.
probe = AES_implemented(bytes(range(16)))
print("round_keys[0] (전치 후) =", probe._round_keys[0])
print("  -> 마스터키 0..15 가 전치돼 들어갔다. 컬럼 c 가 쓰는 키 바이트도 key[4c:4c+4] 다.")
python3 layout.py
layout.py 출력 — 컬럼 0은 입력 바이트 0·1·2·3, 컬럼 1은 4·5·6·7 식으로 각 컬럼이 연속 4바이트 구간에 대응한다는 것이 격자로 확인된다
layout.py 출력 — 컬럼 0은 입력 바이트 0·1·2·3, 컬럼 1은 4·5·6·7 식으로 각 컬럼이 연속 4바이트 구간에 대응한다는 것이 격자로 확인된다

state[4r+c] = input[4c+r]. 표준 AES가 상태를 컬럼 우선으로 채우는 것과 같다. 결과적으로 컬럼 하나 = 입력의 연속된 4바이트다. 컬럼 0은 [0:4], 컬럼 3은 [12:16].

라운드키도 _expand_key 끝에서 같은 전치를 거치므로 같은 규칙을 따른다. 컬럼 c가 쓰는 키 바이트는 항상 key[4c:4c+4] 쪽에서 나온다.

한 바이트를 흔들면 어디까지 번지나

이론은 정리됐으니 실측한다. 평문 한 바이트를 뒤집고 암호문 16바이트 중 몇 개가 따라 바뀌는지 전부 세면 확산이 그림으로 나온다. 원본 AES와 ShiftRows 제거본을 같은 스크립트로 나란히 돌린다.

#!/usr/bin/env python3
"""ShiftRows 유무에 따른 확산(diffusion) 비교.
 
평문 1바이트를 뒤집었을 때 암호문의 어느 바이트가 따라 바뀌는지 센다.
원본 AES 는 16바이트 전부 바뀌고, ShiftRows 를 뺀 AES 는 같은 4바이트 청크만 바뀐다.
"""
import os
import sys
 
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted"))
from AES import AES_implemented
 
 
def diff_map(cipher, base_pt):
    """평문 바이트 i 를 뒤집었을 때 바뀌는 암호문 바이트 집합을 i 별로 반환."""
    base_ct = cipher.encrypt(base_pt)
    rows = []
    for i in range(16):
        pt = bytearray(base_pt)
        pt[i] ^= 0xFF
        ct = cipher.encrypt(bytes(pt))
        rows.append([a != b for a, b in zip(base_ct, ct)])
    return rows
 
 
def show(title, rows):
    print(f"== {title} ==")
    print("    " + "".join(f"{j:2d}" for j in range(16)) + "   changed")
    for i, row in enumerate(rows):
        marks = "".join(" #" if x else " ." for x in row)
        print(f"p{i:<2d}{marks}   {sum(row):2d}/16")
    print()
 
 
key = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
pt = bytes.fromhex("00112233445566778899aabbccddeeff")
 
show("original AES (ShiftRows 있음)", diff_map(AES_implemented(key), pt))
 
AES_implemented._shift_rows = lambda self: None
AES_implemented._shift_rows_inv = lambda self: None
 
show("patched AES (ShiftRows 제거)", diff_map(AES_implemented(key), pt))
python3 diffusion.py
diffusion.py 출력 비교 — 원본 AES는 평문 한 바이트만 바꿔도 암호문 16/16이 바뀌는데, ShiftRows 제거본은 같은 4바이트 청크 안 4/16만 바뀌어 블록 대각 행렬이 나온다
diffusion.py 출력 비교 — 원본 AES는 평문 한 바이트만 바꿔도 암호문 16/16이 바뀌는데, ShiftRows 제거본은 같은 4바이트 청크 안 4/16만 바뀌어 블록 대각 행렬이 나온다

위쪽 원본 AES는 거의 전부 16/16이다. 눈에 띄는 건 p10과 p15가 15/16인 것. 16바이트 전부 바뀌길 기대했는데 한 칸씩 비어 있다.

우연이다. 확산이 덜 됐다는 뜻이 아니라, 완전히 섞인 값 두 개가 그 자리에서 마침 같은 바이트로 떨어질 확률이 1/256이라 16×16 = 256칸 중 한두 개는 이렇게 나온다. 실제로 평문을 바꿔 다시 돌리면 비는 자리가 옮겨 다닌다.

아래쪽이 이 문제의 답이다. 완벽한 블록 대각 행렬. p0~p3은 c0~c3만, p4~p7은 c4~c7만 건드린다. 전부 4/16. 예외가 하나도 없다.

라운드 단위로 끊어 보기

블록 대각 행렬이 나온 이유를 라운드 안에서 확인하고 싶었다. 재구현하면 의미가 없으니 원본 메서드를 감싸서 연산마다 상태를 찍고, 한 바이트만 다른 두 입력을 나란히 돌려 비교했다.

#!/usr/bin/env python3
"""라운드 연산별 오염(taint) 추적.
 
평문 0번 바이트만 뒤집은 두 입력을 AES.py 의 진짜 코드로 나란히 돌리면서,
각 연산 직후 상태 배열의 어느 칸이 달라졌는지 센다. 재구현이 아니라 원본 메서드를
감싸서 스냅샷만 뜨는 방식이라 실제 구현의 동작 그대로다.
"""
import os
import sys
 
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted"))
from AES import AES_implemented
 
SNAPS = []
OPS = ("_add_round_key", "_sub_bytes", "_shift_rows", "_mix_columns")
 
 
def install_probe():
    for name in OPS:
        orig = getattr(AES_implemented, name)
 
        def wrapper(self, *args, _orig=orig, _name=name):
            out = _orig(self, *args)
            SNAPS.append((_name, list(self._state)))
            return out
 
        setattr(AES_implemented, name, wrapper)
 
 
def trace(key, pt):
    SNAPS.clear()
    AES_implemented(key).encrypt(pt)
    return list(SNAPS)
 
 
def report(title, key, pt):
    a = trace(key, pt)
    b = trace(key, bytes([pt[0] ^ 0xFF]) + pt[1:])
    print(f"== {title} ==")
    print("  step                     오염된 상태 칸 수   오염된 컬럼")
    rnd = 0
    for (name, sa), (_, sb) in zip(a, b):
        if name == "_add_round_key":
            rnd += 1
        idx = [i for i, (x, y) in enumerate(zip(sa, sb)) if x != y]
        cols = sorted({i % 4 for i in idx})  # state[4r+c] 이므로 컬럼 = 인덱스 % 4
        print(f"  r{rnd - 1:<2d} {name:<20s} {len(idx):>3d}/16          {cols}")
        if len(cols) == 4:
            print("       -> 네 컬럼 전부 오염. 여기서 추적을 끊는다.")
            return
    print("       -> 끝까지 한 컬럼에 갇혔다.")
 
 
key = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
pt = bytes.fromhex("00112233445566778899aabbccddeeff")
 
install_probe()
report("original AES — p0 한 바이트를 뒤집었을 때", key, pt)
print()
 
AES_implemented._shift_rows = lambda self: None
AES_implemented._shift_rows_inv = lambda self: None
report("patched AES (ShiftRows 제거) — 같은 입력", key, pt)
python3 taint.py
taint.py 출력 — 원본 AES는 라운드 1의 _shift_rows에서 오염 컬럼이 0에서 0,1,2,3으로 번지는데, 제거본은 열 번째 라운드 마지막 _add_round_key까지 컬럼 0에만 4/16으로 갇혀 있다
taint.py 출력 — 원본 AES는 라운드 1의 _shift_rows에서 오염 컬럼이 0에서 0,1,2,3으로 번지는데, 제거본은 열 번째 라운드 마지막 _add_round_key까지 컬럼 0에만 4/16으로 갇혀 있다

원본 AES를 보면 흐름이 정확히 보인다. _mix_columns가 오염을 1/16에서 4/16으로 늘리고 (컬럼 하나가 통째로 물든다), 그 다음 라운드 _shift_rows에서 컬럼 목록이 [0]에서 [0, 1, 2, 3]으로 뛴다. 두 라운드다.

제거본은 열 번째 라운드 마지막 _add_round_key까지 내려가도 4/16, 컬럼 [0]이다. 번질 곳이 없으니 그대로 갇혀 있다. 확산 그림이 왜 블록 대각인지가 여기서 나온다.

부수 효과 — 4바이트 단위 ECB

컬럼이 독립이면 딸려 오는 결과가 하나 더 있다. 서로 다른 평문이라도 같은 자리에 같은 4바이트를 갖고 있으면 그 자리 암호문도 같아진다. 128비트 블록암호라고 부르지만 실제로는 32비트 블록 네 개를 ECB로 이어붙인 것과 다르지 않다.

#!/usr/bin/env python3
"""컬럼이 독립이면 무슨 일이 벌어지나 — 4바이트 단위 ECB.
 
같은 자리에 같은 4바이트를 가진 서로 다른 평문 두 개를 암호화하면, 그 자리 암호문도
같은 값이 나온다. 128비트 블록암호라고 부르지만 실제로는 32비트 블록 넷을 ECB 로
이어붙인 것과 다르지 않다는 뜻이다.
"""
import os
import sys
 
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "extracted"))
from AES import AES_implemented
 
AES_implemented._shift_rows = lambda self: None
AES_implemented._shift_rows_inv = lambda self: None
 
key = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
cipher = AES_implemented(key)
 
pa = b"AAAAsecret!!BBBB"
pb = b"ZZZZsecret!!YYYY"
 
ca, cb = cipher.encrypt(pa), cipher.encrypt(pb)
 
print(f"평문 A     = {pa!r}")
print(f"평문 B     = {pb!r}")
print(f"암호문 A   = {ca.hex()}")
print(f"암호문 B   = {cb.hex()}")
print()
for c in range(4):
    s = slice(4 * c, 4 * c + 4)
    same_pt = pa[s] == pb[s]
    same_ct = ca[s] == cb[s]
    print(
        f"  청크{c} [{4 * c:2d}:{4 * c + 4:2d}]  평문 {pa[s]!r} vs {pb[s]!r} "
        f"{'같음' if same_pt else '다름'}  ->  암호문 {'같음' if same_ct else '다름'}"
        f"  ({ca[s].hex()} / {cb[s].hex()})"
    )
print()
print("평문이 같은 청크는 암호문도 같다 = 4바이트 단위 ECB.")
 
# 같은 청크를 서로 다른 자리에 두면? 컬럼마다 라운드키가 달라 값이 달라진다.
pc = b"secret!!AAAABBBB"
cc = cipher.encrypt(pc)
print()
print(f"평문 C     = {pc!r}   (같은 'secr' 을 청크0 으로 옮김)")
print(f"암호문 C   = {cc.hex()}")
print(f"  A 의 청크1 {ca[4:8].hex()} vs C 의 청크0 {cc[0:4].hex()} -> "
      f"{'같음' if ca[4:8] == cc[0:4] else '다름'} (컬럼마다 라운드키가 다르다)")
python3 chunk_leak.py
chunk_leak.py 출력 — 평문이 겹치는 청크1·청크2는 암호문도 7f9f1b58·50896950으로 동일하고, 같은 문자열을 다른 자리로 옮기면 라운드키가 달라 값이 달라진다
chunk_leak.py 출력 — 평문이 겹치는 청크1·청크2는 암호문도 7f9f1b58·50896950으로 동일하고, 같은 문자열을 다른 자리로 옮기면 라운드키가 달라 값이 달라진다

'secr'과 'et!!'가 겹치는 청크 1·2는 암호문도 그대로 겹친다. 반대로 같은 'secr'을 다른 자리로 옮기면 값이 달라진다. 컬럼마다 배정되는 라운드키 바이트가 다르기 때문이다.

이 문제를 푸는 데 꼭 필요한 성질은 아니지만, 이 암호가 얼마나 망가졌는지는 이쪽이 더 잘 보여 준다.



🐛 삽질 — 컬럼을 세로로 잡으면

▶🐛 삽질 — AES 그림 기억대로 '0,4,8,12'로 묶으면 두 배로 헤맨다

AES 상태를 그림으로 배운 사람은 "컬럼은 세로"라고 기억한다. 실제로 격자 안에서는 세로가 맞다. 문제는 격자 좌표와 입력 바이트 번호가 다르다는 것이다.

_transpose를 확인하지 않고 넘어가면 뒤집을 4바이트를 [0, 4, 8, 12]로 고르게 된다. 그러면 어떻게 되는지 solve.py와 같은 로직에 인덱스만 갈아 끼워 돌려 봤다.

#!/usr/bin/env python3
"""컬럼을 세로로 잡으면 어떻게 되는지 확인.
 
AES 그림을 떠올리면 상태는 세로로 채워지니 "컬럼 = 0,4,8,12번 바이트" 라고 잡기 쉽다.
그 가정으로 뒤집을 바이트를 고르면 질의 두 번으로는 secret 이 안 나온다.
solve.py 와 같은 로직에 인덱스만 바꿔 로컬 chall.py 로 돌려 본다.
"""
import sys
 
from pwn import context, process
 
context.log_level = "error"
 
MENU = b"[1] encrypt, [2] decrypt: "
MODE = sys.argv[1] if len(sys.argv) > 1 else "vertical"
 
if MODE == "vertical":
    GROUPS = [[c, c + 4, c + 8, c + 12] for c in range(4)]  # 틀린 가정: 세로로 4칸
else:
    GROUPS = [[4 * c + r for r in range(4)] for c in range(4)]  # 실제 배치: 연속 4바이트
 
print(f"[*] mode={MODE}  그룹 = {GROUPS}")
 
io = process(["python3", "chall.py"], cwd="local")
io.recvuntil(b"enc(secret) = ")
secret_enc = bytes.fromhex(io.recvline().strip().decode())
 
 
def decrypt(ct):
    io.sendlineafter(MENU, b"2")
    io.sendlineafter(b"Input ciphertext to decrypt in hex: ", ct.hex().encode())
    io.recvuntil(b"dec(ciphertext) = ")
    return bytes.fromhex(io.recvline().strip().decode())
 
 
def flip_group(ct, g):
    out = bytearray(ct)
    for i in g:
        out[i] ^= 0xFF
    return bytes(out)
 
 
p_last = decrypt(flip_group(secret_enc, GROUPS[3]))
p_first = decrypt(flip_group(secret_enc, GROUPS[0]))
 
# 마지막 그룹을 건드린 질의에서 나머지 세 그룹을, 첫 그룹을 건드린 질의에서 그 그룹을 가져온다.
guess = bytearray(16)
for g in GROUPS[:3]:
    for i in g:
        guess[i] = p_last[i]
for i in GROUPS[3]:
    guess[i] = p_first[i]
guess = bytes(guess)
 
print(f"[*] enc(secret) = {secret_enc.hex()}")
print(f"[*] 조립한 secret 후보 = {guess.hex()}")
 
io.sendlineafter(MENU, b"1")
io.sendlineafter(b"Input plaintext to encrypt in hex: ", guess.hex().encode())
io.recvuntil(b"enc(plaintext) = ")
back = bytes.fromhex(io.recvline().strip().decode())
print(f"[*] enc(후보)    = {back.hex()}")
 
if back == secret_enc:
    print("[+] 일치 — secret 복구 성공")
else:
    bad = sum(1 for x, y in zip(back, secret_enc) if x != y)
    print(f"[-] 불일치 — 16바이트 중 {bad}바이트가 다르다. 이 그룹 나누기는 틀렸다.")
io.close()
python3 wrong_grouping.py vertical
wrong_grouping.py를 vertical 모드로 실행한 결과 — 0,4,8,12 묶음으로 뒤집어 조립한 secret 후보를 다시 암호화하면 16바이트 중 16바이트가 원래 암호문과 달라 실패한다
wrong_grouping.py를 vertical 모드로 실행한 결과 — 0,4,8,12 묶음으로 뒤집어 조립한 secret 후보를 다시 암호화하면 16바이트 중 16바이트가 원래 암호문과 달라 실패한다

16바이트 중 16바이트가 다르다. 조립한 후보가 완전히 틀렸다는 뜻이다.

당연하다. [0, 4, 8, 12]는 격자로 보면 컬럼이 아니라 행이다. 행 하나를 뒤집으면 네 컬럼을 전부 건드리는 셈이라 복호 결과 16바이트가 모두 오염된다. 그래서 하나도 못 건진다.

같은 스크립트에 contiguous를 주면 바로 붙는다. enc(후보)가 enc(secret)과 정확히 일치한다.

여기서 배울 것은 인덱스 산수를 머리로 하지 말라는 것이다. _transpose에 range(16)을 한 번 넣어 보면 30초 만에 끝난다.



🎯 공격 설계 — 질의는 두 번이면 된다

정리된 사실은 두 가지다.

첫째, enc(P)[4c:4c+4]는 P[4c:4c+4]에만 의존한다. 반대 방향도 같다. 복호도 컬럼별로 독립이다.

둘째, 서버의 방어는 ciphertext == secret_enc 완전 일치 하나뿐이다.

그럼 secret_enc에서 청크 하나만 뒤집은 값을 던지면 된다. 전체로는 다른 암호문이라 검사를 통과하고, 손대지 않은 세 청크는 복호 결과에 secret의 평문이 그대로 실려 나온다.

공격 흐름 — 마지막 청크만 뒤집은 질의에서 secret[0:12]를, 첫 청크만 뒤집은 질의에서 secret[4:16]을 얻어 이어 붙이면 16바이트가 완성된다
공격 흐름 — 마지막 청크만 뒤집은 질의에서 secret[0:12]를, 첫 청크만 뒤집은 질의에서 secret[4:16]을 얻어 이어 붙이면 16바이트가 완성된다

질의 1은 마지막 청크를 뒤집는다. 앞 12바이트가 secret[0:12]. 질의 2는 첫 청크를 뒤집는다. 뒤 12바이트가 secret[4:16]. 붙이면 16바이트가 다 모인다.

두 번보다 줄일 수는 없다. 어떤 질의든 secret_enc와 최소 한 청크는 달라야 통과하는데, 그 청크의 평문은 그 질의로는 절대 안 나온다. 그래서 하한이 2다.

한 가지 덤이 있다. 두 질의의 결과는 바이트 [4:12] 구간이 겹친다. 컬럼 독립 가정이 맞다면 그 8바이트는 두 질의에서 같은 값이어야 한다. 익스에 assert로 넣어 두면 공짜 자체 검증이 된다.

뒤집는 값은 아무거나 상관없다. secret_enc의 해당 청크와 다르기만 하면 되므로 0xff XOR로 골랐다.



🚀 Full Exploit

로컬 재현부터 했다. 배포본 chall.py·AES.py를 local/에 복사하고 더미 flag.txt를 넣어 같은 서버를 띄운 뒤, 인자 없이 실행하면 로컬로, 호스트·포트를 주면 원격으로 붙게 짰다.

#!/usr/bin/env python3
"""No shift please! (Dreamhack, Bronze 2 / crypto)
 
ShiftRows 가 빠진 AES 는 128비트 블록 하나가 아니라 독립적인 32비트 암호 4개다.
평문/암호문의 연속 4바이트 청크가 곧 AES 컬럼 1개이고, 남은 세 연산
(AddRoundKey / SubBytes / MixColumns) 은 전부 컬럼 안에서 끝나기 때문이다.
 
서버는 enc(secret) 을 알려주고 복호화 오라클도 열어 주지만 enc(secret) 자체는 거부한다.
그러나 한 청크만 뒤집은 암호문은 "다른 값" 이므로 통과하고, 건드리지 않은 세 청크는
secret 의 평문 그대로 돌아온다. 서로 다른 청크를 뒤집은 질의 두 번이면 16바이트가 다 모인다.
 
사용법:
    python3 solve.py                       # 로컬 재현(local/chall.py)
    python3 solve.py host3.dreamhack.games 12345
"""
import sys
 
from pwn import context, process, remote
 
context.log_level = "info"
 
MENU = b"[1] encrypt, [2] decrypt: "
 
 
def flip(block: bytes) -> bytes:
    """청크 하나를 통째로 뒤집어 '원본과 다른 암호문' 을 만든다."""
    return bytes(b ^ 0xFF for b in block)
 
 
def decrypt(io, ct: bytes) -> bytes:
    io.sendlineafter(MENU, b"2")
    io.sendlineafter(b"Input ciphertext to decrypt in hex: ", ct.hex().encode())
    io.recvuntil(b"dec(ciphertext) = ")
    return bytes.fromhex(io.recvline().strip().decode())
 
 
def encrypt(io, pt: bytes) -> bytes:
    io.sendlineafter(MENU, b"1")
    io.sendlineafter(b"Input plaintext to encrypt in hex: ", pt.hex().encode())
    io.recvuntil(b"enc(plaintext) = ")
    return bytes.fromhex(io.recvline().strip().decode())
 
 
def main():
    if len(sys.argv) >= 3:
        io = remote(sys.argv[1], int(sys.argv[2]))
    else:
        io = process(["python3", "chall.py"], cwd="local")
 
    io.recvuntil(b"enc(secret) = ")
    secret_enc = bytes.fromhex(io.recvline().strip().decode())
    print(f"[*] enc(secret)  = {secret_enc.hex()}")
 
    # 질의 1 — 마지막 청크만 뒤집는다. 앞의 세 청크는 secret[0:12] 그대로 나온다.
    q1 = secret_enc[:12] + flip(secret_enc[12:])
    p1 = decrypt(io, q1)
    print(f"[*] query#1 ct   = {q1.hex()}")
    print(f"[*] query#1 pt   = {p1.hex()}   <- 앞 12바이트가 secret")
 
    # 질의 2 — 첫 청크만 뒤집는다. 뒤의 세 청크는 secret[4:16] 그대로 나온다.
    q2 = flip(secret_enc[:4]) + secret_enc[4:]
    p2 = decrypt(io, q2)
    print(f"[*] query#2 ct   = {q2.hex()}")
    print(f"[*] query#2 pt   = {p2.hex()}   <- 뒤 12바이트가 secret")
 
    secret = p1[:12] + p2[12:]
    print(f"[+] secret       = {secret.hex()}")
 
    # 겹치는 8바이트(4~11)가 두 질의에서 같아야 한다 — 컬럼 독립성의 자체 검증.
    assert p1[4:12] == p2[4:12], "청크 독립 가정이 깨졌다"
 
    # secret 을 그대로 암호화 요청하면 서버가 flag 를 뱉는다.
    ct = encrypt(io, secret)
    print(f"[*] enc(secret') = {ct.hex()}")
    assert ct == secret_enc, "복구한 secret 의 암호문이 enc(secret) 과 다르다"
 
    io.recvuntil(b"DH{")
    flag = "DH{" + io.recvuntil(b"}").decode()
    print(f"[+] FLAG: {flag}")
    io.close()
 
 
if __name__ == "__main__":
    main()
python3 solve.py
solve.py 로컬 실행 — 질의 두 번으로 조립한 secret을 다시 암호화하니 enc(secret)과 같은 값이 나오고 로컬 더미 플래그가 출력된다
solve.py 로컬 실행 — 질의 두 번으로 조립한 secret을 다시 암호화하니 enc(secret)과 같은 값이 나오고 로컬 더미 플래그가 출력된다

query#1 pt와 query#2 pt를 나란히 보면 겹치는 구간이 눈으로도 확인된다. 가운데 caeba271ae86c0dae가 두 줄에 그대로 있다. 뒤집은 청크만 다르다.

로컬에서 붙었으니 라이브 서버로 넘어간다. 인자만 바꾸면 된다.

python3 solve.py host3.dreamhack.games 14466
solve.py 라이브 서버 실행 — 질의 두 번 뒤 enc(secret')가 ffa84f1d3f175bba8e1aeb5ed1dfc355로 일치하고 진짜 플래그가 출력된다
solve.py 라이브 서버 실행 — 질의 두 번 뒤 enc(secret')가 ffa84f1d3f175bba8e1aeb5ed1dfc355로 일치하고 진짜 플래그가 출력된다

enc(secret')이 처음 받은 enc(secret)과 정확히 같다. 복구한 값이 진짜 secret이라는 증거다.

DH{This_is_actually_pretty_safe_for_a_4btye_cipher.._But_unsafe_ofc}

flag 문구가 이 문제를 요약한다. 4바이트 암호로서는 그럭저럭 괜찮지만, 4바이트 암호라는 것 자체가 안전하지 않다.



📝 결론

ShiftRows는 확산을 담당하는 유일한 연산이다.

SubBytes는 자리를 안 옮기고 AddRoundKey도 그렇다. MixColumns는 컬럼 안에서만 섞는다. 컬럼 경계를 넘는 연산은 ShiftRows뿐이라, 이걸 빼면 나머지가 아무리 잘 설계돼 있어도 소용이 없다. taint 추적에서 원본은 두 라운드 만에 네 컬럼을 다 물들이는데 제거본은 열 라운드를 다 돌아도 컬럼 하나에 갇혀 있었다. 라운드 수를 늘려도 마찬가지다. 번질 통로가 없으니 100라운드를 돌려도 32비트 암호 네 개다.

암호 프리미티브에서 "가벼운 단계"를 빼는 최적화는 대개 이렇게 끝난다.

ShiftRows는 곱셈도 테이블 조회도 없어서 연산량으로 보면 제일 싸다. 그래서 임베디드 구현을 줄일 때 손대고 싶은 자리이기도 하다. 하지만 이 문제에서 보듯 비용이 싼 것과 기여가 적은 것은 전혀 다른 얘기다. 라운드 함수는 네 연산이 맞물려 하나의 안전성 논증을 이루기 때문에 하나만 빠져도 논증 전체가 무너진다. 부분 최적화를 했다면 표준 테스트 벡터 통과 여부가 아니라 확산 특성을 다시 측정해야 한다. 이 글의 diffusion.py처럼 단순한 실험으로도 바로 드러난다.

"이 값만 아니면 통과"식 방어는 방어가 아니다.

chall.py가 막은 것은 ciphertext == secret_enc 한 가지다. 뒤집어 말하면 그 값과 1비트라도 다르면 전부 열어 준다. 원래 AES였다면 이 검사만으로도 충분했을 것이다. 1비트만 달라도 복호 결과 16바이트가 전부 무의미한 값이 되니까. 즉 이 방어는 밑에 깔린 암호의 확산 성질에 전적으로 의존하고 있었고, 그 전제가 깨지자 같이 무너졌다. 실제 시스템에서도 블랙리스트 한 줄이 암묵적으로 어떤 성질을 가정하고 있는지 따져 볼 필요가 있다.

블록 크기가 32비트로 줄어든 것 자체가 별개의 문제다.

이번 풀이는 복호화 오라클을 썼지만, 그게 없어도 컬럼 하나의 코드북은 2³² 항목이라 저장 가능한 범위다. 같은 자리에 같은 4바이트가 오면 암호문도 같아지므로 ECB 펭귄과 같은 패턴 누출이 4바이트 단위로 일어난다. chunk_leak.py가 보여 준 게 그것이다. 키를 못 얻어도 평문 구조는 샌다.

분석은 실제 코드를 돌려서 하는 게 빠르다.

_transpose 한 줄을 눈으로 풀다가 컬럼을 세로로 잡으면 익스가 통째로 어긋난다. range(16)을 넣어 보면 30초다. 확산도 마찬가지로, 문서를 읽는 것보다 한 바이트씩 뒤집어 세어 보는 쪽이 확실하다. 원본 메서드를 감싸서 스냅샷만 뜨면 재구현 오류 없이 내부를 들여다볼 수 있다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 4] SubBytes 하나 빼면 AES가 그냥 1차식이 된다 — DreamHack No sub please! 풀이
blog

[🥈 Silver 4] SubBytes 하나 빼면 AES가 그냥 1차식이 된다 — DreamHack No sub please! 풀이

AES의 네 연산 중 비선형인 것은 SubBytes 하나뿐이다. 그걸 빼면 남은 ShiftRows·MixColumns·AddRoundKey가 모두 GF(2) 위의 선형·아핀 연산이라 암호 전체가 M·v 더하기 상수 d 꼴로 주저앉는다. 서버는 enc(secret) 하나만 복호를 거부하지만, XOR이 enc(secret)이 되는 다른 세 블록을 대신 복호시키면 상수항이 홀수 번 남아 secret이 그대로 나온다. 복호 질의 세 번으로 끝났다.
#dreamhack#ctf#crypto+7
2026-08-14#dreamhack +5
[🥈 Silver 3] IV 자리에 키를 넣으면 복호 한 번에 키가 샌다 — DreamHack Textbook-CBC 풀이
blog

[🥈 Silver 3] IV 자리에 키를 넣으면 복호 한 번에 키가 샌다 — DreamHack Textbook-CBC 풀이

AES-CBC 서버가 IV 를 키로 재사용한다. 같은 블록을 IV 가 섞이는 자리와 안 섞이는 자리에 나란히 놓고 복호를 한 번 요청하면 두 평문의 XOR 이 그대로 IV, 즉 키가 된다. 진짜 관문은 그 요청이 unpad() 를 통과하게 만드는 꼬리 블록이었다.
#dreamhack#ctf#crypto+7
2026-08-03#dreamhack +5
[🥉 Bronze 1] assert 한 줄이 MISTY1 을 20비트로 — DreamHack 딸깍 풀이
blog

[🥉 Bronze 1] assert 한 줄이 MISTY1 을 20비트로 — DreamHack 딸깍 풀이

RFC 2994 순정 MISTY1 구현에 assert 한 줄이 얹혀 있다. bytes 비교가 사전식이라 128비트 키가 2^20 으로 잘리고, 그 100만 개를 전수 복호하면 DH{...} 형식 평문이 딱 하나 남는다. RFC 원문과의 기계 대조, 시험벡터 재현, C 독립 구현 교차검증까지.
#dreamhack#ctf#crypto+5
2026-08-20#dreamhack +4