Summary
The current logic in the Auction::buy() function is meant to ensure that the buyer gets minted some ZENO tokens after paying for them. Well, apparently they get nothing. And yeah, they still lose their usdc. Yikes!
P.S. This is not reported in the LightyChaser report. And if the protocol wants to argue that this will be fixed later on, the README doesn't say so. And the README should be the source of truth for this contest.
Vulnerability Details
The flow of the Auction::buy() function is as follows:
Buyer specifies amount of ZENO tokens they wish to buy
Function calculates the cost to be paid based on current slippage price
Cost is transferred from buyer to businessAddress
Buyer is minted some ZENO tokens equivalent to the amount they specified
But, a closer look at the ZENO::mint() funtion shows that it can only be called the owner of the Auction contract.
* CAN BE CALLED ONLY BY ASSOCIATED AUCTION CONTRACT (THE OWNER)
*/
function mint(address to, uint256 amount) external onlyOwner {
if (amount == 0) {
revert ZeroAmount();
}
_mint(to, amount);
totalZENOMinted += amount;
}
Thus this function will revert with an OwnableUnauthorizedAccount() error
Impact
The buyer never gets their ZENO tokens, and they lose their usdc also.
Tools Used
PoC
P.S. For this test, I have already fixed the miscalculation error of cost to be transferred from the buyer in the Auction::buy() function. That way, we focus on only the traces of this finding.
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {Auction} from "../contracts/zeno/Auction.sol";
import {ZENO} from "../contracts/zeno/ZENO.sol";
import {MockUSDC} from "../contracts/mocks/core/tokens/MockUSDC.sol";
contract AuctionTest is Test {
Auction auction;
ZENO zeno;
MockUSDC usdc;
address owner;
address businessAddress;
address buyer;
uint256 startTime = 3600 seconds;
uint256 endTime = startTime + 3600 seconds;
uint256 startingPrice = 1e18;
uint256 reservePrice = 5e17;
uint256 totalAllocated = 1000e18;
uint256 initialSupply = 10_000e18;
uint256 zenoMaturityDate = 1 hours;
uint256 amount = 1e18;
function setUp() public {
owner = makeAddr("owner");
businessAddress = makeAddr("businessAddress");
buyer = makeAddr("buyer");
owner = makeAddr("owner");
usdc = new MockUSDC(initialSupply);
zeno = new ZENO(address(usdc), zenoMaturityDate, "ZTOKEN", "ZTK", owner);
vm.prank(owner);
auction = new Auction(
address(zeno),
address(usdc),
businessAddress,
startTime,
endTime,
startingPrice,
reservePrice,
totalAllocated,
owner
);
}
function testBuyerGetsNoZeno() public {
usdc.mint(buyer, 10e18);
vm.warp(startTime + 1 seconds);
vm.startPrank(buyer);
usdc.approve(address(auction), 1e18);
auction.buy(1e18);
vm.stopPrank();
}
}
From the traces, we can see that we always get an OwnableUnauthorizedAccount() error, but the buyer's usdc has already been sent to the businessAddress.
[193113] AuctionTest::testBuyerGetsNoZeno()
├─ [30191] MockUSDC::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 10000000000000000000 [1e19])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 10000000000000000000 [1e19])
│ └─ ← [Stop]
├─ [0] VM::warp(3601)
│ └─ ← [Return]
├─ [0] VM::startPrank(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02])
│ └─ ← [Return]
├─ [25296] MockUSDC::approve(Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 1000000000000000000 [1e18])
│ ├─ emit Approval(owner: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], spender: Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 1000000000000000000 [1e18])
│ └─ ← [Return] true
├─ [117408] Auction::buy(1000000000000000000 [1e18])
│ ├─ [26814] MockUSDC::transferFrom(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], 999861111111111112 [9.998e17])
│ │ ├─ emit Transfer(from: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], to: businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], value: 999861111111111112 [9.998e17])
│ │ └─ ← [Return] true
│ ├─ [3202] ZENO::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 1000000000000000000 [1e18])
│ │ └─ ← [Revert] OwnableUnauthorizedAccount(0x88F59F8826af5e695B13cA934d6c7999875A9EeA)
│ └─ ← [Revert] OwnableUnauthorizedAccount(0x88F59F8826af5e695B13cA934d6c7999875A9EeA)
└─ ← [Revert] OwnableUnauthorizedAccount(0x88F59F8826af5e695B13cA934d6c7999875A9EeA)
Recommendations
Refactor the ::buy() function to transfer ZENO tokens to the buyer, instead of minting directly.
Before any auction starts, the Auction contract is minted some ZENO tokens proportionate to the AuctionState.totalAllocated value by the owner.
function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
uint256 cost = (price * amount) / 1e18;
require(usdc.transferFrom(msg.sender, businessAddress, cost), "Transfer failed");
bidAmounts[msg.sender] += amount;
state.totalRemaining -= amount;
state.lastBidTime = block.timestamp;
state.lastBidder = msg.sender;
- zeno.mint(msg.sender, amount);
+ zeno.transfer(msg.sender, amount); // transfer from contract's zeno tokens balance
emit ZENOPurchased(msg.sender, amount, price);
}
Here is a test to proof this mitigation:
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {Auction} from "../contracts/zeno/Auction.sol";
import {ZENO} from "../contracts/zeno/ZENO.sol";
import {MockUSDC} from "../contracts/mocks/core/tokens/MockUSDC.sol";
contract AuctionTest is Test {
Auction auction;
ZENO zeno;
MockUSDC usdc;
address owner;
address businessAddress;
address buyer;
uint256 startTime = 3600 seconds;
uint256 endTime = startTime + 3600 seconds;
uint256 startingPrice = 1e18;
uint256 reservePrice = 5e17;
uint256 totalAllocated = 1000e18;
uint256 initialSupply = 10_000e18;
uint256 zenoMaturityDate = 1 hours;
function setUp() public {
owner = makeAddr("owner");
businessAddress = makeAddr("businessAddress");
buyer = makeAddr("buyer");
owner = makeAddr("owner");
usdc = new MockUSDC(initialSupply);
zeno = new ZENO(address(usdc), zenoMaturityDate, "ZTOKEN", "ZTK", owner);
vm.prank(owner);
auction = new Auction(
address(zeno),
address(usdc),
businessAddress,
startTime,
endTime,
startingPrice,
reservePrice,
totalAllocated,
owner
);
vm.prank(owner);
zeno.mint(address(auction), totalAllocated);
}
function testBuyerGetsZeno() public {
usdc.mint(buyer, 10e18);
vm.warp(startTime + 1 seconds);
vm.startPrank(buyer);
usdc.approve(address(auction), 1e18);
auction.buy(1e18);
vm.stopPrank();
assert(zeno.balanceOf(buyer) == 1e18);
assert(zeno.balanceOf(address(auction)) == (totalAllocated - 1e18));
}
}
And the test passed with the following traces:
[PASS] testBuyerGetsZeno() (gas: 231080)
Traces:
[231080] AuctionTest::testBuyerGetsZeno()
├─ [30191] MockUSDC::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 10000000000000000000 [1e19])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 10000000000000000000 [1e19])
│ └─ ← [Stop]
├─ [0] VM::warp(3601)
│ └─ ← [Return]
├─ [0] VM::startPrank(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02])
│ └─ ← [Return]
├─ [25296] MockUSDC::approve(Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 1000000000000000000 [1e18])
│ ├─ emit Approval(owner: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], spender: Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 1000000000000000000 [1e18])
│ └─ ← [Return] true
├─ [146890] Auction::buy(1000000000000000000 [1e18])
│ ├─ [26814] MockUSDC::transferFrom(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], 999861111111111112 [9.998e17])
│ │ ├─ emit Transfer(from: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], to: businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], value: 999861111111111112 [9.998e17])
│ │ └─ ← [Return] true
│ ├─ [30590] ZENO::transfer(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 1000000000000000000 [1e18])
│ │ ├─ emit Transfer(from: Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 1000000000000000000 [1e18])
│ │ └─ ← [Return] true
│ ├─ emit ZENOPurchased(buyer: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], amount: 1000000000000000000 [1e18], price: 999861111111111112 [9.998e17])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [939] ZENO::balanceOf(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02]) [staticcall]
│ └─ ← [Return] 1000000000000000000 [1e18]
├─ [939] ZENO::balanceOf(Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA]) [staticcall]
│ └─ ← [Return] 999000000000000000000 [9.99e20]
└─ ← [Stop]