VNX-SWIFT-003 – Swift Insecure Data Storage via UserDefaults for Sensitive Values

Overview

This rule reports a Swift write to UserDefaults (set, setValue or setObject on UserDefaults.standard, UserDefaults(suiteName:), NSUserDefaults.standardUserDefaults(), or a variable the file declares as UserDefaults) whose value or key names a credential, and a SwiftUI @AppStorage property, which is backed by UserDefaults, whose key or property name does. A credential is a name ending with password, passwd, passcode, secret, token, apiKey, privateKey, credential(s), pinCode/PIN, sessionId, cardNumber, cvv or ssn, or a password text field’s .text.

It does not report booleans, numbers, dates and nil under any key, keys that describe rather than hold a credential (hasSeenTokenPrompt, tokenExpiry), reads and removeObject, set(_:forKey:) on receivers that are not UserDefaults, Keychain code, string literals, comments and test files.

UserDefaults (formerly NSUserDefaults) serialises data to a plaintext property list file stored at Library/Preferences/<bundle-id>.plist in the application sandbox. This file is not encrypted at the application layer. While iOS encrypts the file system at rest using hardware-backed data protection, the plist is stored under the default protection class NSFileProtectionCompleteUntilFirstUserAuthentication, which means it is accessible whenever the device has been unlocked at least once after boot — including while the screen is locked.

This maps to CWE-311: Missing Encryption of Sensitive Data.

Severity: High | CWE: CWE-311 – Missing Encryption of Sensitive Data

Why This Matters

The UserDefaults plist is included in iTunes and iCloud device backups by default. An attacker with access to a backup — obtained from an insecure backup destination, a compromised macOS machine, or a social engineering attack against Apple support — can extract the plist and read all stored values in plain text using any standard property list editor.

On jailbroken devices, any process running as mobile can read another application’s preferences plist without special permissions. Malware on a jailbroken device routinely harvests UserDefaults from banking, email, and social applications to collect session tokens. Additionally, MDM profiles can read application preferences on enrolled devices, creating a risk of credential exposure in enterprise deployments.

iOS provides the Keychain specifically to address these threats. The Keychain encrypts each item with a key derived from the device passcode and the item’s accessibility class. Items marked kSecAttrAccessibleWhenUnlockedThisDeviceOnly can only be read when the device is unlocked and cannot be restored to a different device, providing strong protection against backup extraction and device transfer attacks.

What Gets Flagged

// FLAGGED: authentication token written to UserDefaults
UserDefaults.standard.set(authToken, forKey: "auth_token")

// FLAGGED: password persisted in preferences
UserDefaults.standard.set(password, forKey: "userPassword")

// FLAGGED: API key stored unencrypted
defaults.set(apiKey, forKey: "api_key")

// FLAGGED: private key material in UserDefaults
UserDefaults.standard.set(privateKey, forKey: "privateKey")

// FLAGGED: SwiftyUserDefaults subscript and a credential dictionary
Defaults[\.authToken] = response.token
UserDefaults.standard.set(["username": user, "password": pass], forKey: "lastLogin")

// NOT FLAGGED: flags, dates and encrypted values
UserDefaults.standard.set(true, forKey: "hasStoredToken")
UserDefaults.standard.set(try encrypt(token), forKey: "blob")

Remediation

  1. Replace all UserDefaults writes of sensitive data with Keychain writes. The iOS Security framework’s Keychain services API provides hardware-backed encryption at no additional complexity cost.

    // SAFE: store a credential in the Keychain
    import Security
    
    enum KeychainError: Error {
        case saveFailed(OSStatus)
        case loadFailed(OSStatus)
        case itemNotFound
    }
    
    func saveToKeychain(value: String, key: String) throws {
        guard let data = value.data(using: .utf8) else { return }
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: Bundle.main.bundleIdentifier ?? "app",
            kSecAttrAccount: key,
            kSecValueData: data,
            kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        ]
        // Delete any existing item before adding
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }
    
  2. Use a Keychain wrapper library to reduce boilerplate. Libraries such as KeychainAccess or SwiftKeychainWrapper provide a UserDefaults-like interface backed by the Keychain, making migration straightforward.

    // SAFE: Keychain wrapper with familiar interface
    import KeychainAccess
    
    let keychain = Keychain(service: "com.example.MyApp")
    try keychain.set(authToken, key: "auth_token")
    let token = try keychain.getString("auth_token")
    
  3. Audit all UserDefaults usage. Search for .set( calls on UserDefaults instances and review every key being written. Non-sensitive preferences (theme, locale, feature flags) can remain in UserDefaults; credentials, tokens, and keys must move to the Keychain.

  4. Apply the correct accessibility class. Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly for most credentials. Avoid kSecAttrAccessibleAlways or any class without ThisDeviceOnly unless you explicitly need iCloud Keychain synchronisation and have assessed the implications.

  5. Migrate existing users’ data. On first launch after the update, read any credentials previously stored in UserDefaults, move them to the Keychain, and then delete the UserDefaults entries.

References