Reproduced Exploit

Centrifuge — any caller can spend an opened vault through the router

open(vault) sets the router as an endorsed operator for the caller, which is how permissionless claims are supposed to work. requestDeposit is also permissionless, and it does not require owner == initiator. Anyone can pass that owner's address and a controller they control. The vault sees msg.send…

Jul 2024•Other•access-control•4 min read

Chain

Other

Category

access-control

Date

Jul 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 35789-centrifuge-router-can-perform-untrusted-actions-on-behalf-of. Standalone Foundry PoC and full write-up: 35789-centrifuge-router-can-perform-untrusted-actions-on-behalf-of_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/access-control/missing-modifier · vuln/logic/missing-check

Reproduction: cheatcode-free local synthetic. After the owner calls open, anyone can requestDeposit with that owner and their own controller. 1,000 USDC moves. Full trace: output.txt. PoC: test/35789-centrifuge-router-can-perform-untrusted-actions-on-behalf-of_exp.sol.

AuditVault taxonomy: lang/solidity · sector/rwa · sector/token · platform/spearbit · has/github · severity/high · genome: missing-modifier · variant · role-bypass · account-ownership

Key info#

ProtocolCentrifuge VaultRouter
FindingSpearbit Centrifuge review, July 2024, AuditVault 35789
Vulnerable codeVaultRouter.requestDeposit forwards owner with no initiator check
Harm1,000 USDC leaves an owner who had called open. The attacker is the controller and claims it
Chainlocal synthetic

TL;DR#

open(vault) sets the router as an endorsed operator for the caller, which is how permissionless claims are supposed to work. requestDeposit is also permissionless, and it does not require owner == initiator. Anyone can pass that owner's address and a controller they control. The vault sees msg.sender as the router, the operator check passes, and it pulls the owner's assets. The attacker then claims the escrow.

The vulnerable code#

Audited router shape from the Spearbit review. open endorses the router for _initiator(). requestDeposit does not look at _initiator() before it calls the vault.

SOLIDITY
function open(address vault) public protected {
    IERC7540Vault(vault).setEndorsedOperator(_initiator(), true);
}

function requestDeposit(address vault, uint256 amount, address controller, address owner, uint256 topUpAmount)
    external
    payable
    protected
{
    if (owner == address(this)) {
        _approveMax(asset, vault);
    }
    _pay(topUpAmount);
    // @> VULN: any caller can pass another owner's address. The router is already that owner's operator.
    IERC7540Vault(vault).requestDeposit(amount, controller, owner);
    // FIX: require(owner == _initiator() || owner == address(this));
}

The vault accepts the call because isOperator[owner][router] is true, then transferFroms the owner.

Root cause#

Endorsing the router is a delegation to a contract, not to every user of that contract. The router forwards owner and controller from calldata. protected only records who called. It does not compare that caller to owner. So the operator bit, which was meant to let the owner act through the router, lets every other caller act as the owner.

Preconditions#

The owner has called open on the vault and still has it open. The owner holds 1,000 USDC and has approved the vault. The attacker is any address. No router admin key is required.

Attack walkthrough#

  1. The owner holds 1,000 USDC approved to the vault and calls open. isOperator[owner][router] becomes true.
  2. The attacker calls requestDeposit(vault, 1000, attacker, owner, 0).
  3. The router does not revert. It calls vault.requestDeposit(1000, attacker, owner).
  4. The vault's operator check passes. transferFrom moves 1,000 USDC from the owner into the vault. claimable[attacker] is 1,000 USDC.
  5. The attacker calls claim. The vault pays the attacker 1,000 USDC. The owner's balance is 0.

The guarded control adds owner == caller || owner == router before the vault call. The same request reverts. The owner still holds 1,000 USDC. Nothing is escrowed.

Diagrams#

flowchart TD A["Owner calls open on the vault"] --> B["Router is the owner's operator"] B --> C["Attacker calls requestDeposit"] C --> D["Router does not check the caller against owner"] D --> E["Vault pulls 1000 USDC from the owner"] E --> F["Attacker is stored as the controller"] F --> G["Attacker claims the escrow"] G --> H["Attacker holds 1000 USDC"]

Impact#

The owner loses 1,000 USDC. The attacker receives it by naming themselves controller of a request the owner did not sign. The same pattern applies to requestRedeem in the review: tranche tokens can be redeemed to an arbitrary controller. This run shows the deposit path, which moves the asset.

Remediation#

Before calling the vault, require owner == _initiator() || owner == address(this). Centrifuge fixed this in commit 3b21650d.

How to reproduce#

BASH
cd 35789-centrifuge-router-can-perform-untrusted-actions-on-behalf-of_exp
forge test -vv

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.