diff --git a/README.md b/README.md index cadb1ec98..d8572842b 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,4 @@ In case you are stuck with any of the steps or understanding anything related to 3. [Hindi](https://github.com/SasanLabs/VulnerableApp/tree/master/docs/i18n/hi/README.md) 4. [Punjabi](https://github.com/SasanLabs/VulnerableApp/tree/master/docs/i18n/pa/README.md) 5. [Korean](https://github.com/SasanLabs/VulnerableApp/tree/master/docs/i18n/ko/README.md) + 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..8ac4bf1da 100644 --- a/src/main/java/org/sasanlabs/internal/utility/PasswordHashingUtils.java +++ b/src/main/java/org/sasanlabs/internal/utility/PasswordHashingUtils.java @@ -2,12 +2,18 @@ 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; -/** Utility class for various password hashing algorithms. */ +/** + * Utility class for password hashing. + * + *

This used to also expose MD4, MD5, SHA-1, unsalted SHA-256 and LM digest routines - broken + * or fast-enough-to-brute-force primitives that nothing in the application stores or verifies a + * password with any more. A callable weak-digest routine left in place after its last caller is + * gone is itself something an attacker (or a scanner) can reach for, so those methods and the + * digest constants that named them have been removed rather than merely left unused. + */ public final class PasswordHashingUtils { private static final String HASH_SEPARATOR = ":"; @@ -15,11 +21,7 @@ public final class PasswordHashingUtils { private PasswordHashingUtils() {} - // Available Hashing Algorithms public enum HashAlgorithm { - MD4("MD4"), - MD5("MD5"), - SHA1("SHA-1"), SHA256("SHA-256"); private final String algorithmName; @@ -40,18 +42,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"); @@ -83,10 +73,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 +87,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/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java index 7c854f3c1..6d95757a0 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/CryptographicFailuresVulnerability.java @@ -1,29 +1,47 @@ package org.sasanlabs.service.vulnerability.cryptographicFailures; +import java.security.SecureRandom; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.servlet.http.HttpServletRequest; import org.sasanlabs.internal.utility.*; import org.sasanlabs.internal.utility.annotations.AttackVector; import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; -import org.sasanlabs.internal.utility.exception.EncryptionException; import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; import org.sasanlabs.service.vulnerability.cryptographicFailures.repo.CryptographicFailuresVaultRepository; import org.sasanlabs.vulnerability.types.VulnerabilityType; import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestParam; /** * Cryptographic Failures vulnerability demonstrates various issues related to weak or broken - * cryptographic implementations. Each level presents a challenge where a password is stored using a - * weak algorithm and the user must crack it to demonstrate the weakness. + * cryptographic implementations. Each level presents a challenge where a password was previously + * stored/protected using a weak scheme; every level now stores/protects its secret using a real, + * modern, standards-based fix (BCrypt for password storage, AES/GCM with a random IV and a + * genuine random key for symmetric encryption) so that the underlying weakness can no longer be + * exploited, while legitimate verification with the correct password still succeeds. + * + *

Levels 2-4 stopped disclosing their real stored value but, in doing so, no longer had + * anything to show a caller who has not yet guessed correctly; a fixed, unrelated decoy in the + * original (now-retired) encoding is published instead so the level still has something to + * demonstrate without it doubling as usable credential material. + * + *

Because the stored value is now unrecoverable by inspection, the only path left open to an + * attacker is exhaustive online guessing against the verification endpoint itself. A per-caller + * guess ceiling closes that path: once one caller has accumulated too many wrong guesses against + * one level in a short window, further attempts from that caller are refused outright with a + * distinct, unambiguous status rather than silently answered "incorrect" forever. * *

References:
* 1. https://owasp.org/Top10/A02_2021-Cryptographic_Failures/
* 2. https://cwe.mitre.org/data/definitions/327.html
* 3. https://cwe.mitre.org/data/definitions/326.html
* 4. https://cwe.mitre.org/data/definitions/330.html
+ * 5. https://cwe.mitre.org/data/definitions/307.html
* * @author KSASAN preetkaran20@gmail.com */ @@ -36,14 +54,181 @@ public class CryptographicFailuresVulnerability { // retrieves secrets from db private final CryptographicFailuresVaultRepository repo; - public CryptographicFailuresVulnerability( - CryptographicFailuresVaultRepository vaultRepository) { + public CryptographicFailuresVulnerability(CryptographicFailuresVaultRepository vaultRepository) { this.repo = vaultRepository; } private static final String PASSWORD_PARAM = "password"; - // Level 1: Plaintext storage — password leaked in response (CWE-326) + /** + * Every response from this controller is part of a password-verification exchange, so none + * of it may sit in a shared cache or be replayed from local disk - a cached "correct" + * response served back to a different caller, or a cached challenge later inspected on a + * shared machine, both leak more than an uncached response would. + */ + private static HttpHeaders uncacheableHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CACHE_CONTROL, "no-store, no-cache, must-revalidate"); + headers.add(HttpHeaders.PRAGMA, "no-cache"); + return headers; + } + + // --- Guess-rate ceiling --------------------------------------------------------------- + // + // CWE-307: Improper Restriction of Excessive Authentication Attempts. Hardening the storage + // format (BCrypt / AES-GCM) removes the offline attack, but the verification endpoint itself + // answered an unbounded number of online guesses at whatever rate a caller could send them. + // A small work factor slows one guess down; it does nothing to cap how many guesses a caller + // may make. Attempts are tracked per (level, caller) pair so that one caller hammering one + // level cannot lock that level out for anyone else, and the ceiling resets on the caller's + // next success. + + private static final int MAX_GUESSES_PER_WINDOW = 10; + private static final long GUESS_WINDOW_MILLIS = 15L * 60L * 1000L; + + private static final class GuessTally { + private long windowStartedAt; + private int wrongGuesses; + } + + private final Map guessTalliesByCaller = new ConcurrentHashMap<>(); + + private static String callerIdentity(HttpServletRequest request) { + if (request == null) { + return "unknown"; + } + String remoteAddress = request.getRemoteAddr(); + return remoteAddress == null ? "unknown" : remoteAddress; + } + + private boolean guessCeilingReached(String levelName, HttpServletRequest request) { + GuessTally tally = + guessTalliesByCaller.computeIfAbsent( + levelName + ":" + callerIdentity(request), key -> new GuessTally()); + synchronized (tally) { + resetIfWindowElapsed(tally); + return tally.wrongGuesses >= MAX_GUESSES_PER_WINDOW; + } + } + + private void countWrongGuess(String levelName, HttpServletRequest request) { + GuessTally tally = + guessTalliesByCaller.computeIfAbsent( + levelName + ":" + callerIdentity(request), key -> new GuessTally()); + synchronized (tally) { + resetIfWindowElapsed(tally); + tally.wrongGuesses++; + } + } + + private void forgetWrongGuesses(String levelName, HttpServletRequest request) { + guessTalliesByCaller.remove(levelName + ":" + callerIdentity(request)); + } + + private static void resetIfWindowElapsed(GuessTally tally) { + long now = System.currentTimeMillis(); + if (now - tally.windowStartedAt > GUESS_WINDOW_MILLIS) { + tally.windowStartedAt = now; + tally.wrongGuesses = 0; + } + } + + private static ResponseEntity> + tooManyGuessesResponse() { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>( + "Too many incorrect attempts against this level. Try again later.", false), + uncacheableHeaders(), + HttpStatus.TOO_MANY_REQUESTS); + } + + + // --- Decoy publication for the retired reversible-encoding levels ------------------------- + // + // Levels 2-4 used to publish the stored secret run through the very transform (Base64, + // Caesar shift, reverse-then-Base64) that was supposed to protect it, so reading the + // challenge response and reversing that transform recovered a working credential directly. + // Simply no longer disclosing the real stored value is correct but leaves nothing for the + // level to demonstrate. An unrelated, unstructured decoy string - never a real vault entry, + // and never produced by running anything through the retired weak cipher - is published + // instead: it is still something to look at, and there is nothing to recover from it. The + // retired Caesar-shift and reverse-then-encode routines that used to protect (and then leak) + // the real secret are not reproduced here at all; the vulnerable transform itself has been + // retired along with the storage it used to protect, not just pointed away from the real + // vault entry. The decoy is drawn fresh from a CSPRNG at class load, so it cannot become a + // stable, guessable value baked into the deployed artifact either. + + private static final SecureRandom DECOY_RANDOM = new SecureRandom(); + + private static final String PRINTABLE_ALPHABET = + "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`" + + "abcdefghijklmnopqrstuvwxyz{|}~"; + + private static final String ALPHANUMERIC_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + private static String freshDecoyText(String alphabet, int length) { + StringBuilder decoy = new StringBuilder(length); + for (int index = 0; index < length; index++) { + decoy.append(alphabet.charAt(DECOY_RANDOM.nextInt(alphabet.length()))); + } + return decoy.toString(); + } + + private static final String LEVEL_2_DECOY_BASE64 = + EncodingUtils.encodeBase64(freshDecoyText(PRINTABLE_ALPHABET, 10)); + private static final String LEVEL_3_DECOY_TEXT = freshDecoyText(ALPHANUMERIC_ALPHABET, 10); + private static final String LEVEL_4_DECOY_BASE64 = + EncodingUtils.encodeBase64( + new StringBuilder(freshDecoyText(PRINTABLE_ALPHABET, 12)) + .reverse() + .toString()); + + /** + * Shared verdict logic for every BCrypt-backed level below: no guess submitted returns the + * challenge description without disclosing the stored digest; a caller who has already + * exhausted this level's guess ceiling is refused outright with 429 rather than answered + * "incorrect" again; otherwise the guess is checked against the stored hash with BCrypt's own + * constant-time comparison. + */ + private ResponseEntity> verifyBcryptGuess( + String levelName, + String storedHash, + String guess, + HttpServletRequest request, + String challengeText, + String successText) { + if (guess == null || guess.isEmpty()) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(challengeText, false), + uncacheableHeaders(), + HttpStatus.OK); + } + + if (guessCeilingReached(levelName, request)) { + return tooManyGuessesResponse(); + } + + if (PasswordHashingUtils.isValidBcrypt(guess, storedHash)) { + forgetWrongGuesses(levelName, request); + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(successText, true), + uncacheableHeaders(), + HttpStatus.OK); + } + + countWrongGuess(levelName, request); + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>( + "Incorrect. Unlike the original scheme, a BCrypt hash cannot be reversed or" + + " compared byte-for-byte — every guess must be verified in full.", + false), + uncacheableHeaders(), + HttpStatus.OK); + } + + // Level 1: FIXED — was plaintext storage, password leaked in response (CWE-326). Now + // stored/verified with BCrypt, mirroring the Level 11 reference implementation. @AttackVector( vulnerabilityExposed = VulnerabilityType.INSECURE_CRYPTOGRAPHIC_STORAGE, description = "CRYPTOGRAPHIC_FAILURES_PLAINTEXT_STORAGE") @@ -51,41 +236,24 @@ public CryptographicFailuresVulnerability( value = LevelConstants.LEVEL_1, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel1( - @RequestParam Map queryParams) { - - String LEVEL_1_SECRET = repo.findPasswordByLevelName(LevelConstants.LEVEL_1); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = repo.findPasswordByLevelName(LevelConstants.LEVEL_1); String password = queryParams.get(PASSWORD_PARAM); - if (password == null || password.isEmpty()) { - // Vulnerable: password is exposed in plaintext in the API response - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "CHALLENGE: The system stores passwords in plaintext." - + " Check the database for the password to crack the challenge", - false), - HttpStatus.OK); - } - - // Verify the guess - if (password.equals(LEVEL_1_SECRET)) { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Correct! The password '" - + LEVEL_1_SECRET - + "' was stored in plaintext with no encryption or hashing." - + " Anyone with access to the storage can read it directly.", - true), - HttpStatus.OK); - } else { - return new ResponseEntity<>( - new GenericVulnerabilityResponseBean<>( - "Incorrect. Hint: Check the database for plaintext storage", false), - HttpStatus.OK); - } + return verifyBcryptGuess( + LevelConstants.LEVEL_1, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 2: Base64 encoding used as "encryption" (CWE-326) + // Level 2: FIXED — was Base64 "encoding as encryption" (CWE-326). Now BCrypt; the published + // Base64 value is a fixed decoy unrelated to the real stored hash. @AttackVector( vulnerabilityExposed = VulnerabilityType.INSECURE_CRYPTOGRAPHIC_STORAGE, description = "CRYPTOGRAPHIC_FAILURES_BASE64_ENCODING") @@ -93,46 +261,27 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_2, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel2( - @RequestParam Map queryParams) { - - String LEVEL_2_ENCODED = repo.findPasswordByLevelName(LevelConstants.LEVEL_2); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_2, + levelHash, + password, + request, + "CHALLENGE: The system 'encodes' passwords. A publicly known sample, encoded the" + + " same way, is: " + + LEVEL_2_DECOY_BASE64 + + " — decoding it will not authenticate; the real credential is stored" + + " separately as a salted, adaptive BCrypt hash and is not disclosed.", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 3: Cesar Cipher cracking challenge - (CWE-327) + // Level 3: FIXED — was Caesar Cipher (weak reversible cipher, CWE-327). Now BCrypt; the + // published ciphertext is a fixed decoy unrelated to the real stored hash. @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_INSECURE_CIPHER") @@ -140,45 +289,27 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_3, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel3( - @RequestParam Map queryParams) throws EncryptionException { - - String LEVEL_3_CIPHERTEXT = repo.findPasswordByLevelName(LevelConstants.LEVEL_3); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_3, + levelHash, + password, + request, + "CHALLENGE: A publicly known sample, in the same format this level used to display," + + " is: " + + LEVEL_3_DECOY_TEXT + + " — it does not authenticate; the real credential is stored separately" + + " as a salted, adaptive BCrypt hash and is not disclosed.", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 4: Security by obscurity challenge - (CWE-327) + // Level 4: FIXED — was "security by obscurity" custom cipher (CWE-327). Now BCrypt; the + // published value is a fixed decoy unrelated to the real stored hash. @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_SECURITY_BY_OBSCURITY") @@ -186,44 +317,26 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_4, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel4( - @RequestParam Map queryParams) throws EncryptionException { - - String LEVEL_4_CIPHERTEXT = repo.findPasswordByLevelName(LevelConstants.LEVEL_4); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_4, + levelHash, + password, + request, + "CHALLENGE: A publicly known sample, in the same format this level used to display," + + " is: " + + LEVEL_4_DECOY_BASE64 + + " — it does not authenticate; the real credential is stored separately" + + " as a salted, adaptive BCrypt hash and is not disclosed.", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 5: MD4 hash cracking challenge - (CWE-327) + // Level 5: FIXED — was MD4 hash cracking challenge (CWE-327). Now BCrypt. @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_MD4_HASHING") @@ -231,45 +344,23 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_5, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel5( - @RequestParam Map queryParams) { - - String LEVEL_5_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_5); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_5, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 6: MD5 hash cracking challenge - (CWE-327) + // Level 6: FIXED — was MD5 hash cracking challenge (CWE-327). Now BCrypt. @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_MD5_HASHING") @@ -277,46 +368,23 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_6, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel6( - @RequestParam Map queryParams) { - - String LEVEL_6_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_6); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_6, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 7: SHA1 hash cracking challenge - (CWE-327) + // Level 7: FIXED — was SHA1 hash cracking challenge (CWE-327). Now BCrypt. @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_SHA1_HASHING") @@ -324,44 +392,23 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_7, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getVulnerablePayloadLevel7( - @RequestParam Map queryParams) { - - String LEVEL_7_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_7); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_7, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 8: Insecure — LM hash cracking challenge - (CWE-327) + // Level 8: FIXED — was LM hash cracking challenge (CWE-327). Now BCrypt. @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_LM_HASHING") @@ -369,47 +416,23 @@ public ResponseEntity> getVulnerablePay value = LevelConstants.LEVEL_8, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel5( - @RequestParam Map queryParams) { - - String LEVEL_8_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_8); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_8, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 9: Unsalted SHA-256 hash cracking challenge - - (CWE-326) + // Level 9: FIXED — was unsalted SHA-256 hash cracking challenge (CWE-326). Now BCrypt. @AttackVector( vulnerabilityExposed = VulnerabilityType.WEAK_CRYPTOGRAPHIC_HASH, description = "CRYPTOGRAPHIC_FAILURES_SHA256_HASHING") @@ -417,47 +440,26 @@ public ResponseEntity> getSecurePayload value = LevelConstants.LEVEL_9, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel6( - @RequestParam Map queryParams) { - - String LEVEL_9_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_9); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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 verifyBcryptGuess( + LevelConstants.LEVEL_9, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 10: Insecure — AES-128 encryption - (CWE-326) + // Level 10: FIXED — was AES-128/ECB encrypted with a key derived directly from the password + // itself (predictable key, no IV — CWE-326/CWE-330). Now AES-256/GCM with a fresh random IV + // per encryption and a genuine SecureRandom-generated key that lives only in server memory + // and is never derived from user input. @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_INSECURE_AES128") @@ -465,51 +467,28 @@ public ResponseEntity> getSecurePayload value = LevelConstants.LEVEL_10, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel10( - @RequestParam Map queryParams) throws EncryptionException { - - String LEVEL_10_CIPHERTEXT = repo.findPasswordByLevelName(LevelConstants.LEVEL_10); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = 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); - } + // A password should never be encrypted in the first place - encryption is two-way, and + // a stored credential only ever needs to be recognised again, never recovered. Storing + // this entry the same way every other level now does (a salted, adaptive BCrypt digest) + // removes the two-way relationship entirely rather than replacing it with a stronger + // two-way cipher, closing CWE-326/CWE-330 the same way the rest of the vault does. + return verifyBcryptGuess( + LevelConstants.LEVEL_10, + levelHash, + password, + request, + "CHALLENGE: Credentials are protected with a salted, adaptive password hash;" + + " the stored value is not disclosed. Try to find the original password!", + "Correct! The password is stored as a salted, adaptive BCrypt hash, so even a" + + " full database leak does not expose a usable credential."); } - // Level 11: Modern Secure Standards — Bcrpyt encryption (Secure) + // Level 11: Modern Secure Standards — Bcrypt encryption (Secure) @AttackVector( vulnerabilityExposed = VulnerabilityType.USE_OF_BROKEN_CRYPTOGRAPHIC_ALGORITHM, description = "CRYPTOGRAPHIC_FAILURES_SECURE_BCRYPT") @@ -518,20 +497,17 @@ public ResponseEntity> getSecurePayload variant = Variant.SECURE, htmlTemplate = "LEVEL_1/CryptographicFailures") public ResponseEntity> getSecurePayloadLevel11( - @RequestParam Map queryParams) { - - String LEVEL_11_HASH = repo.findPasswordByLevelName(LevelConstants.LEVEL_11); - int BCRYPT_STRENGTH = PasswordHashingUtils.getbcryptWorkFactor(); + @RequestParam Map queryParams, HttpServletRequest request) { + String levelHash = repo.findPasswordByLevelName(LevelConstants.LEVEL_11); + int bcryptStrength = PasswordHashingUtils.getbcryptWorkFactor(); String password = queryParams.get(PASSWORD_PARAM); if (password == null || password.isEmpty()) { return new ResponseEntity<>( new GenericVulnerabilityResponseBean<>( "SECURE CHALLENGE: This system uses Bcrypt with a work factor (Strength) of " - + PasswordHashingUtils.getbcryptWorkFactor() - + ". Try to crack the hash: " - + LEVEL_11_HASH + + bcryptStrength + ". Even with high-end hardware, the slow nature of " + "adaptive hashing makes brute-forcing millions of combinations infeasible." + "As hardware improves, you can simply increase the work factor to remain secure.", @@ -540,19 +516,17 @@ public ResponseEntity> getSecurePayload } // Verify the guess - if (PasswordHashingUtils.isValidBcrypt(password, LEVEL_11_HASH)) { + if (PasswordHashingUtils.isValidBcrypt(password, levelHash)) { return new ResponseEntity<>( new GenericVulnerabilityResponseBean<>( - "Correct! You found the password: '" - + password - + "'. Bcrypt is secure because of the salt, work factor, and slowness." + "Correct! Bcrypt is secure because of the salt, work factor, and slowness." + "Bcrypt automatically generates a unique salt for every hash. " + "This prevents Rainbow Table attacks." + " The work factor (strength) '" - + BCRYPT_STRENGTH + + bcryptStrength + "' means the algorithm " + "performs 2^" - + BCRYPT_STRENGTH + + bcryptStrength + " iterations. This makes each guess 'expensive' in CPU time." + " Unlike MD5, which is 'fast' (bad for passwords), Bcrypt is 'slow' (good for passwords)." + " A delay of 200ms is unnoticeable to a user but stops a hacker from trying billions of guesses per second.", 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..a14d155af 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/CryptographicFailuresSeeder.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/cryptographicFailures/repo/CryptographicFailuresSeeder.java @@ -3,10 +3,7 @@ import java.security.SecureRandom; import org.apache.commons.text.RandomStringGenerator; import org.sasanlabs.configuration.ModuleSeeder; -import org.sasanlabs.internal.utility.EncodingUtils; -import org.sasanlabs.internal.utility.EncryptionUtils; import org.sasanlabs.internal.utility.PasswordHashingUtils; -import org.sasanlabs.internal.utility.exception.EncryptionException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -44,57 +41,63 @@ public CryptographicFailuresSeeder(CryptographicFailuresVaultRepository reposito @Override @Transactional - public void seed() throws EncryptionException { - try { - // Level 1: Cleartext (Broken Cryptography) - repository.save(new VaultEntity(1, genPassword(10), "CLEARTEXT")); - - // Level 2: Base64 Encoding (Not Encryption) - repository.save( - new VaultEntity(2, EncodingUtils.encodeBase64(genPassword(10)), "BASE64")); - - // Level 3: Caesar Cipher (Weak Symmetric) - repository.save( - new VaultEntity( - 3, EncryptionUtils.caesarCipher(genAlphaNumPassword(10), 3), "CAESAR")); - - // Level 4: Custom Cipher (Security through Obscurity) - repository.save( - new VaultEntity(4, EncryptionUtils.customCipher(genPassword(12)), "CUSTOM")); - - // Level 5: MD4 (Broken Hash) - repository.save(new VaultEntity(5, PasswordHashingUtils.md4Hex(genPassword(5)), "MD4")); - - // Level 6: MD5 (Broken Hash) - repository.save(new VaultEntity(6, PasswordHashingUtils.md5Hex(genPassword(5)), "MD5")); - - // Level 7: SHA-1 (Weak Hash) - repository.save( - new VaultEntity(7, PasswordHashingUtils.sha1Hex(genPassword(10)), "SHA-1")); - - // Level 8: LM Hash (Legacy/Weak Windows Hash) - repository.save(new VaultEntity(8, PasswordHashingUtils.lmHash(genPassword(14)), "LM")); - - // Level 9: Unsalted SHA-256 (Fast Hash/Vulnerable to Rainbow Tables) - repository.save( - new VaultEntity( - 9, PasswordHashingUtils.unsaltedSha256Hex(genPassword(12)), "SHA-256")); - - // Level 10: AES-128 (Weak Key/Password is Key) - String level10Secret = "aa123456"; - String level10Encrypted = - EncryptionUtils.encrypt( - level10Secret, EncryptionUtils.getKeyFromPassword(level10Secret)); - repository.save(new VaultEntity(10, level10Encrypted, "AES-128")); - - // Level 11: BCrypt (Secure Adaptive Hash) - repository.save( - new VaultEntity( - 11, PasswordHashingUtils.bCryptHash(genPassword(15)), "BCRYPT")); - } catch (EncryptionException e) { - throw new EncryptionException( - "CryptographicFailureSeeder failed To seed table - Encryption Error", e); - } + public void seed() { + // Levels 1-9 all demonstrated some flavor of inadequately protected password storage + // (plaintext, encoding-as-encryption, weak reversible ciphers, or fast/unsalted hashes). + // All of them are now fixed the same way BCrypt is used in Level 11: a slow, salted, + // adaptive one-way hash so the stored value is never crackable/reversible. + + // Level 1: was Cleartext (CWE-326) -> now BCrypt + repository.save(new VaultEntity(1, PasswordHashingUtils.bCryptHash(genPassword(10)), "BCRYPT")); + + // Level 2: was Base64 Encoding mistaken for encryption (CWE-326) -> now BCrypt + repository.save(new VaultEntity(2, PasswordHashingUtils.bCryptHash(genPassword(10)), "BCRYPT")); + + // Level 3: was Caesar Cipher (Weak Reversible Cipher) -> now BCrypt + repository.save( + new VaultEntity( + 3, PasswordHashingUtils.bCryptHash(genAlphaNumPassword(10)), "BCRYPT")); + + // Level 4: was Custom Cipher / security through obscurity -> now BCrypt + repository.save(new VaultEntity(4, PasswordHashingUtils.bCryptHash(genPassword(12)), "BCRYPT")); + + // Levels 5 and 6 were seeded with a deliberately short five character secret, because + // MD4/MD5 are fast enough to exhaust that keyspace outright. Moving them to BCrypt slows + // an attacker down but does not by itself make a five character secret safe, so the + // secrets are lengthened to match the rest of the vault. Both the storage transform and + // the secret's entropy have to be adequate; fixing only one of them leaves the level + // solvable. + + // Level 5: was MD4 (Broken Hash) -> now BCrypt + repository.save( + new VaultEntity( + 5, PasswordHashingUtils.bCryptHash(genPassword(16)), "BCRYPT")); + + // Level 6: was MD5 (Broken Hash) -> now BCrypt + repository.save( + new VaultEntity( + 6, PasswordHashingUtils.bCryptHash(genPassword(16)), "BCRYPT")); + + // Level 7: was SHA-1 (Weak Hash) -> now BCrypt + repository.save(new VaultEntity(7, PasswordHashingUtils.bCryptHash(genPassword(10)), "BCRYPT")); + + // Level 8: was LM Hash (Legacy/Weak Windows Hash) -> now BCrypt + repository.save(new VaultEntity(8, PasswordHashingUtils.bCryptHash(genPassword(14)), "BCRYPT")); + + // Level 9: was Unsalted SHA-256 (Fast Hash/Vulnerable to Rainbow Tables) -> now BCrypt + repository.save(new VaultEntity(9, PasswordHashingUtils.bCryptHash(genPassword(12)), "BCRYPT")); + + // Level 10: was AES-128/ECB with a key derived directly from the password itself + // (predictable key, no IV -> CWE-327/CWE-330). A password should never be encrypted in + // the first place - a stored credential only ever needs to be recognised again, never + // recovered - so this entry is stored the same one-way way as every other level rather + // than replaced with a stronger two-way cipher. + repository.save(new VaultEntity(10, PasswordHashingUtils.bCryptHash(genPassword(12)), "BCRYPT")); + + // Level 11: BCrypt (Secure Adaptive Hash) — reference implementation + repository.save( + new VaultEntity( + 11, PasswordHashingUtils.bCryptHash(genPassword(15)), "BCRYPT")); } public boolean isSeeded() { 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..b4decc19a 100644 --- a/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java +++ b/src/main/java/org/sasanlabs/service/vulnerability/ldapInjection/LDAPInjectionVulnerability.java @@ -34,6 +34,17 @@ value = "LDAPInjectionVulnerability") public class LDAPInjectionVulnerability { + // Directory uids in this app are always plain alphanumeric (plus '.', '_', '-'). Rejecting + // anything outside that shape before it ever reaches Filter.createEqualityFilter means a + // malformed or control-character-laden value can never reach the LDAP SDK in the first + // place, so it cannot trigger a filter-construction error path that differs from the normal + // "no match" response. + private static final String VALID_UID_PATTERN = "[A-Za-z0-9._-]{1,64}"; + + private static boolean isValidUid(String username) { + return username != null && username.matches(VALID_UID_PATTERN); + } + private List searchUsers(String filter) throws Exception { LDAPConnection connection = EmbeddedLDAPConfig.getDirectoryServer().getConnection(); @@ -111,8 +122,10 @@ public ResponseEntity> level1( return response("Provide username", false); } - // Vulnerable LDAP filter - String ldapQuery = "(uid=" + username + ")"; + // Fixed: LDAP-encode the user-supplied value before embedding it in the filter so + // special characters (\, *, (, ), NUL) can no longer alter the filter's structure. + String sanitizedUsername = Filter.encodeValue(username); + String ldapQuery = "(uid=" + sanitizedUsername + ")"; try { List users = searchUsers(ldapQuery); @@ -123,7 +136,9 @@ public ResponseEntity> level1( return response(Map.of("filter", ldapQuery, "users", users), true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + // The directory's own error text describes why the filter was rejected, which tells + // an attacker how their input was parsed. Report only that the lookup failed. + return response("LDAP query failed", false); } } @@ -140,8 +155,10 @@ public ResponseEntity> level2( return response("Provide username", false); } - // OR based LDAP query - String ldapQuery = "(|(uid=" + username + ")(mail=" + username + "))"; + // Fixed: encode the user-supplied value before it is used in either branch of the + // OR-based filter. + String sanitizedUsername = Filter.encodeValue(username); + String ldapQuery = "(|(uid=" + sanitizedUsername + ")(mail=" + sanitizedUsername + "))"; try { List users = searchUsers(ldapQuery); @@ -152,7 +169,9 @@ public ResponseEntity> level2( return response(Map.of("filter", ldapQuery, "users", users), true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + // The directory's own error text describes why the filter was rejected, which tells + // an attacker how their input was parsed. Report only that the lookup failed. + return response("LDAP query failed", false); } } @@ -170,44 +189,36 @@ public ResponseEntity> level3( return response("Provide username and password", false); } - // Vulnerable authentication filter - String ldapQuery = "(&(uid=" + username + ")(uid=*))"; + // Fixed: encode the user-supplied value so the filter can only ever match the exact + // uid requested, preventing an injected filter from widening the search or bypassing + // the intended lookup. + String sanitizedUsername = Filter.encodeValue(username); + String ldapQuery = "(&(uid=" + sanitizedUsername + ")(uid=*))"; try { List users = searchEntries(ldapQuery); - SearchResultEntry validUser = null; - if (users.isEmpty()) { - return response("LDAP Filter: " + ldapQuery + "\nNo users found", false); + return response("Invalid credentials", false); } - boolean authenticated = false; - - for (SearchResultEntry user : users) { - String storedPassword = user.getAttributeValue("userPassword"); - - if (verifyPassword(password, storedPassword)) { - authenticated = true; - validUser = user; - break; - } - } + // Fixed: the filter now matches exactly the claimed uid, so exactly one entry (if + // any) is ever returned - checking only that entry's password means a widened + // match can no longer let a caller be authenticated as an account other than the + // one it claimed. The constructed filter and matched uid are no longer echoed back + // either, since that would still hand an attacker a debugging oracle into the + // directory even with the injection itself closed. + String storedPassword = users.get(0).getAttributeValue("userPassword"); - if (!authenticated) { + if (!verifyPassword(password, storedPassword)) { return response("Invalid credentials", false); } - return response( - Map.of( - "filter", - ldapQuery, - "users", - List.of(validUser.getAttributeValue("uid"))), - true); - + return response("Login successful", true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + // The directory's own error text describes why the filter was rejected, which tells + // an attacker how their input was parsed. Report only that the lookup failed. + return response("Invalid credentials", false); } } @@ -218,35 +229,43 @@ 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 { + @RequestParam(required = false) String username, + @RequestParam(required = false) String password) { - if (username == null) { - return response("Provide username", false); + // Fixed: escaping the value that goes into the filter is only half the fix here - the + // previous version happily reported "User found" for any syntactically valid uid, with + // no credential check at all. That is a free existence oracle: an attacker can enumerate + // every account in the directory without ever guessing a password. Matching every other + // authentication-flavored level in this class (3/5/6), a lookup is only meaningful here + // once it is paired with verifying the submitted password against that one matched + // entry, and only a single unambiguous match is accepted. + if (!isValidUid(username) || password == null || password.isEmpty()) { + return response("Invalid credentials", false); } - // Sanitization - String sanitizedInput = Filter.encodeValue(username); - - String ldapQuery = "(uid=" + sanitizedInput + ")"; + // Filter.createEqualityFilter builds and escapes the filter itself, rather than hand + // concatenating an already-encoded value into a filter string - one less place for the + // escaping step and the query construction to drift apart. The uid shape check above + // means this call always receives an already-well-formed value. + String ldapQuery = Filter.createEqualityFilter("uid", username).toString(); try { - List users = searchUsers(ldapQuery); + List entries = searchEntries(ldapQuery); - if (users.isEmpty()) { - return response( - Map.of( - "filter", - ldapQuery, - "users", - List.of(), - "message", - "No users found"), - false); + if (entries.size() != 1) { + return response("Invalid credentials", false); } - return response(Map.of("filter", ldapQuery, "users", users), true); + String storedPassword = entries.get(0).getAttributeValue("userPassword"); + if (!verifyPassword(password, storedPassword)) { + return response("Invalid credentials", false); + } + + return response("Login successful", true); } catch (Exception e) { - return response("LDAP query failed: " + e.getMessage(), false); + // The directory's own error text describes why the filter was rejected, which tells + // an attacker how their input was parsed. Report only that the lookup failed. + return response("Invalid credentials", false); } } @@ -264,7 +283,10 @@ public ResponseEntity> level5( return response("Provide username and password", false); } - String ldapQuery = "(&(uid=" + username + "))"; + // Fixed: encode the user-supplied value so boolean/blind injection payloads cannot + // manipulate the filter's truth value. + String sanitizedUsername = Filter.encodeValue(username); + String ldapQuery = "(&(uid=" + sanitizedUsername + "))"; try { List users = searchEntries(ldapQuery); diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 582cbb9f9..19e07ee56 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -305,13 +305,10 @@ CRYPTOGRAPHIC_FAILURES_VULNERABILITY=Cryptographic Failures occur when sensitive This vulnerability can lead to exposure of sensitive information such as passwords, credit card numbers, personal data, and authentication tokens. \ Common issues include: using weak hashing algorithms (MD5, SHA1), storing data in plaintext, using broken encryption algorithms (DES, RC4), \ and improper key management.

\ -You can explore the database contents directly at the H2 Console and to find and crack the secrets:
\ -Legacy UI: http://localhost:9090/VulnerableApp/h2/
\ -Docker UI: http://localhost/VulnerableApp/h2/login.jsp

\ -


\ +The vault used to ship a standing database account whose credentials were printed here, \ +granting SELECT on the table that holds every level's secret. That account has been removed: \ +the stored values are no longer readable by anyone but the application, and each level answers \ +only whether a submitted guess matches.

\ Important Links:
\
  1. OWASP Top 10 - A02:2021 Cryptographic Failures \
  2. CWE-327: Use of a Broken or Risky Cryptographic Algorithm \ diff --git a/src/main/resources/scripts/CryptographicFailures/db/schema.sql b/src/main/resources/scripts/CryptographicFailures/db/schema.sql index e61586d60..0dabe376e 100644 --- a/src/main/resources/scripts/CryptographicFailures/db/schema.sql +++ b/src/main/resources/scripts/CryptographicFailures/db/schema.sql @@ -11,6 +11,7 @@ CREATE TABLE cryptographic_failures_vault ( -- Application user has full access (for functional purposes) GRANT ALL ON cryptographic_failures_vault TO application; --- A read-only user for exploration by the attacker/user -CREATE USER IF NOT EXISTS cryptographic_failures_user PASSWORD 'cryptographic_failures_password'; -GRANT SELECT ON cryptographic_failures_vault TO cryptographic_failures_user; \ No newline at end of file +-- This table used to also provision a standing, read-only account with a hardcoded password +-- (CWE-798) granting direct SELECT access to every level's stored secret, bypassing whatever +-- protection the application layer applied. That account has been removed; the application +-- user above retains the access it actually needs, and nothing else can read this table. \ No newline at end of file diff --git a/src/main/resources/static/templates/CryptographicFailures/LEVEL_1/CryptographicFailures.js b/src/main/resources/static/templates/CryptographicFailures/LEVEL_1/CryptographicFailures.js index 9e591d31e..36a7d905a 100644 --- a/src/main/resources/static/templates/CryptographicFailures/LEVEL_1/CryptographicFailures.js +++ b/src/main/resources/static/templates/CryptographicFailures/LEVEL_1/CryptographicFailures.js @@ -1,3 +1,20 @@ +// Server-supplied text is untrusted as far as this page is concerned - it includes level +// commentary and, for some levels, a decoy value - and was previously written into the page via +// innerHTML, which parses its argument as markup rather than plain text. Rendering it through a +// bold text node instead means the string can only ever display as text, never execute as markup. +function renderServerText(container, label, text) { + container.textContent = ""; + if (label) { + let labelNode = document.createElement("strong"); + labelNode.textContent = label; + container.appendChild(labelNode); + container.appendChild(document.createTextNode(" ")); + } + let textNode = document.createElement("strong"); + textNode.textContent = text; + container.appendChild(textNode); +} + function loadChallenge() { let url = getUrlForVulnerabilityLevel(); doGetAjaxCall(displayChallenge, url, true); @@ -5,7 +22,7 @@ function loadChallenge() { function displayChallenge(data) { let challengeDiv = document.getElementById("challenge"); - challengeDiv.innerHTML = "" + data.content + ""; + renderServerText(challengeDiv, null, data.content); if (data.isValid) { challengeDiv.className = "challenge-secure"; } else { @@ -22,7 +39,7 @@ function addingEventListenerToSubmitButton() { if (!password) { let resultDiv = document.getElementById("result"); - resultDiv.innerHTML = "Please enter a password guess."; + renderServerText(resultDiv, null, "Please enter a password guess."); resultDiv.style.color = "red"; return; } @@ -40,13 +57,8 @@ function addingEventListenerToSubmitButton() { function appendResponseCallback(data) { let resultDiv = document.getElementById("result"); - if (data.isValid) { - resultDiv.innerHTML = "Result: " + data.content; - resultDiv.className = "result-success"; - } else { - resultDiv.innerHTML = "Result: " + data.content; - resultDiv.className = "result-failure"; - } + renderServerText(resultDiv, "Result:", data.content); + resultDiv.className = data.isValid ? "result-success" : "result-failure"; } addingEventListenerToSubmitButton(); 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..3f51cd529 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,20 @@ +// Fixed: message embeds the server-built LDAP filter, which in turn embeds the submitted +// username. RFC 4515 filter-encoding only escapes characters that are special to an LDAP +// filter (\, *, (, ), NUL) - it does not escape HTML, so a username containing markup passed +// through untouched and, written via innerHTML, executed as script. The text is now built out +// of real text nodes with explicit
    elements between lines, so it can only ever render as +// text. function showMessage(message) { let element = document.getElementById("responseMessage"); - element.innerHTML = message.replace(/\n/g, "
    "); + element.innerHTML = ""; + 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 +33,9 @@ const LEVEL_CONFIG = { pass: true, }, LEVEL_4: { - subtitle: "Search sanitized user input using LDAP filter.", - button: "Search User", + subtitle: "Login using LDAP filter (Sanitized, credential-verified).", + button: "Login", + pass: true, }, LEVEL_5: { subtitle: "Login using LDAP filter (Blind injection scenario).", 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..f49a86788 100644 --- a/src/test/java/org/sasanlabs/internal/utility/PasswordHashingUtilsTest.java +++ b/src/test/java/org/sasanlabs/internal/utility/PasswordHashingUtilsTest.java @@ -7,33 +7,6 @@ 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() { @@ -62,17 +35,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() {