Forging A Session Cookie Out Of A Deleted Git Commit
- CTF
- Git
- Flask
- Web
- SFISSA
This was the first challenge I solved at SFISSA's Hack the Flag, and the first CTF challenge I ever worked start to finish. It is called procrastinator, and the description is the whole hint if you read it right:
A dusty archive holds more than old work. Go back far enough, and you'll find something that was never meant to leave.
I did not read it right at first. Here is the actual path, wrong turns included, because the wrong turns are most of what I learned.
Setup, and four ways to waste ten minutes
I run Windows 11 with Kali on WSL, which means my Windows drive is mounted at /mnt/c/. The
challenge handed me a zip in my Downloads folder. Four things went wrong before I had it open.
I tried to SSH into a web server.
ssh 154.57.164.76:31944
ssh: Could not resolve hostname: Name or service not known
Two problems in one line. ssh does not take host:port, the port goes in -p. And nothing
said that instance was SSH in the first place. It was a web app. I had jumped to a tool before
working out what I was even connecting to.
I ran cp with nowhere to copy to.
cp /mnt/c/Users/lolhe/Downloads/a21bac90-....zip
cp: missing destination file operand
cp is cp <source> <destination>. I gave it a source and stopped. The fix is a . on the end,
meaning "into the folder I am standing in."
I was standing in /mnt/c/windows/system32. Not dangerous in WSL, but a bad habit. Work in a
scratch directory.
Then the one that actually cost me time. I ran this:
unzip -l a21bac90-....zip
and then spent a while confused about why I could not cd into anything. -l means list. It
prints what is inside the archive and extracts nothing at all. I had been reading a table of
contents and treating it like a folder. The command I wanted was the same line without the flag.
None of those four are interesting bugs. They are just what it looks like when you know what the tools do in the abstract and have not used them enough for the shapes to be automatic.
Recon
Once it was actually extracted:
unzip a21bac90-....zip
cd crypto_procrastinator
ls -la
build-docker.sh
challenge/
Dockerfile
flag.txt
requirements.txt
cat flag.txt
HTB{fake_flag_for_testing}
Placeholder, as expected. The real flag lives on the running instance, not in the files you are handed. Reading it anyway was still right, because now I know what the flag format looks like.
A friend suggested gobuster at the site, so I ran it:
gobuster dir -u http://<instance> -w /usr/share/wordlists/dirb/common.txt
dashboard (Status: 405)
login (Status: 405)
logout (Status: 302) [--> /]
405 is method not allowed, so those routes exist but refuse a plain GET. That tracks for a
login that wants a POST. Useful, but not the thread. The thread was sitting on my disk already.
The thing that should not have shipped
In the file listing, one directory does not belong in a packaged challenge:
crypto_procrastinator/challenge/application/.git/
A .git directory is the project's entire history. Every version of every file, including the
ones that were later changed or deleted. "Go back far enough" stops being flavor text the moment
you see it.
There was a second tell. requirements.txt listed PyJWT, but nothing in the current code used
it. A dependency with no caller usually means the code that needed it got rewritten, and the
dependency was never cleaned up. Something used to be here.
Reading views.py gave me the shape of the bug:
loginrefuses the literal usernameadmin, but never checks a password against anything. It setssession['user']to whatever you send.dashboardcheckssession['user'] == 'admin'and hands over the flag if it matches.
So I needed to be admin in the session without typing admin at the login form. Nothing in the
current code lets you do that. The history is the only place left to look.
Going back
cd challenge/application
git log
commit 2982215 (HEAD -> main)
critical change :: read secret key from environment
commit 9e336de
initial commit
Two commits, and that message is practically a confession. Somebody hardcoded a secret, then "fixed" it by reading it from the environment instead. So diff them:
git diff 9e336de 2982215
- app.secret_key = 'cb3dc3...' # TODO : remove hardcoded key from git repo
+ app.secret_key = os.environ.get('SECRET_KEY', 'dev')
There it is, TODO and all. I am truncating the key here on purpose, since the challenge may
still be live for other people.
Why one key is the whole app
Flask signs session cookies with secret_key. The cookie is not encrypted, it is signed: you
can read it, but you cannot change it without producing a valid signature, and you cannot produce
a valid signature without the key.
So the key is not "one of the secrets." It is the thing that makes every session claim trustworthy. Anyone holding it can mint a session that says whatever they want, and the server has no way to tell the difference, because by its own rules there is no difference.
pipx install flask-unsign
flask-unsign --sign --cookie "{'user': 'admin'}" --secret 'cb3dc3...'
(pip install fails on Kali with externally-managed-environment. That is PEP 668, and pipx
is the answer: it drops the tool in its own virtualenv and puts the binary on your PATH.)
That prints a three part signed cookie. Then, rather than fighting browser dev tools to set a cookie by hand:
curl -X POST http://<instance>/dashboard --cookie "session=<forged cookie>"
-X POST because gobuster already told me /dashboard rejects GET. The server checked the
signature, the signature was valid, so it believed the payload and handed over the flag.
I never authenticated. I never touched the login form. I just told the server who I was in a language it had no way to doubt.
What I would actually take to work
The real lesson is not the trick, it is the fix that isn't one.
Commit two looks like remediation. The key is out of the source, it reads from the environment now, a reviewer skimming the diff would approve it. But a secret that has ever been committed is in the history forever, and removing it in a later commit changes nothing about that. The only real fix is to rotate the key, because the moment it hit a repo it stopped being a secret. Scrubbing history helps, and it is not sufficient on its own.
Which turns into things worth doing:
- Run
git log -pover any repo you inherit, and grep the history, not just the working tree, for keys and passwords.git log -S'secret_key'finds every commit that added or removed that string. - Treat a shipped
.gitdirectory as a finding by itself. It is not just stale files, it is the full history of everything the tree used to contain, and it is a very common accident in deployed web roots. - Treat "removed hardcoded credential" in a commit message as a rotation ticket, not a closed one.
- A dependency nothing imports is a clue that the code changed shape. Worth a look either way.
That last part is why I am doing offensive challenges at all when the work I want is defensive.
Nothing in this chain was exotic. unzip, git log, git diff, one signing tool, one curl.
The failure was not a clever attack, it was a TODO comment that someone wrote down and meant to
come back to. Hence the name.
One note on honesty, since it matters more to me than looking good: I was being guided through this one. I ran every command myself and the wrong turns above are all mine, but I was not working it out alone, and the write-up of that is here.