{"id":51823,"plugin_id":"plugin_asdk_app_6ac75bbd39c08191ae95c30ac42638e0","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-10-24T13:47:40.983Z","digest":"27f20cdec6de3efeabf9ff73116c2fe57df55c1664aa2604006b36d7159565e2","against":null,"payload":{"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.","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}],"name":"pivoting-phishing-infrastructure-with-scanmalware","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"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}