This challenge is based on the recent paper Zero Knowledge (About) Encryption: A Comparative Security Analysis of Three Cloud-based Password Managers, which analyses the claim that password manager providers cannot access client passwords/secrets. The paper does this by changing the threat model to one where the attacker has full control of the server.
The challenge implements LastPass’ key hierarchy as described in the paper and gives players root access to the server, while a bot interacts with the server at regular intervals. If you haven’t solved the challenge yet, I would recommend reading the paper and giving it another go. If you have solved the challenge but haven’t read the paper, I can still recommend the read (or at least a skim).
The challenge can be solved in a few ways that exploit the same vulnerability. The below solution is just the one I feel explains the vulnerability best.
LastPet
Jeg har hΓΈrt, at gamificerede applikationer er gode til at fΓ₯ folk til at bruge dem, sΓ₯ jeg har lavet en Tamagochi-adgangskodehΓ₯ndtering for at fΓ₯ flere til at oprette gode adgangskoder. Jeg kan selvfΓΈlgelig ikke se brugernes adgangskoder, sΓ₯ du burde nok kunne fΓ₯ root-adgang for at tjekke sikkerheden.
Author: Rabjho (me)
Category: crypto, web
The challenge handout gives us client.py and access to the server container with credentials root:root. I was quite nervous about unintended solves when handing out those privileges, and wrote this before the competition started, so let’s see…
Solution
Using the client
Let’s start by simply using the client to understand its functionality. We can look at the code afterwards.
First, we make an account:
| |
After logging in we can view and add pets to our zoo - in other words, items to our vault. If we add a pet with the necessary fields and then view our vault, we will see something like this. Each pet has an ASCII picture along with the fields we filled out, plus two new fields: mood and extinction. These will become relevant shortly.
| |
Inspecting the client
The client has quite a lot of code. Most of it is just flavour or infrastructure. Let’s walk through the relevant parts.
At the top of client.py we find the key derivation and encryption code. The key derivation uses PBKDF2 with SHA-256 and 600,000 iterations to produce the master key. The authentication key is derived by doing one additional iteration on top of that. Encryption is standard AES-256-CBC (lines 25β48).
Further down we find the logic for the mood and extinction fields β the gamification features:
- Mood is a gamified measure of password strength, calculated entirely client-side.
- There is also a feature to check if a pet has been poached (i.e. the password has been found in a breach). The client sends the first five characters of the password’s SHA-1 hash to the server, which responds with all known hashes that share that prefix (from a subset of
rockyou.txt). This is the same k-anonymity approach that HaveIBeenPwned uses. - Extinction is fetched from the server based on the decrypted URL. This is the key detail for the primary solution.
| |
The fetch_poached function sends the SHA-1 prefix to the server:
| |
Take note of the fetch_extinction call. The client decrypts the URL field and sends the plaintext domain to the server. This is our primary attack vector.
Enumerating the server
Since we have root access, let’s poke around. The application lives in /app/:
| |
server.py is a standard Flask app served by Gunicorn. Importantly, the server never touches the master key used for encryption. It practically only handles authentication, stores encrypted vaults as JSON files, and provides endpoints for looking up website popularity and password leaks.
Looking at access.logs, we find our next piece of the puzzle:
| |
There is another user, karlkoder, who regularly accesses their vault. We can also inspect their encrypted vault directly at /app/zoos/karlkoder.json.
The vulnerability: malleable encrypted fields
Here is the core insight. The encrypted fields have no authentication (plain AES-CBC, no HMAC or GCM), and the same key is reused for all fields in the vault. This means we can freely swap ciphertext between fields.
Recall that when the client renders a pet, it decrypts the URL field and sends the plaintext to the server via the /extinction/<url> endpoint. So if we take the ciphertext from the password field and copy it into the url field in karlkoder.json, the client will happily decrypt the password, think it’s a URL, and send it in plaintext to the server.
Patching the server to capture the leak
To actually read the leaked plaintext, we need to patch server.py so that the /extinction/<url> endpoint logs the incoming URL. A one-liner will do:
| |
Looking at entrypoint.sh, we can see it automatically restarts Gunicorn if it ever dies:
| |
So we can simply kill the running Gunicorn processes and let entrypoint.sh restart the server with our patched code:
| |
Getting the flag
After the server restarts with our patch and karlkoder’s bot accesses the vault again, we can see the decrypted URLs appearing in the logs:
| |
These are the decrypted URLs from karlkoder’s vault. To get the flag, we swap the password ciphertext into the url field for the entry labelled “Flag hording service” in karlkoder.json, and wait for the bot to access the vault again. The decrypted password, which is the flag, will appear in access.logs.
Tl;Dr
- We have root on the server and can read/modify all data except the encryption key.
- The encrypted vault fields use AES-CBC with no integrity check and a shared key, making the ciphertext trivially swappable between fields.
- The client leaks the decrypted URL to the server via the
/extinction/<url>endpoint. - We swap the password ciphertext into the URL field in the target user’s vault.
- We patch the server to log requests to
/extinction/. - When the bot renders its vault, the password is decrypted as a “URL” and sent to our patched server in plaintext - giving us the flag.
