[High] Content script bridge trusts a spoofable origin field in sign requests

Severity: High

Component: ghost-extension-wallet @ 66bd2077c7e82629eba7574466dd1cd94fac2bb9 (content/index.ts lines 17 to 25, background/createBackgroundRpc.ts lines 84 to 125 and 346 to 361, SignRequest.tsx lines 59 to 66, assets/manifest-v3-chrome.json lines 22 to 27)

Source review only. No browser automation against any site. I reproduced the bridge logic offline against the verbatim listener code, 5 of 5 runs identical.

Description

The content script bridge authenticates incoming page messages only by a string field inside the message body, data.origin, and never checks event.source or event.origin. Any window that can deliver a message event to the page, a cross origin iframe embedded on the trusted page, or a popup opener that holds a WindowProxy to an opened trusted page, can set that body field and get its message forwarded to the extension background. The sign request popup then displays the Origin from port.sender.url, which for a content script port is the top level page URL, not the frame or window that actually sent the request. createTx and pjsSignPayload are both in the allowed web method list for this port, so the forged request can carry attacker chosen call data and be signed after the user approves.

const port = chrome.runtime.connect({ name: CHANNEL_ID })
port.onMessage.addListener((msg) =>
// origin is needed to filter from other postMessages
window.postMessage({ origin: `${CHANNEL_ID}/extension`, msg }),
)
window.addEventListener("message", ({ data }) => {
    if (data.origin !== `${CHANNEL_ID}/web`) return
    port.postMessage(data.msg)
})

Proof of concept (the verbatim listener from content/index.ts lines 17 to 25 executed under Node on a simulated window event bus, with the background port replaced by a sink that records what gets forwarded; no browser, no network, no site is contacted. 5 of 5 runs byte identical):

// harness shape: eval the repo's own listener bytes on a fake window
const sink = []                                   // stands in for port.postMessage
const window = {
  postMessage: (m) => {},
  addEventListener: (type, fn) => { listeners.push(fn) },
}
// ...the repo's exact listener code runs, then:
fire({ data: { origin: "ghost-extension/web", msg: { method: "createTx" } },
       source: childFrame })                      // ATTACK: child frame forges the body field
fire({ data: { origin: "other/web", msg: {} }, source: childFrame })   // CONTROL
fire({ data: { origin: "ghost-extension/web", msg: { method: "getAccounts" } },
       source: window })                          // CONTROL 2: legit same-window path

Raw output of the PoC (identical on all 5 runs):

GF02_ATTACK_SOURCE_EQ_WINDOW=false
GF02_ATTACK_SINK=[{"method":"createTx","tag":"attack"}]
GF02_PASS ATTACK child-frame source forwarded
GF02_CONTROL_SOURCE_EQ_WINDOW=false
GF02_CONTROL_SINK=[]
GF02_PASS CONTROL other/web dropped
GF02_CONTROL2_SOURCE_EQ_WINDOW=true
GF02_CONTROL2_SINK=[{"method":"getAccounts","tag":"legit"}]
GF02_PASS CONTROL-2 source===window correct origin forwarded
GF02_LISTENER_RESULT=PASS

The attack message, whose source is a child frame rather than the page window, is forwarded to the background port sink with its createTx payload, because the listener only reads the attacker controlled data.origin body field. Controls: any other body origin is dropped, and the same window legitimate path is unaffected. A source scan also confirms event.source and event.origin appear zero times under src/, createTx and pjsSignPayload are in ALLOWED_WEB_METHODS, both manifests omit all_frames, and the popup Origin field is bound to request.url from port.sender.url.

Steps to reproduce

  1. Clone and pin:
git clone https://git.ghostchain.io/ghostchain/ghost-extension-wallet.git && git -C ghost-extension-wallet checkout 66bd2077c7e82629eba7574466dd1cd94fac2bb9
  1. Save the harness above as poc_postmessage.mjs (it evals the repo’s own listener bytes from src/content/index.ts lines 17 to 25 on a fake window, with chrome.runtime.connect replaced by a sink).
  2. Run:
node poc_postmessage.mjs
  1. Observe the ATTACK case: a message whose data.origin is ghost-extension/web but whose source is a child frame IS forwarded to the sink with its createTx payload; the CONTROL case (other/web) is dropped; the CONTROL-2 case (source === window) forwards normally.
  2. Optional source confirmation: grep for event.source and event.origin under src returns zero hits; both manifest files omit all_frames; createTx and pjsSignPayload are in ALLOWED_WEB_METHODS (createBackgroundRpc.ts lines 346 to 361); the popup binds Origin to request.url from port.sender.url (SignRequest.tsx lines 59 to 66).
  3. Run 5 times; byte identical output.

Impact

Toy numbers: an attacker runs an iframe inside a trusted dApp page, or opens the trusted page from an attacker page with window.open. The attacker posts a createTx request whose call data transfers the user’s full free balance, for example 10_000 CSPR, to an attacker account. The user, with the keyring unlocked from earlier in the session, sees Origin = the trusted page URL in the sign popup and approves. The extension signs and hands the extrinsic back through the same bridge. The popup does show the raw and decoded call data, which is why this is High and not Critical: the attack defeats the Origin display the user is trained to trust, and requires an unlocked keyring plus one Approve click.

Fix

In the content script listener accept only events where event.source === window and match event.origin to the page origin, and reject framed windows outright. Derive the sign request Origin from a non spoofable source, and gate web sign methods on the verified sender frame.

Thank you for your report!

For now the extension is fully redesigned and will use slightly another way of keystore storage. But I’m curious, will it in any case request the user password with decoded trasnaction modal? If yes, it is up to user just to close that window and never open it back, or what I’ missing here?

Thanks for the reply and the redesign note.

The gap is not the password modal itself, it is that the user cannot tell the request is forged. The popup shows the Origin from port.sender.url, which for a content script port is the top level page URL, not the frame or window that actually sent the request. So in the attack, the user is on a trusted page, a cross origin iframe injects a message with the spoofed data.origin string, the content script forwards it, and the approval modal displays the trusted site as the origin. To the user it is indistinguishable from a legitimate request from the page they are on, so closing the window only helps a user who already suspects something.

The fix is in the bridge, not the UI: check event.origin (and ideally event.source) against the page origin before forwarding to the background, instead of trusting a string field inside the message body. The redesigned keystore storage is a good moment to add that check.

Can you check the latest version of the repo please? Does the issue you are describing persists?

Checked the latest version. HEAD d3274222f9aaa99456ed8759687c2915d21eeac8 (“rewrite extension around new UX”, 2026-10-08) vs the audited pin 66bd2077c7e82629eba7574466dd1cd94fac2bb9.

Yes, the issue persists, same mechanism. The content script bridge is byte identical (sha256 match on src/content/index.ts across both revisions):

window.addEventListener("message", ({ data }) => {
    if (data.origin !== `${CHANNEL_ID}/web`) return
    port.postMessage(data.msg)
})

It still forwards on the body string data.origin and still never reads event.source or event.origin. CHANNEL_ID is still “ghost-extension” (src/constants.ts:43), createTx and pjsSignPayload are still on that port’s web allowlist (src/background/createBackgroundRpc.ts:450-474), both manifests still register the content script without all_frames (top frame only, http/https), and the sign popup still displays Origin from port.sender.url of the top frame (SignRequest.tsx:133-137).

What the rewrite adds: a 3 second cooldown keyed by that same top-frame URL, and the account password is now always required on Approve (the background throws without it, createBackgroundRpc.ts:246-253). Both are good hardening, but neither ties the request to the window that posted it: the iframe in the attack still controls when the modal opens, and the password prompt shows the same top-frame URL as Origin.

The one line fix remains: check event.origin (and ideally event.source) against the page origin before forwarding.