Skip to content

Fix JWT alg:none bypass on the refresh-token lesson (Challenge-56) - #491

Closed
beanbeah wants to merge 3 commits into
OWASP-CTF:dc34-ctffrom
beanbeah:ctf/r2-challenge-challenge-56-jwt-refresh-token
Closed

beanbeah wants to merge 3 commits into
OWASP-CTF:dc34-ctffrom
beanbeah:ctf/r2-challenge-challenge-56-jwt-refresh-token

Conversation

@beanbeah

@beanbeah beanbeah commented Aug 9, 2026

Copy link
Copy Markdown

Vulnerability

JWTRefreshEndpoint (/JWT/refresh/checkout and /JWT/refresh/newToken) parsed the caller-supplied JWT with Jwts.parser().parse(token). In jjwt, parse() accepts both cryptographically-signed JWS tokens and unsecured ("alg":"none") JWTs without verifying anything for the latter. An attacker could craft an unsigned token with user=Tom in the claims and have it accepted as authentic, bypassing the signature check entirely — the classic alg:none JWT vulnerability. The old code even had a dedicated success branch that rewarded exactly this forged payload.

Fix

Both endpoints now call Jwts.parser().parseClaimsJws(token) instead of parse(token). parseClaimsJws() requires the token to be a genuine JWS and throws UnsupportedJwtException (a JwtException) for any unsecured/alg:none token, so a forged token's claims are never trusted. Added a JwtException catch in newToken() (previously unnecessary since parse() never threw for a successfully-forged alg:none token) returning 401, mirroring the existing behavior in checkout(). Removed the now-incorrect success-for-alg:none branch.

Verification

  • Standalone jjwt harness (isolated from the Spring test context, to work around heavy local WSL resource contention from many concurrent sibling builds) confirmed: the old parse() call accepts a forged alg:none token and extracts user=Tom; the new parseClaimsJws() call rejects the identical forged token with UnsupportedJwtException; and a legitimately HS512-signed token for the same user is still accepted unchanged, so the real Tom/Jerry login → refresh → checkout flow keeps working.
  • Updated JWTRefreshEndpointTest: algNoneTokenShouldBeRejected (renamed from solutionWithAlgNone) now asserts the forged token is rejected (lessonCompleted=false, jwt-invalid-token feedback) instead of asserting it solves the lesson. Existing tests for the legitimate flow, expired tokens, and malformed-signature tokens are unchanged in expectation.

🤖 Generated with Claude Code

beanbeah and others added 2 commits August 9, 2026 08:37
Every branch built against this base was failing to build/boot in CI
with ExceptionInInitializerError: TypeTag :: UNKNOWN, because lombok
1.18.36 can't reflectively patch JDK 25's javac internals. Baking the
fix into the fork's own dc34-ctf base so every future branch inherits
it automatically instead of needing a manual per-branch cherry-pick.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…en endpoints

JWTRefreshEndpoint.checkout()/newToken() parsed the incoming JWT with
Jwts.parser().parse(token), which in jjwt accepts BOTH signed JWS tokens
and unsecured ("alg":"none") JWTs without verifying anything. An
attacker could therefore forge an unsigned token with claim
user=Tom and have it treated as authentic, bypassing the signature
check entirely (the classic alg:none JWT vulnerability). The old code
even had a dedicated success branch that rewarded exactly this forged
alg:none payload.

Fix: use Jwts.parser().parseClaimsJws(token) instead of parse(token) on
both endpoints. parseClaimsJws() requires the token to be a genuine JWS
(a cryptographically signed JWT) and throws JwtException/
UnsupportedJwtException for any unsecured/alg:none token, so a forged
token is rejected before its claims are ever trusted. Added a
JwtException catch in newToken() (parse() previously never threw for a
successfully-forged alg:none token, so no handling existed there) that
returns 401, mirroring the existing behavior in checkout(). Removed the
now-unreachable/incorrect success-for-alg-none branch in checkout().

Verified independently with a standalone jjwt harness (outside the
Spring test context) showing: (1) the old parse() call accepts a forged
alg:none token and extracts user=Tom, (2) parseClaimsJws() rejects the
identical forged token with UnsupportedJwtException, and (3) a
legitimately HS512-signed token for the same user is still accepted by
parseClaimsJws() unchanged - so the real login/refresh/checkout flow for
Tom and Jerry keeps working.

Updated JWTRefreshEndpointTest: renamed solutionWithAlgNone to
algNoneTokenShouldBeRejected and flipped its assertions to expect
lessonCompleted=false with the jwt-invalid-token feedback message,
since an unsigned alg:none token must never be treated as a solved
assignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

🏆 WebGoat — CTF Patch Score

░░░░░░░░░░░░░░░░░░░░  2 / 137 pts  (1%)

1 / 69 challenges patched

Per-challenge detail is withheld — it would reveal the rubric.

Commit: 7116686 · scoring run

🎉 Your result is on the leaderboard — see where you rank! 🏆

…Header,Claims>)

CI compile failed: parseClaimsJws() returns Jws<Claims>, which is not
assignable to a Jwt<Header, Claims> variable (Java generics are
invariant, and Jws<T> extends Jwt<JwsHeader, T>, not Jwt<Header, T>).
Declare the local as Jws<Claims> instead in both checkout() and
newToken(); .getBody() is unchanged. Reproduced and confirmed this
exact declaration compiles and behaves correctly against the project's
pinned jjwt 0.9.1 with a standalone javac/java smoke test before
pushing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@beanbeah

beanbeah commented Aug 9, 2026

Copy link
Copy Markdown
Author

Closing as part of a full stand-down of this CTF push.

@beanbeah beanbeah closed this Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant