TempleDAO / STAX migrateStake() Unvalidated oldStaking Drain (2022-10-11)

  • Name: TempleDAO / STAX migrateStake() unvalidated oldStaking drain
  • Date: 2022-10-11
  • Protocol: TempleDAO / STAX Finance
  • Chain: Ethereum
  • Category: migration trust-boundary failure / unvalidated legacy-contract migration / missing source validation / synthetic stake mint
  • Loss: about $2.3M to $2.4M, widely reported as roughly 321,154 xLP later swapped into about 1,831 ETH
  • Summary: STAX’s new staking contract 0xd2869042e12a3506100af1d192b5b04d65137941 tried to support self-service migration from an old staking system. Its migrateStake(address oldStaking, uint256 amount) function called StaxLPStaking(oldStaking).migrateWithdraw(msg.sender, amount) and then immediately credited amount to the caller with _applyStake(msg.sender, amount). That was fatal because oldStaking was completely user-controlled and amount was not independently verified against any canonical legacy balance. The attacker deployed a dummy contract whose migrateWithdraw() did nothing, pointed migrateStake() at it, set amount equal to the real LP balance already sitting inside the STAX staking contract, and then called withdrawAll(false) to pull those live custody tokens out.
  • Trigger / entry point:
    • The vulnerable entry point was StaxLPStaking.migrateStake(address oldStaking, uint256 amount) on the live STAX staking contract.
    • That function accepted a caller-supplied oldStaking address and caller-supplied amount.
    • It performed no check that oldStaking matched a canonical legacy staking contract and no check that amount matched any real prior balance.
    • After the external callback, it unconditionally credited the caller via _applyStake(msg.sender, amount).
  • Root cause:
    • STAX treated migration code as a convenience wrapper instead of as balance-sheet authentication logic.
    • The new contract trusted user input for both where legacy value comes from and how much legacy value exists.
    • Success of an external migration callback was treated as if it proved real assets or stake had arrived from the old system.
    • Because withdraw() and withdrawAll() redeemed internal stake balances for real staking tokens already custodied by the new contract, fake migrated stake was immediately cashable.
  • Exploit path:
    1. The attacker funded EOA 0x9c9fb3100a2a521985f0c47de3b4598dafd25b01, then deployed exploit contract 0x2df9c154fe24d081cfe568645fb4075d725431e0.
    2. The exploit contract exposed a dummy migrateWithdraw(address,uint256) function with the same signature the STAX migration path expected, but the function performed no token transfer and no accounting burn.
    3. The attacker read the real staking-token balance already sitting in STAX staking contract 0xd2869042e12a3506100af1d192b5b04d65137941.
    4. They called migrateStake(address(this), stakingContractBalance), passing the malicious contract as oldStaking and the full live custody balance as amount.
    5. migrateStake() dutifully called the attacker’s no-op migrateWithdraw() and then ran _applyStake(msg.sender, amount), minting an equally large internal stake balance for the attacker without any real migration having happened.
    6. The attacker then called withdrawAll(false), which routed into _withdrawFor(msg.sender, msg.sender, _balances[msg.sender], false, msg.sender).
    7. Because _balances[msg.sender] now matched the full staking-contract balance, _withdrawFor() passed its balance checks and transferred the real LP tokens out of STAX custody to the attacker.
    8. Public reporting states the attacker extracted 321,154 xLP, swapped it for roughly 1.26M FRAX and 1.42M TEMPLE, sold through to about 1,831 ETH, and later moved proceeds onward toward a second holding address.
  • What the attacker needed to be true:
    • migrateStake() had to accept an arbitrary oldStaking address from user input.
    • The new contract had to credit migrated stake after the callback without authenticating that legacy stake had actually been withdrawn or burned.
    • The attacker needed the ability to supply a no-op contract with a matching migrateWithdraw(address,uint256) signature.
    • The staking contract had to already hold withdrawable LP tokens so synthetic stake could be redeemed for real assets.
  • Technical anatomy worth remembering:
    • This was not an arbitrary call bug in the usual router sense. The decisive failure was more specific: STAX let users choose the authority that vouched for their migrated balance.
    • The migration callback did not need to be actively malicious; it only needed to exist and return cleanly. The real loss happened one line later when _applyStake() treated the callback as economic proof.
    • withdrawAll(false) is the line that made the bug monetizable. Without a direct path from internal stake balances to live custody tokens, the migration bug would have been less severe.
    • The exploit is a strong reminder that migration code is part of mint and withdrawal logic. If a new contract can create redeemable stake before proving that old stake was consumed, the migration path itself is the exploit surface.
  • Representative transactions and artifacts:
    • Vulnerable STAX staking contract: 0xd2869042e12a3506100af1d192b5b04d65137941
    • Attacker EOA: 0x9c9fb3100a2a521985f0c47de3b4598dafd25b01
    • Attacker exploit contract: 0x2df9c154fe24d081cfe568645fb4075d725431e0
    • Attack transaction: 0x8c3f442fc6d640a6ff3ea0b12be64f1d4609ea94edd2966f42c01cd9bdcf04b5
    • Funding transaction highlighted by rekt.news: 0x95d85a52ca63c16362cabd872c047fbe1e7ec3a4b0b0509a7e28ae8ccaff68aa
    • Secondary holding address cited by rekt.news: 0x2b63d4a3b2db8acbb2671ea7b16993077f1db5a0
  • Response / mitigation:
    • STAX publicly acknowledged the exploit and stated affected users would be remediated.
    • The obvious code-level mitigation was to stop accepting arbitrary legacy sources and to bind migration logic to a single authenticated old staking contract.
    • The deeper lesson is stricter: migration helpers should not credit redeemable balances until the old system has irreversibly transferred or burned the legacy position being imported.

Pattern tags

  • migration-trust-boundary-failure
  • decommissioning-authority-extinguishment-discipline
  • The new staking contract let the user choose both who certifies the old stake and how large that old stake was, so migration support quietly became a synthetic-stake mint surface.
  • migrateWithdraw() success was treated as if it proved real economic consumption on the legacy side, even though the callback could come from an attacker-owned no-op contract.
  • The old staking surface was socially on its way out, but the successor still accepted it as live redeemable-balance authority, so retirement never happened at the actual balance-sheet boundary.

Why the exploit worked

  • STAX fused migration convenience and balance-sheet authentication into one step. migrateStake() did not treat the old staking system as a tightly pinned source of consumed rights; it treated any contract with the right function signature as a valid witness for legacy stake.
  • The protocol also trusted a caller-declared amount. That meant the exploit was not only fake source contract but fake source contract plus attacker-chosen size, letting the attacker mint internal stake equal to the full live custody balance already held by the new contract.
  • The external callback was only a shape check, not an economic proof. Once migrateWithdraw() returned cleanly, _applyStake() created a redeemable balance without proving that any old entitlement had been burned, withdrawn, or made unavailable for reuse.
  • withdrawAll(false) completed the cash-out path. The new contract already custodied real LP tokens, so counterfeit migrated stake was not just an accounting fiction; it immediately became withdrawable value.
  • The preserved lifecycle lesson is sharper than missing validation: the old system was still allowed to certify money-good replacement balances inside the successor even though migration support should have been treated as a high-risk cutover authority surface.

Detection and audit pivots

  • Treat every migration helper as mint-and-withdraw logic, not as ops glue. Review who may certify legacy value, who sets migrated amount, and what exact event proves the old right was consumed before reviewing convenience or UX flow.
  • Test the migration path with a dummy legacy contract whose callback returns successfully without moving assets. If the new contract still credits stake, shares, or debt capacity, the cutover is already broken.
  • Diff new balance created against old balance destroyed per migrated record. Success of a callback or helper should never be accepted as a substitute for one-time consumption proof.
  • Ask whether the retiring system is still economically authoritative anywhere inside the successor. If a deprecated staking, lock, vesting, or market surface can still certify redeemable balance, the old system remains part of production truth.
  • Review withdrawal paths immediately downstream of migration logic. Bugs like STAX become catastrophic when a synthetic migrated balance can redeem live custody inventory without another provenance check.

Failure-prevention checklist

  • Pin the only acceptable legacy staking contract in immutable or tightly governed storage; do not accept oldStaking from user calldata.
  • Derive migrated amount from canonical legacy-state reads or one-time withdrawal / burn proofs, not from a caller-supplied amount field.
  • Credit successor balances only after the old stake has been irreversibly consumed, transferred, or invalidated, and make that consumed-state proof explicit in code.
  • Bind migration authorization, migrated amount, successor beneficiary, and residual routing to one canonical legacy record instead of partially user-supplied context.
  • Keep ordinary withdrawal paths provenance-aware during migration windows. Newly created balances should still prove why they are cashable against real custody.
  • Narrow legacy support aggressively once migration begins: one pinned source, one bounded cutover path, fast kill switch, and a clear terminal state where the old system no longer certifies value at all.
  • Reconcile successor liabilities against legacy value actually consumed for every migration cohort before large custody balances are left inside the new contract.

Audit prompts

  • What exact legacy record or contract is allowed to certify migrated stake, and can a user change that answer through calldata?
  • What line of code proves the old entitlement was consumed before the new entitlement became withdrawable?
  • If migrateWithdraw() returns success but transfers nothing, what stops the successor from minting stake anyway?
  • Can the user influence migrated amount, replacement beneficiary, refund destination, or legacy source independently, or do all of those answers derive from one canonical stored record?
  • Does the successor already hold live custody assets such that any synthetic migrated balance can be cashed out immediately?
  • During cutover, has the team actually deauthorized the old surface, or does the replacement still treat it as a live balance-certifying authority?

Lessons that should already be standard

  • Migration code is mint code wearing friendlier naming.
  • The user picked the old contract should be treated as obviously unsafe anywhere real value is being imported.
  • Callback success is not proof of economic finality.
  • A retiring system is still live production infrastructure if the successor accepts it as proof of redeemable balance.
  • Replacement balances should not become cashable before the legacy right has been proven spent.
  • Leaving large custody balances in the new contract turns migration-authentication mistakes into immediate treasury drains instead of contained accounting bugs.

Why this incident matters

  • It is a clean exemplar of a migration path becoming a mint primitive.
  • It broadens the exploit corpus beyond the more famous upgrade, bridge, oracle, and reentrancy bugs into a quieter but recurring transition-time danger: contracts often treat migration code as operational glue even though it authenticates real value.
  • It also preserves a useful design lesson for future cutovers: the user picked the old contract should sound as obviously unsafe as the user picked the price oracle.
  • It is also a strong lifecycle-control anchor for decommissioning-authority-extinguishment-discipline: the new staking system looked like the successor, but the old staking surface was still allowed to author redeemable balance truth inside the replacement, so retirement never actually happened at the authority boundary.

Internal linkages

Control surface