Created
March 15, 2026 19:36
-
-
Save denniswon/1b9e6911431ef9d49f67ec84becf32df to your computer and use it in GitHub Desktop.
Security Report: Cross-Chain Challenge Slashing Vulnerability
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
| Security Report: Cross-Chain Challenge Slashing Vulnerability | |
| Executive Summary | |
| A critical vulnerability exists in the slashForCrossChainChallenge function of ChallengeVerifier.sol that allows arbitrary slashing of operators on the source chain. The function fails to bind the ZK proof's public values to the caller-supplied inputs and accepts an unverified non-signer list, enabling attackers to slash any chosen operator set without legitimate grounds. | |
| Vulnerability Details | |
| Location | |
| File: ChallengeVerifier.sol | |
| Function: slashForCrossChainChallenge (Lines 190-259) | |
| Accessible via: NewtonProverTaskManagerShared.slashForCrossChainChallenge (Lines 160-189) | |
| Root Cause Analysis | |
| The vulnerability stems from three distinct missing validations: | |
| 1. No Binding Between Proof and Inputs | |
| After verifying the SP1 proof, the contract extracts context but never validates that it matches the caller-supplied parameters: | |
| solidity | |
| IRegoVerifier.RegoContext memory context = | |
| RegoVerifier(regoVerifier).verifyRegoProof(challenge.data, challenge.proof); | |
| // Verify proof output mismatches the task response (challenge is valid) | |
| bool challengeSuccess = keccak256(abi.encode(context.evaluation)) | |
| != keccak256(abi.encode(taskResponse.evaluationResult)); | |
| require(challengeSuccess, ChallengeFailed()); | |
| The code only checks that context.evaluation != taskResponse.evaluationResult. It does not verify: | |
| context.task == task | |
| context.taskResponse == taskResponse | |
| context.entrypoint == policy.getEntrypoint() | |
| An attacker can submit a valid proof generated for an entirely different task/response pair, as long as the evaluation values differ. | |
| 2. No Operator Signature/Certificate Verification | |
| The function accepts pubkeysOfNonSigningOperators directly from the caller without any verification: | |
| solidity | |
| (, address[] memory addressOfNonSigningOperators) = | |
| ChallengeLib.processNonSigners(pubkeysOfNonSigningOperators, blsApkRegistry); | |
| Unlike raiseAndResolveChallenge, which validates non-signers against responseCertificate.hashOfNonSigners: | |
| solidity | |
| // In raiseAndResolveChallenge (correct implementation): | |
| ChallengeLib.validateSignatoryRecord( | |
| task.taskCreatedBlock, | |
| hashesOfPubkeysOfNonSigningOperators, | |
| responseCertificate.hashOfNonSigners | |
| ); | |
| The cross-chain function has no equivalent validation, allowing attackers to supply arbitrary pubkeys or an empty list. | |
| 3. Attacker Controls Slashing Parameters | |
| The task parameter is entirely attacker-controlled: | |
| solidity | |
| ChallengeLib.slashSigningOperators( | |
| ctx, task.quorumNumbers, task.taskCreatedBlock, addressOfNonSigningOperators | |
| ); | |
| The attacker chooses: | |
| task.quorumNumbers — which operator sets to slash | |
| task.taskCreatedBlock — which historical block to reference for operator membership | |
| addressOfNonSigningOperators — who to exclude from slashing | |
| Attack Flow | |
| Step-by-Step Exploitation | |
| Attacker identifies target operators | |
| Select any operator set registered in registryCoordinator | |
| Choose a taskCreatedBlock when target operators were active | |
| Attacker crafts malicious inputs | |
| solidity | |
| Task memory fakeTask = Task({ | |
| taskId: bytes32(0x1234...), | |
| taskCreatedBlock: TARGET_BLOCK, | |
| quorumNumbers: TARGET_QUORUM_BYTES, | |
| // ... other fields are arbitrary | |
| }); | |
| TaskResponse memory fakeResponse = TaskResponse({ | |
| evaluationResult: bytes("RESULT_A"), | |
| // ... other fields point to any verified policy | |
| }); | |
| Attacker generates valid SP1 proof | |
| Create a legitimate proof for any unrelated context | |
| Ensure context.evaluation differs from fakeResponse.evaluationResult | |
| The proof is mathematically valid but semantically disconnected | |
| Attacker submits empty non-signer list | |
| pubkeysOfNonSigningOperators = [] | |
| All operators in the target set become "signers" eligible for slashing | |
| Attacker calls the function | |
| solidity | |
| taskManager.slashForCrossChainChallenge( | |
| registeredDestChainId, | |
| fakeTask, | |
| fakeResponse, | |
| validProofForWrongContext, | |
| [] // empty non-signer list | |
| ); | |
| All target operators are slashed 10% | |
| Slashing Execution Path | |
| The slashing occurs in ChallengeLib.slashSigningOperators: | |
| solidity | |
| // For each operator NOT in non-signer list: | |
| IAllocationManager(ctx.allocationManager).slashOperator( | |
| ctx.serviceManager, | |
| IAllocationManagerTypes.SlashingParams({ | |
| operator: operators[i], | |
| operatorSetId: quorumNumber, | |
| strategies: strategies, | |
| wadsToSlash: wadsToSlash, // 10% = 1e17 | |
| description: "..." | |
| }) | |
| ); | |
| Exploitation Scenarios | |
| Scenario 1: Mass Operator Slashing | |
| Objective: Slash all operators in a target quorum | |
| Preconditions: | |
| isChallengeEnabled == true | |
| serviceManager != address(0) (source chain) | |
| At least one registered destination chain | |
| A verified policy exists on-chain | |
| Target operator set exists at chosen block | |
| ChallengeVerifier authorized to request slashing | |
| Execution: | |
| Craft Task with target quorumNumbers and taskCreatedBlock | |
| Craft TaskResponse pointing to verified policy with arbitrary evaluationResult | |
| Generate SP1 proof with different evaluation value | |
| Call with empty pubkeysOfNonSigningOperators | |
| Result: 100% of operators in target quorum slashed 10% | |
| Scenario 2: Selective/Targeted Slashing | |
| Objective: Slash specific operators while protecting others | |
| Preconditions: | |
| All Scenario 1 preconditions | |
| Knowledge of BLS pubkeys for operators to protect | |
| Execution: | |
| Same as Scenario 1, but include friendly operators in pubkeysOfNonSigningOperators | |
| Only excluded operators are slashed | |
| Result: Discriminatory slashing of chosen operators | |
| Scenario 3: Repeated Slashing Attacks | |
| Objective: Drain operator stakes through multiple slashes | |
| Preconditions: | |
| All Scenario 1 preconditions | |
| Slashing system permits multiple events | |
| Execution: | |
| Execute Scenario 1 | |
| Vary task or taskResponse fields to create new crossChainKey: | |
| solidity | |
| bytes32 crossChainKey = keccak256(abi.encode(taskHash, responseHash)); | |
| Repeat with modified inputs to bypass replay protection | |
| Result: Cumulative slashing (10% each time) until stakes depleted | |
| Comparison with Secure Implementation | |
| The raiseAndResolveChallenge function (same-chain path) demonstrates correct validation: | |
| solidity | |
| // 1. Binds proof to stored task hash | |
| require( | |
| TaskLib.taskHash(context.task) == allTaskHashes[taskResponse.taskId], | |
| TaskLib.TaskMismatch(...) | |
| ); | |
| // 2. Binds proof to stored response hash | |
| require( | |
| keccak256(abi.encode(context.taskResponse)) == allTaskResponses[taskResponse.taskId], | |
| TaskLib.TaskResponseMismatch() | |
| ); | |
| // 3. Binds proof to policy entrypoint | |
| require( | |
| keccak256(abi.encode(policy.getEntrypoint())) | |
| == keccak256(abi.encode(context.entrypoint)), | |
| TaskLib.EntrypointMismatch() | |
| ); | |
| // 4. Validates non-signer list against certificate | |
| ChallengeLib.validateSignatoryRecord( | |
| task.taskCreatedBlock, | |
| hashesOfPubkeysOfNonSigningOperators, | |
| responseCertificate.hashOfNonSigners | |
| ); | |
| The cross-chain function lacks all four of these protections. | |
| Recommended Remediation | |
| Primary Fixes Required | |
| 1. Bind SP1 Proof Outputs to Inputs | |
| Add validation that proof public values match caller-supplied data: | |
| solidity | |
| // After verifyRegoProof: | |
| require( | |
| keccak256(abi.encode(context.task)) == keccak256(abi.encode(task)), | |
| TaskLib.TaskMismatch(...) | |
| ); | |
| require( | |
| keccak256(abi.encode(context.taskResponse)) == keccak256(abi.encode(taskResponse)), | |
| TaskLib.TaskResponseMismatch() | |
| ); | |
| require( | |
| keccak256(abi.encode(context.entrypoint)) | |
| == keccak256(abi.encode(policy.getEntrypoint())), | |
| TaskLib.EntrypointMismatch() | |
| ); | |
| 2. Require and Verify BN254 Certificate | |
| Add a certificate parameter and verify operator signatures on source chain: | |
| solidity | |
| function slashForCrossChainChallenge( | |
| uint256 destChainId, | |
| INewtonProverTaskManager.Task calldata task, | |
| INewtonProverTaskManager.TaskResponse calldata taskResponse, | |
| INewtonProverTaskManager.ResponseCertificate calldata responseCertificate, // NEW | |
| INewtonProverTaskManager.ChallengeData calldata challenge, | |
| IBLSSignatureCheckerTypes.NonSignerStakesAndSignature calldata nonSignerData // MODIFIED | |
| ) external onlyTaskManager nonReentrant returns (bool) { | |
| // Verify BLS aggregate signature on source chain | |
| _verifyBLSSignature(task, taskResponse, responseCertificate, nonSignerData); | |
| // Derive non-signers from verified certificate | |
| ChallengeLib.validateSignatoryRecord( | |
| task.taskCreatedBlock, | |
| _hashPubkeys(nonSignerData.nonSignerPubkeys), | |
| responseCertificate.hashOfNonSigners | |
| ); | |
| // ... rest of function | |
| } | |
| 3. Validate Task/Response Existence on Destination Chain | |
| Consider requiring proof that the task was actually created on the destination chain: | |
| solidity | |
| // Option A: Include destination chain task hash in proof | |
| require( | |
| context.destinationChainTaskHash == expectedDestChainTaskHash, | |
| "Task not registered on destination" | |
| ); | |
| // Option B: Bridge verification (if available) | |
| require( | |
| destChainBridge.verifyTaskExists(destChainId, taskId), | |
| "Task not verified via bridge" | |
| ); | |
| Open Questions for Review | |
| 1. SP1 Proof Circuit Design | |
| Question: Does the SP1 circuit's public values structure support the required bindings? | |
| The fix assumes context.task, context.taskResponse, and context.entrypoint are exposed as public values. If the circuit design differs, modifications to the proving system may be required. | |
| 2. Cross-Chain Certificate Verification | |
| Question: Can BLS signatures be verified on the source chain for tasks created on destination chains? | |
| This depends on: | |
| Whether operator sets are synchronized across chains | |
| Whether the blsApkRegistry on source chain contains destination chain operators | |
| The intended cross-chain trust model | |
| 3. Destination Chain Task Registration | |
| Question: How should the source chain verify a task legitimately existed on the destination chain? | |
| Options include: | |
| Bridge-based verification (requires trusted bridge) | |
| ZK proof of destination chain state (complex) | |
| Operator-attested existence (circular trust) | |
| 4. Replay Key Robustness | |
| Question: Is content-addressed keying sufficient for replay protection? | |
| Current implementation: | |
| solidity | |
| bytes32 crossChainKey = keccak256(abi.encode(taskHash, responseHash)); | |
| Consider whether including destChainId in the key is necessary after proper validation is added. | |
| Impact Assessment | |
| Impact Category Severity Description | |
| Financial Loss Critical Direct loss of operator stakes (10% per slash, repeatable) | |
| Protocol Integrity Critical Slashing system weaponized against honest operators | |
| Operator Trust Critical Operators may withdraw due to arbitrary slashing risk | |
| Attack Surface High Publicly accessible function, no privileged access required | |
| Exploitation Complexity Medium Requires SP1 proof generation but no on-chain state manipulation | |
| Conclusion | |
| The slashForCrossChainChallenge function contains a critical vulnerability that bypasses the security guarantees of the challenge system. Immediate remediation is required before production deployment. The function should either be disabled entirely or patched with the validation logic present in raiseAndResolveChallenge, adapted for the cross-chain context. |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Plan complete and saved to docs/superpowers/plans/2026-03-15-newton-policy-client-refactor.md.
Here's the summary of what the plan covers across 5 chunks and 17 tasks:
┌──────────────────────────────┬─────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
│ Chunk │ Tasks │ What it does │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 1: Forge Setup & Vault │ T1-T7 │ Submodule, remappings, via_ir, rewrite vault + factory + interface + tests │
│ Refactor │ │ │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 2: Rego Policy & Data Oracle │ T8-T9 │ Update Rego policies for intent-based eval, create WASM spending oracle, deploy on-chain │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 3: SDK Refactor │ T10-T13 │ Strip AgentPolicy types, integrate @magicnewton/newton-protocol-sdk, rewrite vault.ts + │
│ │ │ agent.ts │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 4: E2E & Downstream │ T14-T15 │ Dual-anvil E2E tests, update CLI/MCP/API/demo │
├──────────────────────────────┼─────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 5: Docs & Cleanup │ T16-T17 │ Architecture docs, security docs, lessons learned, final verification │
└──────────────────────────────┴─────────┴───────────────────────────────────────────────────────────────────────────────────────────┘
★ Insight ─────────────────────────────────────
This refactor radically simplifies the vault. The current NewtonVault.sol is ~325 lines with agent registration, EIP-712 domain, nonce
tracking, budget enforcement, dual-sig verification. The new vault is ~120 lines: initialize, validate attestation, execute intent, admin
functions. All the complexity moves off-chain to Newton's Rego policy engine and WASM data oracles, where it's more flexible and doesn't
cost gas.
The tradeoff: The vault becomes a generic intent executor — any call the Newton policy approves can be executed. The security guarantee
shifts from "on-chain code enforces rules" to "BLS-staked operator quorum enforces rules, backed by slashable economic stake." This is
the Newton Protocol's core value proposition.
─────────────────────────────────────────────────