Ethology: A Safari Tour in Ethereum’s Dark Forest

Ethereum dark forest monsters are no joke. These frontrunning bots can analyze smart contract calls and functions they have never used before in smart contracts they’ve never seen to extract potential profits.

Given the general lack of understanding of these bots, we set out on a safari tour to shed more light on the situation and see how prevalent they are. We managed to “trap” some generalized frontrunning bots and analyze their behavior. We studied how efficient they are and how likely a transaction is to get hunted down. We also tested different ways to evade them.

What is frontrunning?

In general, frontrunning is the act of getting a transaction first in line in the execution queue, right before a known future transaction occurs.

A simple example of frontrunning is an exchange bid. Say someone is about to buy a huge amount of ETH on Uniswap, enough to drive the price of ETH up. One way to benefit from this scenario is to put a transaction to buy ETH just before this huge amount of ETH is purchased, while the price remains lower. Then, right after the price spikes, sell the ETH for a profit. 

In addition to this arbitrage example, frontrunning many other transactions can also be valuable, including liquidations, buying rare NFTs, or simple user mistakes. (The extractable value from a transaction or an order of transactions is known as MEV).

Frontrunning on Ethereum is achieved by bidding a slightly higher gas price on a transaction, incentivizing miners to place earlier in the order when constructing the block. The higher paying transactions are executed first. Thus if two transactions making a profit from the same contract call are placed in the same block, only the first takes the profit. 

Ethology

The Ethereum is a Dark Forest blog post by Dan Robinson and Georgios Konstantopoulos describes an attempted extraction, where $12K USD found its way into the claws of a sophisticated predator. A predator so advanced that it could track down any value-bearing transaction in the Ethereum txpool and claim it for itself by frontrunning it.

The Dark Forest story is so “scary” that it’s hard to believe at first read. Indeed there are reasons to doubt the existence of such predators. How could funds be taken out of Uniswap, a platform so heavily monitored for arbitrage by a plethora of bots? Could it have been a “regular” arbitrageur? 

Earlier this year, our in-depth analysis proved that this was not the case. This was no ordinary bot. It managed to invoke a contract function that, to our knowledge, had never invoked before. This was done despite the transaction getting obfuscated by a proxy smart contract through which the funds were extracted.

This is quite alarming, to say the least. An ability to monitor any transaction in the txpool is a super-powerful weapon. So troubling that several services began publicly offering a “dark pool” transaction tier.

Instead of using a service such as Infura, or even a private node, a “dark pool” transaction tier enabled transactions to be sent directly to miners, with a promise not to propagate them to the rest of the network and thus keep transactions secure from the predator’s prying eyes. 

A similar approach was used by samczun and co. in their endeavor to extract $9.6M from a vulnerable contract. It’s not hard to imagine that miners start running frontrunning bots themselves while offering safe passage only to those who pay up an additional fee. 

Unlike other elements of Ethereum, such as smart contracts that can be tested in a development environment or on a testnet, these bots only live on the Ethereum mainnet. It does not make financial sense for them to try and frontrun testnet transactions besides some initial experimentation. Additionally, they don’t have to play by the same rules as everyone as their logic is hidden. 

We don’t know the conditions of when these frontrunning predators decide to strike. So, in some ways, tracking down these predators is similar to chasing rare animals. We did not want to target just any frontrunner, rather only specific ones, those of the generalized kind.

Making sure the frontrunners we caught were “true” generalized frontrunners required a unique trap. The trap was a freshly minted contract initiated with a SHA256 hash of a secret string with some funds available for the taking. Only by providing the secret could anyone extract the locked funds. The funds would be sent directly to the sender of the extracting transaction.

The idea was to send a “bait” transaction providing the correct secret to see if anyone tried to copy it and provide the secret themselves, therefore taking the available funds. In the case that anyone takes the funds before the bait transaction does, it means someone was able to analyze the transaction while it was in the txpool, copy its relevant content, and provide the secret themselves.

Interestingly, they would be providing a secret not previously known to them, to a never-before-seen contract, to take the funds – a true generalized frontrunner.

How do generalized frontrunners work?

An essential part of this experiment involves understanding how generalized frontrunners work. However, if one builds a money-making machine, it will likely not be shared on Github. Thus, we can only observe and reverse engineer the perpetrator’s actions.

Building a generalized frontrunner usually requires two components. The first component is an Ethereum account with or without a smart wallet proxy like Zengo’s Etherum wallet where the modified transactions can be sent. The second is the “backend,” the brains of the operation, which mostly happens off-chain. The operator uses some technique to go over each transaction in the pool, parse it, replace its parameters (e.g., the tx caller) and figure out if it bears any profit.

Front-running bot processing flow

A rational bot wouldn’t try to frontrun a transaction that costs more in fees than could potentially be gained. Transaction fees might add up to quite a lot, especially when gas prices are high. Thus, it’s expected that some minimum profit is required to entice the bots to take the bait.

Furthermore, since analysis needs to be done on every transaction in the txpool, of which there are many, time is also of the essence. An Ethereum block takes, on average, 12 seconds to be mined. If the transaction has a high enough gas price, it must be analyzed and replaced quickly enough before the next block comes along. 

This is a probabilistic process, and there is a chance a block is mined immediately after a transaction is broadcasted, leaving bots with no time to successfully analyze it and broadcast a frontrunning transaction.

With these considerations and a few ideas in mind, we tested what it would take for the bots to grab the bait.

Setting the trap

Our contract (the giver) was set up with an initial balance of 0.035 ETH, worth ~$20 at the time. These funds were available to anyone who provided the correct preimage to the hash stored in the contract. To take the funds and act as a trigger for the predators, a separate account (the taker) would attempt to extract the funds by providing the appropriate preimage. 

Round 1: Direct contract call 

To ensure our baseline trap worked properly, we first used the taker account to call the contract. On the first attempt, the gas price was relatively high (set by the ethers framework), and we were able to successfully recover the funds.

This could have resulted from the profit being too low to entice the predators or because the transaction was mined too fast for them to react. Obviously, this was not the desired outcome, as trapping the predators was our goal.

Round 2: Give them time to think

In this round, we addressed the issues we encountered previously. We increased the potential profit and lowered the gas price, so the transaction is not mined too quickly, giving the bots time to find it. The contract was also topped-up with 0.04 ETH (0.005 more than before). 

This time, we had a hit. The transaction was pending for ~3 minutes before it was mined, without getting value from the honeypot contract. Looking at the contract’s internal transaction, we could see the funds went to someone else.

The frontrunning transaction used 25.000001111 Gwei (.000001111Gwei above what we used) and was mined in the same block as the attempted “extraction.”

Planting the tracker

Now that we had successfully caught a bot (at some cost), we could derive some interesting insights. First, the transaction shows that the call to the contract had not been executed directly. The bot did not just copy the tx and blindly sent it from an owned account but instead passed it through a proxy smart contract that acted as a smart wallet to execute these transactions.

We could now track previous and future transaction addresses to see how successful the bot is and how it operates.

Decompiling the contract shows two main functions:

Withdraw,” which basically sends all the funds in the contract to the contract’s operator. The Other function receives some parameters: a contract to call, a parameters list, and a passing value parameter.

With this function, the proxy contract acts as a smart wallet for the operator. Besides the ability to execute calls to external functions, it can also ensure the balance at the start of the transaction is at least as much as the balance in the end, and reverting otherwise, thus avoiding potential loss of fund when calling an unknown contract (except for gas fees of course).

Using Dune Analytics, we can see how successful the bot has been since the start of its operations, which go as far back as May 2018!

We can estimate its earnings at ~17 ETH (~$10K at time of writing) in total, provided that this specific bot always uses the same proxy and sending address to issue transactions.

Funds collected by the bot over time (in ETH)

Round 3: Just how smart are they?

Now that we were convinced the bots were out to get us, we wanted to test if we could extract the bait out of our contract by obfuscating our call via a second contract, a proxy, that will call a function to extract the bait from the giver. (The contract also had a “collect” function to retrieve the funds back to us).

We deployed the ProxyTaker contract and called the appropriate function in an attempt to extract our funds. Since it would cost slightly more to take the funds via the proxy, the giver contract was topped-up to a marginally higher amount of 0.055 ETH. Lo and behold, our transaction was immediately frontrun by another bot.

This time it was far more impressive. Not only was the bot able to detect our extraction transaction, but it identified it from within an internal call, from a completely different contract! Accomplishing this in a record-breaking time. Our extraction transaction was mined in a few seconds (and so was the bot’s).

The identity of the bot is also quite interesting. The bot’s contract is operated mainly by this account. The account has a comment on Etherescan, linking it to the case where white-hackers attempted to extract user funds from a vulnerable Bancor smart contract.

Comment on the operator’s account (source: etherscan)

Given the bot’s same behavioral pattern (call proxy from account A and pass funds to account B) and the proximity to the events, it is reasonable to assume that the bot was also performing generalized frontrunning on white-hackers during that incident.

This bot is much more sophisticated than the former. It focuses not only on ETH but also performs various arbitrage transactions.

Judging by the balance in the account collecting the funds, it’s also far more successful. Currently, the balance holds ~300 ETH (~$180K at time of writing). We can also subtract all the incoming and outgoing funds from the contract’s address to estimate its earnings, which sums up to ~900 ETH. This is only a rough estimation, as the account could have made transactions unrelated to its frontrunning activity. (Here’s a list of all the bot’s transactions with positive value).

Final Round: Successful Extraction

To make the challenge a little more interesting, we made one more obfuscation attempt, this time using a proxy contract that only we could use, an OwnedTaker

The setup is similar to Round 3. The Giver contract was topped up with 0.05 ETH. The funds were also forwarded back to us in the same transaction instead of keeping them in the contract.

In the first attempt, the transaction to the OwnedTaker Contract took ~1 minute to be mined, and we were able to successfully extract the funds.

The experiment was repeated, this time refilling the giver contract with 0.06 ETH, the highest payout we used yet, to ensure the lower payout was not the reason for the lack of a frontrunning attempt.

The GasPrice was also intentionally set to a relatively low value. The transaction was pending for ~7 minutes but still extracted the funds successfully.

The combination of an Owned proxy that only enabled the owner to forward transactions prevented both frontrunners from successfully parsing the data and taking the profit for themselves. 

Successful Extraction

Perhaps the requirement to only allow the call to be executed by the contract owner or the fact the funds were sent to a different target than the calling contract helped avoid a frontrunning attempt.

The bots are quite likely tuned to consider self-preservation first; After all, they are calling an unknown contract, which might execute arbitrary code. It is reasonable to assume they will be avoiding unnecessary risk or lost transaction fees if the profit is not guaranteed.

So, did we win?

Well, depends on how you look at it. We used a new contract, with a secret only known to us. It’s quite evident that both the frontrunners we caught operated on all transactions in the txpool, and judging by their profits, they are quite good at it.

Why did we avoid frontrunning in our last attempt? 

It’s hard to tell. It could be that the bot is not risking communication with authenticated contracts, or the fact that the funds were moved to a different address was unexpected. It definitely doesn’t mean this is a full-proof way to avoid frontrunners. Most likely, there are far more sophisticated bots lurking in the txpool that were simply not interested in taking a risk for such a small profit.

Final thoughts

In this short experiment, we were able to show that generalized frontrunning bots exist and that they are highly sophisticated.

Of course, we understand our experiment is far from complete or definitive. There are many other bots out there with dramatically different triggers and ways of operating. Factors such as potential upside, communication patterns, and minimum complexity (e.g., gas limit), among others, likely impact the way they operate.

However, we do believe our work provides a proof-of-concept and a lead to further unravel the modus operandi of these generalized frontrunning bots.

The scary reality right now is that if there is some call to a contract yielding a profit that anyone can call, even if it is very obscure, it’s highly likely some frontrunning bot will try and take it first. 

Therefore, it is critical to understand these bots and how they operate so we can build more secure systems now and into the future. Initiatives such as Flashbots seek to do just that by democratizing MEV extraction and making it a public resource. We hope this research helps promote this goal. 

If you have encountered these bots, or are looking into more complex obfuscation techniques, feel free to reach out. By sharing your experiences, we’ll better understand these bots and build a safer crypto community for us all. 

Thanks to Tal Be’ery, Omer Shlomovitz, Oded Leiba, Dan Robinson and others for their help reviewing this post

Appendix

Giver contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/utils/Address.sol";

contract Giver {
    using Address for address payable;

    address payable sender;

    bytes32 imageA;

    constructor(bytes32 _imageA) public payable {
        sender = msg.sender;
        imageA = _imageA;
    }

    function giveSender(bytes32 _preimageA) public {
        sender.sendValue(address(this).balance);
    }

    function giveAnyone(bytes32 _preimageA) public {
        require(hash(_preimageA) == imageA);

        msg.sender.sendValue(address(this).balance);
    }

    function hash(bytes32 _preimage) internal pure returns (bytes32 _image) {
        return sha256(abi.encodePacked(_preimage));
    }

    receive() external payable {}
}

ProxyTaker contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/utils/Address.sol";

interface GiverContract {
    function giveAnyone(bytes32 _preimageA) external;
}

contract ProxyTaker {
    using Address for address payable;

    address payable owner;

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

    function take(address giverAddress, bytes32 _preimageA) public {
        GiverContract(giverAddress).giveAnyone(_preimageA);
    }

    function collect() public {
        owner.sendValue(address(this).balance);
    }

    receive() external payable {}
}

OwnedTaker Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts/utils/Address.sol";

interface GiverContract {
    function giveAnyone(bytes32 _preimageA) external;
}

contract OwnedTaker {
    using Address for address payable;

    address payable owner;

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

    function take(address giverAddress, bytes32 _preimageA) public {
        require(msg.sender == owner);
        GiverContract(giverAddress).giveAnyone(_preimageA);
        owner.sendValue(address(this).balance);
    }

    receive() external payable {}
}