CWE-316: Cleartext Storage of Sensitive Information in Memory - Python
Overview
Storing sensitive data (passwords, API keys, cryptographic keys) in memory as cleartext in Python exposes it to memory dumps, debuggers, and memory disclosure vulnerabilities. Python strings are immutable and persist in memory, making secure handling of sensitive data challenging. Use bytearray for mutable secrets, clear them after use, and consider memory-locking libraries.
Primary Defence: Use bytearray for passwords with explicit byte-by-byte clearing (for i in range(len(password)): password[i] = 0) in finally blocks, implement context managers with @contextmanager for automatic cleanup of sensitive data, and use memory-locking with mlock() (Linux) or memfd_create() for highly sensitive keys to prevent cleartext persistence in memory dumps and enable secure disposal when data is no longer needed.
Common Vulnerable Patterns
Storing password as immutable string
import getpass
# VULNERABLE - String is immutable, stays in memory
def authenticate_user():
password = getpass.getpass("Enter password: ")
# Password remains in memory as immutable string
# Cannot be cleared, may appear in memory dumps
result = verify_password(password)
# String not cleared from memory
return result
Storing API keys as strings
import os
# VULNERABLE - API key persists in memory as string
class APIClient:
def __init__(self):
# Immutable string - cannot be cleared
self.api_key = os.environ.get('API_KEY')
self.secret = os.environ.get('API_SECRET')
def make_request(self, endpoint):
# API key visible in memory during entire object lifetime
headers = {'Authorization': f'Bearer {self.api_key}'}
return requests.get(endpoint, headers=headers)
Logging sensitive data
import logging
# VULNERABLE - Password logged to file/memory
def login(username, password):
logging.debug(f"Attempting login for {username} with password {password}")
# Password now in log strings, log files, and memory
result = authenticate(username, password)
logging.info(f"Login successful for {username}")
return result
Concatenating sensitive strings
# VULNERABLE - Creates multiple immutable copies in memory
def build_connection_string(password):
# Each concatenation creates new immutable string
conn_str = "Server=db.example.com;"
conn_str += "User=admin;"
conn_str += f"Password={password};" # Password copied to new string
conn_str += "Database=prod"
# Multiple copies of password in memory
return conn_str
Not clearing sensitive data
from cryptography.fernet import Fernet
# VULNERABLE - Encryption key persists as string
def encrypt_data(data):
# Key loaded as immutable string
key = Fernet.generate_key()
cipher = Fernet(key)
encrypted = cipher.encrypt(data.encode())
# Key never cleared from memory
return encrypted, key
Secure Patterns
Using bytearray for mutable secrets
import getpass
import ctypes
def secure_authenticate():
"""Use bytearray for mutable password that can be cleared"""
# Get password as string initially
password_str = getpass.getpass("Enter password: ")
# Convert to bytearray (mutable)
password = bytearray(password_str.encode('utf-8'))
try:
# Use password for authentication
result = verify_password(password)
return result
finally:
# Explicitly clear password from memory
for i in range(len(password)):
password[i] = 0
# Delete reference
del password
Why this works:
bytearrayenables explicit memory clearing: Unlike immutable strings, mutable bytearrays can be overwritten with zerosfinallyblock ensures cleanup: Password is cleared even if authentication fails or raises an exception- Minimizes cleartext persistence: Converting to
bytearrayimmediately reduces time as immutable string - Non-deterministic GC requires explicit clearing: Python's garbage collection is unreliable for timely memory cleanup
- Overwriting each byte is reliable: Loop-based clearing (
password[i] = 0) works consistently across implementations
Secure key handling with explicit clearing
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
class SecureKeyManager:
"""Manage cryptographic keys with secure memory handling"""
def __init__(self):
self._key = None
def load_key(self):
"""Load key as bytearray for secure clearing"""
key_str = os.environ.get('ENCRYPTION_KEY')
if key_str:
# Convert to bytearray (mutable)
self._key = bytearray(key_str.encode('utf-8'))
def encrypt(self, plaintext):
"""Encrypt data using the key"""
if not self._key:
raise ValueError("Key not loaded")
# Use key for encryption
cipher = Cipher(
algorithms.AES(bytes(self._key)),
modes.GCM(os.urandom(16)),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
return ciphertext
def clear_key(self):
"""Explicitly clear key from memory"""
if self._key:
# Overwrite with zeros
for i in range(len(self._key)):
self._key[i] = 0
# Delete reference
del self._key
self._key = None
def __del__(self):
"""Ensure key is cleared on object destruction"""
self.clear_key()
# Usage
manager = SecureKeyManager()
try:
manager.load_key()
encrypted = manager.encrypt(b"sensitive data")
finally:
manager.clear_key()
Why this works:
- Mutable storage: Stores encryption keys in
bytearraywith explicit clearing (clear_key()overwrites bytes with zeros) preventing indefinite memory persistence - Deterministic cleanup:
__del__provides safety net but unreliable (non-deterministic timing); explicitclear_key()infinallyblocks essential - Environment variable conversion: Converts string to
bytearrayenabling secure clearing; AES-GCM provides authenticated encryption (confidentiality + integrity) - Fail-fast behavior:
if not self._keycheck prevents usage after clearing - Long-running applications: Critical for web servers/daemons where keys held in memory but should be deterministically cleared (session ends, shutdown) vs relying on garbage collector
Context manager for automatic cleanup
import getpass
from contextlib import contextmanager
@contextmanager
def secure_password():
"""Context manager that automatically clears password"""
password_str = getpass.getpass("Enter password: ")
password = bytearray(password_str.encode('utf-8'))
try:
yield password
finally:
# Always clear password, even on exception
for i in range(len(password)):
password[i] = 0
del password
# Usage
def login():
with secure_password() as password:
# Use password
result = authenticate(bytes(password))
return result
# Password automatically cleared after context exits
Why this works:
- Automatic cleanup: Context manager protocol (
@contextmanager+withstatement) ensuresfinallyblock executes even on exceptions, providing automatic password clearing - Yield mechanism:
yieldpasses clearablebytearraytowithblock, thenfinallyoverwrites bytes with zeros and deletes reference on context exit - Reduces errors: Eliminates manual
try-finallyblocks in every auth function, reducing chance of forgetting password clearing - Temporary bytes conversion:
bytes(password)creates immutable bytes object (can't be cleared) but necessary for crypto library compatibility - Idiomatic Python:
with secure_password() as password:syntax signals limited lifetime, making secure clearing automatic and visually clear
Memory locking with mlock (Linux)
import ctypes
import ctypes.util
import os
class SecureMemory:
"""Secure memory allocation with mlock to prevent swapping"""
def __init__(self, size):
self.size = size
self.buffer = bytearray(size)
# Load libc for mlock
libc = ctypes.CDLL(ctypes.util.find_library('c'))
# Lock memory to prevent swapping to disk
addr = ctypes.addressof(ctypes.c_char.from_buffer(self.buffer))
result = libc.mlock(addr, size)
if result != 0:
raise OSError(f"mlock failed with code {result}")
self.locked = True
def write(self, data):
"""Write data to secure buffer"""
if len(data) > self.size:
raise ValueError("Data too large for buffer")
# Clear buffer first
for i in range(self.size):
self.buffer[i] = 0
# Write new data
for i, byte in enumerate(data):
self.buffer[i] = byte
def read(self):
"""Read data from secure buffer"""
return bytes(self.buffer)
def clear(self):
"""Explicitly clear buffer"""
for i in range(self.size):
self.buffer[i] = 0
def __del__(self):
"""Unlock and clear memory on destruction"""
if hasattr(self, 'locked') and self.locked:
self.clear()
# Unlock memory
libc = ctypes.CDLL(ctypes.util.find_library('c'))
addr = ctypes.addressof(ctypes.c_char.from_buffer(self.buffer))
libc.munlock(addr, self.size)
# Usage
secure_mem = SecureMemory(256)
try:
secure_mem.write(b"sensitive password")
# Use the secure memory
password = secure_mem.read()
finally:
secure_mem.clear()
Why this works:
- Prevents swapping:
mlock()system call (via ctypes calling libc) locks memory region into RAM, preventing OS from swapping to disk - Persistent file protection: Critical because swap files persist on disk and could be recovered after process termination
- Implementation: Allocates
bytearray, usesctypes.addressof()to get memory address formlock(), marking pages non-swappable in kernel - Cleanup:
clear()overwrites bytes with zeros;__del__callsmunlock()(but use explicitfinallyfor deterministic cleanup) - Platform/permissions: Linux/Unix-specific (Windows has
VirtualLock()); requiresCAP_IPC_LOCKcapability or sufficientRLIMIT_MEMLOCK; use sparingly (locking too much impacts performance)
Using memfd for secure temporary storage
import os
import tempfile
def process_sensitive_data(sensitive_data):
"""Use in-memory file descriptor for sensitive data"""
# Create memory-based file descriptor (Linux)
# Data never written to disk
fd = os.memfd_create("sensitive", os.MFD_CLOEXEC)
try:
# Write sensitive data
os.write(fd, sensitive_data)
# Seek to beginning
os.lseek(fd, 0, os.SEEK_SET)
# Read and process
data = os.read(fd, len(sensitive_data))
result = process(data)
return result
finally:
# Close file descriptor (data automatically cleared)
os.close(fd)
Why this works:
- RAM-only storage:
memfd_create()(Linux 3.17+) creates anonymous file descriptor backed by RAM not disk, preventing recovery via forensics or temp directory examination - Automatic cleanup:
MFD_CLOEXECflag closes descriptor when executing new programs (prevents child process inheritance); memfd auto-freed when descriptor closed - no disk cleanup needed - No disk persistence: Unlike
tempfile//tmp, memfd exists only in memory throughout lifecycle - File-like interface: Can pass to functions expecting file objects via
os.fdopen(fd, 'rb')for standard Python I/O compatibility - Container-friendly: Ideal for processing sensitive data (e.g., decrypting password before auth) without disk attack surface in containerized environments
Secure password comparison
import hmac
import hashlib
def secure_password_verify(stored_hash, password_input):
"""Verify password without keeping plaintext in memory"""
# Convert to bytearray immediately
password = bytearray(password_input.encode('utf-8'))
try:
# Hash the password
input_hash = hashlib.pbkdf2_hmac(
'sha256',
bytes(password),
b'salt', # Use proper salt
100000
)
# Constant-time comparison
result = hmac.compare_digest(input_hash, stored_hash)
return result
finally:
# Clear password from memory
for i in range(len(password)):
password[i] = 0
del password
Why this works:
- Dual security mechanisms:
hmac.compare_digest()for timing attack prevention +bytearrayoverwrite for memory disclosure prevention - Minimal cleartext exposure: Convert password to
bytearrayimmediately, clear infinallyblock; temporarybytes(password)for PBKDF2 input, then overwrite original - Cryptographic strength: PBKDF2-HMAC-SHA256 with 600,000 iterations makes brute-force impractical (OWASP 2023 recommendation); salt ensures identical passwords produce different hashes
- Constant-time comparison:
hmac.compare_digest()takes same time regardless of first byte difference location, preventing timing attacks (early-return==leaks information) - Custom auth use case: Essential for Python apps not using Django/Flask built-in password handling - provides both cryptographic strength and side-channel resistance
Django with secure session handling
from django.contrib.auth import authenticate, login
from django.http import HttpResponse
def login_view(request):
"""Secure login that clears password from memory"""
if request.method == 'POST':
username = request.POST.get('username')
password_str = request.POST.get('password')
# Convert to bytearray for clearing
password = bytearray(password_str.encode('utf-8'))
try:
# Authenticate user (Django handles password securely)
# Note: authenticate() expects string, but clears internally
user = authenticate(
request,
username=username,
password=password_str
)
if user is not None:
login(request, user)
return HttpResponse("Login successful")
else:
return HttpResponse("Invalid credentials", status=401)
finally:
# Clear password from memory
for i in range(len(password)):
password[i] = 0
del password
Why this works:
- Framework security:
authenticate()uses PBKDF2 by default (configurable to bcrypt/Argon2), with automatic salting and constant-time comparison preventing timing attacks - Memory clearing: Converts password string to mutable
bytearrayand clears infinallyblock for defense-in-depth (though Django clears internally too) - Session-based auth:
login()creates session with cookie-stored ID, avoiding password retransmission on subsequent requests - Secure cookies:
SESSION_COOKIE_SECURE,SESSION_COOKIE_HTTPONLY,SESSION_COOKIE_SAMESITE = 'Strict'prevent interception, XSS, and CSRF - Username enumeration prevention: Generic "Invalid credentials" message for both non-existent users and wrong passwords
Flask with secure credential handling
from flask import Flask, request
import bcrypt
app = Flask(__name__)
@app.route('/login', methods=['POST'])
def login():
"""Secure login endpoint"""
username = request.form.get('username')
password_str = request.form.get('password')
# Convert to bytearray for clearing
password = bytearray(password_str.encode('utf-8'))
try:
# Get stored hash from database
stored_hash = get_user_hash(username)
# Verify password (bcrypt internally clears)
if bcrypt.checkpw(bytes(password), stored_hash):
# Generate session token
token = create_session(username)
return {'token': token}, 200
else:
return {'error': 'Invalid credentials'}, 401
finally:
# Always clear password
for i in range(len(password)):
password[i] = 0
del password
Why this works:
- Immediate conversion: Converts immutable string to mutable
bytearrayand clears infinallyblock to minimize cleartext exposure - Bcrypt security:
checkpw()uses adaptive hashing with automatic salting and constant-time comparison preventing timing attacks - Short-lived copy:
bytes(password)creates temporary immutable view for bcrypt (which requiresbytes), but originalbytearrayis cleared infinally - Session tokens: Returns token after auth to avoid password retransmission; generic error messages prevent username enumeration
- Explicit cleanup:
finallyblock ensures clearing even if DB queries fail or exceptions occur during token generation
Framework-Specific Guidance
Django password handling
from django.contrib.auth.hashers import make_password, check_password
def register_user(username, password_str):
"""Securely hash password during registration"""
# Django's make_password handles clearing internally
password_hash = make_password(password_str)
# Store hash, not plaintext
User.objects.create(username=username, password=password_hash)
FastAPI with secure authentication
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
import bcrypt
app = FastAPI()
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""Secure token endpoint"""
password = bytearray(form_data.password.encode())
try:
user = get_user(form_data.username)
if not user or not bcrypt.checkpw(bytes(password), user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(data={"sub": user.username})
return {"access_token": token, "token_type": "bearer"}
finally:
for i in range(len(password)):
password[i] = 0
del password