문제: DreamHack — keyboard toxicosis 분류: Misc 난이도: 🥉 Bronze 2 FLAG:
cause{TOXIC}

집에 온 용찬이 어머니가 쓰러진 용찬이를 발견한다. 컴퓨터 화면에는 이상한 문자열이 암호처럼 적혀 있었고, 쓰러진 원인을 그 암호에서 찾아 cause{...} 안에 넣어 제출하는 게 목표다. 제공 파일은 딱 하나, help.txt다. 열어 보면 알파벳 대문자가 세 개씩 묶여 다섯 줄 들어 있다. 겉보기엔 평범한 치환 암호 같지만, 쓰인 글자를 세어 보면 자판 왼쪽 귀퉁이에만 모여 있다. 그 관찰 하나가 풀이의 전부다. 위 그림은 결론을 미리 요약한 것이고, 아래에서 어떻게 거기에 도달했는지 하나씩 풀어 간다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | keyboard toxicosis |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Misc |
| 제공 파일 | help.txt (61바이트, 5줄) |
| 서버 | 없음 (파일만) |
| 핵심 기법 | 자판 제스처 암호 — 키 묶음을 자판 위 획으로 보고 격자에 겹쳐 글자 복원 |
풀이는 세 단계다. ① help.txt에 쓰인 글자가 자판의 특정 아홉 칸에만 갇혀 있다는 걸 알아챈다. ② 키 세 개짜리 묶음이 그 블록 위에서 직선 하나(획)를 긋는다고 해석한다. ③ 한 줄의 획들을 3×3 격자에 겹쳐 글자를 읽는다. 결과는 TOXIC — 중독, 제목의 toxicosis와 그대로 맞물린다. 문제 설명의 "쓰러진 원인"이라는 서사가 곧 정답 단어를 가리키는 힌트였던 셈이다.
제목의 toxicosis는 의학에서 "독성 물질에 의한 중독 상태"를 뜻한다. 문제 제목이 keyboard toxicosis이니, 자판(keyboard)으로 중독(toxic)을 표현한다는 이중 힌트다. 암호를 풀기 전에도 답이 toxic 언저리일 거라는 짐작은 할 수 있지만, 그걸 실제로 자판 위에서 복원해 내는 게 문제의 본령이다.
🔬 정찰 — 제공 파일 뜯어보기
받은 파일이 무엇인지부터 본다. 압축을 풀면 extracted/ 안에 파일 하나만 떨어진다. 크기와 종류를 먼저 확인했다.
ls -la extracted/; echo; du -b extracted/help.txt
파일이 61바이트짜리 텍스트 하나뿐이니, 날것으로 전부 들여다봐도 된다. 종류와 줄 수, 그리고 눈에 안 보이는 제어문자까지 한 번에 확인했다.
file extracted/help.txt; wc -l extracted/help.txt; cat -A extracted/help.txtcat -A는 탭을 ^I, 캐리지리턴을 ^M, 줄 끝을 $로 드러낸다. 각 줄 끝에 ^M$가 붙어 있으니 윈도우(CRLF)에서 만든 파일이고, 본문은 아래 다섯 줄이다.

QWE WSX
QAZ ZXC CDE EWQ
QSC ESZ
QWE WSX ZXC
QWE QAZ ZXC바이트가 진짜 이게 전부인지 헥스덤프로 못을 박았다. 추가로 섞인 공백이나 숨은 바이트가 없는지, 혹시 트레일링 공백으로 또 다른 정보가 인코딩돼 있지는 않은지 확인하는 단계다.
xxd extracted/help.txt
헥스덤프로 보니 공백은 20, 줄바꿈은 0d 0a 뿐이고 숨은 바이트는 없다. 61바이트를 분해해 보면 더 분명하다. 세 글자 묶음 14개가 42바이트, 묶음 사이 공백이 9바이트(줄별로 1·3·1·2·2개), 줄 끝 CRLF 다섯 번이 10바이트 — 합이 정확히 61이다. 트레일링 공백이나 널 패딩 같은 여분이 끼어들 자리가 없다. CRLF는 파일을 윈도우에서 만들었다는 흔적일 뿐, 거기에 추가 정보가 숨어 있지는 않다. 즉 정보는 보이는 글자와 그 배치가 전부다. 여기서 암호 해독의 첫 단추가 보인다. 쓰인 글자가 Q W E A S D Z X C 아홉 종류뿐이다. 치환 암호라면 복원된 평문이 알파벳 전역에 고르게 퍼졌을 텐데, 하필 자판 왼쪽 아래 3×3 블록에만 모여 있다. 게다가 늘 세 글자씩 묶인다. 글자 자체가 뜻을 담은 게 아니라 자판 위 위치가 의미라는 신호다.
🧩 핵심 아이디어 — 키 세 개가 긋는 획
자판을 격자로 보자. 왼쪽 세 열, 위 세 행을 떼면 이렇게 된다.
Q W E
A S D
Z X C이 블록 위에서 QWE를 손가락으로 이으면 맨 윗줄을 가로지르는 가로획이 된다. WSX는 가운데 열을 타고 내려오는 세로획, QSC는 왼쪽 위에서 오른쪽 아래로 내려긋는 대각획이다. 키 세 개짜리 묶음 하나가 곧 직선 획 하나다. 공백으로 나뉜 묶음이 여러 개면 그 획들을 같은 격자에 겹쳐 하나의 그림을 만든다.

읽는 방향은 상관없다. QWE든 EWQ든 같은 맨 윗줄 가로획이고, 격자를 채우는 결과는 똑같다. 그래서 두 번째 줄의 EWQ는 첫 줄의 QWE와 같은 획으로 취급한다. 세로획도 마찬가지라 QAZ와 ZAQ는 같고, 대각선 QSC와 CSQ도 같다. 암호를 만든 사람이 손가락으로 어느 쪽에서 긋기 시작했느냐의 차이일 뿐, 자판에 남는 선은 동일하다.
왜 하필 자판 왼쪽 블록일까. 오른손잡이 기준으로 왼손 세 손가락이 자연스럽게 닿는 영역이 Q~Z 세 열이다. QWEASDZXC 아홉 키는 3×3으로 정확히 맞아떨어지는 유일한 모서리 블록이기도 하다. 숫자 키패드(전화기·계산기)로 글자를 그리는 변형도 같은 발상인데, 그쪽은 3×4라 글자를 더 다양하게 만들 수 있는 대신 "어느 아홉 칸을 쓰는가"가 덜 자명하다. 이 문제는 자판 모서리라는 제약 덕분에 블록을 찾기가 비교적 쉽다.
▶🐛 삽질 — 처음엔 치환이나 키 시프트 암호로 의심했다
QWE WSX를 처음 봤을 때 가장 먼저 떠오른 건 자판 기반 치환이었다. 키를 한 칸씩 오른쪽으로 민다거나(Q→W), 자판 열을 숫자로 바꾸는 식의 매핑을 몇 가지 머릿속으로 돌려 봤다. 흔한 자판 암호가 "손가락을 한 칸 밀어 친 오타"를 되돌리는 방식이라, 그 가능성부터 점검한 것이다. 하지만 두 가지가 맞지 않았다.
- 글자 집합이 너무 좁다. 치환이든 시프트든 평문이 영어라면 결과 문자가
Q W E A S D Z X C아홉 종류 안에만 떨어질 이유가 없다. 자판 한 칸 밀기를 역으로 풀어도 평문은 자판 전역으로 퍼져야 한다. 아홉 칸에 갇혀 있다는 건 평문이 그 안에 있는 게 아니라, 그 아홉 칸 자체가 도화지라는 뜻이다. - 세 글자 묶음이 규칙적이다. 치환은 글자 하나하나가 독립적인데, 여기선 늘 정확히 세 개가 한 덩어리다. 세 개를 이어야 의미가 생긴다는 뜻이고, 이건 글자 값이 아니라 **배치(모양)**를 가리킨다. 3×3 블록에서 길이 3짜리 직선은 가로 3개, 세로 3개, 대각 2개로 딱 여덟 개뿐인데, 등장한 유니크 묶음도 정확히 그 범위 안이었다.
그래서 치환 계열 가설은 접고, "아홉 칸 블록 위에 무언가를 그린다"는 쪽으로 방향을 틀었다. 세 글자가 자판에서 일직선이라는 걸 확인한 순간 획이라는 해석이 굳어졌다.
💣 한 글자는 어떻게 만들어지나 — O 조립
획 여러 개가 한 글자가 되는 과정을 둘째 줄로 짚어 보자. QAZ ZXC CDE EWQ는 네 개의 묶음이다. 각각 왼쪽 세로획, 아래 가로획, 오른쪽 세로획, 위 가로획이다. 이 넷을 같은 격자에 차례로 얹으면 네 변이 모두 채워져 사각 테두리, 즉 가운데가 빈 O가 된다.

나머지 글자도 같은 방식이다. 다섯 줄이 각각 어떤 획으로 이뤄지는지 정리하면 이렇다.
| 글자 | 줄 (묶음) | 획 구성 |
|---|---|---|
| T | QWE WSX | 위 가로 + 가운데 세로 |
| O | QAZ ZXC CDE EWQ | 왼·아래·오른·위 네 변 |
| X | QSC ESZ | 두 대각선 (↘, ↙) |
| I | QWE WSX ZXC | 위 가로 + 가운데 세로 + 아래 가로 |
| C | QWE QAZ ZXC | 위 가로 + 왼쪽 세로 + 아래 가로 |
T는 윗변과 가운데 기둥, I는 거기에 아랫변을 더한 모양이라 둘이 헷갈리기 쉽다. 차이는 아래 가로획(ZXC)의 유무 하나다. C는 O에서 오른쪽 세로획을 뺀 것으로 보면 외우기 쉽다. 이렇게 직선 획의 조합만으로 표현되는 글자는 세그먼트 디스플레이(7-세그먼트)로 그릴 수 있는 글자들과 겹친다.
획을 겹칠 때 중요한 성질 하나는 같은 칸을 두 획이 지나도 한 번만 칠해진다는 점이다. 집합의 합집합(OR)처럼 동작한다. 예를 들어 O를 만드는 네 획에서 왼쪽 세로획 QAZ와 아래 가로획 ZXC는 모서리 Z 칸을 공유하는데, 그 칸이 두 번 칠해진다고 달라지는 건 없다. 그래서 묶음의 순서도, 겹침도 결과에 영향을 주지 않는다. 한 줄 안의 묶음을 어떤 순서로 읽든 최종 격자는 똑같다. 이 성질 덕분에 "획들을 모아 칠한 뒤 모양을 읽는다"는 단순한 규칙이 모호함 없이 성립한다.
🔬 획을 좌표로 — analyze.py
머릿속 해석을 기계적으로 검증했다. 블록의 각 키에 좌표 (열, 행)을 매기고, help.txt에 등장하는 모든 묶음을 모아 세 점이 등간격 직선인지, 가로·세로·대각 중 무엇인지 분류하는 짧은 스크립트를 썼다. 만약 하나라도 "직선 아님"이 나오면 획 해석이 틀린 것이다.
#!/usr/bin/env python3
"""help.txt 에 등장하는 키 토큰이 QWERTY 왼쪽 3x3 블록에서 각각 어떤 '획'인지 분류한다.
블록(열 x, 행 y):
Q(0,0) W(1,0) E(2,0)
A(0,1) S(1,1) D(2,1)
Z(0,2) X(1,2) C(2,2)
"""
import pathlib, collections
POS = {
'Q': (0, 0), 'W': (1, 0), 'E': (2, 0),
'A': (0, 1), 'S': (1, 1), 'D': (2, 1),
'Z': (0, 2), 'X': (1, 2), 'C': (2, 2),
}
def classify(tok):
p = [POS[c] for c in tok]
dx = p[1][0] - p[0][0]
dy = p[1][1] - p[0][1]
# 세 점이 등간격 직선인지
straight = (p[2][0] - p[1][0], p[2][1] - p[1][1]) == (dx, dy)
if not straight:
return "직선 아님"
if dy == 0:
row = {0: "맨위", 1: "가운데", 2: "맨아래"}[p[0][1]]
return f"가로획({row} 행)"
if dx == 0:
col = {0: "왼쪽", 1: "가운데", 2: "오른쪽"}[p[0][0]]
return f"세로획({col} 열)"
if dx == dy:
return "대각획(↘ 좌상→우하)"
return "대각획(↙ 우상→좌하)"
def main():
lines = [l.strip() for l in pathlib.Path("extracted/help.txt").read_text().splitlines() if l.strip()]
toks = []
for ln in lines:
toks += ln.split()
uniq = sorted(set(toks))
print(f"줄 수 = {len(lines)}, 전체 토큰 = {len(toks)}, 유니크 토큰 = {len(uniq)}")
print("-" * 44)
for t in uniq:
coords = " ".join(f"{c}{POS[c]}" for c in t)
print(f"{t} : {classify(t):22s} | {coords}")
if __name__ == "__main__":
main()python3 analyze.py
classify 함수는 세 점의 좌표 차이를 본다. 첫 점에서 둘째 점으로 가는 변위 (dx, dy)가 둘째에서 셋째로 갈 때도 똑같으면 세 점이 등간격 직선이다. 그 다음 dy == 0이면 가로, dx == 0이면 세로, dx == dy면 좌상에서 우하로 내려가는 대각, 아니면 반대 대각으로 가른다. 좌표계만 제대로 잡으면 분류 자체는 조건문 몇 줄이다. 핵심은 이 함수가 모든 묶음에 대해 "직선 아님"을 한 번도 내지 않는지를 보는 것이었다.
유니크 묶음은 여덟 개, 전부 "직선 아님" 없이 가로·세로·대각으로 떨어졌다. 3×3 블록에서 가능한 길이 3짜리 직선이 정확히 여덟 개(가로 3 + 세로 3 + 대각 2)인데, 등장한 유니크 묶음 수와 일치한다. 자판 위에서 손가락으로 쭉 긋는 획이라는 해석이 데이터로 확인된 셈이다. 이제 한 줄에 든 획들을 격자에 겹쳐 그림을 완성하면 된다.
🎯 격자에 겹쳐 글자 복원
각 획이 지나는 칸을 3×3 격자에 칠한다. 둘째 줄처럼 묶음이 네 개면 네 획을 모두 얹고, 첫째 줄처럼 두 개면 두 획만 얹는다. 다섯 줄을 차례로 렌더하면 글자가 하나씩 떠오른다.

위에서부터 T O X I C. 문제 설명이 요구한 "쓰러진 원인"은 중독(toxic)이고, 제목 keyboard toxicosis의 toxicosis(중독증)와 정확히 이어진다. 서사와 정답 단어, 그리고 복원된 글자가 모두 한 곳을 가리킨다. 25/01/26 공지대로 대문자로 제출해야 하므로 플래그는 cause{TOXIC}다.
🚀 Full Exploit
격자 렌더를 그대로 코드로 옮겼다. 각 줄의 묶음에서 키를 좌표로 바꿔 격자를 칠하고 출력한다. 글자 판독은 사람이 눈으로 하지만, 아스키 격자가 또렷해서 바로 읽힌다. 손으로 격자를 그리다 I와 T를 헷갈리는 실수를 없애려는 목적이다.
#!/usr/bin/env python3
"""keyboard toxicosis (DreamHack, Bronze 2, misc) solver.
help.txt 의 각 줄은 QWERTY 자판 왼쪽 3x3 블록 위에서 그린 '획(stroke)'들의 모음이다.
3x3 노드:
Q W E (0,0)(1,0)(2,0)
A S D (0,1)(1,1)(2,1)
Z X C (0,2)(1,2)(2,2)
각 3글자 토큰은 그 세 키를 잇는 직선 한 획이다. 한 줄의 획들을 격자에 겹쳐 글자를 읽는다.
"""
import sys, pathlib
POS = {
'Q': (0, 0), 'W': (1, 0), 'E': (2, 0),
'A': (0, 1), 'S': (1, 1), 'D': (2, 1),
'Z': (0, 2), 'X': (1, 2), 'C': (2, 2),
}
def render(line):
grid = [[' '] * 3 for _ in range(3)]
for token in line.split():
for ch in token:
x, y = POS[ch]
grid[y][x] = '#'
return grid
def main():
p = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else 'extracted/help.txt')
letters = []
for raw in p.read_text().splitlines():
line = raw.strip()
if not line:
continue
grid = render(line)
print(f"[{line}]")
for row in grid:
print(' ' + ''.join(row))
print()
letters.append(grid)
# 각 격자가 어떤 글자인지는 사람이 눈으로 읽는다(아래 출력 참조)
print("=> 격자를 위에서부터 읽으면: T O X I C")
print("=> password = TOXIC")
print("=> FLAG = cause{TOXIC}")
if __name__ == '__main__':
main()python3 solve.py
출력 격자를 위에서부터 읽으면 T, O, X, I, C. 이 다섯 글자를 모아 cause{TOXIC}를 제출하면 정답으로 처리된다. 서버가 필요 없는 오프라인 문제라, 파일을 받고 격자를 그리는 것으로 풀이가 끝난다.
📝 정리
브론즈 난이도답게 알고리즘은 없다. 이 문제의 전부는 관찰 하나였다. 암호문에 쓰인 글자가 자판 왼쪽 아홉 칸에만 갇혀 있고 늘 세 개씩 묶인다는 사실. 거기서 "글자 값이 아니라 자판 위 모양"이라는 해석으로 넘어가면 나머지는 격자에 칠하는 단순 작업이다. 문제 제목과 서사가 toxic이라는 답을 넌지시 알려 주지만, 그걸 자판 위에서 실제로 복원해 보는 과정이 핵심이다.
- 문자 집합을 먼저 세어 본다. 치환·비즈네르 같은 고전 암호를 가정하기 전에, 쓰인 글자가 어디에 모여 있는지부터 본다. 알파벳 전역이 아니라 자판 한 귀퉁이에 몰려 있으면 값이 아니라 배치가 의미인 경우가 많다.
help.txt가 아홉 글자만 쓴다는 걸 셈한 게 모든 것의 출발점이었다. - 묶음 단위를 의심한다. 일정하게 N개씩 묶이면 그 N개가 함께 하나의 단위를 이룬다. 여기선 세 키가 직선 한 획이었고, 3×3 블록에서 길이 3짜리 직선이 여덟 가지뿐이라는 제약이 해석을 뒷받침했다.
- 눈으로 읽는 단계는 코드로 또렷하게. 격자를 머릿속에서 그리면
I와T,O와C를 헷갈리기 쉽다. 아스키 격자로 렌더해 두면 판독 실수가 사라지고, 글에 싣기에도 근거가 분명하다.
이 유형은 CTF misc에서 꾸준히 변주된다. 숫자 키패드로 글자를 그리는 판, 휴대폰 T9 자판을 쓰는 판, 혹은 아예 게임 패드 방향키로 획을 긋는 판까지 형태만 바꿔 나온다. 공통점은 "입력 장치의 물리적 배치 위에 선을 그어 모양을 만든다"는 발상이다. 암호문이 특정 키 집합에만 갇혀 있고 규칙적으로 묶인다면, 그 입력 장치를 격자로 펼쳐 놓고 선을 그어 보는 걸 한 번쯤 떠올릴 만하다.
마지막으로, 이번 풀이에서 코드가 한 일은 사실 "확인"이지 "해독"이 아니었다는 점을 짚고 싶다. 답인 TOXIC은 자판 위에서 손으로 획을 그어도 충분히 보인다. 그럼에도 analyze.py로 모든 묶음이 직선인지 검증하고 solve.py로 격자를 또렷하게 렌더한 이유는, 손으로 읽은 결과가 정말 맞는지를 기계로 한 번 더 못 박기 위해서다. 브론즈 문제라 손풀이로 끝내도 되지만, 작은 문제에서 "눈으로 본 것을 코드로 재확인하는" 습관을 들여 두면 더 복잡한 문제에서 추측과 사실을 가르는 힘이 된다. misc는 종종 번뜩이는 관찰로 풀리지만, 그 관찰을 검증 가능한 절차로 바꿔 두는 게 풀이를 남과 공유할 때의 신뢰가 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…