Juicebox contest - 0x1f8b's results

The decentralized fundraising and treasury protocol.

General Information

Platform: Code4rena

Start Date: 18/10/2022

Pot Size: $50,000 USDC

Total HM: 13

Participants: 67

Period: 5 days

Judge: Picodes

Total Solo HM: 7

Id: 172

League: ETH

Juicebox

Findings Distribution

Researcher Performance

Rank: 6/67

Findings: 3

Award: $2,739.30

QA:
grade-b
Gas:
grade-b

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: 0x1f8b

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
selected for report
M-01

Awards

2675.4584 USDC - $2,675.46

External Links

Lines of code

https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721Delegate.sol#L218

Vulnerability details

Impact

The initialize method of the JBTiered721Delegate contract has as a flag that the _store argument is different from address(0), however, it can be initialized by anyone with this value to allow the project to continue with its usual initialization, the attacker could have interfered and modified the corresponding values to carry out an attack.

Proof of Concept

Looking at the method below, we highlight in green the parts that need to be initialized to prevent a call to store=address(0) from failing.

  function initialize(
    uint256 _projectId,
    IJBDirectory _directory,
    string memory _name,
    string memory _symbol,
    IJBFundingCycleStore _fundingCycleStore,
    string memory _baseUri,
    IJBTokenUriResolver _tokenUriResolver,
    string memory _contractUri,
    JB721PricingParams memory _pricing,
    IJBTiered721DelegateStore _store,
    JBTiered721Flags memory _flags
  ) public override {
    // Make the original un-initializable.
    require(address(this) != codeOrigin);
    // Stop re-initialization.
    require(address(store) == address(0));

    // Initialize the sub class.
    JB721Delegate._initialize(_projectId, _directory, _name, _symbol);

    fundingCycleStore = _fundingCycleStore;
    store = _store;
    pricingCurrency = _pricing.currency;
    pricingDecimals = _pricing.decimals;
    prices = _pricing.prices;

    // Store the base URI if provided.
+   if (bytes(_baseUri).length != 0) _store.recordSetBaseUri(_baseUri);

    // Set the contract URI if provided.
+   if (bytes(_contractUri).length != 0) _store.recordSetContractUri(_contractUri);

    // Set the token URI resolver if provided.
+   if (_tokenUriResolver != IJBTokenUriResolver(address(0)))
      _store.recordSetTokenUriResolver(_tokenUriResolver);

    // Record adding the provided tiers.
+   if (_pricing.tiers.length > 0) _store.recordAddTiers(_pricing.tiers);

    // Set the flags if needed.
    if (
+     _flags.lockReservedTokenChanges ||
+     _flags.lockVotingUnitChanges ||
+     _flags.lockManualMintingChanges ||
+     _flags.pausable
    ) _store.recordFlags(_flags);

    // Transfer ownership to the initializer.
    _transferOwnership(msg.sender);
  }

So if the attacker initializes the contract as follows:

  • _baseUri = ""
  • _contractUri = ""
  • _tokenUriResolver = address(0)
  • _pricing.tiers = []
  • _flags = all false

The contract will be initialized and transfered the ownership to msg.sender.

After that, the owner can call didPay with the the fake data provided in JBTiered721Delegate.sol:221 and increase creditsOf of anyone JBTiered721Delegate.sol:587 without touching any store call.

  • The attacker can transfer the ownership to the contract, and the project will be able to initialize the contract again without notice.
  • Ensure that the store address is not empty.

#0 - trust1995

2022-10-23T22:49:46Z

initialialize can only be called once, and it is done directly from the deployer, so I believe it to be invalid.

#1 - drgorillamd

2022-10-24T11:27:37Z

Duplicate #174

#2 - c4-judge

2022-11-05T12:49:19Z

Picodes marked the issue as not a duplicate

#3 - Picodes

2022-11-05T12:59:37Z

I believe the finding to be valid if:

  • the attacker initialize the contract with _store == address(0) and the parameters as above so it does not revert in the normal process
  • the attacker calls initialize to transfer the ownership to himself and modify the storage so he can then call didPay
  • the attacker calls didPay to manipulate creditsOf
  • finally the attacker calls initialize to set _store to non zero and at this point it is like if nothing happened although creditsOf has been manipulated

#4 - c4-judge

2022-11-05T13:00:05Z

Picodes marked the issue as satisfactory

#5 - trust1995

2022-11-05T13:07:52Z

Agree that the finding is valid, I missed that it is possible that _store is never called in the initialization. Because there are multiple unlikely conditionals, I do think it fits Med severity more than high.

#6 - drgorillamd

2022-11-05T13:13:11Z

Hmm, this would require a spoof directory too (to bypass the isTerminalOf check) -> I'd mitigate with a check msg.value==data.value in the abstract delegate contract, ie if someone wants to do this, actually paying the credit is needed

@mejango @xBA5ED summoned

Def nice finding, ggwp!

#7 - Picodes

2022-11-05T14:04:28Z

I do agree than Med is more appropriate as it falls within centralization risks as ultimately only the deployer could exploit this

#8 - c4-judge

2022-11-05T14:04:34Z

Picodes marked the issue as change severity

Low

1. Outdated packages

Some used packages are out of date, it is good practice to use the latest version of these packages:

"@openzeppelin/contracts": "^4.6.0"

Reference:

2. Use string.concat instead of abi.encodePacked

Tthere is a discussion about removing abi.encodePacked from future versions of Solidity (ethereum/solidity#11593), so using abi.encode now will ensure compatibility in the future.

Affected source code:

3. Integer overflow by unsafe casting

Keep in mind that the version of solidity used, despite being greater than 0.8, does not prevent integer overflows during casting, it only does so in mathematical operations.

It is necessary to safely convert between the different numeric types.

Recommendation:

Use a safeCast from Open Zeppelin.

  function tierIdOfToken(uint256 _tokenId) public pure override returns (uint256) {
    // The tier ID is in the first 16 bits.
    return uint256(uint16(_tokenId));
  }

Affected source code:

4. Lack of checks supportsInterface

The EIP-165 standard helps detect that a smart contract implements the expected logic, prevents human error when configuring smart contract bindings, so it is recommended to check that the received argument is a contract and supports the expected interface.

Reference:

Affected source code:

5. Lack of initialize protections

The following contracts are updateable, and follow the update pattern, these contracts contain an initialize method to set the contract once, but anyone can call this method, this will spend project fuel if someone tries to initialize it before the project.

It is recommended to check the sender when initializing the contract.

Affected source code:

#0 - drgorillamd

2022-10-27T20:23:57Z

4 and 5 are pretty generic, feels like it is not for our codebase (EIP165 is implemented in the contract quoted and the delagate is not updatable, initialize is to palliate the lack of constructor, as the contract is cloned)

#1 - Picodes

2022-11-04T10:43:55Z

1, 2, 3, 4 are valid. For 2, the comment mentions abi.encode which is not the same as string.concat or abi.encodePacked

#2 - c4-judge

2022-11-04T10:43:59Z

Picodes marked the issue as grade-b

Awards

25.9629 USDC - $25.96

Labels

bug
G (Gas Optimization)
grade-b
G-02

External Links

Gas

1. Don't use the length of an array for loops condition

It's cheaper to store the length of the array inside a local variable and iterate over it.

Affected source code:

2. Avoid compound assignment operator in state variables

Using compound assignment operators for state variables (like State += X or State -= X ...) it's more expensive than using operator assignment (like State = State + X or State = State - X ...).

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
uint private _a;
function testShort() public {
_a += 1;
}
}

contract TesterB {
uint private _a;
function testLong() public {
_a = _a + 1;
}
}

Gas saving executing: 13 per entry

TesterA.testShort: 43507 TesterB.testLong: 43494

Affected source code:

Total gas saved: 13 * 1 = 13

3. Use string.concat instead of abi.encodePacked

abi.encodePacked has a cost difference with respect to string.concat depending on the types it uses. In the case of the reference shown below you can see that the type address is affected, so I have decided to do a test on it.

Also there is a discussion about removing abi.encodePacked from future versions of Solidity (ethereum/solidity#11593), so using abi.encode now will ensure compatibility in the future.

Affected source code:

4. Use calldata instead of memory

Some methods are declared as external but the arguments are defined as memory instead of as calldata.

By marking the function as external it is possible to use calldata in the arguments shown below and save significant gas.

Recommended change:

- function mintReservesFor(JBTiered721MintReservesForTiersData[] memory _mintReservesForTiersData)
+ function mintReservesFor(JBTiered721MintReservesForTiersData[] calldata _mintReservesForTiersData)
    external
    override
  {
  ...
  }

Affected source code:

5. ++i costs less gas compared to i++ or i += 1

++i costs less gas compared to i++ or i += 1 for unsigned integers, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.

i++ increments i and returns the initial value of i. Which means:

uint i = 1;
i++; // == 1 but i == 2

But ++i returns the actual incremented value:

uint i = 1;
++i; // == 2 and i == 2 too, so no need for a temporary variable

In the first case, the compiler has to create a temporary variable (when used) for returning 1 instead of 2 I suggest using ++i instead of i++ to increment the value of an uint variable. Same thing for --i and i--

Keep in mind that this change can only be made when we are not interested in the value returned by the operation, since the result is different, you only have to apply it when you only want to increase a counter.

Affected source code:

6. Use Custom Errors instead of Revert Strings to save Gas

Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)

Source Custom Errors in Solidity:

Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.

Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
function testRevert(bool path) public view {
 require(path, "test error");
}
}

contract TesterB {
error MyError(string msg);
function testError(bool path) public view {
 if(path) revert MyError("test error");
}
}

Gas saving executing: 9 per entry

TesterA.testRevert: 21611 TesterB.testError: 21602

Affected source code:

Total gas saved: 9 * 2 = 18

7. delete optimization

Use delete instead of set to default value (false or 0).

5 gas could be saved per entry in the following affected lines:

Affected source code:

Total gas saved: 5 * 4 = 20

8. There's no need to set default values for variables

If a variable is not set/initialized, the default value is assumed (0, false, 0x0 ... depending on the data type). You are simply wasting gas if you directly initialize it with its default value.

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
function testInit() public view returns (uint) { uint a = 0; return a; }
}

contract TesterB {
function testNoInit() public view returns (uint) { uint a; return a; }
}

Gas saving executing: 8 per entry

TesterA.testInit: 21392 TesterB.testNoInit: 21384

Affected source code:

Total gas saved: 8 * 1 = 9

9. Remove natspec complaints

Remove natspec complaints in order to save gas. This is not the best way to avoid some warnings.

Affected source code:

10. Use the unchecked keyword

When an underflow or overflow cannot occur, one might conserve gas by using the unchecked keyword to prevent unnecessary arithmetic underflow/overflow tests.

Affected source code:

#0 - drgorillamd

2022-10-27T20:29:01Z

Remove natspec complaints in order to save gas.

?

#1 - Picodes

2022-11-04T10:50:04Z

1, 5, 6, 9 are out of scope or invalid

For example 6 there is no string in the code's require

#2 - Picodes

2022-11-04T10:52:41Z

Overall this does really look like a tool output without further triage

#3 - c4-judge

2022-11-04T10:52:49Z

Picodes marked the issue as grade-b

#4 - c4-judge

2022-11-04T10:52:58Z

Picodes marked the issue as grade-c

#5 - c4-judge

2022-11-04T10:53:12Z

Picodes marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter