Challenge Description

This was a really nice challenge thanks @realgam3!.



  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...


  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:


  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:


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:

docker-compose.yml A mongo DB and a backend service
backend/Dockerfile An alpine python 3.10 gunicorn container servicing app from
backend/requirements.txt Flask, pyqrcode, onetimepass, Flask-PyMongo
backend/ Defines a Flask application implementing the site and it's APIs found above.
backend/ 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

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

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


And indeed in 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}")
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:


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 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 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 the implementation of Random is:

import base64
import random
import string
class Random(random.Random):
    def __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().

    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)


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) {

    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);
    return result;

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

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_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_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

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

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

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; done, an example output:


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
p = int(sys.argv[1])
for mid in range(s,e):
    can = hbar+(mid<<16)
    for i in range(1):
        if r.random_base32() == target:
            print(i, hex(can))
            real = r.random_base32()
            otp = OneTimePassword(user_name='robin',otp_secret=real)

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}