BatCave

Description

Challenge Description

This was a really nice challenge thanks @realgam3!.

Solution

Inventory:

  1. A site.
  2. An archive BatCave.tar.gz, probably a reducted source of the server.
  3. A packet capture file BatCave.pcap with a tlskey.log.

As a cryptography challenge the main suspect is the OTP functionality, this is good as confirmed by the last sentance in the challenge description:

The only problem is that he renewed the OTP right after we stopped sniffing and he left...

Assumptions:

  1. The pcap will be used to find the credentials of robin.
  2. The archive will be used to find the vulnerability in the OTP implementation.
  3. The site will be used to login and get the flag, and also to help with understanding the packet capture.

The site

Navigating to the site we are greeted with:

HomePage

  1. We see we got a session cookie assigned.
  2. Looking at the site sources we look at the js/main.js to see how the sites operates, and the table below summarize the APIs the site access:

Flows

Function URI arguments used in flow(s)
isUserExists /api/user/exists username sign-up
getOTPQRCode /api/user/otp username sign-up, renew-otp
getMe /api/user/me username
signUp* /api/user/signup username, password, otp sign-up
signIn* /api/user/signin username, password, otp sign-in
renewOTP* /api/user/renew otp renew-otp

Functions with '*' are UI-based-flows, with the browser integrated console try using the non-UI based APIs:

await isUserExists('test')
/// false
await Promise.all(['robin', 'batman', 'alfred'].map(u => isUserExists(u)))
/// Array(3) [ true, true, true ]
await getOTPQRCode('test') 
/// "data:image/svg+xml;base64,PD94bWwgdmVyc2l...."
await getOTPQRCode('robin') 
/// "data:image/svg+xml;base64,PD94bWwgdmVyc2l...."
await getMe('test')
/// Uncaught (in promise) Error: You're not logged in
await getMe('robin')
/// Uncaught (in promise) Error: You're not logged in

We try signup: SignUp

Using an Authenticator App, for example from Microsoft or Google, we can scan the QR code to enroll and use the generated OTP to finalize the signup flow. This would have been the eazy way to register, but:

  1. It is better to understand how it works.
  2. The some of the details will be important later.
  3. We did it the hard way :-)

Either downloading the QR Code image in svg format and using svg2png to convert to png or just capturing it directly from the screen we use a QR Code reader and get the following URI otpauth://totp/BatCave:test?secret=GN2AYLZSKJXUOZKW&issuer=INTENT&digits=8&period=30.

Since otpauth URI is missing standartization it is time to check the archive we got and see the server implementation, at least peek at what is used to generate the OTP.

Peeking into the archive

We extract the archive and check out the content tar xzvf BatCave.tar.gz, the table below summarizes the contents:

Path
docker-compose.yml A mongo DB and a backend service
backend/Dockerfile An alpine python 3.10 gunicorn container servicing app from index.py
backend/requirements.txt Flask, pyqrcode, onetimepass, Flask-PyMongo
backend/index.py Defines a Flask application implementing the site and it's APIs found above.
backend/otp.py Implements the core OTP logic based on onetimepass and pyqrcode
backend/public/ A directory tree with the static assets (css/js/fonts/images)
backend/views/ A directory with templates (just one actually)

TOTP Script

Installing python3.10 -m pip install onetimepass pyqrcode we can now generate OTPs with a little script totp.py:

import sys
from otp import OneTimePassword
otp = OneTimePassword(sys.argv[1], sys.argv[2])
print(otp.get_totp())

Run python totp.py test GN2AYLZSKJXUOZKW and get 1944140 (as of epoch 1672577806), now completing the signup flow we are welcomed with:

Unauthorized

And indeed in index.py we can find the following:

#...
AUTHORIZED_USERS = list(map(str.strip, os.getenv("AUTHORIZED_USERS", "batman,alfred,robin").split(",")))
FLAG = os.getenv("FLAG", "INTENT{this_is_not_the_flag}")
#...
@app.route("/")
def index():
    username = session.get("username")
    if username is None:
        return render_template("index.html")

    if username not in AUTHORIZED_USERS:
        return render_template("index.html", message="You're not authorized!")

    return render_template("index.html", message=FLAG)

Assuming there are no authorized users except the defaults, as we can't just signup as an authorized user we need to take over an existing authorized user, the only one which we have any info on is robin. It is time to open up wireshark and look at the network capture, we first set (Pre)-Master-Secret log filename in Edit->Preferences->Protocols->TLS as tlskey.log, this will allow as to see "inside" the encryption. Filtering for http2 && http2.headers.path ~ "api/user" we see:

packet capture

This agrees with the signup flow in js/main.js see above. Now we will use Follow HTTP/2 Steam feature in wireshark, convert the SVG in stream for packet 809 into a png and parse it as QR Code to get the URI otpauth://totp/BatCave:robin?secret=KJNCYRDKEJ3F2TJZ&issuer=INTENT&digits=8&period=30, in the stream for packet 817 we find out the registartion details:

{"username":"robin","password":"TeanTitansGo!","otp":"06840362"}

Since robin did a renew otp flow we expect his secret now is not KJNCYRDKEJ3F2TJZ. Confirmed by trying to log-in with the credentials using our totp.py script resulting in a Incorrect OTP! error. Which also confirms that the username and the password are correct.

Now it is time to figure out the new OTP secret of robin, looking at index.py we see some suspicious lines:

from otp import Random, OneTimePassword
#...
random = Random()
#...
@app.route("/api/user/otp", methods=["POST"])
def otp():
    username = request.json.get("username")
    session["secret"] = session.get("secret") or random.random_base32()
    #...

Instead of using Random from random it uses one from otp module, from otp.py the implementation of Random is:

import base64
import random
import string
#...
class Random(random.Random):
    def __init__(self):
        super().__init__(self)

    def random_printable(self, size=10):
        return bytearray([self.choice(string.printable.encode()) for _ in range(size)])

    def random_base32(self, size=10):
        return base64.b32encode(self.random_printable(size=size)).decode()

The methods random_printable and random_base32 both looks legit, the real suspect here is the constructor __init__, which calls the parent constructor with self as a parameter. As this is a usage of super() with no arguments it will be translated as:

random.Random.__init__(self, self)

Hence we need to see what the parameter to random.Random.__init__ does, from the sources:

import _random
#...
class Random(_random.Random):
#...
    def __init__(self, x=None):
        """Initialize an instance.
        Optional argument x controls seeding, as for Random.seed().
        """

        self.seed(x)
#...
    def seed(self, a=None, version=2):
#...
        if version == 1 and isinstance(a, (str, bytes)):
#...
        elif version == 2 and isinstance(a, (str, bytes, bytearray)):
#...
        elif not isinstance(a, (type(None), int, float, str, bytes, bytearray)):
            _warn('Seeding based on hashing is deprecated\n'
                  'since Python 3.9 and will be removed in a subsequent '
                  'version. The only \n'
                  'supported seed types are: None, '
                  'int, float, str, bytes, and bytearray.',
                  DeprecationWarning, 2)

        super().seed(a)

The last case in random.Random.seed is our case as Random is not any of the acceptable types, now going deeper to find out how _random.Random.seed works:

static PyObject *
random_seed(RandomObject *self, PyObject *arg)
{
    PyObject *result = NULL;            /* guilty until proved innocent */
    PyObject *n = NULL;
    //...

    if (arg == NULL || arg == Py_None) {
       //...
        Py_RETURN_NONE;
    }

    if (PyLong_CheckExact(arg)) {
        n = //...
    } else if (PyLong_Check(arg)) {
        //...
        n = //...
    }
    else {
        Py_hash_t hash = PyObject_Hash(arg);
        if (hash == -1)
            goto Done;
        n = PyLong_FromSize_t((size_t)hash);
    }
    //...
Done:
    //...
    return result;
}

It is evident that the object hash is used as the seed, this lead us even deeper to:

Py_hash_t
PyObject_Hash(PyObject *v)
{
    PyTypeObject *tp = Py_TYPE(v);
    if (tp->tp_hash != NULL)
        return (*tp->tp_hash)(v);
    //...
    return PyObject_HashNotImplemented(v);
}
//...
PyTypeObject PyBaseObject_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "object",                                   /* tp_name */
//...
    (hashfunc)_Py_HashPointer,                  /* tp_hash */

and finally the actual implementation is:

Py_hash_t
_Py_HashPointerRaw(const void *p)
{
    size_t y = (size_t)p;
    /* bottom 3 or 4 bits are likely to be 0; rotate y by 4 to avoid
       excessive hash collisions for dicts and sets */
    y = (y >> 4) | (y << (8 * SIZEOF_VOID_P - 4));
    return (Py_hash_t)y;
}

Py_hash_t
_Py_HashPointer(const void *p)
{
    Py_hash_t x = _Py_HashPointerRaw(p);
    if (x == -1) {
        x = -2;
    }
    return x;
}

from all of the above we arrive at the conclusion that the hash value would be the heap address of the Random object rotated by 4 bits. verifying this we check:

class A: pass
a = A()
print(a) # <__main__.A object at 0x7fa5be137250>
print(hex(hash(a))) # 0x7fa5be13725

as expected. This hash value would be used to seed the actual random implementation, hence we will be able to know future secret values that might have been used by robin to renew his OTP secret. Be employing some exhaustive search (aka brute-force) we can find the correct seed that will produce the secret used in robin's registration. To reduce the search space we run the provided Docker image after modifing it to print the hash of the Random object and exit, using this method we collect dozens of samples to conclude some pattern, first we edit index.py:

#... all imports ...
import sys # for sys.exit

#... staff before next line ...
random = Random()
print(hex(hash(random))) # print the thing!
sys.exit(0)

We use this by building the docker image and running for i in $(seq 1 30); do docker run --rm -it intent-ctf/backend python index.py; done, an example output:

0x7f7ff84600c
0x7f0fc1f8e0c
0x7f0787b400c
0x7f7d4b24c0c
0x7f2d58b5c0c
0x7f784f8400a
0x7fd8cf50807
0x7f18f3a200c
0x7fdcb73c20c
0x7f610fdb00a
0x7fbea6e110c
0x7feccce0e0c
0x7fd7921500c
0x7f80ddeb20c
0x7f0d9ee9007
0x7f44a238e0c
0x7f4afe4920c
0x7ff367dea0c
0x7fc3b309e0c
0x7fcdd01ae0a
0x7f51d98de0c
0x7fc6db7ec0c
0x7fa46890a0c
0x7ff01068a0c
0x7f04739c20c
0x7fdca02080c
0x7f61922200c
0x7f30019b60c
0x7fdbfd5ac0c
0x7f97de4f20c

At our prompting that the search space was too big the author provided the hint: "We found part of a Random address in memory: 0xXXXXXXX39070". Our search script as follows:

import sys
from otp import Random, OneTimePassword
target='KJNCYRDKEJ3F2TJZ'
r=Random()
p = int(sys.argv[1])
m=0x100000>>3
s=p*m
e=(p+1)*m
hbar=0x7f000003907
for mid in range(s,e):
    can = hbar+(mid<<16)
    r.seed(can)
    for i in range(1):
        if r.random_base32() == target:
            print(i, hex(can))
            real = r.random_base32()
            print(real)
            otp = OneTimePassword(user_name='robin',otp_secret=real)
            print(otp.get_totp())

which gives the seed 0x7fcc3ed3907 the next random is KVXVEV3MEJYV2ICA. Using that with our script above, we finally log in as robin, and are greeted with: Success

The flag is: INTENT{J0k3r_1s_Pr3d1ct4bl3}