// SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// Refuse installing an extra Kernel validator after genesis, and refuse /// reconfiguring the root validator that is asking. /// /// THE FOOTGUN. Owner A can `installModule(VALIDATOR, ecdsaValidator, eoa)`. /// That EOA then answers ERC-1271 for V (`0x01‖validator`), which is the /// EIP-3009 drain (`GuardBypassEIP3009`). `appoint` already refuses EOAs; this /// closes the other door. Called from our root validators' `preCheck` (direct /// call / `execute`) and `validateUserOp` (4337). /// /// THE SECOND DOOR, and it was open until 2026-09-10: every root validator here /// reports `isModuleType(4) == true` so the owner keeps a direct-call exit, and /// Kernel's type-4 branches force-call `onInstall` / `onUninstall` with no /// reference to `rootValidator` at all. `uninstallModule(4, root, "")` cleared /// the owner slot — which IS the `AlreadyInitialized` re-install guard — and /// `installModule(4, root, attacker)` then wrote the attacker in as owner. Two /// calls, no signature. So `module == self` is refused on both selectors, and /// `uninstallModule` is on the list at all, which it was not. /// /// `changeRootValidator` is deliberately still permitted: it is A's documented /// unilateral remedy (ADAPTERS_AT_GENESIS.md:246) and it installs the NEW root /// before it re-points, so it never leaves the account without one. What it /// does leave behind is the old validator, still installed and still able to /// answer ERC-1271 — closed on the validator side by `RootBinding`, not here, /// because no calldata filter can see a signature request. /// /// Does not run when `msg.sender` is the account itself (`initConfig`, /// AdapterInstaller). Those paths never install a validator after we deleted /// that genesis self-call. /// /// Residual: a rogue *executor* A installed can `executeFromExecutor` → self /// `installModule(VALIDATOR)`. That is writing a malicious module, not a /// copy-paste of the old agent-validator recipe. library NoExtraValidator { uint256 internal constant MODULE_TYPE_VALIDATOR = 1; uint256 internal constant MODULE_TYPE_EXECUTOR = 2; uint256 internal constant MODULE_TYPE_FALLBACK = 3; bytes4 internal constant INSTALL_MODULE = 0x9517e29f; // installModule(uint256,address,bytes) bytes4 internal constant UNINSTALL_MODULE = 0xa71763a8; // uninstallModule(uint256,address,bytes) bytes4 internal constant INSTALL_VALIDATIONS = 0x9198bdf5; bytes4 internal constant UPGRADE_TO = 0x3659cfe6; // upgradeTo(address) bytes4 internal constant EXECUTE = 0xe9ae5c53; // execute(bytes32,bytes) bytes4 internal constant EXECUTE_USER_OP = 0x8dd7712f; /// `changeRootValidator(bytes21,address,bytes,bytes)`. Allowed on the /// OWNER'S root path — it is the documented escape hatch — and denied for /// the WebAuthn exit caller, which is a different question asked in a /// different place. See WebAuthnRootValidator.preCheck, H-10. bytes4 internal constant CHANGE_ROOT = 0x52141cd9; uint8 internal constant CALLTYPE_SINGLE = 0x00; uint8 internal constant CALLTYPE_BATCH = 0x01; uint8 internal constant CALLTYPE_DELEGATECALL = 0xFF; struct Execution { address target; uint256 value; bytes callData; } error ExtraValidatorForbidden(); /// `installModule` / `uninstallModule` naming the root validator that is /// checking the call. Rotation goes through `changeRootValidator`. error RootValidatorNotReconfigurable(); /// `installModule(3, …)` whose selector data declares CALLTYPE_DELEGATECALL. /// A delegatecall fallback writes the account's storage directly, which is /// strictly worse than the door this library was written to close. error DelegatecallFallbackForbidden(); /// A SELF-CALL THIS FILTER DOES NOT RECOGNISE — M-8 of the round-2 audit. /// /// The list used to be a denylist: anything it did not name fell off the /// end of `_reject` and was permitted. Three things walked through that /// door and none were written down as intentional: /// /// - `upgradeTo(address)` `0x3659cfe6`, which replaces the ENTIRE account /// implementation and is strictly larger than installing a validator; /// - `execute` with an unrecognised call type (`0xFE`, say), where /// `_reject` returned without inspecting the payload at all — the same /// default-open shape the prior audit flagged in the self-call guard, /// leaning on `supportsExecutionMode` advertising CALLTYPE_STATIC while /// `ExecLib` reverts; /// - `installModule(4, , attacker)`, which /// passes the "module != self" test, calls that validator's `onInstall` /// for this account and writes its owner slot PERMANENTLY — so the /// documented escape hatch, `changeRootValidator` to it, reverts /// `AlreadyInitialized` for ever. /// /// So it is an allowlist now. A self-call is permitted only if it is one of /// the shapes this system's own flows produce, and everything else is a /// refusal somebody has to come and widen deliberately. error SelfCallNotRecognised(bytes4 selector); /// `installModule` with a module type this path has no use for. Types 2 and /// 3 are the only ones an account installs through its own root — executors /// (adapters, the installer) and fallbacks. See `SelfCallNotRecognised`. error ModuleTypeForbidden(uint256 moduleType); /// The WebAuthn EXIT CALLER installing or uninstalling a module, at any /// depth — through Kernel (`installModule` / `uninstallModule`) or by /// calling the module's own `onInstall` / `onUninstall` as the account. /// See `_rejectReconfigure`. error ExitCannotReconfigure(bytes4 selector); /// Exit caller rewriting registry/installer policy (setOpsRoot, allowAdapter, /// activateOnly, …) — drops a v2 account back to permissionless v1 activate. error ExitCannotRewritePolicy(bytes4 selector); /// `self` is the ROOT VALIDATOR doing the checking — `address(this)` at /// every call site. It is a parameter rather than derived because this is a /// library: there is no `this` here that means the validator. function reject(address account, address self, bytes calldata msgData) internal pure { _reject(account, self, bytes(msgData), false); } /// The WebAuthn EXIT CALLER's path: same allowlist, but `changeRootValidator` /// is refused even nested inside `execute(self, …)`, and so are /// `installModule` and `uninstallModule` — an executor it installed would /// reach `changeRootValidator` under its own hook, where this never runs. /// Kernel skips `preCheck` on the inner self-call (`msg.sender == /// address(this)`), so the walk has to happen on the outer frame. C-1, and /// finding 2 of the 2026-09-26 audit. function rejectExit(address account, address self, bytes calldata msgData) internal pure { _rejectReconfigure(account, bytes(msgData)); _reject(account, self, bytes(msgData), true); } /// THE EXIT CALLER RECONFIGURES NOTHING — finding 2 of the 2026-09-26 /// audit. H-10 and C-1 refused it `changeRootValidator` at every depth. It /// could still `installModule(2, , )`, which /// `_reject`'s allowlist permits, and that executor's /// `executeFromExecutor(self, changeRootValidator(...))` runs under the /// EXECUTOR's hook — none — so the passkey validator's `preCheck` never saw /// it and the passkey was evicted anyway. An exit moves value out; it does /// not decide what the account runs. /// /// EVERY DEPTH, walking the same shapes `_reject` walks, so /// `execute(self, installModule(...))` is refused like the bare call — the /// nesting C-1 had to close for `changeRootValidator`, and which the /// audit's own suggested fix (a top-level selector check) walked past. /// /// ITS OWN WALK, NOT A BRANCH IN `_reject`, and that is about addresses: /// `_reject` is compiled into every root validator, a root validator's /// address is its bytecode's hash, and it is part of every account's /// address. A branch there moved GatedECDSAValidator and /// ERC1271RootValidator too — and with them every Safe-owned account — /// for a rule only the WebAuthn exit path uses. Kept here, only /// WebAuthnRootValidator changes. // Account-gated policy / installer / curation selectors the exit path must // not drive. Targets are other contracts (registry, installer, curation, // adapters), so the self-only walk above never saw them — finding: exitCaller // zeros setOpsRoot → v1 activate. Caps, LTV ceiling, appoint, migrate, and // pinMarket were the same class and were missing from the denylist. bytes4 internal constant SET_OPS_ROOT = 0xfbf7a623; // setOpsRoot(bytes32,uint8) bytes4 internal constant ARM_OPERATION = 0x7cb35cba; // armOperation(bytes32,bool) bytes4 internal constant ALLOW_ADAPTER = 0xeffa5d6e; // allowAdapter(address) bytes4 internal constant ACTIVATE_ONLY = 0xd739e20e; // activateOnly(address,address[]) bytes4 internal constant REFUSE_ADAPTER = 0xcd264ad7; // refuseAdapter(address) bytes4 internal constant SET_CAP = 0x7aab2c1b; // setCap(address,uint96) bytes4 internal constant SET_CAPS = 0x4d4218ab; // setCaps(address[],uint96[]) bytes4 internal constant SET_LTV_CEILING = 0x825b0e5d; // setLtvCeiling(uint16) bytes4 internal constant APPOINT = 0x37f1a470; // appoint(address) bytes4 internal constant SET_REVOKE_ROOT = 0x21ecffb7; // setRevokeRoot(bytes32) bytes4 internal constant RESET_AUTHORITY = 0x044a737a; // resetAuthority() bytes4 internal constant PIN_MARKET = 0xb27e6b00; // pinMarket(bytes32,uint32) // Both migrateToOwner ABIs (Aave v3 and Morpho) — selector-only denylist. // // THE MORPHO ONE WAS WRONG, and a wrong selector in a denylist fails open: // it read `0x664f1425`, which is no function anywhere in this tree, so the // Morpho migrate was never refused at all. The real ABI takes the four-word // `Migrate` struct. `NoExtraValidatorExit.t.sol` now asserts every constant // here against `Contract.fn.selector`, so this cannot drift again quietly. // // migrateToOwner((address,bytes32,bytes32,address,address,uint256)) bytes4 internal constant MIGRATE_TO_OWNER_AAVE = 0xdf1b3d2c; // migrateToOwner((address,bytes32,bytes32,uint256)) bytes4 internal constant MIGRATE_TO_OWNER_MORPHO = 0x69f9cd6c; /// `RentAdapter.allowDelegatee(bytes32,address)`. Adds a name the keeper's /// OP_DELEGATE may route rent to — it widens what the agent can do, the /// same class as `setCap`. Its inverse, `resetCuration()`, is deliberately /// NOT here: it only removes authority, which an exit is welcome to do. bytes4 internal constant ALLOW_DELEGATEE = 0xd4cc26d6; /// A MODULE'S OWN `onInstall(bytes)` / `onUninstall(bytes)`, called on it /// as the account through `execute` rather than through Kernel's /// `installModule`. See `_rejectReconfigure`. bytes4 internal constant ON_INSTALL = 0x6d61fe70; bytes4 internal constant ON_UNINSTALL = 0x8a91b0e3; function _rejectReconfigure(address account, bytes memory data) private pure { if (data.length < 4) return; bytes4 sel; assembly { sel := mload(add(data, 32)) } if (sel == INSTALL_MODULE || sel == UNINSTALL_MODULE) revert ExitCannotReconfigure(sel); // AND NOT THROUGH THE SIDE DOOR EITHER. The walk above refuses // `installModule`, but a validator's owner slot is written by its // `onInstall`, and `onInstall` keys on `msg.sender` — the account — with // nothing to say it was Kernel asking. So // `execute(ERC1271RootValidator | GatedECDSAValidator, onInstall(attacker))` // squatted that validator's owner slot for this account, and // `onUninstall` on a former root that is still installed, then // `onInstall(attacker)`, re-armed one whose slot was already full. A // later owner rotation back to it then made the attacker root: Kernel // skips `onInstall` when the validator's hook is already set, so the // owner's own validatorData was never written. // // REFUSED FOR EVERY TARGET, not just validators, because there is // nothing to tell them apart with: this is pure calldata, and a list // of validator addresses goes stale the day a new one ships. And // nothing legitimate is lost by it. Kernel's own install path calls // `onInstall` / `onUninstall` from inside `installModule` / // `changeRootValidator` — never through `execute`, so never past this // hook — and both of those are already refused to this caller. An // adapter's `onInstall` refuses a caller that has not installed it // (`_moduleInstalledOnCaller`), the exit tooling (`cmd/ownerexit`) // sends token transfers only, and an adapter's `onUninstall` without // Kernel's uninstall is a refusal of its own (`StillInstalled`) or a // no-op. An exit moves value out; it calls no module's lifecycle. if (sel == ON_INSTALL || sel == ON_UNINSTALL) revert ExitCannotReconfigure(sel); if (_isPolicyRewrite(sel)) revert ExitCannotRewritePolicy(sel); if (sel == EXECUTE_USER_OP) { _rejectReconfigure(account, _tail(data, 4)); return; } // Anything else that is not `execute` is `_reject`'s to rule on. if (sel != EXECUTE) return; (bytes32 mode, bytes memory executionCalldata) = abi.decode(_args(data), (bytes32, bytes)); uint8 callType = uint8(mode[0]); if (callType == CALLTYPE_SINGLE) { bytes memory inner = _singleCallData(executionCalldata); // Walk every target: policy rewrite hits registry/installer, not self. _rejectReconfigure(account, inner); return; } if (callType == CALLTYPE_BATCH) { Execution[] memory calls = abi.decode(executionCalldata, (Execution[])); for (uint256 i; i < calls.length; i++) { _rejectReconfigure(account, calls[i].callData); } } // Any other call type is refused by `_reject` outright. } function _isPolicyRewrite(bytes4 sel) private pure returns (bool) { return sel == SET_OPS_ROOT || sel == ARM_OPERATION || sel == ALLOW_ADAPTER || sel == ACTIVATE_ONLY || sel == REFUSE_ADAPTER || sel == SET_CAP || sel == SET_CAPS || sel == SET_LTV_CEILING || sel == APPOINT || sel == SET_REVOKE_ROOT || sel == RESET_AUTHORITY || sel == PIN_MARKET || sel == MIGRATE_TO_OWNER_AAVE || sel == MIGRATE_TO_OWNER_MORPHO || sel == ALLOW_DELEGATEE; } function _reject(address account, address self, bytes memory data, bool denyChangeRoot) private pure { if (data.length < 4) return; bytes4 sel; assembly { sel := mload(add(data, 32)) } if (sel == INSTALL_MODULE) { if (data.length < 36) return; uint256 moduleType; assembly { moduleType := mload(add(data, 36)) } if (moduleType == MODULE_TYPE_VALIDATOR) revert ExtraValidatorForbidden(); // AND EVERY OTHER TYPE EXCEPT THE TWO THIS SYSTEM USES — M-8. // // Type 4 is the one the audit reproduced: installing ANOTHER // shipped root validator as a HOOK passes the `module != self` test // below, runs that validator's `onInstall` for this account, and // writes its owner slot permanently — after which the escape hatch // this whole design rests on, `changeRootValidator` to that // validator, reverts `AlreadyInitialized` for ever. Types 5 and 6 // are Kernel's permission modules and have no place on a root path // either. Nothing here installs a hook through the root: the one // hook this system ships is placed at genesis. if (moduleType != MODULE_TYPE_EXECUTOR && moduleType != MODULE_TYPE_FALLBACK) { revert ModuleTypeForbidden(moduleType); } if (data.length < 68) return; if (_module(data) == self) revert RootValidatorNotReconfigurable(); if (moduleType == MODULE_TYPE_FALLBACK) _rejectDelegatecallFallback(data); return; } if (sel == UNINSTALL_MODULE) { // Type 1 is already guarded inside Kernel // (`_uninstallValidation` reverts `RootValidatorCannotBeRemoved`); // type 4 is not guarded anywhere, and both arrive here. if (data.length < 68) return; if (_module(data) == self) revert RootValidatorNotReconfigurable(); return; } if (sel == INSTALL_VALIDATIONS) revert ExtraValidatorForbidden(); // THE ACCOUNT'S OWN CODE. `upgradeTo` does everything installing a // validator does and more, and it was not on the old denylist at all — // M-8. Kept here rather than left to the allowlist below so the refusal // names the act. if (sel == UPGRADE_TO) revert ExtraValidatorForbidden(); if (sel == EXECUTE_USER_OP) { if (data.length <= 4) return; uint256 innerLen = data.length - 4; bytes memory inner = new bytes(innerLen); for (uint256 i; i < innerLen; i++) { inner[i] = data[4 + i]; } _reject(account, self, inner, denyChangeRoot); return; } // ROTATION IS ALLOWED HERE AND ONLY HERE. `changeRootValidator` is the // escape hatch the whole design rests on: an owner must be able to // leave. The WebAuthn EXIT CALLER is a different principal and is // refused it separately — see WebAuthnRootValidator.preCheck, H-10. if (sel == CHANGE_ROOT) { if (denyChangeRoot) revert ExtraValidatorForbidden(); return; } // EVERY OTHER SELF-CALL IS REFUSED — M-8. Default-deny, so a selector // nobody has thought about is a revert rather than a pass. if (sel != EXECUTE) revert SelfCallNotRecognised(sel); (bytes32 mode, bytes memory executionCalldata) = abi.decode(_args(data), (bytes32, bytes)); uint8 callType = uint8(mode[0]); if (callType == CALLTYPE_SINGLE) { if (_singleTarget(executionCalldata) != account) return; _reject(account, self, _singleCallData(executionCalldata), denyChangeRoot); return; } if (callType == CALLTYPE_BATCH) { Execution[] memory calls = abi.decode(executionCalldata, (Execution[])); for (uint256 i; i < calls.length; i++) { if (calls[i].target == account) { _reject(account, self, calls[i].callData, denyChangeRoot); } } return; } // DELEGATECALL, AND EVERY CALL TYPE NOBODY HAS NAMED — M-8. This used to // refuse `0xFF` and fall off the end for anything else, so `0xFE` — or // any byte at all — returned without the payload being inspected. A // call type this library cannot decode is a call type it cannot vouch // for. revert ExtraValidatorForbidden(); } /// The `module` argument of install/uninstallModule — the second static /// head word, so a fixed offset on both. function _module(bytes memory data) private pure returns (address module) { uint256 word; assembly { word := mload(add(data, 68)) } module = address(uint160(word)); } /// `installModule(3, target, initData)` installs a FALLBACK handler, and /// `SelectorManager._installSelector` takes its call type from the first /// byte of `selectorData`. `0xFF` is CALLTYPE_DELEGATECALL: the account /// delegatecalls `target` for that selector, so `target` writes the /// account's storage — including the validation storage this library /// exists to protect. /// /// Kernel reads `selectorData` out of `initData` with unchecked offset /// arithmetic; `abi.decode` here does the same arithmetic with bounds /// checks. Where the two could disagree, `abi.decode` reverts and the call /// is refused, so this is never more permissive than Kernel. function _rejectDelegatecallFallback(bytes memory data) private pure { (,, bytes memory initData) = abi.decode(_args(data), (uint256, address, bytes)); // `bytes4 selector ‖ address hook ‖ abi.encode(selectorData, hookData)` if (initData.length <= 24) return; (bytes memory selectorData,) = abi.decode(_tail(initData, 24), (bytes, bytes)); if (selectorData.length == 0) return; if (uint8(selectorData[0]) == CALLTYPE_DELEGATECALL) { revert DelegatecallFallbackForbidden(); } } function _args(bytes memory data) private pure returns (bytes memory out) { return _tail(data, 4); } function _tail(bytes memory data, uint256 from) private pure returns (bytes memory out) { uint256 len = data.length - from; out = new bytes(len); for (uint256 i; i < len; i++) { out[i] = data[from + i]; } } function _singleTarget(bytes memory executionCalldata) private pure returns (address target) { if (executionCalldata.length < 20) return address(0); assembly { target := shr(96, mload(add(executionCalldata, 32))) } } function _singleCallData(bytes memory executionCalldata) private pure returns (bytes memory out) { if (executionCalldata.length <= 52) return ""; return _tail(executionCalldata, 52); } }