xanoutas/solidity-security-findings huggingface dataset:
`whenNotPaused` can be bypassed in CDPVault using `modifyCollateralAndDebt`
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/main/src/CDPVault.sol#L367-L460


# Vulnerability details

## Impact
If the vault is paused, users are blocked from depositing and withdrawing their collateral, this is done using the `whenNotPaused` modifier. However, users can bypass this using `modifyCollateralAndDebt` by passing non-zero `deltaCollateral` to deposit and withdraw collateral.

This allows users/attackers to bypass the protocol's pausing state.

## Proof of Concept

Add the following in `src/test/unit/CDPVault.t.sol`:

```solidity
address admin = makeAddr("ADMIN");
address bob = makeAddr("BOB");

function test_canDepositWithdrawWhenPaused() public {
    CDPVault vault = createCDPVault(
        CDPVaultConstants({
            pool: liquidityPool,
            oracle: oracle,
            token: token,
            tokenScale: 10 ** IERC20Metadata(address(token)).decimals()
        }),
        CDPVaultConfig({
            debtFloor: 1 ether,
            liquidationRatio: 1.25 ether,
            liquidationPenalty: 1 ether,
            liquidationDiscount: 0,
            roleAdmin: admin,
            vaultAdmin: admin,
            pauseAdmin: admin
        }),
        150 ether
    );
    createGaugeAndSetGauge(address(vault));

    token.mint(bob, 200 ether);

    // Verify that the vault is not paused
    assertFalse(vault.paused());

    vm.startPrank(bob);
    token.approve(address(vault), type(uint256).max);
    address position = address(new PositionOwner(vault));

    // Bob doesn't have any collateral
    (uint256 collateral, , , , , ) = vault.positions(position);
    assertEq(collateral, 0);
    assertEq(token.balanceOf(address(vault)), 0);

    // Bob deposits 100 ETH
    vault.deposit(position, 100 ether);

    // Bob has 100 ETH collateral
    (collateral, , , , , ) = vault.positions(position);
    assertEq(collateral, 100 ether);
    assertEq(token.balanceOf(address(vault)), 100 ether);
    vm.stopPrank();

    // Pause the vault
    vm.prank(admin);
    vault.pause();

    // Verify that the vault is paused
    assertTrue(vault.paused());

    // Bob can't deposit when the vault is paused
    vm.prank(bob);
    vm.expectRevert(); // Pausable: paused
    vault.deposit(position, 100 ether);

    // Bob can deposit when the vault is paused using `modifyCollateralAndDebt`
    vm.prank(bob);
    vault.modifyCollateralAndDebt(position, bob, bob, 100 ether, 0);

    // Bob has 200 ETH collateral
    (collateral, , , , , ) = vault.positions(position);
    assertEq(collateral, 200 ether);
    assertEq(token.balanceOf(address(vault)), 200 ether);

    // Bob can't withdraw when the vault is paused
    vm.prank(bob);
    vm.expectRevert(); // Pausable: paused
    vault.withdraw(position, 100 ether);

    // Bob can withdraw when the vault is paused using `modifyCollateralAndDebt`
    vm.prank(bob);
    vault.modifyCollateralAndDebt(position, bob, bob, -100 ether, 0);

    // Bob has 100 ETH collateral
    (collatera
Wrong repayment amount used in `PositionAction::_repay`, forcing users to unexpectedly lose funds
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/main/src/proxy/PositionAction.sol#L569-L591


# Vulnerability details

## Impact
`PositionAction` allows users to interact with their position in the CDP Vault through a proxy, on top of that it allows users to do certain actions before interacting with the position. 
An example of this is the `PositionAction::deposit`, which allows users to:
1. Deposit collateral tokens directly into the position
2. Swap arbitrary tokens for collateral and the deposit into the position

This is handled in `PositionAction::_deposit`, where if swap params exist, swap takes place and the returned amount is used when depositing into the position, else the user's specified amount is used.

However, in `PositionAction::_repay`, this is not the case, where even if a swap took place, the amount sent to the vault/position is still the one specified by the user, which is wrong and inconsistent with the other functions' API.

This can cause unexpected behaviors and reverts when users try to interact with `PositionAction::repay`.


## Proof of Concept

Add the following in `src/test/integration/IntegrationTestBase.sol`, to create a balancer pool for the underlying token:
```solidity
function _createBalancerUnderlyingTokenPool() internal returns (IComposableStablePool stablePool_) {
    deal(address(DAI), address(this), 5_000_000 * 1e18);
    deal(address(USDC), address(this), 5_000_000 * 1e6);
    deal(address(USDT), address(this), 5_000_000 * 1e6);
    underlyingToken.mint(address(this), 5_000_000 * 1e18);

    uint256[] memory maxAmountsIn = new uint256[](4);
    address[] memory assets = new address[](4);
    assets[0] = address(DAI);
    assets[1] = address(USDC);
    assets[2] = address(USDT);

    bool tokenPlaced;
    address tempAsset;
    for (uint256 i; i < assets.length; i++) {
        if (!tokenPlaced) {
            if (uint160(assets[i]) > uint160(address(underlyingToken))) {
                tokenPlaced = true;
                tempAsset = assets[i];
                assets[i] = address(underlyingToken);
            } else if (i == assets.length - 1) {
                assets[i] = address(underlyingToken);
            }
        } else {
            address placeholder = assets[i];
            assets[i] = tempAsset;
            tempAsset = placeholder;
        }
    }

    for (uint256 i; i < assets.length; i++) {
        maxAmountsIn[i] = ERC20(assets[i]).balanceOf(address(this));
        ERC20(assets[i]).safeApprove(address(balancerVault), maxAmountsIn[i]);
    }

    stablePool_ = stablePoolFactory.create(
        "Test Token Pool",
        "FUDT",
        assets,
        200,
        3e14, // swapFee (0.03%)
        address(this) // owner
    );

    balancerVault.joinPool(
        stablePool_.getPoolId(),
        address(this),
        address(this),
        JoinPoolRequest({
            assets: assets,
            maxAmountsIn: maxAmountsIn,
            userData: abi.encode(JoinKind.INIT, maxA
CDPVault Contract: Withdrawals and Deposits Not Pausable as Intended
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/4f508781a49ffa53511e7e5ed6cda0ff0eb5bdc5/src/CDPVault.sol#L223-L249
https://github.com/code-423n4/2024-07-loopfi/blob/4f508781a49ffa53511e7e5ed6cda0ff0eb5bdc5/src/CDPVault.sol#L367-L373


# Vulnerability details

### Impact
Administrator is not able to pause users' withdrawals and deposits as expected.

### Proof of Concept
The CDPVault contract inherits Pausable to provide pausing capabilities to the administrator on users' deposits and withdrawals. The contract correctly exposes the `whenNotPaused()` modifier to the deposit and withdrawal functions. However, these functions are simply used to format input for the `modifyCollateralAndDebt()` public function, which does not implement the `whenNotPaused()` modifier, rendering the pausability feature ineffective.

Add the test below to `2024-07-loopfi/src/test/unit/CDPVault.t.sol`:

```solidity
  function test_PauseAndDeposit_Withdrawal() public {
    CDPVault vault = createCDPVault(token, 150 ether, 10 ether, 1.25 ether, 1.0 ether, 0);
    createGaugeAndSetGauge(address(vault));
    token.mint(address(this), 100 ether);
    token.approve(address(vault), 100 ether);
    address position = address(new PositionOwner(vault));

    vault.pause();  //@auditor Admin pauses contract
    
    vm.expectRevert("Pausable: paused"); // @auditor Foundry Cheatcode to expect a revert only on next line
    vault.deposit(position, 50 ether); // @auditor User makes initial deposit with the deposit function and is reverted as protocol is paused.
    vault.modifyCollateralAndDebt({ // @auditor User bypasses the pause by calling modifyCollateralAndDebt, achieving the same result as deposit without reverting.
        owner: position,
        collateralizer: address(this),
        creditor: address(this),
        deltaCollateral: 50 ether,
        deltaDebt: 0
    });

    vm.expectRevert("Pausable: paused"); // @auditor Foundry Cheatcode to expect a revert only on next line
    vault.withdraw(position, 50 ether); // @auditor User makes initial deposit with the withdraw function and is reverted as protocol is paused.
    vault.modifyCollateralAndDebt({  // @auditor User bypasses the pause by calling modifyCollateralAndDebt, achieving the same result as withdraw without reverting.
        owner: position,
        collateralizer: address(this),
        creditor: address(this),
        deltaCollateral: -50 ether,
        deltaDebt: 0
    });
 }
```
`Run the following command`
```shell
forge test --match-contract CDPVault --match-test test_PauseAndDeposit_Withdrawal -vvv
```
### Tools Used
Foundry

### Recommended Mitigation Steps
Add the `whenNotPaused()` modifier to the `modifyCollateralAndDebt()` function.












































## Assessed type

Other
Invalid handling of flash loan fees in `PositionAction::onCreditFlashLoan` forcing it to always revert
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/main/src/proxy/PositionAction.sol#L489


# Vulnerability details

## Impact
When users take a flash loan, an amount is sent to the receiver, and then some action takes place, after that action that sent amount is expected to be paid + some fee. Users can also call `PositionAction::decreaseLever` through a proxy, to "Decrease the leverage of a position by taking out a credit flash loan to withdraw and sell collateral", after it is called, the flash loan lender sends the credit and calls `onCreditFlashLoan`, which handles all that logic.

However, at the end of `onCreditFlashLoan`, the protocol is approving the flash lender of an amount `subDebt` which doesn't include the fees, it is also not handled when subtracting the debt from the corresponding position.

When fees are >0, `PositionAction::decreaseLever` will always revert as the protocol will always revert with `ERC20: insufficient allowance`, as `Flashlender::creditFlashLoan` will try to transfer amount + fees, which is not approved on the other side.

## Proof of Concept

Add the following test in `src/test/integration/PositionAction20.lever.t.sol`:
```solidity
function test_leverageDownNotApprovingFees() public {
    // Re-initialize the system to have fees > 0
    flashlender = new Flashlender(IPoolV3(address(liquidityPool)), 0.01 ether);
    liquidityPool.setCreditManagerDebtLimit(address(flashlender), type(uint256).max);
    positionAction = new PositionAction20(
        address(flashlender),
        address(swapAction),
        address(poolAction),
        address(vaultRegistry)
    );

    uint256 depositAmount = 1_000 ether;
    uint256 borrowAmount = 200 ether;

    deal(address(token), user, depositAmount);

    address[] memory assets = new address[](2);
    assets[0] = address(underlyingToken);
    assets[1] = address(token);

    vm.startPrank(user);

    // User deposits 1k ETH collateral
    token.approve(address(vault), type(uint256).max);
    vault.deposit(address(userProxy), depositAmount);

    // User borrows 200 ETH
    userProxy.execute(
        address(positionAction),
        abi.encodeWithSelector(
            positionAction.borrow.selector,
            address(userProxy),
            address(vault),
            CreditParams({amount: borrowAmount, creditor: user, auxSwap: emptySwap})
        )
    );

    // User leverages down his position, reverts
    vm.expectRevert(bytes("ERC20: insufficient allowance"));
    userProxy.execute(
        address(positionAction),
        abi.encodeWithSelector(
            positionAction.decreaseLever.selector,
            LeverParams({
                position: address(userProxy),
                vault: address(vault),
                collateralToken: address(token),
                primarySwap: SwapParams({
                    swapProtocol: SwapProtocol.BALANCER,
                    swapType: SwapType.EXACT_OUT,
                    assetIn: address(token),
[M-01] ChainlinkOracle::_fetchAndValidate will use incorrect price when underlying aggregator reaches minAnswer or maxAnswer
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/main/src/oracle/ChainlinkOracle.sol#L96


# Vulnerability details

### Description
Chainlink price feeds have in-built minimum & maximum prices they will return; if due to an unexpected event an asset’s value falls below the price feed’s minimum price, the oracle price feed will continue to report the (now incorrect) minimum price, and same thing with the maximum price.
ChainlinkOracle::_fetchAndValidate doesn't handle this case

### Impact
Code will execute with prices that don’t reflect the current pricing resulting in a potential loss of funds for users.

### Recommended Mitigation: Verify if minAnswer < answer < maxAnswer.

Create the interface `IOffchainAggregator`:

```solidity
interface IOffchainAggregator {
    function minAnswer() external view returns (int192);

    function maxAnswer() external view returns (int192);
}
```

Add the verification on `ChainlinkOracle::_fetchAndValidate`:

```diff
    /// @notice Fetches and validates the latest price from Chainlink
    /// @return isValid Whether the price is valid based on the value range and staleness
    /// @return price Asset price [WAD]
    function _fetchAndValidate(address token) internal view returns (bool isValid, uint256 price) {
        Oracle memory oracle = oracles[token];
        try AggregatorV3Interface(oracle.aggregator).latestRoundData() returns (
            uint80 /*roundId*/,
            int256 answer,
            uint256 /*startedAt*/,
            uint256 updatedAt,
            uint80 /*answeredInRound*/
        ) {
+           IOffchainAggregator aggregator = IOffchainAggregator(oracle.aggregator.aggregator());
+           int192 minAnswer = aggregator.minAnswer();
+           int192 maxAnswer = aggregator.maxAnswer();

+            isValid = (answer > minAnswer && answer < maxAnswer && block.timestamp - updatedAt <= oracle.stalePeriod);
-            isValid = (answer > 0 && block.timestamp - updatedAt <= oracle.stalePeriod);
            return (isValid, wdiv(uint256(answer), oracle.aggregatorScale));
        } catch {
            // return the default values (false, 0) on failure
        }
    }
```

### Example:

- https://etherscan.io/address/0x72AFAECF99C9d9C8215fF44C77B94B99C28741e8#readContract (1INCH/ETH Price Feed)
  - aggregator method returns the current phase's aggregator address.
    - https://etherscan.io/address/0xb2F68c82479928669B0487D1dAeD6Ef47b63411e#readContract (aggregator)
      - verify minAnswer and maxAnswer





## Assessed type

Oracle
Chainlink price feed `updatedAt` timestamp not validated for price staleness in AuraVault.sol 
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/4f508781a49ffa53511e7e5ed6cda0ff0eb5bdc5/src/vendor/AuraVault.sol#L371


# Vulnerability details

## Impact
Smart contract will work with incorrect stale price


## Proof of Concept
The `updatedAt` timestamp in `AuraVault.sol` is commented out and not checked for price staleness. This can lead to using stale incorrect price.

Note that this has not been reported by the [4naly3er report](https://github.com/code-423n4/2024-07-loopfi/blob/main/4naly3er-report.md#m-4-chainlinks-latestrounddata-might-return-stale-or-incorrect-results) but a similar issue that was reported did not include this line but it only talks about the roundid which did not even include this line that I reported below.

```solidity
function _chainlinkSpot() private view returns (uint256 price) {
        bool isValid;
        try AggregatorV3Interface(BAL_CHAINLINK_FEED).latestRoundData() returns (
            uint80 /*roundId*/,
            int256 answer,
            uint256 /*startedAt*/,
371 @>      uint256 /*updatedAt*/,
            uint80 /*answeredInRound*/
        ) {
            price = wdiv(uint256(answer), BAL_CHAINLINK_DECIMALS);
            isValid = (price > 0);
        } catch {}

        if (!isValid) revert AuraVault__chainlinkSpot_invalidPrice();
    }

```




## Tools Used
Manual review

## Recommended Mitigation Steps
Consider validating the `updateAt` timestamp instead of commenting it out in the AuraVault.sol

```diff
function _chainlinkSpot() private view returns (uint256 price) {
        bool isValid;
        try AggregatorV3Interface(BAL_CHAINLINK_FEED).latestRoundData() returns (
            uint80 /*roundId*/,
            int256 answer,
            uint256 /*startedAt*/,
--          uint256 /*updatedAt*/,
++          uint256 updatedAt,
            uint80 /*answeredInRound*/
        ) {
++          require(block.timestamp >= updateAt + 86400, "Stale price");//BAL/USD heartbeat is 86400
            price = wdiv(uint256(answer), BAL_CHAINLINK_DECIMALS);
            isValid = (price > 0);
        } catch {}

        if (!isValid) revert AuraVault__chainlinkSpot_invalidPrice();
    }
```








## Assessed type

Oracle
Griefing of last user withdrawal from StakingLPEth vault
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/main/src/StakingLPEth.sol#L143


# Vulnerability details

During withdrawals, a user can leave behind 1 wei of LP shares, preventing the last user from withdrawing their shares below the `MIN_SHARES` threshold.

## Impact

The last user will be unable to withdraw all their shares from the vault, causing a denial of service (DoS) and a potential loss of funds up to the value of `MIN_SHARES`.

## Proof of Concept

1. The StakingLPEth vault is no longer supported, and all users need to withdraw their shares.
2. One user withdraws all their shares except for 1 wei.
3. The last user attempting to withdraw needs to withdraw all their shares to bring the totalSupply to 0. However, this is impossible because 1 wei of shares always remains, causing the following code to revert: `if (_totalSupply > 0 && _totalSupply < MIN_SHARES) revert MinSharesViolation();`. The _totalSupply will always be `> 0`.
4. The last user is stuck with their LP shares up to the `MIN_SHARES` amount, unable to withdraw them until another user deposits, who will then also be stuck.

## Recommended Mitigation Steps

Mint non-withdrawable dead shares to the vault itself, amounting to `MIN_SHARES`. This will prevent donation attacks and also mitigate the potential griefing of the last user.


## Assessed type

Other
Malicious users can front-run to cause a denial of service (DoS) for StakingLPEth due to MinShares checks
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/4f508781a49ffa53511e7e5ed6cda0ff0eb5bdc5/src/StakingLPEth.sol#L141
https://github.com/code-423n4/2024-07-loopfi/blob/4f508781a49ffa53511e7e5ed6cda0ff0eb5bdc5/src/StakingLPEth.sol#L153


# Vulnerability details

## Impact
Malicious users can transfer liquidity pool token to StakingLPEth contract directly lead to a denial of service (DoS) for StakingLPEth due to the limit shares check
## Proof of Concept
User deposit liquidity pool token to StakingLPEth protocol to get share via invoke external deposit function. Let’s see how share is calculate:

```
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) {
        return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
    }
```
Since `decimalsOffset() == 0` and totalAssets equal the balance of liquidity pool token in this contract
```
function totalAssets() public view virtual override returns (uint256) {
        return _asset.balanceOf(address(this));
    }
```
$$ f(share) = (Amount \ast totalSupply) / (totalAssets() + 1) $$
The minimum share is set to 0.01 ether.
```
uint256 private constant MIN_SHARES = 0.01 ether;
```
Assuming malicious users transfer 1 ether of liquidty pool tokens into the protocol and receive ZERO shares, how much tokens does the next user need to pay if they want to exceed the minimum share limit of  0.01 ether? That would be 1 ether times 0.01 ether, which is a substantial amount.
I add a test case in `StakingLPEthTest`: 
```
function testMinSharesViolation() public{
        address Alice = address(123); 
        address Bob = address(321); 
        liquidityPool.transfer(Alice, 1 ether); 
        liquidityPool.transfer(Bob, 1000 ether); 
        // Assume the malicious user is Alice and transfers 1 ether to stakingLpEth contract
        vm.startPrank(Alice); 
        liquidityPool.transfer(address(stakingLpEth),1 ether); 
        vm.stopPrank(); 
        vm.startPrank(Bob); 
        liquidityPool.approve(address(stakingLpEth),type(uint).max); 
        //1000 ether can't exceed the minimum share limit of 0.01 ether
        vm.expectRevert(StakingLPEth.MinSharesViolation.selector); 
        stakingLpEth.deposit(1000 ether , Bob); 
    }
```
We can see even Bob deposit a substantial number of tokens but still cannot surpass the 0.01 ether share limit which will lead to a denial of service (DoS) for StakingLPEth due to MinShares checks.


## Tools Used
Vscode , foundry 

## Recommended Mitigation Steps
We can solve this issue by setting a minimum deposit amount.


## Assessed type

DoS
If Chainlink price feed answer is a negative value, wrong price will be used
Audit: 2024-07-loopfi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-07-loopfi/blob/4f508781a49ffa53511e7e5ed6cda0ff0eb5bdc5/src/vendor/AuraVault.sol#L374


# Vulnerability details

## Impact
Incorrect price will be used due to underflow from casting `int256` to `uint256` if the  `int` type is negative value because `uint256()` does NOT have underflow/overflow protection.

## Proof of Concept
The issue is that the `answer` from the Chainlink price feed is casted with `uint256()` which has no overflow/underflow protection. Considering the fact that the `answer` variable is an `int` type, `answer` can be negative. When `answer` is negative, there will be an overflow.

Note that [4nalyzer report](https://github.com/code-423n4/2024-07-loopfi/blob/main/4naly3er-report.md#l-23-consider-using-openzeppelins-safecast-library-to-prevent-unexpected-overflows-when-downcasting) does not include this line of the issue I found below and only talks about casting down and not casting from `int` type to `uint` type.

```solidity
File: AuraVault.sol
   function _chainlinkSpot() private view returns (uint256 price) {
        bool isValid;
        try AggregatorV3Interface(BAL_CHAINLINK_FEED).latestRoundData() returns (
            uint80 /*roundId*/,
            int256 answer,
            uint256 /*startedAt*/,
            uint256 /*updatedAt*/,
            uint80 /*answeredInRound*/
        ) {
@>            price = wdiv(uint256(answer), BAL_CHAINLINK_DECIMALS);//FINDING: uint256(answer): negative `answer` will underflow.
            isValid = (price > 0);
        } catch {}

        if (!isValid) revert AuraVault__chainlinkSpot_invalidPrice();
    }
```



## Tools Used
Manual Review

## Recommended Mitigation Steps

Consider using the OZ's safecast library instead of using the uint256() function that has no overflow protection or validating that `answer` is not zero before casting.

```diff
function _chainlinkSpot() private view returns (uint256 price) {
        bool isValid;
        try AggregatorV3Interface(BAL_CHAINLINK_FEED).latestRoundData() returns (
            uint80 /*roundId*/,
            int256 answer,
            uint256 /*startedAt*/,
            uint256 /*updatedAt*/,
            uint80 /*answeredInRound*/
        ) {
++          require(answer > 0, "Negative answer");
            price = wdiv(uint256(answer), BAL_CHAINLINK_DECIMALS);
            isValid = (price > 0);
        } catch {}

        if (!isValid) revert AuraVault__chainlinkSpot_invalidPrice();
    }

```





## Assessed type

Under/Overflow