A phishing email landed in my personal inbox this afternoon claiming MetaMask was rolling out a “verification requirement” and I needed to “activate fingerprint access” before a deadline. Content-wise it’s the standard crypto lure. What made me pull it apart is the authentication result Gmail stamped on it:
dkim=pass header.i=@moodlecloud.com
dkim=pass header.i=@amazonses.com
spf=pass (54.240.27.18 is permitted for mail.gl.moodlecloud.com)
dmarc=pass (p=QUARANTINE) header.from=moodlecloud.com
Everything passes, and none of it is forged. The attacker never touched a mail server. They built a Moodle site and let Moodle send the email.
Delivery: Moodle’s messaging, working as designed
Moodle is a learning-management system. When one user sends another a site message, Moodle emails the recipient a copy. It uses the site’s configured outbound mail. On MoodleCloud, the hosted service, that’s Amazon SES under moodlecloud.com’s own SPF and DKIM. The headers say so directly:
From: "DappMetaMask Connect (via Web3-Portfolio)" <noreply@choomer-in3.moodlecloud.com>
To: damani15 kamani15 <[me]>
X-Mailer: PHPMailer 6.9.3
X-Moodle-Originating-Script: https://choomer-in3.moodlecloud.com => ip-10-80-52-92:message/lib.php:332
X-MoodleCloud-Site: choomer-in3
Put together, this is how the operator set it up:
- A MoodleCloud site at
choomer-in3[.]moodlecloud[.]com, named “Web3-Portfolio”. - A user account for my address with the throwaway name “damani15 kamani15”. That’s the display name in the
To:line. I never signed up for anything. - The sender renamed. The message came from the account whose display name was changed to “DappMetaMask Connect”. The footer’s reply link points at
message/index.php?id=2. On a standard Moodle install, user id 1 is the guest account and id 2 is the admin created at install. So the sender is the site owner. - One message to the enrolled users. Moodle’s notification path took it from there.
The public config of the operator’s second site shows self-registration disabled. That’s consistent with the recipients being created by the admin, most likely a bulk CSV import of a target list, rather than anyone signing themselves up. I can’t see the import itself, so treat that part as inference.
From the mail filter’s point of view this is a legitimate notification from a legitimate SaaS domain. The only hostile part is the text in the body, which is what the operator controls. Most filters trust dmarc=pass from an established domain, and this one had it.
The body was lifted from someone else’s email
The HTML has two leftovers from where it came from:
- Font stack: it starts with
'Glassdoor Sans'. - Class names: every element carries the
m_5260151207233190133prefix Gmail adds when it renders a message.
The operator opened a Glassdoor email in Gmail, copied the rendered layout, swapped in their own text, and pasted it into a Moodle message. The fox logo isn’t on the sending site. It’s hot-linked from a second MoodleCloud site, consensys-ec03[.]moodlecloud[.]com, named after the company that makes MetaMask. The logo’s upload stamp in the pluginfile path is 1790275117, which is 18:38 UTC on the day of the send. My copy went out at 23:49:22 UTC, so the branding site was set up that afternoon.
The call-to-action wasn’t a Moodle link either. It was a SendGrid click-tracking URL (u10277184.ct.sendgrid[.]net/ls/click?upn=…) pasted straight into the message body. MoodleCloud sends through SES, so this link didn’t come from the delivery pipeline. The operator generated it on a SendGrid account they control, or one they’ve compromised. That buys them a second reputable domain in front of the real destination. The actual landing page doesn’t appear anywhere in the email.
The redirect chain
I resolved each hop by reading the Location header and never loading the page. I didn’t use my own tracking link past SendGrid.
| Hop | Host | Registered | Behavior |
|---|---|---|---|
| 1 | u10277184.ct.sendgrid[.]net | — | 302 → frondime[.]com/?IDKE-031080928091840981 |
| 2 | frondime[.]com | 2026-09-02, Dominet (HK), Cloudflare | Bare PHP 302 → hop 3. Redirects the same with or without the IDKE- token. |
| 3 | smartsigmetacontract[.]com | 2026-09-17, Dynadot, Cloudflare | Serves the kit, <title>MetaMask - Smart Shield</title> |
The two landing domains were registered fifteen days apart, through different registrars. Both sit behind Cloudflare, so the origin server isn’t visible from outside.
The kit
The page walks the victim through two steps:
- “Link your email address.” The email is POSTed to
email-send.phpwhen they click Continue. - “Link a wallet.” A 12–24 word recovery-phrase grid.
It’s wrapped in several paragraphs about a “MetaMask Smart Shield Contract”. Supposedly that “links your mnemonic recovery phrase with a smart contract on the blockchain” and emails you when a dApp misbehaves. No such thing exists. A recovery phrase is the wallet, and nothing legitimate ever asks for one.
The HTML has an inline script that assembles a message and POSTs it to mm.php when the victim clicks “Backup”:
🔐 WALLET RESTORE + EMAIL LINK
📧 Email: …
🔑 Seed Phrase: …
📝 Word Count: …
The emoji-labeled block is the house style of Telegram-bot exfiltration. The bot token, if it is Telegram, stays on the server in mm.php. I checked everything the browser receives, and nothing in it contains a token, a chat_id, a webhook or an api.telegram.org call. Kits that send straight from the browser to Telegram hand you the token. This one doesn’t.
The HTML is full of comments like <!-- ORIGINAL SRP GRID - EXACT STRUCTURE FOR m.js --> and <!-- ORIGINAL BUTTON - KEPT EXACTLY AS IS -->. Someone took an older fake “import wallet” kit, kept its script, and bolted a new email-capture step on the front.
m.js: it sends your phrase while you’re still typing
The real collector is m.js, a 60 KB single-line file run through javascript-obfuscator:
- String table: all its strings sit in one array, rotated by a
parseIntchecksum loop at startup. - Lookups: each string is fetched through a decoder with an offset (
0x1ea). - No real encryption: there’s no base64 or RC4 layer on the strings.
I evaluated only the array, decoder and rotation loop in a Node vm context, with no DOM and no network. Then I replaced every decoder call with the string it returns and pretty-printed the result. Readable, it does four things:
- Gets the victim’s IP on load with
$.getJSON("https://api.ipify.org?format=json"). - Sends on every input change. Each change starts a 2,000 ms debounce. When it fires, the script joins whatever words are filled in and POSTs
message=<words> (IP: <ip>)tomm.php. Nothing is sent if the phrase is unchanged since the last send. - Sends on paste immediately. The paste handler splits the clipboard across the boxes and runs the send with no debounce.
- Queues its requests. Sends are serialized with a 2,000 ms gap, so it keeps working through fast typing.
The victim never has to press anything. Five words typed and a walk-away is five words delivered.
I confirmed that behavior instead of trusting my reading of the code:
- The harness: a headless Chromium instance under Playwright, loading my saved copy of the page and
m.js. - No traffic reached the operator. Every request was intercepted:
email-send.phpandmm.phpwere answered locally and logged, ipify was stubbed to return203.0.113.7(a documentation address), and anything else was blocked. - The input: the public BIP39 test phrase,
abandon ×11 about.
The capture:
3.7s POST email-send.php email=victim@example.org
10.4s POST mm.php message=abandon abandon abandon abandon abandon (IP: 203.0.113.7)
--- typed 5 words and stopped ---
18.6s POST mm.php message=abandon abandon … abandon about (IP: 203.0.113.7)
21.0s Backup button: still disabled
The partial phrase left the page after five words, eight seconds before the phrase was complete.
The kit is broken, and it doesn’t matter
The “Backup” button never enables, so the inline script’s tidy WALLET RESTORE + EMAIL LINK message, the one that pairs the email with the phrase, never fires. Here’s why:
- The inline script builds the 12 input boxes and attaches the listeners that enable Backup.
m.jsruns ondocument.ready, empties the same container, and rebuilds it with its own inputs.- The inline listeners are gone with the old boxes.
m.js’s own submit path is wired to#password,#confirm-passwordand a#termsModalthat don’t exist on this page, which are leftovers from the kit’s previous life.
There’s a second break: the inline script throws a TypeError on load, because the success modal it references (#successModal) isn’t on the page.
None of that saves the victim. The streaming collector goes around all of it. The operator gets the email from one endpoint and the phrase, with IP, from the other, and can pair them by source IP and timing.
One more fingerprint. m.js carries its own copy of the BIP39 English wordlist to validate words. It has 2,047 of the 2,048 words, because rebel is missing. Any real phrase containing “rebel” would fail the kit’s validation with “contains invalid words”. That only affects the dead submit path, but a missing word is a distinctive marker for tying other copies of this kit together.
The second site
consensys-ec03[.]moodlecloud[.]com was still up after the sending site had disappeared. The unauthenticated config endpoint the Moodle mobile app uses (tool_mobile_get_public_config) returns:
- The same
sitename, “Web3-Portfolio”. Same operator. registerauth: "", meaning self-signup is off.- Web services and the mobile service switched on.
country: "FR". That’s a free-text setting and says nothing reliable about who the operator is.
It still serves the email’s logo. It isn’t in maintenance mode, and it’s set up to send the next wave.
One practical note if you go poking at MoodleCloud yourself: its load balancer returns 403 to a bare Mozilla/5.0 user-agent. I briefly read that as “site suspended”. Sending a full browser UA returned the login page. Check your own request before you read anything into a 403.
What would have caught it
- Not DMARC. It passed, correctly. The domain really did send it.
- The display name. “DappMetaMask Connect (via Web3-Portfolio)” from a
moodlecloud.comsubdomain is incoherent for a wallet vendor. Filters that score brand names in the display name against the sending domain would flag it. Most people just see the fox. - Where the link goes. The visible link text was a button. The target was a SendGrid tracker that isn’t MetaMask’s, pointing at a domain registered 22 days earlier. Newly registered domain age at click time is the signal that holds up against this whole delivery method.
- The ask itself. No wallet vendor, exchange or “security contract” will ever ask for a recovery phrase. Anyone who asks for it is stealing the wallet.
Where it stands
As of publication, the sending site already returns MoodleCloud’s “not found” page. Everything else was still live when I last checked:
- the second MoodleCloud site;
- the SendGrid tracking link;
- both landing domains.
I’ve reported them to MoodleCloud, SendGrid, Cloudflare, both registrars and MetaMask. I’ll update this post as they come down.
Indicators
Defanged. Recorded 2026-09-24/25 UTC.
Sending site choomer-in3[.]moodlecloud[.]com (sitename "Web3-Portfolio", sender uid 2 "DappMetaMask Connect")
Asset site consensys-ec03[.]moodlecloud[.]com (sitename "Web3-Portfolio")
Redirector u10277184.ct.sendgrid[.]net
Redirector frondime[.]com reg 2026-09-02 Dominet (HK)
Kit smartsigmetacontract[.]com reg 2026-09-17 Dynadot
Kit endpoints /email-send.php /mm.php /m.js
Kit title "MetaMask - Smart Shield"
m.js sha256 8ceef55ff56fb7c1bfc11e883f2c42d7abf5f97286d8cf5a0f5c7411b68c4bd5
page sha256 a392a1c05f1ef89468b6b8aea337fa110235a63b1d9d3794d5360bbadfa95dc0
Marker embedded BIP39 list missing "rebel" (2,047 words)