Update to ScanMalware
Snapshot Nov 13, 2026 · 06:10 UTC · version 1.3.1
Collection source: downloaded plugin package.
Instructions updated for pivoting-phishing-infrastructure-with-scanmalware
Full description — Now says: or fake-download URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL...
Observed in instructions or declared skills. Runtime behavior has not been tested.
Up to 3 plugins free · no card required.
All fields and original values.
Full description
Oct 24, 2026 · 13:47 UTC
URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256, favicon MMH3, screenshot hash, page title) plus JA...
Nov 13, 2026 · 06:10 UTC
or fake-download URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256 including inline scripts, favicon ...
Skill instructions
Oct 24, 2026 · 13:47 UTC
URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256, favicon MMH3, screenshot hash, page title) plu...
Nov 13, 2026 · 06:10 UTC
or fake-download URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256 including inline scripts, favi...
Supporting files
Oct 24, 2026 · 13:47 UTC
[{"relative_path":"LICENSE","size_in_bytes":11255},{"relative_path":"assets/template.md","size_in_bytes":2722},{"relative_path":"references/api-reference.md","size_in_bytes":6163},{"relative_path":"references/standards.md","size_in_bytes...
Nov 13, 2026 · 06:10 UTC
[{"relative_path":"LICENSE","size_in_bytes":11255},{"relative_path":"assets/template.md","size_in_bytes":3190},{"relative_path":"references/api-reference.md","size_in_bytes":9751},{"relative_path":"references/standards.md","size_in_bytes...
Compare saved observations
Download comparison JSONSKILL.md text changes
− removed · + added
--- before +++ after @@ -1,14 +1,16 @@ --- name: pivoting-phishing-infrastructure-with-scanmalware description: >- - Expands one phishing URL into its wider campaign using the ScanMalware public scan - archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches - on each kit artifact (script SHA-256, favicon MMH3, screenshot hash, page title) plus - JARM and IP, and CT-derived subdomains, then judges which pivots are selective enough to - hunt or block on. Use when a reported phishing URL needs its sibling hosts found, when - checking how an already scanned page was judged, or when a verdict must be attributed to - the right host after a redirect. Keywords: ScanMalware, SMQL, matched_on, final_url, - js_sha256, favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking. + Expands one phishing or fake-download URL into its wider campaign using the ScanMalware + public scan archive (no API key needed): redirect-aware domain history (matched_on), + SMQL searches on each kit artifact (script SHA-256 including inline scripts, favicon + MMH3, screenshot hash, page title) and on the operator's own account keys (analytics + IDs, chat-widget licenses, CAPTCHA site keys), plus JARM, IP and CT-derived subdomains, + then judges which pivots are selective enough to hunt or block on. Use when a reported + phishing URL needs its sibling hosts found, when checking how an already scanned page + was judged, or when a verdict must be attributed to the right host after a redirect. + Keywords: ScanMalware, SMQL, matched_on, final_url, js_sha256, tracking_id, + favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking. license: Apache-2.0 --- # Pivoting Phishing Infrastructure with ScanMalware @@ -17,13 +19,16 @@ A single phishing URL is rarely the whole campaign. Kits are redeployed across dozens of disposable hosts, and the pages share artifacts that survive the move: the same bundled -JavaScript, the same favicon, the same rendered screenshot, the same page title. A public -archive of rendered scans lets an analyst search for those artifacts and find the sibling -hosts that were already scanned by someone else. - -ScanMalware (scanmalware.com, operated by Triop AB) renders submitted URLs in a sandboxed -browser and keeps the public scans searchable through a REST API and the SMQL query -language. The read endpoints and anonymous scan submission need no account or API key. +JavaScript, the same favicon, the same rendered screenshot, the same page title. Many also +carry the operator's own accounts, such as an analytics ID or a live-chat license, which +follow the operator even when the kit is rebuilt or moved to another hosting platform. A +public archive of rendered scans lets an analyst search for those artifacts and find the +sibling hosts that were already scanned by someone else. + +ScanMalware (scanmalware.com, operated by Triop AB) runs submitted URLs through isolated +analysis environments, records what each page loads and runs, and keeps the public scans +searchable through a REST API and the SMQL query language. The read endpoints and +anonymous scan submission need no account or API key. This skill covers the pivot procedure and two traps that produce wrong conclusions if ignored: verdicts attributed to a redirect destination, and pivot values that describe a hosting platform rather than an actor. @@ -35,23 +40,26 @@ submitting it again. - A domain appears in scan results with a malicious verdict and you must decide whether the verdict belongs to that domain or to a page that redirected to it. -- You have a kit artifact (script SHA-256, favicon hash, page title) and want every - archived page that carries it, newest first. +- You have a kit artifact (script SHA-256, favicon hash, page title) or an account key + (analytics ID, chat-widget license, CAPTCHA site key) and want every archived page that + carries it, newest first. - You need to decide which indicators from a phishing page are safe to block on without blocking a shared hosting platform. ## Prerequisites -- `curl`, `jq` 1.6 or later, and Python 3.8+ (standard library only) for `scripts/agent.py`. +- `curl` and `jq` 1.6 or later, or the ScanMalware MCP server that comes with this plugin + (see Tools & Systems for the tool behind each step). - Outbound HTTPS to `scanmalware.com`. No account or API key is needed for anything in this workflow except private scans. - Rate limits are per source IP: 600 requests per minute anonymously (60 per minute for - the hash-search pages), 3000 per minute with an API key sent as `X-API-Key`. A 429 - response carries `Retry-After`; wait that many seconds. + the hash-search pages); a ScanMalware account raises them. A 429 response carries + `Retry-After`; wait that many seconds. - OPSEC: `public` scans (the default) are visible to anyone. If the URL carries a victim's - email address, token or tracking ID, submit it as `unlisted` (reachable only by scan ID, - absent from search) or `private` (requires an API key; anonymous requests get HTTP 401). - Submitting also makes the scanner fetch the URL, which a kit operator can observe. + email address, a per-victim link code or a tracking ID, submit it as `unlisted` + (reachable only by scan ID, absent from search) or `private` (requires a ScanMalware + account; anonymous requests get HTTP 401). Submitting also makes the scanner fetch the + URL, which a kit operator can observe. ## Workflow @@ -148,6 +156,19 @@ - The two can disagree. Read `key_evidence` before acting on a `CONFIRMED_SCAM`: when it cites only a new domain plus a login form, with no impersonation or kit match, treat it as unconfirmed. +- A page without a form is not clean. A lure that offers a download (a fake desktop app + or document viewer) gives the form checks nothing to weigh, and the scanner does not + click buttons, so the file behind a Download button is never fetched and an empty + malware result says nothing about it. On 2026-10-20 such a page scored `low` (27) with + brand impersonation as its only risk factor, while the AI classified it + `CONFIRMED_SCAM`, `scam_type` "Malware Distribution". +- Verdicts are per scan, and copies of one kit on different hosts can disagree. Once a + pivot ties scans to one kit (Step 6), judge the kit by its best-evidenced scan, not by + its weakest verdict. A `LOW_RISK` whose `key_evidence` calls the brand the site's own + ("self-branding") because the brand name is in the hostname is unresolved, not clean: + check who owns the registered domain. A vendor-named subdomain on a company's own, + established domain (a help desk at `zendesk.<company>`) can be legitimate; the brand name + on a domain registered days ago that pushes a download is not. ### Step 4: Find out where the page really ended @@ -168,21 +189,56 @@ screenshot: (.screenshot_hashes | {dhash_hex, phash_hex}), primary_ip, primary_asn}' result.json -# Scripts served from the entry host: the strongest kit identifiers +# Scripts served from the entry host, and inline scripts: the strongest kit identifiers jq -r '(.url | capture("^https?://(?<h>[^/:?#]+)").h) as $entry | .javascript_files[] - | select((.url | capture("^https?://(?<h>[^/:?#]+)").h) == $entry) + | select((.url | startswith("inline://")) + or ((.url | capture("^https?://(?<h>[^/:?#]+)").h) == $entry)) | [.content_sha256, .url] | @tsv' result.json +# Account keys found on the page: analytics IDs, chat-widget licenses, CAPTCHA site keys +curl -s "https://scanmalware.com/api/v1/tracking-keys/by-scan/$SCAN_ID" -o trackers.json +jq -r '.tracking_keys[] | [.tracker_type, .tracking_key, .site_count] | @tsv' trackers.json + curl -s "https://scanmalware.com/api/v1/jarm/scan/$SCAN_ID" -o jarm.json jq -r '.signatures[] | [.host, .jarm_signature, .is_known] | @tsv' jarm.json -``` -Discard a favicon whose `url` is on the redirect destination. `mmh3_hash` follows the -Shodan `http.favicon.hash` convention, so the same value can be searched there. -`primary_ip` is in CIDR form (`188.114.96.9/32`); drop the suffix before searching. -`is_known: true` on a JARM means it matches a known signature listed in -`matched_domains`. +# Who served the entry page, from its own response headers +jq -r '(.url | capture("^https?://(?<h>[^/:?#]+)").h) as $entry + | .http_transactions[] + | select((.request.url | capture("^https?://(?<h>[^/:?#]+)").h) == $entry) + | .response.headers | to_entries[] + | select(.key | ascii_downcase | test("^(server|x-vercel-id|x-nf-request-id|cf-ray|x-served-by|x-amz-cf-id)$")) + | "\(.key | ascii_downcase): \(.value)"' result.json | sort -u +``` + +- Inline scripts have URLs of the form `inline://<scan_id>/script_<n>_<sha prefix>`, which + name no host, so a filter on the entry host alone drops them. They are often the kit's + most specific code, for example a chat-widget loader with the operator's license number + in it. After a redirect they may come from the destination page instead. +- An account key is an account the operator signed up for, so it links hosts across kit + rebuilds and hosting platforms. `site_count` is how many archived sites carry it: a key + on thousands of unrelated sites belongs to a platform (a challenge page's CAPTCHA key, a + hosting provider's own analytics), not to the actor. The index also reads keys from the + page source, so a widget that never loads still yields its key. +- `GET /api/v1/scans/$SCAN_ID/pivot-values` lists further searchable values of the scan: + inline script hashes (`js_sha256:`), script hosts (`script_host:`), hosts named in script + source (`js_host:`), resource SHA-256s (`resource_sha256:`), IDS signatures, hosts the + deobfuscator revealed, Telegram bots and chats, and more; the API documentation names the + filter for each. Its `tracking_keys` carry no `site_count`; use the call above, or count + the key with `sites` in Step 6. +- Use a favicon only when its `url` is on the entry host. One fetched from the redirect + destination, or hotlinked from the impersonated brand's own site, describes the brand. + `mmh3_hash` follows the Shodan `http.favicon.hash` convention, so the same value can be + searched there. +- `primary_ip` is in CIDR form (`188.114.96.9/32`); drop the suffix before searching. + `is_known: true` on a JARM means it matches a known signature listed in + `matched_domains`. +- Name the hosting platform from the entry page's headers (`server: Vercel`, + `x-vercel-id`, `cf-ray`, `x-nf-request-id` for Netlify), not from the ASN name: hosts + served by Vercel showed `AS16509 Amazon.com` in the archive's IP table. Filter to the + entry host first; the page's other requests (a favicon hotlinked from the brand, say) + carry their own `server` headers. ### Step 6: Count and read each pivot before trusting it @@ -199,18 +255,99 @@ titles: ([.results[].title] | group_by(.) | map({title: .[0], n: length}))}' smql.json ``` +An account key is searched by tracker type and key, both quoted: + +```bash +smql 'tracker:"livechat" tracking_id:"19281545"' +jq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url, .title] | @tsv)' smql.json +``` + A pivot is useful when its matches render the same page on many hosts (a kit), and useless when they are unrelated sites (a platform). `exact_count: false` means the count hit the -10,000 cap. The same judgement for every pivot of a scan, in one call: +10,000 cap. Title agreement cannot judge a title pivot, since every hit of a title search +carries the title; judge a title by its count and confirm each host it finds with a +second pivot. + +To judge every pivot of a scan at once, write one SMQL query per line and count them in +one request (up to 25). Leave out a favicon served from another host and, when the page +redirected, inline scripts and account keys that may be the destination's: + +```bash +cat > pivots.txt <<'EOF' +js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72 +title:"Meta for Business | Page Appeal" +dhash:c000000000000000 +ip:188.114.96.9 +EOF +jq -R -s '{queries: (split("\n") | map(select(length > 0))), sites: true}' pivots.txt > counts-body.json +curl -s -X POST https://scanmalware.com/api/v1/search/smql/counts \ + -H 'Content-Type: application/json' --data-binary @counts-body.json -o counts.json +jq -r '.counts | to_entries[] + | [.key, .value.total_items, .value.exact_count, .value.sites, .value.elevated_sites, + .value.hosts, .value.shared_widely] | map(tostring) | @tsv' counts.json +``` + +With `sites: true`, a query with 10 or more matches also gets, over up to 200 of its +matching scans, `sites` (distinct registrable domains), `elevated_sites` (those rated high +risk or worse), `hosts` (distinct entry hosts) and `shared_widely` (many sites, and no more +of them high risk than across the whole archive). Read them as: + +| Count result | Assessment | +|---|---| +| `total_items` is 1 | unique: an exact indicator that finds no siblings | +| `shared_widely` is `true` | broad: a common asset or a platform; drop it | +| `elevated_sites` close to `sites`, several `hosts` | cluster: the value travels with a kit; keep it | +| `elevated_sites` well below `sites` | mixed: a shared IP, platform key or common asset; read the hits first | +| Under 10 matches, so no `sites` | read the hits with `smql` above | + +On 2026-11-12, for the Meta example below, the entry-host script matched 1,111 scans on 56 +sites, all 56 high risk (cluster); the title 6,145 scans on 13 sites, all high risk +(cluster, but a title, so confirm each host); the blank-page screenshot dHash came back +`shared_widely` (broad); and the Cloudflare edge IP had 93 of 166 sites high risk (mixed: +the platform hosts the kit among much else). A count that ran out of time has +`count_unavailable: true`; send it again with `"patient": true` (at most 4 queries). The +labels are a first cut; read the hits behind any pivot you intend to block on. + +To turn the kept pivots into a host list, put the ones assessed cluster in `kept.txt`, one +per line, fetch the newest 100 hits of each and group them by entry host: ```bash -python3 scripts/agent.py pivots "$SCAN_ID" --output pivots.json +n=0 +while IFS= read -r q; do + n=$((n + 1)) + curl -s -G https://scanmalware.com/api/v1/search/smql \ + --data-urlencode "q=$q" --data-urlencode "limit=100" --data-urlencode "sort=newest" \ + -o "hits-$n.json" +done < kept.txt + +jq -s '[.[] | .query as $q | .results[] + | {host: (.url | capture("^https?://(?<h>[^/:?#]+)").h | ascii_downcase), + q: $q, at: .submitted_at}] + | group_by(.host) + | map({entry_host: .[0].host, pivot_count: ([.[].q] | unique | length), + found_by: ([.[].q] | unique), + first_seen: (map(.at) | min), last_seen: (map(.at) | max)}) + | sort_by(.first_seen)' hits-*.json > related.json +jq -r '.[] | [.first_seen[0:10], .pivot_count, .entry_host, (.found_by | join(" + "))] | @tsv' related.json +``` + +A host reached by one pivot only, a title in particular, needs a second look. Then ask +which account keys the cluster shares, including keys the seed does not carry, with one +facet query over the kept pivots joined by `OR`: + +```bash +curl -s -G https://scanmalware.com/api/v1/search/smql/facets \ + --data-urlencode 'q=title:"Docusign - Download to Review & Sign Document" OR tracking_id:19281545' \ + --data-urlencode 'fields=tracking_id,primary_ip' --data-urlencode 'size=25' -o facets.json +jq -r '"\(.sample.scans) scans", + (.facets[] | .field as $f | .buckets[] | [$f, .count, (.label // ""), .query] | @tsv)' facets.json ``` -Each pivot gets `matches`, the distinct entry hosts and title agreement among the newest -25 hits, and an `assessment` of `unique`, `cluster`, `broad` or `mixed`, or `drop` for a -favicon served from the redirect destination. The labels are a first cut; read the hits -behind any pivot you intend to block on. +Each value carries `count` (scans in the sample, the newest 1,000 matches by default) and +`query`, which is ready to count with `sites` as above. A key on most scans of the cluster +that the seed lacks is often a second operator account or another generation of the kit; +count it before treating it as an actor link, since a key that is `shared_widely` belongs +to a platform. ### Step 7: Expand with SMQL combinations @@ -222,6 +359,10 @@ # Lure title, excluding the real brand, limited to AI-confirmed scams smql 'title:"The Courier Guy" -domain:thecourierguy.co.za verdict:CONFIRMED_SCAM' 50 jq -r '.results[].url' smql.json + +# New hosts on one operator's chat account this week +smql 'tracker:"livechat" tracking_id:"19470622" submitted:last7d' 100 +jq -r '.results[] | [.submitted_at[0:10], .url] | @tsv' smql.json ``` `GET /api/v1/search/smql/filters` lists all filters with types and allowed values. An @@ -250,7 +391,8 @@ | `matched_on` | Field on `/domains/{domain}/scans` rows that separates scans that started on a domain from scans that only landed there. | | Scanner cloaking | Many kits send visitors that look like scanners to the real brand site, so the scan ends on the brand and the brand's domain collects the phishing scan. | | Shared platform | Cloudflare Pages, R2, Netlify, Vercel and similar give every tenant the same edge IPs and TLS stack; their JARM, IP and ASN cluster the platform, not the actor. | -| Kit artifact | Script SHA-256 from the entry host, favicon MMH3, screenshot dHash, distinctive title. These follow the kit across hosts. | +| Kit artifact | Script SHA-256 from the entry host or an inline script, favicon MMH3 from the entry host, screenshot dHash, distinctive title. These follow the kit across hosts. | +| Account key | An analytics ID, chat-widget license or CAPTCHA site key the operator registered. It follows the operator across kit rebuilds and hosting platforms; one on thousands of unrelated sites belongs to a platform. | | Pivot selectivity | Judge by what the matches look like, not by the count alone: 650 copies of one kit is a strong pivot; 7,000 unrelated sites on one IP is not. | | Two verdicts | `security_verdict` (analyzer checks, `risk_level`) and the AI `classification` are independent and can disagree. | | SMQL | ScanMalware Query Language: `field:value`, `AND`/`OR`, `-field:value`, quotes, ranges (`a..b`), `has:feature`, relative dates (`last7d`). | @@ -263,10 +405,14 @@ - **SMQL filter list**: `GET https://scanmalware.com/api/v1/search/smql/filters`. - **ScanMalware MCP server**: `https://mcp.scanmalware.com/mcp` (streamable HTTP, no authentication), installed with this plugin, exposes the same operations as MCP tools. + Step 1's history with `matched_on` is `get_domain_scans` (not `get_domain_history`, + which has no `matched_on`). The SMQL search is `search_smql` (`get_smql_filters` lists + the filters), the Step 6 counts `count_smql_queries` (pass `sites: true`) and facets + `get_smql_facets`, a scan's searchable values and account keys `get_scan_pivot_values` + (keys without `site_count`), the scans carrying one key `search_tracking_key`, the host + list `get_domain_hosts`, and DNS `get_dns_history` / `search_reverse_dns`. Its `submit_scan` tool has no default visibility: `scan_type` must be given on every call, while the REST endpoint in Step 2 falls back to `public` when it is left out. -- **scripts/agent.py**: `history`, `pivots`, `search` and `submit` subcommands; standard - library only; anonymous, sends no credentials. - **jq**: https://jqlang.org/manual/ ## Common Scenarios @@ -293,6 +439,24 @@ the delivery URL to hunt for in mail logs, and the bucket in `final_url` is the host to report. The screenshot dHash is narrower (17 scans) and confirms the same lure. +### Scenario: A fake desktop-app download behind a document lure + +A page titled "Docusign - Download to Review & Sign Document" tells the visitor to install +a "Docusign Desktop" app to open a document. It has no form, hotlinks DocuSign's own +favicon and loads no script from its own host, so the usual kit pivots are empty. On +2026-10-20 the analyzer rated it `low` and the AI `CONFIRMED_SCAM` ("Malware +Distribution"). The title matched 20 scans on 17 hosts since 2026-07-23, most named after +cryptocurrency firms. The pivot that tied them to one operator was the inline LiveChat +loader: its license 19281545 was on 3 hosts sharing one dedicated IP, and the account keys +of the hosts it found led to a second license, 19470622, on 14 more hosts on Vercel and +Cloudflare. The oldest host +carried a third license of its own. Across the 20 scans the analyzer said 9 `low`, +9 `medium` and 2 `high`, and the AI rated 2 of them `LOW_RISK` as "self-branding". No +scan captured the installer: the Download button is script-driven, so the file has to be +fetched from a live host by hand. Block the entry hosts and the dedicated IP and report +the chat licenses to the vendor; never block `docusign.com`, the Vercel or Cloudflare +edge, or the dedicated IP's ASN, which held 943 unrelated scans. + ### Scenario: An AI `CONFIRMED_SCAM` on a brand-new domain with its own login page `key_evidence` cites only "domain registered 0 days ago" and "login form captures email @@ -334,6 +498,10 @@ - [ ] Both verdicts were read after they populated (no empty `security_verdict`, no 404 from `/ai/{scan_id}` treated as clean). - [ ] Each pivot kept has its match count and a sample of hits that render the same page. -- [ ] No IP, ASN or JARM of a shared platform, and no favicon from a redirect destination, - is in the block list. +- [ ] Every related host found by a title alone was confirmed with a second pivot (an + account key, a script hash, a dedicated IP) or is marked unconfirmed. +- [ ] No account key with a `site_count` far above the cluster size is used as an actor + link. +- [ ] No IP, ASN or JARM of a shared platform, and no favicon from a redirect destination + or hotlinked from the brand, is in the block list. - [ ] Indicators are defanged in the written report.
Full technical diff · 3 changed fields
changed /description
"Expands one phishing URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256, favicon MMH3, screenshot hash, page title) plus JARM and IP, and CT-derived subdomains, then judges which pivots are selective enough to hunt or block on. Use when a reported phishing URL needs its sibling hosts found, when checking how an already scanned page was judged, or when a verdict must be attributed to the right host after a redirect. Keywords: ScanMalware, SMQL, matched_on, final_url, js_sha256, favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking."
"Expands one phishing or fake-download URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256 including inline scripts, favicon MMH3, screenshot hash, page title) and on the operator's own account keys (analytics IDs, chat-widget licenses, CAPTCHA site keys), plus JARM, IP and CT-derived subdomains, then judges which pivots are selective enough to hunt or block on. Use when a reported phishing URL needs its sibling hosts found, when checking how an already scanned page was judged, or when a verdict must be attributed to the right host after a redirect. Keywords: ScanMalware, SMQL, matched_on, final_url, js_sha256, tracking_id, favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking."
changed /included_files
[
{
"relative_path": "LICENSE",
"size_in_bytes": 11255
},
{
"relative_path": "assets/template.md",
"size_in_bytes": 2722
},
{
"relative_path": "references/api-reference.md",
"size_in_bytes": 6163
},
{
"relative_path": "references/standards.md",
"size_in_bytes": 3039
},
{
"relative_path": "references/workflows.md",
"size_in_bytes": 5248
},
{
"relative_path": "scripts/agent.py",
"size_in_bytes": 13959
}
][
{
"relative_path": "LICENSE",
"size_in_bytes": 11255
},
{
"relative_path": "assets/template.md",
"size_in_bytes": 3190
},
{
"relative_path": "references/api-reference.md",
"size_in_bytes": 9751
},
{
"relative_path": "references/standards.md",
"size_in_bytes": 3193
},
{
"relative_path": "references/workflows.md",
"size_in_bytes": 6584
}
]changed /skill_md_contents
"---\nname: pivoting-phishing-infrastructure-with-scanmalware\ndescription: >-\n Expands one phishing URL into its wider campaign using the ScanMalware public scan\n archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches\n on each kit artifact (script SHA-256, favicon MMH3, screenshot hash, page title) plus\n JARM and IP, and CT-derived subdomains, then judges which pivots are selective enough to\n hunt or block on. Use when a reported phishing URL needs its sibling hosts found, when\n checking how an already scanned page was judged, or when a verdict must be attributed to\n the right host after a redirect. Keywords: ScanMalware, SMQL, matched_on, final_url,\n js_sha256, favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking.\nlicense: Apache-2.0\n---\n# Pivoting Phishing Infrastructure with ScanMalware\n\n## Overview\n\nA single phishing URL is rarely the whole campaign. Kits are redeployed across dozens of\ndisposable hosts, and the pages share artifacts that survive the move: the same bundled\nJavaScript, the same favicon, the same rendered screenshot, the same page title. A public\narchive of rendered scans lets an analyst search for those artifacts and find the sibling\nhosts that were already scanned by someone else.\n\nScanMalware (scanmalware.com, operated by Triop AB) renders submitted URLs in a sandboxed\nbrowser and keeps the public scans searchable through a REST API and the SMQL query\nlanguage. The read endpoints and anonymous scan submission need no account or API key.\nThis skill covers the pivot procedure and two traps that produce wrong conclusions if\nignored: verdicts attributed to a redirect destination, and pivot values that describe a\nhosting platform rather than an actor.\n\n## When to Use\n\n- A user reported a phishing URL and you need the other hosts running the same kit.\n- You want to know whether a URL or domain was already scanned, and what it did, before\n submitting it again.\n- A domain appears in scan results with a malicious verdict and you must decide whether\n the verdict belongs to that domain or to a page that redirected to it.\n- You have a kit artifact (script SHA-256, favicon hash, page title) and want every\n archived page that carries it, newest first.\n- You need to decide which indicators from a phishing page are safe to block on without\n blocking a shared hosting platform.\n\n## Prerequisites\n\n- `curl`, `jq` 1.6 or later, and Python 3.8+ (standard library only) for `scripts/agent.py`.\n- Outbound HTTPS to `scanmalware.com`. No account or API key is needed for anything in\n this workflow except private scans.\n- Rate limits are per source IP: 600 requests per minute anonymously (60 per minute for\n the hash-search pages), 3000 per minute with an API key sent as `X-API-Key`. A 429\n response carries `Retry-After`; wait that many seconds.\n- OPSEC: `public` scans (the default) are visible to anyone. If the URL carries a victim's\n email address, token or tracking ID, submit it as `unlisted` (reachable only by scan ID,\n absent from search) or `private` (requires an API key; anonymous requests get HTTP 401).\n Submitting also makes the scanner fetch the URL, which a kit operator can observe.\n\n## Workflow\n\n### Step 1: Check the archive, and read `matched_on`\n\n```bash\nDOMAIN=facebook.com\ncurl -s \"https://scanmalware.com/api/v1/domains/$DOMAIN/scans?limit=100\" -o history.json\njq -r '.[] | [(.matched_on | join(\"+\")), .scan_id, .url, .final_url] | @tsv' history.json\n```\n\nThe endpoint lists scans of the domain and its subdomains, matched on the entry host or\nthe final host after redirects. `matched_on` says which:\n\n| `matched_on` | Meaning | Whose verdict is it |\n|---|---|---|\n| `[\"url\"]` | Scan was submitted for this domain or one of its subdomains | The host in `url` |\n| `[\"url\",\"final_url\"]` | Started and ended under this domain | The host in `url` |\n| `[\"final_url\"]` | Started elsewhere and landed here | The entry host in `url`, not this domain |\n\n`\"url\"` in `matched_on` is not enough to give a verdict to the domain itself. The rows\ninclude subdomains, and on a shared platform a subdomain is somebody else's site: the\nnewest `github.io` rows matched on `url` are tenant pages such as\n`trustwallet-team-support.github.io`. Attribute a verdict to the domain only when the\nhost in `url` is the domain or its `www.` name.\n\nSplit the rows before reading any verdict:\n\n```bash\njq --arg d \"$DOMAIN\" '\n def host: capture(\"^https?://(?<h>[^/:?#]+)\").h | ascii_downcase;\n {this_host: [.[] | select(any(.matched_on[]; . == \"url\"))\n | select((.url | host) == $d or (.url | host) == \"www.\" + $d)] | length,\n subdomain_hosts: [.[] | select(any(.matched_on[]; . == \"url\"))\n | select((.url | host) != $d and (.url | host) != \"www.\" + $d)] | length,\n landed_here_only: [.[] | select(.matched_on == [\"final_url\"])] | length}' history.json\n```\n\nFor a large brand the second number usually dominates: many phishing kits send scanners\nto the real site to hide the lure, so the brand's domain collects scans of phishing pages it\nnever hosted. SMQL `domain:` has the same property (it matches the final host too) and has\nno `matched_on` field, so compare the hosts of `url` and `final_url` on every SMQL row.\n\n### Step 2: Submit the URL if the archive has nothing current\n\n```bash\nSUSPECT_URL='https://example.com/'\njq -n --arg u \"$SUSPECT_URL\" '{url: $u, scan_type: \"unlisted\"}' > submit-body.json\ncurl -s -X POST https://scanmalware.com/api/v1/scan \\\n -H 'Content-Type: application/json' --data-binary @submit-body.json -o submit.json\nSCAN_ID=$(jq -r .scan_id submit.json)\necho \"$SCAN_ID\"\n\nfor i in $(seq 1 60); do\n curl -s \"https://scanmalware.com/api/v1/result/$SCAN_ID/progress\" -o progress.json\n jq -e '.status | test(\"^(completed|failed|error)$\")' progress.json >/dev/null && break\n sleep 5\ndone\n```\n\n`scan_type` is `public`, `unlisted` or `private`. The response carries `scan_id`,\n`status` (`queued`), `message`, `scan_type` and `submitted_at`.\n\n### Step 3: Read both verdicts, and do not merge them\n\n```bash\nfor i in $(seq 1 24); do\n curl -s \"https://scanmalware.com/api/v1/scan/$SCAN_ID/summary\" -o summary.json\n jq -e '.security_verdict.risk_level != null' summary.json >/dev/null && break\n sleep 5\ndone\njq '{url, final_url, risk_score,\n verdict: .security_verdict.verdict,\n risk_level: .security_verdict.risk_level,\n risk_factors: .security_verdict.risk_factors}' summary.json\n\nfor i in $(seq 1 30); do\n if curl -sf \"https://scanmalware.com/api/v1/ai/$SCAN_ID\" -o ai.json; then break; fi\n sleep 10\ndone\njq '.results.analysis | {classification, scam_type, risk_score,\n confidence_percentage, impersonated_brand, key_evidence}' ai.json\n```\n\n- `security_verdict` is the analyzer verdict, built from the scan's own checks (forms,\n URL, redirects, Safe Browsing, TLS, domain age). Branch on `risk_level` (observed values:\n `low`, `medium`, `high`, `malicious`, `unknown`), not on the `verdict` display string,\n which can carry a parenthetical such as `Malicious (Instagram Phishing, cloaked from\n scanners)`. `risk_score` is 0 to 100, higher is worse.\n- The AI analysis is separate. `classification` is one of `LEGITIMATE`, `LOW_RISK`,\n `MODERATE_RISK`, `HIGH_RISK`, `CONFIRMED_SCAM`, `NOT_ASSESSED`.\n- Both fill in after `status` becomes `completed`: `security_verdict` is empty for about\n 15 seconds and `/ai/{scan_id}` returns 404 for up to a few minutes. Empty is not clean.\n- The two can disagree. Read `key_evidence` before acting on a `CONFIRMED_SCAM`: when it\n cites only a new domain plus a login form, with no impersonation or kit match, treat\n it as unconfirmed.\n\n### Step 4: Find out where the page really ended\n\n```bash\ncurl -s \"https://scanmalware.com/api/v1/result/$SCAN_ID\" -o result.json\njq '{url, final_url, title, redirects: [.redirect_chains[] | {from, to, status}]}' result.json\n```\n\n`redirect_chains` records HTTP redirects. A script-driven navigation can move the page to\nanother host without an entry there, so compare the hosts of `url` and `final_url`\ndirectly. If they differ, artifacts captured at the end of the load (favicon, screenshot,\nfinal-page scripts) may belong to the destination, which is often the impersonated brand.\n\n### Step 5: Extract pivot values from attacker-controlled content\n\n```bash\njq '{favicon: (.favicon_hash | {url, mmh3_hash, md5_hash}),\n screenshot: (.screenshot_hashes | {dhash_hex, phash_hex}),\n primary_ip, primary_asn}' result.json\n\n# Scripts served from the entry host: the strongest kit identifiers\njq -r '(.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) as $entry\n | .javascript_files[]\n | select((.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) == $entry)\n | [.content_sha256, .url] | @tsv' result.json\n\ncurl -s \"https://scanmalware.com/api/v1/jarm/scan/$SCAN_ID\" -o jarm.json\njq -r '.signatures[] | [.host, .jarm_signature, .is_known] | @tsv' jarm.json\n```\n\nDiscard a favicon whose `url` is on the redirect destination. `mmh3_hash` follows the\nShodan `http.favicon.hash` convention, so the same value can be searched there.\n`primary_ip` is in CIDR form (`188.114.96.9/32`); drop the suffix before searching.\n`is_known: true` on a JARM means it matches a known signature listed in\n`matched_domains`.\n\n### Step 6: Count and read each pivot before trusting it\n\n```bash\n# Runs an SMQL query and saves the response to smql.json\nsmql() {\n curl -s -G https://scanmalware.com/api/v1/search/smql \\\n --data-urlencode \"q=$1\" --data-urlencode \"limit=${2:-25}\" -o smql.json\n}\n\nsmql 'favicon_hash:-165377145'\njq '{total: .pagination.total_items, exact: .pagination.exact_count,\n hosts: ([.results[].url | capture(\"^https?://(?<h>[^/:?#]+)\").h] | unique | length),\n titles: ([.results[].title] | group_by(.) | map({title: .[0], n: length}))}' smql.json\n```\n\nA pivot is useful when its matches render the same page on many hosts (a kit), and useless\nwhen they are unrelated sites (a platform). `exact_count: false` means the count hit the\n10,000 cap. The same judgement for every pivot of a scan, in one call:\n\n```bash\npython3 scripts/agent.py pivots \"$SCAN_ID\" --output pivots.json\n```\n\nEach pivot gets `matches`, the distinct entry hosts and title agreement among the newest\n25 hits, and an `assessment` of `unique`, `cluster`, `broad` or `mixed`, or `drop` for a\nfavicon served from the redirect destination. The labels are a first cut; read the hits\nbehind any pivot you intend to block on.\n\n### Step 7: Expand with SMQL combinations\n\n```bash\n# Current activity of one kit script\nsmql 'js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72 submitted:last7d' 100\njq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url] | @tsv)' smql.json\n\n# Lure title, excluding the real brand, limited to AI-confirmed scams\nsmql 'title:\"The Courier Guy\" -domain:thecourierguy.co.za verdict:CONFIRMED_SCAM' 50\njq -r '.results[].url' smql.json\n```\n\n`GET /api/v1/search/smql/filters` lists all filters with types and allowed values. An\nunknown filter returns HTTP 400 with `error.message` and a `suggested_query`.\n\n### Step 8: Enumerate hosts under an actor-registered domain\n\nOnly for domains the actor registered, never for a shared platform such as `pages.dev`:\n\n```bash\ncurl -s \"https://scanmalware.com/api/v1/hosts/$DOMAIN?subdomains_only=true&sources=observed,scripts\" -o hosts.json\njq '{scans_examined, sources}' hosts.json\n\ncurl -s \"https://scanmalware.com/api/v1/ct/dns/$DOMAIN\" -o ctdns.json\njq '{degraded, degraded_reason, stale, total_records, subdomain_count, subdomains}' ctdns.json\n```\n\nIf `degraded` is `true`, the zeros are not an answer; retry. An empty `ip` on a CT record\nmeans the name did not resolve when observed (`resolution_error` says why).\n\n## Key Concepts\n\n| Concept | What it means for the pivot |\n|---|---|\n| Entry host vs final host | `url` is what was submitted; `final_url` is where the browser ended. Verdicts and kit artifacts belong to the entry host. |\n| `matched_on` | Field on `/domains/{domain}/scans` rows that separates scans that started on a domain from scans that only landed there. |\n| Scanner cloaking | Many kits send visitors that look like scanners to the real brand site, so the scan ends on the brand and the brand's domain collects the phishing scan. |\n| Shared platform | Cloudflare Pages, R2, Netlify, Vercel and similar give every tenant the same edge IPs and TLS stack; their JARM, IP and ASN cluster the platform, not the actor. |\n| Kit artifact | Script SHA-256 from the entry host, favicon MMH3, screenshot dHash, distinctive title. These follow the kit across hosts. |\n| Pivot selectivity | Judge by what the matches look like, not by the count alone: 650 copies of one kit is a strong pivot; 7,000 unrelated sites on one IP is not. |\n| Two verdicts | `security_verdict` (analyzer checks, `risk_level`) and the AI `classification` are independent and can disagree. |\n| SMQL | ScanMalware Query Language: `field:value`, `AND`/`OR`, `-field:value`, quotes, ranges (`a..b`), `has:feature`, relative dates (`last7d`). |\n\n## Tools & Systems\n\n- **ScanMalware REST API**: https://scanmalware.com/api-docs (OpenAPI document at\n https://scanmalware.com/openapi.json). Endpoints used here are listed in\n `references/api-reference.md` with the response fields verified against the live API.\n- **SMQL filter list**: `GET https://scanmalware.com/api/v1/search/smql/filters`.\n- **ScanMalware MCP server**: `https://mcp.scanmalware.com/mcp` (streamable HTTP, no\n authentication), installed with this plugin, exposes the same operations as MCP tools.\n Its `submit_scan` tool has no default visibility: `scan_type` must be given on every\n call, while the REST endpoint in Step 2 falls back to `public` when it is left out.\n- **scripts/agent.py**: `history`, `pivots`, `search` and `submit` subcommands; standard\n library only; anonymous, sends no credentials.\n- **jq**: https://jqlang.org/manual/\n\n## Common Scenarios\n\n### Scenario: The reported URL bounces the scanner to the real brand\n\nA Meta \"Page Appeal\" lure on a disposable `pages.dev` host ends its scan on\n`www.facebook.com`. The analyzer verdict is `malicious` and the AI classification is\n`CONFIRMED_SCAM`, but the favicon is Facebook's. On 2026-09-26, 97 of the 100 newest\n`facebook.com` history rows were `[\"final_url\"]`, 94 of them from `pages.dev` entry\nhosts. The pivot that works is the kit bundle served from the entry host\n(`static/js/main.*.js`): its SHA-256 matched 651 archived scans, the sampled ones all\ntitled \"Meta for Business | Page Appeal\" on different disposable hosts. The entry host's\nJARM and IP each matched thousands of unrelated Cloudflare-hosted sites and were dropped.\nBlock the entry hosts, never `facebook.com`, and never `pages.dev` as a whole.\n\n### Scenario: A courier-fee scam on object storage\n\nA \"The Courier Guy\" shipping-fee page on an `r2.dev` bucket posts card details to a\nsecond page. There is no entry-host script to pivot on, but on 2026-09-26 the favicon\nMMH3 (`favicon_hash:-165377145`) matched 42 scans, every one titled \"The Courier Guy\",\nacross 22 entry hosts on `r2.dev` and `netlify.app`. Three of them entered through\n`tinyurl.com` and `snip.ly` links whose `final_url` is a kit bucket: the shortener link is\nthe delivery URL to hunt for in mail logs, and the bucket in `final_url` is the host to\nreport. The screenshot dHash is narrower (17 scans) and confirms the same lure.\n\n### Scenario: An AI `CONFIRMED_SCAM` on a brand-new domain with its own login page\n\n`key_evidence` cites only \"domain registered 0 days ago\" and \"login form captures email\nand password\", with no impersonated brand and no kit match. That is the shape of a new\nlegitimate service as often as a phishing page. Pivot on its artifacts anyway: if nothing\nelse in the archive shares them and the analyzer verdict is `low`, record it as unconfirmed\nrather than blocking.\n\n## Output Format\n\n```json\n{\n \"seed\": {\"scan_id\": \"d6b8750f-2d1e-4a25-8a13-6817bda5d930\",\n \"url\": \"hxxp://zermuri-mpt-felvani-p4d7me83[.]pages[.]dev/\",\n \"final_url\": \"https://www.facebook.com/\",\n \"risk_level\": \"malicious\", \"ai_classification\": \"CONFIRMED_SCAM\",\n \"cloaked_to\": \"www.facebook.com\"},\n \"pivots_used\": [\n {\"type\": \"entry_host_script_sha256\",\n \"query\": \"js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72\",\n \"matches\": 651, \"assessment\": \"cluster\"}\n ],\n \"pivots_rejected\": [\n {\"type\": \"jarm_entry_host\", \"matches\": 10000, \"reason\": \"Cloudflare Pages edge\"},\n {\"type\": \"favicon_mmh3\", \"reason\": \"served from the redirect destination\"}\n ],\n \"related_entry_hosts\": [\"berqelo-mpt-zanvuri-p2d6me93[.]pages[.]dev\"],\n \"do_not_block\": [\"facebook.com\", \"pages.dev\"]\n}\n```\n\n`assets/template.md` is the matching analyst report.\n\n## Verification Checklist\n\n- [ ] Every verdict in the report is attributed to an entry host, and every domain-history\n row used was checked for `matched_on == [\"final_url\"]` and for a `url` host that is a\n subdomain rather than the domain itself.\n- [ ] Both verdicts were read after they populated (no empty `security_verdict`, no 404\n from `/ai/{scan_id}` treated as clean).\n- [ ] Each pivot kept has its match count and a sample of hits that render the same page.\n- [ ] No IP, ASN or JARM of a shared platform, and no favicon from a redirect destination,\n is in the block list.\n- [ ] Indicators are defanged in the written report.\n""---\nname: pivoting-phishing-infrastructure-with-scanmalware\ndescription: >-\n Expands one phishing or fake-download URL into its wider campaign using the ScanMalware\n public scan archive (no API key needed): redirect-aware domain history (matched_on),\n SMQL searches on each kit artifact (script SHA-256 including inline scripts, favicon\n MMH3, screenshot hash, page title) and on the operator's own account keys (analytics\n IDs, chat-widget licenses, CAPTCHA site keys), plus JARM, IP and CT-derived subdomains,\n then judges which pivots are selective enough to hunt or block on. Use when a reported\n phishing URL needs its sibling hosts found, when checking how an already scanned page\n was judged, or when a verdict must be attributed to the right host after a redirect.\n Keywords: ScanMalware, SMQL, matched_on, final_url, js_sha256, tracking_id,\n favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking.\nlicense: Apache-2.0\n---\n# Pivoting Phishing Infrastructure with ScanMalware\n\n## Overview\n\nA single phishing URL is rarely the whole campaign. Kits are redeployed across dozens of\ndisposable hosts, and the pages share artifacts that survive the move: the same bundled\nJavaScript, the same favicon, the same rendered screenshot, the same page title. Many also\ncarry the operator's own accounts, such as an analytics ID or a live-chat license, which\nfollow the operator even when the kit is rebuilt or moved to another hosting platform. A\npublic archive of rendered scans lets an analyst search for those artifacts and find the\nsibling hosts that were already scanned by someone else.\n\nScanMalware (scanmalware.com, operated by Triop AB) runs submitted URLs through isolated\nanalysis environments, records what each page loads and runs, and keeps the public scans\nsearchable through a REST API and the SMQL query language. The read endpoints and\nanonymous scan submission need no account or API key.\nThis skill covers the pivot procedure and two traps that produce wrong conclusions if\nignored: verdicts attributed to a redirect destination, and pivot values that describe a\nhosting platform rather than an actor.\n\n## When to Use\n\n- A user reported a phishing URL and you need the other hosts running the same kit.\n- You want to know whether a URL or domain was already scanned, and what it did, before\n submitting it again.\n- A domain appears in scan results with a malicious verdict and you must decide whether\n the verdict belongs to that domain or to a page that redirected to it.\n- You have a kit artifact (script SHA-256, favicon hash, page title) or an account key\n (analytics ID, chat-widget license, CAPTCHA site key) and want every archived page that\n carries it, newest first.\n- You need to decide which indicators from a phishing page are safe to block on without\n blocking a shared hosting platform.\n\n## Prerequisites\n\n- `curl` and `jq` 1.6 or later, or the ScanMalware MCP server that comes with this plugin\n (see Tools & Systems for the tool behind each step).\n- Outbound HTTPS to `scanmalware.com`. No account or API key is needed for anything in\n this workflow except private scans.\n- Rate limits are per source IP: 600 requests per minute anonymously (60 per minute for\n the hash-search pages); a ScanMalware account raises them. A 429 response carries\n `Retry-After`; wait that many seconds.\n- OPSEC: `public` scans (the default) are visible to anyone. If the URL carries a victim's\n email address, a per-victim link code or a tracking ID, submit it as `unlisted`\n (reachable only by scan ID, absent from search) or `private` (requires a ScanMalware\n account; anonymous requests get HTTP 401). Submitting also makes the scanner fetch the\n URL, which a kit operator can observe.\n\n## Workflow\n\n### Step 1: Check the archive, and read `matched_on`\n\n```bash\nDOMAIN=facebook.com\ncurl -s \"https://scanmalware.com/api/v1/domains/$DOMAIN/scans?limit=100\" -o history.json\njq -r '.[] | [(.matched_on | join(\"+\")), .scan_id, .url, .final_url] | @tsv' history.json\n```\n\nThe endpoint lists scans of the domain and its subdomains, matched on the entry host or\nthe final host after redirects. `matched_on` says which:\n\n| `matched_on` | Meaning | Whose verdict is it |\n|---|---|---|\n| `[\"url\"]` | Scan was submitted for this domain or one of its subdomains | The host in `url` |\n| `[\"url\",\"final_url\"]` | Started and ended under this domain | The host in `url` |\n| `[\"final_url\"]` | Started elsewhere and landed here | The entry host in `url`, not this domain |\n\n`\"url\"` in `matched_on` is not enough to give a verdict to the domain itself. The rows\ninclude subdomains, and on a shared platform a subdomain is somebody else's site: the\nnewest `github.io` rows matched on `url` are tenant pages such as\n`trustwallet-team-support.github.io`. Attribute a verdict to the domain only when the\nhost in `url` is the domain or its `www.` name.\n\nSplit the rows before reading any verdict:\n\n```bash\njq --arg d \"$DOMAIN\" '\n def host: capture(\"^https?://(?<h>[^/:?#]+)\").h | ascii_downcase;\n {this_host: [.[] | select(any(.matched_on[]; . == \"url\"))\n | select((.url | host) == $d or (.url | host) == \"www.\" + $d)] | length,\n subdomain_hosts: [.[] | select(any(.matched_on[]; . == \"url\"))\n | select((.url | host) != $d and (.url | host) != \"www.\" + $d)] | length,\n landed_here_only: [.[] | select(.matched_on == [\"final_url\"])] | length}' history.json\n```\n\nFor a large brand the second number usually dominates: many phishing kits send scanners\nto the real site to hide the lure, so the brand's domain collects scans of phishing pages it\nnever hosted. SMQL `domain:` has the same property (it matches the final host too) and has\nno `matched_on` field, so compare the hosts of `url` and `final_url` on every SMQL row.\n\n### Step 2: Submit the URL if the archive has nothing current\n\n```bash\nSUSPECT_URL='https://example.com/'\njq -n --arg u \"$SUSPECT_URL\" '{url: $u, scan_type: \"unlisted\"}' > submit-body.json\ncurl -s -X POST https://scanmalware.com/api/v1/scan \\\n -H 'Content-Type: application/json' --data-binary @submit-body.json -o submit.json\nSCAN_ID=$(jq -r .scan_id submit.json)\necho \"$SCAN_ID\"\n\nfor i in $(seq 1 60); do\n curl -s \"https://scanmalware.com/api/v1/result/$SCAN_ID/progress\" -o progress.json\n jq -e '.status | test(\"^(completed|failed|error)$\")' progress.json >/dev/null && break\n sleep 5\ndone\n```\n\n`scan_type` is `public`, `unlisted` or `private`. The response carries `scan_id`,\n`status` (`queued`), `message`, `scan_type` and `submitted_at`.\n\n### Step 3: Read both verdicts, and do not merge them\n\n```bash\nfor i in $(seq 1 24); do\n curl -s \"https://scanmalware.com/api/v1/scan/$SCAN_ID/summary\" -o summary.json\n jq -e '.security_verdict.risk_level != null' summary.json >/dev/null && break\n sleep 5\ndone\njq '{url, final_url, risk_score,\n verdict: .security_verdict.verdict,\n risk_level: .security_verdict.risk_level,\n risk_factors: .security_verdict.risk_factors}' summary.json\n\nfor i in $(seq 1 30); do\n if curl -sf \"https://scanmalware.com/api/v1/ai/$SCAN_ID\" -o ai.json; then break; fi\n sleep 10\ndone\njq '.results.analysis | {classification, scam_type, risk_score,\n confidence_percentage, impersonated_brand, key_evidence}' ai.json\n```\n\n- `security_verdict` is the analyzer verdict, built from the scan's own checks (forms,\n URL, redirects, Safe Browsing, TLS, domain age). Branch on `risk_level` (observed values:\n `low`, `medium`, `high`, `malicious`, `unknown`), not on the `verdict` display string,\n which can carry a parenthetical such as `Malicious (Instagram Phishing, cloaked from\n scanners)`. `risk_score` is 0 to 100, higher is worse.\n- The AI analysis is separate. `classification` is one of `LEGITIMATE`, `LOW_RISK`,\n `MODERATE_RISK`, `HIGH_RISK`, `CONFIRMED_SCAM`, `NOT_ASSESSED`.\n- Both fill in after `status` becomes `completed`: `security_verdict` is empty for about\n 15 seconds and `/ai/{scan_id}` returns 404 for up to a few minutes. Empty is not clean.\n- The two can disagree. Read `key_evidence` before acting on a `CONFIRMED_SCAM`: when it\n cites only a new domain plus a login form, with no impersonation or kit match, treat\n it as unconfirmed.\n- A page without a form is not clean. A lure that offers a download (a fake desktop app\n or document viewer) gives the form checks nothing to weigh, and the scanner does not\n click buttons, so the file behind a Download button is never fetched and an empty\n malware result says nothing about it. On 2026-10-20 such a page scored `low` (27) with\n brand impersonation as its only risk factor, while the AI classified it\n `CONFIRMED_SCAM`, `scam_type` \"Malware Distribution\".\n- Verdicts are per scan, and copies of one kit on different hosts can disagree. Once a\n pivot ties scans to one kit (Step 6), judge the kit by its best-evidenced scan, not by\n its weakest verdict. A `LOW_RISK` whose `key_evidence` calls the brand the site's own\n (\"self-branding\") because the brand name is in the hostname is unresolved, not clean:\n check who owns the registered domain. A vendor-named subdomain on a company's own,\n established domain (a help desk at `zendesk.<company>`) can be legitimate; the brand name\n on a domain registered days ago that pushes a download is not.\n\n### Step 4: Find out where the page really ended\n\n```bash\ncurl -s \"https://scanmalware.com/api/v1/result/$SCAN_ID\" -o result.json\njq '{url, final_url, title, redirects: [.redirect_chains[] | {from, to, status}]}' result.json\n```\n\n`redirect_chains` records HTTP redirects. A script-driven navigation can move the page to\nanother host without an entry there, so compare the hosts of `url` and `final_url`\ndirectly. If they differ, artifacts captured at the end of the load (favicon, screenshot,\nfinal-page scripts) may belong to the destination, which is often the impersonated brand.\n\n### Step 5: Extract pivot values from attacker-controlled content\n\n```bash\njq '{favicon: (.favicon_hash | {url, mmh3_hash, md5_hash}),\n screenshot: (.screenshot_hashes | {dhash_hex, phash_hex}),\n primary_ip, primary_asn}' result.json\n\n# Scripts served from the entry host, and inline scripts: the strongest kit identifiers\njq -r '(.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) as $entry\n | .javascript_files[]\n | select((.url | startswith(\"inline://\"))\n or ((.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) == $entry))\n | [.content_sha256, .url] | @tsv' result.json\n\n# Account keys found on the page: analytics IDs, chat-widget licenses, CAPTCHA site keys\ncurl -s \"https://scanmalware.com/api/v1/tracking-keys/by-scan/$SCAN_ID\" -o trackers.json\njq -r '.tracking_keys[] | [.tracker_type, .tracking_key, .site_count] | @tsv' trackers.json\n\ncurl -s \"https://scanmalware.com/api/v1/jarm/scan/$SCAN_ID\" -o jarm.json\njq -r '.signatures[] | [.host, .jarm_signature, .is_known] | @tsv' jarm.json\n\n# Who served the entry page, from its own response headers\njq -r '(.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) as $entry\n | .http_transactions[]\n | select((.request.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) == $entry)\n | .response.headers | to_entries[]\n | select(.key | ascii_downcase | test(\"^(server|x-vercel-id|x-nf-request-id|cf-ray|x-served-by|x-amz-cf-id)$\"))\n | \"\\(.key | ascii_downcase): \\(.value)\"' result.json | sort -u\n```\n\n- Inline scripts have URLs of the form `inline://<scan_id>/script_<n>_<sha prefix>`, which\n name no host, so a filter on the entry host alone drops them. They are often the kit's\n most specific code, for example a chat-widget loader with the operator's license number\n in it. After a redirect they may come from the destination page instead.\n- An account key is an account the operator signed up for, so it links hosts across kit\n rebuilds and hosting platforms. `site_count` is how many archived sites carry it: a key\n on thousands of unrelated sites belongs to a platform (a challenge page's CAPTCHA key, a\n hosting provider's own analytics), not to the actor. The index also reads keys from the\n page source, so a widget that never loads still yields its key.\n- `GET /api/v1/scans/$SCAN_ID/pivot-values` lists further searchable values of the scan:\n inline script hashes (`js_sha256:`), script hosts (`script_host:`), hosts named in script\n source (`js_host:`), resource SHA-256s (`resource_sha256:`), IDS signatures, hosts the\n deobfuscator revealed, Telegram bots and chats, and more; the API documentation names the\n filter for each. Its `tracking_keys` carry no `site_count`; use the call above, or count\n the key with `sites` in Step 6.\n- Use a favicon only when its `url` is on the entry host. One fetched from the redirect\n destination, or hotlinked from the impersonated brand's own site, describes the brand.\n `mmh3_hash` follows the Shodan `http.favicon.hash` convention, so the same value can be\n searched there.\n- `primary_ip` is in CIDR form (`188.114.96.9/32`); drop the suffix before searching.\n `is_known: true` on a JARM means it matches a known signature listed in\n `matched_domains`.\n- Name the hosting platform from the entry page's headers (`server: Vercel`,\n `x-vercel-id`, `cf-ray`, `x-nf-request-id` for Netlify), not from the ASN name: hosts\n served by Vercel showed `AS16509 Amazon.com` in the archive's IP table. Filter to the\n entry host first; the page's other requests (a favicon hotlinked from the brand, say)\n carry their own `server` headers.\n\n### Step 6: Count and read each pivot before trusting it\n\n```bash\n# Runs an SMQL query and saves the response to smql.json\nsmql() {\n curl -s -G https://scanmalware.com/api/v1/search/smql \\\n --data-urlencode \"q=$1\" --data-urlencode \"limit=${2:-25}\" -o smql.json\n}\n\nsmql 'favicon_hash:-165377145'\njq '{total: .pagination.total_items, exact: .pagination.exact_count,\n hosts: ([.results[].url | capture(\"^https?://(?<h>[^/:?#]+)\").h] | unique | length),\n titles: ([.results[].title] | group_by(.) | map({title: .[0], n: length}))}' smql.json\n```\n\nAn account key is searched by tracker type and key, both quoted:\n\n```bash\nsmql 'tracker:\"livechat\" tracking_id:\"19281545\"'\njq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url, .title] | @tsv)' smql.json\n```\n\nA pivot is useful when its matches render the same page on many hosts (a kit), and useless\nwhen they are unrelated sites (a platform). `exact_count: false` means the count hit the\n10,000 cap. Title agreement cannot judge a title pivot, since every hit of a title search\ncarries the title; judge a title by its count and confirm each host it finds with a\nsecond pivot.\n\nTo judge every pivot of a scan at once, write one SMQL query per line and count them in\none request (up to 25). Leave out a favicon served from another host and, when the page\nredirected, inline scripts and account keys that may be the destination's:\n\n```bash\ncat > pivots.txt <<'EOF'\njs_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72\ntitle:\"Meta for Business | Page Appeal\"\ndhash:c000000000000000\nip:188.114.96.9\nEOF\njq -R -s '{queries: (split(\"\\n\") | map(select(length > 0))), sites: true}' pivots.txt > counts-body.json\ncurl -s -X POST https://scanmalware.com/api/v1/search/smql/counts \\\n -H 'Content-Type: application/json' --data-binary @counts-body.json -o counts.json\njq -r '.counts | to_entries[]\n | [.key, .value.total_items, .value.exact_count, .value.sites, .value.elevated_sites,\n .value.hosts, .value.shared_widely] | map(tostring) | @tsv' counts.json\n```\n\nWith `sites: true`, a query with 10 or more matches also gets, over up to 200 of its\nmatching scans, `sites` (distinct registrable domains), `elevated_sites` (those rated high\nrisk or worse), `hosts` (distinct entry hosts) and `shared_widely` (many sites, and no more\nof them high risk than across the whole archive). Read them as:\n\n| Count result | Assessment |\n|---|---|\n| `total_items` is 1 | unique: an exact indicator that finds no siblings |\n| `shared_widely` is `true` | broad: a common asset or a platform; drop it |\n| `elevated_sites` close to `sites`, several `hosts` | cluster: the value travels with a kit; keep it |\n| `elevated_sites` well below `sites` | mixed: a shared IP, platform key or common asset; read the hits first |\n| Under 10 matches, so no `sites` | read the hits with `smql` above |\n\nOn 2026-11-12, for the Meta example below, the entry-host script matched 1,111 scans on 56\nsites, all 56 high risk (cluster); the title 6,145 scans on 13 sites, all high risk\n(cluster, but a title, so confirm each host); the blank-page screenshot dHash came back\n`shared_widely` (broad); and the Cloudflare edge IP had 93 of 166 sites high risk (mixed:\nthe platform hosts the kit among much else). A count that ran out of time has\n`count_unavailable: true`; send it again with `\"patient\": true` (at most 4 queries). The\nlabels are a first cut; read the hits behind any pivot you intend to block on.\n\nTo turn the kept pivots into a host list, put the ones assessed cluster in `kept.txt`, one\nper line, fetch the newest 100 hits of each and group them by entry host:\n\n```bash\nn=0\nwhile IFS= read -r q; do\n n=$((n + 1))\n curl -s -G https://scanmalware.com/api/v1/search/smql \\\n --data-urlencode \"q=$q\" --data-urlencode \"limit=100\" --data-urlencode \"sort=newest\" \\\n -o \"hits-$n.json\"\ndone < kept.txt\n\njq -s '[.[] | .query as $q | .results[]\n | {host: (.url | capture(\"^https?://(?<h>[^/:?#]+)\").h | ascii_downcase),\n q: $q, at: .submitted_at}]\n | group_by(.host)\n | map({entry_host: .[0].host, pivot_count: ([.[].q] | unique | length),\n found_by: ([.[].q] | unique),\n first_seen: (map(.at) | min), last_seen: (map(.at) | max)})\n | sort_by(.first_seen)' hits-*.json > related.json\njq -r '.[] | [.first_seen[0:10], .pivot_count, .entry_host, (.found_by | join(\" + \"))] | @tsv' related.json\n```\n\nA host reached by one pivot only, a title in particular, needs a second look. Then ask\nwhich account keys the cluster shares, including keys the seed does not carry, with one\nfacet query over the kept pivots joined by `OR`:\n\n```bash\ncurl -s -G https://scanmalware.com/api/v1/search/smql/facets \\\n --data-urlencode 'q=title:\"Docusign - Download to Review & Sign Document\" OR tracking_id:19281545' \\\n --data-urlencode 'fields=tracking_id,primary_ip' --data-urlencode 'size=25' -o facets.json\njq -r '\"\\(.sample.scans) scans\",\n (.facets[] | .field as $f | .buckets[] | [$f, .count, (.label // \"\"), .query] | @tsv)' facets.json\n```\n\nEach value carries `count` (scans in the sample, the newest 1,000 matches by default) and\n`query`, which is ready to count with `sites` as above. A key on most scans of the cluster\nthat the seed lacks is often a second operator account or another generation of the kit;\ncount it before treating it as an actor link, since a key that is `shared_widely` belongs\nto a platform.\n\n### Step 7: Expand with SMQL combinations\n\n```bash\n# Current activity of one kit script\nsmql 'js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72 submitted:last7d' 100\njq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url] | @tsv)' smql.json\n\n# Lure title, excluding the real brand, limited to AI-confirmed scams\nsmql 'title:\"The Courier Guy\" -domain:thecourierguy.co.za verdict:CONFIRMED_SCAM' 50\njq -r '.results[].url' smql.json\n\n# New hosts on one operator's chat account this week\nsmql 'tracker:\"livechat\" tracking_id:\"19470622\" submitted:last7d' 100\njq -r '.results[] | [.submitted_at[0:10], .url] | @tsv' smql.json\n```\n\n`GET /api/v1/search/smql/filters` lists all filters with types and allowed values. An\nunknown filter returns HTTP 400 with `error.message` and a `suggested_query`.\n\n### Step 8: Enumerate hosts under an actor-registered domain\n\nOnly for domains the actor registered, never for a shared platform such as `pages.dev`:\n\n```bash\ncurl -s \"https://scanmalware.com/api/v1/hosts/$DOMAIN?subdomains_only=true&sources=observed,scripts\" -o hosts.json\njq '{scans_examined, sources}' hosts.json\n\ncurl -s \"https://scanmalware.com/api/v1/ct/dns/$DOMAIN\" -o ctdns.json\njq '{degraded, degraded_reason, stale, total_records, subdomain_count, subdomains}' ctdns.json\n```\n\nIf `degraded` is `true`, the zeros are not an answer; retry. An empty `ip` on a CT record\nmeans the name did not resolve when observed (`resolution_error` says why).\n\n## Key Concepts\n\n| Concept | What it means for the pivot |\n|---|---|\n| Entry host vs final host | `url` is what was submitted; `final_url` is where the browser ended. Verdicts and kit artifacts belong to the entry host. |\n| `matched_on` | Field on `/domains/{domain}/scans` rows that separates scans that started on a domain from scans that only landed there. |\n| Scanner cloaking | Many kits send visitors that look like scanners to the real brand site, so the scan ends on the brand and the brand's domain collects the phishing scan. |\n| Shared platform | Cloudflare Pages, R2, Netlify, Vercel and similar give every tenant the same edge IPs and TLS stack; their JARM, IP and ASN cluster the platform, not the actor. |\n| Kit artifact | Script SHA-256 from the entry host or an inline script, favicon MMH3 from the entry host, screenshot dHash, distinctive title. These follow the kit across hosts. |\n| Account key | An analytics ID, chat-widget license or CAPTCHA site key the operator registered. It follows the operator across kit rebuilds and hosting platforms; one on thousands of unrelated sites belongs to a platform. |\n| Pivot selectivity | Judge by what the matches look like, not by the count alone: 650 copies of one kit is a strong pivot; 7,000 unrelated sites on one IP is not. |\n| Two verdicts | `security_verdict` (analyzer checks, `risk_level`) and the AI `classification` are independent and can disagree. |\n| SMQL | ScanMalware Query Language: `field:value`, `AND`/`OR`, `-field:value`, quotes, ranges (`a..b`), `has:feature`, relative dates (`last7d`). |\n\n## Tools & Systems\n\n- **ScanMalware REST API**: https://scanmalware.com/api-docs (OpenAPI document at\n https://scanmalware.com/openapi.json). Endpoints used here are listed in\n `references/api-reference.md` with the response fields verified against the live API.\n- **SMQL filter list**: `GET https://scanmalware.com/api/v1/search/smql/filters`.\n- **ScanMalware MCP server**: `https://mcp.scanmalware.com/mcp` (streamable HTTP, no\n authentication), installed with this plugin, exposes the same operations as MCP tools.\n Step 1's history with `matched_on` is `get_domain_scans` (not `get_domain_history`,\n which has no `matched_on`). The SMQL search is `search_smql` (`get_smql_filters` lists\n the filters), the Step 6 counts `count_smql_queries` (pass `sites: true`) and facets\n `get_smql_facets`, a scan's searchable values and account keys `get_scan_pivot_values`\n (keys without `site_count`), the scans carrying one key `search_tracking_key`, the host\n list `get_domain_hosts`, and DNS `get_dns_history` / `search_reverse_dns`.\n Its `submit_scan` tool has no default visibility: `scan_type` must be given on every\n call, while the REST endpoint in Step 2 falls back to `public` when it is left out.\n- **jq**: https://jqlang.org/manual/\n\n## Common Scenarios\n\n### Scenario: The reported URL bounces the scanner to the real brand\n\nA Meta \"Page Appeal\" lure on a disposable `pages.dev` host ends its scan on\n`www.facebook.com`. The analyzer verdict is `malicious` and the AI classification is\n`CONFIRMED_SCAM`, but the favicon is Facebook's. On 2026-09-26, 97 of the 100 newest\n`facebook.com` history rows were `[\"final_url\"]`, 94 of them from `pages.dev` entry\nhosts. The pivot that works is the kit bundle served from the entry host\n(`static/js/main.*.js`): its SHA-256 matched 651 archived scans, the sampled ones all\ntitled \"Meta for Business | Page Appeal\" on different disposable hosts. The entry host's\nJARM and IP each matched thousands of unrelated Cloudflare-hosted sites and were dropped.\nBlock the entry hosts, never `facebook.com`, and never `pages.dev` as a whole.\n\n### Scenario: A courier-fee scam on object storage\n\nA \"The Courier Guy\" shipping-fee page on an `r2.dev` bucket posts card details to a\nsecond page. There is no entry-host script to pivot on, but on 2026-09-26 the favicon\nMMH3 (`favicon_hash:-165377145`) matched 42 scans, every one titled \"The Courier Guy\",\nacross 22 entry hosts on `r2.dev` and `netlify.app`. Three of them entered through\n`tinyurl.com` and `snip.ly` links whose `final_url` is a kit bucket: the shortener link is\nthe delivery URL to hunt for in mail logs, and the bucket in `final_url` is the host to\nreport. The screenshot dHash is narrower (17 scans) and confirms the same lure.\n\n### Scenario: A fake desktop-app download behind a document lure\n\nA page titled \"Docusign - Download to Review & Sign Document\" tells the visitor to install\na \"Docusign Desktop\" app to open a document. It has no form, hotlinks DocuSign's own\nfavicon and loads no script from its own host, so the usual kit pivots are empty. On\n2026-10-20 the analyzer rated it `low` and the AI `CONFIRMED_SCAM` (\"Malware\nDistribution\"). The title matched 20 scans on 17 hosts since 2026-07-23, most named after\ncryptocurrency firms. The pivot that tied them to one operator was the inline LiveChat\nloader: its license 19281545 was on 3 hosts sharing one dedicated IP, and the account keys\nof the hosts it found led to a second license, 19470622, on 14 more hosts on Vercel and\nCloudflare. The oldest host\ncarried a third license of its own. Across the 20 scans the analyzer said 9 `low`,\n9 `medium` and 2 `high`, and the AI rated 2 of them `LOW_RISK` as \"self-branding\". No\nscan captured the installer: the Download button is script-driven, so the file has to be\nfetched from a live host by hand. Block the entry hosts and the dedicated IP and report\nthe chat licenses to the vendor; never block `docusign.com`, the Vercel or Cloudflare\nedge, or the dedicated IP's ASN, which held 943 unrelated scans.\n\n### Scenario: An AI `CONFIRMED_SCAM` on a brand-new domain with its own login page\n\n`key_evidence` cites only \"domain registered 0 days ago\" and \"login form captures email\nand password\", with no impersonated brand and no kit match. That is the shape of a new\nlegitimate service as often as a phishing page. Pivot on its artifacts anyway: if nothing\nelse in the archive shares them and the analyzer verdict is `low`, record it as unconfirmed\nrather than blocking.\n\n## Output Format\n\n```json\n{\n \"seed\": {\"scan_id\": \"d6b8750f-2d1e-4a25-8a13-6817bda5d930\",\n \"url\": \"hxxp://zermuri-mpt-felvani-p4d7me83[.]pages[.]dev/\",\n \"final_url\": \"https://www.facebook.com/\",\n \"risk_level\": \"malicious\", \"ai_classification\": \"CONFIRMED_SCAM\",\n \"cloaked_to\": \"www.facebook.com\"},\n \"pivots_used\": [\n {\"type\": \"entry_host_script_sha256\",\n \"query\": \"js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72\",\n \"matches\": 651, \"assessment\": \"cluster\"}\n ],\n \"pivots_rejected\": [\n {\"type\": \"jarm_entry_host\", \"matches\": 10000, \"reason\": \"Cloudflare Pages edge\"},\n {\"type\": \"favicon_mmh3\", \"reason\": \"served from the redirect destination\"}\n ],\n \"related_entry_hosts\": [\"berqelo-mpt-zanvuri-p2d6me93[.]pages[.]dev\"],\n \"do_not_block\": [\"facebook.com\", \"pages.dev\"]\n}\n```\n\n`assets/template.md` is the matching analyst report.\n\n## Verification Checklist\n\n- [ ] Every verdict in the report is attributed to an entry host, and every domain-history\n row used was checked for `matched_on == [\"final_url\"]` and for a `url` host that is a\n subdomain rather than the domain itself.\n- [ ] Both verdicts were read after they populated (no empty `security_verdict`, no 404\n from `/ai/{scan_id}` treated as clean).\n- [ ] Each pivot kept has its match count and a sample of hits that render the same page.\n- [ ] Every related host found by a title alone was confirmed with a second pivot (an\n account key, a script hash, a dedicated IP) or is marked unconfirmed.\n- [ ] No account key with a `site_count` far above the cluster size is used as an actor\n link.\n- [ ] No IP, ASN or JARM of a shared platform, and no favicon from a redirect destination\n or hotlinked from the brand, is in the block list.\n- [ ] Indicators are defanged in the written report.\n"Full snapshot data
{
"description": "Expands one phishing or fake-download URL into its wider campaign using the ScanMalware public scan archive (no API key needed): redirect-aware domain history (matched_on), SMQL searches on each kit artifact (script SHA-256 including inline scripts, favicon MMH3, screenshot hash, page title) and on the operator's own account keys (analytics IDs, chat-widget licenses, CAPTCHA site keys), plus JARM, IP and CT-derived subdomains, then judges which pivots are selective enough to hunt or block on. Use when a reported phishing URL needs its sibling hosts found, when checking how an already scanned page was judged, or when a verdict must be attributed to the right host after a redirect. Keywords: ScanMalware, SMQL, matched_on, final_url, js_sha256, tracking_id, favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking.",
"included_files": [
{
"relative_path": "LICENSE",
"size_in_bytes": 11255
},
{
"relative_path": "assets/template.md",
"size_in_bytes": 3190
},
{
"relative_path": "references/api-reference.md",
"size_in_bytes": 9751
},
{
"relative_path": "references/standards.md",
"size_in_bytes": 3193
},
{
"relative_path": "references/workflows.md",
"size_in_bytes": 6584
}
],
"name": "pivoting-phishing-infrastructure-with-scanmalware",
"skill_md_contents": "---\nname: pivoting-phishing-infrastructure-with-scanmalware\ndescription: >-\n Expands one phishing or fake-download URL into its wider campaign using the ScanMalware\n public scan archive (no API key needed): redirect-aware domain history (matched_on),\n SMQL searches on each kit artifact (script SHA-256 including inline scripts, favicon\n MMH3, screenshot hash, page title) and on the operator's own account keys (analytics\n IDs, chat-widget licenses, CAPTCHA site keys), plus JARM, IP and CT-derived subdomains,\n then judges which pivots are selective enough to hunt or block on. Use when a reported\n phishing URL needs its sibling hosts found, when checking how an already scanned page\n was judged, or when a verdict must be attributed to the right host after a redirect.\n Keywords: ScanMalware, SMQL, matched_on, final_url, js_sha256, tracking_id,\n favicon_hash, dhash, jarm, CONFIRMED_SCAM, phishing kit, scanner cloaking.\nlicense: Apache-2.0\n---\n# Pivoting Phishing Infrastructure with ScanMalware\n\n## Overview\n\nA single phishing URL is rarely the whole campaign. Kits are redeployed across dozens of\ndisposable hosts, and the pages share artifacts that survive the move: the same bundled\nJavaScript, the same favicon, the same rendered screenshot, the same page title. Many also\ncarry the operator's own accounts, such as an analytics ID or a live-chat license, which\nfollow the operator even when the kit is rebuilt or moved to another hosting platform. A\npublic archive of rendered scans lets an analyst search for those artifacts and find the\nsibling hosts that were already scanned by someone else.\n\nScanMalware (scanmalware.com, operated by Triop AB) runs submitted URLs through isolated\nanalysis environments, records what each page loads and runs, and keeps the public scans\nsearchable through a REST API and the SMQL query language. The read endpoints and\nanonymous scan submission need no account or API key.\nThis skill covers the pivot procedure and two traps that produce wrong conclusions if\nignored: verdicts attributed to a redirect destination, and pivot values that describe a\nhosting platform rather than an actor.\n\n## When to Use\n\n- A user reported a phishing URL and you need the other hosts running the same kit.\n- You want to know whether a URL or domain was already scanned, and what it did, before\n submitting it again.\n- A domain appears in scan results with a malicious verdict and you must decide whether\n the verdict belongs to that domain or to a page that redirected to it.\n- You have a kit artifact (script SHA-256, favicon hash, page title) or an account key\n (analytics ID, chat-widget license, CAPTCHA site key) and want every archived page that\n carries it, newest first.\n- You need to decide which indicators from a phishing page are safe to block on without\n blocking a shared hosting platform.\n\n## Prerequisites\n\n- `curl` and `jq` 1.6 or later, or the ScanMalware MCP server that comes with this plugin\n (see Tools & Systems for the tool behind each step).\n- Outbound HTTPS to `scanmalware.com`. No account or API key is needed for anything in\n this workflow except private scans.\n- Rate limits are per source IP: 600 requests per minute anonymously (60 per minute for\n the hash-search pages); a ScanMalware account raises them. A 429 response carries\n `Retry-After`; wait that many seconds.\n- OPSEC: `public` scans (the default) are visible to anyone. If the URL carries a victim's\n email address, a per-victim link code or a tracking ID, submit it as `unlisted`\n (reachable only by scan ID, absent from search) or `private` (requires a ScanMalware\n account; anonymous requests get HTTP 401). Submitting also makes the scanner fetch the\n URL, which a kit operator can observe.\n\n## Workflow\n\n### Step 1: Check the archive, and read `matched_on`\n\n```bash\nDOMAIN=facebook.com\ncurl -s \"https://scanmalware.com/api/v1/domains/$DOMAIN/scans?limit=100\" -o history.json\njq -r '.[] | [(.matched_on | join(\"+\")), .scan_id, .url, .final_url] | @tsv' history.json\n```\n\nThe endpoint lists scans of the domain and its subdomains, matched on the entry host or\nthe final host after redirects. `matched_on` says which:\n\n| `matched_on` | Meaning | Whose verdict is it |\n|---|---|---|\n| `[\"url\"]` | Scan was submitted for this domain or one of its subdomains | The host in `url` |\n| `[\"url\",\"final_url\"]` | Started and ended under this domain | The host in `url` |\n| `[\"final_url\"]` | Started elsewhere and landed here | The entry host in `url`, not this domain |\n\n`\"url\"` in `matched_on` is not enough to give a verdict to the domain itself. The rows\ninclude subdomains, and on a shared platform a subdomain is somebody else's site: the\nnewest `github.io` rows matched on `url` are tenant pages such as\n`trustwallet-team-support.github.io`. Attribute a verdict to the domain only when the\nhost in `url` is the domain or its `www.` name.\n\nSplit the rows before reading any verdict:\n\n```bash\njq --arg d \"$DOMAIN\" '\n def host: capture(\"^https?://(?<h>[^/:?#]+)\").h | ascii_downcase;\n {this_host: [.[] | select(any(.matched_on[]; . == \"url\"))\n | select((.url | host) == $d or (.url | host) == \"www.\" + $d)] | length,\n subdomain_hosts: [.[] | select(any(.matched_on[]; . == \"url\"))\n | select((.url | host) != $d and (.url | host) != \"www.\" + $d)] | length,\n landed_here_only: [.[] | select(.matched_on == [\"final_url\"])] | length}' history.json\n```\n\nFor a large brand the second number usually dominates: many phishing kits send scanners\nto the real site to hide the lure, so the brand's domain collects scans of phishing pages it\nnever hosted. SMQL `domain:` has the same property (it matches the final host too) and has\nno `matched_on` field, so compare the hosts of `url` and `final_url` on every SMQL row.\n\n### Step 2: Submit the URL if the archive has nothing current\n\n```bash\nSUSPECT_URL='https://example.com/'\njq -n --arg u \"$SUSPECT_URL\" '{url: $u, scan_type: \"unlisted\"}' > submit-body.json\ncurl -s -X POST https://scanmalware.com/api/v1/scan \\\n -H 'Content-Type: application/json' --data-binary @submit-body.json -o submit.json\nSCAN_ID=$(jq -r .scan_id submit.json)\necho \"$SCAN_ID\"\n\nfor i in $(seq 1 60); do\n curl -s \"https://scanmalware.com/api/v1/result/$SCAN_ID/progress\" -o progress.json\n jq -e '.status | test(\"^(completed|failed|error)$\")' progress.json >/dev/null && break\n sleep 5\ndone\n```\n\n`scan_type` is `public`, `unlisted` or `private`. The response carries `scan_id`,\n`status` (`queued`), `message`, `scan_type` and `submitted_at`.\n\n### Step 3: Read both verdicts, and do not merge them\n\n```bash\nfor i in $(seq 1 24); do\n curl -s \"https://scanmalware.com/api/v1/scan/$SCAN_ID/summary\" -o summary.json\n jq -e '.security_verdict.risk_level != null' summary.json >/dev/null && break\n sleep 5\ndone\njq '{url, final_url, risk_score,\n verdict: .security_verdict.verdict,\n risk_level: .security_verdict.risk_level,\n risk_factors: .security_verdict.risk_factors}' summary.json\n\nfor i in $(seq 1 30); do\n if curl -sf \"https://scanmalware.com/api/v1/ai/$SCAN_ID\" -o ai.json; then break; fi\n sleep 10\ndone\njq '.results.analysis | {classification, scam_type, risk_score,\n confidence_percentage, impersonated_brand, key_evidence}' ai.json\n```\n\n- `security_verdict` is the analyzer verdict, built from the scan's own checks (forms,\n URL, redirects, Safe Browsing, TLS, domain age). Branch on `risk_level` (observed values:\n `low`, `medium`, `high`, `malicious`, `unknown`), not on the `verdict` display string,\n which can carry a parenthetical such as `Malicious (Instagram Phishing, cloaked from\n scanners)`. `risk_score` is 0 to 100, higher is worse.\n- The AI analysis is separate. `classification` is one of `LEGITIMATE`, `LOW_RISK`,\n `MODERATE_RISK`, `HIGH_RISK`, `CONFIRMED_SCAM`, `NOT_ASSESSED`.\n- Both fill in after `status` becomes `completed`: `security_verdict` is empty for about\n 15 seconds and `/ai/{scan_id}` returns 404 for up to a few minutes. Empty is not clean.\n- The two can disagree. Read `key_evidence` before acting on a `CONFIRMED_SCAM`: when it\n cites only a new domain plus a login form, with no impersonation or kit match, treat\n it as unconfirmed.\n- A page without a form is not clean. A lure that offers a download (a fake desktop app\n or document viewer) gives the form checks nothing to weigh, and the scanner does not\n click buttons, so the file behind a Download button is never fetched and an empty\n malware result says nothing about it. On 2026-10-20 such a page scored `low` (27) with\n brand impersonation as its only risk factor, while the AI classified it\n `CONFIRMED_SCAM`, `scam_type` \"Malware Distribution\".\n- Verdicts are per scan, and copies of one kit on different hosts can disagree. Once a\n pivot ties scans to one kit (Step 6), judge the kit by its best-evidenced scan, not by\n its weakest verdict. A `LOW_RISK` whose `key_evidence` calls the brand the site's own\n (\"self-branding\") because the brand name is in the hostname is unresolved, not clean:\n check who owns the registered domain. A vendor-named subdomain on a company's own,\n established domain (a help desk at `zendesk.<company>`) can be legitimate; the brand name\n on a domain registered days ago that pushes a download is not.\n\n### Step 4: Find out where the page really ended\n\n```bash\ncurl -s \"https://scanmalware.com/api/v1/result/$SCAN_ID\" -o result.json\njq '{url, final_url, title, redirects: [.redirect_chains[] | {from, to, status}]}' result.json\n```\n\n`redirect_chains` records HTTP redirects. A script-driven navigation can move the page to\nanother host without an entry there, so compare the hosts of `url` and `final_url`\ndirectly. If they differ, artifacts captured at the end of the load (favicon, screenshot,\nfinal-page scripts) may belong to the destination, which is often the impersonated brand.\n\n### Step 5: Extract pivot values from attacker-controlled content\n\n```bash\njq '{favicon: (.favicon_hash | {url, mmh3_hash, md5_hash}),\n screenshot: (.screenshot_hashes | {dhash_hex, phash_hex}),\n primary_ip, primary_asn}' result.json\n\n# Scripts served from the entry host, and inline scripts: the strongest kit identifiers\njq -r '(.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) as $entry\n | .javascript_files[]\n | select((.url | startswith(\"inline://\"))\n or ((.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) == $entry))\n | [.content_sha256, .url] | @tsv' result.json\n\n# Account keys found on the page: analytics IDs, chat-widget licenses, CAPTCHA site keys\ncurl -s \"https://scanmalware.com/api/v1/tracking-keys/by-scan/$SCAN_ID\" -o trackers.json\njq -r '.tracking_keys[] | [.tracker_type, .tracking_key, .site_count] | @tsv' trackers.json\n\ncurl -s \"https://scanmalware.com/api/v1/jarm/scan/$SCAN_ID\" -o jarm.json\njq -r '.signatures[] | [.host, .jarm_signature, .is_known] | @tsv' jarm.json\n\n# Who served the entry page, from its own response headers\njq -r '(.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) as $entry\n | .http_transactions[]\n | select((.request.url | capture(\"^https?://(?<h>[^/:?#]+)\").h) == $entry)\n | .response.headers | to_entries[]\n | select(.key | ascii_downcase | test(\"^(server|x-vercel-id|x-nf-request-id|cf-ray|x-served-by|x-amz-cf-id)$\"))\n | \"\\(.key | ascii_downcase): \\(.value)\"' result.json | sort -u\n```\n\n- Inline scripts have URLs of the form `inline://<scan_id>/script_<n>_<sha prefix>`, which\n name no host, so a filter on the entry host alone drops them. They are often the kit's\n most specific code, for example a chat-widget loader with the operator's license number\n in it. After a redirect they may come from the destination page instead.\n- An account key is an account the operator signed up for, so it links hosts across kit\n rebuilds and hosting platforms. `site_count` is how many archived sites carry it: a key\n on thousands of unrelated sites belongs to a platform (a challenge page's CAPTCHA key, a\n hosting provider's own analytics), not to the actor. The index also reads keys from the\n page source, so a widget that never loads still yields its key.\n- `GET /api/v1/scans/$SCAN_ID/pivot-values` lists further searchable values of the scan:\n inline script hashes (`js_sha256:`), script hosts (`script_host:`), hosts named in script\n source (`js_host:`), resource SHA-256s (`resource_sha256:`), IDS signatures, hosts the\n deobfuscator revealed, Telegram bots and chats, and more; the API documentation names the\n filter for each. Its `tracking_keys` carry no `site_count`; use the call above, or count\n the key with `sites` in Step 6.\n- Use a favicon only when its `url` is on the entry host. One fetched from the redirect\n destination, or hotlinked from the impersonated brand's own site, describes the brand.\n `mmh3_hash` follows the Shodan `http.favicon.hash` convention, so the same value can be\n searched there.\n- `primary_ip` is in CIDR form (`188.114.96.9/32`); drop the suffix before searching.\n `is_known: true` on a JARM means it matches a known signature listed in\n `matched_domains`.\n- Name the hosting platform from the entry page's headers (`server: Vercel`,\n `x-vercel-id`, `cf-ray`, `x-nf-request-id` for Netlify), not from the ASN name: hosts\n served by Vercel showed `AS16509 Amazon.com` in the archive's IP table. Filter to the\n entry host first; the page's other requests (a favicon hotlinked from the brand, say)\n carry their own `server` headers.\n\n### Step 6: Count and read each pivot before trusting it\n\n```bash\n# Runs an SMQL query and saves the response to smql.json\nsmql() {\n curl -s -G https://scanmalware.com/api/v1/search/smql \\\n --data-urlencode \"q=$1\" --data-urlencode \"limit=${2:-25}\" -o smql.json\n}\n\nsmql 'favicon_hash:-165377145'\njq '{total: .pagination.total_items, exact: .pagination.exact_count,\n hosts: ([.results[].url | capture(\"^https?://(?<h>[^/:?#]+)\").h] | unique | length),\n titles: ([.results[].title] | group_by(.) | map({title: .[0], n: length}))}' smql.json\n```\n\nAn account key is searched by tracker type and key, both quoted:\n\n```bash\nsmql 'tracker:\"livechat\" tracking_id:\"19281545\"'\njq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url, .title] | @tsv)' smql.json\n```\n\nA pivot is useful when its matches render the same page on many hosts (a kit), and useless\nwhen they are unrelated sites (a platform). `exact_count: false` means the count hit the\n10,000 cap. Title agreement cannot judge a title pivot, since every hit of a title search\ncarries the title; judge a title by its count and confirm each host it finds with a\nsecond pivot.\n\nTo judge every pivot of a scan at once, write one SMQL query per line and count them in\none request (up to 25). Leave out a favicon served from another host and, when the page\nredirected, inline scripts and account keys that may be the destination's:\n\n```bash\ncat > pivots.txt <<'EOF'\njs_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72\ntitle:\"Meta for Business | Page Appeal\"\ndhash:c000000000000000\nip:188.114.96.9\nEOF\njq -R -s '{queries: (split(\"\\n\") | map(select(length > 0))), sites: true}' pivots.txt > counts-body.json\ncurl -s -X POST https://scanmalware.com/api/v1/search/smql/counts \\\n -H 'Content-Type: application/json' --data-binary @counts-body.json -o counts.json\njq -r '.counts | to_entries[]\n | [.key, .value.total_items, .value.exact_count, .value.sites, .value.elevated_sites,\n .value.hosts, .value.shared_widely] | map(tostring) | @tsv' counts.json\n```\n\nWith `sites: true`, a query with 10 or more matches also gets, over up to 200 of its\nmatching scans, `sites` (distinct registrable domains), `elevated_sites` (those rated high\nrisk or worse), `hosts` (distinct entry hosts) and `shared_widely` (many sites, and no more\nof them high risk than across the whole archive). Read them as:\n\n| Count result | Assessment |\n|---|---|\n| `total_items` is 1 | unique: an exact indicator that finds no siblings |\n| `shared_widely` is `true` | broad: a common asset or a platform; drop it |\n| `elevated_sites` close to `sites`, several `hosts` | cluster: the value travels with a kit; keep it |\n| `elevated_sites` well below `sites` | mixed: a shared IP, platform key or common asset; read the hits first |\n| Under 10 matches, so no `sites` | read the hits with `smql` above |\n\nOn 2026-11-12, for the Meta example below, the entry-host script matched 1,111 scans on 56\nsites, all 56 high risk (cluster); the title 6,145 scans on 13 sites, all high risk\n(cluster, but a title, so confirm each host); the blank-page screenshot dHash came back\n`shared_widely` (broad); and the Cloudflare edge IP had 93 of 166 sites high risk (mixed:\nthe platform hosts the kit among much else). A count that ran out of time has\n`count_unavailable: true`; send it again with `\"patient\": true` (at most 4 queries). The\nlabels are a first cut; read the hits behind any pivot you intend to block on.\n\nTo turn the kept pivots into a host list, put the ones assessed cluster in `kept.txt`, one\nper line, fetch the newest 100 hits of each and group them by entry host:\n\n```bash\nn=0\nwhile IFS= read -r q; do\n n=$((n + 1))\n curl -s -G https://scanmalware.com/api/v1/search/smql \\\n --data-urlencode \"q=$q\" --data-urlencode \"limit=100\" --data-urlencode \"sort=newest\" \\\n -o \"hits-$n.json\"\ndone < kept.txt\n\njq -s '[.[] | .query as $q | .results[]\n | {host: (.url | capture(\"^https?://(?<h>[^/:?#]+)\").h | ascii_downcase),\n q: $q, at: .submitted_at}]\n | group_by(.host)\n | map({entry_host: .[0].host, pivot_count: ([.[].q] | unique | length),\n found_by: ([.[].q] | unique),\n first_seen: (map(.at) | min), last_seen: (map(.at) | max)})\n | sort_by(.first_seen)' hits-*.json > related.json\njq -r '.[] | [.first_seen[0:10], .pivot_count, .entry_host, (.found_by | join(\" + \"))] | @tsv' related.json\n```\n\nA host reached by one pivot only, a title in particular, needs a second look. Then ask\nwhich account keys the cluster shares, including keys the seed does not carry, with one\nfacet query over the kept pivots joined by `OR`:\n\n```bash\ncurl -s -G https://scanmalware.com/api/v1/search/smql/facets \\\n --data-urlencode 'q=title:\"Docusign - Download to Review & Sign Document\" OR tracking_id:19281545' \\\n --data-urlencode 'fields=tracking_id,primary_ip' --data-urlencode 'size=25' -o facets.json\njq -r '\"\\(.sample.scans) scans\",\n (.facets[] | .field as $f | .buckets[] | [$f, .count, (.label // \"\"), .query] | @tsv)' facets.json\n```\n\nEach value carries `count` (scans in the sample, the newest 1,000 matches by default) and\n`query`, which is ready to count with `sites` as above. A key on most scans of the cluster\nthat the seed lacks is often a second operator account or another generation of the kit;\ncount it before treating it as an actor link, since a key that is `shared_widely` belongs\nto a platform.\n\n### Step 7: Expand with SMQL combinations\n\n```bash\n# Current activity of one kit script\nsmql 'js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72 submitted:last7d' 100\njq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url] | @tsv)' smql.json\n\n# Lure title, excluding the real brand, limited to AI-confirmed scams\nsmql 'title:\"The Courier Guy\" -domain:thecourierguy.co.za verdict:CONFIRMED_SCAM' 50\njq -r '.results[].url' smql.json\n\n# New hosts on one operator's chat account this week\nsmql 'tracker:\"livechat\" tracking_id:\"19470622\" submitted:last7d' 100\njq -r '.results[] | [.submitted_at[0:10], .url] | @tsv' smql.json\n```\n\n`GET /api/v1/search/smql/filters` lists all filters with types and allowed values. An\nunknown filter returns HTTP 400 with `error.message` and a `suggested_query`.\n\n### Step 8: Enumerate hosts under an actor-registered domain\n\nOnly for domains the actor registered, never for a shared platform such as `pages.dev`:\n\n```bash\ncurl -s \"https://scanmalware.com/api/v1/hosts/$DOMAIN?subdomains_only=true&sources=observed,scripts\" -o hosts.json\njq '{scans_examined, sources}' hosts.json\n\ncurl -s \"https://scanmalware.com/api/v1/ct/dns/$DOMAIN\" -o ctdns.json\njq '{degraded, degraded_reason, stale, total_records, subdomain_count, subdomains}' ctdns.json\n```\n\nIf `degraded` is `true`, the zeros are not an answer; retry. An empty `ip` on a CT record\nmeans the name did not resolve when observed (`resolution_error` says why).\n\n## Key Concepts\n\n| Concept | What it means for the pivot |\n|---|---|\n| Entry host vs final host | `url` is what was submitted; `final_url` is where the browser ended. Verdicts and kit artifacts belong to the entry host. |\n| `matched_on` | Field on `/domains/{domain}/scans` rows that separates scans that started on a domain from scans that only landed there. |\n| Scanner cloaking | Many kits send visitors that look like scanners to the real brand site, so the scan ends on the brand and the brand's domain collects the phishing scan. |\n| Shared platform | Cloudflare Pages, R2, Netlify, Vercel and similar give every tenant the same edge IPs and TLS stack; their JARM, IP and ASN cluster the platform, not the actor. |\n| Kit artifact | Script SHA-256 from the entry host or an inline script, favicon MMH3 from the entry host, screenshot dHash, distinctive title. These follow the kit across hosts. |\n| Account key | An analytics ID, chat-widget license or CAPTCHA site key the operator registered. It follows the operator across kit rebuilds and hosting platforms; one on thousands of unrelated sites belongs to a platform. |\n| Pivot selectivity | Judge by what the matches look like, not by the count alone: 650 copies of one kit is a strong pivot; 7,000 unrelated sites on one IP is not. |\n| Two verdicts | `security_verdict` (analyzer checks, `risk_level`) and the AI `classification` are independent and can disagree. |\n| SMQL | ScanMalware Query Language: `field:value`, `AND`/`OR`, `-field:value`, quotes, ranges (`a..b`), `has:feature`, relative dates (`last7d`). |\n\n## Tools & Systems\n\n- **ScanMalware REST API**: https://scanmalware.com/api-docs (OpenAPI document at\n https://scanmalware.com/openapi.json). Endpoints used here are listed in\n `references/api-reference.md` with the response fields verified against the live API.\n- **SMQL filter list**: `GET https://scanmalware.com/api/v1/search/smql/filters`.\n- **ScanMalware MCP server**: `https://mcp.scanmalware.com/mcp` (streamable HTTP, no\n authentication), installed with this plugin, exposes the same operations as MCP tools.\n Step 1's history with `matched_on` is `get_domain_scans` (not `get_domain_history`,\n which has no `matched_on`). The SMQL search is `search_smql` (`get_smql_filters` lists\n the filters), the Step 6 counts `count_smql_queries` (pass `sites: true`) and facets\n `get_smql_facets`, a scan's searchable values and account keys `get_scan_pivot_values`\n (keys without `site_count`), the scans carrying one key `search_tracking_key`, the host\n list `get_domain_hosts`, and DNS `get_dns_history` / `search_reverse_dns`.\n Its `submit_scan` tool has no default visibility: `scan_type` must be given on every\n call, while the REST endpoint in Step 2 falls back to `public` when it is left out.\n- **jq**: https://jqlang.org/manual/\n\n## Common Scenarios\n\n### Scenario: The reported URL bounces the scanner to the real brand\n\nA Meta \"Page Appeal\" lure on a disposable `pages.dev` host ends its scan on\n`www.facebook.com`. The analyzer verdict is `malicious` and the AI classification is\n`CONFIRMED_SCAM`, but the favicon is Facebook's. On 2026-09-26, 97 of the 100 newest\n`facebook.com` history rows were `[\"final_url\"]`, 94 of them from `pages.dev` entry\nhosts. The pivot that works is the kit bundle served from the entry host\n(`static/js/main.*.js`): its SHA-256 matched 651 archived scans, the sampled ones all\ntitled \"Meta for Business | Page Appeal\" on different disposable hosts. The entry host's\nJARM and IP each matched thousands of unrelated Cloudflare-hosted sites and were dropped.\nBlock the entry hosts, never `facebook.com`, and never `pages.dev` as a whole.\n\n### Scenario: A courier-fee scam on object storage\n\nA \"The Courier Guy\" shipping-fee page on an `r2.dev` bucket posts card details to a\nsecond page. There is no entry-host script to pivot on, but on 2026-09-26 the favicon\nMMH3 (`favicon_hash:-165377145`) matched 42 scans, every one titled \"The Courier Guy\",\nacross 22 entry hosts on `r2.dev` and `netlify.app`. Three of them entered through\n`tinyurl.com` and `snip.ly` links whose `final_url` is a kit bucket: the shortener link is\nthe delivery URL to hunt for in mail logs, and the bucket in `final_url` is the host to\nreport. The screenshot dHash is narrower (17 scans) and confirms the same lure.\n\n### Scenario: A fake desktop-app download behind a document lure\n\nA page titled \"Docusign - Download to Review & Sign Document\" tells the visitor to install\na \"Docusign Desktop\" app to open a document. It has no form, hotlinks DocuSign's own\nfavicon and loads no script from its own host, so the usual kit pivots are empty. On\n2026-10-20 the analyzer rated it `low` and the AI `CONFIRMED_SCAM` (\"Malware\nDistribution\"). The title matched 20 scans on 17 hosts since 2026-07-23, most named after\ncryptocurrency firms. The pivot that tied them to one operator was the inline LiveChat\nloader: its license 19281545 was on 3 hosts sharing one dedicated IP, and the account keys\nof the hosts it found led to a second license, 19470622, on 14 more hosts on Vercel and\nCloudflare. The oldest host\ncarried a third license of its own. Across the 20 scans the analyzer said 9 `low`,\n9 `medium` and 2 `high`, and the AI rated 2 of them `LOW_RISK` as \"self-branding\". No\nscan captured the installer: the Download button is script-driven, so the file has to be\nfetched from a live host by hand. Block the entry hosts and the dedicated IP and report\nthe chat licenses to the vendor; never block `docusign.com`, the Vercel or Cloudflare\nedge, or the dedicated IP's ASN, which held 943 unrelated scans.\n\n### Scenario: An AI `CONFIRMED_SCAM` on a brand-new domain with its own login page\n\n`key_evidence` cites only \"domain registered 0 days ago\" and \"login form captures email\nand password\", with no impersonated brand and no kit match. That is the shape of a new\nlegitimate service as often as a phishing page. Pivot on its artifacts anyway: if nothing\nelse in the archive shares them and the analyzer verdict is `low`, record it as unconfirmed\nrather than blocking.\n\n## Output Format\n\n```json\n{\n \"seed\": {\"scan_id\": \"d6b8750f-2d1e-4a25-8a13-6817bda5d930\",\n \"url\": \"hxxp://zermuri-mpt-felvani-p4d7me83[.]pages[.]dev/\",\n \"final_url\": \"https://www.facebook.com/\",\n \"risk_level\": \"malicious\", \"ai_classification\": \"CONFIRMED_SCAM\",\n \"cloaked_to\": \"www.facebook.com\"},\n \"pivots_used\": [\n {\"type\": \"entry_host_script_sha256\",\n \"query\": \"js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72\",\n \"matches\": 651, \"assessment\": \"cluster\"}\n ],\n \"pivots_rejected\": [\n {\"type\": \"jarm_entry_host\", \"matches\": 10000, \"reason\": \"Cloudflare Pages edge\"},\n {\"type\": \"favicon_mmh3\", \"reason\": \"served from the redirect destination\"}\n ],\n \"related_entry_hosts\": [\"berqelo-mpt-zanvuri-p2d6me93[.]pages[.]dev\"],\n \"do_not_block\": [\"facebook.com\", \"pages.dev\"]\n}\n```\n\n`assets/template.md` is the matching analyst report.\n\n## Verification Checklist\n\n- [ ] Every verdict in the report is attributed to an entry host, and every domain-history\n row used was checked for `matched_on == [\"final_url\"]` and for a `url` host that is a\n subdomain rather than the domain itself.\n- [ ] Both verdicts were read after they populated (no empty `security_verdict`, no 404\n from `/ai/{scan_id}` treated as clean).\n- [ ] Each pivot kept has its match count and a sample of hits that render the same page.\n- [ ] Every related host found by a title alone was confirmed with a second pivot (an\n account key, a script hash, a dedicated IP) or is marked unconfirmed.\n- [ ] No account key with a `site_count` far above the cluster size is used as an actor\n link.\n- [ ] No IP, ASN or JARM of a shared platform, and no favicon from a redirect destination\n or hotlinked from the brand, is in the block list.\n- [ ] Indicators are defanged in the written report.\n"
}SHA-256 of public snapshot: 0ff1dfcedd4975107744b53fe5b9970948008b0273ac22028e71579950756b20