Escaping the PyJail

Introduction. This is a fun hacking challenge done at Santa's Hacking Challenge. You have access to a restricted, sandboxed Python shell (mimicking an online service), and you need to gain broader access to the system. Being unfamiliar with the specificities of Python, hacking my way through the challenge forced me to explore a lot of directions.

Attempt the challenge : contact me for the link.

Scenario. We connect through SSH to a server, and are welcomed by the following text :

Welcome to this python sandbox! All you need is in exit() function (arg -> find the flag)
>>> 

What happens if we simply quit the Python interpreter ?

>>> ^CTraceback (most recent call last):
  File "/challenge/file.py", line 49, in 
    data = raw_input('>>> ')
KeyboardInterrupt
Error in sys.excepthook:
Traceback (most recent call last):
  File "/usr/lib/Python2.7/dist-packages/apport_Python_hook.py", line 46, in apport_excepthook
    if exc_type in (KeyboardInterrupt, ):
NameError: global name 'KeyboardInterrupt' is not defined

Original exception was:
Traceback (most recent call last):
  File "/challenge/file.py", line 49, in 
    data = raw_input('>>> ')
KeyboardInterrupt
Connection closed.
Nothing useful here; quitting the interpreter also kills the SSH connection.

Intel gathering

Alright, let's explore the sandbox, and in particulare poke around the exit function :

>>> exit
>>> exit()
TypeError : exit() takes exactly 1 argument (0 given)
>>> exit(1)
You cannot escape !
>>> exit("a")
Denied

So exit() takes one argument, which we need to find. We get a first non-standard error : the Denied is obviously part of the sandboxing mechanism.

Let's explore what the sandbox let us do :

>>> a=2
>>> a
>>> print a
2
>>> b="test"
Denied
>>> b='test'
Denied
>>> ""
Denied
>>> eval
Denied
>>> import ''
Denied
>>> import 
Denied

Our friend Denied is all over the place. We can do variable assignments, but cannot create strings using " or '. Built-ins like import also trigger the sandboxing mechanism.

>>> dir     
NameError : name 'dir' is not defined

That's interesting. Some commands are Denied, while some are Not defined. In Python, you can easily remove functions using the built-in function del. This is probably a good way of creating a sandbox : simply destroy/remove the functions that should not be accessible. The obvious problem is that the sandboxing script itself might need those functions : doing del print() will also prevent the sandboxing script from using print(). One option could be to rename the functions needed by the script, but not accessible to the users :

my_super_secret_print=print;     
del(print);
my_super_secret_print("my_message")

But if this technique has been used, there's not much we can do. One other option would be to process the input from the user, and prevent him from calling these functions. That would go very well with our "Denied" messages ! Let's try it :

>>> input
Denied
>>> ainputa
Denied

Indeed, there is a regex search on the input, and if is contains some keywords, we get Denied instead of executing our code. Ok. What the script does is a bit clearer. Let's now be a bit more rigorous, and test every built-in function; maybe the developer was careless ? Unfortunately, no function except print is available. In addition, the following characters / strings are blocked :

input
eval
_
__
"
'

Errors & Maths

To better understand the script, I tried listing the possible error messages :

I realized that most math operations were allowed :

Interestingly, the variable x is set by default in the interpreter (while all others 1-char variables are Undefined). More intriguing, it is set to the string OverFlowError, as the following example demonstrates :

>>> print x
OverFlowError
>>> print x*2
OverFlowErrorOverFlowError

(In Python, the * operators repeats the string). Hence, I tried playing around with the integer values fed to exit(). Unfortunately, in Python, integers are like Java's BigInteger, and there's no problem defining y=10**100.

Reflection

What should be clear at this point is that we do not have much. There's no evident hole in the system. At this point, my options were the following :

  1. Brute-force the argument of exit(). This might be acceptable if we are able to do a loop within the interpreter.
  2. Try to gain some extra information about the source/the code. While being a long shot, I really felt like I was missing information at that point.

Unfortunately, (1) turns out not to be doable. Python uses indentation for loops, and the current script processes input line-by-line. I tried using \n and \t characters to do a one-liner, without luck. Bruteforcing from my computer (through ssh) is of course out of the question.

To do (2), I started looking into Python reflection. In particular, I found a thread on StackOverflow : How can I get the source code of a Python function?. I dwelt into the topic, but unfortunately, the Python reflection is packed in modules (either inspect or dis). As mentioned, I'm not able to import new modules, and both weren't available. However, I got the inspiration from a less-related answer to the same question. In Python, functions are objects! It would be great to print the contents of our exit function/object, maybe the flag is hidden there.

In a normal Python interpreter, listing the contents of an object is super simple, and is done via dir(). But remember how it is blocked by the regex ? So, I can probably access attributeX of the object/function exit by calling :

>>> exit.attributeX
but I won't be able to list them. I could blindly try attributes such as flag or pass, but what's there by default ?

On my own machine, I created and executed the following Python script :

def test( flag_input ):
   if flag_input == 12345:
        print "Success!"
   else:
        print "Failure !"
   return 1

print dir(test)

and the result was :

['__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__',
'__dict__', '__doc__', '__format__', '__get__', '__getattribute__', '__globals__', '__hash__',
'__init__', '__module__', '__name__', '__new__', '__reduce__', '__reduce_ex__', '__repr__',
'__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'func_closure', 'func_code',
'func_defaults', 'func_dict', 'func_doc', 'func_globals', 'func_name']

Hold on. Did you see that ? func_code ? this property is there by default in the object (assuming the same version of Python, of course). Can we call it in our sandbox ?

>>> print exit.func_code
<code object exit at 0xb7b57a40, file "/challenge/file.py", line 27>

That doesn't help yet. But a quick search brings us there, where we learn that the return of func_code is itself an object, with really interesting methods. Again, on my local machine :

def test( flag_input ):
   if flag_input == 12345:
        print "Success!"
   else:
        print "Failure !"
   return 1

print dir(test.func_code)

and the result was :

['__class__', '__cmp__', '__delattr__', '__doc__', '__eq__', '__format__', '__ge__',
'__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__ne__',
'__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts',
'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_lnotab', 'co_name',
'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames']

If the input is compared to something, it is probably a simple equality test with a constants. Let's try co_consts on the sandbox :

>>> print exit.func_code.co_consts
(None, 'flag-THE_FLAG', -1, 'cat .passwd', 'You cannot escape !')

This looks like the solution ! ... but it's not. Remember the welcome message ?

Welcome to this python sandbox! All you need is in exit() function (arg -> find the flag)
>>> 

So this constant is the one that allows you to get the flag; not the flag, even if it starts with flag_. In this case, this is a hacking challenge, not a real-world scenario; trying the wrong flag has little consequence. In a real-world PyJail, testing the wrong flag might rise some alerts.

The final challenge was be able to type that string. Remember that ' and " are forbidden :

>>> exit("flag-THE_FLAG")
Denied
>>> exit('flag-THE_FLAG')
Denied
>>> a='flag-THE_FLAG'
Denied

Solution

I tried poking around join(), chr, different encoding function to convert an integer to a string, but without success. Silly me, the answer was much simpler :

>>> exit(exit.func_code.co_consts[1])
Well done flag : flag-THE_REAL_FLAG
Connection closed.

Unsolved mystery

I still wonder what's the use of the variable x set to OverFlowError. I'll update this section when I find out !

Ludovic Barman
Ludovic Barman
written on :
21 / 08 / 2016