Polish ESCS Qualifications 2021 - login system

Opliko's photo
·Jul 4, 2021·

7 min read

While unfortunately I haven't managed to do a lot during the CTF (having different plans for the weekend didn't help when I picked apparently the second hardest challenge [judging by number of solves] after the few easiest ones :), I thought it's a good opportunity to start this blog.

About the task

This is the first (not counting the sanity check) challenge of the CTF and as such it was definitely the most solved one - probably due to the placement and relatively low difficulty. You can find the challenge under hack.cert.pl/challenge/login-system

It's description is also short and simple:

Just a login system. Nothing extraordinary.

From the beginning

When we open the page we see that the description was honest and it really is just a login screen with some basic bootstrap styling.


You may have noticed the weird version by now, but we'll come back to it later. First some basics - let's try a few credentials.

admin:admin doesn't work, but we see that we're redirected to /user page. Probably that's our target then.

403 forbidden on /user

If we try a few more we might find that user:user seems to work and confirms the suspicion, but we still need admin credentials:

login as user

We could try some SQLi in the user inputs, but it won't work - and besides, we already have something to investigate.

Weird version and a bit about .git

So what's up with the Version: 8124c1fa36f29d23c70593c5f7cc9ee54244269c string? This doesn't seem like some normal versioning scheme, but rather like a hash or something. Well, let's open the devtools and find if there are some hints:

HTML source

Interesting, it seems that the version isn't set manually, but instead this short script fetches some endpoint to get it. The URL itself is more interesting though. We might have a git source code exposure here.

Unfortunately, it isn't as simple as going to /.git/ - seems like we can't see folders directly.

forbidden on .git

Files in this folder work fine though - attempting to downlaod /.git/HEAD works fine HEAD download

Now, if it was some random folder it'd be a problem, as we'd have to guess the filenames somehow. But fortunately .git folder has a easy to predict structure - due to the nature of git these files need to be linked together in a way that'd allow to find them after all.

We could now read the docs and figure out how git works, but fortunately people have done that for us already. Meet git-dumper - a simple python tool that can extract a repository from an exposed .git folder. After installing it we can simply run it on the required url:

$ git-dumper https://login-system.ecsc21.hack.cert.pl/.git/ login-system

git dumper

And we get a full git repository out - with the code and its history. Let's open the folder in VS Code then and look at what we have.

Analyzing the code

all of the code Well, it seems that we were totally right - if we successfully log in as admin we will get to see contents of flag.txt. The file is in .gitignore, so we can't see it in the repository and the URL also doesn't work, so it seems like we don't have another choice.

It seems that the passwords are hardcoded, so the obvious route here would be to crack the admin password, but I haven't managed to find it in any online reverse lookup, so it can be probably ruled out - besides, what fun would it be to just wait for hashcat to chew through this sha256? There must be a more interesting solution.

If we look through commit history we can see the initial commit and two commits titled fix.

git history

The first fix appears to add the actual CTF mechanics to a previous even simpler app. That is - it adds the flag to the user.html template, modifies app.py to pass it to the template and adds the version script to index.html

fix #1

The second commit contains only a small change to a SECRET_KEY Flask variable and a new comment

fix #2

That is our biggest hint though. If the last change was adding a TODO, then surely it hasn't yet been done, so we can probably assume Flask['SECRET_KEY'] is still set to \xCC repeated 16 times.

The name immediately suggests that we learned something that we shouldn't - so now we just need to know what SECRET_KEY is in Flask and why it needs to be secret, or in other words what can we do if we have it.

Demystifying secrets

Fortunately a quick search can take us to this StackOverflow question titled demystify Flask app.secret_key where we have two helpful responses. It seems that Flask is using it to sign the session cookie. The cookie is not encrypted (which we can confirm in quickstart docs - "user could look at the contents of your cookie but not modify it"). The first answer also mentions the library Flask uses - itsdangerous, and more specifically the URLSafeTimedSerializer class.

itsdangerous docs

From the docs we can see that to create an object of this class we need secret_key (which we have), but can also add salt (which we don't yet know if is changed from the default in Flask), serializer, serializer_kwargs, signer and signer_kwargs. We can probably safely ignore the fallbacks.

We could try using the defaults, but Flask is fortunately open source so we don't have to. We can quickly find its GitHub repository and from there go to the sessions.py file. After a quick search for URLSafeTimedSerializer we can find it in line 343, inside the SecureCookieSessionInterface class where we can find all of the default parameters:

class SecureCookieSessionInterface(SessionInterface):
    """The default session interface that stores sessions in signed cookies
    through the :mod:`itsdangerous` module.
    #: the salt that should be applied on top of the secret key for the
    #: signing of cookie based sessions.
    salt = "cookie-session"
    #: the hash function to use for the signature.  The default is sha1
    digest_method = staticmethod(hashlib.sha1)
    #: the name of the itsdangerous supported key derivation.  The default
    #: is hmac.
    key_derivation = "hmac"
    #: A python serializer for the payload.  The default is a compact
    #: JSON derived serializer with support for some extra Python types
    #: such as datetime objects or tuples.
    serializer = session_json_serializer
    session_class = SecureCookieSession
    def get_signing_serializer(
        self, app: "Flask"
    ) -> t.Optional[URLSafeTimedSerializer]:
        if not app.secret_key:
            return None
        signer_kwargs = dict(
        return URLSafeTimedSerializer(

So it seems we need to set salt to cookie-session, signer_kwargs to {"key_derivation": "hmac", "digest_method": hashlib.sha1} and serializer to... what exactly? The value in class references a global variable that's fortunately just above it:

session_json_serializer = TaggedJSONSerializer()

After looking at the imports we find one that matches this class:

from .json.tag import TaggedJSONSerializer

So we'll need to import this class from Flask.json.tag too.

Creating a cookie

Let's put it all together then:

import hashlib
from itsdangerous.url_safe import URLSafeTimedSerializer
from flask.json.tag import TaggedJSONSerializer

session_json_serializer = TaggedJSONSerializer()

# stolen secret
secret =  b"\xCC" * 16

serializer = URLSafeTimedSerializer(

Finally we can use our serializer object to generate a signed cookie value with the data we need by just passing a dictionary to its dumps method (like we'd do with JSON or any other serializer). We wanted to become the admin user, so we can find in app.py that the key we need to set is user and the value is just the username:

serializer.dumps({"user": "admin"})


And we got a value! We could now craft a request with a proper cookie, and we would have to if we didn't find user credentials (which can also be easily found by running the hash we found for user in any online sha256 reverse lookup), but while it's simple, we just don't have to bother with doing it manually and can just use the devtools modern browsers give us and change the value from there:


and finally, success

After refreshing we get to our flag (redacted, you have to do at least some work yourself to get it :)