June 2nd, 2026

The Gnosis Pay hack: A deep dive

Research & Analysis
Federico Kunze Küllmer
CEO and Founder
TL;DR

On June 1st, 2026, an attacker drained ~$1.2M from @gnosispay users' debit card-linked wallets, affecting thousands of users.

Although the @zodiaceco's delay module was marked as the initial diagnosis; the vulnerability wasn't in the module itself: it affected the Modifier base class one level down. The implication is that any contract that extends Zodiac's Modifier, Delay 1.1, and Roles v2 is potentially affected if one of its privileged callers is a Safe. The attack needs no access to the user's private keys. A public contract call plus a forged calldata was enough to drain the funds.

This post walks through the vulnerability, replays the live exploit against @gnosischain,  and lays out concrete mitigations for affected protocols and their users.

Attacker addresses (live as of writing):

  • 0xF9Db86Db4FF8ea2a1364544700D076F59eDbcC91 (main EOA, 18k+ transactions)
  • 0xBB897A566146FDB959Ddde80Ce00C50B4F5bE03F (3-of-6 Safe multisig)
  • 0x81BA8A2b895D30280bca199C2Ff75f3F058d4C6c
  • 0x854cF78138Fe3d87193E05030bfaa7a2A898Bcf9

Victim addresses are redacted throughout this article.

Impact: Affected Users and Volume

The impact of the attack is large, affecting a total of ~1,000–5,000 users (rough estimate, our team wasn’t able to trace through all internal transactions) by firing ~34k total transactions (verified onchain).

Funds held in identifiable attacker addresses (block 46,494,105 on Gnosis Chain):

Total ≈$1.16-$1.20M (lower bound since 0x854c...Bcf9 is already emptied)

Why does Gnosis Pay run a Delay Module, and why didn't it prevent the vulnerability?

In the Delay Module, this is to prevent users from double spending their tokens on their Safe Account and is solely for on-chain transactions, most hackers of course will be dealing with this module.

The Delay Module doesn't guard card payments, but the user's own on-chain withdrawals. It puts a three-minute delay on any transfer the user initiates out of their Safe, so they can't double-spend funds that a card authorization has already committed. During those three minutes, the card is practically frozen. It's a consistency lock between the card rail and the self-custody rail.

The attacker used the vulnerability in the underlying Modifier to queue an arbitrary transfer, which is how they took both GNO and EURe, not just the EURe that the Roles Module would have permitted. The three-minute delay was the only thing between queuing and draining, and a delay isn't a defense against a patient attacker: you queue, you wait three minutes, you execute. There's no veto a normal user can reach in that window, and no alert fires when something unexpected lands in your queue. The delay was calibrated to card-authorization timescales — minutes — because that's all it was ever meant to cover. It was never an anti-theft control, and it didn't become one when it had to be/

The Delay Module's cooldown was not a mitigation. The cooldown only delays; it does not prevent the attacker from queuing the malicious transaction in the first place, and once queued, the funds are drained. A key finding was that most users were not notified of the cooldown activation, and since they were not watching their queue, it prevented them from noticing the attack until it was too late. The cooldown shrank the blast-radius window from "instant" to "minutes." It did not prevent the loss.

The 3-minute attack

A typical Gnosis Pay card-linked Safe is wired up like this:

  • A user's Safe holds the funds for the debit card
  • A Delay Module is enabled on the user's Safe, intended to cool down user-initiated outgoing transfers
  • A Roles v2 Module is enabled on the Delay Module, so card swipes can route through the cooldown and prevent potential double-spending

Here's what the attacker does:

  1. From an EOA, call execTransactionFromModule(to, value, data, operation) on the victim's Delay Module, with a 97-byte calldata trailer encoding a forged contract signature.
  2. The Delay Module's moduleOnly gate accepts the call. The attacker isn't an enabled module, but the EIP-1271 contract-signature fallback can be tricked into returning true.
  3. The Delay Module queues the transaction. Whatever (to, value, data, operation) the attacker supplied is now in the queue, typically a multiSend delegate-call that batches ERC20.transfer(attacker, balance) for every token on the Safe.
  4. After the cooldown elapses, anyone calls executeNextTx(...) with the matching parameters. The Delay Module invokes execTransactionFromModule on the user's Safe (which has the Delay Module enabled). The Safe executes the inner multiSend. Funds move.

No signing keys. No social engineering. No compromise of any user. The attacker needs one piece of state: the address of a victim's Delay Module. Those addresses are public, indexable on-chain, and shared as a deployment pattern across an entire user base.

Root cause: a discarded success boolean

The bug is in SignatureChecker._isValidContractSignature in @gnosis.pm/zodiac@3.4.1's base contracts, inherited by Modifier:

function _isValidContractSignature(
   address signer,
   bytes32 hash,
   bytes calldata signature
) internal view returns (bool result) {
   uint256 size;
   assembly { size := extcodesize(signer) }
   if (size == 0) {
       return false;
   }

   (, bytes memory returnData) = signer.staticcall( // <-- 'success' boolean is dropped here
       abi.encodeWithSelector(
           IERC1271.isValidSignature.selector,
           hash,
           signature
       )
   );

   return bytes4(returnData) == EIP1271_MAGIC_VALUE;
}

The staticcall destructuring is the problem: (, bytes memory returnData) = signer.staticcall(...). The first return (the bool success flag) is thrown away altogether. The function then casts the first four bytes of returnData to bytes4 and compares to 0x1626ba7e, the EIP-1271 v0.5 magic value.

When a staticcall reverts, the EVM still populates returnData with the revert payload. Standard Solidity reverts begin with Error(string) selector 0x08c379a0 or Panic(uint256) selector 0x4e487b71. Neither matches the magic value, so ordinary revert paths don't trigger the bug. But one specific contract, called via this exact path on this exact selector, reverts with a 4-byte payload of literally 0x1626ba7e: a Safe v1.3.0 wallet with its default fallback handler.

That coincidence is what makes the vulnerability practical. More on why below.

The structural fix is one line: include the success flag.

(bool success, bytes memory returnData) = signer.staticcall(...);
return success && bytes4(returnData) == EIP1271_MAGIC_VALUE;

The safer rollout is a fresh audited mastercopy plus per-protocol migration. Modifier proxies across the ecosystem all delegate-call to the same logic contract, and a mismatch between the bytecode you think is deployed and the bytecode that's actually deployed is exactly the kind of thing that bites months later.

The live exploit replay

Any victim transaction reproduces. We picked one specific malicious queue submission from block 46,468,498 on Gnosis Chain, transaction 0x1372dcc271ca253e7555b8cb68f0051822ad1c56e183c1a28af329385bb7c8eb.

The transaction's input data, broken down:

0x468721a7                                                      # execTransactionFromModule selector
00000000000000000000000038869bf66a61cf6bdb996a6ae40d5853fd43b526 # to: MultiSend
0000000000000000000000000000000000000000000000000000000000000000 # value: 0
0000000000000000000000000000000000000000000000000000000000000080 # data offset
0000000000000000000000000000000000000000000000000000000000000001 # operation: DelegateCall
0000000000000000000000000000000000000000000000000000000000000184 # data length: 388 bytes
8d80ff0a...                                                     # multiSend payload (transfers ERC20 to attacker)
                                                               # ... 388 bytes of standard ABI-encoded args
000000000000000000000000<INNER_SIG_R_ADDR_REDACTED>             # inner signature blob begins here
0000000000000000000000000000000000000000000000000000000000000082 # ...
...
c7b55b749fce5d2b9d26c5b092e9528e2d524eee060b08b7391db97210ed3813 # salt (32 bytes)
000000000000000000000000<VICTIM_SIGNER_SAFE_REDACTED>            # r = signer address (the victim's enabled-module Safe)
0000000000000000000000000000000000000000000000000000000000000244 # s = offset (580) into calldata where inner sig begins
00                                                              # v = 0 (contract signature path)

The last 97 bytes of the calldata are the appended trailer: salt(32) + r(32) + s(32) + v(1). With v = 0, verification routes through the contract-signature branch in moduleTxSignedBy():


if (v == 0) {
   uint256 start = uint256(s);  // 0x244 = 580
   if (start < 4 || start > end) {
       return (bytes32(0), address(0));
   }
   address signer = address(uint160(uint256(r)));  // victim's enabled-module Safe
   bytes32 hash = moduleTxHash(data[:start], salt);
   return
     _isValidContractSignature(signer, hash, data[start:end])
       ? (hash, signer)
       : (bytes32(0), address(0));
}

The attacker chooses every byte that matters: the signer (which must be an enabled module on the Delay), the start offset (so moduleTxHash hashes whatever prefix of the calldata they want), and the inner signature bytes passed to the signer's isValidSignature.

Reproduce with cast against Gnosis Chain:

# Compute the moduleTxHash the Modifier will hash over the chosen prefix.
cast call 0xVICTIM_DELAY \\
 "moduleTxHash(bytes,bytes32)(bytes32)" \\
 0x468721a7...<first 580 bytes of the calldata>... \\
 0xc7b55b749fce5d2b9d26c5b092e9528e2d524eee060b08b7391db97210ed3813 \\
 --rpc-url <https://rpc.gnosischain.com>
# Returns: 0x39659e8209e5907102278347a4909bf74a7476741e5e82300cfdf31d88877d54

# Call isValidSignature on the signer with that hash and the inner sig.
cast call 0xVICTIM_SIGNER_SAFE \\
 "isValidSignature(bytes32,bytes)(bytes4)" \\
 0x39659e8209e5907102278347a4909bf74a7476741e5e82300cfdf31d88877d54 \\
 0x<inner sig, 194 bytes> \\
 --rpc-url <https://rpc.gnosischain.com>
# Returns an error: execution reverted, data: "0x1626ba7e"

The staticcall reverts, but the revert payload is exactly "0x1626ba7e". Four bytes, identical to the EIP-1271 magic value selector. Back inside _isValidContractSignature, the discarded success flag and the matching bytes4(returnData) mean the function returns true. moduleOnly opens. The transaction queues.

After the Delay Module's cooldown of 3 minutes, anyone can call executeNextTx with the matching parameters. The drain lands. Funds end up in the attacker's multisig Safe.

Why a Safe's isValidSignature reverts with the magic value

This part is where the bug crystallises. A Safe v1.3.0 wallet doesn't natively implement isValidSignature(bytes32, bytes). That selector lives in the CompatibilityFallbackHandler, which the Safe forwards to via its fallback function. The handler's implementation:

function isValidSignature(bytes32 _dataHash, bytes calldata _signature)
   external view returns (bytes4)
{
   ISignatureValidator validator = ISignatureValidator(msg.sender);
   bytes4 value = validator.isValidSignature(abi.encode(_dataHash), _signature);
   return (value == EIP1271_MAGIC_VALUE_BYTES) ? UPDATED_MAGIC_VALUE : bytes4(0);
}

Two things happen here. First, the handler casts msg.sender to ISignatureValidator and calls back into it on the legacy isValidSignature(bytes, bytes) selector (magic value 0x20c13b0b). Second, if that returns the legacy magic value, the handler returns the updated magic value 0x1626ba7e; otherwise it returns zero.

In our exploit context, msg.sender inside the handler is the Delay Module. The Zodiac code called signer.staticcall(isValidSignature(bytes32, bytes)), the Safe forwarded through its fallback handler, and the handler is now trying to call the legacy selector back on the Delay. The Delay Module doesn't implement isValidSignature(bytes, bytes). The call reverts.

When the inner Solidity high-level call reverts, the outer handler propagates the revert. The exact bytes of the revert payload depend on the Safe version, the handler version, and how Solidity's revert propagation interacts with the EVM's returndata buffer for this specific call chain. We verified empirically on Gnosis Chain mainnet that for the deployed handler on the affected Safes, the propagated revert payload is exactly 0x1626ba7e. Four bytes. Identical to the updated EIP-1271 magic value.

Two pieces of audited code, each reasonable in isolation, compose into something exploitable. Nobody wrote this bug. It emerged.

The Zodiac side made a defensible decision to support EIP-1271 contract signatures, then made a single sloppy line by discarding the staticcall success. The Safe v1.3.0 handler made a defensible decision to expose EIP-1271 to legacy callers, then in some revert path produced bytes that happen to collide with the magic value selector. Neither component is wrong on its own. Together they're exploitable.

If you staticcall something and pattern-match on the returned bytes, include the success check. The EVM does not distinguish "returned this on purpose" from "reverted with this happens to look like this" at the bytecode level. Your code has to.

Mitigation for affected protocols and users

Gnosis Pay

Gnosis Pay mentioned in their last post that the issue is fully contained and affected users will be made whole.

If your card-linked Safe still has funds, move funds out preemptively. However, if your Safe has already been drained, a meaningful amount of stolen funds appears to be sitting in a 3-of-6 attacker-controlled multisig (0xBB897A566146FDB959Ddde80Ce00C50B4F5bE03F) that, at the time of writing, still holds most funds and hasn't bridged out. Recovery depends on a coordinated effort between the Gnosis teams, bridge validators, exchanges blacklisting the addresses of the attacker, and community or governance mechanisms to try to recover the funds onchain.

Other protocols using Zodiac

Whilst the vulnerability seems largely contained and the affected accounts identified, this bug affects any contract that extends Zodiac's Modifier class (either Delay 1.1, Roles v2, or a custom Modifier). Every Safe that is set as a privileged caller falls in this category. For example, in the Roles v2 Module, a Safe that is assigned a role is ultimately vulnerable to the same attack vector.

What's next for stablecoin cards

Nailing stablecoin-settled cards is hard: they need to enable payment network debit of a self-custodial account in less than two seconds, at a coffee shop, without the user ever surrendering their keys, and without opening a vulnerability that an attacker can use to drain funds. The Zodiac module stack is one answer to that, and it got a lot of the architecture correct.

Our team at @moneda_com has been working on the right fully self-custodial debit card architecture. What does a stablecoin card account look like if you take into account the UX a cardholder actually expects, and the security properties a self-custodial system has to guarantee? How can you enable a true debit card experience, without the need to pre-fund a wallet, and whilst enabling a secure setup?  How fast can settlement be without the speed itself becoming the vulnerability? What's the smallest amount of trust a user has to extend to make a self-custodial card work at all? We don't think those questions are fully answered yet, by anyone in the ecosystem, and we're keen on announcing our solution soon.

Special thanks to Zodiac and Safe team members for reviewing this article.

Share this post

Moneda Explains: Passkeys

A passkey is a cryptographic credential that replaces your password. Here is how it works, why it resists phishing, and how Moneda uses it.

Moneda Explains
Read article

The Gnosis Pay hack: A deep dive

A debit-card exploit that needed no private keys, no phishing, and no user mistake. We trace how a single discarded line of code let an attacker drain roughly $1.2M from Gnosis Pay wallets, and what it means for anyone building on Zodiac.

Research & Analysis
Read article

The Mr. Meeseeks Problem That's Haunting Agentic Finance

AI agents are coming for your payments. The infrastructure being built to support them has a fundamental flaw nobody is talking about.

Freshly Minted
Read article
Copied To Clipboard.