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
- Clone and pin:
git clone https://git.ghostchain.io/ghostchain/ghost-extension-wallet.git && git -C ghost-extension-wallet checkout 66bd2077c7e82629eba7574466dd1cd94fac2bb9
- Save the harness above as
poc_postmessage.mjs(it evals the repo’s own listener bytes fromsrc/content/index.tslines 17 to 25 on a fakewindow, withchrome.runtime.connectreplaced by a sink). - Run:
node poc_postmessage.mjs
- Observe the ATTACK case: a message whose
data.originisghost-extension/webbut whosesourceis a child frame IS forwarded to the sink with itscreateTxpayload; the CONTROL case (other/web) is dropped; the CONTROL-2 case (source === window) forwards normally. - 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).
- 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.