Jailpy3 (LIT 2025)
I usually don't solve rev challenges (games are an exception) but I have no idea why a python jail was also put into the rev category...
Made with a burning passion for pyjails (i.e. creating insane payloads just to bypass some random condition), I turned everything in this python script into pyjail tech! Here's a program that's suppose to print a flag. But something seems to be getting in the way...
We were provided with a 11.5MB python file... calling this a jail challenge would be wrong instead it is just obfuscated python code.
# The starting of obfuscated python file provided
print({}.__class__.__subclasses__()[2].copy.__builtins__[{}.__class__.__subclasses__()[2].copy.__builtins__[chr(1^2^32^64)+chr(8^32^64)+chr(2^16^32^64)]({}.__class__.__subclasses__()[2].copy.__builtins__[chr(1^2^4^8^16^64)+chr(1^2^4^8^16^64)+chr(1^8^32^64)+chr(1^4^8^32^64)+chr(16^32^64)+chr(1^2^4^8^32^64)+chr(2^16^32^64)+chr(4^16^32^64)+chr(1^2^4^8^16^64)+chr(1^2^4^8^16^64)](chr(1^2^16^32^64)+chr(1^4^16^32^64)+chr(2^32^64)+chr(16^32^64)+chr(2^16^32^64)+chr(1^2^4^8^32^64)+chr(1^2^32^64)+chr(1^4^32^64)+chr(1^2^16^32^64)+chr(1^2^16^32^64)).select.POLLIN^{}.__class__.__subclasses__()[2].copy.__builtins__[chr(1^2^4^8^16^64)+chr(1^2^4^8^16^64)+chr(1^8^32^64)+chr(1^4^8^32^64)+chr(16^32^64)+chr(1^2^4^8^32^64)+chr(2^16^32^64)+chr(4^16^32^64)+chr(1^2^4^8^16^64)+chr(1^2^4^8^16^64)](chr(1^2^16^32^64)+chr(1^4^16^32^64)+chr(2^32^64)+chr(16^32^64)+chr(2^16^32^64)+chr(1^2^4^8^32^64)+chr(1^2^32^64)+chr(1^4^32^64)+chr(1^2^16^32^64)+chr(1^2^16^32^64)).select.POLLPRI^{}...
Seeing this code the first thing that we should do is replace chr(...)
with the actual contents. The ^
operator represents a XOR operation (it is not exponent). So I asked ChatGPT to quickly write a script for me which performs these operations and hopefuly deobfuscates this.
import ast
import operator
import re
import sys
from pathlib import Path
ALLOWED_BINOPS = {
ast.BitXor: operator.xor,
ast.BitOr: operator.or_,
ast.BitAnd: operator.and_,
ast.LShift: operator.lshift,
ast.RShift: operator.rshift,
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
}
ALLOWED_UNARY = {ast.Invert: operator.invert, ast.UAdd: operator.pos, ast.USub: operator.neg}
def safe_eval_int(expr: str) -> int:
"""Evaluate a small integer expression safely."""
node = ast.parse(expr, mode="eval")
def _eval(n):
if isinstance(n, ast.Expression):
return _eval(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, int):
return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in ALLOWED_UNARY:
return ALLOWED_UNARY[type(n.op)](_eval(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in ALLOWED_BINOPS:
return ALLOWED_BINOPS[type(n.op)](_eval(n.left), _eval(n.right))
# Allow nested parentheses via Tuple with one elt in some Python versions? Not needed here.
raise ValueError(f"disallowed expression: {ast.dump(n, include_attributes=False)}")
val = _eval(node)
if not isinstance(val, int):
raise ValueError("non-integer expression")
return val
def decode_chr_sequence_to_str(exprs):
chars = []
for e in exprs:
code = safe_eval_int(e)
try:
chars.append(chr(code))
except ValueError:
raise ValueError(f"chr() out of range: {code}")
return "".join(chars)
def scan_and_replace_chr_concats(src: str):
"""
Find spans like: chr(<e1>) + chr(<e2>) + ... and replace with a quoted string.
Handles whitespace and newlines.
"""
i, n = 0, len(src)
replacements = []
while i < n:
if src.startswith("chr(", i):
start = i
exprs = []
def read_paren(idx):
# idx at start of '('
depth, j = 1, idx + 1
while j < n and depth:
c = src[j]
if c == "(":
depth += 1
elif c == ")":
depth -= 1
j += 1
if depth != 0:
raise ValueError("unbalanced parentheses in chr(...) sequence")
return j # position after ')'
# first chr(
if src.startswith("chr(", i):
# read first expr
open_idx = i + 3 # at '('
end_after_paren = read_paren(open_idx)
exprs.append(src[open_idx + 1 : end_after_paren - 1])
k = end_after_paren
# consume sequences of + chr(
while True:
# skip whitespace
while k < n and src[k].isspace():
k += 1
if k < n and src[k] == "+":
k += 1
while k < n and src[k].isspace():
k += 1
if k < n and src.startswith("chr(", k):
open2 = k + 3
end2 = read_paren(open2)
exprs.append(src[open2 + 1 : end2 - 1])
k = end2
continue
break
if len(exprs) >= 2: # only replace concatenations, not single chr(...)
try:
decoded = decode_chr_sequence_to_str(exprs)
replacements.append((start, k, repr(decoded)))
i = k
continue
except Exception:
# fall through and advance one char if evaluation fails
pass
i += 1
# Apply replacements from end to start.
if not replacements:
return src, 0
out = []
last = 0
for s, e, rep in sorted(replacements, key=lambda t: t[0]):
out.append(src[last:s])
out.append(rep)
last = e
out.append(src[last:])
new_src = "".join(out)
return new_src, len(replacements)
def deobfuscate_text(src: str, max_passes: int = 8):
"""Run multiple passes to catch cascaded patterns."""
total = 0
for _ in range(max_passes):
src, cnt = scan_and_replace_chr_concats(src)
total += cnt
if cnt == 0:
break
return src, total
def main():
if len(sys.argv) < 2:
print("usage: python deobfuscate_chr_concat.py <input.py> [output.py]")
sys.exit(2)
inp = Path(sys.argv[1])
outp = Path(sys.argv[2]) if len(sys.argv) >= 3 else None
text = inp.read_text(encoding="utf-8")
new_text, num = deobfuscate_text(text)
if outp:
outp.write_text(new_text, encoding="utf-8")
print(f"replaced {num} chr-concat sequences -> {outp}")
else:
print(new_text)
if __name__ == "__main__":
main()
After running this script the output looked like (note that it has been truncated... it was still 2.9MB):
# Truncated output after running first deobfuscation script
import collections
print({}.__class__.__subclasses__()[2].copy.__builtins__[{}.__class__.__subclasses__()[2].copy.__builtins__['chr']({}.__class__.__subclasses__()[2].copy.__builtins__['__import__']('subprocess').select.POLLIN^{}.__class__.__subclasses__()[2].copy.__builtins__['__import__']('subprocess').select.POLLPRI^{}.__class__.__subclasses__()[2].copy.__builtins__['__import__']('subprocess').select.POLLNVAL^{}.__class__.__subclasses__()[2].copy.__builtins__['__import__']('subprocess').select.POLLRDNORM)+{}.__class__.__subclasses__()[2].copy.__builtins__['chr']({}...
Now we can notice that there is use of long unnecessary paths to call Python builtin functions and imports. For example the following snippet:
{}.__class__.__subclasses__()[2].copy.__builtins__['chr'](97)
Is equivalent to just using chr(97)
, both would return the sample output of a
. So now once again I gave these instructions to GPT and it produced a functional script.
import ast, operator, re, sys
from pathlib import Path
import subprocess as _subprocess # for select.POLL* constants
# ---------- safe integer evaluator ----------
_BINOPS = {
ast.BitXor: operator.xor, ast.BitOr: operator.or_, ast.BitAnd: operator.and_,
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.LShift: operator.lshift, ast.RShift: operator.rshift,
ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
}
_UNARY = {ast.Invert: operator.invert, ast.UAdd: operator.pos, ast.USub: operator.neg}
# expose only allowed names (after normalization we keep POLL* names)
_ALLOWED_NAMES = {name: getattr(_subprocess.select, name)
for name in dir(_subprocess.select) if name.startswith("POLL")}
def _safe_eval_int(expr: str) -> int:
node = ast.parse(expr, mode="eval")
def ev(n):
if isinstance(n, ast.Expression): return ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, int): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _UNARY: return _UNARY[type(n.op)](ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _BINOPS: return _BINOPS[type(n.op)](ev(n.left), ev(n.right))
if isinstance(n, ast.Name) and n.id in _ALLOWED_NAMES: return _ALLOWED_NAMES[n.id]
raise ValueError(f"disallowed expression: {ast.dump(n, include_attributes=False)}")
v = ev(node)
if not isinstance(v, int): raise ValueError("non-integer")
return v
# ---------- decoding helpers ----------
def _decode_chr_concat(src: str):
"""Replace chr(expr)+chr(expr)+... with quoted string."""
i, n = 0, len(src); reps = []
def read_paren(j):
depth, k = 1, j+1
while k < n and depth:
c = src[k]
if c == "(": depth += 1
elif c == ")": depth -= 1
k += 1
if depth: raise ValueError("unbalanced parens")
return k
while i < n:
if src.startswith("chr(", i):
start = i
aopen = i + 3
aend = read_paren(aopen)
exprs = [src[aopen+1:aend-1]]
k = aend
while True:
while k < n and src[k].isspace(): k += 1
if k < n and src[k] == "+":
k += 1
while k < n and src[k].isspace(): k += 1
if src.startswith("chr(", k):
bopen = k + 3
bend = read_paren(bopen)
exprs.append(src[bopen+1:bend-1])
k = bend
continue
break
if len(exprs) >= 2:
try:
s = "".join(chr(_safe_eval_int(e)) for e in exprs)
reps.append((start, k, repr(s)))
i = k; continue
except Exception:
pass
i += 1
if not reps: return src, 0
out, last = [], 0
for s, e, rep in reps:
out.append(src[last:s]); out.append(rep); last = e
out.append(src[last:])
return "".join(out), len(reps)
def _decode_single_chr(src: str):
"""Replace chr(<int-expr>) with a quoted one-character string where safe."""
# Avoid touching when followed/preceded by + which _decode_chr_concat handles.
pattern = re.compile(r"chr\(\s*([^\(\)]*?)\s*\)")
def repl(m):
expr = m.group(1)
# skip if contains quotes or disallowed chars to be safe
if any(c in expr for c in "'\"[]{}"): return m.group(0)
try:
ch = chr(_safe_eval_int(expr))
return repr(ch)
except Exception:
return m.group(0)
new = pattern.sub(repl, src)
changed = int(new != src)
return new, changed
# ---------- normalization passes ----------
_BUILTINS_JAIL = re.compile(
r"\{\}\.__class__\.__subclasses__\(\)\[\d+\]\.copy\.__builtins__"
)
def _normalize(src: str):
s = src
# 1) Collapse jail path to __builtins__
s = _BUILTINS_JAIL.sub("__builtins__", s)
# 2) Replace __builtins__['chr']( … ) -> chr( … )
s = re.sub(r"__builtins__\s*\[\s*['\"]chr['\"]\s*\]\s*\(", "chr(", s)
# 3) Replace __builtins__['__import__']('subprocess') -> subprocess
s = re.sub(
r"__builtins__\s*\[\s*['\"]__import__['\"]\s*\]\s*\(\s*['\"]subprocess['\"]\s*\)",
"subprocess", s)
# 4) Replace subprocess.select.POLLXXX -> POLLXXX
s = re.sub(r"subprocess\s*\.\s*select\s*\.\s*(POLL[A-Z_]+)", r"\1", s)
return s
def simplify_text(src: str, max_passes: int = 10):
s = _normalize(src)
total = 0
for _ in range(max_passes):
s2, n1 = _decode_chr_concat(s)
s3, n2 = _decode_single_chr(s2)
s4 = _normalize(s3) # new opportunities may appear
total += n1 + n2
if s4 == s:
break
s = s4
return s, total
def main():
if len(sys.argv) < 2:
print("usage: python simplify_ctf_obfuscation.py <input.py> [output.py]")
sys.exit(2)
inp = Path(sys.argv[1]); text = inp.read_text(encoding="utf-8")
out, n = simplify_text(text)
if len(sys.argv) >= 3:
Path(sys.argv[2]).write_text(out, encoding="utf-8")
print(f"simplified with {n} chr-rewrites -> {sys.argv[2]}")
else:
print(out)
if __name__ == "__main__":
main()
Finally, after executing this deobfuscation script we got our flag:
LITCTF{h0w_c0nvolu7ed_c4n_i7_g37_f0r_0n3_s1mpl3_w0rk4round??}
import collections
print('LITCTF{h0w_c0nvolu7ed_c4n_i7_g'__builtins__['__import__']('types').FunctionType(__builtins__['__import__']('marshal').loads(__builtins__['bytes'].fromhex('630000000000000000000000000300000000000000f3300000009700640064016c005a00020065006a020000000000000000000000000000000000006402ab01000000000000010079012903e9000000004ee9010000002902da026f73da055f65786974a900f300000000fa033c783efa083c6d6f64756c653e7208000000010000007314000000f003010101db0009883888328f38893890418d3b7206000000')), {'os': __builtins__['__import__']('os')})()+'37_f0r_0n3_s1mpl3_w0rk4round??}')
The challenge did look hard at start, probably I got scared because of the large file size, but by making two simple observations and using any LLM to write the scripts the challenge was easy solvable.
Last updated