Compound cTUSD Legacy-TUSD Double-Entry sweepToken() Exchange-Rate Collapse Near Miss (2022-02-23)

  • Name: Compound cTUSD legacy-TUSD double-entry sweepToken() exchange-rate collapse near miss
  • Date: 2022-02-23
  • Protocol: Compound V2 cTUSD market / TrueUSD
  • Chain: Ethereum
  • Category: asset-identity aliasing / multi-entry token surface / stray-token rescue bypass / exchange-rate collapse / whitehat-coordinated near miss
  • Loss: no realized theft; at patch time Compound’s TUSD market held roughly $88M, OpenZeppelin said a maximal exploit could have roughly halved the redemption value of pre-existing cTUSD, and ChainSecurity estimated one borrow-side variant at roughly $3.1M profit when the issue was first found
  • Summary: Compound’s cTUSD market treated the upgraded TUSD implementation at 0x0000000000085d4780b73119b644ae5ecd22b376 as its underlying asset. But legacy TUSD address 0x8dd5fbce2f6a956c3022ba3663759011dd51e73e still forwarded token logic into the same balance sheet. That meant one economic asset could be reached through two contract addresses. Compound’s generic sweepToken(token) rescue hook only blocked token == underlying. Anyone could therefore pass the legacy TUSD alias, satisfy the token != underlying check, and still make balanceOf(cTUSD) and transfer(admin, balance) operate on the very same TUSD reserves backing cTUSD. Sweeping the market’s cash into Compound’s Timelock would collapse the cTUSD exchange rate by driving totalCash toward zero. An attacker could then mint discounted cTUSD or repay TUSD debt more cheaply before the funds were returned, socializing the loss onto earlier suppliers. TrustToken patched the root cause on 2022-02-23 before public exploitation.
  • Trigger / entry point:
    • The vulnerable path was Compound V2’s standard CErc20::sweepToken(EIP20NonStandardInterface token) helper on the cTUSD market.
    • sweepToken() was intended to rescue stray ERC-20 balances accidentally sent to the cToken contract.
    • The only asset-identity guard was require(address(token) != underlying, "CErc20::sweepToken: can not sweep underlying token").
    • For most markets that exact-address check is enough. For TUSD it was not, because the legacy TUSD address and the newer configured TUSD implementation both mutated the same balances.
  • Root cause:
    • Compound modeled token address as if it were a canonical asset identity.
    • TUSD’s legacy forwarding contract and newer implementation contract were economically the same asset surface but presented two distinct addresses to integrators.
    • sweepToken() used exact address inequality to distinguish underlying asset from stray asset.
    • Once that identity boundary failed, a housekeeping helper inherited authority to move the market’s actual reserves.
    • The downstream economic damage came from Compound’s exchange-rate formula: if totalCash is swept away, newly minted cTUSD prices against a temporarily broken backing ratio.
  • Exploit path:
    1. The attacker identifies that cTUSD is configured with the newer TUSD address as underlying, while legacy TUSD address 0x8dd5...e73e still forwards to the same balances.
    2. They call cTUSD.sweepToken(legacyTUSD) instead of passing the configured underlying address.
    3. The require(address(token) != underlying) check passes because the alias address differs from the configured address.
    4. legacyTUSD.balanceOf(address(cTUSD)) still returns the market’s real TUSD cash balance, because both addresses expose the same balance sheet.
    5. legacyTUSD.transfer(admin, balance) moves that balance to Compound’s admin / Timelock destination as if it were stray inventory.
    6. cTUSD now reports far less or zero totalCash, so the exchange rate (cash + borrows - reserves) / totalSupply collapses.
    7. Before the swept TUSD is returned, the attacker can either mint fresh cTUSD at the depressed exchange rate or repay TUSD borrows more cheaply than the pre-sweep market state should allow.
    8. When the swept TUSD is later restored, the attacker exits against the recovered exchange rate and the loss is borne by pre-existing cTUSD suppliers.
  • What the attacker needed to be true:
    • The same economic asset had to be reachable through at least two contract addresses.
    • Compound had to key the cannot sweep underlying rule to exact address equality rather than canonicalized asset identity.
    • The market had to keep sweepToken() permissionless.
    • The swept funds had to be returnable later, so the attacker could monetize a temporary exchange-rate dislocation rather than merely grief the market.
    • TUSD did not need to be enabled as collateral for the core mint-at-a-discount path; the exploit primarily targeted supplier exchange-rate fairness.
  • Technical anatomy worth remembering:
    • This was not a bug in Compound’s interest model alone and not a direct bug in either TUSD address in isolation. The exploit lived in the integration seam between a two-address token surface and a generic cToken helper written for one-address asset identity.
    • The dangerous helper was not a core mint or borrow function. It was a maintenance function intended for token rescue. That is exactly why the note matters: low-frequency admin or rescue code can still become solvency-critical when asset identity is modeled too narrowly.
    • The attack did not permanently steal funds out to the attacker in one step. First it relocated market cash to Compound’s Timelock, then monetized the temporary accounting distortion through cheaper minting or repayment.
    • ChainSecurity’s write-up also preserved a liquidation-theory branch, but TUSD was not collateral-enabled on Compound, so the most credible live monetization paths were the cheap-repayment and discounted-mint branches.
    • OpenZeppelin concluded the issue was systemic, not Compound-specific: TUSD was listed across dozens of DeFi protocols, and variants of the same double-entry token assumption failure appeared elsewhere.
  • Representative contracts and artifacts:
    • Legacy TUSD forwarding address: 0x8dd5fbce2f6a956c3022ba3663759011dd51e73e
    • Newer TUSD implementation used by Compound as underlying: 0x0000000000085d4780b73119b644ae5ecd22b376
    • Compound cTUSD market: cTUSD / Compound V2 TUSD market
    • Vulnerable helper shape:
      function sweepToken(EIP20NonStandardInterface token) override external {
          require(address(token) != underlying, "CErc20::sweepToken: can not sweep underlying token");
          uint256 balance = token.balanceOf(address(this));
          token.transfer(admin, balance);
      }
    • Root-cause patch transaction cited by OpenZeppelin: 0x125823f2914e4f14e06b9b1b30fe9dd9512b36354cc1f6c063118c4fe03d8287
  • Response / mitigation:
    • ChainSecurity found the issue during an audit of Compound’s cToken contracts and privately disclosed it.
    • OpenZeppelin later reviewed the same issue during Compound’s comprehensive audit and concluded variants could affect other TUSD integrations too.
    • Proposal 84, which would have modified the TUSD market through normal Compound governance, was discouraged and ultimately defeated because publicly changing cTUSD risked signaling the vulnerability before a fix was in place.
    • OpenZeppelin coordinated with Compound’s multisig / Pause Guardian and TrustToken on a short-fuse plan: if TrustToken did not commit to a root-cause patch within 24 hours, Compound would pause the TUSD market.
    • TrustToken instead patched the issue at the token layer on 2022-02-23, eliminating the dangerous legacy entry point before public disclosure.
    • OpenZeppelin disclosed the retrospective publicly on 2022-03-21 after the fix was live and no funds had been lost.
  • Key transaction hashes:
    • Root-cause TUSD patch tx: 0x125823f2914e4f14e06b9b1b30fe9dd9512b36354cc1f6c063118c4fe03d8287
    • No public exploit transactions exist because the issue was fixed before a live attack.

Pattern tags:

  • Asset-identity aliasing / multi-entry token surface: Compound special-cased only one TUSD address even though legacy and upgraded TUSD entrypoints still touched one economic balance sheet.
  • Housekeeping-helper solvency crossover: a generic sweepToken() rescue verb quietly sat on the market’s reserve boundary once not underlying stopped meaning not the same asset.
  • Temporary reserve-removal monetization: the exploit did not need to steal reserves directly on the first call; it only needed to relocate cTUSD cash long enough for exchange-rate math to publish a cheaper mint / repayment state.
  • Canonical-asset versus raw-address policy drift: Compound’s configured underlying field described one preferred entrypoint, but the protocol had no canonical asset object spanning all TUSD aliases.
  • Design companions: asset-identity-aliasing-multi-entry-token-surface, reserve-backed-claim-truth-security-object, entrypoint-equivalence-under-shared-standards, and unsupported-standard-verb-revocation-and-fail-closed-surface.

Why the exploit worked

  • Compound treated underlying address as a full proof of asset identity. That was true for ordinary ERC-20 listings, but TUSD’s legacy forwarding surface meant one economic reserve could still be reached through two addresses.
  • sweepToken() was written as if different token address implied stray inventory. On cTUSD, that assumption failed: the legacy address satisfied the guard while still exposing the market’s real TUSD balance.
  • The dangerous power sat in a helper that looked operational, not in a bespoke admin drain path. Once the alias bypass succeeded, the helper inherited authority to move the same cash input that exchangeRateStoredInternal() used to price all live cTUSD claims.
  • The near miss also depended on reversibility. Swept funds went to Compound’s Timelock rather than directly to the attacker, but that was still enough because a temporary reserve hole was economically actionable: minting or repaying against the depressed exchange rate would have crystallized loss onto incumbent suppliers.
  • TrustToken’s token-layer patch mattered because the root cause was not merely Compound forgot an extra denylist address; it was that the TUSD asset family itself still exposed a second production entry surface that downstream protocols could misclassify.

Detection and audit pivots

  • For every listed asset, ask whether underlying really means one callable address or one economic asset family. If the token has forwarders, deprecated facades, migration shims, or proxy-era aliases, raw address equality is already too weak for policy.
  • Re-review every rescue, sweep, arbitrary-token, and unsupported token helper against alias surfaces. The sharp question is not can this call move the configured underlying address? but can this call move the same reserve through any other live entrypoint?
  • Trace how reserve movement propagates into pricing or claim issuance. On Compound the important follow-on question was which math uses totalCash next, and who can mint / repay against it before reserves are restored?
  • Test housekeeping verbs under temporary-balance-distortion scenarios, not only direct theft scenarios. A helper that can evacuate reserves to a safe admin wallet can still be exploitable if exchange-rate, collateral, or repayment logic honors the interim state.
  • Treat token-upgrade backward-compatibility as integration state, not token-team trivia. If a token preserves old entrypoints for UX continuity, downstream listings need that alias inventory before markets go live.

Failure-prevention checklist

  • Model listed assets as canonical asset objects, not single addresses. underlying, listed, paused, and cannot sweep logic should collapse every known alias or legacy forwarding surface into one protected identity.
  • Treat sweepToken()-style helpers as reserve code whenever they can touch balances that back live cTokens, vault shares, or collateral claims.
  • Require rescue helpers to prove an invariant stronger than token != underlying, such as this transfer cannot reduce the reserve that prices live claims.
  • Maintain an alias dossier for nontrivial integrated assets: canonical address, legacy addresses, forwarding behavior, shared balances, and which surfaces remain callable in production.
  • Re-test listed markets whenever an integrated token upgrades, adds compatibility forwarding, or changes how legacy entrypoints route into current logic.
  • Prefer root-cause deauthorization upstream when possible. If a deprecated token surface can still move real balances, either disable it or treat it as part of every downstream protocol’s live attack surface.
  • Bound the blast radius of reserve-moving helpers with permissioning, pauses, or market-specific safeguards when a token family is known to have unusual identity or upgrade semantics.
  • Monitor for reserve movements initiated through unexpected token addresses, not just unexpected EOAs or admin paths.

Audit prompts

  • Which listed assets in the protocol can currently be reached through more than one contract address that shares balances, allowances, or transfer effects?
  • Where does the code still equate different address with different asset, especially in rescue, sweep, listing, pause, and unsupported-token paths?
  • Can any helper move the same reserve that prices exchange rate, collateral value, or claim redemption through an alias the protocol does not classify as the underlying?
  • If a reserve-moving helper sends funds to a trusted destination rather than the attacker, does any mint, repay, redeem, liquidation, or oracle path still treat the temporary post-transfer state as economically final?
  • Who owns the alias inventory for integrated tokens, and what operational trigger forces that inventory to be updated when a token team adds or preserves a new compatibility surface?
  • What test proves that every protected asset policy stays true if the caller supplies any documented alternate entrypoint for the same token family?
  • If the token team promises that a legacy surface is deprecated, what onchain fact proves it no longer reaches production balances?
  • Could pausing the market or helper fast enough actually stop the monetization leg, or would a temporary reserve distortion already be enough to let attackers mint or repay at a bad rate before operators react?

Lessons that should already be standard

  • Asset identity is not always address identity. When one balance sheet answers through multiple token addresses, address-level deny rules become naming conventions, not security boundaries.
  • Rescue code is solvency code the moment it can move reserves that price live claims.
  • Funds only move to the timelock is not a safety argument if other protocol math treats the interim reserve hole as a real market state.
  • Backward-compatibility token surfaces are part of the downstream protocol’s threat model whether integrators asked for them or not.
  • Token upgrades should trigger integration re-certification, not just a note that the preferred address still works.
  • If a token family cannot present one unambiguous production entrypoint, downstream protocols need canonicalization and stronger invariant checks before they expose generic helper authority around it.

Why this incident matters

  • It is a clean example of asset identity aliasing: the protocol did not misprice a fake token or admit a malicious wrapper; it mis-modeled the fact that the same asset could answer to two addresses.
  • It shows how seemingly harmless rescue hooks can become balance-sheet critical once underlying and not underlying are separated by address equality alone.
  • It also preserves an important integration lesson for upgradeable or legacy-token ecosystems: same balances, different address is not benign metadata. It can invalidate deny-lists, allowlists, and housekeeping guards across every downstream protocol.

Internal linkages

Control surface