GHOST-E02 — Third-party dust stake can extend an existing user’s warmup expiry
Severity: Medium
Component: ghost-dao-contracts / src/Warmup.sol, src/Staking.sol
Repository: ghost-dao-contracts
Commit: b87dff4
Summary
GhostStaking.stake() allows a caller to stake FTSO for another address.
For recipients that have not enabled locks, an external deposit is accepted and passed to GhostWarmup.addToWarmup() together with a new expiry:
uint48 expiry = epoch.number + warmupPeriod;
IGhostWarmup(warmup).addToWarmup(returnAmount, to, expiry);
GhostWarmup.addToWarmup() aggregates the recipient’s position, but replaces the existing expiry with the expiry of the latest deposit:
info.deposit += payout;
info.payout += ghstPayout;
info.expiry = expiry;
Because of this, a third party can make a negligible deposit, such as 1 wei, to another user’s address and move the expiry of that user’s entire existing warmup position forward.
The attacker does not need any privileged role or access to the victim’s wallet.
Root Cause
The external-deposit check in Staking.stake() only rejects the deposit when the recipient has enabled their lock:
if (locks[to] && to != msg.sender) revert ExternalDepositsLocked();
When the lock is not enabled, the staking contract creates a new expiry and passes it to Warmup.addToWarmup().
Warmup.addToWarmup() stores the position as a single aggregate entry per address. New deposits increase the aggregate deposit and payout, but the expiry is overwritten rather than kept independently per deposit.
This means a small third-party deposit can change the maturity of the recipient’s entire existing position.
Attack Scenario
Assume warmupPeriod = 5.
The victim initially stakes 1000 FTSO at epoch 1:
Victim deposit: 1000 FTSO
Expiry: epoch 6
At epoch 2, an unrelated user deposits only 1 wei to the victim:
stake(1, victim, false, false)
The aggregate position becomes:
Deposit: 1000 FTSO + 1 wei
Expiry: epoch 7
The original 1000 FTSO deposit has therefore become unclaimable at its original expiry even though the victim did not make another deposit.
If the attacker repeats the same action before the new expiry is reached, the victim’s claimable state can be pushed forward again.
Proof of Concept
The attached Foundry PoC reproduces the behavior using the repository contracts.
The main test creates two accounts with the same initial stake:
VICTIMCONTROL
The attacker then deposits 1 wei to the victim.
The test confirms that the victim’s expiry moves from epoch 6 to epoch 7. It then advances to epoch 6 and compares the two accounts: the control account can claim, while the attacked account cannot.
The relevant PoC assertions are:
CONTROL:
claim at epoch 6 -> succeeds
VICTIM:
claim at epoch 6 -> returns 0
warmup deposit -> still present
The PoC also repeats the attack three times and verifies that the victim remains unclaimable after each extension.
The test suite completes successfully:
[PASS] test_strangerExtendsVictimWarmupLock()
[PASS] test_attackRepeatableKeepsVictimLockedForever()
[PASS] test_victimEscapeHatches()
Suite result: ok. 3 passed; 0 failed; 0 skipped
The uploaded PoC contains the full setup and assertions.
Impact
Any user can repeatedly delay another user’s warmup position by spending only a negligible amount of FTSO.
No privileged role is required, and the attacker does not need to control or interact with the victim’s wallet.
The attack affects the victim’s entire aggregated warmup position, not only the attacker’s 1 wei deposit.
The practical result is a griefing/DoS condition where the victim can repeatedly miss the point at which their existing position should become claimable.
The attack can continue for as long as the attacker keeps making third-party deposits before the current expiry.
Existing Lock Mechanism
The contract provides toggleLock() to prevent future external deposits.
However, enabling the lock does not restore an expiry that has already been extended.
For example:
Victim expiry = 6
Attacker deposits 1 wei
Victim expiry = 7
Victim enables external-deposit lock
Future attacker deposits -> reverted
Victim expiry remains 7
Therefore the lock can stop subsequent extensions, but it does not undo an extension that has already occurred.
Why This Is a Security Issue
Allowing users to stake on behalf of another address is an existing feature, and the locks mechanism provides a way for an address to reject future external deposits.
The issue is the effect of an external deposit on an already-existing warmup position.
A third party’s negligible deposit can overwrite the expiry of the recipient’s entire aggregate position. This allows an unrelated user to postpone when someone else’s already-deposited funds become claimable.
The attacker does not need to add a meaningful amount to the victim’s position. A 1 wei deposit is sufficient to move the expiry forward.
This makes the external-deposit primitive usable as a low-cost griefing mechanism against existing warmup positions.
Recommended Fix
The warmup accounting should not allow a third-party deposit to move the expiry of an existing position forward.
A minimal mitigation is to preserve the existing expiry rather than replacing it with the newest expiry:
if (info.expiry == 0 || expiry < info.expiry) {
info.expiry = expiry;
}
A stronger design would track expiry per deposit rather than maintaining a single expiry for all deposits belonging to an address.
The important invariant is:
A third party should not be able to make an existing user’s funds become claimable later merely by depositing a negligible amount into that user’s address.