Storing Passwords in a Recoverable Format
Description
Storing Passwords in a Recoverable Format occurs when software stores passwords in a way that allows them to be recovered to their original plaintext form. This includes using reversible encryption, encoding (Base64), XOR obfuscation, or custom "encryption" schemes. Proper password storage uses one-way cryptographic hash functions that make password recovery computationally infeasible. Any recoverable storage format means that attackers who gain access to the storage can retrieve all passwords.
Risk
Recoverable password storage exposes all users if the system is compromised. Unlike proper hashing where attackers must crack each password individually, reversible encryption or encoding allows instant mass recovery. The encryption key or algorithm becomes a single point of failure—if discovered, all passwords are immediately compromised. This has led to massive breaches where millions of passwords were recovered in minutes. Even "encrypted" passwords using symmetric encryption provide false security.
Solution
Always use proper password hashing algorithms designed for password storage: bcrypt, scrypt, or Argon2. Never use encryption for password storage—passwords don't need to be recovered, only verified. Use unique salts per password. Configure appropriate work factors. If legacy systems use recoverable storage, migrate to proper hashing immediately. For systems requiring password recovery, implement secure password reset flows instead of storing recoverable passwords.
Common Consequences
| Impact | Details |
|---|---|
| Confidentiality | Scope: Mass Password Exposure All passwords can be recovered simultaneously once storage is compromised. |
| Authentication | Scope: Account Takeover Recovered passwords enable immediate access to all user accounts. |
| Privacy | Scope: Cross-Site Compromise Users reusing passwords are compromised across multiple sites. |
Example Code + Solution Code
Vulnerable Code
# VULNERABLE: Base64 "encryption" (just encoding!)
import base64
def store_password_base64(password):
# This is NOT encryption - easily reversible!
encoded = base64.b64encode(password.encode()).decode()
return encoded
def verify_password_base64(password, stored):
decoded = base64.b64decode(stored).decode()
return password == decoded # Password is recovered!
# VULNERABLE: Symmetric encryption for passwords
from cryptography.fernet import Fernet
# If this key is compromised, ALL passwords are exposed
ENCRYPTION_KEY = Fernet.generate_key()
def store_password_encrypted(password):
cipher = Fernet(ENCRYPTION_KEY)
return cipher.encrypt(password.encode())
def verify_password_encrypted(password, stored):
cipher = Fernet(ENCRYPTION_KEY)
decrypted = cipher.decrypt(stored).decode() # Password recovered!
return password == decrypted
# VULNERABLE: XOR "encryption"
def xor_encrypt(password, key="secret"):
return ''.join(chr(ord(p) ^ ord(k)) for p, k in zip(password, key * len(password)))
def store_password_xor(password):
return xor_encrypt(password) # Trivially reversible!
# VULNERABLE: Custom "encryption"
def custom_encrypt(password):
# ROT13 or similar - not real encryption!
return password.translate(str.maketrans(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm'
))
// VULNERABLE: AES encryption for passwords
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class VulnerablePasswordStorage {
private static final byte[] ENCRYPTION_KEY = "MySecretKey12345".getBytes();
public String encryptPassword(String password) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(ENCRYPTION_KEY, "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
byte[] encrypted = cipher.doFinal(password.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
public boolean verifyPassword(String password, String stored) throws Exception {
SecretKeySpec keySpec = new SecretKeySpec(ENCRYPTION_KEY, "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(stored));
String originalPassword = new String(decrypted); // Password recovered!
return password.equals(originalPassword);
}
}
// VULNERABLE: Obfuscation mistaken for encryption
public class ObfuscatedPassword {
public String obfuscate(String password) {
// Just hex encoding - trivially reversible!
StringBuilder hex = new StringBuilder();
for (char c : password.toCharArray()) {
hex.append(String.format("%02x", (int) c));
}
return hex.toString();
}
public String deobfuscate(String hex) {
StringBuilder password = new StringBuilder();
for (int i = 0; i < hex.length(); i += 2) {
password.append((char) Integer.parseInt(hex.substring(i, i + 2), 16));
}
return password.toString(); // Original password!
}
}
// VULNERABLE: Base64 encoding
function storePasswordBase64(password) {
// NOT encryption!
return Buffer.from(password).toString('base64');
}
function verifyPasswordBase64(password, stored) {
const decoded = Buffer.from(stored, 'base64').toString();
return password === decoded; // Password recovered!
}
// VULNERABLE: Crypto with reversible encryption
const crypto = require('crypto');
const ALGORITHM = 'aes-256-cbc';
const KEY = crypto.randomBytes(32);
const IV = crypto.randomBytes(16);
function encryptPassword(password) {
const cipher = crypto.createCipheriv(ALGORITHM, KEY, IV);
let encrypted = cipher.update(password, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
}
function decryptPassword(encrypted) {
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, IV);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted; // Original password!
}
// VULNERABLE: Simple substitution
function simpleEncrypt(password) {
return password.split('').map(c =>
String.fromCharCode(c.charCodeAt(0) + 1)
).join(''); // Just shifts characters - trivially reversible!
}
Fixed Code
# SAFE: Using bcrypt (recommended)
import bcrypt
def hash_password_bcrypt(password):
"""Hash password with bcrypt - NOT recoverable."""
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode(), salt)
return hashed.decode()
def verify_password_bcrypt(password, stored_hash):
"""Verify password without recovering it."""
return bcrypt.checkpw(password.encode(), stored_hash.encode())
# SAFE: Using Argon2 (winner of Password Hashing Competition)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=3,
memory_cost=65536,
parallelism=4
)
def hash_password_argon2(password):
"""Hash password with Argon2 - NOT recoverable."""
return ph.hash(password)
def verify_password_argon2(password, stored_hash):
"""Verify password without recovering it."""
try:
ph.verify(stored_hash, password)
return True
except VerifyMismatchError:
return False
# SAFE: Using PBKDF2 with high iterations
import hashlib
import os
def hash_password_pbkdf2(password):
"""Hash password with PBKDF2 - NOT recoverable."""
salt = os.urandom(32)
iterations = 600000 # OWASP recommendation for SHA-256
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations)
return f"pbkdf2:sha256:{iterations}:{salt.hex()}:{key.hex()}"
def verify_password_pbkdf2(password, stored_hash):
"""Verify password without recovering it."""
parts = stored_hash.split(':')
iterations = int(parts[2])
salt = bytes.fromhex(parts[3])
stored_key = bytes.fromhex(parts[4])
computed_key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations)
# Constant-time comparison
return hmac.compare_digest(computed_key, stored_key)
# Migration from recoverable storage
def migrate_password(user, old_password_encrypted, encryption_key):
"""Migrate from encrypted to hashed storage."""
# Decrypt old password (one-time operation)
cipher = Fernet(encryption_key)
plaintext = cipher.decrypt(old_password_encrypted).decode()
# Hash with bcrypt
hashed = hash_password_bcrypt(plaintext)
# Clear plaintext from memory
del plaintext
# Update user record
user.password_hash = hashed
user.password_encrypted = None # Remove old field
user.save()
// SAFE: Using BCrypt in Java
import org.mindrot.jbcrypt.BCrypt;
public class SecurePasswordStorage {
private static final int WORK_FACTOR = 12;
public String hashPassword(String password) {
// Generates unique salt automatically
// Result is NOT recoverable!
return BCrypt.hashpw(password, BCrypt.gensalt(WORK_FACTOR));
}
public boolean verifyPassword(String password, String storedHash) {
// Verifies without recovering password
return BCrypt.checkpw(password, storedHash);
}
}
// SAFE: Using Argon2 in Java
import de.mkammerer.argon2.Argon2;
import de.mkammerer.argon2.Argon2Factory;
public class Argon2PasswordStorage {
private final Argon2 argon2;
public Argon2PasswordStorage() {
this.argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id);
}
public String hashPassword(String password) {
// Hash is NOT recoverable
return argon2.hash(3, 65536, 4, password.toCharArray());
}
public boolean verifyPassword(String password, String storedHash) {
try {
return argon2.verify(storedHash, password.toCharArray());
} finally {
// Clear sensitive data
argon2.wipeArray(password.toCharArray());
}
}
public boolean needsRehash(String storedHash) {
return argon2.needsRehash(storedHash, 3, 65536, 4);
}
}
// SAFE: Spring Security password encoding
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
@Service
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
public void createUser(String username, String password) {
User user = new User();
user.setUsername(username);
// Hash is NOT recoverable
user.setPasswordHash(passwordEncoder.encode(password));
userRepository.save(user);
}
public boolean authenticate(String username, String password) {
User user = userRepository.findByUsername(username);
if (user == null) return false;
// Verify without recovering
return passwordEncoder.matches(password, user.getPasswordHash());
}
}
// SAFE: Using bcrypt in Node.js
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
async function hashPassword(password) {
// Hash is NOT recoverable
return await bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password, storedHash) {
// Verify without recovering
return await bcrypt.compare(password, storedHash);
}
// SAFE: Using Argon2 in Node.js
const argon2 = require('argon2');
async function hashPasswordArgon2(password) {
// Hash is NOT recoverable
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
}
async function verifyPasswordArgon2(password, storedHash) {
// Verify without recovering
return await argon2.verify(storedHash, password);
}
// SAFE: Using scrypt (built into Node.js)
const crypto = require('crypto');
const { promisify } = require('util');
const scryptAsync = promisify(crypto.scrypt);
async function hashPasswordScrypt(password) {
const salt = crypto.randomBytes(32);
const key = await scryptAsync(password, salt, 64, {
N: 16384, r: 8, p: 1
});
// Hash is NOT recoverable
return `${salt.toString('hex')}:${key.toString('hex')}`;
}
async function verifyPasswordScrypt(password, storedHash) {
const [saltHex, keyHex] = storedHash.split(':');
const salt = Buffer.from(saltHex, 'hex');
const storedKey = Buffer.from(keyHex, 'hex');
const computedKey = await scryptAsync(password, salt, 64, {
N: 16384, r: 8, p: 1
});
// Constant-time comparison
return crypto.timingSafeEqual(computedKey, storedKey);
}
// Migration example
async function migrateFromEncrypted(user, decryptionKey) {
// One-time decryption for migration
const cipher = crypto.createDecipheriv('aes-256-cbc', decryptionKey, iv);
let plaintext = cipher.update(user.encryptedPassword, 'hex', 'utf8');
plaintext += cipher.final('utf8');
// Hash properly
user.passwordHash = await hashPassword(plaintext);
user.encryptedPassword = null; // Remove old field
await user.save();
// Clear plaintext
plaintext = null;
}
Exploited in the Wild
Adobe Breach (2013)
153 million user passwords were exposed using 3DES encryption. Because the same key encrypted all passwords, identical passwords produced identical ciphertexts, making pattern analysis trivial.
Ashley Madison (2015)
While bcrypt was used for some passwords, older accounts had MD5 hashes that were quickly cracked, exposing millions of users.
RockYou (2009)
32 million passwords stored in plaintext were breached, becoming a cornerstone wordlist for password cracking.
Tools to test/exploit
-
Hashcat — identify and crack password hashes.
-
John the Ripper — password recovery.
-
CyberChef — decode Base64, hex, and other encodings.
CVE Examples
-
CVE-2019-5736 — Container password exposure.
-
CVE-2021-22893 — Pulse Secure password storage.
-
CVE-2020-5735 — Recoverable password storage.
References
-
MITRE. "CWE-257: Storing Passwords in a Recoverable Format." https://cwe.mitre.org/data/definitions/257.html
-
OWASP. "Password Storage Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html