← Plugin catalog
Security

ScanMalware

Triop AB v1.0.0

Phishing URL scans and pivots

Create a free account to save this plugin.

Competitor profile

Developer declarations; not independently tested. Listing last checked Nov 4, 2026. Package observed Nov 3, 2026.

Declared capabilities

  • Read
  • Write

Not specified in the available declarations: limitations, pricing & account requirements.

Onboarding example

Check this suspicious link in ScanMalware and summarize what the page does.

View package source →

Description

From the marketplace listing

ScanMalware helps security teams investigate suspicious and phishing URLs. It renders a URL in a sandboxed browser, records what the page loaded and did, and keeps public scans in a searchable archive, so one reported phishing page can lead to the rest of its campaign. With this plugin you can: - Look up what the archive already knows about a domain, URL or IP address: the scanner's verdict and risk factors, network requests, indicators, TLS and JARM details, detected technologies, YARA matches, OCR text and RDAP data. - Find related scans by domain, IP, ASN, JARM signature, favicon hash, screenshot hash, fuzzy hash, OCR text, tracking key or JavaScript fingerprint. - Look up Certificate Transparency records for a domain or IP. - Submit a new URL for scanning, choosing whether the result is public or unlisted, and read the verdict when the scan completes. The included skill walks through pivoting from one phishing URL to its sibling hosts, and avoids two common mistakes: giving a domain a verdict that belongs to a page that only redirected to it, or to another tenant on a shared hosting platform; and blocking on shared IP addresses or TLS fingerprints that describe a hosting platform rather than an attacker. ScanMalware is a defensive tool. Pages are opened in ScanMalware's isolated browser, not on your device. No account or API key is needed. 126 of the 128 tools only read data; the two that write submit a URL for scanning and record a safe or malicious vote on a scan. A public scan publishes the URL and its results, so use "unlisted" for confidential targets. ScanMalware is operated by Triop AB in Sweden.

Language: English · Automatically detected from descriptions.

What changed

Keywords

Files & skills

File archives

Plugin package20 files · 103 KBBrowse files →
Skill instructions
pivoting-phishing-infrastructure-with-scanmalware17.1 KB

View saved version →

---
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.
license: Apache-2.0
---
# Pivoting Phishing Infrastructure with ScanMalware

## Overview

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.
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.

## When to Use

- A user reported a phishing URL and you need the other hosts running the same kit.
- You want to know whether a URL or domain was already scanned, and what it did, before
  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 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`.
- 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.
- 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.

## Workflow

### Step 1: Check the archive, and read `matched_on`

```bash
DOMAIN=facebook.com
curl -s "https://scanmalware.com/api/v1/domains/$DOMAIN/scans?limit=100" -o history.json
jq -r '.[] | [(.matched_on | join("+")), .scan_id, .url, .final_url] | @tsv' history.json
```

The endpoint lists scans of the domain and its subdomains, matched on the entry host or
the final host after redirects. `matched_on` says which:

| `matched_on` | Meaning | Whose verdict is it |
|---|---|---|
| `["url"]` | Scan was submitted for this domain or one of its subdomains | The host in `url` |
| `["url","final_url"]` | Started and ended under this domain | The host in `url` |
| `["final_url"]` | Started elsewhere and landed here | The entry host in `url`, not this domain |

`"url"` in `matched_on` is not enough to give a verdict to the domain itself. The rows
include subdomains, and on a shared platform a subdomain is somebody else's site: the
newest `github.io` rows matched on `url` are tenant pages such as
`trustwallet-team-support.github.io`. Attribute a verdict to the domain only when the
host in `url` is the domain or its `www.` name.

Split the rows before reading any verdict:

```bash
jq --arg d "$DOMAIN" '
      def host: capture("^https?://(?<h>[^/:?#]+)").h | ascii_downcase;
      {this_host: [.[] | select(any(.matched_on[]; . == "url"))
                       | select((.url | host) == $d or (.url | host) == "www." + $d)] | length,
       subdomain_hosts: [.[] | select(any(.matched_on[]; . == "url"))
                       | select((.url | host) != $d and (.url | host) != "www." + $d)] | length,
       landed_here_only: [.[] | select(.matched_on == ["final_url"])] | length}' history.json
```

For a large brand the second number usually dominates: many phishing kits send scanners
to the real site to hide the lure, so the brand's domain collects scans of phishing pages it
never hosted. SMQL `domain:` has the same property (it matches the final host too) and has
no `matched_on` field, so compare the hosts of `url` and `final_url` on every SMQL row.

### Step 2: Submit the URL if the archive has nothing current

```bash
SUSPECT_URL='https://example.com/'
jq -n --arg u "$SUSPECT_URL" '{url: $u, scan_type: "unlisted"}' > submit-body.json
curl -s -X POST https://scanmalware.com/api/v1/scan \
  -H 'Content-Type: application/json' --data-binary @submit-body.json -o submit.json
SCAN_ID=$(jq -r .scan_id submit.json)
echo "$SCAN_ID"

for i in $(seq 1 60); do
  curl -s "https://scanmalware.com/api/v1/result/$SCAN_ID/progress" -o progress.json
  jq -e '.status | test("^(completed|failed|error)$")' progress.json >/dev/null && break
  sleep 5
done
```

`scan_type` is `public`, `unlisted` or `private`. The response carries `scan_id`,
`status` (`queued`), `message`, `scan_type` and `submitted_at`.

### Step 3: Read both verdicts, and do not merge them

```bash
for i in $(seq 1 24); do
  curl -s "https://scanmalware.com/api/v1/scan/$SCAN_ID/summary" -o summary.json
  jq -e '.security_verdict.risk_level != null' summary.json >/dev/null && break
  sleep 5
done
jq '{url, final_url, risk_score,
     verdict: .security_verdict.verdict,
     risk_level: .security_verdict.risk_level,
     risk_factors: .security_verdict.risk_factors}' summary.json

for i in $(seq 1 30); do
  if curl -sf "https://scanmalware.com/api/v1/ai/$SCAN_ID" -o ai.json; then break; fi
  sleep 10
done
jq '.results.analysis | {classification, scam_type, risk_score,
                         confidence_percentage, impersonated_brand, key_evidence}' ai.json
```

- `security_verdict` is the analyzer verdict, built from the scan's own checks (forms,
  URL, redirects, Safe Browsing, TLS, domain age). Branch on `risk_level` (observed values:
  `low`, `medium`, `high`, `malicious`, `unknown`), not on the `verdict` display string,
  which can carry a parenthetical such as `Malicious (Instagram Phishing, cloaked from
  scanners)`. `risk_score` is 0 to 100, higher is worse.
- The AI analysis is separate. `classification` is one of `LEGITIMATE`, `LOW_RISK`,
  `MODERATE_RISK`, `HIGH_RISK`, `CONFIRMED_SCAM`, `NOT_ASSESSED`.
- Both fill in after `status` becomes `completed`: `security_verdict` is empty for about
  15 seconds and `/ai/{scan_id}` returns 404 for up to a few minutes. Empty is not clean.
- 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.

### Step 4: Find out where the page really ended

```bash
curl -s "https://scanmalware.com/api/v1/result/$SCAN_ID" -o result.json
jq '{url, final_url, title, redirects: [.redirect_chains[] | {from, to, status}]}' result.json
```

`redirect_chains` records HTTP redirects. A script-driven navigation can move the page to
another host without an entry there, so compare the hosts of `url` and `final_url`
directly. If they differ, artifacts captured at the end of the load (favicon, screenshot,
final-page scripts) may belong to the destination, which is often the impersonated brand.

### Step 5: Extract pivot values from attacker-controlled content

```bash
jq '{favicon: (.favicon_hash | {url, mmh3_hash, md5_hash}),
     screenshot: (.screenshot_hashes | {dhash_hex, phash_hex}),
     primary_ip, primary_asn}' result.json

# Scripts served from the entry host: the strongest kit identifiers
jq -r '(.url | capture("^https?://(?<h>[^/:?#]+)").h) as $entry
       | .javascript_files[]
       | select((.url | capture("^https?://(?<h>[^/:?#]+)").h) == $entry)
       | [.content_sha256, .url] | @tsv' result.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`.

### Step 6: Count and read each pivot before trusting it

```bash
# Runs an SMQL query and saves the response to smql.json
smql() {
  curl -s -G https://scanmalware.com/api/v1/search/smql \
    --data-urlencode "q=$1" --data-urlencode "limit=${2:-25}" -o smql.json
}

smql 'favicon_hash:-165377145'
jq '{total: .pagination.total_items, exact: .pagination.exact_count,
     hosts: ([.results[].url | capture("^https?://(?<h>[^/:?#]+)").h] | unique | length),
     titles: ([.results[].title] | group_by(.) | map({title: .[0], n: length}))}' 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:

```bash
python3 scripts/agent.py pivots "$SCAN_ID" --output pivots.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.

### Step 7: Expand with SMQL combinations

```bash
# Current activity of one kit script
smql 'js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72 submitted:last7d' 100
jq -r '.pagination.total_items, (.results[] | [.submitted_at[0:10], .url] | @tsv)' smql.json

# 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
```

`GET /api/v1/search/smql/filters` lists all filters with types and allowed values. An
unknown filter returns HTTP 400 with `error.message` and a `suggested_query`.

### Step 8: Enumerate hosts under an actor-registered domain

Only for domains the actor registered, never for a shared platform such as `pages.dev`:

```bash
curl -s "https://scanmalware.com/api/v1/hosts/$DOMAIN?subdomains_only=true&sources=observed,scripts" -o hosts.json
jq '{scans_examined, sources}' hosts.json

curl -s "https://scanmalware.com/api/v1/ct/dns/$DOMAIN" -o ctdns.json
jq '{degraded, degraded_reason, stale, total_records, subdomain_count, subdomains}' ctdns.json
```

If `degraded` is `true`, the zeros are not an answer; retry. An empty `ip` on a CT record
means the name did not resolve when observed (`resolution_error` says why).

## Key Concepts

| Concept | What it means for the pivot |
|---|---|
| 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. |
| `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. |
| 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`). |

## Tools & Systems

- **ScanMalware REST API**: https://scanmalware.com/api-docs (OpenAPI document at
  https://scanmalware.com/openapi.json). Endpoints used here are listed in
  `references/api-reference.md` with the response fields verified against the live API.
- **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.
  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

### Scenario: The reported URL bounces the scanner to the real brand

A Meta "Page Appeal" lure on a disposable `pages.dev` host ends its scan on
`www.facebook.com`. The analyzer verdict is `malicious` and the AI classification is
`CONFIRMED_SCAM`, but the favicon is Facebook's. On 2026-09-26, 97 of the 100 newest
`facebook.com` history rows were `["final_url"]`, 94 of them from `pages.dev` entry
hosts. The pivot that works is the kit bundle served from the entry host
(`static/js/main.*.js`): its SHA-256 matched 651 archived scans, the sampled ones all
titled "Meta for Business | Page Appeal" on different disposable hosts. The entry host's
JARM and IP each matched thousands of unrelated Cloudflare-hosted sites and were dropped.
Block the entry hosts, never `facebook.com`, and never `pages.dev` as a whole.

### Scenario: A courier-fee scam on object storage

A "The Courier Guy" shipping-fee page on an `r2.dev` bucket posts card details to a
second page. There is no entry-host script to pivot on, but on 2026-09-26 the favicon
MMH3 (`favicon_hash:-165377145`) matched 42 scans, every one titled "The Courier Guy",
across 22 entry hosts on `r2.dev` and `netlify.app`. Three of them entered through
`tinyurl.com` and `snip.ly` links whose `final_url` is a kit bucket: the shortener link is
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: 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
and password", with no impersonated brand and no kit match. That is the shape of a new
legitimate service as often as a phishing page. Pivot on its artifacts anyway: if nothing
else in the archive shares them and the analyzer verdict is `low`, record it as unconfirmed
rather than blocking.

## Output Format

```json
{
  "seed": {"scan_id": "d6b8750f-2d1e-4a25-8a13-6817bda5d930",
           "url": "hxxp://zermuri-mpt-felvani-p4d7me83[.]pages[.]dev/",
           "final_url": "https://www.facebook.com/",
           "risk_level": "malicious", "ai_classification": "CONFIRMED_SCAM",
           "cloaked_to": "www.facebook.com"},
  "pivots_used": [
    {"type": "entry_host_script_sha256",
     "query": "js_sha256:0fdef61ae64dde1625136fd374133de9493f1e9699487cd62328313d13b9db72",
     "matches": 651, "assessment": "cluster"}
  ],
  "pivots_rejected": [
    {"type": "jarm_entry_host", "matches": 10000, "reason": "Cloudflare Pages edge"},
    {"type": "favicon_mmh3", "reason": "served from the redirect destination"}
  ],
  "related_entry_hosts": ["berqelo-mpt-zanvuri-p2d6me93[.]pages[.]dev"],
  "do_not_block": ["facebook.com", "pages.dev"]
}
```

`assets/template.md` is the matching analyst report.

## Verification Checklist

- [ ] Every verdict in the report is attributed to an entry host, and every domain-history
      row used was checked for `matched_on == ["final_url"]` and for a `url` host that is a
      subdomain rather than the domain itself.
- [ ] 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.
- [ ] Indicators are defanged in the written report.

Referenced files: 6 — file references are metadata, not package contents.

Package details

Publisher declarations from the archived package. These are separate from our research and the live service's terms.

Package license
Apache-2.0
Package author
Triop AB
Keywords
See keywords

Declared capabilities

  • Read
  • Write

Package observed Nov 3, 2026.

Technical details
First seen
Oct 24, 2026 · 12:40 UTC
Last seen
Nov 4, 2026 · 06:00 UTC
Collection status
Collected

plugin_asdk_app_6ac75bbd39c08191ae95c30ac42638e0

Download plugin data (JSON)