Setting Bear Traps in the Dark Forest
Wed Jan 25, 2023 · 1934 words

Bear in Woods

Some background: Transactions to insecure contracts on the Ethereum blockchain can be front run by attentive parties via bots in order to steal funds. See https://www.paradigm.xyz/2020/08/ethereum-is-a-dark-forest for the canonical example. These bots are the monsters in the “Dark Forest”. This blog post details my attempt to subvert and co-opt these bots in attempt to get them to send me money, to “set bear traps in the Dark Forest”. We cannot introspect a front-running bots internal code, but we can manipulate and invoke it to see how it interacts with smart contracts we write to infer some of its behavior.

My attempt is based on the fact that these bots are rigid and autonomous. There is no human in the loop. Although the bots are complex, they are not analytical. They simply replace the initiator of a transaction or an address in a transaction argument with an address that they own and then check whether the subsequent change increased their account balance. If it did, they broadcast a modified transaction with a higher gas fee in order to front-run the transaction and grab the funds. This happens instantaneously and automatically.

These bots prey on an insecure contract. But there is no guarantee that front running the transaction will work. There is non-determinism between dry-running a transaction locally and including it an actual block. Since the bots are not analytical, only fast, they cannot tell if something will actually work the same as in their sandbox. This is where I will attempt to get them to step into a trap.

Note: source code for this blogpost can be found here: https://github.com/browep/dark-forest-bear-trap

Dark Forest v1 - Proving the Base Case

Let’s create as simple a case for the Dark Forest as possible, a very simple contract that should be easy to front run:

contract DarkForestV1 {

    event Withdrawal(uint amount, uint when);

    constructor() payable {
    }

    function withdraw() external payable returns(uint256) {
        uint256 currentBalance = address(this).balance;
        emit Withdrawal(currentBalance, block.timestamp);
        payable(msg.sender).transfer(currentBalance);
        return currentBalance;
    }
}

Very simple and insecure as can be. There are no protections on the withdraw method, anyone can call it and get funds that belong to the contract. When deployed, it did just that.

Contract: https://etherscan.io/address/0x4236d4fc7d1f0d8229995ea946ecc62b3091cf6c

My transaction: https://etherscan.io/tx/0xce01309ecdb192bc4c794b337889667c66d9c72a74f20dcd76ae0ef419395501

The front-running transaction: https://etherscan.io/tx/0xc7bef2584c44f6627671947c1771d291c9c6a133d0e815c9807a6094ab1bb53b

The transaction was front-run by a contract labeled by etherscan as “MEV bot”. This is likely a bot used to front run or back run contracts. The monsters are really out there, watching and listening. But note: the contract sat for awhile with no calling the withdraw method. The bots did not detect that the contract was vulnerable until I broadcasted a transaction that they could use to detect it was vulnerable.

Dark Forest v2 - Upping the Ante

Let’s see if we can add some requirements onto the contract to make a little riskier to front-run. Particularly, let’s make it a requirement that the transaction sent to our contract is sent with some non-zero value or will fail.

contract DarkForestV2 {

    event Withdrawal(uint amount, uint when);

    constructor() payable {

    }

    function withdraw() public payable {
        require(msg.value != 0, "value cannot be zero");  // <- new addition
        emit Withdrawal(address(this).balance, block.timestamp);
        payable(msg.sender).transfer(address(this).balance);
    }
}

Not many changes here, the withdraw method now checks to make sure the transaction is not zero value. When I send a transaction to the contract, the bots have no trouble front running it, in fact, they were tripping over themselves to try and front run it.

Contract: https://etherscan.io/address/0x27A53658eE98Ae61081615eA056D09D8FE85d3C6

My transaction: https://etherscan.io/tx/0x9483fd03f5598f8512ac545dfdb70290a428af118ab7aa877fe7449b9a705ba2

Multiple front-running txs:

The bots seem to not be shy about sending funds to an unknown contract. Clearly they are ok with risking funds in order to secure more. This is a requirement for setting a trap, they have to be willing to risk funds.

Dark Forest v3 - Setting a Trap

contract DarkForestV3 {

    bool trapClosed = false;

    event Withdrawal(uint amount, uint when);
    event Trap(bool newTrapState, uint when);
    address payable public owner;

    constructor() payable {
        owner = payable(msg.sender);
    }

    function withdraw() public payable {
        require(msg.value != 0, "value cannot be zero");
        if (!trapClosed || msg.sender == owner) {
            emit Withdrawal(address(this).balance, block.number);
            payable(msg.sender).transfer(address(this).balance);
            emit Trap(true, block.number);
            trapClosed = true;
        } else {
            emit Withdrawal(1, block.number);
            payable(msg.sender).transfer(1);
        }
    }
}

The contract has a trapClosed variable that gets set to true once a transaction has already called the method. The first transaction will work but the second will not. It will only send 1 wei back. This will allow a front-running bot to check the transaction in a “dry-run” locally. It should show as a front run opportunity for any front-runner, but the key is that it should show as a front run opportunity for multiple front-runners. But only one of them will grab the funds. The others will be sending funds and those will be stolen by this contract if the caller is not careful.

Contract: https://etherscan.io/address/0xc3c9bf0b2d389efacf440a0ba7c631ad6fce1060

My transaction: https://etherscan.io/tx/0x8666bb095b00b0d6ccb5fc3ccd0e9ba69043cb079cd196d2c43e9e31242c66b0

Front-runner transactions: * https://etherscan.io/tx/0x54a4701bbcc7231bdbc939765670edb973b97ab5f75dba0c8d1b6074168bed75 * https://etherscan.io/tx/0x9ec6ccf208c439b5ec503eaa05ecfed1940d9663bdda68b5850198aba1a38f61

Two front-runners attempt the transaction. The first one gets the funds, but the second one fails. It does not fail during the call to our contract but fails later and is reverted. The failed front-runner is not calling directly into the contract but instead is calling through a proxy contract. This contract source code is not public, only its bytecode. Although hard to check, I can guess that the contract is likely checking to make sure the call to our contract is returning more Ether than is sent. Although their transaction reverted, they do lose funds to gas costs. A net loss for me but, notably, is a way to frustrate this front-runner and potentially drain their account.

Dark Forest v4 - Certain Monsters Only

Since the previous front-runners are using proxy contracts to manage the risk of losing funds we are going to restrict our contract to only allow transactions from EOAs (Externally Owned Accounts). Essentially, this contract will only allow calls directly instead of through other contracts via require(msg.sender == tx.origin, "caller must be EOA");

contract DarkForestV4 {

    bool trapClosed = false;

    event Withdrawal(uint amount, uint when);
    event Trap(bool newTrapState, uint when);
    address payable public owner;

    constructor() payable {
        owner = payable(msg.sender);
    }

    function withdraw() public payable {
        require(msg.value != 0, "value cannot be zero");
        require(msg.sender == tx.origin, "caller must be EOA");
        if (!trapClosed || msg.sender == owner) {
            emit Withdrawal(address(this).balance, block.number);
            payable(msg.sender).transfer(address(this).balance);
            emit Trap(true, block.number);
            trapClosed = true;
        } else {
            emit Withdrawal(1, block.number);
            payable(msg.sender).transfer(1);
        }
    }

}

This will prevent the front-runners from using a proxy contract to guarantee that they don’t lose funds. Let’s see if they fall for it…

Contract: https://etherscan.io/address/0x4105644882a83030549605fD4B5365A824B63293

My transaction: https://etherscan.io/tx/0xd08b0dfec3c5bf16db54b5549bb5d4d7ee18727ca4be5c152965585541689349

Front-runner tx: https://etherscan.io/tx/0x1265ca3ca7952d3a0598771155e0e35c8896c529a69d6c09e37f664dbf315f54

Front-runners did show up, but only one of them and since the trap will only catch the second one the first one got away with all the funds. Clearly, they are not afraid of using EOA transactions. But since only one showed up there is either only one monster out there, or they can coordinate, or they only one thought it worthwhile.

I ran this scenario again with a little more Ether but got the same result, only one monster came to feed at the trap. Attempting it again to get more data points did not seem prudent. Let’s see if we can introduce some fuzzyness to outsmart them.

Dark Forest v5 - Check Your Watch

For this iteration, instead of counting on multiple front-runners and trying to trip up later ones, we are going to attempt to leverage the difference between dry-runs ( what a front-runner would see locally ) and what would happen in actual blocks. We will use a check to for the block number to determine whether the trap will close or not. This requires delicate timing. For this, I created a script that waits for a certain block, sets the block number in the trap a few ahead, then broadcasts the bait transaction right before the block number for the trap. If the front-runners have not included some fuzzing on inputs then they may see a successful dry-run with the current block number but a failed transaction in a block.

contract DarkForestV5 {

    event Withdrawal(uint amount, uint when);
    event Trap(uint when);

    address payable public owner;
    uint public trapBlockNumber;

    constructor() payable {
        owner = payable(msg.sender);
    }

    function withdraw() public payable {
        require(msg.value != 0, "value cannot be zero");
        require(msg.sender == tx.origin, "caller must be EOA");
        if (block.number < trapBlockNumber || msg.sender == owner) {
            emit Withdrawal(address(this).balance, block.number);
            payable(msg.sender).transfer(address(this).balance);
        }
    }

    function resetTrap(uint blockNumber) public payable {
        require(msg.sender == owner, "You aren't the owner");
        trapBlockNumber = blockNumber;
        emit Trap(blockNumber);
    }
}

Contract: https://etherscan.io/address/0x97478a39aed0a538e2cbc4c2b4e628f766035b53

My transaction: https://etherscan.io/tx/0xf86c2eaeee521f3f5fb165f52358ccaf673586e3fc0da73cbd18d770271170a7

No takers. Either the front-runners are using some fuzzing or they are not interested for some other reason.

Dark Forest v6 - Roll the Dice

Although no one was interested in the v5, let’s not give up. Instead, let’s see if we can find a better bait. The next block number is deterministic, a front-runner will be able to know it, but the next coinbase (the recipient of the block reward) will not. We will make it so that, depending on what the next coinbase is, the contract will pay out or not.

contract DarkForestV6 {

    event Withdrawal(uint amount, uint when);

    address payable public owner;

    constructor() payable {
        owner = payable(msg.sender);
    }

    function withdraw() public payable {
        require(msg.value != 0, "value cannot be zero");
        require(msg.sender == tx.origin, "caller must be EOA");
        if (isValid() || msg.sender == owner) {
            emit Withdrawal(address(this).balance, block.number);
            payable(msg.sender).transfer(address(this).balance);
        }
    }

    function isValid() public view returns (bool) {
        return getCoinbase() % 2 == 0;
    }

    function getCoinbase() public view returns (uint) {
        return uint(uint160(address(block.coinbase)));
    }
}

Contract: https://etherscan.io/address/0x3529b8000616732b0ce68a7d7f24007a4daf79ad

After many bait transactions, there were no bites. This is where the trail grows cold.

Conclusion

I learned a lot from this exercise.

Next Step: If You Can’t Beat ‘em, Join ‘em

Most of the interesting bots used a smart contract owned by them to proxy the exploit so that it could check to make sure doing so would actually return more funds than were sent. Such a contract need not be owned by a single account, a generic front-running contract could be added to the blockchain and used by anyone:

contract Armorer {

    constructor() {
    }

    function yoink(address payable _addr, bytes calldata funcData) public payable {
        // grab the starting balance for comparison
        uint256 startBalance = address(this).balance;
        // execute the call to the specified contract
        (bool success, bytes memory data) = _addr.call{value: msg.value, gas: gasleft()}(
            funcData
        );

        // expect the call to be successful
        require(success, "yoink was not successful");
        // get the ending balance for comparison
        uint256 endBalance = address(this).balance;
        // the call to the other contract should have increased this contracts balance
        require(endBalance > startBalance, "balance was not increased");
        // send funds to the account that invoked this
        payable(msg.sender).transfer(address(this).balance);
    }

    // needed to add to the balance
    receive() external payable {
    }

}

The “Armorer” contract protects the front-runner from someone doing exactly what I was doing. See it at https://etherscan.io/address/0xBDBdDDf4a87fd35a5c96aB5f71c5C3d10ec13AB8 and use it as you wish and thanks for reading!


back · writing · who is Paul Brower? · resume · main