Files
labs/Notebooks-CSAI/Lab6_Honeypot_Network_Anomaly.ipynb

66 KiB

Module 6 — Phát hiện bất thường trên mạng, bằng dữ liệu honeypot THẬT 5 tháng

Cách làm: bấm vào một ô code rồi nhấn Shift + Enter để chạy ô đó và sang ô kế tiếp. Chạy lần lượt từ trên xuống. Kernel phải là Python (lớp CSAI). Ô có dòng ✏️ SỬA là chỗ mời bạn đổi số / chữ rồi chạy lại. Sửa hỏng? Menu Kernel → Restart Kernel and Run All Cells.

🎯 Mục tiêu của bài

Học xong bài này, bạn:

  1. Đo được vì sao danh sách đen / chữ ký luôn đi sau kẻ tấn công một bước (Phần 1).
  2. Tính được một đặc trưng hành vi mạng: beaconing — nhịp gọi về đều đặn (Phần 2).
  3. So ngưỡng cố định với ngưỡng động trên chuỗi số liệu theo giờ (Phần 3).
  4. Dùng Isolation Forest nhìn nhiều cột cùng lúc cho từng giờ và giải thích bằng SHAP (Phần 4).
  5. Nhận ra dữ liệu trôi: "bình thường" thay đổi theo tháng (Phần 5).
  6. Săn lùng chủ động: gom cụm K-Means các IP dùng honeypot làm bàn đạp (Phần 6).
  7. Đánh giá khi log không có sẵn đáp án, và thấy kẻ tấn công né bộ phát hiện thế nào (Phần 7, 8).

🗺️ Bài này: bài toán gì, mô hình nào, vì sao?

  • Bài toán: tìm hành vi bất thường trên mạng — giờ bùng nổ, IP gọi về đều nhịp như máy, IP dùng máy chủ làm bàn đạp — mà không cần biết trước IP hay chữ ký của kẻ tấn công. Đây là bài phát hiện bất thường và gom nhóm hành vi.
  • Dữ liệu: số liệu theo giờ (chuỗi thời gian), bảng đường hầm theo ngày, và log gốc 30 ngày; không có nhãn.
  • Vì vậy chọn: mốc = danh sách đen; không có nhãn → ngưỡng trên chuỗi thời gian (một chỉ số), Isolation Forest (nhiều cột), K-Means (gom IP), giải thích bằng SHAP. Bảng ① ② ở mục Trước khi bắt đầu ngay dưới đây.
  • Xử lý gì trước: biết dữ liệu có lỗ ở đâu (giờ cảm biến không ghi) trước khi kết luận "giờ yên lặng".
  • Thước đo: không có nhãn → Phần 7 tự dựng một nhãn gần đúng rồi mới tính precision / recall.

Câu hỏi của cả bài: kẻ tấn công đổi IP, đổi công cụ, đổi mã độc liên tục — danh sách đen và "chữ ký" luôn đi sau một bước. Làm sao phát hiện chúng bằng hành vi trên mạng: nhịp gọi về đều đặn, giờ bùng nổ bất thường, kiểu dùng máy chủ làm bàn đạp?

Dữ liệu (thật, từ honeypot SSH của mình):

File Nội dung
/opt/csai/data/m6_honeypot/hourly.csv mỗi giờ một dòng, 05/05 → 22/09/2026: số kết nối, đăng nhập sai / đúng, số lệnh gõ, số file tải về, số yêu cầu đường hầm, số IP, IP và dải mạng mạnh nhất giờ đó
/opt/csai/data/m6_honeypot/tunnels.csv kẻ tấn công dùng honeypot làm bàn đạp (proxy, direct-tcpip): mỗi ngày, IP nào, xin đi tới đâu, cổng nào, bao nhiêu lần
/opt/csai/data/honeypot_m1/ log gốc 30 ngày (24/08 → 22/09), dùng cho beaconing và danh sách đen

Pipeline — cách làm mọi bài toán AI trong khoá, nhớ thuộc lòng 7 bước. Mỗi phần ghi 📍 Bước ở đầu:

Bước Câu hỏi phải trả lời Phần
1. Thu thập dữ liệu dữ liệu gì, lấy ở đâu, thu thập thế nào, có nhãn hay không nhãn? Phần 0 (5 câu hỏi), 0.1 · đường hầm 6.1 · log gốc 1.1
2. Làm sạch dữ liệu có gì bẩn / thiếu: giờ cảm biến không ghi, IP nội bộ? Phần 0, 0.2
3. Chọn mô hình phù hợp với bài toán bài toán gì, có nhãn không, cách cũ (danh sách đen) làm được đến đâu? 0.3, Phần 1
4. Coding tính đặc trưng hành vi, dựng bộ phát hiện Phần 2 (beaconing), 3 (ngưỡng), 4 (Isolation Forest), 6 (K-Means)
5. Lưu ý khi coding lỗi nào làm bộ phát hiện sai mà code không báo lỗi? Phần 5 ("bình thường" đã học bị cũ)
6. Đánh giá các mô hình so các bộ phát hiện khi không có đáp án; kẻ tấn công né được không? Phần 7, 8
7. Quy tắc bàn tay phải ✋** chọn công cụ nào cho việc gì, mang gì theo khi đi làm Phần 9

📌 Tra cứu nhanh: Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì — mục Trước khi bắt đầu, ngay dưới đây.

Trước khi bắt đầu — Chọn mô hình nào? Khi nào dùng gì, đánh đổi gì

(Đọc thêm — để tra lại khi cần. Lần đầu có thể đọc lướt, chạy xong bài rồi quay lại.)

Không có mô hình "tốt nhất" cho mọi bài toán (No Free Lunch). Chọn mô hình = trả lời lần lượt 4 câu hỏi:

① Có đáp án (nhãn) không?

  • Có nhãn từ quá khứ (email đã biết sạch / lừa đảo, file đã biết là mã độc) → học có giám sát: Naïve Bayes, Logistic Regression, cây quyết định, Random Forest, XGBoost, SVM.
  • Không có nhãn, hoặc phải tìm cái chưa từng thấy (zero-day) → học không giám sát: Isolation Forest tìm điểm lạ, K-Means gom nhóm hành vi (ví dụ UBA: tách bot tự động khỏi thao tác của người mà không cần ai gắn nhãn). ⚠️ Mô hình không giám sát chỉ nói "khác đám đông", không nói "xấu". Trên honeypot, đám đông chính là kẻ tấn công (Lab 3, 5.0).
  • Có rất ít nhãn → bắt đầu bằng luật + không giám sát, rồi gom nhãn dần từ các cảnh báo đã được người phân tích xác nhận.

② Dữ liệu dạng gì?

Dạng dữ liệu Thường dùng Trong khoá
Bảng số (mỗi đối tượng = một hàng số) mô hình cây: cây quyết định, Random Forest, XGBoost · Logistic Regression làm mốc so sánh Lab 3 (mỗi IP 7 số), M5, Lab 7 (hồ sơ hành vi), M8–M10
Văn bản (email, tin nhắn, log dạng chữ) Naïve Bayes / Logistic Regression trên số đếm từ (TF-IDF) · mô hình ngôn ngữ (BERT, LLM) khi cần hiểu ngữ cảnh M1–M2 (Naïve Bayes), Lab 4 (Perceptron, SVM, Logistic Regression, Naïve Bayes, Voting), Lab 10
Chuỗi theo thời gian (số kết nối mỗi giờ, CPU mỗi phút, chuỗi lời gọi API của một chương trình) phân rã chuỗi thời gian (xu hướng + nhịp ngày + phần dư) · HMM, LSTM khi cần học thứ tự sự kiện M4.2, bài này Phần 3
Tệp nhị phân (mã độc) trích đặc trưng (kích thước, entropy…) rồi dùng mô hình cây · đổi file thành ảnh + CNN là hướng nghiên cứu, cần nhiều dữ liệu và GPU M5

③ Sợ bỏ lọt hay sợ báo nhầm?

  • Sợ bỏ lọt (mã độc, xâm nhập, lỗ hổng khi pentest) → ưu tiên recall. Sợ báo nhầm (chặn email khách hàng, khoá tài khoản nhân viên) → ưu tiên precision.
  • ⚠️ Hiểu lầm hay gặp: "muốn precision cao thì chọn Naïve Bayes / SVM". Thực ra gần như mọi mô hình đều ra một điểm / xác suất, và ngưỡng cắt điểm đó mới quyết định đánh đổi: hạ ngưỡng → bắt thêm nhưng báo nhầm thêm (Lab 3 9E.3; Lab 4 9.3). Chọn mô hình tốt rồi chọn ngưỡng theo sức đội SOC.
  • Xác suất của Naïve Bayes thường quá tự tin (sát 0 hoặc 1); SVM mặc định không ra xác suất → muốn đặt ngưỡng theo xác suất phải hiệu chỉnh (calibration) trước.
  • Tìm bất thường / gom nhóm: không có đáp án để tính precision, recall → dùng điểm bất thường, độ tách nhóm (Silhouette), và quan trọng nhất là người phân tích xem lại top N (Lab 3, 5.6); ở bài này phải tự định nghĩa nhãn rồi mới đo được (Phần 7).

④ Ràng buộc khi chạy thật

  • Giải thích được: cần báo cáo kiểm toán / ban giám đốc → hộp trắng (luật, cây quyết định nông, Logistic Regression, Naïve Bayes: đọc được nhánh IF/ELSE hoặc trọng số từ). Cần bắt kẻ tinh vi (APT, tấn công "chậm mà chắc") → chấp nhận hộp đen (Random Forest nhiều cây, XGBoost, deep learning) rồi giải trình bằng SHAP (bài này 4.2; Lab 3 Phần 7).
  • Tốc độ / phần cứng: router, IoT, máy trạm (RAM < 1 GB, CPU yếu) → luật, cây nông, Logistic Regression, Isolation Forest nhỏ. Trung tâm dữ liệu / cloud có GPU → deep learning, LLM.
  • Mất cân bằng lớp (tấn công hiếm): không mô hình nào tự xử lý — phải chỉnh class_weight hoặc ngưỡng. Đo ở Lab 3 (9E): tấn công chỉ 5% → Random Forest mặc định bắt được 65%, thêm class_weight="balanced" → 81%, đổi lại báo nhầm tăng từ 3 lên 12 IP.
  • Bị né: mô hình dựa vào đặc trưng mà kẻ tấn công tự đổi được (tên phần mềm, tốc độ thử) thì dễ bị né (bài này Phần 8: thêm jitter; Lab 3 Phần 8).

Bảng tra nhanh: khi nào dùng gì, đánh đổi gì

Công cụ Dùng khi Được Phải chấp nhận Trong khoá
Luật / ngưỡng (IF/ELSE) kiểu tấn công đã biết rõ nhanh, không cần dữ liệu học, ai cũng hiểu chỉ bắt đúng cái đã viết; dễ bị né (thử chậm, Lab 3 1.2) M1, Lab 3 Phần 1, bài này Phần 1 (danh sách đen)
Naïve Bayes văn bản, ít dữ liệu, cần nhanh học rất nhanh, chạy được trên máy yếu, xem được từ nào "nặng ký" coi các từ độc lập với nhau; xác suất quá tự tin; bị lách bằng cách đổi từ M1–M2, Lab 4 Phần 7
Logistic Regression cần mốc so sánh đơn giản, cần giải thích bằng trọng số nhanh, ổn định, xác suất dùng được chỉ vẽ được ranh giới "thẳng"; quan hệ phức tạp thì kém Lab 4 Phần 6
SVM dữ liệu vừa, nhiều chiều (văn bản) tách tốt khi ít mẫu chậm khi dữ liệu lớn; mặc định không ra xác suất; khó giải thích Lab 4 Phần 4–5
Cây quyết định cần luật đọc được cho người không chuyên ra đúng dạng IF/ELSE cây sâu dễ "học thuộc lòng" (overfit); dữ liệu đổi chút là cây đổi Lab M2 (M2.3), M5
Random Forest dữ liệu bảng có nhãn, cần ổn định chính xác, ít phải tinh chỉnh, ít overfit hơn một cây khó đọc từng quyết định (cần SHAP / độ quan trọng đặc trưng); nặng hơn một cây Lab 3 Phần 9, M8–M10
XGBoost dữ liệu bảng lớn, cần chính xác nhất thường đứng đầu trên dữ liệu bảng; chạy được GPU nhiều tham số phải chỉnh, chỉnh ẩu dễ overfit; khó giải thích M5, Lab 10 5.1
Isolation Forest không có nhãn, tìm cái lạ không cần ai gắn nhãn; ra thứ hạng ưu tiên "lạ" ≠ "xấu"; dữ liệu bẩn làm lệch (Lab 3 Phần 6); phải chọn contamination nếu dùng predict (Lab 3 5.6) Lab 3 Phần 5, bài này Phần 4, Lab 7 Phần 7
K-Means gom nhóm hành vi (người dùng, IP) thấy được "các kiểu" hành vi phải chọn trước số nhóm k; phải chuẩn hoá; chỉ hợp với nhóm "tròn", cỡ gần nhau bài này Phần 6, Lab 7
Phân rã chuỗi thời gian số liệu theo giờ / ngày có nhịp lặp tách nhịp bình thường khỏi đột biến cần chuỗi đủ dài; nhịp thay đổi thì phải tính lại M4.2, bài này Phần 3
Deep learning (LSTM, CNN) dữ liệu rất lớn, dạng chuỗi / ảnh học được mẫu phức tạp cần rất nhiều dữ liệu và GPU; hộp đen Lab 10 5.1 (mạng nơ-ron MLP nhỏ)
LLM đọc, tóm tắt, giải thích log / cảnh báo viết báo cáo nhanh có thể bịa; tốn tiền; không để LLM tự quyết định chặn Lab 3 Phần 10

Tóm lại: chọn mô hình là bài toán cân đối (thiết kế cả hệ thống, không chỉ chọn một thuật toán): biết mình có dữ liệu gì (có nhãn không, dạng bảng / chữ / chuỗi) và cần tối ưu điều gì (bỏ lọt hay báo nhầm, giải thích hay tốc độ). Hệ thống thật hầu như luôn kết hợp nhiều lớp (luật + không giám sát + có giám sát) — bảng ở 9.1 là ví dụ của bài này; quy tắc bàn tay ở Lab 3 Phần 11.

📌 Pipeline xử lý luôn là 7 bước (chi tiết ở bảng đầu bài):

  1. Thu thập dữ liệu — có nhãn hay không nhãn?
  2. Làm sạch dữ liệu
  3. Chọn mô hình phù hợp với bài toán (4 câu hỏi ① – ④ ở trên)
  4. Coding
  5. Lưu ý khi coding — những lỗi không báo lỗi
  6. Đánh giá các mô hình
  7. Quy tắc bàn tay phải ✋

Phần 0 — Thu thập dữ liệu: dữ liệu gì, lấy ở đâu, có nhãn chưa, có cần làm sạch?

📍 Bước 1 — Thu thập dữ liệu và Bước 2 — Làm sạch.

5 câu hỏi về dữ liệu — trả lời trước khi viết dòng code nào:

Câu hỏi Trả lời cho dữ liệu bài này
Dữ liệu gì? 3 bảng: theo giờ (mỗi giờ một dòng — 0.1), đường hầm (mỗi ngày × IP × nơi muốn tới × cổng — Phần 6), và log gốc 30 ngày (như Lab 3 — Phần 1, 2)
Lấy ở đâu? honeypot SSH thật của mình (phần mềm Cowrie), cả kho log 05/05 → 22/09/2026
Thu thập thế nào? Cowrie tự ghi mọi sự kiện; khi xuất cho lớp, log được gom sẵn: theo giờ (giờ Việt Nam: số kết nối, đăng nhập sai / đúng, lệnh, tải file, đường hầm, số IP, IP và dải /24 mạnh nhất) và theo ngày cho đường hầm
Có nhãn chưa? Không. Không ai gắn "giờ này bị tấn công". Phần 7 sẽ tự dựng một nhãn gần đúng để đo
Có cần làm sạch? (a) đã làm khi xuất: bỏ IP nội bộ và IP của người quản trị. (b) trên bảng: phải kiểm tra có giờ nào cảm biến không ghi không (0.2) — giờ đó không phải "0 tấn công", đừng điền 0 rồi kết luận

Giờ xem tận mắt bảng theo giờ (0.1) và kiểm tra giờ bị thiếu (0.2).

0.1 Bảng theo giờ

In [ ]:
import glob
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
pd.set_option("display.width", 200)
RANDOM_STATE = 42

# parse_dates: đọc cột gio_vn thành kiểu thời gian (để vẽ và lọc theo ngày / tháng)
theo_gio = pd.read_csv("/opt/csai/data/m6_honeypot/hourly.csv", parse_dates=["gio_vn"])
# 📦 theo_gio = bảng, mỗi dòng = 1 giờ (giờ Việt Nam), các cột là số liệu của giờ đó
print("theo_gio.shape =", theo_gio.shape, "-> (số giờ, số cột)")
print("Từ", theo_gio.gio_vn.min(), "đến", theo_gio.gio_vn.max())
theo_gio.head(3)

Kết quả: theo_gio.shape = (3363, 12) · Từ 2026-05-05 07:00:00 đến 2026-09-22 18:00:00, rồi 3 dòng đầu (ví dụ 05/05 7h: 12 kết nối, 14 đăng nhập sai, 10 IP, IP mạnh nhất 87.251.64.176).

📦 theo_gio = bảng 3.363 giờ × 12 cột (Phần 3, 4, 5, 7 dùng).

👉 Hiểu: 395 nghìn dòng log mỗi tháng (đếm ở Lab 3, 0.3) đã được gom theo giờ thành vài con số — mỗi giờ là một "bức ảnh chụp" của mạng.

Cột Nghĩa
ket_noi số lần kết nối tới honeypot trong giờ đó
dang_nhap_sai, dang_nhap_dung số lần đăng nhập sai / đúng (honeypot cố tình cho vào để ghi lại hành vi)
lenh số lệnh kẻ tấn công gõ sau khi vào
tai_file số lần tải file (thường là mã độc)
duong_ham số lần xin dùng honeypot làm đường hầm đi tiếp tới nơi khác
so_ip số IP khác nhau trong giờ
top_ip, top_ip_ket_noi IP kết nối nhiều nhất giờ đó, và số kết nối của nó
top_dai, top_dai_ket_noi dải mạng /24 mạnh nhất giờ đó, và số kết nối của dải

0.2 Làm sạch dữ liệu: có giờ nào bị thiếu không?

In [ ]:
du_gio = pd.date_range(theo_gio.gio_vn.min(), theo_gio.gio_vn.max(), freq="h")   # mọi giờ lẽ ra phải có
thieu = du_gio.difference(theo_gio.gio_vn)                                          # giờ không có dòng nào
print(f"Lẽ ra có {len(du_gio)} giờ, bảng có {len(theo_gio)} -> thiếu {len(thieu)} giờ")
print("Thiếu theo ngày:", pd.Series(thieu.date).value_counts().sort_index().to_dict())

Kết quả: Lẽ ra có 3372 giờ, bảng có 3363 -> thiếu 9 giờ · Thiếu theo ngày: {25/06: 6, 22/07: 1, 29/08: 2}

👉 Hiểu: 9 giờ không có một sự kiện nào — với honeypot bị gõ cửa mỗi 19 giây (Lab 3, 0.3), gần như chắc chắn đó là lúc chính cảm biến ngừng ghi (khởi động lại, mất mạng), không phải lúc kẻ tấn công nghỉ. Dữ liệu thật luôn có lỗ; phải biết lỗ ở đâu trước khi kết luận.

0.3 Vậy đây là bài toán gì? Chọn cách giải nào?

📍 Bước 3 — Chọn mô hình phù hợp với bài toán.

Không có nhãn, dữ liệu là chuỗi số theo giờ → phát hiện bất thường không giám sát: ngưỡng trên một chỉ số (Phần 3), Isolation Forest trên nhiều cột (Phần 4); muốn biết các kiểu kẻ tấn công thì gom cụm (Phần 6). Mốc so sánh: danh sách đen (Phần 1).

👉 Hiểu: chọn cách giải sau khi đã biết dữ liệu có nhãn hay không, dạng gì, có lỗ ở đâu (đầu Phần 0, 0.2).


Phần 1 — "Chữ ký" và danh sách đen sụp đổ thế nào?

📍 Bước 3 — Chọn mô hình (mốc so sánh): cách cũ — chặn mọi IP đã thấy — làm được đến đâu?

Cách truyền thống: thấy IP xấu thì đưa vào danh sách chặn, giống truy nã theo biển số xe. Ta thử đúng cách đó trên log thật: lấy mọi IP tấn công trong 2 tuần đầu làm danh sách chặn, rồi xem nó chặn được bao nhiêu trong 2 tuần sau.

In [ ]:
cac_file = sorted(glob.glob("/opt/csai/data/honeypot_m1/cowrie.json.*"))
log = pd.concat(pd.read_json(f, lines=True, dtype=False, convert_dates=False) for f in cac_file)
ket_noi = log[log.eventid == "cowrie.session.connect"].copy()
ket_noi["t"] = pd.to_datetime(ket_noi.timestamp, utc=True)
# 📦 log = log gốc 30 ngày · ket_noi = các dòng kết nối, thêm cột thời gian t

# ✏️ SỬA: ngày chia đôi (trước = học danh sách chặn, sau = thử)
MOC = pd.Timestamp("2026-09-08", tz="UTC")
truoc, sau = ket_noi[ket_noi.t < MOC], ket_noi[ket_noi.t >= MOC]
danh_sach_chan = set(truoc.src_ip)                       # mọi IP đã thấy trước mốc
la_ip_moi = ~sau.src_ip.isin(danh_sach_chan)             # True = IP chưa từng thấy
print(f"Danh sách chặn (IP thấy trước {MOC:%d/%m}): {len(danh_sach_chan)} IP")
print(f"Sau {MOC:%d/%m}: {sau.src_ip.nunique()} IP tấn công, trong đó {sau.src_ip[la_ip_moi].nunique()} IP MỚI (không có trong danh sách)")
print(f"-> {la_ip_moi.mean():.1%} số kết nối sau mốc đến từ IP mới: danh sách chặn KHÔNG chặn được")

Kết quả: Danh sách chặn (IP thấy trước 08/09): 1496 IP · Sau 08/09: 1579 IP tấn công, trong đó 1410 IP MỚI · -> 85.7% số kết nối sau mốc đến từ IP mới: danh sách chặn KHÔNG chặn được

📦 log = log gốc 30 ngày · ket_noi = các dòng kết nối (Phần 2, 8 dùng) · danh_sach_chan = tập 1.496 IP.

👉 Hiểu: chặn mọi IP đã thấy suốt 2 tuần, mà 2 tuần sau 9/10 kẻ tấn công là gương mặt mới và gây ra 86% lưu lượng. Danh sách đen luôn đi sau một bước — đúng lý do phải chuyển sang nhìn hành vi.


Phần 2 — Đặc trưng hành vi mạng: beaconing (nhịp gọi về đều đặn)

📍 Bước 4 — Coding: tính một đặc trưng hành vi từ log gốc.

Phần mềm tự động (bot, mã độc gọi về máy chủ điều khiển) thường kết nối theo nhịp cố định: cứ đúng X giây một lần. Con người thì không bao giờ đều như vậy. Đây là đặc trưng không phụ thuộc IP hay chữ ký — đổi IP, đổi mã độc mà vẫn giữ nhịp thì vẫn bị lộ.

Với mỗi IP, ta đo:

  • nhip = khoảng cách trung vị giữa 2 lần kết nối liên tiếp (giây) — "bao lâu gọi một lần";
  • do_lech = khoảng cách thường lệch khỏi nhịp bao nhiêu, chia cho nhịp. Gần 0 = đều tăm tắp như đồng hồ; lớn = thất thường.

2.1 Tìm các IP đều nhịp nhất

In [ ]:
ket_noi = ket_noi.sort_values("t")
# Khoảng cách (giây) từ mỗi kết nối tới kết nối TRƯỚC của cùng IP
ket_noi["cach"] = ket_noi.groupby("src_ip").t.diff().dt.total_seconds()

def do_lech_tuong_doi(x):
    """Độ lệch tuyệt đối trung vị so với nhịp, chia cho nhịp (0 = đều tuyệt đối)."""
    x = x.dropna()
    nhip_ = x.median()
    return np.median(np.abs(x - nhip_)) / max(nhip_, 1)

# ✏️ SỬA: chỉ xét IP có ít nhất bao nhiêu kết nối (ít quá thì "nhịp" không có nghĩa)
IT_NHAT = 50
so_lan = ket_noi.src_ip.value_counts()
du_nhieu = ket_noi[ket_noi.src_ip.isin(so_lan[so_lan >= IT_NHAT].index)]     # chỉ giữ kết nối của các IP đủ nhiều lần
nhom = du_nhieu.groupby("src_ip")
beacon = pd.DataFrame({"so_ket_noi": nhom.size(), "nhip": nhom.cach.median(), "do_lech": nhom.cach.apply(do_lech_tuong_doi),
                       "so_ngay": nhom.t.apply(lambda t: t.dt.date.nunique())})
# 📦 beacon = bảng mỗi IP (đủ nhiều kết nối) 1 hàng: số kết nối, nhịp (giây), độ lệch, số ngày
print(f"{len(beacon)} IP có >= {IT_NHAT} kết nối. 8 IP ĐỀU NHỊP nhất (nhịp > 10 giây):")
beacon[beacon.nhip > 10].sort_values("do_lech").head(8).round(3)

Kết quả: 218 IP có >= 50 kết nối. 8 IP ĐỀU NHỊP nhất (nhịp > 10 giây):

                so_ket_noi      nhip  do_lech  so_ngay
139.19.117.129          51  3600.355    0.001        5
139.19.117.131          59  3600.366    0.001        4
103.148.79.138          80   686.107    0.001        2
103.148.108.83         531    86.986    0.002        1
130.49.213.228          73   309.492    0.002        1
203.115.98.138         443   283.749    0.003        2
103.59.59.106          621    36.986    0.004        1
20.168.128.80           58   304.950    0.006        1

📦 beacon = mỗi IP (≥ 50 kết nối) một hàng: số kết nối, nhịp (giây), độ lệch, số ngày · IT_NHAT = ngưỡng số kết nối tối thiểu.

👉 Hiểu: hai IP 139.19.117.129 và .131 (cùng dải) gọi đúng mỗi 3.600 giây = mỗi giờ, lệch chưa tới 0,1%, suốt 4–5 ngày — đúng kiểu "gọi về nhà" theo lịch. Các IP khác giữ nhịp 37 giây, 87 giây, 5 phút, 11 phút gần như tuyệt đối. Không người nào gõ đều được như vậy → máy, bất kể IP là gì.

2.2 Nhìn tận mắt: một nhịp "máy" so với một nhịp "lộn xộn"

Cách đọc hình: mỗi cột đứng = một lần kết nối, đặt theo thời gian. Nhịp máy = các cột cách đều nhau như răng lược; nhịp lộn xộn = lúc dày lúc thưa.

In [ ]:
# ✏️ SỬA: 2 IP muốn so (lấy từ bảng 2.1)
IP_DEU, IP_LON_XON = "130.49.213.228", "185.246.128.133"
hinh, o = plt.subplots(2, 1, figsize=(14, 4))
for truc, ip in zip(o, [IP_DEU, IP_LON_XON]):
    t_ip = ket_noi[ket_noi.src_ip == ip].t.head(40)                       # 40 kết nối đầu của IP
    truc.vlines(t_ip, 0, 1, color="#e76f51"); truc.set_yticks([])
    truc.set_title(f"{ip}: nhịp {beacon.loc[ip, 'nhip']:,.1f} giây, độ lệch {beacon.loc[ip, 'do_lech']:.3f} (40 kết nối đầu)")
plt.tight_layout(); plt.show()

Kết quả: 2 hình. Trên: 130.49.213.228: nhịp 309.5 giây, độ lệch 0.002 — 40 vạch cách đều tuyệt đối như răng lược trong 3,5 giờ. Dưới: 185.246.128.133: nhịp 1,226.9 giây, độ lệch 0.117 — vạch lúc dày lúc thưa, nghỉ hẳn từ khoảng 8h đến 13h.

👉 Hiểu: nhìn bằng mắt cũng thấy ngay: hàng răng lược = một chương trình hẹn giờ. 185.246.128.133 vẫn là máy (Module 3: có mặt 30/30 ngày), nhưng nhịp của nó thất thường hơn — một đặc trưng không đủ, cần nhìn nhiều đặc trưng cùng lúc (Phần 4).


Phần 3 — NIDS truyền thống trên chuỗi thời gian: ngưỡng cố định vs ngưỡng động

📍 Bước 4 — Coding: bộ phát hiện đơn giản nhất trên chuỗi theo giờ.

Cách cũ: "quá X kết nối mỗi giờ → báo động". X đặt một lần, dùng mãi. Cách tốt hơn: ngưỡng động — so mỗi giờ với mức thường gặp của chính 7 ngày gần nhất, nên ngưỡng tự lên khi mạng bận, tự xuống khi mạng vắng.

  • Ngưỡng cố định = trung bình + 3 × độ lệch chuẩn của cả 5 tháng.
  • Ngưỡng động = trung vị 7 ngày gần nhất + K × độ lệch trung vị (MAD — "thường cách trung vị bao nhiêu") của 7 ngày đó. Dùng trung vị vì nó không bị vài giờ bùng nổ kéo lệch.

Cách đọc hình: đường xanh = số kết nối mỗi giờ; đường đỏ ngang = ngưỡng cố định; đường cam = ngưỡng động; chấm đỏ = giờ vượt ngưỡng động.

In [ ]:
chuoi = theo_gio.set_index("gio_vn").ket_noi                  # chuỗi số kết nối, chỉ mục là thời gian
nguong_co_dinh = chuoi.mean() + 3 * chuoi.std()
# ✏️ SỬA: K càng lớn ngưỡng động càng "khó tính" (ít báo hơn). Thử 3, 6, 10
K = 6
trung_vi_7n = chuoi.rolling("7D").median()                     # rolling("7D") = cửa sổ trượt 7 ngày tính ngược về trước
mad_7n = (chuoi - trung_vi_7n).abs().rolling("7D").median().clip(lower=1)   # clip: tối thiểu 1 để ngưỡng không bằng 0
nguong_dong = trung_vi_7n + K * mad_7n
vuot_co_dinh, vuot_dong = chuoi > nguong_co_dinh, chuoi > nguong_dong
# 📦 chuoi = số kết nối theo giờ · nguong_dong = ngưỡng động từng giờ · vuot_* = True ở giờ vượt ngưỡng
print(f"Ngưỡng cố định = {nguong_co_dinh:,.0f} kết nối/giờ -> báo {vuot_co_dinh.sum()} giờ")
print(f"Ngưỡng động (K={K}) -> báo {vuot_dong.sum()} giờ (trong đó {(vuot_dong & vuot_co_dinh).sum()} giờ ngưỡng cố định cũng báo)")

plt.figure(figsize=(15, 4))
plt.plot(chuoi.index, chuoi, lw=0.5, label="kết nối / giờ")
plt.plot(nguong_dong.index, nguong_dong, lw=0.8, color="orange", label=f"ngưỡng động (K={K})")
plt.axhline(nguong_co_dinh, color="red", label="ngưỡng cố định")
plt.scatter(chuoi.index[vuot_dong], chuoi[vuot_dong], s=8, color="red", zorder=3, label="vượt ngưỡng động")
plt.yscale("log"); plt.ylabel("kết nối / giờ (thang log)"); plt.legend(loc="upper left"); plt.show()

Kết quả: Ngưỡng cố định = 1,023 kết nối/giờ -> báo 66 giờ · Ngưỡng động (K=6) -> báo 353 giờ (trong đó 66 giờ ngưỡng cố định cũng báo), rồi biểu đồ 5 tháng (thang log): đường xanh dao động ~20 → ~1.000 kết nối/giờ, ngưỡng cố định là một vạch đỏ ngang ở 1.023, ngưỡng động (cam) đi lên đi xuống theo mạng.

📦 chuoi = số kết nối theo giờ · nguong_dong = ngưỡng động từng giờ · vuot_co_dinh, vuot_dong = True ở giờ vượt ngưỡng (Phần 7 dùng).

👉 Hiểu:

  • Ngưỡng cố định chỉ bắt các giờ khổng lồ; giai đoạn mạng vắng (đầu tháng 5, tháng 8) có những giờ cao gấp nhiều lần mức thường ngày mà vẫn dưới 1.023 → bị bỏ qua.
  • Ngưỡng động tự hạ khi mạng vắng nên bắt được chúng — đổi lại báo nhiều hơn (353 giờ ≈ 2,5 giờ/ngày).
  • Chú ý tuần đầu (đầu tháng 5): ngưỡng động chưa có đủ 7 ngày để học "bình thường" nên báo dồn dập — mọi mô hình học hành vi đều cần thời gian khởi động.

3.2 Ai gây ra các giờ bùng nổ?

In [ ]:
cot_xem = ["ket_noi", "so_ip", "top_ip", "top_ip_ket_noi", "top_dai", "top_dai_ket_noi"]
bung_no = theo_gio.set_index("gio_vn")[vuot_dong.to_numpy()].sort_values("ket_noi", ascending=False)
bung_no = bung_no.assign(ti_le_dai=(bung_no.top_dai_ket_noi / bung_no.ket_noi).round(2))
print("8 giờ bùng nổ lớn nhất (vượt ngưỡng động):")
bung_no[cot_xem + ["ti_le_dai"]].head(8)

Kết quả: 8 giờ bùng nổ lớn nhất, ví dụ:

                     ket_noi  so_ip          top_ip  top_ip_ket_noi          top_dai  top_dai_ket_noi  ti_le_dai
2026-07-05 13:00:00     3564     21     36.67.6.139            3333     36.67.6.0/24             3333       0.94
2026-05-24 03:00:00     2403     43  135.125.29.132            2080  135.125.29.0/24             2080       0.87
2026-07-07 02:00:00     2357     19   185.144.27.63            1674  185.144.27.0/24             1674       0.71
...                                135.125.29.132 (23/05 22h -> 24/05 4h, 5 giờ liền)

👉 Hiểu: mỗi giờ bùng nổ có một thủ phạm: 1 IP gây ra 70–94% số kết nối. 135.125.29.132 xả liên tục suốt đêm 23→24/05 (5 giờ liền, mỗi giờ ~2.000 kết nối). Báo động "giờ này nhiều kết nối" chưa đủ — cột top_ip cho đội SOC biết chặn ai ngay.


Phần 4 — Isolation Forest: nhìn nhiều cột cùng lúc cho từng giờ

📍 Bước 4 — Coding: bộ phát hiện nhiều cột, rồi hỏi vì sao (SHAP).

Ngưỡng ở Phần 3 chỉ nhìn một cột (ket_noi). Một giờ có thể bình thường về số kết nối nhưng lạ ở chỗ khác: tải file nhiều bất thường, hoặc im lặng bất thường. Isolation Forest (đã học ở Module 3) chấm điểm mỗi giờ trên 7 cột cùng lúc: giờ nào bị "chặt riêng ra" nhanh nhất là giờ bất thường nhất.

4.1 Giờ nào bất thường nhất?

In [ ]:
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
COT = ["ket_noi", "dang_nhap_sai", "dang_nhap_dung", "lenh", "tai_file", "duong_ham", "so_ip"]
# Nén số lớn (log1p) rồi chuẩn hóa, như Module 3
X_gio = pd.DataFrame(StandardScaler().fit_transform(np.log1p(theo_gio[COT])), columns=COT)
# ✏️ SỬA: tỉ lệ giờ coi là bất thường (0.01 = 1%)
TI_LE = 0.01
rung = IsolationForest(n_estimators=300, contamination=TI_LE, random_state=RANDOM_STATE).fit(X_gio)
theo_gio["diem"] = rung.decision_function(X_gio)                 # càng âm càng bất thường
# 📦 X_gio = 7 cột đã chuẩn bị · rung = Isolation Forest đã học · theo_gio.diem = điểm của từng giờ
print(f"Số giờ bị gắn cờ: {(rung.predict(X_gio) == -1).sum()} / {len(theo_gio)}")
theo_gio.sort_values("diem").head(8)[["gio_vn"] + COT + ["top_ip", "diem"]].round(3)

Kết quả: Số giờ bị gắn cờ: 34 / 3363, 8 giờ bất thường nhất: 05/07 15:00 (2.021 kết nối, 421 lần tải file, 112 IP), 05/09 09:00 và 05/09 10:00 (chỉ 2–3 kết nối), 24/05 03:00 (2.403 kết nối), 10/08 19:00 (2 kết nối), và các giờ đêm 23–24/05.

📦 X_gio = 7 cột đã nén + chuẩn hóa · rung = Isolation Forest đã học · theo_gio.diem = điểm từng giờ (càng âm càng lạ).

👉 Hiểu: Isolation Forest bắt hai kiểu bất thường mà ngưỡng một cột không thấy:

  • giờ bận khác kiểu: 05/07 15h có 421 lần tải file (trung vị 5 tháng: 0) — rất có thể là một đợt cài mã độc hàng loạt (cần mở log giờ đó ra xem);
  • giờ im lặng bất thường: honeypot thường nhận ~110 kết nối/giờ mà có giờ chỉ 2 — rất có thể cảm biến gặp sự cố. Bất thường ≠ tấn công; với SOC, "cảm biến bị mù" cũng là chuyện phải biết.

4.2 Vì sao giờ đó bất thường? (SHAP)

SHAP chia điểm của mô hình cho từng cột (như Module 3): cột nào "đẩy" giờ đó về phía bất thường nhiều nhất. Đóng góp vào cảnh báo dương = cột đó làm giờ đó lạ hơn.

In [ ]:
import warnings
warnings.filterwarnings("ignore", message="IProgress not found")
import shap
giai_thich = shap.TreeExplainer(rung)
def vi_sao(vi_tri):
    """In giá trị 7 cột và đóng góp vào cảnh báo (−SHAP) của 1 giờ."""
    sv = giai_thich(X_gio.iloc[[vi_tri]])
    return pd.DataFrame({"giá trị giờ này": theo_gio.loc[vi_tri, COT], "trung vị 5 tháng": theo_gio[COT].median(),
                         "đóng góp vào cảnh báo": -sv.values[0]}, index=COT).sort_values("đóng góp vào cảnh báo", ascending=False).round(2)

hai_gio = theo_gio.sort_values("diem").index[:2]               # 2 giờ bất thường nhất
for vt in hai_gio:
    print(f"=== {theo_gio.loc[vt, 'gio_vn']:%d/%m %H:%M} (điểm {theo_gio.loc[vt, 'diem']:+.3f}) ===")
    print(vi_sao(vt).to_string(), "\n")

Kết quả:

=== 05/07 15:00 (điểm -0.099) ===
               giá trị giờ này  trung vị 5 tháng  đóng góp vào cảnh báo
tai_file                   421               0.0                   1.76
dang_nhap_dung            1310              43.0                   1.20
so_ip                      112              19.0                   1.11
ket_noi                   2021             113.0                   1.04
...
=== 05/09 09:00 (điểm -0.081) ===
so_ip                        2              19.0                   1.81
dang_nhap_sai                1              45.0                   1.36
dang_nhap_dung               0              43.0                   1.00
...

📦 giai_thich = bộ giải thích SHAP · vi_sao(vi_tri) = hàm trả bảng đóng góp cho 1 giờ.

👉 Hiểu: SHAP nói rõ hai lý do khác nhau: 05/07 15h lạ vì tải file (421 so với 0 thường ngày) → điều tra mã độc; 05/09 9h lạ vì quá ít IP và gần như không ai đăng nhập → kiểm tra cảm biến. Cùng là "điểm âm", nhưng hành động tiếp theo khác hẳn.


Phần 5 — Dữ liệu trôi (drift): "bình thường" không đứng yên

📍 Bước 5 — Lưu ý khi coding: mô hình học "bình thường" từ tháng cũ vẫn chạy, không báo lỗi, nhưng so với một "bình thường" đã cũ.

Mọi bộ phát hiện bất thường đều so với "bình thường" đã học. Nhưng bình thường thay đổi: chiến dịch tấn công bắt đầu, kết thúc, đổi công cụ.

Cách đọc hình: mỗi ô nhỏ là 1 cột, mỗi cột đứng là tổng của 1 tháng.

In [ ]:
theo_gio["thang"] = theo_gio.gio_vn.dt.month
theo_thang = theo_gio.groupby("thang")[COT[:-1]].sum()
# 📦 theo_thang = tổng mỗi cột theo tháng
theo_thang.plot(kind="bar", subplots=True, layout=(2, 3), figsize=(14, 5), legend=False, rot=0, color="#457b9d")
plt.tight_layout(); plt.show()
theo_thang

Kết quả: 6 biểu đồ cột theo tháng và bảng:

thang  ket_noi  dang_nhap_sai  dang_nhap_dung   lenh  tai_file  duong_ham
5       109142          45975           61836  58704      5714       2347
6       120556          68095           41115  35128      7422      12898
7       240081         115785          102049  92524      7359      22737
8       119769          73573           42197  52610        77       7489
9       109942          48183           56203  58552       655       7683

📦 theo_thang = tổng mỗi cột theo tháng.

👉 Hiểu:

  • Tải file sập từ ~7.400/tháng xuống 77 vào tháng 8: chiến dịch cài khóa SSH mdrfckr (chiếm 97% số lần tải file của tháng 7 theo phân tích dữ liệu tháng 7) đã dừng.
  • Kết nối tháng 7 gấp đôi các tháng khác; đường hầm tăng từ 2.347 lên 22.737 rồi giảm.
  • Một mô hình học "bình thường" từ tháng 5–7 sẽ coi tháng 8 "yên ắng lạ thường" ở cột tải file — "bình thường" đã trôi. Bộ phát hiện phải được học lại định kỳ.

Phần 6 — Săn lùng chủ động: gom cụm các IP dùng honeypot làm bàn đạp

📍 Bước 1 + 4 — Thu thập (bảng đường hầm) và Coding (K-Means).

Kẻ tấn công vào được máy chủ thường dùng nó làm bàn đạp (proxy): từ máy chủ của ta, mở kết nối đi tiếp tới nơi khác — để giấu IP thật, gửi thư rác, hoặc kiểm tra xem proxy có dùng được không. Honeypot ghi lại các yêu cầu đó (direct-tcpip) nhưng không cho đi thật.

Không có ai gắn nhãn "IP này là kiểu gì". K-Means tự gom các IP có hành vi giống nhau vào cùng một cụm; người phân tích chỉ cần đọc từng cụm và đặt tên.

6.1 Xem dữ liệu đường hầm

In [ ]:
duong_ham = pd.read_csv("/opt/csai/data/m6_honeypot/tunnels.csv")
# 📦 duong_ham = mỗi dòng: ngày, IP kẻ tấn công, đích muốn tới, cổng đích, số lần
print("duong_ham.shape =", duong_ham.shape, f"| {duong_ham.so_lan.sum():,} yêu cầu từ {duong_ham.src_ip.nunique()} IP tới {duong_ham.dst_ip.nunique()} đích")
print("\nTheo CỔNG đích (80 = web, 25 = gửi thư, 443 = web mã hóa, 53 = DNS):")
print(duong_ham.groupby("dst_port").so_lan.sum().sort_values(ascending=False).head(6).to_string())
print("\n6 ĐÍCH được xin nhiều nhất:")
print(duong_ham.groupby("dst_ip").so_lan.sum().sort_values(ascending=False).head(6).to_string())
print("\n5 IP xin nhiều nhất:")
print(duong_ham.groupby("src_ip").so_lan.sum().sort_values(ascending=False).head(5).to_string())

Kết quả: duong_ham.shape = (5011, 5) | 53,154 yêu cầu từ 777 IP tới 183 đích · theo cổng: 80: 48893, 25: 2185, 443: 1609, 53: 297 · đích nhiều nhất: 1.1.1.1: 12078, rồi 5 địa chỉ IPv6 dạng 2001:4998:... (~2.800 mỗi địa chỉ) · IP xin nhiều nhất: 185.246.128.133: 25927, 179.43.139.58: 9298, 103.77.14.62: 7601.

📦 duong_ham = bảng 5.011 dòng: ngày, IP, đích, cổng, số lần.

👉 Hiểu:

  • Cổng 80 chiếm 92% — dùng máy của ta làm proxy web (giấu IP thật khi lướt / quét web).
  • Cổng 25 (2.185 lần) = gửi thư: các địa chỉ 2001:4998:... thuộc dải IPv6 của Yahoo (tra cứu chủ sở hữu: YAHOO-IPV6-BLK-2, Yahoo Holdings Inc.) — kẻ tấn công thử dùng máy ta phát tán thư rác qua máy chủ thư của Yahoo.
  • 1.1.1.1 là máy chủ DNS công cộng — thường dùng để kiểm tra proxy có thông không.
  • Lại là 185.246.128.133 — kẻ trinh sát "chậm mà chắc" của Module 3 — chiếm 49% mọi yêu cầu đường hầm.

6.2 Gom cụm K-Means

Mỗi IP thành một hàng 6 đặc trưng: tổng số lần, số đích khác nhau, số cổng khác nhau, số ngày có mặt, tỉ lệ yêu cầu tới cổng 25 (gửi thư), tỉ lệ tới cổng 443. K-Means đặt SO_CUM "tâm cụm" rồi xếp mỗi IP vào tâm gần nhất, lặp lại tới khi ổn định. Vì K-Means đo khoảng cách, bắt buộc phải nén + chuẩn hóa trước (xem Module 3, phần 4d).

In [ ]:
from sklearn.cluster import KMeans
nhom_ip = duong_ham.groupby("src_ip")
hanh_vi = pd.DataFrame({"so_lan": nhom_ip.so_lan.sum(), "so_dich": nhom_ip.dst_ip.nunique(), "so_cong": nhom_ip.dst_port.nunique(),
                        "so_ngay": nhom_ip.ngay_vn.nunique(),
                        "ti_le_cong_25": nhom_ip.dst_port.apply(lambda s: (s == 25).mean()),
                        "ti_le_cong_443": nhom_ip.dst_port.apply(lambda s: (s == 443).mean())})
X_ip = StandardScaler().fit_transform(np.log1p(hanh_vi[["so_lan", "so_dich", "so_cong", "so_ngay"]]).join(hanh_vi[["ti_le_cong_25", "ti_le_cong_443"]]))
# ✏️ SỬA: số cụm. Thử 3, 5
SO_CUM = 4
hanh_vi["cum"] = KMeans(n_clusters=SO_CUM, n_init=10, random_state=RANDOM_STATE).fit_predict(X_ip)
# 📦 hanh_vi = mỗi IP 1 hàng + cột 'cum' (số cụm K-Means gán)
chan_dung = hanh_vi.groupby("cum").agg(so_ip=("so_lan", "size"), tong_yeu_cau=("so_lan", "sum"), lan_trung_vi=("so_lan", "median"),
                                       dich_trung_vi=("so_dich", "median"), ngay_trung_vi=("so_ngay", "median"),
                                       ti_le_cong_25=("ti_le_cong_25", "mean"), ti_le_cong_443=("ti_le_cong_443", "mean")).round(2)
print("Chân dung từng cụm:")
print(chan_dung.to_string())
print("\nVí dụ 3 IP mỗi cụm (nhiều yêu cầu nhất):")
for c in sorted(hanh_vi.cum.unique()):
    print(f"  cụm {c}:", list(hanh_vi[hanh_vi.cum == c].so_lan.sort_values(ascending=False).index[:3]))

Kết quả: chân dung 4 cụm:

     so_ip  tong_yeu_cau  lan_trung_vi  dich_trung_vi  ngay_trung_vi  ti_le_cong_25  ti_le_cong_443
cum
0      700          1168           1.0            1.0            1.0           0.94            0.00
1       19         49773         289.0            5.0            6.0           0.00            0.37
2        4          2140         402.5            3.0           30.0           0.23            0.32
3       54            73           1.0            1.0            1.0           0.00            1.00

ví dụ: cụm 1 gồm 185.246.128.133, 179.43.139.58, 103.77.14.62; cụm 2 gồm 178.16.54.226, 23.175.50.52, 193.46.255.86.

📦 hanh_vi = mỗi IP một hàng 6 đặc trưng + cột cum · chan_dung = bảng trên.

👉 Hiểu — đặt tên cho từng cụm (máy chỉ gom, người đặt tên):

  • Cụm 0 — "dò máy gửi thư rác": 700 IP, mỗi IP thử 1 lần, 94% vào cổng 25 → quét hàng loạt xem máy nào gửi thư được.
  • Cụm 3 — "kiểm tra proxy": 54 IP, 1 lần, 100% cổng 443.
  • Cụm 1 — "dùng proxy nặng": chỉ 19 IP nhưng 49.773 yêu cầu (94%), nhiều đích, vài ngày.
  • Cụm 2 — "bám dai": 4 IP có mặt 30 ngày, đa dạng cổng.

Chỉ 4 dòng chân dung thay cho 777 IP — đó là săn lùng chủ động: không đợi cảnh báo, chủ động gom hành vi rồi xem nhóm nào nguy hiểm.


Phần 7 — Đánh giá khi log không có sẵn đáp án

📍 Bước 6 — Đánh giá các mô hình: dựng nhãn gần đúng rồi so các bộ phát hiện trên cùng nhãn.

Precision / recall cần đáp án. Log honeypot không ghi sẵn "giờ này là tấn công". Cách thường làm: người phân tích tự định nghĩa một nhãn rõ ràng, có thể kiểm tra lại, rồi đo các bộ phát hiện theo nhãn đó — và luôn nhớ nhãn này chỉ là một góc nhìn.

Nhãn ở đây: "giờ bị một dải mạng áp đảo" = giờ có ít nhất NGUONG_KN kết nối và một dải /24 chiếm ít nhất TI_LE_DAI số kết nối (một nguồn duy nhất xả dồn dập — kiểu chiến dịch tự động).

In [ ]:
from sklearn.metrics import precision_score, recall_score, f1_score
# ✏️ SỬA: định nghĩa nhãn
NGUONG_KN, TI_LE_DAI = 500, 0.8
nhan = ((theo_gio.ket_noi >= NGUONG_KN) & (theo_gio.top_dai_ket_noi / theo_gio.ket_noi >= TI_LE_DAI)).astype(int).to_numpy()
print(f"Theo nhãn này có {nhan.sum()} 'giờ bị áp đảo' trên {len(nhan)} giờ")
bo_phat_hien = {
    "Ngưỡng cố định (Phần 3)": vuot_co_dinh.to_numpy().astype(int),
    f"Ngưỡng động K={K} (Phần 3)": vuot_dong.to_numpy().astype(int),
    "Isolation Forest 7 cột (Phần 4)": (rung.predict(X_gio) == -1).astype(int),
}
dong = []
for ten, du_doan in bo_phat_hien.items():
    dong.append({"bộ phát hiện": ten, "số giờ báo": du_doan.sum(), "precision": precision_score(nhan, du_doan, zero_division=0),
                 "recall": recall_score(nhan, du_doan), "F1": f1_score(nhan, du_doan)})
pd.DataFrame(dong).set_index("bộ phát hiện").round(3)

Kết quả: Theo nhãn này có 197 'giờ bị áp đảo' trên 3363 giờ, rồi:

                                 số giờ báo  precision  recall     F1
Ngưỡng cố định (Phần 3)                  66      0.591   0.198  0.297
Ngưỡng động K=6 (Phần 3)                353      0.433   0.777  0.556
Isolation Forest 7 cột (Phần 4)          34      0.441   0.076  0.130

👉 Hiểu:

  • Với nhãn "một nguồn xả dồn dập", ngưỡng động tốt nhất: bắt 78% số giờ đó. Ngưỡng cố định bắt được chưa tới 1/5.
  • Isolation Forest điểm thấp — không phải vì nó kém, mà vì nó trả lời câu hỏi khác: "giờ nào lạ trên 7 cột" (gồm cả giờ im lặng, giờ tải file), không phải "giờ nào bị một nguồn xả". Nhãn quyết định "ai giỏi" — đổi NGUONG_KN, TI_LE_DAI rồi chạy lại để thấy thứ hạng thay đổi.
  • Bài học: khi không có đáp án sẵn, hãy viết rõ định nghĩa nhãn và nói rõ bộ phát hiện được đo theo định nghĩa nào.

Phần 8 — Kẻ tấn công né: thêm "độ rung" (jitter) vào nhịp

📍 Bước 6 — Đánh giá các mô hình (thử tấn công): kẻ tấn công biết ta đo gì thì né thế nào?

Nếu kẻ tấn công biết ta bắt beaconing bằng độ đều (Phần 2), chúng thêm độ trễ ngẫu nhiên (jitter) vào mỗi lần gọi về. Ta giả lập điều đó trên nhịp thật của IP đều nhất ở Phần 2: cộng thêm một khoảng ngẫu nhiên ±J giây vào từng khoảng cách, rồi đo lại độ lệch.

In [ ]:
cach_that = ket_noi[ket_noi.src_ip == IP_DEU].cach.dropna().to_numpy()      # các khoảng cách thật của IP đều nhịp
ngau_nhien = np.random.RandomState(0)
# ✏️ SỬA: các mức jitter thử (giây). Ngưỡng nghi ngờ beaconing ở đây: độ lệch < NGUONG_DEU
NGUONG_DEU = 0.05
for J in [0, 5, 30, 60, 150]:
    cach_moi = cach_that + ngau_nhien.uniform(-J, J, len(cach_that))
    dl = np.median(np.abs(cach_moi - np.median(cach_moi))) / np.median(cach_moi)
    print(f"jitter ±{J:3d} giây: độ lệch {dl:.3f} -> {'BỊ PHÁT HIỆN (đều nhịp)' if dl < NGUONG_DEU else 'né được'}")
print(f"\nNhưng: IP này vẫn kết nối {beacon.loc[IP_DEU, 'so_ket_noi']} lần, nhịp trung vị {np.median(cach_that):.0f} giây — số lượng và nhịp TRUNG BÌNH không đổi")

Kết quả:

jitter ±  0 giây: độ lệch 0.002 -> BỊ PHÁT HIỆN (đều nhịp)
jitter ±  5 giây: độ lệch 0.006 -> BỊ PHÁT HIỆN (đều nhịp)
jitter ± 30 giây: độ lệch 0.051 -> né được
jitter ± 60 giây: độ lệch 0.106 -> né được
jitter ±150 giây: độ lệch 0.279 -> né được
Nhưng: IP này vẫn kết nối 73 lần, nhịp trung vị 309 giây — số lượng và nhịp TRUNG BÌNH không đổi

📦 cach_that = các khoảng cách thật của IP đều nhịp nhất · NGUONG_DEU = ngưỡng "đều nhịp" (sửa được).

👉 Hiểu: chỉ cần rung ±30 giây (10% nhịp) là qua mặt bộ phát hiện "đều nhịp". Nhưng kẻ tấn công không giấu được việc kết nối 73 lần, trung bình ~5 phút/lần, suốt cả buổi. Phòng thủ tốt = nhiều đặc trưng cùng lúc (số lần, số ngày, đích đến...) và tập cho mô hình cả các mẫu đã bị "rung" — kẻ tấn công né được một thước đo, khó né được tất cả.


Phần 9 — Chọn thuật toán nào cho việc gì? Tổng kết

📍 Bước 7 — Quy tắc bàn tay phải: tổng kết, chọn công cụ, báo cáo.

Bài này đã đo được gì?

Các con số chính, theo đúng thứ tự pipeline:

  • Danh sách đen (1.1): 85,7% kết nối sau mốc đến từ IP mới → luật / chữ ký chỉ bắt cái đã biết.
  • Cùng một nhãn, ba bộ phát hiện (7.1): ngưỡng động bắt 78% số "giờ bị áp đảo", Isolation Forest chỉ 8%: không phải IF kém, mà nó trả lời câu hỏi khác ("giờ nào lạ trên 7 cột"). Chọn công cụ theo câu hỏi.
  • Bị né (8.1): rung nhịp ±30 giây là qua mặt bộ phát hiện "đều nhịp", nhưng số lần kết nối vẫn tố cáo → dùng nhiều đặc trưng.

Quy tắc bàn tay ✋ áp vào bài này

Xòe bàn tay, đếm từ ngón cái: 5 điều không được bỏ của mọi bài toán phát hiện tấn công bằng AI (đầy đủ ở Lab 3 Phần 11). Bài này đã làm đủ 5 điều:

Ngón Điều Câu hỏi phải tự trả lời Trong bài này Quên điều này thì…
👍 1. Ngón cái DỮ LIỆU sạch Dữ liệu có lỗ không? Có lẫn IP nhà mình không? 9 giờ cảm biến không ghi (0.2) · IP nội bộ, IP quản trị đã bỏ khi xuất điền 0 vào giờ thiếu rồi tưởng là "giờ yên lặng"
☝️ 2. Ngón trỏ ĐẶC TRƯNG đúng Mô tả kẻ tấn công bằng hành vi gì? độ đều của nhịp gọi về (Phần 2) · 7 cột theo giờ (Phần 4) chỉ dựa vào IP / chữ ký: 85,7% kết nối sau mốc đến từ IP mới (1.1)
✋ 3. Ngón giữa CHỌN CÔNG CỤ Không có nhãn thì dùng gì cho câu hỏi nào? ngưỡng động (một chỉ số) · Isolation Forest (nhiều cột) · K-Means (gom IP) — bảng 9.1 dùng một công cụ cho mọi câu hỏi: Isolation Forest chỉ bắt 8% số "giờ bị áp đảo" (7.1)
💍 4. Ngón áp út ĐO trung thực Đo theo nhãn nào? "Bình thường" còn đúng không? nhãn gần đúng viết rõ định nghĩa: ngưỡng động F1 0,556 so với ngưỡng cố định 0,297 (7.1) · tải file từ ~7.400 / tháng còn 77 vào tháng 8 (Phần 5) không biết bộ phát hiện nào tốt hơn; mô hình so với một "bình thường" đã cũ
🤙 5. Ngón út GIẢI THÍCH & CẢNH GIÁC Vì sao giờ đó bị báo? Kẻ tấn công né được không? SHAP cho từng giờ (4.2) · rung nhịp ±30 giây là né được bộ phát hiện "đều nhịp" (8.1) · người phân tích quyết định cảnh báo không ai hiểu; kẻ tấn công né mà không ai biết

Chọn thuật toán nào cho việc gì?

Thuật toán Cần đáp án? Ở bài này Hợp với
Danh sách đen / chữ ký không Phần 1: 85% kết nối đến từ IP mới chặn nhanh mối đe dọa đã biết
Ngưỡng cố định / động không Phần 3: bùng nổ trên một chỉ số giám sát một chỉ số, dễ giải thích
Isolation Forest không Phần 4: giờ lạ trên nhiều cột tìm cái lạ chưa có tên
K-Means không Phần 6: chân dung các nhóm kẻ dùng bàn đạp săn lùng chủ động, phân nhóm hành vi
Random Forest / Decision Tree có Module 3 Phần 9 khi có nhãn tốt, cần độ chính xác

Bảng tra đầy đủ các mô hình của cả khoá (khi nào dùng, đánh đổi gì): mục Trước khi bắt đầu ở đầu bài.

Tương lai (2026): các hệ thống phát hiện mạng thế hệ mới học "bình thường" của từng mạng, kết hợp mô hình ngôn ngữ để giải thích cảnh báo bằng lời. Nhưng nguyên lý vẫn như trong bài: đặc trưng hành vi + so với bình thường + người phân tích quyết định.

In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_lab6"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)
gio_dau = theo_gio.sort_values("diem").iloc[0]
bao_cao = f'''BÁO CÁO MODULE 6: BẤT THƯỜNG MẠNG TRÊN HONEYPOT ({theo_gio.gio_vn.min():%d/%m} - {theo_gio.gio_vn.max():%d/%m/%Y}, {len(theo_gio):,} giờ)
==================================================================================
Danh sách đen      : {la_ip_moi.mean():.0%} kết nối sau {MOC:%d/%m} đến từ IP mới -> không thể dựa vào danh sách chặn
Beaconing          : {(beacon[beacon.nhip > 10].do_lech < NGUONG_DEU).sum()} IP gọi đều nhịp (độ lệch < {NGUONG_DEU}), ví dụ {IP_DEU} mỗi {beacon.loc[IP_DEU, 'nhip']:,.0f} giây
Ngưỡng cố định     : báo {int(vuot_co_dinh.sum())} giờ | ngưỡng động K={K}: báo {int(vuot_dong.sum())} giờ
Isolation Forest   : giờ bất thường nhất {gio_dau.gio_vn:%d/%m %H:%M} ({int(gio_dau.ket_noi)} kết nối, {int(gio_dau.tai_file)} lần tải file)
Đường hầm          : {duong_ham.so_lan.sum():,} yêu cầu từ {duong_ham.src_ip.nunique()} IP, gom thành {SO_CUM} cụm hành vi
Dữ liệu trôi       : tải file tháng 7 = {int(theo_thang.loc[7, 'tai_file']):,} -> tháng 8 = {int(theo_thang.loc[8, 'tai_file']):,}

Khuyến nghị:
- Giám sát theo HÀNH VI (nhịp, giờ bùng nổ, kiểu dùng bàn đạp), không chỉ theo IP / chữ ký.
- Dùng ngưỡng động và học lại "bình thường" định kỳ vì dữ liệu trôi theo tháng.
- Mọi điểm bất thường là gợi ý điều tra: có thể là tấn công, cũng có thể là sự cố của chính cảm biến.
'''
(THU_MUC_RA / "bao_cao_lab6.txt").write_text(bao_cao, encoding="utf-8")
print(bao_cao)

Kết quả: báo cáo in ra và lưu ở ~/lab/out_lab6/bao_cao_lab6.txt: Danh sách đen: 86% kết nối sau 08/09 đến từ IP mới · Beaconing: 27 IP gọi đều nhịp (độ lệch < 0.05), ví dụ 130.49.213.228 mỗi 309 giây · Ngưỡng cố định: báo 66 giờ | ngưỡng động K=6: báo 353 giờ · Isolation Forest: giờ bất thường nhất 05/07 15:00 (2021 kết nối, 421 lần tải file) · Đường hầm: 53,154 yêu cầu từ 777 IP, gom thành 4 cụm hành vi · Dữ liệu trôi: tải file tháng 7 = 7,359 -> tháng 8 = 77, rồi 3 khuyến nghị.

👉 Hiểu: đổi các ô ✏️ (mốc ngày, K, TI_LE, SO_CUM...) rồi Run All → báo cáo tự cập nhật theo.

Câu hỏi suy nghĩ:

  1. Phần 1: nếu danh sách chặn gồm cả dải /24 thay vì từng IP thì chặn được nhiều hơn không? (gợi ý: thử đổi src_ip thành 3 số đầu)
  2. Phần 3: đổi K thành 3 và 10. Bạn chấp nhận bao nhiêu báo động mỗi ngày?
  3. Phần 4: một giờ quá yên lặng có đáng báo động không? Trong SOC, ai cần biết điều đó?
  4. Phần 8: kẻ tấn công thêm jitter thì bộ phát hiện nhịp đều bị né. Đặc trưng nào khác vẫn tố cáo chúng?