Cecuro prevented Freeze On All GoGoPool Withdrawals. ## TL;DR Cecuro discovered a medium severity vulnerability in GoGoPool's `WithdrawQueue` contract on Avalanche C Chain. By creating and cancelling roughly 1,500 withdrawal requests (costing only ~$310 in gas), an attacker could permanently block `depositFromStaking()`, the only function that fulfills pending AVAX withdrawals. Every user with a pending withdrawal would have their funds stuck until the protocol team manually cleaned the queue at significant expense, and the attacker could repeat the whole process immediately afterward. GoGoPool acknowledged the finding through Immunefi and awarded a $5,000 bounty. ## The Target: GoGoPool's Liquid Staking on Avalanche GoGoPool is a liquid staking protocol on Avalanche. Users deposit AVAX and receive ggAVAX, a liquid staking token that accrues staking rewards over time. When users want their AVAX back, they submit a withdrawal request through the `WithdrawQueue` contract, and a privileged depositor role eventually calls `depositFromStaking()` to return staked AVAX and fulfill those requests in FIFO order. This flow is the backbone of the protocol's liquidity cycle. If `depositFromStaking()` cannot execute, no withdrawal gets fulfilled. Period. ## Two Data Structures, One Inconsistency The root cause sits in how the `WithdrawQueue` contract tracks pending withdrawal requests. It maintains two parallel data structures: | Data Structure | Type | Purpose | |---|---|---| | `pendingRequests` | `EnumerableSet.UintSet` | O(1) membership checks ("is this request still active?") | | `pendingRequestsQueue` | `DoubleEndedQueue.Bytes32Deque` | FIFO processing order for `depositFromStaking()` | When a user submits a withdrawal request, the request ID gets added to both structures. When `depositFromStaking()` processes a request, it pops from the deque and removes from the set. So far, so good. The problem emerges when a user *cancels* a request. The `cancelRequest()` function removes the request ID from the `pendingRequests` set (line 216 in the audited source): ```solidity // cancelRequest() removes from the set... pendingRequests.remove(requestId); // ...but the deque entry stays. There is no arbitrary removal on DoubleEndedQueue. ``` OpenZeppelin's `DoubleEndedQueue` only supports `popFront()` and `popBack()`. There is no way to remove an element from the middle. So the cancelled request ID remains in the deque as what we call a "stale ghost entry." The set says the request no longer exists, but the deque still holds a reference to it. This mismatch is the entire vulnerability. ## How depositFromStaking Handles Stale Entries The `depositFromStaking()` function processes the queue with a bounded while loop: ```solidity while (pendingRequests.length() > 0 && requestsProcessed < maxRequestsPerStakingDeposit) { uint256 requestId = uint256(pendingRequestsQueue.front()); if (!pendingRequests.contains(requestId)) { pendingRequestsQueue.popFront(); emit QueueCleaned(bytes32("REQUEST_NOT_IN_PENDING_SET"), requestId); continue; // requestsProcessed is NOT incremented } // ... process real request, increment requestsProcessed ... } ``` Notice the critical detail: when the loop encounters a stale entry, it pops it from the deque and emits a cleanup event, but it does **not** increment `requestsProcessed`. The loop bound `requestsProcessed < maxRequestsPerStakingDeposit` is supposed to cap gas usage per call. But because stale entry cleanup skips the counter, the loop will keep running through every stale entry it finds before the bound takes effect. If 1,500 stale entries sit in front of a legitimate request, the loop must pop all 1,500 before it can process even one real withdrawal. That blows past the Avalanche C Chain block gas limit of ~8 million gas. ## The Attack: $310 to Freeze Everything The attack is remarkably simple and fully permissionless. Any address can execute it. **Step 1: Deposit a small amount of AVAX to get ggAVAX.** Around 10 AVAX is sufficient. This capital is fully recoverable. **Step 2: Create ~1,500 withdrawal requests at 1 wei of ggAVAX each.** Each `requestUnstake()` call is cheap. The total gas cost across all transactions comes to roughly $310 at typical Avalanche gas prices. **Step 3: Cancel all requests.** The `cancelRequests()` batch function removes them from `pendingRequests` but leaves 1,500 stale entries in `pendingRequestsQueue`. The ggAVAX shares are returned to the attacker on cancellation. **Step 4: There is no step 4.** The damage is done. Now, whenever the depositor role calls `depositFromStaking()`, the function tries to iterate through all 1,500 stale entries in a single transaction. At ~4,000 to 5,300 gas per stale entry at scale, the total gas consumption exceeds the block gas limit every time. The transaction reverts with out of gas. | Attack Parameter | Value | |---|---| | Capital required | ~10 AVAX (fully recoverable) | | Gas cost | ~$310 | | Stale entries created | ~1,500 | | Time to execute | ~30 minutes (1,500 txs at Avalanche speeds) | | Repeatability | Unlimited, after each admin cleanup | ## Measuring the Damage: Gas Scaling Analysis We built a proof of concept on a mainnet fork to measure exact gas consumption at different stale entry counts: | Stale Entries | Gas Used | Gas Per Entry | |---|---|---| | 10 | 203,726 | 18,520 | | 50 | 313,946 | 6,155 | | 100 | 487,846 | 4,830 | | 200 | 835,646 | 4,157 | Gas per stale entry converges to approximately 4,000 to 5,300 as the count increases. Using the conservative figure of 5,300 gas per entry against the 8 million block gas limit: **8,000,000 / 5,300 = ~1,509 stale entries required for complete DoS.** With the optimistic figure of 4,157 gas per entry, it takes around 1,924 entries. Either way, the attacker comfortably reaches the threshold for under $400. The gas per entry curve is worth examining. At 10 entries, each stale pop costs over 18,000 gas because the fixed overhead of the transaction and loop setup is amortized across very few entries. As the count increases, the per entry cost drops toward a floor of roughly 4,000 gas, which represents the actual cost of a single `popFront()` plus the `contains()` check against the `EnumerableSet`. This convergence behavior means the attack becomes more gas efficient the larger it gets, not less. An attacker is incentivized to go big rather than execute smaller, repeated attacks. The proof of concept used Foundry's mainnet fork capability to test against the actual deployed contract state, ensuring the gas measurements reflect real storage layouts and slot access patterns rather than synthetic test conditions. ## Why Recovery Is Painful Once the deque is polluted, the protocol has no clean escape hatch: **No batch cleanup function exists.** There is no admin function that can remove multiple stale entries from the deque in one call. **`getNextPendingRequestAmount()` cleans exactly one stale entry per call.** The admin would need to call this function approximately 1,500 times, costing around $840 in gas, just to clear the backlog. During this entire cleanup window, no withdrawals can be processed. **`pause()` halts the queue but does not clean it.** Pausing prevents new requests but does nothing about the stale entries already in the deque. **`rescueStuckAVAX()` handles stuck AVAX, not stale deque entries.** It is designed for a different failure mode entirely. And after all that cleanup effort, the attacker can immediately re pollute the deque for another $310. The protocol is caught in a loop where recovery always costs more than the next attack. Without a code level fix, operational mitigation alone cannot solve the problem. The team would need to either upgrade the contract to add bounded stale cleanup, or deploy a new implementation behind the proxy that addresses the root cause. ## The Impact While the vulnerable code lives in the `WithdrawQueue` contract, the blast radius hits `TokenggAVAX` (the in scope asset at `0xA25EaF2906FA1a3a13EdAc9B9657108Af7B703e3`) directly: **All pending withdrawal requests cannot be fulfilled.** Users who requested unstaking are stuck waiting indefinitely. **Users' AVAX remains locked in the staking system.** The liquid staking token loses its "liquid" property. **The `DEPOSITOR_ROLE` operator cannot return funds.** Even with AVAX available from completed validation cycles, there is no way to route it to waiting users. **The protocol's entire withdrawal flow is DoS'd.** This is not a degradation. It is a complete halt. Immunefi classified this as Medium severity under the "Unbounded gas consumption" category. ## The Fix The recommended fix is a one line change. Count stale entry pops toward the `requestsProcessed` limit: ```solidity // In depositFromStaking() loop: if (!pendingRequests.contains(requestId)) { pendingRequestsQueue.popFront(); emit QueueCleaned(bytes32("REQUEST_NOT_IN_PENDING_SET"), requestId); requestsProcessed++; // This single line bounds the stale cleanup continue; } ``` By incrementing `requestsProcessed` for stale entries, each `depositFromStaking()` call processes at most `maxRequestsPerStakingDeposit` total entries (stale and real combined). Stale entries get cleaned incrementally across multiple calls instead of all at once, keeping each transaction well within the gas limit. An alternative approach is adding a dedicated `cleanQueue(uint256 maxEntries)` function callable by anyone. This would let the community help clean stale entries without needing the depositor role, and it separates cleanup from the withdrawal fulfillment path entirely. ## Lessons for Smart Contract Developers This vulnerability illustrates a pattern that shows up more often than you might expect: **parallel data structure inconsistency**. Any time two data structures represent overlapping state, every mutation path needs to keep both structures synchronized. If one structure lacks a required operation (like arbitrary removal from a deque), the mismatch becomes a latent vulnerability. Here are the key takeaways: **Bound every loop unconditionally.** The `depositFromStaking` loop had a bound, but stale entry processing bypassed it. Every branch inside a loop that calls `continue` should still count toward the iteration limit. If any path skips the counter, you have an unbounded loop in disguise. This applies beyond deques and sets. Any loop that processes a mixed queue of "real" items and "cleanup" items must count both types toward its gas budget. Otherwise, an attacker controls how many iterations actually execute by manipulating the ratio of cleanup items to real items. **Audit your data structure pairings.** When you maintain two structures for the same logical concept, list every mutation (add, remove, cancel, expire) and verify both structures are updated. If a mutation cannot update one structure due to API limitations, design a mitigation (like counting cleanup toward the loop bound). A useful exercise during code review is building a mutation matrix: rows are operations (create, cancel, fulfill, expire), columns are data structures. Every cell should have an entry. Any blank cell is a potential bug. **Consider the cost asymmetry.** The attacker spends $310 to create a problem that costs the protocol $840+ and significant operational effort to fix. Any time the attack to recovery cost ratio favors the attacker this heavily, the protocol needs either a prevention mechanism or an efficient recovery path. In this case, a minimum deposit threshold on `requestUnstake()` would have increased the attacker's capital requirements significantly. Requiring even 0.1 AVAX per request instead of 1 wei would push the capital requirement from effectively zero to 150 AVAX, changing the economics meaningfully. **Test with adversarial queue states.** Standard test suites typically test the happy path: create request, fulfill request. Adversarial testing means creating requests, cancelling them in specific patterns, and then checking whether core functions still execute within gas limits. We recommend writing dedicated gas analysis tests (as shown in the PoC) that measure gas consumption at multiple scales. If gas grows linearly with some user controllable parameter, calculate the crossover point where it exceeds the block gas limit. If that crossover is reachable at reasonable cost, you have a potential DoS vector. **Design for adversarial cleanup from day one.** Every queue or list that users can populate should have a bounded, efficient cleanup mechanism accessible to privileged roles or even to the public. Relying on a single entry per call cleanup function, as GoGoPool's `getNextPendingRequestAmount()` effectively does, creates an operational bottleneck. A batch cleanup function that processes N entries per call is a simple addition that dramatically improves recovery time and cost. ## Broader Context: Gas DoS as an Underexplored Attack Surface Gas DoS vulnerabilities occupy an interesting position in the smart contract security landscape. They do not drain funds directly, which is why they often receive Medium rather than Critical severity ratings. But the practical impact on users can be severe. In this case, every ggAVAX holder with a pending withdrawal would have been unable to access their AVAX for an indefinite period. The challenge with gas DoS is that it requires thinking about how contracts behave at scale under adversarial conditions. A function might work perfectly with 10 pending requests, pass every test with 100 requests, and still fail catastrophically at 1,500. The vulnerability is not in the logic (the code correctly identifies and cleans stale entries) but in the resource consumption profile of that logic. This class of bug tends to appear wherever protocols use data structures that grow unboundedly with user actions. Staking queues, reward distribution loops, governance vote tallying, NFT enumeration, these all have potential gas scaling concerns. The question is always: can a user manipulate the size of the data structure being iterated, and if so, what does that cost them relative to the damage it causes? Protocols that take gas DoS seriously typically implement three layers of defense. First, they bound every loop with a per call iteration limit that applies to all code paths within the loop, not just the "happy path" branches. Second, they impose minimum thresholds on user actions that populate data structures, making it expensive to create large numbers of entries. Third, they provide efficient administrative cleanup mechanisms so that if pollution does occur, recovery is fast and cheap relative to the attack cost. ## How Cecuro Found This This finding came from Cecuro's automated smart contract analysis, which systematically examines data structure interactions and gas consumption patterns across DeFi protocols. The dual data structure inconsistency between `EnumerableSet` and `DoubleEndedQueue` was flagged as a high risk pattern because OpenZeppelin's deque explicitly lacks arbitrary removal, making any paired usage with a set that supports removal a potential synchronization gap. Gas DoS vulnerabilities through data structure pollution represent a class of bugs that requires analyzing how operations scale under adversarial conditions, not just whether they produce correct outputs. Cecuro's analysis pipelines are designed to catch exactly these kinds of scaling and resource consumption patterns. If you are building or maintaining a DeFi protocol, [run your contracts through Cecuro](https://app.cecuro.ai) to catch vulnerabilities like this before they reach production. Comprehensive security coverage means examining not just correctness but also how your contracts behave when someone actively tries to break them. ## Responsible Disclosure Timeline This vulnerability was reported through GoGoPool's bug bounty program on Immunefi. GoGoPool acknowledged the finding, and a $5,000 bounty was awarded. Cecuro follows responsible disclosure practices: we only publish technical details after the affected protocol has had adequate time to remediate. We believe that sharing the full technical analysis, including working proof of concept code, raises the security bar for the entire ecosystem by helping other developers recognize and prevent similar patterns in their own contracts.