prePO contest - kirk-baird's results

Gain exposure to pre-IPO companies & pre-token projects.

General Information

Platform: Code4rena

Start Date: 17/03/2022

Pot Size: $30,000 USDC

Total HM: 8

Participants: 43

Period: 3 days

Judge: gzeon

Total Solo HM: 5

Id: 100

League: ETH

prePO

Findings Distribution

Researcher Performance

Rank: 1/43

Findings: 2

Award: $6,790.00

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: kirk-baird

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

6738.1154 USDC - $6,738.12

External Links

Lines of code

https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/SingleStrategyController.sol#L51-L72

Vulnerability details

Impact

If a strategy does not have sufficient funds to withdraw() for the full amount then it is possible that tokens will be left in this yield contract during migrate().

It is common for withdrawal from a strategy to withdraw less than a user's balance. The reason is that these yield protocols may lend the deposited funds to borrowers, if there is less funds in the pool than the withdrawal amount the withdrawal may succeed but only transfer the funds available rather than the full withdrawal amount.

The impact of tokens remaining in the old strategy is that when we call StrategyController.totalValue() this will only account for the tokens deposited in the new strategy and not those stuck in the previous strategy. Therefore totalValue() is undervalued.

Thus, when a user calls Collateral.deposit() the share calculations _shares = (_amountToDeposit * totalSupply()) / (_valueBefore); will be over stated (note: uint256 _valueBefore = _strategyController.totalValue();). Hence, the user will receive more shares than they should.

The old tokens may be recovered by calling migrate() back to the old strategy. If this is done then totalValue() will now include the tokens previously stuck. The recent depositer may now withdraw and will be owed (_strategyController.totalValue() * _amount) / totalSupply(). Since totalValue() is now includes the previously stuck tokens _owed will be overstated and the user will receive more collateral than they should.

The remaining users who had deposited before migrate() will lose tokens proportional to their share of the totalSupply().

Proof of Concept

https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/SingleStrategyController.sol#L51-L72

function migrate(IStrategy _newStrategy) external override onlyOwner nonReentrant { uint256 _oldStrategyBalance; IStrategy _oldStrategy = _strategy; _strategy = _newStrategy; _baseToken.approve(address(_newStrategy), type(uint256).max); if (address(_oldStrategy) != address(0)) { _baseToken.approve(address(_oldStrategy), 0); _oldStrategyBalance = _oldStrategy.totalValue(); _oldStrategy.withdraw(address(this), _oldStrategyBalance); _newStrategy.deposit(_baseToken.balanceOf(address(this))); } emit StrategyMigrated( address(_oldStrategy), address(_newStrategy), _oldStrategyBalance ); }

The recommendation is to ensure that require(_oldStrategy.totalValue() == 0) after calling _oldStrategy.withdraw(). This ensures that no funds are left in the strategy. Consider the code example below.

function migrate(IStrategy _newStrategy) external override onlyOwner nonReentrant { uint256 _oldStrategyBalance; IStrategy _oldStrategy = _strategy; _strategy = _newStrategy; _baseToken.approve(address(_newStrategy), type(uint256).max); if (address(_oldStrategy) != address(0)) { _baseToken.approve(address(_oldStrategy), 0); _oldStrategyBalance = _oldStrategy.totalValue(); _oldStrategy.withdraw(address(this), _oldStrategyBalance); require(_oldStrategy.totalValue() == 0) _newStrategy.deposit(_baseToken.balanceOf(address(this))); } emit StrategyMigrated( address(_oldStrategy), address(_newStrategy), _oldStrategyBalance ); }

#0 - ramenforbreakfast

2022-03-22T22:51:27Z

This is a valid claim, although it is an edge case. I will maintain the severity of this issue as is. Should consider removing a fixed migration procedure altogether as this issue demonstrates one of the many problems that can occur.

#1 - gzeoneth

2022-04-03T13:31:13Z

Agree with sponsor

Awards

51.8842 USDC - $51.88

Labels

bug
QA (Quality Assurance)
sponsor disputed

External Links

Lines of code

https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarketFactory.sol#L42-L82 https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarket.sol#L67

Vulnerability details

Impact

The is a potential rug pull vector for a user who manipulates the _governance parameter in PrePOMarketFactory::createMarket(). A user who manipulates this value to an address they own will become the owner of the PrePOMarket.sol.

The owner of PrePOMarket.sol may rug pull the protocol by following these steps:

  1. Mint numerous tokens in mintLongShortTokens()
  2. Use the AMM to purchase all LONG tokens (or SHORT)
  3. Call the onlyOwner function setFinalLongPrice() to set the value to _ceilingLongPrice (or _floorLongPrice)
  4. Call redeem() to gain the collateral tokens
  5. Withdraw the collateral tokens for the base tokens

Steps 1-4 can be done in a single transaction allowing the attacker to pull off this attack without allowing time for the DAO to prevent it.

Proof of Concept

https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarketFactory.sol#L42-L82

function createMarket( string memory _tokenNameSuffix, string memory _tokenSymbolSuffix, address _governance, address _collateral, uint256 _floorLongPrice, uint256 _ceilingLongPrice, uint256 _floorValuation, uint256 _ceilingValuation, uint256 _mintingFee, uint256 _redemptionFee, uint256 _expiryTime ) external override onlyOwner nonReentrant { require(_validCollateral[_collateral], "Invalid collateral"); ( LongShortToken _longToken, LongShortToken _shortToken ) = _createPairTokens(_tokenNameSuffix, _tokenSymbolSuffix); bytes32 _salt = keccak256(abi.encodePacked(_longToken, _shortToken)); PrePOMarket _newMarket = new PrePOMarket{salt: _salt}( _governance, _collateral, ILongShortToken(address(_longToken)), ILongShortToken(address(_shortToken)), _floorLongPrice, _ceilingLongPrice, _floorValuation, _ceilingValuation, _mintingFee, _redemptionFee, _expiryTime, false ); _deployedMarkets[_salt] = address(_newMarket); _longToken.transferOwnership(address(_newMarket)); _shortToken.transferOwnership(address(_newMarket)); emit MarketAdded(address(_newMarket), _salt); }

https://github.com/code-423n4/2022-03-prepo/blob/main/contracts/core/PrePOMarket.sol#L44-L68

constructor( address _governance, address _newCollateral, ILongShortToken _newLongToken, ILongShortToken _newShortToken, uint256 _newFloorLongPrice, uint256 _newCeilingLongPrice, uint256 _newFloorValuation, uint256 _newCeilingValuation, uint256 _newMintingFee, uint256 _newRedemptionFee, uint256 _newExpiryTime, bool _allowed ) { require( _newCeilingLongPrice > _newFloorLongPrice, "Ceiling must exceed floor" ); require(_newExpiryTime > block.timestamp, "Invalid expiry"); require(_newMintingFee <= FEE_LIMIT, "Exceeds fee limit"); require(_newRedemptionFee <= FEE_LIMIT, "Exceeds fee limit"); require(_newCeilingLongPrice <= MAX_PRICE, "Ceiling cannot exceed 1"); transferOwnership(_governance); _treasury = _governance; ...

Consider enforcing _governance to be set to the owner of PrePOMarketFactory.sol, which is the governance contract, as seen in the code below.

function createMarket( string memory _tokenNameSuffix, string memory _tokenSymbolSuffix, address _collateral, uint256 _floorLongPrice, uint256 _ceilingLongPrice, uint256 _floorValuation, uint256 _ceilingValuation, uint256 _mintingFee, uint256 _redemptionFee, uint256 _expiryTime ) external override onlyOwner nonReentrant { require(_validCollateral[_collateral], "Invalid collateral"); ( LongShortToken _longToken, LongShortToken _shortToken ) = _createPairTokens(_tokenNameSuffix, _tokenSymbolSuffix); bytes32 _salt = keccak256(abi.encodePacked(_longToken, _shortToken)); PrePOMarket _newMarket = new PrePOMarket{salt: _salt}( owner(), _collateral, ILongShortToken(address(_longToken)), ILongShortToken(address(_shortToken)), _floorLongPrice, _ceilingLongPrice, _floorValuation, _ceilingValuation, _mintingFee, _redemptionFee, _expiryTime, false ); _deployedMarkets[_salt] = address(_newMarket); _longToken.transferOwnership(address(_newMarket)); _shortToken.transferOwnership(address(_newMarket)); emit MarketAdded(address(_newMarket), _salt); }

#0 - ramenforbreakfast

2022-03-22T22:48:14Z

I do not consider this valid.

Given the owner() will be a timelocked DAO multisig, the attack described above breaks basic trust assumptions in our project and the above attack would have to be executed in a timelocked manner since setFinalLongPrice can only be set by the owner().

#1 - gzeoneth

2022-04-03T14:02:20Z

Considering the project described the owner as a timelocked DAO multisig, I am downgrading this to Low/QA. Treating this as warden's QA Report.

#2 - JeeberC4

2022-04-12T18:26:36Z

Per judge downgrade to QA Report, preserving original title: Potential Rug Pull Vector Through _governance Field

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