Skip to content

Commit c8c7dba

Browse files
staking-async: prevent manual application of cancelled slashes (#9659)
Fix security vulnerability where the permissionless `apply_slash` extrinsic could be used to manually apply slashes that governance had cancelled via `cancel_deferred_slash`. Related issue: paritytech-secops/srlabs_findings#563 --------- Co-authored-by: cmd[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 4acb964 commit c8c7dba

File tree

4 files changed

+85
-7
lines changed

4 files changed

+85
-7
lines changed

prdoc/pr_9659.prdoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: 'staking-async: prevent manual application of cancelled slashes'
2+
doc:
3+
- audience: Runtime Dev
4+
description: |-
5+
Fix security vulnerability where the permissionless `apply_slash` extrinsic could be used to manually apply slashes that governance had cancelled via `cancel_deferred_slash`.
6+
crates:
7+
- name: pallet-staking-async
8+
bump: major

substrate/frame/staking-async/src/pallet/impls.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ impl<T: Config> Pallet<T> {
178178
Self::slashable_balance_of_vote_weight(who, issuance)
179179
}
180180

181+
/// Checks if a slash has been cancelled for the given era and slash parameters.
182+
pub(crate) fn check_slash_cancelled(
183+
era: EraIndex,
184+
validator: &T::AccountId,
185+
slash_fraction: Perbill,
186+
) -> bool {
187+
let cancelled_slashes = CancelledSlashes::<T>::get(&era);
188+
cancelled_slashes.iter().any(|(cancelled_validator, cancel_fraction)| {
189+
*cancelled_validator == *validator && *cancel_fraction >= slash_fraction
190+
})
191+
}
192+
181193
pub(super) fn do_bond_extra(stash: &T::AccountId, additional: BalanceOf<T>) -> DispatchResult {
182194
let mut ledger = Self::ledger(StakingAccount::Stash(stash.clone()))?;
183195

substrate/frame/staking-async/src/pallet/mod.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,8 @@ pub mod pallet {
13071307
UnappliedSlashesInPreviousEra,
13081308
/// The era is not eligible for pruning.
13091309
EraNotPrunable,
1310+
/// The slash has been cancelled and cannot be applied.
1311+
CancelledSlash,
13101312
}
13111313

13121314
impl<T: Config> Pallet<T> {
@@ -1322,12 +1324,7 @@ pub mod pallet {
13221324
);
13231325

13241326
// Check if this slash has been cancelled
1325-
let cancelled_slashes = CancelledSlashes::<T>::get(&active_era);
1326-
let is_cancelled = cancelled_slashes.iter().any(|(validator, cancel_fraction)| {
1327-
*validator == key.0 && *cancel_fraction >= key.1
1328-
});
1329-
1330-
if is_cancelled {
1327+
if Self::check_slash_cancelled(active_era, &key.0, key.1) {
13311328
crate::log!(
13321329
debug,
13331330
"🦹 slash for {:?} in era {:?} was cancelled, skipping",
@@ -2033,7 +2030,8 @@ pub mod pallet {
20332030
/// slashes.
20342031
///
20352032
/// ## Parameters
2036-
/// - `era`: The staking era for which slashes should be cancelled.
2033+
/// - `era`: The staking era for which slashes should be cancelled. This is the era where
2034+
/// the slash would be applied, not the era in which the offence was committed.
20372035
/// - `validator_slashes`: A list of validator stash accounts and their slash fractions to
20382036
/// be cancelled.
20392037
#[pallet::call_index(17)]
@@ -2668,6 +2666,13 @@ pub mod pallet {
26682666
let _ = ensure_signed(origin)?;
26692667
let active_era = ActiveEra::<T>::get().map(|a| a.index).unwrap_or_default();
26702668
ensure!(slash_era <= active_era, Error::<T>::EraNotStarted);
2669+
2670+
// Check if this slash has been cancelled
2671+
ensure!(
2672+
!Self::check_slash_cancelled(slash_era, &slash_key.0, slash_key.1),
2673+
Error::<T>::CancelledSlash
2674+
);
2675+
26712676
let unapplied_slash = UnappliedSlashes::<T>::take(&slash_era, &slash_key)
26722677
.ok_or(Error::<T>::InvalidSlashRecord)?;
26732678
slashing::apply_slash::<T>(unapplied_slash, slash_era);

substrate/frame/staking-async/src/tests/slashing.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,59 @@ fn cancel_all_slashes_with_100_percent() {
12971297
})
12981298
}
12991299

1300+
#[test]
1301+
fn apply_slash_rejects_cancelled_slashes() {
1302+
ExtBuilder::default().slash_defer_duration(2).build_and_execute(|| {
1303+
// validator 11 has initial balance and is bonded
1304+
assert_eq!(asset::stakeable_balance::<T>(&11), 1000);
1305+
1306+
// Add a slash for validator 11 in era 1 (will be deferred to era 3)
1307+
add_slash(11);
1308+
Session::roll_next();
1309+
let _ = staking_events_since_last_call();
1310+
1311+
// Check current era
1312+
assert_eq!(active_era(), 1);
1313+
1314+
// Verify the slash is scheduled for era 3
1315+
let slash_era = 3;
1316+
let slash_key = (11, Perbill::from_percent(10), 0);
1317+
assert!(UnappliedSlashes::<T>::contains_key(&slash_era, &slash_key));
1318+
1319+
// Governance cancels this slash
1320+
assert_ok!(Staking::cancel_deferred_slash(
1321+
RuntimeOrigin::root(),
1322+
slash_era,
1323+
vec![(11, Perbill::from_percent(10))],
1324+
));
1325+
1326+
// Verify the cancellation event was emitted
1327+
assert_eq!(
1328+
staking_events_since_last_call(),
1329+
vec![Event::SlashCancelled { slash_era, validator: 11 }]
1330+
);
1331+
1332+
// Verify the slash is cancelled
1333+
let cancelled = CancelledSlashes::<T>::get(&slash_era);
1334+
assert_eq!(cancelled, vec![(11, Perbill::from_percent(10))]);
1335+
1336+
// Move to era 3 when the slash would be applied
1337+
Session::roll_until_active_era(3);
1338+
1339+
// Try to manually apply the cancelled slash - this should fail
1340+
assert_noop!(
1341+
Staking::apply_slash(RuntimeOrigin::signed(1), slash_era, slash_key),
1342+
Error::<T>::CancelledSlash
1343+
);
1344+
1345+
// Verify the slash is still in UnappliedSlashes
1346+
assert!(UnappliedSlashes::<T>::contains_key(&slash_era, &slash_key));
1347+
1348+
// Verify no actual slash was applied (balance remains unchanged)
1349+
assert_eq!(asset::stakeable_balance::<T>(&11), 1000);
1350+
})
1351+
}
1352+
13001353
#[test]
13011354
fn proportional_slash_stop_slashing_if_remaining_zero() {
13021355
ExtBuilder::default().nominate(true).build_and_execute(|| {

0 commit comments

Comments
 (0)