0x554c9e4067e3bc0201ba06fc2cfeeacd178d7dd9c69f9b211bc661bb11296fde0x968e1c984a431f3d0299563f15d48c395f70f719PolygonOn Polygon, the BTC24H Lock contract at 0x968e1c984A431F3D0299563F15d48C395f70F719 held 110000 BTC24H for a future beneficiary, but its public claim() path could be executed by any caller after the unlock time. In transaction 0x554c9e4067e3bc0201ba06fc2cfeeacd178d7dd9c69f9b211bc661bb11296fde, an unprivileged actor triggered claim(), drained the entire lock balance, and then sold the tokens into USDT and WBTC.
The root cause is a direct authorization failure. claim() enforced the release timestamp and one-time claimed flag, but it never enforced that the caller matched the stored owner, and it transferred the payout to msg.sender.
The victim contract is a verified Lock contract deployed on Polygon and funded with the ERC-20 token BTC24H at 0xea4b5c48a664501691b2ecb407938ee92d389a6f. The contract stores a token address, an owner, and a claims struct containing the locked amount, release date, and claimed flag.
The lock had already been funded before the exploit. Immediately before the exploit transaction, the lock still held 110000000000000000000000 BTC24H, releaseDate was , and was . These values are confirmed by the collected transaction trace and by the contract getter observed on the fork.
1734220800claimedfalseThe observed transaction used helper contract 0x3CB2452c615007B9eF94D5814765eB48b71Ae520, but that helper is not necessary for exploitability. The ACT condition is that any unprivileged caller could invoke claim() once the release time had passed.
The vulnerability class is broken authorization on an asset release path. In the verified source, owner is stored during construction and deposit() is restricted to the owner, but claim() is not. Instead, claim() only checks whether the unlock timestamp has passed and whether the lock has already been claimed.
Polygonscan’s verified source for Lock.sol shows the critical implementation:
function claim() external onlyOnOrAfter(claims.releaseDate) {
require(!claims.claimed, "Already claimed");
claims.claimed = true;
uint256 claimAmount = claims.amount;
token.safeTransfer(msg.sender, claimAmount);
}
This violates the intended invariant that only the stored beneficiary should be able to withdraw the locked tokens. The code-level breakpoint is the final transfer to msg.sender, because the caller is not authenticated against owner.
The lock was in a valid funded state immediately before the exploit. The transaction trace shows the exploit helper first reading the BTC24H balance of the lock and then calling Lock::claim(). During that call, the victim contract transferred the entire 110000 BTC24H balance to the caller and set the one-time claim flag.
Relevant trace excerpt from the collected Polygon execution:
0x968e1c984A431F3D0299563F15d48C395f70F719::claim()
BTC24H::transfer(0x3CB2452c615007B9eF94D5814765eB48b71Ae520, 110000000000000000000000)
emit Transfer(from: 0x968e1c984A431F3D0299563F15d48C395f70F719, to: 0x3CB2452c615007B9eF94D5814765eB48b71Ae520, value: 110000000000000000000000)
storage changes:
@ 4: 0 -> 1
The collected balance diff independently confirms the loss from the lock:
{
"token": "0xea4b5c48a664501691b2ecb407938ee92d389a6f",
"holder": "0x968e1c984a431f3d0299563f15d48c395f70f719",
"before": "110000000000000000000000",
"after": "0",
"delta": "-110000000000000000000000"
}
Because claim() lacks any owner check, the first caller after releaseDate can deterministically consume the one-time withdrawal right. Once claimed becomes true, the legitimate beneficiary can no longer recover the locked tokens through the contract.
The adversary flow is short and fully on-chain:
0xde0a99fb39e78efd3529e31d78434f7645601163 submitted transaction 0x554c9e4067e3bc0201ba06fc2cfeeacd178d7dd9c69f9b211bc661bb11296fde.0x3CB2452c615007B9eF94D5814765eB48b71Ae520.Lock.claim(), received 110000 BTC24H, and consumed the claimed flag.10000 BTC24H into a BTC24H/USDT Uniswap V3 pool and 100000 BTC24H into a BTC24H/WBTC Uniswap V3 pool.4953025389 USDT units and 76433345 WBTC units.Representative trace lines for the liquidation stage:
emit Transfer(from: 0x3CB2452c615007B9eF94D5814765eB48b71Ae520, to: UniversalRouter, value: 10000000000000000000000)
emit Swap(... recipient: 0xDE0A99Fb39E78eFd3529e31D78434f7645601163, amount0: -4953025389, amount1: 10000000000000000000000 ...)
emit Transfer(from: 0x3CB2452c615007B9eF94D5814765eB48b71Ae520, to: UniversalRouter, value: 100000000000000000000000)
emit Swap(... recipient: 0xDE0A99Fb39E78eFd3529e31D78434f7645601163, amount0: -76433345, amount1: 100000000000000000000000 ...)
The lock lost its full BTC24H balance, and the intended beneficiary lost the ability to claim through the contract because the single-use withdrawal path was consumed. The measurable asset loss from the victim contract was:
[
{
"token_symbol": "BTC24H",
"amount": "110000000000000000000000",
"decimal": 18
}
]
Observed downstream proceeds in the exploit transaction were 4953025389 USDT units and 76433345 WBTC units to the adversary EOA, while the EOA paid 7624397058625146765 wei of POL in gas.
0x554c9e4067e3bc0201ba06fc2cfeeacd178d7dd9c69f9b211bc661bb11296fde0x968e1c984A431F3D0299563F15d48C395f70F7190xea4b5c48a664501691b2ecb407938ee92d389a6fLock.sol