Skip to content

Commit ff8ee95

Browse files
Implemented JWT Vulnerabilities (#151)
* Implemented JWT invalid signature vulnerability * Implemented JWT algorithm confusion vulnerability * Implemented JWT KID Path Traversal and JKU Misuse vulnerability * Updated Documentation
1 parent 6796505 commit ff8ee95

File tree

9 files changed

+158
-41
lines changed

9 files changed

+158
-41
lines changed

docs/challengeSolutions.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,32 @@ The above challenge was completed using Burp Suite Community Edition.
102102

103103
### Challenge 14 - Find an endpoint that does not perform authentication checks for a user.
104104

105+
## JWT Vulnerabilities
106+
107+
## [Challenge 15 - Find a way to forge valid JWT Tokens](challenges.md#challenge-15---find-a-way-to-forge-valid-jwt-tokens)
108+
109+
#### Detailed Solution
110+
111+
##### crAPI is vulnerable to to the following JWT Vulnerabilities
112+
1. JWT Algorithm Confusion Vulnerability
113+
- crAPI uses RS256 JWT Algorithm by default
114+
- Public Key to verify JWT is available at http://localhost:8888/.well-known/jwks.json
115+
- Convert the public key to base64 encoded form and use it as a secret to create a JWT in HS256 Algorithm
116+
- This JWT will be accepted as a valid JWT Token by crAPI
117+
2. Invalid Signature Vulnerability
118+
- User Dashboard API is not validating JWT signature
119+
- Create a JWT with `sub` header set to a different user's email
120+
- With the above JWT you will be able to extract user data from user dashboard API endpoint
121+
3. JKU Misuse Vulnerability
122+
- crAPI will verify JWT token with any public key that is pointed to by the `jku` JWT header
123+
- Create your own public/private key pair and sign a JWT in RS256 Algorithm
124+
- Host the public key somewhere in JWK format
125+
- Pass the public key URL in `jku` header of the JWT with appropriate `kid` header
126+
- This JWT will be accepted as a valid JWT Token by crAPI
127+
4. KID Path Traversal Vulnerability
128+
- Set the `kid` header of JWT to `../../../../../../dev/null`
129+
- Create a custom JWT in HS256 algorithm with secret as `AA==`
130+
- `AA==` is the Base64 encoded form of Hex null byte `00`
131+
- This JWT will be accepted as a valid JWT Token by crAPI
132+
105133
## << 2 secret challenges >>

docs/challenges.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ After solving the "Find an API endpoint that leaks an internal property of video
9393

9494
### Challenge 14 - Find an endpoint that does not perform authentication checks for a user.
9595

96+
## JWT Vulnerabilities
97+
98+
### Challenge 15 - Find a way to forge valid JWT Tokens
99+
100+
JWT Authentication in crAPI is vulnerable to various attacks. Find any one way to forge a valid JWT token and get full access to the platform.
101+
96102
## << 2 secret challenges >>
97103

98104
There are two more secret challenges in crAPI, that are pretty complex, and for now we don’t share details about them, except the fact they are really cool.

services/identity/src/main/java/com/crapi/config/JwtAuthTokenFilter.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import com.crapi.enums.EStatus;
1818
import com.crapi.service.Impl.UserDetailsServiceImpl;
1919
import java.io.IOException;
20-
import java.io.UnsupportedEncodingException;
20+
import java.text.ParseException;
2121
import javax.servlet.FilterChain;
2222
import javax.servlet.ServletException;
2323
import javax.servlet.http.HttpServletRequest;
@@ -87,8 +87,7 @@ public String getJwt(HttpServletRequest request) {
8787
* @return return username from HttpServletRequest if request have token we are returning username
8888
* from request token
8989
*/
90-
public String getUserFromToken(HttpServletRequest request) throws UnsupportedEncodingException {
91-
90+
public String getUserFromToken(HttpServletRequest request) throws ParseException {
9291
String jwt = getJwt(request);
9392
if (jwt != null && tokenProvider.validateJwtToken(jwt)) {
9493
String username = tokenProvider.getUserNameFromJwtToken(jwt);

services/identity/src/main/java/com/crapi/config/JwtProvider.java

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,20 @@
1717
import com.crapi.entity.User;
1818
import com.google.gson.Gson;
1919
import com.google.gson.GsonBuilder;
20-
import com.nimbusds.jose.JOSEException;
20+
import com.nimbusds.jose.*;
21+
import com.nimbusds.jose.crypto.RSASSAVerifier;
2122
import com.nimbusds.jose.jwk.JWK;
2223
import com.nimbusds.jose.jwk.JWKSet;
2324
import com.nimbusds.jose.jwk.RSAKey;
25+
import com.nimbusds.jwt.JWTParser;
26+
import com.nimbusds.jwt.SignedJWT;
2427
import io.jsonwebtoken.*;
2528
import java.io.ByteArrayInputStream;
2629
import java.io.IOException;
2730
import java.io.InputStream;
31+
import java.net.URI;
32+
import java.net.URLConnection;
33+
import java.nio.charset.StandardCharsets;
2834
import java.security.KeyPair;
2935
import java.text.ParseException;
3036
import java.util.*;
@@ -43,6 +49,8 @@ public class JwtProvider {
4349

4450
private KeyPair keyPair;
4551

52+
private RSAKey publicRSAKey;
53+
4654
private Map<String, Object> publicJwkSet;
4755

4856
public JwtProvider(@Value("${app.jwksJson}") String jwksJson) {
@@ -56,14 +64,15 @@ public JwtProvider(@Value("${app.jwksJson}") String jwksJson) {
5664
}
5765

5866
RSAKey rsaKey = keys.get(0).toRSAKey();
67+
this.publicRSAKey = rsaKey.toPublicJWK();
5968
this.keyPair = rsaKey.toKeyPair();
6069
this.publicJwkSet = jwkSet.toJSONObject();
6170
} catch (IOException | ParseException | JOSEException e) {
6271
throw new RuntimeException(e);
6372
}
6473
}
6574

66-
public String getPublicJwk() {
75+
public String getPublicJwkSet() {
6776
Gson gson = new GsonBuilder().setPrettyPrinting().create();
6877
return gson.toJson(this.publicJwkSet);
6978
}
@@ -86,12 +95,45 @@ public String generateJwtToken(User user) {
8695
* @param token
8796
* @return username from JWT Token
8897
*/
89-
public String getUserNameFromJwtToken(String token) {
90-
return Jwts.parser()
91-
.setSigningKey(this.keyPair.getPublic())
92-
.parseClaimsJws(token)
93-
.getBody()
94-
.getSubject();
98+
public String getUserNameFromJwtToken(String token) throws ParseException {
99+
// Parse without verifying token signature
100+
return JWTParser.parse(token).getJWTClaimsSet().getSubject();
101+
}
102+
103+
// Load RSA Public Key for JKU header if present
104+
private RSAKey getKeyFromJkuHeader(JWSHeader header) {
105+
try {
106+
URI jku = header.getJWKURL();
107+
if (jku != null) {
108+
URLConnection connection = jku.toURL().openConnection();
109+
JWKSet jwkSet = JWKSet.load(connection.getInputStream());
110+
logger.info("JWKSet from URL : " + jwkSet.toString(false));
111+
JWK key = jwkSet.getKeyByKeyId(header.getKeyID());
112+
if (key != null && Objects.equals(key.getAlgorithm().getName(), "RS256")) {
113+
return key.toRSAKey().toPublicJWK();
114+
}
115+
}
116+
} catch (IOException | ParseException e) {
117+
return null;
118+
}
119+
120+
return null;
121+
}
122+
123+
// Construct secret for JWT Verification through HS256 (vulnerability)
124+
private String getJwtSecret(JWSHeader header) throws JOSEException {
125+
// defaultSecret is RSA Public Key as String
126+
// Algorithm Confusion Attack
127+
Base64.Encoder encoder = Base64.getEncoder();
128+
String defaultSecret = encoder.encodeToString(this.publicRSAKey.toPublicKey().getEncoded());
129+
130+
// Check if KID header is pointing to /dev/null file
131+
String kid = header.getKeyID();
132+
if (kid != null && kid.contains("/dev/null")) {
133+
return "AA==";
134+
}
135+
136+
return defaultSecret;
95137
}
96138

97139
/**
@@ -100,20 +142,36 @@ public String getUserNameFromJwtToken(String token) {
100142
*/
101143
public boolean validateJwtToken(String authToken) {
102144
try {
103-
Jwts.parser().setSigningKey(this.keyPair.getPublic()).parseClaimsJws(authToken);
104-
return true;
105-
} catch (SignatureException e) {
106-
logger.error("Invalid JWT signature -> Message: %d ", e);
107-
} catch (MalformedJwtException e) {
108-
logger.error("Invalid JWT token -> Message: %d", e);
109-
} catch (ExpiredJwtException e) {
110-
logger.error("Expired JWT token -> Message: %d", e);
111-
} catch (UnsupportedJwtException e) {
112-
logger.error("Unsupported JWT token -> Message: %d", e);
113-
} catch (IllegalArgumentException e) {
114-
logger.error("JWT claims string is empty -> Message: %d", e);
115-
// } catch (UnsupportedEncodingException e) {
116-
// logger.error("Unable to convert into byte -> Message: %d", e);
145+
SignedJWT signedJWT = SignedJWT.parse(authToken);
146+
JWSHeader header = signedJWT.getHeader();
147+
Algorithm alg = header.getAlgorithm();
148+
149+
// JWT Algorithm confusion vulnerability
150+
logger.info("Algorithm: " + alg.getName());
151+
if (Objects.equals(alg.getName(), "HS256")) {
152+
String secret = getJwtSecret(header);
153+
logger.info("JWT Secret: " + secret);
154+
Jwts.parser()
155+
.setSigningKey(secret.getBytes(StandardCharsets.UTF_8))
156+
.parseClaimsJws(authToken);
157+
return true;
158+
} else {
159+
RSAKey verificationKey = getKeyFromJkuHeader(header);
160+
JWSVerifier verifier;
161+
if (verificationKey == null) {
162+
verifier = new RSASSAVerifier(this.publicRSAKey);
163+
} else {
164+
logger.info("Key from JKU: " + verificationKey.toJSONString());
165+
verifier = new RSASSAVerifier(verificationKey);
166+
}
167+
168+
return signedJWT.verify(verifier);
169+
}
170+
171+
} catch (ParseException e) {
172+
logger.error("Could not parse JWT Token -> Message: %d", e);
173+
} catch (JOSEException e) {
174+
logger.error("RSA JWK Extraction failed -> Message: %d", e);
117175
}
118176

119177
return false;

services/identity/src/main/java/com/crapi/config/WebSecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ protected void configure(HttpSecurity http) throws Exception {
7979
.csrf()
8080
.disable()
8181
.authorizeRequests()
82-
.antMatchers("/identity/api/auth/**", "/identity/health_check")
82+
.antMatchers(
83+
"/identity/api/auth/**", "/identity/health_check", "/identity/api/v2/user/dashboard")
8384
.permitAll()
8485
.anyRequest()
8586
.authenticated()

services/identity/src/main/java/com/crapi/controller/AuthController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public ResponseEntity<CRAPIResponse> verifyJwtToken(
102102

103103
@GetMapping("/jwks.json")
104104
public ResponseEntity<String> verifyJwtToken() {
105-
return ResponseEntity.status(HttpStatus.OK).body(jwtProvider.getPublicJwk());
105+
return ResponseEntity.status(HttpStatus.OK).body(jwtProvider.getPublicJwkSet());
106106
}
107107

108108
/**

services/identity/src/main/java/com/crapi/service/Impl/UserServiceImpl.java

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import com.crapi.utils.EmailTokenGenerator;
2929
import com.crapi.utils.MailBody;
3030
import com.crapi.utils.SMTPMailServer;
31-
import java.io.UnsupportedEncodingException;
31+
import java.text.ParseException;
3232
import javax.servlet.http.HttpServletRequest;
3333
import javax.transaction.Transactional;
3434
import org.apache.logging.log4j.LogManager;
@@ -77,8 +77,7 @@ public UserServiceImpl() {
7777

7878
@Transactional
7979
@Override
80-
public JwtResponse authenticateUserLogin(LoginForm loginForm)
81-
throws UnsupportedEncodingException, BadCredentialsException {
80+
public JwtResponse authenticateUserLogin(LoginForm loginForm) throws BadCredentialsException {
8281
JwtResponse jwtResponse = new JwtResponse();
8382
Authentication authentication = null;
8483
if (loginForm.getEmail() != null) {
@@ -115,7 +114,7 @@ public JwtResponse authenticateUserLogin(LoginForm loginForm)
115114
}
116115

117116
/**
118-
* @param verifyTokenRequest contains JWT token to be verified
117+
* @param token contains JWT token to be verified
119118
* @return boolean with token valid or not
120119
*/
121120
@Transactional
@@ -172,7 +171,9 @@ public DashboardResponse getUserByRequestToken(HttpServletRequest request) {
172171
DashboardResponse dashboardResponse;
173172
ProfileVideo profileVideo;
174173
try {
175-
user = getUserFromToken(request);
174+
// Invalid Signature vulnerability
175+
// Not Checking the validity of the token for this request
176+
user = getUserFromTokenWithoutValidation(request);
176177
userDetails = userDetailsRepository.findByUser_id(user.getId());
177178
profileVideo = profileVideoRepository.findByUser_id(user.getId());
178179
dashboardResponse =
@@ -297,12 +298,34 @@ public User getUserFromToken(HttpServletRequest request) {
297298
} else {
298299
throw new EntityNotFoundException(User.class, "userEmail", username);
299300
}
300-
} catch (UnsupportedEncodingException exception) {
301+
} catch (ParseException exception) {
301302
logger.error("fail to get username from token -> Message:%d", exception);
302303
throw new EntityNotFoundException(User.class, "userEmail", username);
303304
}
304305
}
305306

307+
@Transactional
308+
@Override
309+
public User getUserFromTokenWithoutValidation(HttpServletRequest request) {
310+
User user = null;
311+
try {
312+
String jwt = jwtAuthTokenFilter.getJwt(request);
313+
String username = jwtProvider.getUserNameFromJwtToken(jwt);
314+
if (username != null && !username.equalsIgnoreCase(EStatus.INVALID.toString())) {
315+
user = userRepository.findByEmail(username);
316+
}
317+
318+
if (user != null) {
319+
return user;
320+
} else {
321+
throw new EntityNotFoundException(User.class, "userEmail", username);
322+
}
323+
} catch (ParseException exception) {
324+
logger.error("fail to get username from token -> Message:%d", exception);
325+
throw new EntityNotFoundException(User.class, "userEmail");
326+
}
327+
}
328+
306329
/**
307330
* @param loginWithEmailToken contains user email and email change token, which allow user login
308331
* with email token
@@ -319,7 +342,7 @@ else if (loginWithEmailToken.getToken() == null)
319342
}
320343

321344
/**
322-
* @param loginWithEmailTokenV2 contains user email and email change token, which allow user login
345+
* @param loginWithEmailToken contains user email and email change token, which allow user login
323346
* with email token
324347
* @return check user and token and return jwt token for user.
325348
*/

services/identity/src/main/java/com/crapi/service/UserService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ CRAPIResponse resetPassword(LoginForm loginForm, HttpServletRequest request)
3636

3737
User getUserFromToken(HttpServletRequest request);
3838

39+
User getUserFromTokenWithoutValidation(HttpServletRequest request);
40+
3941
CRAPIResponse loginWithEmailToken(LoginWithEmailToken loginWithEmailToken);
4042

4143
JwtResponse loginWithEmailTokenV2(LoginWithEmailToken loginWithEmailToken);

services/identity/src/test/java/com/crapi/service/Impl/UserServiceImplTest.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import com.crapi.service.VehicleService;
4040
import com.crapi.utils.SMTPMailServer;
4141
import java.io.UnsupportedEncodingException;
42+
import java.text.ParseException;
4243
import lombok.SneakyThrows;
4344
import org.apache.logging.log4j.core.Appender;
4445
import org.apache.logging.log4j.core.LogEvent;
@@ -106,8 +107,7 @@ public void resetPasswordThrowsExceptionWhenUserNotFound() {
106107
}
107108

108109
@Test(expected = EntityNotFoundException.class)
109-
public void testGetUserFromTokenThrowsExceptionWhenUserNotFound()
110-
throws UnsupportedEncodingException {
110+
public void testGetUserFromTokenThrowsExceptionWhenUserNotFound() throws ParseException {
111111
Mockito.when(jwtAuthTokenFilter.getUserFromToken(Mockito.any())).thenReturn(null);
112112
userService.getUserFromToken(getMockHttpRequest());
113113
}
@@ -118,8 +118,8 @@ public void testGetUserFromToken() {
118118
User user = getDummyUser();
119119
try {
120120
Mockito.when(jwtAuthTokenFilter.getUserFromToken(Mockito.any())).thenReturn(user.getEmail());
121-
} catch (UnsupportedEncodingException e) {
122-
logger.error("UnsupportedEncodingException");
121+
} catch (ParseException e) {
122+
logger.error("ParseException");
123123
}
124124
Assertions.assertEquals(userService.getUserFromToken(getMockHttpRequest()), user);
125125
Mockito.when(userRepository.findByEmail(Mockito.any())).thenReturn(user);
@@ -204,7 +204,7 @@ public void getUserByRequestTokenRequestSuccessFull() {
204204
User user = getDummyUser();
205205
UserDetails userDetails = getDummyUserDetails();
206206
ProfileVideo profileVideo = getDummyProfileVideo();
207-
Mockito.doReturn(user).when(userService).getUserFromToken(Mockito.any());
207+
Mockito.doReturn(user).when(userService).getUserFromTokenWithoutValidation(Mockito.any());
208208
userDetailsRepository.findByUser_id(user.getId());
209209
Mockito.when(userDetailsRepository.findByUser_id(Mockito.anyLong())).thenReturn(userDetails);
210210
Mockito.when(profileVideoRepository.findByUser_id(Mockito.anyLong()))
@@ -220,7 +220,7 @@ public void getUserByRequestTokenRequestSuccessFull() {
220220
public void getUserByRequestTokenRequestSuccessFullWhenUserDetailsNull() {
221221
User user = getDummyUser();
222222
ProfileVideo profileVideo = getDummyProfileVideo();
223-
Mockito.doReturn(user).when(userService).getUserFromToken(Mockito.any());
223+
Mockito.doReturn(user).when(userService).getUserFromTokenWithoutValidation(Mockito.any());
224224
userDetailsRepository.findByUser_id(user.getId());
225225
Mockito.when(userDetailsRepository.findByUser_id(Mockito.anyLong())).thenReturn(null);
226226
Mockito.when(profileVideoRepository.findByUser_id(Mockito.anyLong()))
@@ -237,7 +237,7 @@ public void getUserByRequestTokenRequestSuccessFullWhenUserDetailsNull() {
237237
public void getUserByRequestTokenRequestSuccessFullWhenProfileVideoNull() {
238238
User user = getDummyUser();
239239
UserDetails userDetails = getDummyUserDetails();
240-
Mockito.doReturn(user).when(userService).getUserFromToken(Mockito.any());
240+
Mockito.doReturn(user).when(userService).getUserFromTokenWithoutValidation(Mockito.any());
241241
userDetailsRepository.findByUser_id(user.getId());
242242
Mockito.when(userDetailsRepository.findByUser_id(Mockito.anyLong())).thenReturn(userDetails);
243243
Mockito.when(profileVideoRepository.findByUser_id(Mockito.anyLong())).thenReturn(null);

0 commit comments

Comments
 (0)