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):

Now we can notice that there is use of long unnecessary paths to call Python builtin functions and imports. For example the following snippet:

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.

Finally, after executing this deobfuscation script we got our flag:

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.