[💠 Platinum 3] 실행 중에 자기 바이트코드를 갈아끼우는 .pyc — DreamHack pyc 풀이

2026-08-04·1분 읽기·

[💠 Platinum 3] 실행 중에 자기 바이트코드를 갈아끼우는 .pyc — DreamHack pyc 풀이

dis.dis 로 열면 평범한 XOR 검사처럼 보이는 .pyc 인데, 정작 그 코드는 한 번도 실행되지 않는다. ctypes 로 code object 의 co_code 버퍼를 제자리에서 XOR 해 진짜 검사 루틴을 꺼내 쓰기 때문이다. 바이트코드를 손으로 디코드해 복원하고, 4바이트 창 ROTR·XOR 변환을 뒤에서 앞으로 되감아 플래그를 뽑았다.

문제: DreamHack 워게임 — pyc 분류: reversing 난이도: 💠 Platinum 3 FLAG: GoN{S3lf-m0d1fy1ng-pyc_n0_more_pyc_ch4l13ng3_p1z}

배포 파일은 prob.pyc 하나, 2,308바이트다. 문제 설명은 딱 한 줄이다.

Just analyzing result of dis.dis is 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 로 직렬화한 것이다.

오프셋크기내용
04매직 넘버 — 바이트코드 버전 식별자 + \r\n\x0a
44플래그 (0이면 timestamp 기반, 1이면 해시 기반)
84원본 .py 의 mtime
124원본 .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(\" \"))"
file 과 xxd 로 확인한 pyc 헤더. 매직 3439 가 python:3.10-slim 의 MAGIC_NUMBER 와 정확히 일치한다
file 과 xxd 로 확인한 pyc 헤더. 매직 3439 가 python:3.10-slim 의 MAGIC_NUMBER 와 정확히 일치한다

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 임포트와 49바이트 리스트 k·key, 126개짜리 리스트 m 이 상수로 박혀 있다
모듈 최상위 디스어셈블리. struct 임포트와 49바이트 리스트 k·key, 126개짜리 리스트 m 이 상수로 박혀 있다

전역이 넷이다.

  • struct — 임포트만 하고 어디서도 안 쓴다. 미끼다.
  • k — 정수 49개. 검사의 정답값으로 쓰인다.
  • key — 정수 49개. 길이가 k 와 같아서 자연스럽게 "이걸로 XOR 하나 보다" 싶어진다.
  • m — 정수 126개. 나중에 이게 진짜 열쇠였다.

k 와 key 가 49바이트라는 건 답도 49바이트라는 뜻이다. 그다음이 함수 정의와 실행부다.

sed -n '27,86p' dis_top.txt
모듈 실행부 디스어셈블리. input 을 받고 ctypes 를 임포트한 뒤 try 안에서 find 와 chk 를 부르고 except 에서 throw 를 부른다
모듈 실행부 디스어셈블리. input 을 받고 ctypes 를 임포트한 뒤 try 안에서 find 와 chk 를 부르고 except 에서 throw 를 부른다

읽어 보면 원본 소스는 대략 이런 모양이었을 것이다.

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
why_throw.py 실행 결과. bytes.find 에 str 을 넘기면 TypeError 가 나고 b 접두사를 붙이면 0 이 반환된다
why_throw.py 실행 결과. bytes.find 에 str 을 넘기면 TypeError 가 나고 b 접두사를 붙이면 0 이 반환된다

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
dis.dis 가 throw 를 디스어셈블하다 IndexError tuple index out of range 로 죽는다. 출력된 명령은 JUMP_ABSOLUTE 한 줄뿐이다
dis.dis 가 throw 를 디스어셈블하다 IndexError tuple index out of range 로 죽는다. 출력된 명령은 JUMP_ABSOLUTE 한 줄뿐이다

찍힌 건 딱 한 줄이다.

 26           0 JUMP_ABSOLUTE            3 (to 6)
Traceback (most recent call last):
  ...
IndexError: tuple index out of range

dis 는 바이트코드를 선형으로 훑는다. 진입점부터 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
dis 가 보여주는 chk 의 디스어셈블리. r0 부터 r5 까지 대입만 하고 쓰지 않으며 마지막에 zip 으로 XOR 을 돌린다
dis 가 보여주는 chk 의 디스어셈블리. r0 부터 r5 까지 대입만 하고 쓰지 않으며 마지막에 zip 으로 XOR 을 돌린다

정리하면 이렇다.

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
decoy.py 실행 결과. k 와 key 를 XOR 하면 출제자가 심어 둔 미끼 문자열이 그대로 디코드된다
decoy.py 실행 결과. k 와 key 를 XOR 하면 출제자가 심어 둔 미끼 문자열이 그대로 디코드된다
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
dump_code.py 로 뽑은 throw 의 원시 필드. co_names 에 ctypes·c_char·from_address·id·co_code·raw 가 늘어서 있다
dump_code.py 로 뽑은 throw 의 원시 필드. co_names 에 ctypes·c_char·from_address·id·co_code·raw 가 늘어서 있다

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.hasnameco_names 의 인덱스LOAD_GLOBAL · LOAD_ATTR · STORE_ATTR
dis.hasconstco_consts 의 인덱스LOAD_CONST
dis.haslocalco_varnames 의 인덱스LOAD_FAST · STORE_FAST
dis.hasjabs절대 점프 대상 (명령 단위)JUMP_ABSOLUTE
dis.hasjrel상대 점프 오프셋 (명령 단위)FOR_ITER · SETUP_FINALLY
dis.hascomparedis.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
직접 만든 디스어셈블러로 본 throw 의 앞부분. c_char 배열을 chk 의 co_code 주소 더하기 32 에 씌우고 m 과 XOR 하는 루프가 보인다
직접 만든 디스어셈블러로 본 throw 의 앞부분. c_char 배열을 chk 의 co_code 주소 더하기 32 에 씌우고 m 과 XOR 하는 루프가 보인다

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 의 뒷부분. 패치본을 raw 에 써 넣고 chk(ipt) 와 bytes(k) 를 비교한 뒤 같은 XOR 을 한 번 더 걸어 원본으로 되돌린다
throw 의 뒷부분. 패치본을 raw 에 써 넣고 chk(ipt) 와 bytes(k) 를 비교한 뒤 같은 XOR 을 한 번 더 걸어 원본으로 되돌린다

전체를 복원하면 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
bytes_layout.py 실행 결과. 헤더 32바이트 중 세 번째 8바이트가 길이 19와 일치하고, 그 뒤를 읽으면 원본 문자열이 그대로 나온다
bytes_layout.py 실행 결과. 헤더 32바이트 중 세 번째 8바이트가 길이 19와 일치하고, 그 뒤를 읽으면 원본 문자열이 그대로 나온다

ob_size 자리에서 19가 나오고, id(b)+32 부터 19바이트를 읽으면 원본이 그대로 나온다. 마지막 줄에서는 그 리터럴을 제자리에서 0x20 과 XOR 해 대문자를 소문자로 바꿔 놓았다. 파이썬이 불변이라고 부르는 객체가 ctypes 한 줄에 바뀐다.

throw 는 이걸 chk 의 바이트코드에 쓴다. code object 를 새로 만들거나 chk.__code__ 를 교체하는 게 아니라, 이미 로드된 바이트코드 버퍼를 그 자리에서 덮는다. 그래서 chk 라는 함수 객체도, code object 도, co_names · co_consts 도 전부 그대로다. 바뀌는 건 명령 스트림 126바이트뿐이다.

여기까지의 그림을 한 장으로 정리하면 이렇다.

정적으로 보이는 코드와 실제로 도는 경로를 나란히 둔 그림. 왼쪽은 dis 가 보여주는 미끼, 오른쪽은 TypeError 를 거쳐 throw 로 가는 실제 흐름이다
정적으로 보이는 코드와 실제로 도는 경로를 나란히 둔 그림. 왼쪽은 dis 가 보여주는 미끼, 오른쪽은 TypeError 를 거쳐 throw 로 가는 실제 흐름이다

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
patch_chk.py 실행 결과. XOR 로 복원한 chk 가 슬라이스와 BINARY_RSHIFT·BINARY_LSHIFT·BINARY_OR 로 회전 연산을 구성한다
patch_chk.py 실행 결과. XOR 로 복원한 chk 가 슬라이스와 BINARY_RSHIFT·BINARY_LSHIFT·BINARY_OR 로 회전 연산을 구성한다

이번엔 전혀 다른 코드가 나온다. 스택 순서를 따라가 보면 이렇다.

 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 ipt

i 를 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 out
docker run --rm -v "$PWD:/w" -w /w python:3.10-slim python check_rot0.py
check_rot0.py 실행 결과. 가드를 뺀 판도 49바이트 전부가 정상판과 같아서 마스크가 이미 경계를 흡수하고 있음을 보여준다
check_rot0.py 실행 결과. 가드를 뺀 판도 49바이트 전부가 정상판과 같아서 마스크가 이미 경계를 흡수하고 있음을 보여준다

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
runtime_probe.py 실행 결과. print 호출 시점의 co_code 해시가 정적으로 계산한 패치본과 같고, 실행이 끝나면 원본 해시로 돌아온다
runtime_probe.py 실행 결과. print 호출 시점의 co_code 해시가 정적으로 계산한 패치본과 같고, 실행이 끝나면 원본 해시로 돌아온다

세 가지가 한 화면에 나온다.

  • 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
solve.py 실행 결과. k 49바이트를 되감아 49글자 플래그가 나오고 정방향 재검증까지 통과한다
solve.py 실행 결과. k 49바이트를 되감아 49글자 플래그가 나오고 정방향 재검증까지 통과한다
[*] 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
verify.py 실행 결과. 배포된 prob.pyc 가 복원한 플래그에는 Good! 을 출력하고 오답에는 아무것도 출력하지 않는다
verify.py 실행 결과. 배포된 prob.pyc 가 복원한 플래그에는 Good! 을 출력하고 오답에는 아무것도 출력하지 않는다

정답에는 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 4] "L번 도니 한 번씩만 더해졌겠지"는 틀린 직관이었다 — DreamHack pybycodetes 풀이
blog

[🥈 Silver 4] "L번 도니 한 번씩만 더해졌겠지"는 틀린 직관이었다 — DreamHack pybycodetes 풀이

실행 파일이 아니라 파이썬 dis.dis() 바이트코드 덤프 두 장(main.txt, hard2rev.txt)과 출력 결과(output.txt)만 주어지는 리버싱 문제. opcode를 그대로 읽어 파이썬 소스로 재구성하면, flag를 바이트 XOR 한 뒤 값 하나에 +36을 하고 배열 전체를 j//2번 뒤집는 걸 반복하는 함수가 나온다. "루프가 L번 도니까 각 원소에 정확히 한 번씩만 더해졌겠지"라는 첫 직관은 틀렸다 — 반복되는 반전 때문에 같은 값이 여러 번 걸리기도, 아예 안 걸리기도 한다. 실제 값 대신 "위치 라벨" 배열을 똑같은 알고리즘으로 굴려 정확한 횟수와 최종 위치를 추적하고, 그 결과를 역산해 flag를 복원했다.
#dreamhack#ctf#reversing+3
2026-07-21#dreamhack +5
[🥇 Gold 4] 점프도 비교도 없는 VM 에서 flag 를 되뽑기 — DreamHack bitvm 풀이
blog

[🥇 Gold 4] 점프도 비교도 없는 VM 에서 flag 를 되뽑기 — DreamHack bitvm 풀이

명령이 열 개뿐인 바이트코드 VM 이 flag 를 검사한다. 분기도 비교도 산술도 없고 AND·OR·XOR 만 있다. 글자 하나를 거는 제약이 (c & A) == B 와 (c | A) == C 두 줄이라, 비트별로 쪼개면 c = (A & B) | (~A & C) 로 답이 하나씩 딱 떨어진다. 브루트포스도 솔버도 필요 없었다.
#dreamhack#ctf#reversing+6
2026-08-04#dreamhack +4
[🥉 Bronze 1] 위치 무관 바이트 변환은 256개짜리 표 하나로 뒤집힌다 — DreamHack ezmix 풀이
blog

[🥉 Bronze 1] 위치 무관 바이트 변환은 256개짜리 표 하나로 뒤집힌다 — DreamHack ezmix 풀이

stripped 바이너리가 program.bin이라는 2바이트짜리 opcode 프로그램을 해석해 우리가 입력한 문자열을 ADD/XOR/ROR 256번 연속으로 뒤섞는다. 언뜻 복잡해 보이지만 세 연산 전부 버퍼의 모든 바이트에 같은 인자를 똑같이 적용할 뿐이라, 입력 위치와 무관하게 0~255 전체에 대해 한 번만 정방향 표를 만들면 역표는 자동으로 나온다. output.bin 36바이트에 역표를 그대로 대입해 flag를 즉시 복원했다.
#dreamhack#ctf#reversing+3
2026-07-20#dreamhack +4