The Blob URL Phishing Gap That Most Browser Extensions Can't See
The Blob URL Phishing Gap That Most Browser Extensions Can't See
A victim clicks a link in an email. Their browser opens an HTTPS subdomain on *.web.app, sits on a loading spinner for a half second, and then the address bar changes to something like blob:https://oakstreamvault-files-20260506-...web.app/cbc5ded5-fe9d-4ff5-b082-248b4f14f23d. The page that renders is a pixel-perfect clone of login.live.com, drawn from Microsoft's own CDN. The victim's password manager never prompts. The anti-phishing extension shows no warning. The proxy log records two HTTPS connections and nothing that looks like a credential form.
This is a working sample we captured in May 2026. The underlying technique, top-level navigation to a client-constructed blob: URL, is not entirely new. SANS ISC documented a single-page variant in March 2025. Cofense wrote up a Microsoft 365 campaign abusing it shortly after, and ANY.RUN's recent BlobPhish analysis covered an OneDrive-staged variant. ANY.RUN attributes the kit family to a phishing-as-a-service operation they track as Salty 2FA, linked to Storm-1575, in active use since June 2025 and engineered to bypass push, SMS, voice, and authenticator-app MFA. What has not been put on the record is that the kits have hardened, moved to free hosting providers like Firebase and Cloudflare Pages, and, most importantly, that the resulting blob: document is architecturally invisible to nearly every browser security extension on the market.
That last point is what we want to put on the record here.
How the kit assembles the page
The kits we are tracking all follow the same shape. The launcher subdomain looks like <project-name>-files-<YYYYMMDDHHmmss>-<22-char-nonce>.web.app or the equivalent on pages.dev. The HTML returned by the launcher is deliberately empty: a <script> tag, a spinner, no form, no brand assets, nothing for a content scanner to fingerprint.

The script does roughly this:
// Reconstitute the phishing HTML inside the browser process.
// Base64 string literal in some samples, sibling fetch in others,
// client-side decrypted blob in a few.
const phishingHtml = decodePayload(...);
// Wrap as text/html, mint a one-shot URL, top-level navigate.
const blob = new Blob([phishingHtml], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
window.location.replace(blobUrl);
The phishing HTML never traverses the network as an HTTP response. It is reconstructed inside the browser process from a JavaScript string, wrapped in a Blob, handed a one-shot URL by URL.createObjectURL(), and loaded by a top-level navigation. location.replace strips the launcher from history so the victim cannot back out to inspect what loaded them.
The page that renders is a multi-step Microsoft consumer sign-in clone. All steps live in the same DOM as pre-rendered <section> elements (section_uname, section_password, section_confirmemail, section_otp) toggled with display. Brand assets come from aadcdn.msauth.net, the real Microsoft CDN, so the page is visually indistinguishable from the genuine article. Decoy text and motivational quotes are interleaved between form inputs to defeat string-based fingerprinting. Every input field carries autocomplete="off" so password managers do not pop, which incidentally removes the strongest behavioral signal an end user would otherwise have that the page is a fake. Credentials are exfiltrated by a single fetch() to a separate throwaway subdomain, a Cloudflare Worker, or a Discord or Telegram webhook embedded in the kit's JavaScript.

Why the network stack does not see it
This is a refinement of HTML smuggling, MITRE T1027.006, applied to phishing-page delivery rather than malware delivery. The consequences are familiar but worth being explicit about:
- Secure Web Gateways see the launcher HTML and nothing else. The credential form is constructed after SSL decryption is complete, inside the renderer, from a JavaScript variable. There is no HTTP response containing the form for a gateway to hash, signature, or block.
- Email sandbox detonation (Defender for Office 365 Safe Links, Proofpoint URL Defense, Mimecast URL Protect) hits the launcher and captures the bootstrap. Whether the sandbox follows the secondary
blob:navigation depends on its JS execution policy and timeout, and many will report on the bootstrap page as the final state of the URL. - Threat intel feeds (URLScan, PhishTank, OpenPhish) republish what they re-fetch. They re-fetch the launcher, not the blob, so the phishing HTML never enters the public corpus.
- URL reputation (Safe Browsing, SmartScreen, Netcraft) has no per-URL match to make.
blob:URLs are scoped to the browser instance and generated fresh per call. Reputation can only fire on the parent origin, and the per-instance timestamp and nonce naming convention is engineered to keep parent origins one rotation ahead of any blocklist.
By the time something is recognizable as a phishing event, the credential is already in transit to the operator.
The structural blindspot for browser extensions
This is where the kit gets interesting. Almost every browser security extension we have looked at, password managers, anti-phishing toolbars, browser-integrated EDR, enterprise credential monitors, declares its content scripts with the match pattern <all_urls>. The match pattern spec is unambiguous about what that string covers:
http://*/*
https://*/*
file://*/*
ftp://*/*
urn:*
It does not include blob:. It does not include data:, about:blank, about:srcdoc, filesystem:, or javascript:. When the kit performs its location.replace(blobUrl), the resulting document is on the blob: scheme, and the extension's content script is simply not injected. There is no DOMContentLoaded for the form-scanner to bind to. The MutationObserver never observes. The submit listener never registers. The page is, from the extension's perspective, an empty tab.
For password managers this has a perverse secondary effect. The form never receives the autofill prompt the user normally sees on their bank or webmail. To an alert user, that absence is a tell. To most users it just looks like a slightly off day.
Chrome shipped a remedy in MV3 version 119 (October 2023): a manifest option match_origin_as_fallback: true that allows a content script to be injected into opaque-origin frames whose initiating origin matches the script's patterns. On paper, every vendor could enable it tomorrow. In practice almost none have, for reasons that are not bad reasons:
match_origin_as_fallback: trueis incompatible with scheme-wildcard patterns. A manifest has to be rewritten to explicit-scheme patterns (["http://*/*", "https://*/*", "file:///*"]) before the flag can be set. Mature CI/CD pipelines make even a manifest rewrite a quarter-long undertaking.- It changes the script's threat model. Running inside
blob:,data:, andabout:blankframes means inheriting a much larger execution surface, including ad iframes, sandboxed iframes, anddocument.write()'d content. - It is not a single switch. Top-level blob navigations versus blob iframes, MAIN-world versus ISOLATED-world execution, Worker contexts, each requires a separate coverage decision.
- Telemetry pipelines downstream of the content script frequently assume a parseable HTTP(S) document URL. Code that does
new URL(document.location.href).hostnameon ablob:URL returns an empty string. We have seen detection products in the wild emit emptyhostfields, or fail outright, when fed ablob:document.
The combined effect is that a meaningful slice of the browser security market cannot see this technique today, and will not be able to without a multi-quarter platform investment. The kit operators have, deliberately or not, exploited a structural assumption that content scripts run on document load. That assumption was always slightly wrong. There is now a phishing kit family that depends on it being wrong.
Browser-native protections such as Chrome Safe Browsing and Edge SmartScreen run at the browser-process level rather than as content scripts, so they are not architecturally blocked. They are, however, dependent on URL reputation, and the parent-origin rotation we are seeing keeps them a step behind.
What we are seeing
The samples we have captured in 2026 cluster on two free hosting providers: Firebase Hosting (*.web.app) and Cloudflare Pages (*.pages.dev). Both give anonymous, HTTPS-by-default subdomains in seconds. The naming pattern, <lure>-files-<timestamp>-<nonce>.<tld>, is consistent across operators and reads, to a non-technical victim, like a SharePoint share name. Rotation cadence is 24 to 72 hours, roughly the half-life of an abuse report at either provider. The structure of the captured pages, the pre-rendered username, password, recovery email, and OTP sections, and the MFA-relay behavior on submission, is consistent with the Salty 2FA fingerprint ANY.RUN published.
Public coverage of the technique exists, in the form of the Cofense, SANS, and ANY.RUN write-ups linked earlier in this post, but it is light, and it has focused on the network-side bypass rather than the extension-side blindspot. The handful of vendors who have shipped match_origin_as_fallback support have not been loud about it, and the rest of the market has not, as far as we can tell, started the work.
Closing
If your defense-in-depth assumes that the browser extension layer is going to catch what the gateway misses, this is the class of kit that breaks that assumption. The technique is not difficult to deploy, the hosting is free, and the credential UI is indistinguishable from the real thing. The only durable signal is on the document the victim actually sees, which is exactly the document the conventional content-script architecture cannot reach.
Surface detects this. We are deliberately not spelling out how, because the gap is the story, and the next iteration of these kits will read everything we publish. If you want to evaluate your stack against live samples, get in touch.