October 20, 2026
|
Exploit Postmortem
Cover image for "Gnosis Pay's $1.5M Loss and How Olympix Would Have Prevented It"

Gnosis Pay's $1.5M Loss and How Olympix Would Have Prevented It

Date: June 1, 2026

Chain: Gnosis Chain

Loss: $1.5M taken by the attacker, per Gnosis Pay's post-mortem, plus about $300K left in inaccessible accounts

Class: signature check that ignored whether the verifying call succeeded

On May 29, 2026, a wallet on Gnosis Chain deployed 41 small contracts, with the example deployment CertiK points to landing at 13:29:55 UTC. Each one was built to hand back the same four bytes, 0x1626ba7e, the value a smart contract returns under the EIP-1271 standard to say "yes, this signature is valid." Three days later, at 05:26:10 UTC on June 1, one of those contracts was put to work against the Delay module guarding a Gnosis Pay user's wallet.

By the time Gnosis Pay's monitoring flagged the first large unauthorized transfer at 06:17 UTC, EURe, GNO and USDC.e were already leaving users' Safes. Gnosis Pay later put the amount taken at about $1.5M across 5,281 wallets. It was possible because the module asked a contract whether a signature was valid, read the answer, and never checked whether the call that produced the answer had actually succeeded.

A Debit Card Wired to a Safe

Gnosis Pay is a self-custodial debit card. Each user's account is a Safe smart account on Gnosis Chain, and the card spends directly from it. To connect a wallet the user controls to a card network that needs predictable settlement, Gnosis Pay layered two modules from the Zodiac framework on top of every Safe, a Roles module that limits which actions are allowed and a Delay module that puts outgoing non-card transactions in a queue and only lets them execute after a cooldown, so a payment can't be pulled out from under the card.

Each user's Delay module is a lightweight clone, a tiny contract that forwards every call to one shared implementation. The clone in CertiK's example, at 0xB0ca54ac663b96f526996bB6594b5B5648142673, points to the Delay implementation at 0x4A97E65188A950Dd4b0f21F9b5434dAeE0BBF9f5, which the Gnosis Chain explorer shows as verified on August 9, 2024 and compiled with Solidity 0.8.20. That implementation is Delay v1.1.0, and its verified source files show it was built against the legacy @gnosis.pm/zodiac package, the same package whose Modifier and SignatureChecker contracts hold the code below. Because every clone runs the same implementation, the same signature-checking code sat in front of card Safes across the product.

How a Transaction Gets Into the Queue

Getting a transaction into a Delay queue normally means being an enabled module, an address the Delay has been told to trust. Zodiac's Modifier contract also offers a second path, where anyone can submit a transaction as long as it carries a signature from an enabled module. From the verified Delay source on the Gnosis Chain explorer, this is the gate in the Modifier contract:

modifier moduleOnly() {
  if (modules[msg.sender] == address(0)) {
    (bytes32 hash, address signer) = moduleTxSignedBy();

    // is the signer a module?
    if (modules[signer] == address(0)) {
      revert NotAuthorized(msg.sender);
    }

    // is the provided signature fresh?
    if (consumed[signer][hash]) {
      revert HashAlreadyConsumed(hash);
    }

    consumed[signer][hash] = true;
    emit HashExecuted(hash);
  }

  _;
}

The first line asks whether the caller is itself an enabled module. If it isn't, moduleTxSignedBy looks for a signature and returns the address that supposedly signed. The next check only lets the call through if that signer is an enabled module, and the one after it rejects a signature that's already been used, then marks this one as consumed and emits HashExecuted. Everything here depends on moduleTxSignedBy telling the truth about who signed.

moduleTxSignedBy reads its signature from the very end of the raw calldata, the bytes sent with the call, because it's designed to work with any function without changing that function's parameters. When the last byte, v, is zero, it treats the signature as coming from a contract: r holds the signer's address, s holds the offset where the signer's signature bytes start, and the function hands those bytes to _isValidContractSignature.

The Line That Read the Answer and Ignored the Outcome

From the verified Delay source on the Gnosis Chain explorer, in @gnosis.pm/zodiac/contracts/signature/SignatureChecker.sol:

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

    (, bytes memory returnData) = signer.staticcall(
      abi.encodeWithSelector(
        IERC1271.isValidSignature.selector,
        hash,
        signature
      )
    );

    return bytes4(returnData) == EIP1271_MAGIC_VALUE;
  }

The assembly block measures the signer's code size, and the if statement rejects any signer with no code, so only contracts get this far. The staticcall then calls the signer's isValidSignature function with the hash and the signature bytes. A staticcall returns two values, a true or false flag saying whether the call finished, and the bytes that came back, and the leading comma in "(, bytes memory returnData)" throws the flag away. The last line compares the first four returned bytes to the magic value 0x1626ba7e and returns that comparison as the answer.

When a called contract reverts, meaning it fails on purpose and undoes its own work, the flag comes back false, but the contract still chooses what bytes to put in its revert data, and those bytes still arrive in returnData. A contract that reverts with 0x1626ba7e as its revert data therefore passes this check. A reverted call is a "no," whatever words it carries back with it, and treating it as a "yes" because the reply contains the right phrase is like accepting a rejected application because the rejection letter quotes the word "approved."

The fixed version of the same function, from commit 11708ac in Zodiac's newer zodiac-core package on GitHub, keeps the flag and requires it:

    (bool success, bytes memory returnData) = signer.staticcall(
      abi.encodeWithSelector(
        IERC1271.isValidSignature.selector,
        hash,
        signature
      )
    );

    return success && bytes4(returnData) == EIP1271_MAGIC_VALUE;

The only differences are the named success variable in the first line and the "success &&" in the last, so the magic value only counts when it comes back from a call that finished cleanly. That's the same one-condition fix Gnosis Pay's post-mortem shows.

Two Commits on the Same Day

Verichains traced how the flag went missing. A commit on October 28, 2023, 9ffff3a, added EIP-1271 support to Zodiac and checked the success flag. A second commit the same day, 9a9e380, changed how the staticcall was handled and dropped the flag, leaving only the comparison on the returned bytes. According to Gnosis Pay's post-mortem, that code shipped in Zodiac v3.4.0 on October 30, 2023, and the Delay v1.1.0 release on GitHub lists @gnosis.pm/zodiac 3.4.1 as its dependency.

On April 25, 2024, Zodiac's core contracts were split out of the legacy repository into zodiac-core. On February 27, 2026, commit 11708ac fixed the bug there, without a public announcement, in v4.0.0-alpha.0. The Delay contracts Gnosis Pay ran were still compiled against the legacy package, so the fix never reached them, and on June 5, four days after the attack, the Zodiac team flagged the signature patch as a security fix, according to Gnosis Pay's timeline.

Forty-One Queued Withdrawals

CertiK's analysis reconstructs the sequence. The wallet at 0x81BA8A2b895D30280bca199C2Ff75f3F058d4C6c, which the explorer shows also created the reverting signer contract at 0x5a77953caa27ed4638f4dfdc665b8064d0e97a35, called execTransactionFromModule on the victim's Delay clone at 05:26:10 UTC in block 46468498. The decoded call asked the Delay module to run a delegatecall into Safe's MultiSend contract, a helper that bundles several calls into one, and the bundle held two transfers to 0x81BA8A, one of EURe and one of GNO, out of the victim Safe at 0x915E683649AB7743F8145d0536CF5168959c9159.

execTransactionFromModule decodes only its four standard parameters, so the attacker appended extra bytes after them that the function ignored and moduleTxSignedBy read. In the raw input on the explorer, the last byte, v, is zero, the s value points to offset 0x244, and the r value is the address 0x7f59e536f083a63b67adfe3bc793a47744dba7d8, a Safe proxy running the SafeL2 implementation. CertiK describes how that Safe's own signature checking parsed a second layer of signature data whose owner pointed at the attacker's contract, which reverted with 0x1626ba7e in its revert data. Since the call to execTransactionFromModule succeeded, that Safe was an address the Delay module recognized as an enabled module, the magic value came back through the unchecked staticcall, and the module emitted HashExecuted and TransactionAdded, putting the transfer in queue slot 2.

Thirty-one minutes later, at 05:57:35 UTC in block 46468862, a different address, 0xf6a9f265012130D02FdA1f39F74e9FAF8388D2f6, called executeNextTx, which anyone can call once a queued transaction's cooldown has passed. The explorer shows that transaction moving 22,871.78 EURe and 10.97 GNO from the victim Safe to 0x81BA8A. Verichains' example transaction, at 06:29:55 UTC in block 46469239, queued a 10,000 USDC.e transfer to the same wallet through another Delay clone. CertiK counts 41 transactions queued this way, and the exploit wallet had sent 946 transactions on Gnosis Chain as of October 20, 2026.

Counting the Loss and Following the Money

CertiK's early estimate, published June 4, put the loss at about $265K, and Verichains used the same figure. Gnosis Pay's own count, published in its post-mortem on July 3, is the full figure of about $1.5M taken, made up of about $641K in GNO, $453K in EURe and $399K in USDC.e plus small amounts of SAFE, WETH and other tokens, taken across 5,281 wallets holding at least $1, plus about $300K stranded in accounts that couldn't be accessed.

CertiK traces about $246K in USDT bridged from the exploit wallet to Hyperliquid, then consolidated in 0xb1834575349c6eb56675c35b4109c3d3a77dd2fc, which held about 147,495 USDC at the time of its report, with part swapped to XMR and split across four more wallets.

Restoring Every Balance

Martin Köppelmann, Gnosis co-founder and CEO, confirmed the exploit on the morning of June 1. According to The Block, he deleted an earlier post urging users to withdraw EURe and GNO immediately, saying most users wouldn't be able to and that Gnosis believed it could contain most of the damage, and then wrote that Gnosis would cover all user losses. NOCA, Gnosis Pay's treasury manager, had detected the first large transfer at 06:17 UTC, and the team identified the Zodiac modules as the root cause at 08:06 UTC. Card services went offline, bridge validators paused the bridge to Gnosis Chain, attacker-linked addresses were shared with stablecoin issuers, and leadership warned other projects exposed to the same flaw.

The repaired modules went to ChainSecurity for a focused review, which finished on June 4. The first accounts came back on the evening of June 3, new card Safe modules rolled out in tranches from June 4 to June 7, linked to users' existing cards, and service reached 99% of users by June 6. Gnosis absorbed the losses, Gnosis Pay reports that no user lost funds, and the post-mortem says the team was still exploring ways to recover the roughly $300K in inaccessible accounts. It also lists follow-up work that includes an independent end-to-end reassessment with an external security firm, active monitoring of dependencies, and extending smart contract audits to the external contracts Gnosis Pay relies on.

That last item describes how the bug got through. The Delay module was a dependency, a shared building block from another team, and the vulnerable line lived two layers further down in a signature helper added to that dependency years earlier. A review scoped to Gnosis Pay's own contracts in a fixed window wouldn't necessarily reach it, and a fix published in a renamed package wouldn't reach a build that never moved to it.

How Olympix Treats a Failed Call as a Failed Check

Olympix reads a contract the way an attacker does, as states, transitions and entry points, including the code it inherits from dependencies, and it takes in the context around the code to infer the rules the system depends on without writing them down. In the Delay module, the state that matters is the transaction queue and the consumed-signature table, and every entry point that can add to the queue is a path worth mapping. The space of possible calls looks infinite, since moduleTxSignedBy will parse any bytes anyone appends, but the engine maps every path and uses signals to decide which ones to attack.

The signal in this code is specific: the leading comma in "(, bytes memory returnData)", a low-level call whose success flag is discarded, followed by a return value computed only from the returned bytes, inside the function that decides who's allowed to move a card holder's funds. From that signal, the engine narrows from every path, to the feasible ones that reach execTransactionFromModule from a caller who isn't a module, to the ones that pass through the contract-signature branch where v is zero and so can change who the module believes signed.

The invariant those paths touch is plain: a transaction should only enter the queue if an enabled module approved it through a call that actually finished. Counterexample-Guided Invariant Refutation starts from that rule and from the queue it protects and executes those paths until one adds a transfer nobody authorized, and Bounded Adversarial Verification comes at the same spot from the discarded flag itself, throwing known strategies at it until one lands. A signer that reverts while returning the magic value is the first thing either approach would try against a check that never reads the flag.

The output is a counterexample. For this exploit it would contain a signer contract whose isValidSignature reverts with 0x1626ba7e, a call to execTransactionFromModule from an address that isn't a module, with v set to zero, r pointing at that signer and s pointing at its signature bytes appended past the normal parameters, and the resulting state change, a new entry in txHash and txCreatedAt for a transfer out of a Safe the caller doesn't control. It would list the minimal conditions, namely that the signer has code and that the flag is never read, and it would come with a generated Forge test that deploys the reverting signer, queues the transfer, advances time past the cooldown and calls executeNextTx to show the Safe's balance leaving. That's the June 1 attack, produced in development instead of on mainnet.

Because Olympix runs automatically at every merge and before release, in the Zodiac codebase this would have surfaced at the October 28, 2023 merge of commit 9a9e380 into Zodiac, the change that dropped the flag. For a team consuming the package, it would have surfaced again at the merge that pinned Delay to @gnosis.pm/zodiac 3.4.1, and in every release built against it afterward, giving a standing view of a risk that sat in a dependency, outside the team's own files.

One Flag, Thousands of Safes

Every Gnosis Pay card Safe leaned on the same Delay code, so a single discarded flag in that code reached every Safe it guarded. For builders working with contract signatures, the success flag on the verifying call is part of the signature check, and a dependency that's fixed upstream is only fixed for you once the build you deploy actually uses it. If your contracts gate funds on calls to other contracts, including the ones you import, book a technical walkthrough.

On-Chain References

Protocol: Gnosis Pay (Zodiac Delay module v1.1.0)

Chain: Gnosis Chain

Date: June 1, 2026, queue tx at 05:26:10 UTC (block 46468498), execution at 05:57:35 UTC (block 46468862)

Loss: $1.5M taken plus about $300K made inaccessible (Gnosis Pay post-mortem); CertiK's early estimate was about $265K

Exploit wallet: 0x81BA...4C6c

Attacker contract (reverting signer): 0x5a77...7a35

Safe named as signer (r value): 0x7f59...a7d8

Victim Safe (example tx): 0x915E...9159

Delay module clone (example tx): 0xB0ca...2673

Delay implementation (verified source): 0x4A97...F9f5

MultiSend contract (queued delegatecall target): 0x3886...B526

Attack contract deployment tx (example, May 29, 13:29:55 UTC): 0xf5b2...43b2

Queue tx (execTransactionFromModule): 0x61af...e580

Execution tx (executeNextTx): 0x06b0...1eed

Example attack tx (Verichains, 06:29:55 UTC): 0x5ea4...2ce5

Consolidation wallet (Ethereum, per CertiK): 0xb183...d2fc

USDT bridge tx to Hyperliquid (per CertiK): 0x1138...dd7e

Sources

Gnosis Pay: Post-Mortem of the Gnosis Pay Vulnerability Exploit

CertiK: GnosisPay Incident Analysis

Verichains: Gnosis Pay Exploit, The Devs Discovered the Bug, So Why Did the Attack Still Happen

The Block: Gnosis Will Cover All User Losses Amid Exploit Related to Gnosis Pay

GitHub: zodiac-modifier-delay v1.1.0

PeckShield: Gnosis Pay exploit alert

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.