Problem: a malicious validator can prevent other malicious validators from being slashed by skipping the vote process, since slashing requires unanimity of votes. To stop this, governance might set the skipping malicious validator to inactive so they cannot break the next round, but once inactive that validator becomes ineligible to be slashed — effectively shielded. They can then unstake/withdraw (after timelock) and exit the protocol with no consequences despite having behaved maliciously.
Impact
Malicious validators can escape slashing with 100% of their funds while still acting maliciously at the network and protocol level (by skipping votes). This can lead to protocol insolvency because the protocol cannot punish malicious validators.
Recommendation
Allow voteToSlashValidator to also target inactive validators. For example, remove the active check:
Proof of Concept
1
Context (step 1)
A malicious validator intentionally skips the voting process to protect another malicious validator from being slashed.
Governance sets this skipping malicious validator to inactive (to avoid them breaking future slashing rounds).
Once inactive, that validator cannot be slashed anymore due to the active check in voteToSlashValidator and can exit the protocol later with no consequences.
2
PoC test (step 2)
Add the following test in PlumeStakingDiamond.t.sol:
Run: orge test --mt testMaliciousValidator_cannotBeSlashed_onceInactive --via-ir
function voteToSlashValidator(uint16 maliciousValidatorId, uint256 voteExpiration) external nonReentrant {
...
- if (!targetValidator.active) {
- revert ValidatorInactive(maliciousValidatorId);
- }
...
function testMaliciousValidator_cannotBeSlashed_onceInactive() public {
// Setup validators
testSlash_Setup();
// Set max slash vote duration
vm.startPrank(admin);
ManagementFacet(address(diamondProxy)).setMaxSlashVoteDuration(1 days);
vm.stopPrank();
// Create users and only stake funds with validator 0
address user1_slash = makeAddr("user1_slash");
address user2_slash = makeAddr("user2_slash");
vm.deal(user1_slash, 100 ether);
vm.deal(user2_slash, 100 ether);
// user1 stakes with validator 0
vm.startPrank(user1_slash);
StakingFacet(address(diamondProxy)).stake{value: 10 ether}(
DEFAULT_VALIDATOR_ID
);
vm.stopPrank();
// user2 stakes with validator 1
vm.startPrank(user2_slash);
StakingFacet(address(diamondProxy)).stake{value: 10 ether}(
1
);
vm.stopPrank();
// Target validator to slash
address voter1Admin = user2; // user2 is admin for validator1
uint16 voter0ValidatorId = 0;
address voter0Admin = validatorAdmin; // validatorAdmin is admin for validator0
vm.startPrank(voter1Admin);
uint256 voteExpiration = block.timestamp + 1 hours; // Set vote expiration 1 hour from now
ValidatorFacet(address(diamondProxy)).voteToSlashValidator(
2,
voteExpiration
);
vm.stopPrank();
// 1 hour passes and validator 0 intentionally skips voting.
// Votes expires and the malicious validator 2 cannot be slashed
vm.warp(block.timestamp + 1 hours + 1 minutes);
// Governance set the validator 0 to inactive so he does not break the slashing process
// in the next round
vm.startPrank(admin); // Assuming admin has ADMIN_ROLE needed for setValidatorStatus
ValidatorFacet(address(diamondProxy)).setValidatorStatus(
voter0ValidatorId,
false
);
vm.stopPrank();
// Validator identifies that validator 0 is behaving maliciously
// he votes to slash validator 0
// but the transaction reverts with ValidatorInactive(0)
vm.startPrank(voter1Admin);
voteExpiration = block.timestamp + 1 hours; // Set vote expiration 1 hour from now
ValidatorFacet(address(diamondProxy)).voteToSlashValidator(
voter0ValidatorId,
voteExpiration
);
vm.stopPrank();
}
Failing tests:
Encountered 1 failing test in test/PlumeStakingDiamond.t.sol:PlumeStakingDiamondTest
[FAIL: ValidatorInactive(0)] testMaliciousValidator_cannotBeSlashed_onceInactive() (gas: 1633834)