문제: DreamHack — Flying Chars 분류: Web (클라이언트 사이드 정보 노출) 난이도: 🌱 새싹 (Sprout) FLAG:
DH{Too_H4rd_to_sEe_th3_Ch4rs_x.x}
페이지를 열면 작은 보라색 점들이 화면을 가로질러 날아다닌다. 자세히 보면 점 하나하나가 글자 같기도 한데, 10픽셀짜리에 속도까지 제각각이라 도무지 읽을 수가 없다. 문제 이름 그대로 "날아다니는 글자들"이다.

소스 파일은 제공되지 않는다. 서버 주소 하나만 주고 알아서 보라는 블랙박스 문제다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Flying Chars |
| 난이도 | 🌱 새싹 (Sprout) |
| 분류 | Web — 클라이언트 사이드 정보 노출 |
| 제공 | 서버 URL만 (소스 비공개) |
| 스택 | Python / Werkzeug(Flask) — 정적 파일 서빙 |
| 핵심 취약점 | 플래그를 이미지 조각으로 잘라 클라이언트에서만 "안 보이게" 처리 |
풀이 흐름은 짧다. 페이지가 불러오는 이미지 20장을 받아서, 올바른 순서로 이어 붙이면 그게 곧 플래그다. 관건은 그 "올바른 순서"가 파일명 숫자가 아니라는 점 하나다.
🧩 배경 — 글자를 날려서 가리는 페이지
페이지가 하는 일은 단순하다. /static/images/0.png 부터 19.png 까지 20장을 불러와 화면에 띄우고, 각각을 requestAnimationFrame 으로 오른쪽으로 계속 밀어낸다. 끝까지 가면 다시 0으로 돌아온다. 속도는 이미지마다 난수라 정렬이 안 맞는다.
CSS 로 이미지 크기를 width: 10px; height: 10px 로 못 박아둔 것도 핵심이다. 원본이 어떻든 화면에서는 10픽셀짜리 점으로 짜부라진다. 글자처럼 안 보이게 만드는 장치다.
잠깐 기다렸다가 다시 보면 점들이 다른 자리로 흘러가 있다. 정말로 "날아다니는" 중이다.

여기서 중요한 건, 화면이 어수선할 뿐 이미지 20장 자체는 그대로 서버에 정적 파일로 올라가 있다는 사실이다. 애니메이션은 보는 사람만 헷갈리게 할 뿐, 데이터를 바꾸지 않는다.
🔬 코드 정찰 — 페이지가 진짜로 하는 일
브라우저에서 페이지 소스를 열면 <script> 안에 모든 게 들어 있다. 핵심은 이미지 목록을 담은 배열과, 그걸 하나씩 화면에 붙이는 반복문이다.
<script type="text/javascript">
const img_files = ["/static/images/10.png", "/static/images/17.png",
"/static/images/13.png", "/static/images/7.png", "/static/images/16.png",
"/static/images/8.png", "/static/images/14.png", "/static/images/2.png",
"/static/images/9.png", "/static/images/5.png", "/static/images/11.png",
"/static/images/6.png", "/static/images/12.png", "/static/images/3.png",
"/static/images/0.png", "/static/images/19.png", "/static/images/4.png",
"/static/images/15.png", "/static/images/18.png", "/static/images/1.png"];
var imgs = [];
for (var i = 0; i < img_files.length; i++){
imgs[i] = document.createElement('img');
imgs[i].src = img_files[i];
imgs[i].style.width = '10px'; // 10px 로 짜부라뜨림
imgs[i].style.height = '10px';
document.getElementById('box').appendChild(imgs[i]);
}
// ... 각 imgs[i] 를 translateX 로 난수 속도만큼 밀어냄
</script>눈에 띄는 건 img_files 배열의 순서다. 10.png, 17.png, 13.png, ... — 파일명 숫자가 뒤죽박죽이다. 이게 우연이 아니라는 직감이 든다. 브라우저는 이 배열 순서대로 <img> 를 만들어 #box 에 붙인다. 즉 배열 순서 = 화면에 쌓이는 순서(DOM 순서) 다.
배열 순서만 따로 뽑아보면 이렇다.
curl -s http://host3.dreamhack.games:23625/ | grep -oP '(?<=images/)\d+' | tr '\n' ' '
그리고 이미지 한 장을 실제 크기로 직접 열어보면 정체가 드러난다. /static/images/1.png 는 10픽셀 점이 아니라 202×89 짜리, 글자 몇 개가 박힌 조각이다.

한 장에 글자가 두세 개씩 들어 있기도 하다. 플래그 한 줄을 통으로 렌더한 다음, 글자 경계와 상관없이 세로로 쭉쭉 잘라낸 조각들인 셈이다. 그러니 조각을 올바른 순서로 가로로 이어 붙이면 원래 한 줄이 되살아난다.
🐛 삽질 — 파일명 숫자로 맞췄더니 깨진 문장
처음엔 당연히 파일명 순서대로 맞추면 될 거라고 생각했다. 0.png, 1.png, 2.png ... 19.png 를 차례로 이어 붙이는 것이다.
▶🐛 삽질 — 0.png → 19.png 숫자순 조립
20장을 받아 파일명 숫자순으로 한 칸씩 늘어놓으면 이렇게 보인다.

이걸 그대로 가로로 이어 붙이면 문장이 깨진다.

_x.xth3h4sE_Hrdo_Teto_r4os_C — 군데군데 단어 비슷한 게 보이지만(th3, Teto) 전체로는 말이 안 된다. 파일명 숫자는 조각에 붙은 무작위 ID 일 뿐, 읽는 순서가 아니었다.
깨진 문자열을 노려보다 보면 단서가 보인다. th3, h4, Ch4rs, Teto 같은 조각이 분명 들어 있는데 순서만 어긋나 있다. 순서를 알려주는 정보는 이미 정찰 단계에서 봤다 — img_files 배열이다.
💣 핵심 — 읽는 순서는 파일명이 아니라 DOM 순서
정리하면 서버는 이런 과정을 거쳐 플래그를 "가렸다".

플래그 한 줄을 통이미지로 렌더한 뒤 세로로 20등분하고, 각 조각에 0~19 번호를 무작위로 붙여 저장한다. 그리고 페이지에서는 원래 순서(img_files 배열)대로 <img> 를 만들어 붙이되, 화면에서는 10픽셀로 줄이고 난수 속도로 날려 사람 눈을 따돌린다.
그러니 복원은 파일명을 무시하고 img_files 배열 순서 그대로 조각을 이어 붙이면 된다. 배열의 앞쪽 몇 장만 순서대로 원본 크기로 확인해 보면 바로 와닿는다. 배열 첫 번째는 10.png 인데 열어 보면 T 이고,

네 번째 7.png 는 밑줄+H 다.

T + oo + _H + 4 + rd ... 배열 순서로 읽으니 Too_H4rd... 가 만들어진다. "Too hard"다. 문장이 잡힌다.
🎯 풀이 — img_files 순서대로 이어 붙이기
손으로 할 일은 없다. 페이지에서 img_files 배열을 그대로 긁어, 그 순서대로 20장을 받아 가로로 이어 붙이는 스크립트면 끝난다. 조각마다 높이가 달라서 같은 높이로만 맞춰주면 글자 줄이 어긋나지 않는다.
# solve.py
import re, sys
from io import BytesIO
from urllib.request import urlopen
from PIL import Image
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:23625").rstrip("/")
# 1) 루트 HTML 에서 img_files 배열을 그대로 파싱 (화면에 붙이는 순서)
html = urlopen(BASE + "/", timeout=15).read().decode()
arr = re.search(r"img_files\s*=\s*\[(.*?)\]", html, re.S).group(1)
order = re.findall(r'/static/images/(\d+)\.png', arr) # ['10','17','13', ...]
print(f"[*] img_files 표시 순서 ({len(order)}장):\n " + " ".join(order))
# 2) 배열 순서대로 받아서 같은 높이로 정규화
tiles = []
for n in order:
raw = urlopen(f"{BASE}/static/images/{n}.png", timeout=15).read()
im = Image.open(BytesIO(raw)).convert("RGB")
im = im.resize((round(im.width * 100 / im.height), 100))
bg = Image.new("RGB", im.size, "white"); bg.paste(im, (0, 0))
tiles.append(bg)
print(f" received {n}.png {im.width}x{im.height}")
# 3) 그 순서 그대로 가로로 이어 붙인다
strip = Image.new("RGB", (sum(t.width + 2 for t in tiles), 100), "white")
x = 0
for t in tiles:
strip.paste(t, (x, 0)); x += t.width + 2
strip.save("reconstructed.png")
print("[*] 이어 붙인 결과 →reconstructed.png (눈으로 읽으면 flag 내용)")
print("[+] FLAG: DH{Too_H4rd_to_sEe_th3_Ch4rs_x.x}")실행하면 배열 순서를 뽑고 20장을 차례로 받아 reconstructed.png 로 저장한다.

저장된 이미지를 열면 깨졌던 문장이 한 줄로 또렷하게 살아난다.

Too_H4rd_to_sEe_th3_Ch4rs_x.x — "글자가 너무 빨라서 못 읽겠지" 하고 약 올리는 문장이다. 끝의 x.x 는 눈 감은 얼굴 이모티콘이다. 여기에 형식만 씌우면 플래그가 된다.
DH{Too_H4rd_to_sEe_th3_Ch4rs_x.x}📝 결론
클라이언트에서 가리는 건 숨기는 게 아니다.
이 문제의 모든 장치 — 10픽셀 축소, 난수 속도 애니메이션, 파일명 셔플 — 는 전부 브라우저 화면에서만 작동한다. 정작 데이터인 이미지 20장은 누구나 받을 수 있는 정적 파일로 그대로 노출돼 있었다. 사람 눈을 속이는 것과 데이터를 보호하는 건 다른 일이다.
순서를 흩뜨려도 순서 정보를 같이 흘리면 소용없다.
파일명을 무작위로 섞어 둔 건 그럴듯한 방해였지만, 정작 올바른 순서가 img_files 배열에 고스란히 남아 있었다. 클라이언트가 데이터를 올바르게 조립하려면 그 순서를 어딘가에서 알아야 하고, 클라이언트에 있는 정보는 공격자에게도 있는 정보다.
민감한 값을 정말 가리려면 애초에 서버 밖으로 내보내지 않아야 한다. 인증·인가를 통과한 요청에만 응답으로 내려주고, 화면 처리에 기대지 않는 것이 기본이다. 화면을 어지럽히는 트릭은 CTF 의 장난스러운 출제 포인트일 뿐, 실제 보호 수단이 될 수 없다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…