Halborn Logo

Proof of Creativity Protocol - Story


Prepared by:

Halborn Logo

HALBORN

Last Updated 12/13/2024

Date of Engagement by: October 28th, 2024 - December 4th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

15

Critical

2

High

2

Medium

3

Low

3

Informational

5


1. Introduction

Story engaged Halborn to conduct a security assessment on their smart contracts beginning on October 28th and ending on December 4th, 2024. The security assessment was scoped to the smart contracts provided to the Halborn team.

Commit hashes and further details can be found in the Scope section of this report.

2. Assessment Summary

The team at Halborn assigned a full-time security engineer to assess the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this assessment is to:

    • Ensure that smart contract functions operate as intended.

    • Identify potential security issues with the smart contracts.

In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Story team. The main ones are the following:

    • Pass the address of the user who intends to register the new Group IP account and transfer fees from this address.

    • Enforce uniqueness within the ipIds array provided to the claimReward function.

    • Implement strict access control in the mintLicenseTokens function to ensure that only the IP owner or authorized operators can mint license tokens for their IPs

    • Introduce a short time delay during which the Group IP owner can still add new IPs without the risk of being front-run by an attacker registering a derivative.

    • Add checks in the addIp function to ensure that the license terms attached to both the group and the IP Assets are not expired.

    • Modify the tagDerivativeIfParentInfringed function by adding a call to _updateActiveArbitrationPolicy before fetching the arbitration policy.

    • Enforce the maximum Royalty Tokens limit specified by the user during derivative registration.

3. Test Approach and Methodology

Halborn performed a combination of manual review of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the smart contract assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:

    • Research into the architecture, purpose, and use of the platform.

    • Smart contract manual code review and walkthrough to identify any logic issue.

    • Thorough assessment of safety and usage of critical Solidity variables and functions in scope that could lead to arithmetic related vulnerabilities.

    • Manual testing by custom scripts.

    • Graphing out functionality and contract logic/connectivity/functions (solgraph).

    • Static Analysis of security for scoped contract, and imported functions. (Slither).

    • Local or public testnet deployment (Foundry, Remix IDE).

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILIY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
(a) Repository: protocol-core-v1
(b) Assessed Commit ID: 5bcc4ae
(c) Items in scope:
  • contracts/pause/ProtocolPausableUpgradeable.sol
  • contracts/pause/ProtocolPauseAdmin.sol
  • contracts/IPAccountImpl.sol
↓ Expand ↓
Out-of-Scope: Third party dependencies and economic attacks.
Files and Repository
(a) Repository: story-geth
(b) Assessed Commit ID: ab8925d
(c) Items in scope:
  • core/vm/ipgraph.go
  • crypto/secp256r1/publickey.go
  • crypto/secp256r1/verifier.go
Out-of-Scope: Third party dependencies and economic attacks.
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

2

High

2

Medium

3

Low

3

Informational

5

Security analysisRisk levelRemediation Date
Denial of service caused by improper registration fee handling in group IP accountCriticalSolved - 11/15/2024
Malicious IP owner can drain rewards via duplicate entries in claimReward functionCriticalSolved - 11/12/2024
Griefing attack in group IP management via license token mintingHighSolved - 11/16/2024
Front-running exploit disrupts group IP management via derivative registrationHighSolved - 11/16/2024
No expiration check enables unauthorized access to reward poolsMediumSolved - 11/14/2024
Outdated arbitration policy used when tagging derivative IP assetsMediumSolved - 11/26/2024
Malicious IP owner can front-run derivative registration to drain user's royalty tokensMediumSolved - 11/20/2024
Uninitialized dynamic array in getGroupMembers function leads to runtime errorLowSolved - 12/11/2024
Usage of direct approve calls leads to several issuesLowSolved - 12/11/2024
Lack of proper normalization in secp256r1 curve verifier leads to signature malleabilityLowRisk Accepted - 12/12/2024
Use of custom errors instead of revert stringsInformationalSolved - 12/03/2024
Consider using named mappingsInformationalPartially Solved - 12/03/2024
Incorrect order of modifiers: nonReentrant should precede all other modifiersInformationalSolved - 12/03/2024
Potential license incompatibilitiesInformationalSolved - 12/11/2024
Unused custom errorsInformationalSolved - 12/04/2024

7. Findings & Tech Details

7.1 Denial of service caused by improper registration fee handling in group IP account

// Critical

Description

The registerGroup function defined in the GroupingModule is an entry point for all the flows related to the Group IP. It allows users to register a new Group IP account. It calls the registerGroup function on the GroupIPAssetRegistry contract for the actual accounting process:

function registerGroup(address groupPool) external nonReentrant whenNotPaused returns (address groupId) {
    // mint Group NFT
    uint256 groupNftId = GROUP_NFT.mintGroupNft(msg.sender, msg.sender);
    // register Group NFT
    groupId = GROUP_IP_ASSET_REGISTRY.registerGroup(address(GROUP_NFT), groupNftId, groupPool);
    emit IPGroupRegistered(groupId, groupPool);
}

This function internally calls the _register function, which is defined in the IPAssetRegistry, from which the GroupIPAssetRegistry abstract contract inherits:

function registerGroup(
    address groupNft,
    uint256 groupNftId,
    address rewardPool
) external onlyGroupingModule whenNotPaused returns (address groupId) {
    groupId = _register({ chainid: block.chainid, tokenContract: groupNft, tokenId: groupNftId });

    IIPAccount(payable(groupId)).setBool("GROUP_IPA", true);
    GroupIPAssetRegistryStorage storage $ = _getGroupIPAssetRegistryStorage();
    if (!$.whitelistedGroupRewardPools[rewardPool]) {
        revert Errors.GroupIPAssetRegistry__GroupRewardPoolNotRegistered(rewardPool);
    }
    _getGroupIPAssetRegistryStorage().rewardPools[groupId] = rewardPool;
}

The _register function checks if the registration fee was previously set by the owner and transfers the fixed amount from the msg.sender, which in this case is the GroupingModule contract:

function _register(uint256 chainid, address tokenContract, uint256 tokenId) internal override returns (address id) { 
    IPAssetRegistryStorage storage $ = _getIPAssetRegistryStorage();

    // Pay registration fee
    uint96 feeAmount = $.feeAmount;
    if (feeAmount > 0) {
        address feeToken = $.feeToken;
        address treasury = $.treasury;
        IERC20(feeToken).safeTransferFrom(msg.sender, treasury, uint256(feeAmount));
        emit IPRegistrationFeePaid(msg.sender, treasury, feeToken, feeAmount);
    }

    id = _registerIpAccount(chainid, tokenContract, tokenId);
    IIPAccount ipAccount = IIPAccount(payable(id));

    if (bytes(ipAccount.getString("NAME")).length != 0) {
        revert Errors.IPAssetRegistry__AlreadyRegistered();
    }

    (string memory name, string memory uri) = _getNameAndUri(chainid, tokenContract, tokenId);
    uint256 registrationDate = block.timestamp;
    ipAccount.setString("NAME", name);
    ipAccount.setString("URI", uri);
    ipAccount.setUint256("REGISTRATION_DATE", registrationDate);

    $.totalSupply++;

    emit IPRegistered(id, chainid, tokenContract, tokenId, name, uri, registrationDate);
}

The intended behavior is to transfer the fee from the user who is registering the Group IP account. However, as it does not grant the required approval, the entire process will revert, which breaks the core feature and causes a denial of service.

Proof of Concept

The core functionalities are broken due to improper registration fee handling:

function test_groupingFee() public { 
    //Admin set the registration fee
    vm.prank(u.admin);
    address treasury = address(0x123);
    ipAssetRegistry.setRegistrationFee(treasury, address(erc20), 1000);
    
    //User grants approval required for fee transfer
    vm.startPrank(alice);
        erc20.approve(address(ipAssetRegistry), type(uint256).max);

        //Transaction reverts due to improper approval handling
        vm.expectRevert();
        address groupId = groupingModule.registerGroup(address(rewardPool));
}


The result is the following:

BVSS
Recommendation

It is recommended to pass the address of the user who intends to register the new Group IP account and transfer fees from this address.

Remediation

SOLVED: The Story team solved the issue in the specified commit id. The address of the account that pays the registration fee is passed across the entire flow.

Remediation Hash
References

7.2 Malicious IP owner can drain rewards via duplicate entries in claimReward function

// Critical

Description

The RoyaltyModule is designed to manage the flow of revenue between parent and child IP Assets. Revenue can flow through the system when licenses are minted or when tips are sent directly to an IPA. In such cases, the revenue is intended to be distributed fairly among the IPAs within a group, ensuring that each member receives their rightful share.


However, a vulnerability exists in the reward distribution logic that allows a malicious IP owner to manipulate the system and redirect all rewards to a single IP account within a group.


When a user calls the claimReward function defined in the GroupingModule contract, they provide an array of ipIds representing the IP Assets that should receive rewards.

function claimReward(address groupId, address token, address[] calldata ipIds) external whenNotPaused {
    IGroupRewardPool pool = IGroupRewardPool(GROUP_IP_ASSET_REGISTRY.getGroupRewardPool(groupId));
    // Trigger group pool to distribute rewards to group members' vaults
    uint256[] memory rewards = pool.distributeRewards(groupId, token, ipIds);
    emit ClaimedReward(groupId, token, ipIds, rewards);
}

The distributeRewards function, from EvenSplitGroupPool contract, then calculates the available rewards for each IP Asset by calling the internal _getAvailableReward.

function distributeRewards(
    address groupId,
    address token,
    address[] calldata ipIds
) external whenNotPaused onlyGroupingModule returns (uint256[] memory rewards) {
    rewards = _getAvailableReward(groupId, token, ipIds);
    uint256 totalRewards = 0;
    for (uint256 i = 0; i < ipIds.length; i++) {
        totalRewards += rewards[i];
    }
    if (totalRewards == 0) return rewards;
    IERC20(token).approve(address(ROYALTY_MODULE), totalRewards);
    EvenSplitGroupPoolStorage storage $ = _getEvenSplitGroupPoolStorage();
    for (uint256 i = 0; i < ipIds.length; i++) {
        if (rewards[i] == 0) continue;
        // Calculate pending reward for each IP
        $.ipRewardDebt[groupId][token][ipIds[i]] += rewards[i];
        // Call royalty module to transfer reward to IP's vault as royalty
        ROYALTY_MODULE.payRoyaltyOnBehalf(ipIds[i], groupId, token, rewards[i]);
    }
}

In the _getAvailableReward function, the total accumulated pool balance is divided equally among all IP Assets in the group (totalAccumulatedPoolBalance / totalIps) to determine the rewardPerIP. The function then calculates the individual reward for each IP Asset by subtracting any previously recorded reward debt. However, the function does not enforce uniqueness within the ipIds array, allowing the same IP Asset to appear multiple times, before the reward debt is updated.

function _getAvailableReward(
    address groupId,
    address token,
    address[] memory ipIds
) internal view returns (uint256[] memory) {
    EvenSplitGroupPoolStorage storage $ = _getEvenSplitGroupPoolStorage();
    uint256 totalIps = $.totalMemberIps[groupId];
    if (totalIps == 0) return new uint256[](ipIds.length);

    uint256 totalAccumulatedPoolBalance = $.poolBalance[groupId][token];
    uint256[] memory rewards = new uint256[](ipIds.length);
    for (uint256 i = 0; i < ipIds.length; i++) {
        // Ignore if IP is not added to pool
        if (!_isIpAdded(groupId, ipIds[i])) {
            rewards[i] = 0;
            continue;
        }
        uint256 rewardPerIP = totalAccumulatedPoolBalance / totalIps;
        rewards[i] = rewardPerIP - $.ipRewardDebt[groupId][token][ipIds[i]];
    }
    return rewards;
}

Because of this oversight, a malicious user can pass the same ipId multiple times in the ipIds array when calling claimReward. Each occurrence of the ipId in the array results in the rewardPerIP being added to the IP Asset's reward debt multiple times. Consequently, the IP Asset accumulates a disproportionately large share of the total rewards, effectively taking the entire reward pool designated to be shared among other users.

Proof of Concept

The following test demonstrates how a malicious user can exploit this vulnerability:

function test_GroupingModule_duplicatedIPs() public {
    vm.warp(100);

    //Alice registers a new group IP
    vm.prank(alice);
    address groupId = groupingModule.registerGroup(address(rewardPool));

    uint256 termsId = pilTemplate.registerLicenseTerms(
        PILFlavors.commercialRemix({
            mintingFee: 0,
            commercialRevShare: 10_000_000,
            currencyToken: address(erc20),
            royaltyPolicy: address(royaltyPolicyLAP)
        })
    );

    //IP Owner 1 attaches license terms and mints license tokens
    vm.prank(ipOwner1);
    licensingModule.attachLicenseTerms(ipId1, address(pilTemplate), termsId);
    licensingModule.mintLicenseTokens(ipId1, address(pilTemplate), termsId, 1, address(this), "", 0);

    //IP Owner 2 attaches license terms and mints license tokens
    vm.prank(ipOwner2);
    licensingModule.attachLicenseTerms(ipId2, address(pilTemplate), termsId);
    licensingModule.mintLicenseTokens(ipId2, address(pilTemplate), termsId, 1, address(this), "", 0);

    //IP Owner 3 attaches license terms and mints license tokens
    vm.prank(ipOwner3);
    licensingModule.attachLicenseTerms(ipId3, address(pilTemplate), termsId);
    licensingModule.mintLicenseTokens(ipId3, address(pilTemplate), termsId, 1, address(this), "", 0);

    //Alice attaches license terms to the group IP
    vm.startPrank(alice);
    licensingModule.attachLicenseTerms(groupId, address(pilTemplate), termsId);
    vm.stopPrank();

    //Alice adds IPs to the group
    vm.startPrank(alice);
        address[] memory ipIds = new address[](3);
        ipIds[0] = ipId1;
        ipIds[1] = ipId2;
        ipIds[2] = ipId3;
        groupingModule.addIp(groupId, ipIds);
        assertEq(ipAssetRegistry.totalMembers(groupId), 3);
        assertEq(rewardPool.getTotalIps(groupId), 3);
    vm.stopPrank();

    //Register a derivative IP asset
    address[] memory parentIpIds = new address[](1);
    parentIpIds[0] = groupId;
    uint256[] memory licenseTermsIds = new uint256[](1);
    licenseTermsIds[0] = termsId;

    vm.prank(ipOwner4);
    licensingModule.registerDerivative(ipId4, parentIpIds, licenseTermsIds, address(pilTemplate), "", 0);

    //IP Owner 4 pays royalties that should be distributed amongs IP owners
    erc20.mint(ipOwner4, 150 ether);
    vm.startPrank(ipOwner4);
        erc20.approve(address(royaltyModule), 150 ether);
        royaltyModule.payRoyaltyOnBehalf(ipId4, ipOwner4, address(erc20), 150 ether);
    vm.stopPrank();

    //Transfer a portion of the royalties to the group's vault
    royaltyPolicyLAP.transferToVault(ipId4, groupId, address(erc20), 15 ether);
    vm.warp(vm.getBlockTimestamp() + 7 days);

    //Take a snapshot of the group's royalty vault
    uint256 snapshotId = IIpRoyaltyVault(royaltyModule.ipRoyaltyVaults(groupId)).snapshot();
    uint256[] memory snapshotIds = new uint256[](1);
    snapshotIds[0] = snapshotId;

    //Collect royalties for the group
    groupingModule.collectRoyalties(groupId, address(erc20), snapshotIds);

    //The same ipId1 is included three times
    address[] memory claimIpIds = new address[](3);
    claimIpIds[0] = ipId1;
    claimIpIds[1] = ipId1;
    claimIpIds[2] = ipId1;

    //Owner of IP1 claims rewards using the manipulated ipIds array
    vm.prank(ipOwner1);
        groupingModule.claimReward(groupId, address(erc20), claimIpIds);

    //Assert that ipId1 received the entire reward pool
    assertApproxEqAbs(erc20.balanceOf(royaltyModule.ipRoyaltyVaults(ipId1)), 15 ether, 100);

The result of the test is the following:

BVSS
Recommendation

It is recommended to enforce uniqueness within the ipIds array provided to the claimReward function. This can be achieved by implementing a check that ensures each IP Asset appears only once in the array before proceeding with the reward distribution.

Remediation

SOLVED: The Story team solved the issue in the specified commit id. The vulnerability was fixed by updating each IP Asset's reward debt immediately after calculating its reward within the loop, ensuring that if the same ipId appears multiple times in the ipIds array, subsequent calculations account for the updated debt and prevent extra rewards from being allocated to duplicates.

Remediation Hash
References

7.3 Griefing attack in group IP management via license token minting

// High

Description

The registerGroup function in the GroupingModule allows users to register a new Group IP account. It internally calls the registerGroup function on the GroupIPAssetRegistry contract for the actual accounting process:

function registerGroup(address groupPool) external nonReentrant whenNotPaused returns (address groupId) {
        // mint Group NFT
        uint256 groupNftId = GROUP_NFT.mintGroupNft(msg.sender, msg.sender);
        // register Group NFT
        groupId = GROUP_IP_ASSET_REGISTRY.registerGroup(address(GROUP_NFT), groupNftId, groupPool);
        emit IPGroupRegistered(groupId, groupPool);
}

After registering the Group IP, the group owner is intended to attache license terms to it using the attachLicenseTerms function in the LicensingModule:

function attachLicenseTerms(
    address ipId,
    address licenseTemplate,
    uint256 licenseTermsId
) external verifyPermission(ipId) {
    _verifyIpNotDisputed(ipId);
    LICENSE_REGISTRY.attachLicenseTermsToIp(ipId, licenseTemplate, licenseTermsId);
    emit LicenseTermsAttached(msg.sender, ipId, licenseTemplate, licenseTermsId);
}

The owner then can attempt to add individual IPs to their group using the addIp function in the GroupingModule. Before adding IPs, the _checkIfGroupMembersLocked function checks if the group is locked due to previous actions, such as having derivative IPs or minted license tokens. The getTotalTokensByLicensor function in the LICENSE_TOKEN contract returns the total number of license tokens minted under the specified IP:

function _checkIfGroupMembersLocked(address groupIpId) internal view {
    if (LICENSE_REGISTRY.hasDerivativeIps(groupIpId)) {
        revert Errors.GroupingModule__GroupFrozenDueToHasDerivativeIps(groupIpId);
    }
    if (LICENSE_TOKEN.getTotalTokensByLicensor(groupIpId) > 0) {
        revert Errors.GroupingModule__GroupFrozenDueToAlreadyMintLicenseTokens(groupIpId);
    }
}

Because there is no permission check to verify that the caller is authorized to mint license tokens for licensorIpId, any user can front-run the owner transaction, invoking this function. An attacker can exploit the lack of access control in the mintLicenseTokens function to mint license tokens for Alice's Group IP without authorization, as presented in the Proof of Concept.


An attacker can prevent the legitimate owner from managing their Group IP by unauthorized minting of license tokens, causing a denial-of-service condition. This disrupts the core functionality of the Group IP management system and can lead to operational and financial losses for the rightful owner.


Note that the similar scenario applies also to removing IP from the Group IP by using the removeIp function, which also uses the internal _checkIfGroupMembersLocked.

Proof of Concept

When Alice calls addIp function, the _checkIfGroupMembersLocked function checks if any license tokens have been minted under groupId. Since the attacker has front-run Alice's transaction and minted a license token, getTotalTokensByLicensor(groupId) returns a value greater than zero, causing the function to revert and preventing Alice from adding IPs to her group:

    function test_GroupingModule_revertOnFrontRunning() public {
        vm.warp(100);

        //Alice registers a new group IP
        vm.prank(alice);
        address groupId = groupingModule.registerGroup(address(rewardPool));

        uint256 termsId = pilTemplate.registerLicenseTerms(
            PILFlavors.commercialRemix({
                mintingFee: 0,
                commercialRevShare: 10_000_000,
                currencyToken: address(erc20),
                royaltyPolicy: address(royaltyPolicyLAP)
            })
        );

        vm.prank(ipOwner1);
            licensingModule.attachLicenseTerms(ipId1, address(pilTemplate), termsId);
            licensingModule.mintLicenseTokens(ipId1, address(pilTemplate), termsId, 1, address(this), "", 0);
        vm.prank(ipOwner2);
            licensingModule.attachLicenseTerms(ipId2, address(pilTemplate), termsId);
            licensingModule.mintLicenseTokens(ipId2, address(pilTemplate), termsId, 1, address(this), "", 0);
        
        //Alice attaches the license term 
        vm.prank(alice);
            licensingModule.attachLicenseTerms(groupId, address(pilTemplate), termsId);
        
        // Attacker front-runs Alice's transaction, minting a new license token for the group IP
        vm.prank(attacker);
            licensingModule.mintLicenseTokens(groupId, address(pilTemplate), termsId, 1, address(attacker), "", 0);

        // Alice tries to add the intended IPs to the group IP
        vm.startPrank(alice);
            address[] memory ipIds = new address[](2);
            ipIds[0] = ipId1;
            ipIds[1] = ipId2;

            //Transaction reverts 
            vm.expectRevert();
            groupingModule.addIp(groupId, ipIds);
        vm.stopPrank();
    }

The result of the test is the following:

BVSS
Recommendation

It is recommended to implement strict access control in the mintLicenseTokens function to ensure that only the IP owner or authorized operators can mint license tokens for their IPs. This can be achieved by adding a verifyPermission modifier to the function.

Remediation

SOLVED: The Story team solved the issue in the specified commit id. The disabled field has been added to the LicensingConfig struct. When set to true, both the mintLicenseTokens function and _executeLicensingHookAndPayMintingFee (executed during the derivative registration process) will revert. This effectively provides the Group IP owner with the ability to manage their own group and revoke the disabled status when it is ready to operate.

Remediation Hash
References

7.4 Front-running exploit disrupts group IP management via derivative registration

// High

Description

The registerGroup function in the GroupingModule allows users to register a new Group IP account. After registering the Group IP, the group owner is intended to attach license terms to it using the attachLicenseTerms function in the LicensingModule. The owner then can attempt to add individual IPs to their group using the addIp function in the GroupingModule. Before adding IPs, the _checkIfGroupMembersLocked function checks if the group is locked due to previous actions, such as having derivative IPs or minted license tokens:

function _checkIfGroupMembersLocked(address groupIpId) internal view {
    if (LICENSE_REGISTRY.hasDerivativeIps(groupIpId)) {
        revert Errors.GroupingModule__GroupFrozenDueToHasDerivativeIps(groupIpId);
    }
    if (LICENSE_TOKEN.getTotalTokensByLicensor(groupIpId) > 0) {
        revert Errors.GroupingModule__GroupFrozenDueToAlreadyMintLicenseTokens(groupIpId);
    }
}

The hasDerivativeIps function in the LICENSE_REGISTRY checks if there are any derivative IPs registered under the specified IP.

function hasDerivativeIps(address parentIpId) external view returns (bool) {
    return _getLicenseRegistryStorage().childIps[parentIpId].length() > 0;
}

However, any user can front-run the owner's transaction intended to add the specified IP assets to the group IP, by invoking the registerDerivative function. By registering an unauthorized derivative IP, the attacker effectively locks the Group IP, preventing the legitimate owner from managing it. This creates a denial-of-service condition, disrupting the core functionality of the Group IP management system.


Note that the similar scenario applies also to removing IP from the Group IP by using the removeIp function, which also uses the internal _checkIfGroupMembersLocked.

Proof of Concept

When Alice calls the addIp function, the _checkIfGroupMembersLocked function checks if any derivative IPs have been registered under groupId. Since the attacker has front-run Alice's transaction and registered a derivative IP, hasDerivativeIps(groupId) returns true, causing the function to revert and preventing Alice from adding IPs to her group:

function test_GroupingModule_revertOnFrontRunning_registerDerivative() public {
    //Alice registers a new group IP
    vm.prank(alice);
        address groupId = groupingModule.registerGroup(address(rewardPool));
        
    uint256 termsId = pilTemplate.registerLicenseTerms(
        PILFlavors.commercialRemix({
            mintingFee: 0,
            commercialRevShare: 10_000_000,
            currencyToken: address(erc20),
            royaltyPolicy: address(royaltyPolicyLAP)
        })
    );

    vm.prank(ipOwner1);
        licensingModule.attachLicenseTerms(ipId1, address(pilTemplate), termsId);
        licensingModule.mintLicenseTokens(ipId1, address(pilTemplate), termsId, 1, address(this), "", 0);
    vm.prank(ipOwner2);
        licensingModule.attachLicenseTerms(ipId2, address(pilTemplate), termsId);
        licensingModule.mintLicenseTokens(ipId2, address(pilTemplate), termsId, 1, address(this), "", 0);

    //Alice attaches the license term 
    vm.prank(alice);
        licensingModule.attachLicenseTerms(groupId, address(pilTemplate), termsId);

    //Attacker front-runs Alice's transaction, registering a new derivative for the group IP
    vm.startPrank(ipOwner3);
        address[] memory parentIpIds = new address[](1);
        uint256[] memory licenseTermsIds = new uint256[](1);
        parentIpIds[0] = groupId;
        licenseTermsIds[0] = termsId;
        licensingModule.registerDerivative(ipId3, parentIpIds, licenseTermsIds, address(pilTemplate), "", 0);
    vm.stopPrank();

    // Alice tries to add the intended IPs to the group IP
    vm.startPrank(alice);
        address[] memory ipIds = new address[](2);
        ipIds[0] = ipId1;
        ipIds[1] = ipId2;
            
        //Transaction reverts 
        vm.expectRevert();
        groupingModule.addIp(groupId, ipIds);
    vm.stopPrank();
}

The result of the test is the following:

BVSS
Recommendation

It is recommended to consider introducing a short time delay during which the Group IP owner can still add new IPs without the risk of being front-run by an attacker registering a derivative. This grace period would help mitigate the front-running vector, allowing the owner to add the intended IP assets even if the derivative was registered beforehand.

Remediation

SOLVED: The Story team solved the issue in the specified commit id. The disabled field has been added to the LicensingConfig struct. When set to true, both the mintLicenseTokens function and _executeLicensingHookAndPayMintingFee (executed during the derivative registration process) will revert. This effectively provides the Group IP owner with the ability to manage their own group and revoke the disabled status when it is ready to operate.

Remediation Hash
References

7.5 No expiration check enables unauthorized access to reward pools

// Medium

Description

The addIp function, defined in the GroupingModule contract, is responsible for adding IP Assets to a group. It performs several checks to ensure that both the group and the IP Assets meet specific criteria before the addition is allowed:

function addIp(address groupIpId, address[] calldata ipIds) external whenNotPaused verifyPermission(groupIpId) {
    _checkIfGroupMembersLocked(groupIpId);
    // the group IP must has license terms and minting fee is 0 to be able to add IP to group
    if (LICENSE_REGISTRY.getAttachedLicenseTermsCount(groupIpId) == 0) {
    revert Errors.GroupingModule__GroupIPHasNoLicenseTerms(groupIpId);
    }
    (address groupLicenseTemplate, uint256 groupLicenseTermsId) = LICENSE_REGISTRY.getAttachedLicenseTerms(
        groupIpId,
        0
    );
    PILTerms memory groupLicenseTerms = IPILicenseTemplate(groupLicenseTemplate).getLicenseTerms(
        groupLicenseTermsId
    );
    if (groupLicenseTerms.defaultMintingFee != 0) {
        revert Errors.GroupingModule__GroupIPHasMintingFee(groupIpId, groupLicenseTemplate, groupLicenseTermsId);
    }

    GROUP_IP_ASSET_REGISTRY.addGroupMember(groupIpId, ipIds); 
    IGroupRewardPool pool = IGroupRewardPool(GROUP_IP_ASSET_REGISTRY.getGroupRewardPool(groupIpId));
    for (uint256 i = 0; i < ipIds.length; i++) {
        if (GROUP_IP_ASSET_REGISTRY.isRegisteredGroup(ipIds[i])) {
            revert Errors.GroupingModule__CannotAddGroupToGroup(groupIpId, ipIds[i]);
        }
        // check if the IP has the same license terms as the group
        if (!LICENSE_REGISTRY.hasIpAttachedLicenseTerms(ipIds[i], groupLicenseTemplate, groupLicenseTermsId)) {
            revert Errors.GroupingModule__IpHasNoGroupLicenseTerms(
                ipIds[i],
                groupLicenseTemplate,
                groupLicenseTermsId
            );
        }
        pool.addIp(groupIpId, ipIds[i]);
    }

    emit AddedIpToGroup(groupIpId, ipIds);
}

However, it does not check whether the license terms attached to the IP Assets or the group have expired. As a result, IP Assets with expired license terms can be added to a group, potentially bypassing licensing rules and unfairly participate in the reward distribution.

Proof of Concept

The following test demonstrates how an IP Asset with expired license terms can be added to a group:

function test_registerExpired() public {
    vm.warp(block.timestamp + 10 days);

    //Alice registers a new group IP
    vm.prank(alice);
        address groupId = groupingModule.registerGroup(address(rewardPool));

    //Create outdated term 
    PILTerms memory terms = PILFlavors.commercialRemix({
        mintingFee: 0,
        commercialRevShare: 10,
        currencyToken: address(erc20),
        royaltyPolicy: address(royaltyPolicyLAP)
    });
    //Set expiration to 5 days ago
    terms.expiration = block.timestamp - 5 days;

    //Register the expired license terms
    uint256 termsId = pilTemplate.registerLicenseTerms(terms);

    // IP Owner 1 has attached the expired license terms to their IP Asset
    vm.prank(ipOwner1);
        licensingModule.attachLicenseTerms(ipId1, address(pilTemplate), termsId);
        licensingModule.mintLicenseTokens(ipId1, address(pilTemplate), termsId, 1, address(this), "", 0);

    //Alice attaches the same expired license terms to the group IP
    vm.startPrank(alice);
        licensingModule.attachLicenseTerms(groupId, address(pilTemplate), termsId);
        vm.stopPrank();
        
    //Alice adds the IP Asset with expired license terms to the group
    vm.startPrank(alice);
        address[] memory ipIds = new address[](1);
        ipIds[0] = ipId1;
        groupingModule.addIp(groupId, ipIds);
    vm.stopPrank();
}

BVSS
Recommendation

It is recommended to add checks in the addIp function to ensure that the license terms attached to both the group and the IP Assets are not expired.

Remediation

SOLVED: The Story team solved the issue in the specified commit id. The check to ensure whether the license term has expired was added.

Remediation Hash
References

7.6 Outdated arbitration policy used when tagging derivative IP assets

// Medium

Description

The purpose of the DisputeModule contract is to allow users to raise and resolve disputes related to IP assets. Disputes are handled through arbitration policies, which define the rules and procedures for dispute resolution. The arbitration policies can be set by IP owners and are subject to a cooldown period before becoming active. The IP owners can set a new arbitration policy for their IP assets by calling the setArbitrationPolicy function.

function setArbitrationPolicy(address ipId, address nextArbitrationPolicy) external verifyPermission(ipId) {
    DisputeModuleStorage storage $ = _getDisputeModuleStorage();
    if (!$.isWhitelistedArbitrationPolicy[nextArbitrationPolicy])
        revert Errors.DisputeModule__NotWhitelistedArbitrationPolicy();

    $.nextArbitrationPolicies[ipId] = nextArbitrationPolicy;

    uint256 nextArbitrationUpdateTimestamp = block.timestamp + $.arbitrationPolicyCooldown;
    $.nextArbitrationUpdateTimestamps[ipId] = nextArbitrationUpdateTimestamp;

    emit ArbitrationPolicySet(ipId, nextArbitrationPolicy, nextArbitrationUpdateTimestamp);
}

Users can raise disputes against an IP asset by calling raiseDispute function. This function updates the active arbitration policy for the IP asset before proceeding. It checks if the cooldown period has passed and updates the active arbitration policy accordingly. Then the dispute is processed according to the active arbitration policy.

function raiseDispute(
    address targetIpId,
    bytes32 disputeEvidenceHash,
    bytes32 targetTag,
    bytes calldata data
) external nonReentrant whenNotPaused returns (uint256) {
    if (!IP_ASSET_REGISTRY.isRegistered(targetIpId)) revert Errors.DisputeModule__NotRegisteredIpId();
    DisputeModuleStorage storage $ = _getDisputeModuleStorage();
    if (!$.isWhitelistedDisputeTag[targetTag]) revert Errors.DisputeModule__NotWhitelistedDisputeTag();
    if (disputeEvidenceHash == bytes32(0)) revert Errors.DisputeModule__ZeroDisputeEvidenceHash();

    address arbitrationPolicy = _updateActiveArbitrationPolicy(targetIpId);
    uint256 disputeId = ++$.disputeCounter;
    uint256 disputeTimestamp = block.timestamp;

    $.disputes[disputeId] = Dispute({
        targetIpId: targetIpId,
        disputeInitiator: msg.sender,
        disputeTimestamp: disputeTimestamp,
        arbitrationPolicy: arbitrationPolicy,
        disputeEvidenceHash: disputeEvidenceHash,
        targetTag: targetTag,
        currentTag: IN_DISPUTE,
        parentDisputeId: 0
    });

    IArbitrationPolicy(arbitrationPolicy).onRaiseDispute(msg.sender, data);

    emit DisputeRaised(
        disputeId,
        targetIpId,
        msg.sender,
        disputeTimestamp,
        arbitrationPolicy,
        disputeEvidenceHash,
        targetTag,
        data
    );

    return disputeId;
}

Also, if a parent IP asset has been tagged with an infringement (e.g., plagiarism), derivative IP assets can be automatically tagged by calling the tagDerivativeIfParentInfringed function. It tags the derivative IP with the same infringement tag as the parent.

function tagDerivativeIfParentInfringed(
    address parentIpId,
    address derivativeIpId,
    uint256 parentDisputeId
) external whenNotPaused {
    DisputeModuleStorage storage $ = _getDisputeModuleStorage();

    Dispute memory parentDispute = $.disputes[parentDisputeId];
    if (parentDispute.targetIpId != parentIpId) revert Errors.DisputeModule__ParentIpIdMismatch();

    // a dispute current tag prior to being resolved can be in 3 states - IN_DISPUTE, 0, or a tag (ie. "PLAGIARISM)
    // by restricting IN_DISPUTE and 0 - it is ensire the parent has been tagged before resolving dispute
    if (parentDispute.currentTag == IN_DISPUTE || parentDispute.currentTag == bytes32(0))
        revert Errors.DisputeModule__ParentNotTagged();

    if (!LICENSE_REGISTRY.isParentIp(parentIpId, derivativeIpId)) revert Errors.DisputeModule__NotDerivative();

    address arbitrationPolicy = $.arbitrationPolicies[derivativeIpId];
    if (!$.isWhitelistedArbitrationPolicy[arbitrationPolicy]) arbitrationPolicy = $.baseArbitrationPolicy;

    uint256 disputeId = ++$.disputeCounter;
    uint256 disputeTimestamp = block.timestamp;

    $.disputes[disputeId] = Dispute({
        targetIpId: derivativeIpId,
        disputeInitiator: msg.sender,
        disputeTimestamp: disputeTimestamp,
        arbitrationPolicy: arbitrationPolicy,
        disputeEvidenceHash: "",
        targetTag: parentDispute.currentTag,
        currentTag: parentDispute.currentTag,
        parentDisputeId: parentDisputeId
    });

    $.successfulDisputesPerIp[derivativeIpId]++;

    emit DerivativeTaggedOnParentInfringement(
        parentIpId,
        derivativeIpId,
        parentDisputeId,
        parentDispute.currentTag,
        disputeTimestamp
    );
}

However, in the current implementation of tagDerivativeIfParentInfringed, the function fetches the arbitration policy for the derivativeIpId directly from the storage ($.arbitrationPolicies[derivativeIpId]) without updating it beforehand. The function does not call _updateActiveArbitrationPolicy, so it does not check if the cooldown period has passed and whether the arbitration policy should be updated. As a result, if the arbitration policy was changed and the cooldown period has passed, the tagDerivativeIfParentInfringed will use an outdated arbitration policy, which is not intended by the IP owner. This will lead to disputes being handled incorrectly, violating the expectations of the IP owner.

BVSS
Recommendation

It is recommended to modify the tagDerivativeIfParentInfringed function by adding a call to _updateActiveArbitrationPolicy before fetching the arbitration policy. This ensures that the arbitration policy is current and reflects any changes that may occur after the cooldown period.

Remediation

SOLVED: The Story team solved the issue in the specified PR. Instead of calling _updateActiveArbitrationPolicy, the parent dispute was used. According to the team:

This is because - in our roadmap - we are considering replacing the need for tagDerivativeIfParentInfringed() with a precompile that checks if any ancestor is tagged. The closest behaviour we can get to - without the precompile - would be to propagate the dispute to the derivative with the same arbitration policy it had in the parent.

Remediation Hash
References

7.7 Malicious IP owner can front-run derivative registration to drain user's royalty tokens

// Medium

Description

The RoyaltyModule is designed to manage the distribution of royalty tokens among IP owners and royalty policies when a derivative IP is linked to one or more parent IPs. External Royalty Policies enable IP owners to define custom royalty distribution rules by implementing the IExternalRoyaltyPolicy interface.

interface IExternalRoyaltyPolicy {
    /// @notice Returns the amount of royalty tokens required to link a child to a given IP asset
    /// @param ipId The ipId of the IP asset
    /// @param licensePercent The percentage of the license
    /// @return The amount of royalty tokens required to link a child to a given IP asset
    function getPolicyRtsRequiredToLink(address ipId, uint32 licensePercent) external view returns (uint32);
}

IP Owners can deploy and register an External Royalty Policy contract that implements IExternalRoyaltyPolicy and mint license tokens specifying the intended policy. Other users can then create derivative IPs by linking to parent IPs using the specified licenses and royalty policies. During the linking process, the RoyaltyModule calculates and distributes royalty tokens, associated with the user's IPRoyaltyVault, to royalty policies and IP owners based on the outputs of getPolicyRtsRequiredToLink.


The vulnerability lies in the _distributeRoyaltyTokensToPolicies function within the RoyaltyModule contract. This function relies on the getPolicyRtsRequiredToLink method from External Royalty Policies to determine the number of RTs to distribute. However, there is no validation on the value returned by this method.


function _distributeRoyaltyTokensToPolicies(
    address ipId,
    address[] calldata parentIpIds,
    address[] calldata licenseRoyaltyPolicies,
    uint32[] calldata licensesPercent,
    address ipRoyaltyVault
) internal {
    RoyaltyModuleStorage storage $ = _getRoyaltyModuleStorage();

    uint32 totalRtsRequiredToLink;
    // this loop is limited to maxParents
    for (uint256 i = 0; i < parentIpIds.length; i++) {
        if (parentIpIds[i] == address(0)) revert Errors.RoyaltyModule__ZeroParentIpId();
        if (licenseRoyaltyPolicies[i] == address(0)) revert Errors.RoyaltyModule__ZeroRoyaltyPolicy();
        _addToAccumulatedRoyaltyPolicies(parentIpIds[i], licenseRoyaltyPolicies[i]);
        address[] memory accParentRoyaltyPolicies = $.accumulatedRoyaltyPolicies[parentIpIds[i]].values();

        // this loop is limited to accumulatedRoyaltyPoliciesLimit
        for (uint256 j = 0; j < accParentRoyaltyPolicies.length; j++) {
            // add the parent ancestor royalty policies to the child
            _addToAccumulatedRoyaltyPolicies(ipId, accParentRoyaltyPolicies[j]);
            // transfer the required royalty tokens to each policy
            uint32 licensePercent = accParentRoyaltyPolicies[j] == licenseRoyaltyPolicies[i]
                ? licensesPercent[i]
                : 0;
            uint32 rtsRequiredToLink = IRoyaltyPolicy(accParentRoyaltyPolicies[j]).getPolicyRtsRequiredToLink(
                parentIpIds[i],
                licensePercent
            );
            totalRtsRequiredToLink += rtsRequiredToLink;
            if (totalRtsRequiredToLink > MAX_PERCENT) revert Errors.RoyaltyModule__AboveMaxPercent();
            IERC20(ipRoyaltyVault).safeTransfer(accParentRoyaltyPolicies[j], rtsRequiredToLink);
        }
    }

    if ($.accumulatedRoyaltyPolicies[ipId].length() > $.maxAccumulatedRoyaltyPolicies)
        revert Errors.RoyaltyModule__AboveAccumulatedRoyaltyPoliciesLimit();

    // sends remaining royalty tokens to the ipId address or
    // in the case the ipId is a group then send to the group reward pool
    address receiver = IP_ASSET_REGISTRY.isRegisteredGroup(ipId)
        ? IP_ASSET_REGISTRY.getGroupRewardPool(ipId)
        : ipId;
    IERC20(ipRoyaltyVault).safeTransfer(receiver, MAX_PERCENT - totalRtsRequiredToLink);
}

A malicious IP owner can observe a pending derivative registration transaction and front-run it by increasing the RT requirements in their royalty policy. This forces the user to transfer more Royalty Tokens than anticipated, potentially draining the user's RTs.

BVSS
Recommendation

It is recommended to enforce the maximum Royalty Tokens limit specified by the user during derivative registration. If the required RTs exceed the maximum limit, the transaction should revert, preventing the user from overpaying.

Remediation

SOLVED: The Story team solved the issue in the specified PR. A new variable, maxRts, defined by the user when registering a derivative, was implemented to ensure that the royalty tokens distributed to royalty policies do not exceed the specified limit.

Remediation Hash
References

7.8 Uninitialized dynamic array in getGroupMembers function leads to runtime error

// Low

Description

The getGroupMembers function, implemented in the GroupIPAssetRegistry contract, is intended to retrieve a subset of IP addresses that are members of a specific group:

function getGroupMembers(
    address groupId,
    uint256 startIndex,
    uint256 size
) external view returns (address[] memory results) {
    EnumerableSet.AddressSet storage allMemberIpIds = _getGroupIPAssetRegistryStorage().groups[groupId];
    uint256 totalSize = allMemberIpIds.length();
    if (startIndex >= totalSize) return results;

    uint256 resultsSize = (startIndex + size) > totalSize ? size - ((startIndex + size) - totalSize) : size;
    for (uint256 i = 0; i < resultsSize; i++) {
        results[i] = allMemberIpIds.at(startIndex + i);
    }
    return results;
}

The function declares a dynamic array results to hold the returned addresses but does not initialize it with a specific length before accessing its elements. In Solidity, when working with dynamic arrays in memory, it must be allocated with a defined length before accessing or assigning values to their elements. Attempting to assign values to results[i] without initializing results leads to a runtime error (Panic: Index out of bounds), causing the transaction to revert and making the getGroupMembers function broken.

Proof of Concept

The test will fail because the contract reverts when trying to assign a value to results[0] without initializing results.

function test_GroupingModule_getGroupMembers() public {
    vm.warp(100);

    //Alice registers a new group IP
    vm.prank(alice);
    address groupId = groupingModule.registerGroup(address(rewardPool));

    uint256 termsId = pilTemplate.registerLicenseTerms(
        PILFlavors.commercialRemix({
            mintingFee: 0,
            commercialRevShare: 10_000_000,
            currencyToken: address(erc20),
            royaltyPolicy: address(royaltyPolicyLAP)
        })
    );

    //IP owners attach license terms and mint license tokens
    vm.prank(ipOwner1);
        licensingModule.attachLicenseTerms(ipId1, address(pilTemplate), termsId);
        licensingModule.mintLicenseTokens(ipId1, address(pilTemplate), termsId, 1, address(this), "", 0);
    vm.prank(ipOwner2);
        licensingModule.attachLicenseTerms(ipId2, address(pilTemplate), termsId);
        licensingModule.mintLicenseTokens(ipId2, address(pilTemplate), termsId, 1, address(this), "", 0);
        
    //Alice attaches the license term 
    vm.prank(alice);
        licensingModule.attachLicenseTerms(groupId, address(pilTemplate), termsId);

    //Alice adds the intended IPs to the group IP
    vm.startPrank(alice);
        address[] memory ipIds = new address[](2);
        ipIds[0] = ipId1;
        ipIds[1] = ipId2;
        groupingModule.addIp(groupId, ipIds);
    vm.stopPrank();

    //Expect a revert due to uninitialized results array in getGroupMembers function
    vm.expectRevert();
    ipAssetRegistry.getGroupMembers(groupId, 0, 1); 
}

BVSS
Recommendation

It is recommended to initialize the results array properly with the correct length before accessing or assigning values to it. This can be achieved by allocating memory for results: 

results = new address[](resultsSize);
Remediation

SOLVED: The Story team solved the issue in the specified PR. The getGroupMembers function was updated to initialize the results array dynamically based on the size of the group members.

Remediation Hash
References

7.9 Usage of direct approve calls leads to several issues

// Low

Description

The distributeRewards function in the EvenSplitGroupPool contract calls the approve method on tokens intended for reward distribution:

function distributeRewards( 
    address groupId,
    address token,
     address[] calldata ipIds
) external whenNotPaused onlyGroupingModule returns (uint256[] memory rewards) {
    rewards = _getAvailableReward(groupId, token, ipIds);
    uint256 totalRewards = 0;
    for (uint256 i = 0; i < ipIds.length; i++) {
        totalRewards += rewards[i];
    }
    if (totalRewards == 0) return rewards;
    IERC20(token).approve(address(ROYALTY_MODULE), totalRewards); 
    EvenSplitGroupPoolStorage storage $ = _getEvenSplitGroupPoolStorage();
    for (uint256 i = 0; i < ipIds.length; i++) {
        if (rewards[i] == 0) continue;
        // calculate pending reward for each IP
        $.ipRewardDebt[groupId][token][ipIds[i]] += rewards[i];
        // call royalty module to transfer reward to IP's vault as royalty
        ROYALTY_MODULE.payRoyaltyOnBehalf(ipIds[i], groupId, token, rewards[i]);
    }
}

However, certain tokens like USDT do not strictly adhere to the ERC-20 standard, leading to potential failures in the contract's operation. Specifically:

  • Approval Race Conditions: Some tokens, to protect against race conditions, do not allow approving an amount M > 0 when an existing amount N > 0 is already approved. This restriction can cause the approve call to revert if not handled correctly.

  • Non-Standard Return Values: The approve call does not return a boolean in some tokens, which can cause compatibility issues with contracts expecting a standard ERC-20 boolean response.

  • Approval Value Limits: The function reverts if the approval value exceeds uint96, which can happen if large token amounts are involved.


These issues can cause the distributeRewards function to revert, preventing the distribution of rewards when using affected tokens. This not only disrupts the reward mechanism but also affects user trust and the protocol's functionality, especially if popular tokens like USDT are intended for use.

BVSS
Recommendation

It is recommended to use SafeERC20's forceApprove method instead of direct approve, to ensure compatibility with all ERC20 tokens, including those with non-standard implementations, This will provide a more robust solution for managing token allowances across various ERC20 token contracts.

Remediation

SOLVED: The Story team solved the issue in the specified PR. The distributeRewards function was updated to use SafeERC20's forceApprove() method.

Remediation Hash
References

7.10 Lack of proper normalization in secp256r1 curve verifier leads to signature malleability

// Low

Description

In ECDSA, a signature is composed of two values, r and s, derived from the message hash and the signer's private key. The malleability issue stems from the fact that for any valid signature (r, s), the signature (r, n - s) is also valid, where n is the order of the elliptic curve group.


In Go, the crypto/ecdsa package provides functions for ECDSA signature generation and verification. The ecdsa.Verify function checks the validity of a signature by recalculating the signature values using the public key and message hash:

func Verify(hash []byte, r, s, x, y *big.Int) bool {
	// Create the public key format
	publicKey := newPublicKey(x, y)

	// Check if they are invalid public key coordinates
	if publicKey == nil {
		return false
	}

	// Verify the signature with the public key,
	// then return true if it's valid, false otherwise
	return ecdsa.Verify(publicKey, hash, r, s)
}

However, the ecdsa.Verify function verifies signatures without normalizing the s value. Specifically, it accepts signatures where s is in the range (0,n) without normalizing s to ensure s ≤ n/2.


As a result, both s and n - s are considered valid, making the signature verification process susceptible to malleability attacks.

Proof of Concept

The Verify function is susceptible to signature malleability, showing that both the original signature (r, s) and the altered signature (r, n - s) are valid for the same message and public key:

package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"fmt"
	"math/big"
)

// Generates appropriate public key format from given coordinates
func newPublicKey(x, y *big.Int) *ecdsa.PublicKey {
	//Check if the given coordinates are valid
	if x == nil || y == nil || !elliptic.P256().IsOnCurve(x, y) {
		return nil
	}

	return &ecdsa.PublicKey{
		Curve: elliptic.P256(),
		X:     x,
		Y:     y,
	}
}

// Verify verifies the given signature (r, s) for the given hash and public key (x, y).
// It returns true if the signature is valid, false otherwise.
func Verify(hash []byte, r, s, x, y *big.Int) bool {
	//Create the public key format
	publicKey := newPublicKey(x, y)

	//Check if they are invalid public key coordinates
	if publicKey == nil {
		return false
	}

	//Verify the signature with the public key,
	return ecdsa.Verify(publicKey, hash, r, s)
}

func main() {
	//Generate a new private key using P-256 curve
	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		fmt.Println("Error generating private key:", err)
		return
	}

	//Get public key components
	x := privateKey.PublicKey.X
	y := privateKey.PublicKey.Y

	//Message to be signed
	message := []byte("xyz")

	//Hash the message using SHA-256
	hash := sha256.Sum256(message)

	//Sign the hash using the private key
	r, s, err := ecdsa.Sign(rand.Reader, privateKey, hash[:])
	if err != nil {
		fmt.Println("Error signing message:", err)
		return
	}

	//Verify the original signature
	validOriginal := Verify(hash[:], r, s, x, y)
	fmt.Printf("Original signature valid: %v\n", validOriginal)

	//Get the curve order n
	nCurve := elliptic.P256().Params().N

	//Compute s' = n - s
	sPrime := new(big.Int).Sub(nCurve, s)

	//Verify the malleable signature
	validMalleable := Verify(hash[:], r, sPrime, x, y)
	fmt.Printf("Malleable signature valid: %v\n", validMalleable)

	if validMalleable {
		fmt.Println("Malleability demonstrated in Verify function.")
	} else {
		fmt.Println("Malleability not demonstrated.")
	}
}

The result of the test is the following:


BVSS
Recommendation

To mitigate the vulnerability, modify the signature verification process to enforce the canonical form of s by normalizing it to s ≤ n / 2.


func Verify(hash []byte, r, s, x, y *big.Int) ([]byte, error) {
	// Create the public key format
	publicKey := newPublicKey(x, y)
	if publicKey == nil {
		return nil, errors.New("invalid public key coordinates")
	}

	if checkMalleability(s) {
		return nil, errors.New("malleability issue")
	}

	// Verify the signature with the public key and return 1 if it's valid, 0 otherwise
	if ok := ecdsa.Verify(publicKey, hash, r, s); ok {
		return common.LeftPadBytes(common.Big1.Bytes(), 32), nil
	}

	return common.LeftPadBytes(common.Big0.Bytes(), 32), nil
}

// Check the malleability issue
func checkMalleability(s *big.Int) bool {
	return s.Cmp(secp256k1halfN) <= 0
}
Remediation

RISK ACCEPTED: The Story team accepted the risk of this issue and states the following:

To ensure compatibility with the NIST P-256 specification, the malleability check was not implemented. The necessary protection will be applied at the contract layer whenever the precompile is invoked.


References

7.11 Use of custom errors instead of revert strings

// Informational

Description

In the IPAccountImpl, RoyaltyModule, RoyaltyPolicyLAP RoyaltyPolicyLRP and LicenseRegistry contract, require statements are used. In Solidity development, replacing hard-coded revert message strings with the Error() syntax is an optimization strategy that can significantly reduce gas costs. Hardcoded strings, stored on the blockchain, increase the size and cost of deploying and executing contracts. The Error() syntax allows for the definition of reusable, parameterized custom errors, leading to a more efficient use of storage and reduced gas consumption.


This approach not only optimizes gas usage during deployment and interaction with the contract but also enhances code maintainability and readability by providing clearer, context-specific error information.

Score
Recommendation

It is recommend to replace all revert strings with custom errors.

Remediation

SOLVED: The Story team solved the issue in the specified PR.

Remediation Hash

7.12 Consider using named mappings

// Informational

Description

During the audit, it was identified that DisputeModule, IPAccountStorage, AccessController, LicensorApprovalChecker and IPGraphACL contracts do not use named mappings, unlike the other contracts in the protocol.


The project is using Solidity version greater than 0.8.18, which supports named mappings. Using named mappings can improve the readability and maintainability of the code by making the purpose of each mapping clearer. This practice will enhance code readability and make the purpose of each mapping more explicit.

Score
Recommendation

Consider refactoring all the mappings to use named arguments.

Remediation

PARTIALLY SOLVED: The Story team partially solved the issue in the specified PR. The mappings in DisputeModule contract have been refactored.

Remediation Hash

7.13 Incorrect order of modifiers: nonReentrant should precede all other modifiers

// Informational

Description

To mitigate the risk of reentrancy attacks, a modifier named nonReentrant is commonly used. This modifier acts as a lock, ensuring that a function cannot be called recursively while it is still in execution. A typical implementation of the nonReentrant modifier locks the function at the beginning and unlocks it at the end. However, it is vital to place the nonReentrant modifier before all other modifiers in a function. Placing it first ensures that all other modifiers cannot bypass the reentrancy protection. In the current implementation, some functions use other modifiers before nonReentrant, which may compromise the protection it provides.


During the audit it was identified that the following functions does not use nonReentrant as a first modifier:

  • ArbitrationPolicyUMA.sol

    • onRaiseDispute


  • LicensingModule.sol

    • mintLicenseTokens

    • registerDerivative


  • RoyaltyPolicyLAP.sol

    • onLicenseMinting

    • onLinkToParents


  • RoyaltyPolicyLRP.sol

    • onLicenseMinting

    • onLinkToParents

Score
Recommendation

It is recommended to follow the best practice of placing the nonReentrant modifier before all other modifiers. By doing so, one can reduce the risk of reentrancy-related vulnerabilities. This simple yet effective approach can help enhance the security posture of any Solidity smart contract.

Remediation

SOLVED: The Story team solved the issue in the specified PR.

Remediation Hash

7.14 Potential license incompatibilities

// Informational

Description

The inconsistent use of open-source licenses across the files in the protocol was identified. The VaultController and IpRoyaltyVault contracts specifying the MIT license, the IPAccountStorageOps is unlicensed and others using the more restrictive BUSL-1.1 license. Specifically:


  • Certain files are marked with // SPDX-License-Identifier: MIT, which is a permissive open-source license that allows anyone to use, copy, modify, and distribute the code, provided the original license is included.

  • Other files use // SPDX-License-Identifier: BUSL-1.1, a more restrictive license that limits commercial use for a period of time, making it incompatible with MIT in terms of redistributing or combining the code.


This discrepancy could lead to license incompatibility issues when attempting to combine or redistribute the project's code as a whole, potentially violating the terms of one or both licenses. This is particularly problematic in open-source projects, where license compliance is crucial for legal and community standards.

Score
Recommendation

Ensure consistent licensing across the project by reviewing and aligning all file headers to use the same license, depending on the intended usage and distribution model.

Remediation

SOLVED: The Story team solved the issue in the specified PRs. All contracts use the BUSL-1.1 license.

Remediation Hash

7.15 Unused custom errors

// Informational

Description

Throughout the code in scope, there are several custom errors that are declared but never used.


In Errors.sol:

  • error GroupingModule__InvalidGroupRewardPool(address rewardPool);

  • error GroupingModule__CallerIsNotLicensingModule(address caller);

  • error GroupIPAssetRegistry__GroupFrozen(address groupId);

  • error GroupIPAssetRegistry__GroupNotFrozen(address groupId);

  • error LicenseRegistry__NotTransferable();

  • error LicenseRegistry__NoParentIp();

  • error LicenseToken__ZeroLicensingModule();

  • error LicenseToken__ZeroDisputeModule();

  • error LicensingModule__ReceiverCheckFailed(address receiver);

  • error LicensingModule__ZeroGroupingModule();

  • error RoyaltyPolicyLAP__IpTagged();

  • error RoyaltyPolicyLAP__AlreadyClaimed();

  • error RoyaltyPolicyLAP__ClaimerNotAnAncestor();

  • error RoyaltyPolicyLAP__NotAllRevenueTokensHaveBeenClaimed();

  • error RoyaltyPolicyLAP__InvalidTargetIpId();

  • error EvenSplitGroupPool__UnregisteredCurrencyToken(address currencyToken);

  • error EvenSplitGroupPool__UnregisteredGroupIP(address groupId);

Score
Recommendation

It is recommended to remove all unused errors.

Remediation

SOLVED: The Story team solved the issue in the specified PR. The mentioned custom errors were removed.

Remediation Hash

8. Automated Testing

Static Analysis Report

Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats, Slither was run on the all-scoped contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.


The security team assessed all findings identified by the Slither software and everything was categorised as false positives.

Results



Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

© Halborn 2024. All rights reserved.