Files
labs/Notebooks-CSAI/Lab_M1_tung_khoi.ipynb

52 KiB

Lab Module 1 — chạy từng khối code

Cách làm: bấm vào một ô code rồi nhấn Shift + Enter để chạy ô đó và sang ô kế tiếp. Kết quả hiện ngay dưới ô. Chạy lần lượt từ trên xuống (ô sau dùng kết quả của ô trước). Kernel phải là Python (lớp CSAI) (góc trên bên phải).

Ô có dòng ✏️ SỬA là chỗ mời bạn đổi chữ / số rồi Shift + Enter lại để xem kết quả thay đổi thế nào. Sửa hỏng? Menu Kernel → Restart Kernel and Run All Cells để chạy lại từ đầu.

3 bài: M1 (bộ lọc từ khóa) · M1.2 (log honeypot thật) · M1.3 (máy học phân loại email).


Bài M1 — Bộ lọc từ khóa (luật cứng IF/ELSE) và cú "lách luật"

M1-1. Đọc 50 tin nhắn có nhãn

In [ ]:
import pandas as pd
# Bảng hiện đủ nội dung tin (mặc định pandas cắt ở 50 ký tự)
pd.set_option("display.max_colwidth", 150)
# Đọc file CSV: mỗi dòng 1 tin nhắn, 5 cột
tin = pd.read_csv("~/lab/day1_labs/Manual_SOC_Dataset.csv")
# Đặt tên ngắn cho 5 cột: mã tin, kênh, người gửi, nội dung, nhãn đúng
tin.columns = ["ma", "kenh", "nguoi_gui", "noi_dung", "nhan"]
print("Số tin:", len(tin))
# Đếm mỗi loại nhãn có bao nhiêu tin
print(tin.nhan.value_counts().to_string())
# Dòng cuối của ô = bảng hiện ra bên dưới: 5 tin đầu
tin.head(5)

Kết quả: Số tin: 50 · Lừa đảo (Phishing) 30 · An toàn (Benign) 20, rồi bảng 5 tin đầu (MSG-001 … MSG-005).

👉 Hiểu: mỗi tin có nhãn đúng do người soạn bài gắn sẵn → dùng để chấm xem bộ lọc đoán đúng bao nhiêu.

M1-2. Viết luật: 2 danh sách "đáng ngờ"

In [ ]:
# ✏️ SỬA: danh sách TỪ KHÓA đáng ngờ (viết chữ thường). Tin nào chứa 1 từ trong đây -> coi là lừa đảo
TU_KHOA = ["thanh toán gấp", "chuyển gấp", "bảo trì hệ thống", "số tài khoản mới",
           "tai nạn giao thông", "viện phí", "quên mang ví", "trúng thưởng",
           "chiết khấu 50%", "quay trộm", "mờ ám", "bitcoin", "vneid", ".apk"]
# ✏️ SỬA: danh sách TÊN MIỀN giả mạo. Người gửi hoặc nội dung có tên miền này -> lừa đảo
TEN_MIEN = ["doitac-uytln", "gmali", "vnpt-billing-pay", "viettel-nhanthuong",
            "shopee-sale-khung", "vieclam-tainha", "cong-an-dieu-tra", "webmail-verify", "micros0ft"]
print(len(TU_KHOA), "từ khóa,", len(TEN_MIEN), "tên miền")

Kết quả: 14 từ khóa, 9 tên miền

👉 Hiểu: đây là toàn bộ "trí khôn" của bộ lọc: 23 chuỗi chữ do người viết tay.

M1-3. Hàm kiểm tra một tin: IF/ELSE

In [ ]:
def la_lua_dao(noi_dung, nguoi_gui, tu_khoa, ten_mien):
    """Trả về (True/False, lý do). True = dính luật = lừa đảo."""
    # Đổi hết sang chữ thường để "TRÚNG THƯỞNG" cũng khớp "trúng thưởng"
    noi_dung = noi_dung.lower()
    nguoi_gui = nguoi_gui.lower()
    # Luật 1: nội dung có chứa từ khóa nào không?
    for tu in tu_khoa:
        if tu in noi_dung:
            return True, "từ khóa '" + tu + "'"
    # Luật 2: người gửi hoặc nội dung có tên miền giả mạo nào không?
    for mien in ten_mien:
        if mien in nguoi_gui or mien in noi_dung:
            return True, "tên miền '" + mien + "'"
    # Không dính luật nào -> an toàn
    return False, ""

# ✏️ SỬA: viết thử tin nhắn của bạn trong ngoặc kép (nội dung, người gửi) rồi chạy lại
print(la_lua_dao("Chúc mừng bạn đã TRÚNG THƯỞNG 1 chiếc iPhone!", "Shopee", TU_KHOA, TEN_MIEN))
print(la_lua_dao("Tối nay mấy giờ đá bóng nhỉ?", "Hưng IT", TU_KHOA, TEN_MIEN))

Kết quả:

(True, "từ khóa 'trúng thưởng'")
(False, '')

👉 Hiểu: máy chỉ so chữ: thấy đúng chuỗi trong danh sách thì báo lừa đảo, không thấy thì cho qua. Thử viết "trúng giải" thay cho "TRÚNG THƯỞNG" xem máy còn bắt không.

M1-4. Chạy bộ lọc trên cả 50 tin

In [ ]:
def cham_bo_tin(bo_tin, tu_khoa, ten_mien):
    """Chạy la_lua_dao trên từng tin của bảng, thêm 3 cột: may_doan, ly_do, dung"""
    kq = bo_tin.copy()
    doan = [la_lua_dao(nd, ng, tu_khoa, ten_mien) for nd, ng in zip(kq.noi_dung, kq.nguoi_gui)]
    kq["may_doan"] = ["Lừa đảo" if bat else "An toàn" for bat, ly_do in doan]
    kq["ly_do"] = [ly_do for bat, ly_do in doan]
    # Đúng khi máy đoán "Lừa đảo" và nhãn đúng cũng là lừa đảo (hoặc cả hai đều an toàn)
    kq["dung"] = (kq.may_doan == "Lừa đảo") == kq.nhan.str.contains("Lừa đảo")
    return kq

kq = cham_bo_tin(tin, TU_KHOA, TEN_MIEN)
print("Đoán đúng:", kq.dung.sum(), "/", len(kq), f"({kq.dung.mean():.0%})")
# Bảng các tin máy đoán SAI (~ nghĩa là "không")
kq[~kq.dung][["ma", "nguoi_gui", "noi_dung", "nhan", "may_doan"]]

Kết quả: Đoán đúng: 48 / 50 (96%), rồi bảng 2 tin máy đoán sai: MSG-005 và MSG-018 (cùng một tin tống tiền "Mật khẩu 'honghanh123' của bạn đã bị lộ...", nhãn Lừa đảo, máy đoán An toàn).

👉 Hiểu: trên tin quen thuộc (luật được viết ra từ chính những tin này) bộ lọc đúng 96%. Tin tống tiền lọt vì không chứa chữ nào trong danh sách — thử thêm "bị lộ" vào TU_KHOA ở ô M1-2, chạy lại M1-2 rồi ô này.

M1-5. Máy nhanh cỡ nào?

In [ ]:
import time
# ✏️ SỬA: số tin muốn thử (10_000 = mười nghìn). Thử 100_000 rồi 1_000_000
SO_TIN = 10_000
# Nhân bản 50 tin cho đủ SO_TIN tin
nhieu_tin = pd.concat([tin] * (SO_TIN // len(tin)), ignore_index=True)
bat_dau = time.perf_counter()
kq_nhieu = cham_bo_tin(nhieu_tin, TU_KHOA, TEN_MIEN)
giay = time.perf_counter() - bat_dau
print(f"Máy quét {len(nhieu_tin):,} tin trong {giay:.2f} giây, đúng {kq_nhieu.dung.mean():.0%}")
print(f"Người đọc 10 giây/tin cần {len(nhieu_tin) * 10 / 3600:,.1f} giờ")

Kết quả (số giây mỗi lần một khác là bình thường): Máy quét 10,000 tin trong 0.0x giây, đúng 96% · Người đọc 10 giây/tin cần 27.8 giờ

👉 Hiểu: máy không biết mệt: hàng chục nghìn tin trong chưa tới 1 giây.

M1-6. Kẻ tấn công viết lại tin để "lách luật"

In [ ]:
# 10 tin lừa đảo được viết lại, tránh đúng các chữ có trong luật
le = pd.read_csv("~/lab/day1_labs/Evasion_Dataset.csv")
le.columns = ["ma", "kenh", "nguoi_gui", "noi_dung", "nhan"]
kq_le = cham_bo_tin(le, TU_KHOA, TEN_MIEN)
print("Bắt được:", (kq_le.may_doan == "Lừa đảo").sum(), "/", len(le), "tin lừa đảo")
kq_le[["ma", "nguoi_gui", "noi_dung", "may_doan", "ly_do"]]

Kết quả: Bắt được: 2 / 10 tin lừa đảo. Bảng: EVA-004 bị bắt vì tên miền 'webmail-verify', EVA-007 vì từ khóa '.apk', 8 tin còn lại An toàn.

👉 Hiểu: so tin lọt với luật: luật có doitac-uytln nhưng kẻ tấn công viết doitac-uytin; luật có tai nạn giao thông nhưng tin viết "bị tông xe". Chỉ cần đổi cách viết là qua mặt luật cứng.

M1-7. Thử làm người viết luật: vá thêm từ khóa

In [ ]:
# ✏️ SỬA: thêm từ khóa mới (nhìn các tin BỎ LỌT ở bảng trên để chọn), rồi chạy lại ô này
THEM_TU_KHOA = ["tông xe", "chuyển khoản", "tài khoản"]
TU_KHOA_MOI = TU_KHOA + THEM_TU_KHOA

moi_le = cham_bo_tin(le, TU_KHOA_MOI, TEN_MIEN)
moi_50 = cham_bo_tin(tin, TU_KHOA_MOI, TEN_MIEN)
print("10 tin lách luật: bắt được", (moi_le.may_doan == "Lừa đảo").sum(), "/ 10   (luật cũ: 2)")
print("50 tin cũ       : đoán đúng", moi_50.dung.sum(), "/ 50   (luật cũ: 48)")
# Tin AN TOÀN mà luật mới bắt nhầm thành lừa đảo
nham = moi_50[(moi_50.may_doan == "Lừa đảo") & moi_50.nhan.str.contains("An toàn")]
print("Tin an toàn bị báo nhầm:", len(nham))
nham[["ma", "nguoi_gui", "noi_dung", "ly_do"]]

Kết quả: bắt được 5 / 10 (luật cũ: 2) (thêm EVA-001 "chuyển khoản", EVA-003 "tông xe", EVA-010 "tài khoản") · đoán đúng 42 / 50 (luật cũ: 48) · Tin an toàn bị báo nhầm: 6, kèm bảng 6 tin thật (thông báo VNeID, hóa đơn kế toán) bị bắt vì từ khóa 'tài khoản'.

👉 Hiểu: vá luật bắt thêm được tin lách luật, nhưng từ khóa chung chung ("tài khoản") làm báo nhầm tin thật. Thử bỏ "tài khoản" khỏi danh sách rồi chạy lại: hết báo nhầm nhưng cũng bắt ít hơn. Luật cứng luôn phải chạy theo kẻ tấn công → Bài M2 sẽ cho máy tự học thay vì viết tay.


Bài M1.2 — 30 ngày log honeypot THẬT: xem → thống kê → làm sạch → đặc trưng

Honeypot = máy chủ SSH "mồi" cố tình để kẻ tấn công vào. Log ở /opt/csai/data/honeypot_m1 là dữ liệu tấn công thật thu từ máy chủ của mình, 30 ngày 24/08–22/09/2026.

M1.2-① Xem log: mỗi dòng log là gì?

In [ ]:
%%bash
# Xem tên 3 file log đầu tiên (mỗi ngày 1 file, tên có ngày)
ls /opt/csai/data/honeypot_m1 | head -3
# Lấy 3 dòng đầu của file ngày 22/09 và trình bày lại cho dễ đọc (python -m json.tool)
head -3 /opt/csai/data/honeypot_m1/cowrie.json.2026-09-22 | python -m json.tool --json-lines

Kết quả:

cowrie.json.2026-08-24
cowrie.json.2026-08-25
cowrie.json.2026-08-26
{
    "eventid": "cowrie.session.connect",
    "timestamp": "2026-09-22T00:00:02.779416Z",
    "src_ip": "109.160.32.67",
    "src_port": 36330,
    "session": "54613b5cec67"
}
{
    "eventid": "cowrie.client.version",
    "timestamp": "2026-09-22T00:00:02.793738Z",
    "src_ip": "109.160.32.67",
    "session": "54613b5cec67",
    "version": "SSH-2.0-Go"
}
{
    "eventid": "cowrie.login.failed",
    "timestamp": "2026-09-22T00:00:03.650371Z",
    "src_ip": "109.160.32.67",
    "session": "54613b5cec67",
    "username": "ftpuser1",
    "password": "123456"
}

👉 Hiểu: mỗi dòng là một sự kiện; trường eventid cho biết loại:

eventid Nghĩa Trường quan trọng
cowrie.session.connect một máy lạ kết nối vào src_ip = IP kẻ tấn công · timestamp = thời điểm, giờ UTC (chữ Z)
cowrie.client.version kẻ tấn công khai tên phần mềm SSH version (vd SSH-2.0-Go = chương trình viết bằng ngôn ngữ Go)
cowrie.login.failed / .success kẻ tấn công thử đăng nhập (sai / đúng) username, password đã thử

3 dòng mẫu là một lần tấn công (cùng session): kết nối → khai SSH-2.0-Go → thử ftpuser1 / 123456, tất cả trong 1 giây.

M1.2-② Log lớn cỡ nào?

In [ ]:
%%bash
# Có bao nhiêu file log? (ls liệt kê, wc -l đếm số dòng = số file)
ls /opt/csai/data/honeypot_m1 | wc -l
# Cả thư mục nặng bao nhiêu? (du -sh = dung lượng, dạng dễ đọc)
du -sh /opt/csai/data/honeypot_m1
# Tổng cộng bao nhiêu dòng log? (cat ghép 30 file, wc -l đếm dòng)
cat /opt/csai/data/honeypot_m1/cowrie.json.* | wc -l

Kết quả:

30
63M	/opt/csai/data/honeypot_m1
395124

👉 Hiểu: 30 file · 63 MB · 395.124 dòng. Đọc tay mỗi dòng 1 giây = hơn 4 ngày không ngủ.

M1.2-③ Thống kê sơ bộ bằng lệnh Linux

In [ ]:
%%bash
# (1) Mỗi LOẠI sự kiện có bao nhiêu dòng?
#     grep -oh = chỉ lấy phần khớp mẫu "eventid": "..." ; sort | uniq -c = gom giống nhau và đếm ; sort -rn = nhiều nhất lên đầu
grep -oh '"eventid": "[^"]*"' /opt/csai/data/honeypot_m1/cowrie.json.* | sort | uniq -c | sort -rn
# (2) Có bao nhiêu IP KHÁC NHAU đã kết nối?
#     lấy dòng kết nối -> cắt riêng phần IP -> sort -u = bỏ trùng -> đếm dòng
grep -h '"cowrie.session.connect"' /opt/csai/data/honeypot_m1/cowrie.json.* | grep -o '"src_ip": "[^"]*"' | sort -u | wc -l
# (3) Top 5 IP kết nối nhiều nhất
grep -h '"cowrie.session.connect"' /opt/csai/data/honeypot_m1/cowrie.json.* | grep -o '"src_ip": "[^"]*"' | sort | uniq -c | sort -rn | head -5

Kết quả:

 136063 "eventid": "cowrie.session.connect"
 129951 "eventid": "cowrie.client.version"
  65772 "eventid": "cowrie.login.success"
  63338 "eventid": "cowrie.login.failed"
2906
   5569 "src_ip": "154.18.197.29"
   3757 "src_ip": "130.12.182.196"
   2774 "src_ip": "109.160.32.31"
   2350 "src_ip": "103.245.237.17"
   2291 "src_ip": "85.11.167.127"

👉 Hiểu: 136.063 lần kết nối từ 2.906 IP, 4 loại sự kiện trộn lẫn. grep đếm được vài thứ đơn giản, nhưng đổi giờ UTC → giờ Việt Nam hay đếm cặp username/password thì rất khó → cần làm sạch log thành bảng.

M1.2-④ Làm sạch: biến log thành bảng gọn — 5 khối nhỏ

④a — Đọc cả 30 file log vào một bảng

In [ ]:
import glob, os
import pandas as pd
# Lấy danh sách 30 file log, đọc từng file (mỗi dòng 1 sự kiện JSON) rồi ghép lại
cac_file = sorted(glob.glob("/opt/csai/data/honeypot_m1/cowrie.json.*"))
df = pd.concat(pd.read_json(f, lines=True, dtype=False, convert_dates=False) for f in cac_file)
print("Đọc xong:", len(df), "dòng, trộn", df.eventid.nunique(), "loại sự kiện,", len(df.columns), "cột:", list(df.columns))
# Mỗi loại sự kiện chỉ dùng vài cột -> các ô còn lại trống (NaN)
df.head(5)

Kết quả: Đọc xong: 395124 dòng, trộn 4 loại sự kiện, 8 cột: [...], rồi bảng 5 dòng đầu có nhiều ô NaN (trống).

👉 Hiểu: 4 loại sự kiện nằm chung một bảng → mỗi dòng chỉ điền vài cột, còn lại bỏ trống. Cần tách ra.

④b — Tách thành 3 bảng, mỗi bảng chỉ giữ cột cần dùng

In [ ]:
# Kết nối: giờ + IP ;  Đăng nhập (thất bại + thành công): IP + username + password ;  Phần mềm: IP + tên phần mềm
ket_noi   = df[df.eventid == "cowrie.session.connect"][["timestamp", "src_ip"]]
dang_nhap = df[df.eventid.isin(["cowrie.login.failed", "cowrie.login.success"])][["src_ip", "username", "password"]]
phan_mem  = df[df.eventid == "cowrie.client.version"][["src_ip", "version"]]
print("ket_noi:", len(ket_noi), "dòng | dang_nhap:", len(dang_nhap), "dòng | phan_mem:", len(phan_mem), "dòng")
dang_nhap.head(3)

Kết quả: ket_noi: 136063 dòng | dang_nhap: 129110 dòng | phan_mem: 129951 dòng, rồi 3 dòng đầu của bảng đăng nhập.

④c — Xử lý ô trống

In [ ]:
# Đếm ô trống trong từng cột của bảng đăng nhập
print("Ô trống mỗi cột:\n" + dang_nhap.isna().sum().to_string())
# Lần thử đăng nhập KHÔNG gửi mật khẩu -> ghi rõ "(không có)", không xóa dòng
dang_nhap = dang_nhap.fillna({"password": "(không có)"})
print("Sau khi điền, còn ô trống:", dang_nhap.password.isna().sum())

Kết quả: cột password có 244 ô trống, sau khi điền còn 0.

👉 Hiểu: không xóa dòng: "không gửi mật khẩu" cũng là một kiểu hành vi của kẻ tấn công.

④d — Đổi giờ quốc tế (UTC) sang giờ Việt Nam

In [ ]:
# Log ghi giờ UTC (chữ Z ở cuối). Việt Nam = UTC + 7 giờ
ket_noi["gio_vn"] = pd.to_datetime(ket_noi.timestamp, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh")
# Đặt 2 cột cạnh nhau để so, rồi chỉ giữ giờ VN + IP
print(ket_noi[["timestamp", "gio_vn"]].head(3).to_string(index=False))
ket_noi = ket_noi[["gio_vn", "src_ip"]]

Kết quả: 3 dòng, ví dụ 2026-08-24T00:01:03.700755Z → 2026-08-24 07:01:03.700755+07:00

👉 Hiểu: 0h01 UTC = 7h01 sáng Việt Nam. Không đổi giờ thì mọi thống kê "giờ nào bị tấn công nhiều" đều lệch 7 tiếng.

④e — Lưu 3 bảng sạch

In [ ]:
# Lưu vào thư mục ~/m12_sach (các bước sau mở lại từ đây, kể cả khi bạn khởi động lại kernel)
thu_muc = os.path.expanduser("~/m12_sach")
os.makedirs(thu_muc, exist_ok=True)
ket_noi.to_csv(thu_muc + "/ket_noi.csv", index=False)
dang_nhap.to_csv(thu_muc + "/dang_nhap.csv", index=False)
phan_mem.to_csv(thu_muc + "/phan_mem.csv", index=False)
print("Đã lưu:", sorted(os.listdir(thu_muc)))
print("395124 dòng trộn lẫn ->", len(ket_noi), "+", len(dang_nhap), "+", len(phan_mem), "dòng trong 3 bảng gọn")

Kết quả: Đã lưu: ['dang_nhap.csv', 'ket_noi.csv', 'phan_mem.csv'] · 395124 dòng trộn lẫn -> 136063 + 129110 + 129951 dòng trong 3 bảng gọn

M1.2-⑤ Đặc trưng: trả lời 4 câu hỏi, mỗi câu một khối

⑤a — Có bao nhiêu IP tấn công? IP nào nhiều nhất?

In [ ]:
import pandas as pd
# Mở bảng kết nối đã làm sạch ở bước ④
ket_noi = pd.read_csv("~/m12_sach/ket_noi.csv")
# Đếm mỗi IP xuất hiện bao nhiêu lần, xếp nhiều nhất lên đầu
so_lan = ket_noi.src_ip.value_counts()
# len(ket_noi) = số kết nối; len(so_lan) = số IP khác nhau
print("Số lần kết nối:", len(ket_noi), "| Số IP khác nhau:", len(so_lan))
# .head(3) = 3 IP đầu bảng
print("Top 3 IP kết nối nhiều nhất:\n" + so_lan.head(3).to_string())
# (so_lan == 1).sum() = đếm IP chỉ xuất hiện đúng 1 lần
print("Số IP chỉ ghé 1 lần:", (so_lan == 1).sum())

Kết quả: Số lần kết nối: 136063 | Số IP khác nhau: 2906 · top 3: 154.18.197.29 5569, 130.12.182.196 3757, 109.160.32.31 2774 · Số IP chỉ ghé 1 lần: 1103

👉 Hiểu: giống hệt kết quả grep ở bước ③ — nhưng giờ chỉ cần công thức TOP: bảng.cột.value_counts().head(N).

⑤b — Giờ nào bị tấn công nhiều nhất (giờ Việt Nam)?

In [ ]:
import pandas as pd
ket_noi = pd.read_csv("~/m12_sach/ket_noi.csv")
# Đọc lại cột giờ Việt Nam thành thời gian thật, rồi lấy riêng GIỜ (0-23)
gio = pd.to_datetime(ket_noi.gio_vn, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh").dt.hour
# Đếm số kết nối mỗi giờ, xếp theo giờ từ 0h đến 23h
dem = gio.value_counts().sort_index()
# Mỗi giờ in 1 dòng: giờ | số kết nối | thanh # (giờ đông nhất = 40 dấu #)
for h, n in dem.items():
    print(f"{h:02d}h | {n:5d} | " + "#" * int(n * 40 / dem.max()))
# idxmax() = giờ đông nhất; dem.max() / dem.min() = giờ đông gấp mấy lần giờ vắng
print("Giờ đông nhất:", dem.idxmax(), "h (giờ VN) | gấp", round(dem.max() / dem.min(), 1), "lần giờ vắng nhất")

Kết quả: 24 dòng 00h … 23h kèm thanh #, rồi Giờ đông nhất: 0 h (giờ VN) | gấp 1.8 lần giờ vắng nhất

👉 Hiểu: giờ đông nhất chỉ gấp 1,8 lần giờ vắng nhất → tấn công gần như đều suốt 24 giờ: đây là máy chạy tự động, không phải người ngồi gõ theo giờ làm việc.

⑤c — Kẻ tấn công thử username / password nào nhất?

In [ ]:
import pandas as pd
# dtype=str: đọc mọi thứ là chữ (mật khẩu "123456" không bị biến thành số); keep_default_na=False: giữ mật khẩu rỗng
dang_nhap = pd.read_csv("~/m12_sach/dang_nhap.csv", dtype=str, keep_default_na=False)
print("Tổng số lần thử đăng nhập:", len(dang_nhap))
# 5 username và 5 password bị thử nhiều nhất
print("Top 5 username:\n" + dang_nhap.username.value_counts().head(5).to_string())
print("Top 5 password:\n" + dang_nhap.password.value_counts().head(5).to_string())

Kết quả: Tổng số lần thử đăng nhập: 129110 · username: root 74861, ubuntu 5265, admin 4027, user 2125, test 1376 · password: 123456 6247, Aa112211 2476, 123 2418, 1234 2050, root 1593

👉 Hiểu: kẻ tấn công thử theo danh sách mật khẩu yếu có sẵn — máy chủ nào dùng các mật khẩu này sẽ bị đoán trúng ngay.

⑤d — Kẻ tấn công dùng phần mềm gì?

In [ ]:
import pandas as pd
phan_mem = pd.read_csv("~/m12_sach/phan_mem.csv")
# Đếm từng tên phần mềm SSH mà kẻ tấn công khai báo
dem = phan_mem.version.value_counts()
print("Top 5 phần mềm:\n" + dem.head(5).to_string())
# dem.index[0] = tên đứng đầu; dem.iloc[0] / len(phan_mem) = tỉ lệ phần trăm của nó
print(f"Riêng {dem.index[0]} chiếm {dem.iloc[0] / len(phan_mem):.0%} số lần khai báo")

Kết quả: SSH-2.0-Go 120382, SSH-2.0-libssh_0.9.6 2722, SSH-2.0-phpseclib_1.0 (openssl) 738, SSH-2.0-PuTTY_Release_0.84 719, SSH-2.0-libssh2_1.8.1 716 · Riêng SSH-2.0-Go chiếm 93% số lần khai báo

👉 Hiểu: 93% là chương trình tự động viết bằng Go — không phải phần mềm người dùng hằng ngày.

M1.2-⑥ Tra cứu sâu: mỗi câu hỏi một khối

Khối nào có dòng SỬA DÒNG NÀY thì được đổi giá trị trong ngoặc kép (IP, username, ngày) rồi chạy lại ô đó để hỏi câu khác.

⑥a — Ngày nào, thứ mấy bị tấn công nhiều nhất?

In [ ]:
import pandas as pd
ket_noi = pd.read_csv("~/m12_sach/ket_noi.csv")
# File CSV lưu thời gian dưới dạng chữ -> đổi lại thành thời gian thật (giờ Việt Nam)
thoi_gian = pd.to_datetime(ket_noi.gio_vn, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh")
# .dt.date = lấy NGÀY -> đếm -> lấy 5 ngày đông nhất
print("TOP 5 NGÀY bị tấn công nhiều nhất:")
print(thoi_gian.dt.date.rename("ngay").value_counts().head(5).to_string())
# .dt.day_name() = lấy THỨ trong tuần (tiếng Anh: Monday, Tuesday...) -> đếm
print("\nTấn công theo THỨ trong tuần:")
print(thoi_gian.dt.day_name().rename("thu").value_counts().to_string())

Kết quả: top ngày 2026-09-20 10275 · 2026-09-13 9183 · 2026-09-15 8014 · 2026-09-18 7720 · 2026-09-16 7326 · theo thứ: Sunday 26130 đứng đầu … Monday 16841 cuối

👉 Hiểu: 30 ngày này chỉ có 4 Chủ nhật (thứ Hai, thứ Ba có 5) mà Chủ nhật vẫn đứng đầu — vì 2 ngày đông nhất 20/09 và 13/09 đều là Chủ nhật. Vài ngày bùng nổ có thể kéo lệch cả bảng thống kê.

⑥b — Top cặp username / password

In [ ]:
import pandas as pd
# Mở bảng THỬ ĐĂNG NHẬP. dtype=str: đọc mọi thứ là chữ (mật khẩu "123456" không bị biến thành số)
# keep_default_na=False: giữ nguyên mật khẩu rỗng, không coi là ô trống
dang_nhap = pd.read_csv("~/m12_sach/dang_nhap.csv", dtype=str, keep_default_na=False)
# Ghép 2 cột thành 1 chuỗi "username / password" rồi đếm -> top theo CẶP
print("TOP 10 CẶP username / password:")
print((dang_nhap.username + " / " + dang_nhap.password).value_counts().head(10).to_string())

Kết quả: root / Aa112211 2474 · root / root 837 · root / abcd1234 790 · root / 3245gs5662d34 604 · 345gs5662d34 / 345gs5662d34 601 · root / 123456 600 · ubuntu / 123456 436 · ubuntu / ubuntu 398 · …

👉 Hiểu: kẻ tấn công thử theo cặp có sẵn, vd tên đăng nhập trùng mật khẩu (ubuntu / ubuntu).

⑥c — Một username bị thử những mật khẩu gì?

In [ ]:
import pandas as pd
# ===== SỬA DÒNG NÀY: username muốn soi (thử "admin", "ubuntu", "postgres"...) =====
USER_CAN_SOI = "root"
dang_nhap = pd.read_csv("~/m12_sach/dang_nhap.csv", dtype=str, keep_default_na=False)
# Lọc: chỉ giữ các lần thử có username đúng bằng USER_CAN_SOI (== nghĩa là "bằng")
loc = dang_nhap[dang_nhap.username == USER_CAN_SOI]
print("Username", USER_CAN_SOI, "bị thử", len(loc), "lần, từ", loc.src_ip.nunique(), "IP khác nhau")
print("TOP 10 mật khẩu kẻ tấn công thử với", USER_CAN_SOI + ":")
print(loc.password.value_counts().head(10).to_string())

Kết quả: Username root bị thử 74861 lần, từ 706 IP khác nhau · top: Aa112211 2474, root 837, abcd1234 790, 3245gs5662d34 604, 123456 600, … P@ssw0rd 255

👉 Hiểu: thử đổi "root" thành "postgres" hoặc "oracle" (tài khoản dịch vụ hay có trên máy chủ) — danh sách mật khẩu thử khác hẳn.

⑥d — Kiểu tấn công: chỉ quét hay dò mật khẩu? Dùng công cụ gì?

In [ ]:
import pandas as pd
# Mở cả 3 bảng sạch
ket_noi   = pd.read_csv("~/m12_sach/ket_noi.csv")
dang_nhap = pd.read_csv("~/m12_sach/dang_nhap.csv", dtype=str, keep_default_na=False)
phan_mem  = pd.read_csv("~/m12_sach/phan_mem.csv")
# set(...) = tập hợp các IP KHÁC NHAU của mỗi bảng
ip_ket_noi   = set(ket_noi.src_ip)
ip_dang_nhap = set(dang_nhap.src_ip)
# Phép trừ tập hợp: IP có kết nối nhưng KHÔNG hề thử mật khẩu -> chỉ "gõ cửa" rồi đi (quét, dò dịch vụ)
print("KIỂU 1 - Chỉ kết nối rồi đi (quét / dò dịch vụ):", len(ip_ket_noi - ip_dang_nhap), "IP")
# Phép giao tập hợp: IP vừa kết nối VỪA thử mật khẩu -> dò mật khẩu (brute-force)
print("KIỂU 2 - Có thử mật khẩu (brute-force):          ", len(ip_ket_noi & ip_dang_nhap), "IP")
# Rút gọn tên phần mềm: "SSH-2.0-Go" -> "Go", "SSH-2.0-PuTTY_Release_0.84" -> "PuTTY"
ho_phan_mem = phan_mem.version.str.extract(r"^SSH-[\d.]+-([A-Za-z]+)")[0].fillna("khác").rename("cong_cu")
print("\nTOP 5 CÔNG CỤ tấn công (theo số lần khai báo):")
print(ho_phan_mem.value_counts().head(5).to_string())

Kết quả:

KIỂU 1 - Chỉ kết nối rồi đi (quét / dò dịch vụ): 2049 IP
KIỂU 2 - Có thử mật khẩu (brute-force):           857 IP

công cụ: Go 120382 · libssh 4269 · PuTTY 1396 · phpseclib 947 · khác 739

👉 Hiểu: hơn 2/3 số IP chỉ "gõ cửa" rồi đi (máy quét dò khắp Internet); gần 1/3 mới thật sự dò mật khẩu.

⑥e — Hồ sơ một IP: nó đã làm gì?

In [ ]:
import pandas as pd
# ===== SỬA DÒNG NÀY: IP muốn soi (lấy từ kết quả TOP 10 IP ở trên) =====
IP_CAN_SOI = "154.18.197.29"
ket_noi   = pd.read_csv("~/m12_sach/ket_noi.csv")
dang_nhap = pd.read_csv("~/m12_sach/dang_nhap.csv", dtype=str, keep_default_na=False)
phan_mem  = pd.read_csv("~/m12_sach/phan_mem.csv")
# Lọc từng bảng: chỉ giữ dòng của đúng IP này
kn = ket_noi[ket_noi.src_ip == IP_CAN_SOI]
dn = dang_nhap[dang_nhap.src_ip == IP_CAN_SOI].replace("", "(trống)")  # ô rỗng -> ghi "(trống)" cho dễ đọc
pm = phan_mem[phan_mem.src_ip == IP_CAN_SOI]
thoi_gian = pd.to_datetime(kn.gio_vn, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh")
print("HỒ SƠ IP", IP_CAN_SOI)
# .min() = sớm nhất, .max() = muộn nhất, .dt.date.nunique() = số ngày khác nhau, .dt.hour.nunique() = số giờ khác nhau
print("  Lần đầu:", thoi_gian.min(), "| lần cuối:", thoi_gian.max())
print("  Xuất hiện", thoi_gian.dt.date.nunique(), "ngày, trong", thoi_gian.dt.hour.nunique(), "khung giờ khác nhau của ngày")
print("  Số lần kết nối:", len(kn), "| số lần thử đăng nhập:", len(dn))
print("  Phần mềm đã khai:", ", ".join(pm.version.unique()))
print("  TOP 5 username:", ", ".join(dn.username.value_counts().head(5).index))
print("  TOP 5 password:", ", ".join(dn.password.value_counts().head(5).index))

Kết quả:

HỒ SƠ IP 154.18.197.29
  Lần đầu: 2026-08-29 13:00:09.623421+07:00 | lần cuối: 2026-09-21 00:59:17.779344+07:00
  Xuất hiện 12 ngày, trong 24 khung giờ khác nhau của ngày
  Số lần kết nối: 5569 | số lần thử đăng nhập: 4590
  Phần mềm đã khai: SSH-2.0-Go
  TOP 5 username: root, ubuntu, toor, (trống)
  TOP 5 password: root@1234, root@123, root12345, root1, root123

👉 Hiểu: hoạt động ở đủ 24 giờ, chỉ một chương trình Go → máy tự động. Thử đổi IP thành 109.160.32.31.

⑥f — Top dải mạng: nhiều IP nhưng thật ra là một "chủ"?

In [ ]:
import pandas as pd
ket_noi = pd.read_csv("~/m12_sach/ket_noi.csv")
# Cắt số cuối của IP: "109.160.32.18" -> tách ở dấu chấm CUỐI -> "109.160.32" -> thêm ".0/24"
ket_noi["dai_mang"] = ket_noi.src_ip.str.rsplit(".", n=1).str[0] + ".0/24"
# Gom theo dải: đếm số kết nối và số IP khác nhau trong mỗi dải
bang = ket_noi.groupby("dai_mang").agg(so_ket_noi=("src_ip", "size"), so_ip=("src_ip", "nunique"))
print("TOP 10 DẢI MẠNG /24 (cùng 3 số đầu của IP):")
print(bang.sort_values("so_ket_noi", ascending=False).head(10).to_string())

Kết quả: đứng đầu 109.160.32.0/24 41899 42, rồi 91.92.42.0/24 6805 11, 160.187.229.0/24 6429 14, 154.18.197.0/24 5569 1, …

👉 Hiểu: 42 IP cùng dải 109.160.32.x tạo ra 41.899 kết nối — gấp hơn 7 lần IP đứng đầu ⑤a. Nhìn từng IP không thấy; nhìn cả dải mới thấy nguồn tấn công lớn nhất. Chặn từng IP là không đủ.

⑥g — IP "kiên trì": quay lại nhiều ngày nhất

In [ ]:
import pandas as pd
ket_noi = pd.read_csv("~/m12_sach/ket_noi.csv")
ket_noi["ngay"] = pd.to_datetime(ket_noi.gio_vn, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh").dt.date
# Với mỗi IP: đếm số NGÀY khác nhau nó xuất hiện -> xếp giảm dần -> lấy 10
so_ngay = ket_noi.groupby("src_ip").ngay.nunique().sort_values(ascending=False)
print("TOP 10 IP 'kiên trì' nhất (xuất hiện nhiều ngày nhất trong 30 ngày):")
print(so_ngay.head(10).to_string())
# (so_ngay == 30).sum() = có bao nhiêu IP xuất hiện đủ 30/30 ngày
print("Số IP xuất hiện đủ 30/30 ngày:", (so_ngay == 30).sum())
print("Số IP chỉ xuất hiện 1 ngày:   ", (so_ngay == 1).sum())

Kết quả: 94.154.43.56 30 · 185.246.128.133 30 · 176.53.159.196 29 · 94.154.43.234 29 · 185.246.130.20 27 · … · Số IP xuất hiện đủ 30/30 ngày: 2 · Số IP chỉ xuất hiện 1 ngày: 2487

👉 Hiểu: đa số IP (2.487 / 2.906) chỉ ghé 1 ngày rồi biến mất — chặn sau khi phát hiện thì đã muộn; vài IP thì ngày nào cũng có mặt.

⑥h — Soi một ngày cụ thể

In [ ]:
import pandas as pd
# ===== SỬA DÒNG NÀY: ngày muốn soi (giờ Việt Nam), dạng năm-tháng-ngày, trong khoảng 2026-08-24 .. 2026-09-22 =====
NGAY_CAN_SOI = "2026-09-20"
ket_noi = pd.read_csv("~/m12_sach/ket_noi.csv")
thoi_gian = pd.to_datetime(ket_noi.gio_vn, utc=True).dt.tz_convert("Asia/Ho_Chi_Minh")
# Lọc các kết nối có NGÀY đúng bằng ngày cần soi (.astype(str) đổi ngày thành chữ để so sánh)
mot_ngay = ket_noi[thoi_gian.dt.date.astype(str) == NGAY_CAN_SOI]
gio_mot_ngay = thoi_gian[thoi_gian.dt.date.astype(str) == NGAY_CAN_SOI].dt.hour.rename("gio")
print("Ngày", NGAY_CAN_SOI, ":", len(mot_ngay), "kết nối từ", mot_ngay.src_ip.nunique(), "IP")
print("TOP 5 IP trong ngày:")
print(mot_ngay.src_ip.value_counts().head(5).to_string())
print("TOP 3 giờ trong ngày:")
print(gio_mot_ngay.value_counts().head(3).to_string())

Kết quả: Ngày 2026-09-20 : 10275 kết nối từ 178 IP · top IP: 5 IP 109.160.32.x, mỗi IP đúng 1099 · top giờ: 12 1498, 23 1425, 16 937

👉 Hiểu: nhiều IP cùng dải, cùng đúng một số kết nối trong ngày → một hệ thống điều khiển chung. Thử đổi ngày thành "2026-09-05" (ngày vắng nhất).

M1.2-⑦ Tổng kết

Bước Làm gì Thu được
① Xem log head, json.tool mỗi dòng = một sự kiện, 4 loại
② Đếm dữ liệu ls, du, wc -l 30 file, 63 MB, 395.124 dòng
③ Thống kê sơ bộ grep | sort | uniq -c số kết nối, số IP, top IP
④ Làm sạch Python + pandas 3 bảng gọn, giờ Việt Nam
⑤ Đặc trưng value_counts() top IP, giờ, username/password, phần mềm
⑥ Tra cứu sâu lọc + đếm ngày/thứ, cặp user/pass, kiểu tấn công, hồ sơ IP, dải mạng, IP kiên trì

Bài M1.3 — Máy học phân loại email (và cái bẫy "100%")

M1.3-1. Dữ liệu: 10 email, mỗi email lặp 10 lần

In [ ]:
import pandas as pd
# 10 email mẫu (tiếng Anh): spam = lừa đảo, ham = email thật
EMAIL = [
    ("URGENT: Your account will be closed. Please verify your password here.", "spam"),
    ("Hey team, just checking in on the project status.", "ham"),
    ("Claim your FREE lottery winnings now! Click this link.", "spam"),
    ("Meeting notes from yesterday's sync are attached.", "ham"),
    ("Verify your banking details immediately to prevent suspension.", "spam"),
    ("Let's grab lunch tomorrow around noon.", "ham"),
    ("Security Alert: We detected unusual login activity. Reset password.", "spam"),
    ("Please review the attached invoice for Q3.", "ham"),
    ("You have 1 new voice message. Listen here.", "spam"),
    ("Don't forget to submit your timesheets by Friday.", "ham"),
]
# Nhân mỗi email lên 10 bản -> bảng 100 dòng, 2 cột: text, label
email = pd.DataFrame(EMAIL * 10, columns=["text", "label"])
print("Tổng số dòng:", len(email), "| số email KHÁC NHAU:", email.text.nunique())

Kết quả: Tổng số dòng: 100 | số email KHÁC NHAU: 10

👉 Hiểu: 100 dòng nhưng thật ra chỉ có 10 email — nhớ điều này, nó là cái bẫy ở M1.3-6.

M1.3-2. Cách cũ: chấm điểm theo từ khóa

In [ ]:
# ✏️ SỬA: từ khóa đáng ngờ (chữ thường)
TU_KHOA_EMAIL = ["urgent", "verify", "free", "reset", "password"]
for text, label in EMAIL:
    # Điểm = số từ khóa xuất hiện trong email
    diem = sum(1 for tu in TU_KHOA_EMAIL if tu in text.lower())
    print(f"điểm {diem} | thật là: {label:4s} | {text}")

Kết quả: 10 dòng; email đầu (URGENT ... verify ... password) được 3 điểm, nhưng You have 1 new voice message (spam) chỉ 0 điểm — y như bài M1.

M1.3-3. Chia học / thi theo từng email

In [ ]:
from sklearn.model_selection import GroupShuffleSplit
# ✏️ SỬA: đổi số 42 thành số khác -> máy chia 7/3 theo cách khác -> kết quả các ô sau đổi theo
HAT_GIONG = 42
# groups=email.text: 10 bản sao của một email luôn đi CÙNG nhau (hoặc cùng vào học, hoặc cùng vào thi)
chia = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=HAT_GIONG)
vi_tri_hoc, vi_tri_thi = next(chia.split(email, email.label, groups=email.text))
hoc, thi = email.iloc[vi_tri_hoc], email.iloc[vi_tri_thi]
print("Phần HỌC:", hoc.text.nunique(), "email (", len(hoc), "dòng)  | Phần THI:", thi.text.nunique(), "email (", len(thi), "dòng)")
print("Email vừa có trong phần học vừa có trong phần thi:", len(set(hoc.text) & set(thi.text)))
print("Đề thi:")
for t in thi.text.unique():
    print("  -", t)

Kết quả: Phần HỌC: 7 email ( 70 dòng) | Phần THI: 3 email ( 30 dòng) · ... cả hai: 0 · đề thi 3 email (trong đó có Don't forget to submit your timesheets by Friday.).

👉 Hiểu: đề thi chỉ gồm email máy chưa từng thấy — giống thực tế: email lừa đảo ngày mai là email mới.

M1.3-4. Chữ → số (TF-IDF), rồi cho máy học (Naive Bayes)

In [ ]:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
# Lập từ điển từ phần HỌC (bỏ các từ quá phổ biến tiếng Anh như "the", "your"), biến mỗi email thành 1 hàng số
tfidf = TfidfVectorizer(stop_words="english")
X_hoc = tfidf.fit_transform(hoc.text)
X_thi = tfidf.transform(thi.text)
print("Từ điển máy học được:", len(tfidf.vocabulary_), "từ")
print(sorted(tfidf.vocabulary_))
# Naive Bayes học: từ nào hay đi với spam, từ nào hay đi với ham
mo_hinh = MultinomialNB().fit(X_hoc, hoc.label)
print("Đã học xong.")

Kết quả: Từ điển máy học được: 38 từ + danh sách ['account', 'activity', 'alert', 'attached', ...] + Đã học xong.

👉 Hiểu: máy không "đọc hiểu": nó chỉ đếm từ nào hay xuất hiện ở email lừa đảo, từ nào ở email thật.

M1.3-5. Chấm bài thi

In [ ]:
from sklearn.metrics import accuracy_score, precision_score, recall_score, confusion_matrix
doan = mo_hinh.predict(X_thi)
print(f"Accuracy  (đúng bao nhiêu phần)          : {accuracy_score(thi.label, doan):.2f}")
print(f"Precision (báo spam thì đúng bao nhiêu)  : {precision_score(thi.label, doan, pos_label='spam', zero_division=0):.2f}")
print(f"Recall    (bắt được bao nhiêu spam thật) : {recall_score(thi.label, doan, pos_label='spam', zero_division=0):.2f}")
print("Bảng nhầm lẫn (hàng = thật ham/spam, cột = máy đoán ham/spam):")
print(confusion_matrix(thi.label, doan, labels=["ham", "spam"]))
print()
for t in thi.text.unique():
    du_doan = mo_hinh.predict(tfidf.transform([t]))[0]
    that = email[email.text == t].label.iloc[0]
    print("ĐÚNG" if du_doan == that else "SAI ", f"| máy đoán {du_doan:4s} | thật là {that:4s} |", t)

Kết quả: Accuracy 0.67 · Precision 0.50 · Recall 1.00 · bảng nhầm lẫn [[10 10] [ 0 10]] (đếm theo dòng: mỗi email đề thi có 10 bản sao → 10 dòng ham đoán đúng, 10 dòng ham bị đoán nhầm thành spam, 10 dòng spam đoán đúng), rồi 3 dòng: 2 dòng ĐÚNG, 1 dòng SAI | máy đoán spam | thật là ham | Don't forget to submit your timesheets by Friday.

👉 Hiểu: với email mới, máy đúng 2/3 và báo nhầm một email công việc bình thường là lừa đảo — vì chỉ có 7 email để học.

M1.3-6. Tự viết email để thử máy

In [ ]:
# ✏️ SỬA: viết email của bạn (tiếng Anh) trong ngoặc kép rồi chạy lại
EMAIL_CUA_TOI = "Please verify your password to claim a free gift"
xac_suat = mo_hinh.predict_proba(tfidf.transform([EMAIL_CUA_TOI]))[0]
for nhan, p in zip(mo_hinh.classes_, xac_suat):
    print(f"{nhan}: {p:.0%}")
print("=> máy đoán:", mo_hinh.classes_[xac_suat.argmax()])

Kết quả: ham: 4% · spam: 96% · => máy đoán: spam

👉 Hiểu: thử viết email không có chữ nào trong từ điển ở M1.3-4 (ví dụ "See you at the gym"): máy chỉ còn đoán theo tỉ lệ spam/ham đã học.

M1.3-7. Cái bẫy "100% ảo": chia ngẫu nhiên từng dòng

In [ ]:
from sklearn.model_selection import train_test_split
# Cách chia SAI: chia ngẫu nhiên 100 DÒNG 70/30, không để ý các dòng là bản sao của nhau
hoc2, thi2 = train_test_split(email, test_size=0.3, random_state=42)
trung = set(hoc2.text) & set(thi2.text)
print("Đề thi có", thi2.text.nunique(), "email khác nhau,", len(trung), "email trong số đó ĐÃ CÓ trong phần học")
tfidf2 = TfidfVectorizer(stop_words="english")
mo_hinh2 = MultinomialNB().fit(tfidf2.fit_transform(hoc2.text), hoc2.label)
print(f"Accuracy: {accuracy_score(thi2.label, mo_hinh2.predict(tfidf2.transform(thi2.text))):.2f}")

Kết quả: Đề thi có 10 email khác nhau, 10 email trong số đó ĐÃ CÓ trong phần học · Accuracy: 1.00

👉 Hiểu: chia ngẫu nhiên thì bản sao của mọi email đề thi đã nằm sẵn trong phần học → máy chỉ cần nhớ là được 100%. Con số thật về khả năng bắt email mới là 0.67 ở M1.3-5. Gặp kết quả 100%, luôn hỏi: đề thi có lẫn bài đã học không?