All blog posts
Security Research

A ClickFix loader rewritten to get past a rival's page cleaner

Two malware kits were fighting for the same screen on the same hacked WordPress pages. One of them wrote the whole war into its code, in Russian, down to the moment the browser broke.

ADAMnetworks•
Clickfix vs clickfix hero image

A ClickFix loader we captured on hacked WordPress sites on September 29 carries Russian comments describing how a rival script kept deleting its fake verification screen before visitors could read it, and how the author rewrote the loader to get past it.

The culprit was another script on the same page, a cleaner that removed anything resembling a full-screen overlay, and the author named it by its settings object, CFG_CONFIG. It came from a rival ClickFix kit, and both kits were after the same visitors. A ClickFix lure is a fake check, usually dressed as a CAPTCHA, that tells the visitor to open PowerShell or Terminal, paste a command and press Enter, after which the command fetches and runs the next stage. Only one lure can hold the screen, and that is the one whose command gets pasted.

Pages often carry more than one kit. Earlier research on derp.ca covering 1,509 compromised WordPress sites found one page carrying four separate ClickFix script blocks. This author studied the rival’s rules, found a way to pass as one of its own, and then wrote a guard to clear rival lures off the page. The author’s comments say the first version of that guard set off “a war of observers”.

Russian comments naming CFG_CONFIG and describing the cancelled iframe requests and the class-change workaround

*Figure 1. Comments in the loader, collected September 29, 2026. Comments in every figure are translated from Russian beneath it.*

PRE-ARM: the positioning class (.wp-security-root - fixed, z, size) is NOT applied yet.

On rigquip, someone else’s “cleaner” (CFG_CONFIG) checks EVERY added DOM node immediately (MutationObserver, microtask), and body.children every 150 ms (a sweep): “fixed + z >= 9990 + >= 90% of the screen” = “foreign overlay” -> display:none !important + removeChild.

The root, when added with its positioning class, was killed before the iframe finished navigating (in Network: canceled at 3 ms / 74 ms).

Therefore: a neutral class (display:none), a neutral wrapper (not a direct child of body - the sweep does not see it), and the class is applied in a macrotask AFTER their microtask. Their observer watches only childList; it is blind to class changes.

Trusted names outrank size in the cleaner’s rules

The cleaner’s first check is whether an element carries a trusted name. It joins the element’s ID and class names into one string and matches that string against a short list. In a typical configuration the list holds uS1j3xMa60p9uwhK, the label on the cleaner’s own loading spinner, plus any name containing cfm, and a separate skip pattern for media-modal, elementor and adminbar. Anything matching is left alone regardless of its size.

Elements without a trusted name are measured instead. If the element is fixed to the browser window, carries a z-index of 9990 or more, and covers at least 90 percent of the window’s width and height, the cleaner removes its mouse input, hides it with display:none !important, and deletes it from the page. The z-index decides which of two overlapping elements is drawn on top. The panel’s text is never examined.

These checks run from three places. A MutationObserver, the browser feature that notifies a script when the page changes, inspects each element as it is added. A timer re-scans the top level of the page body every 150 milliseconds until five seconds after load. A click handler inspects whatever the visitor clicks, along with every element enclosing it. After the timer expires, the observer and the click handler keep running for as long as the page is open.

The loader originally inserted its panel with the full-screen styling already applied, and the cleaner removed it before the embedded page had finished loading.

Cleanup configuration, trusted-name test and full-screen panel size test

*Figure 2. Cleanup code collected October 2, 2026, with whitespace changed for readability. An archived page in the Moncha repository preserves an exact copy.*

The loader borrowed two names and hid its panel one level down

The loader’s author dealt with the name check first. The panel’s class list now includes og-cfm, which contains cfm, and media-modal, which is on the skip pattern, so the panel is exempt both in configurations that trust cfm and in the ones that do not.

The size check took more work, because the cleaner looks in three places and each one sees a different moment of the panel’s arrival. The loader builds the panel, wp-security-root, inside a plain container, wp-security-shell, that has no positioning and no z-index. At that point the panel carries a neutral class and none of the full-screen styling. When the container is inserted, the 150 millisecond sweep and the observer both see only the plain shell, because neither looks inside an added element, and the panel one level down goes unchecked. The full-screen classes are then applied on a zero-delay timer, after the observer has finished. A class change neither adds nor removes an element, so the observer stays quiet. The author’s note on this reads: “Their observer watches only childList; it is blind to class changes.” From then on, the borrowed names cover the click handler as well. The Figure 1 comment describes the wrapper as the part the sweep misses; the code shows the sweep missing the panel inside it.

The loader does not copy the spinner label because, as its comment says, that label is regenerated and “would give away the overlap.”

Loader comments and code showing the plain wrapper, deferred positioning and borrowed class names

*Figure 3. Loader code collected September 29, 2026, showing the plain shell, the delayed class change and the borrowed names.*

og-cfm: a safeguard against other people’s “overlay killers” (CFG_CONFIG and similar): they treat any element whose id/class contains “cfm” as “their own.” They will leave our root with this class alone.

Neutral wrapper: the other script’s sweep iterates over body.children - the wrapper (position:static, no z) is not an overlay as far as it is concerned, and the sweep does not see the root inside it at all.

Macrotask: by this point, MutationObserver (their CFG_CONFIG) has already processed the “addition” of the neutral nodes (microtask) and found nothing foreign. Changing the class does not create a childList mutation.

We clear pointer-events on the iframe only in arm() - until the signal is confirmed, clicks must pass through to the site (see the comment above).

media-modal: a class from the other “cleaner’s” skipRe (CFG_CONFIG.skip = “media-modal|elementor|adminbar”, static).

isTrusted() checks id+class for a match against skipRe - our root becomes “one of theirs”: the sweep, MutationObserver and click-capture handler no longer touch it.

We do not add the random trusted token (uS1j3xMa60p9uwhK) - it is regenerated and would give away the overlap.

Nine copies of the cleaner leave their own CAPTCHA exposed

The second borrowed name turned out to matter because the cleaner’s exemptions vary between copies. Our scanners recorded it in 482 visits to 472 hostnames between September 7 and October 3. In 473 of those visits it trusts cfm, but nine keep only the spinner label and the skip pattern. In those nine, the cleaner’s own lure meets every removal condition once it appears, because the cfm panel is fixed, fills the window and sits at a z-index of 2147483647. The September 11 chain uses one of these configurations, and both variants were still live on October 3.

The cleaner is tied to three Polygon contracts, created on September 3, September 14 and September 21, and each serves its own group of hostnames (333, 118 and 21) over overlapping dates. The removal rules are identical across the three groups. Two retained verification scripts of 69,051 bytes each differ only in two copies of the spinner label.

The cleaner guards a fake CAPTCHA loaded through Polygon

The cleaner ships alongside a loader with a lure of its own, one that asks a smart contract on the Polygon blockchain for a list of delivery servers, decodes the reply, and fetches /js/all.min.js?m=1 from one of them. The addresses come from the blockchain, but the script itself comes from an ordinary web server. The same spinner label, uS1j3xMa60p9uwhK, appears in the cleaner’s settings and in this loader. The earliest chain linking the two in our collection is dated September 10.

Field Effect described a Polygon-backed loader fetching /js/all.min.js on August 24, and Unit 42’s July 2 JokerStat research and technical figures show the same getServers lookup, so the delivery side of this kit is already on record.

Once downloaded, the script waits for a click, then sends browser, device and page details to /first_allow_host, an endpoint ANY.RUN’s September 7 ruleset changelog flags as TerminalFix-related. The server replies with an ID, a command, and a decision on whether to show the lure. An approved Windows visitor gets a full-screen panel with the ID cfm, and the page title changes to “Just a moment…”. Clicking the checkbox copies the command, and the instructions tell the visitor to open PowerShell or Terminal, paste and press Enter. Every five seconds after that, the script asks /check_allow_host whether to keep the lure up, and a negative answer hides the panel and reloads the page. Gelato’s September 28 analysis documents this controller and the decoder for its delivery addresses.

A Windows and macOS lure sits behind the loader

We found the adapting loader in eight page visits on September 29 and

  1. The page it embeds is a second ClickFix lure with one command for Windows and one for macOS. Clicking its checkbox copies a command, and a copy handler substitutes that command whenever the visitor copies anything else on the page.

The Windows command fetches and runs remote PowerShell. The macOS command, once its nested encodings are stripped, installs a LaunchAgent configured to start on load and keep running, then reads a Polygon contract for an address and pulls more AppleScript from it. That contract, 0xA3a603F8a454a9c905b4c579Bb72628F7C15C2A0, is the one Have I Been Squatted documented on July 6, and UnderDefense and Bolster have since traced the same macOS chain arriving through other delivery routes.

The embedded page signals the loader with __overlayGate ready and alive messages that each carry a token. This branch has its own command to keep on screen and runs separately from the Polygon-loaded cfm controller.

The guard hides rival panels by their shape

Getting past the cleaner left one more problem, because other fake CAPTCHAs on the same sites could still draw over the loader’s panel. The bundle includes a “Security Guard” for them, and its comments name the targets as foreign loading spinners, cfm-* CAPTCHAs and other fake Cloudflare-style screens that cover or delete its panel.

The guard matches on shape. Any visible, non-empty panel fixed over at least 90 percent of the window in both directions qualifies if its z-index is 900 or more, a threshold far below the cleaner’s 9990. The guard’s own panel is exempt through its ID and a data-og-self marker that also covers everything inside it.

One comment gives an example, interseq[.]at with the marker data-cfmd-instr, which PhishEye’s September 15 investigation independently documented, along with interseqf[.]com, serving fake-verification JavaScript. That component draws its screen inside a shadow DOM, a block of page elements sealed inside one host element, and hiding the host hides everything within it.

Guard comments describing foreign fake CAPTCHAs and the interseq example

*Figure 4. Comments in the guard, collected September 30, 2026, naming the competing screens it is meant to suppress.*

Whether the element is a foreign full-screen fixed overlay.

Criteria: position:fixed, z-index >= 900, visible area >= 90% of the viewport on both axes, not empty (has content), and not ours.

Foreign anti-bot overlays (their spinner and the foreign CAPTCHA with cfm-* classes) meet these criteria and are suppressed: these are precisely what we hide. Our own overlay is protected separately - by its id and the data-og-self attribute (isSelf), rather than by the criteria.

Gentle suppression of foreign full-screen layers: styles only, without removeChild. Called only by the timer (boot, 3-second interval, 30-second final pass) - NOT in the MutationObserver callback.

Synchronous mutations in the callback trigger a cascade with the other observer (a war, 3372 mutations on a live site).

A foreign host with a shadow DOM (for example interseq.at data-cfmd-instr) meets the isForeignOverlay criteria: fixed + z MAX_INT + >= 90% of the viewport + non-empty; display:none on it also hides its shadow-DOM contents.

Deleting rivals set off 3,372 page changes

Deleting an element is itself a page change, and other scripts’ observers react to it. The guard’s author says an earlier version, which deleted rival panels, woke the competing observers and produced 3,372 page changes “on a live site.” The comments call this a “war of observers.”

The same loop ran from the other side when the guard put its own panel back inside its observer callback, which “instantly woke the other observer.” The two then cut each other down within a single tick, and the page never got a chance to redraw. The author sums up the result as “the browser broke.”

From version 1.2.3, the author writes, killForeign() only sets styles. It applies display:none to hide the panel and pointer-events:none to block clicks, both with !important, and never calls removeChild(). The visitor sees the rival screen vanish, while the rival’s observer, which watches only for added and removed elements, has nothing to react to. The guard’s own work moved onto timers at the same time: once at start, every three seconds after that, and once more at 30 seconds. A comment says the interval used to be one second. Re-adding its own panel now waits for a timer too, with a 400 millisecond cooldown.

The guard’s style-only suppression function and its author’s note on the war of observers

*Figure 5. The guard’s suppression function, collected September 30, 2026.*

Gentle suppression: styles only, NO removeChild. removeChild wakes up the other MutationObservers (childList) - a war of observers (3372 mutations on a live site). Changing styles wakes only observers watching the style attribute, and the other script’s sweep generally does not maintain those. For a foreign host with a shadow DOM, display:none on the light-DOM host hides the contents too - that is enough.

The guard first appears in our collection on September 27, and seventeen saved page bodies across ten visits contain it, all with the style-only design. The deletion-based version survives only in the comments.

The guard props its own panel back up on every pass

Each pass forces the guard’s panel back to full screen with display:flex, a fixed position and a z-index of 2147483647. While the panel is waiting or active, the pass also restores its visibility and opacity, and it turns mouse input back on when the panel is meant to block the page. The display reset is the one the comment explains: “the other killer sets display:none !important, and neither visibility nor opacity can override it.”

The guard also moves its shell to the end of the page body on every pass. When two panels share a z-index, the one later in the page is drawn on top, and the comment expects interseq’s host, which loads late, to be the competitor.

The guard marking its own panel, moving its shell last and restoring display and position

Figure 6. The guard’s restoration code, collected September 30, 2026.

Always reapply display:flex: the other killer sets display:none !important, and neither visibility nor opacity can override it.

Its recovery code watches the wrong level

If another script deletes the panel, the guard is supposed to put it back. Its observer watches only the top level of the page body and checks each removed element for the ID wp-security-root. But a page record from September 30 shows the panel sitting inside wp-security-shell, one level down, with the borrowed names, the data-og-self marker and the forced styles in place.

Saved page tags showing wp-security-root nested inside wp-security-shell, with borrowed names and forced full-screen styles

*Figure 7. Page structure recorded September 30, 2026, with the opening tags wrapped for readability.*

So if the panel itself is deleted, the observer, watching one level too high, never hears about it. If the shell is deleted instead, the observer sees a removal with the wrong ID.

The file’s own comments disagree on the layout. The observer setup says the panel is “a direct child of body,” while the shell-moving code places it inside the shell and relies on that mismatch, because moving the shell registers as a removal with a different ID and so “the cascade will not start.” The wrapper that gets the panel past the cleaner also stops the guard from reacting to its own moves, and it leaves the recovery code watching a spot the panel never occupies.

Every stage still needs a network connection

Whichever panel ends up on top, the scripts still have to load from delivery servers, the Polygon branch still has to reach its controller for a command, and the pasted command still has to download its payload. ADAMnetworks’ adam:ONE applies Don’t Talk To Strangers (DTTS), which denies IP connections by default unless the destination was first requested through DNS and allowed by policy. Loader, controller and payload connections to disallowed destinations fail under that rule, regardless of what the page shows. ClickNix inspects page content in the browser, and DTTS covers the connections that follow it.

Our PasteSwitch investigation followed one lure from the browser to the command; this post covers the step before that, where kits compete over whose instructions the visitor reads.

ADAMnetworks’ ClickNix browser extension checks page HTML locally against YARA rules for ClickFix content, and its rules match all eight loader samples and the embedded lure page.

Indicators

Role Indicator Last seen
cfm controller (/first_allow_host) humanwebpage[.]online Oct 13
cfm controller simplecheck[.]online Oct 6
cfm controller webpagehelp[.]online Oct 6
cfm controller webpagesafe[.]online Oct 6
cfm controller hi-tuffglass[.]com Oct 6
Rival fake CAPTCHA (named in guard) interseq[.]at Oct 13
Rival fake CAPTCHA interseqf[.]com Oct 6
macOS stage contract (Polygon) 0xA3a603F8a454a9c905b4c579Bb72628F7C15C2A0

The loader installs as a rogue WordPress plugin with a randomized name, for example wp-security-optimizer-bncm, wp-cache-shield-jv4t and wp-kit-security-n4a4, and loads assets/js/wp-security.js.

See it on your own network

Zero Trust Connectivity reduces attack surface 7000:1 by denying egress by default. Talk to us about what that looks like in your environment.