문제: DreamHack 워게임 — pyc 분류: reversing 난이도: 💠 Platinum 3 FLAG:
GoN{S3lf-m0d1fy1ng-pyc_n0_more_pyc_ch4l13ng3_p1z}
배포 파일은 prob.pyc 하나, 2,308바이트다. 문제 설명은 딱 한 줄이다.
Just analyzing result of
dis.disis not fun at all.
이 한 줄이 문제 전체를 요약한다. dis.dis 결과를 아무리 노려봐도 답이 안 나온다는 뜻이 아니라,
dis.dis 가 보여주는 코드가 실제로 도는 코드가 아니라는 뜻이었다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | pyc |
| 난이도 | 💠 Platinum 3 |
| 분류 | reversing |
| 제공 파일 | prob.pyc (2,308 B) — 서버 없음 |
| 원 출처 | 2022 Spring GoN Open Qual CTF |
| 스택 | CPython 3.10 바이트코드 · marshal · ctypes |
| 핵심 기법 | ctypes 로 co_code 버퍼를 제자리 XOR 하는 자가변형 바이트코드 |
| 부가 함정 | dis 를 죽이는 쓰레기 바이트 · 미끼 플래그 · 도달 불가능한 정답 분기 |
풀이 흐름은 이렇다.
dis.dis 를 돌리면 검사 함수 chk 는 멀쩡히 나오는데 throw 에서 IndexError 로 죽는다.
그 throw 를 손으로 디코드해 보니 ctypes 로 chk.__code__.co_code 의 바이트 버퍼를 잡아
전역 리스트 m 과 XOR 하고 있었다. 그 XOR 결과가 진짜 검사 루틴이고,
그건 4바이트 창을 앞에서 뒤로 밀며 ROTR32 + XOR 0xDEADBEEF 를 거는 스트림 변환이었다.
각 단계가 전단사라 순서만 뒤집으면 그대로 역산된다.
🧩 배경 — .pyc 는 헤더 16바이트 + marshal 덩어리
파이썬이 모듈을 처음 임포트하면 소스를 컴파일해 __pycache__ 에 .pyc 로 캐싱한다.
그 파일 구조는 아주 단순하다. 앞 16바이트가 헤더고, 나머지는 code object 를
marshal 로 직렬화한 것이다.
| 오프셋 | 크기 | 내용 |
|---|---|---|
| 0 | 4 | 매직 넘버 — 바이트코드 버전 식별자 + \r\n\x0a |
| 4 | 4 | 플래그 (0이면 timestamp 기반, 1이면 해시 기반) |
| 8 | 4 | 원본 .py 의 mtime |
| 12 | 4 | 원본 .py 의 크기 |
| 16 | — | marshal.dumps(code_object) |
여기서 제일 먼저 확인할 게 매직 넘버다. marshal 포맷은 파이썬 마이너 버전마다
바뀌기 때문에, 버전이 안 맞으면 로드 자체가 안 된다. 실제로 이 문제 파일을
로컬 3.13으로 열면 ValueError: bad marshal data (unknown type code) 로 끝난다.
그래서 헤더부터 본다. file 명령은 매직 넘버를 알아서 해석해 주고,
importlib.util.MAGIC_NUMBER 와 대조하면 확실해진다.
file extracted/prob.pyc; xxd -l 48 extracted/prob.pyc; docker run --rm python:3.10-slim python -c "import importlib.util,sys;print(\"py\",sys.version.split()[0],\"MAGIC\",importlib.util.MAGIC_NUMBER.hex(\" \"))"
6f 0d 0d 0a → 앞 2바이트 0x0d6f = 3439, 즉 CPython 3.10 이다.
file 도 같은 결론을 내고 원본 .py 는 1,903바이트에 2022년 2월 21일자라고 알려 준다.
로컬에는 3.12와 3.13밖에 없어서 python:3.10-slim 컨테이너를 하나 띄워 그 안에서 작업했다.
아래 스크립트가 헤더 16바이트를 건너뛰고 code object 를 꺼내 dis.dis 로 뿌린다.
# dis_pyc.py — prob.pyc 를 Python 3.10 인터프리터에서 마샬 해제해 dis.dis 로 뿌린다.
import dis, marshal, sys, importlib.util
path = sys.argv[1] if len(sys.argv) > 1 else "prob.pyc"
with open(path, "rb") as f:
magic = f.read(4)
flags = int.from_bytes(f.read(4), "little")
f.read(8) # timestamp + source size
code = marshal.load(f)
print(f"# magic={magic.hex()} flags={flags} python={sys.version.split()[0]}")
dis.dis(code)🔬 정찰 — 모듈 최상위에 뭐가 있나
먼저 모듈 레벨부터 본다. 출력이 길어서 파일로 받아 두고 잘라 봤다.
docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python dis_pyc.py extracted/prob.pyc > dis_top.txt 2>&1; sed -n '1,26p' dis_top.txt
전역이 넷이다.
struct— 임포트만 하고 어디서도 안 쓴다. 미끼다.k— 정수 49개. 검사의 정답값으로 쓰인다.key— 정수 49개. 길이가k와 같아서 자연스럽게 "이걸로 XOR 하나 보다" 싶어진다.m— 정수 126개. 나중에 이게 진짜 열쇠였다.
k 와 key 가 49바이트라는 건 답도 49바이트라는 뜻이다. 그다음이 함수 정의와 실행부다.
sed -n '27,86p' dis_top.txt
읽어 보면 원본 소스는 대략 이런 모양이었을 것이다.
ipt = input().encode() # 22행
import ctypes # 24행
def throw(): ... # 25행
try: # 44행
if ipt.find('GoN{') != -1 and chk(ipt) == bytes(k):
print('Good!') # 46행
except: # 47행
throw() # 48행플래그 접두사가 GoN{ 인 것까지 여기서 나온다. 드림핵 문제인데 DH{ 가 아닌 이유는
이 문제가 2022 Spring GoN Open Qual CTF 출제작을 옮겨 온 것이기 때문이다.
그런데 저 try 블록이 이상하다. ipt 는 input().encode() 라 bytes 인데,
find 에 넘기는 건 str 리터럴 'GoN{' 이다. 파이썬 3에서 이건 타입 에러다.
# why_throw.py — main 의 try 블록이 '항상' 예외로 빠지는 이유를 확인한다.
ipt = "GoN{aaaa}".encode()
print(f"ipt = {ipt!r} (type={type(ipt).__name__})")
try:
print(ipt.find("GoN{"))
except TypeError as e:
print(f"ipt.find('GoN{{') → TypeError: {e}")
print(f"참고: 올바른 호출 ipt.find(b'GoN{{') = {ipt.find(b'GoN{')}")docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python why_throw.py
argument should be integer or bytes-like object, not 'str'.
즉 어떤 입력을 넣어도 try 는 첫 줄에서 터진다. chk(ipt) == bytes(k) 는 평가조차 안 되고,
print('Good!') 도 도달 불가능하다. 실행은 100% except 로 가고, 거기서 throw() 가 불린다.
if 문 전체가 미끼였다. 진짜 검사는 throw 안에 있다.
💥 dis 가 throw 에서 죽는다
그래서 throw 를 보려는데, 여기서 문제 설명이 말한 그 일이 벌어진다.
sed -n '163,184p' dis_top.txt
찍힌 건 딱 한 줄이다.
26 0 JUMP_ABSOLUTE 3 (to 6)
Traceback (most recent call last):
...
IndexError: tuple index out of rangedis 는 바이트코드를 선형으로 훑는다. 진입점부터 2바이트씩 잘라 순서대로 해석할 뿐,
점프를 따라가지 않는다. 그래서 첫 명령이 6번지로 뛰라고 해도 그냥 2번지부터 계속 읽는다.
throw 의 co_code 앞부분은 이렇게 생겼다.
오프셋 바이트 dis 의 해석 실제
0 71 03 JUMP_ABSOLUTE 3 6번지로 점프 (인자는 명령 단위 × 2)
2 65 ff LOAD_NAME 255 ← 실행되지 않는 쓰레기
4 ff 09 (opcode 255 = 미정의) ← 실행되지 않는 쓰레기
6 09 09 NOP 여기가 진짜 시작throw 의 co_names 는 17개뿐인데 2번지에서 LOAD_NAME 255 를 만나니
_get_name_info 가 co_names[255] 를 집으려다 IndexError 로 터진다.
점프로 건너뛰는 자리에 이름 인덱스가 큰 명령을 두 개 심어 둔 것이다.
실행에는 아무 영향이 없고 정적 분석기만 죽는다.
이런 걸 만나면 방법은 둘이다. 점프를 따라가는 디스어셈블러를 쓰거나,
착지 지점부터 직접 디코드하거나. 후자를 택했다. dis 모듈이 opcode 테이블
(dis.opname, dis.hasname, dis.hasjabs …)을 그대로 노출하니 40줄이면 된다.
🐛 첫 갈림길 — dis 가 보여준 chk 를 곧이곧대로 풀면
throw 를 파기 전에 chk 부터 봤다. 여기가 이 문제의 첫 갈림길이다.
▶🕳️ 이 길로 가면 미끼 플래그가 나온다
dis 가 뿌려 준 chk 는 아주 멀쩡하게 읽힌다.
sed -n '87,163p' dis_top.txt
정리하면 이렇다.
def chk(ipt):
i = range(3) # 바로 덮인다
i = len([3, 4]) # 또 덮인다
r0 = int.from_bytes(r0, 'little').to_bytes(4, 'little')
r1, r2, r3, r4, r5 = 16, 32, 0xffffffff, 0xdeadbeef, 'Good!'
res = range(int(str('3'))) # 덮인다
res = list()
for i, j in zip(list(ipt), key):
res.append(i ^ j)
return bytes(res)옮겨 적다 틀린 게 아니다. 세 번째 줄은 바이트코드가 정말로 r0 을 대입하기 전에 읽는다
(LOAD_FAST 2 (r0) 이 STORE_FAST 2 (r0) 보다 먼저 온다). 실행됐으면
UnboundLocalError 가 났을 코드다. 애초에 실행될 일이 없으니 상관없었던 것이다.
의미가 있는 건 마지막 세 줄뿐이다. ipt 를 key 와 XOR 해서 돌려준다는 것.
그럼 chk(ipt) == bytes(k) 를 만족하는 답은 k XOR key 다. 계산해 봤다.
# decoy.py — dis 가 보여준 '가짜 chk' 를 곧이곧대로 역산하면 무엇이 나오는가.
import marshal
with open("extracted/prob.pyc", "rb") as f:
f.read(16)
top = marshal.load(f)
k = bytes(top.co_consts[2])
key = bytes(top.co_consts[3])
naive = bytes(a ^ b for a, b in zip(k, key))
print(f"[*] len(k)={len(k)} len(key)={len(key)}")
print(f"[*] k XOR key = {naive.hex()}")
print(f"[*] 그대로 디코드: {naive!r}")docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python decoy.py
b'NoD{No!_Th15_1s_N0t_Fl4G_Y0U_n33d_t0_rev_more!@#}'GoN 을 뒤집은 NoD 로 시작하는, 읽히는 문자열이 나온다.
출제자가 정확히 여기까지 온 사람을 위해 심어 둔 미끼다.
길이도 49바이트로 딱 맞고 형식도 그럴듯해서, 확인 없이 제출하면 오답만 받고 이유를 모른다.
문자열 자체가 "이건 플래그가 아니고 리버싱을 더 해야 한다"는 안내이기도 하다.
이 길로 안 빠진 건 코드가 너무 이상했기 때문이다.
r0 부터 r5 까지 여섯 개를 대입해 놓고 하나도 안 쓰고,
res 를 세 번 덮어쓰고, i 를 두 번 덮어쓴다.
컴파일러가 이런 걸 남기지 않는다. 사람이 쓴 코드도 아니다.
게다가 0xdeadbeef 같은 상수가 대입만 되고 아무 데도 안 쓰인다.
뭔가와 XOR 해서 다른 코드가 되도록 맞춰 놓은 바이트열이라고 보는 게 자연스러웠다.
위 결과는 throw 를 다 풀고 난 뒤에 "그 길로 갔으면 뭐가 나왔을까" 싶어 돌려 본 것이다.
Platinum 3 짜리 문제가 XOR 한 줄로 끝날 리 없다는 감이 아니라,
죽은 대입 여섯 개라는 눈에 보이는 근거가 방향을 갈랐다.
🔎 dis 가 못 하는 일을 직접 한다
dis 가 죽는 자리에서 멈출 수는 없으니 원시 필드부터 통째로 꺼냈다.
code object 의 co_names · co_varnames · co_consts · co_code 는
dis 없이도 그냥 읽을 수 있다.
# dump_code.py — dis 가 포기한 코드 객체까지 전부 훑어 원시 필드를 그대로 뽑는다.
import marshal, sys, types
def load(path):
with open(path, "rb") as f:
f.read(16)
return marshal.load(f)
def walk(co, depth=0):
pad = " " * depth
print(f"{pad}=== code {co.co_name!r} (line {co.co_firstlineno}) ===")
print(f"{pad}argcount={co.co_argcount} nlocals={co.co_nlocals} stacksize={co.co_stacksize} flags={co.co_flags:#x}")
print(f"{pad}names = {co.co_names}")
print(f"{pad}varnames = {co.co_varnames}")
print(f"{pad}consts = {[c if not isinstance(c, types.CodeType) else f'<code {c.co_name}>' for c in co.co_consts]}")
print(f"{pad}co_code({len(co.co_code)}B) = {co.co_code.hex()}")
print()
for c in co.co_consts:
if isinstance(c, types.CodeType):
walk(c, depth + 1)
walk(load(sys.argv[1]))docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python dump_code.py extracted/prob.pyc > dump_code.txt 2>&1; sed -n '/=== code .throw./,$p' dump_code.txt
dis 는 죽었지만 이름 목록은 그대로 나온다. 그리고 이 목록만으로도 무슨 일을 하는지 짐작이 간다.
('ctypes', 'c_char', 'len', 'm', 'from_address', 'id', 'chk',
'__code__', 'co_code', 'zip', 'raw', 'append', 'bytes', 'ipt', 'k',
'print', 'co_consts')ctypes.c_char · from_address · id · chk.__code__.co_code · raw.
자기 바이트코드의 주소를 구해서 그 메모리를 직접 만진다는 뜻이다.
3.10 바이트코드는 2바이트 고정이라 손으로 읽을 만하다
디코더를 짜기 전에 형식만 짚고 간다. Python 3.6부터 바이트코드는 wordcode 라
모든 명령이 (opcode, arg) 2바이트 고정이다. 인자가 없는 명령도 자리를 채우려고
0을 달고 다닌다. 그래서 짝수 오프셋만 보면 되고, 명령 길이를 계산할 필요가 없다.
인자는 8비트라 255까지밖에 못 담는데, 그보다 크면 앞에 EXTENDED_ARG 를 붙여
8비트씩 이어 붙인다. 그래서 디코더는 EXTENDED_ARG 를 만나면 그 값을 왼쪽으로
8비트 밀어 다음 명령의 인자에 OR 해 줘야 한다. 이 문제는 인자가 다 작아서
실제로 등장하진 않지만, 빼먹으면 다른 파일에서 조용히 틀린다.
인자가 무슨 뜻인지는 opcode마다 다르고, dis 모듈이 그걸 집합으로 공개한다.
| 집합 | 인자의 의미 | 예 |
|---|---|---|
dis.hasname | co_names 의 인덱스 | LOAD_GLOBAL · LOAD_ATTR · STORE_ATTR |
dis.hasconst | co_consts 의 인덱스 | LOAD_CONST |
dis.haslocal | co_varnames 의 인덱스 | LOAD_FAST · STORE_FAST |
dis.hasjabs | 절대 점프 대상 (명령 단위) | JUMP_ABSOLUTE |
dis.hasjrel | 상대 점프 오프셋 (명령 단위) | FOR_ITER · SETUP_FINALLY |
dis.hascompare | dis.cmp_op 의 인덱스 | COMPARE_OP |
점프 인자가 바이트가 아니라 명령 단위라는 게 3.10의 특징이다.
JUMP_ABSOLUTE 3 이 3번지가 아니라 6번지를 뜻하는 게 그래서다.
3.11에서 다시 바뀌므로 버전을 확인하고 읽어야 한다.
이 여섯 집합만 알면 dis 가 붙여 주는 괄호 주석을 그대로 재현할 수 있다.
이제 6번지부터 디코드하면 된다. 아래가 그 미니 디스어셈블러다.
이름·상수 인덱스가 범위를 벗어나도 죽지 않게 <OOB> 로 표기한다.
# disasm_raw.py — dis 가 포기한 코드 객체를, 실제 실행 흐름이 착지하는 오프셋부터 직접 디코드한다.
import dis, marshal, sys, types
def load(path):
with open(path, "rb") as f:
f.read(16)
return marshal.load(f)
def find(co, name):
if co.co_name == name:
return co
for c in co.co_consts:
if isinstance(c, types.CodeType):
r = find(c, name)
if r:
return r
def safe(seq, i):
try:
v = seq[i]
except IndexError:
return "<OOB>"
return f"<code {v.co_name}>" if isinstance(v, types.CodeType) else repr(v)
def show(co, start=0):
code = co.co_code
i, ext = start, 0
while i < len(code):
op, arg = code[i], code[i + 1]
arg |= ext
ext = 0
nm = dis.opname[op]
extra = ""
if op in dis.hasname:
extra = f"({safe(co.co_names, arg)})"
elif op in dis.hasconst:
extra = f"({safe(co.co_consts, arg)})"
elif op in dis.haslocal:
extra = f"({safe(co.co_varnames, arg)})"
elif op in dis.hasjabs:
extra = f"-> {arg * 2}"
elif op in dis.hasjrel:
extra = f"-> {i + 2 + arg * 2}"
elif op in dis.hascompare:
extra = f"({dis.cmp_op[arg]})"
print(f"{i:5} {op:3} {nm:<24} {arg:<4} {extra}")
if nm == "EXTENDED_ARG":
ext = arg << 8
i += 2
co = load(sys.argv[1])
target = find(co, sys.argv[2])
show(target, int(sys.argv[3]) if len(sys.argv) > 3 else 0)세 번째 인자가 시작 오프셋이다. JUMP_ABSOLUTE 3 이 가리키는 6을 넣는다.
docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python disasm_raw.py extracted/prob.pyc throw 6 > dis_throw.txt; sed -n '1,33p' dis_throw.txt
6번지의 NOP 부터 정상적으로 읽힌다. 앞 절반을 파이썬으로 되돌리면 이렇다.
ptr1 = (ctypes.c_char * len(m)).from_address(id(chk.__code__.co_code) + 32)
res = []
for i, j in zip(ptr1.raw, m):
res.append(i ^ j)뒷부분도 마저 본다.
sed -n '34,85p' dis_throw.txt
전체를 복원하면 throw 는 이렇게 생긴 함수다.
def throw():
# chk 의 바이트코드 버퍼를 잡는다
ptr1 = (ctypes.c_char * len(m)).from_address(id(chk.__code__.co_code) + 32)
# 1차 XOR — 여기서 chk 가 '진짜 chk' 로 바뀐다
res = []
for i, j in zip(ptr1.raw, m):
res.append(i ^ j)
ptr1.raw = bytes(res)
# 진짜 검사
if chk(ipt) == bytes(k):
print(chk.__code__.co_consts[8]) # co_consts[8] == 'Good!'
# 2차 XOR — 원본으로 되돌린다
res = []
for i, j in zip(ptr1.raw, m):
res.append(i ^ j)
ptr1.raw = bytes(res)성공 메시지조차 문자열 리터럴로 두지 않고 chk.__code__.co_consts[8] 에서 꺼내 온다.
가짜 chk 의 상수 목록에 'Good!' 이 들어 있던 게 그래서였다.
💣 핵심 — id(bytes) + 32 는 왜 바이트 버퍼인가
이 문제의 급소는 저 + 32 다.
co_code 는 bytes 객체다. 파이썬 레벨에서는 불변이지만, CPython 구현에서
bytes 는 PyBytesObject 구조체고 그 안에 실제 바이트 배열이 인라인으로 붙어 있다.
typedef struct {
PyObject_VAR_HEAD /* ob_refcnt(8) + ob_type(8) + ob_size(8) */
Py_hash_t ob_shash; /* 8 */
char ob_sval[1]; /* ← 여기부터 실제 바이트 */
} PyBytesObject;64비트 CPython에서 앞 네 필드가 정확히 8바이트씩, 합쳐서 32바이트다.
그리고 id(obj) 는 CPython에서 그 객체의 주소를 그대로 돌려준다.
그러니 id(b) + 32 는 ob_sval, 즉 바이트 배열의 시작 주소다.
말로만 하면 미덥지 않으니 직접 확인했다.
# bytes_layout.py — throw() 가 쓰는 id(bytes 객체) + 32 가 정말 바이트 버퍼의 시작인지 확인한다.
import ctypes, sys, struct
b = b"ABCDEFGH-0123456789"
addr = id(b)
print(f"python = {sys.version.split()[0]}")
print(f"id(b) = {addr:#x}")
print(f"헤더 32B 덤프 = {ctypes.string_at(addr, 32).hex(' ')}")
refcnt, _typ, size, shash = struct.unpack("qqqq", ctypes.string_at(addr, 32))
print(f" ob_size = {size} (len(b) = {len(b)})")
print(f"id(b)+32 에서 읽기 = {ctypes.string_at(addr + 32, len(b))!r}")
print(f"원본 = {b!r}")
print(f"일치? = {ctypes.string_at(addr + 32, len(b)) == b}")
# throw() 와 똑같이 c_char 배열을 씌워 제자리 수정까지 해 본다
buf = (ctypes.c_char * len(b)).from_address(addr + 32)
buf.raw = bytes(c ^ 0x20 for c in b)
print(f"XOR 0x20 후 b = {b!r} ← 불변 객체가 바뀌었다")docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python bytes_layout.py
ob_size 자리에서 19가 나오고, id(b)+32 부터 19바이트를 읽으면 원본이 그대로 나온다.
마지막 줄에서는 그 리터럴을 제자리에서 0x20 과 XOR 해 대문자를 소문자로 바꿔 놓았다.
파이썬이 불변이라고 부르는 객체가 ctypes 한 줄에 바뀐다.
throw 는 이걸 chk 의 바이트코드에 쓴다. code object 를 새로 만들거나
chk.__code__ 를 교체하는 게 아니라, 이미 로드된 바이트코드 버퍼를 그 자리에서 덮는다.
그래서 chk 라는 함수 객체도, code object 도, co_names · co_consts 도 전부 그대로다.
바뀌는 건 명령 스트림 126바이트뿐이다.
여기까지의 그림을 한 장으로 정리하면 이렇다.

126과 128 — 길이가 어긋나 있다
작은 디테일 하나. chk.co_code 는 128바이트인데 m 은 126개다.
throw 가 잡는 창도 len(m) 이라 126바이트다.
zip 은 짧은 쪽에서 멈추므로 마지막 2바이트는 XOR 되지 않고 원본 그대로 남는다.
그 2바이트가 53 00, 즉 RETURN_VALUE 다. 가짜 코드에서도 진짜 코드에서도
마지막 명령이 같아서 굳이 바꿀 필요가 없었던 것이다.
이걸 놓치고 128바이트 마스크를 기대하면 뒤 2바이트가 어긋난다.
정적으로 재현할 때는 zip 을 그대로 쓰면 자연스럽게 맞는다.
🧩 진짜 chk 복원
패치를 정적으로 재현하는 건 간단하다. co_code 와 m 을 XOR 해서
code.replace(co_code=...) 로 갈아 끼우면 dis 가 읽어 준다.
# patch_chk.py — throw() 가 런타임에 하는 XOR 패치를 정적으로 재현해 진짜 chk 를 만든다.
import dis, marshal, sys, types
with open("extracted/prob.pyc", "rb") as f:
f.read(16)
top = marshal.load(f)
consts = top.co_consts
m = consts[4] # 패치 마스크
chk = next(c for c in consts if isinstance(c, types.CodeType) and c.co_name == "chk")
real = bytes(a ^ b for a, b in zip(chk.co_code, m))
print(f"# len(co_code)={len(chk.co_code)} len(m)={len(m)}")
print(f"# real co_code = {real.hex()}\n")
real_code = chk.replace(co_code=real)
dis.dis(real_code)docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python patch_chk.py
이번엔 전혀 다른 코드가 나온다. 스택 순서를 따라가 보면 이렇다.
44 LOAD_FAST 0 (ipt) / 46 LOAD_CONST 0 (None) / 48 LOAD_FAST 1 (i)
50 BUILD_SLICE 2 / 52 BINARY_SUBSCR → ipt[:i]
54 LOAD_FAST 2 (r0)
56 LOAD_FAST 1 (i) / 58 LOAD_CONST 4 (16) / 60 BINARY_ADD
62 LOAD_CONST 5 (32) / 64 BINARY_MODULO → (i + 16) % 32
66 BINARY_RSHIFT → r0 >> ((i + 16) % 32)
68 LOAD_FAST 2 (r0)
70 LOAD_CONST 4 (16) / 72 LOAD_FAST 1 (i) / 74 BINARY_SUBTRACT
76 LOAD_CONST 5 (32) / 78 BINARY_MODULO → (16 - i) % 32
80 BINARY_LSHIFT → r0 << ((16 - i) % 32)
82 BINARY_OR
84 LOAD_CONST 6 (4294967295) / 86 BINARY_AND → & 0xFFFFFFFF
88 LOAD_CONST 7 (3735928559) / 90 BINARY_XOR → ^ 0xDEADBEEF아까 가짜 chk 에서 대입만 되고 안 쓰이던 16 · 32 · 0xffffffff · 0xdeadbeef 가
전부 여기서 쓰인다. 상수 목록은 그대로 두고 명령만 바꿨기 때문에 가짜 쪽에는
쓸 데 없는 상수가 남아 있었던 것이다. 위화감의 정체가 이거였다.
전체를 파이썬으로 되돌리면 이렇다.
def chk(ipt):
for i in range(len(ipt) - 3):
r0 = int.from_bytes(ipt[i:i+4], 'little')
v = (((r0 >> ((i + 16) % 32)) | (r0 << ((16 - i) % 32))) & 0xFFFFFFFF) ^ 0xDEADBEEF
ipt = ipt[:i] + v.to_bytes(4, 'little') + ipt[i+4:]
return ipti 를 0부터 len(ipt) - 4 까지 밀면서, 매번 그 자리의 4바이트를 리틀엔디언 정수로 읽고
변환한 값을 같은 자리에 다시 써 넣는다. 창이 1바이트씩만 움직이므로
앞에서 바뀐 결과가 뒤 단계의 입력으로 계속 섞여 든다.
🎯 역산 — 시프트 두 개가 사실은 회전이다
>> 와 << 를 OR 로 합치고 32비트로 자르는 형태는 회전 연산의 전형이다.
다만 시프트 양이 각각 (i+16) % 32 와 (16-i) % 32 로 따로 적혀 있어서
한눈에 짝이 안 맞아 보인다. 확인해 보면 맞는다.
s = (i + 16) % 32 로 두면 (16 - i) ≡ -(i + 16) ≡ 32 - s (mod 32) 다.
그러니 이 식은 ROTR32(r0, s) 이고, 뒤에 ^ 0xDEADBEEF 가 붙은 것뿐이다.
▶🕳️ 확인하느라 시간 쓴 것 — 회전의 경계 두 군데
"이건 회전이다"라고 단정하기 전에 두 군데를 손으로 확인해야 했다.
첫째는 (16 - i) 가 음수가 되는 i > 16 구간이다.
C였다면 음수 시프트가 미정의 동작이라 여기서 멈췄겠지만,
파이썬의 % 는 항상 음이 아닌 값을 주므로 (16 - 17) % 32 == 31 이다.
그래서 i = 17 이면 ROTR32(r0, 1) 이 된다. 정상이다.
둘째는 s = 0 인 경우다. i = 16 이면 (16+16) % 32 와 (16-16) % 32 가 둘 다 0이라
식이 (r0 >> 0) | (r0 << 0) = r0 이 된다. 여기가 걸렸다.
회전을 일반식 (x << n) | (x >> (32 - n)) 으로 짜면 n = 0 일 때
x >> 32 라는 항이 생기는데, 파이썬은 임의 정밀도라 이게 어떻게 될지 바로 안 보인다.
그래서 방어적으로 n = 0 을 따로 반환하게 짰다.
def rotl(x, n):
n %= 32
return ((x << n) | (x >> (32 - n))) & MASK if n else x다 풀고 나서 이 가드가 정말 필요했는지 대조해 봤다. 두 판을 나란히 돌린 게 아래다.
# check_rot0.py — 회전량 0을 특수처리하지 않으면 실제로 어떤 결과가 나오는지 확인한다.
def rotl_bad(x, n): # n==0 을 따로 처리하지 않은 판
n %= 32
return ((x << n) | (x >> (32 - n))) & MASK
def rotl_ok(x, n):
n %= 32
return ((x << n) | (x >> (32 - n))) & MASK if n else x
def unchk(out, rotl):
for i in reversed(range(len(out) - 3)):
v = int.from_bytes(out[i:i + 4], "little")
r0 = rotl(v ^ 0xDEADBEEF, (i + 16) % 32)
out = out[:i] + r0.to_bytes(4, "little") + out[i + 4:]
return outdocker run --rm -v "$PWD:/w" -w /w python:3.10-slim python check_rot0.py
49바이트가 전부 같다. x 가 32비트로 유지되는 한 x >> 32 는 0이고,
반대 방향의 x << 32 도 뒤의 & 0xFFFFFFFF 에 잘려 나가기 때문이다.
가드는 없어도 됐다. 그래도 솔버에는 남겨 뒀다 —
입력이 32비트를 넘는 상황까지 방어가 되고, 읽는 사람에게 의도가 드러난다.
경계를 미리 의심한 것 자체는 나쁘지 않았는데, 확인은 짐작이 아니라 이렇게 두 판을 같이 돌려 비교하는 게 맞았다.
역산은 단순하다. 각 단계가 4바이트를 전단사로 바꾸므로 순서만 뒤집으면 된다.
i 를 len - 4 부터 0까지 내려가면서 ROTL32(v ^ 0xDEADBEEF, s) 로 되돌린다.
def unchk(out: bytes) -> bytes:
for i in reversed(range(len(out) - 3)):
v = int.from_bytes(out[i:i + 4], "little")
r0 = rotl(v ^ 0xDEADBEEF, (i + 16) % 32)
out = out[:i] + r0.to_bytes(4, "little") + out[i + 4:]
return out브루트포스도, 솔버도 필요 없다. k 49바이트를 넣고 46번 되감으면 끝이다.
✅ 런타임으로 한 번 더 확인
정적 재현이 맞다는 건 assert 로 확인했지만, "실행 중에 진짜로 바이트코드가 바뀌는가"는
따로 봐야 마음이 놓인다. prob.pyc 를 그대로 exec 하되 두 이름만 가로챘다.
input 은 복원한 플래그를 돌려주게 하고, print 는 호출되는 그 순간
chk 의 co_code 해시를 찍게 했다. throw 가 print 를 부르는 시점은
1차 XOR 직후이자 2차 XOR 직전이라, 패치된 상태를 정확히 들여다볼 수 있다.
# runtime_probe.py — throw() 가 '실행 중에' chk 의 바이트코드를 갈아끼우는지 직접 확인한다.
import marshal, hashlib, types
FLAG = "GoN{S3lf-m0d1fy1ng-pyc_n0_more_pyc_ch4l13ng3_p1z}"
with open("extracted/prob.pyc", "rb") as f:
f.read(16)
top = marshal.load(f)
chk_co = next(c for c in top.co_consts
if isinstance(c, types.CodeType) and c.co_name == "chk")
m = top.co_consts[4]
orig = chk_co.co_code
patched = bytes(a ^ b for a, b in zip(orig, m)) + orig[len(m):]
sha = lambda x: hashlib.sha1(x).hexdigest()[:16]
print(f"원본 co_code sha1[:16] = {sha(orig)}")
print(f"기대 패치본 sha1[:16] = {sha(patched)}")
g = {"__name__": "__main__", "__builtins__": __builtins__}
g["input"] = lambda *a: FLAG
def spy(*args):
live = g["chk"].__code__.co_code
print(f"[print() 호출 시점] co_code sha1[:16] = {sha(live)}"
f" → 패치본과 일치? {live == patched}")
print("[print() 호출 시점] chk 가 넘긴 값 =", *args)
g["print"] = spy
exec(top, g)
after = g["chk"].__code__.co_code
print(f"실행 끝난 뒤 co_code sha1[:16] = {sha(after)} → 원본으로 복구? {after == orig}")docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python runtime_probe.py
세 가지가 한 화면에 나온다.
print가 불린 시점의co_code해시가 정적으로 계산한원본 XOR m과 정확히 일치한다.- 그때
chk가 넘긴 값은Good!이다. 즉 우리 입력이 검사를 통과했다. - 모듈 실행이 끝난 뒤 해시는 원본으로 돌아와 있다. 2차 XOR 이 흔적을 지운 것이다.
원본 복구까지 넣은 건 사후 분석을 막으려는 장치로 보인다. 실행이 끝난 프로세스를 덤프해도 패치된 바이트코드는 남아 있지 않다.
🚀 Full Exploit
k 와 m 은 하드코딩하지 않고 prob.pyc 에서 매번 읽는다.
정방향 chk 도 같이 구현해 두고 마지막에 chk(flag) == k 로 자기 검증을 한다.
#!/usr/bin/env python3
"""pyc (DreamHack, Platinum 3) 솔버.
throw() 가 ctypes 로 chk.__code__.co_code 를 m 과 XOR 해 갈아끼운 뒤 실행하는
'진짜' chk 는 아래와 같다 — 4바이트 창을 앞에서 뒤로 밀며 제자리 변환한다.
for i in range(len(ipt) - 3):
r0 = int.from_bytes(ipt[i:i+4], 'little')
v = ROTR32(r0, (i+16) % 32) ^ 0xDEADBEEF
ipt = ipt[:i] + v.to_bytes(4, 'little') + ipt[i+4:]
return ipt
각 단계가 4바이트를 전단사로 바꾸므로 순서만 뒤집으면 그대로 역산된다.
"""
import marshal, types
MASK = 0xFFFFFFFF
def rotr(x, n):
n %= 32
return ((x >> n) | (x << (32 - n))) & MASK if n else x
def rotl(x, n):
n %= 32
return ((x << n) | (x >> (32 - n))) & MASK if n else x
def chk(ipt: bytes) -> bytes:
"""복원한 진짜 chk (정방향)."""
for i in range(len(ipt) - 3):
r0 = int.from_bytes(ipt[i:i + 4], "little")
v = rotr(r0, (i + 16) % 32) ^ 0xDEADBEEF
ipt = ipt[:i] + v.to_bytes(4, "little") + ipt[i + 4:]
return ipt
def unchk(out: bytes) -> bytes:
"""뒤에서 앞으로 되감는다."""
for i in reversed(range(len(out) - 3)):
v = int.from_bytes(out[i:i + 4], "little")
r0 = rotl(v ^ 0xDEADBEEF, (i + 16) % 32)
out = out[:i] + r0.to_bytes(4, "little") + out[i + 4:]
return out
# 상수 k / m 은 pyc 에서 직접 읽는다 (하드코딩하지 않는다)
with open("extracted/prob.pyc", "rb") as f:
f.read(16)
top = marshal.load(f)
k = bytes(top.co_consts[2])
m = top.co_consts[4]
orig_chk = next(c for c in top.co_consts
if isinstance(c, types.CodeType) and c.co_name == "chk")
print(f"[*] len(k)={len(k)} len(m)={len(m)} len(chk.co_code)={len(orig_chk.co_code)}")
print(f"[*] k = {k.hex()}")
flag = unchk(k)
print(f"[*] flag bytes = {flag.hex()}")
print(f"[+] FLAG = {flag.decode()}")
assert chk(flag) == k, "역산 검증 실패"
print("[+] 정방향 재검증 OK: chk(flag) == bytes(k)")docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python solve.py
[*] len(k)=49 len(m)=126 len(chk.co_code)=128
[+] FLAG = GoN{S3lf-m0d1fy1ng-pyc_n0_more_pyc_ch4l13ng3_p1z}
[+] 정방향 재검증 OK: chk(flag) == bytes(k)자체 검증만으로는 부족하니 배포된 prob.pyc 를 손 하나 안 대고 그대로 실행해
확인했다. 오답도 같이 넣어 대조했다.
#!/usr/bin/env python3
"""배포된 prob.pyc 를 손대지 않고 그대로 실행해 flag 를 확인한다."""
import subprocess, sys
FLAG = "GoN{S3lf-m0d1fy1ng-pyc_n0_more_pyc_ch4l13ng3_p1z}"
WRONG = "GoN{this_is_definitely_not_the_right_flag_aaaaaa}"
for label, s in (("정답 후보", FLAG), ("오답 대조", WRONG)):
r = subprocess.run([sys.executable, "extracted/prob.pyc"],
input=s + "\n", capture_output=True, text=True)
out = r.stdout.strip() or "(출력 없음)"
print(f"{label}: {s}")
print(f" → prob.pyc 출력: {out}")docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python verify.py
정답에는 Good!, 오답에는 침묵. 이 문제는 오답일 때 아무 메시지도 안 내므로
"출력이 없다"가 곧 실패 신호다.
재현하려면
폴더 구조는 이렇다. 드림핵에서 받은 배포본을 extracted/ 에 풀고,
이 글의 스크립트들을 그 위 디렉토리에 두면 경로가 맞는다.
pyc/
├── extracted/prob.pyc ← 배포본
├── dis_pyc.py dump_code.py disasm_raw.py patch_chk.py
├── why_throw.py decoy.py bytes_layout.py check_rot0.py
├── runtime_probe.py solve.py verify.py
└── reproduce.sh로컬 파이썬이 3.10이 아니면 marshal.load 부터 실패한다. 도커만 있으면 되게 묶어 뒀다.
#!/usr/bin/env bash
# pyc (DreamHack Platinum 3, id 464) — 한 방 재현.
# prob.pyc 는 Python 3.10 바이트코드라 로컬 3.12/3.13 으로는 marshal 조차 못 읽는다.
# 그래서 python:3.10-slim 컨테이너 안에서 돌린다 (도커만 있으면 된다).
# ① solve.py 로 k 를 역산해 flag 를 뽑고 ② 배포된 prob.pyc 를 그대로 실행해 'Good!' 을 확인한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
IMG=python:3.10-slim
docker image inspect "$IMG" >/dev/null 2>&1 || docker pull "$IMG"
OUT=$(timeout 300 docker run --rm -v "$PWD:/w" -w /w "$IMG" python solve.py 2>&1) || true
echo "$OUT" | tail -6
VOUT=$(timeout 300 docker run --rm -v "$PWD:/w" -w /w "$IMG" python verify.py 2>&1) || true
echo "$VOUT"
FLAG=$(printf '%s' "$OUT" | grep -aoE 'GoN\{[^}]+\}' | head -1)
GOOD=$(printf '%s' "$VOUT" | grep -c 'Good!' || true)
if [ -n "$FLAG" ] && [ "$GOOD" -ge 1 ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '${EXPECT:-미기재}' / Good! 출력 ${GOOD}회)"; exit 1성공 판정은 두 개다. solve.py 출력에서 GoN 으로 시작하는 문자열이 잡히고,
verify.py 출력에 Good! 이 최소 한 번 나와야 한다. 둘 중 하나만 만족하면 실패로 본다.
🧾 함정 정리
이 문제가 깔아 둔 장치를 모아 보면 이렇다. 하나하나는 사소한데 겹치니 꽤 두껍다.
| 장치 | 무엇을 노렸나 | 어떻게 뚫었나 |
|---|---|---|
dis 를 죽이는 65 ff ff 09 | 정적 분석기 자체를 중단시킨다 | 점프 착지점(6번지)부터 직접 디코드 |
bytes.find(str) 로 항상 예외 | 정답 분기를 도달 불가능하게 만든다 | 실제로 실행해 TypeError 확인 |
가짜 chk + 미끼 플래그 | 여기서 만족하고 제출하게 만든다 | 죽은 대입이 여섯 개라는 위화감 추적 |
co_code 제자리 XOR | 파일에는 진짜 코드가 없다 | m 과 XOR 해 정적으로 재현 |
| 실행 후 원본 복구 | 사후 메모리 분석을 막는다 | 실행 중간(print 시점)에 관측 |
성공 문자열을 co_consts[8] 로 | 문자열 검색으로 못 찾게 한다 | 상수 목록을 통째로 덤프 |
struct 임포트 · key 상수 | 시선을 분산시킨다 | 실제 참조 여부로 판별 |
strings 로 Good! 을 찾아 그 근처를 보는 흔한 접근도 여기서는 엉뚱한 데로 데려간다.
파일 안에서 'Good!' 이 있는 곳은 모듈 상수(도달 불가능한 print('Good!'))와
chk 의 상수 목록, 두 군데다. 둘 다 미끼 쪽이다.
정작 그걸 실제로 출력하는 throw 의 상수 목록은 (None, 1, 32, 8) 뿐이라
문자열이 아예 없다 — 첨자 8 로 남의 상수를 꺼내 쓰기 때문이다.
📝 결론
dis 출력은 "실행될 코드"가 아니라 "지금 저장된 바이트"일 뿐이다.
정적 디스어셈블리는 파일에 적힌 명령을 보여 준다. 그 명령이 실행 시점에도 그대로일 거라는 건
가정이지 사실이 아니다. 파이썬은 ctypes 만 있으면 자기 바이트코드를 제자리에서 고칠 수 있고,
그러면 파일에 있는 코드와 실제로 도는 코드가 완전히 갈린다.
네이티브 바이너리의 자가변형 코드와 발상이 같은데, 파이썬 쪽이 훨씬 짧게 써진다.
의미 없는 코드가 보이면 그게 신호다.
가짜 chk 에서 r0 부터 r5 까지 여섯 변수를 대입하고 한 번도 안 쓴다.
컴파일러가 남길 만한 모양이 아니고 사람이 쓸 만한 모양도 아니다.
XOR 마스크를 맞추려고 채워 넣은 자리였다.
특히 상수 목록은 그대로 두고 명령만 바꾸는 이 수법에서는,
쓰이지 않는 상수가 곧 "다른 코드가 이 상수를 쓴다"는 증거가 된다.
0xdeadbeef 가 대입만 되고 안 쓰인 게 정확히 그 신호였다.
도구가 죽으면 도구를 고치는 게 답일 때가 있다.
dis.dis 가 IndexError 로 죽었을 때 선택지는 다른 도구를 찾거나 직접 짜는 것이었는데,
dis 모듈이 opcode 테이블을 그대로 공개하고 있어서 40줄짜리 디코더로 충분했다.
앞의 표에 정리한 여섯 집합(hasname·hasconst·haslocal·hasjabs·hasjrel·hascompare)만
알면 어느 인자가 무슨 테이블을 가리키는지 다 나온다.
디컴파일러를 구하러 다니는 것보다 빨랐다.
방어 관점에서는 어떤가.
이런 자가변형은 난독화로는 꽤 효과적이지만 보호 수단으로는 약하다.
검사 로직이 결국 프로세스 안에서 평문으로 복원되기 때문에,
이 글에서 한 것처럼 실행 중 한 시점만 관측하면 그대로 드러난다.
게다가 마스크 m 이 같은 파일에 들어 있으니 정적으로도 재현된다.
클라이언트에 검증을 두는 한 우회는 시간 문제고,
정말 지켜야 할 값은 클라이언트가 아니라 서버가 들고 있어야 한다.
ctypes.from_address 로 인터프리터 내부 객체를 직접 만지는 것도 짚어 둘 만하다.
bytes 처럼 불변으로 취급되는 객체를 제자리에서 바꾸면 인터프리터의 가정이 깨진다.
같은 리터럴이 캐싱·인턴돼 다른 곳에서 공유되고 있으면 그쪽까지 오염되고,
해시를 이미 계산해 둔 객체면 딕셔너리에서 영영 못 찾게 된다.
이 문제처럼 통제된 상황이라 조용히 끝났을 뿐이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…