[🥉 Bronze 2] 난해한 언어 엄랭 읽어내기 — DreamHack 엄... 풀이

2026-08-19·1분 읽기·

[🥉 Bronze 2] 난해한 언어 엄랭 읽어내기 — DreamHack 엄... 풀이

배포본은 온점과 반점, 그리고 "어" "엄" "식" 만으로 채워진 .umm 파일 한 개다. 문제 설명은 GitHub 링크 하나뿐. 엄랭(umjunsik-lang) v2 문법을 확정하고 디스어셈블러와 인터프리터를 직접 짜서 51줄을 해석했다. 공백이 곱하기라는 것과, 마지막 카운트다운 루프가 장식이 아니라 플래그의 일부라는 것이 관문이었다.

문제: DreamHack — 엄... 분류: misc 난이도: 🥉 Bronze 2 FLAG: DH{How_is_Um_Junsik_a_human_name_lol109876543210}

문제 페이지에는 설명이 한 줄도 없다. 링크 하나가 전부다.

문제 설명이 가리키는 rycont/umjunsik-lang 저장소 첫 화면. About 란의 "어떻게 엄준식이 언어이름이냐" 문구, 별 835개, 그리고 언어별 구현체 폴더가 줄줄이 늘어선 파일 목록이 보인다
문제 설명이 가리키는 rycont/umjunsik-lang 저장소 첫 화면. About 란의 "어떻게 엄준식이 언어이름이냐" 문구, 별 835개, 그리고 언어별 구현체 폴더가 줄줄이 늘어선 파일 목록이 보인다

별 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

recon.sh 실행 결과 — file 은 UTF-8 텍스트, wc 는 51줄 1066바이트로 보고하고 문자 인구조사에서 온점 343개와 어 106개, 공백은 단 4개로 집계된다
recon.sh 실행 결과 — file 은 UTF-8 텍스트, wc 는 51줄 1066바이트로 보고하고 문자 인구조사에서 온점 343개와 어 106개, 공백은 단 4개로 집계된다

읽어낼 것이 세 가지다.

글자 수와 바이트 수가 다르다. 어·엄·식·ㅋ 는 UTF-8 로 3바이트다. 1,066바이트짜리 파일이지만 실제 글자는 그보다 훨씬 적다. 바이트 단위로 grep -c 같은 걸 돌리면 숫자가 어긋난다.

등장 글자가 스무 종류뿐이다. 온점 343, 어 106, 반점 60, ㅋ 39, 식 38, 엄 6. 나머지는 한 자리 수다. 언어의 어휘 자체가 이만큼 좁다는 뜻이다.

공백이 딱 4개다. 이게 제일 중요한 관찰이다. 마지막 줄 이 사람이름이냐ㅋㅋ 에 있는 하나를 빼면 코드 본문의 공백은 3개뿐이다. 이 언어에서 공백은 여백이 아니라 연산자다.

파일 전문은 이렇다.

어떻게
 
엄..... .............
어엄.......... ..........
어어엄..
어어어엄...........
어어어어엄..... .........
 
식어...ㅋ
식어.......ㅋ
식어어.......................ㅋ
식어.......ㅋ
식어어...........ㅋ
식어어...................ㅋ
식어어,,,,,ㅋ
식어어.....ㅋ
식어어...............ㅋ
식어어,,,,,ㅋ
식어어,,,,,,,,,,,,,,,ㅋ
식어어.........ㅋ
식어어,,,,,ㅋ
식어.........ㅋ
식어어.................ㅋ
식어어..........ㅋ
식어어...............ㅋ
식어어.....ㅋ
식어어.......ㅋ
식어어,,,,,ㅋ
식어어,,,ㅋ
식어어,,,,,ㅋ
식어어....ㅋ
식어어.................ㅋ
식어어.........ㅋ
식어어,,,ㅋ
식어어..........ㅋ
식어어,,,,,ㅋ
식어어..........ㅋ
식어어,,,ㅋ
식어어.........ㅋ
식어어.ㅋ
식어어,,,,,ㅋ
식어어........ㅋ
식어어...........ㅋ
식어어........ㅋ
어어어엄어어어어,
식어어어어!
동탄어어어어?준어어어어어....
준어어어어어
식어어.........................ㅋ
 
이 사람이름이냐ㅋㅋ

식어어...ㅋ 가 줄줄이 늘어선 구간은 한눈에 봐도 문자 출력이다. 문제는 그 아래 다섯 줄이다. 동탄·준 이 섞인 그 구간을 어떻게 읽느냐로 답이 갈린다.


🧩 배경 — 엄랭(umjunsik-lang) v2

문법은 추측하지 않고 저장소 README 에서 확정한다. 2020년에 공개된 언어고 지금 배포되는 건 v2 다.

브라우저로 연 umjunsik-lang README 의 문법 섹션. 자료형은 온점 개수만큼 더하고 반점 개수만큼 뺀다고 적혀 있고 연산자 표에 곱하기가 공백으로 명시돼 있다
브라우저로 연 umjunsik-lang README 의 문법 섹션. 자료형은 온점 개수만큼 더하고 반점 개수만큼 뺀다고 적혀 있고 연산자 표에 곱하기가 공백으로 명시돼 있다

git clone 으로 받아 문제 폴더에 사본을 남겨 두고(ref/umjunsik-README.md), 필요한 절만 잘라서 본다.

sed -n "122,161p" ref/umjunsik-README.md

README 의 연산자·변수 절 원문. 곱하기가 공백이라는 항목과 대입은 연음 갯수번째 변수, 사용도 연음 갯수번째 변수라는 두 설명이 함께 보인다
README 의 연산자·변수 절 원문. 곱하기가 공백이라는 항목과 대입은 연음 갯수번째 변수, 사용도 연음 갯수번째 변수라는 두 설명이 함께 보인다

정리하면 이렇다.

표기뜻
.1 증가
,1 감소
공백곱하기 (항끼리 곱한다)
어 × NN번째 변수를 읽는다
(어 × N) + 엄N+1번째 변수에 쓴다
식 … !사이의 수를 10진 정수로 출력
식 … ㅋ사이의 수를 유니코드 문자로 출력 (수가 없으면 개행)
어떻게 / 이 사람이름이냐ㅋㅋ프로그램 시작·끝 표식

여기서 걸려 넘어지기 쉬운 게 마지막 두 줄이다. 어 는 값이 아니라 개수만 의미를 갖는데, 그 개수를 세는 기준이 읽을 때와 쓸 때가 다르다. 어어 를 수식 안에서 만나면 2번 변수를 읽는 것이고, 어어엄 을 만나면 3번 변수에 쓰는 것이다. 한 칸 어긋난다.

엄랭 한 줄의 해부도. 위쪽은 어 네 개 다음 엄이 오고 온점 다섯 개와 아홉 개가 공백으로 곱해져 5번 변수에 45가 들어가는 과정, 아래쪽은 식과 ㅋ 사이의 2번 변수에 23을 더해 123번 유니코드를 찍는 과정을 색으로 나눠 표시했다
엄랭 한 줄의 해부도. 위쪽은 어 네 개 다음 엄이 오고 온점 다섯 개와 아홉 개가 공백으로 곱해져 5번 변수에 45가 들어가는 과정, 아래쪽은 식과 ㅋ 사이의 2번 변수에 23을 더해 123번 유니코드를 찍는 과정을 색으로 나눠 표시했다

정찰에서 센 공백 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

umm disasm.py 가 1행부터 26행까지를 편 결과. 3행이 var1 에 5 곱하기 13, 4행이 var2 에 10 곱하기 10 을 넣고 9행부터는 putchar 호출이 이어진다
umm disasm.py 가 1행부터 26행까지를 편 결과. 3행이 var1 에 5 곱하기 13, 4행이 var2 에 10 곱하기 10 을 넣고 9행부터는 putchar 호출이 이어진다

python3 umm_disasm.py extracted/main.umm --from 27

umm disasm.py 가 27행부터 51행까지를 편 결과. 45행 var4 감소, 46행 print int, 47행 조건 점프, 48행 무조건 점프가 연달아 나오고 49행에서 putchar 로 끝난다
umm disasm.py 가 27행부터 51행까지를 편 결과. 45행 var4 감소, 46행 print int, 47행 조건 점프, 48행 무조건 점프가 연달아 나오고 49행에서 putchar 로 끝난다

구조가 드러났다.

  • 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

umm interp.py 추적 결과. 3행부터 7행까지 var1 은 65, var2 는 100, var5 는 45 로 채워지고 9행부터 putchar 68 이 D, 72 가 H, 123 이 여는 중괄호로 찍히는 과정이 변수 상태와 함께 나온다
umm interp.py 추적 결과. 3행부터 7행까지 var1 은 65, var2 는 100, var5 는 45 로 채워지고 9행부터 putchar 68 이 D, 72 가 H, 123 이 여는 중괄호로 찍히는 과정이 변수 상태와 함께 나온다

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

README 지시문 절 원문. 동탄은 정수가 0이면 뒤의 명령을 실행하고 아니면 다음 줄로 넘어간다고, 준은 뒤에 오는 정수번째 줄로 이동한다고 적혀 있다
README 지시문 절 원문. 동탄은 정수가 0이면 뒤의 명령을 실행하고 아니면 다음 줄로 넘어간다고, 준은 뒤에 오는 정수번째 줄로 이동한다고 적혀 있다

두 가지가 확정된다.

조건은 "참이면"이 아니라 "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

umm interp.py 로 45행부터 49행 구간을 추적한 결과. var4 가 10 부터 0 까지 줄면서 print int 로 매번 찍히고 0 이 되는 순간 조건이 taken 이 되어 49행으로 뛰어 닫는 중괄호를 출력한다
umm interp.py 로 45행부터 49행 구간을 추적한 결과. var4 가 10 부터 0 까지 줄면서 print int 로 매번 찍히고 0 이 되는 순간 조건이 taken 이 되어 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

traps.py 실행 결과. 오독 1 은 제어문자 범벅에 무한 루프, 오독 2 는 chr 범위 초과로 죽고, 오독 3 은 앞부분만 나오다 무한 루프, 오독 4 만 정상적으로 생긴 문자열을 내놓는다
traps.py 실행 결과. 오독 1 은 제어문자 범벅에 무한 루프, 오독 2 는 chr 범위 초과로 죽고, 오독 3 은 앞부분만 나오다 무한 루프, 오독 4 만 정상적으로 생긴 문자열을 내놓는다

오독 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

pip 레퍼런스 런타임 umjunsik 2.0.2 로 main.umm 을 돌린 결과. 버전 정보에 이어 플래그 문자열이 개행 없이 출력되고 zsh 가 줄 끝에 퍼센트 표시를 남겼다
pip 레퍼런스 런타임 umjunsik 2.0.2 로 main.umm 을 돌린 결과. 버전 정보에 이어 플래그 문자열이 개행 없이 출력되고 zsh 가 줄 끝에 퍼센트 표시를 남겼다

출력 끝에 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

build umcc.sh 가 저장소의 C 구현체를 gcc 로 빌드하고, 그 컴파일러가 main.umm 을 네이티브 실행파일로 바꿔 돌린 결과. 같은 플래그 문자열이 나온다
build umcc.sh 가 저장소의 C 구현체를 gcc 로 빌드하고, 그 컴파일러가 main.umm 을 네이티브 실행파일로 바꿔 돌린 결과. 같은 플래그 문자열이 나온다

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

umcc 가 생성한 C 소스에서 3행부터 9행, 45행부터 50행 구간. var 배열 대입이 5 곱하기 13 형태로 남아 있고 조건 점프가 JUN 매크로로 바뀌어 있다
umcc 가 생성한 C 소스에서 3행부터 9행, 45행부터 50행 구간. var 배열 대입이 5 곱하기 13 형태로 남아 있고 조건 점프가 JUN 매크로로 바뀌어 있다

읽어 둔 내용이 그대로 나온다. 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

verify.py 실행 결과. 직접 구현한 solve.py, pip 레퍼런스 런타임, C 컴파일 결과 세 가지가 모두 같은 문자열을 내고 MATCH 로 판정된다
verify.py 실행 결과. 직접 구현한 solve.py, pip 레퍼런스 런타임, C 컴파일 결과 세 가지가 모두 같은 문자열을 내고 MATCH 로 판정된다



🚀 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

solve.py 실행 결과. 엄랭 프로그램이 낸 출력 전체가 한 줄, 그 안에서 정규식으로 뽑아낸 49글자 플래그가 또 한 줄로 찍혔다. 외부 런타임 없이 이 파일 하나로 나온 값이다
solve.py 실행 결과. 엄랭 프로그램이 낸 출력 전체가 한 줄, 그 안에서 정규식으로 뽑아낸 49글자 플래그가 또 한 줄로 찍혔다. 외부 런타임 없이 이 파일 하나로 나온 값이다

플래그는 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

reproduce.sh 실행 결과. solve.py 출력을 받아 정규식으로 플래그를 뽑고 문제.json 에 기록된 값과 비교해 PASS 로 끝난다
reproduce.sh 실행 결과. solve.py 출력을 받아 정규식으로 플래그를 뽑고 문제.json 에 기록된 값과 비교해 PASS 로 끝난다



📝 결론

세는 문제는 세는 도구를 먼저 만든다

온점 15개와 17개는 화면에서 구분이 안 된다. 이 문제에서 실제로 시간을 아껴 준 건 기법이 아니라 51줄을 기호식으로 펴 주는 82줄짜리 디스어셈블러였다. 손으로 세다 한 글자 틀리면 그 글자가 어디서 틀렸는지 되짚는 데 훨씬 오래 걸린다.

명세는 링크만 받았어도 원문을 본다

공백이 곱하기라는 건 코드를 아무리 노려봐도 안 나온다. README 연산자 절 세 줄에 적혀 있다. 문제 설명이 URL 하나뿐이면 그 URL 이 곧 문제의 절반이다.

그럴듯한 답이 제일 위험하다

카운트다운을 버리고 얻는 DH{How_is_Um_Junsik_a_human_name_lol} 은 형식도 문장도 멀쩡하다. 나머지 오독 셋은 화면이 깨져서 스스로 걸리는데 이것만 아무 신호를 안 준다. 실측해 보기 전까지는 "그럴듯함"이 아무것도 보증하지 않는다는 걸 다시 확인했다.

검증은 남의 구현으로 한다

내가 짠 해석기가 명세를 옳게 옮겼는지는 내가 판정할 수 없다. 같은 오해를 두 번 하기 때문이다. pip 레퍼런스 런타임과 C 컴파일러, 계보가 다른 구현 둘을 붙여 세 결과가 한 글자도 다르지 않은 걸 보고서야 제출했다.

난해한 언어라도 결국 인터프리터다

어·엄·식·동탄·준 을 각각 참조·대입·출력·분기·점프로 바꿔 놓으면 변수 다섯 개짜리 평범한 절차형 프로그램이다. 겉모습이 낯설어서 어려워 보일 뿐, 해야 할 일은 명세를 표로 옮기고 그 표대로 실행기를 짜는 것뿐이었다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 신음소리 13글자로 짠 프로그램에서 Base45 플래그 꺼내기 — DreamHack 어 형이야 풀이
blog

[🥉 Bronze 2] 신음소리 13글자로 짠 프로그램에서 Base45 플래그 꺼내기 — DreamHack 어 형이야 풀이

배포본은 확장자가 .bro 인 22KB 짜리 텍스트 하나다. 등장하는 글자가 마침표까지 합쳐 열세 종류뿐이라 난해한 프로그래밍 언어 "혀엉..." 소스라는 게 금방 드러난다. 명세대로 인터프리터를 짜서 돌리면 영문 안내문이 나오고, 그 안에 따옴표로 묶인 26글자가 하나 박혀 있다. 문제 설명이 말한 "45살" 은 시저 45가 아니라 Base45 였다.
#dreamhack#ctf#misc+7
2026-08-20#dreamhack +5
[🥉 Bronze 4] 반투명 덧칠은 지우개가 아니다 — DreamHack QR Code 풀이
blog

[🥉 Bronze 4] 반투명 덧칠은 지우개가 아니다 — DreamHack QR Code 풀이

QR 코드 위에 검은 낙서가 덮여 있다. 그런데 픽셀값이 열 종류밖에 없고 255, 63, 16, 4, 1 이라는 등비 사슬을 이룬다. 알파 75% 검정을 겹칠 때마다 밑의 밝기가 1/4 씩만 남은 흔적이다. "픽셀값이 0보다 크면 흰 모듈" 한 줄로 29x29 격자를 되살리고, 재인코딩본과 841칸 전수 대조까지 했다.
#dreamhack#ctf#misc+5
2026-08-22#dreamhack +3
[🥉 Bronze 1] 31줄 발췌본이 base64 인코더라는 걸 증명하기 — DreamHack [CodeEngn] Malware L05 풀이
blog

[🥉 Bronze 1] 31줄 발췌본이 base64 인코더라는 걸 증명하기 — DreamHack [CodeEngn] Malware L05 풀이

악성코드 소스 31줄만 주고 "무엇을 하는 함수인가"를 묻는다. 표준 알파벳 64자와 6비트 마스크가 보이니 base64 같은데, 눈으로 찍는 대신 가려진 두 줄을 채워 컴파일하고 입력 304종을 표준과 바이트 단위로 맞춰 봤다. 덤으로 이 구현이 3의 배수 아닌 길이에서 터지고 76열 규격도 어긴다는 것까지 실측했다.
#dreamhack#ctf#misc+6
2026-08-19#dreamhack +3