GHOST-E02 — Third-party dust stake can extend an existing user's warmup expiry

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:

  • VICTIM
  • CONTROL

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.

Thank you for your report!

Your report is precisely right. Let me add some context:

At any point in time, a user can ghost - that is bridge-transfer - right away, without needing to wait for the expiry.

So if someone attacks an honest user, the user can ghost at any point without any issues. That said, you’re right that this behavior could be unexpected from the user’s perspective. To fully mitigate it, we’d need to flip the logic of how it’s checked.

But here’s the catch: we don’t know which route is preferable for the majority of users:

  1. Accumulate tokens from different addresses into one, indefinitely, regardless of expiry, because bridging is available at any point in time.
  2. Require active permission for that kind of behavior - exactly what you’re highlighting in your report - so it becomes a deliberate action.

I don’t think this is a technical question (the fix is dead simple) - it’s more about what the community decides. Any thoughts?

Thanks for the clarification. Good to know ghost() is always available as an alternative way to move the funds.

I think the core issue still stands, though. The impact here isn’t that the funds are permanently locked; it’s that an unrelated third party can change when an existing position becomes claimable without any action from the owner. From the user’s perspective, someone else’s deposit shouldn’t silently change the timing of their existing position.

On the fix, I agree it can be narrower than introducing a new permission flow. If a third-party deposit simply cannot move the expiry of an existing position forward, the existing accumulation behavior and staking for other addresses can remain unchanged. The only behavior removed would be the ability to extend someone else’s existing warmup position through a dust deposit.

That seems like a relatively small behavioral change while preserving the existing use cases. I’d be interested in your thoughts on whether that would be preferable.

Actually, this could be gamified while fully eliminating any behavior reported in your post. For example, the warmup could be increased in accordance with the amount that is locked.

I have no idea how or what exactly - that’s just a fresh idea I had while reading your response. Any ideas on this?

Yeah, I think that could work.

The main thing I’d want to avoid is a small deposit being able to move the expiry of a much larger existing position.

A weighted expiry seems like one way to handle that. For example, a 1 wei deposit into a 1000-token position shouldn’t meaningfully move the existing expiry, while a much larger deposit could have a larger effect.

That would keep accumulation and stake-for-others working, while making the amount of influence on the expiry depend on the amount actually added.

I don’t think the exact formula needs to be decided yet. The main property I’d preserve is that a small unrelated deposit shouldn’t be able to materially extend someone else’s existing warmup.