Component: apps/app/src/pages/ConnectMcp.tsx (browser consent flow for the @mysten-incubation/memwal-mcp bridge)
Type: Security — consent/authorization phishing (UX + missing request binding)
Severity: Medium (requires social engineering + victim wallet approval)
Summary
The /connect/mcp page registers a delegate key on the connected wallet's Walrus Memory account based entirely on query-string parameters, and its consent screen presents an attacker-controlled application name (label) plus a delegate address the victim has no way to validate. Because the page can be opened by anyone with any parameters (there is no binding to the local MCP bridge that is supposed to originate the request), an attacker can send a victim a link on the trusted memory.walrus.xyz domain that reads "Cursor wants access to your Walrus Memory" and, on approval, permanently grants the attacker's own delegate key read/decrypt/write access to all of the victim's memories until manually revoked.
This is an app-level consent-phishing surface, not a wallet-level issue: the branded, first-party consent screen actively lends credibility to a request that is fully attacker-defined.
Verified facts (code)
apps/app/src/pages/ConnectMcp.tsx:
publicKey, delegateAddress, label, relayer, port, connectState are all read directly from useSearchParams().
paramsValid validates format only (hex length / port range); it does not verify any relationship between the parameters, nor the origin of the request.
- On approve, it builds and signs
account::add_delegate_key(accountId, publicKey, delegateAddress, label, clock) with the raw params, signed by the connected wallet (the victim, who is the account owner) — so the on-chain owner check passes.
- The consent screen (
ConsentCard) shows the attacker-controlled label as the requesting app, a truncated delegateAddress (slice(0,16)…slice(-6)), and the relayer. The publicKey is not shown, and there is no warning that the link source must be trusted.
- The only cross-origin protection (
connectState) guards the localhost callback, not the on-chain add_delegate_key. There is no referrer/opener/bridge-origin check.
I confirmed paramsValid returns true for entirely attacker-chosen values (random publicKey, an attacker-owned delegateAddress, arbitrary port, a self-generated 64-hex connectState).
Attack flow (no local bridge required on the victim side)
- Attacker generates their own delegate keypair
(pk, addr) (a normal, self-consistent keypair they control).
- Attacker crafts:
https://memory.walrus.xyz/connect/mcp?port=20000&publicKey=<pk>&delegateAddress=<addr>&label=Cursor&relayer=<relayer>&connectState=<random 64-hex>
- Victim opens the link → sees "Cursor wants access to your Walrus Memory" on the real domain → connects wallet → approves.
add_delegate_key succeeds (victim is the owner). The attacker's key is now a registered delegate on the victim's account.
- The localhost callback fails (no bridge), showing only a benign "couldn't reach local listener" message — but the on-chain delegate registration already succeeded.
- The attacker now uses
(pk, addr) to: decrypt the victim's memories via SEAL (seal_approve delegate branch matches addr) and read/write via the relayer (auth.rs matches pk in delegate_keys). Access persists until the victim manually revokes it.
Important: this is NOT the public_key / sui_address desync
A related report framed the root cause as the contract not verifying that sui_address == blake2b256(0x00 ‖ public_key) in add_delegate_key, and proposed enforcing that derivation. That fix would not prevent this attack, and the desync is not the enabling factor:
- Relayer API access checks the public key (
verify_delegate_key_onchain, needs the matching private key); SEAL decryption checks the address (is_delegate_address).
- An attacker's optimal choice is therefore a fully consistent keypair they own (
addr = derive(pk)), which grants both relayer and SEAL access and passes any derivation check.
- A mismatched pair grants strictly less (SEAL only), so an attacker gains nothing from the desync.
The vulnerability is the consent/phishing surface — who can trigger the request and how it is presented — not key derivation.
Mitigating factors (honest severity)
- Requires social engineering: the victim must open the link, connect their wallet, and approve the transaction.
- The wallet popup does display an
add_delegate_key move call, so a vigilant user could refuse. In practice the branded first-party consent screen suppresses suspicion, which is exactly what makes consent phishing effective.
Recommended improvements
- Bind the request to the local bridge. Require proof the page was opened by a locally-running bridge for
connectState (e.g., only proceed if the localhost /callback preflight/handshake on port succeeds before signing), so a bare emailed link cannot drive an on-chain add.
- Make consent honest and scary enough. Explicitly state that approving grants a third party persistent read + decrypt + write access to all memories across all namespaces until revoked; show the full
delegateAddress and publicKey; and warn the user to only continue if they personally started this from their own machine.
- Do not present
label as a verified identity. Render it as untrusted free text (e.g., "This app calls itself: …") rather than an authoritative " wants access".
- Optional relayer allowlist for the
relayer param, or a prominent warning when it is non-default.
Separate low / defense-in-depth note
Independently of the above, add_delegate_key never verifies sui_address is derived from public_key, and dedups only on public_key (not sui_address). Worth adding as defense-in-depth (and to keep the on-chain public_key field meaningful), but — as shown above — it is not what enables the phishing attack and should not be presented as the fix for it.
Component:
apps/app/src/pages/ConnectMcp.tsx(browser consent flow for the@mysten-incubation/memwal-mcpbridge)Type: Security — consent/authorization phishing (UX + missing request binding)
Severity: Medium (requires social engineering + victim wallet approval)
Summary
The
/connect/mcppage registers a delegate key on the connected wallet's Walrus Memory account based entirely on query-string parameters, and its consent screen presents an attacker-controlled application name (label) plus a delegate address the victim has no way to validate. Because the page can be opened by anyone with any parameters (there is no binding to the local MCP bridge that is supposed to originate the request), an attacker can send a victim a link on the trustedmemory.walrus.xyzdomain that reads "Cursor wants access to your Walrus Memory" and, on approval, permanently grants the attacker's own delegate key read/decrypt/write access to all of the victim's memories until manually revoked.This is an app-level consent-phishing surface, not a wallet-level issue: the branded, first-party consent screen actively lends credibility to a request that is fully attacker-defined.
Verified facts (code)
apps/app/src/pages/ConnectMcp.tsx:publicKey,delegateAddress,label,relayer,port,connectStateare all read directly fromuseSearchParams().paramsValidvalidates format only (hex length / port range); it does not verify any relationship between the parameters, nor the origin of the request.account::add_delegate_key(accountId, publicKey, delegateAddress, label, clock)with the raw params, signed by the connected wallet (the victim, who is the account owner) — so the on-chain owner check passes.ConsentCard) shows the attacker-controlledlabelas the requesting app, a truncateddelegateAddress(slice(0,16)…slice(-6)), and therelayer. ThepublicKeyis not shown, and there is no warning that the link source must be trusted.connectState) guards the localhost callback, not the on-chainadd_delegate_key. There is no referrer/opener/bridge-origin check.I confirmed
paramsValidreturnstruefor entirely attacker-chosen values (randompublicKey, an attacker-owneddelegateAddress, arbitraryport, a self-generated 64-hexconnectState).Attack flow (no local bridge required on the victim side)
(pk, addr)(a normal, self-consistent keypair they control).https://memory.walrus.xyz/connect/mcp?port=20000&publicKey=<pk>&delegateAddress=<addr>&label=Cursor&relayer=<relayer>&connectState=<random 64-hex>add_delegate_keysucceeds (victim is the owner). The attacker's key is now a registered delegate on the victim's account.(pk, addr)to: decrypt the victim's memories via SEAL (seal_approvedelegate branch matchesaddr) and read/write via the relayer (auth.rsmatchespkindelegate_keys). Access persists until the victim manually revokes it.Important: this is NOT the
public_key/sui_addressdesyncA related report framed the root cause as the contract not verifying that
sui_address == blake2b256(0x00 ‖ public_key)inadd_delegate_key, and proposed enforcing that derivation. That fix would not prevent this attack, and the desync is not the enabling factor:verify_delegate_key_onchain, needs the matching private key); SEAL decryption checks the address (is_delegate_address).addr = derive(pk)), which grants both relayer and SEAL access and passes any derivation check.The vulnerability is the consent/phishing surface — who can trigger the request and how it is presented — not key derivation.
Mitigating factors (honest severity)
add_delegate_keymove call, so a vigilant user could refuse. In practice the branded first-party consent screen suppresses suspicion, which is exactly what makes consent phishing effective.Recommended improvements
connectState(e.g., only proceed if the localhost/callbackpreflight/handshake onportsucceeds before signing), so a bare emailed link cannot drive an on-chain add.delegateAddressandpublicKey; and warn the user to only continue if they personally started this from their own machine.labelas a verified identity. Render it as untrusted free text (e.g., "This app calls itself: …") rather than an authoritative " wants access".relayerparam, or a prominent warning when it is non-default.Separate low / defense-in-depth note
Independently of the above,
add_delegate_keynever verifiessui_addressis derived frompublic_key, and dedups only onpublic_key(notsui_address). Worth adding as defense-in-depth (and to keep the on-chainpublic_keyfield meaningful), but — as shown above — it is not what enables the phishing attack and should not be presented as the fix for it.