문제: DreamHack — 엄... 분류: misc 난이도: 🥉 Bronze 2 FLAG:
DH{How_is_Um_Junsik_a_human_name_lol109876543210}
문제 페이지에는 설명이 한 줄도 없다. 링크 하나가 전부다.

별 835개짜리 한국어 난해한 프로그래밍 언어 저장소다. 배포본으로 받은 main.umm 이 이 언어로 쓰였고, 그 프로그램이 화면에 뭘 찍는지 알아내면 그게 플래그다. 실행만 하면 끝나는 문제로 보이지만, 온점 343개를 손으로 세어 알파벳으로 옮기다 보면 한 글자씩 어긋나는 지점이 몇 군데 있다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | 엄... |
| 난이도 | 🥉 Bronze 2 |
| 분류 | misc |
| 제공 파일 | challenge_2002.zip → extracted/main.umm (1,066바이트) |
| 서버 | 없음 (오프라인 문제, needs_vm=false) |
| 문제 설명 | https://github.com/rycont/umjunsik-lang 링크 한 줄 |
| 핵심 | 엄랭 v2 문법 확정 → 51줄 정적 해석 → goto 루프까지 실행 |
재현 환경 — Ubuntu 25.10 (커널 6.17.0-41) · Python 3.13.7 · gcc 15.2.0. 교차검증에 쓴 두 구현은 pip umjunsik 2.0.2 와 저장소 커밋 e973f9d(2024-05-06)의 umjunsik-lang-cc. solve.py 는 표준 라이브러리만 쓰므로 이 버전에 매이지 않는다.
풀이는 네 단계로 갈렸다. ① 배포본이 무슨 글자로 이루어졌는지 세고 ② 공식 저장소에서 문법을 확정하고 ③ 51줄을 구조로 펴는 디스어셈블러를 짜고 ④ 인터프리터로 goto 루프까지 실제로 돌린다.
🔬 정찰 — .umm 파일 하나
먼저 배포본이 정확히 뭔지부터 본다. 한글이 섞여 있으니 바이트 수와 글자 수가 다를 것이고, 그 차이가 나중에 "온점 몇 개"를 셀 때 함정이 된다.
#!/usr/bin/env bash
# 배포본 정찰 — 무슨 파일이고, 어떤 글자로만 이루어졌나.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
file extracted/main.umm
wc -lc extracted/main.umm
echo
echo "[앞 48바이트]"
xxd -l 48 extracted/main.umm
echo
echo "[문자 인구조사 — 등장 횟수 순]"
python3 - <<'PY'
from collections import Counter
src = open('extracted/main.umm', encoding='utf-8').read()
for ch, n in Counter(src).most_common():
name = {'\n': r'\n', ' ': '(공백)'}.get(ch, ch)
print(f' {name:<8} {n:>4} U+{ord(ch):04X} {len(ch.encode()):d}바이트')
PY./recon.sh
읽어낼 것이 세 가지다.
글자 수와 바이트 수가 다르다. 어·엄·식·ㅋ 는 UTF-8 로 3바이트다. 1,066바이트짜리 파일이지만 실제 글자는 그보다 훨씬 적다. 바이트 단위로 grep -c 같은 걸 돌리면 숫자가 어긋난다.
등장 글자가 스무 종류뿐이다. 온점 343, 어 106, 반점 60, ㅋ 39, 식 38, 엄 6. 나머지는 한 자리 수다. 언어의 어휘 자체가 이만큼 좁다는 뜻이다.
공백이 딱 4개다. 이게 제일 중요한 관찰이다. 마지막 줄 이 사람이름이냐ㅋㅋ 에 있는 하나를 빼면 코드 본문의 공백은 3개뿐이다. 이 언어에서 공백은 여백이 아니라 연산자다.
파일 전문은 이렇다.
어떻게
엄..... .............
어엄.......... ..........
어어엄..
어어어엄...........
어어어어엄..... .........
식어...ㅋ
식어.......ㅋ
식어어.......................ㅋ
식어.......ㅋ
식어어...........ㅋ
식어어...................ㅋ
식어어,,,,,ㅋ
식어어.....ㅋ
식어어...............ㅋ
식어어,,,,,ㅋ
식어어,,,,,,,,,,,,,,,ㅋ
식어어.........ㅋ
식어어,,,,,ㅋ
식어.........ㅋ
식어어.................ㅋ
식어어..........ㅋ
식어어...............ㅋ
식어어.....ㅋ
식어어.......ㅋ
식어어,,,,,ㅋ
식어어,,,ㅋ
식어어,,,,,ㅋ
식어어....ㅋ
식어어.................ㅋ
식어어.........ㅋ
식어어,,,ㅋ
식어어..........ㅋ
식어어,,,,,ㅋ
식어어..........ㅋ
식어어,,,ㅋ
식어어.........ㅋ
식어어.ㅋ
식어어,,,,,ㅋ
식어어........ㅋ
식어어...........ㅋ
식어어........ㅋ
어어어엄어어어어,
식어어어어!
동탄어어어어?준어어어어어....
준어어어어어
식어어.........................ㅋ
이 사람이름이냐ㅋㅋ식어어...ㅋ 가 줄줄이 늘어선 구간은 한눈에 봐도 문자 출력이다. 문제는 그 아래 다섯 줄이다. 동탄·준 이 섞인 그 구간을 어떻게 읽느냐로 답이 갈린다.
🧩 배경 — 엄랭(umjunsik-lang) v2
문법은 추측하지 않고 저장소 README 에서 확정한다. 2020년에 공개된 언어고 지금 배포되는 건 v2 다.

git clone 으로 받아 문제 폴더에 사본을 남겨 두고(ref/umjunsik-README.md), 필요한 절만 잘라서 본다.
sed -n "122,161p" ref/umjunsik-README.md
정리하면 이렇다.
| 표기 | 뜻 |
|---|---|
. | 1 증가 |
, | 1 감소 |
| 공백 | 곱하기 (항끼리 곱한다) |
어 × N | N번째 변수를 읽는다 |
(어 × N) + 엄 | N+1번째 변수에 쓴다 |
식 … ! | 사이의 수를 10진 정수로 출력 |
식 … ㅋ | 사이의 수를 유니코드 문자로 출력 (수가 없으면 개행) |
어떻게 / 이 사람이름이냐ㅋㅋ | 프로그램 시작·끝 표식 |
여기서 걸려 넘어지기 쉬운 게 마지막 두 줄이다. 어 는 값이 아니라 개수만 의미를 갖는데, 그 개수를 세는 기준이 읽을 때와 쓸 때가 다르다. 어어 를 수식 안에서 만나면 2번 변수를 읽는 것이고, 어어엄 을 만나면 3번 변수에 쓰는 것이다. 한 칸 어긋난다.

정찰에서 센 공백 3개가 여기서 값이 된다. 엄..... ............. 은 온점 18개가 아니라 5 * 13 = 65 다. 65는 대문자 A 의 코드다.
🔧 엄랭 디스어셈블러 — 51줄을 구조로 편다
손으로 온점을 세면 반드시 틀린다. 15개와 17개는 눈으로 구분이 안 된다. 그래서 줄마다 종류를 가르고 기호식으로 바꿔 찍는 정적 도구를 먼저 만들었다. 실행은 하지 않고 구조만 본다.
줄 종류를 가르는 우선순위는 레퍼런스 런타임과 같게 맞췄다. 동탄 → 준 → 화이팅! → 식+? → 식+! → 식+ㅋ → 엄 순이다. 순서가 중요한 이유는 47번째 줄처럼 동탄 과 준 이 한 줄에 같이 있는 경우가 있기 때문이다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""umm_disasm.py — 엄랭(.umm) 정적 디스어셈블러.
실행하지 않고 줄 단위 구조만 뽑는다. 엄랭 v2 문법:
엄 대입 (엄 앞의 '어' 개수 = 변수 인덱스 - 1)
어 변수참조('어' 개수 = 변수 인덱스)
. +1 , -1 ' '(공백) 곱하기
식...! 정수 출력 식...ㅋ 유니코드 문자 출력(빈 값이면 개행)
동탄X?CMD X가 0이면 CMD 실행 준N N번째 줄로 점프
사용: python3 umm_disasm.py extracted/main.umm
python3 umm_disasm.py extracted/main.umm --from 40 --to 51 # 구간만
"""
import argparse
def parse_num(expr):
"""숫자식을 사람이 읽는 기호식으로. 공백은 곱하기라 항끼리 '*' 로 잇는다."""
terms = []
for tok in expr.split(' '):
eo, dot, com = tok.count('어'), tok.count('.'), tok.count(',')
base = f'var{eo}' if eo else '0'
delta = dot - com
if eo and delta:
terms.append(f'({base}{delta:+d})')
elif eo:
terms.append(base)
else:
terms.append(str(delta))
return ' * '.join(terms)
def classify(line):
"""레퍼런스 런타임(umjunsik 2.0.2)과 같은 우선순위로 줄 종류를 가른다."""
if line == '':
return 'BLANK', ''
if line in ('어떻게', '이 사람이름이냐ㅋㅋ'):
return 'MARKER', line
if '동탄' in line:
cond, cmd = line.replace('동탄', '').split('?', 1)
_, inner = classify(cmd.strip())
return 'IF', f'if ({parse_num(cond)}) == 0 -> {inner}'
if '준' in line:
return 'GOTO', f'goto line {parse_num(line.replace("준", ""))}'
if '화이팅!' in line:
return 'EXIT', f'exit {parse_num(line.split("화이팅!")[1])}'
if '식' in line and '?' in line:
return 'INPUT', f'var{line.replace("식?", "").count("어")} <- stdin'
if '식' in line and line.endswith('!'):
return 'PUTINT', f'print_int({parse_num(line[1:-1])})'
if '식' in line and line.endswith('ㅋ'):
body = line[1:-1]
return 'PUTCHAR', 'newline' if body == '' else f'putchar({parse_num(body)})'
if '엄' in line:
var, val = line.split('엄', 1)
return 'ASSIGN', f'var{var.count("어") + 1} <- {parse_num(val)}'
return 'UNKNOWN', line
def main(path, lo=1, hi=10 ** 9):
src = open(path, encoding='utf-8').read().rstrip('\n')
lines = src.split('\n')
print(f'{path}: {len(lines)} lines (표시 {max(lo, 1)}~{min(hi, len(lines))})')
print(f"{'ln':>3} {'kind':<8} {'meaning'}")
print('-' * 62)
for i, raw in enumerate(lines, 1):
if not lo <= i <= hi:
continue
kind, meaning = classify(raw.strip())
if kind == 'BLANK':
print(f'{i:>3} {"":<8} .')
else:
print(f'{i:>3} {kind:<8} {meaning}')
if __name__ == '__main__':
ap = argparse.ArgumentParser()
ap.add_argument('path', nargs='?', default='extracted/main.umm')
ap.add_argument('--from', dest='lo', type=int, default=1)
ap.add_argument('--to', dest='hi', type=int, default=10 ** 9)
a = ap.parse_args()
main(a.path, a.lo, a.hi)python3 umm_disasm.py extracted/main.umm --to 26
python3 umm_disasm.py extracted/main.umm --from 27
구조가 드러났다.
- 3~7행이 변수 다섯 개를 채운다.
var1=65,var2=100,var3=2,var4=11,var5=45. - 9~44행이
var1또는var2에 상수를 더해 문자로 찍는다. 36글자. - 45~48행이
var4를 하나씩 줄이며 정수로 찍는 반복문이다. - 49행이 마지막 한 글자를 찍는다.
var3 은 2가 들어가지만 그 뒤로 아무도 안 읽는다. 아무 일도 하지 않는 변수다.
그리고 var5 가 45다. 45행은 반복문이 시작하는 바로 그 줄이다. 우연이 아니라 준어어어어어 가 그 줄로 돌아가려고 잡아 둔 상수다.
💣 핵심 1 — 상수 두 개가 알파벳의 기준점
var1=65 는 A, var2=100 은 d 다. 9~44행은 이 두 값에서 얼마나 떨어졌는지만 적어 둔 것이다.
디스어셈블 결과를 눈으로 더하는 대신 인터프리터를 짜서 스텝마다 변수 상태와 출력 바이트를 찍게 했다. 레퍼런스 런타임의 의미를 그대로 옮기되, 어디서 무슨 글자가 나왔는지 추적이 남는다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""umm_interp.py — 엄랭(.umm) 인터프리터 + 실행 추적기.
레퍼런스 런타임(umjunsik 2.0.2)의 의미를 그대로 옮겨 적되, --trace 로
줄 번호 / 변수 상태 / 출력 바이트를 매 스텝 찍는다.
python3 umm_interp.py extracted/main.umm # 그냥 실행
python3 umm_interp.py extracted/main.umm --trace # 스텝 추적
python3 umm_interp.py extracted/main.umm --trace --from 44 --to 50
"""
import sys
MAXVAR = 256
class Umm:
def __init__(self, src, trace=False, lo=1, hi=10 ** 9):
# 준(goto)이 세는 줄 번호는 '어떻게' 를 1번으로 하고 빈 줄도 그대로 센다.
self.lines = src.rstrip('\n').split('\n')
self.var = [0] * MAXVAR
self.out = []
self.trace = trace
self.lo, self.hi = lo, hi
def num(self, expr):
"""공백은 곱하기. 항 하나 = (변수값 or 0) + 온점개수 - 반점개수."""
result = 1
for tok in expr.split(' '):
eo = tok.count('어')
result *= (self.var[eo - 1] if eo else 0) + tok.count('.') - tok.count(',')
return result
def log(self, pc, msg):
if self.trace and self.lo <= pc <= self.hi:
v = ' '.join(f'v{i + 1}={self.var[i]}' for i in range(5))
print(f' [line {pc:>2}] {msg:<40} | {v}')
def step(self, pc, line):
"""한 줄 실행. 점프하면 다음 줄 번호를, 아니면 None 을 돌려준다."""
if line == '' or line in ('어떻게', '이 사람이름이냐ㅋㅋ'):
return None
if '동탄' in line:
cond, cmd = line.replace('동탄', '').split('?', 1)
taken = self.num(cond) == 0
self.log(pc, f'if {self.num(cond)}==0 -> {"taken" if taken else "fall"}')
return self.step(pc, cmd.strip()) if taken else None
if '준' in line:
dst = self.num(line.replace('준', ''))
self.log(pc, f'goto {dst}')
return dst
if '화이팅!' in line:
sys.stdout.write(str(self.num(line.split('화이팅!')[1])))
sys.exit(0)
if '식' in line and '?' in line:
self.var[line.replace('식?', '').count('어')] = int(input())
return None
if '식' in line and line.endswith('!'):
val = self.num(line[1:-1])
self.emit(str(val))
self.log(pc, f'print_int {val}')
return None
if '식' in line and line.endswith('ㅋ'):
val = self.num(line[1:-1])
ch = chr(val) if val else '\n'
self.emit(ch)
self.log(pc, f'putchar {val} -> {ch!r}')
return None
if '엄' in line:
head, val = line.split('엄', 1)
idx = head.count('어')
self.var[idx] = self.num(val)
self.log(pc, f'var{idx + 1} = {self.var[idx]}')
return None
raise SyntaxError(f'line {pc}: 어떻게 이게 엄랭이냐! {line!r}')
def emit(self, s):
self.out.append(s)
if not self.trace: # 추적 중이면 로그와 섞이지 않게 모아 뒀다 끝에 한 번
sys.stdout.write(s)
sys.stdout.flush()
def run(self, budget=100000):
pc = 1
while 1 <= pc <= len(self.lines):
if budget <= 0:
raise RecursionError(f'스텝 예산 소진, pc={pc} (무한 루프)')
budget -= 1
jump = self.step(pc, self.lines[pc - 1].strip())
pc = jump if jump is not None else pc + 1
return ''.join(self.out)
def main():
args = [a for a in sys.argv[1:] if not a.startswith('--')]
path = args[0] if args else 'extracted/main.umm'
lo = int(sys.argv[sys.argv.index('--from') + 1]) if '--from' in sys.argv else 1
hi = int(sys.argv[sys.argv.index('--to') + 1]) if '--to' in sys.argv else 10 ** 9
src = open(path, encoding='utf-8').read()
vm = Umm(src, trace='--trace' in sys.argv, lo=lo, hi=hi)
out = vm.run()
print(f'\n--- stdout {len(out)} chars ---')
print(out)
if __name__ == '__main__':
main()python3 umm_interp.py extracted/main.umm --trace --from 3 --to 14
68 → D, 72 → H, 123 → 여는 중괄호. 플래그가 시작됐다. 11행이 var2+23 이라 123이 나오는데, 100에 23을 더해 중괄호를 만든다는 건 출제자가 var2 를 딱 그 자리에 맞춰 잡았다는 뜻이다.
이대로 44행까지 가면 DH{How_is_Um_Junsik_a_human_name_lol 까지 나온다. 여는 중괄호는 있는데 닫는 중괄호가 아직 없다.
💣 핵심 2 — 45~48행 카운트다운은 장식이 아니다
닫는 중괄호는 49행에 있다(var2+25 = 125). 그런데 44행과 49행 사이에 반복문 네 줄이 끼어 있다. 이 구간이 화면에 뭘 남기는지가 이 문제의 전부다.
동탄 과 준 의 정확한 의미를 다시 확인한다.
sed -n "225,238p" ref/umjunsik-README.md
두 가지가 확정된다.
조건은 "참이면"이 아니라 "0이면"이다. 47행 동탄어어어어?준어어어어어.... 는 var4 가 0일 때만 준 (var5+4) 를 탄다. 0이 아니면 다음 줄로 내려가고, 48행이 무조건 준 var5 로 되돌린다.
준 이 세는 줄 번호는 파일의 물리적 줄이다. 어떻게 가 1번이고 빈 줄도 그대로 센다. var5=45 가 정확히 45행(반복문 첫 줄)을 가리키고 var5+4=49 가 마지막 출력 줄을 가리키는 것이 그 증거다. 빈 줄을 빼고 세면 두 상수 모두 엉뚱한 데를 찍는다.
이제 그 구간만 추적해 본다.
python3 umm_interp.py extracted/main.umm --trace --from 46 --to 49
var4 는 11에서 시작해 감소가 먼저 일어나므로 10, 9, 8, … 1, 0 열한 개가 찍힌다. 46행은 식…! 라 문자가 아니라 10진 정수로 나간다. 그래서 화면에 남는 문자열은 10 9 8 … 이 이어붙은 109876543210 이다.
lol 다음에 이 열두 글자가 오고 그다음이 닫는 중괄호다. 반복문을 디버그용 출력으로 착각하고 버리면 플래그가 통째로 달라진다.
🐛 삽질 — 오독 네 갈래를 실제로 돌려 봤다
▶🐛 삽질 — 문법을 조금씩 잘못 읽으면 무엇이 나오나
읽는 방식을 확정하기 전에 몇 가지가 계속 걸렸다. 공백을 정말 곱하기로 봐야 하나, 어 개수 기준이 읽기와 쓰기에서 정말 다른가, 준 이 빈 줄을 세나, 카운트다운 숫자가 플래그에 들어가는 게 맞나.
말로 따지는 대신 각 오독을 인터프리터에 심어 실제로 돌렸다. 결과가 화면에 나오면 더 볼 것도 없다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""traps.py — 엄랭 문법을 조금씩 잘못 읽으면 무엇이 나오는지 실측한다.
네 가지 오독을 각각 인터프리터에 심어 돌려 보고 진짜 답과 대조한다.
python3 traps.py
"""
import contextlib
import io
from umm_interp import Umm
SRC = open('extracted/main.umm', encoding='utf-8').read()
REAL = 'DH{How_is_Um_Junsik_a_human_name_lol109876543210}'
LINES = SRC.rstrip('\n').split('\n')
class NoMul(Umm):
"""오독 1: 공백을 그냥 여백으로 보고 곱하기를 빼먹는다."""
def num(self, expr):
eo = expr.count('어')
return (self.var[eo - 1] if eo else 0) + expr.count('.') - expr.count(',')
class OffByOne(Umm):
"""오독 2: 변수 참조에서 '어' 하나를 더 세어 한 칸 밀린 변수를 읽는다."""
def num(self, expr):
result = 1
for tok in expr.split(' '):
eo = tok.count('어')
result *= (self.var[eo] if eo else 0) + tok.count('.') - tok.count(',')
return result
def run(cls, src=SRC):
"""오독본을 돌려 (출력, 사고원인) 을 돌려준다. stdout 은 삼킨다."""
vm = cls(src)
try:
with contextlib.redirect_stdout(io.StringIO()):
return vm.run(budget=3000), ''
except Exception as exc: # noqa: BLE001 - 어떻게 죽는지도 결과다
return ''.join(vm.out), f'{type(exc).__name__}: {exc}'
CASES = [
('오독 1 · 공백=곱하기를 놓침', NoMul, SRC),
('오독 2 · 변수 인덱스 한 칸 밀림', OffByOne, SRC),
('오독 3 · 빈 줄을 빼고 줄 번호를 셈', Umm,
'\n'.join(l for l in LINES if l.strip())),
('오독 4 · 45~48줄 카운트다운을 노이즈로 취급', Umm,
'\n'.join(l for i, l in enumerate(LINES, 1) if not 45 <= i <= 48)),
]
def main():
print(f'[정답] {REAL}')
print()
for label, cls, src in CASES:
out, err = run(cls, src)
print(f'{"OK " if out == REAL else "X "}{label}')
print(f' 출력 {out[:56]!r}{" ..." if len(out) > 56 else ""}')
if err:
print(f' 사고 {err}')
print()
print('오독 1~3 은 화면에서 바로 티가 난다. 오독 4 만 형식도 길이도 멀쩡하다.')
if __name__ == '__main__':
main()python3 traps.py
오독 1 — 공백을 곱하기로 안 봤을 때. var1 이 65가 아니라 18이 되고 var2 는 20이 된다. 첫 글자부터 제어문자다. 게다가 var5 가 45가 아니라 14가 되면서 준 이 14행으로 뛰어 무한 루프가 된다. 화면이 바로 망가지니 이 오독은 오래 못 간다.
오독 2 — 변수 인덱스를 한 칸 밀었을 때. gk 로 시작하는 여섯 글자를 찍다가 chr() 범위를 벗어나 죽는다. 이것도 금방 걸린다.
오독 3 — 준 이 빈 줄을 안 센다고 봤을 때. 여기가 조금 고약하다. DH{How_is_Um_Junsik_a_human_name_lol10 까지는 정확히 맞는 글자가 나온다. 빈 줄을 뺀 번호 45는 원본 47행(동탄 줄)이라 var4 를 줄이는 45행을 건너뛰게 되고, 47행과 48행 사이에서 영원히 돈다. 출력이 맞다가 멈추니 "내 스크립트가 느린가" 싶어진다.
오독 4 — 카운트다운을 노이즈로 봤을 때. 이게 제일 무섭다. 45~48행을 통째로 지우고 돌리면 DH{How_is_Um_Junsik_a_human_name_lol} 이 나온다. 형식도 맞고, 길이도 그럴듯하고, 문장까지 자연스럽게 끝난다. 앞의 셋은 화면이 깨지니 알아서 걸리지만 이건 아무 신호도 안 준다. 제출해 보기 전에는 틀렸다는 걸 알 방법이 없다.
결국 판정은 눈이 아니라 "명세대로 끝까지 실행한 결과"가 해야 한다는 얘기다.
✅ 교차검증 — 남의 구현 두 개로 확인
내가 짠 인터프리터가 명세를 제대로 옮겼는지는 내가 보증할 수 없다. 저장소에 구현체가 열한 종 있으니 그중 둘을 가져다 대조했다.
먼저 공식 파이썬 런타임이다. pip 에 umjunsik 이름으로 올라와 있다. 문제 폴더 안에 venv 를 만들어 깔았다(setup_ref.sh).
#!/usr/bin/env bash
# 교차검증용 레퍼런스 런타임(pip umjunsik)을 문제 폴더 안 venv 로 깐다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
python3 -m venv .venv
./.venv/bin/pip install -q umjunsik
./.venv/bin/pip show umjunsik | sed -n "1,2p".venv/bin/pip show umjunsik | sed -n "1,2p" && .venv/bin/umjunsik extracted/main.umm
출력 끝에 zsh 가 붙인 퍼센트 표시가 보인다. 엄랭 v2 는 개행을 자동으로 찍지 않는다는 뜻이라, 마지막 글자까지 전부 프로그램이 낸 것이다.
두 번째는 C 구현체(umcc)다. 이건 인터프리터가 아니라 엄랭을 C 로 낮춰서 gcc 로 컴파일한다. 해석기 두 개를 비교하는 것보다 훨씬 독립적인 검증이 된다.
#!/usr/bin/env bash
# 엄랭 공식 저장소의 C 구현체(umcc)를 받아 빌드하고 main.umm 을 네이티브로 컴파일한다.
# 산출물: build/main_umcc (실행파일), build/main_umcc.c (생성된 C 소스)
set -eu
cd "$(dirname "$(readlink -f "$0")")"
SRC=${1:-/tmp/umjunsik-lang}
[ -d "$SRC" ] || git clone --depth 1 https://github.com/rycont/umjunsik-lang.git "$SRC"
make -C "$SRC/umjunsik-lang-cc"
mkdir -p build
"$SRC/umjunsik-lang-cc/bin/umcc" extracted/main.umm -o build/main_umcc
"$SRC/umjunsik-lang-cc/bin/umcc" -c extracted/main.umm -o build/main_umcc.c
echo "built: build/main_umcc, build/main_umcc.c"./build_umcc.sh && ./build/main_umcc
umcc -c 를 주면 생성된 C 소스를 볼 수 있다. 그런데 이게 전부 한 줄로 나와서 그대로는 못 읽는다. 줄 라벨 앞에서 개행을 넣어 편 뒤 필요한 구간만 본다.
#!/usr/bin/env bash
# umcc 가 뽑아낸 C 소스는 통째로 한 줄이라 읽을 수가 없다.
# 줄 라벨(_1: _2: ...) 앞에서 개행을 넣어 사람이 읽을 수 있게 편 뒤 필요한 구간만 보여준다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
FROM=${1:-45}
TO=${2:-50}
sed 's/_\([0-9]\{1,\}\):/\n_\1:/g' build/main_umcc.c \
| sed -n "/^_${FROM}:/,/^_${TO}:/p"./show_umcc_c.sh 3 9 && ./show_umcc_c.sh 45 50
읽어 둔 내용이 그대로 나온다. var[0]=(5)*(13) 이 공백이 곱하기라는 것을, _45: 부터 _49: 까지 붙은 라벨이 줄 번호가 물리적 줄 기준이라는 것을 각각 확인해 준다(빈 줄인 8행에도 _8: 라벨이 붙어 있다). 47행의 if((var[3]+0)==0){JUN(var[4]+4)} 는 "0이면 실행"이라는 조건 방향까지 못 박는다.
세 구현을 한 번에 돌려 대조하는 스크립트로 마무리한다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""verify.py — 내 해석이 맞는지 독립 구현 두 개와 대조한다.
1. solve.py 직접 구현 (의존성 없음)
2. umjunsik 2.0.2 pip 레퍼런스 런타임 (.venv/bin/umjunsik)
3. umcc 엄랭 -> C 컴파일러가 만든 네이티브 바이너리 (build/main_umcc)
셋 다 없으면 없는 대로 표시하고 있는 것끼리만 비교한다.
python3 verify.py
"""
import pathlib
import subprocess
import sys
import solve
HERE = pathlib.Path(__file__).resolve().parent
UMM = HERE / 'extracted' / 'main.umm'
def shell(cmd):
"""실행해서 stdout 을 돌려준다. 실행 파일이 없으면 None."""
if not pathlib.Path(cmd[0]).exists():
return None
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
return p.stdout
def main():
results = {}
results['solve.py (직접 구현)'] = solve.evaluate(UMM.read_text(encoding='utf-8'))
results['umjunsik 2.0.2 (pip 레퍼런스)'] = shell([str(HERE / '.venv/bin/umjunsik'), str(UMM)])
results['umcc (엄랭->C 컴파일)'] = shell([str(HERE / 'build/main_umcc')])
for name, out in results.items():
if out is None:
print(f' -- {name:<30} (없음, 건너뜀)')
else:
print(f' {name:<30} {out.strip()!r}')
got = [v.strip() for v in results.values() if v is not None]
print()
if len(got) >= 2 and len(set(got)) == 1:
print(f'MATCH 구현 {len(got)}개가 같은 출력을 낸다: {got[0]}')
return 0
print(f'MISMATCH {set(got)}')
return 1
if __name__ == '__main__':
sys.exit(main())python3 verify.py
🚀 Full Exploit — solve.py
풀이용 최종본은 의존성 없이 이 파일 하나로 돌아간다. 앞의 umm_interp.py 는 추적용이라 클래스로 짰지만, 이쪽은 남에게 그대로 건네줄 수 있게 함수 하나로 합쳤다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""solve.py — DreamHack '엄...' (Bronze 2, misc)
main.umm 은 엄랭(umjunsik-lang) v2 소스다. 외부 런타임 없이 이 파일 하나로
해석해서 flag 를 뽑는다. 의존성 없음.
python3 solve.py # extracted/main.umm
python3 solve.py 다른파일.umm
"""
import re
import sys
MAXVAR = 256
def evaluate(src):
"""엄랭 v2 소스를 실행하고 표준출력 문자열을 돌려준다.
문법 요약
. +1 , -1 ' '(공백) 곱하기
어 * n n번째 변수 참조
(어 * (n-1)) 엄 n번째 변수에 대입
식 <수> ! 정수 출력
식 <수> ㅋ 유니코드 문자 출력 (수가 없으면 개행)
동탄 <수> ? <명령> 수가 0이면 명령 실행
준 <수> 그 번호의 줄로 점프 ('어떻게' 가 1번, 빈 줄도 센다)
화이팅! <수> 그 값을 찍고 종료
"""
lines = src.rstrip('\n').split('\n')
var = [0] * MAXVAR
out = []
def num(expr):
result = 1
for tok in expr.split(' '): # 공백은 곱하기
eo = tok.count('어')
result *= (var[eo - 1] if eo else 0) + tok.count('.') - tok.count(',')
return result
def step(line):
"""한 줄 실행. 점프하면 목적지 줄 번호, 아니면 None."""
if line == '' or line in ('어떻게', '이 사람이름이냐ㅋㅋ'):
return None
if '동탄' in line: # 조건: 0이면 실행
cond, cmd = line.replace('동탄', '').split('?', 1)
return step(cmd.strip()) if num(cond) == 0 else None
if '준' in line: # 무조건 점프
return num(line.replace('준', ''))
if '화이팅!' in line:
out.append(str(num(line.split('화이팅!')[1])))
raise SystemExit
if '식' in line and '?' in line:
var[line.replace('식?', '').count('어')] = int(input())
return None
if '식' in line and line.endswith('!'): # 정수 출력
out.append(str(num(line[1:-1])))
return None
if '식' in line and line.endswith('ㅋ'): # 문자 출력
val = num(line[1:-1])
out.append(chr(val) if val else '\n')
return None
if '엄' in line: # 대입
head, val = line.split('엄', 1)
var[head.count('어')] = num(val)
return None
raise SyntaxError(f'어떻게 이게 엄랭이냐! {line!r}')
pc, budget = 1, 1000000
try:
while 1 <= pc <= len(lines):
budget -= 1
if budget <= 0:
raise RecursionError(f'무한 루프 (pc={pc})')
jump = step(lines[pc - 1].strip())
pc = jump if jump is not None else pc + 1
except SystemExit:
pass
return ''.join(out)
def main():
path = sys.argv[1] if len(sys.argv) > 1 else 'extracted/main.umm'
text = evaluate(open(path, encoding='utf-8').read())
print(f'[출력] {text}')
flag = re.search(r'DH\{[^}]+\}', text)
if not flag:
print('[!] flag 형식을 못 찾았다')
sys.exit(1)
print(f'[FLAG] {flag.group(0)}')
if __name__ == '__main__':
main()python3 solve.py
플래그는 DH{How_is_Um_Junsik_a_human_name_lol109876543210} 다.
백업 폴더만 들고도 다시 뽑을 수 있게 재현 스크립트도 같이 남긴다. 정답과 대조까지 스스로 한다.
#!/usr/bin/env bash
# 엄... (DreamHack Bronze 2, misc) — 한 방 재현.
# extracted/main.umm 을 직접 구현한 엄랭 인터프리터로 해석해 flag 를 뽑고 대조한다.
# 외부 런타임·네트워크 불필요. python3 만 있으면 된다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
OUT=$(timeout 60 python3 solve.py 2>&1) || true
echo "$OUT"
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -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
📝 결론
세는 문제는 세는 도구를 먼저 만든다
온점 15개와 17개는 화면에서 구분이 안 된다. 이 문제에서 실제로 시간을 아껴 준 건 기법이 아니라 51줄을 기호식으로 펴 주는 82줄짜리 디스어셈블러였다. 손으로 세다 한 글자 틀리면 그 글자가 어디서 틀렸는지 되짚는 데 훨씬 오래 걸린다.
명세는 링크만 받았어도 원문을 본다
공백이 곱하기라는 건 코드를 아무리 노려봐도 안 나온다. README 연산자 절 세 줄에 적혀 있다. 문제 설명이 URL 하나뿐이면 그 URL 이 곧 문제의 절반이다.
그럴듯한 답이 제일 위험하다
카운트다운을 버리고 얻는 DH{How_is_Um_Junsik_a_human_name_lol} 은 형식도 문장도 멀쩡하다. 나머지 오독 셋은 화면이 깨져서 스스로 걸리는데 이것만 아무 신호를 안 준다. 실측해 보기 전까지는 "그럴듯함"이 아무것도 보증하지 않는다는 걸 다시 확인했다.
검증은 남의 구현으로 한다
내가 짠 해석기가 명세를 옳게 옮겼는지는 내가 판정할 수 없다. 같은 오해를 두 번 하기 때문이다. pip 레퍼런스 런타임과 C 컴파일러, 계보가 다른 구현 둘을 붙여 세 결과가 한 글자도 다르지 않은 걸 보고서야 제출했다.
난해한 언어라도 결국 인터프리터다
어·엄·식·동탄·준 을 각각 참조·대입·출력·분기·점프로 바꿔 놓으면 변수 다섯 개짜리 평범한 절차형 프로그램이다. 겉모습이 낯설어서 어려워 보일 뿐, 해야 할 일은 명세를 표로 옮기고 그 표대로 실행기를 짜는 것뿐이었다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…