Tamper-Resistant Monotonic Counters for Desktop Apps¶
Date: 2026-04-03 Context: C++, Windows + macOS. Trial period protection via increment-only counter. 4 storage layers; current value = max() of all four.
Architecture: 4-Layer Storage¶
Layer 1: OS credential store (Windows Credential Manager / macOS Keychain)
Layer 2: Registry (Windows) / plist + xattr (macOS) - disguised
Layer 3: Hidden file + NTFS ADS (Windows) / extended attributes (macOS)
Layer 4: Server backend (source of truth)
Current counter = max(layer1, layer2, layer3, layer4)
Loss of any single layer is non-critical. Attacker must defeat all four simultaneously.
Layer 1: OS Credential Store¶
Windows Credential Manager¶
#include <windows.h>
#include <wincred.h>
#pragma comment(lib, "advapi32.lib")
// Disguise as system component
static const wchar_t* CRED_TARGET = L"Microsoft.Windows.Security.TokenBroker.Cache.v2";
bool WriteCounter(uint64_t counter) {
CREDENTIALW cred = {};
cred.Type = CRED_TYPE_GENERIC;
cred.TargetName = const_cast<LPWSTR>(CRED_TARGET);
cred.CredentialBlobSize = sizeof(uint64_t);
cred.CredentialBlob = reinterpret_cast<LPBYTE>(&counter);
cred.Persist = CRED_PERSIST_LOCAL_MACHINE; // survives logoff
cred.UserName = const_cast<LPWSTR>(L"WindowsSecurityService");
return CredWriteW(&cred, 0) == TRUE;
}
bool ReadCounter(uint64_t& counter) {
PCREDENTIALW pCred = nullptr;
if (!CredReadW(CRED_TARGET, CRED_TYPE_GENERIC, 0, &pCred))
return false;
if (pCred->CredentialBlobSize >= sizeof(uint64_t))
memcpy(&counter, pCred->CredentialBlob, sizeof(uint64_t));
CredFree(pCred);
return true;
}
Limits: Max blob 2560 bytes (Win7+). CRED_PERSIST_LOCAL_MACHINE = survives logon session.
Vulnerabilities: - Visible in Control Panel > Credential Manager > Generic Credentials - cmdkey /delete:TargetName removes it - Other apps of same user can read/write (no inter-app isolation) - Stored as encrypted .vcrd files in %LOCALAPPDATA%\Microsoft\Vault\ via DPAPI
Mitigation: Store encrypted value + HMAC so raw blob doesn't reveal it's a counter.
macOS Keychain¶
#import <Security/Security.h>
static NSString* const kServiceName = @"com.apple.security.analytics.cache";
static NSString* const kAccountName = @"device-state-v2";
bool WriteCounterToKeychain(uint64_t counter) {
NSData* data = [NSData dataWithBytes:&counter length:sizeof(counter)];
NSDictionary* query = @{
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService: kServiceName,
(__bridge id)kSecAttrAccount: kAccountName,
};
NSDictionary* update = @{ (__bridge id)kSecValueData: data };
OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query,
(__bridge CFDictionaryRef)update);
if (status == errSecItemNotFound) {
NSMutableDictionary* addQuery = [query mutableCopy];
addQuery[(__bridge id)kSecValueData] = data;
addQuery[(__bridge id)kSecAttrAccessible] =
(__bridge id)kSecAttrAccessibleAfterFirstUnlock;
status = SecItemAdd((__bridge CFDictionaryRef)addQuery, NULL);
}
return status == errSecSuccess;
}
Keychain survival matrix:
| Storage | App Uninstall | "Reset Settings" | OS Reinstall |
|---|---|---|---|
| ~/Library/Preferences/*.plist | Survives (macOS) | Deleted | Deleted |
| Keychain entry | Survives | Survives | Deleted |
| iCloud Keychain sync | Survives | Survives | Survives (!) |
| xattr on ~/Library/ | Survives | Survives | Deleted |
macOS vulnerabilities: Keychain Access.app shows all generic passwords. security find-generic-password -s "ServiceName" / security delete-generic-password work from CLI.
Secure Enclave on Apple Silicon: SE stores only P256 private keys. Workaround: 1. Create SE-bound key with kSecAttrTokenIDSecureEnclave 2. Sign {counter_value, device_id, timestamp} with SE key (ECDSA) 3. Store signed struct in Keychain 4. On read: verify SE signature. Invalid signature = tamper detected 5. Key is device-bound (non-exportable). New Mac = new key, server layer recovers.
Note: SE counter lockboxes (8-bit, 8-bit max attempts) are not accessible via public API - used only for passcode protection.
Layer 2: Disguised Registry / Plist¶
Windows Registry Hidden Locations¶
// HKCU keys (no admin required) disguised as system components
const wchar_t* locations[] = {
L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\SessionInfo\\{random-GUID}",
L"Software\\Microsoft\\CTF\\Assemblies\\{random-GUID}",
L"Software\\Microsoft\\InputMethod\\Settings\\{random-GUID}",
L"Software\\Classes\\CLSID\\{random-GUID}\\InprocServer32",
L"Software\\Wow6432Node\\Microsoft\\Cryptography\\Providers\\{random-GUID}",
};
bool WriteCounterToRegistry(const wchar_t* subkey, const wchar_t* valueName,
uint64_t counter) {
HKEY hKey;
LONG result = RegCreateKeyExW(HKEY_CURRENT_USER, subkey, 0, NULL,
REG_OPTION_NON_VOLATILE, KEY_WRITE,
NULL, &hKey, NULL);
if (result != ERROR_SUCCESS) return false;
uint64_t obfuscated = counter ^ GetMachineFingerprint(); // XOR with HW fp
result = RegSetValueExW(hKey, valueName, 0, REG_BINARY,
reinterpret_cast<const BYTE*>(&obfuscated),
sizeof(obfuscated));
RegCloseKey(hKey);
return result == ERROR_SUCCESS;
}
What registry cleaners delete:
| Category | CCleaner | Revo Uninstaller |
|---|---|---|
| HKCU\Software{AppName} | Yes (post-uninstall) | Yes (aggressive) |
| Orphaned COM CLSID | Partial | Partial |
| Random GUID in system subkeys | No | No |
| HKCU\Software\Microsoft* | No (too risky) | No |
Layer 3: Hidden Files¶
Windows: NTFS Alternate Data Streams (ADS)¶
Strongest hidden channel on Windows - invisible to Explorer and dir command.
bool WriteCounterADS(const wchar_t* hostFile, uint64_t counter) {
// hostFile:streamName syntax
std::wstring adsPath = std::wstring(hostFile) + L":SysCache.dat";
HANDLE hFile = CreateFileW(adsPath.c_str(), GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) return false;
DWORD written;
uint64_t encrypted = EncryptCounter(counter);
WriteFile(hFile, &encrypted, sizeof(encrypted), &written, NULL);
CloseHandle(hFile);
return written == sizeof(encrypted);
}
bool ReadCounterADS(const wchar_t* hostFile, uint64_t& counter) {
std::wstring adsPath = std::wstring(hostFile) + L":SysCache.dat";
HANDLE hFile = CreateFileW(adsPath.c_str(), GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) return false;
uint64_t encrypted; DWORD read;
ReadFile(hFile, &encrypted, sizeof(encrypted), &read, NULL);
CloseHandle(hFile);
if (read == sizeof(encrypted)) { counter = DecryptCounter(encrypted); return true; }
return false;
}
ADS properties: - Invisible in Explorer (Win10+: dir /r shows them) - No file size change visible - Deleted on FAT32/exFAT copy - NTFS only - Antivirus CAN scan (MITRE ATT&CK T1564.004) - Process Monitor sees ADS access
Best host file: %APPDATA%\Microsoft\ or %LOCALAPPDATA%\Microsoft\Windows\Caches\
Filesystem timestamps as covert storage:
// Encode counter in file modification timestamp
FILETIME ft;
uint64_t encoded = EncodeCounterAsTimestamp(counter); // 16-bit days fits fine
ft.dwLowDateTime = (DWORD)encoded;
ft.dwHighDateTime = (DWORD)(encoded >> 32);
SetFileTime(hFile, NULL, NULL, &ft);
macOS: Extended Attributes (xattr)¶
#include <sys/xattr.h>
bool WriteCounterXattr(const char* filePath, uint64_t counter) {
const char* attrName = "com.apple.metadata.kMDItemFinderComment";
uint64_t encrypted = EncryptCounter(counter);
return setxattr(filePath, attrName, &encrypted, sizeof(encrypted), 0, 0) == 0;
}
bool ReadCounterXattr(const char* filePath, uint64_t& counter) {
uint64_t encrypted;
ssize_t size = getxattr(filePath, "com.apple.metadata.kMDItemFinderComment",
&encrypted, sizeof(encrypted), 0, 0);
if (size == sizeof(encrypted)) {
counter = DecryptCounter(encrypted);
return true;
}
return false;
}
xattr survives: app uninstall, Time Machine restore. Attach to ~/Library/ directory or system-created files that won't be deleted.
Layer 4: TPM 2.0 NV Counters¶
Hardware-guaranteed monotonic counter. Cannot decrement even with full OS access.
| Property | Value |
|---|---|
| Counter size | 64-bit |
| Operations | Define, Increment, Read (no Decrement/Write) |
| Min NV memory | 3834 bytes, >=68 indexes |
| Lifetime writes | ~100KB total (!!) |
| Rate limiting | TPM_RC_NV_RATE on high-frequency writes |
Lifetime budget math: 100KB / 8 bytes = ~12,500 writes. At 1 write/day = ~34 years. At 1 write/app launch with frequent users = may exhaust in a year.
#include <tss2/tss2_esys.h>
ESYS_CONTEXT *esysContext;
Esys_Initialize(&esysContext, NULL, NULL);
// Define NV counter space
TPM2B_NV_PUBLIC publicInfo = {
.nvPublic = {
.nvIndex = 0x01500000, // user NV range
.nameAlg = TPM2_ALG_SHA256,
.attributes = (
TPMA_NV_COUNTER | // increment-only
TPMA_NV_AUTHWRITE |
TPMA_NV_AUTHREAD |
TPMA_NV_NO_DA // not DA lockout
),
.dataSize = 8, // 64-bit
}
};
Esys_NV_DefineSpace(esysContext, ESYS_TR_RH_OWNER,
ESYS_TR_PASSWORD, ESYS_TR_NONE, ESYS_TR_NONE,
&auth, &publicInfo, &nvHandle);
// Increment
Esys_NV_Increment(esysContext, nvHandle, nvHandle,
ESYS_TR_PASSWORD, ESYS_TR_NONE, ESYS_TR_NONE);
// Read
TPM2B_MAX_NV_BUFFER *data;
Esys_NV_Read(esysContext, nvHandle, nvHandle,
ESYS_TR_PASSWORD, ESYS_TR_NONE, ESYS_TR_NONE,
8, 0, &data);
TPM verdict: - Survives OS reinstall and disk format - Slow (~100ms per increment) - No TPM on macOS - User with admin rights can clear TPM via Settings > Security > TPM > Clear TPM - TBS service (tbs) sometimes missing on Win11 despite TPM 2.0 hardware. Check: sc query tbs
Clock Rollback Detection¶
Method 1: Last Known Time (Primary)¶
bool DetectClockRollback() {
uint64_t lastKnownTime = ReadLastKnownTime(); // from all 4 layers, take max
uint64_t currentTime = GetCurrentUnixTime();
if (currentTime < lastKnownTime) return true; // rollback detected
WriteLastKnownTime(currentTime);
return false;
}
Method 2: Sentinel File mtime¶
bool DetectRollbackViaFileTimestamps() {
struct stat st;
stat(SENTINEL_PATH, &st);
time_t fileModTime = st.st_mtime;
time_t currentTime = time(NULL);
if (fileModTime > currentTime + 3600) return true; // 1h tolerance
return false;
}
Method 3: TLS Certificate Timestamps¶
HTTP Date header from any HTTPS request (Google/Cloudflare CDN) provides authenticated time reference. Hard to fake without controlling the remote server.
Method 4: Compile-Time Anchor¶
constexpr time_t BUILD_TIME = __TIME_UNIX__; // GCC/Clang
bool DetectRollbackVsBuildTime() {
return time(NULL) < BUILD_TIME;
}
Method 5: Multiple Source Comparison¶
struct TimeSource {
time_t system_time;
time_t file_mtime; // sentinel file
time_t last_known; // from storage
time_t build_time; // __TIME__
time_t ntp_time; // if available
time_t tls_time; // from HTTPS header
};
bool IsTimeConsistent(TimeSource& ts) {
time_t max_known = std::max({ts.last_known, ts.build_time});
return ts.system_time >= max_known - 86400; // 1-day tolerance
}
Google Roughtime: authenticated time protocol with Ed25519 signatures. MITM impossible. ~1s accuracy - sufficient.
Survival Matrix¶
| Storage | App Uninstall | Cleaner (CCleaner) | OS Reinstall | VM Snapshot Rollback |
|---|---|---|---|---|
| Cred Manager (Win) | Survives | Survives | Deleted | Rolled back |
| Keychain (macOS) | Survives | Survives | Deleted | Rolled back |
| iCloud Keychain | Survives | Survives | Survives | Survives |
| Registry (disguised GUID) | Survives | Survives | Deleted | Rolled back |
| NTFS ADS | Survives | Survives | Deleted | Rolled back |
| xattr (macOS) | Survives | Survives | Deleted | Rolled back |
| TPM NV | Survives | Survives | Survives | Survives |
| Server backend | Survives | Survives | Survives | Survives |
VM snapshot rollback defeats all local layers. Server backend + TPM are only real defense.
Gotchas¶
- TPM NV write lifetime is ~12,500 operations. Never increment on every app launch. Use lazy increment: write only when crossing day/session boundaries.
- CRED_PERSIST_LOCAL_MACHINE vs CRED_PERSIST_SESSION - session-scoped credentials disappear at logoff. Always use LOCAL_MACHINE for persistence.
- ADS are stripped on FAT32/exFAT copy. Don't rely on ADS for the primary storage; use as one of 4 layers.
- xattr behavior varies by copy tool.
cppreserves; many zip tools and cloud sync don't. Don't rely solely on xattr. - macOS sandbox (App Store apps): Keychain access is app-sandboxed; on uninstall the container and its Keychain entries ARE deleted. For non-App-Store apps, Keychain survives uninstall.
- TBS service missing on Win11 despite TPM 2.0 present. Always check
Tbsi_Context_Createreturn code and handle gracefully. - Registry GUID key format: store counter as REG_BINARY XOR'd with machine fingerprint so
regeditbrowser shows unrecognizable bytes, not a readable number. - Server counter is not always available offline. Local layers must hold sufficient state for offline periods. Accept server value as authoritative only when connectivity is confirmed.