On 21 February 2025, Ben Zhou, CEO of Bybit, a well-known crypto exchange, announced on X that one of the platform’s cold wallets for ETH tokens and derivatives had been hacked.
At the time of writing, the estimated loss is around 1.5 billion USD. So, what happened?
Bybit uses Gnosis Safe for its cold wallet solution, a highly secure, multi-signature smart-contract solution designed for managing digital assets on EVM chains. This is ideal for organisations and individuals looking to scale their security as their portfolio grows.
Assets are managed by the Gnosis Safe smart contract, allowing customisable spending rules, such as requiring multiple signatures for transactions. This added layer of security makes attacks more difficult, as compromising a single actor would not be enough to authorise a malicious transaction.
It is still unclear how the attacker manipulated Bybit’s three signers into approving the malicious transaction. The post on X mentions a “musked” UI, suggesting that the attacker tricked the victims into using a malicious front-end.
The malicious transaction mentioned on X isn’t particularly revealing either. It simply shows 401,000 ETH being withdrawn from the Safe wallet at address 0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4.
To understand what happened, we need to analyse the transactions leading up to the attack.
It is possible to execute a transaction with Gnosis Safe. This is achieved using the execTransaction() function.
execTransaction( address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)
The arguments for this function are as follows:
- To: The address of the smart contract to interact with.
- Value: The message value of the transaction.
- Data: The call data to be sent to the target smart-contract.
- Operation: The type of call. 0 for a call, 1 for a delegatecall.
- Gas: Gas related parameters.
- Signatures: Concatenated signatures
Using that knowledge, we can investigate the execTransaction on block 21895238.
Function: execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) MethodID: 0x6a761202 [0]: 00000000000000000000000096221423681a6d52e184d440a8efcebb105c7242 [1]: 0000000000000000000000000000000000000000000000000000000000000000 [2]: 0000000000000000000000000000000000000000000000000000000000000140 [3]: 0000000000000000000000000000000000000000000000000000000000000001 [4]: 000000000000000000000000000000000000000000000000000000000000b2b2 [5]: 0000000000000000000000000000000000000000000000000000000000000000 [6]: 0000000000000000000000000000000000000000000000000000000000000000 [7]: 0000000000000000000000000000000000000000000000000000000000000000 [8]: 0000000000000000000000000000000000000000000000000000000000000000 [9]: 00000000000000000000000000000000000000000000000000000000000001c0 [10]: 0000000000000000000000000000000000000000000000000000000000000044 [11]: a9059cbb000000000000000000000000bdd077f651ebe7f7b3ce16fe5f2b025b [12]: e296951600000000000000000000000000000000000000000000000000000000 [13]: 0000000000000000000000000000000000000000000000000000000000000000 [14]: 00000000000000000000000000000000000000000000000000000000000000c3 [15]: d0afef78a52fd504479dc2af3dc401334762cbd05609c7ac18db9ec5abf4a07a [16]: 5cc09fc86efd3489707b89b0c729faed616459189cb50084f208d03b201b001f [17]: 1f0f62ad358d6b319d3c1221d44456080068fe02ae5b1a39b4afb1e6721ca7f9 [18]: 903ac523a801533f265231cd35fc2dfddc3bd9a9563b51315cf9d5ff23dc6d2c [19]: 221fdf9e4b878877a8dbeee951a4a31ddbf1d3b71e127d5eda44b4730030114b [20]: aba52e06dd23da37cd2a07a6e84f9950db867374a0f77558f42adf4409bfd569 [21]: 673c1f0000000000000000000000000000000000000000000000000000000000
According to the first parameters, the smart contract at address 0x96221423681a6d52e184d440a8efcebb105c7242 was called and according to the operation parameter, it was a delegatecall. A delegatecall is a low-level function that executes code from another contract while preserving the caller’s context, including storage.
The data parameter, 140, is actually an offset to the actual calldata to pass. The important part is:
[11]: a9059cbb000000000000000000000000bdd077f651ebe7f7b3ce16fe5f2b025b [12]: e296951600000000000000000000000000000000000000000000000000000000
Within the above:
- a9059cbb is the 4 bytes signature for the transfer(address,uint256) function.
- 0xbdd077f651ebe7f7b3ce16fe5f2b025be2969516 is the address parameters.
- 0 is the value.
At first glance, it appears to be a simple zero-token transfer to 0xbdd077f651ebe7f7b3ce16fe5f2b025be2969516, seemingly harmless.
The source code of the contract was not published, but it is trivial to reverse engineer.
# Palkeoramix decompiler. def storage: stor0 is uint256 at storage 0 def _fallback() payable: # default function revert def transfer(address _to, uint256 _value) payable: require calldata.size - 4 >=ΓÇ 64 require _to == _to stor0 = _to
The transfer function behaves unexpectedly. It simply modifies storage at offset 0 with the address passed in the _to parameter. Since this is a delegatecall, it effectively overwrites storage slot 0 with an arbitrary value, 0xbdd077f651ebe7f7b3ce16fe5f2b025be2969516.
In a Gnosis Safe, storage slot 0 typically holds the address of the current owner or the contract’s “master” (the contract’s controller). This address is critical because it can control the governance and permissions of the safe. If an attacker is able to manipulate this storage slot, they could potentially change the ownership or control of the Gnosis Safe.
The new malicious master contract is provided without source code, but is also trivial to reverse engineer:
# Palkeoramix decompiler. def storage: stor0 is uint256 at storage 0 def _fallback() payable: # default function revert def transfer(address _to, uint256 _value) payable: require calldata.size - 4 >=ΓÇ 64 require _to == _to if 0xfa09c3a328792253f8dee7116848723b72a6d2e != caller: revert with 0, 'Ownable: caller is not the owner' stor0 = _to def unknown1163b2b0(uint256 _param1) payable: # sweepETH(address receiver) require calldata.size - 4 >=ΓÇ 32 require _param1 == addr(_param1) if 0xfa09c3a328792253f8dee7116848723b72a6d2e != caller: revert with 0, 'Ownable: caller is not the owner' call addr(_param1) with: value eth.balance(this.address) wei gas 2300 * is_zero(value) wei if not ext_call.success: revert with ext_call.return_data[0 len return_data.size] def unknown582515c7(uint256 _param1, uint256 _param2) payable: # sweepERC20(address,address) require calldata.size - 4 >=ΓÇ 64 require _param1 == addr(_param1) require _param2 == addr(_param2) if 0xfa09c3a328792253f8dee7116848723b72a6d2e != caller: revert with 0, 'Ownable: caller is not the owner' static call addr(_param1).balanceOf(address tokenOwner) with: gas gas_remaining wei args this.address if not ext_call.success: revert with ext_call.return_data[0 len return_data.size] require return_data.size >=ΓÇ 32 mem[ceil32(return_data.size) + 196 len 96] = transfer(address to, uint256 tokens), addr(_param2) << 64, 0, ext_call.return_datamem[ceil32(return_data.size) + 196 len 28] call addr(_param1).mem[ceil32(return_data.size) + 196 len 4] with: gas gas_remaining wei args mem[ceil32(return_data.size) + 200 len 64] if not ext_call.success: revert with 0, 'Token transfer failed'
It disables all functionality by overwriting the fallback() method to simply revert, while implementing transfer functions that send arbitrary tokens, with access restricted to only the attacker’s address.
With this explanation, we can now understand how the transaction mentioned in the X post occurred.
A transaction from 0x0fa09C3A328792253f8dee7116848723b72a6d2e calls the sweepETH() method, transferring 401,000 ETH to 0x47666Fab8bd0Ac7003bce3f5C3585383F09486E2, the attacker’s wallet.
This is as far as we can go for now. Bybit has not made any official statement about what led their three signers to approve the malicious transaction, and we have no insight into the front-end code they were using. Ben Zhou explained in a podcast that he received an email from his team with a link to their Safe wallet. He checked the URL and verified that the transaction looked legitimate. All of the three signers did the same. But was it the official, well-audited Gnosis Safe front-end, or a custom one, as we might expect for a wallet securing over a billion dollars in assets? Could this be a simple yet most expensive targeted blind-signing phishing attack ever? Hopefully, we will learn more in the coming weeks.
This breach is a costly reminder to regularly conduct threat modeling and implement safeguards before any transaction is issued on-chain. LRQA always recommends that organisations:
- Build the transaction offline and compare it to what is being signed.
- Warn users if it is their first interaction with a new smart contract.
- Trigger a warning on the front-end for Delegatecall transactions.
- Perform simulations to check for potential blockchain state changes.