Skip to content

Per-subnet ChildkeyTake is not migrated during hotkey swap, silently resetting the new hotkey's take to the subnet floor

Low
sam0x17 published GHSA-wc2g-rc74-vgw3 Jun 17, 2026

Software

pallet-subtensor / subtensor-runtime (node-subtensor)

Affected versions

<= spec_version 417

Patched versions

opentensor/subtensor@d0e9c43bf701 (PR #18); mainnet spec_version 419

Description

Summary

During a hotkey swap, the runtime migrates the global delegate take (Delegates) and the childkey-take rate-limit bookkeeping (LastTxBlockChildKeyTake) from the old hotkey to the new hotkey, but it never migrates the actual per-subnet ChildkeyTake(hotkey, netuid) values. Because get_childkey_take falls back to the effective per-subnet minimum when no explicit value exists, the new hotkey silently starts at the subnet floor take on every subnet where the operator had configured a higher take. Until the operator notices and re-runs set_childkey_take, a larger share of validator dividends is redistributed to parents. The old hotkey's ChildkeyTake rows are also orphaned (not removed). This is self-affecting, fully recoverable via set_childkey_take, and never moves value outside the operator's own validator graph.

Details

The swap entrypoint do_swap_hotkey and its helpers move a long list of per-hotkey and per-(hotkey, netuid) storage items, but ChildkeyTake is conspicuously absent.

pallets/subtensor/src/swap/swap_hotkey.rs:117-120 migrates only the rate-limit timestamp for childkey take, not the take value itself:

// 11. Swap LastTxBlockChildKeyTake
let last_tx_block_child_key_take: u64 = Self::get_last_tx_block_childkey_take(old_hotkey);
Self::set_last_tx_block_childkey(new_hotkey, last_tx_block_child_key_take);
weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1));

pallets/subtensor/src/swap/swap_hotkey.rs:263-268 migrates the global Delegates take (and removes the old row), establishing the exact pattern that ChildkeyTake should mirror but does not:

// 10. Swap delegates.
// Delegates( hotkey ) -> take value -- the hotkey delegate take value.
if Delegates::<T>::contains_key(old_hotkey) {
    let old_delegate_take = Delegates::<T>::get(old_hotkey);
    Delegates::<T>::remove(old_hotkey);
    Delegates::<T>::insert(new_hotkey, old_delegate_take);
    weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2));
}

perform_hotkey_swap_on_one_subnet (the per-subnet routine, swap_hotkey.rs:391-646) migrates membership, UIDs/Keys, Prometheus, axons, weight commits, loaded emission, neuron certificates, parent/child relations, pending children, auto-stake destination, subnet-owner hotkey, dividend records, alpha, and root claimable — but there is no ChildkeyTake read/insert/remove anywhere in this function, nor in perform_hotkey_swap_on_all_subnets. A repo-wide search confirms the only ChildkeyTake::<T>::insert call site in non-test code is do_set_childkey_take:

  • pallets/subtensor/src/staking/set_children.rs:765 — ChildkeyTake::<T>::insert(hotkey.clone(), netuid, take); (the only writer)

The storage default is what makes this a silent reset rather than a hard error. get_childkey_take reads the raw value and clamps it up to the effective minimum:

pallets/subtensor/src/staking/set_children.rs:792-794:

pub fn get_childkey_take(hotkey: &T::AccountId, netuid: NetUid) -> u16 {
    ChildkeyTake::<T>::get(hotkey, netuid).max(Self::get_effective_min_childkey_take(netuid))
}

ChildkeyTake is ValueQuery returning 0 when absent (pallets/subtensor/src/lib.rs:1196), so for a brand-new hotkey get_childkey_take returns get_effective_min_childkey_take(netuid) — the larger of the global MinChildkeyTake and the per-subnet MinChildkeyTakePerSubnet (pallets/subtensor/src/utils/misc.rs:425-427). The operator's previously configured (higher) take is therefore dropped to the subnet floor.

The economic consequence flows through the dividend split in get_parent_child_dividends_distribution:

pallets/subtensor/src/coinbase/run_coinbase.rs:882-884:

let child_take_proportion: U96F32 =
    U96F32::saturating_from_num(Self::get_childkey_take(hotkey, netuid))
        .safe_div(U96F32::saturating_from_num(u16::MAX));

A lower child_take_proportion means the validator (childkey operator) retains a smaller cut and a larger share of the validating emission is distributed to parents. So the take reset is not cosmetic: it redirects real emissions away from the operator to the parent hotkeys on every affected subnet until corrected.

Finally, the old ChildkeyTake(old_hotkey, netuid) rows are never removed by the swap. They become orphaned storage. (Note: they are not actively harmful because Owner(old_hotkey) is removed and the old hotkey is deregistered, so emission no longer reads them — but they are stale rows that only get cleaned up if/when the subnet is dissolved; see pallets/subtensor/src/coinbase/root.rs:407-415, which is the subnet-removal cleanup path, not the swap path.)

There is no migration that backfills or moves ChildkeyTake across a swap; migrate_fix_childkeys operates on ChildKeys/ParentKeys adjacency, not on the take value.

Proof of Concept / Attack Scenario

This is a self-inflicted state-loss bug, not an adversary-against-victim exploit. The "victim" and the "actor" are the same coldkey/operator.

Preconditions:

  1. An operator coldkey C owns hotkey H_old that is registered on subnet N and acts as a childkey (has parents delegating to it).
  2. C has set a non-minimum childkey take, e.g. set_childkey_take(H_old, N, take = 8000) (call_index 75), so ChildkeyTake(H_old, N) = 8000 while the subnet floor is, say, the global MinChildkeyTake.

Steps:

  1. C submits swap_hotkey(H_old, H_new) (call_index 70) or the subnet-scoped swap_hotkey_v2(H_old, H_new, Some(N)) (call_index 72), signed by C, paying the swap cost.
  2. The swap moves Delegates, parent/child links, stake, UIDs, etc., to H_new, but leaves ChildkeyTake(H_old, N) = 8000 in place and writes nothing for H_new.
  3. From the next coinbase epoch, get_childkey_take(H_new, N) returns the subnet floor (e.g. the minimum) instead of 8000.
  4. In get_parent_child_dividends_distribution, the operator's retained cut shrinks and the difference is paid out to the parent hotkeys of H_new on subnet N, every epoch.

What the operator loses: the delta (8000 - floor)/u16::MAX of the validating emission that would have been retained by their hotkey, redirected to parents, for as many epochs as it takes to notice. What they regain: nothing automatically — they must re-run set_childkey_take(H_new, N, 8000) for every affected subnet (subject to the increase rate limit) to restore the prior configuration.

Impact

  • Who: Any operator who swaps a hotkey that had a configured (non-floor) childkey take on one or more subnets.
  • Conditions: Triggered by the operator's own swap_hotkey / swap_hotkey_v2 call; no third party can induce or exploit it against another account.
  • Worst case: Per-subnet redirection of a slice of validator dividends to parent hotkeys for the window between the swap and the operator re-setting the take. The magnitude is bounded by (old_take - subnet_floor)/u16::MAX of the childkey's validating emission per epoch, multiplied by the affected subnets. No funds leave the operator's broader graph in an attacker-controlled way; value flows to the parents the operator themselves delegated to.
  • Integrity: Minor, self-affecting state-config loss plus orphaned ChildkeyTake(old_hotkey, *) rows (storage bloat, not a safety issue).
  • Availability / global integrity: None. No total-supply, conservation, or cross-account corruption. No permissionless theft.
  • Mitigating factors: Fully and cheaply recoverable via set_childkey_take; the operator controls both the trigger and the fix; the lost emission goes to the operator's own parents (often the same economic entity for root-validator → subnet-owner delegation chains); the take floor (not zero) limits how far the value can drop.

Affected Code

  • pallets/subtensor/src/swap/swap_hotkey.rs:117-120 — migrates LastTxBlockChildKeyTake only (rate-limit timestamp), not the take value.
  • pallets/subtensor/src/swap/swap_hotkey.rs:256-259 — removes LastTxBlockChildKeyTake from old hotkey (again, timestamp only).
  • pallets/subtensor/src/swap/swap_hotkey.rs:263-268 — migrates Delegates (the pattern ChildkeyTake should follow).
  • pallets/subtensor/src/swap/swap_hotkey.rs:391-646 — perform_hotkey_swap_on_one_subnet: migrates many per-(hotkey, netuid) items but never ChildkeyTake.
  • pallets/subtensor/src/staking/set_children.rs:765 — the sole ChildkeyTake::<T>::insert writer (do_set_childkey_take).
  • pallets/subtensor/src/staking/set_children.rs:792-794 — get_childkey_take floor-clamps to the effective minimum, turning a missing migration into a silent reset.
  • pallets/subtensor/src/coinbase/run_coinbase.rs:882-884 — emission split consumes get_childkey_take, so the reset redirects dividends.
  • pallets/subtensor/src/lib.rs:1196 — ChildkeyTake StorageDoubleMap (hotkey, netuid) -> u16, ValueQuery default 0.
  • pallets/subtensor/src/utils/misc.rs:425-427 — get_effective_min_childkey_take (the floor returned for a fresh hotkey).

Remediation

Migrate ChildkeyTake(hotkey, netuid) during the swap, mirroring Delegates, and remove the orphaned old rows.

For the per-subnet path, add it inside perform_hotkey_swap_on_one_subnet (which already has netuid in scope), so it covers both swap_hotkey (all subnets, called in a loop) and swap_hotkey_v2 (single subnet):

// Swap ChildkeyTake( hotkey, netuid ) -> u16 -- per-subnet childkey take.
if ChildkeyTake::<T>::contains_key(old_hotkey, netuid) {
    let take = ChildkeyTake::<T>::take(old_hotkey, netuid); // read + remove
    ChildkeyTake::<T>::insert(new_hotkey, netuid, take);
    weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2));
}

Placing it in the per-subnet routine (rather than alongside the Delegates block) is preferable because ChildkeyTake is per-(hotkey, netuid): the all-subnets swap iterates every netuid via perform_hotkey_swap_on_one_subnet, and the subnet-scoped swap calls that same routine once for the target netuid. Using ::take both copies the value and clears the old row in one operation, eliminating the orphaned storage.

Notes:

  • Only migrate when the key explicitly exists, to preserve default semantics (don't materialize a floor value for hotkeys that never set a take).
  • The swap is already wrapped/charged as a single dispatch; no additional with_storage_layer is required for this mechanical add.

Recommended regression test: in the swap test suite, set ChildkeyTake(old_hotkey, netuid) to a non-floor value via do_set_childkey_take, perform both swap_hotkey and swap_hotkey_v2, then assert ChildkeyTake::<T>::get(new_hotkey, netuid) equals the original take and ChildkeyTake::<T>::contains_key(old_hotkey, netuid) is false (no orphan). Optionally assert get_parent_child_dividends_distribution yields the same split before and after the swap.

Verification

I independently re-verified every claim against source at main @ 49164bd (spec_version 417):

  • Confirmed do_swap_hotkey migrates only LastTxBlockChildKeyTake for childkey-take, not the take value (swap_hotkey.rs:117-120, removal at 256-259).
  • Confirmed Delegates is migrated with the remove+insert pattern at swap_hotkey.rs:263-268, establishing the intended pattern that ChildkeyTake omits.
  • Read the entire perform_hotkey_swap_on_one_subnet (swap_hotkey.rs:391-646) and perform_hotkey_swap_on_all_subnets (203-293); confirmed no ChildkeyTake read/insert/remove anywhere in the swap path.
  • grep confirmed the only non-test ChildkeyTake::<T>::insert is set_children.rs:765 (do_set_childkey_take); there is no translate/swap/migration that moves it.
  • Confirmed the silent-reset mechanism: ChildkeyTake is ValueQuery default 0 (lib.rs:1196) and get_childkey_take clamps to get_effective_min_childkey_take (set_children.rs:792-794, misc.rs:425-427), so a fresh hotkey returns the subnet floor.
  • Confirmed the economic consequence: the emission split reads get_childkey_take(hotkey, netuid) at run_coinbase.rs:882-884; a lower take routes more emission to parents.
  • Confirmed ChildkeyTake is cleaned only on subnet dissolution (root.rs:407-415), not on swap, so old rows are orphaned but inert (Owner removed, hotkey deregistered) until subnet removal.
  • Confirmed call indices: swap_hotkey = 70, swap_hotkey_v2 = 72, set_childkey_take = 75 (macros/dispatches.rs).

Severity assessment vs. the seed: I concur with Low. The seed's framing is accurate. The issue is self-affecting (origin must own both hotkeys — swap_hotkey.rs:44-55), recoverable via set_childkey_take, redirects value only to the operator's own parents, and never violates conservation or enables cross-account theft. CVSS v3.1 base 3.7 (AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N): network vector, high attack complexity (requires the specific precondition of a non-floor configured take plus a swap, and the impact is a bounded recoverable economic drift rather than a deterministic exploit), low privileges (must own the hotkey), low integrity impact (config/state loss + storage orphan), no confidentiality or availability impact.

Reproduction

Status: REPRODUCED — passing compiled test.

  • Test: tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap
  • File: pallets/subtensor/src/tests/swap_hotkey.rs
  • Run: SKIP_WASM_BUILD=1 cargo test -p pallet-subtensor --lib -- tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap --exact

The test asserts the buggy behavior (and a contrast assertion of the safe/expected behavior); it passes against main @ 49164bd6 (spec_version 417), demonstrating the vulnerability is live.

Reproduction test source (the actual passing test)
// pallets/subtensor/src/tests/swap_hotkey.rs
// ---- GHSA-2026-014 (method: e2e) ----
// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::swap_hotkey::ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap --exact --nocapture
#[test]
fn ghsa_2026_014_childkey_take_not_migrated_on_hotkey_swap() {
    new_test_ext(1).execute_with(|| {
        let netuid = NetUid::from(1);
        let coldkey = U256::from(2);
        let old_hotkey = U256::from(1);
        let new_hotkey = U256::from(5);
        let mut weight = Weight::zero();

        add_network(netuid, 1, 0);
        // Establish coldkey ownership of old_hotkey (Owner(old_hotkey) = coldkey).
        SubtensorModule::create_account_if_non_existent(&coldkey, &old_hotkey)
            .expect("account creation should succeed");

        // The effective minimum (floor) childkey take in this mock is 0.
        let floor_take = SubtensorModule::get_effective_min_childkey_take(netuid);
        assert_eq!(floor_take, 0);

        // Configure a NON-minimum childkey take on the old hotkey for this subnet.
        // 5000 is well above the floor (0) and below the max (11_796).
        let configured_childkey_take: u16 = 5000;
        assert!(configured_childkey_take > floor_take);
        ChildkeyTake::<Test>::insert(old_hotkey, netuid, configured_childkey_take);

        // Configure a NON-default delegate take on the old hotkey for contrast.
        // (Default delegate take in this mock is 11_796.)
        let configured_delegate_take: u16 = 100;
        Delegates::<Test>::insert(old_hotkey, configured_delegate_take);

        // Sanity: pre-swap the old hotkey carries the configured values.
        assert_eq!(
            ChildkeyTake::<Test>::get(old_hotkey, netuid),
            configured_childkey_take
        );
        assert_eq!(
            SubtensorModule::get_childkey_take(&old_hotkey, netuid),
            configured_childkey_take
        );
        assert_eq!(Delegates::<Test>::get(old_hotkey), configured_delegate_take);

        // Perform the real hotkey swap on all subnets.
        assert_ok!(SubtensorModule::perform_hotkey_swap_on_all_subnets(
            &old_hotkey,
            &new_hotkey,
            &coldkey,
            &mut weight,
            false
        ));

        // CONTRAST (safe behavior): Delegates take IS migrated to the new hotkey.
        assert!(!Delegates::<Test>::contains_key(old_hotkey));
        assert_eq!(Delegates::<Test>::get(new_hotkey), configured_delegate_take);

        // BUG (GHSA-2026-014): ChildkeyTake is NOT migrated.
        // The new hotkey silently drops to the storage default (0) / floor take,
        // instead of carrying over the configured 5000.
        assert_eq!(
            ChildkeyTake::<Test>::get(new_hotkey, netuid),
            0,
            "BUG present: ChildkeyTake(new_hotkey) should have inherited the configured take but is default"
        );
        assert_ne!(
            ChildkeyTake::<Test>::get(new_hotkey, netuid),
            configured_childkey_take
        );
        // The effective getter for the new hotkey collapses to the floor take.
        assert_eq!(
            SubtensorModule::get_childkey_take(&new_hotkey, netuid),
            floor_take
        );

        // BUG: the old hotkey's ChildkeyTake row is orphaned (left behind, not removed).
        assert_eq!(
            ChildkeyTake::<Test>::get(old_hotkey, netuid),
            configured_childkey_take,
            "BUG present: ChildkeyTake(old_hotkey) row is orphaned after swap"
        );
        assert!(ChildkeyTake::<Test>::contains_key(old_hotkey, netuid));
    });
}

References

  • Source: subtensor main @ 49164bd, spec_version 417
  • pallets/subtensor/src/swap/swap_hotkey.rs (swap state migration)
  • pallets/subtensor/src/staking/set_children.rs (ChildkeyTake storage, do_set_childkey_take, get_childkey_take)
  • pallets/subtensor/src/coinbase/run_coinbase.rs:882-884 (dividend split consumer)
  • pallets/subtensor/src/coinbase/root.rs:407-415 (subnet-dissolution cleanup of ChildkeyTake)

Patch

Fixed in opentensor/subtensor@d0e9c43bf701 (PR #18).

Live on mainnet as of spec_version 419 (main).

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Initialization

The product does not initialize or incorrectly initializes a resource, which might leave the resource in an unexpected state when it is accessed or used. Learn more on MITRE.