Ambire Wallet: Five vulnerabilities V12 found across provider and approval boundaries
V12 found five vulnerabilities while reviewing Ambire Wallet v6.13.4, a browser extension for Ethereum and EVM networks.
V12 found five vulnerabilities while reviewing Ambire Wallet v6.13.4, a browser extension for Ethereum and EVM networks. The findings spanned provider message routing, SIWE auto-login policy, mutable approval state, and the boundary between typed-data signing and executable smart account operations.
These issues had a common theme, what the user had explicitly reviewed and authorized was not enforced. In this article we will go through each finding, demonstrating impacts ranging from complete account takeover on a dApp to arbitrary malicious transactions being signed.
The findings
Before getting into the bugs, let’s walk through how a browser wallet actually handles a request.
When a dApp calls ethereum.request(), the request does not go straight to the approval window. It first passes through the provider injected into the page, then a content script, and then the extension’s background script. The background script creates the request, keeps track of its current state, and sends a copy of the state to the approval window, which is shown to the user.
When the user clicks “Sign”, the action travels back in the other direction. One thing to note is that the approval window does not render the background state directly; it renders the latest copy it has received from the background script.
Most of the time, this makes no difference. It becomes security relevant when another event changes the background state between the screen being rendered and the click being processed or when a response is delivered outside the context that created the request. Several of V12’s findings came from following those boundaries end to end.
Provider messages were sent to the whole tab
Ambire injects its provider into every frame, which is normal for a browser wallet because a dApp may be running in the top-level page or inside an iframe. It also means one browser tab can contain several provider instances belonging to different origins.
For example, a connected dApp app.example.com could contain an iframe from app.other.com. The two pages are in the same tab, but they are not the same security context. Connecting the provider in one of them should have no effect on what the other frame can do.
When responding to a request, the background script sent replies using a helper that only accepted a tab ID:
function sendMessage<TPayload>( message: SendMessage<TPayload>, { tabId }: { tabId?: number } = {}) { if (typeof tabId === 'undefined') return chrome?.runtime?.sendMessage?.(message)
return chrome.tabs?.sendMessage?.(tabId, message)}
// ... sendMessage( { topic: repliedTopic, payload: { response }, id: message.id }, { tabId: sender.tab?.id } )Source: Vulnerable message helper and reply call site
With no frameId or documentId provided, Chrome will send the message to all matching content scripts in the tab. Ambire then accepted replies by checking their topic and request ID. It did not check which provider, frame, document, or origin the reply belonged to.
To validate this finding, the top-level dApp was connected to the wallet, and then an iframe with a different origin was placed in the page that listened for provider events. Changing the selected account caused the iframe to receive accountsChanged, and the same happened with chain changes and wallet lock/unlock events.
There was a similar issue with navigation, as a session created for one page could remain alive after the tab navigated away to another origin. This allowed the new document to receive events intended for the old one.
The reply path was more serious, since a malicious frame could leave requests with chosen numerical IDs waiting in the background. If one of those IDs matched a request in another frame, a response would be delivered to both content scripts and accepted by both bridge listeners.
In the signing case, this meant the malicious frame could receive the signature returned for a request that the user had approved in the other frame. The same bug affected any provider method whose result travelled over that reply path.
A malicious frame could use this bug to leak wallet addresses, receive signed messages, or even be sent the plaintext of a message the victim had just decrypted.
SIWE auto-login matched sibling subdomains
The next issue was in Ambire’s Sign In with Ethereum auto-login feature.
A SIWE message contains the site authority, wallet address, URI, chain ID, nonce, and a few other fields. Normally, the wallet shows that message to the user before signing it, but Ambire could also remember an auto-login policy for a site. The wallet would then automatically sign future challenges without showing another approval prompt for that site.
The issue was that Ambire compared the two hosts by reducing them to their registrable domain, not by comparing their origin. This was the actual check:
const requestHostname = new URL(requestOrigin).hostconst parsedSiweMessage = new SiweMessage(messageString)
if (getDomain(parsedSiweMessage.domain) !== getDomain(requestHostname)) return { parsedSiwe: AutoLoginController.convertSiweToViemFormat(parsedSiweMessage), status: 'domain-mismatch' }Source: Vulnerable SIWE domain check
For example, both getDomain('app.example.com') and getDomain('evil.example.com') return example.com, and the mismatch branch is skipped.
This could be exploited under the following circumstances:
- A victim enables SIWE auto-login for
app.example.com. - An attacker controls a connected sibling host such as
evil.example.com. - The attacker starts their own unauthenticated login session with
app.example.comand receives a fresh nonce for that session. - From
evil.example.com, they ask the victim’s wallet to sign a SIWE message containing the target host, the victim’s address, and the attacker’s nonce. - Ambire considers the stored policy active and returns the signature without showing the normal SIWE approval.
- The attacker submits the signed message to
app.example.comin the session they started earlier and becomes authenticated as the victim’s wallet address.
This would let the attacker authenticate their own session as the victim, resulting in a complete account takeover on the dApp. Any non-wallet action the victim was able to do on the dApp after logging in would now be available to the attacker.
A “Sign” click could move to another message
V12 also found a race condition in the signing flow. Ambire kept the active request in a single SignMessageController, and when the user clicked “Sign”, the UI sent the selected signing keys to the background, but it did not send an identifier or digest of the message that was on screen.
Signing then paused while the wallet retrieved the signer, but during that pause, another request of the same kind and for the same account could replace the contents of the controller.
The check after the asynchronous signer lookup only confirmed that the controller still contained some message:
#isSigningOperationValidAfterAsyncOperation() { return this.isInitialized && !!this.messageToSign}
// ...
async #sign() { // ... this.signer = await this.#keystore.getSigner(signerKey.addr, signerKey.type) // ... if (!this.#isSigningOperationValidAfterAsyncOperation()) return}Source: Vulnerable message signing check and signer lookup
The replacement request satisfied both conditions. The operation then read the current message from the controller, which was now R2, and returned that signature to R2 without another click.
R1 itself was rejected when the replacement happened, and the signing operation started by R1’s approval continued running but now with R2’s state.
For typed data, the replacement signature could authorize a permit, order, delegated action, or login and potentially lead to asset loss.
A transaction could gain another call after review
The review surfaced a second race condition in the signing flow.
Ambire intentionally combines compatible dApp calls for the same account and chain, so if a second call arrives while an account operation is still waiting for approval, the background can append it to the existing operation rather than opening another request.
Appending a call changes the operation’s internal ID, but the approval window sent back a stable request ID when the user clicked “Sign”. It did not send the ID of the account operation that had produced the screen.
The UI sent only the stable request ID:
mainControllerDispatch({ type: 'method', params: { method: 'handleSignAndBroadcastAccountOp', args: [type, signAccountOpState.fromRequestId] }})Source: Vulnerable approval dispatch
The background then used that ID to resolve the current mutable operation:
if ( this.requests.currentUserRequest?.kind === 'calls' && this.requests.currentUserRequest.signAccountOp.fromRequestId === fromRequestId) { signAccountOp = this.requests.currentUserRequest.signAccountOp}Source: Vulnerable account operation lookup
This left a small window between the background accepting a new call and the approval window rendering the updated state:
- A harmless call is shown in the approval window.
- The dApp sends another call for the same account and chain.
- The background appends it to the live account operation.
- The user’s existing Sign action reaches the background before the updated operation reaches the screen.
- The background resolves the stable request ID and signs the new call list.
The result was an operation containing a call that the user had not reviewed. The appended call could transfer assets, grant token approvals, or invoke another contract from the selected account.
An account switch turned typed data into an executable operation
The fifth finding was more involved because it crossed both the extension and the Ambire smart account signature format.
Ambire smart accounts can execute a list of calls after validating a signature from one of their privileged keys. The wallet also supports ordinary EIP-712 typed data signing, which is used by dApps for structured messages.
Ambire specifically blocked generic signing of the AmbireOperation type, which contains an operation hash representing smart account calls. The generic typed-data approval screen only shows the hash; it does not provide the normal transaction review showing what those calls will do.
The issue was that the check only ran when the account requested by the dApp was already selected. So if account A was selected and account B was an imported Ambire smart account, a dApp could request an AmbireOperation signature from B. As B was not selected, the request was placed behind an account switch prompt before it reached the block.
The selected account condition and later account switch path looked like this:
if ( msgAddress === this.#selectedAccount.account.addr && (typedData.primaryType === 'AmbireOperation' || !!typedData.types.AmbireOperation)) { throw ethErrors.rpc.methodNotSupported('Signing an AmbireOperation is not allowed')}
// ...
const isASignOperationRequestedForAnotherAccount = isSignRequest(userRequest.kind) && (userRequest as SignUserRequest).meta.accountAddr !== this.#selectedAccount.account?.addr
if (!isASignOperationRequestedForAnotherAccount) { await this.addUserRequests([userRequest], { // ... }) return}
await this.#addSwitchAccountUserRequest(userRequest as SignUserRequest)Source: Vulnerable AmbireOperation check and account switch path
The user would then see two prompts:
- Switch from account A to account B.
- Sign the typed-data message.
After the switch, Ambire put the original request back into the queue without rebuilding it or checking the type again. The user still had to approve both prompts, but the second screen showed an opaque operation hash rather than the calls represented by that hash.
At this point the dApp had a valid typed-data signature, but it was returned using Ambire’s unprotected signature mode, 00. A normal executable Ambire operation uses mode 01.
The mode was appended to the signature after signing, so it was not covered by the signature itself and could be modified while keeping the signature valid. Modifying this byte changed how the smart account contract reconstructed the digest to verify.
In this case, the wallet had already signed the same canonical EIP-712 AmbireOperation digest that mode 01 would reconstruct. Replacing 00 with 01 therefore caused the contract validator to recover the address of a privileged signer for the executable operation.
We changed that byte and submitted the signature with the original calls to AmbireAccount.execute(), and the calls executed from the smart account and its nonce increased.
Those calls could include transfers or approvals from the smart account.
The user interaction makes this different from a silent signing bug, as the user approved an account switch and a typed-data request. The problem was that the wallet had deliberately blocked this type from the generic flow because the screen did not explain its executable meaning, and the account switch path bypassed that protection.
Fixes
Ambire fixed the provider issue by sending replies to the exact frame or document that made the request. The provider ID and origin are now checked as well, and sessions are removed correctly when their tab closes.
SIWE auto-login now compares normalized hostnames and ports rather than their shared registrable domain. A policy for one subdomain no longer applies to its siblings.
For message signing, each controller initialization receives a generation number. A signing operation records that number and checks it again after asynchronous work, so replacing the request causes the stale operation to stop.
Transaction approvals now include the account operation ID that was shown on screen. If another call changes the operation before the click is processed, the IDs no longer match and the user has to review the updated transaction.
The AmbireOperation check was added at several points in the request and signing path, including around account switching and inside the generic signing library. Internal wallet code that genuinely needs to sign this type must now opt in explicitly.
The first four fixes shipped in Ambire Wallet v6.14.2. The AmbireOperation fix followed in v6.14.3. Both extension releases were published on July 15th, 2026.
We would like to thank the Ambire team for working with us to validate and fix these findings and for coordinating disclosure.
Conclusion
Each of V12’s findings broke a similar invariant: what the user saw and approved was not in sync with what the wallet actually did. V12 was able to trace each request past the approval screen and through every hop where that binding could break.
V12 is your security agent for mission critical software.
Find bugs like this in your code: https://v12.sh/.