Platform: Code4rena
Start Date: 12/04/2023
Pot Size: $60,500 USDC
Total HM: 21
Participants: 199
Period: 7 days
Judge: hansfriese
Total Solo HM: 5
Id: 231
League: ETH
Rank: 108/199
Findings: 2
Award: $22.67
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: decade
Also found by: 0x3b, 0xDACA, 0xWaitress, 0xWeiss, 0xkaju, Arz, Aymen0909, BPZ, EloiManuel, HaCk0, J4de, Jerry0x, Jiamin, John, Juntao, Kek, Lalanda, MiloTruck, Mukund, PNS, RedTiger, Ruhum, Satyam_Sharma, ToonVH, Tricko, Udsen, ak1, anodaram, bin2chen, carrotsmuggler, cccz, circlelooper, deadrxsezzz, giovannidisiena, jasonxiale, joestakey, juancito, karanctf, kenta, kodyvim, ladboy233, lil_eth, lukino, markus_ether, marwen, mrpathfindr, nobody2018, parlayan_yildizlar_takimi, peakbolt, ravikiranweb3, rbserver, rvierdiiev, silviaxyz, volodya, zhuXKET, zzebra83
0.0748 USDC - $0.07
https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Equity.sol#L313
The Equity
smart contract features a restructureCapTable()
function designed to allow qualified FPS holders to restructure the cap table when the system is at risk due to low or negative equity. However, there is a critical vulnerability in this function, as it fails to correctly iterate through the provided addressesToWipe
array, leading to an incomplete restructuring of the cap table.
The vulnerability stems from the use of addressesToWipe[0]
instead of addressesToWipe[i]
within the loop, causing the function to burn only the first user's balance repeatedly while ignoring the other users in the array. Moreover, since the _burn
function does not check if the user's balance is greater than 0, the transaction is not reverted, which might lead to the authorized user incorrectly assuming that they have successfully restructured the cap table.
>>> equity.totalSupply() 500 >>> equity.balanceOf(user1) 100 >>> equity.balanceOf(user2) 100 >>> equity.balanceOf(user3) 100 >>> equity.balanceOf(user4) 100 >>> equity.balanceOf(user5) 100 >>> addressesToWipe = [user1, user2, user3, user4, user5] >>> equity.restructureCapTable([], addressesToWipe, {'from': authorizedUser}) Transaction sent: 0x2709e5846a01324844db512cdc14d1f7dd4f0603bdb9b08823649a0249756270 Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0 Equity.restructureCapTable confirmed Block: 7 Gas used: 50737 (0.42%) <Transaction '0x2709e5846a01324844db512cdc14d1f7dd4f0603bdb9b08823649a0249756270'> >>> equity.totalSupply() 400 >>> equity.balanceOf(user1) 0 >>> equity.balanceOf(user2) 100 >>> equity.balanceOf(user3) 100 >>> equity.balanceOf(user4) 100 >>> equity.balanceOf(user5) 100
To fix this vulnerability, it is recommended to implement the following change:
addressesToWipe[0]
with addressesToWipe[i]
within the loop in the restructureCapTable function to ensure that all users in the addressesToWipe
array have their balances burned as intended.#0 - c4-pre-sort
2023-04-20T14:28:47Z
0xA5DF marked the issue as duplicate of #941
#1 - c4-judge
2023-05-18T14:27:03Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: juancito
Also found by: 0xAgro, 0xNorman, 0xSmartContract, 0xStalin, 0xTheC0der, 0xWaitress, 0xhacksmithh, 0xnev, 3dgeville, 8olidity, Arz, Aymen0909, BGSecurity, BRONZEDISC, Bauchibred, Bauer, BenRai, ChainHunters, ChrisTina, CodeFoxInc, DedOhWale, DishWasher, EloiManuel, IceBear, Inspex, Jorgect, Kaysoft, LeoGold, LewisBroadhurst, Madalad, MiloTruck, MohammedRizwan, Nyx, Polaris_tow, RaymondFam, SaharDevep, SanketKogekar, Sathish9098, SolidityATL, Udsen, W0RR1O, aria, ayden, berlin-101, bin2chen, catellatech, codeslide, crc32, decade, descharre, evmboi32, eyexploit, fatherOfBlocks, georgits, giovannidisiena, joestakey, karanctf, kodyvim, ltyu, lukris02, m9800, matrix_0wl, mov, mrpathfindr, nadin, niser93, p0wd3r, parlayan_yildizlar_takimi, pavankv, pontifex, qpzm, ravikiranweb3, rbserver, santipu_, shealtielanz, slvDev, tnevler, wonjun, xmxanuel, yixxas
22.6007 USDC - $22.60
The MintingHub
smart contract features a open position system that allows any user to open a position to mint Frankencoins
. Every user can open a new position with calling openPosition
function. However according to the documentation of frankencoin when opening a position, it is already provided with some collateral, but nothing is minted yet. Minting is not possible until the initialization period of seven
days has passed.
If we take a look to the openPosition
function it can be seen that this seven day
restriction can be bypassed.
function openPosition( address _collateralAddress, uint256 _minCollateral, uint256 _initialCollateral, uint256 _mintingMaximum, uint256 _expirationSeconds, uint256 _challengeSeconds, uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reservePPM) public returns (address) { return openPosition(_collateralAddress, _minCollateral, _initialCollateral, _mintingMaximum, 7 days, _expirationSeconds, _challengeSeconds, _mintingFeePPM, _liqPrice, _reservePPM); }
Given openPosition
function is setting parameter correctly, however it is calling another openPosition
function with setting 7 days
value to the _initPeriodSeconds
parameter.
function openPosition( address _collateralAddress, uint256 _minCollateral, uint256 _initialCollateral, uint256 _mintingMaximum, uint256 _initPeriodSeconds, uint256 _expirationSeconds, uint256 _challengeSeconds, uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reservePPM) public returns (address) { IPosition pos = IPosition( POSITION_FACTORY.createNewPosition( msg.sender, address(zchf), _collateralAddress, _minCollateral, _initialCollateral, _mintingMaximum, _initPeriodSeconds, _expirationSeconds, _challengeSeconds, _mintingFeePPM, _liqPrice, _reservePPM ) ); zchf.registerPosition(address(pos)); zchf.transferFrom(msg.sender, address(zchf.reserve()), OPENING_FEE); IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral); return address(pos); }
However it can be seen that this second openPosition
function is public and accessible for anyone. If we directly call this function instead of the first openPosition
it is possible to bypass seven days
restriction. This function is calling createNewPosition
function function from PositionFactory.sol
contract.
function createNewPosition(address _owner, address _zchf, address _collateral, uint256 _minCollateral, uint256 _initialCollateral, uint256 _initialLimit, uint256 initPeriod, uint256 _duration, uint256 _challengePeriod, uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reserve) external returns (address) { return address(new Position(_owner, msg.sender, _zchf, _collateral, _minCollateral, _initialCollateral, _initialLimit, initPeriod, _duration, _challengePeriod, _mintingFeePPM, _liqPrice, _reserve)); }
And it is calling new Position()
. So if we take a look to the Position
contract
constructor(address _owner, address _hub, address _zchf, address _collateral, uint256 _minCollateral, uint256 _initialCollateral, uint256 _initialLimit, uint256 initPeriod, uint256 _duration, uint256 _challengePeriod, uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reservePPM) { require(initPeriod >= 3 days); // must be at least three days, recommended to use higher values setOwner(_owner); original = address(this); hub = _hub; price = _liqPrice; zchf = IFrankencoin(_zchf); collateral = IERC20(_collateral); mintingFeePPM = _mintingFeePPM; reserveContribution = _reservePPM; if(_initialCollateral < _minCollateral) revert InsufficientCollateral(); minimumCollateral = _minCollateral; challengePeriod = _challengePeriod; start = block.timestamp + initPeriod; // one week time to deny the position cooldown = start; expiration = start + _duration; limit = _initialLimit; emit PositionOpened(_owner, original, _zchf, address(collateral), _liqPrice); }
It is setting given initPeriod
parameter to the start
variable. And it is only checking if given initPeriod
is higher than or equal to the 3 days
instead of 7 days
. So with the help of second function any user can open a position with 3 day restriction and it bypasses the restriction limit defined in the first place.
openPosition
function with giving 3 days
for variable _initPeriodSeconds
and it will open a position.It is recommended that the visibility of the second openPosition
function be changed from public
to internal
. Here is the updated code for the openPosition
function:
function openPosition( address _collateralAddress, uint256 _minCollateral, uint256 _initialCollateral, uint256 _mintingMaximum, uint256 _initPeriodSeconds, uint256 _expirationSeconds, uint256 _challengeSeconds, uint32 _mintingFeePPM, uint256 _liqPrice, uint32 _reservePPM) internal returns (address) { IPosition pos = IPosition( POSITION_FACTORY.createNewPosition( msg.sender, address(zchf), _collateralAddress, _minCollateral, _initialCollateral, _mintingMaximum, _initPeriodSeconds, _expirationSeconds, _challengeSeconds, _mintingFeePPM, _liqPrice, _reservePPM ) ); zchf.registerPosition(address(pos)); zchf.transferFrom(msg.sender, address(zchf.reserve()), OPENING_FEE); IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral); return address(pos); }
#0 - 0xA5DF
2023-04-27T09:45:15Z
Dupe of #242
#1 - c4-judge
2023-05-18T05:50:43Z
hansfriese marked the issue as grade-b