Có bạn hỏi: Claude Opus 4.7 vừa ra, GPT-5.4 cũng mạnh hơn rồi, Gemini 3.1 Pro thì 1 triệu token context, vậy sao chúng ta còn phải fine-tune LLaMA làm gì nữa cho mệt? đây là câu hỏi tôi đã phải đối diện không chỉ một lần. Và câu trả lời, thật ra, không phải là chuyện benchmark cao thấp. Nó là chuyện của một nhóm hẹp những người làm security có nhu cầu rất khác với số đông người dùng AI. Xem xét các lý do:

  • Khi một pentester có trong tay một phiên Burp Suite với bảy nghìn request vừa intercept được trong lúc đánh giá một web banking của khách, anh ta không thể nào đưa đống dữ liệu đó qua API của OpenAI hay Anthropic được. Hợp đồng NDA cấm. Luật bảo vệ dữ liệu cá nhân cấm. Và đôi khi, lương tâm nghề nghiệp cấm. Ngay cả khi API có chế độ zero-data-retention, thì việc payload thật, cookie thật, token thật của người dùng thật đi qua mạng ra một bên thứ ba vẫn là rủi ro không thể biện minh. Đó là lý do đầu tiên.
  • Lý do thứ hai là chuyện chuyên biệt hoá. Claude Opus 4.7 biết về SQL Injection ở mức độ của một cuốn sách giáo khoa, nó rất rộng, nhưng chung chung (có thể nó biết sâu cả về mặt lý thuyết). Nó có thể giải thích UNION-based SQLi cho sinh viên năm hai rất hay, nhưng khi tôi đưa cho nó một payload WAF bypass như 1%27%20UnIoN%2F%2A%21%2ASeL%2F%2A%2A%2FeCt, nó hiểu được đấy, nhưng nó không có cái trực giác của một người đã đọc hàng nghìn request loại này. Một model 3B được fine-tune chuyên biệt trên 5000 payload Burp intercept có thể sẽ nhận ra pattern nhanh hơn, và quan trọng là nó không bị safety filter chặn khi tôi yêu cầu sinh payload bypass mới.
  • Lý do thứ ba là tốc độ và chi phí. Tôi muốn tích hợp một classifier vào Burp extension, chạy trên mỗi request đi qua proxy với hàng trăm request mỗi giây trong một pentest session. Với Claude Opus 4.7, chỉ tính về độ trễ mạng thôi đã không khả thi chứ chưa nói đến giá 75 đô mỗi triệu token output. Một LLaMA 3.2 3B fine-tune chạy local trên một RTX 4090 cho ra kết quả dưới 200ms mỗi request, và chi phí duy nhất là tiền điện.

Fine-tune một model ngôn ngữ không phải là cuộc đua benchmark với các lab lớn. Nó là cách chúng ta mang kiến thức của một cộng đồng hẹp những người làm security vào trong trọng số của một model nhỏ, đủ nhỏ để chạy trong lab của mình.

1.Bức tranh tổng thể trước khi bắt đầu

Việc đầu tiên cần làm khi thực hiện finetuning model đó là chuẩn bị dữ liệu, đây là giai đoạn tốn nhiều công sức nhất (70 – 80%). Mặc dù bức tranh tổng thể fine-tune bao gồm 6 công đoạn. Nhưng tin tôi đi dữ liệu vẫn là cái công phu bậc nhất. 

1.png

Code hoàn toàn không khó, nhưng việc làm dữ liệu thực sự là cơn ác mộng, nó tốn công sức, tốn tiền của, và đòi hỏi người làm việc này không chỉ phải là người có đầu óc, mà còn cần sự tỉ mẩn. 

2.Vì sao vẫn là LLaMA, khi đã có Opus 4.7 và GPT-5.4

Đúng là các model mới nhất năm 2026 đã làm được những điều không tưởng. Claude Opus 4.7 có thể nhận một PDF báo cáo pentest 200 trang, đọc hết, tổng hợp, và viết lại theo format mà khách yêu cầu. GPT-5.4 có thể nhận yêu cầu "hãy kiểm tra endpoint này xem có SQL Injection không" rồi tự mở trình duyệt, mở Burp, thử payload, đọc response, và kết luận. Gemini 3.1 Pro đạt 94.3% GPQA Diamond đây là điểm reasoning cao chưa từng có. Nếu mà đứng từ góc độ làm được hay không?, câu trả lời là có, chúng làm được. Nhưng đứng từ góc độ có nên dùng cho production security pipeline không, câu trả lời phức tạp hơn. Có mấy ý như sau:

  • Đầu tiên, các model frontier này đều có safety training rất mạnh. Khi tôi yêu cầu Claude Opus 4.7 sinh một payload SQLi để bypass ModSecurity CRS paranoia level 2, nó sẽ hỏi lại tôi về ngữ cảnh, yêu cầu tôi xác nhận đây là môi trường lab, và đôi khi từ chối hoàn toàn nếu payload quá aggressive. Đó là hành vi đúng đắn cho một AI assistant đại chúng, nhưng với một researcher đang làm CTF hay một pentester đang kiểm thử target có hợp đồng rõ ràng, nó là rào cản. Một LLaMA fine-tune của riêng tôi, trong lab của tôi, không có rào cản đó và tôi phải chịu trách nhiệm đạo đức về việc dùng nó.
  • Thứ hai, mấy model lớn đều chạy trên cloud. Kể cả khi Anthropic có enterprise tier với zero-data-retention, tôi vẫn phải gửi payload ra ngoài . Trong một test room có policy nghiêm ngặt, gọi API không phải lúc nào cũng khả thi. Một LLaMA chạy local trên lab server không có vấn đề đó. Không có thứ gì đi ra ngoài cả. Tất cả ở lại với mạng nội bộ của doanh nghiệp.
  • Thứ ba, về chi phí ở quy mô pipeline. Nếu tôi muốn classify 100.000 HTTP request trong một đợt phân tích log, Claude Opus 4.7 với giá 15 đô input và 75 đô output mỗi triệu token sẽ tốn khoảng 30-50 đô cho một đợt. Làm 10 đợt một tháng là 500 đô. Một GPU Ampere tự host phân bổ chi phí theo năm thì rẻ hơn nhiều. Tất nhiên nếu tiền không phải vấn đề của bạn thì xem như chúng ta đã không thuộc về nhau ☹.
  • Thứ tư, và đây là điểm mà tôi nghĩ nhiều người bỏ qua, là chuyện kiểm soát. Khi tôi có model local, tôi biết chính xác nó được train trên cái gì, và tôi có thể audit được behavior của nó. Khi một Claude Opus 4.7 hay một GPT-5.4 trả lời câu hỏi của tôi, tôi không biết câu trả lời đó bị ảnh hưởng bởi những gì trong training data của OpenAI hay Anthropic. 

Dù vậy, tôi không tẩy chay các model lớn. Ngược lại, chúng đóng vai trò rất hay trong quá trình fine-tune: tôi dùng Claude Opus 4.7 để sinh dữ liệu training. Đây là kỹ thuật knowledge distillation qua data augmentation. Tôi để Opus sinh ra hàng nghìn biến thể payload với giải thích kèm theo, rồi dùng dataset đó train LLaMA local. Cuối cùng, LLaMA học được "cái biết" của Opus mà không phụ thuộc Opus khi chạy. Tôi sẽ quay lại chi tiết này ở phần tiếp theo.

3.Format dữ liệu đầu vào

Mọi dataset fine-tune cho LLM instruction-tuned đều có thể quy về một cấu trúc gồm ba trường: instruction, input, output.  Mỗi dòng trong một file JSON Lines là một object JSON độc lập. Đây là một sample mẫu cho task phân loại HTTP request:

{"instruction": "Bạn là chuyên gia phân tích web traffic. Phân loại HTTP request sau thành một trong các nhãn: SQLI, XSS, CMDI, BENIGN. Chỉ trả về duy nhất nhãn, không giải thích.", "input": "GET /product.php?id=1' UNION SELECT username,password FROM users-- HTTP/1.1\nHost: shop.local\nUser-Agent: Mozilla/5.0", "output": "SQLI"}

Trường instruction là vai trò của người thầy. Nó mô tả task một cách khái quát cho model hiểu nó đang phải làm gì. Nguyên tắc quan trọng nhất ở đây là nhất quán. Nếu trong 3000 sample tôi cứ thay đổi câu chữ liên tục, chỗ thì "Phân loại HTTP request", chỗ thì "Cho biết loại tấn công", chỗ thì "Xác định vulnerability". Điều này dẫn tới con model sẽ học phần noise ngôn ngữ thay vì học task thật. Tuy nhiên, nhất quán không có nghĩa là giống y đúc. Chúng ta có thể chuẩn bị khoảng ba đến năm biến thể paraphrase của cùng một instruction, rồi phân phối đều trong dataset. Làm vậy để model hiểu task ở mức semantic, chứ không overfit vào cấu trúc câu.

Trường input là nơi chứa dữ liệu thật như các payload thật, request thật, code thật. Đây là phần đa dạng nhất và cần được giữ càng giống production càng tốt. Tôi từng mắc sai lầm là làm sạch input quá mức, như bỏ User-Agent header đi cho gọn, hoặc normalize URL, hoặc escape toàn bộ ký tự đặc biệt. Kết quả là model tôi train rất giỏi trên dataset sạch, nhưng khi gặp request thật từ Burp với đủ loại header rác và encoding lộn xộn, nó miss rất nhiều. Bài học rút ra là: production messy thì dataset cũng phải messy.

Output là ground truth, đây là cái mà model phải học để sinh ra. Với task classification, tôi giữ nó ngắn và cứng nhắc: một từ, viết hoa, không giải thích. Với task generation, ví dụ như sinh payload thì output là chuỗi payload cụ thể, có format rõ ràng. Điểm quan trọng ở đây là output phải chính xác theo ngữ cảnh của input. Nếu input là một endpoint MySQL backend, output phải là payload MySQL syntax, không được lẫn PostgreSQL. Đừng coi nhẹ chuyện này vì model sẽ học đúng những gì bạn cho nó.

tại sao lại là JSONL, không phải CSV hay JSON thường?  có mấy lý do:

  • Thứ nhất, JSONL streaming-friendly: tôi có thể đọc từng dòng mà không cần load cả file vào RAM. Với dataset 100.000 sample, điều này quan trọng.
  • Thứ hai, JSON có cơ chế escape chuẩn cho các ký tự phức tạp. Payload SQLi có dấu nháy đơn, dấu nháy kép, backslash, newline,… Trong khi đó CSV sẽ dễ bị rối loạn.
  • Thứ ba, thư viện datasets của HuggingFace hỗ trợ JSONL native: một lệnh load_dataset("json", data_files="x.jsonl") là xong. (lói mọe từ đầu cho rồi =)) ).

4.Nguồn dữ liệu

Khi đã nắm format, câu hỏi tiếp theo là: lấy dữ liệu ở đâu? Đối với câu hỏi này thì thường có nhiều câu trả lời, tôi không sure về một câu trả lời chính xác. Theo kinh nghiệm thì có bốn nguồn cơ bản:

2.png

Nguồn A là Burp Suite Sessions

Đây là dữ liệu vàng vì nó đến trực tiếp từ workflow mà chúng ta đang test hàng ngày. Tất nhiên trong môi trường lab chúng tôi chỉ có dữ liệu từ các bài thực hành trên PortSwigger labs, hay làm CTF trên HackTheBox, SecAthon. Burp Suite ghi lại tất cả request qua proxy Mỗi session intercept kéo dài một buổi là vài nghìn request, đủ để làm cốt lõi của một dataset cho task classification.

Cách lấy dữ liệu ra khá đơn giản. Trong Burp, bạn chỉ cần vào Project menu, chọn Save project, ra một file XML. Hoặc đơn giản hơn, cài extension Logger++ để export HTTP history trực tiếp. File XML này có cấu trúc rõ ràng: mỗi item là một request-response pair, với request được encode base64. Tôi viết một script Python ngắn để parse, decode, và gán nhãn first-pass bằng regex, rồi review thủ công. Đây là đoạn code tôi dùng trong lab:

</> Python

import xml.etree.ElementTree as ET
import base64, json, re

def label_request(req_text: str) -> str:
    """First-pass labeler bằng regex. Đây chỉ là vòng đầu."""
    sqli = re.compile(
        r"(union\s+select|or\s+1\s*=\s*1|'--|/\*!|sleep\(|benchmark\()",
        re.IGNORECASE)
    xss = re.compile(
        r"(<script|javascript:|onerror\s*=|onload\s*=|<img[^>]*src)",
        re.IGNORECASE)
    cmdi = re.compile(
        r"(;\s*(cat|ls|id|whoami|nc|bash)|`[^`]+`|\$\([^)]+\))")

    if sqli.search(req_text): return "SQLI"
    if xss.search(req_text):  return "XSS"
    if cmdi.search(req_text): return "CMDI"
    return "BENIGN"

def burp_xml_to_jsonl(xml_path: str, out_path: str):
    tree = ET.parse(xml_path)
    instr = ("Bạn là chuyên gia phân tích web traffic. Phân loại "
             "HTTP request sau: SQLI, XSS, CMDI, BENIGN. Chỉ trả nhãn.")

    with open(out_path, "w", encoding="utf-8") as f:
        for item in tree.findall(".//item"):
            req_b64 = item.find("request").text
            req = base64.b64decode(req_b64).decode("utf-8", errors="ignore")
            label = label_request(req)
            sample = {"instruction": instr, "input": req.strip(),
                      "output": label}
           f.write(json.dumps(sample, ensure_ascii=False) + "\n")
burp_xml_to_jsonl("ctf_session.xml", "burp_samples.jsonl")

 

File này tất nhiên có thể lỗi nhé, các payload obfuscated có thể bị bỏ sót do đấy bạn phải tự sửa lại đoạn code phù hợp. Cố gắng review ít nhất 100-200 samples đầu tiên. Với dataset lớn hơn, bạn có thể dùng Label Studio để có UI tử tế. Nguyên tắc cũ của ngành ML vẫn đúng: garbage in, garbage out, không có lối thoát tắt.

Nguồn B : Các public Datasets

Đối với các công ty lớn, làm dịch vụ pentest thường xuyên thì đương nhiên bộ dữ liệu A đã rất ngon rồi, nhưng với những nhóm nghiên cứu nhỏ thì nó không thể đủ được. Bổ sung các public dataset là việc thường xuyên. Có một số nguồn sau: bộ SQLi-XSS dataset trên Kaggle có khoảng 35.000 record với nhãn đã có; bộ CSIC 2010 HTTP Dataset với 60.000 request bao gồm cả normal và attack; và repo PayloadsAllTheThings trên GitHub với hàng nghìn payload cho mọi loại tấn công. Với code-level vulnerability detection, ta có thể dùng BigVul và CVEfixes.

Chuyển từ CSV của Kaggle sang JSONL format có thể đơn giản chỉ cần chạy code :

</> python

import pandas as pd, json

df = pd.read_csv("sqli_xss_dataset.csv")
instr = ("Phân loại đoạn input sau là một trong: "
         "SQLI, XSS, CMDI, BENIGN. Chỉ trả về nhãn.")

with open("kaggle_samples.jsonl", "w", encoding="utf-8") as f:
    for _, row in df.iterrows():
        sample = {
            "instruction": instr,
            "input": str(row["Sentence"]).strip(),
            "output": str(row["Label"]).upper()
        }
       f.write(json.dumps(sample, ensure_ascii=False) + "\n")

Một lưu ý quan trọng về public dataset: đừng tin hoàn toàn vào nhãn. Kaggle datasets thường được tạo bởi amateur với quality control không đồng đều. Trước khi đưa vào training, ta luôn cần spot-check khoảng 100 sample ngẫu nhiên để ước lượng chất lượng nhãn. Nếu error rate quá 5%, rất có thể chúng ta sẽ cần re-label nguyên bộ dataset hoặc bỏ đi mà làm người .

Nguồn C là Synthetic data

Đây là phần ta nên quay lại với Claude Opus 4.7 và GPT-5.4. Khi dataset thiếu sample cho một lớp nào đó và ta cần thêm data để bù đắp. Ta có thể sử dụng 2 kỹ thuật để bù đắp:

  • Template mutation: hiểu đơn giản là viết một template có param rồi sinh hàng loạt data. Ví dụ với SQLi UNION-based:
</> python

import random, json, itertools

union_templates = [
    "{prefix}' UNION SELECT {cols}--",
    "{prefix}) UNION SELECT {cols}-- -",
    "{prefix}'/**/UNION/**/SELECT/**/{cols}--",
    "{prefix}' UnIoN SeLeCt {cols}#",
]
prefixes = ["1", "admin", "-1", "' OR 1=1"]
col_sets  = ["1,2,3", "NULL,NULL",
             "username,password FROM users",
             "table_name FROM information_schema.tables"]
endpoints = ["/product.php?id=", "/user?uid=", "/search.php?q="]

instr = "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN."

with open("synthetic_sqli.jsonl", "w") as f:
    for tpl, prefix, cols, ep in itertools.product(
           union_templates, prefixes, col_sets, endpoints):
        payload = tpl.format(prefix=prefix, cols=cols)
        req = f"GET {ep}{payload} HTTP/1.1\nHost: demo.local"
       f.write(json.dumps({
            "instruction": instr, "input": req, "output": "SQLI"
        }, ensure_ascii=False) + "\n")

Ưu điểm của template mutation là ta kiểm soát hoàn toàn tính đúng đắn của nhãn payload được sinh ra chắc chắn là SQLi theo định nghĩa của mình.

  • Dùng LLM tạo nhiều variants đa dạng:  Đây là chỗ Claude Opus 4.7 và GPT-5.4 thể hiện giá trị thực sự của chúng, trở thành thầy của LlaMA
</> python

import anthropic, json

client = anthropic.Anthropic()

prompt = """Bạn là chuyên gia AppSec. Cho payload SQLi gốc sau:
{seed}

Sinh ra 5 biến thể WAF bypass khác nhau, mỗi dòng 1 payload.
Yêu cầu biến thể đa dạng về:
- Encoding (URL, double URL, unicode)
- Case mixing (random upper/lower)
- Comment injection (/**/, /*!MYSQL*/)
- Whitespace alternative (tab, %09, %0a)
- Operator substitution (|| thay cho OR, etc.)

Chỉ trả payload, không giải thích, không numbering."""

seeds = ["' OR 1=1--", "admin'--", "1' UNION SELECT NULL--"]
instr = "Phân loại: SQLI, XSS, CMDI, BENIGN"

with open("opus_variants.jsonl", "w") as f:
    for seed in seeds:
        msg = client.messages.create(
            model="claude-opus-4-7",
           max_tokens=500,
           messages=[{"role": "user",
                       "content": prompt.format(seed=seed)}]
        )
        for line in msg.content[0].text.strip().split("\n"):
            if not line.strip(): continue
           f.write(json.dumps({
                "instruction": instr,
                "input": line.strip(),
                "output": "SQLI"
            }, ensure_ascii=False) + "\n")

Nguồn D là  Adversarial data

Đây là nguồn tác động nhiều vào tính robustness của model cuối. Có một paper năm 2025 (arXiv 2504.21045) đã chỉ ra rằng model chỉ train trên payload non-obfuscated rớt F1 rất mạnh khi gặp obfuscated payload trong production. Thủ phạm rất đơn giản: model không biết %27 là dạng URL-encoded của dấu nháy đơn. Giải pháp là đưa obfuscated samples vào dataset ngay từ đầu. Ví dụ :

</> python

import urllib.parse, random, json

def url_encode(s): return urllib.parse.quote(s)
def double_url_encode(s):
    return urllib.parse.quote(urllib.parse.quote(s))
def case_mix(s):
    return "".join(c.upper() if random.random() > 0.5
                   else c.lower() for c in s)
def comment_inject(s):
    return s.replace("UNION", "UN/**/ION")\
            .replace("SELECT", "SEL/**/ECT")
def unicode_sub(s):
    return s.replace("'", "\uFF07").replace("<", "\uFF1C")

obfuscators = [url_encode, double_url_encode, case_mix,
              comment_inject, unicode_sub]

clean_payloads = [
    ("' OR 1=1 UNION SELECT username,password FROM users--", "SQLI"),
    ("<script>alert(document.cookie)</script>", "XSS"),
    ("; cat /etc/passwd", "CMDI"),
]

with open("obfuscated.jsonl", "w") as f:
    for payload, label in clean_payloads:
        for obf in obfuscators:
            req = f"GET /vuln?q={obf(payload)} HTTP/1.1\nHost: target.local"
           f.write(json.dumps({
                "instruction": "Phân loại: SQLI, XSS, CMDI, BENIGN",
                "input": req,
                "output": label
            }, ensure_ascii=False) + "\n")

Một điểm quan trọng: khi sinh obfuscated sample, nhãn vẫn giữ nguyên SQLI chứ không phải OBFUSCATED_SQLI. Lý do là vì tôi muốn model học khả năng nhận diện bất kể payload có obfuscate hay không. Đây là hình thức data augmentation cổ điển: cùng một class, thêm biến thể ở mức transformation.

Sau khi có bốn file JSONL từ bốn nguồn, ta gộp chúng lại, loại trùng lặp, và chia thành train/val/test theo tỉ lệ 70/15/15:

</> python
import json, random, hashlib

files = ["burp_samples.jsonl", "kaggle_samples.jsonl",
         "synthetic_sqli.jsonl", "obfuscated.jsonl",
         "opus_variants.jsonl"]

seen, samples = set(), []
for fp in files:
    for line in open(fp, encoding="utf-8"):
        s = json.loads(line)
        h = hashlib.md5(s["input"].encode()).hexdigest()
        if h in seen: continue
        seen.add(h)
       samples.append(s)

random.seed(42)
random.shuffle(samples)

n = len(samples)
train, val, test = samples[:int(.70*n)], samples[int(.70*n):int(.85*n)], samples[int(.85*n):]

for name, data in [("train", train), ("val", val), ("test", test)]:
    with open(f"{name}.jsonl", "w", encoding="utf-8") as f:
        for s in data:
           f.write(json.dumps(s, ensure_ascii=False) + "\n")

print(f"Train: {len(train)}, Val: {len(val)}, Test: {len(test)}")

Dedupe bằng hash của trường input là bước cực kỳ quan trọng. Không thực hiện có thể dẫn tới hậu quả là một số sample giống nhau nằm cả trong train và test, dẫn đến test F1 cao giả tạo. Đây là data leakage cổ điển. Đừng để nó xảy ra.

5. Nhìn kỹ vào từng sample

Vậy dataset thực sự sẽ trông như thế nào? Khi ta đã hiểu format thì ta cần làm là triển khai nó. Ở đây tôi sẽ đi qua ba loại task phổ biến và phân tích từng sample

  • Loại 1: Classify request thành các nhãn attack: đây là task đơn giản nhất , với input là một HTTP request, output là một nhãn duy nhất. Ví dụ:
</> JSON
{"instruction": "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN.", "input": "GET /api/users?page=1&limit=20 HTTP/1.1\nHost: api.local\nAuthorization: Bearer eyJhbG...", "output": "BENIGN"}

{"instruction": "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN.", "input": "POST /login HTTP/1.1\nHost: app.local\nContent-Type: application/x-www-form-urlencoded\n\nuser=admin&pass=x' UNION SELECT 1,2,3--", "output": "SQLI"}

{"instruction": "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN.", "input": "GET /search?q=<img src=x onerror=alert(1)> HTTP/1.1\nHost: app.local", "output": "XSS"}

{"instruction": "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN.", "input": "GET /ping?host=8.8.8.8;cat /etc/passwd HTTP/1.1\nHost: app.local", "output": "CMDI"}

{"instruction": "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN.", "input": "GET /product?id=%27%20UNION%20SELECT%20NULL-- HTTP/1.1\nHost: app.local", "output": "SQLI"}

Trong ví dụ trên : Sample 1 là request hợp lệ, dùng để dạy model rằng token dài và phức tạp chưa chắc là tấn công, tránh false positive theo kiểu WAF ngây thơ. Sample 2 cho thấy SQLi có thể nằm trong POST body và đi kèm header chuẩn, nên model phải học cả ngữ cảnh protocol. Sample 3 nhấn mạnh XSS không chỉ xuất hiện qua script mà còn qua nhiều vector khác như img hoặc svg. Sample 4 là Command Injection, trong đó ý nghĩa của tham số và ngữ cảnh shell rất quan trọng. Sample 5 là SQLi đã được URL encoding toàn bộ, cho thấy nếu thiếu dữ liệu bị làm rối thì model rất dễ bỏ sót tấn công thực tế.

  • Loại 2: Generation payload theo ngữ cảnh: Task này khó hơn nhiều vì output dài và đa dạng. Cần phải cho LLM biết ngữ cảnh (target, WAF, DBMS) và yêu cầu sinh payload phù hợp.
</> JSON
{"instruction": "Sinh 3 payload SQL Injection để khai thác tham số 'id' kiểu integer trên MySQL backend. Target có WAF ModSecurity CRS paranoia 1. Trả 3 payload, mỗi payload 1 dòng.", "input": "Endpoint: GET /product.php?id={INJECT} HTTP/1.1\nDBMS: MySQL 5.7\nWAF: ModSecurity CRS v3 paranoia 1", "output": "1 AND (SELECT 1 FROM (SELECT SLEEP(5))a)\n1 UNION/**/SELECT/**/user(),database(),version()\n1 AND 1=(SELECT 1 FROM information_schema.tables LIMIT 1)"}

{"instruction": "Sinh 3 payload XSS reflected bypass filter strip thẻ script. Trả 3 payload, mỗi dòng 1 payload.", "input": "Context: HTML body\nFilter: /<script.*?>/gi được thay bằng chuỗi rỗng", "output": "<img src=x onerror=alert(document.domain)>\n<svg/onload=alert(1)>\n<body onload=alert(String.fromCharCode(88,83,83))>"}

Điểm hay của format này là trường input mô tả ngữ cảnh rõ ràng cụ thể như endpoint, DBMS, WAF, filter rule. Trường output là payload cụ thể phù hợp với ngữ cảnh đó. Dataset dạng này dạy model suy luận theo context chứ không chỉ copy payload. Một LLaMA sau khi fine-tune trên vài nghìn sample như vậy có thể sinh payload phù hợp khi tôi đưa cho nó một target mới chưa từng có trong training set.

  • Loại 3: Code vulnerability detection, ví dụ:
</> JSON
{"instruction": "Phân tích đoạn code PHP. Trả về JSON: {\"vulnerable\": true|false, \"cwe\": \"CWE-XX\" hoặc null, \"line\": số hoặc null}.", "input": "<?php\n$id = $_GET['id'];\n$sql = \"SELECT * FROM products WHERE id = $id\";\n$result = mysqli_query($conn, $sql);\n?>", "output": "{\"vulnerable\": true, \"cwe\": \"CWE-89\", \"line\": 3}"}

{"instruction": "Phân tích đoạn code PHP. Trả về JSON: {\"vulnerable\": true|false, \"cwe\": \"CWE-XX\" hoặc null, \"line\": số hoặc null}.", "input": "<?php\n$id = $_GET['id'];\n$stmt = $conn->prepare(\"SELECT * FROM products WHERE id = ?\");\n$stmt->bind_param('i', $id);\n$stmt->execute();\n?>", "output": "{\"vulnerable\": false, \"cwe\": null, \"line\": null}"}

Các samples này có thể dạy model cách so sánh. Cùng một logic nghiệp vụ là đọc id từ GET, query database. Nhưng sample đầu dùng string concatenation (vulnerable) và sample sau dùng prepared statement (safe). Khi dataset có đủ các cặp đối lập như vậy, model học được khái niệm safe coding pattern chứ không chỉ học pattern tấn công.

6. Setup và cài đặt

Setup môi trường có thể làm bằng conda

</> BASH
conda create -n llama_sec python=3.10 -y
conda activate llama_sec
# PyTorch với CUDA 12.1
pip install torch --index-url https://download.pytorch.org/whl/cu121
# HuggingFace stack
pip install -U transformers datasets accelerate peft trl bitsandbytes
# Unsloth - tăng tốc 2x, tiết kiệm 60% VRAM
pip install "unsloth[cu121-torch240] @ git+https://github.com/unslothai/unsloth.git"
# Tiện ích
pip install wandb scikit-learn

Về cấu trúc project:

</> TEXT
llama_websec/
├── data/
│   ├── raw/                   # file gốc từ Burp, Kaggle...
│   ├── processed/             # JSONL của từng nguồn
│   └── final/
│       ├── train.jsonl        # 70%
│       ├── val.jsonl          # 15%
│       └── test.jsonl         # 15%
├── scripts/
│   ├── burp_to_jsonl.py
│   ├── csv_to_jsonl.py
│   ├── template_mutation.py
│   ├── obfuscate.py
│   └── merge_split.py
├── train.py
├── inference.py
├── evaluate.py
└── outputs/
    └── llama32_websec_lora/

Trước khi training ta thực hiện classification như đã mô tả trước đó, rồi mới phân tích từng phần:

</> python
from unsloth import FastLanguageModel
import torch
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments, DataCollatorForSeq2Seq

# ════ 1. Load base model ở 4-bit ════
max_seq_length = 2048

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name     = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit",
    max_seq_length = max_seq_length,
    dtype          = None,
    load_in_4bit   = True,
)

# ════ 2. Gắn LoRA adapter ════
model = FastLanguageModel.get_peft_model(
    model,
    r = 16,
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj"],
    lora_alpha = 16,
    lora_dropout = 0,
    bias = "none",
   use_gradient_checkpointing = "unsloth",
    random_state = 42,
)

# ════ 3. Format prompt kiểu Alpaca ════
alpaca_prompt = """Bạn là chuyên gia phân tích web traffic.

### Instruction:
{}

### Input:
{}

### Response:
{}"""

EOS_TOKEN = tokenizer.eos_token

def formatting_fn(examples):
    texts = []
    for instr, inp, out in zip(
        examples["instruction"], examples["input"],
        examples["output"]):
       texts.append(alpaca_prompt.format(instr, inp, out) + EOS_TOKEN)
    return {"text": texts}

# ════ 4. Load dataset ════
train_ds = load_dataset("json",
    data_files="data/final/train.jsonl", split="train")
val_ds = load_dataset("json",
    data_files="data/final/val.jsonl", split="train")

train_ds = train_ds.map(formatting_fn, batched=True)
val_ds   = val_ds.map(formatting_fn, batched=True)

# ════ 5. Trainer ════
trainer = SFTTrainer(
    model = model,
    tokenizer = tokenizer,
    train_dataset = train_ds,
    eval_dataset = val_ds,
    dataset_text_field = "text",
    max_seq_length = max_seq_length,
    data_collator = DataCollatorForSeq2Seq(tokenizer=tokenizer),
    args = TrainingArguments(
       per_device_train_batch_size = 2,
       gradient_accumulation_steps = 4,
        warmup_steps = 10,
       num_train_epochs = 3,
        learning_rate = 2e-4,
        fp16 = not torch.cuda.is_bf16_supported(),
        bf16 = torch.cuda.is_bf16_supported(),
        logging_steps = 10,
        eval_strategy = "epoch",
        save_strategy = "epoch",
        optim = "adamw_8bit",
        weight_decay = 0.01,
       lr_scheduler_type = "linear",
        seed = 42,
        output_dir = "outputs/checkpoints",
        report_to = "none",
    ),
)

trainer_stats = trainer.train()

# ════ 6. Save adapter ════
model.save_pretrained("outputs/llama32_websec_lora")
tokenizer.save_pretrained("outputs/llama32_websec_lora")

Việc load model ở 4-bit thông qua QloRA làm cho Base model bị đóng băng, không thay đổi. Tùy vào điều kiện phần cứng bạn cũng có thể làm việc với các pretrained model to hơn.

Tham số r = 16 là rank của LoRA decomposition. Hiểu đơn giản, mỗi ma trận trọng số lớn trong model được xấp xỉ bằng tích của hai ma trận nhỏ có rank r. r càng cao, càng nhiều tham số được train, càng flexible nhưng cũng càng dễ overfit. Với task classification đơn giản như của tôi, 8 đến 16 là khoảng tối ưu. Với task generation phức tạp hơn như sinh payload, thường đc đẩy lên 32 hoặc 64.

Learning rate 2e-4 cao hơn một bậc so với full fine-tune (thường là 5e-5). Lý do là vì LoRA adapter nhỏ, cần lr lớn để học hiệu quả. Tôi đã thử đẩy lên 1e-3 vài lần và luôn gặp instability loss nhảy loạn, model không hội tụ.

Số epochs 3 phù hợp với dataset 2000-5000 sample. Nếu dataset nhỏ hơn (dưới 1000 sample), có thể tăng lên 5-7 epochs. Nếu lớn hơn (trên 20.000), ta giảm còn 1-2 epochs. Nhưng nguyên tắc vàng là luôn monitor validation loss vì nếu nó tăng trở lại sau khi đã giảm, đó là dấu hiệu overfit và nên dừng training sớm.

Sau khi training xong ta chỉ lưu phần LoRA adapter chứ không lưu lại toàn bộ mô hình gốc. Lúc nào cần dùng thì chỉ cần ghép mô hình gốc với adapter là được.

</> python
from unsloth import FastLanguageModel

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "outputs/llama32_websec_lora",
    max_seq_length = 2048,
    dtype = None,
    load_in_4bit = True,
)
FastLanguageModel.for_inference(model)

alpaca_prompt = """Bạn là chuyên gia phân tích web traffic.

### Instruction:
{}

### Input:
{}

### Response:
"""

def classify(http_req: str) -> str:
    prompt = alpaca_prompt.format(
        "Phân loại HTTP request. Trả về: SQLI, XSS, CMDI, BENIGN.",
        http_req)
    inputs = tokenizer([prompt], return_tensors="pt").to("cuda")
    outputs = model.generate(
        **inputs, max_new_tokens=10, use_cache=True,
        do_sample=False, temperature=0.0)
    decoded = tokenizer.batch_decode(outputs, skip_special_tokens=True)[0]
    response = decoded.split("### Response:")[-1].strip()
    return response.split()[0].upper().strip(".,!?")

# Test một số request
test_reqs = [
    "GET /api/users?page=1&limit=20 HTTP/1.1\nHost: api.local",
    "GET /product?id=1' OR 1=1-- HTTP/1.1\nHost: shop.local",
    "POST /comment HTTP/1.1\nHost: blog.local\n\ntext=<script>alert(1)</script>",
    "GET /check?ip=8.8.8.8;whoami HTTP/1.1\nHost: tools.local",
]
for req in test_reqs:
    print(f"[{classify(req):8s}] {req[:70]}...")

Output kỳ vọng sẽ trông như thế này:

</> TEXT
[BENIGN  ] GET /api/users?page=1&limit=20 HTTP/1.1...
[SQLI    ] GET /product?id=1' OR 1=1-- HTTP/1.1...
[XSS     ] POST /comment HTTP/1.1\nHost: blog.local...
[CMDI    ] GET /check?ip=8.8.8.8;whoami HTTP/1.1...

Nếu muốn deploy model làm một service local, FastAPI là lựa chọn đơn giản. Ta có thể wrap hàm classify trong một endpoint, chạy với uvicorn, và thế là Burp Suite có thể gọi nó qua extension. 

</> python
from fastapi import FastAPI
from pydantic import BaseModel
from unsloth import FastLanguageModel

app = FastAPI()
model, tokenizer = FastLanguageModel.from_pretrained(
    "outputs/llama32_websec_lora", load_in_4bit=True)
FastLanguageModel.for_inference(model)

class Req(BaseModel):
    http_request: str

@app.post("/classify")
def endpoint(body: Req):
    return {"label": classify(body.http_request)}
# Chạy: uvicorn api:app --host 127.0.0.1 --port 8080

Nếu muốn deploy portable hơn, có thể xuất sang định dạng GGUF để chạy với llama.cpp hoặc Ollama:

</> python
model.save_pretrained_gguf(
    "outputs/llama32_websec_gguf",
    tokenizer,
   quantization_method = "q4_k_m",
)
# Sau đó: ollama create mymodel -f Modelfile

 

Cuối cùng trước khi đem đi sử dụng, cần phải quan tâm tới evaluation và pitfall:

</> python
import json
from sklearn.metrics import classification_report, confusion_matrix
from inference import classify

y_true, y_pred = [], []
with open("data/final/test.jsonl") as f:
    for line in f:
        s = json.loads(line)
       y_true.append(s["output"])
       y_pred.append(classify(s["input"]))

print(classification_report(y_true, y_pred, digits=4))
labels = ["BENIGN", "SQLI", "XSS", "CMDI"]
print("\nConfusion Matrix:")
cm = confusion_matrix(y_true, y_pred, labels=labels)
for i, row in enumerate(cm):
    print(f"{labels[i]:8s}", row)

 

Hope this help 😊. Cuối cùng, fine tune LLaMA cho security không phải là cuộc đua để thắng các model frontier về benchmark. Giá trị thật của nó nằm ở chỗ dữ liệu nhạy cảm không phải rời khỏi mạng nội bộ, hành vi của model có thể kiểm soát và audit, chi phí đủ thấp để đưa vào pipeline chạy thường xuyên, và toàn bộ kết quả có thể tái lập trong lab hoặc môi trường nghiên cứu. Các model lớn vẫn rất hữu ích để học, khám phá và hỗ trợ sinh dữ liệu, nhưng khi cần một công cụ chuyên biệt, chạy local, phục vụ đúng workflow của pentester và researcher, thì một LLaMA nhỏ được fine tune cẩn thận vẫn là lựa chọn thực dụng, bền vững và có chủ quyền hơn.