Delegate - sces60107's results

Securing onchain identities by linking cold and hot wallets

General Information

Platform: Code4rena

Start Date: 05/09/2023

Pot Size: $50,000 USDC

Total HM: 2

Participants: 16

Period: 6 days

Judge: GalloDaSballo

Total Solo HM: 2

Id: 284

League: ETH

Delegate

Findings Distribution

Researcher Performance

Rank: 10/16

Findings: 1

Award: $40.13

QA:
grade-b

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: DadeKuma

Also found by: Brenzee, Fulum, gkrastenov, kodyvim, ladboy233, lodelux, lsaudit, p0wd3r, sces60107

Labels

bug
downgraded by judge
grade-b
QA (Quality Assurance)
sponsor confirmed
Q-03

Awards

40.1294 USDC - $40.13

External Links

Lines of code

https://github.com/code-423n4/2023-09-delegate/blob/main/src/DelegateToken.sol#L89 https://github.com/code-423n4/2023-09-delegate/blob/main/src/libraries/DelegateTokenTransferHelpers.sol#L79 https://github.com/code-423n4/2023-09-delegate/blob/main/src/DelegateToken.sol#L408

Vulnerability details

Impact

The initial value of erc1155PullAuthorization is ERC1155_NOT_PULLED. And it would be updated to ERC1155_PULLED in DelegateTokenTransferHelpers.checkERC1155BeforePull. And when DelegateToken receives the erc 1155 token, DelegateToken.onERC1155Received will be trigger then update erc1155PullAuthorization to ERC1155_NOT_PULLED. However, DelegateToken.onERC1155Received doesn’t check if the msg.sender is the erc1155 contract. Any contract can call the function to update erc1155PullAuthorization. Therefore, the real token transfer would be blocked.

Proof of Concept

Usually, DelegateToken.onERC1155Received would be called by ERC115 safeTransferFrom. https://github.com/code-423n4/2023-09-delegate/blob/main/src/DelegateToken.sol#L89

    /// @inheritdoc IERC1155Receiver
    function onERC1155Received(address operator, address, uint256, uint256, bytes calldata) external returns (bytes4) {
        TransferHelpers.revertInvalidERC1155PullCheck(erc1155PullAuthorization, operator);
        return IERC1155Receiver.onERC1155Received.selector;
    }

And it calls revertInvalidERC1155PullCheck to update erc1155PullAuthorization to ERC1155_NOT_PULLED. Also, we can notice that onERC1155Received reverts when erc1155Pulled is not ERC1155_PULLED. https://github.com/code-423n4/2023-09-delegate/blob/main/src/libraries/DelegateTokenTransferHelpers.sol#L79

    function checkERC1155Pulled(Structs.Uint256 storage erc1155Pulled, address operator) internal returns (bool) {
        if (erc1155Pulled.flag == ERC1155_PULLED && address(this) == operator) {
            erc1155Pulled.flag = ERC1155_NOT_PULLED;
            return true;
        }
        return false;
    }

    function revertInvalidERC1155PullCheck(Structs.Uint256 storage erc1155PullAuthorization, address operator) internal {
        if (!checkERC1155Pulled(erc1155PullAuthorization, operator)) revert Errors.ERC1155PullNotRequested(operator);
    }

Let's take a look at the flash loan logic for ERC1155. https://github.com/code-423n4/2023-09-delegate/blob/main/src/DelegateToken.sol#L408

    function flashloan(Structs.FlashInfo calldata info) external payable nonReentrant {
        …
        } else if (info.tokenType == IDelegateRegistry.DelegationType.ERC1155) {
            RegistryHelpers.revertERC1155FlashAmountUnavailable(delegateRegistry, info);
            TransferHelpers.checkERC1155BeforePull(erc1155PullAuthorization, info.amount);
            IERC1155(info.tokenContract).safeTransferFrom(address(this), info.receiver, info.tokenId, info.amount, "");
            Helpers.revertOnCallingInvalidFlashloan(info);
            TransferHelpers.pullERC1155AfterCheck(erc1155PullAuthorization, info.amount, info.tokenContract, info.tokenId);
        }
    }

The normal case is:

  • TransferHelpers.checkERC1155BeforePull updates erc1155PullAuthorization to ERC1155_PULLED
  • TransferHelpers.pullERC1155AfterCheck calls IERC1155(underlyingContract).safeTransferFrom to transfer back the erc1155 token
  • DelegateToken.onERC1155Received would be trigger to update erc1155PullAuthorization to ERC1155_NOT_PULLED

But the following case could happen and make the translation revert.

  • TransferHelpers.checkERC1155BeforePull updates erc1155PullAuthorization to ERC1155_PULLED
  • Helpers.revertOnCallingInvalidFlashloan(info) is called. The flash loan caller may call some smart contracts.
  • One of the contract could be malicious and it directly calls DelegateToken.onERC1155Received to update erc1155PullAuthorization to ERC1155_NOT_PULLED
  • TransferHelpers.pullERC1155AfterCheckcallsIERC1155(underlyingContract).safeTransferFrom` to transfer back the erc1155 token
  • DelegateToken.onERC1155Received would be trigger but it would revert since erc1155PullAuthorization is not ERC1155_PULLED

Tools Used

Manual Review

The root cause is that any contract can call DelegateToken.onERC1155Received. The only legit caller is the erc 1155 token contract. Add a state variable to record the erc 1155 token contract.

    Structs.Uint256 internal erc1155PullAuthorization = Structs.Uint256(TransferHelpers.ERC1155_NOT_PULLED);
+   address internal erc1155contract = address(0);

    /// @inheritdoc IERC1155Receiver
    function onERC1155Received(address operator, address, uint256, uint256, bytes calldata) external returns (bytes4) {
+       if (msg.sender != erc1155contract) revert Errors.InvalidERC1155ContractAddress();
        TransferHelpers.revertInvalidERC1155PullCheck(erc1155PullAuthorization, operator);
        return IERC1155Receiver.onERC1155Received.selector;
    }



    function flashloan(Structs.FlashInfo calldata info) external payable nonReentrant {
        …
        } else if (info.tokenType == IDelegateRegistry.DelegationType.ERC1155) {
            RegistryHelpers.revertERC1155FlashAmountUnavailable(delegateRegistry, info);
            TransferHelpers.checkERC1155BeforePull(erc1155PullAuthorization, info.amount);
+           erc1155contract  = info.tokenContract;
            IERC1155(info.tokenContract).safeTransferFrom(address(this), info.receiver, info.tokenId, info.amount, "");
            Helpers.revertOnCallingInvalidFlashloan(info);
            TransferHelpers.pullERC1155AfterCheck(erc1155PullAuthorization, info.amount, info.tokenContract, info.tokenId);
+           erc1155contract  = address(0);
        }
    }

Assessed type

Token-Transfer

#0 - 0xfoobar

2023-09-20T22:45:45Z

Need to double-check this but issue is plausible

#1 - c4-sponsor

2023-09-20T22:45:56Z

0xfoobar (sponsor) confirmed

#2 - GalloDaSballo

2023-09-29T08:52:53Z

There's 2 sides to this finding:

  1. Impact -> Impact doesn't seem to be fully explored, this similar idea of anyone being able to call the onReceived is fairly widespread but the finding simply uses it to cause a revert which is arguably just a grief

  2. The lack of validation for the caller may be a component of a broader attack, since there's no verification that the intended ERC1155 is the one confirming the transfer

#3 - GalloDaSballo

2023-10-01T18:45:17Z

It is possible for an external contract to prevent the Flashloan to work

However, the control as to how that would happen is under the callers control

While there is a lack of check for toggling of erc1155Pulled.flag this will cause the tx to revert

Due to the lowered impact, I'm downgrading to Qa

#4 - c4-judge

2023-10-01T18:45:21Z

GalloDaSballo changed the severity to QA (Quality Assurance)

#5 - c4-judge

2023-10-02T11:42:42Z

GalloDaSballo marked the issue as grade-b

#6 - GalloDaSballo

2023-10-02T11:43:05Z

Awarding QA - B in spite of low score due to the finding being notable

#7 - DadeKuma

2023-10-05T13:41:05Z

I think that this issue is invalid.

onERC1155Received will not revert only when the current state is ERC1155_PULLED

function onERC1155Received(address operator, address, uint256, uint256, bytes calldata) external returns (bytes4) {
    TransferHelpers.revertInvalidERC1155PullCheck(erc1155PullAuthorization, operator);
    return IERC1155Receiver.onERC1155Received.selector;
}

function revertInvalidERC1155PullCheck(Structs.Uint256 storage erc1155PullAuthorization, address operator) internal {
    if (!checkERC1155Pulled(erc1155PullAuthorization, operator)) revert Errors.ERC1155PullNotRequested(operator);
}

function checkERC1155Pulled(Structs.Uint256 storage erc1155Pulled, address operator) internal returns (bool) {
    if (erc1155Pulled.flag == ERC1155_PULLED && address(this) == operator) {
        erc1155Pulled.flag = ERC1155_NOT_PULLED;
        return true;
    }
    return false;
}

State ERC1155_PULLED is set here:

function checkERC1155BeforePull(Structs.Uint256 storage erc1155Pulled, uint256 pullAmount) internal {
    if (pullAmount == 0) revert Errors.WrongAmountForType(IDelegateRegistry.DelegationType.ERC1155, pullAmount);
    if (erc1155Pulled.flag == ERC1155_NOT_PULLED) {
        erc1155Pulled.flag = ERC1155_PULLED;
    } else {
        revert Errors.ERC1155Pulled();
    }
}

checkERC1155BeforePull is called only during a flashloan.

	function flashloan(Structs.FlashInfo calldata info) external payable nonReentrant {
	    StorageHelpers.revertNotOperator(accountOperator, info.delegateHolder);
	    if (info.tokenType == IDelegateRegistry.DelegationType.ERC721) {
	        RegistryHelpers.revertERC721FlashUnavailable(delegateRegistry, info);
	        IERC721(info.tokenContract).transferFrom(address(this), info.receiver, info.tokenId);
	        Helpers.revertOnCallingInvalidFlashloan(info);
	        TransferHelpers.checkERC721BeforePull(info.amount, info.tokenContract, info.tokenId);
	        TransferHelpers.pullERC721AfterCheck(info.tokenContract, info.tokenId);
	    } else if (info.tokenType == IDelegateRegistry.DelegationType.ERC20) {
	        RegistryHelpers.revertERC20FlashAmountUnavailable(delegateRegistry, info);
	        SafeERC20.safeTransfer(IERC20(info.tokenContract), info.receiver, info.amount);
	        Helpers.revertOnCallingInvalidFlashloan(info);
	        TransferHelpers.checkERC20BeforePull(info.amount, info.tokenContract, info.tokenId);
	        TransferHelpers.pullERC20AfterCheck(info.tokenContract, info.amount);
	    } else if (info.tokenType == IDelegateRegistry.DelegationType.ERC1155) {
	        RegistryHelpers.revertERC1155FlashAmountUnavailable(delegateRegistry, info);
>	        TransferHelpers.checkERC1155BeforePull(erc1155PullAuthorization, info.amount);
	        IERC1155(info.tokenContract).safeTransferFrom(address(this), info.receiver, info.tokenId, info.amount, "");
	        Helpers.revertOnCallingInvalidFlashloan(info);
	        TransferHelpers.pullERC1155AfterCheck(erc1155PullAuthorization, info.amount, info.tokenContract, info.tokenId);
	    }
	}

This means that the info.receiver must call a malicious contract after it receives the token, which then should send an ERC1155 to DelegateToken, just to make the transaction revert without gaining anything?

What if the malicious contract is the 1155 contract itself? Then the mitigation would not have any effect, as it wouldn't revert when DelegateToken receives the token. I don't think this is a valid issue, as it's not preventable + the impact is non-existent.

#8 - GalloDaSballo

2023-10-08T08:18:06Z

The finding highlights how the onERC1155Received pattern, without verifying the caller can be griefed

The impact falls under self-rekt / gotcha as the contract that is interacted with must be malicious

Interestingly enough this could be used to prevent claims from flashloans if the token is ERC1155

I believe the finding to be notable enough to warrant it being added to the report

AuditHub

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

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter