[🌱 새싹] 화면에 흩날리는 글자를 슬라이스로 되맞추기 — DreamHack Flying Chars 풀이

2026-06-24·1분 읽기·

[🌱 새싹] 화면에 흩날리는 글자를 슬라이스로 되맞추기 — DreamHack Flying Chars 풀이

플래그 한 줄을 20조각 이미지로 잘라 10×10px 로 줄인 뒤 제각각 속도로 화면에 흘려보내는 문제다. 눈으로는 못 읽지만 조각은 그대로 서버에 있다. 파일명 숫자순으로 맞추면 깨진 문장이 나오고, HTML 의 img_files 배열(브라우저가 화면에 붙이는 순서) 그대로 이어 붙이면 플래그가 복원된다. 클라이언트에서 가리는 건 숨기는 게 아니라는 전형적인 예제.

문제: 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픽셀짜리 점으로 짜부라진다. 글자처럼 안 보이게 만드는 장치다.

잠깐 기다렸다가 다시 보면 점들이 다른 자리로 흘러가 있다. 정말로 "날아다니는" 중이다.

1.5초 뒤의 같은 화면 — 조각들이 전부 다른 위치로 흘러가 있지만 이미지 파일 자체는 서버에 그대로 있다
1.5초 뒤의 같은 화면 — 조각들이 전부 다른 위치로 흘러가 있지만 이미지 파일 자체는 서버에 그대로 있다

여기서 중요한 건, 화면이 어수선할 뿐 이미지 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' ' '

img_files 배열에서 이미지 번호만 순서대로 뽑아낸 결과 — 파일명 숫자가 아무 규칙 없이 뒤섞여 있다
img_files 배열에서 이미지 번호만 순서대로 뽑아낸 결과 — 파일명 숫자가 아무 규칙 없이 뒤섞여 있다

그리고 이미지 한 장을 실제 크기로 직접 열어보면 정체가 드러난다. /static/images/1.png 는 10픽셀 점이 아니라 202×89 짜리, 글자 몇 개가 박힌 조각이다.

1.png 를 원본 크기로 연 화면 — 10픽셀 점이 아니라 글자 몇 개가 박힌 202x89 짜리 조각이었다
1.png 를 원본 크기로 연 화면 — 10픽셀 점이 아니라 글자 몇 개가 박힌 202x89 짜리 조각이었다

한 장에 글자가 두세 개씩 들어 있기도 하다. 플래그 한 줄을 통으로 렌더한 다음, 글자 경계와 상관없이 세로로 쭉쭉 잘라낸 조각들인 셈이다. 그러니 조각을 올바른 순서로 가로로 이어 붙이면 원래 한 줄이 되살아난다.


🐛 삽질 — 파일명 숫자로 맞췄더니 깨진 문장

처음엔 당연히 파일명 순서대로 맞추면 될 거라고 생각했다. 0.png, 1.png, 2.png ... 19.png 를 차례로 이어 붙이는 것이다.

🐛 삽질 — 0.png → 19.png 숫자순 조립

20장을 받아 파일명 숫자순으로 한 칸씩 늘어놓으면 이렇게 보인다.

0.png 부터 19.png 까지를 파일명 숫자 순서로 늘어놓은 스무 조각 — 이 순서로는 문장이 되지 않는다
0.png 부터 19.png 까지를 파일명 숫자 순서로 늘어놓은 스무 조각 — 이 순서로는 문장이 되지 않는다

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

파일명 숫자순으로 그대로 이어 붙인 결과 — 군데군데 단어 비슷한 게 보이지만 전체로 읽어 보면 말이 되지 않는다
파일명 숫자순으로 그대로 이어 붙인 결과 — 군데군데 단어 비슷한 게 보이지만 전체로 읽어 보면 말이 되지 않는다

_x.xth3h4sE_Hrdo_Teto_r4os_C — 군데군데 단어 비슷한 게 보이지만(th3, Teto) 전체로는 말이 안 된다. 파일명 숫자는 조각에 붙은 무작위 ID 일 뿐, 읽는 순서가 아니었다.

깨진 문자열을 노려보다 보면 단서가 보인다. th3, h4, Ch4rs, Teto 같은 조각이 분명 들어 있는데 순서만 어긋나 있다. 순서를 알려주는 정보는 이미 정찰 단계에서 봤다 — img_files 배열이다.



💣 핵심 — 읽는 순서는 파일명이 아니라 DOM 순서

정리하면 서버는 이런 과정을 거쳐 플래그를 "가렸다".

서버가 플래그 한 줄을 20조각으로 잘라 파일명을 섞고 화면에 흘려보내는 과정, 그리고 복원
서버가 플래그 한 줄을 20조각으로 잘라 파일명을 섞고 화면에 흘려보내는 과정, 그리고 복원

플래그 한 줄을 통이미지로 렌더한 뒤 세로로 20등분하고, 각 조각에 0~19 번호를 무작위로 붙여 저장한다. 그리고 페이지에서는 원래 순서(img_files 배열)대로 <img> 를 만들어 붙이되, 화면에서는 10픽셀로 줄이고 난수 속도로 날려 사람 눈을 따돌린다.

그러니 복원은 파일명을 무시하고 img_files 배열 순서 그대로 조각을 이어 붙이면 된다. 배열의 앞쪽 몇 장만 순서대로 원본 크기로 확인해 보면 바로 와닿는다. 배열 첫 번째는 10.png 인데 열어 보면 T 이고,

배열의 첫 조각인 10.png — 대문자 T 하나가 들어 있다. 파일명 숫자와 실제 순서가 무관하다는 첫 단서다
배열의 첫 조각인 10.png — 대문자 T 하나가 들어 있다. 파일명 숫자와 실제 순서가 무관하다는 첫 단서다

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

배열의 네 번째 조각인 7.png — 밑줄과 대문자 H 가 들어 있어 앞 조각들과 이으면 단어 모양이 잡힌다
배열의 네 번째 조각인 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 로 저장한다.

solve.py 실행 — img_files 순서대로 스무 장을 내려받아 가로로 이어 붙이고 플래그 한 줄을 복원한다
solve.py 실행 — img_files 순서대로 스무 장을 내려받아 가로로 이어 붙이고 플래그 한 줄을 복원한다

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

img_files 배열 순서대로 이어 붙인 결과 — 깨져 있던 문장이 한 줄로 또렷하게 되살아나 플래그가 읽힌다
img_files 배열 순서대로 이어 붙인 결과 — 깨져 있던 문장이 한 줄로 또렷하게 되살아나 플래그가 읽힌다

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

댓글

0

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

댓글 불러오는 중…

Related

관련 글

3
[🌱 새싹] 호박을 만 번 깎으면 떨어지는 플래그 — DreamHack Carve Party 풀이
blog

[🌱 새싹] 호박을 만 번 깎으면 떨어지는 플래그 — DreamHack Carve Party 풀이

서버도 백엔드도 없는 HTML 한 장짜리 문제. 호박을 10000번 클릭하면 canvas에 플래그가 그려지는데, 그 플래그 바이트는 클릭 100번마다 한 번씩 XOR 변환을 거친다. 키로 쓰이는 pie 변수가 함수 밖 전역이라 배치 사이에 값이 이어진다는 점만 잡으면, 손으로 만 번 누르는 대신 100배치를 그대로 시뮬레이션해서 플래그를 꺼낼 수 있다.
#dreamhack#ctf#web+4
2026-06-22#dreamhack +4
[🥉 Bronze 2] 서명 없는 세션 쿠키와 초 단위로 새는 가입 시각 — DreamHack What time is it? 풀이
blog

[🥉 Bronze 2] 서명 없는 세션 쿠키와 초 단위로 새는 가입 시각 — DreamHack What time is it? 풀이

세션 토큰이 사용자 이름과 가입 시각에 2026을 곱한 값을 이어 붙인 게 전부였다. 서명이 없으니 admin의 가입 시각만 알면 쿠키를 손으로 만들 수 있고, 그 시각은 /welcome의 가입 피드가 초 단위로 그대로 뿌리고 있었다. 쿠키 한 줄을 갈아 끼워 관리자 화면의 FLAG를 회수한 기록.
#dreamhack#ctf#web+6
2026-08-08#dreamhack +4
[🥉 Bronze 4] 버튼을 쫓아갈 필요가 없었다 — DreamHack Click me! 풀이
blog

[🥉 Bronze 4] 버튼을 쫓아갈 필요가 없었다 — DreamHack Click me! 풀이

마우스가 가까이 가면 도망가는 버튼. "Catch me if you can!"이라는 문제 설명 그대로, 겉보기엔 반응속도와 클릭 타이밍을 겨루는 미니게임 같다. 그런데 버튼의 클릭 이벤트가 하는 일이라곤 특정 URL로 이동하는 것뿐이고, 그 URL에 서버가 어떤 조건도 검증하지 않는다는 걸 소스에서 확인한 순간, 버튼을 쫓아다닐 이유 자체가 사라졌다.
#dreamhack#ctf#web+2
2026-07-20#dreamhack +4