Testing Self-Mutating Phishing Pages with a JavaScript Runtime Monitor

Testing Self-Mutating Phishing Pages with a JavaScript Runtime Monitor

pr0h0
cybersecurityphishingjavascriptruntime-monitoring
AI Usage (76%)

The headline claim is small, but the operational consequence is not. If phishing pages rebuild their JavaScript every time someone opens them, then a single screenshot, hash, or blocked URL is much less useful than it used to be. I would treat that as a runtime-analysis problem, not a static-review problem.

📝

I am treating the mutation claim as confirmed because the report states it. Everything below about how the page works is inference unless I say otherwise.

What the report says, and what I would not assume

The confirmed claim: phishing pages change code on each open

The source report says the phishing page changes its code every time someone opens it. That is the one fact I am comfortable treating as established from the material I was given.

A clean way to separate that from everything else is:

StatusClaim
ConfirmedThe reported phishing pages mutate their code on each open.
ConfirmedStatic inspection alone becomes less reliable against that behavior.
InferenceThe mutation is probably produced by server-side generation, client-side assembly, or both.
InferenceThe goal is likely to frustrate hashes, signatures, and one-shot URL blocklists.

What is still inference: how the mutation is implemented

I would not assume the page is “self-modifying” in only one specific way. Several common patterns could produce the same effect:

  • the server emits a fresh HTML or JavaScript payload for each visit
  • the page receives a per-session token and builds scripts from that token
  • the page loads a small bootstrap that pulls in a second-stage payload from an ephemeral path
  • the page rewrites itself in the browser using document.write, eval, blob URLs, or DOM injection

All of those are plausible. The report I saw does not prove which one is in use, so I would not name a specific mechanism without runtime evidence.

Why this matters more than ordinary obfuscation

Ordinary obfuscation is annoying, but it still tends to leave a stable sample behind. Once you decode it, you can often hash it, compare it, and write a rule.

Live mutation is different. The thing you scan at 09:00 may not exist at 09:05. The page becomes a moving target, and the investigator has to capture behavior, not just source text.

Why self-mutating phishing breaks static review

Hashes, signatures, and URL reputation lose value fast

If each open produces a different script body or script path, then a detector that depends on a static hash will keep missing new variants.

The same issue shows up in URL reputation. A scanner may block example.bad/payload.js, but the next visit uses example.bad/payload-41a9.js or a short-lived CDN path that never existed in the previous scan. In that case, the indicator was real, but it aged out before anyone could operationalize it.

One-time tokens and per-session script generation

A common trick is to put a one-time token in the HTML and let that token determine the rest of the page. From a defender’s perspective, that is enough to make the content unstable even if the campaign itself is unchanged.

That instability matters because a mail gateway, web proxy, or sandbox may only see one visit. If the page binds its payload to a token that expires after first use, you get a sample that cannot be reproduced later unless you capture the runtime context too.

The difference between packed JavaScript and live mutation

Packed JavaScript is not the same thing as self-mutating content.

PropertyPacked JavaScriptLive-mutation phishing
Source changes between visitsUsually noYes
Unpacking possible offlineOften yesSometimes no
Hash stabilityHighLow
Value of one sampleModerateOften weak
Best analysis methodDecode and inspectObserve in a live browser

That last row is the important one. A packed script is still a file. A mutating phishing page is a process.

How to inspect the page at runtime instead of trusting the source

Capture the initial HTML, script URLs, and network requests

My default move is to load the page in a throwaway browser context and record the first response, every request, and every response that follows. I want the exact URLs, not a vague “page loaded” result.

A safe local workflow looks like this:

npm init -y
npm i playwright
npx playwright install chromium

Then point a small monitor at the page:

monitor.mjs
import fs from "node:fs/promises";


const url = process.argv[2];
const out = process.argv[3] || "visit.json";

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1280, height: 900 }
});

await context.addInitScript(() => {
window.__events = [];
const log = (type, data) => {
  window.__events.push({ type, data, ts: Date.now() });
};

const origFetch = window.fetch;
window.fetch = async (...args) => {
  log("fetch", { input: String(args[0]) });
  return origFetch(...args);
};

const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, requestUrl, ...rest) {
  this.__url = String(requestUrl);
  return origOpen.call(this, method, requestUrl, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
  log("xhr", { url: this.__url });
  return origSend.apply(this, args);
};

const origEval = window.eval;
window.eval = (code) => {
  log("eval", { sample: String(code).slice(0, 120) });
  return origEval(code);
};

const origWrite = Document.prototype.write;
Document.prototype.write = function (...args) {
  log("document.write", { sample: String(args[0]).slice(0, 120) });
  return origWrite.apply(this, args);
};

const storageSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function (key, value) {
  log("storage", { key, value: String(value).slice(0, 120) });
  return storageSetItem.call(this, key, value);
};

const origPushState = history.pushState;
history.pushState = function (...args) {
  log("history.pushState", { url: String(location.href) });
  return origPushState.apply(this, args);
};

const origReplaceState = history.replaceState;
history.replaceState = function (...args) {
  log("history.replaceState", { url: String(location.href) });
  return origReplaceState.apply(this, args);
};
});

const page = await context.newPage();
const network = [];

page.on("request", (request) => {
network.push({ type: "request", url: request.url(), method: request.method() });
});

page.on("response", (response) => {
network.push({ type: "response", url: response.url(), status: response.status() });
});

page.on("framenavigated", (frame) => {
network.push({ type: "nav", url: frame.url() });
});

await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);

const snapshot = await page.evaluate(() => ({
html: document.documentElement.outerHTML,
events: window.__events,
location: location.href,
cookie: document.cookie
}));

await fs.writeFile(out, JSON.stringify({ network, snapshot }, null, 2));
await browser.close();

That monitor is intentionally boring. It observes, records, and exits. No exploit logic, no payload staging, no interaction beyond page loading.

Hook fetch, XMLHttpRequest, eval, and DOM writes

The point of the hooks is not to catch every evasive trick. The point is to force the page to leave a trail:

  • fetch shows late-stage network pulls
  • XMLHttpRequest catches older code paths and a lot of ad hoc loaders
  • eval tells you when code is being assembled and executed dynamically
  • document.write exposes bootstrap pages that inject follow-on scripts
  • storage hooks reveal state that may control which variant loads next

If a sample changes from one run to the next, those hooks often show where the change starts.

Record storage, cookies, and redirects before the page settles

I also want the browser state at the end of the load, not just the requests. Cookies and storage often carry the token that makes the next visit different.

For redirects, I care about the chain, not just the final URL. A phishing page that bounces through a sequence of ephemeral paths is doing something operationally different from a static landing page.

A safe JavaScript runtime monitor workflow

Run the page in an isolated browser profile or container

Do not run this in your daily browser profile. Use a fresh browser context, a disposable user-data directory, or a container with no access to your real accounts.

⚠️

Even a defensive test can leak cookies, open trusted tabs, or pick up ambient login state if you reuse your normal browser profile.

Snapshot the DOM and network graph after each load

I prefer to save three artifacts per visit:

  • the final DOM as text
  • the network event log
  • the browser event log from the hooks

That gives me something I can diff later without rerunning the sample.

Diff two visits and look for unstable script paths or payloads

A simple diff is often enough to prove mutation.

node monitor.mjs http://127.0.0.1:8000/ visit-1.json
node monitor.mjs http://127.0.0.1:8000/ visit-2.json

jq -r '.snapshot.events[] | [.type, (.data.sample // .data.url // .data.key // "")] | @tsv' visit-1.json > visit-1.tsv
jq -r '.snapshot.events[] | [.type, (.data.sample // .data.url // .data.key // "")] | @tsv' visit-2.json > visit-2.tsv
diff -u visit-1.tsv visit-2.tsv

Example output from a harmless local test page:

- document.write    <script src="/assets/app.91c2.js"></script>
+ document.write    <script src="/assets/app.a7f4.js"></script>
- request   http://127.0.0.1:8000/assets/app.91c2.js
+ request   http://127.0.0.1:8000/assets/app.a7f4.js

That is enough to prove runtime mutation without keeping or analyzing a real phishing payload.

Example monitor output that proves mutation without triggering abuse

A good report should show the unstable pieces side by side. In my own lab, I would keep the evidence small and explicit:

VisitInitial DOM noteNetwork noteRuntime note
1script tag points to one asset pathrequest for app.91c2.jsdocument.write logged a matching path
2same base page, different script pathrequest for app.a7f4.jsdocument.write logged a new path

That is the kind of proof I would trust. A screenshot alone would not be enough.

What to look for when the page mutates every visit

Server-rendered variation versus client-side self-modification

These two cases look similar from the outside, but they matter differently in analysis.

If the server renders a new page every time, then the variability is probably in the campaign backend. If the client mutates itself after load, then a small bootstrap may be hiding a larger second stage.

I would not assume which one is true until I see the runtime trace.

Hostname churn, ephemeral paths, and short-lived resources

The report’s claim about changing code fits a broader pattern I have seen in malicious campaigns: hostnames move, paths disappear, and resources expire quickly. That behavior is usually meant to outlast one scan and frustrate the next.

The practical defense is to collect the exact request set during the visit. If you only keep a URL string, you lose most of the context.

Login forms that rewrite action targets or credential collectors

One thing I would watch closely is form handling. A page can look like a normal login form and still rewrite its action target, post credentials somewhere unexpected, or swap collectors between visits.

That is one reason I prefer browser runtime logs over static HTML reviews. The interesting part is often what happens after the page has already loaded.

Defensive takeaways for security teams

Prefer runtime evidence over single-scan verdicts

If your pipeline makes a decision from a single page fetch, you are going to miss samples like this. I would rather have one good detonation record than ten shallow hashes.

That means collecting:

  • rendered DOM
  • network requests and responses
  • final redirect chain
  • storage and cookie state
  • any dynamic code execution event

Add browser-layer and mail-layer controls, not just blocklists

Blocklists still matter, but they are not enough when the page keeps changing. You need controls that can inspect behavior in the browser and correlate it with mail or proxy telemetry.

The best signal is often not “this URL is bad.” It is “this page keeps producing different code paths for different visitors.”

Escalate to dynamic analysis when content fingerprints keep changing

If a page fingerprint changes every time you scan it, stop trusting the fingerprint. Escalate it to a sandbox, a headless browser workflow, or a human review that includes runtime artifacts.

That is the technical position I would take: repeated instability is itself a signal.

What this means for developers building security tooling

Log artifacts in a reproducible form the team can diff later

If you build tooling, make the output diff-friendly. JSON with stable keys works better than a screenshot attachment that nobody can search later.

I would keep:

  • normalized request logs
  • DOM snapshots
  • extracted script URLs
  • hashes of the final DOM and fetched scripts
  • timing data for the first few seconds of execution

Treat page mutation as an analysis signal, not a curiosity

It is easy to shrug and call it obfuscation. That misses the point.

Mutation means the page is actively trying to be a moving target. For defenders, that should raise the priority of the sample, not lower it.

Keep the monitor scoped so it observes without becoming a risk

A monitor should not execute arbitrary user code outside the browser, and it should not start interacting with forms or sending credentials anywhere. Observation is enough.

If the tool needs to prove mutation, it can do that by recording the evidence the page emits on its own.

Conclusion: the real failure is static thinking, not just phishing

The report’s claim is believable because the tactic makes defensive shortcuts fail. A page that changes on each open is trying to outrun the workflow most teams still rely on: fetch once, hash once, block once.

My view is simple: the right response is not a fancier static scanner. It is a repeatable runtime monitor that captures behavior, stores the artifacts, and makes variation visible.

If you can diff two visits, you are already ahead of the page.

Share this post

More posts

Comments