Files
labs/Notebooks-CSAI/Lab_OSINT_Phishing_Kit_GPU.ipynb

25 KiB

Phishing kit bị sao chép hoặc dịch — nhận ra "cùng một bộ kit" qua cấu trúc, tài nguyên và ngữ nghĩa (GPU)

Cách làm: Shift + Enter để chạy từng ô, lần lượt từ trên xuống. Kernel phải là Python (lớp CSAI – GPU). Ô có dòng ✏️ SỬA là chỗ mời bạn đổi rồi chạy lại. Chạy xong nhớ chạy ô dọn dẹp cuối bài để trả bộ nhớ GPU cho cả lớp.

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

  1. Hiểu vì sao trang lừa đảo tiếng Việt và trang tiếng Anh có thể là cùng một bộ kit (Phần 0).
  2. So hai trang theo 3 lớp: cấu trúc HTML, tài nguyên dùng chung, ngữ nghĩa (Phần 1, 2, 3).
  3. Thấy mô hình embedding đa ngôn ngữ (LaBSE) chạy trên GPU so được nghĩa xuyên ngôn ngữ — và vì sao chỉ ngữ nghĩa thôi là không đủ (Phần 3, 4).
  4. Biết đặt ngưỡng từ dữ liệu, không lấy con số "> 90%" từ tài liệu (Phần 4).

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

  • Bài toán: cho hai trang web, hỏi "có phải cùng một bộ kit lừa đảo không?" → bài toán so độ giống (similarity), không phải phân loại.
  • Dữ liệu: ⚠️ (minh họa) — máy chủ dạy học không có bản chụp trang lừa đảo thật, nên bài tự viết vài trang HTML cho một thương hiệu bịa ra (NovaPay). Không dùng thương hiệu thật: quét công khai tên miền giả mạo một thương hiệu thật cũng là làm lộ thông tin.
  • Cách giải: 3 lớp kiểm tra, mỗi lớp một công cụ: difflib (cấu trúc), tập hợp + Jaccard (tài nguyên), LaBSE trên GPU + cosine (ngữ nghĩa).
  • Thước đo: điểm giống nhau 0–1 của từng lớp; ngưỡng chọn bằng cách nhìn cặp "cùng kit" và cặp "khác kit".
Bước pipeline Phần
1. Thu thập dữ liệu Phần 0 (trang minh hoạ)
2. Làm sạch dữ liệu 1.1 (tách thẻ HTML), 3.1 (tách chữ hiển thị)
3. Chọn cách giải ngay trên
4. Coding Phần 1, 2, 3
5. Lưu ý khi coding 3.3 (mọi trang đăng nhập đều "giống nghĩa")
6. Đánh giá Phần 4
7. Quy tắc bàn tay phải ✋ Phần 5

Từ khoá: phishing kit = bộ file dựng sẵn (HTML, JS, ảnh) mà kẻ lừa đảo mua / chép rồi dựng hàng loạt trang giả · DOM = cây thẻ HTML của trang · embedding = dãy số biểu diễn nghĩa của một câu; hai câu cùng nghĩa (kể cả khác ngôn ngữ) có dãy số gần nhau · cosine = độ giống hướng của hai dãy số, 1 = giống hệt.

Ngoài lớp học, công cụ có sẵn để làm việc này là urlscan.io (tab Similar, tìm theo file dùng chung giữa các lần quét). Chỉ tìm được trong những gì dịch vụ đó đã quét, không phải "toàn cầu".


Phần 0 — Thu thập dữ liệu: 5 trang minh hoạ

📍 Bước 1. Dữ liệu gì: 5 trang HTML viết tay (minh họa). Lấy ở đâu: viết ngay trong ô dưới. Có nhãn: có — ta biết trang nào cùng kit (đó là lý do dùng dữ liệu minh hoạ: để kiểm chứng phương pháp). Cần làm sạch: có — phải tách thẻ HTML và chữ hiển thị trước khi so.

Trang Là gì Cùng kit với A?
A kit gốc tiếng Anh, giả trang đăng nhập NovaPay —
B bản dịch tiếng Việt của A (giữ nguyên bố cục, file JS/ảnh) có
C cùng kit A, đổi thương hiệu thành OrbitBank có
D trang đăng nhập khác hẳn (bố cục khác, file khác), tiếng Việt không
E bài báo tiếng Việt, không phải trang đăng nhập không
In [ ]:
# (minh họa) — 5 trang tự viết cho thương hiệu bịa. ✏️ SỬA: thêm / đổi trang để thử
KIT = '''<html lang="{lang}"><head><title>{title}</title><link rel="stylesheet" href="/assets/np-style.css">
<script src="/assets/np-login.js"></script></head><body><div class="wrap"><img src="/assets/logo-np.png" alt="{brand}">
<h1>{h1}</h1><form action="/next.php" method="post"><label>{u}</label><input name="user">
<label>{p}</label><input name="pass" type="password"><button>{b}</button></form>
<p class="note">{note}</p></div><img src="https://{real}/img/footer.png"></body></html>'''
TRANG = {
 "A": KIT.format(lang="en", title="NovaPay - Sign in", brand="NovaPay", h1="Sign in to your NovaPay account",
                 u="Email or phone", p="Password", b="Sign in", note="Your account has been limited. Verify within 24 hours to avoid suspension.",
                 real="novapay.example"),
 "B": KIT.format(lang="vi", title="NovaPay - Đăng nhập", brand="NovaPay", h1="Đăng nhập vào tài khoản NovaPay của bạn",
                 u="Email hoặc số điện thoại", p="Mật khẩu", b="Đăng nhập", note="Tài khoản của bạn đã bị hạn chế. Hãy xác minh trong 24 giờ để tránh bị khoá.",
                 real="novapay.example"),
 "C": KIT.format(lang="en", title="OrbitBank - Sign in", brand="OrbitBank", h1="Sign in to OrbitBank online banking",
                 u="Customer ID", p="Password", b="Continue", note="Unusual activity detected. Confirm your identity today to keep access.",
                 real="orbitbank.example"),
 "D": '''<html lang="vi"><head><title>Cổng dịch vụ - Đăng nhập</title><link rel="stylesheet" href="/static/main.8f2a.css"></head>
<body><header><nav><a href="/">Trang chủ</a><a href="/help">Trợ giúp</a></nav></header><main><section><h2>Đăng nhập</h2>
<div class="field"><span>Tên đăng nhập</span><input id="u"></div><div class="field"><span>Mật khẩu</span><input id="p" type="password"></div>
<a class="btn" href="#" onclick="go()">Tiếp tục</a></section></main><footer>© Cổng dịch vụ</footer><script src="/static/app.31c9.js"></script></body></html>''',
 "E": '''<html lang="vi"><head><title>Tin tức - Thời tiết cuối tuần</title><link rel="stylesheet" href="/css/news.css"></head><body>
<article><h1>Miền Bắc đón không khí lạnh cuối tuần</h1><p>Nhiệt độ ban đêm có thể xuống dưới 18 độ ở vùng núi.</p>
<p>Người dân nên theo dõi bản tin dự báo trước khi đi xa.</p></article><script src="/js/ads.js"></script></body></html>''',
}
CUNG_KIT = {("A", "B"), ("A", "C"), ("B", "C")}      # đáp án đã biết (vì trang do mình viết)
for ten, html in TRANG.items():
    print(f"📦 TRANG['{ten}'] = {len(html)} ký tự, mở đầu: {html[:70]!r}")

Kết quả: 5 trang, dài 358–632 ký tự. A, B, C mở đầu giống nhau (<html lang=... ><head><title>... - Sign in / Đăng nhập), D và E khác hẳn.

📦 TRANG = từ điển tên trang → mã HTML. · 📦 CUNG_KIT = 3 cặp đã biết là cùng kit: A-B, A-C, B-C.

👉 Hiểu: đây là dữ liệu (minh họa), có đáp án sẵn để kiểm chứng phương pháp trước khi dùng trên trang thật.


Phần 1 — Lớp 1: cấu trúc (DOM)

📍 Bước 2 → 4. Bỏ hết chữ, chỉ giữ dãy tên thẻ (html head title link script … form input button …). Bản dịch đổi chữ nhưng giữ khung → dãy thẻ gần như giống hệt. So hai dãy bằng difflib.SequenceMatcher (tỉ lệ phần trùng, 0–1).

1.1 Tách dãy thẻ

In [ ]:
from html.parser import HTMLParser
import difflib, itertools

class LayThe(HTMLParser):                    # HTMLParser của thư viện chuẩn: gọi handle_starttag mỗi khi gặp thẻ mở
    def __init__(self):
        super().__init__(); self.the = []; self.tai_nguyen = set(); self.chu = []
    def handle_starttag(self, tag, attrs):
        self.the.append(tag)
        for k, v in attrs:                   # attrs = danh sách (tên thuộc tính, giá trị)
            if k in ("src", "href") and v and not v.startswith("#"):
                self.tai_nguyen.add(v)
    def handle_data(self, data):
        if data.strip():
            self.chu.append(data.strip())

def phan_tich(html):
    p = LayThe(); p.feed(html); return p

PT = {ten: phan_tich(h) for ten, h in TRANG.items()}
print("📦 PT['A'].the =", " ".join(PT["A"].the))
print("📦 PT['B'].the =", " ".join(PT["B"].the))
print("📦 PT['D'].the =", " ".join(PT["D"].the))

Kết quả: dãy thẻ của A và B giống hệt nhau (html head title link script body div img h1 form label input label input button p img). Dãy của D khác hẳn: có header nav main section footer, không có form.

📦 PT[ten].the = dãy tên thẻ; PT[ten].tai_nguyen = tập file src/href; PT[ten].chu = các đoạn chữ hiển thị.

👉 Hiểu: dịch sang tiếng Việt chỉ đổi chữ, không đổi khung. Bỏ chữ đi thì bản dịch lộ nguyên hình.

1.2 Điểm giống cấu trúc của mọi cặp

In [ ]:
CAP = list(itertools.combinations(TRANG, 2))          # mọi cặp (A,B), (A,C)... không lặp
cau_truc = {(x, y): difflib.SequenceMatcher(None, PT[x].the, PT[y].the).ratio() for x, y in CAP}
for (x, y), v in sorted(cau_truc.items(), key=lambda kv: -kv[1]):
    print(f"  {x}-{y}: {v:.2f}  {'← cùng kit' if (x, y) in CUNG_KIT else ''}")

Kết quả: 3 cặp cùng kit (A-B, A-C, B-C) đều 1,00. Mọi cặp khác chỉ 0,26–0,39.

👉 Hiểu: với kit chép nguyên khung, lớp cấu trúc tách rất rõ. Trên trang thật, kẻ lừa đảo hay sửa nhẹ (thêm một thẻ, đổi thứ tự) nên điểm sẽ thấp hơn 1 — vì vậy dùng tỉ lệ giống, không dùng hash chính xác.


Phần 2 — Lớp 2: tài nguyên dùng chung

Kit chép lại thường giữ nguyên tên file JS / CSS / ảnh, và hay gọi ảnh từ trang thật (để trông giống). Đo bằng Jaccard = số file chung / tổng số file khác nhau của hai trang.

In [ ]:
def jaccard(a, b):
    return len(a & b) / len(a | b) if a | b else 0.0      # & = phần chung, | = hợp
tai_nguyen = {(x, y): jaccard(PT[x].tai_nguyen, PT[y].tai_nguyen) for x, y in CAP}
print("📦 PT['A'].tai_nguyen =", sorted(PT["A"].tai_nguyen))
print("📦 PT['C'].tai_nguyen =", sorted(PT["C"].tai_nguyen))
for (x, y), v in sorted(tai_nguyen.items(), key=lambda kv: -kv[1]):
    print(f"  {x}-{y}: {v:.2f}  {'← cùng kit' if (x, y) in CUNG_KIT else ''}")

Kết quả: A-B 1,00 (chung cả 4 file, kể cả ảnh gọi từ novapay.example). A-C và B-C 0,60 (chung 3 file /assets/…, khác ảnh gọi từ trang thật vì đổi thương hiệu). Mọi cặp khác 0,00.

📦 tai_nguyen = điểm Jaccard của mọi cặp.

👉 Hiểu: tên file np-login.js, logo-np.png là "chữ ký" của bộ kit: đổi thương hiệu thì quên đổi tên file. Ảnh gọi thẳng từ trang thật còn cho biết thương hiệu bị giả mạo là ai.


Phần 3 — Lớp 3: ngữ nghĩa xuyên ngôn ngữ (LaBSE trên GPU)

LaBSE (Language-agnostic BERT Sentence Embedding) biến một câu thành 768 số; câu cùng nghĩa ở ngôn ngữ khác nhau cho ra dãy số gần nhau. ⚠️ Đính chính hay gặp: BERT / LaBSE không phải mô hình dịch. Nó không dịch câu, nó đặt câu vào một "không gian nghĩa" chung.

3.1 Tải mô hình (từ bộ nhớ đệm offline của lớp) và đo GPU vs CPU

In [ ]:
import os, time, warnings, torch
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"                 # tắt thanh tiến trình khi nạp mô hình
warnings.filterwarnings("ignore", message="IProgress not found")  # cảnh báo vô hại của tqdm trong Jupyter
import transformers; transformers.utils.logging.disable_progress_bar(); transformers.utils.logging.set_verbosity_error()
from sentence_transformers import SentenceTransformer
print("GPU:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "không có")
t = time.perf_counter()
mo_hinh = SentenceTransformer("sentence-transformers/LaBSE", device="cuda")   # đọc từ /opt/csai/models (không tải mạng)
print(f"📦 mo_hinh = LaBSE, {mo_hinh.encode("x").shape[0]} chiều, nạp trong {time.perf_counter() - t:.1f} s")

chu = {ten: " ".join(PT[ten].chu) for ten in TRANG}          # chữ hiển thị của mỗi trang (đã bỏ thẻ)
print("📦 chu['B'] =", chu["B"][:120], "…")

cau_thu = list(chu.values()) * 200                            # ✏️ SỬA: số lần lặp để đo tốc độ
for thiet_bi in ("cuda", "cpu"):
    mo_hinh.to(thiet_bi); t = time.perf_counter()
    mo_hinh.encode(cau_thu, batch_size=64)
    print(f"  mã hoá {len(cau_thu)} đoạn trên {thiet_bi:4s}: {time.perf_counter() - t:5.1f} s")
mo_hinh = mo_hinh.to("cuda")                                   # .to() trả lại chính mô hình; gán lại để Jupyter không in cả mô hình

Kết quả: LaBSE (768 chiều) nạp trong khoảng 2 giây từ bộ nhớ đệm của lớp. Mã hoá 1.000 đoạn chữ: GPU khoảng 2 giây, CPU khoảng 45 giây (đo nhiều lần: 1,7–2,2 s và 44,8–46,7 s) → GPU nhanh hơn khoảng 20 lần.

📦 mo_hinh = mô hình LaBSE trên GPU. · 📦 chu[ten] = chữ hiển thị của trang (đã bỏ thẻ).

👉 Hiểu: so vài trang thì CPU cũng được; so hàng nghìn trang nghi vấn mỗi ngày thì GPU mới làm kịp.

3.2 Cosine giữa các trang

In [ ]:
from sentence_transformers.util import cos_sim
vec = {ten: mo_hinh.encode(c, convert_to_tensor=True) for ten, c in chu.items()}   # 📦 vec[ten] = tensor 768 số
ngu_nghia = {(x, y): float(cos_sim(vec[x], vec[y])) for x, y in CAP}
for (x, y), v in sorted(ngu_nghia.items(), key=lambda kv: -kv[1]):
    print(f"  {x}-{y}: {v:.2f}  {'← cùng kit' if (x, y) in CUNG_KIT else ''}")

Kết quả: A-B (bản dịch) 0,94 — cao nhất. Nhưng A-C và B-C (cùng kit, đổi thương hiệu) chỉ 0,55 và 0,50, thấp hơn B-D (0,52, trang đăng nhập khác kit) và gần bằng A-D (0,49).

📦 vec[ten] = 768 số biểu diễn nghĩa của trang. · 📦 ngu_nghia = cosine của mọi cặp.

👉 Hiểu: LaBSE nhận ra bản dịch rất tốt (tiếng Anh ↔ tiếng Việt 0,94). Nhưng nó đo nghĩa của chữ, không đo kit: đổi thương hiệu và câu dụ dỗ thì điểm tụt ngang một trang đăng nhập bất kỳ.

3.3 ⚠️ Lưu ý: mọi trang đăng nhập đều "giống nghĩa"

📍 Bước 5. Nhìn cặp A-D: D là trang đăng nhập khác kit, nhưng nghĩa thì cũng "đăng nhập, mật khẩu, tiếp tục". So từng câu để thấy rõ:

In [ ]:
cau = ["Sign in to your NovaPay account", "Đăng nhập vào tài khoản NovaPay của bạn", "Đăng nhập", "Miền Bắc đón không khí lạnh cuối tuần"]
v = mo_hinh.encode(cau, convert_to_tensor=True)
ma_tran = cos_sim(v, v).cpu().numpy()                       # ma trận cosine mọi cặp câu
import pandas as pd
print(pd.DataFrame(ma_tran.round(2), index=[c[:28] for c in cau], columns=["c1", "c2", "c3", "c4"]).to_string())

Kết quả: "Sign in to your NovaPay account" ↔ "Đăng nhập vào tài khoản NovaPay của bạn" = 0,96. Cả hai ↔ "Đăng nhập" = 0,47–0,51. Câu về thời tiết ↔ mọi câu khác = 0,17–0,27.

👉 Hiểu: đúng nghĩa xuyên ngôn ngữ. Nhưng vì mọi trang đăng nhập đều nói "đăng nhập, mật khẩu, tiếp tục", ngữ nghĩa một mình chỉ nói được "đây là trang đăng nhập", không nói được "cùng một bộ kit".


Phần 4 — Đánh giá: ghép 3 lớp, đặt ngưỡng từ dữ liệu

📍 Bước 6. Bảng dưới đặt 3 điểm cạnh nhau. Cách đọc: mỗi hàng một cặp trang; cột cuối là đáp án đã biết.

Cách đọc biểu đồ
Mỗi nhóm cột một cặp trang
3 màu 3 lớp: cấu trúc, tài nguyên, ngữ nghĩa
Viền đen đậm cặp cùng kit
In [ ]:
%matplotlib inline
import matplotlib.pyplot as plt, numpy as np
bang = pd.DataFrame({"cấu trúc": cau_truc, "tài nguyên": tai_nguyen, "ngữ nghĩa": ngu_nghia})
bang.index = [f"{x}-{y}" for x, y in bang.index]
bang["cùng kit?"] = ["có" if tuple(i.split("-")) in CUNG_KIT else "không" for i in bang.index]
bang = bang.sort_values("cấu trúc", ascending=False)
print(bang.round(2).to_string())

fig, ax = plt.subplots(figsize=(10, 3.6)); x = np.arange(len(bang)); w = 0.27
for k, (cot, mau) in enumerate([("cấu trúc", "#4c78a8"), ("tài nguyên", "#f58518"), ("ngữ nghĩa", "#54a24b")]):
    ax.bar(x + (k - 1) * w, bang[cot], w, color=mau, label=cot,
           edgecolor=["black" if c == "có" else "none" for c in bang["cùng kit?"]], linewidth=1.5)
ax.set_xticks(x); ax.set_xticklabels(bang.index); ax.set_ylim(0, 1.05); ax.legend(ncol=3)
ax.set_title("Ba lớp so sánh, mọi cặp trang (viền đen = cùng kit)"); plt.tight_layout(); plt.show()

# ✏️ SỬA: luật ghép — cùng kit nếu cấu trúc ≥ NGUONG_CT VÀ (tài nguyên ≥ NGUONG_TN HOẶC ngữ nghĩa ≥ NGUONG_NN)
NGUONG_CT, NGUONG_TN, NGUONG_NN = 0.8, 0.5, 0.8
bang["đoán"] = np.where((bang["cấu trúc"] >= NGUONG_CT) & ((bang["tài nguyên"] >= NGUONG_TN) | (bang["ngữ nghĩa"] >= NGUONG_NN)), "có", "không")
print("\nĐoán đúng:", (bang["đoán"] == bang["cùng kit?"]).sum(), "/", len(bang), "cặp")

Kết quả: bảng 10 cặp. Ba cặp cùng kit: cấu trúc 1,00, tài nguyên 0,6–1,0, ngữ nghĩa 0,50–0,94. Bảy cặp khác kit: cấu trúc ≤ 0,39, tài nguyên 0, ngữ nghĩa lên tới 0,52. Luật ghép (cấu trúc ≥ 0,8 và (tài nguyên ≥ 0,5 hoặc ngữ nghĩa ≥ 0,8)) đoán đúng 10 / 10 cặp.

📦 bang = điểm 3 lớp + đáp án + đoán của luật.

👉 Hiểu: nếu chỉ dùng ngữ nghĩa với ngưỡng "> 90%" như tài liệu gốc gợi ý, ta bắt được 1 / 3 cặp cùng kit (chỉ bản dịch). Ngưỡng phải chọn từ dữ liệu có đáp án, và lớp khó đổi nhất (cấu trúc, tài nguyên) phải đứng trước. 10/10 ở đây là trên 5 trang tự viết — trên trang thật sẽ thấp hơn.


Phần 5 — Tổng kết và dọn dẹp

📍 Bước 7.

Ngón Điều Trong bài này
👍 Dữ liệu sạch so cái gì: thẻ, file, hay chữ hiển thị? tách 3 thứ riêng (1.1, 3.1)
☝️ Đặc trưng đúng lớp nào khó đổi với kẻ lừa đảo? dịch chữ thì dễ, dựng lại khung + file thì tốn công → cấu trúc + tài nguyên là dấu vết bền
✋ Chọn công cụ cần GPU khi nào? LaBSE trên GPU nhanh hơn CPU nhiều lần (3.1) khi phải so hàng nghìn trang
💍 Đo trung thực ngưỡng lấy từ đâu? từ cặp đã biết đáp án (Phần 4), không từ "> 90%" trong tài liệu
🤙 Cảnh giác ngữ nghĩa một mình nói được gì? chỉ nói "cùng là trang đăng nhập" (3.3) — không nói "cùng kit"

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

  1. Kẻ lừa đảo muốn né lớp 1 (cấu trúc) thì phải làm gì? Tốn công hơn hay ít hơn né lớp 3?
  2. Nếu bạn có 10.000 trang nghi vấn thật, bạn sẽ chạy lớp nào trước để lọc nhanh, lớp nào sau?
  3. Vì sao bài này không dùng thương hiệu thật, và vì sao không quét công khai tên miền giả mạo thương hiệu thật trên urlscan?
In [ ]:
# DỌN DẸP — trả bộ nhớ GPU cho cả lớp
del mo_hinh, vec, v
import gc; gc.collect(); torch.cuda.empty_cache()
print("Đã trả GPU. Bộ nhớ GPU notebook này còn giữ:", torch.cuda.memory_allocated() // 2**20, "MiB")

Kết quả: sau khi dọn, notebook còn giữ khoảng 8 MiB bộ nhớ GPU (từ khoảng 1,8 GB khi có mô hình).

👉 Hiểu: GPU dùng chung cả lớp — luôn chạy ô này khi xong bài.