Decrypting PDQ credentials
Before falling into the rabbit hole that is SCCM, my gateway into abusing endpoint management software came when I was just getting started in offensive security. I was going through the usual network traffic poisoning when a service account began hammering my testing device and filling ntlmrelayx with admin SOCKS sessions. When I took a look at the sessions I found I’d managed to capture the credentials for a PDQ inventory service account which ended up allowing me to elevate privileges in the environment. If you ask around your shop you’ll probably run into someone else that’s had a similar interaction with PDQ software and abusing it seems to be a common finding.
Just so it’s known, PDQ is aware of the security risks around all the credential distribution but considers them a Microsoft problem. From the article:
PDQ Deploy was notified of the issue in July 2024 and said in a response that the risk is “due to long-established and well-understood vulnerabilities in Microsoft Windows that enable credentials in active memory to be extracted."
But that’s not the point of this post. Really this is the result of boredom on a long flight and since I’ve been trying to get more comfortable with reversing cryptography I figured PDQ might be a good target since endpoint software is always littered with credentials. So, in this post we’ll briefly touch on how the software operates and then do a bit of .NET “reversing” to decrypt some credentials.
What are PDQ Deploy/Inventory?
PDQ deploy and inventory provide similar services to what you’d find in SCCM just at a more affordable price for small and medium sized businesses. Deploy handles tasks like software deployment, operating system patching, and configuration management while inventory is geared more toward asset management. The two complement each other as inventory will keep track of things like missing software or patches on an endpoint that deploy can then get pushed to the client.
Operationally, both services run a background service in the context of a user service account. This account must be a local administrator on the software’s host machine and must be a console user. The key thing is the account does not require admin privileges on any target “managed” systems.
During any remote tasks like a package install or inventory scan, since PDQ doesn’t use an agent, each service uses a privileged service account (“deploy user” and “scan user” respectively) to authenticate via SMB and execute tasks. These accounts are required to have local admin on the target hosts but do not require local admin on the service’s host OS. If this sounds confusing here’s how PDQ displays it:
PDQ does recommend using LAPS as the more secure deployment configuration which we’ll touch on later and also does warn against granting the these accounts domain admin privileges. Seen this myself, not the best idea.
Secure Credential Storage
According to documentation, each of the credentials stored by the application are protected by “three separate AES-encrypted keys” stored in the application’s database, registry, and the app binaries. This is reinforced from the product’s security guide shown below:
To start collecting the keys, I started with the registry first and found the “Secure Key” value at Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Admin Arsenal\PDQ Deploy\Secure Key
. I expected binary data but instead found a GUID which I thought may just be an identifier for the actual key.
Then, from the sqlite database stored at C:\ProgramData\Admin Arsenal\PDQ Deploy\Database.db
, a similarly named column in the DatabaseInfo
table contained another GUID identifier.
For the app key, nearly the entire application is written in C# so “reversing” the binaries should be easy. Searching for the string SecureKey
in dnSpy lands on the DatabaseEncryptionMethod
enum that defines two methods for encryption. The enum is used by a few methods in the SqliteDatabaseManager
class related to encryption but don’t actually contain the app key. What it does show though is how the app recovers the registry and database keys (2, 3) and then concatenates all three keys into one “EncryptionKey” (1).
Finally, searching for the string “EncryptionKey” across all the service dependencies hits in the AdminArsenal.PDQDeploy.dll
to reveal the third and final “key”.
Decryption Flow
So now that we know it’s not 3 separate AES keys, let’s see how the credentials are decrypted with this “key”. The TryDecryptSetting
method in the SqliteDatabaseManager
class gets things started. First, the method determines the encryption version being used for the encrypted password (1) then calls a decryption routine based on that result for type 0 or type 2 (2).
The decryption setting is determined by the GetEncryptionVersion
method based on the header bytes parsed from encrypted ciphertext.
Looking at the encrypted blob in the database, the (encrypted)
header confirms it’s a “type 2” decryption setting.
Next, the TryDecryptSetting
method calls Decrypt
with the ciphertext and assembled key. There’s a bit of redundancy here as the method again checks the encryption version but if it’s a match on our type 2 version, the method strips the header bytes (1) off the ciphertext then calls GetStandardEncryptionStream
to decrypt the remainder (2) with the decryption “key”. The first 4 bytes of the returned value identify the length of the decrypted plaintext string (3) which is extracted from the remaining bytes and returned (4).
GetStandardEncryptionStream
is used by both the Encrypt
and Decrypt
methods. If it is called for decryption, it reads the first byte of the ciphertext to determine the IV length then extracts that length of bytes as the IV. Finally, we learn where the actual AES key comes from. Since we’re on version type 2, the decryption key is derived from the first 16 bytes of the sha256 hash of the combined GUID string. Neat.
Here’s an example of a hex blob pulled from my testing environment to show how it’s structured based on what we just learned.
Example blob: 28656e63727970746564290010644d18eb7817dad6de5f531b1b0b60113087662f3cf0ffdaa7760418c15ee6ea
Header: 28656e637279707465642900 (encrypted) + \x0
IVlen: \x10 (16)
IV: 644d18eb7817dad6de5f531b1b0b6011
Encdata: 3087662f3cf0ffdaa7760418c15ee6ea
Since we found out earlier how to parse the plaintext out of the decrypted data all the pieces are together to decrypt the password. You can grab the python POC here or checkout the BOF @_druid whipped up.
Attacking PDQ
I won’t go into too much detail but I’ll share a few notes I kept track of while I was digging into all of this.
-
Hunting down the PDQ server itself can be a pain. I have seen guidance for setting up SPNs for the service here which could help find it in AD. Otherwise, I’ve seen most of the service accounts and hostnames use “PDQ” in the AD object’s name.
-
If you compromise the host OS you can pull the background service account out of LSA secrets which will give you console access and permissions to decrypt creds from the database.
-
The service does something weird with UAC when you’re logged onto the host interactively as an admin. I’m assuming it’s suppressing it for any user that doesn’t have permissions for the service which is cool. Might look into a bypass here if you’re curious. That’s not a blocker though. The service performs database backs up regularly and maintains 10 backups. If you’re admin and don’t want to dump LSA you can read all the keys remotely and then pull one of the backups down. Pulling the active database will get you a sharing violation. I’m sure there’s ways around it but might try the easy road first.
-
LAPS support via PDQ requires disabling remote UAC on endpoints. The LAPS reader is in the database so do with that what you will.
-
I’m not certain, but across a few installations the app SecureKey has been static. Curious how far back it’s been static and if it changes on versions iterations.
-
There are interesting exclusion recommendations for PDQ related file paths.
Defensive guidance
-
Isolate the PDQ server and restrict inbound access. Other than remote console sessions for admins, there is no reason I can see for there to be inbound access to the host from end points.
-
Audit the local administrators group for PDQ servers and remove any excessive members.