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.
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:
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 theEXTERNAL_CALLER
signs the transaction in the backend and then sends the signed object to the user and user sends it to the contract by theexecuteMetaTransaction()
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:-
Calling this contract using the callExternalContract()
via executeMetaTransaction()
as intended.
user B, who you can think of as the relayer, will cover the associative gas costs for copying this large return data into memory.
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:
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.
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.
Manual code review
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:
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.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.