DS 6042 · Lab 16 · 2026

microNIDS — ML for network intrusion detection

An 180-line gradient-boosted IDS on real NSL-KDD flows. Three adversarial flow attacks. Two layered defenses. Build, break, secure — the same arc every operational SOC walks every week.

A Network Intrusion Detection System reads traffic and emits a verdict — benign or malicious — per flow. Modern enterprise IDSes are hybrid: signature engines (Snort, Suricata) catch known patterns, ML classifiers catch statistical anomalies the signatures miss. The signatures are brittle but cheap. The ML is generalizable but attackable. This lab builds the ML half, attacks it three different ways, then layers defenses that buy back partial coverage.

Most ML-NIDS work in the literature targets one of three datasets — NSL-KDD (1999 data, 2009 revisions), CIC-IDS2017 (real PCAPs), or UNSW-NB15 (synthetic but well-distributed). All three describe network flows as a fixed vector of statistical features (byte counts, durations, packet counts, error rates) and train a classifier. microNIDS uses NSL-KDD for the walkthrough — its 41-feature schema is exhaustively documented and its dataset is small enough to retrain in seconds. Once you've seen the whole pipeline, the optional Going Further section swaps in CIC-IDS2017 — a larger, real-world captured-traffic dataset — to see which attacks still land.

dataset: NSL-KDD (Tavallaee et al., 2009) · feature schema: KDD-99 41 features · companion walkthroughs: Lab 04 · microagent, Lab 06 · microMCP, Lab 14 · microRAG · threat taxonomy: MITRE ATLAS T0043 (Craft Adversarial Data).
Get all the code in one file: ↓ lab-16-code.zip classifier, attacks, defenses, sample data — drop into Class/labs/lab-16/ and follow §3.

Where to find it

Authorized use — read this carefully

Everything in this lab runs against the NSL-KDD synthetic dataset and a model you trained yourself. Adversarial ML against ML-NIDS systems on production networks is unauthorized access; some jurisdictions (US under CFAA, UK under CMA, EU under NIS2) treat it as a felony regardless of intent. The skills here generalize directly to attacking production IDSes; the corresponding boundary is research disclosure to the vendor, never live testing. Lab 10's scope rules apply equally here.

1 · Anatomy of an ML-IDS ~10 min

An ML-NIDS has two pipelines, a training path and an inference path. Both share the same feature vocabulary; training writes the model, inference reads it.

Think · Pair · Share · flow features vs. the packet
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare predictions. Did your partner name a blind spot you missed — encrypted-payload malware, a single-flow exploit with benign-looking stats, a signature-only known-bad? Which of you separated "can't see it" from "can see it but misclassifies it"?
  • Share · 2 minAs a table, commit to one row: the single most dangerous thing flow-only features miss, and why DPI would (or wouldn't) fix it. Carry it into the five steps below and check it against the feature groups.
the five steps end to end
  1. Load. Parse the flow records — one row per network connection. NSL-KDD ships pre-aggregated; production systems aggregate live from packet captures using Zeek or NetFlow.
  2. Preprocess. Three columns are categorical (protocol_type, service, flag) and need integer encoding. The rest are numeric; we z-score-normalize them. The gradient-boosted trees split on thresholds and don't strictly need it — but it keeps preprocessing identical for the scale-sensitive logistic-regression model we add to the §7 ensemble.
  3. Train. Gradient-boosted trees learn the decision boundary between benign and attack flows. We use HistGradientBoostingClassifier — sklearn's port of LightGBM — because it ships with sklearn and needs no native-library install. Production teams reach for XGBoost or LightGBM directly.
  4. Predict. A new flow comes in. Same encoding, same scaler, model emits a probability that the flow is malicious. ≥ 0.5 → alert.
  5. Explain. Permutation importance ranks features by how much the model's accuracy drops when each is shuffled. The top of that list is also the attacker's target list — §4 shows what they do with it.
Widget · interactive pipeline · click a stage
Each box is one of the five steps. Click a stage to see why it exists, the actual micro_nids.py code that implements it, and a snapshot of what flows out when the corpus is NSL-KDD and the test flow is a Neptune SYN-flood attack.

The pipeline runs top-to-bottom at training time. At inference time the model sees one flow at a time and emits one verdict. The attack scripts in §4-§6 each tamper with one stage's input; the defense scripts in §7 each add a check at one stage's output.

ComponentmicroNIDS (this lab)Production (Snort + Suricata + Zeek + ML)
Flow aggregationNSL-KDD pre-aggregated CSVZeek (Bro) on a SPAN port; Cisco NetFlow v9; nProbe
Feature schema41 hand-engineered flow stats120+ derived features per flow, plus DPI fields when not encrypted
ModelHistGradientBoosting · 200 trees · depth 6XGBoost / CatBoost / DNN ensembles; periodic retraining on labeled SOC tickets
Signature enginen/aSnort or Suricata in front of the ML stage; ML triages everything the signatures didn't catch
Code~180 linestens of thousands · vendor SDKs · SIEM glue
Both columns run the same five steps. microNIDS strips the heavy ops (real packet capture, vendor SIEM, MLOps) so the structural lessons stand naked.
Try it · why not just write Snort rules?

NSL-KDD attacks are well-documented. Why don't enterprises just write a Snort rule for each one and skip the ML?

Show answer
Three reasons. (1) Novelty: a brand-new attack has no signature yet — ML can flag the unusual flow statistics even without the rule. (2) Volume: Snort rules are linear in number of patterns; production-scale SOCs have tens of thousands of rules and the matching cost compounds. (3) Polymorphism: a single attack technique generates many variants (different ports, sizes, timing); ML generalizes across the variants better than per-variant rules. The catch is that ML introduces a new attack class — adversarial ML — that signatures don't have. The whole reason Lab 16 §4-§6 exist.

2 · The microNIDS code · five steps ~15 min

Open nids/micro_nids.py. ↓ download lab-16-code.zip Each step below maps to one section of the file.

Think · Pair · Share · the explain step is a target menu
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare lists. Did one of you realize the feature ranking is enough on its own — that you can craft an evasion from the importance order alone, without ever touching the model weights? What's the cheapest feature on the list to actually move on a real network?
  • Share · 2 minAs a table, rank the top three features by "how easy is this to change while the attack still works," then watch the real version reshape exactly those features in Attack 1.

2.1 Step 1 · load

COLUMNS = [
    "duration", "protocol_type", "service", "flag", "src_bytes", "dst_bytes",
    # ... 35 more flow statistics ...
    "label", "difficulty",
]

def load(split: str = "train") -> pd.DataFrame:
    full = DATA / ("KDDTrain+.txt" if split == "train" else "KDDTest+.txt")
    if full.exists():
        df = pd.read_csv(full, header=None, names=COLUMNS).drop(columns=["difficulty"])
    else:
        df = pd.read_csv(DATA / "nslkdd_sample.csv")
    df["y"] = (df["label"] != "normal").astype(int)
    return df

The 41 features split into four conceptual groups: basic (duration, byte counts, protocol/service/flag), content-based (failed logins, file accesses, shell escalations — derived from payload inspection), traffic-window (counts of similar connections in the last 2 seconds), and host-window (counts of similar connections in the last 100 connections to the same host). Each group has different evasion characteristics — Attack 3 in §6 specifically targets the traffic-window features.

2.2 Step 2 · preprocess

CATEGORICAL = ["protocol_type", "service", "flag"]

def preprocess(df, encoders=None, scaler=None):
    if encoders is None:
        encoders = {}
        for col in CATEGORICAL:
            le = LabelEncoder().fit(df[col])
            df[col] = le.transform(df[col])
            encoders[col] = le
    # ... fold unknown categories onto the most common at predict time ...
    X = df[feature_cols].values.astype(np.float32)
    y = df["y"].values.astype(np.int8)
    if scaler is None:
        scaler = StandardScaler().fit(X)
    X = scaler.transform(X).astype(np.float32)
    return X, y, encoders, scaler, feature_cols

The encoders and scaler get persisted alongside the model. At inference time they must be applied identically — if you re-fit on the inference data you've introduced train-test skew and the model behavior changes silently. Every production MLOps stack has a horror story about a scaler that re-fit on production data.

2.3 Step 3 · train

def train(out=ARTIFACTS / "model.pkl"):
    train_df = load("train")
    X, y, encoders, scaler, feature_cols = preprocess(train_df)
    model = HistGradientBoostingClassifier(
        max_iter=200, max_depth=6, learning_rate=0.1, random_state=42,
    )
    model.fit(X, y)
    bundle = {"model": model, "encoders": encoders, "scaler": scaler,
              "feature_cols": feature_cols}
    pickle.dump(bundle, out.open("wb"))

A 200-tree gradient-boosted model trains on the full NSL-KDD train split in about 10 seconds on a laptop. Test ROC-AUC is around 0.96; raw accuracy is ~80%. Why the gap? NSL-KDD test contains attack categories that are absent from train — that's a deliberate feature of the dataset, designed to make accuracy harder so methodological papers can't cherry-pick. Production IDS authors face the same problem every day: today's training data doesn't include tomorrow's attacks.

2.4 Step 4 · predict

def predict(row_idx=0, split="test"):
    bundle = load_bundle()
    df = load(split)
    row = df.iloc[[row_idx]]
    X, y, *_ = preprocess(row, encoders=bundle["encoders"], scaler=bundle["scaler"])
    score = float(bundle["model"].predict_proba(X)[0, 1])
    pred = "attack" if score >= 0.5 else "benign"
    return {"score": score, "predicted": pred, "truth": "attack" if y[0] else "benign"}

One flow in, one verdict out. In production the verdict streams into a SIEM (Splunk, Sentinel, Chronicle) where rules + analyst review decide whether to page the on-call.

2.5 Step 5 · explain

def explain(top_k=10, n_repeats=5, sample=2000):
    bundle = load_bundle()
    test_df = load("test").sample(n=sample, random_state=42)
    X, y, *_ = preprocess(test_df, encoders=bundle["encoders"], scaler=bundle["scaler"])
    pi = permutation_importance(bundle["model"], X, y,
                                 n_repeats=n_repeats, random_state=42)
    return sorted(zip(bundle["feature_cols"], pi.importances_mean),
                   key=lambda kv: -kv[1])[:top_k]

Permutation importance shuffles each column in the test set and measures how much the model's accuracy drops. Bigger drop ⇒ the model depended on that feature more. The output is the attacker's target list.

Widget · top features by permutation importance · 2,000-flow sample
Hover a bar to see the feature's role. The top entry — src_bytes — is where every adversarial flow attack in §4 starts: change the byte count, change the score. Notice how statistical the features are: there is no DPI, no payload inspection, no signature matching. The model is doing classification on metadata alone.
the attacker's reading of this chart

"src_bytes is 4× more important than the next feature. If I can move that one number, I can probably flip the verdict. dst_host_rerror_rate says the model is reading my error rate. dst_bytes and duration say it's reading my transfer size and timing. Five of the top eight features are statistical things I control with how I send packets. The defender wrote me a recipe for evasion."

3 · Run it ~15 min

On Rivanna, run this inside an Open OnDemand interactive session (Interactive Apps → Code Server, 4 cores is plenty, no GPU), not on the login node — training is CPU-heavy and the login node is single-threaded and rate-limited. Then open a terminal there.

The whole lab — build, break, and secure — runs from one command. run.sh creates a local venv, installs the three dependencies, and executes every step with headings:

$ cd Class/labs/lab-16
$ module load miniforge   # Rivanna only · gives you Python 3.13 (skip if you already have 3.9+)
$ chmod +x ./run.sh     # make the script executable (only needed once)
$ ./run.sh              # or: ./run.sh build | break | secure

Prefer to run each step yourself and watch it? Here is the same thing by hand — start with build:

$ python3 -m venv .venv && source .venv/bin/activate
$ pip install -r nids/requirements.txt
$ python nids/micro_nids.py train
saved → artifacts/model.pkl  (125973 training flows)
              precision    recall  f1-score   support
      benign       0.69      0.97      0.81      9711
      attack       0.97      0.68      0.80     12833
    accuracy                           0.80     22544
ROC-AUC: 0.9655
$ python nids/micro_nids.py predict --row 0
row 0 · score=1.000 · predicted=attack · truth=attack
$ python nids/micro_nids.py explain --top-k 8
top-8 features by permutation importance:
  src_bytes                    0.0899  ████████████████████████████████████████████████████████████
  dst_host_rerror_rate         0.0247  ████████████████
  dst_bytes                    0.0189  ████████████
  dst_host_srv_count           0.0167  ███████████
  duration                     0.0159  ██████████

~80% test accuracy at 0.96 ROC-AUC matches the canonical NSL-KDD literature numbers. The model is good. Now read the attacker's chart and pretend you've never seen it before — that's how Lab 16's break phase begins.

4 · Break · Attack 1 · feature-space perturbation ~20 min

MITRE ATLAS T0043 — Craft Adversarial Data. The attacker has white-box access to the model (or to its feature-importance output, which is structurally just as bad) and reshapes a real attack flow's statistical fingerprint to mimic the benign class.

Think · Pair · Share · 0.96 AUC, still evadable
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare predictions. Did your partner catch that AUC measures average separation on the natural flow distribution, while the attacker operates off-distribution on purpose? What did each of you miss about the gap between "accurate" and "robust"?
  • Share · 2 minAs a table, commit to one answer: the smallest set of features that, set to benign-class mid-points, you think flips a Neptune flow — then check it against PERTURB_TARGETS in the code below.
Vulnerability · feature space is the attacker's playground · micro_nids.py entire model

The model classifies on aggregated statistics. Every one of those statistics is a number the attacker can influence by how they send packets. Look at the explain output above and the list reads as a target menu: "change src_bytes," "change dst_bytes," "lower the error rate." None of these are protocol violations. None of them require new tools. The classifier has no way to know whether a 1,024-byte flow is a benign API call or a padded SYN flood — its only input is the byte count.

# attacks/01_feature_perturbation.py — the perturbation
PERTURB_TARGETS = {
    "src_bytes":               (200,  2000),   # mid-range HTTP body
    "dst_bytes":               (500,  5000),   # mid-range response
    "duration":                (1,    30),
    "count":                   (1,    8),
    "diff_srv_rate":           (0.0,  0.1),
    "dst_host_srv_count":      (50,   200),
    "dst_host_same_srv_rate":  (0.8,  1.0),
}

def perturb(flow):
    for col, (lo, hi) in PERTURB_TARGETS.items():
        flow[col] = (lo + hi) / 2    # pin at the benign-class mid-point
    return flow

Each PERTURB_TARGETS entry is a network-valid bound — values that any real benign flow could plausibly have. The structural fix would be a defender that can see why the perturbation happened, not just what value it landed at. §7 walks the closest approximations.

Widget · live perturbation · drag sliders, watch the verdict flip
A real Neptune SYN-flood flow from the test set. Drag each slider to perturb that feature; the score recomputes (a logistic surrogate of the real model) and the verdict flips in real time. Set all features to the benign-class mid-points and the verdict snaps to benign — that's Attack 1 in one chart.

The widget uses a logistic regression surrogate trained on the same NSL-KDD split so it runs in the browser without 200 trees. The real model in micro_nids.py has the same shape of decision boundary; the per-feature thresholds differ slightly.

4.1 Run it

$ python attacks/01_feature_perturbation.py

Attack 1 · feature-space perturbation
  pool: 8067 high-confidence attack flows
--- flow 0 · category=neptune ---
  original score      : 1.000
  after perturbation  : 0.119   (Δ = -0.881)
  ✓ EVASION SUCCEEDED — flow now classified as benign
--- flow 1 · category=neptune ---
  ✓ EVASION SUCCEEDED — flow now classified as benign
--- flow 2 · category=saint ---
  ✗ still classified as attack
--- flow 3 · category=neptune ---
  ✓ EVASION SUCCEEDED — flow now classified as benign
--- flow 4 · category=neptune ---
  ✓ EVASION SUCCEEDED — flow now classified as benign

4/5 flows successfully disguised by feature perturbation.
Try it · why did saint survive?

The saint attack flow on row 2 didn't evade. Why might feature-space perturbation work poorly on it specifically, when it nailed every Neptune flow?

Show answer
Saint is a probing attack (slow port-scan, sometimes called "stealthier reconnaissance"). It already has byte counts and timing within the benign range — the very thing PERTURB_TARGETS pushes Neptune toward. So the perturbation has little to "fix"; the model is catching Saint on features outside the perturbation set (probably dst_host_rerror_rate and dst_host_srv_count patterns it has learned about reconnaissance specifically). Lesson: different attack categories have different evasion-resistance profiles. A defender can use that — train per-category models and require unanimous benign verdicts — but the attacker can adapt.

5 · Break · Attack 2 · packet padding ~15 min

MITRE ATLAS T0043. A more constrained, more realistic perturbation: only the byte counts. The attacker pads payloads with junk (zeroes after a protocol terminator, repeated NOPs in a shellcode, harmless HTTP headers). The attack still completes; the byte counts now look like a normal file transfer.

Think · Pair · Share · the stealth–success tradeoff
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare predictions. Did your partner reason about which flows already sit in the benign byte range (where padding does nothing)? Who weighed the realism win — any TCP-aware attacker can append bytes — against the lower success rate?
  • Share · 2 minAs a table, commit to a single evasion number you expect out of five flows and the reason, then check it against the run output below.
Vulnerability · byte counts are alone insufficient signal · attacks/02_packet_padding.py
TARGET_SRC_BYTES = 1024     # mid-size POST body
TARGET_DST_BYTES = 8192     # typical HTML response

def pad(flow):
    flow["src_bytes"] = max(flow["src_bytes"], TARGET_SRC_BYTES)
    flow["dst_bytes"] = max(flow["dst_bytes"], TARGET_DST_BYTES)
    return flow

Padding only adds bytes; it never removes them. That constraint makes this attack more realistic than Attack 1 (any TCP-aware attacker can append bytes) but weaker (it only touches two features). Result: ~50% evasion rate on Neptune flows that had tiny byte counts; near-zero on flows that were already in the right byte range.

5.1 Run it

$ python attacks/02_packet_padding.py

Attack 2 · packet padding
  pool: 8067 high-confidence attack flows
--- flow 0 · category=neptune ---
  src_bytes      0 →   1024    dst_bytes      0 →   8192
  ✓ EVASION SUCCEEDED — flow now classified as benign
--- flow 1 · category=neptune ---
  src_bytes      0 →   1024    dst_bytes      0 →   8192
  ✗ still classified as attack
--- flow 2 · category=saint ---
  src_bytes     20 →   1024    dst_bytes      0 →   8192
  ✗ still classified as attack
--- flow 3 · category=neptune ---
  src_bytes      0 →   1024    dst_bytes      0 →   8192
  ✗ still classified as attack
--- flow 4 · category=neptune ---
  src_bytes      0 →   1024    dst_bytes      0 →   8192
  ✓ EVASION SUCCEEDED — flow now classified as benign

2/5 flows evaded the classifier with only byte-count padding.

The success-rate-vs-stealth tradeoff is real: 80% evasion (Attack 1) requires changing many features and hoping the defender doesn't notice the joint distribution; 40% evasion (Attack 2) needs only the bytes and is invisible at the packet layer.

6 · Break · Attack 3 · slow-and-low timing ~15 min

The "slow rate" lineage — Slowloris, Slowdrip, drip-feed port scans. The attacker stretches the attack across minutes or hours instead of seconds, breaking the count-window features that the classifier relies on. Count windows in NSL-KDD assume the attacker is in a hurry; the attacker who isn't is invisible to them.

Think · Pair · Share · the weakest attack, and why it's famous anyway
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare predictions. Did one of you spot that the dataset already encodes a slow attack as many low-count flows, so a single low-count flow isn't anomalous? Did the other note that the byte-count signature survives untouched by timing changes alone?
  • Share · 2 minAs a table, commit to one answer: what would a slow-and-low attacker have to combine this with to actually win? Carry that hypothesis into the composite-attack assignment.
Vulnerability · count windows are an assumption about attacker tempo
def jitter(flow):
    flow["duration"] = max(flow["duration"], 120)           # ≥ 2 minutes
    flow["count"] = 1                                         # no repetition
    flow["srv_count"] = 1
    flow["serror_rate"] = 0.0                                 # no retries
    flow["dst_host_count"] = min(flow["dst_host_count"], 5)
    flow["dst_host_srv_count"] = min(flow["dst_host_srv_count"], 5)
    flow["dst_host_serror_rate"] = 0.0
    return flow

The count features were engineered assuming "attacks happen fast." The Slowloris assumption is "attacks can wait." Both are correct; only one made it into the dataset. A model trained on the wrong assumption can't see the right attack.

6.1 Run it

$ python attacks/03_timing_jitter.py

Attack 3 · timing / count perturbation (slow-and-low)
--- flow 4 · category=neptune ---
  duration     0 →   120 s    count  120 →    1
  ✓ EVASION SUCCEEDED — flow now classified as benign

1/5 flows evaded the classifier via slow-and-low timing.
Try it · why is the success rate so low?

Only 1/5 timing-jitter evasions succeeded — much worse than Attacks 1 and 2. Why is this perturbation the weakest of the three, given how much real-world attack tradecraft is built around it?

Show answer
Two reasons. (1) NSL-KDD already represents Slowloris-style attacks as multiple low-count flows, so a single low-count flow isn't unusual to the model. (2) The non-count features still carry the attack signature — Neptune's src_bytes=0 tells the model "SYN flood" regardless of how slowly it arrives. A real slow-and-low attack would also need to change the byte counts (combine Attack 2 with Attack 3). The lab leaves this combined attack as an Assignment — feature-space exploit composition is the natural next move.

7 · Secure · ensemble + envelope ~30 min

Two defenses, deliberately limited. Both make a measurable dent in the attacks. Neither comes close to a complete fix. That's the lesson — production NIDS security is defense-in-depth, not a single trick.

Think · Pair · Share · why the envelope can't win
I've done the Think step — reveal Pair & Share
  • Pair · 5 minCompare predictions. Did your partner catch that the attacker moves features toward the benign median, not away from it — so deviation magnitude is near zero on exactly the axis the envelope watches? Now extend it: would a model-diversity ensemble do any better against that same calibrated attacker, and why might it even make one attack worse?
  • Share · 2 minAs a table, commit to a two-row prediction — envelope catch rate and ensemble effect — for Attack 1, then check both against the coverage reports below.

7.1 Defense 1 · model-diversity ensemble

Three classifiers trained on the same data but from different model families — gradient-boosted trees, random forest, logistic regression. Vote (or average probabilities) at inference time. An adversarial perturbation tuned to fool one decision boundary is unlikely to land on the benign side of all three at once.

# secure/ensemble_nids.py — the train function
models = {
    "hgb": HistGradientBoostingClassifier(max_iter=200, max_depth=6, ...),
    "rf":  RandomForestClassifier(n_estimators=150, max_depth=10, ...),
    "lr":  LogisticRegression(max_iter=500, ...),
}
for mdl in models.values():
    mdl.fit(X, y)
# At inference: average the three probabilities.
probs = np.mean([m_.predict_proba(X)[:, 1] for m_ in models.values()], axis=0)

Coverage report

attackevasion vs single modelevasion vs ensemble
Attack 1 · feature perturbation17/2018/20
Attack 2 · packet padding12/207/20
Attack 3 · timing jitter9/2010/20

The ensemble shrinks padding evasion meaningfully (12 → 7) because the three model families have different sensitivities to src_bytes / dst_bytes. It doesn't help against feature perturbation (the attacker is moving features to ranges that look benign to all three models) and slightly worsens timing jitter (the LR component happens to over-trust the slow-and-low signal). The point of including the worsened number isn't to celebrate the defense — it's diversity helps some attacks, not all, and sometimes the diversity itself opens a new gap.

7.2 Defense 2 · per-feature distributional envelope

Fit median + IQR per feature on the benign training class. At inference, count how many features sit outside the ±3-IQR envelope. ≥ 4 features outside ⇒ flag.

# secure/baseline_anomaly.py — the envelope check
def anomaly_score(envelope, row):
    outliers = 0
    for col, (med, iqr) in envelope["stats"].items():
        z = abs(row[col] - med) / iqr
        if z >= envelope["z_threshold"]:
            outliers += 1
    return outliers

Coverage report

attackevasion vs single modelcaught by envelope (of those)
Attack 1 · feature perturbation17/200/17
Attack 2 · packet padding12/201/12
Attack 3 · timing jitter9/200/9

The envelope is nearly useless against perturbation attacks. The reason is in the attack design — every attack here moves features to plausibly benign values, not outlier values. The envelope is looking for "too far from the benign median"; the attacker is going to the benign median.

honest framing of the two defenses

The envelope is a defense against noisy adversarial attacks — attackers who slap on random perturbations and hope for the best. It does nothing against a calibrated attacker who knows the benign distribution. The ensemble is a defense against single-model overfit to one decision boundary. It does little against an attacker who targets all three boundaries. Each defense has a specific blind spot, and the production answer is to deploy several of them in series — plus signature engines, plus DPI, plus human review — and treat any single layer as fallible.

Widget · defense coverage matrix · attacks × defenses
A 3×4 matrix. Rows: the three attacks. Columns: no defense, ensemble alone, envelope alone, both. Each cell shows the evasion rate (lower is better for the defender). Hover a cell to see the per-defense reasoning.

8 · Scaling up · production NIDS ~15 min

microNIDS is structurally complete — the five steps map one-to-one onto every production IDS. What's missing from microNIDS is the operational machinery production systems add around those five steps.

Think · Pair · Share · the base-rate problem
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare estimates. Did your partner work the base-rate arithmetic — a 1% FP rate on 100,000 benign flows is ~1,000 false alarms for every 1 true positive, so precision collapses to well under 1%? Which of you tied that to alert fatigue, and which to the signature-front-end / SIEM-correlation design choices?
  • Share · 2 minAs a table, commit to one number for the real-alert fraction and name the single production layer you'd add first to raise it. Check your reasoning against Step 1 · signature engine and Step 4 · SIEM integration below.
Step 0 · packet capture

Production: a SPAN/mirror port on the network switch streams every packet to the IDS host. Tools: Zeek for flow aggregation + protocol parsing, nProbe for NetFlow export, dedicated TAPs for line-rate capture. microNIDS skips this — NSL-KDD is pre-aggregated.

Step 1 · signature engine in front

Snort and Suricata sit in front of the ML stage. They catch the known-bad with one rule per pattern at line rate, leaving the ML to triage what they didn't match. The ML output feeds back into the SOC as a "low-confidence alert" queue; analyst feedback retrains the model.

Step 2 · richer features

NSL-KDD has 41 features. CIC-IDS2017 has ~80. Production stacks derive 120+ per flow plus optional deep-packet-inspection fields when traffic isn't TLS-encrypted. JA3/JA3S TLS fingerprints, HTTP user-agent profiles, DNS query patterns — all are feature engineering wins.

Step 3 · concept drift

An IDS model trained on 2023 traffic decays measurably on 2024 traffic. Production systems retrain weekly or monthly on labeled SOC tickets. Evidently and whylogs monitor distribution drift; alerts fire when input features stop matching the training distribution.

Step 4 · SIEM integration

Splunk, Microsoft Sentinel, Google Chronicle, Elastic SIEM. The IDS verdict streams in as one event per flow; SIEM correlation rules combine it with endpoint, identity, and authentication signals. The IDS rarely alerts directly to humans; it feeds the correlation engine.

Step 5 · adversarial training

The most direct counter to the attacks in §4-§6: augment the training set with perturbed-attack examples labeled "attack." The model learns the perturbed-attack manifold. This is also Lab 17's main defense — adversarial training is the same idea applied to image classifiers.

Going further · CIC-IDS2017

CIC-IDS2017 is the modern successor to NSL-KDD. Same shape (flow features → classifier) but built from real PCAPs captured in 2017 against a target network deliberately exposed to a curated attack list (DoS, brute force, infiltration, botnet, web attacks). 80+ features, 2.8M flows, more realistic feature distributions. Swapping CIC-IDS2017 in for NSL-KDD is a one-day exercise:

  1. Download the labeled CSVs from CIC (8 files, ~500 MB total).
  2. Replace load() in micro_nids.py with a CIC parser — the column names differ but the shape is identical.
  3. Retrain. ROC-AUC should land around 0.99 on the easier attack classes.
  4. Re-run the three attacks. Two will work, one will fail outright because CIC's feature engineering is more robust than NSL-KDD's.

The interesting research question — "which attacks generalize across IDS datasets?" — is the natural extension lab. See the Assignment for a smaller version.

Assignment · combine + defend ~3 hours

Three deliverables:

  1. Combine Attacks 1 + 2 + 3 into a single composite attacker. Write attacks/04_composite.py. The composite should hit ≥ 95% evasion on the 20-flow pool, defeating both Defense 1 and Defense 2.
  2. Add a third defense. One of: adversarial training (retrain with perturbed-attack examples), DPI feature additions (parse the original NSL-KDD content features more aggressively), per-category one-vs-all classifiers (each category votes separately). Run on the composite attacker; report the coverage.
  3. writeup.md — one page. Three sections: (a) which features your composite touched and why; (b) what assumption your defense exploits; (c) the next attack class that defeats your defense, with one sentence of justification.

Rubric

criterionpoints
Composite attack achieves ≥ 95% evasion on the 20-flow pool25
Third defense runs cleanly and is documented in the source25
Defense reduces composite-attack evasion by ≥ 30 percentage points20
Writeup correctly identifies the assumption the defense exploits15
Writeup anticipates the next attack class with sound reasoning15
Total100

FAQ

Why NSL-KDD instead of something modern?

For the walkthrough: small (~25 MB total), well-documented, every feature has a published interpretation. For learning the structural lessons — feature engineering, gradient-boosted classifiers, adversarial flow perturbation — the dataset's vintage doesn't matter. For production-quality results, the answer is CIC-IDS2017 (see §8); the lab provides a direct upgrade path.

How does this connect to Lab 17?

Lab 16 attacks an IDS in the feature space: the attacker controls the inputs the model reads (byte counts, durations) and reshapes them within network-valid bounds. Lab 17 attacks an image classifier in the pixel space: the attacker adds tiny, imperceptible noise to pixel values and watches the classifier flip. Same abstract attack — gradient-following perturbation of model inputs to flip the decision — but different domains, different constraints, different defenses.

Are XGBoost / LightGBM faster than sklearn's HistGradientBoosting?

Slightly. The runtime difference at 125k rows × 41 columns is negligible (under a second either way). XGBoost and LightGBM win at large scale (millions of rows) and on specialized GPU paths. For pedagogy and laptop reproducibility, sklearn's HistGradientBoostingClassifier is the right choice — the API is identical and no native-library install is needed.

Why doesn't the envelope defense work better?

Because the attacker is going to the benign distribution, not away from it. Distributional anomaly detection sees the magnitude of deviation; the perturbation has near-zero magnitude on the wrong axis. The envelope catches noisy attacks, not calibrated ones. This is the same reason why MLE-based outlier detection fails on careful adversarial examples in Lab 17.

Could you write a Snort signature that catches Attack 1?

For a specific perturbation, yes — the signature would look for "Neptune-shaped flow but with src_bytes > 200." For the general perturbation class, no — the attacker has too many degrees of freedom and the signature space is unbounded. This is the recurring lesson: signatures catch known attacks well, ML generalizes (badly) to novel ones, and the union of the two is what production NIDS actually deploy.