Polish ESCS Qualifications 2021 - login system
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.
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:
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:
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.
Files in this folder work fine though - attempting to downlaod /.git/HEAD
works fine
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
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
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
.
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
The second commit contains only a small change to a SECRET_KEY
Flask variable and a new comment
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.
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(
key_derivation=self.key_derivation,
digest_method=self.digest_method
)
return URLSafeTimedSerializer(
app.secret_key,
salt=self.salt,
serializer=self.serializer,
signer_kwargs=signer_kwargs,
)
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(
secret,
salt="cookie-session",
serializer=session_json_serializer,
signer_kwargs=dict(
key_derivation="hmac",
digest_method=hashlib.sha1
)
)
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 :)