From 64e0eb55abafdd66b5b64148c43b4a5e736de7a6 Mon Sep 17 00:00:00 2001 From: Attalla <37474787+attalla1@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:35:04 +0000 Subject: [PATCH] fix(VulnerableApp): apply latest security remediations (squashed off dc34-ctf) --- .../VulnerableAppConfiguration.java | 80 +- .../internal/utility/EncodingUtils.java | 9 +- .../internal/utility/EncryptionUtils.java | 103 --- .../utility/PasswordHashingUtils.java | 78 +- .../service/email/EmailServiceImpl.java | 40 +- .../authentication/AuthLoginService.java | 245 ++++-- .../AuthenticationVulnerability.java | 41 +- .../CachePoisoningVulnerability.java | 121 ++- .../ClickjackingVulnerability.java | 32 +- .../commandInjection/CommandInjection.java | 66 +- .../CryptographicFailuresVulnerability.java | 574 +++++-------- .../repo/CryptographicFailuresSeeder.java | 100 +-- .../repo/VaultCipher.java | 72 ++ .../fileupload/PreflightController.java | 65 +- .../fileupload/UnrestrictedFileUpload.java | 245 ++++-- .../vulnerability/idor/IDORVulnerability.java | 116 ++- .../vulnerability/jwt/JWTVulnerability.java | 790 ++++++++---------- .../vulnerability/jwt/bean/JWTUtils.java | 10 +- .../vulnerability/jwt/impl/JWTValidator.java | 367 ++++---- .../jwt/keys/JWTAlgorithmKMS.java | 108 ++- .../jwt/keys/SymmetricAlgorithmKey.java | 5 +- .../LDAPInjectionVulnerability.java | 143 ++-- .../Http3xxStatusCodeBasedInjection.java | 185 ++-- .../passwordReset/PasswordResetService.java | 172 ++-- .../PathTraversalVulnerability.java | 210 ++--- .../vulnerability/rfi/UrlParamBasedRFI.java | 192 ++++- .../SessionManagementService.java | 178 ++-- .../SessionManagementVulnerability.java | 24 +- .../BlindSQLInjectionVulnerability.java | 64 +- .../ErrorBasedSQLInjectionVulnerability.java | 195 ++--- .../UnionBasedSQLInjectionVulnerability.java | 36 +- .../vulnerability/ssrf/SSRFVulnerability.java | 231 ++++- .../PersistentXSSInHTMLTagVulnerability.java | 96 +-- .../xss/reflected/XSSInImgTagAttribute.java | 108 ++- .../reflected/XSSWithHtmlTagInjection.java | 39 +- .../vulnerability/xxe/XXEVulnerability.java | 54 +- src/main/resources/application.properties | 7 + .../scripts/Authentication/db/data.sql | 47 +- .../JWT}/keys/private_key.pem | 0 .../JWT}/keys/public_crt.pem | 0 .../static/password-reset/reset.html | 15 +- .../CommandInjection/LEVEL_1/CI_Level1.js | 5 +- .../JWTVulnerability/LEVEL_1/JWT_Level1.js | 19 +- .../LEVEL_1/LDAP.js | 19 +- .../SSRFVulnerability/LEVEL_1/SSRF.js | 53 +- .../LEVEL_1/FileUpload.js | 4 +- .../templates/XXEVulnerability/LEVEL_1/XXE.js | 14 +- .../internal/utility/EncryptionUtilsTest.java | 89 -- .../utility/PasswordHashingUtilsTest.java | 52 +- .../service/email/EmailServiceImplTest.java | 18 +- .../PasswordResetServiceTest.java | 101 ++- .../SessionManagementServiceTest.java | 75 +- 52 files changed, 3107 insertions(+), 2605 deletions(-) delete mode 100644 src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java create mode 100644 src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/VaultCipher.java rename src/main/resources/{static/templates/JWTVulnerability => scripts/JWT}/keys/private_key.pem (100%) rename src/main/resources/{static/templates/JWTVulnerability => scripts/JWT}/keys/public_crt.pem (100%) delete mode 100644 src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java diff --git a/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java b/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java index 222785ad1..aa2d414a0 100755 --- a/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java +++ b/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java @@ -50,6 +50,9 @@ public class VulnerableAppConfiguration { Arrays.asList( "/" + UnrestrictedFileUpload.CONTROLLER_PATH + "/" + LevelConstants.LEVEL_9); + /** Upper bound on a multipart request accepted on the overridden paths: 1 MiB. */ + private static final long MAX_FILE_UPLOAD_SIZE_IN_BYTES = 1_048_576L; + /** * Will Inject MessageBundle into messageSource bean. * @@ -186,20 +189,48 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() { } /** - * Customized MultipartFilter bean disables default max upload size for multipart files and - * their overall requests, for select paths. See {@link - * UnrestrictedFileUpload#getVulnerablePayloadLevel10()} for usage. + * Customized MultipartFilter bean that bounds the accepted multipart size for the paths listed + * in {@link #MAX_FILE_UPLOAD_SIZE_OVERRIDE_PATHS}. + * + *

These paths used to be resolved with {@code setMaxUploadSize(-1)}, which removed the limit + * entirely. That is an uncontrolled resource consumption flaw and a controller side size check + * cannot close it: commons-fileupload spools the whole request body to a temporary file before + * the handler is ever invoked, so the disk is already consumed by the time the handler could + * refuse it. The limit is therefore enforced by the resolver, which is the only layer that sees + * the request before it is buffered. */ @Bean @Order(0) public MultipartFilter multipartFilter() { class MaxUploadSizeOverrideMultipartFilter extends MultipartFilter { + @Override + protected void doFilterInternal( + HttpServletRequest request, + javax.servlet.http.HttpServletResponse response, + javax.servlet.FilterChain filterChain) + throws javax.servlet.ServletException, IOException { + try { + super.doFilterInternal(request, response, filterChain); + } catch (org.springframework.web.multipart.MultipartException e) { + // The size bound is enforced here rather than in the handler, so an oversized + // request is refused before the handler ever runs and the exception would + // otherwise escape the filter chain as a server error. The refusal is reported + // with the same body the handler uses for input it will not store, so a client + // sees a rejected upload rather than a broken endpoint. + response.setStatus(javax.servlet.http.HttpServletResponse.SC_OK); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"content\":\"Input is invalid\",\"isValid\":false}"); + response.getWriter().flush(); + } + } + @Override protected MultipartResolver lookupMultipartResolver(HttpServletRequest request) { if (MAX_FILE_UPLOAD_SIZE_OVERRIDE_PATHS.contains(request.getServletPath())) { CommonsMultipartResolver multipart = new CommonsMultipartResolver(); - multipart.setMaxUploadSize(-1); - multipart.setMaxUploadSizePerFile(-1); + multipart.setMaxUploadSize(MAX_FILE_UPLOAD_SIZE_IN_BYTES); + multipart.setMaxUploadSizePerFile(MAX_FILE_UPLOAD_SIZE_IN_BYTES); return multipart; } else { // returns default implementation @@ -210,4 +241,43 @@ protected MultipartResolver lookupMultipartResolver(HttpServletRequest request) ; return new MaxUploadSizeOverrideMultipartFilter(); } + + /** + * Sends framing protection on every response, not only on the JSON answers of the clickjacking + * levels. + * + *

A clickjacking attack frames whatever the victim actually sees, and the pages of a level + * are served straight out of {@code static/} by the resource handler, which no controller ever + * touches. Setting the headers in the controller alone therefore protected the API answer while + * leaving the page that renders it embeddable. {@code X-Frame-Options: DENY} is the legacy + * control and {@code frame-ancestors 'none'} its modern replacement, so both are sent and old + * and current browsers alike refuse to render any of it inside a frame. DENY rather than + * SAMEORIGIN, because a same-origin attacker page is enough to mount the overlay attack. + */ + @Bean + @Order(1) + public javax.servlet.Filter framingProtectionFilter() { + return new org.springframework.web.filter.OncePerRequestFilter() { + @Override + protected void doFilterInternal( + HttpServletRequest request, + javax.servlet.http.HttpServletResponse response, + javax.servlet.FilterChain filterChain) + throws javax.servlet.ServletException, IOException { + // Set on every response without exception. This used to skip the clickjacking + // paths and leave them to their handler, to avoid emitting the header twice: a + // handler writes its headers after this filter and they are appended rather than + // replaced, and a browser ignores X-Frame-Options entirely when it appears more + // than once. But a handler only writes headers on a response it produced, so + // every response those URLs give that never reached the handler carried no + // framing protection at all: a request with the wrong method, an OPTIONS probe, + // anything ending in an error page. An attacker frames a URL, not a handler, so + // the header has to be on the response. Setting it here, before the chain runs, + // covers all of them, and no handler adds it any more so it is still sent once. + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + filterChain.doFilter(request, response); + } + }; + } } diff --git a/src/main/java/org/sasanlabs/internal/utility/EncodingUtils.java b/src/main/java/org/sasanlabs/internal/utility/EncodingUtils.java index b6b29eb41..0791282da 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); @@ -11,7 +9,8 @@ public static String bytesToHex(byte[] data) { return builder.toString(); } - public static String encodeBase64(String rawText) { - return Base64.getEncoder().encodeToString(rawText.getBytes()); - } + // encodeBase64 was the helper the CryptographicFailures LEVEL_2 vault was built on: it stored + // Base64 of the password and called it encryption. That level now stores a one-way digest, the + // helper has no callers left, and leaving a "make it look encoded" utility in a shared package + // is an invitation to reintroduce the same mistake. } diff --git a/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java b/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java deleted file mode 100644 index 21caa50d1..000000000 --- a/src/main/java/org/sasanlabs/internal/utility/EncryptionUtils.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.sasanlabs.internal.utility; - -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import org.sasanlabs.internal.utility.exception.EncryptionException; - -/** This class contains methods related to encryption. */ -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 { - new SecureRandom().nextBytes(salt); - } - - public static SecretKey getKeyFromPassword(String password) throws EncryptionException { - try { - SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); - KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 1, 128); - - return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), "AES"); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new EncryptionException("Error generating AES key from password", e); - } - } - - public static String encrypt(String plaintext, SecretKey key) throws EncryptionException { - try { - // VULNERABILITY NOTE: ECB mode does not use an IV and reveals patterns (CWE-327) - Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); - cipher.init(Cipher.ENCRYPT_MODE, key); - - byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); - return java.util.Base64.getEncoder().encodeToString(encrypted); - - } catch (NoSuchPaddingException | NoSuchAlgorithmException e) { - throw new EncryptionException("AES configuration not found ", e); - } catch (InvalidKeyException e) { - throw new EncryptionException("The provided key is invalid for AES encryption", e); - } catch (IllegalBlockSizeException | BadPaddingException e) { - throw new EncryptionException( - "AES encryption failed due to block size or padding issues", e); - } - } -} diff --git a/src/main/java/org/sasanlabs/internal/utility/PasswordHashingUtils.java b/src/main/java/org/sasanlabs/internal/utility/PasswordHashingUtils.java index 7ef8006d2..0b12284eb 100644 --- a/src/main/java/org/sasanlabs/internal/utility/PasswordHashingUtils.java +++ b/src/main/java/org/sasanlabs/internal/utility/PasswordHashingUtils.java @@ -2,8 +2,6 @@ import java.nio.charset.StandardCharsets; import java.security.*; -import javax.crypto.Cipher; -import javax.crypto.spec.SecretKeySpec; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -17,9 +15,6 @@ private PasswordHashingUtils() {} // Available Hashing Algorithms public enum HashAlgorithm { - MD4("MD4"), - MD5("MD5"), - SHA1("SHA-1"), SHA256("SHA-256"); private final String algorithmName; @@ -40,18 +35,6 @@ public String label() { } } - public static String md4Hex(String rawPassword) { - return getHashAsHex(rawPassword, HashAlgorithm.MD4); - } - - public static String md5Hex(String rawPassword) { - return getHashAsHex(rawPassword, HashAlgorithm.MD5); - } - - public static String sha1Hex(String rawPassword) { - return getHashAsHex(rawPassword, HashAlgorithm.SHA1); - } - public static String getHashAsHex(String rawPassword, HashAlgorithm hashAlgorithm) { try { MessageDigest messageDigest = MessageDigest.getInstance(hashAlgorithm.label(), "BC"); @@ -71,8 +54,11 @@ public static boolean isValidSaltedSha256(String rawPassword, String saltedSha25 String[] saltAndHash = saltedSha256Hash.split(HASH_SEPARATOR, 2); if (saltAndHash.length != 2) { - // Backward compatibility for old plaintext test data. - return saltedSha256Hash.equals(rawPassword); + // A stored value with no salt separator is not a verifier this method can check. It + // used to fall through to comparing the stored value against the submitted password, + // which turns any unsalted row into a cleartext credential check inside the very + // helper whose job is to prevent one. There is no such row, so refuse instead. + return false; } String calculatedHash = sha256Hex(saltAndHash[0], rawPassword); @@ -83,10 +69,6 @@ public static String sha256Hex(String salt, String rawPassword) { return getHashAsHex(salt + rawPassword, HashAlgorithm.SHA256); } - public static String unsaltedSha256Hex(String rawPassword) { - return getHashAsHex(rawPassword, HashAlgorithm.SHA256); - } - // BC not used for bcrypt due to extra complexity for BC implementation public static int getbcryptWorkFactor() { return bcryptWorkFactor; @@ -101,54 +83,4 @@ public static boolean isValidBcrypt(String rawPassword, String bcryptHash) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(bcryptWorkFactor); return encoder.matches(rawPassword, bcryptHash); } - - /** - * Computes an LM hash for the given password. - * - *

Algorithm based on the LAN Manager specification. - * - * @see Wikipedia: LAN Manager - */ - public static String lmHash(String rawPassword) { - try { - // Convert to uppercase and pad to 14 bytes - String pwd = rawPassword.toUpperCase(); - byte[] keyBytes = new byte[14]; - byte[] passwordBytes = pwd.getBytes(StandardCharsets.US_ASCII); - System.arraycopy(passwordBytes, 0, keyBytes, 0, Math.min(passwordBytes.length, 14)); - - // Split into two 7-byte keys - byte[] tmpKey1 = new byte[7]; - byte[] tmpKey2 = new byte[7]; - System.arraycopy(keyBytes, 0, tmpKey1, 0, 7); - System.arraycopy(keyBytes, 7, tmpKey2, 0, 7); - - // Encrypt the magic string "KGS!@#$%" using each key - return EncodingUtils.bytesToHex(lmDesEncrypt(tmpKey1)) - + EncodingUtils.bytesToHex(lmDesEncrypt(tmpKey2)); - } catch (Exception e) { - throw new RuntimeException("LM Hashing failed", e); - } - } - - private static byte[] lmDesEncrypt(byte[] key7) throws Exception { - // LM Hash uses a specific parity-bit transformation to turn 7 bytes into an 8-byte DES key - byte[] key8 = new byte[8]; - key8[0] = (byte) (key7[0] >> 1); - key8[1] = (byte) (((key7[0] & 0x01) << 6) | (key7[1] >> 2)); - key8[2] = (byte) (((key7[1] & 0x03) << 5) | (key7[2] >> 3)); - key8[3] = (byte) (((key7[2] & 0x07) << 4) | (key7[3] >> 4)); - key8[4] = (byte) (((key7[3] & 0x0F) << 3) | (key7[4] >> 5)); - key8[5] = (byte) (((key7[4] & 0x1F) << 2) | (key7[5] >> 6)); - key8[6] = (byte) (((key7[5] & 0x3F) << 1) | (key7[6] >> 7)); - key8[7] = (byte) (key7[6] & 0x7F); - - for (int i = 0; i < 8; i++) { - key8[i] = (byte) (key8[i] << 1); - } - - Cipher des = Cipher.getInstance("DES/ECB/NoPadding", "BC"); - des.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key8, "DES")); - return des.doFinal("KGS!@#$%".getBytes(StandardCharsets.US_ASCII)); - } } diff --git a/src/main/java/org/sasanlabs/service/email/EmailServiceImpl.java b/src/main/java/org/sasanlabs/service/email/EmailServiceImpl.java index 37e3b599d..e20644500 100644 --- a/src/main/java/org/sasanlabs/service/email/EmailServiceImpl.java +++ b/src/main/java/org/sasanlabs/service/email/EmailServiceImpl.java @@ -10,6 +10,7 @@ import org.sasanlabs.configuration.EmailConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.mail.MailException; import org.springframework.mail.MailSendException; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; @@ -39,13 +40,26 @@ public void sendEmail(String to, String subject, String body) { message.setTo(to); message.setSubject(subject); message.setText(body); - try { - javaMailSender.send(message); - } catch (MailSendException ex) { - LOGGER.warn("Mail server unavailable while sending email to {}", to, ex); - } + send(() -> javaMailSender.send(message), to); } + /** + * Sends an HTML message, and treats a mail server that will not take it as a delivery problem + * rather than as a failure of whatever asked for the mail. + * + *

This used to call {@code send} outside any handler, so an unreachable or unauthenticated + * SMTP host turned every caller into a server error. That matters most for the password reset + * flow: the request endpoint has to answer the same way whether or not an account exists, and a + * 500 raised while delivering the mail told a caller both that the account existed and that the + * reset had got as far as sending, which is exactly the distinction the generic response is + * there to hide. It also made the whole flow unusable in any deployment without a mail server. + * The message has already been persisted by the time delivery is attempted, so swallowing a + * delivery failure loses nothing but the mail itself, which is logged. + * + *

The formatting failure below now returns instead of falling through: sending a + * half-populated message with no recipient would only have raised a second, more confusing + * error. + */ @Override public void sendHtmlEmail(String to, String subject, String htmlBody) { validateEmailInputs(to, subject, htmlBody, "htmlBody"); @@ -57,9 +71,23 @@ public void sendHtmlEmail(String to, String subject, String htmlBody) { helper.setSubject(subject); helper.setText(htmlBody, true); } catch (MessagingException ex) { + LOGGER.warn("Unable to build the message addressed to {}", to, ex); + return; + } + send(() -> javaMailSender.send(message), to); + } + + /** + * Runs a delivery attempt and downgrades any mail layer failure to a warning. {@link + * MailException} is the root of the hierarchy, so this covers an unreachable host ({@link + * MailSendException}), a rejected login and a message the sender refuses to prepare alike. + */ + private void send(Runnable delivery, String to) { + try { + delivery.run(); + } catch (MailException ex) { LOGGER.warn("Mail server unavailable while sending email to {}", to, ex); } - javaMailSender.send(message); } @Override 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..364328e25 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthLoginService.java @@ -3,10 +3,18 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.sasanlabs.internal.utility.PasswordHashingUtils; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.jdbc.core.BeanPropertyRowMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -23,10 +31,29 @@ public class AuthLoginService { private static final Logger LOGGER = LogManager.getLogger(AuthLoginService.class); + /** + * The only failure message this service ever produces. Returning a different message for an + * unknown account than for a wrong password is what lets an attacker enumerate usernames. + */ + private static final String GENERIC_FAILURE_MESSAGE = "Invalid credentials"; + + /** Levels whose seeded credential is stored as plaintext and is replaced by a hash at boot. */ + private static final Set PLAINTEXT_LEVELS = new HashSet<>(Arrays.asList(1, 2, 3)); + + /** Stored BCrypt hashes below this work factor are re-hashed on the next successful login. */ + private static final int MINIMUM_BCRYPT_COST = 10; + private final JdbcTemplate jdbcTemplate; private final AuthUserRepository authUserRepository; private final BCryptPasswordEncoder passwordEncoder; + /** + * A throw away but well formed BCrypt hash. Verifying against it for an unknown account costs + * the same as verifying a real one, so response time cannot be used to tell whether a username + * exists. + */ + private final String decoyHash; + public AuthLoginService( JdbcTemplate jdbcTemplate, AuthUserRepository authUserRepository, @@ -34,126 +61,208 @@ public AuthLoginService( this.jdbcTemplate = jdbcTemplate; this.authUserRepository = authUserRepository; this.passwordEncoder = passwordEncoder; + this.decoyHash = passwordEncoder.encode(UUID.randomUUID().toString()); } - /** Level 1: SQL Injection. Demonstrates a login query vulnerable to string concatenation. */ + /** + * Replaces every plaintext credential seeded for the levels above with a BCrypt hash once the + * database has been populated, so the password column never holds a recoverable secret. The + * seeded passwords keep working because they are verified against the new hash. + */ + @EventListener(ApplicationReadyEvent.class) + public void replacePlaintextCredentialsWithHashes() { + for (AuthUser user : authUserRepository.findAll()) { + if (user.getAlgorithm() != AuthUserAlgorithm.PLAIN + || !PLAINTEXT_LEVELS.contains(user.getLevel()) + || user.getPassword() == null) { + continue; + } + try { + storeAsBcrypt(user, user.getPassword()); + } catch (Exception e) { + LOGGER.error("Unable to hash the stored credential of a seeded account", e); + } + } + } + + /** + * Level 1: the login lookup is parameterized, so neither the username nor the password can + * change the shape of the statement, and the password is verified in the application rather + * than compared inside the query. + */ 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 - + "'"; + if (username == null || password == null) { + return AuthResult.failure(GENERIC_FAILURE_MESSAGE); + } try { - // Level 1 still uses JdbcTemplate to allow SQL Injection bypass List users = - jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(AuthUser.class)); + jdbcTemplate.query( + "SELECT * FROM auth_users WHERE level=1 AND username=?", + new BeanPropertyRowMapper<>(AuthUser.class), + username); + // Constant work on both branches, so the response time does not reveal the account. + passwordEncoder.matches(password, decoyHash); if (!users.isEmpty()) { - return AuthResult.success(users.get(0)); + AuthUser user = users.get(0); + if (isPasswordValid(password, user)) { + upgradeStoredCredential(user, password); + return AuthResult.success(user); + } } } catch (Exception e) { - // In a real exploit, this might be an error-based SQLi - return AuthResult.failure("Database error: " + e.getMessage()); + // The database error text is never returned: leaking it is what turns a failed login + // into an error based SQL injection oracle. + LOGGER.error("Error while authenticating the user", e); } - return AuthResult.failure("Invalid credentials"); + return AuthResult.failure(GENERIC_FAILURE_MESSAGE); } - /** Level 2: Sensitive Data Logging. Logs the provided password to the logs. */ + /** Level 2 authentication. */ public AuthResult authenticateLevel2Logging(String username, String password) { - Optional userOpt = authUserRepository.findByUsernameAndLevel(username, 2); - - LOGGER.info("Login attempt for user: {} | provided password: {}", username, password); - - if (userOpt.isPresent() - && password != null - && password.equals(userOpt.get().getPassword())) { - return AuthResult.success(userOpt.get()); - } - return AuthResult.failure("Invalid credentials"); + return authenticateInternal(username, password, 2); } /** Authentication method that returns generic failures to avoid username enumeration. */ public AuthResult authenticate(String username, String password, int level) { - return authenticateInternal(username, password, level, false); + return authenticateInternal(username, password, level); } - /** Authentication method that intentionally exposes username enumeration behavior. */ + /** + * Kept for backwards compatibility. It now behaves exactly like {@link #authenticate}: the + * response no longer distinguishes an unknown account from a wrong password. + */ public AuthResult authenticateWithEnumeration(String username, String password, int level) { - return authenticateInternal(username, password, level, true); + return authenticateInternal(username, password, level); } - private AuthResult authenticateInternal( - String username, String password, int level, boolean enumerable) { + private AuthResult authenticateInternal(String username, String password, int level) { + if (username == null || password == null) { + return AuthResult.failure(GENERIC_FAILURE_MESSAGE); + } + Optional userOpt = authUserRepository.findByUsernameAndLevel(username, level); + + // A BCrypt verification against the decoy is performed on every request, whichever branch + // is taken. Doing it only for an unknown account would have replaced the message oracle + // with a timing one, and an inverted one at that: the levels whose stored credential is a + // fast digest would have answered a real account sooner than an imaginary one. It also + // keeps the response time of a level whose stored hash is cheap indistinguishable from one + // whose hash is expensive. + passwordEncoder.matches(password, decoyHash); + if (userOpt.isEmpty()) { - if (enumerable) { - return AuthResult.failure("User not found"); - } - return AuthResult.failure("Invalid credentials"); + return AuthResult.failure(GENERIC_FAILURE_MESSAGE); } AuthUser user = userOpt.get(); - boolean isValid = false; - AuthUserAlgorithm algorithm = user.getAlgorithm(); - - if (algorithm == null) { - return AuthResult.failure("System error: Algorithm not configured"); + if (!isPasswordValid(password, user)) { + return AuthResult.failure(GENERIC_FAILURE_MESSAGE); } + upgradeStoredCredential(user, password); + return AuthResult.success(user); + } + + private boolean isPasswordValid(String password, AuthUser user) { + AuthUserAlgorithm algorithm = user.getAlgorithm(); + if (password == null || algorithm == null) { + return false; + } switch (algorithm) { case PLAIN: - isValid = password != null && password.equals(user.getPassword()); - break; + return constantTimeEquals(password, user.getPassword()); case MD5: - isValid = matchesHash(password, user.getPassword(), "MD5"); - break; + return matchesHash(password, user.getPassword(), "MD5"); case SHA1: - isValid = matchesHash(password, user.getPassword(), "SHA-1"); - break; + return matchesHash(password, user.getPassword(), "SHA-1"); case SHA256: - String salt = user.getSalt(); - String input; - if (salt != null) { - input = salt + password; - } else { - input = "" + password; - } - isValid = matchesHash(input, user.getPassword(), "SHA-256"); - break; + String salt = user.getSalt() == null ? "" : user.getSalt(); + return matchesHash(salt + password, user.getPassword(), "SHA-256"); case BCRYPT: - isValid = password != null && passwordEncoder.matches(password, user.getPassword()); - break; case BCRYPT_LOW_ITERATION: - if (password != null) { - BCryptPasswordEncoder weakEncoder = new BCryptPasswordEncoder(4); - isValid = weakEncoder.matches(password, user.getPassword()); - } - break; + // The work factor is read from the stored hash, so one encoder verifies both. + return passwordEncoder.matches(password, user.getPassword()); default: - break; + return false; } + } - if (isValid) { - return AuthResult.success(user); + /** + * Re-hashes any credential that is not already held as BCrypt at an adequate work factor. The + * plaintext is only available at the moment of a successful login, so this is the one point at + * which a weak representation can be replaced by a strong one. + * + *

Everything other than BCrypt above {@link #MINIMUM_BCRYPT_COST} counts as weak here. + * Plaintext needs no attack at all; MD5, SHA-1 and SHA-256 are general purpose digests built to + * be fast, which is the opposite of what a password hash needs, and the unsalted ones let a + * single precomputed table cover every account at once. A stored BCrypt hash below the minimum + * cost is weak for the same reason at a smaller scale: each drop of one in the cost factor + * halves the work an offline attacker has to do. + */ + private void upgradeStoredCredential(AuthUser user, String password) { + AuthUserAlgorithm algorithm = user.getAlgorithm(); + boolean adequate = + algorithm == AuthUserAlgorithm.BCRYPT + && bcryptCost(user.getPassword()) >= MINIMUM_BCRYPT_COST; + if (adequate) { + return; } - if (enumerable) { - return AuthResult.failure("Invalid password"); + try { + storeAsBcrypt(user, password); + } catch (Exception e) { + LOGGER.error("Unable to upgrade the stored credential of an account", e); } - return AuthResult.failure("Invalid credentials"); } + /** + * Stores the password as a BCrypt hash. BCrypt generates a fresh per credential salt and embeds + * it in the hash, so no separate salt column is needed. + */ + private void storeAsBcrypt(AuthUser user, String password) { + String hash = PasswordHashingUtils.bCryptHash(password); + jdbcTemplate.update( + "UPDATE auth_users SET password=?, salt=NULL, algorithm='BCRYPT' WHERE id=?", + hash, + user.getId()); + user.setPassword(hash); + user.setSalt(null); + user.setAlgorithm(AuthUserAlgorithm.BCRYPT); + } + + /** Reads the work factor out of a {@code $2a$NN$...} BCrypt hash. */ + private static int bcryptCost(String hash) { + if (hash == null || hash.length() < 7 || hash.charAt(0) != '$') { + return 0; + } + try { + return Integer.parseInt(hash.substring(4, 6)); + } catch (NumberFormatException e) { + return 0; + } + } + + // ------------------------------------------------------------------ Hashing helpers + private boolean matchesHash(String input, String storedHash, String algorithm) { if (input == null || storedHash == null) return false; try { MessageDigest digest = MessageDigest.getInstance(algorithm); byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); - return bytesToHex(hash).equalsIgnoreCase(storedHash); + return constantTimeEquals(bytesToHex(hash), storedHash.toLowerCase(Locale.ROOT)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("Algorithm not found: " + algorithm, e); } } + /** Comparison whose duration does not depend on how many leading characters matched. */ + private static boolean constantTimeEquals(String left, String right) { + if (left == null || right == null) { + return false; + } + return MessageDigest.isEqual( + left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); + } + private static String bytesToHex(byte[] data) { StringBuilder builder = new StringBuilder(data.length * 2); for (byte value : data) { 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..d7b191159 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/authentication/AuthenticationVulnerability.java @@ -93,7 +93,10 @@ public ResponseEntity> level1SQLi( description = "AUTHENTICATION_VULNERABILITY_LEVEL_2_PAYLOAD_DESCRIPTION", value = "AUTHENTICATION_VULNERABILITY_LEVEL_2_PAYLOAD_VALUE")) - @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_1/Auth") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_2, + htmlTemplate = "LEVEL_8/Auth", + requestMethod = RequestMethod.POST) public ResponseEntity> level2Logging( @RequestParam(required = false) String username, @RequestParam(required = false) String password) { @@ -140,10 +143,8 @@ 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); + // The stored credential is never echoed back to the caller. + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Level 4 — MD5 @@ -178,10 +179,10 @@ public ResponseEntity> level4Md5( if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } - Map profile = buildProfile(result.getUser()); - profile.put("passwordHash", result.getUser().getPassword()); - profile.put("algorithm", "MD5"); - return response(profile, true); + // Neither the stored hash nor the algorithm that produced it is disclosed. Handing a caller + // the password hash of the account they just logged into gives away the one thing an + // offline attack needs, and naming the algorithm tells the attacker which mode to run. + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Level 5 — SHA-1 @@ -216,10 +217,8 @@ public ResponseEntity> level5Sha1( if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } - Map profile = buildProfile(result.getUser()); - profile.put("passwordHash", result.getUser().getPassword()); - profile.put("algorithm", "SHA-1"); - return response(profile, true); + // The stored hash and its algorithm stay on the server, as on every other level. + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Level 6 — SHA-256 (No @@ -255,10 +254,8 @@ public ResponseEntity> level6Sha256NoSa if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } - Map profile = buildProfile(result.getUser()); - profile.put("passwordHash", result.getUser().getPassword()); - profile.put("algorithm", "SHA-256"); - return response(profile, true); + // The stored hash and its algorithm stay on the server, as on every other level. + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Level 7 — Username Enum @@ -289,8 +286,8 @@ public ResponseEntity> level7UsernameEn if (isCredentialMissing(username, password)) { return response("Please provide username and password", false); } - AuthLoginService.AuthResult result = - authLoginService.authenticateWithEnumeration(username, password, 7); + // The generic path answers an unknown account and a wrong password identically. + AuthLoginService.AuthResult result = authLoginService.authenticate(username, password, 7); if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } @@ -397,10 +394,8 @@ public ResponseEntity> level10LowIterat if (!result.isAuthenticated()) { return response(result.getErrorMessage(), false); } - Map profile = buildProfile(result.getUser()); - profile.put("passwordHash", result.getUser().getPassword()); - profile.put("algorithm", "BCrypt (Cost: 4)"); - return response(profile, true); + // Neither the stored hash nor its work factor is disclosed to the caller. + return response(buildProfile(result.getUser()), true); } // ------------------------------------------------------------------ Helpers 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..14af40360 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cachePoisoning/CachePoisoningVulnerability.java @@ -7,7 +7,6 @@ import java.time.Instant; import java.util.Locale; import java.util.concurrent.ConcurrentHashMap; -import java.util.regex.Pattern; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; @@ -55,11 +54,6 @@ public class CachePoisoningVulnerability { static final String DEMO_USER_DOMAIN = "vulnerableapp.local"; static final Duration DEFAULT_TTL = Duration.ofSeconds(60); - private static final Pattern SCRIPT_BLOCK_PATTERN = - Pattern.compile("(?is)<\\s*script\\b[^>]*>.*?<\\s*/\\s*script\\s*>"); - private static final Pattern SCRIPT_TAG_PATTERN = - Pattern.compile("(?is)<\\s*/?\\s*script\\b[^>]*>"); - private static final Pattern JAVASCRIPT_SCHEME_PATTERN = Pattern.compile("(?i)javascript\\s*:"); private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); @@ -81,8 +75,11 @@ public ResponseEntity> getVulnerablePay boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel1Response(banner); + // The banner is the only input that changes the body, so it has to be part of the cache + // key: an entry produced by "?banner=" can then never be served to a request that + // did not ask for that banner. return buildCachedResponse( - buildRouteOnlyCacheKey(request), + buildRouteAndBannerCacheKey(request, banner), responseContent, resolvePublicCacheControl(browserCache), true); @@ -106,7 +103,7 @@ public ResponseEntity> getVulnerablePay HttpServletRequest request) { String responseContent = buildLevel2Response(banner); return buildCachedResponse( - buildRouteOnlyCacheKey(request), + buildRouteAndBannerCacheKey(request, banner), responseContent, resolvePublicCacheControl(browserCache), true); @@ -128,12 +125,16 @@ public ResponseEntity> getVulnerablePay @RequestParam(value = "browserCache", required = false, defaultValue = "true") boolean browserCache, HttpServletRequest request) { - String responseContent = buildLevel3Response(banner, request); + String responseContent = buildLevel3Response(banner); + // The asset host no longer comes from X-Forwarded-Host, so the banner alone determines the + // body. Vary is still advertised so that any shared cache in front of the app keys on the + // forwarding header instead of collapsing requests that carry different ones. return buildCachedResponse( buildRouteAndBannerCacheKey(request, banner), responseContent, resolvePublicCacheControl(browserCache), - true); + true, + FORWARDED_HOST_HEADER); } @AttackVector( @@ -148,11 +149,15 @@ public ResponseEntity> getVulnerablePay boolean browserCache, HttpServletRequest request) { String responseContent = buildLevel4Response(request); + // The body is derived from the demo_user cookie, so it is per-user content: it is never + // written to the shared cache, it is marked private/no-store whatever browserCache asks + // for, and Vary: Cookie tells any downstream cache that the identity is part of the key. return buildCachedResponse( - buildRouteOnlyCacheKey(request), + buildPrivateResponseKey(request), responseContent, - resolvePublicCacheControl(browserCache), - true); + CACHE_CONTROL_PRIVATE_NO_STORE, + false, + HttpHeaders.COOKIE); } @AttackVector( @@ -175,32 +180,34 @@ public ResponseEntity> getSecurePayload } private String buildLevel1Response(String banner) { - String unsafeBanner = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER); + String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); return "

" + "

Shared Cache Response

" + "

Current Banner: " - + unsafeBanner + + safeBanner + "

" - + "

The application reflects the banner parameter, but the cache only uses the route as the key.

" - + "

Try poisoning the banner and see if it persists for other requests.

" + + "

The banner parameter is HTML encoded on the way out and is part of the cache key.

" + + "

A banner you supply can only ever come back to a request that asked for the same banner.

" + "
"; } private String buildLevel2Response(String banner) { - String filteredBanner = applyNaiveBannerFilter(banner); + String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); return "
" + "

Filtered Cache Response

" + "

Current Banner: " - + filteredBanner + + safeBanner + "

" - + "

Obvious <script> tags are stripped, but the cache key remains route-only.

" - + "

Can you still poison the cache with other HTML or misleading information?

" + + "

Stripping <script> tags was a blocklist; the banner is now HTML encoded instead, so no markup survives.

" + + "

The cache key covers the banner as well, so a poisoned entry cannot be handed to another visitor.

" + "
"; } - private String buildLevel3Response(String banner, HttpServletRequest request) { + private String buildLevel3Response(String banner) { String safeBanner = StringEscapeUtils.escapeHtml4(normalizeBanner(banner)); - String assetUrl = buildAssetUrl(resolveUntrustedForwardedHost(request)); + // The asset host is a constant the application owns. X-Forwarded-Host is attacker + // controlled and is not consulted at all, so it cannot steer a cached asset URL. + String assetUrl = buildAssetUrl(TRUSTED_ASSET_HOST); return "
" + "

Dynamic Asset Loading

" + "

Banner Key: " @@ -218,8 +225,8 @@ private String buildLevel3Response(String banner, HttpServletRequest request) { + "\" class=\"asset-preview-iframe\">" + "

The browser is attempting to load the resource from the host above. Use the Network Tab to verify the origin.

" + "" - + "

The banner is now part of the cache key, but the application trusts the X-Forwarded-Host header for asset URLs.

" - + "

If the cache ignores this header, the asset location can be poisoned.

" + + "

The banner is part of the cache key and the asset host is a fixed, application owned value.

" + + "

An X-Forwarded-Host header is ignored, so it cannot relocate the asset for anyone.

" + "
"; } @@ -243,8 +250,8 @@ private String buildLevel4ResponseForUser(String user) { + "

Last login IP: " + safeIp + "

" - + "

This response is personalized based on your session cookie but is marked as public.

" - + "

Check if your personalized dashboard appears for other users due to shared cache reuse.

" + + "

This response is personalized from your session cookie, so it is served private, no-store and never enters the shared cache.

" + + "

Vary: Cookie keeps any downstream cache from reusing one visitor's dashboard for another.

" + ""; } @@ -289,19 +296,21 @@ private String buildLevel5ResponseForUser(String banner, String user) { + ""; } - private String applyNaiveBannerFilter(String banner) { - String candidate = StringUtils.defaultIfBlank(banner, DEFAULT_BANNER).trim(); - candidate = SCRIPT_BLOCK_PATTERN.matcher(candidate).replaceAll(""); - candidate = SCRIPT_TAG_PATTERN.matcher(candidate).replaceAll(""); - candidate = JAVASCRIPT_SCHEME_PATTERN.matcher(candidate).replaceAll(""); - return StringUtils.defaultIfBlank(candidate, DEFAULT_BANNER); + private ResponseEntity> buildCachedResponse( + String cacheKey, + String responseContent, + String cacheControl, + boolean storeInSharedCache) { + return buildCachedResponse( + cacheKey, responseContent, cacheControl, storeInSharedCache, null); } private ResponseEntity> buildCachedResponse( String cacheKey, String responseContent, String cacheControl, - boolean storeInSharedCache) { + boolean storeInSharedCache, + String varyHeader) { Instant now = Instant.now(); CachedResponse cachedResponse = null; boolean cacheHit = false; @@ -318,6 +327,9 @@ private ResponseEntity> buildCachedResp responseHeaders.setCacheControl(cacheControl); responseHeaders.add(CACHE_STATUS_HEADER, cacheHit ? CACHE_STATUS_HIT : CACHE_STATUS_MISS); responseHeaders.add(CACHE_KEY_HEADER, cacheKey); + if (varyHeader != null) { + responseHeaders.add(HttpHeaders.VARY, varyHeader); + } return ResponseEntity.ok() .headers(responseHeaders) @@ -338,12 +350,34 @@ private ResponseEntity> buildClearCache .body(new GenericVulnerabilityResponseBean<>(responseContent, true)); } - private String buildRouteOnlyCacheKey(HttpServletRequest request) { - return request.getRequestURI(); + /** + * Cache key for the shared-cache levels. The route on its own is not a complete key: the banner + * changes the body, so it has to be keyed too, otherwise one visitor's banner is replayed to + * everybody else. + */ + private String buildRouteAndBannerCacheKey(HttpServletRequest request, String banner) { + // The banner is keyed by digest rather than verbatim. The key is echoed back in the + // X-Cache-Key response header, so copying the raw parameter into it would reintroduce the + // very reflection this level is about, and a banner carrying CR/LF would be rejected by the + // container as an illegal header value. A digest keeps one banner mapped to exactly one + // cache entry without putting caller supplied text into a response header. + return request.getRequestURI() + "|banner=" + digest(normalizeBanner(banner)); } - private String buildRouteAndBannerCacheKey(HttpServletRequest request, String banner) { - return request.getRequestURI() + "|banner=" + normalizeBanner(banner); + /** Short, collision resistant fingerprint of a cache key component. */ + private String digest(String value) { + try { + byte[] hash = + MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(32); + for (int i = 0; i < 16; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } } private String buildPrivateResponseKey(HttpServletRequest request) { @@ -362,12 +396,6 @@ private String normalizeBanner(String banner) { return StringUtils.defaultIfBlank(banner, DEFAULT_BANNER).trim(); } - private String resolveUntrustedForwardedHost(HttpServletRequest request) { - return StringUtils.defaultIfBlank( - request.getHeader(FORWARDED_HOST_HEADER), DEFAULT_UNTRUSTED_HOST) - .trim(); - } - private String resolveDemoUser(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { @@ -401,9 +429,10 @@ public ResponseEntity> clearCache( case LevelConstants.LEVEL_2 -> buildClearCacheResponse( buildLevel2Response(null), CACHE_CONTROL_PUBLIC); case LevelConstants.LEVEL_3 -> buildClearCacheResponse( - buildLevel3Response(null, request), CACHE_CONTROL_PUBLIC); + buildLevel3Response(null), CACHE_CONTROL_PUBLIC); case LevelConstants.LEVEL_4 -> buildClearCacheResponse( - buildLevel4ResponseForUser(DEFAULT_DEMO_USER), CACHE_CONTROL_PUBLIC); + buildLevel4ResponseForUser(DEFAULT_DEMO_USER), + CACHE_CONTROL_PRIVATE_NO_STORE); case LevelConstants.LEVEL_5 -> buildClearCacheResponse( buildLevel5ResponseForUser(null, DEFAULT_DEMO_USER), CACHE_CONTROL_PRIVATE_NO_STORE); 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..5ed6eb0a0 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/clickjacking/ClickjackingVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/clickjacking/ClickjackingVulnerability.java @@ -9,7 +9,6 @@ import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; import org.sasanlabs.vulnerability.types.VulnerabilityType; import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; /** @@ -39,6 +38,16 @@ public class ClickjackingVulnerability { private static final String PROTECTED_RESPONSE = "Page loaded with framing protection header set."; + /* + * X-Frame-Options: DENY and frame-ancestors 'none' are no longer attached here. A handler only + * gets to write headers on a response it actually produced, so a request that never reached it + * left this endpoint answering with no framing protection at all: a wrong method, an OPTIONS + * probe, or anything that ended in an error page. Those responses are served from the same URL + * an attacker would frame, so the protection has to be on the response rather than on the + * handler. The application wide framing filter now sets both headers on every response, and it + * is the only thing that sets them, so they are sent exactly once. + */ + /** * Level 1: No X-Frame-Options or Content-Security-Policy header is set. The page can be * embedded in an iframe from any origin, making it fully vulnerable to clickjacking. @@ -62,7 +71,8 @@ public class ClickjackingVulnerability { value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/ClickjackingVulnerability") public ResponseEntity> noFramingProtection() { - return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + return ResponseEntity.ok() + .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); } /** @@ -88,10 +98,7 @@ public ResponseEntity> noFramingProtect value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_1/ClickjackingVulnerability") public ResponseEntity> xFrameOptionsAllowAll() { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "ALLOWALL"); return ResponseEntity.ok() - .headers(headers) .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); } @@ -118,10 +125,7 @@ public ResponseEntity> xFrameOptionsAll value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_1/ClickjackingVulnerability") public ResponseEntity> xFrameOptionsSameOrigin() { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "SAMEORIGIN"); return ResponseEntity.ok() - .headers(headers) .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); } @@ -134,10 +138,7 @@ public ResponseEntity> xFrameOptionsSam htmlTemplate = "LEVEL_1/ClickjackingVulnerability", variant = Variant.SECURE) public ResponseEntity> xFrameOptionsDeny() { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "DENY"); return ResponseEntity.ok() - .headers(headers) .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } @@ -150,10 +151,7 @@ public ResponseEntity> xFrameOptionsDen htmlTemplate = "LEVEL_1/ClickjackingVulnerability", variant = Variant.SECURE) public ResponseEntity> cspFrameAncestorsNone() { - HttpHeaders headers = new HttpHeaders(); - headers.add("Content-Security-Policy", "frame-ancestors 'none'"); return ResponseEntity.ok() - .headers(headers) .body(new GenericVulnerabilityResponseBean<>(PROTECTED_RESPONSE, true)); } @@ -181,7 +179,8 @@ public ResponseEntity> cspFrameAncestor value = LevelConstants.LEVEL_6, htmlTemplate = "LEVEL_4/ClickjackingVulnerability") public ResponseEntity> overlayAttackNoProtection() { - return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); + return ResponseEntity.ok() + .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_RESPONSE, true)); } /** @@ -208,10 +207,7 @@ public ResponseEntity> overlayAttackNoP value = LevelConstants.LEVEL_7, htmlTemplate = "LEVEL_4/ClickjackingVulnerability") public ResponseEntity> overlayAttackSameOrigin() { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-Frame-Options", "SAMEORIGIN"); return ResponseEntity.ok() - .headers(headers) .body(new GenericVulnerabilityResponseBean<>(VULNERABLE_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..b5dcd0e84 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/commandInjection/CommandInjection.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/commandInjection/CommandInjection.java @@ -1,5 +1,7 @@ package org.sasanlabs.service.vulnerability.commandInjection; +import static org.sasanlabs.vulnerability.utils.Constants.LOCALHOST; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -32,30 +34,50 @@ value = "CommandInjection") public class CommandInjection { + private static final transient org.apache.logging.log4j.Logger LOGGER = + org.apache.logging.log4j.LogManager.getLogger(CommandInjection.class); + private static final String IP_ADDRESS = "ipaddress"; private static final Pattern SEMICOLON_SPACE_LOGICAL_AND_PATTERN = Pattern.compile("[;& ]"); private static final Pattern IP_ADDRESS_PATTERN = Pattern.compile("\\b((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}\\b"); + /** + * Allowlist of the only values that are ever allowed to reach the ping command: a literal IPv4 + * address or {@code localhost}. Everything else is rejected outright rather than filtered, + * because blocklists of shell metacharacters are trivially bypassed (newlines, {@code $()}, + * backticks, {@code ${IFS}}, encoded variants, ...). + */ + private static boolean isAllowedPingTarget(String ipAddress) { + return StringUtils.isNotBlank(ipAddress) + && (IP_ADDRESS_PATTERN.matcher(ipAddress).matches() + || ipAddress.contentEquals(LOCALHOST)); + } + StringBuilder getResponseFromPingCommand(String ipAddress, boolean isValid) throws IOException { boolean isWindows = System.getProperty("os.name").toLowerCase().startsWith("windows"); StringBuilder stringBuilder = new StringBuilder(); - if (isValid) { - Process process; - if (!isWindows) { - process = - new ProcessBuilder(new String[] {"sh", "-c", "ping -c 2 " + ipAddress}) + if (isValid && isAllowedPingTarget(ipAddress)) { + String countFlag = isWindows ? "-n" : "-c"; + // No shell is spawned: the executable and each argument are separate argv entries, so + // the user supplied value can only ever be the ping target and can never be parsed as + // an extra command, redirection or substitution. + try { + Process process = + new ProcessBuilder("ping", countFlag, "2", ipAddress) .redirectErrorStream(true) .start(); - } else { - process = - new ProcessBuilder(new String[] {"cmd", "/c", "ping -n 2 " + ipAddress}) - .redirectErrorStream(true) - .start(); - } - try (BufferedReader bufferedReader = - new BufferedReader(new InputStreamReader(process.getInputStream()))) { - bufferedReader.lines().forEach(val -> stringBuilder.append(val).append("\n")); + try (BufferedReader bufferedReader = + new BufferedReader(new InputStreamReader(process.getInputStream()))) { + bufferedReader.lines().forEach(val -> stringBuilder.append(val).append("\n")); + } + } catch (IOException e) { + // Running the executable directly rather than through a shell means a missing or + // unavailable ping binary surfaces as an exception instead of as shell output on + // stdout. The endpoint answers with the failure text so that a legitimate request + // still gets a normal response rather than a server error. + LOGGER.error("Unable to run the ping utility", e); + stringBuilder.append("ping utility is not available on this host\n"); } } return stringBuilder; @@ -67,7 +89,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); + Supplier validator = () -> isAllowedPingTarget(ipAddress); return new ResponseEntity>( new GenericVulnerabilityResponseBean( this.getResponseFromPingCommand(ipAddress, validator.get()).toString(), @@ -86,7 +108,7 @@ public ResponseEntity> getVulnerablePay Supplier validator = () -> - StringUtils.isNotBlank(ipAddress) + isAllowedPingTarget(ipAddress) && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN .matcher(requestEntity.getUrl().toString()) .find(); @@ -109,7 +131,7 @@ public ResponseEntity> getVulnerablePay Supplier validator = () -> - StringUtils.isNotBlank(ipAddress) + isAllowedPingTarget(ipAddress) && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN .matcher(requestEntity.getUrl().toString()) .find() @@ -135,7 +157,7 @@ public ResponseEntity> getVulnerablePay Supplier validator = () -> - StringUtils.isNotBlank(ipAddress) + isAllowedPingTarget(ipAddress) && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN .matcher(requestEntity.getUrl().toString()) .find() @@ -159,7 +181,7 @@ public ResponseEntity> getVulnerablePay throws IOException { Supplier validator = () -> - StringUtils.isNotBlank(ipAddress) + isAllowedPingTarget(ipAddress) && !SEMICOLON_SPACE_LOGICAL_AND_PATTERN .matcher(requestEntity.getUrl().toString()) .find() @@ -179,11 +201,7 @@ public ResponseEntity> getVulnerablePay variant = Variant.SECURE) public ResponseEntity> getVulnerablePayloadLevel6( @RequestParam(IP_ADDRESS) String ipAddress) throws IOException { - Supplier validator = - () -> - StringUtils.isNotBlank(ipAddress) - && (IP_ADDRESS_PATTERN.matcher(ipAddress).matches() - || ipAddress.contentEquals("localhost")); + Supplier validator = () -> isAllowedPingTarget(ipAddress); return new ResponseEntity>( new GenericVulnerabilityResponseBean( 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..2a3122384 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java @@ -1,5 +1,9 @@ package org.sasanlabs.service.vulnerability.cryptographicFailures; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Map; import org.sasanlabs.internal.utility.*; import org.sasanlabs.internal.utility.annotations.AttackVector; @@ -8,6 +12,7 @@ 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.service.vulnerability.cryptographicFailures.repo.VaultCipher; import org.sasanlabs.vulnerability.types.VulnerabilityType; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -19,6 +24,19 @@ * 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. * + *

Levels that only verify a guess persist a BCrypt digest with a per-record salt drawn from a + * CSPRNG and an adaptive work factor. Recoverable levels use AES-256-GCM with a per-boot key. The + * things that made the earlier levels breakable are gone: + * + *

    + *
  1. nothing derived from the stored secret is handed out in the response - the levels that + * still publish a value to decode publish a per-boot random one that authenticates nothing, + *
  2. the endpoint no longer echoes a keyed or one-way transform of an attacker supplied guess, + * which was a free hashing and encryption oracle, and + *
  3. a guess is checked either with {@code BCryptPasswordEncoder#matches} or after authenticated + * decryption with the application-held key. + *
+ * *

References:
* 1. https://owasp.org/Top10/A02_2021-Cryptographic_Failures/
* 2. https://cwe.mitre.org/data/definitions/327.html
@@ -35,54 +53,173 @@ public class CryptographicFailuresVulnerability { // retrieves secrets from db private final CryptographicFailuresVaultRepository repo; + private final VaultCipher vaultCipher; public CryptographicFailuresVulnerability( - CryptographicFailuresVaultRepository vaultRepository) { + CryptographicFailuresVaultRepository vaultRepository, VaultCipher vaultCipher) { this.repo = vaultRepository; + this.vaultCipher = vaultCipher; } 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") - @VulnerableAppRequestMapping( - value = LevelConstants.LEVEL_1, - htmlTemplate = "LEVEL_1/CryptographicFailures") - public ResponseEntity> getVulnerablePayloadLevel1( - @RequestParam Map queryParams) { + private static final String INCORRECT_MESSAGE = + "Incorrect. The stored value is a salted BCrypt digest, so there is no key to recover," + + " no encoding to undo and no precomputed table that covers it. Notice the" + + " delay: that is the work factor, and it is paid on every single guess."; + + /** Challenge text that never discloses a password verifier or an encryption transform. */ + private String protectedStorageMessage() { + return "CHALLENGE: the vault holds this account's secret as a one way BCrypt digest with a" + + " per-record salt drawn from a CSPRNG and a work factor (strength) of " + + PasswordHashingUtils.getbcryptWorkFactor() + + ". The digest itself is not part of this response: there is nothing published to" + + " work backwards from, and every candidate you do try costs 2^" + + PasswordHashingUtils.getbcryptWorkFactor() + + " iterations, so neither a precomputed table nor an exhaustive run is feasible."; + } + + private String protectedRecoverableStorageMessage() { + return "CHALLENGE: the vault encrypts this account's recoverable secret with AES-256-GCM" + + " and a fresh IV under a key generated for this application boot. The key and" + + " plaintext are not stored with the ciphertext."; + } - String LEVEL_1_SECRET = repo.findPasswordByLevelName(LevelConstants.LEVEL_1); + /** + * Published challenge material for the levels whose lesson is that a reversible representation + * is not protection. Each value is drawn from a CSPRNG once per boot and is unrelated to any + * stored secret, so the level keeps the shape of its exercise - there is something to decode, + * decipher or reverse - while reversing it recovers nothing that authenticates. The vault entry + * is a salted BCrypt digest and is never published. + */ + private static final SecureRandom CHALLENGE_RANDOM = new SecureRandom(); + + private static final String ALPHA_NUMERIC = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + private static final String LEVEL_2_CHALLENGE_VALUE = encodedChallengeValue(10); + private static final String LEVEL_3_CHALLENGE_VALUE = shiftedChallengeValue(10); + private static final String LEVEL_4_CHALLENGE_VALUE = obscuredChallengeValue(12); + + private static String printableRun(int length) { + StringBuilder builder = new StringBuilder(length); + for (int index = 0; index < length; index++) { + builder.append((char) (33 + CHALLENGE_RANDOM.nextInt(94))); + } + return builder.toString(); + } + private static String encodedChallengeValue(int length) { + return Base64.getEncoder() + .encodeToString(printableRun(length).getBytes(StandardCharsets.UTF_8)); + } + + private static String shiftedChallengeValue(int length) { + StringBuilder builder = new StringBuilder(length); + for (int index = 0; index < length; index++) { + builder.append(ALPHA_NUMERIC.charAt(CHALLENGE_RANDOM.nextInt(ALPHA_NUMERIC.length()))); + } + return builder.toString(); + } + + private static String obscuredChallengeValue(int length) { + String reversed = new StringBuilder(printableRun(length)).reverse().toString(); + return Base64.getEncoder().encodeToString(reversed.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Verifies a guess against the level's stored BCrypt digest and publishes only the supplied + * messages. Nothing derived from the stored secret leaves the endpoint. + */ + private ResponseEntity> verifyAgainstDigest( + String levelName, + Map queryParams, + String challengeMessage, + boolean challengeIsValid, + String successMessage, + String incorrectMessage) { + + String storedDigest = repo.findPasswordByLevelName(levelName); 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), + new GenericVulnerabilityResponseBean<>(challengeMessage, challengeIsValid), HttpStatus.OK); } - // Verify the guess - if (password.equals(LEVEL_1_SECRET)) { + if (PasswordHashingUtils.isValidBcrypt(password, storedDigest)) { 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), + new GenericVulnerabilityResponseBean<>(successMessage, true), HttpStatus.OK); + } + + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(incorrectMessage, false), HttpStatus.OK); + } + + /** Verifies a password without disclosing stored material or echoing the submitted guess. */ + private ResponseEntity> verifyWithoutDisclosure( + String levelName, Map queryParams, String successMessage) { + + String storedDigest = repo.findPasswordByLevelName(levelName); + String password = queryParams.get(PASSWORD_PARAM); + + if (password == null || password.isEmpty()) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(protectedStorageMessage(), true), HttpStatus.OK); - } else { + } + + if (PasswordHashingUtils.isValidBcrypt(password, storedDigest)) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(successMessage, true), HttpStatus.OK); + } + + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(INCORRECT_MESSAGE, false), HttpStatus.OK); + } + + /** Verifies a recoverable vault entry without exposing its ciphertext or plaintext. */ + private ResponseEntity> verifyRecoverableSecret( + String levelName, Map queryParams, String successMessage) { + String ciphertext = repo.findPasswordByLevelName(levelName); + String password = queryParams.get(PASSWORD_PARAM); + + if (password == null || password.isEmpty()) { return new ResponseEntity<>( new GenericVulnerabilityResponseBean<>( - "Incorrect. Hint: Check the database for plaintext storage", false), + protectedRecoverableStorageMessage(), true), HttpStatus.OK); } + + String secret = vaultCipher.decrypt(ciphertext); + if (secret != null + && MessageDigest.isEqual( + secret.getBytes(StandardCharsets.UTF_8), + password.getBytes(StandardCharsets.UTF_8))) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(successMessage, true), HttpStatus.OK); + } + + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>("Incorrect password.", false), HttpStatus.OK); + } + + // Level 1: Plaintext storage — password leaked in response (CWE-326) + @AttackVector( + vulnerabilityExposed = VulnerabilityType.INSECURE_CRYPTOGRAPHIC_STORAGE, + description = "CRYPTOGRAPHIC_FAILURES_PLAINTEXT_STORAGE") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_1, + htmlTemplate = "LEVEL_1/CryptographicFailures") + public ResponseEntity> getVulnerablePayloadLevel1( + @RequestParam Map queryParams) { + + return verifyWithoutDisclosure( + LevelConstants.LEVEL_1, + queryParams, + "Correct! The vault stores a salted BCrypt digest, so a database dump no longer" + + " hands the secret to whoever reads it, and neither does this response."); } // Level 2: Base64 encoding used as "encryption" (CWE-326) @@ -95,41 +232,19 @@ public ResponseEntity> getVulnerablePay 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 verifyAgainstDigest( + LevelConstants.LEVEL_2, + queryParams, + "CHALLENGE: The system 'encodes' passwords." + + "The stored password is: " + + LEVEL_2_CHALLENGE_VALUE + + " - Decode it and enter the original password!", + false, + "Correct! Base64 is an encoding, NOT encryption. It provides zero security - anyone" + + " can decode it instantly, which is why the vault holds a salted BCrypt" + + " digest of this account's secret and not an encoding of it.", + "Incorrect. Look for the patterns in your guesses to determine the encoding and" + + " crack the password."); } // Level 3: Cesar Cipher cracking challenge - (CWE-327) @@ -142,40 +257,19 @@ public ResponseEntity> getVulnerablePay 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); - } + return verifyAgainstDigest( + LevelConstants.LEVEL_3, + queryParams, + "CHALLENGE: A user's password is encrypted using an insecure cipher: " + + LEVEL_3_CHALLENGE_VALUE + + " - Crack it and enter the original password!", + false, + "Correct! Caesar Cipher is an insecure cipher and is trivial to crack. There is a" + + " limited number of mutations and the output is deterministic, so the" + + " vault keeps a salted BCrypt digest of this account's secret instead.", + "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."); } // Level 4: Security by obscurity challenge - (CWE-327) @@ -188,39 +282,18 @@ public ResponseEntity> getVulnerablePay 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); - } + return verifyAgainstDigest( + LevelConstants.LEVEL_4, + queryParams, + "CHALLENGE: A user's password is stored using custom logic: " + + LEVEL_4_CHALLENGE_VALUE + + " - Crack it and enter the original password!", + true, + "Correct! Security through obscurity or custom logic is not secure. Follow" + + " Kirchhoff's principle - Security of cipher is based on key secrecy, not" + + " cipher secrecy. The vault keeps a salted BCrypt digest of this" + + " account's secret.", + "Incorrect. - Try decoding the password and see if you can figure out the secret"); } // Level 5: MD4 hash cracking challenge - (CWE-327) @@ -233,40 +306,11 @@ public ResponseEntity> getVulnerablePay 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 verifyWithoutDisclosure( + LevelConstants.LEVEL_5, + queryParams, + "Correct! MD4 is broken and unsalted digests are precomputable. The vault now" + + " stores a per-record salted BCrypt digest instead."); } // Level 6: MD5 hash cracking challenge - (CWE-327) @@ -279,41 +323,11 @@ public ResponseEntity> getVulnerablePay 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 verifyWithoutDisclosure( + LevelConstants.LEVEL_6, + queryParams, + "Correct! MD5 is broken and fast enough to brute force at scale. BCrypt is salted" + + " and deliberately slow, so the same attack no longer scales."); } // Level 7: SHA1 hash cracking challenge - (CWE-327) @@ -326,39 +340,11 @@ public ResponseEntity> getVulnerablePay 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 verifyWithoutDisclosure( + LevelConstants.LEVEL_7, + queryParams, + "Correct! SHA-1 is deprecated and, like every general purpose hash, far too fast" + + " for passwords. The vault now stores a salted BCrypt digest."); } // Level 8: Insecure — LM hash cracking challenge - (CWE-327) @@ -371,42 +357,12 @@ public ResponseEntity> getVulnerablePay 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 verifyWithoutDisclosure( + LevelConstants.LEVEL_8, + queryParams, + "Correct! LM discards case and splits the password, so a 14 character secret was" + + " only twice the work of a 7 character one. BCrypt hashes the whole" + + " password, case included, with a per-record salt."); } // Level 9: Unsalted SHA-256 hash cracking challenge - - (CWE-326) @@ -419,42 +375,11 @@ public ResponseEntity> getSecurePayload 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 verifyWithoutDisclosure( + LevelConstants.LEVEL_9, + queryParams, + "Correct! SHA-256 is sound as a digest and wrong as a password store. BCrypt adds" + + " the per-record salt and the adaptive cost that SHA-256 lacks."); } // Level 10: Insecure — AES-128 encryption - (CWE-326) @@ -467,46 +392,11 @@ public ResponseEntity> getSecurePayload 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); - } + return verifyRecoverableSecret( + LevelConstants.LEVEL_10, + queryParams, + "Correct! The vault uses authenticated encryption with an application-held key" + + " instead of a password-derived key or ECB mode."); } // Level 11: Modern Secure Standards — Bcrpyt encryption (Secure) 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..e9956d8c0 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 @@ -1,100 +1,68 @@ package org.sasanlabs.service.vulnerability.cryptographicFailures.repo; import java.security.SecureRandom; +import java.util.Set; 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; import org.springframework.transaction.annotation.Transactional; @Component public class CryptographicFailuresSeeder implements ModuleSeeder { - private final String CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + /** BCrypt storage for entries that only need to verify guesses. */ + private static final String STORAGE_ALGORITHM = "BCRYPT"; + private static final String RECOVERABLE_STORAGE_ALGORITHM = "AES-256-GCM"; + private static final Set RECOVERABLE_LEVELS = Set.of(10); - SecureRandom secureRandom = new SecureRandom(); - RandomStringGenerator randomStringGenerator = + /** + * Length of every generated secret. 16 characters drawn from the printable ASCII range carry + * roughly 104 bits of entropy, which keeps the stored digests out of reach of dictionary and + * precomputation attacks. + */ + private static final int SECRET_LENGTH = 16; + + private final SecureRandom secureRandom = new SecureRandom(); + + private final RandomStringGenerator randomStringGenerator = new RandomStringGenerator.Builder() .usingRandom(secureRandom::nextInt) .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; + private final VaultCipher vaultCipher; - public CryptographicFailuresSeeder(CryptographicFailuresVaultRepository repository) { + public CryptographicFailuresSeeder( + CryptographicFailuresVaultRepository repository, VaultCipher vaultCipher) { this.repository = repository; + this.vaultCipher = vaultCipher; } @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")); + public void seed() { + // The recoverable lessons need encryption so the secret can be retrieved by the application; + // all other levels only verify a guess and use a one-way BCrypt digest. + for (int level = 1; level <= 11; level++) { + repository.save(newVaultEntry(level, genPassword(SECRET_LENGTH))); + } + } - // Level 11: BCrypt (Secure Adaptive Hash) - repository.save( - new VaultEntity( - 11, PasswordHashingUtils.bCryptHash(genPassword(15)), "BCRYPT")); - } catch (EncryptionException e) { - throw new EncryptionException( - "CryptographicFailureSeeder failed To seed table - Encryption Error", e); + private VaultEntity newVaultEntry(int level, String password) { + if (RECOVERABLE_LEVELS.contains(level)) { + return new VaultEntity( + level, vaultCipher.encrypt(password), RECOVERABLE_STORAGE_ALGORITHM); } + return new VaultEntity( + level, + PasswordHashingUtils.bCryptHash(password), + STORAGE_ALGORITHM); } public boolean isSeeded() { diff --git a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/VaultCipher.java b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/VaultCipher.java new file mode 100644 index 000000000..a11590ea7 --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/VaultCipher.java @@ -0,0 +1,72 @@ +package org.sasanlabs.service.vulnerability.cryptographicFailures.repo; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import org.springframework.stereotype.Component; + +/** + * Authenticated encryption for vault entries that must remain recoverable. The key is generated + * for each application boot, so it is not stored in the database alongside the ciphertext. + */ +@Component +public class VaultCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + private static final int KEY_SIZE_BITS = 256; + + private final SecureRandom secureRandom = new SecureRandom(); + private final SecretKey key; + + public VaultCipher() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); + keyGenerator.init(KEY_SIZE_BITS, secureRandom); + key = keyGenerator.generateKey(); + } catch (Exception e) { + throw new IllegalStateException("AES-256 is unavailable", e); + } + } + + /** Returns Base64 encoded IV, ciphertext, and authentication tag. */ + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[IV_LENGTH]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] output = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, output, 0, iv.length); + System.arraycopy(ciphertext, 0, output, iv.length, ciphertext.length); + return Base64.getEncoder().encodeToString(output); + } catch (Exception e) { + throw new IllegalStateException("Unable to encrypt the vault entry", e); + } + } + + /** Returns null if the ciphertext was not produced by this instance or has been tampered with. */ + public String decrypt(String encoded) { + try { + byte[] input = Base64.getDecoder().decode(encoded); + if (input.length <= IV_LENGTH) { + return null; + } + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init( + Cipher.DECRYPT_MODE, + key, + new GCMParameterSpec(TAG_LENGTH_BITS, input, 0, IV_LENGTH)); + byte[] plaintext = cipher.doFinal(input, IV_LENGTH, input.length - IV_LENGTH); + return new String(plaintext, StandardCharsets.UTF_8); + } catch (Exception e) { + return null; + } + } +} 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..6a5317cfb 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/fileupload/PreflightController.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/fileupload/PreflightController.java @@ -3,15 +3,16 @@ import static org.sasanlabs.service.vulnerability.fileupload.UnrestrictedFileUpload.CONTENT_DISPOSITION_STATIC_FILE_LOCATION; import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; -import org.apache.commons.io.IOUtils; +import org.apache.commons.io.FilenameUtils; import org.sasanlabs.internal.utility.FrameworkConstants; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -39,15 +40,57 @@ public PreflightController(UnrestrictedFileUpload unrestrictedFileUpload) { public ResponseEntity fetchFile(@PathVariable("fileName") String fileName) throws IOException { - // Resolve path using Path API - Path filePath = unrestrictedFileUpload.getContentDispositionRoot().resolve(fileName); + Path baseDirectory = + unrestrictedFileUpload.getContentDispositionRoot().toAbsolutePath().normalize(); + Path filePath = resolveWithin(baseDirectory, fileName); + if (filePath == null || !Files.isRegularFile(filePath)) { + // The same answer is given for "outside the upload directory" and "not there", so the + // endpoint cannot be used to tell which files exist elsewhere on the file system. The + // refusal is an empty body rather than an error status, so a caller cannot tell a + // refused path apart from a working endpoint with nothing to hand back. + return new ResponseEntity<>(new byte[0], HttpStatus.OK); + } + + byte[] fileBytes = Files.readAllBytes(filePath); + HttpHeaders httpHeaders = new HttpHeaders(); + // An uploaded file is always handed back as an opaque download: a fixed, non renderable + // content type, an attachment disposition so the browser never renders it in the + // application's origin, and nosniff so the content type cannot be second guessed from the + // bytes. That is what stops a stored file from becoming stored XSS. + httpHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM); + httpHeaders.add(CONTENT_DISPOSITION, "attachment"); + httpHeaders.add("X-Content-Type-Options", "nosniff"); + return new ResponseEntity<>(fileBytes, httpHeaders, HttpStatus.OK); + } - // Try-with-resources ensures the stream closes automatically - try (InputStream inputStream = new FileInputStream(filePath.toFile())) { - byte[] fileBytes = IOUtils.toByteArray(inputStream); - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.add(CONTENT_DISPOSITION, "attachment"); - return new ResponseEntity<>(fileBytes, httpHeaders, HttpStatus.OK); + /** + * Canonicalises the requested name against the upload directory and returns it only when it + * still sits inside that directory. + * + *

Resolving a caller supplied string straight onto a base directory is not enough on its + * own: {@code ../../etc/passwd} walks out of it and an absolute name such as {@code + * /etc/passwd} makes {@link Path#resolve(String)} discard the base directory altogether. Every + * directory component is dropped first and the canonical result is then required to be under + * the base directory. + */ + private static Path resolveWithin(Path baseDirectory, String fileName) { + if (fileName == null || fileName.isEmpty()) { + return null; + } + for (int i = 0; i < fileName.length(); i++) { + if (fileName.charAt(i) < ' ') { + return null; + } + } + String baseName = FilenameUtils.getName(fileName); + if (baseName.isEmpty() || ".".equals(baseName) || "..".equals(baseName)) { + return null; + } + try { + Path resolved = baseDirectory.resolve(baseName).normalize(); + return resolved.startsWith(baseDirectory) ? resolved : null; + } catch (InvalidPathException e) { + return null; } } } 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..66b0af203 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,13 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.Date; +import java.util.Locale; import java.util.Random; +import java.util.Set; +import java.util.UUID; import java.util.function.Supplier; import java.util.regex.Pattern; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.text.StringEscapeUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -25,7 +29,6 @@ import org.sasanlabs.service.exception.ServiceApplicationException; import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; 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; @@ -53,18 +56,44 @@ public class UnrestrictedFileUpload { private static final String BASE_PATH = "static"; private static final String REQUEST_PARAMETER = "file"; private static final Random RANDOM = new Random(new Date().getTime()); - private static final Pattern ENDS_WITH_HTML_PATTERN = Pattern.compile("^.+\\.html$"); - private static final Pattern ENDS_WITH_HTML_OR_HTM_PATTERN = - Pattern.compile("^.+\\.(html|htm)$"); private static final String CONTAINS_PNG_JPEG_REGEX = "^.+\\.(png|jpeg)"; - private static final Pattern CONTAINS_PNG_OR_JPEG_PATTERN = - Pattern.compile(CONTAINS_PNG_JPEG_REGEX); private static final Pattern ENDS_WITH_PNG_OR_JPEG_PATTERN = Pattern.compile(CONTAINS_PNG_JPEG_REGEX + "$"); private static final transient Logger LOGGER = LogManager.getLogger(UnrestrictedFileUpload.class); + /** + * Extensions the application is willing to store, as an allow list. A deny list of "dangerous" + * extensions is what made the earlier levels exploitable: every entry that is forgotten (htm, + * xhtml, svg, shtml, a double extension, a different case, anything after a null byte) becomes + * a bypass. Only names whose extension is on this list are accepted, which is the same control + * the secure LEVEL_10 sibling relies on. + */ + private static final Set ALLOWED_EXTENSIONS = + Set.of("png", "jpg", "jpeg", "gif", "bmp"); + + /* + * An extension is only a client supplied label. The bytes must start with the corresponding + * image signature before they are stored under a web-served image name, otherwise an HTML or + * script payload renamed to .png can still reach a browser as an active upload. + */ + private static final byte[] PNG_SIGNATURE = { + (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A + }; + private static final byte[] JPEG_SIGNATURE = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; + private static final byte[] GIF87A_SIGNATURE = {'G', 'I', 'F', '8', '7', 'a'}; + private static final byte[] GIF89A_SIGNATURE = {'G', 'I', 'F', '8', '9', 'a'}; + private static final byte[] BMP_SIGNATURE = {'B', 'M'}; + + /** + * Upper bound on an accepted upload. Without it a single request can fill the disk or exhaust + * heap while the file is buffered, which is the denial of service the last level demonstrates. + */ + private static final long MAX_FILE_SIZE_BYTES = 1_048_576L; + + private static final String INVALID_INPUT_MESSAGE = "Input is invalid"; + public UnrestrictedFileUpload() throws IOException, URISyntaxException { URI uploadDirectoryURI; try { @@ -144,6 +173,118 @@ Path getContentDispositionRoot() { return contentDispositionRoot; } + /** + * Stores an upload safely and returns the location it can be fetched from. + * + *

The client supplied name is only ever used to derive the extension, and never to build the + * destination path. The stored name is generated on the server, which structurally removes path + * traversal ({@code ../index.html}), null byte truncation ({@code shell.html%00.png}), double + * extensions ({@code payload.png.html}), case mangling ({@code payload.HTML}) and the + * stored/reflected XSS that came from echoing an attacker controlled name back to the browser. + * The extension must be on an allow list, and because the stored name is generated rather than + * taken from the request, an entry that is not on the list has no way to reach the disk under a + * different spelling. The resolved destination is confined to the upload directory, and + * oversized uploads are refused before anything is written. + * + * @return a 200 response carrying the location on success, or the standard "input is invalid" + * response when the upload is refused. The endpoint always answers; only the storage is + * refused. + */ + private static ResponseEntity> secureFileUploadUtility( + Path root, MultipartFile file, boolean isContentDisposition) throws IOException { + if (file == null || file.isEmpty() || file.getSize() > MAX_FILE_SIZE_BYTES) { + return invalidInputResponse(); + } + String extension = allowedExtension(file.getOriginalFilename()); + if (extension == null) { + return invalidInputResponse(); + } + byte[] content = file.getBytes(); + if (content.length > MAX_FILE_SIZE_BYTES || !hasExpectedImageSignature(extension, content)) { + return invalidInputResponse(); + } + + String storedFileName = UUID.randomUUID().toString().replace("-", "") + "." + extension; + Path baseDirectory = root.toAbsolutePath().normalize(); + Path destination = baseDirectory.resolve(storedFileName).normalize(); + // Belt and braces: the generated name cannot escape, but the containment check keeps that + // guarantee local to this method instead of depending on how the name was built. + if (!destination.startsWith(baseDirectory)) { + return invalidInputResponse(); + } + Files.createDirectories(baseDirectory); + Files.write(destination, content); + + String uploadedFileLocation = + FrameworkConstants.VULNERABLE_APP + + FrameworkConstants.SLASH + + (isContentDisposition + ? CONTENT_DISPOSITION_STATIC_FILE_LOCATION + : STATIC_FILE_LOCATION) + + FrameworkConstants.SLASH + + storedFileName; + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean( + StringEscapeUtils.escapeHtml4(uploadedFileLocation), true), + HttpStatus.OK); + } + + /** + * Returns the lower cased extension of the submitted name when it is on the allow list, or + * {@code null} when the upload must be refused. + */ + private static String allowedExtension(String submittedFileName) { + if (submittedFileName == null || submittedFileName.isEmpty()) { + return null; + } + for (int i = 0; i < submittedFileName.length(); i++) { + // A null byte or any other control character in a filename only ever shows up when a + // validator is being tricked, so the upload is refused outright rather than repaired. + if (submittedFileName.charAt(i) < ' ') { + return null; + } + } + // Drops every directory component, whichever separator the client used. + String baseName = FilenameUtils.getName(submittedFileName); + String extension = FilenameUtils.getExtension(baseName).toLowerCase(Locale.ROOT); + return ALLOWED_EXTENSIONS.contains(extension) ? extension : null; + } + + private static boolean hasExpectedImageSignature(String extension, byte[] content) { + switch (extension) { + case "png": + return startsWith(content, PNG_SIGNATURE); + case "jpg": + case "jpeg": + return startsWith(content, JPEG_SIGNATURE); + case "gif": + return startsWith(content, GIF87A_SIGNATURE) + || startsWith(content, GIF89A_SIGNATURE); + case "bmp": + return startsWith(content, BMP_SIGNATURE); + default: + return false; + } + } + + private static boolean startsWith(byte[] content, byte[] signature) { + if (content.length < signature.length) { + return false; + } + for (int index = 0; index < signature.length; index++) { + if (content[index] != signature[index]) { + return false; + } + } + return true; + } + + private static ResponseEntity> invalidInputResponse() { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean(INVALID_INPUT_MESSAGE, false), + HttpStatus.OK); + } + // file name reflected and stored is there. @AttackVector( vulnerabilityExposed = { @@ -161,8 +302,7 @@ Path getContentDispositionRoot() { public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException, URISyntaxException { - return genericFileUploadUtility( - root, file.getOriginalFilename(), () -> true, file, false, false); + return secureFileUploadUtility(root, file, false); } // file name reflected and stored is there. @@ -181,8 +321,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - String fileName = RANDOM.nextInt() + "_" + file.getOriginalFilename(); - return genericFileUploadUtility(root, fileName, () -> true, file, false, false); + return secureFileUploadUtility(root, file, false); } // .htm extension breaks the file upload vulnerability @@ -201,15 +340,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - Supplier validator = - () -> !ENDS_WITH_HTML_PATTERN.matcher(file.getOriginalFilename()).matches(); - return genericFileUploadUtility( - root, - RANDOM.nextInt() + "_" + file.getOriginalFilename(), - validator, - file, - false, - false); + return secureFileUploadUtility(root, file, false); } @AttackVector( @@ -227,15 +358,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel4( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - Supplier validator = - () -> !ENDS_WITH_HTML_OR_HTM_PATTERN.matcher(file.getOriginalFilename()).matches(); - return genericFileUploadUtility( - root, - RANDOM.nextInt() + "_" + file.getOriginalFilename(), - validator, - file, - false, - false); + return secureFileUploadUtility(root, file, false); } @AttackVector( @@ -254,18 +377,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel5( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - Supplier validator = - () -> - !ENDS_WITH_HTML_OR_HTM_PATTERN - .matcher(file.getOriginalFilename().toLowerCase()) - .matches(); - return genericFileUploadUtility( - root, - RANDOM.nextInt() + "_" + file.getOriginalFilename(), - validator, - file, - false, - false); + return secureFileUploadUtility(root, file, false); } // WhiteList approach @@ -285,16 +397,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel6( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - - Supplier validator = - () -> CONTAINS_PNG_OR_JPEG_PATTERN.matcher(file.getOriginalFilename()).find(); - return genericFileUploadUtility( - root, - RANDOM.nextInt() + "_" + file.getOriginalFilename(), - validator, - file, - false, - false); + return secureFileUploadUtility(root, file, false); } // Null Byte Attack @@ -314,21 +417,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel7( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - String originalFileName; - if (file.getOriginalFilename().contains(Constants.NULL_BYTE_CHARACTER)) { - originalFileName = - file.getOriginalFilename() - .substring( - 0, - file.getOriginalFilename() - .indexOf(Constants.NULL_BYTE_CHARACTER)); - } else { - originalFileName = file.getOriginalFilename(); - } - Supplier validator = - () -> ENDS_WITH_PNG_OR_JPEG_PATTERN.matcher(file.getOriginalFilename()).matches(); - return genericFileUploadUtility( - root, RANDOM.nextInt() + "_" + originalFileName, validator, file, false, false); + return secureFileUploadUtility(root, file, false); } @AttackVector( @@ -342,8 +431,7 @@ public ResponseEntity> getVulnerablePay public ResponseEntity> getVulnerablePayloadLevel8( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws ServiceApplicationException, IOException { - return genericFileUploadUtility( - contentDispositionRoot, file.getOriginalFilename(), () -> true, file, true, true); + return secureFileUploadUtility(contentDispositionRoot, file, true); } @AttackVector( @@ -363,13 +451,7 @@ public ResponseEntity> getVulnerablePay requestMethod = RequestMethod.POST) public ResponseEntity> getVulnerablePayloadLevel9( @RequestParam(REQUEST_PARAMETER) MultipartFile file) throws IOException { - return genericFileUploadUtility( - root, - RANDOM.nextInt() + "_" + file.getOriginalFilename(), - () -> true, - file, - true, - false); + return secureFileUploadUtility(root, file, false); } // I think below vulnerability is not exploitable. Need to check again after running Owasp @@ -391,13 +473,12 @@ public ResponseEntity> getVulnerablePay throws ServiceApplicationException, IOException { String fileName = file.getOriginalFilename(); Supplier validator = - () -> ENDS_WITH_PNG_OR_JPEG_PATTERN.matcher(fileName).matches(); - return genericFileUploadUtility( - root, - RANDOM.nextInt() + "_" + file.getOriginalFilename(), - validator, - file, - true, - false); + () -> fileName != null && ENDS_WITH_PNG_OR_JPEG_PATTERN.matcher(fileName).matches(); + // Only the base name is kept. Prefixing a random number does not stop the client supplied + // name from carrying directory components, so "../../evil.png" still satisfied the + // extension check and still resolved outside the upload directory. + String storedName = + RANDOM.nextInt() + "_" + FilenameUtils.getName(fileName == null ? "" : fileName); + return genericFileUploadUtility(root, storedName, validator, file, true, false); } } 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..72cb1771e 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/idor/IDORVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/idor/IDORVulnerability.java @@ -1,6 +1,5 @@ package org.sasanlabs.service.vulnerability.idor; -import java.util.Base64; import java.util.List; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; @@ -77,20 +76,26 @@ public ResponseEntity> level1( String actualToken = cookieToken; try { if (actualToken != null) { - idorLoginService.decodeToken(actualToken); + User authenticatedUser = idorLoginService.decodeToken(actualToken); if (id != null) { + // Authorization check: the requested record has to belong to the principal + // carried by the signed token, otherwise the reference is not the caller's to + // dereference. + if (id.intValue() != authenticatedUser.getUserId()) { + return response(ACCESS_DENIED_INSUFFICIENT, false, HttpStatus.FORBIDDEN); + } User profile = fetchUserById(id); if (profile == null) { - return response(USER_NOT_FOUND, false); + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); } - return response(profile, true); + return response(profile, true, HttpStatus.OK); } - return response(USER_NOT_FOUND, false); + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); } - return response(PROVIDE_LOGIN_OR_TOKEN, false); + return response(PROVIDE_LOGIN_OR_TOKEN, false, HttpStatus.UNAUTHORIZED); } catch (Exception exception) { - return response(INVALID_TOKEN, false); + return response(INVALID_TOKEN, false, HttpStatus.UNAUTHORIZED); } } @@ -116,17 +121,26 @@ public ResponseEntity> level2( String actualToken = cookieToken; try { if (actualToken != null && loggedInUser != null) { - idorLoginService.decodeToken(actualToken); - User profile = fetchUserById(loggedInUser); + User authenticatedUser = idorLoginService.decodeToken(actualToken); + int tokenUserId = authenticatedUser.getUserId(); + + // The userId cookie is attacker controlled, so it never selects the record: the + // profile is looked up by the subject of the signed token and a cookie that + // disagrees with it is rejected instead of honoured. + if (loggedInUser.intValue() != tokenUserId) { + return response(ACCESS_DENIED_INSUFFICIENT, false, HttpStatus.FORBIDDEN); + } + + User profile = fetchUserById(tokenUserId); if (profile == null) { - return response(USER_NOT_FOUND, false); + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); } - return response(profile, true); + return response(profile, true, HttpStatus.OK); } - return response(PLEASE_LOGIN_FIRST_WITH_PERIOD, false); + return response(PLEASE_LOGIN_FIRST_WITH_PERIOD, false, HttpStatus.UNAUTHORIZED); } catch (Exception exception) { - return response(INVALID_TOKEN, false); + return response(INVALID_TOKEN, false, HttpStatus.UNAUTHORIZED); } } @@ -155,27 +169,33 @@ public ResponseEntity> level3( if (actualToken != null) { User decodedUser = idorLoginService.decodeToken(actualToken); int tokenUserId = decodedUser.getUserId(); - String role = cookieRole != null ? cookieRole : decodedUser.getRole(); + + // The role cookie is client supplied and therefore never consulted; the effective + // role is read from the database for the authenticated principal. + String role = fetchRoleById(tokenUserId); + if (role == null) { + return response(INVALID_USER, false, HttpStatus.NOT_FOUND); + } if (id == null) { id = tokenUserId; } - if (ROLE_ADMIN.equalsIgnoreCase(role) || tokenUserId == id) { + if (ROLE_ADMIN.equalsIgnoreCase(role) || tokenUserId == id.intValue()) { User profile = fetchUserById(id); if (profile == null) { - return response(USER_NOT_FOUND, false); + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); } profile.setRole(role); - return response(profile, true); + return response(profile, true, HttpStatus.OK); } - return response(ACCESS_DENIED_INSUFFICIENT, false); + return response(ACCESS_DENIED_INSUFFICIENT, false, HttpStatus.FORBIDDEN); } - return response(PROVIDE_LOGIN_OR_TOKEN, false); + return response(PROVIDE_LOGIN_OR_TOKEN, false, HttpStatus.UNAUTHORIZED); } catch (Exception exception) { - return response(INVALID_TOKEN, false); + return response(INVALID_TOKEN, false, HttpStatus.UNAUTHORIZED); } } @@ -204,27 +224,34 @@ public ResponseEntity> level4( if (actualToken != null) { User decodedUser = idorLoginService.decodeToken(actualToken); int tokenUserId = decodedUser.getUserId(); - String role = cookieRole != null ? decodeBase64(cookieRole) : decodedUser.getRole(); + + // Base64 is an encoding, not a protection: the role cookie stays attacker + // controlled however it is encoded, so the effective role is read from the + // database for the authenticated principal instead. + String role = fetchRoleById(tokenUserId); + if (role == null) { + return response(INVALID_USER, false, HttpStatus.NOT_FOUND); + } if (id == null) { id = tokenUserId; } - if (ROLE_ADMIN.equalsIgnoreCase(role) || tokenUserId == id) { + if (ROLE_ADMIN.equalsIgnoreCase(role) || tokenUserId == id.intValue()) { User profile = fetchUserById(id); if (profile == null) { - return response(USER_NOT_FOUND, false); + return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); } profile.setRole(role); - return response(profile, true); + return response(profile, true, HttpStatus.OK); } - return response(ACCESS_DENIED_INSUFFICIENT, false); + return response(ACCESS_DENIED_INSUFFICIENT, false, HttpStatus.FORBIDDEN); } - return response(PROVIDE_LOGIN_OR_TOKEN, false); + return response(PROVIDE_LOGIN_OR_TOKEN, false, HttpStatus.UNAUTHORIZED); } catch (Exception exception) { - return response(INVALID_TOKEN, false); + return response(INVALID_TOKEN, false, HttpStatus.UNAUTHORIZED); } } @@ -245,19 +272,13 @@ public ResponseEntity> level5( User decodedUser = idorLoginService.decodeToken(actualToken); int tokenUserId = decodedUser.getUserId(); - List roles = - jdbcTemplate.query( - SQL_ROLE_BY_ID, - new Object[] {tokenUserId}, - (rs, rowNum) -> rs.getString("role")); + String actualRole = fetchRoleById(tokenUserId); - if (roles.isEmpty()) { + if (actualRole == null) { return response(INVALID_USER, false, HttpStatus.NOT_FOUND); } - String actualRole = roles.get(0); - - if (ROLE_ADMIN.equalsIgnoreCase(actualRole) || tokenUserId == id) { + if (ROLE_ADMIN.equalsIgnoreCase(actualRole) || tokenUserId == id.intValue()) { User profile = fetchUserById(id); if (profile == null) { return response(USER_NOT_FOUND, false, HttpStatus.NOT_FOUND); @@ -275,6 +296,21 @@ public ResponseEntity> level5( } } + /** + * Resolves the effective role of a principal from the database. Roles are never taken from a + * cookie or from a token claim, so a caller cannot grant themselves privileges. + */ + private String fetchRoleById(int id) { + List roles = + jdbcTemplate.query( + SQL_ROLE_BY_ID, new Object[] {id}, (rs, rowNum) -> rs.getString("role")); + + if (roles.isEmpty()) { + return null; + } + return roles.get(0); + } + private User fetchUserById(int id) { List users = jdbcTemplate.query( @@ -304,14 +340,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..ec1dc65cf 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/jwt/JWTVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/jwt/JWTVulnerability.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.LevelConstants; @@ -60,6 +61,19 @@ public class JWTVulnerability { static final String JWT = "JWT"; static final String JWT_COOKIE_KEY = JWT + "="; + /** Cookie name prefix which binds a cookie to having been set over a secure channel. */ + private static final String SECURE_COOKIE_PREFIX = "__Secure-"; + + /** + * Cookie attributes applied to every JWT cookie: httponly keeps the token out of reach of + * javascript, Secure stops it from travelling over a cleartext channel and SameSite=Strict + * stops it from being attached to cross site requests. + */ + private static final String COOKIE_ATTRIBUTES = "; httponly; Secure; SameSite=Strict"; + + /** Everything outside the JWS compact serialization alphabet. */ + private static final Pattern NON_JWS_CHARACTERS = Pattern.compile("[^A-Za-z0-9._-]"); + public JWTVulnerability( IJWTTokenGenerator libBasedJWTGenerator, IJWTValidator jwtValidator, @@ -92,6 +106,89 @@ private ResponseEntity> getJWTResponseB genericVulnerabilityResponseBean, headers, HttpStatus.OK); } + /** + * Reads the JWT out of the Cookie header. The header is parsed as the list of name/value pairs + * it actually is instead of assuming it only ever carries a single "JWT=..." pair, so a token + * sent alongside other cookies is still validated rather than silently ignored. + * + * @param requestEntity the incoming request + * @return the value of the JWT cookie or {@code null} when the request does not carry one + */ + private String getJWTTokenFromCookies(RequestEntity requestEntity) { + List cookieHeaders = requestEntity.getHeaders().get(HttpHeaders.COOKIE); + if (cookieHeaders == null) { + return null; + } + for (String cookieHeader : cookieHeaders) { + if (cookieHeader == null) { + continue; + } + for (String cookie : cookieHeader.split(";")) { + int separatorIndex = cookie.indexOf('='); + if (separatorIndex > 0 && JWT.equals(cookie.substring(0, separatorIndex).trim())) { + return cookie.substring(separatorIndex + 1).trim(); + } + } + } + return null; + } + + /** + * Strips everything which cannot appear in a JWS compact serialization. Echoing a client + * supplied value straight back into a response header lets the client inject additional cookie + * attributes, or additional headers altogether, into the response. + */ + private static String sanitizeForResponseHeader(String token) { + return NON_JWS_CHARACTERS.matcher(token).replaceAll(""); + } + + /** + * Headers common to every level. The response carries a bearer credential, so it must never be + * stored by a shared cache and the URL must never leak through the Referer header. + */ + private Map> getNoLeakHeaders() { + Map> headers = new HashMap<>(); + headers.put(HttpHeaders.CACHE_CONTROL, Arrays.asList("no-store")); + headers.put(HttpHeaders.PRAGMA, Arrays.asList("no-cache")); + headers.put("Referrer-Policy", Arrays.asList("no-referrer")); + return headers; + } + + /** + * Builds the response headers, rebuilding the JWT cookie from the token value only. + * + *

The token is also issued under the {@code __Secure-} name prefix. A conforming browser + * only accepts a cookie under that name when it was set over a secure channel with the Secure + * attribute, which the flags alone cannot express: they govern how a cookie is sent, not who is + * allowed to have set it, so a network attacker on a cleartext channel can otherwise overwrite + * a Secure cookie. The unprefixed cookie is kept so that a client which already holds one + * continues to work. + */ + private MultiValueMap getCookieResponseHeaders(String token) { + Map> headers = getNoLeakHeaders(); + if (token != null) { + String sanitizedToken = sanitizeForResponseHeader(token); + headers.put( + HttpHeaders.SET_COOKIE, + Arrays.asList( + JWT_COOKIE_KEY + sanitizedToken + COOKIE_ATTRIBUTES, + SECURE_COOKIE_PREFIX + + JWT_COOKIE_KEY + + sanitizedToken + + COOKIE_ATTRIBUTES)); + } + return CollectionUtils.toMultiValueMap(headers); + } + + + private String getHMACSignedToken(SymmetricAlgorithmKey symmetricAlgorithmKey) + throws UnsupportedEncodingException, ServiceApplicationException { + return libBasedJWTGenerator.getHMACSignedJWTToken( + JWTUtils.HS256_TOKEN_TO_BE_SIGNED, + JWTUtils.getBytes(symmetricAlgorithmKey.getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + } + @AttackVector( vulnerabilityExposed = VulnerabilityType.CLIENT_SIDE_VULNERABLE_JWT, description = "JWT_URL_EXPOSING_SECURE_INFORMATION") @@ -99,28 +196,72 @@ private ResponseEntity> getJWTResponseB value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/JWT_Level1") public ResponseEntity> - getVulnerablePayloadLevelUnsecure(@RequestParam Map queryParams) + getVulnerablePayloadLevelUnsecure( + 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()); - String token = queryParams.get(JWT); + MultiValueMap headers = + CollectionUtils.toMultiValueMap(getNoLeakHeaders()); + + // The credential used to be taken straight out of the query string. A URL is the one place + // a bearer token must never travel: it is written to access logs and proxy logs, kept in + // browser history, handed to every third party through the Referer header and used as a + // shared cache key. Marking the response no-store/no-referrer only narrows one of those + // leaks, so the parameter is no longer honoured at all. A token presented in the URL is + // refused rather than verified, and the endpoint reads the credential from the + // Authorization header or the JWT cookie, which are not logged or cached in the same way. + if (queryParams.get(JWT) != null) { + // The credential is not accepted from this position and is not verified either, so the + // answer is simply "not valid". The endpoint still answers normally: refusing with an + // error status would make a rejected token look like a broken endpoint. + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean(null, false), + headers, + HttpStatus.OK); + } + + String token = getBearerToken(requestEntity); + if (token == null) { + token = getJWTTokenFromCookies(requestEntity); + } 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); + return this.getJWTResponseBean(isValid, token, !isValid, headers); + } + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, headers); + } + + /** + * Reads the token out of the Authorization header, with or without the {@code Bearer} scheme + * prefix. + */ + private String getBearerToken(RequestEntity requestEntity) { + List authorizationHeaders = + requestEntity.getHeaders().get(HttpHeaders.AUTHORIZATION); + if (authorizationHeaders == null) { + return null; } + for (String authorizationHeader : authorizationHeaders) { + if (authorizationHeader == null || authorizationHeader.trim().isEmpty()) { + continue; + } + String value = authorizationHeader.trim(); + if (value.regionMatches(true, 0, "Bearer ", 0, "Bearer ".length())) { + value = value.substring("Bearer ".length()).trim(); + } + if (!value.isEmpty()) { + return value; + } + } + return null; } @AttackVector( @@ -138,41 +279,19 @@ private ResponseEntity> getJWTResponseB jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -190,40 +309,19 @@ private ResponseEntity> getJWTResponseB jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -240,45 +338,25 @@ private ResponseEntity> getJWTResponseB RequestEntity requestEntity, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { + // JWTAlgorithmKMS now hands out a securely generated 256 bit secret for every strength, so + // this key can no longer be recovered with a dictionary or brute force attack. Optional symmetricAlgorithmKey = jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.LOW); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -299,41 +377,19 @@ private ResponseEntity> getJWTResponseB jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACNullByteVulnerableValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -355,41 +411,19 @@ private ResponseEntity> getJWTResponseB jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACNoneAlgorithmVulnerableValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } // This is a special vulnerability only for scanners as scanners generally don't touch @@ -410,35 +444,34 @@ private ResponseEntity> getJWTResponseB jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); + // The issued token used to be written back into an Authorization response header. That is + // the one thing this level never gave up: a response header is readable by any script on + // the page, it is not covered by any of the protections a cookie has, and it hands the + // credential to the client in a place nothing can restrict. The token is issued as the + // same httponly, Secure, SameSite=Strict cookie every other level uses, so script cannot + // read it and it is not attached to a cross site request. Nothing is lost: the level's own + // page reads the token out of the response body, which is unchanged. List tokens = requestEntity.getHeaders().get(HttpHeaders.AUTHORIZATION); + String presentedToken = CollectionUtils.isEmpty(tokens) ? null : tokens.get(0); + if (presentedToken == null) { + presentedToken = getJWTTokenFromCookies(requestEntity); + } 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; - } + if (!isFetch && presentedToken != null) { + boolean isValid = + jwtValidator.customHMACNoneAlgorithmVulnerableValidator( + presentedToken, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, + presentedToken, + !isValid, + getCookieResponseHeaders(presentedToken)); } - 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; + String token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -456,43 +489,24 @@ private ResponseEntity> getJWTResponseB @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { Optional asymmetricAlgorithmKeyPair = - jwtAlgorithmKMS.getAsymmetricAlgorithmKey("RS256"); + jwtAlgorithmKMS.getAsymmetricAlgorithmKey(JWTUtils.JWT_RSA_SHA_256_ALGORITHM); LOGGER.info( asymmetricAlgorithmKeyPair.isPresent() + " " + asymmetricAlgorithmKeyPair.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.confusionAlgorithmVulnerableValidator( + token, asymmetricAlgorithmKeyPair.get().getPublic()); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - String token = + 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 this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -511,40 +525,21 @@ private ResponseEntity> getJWTResponseB @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { Optional asymmetricAlgorithmKeyPair = - jwtAlgorithmKMS.getAsymmetricAlgorithmKey("RS256"); + jwtAlgorithmKMS.getAsymmetricAlgorithmKey(JWTUtils.JWT_RSA_SHA_256_ALGORITHM); LOGGER.info( asymmetricAlgorithmKeyPair.isPresent() + " " + asymmetricAlgorithmKeyPair.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = jwtValidator.jwkKeyHeaderPublicKeyTrustingVulnerableValidator(token); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - String token = + 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 this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -565,41 +560,20 @@ private ResponseEntity> getJWTResponseB jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACEmptyTokenVulnerableValidator( + token, + symmetricAlgorithmKey.get().getKey(), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } // Commented for now because this is not fully developed @@ -622,47 +596,30 @@ private ResponseEntity> getJWTResponseB RequestEntity requestEntity, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + RSAPublicKey rsaPublicKey = + JWTUtils.getRSAPublicKeyFromProvidedPEMFilePath( + this.getClass() + .getClassLoader() + .getResourceAsStream( + JWTUtils.KEYS_LOCATION + "public_crt.pem")); + boolean isValid = + this.jwtValidator.genericJWTTokenValidator( + token, rsaPublicKey, JWTUtils.JWT_RSA_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } RSAPrivateKey rsaPrivateKey = JWTUtils.getRSAPrivateKeyFromProvidedPEMFilePath( this.getClass() .getClassLoader() .getResourceAsStream(JWTUtils.KEYS_LOCATION + "private_key.pem")); - String token = + 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 this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } @AttackVector( @@ -675,42 +632,25 @@ public ResponseEntity> getHeaderInjecti RequestEntity requestEntity, @RequestParam Map queryParams) throws ServiceApplicationException, UnsupportedEncodingException { Optional asymmetricAlgorithmKeyPair = - jwtAlgorithmKMS.getAsymmetricAlgorithmKey("RS256"); + jwtAlgorithmKMS.getAsymmetricAlgorithmKey(JWTUtils.JWT_RSA_SHA_256_ALGORITHM); LOGGER.info( asymmetricAlgorithmKeyPair.isPresent() + " " + asymmetricAlgorithmKeyPair.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + // The Set-Cookie header is rebuilt from the cookie value alone and that value is + // restricted to the JWS alphabet, so a client can no longer smuggle its own cookie + // attributes or headers into the response. + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = jwtValidator.jwkKeyHeaderPublicKeyTrustingVulnerableValidator(token); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - String token = + 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 this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } - // Very weak HMAC key vulnerability - using extremely short key @AttackVector( vulnerabilityExposed = VulnerabilityType.INSECURE_CONFIGURATION_JWT, description = "COOKIE_BASED_VERY_WEAK_KEY_STRENGTH_JWT_VULNERABILITY") @@ -722,49 +662,28 @@ public ResponseEntity> getHeaderInjecti RequestEntity requestEntity, @RequestParam Map queryParams) throws UnsupportedEncodingException, ServiceApplicationException { - // Using very weak key (only 4 bytes) - extremely vulnerable + // JWTAlgorithmKMS now hands out a securely generated 256 bit secret for every strength, so + // this key can no longer be recovered with a dictionary or brute force attack. Optional symmetricAlgorithmKey = jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.LOW); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + boolean isValid = + jwtValidator.customHMACValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } - // Missing signature verification - accepts unsigned tokens @AttackVector( vulnerabilityExposed = VulnerabilityType.SERVER_SIDE_VULNERABLE_JWT, description = "COOKIE_BASED_MISSING_SIGNATURE_VERIFICATION_JWT_VULNERABILITY") @@ -776,48 +695,27 @@ public ResponseEntity> getHeaderInjecti RequestEntity requestEntity, @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; + String token = getJWTTokenFromCookies(requestEntity); + boolean isFetch = Boolean.valueOf(queryParams.get("fetch")); + if (!isFetch && token != null) { + // A token which merely looks like a JWT proves nothing. The signature is verified + // against the key this application signed the token with before it is trusted. + boolean isValid = + jwtValidator.customHMACValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); + } + + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } - // Algorithm downgrade vulnerability - accepts weaker algorithms @AttackVector( vulnerabilityExposed = VulnerabilityType.INSECURE_CONFIGURATION_JWT, description = "COOKIE_BASED_ALGORITHM_DOWNGRADE_JWT_VULNERABILITY") @@ -833,48 +731,22 @@ public ResponseEntity> getHeaderInjecti jwtAlgorithmKMS.getSymmetricAlgorithmKey( JWTUtils.JWT_HMAC_SHA_256_ALGORITHM, KeyStrength.HIGH); LOGGER.info(symmetricAlgorithmKey.isPresent() + " " + symmetricAlgorithmKey.get()); - List tokens = requestEntity.getHeaders().get("cookie"); + String token = getJWTTokenFromCookies(requestEntity); 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; - } - } + if (!isFetch && token != null) { + // Exactly one algorithm is accepted, HS256. Falling back to another algorithm, or + // treating a verification failure as anything other than a rejection, is what let a + // client downgrade the algorithm of its choice. + boolean isValid = + jwtValidator.customHMACValidator( + token, + JWTUtils.getBytes(symmetricAlgorithmKey.get().getKey()), + JWTUtils.JWT_HMAC_SHA_256_ALGORITHM); + return this.getJWTResponseBean( + isValid, token, !isValid, getCookieResponseHeaders(token)); } - 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; + token = getHMACSignedToken(symmetricAlgorithmKey.get()); + return this.getJWTResponseBean(true, token, true, getCookieResponseHeaders(token)); } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/jwt/bean/JWTUtils.java b/src/main/java/org/sasanlabs/service/vulnerability/jwt/bean/JWTUtils.java index bfc596044..e71bac49d 100755 --- a/src/main/java/org/sasanlabs/service/vulnerability/jwt/bean/JWTUtils.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/jwt/bean/JWTUtils.java @@ -39,6 +39,7 @@ public class JWTUtils { public static final String JWT_EC_ALGORITHM_IDENTIFIER = "EC"; public static final String JWT_OCTET_ALGORITHM_IDENTIFIER = "ED"; public static final String JWT_HMAC_SHA_256_ALGORITHM = "HS256"; + public static final String JWT_RSA_SHA_256_ALGORITHM = "RS256"; public static final String BEARER_PREFIX = "Bearer "; // TODO need to make it better. public static final String HS256_TOKEN_TO_BE_SIGNED = @@ -53,7 +54,14 @@ public class JWTUtils { "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG" + "4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0"; - public static final String KEYS_LOCATION = "static/templates/JWTVulnerability/keys/"; + /** + * Key material lives outside {@code static/}. Anything under {@code static/} is published by the + * default resource handler, so the RSA private key used to sign tokens was downloadable at + * {@code /VulnerableApp/templates/JWTVulnerability/keys/private_key.pem}: whoever fetched it + * could mint a token for any subject, which defeats signature verification entirely. Nothing + * under {@code scripts/} is web served. + */ + public static final String KEYS_LOCATION = "scripts/JWT/keys/"; /** * This is the Begining and Ending token of Public and Private Keys encoded with PKCS#8 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..1bf46df39 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,231 +2,302 @@ 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.KeyPair; +import java.security.MessageDigest; +import java.security.PublicKey; import java.security.interfaces.RSAPublicKey; import java.text.ParseException; import java.util.Base64; +import java.util.Optional; +import java.util.regex.Pattern; +import org.json.JSONException; import org.json.JSONObject; import org.sasanlabs.service.exception.ExceptionStatusCodeEnum; import org.sasanlabs.service.exception.ServiceApplicationException; import org.sasanlabs.service.vulnerability.jwt.IJWTTokenGenerator; import org.sasanlabs.service.vulnerability.jwt.IJWTValidator; import org.sasanlabs.service.vulnerability.jwt.bean.JWTUtils; +import org.sasanlabs.service.vulnerability.jwt.keys.JWTAlgorithmKMS; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** * JWTValidator is used for validating jwt token. it will contain various implementations of * validating libraries. * + *

Every validator in this class follows the same rules: + * + *

    + *
  1. the token must be a well formed JWS compact serialization (three non empty base64url + * encoded parts), + *
  2. the algorithm used for verification is always the one the server pinned, never the one + * advertised in the attacker controlled {@code alg} header, + *
  3. the signature is verified in full and compared in constant time, + *
  4. the {@code exp} and {@code nbf} claims are honoured when they are present. + *
+ * * @author KSASAN preetkaran20@gmail.com */ @Component public class JWTValidator implements IJWTValidator { - private IJWTTokenGenerator libBasedJWTGenerator; + /** JWS compact serialization only allows base64url characters in each of its three parts. */ + private static final Pattern BASE64_URL_PATTERN = Pattern.compile("[A-Za-z0-9_-]+"); + + private static final int JWS_COMPACT_SERIALIZATION_PARTS = 3; + + private static final String EXPIRY_CLAIM = "exp"; + + private static final String NOT_BEFORE_CLAIM = "nbf"; + + /** Tolerance, in seconds, for clock drift between the token issuer and this application. */ + private static final long CLOCK_SKEW_IN_SECONDS = 60L; + + private final IJWTTokenGenerator libBasedJWTGenerator; + + private final JWTAlgorithmKMS jwtAlgorithmKMS; public JWTValidator(IJWTTokenGenerator libBasedJWTGenerator) { + this(libBasedJWTGenerator, new JWTAlgorithmKMS()); + } + + @Autowired + public JWTValidator(IJWTTokenGenerator libBasedJWTGenerator, JWTAlgorithmKMS jwtAlgorithmKMS) { this.libBasedJWTGenerator = libBasedJWTGenerator; + this.jwtAlgorithmKMS = jwtAlgorithmKMS; } - @Override - public boolean customHMACValidator(String token, byte[] key, String algorithm) - throws ServiceApplicationException { + /** + * Splits the token into the three parts of a JWS compact serialization and rejects anything + * which is not a structurally valid token, e.g. empty tokens, tokens without a signature or + * tokens carrying characters outside of the base64url alphabet (null bytes and their url + * encoded form included). + * + * @param token the token as it was received from the client + * @return the three parts of the token or {@code null} when the token is malformed + */ + private String[] getWellFormedJWSParts(String token) { + if (token == null) { + return null; + } + String[] jwtParts = token.trim().split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); + if (jwtParts.length != JWS_COMPACT_SERIALIZATION_PARTS) { + return null; + } + for (String jwtPart : jwtParts) { + if (!BASE64_URL_PATTERN.matcher(jwtPart).matches()) { + return null; + } + } + return jwtParts; + } + + private JSONObject decodeBase64UrlEncodedJson(String base64UrlEncodedValue) { try { - String[] jwtParts = token.split(JWTUtils.JWT_TOKEN_PERIOD_CHARACTER_REGEX, -1); - String newTokenSigned = - libBasedJWTGenerator.getHMACSignedJWTToken( - jwtParts[0] + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER + jwtParts[1], - key, - algorithm); - if (newTokenSigned.equals(token)) { - return true; - } else { + return new JSONObject( + new String( + Base64.getUrlDecoder().decode(base64UrlEncodedValue), + StandardCharsets.UTF_8)); + } catch (IllegalArgumentException | JSONException ex) { + return null; + } + } + + /** + * The {@code alg} header is supplied by the client and can therefore never decide how a token + * is verified. It is only used to reject tokens which do not match the algorithm this + * application signed them with, which is what stops the {@code none} algorithm, the algorithm + * confusion and the algorithm downgrade attacks. + */ + private boolean isPinnedAlgorithm(JSONObject header, String pinnedAlgorithm) { + if (header == null || pinnedAlgorithm == null) { + return false; + } + Object algorithm = header.opt(JWTUtils.JWT_ALGORITHM_KEY_HEADER); + return (algorithm instanceof String) && pinnedAlgorithm.equals(algorithm); + } + + /** Rejects expired tokens and tokens which are not valid yet. */ + private boolean areTemporalClaimsValid(String base64UrlEncodedPayload) { + JSONObject payload = decodeBase64UrlEncodedJson(base64UrlEncodedPayload); + if (payload == null) { + return false; + } + long currentTimeInSeconds = System.currentTimeMillis() / 1000L; + if (payload.has(EXPIRY_CLAIM)) { + long expiry = payload.optLong(EXPIRY_CLAIM, Long.MIN_VALUE); + if (expiry == Long.MIN_VALUE + || currentTimeInSeconds - CLOCK_SKEW_IN_SECONDS >= expiry) { return false; } - } catch (UnsupportedEncodingException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); + } + if (payload.has(NOT_BEFORE_CLAIM)) { + long notBefore = payload.optLong(NOT_BEFORE_CLAIM, Long.MAX_VALUE); + if (notBefore == Long.MAX_VALUE + || currentTimeInSeconds + CLOCK_SKEW_IN_SECONDS < notBefore) { + return false; + } + } + return true; + } + + private boolean verifySignature(String token, JWSVerifier verifier) { + try { + return SignedJWT.parse(token).verify(verifier); + } catch (ParseException | JOSEException ex) { + return false; } } @Override - public boolean customHMACNullByteVulnerableValidator(String token, byte[] key, String algorithm) + 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) { + if (key == null + || algorithm == null + || !JWTUtils.JWT_HMAC_ALGO_TO_JAVA_ALGORITHM_MAPPING.containsKey(algorithm)) { 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); + String[] jwtParts = getWellFormedJWSParts(token); + if (jwtParts == null) { + return false; + } + JSONObject header = decodeBase64UrlEncodedJson(jwtParts[0]); + if (!isPinnedAlgorithm(header, algorithm) || !areTemporalClaimsValid(jwtParts[1])) { + return false; } - return this.customHMACValidator( - jwtParts[0] - + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER - + jwtParts[1] - + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER - + jwtParts[2], - key, - algorithm); + String signingInput = + jwtParts[0] + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER + jwtParts[1]; + String expectedToken = + libBasedJWTGenerator.getHMACSignedJWTToken(signingInput, key, algorithm); + String presentedToken = + signingInput + JWTUtils.JWT_TOKEN_PERIOD_CHARACTER + jwtParts[2]; + // Constant time comparison, a byte by byte String#equals leaks the expected signature. + return MessageDigest.isEqual( + JWTUtils.getBytes(expectedToken), JWTUtils.getBytes(presentedToken)); } catch (UnsupportedEncodingException ex) { throw new ServiceApplicationException( "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); } } + @Override + public boolean customHMACNullByteVulnerableValidator(String token, byte[] key, String algorithm) + throws ServiceApplicationException { + // Truncating the received signature at a null byte let an attacker append arbitrary data + // to a signature, so the signature is now always verified in full. A token carrying a null + // byte, raw or url encoded, is not a well formed JWS and is rejected outright. + 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); - } + // "alg" is attacker controlled, so it can never be used to decide that a token is already + // trusted. Every token, "none" included, is verified against the algorithm pinned by the + // caller, which makes the unsigned token attack fail on the signature comparison. + return this.customHMACValidator(token, key, algorithm); } @Override public boolean genericJWTTokenValidator(String token, Key key, String algorithm) throws ServiceApplicationException { - try { - if (algorithm.startsWith(JWTUtils.JWT_HMAC_ALGORITHM_IDENTIFIER)) { - return this.customHMACValidator(token, key.getEncoded(), algorithm); - } else { - JWSVerifier verifier = null; - if (algorithm.startsWith(JWTUtils.JWT_RSA_PSS_ALGORITHM_IDENTIFIER) - || algorithm.startsWith(JWTUtils.JWT_RSA_ALGORITHM_IDENTIFIER)) { - verifier = new RSASSAVerifier((RSAPublicKey) key); - } else if (algorithm.startsWith(JWTUtils.JWT_EC_ALGORITHM_IDENTIFIER)) { - // TODO adding EC and OCTET for now not needed so not writing that. - return false; - } - SignedJWT signedJWT = SignedJWT.parse(token); - return signedJWT.verify(verifier); - } - } catch (JOSEException | ParseException | ServiceApplicationException ex) { - throw new ServiceApplicationException( - "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); + if (key == null || algorithm == null) { + return false; } + String[] jwtParts = getWellFormedJWSParts(token); + if (jwtParts == null) { + return false; + } + JSONObject header = decodeBase64UrlEncodedJson(jwtParts[0]); + if (!isPinnedAlgorithm(header, algorithm) || !areTemporalClaimsValid(jwtParts[1])) { + return false; + } + if (algorithm.startsWith(JWTUtils.JWT_HMAC_ALGORITHM_IDENTIFIER)) { + return this.customHMACValidator(token, key.getEncoded(), algorithm); + } + if ((algorithm.startsWith(JWTUtils.JWT_RSA_ALGORITHM_IDENTIFIER) + || algorithm.startsWith(JWTUtils.JWT_RSA_PSS_ALGORITHM_IDENTIFIER)) + && key instanceof RSAPublicKey) { + return verifySignature(token.trim(), new RSASSAVerifier((RSAPublicKey) key)); + } + // EC and OCTET key types are not configured for this application, fail closed. + return false; } @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); + // The verification algorithm is derived from the type of the key this application owns and + // not from the "alg" header. An RSA public key is therefore only ever used to verify an + // RSA signature and can no longer be replayed as an HMAC shared secret. + if (!(key instanceof RSAPublicKey)) { + return false; } - return false; + return this.genericJWTTokenValidator(token, key, JWTUtils.JWT_RSA_SHA_256_ALGORITHM); } @Override public boolean jwkKeyHeaderPublicKeyTrustingVulnerableValidator(String token) throws ServiceApplicationException { + // CVE-2018-0114: the "jwk" header is supplied by the client and must never become the + // trust anchor. The token is verified with this application's own public key and, when the + // header embeds a key, that key has to be exactly the key this application trusts. + Optional trustedKeyPair = + jwtAlgorithmKMS.getAsymmetricAlgorithmKey(JWTUtils.JWT_RSA_SHA_256_ALGORITHM); + if (!trustedKeyPair.isPresent()) { + return false; + } + PublicKey trustedPublicKey = trustedKeyPair.get().getPublic(); + String[] jwtParts = getWellFormedJWSParts(token); + if (jwtParts == null) { + return false; + } + JSONObject header = decodeBase64UrlEncodedJson(jwtParts[0]); + if (!isPinnedAlgorithm(header, JWTUtils.JWT_RSA_SHA_256_ALGORITHM)) { + return false; + } + if (header.has(JWTUtils.JSON_WEB_KEY_HEADER) + && !isTrustedJsonWebKey(header, trustedPublicKey)) { + return false; + } + return this.genericJWTTokenValidator( + token, trustedPublicKey, JWTUtils.JWT_RSA_SHA_256_ALGORITHM); + } + + /** Compares the public key embedded in the "jwk" header with the key this application owns. */ + private boolean isTrustedJsonWebKey(JSONObject header, PublicKey trustedPublicKey) { + if (!(trustedPublicKey instanceof RSAPublicKey)) { + return false; + } 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); + RSAPublicKey presentedKey = + RSAKey.parse(header.getJSONObject(JWTUtils.JSON_WEB_KEY_HEADER).toString()) + .toRSAPublicKey(); + RSAPublicKey trustedKey = (RSAPublicKey) trustedPublicKey; + return trustedKey.getModulus().equals(presentedKey.getModulus()) + && trustedKey.getPublicExponent().equals(presentedKey.getPublicExponent()); + } catch (ParseException | JOSEException | JSONException ex) { + return false; } - return false; } @Override public boolean customHMACEmptyTokenVulnerableValidator( String token, String key, String algorithm) throws ServiceApplicationException { + // An empty or partial token no longer short circuits to "valid". Structural validation is + // part of customHMACValidator, so "." and friends are rejected before any key is used. + if (key == null) { + return false; + } 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; - } + return this.customHMACValidator(token, JWTUtils.getBytes(key), algorithm); } catch (UnsupportedEncodingException ex) { throw new ServiceApplicationException( "Following exception occurred: ", ex, ExceptionStatusCodeEnum.SYSTEM_ERROR); 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..00efce67a 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 @@ -3,19 +3,17 @@ import com.fasterxml.jackson.core.type.TypeReference; import java.io.IOException; import java.io.InputStream; -import java.security.Key; import java.security.KeyPair; -import java.security.KeyStore; -import java.security.KeyStoreException; +import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.UnrecoverableKeyException; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; +import java.security.SecureRandom; +import java.util.Base64; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.JSONSerializationUtils; @@ -25,6 +23,12 @@ * Parses SymmetricAlgoKeys.json from scripts/JWT. Initialization is costly, reuse of one instance * is recommended. Also this class is responsible to generate the Asymmetric Algorithm keys. * + *

SymmetricAlgoKeys.json only declares which algorithm/strength combinations exist. The key + * material itself is never read from it: a checked in secret is public knowledge and the + * configured "LOW" strength secret was short enough to be recovered with a dictionary attack. + * Instead a 256 bit secret is generated with a {@link SecureRandom} for every combination, which + * is the minimum key size RFC 7518 mandates for HS256. + * * @author KSASAN preetkaran20@gmail.com */ @Component @@ -36,11 +40,23 @@ public class JWTAlgorithmKMS { private static final String SYMMETRIC_KEYS_FILE = "/scripts/JWT/SymmetricAlgoKeys.json"; - private static final String KEY_STORE_FILE_NAME = "sasanlabs.p12"; + /** RFC 7518 section 3.2: an HMAC SHA key must be at least as long as the hash output. */ + private static final int SYMMETRIC_KEY_LENGTH_IN_BYTES = 32; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + /** + * Generated secrets are shared by every instance of this class so that a token signed by one + * instance still verifies against another one within the same application. + */ + private static final Map GENERATED_SYMMETRIC_KEYS = new ConcurrentHashMap<>(); + + private static final String RSA_ALGORITHM = "RS256"; - private static final String KEY_STORE_PASSWORD = "changeIt"; + private static final int RSA_KEY_SIZE = 2048; - private static final String RSA_KEY_ALIAS = "SasanLabs"; + /** Shared by every instance of this class, for the same reason as the symmetric secrets. */ + private static KeyPair generatedRSAKeyPair; private static final transient Logger LOGGER = LogManager.getLogger(JWTAlgorithmKMS.class); @@ -54,9 +70,44 @@ public JWTAlgorithmKMS() { } catch (IOException e) { LOGGER.error("Following error occurred while parsing SymmetricAlgoKeys", e); } + symmetricAlgorithmKeySet = withSecurelyGeneratedKeys(symmetricAlgorithmKeySet); loadAsymmetricAlgorithmKeys(); } + /** + * Keeps the algorithm/strength combinations declared by the configuration but discards the + * configured key material in favour of a securely generated secret. + * + * @param configuredKeys the combinations declared in SymmetricAlgoKeys.json + * @return the same combinations, each holding a securely generated secret + */ + private static Set withSecurelyGeneratedKeys( + Set configuredKeys) { + Set secureKeys = new LinkedHashSet<>(); + if (configuredKeys == null) { + return secureKeys; + } + for (SymmetricAlgorithmKey configuredKey : configuredKeys) { + SymmetricAlgorithmKey secureKey = new SymmetricAlgorithmKey(); + secureKey.setAlgorithm(configuredKey.getAlgorithm()); + secureKey.setStrength(configuredKey.getStrength()); + secureKey.setKey( + generateKey(configuredKey.getAlgorithm(), configuredKey.getStrength())); + secureKeys.add(secureKey); + } + return secureKeys; + } + + private static String generateKey(String algorithm, KeyStrength keyStrength) { + return GENERATED_SYMMETRIC_KEYS.computeIfAbsent( + algorithm + "_" + keyStrength, + (combination) -> { + byte[] keyBytes = new byte[SYMMETRIC_KEY_LENGTH_IN_BYTES]; + SECURE_RANDOM.nextBytes(keyBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes); + }); + } + /** * Returns first matched Key for Algorithm and KeyStrength. * @@ -84,27 +135,26 @@ public Optional getAsymmetricAlgorithmKey(String algorithm) { return Optional.ofNullable(asymmetricAlgorithmKeyMap.get(algorithm)); } + /** + * Loads the RSA key pair used to sign RS256 tokens. The key pair is generated in process + * instead of being read from the sasanlabs.p12 key store bundled with the application: a + * private key shipped inside the artifact is known to everybody who can read the artifact, + * which lets anyone mint tokens this application would accept as its own. + */ private void loadAsymmetricAlgorithmKeys() { try { - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load( - getClass().getClassLoader().getResourceAsStream(KEY_STORE_FILE_NAME), - KEY_STORE_PASSWORD.toCharArray()); - Key privateKey = null; - Certificate certificate = null; - privateKey = keyStore.getKey(RSA_KEY_ALIAS, KEY_STORE_PASSWORD.toCharArray()); - certificate = keyStore.getCertificate(RSA_KEY_ALIAS); - // Need to handle for case of PS256 and Elliptical curve cryptography - if (privateKey.getAlgorithm().contains("RSA")) { - asymmetricAlgorithmKeyMap.put( - "RS256", new KeyPair(certificate.getPublicKey(), (PrivateKey) privateKey)); - } - } catch (KeyStoreException - | NoSuchAlgorithmException - | CertificateException - | IOException - | UnrecoverableKeyException e) { - LOGGER.error(e); + asymmetricAlgorithmKeyMap.put(RSA_ALGORITHM, generateRSAKeyPair()); + } catch (NoSuchAlgorithmException e) { + LOGGER.error("Following error occurred while generating the RSA key pair", e); + } + } + + private static synchronized KeyPair generateRSAKeyPair() throws NoSuchAlgorithmException { + if (generatedRSAKeyPair == null) { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(RSA_KEY_SIZE, SECURE_RANDOM); + generatedRSAKeyPair = keyPairGenerator.generateKeyPair(); } + return generatedRSAKeyPair; } } diff --git a/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/SymmetricAlgorithmKey.java b/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/SymmetricAlgorithmKey.java index c4b2d99a8..6d298d778 100755 --- a/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/SymmetricAlgorithmKey.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/jwt/keys/SymmetricAlgorithmKey.java @@ -47,15 +47,14 @@ public int hashCode() { return result; } + /** The secret is deliberately redacted, this bean is written to the application log. */ @Override public String toString() { return "SymmetricAlgorithmKey [algorithm=" + algorithm + ", strength=" + strength - + ", key=" - + key - + "]"; + + ", key=]"; } @Override 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..4d8014165 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java @@ -34,7 +34,13 @@ value = "LDAPInjectionVulnerability") public class LDAPInjectionVulnerability { - private List searchUsers(String filter) throws Exception { + private static final String UID_ATTRIBUTE = "uid"; + private static final String MAIL_ATTRIBUTE = "mail"; + private static final String NO_USERS_FOUND = "No users found"; + private static final String SEARCH_FAILED = "Search failed"; + private static final String INVALID_CREDENTIALS = "Invalid credentials"; + + private List searchUsers(Filter filter) throws Exception { LDAPConnection connection = EmbeddedLDAPConfig.getDirectoryServer().getConnection(); @@ -63,7 +69,7 @@ private List searchUsers(String filter) throws Exception { } } - private List searchEntries(String filter) throws Exception { + private List searchEntries(Filter filter) throws Exception { LDAPConnection connection = EmbeddedLDAPConfig.getDirectoryServer().getConnection(); try { @@ -111,19 +117,21 @@ public ResponseEntity> level1( return response("Provide username", false); } - // Vulnerable LDAP filter - String ldapQuery = "(uid=" + username + ")"; + // The filter is built through the LDAP SDK's filter API instead of string concatenation, + // so the user input is always an assertion value and can never re-write the filter. + Filter ldapFilter = Filter.createEqualityFilter(UID_ATTRIBUTE, username); + String ldapQuery = ldapFilter.toString(); try { - List users = searchUsers(ldapQuery); + List users = searchUsers(ldapFilter); if (users.isEmpty()) { - return response("No users found", false); + return response(NO_USERS_FOUND, false); } return response(Map.of("filter", ldapQuery, "users", users), true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + return response(SEARCH_FAILED, false); } } @@ -140,19 +148,24 @@ public ResponseEntity> level2( return response("Provide username", false); } - // OR based LDAP query - String ldapQuery = "(|(uid=" + username + ")(mail=" + username + "))"; + // OR based LDAP query, assembled from parameterised sub filters rather than concatenated + // text, so neither branch can be closed off or extended by the supplied value. + Filter ldapFilter = + Filter.createORFilter( + Filter.createEqualityFilter(UID_ATTRIBUTE, username), + Filter.createEqualityFilter(MAIL_ATTRIBUTE, username)); + String ldapQuery = ldapFilter.toString(); try { - List users = searchUsers(ldapQuery); + List users = searchUsers(ldapFilter); if (users.isEmpty()) { - return response("No users found", false); + return response(NO_USERS_FOUND, false); } return response(Map.of("filter", ldapQuery, "users", users), true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + return response(SEARCH_FAILED, false); } } @@ -170,16 +183,27 @@ public ResponseEntity> level3( return response("Provide username and password", false); } - // Vulnerable authentication filter - String ldapQuery = "(&(uid=" + username + ")(uid=*))"; + // Authentication filter built with the SDK filter API: the username is bound as an + // assertion value, so "*)(uid=*" style payloads can no longer widen the match set and + // authenticate against some other account's password. + Filter ldapFilter = + Filter.createANDFilter( + Filter.createEqualityFilter(UID_ATTRIBUTE, username), + Filter.createPresenceFilter(UID_ATTRIBUTE)); + String ldapQuery = ldapFilter.toString(); try { - List users = searchEntries(ldapQuery); + List users = searchEntries(ldapFilter); SearchResultEntry validUser = null; + // A login must not say whether the account exists. Answering "no users found" for an + // unknown uid and "invalid credentials" for a wrong password turns this endpoint into + // an oracle: it reports whether a supplied uid matched anything, which is exactly the + // signal a filter manipulation probe reads to decide the input reached the filter. + // Both outcomes collapse into one answer, the way LEVEL_5 and the secure LEVEL_6 do. if (users.isEmpty()) { - return response("LDAP Filter: " + ldapQuery + "\nNo users found", false); + return response(INVALID_CREDENTIALS, false); } boolean authenticated = false; @@ -195,7 +219,7 @@ public ResponseEntity> level3( } if (!authenticated) { - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } return response( @@ -203,11 +227,11 @@ public ResponseEntity> level3( "filter", ldapQuery, "users", - List.of(validUser.getAttributeValue("uid"))), + List.of(validUser.getAttributeValue(UID_ATTRIBUTE))), true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + return response(INVALID_CREDENTIALS, false); } } @@ -218,35 +242,39 @@ public ResponseEntity> level3( payload = "LDAP_PAYLOAD_LEVEL_4") @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_1/LDAP") public ResponseEntity> level4( - @RequestParam(required = false) String username) throws Exception { - - if (username == null) { - return response("Provide username", false); + @RequestParam(required = false) String username, + @RequestParam(required = false) String password) throws Exception { + + // Escaping the assertion value only ever protected the filter grammar, and this level + // already did that. What it never did was ask who was calling: an anonymous request could + // read directory entries straight out of the search, and the echoed filter handed back the + // query built from the submitted value. The lookup is now gated on the caller proving the + // account is theirs, and it answers the same way for an unknown uid and a wrong password, + // exactly as the secure LEVEL_6 sibling does. + if (username == null || password == null || username.isEmpty() || password.isEmpty()) { + return response(INVALID_CREDENTIALS, false); } - // Sanitization - String sanitizedInput = Filter.encodeValue(username); - - String ldapQuery = "(uid=" + sanitizedInput + ")"; + // Parameterised filter construction replaces manual escaping of a concatenated string: + // the SDK owns the encoding, so there is no filter grammar for the input to escape into. + Filter ldapFilter = Filter.createEqualityFilter(UID_ATTRIBUTE, username); try { - List users = searchUsers(ldapQuery); + List users = searchEntries(ldapFilter); if (users.isEmpty()) { - return response( - Map.of( - "filter", - ldapQuery, - "users", - List.of(), - "message", - "No users found"), - false); + return response(INVALID_CREDENTIALS, false); } - return response(Map.of("filter", ldapQuery, "users", users), true); + String storedPassword = users.get(0).getAttributeValue("userPassword"); + + if (verifyPassword(password, storedPassword)) { + return response("Login successful", true); + } + + return response(INVALID_CREDENTIALS, false); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + return response(INVALID_CREDENTIALS, false); } } @@ -264,26 +292,27 @@ public ResponseEntity> level5( return response("Provide username and password", false); } - String ldapQuery = "(&(uid=" + username + "))"; + // Parameterised filter: a blind payload such as "*)(uid=*" is treated as a literal uid, + // so it matches nothing and can no longer be used to log in as an arbitrary account. + Filter ldapFilter = + Filter.createANDFilter(Filter.createEqualityFilter(UID_ATTRIBUTE, username)); try { - List users = searchEntries(ldapQuery); + List users = searchEntries(ldapFilter); if (users.isEmpty()) { - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } - for (SearchResultEntry user : users) { - String storedPassword = user.getAttributeValue("userPassword"); + String storedPassword = users.get(0).getAttributeValue("userPassword"); - if (verifyPassword(password, storedPassword)) { - return response("Login successful", true); - } + if (verifyPassword(password, storedPassword)) { + return response("Login successful", true); } - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } catch (Exception e) { - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } } @@ -301,19 +330,17 @@ public ResponseEntity> level6( @RequestParam(required = false) String password) { if (username == null || password == null || username.isEmpty() || password.isEmpty()) { - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } try { - // Sanitization (secure) - String sanitizedInput = Filter.encodeValue(username); - - String ldapQuery = "(uid=" + sanitizedInput + ")"; + // Parameterised filter construction (secure) + Filter ldapFilter = Filter.createEqualityFilter(UID_ATTRIBUTE, username); - List users = searchEntries(ldapQuery); + List users = searchEntries(ldapFilter); if (users.isEmpty()) { - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } String storedPassword = users.get(0).getAttributeValue("userPassword"); @@ -321,9 +348,9 @@ public ResponseEntity> level6( return response("Login successful", true); } - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } catch (Exception e) { - return response("Invalid credentials", false); + return response(INVALID_CREDENTIALS, false); } } } 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..b5fbcb32b 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/openRedirect/Http3xxStatusCodeBasedInjection.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/openRedirect/Http3xxStatusCodeBasedInjection.java @@ -1,15 +1,11 @@ package org.sasanlabs.service.vulnerability.openRedirect; -import static org.sasanlabs.vulnerability.utils.Constants.NULL_BYTE_CHARACTER; - import java.net.MalformedURLException; -import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import java.util.function.Function; -import org.sasanlabs.internal.utility.FrameworkConstants; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; import org.sasanlabs.internal.utility.annotations.AttackVector; @@ -56,20 +52,84 @@ public class Http3xxStatusCodeBasedInjection { private static final String LOCATION_HEADER_KEY = "Location"; private static final String RETURN_TO = "returnTo"; + private static final Set WHITELISTED_URLS = new HashSet<>(Arrays.asList("/", "/VulnerableApp/")); + /** + * Structural allow-list check for a redirect target. + * + *

Only the exact application relative paths held in {@link #WHITELISTED_URLS} are ever + * accepted, so the destination can never be attacker controlled. Deny listing the shape of a + * hostile URL is what the vulnerable levels used to do and it is unwinnable: {@code + * //evil.com}, {@code \/\/evil.com}, {@code https://trusted.com@evil.com}, {@code + * HtTpS://evil.com}, {@code /%09/evil.com}, {@code %00//evil.com} and a control character + * prefixed variant of any of them all defeat a prefix check, yet none of them can ever be equal + * to an allow-listed entry. + * + *

The value is normalised the way a browser normalises a URL (backslashes behave like + * forward slashes, C0 controls and whitespace are stripped) before it is matched, so a + * decorated spelling of an allow-listed path cannot smuggle anything past the comparison + * either. A control character or whitespace anywhere in the value is rejected outright, which + * also removes any CR/LF header splitting concern. + */ + private static String normalizedAllowedTarget(String urlToRedirect) { + if (urlToRedirect == null || urlToRedirect.isEmpty()) { + return null; + } + StringBuilder normalizedUrl = new StringBuilder(urlToRedirect.length()); + for (int index = 0; index < urlToRedirect.length(); index++) { + char currentCharacter = urlToRedirect.charAt(index); + if (currentCharacter <= ' ' || currentCharacter == 0x7F) { + return null; + } + normalizedUrl.append(currentCharacter == '\\' ? '/' : currentCharacter); + } + String normalizedTarget = normalizedUrl.toString(); + return WHITELISTED_URLS.contains(normalizedTarget) ? normalizedTarget : null; + } + + private static boolean isAllowedRedirectTarget(String urlToRedirect) { + return normalizedAllowedTarget(urlToRedirect) != null; + } + private ResponseEntity getURLRedirectionResponseEntity( String urlToRedirect, Function validator) { MultiValueMap headerParam = new org.springframework.http.HttpHeaders(); if (validator.apply(urlToRedirect)) { + // The header carries the normalised spelling of the allow-listed path rather than the + // raw parameter, so a decorated spelling such as "\VulnerableApp\" can never reach the + // Location header even though it resolves to an allow-listed path. + String normalizedTarget = normalizedAllowedTarget(urlToRedirect); headerParam.put(LOCATION_HEADER_KEY, new ArrayList<>()); - headerParam.get(LOCATION_HEADER_KEY).add(urlToRedirect); + headerParam + .get(LOCATION_HEADER_KEY) + .add(normalizedTarget == null ? urlToRedirect : normalizedTarget); return new ResponseEntity<>(headerParam, HttpStatus.FOUND); } + // The destination is not on the allow list, so no Location header is emitted at all and + // the browser stays where it is. The refusal is expressed by the absence of the redirect + // rather than by an error status, so the endpoint still answers normally. return new ResponseEntity<>(HttpStatus.OK); } + /** + * Response shape for the phishing progression levels, matching their secure LEVEL_11 sibling: + * an allow-listed application relative path still gets its 302, and anything else is refused + * with 403 and an explanatory body, so the victim is never sent to the attacker's page. + * LEVEL_1 to LEVEL_7 keep the empty 200 their own secure sibling, LEVEL_8, answers with. + */ + private ResponseEntity getPhishingRedirectResponseEntity(String urlToRedirect) { + String normalizedTarget = normalizedAllowedTarget(urlToRedirect); + if (normalizedTarget != null) { + return ResponseEntity.status(HttpStatus.FOUND) + .header(LOCATION_HEADER_KEY, normalizedTarget) + .build(); + } + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Redirect blocked: untrusted redirect target"); + } + @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = "OPEN_REDIRECT_QUERY_PARAM_DIRECTLY_ADD_TO_LOCATION_HEADER") @@ -89,13 +149,12 @@ private ResponseEntity getURLRedirectionResponseEntity( htmlTemplate = "LEVEL_1/Http3xxStatusCodeBasedInjection") public ResponseEntity getVulnerablePayloadLevel1( @RequestParam(RETURN_TO) String urlToRedirect) { - return this.getURLRedirectionResponseEntity(urlToRedirect, (url) -> true); + // The raw parameter used to be copied into the Location header with no validation at + // all. It is now matched against the allow-list of application relative paths. + return this.getURLRedirectionResponseEntity( + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } - // Payloads: - // 1. Protocol other than http can be used e.g. ftp://ftp.dlptest.com/ also - // 2. "//facebook.com" - @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = @@ -117,19 +176,14 @@ public ResponseEntity getVulnerablePayloadLevel1( public ResponseEntity getVulnerablePayloadLevel2( RequestEntity requestEntity, @RequestParam(RETURN_TO) String urlToRedirect) throws MalformedURLException { - URL requestUrl = new URL(requestEntity.getUrl().toString()); + // The old deny list only looked at the "http://", "https://" and "www." prefixes, so + // "//evil.com" and "ftp://evil.com" walked straight through it. Comparing the value + // against the request authority was equally useless because that authority is derived + // from the attacker supplied Host header. return this.getURLRedirectionResponseEntity( - urlToRedirect, - (url) -> - (!url.startsWith(FrameworkConstants.HTTP) - && !url.startsWith(FrameworkConstants.HTTPS) - && !url.startsWith(FrameworkConstants.WWW)) - || requestUrl.getAuthority().equals(urlToRedirect)); + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } - // Payloads: - // 1. /%09/localdomain.pw - // 2. %00//google.com @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = @@ -151,18 +205,12 @@ public ResponseEntity getVulnerablePayloadLevel2( public ResponseEntity getVulnerablePayloadLevel3( RequestEntity requestEntity, @RequestParam(RETURN_TO) String urlToRedirect) throws MalformedURLException { - URL requestUrl = new URL(requestEntity.getUrl().toString()); + // Adding "//" to the deny list still left "/%09/evil.com", "\/\/evil.com" and any + // uppercase spelling of a scheme usable. The allow-list removes the whole class. return this.getURLRedirectionResponseEntity( - urlToRedirect, - (url) -> - (!url.startsWith(FrameworkConstants.HTTP) - && !url.startsWith(FrameworkConstants.HTTPS) - && !url.startsWith("//") - && !url.startsWith(FrameworkConstants.WWW)) - || requestUrl.getAuthority().equals(url)); + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } - // As there can be too many hacks e.g. using %00 to %1F so blacklisting is not possible @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = @@ -184,22 +232,12 @@ public ResponseEntity getVulnerablePayloadLevel3( public ResponseEntity getVulnerablePayloadLevel4( RequestEntity requestEntity, @RequestParam(RETURN_TO) String urlToRedirect) throws MalformedURLException { - URL requestUrl = new URL(requestEntity.getUrl().toString()); + // Banning the null byte only closed one of the 33 control characters a browser strips + // while parsing a URL. The allow-list rejects every one of them by construction. return this.getURLRedirectionResponseEntity( - urlToRedirect, - (url) -> - (!url.startsWith(FrameworkConstants.HTTP) - && !url.startsWith(FrameworkConstants.HTTPS) - && !url.startsWith(FrameworkConstants.WWW) - && !url.startsWith("//") - && !url.startsWith(NULL_BYTE_CHARACTER)) - || requestUrl.getAuthority().equals(url)); + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } - // Payloads: - // 1. /%09/localdomain.pw - // 2. \/google.com - // 3. \/\/localdomain.pw/ @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = @@ -221,21 +259,12 @@ public ResponseEntity getVulnerablePayloadLevel4( public ResponseEntity getVulnerablePayloadLevel5( RequestEntity requestEntity, @RequestParam(RETURN_TO) String urlToRedirect) throws MalformedURLException { - URL requestUrl = new URL(requestEntity.getUrl().toString()); + // Screening the first character still let "localdomain.pw/" and "\/\/evil.com" through, + // both of which a browser resolves to an external origin. return this.getURLRedirectionResponseEntity( - urlToRedirect, - (url) -> - (!url.startsWith(FrameworkConstants.HTTP) - && !url.startsWith(FrameworkConstants.HTTPS) - && !url.startsWith("//") - && !url.startsWith(FrameworkConstants.WWW) - && !url.startsWith(NULL_BYTE_CHARACTER) - && (url.length() > 0 && url.charAt(0) > 20)) - || requestUrl.getAuthority().equals(url)); + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } - // case study explaning issue with this approach: - // https://appsec-labs.com/portal/case-study-open-redirect/ @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = @@ -257,13 +286,14 @@ 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); + // Gluing the parameter onto "://" was not a defence at all: with no + // separator in between, "@evil.com" turned the trusted authority into userinfo and + // ".evil.com" turned it into a subdomain label, so the browser left the origin either + // way. The authority itself also came from the attacker controlled Host header. The + // Location header is now the allow-listed application relative path, which is + // same-origin by construction and needs no trust in the request URL. + return this.getURLRedirectionResponseEntity( + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } @AttackVector( @@ -287,21 +317,11 @@ 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); + // Forcing a single "/" after the authority stopped "@evil.com" but not the Host header + // route, and it still emitted an absolute URL built from untrusted input. Same fix as + // LEVEL_6: emit only an allow-listed application relative path. + return this.getURLRedirectionResponseEntity( + urlToRedirect, Http3xxStatusCodeBasedInjection::isAllowedRedirectTarget); } // using whitelisting approach @@ -335,12 +355,14 @@ public ResponseEntity getVulnerablePayloadLevel8( htmlTemplate = "LEVEL_9/Http3xxStatusCodeBasedInjection") public ResponseEntity getVulnerablePayloadLevel9( @RequestParam(RETURN_TO) String urlToRedirect) { - return this.getURLRedirectionResponseEntity(urlToRedirect, (url) -> true); + // Any URL was accepted, which is what let the redirect land a victim on a credential + // harvesting page. The allow-list and the 403 refusal both match the secure LEVEL_11 + // sibling, so hostile external targets and the bundled fake login page are refused. + return this.getPhishingRedirectResponseEntity(urlToRedirect); } - // Payloads: any URL e.g. /VulnerableApp/phishing/fake-login.html // The UI shows an interstitial popup; when the user clicks Continue the browser - // calls this endpoint which issues the 302 redirect to whatever URL was supplied. + // calls this endpoint which issues the 302 redirect only for an allow-listed URL. @AttackVector( vulnerabilityExposed = {VulnerabilityType.OPEN_REDIRECT_3XX_STATUS_CODE}, description = "OPEN_REDIRECT_PHISHING_WARNING_BEFORE_REDIRECT") @@ -360,7 +382,10 @@ public ResponseEntity getVulnerablePayloadLevel9( htmlTemplate = "LEVEL_10/Http3xxStatusCodeBasedInjection") public ResponseEntity getVulnerablePayloadLevel10( @RequestParam(RETURN_TO) String urlToRedirect) { - return this.getURLRedirectionResponseEntity(urlToRedirect, (url) -> true); + // An interstitial warning is advisory only; the server still has to refuse an + // untrusted destination, because the endpoint can be called directly. Same refusal as + // the secure LEVEL_11 sibling. + return this.getPhishingRedirectResponseEntity(urlToRedirect); } @AttackVector( 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..0c5fd799a 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetService.java @@ -3,14 +3,11 @@ 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; @@ -26,6 +23,9 @@ @Service public class PasswordResetService { + private static final org.apache.logging.log4j.Logger LOGGER = + org.apache.logging.log4j.LogManager.getLogger(PasswordResetService.class); + private static class ResetAttempt { private final AtomicInteger count; private volatile long windowStartTime; @@ -42,6 +42,10 @@ private ResetAttempt(int count, long windowStartTime) { private static final String RATE_LIMITED_MESSAGE = "Too many reset requests, try again after 30 mins"; private static final int ENFORCED_EXPIRY_MINUTES = 5; + + /** Bytes of {@link SecureRandom} output behind every reset token: 192 bits. */ + private static final int TOKEN_ENTROPY_BYTES = 24; + private static final int MAX_RESET_REQUESTS = 5; private static final long RESET_REQUEST_TTL_MILLIS = 30 * 60 * 1000; @@ -50,10 +54,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 resetRequestAttempts = new ConcurrentHashMap<>(); private final SecureRandom secureRandom = new SecureRandom(); - private final Random weakRandom = new Random(); public PasswordResetService( PasswordResetUserRepository userRepository, @@ -75,7 +78,7 @@ public ResponseEntity> requestReset( return response("email is required", false); } - if (isRateLimitingEnabled(level) && isRateLimitedAndConsumeSlot(email)) { + if (isRateLimitedAndConsumeSlot(level, email)) { Map content = new LinkedHashMap<>(); content.put("message", RATE_LIMITED_MESSAGE); content.put("rateLimitingApplied", true); @@ -86,9 +89,11 @@ public ResponseEntity> requestReset( Optional userOpt = userRepository.findByEmailAndLevel(email, level); if (userOpt.isEmpty()) { - if (isEnumerationVulnerable(level)) { - return response("No account found for email: " + email, false); - } + // The answer for an address with no account is byte for byte the answer for an address + // with one. Reporting "no account found" turns the reset form into an oracle for which + // addresses are registered, which is the whole of the enumeration attack: an attacker + // walks a candidate list and keeps every address that comes back with the distinct + // error. Nothing is sent, so the only difference an attacker can observe is timing. Map content = new LinkedHashMap<>(); content.put("message", GENERIC_EMAIL_MESSAGE); content.put("level", level); @@ -108,17 +113,25 @@ 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 (RuntimeException e) { + // Delivery is not allowed to decide what this endpoint answers. The whole point of the + // generic response is that a caller cannot tell an address with an account from one + // without; letting a mail failure surface as an error would restore that distinction, + // because only an address that actually has an account ever reaches this line. + LOGGER.warn("Unable to deliver the reset mail for level {}", level, e); + } Map content = new LinkedHashMap<>(); content.put("message", GENERIC_EMAIL_MESSAGE); @@ -142,13 +155,18 @@ public ResponseEntity> resetPassword( PasswordResetToken resetToken = tokenOpt.get(); - if (!isMissingExpirationVulnerable(level) - && resetToken.getExpiresAt() != null - && LocalDateTime.now().isAfter(resetToken.getExpiresAt())) { + // A token with no expiry recorded is not a token that never expires, it is a token whose + // lifetime was never bounded, so it is refused outright rather than accepted forever. The + // email promises the link lasts ENFORCED_EXPIRY_MINUTES and that promise is now enforced. + if (resetToken.getExpiresAt() == null + || LocalDateTime.now().isAfter(resetToken.getExpiresAt())) { return response(INVALID_TOKEN_MESSAGE, false); } - if (!isReusableTokenVulnerable(level) && resetToken.isUsed()) { + // One token, one reset. A token that survives its use lets anyone who ever saw it - in a + // proxy log, a browser history, a forwarded mail - set the password again at any later + // time, long after the legitimate owner has finished. + if (resetToken.isUsed()) { return response(INVALID_TOKEN_MESSAGE, false); } @@ -162,10 +180,8 @@ public ResponseEntity> resetPassword( user.setPassword(passwordEncoder.encode(newPassword)); userRepository.save(user); - if (!isReusableTokenVulnerable(level)) { - resetToken.setUsed(true); - tokenRepository.save(resetToken); - } + resetToken.setUsed(true); + tokenRepository.save(resetToken); Map content = new LinkedHashMap<>(); content.put("message", "Password reset successful"); @@ -174,40 +190,31 @@ public ResponseEntity> resetPassword( return response(content, true); } + /** + * Mints a reset token that carries no information about who it is for and cannot be reached by + * guessing. + * + *

A reset token is a bearer credential for an account, so it has to be as unguessable as the + * password it replaces. Every derivation that ties the token to something an attacker already + * knows or can enumerate is gone: the identifier form {@code reset-} handed anyone the + * token of every other user by counting, the {@code weak-NNNN} form had a 9000 value space that + * a script exhausts in seconds, and the Base64 wrapped {@code obf::} form + * only looked random - decoded, it is the same user id plus a timestamp an attacker can bracket + * to a handful of candidates. All of them are replaced by {@value #TOKEN_ENTROPY_BYTES} bytes + * drawn from {@link SecureRandom}, which is the same generator the secure sibling level uses. + */ 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)); + byte[] bytes = new byte[TOKEN_ENTROPY_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } + /** + * Bounds the lifetime of every token. A token with a null expiry is one that never stops + * working, so a link recovered from a mailbox, a backup or a proxy log months later still + * resets the account. + */ private LocalDateTime computeExpiry(int level, LocalDateTime now) { - if (isMissingExpirationVulnerable(level)) { - return null; - } return now.plusMinutes(ENFORCED_EXPIRY_MINUTES); } @@ -232,31 +239,25 @@ private String adjustSchemeForLevel(String baseUrl) { return result; } - private static boolean isEnumerationVulnerable(int level) { - return level <= 4; - } - - private static boolean isMissingExpirationVulnerable(int level) { - return level <= 3; - } - - private static boolean isReusableTokenVulnerable(int level) { - return level <= 2; - } - - private static boolean isWeakRandomTokenVulnerable(int level) { - return level >= 2 && level <= 5; - } - - private static boolean isRateLimitingEnabled(int level) { - return level == 10; - } - - private boolean isRateLimitedAndConsumeSlot(String email) { + /** + * Throttles reset requests per account, on the levels whose contract is that a reset request is + * throttled at all. + * + *

Unthrottled requests are what make a token worth attacking: an attacker can ask for an + * unbounded number of simultaneously live tokens for one account, widening the window in which + * any one of them can be guessed, and can use the same endpoint to flood a victim's mailbox + * until the genuine mail is buried. The bound is {@value #MAX_RESET_REQUESTS} requests per + * address per {@code RESET_REQUEST_TTL_MILLIS}, which is the bound the secure sibling level + * already applied. + */ + private boolean isRateLimitedAndConsumeSlot(int level, String email) { + if (!isRateLimitingEnabled(level)) { + return false; + } long now = System.currentTimeMillis(); AtomicBoolean blocked = new AtomicBoolean(false); - level10ResetRequests.compute( + resetRequestAttempts.compute( email, (key, attempt) -> { if (attempt == null @@ -274,6 +275,17 @@ private boolean isRateLimitedAndConsumeSlot(String email) { return blocked.get(); } + /** + * Level 9 is the level whose stated contract is "everything else is in place, only throttling + * is missing", so it is the level the throttle has to reach; level 10 already had it. The lower + * levels are deliberately left alone: their weakness was the token itself, that token is now + * unguessable, and adding a request bound there would change how those endpoints answer a + * perfectly ordinary sequence of reset requests without closing anything. + */ + private static boolean isRateLimitingEnabled(int level) { + return level >= 9; + } + private static ResponseEntity> response( Object content, boolean isValid) { return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(content, isValid)); 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..39f16132f 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/pathTraversal/PathTraversalVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/pathTraversal/PathTraversalVulnerability.java @@ -6,10 +6,12 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.LevelConstants; @@ -28,6 +30,14 @@ * Path traversal vulnerability. More information * + *

Every level funnels the untrusted {@code fileName} through {@link + * #resolveWithinBaseDirectory(String)}, which resolves it against a fixed base directory, + * canonicalises the result and only serves the file when the canonical path is still inside that + * base directory and names an explicitly allowed file. Blocklists on the raw request (rejecting + * {@code ..}, {@code ../} or {@code %2f}, or truncating at a null byte) are not used any more: + * they only describe traversal payloads instead of confining the resolved path, and each of them + * has a trivial bypass. + * * @author KSASAN preetkaran20@gmail.com */ @Profile("unsafe") @@ -39,16 +49,57 @@ public class PathTraversalVulnerability { private static final List ALLOWED_FILE_NAMES = Arrays.asList("UserInfo.json", "OwaspAppInfo.json"); + /** Fixed base directory. No request is ever allowed to read outside of it. */ + private static final String BASE_DIRECTORY = "/scripts/PathTraversal"; + + private static final Path BASE_PATH = Paths.get(BASE_DIRECTORY).normalize(); + private static final transient Logger LOGGER = LogManager.getLogger(PathTraversalVulnerability.class); private static final String URL_PARAM_KEY = "fileName"; - private ResponseEntity> readFile( - Supplier condition, String fileName) { - if (condition.get()) { - InputStream infoFileStream = - this.getClass().getResourceAsStream("/scripts/PathTraversal/" + fileName); + /** + * Resolves the untrusted file name against {@link #BASE_DIRECTORY}, canonicalises the outcome + * and returns the resource path only when it is still confined to the base directory and names + * one of the {@link #ALLOWED_FILE_NAMES}. + * + *

The containment check is made on the canonical path, so it holds for every shape the + * traversal can arrive in once the servlet container has decoded the query string: {@code + * ../}, {@code ..\}, {@code %2e%2e%2f}, {@code %2f}, a mixed-case or repeated variant of those, + * an absolute path such as {@code /etc/passwd}, or a name that walks out of the directory and + * back in again. Double encoded payloads survive decoding as literal text and simply fail to + * match an allowed name. A null byte is rejected outright rather than being truncated, so a + * poisoned name can no longer smuggle an allowed suffix past the check. + * + * @param fileName untrusted file name taken from the request + * @return the confined classpath resource path, or {@code null} when the request must be denied + */ + private static String resolveWithinBaseDirectory(String fileName) { + if (fileName == null || fileName.contains(NULL_BYTE_CHARACTER)) { + return null; + } + try { + Path resolvedPath = BASE_PATH.resolve(fileName).normalize(); + if (!resolvedPath.startsWith(BASE_PATH)) { + return null; + } + Path relativePath = BASE_PATH.relativize(resolvedPath); + if (relativePath.getNameCount() != 1 + || !ALLOWED_FILE_NAMES.contains(relativePath.toString())) { + return null; + } + return BASE_DIRECTORY + "/" + relativePath; + } catch (InvalidPathException e) { + LOGGER.error("Rejecting a file name which is not a valid path: ", e); + return null; + } + } + + private ResponseEntity> readFile(String fileName) { + String resourcePath = resolveWithinBaseDirectory(fileName); + if (resourcePath != null) { + InputStream infoFileStream = this.getClass().getResourceAsStream(resourcePath); if (infoFileStream != null) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(infoFileStream))) { @@ -78,8 +129,7 @@ private ResponseEntity> readFile( htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam Map queryParams) { - String fileName = queryParams.get(URL_PARAM_KEY); - return this.readFile(() -> fileName != null, fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -90,10 +140,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel2( RequestEntity requestEntity, @RequestParam Map queryParams) { - String fileName = queryParams.get(URL_PARAM_KEY); - return this.readFile( - () -> !requestEntity.getUrl().toString().contains("../") && fileName != null, - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -104,10 +151,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel3( RequestEntity requestEntity, @RequestParam Map queryParams) { - String fileName = queryParams.get(URL_PARAM_KEY); - return this.readFile( - () -> !requestEntity.getUrl().toString().contains("..") && fileName != null, - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -119,13 +163,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel4( RequestEntity requestEntity, @RequestParam Map queryParams) { - String fileName = queryParams.get(URL_PARAM_KEY); - return this.readFile( - () -> - !requestEntity.getUrl().toString().contains("..") - && !requestEntity.getUrl().toString().contains("%2f") - && fileName != null, - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -137,13 +175,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel5( RequestEntity requestEntity, @RequestParam Map queryParams) { - String fileName = queryParams.get(URL_PARAM_KEY); - return this.readFile( - () -> - !requestEntity.getUrl().toString().contains("..") - && !requestEntity.getUrl().toString().toLowerCase().contains("%2f") - && fileName != null, - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -155,8 +187,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel6( @RequestParam Map queryParams) { - String fileName = queryParams.get(URL_PARAM_KEY); - return this.readFile(() -> fileName != null && !fileName.contains(".."), fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } // Null Byte @@ -169,23 +200,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel7( RequestEntity requestEntity, @RequestParam Map queryParams) { - String queryFileName = queryParams.get(URL_PARAM_KEY); - String fileName = null; - if (queryFileName != null) { - int indexOfNullByte = queryFileName.indexOf(NULL_BYTE_CHARACTER); - fileName = - indexOfNullByte >= 0 - ? queryFileName.substring(0, indexOfNullByte) - : queryFileName; - } - return this.readFile( - () -> - queryFileName != null - && ALLOWED_FILE_NAMES.stream() - .anyMatch( - allowedFileName -> - queryFileName.contains(allowedFileName)), - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -197,24 +212,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel8( RequestEntity requestEntity, @RequestParam Map queryParams) { - String queryFileName = queryParams.get(URL_PARAM_KEY); - String fileName = null; - if (queryFileName != null) { - int indexOfNullByte = queryFileName.indexOf(NULL_BYTE_CHARACTER); - fileName = - indexOfNullByte >= 0 - ? queryFileName.substring(0, indexOfNullByte) - : queryFileName; - } - return this.readFile( - () -> - queryFileName != null - && !requestEntity.getUrl().toString().contains("../") - && ALLOWED_FILE_NAMES.stream() - .anyMatch( - allowedFileName -> - queryFileName.contains(allowedFileName)), - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -226,24 +224,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel9( RequestEntity requestEntity, @RequestParam Map queryParams) { - String queryFileName = queryParams.get(URL_PARAM_KEY); - String fileName = null; - if (queryFileName != null) { - int indexOfNullByte = queryFileName.indexOf(NULL_BYTE_CHARACTER); - fileName = - indexOfNullByte >= 0 - ? queryFileName.substring(0, indexOfNullByte) - : queryFileName; - } - return this.readFile( - () -> - queryFileName != null - && !requestEntity.getUrl().toString().contains("..") - && ALLOWED_FILE_NAMES.stream() - .anyMatch( - allowedFileName -> - queryFileName.contains(allowedFileName)), - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -255,25 +236,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel10( RequestEntity requestEntity, @RequestParam Map queryParams) { - String queryFileName = queryParams.get(URL_PARAM_KEY); - String fileName = null; - if (queryFileName != null) { - int indexOfNullByte = queryFileName.indexOf(NULL_BYTE_CHARACTER); - fileName = - indexOfNullByte >= 0 - ? queryFileName.substring(0, indexOfNullByte) - : queryFileName; - } - return this.readFile( - () -> - queryFileName != null - && !requestEntity.getUrl().toString().contains("..") - && !requestEntity.getUrl().toString().contains("%2f") - && ALLOWED_FILE_NAMES.stream() - .anyMatch( - allowedFileName -> - queryFileName.contains(allowedFileName)), - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -285,25 +248,7 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel11( RequestEntity requestEntity, @RequestParam Map queryParams) { - String queryFileName = queryParams.get(URL_PARAM_KEY); - String fileName = null; - if (queryFileName != null) { - int indexOfNullByte = queryFileName.indexOf(NULL_BYTE_CHARACTER); - fileName = - indexOfNullByte >= 0 - ? queryFileName.substring(0, indexOfNullByte) - : queryFileName; - } - return this.readFile( - () -> - queryFileName != null - && !requestEntity.getUrl().toString().contains("..") - && !requestEntity.getUrl().toString().toLowerCase().contains("%2f") - && ALLOWED_FILE_NAMES.stream() - .anyMatch( - allowedFileName -> - queryFileName.contains(allowedFileName)), - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } @AttackVector( @@ -315,23 +260,6 @@ public ResponseEntity> getVulnerablePay htmlTemplate = "LEVEL_1/PathTraversal") public ResponseEntity> getVulnerablePayloadLevel12( @RequestParam Map queryParams) { - String queryFileName = queryParams.get(URL_PARAM_KEY); - String fileName = null; - if (queryFileName != null) { - int indexOfNullByte = queryFileName.indexOf(NULL_BYTE_CHARACTER); - fileName = - indexOfNullByte >= 0 - ? queryFileName.substring(0, indexOfNullByte) - : queryFileName; - } - return this.readFile( - () -> - queryFileName != null - && !queryFileName.contains("..") - && ALLOWED_FILE_NAMES.stream() - .anyMatch( - allowedFileName -> - queryFileName.contains(allowedFileName)), - fileName); + return this.readFile(queryParams.get(URL_PARAM_KEY)); } } 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..166e71f3b 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/rfi/UrlParamBasedRFI.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/rfi/UrlParamBasedRFI.java @@ -1,18 +1,25 @@ package org.sasanlabs.service.vulnerability.rfi; -import static org.sasanlabs.vulnerability.utils.Constants.NULL_BYTE_CHARACTER; - import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; import java.util.Map; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.GenericUtils; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; -import org.sasanlabs.service.vulnerability.pathTraversal.PathTraversalVulnerability; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,6 +27,10 @@ import org.springframework.web.client.RestTemplate; /** + * Remote file inclusion levels. Both levels take a URL from the caller and include what is at the + * other end of it in the response, which is why the destination has to be decided by the server and + * not by the caller. + * * @author KSASAN preetkaran20@gmail.com */ @Profile("unsafe") @@ -28,48 +39,167 @@ value = "RemoteFileInclusion") public class UrlParamBasedRFI { - private static final transient Logger LOGGER = - LogManager.getLogger(PathTraversalVulnerability.class); + private static final transient Logger LOGGER = LogManager.getLogger(UrlParamBasedRFI.class); private static final String URL_PARAM_KEY = "url"; + private static final String REFUSED_MESSAGE = + "The requested destination is not one this application includes content from."; + + /** + * Only these two schemes are ever fetched, so {@code file:}, {@code jar:}, {@code netdoc:}, + * {@code ftp:} and friends cannot be used to turn a remote include into a read of the local + * filesystem or of an internal service. + */ + private static final Set ALLOWED_SCHEMES = + Collections.unmodifiableSet(new HashSet<>(Arrays.asList("http", "https"))); + + /** + * Allow list of the destinations this application legitimately includes content from. This is + * what actually closes the inclusion: what comes back is placed directly into the response, so + * a caller who can name the destination can make the application serve content of their + * choosing under this origin, and can use the server as a proxy to reach anything the server + * can reach but they cannot. + */ + private static final Set ALLOWED_HOSTS = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + "gist.githubusercontent.com", + "raw.githubusercontent.com", + "github.com"))); + @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) { - try { - URL url = new URL(queryParameterURL); - RestTemplate restTemplate = new RestTemplate(); - payload.append(restTemplate.getForObject(url.toURI(), String.class)); - } catch (IOException | URISyntaxException e) { - LOGGER.error("Following error occurred:", e); - } - } - - return new ResponseEntity<>( - GenericUtils.wrapPayloadInGenericVulnerableAppTemplate(payload.toString()), - HttpStatus.OK); + return include(queryParams.get(URL_PARAM_KEY)); } @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_2) public ResponseEntity getVulnerablePayloadLevelUnsecureLevel2( @RequestParam Map queryParams) { + // The level used to gate the fetch on the value containing a null byte. That was never a + // security control: it selected which requests were served, not which destinations were + // safe, and a null byte is trivially supplied. The destination is judged the same way here + // as on the level above. + return include(queryParams.get(URL_PARAM_KEY)); + } + + private ResponseEntity include(String queryParameterURL) { StringBuilder payload = new StringBuilder(); - String queryParameterURL = queryParams.get(URL_PARAM_KEY); - if (queryParameterURL != null && queryParameterURL.contains(NULL_BYTE_CHARACTER)) { - try { - URL url = new URL(queryParameterURL); - RestTemplate restTemplate = new RestTemplate(); - payload.append(restTemplate.getForObject(url.toURI(), String.class)); - } catch (IOException | URISyntaxException e) { - LOGGER.error("Following error occurred:", e); - } + if (queryParameterURL == null) { + return wrap(payload.toString()); + } + URL url = parse(queryParameterURL); + if (url == null || !isAllowedIncludeTarget(url)) { + return wrap(REFUSED_MESSAGE); } + try { + RestTemplate restTemplate = new RestTemplate(); + payload.append(restTemplate.getForObject(url.toURI(), String.class)); + } catch (URISyntaxException | RuntimeException e) { + LOGGER.error("Following error occurred:", e); + } + return wrap(payload.toString()); + } + private ResponseEntity wrap(String payload) { return new ResponseEntity<>( - GenericUtils.wrapPayloadInGenericVulnerableAppTemplate(payload.toString()), - HttpStatus.OK); + GenericUtils.wrapPayloadInGenericVulnerableAppTemplate(payload), HttpStatus.OK); + } + + private static URL parse(String value) { + try { + URL url = new URL(value); + url.toURI(); + return url; + } catch (MalformedURLException | URISyntaxException e) { + LOGGER.error("Provided URL: {} is not valid and following exception occurred", value, e); + return null; + } + } + + /** + * Allow list check for a destination the server is about to include. + * + *

Four things have to hold: the scheme is http or https, no userinfo is present (so {@code + * http://github.com@169.254.169.254/} cannot masquerade as an allowed host), the host name is + * on {@link #ALLOWED_HOSTS}, and every address that name resolves to is a routable public + * address. Resolving is what makes the last check meaningful: a name comparison alone would + * still admit an allow-listed name re-pointed at an internal address, and a list of forbidden + * literals would miss the decimal, octal and IPv6 mapped spellings of the same address. + */ + private static boolean isAllowedIncludeTarget(URL url) { + String protocol = url.getProtocol(); + if (protocol == null || !ALLOWED_SCHEMES.contains(protocol.toLowerCase(Locale.ROOT))) { + return false; + } + if (url.getUserInfo() != null) { + return false; + } + String host = url.getHost(); + if (host == null || host.isEmpty()) { + return false; + } + host = host.toLowerCase(Locale.ROOT); + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + if (!ALLOWED_HOSTS.contains(host)) { + return false; + } + try { + InetAddress[] resolvedAddresses = InetAddress.getAllByName(host); + if (resolvedAddresses.length == 0) { + return false; + } + for (InetAddress resolvedAddress : resolvedAddresses) { + if (isInternalAddress(resolvedAddress)) { + LOGGER.error( + "Host {} resolves to internal address {}, refusing to include it", + host, + resolvedAddress); + return false; + } + } + } catch (UnknownHostException e) { + LOGGER.error("Unable to resolve host: {} for the provided URL", host, e); + return false; + } + return true; + } + + /** + * Rejects loopback, link local (which covers the {@code 169.254.169.254} cloud metadata + * service), site local / private, carrier grade NAT, multicast, wildcard and otherwise reserved + * addresses, in both IPv4 and IPv6. + */ + private static boolean isInternalAddress(InetAddress address) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return true; + } + byte[] addressBytes = address.getAddress(); + if (address instanceof Inet4Address) { + int firstOctet = addressBytes[0] & 0xFF; + int secondOctet = addressBytes[1] & 0xFF; + int thirdOctet = addressBytes[2] & 0xFF; + // 0.0.0.0/8, 127.0.0.0/8, 100.64.0.0/10 (CGNAT), 192.0.0.0/24 (IETF protocol + // assignments), 198.18.0.0/15 (benchmarking) and 240.0.0.0/4 (reserved). + return firstOctet == 0 + || firstOctet == 127 + || (firstOctet == 100 && secondOctet >= 64 && secondOctet <= 127) + || (firstOctet == 192 && secondOctet == 0 && thirdOctet == 0) + || (firstOctet == 198 && (secondOctet == 18 || secondOctet == 19)) + || firstOctet >= 240; + } + if (address instanceof Inet6Address) { + // fc00::/7, the IPv6 unique local addresses. + return (addressBytes[0] & 0xFE) == 0xFC; + } + return false; } } 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..29834cb05 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementService.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementService.java @@ -1,12 +1,12 @@ package org.sasanlabs.service.vulnerability.sessionManagement; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; import org.springframework.http.HttpHeaders; @@ -38,10 +38,12 @@ private FailedLoginAttempt(int count, long lastAttemptTime) { private static final String MESSAGE = "message"; private static final String SESSION_ID = "sessionId"; private static final String INVALID_CREDENTIALS = "Invalid credentials"; - private static final String SESSION_PREFIX = "SESSION-"; - private static final String VULNERABILITY = "vulnerability"; private static final String FAILED_ATTEMPT_MESSAGE = "Too many failed attempts, try again after 5 mins"; + + /** Wrong passwords an account will answer before it stops answering guesses. */ + private static final int MAX_FAILED_ATTEMPTS = 3; + private static final String USER_ID = "userId"; private static final String USERNAME = "username"; private static final String FIRSTNAME = "firstname"; @@ -51,17 +53,27 @@ private FailedLoginAttempt(int count, long lastAttemptTime) { private static final String SERVER_SESSION_INVALIDATED = "serverSessionInvalidated"; private static final long FAILED_ATTEMPT_TTL_MILLIS = 5 * 60 * 1000; - private final Map level6FailedAttempts = new ConcurrentHashMap<>(); + private final Map failedAttempts = new ConcurrentHashMap<>(); private final Map sessions = new ConcurrentHashMap<>(); - private final AtomicInteger level1PredictableSessionCounter = new AtomicInteger(1000); - private final AtomicInteger level2PredictableSessionCounter = new AtomicInteger(1000); - private final AtomicInteger level3PredictableSessionCounter = new AtomicInteger(18920); private final SessionUserRepository sessionUserRepository; public SessionManagementService(SessionUserRepository sessionUserRepository) { this.sessionUserRepository = sessionUserRepository; } + /** + * Authenticates and issues a session identifier the server chose. + * + *

A session identifier presented before authentication has no standing: whoever sent it is, + * by definition, not yet logged in, and honouring it after a successful login is what session + * fixation is. The attack needs nothing more than the ability to plant a cookie value in the + * victim's browser - a subdomain, a stale link, an XSS anywhere on the origin - and then wait: + * the moment the victim authenticates, the value the attacker already holds is bound to the + * victim's account. So the identifier presented on the way in is discarded and a fresh + * unpredictable one is minted at the privilege change, which is exactly what the secure sibling + * level does. Any session that was registered under the presented identifier is dropped, so a + * planted value is not merely unused but no longer resolves to anyone. + */ public ResponseEntity> level1Login( String username, String password, String incomingSessionId) { Optional user = @@ -70,29 +82,21 @@ public ResponseEntity> level1Login( return response(INVALID_CREDENTIALS, false); } - boolean clientSuppliedSession = incomingSessionId != null && !incomingSessionId.isBlank(); - String sessionId = incomingSessionId; - if (sessionId == null || sessionId.isBlank()) { - sessionId = SESSION_PREFIX + level1PredictableSessionCounter.incrementAndGet(); + if (incomingSessionId != null && !incomingSessionId.isBlank()) { + sessions.remove(sessionKey(LevelConstants.LEVEL_1, incomingSessionId)); } + String sessionId = newSessionId(); sessions.put(sessionKey(LevelConstants.LEVEL_1, sessionId), user.get()); Map content = new LinkedHashMap<>(); content.put(MESSAGE, SUCCESSFUL_LOGIN_MESSAGE); content.put(SESSION_ID, sessionId); - if (clientSuppliedSession) { - content.put("preLoginSessionId", incomingSessionId); - content.put("postLoginSessionId", sessionId); - content.put("sessionFixationConfirmed", incomingSessionId.equals(sessionId)); - content.put(VULNERABILITY, "Client-supplied session ID was kept after login"); - } - - ResponseCookie cookie = - ResponseCookie.from(LEVEL1_COOKIE, sessionId).path(COOKIE_PATH).build(); return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header( + HttpHeaders.SET_COOKIE, + buildHttpOnlySessionCookie(LEVEL1_COOKIE, sessionId).toString()) .body(new GenericVulnerabilityResponseBean<>(content, true)); } @@ -104,13 +108,17 @@ public ResponseEntity> level2Login( return response(INVALID_CREDENTIALS, false); } - String sessionId = SESSION_PREFIX + level2PredictableSessionCounter.incrementAndGet(); + // A counter is not a secret. Every issued identifier told the holder what the next one and + // the previous one would be, so any authenticated user could read the identifier of the + // session issued just before or just after their own and use it against the profile + // endpoint. The identifier is now drawn from the same unpredictable source as the secure + // sibling level, so observing one says nothing about any other. + String sessionId = newSessionId(); sessions.put(sessionKey(LevelConstants.LEVEL_2, sessionId), user.get()); Map content = new LinkedHashMap<>(); content.put(MESSAGE, SUCCESSFUL_LOGIN_MESSAGE); content.put(SESSION_ID, sessionId); - content.put(VULNERABILITY, "Session ID was generated from a predictable counter"); return ResponseEntity.ok() .header( @@ -127,14 +135,19 @@ public ResponseEntity> level3Login( return response(INVALID_CREDENTIALS, false); } - String sessionId = SESSION_PREFIX + level3PredictableSessionCounter.addAndGet(100); - String encodedSessionId = Base64.getEncoder().encodeToString(sessionId.getBytes()); + // Base64 is an encoding, not a secret: decoding it is a one line operation and what came out + // was a counter stepping by a fixed amount, so the identifier was as guessable as the one on + // the level below and only looked otherwise. The encoding is kept so the identifier has the + // shape callers expect, but what is encoded is now unpredictable, which is the part that + // ever mattered. + String sessionId = newSessionId(); + String encodedSessionId = + Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)); sessions.put(sessionKey(LevelConstants.LEVEL_3, encodedSessionId), user.get()); Map content = new LinkedHashMap<>(); content.put(MESSAGE, SUCCESSFUL_LOGIN_MESSAGE); content.put(SESSION_ID, encodedSessionId); - content.put(VULNERABILITY, "Session ID was generated from a predictable counter"); return ResponseEntity.ok() .header( @@ -151,7 +164,7 @@ public ResponseEntity> level4Login( return response(INVALID_CREDENTIALS, false); } - String sessionId = UUID.randomUUID().toString(); + String sessionId = newSessionId(); sessions.put(sessionKey(LevelConstants.LEVEL_4, sessionId), user.get()); Map content = new LinkedHashMap<>(); @@ -165,26 +178,49 @@ public ResponseEntity> level4Login( .body(new GenericVulnerabilityResponseBean<>(content, true)); } - public ResponseEntity> level5Login( + /** + * Authenticates under the same per account attempt bound the secure sibling level applies. + * + *

Unlimited guesses turn every weak password into a known one: the endpoint answered a wrong + * password as fast as a right one and never counted, so a small wordlist run against a single + * username was guaranteed to finish. Failures are now counted per account and the account stops + * answering guesses once {@value #MAX_FAILED_ATTEMPTS} of them have been wrong, which is what + * makes an online guessing run stop being worth mounting. A successful login clears the count, + * so the legitimate owner is never locked out by their own typo. + */ + public synchronized ResponseEntity> level5Login( String username, String password) { + if (username == null || username.isBlank()) { + return response(INVALID_CREDENTIALS, false); + } + if (getFailedAttemptCount(LevelConstants.LEVEL_5, username) >= MAX_FAILED_ATTEMPTS) { + Map content = new LinkedHashMap<>(); + content.put(MESSAGE, FAILED_ATTEMPT_MESSAGE); + content.put(RATE_LIMITING_APPLIED, true); + content.put("loginBlocked", true); + return response(content, false); + } + Optional user = sessionUserRepository.findByUsernameAndPassword(username, password); if (user.isEmpty()) { + int failedAttempts = incrementFailedAttempt(LevelConstants.LEVEL_5, username); Map content = new LinkedHashMap<>(); content.put(MESSAGE, INVALID_CREDENTIALS); - content.put("attemptAllowed", true); - content.put(RATE_LIMITING_APPLIED, false); - content.put(VULNERABILITY, "Login attempts are not rate limited"); + content.put("failedAttempts", failedAttempts); + content.put("attemptAllowed", false); + content.put(RATE_LIMITING_APPLIED, true); return response(content, false); } - String sessionId = UUID.randomUUID().toString(); + failedAttempts.remove(attemptKey(LevelConstants.LEVEL_5, username)); + String sessionId = newSessionId(); sessions.put(sessionKey(LevelConstants.LEVEL_5, sessionId), user.get()); Map content = new LinkedHashMap<>(); content.put(MESSAGE, SUCCESSFUL_LOGIN_MESSAGE); content.put(SESSION_ID, sessionId); - content.put(RATE_LIMITING_APPLIED, false); + content.put(RATE_LIMITING_APPLIED, true); return ResponseEntity.ok() .header( @@ -198,7 +234,7 @@ public synchronized ResponseEntity> lev if (username == null || username.isBlank()) { return response(INVALID_CREDENTIALS, false); } - if (getLevel6FailedAttemptCount(username) >= 3) { + if (getFailedAttemptCount(LevelConstants.LEVEL_6, username) >= MAX_FAILED_ATTEMPTS) { Map content = new LinkedHashMap<>(); content.put(MESSAGE, FAILED_ATTEMPT_MESSAGE); content.put(RATE_LIMITING_APPLIED, true); @@ -209,16 +245,16 @@ public synchronized ResponseEntity> lev Optional user = sessionUserRepository.findByUsernameAndPassword(username, password); if (user.isEmpty()) { - int failedAttempts = incrementLevel6FailedAttempt(username); + int failedAttemptCount = incrementFailedAttempt(LevelConstants.LEVEL_6, username); Map content = new LinkedHashMap<>(); content.put(MESSAGE, INVALID_CREDENTIALS); - content.put("failedAttempts", failedAttempts); + content.put("failedAttempts", failedAttemptCount); content.put(RATE_LIMITING_APPLIED, true); return response(content, false); } - level6FailedAttempts.remove(username); - String sessionId = UUID.randomUUID().toString(); + failedAttempts.remove(attemptKey(LevelConstants.LEVEL_6, username)); + String sessionId = newSessionId(); if (incomingSessionId != null && !incomingSessionId.isBlank()) { sessions.remove(sessionKey(LevelConstants.LEVEL_6, incomingSessionId)); } @@ -260,28 +296,16 @@ public ResponseEntity> profile( return response(content, true); } - public ResponseEntity> logoutWithoutInvalidation( - String cookieName, String sessionId, Boolean includeProof, Boolean isHttpOnly) { - ResponseCookie cookie = - ResponseCookie.from(cookieName, "") - .path(COOKIE_PATH) - .maxAge(0) - .httpOnly(isHttpOnly) - .build(); - - Map content = new LinkedHashMap<>(); - content.put(MESSAGE, "Logged out"); - if (includeProof) { - content.put(SESSION_ID, sessionId); - content.put(SERVER_SESSION_INVALIDATED, false); - content.put( - VULNERABILITY, "Logout cleared the cookie but kept the server-side session"); - } - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(new GenericVulnerabilityResponseBean<>(content, true)); - } - + /** + * Ends the session on the server, not only in the browser. + * + *

Clearing the cookie only removes the copy the honest client holds. It does nothing to the + * copy an attacker already took, and the server went on accepting that copy: the logout a user + * performs on a shared or borrowed machine, precisely the moment they most need the session to + * end, ended nothing. The registration is now dropped first, so the identifier stops resolving + * to anyone before the response is written, and only then is the cookie cleared. This is the one + * logout path every level uses. + */ public ResponseEntity> logoutWithInvalidation( String cookieName, String level, String sessionId) { if (sessionId != null && !sessionId.isBlank()) { @@ -313,26 +337,48 @@ private static ResponseEntity> response return ResponseEntity.ok(new GenericVulnerabilityResponseBean<>(content, isValid)); } - private int getLevel6FailedAttemptCount(String username) { - FailedLoginAttempt attempt = level6FailedAttempts.get(username); + /** + * Draws a session identifier that cannot be guessed from any other one. + * + *

{@link UUID#randomUUID()} is backed by a cryptographically strong generator, so the 122 + * random bits in the identifier carry no relationship to the identifier issued before or after + * it. This is the single source used by every level, so no level can regress to a counter. + */ + private static String newSessionId() { + return UUID.randomUUID().toString(); + } + + /** + * Counts are held per level as well as per account. The levels share one seeded user table, so + * a counter keyed on the username alone would let guesses aimed at one level lock the same + * account out of another. + */ + private static String attemptKey(String level, String username) { + return level + ":" + username; + } + + private int getFailedAttemptCount(String level, String username) { + String key = attemptKey(level, username); + FailedLoginAttempt attempt = failedAttempts.get(key); if (attempt == null) { return 0; } if (System.currentTimeMillis() - attempt.lastAttemptTime > FAILED_ATTEMPT_TTL_MILLIS) { - level6FailedAttempts.remove(username); + failedAttempts.remove(key); return 0; } return attempt.count; } - private int incrementLevel6FailedAttempt(String username) { + private int incrementFailedAttempt(String level, String username) { long now = System.currentTimeMillis(); - FailedLoginAttempt attempt = level6FailedAttempts.get(username); + String key = attemptKey(level, username); + FailedLoginAttempt attempt = failedAttempts.get(key); if (attempt == null || now - attempt.lastAttemptTime > FAILED_ATTEMPT_TTL_MILLIS) { - level6FailedAttempts.put(username, new FailedLoginAttempt(1, now)); + failedAttempts.put(key, new FailedLoginAttempt(1, now)); return 1; } 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..37568c6e4 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementVulnerability.java @@ -64,8 +64,10 @@ public ResponseEntity> level1SessionFix return sessionManagementService.level1Login(username, password, sessionId); 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<>( @@ -112,8 +114,10 @@ public ResponseEntity> level2Predictabl String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; return sessionManagementService.level2Login(username, password); 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<>( @@ -161,8 +165,10 @@ public ResponseEntity> level2Profile( String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; return sessionManagementService.level3Login(username, password); 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<>( @@ -209,8 +215,10 @@ public ResponseEntity> level4MissingLog String password = loginRequestBody != null ? loginRequestBody.getPassword() : ""; return sessionManagementService.level4Login(username, password); 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<>( 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..4a63387ed 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/BlindSQLInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/BlindSQLInjectionVulnerability.java @@ -3,6 +3,8 @@ import java.util.Map; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; import org.sasanlabs.internal.utility.annotations.AttackVector; @@ -39,6 +41,9 @@ public class BlindSQLInjectionVulnerability { static final String CAR_IS_PRESENT_RESPONSE = "{ \"isCarPresent\": true}"; + private static final transient Logger LOGGER = + LogManager.getLogger(BlindSQLInjectionVulnerability.class); + public BlindSQLInjectionVulnerability( @Qualifier("applicationJdbcTemplate") JdbcTemplate applicationJdbcTemplate) { this.applicationJdbcTemplate = applicationJdbcTemplate; @@ -87,17 +92,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 isCarPresent(queryParams.get(Constants.ID)); } @AttackVector( @@ -128,18 +123,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 isCarPresent(queryParams.get(Constants.ID)); } @VulnerableAppRequestMapping( @@ -210,4 +194,38 @@ public ResponseEntity getCarInformationLevel5( .body(ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); } } + + /** + * Answers whether a car with the supplied identifier exists, using a parameterized query so + * that the caller supplied value is always bound as data and can never alter the structure of + * the statement. + * + *

The lookup is wrapped so that a value which is not a valid identifier is answered with the + * ordinary "car is not present" body rather than escaping as a 5xx. The {@code id} column is an + * INT, so binding a value such as {@code 1 AND (SELECT ...)=1} makes the database raise a + * conversion error; letting that propagate would replace the injection with an error oracle and + * would leave a probe unable to tell a blocked attack apart from a broken endpoint. + */ + private ResponseEntity isCarPresent(final String id) { + BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); + try { + return applicationJdbcTemplate.query( + (conn) -> conn.prepareStatement("select * from cars where id=?"), + (prepareStatement) -> { + prepareStatement.setString(1, id); + }, + (rs) -> { + if (rs.next()) { + return bodyBuilder.body(CAR_IS_PRESENT_RESPONSE); + } + return bodyBuilder.body( + ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); + }); + } catch (Exception ex) { + LOGGER.error("Following error occurred", ex); + return bodyBuilder.body( + ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); + } + } + } 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..8aaf902bd 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/ErrorBasedSQLInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/ErrorBasedSQLInjectionVulnerability.java @@ -38,8 +38,6 @@ public class ErrorBasedSQLInjectionVulnerability { private static final transient Logger LOGGER = LogManager.getLogger(ErrorBasedSQLInjectionVulnerability.class); - private static final Function GENERIC_EXCEPTION_RESPONSE_FUNCTION = - (ex) -> "{ \"isCarPresent\": false, \"moreInfo\": " + ex.getMessage() + "}"; static final String CAR_IS_NOT_PRESENT_RESPONSE = "{ \"isCarPresent\": false}"; static final Function CAR_IS_PRESENT_RESPONSE = (carInformation) -> @@ -59,39 +57,8 @@ 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)); - } + final String id = queryParams.get(Constants.ID); + return findCarInformationById(id); } @AttackVector( @@ -104,39 +71,8 @@ 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)); - } + final String id = queryParams.get(Constants.ID); + return findCarInformationById(id); } // https://stackoverflow.com/questions/15537368/how-can-sanitation-that-escapes-single-quotes-be-defeated-by-sql-injection-in-sq @@ -150,43 +86,8 @@ 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)); - } + final String id = queryParams.get(Constants.ID); + return findCarInformationById(id); } // Assumption that only creating PreparedStatement object can save is wrong. You @@ -200,45 +101,8 @@ 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)); - } + final String id = queryParams.get(Constants.ID); + return findCarInformationById(id); } @VulnerableAppRequestMapping( @@ -290,4 +154,47 @@ public ResponseEntity doesCarInformationExistsLevel5( ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); } } + + /** + * Looks up a car by its identifier using a parameterized query so that the user supplied value + * is always bound as data and can never alter the structure of the statement. Any failure is + * reported with the same generic body and status code that a miss returns, so no database error + * detail ever reaches the caller. + */ + private ResponseEntity findCarInformationById(final String id) { + BodyBuilder bodyBuilder = ResponseEntity.status(HttpStatus.OK); + try { + return applicationJdbcTemplate.query( + (conn) -> conn.prepareStatement("select * from cars where id=?"), + (prepareStatement) -> { + prepareStatement.setString(1, 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( + ErrorBasedSQLInjectionVulnerability + .CAR_IS_NOT_PRESENT_RESPONSE); + } + } else { + return bodyBuilder.body( + ErrorBasedSQLInjectionVulnerability + .CAR_IS_NOT_PRESENT_RESPONSE); + } + }); + } catch (Exception ex) { + LOGGER.error("Following error occurred", ex); + return bodyBuilder.body( + ErrorBasedSQLInjectionVulnerability.CAR_IS_NOT_PRESENT_RESPONSE); + } + } } 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..136c91e52 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/UnionBasedSQLInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/sqlInjection/UnionBasedSQLInjectionVulnerability.java @@ -9,6 +9,8 @@ import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Root; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; import org.sasanlabs.internal.utility.annotations.AttackVector; @@ -39,6 +41,9 @@ value = "UnionBasedSQLInjectionVulnerability") public class UnionBasedSQLInjectionVulnerability { + private static final transient Logger LOGGER = + LogManager.getLogger(UnionBasedSQLInjectionVulnerability.class); + private final JdbcTemplate applicationJdbcTemplate; private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final CarInformationRepository carInformationRepository; @@ -64,9 +69,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 findCarInformationById(queryParams.get("id")); } @AttackVector( @@ -79,9 +82,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 findCarInformationById(queryParams.get("id")); } @AttackVector( @@ -200,4 +201,27 @@ private ResponseEntity resultSetToResponse(final ResultSet rs) } return new ResponseEntity<>(carInformation, HttpStatus.OK); } + + /** + * Looks up a car by identifier with a parameterized query, so the caller supplied value is + * always bound as data and can never extend the statement with a UNION. + * + *

The lookup is wrapped so that a value which is not a valid identifier is answered with the + * ordinary empty car body rather than escaping as a 5xx: the {@code id} column is an INT, so + * binding a value such as {@code 1 union select ...} makes the database raise a conversion + * error, and letting that propagate would leave a probe unable to tell a blocked attack apart + * from a broken endpoint. + */ + private ResponseEntity findCarInformationById(final String id) { + try { + return applicationJdbcTemplate.query( + "select * from cars where id=?", + prepareStatement -> prepareStatement.setString(1, id), + this::resultSetToResponse); + } catch (Exception ex) { + LOGGER.error("Following error occurred", ex); + return new ResponseEntity<>(new CarInformation(), HttpStatus.OK); + } + } + } 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..0a3d7193c 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/ssrf/SSRFVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/ssrf/SSRFVulnerability.java @@ -2,11 +2,22 @@ import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -33,7 +44,36 @@ public class SSRFVulnerability { private static final String FILE_URL = "fileurl"; - private static final String FILE_PROTOCOL = "file://"; + + /** + * Only these two schemes are ever fetched. Everything else, {@code file:}, {@code jar:}, {@code + * netdoc:}, {@code ftp:}, {@code gopher:}, {@code dict:} and friends, is refused, so a scheme + * deny list such as a {@code startsWith("file://")} check (which {@code FILE:/etc/passwd} and + * {@code file:/etc/passwd} both slip past) is no longer relied upon. + */ + private static final Set ALLOWED_SCHEMES = + Collections.unmodifiableSet(new HashSet<>(Arrays.asList("http", "https"))); + + /** + * Allow list of the destinations this application legitimately fetches. This is what actually + * closes the SSRF: an address level deny list on its own is bypassable through redirects, DNS + * rebinding and alternative spellings of an internal address, whereas a host that is not on + * this list is never contacted at all. + */ + private static final Set ALLOWED_HOSTS = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + "gist.githubusercontent.com", + "raw.githubusercontent.com", + "github.com"))); + + private static final int MAX_REDIRECTS = 5; + private static final int CONNECT_TIMEOUT_IN_MILLIS = 5000; + private static final int READ_TIMEOUT_IN_MILLIS = 10000; + private static final String BLOCKED_REDIRECT_RESPONSE = + "Redirect to a destination outside the allow list was refused"; + private final String gistUrl; public SSRFVulnerability(@Value("${gistId.sasanlabs.projects}") String gistId) { @@ -53,6 +93,116 @@ private boolean isUrlValid(String url) { } } + /** + * Parses the user supplied value and decides whether the server is allowed to fetch it. See + * {@link #isAllowedFetchTarget(URL)} for the checks that are applied. + */ + private static boolean isAllowedFetchTarget(String url) { + if (url == null) { + return false; + } + try { + URL parsedUrl = new URL(url); + parsedUrl.toURI(); + return isAllowedFetchTarget(parsedUrl); + } catch (MalformedURLException | URISyntaxException e) { + LOGGER.error("Provided URL: {} is not valid and following exception occured", url, e); + return false; + } + } + + /** + * Allow list check for a destination the server is about to fetch. + * + *

Four things have to hold: the scheme is http or https, no userinfo is present (so {@code + * http://gist.githubusercontent.com@169.254.169.254/} cannot masquerade as an allowed host), + * the host name is on {@link #ALLOWED_HOSTS}, and every address that host name resolves to is a + * routable public address. + * + *

The resolution step is the reason a string comparison alone is not enough. Checking the + * name would still allow an allow-listed name that is re-pointed at an internal address (DNS + * rebinding), and checking a literal against a list of forbidden strings would miss the decimal + * ({@code 2852039166}), octal, IPv6 mapped ({@code [::ffff:169.254.169.254]}) and shortened + * spellings of the very same address. Resolving first collapses every one of those spellings + * onto the same {@link InetAddress} before it is judged. + */ + private static boolean isAllowedFetchTarget(URL url) { + if (url == null) { + return false; + } + String protocol = url.getProtocol(); + if (protocol == null || !ALLOWED_SCHEMES.contains(protocol.toLowerCase(Locale.ROOT))) { + return false; + } + if (url.getUserInfo() != null) { + return false; + } + String host = url.getHost(); + if (host == null || host.isEmpty()) { + return false; + } + host = host.toLowerCase(Locale.ROOT); + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); + } + if (!ALLOWED_HOSTS.contains(host)) { + return false; + } + try { + InetAddress[] resolvedAddresses = InetAddress.getAllByName(host); + if (resolvedAddresses.length == 0) { + return false; + } + for (InetAddress resolvedAddress : resolvedAddresses) { + if (isInternalAddress(resolvedAddress)) { + LOGGER.error( + "Host {} resolves to internal address {}, refusing to fetch it", + host, + resolvedAddress); + return false; + } + } + } catch (UnknownHostException e) { + LOGGER.error("Unable to resolve host: {} for the provided URL", host, e); + return false; + } + return true; + } + + /** + * Rejects loopback, link local (which covers the {@code 169.254.169.254} cloud metadata + * service), site local / private, carrier grade NAT, multicast, wildcard and otherwise reserved + * addresses, in both IPv4 and IPv6. + */ + private static boolean isInternalAddress(InetAddress address) { + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress()) { + return true; + } + byte[] addressBytes = address.getAddress(); + if (address instanceof Inet4Address) { + int firstOctet = addressBytes[0] & 0xFF; + int secondOctet = addressBytes[1] & 0xFF; + int thirdOctet = addressBytes[2] & 0xFF; + // 0.0.0.0/8, 127.0.0.0/8, 100.64.0.0/10 (CGNAT), 192.0.0.0/24 (IETF protocol + // assignments), 198.18.0.0/15 (benchmarking) and 240.0.0.0/4 (reserved). + return firstOctet == 0 + || firstOctet == 127 + || (firstOctet == 100 && secondOctet >= 64 && secondOctet <= 127) + || (firstOctet == 192 && secondOctet == 0 && thirdOctet == 0) + || (firstOctet == 198 && (secondOctet == 18 || secondOctet == 19)) + || firstOctet >= 240; + } + if (address instanceof Inet6Address) { + // fc00::/7, the IPv6 unique local addresses. + return (addressBytes[0] & 0xFE) == 0xFC; + } + return false; + } + private ResponseEntity> invalidUrlResponse() { return new ResponseEntity<>( new GenericVulnerabilityResponseBean<>("Provided URL not valid", false), @@ -81,13 +231,61 @@ private ResponseEntity> invalidUrlRespo } String getResponseForURLConnection(URL u) throws IOException { - URLConnection urlConnection = u.openConnection(); + URL target = u; + for (int hop = 0; hop <= MAX_REDIRECTS; hop++) { + URLConnection urlConnection = target.openConnection(); + urlConnection.setConnectTimeout(CONNECT_TIMEOUT_IN_MILLIS); + urlConnection.setReadTimeout(READ_TIMEOUT_IN_MILLIS); + if (!(urlConnection instanceof HttpURLConnection)) { + return readResponseBody(urlConnection); + } + HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection; + // Redirects are followed by hand so that every hop is re-checked against the allow + // list. Letting the JDK follow them automatically would hand an attacker a way to + // reach an internal destination from an allowed one. + httpURLConnection.setInstanceFollowRedirects(false); + int responseCode = httpURLConnection.getResponseCode(); + String location = httpURLConnection.getHeaderField("Location"); + if (responseCode < 300 || responseCode >= 400 || location == null) { + return readResponseBody(httpURLConnection); + } + URL redirectTarget = new URL(target, location); + httpURLConnection.disconnect(); + if (!isAllowedFetchTarget(redirectTarget)) { + LOGGER.error("Refused to follow redirect to blocked target: {}", redirectTarget); + return BLOCKED_REDIRECT_RESPONSE; + } + target = redirectTarget; + } + return BLOCKED_REDIRECT_RESPONSE; + } + + private String readResponseBody(URLConnection urlConnection) throws IOException { try (BufferedReader reader = - new BufferedReader(new InputStreamReader(urlConnection.getInputStream()))) { + new BufferedReader(new InputStreamReader(openResponseStream(urlConnection)))) { return reader.lines().collect(Collectors.joining()); } } + /** + * An error status makes {@code getInputStream()} throw, which would escape the controller as a + * 500. The error stream carries the body in that case, so the endpoint keeps answering with the + * remote response instead of failing. + */ + private InputStream openResponseStream(URLConnection urlConnection) throws IOException { + try { + return urlConnection.getInputStream(); + } catch (IOException e) { + if (urlConnection instanceof HttpURLConnection) { + InputStream errorStream = ((HttpURLConnection) urlConnection).getErrorStream(); + if (errorStream != null) { + return errorStream; + } + } + throw e; + } + } + @AttackVector( vulnerabilityExposed = VulnerabilityType.SIMPLE_SSRF, description = "SSRF_VULNERABILITY_URL_WITHOUT_CHECK", @@ -95,7 +293,9 @@ String getResponseForURLConnection(URL u) throws IOException { @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/SSRF") public ResponseEntity> getVulnerablePayloadLevel1( @RequestParam(FILE_URL) String url) throws IOException { - if (isUrlValid(url)) { + // Every URL was fetched, so file:///etc/passwd read local files and + // http://169.254.169.254/... handed out the cloud instance credentials. + if (isAllowedFetchTarget(url)) { return getGenericVulnerabilityResponseWhenURL(url); } else { return invalidUrlResponse(); @@ -109,10 +309,10 @@ public ResponseEntity> getVulnerablePay @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_1/SSRF") public ResponseEntity> getVulnerablePayloadLevel2( @RequestParam(FILE_URL) String url) throws IOException { - if (isUrlValid(url) && !url.startsWith(FILE_PROTOCOL)) { - + // Banning the literal "file://" prefix left FILE://, file:/etc/passwd, jar:file:, + // netdoc: and ftp:// usable, and did nothing about the metadata service. + if (isAllowedFetchTarget(url)) { return getGenericVulnerabilityResponseWhenURL(url); - } else { return invalidUrlResponse(); } @@ -125,13 +325,13 @@ public ResponseEntity> getVulnerablePay @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_1/SSRF") public ResponseEntity> getVulnerablePayloadLevel3( @RequestParam(FILE_URL) String url) throws IOException { - if (isUrlValid(url) && !url.startsWith(FILE_PROTOCOL)) { - if (new URL(url).getHost().equals("169.254.169.254")) { - return this.invalidUrlResponse(); - } + // Comparing the host string against "169.254.169.254" is a deny list over spellings: + // [::ffff:169.254.169.254], 169.254.170.2 and the decimal form 2852039166 all name the + // same link local range and all bypassed it. The resolved address is checked instead. + if (isAllowedFetchTarget(url)) { return getGenericVulnerabilityResponseWhenURL(url); } else { - return this.invalidUrlResponse(); + return invalidUrlResponse(); } } @@ -142,10 +342,9 @@ public ResponseEntity> getVulnerablePay @VulnerableAppRequestMapping(value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_1/SSRF") public ResponseEntity> getVulnerablePayloadLevel4( @RequestParam(FILE_URL) String url) throws IOException { - if (isUrlValid(url) && !url.startsWith(FILE_PROTOCOL)) { - if (MetaDataServiceMock.isPresent(new URL(url))) { - return this.invalidUrlResponse(); - } + // Enumerating the known metadata hosts still let every other internal destination + // through, for example http://localhost:9090/ or a private RFC1918 address. + if (isAllowedFetchTarget(url)) { return getGenericVulnerabilityResponseWhenURL(url); } else { return this.invalidUrlResponse(); 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..5615eb729 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 @@ -2,7 +2,6 @@ import java.util.Map; import java.util.function.Function; -import java.util.regex.Pattern; import org.apache.commons.text.StringEscapeUtils; import org.sasanlabs.internal.utility.LevelConstants; import org.sasanlabs.internal.utility.Variant; @@ -10,7 +9,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; @@ -26,9 +24,6 @@ public class PersistentXSSInHTMLTagVulnerability { private static final String PARAMETER_NAME = "comment"; - private static final Pattern IMG_INPUT_TAG_PATTERN = Pattern.compile("( getVulnerablePayloadLevel1( @RequestParam Map queryParams) { return new ResponseEntity( - this.getCommentsPayload(queryParams, LevelConstants.LEVEL_1, post -> post), + this.getCommentsPayload( + queryParams, + LevelConstants.LEVEL_1, + post -> StringEscapeUtils.escapeHtml4(post)), HttpStatus.OK); } + // Stripping getVulnerablePayloadLevel2( this.getCommentsPayload( queryParams, LevelConstants.LEVEL_2, - post -> IMG_INPUT_TAG_PATTERN.matcher(post).replaceAll("")), + post -> StringEscapeUtils.escapeHtml4(post)), HttpStatus.OK); } - // + // A case insensitive strip of + // through. Encoding the post on output removes the need to guess at tag names. @AttackVector( vulnerabilityExposed = VulnerabilityType.PERSISTENT_XSS, description = @@ -132,14 +113,12 @@ public ResponseEntity getVulnerablePayloadLevel3( this.getCommentsPayload( queryParams, LevelConstants.LEVEL_3, - post -> - IMG_INPUT_TAG_CASE_INSENSITIVE_PATTERN - .matcher(post) - .replaceAll("")), + post -> StringEscapeUtils.escapeHtml4(post)), HttpStatus.OK); } - // NullByte + // The tag check stopped at the first null byte while the whole post was rendered, so a payload + // hidden behind a null byte was never inspected. Encoding covers the whole post instead. @AttackVector( vulnerabilityExposed = {VulnerabilityType.PERSISTENT_XSS}, description = @@ -149,19 +128,16 @@ public ResponseEntity getVulnerablePayloadLevel3( htmlTemplate = "LEVEL_1/PersistentXSS") public ResponseEntity getVulnerablePayloadLevel4( @RequestParam Map queryParams) { - Function function = - (post) -> { - boolean containsHarmfulTags = - this.nullByteVulnerablePatternChecker(post, IMG_INPUT_TAG_PATTERN); - return containsHarmfulTags - ? IMG_INPUT_TAG_PATTERN.matcher(post).replaceAll("") - : post; - }; return new ResponseEntity( - this.getCommentsPayload(queryParams, LevelConstants.LEVEL_4, function), + this.getCommentsPayload( + queryParams, + LevelConstants.LEVEL_4, + post -> StringEscapeUtils.escapeHtml4(post)), HttpStatus.OK); } + // Same null byte blind spot as LEVEL_4, only case insensitive. Fixed the same way, by encoding + // the entire post on output rather than inspecting a prefix of it. @AttackVector( vulnerabilityExposed = {VulnerabilityType.PERSISTENT_XSS}, description = @@ -171,20 +147,17 @@ public ResponseEntity getVulnerablePayloadLevel4( htmlTemplate = "LEVEL_1/PersistentXSS") public ResponseEntity getVulnerablePayloadLevel5( @RequestParam Map queryParams) { - Function function = - (post) -> { - boolean containsHarmfulTags = - this.nullByteVulnerablePatternChecker( - post, IMG_INPUT_TAG_CASE_INSENSITIVE_PATTERN); - return containsHarmfulTags - ? IMG_INPUT_TAG_CASE_INSENSITIVE_PATTERN.matcher(post).replaceAll("") - : post; - }; return new ResponseEntity( - this.getCommentsPayload(queryParams, LevelConstants.LEVEL_5, function), + this.getCommentsPayload( + queryParams, + LevelConstants.LEVEL_5, + post -> StringEscapeUtils.escapeHtml4(post)), HttpStatus.OK); } + // The escaping itself stopped at the first null byte and the remainder of the post was + // concatenated raw, so everything after a null byte was unescaped markup. The whole post is + // now encoded in one pass. @AttackVector( vulnerabilityExposed = {VulnerabilityType.PERSISTENT_XSS}, description = @@ -194,18 +167,11 @@ public ResponseEntity getVulnerablePayloadLevel5( htmlTemplate = "LEVEL_1/PersistentXSS") public ResponseEntity getVulnerablePayloadLevel6( @RequestParam Map queryParams) { - Function function = - (post) -> { - // This logic represents null byte vulnerable escapeHtml function - return post.contains(Constants.NULL_BYTE_CHARACTER) - ? StringEscapeUtils.escapeHtml4( - post.substring( - 0, post.indexOf(Constants.NULL_BYTE_CHARACTER))) - + post.substring(post.indexOf(Constants.NULL_BYTE_CHARACTER)) - : StringEscapeUtils.escapeHtml4(post); - }; return new ResponseEntity( - this.getCommentsPayload(queryParams, LevelConstants.LEVEL_6, function), + this.getCommentsPayload( + queryParams, + LevelConstants.LEVEL_6, + post -> StringEscapeUtils.escapeHtml4(post)), HttpStatus.OK); } 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..0282a3733 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 @@ -9,7 +9,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; @@ -36,13 +35,43 @@ public class XSSInImgTagAttribute { private final Set allowedValues = new HashSet<>(); + private static final String IMG_TAG_TEMPLATE = ""; + public XSSInImgTagAttribute() { allowedValues.add(OWASP_IMAGE); allowedValues.add(ZAP_IMAGE); } - // Just adding User defined input(Untrusted Data) into Src tag is not secure. - // Can be broken by various ways + /** + * Accepts exactly what the secure LEVEL_7 sibling accepts: one of the two known images, or any + * PNG served out of the application's own image directory. Anything else is refused, so the + * reflected value can never be attacker chosen in the first place. + */ + private boolean isAllowedImageLocation(String imageLocation) { + return imageLocation != null + && (allowedValues.contains(imageLocation) + || (imageLocation.startsWith(IMAGE_RESOURCE_PATH) + && imageLocation.endsWith(FILE_EXTENSION))); + } + + /** + * Single response shape for the unsecure levels, matching the secure LEVEL_6 and LEVEL_7 + * siblings: an allowed image is rendered into a quoted, HTML encoded {@code src} attribute and + * anything else is refused with 400, exactly as both of them do. Encoding on its own leaves the + * endpoint reflecting arbitrary caller supplied text, which is why both secure siblings + * validate as well. + */ + private ResponseEntity renderAllowedImage(String imageLocation) { + if (!isAllowedImageLocation(imageLocation)) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + return new ResponseEntity<>( + String.format(IMG_TAG_TEMPLATE, StringEscapeUtils.escapeHtml4(imageLocation)), + HttpStatus.OK); + } + + // The untrusted value lands in an attribute value, so it needs both a quoted attribute and + // attribute context encoding. Quoting alone or encoding alone is not enough. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = "XSS_DIRECT_INPUT_SRC_ATTRIBUTE_IMG_TAG") @@ -50,14 +79,11 @@ public XSSInImgTagAttribute() { public ResponseEntity getVulnerablePayloadLevel1( @RequestParam(PARAMETER_NAME) String imageLocation) { - String vulnerablePayloadWithPlaceHolder = ""; - - return new ResponseEntity<>( - String.format(vulnerablePayloadWithPlaceHolder, imageLocation), HttpStatus.OK); + return renderAllowedImage(imageLocation); } - // Adding Untrusted Data into Src tag between quotes is beneficial but not - // without escaping the input + // The attribute was quoted but the value was not encoded, so a plain double quote closed it. + // Encoding the value keeps the quotes doing their job. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = "XSS_QUOTES_ON_INPUT_SRC_ATTRIBUTE_IMG_TAG") @@ -65,15 +91,11 @@ public ResponseEntity getVulnerablePayloadLevel1( public ResponseEntity getVulnerablePayloadLevel2( @RequestParam(PARAMETER_NAME) String imageLocation) { - String vulnerablePayloadWithPlaceHolder = ""; - - String payload = String.format(vulnerablePayloadWithPlaceHolder, imageLocation); - - return new ResponseEntity<>(payload, HttpStatus.OK); + return renderAllowedImage(imageLocation); } - // Good way for HTML escapes so hacker cannot close the tags but can use event - // handlers like onerror etc. eg:- ''onerror='alert(1);' + // The value was encoded but the attribute was unquoted, so whitespace ended the attribute and + // let an event handler in (eg:- ''onerror='alert(1);'). Quoting the attribute closes that. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = "XSS_HTML_ESCAPE_ON_DIRECT_INPUT_SRC_ATTRIBUTE_IMG_TAG") @@ -81,19 +103,12 @@ public ResponseEntity getVulnerablePayloadLevel2( 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 renderAllowedImage(imageLocation); } - // Good way for HTML escapes so hacker cannot close the tags and also cannot pass brackets but - // can use event - // handlers like onerror etc. eg:- onerror=alert`1` (backtick operator) + // Banning parenthesis did not help because the attribute was still unquoted and payloads such + // as onerror=alert`1` (backtick operator) need none. The blocklist is dropped in favour of a + // quoted attribute plus encoding of the value. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = @@ -102,21 +117,13 @@ public ResponseEntity getVulnerablePayloadLevel3( 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))); - } - - return new ResponseEntity<>(payload.toString(), HttpStatus.OK); + return renderAllowedImage(imageLocation); } - // Assume here that there is a validator vulnerable to Null Byte which validates the file name - // only till null byte + // The validator used to stop at the first null byte while the whole value was rendered, so + // "/VulnerableApp/images/ZAP.png\0 onerror=..." passed validation and still reached the page. + // The allow list is now applied to exactly the value that gets rendered, and the value is put + // in a quoted, encoded attribute so the allow list is not the only thing holding the line. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = @@ -125,26 +132,7 @@ public ResponseEntity getVulnerablePayloadLevel4( 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); + return renderAllowedImage(imageLocation); } // Good way and can protect against attacks but it is better to have check on 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..fd05632e4 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; @@ -27,8 +25,8 @@ value = "XSSWithHtmlTagInjection") public class XSSWithHtmlTagInjection { - // Just adding User defined input(Untrusted Data) into div tag. - // Can be broken by various ways + // The untrusted input lands in HTML text-node context, so it is HTML entity encoded + // before being written out. Encoding the output is the structural fix. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = "XSS_DIRECT_INPUT_DIV_TAG") @@ -38,15 +36,15 @@ public ResponseEntity getVulnerablePayloadLevel1( String vulnerablePayloadWithPlaceHolder = "

%s
"; StringBuilder payload = new StringBuilder(); for (Map.Entry map : queryParams.entrySet()) { - payload.append(String.format(vulnerablePayloadWithPlaceHolder, map.getValue())); + String escapedValue = StringEscapeUtils.escapeHtml4(map.getValue()); + payload.append(String.format(vulnerablePayloadWithPlaceHolder, escapedValue)); } return new ResponseEntity(payload.toString(), HttpStatus.OK); } - // Just adding User defined input(Untrusted Data) into div tag if doesn't contains - // anchor/script/image tag. - // Can be broken by various ways - // eg: + // The tag blocklist that used to guard this level was trivially bypassable (eg: + // ). Blocklisting markup is replaced by HTML + // entity encoding of the untrusted value, which is safe for every tag and event handler. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = @@ -56,20 +54,16 @@ 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())); - } + String escapedValue = StringEscapeUtils.escapeHtml4(map.getValue()); + payload.append(String.format(vulnerablePayloadWithPlaceHolder, escapedValue)); } return new ResponseEntity(payload.toString(), HttpStatus.OK); } - // Just adding User defined input(Untrusted Data) into div tag if doesn't contains - // anchor/script/image tag and also alert/javascript keyword. - // Can be broken by various ways - // eg: + // The tag blocklist plus the alert/javascript keyword ban were bypassable (eg: + // ). + // Keyword bans are replaced by HTML entity encoding of the untrusted value. @AttackVector( vulnerabilityExposed = VulnerabilityType.REFLECTED_XSS, description = @@ -79,14 +73,9 @@ 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())); - } + String escapedValue = StringEscapeUtils.escapeHtml4(map.getValue()); + payload.append(String.format(vulnerablePayloadWithPlaceHolder, escapedValue)); } return new ResponseEntity(payload.toString(), HttpStatus.OK); } 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..e374dd58d 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java @@ -2,6 +2,7 @@ import java.io.InputStream; import javax.servlet.http.HttpServletRequest; +import javax.xml.XMLConstants; import javax.xml.bind.JAXBContext; import javax.xml.bind.JAXBElement; import javax.xml.bind.JAXBException; @@ -55,12 +56,16 @@ 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"); + // Constructing this controller used to widen javax.xml.accessExternalDTD to "all". That + // is a process wide setting: it outlives the request, applies to every XML parser in the + // JVM including ones this class knows nothing about, and its only purpose was to let a + // document reach the local file system and make outbound calls. Each parser below states + // its own restrictions instead. this.bookEntityRepository = bookEntityRepository; } - // No XXE protection + // Unmarshalling is done through an explicitly hardened parser instead of handing the raw + // request stream to JAXB with the platform default configuration. @AttackVector(vulnerabilityExposed = VulnerabilityType.XXE, description = "XXE_NO_VALIDATION") @VulnerableAppRequestMapping( value = LevelConstants.LEVEL_1, @@ -70,17 +75,20 @@ public ResponseEntity> getVulnerablePaylo 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); + // The book documents this endpoint accepts never carry a DOCTYPE, so the strongest + // configuration applies: the declaration itself is rejected, which removes every + // entity (general, parameter, internal and external) and the external DTD subset in + // one step. The remaining features are kept as defence in depth in case a parser + // implementation is swapped in that does not honour disallow-doctype-decl. + SAXParserFactory spf = SAXParserFactory.newInstance(); + spf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + spf.setFeature("http://xml.org/sax/features/external-general-entities", false); + spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + spf.setXIncludeAware(false); + + return saveJaxBBasedBookInformation(spf, in, LevelConstants.LEVEL_1); } catch (Exception e) { LOGGER.error(e); } @@ -145,9 +153,25 @@ public ResponseEntity> getVulnerablePaylo HttpServletRequest request) { try { InputStream in = request.getInputStream(); - // Only disabling external Entities + // Disabling general entities on its own is not enough: parameter entities and the + // external DTD subset are separate channels and either of them can still reach the + // file system or make an outbound call. Switching those off closes the external + // routes, but it leaves the declaration itself accepted, and an entity declared in + // the internal subset needs no external route at all: it is expanded from the + // document and comes straight back in the echoed book, which is a caller controlled + // substitution the endpoint performs on request. The book documents this endpoint + // accepts carry no DOCTYPE, so the declaration is rejected outright, exactly as + // LEVEL_1 does. That removes general, parameter, internal and external entities and + // the external DTD subset in one step; the rest is kept as defence in depth for a + // parser that does not honour disallow-doctype-decl. SAXParserFactory spf = SAXParserFactory.newInstance(); + spf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); spf.setFeature("http://xml.org/sax/features/external-general-entities", false); + spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + spf.setXIncludeAware(false); + return saveJaxBBasedBookInformation(spf, in, LevelConstants.LEVEL_2); } catch (Exception e) { LOGGER.error(e); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a533e463e..bbee7f30b 100755 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,12 @@ server.port=9090 server.servlet.context-path=/VulnerableApp +# An upload refused by the multipart size bound is answered without its body having been read. +# Tomcat only discards up to max-swallow-size of an unread body before it resets the connection, +# which turns a clean refusal into a broken connection and leaves the client to retry, so refusing +# the upload costs the server nothing but still costs the client the whole transfer. -1 discards +# the remainder instead: the bytes are dropped as they arrive, nothing reaches disk or heap, and +# the size bound still holds. +server.tomcat.max-swallow-size=-1 # H2 Database inmem configuration Admin user configurations spring.datasource.admin.url=jdbc:h2:mem:testdb spring.datasource.admin.driverClassName=org.h2.Driver diff --git a/src/main/resources/scripts/Authentication/db/data.sql b/src/main/resources/scripts/Authentication/db/data.sql index c1a7b3e3d..7387b51bd 100644 --- a/src/main/resources/scripts/Authentication/db/data.sql +++ b/src/main/resources/scripts/Authentication/db/data.sql @@ -10,26 +10,43 @@ INSERT INTO auth_users VALUES (2, 'admin_logs', 'v9K#2mLp!8zQ', NULL, 'PLAIN', 2 -- Real password: 'b7X$4nRj-6mW' INSERT INTO auth_users VALUES (3, 'admin_plain', 'b7X$4nRj-6mW', NULL, 'PLAIN', 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'); - --- Level 5: SHA1 Hashing (x5B&3gHq+7vS) -INSERT INTO auth_users VALUES (5, 'admin_sha1', '632e10860bd26278451d3f89d1c46f180e5623e0', NULL, 'SHA1', 5, 'admin_sha1@example.com', 'ADMIN'); - --- Level 6: SHA-256 (No Salt) (m8D!4kLr#2jZ) -INSERT INTO auth_users VALUES (6, 'admin_sha256', '8b8eca84f7e2b04f531749f999c3bf9e3f045bab78f4c8a451fa70929b3c3946', NULL, 'SHA256', 6, 'admin_sha256@example.com', 'ADMIN'); +-- Level 4: BCrypt over the level 4 credential (f2C@9tYk*1hP) +-- The account used to hold a raw MD5 digest. MD5 is fast and unsalted, so a database dump was a +-- wordlist run away from the plaintext (hashcat -m 0 does billions of guesses a second), and two +-- users with the same password produced the same digest. BCrypt at cost 12 salts per credential +-- and is deliberately slow, which is the property a password hash needs. +-- Bcrypt hash (cost 12) for 'f2C@9tYk*1hP' +INSERT INTO auth_users VALUES (4, 'admin_md5', '$2a$12$22imQOrMpbjlucHCTgw0n.CxmjYOYqXA0khWklzpOopM57bG2IlUK', NULL, 'BCRYPT', 4, 'admin_md5@example.com', 'ADMIN'); + +-- Level 5: BCrypt over the level 5 credential (x5B&3gHq+7vS) +-- Was a raw SHA-1 digest. SHA-1 is deprecated and, more to the point here, just as fast and just as +-- unsalted as MD5, so it fell to exactly the same offline attack (hashcat -m 100). +-- Bcrypt hash (cost 12) for 'x5B&3gHq+7vS' +INSERT INTO auth_users VALUES (5, 'admin_sha1', '$2a$12$D.IwGfcq.6eTGCGtuazW/uoQChfVIzo0UtItuz10cOeFBPaQwlgJK', NULL, 'BCRYPT', 5, 'admin_sha1@example.com', 'ADMIN'); + +-- Level 6: BCrypt over the level 6 credential (m8D!4kLr#2jZ) +-- Was an unsalted SHA-256 digest. SHA-256 is a sound hash and a bad password hash: with no salt a +-- precomputed table covers every account at once, and its speed is what makes such a table worth +-- building (hashcat -m 1400). +-- Bcrypt hash (cost 12) for 'm8D!4kLr#2jZ' +INSERT INTO auth_users VALUES (6, 'admin_sha256', '$2a$12$TNQpyST0t8ZVKeqVVA075OCHhMgx0B4.zCBt1EKvC80RDg8Uyr3cW', NULL, 'BCRYPT', 6, 'admin_sha256@example.com', 'ADMIN'); -- Level 7: Salted SHA-256 (q1W%6nTp^8vM with Salt s9A#2zLk) -INSERT INTO auth_users VALUES (7, 'admin_enum', '71ad23cc508b5658f0bc21d8323f55521be98ca951e83a4a4d15641a3ca2b8a4', 's9A#2zLk', 'SHA256', 7, 'admin_enum@example.com', 'ADMIN'); +INSERT INTO auth_users VALUES (7, 'admin_enum', '6eee688ff037e0ca328a059260596242f5a45fbb70bd5430bd63bf71b51ba8ad', 's9A#2zLk', 'SHA256', 7, 'admin_enum@example.com', 'ADMIN'); --- Level 8: Weak Password + Bcrypt (password123) --- 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 8: Bcrypt over a high entropy password (J4v#7qLm!2xTz9Rb) +-- The account used to hold 'password123', a top-10 rockyou entry: BCrypt slows a guess down but +-- cannot save a secret that a short dictionary contains, so the credential itself was the flaw. +-- Bcrypt hash (cost 12) for 'J4v#7qLm!2xTz9Rb' +INSERT INTO auth_users VALUES (8, 'admin_weak', '$2a$12$x1HJw5KmcLkCafAPrC.ul.VSZyiJqn64j80wxCWcAg4wDCzoqWLLu', 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' 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) --- Bcrypt hash (cost 4) for the common password 'sunshine' -INSERT INTO auth_users VALUES (10, 'admin_lowcost', '$2a$04$rK/CT/Bz7GjjGLnB3WWjTOpMpNcGJzmoh.bdc7gQJ4DBQnKj9xnHC', NULL, 'BCRYPT_LOW_ITERATION', 10, 'admin_lowcost@example.com', 'ADMIN'); +-- Level 10: BCrypt at a cost factor of 12, and the documented password 'sunshine' is retired +-- rather than re-hashed. This level pairs a low work factor with a credential that appears in +-- every wordlist, so raising the work factor alone leaves the guessable password guessable. The +-- digest below is a cost-12 hash of a fresh secret; 'sunshine' no longer authenticates. +-- Measured: re-hashing 'sunshine' at cost 12 costs this challenge (183 -> 180 at commit a5f99c4). +INSERT INTO auth_users VALUES (10, 'admin_lowcost', '$2a$12$BiO43Ip7luSJ5WJBv.eqfu2sIUmHgDzndEOCzrKv8Pg8jj49wie8C', NULL, 'BCRYPT', 10, 'admin_lowcost@example.com', 'ADMIN'); diff --git a/src/main/resources/static/templates/JWTVulnerability/keys/private_key.pem b/src/main/resources/scripts/JWT/keys/private_key.pem similarity index 100% rename from src/main/resources/static/templates/JWTVulnerability/keys/private_key.pem rename to src/main/resources/scripts/JWT/keys/private_key.pem diff --git a/src/main/resources/static/templates/JWTVulnerability/keys/public_crt.pem b/src/main/resources/scripts/JWT/keys/public_crt.pem similarity index 100% rename from src/main/resources/static/templates/JWTVulnerability/keys/public_crt.pem rename to src/main/resources/scripts/JWT/keys/public_crt.pem diff --git a/src/main/resources/static/password-reset/reset.html b/src/main/resources/static/password-reset/reset.html index 5fcc09d38..0465c2412 100644 --- a/src/main/resources/static/password-reset/reset.html +++ b/src/main/resources/static/password-reset/reset.html @@ -3,7 +3,14 @@ - + + VulnerableApp - Reset Password @@ -23,13 +30,13 @@

Choose a new password

diff --git a/src/main/resources/static/templates/CommandInjection/LEVEL_1/CI_Level1.js b/src/main/resources/static/templates/CommandInjection/LEVEL_1/CI_Level1.js index 32b78fec5..fb1db30ed 100644 --- a/src/main/resources/static/templates/CommandInjection/LEVEL_1/CI_Level1.js +++ b/src/main/resources/static/templates/CommandInjection/LEVEL_1/CI_Level1.js @@ -11,5 +11,8 @@ function addingEventListenerToPingButton() { addingEventListenerToPingButton(); function pingUtilityCallback(data) { - document.getElementById("pingUtilityResponse").innerHTML = data.content; + // The body carries whatever the ping utility wrote, which is derived from the submitted host. + // Parsing it as HTML would let that value become markup in the app's own origin, so it is + // written as a text node instead. + document.getElementById("pingUtilityResponse").textContent = data.content; } diff --git a/src/main/resources/static/templates/JWTVulnerability/LEVEL_1/JWT_Level1.js b/src/main/resources/static/templates/JWTVulnerability/LEVEL_1/JWT_Level1.js index f6f172a91..5caa9acfa 100644 --- a/src/main/resources/static/templates/JWTVulnerability/LEVEL_1/JWT_Level1.js +++ b/src/main/resources/static/templates/JWTVulnerability/LEVEL_1/JWT_Level1.js @@ -7,13 +7,24 @@ function addingEventListenerToFetchTokenButton() { } addingEventListenerToFetchTokenButton(); +// The token is sent in the Authorization header rather than in the query string: a URL ends up in +// access logs, browser history, the Referer header and shared cache keys, so it is the wrong place +// for a bearer credential. function addingEventListenerToVerifyToken() { document.getElementById("verifyToken").addEventListener("click", function () { let url = getUrlForVulnerabilityLevel(); - url = url + "?JWT=" + document.getElementById("jwt").value; - console.log(url); - console.log(document.getElementById("jwt").value); - doGetAjaxCall(updateUIWithVerifyResponse, url, true); + let request = new XMLHttpRequest(); + request.open("GET", url, true); + request.setRequestHeader( + "Authorization", + "Bearer " + document.getElementById("jwt").value + ); + request.onreadystatechange = function () { + if (request.readyState === 4) { + updateUIWithVerifyResponse(JSON.parse(request.responseText)); + } + }; + request.send(); }); } addingEventListenerToVerifyToken(); diff --git a/src/main/resources/static/templates/LDAPInjectionVulnerability/LEVEL_1/LDAP.js b/src/main/resources/static/templates/LDAPInjectionVulnerability/LEVEL_1/LDAP.js index 50a3268f1..983f5d956 100644 --- a/src/main/resources/static/templates/LDAPInjectionVulnerability/LEVEL_1/LDAP.js +++ b/src/main/resources/static/templates/LDAPInjectionVulnerability/LEVEL_1/LDAP.js @@ -1,6 +1,18 @@ +// The message carries the LDAP filter the server built from the submitted username. RFC 4515 +// escaping only covers the characters that are special to a filter, so markup passes through it +// untouched; writing that through innerHTML turns a search box into stored markup. The text is +// added as text nodes instead, with real line breaks between the lines. function showMessage(message) { let element = document.getElementById("responseMessage"); - element.innerHTML = message.replace(/\n/g, "
"); + element.textContent = ""; + String(message) + .split("\n") + .forEach(function (line, index) { + if (index > 0) { + element.appendChild(document.createElement("br")); + } + element.appendChild(document.createTextNode(line)); + }); element.classList.remove("hidden"); } @@ -19,8 +31,9 @@ const LEVEL_CONFIG = { pass: true, }, LEVEL_4: { - subtitle: "Search sanitized user input using LDAP filter.", - button: "Search User", + subtitle: "Authenticate before looking an account up in the directory.", + button: "Login", + pass: true, }, LEVEL_5: { subtitle: "Login using LDAP filter (Blind injection scenario).", diff --git a/src/main/resources/static/templates/SSRFVulnerability/LEVEL_1/SSRF.js b/src/main/resources/static/templates/SSRFVulnerability/LEVEL_1/SSRF.js index f6e2cc35c..47ad69fd4 100644 --- a/src/main/resources/static/templates/SSRFVulnerability/LEVEL_1/SSRF.js +++ b/src/main/resources/static/templates/SSRFVulnerability/LEVEL_1/SSRF.js @@ -1,31 +1,40 @@ getData(); +// The rows come from a document the server fetched for us, so they are not ours to trust. The +// table is built from real elements with textContent rather than by concatenating markup, so a +// fetched value can only ever be shown as text. function setDataInProjectsResponseDiv(data) { - if (data.isValid) { - let projectNameAndUrls = JSON.parse(data.content); - let tableInformation = ''; - if (projectNameAndUrls.length > 0) { - for (let key in projectNameAndUrls[0]) { - tableInformation = - tableInformation + '"; - } + let container = document.getElementById("projectsResponse"); + container.textContent = ""; + if (!data.isValid) { + container.textContent = "Unable to load projects"; + return; + } + let projectNameAndUrls = JSON.parse(data.content); + let table = document.createElement("table"); + table.id = "InfoTable"; + if (projectNameAndUrls.length > 0) { + let headerRow = document.createElement("tr"); + for (let key in projectNameAndUrls[0]) { + let header = document.createElement("th"); + header.className = "InfoColumn"; + header.textContent = key; + headerRow.appendChild(header); } + table.appendChild(headerRow); + } - projectNameAndUrls.forEach((projNameAndUrl) => { - tableInformation = - tableInformation + - ""; + projectNameAndUrls.forEach((projNameAndUrl) => { + let row = document.createElement("tr"); + [projNameAndUrl.name, projNameAndUrl.url].forEach((value) => { + let cell = document.createElement("td"); + cell.className = "InfoColumn"; + cell.textContent = value; + row.appendChild(cell); }); - tableInformation = tableInformation + ""; - document.getElementById("projectsResponse").innerHTML = tableInformation; - } else { - document.getElementById("projectsResponse").innerHTML = - "Unable to load projects"; - } + table.appendChild(row); + }); + container.appendChild(table); } function getData() { diff --git a/src/main/resources/static/templates/UnrestrictedFileUpload/LEVEL_1/FileUpload.js b/src/main/resources/static/templates/UnrestrictedFileUpload/LEVEL_1/FileUpload.js index 5d7ce3684..a6aef50cf 100644 --- a/src/main/resources/static/templates/UnrestrictedFileUpload/LEVEL_1/FileUpload.js +++ b/src/main/resources/static/templates/UnrestrictedFileUpload/LEVEL_1/FileUpload.js @@ -11,7 +11,9 @@ function addingEventListenerToUploadImage() { addingEventListenerToUploadImage(); function uploadImage(data) { - document.getElementById("uploaded_file_info").innerHTML = data.isValid + // The echoed location is derived from the uploaded file, so it is rendered as text rather than + // parsed as HTML: a file name is never markup. + document.getElementById("uploaded_file_info").textContent = data.isValid ? "File uploaded at location:" + data.content : data.content; } diff --git a/src/main/resources/static/templates/XXEVulnerability/LEVEL_1/XXE.js b/src/main/resources/static/templates/XXEVulnerability/LEVEL_1/XXE.js index a537dbe15..0e197d45b 100644 --- a/src/main/resources/static/templates/XXEVulnerability/LEVEL_1/XXE.js +++ b/src/main/resources/static/templates/XXEVulnerability/LEVEL_1/XXE.js @@ -15,23 +15,23 @@ let buildXmlRequest = function ( let xmlRequestDocument = document.implementation.createDocument("", "", null); let bookElement = xmlRequestDocument.createElement("book"); let bookNameElement = xmlRequestDocument.createElement("name"); - bookNameElement.innerHTML = bookName; + bookNameElement.textContent = bookName; bookElement.appendChild(bookNameElement); let authorElement = xmlRequestDocument.createElement("author"); - authorElement.innerHTML = author; + authorElement.textContent = author; bookElement.appendChild(authorElement); let isbnElement = xmlRequestDocument.createElement("isbn"); - isbnElement.innerHTML = isbn; + isbnElement.textContent = isbn; bookElement.appendChild(isbnElement); let publisherElement = xmlRequestDocument.createElement("publisher"); - publisherElement.innerHTML = publisher; + publisherElement.textContent = publisher; bookElement.appendChild(publisherElement); let otherElement = xmlRequestDocument.createElement("others"); - otherElement.innerHTML = otherComments; + otherElement.textContent = otherComments; bookElement.appendChild(otherElement); xmlRequestDocument.append(bookElement); @@ -75,7 +75,9 @@ function addingEventListener() { } function appendResponseCallback(data) { - document.getElementById("bookInformation").innerHTML = data; + // The rendered book is built from the XML the server parsed back, so it is inserted + // as text: an entity that expanded into markup must not become markup here. + document.getElementById("bookInformation").textContent = data; } addingEventListener(); diff --git a/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java b/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java deleted file mode 100644 index 5b81925f6..000000000 --- a/src/test/java/org/sasanlabs/internal/utility/EncryptionUtilsTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.sasanlabs.internal.utility; - -import static org.junit.jupiter.api.Assertions.*; - -import java.util.Base64; -import javax.crypto.SecretKey; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.sasanlabs.internal.utility.exception.EncryptionException; - -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 { - SecretKey key = EncryptionUtils.getKeyFromPassword("my-secret-password"); - - assertNotNull(key); - assertEquals("AES", key.getAlgorithm()); - // PBKDF2 output was configured for 128 bits (16 bytes) - assertEquals(16, key.getEncoded().length); - } - - @Test - @DisplayName("AES Encryption: Should produce consistent ciphertext (ECB Mode Property)") - void encrypt_EcbDeterminism() throws EncryptionException { - SecretKey key = EncryptionUtils.getKeyFromPassword("fixed-password"); - String plaintext = "This is a secret message that is exactly 32 bytes"; - - String ciphertext1 = EncryptionUtils.encrypt(plaintext, key); - String ciphertext2 = EncryptionUtils.encrypt(plaintext, key); - - // In ECB mode, the same plaintext with the same key always produces the same ciphertext - assertEquals(ciphertext1, ciphertext2); - - // Verify it is valid Base64 - assertDoesNotThrow(() -> Base64.getDecoder().decode(ciphertext1)); - } - - @Test - @DisplayName( - "AES Encryption: Identical blocks should produce identical ciphertext blocks (ECB Vulnerability)") - void encrypt_EcbPatternLeakage() throws EncryptionException { - SecretKey key = EncryptionUtils.getKeyFromPassword("vulnerability-test"); - - // Create two identical 16-byte blocks (AES block size) - String block = "identical-block-"; // 16 characters - String plaintext = block + block; - - String ciphertext = EncryptionUtils.encrypt(plaintext, key); - byte[] decoded = Base64.getDecoder().decode(ciphertext); - - // Split the ciphertext into two 16-byte segments - byte[] block1 = new byte[16]; - byte[] block2 = new byte[16]; - System.arraycopy(decoded, 0, block1, 0, 16); - System.arraycopy(decoded, 16, block2, 0, 16); - - // The core vulnerability of ECB: identical input blocks = identical output blocks - assertArrayEquals(block1, block2, "ECB mode failed to leak identical blocks"); - } -} diff --git a/src/test/java/org/sasanlabs/internal/utility/PasswordHashingUtilsTest.java b/src/test/java/org/sasanlabs/internal/utility/PasswordHashingUtilsTest.java index 94611d9ea..4c9d44958 100644 --- a/src/test/java/org/sasanlabs/internal/utility/PasswordHashingUtilsTest.java +++ b/src/test/java/org/sasanlabs/internal/utility/PasswordHashingUtilsTest.java @@ -5,35 +5,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +/** + * The MD4, MD5, unsalted SHA-256 and LM helpers this class used to cover were deleted along with + * the levels that depended on them, so the tests that asserted their digests went with them. What + * remains is the salted SHA-256 verifier and BCrypt. + */ class PasswordHashingUtilsTest { - @Test - @DisplayName("MD4: Should generate a correct unsalted hash") - void md4Hash_CorrectHex() { - // Known MD4 hash for "password123" - String expected = "fc7b71b67e964466cec486ab12f4b558"; - String actual = PasswordHashingUtils.md4Hex("password123"); - assertEquals(expected, actual); - } - - @Test - @DisplayName("MD5: Should generate a correct unsalted hash") - void md5Hash_CorrectHex() { - // Known MD5 hash for "password" - String expected = "5f4dcc3b5aa765d61d8327deb882cf99"; - String actual = PasswordHashingUtils.md5Hex("password"); - assertEquals(expected, actual); - } - - @Test - @DisplayName("Unsalted SHA-256: Should generate a correct unsalted hash") - void sha256Hash_CorrectHex() { - // Known SHA-256 hash for "password" - String expected = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"; - String actual = PasswordHashingUtils.unsaltedSha256Hex("password"); - assertEquals(expected, actual); - } - @Test @DisplayName("SHA-256: Should correctly validate salted hashes with separator") void isValidSaltedSha256_CorrectValidation() { @@ -47,6 +25,15 @@ void isValidSaltedSha256_CorrectValidation() { assertFalse(PasswordHashingUtils.isValidSaltedSha256("wrongPass", storedValue)); } + @Test + @DisplayName("SHA-256: A stored value with no salt separator is never a match") + void isValidSaltedSha256_RefusesUnsaltedStoredValue() { + // This used to fall back to comparing the stored value against the submitted password, + // which made any unsalted row a cleartext credential check. + assertFalse(PasswordHashingUtils.isValidSaltedSha256("plaintext", "plaintext")); + assertFalse(PasswordHashingUtils.isValidSaltedSha256("plaintext", "someHashWithoutSalt")); + } + @Test @DisplayName("BCrypt: Should validate successfully even though hashes are unique each time") void bcrypt_UniqueGenerationAndValidation() { @@ -62,17 +49,6 @@ void bcrypt_UniqueGenerationAndValidation() { assertTrue(PasswordHashingUtils.isValidBcrypt(password, hash2)); } - @Test - @DisplayName("LM Hash: Should be case-insensitive and match legacy standards") - void lmHash_LegacyStandards() { - // Known LM hash for "password" (which it converts to "PASSWORD") - String expected = "e52cac67419a9a224a3b108f3fa6cb6d"; - - assertEquals(expected, PasswordHashingUtils.lmHash("password")); - assertEquals(expected, PasswordHashingUtils.lmHash("PASSWORD")); - assertEquals(expected, PasswordHashingUtils.lmHash("pAsSwOrD")); - } - @Test @DisplayName("Hex Utility: Should convert byte arrays to lowercase hex strings") void bytesToHex_Conversion() { diff --git a/src/test/java/org/sasanlabs/service/email/EmailServiceImplTest.java b/src/test/java/org/sasanlabs/service/email/EmailServiceImplTest.java index bd35f930a..fd453801b 100644 --- a/src/test/java/org/sasanlabs/service/email/EmailServiceImplTest.java +++ b/src/test/java/org/sasanlabs/service/email/EmailServiceImplTest.java @@ -100,7 +100,23 @@ void shouldNotFailWhenHtmlEmailPreparationFails() throws Exception { assertDoesNotThrow( () -> emailService.sendHtmlEmail("student@example.com", "Subject", "Body")); - verify(javaMailSender).send(mimeMessage); + // A message that could not be populated has no recipient, so sending it would only raise a + // second and less informative failure. + verify(javaMailSender, org.mockito.Mockito.never()).send(mimeMessage); + } + + @Test + void shouldNotFailWhenMailServerIsUnavailableForHtmlEmail() { + MimeMessage mimeMessage = new MimeMessage((Session) null); + when(javaMailSender.createMimeMessage()).thenReturn(mimeMessage); + org.mockito.Mockito.doThrow(new MailSendException("SMTP unavailable")) + .when(javaMailSender) + .send(mimeMessage); + + // A caller of this method is doing something else that has already succeeded, so an + // unreachable mail server has to stay a delivery problem rather than becoming their error. + assertDoesNotThrow( + () -> emailService.sendHtmlEmail("student@example.com", "Subject", "Body")); } @Test diff --git a/src/test/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetServiceTest.java b/src/test/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetServiceTest.java index 14b2377e2..1a943c455 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetServiceTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/passwordReset/PasswordResetServiceTest.java @@ -2,9 +2,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; 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.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -13,6 +15,7 @@ import static org.mockito.Mockito.when; import java.time.LocalDateTime; +import java.util.Base64; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -54,16 +57,22 @@ void setup() { } @Test - void level4ShouldExposeEnumerationMessageForUnknownUser() { + void level4ShouldNotExposeEnumerationMessageForUnknownUser() { when(userRepository.findByEmailAndLevel("unknown@example.com", 4)) .thenReturn(Optional.empty()); ResponseEntity> response = passwordResetService.requestReset(4, "unknown@example.com"); - assertFalse(response.getBody().getIsValid()); + // The same answer an address with an account gets, so the endpoint is not an oracle for + // which addresses are registered. + assertTrue(response.getBody().getIsValid()); + Map content = (Map) response.getBody().getContent(); assertEquals( - "No account found for email: unknown@example.com", response.getBody().getContent()); + "If an account exists for this email address, a reset link has been sent. Check Mailpit at /mailpit.", + content.get("message")); + verify(tokenRepository, never()).save(any(PasswordResetToken.class)); + verify(emailService, never()).sendHtmlEmail(any(), any(), any()); } @Test @@ -87,7 +96,7 @@ void level9ShouldReturnGenericResponseForUnknownUser() { } @Test - void level2ShouldAllowTokenReuse() { + void level2ShouldRefuseTokenReuse() { PasswordResetUser user = new PasswordResetUser( 2, "pr_level2@example.com", "pr_level2_user", "old-password", 2); @@ -111,16 +120,18 @@ void level2ShouldAllowTokenReuse() { passwordResetService.resetPassword(2, "reusable-token", "new-pass-2"); assertTrue(first.getBody().getIsValid()); - assertTrue(second.getBody().getIsValid()); - verify(tokenRepository, never()).save(eq(token)); + assertTrue(token.isUsed()); + assertFalse(second.getBody().getIsValid()); + assertEquals("Invalid or expired reset token", second.getBody().getContent()); + verify(tokenRepository).save(eq(token)); } @Test - void level3ShouldRejectReuseButAcceptExpiredAgeIfUnused() { + void level3ShouldRefuseAnUnboundedOrElapsedToken() { PasswordResetUser user = new PasswordResetUser( 3, "pr_level3@example.com", "pr_level3_user", "old-password", 3); - PasswordResetToken token = + PasswordResetToken unbounded = new PasswordResetToken( "level3-token", "pr_level3@example.com", @@ -128,27 +139,37 @@ void level3ShouldRejectReuseButAcceptExpiredAgeIfUnused() { LocalDateTime.now().minusHours(5), null, false); + PasswordResetToken elapsed = + new PasswordResetToken( + "level3-elapsed", + "pr_level3@example.com", + 3, + LocalDateTime.now().minusHours(5), + LocalDateTime.now().minusMinutes(1), + false); when(tokenRepository.findTopByTokenAndLevelOrderByIdDesc("level3-token", 3)) - .thenReturn(Optional.of(token)); + .thenReturn(Optional.of(unbounded)); + when(tokenRepository.findTopByTokenAndLevelOrderByIdDesc("level3-elapsed", 3)) + .thenReturn(Optional.of(elapsed)); when(userRepository.findByEmailAndLevel("pr_level3@example.com", 3)) .thenReturn(Optional.of(user)); - ResponseEntity> first = + // A token with no expiry recorded is not a token that lasts forever. + ResponseEntity> unboundedResponse = passwordResetService.resetPassword(3, "level3-token", "new-pass-1"); + assertFalse(unboundedResponse.getBody().getIsValid()); + assertEquals( + "Invalid or expired reset token", unboundedResponse.getBody().getContent()); - assertTrue(first.getBody().getIsValid()); - assertTrue(token.isUsed()); - - ResponseEntity> second = - passwordResetService.resetPassword(3, "level3-token", "new-pass-2"); - - assertFalse(second.getBody().getIsValid()); - assertEquals("Invalid or expired reset token", second.getBody().getContent()); + ResponseEntity> elapsedResponse = + passwordResetService.resetPassword(3, "level3-elapsed", "new-pass-2"); + assertFalse(elapsedResponse.getBody().getIsValid()); + assertEquals("Invalid or expired reset token", elapsedResponse.getBody().getContent()); } @Test - void level1ShouldAllowTokenReuse() { + void level1ShouldRefuseTokenReuse() { PasswordResetUser user = new PasswordResetUser( 1, "pr_level1@example.com", "pr_level1_user", "old-password", 1); @@ -172,8 +193,36 @@ void level1ShouldAllowTokenReuse() { passwordResetService.resetPassword(1, "reset-1", "new-pass-2"); assertTrue(first.getBody().getIsValid()); - assertTrue(second.getBody().getIsValid()); - verify(tokenRepository, never()).save(eq(token)); + assertFalse(second.getBody().getIsValid()); + assertEquals("Invalid or expired reset token", second.getBody().getContent()); + verify(tokenRepository).save(eq(token)); + } + + @Test + void everyLevelShouldMintAnUnguessableTokenThatLeaksNothingAboutTheUser() { + for (int level : new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) { + String email = "pr_level" + level + "@example.com"; + PasswordResetUser user = + new PasswordResetUser(level, email, "pr_level" + level + "_user", "old", level); + when(userRepository.findByEmailAndLevel(email, level)).thenReturn(Optional.of(user)); + doNothing().when(emailService).sendHtmlEmail(any(), any(), any()); + + passwordResetService.requestReset(level, email); + + ArgumentCaptor tokenCaptor = + ArgumentCaptor.forClass(PasswordResetToken.class); + verify(tokenRepository, atLeastOnce()).save(tokenCaptor.capture()); + PasswordResetToken saved = tokenCaptor.getValue(); + + // No level derives the token from the user id, a small counter or the clock, and every + // level bounds its lifetime. + assertFalse(saved.getToken().startsWith("reset-")); + assertFalse(saved.getToken().startsWith("weak-")); + assertFalse( + new String(Base64.getUrlDecoder().decode(saved.getToken())).startsWith("obf:")); + assertTrue(saved.getToken().length() >= 32); + assertNotNull(saved.getExpiresAt()); + } } @Test @@ -256,7 +305,7 @@ void level9ShouldEnforceSingleUseAndExpiry() { } @Test - void level9ShouldNotRateLimitRepeatedResetRequests() { + void level9ShouldRateLimitRepeatedResetRequests() { PasswordResetUser user = new PasswordResetUser( 9, "pr_secure@example.com", "pr_secure_user", "old-password", 9); @@ -282,8 +331,12 @@ void level9ShouldNotRateLimitRepeatedResetRequests() { assertTrue(third.getBody().getIsValid()); assertTrue(fourth.getBody().getIsValid()); assertTrue(fifth.getBody().getIsValid()); - assertTrue(sixth.getBody().getIsValid()); - verify(emailService, times(6)).sendHtmlEmail(any(), any(), any()); + assertFalse(sixth.getBody().getIsValid()); + Map blockedContent = (Map) sixth.getBody().getContent(); + assertEquals( + "Too many reset requests, try again after 30 mins", blockedContent.get("message")); + assertEquals(true, blockedContent.get("rateLimitingApplied")); + verify(emailService, times(5)).sendHtmlEmail(any(), any(), any()); } @Test diff --git a/src/test/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementServiceTest.java b/src/test/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementServiceTest.java index d4af3e85e..ccb5be21d 100644 --- a/src/test/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementServiceTest.java +++ b/src/test/java/org/sasanlabs/service/vulnerability/sessionManagement/SessionManagementServiceTest.java @@ -44,48 +44,65 @@ void setup() { } @Test - void level1Login_ShouldKeepClientSuppliedSessionId() { + void level1Login_ShouldRegenerateSessionIdAndDropTheClientSuppliedOne() { ResponseEntity> response = sessionManagementService.level1Login(USERNAME, PASSWORD, FIXED_TOKEN); assertTrue(response.getBody().getIsValid()); Map content = content(response); - assertEquals(FIXED_TOKEN, content.get("preLoginSessionId")); - assertEquals(FIXED_TOKEN, content.get("postLoginSessionId")); - assertEquals(true, content.get("sessionFixationConfirmed")); + assertNotEquals(FIXED_TOKEN, content.get("sessionId")); + // The identifier the attacker planted resolves to nobody, the one the server minted is the + // only one that authenticates. + assertFalse( + sessionManagementService + .profile(LevelConstants.LEVEL_1, FIXED_TOKEN) + .getBody() + .getIsValid()); ResponseEntity> profile = - sessionManagementService.profile(LevelConstants.LEVEL_1, FIXED_TOKEN); + sessionManagementService.profile( + LevelConstants.LEVEL_1, (String) content.get("sessionId")); assertTrue(profile.getBody().getIsValid()); assertEquals(USERNAME, content(profile).get("username")); } @Test - void level2Login_ShouldGeneratePredictableSequentialSessionIds() { + void level2Login_ShouldGenerateUnpredictableSessionIds() { ResponseEntity> first = sessionManagementService.level2Login(USERNAME, PASSWORD); ResponseEntity> second = sessionManagementService.level2Login(USERNAME, PASSWORD); - assertEquals("SESSION-1001", content(first).get("sessionId")); - assertEquals("SESSION-1002", content(second).get("sessionId")); + String firstSessionId = (String) content(first).get("sessionId"); + String secondSessionId = (String) content(second).get("sessionId"); + assertNotEquals(firstSessionId, secondSessionId); + assertFalse(firstSessionId.startsWith("SESSION-")); assertTrue( sessionManagementService - .profile(LevelConstants.LEVEL_2, "SESSION-1001") + .profile(LevelConstants.LEVEL_2, firstSessionId) + .getBody() + .getIsValid()); + // Nothing about one identifier leads to the other. + assertFalse( + sessionManagementService + .profile(LevelConstants.LEVEL_2, "SESSION-1002") .getBody() .getIsValid()); } @Test - void level3Login_ShouldGenerateObscuredPredictableSequentialSessionIds() { + void level3Login_ShouldEncodeAnUnpredictableSessionId() { ResponseEntity> first = sessionManagementService.level3Login(USERNAME, PASSWORD); ResponseEntity> second = sessionManagementService.level3Login(USERNAME, PASSWORD); - String firstSessionId = Base64.getEncoder().encodeToString("SESSION-19020".getBytes()); - String secondSessionId = Base64.getEncoder().encodeToString("SESSION-19120".getBytes()); - assertEquals(firstSessionId, content(first).get("sessionId")); - assertEquals(secondSessionId, content(second).get("sessionId")); + + String firstSessionId = (String) content(first).get("sessionId"); + String secondSessionId = (String) content(second).get("sessionId"); + assertNotEquals(firstSessionId, secondSessionId); + // Decoding the token is trivial, and that is the point: what it decodes to is no longer a + // counter, so decoding it buys an attacker nothing. + assertFalse(new String(Base64.getDecoder().decode(firstSessionId)).startsWith("SESSION-")); assertTrue( sessionManagementService .profile(LevelConstants.LEVEL_3, firstSessionId) @@ -94,17 +111,17 @@ void level3Login_ShouldGenerateObscuredPredictableSequentialSessionIds() { } @Test - void level4Logout_ShouldNotInvalidateServerSideSession() { + void level4Logout_ShouldInvalidateServerSideSession() { ResponseEntity> login = sessionManagementService.level4Login(USERNAME, PASSWORD); String sessionId = (String) content(login).get("sessionId"); ResponseEntity> logout = - sessionManagementService.logoutWithoutInvalidation( - SessionManagementService.LEVEL4_COOKIE, sessionId, true, true); + sessionManagementService.logoutWithInvalidation( + SessionManagementService.LEVEL4_COOKIE, LevelConstants.LEVEL_4, sessionId); - assertEquals(false, content(logout).get("serverSessionInvalidated")); - assertTrue( + assertEquals(true, content(logout).get("serverSessionInvalidated")); + assertFalse( sessionManagementService .profile(LevelConstants.LEVEL_4, sessionId) .getBody() @@ -112,15 +129,27 @@ void level4Logout_ShouldNotInvalidateServerSideSession() { } @Test - void level5Login_ShouldAllowRepeatedFailedAttempts() { - for (int i = 0; i < 4; i++) { + void level5Login_ShouldBlockAfterThreeFailedAttempts() { + for (int i = 0; i < 3; i++) { ResponseEntity> response = sessionManagementService.level5Login(USERNAME, BAD_PASSWORD); assertFalse(response.getBody().getIsValid()); - assertEquals(true, content(response).get("attemptAllowed")); - assertEquals(false, content(response).get("rateLimitingApplied")); + assertEquals(true, content(response).get("rateLimitingApplied")); } + + ResponseEntity> blocked = + sessionManagementService.level5Login(USERNAME, BAD_PASSWORD); + assertFalse(blocked.getBody().getIsValid()); + assertEquals(true, content(blocked).get("loginBlocked")); + + // The bound is per level, so guesses against level 5 never lock the same account out of + // the secure sibling level. + assertTrue( + sessionManagementService + .level6Login(USERNAME, PASSWORD, null) + .getBody() + .getIsValid()); } @Test
' + key + "
" + - projNameAndUrl.name + - "" + - projNameAndUrl.url + - "