Summary
The Protocol Implements functionality in the Auction contract, any user with usdc holding will be use buy function to enter the auction with given period and time but it will revert the If the ZENO contract initialOwner not transfer the ownership to the perticular Auction then user can't buy the zeno.
Vulnerability Details
The Issue Arises when Auction ready and set up for the users to enter the Auction and buy zeno for USDC token. and that time user will send amount for zeno token, then we can see that auction contract will mint the zeno token to the current user. that time if the ZENO contract, check for the current msg.sender SLOC#34 is the initialOwner but ZENO owner forgot to transfer the ownership to the perticular auction, auction startTime user cannot mint the zeno token from Auction Contract.
zeno.mint(msg.sender, amount);
However this zeno contract not transfering to the ownership to auction will leading to the user can't buy zeno from running auction.
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;
}
Impact
Impact: The Auctoins buy zeno functionality is non-operational, but theres no risk of the funds loss.
Likelihood: The Auctions buy function fails to excute as intended if the owner of the zeno is not the auction contract address.
Steps to Reproduce
Step 1: ZENO FACTORY Initial Owner Create the new ZENO token from the ZENO Factory.
Step 2: Auction FACTORY initial owner create the new Auction from the Auction FActory.
Step 3: Now Auction Starts and Users who wants buy zeno got to the current running auction and execute the Auction::buy.
Bid on the ZENO auction
User will able to buy ZENO tokens in exchange for USDC
*/
function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
uint256 cost = price * amount;
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);
emit ZENOPurchased(msg.sender, amount, price);
}
Step 4: Users can't buy ZENO token from auction due to ZENO contract function ZENO::mint has the crucial check onlyOwner which is deployer of the ZENO Token contract not 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;
}
Step 5: Users Can't buy Zeno Due to constantly Failing till the ZENO Factory initial Owner not transfering Ownership to the Auction contract.
Foundry POC
Step 1: Clone the github Repo from Codehawks github link.
Step 2: Navigate to the repo root directory. cd 2025-02-raac
Step 3: Integrate foundry in this project link with this command npm i npm i --save-dev @nomicfoundation/hardhat-foundry.
Step 4: Add require("@nomicfoundation/hardhat-foundry"); to the top of your hardhat.config.cjs file.
Step 5: Comment Out Your Network Section in the hardhat.config.cjs file.
Step 6: Navigate to the test/libraries/ReserveLibraryMock.sol replace this file name with this test/libraries/ReserveLibraryMock.sol.bak so at the time of compilation of contract tester dont get errors.
Step 7: Now Inside the test Directory Create Test File NoBuyTransferFrom.t.sol. and copy paste the following POC below for running test.
Step 8: Test POC with this command forge test --match-path test/NoBuyDuetoNotTransferingOwnership.t.sol -vvvv
POC
Foundry Solidity Test
pragma solidity ^0.8.16;
import {Test, console} from "forge-std/Test.sol";
import {ZENO} from "../contracts/zeno/ZENO.sol";
import {ZENOFactory} from "../contracts/zeno/ZENOFactory.sol";
import {Auction} from "../contracts/zeno/Auction.sol";
import {AuctionFactory} from "../contracts/zeno/AuctionFactory.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract USDCMOCK is ERC20 {
constructor() ERC20("USDC", "USDC") {}
function mint(address recipient, uint256 amount) public {
_mint(recipient, amount);
}
function decimals() public view override returns (uint8) {
return 6;
}
}
contract NoBuyDuetoNotTransferingOwnership is Test {
AuctionFactory auctionFactory;
ZENOFactory zenoFactory;
Auction auction;
ZENO zeno;
address ALICE = makeAddr("alice");
address BOB = makeAddr("bob");
address OWNER_ZENO_FACTORY = makeAddr("owner_zeno");
address OWNER_AUCTION_FACTORY = makeAddr("owner_auction");
address BUISNESS_ADDRESS = makeAddr("business_address");
uint256 MATURITY_DATE = 3 days;
uint256 AUCTIONS_START_TIME = 1 hours;
uint256 AUCTION_END_TIME = AUCTIONS_START_TIME + 1 days;
uint256 startingPrice = 100;
uint256 reservePrice = 10;
uint256 TOTAL_ZENO_ALLCATED = 10;
USDCMOCK USDC_MOCK;
function setUp() public {
USDC_MOCK = new USDCMOCK();
zenoFactory = new ZENOFactory(OWNER_ZENO_FACTORY);
auctionFactory = new AuctionFactory(OWNER_AUCTION_FACTORY);
USDC_MOCK.mint(ALICE, 100000);
}
function testFail_NooneCanBuyFromZenoAuction() public {
vm.startPrank(OWNER_ZENO_FACTORY);
zenoFactory.createZENOContract(address(USDC_MOCK), MATURITY_DATE);
vm.stopPrank();
ZENO.ZENODetails memory zeno_details = zenoFactory.getZENODetails(0);
console.log("Zeno Address: ", address(zeno_details.zenoAddress));
vm.startPrank(OWNER_AUCTION_FACTORY);
auctionFactory.createAuction(
address(zeno_details.zenoAddress),
address(USDC_MOCK),
BUISNESS_ADDRESS,
AUCTIONS_START_TIME,
AUCTION_END_TIME,
startingPrice,
reservePrice,
TOTAL_ZENO_ALLCATED
);
vm.stopPrank();
Auction.AuctionDetails memory auction_details = auctionFactory.getAuctionDetails(0);
console.log("Auction Contract Address: ", address(auction_details.auctionAddress));
vm.startPrank(ALICE);
console.log("USDC BALANCE of Alice: ", USDC_MOCK.balanceOf(address(this)));
USDC_MOCK.approve(address(auction_details.auctionAddress), 500);
console.log("Approved balance: ", USDC_MOCK.allowance(ALICE, address(auction_details.auctionAddress)));
vm.warp(AUCTIONS_START_TIME + 20);
Auction(address(auction_details.auctionAddress)).buy(1);
vm.stopPrank();
}
}
POC Result
Ran 1 test for test/NoBuyDuetoNotTransferingOwnership.t.sol:NoBuyDuetoNotTransferingOwnership
[PASS] testFail_NooneCanBuyFromZenoAuction() (gas: 1952146)
Logs:
Zeno Address: 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac
Auction Contract Address: 0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2
USDC BALANCE of Alice: 0
Approved balance: 500
Traces:
[1952146] NoBuyDuetoNotTransferingOwnership::testFail_NooneCanBuyFromZenoAuction()
├─ [0] VM::startPrank(owner_zeno: [0xA16BF0791a22419D925CCDd34BEcac3b887AFcDc])
│ └─ ← [Return]
├─ [982477] ZENOFactory::createZENOContract(USDCMOCK: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 259200 [2.592e5])
│ ├─ [876996] → new ZENO@0xffD4505B3452Dc22f8473616d50503bA9E1710Ac
│ │ ├─ emit OwnershipTransferred(previousOwner: 0x0000000000000000000000000000000000000000, newOwner: ZENOFactory: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ │ └─ ← [Return] 3919 bytes of code
│ ├─ emit ZENOCreated(zenoAddress: ZENO: [0xffD4505B3452Dc22f8473616d50503bA9E1710Ac], maturityDate: 259200 [2.592e5])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [5900] ZENOFactory::getZENODetails(0) [staticcall]
│ ├─ [2791] ZENO::getDetails() [staticcall]
│ │ └─ ← [Return] ZENODetails({ zenoAddress: 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac, maturityDate: 259200 [2.592e5], name: "ZENO Bond 1", symbol: "ZENO1" })
│ └─ ← [Return] ZENODetails({ zenoAddress: 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac, maturityDate: 259200 [2.592e5], name: "ZENO Bond 1", symbol: "ZENO1" })
├─ [0] console::log("Zeno Address: ", ZENO: [0xffD4505B3452Dc22f8473616d50503bA9E1710Ac]) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::startPrank(owner_auction: [0xc09882dBdE929E7F0C6B5B28b9F9fa35504c9e62])
│ └─ ← [Return]
├─ [779576] AuctionFactory::createAuction(ZENO: [0xffD4505B3452Dc22f8473616d50503bA9E1710Ac], USDCMOCK: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], business_address: [0x6d5AE8abD3Fb993A9e8607BDbA3cc687f9A70443], 3600, 90000 [9e4], 100, 10, 10)
│ ├─ [698468] → new Auction@0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2
│ │ ├─ emit OwnershipTransferred(previousOwner: 0x0000000000000000000000000000000000000000, newOwner: AuctionFactory: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a])
│ │ └─ ← [Return] 2680 bytes of code
│ ├─ emit AuctionCreated(auctionAddress: Auction: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [7485] AuctionFactory::getAuctionDetails(0) [staticcall]
│ ├─ [4692] Auction::getDetails() [staticcall]
│ │ └─ ← [Return] AuctionDetails({ auctionAddress: 0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2, zenoAddress: 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac, businessAddress: 0x6d5AE8abD3Fb993A9e8607BDbA3cc687f9A70443, auctionEndTime: 90000 [9e4], startingPrice: 100, reservePrice: 10, auctionStartTime: 3600, totalZENOAllocated: 10, totalZENORemaining: 10, lastBidTime: 0, lastBidder: 0x0000000000000000000000000000000000000000, lastBidAmount: 0, price: 100 })
│ └─ ← [Return] AuctionDetails({ auctionAddress: 0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2, zenoAddress: 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac, businessAddress: 0x6d5AE8abD3Fb993A9e8607BDbA3cc687f9A70443, auctionEndTime: 90000 [9e4], startingPrice: 100, reservePrice: 10, auctionStartTime: 3600, totalZENOAllocated: 10, totalZENORemaining: 10, lastBidTime: 0, lastBidder: 0x0000000000000000000000000000000000000000, lastBidAmount: 0, price: 100 })
├─ [0] console::log("Auction Contract Address: ", Auction: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::startPrank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← [Return]
├─ [2562] USDCMOCK::balanceOf(NoBuyDuetoNotTransferingOwnership: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("USDC BALANCE of Alice: ", 0) [staticcall]
│ └─ ← [Stop]
├─ [24739] USDCMOCK::approve(Auction: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], 500)
│ ├─ emit Approval(owner: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], spender: Auction: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], value: 500)
│ └─ ← [Return] true
├─ [814] USDCMOCK::allowance(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], Auction: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 500
├─ [0] console::log("Approved balance: ", 500) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(3620)
│ └─ ← [Return]
├─ [97187] Auction::buy(1)
│ ├─ [30866] USDCMOCK::transferFrom(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], business_address: [0x6d5AE8abD3Fb993A9e8607BDbA3cc687f9A70443], 100)
│ │ ├─ emit Transfer(from: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], to: business_address: [0x6d5AE8abD3Fb993A9e8607BDbA3cc687f9A70443], value: 100)
│ │ └─ ← [Return] true
│ ├─ [615] ZENO::mint(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 1)
│ │ └─ ← [Revert] OwnableUnauthorizedAccount(0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2)
│ └─ ← [Revert] OwnableUnauthorizedAccount(0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2)
└─ ← [Revert] OwnableUnauthorizedAccount(0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.44ms (1.28ms CPU time)
Ran 1 test suite in 1.74s (3.44ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Recommended Mitigation
It is advisable to check current ownership of the zeno contract matches the current auction contract address.
// File: contracts/zeno/Auction.sol
.
.
.
+ modifier checkZENOOwnerShip() {
+ require(address(this) == ZENO(zeno).owner(), "Auction Not the Owner");
+ _;
+}
.
.
.
- function buy(uint256 amount) external whenActive {
+ function buy(uint256 amount) external whenActive checkZENOOwnerShip {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
uint256 cost = price * amount;
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);
emit ZENOPurchased(msg.sender, amount, price);
}