FeeRouter: setDistributableToken can brick live-sale buys via canonical-asset delisting (DoS vector behind GOVERNOR_ROLE) #127

Closed
opened 2026-05-04 07:07:43 +00:00 by Brouie · 3 comments
Brouie commented 2026-05-04 07:07:43 +00:00 (Migrated from gitlab.com)

found during the post-audit security review pass (followup to #126).

Summary

FeeRouter.setDistributableToken(IERC20 token, bool allowed) (line 112) lets GOVERNOR_ROLE toggle any token in/out of the distribution allowlist with no guards. distributeFees (line 70) reverts on non-allowlisted tokens.

TimeCurve._buy calls feeRouter.distributeFees(acceptedAsset, amount) on every buy. if distributableToken[acceptedAsset] is set to false mid-sale, every subsequent buy() reverts. sale frozen until governor re-allowlists.

Code

FeeRouter.sol:112-116:

function setDistributableToken(IERC20 token, bool allowed) external onlyRole(GOVERNOR_ROLE) {
    require(address(token) != address(0), "FeeRouter: zero token");
    distributableToken[address(token)] = allowed;
    emit DistributableTokenUpdated(address(token), allowed, msg.sender);
}

FeeRouter.sol:73:

require(distributableToken[address(token)], "FeeRouter: token not distributable");

TimeCurve.sol:423-424 (consumes from _buy):

acceptedAsset.safeTransfer(address(feeRouter), amount);
feeRouter.distributeFees(acceptedAsset, amount);

Attack paths

  1. Compromised governor key — adversary calls setDistributableToken(CL8Y, false) → all buys revert. recovery requires governor key recovery + re-allowlist tx. live sale frozen during gap.
  2. Multisig misconfig — governor signs wrong tx (e.g. typo in token address paired with allowed=false) → if the wrong address happens to be CL8Y, same outcome.
  3. Honest mistake during ops — "cleaning up" old test tokens from allowlist and accidentally including CL8Y → same outcome.

during the freeze window:

  • extendDeadline math still ticks
  • WarBow CL8Y burn paths still work (don't go through distributeFees)
  • when sale resumes, deadline may have moved against participants

Severity

Medium — privileged DoS, no fund extraction. but new category not explicitly covered by audit H-01 (which focused on "redirect value" rather than "brick distribution").

Mitigations

pick one or combine:

  1. Hardcode acceptedAsset as immutable distributable — never allowed to be unlisted. requires either an immutable mapping seed or a runtime check that prevents setting distributableToken[canonicalSaleAsset] = false.
  2. Emergency unfreeze path — if canonical asset is removed from allowlist while sale is live, allow a unfreezeFeeRouting admin call that re-allowlists in a single tx. doesn't prevent the DoS, just reduces recovery latency.
  3. Two-step allowlist removal — startUnlistToken + finalizeUnlistToken with a delay window. gives ops time to catch mistakes.

option 1 (hardcode canonical asset) is strongest. option 2 is least invasive change.

Pre-deploy relevance

This is in v1 deployment scope (FeeRouter ships tonight). worth a fix or explicit operational policy (e.g., "governor mnemonic stays cold-stored, only thawed for emergency rotation") before mainnet.

cc @PlasticDigits

found during the post-audit security review pass (followup to #126). ## Summary `FeeRouter.setDistributableToken(IERC20 token, bool allowed)` (line 112) lets `GOVERNOR_ROLE` toggle any token in/out of the distribution allowlist with no guards. `distributeFees` (line 70) reverts on non-allowlisted tokens. `TimeCurve._buy` calls `feeRouter.distributeFees(acceptedAsset, amount)` on every buy. if `distributableToken[acceptedAsset]` is set to `false` mid-sale, every subsequent `buy()` reverts. sale frozen until governor re-allowlists. ## Code `FeeRouter.sol:112-116`: ```solidity function setDistributableToken(IERC20 token, bool allowed) external onlyRole(GOVERNOR_ROLE) { require(address(token) != address(0), "FeeRouter: zero token"); distributableToken[address(token)] = allowed; emit DistributableTokenUpdated(address(token), allowed, msg.sender); } ``` `FeeRouter.sol:73`: ```solidity require(distributableToken[address(token)], "FeeRouter: token not distributable"); ``` `TimeCurve.sol:423-424` (consumes from `_buy`): ```solidity acceptedAsset.safeTransfer(address(feeRouter), amount); feeRouter.distributeFees(acceptedAsset, amount); ``` ## Attack paths 1. **Compromised governor key** — adversary calls `setDistributableToken(CL8Y, false)` → all buys revert. recovery requires governor key recovery + re-allowlist tx. live sale frozen during gap. 2. **Multisig misconfig** — governor signs wrong tx (e.g. typo in token address paired with `allowed=false`) → if the wrong address happens to be CL8Y, same outcome. 3. **Honest mistake during ops** — "cleaning up" old test tokens from allowlist and accidentally including CL8Y → same outcome. during the freeze window: - `extendDeadline` math still ticks - WarBow CL8Y burn paths still work (don't go through `distributeFees`) - when sale resumes, `deadline` may have moved against participants ## Severity **Medium** — privileged DoS, no fund extraction. but new category not explicitly covered by audit H-01 (which focused on "redirect value" rather than "brick distribution"). ## Mitigations pick one or combine: 1. **Hardcode `acceptedAsset` as immutable distributable** — never allowed to be unlisted. requires either an immutable mapping seed or a runtime check that prevents setting `distributableToken[canonicalSaleAsset] = false`. 3. **Emergency unfreeze path** — if canonical asset is removed from allowlist while sale is live, allow a `unfreezeFeeRouting` admin call that re-allowlists in a single tx. doesn't prevent the DoS, just reduces recovery latency. 4. **Two-step allowlist removal** — `startUnlistToken` + `finalizeUnlistToken` with a delay window. gives ops time to catch mistakes. option 1 (hardcode canonical asset) is strongest. option 2 is least invasive change. ## Pre-deploy relevance **This is in v1 deployment scope** (FeeRouter ships tonight). worth a fix or explicit operational policy (e.g., "governor mnemonic stays cold-stored, only thawed for emergency rotation") before mainnet. cc @PlasticDigits
Brouie commented 2026-05-04 07:58:32 +00:00 (Migrated from gitlab.com)

mentioned in issue #130

mentioned in issue #130
Brouie commented 2026-05-04 08:14:03 +00:00 (Migrated from gitlab.com)

mentioned in issue #131

mentioned in issue #131
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-04 08:22:44 +00:00
PlasticDigits commented 2026-05-04 08:22:45 +00:00 (Migrated from gitlab.com)

Acceptable governance risk. Closing

Acceptable governance risk. Closing
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#127
No description provided.