All audits
FailedEthereumIndependent

Prisma migrateTroveZap (2024)

Smart contract security audit by TierZero · 2024-03-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

1
Critical
2
High
2
Medium
1
Low
1
Informational

Independent research — not a commissioned audit.

What the protocol did

Prisma Finance is an Ethereum collateralized-debt-position (CDP) protocol modeled on Liquity: users lock LST collateral (wstETH, rETH, cbETH, and similar) into a Trove, mint the mkUSD stablecoin against it, and manage the position through a BorrowerOperations contract. Prisma extended the Liquity design with a delegate-approval system — a user can authorize a helper contract to call trove-management functions (open, adjust, close) on their behalf, which is what makes one-click "zap" contracts possible: a single transaction that unwraps, swaps, or migrates collateral and then touches the trove without the user manually sequencing three or four calls themselves.

In March 2024 the team shipped a new periphery contract, a trove-migration zap, meant to let users move a trove from one collateral market to another in a single transaction. It went live, was used briefly, and on March 28, 2024 an attacker used it to drain troves across multiple collateral markets, with losses reported in the region of $11.6 million in mkUSD and LST collateral. This is the pattern we see over and over in periphery/zap code: the core protocol had already been reviewed, the new convenience contract had not been held to the same bar, and it sat on top of allowances and delegate rights that made it far more dangerous than its size suggested.

The vulnerability

The bug class is an unchecked arbitrary external call driven by caller-supplied input — the zap exposed a low-level execution path that took a target address and calldata from msg.sender and forwarded them via .call() without constraining either the destination or the function selector being invoked. That would be a survivable bug in isolation. It became a critical one because the zap contract was not an isolated actor: it held live token allowances from users (granted so it could pull collateral during migration) and/or delegate approval to act on users' troves. An arbitrary-call primitive sitting inside a contract with standing privilege over other users' assets is a drain, not a curiosity.

The flawed shape looks like this:

// Illustrative — the flawed pattern, not the literal source
function migrateTrove(
    address target,
    bytes calldata data,
    uint256 troveId
) external {
    // zap already holds ERC20 allowances / delegate rights from many users
    (bool ok, ) = target.call(data);
    require(ok, "migration step failed");
    // ... continue migration using zap's own balances/allowances
}

Nothing here checks that target is a whitelisted contract the zap is supposed to talk to, and nothing checks that data decodes to a selector the migration flow is supposed to invoke. Any address, any function.

How the exploit worked

  1. The attacker identified that the migration zap's call-forwarding entry point placed no restriction on target or data.
  2. Because other users had approved the zap (or granted it delegate rights) as part of a normal migration flow, the zap contract itself held spending power over their collateral.
  3. The attacker crafted data equal to an ERC20 transferFrom call — or the trove-manager equivalent — naming a victim's address as from and their own address as to, and set target to the collateral token or trove-manager contract.
  4. The zap executed the call as itself, and because it was an approved spender / approved delegate, the call succeeded: collateral and mkUSD moved out of victims' troves and wallets into the attacker's control.
  5. The attacker repeated this across the collateral markets the zap served, then moved funds through DEXs before the team could pause the contract. A significant portion was later returned after on-chain negotiation, but the exploit itself was a single-transaction drain enabled entirely by the missing target/selector check.

This is structurally the same mistake behind the Furucombo hack (2021) and a recurring theme in zap/router contracts generally — the "arbitrary call" primitive is fine for a contract with no privilege, and a hazard for one that inherits allowances or delegate rights from its users.

How an audit catches this

This is not a subtle bug to find; it is a subtle bug to think to look for, because reviewers often treat the core lending contracts as the attack surface and wave through "just a zap." Our process on any periphery contract explicitly asks:

  • Does any function forward caller-controlled target/data into .call(), .delegatecall(), or a low-level staticcall? Flag every instance regardless of apparent low severity.
  • Does this contract hold — even transiently — token allowances, delegate approvals, or custody from third parties other than msg.sender? If yes, any arbitrary-call primitive in the same contract is automatically critical, not medium.
  • Write a proof-of-concept test that calls the entry point with target set to a token the contract has an allowance for, and data set to transferFrom(victim, attacker, amount). If it succeeds, the finding is proven, not theoretical.
  • Check for an explicit allowlist of callable targets and, where selectors matter, a selector allowlist too — absence of both is the tell.
  • Confirm allowances granted to periphery contracts are scoped and revoked/reset after use rather than left standing indefinitely.

We run the same discipline on EVM router/zap contracts that we apply to composability risk on other chains — the underlying lesson about unchecked cross-contract calls inheriting ambient privilege is the same one covered in our piece on cross-program invocation risk in Solana DeFi, and the general checklist we use for this class of review is laid out in our program security audit checklist for trading systems.

Remediation

  • Never forward caller-supplied target/data into a raw external call from a contract that holds allowances, delegate rights, or custody on behalf of other users. If generic composability is genuinely required, restrict target to an allowlist set by governance, and restrict data to a selector allowlist.
  • Prefer typed, explicit functions (swapAndMigrate(tokenIn, tokenOut, amountIn, minOut)) over generic call-forwarding in any contract that touches third-party funds.
  • Scope allowances to periphery contracts tightly: approve only the amount needed for the immediate transaction, and where the token standard allows, use permit-style one-shot approvals instead of standing approve calls — a pattern we cover in more depth in our note on Permit2-style gasless approvals and their sniping-bot exposure.
  • Ship new periphery contracts through the same review bar as core contracts before they touch real funds — a new zap is not lower risk just because it's smaller; it's often higher risk because it aggregates privilege.
  • Have a tested pause/circuit-breaker on any contract with delegate rights, and monitoring that alerts on abnormal transferFrom volume through a periphery contract within its first hours live.

If your protocol has a zap, router, or any other contract that inherits user allowances or delegate rights, that is exactly the surface our smart contract audit engagement is built to stress-test before it goes live.

Thinking about a review like this for your own contracts? Get a smart contract audit from an independent team.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit