Created
March 16, 2026 04:39
-
-
Save denniswon/68a25027c2a737d1b8b18adec91da42d to your computer and use it in GitHub Desktop.
Denial of Service: Failing External Calls in Challenge Slashing Operations
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
| Denial of Service: Failing External Calls in Challenge Slashing Operations | |
| Executive Summary | |
| The ChallengeVerifier contract's slashing mechanism can experience denial of service when processing challenges. The slashSigningOperators function iterates over all operators and strategies without any batching or gas limits, causing transactions to revert when these sets grow large. This prevents challenge finalization, attestation invalidation, and operator punishment. | |
| Technical Analysis | |
| Root Cause | |
| The vulnerability stems from unbounded iteration in ChallengeLib.slashSigningOperators: | |
| solidity | |
| for (uint256 i; i < allOperatorInfo.length;) { | |
| for (uint256 j; j < allOperatorInfo[i].length;) { | |
| // ... per-operator processing | |
| if (wasSigningOperator) { | |
| _slashOperator(ctx, operatorAddress, uint8(quorumNumbers[i])); | |
| } | |
| unchecked { ++j; } | |
| } | |
| unchecked { ++i; } | |
| } | |
| For each operator, _slashOperator performs additional heavy operations: | |
| solidity | |
| function _slashOperator( | |
| ChallengeContext memory ctx, | |
| address operatorAddress, | |
| uint8 quorumId | |
| ) private { | |
| OperatorSet memory operatorset = OperatorSet({avs: ctx.serviceManager, id: quorumId}); | |
| IStrategy[] memory strategies = | |
| IAllocationManager(ctx.allocationManager).getStrategiesInOperatorSet(operatorset); | |
| uint256 strategyLength = strategies.length; | |
| uint256[] memory wadsToSlash = new uint256[](strategyLength); | |
| for (uint256 i; i < strategyLength;) { | |
| wadsToSlash[i] = WADS_TO_SLASH; | |
| unchecked { ++i; } | |
| } | |
| // External call to InstantSlasher | |
| InstantSlasher(ctx.instantSlasher).fulfillSlashingRequest(slashingparams); | |
| } | |
| Key issues: | |
| Fetches the full strategies list via external call for every operator | |
| Allocates new wadsToSlash array for every operator | |
| Makes an external fulfillSlashingRequest call for every operator | |
| No upper bound on operators per quorum or strategies per operator set | |
| Affected Functions | |
| All challenge resolution paths are affected: | |
| Function Location | |
| raiseAndResolveChallenge ChallengeVerifier.sol:195 | |
| slashForCrossChainChallenge ChallengeVerifier.sol:260 | |
| challengeDirectlyVerifiedAttestation ChallengeVerifier.sol | |
| challengeDirectlyVerifiedMismatch ChallengeVerifier.sol | |
| Critical Ordering Problem | |
| Each affected function only records success after slashing completes: | |
| solidity | |
| // In raiseAndResolveChallenge | |
| ChallengeLib.slashSigningOperators( | |
| ctx, task.quorumNumbers, task.taskCreatedBlock, addressOfNonSigningOperators | |
| ); | |
| taskSuccesfullyChallenged[taskResponse.taskId] = true; // Only reached if slashing succeeds | |
| return true; | |
| If slashing reverts due to gas exhaustion: | |
| taskSuccesfullyChallenged[taskId] remains false | |
| The attestation is never invalidated | |
| Operators escape punishment | |
| The challenge can be retried but will continue to fail | |
| Exploitation Scenarios | |
| Scenario 1: Mismatch Challenge Blocked | |
| Context: challengeDirectlyVerifiedMismatch is called when both direct and regular attestation paths have been executed for the same task, and their hashes differ. | |
| Preconditions: | |
| Source-chain deployment with challenges enabled | |
| Both direct attestation flow and regular respondToTask flow completed for the same task | |
| Hash mismatch exists between the two paths | |
| Large operator set and/or strategy list in the quorum | |
| Exploitation Flow: | |
| Task is created and processed through both attestation paths | |
| A challenger detects the mismatch and calls challengeDirectlyVerifiedMismatch | |
| The function validates the mismatch condition: | |
| solidity | |
| bool taskHashMismatch = directTaskHash != bytes32(0) && directTaskHash != regularTaskHash; | |
| bool responseHashMismatch = directResponseHash != bytes32(0) && directResponseHash != regularResponseHash; | |
| require(taskHashMismatch || responseHashMismatch, ChallengeFailed()); | |
| Signature re-verification succeeds | |
| slashSigningOperators begins iteration but exceeds block gas limit | |
| Transaction reverts | |
| taskSuccesfullyChallenged[taskId] remains false | |
| AttestationValidator.invalidateAttestation(taskId) is never called | |
| Operators avoid slashing | |
| Impact: Malicious operators can provide conflicting attestations without consequence. The invalid attestation remains active in the system. | |
| Scenario 2: Cross-Chain Challenge Blocked | |
| Context: slashForCrossChainChallenge processes challenges proven on destination chains. | |
| Preconditions: | |
| Source-chain deployment with serviceManager set (non-zero) | |
| Destination chain is registered in registeredDestinationChains | |
| Valid ZK proof from destination chain proving response incorrectness | |
| Large operator/strategy sets | |
| Exploitation Flow: | |
| A task response on a destination chain is proven incorrect via ZK proof | |
| Challenger relays the challenge to source chain via slashForCrossChainChallenge | |
| Policy and ZK verification succeed: | |
| solidity | |
| IRegoVerifier.RegoContext memory context = | |
| RegoVerifier(regoVerifier).verifyRegoProof(challenge.data, challenge.proof); | |
| bool challengeSuccess = keccak256(abi.encode(context.evaluation)) | |
| != keccak256(abi.encode(taskResponse.evaluationResult)); | |
| require(challengeSuccess, ChallengeFailed()); | |
| slashSigningOperators is called but reverts due to gas exhaustion | |
| crossChainChallenged[crossChainKey] is never set to true | |
| Event CrossChainChallengeRelayed is never emitted | |
| Operators on source chain avoid slashing for invalid destination chain responses | |
| Impact: Cross-chain security guarantees are broken. Operators can misbehave on destination chains without consequence on the source chain where their stake is held. | |
| Scenario 3: Local ZK Challenge Blocked | |
| Context: raiseAndResolveChallenge processes local ZK proofs demonstrating incorrect task responses. | |
| Preconditions: | |
| Source-chain deployment with challenges enabled | |
| Valid ZK proof demonstrating evaluation mismatch | |
| Task is within challenge window | |
| Large operator/strategy sets | |
| Exploitation Flow: | |
| Operators submit an incorrect taskResponse with wrong evaluationResult | |
| A challenger generates a ZK proof showing the correct evaluation differs | |
| Challenger calls raiseAndResolveChallenge | |
| All validations pass: | |
| solidity | |
| bool challengeSuccess = keccak256(abi.encode(context.evaluation)) | |
| != keccak256(abi.encode(taskResponse.evaluationResult)); | |
| require(challengeSuccess, ChallengeFailed()); | |
| slashSigningOperators reverts due to gas | |
| taskSuccesfullyChallenged[taskResponse.taskId] remains false | |
| The incorrect response remains accepted | |
| Impact: Core challenge mechanism fails. Incorrect task responses cannot be challenged despite valid proofs. | |
| Gas Consumption Analysis | |
| Per-Operator Costs in _slashOperator: | |
| Operation Estimated Gas | |
| External call to getStrategiesInOperatorSet ~2,600 + storage reads | |
| Memory allocation for wadsToSlash array ~200 + 3 per element | |
| Loop to fill wadsToSlash ~50 per strategy | |
| Struct construction for SlashingParams ~500 | |
| External call to fulfillSlashingRequest ~2,600 + internal processing | |
| Scaling Factor: | |
| Total gas ≈ O(operators × quorums) × (base_cost + O(strategies)) | |
| With: | |
| 100 operators per quorum | |
| 2 quorums | |
| 10 strategies per operator set | |
| Approximate iterations: 200 × 10 = 2,000 individual operations, plus 200 external fulfillSlashingRequest calls. This can easily exceed the 30M gas block limit. | |
| Remediation Approach | |
| Recommended Fix: Batched Slashing with State Persistence | |
| Step 1: Record challenge success before slashing | |
| solidity | |
| // Mark challenge as successful first | |
| taskSuccesfullyChallenged[taskResponse.taskId] = true; | |
| // Invalidate attestation | |
| AttestationValidator(attestationValidator).invalidateAttestation(taskId); | |
| // Then attempt slashing (can be retried if it fails) | |
| if (serviceManager != address(0)) { | |
| ChallengeLib.slashSigningOperators(...); | |
| } | |
| Step 2: Implement batched slashing | |
| solidity | |
| struct SlashingState { | |
| bytes32 challengeId; | |
| uint256 processedQuorumIndex; | |
| uint256 processedOperatorIndex; | |
| bool completed; | |
| } | |
| mapping(bytes32 => SlashingState) public slashingProgress; | |
| function continueSlashing(bytes32 challengeId, uint256 maxOperators) external { | |
| SlashingState storage state = slashingProgress[challengeId]; | |
| require(!state.completed, "Already completed"); | |
| // Process up to maxOperators, then save progress | |
| // ... | |
| } | |
| Step 3: Use O(1) non-signer lookup | |
| solidity | |
| // Convert array to mapping for O(1) lookups | |
| mapping(address => bool) memory isNonSigner; | |
| for (uint256 i; i < addressOfNonSigningOperators.length; i++) { | |
| isNonSigner[addressOfNonSigningOperators[i]] = true; | |
| } | |
| // Check in O(1) instead of O(n) loop | |
| if (!isNonSigner[operatorAddress]) { | |
| _slashOperator(...); | |
| } | |
| Step 4: Cache strategy data | |
| solidity | |
| // Fetch strategies once per quorum, not per operator | |
| IStrategy[] memory strategies = | |
| IAllocationManager(ctx.allocationManager).getStrategiesInOperatorSet(operatorset); | |
| // Reuse for all operators in that quorum | |
| Open Questions | |
| Partial Slashing Fairness: If slashing is batched, what happens if only some operators are slashed before an interruption? Should slashing be atomic across all operators? | |
| Challenge Window Extension: If slashing fails due to gas and needs retry, should the challenge window be extended to allow completion? | |
| Maximum Operator/Strategy Limits: Should the protocol enforce hard limits on operators per quorum or strategies per operator set to guarantee slashing can complete in a single transaction? | |
| Slashing Priority: If the challenge is recorded before slashing completes, but slashing repeatedly fails, what is the acceptable delay before operators are penalized? | |
| External Contract Dependencies: The InstantSlasher.fulfillSlashingRequest call's gas consumption depends on the external contract's implementation. How should the protocol handle changes in that contract's gas requirements? | |
| Conclusion | |
| The unbounded iteration pattern in slashSigningOperators creates a denial-of-service vulnerability that blocks challenge finalization when operator or strategy sets grow large. The critical issue is the ordering of state updates—slashing must succeed before the challenge is recorded, creating an all-or-nothing situation that fails entirely under gas pressure. | |
| The recommended fix separates challenge recording from slashing execution, allowing challenges to be finalized while slashing is processed in batches. This maintains the security guarantee that proven challenges cannot be ignored while accommodating the practical gas limits of blockchain execution. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment