A “Simple” pyc

The challenge is a single compiled prob.pyc. First problem: it was built with a very recent CPython, and most decompilers choke on it. pycdc plus a couple of online tools got most of the source back, and at a glance it looks trivial:

key = [239, 88, 97, 17, ...]
k   = [161, 55, 37, 106, ...]

def chk(ipt):
    # ... a bunch of dead assignments (r0..r5) ...
    res = []
    for i, j in zip(list(ipt), key):
        res.append(i ^ j)
    return bytes(res)

ipt = input().encode()

chk seems to just XOR the input with key, and success requires chk(ipt) == bytes(k). So the “flag” is k[i] ^ key[i], right?

flag = bytes(a ^ b for a, b in zip(k, key))
# b'NoD{No!_Th15_1s_N0t_Fl4G_Y0U_n33d_t0_rev_more!@#}'

NoD{No!_Th15_1s_N0t_Fl4G_Y0U_n33d_t0_rev_more!@#}: a taunt. The visible chk is a decoy.

The Trap: Self-Modifying Bytecode

The real logic is hidden in throw(), which never looks like it matters, but it rewrites chk at runtime using ctypes:

def throw():
    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)
    ptr1.raw = bytes(res)              # XOR chk's real bytecode with m, in place
    if chk(ipt) == bytes(k):
        print(chk.__code__.co_consts[8])
    # ... XOR back to restore ...

try:
    if ipt.find('GoN{') != -1 and chk(ipt) == bytes(k):
        print('Good!')
except:
    throw()

The co_code of a code object is a bytes blob of raw bytecode. from_address(id(chk.__code__.co_code) + 32) reaches into that object’s internal buffer and XORs it with the array m. In other words, the chk that actually runs is not the chk we decompiled. Right before the real comparison, its bytecode is patched into a completely different function. The decompiler only ever saw the pre-patch version.

Recovering the Real chk

The plan is to reproduce the patch offline: take the original chk bytecode, XOR it with m, and disassemble the result. The only fiddly part is figuring out where inside co_code the patch actually begins.

Dumping the chk code object to find where the patched region starts

Once the offset lines up, XOR-ing the bytecode with m and disassembling gives readable instructions:

m = [0, 0, 16, 0, 255, 1, 254, 0, ...]
bytecode = bytes.fromhex("74 00 64 01 83 01 7D 01 ...")   # original chk co_code
patched  = bytes(b ^ m[i] if i < len(m) else b
                 for i, b in enumerate(bytecode))

Disassembling patched reveals the genuine algorithm: a rolling 32-bit transform over the input, sliding a 4-byte window and applying a rotate and an XOR with 0xDEADBEEF:

Reconstructing the real rotate-and-XOR transform from the patched bytecode

def chk(ipt):                     # the REAL check, reconstructed
    for i in range(len(ipt) - 3):
        r0 = int.from_bytes(ipt[i:i+4], 'little')
        rotated = ((r0 >> ((i + 16) % 32)) | (r0 << ((-i + 16) % 32))) & 0xFFFFFFFF
        ipt = ipt[:i] + (rotated ^ 0xDEADBEEF).to_bytes(4, 'little') + ipt[i+4:]
    return ipt

The constants that looked like dead code in the decompiled chk (16, 32, 0xFFFFFFFF, 0xDEADBEEF) were the real transform’s parameters all along.

Inverting the Transform

chk mutates the buffer forward, block by block, so the inverse walks backward from the last window, undoing the XOR and the rotation at each step:

k = [161, 55, 37, 106, 136, 128, 88, 143, ...]

def reverse_chk(output):
    result = bytearray(output)
    for i in range(len(output) - 4, -1, -1):        # reverse order
        cur = int.from_bytes(result[i:i+4], 'little')
        unxored = cur ^ 0xDEADBEEF                   # XOR is its own inverse
        s = (i + 16) % 32
        original = ((unxored << s) | (unxored >> (32 - s))) & 0xFFFFFFFF  # rotate back
        result[i:i+4] = original.to_bytes(4, 'little')
    return result

print(reverse_chk(bytes(k)).decode())
GoN{REDACTED}

Takeaways

The whole challenge is a lesson in not trusting the decompiler’s output. Static analysis showed a plain XOR and even handed over a fake flag to reward a shallow read. The actual check only exists for a few microseconds at runtime, materialized by XOR-patching a code object’s raw bytecode through ctypes.

Two habits beat it: reading every function (the “irrelevant” throw was the entire point), and reconstructing the patched code the same way the program does instead of trusting the source you can see. Once the real chk is out (a rotate + 0xDEADBEEF XOR sliding over the input), inverting it is routine.


“쉬운” pyc

문제는 컴파일된 prob.pyc 하나입니다. 첫 번째 난관은, 꽤 최신 CPython으로 빌드돼서 대부분의 디컴파일러가 뻗어버린다는 점이에요. pycdc와 온라인 도구 몇 개를 조합해 소스를 대충 복원했는데, 얼핏 보면 자명해 보입니다.

key = [239, 88, 97, 17, ...]
k   = [161, 55, 37, 106, ...]

def chk(ipt):
    # ... 죽은 대입문 뭉치 (r0..r5) ...
    res = []
    for i, j in zip(list(ipt), key):
        res.append(i ^ j)
    return bytes(res)

ipt = input().encode()

chk는 입력을 key와 XOR할 뿐이고, 성공하려면 chk(ipt) == bytes(k)여야 합니다. 그럼 “플래그”는 k[i] ^ key[i]겠죠?

flag = bytes(a ^ b for a, b in zip(k, key))
# b'NoD{No!_Th15_1s_N0t_Fl4G_Y0U_n33d_t0_rev_more!@#}'

NoD{No!_Th15_1s_N0t_Fl4G_Y0U_n33d_t0_rev_more!@#}, 조롱이네요. 눈에 보이는 chk는 미끼입니다.

함정: 자기수정 바이트코드

진짜 로직은 throw()에 숨어 있어요. 중요해 보이지도 않는 이 함수가 ctypes로 런타임에 chk를 다시 씁니다.

def throw():
    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)
    ptr1.raw = bytes(res)              # chk의 진짜 바이트코드를 m과 XOR, 제자리에서
    if chk(ipt) == bytes(k):
        print(chk.__code__.co_consts[8])
    # ... 다시 XOR해서 복원 ...

try:
    if ipt.find('GoN{') != -1 and chk(ipt) == bytes(k):
        print('Good!')
except:
    throw()

코드 객체의 co_code는 원시 바이트코드가 담긴 bytes 덩어리입니다. from_address(id(chk.__code__.co_code) + 32)는 그 객체의 내부 버퍼로 손을 뻗어 배열 m과 XOR합니다. 다시 말해, 실제로 실행되는 chk는 우리가 디컴파일한 chk가 아니에요. 진짜 비교 직전에 그 바이트코드가 완전히 다른 함수로 패치됩니다. 디컴파일러는 패치 이전 버전만 본 거죠.

진짜 chk 복원하기

계획은 이 패치를 오프라인에서 재현하는 겁니다. 원본 chk 바이트코드를 가져와 m과 XOR하고, 그 결과를 디스어셈블하는 거예요. 까다로운 부분은 co_code 안에서 패치가 실제로 어디서 시작하는지 알아내는 것뿐입니다.

패치 영역이 어디서 시작하는지 찾기 위해 chk 코드 객체 덤프

오프셋이 맞아떨어지면, 바이트코드를 m과 XOR하고 디스어셈블했을 때 읽을 수 있는 명령어가 나옵니다.

m = [0, 0, 16, 0, 255, 1, 254, 0, ...]
bytecode = bytes.fromhex("74 00 64 01 83 01 7D 01 ...")   # 원본 chk co_code
patched  = bytes(b ^ m[i] if i < len(m) else b
                 for i, b in enumerate(bytecode))

patched를 디스어셈블하면 진짜 알고리즘이 드러납니다. 입력 위를 4바이트 창으로 슬라이딩하며, 회전과 0xDEADBEEF XOR을 적용하는 롤링 32비트 변환이에요.

패치된 바이트코드에서 실제 rotate-and-XOR 변환 재구성

def chk(ipt):                     # 재구성한 진짜 검사
    for i in range(len(ipt) - 3):
        r0 = int.from_bytes(ipt[i:i+4], 'little')
        rotated = ((r0 >> ((i + 16) % 32)) | (r0 << ((-i + 16) % 32))) & 0xFFFFFFFF
        ipt = ipt[:i] + (rotated ^ 0xDEADBEEF).to_bytes(4, 'little') + ipt[i+4:]
    return ipt

디컴파일된 chk에서 죽은 코드처럼 보였던 상수들(16, 32, 0xFFFFFFFF, 0xDEADBEEF)이 처음부터 진짜 변환의 파라미터였던 겁니다.

변환 뒤집기

chk는 버퍼를 블록 단위로 앞에서부터 변형하므로, 역연산은 마지막 창부터 거꾸로 걸어가며 각 단계의 XOR과 회전을 되돌립니다.

k = [161, 55, 37, 106, 136, 128, 88, 143, ...]

def reverse_chk(output):
    result = bytearray(output)
    for i in range(len(output) - 4, -1, -1):        # 역순
        cur = int.from_bytes(result[i:i+4], 'little')
        unxored = cur ^ 0xDEADBEEF                   # XOR은 자기 자신이 역
        s = (i + 16) % 32
        original = ((unxored << s) | (unxored >> (32 - s))) & 0xFFFFFFFF  # 회전 되돌리기
        result[i:i+4] = original.to_bytes(4, 'little')
    return result

print(reverse_chk(bytes(k)).decode())
GoN{REDACTED}

정리

이 문제 전체는 디컴파일러의 출력을 믿지 말라는 교훈입니다. 정적 분석은 평범한 XOR을 보여줬고, 심지어 얕게 읽은 사람에게 가짜 플래그까지 쥐여주며 보상하죠. 진짜 검사는 런타임에 단 몇 마이크로초 동안만 존재하는데, ctypes로 코드 객체의 원시 바이트코드를 XOR 패치해 순간적으로 만들어집니다.

두 가지 습관이 이걸 이깁니다. 모든 함수를 읽는 것(“무관해” 보이던 throw가 핵심의 전부였죠), 그리고 보이는 소스를 믿는 대신 프로그램이 하는 그대로 패치된 코드를 재구성하는 것. 진짜 chk(입력 위를 슬라이딩하는 회전 + 0xDEADBEEF XOR)만 꺼내고 나면, 뒤집는 건 기계적인 작업입니다.