b14g Exploit: Withdrawing Zero CORE Drained 97M CORE
Originally published at panda-security.xyz
At 09:29:51 UTC on 31 October 2026, a single transaction on the Core blockchain emptied 91 staking orders held by b14g, a Bitcoin dual-staking protocol. The attacker did not steal a key, deploy a new contract or bend a price oracle. They sent b14g a list of 99 withdrawal requests, each for 0 CORE. Core's staking layer reads a zero amount as "withdraw everything", so every order paid out in full: 97,045,563 CORE, worth about $1.47M at the pre-attack price according to Bitquery.
Two days later the story took an unusual turn. On 2 November, a Core network hard fork moved 68,146,177.22 CORE out of three attacker-controlled addresses into a recovery address. That is roughly 70% of the loss, clawed back by changing the chain's rules rather than by any transaction the attacker signed.
This post walks through the bug, the on-chain evidence, and the lessons for anyone building on top of somebody else's staking or vault interface.
What b14g Does
b14g matches two kinds of users on Core. Bitcoin holders lock BTC in a time lock on the Bitcoin chain, and CORE holders stake CORE alongside them so both sides earn Core's higher "dual staking" yield. On the Core side, b14g represents each arrangement as an order: a small contract that holds CORE and keeps it delegated to a Core validator.
To withdraw, a user calls b14g's main contract with three pieces of data per entry: the order, the validator it delegates to, and how much CORE to take out. b14g forwards the request to Core's system staking contract, which releases the coins. According to Bitquery, six wallets made eight ordinary withdrawals through this path between 28 October and the attack, each asking for a specific amount and receiving exactly that.
The BTC side was not touched. b14g says Bitcoin staked through it stays time-locked on Bitcoin and was not affected; only CORE was drained.
The Attack Transaction
We read the attack transaction directly from Core's public RPC to check the reported figures:
- Transaction:
0xe1947f325e1f0bc22290ea97dc5b2ce44827fbb56e1cc40abaeda1d2ed04f62c - Block: 40,042,125, timestamp 2026-10-31 09:29:51 UTC
- Sender:
0xc556ed208285406d71705927f92d4f4eaea579f1 - Called contract:
0x04ea61c431f7934d51fed2acb2c5f942213f8967(b14g's proxy) - Status: success, 11,464,357 gas, 327 event logs
The calldata is a single dynamic array with 99 entries. Each entry is a tuple of (order address, validator address, amount). Decoding it, we found that every amount is zero, the entries point at 91 distinct orders, and those orders are spread across 23 validators. The receipt also contains exactly 99 events from b14g's contract and 99 matching events from Core's system contract at 0x...1011, which lines up with one forwarded request per entry.
There are no ERC-20 Transfer events in the receipt. CORE is the chain's native asset, so the 97 million moved as native value through internal calls. Monitoring that only watches token Transfer logs would have seen nothing.
The sender wallet was fresh. Bitquery reports that its only funding was 615.62 CORE for gas, sent 29 minutes before the drain, and that the attack call was its first interaction with b14g.
Root Cause: A Sentinel Value Meets a Missing Ownership Check
Two separate design decisions combined into one exploit.
Zero means "all" in Core's staking layer
Core's staking code treats a withdrawal amount of 0 as a request to withdraw the delegator's entire stake with that validator. Each b14g order is the delegator from Core's point of view. So when b14g forwarded amount = 0 on behalf of an order, Core released that order's full balance. Core DAO said its consensus, validators and staking contracts kept working normally, and Bitquery's reading of the chain agrees: the system contract paid out exactly what it was asked to pay out.
Using 0 (or type(uint256).max) as "everything" is a common convenience in DeFi interfaces. On its own it is harmless. It becomes dangerous the moment another contract passes user-controlled amounts straight into it.
b14g did not check who was asking
The second half is on b14g's side. The attacker's wallet had funded none of the 91 orders, yet b14g accepted withdrawal requests against all of them and sent the proceeds to the caller. Bitquery notes it has not read b14g's source and cannot point to the exact line; b14g has promised a full post-mortem. What the chain does show is that a wallet with no stake asked for nothing, against orders it did not own, and was paid everything.
Bitquery also found that the logic running at the time had been b14g's standing implementation for about 17 months, so this was not a fresh, rushed upgrade.
What the pattern looks like (simplified)
The Solidity below is simplified and illustrative. It is not b14g's or Core's code. It shows how the two halves interact.
// Simplified illustration only - not the real b14g or Core contracts. interface IStakeHub { // amount == 0 means "withdraw the full stake" function undelegate(address validator, uint256 amount) external; } contract VulnerableRouter { struct Req { address order; address validator; uint256 amount; } function batchWithdraw(Req[] calldata reqs) external { for (uint256 i; i < reqs.length; ++i) { // BUG 1: no check that msg.sender owns reqs[i].order // BUG 2: amount 0 is forwarded unchanged and becomes "all" uint256 got = IOrder(reqs[i].order).withdraw( reqs[i].validator, reqs[i].amount ); payable(msg.sender).transfer(got); } } } A fixed version closes both doors:
// Simplified illustration only. function batchWithdraw(Req[] calldata reqs) external { for (uint256 i; i < reqs.length; ++i) { Req calldata r = reqs[i]; require(orderOwner[r.order] == msg.sender, "not owner"); require(r.amount > 0, "zero amount"); require(r.amount <= stakeOf[r.order], "exceeds stake"); uint256 before = address(this).balance; IOrder(r.order).withdraw(r.validator, r.amount); uint256 got = address(this).balance - before; require(got == r.amount, "unexpected payout"); stakeOf[r.order] -= got; (bool ok, ) = msg.sender.call{value: got}(""); require(ok, "send failed"); } } The ownership check is the real fix. Rejecting zero and asserting the received amount are defence in depth, so that a misunderstood downstream convention cannot silently turn into "send everything".
Timeline
All times UTC, from Bitquery's reconstruction, ChainClarity's trace and b14g's posts:
| Time | Event |
|---|---|
| 31 Oct 09:00:42 | Attacker wallet receives 615.62 CORE for gas |
| 31 Oct 09:29:51 | Drain transaction: 99 zero-amount requests, 91 orders, 97,045,563 CORE |
| 31 Oct 09:37 to 10:20 | About 23.4M CORE moved out through single-use relay addresses |
| 31 Oct 10:35:51 | b14g multisig pauses the contract (about 66 minutes after the drain) |
| 31 Oct 10:36:33 | Attacker moves 73,646,178 CORE to a second wallet, 0x27758c30f8e424fbd8ca605bf31e9496c26ce92c |
| 31 Oct 16:38 | b14g posts its security update on X |
| 2 Nov 04:00 (scheduled) | Core hard fork activates and moves 68,146,177.22 CORE to a recovery address |
| 2 Nov 07:23 | b14g publishes "Recovery Update #1" |
b14g says it paused the contracts as soon as it discovered the problem, and the chain cannot show when that was. What the chain does show is that the pause landed 66 minutes after the drain. By then about 23M CORE had already left the first wallet. Some of it reached exchange deposit addresses, and b14g says exchange partners froze about 9M CORE.
The Hard-Fork Recovery
According to b14g's recovery update and reporting by The Defiant, Core's Fermi upgrade included a one-time state change: it set the balances of three attacker addresses to zero and credited the combined amount, 68,146,177.22 CORE, to a designated recovery address. b14g describes this as made "without reversing transactions, minting new tokens, or changing the total CORE supply". When we queried Core's RPC late on 2 November (UTC), the second attacker wallet held 0 CORE, which matches that account.
As of b14g's update, the recovered CORE has not been distributed, no official claims process exists, and the exchange-frozen funds have not been returned. The Defiant estimates that around 19.9M CORE sits outside both the fork recovery and the reported freezes, while noting the figure is provisional.
For users this is good news. For protocol designers it is a reminder of something else: the recovery depended on a chain-level intervention that most applications cannot count on. On Ethereum or a major L2, there would be no fork. Treat a hard fork as a rare outcome, not as a plan.
Lessons for Builders and Auditors
1. Map every sentinel value in the interfaces you call. Zero-means-all, max-uint-means-all, address(0)-means-default: list them for each external dependency and decide, explicitly, whether your contract rejects them or authorises them. Our smart contract security checklist has a section on external call assumptions that fits here.
2. Ownership checks belong on every path that moves user funds. The missing control was not input sanitisation; it was "whose position is this?". The same family of failure showed up when a retired rebalancer kept vault permissions at Troves, and in the WAX bridge, where one unchecked number drained the contract.
3. Assert what you received, not what you asked for. Measuring the balance delta and comparing it with the requested amount turns a silent overpayment into a revert.
4. Batch functions multiply blast radius. A batch withdraw is convenient for users and for attackers. Here one call emptied 91 positions. Per-call or per-block outflow limits would have capped the damage.
5. Watch native-asset flows, and rehearse the pause. Native transfers emit no ERC-20 events, so monitoring has to track balances and internal calls. And 66 minutes from drain to pause is a measurable number; incident playbooks should aim to make it minutes.
6. Time in production is not proof of safety. The vulnerable logic ran for roughly 17 months. That measures how long nobody looked, not whether it was correct.
Key Addresses and Transactions
| Item | Value |
|---|---|
| Drain transaction (block 40,042,125) | 0xe1947f325e1f0bc22290ea97dc5b2ce44827fbb56e1cc40abaeda1d2ed04f62c |
| Attacker wallet A (sender) | 0xc556ed208285406d71705927f92d4f4eaea579f1 |
| Attacker wallet B | 0x27758c30f8e424fbd8ca605bf31e9496c26ce92c |
| b14g contract (proxy) | 0x04ea61c431f7934d51fed2acb2c5f942213f8967 |
| b14g multisig | 0xff2a4d8f696991d5350b28fd6fbcf3c7b7f94233 |
Sources
- b14g Security Update on X: ~97M CORE withdrawn at 09:29 UTC, contracts paused
- b14g Recovery Update #1 on X: 68,146,177.22 CORE recovered via hard fork, 97,045,563 CORE affected
- Bitquery: b14g Hack, 97M CORE Taken by Asking to Withdraw Zero: mechanism, timeline, 66-minute pause, fund flows and addresses
- ChainClarity: zero-CORE withdrawal emptied 91 b14g positions: transaction receipt details and relay trace
- The Defiant via Cryptonews.net: Core fork recovers 68.15M CORE: Fermi upgrade details and remaining shortfall
- Core public RPC (
rpc.coredao.org): transaction, receipt, calldata decoding, block timestamp and current balances, read by PandaSecurity
Closing Thoughts
The b14g exploit is a clean example of a composability bug. Core's staking contract kept its documented promise, and b14g's withdrawal path had worked for months. The loss came from the seam between them, where a harmless convenience in one contract became a wildcard in another because nobody asked who owned the position. If your protocol wraps staking, vaults or any external system with "magic" inputs, talk to our team or check our audit pricing to get those integration boundaries reviewed before someone else reviews them for you.
原文:https://www.a8q9.cn/en/article/b14g-exploit-withdrawing-zero-core-drained-97m-core-960d6b.html