Gnosis Pay Exploit: The Devs Discovered the Bug, So Why Did the Attack Still Happen?
TL;DR: On June 1, 2026, Gnosis Pay lost about $265,000 to a hacker. The hacker broke in by tricking the system's security checks, using a tiny missing piece of code to pass off fake error messages as real, approved signatures. But here is the most concerning part: the developers had already found and silently fixed this exact bug months before the hack in their another code repository. Why didn't they warn anyone that the older code - which Gnosis Pay was still using - was completely vulnerable and waiting to be robbed?
Attack Summary
Date: June 1, 2026 UTC.
Protocol: Gnosis Pay / Zodiac Delay v1.1.0.
Chain: Gnosis Chain.
Loss: ~$265K across multiple victims.
Root Cause: Missing status check in static call.
Attacker: 0x81ba8a2b895d30280bca199c2ff75f3f058d4c6c.
Example Attack TX: 0x5ea42911c803ba2cf1cb558e88129102de9023980a5c73253d59407859ce2ce5.
Vulnerable Function Path:
Delay.execTransactionFromModule()
-> moduleOnly()
-> moduleTxSignedBy()
-> _isValidContractSignature()Background
Gnosis Pay connects a card product to self-custody Safe accounts. The relevant architecture uses Zodiac modules:
Roles Module controls which actions are allowed.
Delay Module queues outgoing non-card transactions and enforces a short cooldown before execution.
In the intended flow, a withdrawal is queued first, waits through the delay period, and only then becomes executable. That design is reasonable. The failure was not that a delay existed; the failure was that the module accepted malicious transactions into the queue through a broken signature-verification fallback.
Root-cause Analysis
Data Control: Start of attack
The attacker called execTransactionFromModule() with crafted calldata.
The function was protected by moduleOnly.
The vulnerable authorization path tried to support a generic fallback: if msg.sender is not an enabled module, check whether the transaction was signed by an enabled module.
The key helper was moduleTxSignedBy():
function moduleTxSignedBy() internal view returns (bytes32, address) {
bytes calldata data = msg.data;
if (data.length < 4 + 32 + 65) {
return (bytes32(0), address(0));
}
(uint8 v, bytes32 r, bytes32 s) = _splitSignature(data);
uint256 end = data.length - (32 + 65);
bytes32 salt = bytes32(data[end:]);
if (v == 0) {
uint256 start = uint256(s);
if (start < 4 || start > end) {
return (bytes32(0), address(0));
}
address signer = address(uint160(uint256(r)));
bytes32 hash = moduleTxHash(data[:start], salt);
return
_isValidContractSignature(signer, hash, data[start:end])
? (hash, signer)
: (bytes32(0), address(0));
}
}The problem starts with:
bytes calldata data = msg.data;execTransactionFromModule() only decodes four normal parameters:
function execTransactionFromModule(
address to,
uint256 value,
bytes calldata data,
Enum.Operation operation
) public override moduleOnly returns (bool success)But moduleTxSignedBy() authenticates against the whole calldata buffer. That includes attacker-controlled bytes appended after the ABI-encoded function arguments. By shaping those trailing bytes, the attacker controlled v, r, s, salt, the recovered signer, and the signature payload passed into _isValidContractSignature().
The Revert-Data Trick: The real root-cause
Inside _isValidContractSignature():
Notice what is missing: the success boolean.
The staticcall could revert, but the code ignored whether it succeeded. It only checked whether the first four bytes of returnData matched 0x1626ba7e. Because revert data is also returned to the caller, the attacker could make a failed call look like a valid ERC-1271 signature.
Attacker try to route the call “verify signature” to victim’s Safe Wallet -> Fallback Handler contract -> Attacker’s contract. Then attacker use revert data to trick Safe to return.
This is the heart of the exploit: the attacker controlled the revert message, the revert message became returnData, and returnData satisfied the signature check.
Step-by-Step Bypass Authorization Flow
Deploy Verification Trap - The attacker deployed a contract that could be reached through the ERC-1271 verification path and revert with data beginning with
0x1626ba7e. Contract address:0x5a77953caa27ed4638f4dfdc665b8064d0e97a35Craft Module Call - The attacker called
Delay.execTransactionFromModule()with a normal-looking transaction body. In the example attack transaction, the decoded action queued a USDC.e transfer to the attacker.Append Signature Payload - Extra bytes were appended after the normal ABI parameters.
execTransactionFromModule()ignored those bytes, butmoduleTxSignedBy()parsed them as signature material.Route Verification Through Controlled Contract - The crafted signature caused verification to traverse Safe/fallback ERC-1271 logic until it reached the attacker-controlled contract.
Revert With Magic Value - The attacker-controlled contract reverted with data beginning with
0x1626ba7e.Bypass Authorization -
_isValidContractSignature()ignoredsuccess, checked onlybytes4(returnData), and accepted the reverted call as a valid signature.Queue and Execute Malicious Withdrawals - The Delay module emitted
HashExecutedandTransactionAdded, planting malicious withdrawals into the queue. Across multiple victims, those queued withdrawals later enabled the ~$265K drain.
Forensic Timeline: The Silent Fix
The tricky part of this incident is the repository history. There are two related but different code lines:
gnosisguild/zodiac- legacy package, historically published as@gnosis.pm/zodiac, later as@gnosis-guild/zodiac.gnosisguild/zodiac-core- reworked core package, published as@gnosis-guild/zodiac-core- it is a new folks fromgnosisguild/zodiacfrom Apr 25, 2024 in1b16acacommit.
The official Delay contract version 1.1.0 uses the legacy package @gnosis.pm/zodiac.
On Oct 28, 2023, commit
9ffff3aadded EIP-1271 support. The early implementation checked thesuccessvalue fromstaticcall.
On Oct 28, 2023, commit
9a9e380changed thestaticcallhandling and discardedsuccess, leaving only thereturnDatamagic-value check. The vulnerability was introduced in this commit.
On Feb 27, 2026, commit
11708acinzodiac-coresilently fixed the vulnerability and releasedv4.0.0-alpha.0version.
The bug still existed on production contract. The exploited on-chain source was still using the legacy @gnosis.pm/zodiac.
The forensic question is: Why were production Gnosis Pay Delay instances still compiled against the stale legacy @gnosis.pm/zodiac dependency, when a newer zodiac-core line existed and had already addressed this class of bug?
Lessons Learned
Never trust outside code blindly. Just because code is on GitHub doesn’t mean it is safe. Always check and test other people’s code before letting it touch your money.
Repository splits create security drift. When a package is extracted, renamed, or replaced, old package lines can keep dangerous code unless fixes are explicitly backported or downstream users migrate.
Low-level call must check
success. Revert data is not a successful signature response. A magic value inside revert data should never authorize anything.Stale deployments need active monitoring. A fixed library does not protect already deployed contracts. Teams need dependency inventory, deployed-bytecode tracking, and migration playbooks for security-sensitive modules.
Conclusion
The Gnosis Pay hack is a very expensive lesson: security tools are only as strong as their weakest link. The Delay module was built to slow down hackers, but the attacker didn’t break the clock-they just tricked the guard into letting them pass.
A single missing check turned a loud error into a green light. In the crypto world, this is exactly the kind of tiny “oops” mistake that costs hundreds of thousands of dollars.
Don’t let a small bug turn your project into the next big hack story. Hire Verichains for a full security audit. Our top security experts will find those hidden bugs, spot quiet code changes, and make sure your smart contracts are truly safe—not just pretending to be!













