Files
labs/Notebooks-CSAI/Lab_OSINT_Threat_Hunting.ipynb

77 KiB

Threat Hunting & OSINT — kiểm chứng bằng chứng trên dữ liệu THẬT của máy chủ

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. Biết vì sao 3 công cụ cùng nói "xấu" chưa chắc là 3 bằng chứng, và tự lần ra nguồn gốc của từng lời khẳng định (Phần 1).
  2. Biết chấm độ tin cậy của nguồn theo Admiralty Code (A–F × 1–6) thay vì đếm số nguồn (Phần 1).
  3. Tự tìm ra chỗ lộ của một honeypot "để nguyên mặc định" và biết bot thật có kiểm tra những chỗ đó không (Phần 2).
  4. Dựng đồ thị có kiểu (typed graph) từ dấu vân tay SSH/TLS và khoá SSH, tìm cụm hạ tầng và đối chiếu với nhóm tấn công đã biết (Phần 3).
  5. Tính favicon hash đúng cách Shodan tính, và hiểu nó nói được gì, không nói được gì (Phần 4).
  6. Phân biệt hash chính xác và hash mờ (fuzzy hash), và nhận ra "chữ ký" giả là hash của file rỗng (Phần 5).

🗺️ Bài này: bài toán gì, cách giải nào, vì sao?

  • Bài toán: một nhà phân tích SOC nhận được nhiều tín hiệu ("IP này nằm trong danh sách đen", "máy này giống honeypot", "hai IP dùng chung một khoá SSH"). Câu hỏi không phải "mô hình nào chính xác nhất" mà là "bằng chứng nào đáng tin, nối với nhau ra sao?" → threat hunting = đi săn chủ động bằng cách pivot (từ một dấu vết lần sang dấu vết khác) và kiểm chứng chéo.
  • Dữ liệu: dữ liệu thật của máy chủ dạy học: danh sách IP C2 (máy chủ điều khiển mã độc), kho mẫu mã độc, danh sách IP bị chặn trên web, dấu vân tay SSH/TLS và khoá SSH thu từ honeypot. IP đã được đổi thành mã IP-xxxxxxxx (cùng một IP luôn ra cùng một mã, nên vẫn nối được giữa các bảng).
  • Có nhãn không? Gần như không: chỉ có vài nhãn nhóm tấn công (TA-xx) do người phân tích gắn từ điều tra trước → dùng để đối chiếu ở Phần 3, không dùng để huấn luyện.
  • Vì vậy chọn: đếm + nối bảng bằng pandas (Phần 1, 2), đồ thị + phát hiện cộng đồng Louvain (Phần 3), hàm băm mmh3, SHA-256, ssdeep (Phần 4, 5). Không có mô hình "học" nào ở đây — đây là phần thu thập và kiểm chứng bằng chứng mà mọi mô hình AI ở các bài khác đều phụ thuộc vào.
  • Thước đo: không có "độ chính xác". Ta đo có bao nhiêu nguồn độc lập thật, bao nhiêu chỗ mâu thuẫn, cụm có khớp nhãn đã biết không.

Pipeline — 7 bước của mọi bài trong khoá. Bài này nặng ở bước 1 (thu thập) và 6 (đánh giá bằng chứng). 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
2. Làm sạch dữ liệu cột nào bẩn, đọc sai thì kết luận sai ra sao? 1.3 (cột loại mối đe doạ chứa ngày tháng)
3. Chọn mô hình / cách giải phù hợp đếm, nối bảng, đồ thị hay hàm băm? mục Trước khi bắt đầu, đầu mỗi phần
4. Coding dựng công cụ tra cứu, đồ thị, hàm băm Phần 1 → 5
5. Lưu ý khi coding lỗi nào không báo lỗi mà làm sai kết luận? 3.2 (một nút "nhãn" dính cả đồ thị), 4.2 (base64 sai kiểu), 4.3 (favicon là HTML), 5.1 (hash file rỗng)
6. Đánh giá bao nhiêu nguồn độc lập? cụm có khớp nhãn? 1.6 (Admiralty), 3.5
7. Quy tắc bàn tay phải ✋ tổng kết, mang gì theo khi đi làm Phần 6

Ký hiệu và từ khoá dùng trong bài

Từ Nghĩa
IOC (Indicator of Compromise) dấu hiệu xâm nhập: IP, tên miền, hash file, dấu vân tay
feed / danh sách đen danh sách IOC do ai đó tổng hợp và chia sẻ (ví dụ abuse.ch URLhaus)
pivot từ một dấu vết lần sang dấu vết khác có liên quan (IP → khoá SSH → các IP khác dùng khoá đó)
C2 máy chủ điều khiển (command & control) mà mã độc gọi về
HASSH dấu vân tay của phần mềm SSH phía kẻ tấn công (tính từ danh sách thuật toán nó đề nghị khi bắt tay)
JA4 dấu vân tay tương tự cho TLS (HTTPS)
honeypot máy mồi giả làm máy thật để ghi lại kẻ tấn công. Cowrie = honeypot SSH/Telnet; OpenCanary = honeypot nhiều dịch vụ
IP-xxxxxxxx mã thay cho IP thật (HMAC), cùng IP → cùng mã
📦 dòng mô tả biến vừa tạo chứa gì

Trước khi bắt đầu — Kỹ thuật nào làm được gì, bị né thế nào?

(Đọc thêm, để tra lại khi cần.) Ba nguyên tắc xuyên suốt: không có kỹ thuật "thần thánh" (mỗi kỹ thuật là một tín hiệu, có độ phủ và cách bị né riêng); tách ví dụ minh hoạ khỏi dữ liệu thật; không dùng "100%" (nhiều nguồn đồng thuận vẫn có thể cùng sai).

Kỹ thuật Loại tín hiệu Công cụ Bị né bằng cách Trong khoá
Corroboration (kiểm chứng chéo) quy trình tư duy bảng tra cứu + Admiralty Code các nguồn không độc lập bài này Phần 1
Seasoned honeypot ("làm cũ" honeypot) phòng thủ chủ động Cowrie honeyfs + txtcmds, OpenCanary kẻ tấn công giỏi soi honeypot bài này Phần 2, Lab_Python_Honeypot
Typed graph (đồ thị có kiểu) mô hình dữ liệu networkx (bài này), Neo4j, OpenCTI, Maltego — bài này Phần 3, Lab 7 (gom cụm)
Favicon hash vân tay phần mềm Python mmh3, Shodan http.favicon.hash đổi favicon bài này Phần 4
Hash chính xác / hash mờ vân tay file, trang web SHA-256, ssdeep, TLSH sửa vài byte (với hash chính xác) bài này Phần 5, Lab 5
Passive DNS lịch sử "tên miền ↔ IP" sensor cạnh resolver, feed ngoài DNS-over-HTTPS, tên miền dùng một lần lý thuyết (không có sensor trên lớp)
Phishing kit bị dịch cấu trúc + ngữ nghĩa so DOM + mô hình embedding đa ngôn ngữ viết lại giao diện notebook GPU Lab_OSINT_Phishing_Kit_GPU
Timing correlation nhịp thời gian lưu lượng cần quyền giám sát cấp nhà mạng jitter, padding, cache CDN chỉ lý thuyết

Thứ tự trong bài: tư duy kiểm chứng trước (Phần 1) → thu bằng chứng (Phần 2, 4, 5) → ghép bằng chứng (Phần 3).


Phần 0 — Thu thập dữ liệu: các bảng bằng chứng của máy chủ

📍 Bước 1 — Thu thập dữ liệu.

Dữ liệu gì? Bảy nhóm bằng chứng thật mà hệ thống SOC của máy chủ dạy học đang dùng:

File trong /opt/csai/data/osint/ Là gì Lấy từ đâu trên máy chủ
c2_list.csv danh sách đen IP C2: mỗi IP một hàng, cột sources ghi ai đã báo IP đó bộ làm giàu dữ liệu C2 của máy chủ (gộp feed ngoài + phát hiện từ honeypot)
c2_active.csv phần còn hiệu lực của danh sách trên, đúng file mà công cụ "tra danh tiếng IP" của SOC dò như trên
vault_c2.csv IP xuất hiện trong sổ kho mẫu mã độc (nơi mẫu tải về / gọi về) sổ ghi chép kho mẫu (CATALOG.md)
web_bans.csv IP từng bị fail2ban chặn trên các dịch vụ web thật, kèm tên "nhà tù" (jail) cơ sở dữ liệu fail2ban
edges_hassh.csv, edges_ja4.csv, edges_sshkey.csv, sshkeys.csv, fp_campaigns.csv ai (IP) dùng dấu vân tay SSH / TLS nào, khoá SSH nào; khoá nào đã được gắn nhóm tấn công cơ sở dữ liệu dấu vân tay của honeypot
honeypot_facts.csv, canary_404.html những gì honeypot trả lời kẻ tấn công, so với cấu hình của nó bản Cowrie cũ trên máy chủ (đã dừng), trang lỗi của OpenCanary
favicons/ biểu tượng (favicon) mà các dịch vụ thật đang phục vụ lấy trực tiếp từ các dịch vụ

Thu thập thế nào? Một script xuất dữ liệu chỉ đọc trên máy chủ: đổi mọi IP thành mã IP-xxxxxxxx (HMAC), bỏ IP nội bộ / của chủ nhà, thay khoá SSH bằng tên K1, K2… (một tên khoá gốc chứa cả địa chỉ IP), rồi kiểm tra lại không còn IP nào lọt ra.

Có nhãn chưa? Không có nhãn "xấu / tốt" để học. Chỉ có nhãn nhóm tấn công (TA-xx) trên vài khoá SSH và vân tay — dùng để đối chiếu ở Phần 3.

Có cần làm sạch? Có: một cột của danh sách đen bị ghi sai kiểu dữ liệu (Phần 1.3 tìm ra).

0.1 Mở các bảng và xem bên trong

In [ ]:
import pandas as pd
from pathlib import Path

THU_MUC = Path("/opt/csai/data/osint")            # nơi dữ liệu của bài (chỉ đọc, dùng chung cả lớp)
pd.set_option("display.max_colwidth", 80)          # không cắt chữ dài thành "..."

# dtype=str + keep_default_na=False: đọc mọi ô thành chữ, KHÔNG biến ô trống / "NA" thành NaN
doc = lambda ten: pd.read_csv(THU_MUC / ten, dtype=str, keep_default_na=False)   # lambda = hàm 1 dòng
c2      = doc("c2_list.csv")       # danh sách đen IP C2 (toàn bộ lịch sử)
c2_hl   = doc("c2_active.csv")     # phần còn hiệu lực
kho_mau = doc("vault_c2.csv")      # IP trong sổ kho mẫu mã độc
chan_web = doc("web_bans.csv")     # IP fail2ban từng chặn trên web thật

for ten, bang in [("c2", c2), ("c2_hl", c2_hl), ("kho_mau", kho_mau), ("chan_web", chan_web)]:
    print(f"📦 {ten:8s} = bảng {bang.shape[0]:,} hàng × {bang.shape[1]} cột: {list(bang.columns)}")   # .shape = (số hàng, số cột)
print()
print(c2.head(3).to_string())      # 3 hàng đầu để thấy dữ liệu thật trông thế nào

Kết quả: danh sách đen c2 có 46.253 IP (6 cột), phần còn hiệu lực c2_hl 22.106 IP, sổ kho mẫu kho_mau 19 IP, bảng chặn web chan_web 474 hàng (một IP có thể bị nhiều nhà tù chặn). Ba hàng đầu đến từ nguồn feodo_tracker.

📦 c2 = bảng IP C2: ip_token (mã IP), sources (ai báo), status, ngày thêm, ngày thấy cuối, threat_type. · 📦 c2_hl, kho_mau = một cột mã IP. · 📦 chan_web = mã IP + tên nhà tù fail2ban.

👉 Hiểu: mọi IP đã là mã IP-xxxxxxxx; cùng IP luôn ra cùng mã nên vẫn nối được 4 bảng với nhau.

0.2 Nhìn thô bằng lệnh Linux: cột sources trông thế nào?

Trước khi viết code, xem tận mắt vài hàng mà cột sources có dấu phẩy (nhiều nguồn cùng báo một IP):

In [ ]:
%%bash
# grep -oE: cắt ra "mã IP + cột sources" (cột có dấu phẩy được bọc trong ngoặc kép "...")
# cut -d, -f2-: bỏ mã IP, giữ phần nguồn; sort | uniq -c: đếm từng kiểu; sort -rn: nhiều nhất lên đầu
grep -oE '^IP-[0-9a-f]+,("[^"]*"|[^,]*)' /opt/csai/data/osint/c2_list.csv | cut -d, -f2- | sort | uniq -c | sort -rn | head -12

Kết quả: phần lớn hàng chỉ có một tên nguồn (urlhaus 30.918, cowrie_log 14.210). Hàng nhiều nguồn được bọc trong ngoặc kép, nhiều nhất là "cowrie_log,credential_intelligence" (918 hàng), rồi "cowrie_log,incident_report" (61), "urlhaus,cowrie_log" (35).

👉 Hiểu: nhìn tên thì tưởng nhiều nguồn khác nhau. Nhưng cowrie_log, credential_intelligence, incident_report đều có chữ "cowrie" hoặc là báo cáo về honeypot — câu hỏi của Phần 1 là chúng có độc lập với nhau không.


Phần 1 — Thẻ 6: Kiểm chứng chéo (corroboration). "Ba nguồn nói xấu" là mấy bằng chứng?

📍 Bước 3 → 4 — chọn cách giải, coding. Cách giải: nối bảng và lần ngược nguồn gốc, không cần mô hình.

Hiểu đơn giản: ba nhân chứng không quen nhau cùng kể một chuyện thì đáng tin. Ba người cùng nghe lại từ một người thì chỉ là một lời khai. Trong threat intel, lỗi này gọi là circular reporting (báo cáo vòng tròn): nhiều feed cùng lấy từ một gốc, nên "3 feed nói xấu" có thể chỉ là 1 phát hiện bị chép 3 lần.

  • Correlation (tương quan) = tìm mối liên hệ giữa các sự kiện.
  • Corroboration (kiểm chứng chéo) = kiểm tra một kết luận bằng các nguồn độc lập.

1.1 Ba "công cụ SOC" cùng nói một IP là xấu

Hệ thống SOC của máy chủ có ba công cụ mà một nhà phân tích (hay một trợ lý AI) thường hỏi lần lượt. Ô dưới dựng lại đúng cách ba công cụ đó làm việc, trên dữ liệu thật:

In [ ]:
TAP_HL  = set(c2_hl.ip_token)       # set = tập hợp, tra "có trong tập không" rất nhanh
TAP_KHO = set(kho_mau.ip_token)

def cong_cu_1_tra_danh_tieng(ip):     # giống công cụ "check_ip_reputation": dò danh sách còn hiệu lực
    return "XẤU (có trong danh sách đen)" if ip in TAP_HL else "không biết"

def cong_cu_2_thong_ke_feed(ip):      # giống công cụ "thống kê feed C2": đọc danh sách đầy đủ, báo IP đến từ nguồn nào
    hang = c2[c2.ip_token == ip]
    return f"XẤU (feed ghi nguồn: {hang.sources.iloc[0]})" if len(hang) else "không có trong feed"

def cong_cu_3_kho_ma_doc(ip):         # giống tra sổ kho mẫu: IP này có dính tới mẫu mã độc nào không
    return "XẤU (xuất hiện trong kho mẫu mã độc)" if ip in TAP_KHO else "không có trong kho"

# chọn một IP mà cả 3 công cụ đều "biết": có trong kho mẫu VÀ còn trong danh sách hiệu lực
ung_vien = sorted(TAP_KHO & TAP_HL)              # & = phần giao của hai tập
IP_THU = ung_vien[0]                              # ✏️ SỬA: đổi chỉ số [0] → [1] để thử IP khác
print(f"📦 ung_vien = {len(ung_vien)} IP vừa ở kho mẫu vừa còn hiệu lực: {ung_vien}")
print(f"Tra IP {IP_THU}:")
for ham in (cong_cu_1_tra_danh_tieng, cong_cu_2_thong_ke_feed, cong_cu_3_kho_ma_doc):
    print(f"  {ham.__name__:26s} → {ham(IP_THU)}")    # __name__ = tên của hàm

Kết quả: có 2 IP vừa nằm trong kho mẫu vừa còn hiệu lực. Với IP-3bd34b18, cả ba công cụ đều trả lời XẤU: công cụ 1 thấy trong danh sách đen, công cụ 2 ghi nguồn cowrie_log,incident_report, công cụ 3 thấy trong kho mẫu mã độc.

📦 ung_vien = danh sách mã IP có mặt ở cả kho mẫu lẫn danh sách còn hiệu lực. · 📦 IP_THU = IP đang tra.

👉 Hiểu: một trợ lý AI hay một người mới vào nghề sẽ viết: "3 nguồn độc lập xác nhận, độ tin cậy rất cao". Phần còn lại của Phần 1 kiểm tra câu đó.

1.2 Lần ngược nguồn gốc: các "nguồn" thực ra đến từ đâu?

Cột sources liệt kê tên nguồn như bộ thu thập ghi lại. Nhưng tên khác nhau chưa chắc gốc khác nhau. Ta gom mỗi tên về gốc thật (upstream):

Tên nguồn trong cột sources Gốc thật Vì sao
urlhaus, feodo_tracker abuse.ch hai feed của cùng một tổ chức
cowrie_log, credential_intelligence, incident_report, fingerprint_TA-07, malware_static_analysis… honeypot của mình đều được rút ra từ log Cowrie, hoặc từ báo cáo / mẫu mã độc viết dựa trên log Cowrie

Ô dưới đếm: mỗi IP được báo bởi mấy tên nguồn, và thực ra từ mấy gốc độc lập:

In [ ]:
# ✏️ SỬA: bảng quy đổi tên nguồn → gốc thật. Tên nào không có ở đây sẽ bị coi là "honeypot của mình"
GOC = {"urlhaus": "abuse.ch", "feodo_tracker": "abuse.ch", "new": "chưa rõ"}

def cac_goc(chuoi_nguon):
    ten_nguon = [t.strip() for t in chuoi_nguon.split(",")]               # "a,b" → ["a", "b"]
    return sorted({GOC.get(t, "honeypot của mình") for t in ten_nguon})    # .get(t, mặc định): không có thì lấy mặc định

c2["so_ten_nguon"] = c2.sources.str.split(",").str.len()                   # đếm số tên trong chuỗi
c2["goc"] = c2.sources.map(lambda s: " + ".join(cac_goc(s)))               # .map: áp hàm cho từng ô
c2["so_goc"] = c2.sources.map(lambda s: len(cac_goc(s)))
print("📦 c2 có thêm 3 cột: so_ten_nguon, goc, so_goc. Ví dụ hàng có nhiều tên nguồn nhất:")
print(c2.sort_values("so_ten_nguon", ascending=False)[["ip_token", "sources", "so_ten_nguon", "goc", "so_goc"]].head(3).to_string())

nhieu_ten = c2[c2.so_ten_nguon >= 2]
print(f"\nIP được ≥ 2 TÊN nguồn báo : {len(nhieu_ten):,}")
print(f"IP được ≥ 2 GỐC độc lập báo: {(c2.so_goc >= 2).sum():,}")
print("\nCác IP 'nhiều tên nguồn' thực ra đến từ gốc nào:")
print(nhieu_ten.goc.value_counts().to_string())

Kết quả: 1.103 IP được từ 2 tên nguồn trở lên báo, nhưng chỉ 60 IP được 2 gốc độc lập báo (abuse.ch và honeypot của mình). 1.043 IP "nhiều nguồn" còn lại thực ra chỉ có một gốc: honeypot của mình. Hàng nhiều tên nhất (3 tên: cowrie_mirai_20260331,incident_report,cowrie_log) vẫn chỉ là 1 gốc.

📦 cột so_ten_nguon = đếm tên trong sources; cột goc = các gốc thật; cột so_goc = số gốc độc lập.

👉 Hiểu: đếm tên nguồn là đếm sai. Trong 1.103 IP trông như "được nhiều nguồn xác nhận", 95% chỉ là một phát hiện của honeypot được ghi lại dưới nhiều tên (log → phân tích mật khẩu → báo cáo sự cố). Đây chính là circular reporting ngay trong hệ thống của mình.

1.3 Làm sạch: cột "loại mối đe doạ" chứa gì?

📍 Bước 2 — Làm sạch dữ liệu. Trước khi dùng cột threat_type để nói "IP này là loại gì", xem nó thật sự chứa gì:

In [ ]:
import re
la_ngay = c2.threat_type.str.match(r"^\d{4}-\d{2}-\d{2}")       # regex: bắt đầu bằng YYYY-MM-DD
print("10 giá trị hay gặp nhất của threat_type (TRƯỚC khi làm sạch):")
print(c2.threat_type.value_counts().head(10).to_string())
print(f"\nSố hàng mà threat_type thực ra là NGÀY THÁNG: {la_ngay.sum():,} / {len(c2):,}")
print("Những hàng đó đến từ nguồn nào:", c2[la_ngay].sources.value_counts().head(3).to_dict())

# Làm sạch: ngày tháng không phải loại mối đe doạ → đổi thành "không rõ (cột bị ghi lệch)"
c2["loai_sach"] = c2.threat_type.where(~la_ngay, "không rõ (cột bị ghi lệch)")   # where: giữ chỗ đúng, thay chỗ sai
print("\nSAU khi làm sạch:")
print(c2.loai_sach.value_counts().head(6).to_string())

Kết quả: 30.998 / 46.253 hàng (67%) có threat_type là ngày tháng chứ không phải loại mối đe doạ; gần hết (30.918) đến từ nguồn urlhaus. Giá trị thật hay gặp nhất là attacker (14.677) và c2_argument (545). Sau khi làm sạch, 30.998 hàng thành "không rõ (cột bị ghi lệch)".

📦 la_ngay = cột True/False, True ở hàng mà threat_type bắt đầu bằng YYYY-MM-DD. · 📦 cột loai_sach = threat_type đã làm sạch.

👉 Hiểu: bộ đọc feed URLhaus đã ghi ngày vào cột loại (lệch cột). Code không báo lỗi gì. Nếu lập báo cáo "phân bố loại mối đe doạ" mà không nhìn, bạn sẽ có hàng chục "loại" là ngày tháng — hoặc tệ hơn, lọc threat_type == "c2" và bỏ sót 2/3 danh sách.

1.4 Kho mẫu mã độc có phải nguồn thứ ba độc lập không?

Công cụ 3 (kho mẫu) nghe như một nguồn riêng. Kiểm tra: các IP trong kho mẫu đã nằm sẵn trong danh sách đen chưa, và danh sách đen biết chúng từ đâu?

In [ ]:
kho_trong_c2 = c2[c2.ip_token.isin(TAP_KHO)]                     # isin: hàng nào có mã nằm trong tập kho mẫu
print(f"📦 kho_trong_c2 = {len(kho_trong_c2)} / {len(TAP_KHO)} IP của kho mẫu đã có trong danh sách đen")
print("Danh sách đen biết các IP đó từ gốc nào:")
print(kho_trong_c2.goc.value_counts().to_string())
print(f"\nTrong đó còn hiệu lực (công cụ 1 còn báo): {len(TAP_KHO & TAP_HL)}")

Kết quả: 19 / 19 IP của kho mẫu đã có sẵn trong danh sách đen: 13 IP danh sách đen biết chỉ từ honeypot của mình, 6 IP từ cả abuse.ch lẫn honeypot. Chỉ 2 trong số đó còn hiệu lực.

📦 kho_trong_c2 = các hàng của danh sách đen ứng với IP trong kho mẫu.

👉 Hiểu: kho mẫu mã độc không phải nguồn thứ ba. Mẫu mã độc được honeypot tải về; IP trong sổ kho mẫu là nơi các mẫu đó tải về / gọi về; và chính honeypot đã đưa các IP đó vào danh sách đen. Công cụ 3 "đồng ý" với công cụ 1 do cấu tạo, không phải do kiểm chứng.

1.5 Ngược lại: IP tấn công web thật thì ba công cụ nói gì?

Lấy các IP mà fail2ban đã tự chặn vì tấn công các dịch vụ web thật (bằng chứng quan sát trực tiếp trên máy mình). Ba công cụ biết bao nhiêu IP trong số đó?

In [ ]:
TAP_WEB = set(chan_web.ip_token)
print(f"📦 TAP_WEB = {len(TAP_WEB)} IP từng bị fail2ban chặn. Theo nhà tù (jail):")
print(chan_web.jail.value_counts().to_string())
biet = TAP_WEB & set(c2.ip_token)
print(f"\nCó trong danh sách đen (mọi thời điểm): {len(biet)} / {len(TAP_WEB)}  = {100 * len(biet) / len(TAP_WEB):.1f}%")
print(f"Công cụ 1 (còn hiệu lực) trả lời 'XẤU'  : {len(TAP_WEB & TAP_HL)}")
print(f"Công cụ 1 trả lời 'không biết'          : {len(TAP_WEB - TAP_HL)}")       # - = hiệu hai tập

Kết quả: fail2ban đã chặn 467 IP tấn công các dịch vụ web thật (453 ở nhà tù nginx-exploit, 14 nginx-scanner, 5 nginx-ai-probe, 2 nginx-openwebui). Danh sách đen chỉ biết 19 IP trong số đó (4,1%); công cụ 1 trả lời "XẤU" cho 15 IP và "không biết" cho 452 IP.

📦 TAP_WEB = tập mã IP bị fail2ban chặn. · 📦 biet = phần giao với danh sách đen.

👉 Hiểu: chiều ngược lại của bài học: "không biết" không có nghĩa là "sạch". Danh sách đen này chủ yếu chứa máy chủ phát tán mã độc và IP tấn công SSH, không chứa IP dò quét web. Một quan sát trực tiếp (fail2ban thấy IP khai thác lỗ hổng) mạnh hơn nhiều so với việc tra danh sách.

1.6 Chấm điểm nguồn bằng Admiralty Code

📍 Bước 6 — Đánh giá. Đếm số nguồn là chưa đủ. Chuẩn quân sự / tình báo dùng Admiralty Code: chấm hai trục riêng.

Độ tin cậy nguồn Độ tin cậy thông tin
A hoàn toàn tin cậy 1 đã được nguồn khác độc lập xác nhận
B thường tin cậy 2 có lẽ đúng
C khá tin cậy 3 có thể đúng
D thường không tin cậy 4 đáng ngờ
E không tin cậy 5 khó tin
F chưa đánh giá được 6 chưa đánh giá được

Một quan sát trực tiếp mạnh (máy mình thấy IP đó tấn công) nặng hơn ba dấu hiệu gián tiếp yếu. Ô dưới là cách chấm gợi ý; bạn có quyền chấm khác và phải nói vì sao:

In [ ]:
# ✏️ SỬA: chấm lại theo ý bạn (chữ A–F, số 1–6) rồi chạy lại
cham = pd.DataFrame([
    ("fail2ban trên web thật",          "quan sát trực tiếp trên máy mình",           "B", 2),
    ("honeypot của mình (Cowrie)",       "quan sát trực tiếp, nhưng trên máy mồi",     "B", 2),
    ("abuse.ch (URLhaus, Feodo)",        "cộng đồng báo cáo, một tổ chức tổng hợp",   "B", 3),
    ("kho mẫu mã độc của mình",          "rút từ honeypot của mình → KHÔNG độc lập",   "B", 2),
], columns=["nguồn", "bản chất", "tin_cay_nguon", "tin_cay_thong_tin"])
cham["goc_doc_lap"] = ["máy mình (web)", "máy mình (honeypot)", "abuse.ch", "máy mình (honeypot)"]
print(cham.to_string(index=False))
print(f"\nSố nguồn trong bảng: {len(cham)}  →  số GỐC độc lập thật: {cham.goc_doc_lap.nunique()}")

# Với IP thử ở 1.1: những gốc nào thật sự đứng sau 3 câu "XẤU"?
goc_ip = set(cac_goc(c2.loc[c2.ip_token == IP_THU, "sources"].iloc[0]))
print(f"\nIP {IP_THU}: 3 công cụ nói 'XẤU', nhưng gốc thật chỉ là: {sorted(goc_ip)}  ({len(goc_ip)} gốc)")

Kết quả: bảng chấm có 4 nguồn nhưng chỉ 3 gốc độc lập (kho mẫu và honeypot cùng một gốc). Riêng IP-3bd34b18 ở 1.1: ba công cụ nói "XẤU" nhưng gốc thật chỉ là ['honeypot của mình'] — 1 gốc.

📦 cham = bảng chấm Admiralty: nguồn, bản chất, độ tin cậy nguồn (A–F), độ tin cậy thông tin (1–6), gốc độc lập.

👉 Hiểu: câu đúng phải là: "IP này được một nguồn (honeypot của mình) quan sát, mức B2; chưa có nguồn độc lập nào xác nhận". Đủ để chặn trên honeypot, chưa đủ để kết luận đó là C2 của một chiến dịch cụ thể.

Biểu đồ: danh sách đen còn hiệu lực đến từ gốc nào?

Cách đọc
Mỗi thanh một gốc (nhóm nguồn có chung xuất xứ)
Độ dài số IP còn hiệu lực mà gốc đó đóng góp
Câu hỏi danh sách "của mình" thật ra phụ thuộc bao nhiêu vào một tổ chức bên ngoài?
In [ ]:
%matplotlib inline
import matplotlib.pyplot as plt
dong_gop = c2[c2.ip_token.isin(TAP_HL)].goc.value_counts()       # đếm IP còn hiệu lực theo gốc
fig, ax = plt.subplots(figsize=(8, 2.8))
ax.barh(dong_gop.index[::-1], dong_gop.values[::-1], color="#4c78a8")   # [::-1]: đảo để thanh lớn nhất nằm trên
for i, v in enumerate(dong_gop.values[::-1]):                    # enumerate: lấy cả vị trí i lẫn giá trị v
    ax.text(v, i, f" {v:,}", va="center")
ax.set_xlabel("số IP còn hiệu lực"); ax.set_title("Danh sách đen C2 còn hiệu lực — đóng góp theo gốc")
plt.tight_layout(); plt.show()
print(dong_gop.to_string())

Kết quả: trong 22.106 IP còn hiệu lực: 14.656 từ abuse.ch, 7.419 từ honeypot của mình, chỉ 31 từ cả hai.

👉 Hiểu: gần 2/3 danh sách đen "của mình" thực ra là dữ liệu của một tổ chức bên ngoài (abuse.ch). Nếu một công cụ khác cũng tra abuse.ch (ví dụ ThreatFox — cũng của abuse.ch) thì "hai công cụ đồng ý" lại là một gốc. Và chỉ 31 IP thật sự được hai gốc cùng thấy.


Phần 2 — Thẻ 8: Nhận ra honeypot ("seasoned honeypot")

📍 Bước 1 → 4. Kẻ tấn công cũng làm threat hunting: trước khi thả mã độc, nhiều bot kiểm tra xem máy có thật không. Một honeypot "để nguyên mặc định" trả lời những câu không khớp nhau → bị lộ.

Hiểu đơn giản: căn nhà mẫu trống trơn thì ai cũng biết không có người ở. Căn nhà có giày dép, bát đĩa, thư từ thì trông như nhà thật. "Làm cũ" (seasoning) honeypot = thêm dữ liệu, lịch sử, dấu vết người dùng, và làm cho mọi câu trả lời khớp nhau.

Cowrie có hai chỗ quyết định nó trả lời gì:

  • honeyfs/ = nội dung các file giả (/etc/hostname, /proc/cpuinfo…) mà lệnh cat đọc ra.
  • cowrie.cfg = cấu hình: banner SSH, tên máy, phiên bản nhân mà lệnh uname in ra.

⚠️ Dữ liệu dưới đây là bản Cowrie cũ trên máy chủ dạy học (đã dừng). Honeypot đang chạy hiện nay nằm ở máy khác và chưa được kiểm tra — không suy ra nó cũng y như vậy.

2.1 Kẻ tấn công thấy gì, và cấu hình nói gì?

In [ ]:
su_that = doc("honeypot_facts.csv")
# 📦 su_that = mỗi hàng một câu hỏi kẻ tấn công có thể hỏi; cột 2 = honeyfs trả lời; cột 3 = cowrie.cfg nói
print(su_that.to_string(index=False))

Kết quả: bảng 10 mục. Kẻ tấn công gõ cat /etc/hostname sẽ thấy svr04, cat /etc/issue thấy Debian GNU/Linux 7, cat /proc/version thấy nhân 3.2.0-4-amd64, CPU Core 2 Duo E8200, RAM 4054744 kB, người dùng phil (Phil California), không có lịch sử lệnh. Trong khi cowrie.cfg đặt tên máy srv-node-01, banner SSH OpenSSH_9.2p1 Debian-2+deb12u2, nhân 6.1.0-21-amd64.

📦 su_that = 10 hàng × 3 cột: mục, honeyfs trả về, cowrie.cfg nói.

👉 Hiểu: người dựng đã sửa cowrie.cfg cho giống một máy Debian 12 mới, nhưng quên honeyfs — vẫn là bộ file mặc định đi kèm Cowrie (svr04, phil là giá trị mặc định nổi tiếng mà các công cụ soi honeypot đều biết).

Tự làm trước khi chạy ô tiếp: đọc bảng trên và tìm các chỗ mâu thuẫn mà một bot có thể phát hiện chỉ bằng 2–3 lệnh.

2.2 Cho máy tự soát mâu thuẫn

In [ ]:
hang = su_that.set_index("muc")                     # set_index: tra theo tên mục thay vì số thứ tự
mau_thuan = []
# 1) nhân Linux: cat /proc/version (honeyfs) với uname -r (cowrie.cfg)
nhan_cat = re.search(r"\d+\.\d+", hang.loc["Nhân Linux (cat /proc/version)", "honeyfs_tra_ve"]).group()
nhan_uname = re.search(r"\d+\.\d+", hang.loc["Nhân Linux (uname -r)", "cowrie_cfg_noi"]).group()
if nhan_cat != nhan_uname:
    mau_thuan.append(f"nhân: 'cat /proc/version' ra {nhan_cat} nhưng 'uname -r' ra {nhan_uname}")
# 2) hệ điều hành: /etc/issue với banner SSH
issue = hang.loc["Hệ điều hành (/etc/issue)", "honeyfs_tra_ve"]
banner = hang.loc["Phiên bản SSH (banner)", "cowrie_cfg_noi"]
if "7" in issue and "deb12" in banner:
    mau_thuan.append(f"hệ điều hành: /etc/issue = '{issue}' nhưng banner SSH = '{banner}' (Debian 12)")
# 3) tên máy ở 3 chỗ khác nhau
ten = {hang.loc["Tên máy (hostname)", "honeyfs_tra_ve"], hang.loc["Tên trong /etc/hosts", "honeyfs_tra_ve"],
       hang.loc["Tên máy (hostname)", "cowrie_cfg_noi"]}
if len(ten) > 1:
    mau_thuan.append(f"tên máy: {len(ten)} tên khác nhau {sorted(ten)}")
# 4) phần cứng quá cũ so với nhân mới
cpu = hang.loc["CPU (/proc/cpuinfo)", "honeyfs_tra_ve"]
mau_thuan.append(f"phần cứng: CPU '{' '.join(cpu.split())}' (đời 2008) đi với nhân {nhan_uname} (đời 2023)")
print(f"📦 mau_thuan = {len(mau_thuan)} chỗ:")
for i, m in enumerate(mau_thuan, 1):
    print(f"  {i}. {m}")

Kết quả: máy tự tìm ra 4 chỗ mâu thuẫn: (1) cat /proc/version ra nhân 3.2 nhưng uname -r ra 6.1; (2) /etc/issue nói Debian 7 nhưng banner SSH nói Debian 12; (3) 3 tên máy khác nhau nas3, srv-node-01, svr04; (4) CPU đời 2008 đi với nhân đời 2023.

📦 mau_thuan = danh sách câu mô tả từng mâu thuẫn.

👉 Hiểu: chỉ cần 2 lệnh (uname -r và cat /proc/version) là lộ. Không cần công cụ đặc biệt.

2.3 Bot thật có kiểm tra những chỗ đó không?

Nói "bot sẽ soát" thì phải đo. Dùng lệnh thật kẻ tấn công gõ vào honeypot trong tháng 7/2026 (dữ liệu của Module 5): bao nhiêu phiên có gõ các lệnh dò hệ thống?

Cách đọc biểu đồ
Mỗi thanh một kiểu lệnh dò
Độ dài % số phiên (có gõ ít nhất một lệnh) đã chạy lệnh đó
In [ ]:
lenh = pd.read_csv("/opt/csai/data/m5_honeypot/commands.csv", dtype=str, keep_default_na=False)
print(f"📦 lenh = {len(lenh):,} lệnh trong {lenh.session.nunique():,} phiên. Ví dụ: {lenh.lenh.iloc[0]!r}")
# ✏️ SỬA: thêm / bớt kiểu lệnh dò (tên: regex)
DO_THAM = {"uname": r"\buname\b", "hostname": r"\bhostname\b", "whoami": r"\bwhoami\b",
           "nproc": r"\bnproc\b", "lscpu": r"\blscpu\b", "/proc/cpuinfo": r"/proc/cpuinfo",
           "/proc/version": r"/proc/version", "/etc/issue, os-release": r"/etc/(?:issue|os-release)"}   # (?:...) = nhóm không bắt
tong_phien = lenh.session.nunique()
ty_le = pd.Series({ten: lenh[lenh.lenh.str.contains(mau, regex=True)].session.nunique() / tong_phien * 100
                   for ten, mau in DO_THAM.items()}).sort_values()
fig, ax = plt.subplots(figsize=(8, 3.2))
ax.barh(ty_le.index, ty_le.values, color="#f58518")
for i, v in enumerate(ty_le.values):
    ax.text(v, i, f" {v:.1f}%", va="center")
ax.set_xlabel("% phiên có chạy lệnh"); ax.set_title("Bot thật dò hệ thống bằng lệnh gì (honeypot, tháng 7/2026)")
plt.tight_layout(); plt.show()
print(ty_le.sort_values(ascending=False).round(1).to_string())

Kết quả: trong 70.372 phiên có gõ lệnh (92.510 lệnh, tháng 7/2026): 33,4% phiên chạy uname, 7,7% hostname, 4,5% whoami, khoảng 2% mỗi lệnh /etc/issue, nproc, /proc/cpuinfo, lscpu, /proc/version.

📦 lenh = bảng lệnh: phiên, giờ, thứ tự, nội dung lệnh. · 📦 ty_le = % phiên chạy từng kiểu lệnh dò.

👉 Hiểu: bot có dò hệ thống, và rất thường xuyên: cứ 3 phiên thì 1 phiên chạy uname. Phần lớn chỉ để chọn đúng bản mã độc cho kiến trúc CPU, nhưng 2% phiên đọc cả /proc/version — đủ để so với uname và thấy mâu thuẫn ở 2.2. Đây là con số đo được, không phải đoán.

2.4 Honeypot web (OpenCanary): trang lỗi 404 tự khai điều gì?

OpenCanary giả làm trang đăng nhập của một ổ lưu trữ mạng (NAS Synology). Xem trang lỗi 404 mà nó trả về cho mọi đường dẫn lạ:

In [ ]:
trang = (THU_MUC / "canary_404.html").read_text()
print(trang)
print("--- soát ---")
print("Máy chủ web tự khai :", re.search(r"<address>(.*?)</address>", trang).group(1))
print("Cổng tự khai        :", re.search(r"Port (\d+)", trang).group(1), "(trong khi honeypot web nghe ở cổng 8080)")

Kết quả: trang lỗi 404 tự khai Apache/2.4.52 (Ubuntu) Server at [IP-LAN] Port 80. (Chỗ [IP-LAN] trong bản gốc là địa chỉ IP nội bộ của máy chủ — đã được che khi xuất dữ liệu.) Cổng tự khai là 80, trong khi honeypot web nghe ở cổng 8080.

👉 Hiểu: ba chỗ lộ cùng lúc: (1) rò IP nội bộ ra Internet — đây là lỗi thật, không chỉ là chỗ lộ honeypot; (2) cổng tự khai không khớp cổng thật; (3) giao diện giả làm NAS Synology nhưng máy chủ web tự nhận là Apache trên Ubuntu.

2.5 Kế hoạch "làm cũ" — sửa gì, ở đâu

Chỗ lộ Sửa ở đâu Lưu ý
Nhân / hệ điều hành không khớp sửa honeyfs/proc/version, honeyfs/etc/issue cho khớp cowrie.cfg (hoặc ngược lại) chọn một câu chuyện và giữ nó ở mọi chỗ
Tên máy ở 3 nơi khác nhau honeyfs/etc/hostname, honeyfs/etc/hosts, hostname trong cowrie.cfg
Phần cứng đời cũ, RAM nhỏ honeyfs/proc/cpuinfo, honeyfs/proc/meminfo lấy từ một máy thật cùng đời
Không có dấu vết người dùng thêm /root/.bash_history, file cấu hình có mật khẩu giả, file .env breadcrumb: file cấu hình trỏ sang một honeypot khác trông như "máy chủ cơ sở dữ liệu"
File mới không hiện khi ls thêm vào cả honeyfs/ (nội dung) và cây thư mục giả fs.pickle (bằng công cụ fsctl / createfs của Cowrie) chỉ thêm vào honeyfs/ thì cat đọc được nhưng ls không thấy — lại thêm một chỗ lộ
OpenCanary khai IP nội bộ, sai cổng sửa trang lỗi, bỏ IP nội bộ rò IP nội bộ là lỗi thật, không chỉ là chỗ lộ
Banner "Apache/Ubuntu" mà giao diện là Synology chọn banner khớp với giao diện

⚠️ "Làm cũ" hiệu quả nhất với bot và kẻ tấn công tầm trung. Nhóm APT giỏi soi honeypot vẫn có thể nhận ra.


Phần 3 — Thẻ 7: Đồ thị có kiểu (typed graph) — nối bằng chứng thành bản đồ hạ tầng

📍 Bước 3 → 4. Bảng là danh bạ (ai là ai). Đồ thị có kiểu là cây gia phả (ai liên quan với ai, theo kiểu gì). Mỗi nút có kiểu (IP, HASSH, JA4, KHOÁ SSH), mỗi cạnh có kiểu (DUNG_VAN_TAY_SSH, DUNG_VAN_TAY_TLS, DUNG_KHOA).

Vì sao cần đồ thị: hai IP không chung gì trong bảng, nhưng cùng dùng một khoá SSH → đồ thị nối chúng lại qua nút khoá. Đó chính là pivot tự động.

3.1 Dựng đồ thị

In [ ]:
import networkx as nx
van_tay_ssh = pd.read_csv(THU_MUC / "edges_hassh.csv")     # ip_token, hassh, sessions
van_tay_tls = pd.read_csv(THU_MUC / "edges_ja4.csv")       # ip_token, ja4, sessions
dung_khoa   = pd.read_csv(THU_MUC / "edges_sshkey.csv")    # ip_token, key_id, sightings
khoa        = pd.read_csv(THU_MUC / "sshkeys.csv")         # key_id, threat_actor, ...
chien_dich  = pd.read_csv(THU_MUC / "fp_campaigns.csv")    # vân tay đã được gắn nhóm tấn công
print("📦 khoa =");       print(khoa.to_string(index=False))
print("📦 chien_dich ="); print(chien_dich[["fingerprint_type", "fingerprint_value", "campaign_tag"]].to_string(index=False))

G = nx.Graph()                                            # đồ thị vô hướng
# mỗi nút là một cặp (kiểu, giá trị) → hai nút khác kiểu không bao giờ trùng nhau
for r in van_tay_ssh.itertuples():                         # itertuples: duyệt từng hàng, r.cot = giá trị
    G.add_edge(("IP", r.ip_token), ("HASSH", r.hassh), kieu="DUNG_VAN_TAY_SSH")
for r in van_tay_tls.itertuples():
    G.add_edge(("IP", r.ip_token), ("JA4", r.ja4), kieu="DUNG_VAN_TAY_TLS")
for r in dung_khoa.itertuples():
    G.add_edge(("IP", r.ip_token), ("KHOA", r.key_id), kieu="DUNG_KHOA")
from collections import Counter                            # Counter: đếm số lần xuất hiện
print(f"\n📦 G = {G.number_of_nodes():,} nút, {G.number_of_edges():,} cạnh. Nút theo kiểu:", dict(Counter(n[0] for n in G)))
print("Ví dụ một cạnh:", next(iter(G.edges(data=True))))

Kết quả: đồ thị G có 4.251 nút và 4.317 cạnh: 4.224 IP, 19 HASSH, 5 JA4, 3 khoá SSH. Bảng khoa: K1 gắn nhóm TA-06 (thấy 329.950 lần, 3.284 IP), K2 gắn TA-01/TA-05 (5 IP), K3 chưa có nhãn (1 IP). Bảng chien_dich: một HASSH và một JA4 gắn nhãn TA-07.

📦 G = đồ thị; mỗi nút là cặp (kiểu, giá trị), mỗi cạnh có thuộc tính kieu. · 📦 khoa, chien_dich = nhãn nhóm tấn công do người phân tích gắn trước.

👉 Hiểu: chỉ 27 nút "vân tay / khoá" mà nối được hơn 4.200 IP — mỗi nút vân tay là một điểm pivot.

3.2 ⚠️ Lỗi không báo lỗi: thêm nút "nhãn" vào đồ thị

📍 Bước 5 — Lưu ý khi coding. Một ý tự nhiên: thêm nút "DANH_SÁCH_ĐEN" và nối mọi IP có trong danh sách đen vào nó. Thử xem chuyện gì xảy ra với số thành phần liên thông (connected components = các nhóm nút nối được với nhau):

In [ ]:
print("Không có nút nhãn :", nx.number_connected_components(G), "thành phần liên thông")
G_sai = G.copy()
TAP_C2 = set(c2.ip_token)
for n in list(G_sai.nodes):
    if n[0] == "IP" and n[1] in TAP_C2:
        G_sai.add_edge(n, ("NHAN", "DANH_SACH_DEN"), kieu="CO_TRONG")     # một nút nhãn nối với mọi IP bị liệt kê
cc_sai = sorted(nx.connected_components(G_sai), key=len, reverse=True)
print("Có nút nhãn       :", len(cc_sai), "thành phần; thành phần lớn nhất có", len(cc_sai[0]), "nút")
print("→ Nút nhãn trở thành 'cầu nối' giả: hai IP chỉ chung việc 'cùng bị ghi tên' bị coi là cùng hạ tầng.")
print("  Cách đúng: nhãn là THUỘC TÍNH của nút, không phải nút.")
nx.set_node_attributes(G, {n: (n[1] in TAP_C2) for n in G if n[0] == "IP"}, "trong_danh_sach_den")

Kết quả: không có nút nhãn: 17 thành phần liên thông. Thêm một nút "DANH_SACH_DEN" nối mọi IP bị liệt kê: còn 1 thành phần chứa 4.252 nút — mọi thứ dính vào nhau.

👉 Hiểu: code chạy bình thường, không báo lỗi, nhưng kết luận "tất cả là một hạ tầng" là sai. Nút nhãn (danh sách đen, quốc gia, nhà mạng…) phải là thuộc tính của nút IP, không được là nút trong đồ thị dùng để tìm cụm.

3.3 Thành phần liên thông: hạ tầng nào nối với nhau?

In [ ]:
cac_tp = sorted(nx.connected_components(G), key=len, reverse=True)
print(f"📦 cac_tp = {len(cac_tp)} thành phần. 6 thành phần lớn nhất:")
for i, tp in enumerate(cac_tp[:6]):
    kieu = Counter(n[0] for n in tp)
    nhan = [n[1] for n in tp if n[0] in ("KHOA", "JA4")]
    print(f"  #{i}: {len(tp):5d} nút  {dict(kieu)}  khoá/JA4: {nhan[:4]}")

Kết quả: 17 thành phần. Lớn nhất 4.089 nút: 4.087 IP + 1 HASSH + khoá K1 (TA-06). Tiếp theo: 63 nút (60 IP, 2 HASSH, 1 JA4), 37 nút (35 IP, 2 HASSH), 10 nút (7 IP, 2 HASSH, JA4 của TA-07), 6 nút (5 IP + khoá K2).

📦 cac_tp = danh sách các thành phần, xếp từ lớn đến nhỏ.

👉 Hiểu: thành phần lớn nhất dính hai thứ rất khác nhau: hàng nghìn IP dùng khoá K1 và hàng trăm IP dùng một HASSH phổ biến, chỉ vì một vài IP có cả hai. Liên thông thôi là quá thô → cần Louvain.

3.4 Louvain: tách thành phần lớn thành các cộng đồng

Một thành phần liên thông lớn có thể chứa nhiều nhóm chỉ nối nhau qua một vài IP. Thuật toán Louvain tìm các cộng đồng = nhóm nút có mật độ liên kết cao bên trong, thưa ra bên ngoài (không cần "khép kín").

In [ ]:
HAT_GIONG = 42                                                    # ✏️ SỬA: Louvain có yếu tố ngẫu nhiên; đổi số để xem cụm có ổn định không
cong_dong = sorted(nx.community.louvain_communities(G, seed=HAT_GIONG), key=len, reverse=True)
print(f"📦 cong_dong = {len(cong_dong)} cộng đồng; kích thước 8 cộng đồng lớn nhất:", [len(c) for c in cong_dong[:8]])

nhan_khoa = dict(zip(khoa.key_id, khoa.threat_actor))             # K1 → nhóm tấn công
nhan_vt = dict(zip(chien_dich.fingerprint_value, chien_dich.campaign_tag))
def chan_dung(cd):
    kieu = Counter(n[0] for n in cd)
    ip = [n for n in cd if n[0] == "IP"]
    nhan = sorted(({nhan_khoa.get(n[1]) for n in cd if n[0] == "KHOA"} | {nhan_vt.get(n[1]) for n in cd if n[0] in ("HASSH", "JA4")}) - {None, "UNKNOWN"})   # bỏ "chưa có nhãn"
    trong_dsd = sum(G.nodes[n].get("trong_danh_sach_den", False) for n in ip)
    vt = [n[1][:12] for n in cd if n[0] in ("HASSH", "JA4", "KHOA")]
    return {"số IP": len(ip), "HASSH": kieu.get("HASSH", 0), "JA4": kieu.get("JA4", 0), "khoá": kieu.get("KHOA", 0),
            "nhãn đã biết": ", ".join(nhan) or "—", "IP trong d.s. đen": trong_dsd, "vân tay / khoá": " ".join(vt[:3])}
bang_cd = pd.DataFrame([chan_dung(cd) for cd in cong_dong[:8]])
print(bang_cd.to_string())

Kết quả: Louvain tách ra 18 cộng đồng (8 lớn nhất: 3.283, 806, 63, 37, 10, 6, 6, 5 nút). Cộng đồng 0: 3.282 IP quanh khoá K1 → nhãn TA-06 (3.253 IP trong số đó đã nằm trong danh sách đen). Cộng đồng 1: 805 IP quanh một HASSH acaa53e0a7d7…, không có nhãn. Cộng đồng 4: 7 IP + 2 HASSH + 1 JA4 → nhãn TA-07. Cộng đồng 6: 5 IP + khoá K2 → TA-01/TA-05.

📦 cong_dong = danh sách các cộng đồng (mỗi cộng đồng là một tập nút). · 📦 bang_cd = chân dung 8 cộng đồng lớn nhất.

👉 Hiểu: Louvain tách đúng chỗ mà liên thông gộp sai: nhóm TA-06 (khoá K1) và nhóm 805 IP dùng HASSH phổ biến giờ là hai cộng đồng riêng. Cả ba nhãn đã biết đều rơi vào cộng đồng riêng của mình.

3.5 Đối chiếu với nhãn đã biết — và một cái bẫy

📍 Bước 6 — Đánh giá. Mỗi cộng đồng có khớp nhóm tấn công mà người phân tích đã gắn trước đó không? Và một câu hỏi quan trọng hơn: dùng chung một dấu vân tay có phải là cùng một kẻ tấn công không?

In [ ]:
# HASSH nào được nhiều IP dùng nhất?
do_pho_bien = van_tay_ssh.groupby("hassh").ip_token.nunique().sort_values(ascending=False)   # groupby: gom theo vân tay, đếm IP khác nhau
print("📦 do_pho_bien = số IP dùng mỗi HASSH (5 cái phổ biến nhất):")
print(do_pho_bien.head(5).to_string())
print(f"\nHASSH phổ biến nhất được {do_pho_bien.iloc[0]} IP dùng = {100 * do_pho_bien.iloc[0] / van_tay_ssh.ip_token.nunique():.0f}% số IP có vân tay SSH.")

# Cạnh QUAN SÁT vs cạnh SUY LUẬN
print("\nCạnh trong đồ thị này đều là QUAN SÁT (honeypot thấy IP dùng vân tay / khoá đó).")
print("Nhãn nhóm tấn công là SUY LUẬN của người phân tích → phải ghi kèm độ tin cậy khi đưa vào đồ thị.")

Kết quả: HASSH acaa53e0a7d7… được 805 IP dùng = 86% số IP có vân tay SSH; HASSH thứ hai chỉ 39 IP.

📦 do_pho_bien = số IP khác nhau dùng mỗi HASSH.

👉 Hiểu: một vân tay mà 86% kẻ tấn công cùng có thì gần như chắc chắn là một thư viện SSH phổ biến (nhiều bot dùng chung), không phải một nhóm tấn công. Cộng đồng 805 IP ở 3.4 vì vậy không được đặt tên "nhóm X". Ngược lại, một khoá SSH riêng mà kẻ tấn công cài vào máy nạn nhân là dấu vết cá nhân hơn nhiều.

⚠️ Một chi tiết thật: HASSH được gắn nhãn TA-07 trong bảng chien_dich là 390ffe6f4c37…, nhưng HASSH thực sự xuất hiện trong cộng đồng TA-07 là 390ffe68a68c… (giống 6 ký tự đầu). Bản ghi nhãn trỏ tới một vân tay mà honeypot chưa từng thấy — nhãn cũng là dữ liệu, cũng có thể sai.

Biểu đồ: một cộng đồng có nhãn đã biết, vẽ ra để nhìn

Cách đọc
Chấm xanh IP (viền đỏ = có trong danh sách đen)
Hình vuông cam vân tay SSH (HASSH)
Tam giác tím vân tay TLS (JA4)
Kim cương đỏ khoá SSH
Đường nối một quan sát "IP này dùng vân tay / khoá kia"
In [ ]:
# chọn cộng đồng có nhãn đã biết, nhỏ (≤ 80 nút, vẽ được rõ), có NHIỀU KIỂU nút nhất; ✏️ SỬA: đổi điều kiện để vẽ cộng đồng khác
co_nhan = [cd for cd in cong_dong if chan_dung(cd)["nhãn đã biết"] != "—" and len(cd) <= 80]
cd_ve = max(co_nhan, key=lambda cd: (len({n[0] for n in cd}), -len(cd)))   # nhiều kiểu nút nhất; hoà thì lấy cộng đồng nhỏ hơn
H = G.subgraph(cd_ve)
vi_tri = nx.spring_layout(H, seed=1)                      # spring_layout: đặt nút sao cho nút nối nhau ở gần nhau
fig, ax = plt.subplots(figsize=(8, 6))
KIEU_VE = {"IP": ("o", "#4c78a8", 60), "HASSH": ("s", "#f58518", 200), "JA4": ("^", "#b279a2", 220), "KHOA": ("D", "#e45756", 220)}
nx.draw_networkx_edges(H, vi_tri, ax=ax, alpha=0.4)
for kieu, (hinh, mau, co) in KIEU_VE.items():
    nut = [n for n in H if n[0] == kieu]
    vien = ["red" if H.nodes[n].get("trong_danh_sach_den") else "white" for n in nut]
    nx.draw_networkx_nodes(H, vi_tri, nodelist=nut, node_shape=hinh, node_color=mau, node_size=co, edgecolors=vien, ax=ax, label=kieu)
nx.draw_networkx_labels(H, vi_tri, {n: n[1][:10] for n in H if n[0] != "IP"}, font_size=8, ax=ax)
ax.legend(scatterpoints=1); ax.set_title(f"Cộng đồng có nhãn {chan_dung(cd_ve)['nhãn đã biết']}: {len(cd_ve)} nút"); ax.axis("off")
plt.tight_layout(); plt.show()
print(chan_dung(cd_ve))

Kết quả: cộng đồng TA-07: 10 nút = 7 IP + 2 HASSH + 1 JA4; cả 7 IP đều đã nằm trong danh sách đen (viền đỏ). Một JA4 (t12d4312h1…) và HASSH 390ffe68a6… cùng nối phần lớn các IP; HASSH 1f2f2f9b0a… nối thêm 3 IP.

👉 Hiểu: đây là hình ảnh của pivot: từ một IP, đi qua vân tay TLS sang IP khác, rồi qua vân tay SSH sang IP thứ ba. Ba IP ở góc dưới chỉ nối với nhóm qua một HASSH — bằng chứng yếu hơn các IP nối qua cả JA4 lẫn HASSH.


Phần 4 — Thẻ 2: Favicon hash — dấu vân tay của phần mềm

📍 Bước 4 → 5. Favicon là biểu tượng nhỏ trên tab trình duyệt. Mọi máy chạy cùng một phần mềm, để nguyên mặc định, đều có cùng một favicon → hash của nó là một "dấu vân tay" giúp tìm mọi máy chạy phần mềm đó trên Shodan (http.favicon.hash:<số>), dù IP và tên miền khác nhau.

Cách Shodan tính (dễ sai): Shodan không hash file nhị phân trực tiếp. Nó đổi file sang Base64 có xuống dòng mỗi 76 ký tự (kiểu codecs.encode(..., "base64")), rồi hash bằng MurmurHash3 (mmh3, ra số nguyên có dấu).

4.1 Tính favicon hash của các dịch vụ thật của máy chủ

In [ ]:
import mmh3, codecs, base64, hashlib
THU_MUC_ICON = THU_MUC / "favicons"
dong = []
for f in sorted(THU_MUC_ICON.iterdir()):                     # iterdir: liệt kê file trong thư mục
    du_lieu = f.read_bytes()                                  # đọc dạng byte (nhị phân)
    dong.append({"file": f.name, "byte": len(du_lieu),
                 "mmh3 (đúng kiểu Shodan)": mmh3.hash(codecs.encode(du_lieu, "base64")),
                 "4 byte đầu": du_lieu[:4]})
bang_icon = pd.DataFrame(dong)
print("📦 bang_icon ="); print(bang_icon.to_string(index=False))

Kết quả: 5 file. Favicon hash kiểu Shodan: Gitea -1668137428, Nextcloud 167837543, Open WebUI 1470014414, OpenCanary (giao diện NAS) 149371702. File openwebui_favicon_ico_is_html.html cũng ra một số (-937345168) nhưng 4 byte đầu là <!do.

📦 bang_icon = bảng 5 hàng: tên file, số byte, hash mmh3, 4 byte đầu.

👉 Hiểu: ba dịch vụ thật đều đang dùng favicon mặc định của phần mềm → ai có số này đều tìm được mọi máy chạy cùng phần mềm, kể cả máy của mình.

4.2 ⚠️ Lỗi không báo lỗi: Base64 sai kiểu

📍 Bước 5. Python có hai cách đổi Base64. Cả hai đều chạy, không báo lỗi, nhưng ra hash khác nhau:

In [ ]:
du_lieu = (THU_MUC_ICON / "nextcloud_favicon.ico").read_bytes()
kieu_shodan = codecs.encode(du_lieu, "base64")      # có ký tự xuống dòng mỗi 76 ký tự
kieu_lien = base64.b64encode(du_lieu)               # một dòng liền, không xuống dòng
print("codecs.encode : xuống dòng", kieu_shodan.count(b"\n"), "lần → mmh3 =", mmh3.hash(kieu_shodan))
print("b64encode     : xuống dòng", kieu_lien.count(b"\n"), "lần → mmh3 =", mmh3.hash(kieu_lien))
print("Giống base64.encodebytes?", kieu_shodan == base64.encodebytes(du_lieu))

Kết quả: codecs.encode xuống dòng 58 lần → 167837543 (khớp ở 4.1); b64encode không xuống dòng → -1287608477. codecs.encode(..., "base64") cho kết quả giống hệt base64.encodebytes.

👉 Hiểu: cùng một file, hai con số khác nhau, không có lỗi nào được báo. Tra Shodan bằng số sai → "không tìm thấy máy nào" → kết luận sai "không ai chạy phần mềm này".

4.3 ⚠️ "Favicon" không phải ảnh

Có dịch vụ trả về trang HTML ở đường dẫn /favicon.ico (ứng dụng một trang — SPA — trả cùng một trang cho mọi đường dẫn lạ). Hash của nó vô nghĩa. Kiểm tra mấy byte đầu (magic bytes) trước khi tin:

In [ ]:
CHU_KY = {b"\x89PNG": "ảnh PNG", b"\x00\x00\x01\x00": "biểu tượng ICO", b"<!do": "TRANG HTML", b"<svg": "ảnh SVG"}
for f in sorted(THU_MUC_ICON.iterdir()):
    dau = f.read_bytes()[:4]
    loai = next((ten for ky, ten in CHU_KY.items() if dau.lower().startswith(ky.lower())), "không rõ")   # next(..., mặc định)
    print(f"{f.name:40s} {loai}")

Kết quả: 4 file là ảnh thật (2 PNG, 2 ICO). openwebui_favicon_ico_is_html.html là TRANG HTML.

👉 Hiểu: đường dẫn /favicon.ico của Open WebUI trả về trang chính của ứng dụng (ứng dụng một trang trả cùng một trang cho mọi đường dẫn lạ). Hash của trang đó là hash của HTML, không phải của biểu tượng. Luôn lấy đúng file mà trang khai báo trong thẻ <link rel="icon"> và kiểm tra magic bytes.

4.4 Favicon hash nói được gì, không nói được gì

✅ Nói được máy chạy phần mềm gì (Nextcloud, Gitea, Open WebUI, OpenCanary…) nếu để favicon mặc định
❌ Không nói được máy của ai — favicon mặc định thì triệu máy giống nhau
❌ Không thấy được máy Shodan chưa quét, máy mới dựng
Bị né đổi một file favicon là mất dấu
Với dịch vụ thật đổi favicon chỉ giảm bị lọc hàng loạt, không phải biện pháp bảo vệ: tiêu đề trang, tên file JavaScript, chân trang vẫn lộ phần mềm. Vá lỗi + chặn dò quét quan trọng hơn nhiều
Với honeypot ngược lại: favicon riêng của OpenCanary có thể tố cáo đây là OpenCanary chứ không phải Synology thật

Phần 5 — Thẻ 3: Hash chính xác và hash mờ

📍 Bước 4 → 5. Hash chính xác (SHA-256): đổi 1 byte là ra hash hoàn toàn khác → tốt để nói "đúng file này", tệ để nói "gần giống file này". Hash mờ (fuzzy hash: ssdeep, TLSH): file gần giống thì hash gần giống, cho điểm giống nhau 0–100.

5.1 ⚠️ "Chữ ký mã độc" e3b0c442…

In [ ]:
rong = hashlib.sha256(b"").hexdigest()            # hash của chuỗi RỖNG (0 byte)
print("SHA-256 của 0 byte:", rong)
print("Thấy giá trị này trong log = bạn vừa hash một phản hồi trống (lỗi 403, hết giờ, file không tồn tại),")
print("KHÔNG phải chữ ký mã độc.")

Kết quả: SHA-256 của 0 byte là e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

👉 Hiểu: chuyện thật trên máy chủ dạy học: sổ kho mẫu mã độc ghi lại một lần quét 14 ngày tìm được 61 lần "upload" lên honeypot, cả 61 đều mang đúng hash này — tức là không có file nào được tải lên thật (upload thất bại hoặc rỗng). Ai đếm "61 mẫu mã độc mới" là đếm sai.

5.2 Sửa 1 ký tự: hash chính xác và hash mờ phản ứng thế nào?

In [ ]:
import ppdeep                                       # ssdeep viết bằng Python thuần
goc = (THU_MUC / "canary_404.html").read_bytes()
sua = goc.replace(b"Port 80", b"Port 81")          # ✏️ SỬA: thử sửa chỗ khác, nhiều hơn
print("SHA-256 gốc:", hashlib.sha256(goc).hexdigest()[:24], "…")
print("SHA-256 sửa:", hashlib.sha256(sua).hexdigest()[:24], "…")
print("ssdeep gốc :", ppdeep.hash(goc))
print("ssdeep sửa :", ppdeep.hash(sua))
print("Điểm giống nhau (ssdeep, 0–100):", ppdeep.compare(ppdeep.hash(goc), ppdeep.hash(sua)))

Kết quả: sửa Port 80 thành Port 81 (1 ký tự): SHA-256 đổi hoàn toàn (2a76d961… → 2a7e31a2…), hai chuỗi ssdeep gần như giống nhau, điểm giống nhau 96 / 100.

👉 Hiểu: muốn hỏi "có phải đúng file này?" → SHA-256. Muốn hỏi "có phải gần như file này (bản sửa nhẹ, bản dịch, bản đổi màu của cùng một bộ kit)?" → hash mờ.

5.3 Với file chương trình: "gần như sao chép" khác "cùng họ"

In [ ]:
chuong_trinh = Path("/usr/bin/ls").read_bytes()
ban_va = bytearray(chuong_trinh)                     # bytearray: chuỗi byte sửa được
SO_BYTE_SUA = 16                                     # ✏️ SỬA: sửa nhiều byte hơn (100, 5000…) rồi chạy lại
giua = len(ban_va) // 2
ban_va[giua:giua + SO_BYTE_SUA] = b"\x90" * SO_BYTE_SUA   # ghi đè một đoạn ở giữa file
h_goc, h_va = ppdeep.hash(chuong_trinh), ppdeep.hash(bytes(ban_va))
print(f"/usr/bin/ls ({len(chuong_trinh):,} byte) so với bản bị sửa {SO_BYTE_SUA} byte: điểm ssdeep =", ppdeep.compare(h_goc, h_va))
print("SHA-256 có giống nhau không?", hashlib.sha256(chuong_trinh).digest() == hashlib.sha256(bytes(ban_va)).digest())
h_dir = ppdeep.hash(Path("/usr/bin/dir").read_bytes())
print("/usr/bin/ls so với /usr/bin/dir (hai chương trình anh em, cùng bộ coreutils): điểm ssdeep =", ppdeep.compare(h_goc, h_dir))

Kết quả: /usr/bin/ls (142.312 byte) so với bản bị ghi đè 16 byte: ssdeep 99, SHA-256 khác nhau. ls so với dir (hai chương trình anh em cùng bộ coreutils, cùng kích thước): ssdeep 0.

📦 ban_va = bản sao của ls đã bị sửa SO_BYTE_SUA byte ở giữa.

👉 Hiểu: hash mờ bắt rất tốt bản sao bị sửa nhẹ (mã độc vá vài byte để né SHA-256), nhưng không bắt được hai chương trình "cùng họ" được biên dịch riêng. Muốn bắt "cùng họ" phải dùng đặc trưng khác (chuỗi ký tự, hàm import, entropy như ở Lab 5).


Phần 6 — Tổng kết

📍 Bước 7 — Quy tắc bàn tay phải.

In [ ]:
from pathlib import Path
THU_MUC_RA = Path.home() / "lab" / "out_osint"
THU_MUC_RA.mkdir(parents=True, exist_ok=True)
bao_cao = f'''BÁO CÁO THREAT HUNTING & OSINT (dữ liệu thật của máy chủ dạy học)
================================================================
Kiểm chứng chéo : IP thử {IP_THU}: 3 công cụ nói XẤU, gốc thật = {sorted(goc_ip)}
                  IP có ≥ 2 tên nguồn {len(nhieu_ten):,} | có ≥ 2 gốc độc lập {(c2.so_goc >= 2).sum():,}
                  kho mẫu: {len(kho_trong_c2)}/{len(TAP_KHO)} IP đã có sẵn trong danh sách đen
                  IP tấn công web bị chặn: {len(TAP_WEB)}, danh sách đen biết {len(biet)} ({100 * len(biet) / len(TAP_WEB):.1f}%)
Làm sạch        : {la_ngay.sum():,} hàng threat_type là ngày tháng
Honeypot        : {len(mau_thuan)} chỗ mâu thuẫn; bot chạy 'uname' ở {ty_le['uname']:.1f}% phiên
Đồ thị          : {G.number_of_nodes():,} nút, {G.number_of_edges():,} cạnh, {len(cac_tp)} thành phần, {len(cong_dong)} cộng đồng Louvain
Hash            : rỗng = {rong[:12]}…; sửa 1 ký tự → ssdeep {ppdeep.compare(ppdeep.hash(goc), ppdeep.hash(sua))}
'''
(THU_MUC_RA / "bao_cao_osint.txt").write_text(bao_cao, encoding="utf-8")
print(bao_cao)

Kết quả: báo cáo được ghi vào ~/lab/out_osint/bao_cao_osint.txt: IP thử có 3 câu "XẤU" nhưng 1 gốc; 1.103 IP nhiều tên nguồn nhưng 60 IP hai gốc; kho mẫu 19/19 đã có sẵn; danh sách đen biết 4,1% IP tấn công web; 30.998 hàng cột loại bị lệch; 4 chỗ lộ honeypot, bot chạy uname ở 33,4% phiên; đồ thị 4.251 nút, 17 thành phần, 18 cộng đồng; hash rỗng e3b0c442…; sửa 1 ký tự → ssdeep 96.

👉 Hiểu: không con số nào ở đây đến từ một mô hình "học". Tất cả là kiểm chứng bằng chứng — bước mà nếu làm ẩu, mọi mô hình AI phía sau đều học trên nhãn sai.

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

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 Cột này chứa đúng thứ nó nói không? cột threat_type chứa ngày tháng ở phần lớn hàng (1.3) báo cáo "loại mối đe doạ" toàn ngày tháng, hoặc tệ hơn: bỏ qua mà không biết
☝️ 2. Ngón trỏ ĐẶC TRƯNG đúng Dấu vết nào nói lên ai, dấu vết nào chỉ nói lên phần mềm gì? khoá SSH riêng ≈ nhóm tấn công; HASSH / favicon phổ biến chỉ nói thư viện / phần mềm (3.5, 4.4) gộp cả nghìn IP thành "một nhóm" chỉ vì dùng chung thư viện SSH
✋ 3. Ngón giữa CHỌN CÔNG CỤ Bảng, đồ thị hay hàm băm? Hash chính xác hay mờ? đồ thị để pivot (Phần 3); ssdeep để bắt "gần giống" (5.2) dùng SHA-256 để tìm biến thể → không tìm được gì
💍 4. Ngón áp út ĐO trung thực Bao nhiêu nguồn độc lập thật? Bot có thật sự kiểm tra không? 3 công cụ = 1–2 gốc (1.6); đo % phiên chạy uname thay vì đoán (2.3) báo cáo "3 nguồn xác nhận, độ tin cậy 100%"
🤙 5. Ngón út GIẢI THÍCH & CẢNH GIÁC "Không biết" có phải "sạch"? Nhãn là quan sát hay suy luận? phần lớn IP tấn công web không có trong danh sách đen (1.5); nhãn TA là suy luận (3.5) bỏ qua IP đang tấn công vì "công cụ nói không biết"

Câu hỏi thảo luận

  1. Phần 1.2: sửa bảng GOC, coi credential_intelligence là một gốc riêng. Số IP "≥ 2 gốc" thay đổi thế nào? Vì sao làm vậy lại là tự lừa mình?
  2. Phần 1.6: bạn chấm Admiralty cho "kho mẫu mã độc của mình" thế nào? Nó có nên được tính là nguồn thứ ba không?
  3. Phần 2: nếu bạn là kẻ viết bot, bạn kiểm tra 2 lệnh nào để biết máy là honeypot? Nếu bạn là người dựng honeypot, sửa 2 chỗ nào trước?
  4. Phần 3.4: đổi HAT_GIONG. Các cộng đồng lớn có giữ nguyên không? Một kết luận chỉ đúng với một hạt giống thì có nên báo cáo không?
  5. Phần 4: dịch vụ nào trong công ty bạn đang để favicon mặc định? Bạn có đổi không, và đổi để làm gì?