Taking a Closer Look At Unibot Exploit

9 min read

Learn how Unibot was exploited, resulting in a loss of 355.5 ETH, worth $640,000.

TL;DR#

On October 31, 2023, Unibot was exploited on the Ethereum Mainnet due to a smart contract vulnerability, which resulted in a loss of 355.5 ETH, worth approximately $640,000.

Introduction to Unibot#

Unibot aims to offer trading tools, including a trading bot and an integrated trading terminal for Ethereum.

Vulnerability Assessment#

The root cause of the exploit is a call injection vulnerability.

Steps#

Step 1:

We attempt to analyze the attack transaction executed by the exploiter.

Step 2:

The affected contract was not verified and published; therefore, we tried to decode it. It can be seen that, due to a lack of input validation in function 0xb2bd16ab, the attacker is able to call this function with a crafted input for executing transferFrom to any approved desired token.

function 0xb2bd16ab(uint256 varg0, uint256 varg1, bool varg2, uint256 varg3, bytes varg4, uint256 varg5) public payable { 
  require(msg.data.length - 4 >= 192);
  require(varg0 <= uint64.max);
  require(4 + varg0 + 31 < msg.data.length);
  require(varg0.length <= uint64.max);
  require(4 + varg0 + (varg0.length << 5) + 32 <= msg.data.length);
  require(varg2 == varg2);
  require(varg4 <= uint64.max);
  require(4 + varg4 + 31 < msg.data.length);
  require(varg4.length <= uint64.max);
  require(4 + varg4 + varg4.length + 32 <= msg.data.length);
  require(varg5 <= uint64.max);
  require(4 + varg5 + 31 < msg.data.length);
  require(varg5.length <= uint64.max);
  v0 = varg5.data;
  require(4 + varg5 + varg5.length + 32 <= msg.data.length);
  v1 = v2 = 0;
  assert(varg0.length);
  require(0 + varg0.data + 32 - (0 + varg0.data) >= 32);
  require(varg0[v2] == address(varg0[v2]));
  if (address(varg0[v2]) == stor_3_0_19) {
    require(msg.value == varg1, Error('Tx value is insufficient for tx'));
  } else {
    if (varg2 != 1) {
      v3 = v4 = 6485;
      v5 = v6 = 0;
      assert(v6 < varg0.length);
    } else {
        assert(0 < varg0.length);
        require(0 + varg0.data + 32 - (0 + varg0.data) >= 32);
        require(varg0[0] == address(varg0[0]));
        require(bool((address(varg0[0])).code.size));
        v7, /* uint256 */ v1 = address(varg0[0]).balanceOf(address(this)).gas(msg.gas);
        require(bool(v7), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
        require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
        v3 = v8 = 6232;
        v5 = v9 = 0;
        assert(v9 < varg0.length);
      }
      require((v5 << 5) + varg0.data + 32 - ((v5 << 5) + varg0.data) >= 32);
      require(varg0[v5] == address(varg0[v5]));
      v10 = v11 = 0;
      while (v10 < 100) {
        MEM[v10 + MEM[64]] = MEM[v10 + (MEM[64] + 32)];
        v10 += 32;
      }
      if (v10 <= 100) {
        // Unknown jump to Block 0x50b5B0x383f0x171aB0x266. Refer to 3-address code (TAC);
      } else {
         MEM[100 + MEM[64]] = 0;
      }
      v12 = v13, /* uint256 */ v14, /* uint256 */ v15 = address(varg0[v5]).transferFrom(msg.sender, address(this), varg1).gas(msg.gas);
      if (RETURNDATASIZE() == 0) {
        v16 = 96;
      } else {
        v16 = v17 = new bytes[](RETURNDATASIZE());
        RETURNDATACOPY(v17.data, 0, RETURNDATASIZE());
      }
      if (v13) {
        v12 = v18 = !MEM[v16];
        if (MEM[v16]) {
          require(32 + v16 + MEM[v16] - (32 + v16) >= 32);
          v12 = MEM[32 + v16];
          require(v12 == bool(v12));
        }
      }
      require(v12, Error(0x535446));
      // Unknown jump to Block {'0x1858B0x266', '0x1955B0x266'}. Refer to 3-address code (TAC);
      assert(0 < varg0.length);
      require(0 + varg0.data + 32 - (0 + varg0.data) >= 32);
      require(varg0[0] == address(varg0[0]));
      require(bool((address(varg0[0])).code.size));
      v19, /* uint256 */ v20 = address(varg0[0]).balanceOf(address(this)).gas(msg.gas);
      require(bool(v19), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
      require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
      require(v1 + varg1 <= v20, Error('Fee On Transfer check failed, token has Fee on Transfer'));
      assert(0 < varg0.length);
      require(0 + varg0.data + 32 - (0 + varg0.data) >= 32);
      require(varg0[0] == address(varg0[0]));
      assert(3 < varg0.length);
      require(96 + varg0.data + 32 - (96 + varg0.data) >= 32);
      require(varg0[3] == address(varg0[3]));
      require(bool((address(varg0[0])).code.size));
      v21, /* uint256 */ v22 = address(varg0[0]).allowance(address(this), address(varg0[3])).gas(msg.gas);
      require(bool(v21), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
      require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
      if (v22 < varg1) {
        assert(0 < varg0.length);
        require(0 + varg0.data + 32 - (0 + varg0.data) >= 32);
        require(varg0[0] == address(varg0[0]));
        assert(3 < varg0.length);
        require(96 + varg0.data + 32 - (96 + varg0.data) >= 32);
        require(varg0[3] == address(varg0[3]));
        v23 = v24 = 0;
        while (v23 < 68) {
          MEM[v23 + MEM[64]] = MEM[v23 + (MEM[64] + 32)];
          v23 += 32;
      }
        if (v23 <= 68) {
          // Unknown jump to Block 0x50b5B0x3a43B0x266. Refer to 3-address code (TAC);
        } else {
          MEM[68 + MEM[64]] = 0;
        }
        v25 = v26, /* uint256 */ v27, /* uint256 */ v28 = address(varg0[0]).approve(address(varg0[3]), uint256.max).gas(msg.gas);
        if (RETURNDATASIZE() == 0) {
           v29 = v30 = 96;
        } else {
          v29 = v31 = new bytes[](RETURNDATASIZE());
          RETURNDATACOPY(v31.data, 0, RETURNDATASIZE());
        }
        if (v26) {
          v25 = v32 = !MEM[v29];
          if (MEM[v29]) {
            require(v28 + MEM[v29] - v28 >= 32);
            v25 = MEM[v28];
            require(v25 == bool(v25));
          }
        }
        require(v25, Error(21313));
      }
  }
  assert(1 < varg0.length);
  require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
  require(varg0[1] == address(varg0[1]));
  if (address(varg0[1]) == stor_3_0_19) {
    v33 = v34 = this.balance;
  } else {
    assert(1 < varg0.length);
    require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
    require(varg0[1] == address(varg0[1]));
    require(bool((address(varg0[1])).code.size));
    v35, /* uint256 */ v33 = address(varg0[1]).balanceOf(address(this)).gas(msg.gas);
    require(bool(v35), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
  }
  assert(1 < varg0.length);
  require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
  require(varg0[1] == address(varg0[1]));
  if (address(varg0[1]) == stor_3_0_19) {
    v36 = v37 = msg.sender.balance;
  } else {
    assert(1 < varg0.length);
    require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
    require(varg0[1] == address(varg0[1]));
    require(bool((address(varg0[1])).code.size));
    v38, /* uint256 */ v36 = address(varg0[1]).balanceOf(msg.sender).gas(msg.gas);
    require(bool(v38), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
  }
  assert(2 < varg0.length);
  require(64 + varg0.data + 32 - (64 + varg0.data) >= 32);
  require(varg0[2] == address(varg0[2]));
  CALLDATACOPY(v39.data, varg4.data, varg4.length);
  MEM[varg4.length + v39.data] = 0;
  v40, /* uint256 */ v41, /* uint256 */ v42 = address(varg0[2]).call(v39.data).value(msg.value).gas(msg.gas);
  if (RETURNDATASIZE() != 0) {
    v43 = new bytes[](RETURNDATASIZE());
    RETURNDATACOPY(v43.data, 0, RETURNDATASIZE());
  }
  require(v40, MEM[64], RETURNDATASIZE());
  assert(1 < varg0.length);
  require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
  require(varg0[1] == address(varg0[1]));
  if (address(varg0[1]) == stor_3_0_19) {
      v44 = v45 = this.balance;
  } else {
    assert(1 < varg0.length);
    require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
    require(varg0[1] == address(varg0[1]));
    require(bool((address(varg0[1])).code.size));
    v46, /* uint256 */ v44 = address(varg0[1]).balanceOf(address(this)).gas(msg.gas);
    require(bool(v46), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
    require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
  }
  if (v44 - v33) {
    assert(1 < varg0.length);
    require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
    require(varg0[1] == address(varg0[1]));
    if (address(varg0[1]) != stor_3_0_19) {
      assert(1 < varg0.length);
      require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
      require(varg0[1] == address(varg0[1]));
      v47 = v48 = 0;
      while (v47 < 68) {
        MEM[v47 + MEM[64]] = MEM[v47 + (MEM[64] + 32)];
        v47 += 32;
      }
      if (v47 <= 68) {
        // Unknown jump to Block 0x50b5B0x3baa0x171aB0x266. Refer to 3-address code (TAC);
      } else {
        MEM[68 + MEM[64]] = 0;
      }
      v49 = v50, /* uint256 */ v51, /* uint256 */ v52 = address(varg0[1]).transfer(msg.sender, v44 - v33).gas(msg.gas);
      if (RETURNDATASIZE() == 0) {
         v53 = 96;
      } else {
        v53 = v54 = new bytes[](RETURNDATASIZE());
        RETURNDATACOPY(v54.data, 0, RETURNDATASIZE());
      }
      if (v50) {
        v49 = v55 = !MEM[v53];
        if (MEM[v53]) {
          require(32 + v53 + MEM[v53] - (32 + v53) >= 32);
          v49 = MEM[32 + v53];
          require(v49 == bool(v49));
        }
      }
      require(v49, Error(21332));
    } else {
      0x3013(v44 - v33, msg.sender);
    }
  }
  assert(1 < varg0.length);
  require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
  require(varg0[1] == address(varg0[1]));
  if (address(varg0[1]) == stor_3_0_19) {
      v56 = v57 = msg.sender.balance;
  } else {
      assert(1 < varg0.length);
      require(32 + varg0.data + 32 - (32 + varg0.data) >= 32);
      require(varg0[1] == address(varg0[1]));
      require(bool((address(varg0[1])).code.size));
      v58, /* uint256 */ v56 = address(varg0[1]).balanceOf(msg.sender).gas(msg.gas);
      require(bool(v58), 0, RETURNDATASIZE()); // checks call status, propagates error data on error
      require(MEM[64] + RETURNDATASIZE() - MEM[64] >= 32);
  }
  require(v36 - v56 > varg3, Error('MEV PROTECTION: Sandwich detected'));
  return v36 - v56;
}

Step 3:

Specifically, the parameters of this function, `varg0` and `varg4`, are not properly validated and can be used to arbitrarily call any external contract.

CALLDATACOPY(v39.data, varg4.data, varg4.length);
MEM[varg4.length + v39.data] = 0;
v40, /* uint256 */ v41, /* uint256 */ v42 = address(varg0[2]).call(v39.data).value(msg.value).gas(msg.gas);

Step 4:

The hacker has already laundered the stolen assets, worth 355.5 ETH, into Tornado Cash.

undefined

Aftermath#

The team acknowledged the occurrence of the exploit and stated that they have paused their new router due to a token approval exploit.

They further reassured that any funds lost due to the bug will be compensated, and the team will be releasing a detailed response after their investigations are complete.

Solution#

At the heart of many decentralized applications and protocols, allowance mechanisms serve as a foundational aspect of how users interact with smart contracts. Through these mechanisms, users give permissions to contracts or other addresses to transfer tokens on their behalf up to a certain amount. These permissions are crucial for various functions, such as trading tokens on decentralized exchanges or interacting with decentralized finance protocols.

However, when not properly managed, these allowances can open doors for potential exploits. One such vulnerability arises when users grant unlimited allowances to certain contracts without fully understanding the implications. By granting unlimited access, users essentially give carte blanche permissions for these contracts or addresses to move all their tokens whenever they wish.

The exploit in reference to Unibot took advantage of the very trust that users placed in the allowance mechanism. Malicious actors could trigger the contract in a manner that utilized the vast allowances set by unsuspecting users. As a result, significant funds were at risk, and many users faced substantial losses.

It's important to note that the severity of this exploit could have been mitigated to a much greater extent had the affected users taken the simple precautionary measure of revoking unlimited allowance access to the affected contract. By setting specific limits on their allowances or by frequently auditing and adjusting them, users could have protected their assets more effectively.

Smart contract verification and rigorous auditing are essential for building trust in the blockchain and decentralized finance sectors. A verified smart contract displays public code that matches its deployed bytecode, ensuring transparency. Yet, mere verification isn't sufficient. Audits delve deeper, probing the code for vulnerabilities and malicious patterns. It's crucial for these audits to be conducted by esteemed audit firms with established track records. Their expertise and sophisticated techniques enhance user trust by ensuring that the contract is robustly examined, thereby minimizing unseen vulnerabilities.

In the dynamic world of DeFi and smart contracts, even with the best measures in place, vulnerabilities might sometimes slip through. This unpredictability underscores the importance of strong protective measures, similar to those offered by Neptune Mutual. If Unibot had proactively teamed up with Neptune Mutual to create a dedicated cover pool, the financial impact of the exploit could have been notably reduced. These cover pools serve as financial safety nets, allowing users a way to recover potential financial setbacks from smart contract vulnerabilities.

By partnering with Neptune Mutual, users are spared the often complex process of producing extensive proof of their financial losses. Once an incident is verified and resolved through our incident handling framework, our primary aim becomes the rapid distribution of claims, ensuring timely financial relief for those affected.

Operating on various blockchain networks like EthereumArbitrum, and the BNB chain, Neptune Mutual is committed to extending its safeguarding services to a wide range of DeFi participants. Our unwavering dedication to user security bolsters confidence in the DeFi arena, especially in the wake of major security breaches such as the Unibot incident.

Reference Source BlockSec

By

Tags