0x80b9c9c883e376c4aa43d72413ab1bd6a64a0654BaseLocker on Base was exploited through splitLock refund-path reentrancy. The attacker-controlled EOA 0x3cc1edd8a25c912fcb51d7e61893e737c48cd98d used helper contract 0x0f30ae8f41a5d3cc96abd07adf1550a9a0e557b5 as the beneficiary of an unlocked BIZNESS lock, then called the helper's exploit function in tx 0x984cb29cdb4e92e5899e9c94768f8a34047d0e1074f9c4109364e3682e488873. The root cause is that Locker refunds excess ETH to msg.sender inside _feeHandler before splitLock reduces the original lock amount. That callback lets the beneficiary contract reenter withdrawLock on the same lock, withdraw the full pre-split amount, and still receive a fresh split lock. The resulting duplication drained 220627279869879905706908225 BIZNESS from Locker and the controller EOA finished the seed transaction with 4714399733262014704 wei more ETH.
Locker is an upgradeable proxy at 0x80b9c9c883e376c4aa43d72413ab1bd6a64a0654 whose implementation at 0xd6a7cfa86a41b8f40b8dfeb987582a479eb10693 manages ERC20 and NFT time locks. Each lock stores the token, beneficiary, amount, unlock timestamp, and withdrawn flag.
For ERC20 positions, createLock transfers tokens into Locker and records a beneficiary. later transfers the full stored amount to that beneficiary once the lock is unlocked, then marks the lock withdrawn. is intended to reduce the original lock amount and mint a second lock for the split portion, preserving the total claimable amount across both locks.
withdrawLocksplitLockThe incident matters because the split path is payable and routes through the protocol fee/refund mechanism. That refund path introduces an external call to the current caller during a stateful asset-management operation.
The vulnerability class is reentrancy caused by a checks-effects-interactions violation in splitLock. The relevant Locker source shows splitLock validating the lock, building a fee whitelist, calling _feeHandler(_whitelist), only then subtracting _newAmount from _lock.amount, and finally creating the new split lock. _feeHandler transfers the fee to treasury and, when msg.value > _f, performs payable(_msgSender()).call{value: msg.value - _f}(""). Because the beneficiary can also be the caller through an attacker-controlled helper contract, that refund hands control back before the source lock state is updated. withdrawLock only checks that the lock is not yet withdrawn, is unlocked, and that the caller is the beneficiary; it then transfers the full original amount and sets withdrawn = true. The trace for the seed transaction shows this exact sequence: Locker::splitLock refunds 0.001 ETH, the helper fallback reenters Locker::withdrawLock(11), and Locker transfers 4412545597397598114138189 BIZNESS to the helper. After the callback returns, splitLock still resumes and mints a fresh live lock, breaking conservation of claimable locked amount.
The broken invariant is: for an ERC20 lock being split, the sum of claimable amount in the original lock plus the newly created split lock must remain equal to the pre-split amount, and no withdrawal may observe the pre-split amount after split processing starts.
The verified Locker source confirms the vulnerable sequence:
function splitLock(uint256 _id, uint256 _newAmount, uint256 _newUnlockTime) external payable whenNotPaused returns (uint256 _splitId) {
Lock storage _lock = locks[_id];
require(!_lock.withdrawn, "Locker: lock already withdrawn");
require(_newUnlockTime >= _lock.unlockTime, "Locker: new unlock time must be greater than or equal to the current lock time");
require(_newAmount > 0 && _newAmount < _lock.amount, "Locker: invalid new amount");
_feeHandler(_whitelist);
_lock.amount -= _newAmount;
_splitId = lockId;
++lockId;
locks[_splitId] = Lock({ ... beneficiary: _lock.beneficiary, amount: _newAmount, ... });
}
function _feeHandler(address[] memory _whitelist) internal {
uint256 _f = _fee(_whitelist);
if (msg.value > _f) {
(bool _success, ) = payable(_msgSender()).call{value: msg.value - _f}("");
require(_success, "Locker: refund failed");
}
}
withdrawLock separately confirms why the callback is exploitable:
function withdrawLock(uint256 _id) external whenNotPaused {
Lock storage _lock = locks[_id];
require(!_lock.withdrawn, "Locker: lock already withdrawn");
require(block.timestamp >= _lock.unlockTime, "Locker: lock not yet unlocked");
require(_msgSender() == _lock.beneficiary, "Locker: not the beneficiary");
_lock.withdrawn = true;
IERC20(_lock.token).safeTransfer(_lock.beneficiary, _lock.amount);
}
The seed trace shows the exploit on lock 11. Locker::splitLock{value: 11000000000000000}(11, 4412545597397598114138188, 1735353747) sends the fee to treasury and then refunds 1000000000000000 wei to the helper fallback. Inside that fallback, the helper immediately calls Locker::withdrawLock(11), and Locker transfers 4412545597397598114138189 BIZNESS to the helper before splitLock has reduced the original lock amount. Because splitLock later resumes and creates a new lock for the split amount, the helper ends the step with withdrawn tokens plus a new live claim.
Representative trace excerpt:
Locker::splitLock{value: 11000000000000000}(11, 4412545597397598114138188, 1735353747)
GnosisSafeProxy::fallback{value: 10000000000000000}()
0x0F30...57b5::fallback{value: 1000000000000000}()
Locker::withdrawLock(11)
TokenV2::transfer(0x0F30...57b5, 4412545597397598114138189)
The helper contract evidence is consistent with this mechanism. Its creation metadata ties it to the controller EOA, and the decompilation shows a dedicated exploit entrypoint using selector 0x735ac5b2, repeated interaction with Locker, and fallback behavior designed to call back into the Locker proxy.
The adversary lifecycle begins with deployment of helper contract 0x0f30ae8f41a5d3cc96abd07adf1550a9a0e557b5 in tx 0xcf399f203ce225bca0f197e8a0dfb2a06991d91474f67e386f74a28253b73a0d. Collector metadata attributes that contract to creator 0x3cc1edd8a25c912fcb51d7e61893e737c48cd98d.
The helper then bought BIZNESS and created an initial Locker position with itself as beneficiary. Once the lock was unlocked, the EOA submitted tx 0x984cb29cdb4e92e5899e9c94768f8a34047d0e1074f9c4109364e3682e488873 to the helper with selector 0x735ac5b2, passing the BIZNESS token address and loop count 0x32 and funding the repeated fee/refund path with 0.51 ETH.
Within the transaction, the helper repeatedly invoked splitLock on the current lock id. Each iteration preserved exploitability because the new split lock kept the helper as beneficiary and remained live after the reentrant withdrawal. The trace shows the pattern recurring from lock 11 through later split ids. After accumulating duplicated BIZNESS, the helper swapped the tokens into WETH, unwrapped to ETH, and paid the controlling EOA.
The balance diff artifact records Locker's BIZNESS balance decreasing from 253392136597397598114138189 to 32764856727517692407229964, a loss of 220627279869879905706908225 raw units. The same artifact records the controller EOA's ETH balance increasing from 708409545891204908 wei to 5422809279153219612 wei, for a net gain of 4714399733262014704 wei after fees. WETH inside the helper decreased by 5214470174770264654 units, matching the realized sale proceeds before payout.
The affected public protocol components are Locker proxy 0x80b9c9c883e376c4aa43d72413ab1bd6a64a0654 and BIZNESS token 0xf3a605573b93fd22496f471a88ae45f35c1df5a7. The exploit is ACT because it only requires an unlocked lock whose beneficiary is an attacker-controlled contract and enough ETH to drive the fee/refund path.
0x984cb29cdb4e92e5899e9c94768f8a34047d0e1074f9c4109364e3682e4888730x80b9c9c883e376c4aa43d72413ab1bd6a64a06540xd6a7cfa86a41b8f40b8dfeb987582a479eb106930x0f30ae8f41a5d3cc96abd07adf1550a9a0e557b5https://basescan.org/address/0xd6a7cfa86a41b8f40b8dfeb987582a479eb10693#code