Reproduced Exploit
TempleDAO StaxLPStaking Exploit — Access-Control-Free `migrateStake()` Pool Drain
StaxLPStaking.migrateStake(address oldStaking, uint256 amount) was designed to let a legitimate staker move their balance from a previous staking contract into this one. It intended the caller to be the owner of the funds, and oldStaking to be a trusted predecessor contract whose migrateWithdraw()…
Loss
~$2.3M — 321,154.865 xFraxTempleLP tokens drained from the StaxLPStaking pool
Chain
Ethereum
Category
Access Control
Date
Oct 2022
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2022-10-Templedao_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Templedao_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/access-control/missing-validation
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo does not whole-compile, so this PoC was extracted). Full verbose trace: output.txt. Verified vulnerable source: contracts_StaxLPStaking.sol.
Key info#
| Loss | ~$2.3M — 321,154.865 xFraxTempleLP tokens drained from the StaxLPStaking pool |
| Vulnerable contract | StaxLPStaking — 0xd2869042E12a3506100af1D192b5b04D65137941 |
| Victim pool / asset | xFraxTempleLP — 0xBcB8b7FC9197fEDa75C101fA69d3211b5a30dCD9, held inside StaxLPStaking |
| Attacker EOA | 0x9c9fB3100a2a521985f0c47de3b4598dafD25b01 |
| Attacker contract | 0x2df9C154fE24d081Cfe568645fB4075D725431e0 |
| Attack tx | 0x8c3f442fc6d640a6ff3ea0b12be64f1d4609ea94edd2966f42c01cd9bdcf04b5 |
| Chain / block / date | Ethereum mainnet / 15,725,066 / October 11, 2022 |
| Compiler | Solidity ^0.8.4 (no optimizer details exposed) |
| Bug class | Missing access control / broken trust assumption on an attacker-supplied address (oldStaking) |
TL;DR#
StaxLPStaking.migrateStake(address oldStaking, uint256 amount) was designed to let a legitimate staker
move their balance from a previous staking contract into this one. It intended the caller to be the
owner of the funds, and oldStaking to be a trusted predecessor contract whose migrateWithdraw()
would push those funds across.
Neither assumption is enforced. migrateStake is external with no access control, and oldStaking
is a caller-supplied address. The function blindly trusts the callback
StaxLPStaking(oldStaking).migrateWithdraw(msg.sender, amount) to behave correctly, then calls
_applyStake(msg.sender, amount) which credits the caller with amount of staked balance.
An attacker deployed a throwaway "old staking" contract whose migrateWithdraw() is an empty no-op
stub, then called migrateStake(fakeOld, poolBalance). The stub returns without moving a single real
token, yet _applyStake still mints the attacker a _balances entry equal to the entire xFraxTempleLP
balance sitting in the real StaxLPStaking pool. A follow-up withdrawAll() then pulls all 321,154.86
xFraxTempleLP out of the pool into the attacker's wallet — netting ~$2.3M with zero capital, zero
flash-loan, and one extra transaction.
Background — what StaxLPStaking does#
StaxLPStaking (source) is a Synthetix-style
single-staking rewards pool (a fork of BaseRewardPool / Convex cvxLocker), repurposed by TempleDAO.
Users deposit the stakingToken (here the Curve-style xFraxTempleLP LP token), receive rewards
distributed over a 7-day DURATION, and withdraw later.
The accounting primitives are standard:
_totalSupply— total staked tokens (L24)_balances[account]— per-user stake (L28)_applyStake(_for, _amount)— the only place_totalSupplyand_balancesare incremented on the deposit side (L129-L133):function _applyStake(address _for, uint256 _amount) internal updateReward(_for) { _totalSupply += _amount; _balances[_for] += _amount; emit Staked(_for, _amount); }_withdrawFor(...)(L135-L155) decrements both counters and performsstakingToken.safeTransfer(toAddress, amount).- The deposit path that honest users take,
stakeFor, always pulls tokens in first viastakingToken.safeTransferFrom(msg.sender, address(this), _amount)before_applyStake(L121-L127).
The pool also has a migration subsystem. setMigrator (owner-only) nominates a "new staking contract",
and migrateWithdraw (onlyMigrator) is meant to be called by that new contract to pull a staker's
balance out of this pool during a migration (L255-L257):
function migrateWithdraw(address staker, uint256 amount) external onlyMigrator {
_withdrawFor(staker, msg.sender, amount, true, staker);
}
On-chain state at the fork block (block 15,725,066), read straight off the trace:
| Parameter | Value |
|---|---|
stakingToken | xFraxTempleLP 0xBcB8b7FC9197fEDa75C101fA69d3211b5a30dCD9 |
| xFraxTempleLP balance of the StaxLPStaking pool | 321,154.865567124596801893 (321,154.865 LP) |
migrator | a previously-set, legitimate migrator contract |
| Attacker's pre-exploit xFraxTempleLP balance | 0 |
That pool balance — the honest deposits of real LPs — is the entire prize.
The vulnerable code#
The single function that caused the loss:
/**
* @notice For migrations to a new staking contract:
* 1. User/DApp checks if the user has a balance in the `oldStakingContract`
* 2. If yes, user calls this function `newStakingContract.migrateStake(oldStakingContract, balance)`
* ...
* @param oldStaking The old staking contract funds are being migrated from.
* @param amount The amount to migrate - generally this would be the staker's balance
*/
function migrateStake(address oldStaking, uint256 amount) external {
StaxLPStaking(oldStaking).migrateWithdraw(msg.sender, amount);
_applyStake(msg.sender, amount);
}
— contracts_StaxLPStaking.sol:241-244
Three properties compose into the exploit:
- No access control. The function is
external, callable by any address. There is noonlyMigrator, noonlyOwner, no whitelist, no check thatoldStakingis the previously-registered predecessor. oldStakingis attacker-controlled. It is cast toStaxLPStakingand called via a low-trust external interface — anyone can deploy a contract exposing amigrateWithdraw(address,uint256)stub._applyStakeruns unconditionally after the callback. Whether or not the callback actually moved any real tokens, the caller is creditedamountof staked balance. There is no reconciliation between "tokens this contract actually received" and "stake credit granted."
Contrast with the honest stakeFor path, which always does safeTransferFrom before _applyStake.
migrateStake skips that pull entirely — it trusts the callback to have delivered the funds, but never
verifies it.
Root cause — why it was possible#
migrateStake encodes a trust assumption that the designers never enforced:
"The address passed as
oldStakingis a legitimate predecessor StaxLPStaking whosemigrateWithdrawhas just transferredamountofstakingTokeninto this contract on the caller's behalf."
That assumption is false on two counts, either of which alone is sufficient:
oldStakingis not validated. Nothing checks thatoldStakingis the registered migrator, is a contract the DAO controls, or even is aStaxLPStakingat all. TheStaxLPStaking(oldStaking)cast is purely a typing hint — it does not authenticate the callee.- The callback's effect is not verified. Even if a real predecessor were supplied, the function never
measures
stakingToken.balanceOf(address(this))before and after the call to confirm thatamountactually arrived. So a malicious callee (or a legitimately-buggy one) leaves_applyStaketo mint free stake credit.
The attacker combined both: they supplied their own contract as oldStaking, gave it a
migrateWithdraw that does nothing, and let _applyStake credit them with the entire pool balance.
The resulting _balances[attacker] == poolBalance is fully withdrawable through the normal
withdrawAll path, because _withdrawFor only checks _balances[staker] >= amount — which now passes
trivially — and then does a real stakingToken.safeTransfer of the pool's genuine holdings.
This is the canonical untrusted-callee / missing-access-control pattern: a privileged state mutation
(_applyStake) is gated not by caller authorization or by a verified precondition, but by the return
of an external call to an address the caller chose.
Preconditions#
migrateStakeexists and isexternalwith no guard ✓ (always true on the deployed contract).- The pool holds a nonzero
stakingTokenbalance ✓ — 321,154.865 xFraxTempleLP of honest LP deposits. - Attacker can deploy an EOA-controlled contract exposing
migrateWithdraw(address,uint256)✓ — trivial. - No capital, no flash-loan, no oracle, no timing, no privileged role required. The attack is
permissionless and atomic in two transactions (
migrateStakethenwithdrawAll).
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from output.txt — the Transfer / Staked / Withdrawn
events and storage diffs in the -vvvvv trace at block 15,725,066.
| # | Step | xFraxTempleLP held by attacker | xFraxTempleLP held by pool | Effect |
|---|---|---|---|---|
| 0 | Initial state | 0.000 | 321,154.865 | Honest LP deposits parked in StaxLPStaking. |
| 1 | Deploy fake oldStaking — a contract whose migrateWithdraw(address,uint256) is an empty body | 0.000 | 321,154.865 | Sets up the no-op callback target. |
| 2 | StaxLPStaking.migrateStake(fakeOld, 321,154.865...) — migrateWithdraw is called and returns instantly (no tokens move); then _applyStake(attacker, 321,154.865...) credits the attacker's _balances and bumps _totalSupply again | 0.000 | 321,154.865 | Staked(attacker, 321154.865...) emitted. Attacker now "owns" the whole pool on paper, having deposited nothing. |
| 3 | StaxLPStaking.withdrawAll(false) — _withdrawFor sees _balances[attacker] == 321,154.865... (passes the >= amount check), decrements counters, and does stakingToken.safeTransfer(attacker, 321,154.865...) | 321,154.865 | 0.000 | Withdrawn(attacker, …) + Transfer(pool → attacker, 321,154.865...). Pool emptied. |
Storage-diff corroboration from the trace (the migrateStake leg):
- Slot
3(the_totalSupply-containing region) goes from0x...4401d713e9e597a14165to0x...8803ae27d3cb2f4282ca— i.e._totalSupplydoubles from321,154.865...to642,309.731..., even though no tokens entered the contract. That doubling is the smoking gun: a real deposit would leave_totalSupplyunchanged relative to the token balance, because the sameamountwould be both pulled in and credited. _balances[attacker]slot goes0 → 0x...4401d713e9e597a14165(the full 321,154.865...).
Then in the withdrawAll leg:
- Slot
3returns from0x...8803ae27d3cb2f4282caback to0x...4401d713e9e597a14165(_totalSupplyhalved — the attacker withdrew their phantom stake, but the original honest_totalSupplyis left intact, so honest stakers' accounting still sums correctly even though the underlying tokens are gone). - A genuine
Transferevent moves321,154.865...xFraxTempleLP fromStaxLPStakingto the attacker.
The PoC's final log line confirms the haul exactly:
[End] Attacker xFraxTempleLP balance after exploit: 321154.865567124596801893
Why _withdrawFor did not save the pool#
_withdrawFor only checks _balances[staker] >= amount
(L143). Once _applyStake has minted a
phantom _balances[attacker] == poolBalance, that check passes by construction. There is no
cross-check against the contract's actual stakingToken.balanceOf(address(this)), so the subsequent
stakingToken.safeTransfer(toAddress, amount) happily pays out real tokens that belong to other stakers.
The pool becomes insolvent: _totalSupply (after the attacker's withdrawal) still claims the original
321,154.865 LP are owed to honest stakers, but the contract holds 0 LP.
Profit / loss accounting#
| Direction | xFraxTempleLP |
|---|---|
| Tokens deposited by attacker | 0.000 |
Phantom stake credited by migrateStake | 321,154.865 (non-existent) |
Tokens withdrawn via withdrawAll | 321,154.865 (real, from honest LPs) |
| Net profit | +321,154.865 xFraxTempleLP |
| USD value at the time | ~$2.3M |
The attacker's cost was gas + one contract deployment. No capital was risked and no loan was required.
Diagrams#
Sequence of the attack#
Flowchart of the flawed trust boundary#
State evolution of the pool's token balance vs. accounting#
Remediation#
- Add access control to
migrateStake. The legitimate use case is a DAO-announced migration from a specific predecessor contract. Restrict it to that predecessor:or register the predecessor via an owner-only setter and check it.function migrateStake(address oldStaking, uint256 amount) external { require(oldStaking == approvedOldStaking, "not the registered predecessor"); ... } - Verify the callback's effect, don't trust it. Measure the token balance before and after the
external call and require the delta to equal
amount:This turns the trust assumption into a checked postcondition and neutralizes the bug even if access control is misconfigured.uint256 before = stakingToken.balanceOf(address(this)); StaxLPStaking(oldStaking).migrateWithdraw(msg.sender, amount); require(stakingToken.balanceOf(address(this)) - before == amount, "migration underfunded"); _applyStake(msg.sender, amount); - Pull, don't be pulled. For migrations, have this contract itself call into the registered
predecessor's
migrateWithdrawand handle the transfer — never let a caller-chosen address drive a privileged internal accounting mutation. - Cross-check
_withdrawForagainst real balances. At minimum, reverting whenstakingToken.balanceOf(address(this)) < _totalSupplyduring withdrawals would have turned the drain into a revert once the pool was over-credited (though it would not have prevented the phantom credit itself — fixes 1–2 are the real cure).
The TempleDAO team patched by removing the unauthenticated path and gating migration to the registered migrator only.
How to reproduce#
_shared/run_poc.sh 2022-10-Templedao_exp --mt testExploit -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (fork block 15,725,066 is from October 2022).
foundry.tomluses an Infura mainnet endpoint; most public RPCs prune state this old and fail withheader not found/missing trie node. - The PoC
(test/Templedao_exp.sol) inlines the attacker logic directly in the test
contract: its own
migrateWithdraw(address,uint256){}(L59-L64) is the no-op "fake old staking" callback, soaddress(this)is passed asoldStaking.
Expected tail (output.txt):
[PASS] testExploit() (gas: 145515)
Logs:
[Start] Attacker xFraxTempleLP balance before exploit: 0.000000000000000000
[End] Attacker xFraxTempleLP balance after exploit: 321154.865567124596801893
References: BlockSecTeam, FrankResearcher, Rekt news.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-10-Templedao_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Templedao_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "TempleDAO StaxLPStaking Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.