Skip to content

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 cannot be overwritten in place, making secure handling of sensitive data challenging. Use bytearray for mutable secrets where APIs support it, clear them after use, and consider memory-locking libraries for high-risk secrets.

Primary Defence: Use bytearray for passwords with explicit clearing (for i in range(len(password)): password[i] = 0) in finally blocks where APIs allow it, implement context managers with @contextmanager for scoped cleanup, and use OS controls such as mlock() plus dump/swap restrictions for highly sensitive processes. Treat memfd_create() as RAM-backed file-descriptor storage, not as a memory-dump or swap protection by itself.

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:

  • bytearray enables explicit memory clearing: Unlike immutable strings, mutable bytearrays can be overwritten with zeros
  • finally block ensures cleanup: Password is cleared even if authentication fails or raises an exception
  • Minimizes cleartext persistence: Converting to bytearray immediately reduces time as immutable string
  • Non-deterministic GC requires explicit clearing: Python's garbage collection is unreliable for timely memory cleanup
  • Overwriting each byte clears this buffer: Loop-based clearing (password[i] = 0) removes the contents of the bytearray, but earlier immutable strings or temporary bytes copies may still exist

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 bytearray with explicit clearing (clear_key() overwrites bytes with zeros) preventing indefinite memory persistence
  • Deterministic cleanup: __del__ provides safety net but unreliable (non-deterministic timing); explicit clear_key() in finally blocks essential
  • Environment variable conversion: Converts string to bytearray enabling secure clearing; AES-GCM provides authenticated encryption (confidentiality + integrity)
  • Fail-fast behavior: if not self._key check 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 + with statement) ensures finally block executes even on exceptions, providing automatic password clearing
  • Yield mechanism: yield passes clearable bytearray to with block, then finally overwrites bytes with zeros and deletes reference on context exit
  • Reduces errors: Eliminates manual try-finally blocks 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, uses ctypes.addressof() to get memory address for mlock(), marking pages non-swappable in kernel
  • Cleanup: clear() overwrites bytes with zeros; __del__ calls munlock() (but use explicit finally for deterministic cleanup)
  • Platform/permissions: Linux/Unix-specific (Windows has VirtualLock()); requires CAP_IPC_LOCK capability or sufficient RLIMIT_MEMLOCK; use sparingly (locking too much impacts performance)

Using memfd for RAM-backed 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-backed file descriptor: memfd_create() creates an anonymous in-memory file descriptor, avoiding ordinary temporary files on disk
  • Automatic cleanup: MFD_CLOEXEC flag closes descriptor when executing new programs (prevents child process inheritance); memfd auto-freed when descriptor closed - no disk cleanup needed
  • No ordinary temp-file persistence: Unlike tempfile//tmp, memfd has no pathname to clean up, but it does not replace mlock(), dump exclusion, or process hardening for secrets
  • 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 + bytearray overwrite for memory disclosure prevention
  • Minimal cleartext exposure: Convert password to bytearray immediately, clear in finally block; temporary bytes(password) for PBKDF2 input, then overwrite original
  • Cryptographic strength: PBKDF2-HMAC-SHA256 with 600,000 iterations follows current OWASP guidance where PBKDF2 is required; tune parameters on production hardware and prefer Argon2id or bcrypt where appropriate
  • 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 hashing and comparison)
            # Note: authenticate() expects string; that string cannot be cleared by this code
            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: Django's password hashers use salted adaptive password hashing and timing-resistant verification, but this does not guarantee that request password strings are wiped from memory
  • Memory clearing: Converts password string to mutable bytearray and clears that copy in finally for defense-in-depth, while recognizing the original request string may remain until garbage collection
  • 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 with bcrypt. Temporary Python bytes/native buffers may remain until GC/native cleanup.
        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 bytearray and clears in finally block to minimize cleartext exposure
  • Bcrypt security: checkpw() verifies against a salted adaptive bcrypt hash, but temporary Python bytes objects created for the library may remain until garbage collection
  • Short-lived copy: bytes(password) creates temporary immutable view for bcrypt (which requires bytes), but original bytearray is cleared in finally
  • Session tokens: Returns token after auth to avoid password retransmission; generic error messages prevent username enumeration
  • Explicit cleanup: finally block 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 hashes the password, but this input string cannot be explicitly cleared by this code.
    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

Remediation Steps

  1. Locate sensitive values stored in immutable str or bytes, module globals, object fields, caches, request objects, and environment-derived configuration.
  2. Trace framework input boundaries such as Django, Flask, FastAPI, CLI prompts, and secret-loading code to identify unavoidable immutable copies.
  3. Use bytearray or memory-locking wrappers where downstream APIs can consume them, and clear mutable buffers in finally blocks or context managers.
  4. Treat __del__ cleanup as best-effort only; use explicit context managers and close/clear methods for deterministic cleanup.
  5. Remove secrets from logs, exception messages, tracebacks, telemetry, and temporary files.
  6. Re-scan and review crash dumps, core dumps, and worker process settings for the affected deployment.

Testing

  • Normal input: verify login, password hashing, encryption, and token flows still work with scoped bytearray handling.
  • Boundary input: test authentication failures, exceptions, cancelled requests, and missing form fields to confirm cleanup paths run.
  • Malicious input: capture a controlled memory dump or traceback in a non-production environment and search for known test secrets.

Additional Resources