A deep dive of the $230 Million WazirX Compromise

Web2 attacks paying out big in Web3!

TL;DR

WazirX had their Gnosis Safe wallet compromised after three (3) signers from WazirX were lured into signing a transaction that performed a delegatecall to an attack contract that changed the implementation address of the wallet. This gave locked WazirX out of the Gnosis Safe Wallet and gave the adversary the ability to transfer all value out of the wallet, to the tune of $230M.

The Gnosis Safe Wallet had been enrolled in Liminal’s Custody service and a Liminal private key was used to sign transactions that were broadcast on chain. The multi-sig was a 4 of 6 that operated with 3 signers from WazirX (Ledger hardware wallets) and a co-signer (fourth signer) from Liminal (a HSM). Note, there's no requirement for Liminal to be the 4th signature, it could have easily been one of WazirX's other signers.

Liminal maintain they were not breached and that the malicious transactions originated from the WazirX signers themselves. There seems to be a difference between Safe wallets migrated to the Liminal platform and Safe wallets that are created there - "The WazirX team requested Liminal to import an existing SAFE contract on the Liminal platform and our team helped them with the same. This is the only wallet that was compromised in the incident". This may explain why the allowlist was subverted.

WazirX stated that the wallets were all separate hardware wallets in three different locations and that links to Liminal’s custody service were bookmarked. They also stated that the attack was not the result of a phishing link. This would seemingly rule out phishing, spear-phishing or clickjacking (weaponised reflected XSS).

Liminal note that the WazirX team created multiple transactions before the attack transaction changed the proxy implementation (more detail here);

  1. A transaction related to GALA token from Victim 1 that had a signature mismatch and was rejected by Liminal.

  2. A transaction related to GALA token from Victim 2 that was rejected by Liminal “owing to the mismatch in transaction information and the payload”.

  3. A transfer of $500,000 USDT was initiated by Victim 1, signed by 2 other co-signers and submitted by the Liminal Co-Signer (HSM) on chain. It had 2 valid WazirX signatures and one invalid signature so it reverted with error GS026. The signature that reverted was that of Victim 3.

  4. The attack transaction signed by all 3 WazirX signers and the Liminal Co-signer is submitted on chain. This changed the implementation of the Gnosis Safe Proxy via a delegatecall.

The adversary now had full control of all funds held in the proxy and can continue to delegatecall to the attack contract through the proxy.

Accruing signatures through failed transactions

As noted above there were a number of transactions performed that failed off-chain (2) and on-chain (1) before the attack transaction. Liminal refer to these as “sequences” in their interim report.

  1. Sequence #1 (off-chain) is the first GALA transaction from Victim 1 that had the wrong signature.

    1. 8e64a89386af2f223b8433a1df65db8f0ff60544b2f02c56ec02b640d6fb15a11f7dffda5b99b0292b5e02d0cc44508d7d8f994515358e9da3016d8098b2258820

    2. This was blocked by Liminal due to "signature mismatch" (see interim report).

  2. Sequence #3 (off-chain) is the second GALA transaction from Victim 2 where the transaction and payload did not match.

    1. 3da7c6bd7c130430cf662de3d9af067c4a0629f849e29003c40ad3979cd670720c3ceb3a61e38fb7166353e3262eb6554c3f6571807cf22f9bdc4a83a56441661f

    2. Blocked by Liminal due to "mismatch in transaction information and the payload" (see interim report).

  3. Sequence #8 (on-chain) is the failed USDT transaction that reverted as one of the signers from WazirX , Victim 3 (0xfa54b4085811aef6acf47d51b05fda188deae28b) was not correct.

    1. 40082eba0f71627a3451d47132b8f4c266bc9be2bebe4424848757d10f13e76963af0b543a2357765d9f290410e919301ad065f0f2efa1233eb8634c93111f0e20

    2. Submitted by Liminal but reverted on chain due to an invalid ecrecover.

All three of these signatures from failed transactions are used as the signatures for the final attack transaction. See below;

0x3da7c6bd7c130430cf662de3d9af067c4a0629f849e29003c40ad3979cd670720c3ceb3a61e38fb7166353e3262eb6554c3f6571807cf22f9bdc4a83a56441661f8e64a89386af2f223b8433a1df65db8f0ff60544b2f02c56ec02b640d6fb15a11f7dffda5b99b0292b5e02d0cc44508d7d8f994515358e9da3016d8098b2258820000000000000000000000000d967113224c354600b3151e27aaba53e3034f37200000000000000000000000000000000000000000000000000000000000000000140082eba0f71627a3451d47132b8f4c266bc9be2bebe4424848757d10f13e76963af0b543a2357765d9f290410e919301ad065f0f2efa1233eb8634c93111f0e20

Breaking this out signature by signature;

0x3da7c6bd7c130430cf662de3d9af067c4a0629f849e29003c40ad3979cd670720c3ceb3a61e38fb7166353e3262eb6554c3f6571807cf22f9bdc4a83a56441661f - From Sequence #3 and Victim #2

8e64a89386af2f223b8433a1df65db8f0ff60544b2f02c56ec02b640d6fb15a11f7dffda5b99b0292b5e02d0cc44508d7d8f994515358e9da3016d8098b2258820 - From Sequence #1 and Victim #1

000000000000000000000000d967113224c354600b3151e27aaba53e3034f372000000000000000000000000000000000000000000000000000000000000000001 - From Liminal Co-Signer

40082eba0f71627a3451d47132b8f4c266bc9be2bebe4424848757d10f13e76963af0b543a2357765d9f290410e919301ad065f0f2efa1233eb8634c93111f0e20 - From Sequence #8 and Victim #3

The attacker required 3 valid WazirX signatures for the attack transaction and accrues them by manipulating the signing requests that are submitted to three valid transactions (GALA, GALA and USDT). The WazirX signers thought they were signing transfers of GALA and USDT but each of these transactions failed because they were actually signing the attack transaction. The fourth signature is from the Liminal Co-signer.

After receiving the signatures for the failed transactions in Sequence #1 and #3 the adversary does not MITMiddle/MITBrowser Victim 1 or Victim 2 in Sequence #8 (the USDT transfer) as they already have their attack transaction signatures from the failed GALA transactions. The USDT transfer fails because the attacker needed to MITM Victim 3 to get the signature for the attack transaction. Victim 3 was signing the attack transaction in the USDT transfer and that is why it failed with an invalid signature.

The adversary has to deal with any of the WazirX victims signing a transaction in any order. Once they have the signature for the attack transaction they either need to accrue a new signature or block valid transactions or the nonce will change. We don't know if Sequence #8 happened before or after #1 and #3 but it strongly suggests accruing signatures and blocking valid transaction was co-ordinated via a command-and-control (C2) server.

The signatures must match the transaction Gnosis Safe transaction hash and this is built up from the following code (link);

  txHash = getTransactionHash( // Transaction info
      to,
      value,
      data,
      operation,
      safeTxGas,
      // Payment info
      baseGas,
      gasPrice,
      gasToken,
      refundReceiver,
      // Signature info
      // We use the post-increment here, so the current nonce value 
      // is used and incremented afterwards.
      nonce++
  );
  checkSignatures(txHash, signatures);

I've linked to the latest version of the Gnosis Safe Wallet implementation rather than the original code from the implementation at block 20331564 0xd9db270c1b5e3bd161e8c8503c55ceabee709552. There's little difference and it's easier to link to on Github. However if you want the exact code download it from the link above.

In summary;

  • Sequence #1, the first GALA transaction fails because the Victim 1 tries to sign the GALA transaction but really signs the attack transaction (with the predicted nonce) that will be submitted later.

  • Sequence #3, the second GALA transaction fails because Victim 2 tries to sign the GALA transaction but really signs the attack transaction (with the predicted nonce) that will be submitted later.

  • Sequence #8, Victim 1 and Victim 2 successfully sign the USDT transfer but Victim 3 really signs the attack transaction (with the predicted nonce) that will be submitted later.

With the three signatures the attacker submits the attack transaction as one of the victims. This would require them to be logged in and have an authenticated session to the Liminal website as one of the victims.

Note that the Liminal Co-Signer (HSM) does not provide a signature for the Gnosis Safe Wallet transaction but is required to sign the EVM transaction and broadcast it on chain. There's a subtle difference in the way the submitting signer works in the safe wallet implementation.

The Liminal Co-Signer only needs to provide their address and a v of 1. It’s signature is;

000000000000000000000000d967113224c354600b3151e27aaba53e3034f372000000000000000000000000000000000000000000000000000000000000000001

Looking at the Gnosis Safe source the code checks the message sender is an owner and if it is it passes the signature check. The attacker doesn’t need Liminal to sign the Safe wallet transaction hash, only to sign and submit the attack transaction.

Using the Liminal co-signer as the 4th signer is lower cost in terms of complexity than compromising a 4th WazirX signer. As long as the adversary has the Victim's authenticated session to the Liminal website the 4th signature is free and there's no requirement to get a 4th WazirX signer to fail a transaction to grab their signature.

The Whitelist and the Nonce

The attack transaction was submitted on-chain with signatures that were accrued across three failed transactions. The nonce (1718) would have been predicted by the adversary and all transactions would need to fail to be executed on chain so the nonce didn’t change at the time of the attack. For example, if any successful transaction was executed the nonce would have been incremented and all accrued signatures would have failed because the nonce is incremented in the txHash and signed.

An allowlist was configured at Liminal but appears to have been bypassed;

A policy to whitelist destination addresses was also in place to enhance security. These whitelisted addresses were earmarked and facilitated on the interface by Liminal; consequently, the WazirX team had the ability to initiate transactions to the said whitelisted addresses. (WazirX)

And Liminal note;

After all authorized signatures required for tx were received, Liminal provided the final signature. The transaction was crafted in such a way that the fields used to verify policies were using legit transaction details. (Liminal)

The allowlist may have only applied to transfers and approvals and not to arbitrary calldata. For example it may have only applied to the transfer function signature. This seems to be the case with the allowlist only focused on Send transactions as described in this example.

Either way the transaction was able to be submitted to Liminal using an authenticated browser session and then broadcast on chain via the Liminal Co-Signer - 0xd967113224c354600b3151e27aaba53e3034f372.

There's some outstanding questions as to why the two GALA transactions failed off-chain but the USDT transaction was broadcast and failed on-chain. Liminal describe some of the validation they perform on the fields to compute the safeTxHash off-chain;

The Liminal platform rejected the transaction and displayed an error stating that the transaction creation has failed. Now the fact that Liminal provides the correct safeTxHash in the transaction pre-build and receives malicious safeTxhash and signature from the user’s device in return points to the fact that this was a malicious GALA transaction from the victim’s machine, which was already compromised.

What's different between the GALA transactions that failed off-chain and the USDT transaction that failed on-chain? It makes more sense if you separate these transactions into whether they were new or existing transactions.

Assume Sequence #1 (the GALA transaction) is a new transaction, Victim 1 entered the correct fields into the UI but then submitted a signature for the attack transaction (due to the MITM/MITB and the adversary changing the sign request). Liminal checked the values the victim entered into the user interface and calculated the safeTxHash and compared it to the attack transaction signature and it failed. Liminal has a demo of how transaction signing and validation is performed in this video.

When Victim 3 was asked to sign an existing transaction for USDT the safeTxHash could not have been compared to the signed data otherwise the transaction would have been blocked and never submitted on-chain by the Liminal co-signer.

It's likely that Liminal only check the safeTxHash when transactions are initiated and not on additional signatures to an initiated transaction. This would explain why the two GALA transactions were blocked (as they were new transactions) and why the USDT transaction was broadcast on chain (existing transaction).

There still needs to be a whitelist bypass and I would say there is the ability to submit a properly formatted transaction to a Liminal API and have it co-signed and broadcast on chain. Or the adversary might have used an authenticated Liminal session to add the destination as a whitelisted address without the WazirX team knowing.

The Compromise

The adversary was able to MITM/MITB three transaction signing requests and then submits a transaction as one of the victims to the Liminal custody service. WazirX state in this X/Twitter post;

» Nature of the Cyber Attack: The cyber attack stemmed from a discrepancy between the data displayed on Liminal's interface and the transaction's actual contents. During the cyber attack, there was a mismatch between the information displayed on Liminal's interface and what was actually signed. We suspect the payload was replaced to transfer wallet control to an attacker.

There’s a number of popular ways javascript execution can be hijacked by an adversary;

  • Direct compromise of the endpoint, malware installation and web injects.

  • Direct compromise of the endpoint, restarting the browser in debug mode (--silent-debugger-extension-api) and then injecting javascript into the tab for the Liminal web application.

  • Malicious browser extension install via supply chain or social engineering. I think this is the most likely scenario.

Rilide is an example of a malicious browser extension that targets crypto users and play to earn games.

Source: https://www.trustwave.com/en-us/resources/blogs/spiderlabs-blog/new-rilide-stealer-version-targets-banking-data-and-works-around-google-chrome-manifest-v3/

The attacker's goal is to predict the nonce, fail any legitimate transactions (so they don't have to regather all signatures) and retrieve a signed transaction from each of the victims, exactly once. If the nonce changes they need to throw away everything they've gathered and start again.

Therefore there needs to be a command-and-control (C2) server the browser extension or malware is communicating with to receive tasking, gather signatures and then submit the final transaction via one of the victims authenticated session (or directly if there's an unauthenticated API endpoint at Liminal).

Gnosis Safe Wallets

WazirX had what they thought was a layered security model. Multiple signers, a third-party co-signer and an allowlist that would block transactions to contract addresses not explicitly approved. WazirX assumed there was no transaction type that could give an adversary full access to funds however there are pitfalls with delegatecall and it should have been disabled irrespective of the allowlist.

Gnosis Safe Wallets have the notion of “Guards” which are contracts that can check a transaction and revert based on conditions. See the code here;

address guard = getGuard();
{
    if (guard != address(0)) {
        ITransactionGuard(guard).checkTransaction(
            // Transaction info
            to,
            value,
            data,
            operation,
            safeTxGas,
            // Payment info
            baseGas,
            gasPrice,
            gasToken,
            refundReceiver,
            // Signature info
            signatures,
            msg.sender
        );
    }
}

The WazirX Safe Wallet had no guards (the SetGuard Event never occurs), WazirX relied solely on the allowlist protection offered by Liminal. With Liminal not blocking particular function signatures or operations involving delegatecall (or a allowlist bypass) the attacker was able to submit the attack transaction and change the implementation of the Safe wallet.

Interestingly if you look at all transactions (1,719 in total) that emit the ExecutionSuccess event from the WazirX Safe Wallet (0x27fd43babfbe83a81d14665b1a6fb8030a60c9b4) none of them use a delegatecall (operation is set to 1) except for the attack transaction 0x48164d3adbab78c2cb9876f6e17f88e321097fcd14cadd57556866e4ef3e185d.

As delegatecall was never used a guard blocking delegatecall would have been advantageous. The guard could have been disabled for contract upgrades.

The Attack

The attack transaction triggers the WazirX Safe Wallet to delegatecall 0xfbfFEF83b1C172fE3BC86C1CCB036AB9F3efCAF2 with the calldata 0x804e1f0a000000000000000000000000ef279c2ab14960aa319008cbea384b9f8ac35fc6 which is passing an address 0xef279c2ab14960aa319008cbea384b9f8ac35fc6 to the function selector 0x804e1f0a.

The decompiled attack contract 0xfbfFEF83b1C172fE3BC86C1CCB036AB9F3efCAF2 shows how the address parameter is received by 0x804e1f0a and then stored in part of storage slot 0. Due to the fact this is a delegatecall it is the storage of the WazirX Safe wallet that modified, overwriting the implementation or singleton with that of 0xEf279c2aB14960Aa319008cbEa384b9f8aC35fC6.

# Palkeoramix decompiler. 
def storage:
  stor0 is uint128 at storage 0 offset 160
  stor0 is addr at storage 0
def _fallback() payable: # default function
  revert
def unknown804e1f0a(addr _param1) payable: 
  require calldata.size - 4 >= 32
  addr(stor0.field_0) = _param1
  Mask(96, 0, stor0.field_160) = 0

At the end of the transaction the implementation contract for the WazirX Safe Wallet Proxy is changed from 0xd9db270c1b5e3bd161e8c8503c55ceabee709552 to 0xEf279c2aB14960Aa319008cbEa384b9f8aC35fC6 .

The adversary deployed the attack contracts 0xfbfFEF83b1C172fE3BC86C1CCB036AB9F3efCAF2 and 0xEf279c2aB14960Aa319008cbEa384b9f8aC35fC6 approximately 8 days before the attack.

With the WazirX Safe wallet implementation changed to 0xEf279c2aB14960Aa319008cbEa384b9f8aC35fC6 the attacker can delegatecall through the Safe wallet proxy and execute the logic from the attack contract. The decompiled form is shown below;

# Palkeoramix decompiler. 
def _fallback() payable: # default function
  revert
def unknown48d3c273(uint256 _param1, uint256 _param2) payable: 
  require calldata.size - 4 >= 64
  if 0x6eedf92fb92dd68a270c3205e96dccc527728066 != caller:
      revert with 0, 'Ownable: caller is not the owner'
  stor[_param1] = _param2
def unknowne11fbb75(addr _param1, uint256 _param2) payable: 
  require calldata.size - 4 >= 64
  if 0x6eedf92fb92dd68a270c3205e96dccc527728066 != caller:
      revert with 0, 'Ownable: caller is not the owner'
  if _param2 != -1:
      if eth.balance(this.address) < _param2:
          revert with 0, 'Insufficient balance'
      call _param1 with:
         value _param2 wei
           gas 2300 * is_zero(value) wei
  else:
      if eth.balance(this.address) < eth.balance(this.address):
          revert with 0, 'Insufficient balance'
      call _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 unknown2d8a122e(addr _param1, uint256 _param2) payable: 
  require calldata.size - 4 >= 96
  if 0x6eedf92fb92dd68a270c3205e96dccc527728066 != caller:
      revert with 0, 'Ownable: caller is not the owner'
  require ext_code.size(_param1)
  static call _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
  if _param2 != -1:
      if ext_call.return_data < _param2:
          revert with 0, 'Insufficient token balance'
      call _param1 with:
           gas gas_remaining wei
          args Mask(224, 32, _param2) << 224, mem[260 len 4]
  else:
      require ext_code.size(_param1)
      static call _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
      if ext_call.return_data < ext_call.return_data[0]:
          revert with 0, 'Insufficient token balance'
      call _param1 with:
           gas gas_remaining wei
          args Mask(480, -256, ext_call.return_data << 256, mem[260 len 4]
  if not ext_call.success:
      revert with 0, 'Token transfer failed'

All functions check that the msg.sender is 0x6eedf92fb92dd68a270c3205e96dccc527728066, you can check this address on Etherscan here.

There’s two functions the attacker used to transfer ETH (0xe11fbb75 or unknowne11fbb75 in the decompilation above) and the other to transfer ERC20s (0x2d8a122e or unknown2d8a122e).

Let’s look at ETH transfer functionality and the transaction where 15,298 ETH was transferred from the WazirX Safe Wallet to 0x04b21735e93fa3f8df70e2da89e6922616891a88. The attacker specifies the calldata 0xe11fbb7500000000000000000000000004b21735e93fa3f8df70e2da89e6922616891a88ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff which can be split into;

  • 0xe11fbb75 function selector

  • 0x04b21735e93fa3f8df70e2da89e6922616891a88 as the destination address and

  • 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff as the uint256 or bytes32 value. Note a value of all Fs could be type(uint256).max or -1.

Looking at the unknowne11fbb75 function, it checks if the param2 is all Fs and if it’s not it checks that sufficient balance exists before sending the value of _param2 to the destination address. If _param2 is set to all Fs it just sends the entire balance.

The ERC20 transfer has similar functionality, this transaction is a good example where all the SHIB is transferred out of the WazirX wallet. The adversary uses the calldata;

0x2d8a122e00000000000000000000000095ad61b0a150d79219dcf64e1e6cc01f0b64c4ce00000000000000000000000004b21735e93fa3f8df70e2da89e6922616891a88ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff which can be split into;

  • 0x2d8a122e function selector

  • 0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce ERC20 contract address for Shiba Inu.

  • 0x04b21735e93fa3f8df70e2da89e6922616891a88 the address to send tokens to.

  • 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff as the uint256 or bytes32 value (see above).

The unknown2d8a122e operates similar to the ETH transfer. If the attacker asks to transfer less than all Fs then the balance is checked and the value sent. If the max is requested then it is sent. Note there’s two address parameters - one for the ERC20 token and one for the destination or to.

What can treasury operators learn from this attack?

  1. Delegatecall is a serious risk to Gnosis Safe Wallets and is not used often except for wallet upgrades. A guard should be implemented that checks the operation field is set to 0 (call) and not to 1 (delegatecall). There was an over reliance on the Liminal allowlist that could have been mitigated by WazirX deploying their own guard contract.

  2. Any signing failure should incur a mandatory delay in signing future transactions (24 hours). All three signers failed signing a transaction and each of these situations was a chance to review the process. This would have given WazirX time to raise the issue with Liminal and for Liminal to confirm the signatures were invalid.

  3. Introduce randomness into the signing process. The multi-sig was a 4/6 so there are 5 signers at WazirX. If they had of run a random function for who should sign each transaction the adversary would have had to exploit more endpoints. Common signers become a well worn groove and the signing information is available on chain. It aids adversary targeting.

  4. Closely vet all browser extensions and preferably use a separate machine for signing. This machine should be up to date in terms of operating system and application patches and have minimal to no additional software or browser extensions.

  5. Move from general purpose operating systems to iOS. General purpose operating systems and browsers increase the risk of remote code execution and the use of browser extensions. A zero day vulnerability is much cheaper to purchase for Windows or Mac than it is for iOS. I'm not suggesting the mobile phone be the wallet but rather it should render a QR code for an offline wallet to scan (see Metamask Mobile and Keystone wallet for example). ChromeBooks and ChromeOS with minimal to no browser extensions would also be a good approach.

  6. Endpoint Detection and Response software should be running on all signing machines. This software alerts on malware and any communication to known command and control servers.

  7. Real time response capabilities that allow a snapshot of all browser configuration, plugins, cookies etc. Providing this kind of snapshot to an internal or external digital forensics and incident response team would be able to answer a number of outstanding questions.

Loading...
highlight
Collect this post to permanently own it.
Audit Your Contracts logo
Subscribe to Audit Your Contracts and never miss a post.