Platform: Code4rena
Start Date: 18/04/2024
Pot Size: $36,500 USDC
Total HM: 19
Participants: 183
Period: 7 days
Judge: Koolex
Id: 367
League: ETH
Rank: 1/183
Findings: 10
Award: $1,566.96
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: MrPotatoMagic
Also found by: 0x175, 0x486776, 0x77, 0xAkira, 0xAsen, 0xDemon, 0xabhay, 0xblack_bird, 0xlemon, 0xloscar01, 0xtankr, 3docSec, 4rdiii, Abdessamed, AlexCzm, Angry_Mustache_Man, BiasedMerc, Circolors, Cryptor, DMoore, DPS, DedOhWale, Dinesh11G, Dots, GalloDaSballo, Giorgio, Honour, Imp, Jorgect, Krace, KupiaSec, Mrxstrange, NentoR, Pechenite, PoeAudits, Ryonen, SBSecurity, Sabit, T1MOH, TheFabled, TheSavageTeddy, Tychai0s, VAD37, Vasquez, WildSniper, ZanyBonzy, adam-idarrha, alix40, asui, blutorque, btk, c0pp3rscr3w3r, caglankaan, carrotsmuggler, d_tony7470, dimulski, dinkras, djxploit, falconhoof, forgebyola, grearlake, imare, itsabinashb, josephdara, kartik_giri_47538, ke1caM, kennedy1030, koo, lionking927, ljj, niser93, pep7siup, poslednaya, ptsanev, sashik_eth, shaflow2, steadyman, turvy_fuzz, ubl4nk, valentin_s2304, web3km, xyz, y4y, zhaojohnson, zigtur
0.0234 USDC - $0.02
The function deposit
has the modifier isValidDNft(id)
, thus allowing any user to deposit tokens to any other user's account, since there is no ownership check. The only check is on validity, which makes sure that the id in question exists.
modifier isValidDNft(uint id) { if (dNft.ownerOf(id) == address(0)) revert InvalidDNft(); _; }
The issue with this is that the protocol implements a flashloan resistance mechanism. It tracks a value idToBlockOfLastDeposit[id]
which is the block number of the last deposit, and forbids withdrawals if the same block number is present.
// function deposit: idToBlockOfLastDeposit[id] = block.number; // function withdraw: function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
Thus if someone deposits even a single wei of any token to a user's account, they cannot make a withdrawal that block.
This can be used to grief users. User's trying to withdraw their funds can be frontrun with a single wei deposit, and they will not be able to withdraw their funds.
This is actually beneficial to the attacker due to the way kerosene
works. Kerosene's value is determined by the free collateral in the system divided by the amount of kerosene in supply.
K_val = (TVL - dyad_supply)/K_supply
When a user makes a withdrawal, they reduce the TVL, reducing the value of kerosene tokens. Thus this griefing attack can be used by attackers to prevent withdrawals, thus keeping the value of kerosene tokens propped up artificially. This can cause damage to the system since kerosene tokens make up some of the backing in the system, keeping positions above the minimum collateral ratio.
Lets say the attacker has a large position in the system, close to a CR of 150%. Lets say the value of kerosene token is 100 USDC.
Since this is not just a griefing attack, but also profits the attacker by delaying/preventing liquidation, this is a high severity issue.
Manual Review
Use the isDNftOwner
modifier in deposit
function to prevent users from carrying out this attack
DoS
#0 - c4-pre-sort
2024-04-27T11:58:24Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:28:36Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:42:04Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:43:20Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:45:37Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:55:59Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:56:02Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-08T15:26:32Z
koolexcrypto marked the issue as duplicate of #1001
#8 - c4-judge
2024-05-11T19:48:38Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: Circolors
Also found by: 0x175, 0x486776, 0xAlix2, 0xSecuri, 0xShitgem, 0xfox, 0xlemon, 0xnilay, 3th, 4rdiii, Aamir, Al-Qa-qa, AlexCzm, Egis_Security, Evo, Honour, Infect3d, Josh4324, Limbooo, Mahmud, SBSecurity, TheSchnilch, ahmedaghadi, alix40, amaron, bbl4de, bhilare_, btk, carrotsmuggler, cinderblock, d3e4, dimulski, dinkras, ducanh2706, iamandreiski, itsabinashb, ke1caM, ljj, sashik_eth, shaflow2, steadyman, web3km, y4y
3.8221 USDC - $3.82
Users can put in kerosene tokens in vaults, and that kerosene is used to overcollateralize vault debts. There are two types of kerosene vaults: bounded and unbounded. Bounded vaults cannot be withdrawn out of, and has 2* the weight of unbounded vault deposits.
The issue is that the protocol doesnt properly handle withdrawals from unbounded kerosene vaults either. Unbounded kerosene vaults, by design are supposed to be able to be withdrawn from. To highlight the problem, lets look at the withdraw
function in VaultManagerV2.sol
:
if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); uint dyadMinted = dyad.mintedDyad(address(this), id); Vault _vault = Vault(vault); uint value = //... 10**_vault.oracle().decimals() //... if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow();
This is the only function which does withdrawals from vaults. There is also the redeemDyad
function, but that also runs into the same issues. As seen in the snippet, the _vault.oracle().decimals()
is needed for the calculations.
However, the kerosene vaults dont have an oracle
storage variable. This means users cannot pass in the address of kerosene vaults in the withdraw
function to withdraw from, since it will revert when calling _vault.oracle().decimals()
. This means the VaultManager provides no way to take out kerosene from unbounded kerosene vaults.
This basically makes unbounded kerosene vaults identical to kerosene vaults, with lower weight. Since this constitutes direct loss of funds, and is meant to operate differently, this is a medium severity issue.
The redeemDyad
function also runs into the same issue, trying to call the oracle
, which wont exist in kerosene vaults.
The contract can withdraw from vaults via the withdraw
and redeemDyad
functions. Both functions call the vault's oracle
contract, which is not present in kerosene vaults.
Thus kerosene vaults cannot be withdrawn from.
Manual review
Add a new withdrawKerosene
function which allows withdrawals from kerosene vaults. This function should not call the oracle
contract, and should be able to withdraw from only the unbounded vaults.
Error
#0 - c4-pre-sort
2024-04-26T21:25:41Z
JustDravee marked the issue as duplicate of #1048
#1 - c4-pre-sort
2024-04-28T18:38:24Z
JustDravee marked the issue as duplicate of #830
#2 - c4-pre-sort
2024-04-29T08:44:28Z
JustDravee marked the issue as sufficient quality report
#3 - c4-judge
2024-05-11T20:05:54Z
koolexcrypto marked the issue as satisfactory
#4 - c4-judge
2024-05-13T18:39:28Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: 0xAlix2
Also found by: 0x486776, 0xabhay, 0xlucky, 0xtankr, Abdessamed, Circolors, CodeWasp, DarkTower, Egis_Security, Giorgio, Infect3d, Krace, KupiaSec, Limbooo, Maroutis, NentoR, Ryonen, SpicyMeatball, T1MOH, TheFabled, TheSavageTeddy, TheSchnilch, VAD37, XDZIBECX, btk, carrotsmuggler, cu5t0mpeo, dimulski, gumgumzum, iamandreiski, imare, itsabinashb, ke1caM, kennedy1030, lian886, n4nika, oakcobalt, sashik_eth, shaflow2, steadyman, web3km, windhustler, zhaojohnson
3.8221 USDC - $3.82
The price of kerosene is calculated in the assetPrice
function as shown below.
for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[i]); tvl += vault.asset().balanceOf(address(vault)) * vault.assetPrice() * 1e18 / (10**vault.asset().decimals()) / (10**vault.oracle().decimals()); } uint numerator = tvl - dyad.totalSupply(); uint denominator = kerosineDenominator.denominator(); return numerator * 1e8 / denominator;
Simplified, the price of kerosene is calculated as:
Thus if the TVL ever falls below the dyad supply, it will lead to a revert.
The TVL is calculated with only the exo collateral, i.e. kerosine backing is not used to calculate the TVL. Also, the dyad amount that can be minted is capped by the non-kerosine collateral as shown in the VaultManagerV2.sol
.
if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat();
So, users can mint upto TVL of dyad. But if TVL ever drops below the dyad supply, the pricing mechanism breaks.
This is a very bad design, since users can toe the very extreme scenario even under normal conditions. If any volatility hits the markets, the TVL will drop, and assetPrice
function will start reverting.
This will prevent liquidations from going through, since the collateralizaton ratio calculation calls assetPrice
on the kerosene vault. So in case of volatility, mints, withdrawals and liquidations will all fail due to the pricing mechanism breaking.
Lets assume a scenario where the system has 2 users, Alice and Bob. Alice has 1 million USDC in their vault. Bob has 9 million USDC in their vault. Alice also has 600k kerosene tokens, and kerosene has a total supply of 10e6 (10 mill).
Thus TVL = 10 million, dyad_supply = 0. kerosene price = 10e6/10e6 = 1.
Now, Alice decides to mint 1 million dyad tokens (minus 1 wei, to pass checks).
Alice_dyad_mint = 1e6 -1 Alice_exo_collateral = 1e6
So the if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat();
check passes.
kerosene price = (10e6 - 1e6)/10e6 = 0.9 Alice_kerosene_collateral = 600k * 0.9 = 540k USD worth Alice_collateral_ratio = (1e6 + 540k)/(1e6) = 1.54
So the if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow();
check also passes.
Now, Bob removes his 9 million USDC of collateral.
Now, TVL = 1 million, dyad_supply = 1 million - 1 wei.
If the TVL reduces by even 1e-18, the assetPrice
function will start reverting.
The main issue is that the system allowed ALICE to create a position where if all other players leave, Alice will be at the very extreme edge of the system. This design can be abused by users very easily.
Once assetPrice
starts reverting, Alice cannot be liquidated anymore.
Manual Review
Limit dyad to be minted upto 0.8* exo collateral. This will make it less capital efficient, but safer.
Under/Overflow
#0 - c4-pre-sort
2024-04-28T17:11:32Z
JustDravee marked the issue as duplicate of #415
#1 - c4-pre-sort
2024-04-29T09:31:18Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T11:02:12Z
koolexcrypto marked the issue as duplicate of #308
#3 - c4-judge
2024-05-11T20:08:27Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: carrotsmuggler
Also found by: Al-Qa-qa, Emmanuel, TheFabled, TheSavageTeddy, ZanyBonzy, adam-idarrha, alix40, lian886
970.4389 USDC - $970.44
https://github.com/code-423n4/2024-04-dyad/blob/44becc2f09c3a75bd548d5ec756a8e88a345e826/src/core/Vault.kerosine.sol#L47-L59 https://github.com/code-423n4/2024-04-dyad/blob/44becc2f09c3a75bd548d5ec756a8e88a345e826/src/core/VaultManagerV2.sol#L225-L226
The protocol implements a flash-loan manipulation protection mechanism with the idToBlockOfLastDeposit
variable. This values is set to the current block number during a deposit, and is checked during a withdrawal. If the system detects a deposit and withdrawal in the same block, the system reverts the transaction.
//function deposit idToBlockOfLastDeposit[id] = block.number; //function withdraw if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
The issue is that there is another way to move funds around: liquidations. This calls the move
function to transfer around the balances, and does not update the idToBlockOfLastDeposit
of the receiving account.
function liquidate( uint id, uint to ) { //... vault.move(id, to, collateral); //... }
So, a user can
The step 2 involves manipulating the price of kerosene, which affects their collateralization ratio. This has been discussed in a separate issue, and mainly states that if the user mints more dyad against free collateral in the system, or if any user takes out free collateral in the system, the price of kerosene will fall.
The flaw being discussed in this report is that the flash loan protection mechanism can be bypassed. This is different from the price manipulation issue and is thus a separate issue. Since this bypasses one of the primary safeguards in the system, this is a high severity issue.
The POC exploit setup requires 4 accounts: A, B, C and D. A is where the flashed funds will be deposited to. B is where the liquidated funds will be deposited to. C is for manipulating the kerosene price. D is for minting dyad at manipulated price to accrue bad debt in the system.
A is used to inflate the price of kerosene. B is used to bypass the flash loan protection mechanism. C is used to deflate the price of kerosene. D is used to mint dyad at the inflated price, accruing bad debt in the system and damaging the protocol.
This lets Alice open positions at a CR of close to 1.0. This is very dangerous in a CDP protocol, since Alice's risk is very low as she can sell off the minted dyad to recover her investment, but the protocol is now at a very risky position, close to accruing bad debt. Thus this is a high severity issue.
Manual Review
The flashloan protection can be bypassed. MEV liquidation bots rely on flashloans to carryout liquidations, so there isnt a very good way to prevent this attack vector. Making the price of kerosene less manipulatable is a good way to lower this attack chance. However the system will still be open to flashloan deposits via liquidations.
Incorporating a mint fee will also help mitigate this vector, since the attacker will have a higher cost to manipulate the system.
Other
#0 - c4-pre-sort
2024-04-28T19:01:54Z
JustDravee marked the issue as high quality report
#1 - c4-pre-sort
2024-04-28T19:01:57Z
JustDravee marked the issue as primary issue
#2 - koolexcrypto
2024-05-05T13:23:04Z
@carrotsmuggler the attack assumes that kerosine is used within the CR. Could you please clarify how the attacker would acquire this big amount of kerosine?
Marking this as valid for now, re-evaluating in PJQA phase.
#3 - c4-judge
2024-05-05T13:25:37Z
koolexcrypto changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-05-05T13:25:49Z
koolexcrypto marked the issue as satisfactory
#5 - carrotsmuggler
2024-05-05T13:29:41Z
@koolexcrypto kerosene can just be bought off of DEXs and other open markets. In this attack, kerosene is not being flashloaned. Kerosene is just required as an initial investment. USDC is flashloaned, and dyad tokens are minted against that up to a CR of 1.5, which drops to 1.0 once the value of kerosene drops.
The point of the issue is to show that the flashloan protection can be bypassed, which is being done here by utilising multiple accounts.
#6 - koolexcrypto
2024-05-05T15:15:30Z
@carrotsmuggler
Thank you for your input. I requested the answer in PJQA,my bad, I didn't communicate this better.
I'm requesting a PoC (with code) in order to be able to evaluate the severity better. At the moment, the attack is too expensive since the attacker should hold a big amount of Kerosene, which practically difficult since Kerosene is being distributed over 10 years. Unless there is a demonstrated impact on the protocol, this would be a QA. Please prepare the PoC and post it in PJQA phase.
#7 - c4-judge
2024-05-11T11:57:31Z
koolexcrypto marked the issue as selected for report
#8 - jorgect207
2024-05-16T00:27:36Z
Thanks t0 the judge and everyone who participate in this.
I consider that this is not a valid vulnerability for next reasons:
No
.No
.For this reasons, i think that this issue have to be duplicated of #67 or QA.
#9 - Emedudu
2024-05-16T12:24:27Z
The first one and the most important is that just bypass the flash loan protection in fact is not a vulnerability
So why did the protocol put a defense mechanism in place to prevent flashloan attacks?
This report identifies a root issue(which is bypassing of the flashloan protection, allowing flashloan attacks), and has proved how this issue has an impact on the protocol.
#10 - jorgect207
2024-05-16T20:49:02Z
hey @Emedudu the protocol put defense again flash loan to prevent a major attack, but indeed just bypass the flash loan protection is not at vulnerability, you basically are depositing and withdrawing in the same block.timestamp but it's the protocol losing funds? No, is the function of the protocol or its availability could be impacted? No, i just following code4arena rules and my discernment.
Just bypass the flash loan protection is not a vulnerability is you are no actually attacking the protocol, but you explain about the kerosone manipulation which is a valid findings, that why i think that this should be duplicate of #67
#11 - Emedudu
2024-05-17T04:37:31Z
Hi @jorgect207
Every flashloan attack that has ever happened could have also been done by a rich attacker.
What makes flashloan attacks severe is that they allow anyone to perform those attacks.
Protocol is aware of this, and that's why they put in place a flashloan protection mechanism.(This also explains why #67 is acknowledged, while this issue is confirmed)
#12 - Al-Qa-qa
2024-05-17T04:38:51Z
This issue is not dup #67, as the root cause is different.
For the impact of The flashloan attack, the report says that this will manipulate kerosine price, and the protocol pointed to the kind of issues via flash loan.
There is another impact, which is liquidating users' positions, and I mentioned how this will occur in my report #1114
#13 - carrotsmuggler
2024-05-17T17:32:03Z
The problem this issue addresses, is that the flash loan protection can be bypassed. For that, a POC is taken from the issue no 537 showing that self liquidation can be used to flash funds, manipulate the system, and then take them out in the same transaction. The same is quoted below.
However, the main point of contention here seems to be the impact. Flashloans in general don't do anything a well funded attacker cannot do on their own, and not an exploit on their own. However, they can be used to exacerbate an existing problem by anyone, well funded or not.
To eradicate this vector, and to make the system less manipulatable, the devs had put in certain restrictions. This issue shows that these restrictions are insufficient. So users can use flashloans and thus a near infinite amount of funds to manipulate the system. This was reported since the devs had explicitly put up a counter to this.
Since this breaks the safeguards put in place by the devs and makes the system more easily manipulatable, I believe this is of medium severity. This can be used to #67, but should not be a duplicate. This can also be abused to mint positions at 100% CR instead of 150% with a very large volume by anyone due to the flashloans, which makes the system way more unstable. Even small changes in price at that condition will be enough to cause bad debt to the system then.
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import {DeployBase, Contracts} from "../script/deploy/DeployBase.s.sol"; import {Parameters} from "../src/params/Parameters.sol"; import {DNft} from "../src/core/DNft.sol"; import {Dyad} from "../src/core/Dyad.sol"; import {Licenser} from "../src/core/Licenser.sol"; import {VaultManagerV2} from "../src/core/VaultManagerV2.sol"; import {Vault} from "../src/core/Vault.sol"; import {OracleMock} from "./OracleMock.sol"; import {ERC20Mock} from "./ERC20Mock.sol"; import {IAggregatorV3} from "../src/interfaces/IAggregatorV3.sol"; import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {KerosineManager} from "../src/core/KerosineManager.sol"; import {UnboundedKerosineVault} from "../src/core/Vault.kerosine.unbounded.sol"; import {BoundedKerosineVault} from "../src/core/Vault.kerosine.bounded.sol"; import {Kerosine} from "../src/staking/Kerosine.sol"; import {KerosineDenominator} from "../src/staking/KerosineDenominator.sol"; contract VaultManagerV2Test is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManagerV2; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; //Kerosine Kerosine kerosine; UnboundedKerosineVault unboundedKerosineVault; KerosineManager kerosineManager; KerosineDenominator kerosineDenominator; //users address user1; address user2; function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(3000e8); vaultManagerLicenser = new Licenser(); vaultLicenser = new Licenser(); dyad = new Dyad(vaultManagerLicenser); //vault Manager V2 vaultManagerV2 = new VaultManagerV2( dNft, dyad, vaultLicenser ); //vault wethVault = new Vault( vaultManagerV2, ERC20(address(weth)), IAggregatorV3(address(wethOracle)) ); //kerosineManager kerosineManager = new KerosineManager(); kerosineManager.add(address(wethVault)); vaultManagerV2.setKeroseneManager(kerosineManager); //kerosine token kerosine = new Kerosine(); //Unbounded KerosineVault unboundedKerosineVault = new UnboundedKerosineVault( vaultManagerV2, kerosine, dyad, kerosineManager ); //kerosineDenominator kerosineDenominator = new KerosineDenominator( kerosine ); unboundedKerosineVault.setDenominator(kerosineDenominator); //Licenser add vault vaultLicenser.add(address(wethVault)); vaultLicenser.add(address(unboundedKerosineVault)); //vaultManagerLicenser add manager vaultManagerLicenser.add(address(vaultManagerV2)); } function testFlashLoanAttackUsingLiquidateSimulation() public{ wethOracle.setPrice(1000e8); //1 The attacker prepares two NFTs ,some collateral and some Kerosene Token. uint id = mintDNft(); uint id_for_liquidator = mintDNft(); weth.mint(address(this), 1e18); //2 deposit all the non-Kerosene collateral in the vault with One NFT like id=1 vaultManagerV2.add(id_for_liquidator, address(wethVault)); weth.approve(address(vaultManagerV2), 1e18); vaultManagerV2.deposit(id_for_liquidator, address(wethVault), 1e18); //3 In the next blocknumber, flashloan non-Kerosene collateral from Lending like Aave, vm.roll(block.number + 1); weth.mint(address(this), 1e18);//Simulation borrow 1 weth //deposit all the borrowed flashloan non-Kerosene collateral and Kerosene Token in the vault with One NFT like id=0 vaultManagerV2.add(id, address(wethVault)); weth.approve(address(vaultManagerV2), 1e18); vaultManagerV2.deposit(id, address(wethVault), 1e18); vaultManagerV2.addKerosene(id, address(unboundedKerosineVault)); kerosine.approve(address(vaultManagerV2), 1000_000_000e18); vaultManagerV2.deposit(id, address(unboundedKerosineVault), 1000_000_000e18); //Mint the max number Dyad you can vaultManagerV2.mintDyad(id, 1000e18, address(this)); uint256 cr = vaultManagerV2.collatRatio(id); //2e18 assertEq(cr, 2e18); //withdraw using id_for_liquidator, manipulate the Kerosene price vaultManagerV2.withdraw(id_for_liquidator, address(wethVault), 1e18, address(this)); cr = vaultManagerV2.collatRatio(id); //1e18 assertEq(cr, 1e18); //liquidate vaultManagerV2.liquidate(id, id_for_liquidator); //withdraw the vault which is move from id using id_for_liquidator vaultManagerV2.withdraw(id_for_liquidator, address(wethVault), 1e18, address(this)); console.log("weth balance is ", weth.balanceOf(address(this))/1e18); } function mintDNft() public returns (uint) { return dNft.mintNft{value: 1 ether}(address(this)); } function deposit( ERC20Mock token, uint id, address vault, uint amount ) public { vaultManagerV2.add(id, vault); token.mint(address(this), amount); token.approve(address(vaultManagerV2), amount); vaultManagerV2.deposit(id, address(vault), amount); } receive() external payable {} function onERC721Received( address, address, uint256, bytes calldata ) external pure returns (bytes4) { return 0x150b7a02; } }
#14 - adamidarrha
2024-05-17T19:01:09Z
for the impact the sponsors stated quite clearly from the DYAD code4rena contest page that the main point of migrating from vaultManagerV1 to V2 is the need for a flashloan protection mechanism, and the impact of the bypass is the ability to manipulate kerosene price which could lead to mass liquidations as discussed in separate issues:
Attack ideas (where to focus for bugs)
Manipulation of Kerosene Price.
Flash Loan attacks.
Migration
The goal is to migrate from VaultManager to VaultManagerV2. The main reason is the need for a flash loan protection which makes it harder to manipulate the deterministic Kerosene price.
#15 - koolexcrypto
2024-05-28T09:56:53Z
Thank you all for your input.
After reading all the comments above, I believe this should be a valid high due to the following reasons:
#16 - c4-judge
2024-05-28T09:57:12Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: SBSecurity
Also found by: AlexCzm, Emmanuel, Stefanov, carlitox477, carrotsmuggler, d3e4, grearlake, peanuts
223.9474 USDC - $223.95
The docs state that the liquidation bonus should be 20%.
and in return receives an equivalent value plus a 20% bonus of the target Note’s backing colateral
This intent is also reflected in the constant values set in the system.
uint public constant LIQUIDATION_REWARD = 0.2e18; // 20%
However, the way the math works out, the liquidation bonus is actually capped to 10%.
The liquidation bonus is calculated in two steps.
uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD); uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr); // When liquidating uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
The first two lines calculate liquidationAssetShare
, and the last line calculates the collateral amount to be transferred to the liquidator given this value. The issue is that this value is capped to 110%, which gives a 10% bonus only.
cappedCR
can have a maximum value of 1.5. We are ignoring WAD mathematics here, since it can be shown in simple mathematics as well. Any higher value of cappedCR
will not result in a liquidation. So cappedCR
has to be less than 1.5, but her for the sake of simplicity lets assume it is exactly 1.5.
Then liquidationEquityShare
is calculated as = (1.5 - 1)*0.2 = 0.1, Since LIQUIDATION_REWARD
is 0.2.
Then liquidationAssetShare
is calculated as = (0.1 + 1)/1.5 = 0.733
So if 100 dyad is being liquidated, that means the collateral backing it must be 150, since the CR is 1.5. In this case, the collateral paid out = 150*0.733 = 110.
So even in the case with the highest amount of liquidation reward, the liquidation bonus is capped to 10%.
This is assumed to be a case of a mathematical mistake, and is thus a medium severity issue.
In simple maths,
LIQUIDATION_REWARD=0.2
, and cappedCR
can have a max value of 1.5, which nets the max liquidation bonus.
liquidationEquityShare = (1.5-1)*0.2 = 0.1
liquidationAssetShare = (0.1+1)/1.5 = 0.733
For 1.5 units of collateral present, the liquidator is paid 0.733 * 1.5 = 1.1
units of collateral, so a bonus of 10% instead of 20%.
Manual review
The liquidationEquityShare
needs to be doubled.
uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD) * 2
Math
#0 - c4-pre-sort
2024-04-28T18:58:40Z
JustDravee marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-04-28T18:58:43Z
JustDravee marked the issue as primary issue
#2 - shafu0x
2024-05-01T22:28:33Z
technically true, but we are not gonna change that
#3 - koolexcrypto
2024-05-04T21:40:48Z
Good finding with a clear description.
#4 - c4-judge
2024-05-04T21:41:08Z
koolexcrypto marked the issue as selected for report
#5 - c4-judge
2024-05-04T21:42:09Z
koolexcrypto marked the issue as satisfactory
#6 - Slavchew
2024-05-15T22:25:47Z
Hey @koolexcrypto
Thank you for your judgment here. But there are a few things I want to point out:
#7 - AtanasDimulski
2024-05-16T06:03:34Z
I believe this issue is invalid, as mentioned in the report the docs state that and in return receives an equivalent value plus a 20% bonus of the target Note’s backing colateral. The warden calculations show exactly the same. The liquidator will receive 100 collateral for the 100 dyad tokens he burned, and then from the 50 tokens remaining, he receives exactly 20% bonus, which is 10. 50 is the backing collateral of the note once 100 dyad has been repaid by the liquidator. I believe this works as intended, and sponsors were not that specific in their documentation as from which amount the bonus should be calculated.
#8 - adamidarrha
2024-05-16T10:07:19Z
This issue should be invalidated due to a misunderstanding regarding the protocol's intended functionality with respect to liquidation rewards. The protocol specifically designs rewards for liquidators to be 20% of the overcollateralization of a position, not 20% of the total collateral:
uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD); uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr); uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
This code calculates the reward based on the overcollateralisation which is: cappedCr - 1e18
, and then applies a 20% bonus on this excess, not on the entire collateral. This is distinctly indicated by the calculation (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD) = (cappedCR - 1e18)*0.2e18/1e18
.
and then get that as a percentage of the totalCollateral that the position has (liquidationEquityShare + 1e18).divWadDown(cappedCr)
This approach clearly demonstrates that the protocol targets only the overcollateralized portion for the liquidation reward, aligning with the sponsor's clarification that this functionality is as intended and will not be altered.
Example calculation:
this is the calculation:
cappedCr = 149e18
uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD) = (149e18 - 1e18)*0.2e18/1e18 = 0.098e18
uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr) = (0.098e18 + 1e18)*1e18/149e18 = 0.733e18
uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare) = 149 * 0.733 ≈ 109.22
The liquidator receives approximately $109.22, with $9.22 as a bonus, which is 20% of the $49 overcollateralization (calculated as 49 * 20 / 100 = 9.8).
#9 - Slavchew
2024-05-16T10:31:43Z
This issue should be invalidated due to a misunderstanding regarding the protocol's intended functionality with respect to liquidation rewards. The protocol specifically designs rewards for liquidators to be 20% of the overcollateralization of a position, not 20% of the total collateral:
The documentation isn't quite good, yeah, but that's where the sponsors comes in, just going to post this answer here.
No one will interact with the protocol if you get a 20% bonus from the leftover.
#10 - T1MOH593
2024-05-16T11:09:00Z
Want to shed light on the issue. In this sheet sponsor explained the exact math behind liquidation bonus https://discord.com/channels/810916927919620096/1229440078280130634/1233098714436866100
Code behaves as expected, problem is stale documentation. Want to note that historically issue that arises from stale docs was judged as QA
#11 - carlitox477
2024-05-16T13:06:04Z
About this issue, this one is a clear duplicate, the direct consequence of what is explained is that rewards are capped to 10% rather than 20%
About arguments from other wardens trying to invalidate the issue, my report also include a conversation with the sponsor in which they expressed that they originally wanted 20% of the total
#12 - AlexCZM
2024-05-16T14:14:45Z
Two ideas on this topic:
Rewarding liquidators with 20% of the overcollateralization of the position only disincentivize the liquidation as the CR became smaller and smaller. If a 100 dyad position with a CR of 149% is not appealing to the liquidators (less than 10$ in reward), why would same position of 100 DYAD and a CR of 110% would be be of interest for liquidators if they get ... 2$? ( 20% from 110 collateral - 100 dyad)?
The sponsor didn't contradict warden's logic (eg. hey, it's 20% from excess collateral and not 20% of the entire position). Instead he reacted with "oops it's 10%, doc are stale, we good". This make me think he wasn't aware of the diminishing liquidation returns as the CR decreases.
I do believe this is valid M.
#13 - AtanasDimulski
2024-05-16T14:22:02Z
What @T1MOH593 shared in this comment demonstrates what was publicly clarified by the sponsor. Private messages have never been accepted as a proof of anything, thus this issue should be invalid, or informational at best due to not clear documentation.
#14 - AtanasDimulski
2024-05-16T14:23:57Z
@AlexCZM what you are describing in your first argument is not a problem with the percentage, but rather a problem with creating small positions that are not profitable for liquidations, which is a separate issue.
#15 - AlexCZM
2024-05-16T14:54:06Z
@AtanasDimulski
#16 - koolexcrypto
2024-05-28T19:21:22Z
Thank you everyone for your feedback.
The issue is still a valid Medium as it points out the incorrectness in liquidation bonus logic. However, changing the primary report to #982 , since it provides a comprehensive detail.
#17 - c4-judge
2024-05-28T19:21:32Z
koolexcrypto marked the issue as not selected for report
#18 - c4-judge
2024-05-28T19:22:12Z
koolexcrypto marked the issue as duplicate of #982
🌟 Selected for report: Circolors
Also found by: 0xtankr, AamirMK, Al-Qa-qa, FastChecker, Infect3d, SBSecurity, Strausses, T1MOH, VAD37, carrotsmuggler, gumgumzum
122.4433 USDC - $122.44
The bounded kerosene vault actually calls the unbounded kerosene vault for its price, and just returns the double of that value.
function assetPrice() public view override returns (uint) { return unboundedKerosineVault.assetPrice() * 2; }
For this to work, the setUnboundedKerosineVault
function needs to be called on the bounded kerosene vault. This is missing from the deployment script.
Since the deployment script is in scope, the deployed state should also be checked for configuration errors. This is a configuration error since the vault is deployed, but then not initialized enough to use its functionality properly. Thus this a medium severity issue.
The assetPrice
function in bounded vaults will not function. It will revert on a call.
Manual review
Call the setUnboundedKerosineVault
function on the bounded vault with the unbounded vault's address.
Other
#0 - c4-pre-sort
2024-04-28T19:07:06Z
JustDravee marked the issue as duplicate of #829
#1 - c4-pre-sort
2024-04-29T09:22:38Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T10:52:12Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-29T12:33:26Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: TheSavageTeddy
Also found by: 0x175, 0x486776, 0xnev, AamirMK, AlexCzm, ArmedGoose, BiasedMerc, CaeraDenoir, Egis_Security, Jorgect, KYP, MrPotatoMagic, PoeAudits, SBSecurity, SovaSlava, VAD37, adam-idarrha, alix40, carrotsmuggler, d_tony7470, dimulski, grearlake, josephdara, ljj, n0kto, okolicodes, sashik_eth, sil3th, turvy_fuzz
7.3512 USDC - $7.35
The VaultManager.sol
contract's remove
function allows users to remove vaults from their own list of vaults. It first does a check to ensure there is no deposits in the vault still by that user.
if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets();
The issue is that anyone can make deposits to any other user's vault. This is because the deposit
function only does a isValidDNft
check which only checks if the target vault exists, not if it is owned by the same person.
modifier isValidDNft(uint id) { if (dNft.ownerOf(id) == address(0)) revert InvalidDNft(); _; }
So any malicious user can deposit a single wei of tokens to a vault and prevent the vault owners from removing that vault. This is a griefing vector.
Alice wants to remove vault A from her list. She takes out all her collateral and calls remove(ALice_nft_id, vault_A_Address)
.
Bob frontruns Alice's remove
calls and deposits 1 wei of tokens to vault A by calling deposit(Alice_nft_id,vault_A_Address,1)
.
Alice's transaction reverts.
Manual Review
Change the isValidDNft
modifier to a isDNftOwner
check.
DoS
#0 - c4-pre-sort
2024-04-27T13:34:03Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:28:36Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:41:18Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:42:04Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:45:37Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:55:44Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:55:47Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-05T21:55:50Z
koolexcrypto marked the issue as not a duplicate
#8 - c4-judge
2024-05-06T08:51:27Z
koolexcrypto marked the issue as duplicate of #118
#9 - c4-judge
2024-05-11T12:23:35Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: Pataroff
Also found by: Egis_Security, Evo, Jorgect, MrPotatoMagic, SBSecurity, T1MOH, carrotsmuggler, ljj
223.9474 USDC - $223.95
The function burnDyad
is open for all, meaning that any user can remove dyad debt from any other user's account. The function only has the isValidDNft
modifier, which checks if the nft exists, not the ownership.
The issue is that if a user wants to repay the complete debt of their own account, by either calling burnDyad
or redeemDyad
, another user can call burnDyad
on their account and burn 1 wei frontrunning the transaction. This will cause the owner's transaction to revert, since the system will be trying to burn more debt than the user accrued.
function burnDyad( uint id, uint amount ) external isValidDNft(id) { dyad.burn(id, msg.sender, amount); emit BurnDyad(id, amount, msg.sender); }
The dyad contract ensures that users cannot burn more tokens than they minted via an underflow protection.
_burn(from, amount); mintedDyad[msg.sender][id] -= amount;
So users will be griefed and will be unable to pay off their full debt. This is a griefing attack and is thus a medium severity issue.
Assume Alice has a debt of 100 dyad. She calls burnDyad
with 100. Bob frontruns this transaction and pays off 1 wei of Alice's debt. Alice's transaction now fails, due to her trying to repay more debt than she has.
Manual review
Add a code section that truncates the amount to pay in case a larger amount is passed in for both burnDyad
and redeemDyad
.
if(amount > dyad.mintedDyad(address(this), id)) amount = dyad.mintedDyad(address(this), id);
DoS
#0 - c4-pre-sort
2024-04-28T05:28:46Z
JustDravee marked the issue as primary issue
#1 - c4-pre-sort
2024-04-28T05:28:57Z
JustDravee marked the issue as sufficient quality report
#2 - shafu0x
2024-05-01T22:26:38Z
yeah, burn dyad should only be done by the owner.
#3 - c4-judge
2024-05-05T12:57:04Z
koolexcrypto marked the issue as satisfactory
#4 - c4-judge
2024-05-09T13:03:11Z
koolexcrypto marked the issue as selected for report
#5 - c4-judge
2024-05-10T10:13:37Z
koolexcrypto marked the issue as not selected for report
#6 - c4-judge
2024-05-10T10:14:25Z
koolexcrypto removed the grade
#7 - c4-judge
2024-05-10T10:14:50Z
koolexcrypto marked the issue as duplicate of #992
#8 - c4-judge
2024-05-11T12:16:00Z
koolexcrypto marked the issue as satisfactory
#9 - c4-judge
2024-05-28T10:29:19Z
koolexcrypto marked the issue as duplicate of #100
🌟 Selected for report: carrotsmuggler
Also found by: 0xAlix2, 0xSecuri, 0xleadwizard, 0xlemon, 0xtankr, 3th, Aamir, Abdessamed, Bauchibred, Circolors, Egis_Security, Evo, Hueber, Mahmud, SBSecurity, TheSavageTeddy, TheSchnilch, Tychai0s, alix40, bbl4de, btk, d3e4, ducanh2706, falconhoof, itsabinashb, ke1caM, lian886, n4nika, oakcobalt, pontifex, sashik_eth, steadyman, tchkvsky, zhuying
4.8369 USDC - $4.84
The kerosene manager is the contract responsible for managing kerosene prices. In the current state it has broken functionality due to the design.
The KeroseneManager
contract contains a list of vaults. When we look at UnboundedKerosineVault
contract, we see what those vaults are used for.
function assetPrice() public view override returns (uint) { // ... address[] memory vaults = kerosineManager.getVaults(); uint numberOfVaults = vaults.length; for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[i]); // ... * vault.assetPrice() * 1e18 // ... }
The KeroseneManager
contract is expected to have a list of the backing vaults. This list is then queried for the individual assetPrice()
. Crucially, the KeroseneManager
contract will not have the vault which takes kerosene as the asset. This is because calling assetPrice
on a vault handling kerosene will make it go into an infinite loop.
So this section of the code expects the KeroseneManager
contract to only contain a list of the vaults which has exo collateral.
Now let's look at VaultManagerV2.sol
contract's getKeroseneValue
function. This function is supposed to return the value of kerosene tokens a user has deposited. It should do this by querying all the vaults which takes keroesne as the deposit.
function getKeroseneValue( uint id ) public view returns (uint) { uint numberOfVaults = vaultsKerosene[id].length(); for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaultsKerosene[id].at(i)); if (keroseneManager.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } //... }
So the vaultsKerosene
should hold the vaults which take keorsene
as its deposit. But then the code calls keroseneManager.isLicensed(address(vault)
. The isLicensed
function only checks in the same vaults
array in the kerosene manager.
function isLicensed( address vault ) return vaults.contains(vault);
So, this expects the KeroseneManager
to also contain the kerosene accepting vaults as well!
So we have shown that in UnboundedKerosineVault
, the KeroseneManager
contract is expected to have only the backing vaults, not the keresone deposit vaults itself, or it will enter an infinite loop.
We have also shown that in VaultManagerV2
, the KeroseneManager
contract is expected to have the kerosene deposit vaults as well, or it will not be able to pass the isLicensed
check.
Both the above statements cannot be true at the same time. This is a design flaw. The KeroseneManager
contract, if it contains the kerosene deposit vaults will break the kerosene pricing mechanism, and if it does not, it will break the manager contract.
Thus, the current design is flawed and will break the functionality.
If we look at the deployment script, we see that the kerosene manager only has the backing vaults.
kerosineManager.add(address(ethVault)); kerosineManager.add(address(wstEth));
This means the manager's isLicensed
call will fail for vaults which accept kerosene.
This is a design flaw as described above. A proof of concept cannot be shown since its broken functionality.
Manual Review
Store the kerosene vaults info in the Licenser contract. Then change the keroseneManager.isLicensed
call to vaultLicenser.isLicensed
in the manager contract.
Error
#0 - c4-pre-sort
2024-04-27T16:54:03Z
JustDravee marked the issue as high quality report
#1 - c4-pre-sort
2024-04-27T16:54:07Z
JustDravee marked the issue as primary issue
#2 - shafu0x
2024-05-01T22:25:43Z
Kerosene Manager only uses vaults with exogenous collateral.
#3 - koolexcrypto
2024-05-05T11:29:55Z
getKeroseneValue()
function should return the value of vaults that have kerosene tokens. However, as you mentioned, Kerosene Manager only uses vaults with exogenous collateral. So, those vaults won't be licensed by Kerosene Manager as they don't hold any kerosene tokens.
From Deploy V2 script
KerosineManager kerosineManager = new KerosineManager(); kerosineManager.add(address(ethVault)); kerosineManager.add(address(wstEth));
This result in, getKeroseneValue will always return zero.
Instead of this condition
if (keroseneManager.isLicensed(address(vault))) {
We should probably have this
if (!keroseneManager.isLicensed(address(vault)) and vaultLicenser.isLicensed(address(vault)))
This checks that the vault is a kerosene, since it is licensed by Licenser and not licensed by Kerosene Manager.
Could you please confirm this? @shafu0x
#4 - shafu0x
2024-05-06T15:18:25Z
Ahh ok now it makes sense. thanks for the clarification. this is a correct find.
#5 - c4-judge
2024-05-10T10:17:08Z
koolexcrypto marked the issue as selected for report
🌟 Selected for report: carrotsmuggler
Also found by: 0xAlix2, 0xSecuri, 0xblack_bird, 0xnev, AM, Al-Qa-qa, AlexCzm, Dudex_2004, Egis_Security, GalloDaSballo, Infect3d, Jorgect, KupiaSec, Ryonen, SpicyMeatball, T1MOH, VAD37, adam-idarrha, amaron, cu5t0mpeo, d3e4, darksnow, forgebyola, foxb868, itsabinashb, jesjupyter, nnez, peanuts, pontifex, wangxx2026, windhustler, zhuying
6.3334 USDC - $6.33
The value of kerosene tokens is calculated according to the following formula:
So the price of kerosene changes based on user actions. If a user has a large amount of tokens in their vault, that adds to the TVL. Now if a user mints a bunch of dyad tokens with their deposit as collateral, or if a removes a bunch of their unused collateral, the price of kerosene tokens will drop instantaneously.
When calculating the collateralization ratio of a user's position, the price of kerosene is essential. This is used in the getKeroseneValue
function.
function getKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaultsKerosene[id].length(); for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaultsKerosene[id].at(i)); uint usdValue; if (keroseneManager.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; } function getTotalUsdValue( uint id ) public view returns (uint) { return getNonKeroseneValue(id) + getKeroseneValue(id); }
As seen above, thetotalUsdValue
depends on the getKeroseneValue
function, which calls getUsdValue
on the kerosene vaults. So instantaneously dropping the price of kerosene tokens will also instantaneously drop the totalUSDValue
of the user's position, decreasing their collateralization ratio. This can be used to force users into liquidation, if their final ratio drops below the liquidation threshold.
Since this allows any user to affect the collateralization ratio of other user's positions, this is a high severity issue.
Assume Alice has 1 million USDC tokens in her vault, and 0 minted dyad tokens. TVL is 10 million USD of exo collateral, dyad supply is 5.5 million. Assume total supply of kerosene is 100,000.
So the price of kerosene is:
Now assume Bob has an account with 1000 USDC tokens, and 20 kerosene tokens. They minted 1150 dyad tokens. These values were already included in the calculation above, so the minted amounts wont change. Their current collateralization ratio is:
Now, Alice decides to attack BOB's position. She withdraws her 1 million USDC tokens from her vault. This drops the TVL to 9 million USD. Now the price of kerosene is:
Now the collateralization ratio of Bob's position is:
Thus Bob's position is liquidatable.
Thus whales can set up traps by putting in large amounts of capital as TVL into the vault, propping up the price of kerosene. Then whenever a user opens a position which is moderately close to the liquidation threshold, the whale can immediately take out their liquidity, effectively doing a rug pull, and liquidate the users.
Since this gives high net worth users an easy way to manipulate the protocol at no loss, this is a high severity issue.
Manual Review
Add a TWAP mechanism to calculate the price of kerosene. The price of kerosene being manipulatable instantaneously is a huge risk. By using a TWAP mechanism, the price of kerosene will be more stable, and users will have more time to react to changes in the price of kerosene.
Oracle
#0 - c4-pre-sort
2024-04-28T05:06:21Z
JustDravee marked the issue as high quality report
#1 - c4-pre-sort
2024-04-28T05:08:13Z
JustDravee marked the issue as primary issue
#2 - 0xMax1
2024-04-30T08:58:35Z
Kerosene can be very volatile. Especially in the early days. Users should act accordingly.
@shafu0x I suggest we label issue 67 as sponsor acknowledged
#3 - koolexcrypto
2024-05-05T09:59:03Z
relying solely on the spot price makes users positions subject to liquidation which is a known issue in DeFi. Users should act to prevent their position from being liquidated, However, in this scenario, it is not possible since the price is manipulated in one block. downgrading to medium since the price math is stated by the protocol
#4 - c4-judge
2024-05-05T09:59:13Z
koolexcrypto changed the severity to 2 (Med Risk)
#5 - koolexcrypto
2024-05-05T10:01:47Z
high quality report btw, concise and clear.
#6 - c4-judge
2024-05-05T10:02:18Z
koolexcrypto marked the issue as selected for report
#7 - c4-judge
2024-05-05T10:03:34Z
koolexcrypto marked the issue as satisfactory
#8 - c4-judge
2024-05-08T11:50:09Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#9 - c4-judge
2024-05-08T11:55:11Z
koolexcrypto marked the issue as satisfactory
#10 - c4-judge
2024-05-10T10:23:14Z
koolexcrypto marked the issue as selected for report
#11 - koolexcrypto
2024-05-24T11:12:20Z
Any issue dup with #67 will get partial credit if it doesn't show how.