Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: medium
Invalid

Unbounded return data in function `callExternalContract()` in `MembershipFactory.sol` leads to financial losses to the relayers.

Summary

The callExternalContract() function in MembershipFactory.sol does not limit the size of return data from external calls. This allows users with the EXTERNAL_CALLER role to create contracts that return an excessive amount of data, call such contracts via the meta-transaction system, causing meta-transactions to fail after consuming a significant amount of gas, paid by relayers, resulting in financial losses to the relayers.

Vulnerability Details

The Byzantium 2017 mainnet hard-fork introduced EIP-211. This EIP established an arbitrary-length return data buffer as well as 2 new opcodes: RETURNDATASIZE and RETURNDATACOPY. This enables callers to copy all or part of the return data from an external call to memory. However under Solidity's implementation, up until at least 0.8.26, the entirety of this return data is automatically copied from the buffer into memory. This automatic copying can be exploited to consume excessive gas.

Consider the following example:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
contract Attacker {
function returnExcessData() external pure returns (string memory) {
revert("Passing in excess data that the Solidity compiler will automatically copy to memory"); // Both statements can return unbounded data
return "Passing in excess data that the Solidity compiler will automatically copy to memory";
}
}
contract Victim {
function callAttacker(address attacker) external returns (bytes memory) {
(bool success, bytes memory returnData) = attacker.call{gas: 2500}(abi.encodeWithSignature("returnExcessData()"));
require(success, "Function call failed!");
return returnData;
}
}

In the above example, you can observe that even though the Victim contract has given the external call a gas stipend of 2500, Solidity will still invoke RETURNDATACOPY during the top-level call-frame. This means the Attacker contract, through revert or return, can force the Victim contract to consume unbounded gas during their own call-frame and not that of the Attacker. Given that memory gas costs grow exponentially after 23 words, this attack vector has the potential to prevent certain contract flows from being executed due to an Out of Gas error.

The issue in One World Project's case arises from the fact that the function callExternalContract() in MembershipFactory.sol does not restrict the size of returndata, allowing an attacker to return large data payloads.

As noted in by the One World Project team in cyfrin's previous audit under finding 7.3.3,

The MetaTransaction's only intended use is to call the callExternalContract() function. The
current implementation is that the EXTERNAL_CALLER signs the transaction in the backend and then sends the signed object to the user and user sends it to the contract by the executeMetaTransaction() function.

The idea of a meta-transaction is quite simple. A meta-transaction is a regular Ethereum transaction which contains another transaction, the actual transaction. The actual transaction is signed by a user and then sent to an operator or something similar; no gas and blockchain interaction required. The operator takes this signed transaction and submits it to the blockchain paying for the fees himself. A contract ensures there's a valid signature on the actual transaction and then executes it.

The meta-transcation flow in One World Project goes something like this:-

  • Two Users (user A and user B): user A lacks the funds to cover gas fees, while user B is willing to assist.

  • Off-Chain Signature: user A creates and signs a transaction off-chain. This transaction contains instructions they want to execute but can't afford to submit on-chain due to gas constraints. In our case, user A aims to call callExternalContract() function. Note that we can only use the meta-transaction system to call function callExternalContract() as highlighted by the One World Project Team in the cyfrin audit.

  • Relaying by user B: user A sends their signed transaction to user B, who then calls the executeMetaTransaction function within NativeMetaTransaction.sol.

  • Execution on Behalf of user A: user B submits the transaction to executeMetaTransaction specifying the function signature of function callExternalContract(). NativeMetaTransaction.sol uses user's A signature to verify and then executes the transaction as if user A is the originator. This allows actions, like creating a DAO, to occur under user A's identity even though user B paid for the gas.

The above setup can be exploited by user A where user A, creates a contract that returns excessive data, forcing user B to pay significant gas costs.

User A can exploit this by:

  • Creating a contract that returns an extremely large amount of data. An example exploit contract might look like the below:-

contract Exploit {
function generateLargeReturn() external pure returns (bytes memory) {
bytes memory largeData = new bytes(3_000); // huge return data
for (uint256 i = 0; i < largeData.length; i++) {
largeData[i] = 0xff;
}
return largeData;
}
}
  1. Calling this contract using the callExternalContract() via executeMetaTransaction() as intended.

  2. user B, who you can think of as the relayer, will cover the associative gas costs for copying this large return data into memory.

  3. Rinse and repeat this attack to drain relayer's/user's B funds.

The same is also observed for the function callExternalContract() in MembershipERC1155.sol which looks like the below:

/// @notice Performs an external call to another contract
/// @param contractAddress The address of the external contract
/// @param data The calldata to be sent
/// @return result The bytes result of the external call
function callExternalContract(
address contractAddress,
bytes memory data
) external payable onlyRole(OWP_FACTORY_ROLE) returns (bytes memory) {
(bool success, bytes memory returndata) = contractAddress.call{
value: msg.value
}(data);
require(success, "External call failed");
return returndata;
}

In the function, the size of returndata is not restricted allowing an external contract to return large data payloads. A user with the OWP_FACTORY_ROLE role can abuse this automatic copy into memory.

Also see for additional context:

  1. Solidity Issue #12306

  2. ExcessivelySafeCall GitHub Repository

  3. EigenLayer's original DelegationManager.sol

Impact

  • Financial drain on relayer services: The relayers responsible for executing meta-transactions will have to pay significant gas costs for failed transactions, as they are charged for the gas used, even if the transaction fails.

  • Potential complete shutdown of gas-subsidized transactions: If the attacker repeatedly exploits this vulnerability, it could lead to relayers becoming unwilling to subsidize gas costs, effectively shutting down the meta-transaction system.

Tools Used

Manual code review

Recommendations

  • Use Yul to make the low-level call, whilst only allowing bounded return data. This method completely cuts off the attack vector for any arbitrary external call. Take for example what EigenLayer did with their original mainnet DelegationManager.sol contract. The contract looks like below:

function _delegationWithdrawnHook(
IDelegationTerms dt,
address staker,
IStrategy[] memory strategies,
uint256[] memory shares
) internal {
/**
* We use low-level call functionality here to ensure that an operator cannot maliciously make this function fail in order to prevent undelegation.
* In particular, in-line assembly is also used to prevent the copying of uncapped return data which is also a potential DoS vector.
*/
// format calldata
bytes memory lowLevelCalldata = abi.encodeWithSelector(
IDelegationTerms.onDelegationWithdrawn.selector,
staker,
strategies,
shares
);
// Prepare memory for low-level call return data. We accept a max return data length of 32 bytes
bool success;
bytes32[1] memory returnData;
// actually make the call
assembly {
success := call(
// gas provided to this context
LOW_LEVEL_GAS_BUDGET,
// address to call
dt,
// value in wei for call
0,
// memory location to copy for calldata
add(lowLevelCalldata, 32),
// length of memory to copy for calldata
mload(lowLevelCalldata),
// memory location to copy return data
returnData,
// byte size of return data to copy to memory
32
)
}
// if the call fails, we emit a special event rather than reverting
if (!success) {
emit OnDelegationWithdrawnCallFailure(dt, returnData[0]);
}
}

In the contract, delegators could delegate and undelegate their restaked assets to a manager and each of these delegation flows had its own callback hook to an arbitrary external contract the manager specified. However the manager could use their arbitrary external contract to return unbounded data, causing the delegator to run out of gas, and thus not be able to undelegate their assets from that manager.

To mitigate this griefing risk entirely, EigenLayer used a Yul call, where they limit the return data size to 1 word. If the external manager contract tries to return any more data than this, the excess of 32 bytes simply won't be copied to memory.

Updates

Lead Judging Commences

0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

bill Submitter
10 months ago
0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.