[🥉 Bronze 3] 약관 27개 조문에 숨은 36글자 — DreamHack 서비스 이용 약관 풀이

2026-08-17·1분 읽기·

[🥉 Bronze 3] 약관 27개 조문에 숨은 36글자 — DreamHack 서비스 이용 약관 풀이

hex 한 줄짜리 텍스트 파일을 되돌리면 UTF-8 이 아니라 EUC-KR 로 쓰인 이용약관이 나온다. 본문에는 "이건 플래그가 아니다" 라고 대놓고 적힌 미끼가 박혀 있고, 진짜 플래그는 한글과 구분이 안 가는 전각 ASCII 36글자로 조문 사이에 섞여 있다. NFKC 정규화가 마지막 열쇠다.

문제: DreamHack — 서비스 이용 약관 분류: misc / crypto 난이도: 🥉 Bronze 3 FLAG: DH{Do_Y0u_Know_EUC-KR_enc0ding?}

문제 설명은 이렇다.

드림이는 드림핵에 적용할 새 서비스 이용 약관을 작성했어요. 그런데 이용 약관이 저장된 파일이 잘못된 방식으로 인코딩되어 드림이가 당황스러워하고 있어요. 여러분이 드림이를 도와 이용 약관을 복원해 주세요.

받은 건 tos.txt 하나. 서버도 없고 바이너리도 없다.

"잘못된 방식으로 인코딩" 이라는 한 문장이 문제의 전부인데, 여기서 말하는 인코딩이 한 겹이 아니다. 겉의 hex 를 벗기면 두 번째 인코딩이 나오고, 그걸 벗기면 세 번째가 나온다. 마지막 겹은 눈으로는 절대 안 보인다.

file extracted/tos.txt && wc -c extracted/tos.txt && head -c 96 extracted/tos.txt && echo
file 과 wc 로 본 tos.txt 기본 정보 — ASCII text 이고 51050자짜리 매우 긴 줄 하나에 CRLF 줄바꿈을 쓴다. 앞부분을 찍어 보면 Encoded toc 라는 헤더 뒤에 16진수 문자열이 이어진다
file 과 wc 로 본 tos.txt 기본 정보 — ASCII text 이고 51050자짜리 매우 긴 줄 하나에 CRLF 줄바꿈을 쓴다. 앞부분을 찍어 보면 Encoded toc 라는 헤더 뒤에 16진수 문자열이 이어진다

파일은 51,071바이트인데 file 이 "very long lines (51050)" 이라고 알려 준다. 즉 사실상 한 줄짜리 파일이다. 앞머리에 Encoded toc (hex) 라는 헤더가 붙어 있다. tos 를 toc 로 적어 둔 건 출제자의 오타로 보인다.


문제 개요

항목내용
문제명서비스 이용 약관
난이도🥉 Bronze 3
분류misc / crypto
제공 파일tos.txt (ASCII, 51,071 B)
서버없음 — 완전 오프라인
핵심 기법hex → EUC-KR → 전각(fullwidth) ASCII → NFKC 정규화
함정본문에 DH{...} 모양의 미끼가 박혀 있다

풀이 흐름은 짧다. hex 를 되돌리고, UTF-8 이 아니라 EUC-KR 로 읽고, 복원된 약관에서 한글 사이에 섞인 전각 라틴 문자만 골라내 NFKC 로 정규화하면 끝난다.

짧지만 각 단계마다 한 번씩 걸려 넘어질 자리가 있다.



🧩 배경 — EUC-KR 이라는 "잘못된 인코딩"

지금 한국어 텍스트는 거의 전부 UTF-8 이다. 하지만 UTF-8 이전에는 EUC-KR(과 그 확장인 CP949)이 사실상 표준이었고, 관공서 문서나 오래된 시스템이 뱉는 파일에는 아직도 남아 있다.

둘의 차이는 단순하다.

한글 한 글자ASCII 한 글자첫 바이트
UTF-83바이트1바이트0xEA~0xED
EUC-KR2바이트1바이트0xB0~0xC8 등

"잘못 인코딩됐다" 는 문제 설명은 사실 읽는 쪽이 인코딩을 잘못 고르면 깨진다는 뜻이다. 파일 자체는 멀쩡하고, EUC-KR 로 읽어야 할 걸 UTF-8 로 읽으면 첫 바이트부터 실패한다.

여기까지는 흔한 이야기다. 이 문제가 한 걸음 더 나가는 지점은 뒤에 나온다.


🔬 정찰 — hex 한 줄을 뜯어본다

먼저 파일의 실제 바이트를 본다.

xxd -l 224 extracted/tos.txt
xxd 로 본 tos.txt 앞 224바이트 — Encoded toc 헤더와 CRLF 두 개가 지나면 0x30 부터 0x66 사이 값만 이어진다. 즉 파일 본문은 0-9a-f 로만 이뤄진 16진수 텍스트다
xxd 로 본 tos.txt 앞 224바이트 — Encoded toc 헤더와 CRLF 두 개가 지나면 0x30 부터 0x66 사이 값만 이어진다. 즉 파일 본문은 0-9a-f 로만 이뤄진 16진수 텍스트다

0d 0a 0d 0a 로 헤더가 끝나고, 그 뒤로는 3039, 6166 만 나온다. ASCII 로 09, af. 헤더 말대로 hex 다.

51,050자니까 되돌리면 25,525바이트가 나온다. 짝수라 잘려 있지도 않다.

되돌린 바이트는 무슨 인코딩인가

hex 를 풀고 나서 바로 .decode() 를 부르면 안 된다. 어떤 코덱이 맞는지부터 확인한다. probe_encoding.py 를 만들어 후보를 하나씩 찔러 봤다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""hex 를 되돌린 뒤 어떤 문자 인코딩인지 실제로 찔러본다.
 
부수 효과로 extracted/tos.bin (hex 를 푼 날바이트) 을 남긴다.
iconv 같은 외부 도구로 교차확인할 때 이 파일을 쓴다.
"""
import binascii
from pathlib import Path
 
import chardet
 
HERE = Path(__file__).resolve().parent
TOS = HERE / "extracted" / "tos.txt"
BIN = HERE / "extracted" / "tos.bin"
 
raw = TOS.read_bytes()
hex_line = raw.split(b"\r\n")[2]
blob = binascii.unhexlify(hex_line)
BIN.write_bytes(blob)
print(f"[hex] {len(hex_line)}자 -> {len(blob)}바이트, {BIN.name} 로 저장")
print(f"[hex] 앞 16바이트 : {blob[:16].hex(' ')}")
print()
 
for enc in ("utf-8", "utf-16-le", "euc-kr", "cp949", "johab"):
    try:
        s = blob.decode(enc)
    except UnicodeDecodeError as e:
        print(f"[{enc:<9}] FAIL  {e.reason} @ offset {e.start}")
    except LookupError:
        print(f"[{enc:<9}] SKIP  코덱 없음")
    else:
        print(f"[{enc:<9}] OK    {len(s)}글자 | 첫 줄 = {s.splitlines()[0]}")
 
print()
guess = chardet.detect(blob)
print(f"[chardet] encoding={guess['encoding']} confidence={guess['confidence']:.2f} language={guess['language']}")
python3 probe_encoding.py
probe_encoding.py 실행 결과 — utf-8 은 offset 0 에서 invalid start byte 로 실패하고 utf-16-le 와 johab 도 깨진다. euc-kr 과 cp949 만 15016글자로 디코드되며 첫 줄이 이용약관이다. chardet 도 EUC-KR 을 신뢰도 0.99 로 지목한다
probe_encoding.py 실행 결과 — utf-8 은 offset 0 에서 invalid start byte 로 실패하고 utf-16-le 와 johab 도 깨진다. euc-kr 과 cp949 만 15016글자로 디코드되며 첫 줄이 이용약관이다. chardet 도 EUC-KR 을 신뢰도 0.99 로 지목한다

UTF-8 은 첫 바이트(0xc0)에서 바로 죽는다. 0xc0 은 UTF-8 에서 아예 쓰이지 않는 값이다. EUC-KR 과 CP949 는 둘 다 통과하고 첫 줄이 이용약관 으로 읽힌다. chardet 도 EUC-KR 을 신뢰도 0.99 로 찍는다.

CP949 는 EUC-KR 의 상위호환이라 둘 다 되는 게 정상이다. 이 파일에는 CP949 확장 영역 글자가 없다는 뜻이기도 하다.

파이썬 말고 외부 도구로도 확인

한 도구만 믿기보다 계열이 다른 것으로 한 번 더 본다. iconv 는 glibc 가 제공하는 변환기다.

iconv -f UTF-8 -t UTF-8 extracted/tos.bin | wc -c; iconv -f EUC-KR -t UTF-8 extracted/tos.bin | sed -n "1,4p"
iconv 로 교차확인한 결과 — UTF-8 유효성 검사는 0 위치에 잘못된 입력 순서열이 있다며 0바이트를 내놓고, EUC-KR 에서 UTF-8 로 변환하면 이용약관 제1장 총칙 제1조 목적 조문이 정상적인 한글로 나온다
iconv 로 교차확인한 결과 — UTF-8 유효성 검사는 0 위치에 잘못된 입력 순서열이 있다며 0바이트를 내놓고, EUC-KR 에서 UTF-8 로 변환하면 이용약관 제1장 총칙 제1조 목적 조문이 정상적인 한글로 나온다

iconv -f UTF-8 -t UTF-8 은 "이 바이트열이 유효한 UTF-8 인가" 를 묻는 관용적인 방법이다. 0바이트를 내놓고 오류로 끝난다. EUC-KR 로 지정하면 약관이 그대로 나온다.

주식회사 티오리한국(이하 '회사')가 제공하는 Dreamhack 서비스... — 진짜 약관처럼 생긴 문서다. 문제 설명이 "이 스토리는 픽션" 이라고 못박아 뒀으니 내용은 그대로 믿을 게 아니다.



🐛 삽질 — 두 번 헛짚었다

첫 겹을 벗기고 나면 15,016글자짜리 약관이 손에 들어온다. 여기서 DH 를 찾으면 뭔가 나온다. 그런데 그게 함정이었다.

▶🐛 삽질 1 — 대놓고 박혀 있던 미끼 플래그

복원된 본문을 DH{ 로 찾으면 딱 한 군데 걸린다. 제27조다.

iconv -f EUC-KR -t UTF-8 extracted/tos.bin | grep -a -n -A1 "제27조"
iconv 와 grep 으로 찾은 제27조 — 조문 제목이 플래그이고 바로 다음 줄에 DH 로 시작하는 문자열 하나가 적혀 있는데, 그 문장이 이 문자열은 플래그가 아닙니다 라고 스스로 밝히고 있다
iconv 와 grep 으로 찾은 제27조 — 조문 제목이 플래그이고 바로 다음 줄에 DH 로 시작하는 문자열 하나가 적혀 있는데, 그 문장이 이 문자열은 플래그가 아닙니다 라고 스스로 밝히고 있다

조문 제목이 제27조 (플래그) 다. 그리고 본문은 이렇게 적혀 있다.

문자열 DH{New_Dreamhack_Terms_Of_Service} 는 플래그가 아닙니다.

읽고 나서야 웃었다. 형식도 맞고 내용도 그럴듯해서 그냥 던져 볼 뻔했다. 출제자가 친절하게 아니라고 적어 둔 덕에 제출 시도는 안 했지만, "찾았다" 로 끝내면 안 된다는 신호를 여기서 받은 셈이다.

grep 에 -a 를 붙인 것도 이유가 있다. 이 스트림에는 CR 이 섞여 있어서 GNU grep 이 바이너리로 판정해 버리는 일이 있었다. -a 없이 돌리면 매치가 있는데도 조용히 종료코드 1 로 끝난다.

▶🐛 삽질 2 — 0xA3 을 그냥 grep 했다가 오탐 79개

뒤에 나오지만 진짜 플래그는 EUC-KR 의 A3 행에 들어 있다. 그걸 알고 나서 "그럼 0xA3 바이트만 세면 되겠네" 하고 바이트 단위로 훑었더니 엉뚱한 값이 나왔다.

runs = [m for m in re.finditer(rb'(?:\xa3.)+', b)]   # 43개 — 대부분 가짜

EUC-KR 은 가변 길이다. 0xA3 은 전각 글자의 선두 바이트이기도 하지만, 전혀 다른 한글의 뒷바이트로도 나온다. 파일 전체에서 0xA3 바이트는 79개인데 그 중 실제 선두는 36개뿐이다.

정규식으로 바이트를 훑으면 이 둘을 구분하지 못한다. 첫 바이트부터 순서대로 걸으면서 "지금 이 바이트가 글자의 시작인가" 를 유지해야 한다. solve.py 의 walk_euckr() 가 그 일을 한다.

이런 종류의 착각은 UTF-8 에서는 잘 안 생긴다. UTF-8 은 후속 바이트가 항상 10xxxxxx 라 바이트만 봐도 선두인지 알 수 있게 설계돼 있기 때문이다. EUC-KR 에는 그런 장치가 없다.


💣 핵심 — 한글 사이에 섞인 전각 ASCII

미끼에 속지 않으려면 "약관 본문에 있어서는 안 될 것" 을 찾아야 한다. 정상적인 한국어 약관이라면 글자는 한글 아니면 ASCII다. 그 밖의 것이 남으면 그게 심어진 것이다.

그래서 글자를 종류별로 세는 charscan.py 를 만들었다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""복원된 약관 본문의 글자를 종류별로 세어 '튀는 글자' 를 찾아낸다.
 
약관은 한글 + ASCII 로만 이뤄져야 정상이다. 그 밖의 글자가 남으면
그게 곧 출제자가 심어 둔 것이다.
"""
import unicodedata
from collections import Counter
from pathlib import Path
 
HERE = Path(__file__).resolve().parent
BIN = HERE / "extracted" / "tos.bin"
 
text = BIN.read_bytes().decode("euc-kr")
 
 
def kind(ch: str) -> str:
    o = ord(ch)
    if o < 0x80:
        return "ASCII"
    if 0xAC00 <= o <= 0xD7A3:
        return "한글"
    if 0xFF01 <= o <= 0xFF5E:
        return "전각ASCII"
    return "기타"
 
 
tally = Counter(kind(c) for c in text)
print(f"본문 {len(text)}글자")
for k, n in tally.most_common():
    print(f"  {k:<9} {n:>6}")
print()
 
print("한글/ASCII 가 아닌 글자 (많은 순):")
odd = Counter(c for c in text if kind(c) not in ("ASCII", "한글"))
for ch, n in odd.most_common(12):
    print(f"  U+{ord(ch):04X}  {ch}  x{n:<3} {unicodedata.name(ch, '?')}")
print(f"  ... 총 {len(odd)}종")
print()
 
hits = [i for i, c in enumerate(text) if kind(c) == "전각ASCII"]
print(f"전각 ASCII {len(hits)}글자, 위치 {hits[0]}~{hits[-1]}")
print(f"그 구간 원문 : {text[hits[0]:hits[-1] + 1]}")
python3 charscan.py
charscan.py 실행 결과 — 본문 15016글자 중 한글이 10369개 ASCII 가 4507개인데 전각 ASCII 가 36개 섞여 있다. 유니코드 이름을 함께 찍어 보면 FULLWIDTH LATIN CAPITAL LETTER K 같은 항목이 보이고, 마지막 줄에 전각 구간 원문이 통째로 드러난다
charscan.py 실행 결과 — 본문 15016글자 중 한글이 10369개 ASCII 가 4507개인데 전각 ASCII 가 36개 섞여 있다. 유니코드 이름을 함께 찍어 보면 FULLWIDTH LATIN CAPITAL LETTER K 같은 항목이 보이고, 마지막 줄에 전각 구간 원문이 통째로 드러난다

15,016글자 중 전각 ASCII 가 정확히 36글자다. 위치도 11743~11791 한 군데에 몰려 있다. 그 구간을 그대로 찍어 보면 이렇게 나온다.

⑤ 플래그는 다음 문자열을 NFKC 정규화한 것입니다 : DH{Do_Y0u_…}

제18조 (유료 콘텐츠) 의 다섯 번째 항이다. 유료 콘텐츠 조문 한복판에 플래그 안내가 끼어 있다. ①~⑪ 같은 원문자와 ‘’ 같은 인용부호도 "기타" 로 잡히는데, 그건 약관 문서에 원래 흔한 것들이라 같이 나올 뿐 단서는 아니다.

NFKC 라는 지시어 자체도 전각으로 쓰여 있다는 게 재밌다. 전각 구간 36글자 = 지시어 4글자 + 플래그 32글자다.

세 겹 구조 도식 — hex ASCII 51050자를 unhexlify 하면 EUC-KR 바이트 25525개가 되고 그것을 디코드하면 약관 본문이 된다. 마지막 겹의 전각 문자열을 NFKC 정규화하면 플래그다. 아래 칸은 D 가 1바이트인 반면 전각 D 는 a3 c4 두 바이트임을 대비한다
세 겹 구조 도식 — hex ASCII 51050자를 unhexlify 하면 EUC-KR 바이트 25525개가 되고 그것을 디코드하면 약관 본문이 된다. 마지막 겹의 전각 문자열을 NFKC 정규화하면 플래그다. 아래 칸은 D 가 1바이트인 반면 전각 D 는 a3 c4 두 바이트임을 대비한다

왜 눈에 안 띄었나

전각(fullwidth) 문자는 유니코드 U+FF01~U+FF5E 에 있는, ASCII 를 한글 폭에 맞춰 다시 그린 글자다. D 와 D 는 의미상 같은 글자인데 코드포인트가 다르다.

EUC-KR 에서는 이게 더 얄궂다. 전각 ASCII 는 A3 행에 통째로 들어 있고, 뒷바이트가 정확히 ASCII | 0x80 이다.

글자유니코드EUC-KR 바이트
DU+004444 (1바이트)
DU+FF24a3 c4 (2바이트)
{U+007B7b (1바이트)
{U+FF5Ba3 fb (2바이트)

즉 전각으로 쓰인 플래그는 EUC-KR 안에서 한글과 똑같은 2바이트 글자로 존재한다. grep DH 로도 안 잡히고, strings 로도 안 잡힌다. 실제 바이트를 보면 분명하다.

xxd -s 19968 -l 144 extracted/tos.bin
xxd 로 본 플래그 구간 원본 바이트 — 앞쪽은 c6a3 cba3 c320 같은 평범한 한글 바이트인데 오프셋 0x4e1a 부터 a3c4 a3c8 a3fb 처럼 a3 로 시작하는 쌍이 줄줄이 이어진다. 이 a3 행 구간이 끝나면 다시 0d0a 와 한글 바이트로 돌아간다
xxd 로 본 플래그 구간 원본 바이트 — 앞쪽은 c6a3 cba3 c320 같은 평범한 한글 바이트인데 오프셋 0x4e1a 부터 a3c4 a3c8 a3fb 처럼 a3 로 시작하는 쌍이 줄줄이 이어진다. 이 a3 행 구간이 끝나면 다시 0d0a 와 한글 바이트로 돌아간다

0x4e1a 부터 a3c4 a3c8 a3fb a3c4 a3ef ... 가 이어진다. a3c4 → 0xc4 - 0x80 = 0x44 → D. a3c8 → H. a3fb → {. 뒷바이트에서 0x80 만 빼면 그대로 ASCII 가 된다.

이 규칙 덕분에 유니코드 테이블 없이도 답을 낼 수 있다 — EUC-KR 스트림을 걸으면서 선두 바이트가 0xA3 인 글자만 모으면 된다. solve.py 는 이 경로를 교차검증용으로 따로 돌린다.

마지막 열쇠 — NFKC

본문이 시키는 NFKC 는 유니코드 정규화 형식 중 하나다. NFC 가 "모양이 같은 것을 합치는" 수준이라면, NFKC 는 호환 문자를 대표 문자로 접는다. 전각 D → D, ① → 1, fi 합자 → fi 같은 변환이 여기서 일어난다.

파이썬에서는 unicodedata.normalize("NFKC", s) 한 줄이다. 전각 32글자를 여기에 통과시키면 평범한 ASCII 플래그가 된다.


🎯 교차확인 — CyberChef 로 같은 답이 나오는가

내 스크립트만으로 결론을 내면 스크립트 버그와 정답을 구분할 수 없다. 그래서 완전히 다른 구현으로 한 번 더 확인했다. CyberChef 는 브라우저에서 도는 인코딩 변환 도구다.

플래그가 든 구간의 hex 238자를 잘라 From Hex → Decode text (EUC Korean 51949) 레시피에 넣었다. 레시피와 입력은 URL 해시에 실려 있어 주소만 열면 그대로 재현된다.

CyberChef 브라우저 화면 — 주소창에 From_Hex 와 Decode_text EUC Korean 51949 레시피가 실려 있고 Recipe 패널에 두 단계가 쌓여 있다. Output 에는 해당 조문과 전각 플래그가 폭 넓은 글자로 출력된다
CyberChef 브라우저 화면 — 주소창에 From_Hex 와 Decode_text EUC Korean 51949 레시피가 실려 있고 Recipe 패널에 두 단계가 쌓여 있다. Output 에는 해당 조문과 전각 플래그가 폭 넓은 글자로 출력된다

Output 을 보면 글자 사이가 유난히 벌어져 있다. 그게 전각이라는 증거다. 브라우저 폰트로 렌더해도 DH{ 는 DH{ 보다 눈에 띄게 넓다. 파이썬이 준 답과 같은 문자열이 나왔으니 코덱 선택은 틀리지 않았다.



🚀 Full Exploit

지금까지의 판단을 한 파일로 묶었다. tos.txt 만 있으면 플래그까지 간다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DreamHack "서비스 이용 약관" (Bronze 3, misc/crypto) — solve
 
tos.txt 는 이렇게 생겼다.
 
    Encoded toc (hex)\r\n
    \r\n
    c0ccbfebbee0b0fc0d0ac1a631c0e5...      <- 51050자 한 줄
 
풀이는 세 겹이다.
  1) 헤더를 떼고 hex 를 바이트로 되돌린다.
  2) 그 바이트는 UTF-8 이 아니라 EUC-KR 이다 (= 문제가 말하는 "잘못된 인코딩").
  3) 복원된 약관 본문 안에서 flag 는 '전각(fullwidth) 라틴' 으로 심어져 있다.
     본문 제18조 ⑤ 가 "다음 문자열을 NFKC 정규화한 것" 이라고 알려 준다.
 
EUC-KR 에서 전각 ASCII 는 A3 행에 있고, 뒷바이트는 정확히 (ASCII | 0x80) 이다.
  'D' = 0x44        ->  'D' = 0xA3 0xC4
그래서 EUC-KR 스트림을 걸으며 선두 바이트가 0xA3 인 글자만 모으면
유니코드 테이블 없이도 같은 답이 나온다 (verify_a3_row 가 그걸 확인한다).
"""
import binascii
import sys
import unicodedata
from pathlib import Path
 
HERE = Path(__file__).resolve().parent
TOS = HERE / "extracted" / "tos.txt"
 
 
def load_hex(path: Path) -> bytes:
    """헤더 2줄을 떼고 hex 본문만 바이트로 되돌린다."""
    raw = path.read_bytes()
    parts = raw.split(b"\r\n")
    hex_line = parts[2]
    print(f"[1] {path.name}: {len(raw)}바이트, 헤더={parts[0].decode()!r}")
    print(f"[1] hex 본문 {len(hex_line)}자 -> {len(hex_line)//2}바이트")
    return binascii.unhexlify(hex_line)
 
 
def pick_encoding(blob: bytes) -> str:
    """UTF-8 은 깨지고 EUC-KR 로만 읽힌다는 것을 실제로 보여 준다."""
    for enc in ("utf-8", "euc-kr"):
        try:
            blob.decode(enc)
        except UnicodeDecodeError as e:
            print(f"[2] {enc:<7} 디코드 실패 - {e.reason} (offset {e.start})")
        else:
            print(f"[2] {enc:<7} 디코드 성공")
            good = enc
    return good
 
 
def walk_euckr(blob: bytes):
    """EUC-KR 스트림을 걸으며 (바이트열, 글자) 를 순서대로 내놓는다.
 
    바이트를 그냥 grep 하면 안 된다. 0xA3 은 다른 한글의 '뒷바이트' 로도
    나오기 때문에(파일 전체에 79개, 그 중 선두는 36개뿐) 선두/후행 구분을 지켜야 한다.
    """
    i = 0
    while i < len(blob):
        n = 2 if blob[i] >= 0x80 else 1
        chunk = blob[i:i + n]
        yield chunk, chunk.decode("euc-kr")
        i += n
 
 
def verify_a3_row(blob: bytes) -> str:
    """선두 바이트가 0xA3(전각 ASCII 행)인 글자만 모은다."""
    out = []
    for chunk, ch in walk_euckr(blob):
        if len(chunk) == 2 and chunk[0] == 0xA3:
            out.append(chr(chunk[1] - 0x80))
    naive = blob.count(b"\xa3")
    print(f"[5] 0xA3 을 단순 바이트로 세면 {naive}개 (후행바이트 오탐 포함)")
    print(f"[5] EUC-KR 스트림을 걸어서 고른 전각 글자: {len(out)}개")
    return "".join(out)
 
 
def extract_flag(text: str) -> str:
    """전각 라틴이 연속으로 이어지는 가장 긴 구간을 뽑아 NFKC 정규화한다."""
    runs, cur = [], ""
    for ch in text:
        if 0xFF01 <= ord(ch) <= 0xFF5E:
            cur += ch
        else:
            if cur:
                runs.append(cur)
            cur = ""
    if cur:
        runs.append(cur)
    runs.sort(key=len, reverse=True)
    print(f"[4] 전각 구간 {len(runs)}개, 가장 긴 것 {len(runs[0])}자")
    print(f"[4] 원문 그대로 : {runs[0]}")
    return unicodedata.normalize("NFKC", runs[0])
 
 
def find_decoy(text: str) -> str:
    """제27조가 '이건 플래그가 아니다' 라고 못박아 둔 미끼."""
    decoy = "DH{New_Dreamhack_Terms_Of_Service}"
    line = next(l for l in text.splitlines() if decoy in l)
    print(f"[3] 본문에 박힌 미끼 {decoy}")
    print(f"[3] 제27조 원문 : {line.strip()}")
    return decoy
 
 
def main() -> int:
    blob = load_hex(TOS)
    enc = pick_encoding(blob)
    text = blob.decode(enc)
 
    find_decoy(text)
    flag = extract_flag(text)
 
    a3 = unicodedata.normalize("NFKC", verify_a3_row(blob))
    assert flag in a3, "A3 행 교차검증 실패"
    print(f"[6] 교차검증(0xA3 행 전체) : {a3}")
 
    print()
    print(f"FLAG: {flag}")
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())
python3 solve.py
solve.py 실행 결과 — hex 51050자를 25525바이트로 되돌리고 utf-8 은 실패 euc-kr 은 성공했다고 찍은 뒤, 미끼 문자열과 제27조 원문을 보여 준다. 이어서 전각 구간 32자를 뽑고 A3 행 교차검증까지 통과한 다음 마지막 줄에 플래그가 출력된다
solve.py 실행 결과 — hex 51050자를 25525바이트로 되돌리고 utf-8 은 실패 euc-kr 은 성공했다고 찍은 뒤, 미끼 문자열과 제27조 원문을 보여 준다. 이어서 전각 구간 32자를 뽑고 A3 행 교차검증까지 통과한 다음 마지막 줄에 플래그가 출력된다

[5] 줄이 삽질 2에서 이야기한 그 차이다. 0xA3 바이트는 79개인데 실제 전각 글자는 36개. 두 경로(유니코드 범위로 고르기 / A3 행 바이트로 고르기)가 같은 문자열을 내놓아서 assert 가 통과한다.

[6] 에 NFKC 네 글자가 앞에 붙어 나오는 건 지시어까지 전각이라 같이 딸려 온 것이다.

플래그는 이것이다.

DH{Do_Y0u_Know_EUC-KR_enc0ding?}

♻️ 재현

문제 폴더만 있으면 한 번에 다시 돌아가도록 reproduce.sh 를 남겨 뒀다. 정답과 대조까지 스크립트가 스스로 한다.

#!/usr/bin/env bash
# 서비스 이용 약관 (DreamHack Bronze 3, misc/crypto) — 한 방 재현
# tos.txt 만 있으면 flag 까지 간다. 서버·네트워크 불필요.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
 
if [ ! -f extracted/tos.txt ]; then
  echo "❌ extracted/tos.txt 가 없습니다 (challenge_2189.zip 을 풀어 주세요)"
  exit 1
fi
 
# tos.bin(hex 를 푼 날바이트)은 probe_encoding.py 가 만든다. solve.py 는 tos.txt 만으로도 돈다.
_probe=$(python3 probe_encoding.py 2>&1) || true
 
OUT=$(timeout 120 python3 solve.py 2>&1) || true
 
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | tail -1)
 
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
  echo; echo "✅ PASS  $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
./reproduce.sh
reproduce.sh 실행 결과 — solve.py 출력 마지막 여덟 줄을 그대로 보여 준 다음, 정규식으로 뽑아낸 플래그와 함께 초록색 PASS 표시가 찍히고 종료코드 0 으로 끝난다
reproduce.sh 실행 결과 — solve.py 출력 마지막 여덟 줄을 그대로 보여 준 다음, 정규식으로 뽑아낸 플래그와 함께 초록색 PASS 표시가 찍히고 종료코드 0 으로 끝난다

기대값 EXPECT 는 같은 폴더의 문제.json 에서 읽는데, 이 파일은 백업 단계에서 생긴다. 아직 없는 시점이라 위 실행은 "플래그를 뽑아냈는가" 만 보고 통과했다. 문제.json 이 놓이면 그때부터는 값까지 대조한다.

grep 에서 tail -1 을 쓴 이유도 있다. 출력에 DH{ 로 시작하는 문자열이 두 번 나온다 — 미끼와 진짜. head -1 로 잡으면 미끼를 정답으로 착각한 채 조용히 실패한다.

로컬 환경은 Ubuntu 25.10 / Python 3.13 / chardet 5.2.0 이다. chardet 은 정찰용이라 없어도 solve.py 는 돈다.



📝 결론

미끼는 "형식이 맞는 것" 으로 온다.

이 문제에서 가장 잘 걸리는 자리는 인코딩이 아니라 제27조다. DH{...} 형태를 찾자마자 손이 제출 버튼으로 갔다. 출제자가 아니라고 적어 두지 않았으면 오답 한 번은 먹었을 것이다.

CTF 든 실무든, 패턴 매칭으로 찾은 것은 "찾았다" 가 아니라 "후보를 하나 얻었다" 다.

보이지 않는 글자는 보이는 글자와 같은 취급을 받을 수 있다.

전각 D 와 반각 D 는 사람 눈에도, 대부분의 필터에도 다른 글자다. 그런데 NFKC 정규화를 거치면 같아진다. 이 성질은 실제 취약점의 재료가 된다.

검증을 먼저 하고 정규화를 나중에 하면 그대로 우회가 된다. <script> 는 태그 블랙리스트를 통과하지만 NFKC 를 지나면 <script> 가 된다. 파이썬 식별자, 파일 경로, 사용자명 중복 검사에서 같은 패턴이 반복해서 발견돼 왔다.

순서는 정규화 → 검증이다. 반대로 하면 검사한 문자열과 실제로 쓰이는 문자열이 달라진다.

인코딩을 추측하지 말고 측정한다.

chardet, iconv -f X -t X, CyberChef — 계열이 다른 도구 세 개가 같은 답을 주면 그때 믿는다. 이 문제는 EUC-KR 하나만 맞히면 되는 쉬운 축이지만, 실무에서 만나는 파일은 CP949 와 EUC-KR 이 섞여 있거나 UTF-8 로 두 번 인코딩된 것들이라 한 번에 안 맞는다.

가변 길이 인코딩에서 바이트 단위 검색은 틀린다.

0xA3 을 그냥 세면 79개, 실제로는 36개였다. UTF-8 은 후속 바이트가 10xxxxxx 로 표시돼 있어 바이트만 봐도 경계를 알 수 있지만 EUC-KR 에는 그런 표식이 없다. 레거시 인코딩을 다룰 때는 스트림을 처음부터 걸어야 한다. 이건 파서 구현에서 경계 혼동 취약점이 생기는 자리이기도 하다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 3] 값을 묻지 말고 지시문을 되풀이시킨다 — DreamHack baby-ai 풀이
blog

[🥉 Bronze 3] 값을 묻지 말고 지시문을 되풀이시킨다 — DreamHack baby-ai 풀이

웹 서버 안에서 0.5B 짜리 로컬 모델을 돌리는 챗봇 문제. 플래그는 시스템 프롬프트 첫 줄에 그대로 박혀 있고, 입력에 admin·flag·pass·secret·private 이 들어가면 모델까지 가지도 못한다. 그런데 그 블랙리스트는 질문만 본다. 비밀을 묻는 대신 지시문을 그대로 되풀이하게 시키면 한 줄에 다 나온다. 키릴 문자 한 글자로 부분문자열 검사 자체를 빗나가게 하는 두 번째 경로도 같이 다룬다.
#dreamhack#ctf#web+8
2026-07-29#dreamhack +4
[🥉 Bronze 4] "로봇 인증"이 오히려 로봇에게 판을 깔아줬다 — DreamHack Robot Only 풀이
blog

[🥉 Bronze 4] "로봇 인증"이 오히려 로봇에게 판을 깔아줬다 — DreamHack Robot Only 풀이

숫자 하나를 3초 안에 그대로 되받아 타이핑하면 "로봇 인증"이 완료된다. 그 숫자는 Python random 모듈의 원시 출력 6개를 이어붙여 고정된 상수로 XOR한 값이라, 우리가 화면에서 읽는 순간 그 6개 숫자를 그대로 역산할 수 있다. 그리고 이 인증은 실패해도 몇 번이든 다시 시도할 수 있다 — 104번만 반복하면 Mersenne Twister의 내부 상태를 완전히 복원하는 데 필요한 624개의 연속된 원시값을 전부 모을 수 있다는 뜻이다. 그 뒤로는 서버가 뽑을 도박 게임의 정답을 베팅하기도 전에 알 수 있게 된다.
#dreamhack#ctf#misc+5
2026-07-20#dreamhack +4
[🥉 Bronze 3] 로마자 일본어를 단일치환으로 가린 매칭 퍼즐 — DreamHack Easy Linguistics 풀이
blog

[🥉 Bronze 3] 로마자 일본어를 단일치환으로 가린 매칭 퍼즐 — DreamHack Easy Linguistics 풀이

일본어 명사를 로마자로 적은 뒤 단일치환으로 암호화한 여섯 단어를, 각각의 뜻에 짝짓는 언어학 퍼즐. 두 단어가 공유하는 접미사를 앵커로 삼아 치환표를 세우면 yoasobi·yokaze·yamiyo 같은 원래 단어가 복원된다. 처음엔 글자 두 개가 빈칸으로 남았지만, 나머지 대응을 채우자 마지막 단어까지 tobihi(飛び火, 튀는 불티)라는 진짜 일본어 단어로 딱 떨어졌다. 맞춘 순서가 곧 플래그가 된다.
#dreamhack#ctf#misc+3
2026-07-14#dreamhack +4