squirrelscan
GitHub

Fake authentication overlay: full-screen login iframes

A fixed, full-viewport, high z-index iframe with an auth identifier is a credential-harvesting overlay. squirrel measures the geometry and the intent.

What a fake authentication overlay is

A credential-harvesting overlay is an iframe injected into a real page, positioned to cover the whole viewport, that renders a login form the attacker controls. The address bar still shows your domain, so nothing about the page warns the visitor. The injected #google-auth iframe is the pattern this rule was written against.

The geometry is what gives it away: position: fixed, a very high z-index, width and height at 100% of the viewport, and pinned to the top left corner. Legitimate full-page app shells share that geometry, which is why geometry alone is not enough to call it an attack.

What squirrel checks

The rule runs on every crawled page and emits at most one check named fake-auth-overlay. It examines every <iframe> that could be an overlay (its id, class or source suggests sign-in, or it loads another host on a page with sign-in copy), with its geometry taken from its inline style, from rules in the page’s <style> blocks whose selector names the iframe by its tag, .class, #id or attributes (iframe[id], [class~=c], *[src*=x]; a descendant, child or sibling combinator is read for its last compound only, the ancestors are not checked), including rules inside the @media, @supports, @layer, @container and @scope blocks that can apply to a screen and rules nested in a style rule (main { .c { ... } }, &), and from !important declarations (which win over either). The cascade is !important, then specificity, then source order, so an id rule beats a class rule written later; inset: 0 (or top, right, bottom and left all zero) counts as pinned to the corner and, when the iframe has no width or height of its own, as filling the viewport; with a size of its own it does not. A @media block is read unless every query in its list is print-only, speech-only or not all; any other condition (a width, @supports not (...), @container) may hold on some viewport or browser, so the block is read, as rules of unknown applicability (see the limits below). Other at-rules (@keyframes, @font-face, @page, @import) are not. The stylesheet is scanned once, up to 4 MB of CSS per page, and only rules that can match an iframe on the page are kept, so padding the head of a stylesheet or placing benign iframes first does not hide the overlay; when the CSS is longer than that and nothing else fired, the rule emits an info fake-auth-overlay check with details.cssTruncated, and a finding carries the same detail. Only rules that set a geometry property (position, inset and its longhands, top, right, bottom, left, width, height, z-index) are kept. Per iframe, at most the last 200 of the kept rules that can match its tag, id or classes are applied, in stylesheet order, and the page as a whole has 1,000,000 rule visits to spend on iframes (a long class attribute is charged by length). When any of those bounds cuts something, the check carries details.cssTruncated, so a clean result on such a page is not read as “nothing there”; a selector list is split at its commas and each part is read up to 2,048 characters (a long list of short parts is read whole, up to a prelude of 256 KB), and a body is read up to 256 KB. A part, a prelude or a body past those that could match an iframe on the page (an id or class the page has, or a tag, or any part when the page has an iframe) is not read and makes the CSS cssTruncated; a rule past them for anything else is skipped without a signal, since it cannot match. The reported source is an origin and path only (no credentials, query or fragment). The CSS is read three ways and the frame is a cover when any of them says so: as a browser tokenises it (a ;, /* or } inside a string, an unquoted url() or a block does not split, open a comment or close a rule; url( is recognised with escapes and in any case; only a block’s own closer closes it; a CR, LF or form feed ends a string), with complete comments dropped and a split on every ; (an unterminated comment or string is read on), and as a plain split on ;. A parser that differs from a browser (or from this one) on a quote, comment, bracket, escape or newline cannot by itself hide a declaration from all three, and what the plain split caught before is still caught (a declaration written inside a comment is a finding too). The readings share limits, listed below. It requires all four geometry conditions: position is fixed or absolute; z-index is at least 1000; width and height each fill their axis: a number of 100 or more in % (a unit followed by other text is still the unit: 100%x), a viewport unit of the axis (vw/vh and their d, s and l forms, vi/vb), a container unit (cqw, cqh, cqi, cqb) or vmin/vmax, in any spelling (100.0%, +100vw, 1e2vw, 150%, 10000vw; CSS escapes and comments inside the value are undone first)), or stretch, fill-available and their prefixed forms. Inside calc(), min(), max() or clamp() the numbers of the axis are added, so calc(50vw + 50vw) fills. A product or quotient of an axis length (calc(100vw * 20)) counts as filling, and a value padded past 2,048 characters counts as filling too. Not it: 10vw, 100px, 0.100vw, 50%, -100vw. Known limits, all of which over-read toward a cover or read as not one: a fixed length inside calc() is not subtracted (calc(50vw + 50vw - 400px) fills), a product fills (calc(1vw * 40)), 100cqw by 100cqh and 100vmin by 100vmin fill, a length held in a custom property (var(--w)) is not resolved, a rule whose applicability cannot be decided here is of unknown applicability: one in a @media that is not all or screen, in @supports, @container, @scope or @layer (a layered rule would lose to an unlayered one in a browser, which is not modelled), one with an ancestor part (main .c, html.x iframe) or with a pseudo-class (:hover, :not(), :is(), :where()). The cascade is worked out twice, once with every rule applied as if it matched and once with only the rules whose applicability is known, and the iframe is a cover when either says so: a rule that cannot be evaluated can enable a cover (a lone signal, so a benign frame under .modal-open .frame{position:fixed...} is reported at info) but cannot disable one, so a decoy rule that a browser would never apply does not hide a cover written in decided rules (the protection covers decided covers only; a cover that is itself in an undecided rule can still be hidden by a later decoy), and there a class, an id and an attribute value are compared case-sensitively, as a browser does in standards mode (a page with no doctype is in quirks mode, where a class and an id compare without case, as a browser does; a legacy doctype that also triggers quirks mode is read as standards mode; an attribute HTML reads without case, such as type, lang or dir, and a selector with the i flag are compared without it, and the fold is ASCII only, so the Kelvin sign U+212A is not k; the s flag is not read, and a selector using it is treated as doubtful, below). A namespace separator on a type or an attribute does not hide a rule: *|iframe.cover, *|*.cover, *|iframe[id=x], [*|id=x] and [|id=x] are read as iframe.cover, *.cover, iframe[id=x] and [id=x]. A selector list with a certain syntax error is dropped whole, for a decoy rule and for a cover rule, as every browser does: a stray character (!!), an empty part (.a,), a doubled combinator, a bad identifier (.1a), an attribute selector with a bad operator, flag or value ([x=y q], [x=1]), an invalid argument of :not() or :has(), an invalid argument of :nth-child(), :nth-last-child(), :nth-of-type(), :nth-last-of-type(), :lang() or :dir() (:nth-child(bogus), :nth-child(), :lang(), :lang(en fr), :lang(1); only CSS white space is trimmed around an argument, not NBSP), or anything after a pseudo-element (::before.x, ::before:hover, .x::before .y, ::selection::before, since a highlight pseudo-element takes no further one), a ::slotted() argument that is not one compound selector (::slotted(a b), ::slotted(a, b): a list there is a syntax error, as Chrome reads it; Safari and Firefox were not checked) or a :host() or :host-context() argument that is not one compound selector (:host(::before), :host(a b); a list in :host() or :host-context() is read as undecided, not as an error; :is() and :where() after one are not an error), at the top level, in @media and nested (a rule nested in a dropped rule is dropped with it). A part that is valid in some engine or after an @namespace is not a certain error, so the rule is kept and read as of unknown applicability: an unknown or engine-prefixed pseudo-class or pseudo-element (:bogus, :-moz-any-link, ::-moz-selection, :-ms-input-placeholder), including the media-state names Chrome does not support (:playing, :muted, :seeking, :buffering, :stalled, :paused, :volume-locked, :current(.a)), an identifier :dir() does not know (:dir(foo), which Chrome accepts), a :lang() with a string, a wildcard or a comma list (:lang(en, fr), :lang("en"), :lang(*-Latn)), an of keyword of :nth-child() written in capitals, any :host-context() (Firefox and Safari do not support it and drop the rule, so a selector list containing one is undecided even with a valid argument; an invalid argument is still an error), a pseudo-class after ::part(), ::slotted(), ::selection, ::highlight() or ::view-transition-* that Chrome is not known to accept, a named namespace (h|x, h|iframe.cover), a type with an empty namespace (|iframe.cover: Chrome accepts it but does not apply it to an HTML element), the s flag, an unterminated bracket or string. Such a rule can enable a cover (a lone signal, found in the full reading) but never disables one in the decided reading, so appending a part this check does not know cannot hide a cover rule or let a decoy hide one. :is() and :where() forgive an invalid argument, as a browser does, so they are not checked. Pseudo-class names are matched against a list (Chrome’s, with :target-current, ::search-text and the scrollbar pseudo-classes), and a name newer than the list is read as undecided, not dropped; the full reading compares them without case; a pseudo-class counts as a class in the specificity (:where() counts nothing); a media query list or an at-rule prelude past 2,048 characters is read as undecided and its block is still read; nesting is read eight levels deep, at most 64 parts of a selector list are combined with &, no produced selector is longer than 2,048 characters, and a sheet reads at most 50,000 nested rules and produces at most 20,000 selectors for them (a child without & is read for its own last compound whatever its parent is; & is the parent’s last compound, so nothing multiplies with depth); whatever one of those drops, and a rule whose body is past the 256 KB bound and holds nested rules, makes the CSS cssTruncated rather than being skipped in silence, nested conditional group rules written with bare declarations are not read, and these are not read at all: external <link> stylesheets (the CDO and CDC tokens <!-- and --> around a style are not handled: the text up to the first { is read as the first selector, whose last compound is the iframe’s, so a rule written right after <!-- is read), a near-full size (90vw, 95%), min-width and min-height, huge px or rem sizes, and transform: scale() or zoom. inset-block, inset-inline and the inset-*-start/inset-*-end longhands count as the four sides; and top or left is 0 or 0px.

A frame with that geometry fires only when it also carries auth intent. That means either its id or class matches auth, login, signin, sign-in, oauth, verify or credential, or its src matches the same list without credential, or it loads from a host that is not the site’s own while the page separately shows auth copy or a password field. The auth copy is read with a space at every element boundary as well as without, so whitespace between elements cannot decide whether it is seen.

  • Info when this is the only integrity signal on the page. Message: Suspicious authentication overlay (single signal — review).
  • Fail when at least two distinct integrity signals fire on the page. Message: Likely credential-harvesting overlay (3 corroborating integrity signals).

The reported value names the z-index, the frame id and the source URL. The “Sign in with brand” link pattern is deliberately left to integrity/brand-impersonation, so one off-brand sign-in link cannot count as two signals and satisfy the escalation gate on its own.

How to fix it

<!-- remove the injected overlay -->
<iframe id="google-auth" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999"></iframe>

Find the template or plugin that emits the markup and delete it, then look for the loader that injected it. Treat the site as compromised: audit recently modified files, rotate credentials and read the access logs.

A cookie banner or a support widget in a fixed iframe does not match, because it does not fill the viewport. A legitimate full-page portal iframe on your own origin matches only if an auth word appears in its id, its class or its src, which a path like /login will do.

Rule IDintegrity/fake-auth-overlay
CategorySite Integrity
ScopePage
Severitywarning
Weight8/10

Enable / disable

Disable this rule

[rules]
disable = ["integrity/fake-auth-overlay"]

Disable all Site Integrity rules

[rules]
disable = ["integrity/*"]

Enable only this rule

[rules]
enable = ["integrity/fake-auth-overlay"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Site Integrity findings ship in every audit next to the SEO, performance and agent experience rules. See Website security scan with AI for how an agent works through a report.

Glossary: Hacked site signals.

References

Check your site

Run squirrel audit https://example.com and open the Site Integrity section of the report. Each overlay is listed by page with its z-index, id and source. Signed-out local audits need no account and cost nothing. Signed-in audits spend credits.

Type to search…

↑↓ navigate↵ openesc close