Reproduced Exploit
A stranger registers a backdoored wallet and spends 1,000 of the victim's CASH
GnosisSafeRegistry.register accepts any proxy that matches the known Safe bytecode, points at the known master copy, has threshold 1, and has one owner. It never checks modules, and it never checks that the listed owner asked for this wallet. A stranger deploys that proxy, names the victim as the s…
Loss
1,000 CASH (1000000000000000000000 wei) moves from the victim to the stranger's module
Chain
Ethereum
Category
access-control
Date
Feb 2020
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 11479-c01-anyone-can-register-backdoored-wallets-for-any-owner-in. Standalone Foundry PoC and full write-up: 11479-c01-anyone-can-register-backdoored-wallets-for-any-owner-in_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/access-control/missing-auth · vuln/logic/missing-check
Reproduction: self-contained synthetic Foundry reduction; see output.txt.
Key info#
| Field | Value |
|---|---|
| Loss | 1,000 CASH (1000000000000000000000 wei) moves from the victim to the stranger's module |
| Vulnerable contract | GnosisSafeRegistry.recordOwnerSafe (reduction of register) |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2020-02 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | register files a safe under owners[0] with no module check and no owner authorization |
TL;DR#
GnosisSafeRegistry.register accepts any proxy that matches the known Safe bytecode, points at the known master copy, has threshold 1, and has one owner. It never checks modules, and it never checks that the listed owner asked for this wallet. A stranger deploys that proxy, names the victim as the sole owner, enables their own module, and registers it. The victim approves the wallet getSafe returns. The module calls transferFrom and takes 1,000 CASH. The victim's own module-free wallet is only queued behind it, so it is not the current safe. A registry that rejects safes with a module refuses the backdoor, and the stranger cannot take the victim's remaining 1,000 CASH.
Background#
OpenZeppelin reviewed Augur Core v2 components at commit 67c0fec95e65fca8d27751f28a6c06e5247978c6 and published the report on 27 February 2020. GnosisSafeRegistry is the on-chain list of each user's Gnosis Safe. getSafe returns the first registered safe. Later safes sit in the queue until the current one deregisters itself. Registration was expected to happen from a user-controlled safe. The checks stop at proxy code hash, master copy, threshold, and owner count. Gnosis modules can execute calls from the safe with no owner signature. A module therefore turns a safe that lists the victim as owner into a wallet the stranger controls.
The vulnerable code#
function register() external {
IGnosisSafe _safe = getAndValidateSafeFromSender();
address[] memory _owners = _safe.getOwners();
address _owner = _owners[0];
// External self-call so the storage write keeps a source map under viaIR.
this.recordOwnerSafe(_owner, _safe);
}
function recordOwnerSafe(address _owner, IGnosisSafe _safe) external {
require(msg.sender == address(this), "record");
// @> VULN: stores this safe under owners[0] with no module check and no proof the owner authorized the wallet
emit SafeRecorded(_owner, address(_safe)); accountSafes[_owner].push(_safe);
}
The audited function is register in GnosisSafeRegistry.sol. It pushes msg.sender onto accountSafes[owners[0]] immediately after getAndValidateSafeFromSender. That helper checks the proxy hash, masterCopy == gnosisSafeMasterCopy, getThreshold() == 1, and getOwners().length == 1. It does not call getModules, and it does not require a signature from owners[0]. The synthetic keeps that push. The self-call only separates the write so the line is visible in the trace. getAndValidateSafeFromSender is copied verbatim.
Root cause#
The registry treats "this proxy looks like a one-of-one Safe whose owner slot says the victim" as "this is the victim's wallet". Owner slot and control are not the same thing once a module is enabled. Modules are not part of the check, so the stranger can satisfy every require and still be the party that can move tokens.
Preconditions#
- The victim has never registered a safe, or the attacker registers before they do.
getSafereturns index 0. - The victim holds CASH and later approves the address
getSafereturns. Here that amount is 1,000 CASH, and a second 1,000 stays for the control. - The attacker can deploy a proxy whose runtime hash matches the registry's
proxyCodeHashand whose master copy is the registry's Safe master. That is the normal factory path. - The control registry is the same code plus
require(getModules() == address(0))before the push.
Attack walkthrough#
- The stranger deploys a proxy aimed at the Safe master and calls
setup(victim, module). Threshold is 1, the only owner is the victim, andgetModulesreturns the stranger's module (output.txt). - The module makes the proxy call
register. The proxy hash, master copy, threshold, and owner count all pass.accountSafes[victim]stores the proxy. getSafe(victim)is that proxy. The victim approves it for 1,000 CASH. The module'sexecTransactionFromModulecallstransferFromand the module's balance becomes 1000000000000000000000 (output.txt:6).- The victim then registers a module-free safe on the same registry. It is stored at index 1.
getSafestill returns the backdoor, so the real wallet is not current (output.txt:14). - Control: the same backdoor calls the fixed registry.
getModules() != address(0), so registration reverts (output.txt:16). The victim registers the module-free safe there.getSafeon the fixed registry is that safe. The victim approves it for the remaining 1,000 CASH. The stranger'strySpendfails (output.txt:18). The victim still holds 1000000000000000000000 (output.txt:8) and the clean allowance is still 1000000000000000000000 (output.txt:12). The backdoor allowance is 0 (output.txt:10). [PASS](output.txt:4).
Diagrams#
Impact#
The victim loses 1,000 CASH. The module's balance increases by 1000000000000000000000 and the victim's balance falls from 2,000 CASH to 1,000 CASH. The wallet the registry advertises as the victim's is the stranger's. The victim's real safe cannot become current until the backdoored safe deregisters itself, which the victim cannot force. On the fixed registry the same module is rejected, the clean safe is the one getSafe returns, and the remaining 1,000 CASH stays with the victim with its allowance unused.
Remediation#
Reject safes that have a module, and do not treat owners[0] as authorization. The Augur fix in commit 7787dba1 only registers proxies deployed with known parameters and no modules. The control in this reduction is that check:
function register() external {
IGnosisSafe _safe = getAndValidateSafeFromSender();
+ require(_safe.getModules() == address(0), "Safe has a module");
address[] memory _owners = _safe.getOwners();
address _owner = _owners[0];
accountSafes[_owner].push(_safe);
}
Also require that the transaction is authorized by owners[0], not merely sent by a proxy that lists that address.
How to reproduce#
cd 11479-c01-anyone-can-register-backdoored-wallets-for-any-owner-in_exp
forge test -vv
Sources#
- AuditVault finding: 11479-c01-anyone-can-register-backdoored-wallets-for-any-owner-in
- OpenZeppelin, Augur Core v2 Audit: Components, 27 February 2020: https://www.openzeppelin.com/news/augur-core-v2-audit-components
AugurProject/augur67c0fec95e65fca8d27751f28a6c06e5247978c6packages/augur-core/source/contracts/GnosisSafeRegistry.solregister/getAndValidateSafeFromSender- Fix:
AugurProject/augurcommit7787dba1b0b0ba8b93e23e08be5540561c47dc6f
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 11479-c01-anyone-can-register-backdoored-wallets-for-any-owner-in_exp (evm-hack-registry mirror).
- AuditVault finding: 11479-c01-anyone-can-register-backdoored-wallets-for-any-owner-in.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.