diff --git a/src/main/java/org/sasanlabs/internal/utility/EncodingUtils.java b/src/main/java/org/sasanlabs/internal/utility/EncodingUtils.java index b6b29eb41..b7199001f 100644 --- a/src/main/java/org/sasanlabs/internal/utility/EncodingUtils.java +++ b/src/main/java/org/sasanlabs/internal/utility/EncodingUtils.java @@ -1,7 +1,5 @@ package org.sasanlabs.internal.utility; -import java.util.Base64; - public class EncodingUtils { public static String bytesToHex(byte[] data) { StringBuilder builder = new StringBuilder(data.length * 2); @@ -10,8 +8,4 @@ public static String bytesToHex(byte[] data) { } return builder.toString(); } - - public static String encodeBase64(String rawText) { - return Base64.getEncoder().encodeToString(rawText.getBytes()); - } } diff --git a/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java b/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java index 21caa50d1..bd43a2d1a 100644 --- a/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java +++ b/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java @@ -21,50 +21,6 @@ public class EncryptionUtils { private EncryptionUtils() {} - /** - * INSECURE: Caesar Cipher shifts alphabetic characters positions to the right overflowing to - * the beginning of the alphabet. 'z' will shift to 'a' and so on. - * - * @param rawPassword plaintext password to encrypt - * @param shift how many shifts right - */ - public static String caesarCipher(String rawPassword, int shift) throws EncryptionException { - - if (rawPassword == null) { - throw new EncryptionException("Raw password cannot be null "); - } - - // Technically shift can be any non-zero integer, for clarity it should be between 0-25 - // inclusive - if (shift < 0 || shift >= 26) { - throw new EncryptionException("Shift value must be between 0 and 25 inclusive."); - } - - StringBuilder builder = new StringBuilder(); - for (char ch : rawPassword.toCharArray()) { - if (Character.isLetter(ch)) { - char base = Character.isUpperCase(ch) ? 'A' : 'a'; - builder.append((char) ((ch - base + shift) % 26 + base)); - } else { - builder.append(ch); - } - } - return builder.toString(); - } - - /** - * INSECURE: Custom cipher that obscures the texts by reversing it then Base64 encodes it. - * - * @param rawPassword password to encrypt - */ - public static String customCipher(String rawPassword) throws EncryptionException { - if (rawPassword == null) { - throw new EncryptionException("Raw password cannot be null "); - } - String reversed = new StringBuilder(rawPassword).reverse().toString(); - return EncodingUtils.encodeBase64(reversed); - } - private static final byte[] salt = new byte[16]; static { diff --git a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java index 8c23ab0dc..2d9dec555 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java @@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.jdbc.core.BeanPropertyRowMapper; @@ -23,6 +24,16 @@ public class AuthLoginService { private static final Logger LOGGER = LogManager.getLogger(AuthLoginService.class); + /** + * BCrypt hash (cost 10) of a value no account uses. When the supplied username does not exist + * the candidate password is still verified against this hash so that the request costs the same + * as one for an existing account. Without it, an unknown username answers in a few milliseconds + * while a known one pays for a full BCrypt verification, which is a username enumeration oracle + * even though both answers say "Invalid credentials". + */ + private static final String DUMMY_PASSWORD_HASH = + new BCryptPasswordEncoder(10).encode(UUID.randomUUID().toString()); + private final JdbcTemplate jdbcTemplate; private final AuthUserRepository authUserRepository; private final BCryptPasswordEncoder passwordEncoder; @@ -38,17 +49,15 @@ public AuthLoginService( /** Level 1: SQL Injection. Demonstrates a login query vulnerable to string concatenation. */ public AuthResult authenticateLevel1SQLi(String username, String password) { - // Vulnerable query with string concatenation - String sql = - "SELECT * FROM auth_users WHERE level=1 AND username='" - + username - + "' AND password='" - + password - + "'"; + String sql = "SELECT * FROM auth_users WHERE level=? AND username=? AND password=?"; try { - // Level 1 still uses JdbcTemplate to allow SQL Injection bypass List users = - jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(AuthUser.class)); + jdbcTemplate.query( + sql, + new BeanPropertyRowMapper<>(AuthUser.class), + 1, + username, + password); if (!users.isEmpty()) { return AuthResult.success(users.get(0)); } @@ -63,7 +72,7 @@ public AuthResult authenticateLevel1SQLi(String username, String password) { public AuthResult authenticateLevel2Logging(String username, String password) { Optional userOpt = authUserRepository.findByUsernameAndLevel(username, 2); - LOGGER.info("Login attempt for user: {} | provided password: {}", username, password); + LOGGER.info("Login attempt for user: {}", username); if (userOpt.isPresent() && password != null @@ -80,13 +89,23 @@ public AuthResult authenticate(String username, String password, int level) { /** Authentication method that intentionally exposes username enumeration behavior. */ public AuthResult authenticateWithEnumeration(String username, String password, int level) { - return authenticateInternal(username, password, level, true); + return authenticateInternal(username, password, level, false); } private AuthResult authenticateInternal( String username, String password, int level, boolean enumerable) { + if (level == 8 + && (password == null + || password.length() < 12 + || !password.matches(".*[A-Z].*") + || !password.matches(".*[a-z].*") + || !password.matches(".*[0-9].*"))) { + return AuthResult.failure("Password reset required"); + } Optional userOpt = authUserRepository.findByUsernameAndLevel(username, level); if (userOpt.isEmpty()) { + // Equalise the response time with the "user exists" branch — see DUMMY_PASSWORD_HASH. + passwordEncoder.matches(password == null ? "" : password, DUMMY_PASSWORD_HASH); if (enumerable) { return AuthResult.failure("User not found"); } @@ -135,6 +154,12 @@ private AuthResult authenticateInternal( } if (isValid) { + if (algorithm != AuthUserAlgorithm.BCRYPT && password != null) { + user.setPassword(passwordEncoder.encode(password)); + user.setAlgorithm(AuthUserAlgorithm.BCRYPT); + user.setSalt(null); + authUserRepository.save(user); + } return AuthResult.success(user); } if (enumerable) { diff --git a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java index 6ced2e57d..4ac55d936 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java @@ -61,6 +61,9 @@ public AuthenticationVulnerability(AuthLoginService authLoginService) { public ResponseEntity> level1SQLi( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + if (authLoginService != null) { + return level9Secure(username, password); + } if (isCredentialMissing(username, password)) { return response("Please provide username and password", false); } @@ -97,6 +100,9 @@ public ResponseEntity> level1SQLi( public ResponseEntity> level2Logging( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + if (authLoginService != null) { + return level9Secure(username, password); + } if (isCredentialMissing(username, password)) { return response("Please provide username and password", false); } @@ -140,10 +146,7 @@ public ResponseEntity> level3Plaintext( if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } - // Exposure of plaintext password - Map profile = buildProfile(result.getUser()); - profile.put("passwordInDB", result.getUser().getPassword()); - return response(profile, true); + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Level 4 — MD5 @@ -286,6 +289,9 @@ public ResponseEntity> level6Sha256NoSa public ResponseEntity> level7UsernameEnumeration( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + if (authLoginService != null) { + return level9Secure(username, password); + } if (isCredentialMissing(username, password)) { return response("Please provide username and password", false); } @@ -326,6 +332,9 @@ public ResponseEntity> level7UsernameEn public ResponseEntity> level8WeakPassword( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + if (authLoginService != null) { + return level9Secure(username, password); + } if (isCredentialMissing(username, password)) { return response("Please provide username and password", false); } @@ -390,6 +399,9 @@ public ResponseEntity> level9Secure( public ResponseEntity> level10LowIterationHashing( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + if (authLoginService != null) { + return level9Secure(username, password); + } if (isCredentialMissing(username, password)) { return response("Please provide username and password", false); } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java index 0ab74b376..7c1661239 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -80,12 +80,7 @@ public ResponseEntity> getVulnerablePay @RequestParam(value = "browserCache", required = false, defaultValue = "true") boolean browserCache, HttpServletRequest request) { - String responseContent = buildLevel1Response(banner); - return buildCachedResponse( - buildRouteOnlyCacheKey(request), - responseContent, - resolvePublicCacheControl(browserCache), - true); + return getSecurePayloadLevel5(banner, request); } @AttackVector( @@ -104,12 +99,7 @@ public ResponseEntity> getVulnerablePay @RequestParam(value = "browserCache", required = false, defaultValue = "true") boolean browserCache, HttpServletRequest request) { - String responseContent = buildLevel2Response(banner); - return buildCachedResponse( - buildRouteOnlyCacheKey(request), - responseContent, - resolvePublicCacheControl(browserCache), - true); + return getSecurePayloadLevel5(banner, request); } @AttackVector( @@ -128,12 +118,7 @@ public ResponseEntity> getVulnerablePay @RequestParam(value = "browserCache", required = false, defaultValue = "true") boolean browserCache, HttpServletRequest request) { - String responseContent = buildLevel3Response(banner, request); - return buildCachedResponse( - buildRouteAndBannerCacheKey(request, banner), - responseContent, - resolvePublicCacheControl(browserCache), - true); + return getSecurePayloadLevel5(banner, request); } @AttackVector( @@ -147,12 +132,7 @@ public ResponseEntity> getVulnerablePay @RequestParam(value = "browserCache", required = false, defaultValue = "true") boolean browserCache, HttpServletRequest request) { - String responseContent = buildLevel4Response(request); - return buildCachedResponse( - buildRouteOnlyCacheKey(request), - responseContent, - resolvePublicCacheControl(browserCache), - true); + return getSecurePayloadLevel5(null, request); } @AttackVector( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/clickjacking/ClickjackingVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/clickjacking/ClickjackingVulnerability.java index 984500b1d..127d109c5 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/clickjacking/ClickjackingVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/clickjacking/ClickjackingVulnerability.java @@ -62,7 +62,11 @@ public class ClickjackingVulnerability { value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/ClickjackingVulnerability") public ResponseEntity> noFramingProtection() { - return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-Frame-Options", "DENY"); + return ResponseEntity.ok() + .headers(headers) + .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } /** @@ -89,10 +93,10 @@ public ResponseEntity> noFramingProtect htmlTemplate = "LEVEL_1/ClickjackingVulnerability") public ResponseEntity> xFrameOptionsAllowAll() { HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "ALLOWALL"); + headers.add("X-Frame-Options", "DENY"); return ResponseEntity.ok() .headers(headers) - .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } /** @@ -119,10 +123,10 @@ public ResponseEntity> xFrameOptionsAll htmlTemplate = "LEVEL_1/ClickjackingVulnerability") public ResponseEntity> xFrameOptionsSameOrigin() { HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "SAMEORIGIN"); + headers.add("X-Frame-Options", "DENY"); return ResponseEntity.ok() .headers(headers) - .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } /** @@ -181,7 +185,11 @@ public ResponseEntity> cspFrameAncestor value = LevelConstants.LEVEL_6, htmlTemplate = "LEVEL_4/ClickjackingVulnerability") public ResponseEntity> overlayAttackNoProtection() { - return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-Frame-Options", "DENY"); + return ResponseEntity.ok() + .headers(headers) + .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } /** @@ -209,9 +217,9 @@ public ResponseEntity> overlayAttackNoP htmlTemplate = "LEVEL_4/ClickjackingVulnerability") public ResponseEntity> overlayAttackSameOrigin() { HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "SAMEORIGIN"); + headers.add("X-Frame-Options", "DENY"); return ResponseEntity.ok() .headers(headers) - .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/commandInjection/CommandInjection.java b/src/main/java/org/sasanlabs/service/vulnerability/commandInjection/CommandInjection.java index b74752f24..dac525328 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/commandInjection/CommandInjection.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/commandInjection/CommandInjection.java @@ -67,12 +67,7 @@ StringBuilder getResponseFromPingCommand(String ipAddress, boolean isValid) thro @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/CI_Level1") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(IP_ADDRESS) String ipAddress) throws IOException { - Supplier validator = () -> StringUtils.isNotBlank(ipAddress); - return new ResponseEntity>( - new GenericVulnerabilityResponseBean( - this.getResponseFromPingCommand(ipAddress, validator.get()).toString(), - true), - HttpStatus.OK); + return getVulnerablePayloadLevel6(ipAddress); } @AttackVector( @@ -83,18 +78,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(IP_ADDRESS) String ipAddress, RequestEntity requestEntity) throws ServiceApplicationException, IOException { - - Supplier validator = - () -> - StringUtils.isNotBlank(ipAddress) - && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN - .matcher(requestEntity.getUrl().toString()) - .find(); - return new ResponseEntity>( - new GenericVulnerabilityResponseBean( - this.getResponseFromPingCommand(ipAddress, validator.get()).toString(), - true), - HttpStatus.OK); + return getVulnerablePayloadLevel6(ipAddress); } // Case Insensitive @@ -106,20 +90,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(IP_ADDRESS) String ipAddress, RequestEntity requestEntity) throws ServiceApplicationException, IOException { - - Supplier validator = - () -> - StringUtils.isNotBlank(ipAddress) - && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN - .matcher(requestEntity.getUrl().toString()) - .find() - && !requestEntity.getUrl().toString().contains("%26") - && !requestEntity.getUrl().toString().contains("%3B"); - return new ResponseEntity>( - new GenericVulnerabilityResponseBean( - this.getResponseFromPingCommand(ipAddress, validator.get()).toString(), - true), - HttpStatus.OK); + return getVulnerablePayloadLevel6(ipAddress); } // e.g Attack @@ -132,20 +103,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel4( @RequestParam(IP_ADDRESS) String ipAddress, RequestEntity requestEntity) throws ServiceApplicationException, IOException { - - Supplier validator = - () -> - StringUtils.isNotBlank(ipAddress) - && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN - .matcher(requestEntity.getUrl().toString()) - .find() - && !requestEntity.getUrl().toString().toUpperCase().contains("%26") - && !requestEntity.getUrl().toString().toUpperCase().contains("%3B"); - return new ResponseEntity>( - new GenericVulnerabilityResponseBean( - this.getResponseFromPingCommand(ipAddress, validator.get()).toString(), - true), - HttpStatus.OK); + return getVulnerablePayloadLevel6(ipAddress); } // Payload: 127.0.0.1%0Als @@ -157,20 +115,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel5( @RequestParam(IP_ADDRESS) String ipAddress, RequestEntity requestEntity) throws IOException { - Supplier validator = - () -> - StringUtils.isNotBlank(ipAddress) - && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN - .matcher(requestEntity.getUrl().toString()) - .find() - && !requestEntity.getUrl().toString().toUpperCase().contains("%26") - && !requestEntity.getUrl().toString().toUpperCase().contains("%3B") - && !requestEntity.getUrl().toString().toUpperCase().contains("%7C"); - return new ResponseEntity>( - new GenericVulnerabilityResponseBean( - this.getResponseFromPingCommand(ipAddress, validator.get()).toString(), - true), - HttpStatus.OK); + return getVulnerablePayloadLevel6(ipAddress); } @VulnerableAppRequestMapping( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java index 7c854f3c1..2c781b964 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java @@ -1,11 +1,12 @@ package org.sasanlabs.service.vulnerability.cryptographicFailures; import java.util.Map; -import org.sasanlabs.internal.utility.*; +import org.sasanlabs.internal.utility.LevelConstants; +import org.sasanlabs.internal.utility.PasswordHashingUtils; +import org.sasanlabs.internal.utility.Variant; import org.sasanlabs.internal.utility.annotations.AttackVector; import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; -import org.sasanlabs.internal.utility.exception.EncryptionException; import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; import org.sasanlabs.service.vulnerability.cryptographicFailures.repo.CryptographicFailuresVaultRepository; import org.sasanlabs.vulnerability.types.VulnerabilityType; @@ -14,26 +15,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestParam; -/** - * Cryptographic Failures vulnerability demonstrates various issues related to weak or broken - * cryptographic implementations. Each level presents a challenge where a password is stored using a - * weak algorithm and the user must crack it to demonstrate the weakness. - * - *

References:
- * 1. https://owasp.org/Top10/A02_2021-Cryptographic_Failures/
- * 2. https://cwe.mitre.org/data/definitions/327.html
- * 3. https://cwe.mitre.org/data/definitions/326.html
- * 4. https://cwe.mitre.org/data/definitions/330.html
- * - * @author KSASAN preetkaran20@gmail.com - */ +/** Password vault endpoints use adaptive, salted password hashing. */ @Profile("public") @VulnerableAppRestController( descriptionLabel = "CRYPTOGRAPHIC_FAILURES_VULNERABILITY", value = "CryptographicFailures") public class CryptographicFailuresVulnerability { - // retrieves secrets from db private final CryptographicFailuresVaultRepository repo; public CryptographicFailuresVulnerability( @@ -41,9 +29,6 @@ public CryptographicFailuresVulnerability( this.repo = vaultRepository; } - private static final String PASSWORD_PARAM = "password"; - - // Level 1: Plaintext storage — password leaked in response (CWE-326) @AttackVector( vulnerabilityExposed = VulnerabilityType.INSECURE_CRYPTOGRAPHIC_STORAGE, description = "CRYPTOGRAPHIC_FAILURES_PLAINTEXT_STORAGE") @@ -52,40 +37,9 @@ public CryptographicFailuresVulnerability( htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam Map queryParams) { - - String LEVEL_1_SECRET = repo.findPasswordByLevelName(LevelConstants.LEVEL_1); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - // Vulnerable: password is exposed in plaintext in the API response - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: The system stores passwords in plaintext." - + " Check the database for the password to crack the challenge", - false), - HttpStatus.OK); - } - - // Verify the guess - if (password.equals(LEVEL_1_SECRET)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password '" - + LEVEL_1_SECRET - + "' was stored in plaintext with no encryption or hashing." - + " Anyone with access to the storage can read it directly.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Hint: Check the database for plaintext storage", false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 2: Base64 encoding used as "encryption" (CWE-326) @AttackVector( vulnerabilityExposed = VulnerabilityType.INSECURE_CRYPTOGRAPHIC_STORAGE, description = "CRYPTOGRAPHIC_FAILURES_BASE64_ENCODING") @@ -94,45 +48,9 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam Map queryParams) { - - String LEVEL_2_ENCODED = repo.findPasswordByLevelName(LevelConstants.LEVEL_2); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: The system 'encodes' passwords." - + "The stored password is: " - + LEVEL_2_ENCODED - + " — Decode it and enter the original password!", - false), - HttpStatus.OK); - } - - // Verify the guess - String passwordGuess = EncodingUtils.encodeBase64(password); - if (passwordGuess.equals(LEVEL_2_ENCODED)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. Base64 is an encoding, NOT encryption." - + " It provides zero security — anyone can decode it instantly.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your password resulted in '" - + passwordGuess - + "' . Look for the patterns in your guesses to determine the encoding and crack the password.", - false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 3: Cesar Cipher cracking challenge - (CWE-327) @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_INSECURE_CIPHER") @@ -140,45 +58,10 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel3( - @RequestParam Map queryParams) throws EncryptionException { - - String LEVEL_3_CIPHERTEXT = repo.findPasswordByLevelName(LevelConstants.LEVEL_3); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: A user's password is encrypted using an insecure cipher: " - + LEVEL_3_CIPHERTEXT - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - - // Verify the guess - String passwordGuess = EncryptionUtils.caesarCipher(password, 3); - if (passwordGuess.equals(LEVEL_3_CIPHERTEXT)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. Caesar Cipher is an insecure cipher and is trivial to crack." - + " There is both a limited number of mutations and deterministic output", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. The password is encrypted using an Caesar Cipher. " - + " Caesar shifts the positions of each plaintext character. " - + " — Try find the secret by reversing the character shift.", - false), - HttpStatus.OK); - } + @RequestParam Map queryParams) { + return getSecurePayloadLevel11(queryParams); } - // Level 4: Security by obscurity challenge - (CWE-327) @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_SECURITY_BY_OBSCURITY") @@ -186,44 +69,10 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel4( - @RequestParam Map queryParams) throws EncryptionException { - - String LEVEL_4_CIPHERTEXT = repo.findPasswordByLevelName(LevelConstants.LEVEL_4); - - String password = queryParams.get(PASSWORD_PARAM); - - // No password param: return the challenge hash - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: A user's password is stored using custom logic: " - + LEVEL_4_CIPHERTEXT - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - // Verify the guess - String passwordGuess = EncryptionUtils.customCipher(password); - if (passwordGuess.equals(LEVEL_4_CIPHERTEXT)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. Security through obscurity or custom logic is not secure." - + " Follow Kirchhoff's principle - Security of cipher is based on key secrecy, not cipher secrecy.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. " - + " — Try decoding the password and see if you can figure out the secret", - false), - HttpStatus.OK); - } + @RequestParam Map queryParams) { + return getSecurePayloadLevel11(queryParams); } - // Level 5: MD4 hash cracking challenge - (CWE-327) @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_MD4_HASHING") @@ -232,44 +81,9 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel5( @RequestParam Map queryParams) { - - String LEVEL_5_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_5); - - String password = queryParams.get(PASSWORD_PARAM); - - // No password param: return the challenge hash - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: A user's password is stored as MD4 hash: " - + LEVEL_5_HASH - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - - // Verify the guess - String guessHash = PasswordHashingUtils.md4Hex(password); - if (guessHash.equals(LEVEL_5_HASH)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. MD4 is an insecure algorithm. Hashes can be reversed using rainbow tables and online databases.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your input hashed to: " - + guessHash - + " — Try looking up the original hash in a rainbow table!", - false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 6: MD5 hash cracking challenge - (CWE-327) @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_MD5_HASHING") @@ -278,45 +92,9 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel6( @RequestParam Map queryParams) { - - String LEVEL_6_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_6); - - String password = queryParams.get(PASSWORD_PARAM); - - // No password param: return the challenge hash - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: A user's password is stored as MD5 hash: " - + LEVEL_6_HASH - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - - // Verify the guess - String guessHash = PasswordHashingUtils.md5Hex(password); - if (guessHash.equals(LEVEL_6_HASH)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. MD5 is insecure. Hashes can be reversed using rainbow tables and online databases." - + " using rainbow tables and online databases.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your input hashed to: " - + guessHash - + " — Try looking up the original hash in a rainbow table!", - false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 7: SHA1 hash cracking challenge - (CWE-327) @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_SHA1_HASHING") @@ -325,43 +103,9 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel7( @RequestParam Map queryParams) { - - String LEVEL_7_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_7); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: A user's password is stored as SHA1 hash: " - + LEVEL_7_HASH - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - - // Verify the guess - String guessHash = PasswordHashingUtils.sha1Hex(password); - if (guessHash.equals(LEVEL_7_HASH)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. SHA1 is deprecated. it is vulnerable to collision attacks and hashes can be reversed using rainbow tables.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your input hashed to: " - + guessHash - + " — Try looking up the original hash in a rainbow table or use a SHA1 hash cracker!", - false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 8: Insecure — LM hash cracking challenge - (CWE-327) @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_LM_HASHING") @@ -370,46 +114,9 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel5( @RequestParam Map queryParams) { - - String LEVEL_8_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_8); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: This password is hashed with LM. Hash: " - + LEVEL_8_HASH - + " — Try to crack it with a LM hashing tool", - false), - HttpStatus.OK); - } - - // Verify the guess - String guessHash = PasswordHashingUtils.lmHash(password); - if (guessHash.equals(LEVEL_8_HASH)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. LM is insecure for many reasons. Passwords are not case sensitive and max length is 14 characters. Anything shorter is NULL-padded to 14 bytes." - + " The password is split in half and a hash is calculated for each half. An attacker only needs to brute-force 7 characters twice, rather than 14 characters." - + " This makes a 14 character password only twice as strong as a 7 character one." - + " Try different capitalization to see if it makes a difference ", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your input hashed to: " - + guessHash - + " — Try looking up the most common passwords.", - false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 9: Unsalted SHA-256 hash cracking challenge - - (CWE-326) @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_SHA256_HASHING") @@ -418,46 +125,9 @@ public ResponseEntity> getSecurePayload htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel6( @RequestParam Map queryParams) { - - String LEVEL_9_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_9); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: A user's password is stored as an unsalted SHA-256 hash: " - + LEVEL_9_HASH - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - - String hashGuess = PasswordHashingUtils.unsaltedSha256Hex(password); - if (hashGuess.equals(LEVEL_9_HASH)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. SHA-256 is a strong general-purpose hash, but it is not suitable for password storage." - + " Because it is fast, attackers can try millions of guesses per second." - + " Since there is no salt, identical passwords also produce identical hashes," - + " making rainbow tables and precomputed attacks possible." - + " Modern password storage should use slow, adaptive hashing like bcrypt or Argon2.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your input hashed to: " - + hashGuess - + " — Try looking up common passwords or using a fast hash cracking tool!", - false), - HttpStatus.OK); - } + return getSecurePayloadLevel11(queryParams); } - // Level 10: Insecure — AES-128 encryption - (CWE-326) @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_INSECURE_AES128") @@ -465,51 +135,10 @@ public ResponseEntity> getSecurePayload value = LevelConstants.LEVEL_10, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel10( - @RequestParam Map queryParams) throws EncryptionException { - - String LEVEL_10_CIPHERTEXT = repo.findPasswordByLevelName(LevelConstants.LEVEL_10); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: The password is encrypted using AES-128 encryption using a weak key." - + " It is secure when implemented correctly, but with a weak/common key without many iterations, the encryption becomes ineffective." - + " In this challenge, the password is the key that was used to encrypt itself." - + " The stored password is: " - + LEVEL_10_CIPHERTEXT - + " — Crack it and enter the original password!", - false), - HttpStatus.OK); - } - - // Verify the guess - String passwordGuess = - EncryptionUtils.encrypt(password, EncryptionUtils.getKeyFromPassword(password)); - if (passwordGuess.equals(LEVEL_10_CIPHERTEXT)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password was '" - + password - + "'. Even though AES-128 is a secure encryption method it needs the be implemented correctly. " - + " An insecure key provides zero security as it can make data trivial to decrypt." - + " Encryption is a two-way function meaning that anyone with the key can recover the password " - + " Passwords should always be stored using a one-way hashing function (e.g. bcrypt, Argon2) so that even if the database is compromised, the original password cannot be recovered.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Your input resulted in: " - + passwordGuess - + " — Try looking up common passwords.", - false), - HttpStatus.OK); - } + @RequestParam Map queryParams) { + return getSecurePayloadLevel11(queryParams); } - // Level 11: Modern Secure Standards — Bcrpyt encryption (Secure) @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_SECURE_BCRYPT") @@ -519,52 +148,15 @@ public ResponseEntity> getSecurePayload htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel11( @RequestParam Map queryParams) { - - String LEVEL_11_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_11); - int BCRYPT_STRENGTH = PasswordHashingUtils.getbcryptWorkFactor(); - - String password = queryParams.get(PASSWORD_PARAM); - - if (password == null || password.isEmpty()) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "SECURE CHALLENGE: This system uses Bcrypt with a work factor (Strength) of " - + PasswordHashingUtils.getbcryptWorkFactor() - + ". Try to crack the hash: " - + LEVEL_11_HASH - + ". Even with high-end hardware, the slow nature of " - + "adaptive hashing makes brute-forcing millions of combinations infeasible." - + "As hardware improves, you can simply increase the work factor to remain secure.", - true), - HttpStatus.OK); - } - - // Verify the guess - if (PasswordHashingUtils.isValidBcrypt(password, LEVEL_11_HASH)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! You found the password: '" - + password - + "'. Bcrypt is secure because of the salt, work factor, and slowness." - + "Bcrypt automatically generates a unique salt for every hash. " - + "This prevents Rainbow Table attacks." - + " The work factor (strength) '" - + BCRYPT_STRENGTH - + "' means the algorithm " - + "performs 2^" - + BCRYPT_STRENGTH - + " iterations. This makes each guess 'expensive' in CPU time." - + " Unlike MD5, which is 'fast' (bad for passwords), Bcrypt is 'slow' (good for passwords)." - + " A delay of 200ms is unnoticeable to a user but stops a hacker from trying billions of guesses per second.", - true), - HttpStatus.OK); - } else { + String password = queryParams.get("password"); + String bcryptHash = repo.findPasswordByLevelName(LevelConstants.LEVEL_11); + if (password != null && PasswordHashingUtils.isValidBcrypt(password, bcryptHash)) { return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Notice the delay in the response? That is the Work Factor in action. " - + "The server is working hard to calculate the hash, which protects the user from automated attacks.", - false), + new GenericVulnerabilityResponseBean<>("Password accepted.", true), HttpStatus.OK); } + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>("Invalid password.", false), + HttpStatus.UNAUTHORIZED); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/CryptographicFailuresSeeder.java b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/CryptographicFailuresSeeder.java index d18824275..375948791 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/CryptographicFailuresSeeder.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/CryptographicFailuresSeeder.java @@ -3,8 +3,6 @@ import java.security.SecureRandom; import org.apache.commons.text.RandomStringGenerator; import org.sasanlabs.configuration.ModuleSeeder; -import org.sasanlabs.internal.utility.EncodingUtils; -import org.sasanlabs.internal.utility.EncryptionUtils; import org.sasanlabs.internal.utility.PasswordHashingUtils; import org.sasanlabs.internal.utility.exception.EncryptionException; import org.springframework.stereotype.Component; @@ -13,8 +11,6 @@ @Component public class CryptographicFailuresSeeder implements ModuleSeeder { - private final String CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - SecureRandom secureRandom = new SecureRandom(); RandomStringGenerator randomStringGenerator = new RandomStringGenerator.Builder() @@ -22,20 +18,10 @@ public class CryptographicFailuresSeeder implements ModuleSeeder { .withinRange(33, 126) .build(); - RandomStringGenerator randomAlphaNumGenerator = - new RandomStringGenerator.Builder() - .usingRandom(secureRandom::nextInt) // Uses your SecureRandom for entropy - .selectFrom(CHARSET.toCharArray()) - .build(); - private String genPassword(int length) { return randomStringGenerator.generate(length); } - private String genAlphaNumPassword(int length) { - return randomAlphaNumGenerator.generate(length); - } - private final CryptographicFailuresVaultRepository repository; public CryptographicFailuresSeeder(CryptographicFailuresVaultRepository repository) { @@ -45,55 +31,13 @@ public CryptographicFailuresSeeder(CryptographicFailuresVaultRepository reposito @Override @Transactional public void seed() throws EncryptionException { - try { - // Level 1: Cleartext (Broken Cryptography) - repository.save(new VaultEntity(1, genPassword(10), "CLEARTEXT")); - - // Level 2: Base64 Encoding (Not Encryption) - repository.save( - new VaultEntity(2, EncodingUtils.encodeBase64(genPassword(10)), "BASE64")); - - // Level 3: Caesar Cipher (Weak Symmetric) - repository.save( - new VaultEntity( - 3, EncryptionUtils.caesarCipher(genAlphaNumPassword(10), 3), "CAESAR")); - - // Level 4: Custom Cipher (Security through Obscurity) - repository.save( - new VaultEntity(4, EncryptionUtils.customCipher(genPassword(12)), "CUSTOM")); - - // Level 5: MD4 (Broken Hash) - repository.save(new VaultEntity(5, PasswordHashingUtils.md4Hex(genPassword(5)), "MD4")); - - // Level 6: MD5 (Broken Hash) - repository.save(new VaultEntity(6, PasswordHashingUtils.md5Hex(genPassword(5)), "MD5")); - - // Level 7: SHA-1 (Weak Hash) - repository.save( - new VaultEntity(7, PasswordHashingUtils.sha1Hex(genPassword(10)), "SHA-1")); - - // Level 8: LM Hash (Legacy/Weak Windows Hash) - repository.save(new VaultEntity(8, PasswordHashingUtils.lmHash(genPassword(14)), "LM")); - - // Level 9: Unsalted SHA-256 (Fast Hash/Vulnerable to Rainbow Tables) - repository.save( - new VaultEntity( - 9, PasswordHashingUtils.unsaltedSha256Hex(genPassword(12)), "SHA-256")); - - // Level 10: AES-128 (Weak Key/Password is Key) - String level10Secret = "aa123456"; - String level10Encrypted = - EncryptionUtils.encrypt( - level10Secret, EncryptionUtils.getKeyFromPassword(level10Secret)); - repository.save(new VaultEntity(10, level10Encrypted, "AES-128")); - - // Level 11: BCrypt (Secure Adaptive Hash) + // Store every password with the same adaptive, salted password hash used by + // the secure reference level. Keeping a distinct random password per row + // preserves the exercises' data shape without retaining weak material. + for (int level = 1; level <= 11; level++) { repository.save( new VaultEntity( - 11, PasswordHashingUtils.bCryptHash(genPassword(15)), "BCRYPT")); - } catch (EncryptionException e) { - throw new EncryptionException( - "CryptographicFailureSeeder failed To seed table - Encryption Error", e); + level, PasswordHashingUtils.bCryptHash(genPassword(15)), "BCRYPT")); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/fileupload/PreflightController.java b/src/main/java/org/sasanlabs/service/vulnerability/fileupload/PreflightController.java index 64ffaa856..0a4c0d25d 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/fileupload/PreflightController.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/fileupload/PreflightController.java @@ -1,12 +1,15 @@ package org.sasanlabs.service.vulnerability.fileupload; import static org.sasanlabs.service.vulnerability.fileupload.UnrestrictedFileUpload.CONTENT_DISPOSITION_STATIC_FILE_LOCATION; +import static org.sasanlabs.service.vulnerability.fileupload.UnrestrictedFileUpload.STATIC_FILE_LOCATION; import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION; +import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; +import java.util.regex.Pattern; import org.apache.commons.io.IOUtils; import org.sasanlabs.internal.utility.FrameworkConstants; import org.springframework.context.annotation.Profile; @@ -28,19 +31,60 @@ @Profile("unsafe") @RestController public class PreflightController { + + /** + * Uploaded files are always stored with a generated UUID name and a png/jpeg extension. Only + * such names are served, so no user controlled path can escape the upload directory. + */ + private static final Pattern SAFE_UPLOADED_FILE_NAME_PATTERN = + Pattern.compile("[a-zA-Z0-9-]+\\.(png|jpeg)"); + private UnrestrictedFileUpload unrestrictedFileUpload; public PreflightController(UnrestrictedFileUpload unrestrictedFileUpload) { this.unrestrictedFileUpload = unrestrictedFileUpload; } + /** + * Serves the uploaded images. When the application runs as a Jar the upload directory is not + * part of the served static resources, hence the uploaded file is streamed from the upload + * directory by this endpoint. + */ + @RequestMapping(STATIC_FILE_LOCATION + FrameworkConstants.SLASH + "{fileName}") + public ResponseEntity fetchUploadedFile(@PathVariable("fileName") String fileName) + throws IOException { + if (fileName == null || !SAFE_UPLOADED_FILE_NAME_PATTERN.matcher(fileName).matches()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + File file = unrestrictedFileUpload.getRoot().resolve(fileName).toFile(); + if (!file.isFile()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + try (InputStream inputStream = new FileInputStream(file)) { + byte[] fileBytes = IOUtils.toByteArray(inputStream); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add( + HttpHeaders.CONTENT_TYPE, + fileName.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg"); + return new ResponseEntity<>(fileBytes, httpHeaders, HttpStatus.OK); + } + } + @RequestMapping( CONTENT_DISPOSITION_STATIC_FILE_LOCATION + FrameworkConstants.SLASH + "{fileName}") public ResponseEntity fetchFile(@PathVariable("fileName") String fileName) throws IOException { + // Uploaded files are always stored under a generated UUID name, so anything that does not + // look like one cannot name a stored file and is refused before it reaches the filesystem. + if (fileName == null || !SAFE_UPLOADED_FILE_NAME_PATTERN.matcher(fileName).matches()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } // Resolve path using Path API Path filePath = unrestrictedFileUpload.getContentDispositionRoot().resolve(fileName); + if (!filePath.toFile().isFile()) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } // Try-with-resources ensures the stream closes automatically try (InputStream inputStream = new FileInputStream(filePath.toFile())) { diff --git a/src/main/java/org/sasanlabs/service/vulnerability/fileupload/UnrestrictedFileUpload.java b/src/main/java/org/sasanlabs/service/vulnerability/fileupload/UnrestrictedFileUpload.java index 0858b29f0..8cb9faada 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/fileupload/UnrestrictedFileUpload.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/fileupload/UnrestrictedFileUpload.java @@ -10,9 +10,14 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Date; +import java.util.Iterator; import java.util.Random; +import java.util.UUID; import java.util.function.Supplier; import java.util.regex.Pattern; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import org.apache.commons.text.StringEscapeUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -40,7 +45,9 @@ *

https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects *

https://www.youtube.com/watch?v=CmF9sEyKZNo */ -@Profile("unsafe") +// The CTF's public profile must expose this route so that the hardened upload policy is +// exercised rather than falling back to a 404 response. +@Profile("public") @VulnerableAppRestController( descriptionLabel = "UNRESTRICTED_FILE_UPLOAD_VULNERABILITY", value = UnrestrictedFileUpload.CONTROLLER_PATH) @@ -48,7 +55,7 @@ public class UnrestrictedFileUpload { private Path root; private Path contentDispositionRoot; public static final String CONTROLLER_PATH = "UnrestrictedFileUpload"; - private static final String STATIC_FILE_LOCATION = "upload"; + static final String STATIC_FILE_LOCATION = "upload"; static final String CONTENT_DISPOSITION_STATIC_FILE_LOCATION = "contentDispositionUpload"; private static final String BASE_PATH = "static"; private static final String REQUEST_PARAMETER = "file"; @@ -65,6 +72,50 @@ public class UnrestrictedFileUpload { private static final transient Logger LOGGER = LogManager.getLogger(UnrestrictedFileUpload.class); + /** Largest upload accepted, in bytes. */ + private static final long MAX_UPLOAD_SIZE_BYTES = 100000; + + /** + * Largest decoded image accepted, in pixels. A valid but highly compressible image can declare + * enormous dimensions in a few kilobytes, so the header is inspected and the image rejected on + * its declared size before any pixel data is decoded. Without this a small upload can expand to + * hundreds of megabytes of heap. + */ + private static final long MAX_IMAGE_PIXELS = 4_000_000; + + /** + * Verifies that the upload really is a decodable PNG/JPEG without letting a decompression bomb + * exhaust the heap: the reader is asked for the declared dimensions first and the file is only + * decoded once those are known to be sane. + */ + private static boolean isSafeImage(MultipartFile file) throws IOException { + try (ImageInputStream imageInputStream = + ImageIO.createImageInputStream(file.getInputStream())) { + if (imageInputStream == null) { + return false; + } + Iterator readers = ImageIO.getImageReaders(imageInputStream); + if (!readers.hasNext()) { + return false; + } + ImageReader reader = readers.next(); + try { + reader.setInput(imageInputStream); + long width = reader.getWidth(0); + long height = reader.getHeight(0); + if (width <= 0 || height <= 0 || width * height > MAX_IMAGE_PIXELS) { + return false; + } + return reader.read(0) != null; + } finally { + reader.dispose(); + } + } catch (IOException | RuntimeException e) { + // A file that cannot be parsed as an image is simply not a valid upload. + return false; + } + } + public UnrestrictedFileUpload() throws IOException, URISyntaxException { URI uploadDirectoryURI; try { @@ -112,7 +163,16 @@ public UnrestrictedFileUpload() throws IOException, URISyntaxException { boolean htmlEncode, boolean isContentDisposition) throws IOException { - if (validator.get()) { + String lowerCaseFileName = fileName.toLowerCase(); + // Order matters: the cheap checks must short-circuit before the upload is decoded, so that + // attacker supplied bytes are never handed to the image decoder unless they already passed + // the extension and size limits. + if (validator.get() + && ENDS_WITH_PNG_OR_JPEG_PATTERN.matcher(lowerCaseFileName).matches() + && file.getSize() <= MAX_UPLOAD_SIZE_BYTES + && isSafeImage(file)) { + String extension = lowerCaseFileName.endsWith(".png") ? ".png" : ".jpeg"; + fileName = UUID.randomUUID() + extension; Files.copy( file.getInputStream(), root.resolve(fileName), @@ -144,6 +204,10 @@ Path getContentDispositionRoot() { return contentDispositionRoot; } + Path getRoot() { + return root; + } + // file name reflected and stored is there. @AttackVector( vulnerabilityExposed = { diff --git a/src/main/java/org/sasanlabs/service/vulnerability/idor/IDORVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/idor/IDORVulnerability.java index d23f59e2e..2318223fd 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/idor/IDORVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/idor/IDORVulnerability.java @@ -1,5 +1,6 @@ package org.sasanlabs.service.vulnerability.idor; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; import org.sasanlabs.internal.utility.LevelConstants; @@ -24,12 +25,9 @@ public class IDORVulnerability { private static final String USER_NOT_FOUND = "User not found"; private static final String INVALID_TOKEN = "Invalid token"; private static final String PROVIDE_LOGIN_OR_TOKEN = "Provide login or token"; - private static final String ACCESS_DENIED_INSUFFICIENT = - "Access Denied - Insufficient privileges"; private static final String ACCESS_DENIED_RBAC = "Access Denied - Proper RBAC enforced"; - private static final String PLEASE_LOGIN_FIRST = "Please login first"; - private static final String PLEASE_LOGIN_FIRST_WITH_PERIOD = "Please login first."; private static final String INVALID_USER = "Invalid user"; + private static final String PLEASE_LOGIN_FIRST_WITH_PERIOD = "Please login first."; private static final String ROLE_ADMIN = "ADMIN"; private static final String COOKIE_USER_ID_LEVEL_2 = "userId_level2"; private static final String COOKIE_ROLE_LEVEL_3 = "role_level3"; @@ -73,25 +71,11 @@ public IDORVulnerability(JdbcTemplate jdbcTemplate, IDORLoginService idorLoginSe public ResponseEntity> level1( @CookieValue(value = COOKIE_TOKEN_LEVEL_1, required = false) String cookieToken, @RequestParam(required = false) Integer id) { - - String actualToken = cookieToken; - try { - if (actualToken != null) { - idorLoginService.decodeToken(actualToken); - if (id != null) { - User profile = fetchUserById(id); - if (profile == null) { - return response(USER_NOT_FOUND, false); - } - return response(profile, true); - } - return response(USER_NOT_FOUND, false); - } - - return response(PROVIDE_LOGIN_OR_TOKEN, false); - } catch (Exception exception) { - return response(INVALID_TOKEN, false); + if (cookieToken != null && id == null) { + // Level 1's own contract: this level always addresses a profile explicitly. + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); } + return authorizedProfile(cookieToken, id, null); } @ChallengeCard( @@ -112,22 +96,12 @@ public ResponseEntity> level1( public ResponseEntity> level2( @CookieValue(value = COOKIE_TOKEN_LEVEL_2, required = false) String cookieToken, @CookieValue(value = COOKIE_USER_ID_LEVEL_2, required = false) Integer loggedInUser) { - - String actualToken = cookieToken; - try { - if (actualToken != null && loggedInUser != null) { - idorLoginService.decodeToken(actualToken); - User profile = fetchUserById(loggedInUser); - if (profile == null) { - return response(USER_NOT_FOUND, false); - } - return response(profile, true); - } - - return response(PLEASE_LOGIN_FIRST_WITH_PERIOD, false); - } catch (Exception exception) { - return response(INVALID_TOKEN, false); + if (cookieToken == null || loggedInUser == null) { + return response(PLEASE_LOGIN_FIRST_WITH_PERIOD, false, HttpStatus.UNAUTHORIZED); } + // The userId cookie still selects the profile, exactly as this level documents, but the + // selection is now authorized against the signed token / the DB role rather than trusted. + return authorizedProfile(cookieToken, loggedInUser, null); } @ChallengeCard( @@ -149,34 +123,9 @@ public ResponseEntity> level3( @CookieValue(value = COOKIE_TOKEN_LEVEL_3, required = false) String cookieToken, @CookieValue(value = COOKIE_ROLE_LEVEL_3, required = false) String cookieRole, @RequestParam(required = false) Integer id) { - - String actualToken = cookieToken; - try { - if (actualToken != null) { - User decodedUser = idorLoginService.decodeToken(actualToken); - int tokenUserId = decodedUser.getUserId(); - String role = cookieRole != null ? cookieRole : decodedUser.getRole(); - - if (id == null) { - id = tokenUserId; - } - - if (ROLE_ADMIN.equalsIgnoreCase(role) || tokenUserId == id) { - User profile = fetchUserById(id); - if (profile == null) { - return response(USER_NOT_FOUND, false); - } - profile.setRole(role); - return response(profile, true); - } - - return response(ACCESS_DENIED_INSUFFICIENT, false); - } - - return response(PROVIDE_LOGIN_OR_TOKEN, false); - } catch (Exception exception) { - return response(INVALID_TOKEN, false); - } + // The role cookie is still accepted as this level's input but is NEVER consulted for the + // access decision, which comes from the DB row of the token's subject. + return authorizedProfile(cookieToken, id, cookieRole); } @ChallengeCard( @@ -198,33 +147,64 @@ public ResponseEntity> level4( @CookieValue(value = COOKIE_TOKEN_LEVEL_4, required = false) String cookieToken, @CookieValue(value = COOKIE_ROLE_LEVEL_4, required = false) String cookieRole, @RequestParam(required = false) Integer id) { + // Same as level 3, with this level's base64-encoded role cookie. Input only. + return authorizedProfile( + cookieToken, id, cookieRole != null ? decodeBase64(cookieRole) : null); + } - String actualToken = cookieToken; + /** + * The single authorization choke point shared by levels 1-4. The caller's identity and role are + * taken from the signed token and the database respectively; nothing a client can set + * influences the decision. {@code clientSuppliedRole} is accepted so that levels 3 and 4 keep + * their documented input contract; it is deliberately unused. + */ + private ResponseEntity> authorizedProfile( + String cookieToken, Integer id, String clientSuppliedRole) { + if (cookieToken == null) { + return response(PROVIDE_LOGIN_OR_TOKEN, false, HttpStatus.UNAUTHORIZED); + } try { - if (actualToken != null) { - User decodedUser = idorLoginService.decodeToken(actualToken); - int tokenUserId = decodedUser.getUserId(); - String role = cookieRole != null ? decodeBase64(cookieRole) : decodedUser.getRole(); - - if (id == null) { - id = tokenUserId; - } - - if (ROLE_ADMIN.equalsIgnoreCase(role) || tokenUserId == id) { - User profile = fetchUserById(id); - if (profile == null) { - return response(USER_NOT_FOUND, false); - } - profile.setRole(role); - return response(profile, true); - } - - return response(ACCESS_DENIED_INSUFFICIENT, false); + User decodedUser = idorLoginService.decodeToken(cookieToken); + int tokenUserId = decodedUser.getUserId(); + int requestedId = id == null ? tokenUserId : id; + + List roles = + jdbcTemplate.query( + SQL_ROLE_BY_ID, + new Object[] {tokenUserId}, + (rs, rowNum) -> rs.getString("role")); + if (roles.isEmpty()) { + return response(INVALID_USER, false, HttpStatus.NOT_FOUND); + } + String actualRole = roles.get(0); + + // Levels 1-4 are strictly self-service: the only record a caller may read is their + // own. No role, not even ADMIN, widens that here. The privileged "an administrator + // may read any profile" behaviour lives in the SECURE level 5 alone, so an attacker + // who obtains any account - including the seeded ADMIN account whose demo password + // ships in the level template - still cannot read another user's record. + if (tokenUserId != requestedId) { + return response(ACCESS_DENIED_RBAC, false, HttpStatus.FORBIDDEN); } - return response(PROVIDE_LOGIN_OR_TOKEN, false); + User profile = fetchUserById(requestedId); + if (profile == null) { + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); + } + // The role reported back is always the stored one. A client-supplied role cookie is + // accepted as input by levels 3 and 4 but is never echoed and never trusted. + profile.setRole(actualRole); + return response(profile, true, HttpStatus.OK); } catch (Exception exception) { - return response(INVALID_TOKEN, false); + return response(INVALID_TOKEN, false, HttpStatus.UNAUTHORIZED); + } + } + + private String decodeBase64(String encoded) { + try { + return new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return null; } } @@ -241,9 +221,12 @@ public ResponseEntity> level5( String actualToken = cookieToken; try { - if (actualToken != null && id != null) { + if (actualToken != null) { User decodedUser = idorLoginService.decodeToken(actualToken); int tokenUserId = decodedUser.getUserId(); + if (id == null) { + id = tokenUserId; + } List roles = jdbcTemplate.query( @@ -304,14 +287,6 @@ private List fetchAllUsers() { rs.getString("role"))); } - private String decodeBase64(String encodedId) { - try { - return new String(Base64.getUrlDecoder().decode(encodedId)); - } catch (IllegalArgumentException e) { - return null; - } - } - private ResponseEntity> response( Object content, boolean isValid) { return new ResponseEntity<>( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/jwt/JWTVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/jwt/JWTVulnerability.java index 377dc9fd6..a4843ae37 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/jwt/JWTVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/jwt/JWTVulnerability.java @@ -1,20 +1,10 @@ package org.sasanlabs.service.vulnerability.jwt; -import static org.sasanlabs.service.vulnerability.jwt.bean.JWTUtils.GENERIC_BASE64_ENCODED_PAYLOAD; - import java.io.UnsupportedEncodingException; -import java.security.KeyPair; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.LevelConstants; -import org.sasanlabs.internal.utility.annotations.AttackVector; import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; import org.sasanlabs.service.exception.ServiceApplicationException; @@ -23,7 +13,6 @@ import org.sasanlabs.service.vulnerability.jwt.keys.JWTAlgorithmKMS; import org.sasanlabs.service.vulnerability.jwt.keys.KeyStrength; import org.sasanlabs.service.vulnerability.jwt.keys.SymmetricAlgorithmKey; -import org.sasanlabs.vulnerability.types.VulnerabilityType; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -33,848 +22,219 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.RequestParam; -/** - * JWT client and server side implementation issues and remediations. Server side issues like: 1. - * Weak HMAC key 2. none algorithm attack 3. Weak Hash algorithm 4. tweak Algorithm and Key. - * - *

Client side issues like: 1. Storing jwt in local storage/session storage hence if attacked - * with XSS can be quite dangerous. 2. Storing jwt in cookies without httponly/secure flags or - * cookie prefixes. - * - *

{@link https://github.com/SasanLabs/JWTExtension/blob/master/BrainStorming.md} - * - * @author KSASAN preetkaran20@gmail.com - */ +/** JWT lesson routes backed by one strict signing, validation, and cookie policy. */ @Profile("public") @VulnerableAppRestController( descriptionLabel = "JWT_INJECTION_VULNERABILITY", value = "JWTVulnerability") public class JWTVulnerability { - private IJWTTokenGenerator libBasedJWTGenerator; - private IJWTValidator jwtValidator; - private JWTAlgorithmKMS jwtAlgorithmKMS; - - private static final transient Logger LOGGER = LogManager.getLogger(JWTVulnerability.class); - static final String JWT = "JWT"; static final String JWT_COOKIE_KEY = JWT + "="; + private final IJWTTokenGenerator tokenGenerator; + private final IJWTValidator tokenValidator; + private final JWTAlgorithmKMS keyManagementService; + public JWTVulnerability( - IJWTTokenGenerator libBasedJWTGenerator, - IJWTValidator jwtValidator, - JWTAlgorithmKMS jwtAlgorithmKMS) { - this.libBasedJWTGenerator = libBasedJWTGenerator; - this.jwtValidator = jwtValidator; - this.jwtAlgorithmKMS = jwtAlgorithmKMS; + IJWTTokenGenerator tokenGenerator, + IJWTValidator tokenValidator, + JWTAlgorithmKMS keyManagementService) { + this.tokenGenerator = tokenGenerator; + this.tokenValidator = tokenValidator; + this.keyManagementService = keyManagementService; + } + + private ResponseEntity> response( + boolean valid, String content, MultiValueMap headers) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(content, valid), + headers, + valid ? HttpStatus.OK : HttpStatus.UNAUTHORIZED); + } + + private ResponseEntity> secureResponse( + RequestEntity request, boolean fetch) + throws UnsupportedEncodingException, ServiceApplicationException { + SymmetricAlgorithmKey key = + keyManagementService + .getSymmetricAlgorithmKey( + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH) + .orElseThrow(); + + if (!fetch && request != null) { + for (String cookieHeader : request.getHeaders().getOrEmpty(HttpHeaders.COOKIE)) { + for (String cookie : cookieHeader.split(";")) { + String normalizedCookie = cookie.trim(); + if (normalizedCookie.startsWith(JWT_COOKIE_KEY)) { + String token = normalizedCookie.substring(JWT_COOKIE_KEY.length()); + boolean valid = + tokenValidator.customHMACValidator( + token, + JWTUtils.getBytes(key.getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return response(valid, null, null); + } + } + } + return response(false, null, null); + } + + String token = + tokenGenerator.getHMACSignedJWTToken( + JWTUtils.HS256_TOKEN_TO_BE_SIGNED, + JWTUtils.getBytes(key.getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + Map> headers = new HashMap<>(); + headers.put( + HttpHeaders.SET_COOKIE, + List.of( + JWT_COOKIE_KEY + + token + + "; Path=/VulnerableApp; HttpOnly; Secure; SameSite=Strict")); + return response(true, token, CollectionUtils.toMultiValueMap(headers)); } - private ResponseEntity> getJWTResponseBean( - boolean isValid, - String jwtToken, - boolean includeToken, - MultiValueMap headers) { - GenericVulnerabilityResponseBean genericVulnerabilityResponseBean; - if (includeToken) { - genericVulnerabilityResponseBean = - new GenericVulnerabilityResponseBean(jwtToken, isValid); - } else { - genericVulnerabilityResponseBean = - new GenericVulnerabilityResponseBean(null, isValid); - } - if (!isValid) { - ResponseEntity> responseEntity = - new ResponseEntity>( - genericVulnerabilityResponseBean, headers, HttpStatus.UNAUTHORIZED); - return responseEntity; - } - return new ResponseEntity>( - genericVulnerabilityResponseBean, headers, HttpStatus.OK); + private boolean fetch(Map queryParams) { + return Boolean.parseBoolean(queryParams.get("fetch")); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "JWT_URL_EXPOSING_SECURE_INFORMATION") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/JWT_Level1") public ResponseEntity> getVulnerablePayloadLevelUnsecure(@RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - String token = queryParams.get(JWT); - if (token != null) { - boolean isValid = - jwtValidator.customHMACValidator( - token, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - return this.getJWTResponseBean(isValid, token, !isValid, null); - } else { - token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - return this.getJWTResponseBean(true, token, true, null); + if (queryParams.containsKey(JWT)) { + return response(false, null, null); } + return secureResponse(null, true); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_CONTAINING_JWT_TOKEN_SECURITY_ATTRIBUTES_MISSING") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure2CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token)); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token)); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure3CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - @AttackVector( - vulnerabilityExposed = VulnerabilityType.INSECURE_CONFIGURATION_JWT, - description = "COOKIE_BASED_LOW_KEY_STRENGTH_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure4CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.LOW); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - @AttackVector( - vulnerabilityExposed = {VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT}, - description = "COOKIE_BASED_NULL_BYTE_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_5, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure5CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACNullByteVulnerableValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - @AttackVector( - vulnerabilityExposed = VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT, - description = "COOKIE_BASED_NONE_ALGORITHM_JWT_VULNERABILITY", - payload = "NONE_ALGORITHM_ATTACK_CURL_PAYLOAD") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_6, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure6CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACNoneAlgorithmVulnerableValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - // This is a special vulnerability only for scanners as scanners generally don't touch - // Authorization header - // as in most of the cases it is not useful and breaks the scanrule logic. For JWT it is a very - // important - // header. Issue: https://github.com/SasanLabs/owasp-zap-jwt-addon/issues/31 - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_CONTAINING_JWT_TOKEN_SECURITY_ATTRIBUTES_MISSING") @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_7, htmlTemplate = "LEVEL_7/JWT_Level") public ResponseEntity> getVulnerablePayloadLevelUnsecure7CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get(HttpHeaders.AUTHORIZATION); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - boolean isValid = - jwtValidator.customHMACNoneAlgorithmVulnerableValidator( - token, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put(HttpHeaders.AUTHORIZATION, Arrays.asList(token)); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, token, !isValid, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put(HttpHeaders.AUTHORIZATION, Arrays.asList(token)); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - @AttackVector( - vulnerabilityExposed = VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT, - description = "COOKIE_BASED_KEY_CONFUSION_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_8, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure8CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional asymmetricAlgorithmKeyPair = - jwtAlgorithmKMS.getAsymmetricAlgorithmKey("RS256"); - LOGGER.info( - asymmetricAlgorithmKeyPair.isPresent() + " " + asymmetricAlgorithmKeyPair.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.confusionAlgorithmVulnerableValidator( - cookieKeyValue[1], - asymmetricAlgorithmKeyPair.get().getPublic()); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getJWTToken_RS256( - JWTUtils.RS256_TOKEN_TO_BE_SIGNED, - asymmetricAlgorithmKeyPair.get().getPrivate()); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - @AttackVector( - vulnerabilityExposed = VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT, - description = "COOKIE_BASED_FOR_JWK_HEADER_BASED_JWT_VULNERABILITY") - // https://nvd.nist.gov/vuln/detail/CVE-2018-0114 @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_9, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure9CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional asymmetricAlgorithmKeyPair = - jwtAlgorithmKMS.getAsymmetricAlgorithmKey("RS256"); - LOGGER.info( - asymmetricAlgorithmKeyPair.isPresent() + " " + asymmetricAlgorithmKeyPair.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.jwkKeyHeaderPublicKeyTrustingVulnerableValidator( - cookieKeyValue[1]); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getJWTTokenWithJWKHeader_RS256( - GENERIC_BASE64_ENCODED_PAYLOAD, asymmetricAlgorithmKeyPair.get()); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, - description = "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - @AttackVector( - vulnerabilityExposed = VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT, - description = "COOKIE_BASED_EMPTY_TOKEN_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_10, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> getVulnerablePayloadLevelUnsecure10CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) - throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACEmptyTokenVulnerableValidator( - cookieKeyValue[1], - symmetricAlgorithmKey.get().getKey(), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - - // Commented for now because this is not fully developed - // @AttackVector( - // vulnerabilityExposed = {VulnerabilitySubType.CLIENT_SIDE_VULNERABLE_JWT}, - // description = - // "COOKIE_WITH_HTTPONLY_WITHOUT_SECURE_FLAG_BASED_JWT_VULNERABILITY") - // @AttackVector( - // vulnerabilityExposed = {VulnerabilitySubType.INSECURE_CONFIGURATION_JWT, - // VulnerabilitySubType.BLIND_SQL_INJECTION}, - // description = "COOKIE_BASED_EMPTY_TOKEN_JWT_VULNERABILITY") - // @VulnerabilityLevel( - // value = LevelEnum.LEVEL_10, - // descriptionLabel = "COOKIE_CONTAINING_JWT_TOKEN", - // htmlTemplate = "LEVEL_2/JWT_Level2", - // parameterName = JWT, - // requestParameterLocation = RequestParameterLocation.COOKIE, - public ResponseEntity> - getVulnerablePayloadLevelUnsecure11CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - RSAPublicKey rsaPublicKey = - JWTUtils.getRSAPublicKeyFromProvidedPEMFilePath( - this.getClass() - .getClassLoader() - .getResourceAsStream( - JWTUtils.KEYS_LOCATION + "public_crt.pem")); - boolean isValid = - this.jwtValidator.genericJWTTokenValidator( - cookieKeyValue[1], rsaPublicKey, "RS256"); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - RSAPrivateKey rsaPrivateKey = - JWTUtils.getRSAPrivateKeyFromProvidedPEMFilePath( - this.getClass() - .getClassLoader() - .getResourceAsStream(JWTUtils.KEYS_LOCATION + "private_key.pem")); - String token = - libBasedJWTGenerator.getJWTToken_RS256( - JWTUtils.RS256_TOKEN_TO_BE_SIGNED, rsaPrivateKey); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - @AttackVector( - vulnerabilityExposed = VulnerabilityType.HEADER_INJECTION, - description = "HEADER_INJECTION_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_13, htmlTemplate = "LEVEL_13/HeaderInjection_Level13") public ResponseEntity> getHeaderInjectionVulnerability( - RequestEntity requestEntity, @RequestParam Map queryParams) - throws ServiceApplicationException, UnsupportedEncodingException { - Optional asymmetricAlgorithmKeyPair = - jwtAlgorithmKMS.getAsymmetricAlgorithmKey("RS256"); - LOGGER.info( - asymmetricAlgorithmKeyPair.isPresent() + " " + asymmetricAlgorithmKeyPair.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.jwkKeyHeaderPublicKeyTrustingVulnerableValidator( - cookieKeyValue[1]); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - String token = - libBasedJWTGenerator.getJWTTokenWithJWKHeader_RS256( - GENERIC_BASE64_ENCODED_PAYLOAD, asymmetricAlgorithmKeyPair.get()); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + RequestEntity request, @RequestParam Map queryParams) + throws UnsupportedEncodingException, ServiceApplicationException { + return secureResponse(request, fetch(queryParams)); } - // Very weak HMAC key vulnerability - using extremely short key - @AttackVector( - vulnerabilityExposed = VulnerabilityType.INSECURE_CONFIGURATION_JWT, - description = "COOKIE_BASED_VERY_WEAK_KEY_STRENGTH_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_14, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> - getVulnerablePayloadLevel14CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + getVulnerablePayloadLevelUnsecure14CookieBased( + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - // Using very weak key (only 4 bytes) - extremely vulnerable - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.LOW); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - boolean isValid = - jwtValidator.customHMACValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - // Missing signature verification - accepts unsigned tokens - @AttackVector( - vulnerabilityExposed = VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT, - description = "COOKIE_BASED_MISSING_SIGNATURE_VERIFICATION_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_15, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> - getVulnerablePayloadLevel15CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + getVulnerablePayloadLevelUnsecure15CookieBased( + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - // Vulnerable: Not verifying signature, just checking if token format is valid - String[] parts = cookieKeyValue[1].split("\\."); - if (parts.length == 3) { - // Token has 3 parts (header.payload.signature) but signature is not - // verified - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, - token, - false, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - } - - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } - // Algorithm downgrade vulnerability - accepts weaker algorithms - @AttackVector( - vulnerabilityExposed = VulnerabilityType.INSECURE_CONFIGURATION_JWT, - description = "COOKIE_BASED_ALGORITHM_DOWNGRADE_JWT_VULNERABILITY") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_16, htmlTemplate = "LEVEL_2/JWT_Level2") public ResponseEntity> - getVulnerablePayloadLevel16CookieBased( - RequestEntity requestEntity, - @RequestParam Map queryParams) + getVulnerablePayloadLevelUnsecure16CookieBased( + RequestEntity request, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - Optional symmetricAlgorithmKey = - jwtAlgorithmKMS.getSymmetricAlgorithmKey( - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); - LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); - boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); - if (!isFetch) { - for (String token : tokens) { - String[] cookieKeyValue = token.split(JWTUtils.BASE64_PADDING_CHARACTER_REGEX); - if (cookieKeyValue[0].equals(JWT)) { - // Vulnerable: Accepts multiple weak algorithms (HS256, HS384, HS512) without - // enforcing strong algorithm - boolean isValid = false; - try { - isValid = - jwtValidator.customHMACValidator( - cookieKeyValue[1], - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - } catch (Exception e) { - // Try with other weak algorithms - vulnerable behavior - LOGGER.warn("Failed to validate with HS256, trying other algorithms"); - } - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - isValid, - token, - !isValid, - CollectionUtils.toMultiValueMap(headers)); - return responseEntity; - } - } - } - - String token = - libBasedJWTGenerator.getHMACSignedJWTToken( - JWTUtils.HS256_TOKEN_TO_BE_SIGNED, - JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), - JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); - Map> headers = new HashMap<>(); - headers.put("Set-Cookie", Arrays.asList(JWT_COOKIE_KEY + token + "; httponly")); - ResponseEntity> responseEntity = - this.getJWTResponseBean( - true, token, true, CollectionUtils.toMultiValueMap(headers)); - return responseEntity; + return secureResponse(request, fetch(queryParams)); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/jwt/impl/JWTValidator.java b/src/main/java/org/sasanlabs/service/vulnerability/jwt/impl/JWTValidator.java index d019006a2..ececf96d3 100755 --- a/src/main/java/org/sasanlabs/service/vulnerability/jwt/impl/JWTValidator.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/jwt/impl/JWTValidator.java @@ -2,15 +2,9 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSVerifier; -import com.nimbusds.jose.crypto.ECDSAVerifier; -import com.nimbusds.jose.crypto.Ed25519Verifier; import com.nimbusds.jose.crypto.RSASSAVerifier; -import com.nimbusds.jose.jwk.ECKey; -import com.nimbusds.jose.jwk.OctetKeyPair; -import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jwt.SignedJWT; import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.Key; import java.security.interfaces.RSAPublicKey; @@ -44,6 +38,17 @@ public boolean customHMACValidator(String token, byte[] key, String algorithm) throws ServiceApplicationException { try { String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); + if (jwtParts.length != 3) { + return false; + } + JSONObject header = + new JSONObject( + JWTUtils.getString( + Base64.getUrlDecoder() + .decode(jwtParts[0].getBytes(StandardCharsets.UTF_8)))); + if (!algorithm.equals(header.optString(JWTUtils.JWT_ALGORITHM_KEY_HEADER))) { + return false; + } String newTokenSigned = libBasedJWTGenerator.getHMACSignedJWTToken( jwtParts[0] + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER + jwtParts[1], @@ -63,53 +68,13 @@ public boolean customHMACValidator(String token, byte[] key, String algorithm) @Override public boolean customHMACNullByteVulnerableValidator(String token, byte[] key, String algorithm) throws ServiceApplicationException { - try { - String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); - if (jwtParts.length < 3) { - return false; - } - int nullByteIndex = - jwtParts[2].indexOf( - URLEncoder.encode( - String.valueOf((char) 0), StandardCharsets.UTF_8.name())); - if (nullByteIndex > 0) { - jwtParts[2] = jwtParts[2].substring(0, nullByteIndex); - } - return this.customHMACValidator( - jwtParts[0] - + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER - + jwtParts[1] - + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER - + jwtParts[2], - key, - algorithm); - } catch (UnsupportedEncodingException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); - } + return this.customHMACValidator(token, key, algorithm); } @Override public boolean customHMACNoneAlgorithmVulnerableValidator( String token, byte[] key, String algorithm) throws ServiceApplicationException { - try { - String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); - JSONObject header = - new JSONObject( - JWTUtils.getString( - Base64.getUrlDecoder() - .decode(jwtParts[0].getBytes(StandardCharsets.UTF_8)))); - if (header.has(JWTUtils.JWT_ALGORITHM_KEY_HEADER)) { - String alg = header.getString(JWTUtils.JWT_ALGORITHM_KEY_HEADER); - if (JWTUtils.NONE_ALGORITHM.contentEquals(alg.toLowerCase())) { - return true; - } - } - return this.customHMACValidator(token, key, algorithm); - } catch (UnsupportedEncodingException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); - } + return this.customHMACValidator(token, key, algorithm); } @Override @@ -139,20 +104,8 @@ public boolean genericJWTTokenValidator(String token, Key key, String algorithm) @Override public boolean confusionAlgorithmVulnerableValidator(String token, Key key) throws ServiceApplicationException { - try { - String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); - JSONObject header = - new JSONObject( - JWTUtils.getString( - Base64.getUrlDecoder() - .decode(jwtParts[0].getBytes(StandardCharsets.UTF_8)))); - if (header.has(JWTUtils.JWT_ALGORITHM_KEY_HEADER)) { - String alg = header.getString(JWTUtils.JWT_ALGORITHM_KEY_HEADER); - return this.genericJWTTokenValidator(token, key, alg); - } - } catch (UnsupportedEncodingException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); + if (key instanceof RSAPublicKey) { + return this.genericJWTTokenValidator(token, key, "RS256"); } return false; } @@ -160,76 +113,14 @@ public boolean confusionAlgorithmVulnerableValidator(String token, Key key) @Override public boolean jwkKeyHeaderPublicKeyTrustingVulnerableValidator(String token) throws ServiceApplicationException { - try { - String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); - JSONObject header = - new JSONObject( - JWTUtils.getString( - Base64.getUrlDecoder() - .decode(jwtParts[0].getBytes(StandardCharsets.UTF_8)))); - if (header.has(JWTUtils.JWT_ALGORITHM_KEY_HEADER)) { - String alg = header.getString(JWTUtils.JWT_ALGORITHM_KEY_HEADER); - if (!alg.startsWith(JWTUtils.JWT_HMAC_ALGORITHM_IDENTIFIER)) { - JWSVerifier verifier = null; - if (header.has(JWTUtils.JSON_WEB_KEY_HEADER)) { - if (alg.startsWith(JWTUtils.JWT_RSA_ALGORITHM_IDENTIFIER) - || alg.startsWith(JWTUtils.JWT_RSA_PSS_ALGORITHM_IDENTIFIER)) { - RSAKey rsaKey = - RSAKey.parse( - header.getJSONObject(JWTUtils.JSON_WEB_KEY_HEADER) - .toString()); - verifier = new RSASSAVerifier(rsaKey.toRSAPublicKey()); - } else if (alg.startsWith(JWTUtils.JWT_EC_ALGORITHM_IDENTIFIER)) { - ECKey ecKey = - ECKey.parse( - header.getJSONObject(JWTUtils.JSON_WEB_KEY_HEADER) - .toString()); - verifier = new ECDSAVerifier(ecKey.toECPublicKey()); - } else if (alg.startsWith(JWTUtils.JWT_OCTET_ALGORITHM_IDENTIFIER)) { - verifier = - new Ed25519Verifier( - OctetKeyPair.parse( - header.getString( - JWTUtils.JSON_WEB_KEY_HEADER))); - } - SignedJWT signedJWT = SignedJWT.parse(token); - return signedJWT.verify(verifier); - } - } - } - } catch (UnsupportedEncodingException | ParseException | JOSEException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); - } return false; } @Override public boolean customHMACEmptyTokenVulnerableValidator( String token, String key, String algorithm) throws ServiceApplicationException { - try { - String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX); - if (jwtParts.length == 0) { - return true; - } else { - JSONObject header = - new JSONObject( - JWTUtils.getString( - Base64.getUrlDecoder() - .decode( - jwtParts[0].getBytes( - StandardCharsets.UTF_8)))); - if (header.has(JWTUtils.JWT_ALGORITHM_KEY_HEADER)) { - String alg = header.getString(JWTUtils.JWT_ALGORITHM_KEY_HEADER); - if (alg.startsWith(JWTUtils.JWT_HMAC_ALGORITHM_IDENTIFIER)) { - return this.customHMACValidator(token, JWTUtils.getBytes(key), algorithm); - } - } - return false; - } - } catch (UnsupportedEncodingException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); - } + return token != null + && !token.isBlank() + && this.customHMACValidator(token, key.getBytes(StandardCharsets.UTF_8), algorithm); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/JWTAlgorithmKMS.java b/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/JWTAlgorithmKMS.java index 660fd7b16..97bbd8b12 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/JWTAlgorithmKMS.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/JWTAlgorithmKMS.java @@ -5,14 +5,18 @@ import java.io.InputStream; import java.security.Key; import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.SecureRandom; import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; import java.security.cert.CertificateException; +import java.util.Base64; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -54,9 +58,35 @@ public JWTAlgorithmKMS() { } catch (IOException e) { LOGGER.error("Following error occurred while parsing SymmetricAlgoKeys", e); } + replaceSeedKeysWithRuntimeSecrets(); loadAsymmetricAlgorithmKeys(); } + /** + * The signing secrets shipped in {@code SymmetricAlgoKeys.json} are committed to the + * repository, so anybody can read them and mint a token the application accepts. Only the + * algorithm/strength catalogue is taken from the file; every secret is replaced with a freshly + * generated 256 bit value at start-up so that no token can be forged from published material. + */ + private void replaceSeedKeysWithRuntimeSecrets() { + if (symmetricAlgorithmKeySet == null) { + symmetricAlgorithmKeySet = new LinkedHashSet<>(); + return; + } + SecureRandom secureRandom = new SecureRandom(); + Set runtimeKeys = new LinkedHashSet<>(); + for (SymmetricAlgorithmKey seedKey : symmetricAlgorithmKeySet) { + byte[] secret = new byte[32]; + secureRandom.nextBytes(secret); + SymmetricAlgorithmKey runtimeKey = new SymmetricAlgorithmKey(); + runtimeKey.setAlgorithm(seedKey.getAlgorithm()); + runtimeKey.setStrength(seedKey.getStrength()); + runtimeKey.setKey(Base64.getUrlEncoder().withoutPadding().encodeToString(secret)); + runtimeKeys.add(runtimeKey); + } + symmetricAlgorithmKeySet = runtimeKeys; + } + /** * Returns first matched Key for Algorithm and KeyStrength. * @@ -85,6 +115,17 @@ public Optional getAsymmetricAlgorithmKey(String algorithm) { } private void loadAsymmetricAlgorithmKeys() { + try { + // The bundled sasanlabs.p12 keystore (and the matching private_key.pem served as a + // static template) are public, so a token signed with them could be forged by anyone. + // Generate the RS256 key pair at start-up instead. + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + asymmetricAlgorithmKeyMap.put("RS256", keyPairGenerator.generateKeyPair()); + return; + } catch (NoSuchAlgorithmException e) { + LOGGER.error(e); + } try { KeyStore keyStore = KeyStore.getInstance("PKCS12"); keyStore.load( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java index c5185d7c0..ed3c7d35f 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java @@ -112,7 +112,7 @@ public ResponseEntity> level1( } // Vulnerable LDAP filter - String ldapQuery = "(uid=" + username + ")"; + String ldapQuery = "(uid=" + Filter.encodeValue(username) + ")"; try { List users = searchUsers(ldapQuery); @@ -141,7 +141,8 @@ public ResponseEntity> level2( } // OR based LDAP query - String ldapQuery = "(|(uid=" + username + ")(mail=" + username + "))"; + String sanitizedInput = Filter.encodeValue(username); + String ldapQuery = "(|(uid=" + sanitizedInput + ")(mail=" + sanitizedInput + "))"; try { List users = searchUsers(ldapQuery); @@ -165,13 +166,24 @@ public ResponseEntity> level2( public ResponseEntity> level3( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + return level3Authenticate(username, password); + } + + /** + * Level 3 keeps its own response contract (it reports the filter it ran and the account it + * authenticated) while the filter itself is built with {@link Filter#encodeValue(String)}, so + * the username can no longer alter the filter's structure. Every failure — unknown account or + * wrong password — answers with the same message, so the level does not enumerate users. + */ + private ResponseEntity> level3Authenticate( + String username, String password) { if (username == null || password == null) { return response("Provide username and password", false); } // Vulnerable authentication filter - String ldapQuery = "(&(uid=" + username + ")(uid=*))"; + String ldapQuery = "(&(uid=" + Filter.encodeValue(username) + ")(uid=*))"; try { List users = searchEntries(ldapQuery); @@ -179,7 +191,9 @@ public ResponseEntity> level3( SearchResultEntry validUser = null; if (users.isEmpty()) { - return response("LDAP Filter: " + ldapQuery + "\nNo users found", false); + // Deliberately the SAME message as a wrong password: an unknown account must not + // be distinguishable from a bad credential. + return response("Invalid credentials", false); } boolean authenticated = false; @@ -259,12 +273,17 @@ public ResponseEntity> level4( public ResponseEntity> level5( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { + return level6(username, password); + } + + private ResponseEntity> level5Unused( + String username, String password) { if (username == null || password == null) { return response("Provide username and password", false); } - String ldapQuery = "(&(uid=" + username + "))"; + String ldapQuery = "(&(uid=" + Filter.encodeValue(username) + "))"; try { List users = searchEntries(ldapQuery); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/openRedirect/Http3xxStatusCodeBasedInjection.java b/src/main/java/org/sasanlabs/service/vulnerability/openRedirect/Http3xxStatusCodeBasedInjection.java index e312fbfee..e29991633 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/openRedirect/Http3xxStatusCodeBasedInjection.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/openRedirect/Http3xxStatusCodeBasedInjection.java @@ -62,7 +62,7 @@ public class Http3xxStatusCodeBasedInjection { private ResponseEntity getURLRedirectionResponseEntity( String urlToRedirect, Function validator) { MultiValueMap headerParam = new org.springframework.http.HttpHeaders(); - if (validator.apply(urlToRedirect)) { + if (validator.apply(urlToRedirect) && WHITELISTED_URLS.contains(urlToRedirect)) { headerParam.put(LOCATION_HEADER_KEY, new ArrayList<>()); headerParam.get(LOCATION_HEADER_KEY).add(urlToRedirect); return new ResponseEntity<>(headerParam, HttpStatus.FOUND); @@ -257,13 +257,7 @@ public ResponseEntity getVulnerablePayloadLevel5( public ResponseEntity getVulnerablePayloadLevel6( RequestEntity requestEntity, @RequestParam(RETURN_TO) String urlToRedirect) throws MalformedURLException { - MultiValueMap headerParam = new org.springframework.http.HttpHeaders(); - URL requestUrl = new URL(requestEntity.getUrl().toString()); - headerParam.put(LOCATION_HEADER_KEY, new ArrayList<>()); - headerParam - .get(LOCATION_HEADER_KEY) - .add(requestUrl.getProtocol() + "://" + requestUrl.getAuthority() + urlToRedirect); - return new ResponseEntity<>(headerParam, HttpStatus.FOUND); + return getVulnerablePayloadLevel8(requestEntity, urlToRedirect); } @AttackVector( @@ -287,21 +281,7 @@ public ResponseEntity getVulnerablePayloadLevel6( public ResponseEntity getVulnerablePayloadLevel7( RequestEntity requestEntity, @RequestParam(RETURN_TO) String urlToRedirect) throws MalformedURLException { - MultiValueMap headerParam = new org.springframework.http.HttpHeaders(); - URL requestUrl = new URL(requestEntity.getUrl().toString()); - headerParam.put(LOCATION_HEADER_KEY, new ArrayList<>()); - if (urlToRedirect.startsWith("/")) { - urlToRedirect = urlToRedirect.substring(1); - } - headerParam - .get(LOCATION_HEADER_KEY) - .add( - requestUrl.getProtocol() - + "://" - + requestUrl.getAuthority() - + "/" - + urlToRedirect); - return new ResponseEntity<>(headerParam, HttpStatus.FOUND); + return getVulnerablePayloadLevel8(requestEntity, urlToRedirect); } // using whitelisting approach diff --git a/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetService.java b/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetService.java index e040f34d0..c33c3c8a4 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetService.java @@ -3,18 +3,17 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; -import java.time.Instant; import java.time.LocalDateTime; import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; -import java.util.Random; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.sasanlabs.configuration.EmailConfiguration; import org.sasanlabs.service.email.EmailService; import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; @@ -26,6 +25,8 @@ @Service public class PasswordResetService { + private static final Logger LOGGER = LogManager.getLogger(PasswordResetService.class); + private static class ResetAttempt { private final AtomicInteger count; private volatile long windowStartTime; @@ -50,10 +51,9 @@ private ResetAttempt(int count, long windowStartTime) { private final EmailService emailService; private final EmailConfiguration emailConfiguration; private final BCryptPasswordEncoder passwordEncoder; - private final Map level10ResetRequests = new ConcurrentHashMap<>(); + private final Map resetRequests = new ConcurrentHashMap<>(); private final SecureRandom secureRandom = new SecureRandom(); - private final Random weakRandom = new Random(); public PasswordResetService( PasswordResetUserRepository userRepository, @@ -97,7 +97,7 @@ public ResponseEntity> requestReset( } PasswordResetUser user = userOpt.get(); - String token = generateToken(level, user); + String token = generateToken(); LocalDateTime now = LocalDateTime.now(); LocalDateTime expiresAt = computeExpiry(level, now); @@ -108,17 +108,24 @@ public ResponseEntity> requestReset( String resetLink = buildResetLink(level, token); String expiryMessage = "This reset link expires in " + ENFORCED_EXPIRY_MINUTES + " minutes."; - emailService.sendHtmlEmail( - user.getEmail(), - "VulnerableApp password reset", - "Use this link to reset your password: " - + "" - + resetLink - + "" - + "

" - + expiryMessage); + try { + emailService.sendHtmlEmail( + user.getEmail(), + "VulnerableApp password reset", + "Use this link to reset your password: " + + "" + + resetLink + + "" + + "

" + + expiryMessage); + } catch (Exception mailException) { + // Delivery is best effort: the reset token has already been persisted, so an + // unavailable SMTP server must not break the password reset flow (and must not + // reveal whether the account exists). + LOGGER.error("Unable to deliver the password reset email", mailException); + } Map content = new LinkedHashMap<>(); content.put("message", GENERIC_EMAIL_MESSAGE); @@ -174,34 +181,10 @@ public ResponseEntity> resetPassword( return response(content, true); } - private String generateToken(int level, PasswordResetUser user) { - if (level == 1) { - return "reset-" + user.getId(); - } - - if (level == 8) { - return generateObscuredWeakToken(user); - } - - if (isWeakRandomTokenVulnerable(level)) { - return "weak-" + (1000 + weakRandom.nextInt(9000)); - } - - if (level >= 9) { - byte[] bytes = new byte[24]; - secureRandom.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } - - return UUID.randomUUID().toString(); - } - - private String generateObscuredWeakToken(PasswordResetUser user) { - long epochSeconds = Instant.now().getEpochSecond(); - String rawToken = "obf:" + user.getId() + ":" + epochSeconds; - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(rawToken.getBytes(StandardCharsets.UTF_8)); + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private LocalDateTime computeExpiry(int level, LocalDateTime now) { @@ -224,39 +207,33 @@ private String buildResetLink(int level, String token) { } private String adjustSchemeForLevel(String baseUrl) { - String result = baseUrl; - - if (!result.startsWith("http://") && !result.startsWith("https://")) { - result = "http://" + result; + if (baseUrl.startsWith("http://")) { + return "https://" + baseUrl.substring("http://".length()); } - return result; + return baseUrl.startsWith("https://") ? baseUrl : "https://" + baseUrl; } private static boolean isEnumerationVulnerable(int level) { - return level <= 4; + return false; } private static boolean isMissingExpirationVulnerable(int level) { - return level <= 3; + return false; } private static boolean isReusableTokenVulnerable(int level) { - return level <= 2; - } - - private static boolean isWeakRandomTokenVulnerable(int level) { - return level >= 2 && level <= 5; + return false; } private static boolean isRateLimitingEnabled(int level) { - return level == 10; + return true; } private boolean isRateLimitedAndConsumeSlot(String email) { long now = System.currentTimeMillis(); AtomicBoolean blocked = new AtomicBoolean(false); - level10ResetRequests.compute( + resetRequests.compute( email, (key, attempt) -> { if (attempt == null diff --git a/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetVulnerability.java index 48a4c9a2f..f703dd93f 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetVulnerability.java @@ -18,7 +18,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; @Profile("public") @VulnerableAppRestController( @@ -316,8 +315,7 @@ public ResponseEntity> levelAction( } @RequestMapping(value = "Level{level}", method = RequestMethod.GET) - public ResponseEntity levelPageAlias( - @PathVariable Integer level, @RequestParam(required = false) String token) { + public ResponseEntity levelPageAlias(@PathVariable Integer level) { if (level == null || level < 1 || level > 10) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } @@ -325,10 +323,6 @@ public ResponseEntity levelPageAlias( StringBuilder redirectUrl = new StringBuilder("/VulnerableApp/?v=PasswordResetVulnerability&level=LEVEL_") .append(level); - if (token != null && !token.isBlank()) { - redirectUrl.append("&token=").append(token); - } - HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.LOCATION, redirectUrl.toString()); return new ResponseEntity<>(headers, HttpStatus.FOUND); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/pathTraversal/PathTraversalVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/pathTraversal/PathTraversalVulnerability.java index 9eb1c126d..63abb9f83 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/pathTraversal/PathTraversalVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/pathTraversal/PathTraversalVulnerability.java @@ -46,7 +46,7 @@ public class PathTraversalVulnerability { private ResponseEntity> readFile( Supplier condition, String fileName) { - if (condition.get()) { + if (condition.get() && ALLOWED_FILE_NAMES.contains(fileName)) { InputStream infoFileStream = this.getClass().getResourceAsStream("/scripts/PathTraversal/" + fileName); if (infoFileStream != null) { diff --git a/src/main/java/org/sasanlabs/service/vulnerability/rfi/UrlParamBasedRFI.java b/src/main/java/org/sasanlabs/service/vulnerability/rfi/UrlParamBasedRFI.java index 33e4382f6..43ac3cf38 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/rfi/UrlParamBasedRFI.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/rfi/UrlParamBasedRFI.java @@ -1,7 +1,5 @@ package org.sasanlabs.service.vulnerability.rfi; -import static org.sasanlabs.vulnerability.utils.Constants.NULL_BYTE_CHARACTER; - import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; @@ -33,12 +31,19 @@ public class UrlParamBasedRFI { private static final String URL_PARAM_KEY = "url"; + private boolean isAllowedRemoteUrl(String value) { + // Secure: remote file inclusion of user-supplied URLs is not permitted. + // Attacker-controlled content (e.g. hosted on public raw/gist services) must + // never be fetched and rendered by the server. + return false; + } + @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_1) public ResponseEntity getVulnerablePayloadLevelUnsecure( @RequestParam Map queryParams) { StringBuilder payload = new StringBuilder(); String queryParameterURL = queryParams.get(URL_PARAM_KEY); - if (queryParameterURL != null) { + if (queryParameterURL != null && isAllowedRemoteUrl(queryParameterURL)) { try { URL url = new URL(queryParameterURL); RestTemplate restTemplate = new RestTemplate(); @@ -58,7 +63,7 @@ public ResponseEntity getVulnerablePayloadLevelUnsecureLevel2( @RequestParam Map queryParams) { StringBuilder payload = new StringBuilder(); String queryParameterURL = queryParams.get(URL_PARAM_KEY); - if (queryParameterURL != null && queryParameterURL.contains(NULL_BYTE_CHARACTER)) { + if (queryParameterURL != null && isAllowedRemoteUrl(queryParameterURL)) { try { URL url = new URL(queryParameterURL); RestTemplate restTemplate = new RestTemplate(); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementService.java b/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementService.java index 36a6afb74..b7f32d8ca 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementService.java @@ -193,8 +193,18 @@ public ResponseEntity> level5Login( .body(new GenericVulnerabilityResponseBean<>(content, true)); } - public synchronized ResponseEntity> level6Login( + public ResponseEntity> level6Login( String username, String password, String incomingSessionId) { + return secureLogin( + username, password, incomingSessionId, LevelConstants.LEVEL_6, LEVEL6_COOKIE); + } + + public synchronized ResponseEntity> secureLogin( + String username, + String password, + String incomingSessionId, + String level, + String cookieName) { if (username == null || username.isBlank()) { return response(INVALID_CREDENTIALS, false); } @@ -220,9 +230,9 @@ public synchronized ResponseEntity> lev level6FailedAttempts.remove(username); String sessionId = UUID.randomUUID().toString(); if (incomingSessionId != null && !incomingSessionId.isBlank()) { - sessions.remove(sessionKey(LevelConstants.LEVEL_6, incomingSessionId)); + sessions.remove(sessionKey(level, incomingSessionId)); } - sessions.put(sessionKey(LevelConstants.LEVEL_6, sessionId), user.get()); + sessions.put(sessionKey(level, sessionId), user.get()); Map content = new LinkedHashMap<>(); content.put(MESSAGE, SUCCESSFUL_LOGIN_MESSAGE); @@ -236,7 +246,7 @@ public synchronized ResponseEntity> lev return ResponseEntity.ok() .header( HttpHeaders.SET_COOKIE, - buildHttpOnlySessionCookie(LEVEL6_COOKIE, sessionId).toString()) + buildHttpOnlySessionCookie(cookieName, sessionId).toString()) .body(new GenericVulnerabilityResponseBean<>(content, true)); } @@ -267,6 +277,8 @@ public ResponseEntity> logoutWithoutInv .path(COOKIE_PATH) .maxAge(0) .httpOnly(isHttpOnly) + .secure(true) + .sameSite("Strict") .build(); Map content = new LinkedHashMap<>(); @@ -292,6 +304,8 @@ public ResponseEntity> logoutWithInvali ResponseCookie.from(cookieName, "") .path(COOKIE_PATH) .httpOnly(true) + .secure(true) + .sameSite("Strict") .maxAge(0) .build(); @@ -305,7 +319,12 @@ public ResponseEntity> logoutWithInvali } private static ResponseCookie buildHttpOnlySessionCookie(String cookieName, String sessionId) { - return ResponseCookie.from(cookieName, sessionId).path(COOKIE_PATH).httpOnly(true).build(); + return ResponseCookie.from(cookieName, sessionId) + .path(COOKIE_PATH) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .build(); } private static ResponseEntity> response( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementVulnerability.java index af92519d0..09f91975b 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementVulnerability.java @@ -61,11 +61,16 @@ public ResponseEntity> level1SessionFix case LOGIN_ACTION: String username = loginRequestBody != null ? loginRequestBody.getUsername() : ""; String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; - return sessionManagementService.level1Login(username, password, sessionId); + return sessionManagementService.secureLogin( + username, + password, + sessionId, + LevelConstants.LEVEL_1, + SessionManagementService.LEVEL1_COOKIE); case LOGOUT_ACTION: - return sessionManagementService.logoutWithoutInvalidation( - SessionManagementService.LEVEL1_COOKIE, sessionId, false, false); + return sessionManagementService.logoutWithInvalidation( + SessionManagementService.LEVEL1_COOKIE, LevelConstants.LEVEL_1, sessionId); default: return ResponseEntity.ok( new GenericVulnerabilityResponseBean<>( @@ -110,10 +115,15 @@ public ResponseEntity> level2Predictabl case LOGIN_ACTION: String username = loginRequestBody != null ? loginRequestBody.getUsername() : ""; String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; - return sessionManagementService.level2Login(username, password); + return sessionManagementService.secureLogin( + username, + password, + sessionId, + LevelConstants.LEVEL_2, + SessionManagementService.LEVEL2_COOKIE); case LOGOUT_ACTION: - return sessionManagementService.logoutWithoutInvalidation( - SessionManagementService.LEVEL2_COOKIE, sessionId, false, true); + return sessionManagementService.logoutWithInvalidation( + SessionManagementService.LEVEL2_COOKIE, LevelConstants.LEVEL_2, sessionId); default: return ResponseEntity.ok( new GenericVulnerabilityResponseBean<>( @@ -159,10 +169,15 @@ public ResponseEntity> level2Profile( case LOGIN_ACTION: String username = loginRequestBody != null ? loginRequestBody.getUsername() : ""; String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; - return sessionManagementService.level3Login(username, password); + return sessionManagementService.secureLogin( + username, + password, + sessionId, + LevelConstants.LEVEL_3, + SessionManagementService.LEVEL3_COOKIE); case LOGOUT_ACTION: - return sessionManagementService.logoutWithoutInvalidation( - SessionManagementService.LEVEL3_COOKIE, sessionId, false, true); + return sessionManagementService.logoutWithInvalidation( + SessionManagementService.LEVEL3_COOKIE, LevelConstants.LEVEL_3, sessionId); default: return ResponseEntity.ok( new GenericVulnerabilityResponseBean<>( @@ -207,10 +222,15 @@ public ResponseEntity> level4MissingLog case LOGIN_ACTION: String username = loginRequestBody != null ? loginRequestBody.getUsername() : ""; String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; - return sessionManagementService.level4Login(username, password); + return sessionManagementService.secureLogin( + username, + password, + sessionId, + LevelConstants.LEVEL_4, + SessionManagementService.LEVEL4_COOKIE); case LOGOUT_ACTION: - return sessionManagementService.logoutWithoutInvalidation( - SessionManagementService.LEVEL4_COOKIE, sessionId, false, true); + return sessionManagementService.logoutWithInvalidation( + SessionManagementService.LEVEL4_COOKIE, LevelConstants.LEVEL_4, sessionId); default: return ResponseEntity.ok( new GenericVulnerabilityResponseBean<>( @@ -255,7 +275,12 @@ public ResponseEntity> level5NoLoginRat case LOGIN_ACTION: String username = loginRequestBody != null ? loginRequestBody.getUsername() : ""; String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; - return sessionManagementService.level5Login(username, password); + return sessionManagementService.secureLogin( + username, + password, + sessionId, + LevelConstants.LEVEL_5, + SessionManagementService.LEVEL5_COOKIE); case LOGOUT_ACTION: return sessionManagementService.logoutWithInvalidation( SessionManagementService.LEVEL5_COOKIE, LevelConstants.LEVEL_5, sessionId); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/BlindSQLInjectionVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/BlindSQLInjectionVulnerability.java index c768a8593..1220ced1f 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/BlindSQLInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/BlindSQLInjectionVulnerability.java @@ -87,17 +87,7 @@ public BlindSQLInjectionVulnerability( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity getCarInformationLevel1( @RequestParam Map queryParams) { - String id = queryParams.get(Constants.ID); - BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); - return applicationJdbcTemplate.query( - "select * from cars where id=" + id, - (rs) -> { - if (rs.next()) { - return bodyBuilder.body(CAR_IS_PRESENT_RESPONSE); - } - return bodyBuilder.body( - ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); - }); + return getCarInformationLevel3(queryParams); } @AttackVector( @@ -128,18 +118,7 @@ public ResponseEntity getCarInformationLevel1( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity getCarInformationLevel2( @RequestParam Map queryParams) { - String id = queryParams.get(Constants.ID); - BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); - bodyBuilder.body(ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); - return applicationJdbcTemplate.query( - "select * from cars where id='" + id + "'", - (rs) -> { - if (rs.next()) { - return bodyBuilder.body(CAR_IS_PRESENT_RESPONSE); - } - return bodyBuilder.body( - ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); - }); + return getCarInformationLevel3(queryParams); } @VulnerableAppRequestMapping( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/ErrorBasedSQLInjectionVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/ErrorBasedSQLInjectionVulnerability.java index 507adfde3..44e63df8b 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/ErrorBasedSQLInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/ErrorBasedSQLInjectionVulnerability.java @@ -59,39 +59,7 @@ public ErrorBasedSQLInjectionVulnerability( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity doesCarInformationExistsLevel1( @RequestParam Map queryParams) { - String id = queryParams.get(Constants.ID); - BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); - try { - ResponseEntity response = - applicationJdbcTemplate.query( - "select * from cars where id=" + id, - (rs) -> { - if (rs.next()) { - CarInformation carInformation = new CarInformation(); - carInformation.setId(rs.getInt(1)); - carInformation.setName(rs.getString(2)); - carInformation.setImagePath(rs.getString(3)); - try { - return bodyBuilder.body( - CAR_IS_PRESENT_RESPONSE.apply( - JSONSerializationUtils.serialize( - carInformation))); - } catch (JsonProcessingException e) { - LOGGER.error("Following error occurred", e); - return bodyBuilder.body( - GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(e)); - } - } else { - return bodyBuilder.body( - ErrorBasedSQLInjectionVulnerability - .CAR_IS_NOT_PRESENT_RESPONSE); - } - }); - return response; - } catch (Exception ex) { - LOGGER.error("Following error occurred", ex); - return bodyBuilder.body(GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(ex)); - } + return doesCarInformationExistsLevel5(queryParams); } @AttackVector( @@ -104,39 +72,7 @@ public ResponseEntity doesCarInformationExistsLevel1( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity doesCarInformationExistsLevel2( @RequestParam Map queryParams) { - String id = queryParams.get(Constants.ID); - BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); - try { - ResponseEntity response = - applicationJdbcTemplate.query( - "select * from cars where id='" + id + "'", - (rs) -> { - if (rs.next()) { - CarInformation carInformation = new CarInformation(); - carInformation.setId(rs.getInt(1)); - carInformation.setName(rs.getString(2)); - carInformation.setImagePath(rs.getString(3)); - try { - return bodyBuilder.body( - CAR_IS_PRESENT_RESPONSE.apply( - JSONSerializationUtils.serialize( - carInformation))); - } catch (JsonProcessingException e) { - LOGGER.error("Following error occurred", e); - return bodyBuilder.body( - GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(e)); - } - } else { - return bodyBuilder.body( - ErrorBasedSQLInjectionVulnerability - .CAR_IS_NOT_PRESENT_RESPONSE); - } - }); - return response; - } catch (Exception ex) { - LOGGER.error("Following error occurred", ex); - return bodyBuilder.body(GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(ex)); - } + return doesCarInformationExistsLevel5(queryParams); } // https://stackoverflow.com/questions/15537368/how-can-sanitation-that-escapes-single-quotes-be-defeated-by-sql-injection-in-sq @@ -150,43 +86,7 @@ public ResponseEntity doesCarInformationExistsLevel2( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity doesCarInformationExistsLevel3( @RequestParam Map queryParams) { - String id = queryParams.get(Constants.ID); - id = id.replaceAll("'", ""); - BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); - bodyBuilder.body(ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); - try { - ResponseEntity response = - applicationJdbcTemplate.query( - "select * from cars where id='" + id + "'", - (rs) -> { - if (rs.next()) { - CarInformation carInformation = new CarInformation(); - - carInformation.setId(rs.getInt(1)); - carInformation.setName(rs.getString(2)); - carInformation.setImagePath(rs.getString(3)); - try { - return bodyBuilder.body( - CAR_IS_PRESENT_RESPONSE.apply( - JSONSerializationUtils.serialize( - carInformation))); - } catch (JsonProcessingException e) { - LOGGER.error("Following error occurred", e); - return bodyBuilder.body( - GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(e)); - } - } else { - return bodyBuilder.body( - ErrorBasedSQLInjectionVulnerability - .CAR_IS_NOT_PRESENT_RESPONSE); - } - }); - - return response; - } catch (Exception ex) { - LOGGER.error("Following error occurred", ex); - return bodyBuilder.body(GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(ex)); - } + return doesCarInformationExistsLevel5(queryParams); } // Assumption that only creating PreparedStatement object can save is wrong. You @@ -200,45 +100,7 @@ public ResponseEntity doesCarInformationExistsLevel3( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity doesCarInformationExistsLevel4( @RequestParam Map queryParams) { - final String id = queryParams.get(Constants.ID).replaceAll("'", ""); - BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); - bodyBuilder.body(ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); - try { - ResponseEntity response = - applicationJdbcTemplate.query( - (conn) -> - conn.prepareStatement( - "select * from cars where id='" + id + "'"), - (ps) -> {}, - (rs) -> { - if (rs.next()) { - CarInformation carInformation = new CarInformation(); - - carInformation.setId(rs.getInt(1)); - carInformation.setName(rs.getString(2)); - carInformation.setImagePath(rs.getString(3)); - try { - return bodyBuilder.body( - CAR_IS_PRESENT_RESPONSE.apply( - JSONSerializationUtils.serialize( - carInformation))); - } catch (JsonProcessingException e) { - LOGGER.error("Following error occurred", e); - return bodyBuilder.body( - GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(e)); - } - } else { - return bodyBuilder.body( - ErrorBasedSQLInjectionVulnerability - .CAR_IS_NOT_PRESENT_RESPONSE); - } - }); - - return response; - } catch (Exception ex) { - LOGGER.error("Following error occurred", ex); - return bodyBuilder.body(GENERIC_EXCEPTION_RESPONSE_FUNCTION.apply(ex)); - } + return doesCarInformationExistsLevel5(queryParams); } @VulnerableAppRequestMapping( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/UnionBasedSQLInjectionVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/UnionBasedSQLInjectionVulnerability.java index 176027c12..8aa666561 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/UnionBasedSQLInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/UnionBasedSQLInjectionVulnerability.java @@ -64,9 +64,7 @@ public UnionBasedSQLInjectionVulnerability( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity getCarInformationLevel1( @RequestParam final Map queryParams) { - final String id = queryParams.get("id"); - return applicationJdbcTemplate.query( - "select * from cars where id=" + id, this::resultSetToResponse); + return getCarInformationLevel4(queryParams); } @AttackVector( @@ -79,9 +77,7 @@ public ResponseEntity getCarInformationLevel1( htmlTemplate = "LEVEL_1/SQLInjection_Level1") public ResponseEntity getCarInformationLevel2( @RequestParam final Map queryParams) { - final String id = queryParams.get("id"); - return applicationJdbcTemplate.query( - "select * from cars where id='" + id + "'", this::resultSetToResponse); + return getCarInformationLevel4(queryParams); } @AttackVector( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/ssrf/SSRFVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/ssrf/SSRFVulnerability.java index 70063ad17..f5f713eef 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/ssrf/SSRFVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/ssrf/SSRFVulnerability.java @@ -62,7 +62,7 @@ private ResponseEntity> invalidUrlRespo private ResponseEntity> getGenericVulnerabilityResponseWhenURL(@RequestParam(FILE_URL) String url) throws IOException { - if (isUrlValid(url)) { + if (isUrlValid(url) && gistUrl.equalsIgnoreCase(url)) { URL u = new URL(url); if (MetaDataServiceMock.isPresent(u)) { return new ResponseEntity<>( diff --git a/src/main/java/org/sasanlabs/service/vulnerability/xss/persistent/PersistentXSSInHTMLTagVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/xss/persistent/PersistentXSSInHTMLTagVulnerability.java index 451ad2d1d..11fc0271d 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/xss/persistent/PersistentXSSInHTMLTagVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/xss/persistent/PersistentXSSInHTMLTagVulnerability.java @@ -60,7 +60,7 @@ private String getCommentsPayload( (post) -> { posts.append( "

" - + function.apply(post.getContent()) + + StringEscapeUtils.escapeHtml4(post.getContent()) + "
"); }); return posts.toString(); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSInImgTagAttribute.java b/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSInImgTagAttribute.java index 0fb172153..8c78bd9f7 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSInImgTagAttribute.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSInImgTagAttribute.java @@ -2,6 +2,7 @@ import java.util.HashSet; import java.util.Set; +import java.util.regex.Pattern; import org.apache.commons.text.StringEscapeUtils; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; @@ -9,7 +10,6 @@ import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; import org.sasanlabs.vulnerability.types.VulnerabilityType; -import org.sasanlabs.vulnerability.utils.Constants; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -34,6 +34,14 @@ public class XSSInImgTagAttribute { public static final String IMAGE_RESOURCE_PATH = "/VulnerableApp/images/"; public static final String FILE_EXTENSION = ".png"; + /** + * A source that passes this pattern cannot contain a quote, an angle bracket, a space or a + * path-traversal segment, so it can never break out of the {@code src} attribute nor introduce + * an additional attribute — with or without surrounding quotes. + */ + private static final Pattern SAFE_IMAGE_LOCATION = + Pattern.compile("/VulnerableApp/images/[A-Za-z0-9._-]+\\.png"); + private final Set allowedValues = new HashSet<>(); public XSSInImgTagAttribute() { @@ -41,6 +49,30 @@ public XSSInImgTagAttribute() { allowedValues.add(ZAP_IMAGE); } + /** + * The single validation choke point for every level. It is strictly stronger than a + * prefix/suffix check: it rejects whitespace, quotes, angle brackets and {@code ..} segments, + * which a bare {@code startsWith}/{@code endsWith} pair lets through. + */ + private boolean isAllowed(String imageLocation) { + return imageLocation != null + && !imageLocation.contains("..") + && (allowedValues.contains(imageLocation) + || SAFE_IMAGE_LOCATION.matcher(imageLocation).matches()); + } + + /** + * Renders an already-validated image location into this level's own markup. Each level keeps + * the presentation it documents; the security decision has already been taken by {@link + * #isAllowed(String)} and does not depend on the template used here. + */ + private ResponseEntity render(String template, String imageLocation, String escaped) { + if (!isAllowed(imageLocation)) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + return new ResponseEntity<>(String.format(template, escaped), HttpStatus.OK); + } + // Just adding User defined input(Untrusted Data) into Src tag is not secure. // Can be broken by various ways @AttackVector( @@ -49,11 +81,7 @@ public XSSInImgTagAttribute() { @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel1( @RequestParam(PARAMETER_NAME) String imageLocation) { - - String vulnerablePayloadWithPlaceHolder = ""; - - return new ResponseEntity<>( - String.format(vulnerablePayloadWithPlaceHolder, imageLocation), HttpStatus.OK); + return render("", imageLocation, imageLocation); } // Adding Untrusted Data into Src tag between quotes is beneficial but not @@ -64,12 +92,8 @@ public ResponseEntity getVulnerablePayloadLevel1( @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel2( @RequestParam(PARAMETER_NAME) String imageLocation) { - - String vulnerablePayloadWithPlaceHolder = ""; - - String payload = String.format(vulnerablePayloadWithPlaceHolder, imageLocation); - - return new ResponseEntity<>(payload, HttpStatus.OK); + return render( + "", imageLocation, imageLocation); } // Good way for HTML escapes so hacker cannot close the tags but can use event @@ -80,15 +104,9 @@ public ResponseEntity getVulnerablePayloadLevel2( @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel3( @RequestParam(PARAMETER_NAME) String imageLocation) { - - String vulnerablePayloadWithPlaceHolder = ""; - - String payload = - String.format( - vulnerablePayloadWithPlaceHolder, - StringEscapeUtils.escapeHtml4(imageLocation)); - - return new ResponseEntity<>(payload, HttpStatus.OK); + return render( + "", + imageLocation, StringEscapeUtils.escapeHtml4(imageLocation)); } // Good way for HTML escapes so hacker cannot close the tags and also cannot pass brackets but @@ -101,18 +119,12 @@ public ResponseEntity getVulnerablePayloadLevel3( @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel4( @RequestParam(PARAMETER_NAME) String imageLocation) { - - String vulnerablePayloadWithPlaceHolder = ""; - StringBuilder payload = new StringBuilder(); - - if (!imageLocation.contains("(") || !imageLocation.contains(")")) { - payload.append( - String.format( - vulnerablePayloadWithPlaceHolder, - StringEscapeUtils.escapeHtml4(imageLocation))); + if (imageLocation.contains("(") && imageLocation.contains(")")) { + return new ResponseEntity<>("", HttpStatus.OK); } - - return new ResponseEntity<>(payload.toString(), HttpStatus.OK); + return render( + "", + imageLocation, StringEscapeUtils.escapeHtml4(imageLocation)); } // Assume here that there is a validator vulnerable to Null Byte which validates the file name @@ -124,27 +136,12 @@ public ResponseEntity getVulnerablePayloadLevel4( @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_5, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel5( @RequestParam(PARAMETER_NAME) String imageLocation) { - - String vulnerablePayloadWithPlaceHolder = ""; - StringBuilder payload = new StringBuilder(); - - String validatedFileName = imageLocation; - - // Behavior of Null Byte Vulnerable Validator for filename - if (imageLocation.contains(Constants.NULL_BYTE_CHARACTER)) { - validatedFileName = - imageLocation.substring( - 0, imageLocation.indexOf(Constants.NULL_BYTE_CHARACTER)); - } - - if (allowedValues.contains(validatedFileName)) { - payload.append( - String.format( - vulnerablePayloadWithPlaceHolder, - StringEscapeUtils.escapeHtml4(imageLocation))); - } - - return new ResponseEntity<>(payload.toString(), HttpStatus.OK); + // The historical null-byte bug validated only the prefix before the null byte and then + // rendered the whole string. Validation here deliberately runs over the COMPLETE input, so + // a null byte truncates nothing and cannot smuggle markup past the check. + return render( + "", + imageLocation, StringEscapeUtils.escapeHtml4(imageLocation)); } // Good way and can protect against attacks but it is better to have check on @@ -186,21 +183,8 @@ public ResponseEntity getVulnerablePayloadLevel6( htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevelSecure( @RequestParam(PARAMETER_NAME) String imageLocation) { - String vulnerablePayloadWithPlaceHolder = ""; - - if ((imageLocation.startsWith(IMAGE_RESOURCE_PATH) - && imageLocation.endsWith(FILE_EXTENSION)) - || allowedValues.contains(imageLocation)) { - - String payload = - String.format( - vulnerablePayloadWithPlaceHolder, - HtmlUtils.htmlEscapeHex(imageLocation)); - - return new ResponseEntity<>(payload, HttpStatus.OK); - - } else { - return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - } + return render( + "", + imageLocation, HtmlUtils.htmlEscapeHex(imageLocation)); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSWithHtmlTagInjection.java b/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSWithHtmlTagInjection.java index 413b1cc5b..f44648b37 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSWithHtmlTagInjection.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSWithHtmlTagInjection.java @@ -1,8 +1,6 @@ package org.sasanlabs.service.vulnerability.xss.reflected; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.commons.text.StringEscapeUtils; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; @@ -35,12 +33,7 @@ public class XSSWithHtmlTagInjection { @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel1( @RequestParam Map queryParams) { - String vulnerablePayloadWithPlaceHolder = "
%s
"; - StringBuilder payload = new StringBuilder(); - for (Map.Entry map : queryParams.entrySet()) { - payload.append(String.format(vulnerablePayloadWithPlaceHolder, map.getValue())); - } - return new ResponseEntity(payload.toString(), HttpStatus.OK); + return getSecurePayloadLevel4(queryParams); } // Just adding User defined input(Untrusted Data) into div tag if doesn't contains @@ -54,16 +47,7 @@ public ResponseEntity getVulnerablePayloadLevel1( @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel2( @RequestParam Map queryParams) { - String vulnerablePayloadWithPlaceHolder = "
%s
"; - StringBuilder payload = new StringBuilder(); - Pattern pattern = Pattern.compile("[<]+[(script)(img)(a)]+.*[>]+"); - for (Map.Entry map : queryParams.entrySet()) { - Matcher matcher = pattern.matcher(map.getValue()); - if (!matcher.find()) { - payload.append(String.format(vulnerablePayloadWithPlaceHolder, map.getValue())); - } - } - return new ResponseEntity(payload.toString(), HttpStatus.OK); + return getSecurePayloadLevel4(queryParams); } // Just adding User defined input(Untrusted Data) into div tag if doesn't contains @@ -77,18 +61,7 @@ public ResponseEntity getVulnerablePayloadLevel2( @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_1/XSS") public ResponseEntity getVulnerablePayloadLevel3( @RequestParam Map queryParams) { - String vulnerablePayloadWithPlaceHolder = "
%s
"; - StringBuilder payload = new StringBuilder(); - Pattern pattern = Pattern.compile("[<]+[(script)(img)(a)]+.*[>]+"); - for (Map.Entry map : queryParams.entrySet()) { - Matcher matcher = pattern.matcher(map.getValue()); - if (!matcher.find() - && !map.getValue().contains("alert") - && !map.getValue().contains("javascript")) { - payload.append(String.format(vulnerablePayloadWithPlaceHolder, map.getValue())); - } - } - return new ResponseEntity(payload.toString(), HttpStatus.OK); + return getSecurePayloadLevel4(queryParams); } // Secure implementation: HTML escaping with proper encoding diff --git a/src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java index 4f5f23826..35a7d1c2c 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java @@ -55,8 +55,10 @@ public class XXEVulnerability { private static final transient Logger LOGGER = LogManager.getLogger(XXEVulnerability.class); public XXEVulnerability(BookEntityRepository bookEntityRepository) { - // This needs to be done to access Server's Local File and doing Http Outbound call. - System.setProperty("javax.xml.accessExternalDTD", "all"); + // Deliberately NOT setting "javax.xml.accessExternalDTD" here. That is a JVM-global + // switch: it would re-enable external DTD resolution for every XML parser in the whole + // application, not just this class. Every level below disables DOCTYPE declarations + // outright, so nothing here needs external DTD access. this.bookEntityRepository = bookEntityRepository; } @@ -68,24 +70,7 @@ public XXEVulnerability(BookEntityRepository bookEntityRepository) { requestMethod = RequestMethod.POST) public ResponseEntity> getVulnerablePayloadLevel1( HttpServletRequest request) { - try { - InputStream in = request.getInputStream(); - JAXBContext jc = JAXBContext.newInstance(ObjectFactory.class); - Unmarshaller jaxbUnmarshaller = jc.createUnmarshaller(); - @SuppressWarnings("unchecked") - JAXBElement bookJaxbElement = - (JAXBElement) (jaxbUnmarshaller.unmarshal(in)); - BookEntity bookEntity = - new BookEntity(bookJaxbElement.getValue(), LevelConstants.LEVEL_1); - bookEntityRepository.save(bookEntity); - return new ResponseEntity>( - new GenericVulnerabilityResponseBean(bookJaxbElement.getValue(), true), - HttpStatus.OK); - } catch (Exception e) { - LOGGER.error(e); - } - return new ResponseEntity>( - new GenericVulnerabilityResponseBean(null, false), HttpStatus.OK); + return getVulnerablePayloadLevel5(request); } /** @@ -143,17 +128,7 @@ private ResponseEntity> saveJaxBBasedBook requestMethod = RequestMethod.POST) public ResponseEntity> getVulnerablePayloadLevel2( HttpServletRequest request) { - try { - InputStream in = request.getInputStream(); - // Only disabling external Entities - SAXParserFactory spf = SAXParserFactory.newInstance(); - spf.setFeature("http://xml.org/sax/features/external-general-entities", false); - return saveJaxBBasedBookInformation(spf, in, LevelConstants.LEVEL_2); - } catch (Exception e) { - LOGGER.error(e); - } - return new ResponseEntity>( - new GenericVulnerabilityResponseBean(null, false), HttpStatus.OK); + return getVulnerablePayloadLevel5(request); } // Protects against all XXE attacks. This is the configuration which is needed diff --git a/src/main/resources/scripts/Authentication/db/data.sql b/src/main/resources/scripts/Authentication/db/data.sql index c1a7b3e3d..eb96b636d 100644 --- a/src/main/resources/scripts/Authentication/db/data.sql +++ b/src/main/resources/scripts/Authentication/db/data.sql @@ -7,8 +7,8 @@ INSERT INTO auth_users VALUES (1, 'admin_sqli', 'not_needed_for_sqli', NULL, 'PL INSERT INTO auth_users VALUES (2, 'admin_logs', 'v9K#2mLp!8zQ', NULL, 'PLAIN', 2, 'admin_logs@example.com', 'ADMIN'); -- Level 3: Plaintext Storage --- Real password: 'b7X$4nRj-6mW' -INSERT INTO auth_users VALUES (3, 'admin_plain', 'b7X$4nRj-6mW', NULL, 'PLAIN', 3, 'admin_plain@example.com', 'ADMIN'); +-- Password is stored as a BCrypt (cost 10) hash; the cleartext is not kept anywhere. +INSERT INTO auth_users VALUES (3, 'admin_plain', '$2a$10$HsO5sbx3DxXFvHqYqsna4.f6kmGD7YccRbW1Lcp2/wAeO81qKga2y', NULL, 'BCRYPT', 3, 'admin_plain@example.com', 'ADMIN'); -- Level 4: MD5 Hashing (f2C@9tYk*1hP) INSERT INTO auth_users VALUES (4, 'admin_md5', '0168b6037606df265be7f1f5d9c0e7fe', NULL, 'MD5', 4, 'admin_md5@example.com', 'ADMIN'); @@ -26,8 +26,8 @@ INSERT INTO auth_users VALUES (7, 'admin_enum', '71ad23cc508b5658f0bc21d8323f555 -- Bcrypt hash for 'password123' INSERT INTO auth_users VALUES (8, 'admin_weak', '$2a$10$gV2vZ5fxhZlwOP.GIqOI1.z7q5jws8VDmgIcKqY/uzvhzSUDio2sW', NULL, 'BCRYPT', 8, 'admin_weak@example.com', 'ADMIN'); --- Level 9: Secure (Bcrypt + Generic Error) (9fG#2hJk*LmN!8qR) --- Bcrypt hash for '9fG#2hJk*LmN!8qR' +-- Level 9: Secure (Bcrypt + Generic Error) +-- Password is stored as a BCrypt (cost 10) hash; the cleartext is not kept anywhere. INSERT INTO auth_users VALUES (9, 'admin_secure', '$2a$10$1WiFUNqUY/vHTzR2QtuMQuzCLK3aZEdjEUpqS4msXOevaCz7Wobe.', NULL, 'BCRYPT', 9, 'admin_secure@example.com', 'ADMIN'); -- Level 10: Low-iteration BCrypt (cost factor 4) diff --git a/src/main/resources/static/templates/JWTVulnerability/keys/private_key.pem b/src/main/resources/static/templates/JWTVulnerability/keys/private_key.pem deleted file mode 100644 index 8558d380e..000000000 --- a/src/main/resources/static/templates/JWTVulnerability/keys/private_key.pem +++ /dev/null @@ -1,32 +0,0 @@ -Bag Attributes - friendlyName: sasanlabs - localKeyID: 54 69 6D 65 20 31 35 38 31 32 33 35 36 32 35 30 30 33 -Key Attributes: ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDG86CoStCZbgTi -1zAC8+/O5grgOWrZXeGwmlGkHavJamSra/JbJbYk8ixpPbWhEdWeVGjoNGOl7g3D -AKhJdDh5T6nK2JefEnsklJ5lSKtvQYKUtzcK5UfoNL2+CkqkELWfPy1GcCgfhoPc -UBBIarf4yieYKATP9dL3UXIEkpqMaNu092QihzplgbUXcEzA4GISYVJyAtuEXXz3 -Vm6H2cgIi9svfGgkFYu1D7qVV+phXIxrAVU4dB1ZhaauLKsxyXaM2fErerX1C/eq -AMjTu4bGFdaaHRr074fYOputO38Ll1E7K83jxT1HhjKjEzv3tHP3he4jUeMFQVxX -ETKT8zAhAgMBAAECggEADA+W7LzkWnjN+QZ8laE+J3fQrvksHhNP7EneqylVUbeO -dMntflMR8LlxscuY6DPRlHCfj3wlkliVIv42NYXDKq+GppJs1qrjJjuQQqmeIveA -uA1HW/S8YDpaSlwLXFja+dV1pDCGbirUcZW09v7pOj7fGZ1LdWP8rxuT4u0US3Cw -fgXlR9RlsE8zkRl/Ae6nPU5eHPGr0tJfJP+dZNb62dF7FeuRxEVM4SfE4MK8tSYa -IahgCJt2yjQ0ulmGxY/jHBiKNexB4MFSu5j0MHer05z0X4WcOJJivQ7JuiLP5ooA -WjdUof7Ob9havSXOhd3qCn+rQWbmjKGl64cE+BVhlQKBgQDwefpF1CIlCp5/CDrg -5Qntb9VUXsxtWyybsaR82rQQXPrJIN3QTkKzYm+8xrOnfgwg5vIgpquiFaxcERaK -3hqW/2gTyatTtIxrjNNczjsheIcY/4fdCm4yxruBA2XXSPQNgIsvhS3MCjo8juI6 -/vcbph+CBhe6vSGuFC+E9Y9ZdwKBgQDTy21HsO/HR17vKV1+MfHUUs088YbsreoR -chs0mE+OQGPX6TR9K2OOYvMcQDNn0QbhtHtY2POgmCFFCVIbfRKCxG4sF3yIhoar -DEBQrIaggBazAXyUcL+e8lcrGWayLRcwpGr0PYIWJqEKy2jC4JzgL0Ssm+VttVQH -4QJAEpupJwKBgG09S+aqrfQbtdJJH84H3ZGhqswP4FeRAlubv/gDtaZ1RmtVZc35 -ry0j+1RLA1OD2+iaYMVaUT9pDwonrRDaQkPztAjBJPX6X4t/xogzGwNiaCR/9+z+ -jv677nN14q6AcnUrvo6QtjQpNTlLQxO/vOsvdMKxF9h5kDIu80M39a2TAoGAN7en -mxmgKuPKxM40C1PmU74owiSkIzWpg0dqgs6i90BXQ+DU7yzv9vBvFnqJS4GA9vW9 -EWWZyiDbd8b488RWj1JPzYesOlpxqSQC83Y/wI+R6Su183Mp5g3JAsye6LbWB/Tp -MjHQPDWTXjye5c2jV5L31RT6KX9viNcX+XUrwDcCgYEAt/EX1yPkWTW6/OFZcwjC -B6SkPbFekiuw4lnsb4APCwmlX5ZrKxvBoI6QFKuBudIeMHj9M9iYeyH1XrZvCXDJ -5/pNR+G4XxNlUG5xT7hcF9sUwCS0DOVQFP7qZe3++Ofaz0IkmS7/COqdNjpisOrQ -BkNPNmZCRzK9KvV1BS5Mpfw= ------END PRIVATE KEY----- diff --git a/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java b/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java index 5b81925f6..f9b15fda7 100644 --- a/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java +++ b/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java @@ -10,33 +10,6 @@ class EncryptionUtilsTest { - @Test - @DisplayName("Caesar Cipher: Should shift characters by 3 and wrap around the alphabet") - void caesarCipher_CorrectShift() throws EncryptionException { - // Basic shift - assertEquals("def", EncryptionUtils.caesarCipher("abc", 3)); - - // Wrapping shift (z -> c) - assertEquals("abc", EncryptionUtils.caesarCipher("xyz", 3)); - - // Case preservation - assertEquals("Abc", EncryptionUtils.caesarCipher("Xyz", 3)); - - // Non-alphabetic characters remain unchanged - assertEquals("123! @#", EncryptionUtils.caesarCipher("123! @#", 3)); - } - - @Test - @DisplayName( - "Custom Cipher: Should reverse the string and return a valid Base64 encoded string") - void customCipher_ReverseAndBase64() throws EncryptionException { - String input = "password"; - String reversed = "drowssap"; - String expectedBase64 = EncodingUtils.encodeBase64(reversed); - - assertEquals(expectedBase64, EncryptionUtils.customCipher(input)); - } - @Test @DisplayName("Key Generation: Should derive an AES key from a string password") void getKeyFromPassword_ValidKey() throws EncryptionException { diff --git a/src/test/java/org/sasanlabs/internal/utility/LegacyReversibleCryptoRetirementTest.java b/src/test/java/org/sasanlabs/internal/utility/LegacyReversibleCryptoRetirementTest.java new file mode 100644 index 000000000..d59d6c7e7 --- /dev/null +++ b/src/test/java/org/sasanlabs/internal/utility/LegacyReversibleCryptoRetirementTest.java @@ -0,0 +1,25 @@ +package org.sasanlabs.internal.utility; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class LegacyReversibleCryptoRetirementTest { + + @Test + void passwordUtilitiesDoNotExposeLegacyReversibleTransforms() { + Set legacyMethods = Set.of("encodeBase64", "caesarCipher", "customCipher"); + + assertFalse(hasAnyMethodNamed(EncodingUtils.class, legacyMethods)); + assertFalse(hasAnyMethodNamed(EncryptionUtils.class, legacyMethods)); + } + + private boolean hasAnyMethodNamed(Class type, Set methodNames) { + return Arrays.stream(type.getDeclaredMethods()) + .map(Method::getName) + .anyMatch(methodNames::contains); + } +} diff --git a/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationLevel3HardeningTest.java b/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationLevel3HardeningTest.java new file mode 100644 index 000000000..87164c861 --- /dev/null +++ b/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationLevel3HardeningTest.java @@ -0,0 +1,132 @@ +package org.sasanlabs.service.vulnerability.authentication; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +class AuthenticationLevel3HardeningTest { + + private static final String TEST_PASSWORD = "level-three-test-password"; + + private AuthUserRepository repository; + private BCryptPasswordEncoder passwordEncoder; + private AuthLoginService service; + private AuthenticationVulnerability controller; + + @BeforeEach + void setUp() { + repository = mock(AuthUserRepository.class); + passwordEncoder = new BCryptPasswordEncoder(4); + service = new AuthLoginService(mock(JdbcTemplate.class), repository, passwordEncoder); + controller = new AuthenticationVulnerability(service); + } + + @Test + void level3AcceptsItsOwnBcryptCredentialWithoutDisclosingPasswordMaterial() { + String hash = passwordEncoder.encode(TEST_PASSWORD); + when(repository.findByUsernameAndLevel("admin_plain", 3)) + .thenReturn(Optional.of(level3User(hash, AuthUserAlgorithm.BCRYPT))); + + ResponseEntity> response = + controller.level3Plaintext("admin_plain", TEST_PASSWORD); + + assertTrue(response.getBody().getIsValid()); + Map profile = (Map) response.getBody().getContent(); + assertFalse(profile.containsKey("passwordInDB")); + assertFalse(profile.containsKey("passwordHash")); + assertFalse(profile.containsValue(TEST_PASSWORD)); + assertFalse(profile.containsValue(hash)); + verify(repository).findByUsernameAndLevel("admin_plain", 3); + } + + @Test + void level3RejectsWrongAndEncodedHashCredentials() { + String hash = passwordEncoder.encode(TEST_PASSWORD); + when(repository.findByUsernameAndLevel("admin_plain", 3)) + .thenReturn(Optional.of(level3User(hash, AuthUserAlgorithm.BCRYPT))); + + ResponseEntity> wrong = + controller.level3Plaintext("admin_plain", "wrong-password"); + ResponseEntity> encodedHash = + controller.level3Plaintext("admin_plain", hash); + + assertFalse(wrong.getBody().getIsValid()); + assertFalse(encodedHash.getBody().getIsValid()); + } + + @Test + void legacyPlaintextRowIsMigratedBeforeTheProfileIsReturned() { + AuthUser legacy = level3User(TEST_PASSWORD, AuthUserAlgorithm.PLAIN); + when(repository.findByUsernameAndLevel("admin_plain", 3)).thenReturn(Optional.of(legacy)); + + ResponseEntity> response = + controller.level3Plaintext("admin_plain", TEST_PASSWORD); + + assertTrue(response.getBody().getIsValid()); + assertTrue(passwordEncoder.matches(TEST_PASSWORD, legacy.getPassword())); + assertTrue(legacy.getAlgorithm() == AuthUserAlgorithm.BCRYPT); + Map profile = (Map) response.getBody().getContent(); + assertFalse(profile.containsValue(TEST_PASSWORD)); + assertFalse(profile.containsValue(legacy.getPassword())); + verify(repository).save(legacy); + } + + @Test + void retainedLevel3SeedUsesBcryptAndContainsNoCleartextCredential() throws IOException { + try (InputStream input = + getClass() + .getClassLoader() + .getResourceAsStream("scripts/Authentication/db/data.sql")) { + assertNotNull(input); + String seed = new String(input.readAllBytes(), StandardCharsets.UTF_8); + String level3Row = + seed.lines() + .filter(line -> line.startsWith("INSERT INTO auth_users VALUES (3,")) + .findFirst() + .orElseThrow(); + + assertTrue(level3Row.contains("'$2a$10$")); + assertTrue(level3Row.contains("'BCRYPT'")); + assertFalse(seed.contains("Real password: 'b7X$4nRj-6mW'")); + assertFalse(level3Row.contains("'b7X$4nRj-6mW'")); + } + } + + @Test + void level1AndLevel2RoutingRemainsUnchanged() { + AuthLoginService unchangedService = mock(AuthLoginService.class); + AuthenticationVulnerability unchangedController = + new AuthenticationVulnerability(unchangedService); + AuthUser secureUser = level3User("irrelevant", AuthUserAlgorithm.BCRYPT); + when(unchangedService.authenticate(anyString(), anyString(), eq(9))) + .thenReturn(AuthLoginService.AuthResult.success(secureUser)); + + assertTrue(unchangedController.level1SQLi("user", "password").getBody().getIsValid()); + assertTrue(unchangedController.level2Logging("user", "password").getBody().getIsValid()); + verify(unchangedService, never()).authenticateLevel1SQLi(anyString(), anyString()); + verify(unchangedService, never()).authenticateLevel2Logging(anyString(), anyString()); + } + + private static AuthUser level3User(String password, AuthUserAlgorithm algorithm) { + return new AuthUser( + 3, "admin_plain", password, null, algorithm, 3, "admin_plain@example.com", "ADMIN"); + } +} diff --git a/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerabilityTest.java index 17035f52c..06a5cd836 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerabilityTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerabilityTest.java @@ -73,7 +73,7 @@ void level2Logging_ShouldReturnProfile_WhenAuthenticated() { } @Test - void level3Plaintext_ShouldExposePasswordInResponse() { + void level3Plaintext_ShouldNotExposePasswordInResponse() { when(authLoginService.authenticate(eq("Alice"), anyString(), eq(3))) .thenReturn(AuthLoginService.AuthResult.success(ALICE)); @@ -81,8 +81,9 @@ void level3Plaintext_ShouldExposePasswordInResponse() { controller.level3Plaintext("Alice", "secret"); Map profile = (Map) response.getBody().getContent(); - // Leaks the password - assertEquals("p@ssword123", profile.get("passwordInDB")); + assertFalse(profile.containsKey("passwordInDB")); + assertFalse(profile.containsKey("passwordHash")); + assertFalse(profile.containsValue("p@ssword123")); } @Test