AID-H-008.002: Byzantine-Robust Aggregation Rules
Pillars: model
Lifecycle: building, operation
Scope boundary
Owns statistical filtering, down-weighting, or exclusion of anomalous and Byzantine federated updates before they corrupt the global model. It does not provide individual-contribution confidentiality.
AID-H-008.001: AID-H-008.002 controls malicious-update influence using per-client statistics or equivalent proofs; AID-H-008.001 hides each client's update under secure aggregation. Deploy both only through an explicitly compatible privacy-preserving robust protocol or trusted aggregation boundary.
AID-H-008.003: AID-H-008.002 limits the influence of accepted updates even from authorized but malicious clients; AID-H-008.003 decides whether the identity and exact update may enter the round. Authorized participation is not proof of benign contribution.
Implementation guidance
- AID-H-008.002-G001: Replace standard Federated Averaging with robust statistical aggregators like Krum, Multi-Krum, or Trimmed Mean.
- AID-H-008.002-G002: Monitor the statistical properties of each client's updates over time to detect consistently anomalous actors.
- AID-H-008.002-G003: Implement client reputation / trust scoring and use it to weight or exclude updates.
AID-H-008.002-G001
Concept:
Naive Federated Averaging (simple mean of all client updates) is fragile. A single malicious client can submit an extreme update to poison the global model. Byzantine-robust aggregators like Krum defend against this by down-weighting or outright ignoring outliers. Krum, for example, chooses the update that is most similar to the majority of other updates, assuming attackers are statistical outliers.
Implement the Krum Aggregation Function
The server computes pairwise distances between client updates, scores each update by how close it is to its nearest neighbors, and then selects the update with the lowest score. That chosen update becomes the global update for that round.
# File: hardening/robust_aggregators.py
import numpy as np
def krum_aggregator(client_updates, num_malicious):
"""Select one update only when the Krum Byzantine assumptions hold."""
if isinstance(num_malicious, bool) or not isinstance(num_malicious, int) or num_malicious < 0:
raise ValueError("num_malicious must be a non-negative integer")
num_clients = len(client_updates)
if num_clients < 2 * num_malicious + 3:
raise ValueError("Krum requires n >= 2f + 3 authenticated clients")
updates = [np.asarray(update, dtype=np.float64) for update in client_updates]
reference_shape = updates[0].shape
if not reference_shape or any(update.shape != reference_shape for update in updates):
raise ValueError("client updates must have one identical non-empty shape")
if any(not np.isfinite(update).all() for update in updates):
raise ValueError("client updates contain NaN or infinity")
stacked = np.stack(updates)
deltas = stacked[:, None, ...] - stacked[None, :, ...]
distances = np.sum(deltas * deltas, axis=tuple(range(2, deltas.ndim)))
np.fill_diagonal(distances, np.inf)
neighbor_count = num_clients - num_malicious - 2
scores = np.array([
np.partition(row, neighbor_count - 1)[:neighbor_count].sum()
for row in distances
])
if not np.isfinite(scores).all():
raise ValueError("Krum scores are non-finite")
best_client_index = int(np.argmin(scores))
return updates[best_client_index].copy()
# --- Usage on FL server ---
# aggregated_update = krum_aggregator(received_updates, num_malicious=3)
Action: In any environment where you assume only a minority of clients are malicious, replace plain averaging with Krum, Multi-Krum, trimmed mean, or similar. You must define an upper bound on the number of malicious clients (f) to tune these defenses.
Keep the action safely bounded
Validate dimensionality, finite/bounded values and the algorithm's participant/Byzantine assumptions; calibrate with signed benign/attack simulations, run the pinned implementation over the complete cohort, bind selected/rejected updates and aggregate digest, and deny publication when assumptions or utility/security gates fail.
Verify safely
A separately credentialed verifier independently reads back or replays each round's complete authenticated update population and signed Krum, Multi-Krum, coordinate-trimmed-mean or other reviewed robust rule, exercises positive, negative, boundary and dependency-failure fixtures, and reconciles the complete eligible population.
AID-H-008.002-G002
Concept:
A one-time weird update might be noise. A client that repeatedly submits high-deviation or low-similarity updates across many rounds is more suspicious. Track basic stats (norm, cosine similarity to the global model) per client per round, then flag clients whose behavior is consistently out-of-family. Those clients can be down-weighted or blocked in future rounds.
Step 1: Persist per-client statistics in a server-side store
The server should write one row per client per round so the evidence survives process restarts and can be audited later. The example below uses SQLite for a simple, deterministic evidence trail; in production you can move the same schema to Postgres or your experiment metadata store.
# File: hardening/client_monitoring.py
from __future__ import annotations
import math
import os
import sqlite3
from pathlib import Path
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def _required_positive_float(name: str) -> float:
value = float(os.environ[name])
if not math.isfinite(value) or value <= 0:
raise RuntimeError(f"{name} must be a finite positive number")
return value
UPDATE_MIN_NORM = _required_positive_float("VERIFIED_H008_UPDATE_MIN_NORM")
def _required_unit_interval(name: str) -> float:
value = float(os.environ[name])
if not math.isfinite(value) or not 0 < value <= 1:
raise RuntimeError(f"{name} must be in the interval (0, 1]")
return value
UPDATE_MAX_SIMILARITY = _required_unit_interval("VERIFIED_H008_UPDATE_MAX_SIMILARITY")
DB_PATH = Path("state/fl_client_stats.db")
def init_stats_store(db_path: Path = DB_PATH) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS fl_client_stats (
client_id TEXT NOT NULL,
round_id INTEGER NOT NULL,
update_norm REAL NOT NULL,
similarity REAL NOT NULL,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (client_id, round_id)
)
"""
)
conn.commit()
return conn
def log_update_stats(
client_id: str,
update_vector: np.ndarray,
last_global_update_vector: np.ndarray,
conn: sqlite3.Connection,
round_id: int,
) -> dict:
if not isinstance(client_id, str) or not client_id or type(round_id) is not int or round_id < 0:
raise ValueError("client and round identity are invalid")
update_vec = np.asarray(update_vector, dtype=np.float64).reshape(1, -1)
baseline_vec = np.asarray(last_global_update_vector, dtype=np.float64).reshape(1, -1)
if (update_vec.shape != baseline_vec.shape or update_vec.size == 0 or
not np.isfinite(update_vec).all() or not np.isfinite(baseline_vec).all()):
raise ValueError("update and baseline must be aligned finite vectors")
update_norm = float(np.linalg.norm(update_vec))
similarity = float(cosine_similarity(update_vec, baseline_vec)[0, 0])
conn.execute(
"""
INSERT INTO fl_client_stats (client_id, round_id, update_norm, similarity)
VALUES (?, ?, ?, ?)
""",
(client_id, round_id, update_norm, similarity),
)
conn.commit()
return {
"client_id": client_id,
"round_id": round_id,
"norm": update_norm,
"similarity": similarity,
}
def get_recent_outliers(
conn: sqlite3.Connection,
*,
min_norm: float,
max_similarity: float,
) -> list[tuple]:
cursor = conn.execute(
"""
SELECT client_id, round_id, update_norm, similarity, recorded_at
FROM fl_client_stats
WHERE update_norm >= ? OR similarity <= ?
ORDER BY round_id DESC, client_id ASC
""",
(min_norm, max_similarity),
)
return cursor.fetchall()
# Example usage on the FL server:
# conn = init_stats_store()
# record = log_update_stats("client-123", local_update, global_update, conn, round_id=42)
# suspicious_rows = get_recent_outliers(conn, min_norm=UPDATE_MIN_NORM, max_similarity=UPDATE_MAX_SIMILARITY)Step 2: Review multi-round outliers before quarantine or exclusion
Do not key off one noisy round. Query the stored history and require repeated anomalies before you down-weight or remove a client from aggregation.
Action: Persist per-client norm and similarity every round, review the multi-round history, and use that durable evidence to support quarantine, reputation downgrade, or exclusion decisions.
Keep the action safely bounded
Evaluate all updates before aggregation, record score/reason and model version, quarantine findings for bounded review or secondary checks, and deny inclusion on confirmed policy breach or missing/error evidence; never exclude solely from an uncalibrated detector.
Verify safely
A separately credentialed verifier independently reads back or replays each authenticated per-client update under a signed instantaneous anomaly model, feature schema and calibrated threshold, exercises positive, negative, boundary and dependency-failure fixtures, and reconciles the complete eligible population.
AID-H-008.002-G003
Concept:
Not all clients deserve equal influence. The FL server can maintain a reputation score for each client based on historical contribution quality (e.g. how aligned their updates were with the eventual global direction). During aggregation, weight high-reputation clients more, and either heavily down-weight or fully ignore low-reputation / repeatedly suspicious clients.
Implement Reputation-Based Weighted Aggregation
Below is a simple pattern: (1) maintain a reputation score per client, (2) compute a weighted average of updates using these scores, (3) update each client's reputation after each round based on how well they agreed with the final global update.
# File: hardening/reputation_aggregator.py
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
# Example server-side state:
# client_reputations = {
# "client_123": 1.0,
# "client_777": 0.6
# }
def reputation_weighted_aggregation(client_updates, client_ids, reputations):
"""
Weighted average of client updates using their reputation scores.
client_updates: list[np.array]
client_ids: list[str]
reputations: dict[str, float]
"""
if not client_updates or len(client_updates) != len(client_ids):
raise ValueError("client updates and IDs must be non-empty and aligned")
if (len(client_ids) != len(set(client_ids)) or
any(not isinstance(cid, str) or not cid for cid in client_ids)):
raise ValueError("client IDs must be unique non-empty strings")
missing = [cid for cid in client_ids if cid not in reputations]
if missing:
raise ValueError("reputation missing for admitted client")
updates = np.stack([np.asarray(update, dtype=np.float64) for update in client_updates])
if not np.all(np.isfinite(updates)):
raise ValueError("client update contains a non-finite value")
weights = np.array(
[float(reputations[cid]) for cid in client_ids],
dtype=np.float64,
)
if not np.all(np.isfinite(weights)) or np.any(weights < 0.0) or np.any(weights > 1.0):
raise ValueError("reputation weights must be finite and in [0, 1]")
weight_sum = float(weights.sum())
if not np.isfinite(weight_sum) or weight_sum <= 0.0:
raise ValueError("no client has a positive finite reputation weight")
normalized_weights = weights / weight_sum
# Weighted linear combination of updates
weighted_avg = np.tensordot(normalized_weights, updates, axes=(0, 0))
return weighted_avg
def update_reputations(global_update, client_updates, client_ids, reputations, learning_rate: float):
"""
After producing the new global update, measure each client's 'quality'
(e.g. cosine similarity to the final global update). Then adjust the
client's reputation with a moving average.
"""
if isinstance(learning_rate, bool) or not isinstance(learning_rate, (int, float)) or not np.isfinite(learning_rate) or not 0.0 <= learning_rate <= 1.0:
raise ValueError("lr must be between zero and one")
for i, cid in enumerate(client_ids):
similarity = cosine_similarity(
client_updates[i].reshape(1, -1),
global_update.reshape(1, -1)
)[0, 0]
# Cosine similarity is [-1, 1], but a reputation weight must remain
# non-negative. Treat anti-alignment as zero contribution quality.
quality = max(0.0, min(1.0, float(similarity)))
if cid not in reputations or isinstance(reputations[cid], bool):
raise ValueError("reputation missing or invalid for admitted client")
old_rep = float(reputations[cid])
if not np.isfinite(old_rep) or not 0.0 <= old_rep <= 1.0:
raise ValueError("stored reputation is outside [0, 1]")
new_rep = (1 - learning_rate) * old_rep + learning_rate * quality
reputations[cid] = max(0.0, min(1.0, new_rep))
return reputations
Action: Maintain a per-client reputation score and use it as a weighting factor in aggregation. Over time, clients that repeatedly submit anomalous or low-alignment updates lose influence. This naturally rate-limits persistent Byzantine clients without requiring an immediate hard ban.
Verify safely
A separately credentialed verifier independently reads back or replays each durable cryptographic participant identity and its complete signed longitudinal event history across rounds, exercises positive, negative, boundary and dependency-failure fixtures, and reconciles the complete eligible population.
Open-source tools
- Flower (Federated Learning Framework)
- NVIDIA FLARE
- FATE (Federated AI Technology Enabler)
- PySyft (OpenMined)
- PyTorch
- TensorFlow
- NumPy
- SciPy
Threat mappings
MITRE ATLAS
- AML.T0018 Manipulate AI Model
- AML.T0020 Training Data Poisoning
- AML.T0018.000 Manipulate AI Model: Poison AI Model (Byzantine-robust rules filter poisoned model updates)
MAESTRO
- Data Poisoning (L2)
- Data Poisoning (Training Phase) (L1) (Byzantine-robust rules filter poisoned FL updates)
OWASP LLM Top 10 2026
- LLM05:2026 Data and Model Poisoning
OWASP ML Top 10 2023
- ML02:2023 Data Poisoning Attack
- ML10:2023 Model Poisoning
OWASP Top 10 for Agentic Applications 2026
- N/A
NIST Adversarial Machine Learning 2025
- NISTAML.013 Data Poisoning
- NISTAML.024 Targeted Poisoning
- NISTAML.026 Model Poisoning (Integrity)
- NISTAML.011 Model Poisoning (Availability)
Cisco Integrated AI Security and Safety Framework
- AITech-6.1 Training Data Poisoning
Google Secure AI Framework 2.0 - Risks
- DP: Data Poisoning (Byzantine-robust aggregation filters poisoned FL updates)
Databricks AI Security Framework 3.0
- Datasets 3.1: Data poisoning (robust aggregation filters poisoned client updates)