Created
March 16, 2026 03:39
-
-
Save denniswon/b7a9698251f6ad4f8c6b4e813bcb465a to your computer and use it in GitHub Desktop.
Vulnerability Report: Missing Ownership Verification in PolicyClientRegistry.registerClient
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Vulnerability Report: Missing Ownership Verification in PolicyClientRegistry.registerClient | |
| Executive Summary | |
| The PolicyClientRegistry.registerClient function lacks verification that the caller is the actual owner of the policy client being registered. This allows any address to register as the "registry owner" of any INewtonPolicyClient contract, enabling them to control the client's active status and block identity linking operations in the dependent IdentityRegistry. | |
| Vulnerability Details | |
| Location | |
| File: PolicyClientRegistry.sol | |
| Function: registerClient(address client) | |
| Lines: 37-50 | |
| Vulnerable Code | |
| solidity | |
| function registerClient( | |
| address client | |
| ) external { | |
| require( | |
| IERC165(client).supportsInterface(type(INewtonPolicyClient).interfaceId), | |
| NotPolicyClient(client) | |
| ); | |
| require(_clients[client].registeredAt == 0, ClientAlreadyRegistered(client)); | |
| _clients[client] = | |
| ClientRecord({owner: msg.sender, active: true, registeredAt: uint64(block.timestamp)}); | |
| _ownerClients[msg.sender].add(client); | |
| emit ClientRegistered(client, msg.sender); | |
| } | |
| Root Cause Analysis | |
| The function performs only two checks before registration: | |
| ERC165 Interface Check: Verifies the client implements INewtonPolicyClient | |
| Duplication Check: Ensures the client hasn't been registered before | |
| Missing: Verification that msg.sender is authorized to register the client. The INewtonPolicyClient interface exposes a getOwner() function, but it is never called during registration. | |
| The consequence is that whoever calls registerClient first becomes the "registry owner" stored in _clients[client].owner, regardless of their relationship to the actual policy client contract. | |
| Impact Analysis | |
| Direct Impact | |
| Once an attacker becomes the registry owner, they gain control over: | |
| deactivateClient(client) - Sets active = false | |
| activateClient(client) - Sets active = true | |
| setClientOwner(client, newOwner) - Transfers registry ownership | |
| Downstream Impact on IdentityRegistry | |
| The IdentityRegistry contract depends on PolicyClientRegistry.isRegisteredClient() to gate identity linking operations: | |
| solidity | |
| function isRegisteredClient( | |
| address client | |
| ) external view returns (bool) { | |
| return _clients[client].registeredAt != 0 && _clients[client].active; | |
| } | |
| When an attacker calls deactivateClient(), this function returns false, causing all identity linking operations to revert at the check: | |
| solidity | |
| require( | |
| IPolicyClientRegistry(policyClientRegistry).isRegisteredClient(_policyClient), | |
| // error | |
| ); | |
| Severity Justification | |
| Factor Assessment Rationale | |
| Impact Medium Causes denial-of-service for identity linking; no direct fund loss | |
| Likelihood High Permissionless function; front-running is standard and trivial | |
| Overall HIGH Combination of feasibility and significant operational disruption | |
| Exploitation Scenarios | |
| Scenario 1: Front-Running Registration | |
| Preconditions: | |
| A legitimate policy client C is deployed and implements INewtonPolicyClient | |
| The rightful owner submits a public mempool transaction to registerClient(C) | |
| Attacker monitors the mempool | |
| Attack Steps: | |
| Attacker observes pending registerClient(C) transaction in mempool | |
| Attacker submits identical registerClient(C) call with higher gas price | |
| Attacker's transaction executes first, storing attacker as _clients[C].owner | |
| Rightful owner's transaction reverts with ClientAlreadyRegistered(C) | |
| Attacker calls deactivateClient(C) | |
| All IdentityRegistry linking operations for client C now revert | |
| Result: Complete denial-of-service for identity linking to client C. | |
| Scenario 2: Preemptive Registration | |
| Preconditions: | |
| A legitimate policy client C is deployed but not yet registered | |
| Attacker discovers C's address before the rightful owner registers | |
| Attack Steps: | |
| Attacker calls registerClient(C) before the rightful owner | |
| Attacker becomes _clients[C].owner | |
| Attacker immediately calls deactivateClient(C) | |
| Rightful owner cannot register (already registered) or activate (not registry owner) | |
| Result: Policy client C is permanently blocked from IdentityRegistry integration without attacker cooperation. | |
| Scenario 3: Silent Squatting with Delayed Activation | |
| Preconditions: | |
| Attacker successfully registers C first (via Scenario 1 or 2) | |
| Attacker keeps active = true initially | |
| Attack Progression: | |
| Attacker registers C and leaves it active | |
| Users begin linking identities through client C | |
| At a strategically chosen time, attacker calls deactivateClient(C) | |
| All new identity linking operations fail | |
| Attacker may demand off-chain concessions to reactivate or transfer ownership | |
| Result: Operational disruption with potential extortion vector. | |
| Recovery Limitations | |
| The vulnerable contract provides no recovery mechanism for hijacked registry records: | |
| No admin override function to reassign ownership | |
| No governance capability to invalidate malicious registrations | |
| The rightful owner cannot fix this on-chain without attacker cooperation | |
| The only recourse would be: | |
| Protocol upgrade/migration to a new registry | |
| Off-chain negotiation with the attacker | |
| Deploying a new policy client contract and registering it correctly | |
| Recommended Fix | |
| Primary Fix: Verify Caller Against Policy Client Owner | |
| Add ownership verification by calling INewtonPolicyClient(client).getOwner(): | |
| solidity | |
| function registerClient( | |
| address client | |
| ) external { | |
| require( | |
| IERC165(client).supportsInterface(type(INewtonPolicyClient).interfaceId), | |
| NotPolicyClient(client) | |
| ); | |
| require(_clients[client].registeredAt == 0, ClientAlreadyRegistered(client)); | |
| require( | |
| INewtonPolicyClient(client).getOwner() == msg.sender, | |
| NotClientOwner(client, msg.sender) | |
| ); | |
| _clients[client] = | |
| ClientRecord({owner: msg.sender, active: true, registeredAt: uint64(block.timestamp)}); | |
| _ownerClients[msg.sender].add(client); | |
| emit ClientRegistered(client, msg.sender); | |
| } | |
| Secondary Fix: Update _requireClientOwner for Consistency | |
| Ensure ongoing ownership checks also verify against the policy client's actual owner: | |
| solidity | |
| function _requireClientOwner( | |
| address client | |
| ) private view { | |
| require(_clients[client].registeredAt != 0, ClientNotRegistered(client)); | |
| require( | |
| INewtonPolicyClient(client).getOwner() == msg.sender, | |
| NotClientOwner(client, msg.sender) | |
| ); | |
| } | |
| This ensures that if the policy client's on-chain owner changes, the registry operations follow the actual ownership rather than stale registry data. | |
| Additional Considerations | |
| Synchronization Between Registry and Policy Client Ownership | |
| The secondary fix introduces a design consideration: should registry ownership be: | |
| Static at registration time (current design intent) - The registry owner is set once and can only be changed via setClientOwner | |
| Dynamic based on policy client owner (secondary fix approach) - Registry operations always defer to INewtonPolicyClient(client).getOwner() | |
| Trade-offs: | |
| Approach Pros Cons | |
| Static Gas efficient for repeated operations; clear ownership model Registry can become out-of-sync with actual client ownership | |
| Dynamic Always reflects true ownership Additional external call per operation; dependency on policy client implementation | |
| Open Questions | |
| Should setClientOwner be removed if using dynamic ownership? If the registry always checks INewtonPolicyClient.getOwner(), the registry's owner field becomes redundant. | |
| What happens if INewtonPolicyClient.getOwner() reverts? The fix assumes this function is always callable. If the policy client can be in a state where getOwner() reverts, all registry operations would also revert. | |
| Should there be an admin recovery mechanism? Consider adding a protocol-owned override for cases where legitimate recovery is needed (e.g., compromised owner keys on the policy client). | |
| Conclusion | |
| The missing ownership verification in registerClient creates a high-severity vulnerability allowing unauthorized parties to control policy client registry records and deny service to identity linking operations. The recommended fix is straightforward: verify msg.sender against INewtonPolicyClient(client).getOwner() before allowing registration. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment