I enjoyed this crypto-based Python jail challenge at jailCTF 2025. I even found a partially unintended solution! Here's my writeup.
#!/usr/local/bin/python3
from Crypto.Util.number import bytes_to_long, long_to_bytes, getPrime
import os
import subprocess
def rsagen():
p, q = getPrime(768), getPrime(768)
n = p * q
e = 0x10001
d = pow(e, -1, (p - 1) * (q - 1))
return (n, e)
def rsaenc(pk, msg):
n, e = pk
m = bytes_to_long(msg[:192])
c = pow(m, e, n)
return long_to_bytes(c)
if __name__ == '__main__':
secret = os.urandom(8).hex()
flag = open('flag.txt', 'r').read().strip()
pk = rsagen()
while True:
code = input('>>> ')
if any(c not in '0123456789abcdefghijklmnopqrstuvwxyz_+-*/ ' for c in code):
print("You can't do that!")
continue
template = f'flag_you_will_never_guess_this_{secret} = {flag!r}\n\nprint({code})'
output = subprocess.run(
['python3', '-c', template],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
).stdout
print(rsaenc(pk, output).hex())The server is a "signing oracle", where we can submit a Python expression using a limited alphabet and see a signed RSA response.
Our solution will consist of three steps:
- Finding the RSA modulus
nwith crypto - Revealing
secretwith a lovely Python trick - Reading the flag
The intended solution for step 3 involved an RSA attack known as the Franklin–Reiter related-message attack. But I found an alternative method where I intentionally cause SyntaxWarnings to extract the flag. I'll explain both methods in this writeup.
When we connect to the server and send something like
>>> 1+1
it evaluates the following Python program:
flag_you_will_never_guess_this_1234abcd1234abcd = "jail{blah_blah_blah}" print(1+1)
It gathers the output (here output = b"2\n") and sends back a huge number
Two things to note that we'll use to our advantage:
- The output is trimmed to the first 192 bytes.
- STDERR is piped to STDOUT when capturing output, so it's included.
Let's call bytes_to_long, which interprets the bytes as base-256). We choose each
where
We can apply a common trick in crypto CTF challenges. By the definition of modulo, we know that each difference
If we pick our
Now that we have n, we can leak message equality.
Suppose that we could get the server to tell us
In the real challenge, we can do no such thing. We don't know the variable name the flag is stored in, and even if we did, we don't have square brackets to extract that first byte with.
Here we come to the essential idea in this challenge: we can make a guess at secret and let Python's NameError tell us if we are close enough.
When we ask for a variable that doesn't exist, Python uses Levenshtein distance to decide if it should suggest a close alternative:
❯ uv run --python 3.10 python
Python 3.10.12 (main, Jul 26 2023, 13:20:36) [Clang 16.0.3 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> flag_you_will_never_guess_this_1234abcd1234abcd = 'flag{}'
>>> #
>>> # If more than 1/3 of the chars differ, there's no hint:
>>> #
>>> flag_you_will_never_guess_thisxxxxxxxxxxxxxxxxx
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'flag_you_will_never_guess_thisxxxxxxxxxxxxxxxxx' is not defined
>>> #
>>> # If less than 1/3 of the chars differ, we get a hint!
>>> #
>>> flag_you_will_never_guess_this_xxxxxxxxxxxxxxxx
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'flag_you_will_never_guess_this_xxxxxxxxxxxxxxxx' is not defined.
Did you mean: 'flag_you_will_never_guess_this_1234abcd1234abcd'?Thus, we can submit the following guesses to the oracle:
flag_you_will_never_guess_thisx0xxxxxxxxxxxxxxx
flag_you_will_never_guess_thisx1xxxxxxxxxxxxxxx
...
flag_you_will_never_guess_thisxfxxxxxxxxxxxxxxx
Note the _ that has turned into an x.
For all but one of these, we can predict the no-hint NameError we expect to get if our guess is wrong. But for one of them, the Levenshtein distance will be smaller, and we'll get the "Did you mean:" hint, causing the encrypted response to differ.
That means our guess was correct, and we can lock in that character of the variable name and continue. To keep the Levenshtein distance "balanced", right on the edge of getting a hint, we need to replace one more character with x — this time, the s in this:
flag_you_will_never_guess_thixx10xxxxxxxxxxxxxx
flag_you_will_never_guess_thixx11xxxxxxxxxxxxxx
flag_you_will_never_guess_thixx12xxxxxxxxxxxxxx
...
We continue this process to exfiltrate all of secret.
Okay, now we know the flag is contained in, say, flag_you_will_never_guess_this_1234abcd1234abcd, and we can ask the server for it:
>>> flag_you_will_never_guess_this_1234abcd1234abcd
...but of course, we get an encrypted response. How do we decrypt it?
In proper usage of RSA, messages should always be padded with random bytes so that an attacker cannot use a related message attack to break the cryptosystem. In this challenge, that's exactly how we'll leak the flag.
Remember that the messages are turned into numbers by bytes_to_long, which interprets a string as a base-256 number. So, b"flag{}" is represented by a number like
ord('f') * 256**5
+ ord('l') * 256**4
+ ord('a') * 256**3
+ ord('g') * 256**2
+ ord('{') * 256
+ ord('}')
Let's call this number
What happens to this number when we add bytes to the end of this string? Well, think about what happens in base 10: when we write a 6 at the end of 345 to make 3456, it's like we're applying
When we submit just flag_you_will_never_guess_this_1234abcd1234abcd to the server, the Python code prints the flag, followed by a newline. So our first plaintext-ciphertext pair is
Now, we can obtain a "related" message: we submit flag_you_will_never_guess_this_1234abcd1234abcd+__name__. The Python code prints the flag, followed by b"__main__", followed by a newline. So we get
where bytes_to_long(b"__main__\n").
The trick is now that we can consider the following polynomials in
where
From our equations for
So we can use SageMath to compute the polynomial long_to_bytes on it gets us the flag.
Just like in step 2, we can use the 192-byte limit and piping of STDERR to our advantage.
Let's cause the Python code to produce 191 bytes of known junk data, followed by the flag. To produce this known junk data, we can use SyntaxWarnings!
If we ask the server for
>>> 1and 1and 0o1and __name__*5+flag_you_will_never_etcit will respond by encrypting:
b'<string>:3: SyntaxWarning: invalid decimal literal\n<string>:3: SyntaxWarning: invalid decimal literal\n<string>:3: SyntaxWarning: invalid octal literal\n__main____main____main____main____main__j'
(Our literals are "invalid" because Python would prefer we write 1 and over 1and.)
The very last byte of this output is the first byte of the flag. So we can make 95 "guesses" for this single byte and compare them to the encrypted response from the server. Only our guess for j will match it.
Then we continue, this time with one fewer byte of junk data, so there's room for two characters of the flag. We send:
>>> 1and 1and __name__*11+flag_you_will_never_etc
And the server responds by encrypting:
b'<string>:3: SyntaxWarning: invalid decimal literal\n<string>:3: SyntaxWarning: invalid decimal literal\n__main____main____main____main____main____main____main____main____main____main____main__ja'
and we can guess the next byte of the flag. Repeat until we have the whole thing.
If we only had __name__ at our disposal, we'd be forced to guess 8 bytes of the flag at a time, but by combining it with SyntaxWarnings of various known lengths, we can make junk of any length we desire. Nifty!
I couldn't summarize this writeup better than the flag itself:
jail{pyth0n_tr4c3b4ck_is_a_l3v3n5ht31n_d15t4nc3_0r4cle}
Thanks to Lyndon for this creative challenge, and to my teammate ovs for realizing we could use NameError to leak secret.