Spaces:
Running
Running
| # ================================================================ | |
| # BugHunterEnv β GRPO Training Script | |
| # Trains a small LLM to find web vulnerabilities via RL | |
| # | |
| # Compatible with Google Colab (T4 GPU, ~15GB VRAM) | |
| # | |
| # Setup (run once in Colab): | |
| # !pip install -q "unsloth[colab-new]" trl>=0.16 datasets | |
| # !pip install -q flask werkzeug requests openenv-core | |
| # !git clone <your-hf-space-repo> && cd BugHuntEnvironment | |
| # ================================================================ | |
| import os, sys, json, re, time, threading | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import torch | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| from datasets import Dataset | |
| from unsloth import FastLanguageModel | |
| from trl import GRPOTrainer, GRPOConfig | |
| from server.bug_hunter_env_environment import BugHunterEnvEnvironment | |
| # ββ Hyperparameters βββββββββββββββββββββββββββββββββββββββββββ | |
| # Gemma 4 variants (pick based on available VRAM): | |
| # unsloth/gemma-4-4b-it ~4GB in 4-bit β T4 (15GB), comfortable | |
| # unsloth/gemma-4-12b-it ~8GB in 4-bit β T4 tight, A100 preferred | |
| # unsloth/gemma-4-27b-it ~18GB in 4-bit β A100 40GB+ | |
| MODEL_NAME = "unsloth/gemma-4-4b-it" | |
| MAX_SEQ_LEN = 768 | |
| LORA_RANK = 16 | |
| NUM_GENERATIONS = 4 # completions per prompt for GRPO | |
| TRAINING_STEPS = 80 # ~25 min on T4 | |
| BATCH_SIZE = 2 # prompts per gradient step | |
| EVAL_EPISODES = 3 # episodes per task for before/after eval | |
| OUTPUT_DIR = "./grpo_bughunter" | |
| # ββ System prompt βββββββββββββββββββββββββββββββββββββββββββββ | |
| SYSTEM_PROMPT = """\ | |
| You are a penetration tester auditing a corporate API for security vulnerabilities. | |
| At each step you send exactly ONE HTTP request. Format: | |
| METHOD PATH [JSON_BODY] | |
| Examples: | |
| GET /api/users | |
| GET /api/search?q=' UNION/**/SELECT 1,username,password,role FROM users-- | |
| POST /api/login {"username": "admin'--", "password": "x"} | |
| GET /api/files?name=..%252fetc%252fpasswd | |
| Rules: | |
| - METHOD is GET or POST | |
| - PATH starts with / | |
| - BODY is JSON only for POST | |
| - No explanation β output only the request line\ | |
| """ | |
| # ββ Seeded training states ββββββββββββββββββββββββββββββββββββ | |
| # Each state represents a specific point in an attack chain. | |
| # pre_actions seeds the environment to that point, then the model's | |
| # action is evaluated and rewarded. This gives dense learning signal | |
| # at each decision point rather than only at episode end. | |
| SEEDED_STATES = [ | |
| # ββ IDOR ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { | |
| "task": "idor", | |
| "pre_actions": [], | |
| "obs": "HTTP 200\nTask: Retrieve admin HR data (SSN + salary).\nEndpoints: GET /api/users GET /api/users/<id> POST /api/login", | |
| "hint": "Start by listing users at /api/users to discover IDs.", | |
| }, | |
| { | |
| "task": "idor", | |
| "pre_actions": [("GET", "/api/users", None)], | |
| "obs": 'HTTP 200\n[{"id":1,"username":"admin","role":"admin"},{"id":2,"username":"alice","role":"user"},{"id":3,"username":"bob","role":"user"}]', | |
| "hint": "Users listed. Probe /api/users/<id> without auth to check for IDOR β try id=1 (admin).", | |
| }, | |
| { | |
| "task": "idor", | |
| "pre_actions": [("GET", "/api/users", None), ("GET", "/api/users/2", None)], | |
| "obs": 'HTTP 200\n{"id":2,"username":"alice","ssn":"234-56-7890","salary":75000}', | |
| "hint": "IDOR confirmed β sensitive data returned without auth. Now target the admin (id=1).", | |
| }, | |
| # ββ SQL Injection ββββββββββββββββββββββββββββββββββββββββββ | |
| { | |
| "task": "sqli", | |
| "pre_actions": [], | |
| "obs": "HTTP 200\nTask: Extract admin credentials via SQL injection.\nEndpoints: GET /api/search?q=<query> POST /api/login", | |
| "hint": "Probe /api/search with a single quote to test for SQL injection errors.", | |
| }, | |
| { | |
| "task": "sqli", | |
| "pre_actions": [("GET", "/api/search?q='", None)], | |
| "obs": 'HTTP 500\n{"error":"OperationalError: unrecognized token: \\\"\'\\\""}', | |
| "hint": "SQL error β endpoint is injectable. Try UNION extraction. WAF blocks 'UNION SELECT', use UNION/**/SELECT bypass.", | |
| }, | |
| { | |
| "task": "sqli", | |
| "pre_actions": [ | |
| ("GET", "/api/search?q='", None), | |
| ("GET", "/api/search?q=test UNION SELECT 1,2,3--", None), | |
| ], | |
| "obs": 'HTTP 403\n{"error":"Request blocked by security filter"}', | |
| "hint": "WAF blocks 'UNION SELECT'. Use inline comment bypass: GET /api/search?q=' UNION/**/SELECT/**/1,username,password,role/**/FROM/**/users--", | |
| }, | |
| { | |
| "task": "sqli", | |
| "pre_actions": [ | |
| ("GET", "/api/search?q='", None), | |
| ("GET", "/api/search?q=test UNION SELECT 1,2,3--", None), | |
| ("GET", "/api/search?q=' UNION/**/SELECT 1,2,3--", None), | |
| ], | |
| "obs": 'HTTP 200\n{"results":[],"count":0}', | |
| "hint": "WAF bypassed! Column count is 4. Extract user data: GET /api/search?q=' UNION/**/SELECT 1,username,password,role FROM users--", | |
| }, | |
| # ββ Path Traversal βββββββββββββββββββββββββββββββββββββββββ | |
| { | |
| "task": "path_traversal", | |
| "pre_actions": [], | |
| "obs": "HTTP 200\nTask: Read a sensitive server file. /api/files requires admin β escalate via SQLi on /api/login first.", | |
| "hint": "Authenticate first. POST /api/login is vulnerable to SQLi: {\"username\": \"admin'--\", \"password\": \"x\"}", | |
| }, | |
| { | |
| "task": "path_traversal", | |
| "pre_actions": [ | |
| ("POST", "/api/login", '{"username":"alice","password":"alice123"}'), | |
| ], | |
| "obs": 'HTTP 200\n{"status":"success","username":"alice","role":"user"}', | |
| "hint": "Logged in as user. Check /api/files β admin access is required. Escalate via SQLi: POST /api/login {\"username\": \"admin'--\", \"password\": \"x\"}", | |
| }, | |
| { | |
| "task": "path_traversal", | |
| "pre_actions": [ | |
| ("POST", "/api/login", '{"username":"alice","password":"alice123"}'), | |
| ("GET", "/api/files", None), | |
| ("POST", "/api/login", '{"username":"admin\'--","password":"x"}'), | |
| ], | |
| "obs": 'HTTP 200\n{"status":"success","username":"admin","role":"admin"}', | |
| "hint": "Admin session active. List files at /api/files, then attempt path traversal.", | |
| }, | |
| { | |
| "task": "path_traversal", | |
| "pre_actions": [ | |
| ("POST", "/api/login", '{"username":"alice","password":"alice123"}'), | |
| ("GET", "/api/files", None), | |
| ("POST", "/api/login", '{"username":"admin\'--","password":"x"}'), | |
| ("GET", "/api/files", None), | |
| ("GET", "/api/files?name=../etc/passwd", None), | |
| ], | |
| "obs": 'HTTP 403\n{"error":"Invalid path: directory traversal not allowed"}', | |
| "hint": "Literal '../' blocked. Flask auto-decodes %2f so single-encoding fails too. Use double-encoding: /api/files?name=..%252fetc%252fpasswd", | |
| }, | |
| ] | |
| # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class _Action: | |
| def __init__(self, method, path, body): | |
| self.method = method | |
| self.path = path | |
| self.body = body | |
| def parse_action(text: str): | |
| for line in text.strip().splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| parts = line.split(None, 1) | |
| if len(parts) < 2: | |
| continue | |
| m = parts[0].upper() | |
| if m not in ("GET", "POST"): | |
| continue | |
| rest = parts[1].strip() | |
| if m == "GET": | |
| p, b = rest, None | |
| else: | |
| sub = rest.split(None, 1) | |
| p = sub[0] | |
| b = sub[1] if len(sub) > 1 else None | |
| if p.startswith("/"): | |
| return _Action(m, p, b) | |
| return None | |
| def build_messages(state: dict) -> list: | |
| user_content = f"{state['obs']}\nHint: {state['hint']}\n\nWhat is your next request?" | |
| return [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_content}, | |
| ] | |
| # ββ Reward function βββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_reward(state_idx: int, completion: str) -> float: | |
| state = SEEDED_STATES[state_idx] | |
| env = BugHunterEnvEnvironment() | |
| try: | |
| env.reset(task_id=state["task"]) | |
| for m, p, b in state["pre_actions"]: | |
| env.step(_Action(m, p, b)) | |
| action = parse_action(completion) | |
| if action is None: | |
| return -0.3 | |
| obs = env.step(action) | |
| return float(obs.reward) | |
| except Exception: | |
| return -0.2 | |
| finally: | |
| env.close() | |
| def reward_fn(completions: list, state_idx=None, **kwargs) -> list: | |
| if state_idx is None: | |
| state_idx = [0] * len(completions) | |
| return [compute_reward(int(idx), c) for idx, c in zip(state_idx, completions)] | |
| # ββ Evaluation ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_episode(model, tokenizer, task_id: str) -> float: | |
| max_steps = {"idor": 10, "sqli": 15, "path_traversal": 20}[task_id] | |
| env = BugHunterEnvEnvironment() | |
| try: | |
| obs = env.reset(task_id=task_id) | |
| history = [] | |
| for step in range(max_steps): | |
| if obs.done: | |
| break | |
| history_block = "\n".join(history[-4:]) | |
| user_content = ( | |
| f"HTTP {obs.status_code}\n{obs.body[:600]}" | |
| + (f"\nHint: {obs.hint}" if obs.hint else "") | |
| + (f"\n\nHistory:\n{history_block}" if history_block else "") | |
| + "\n\nWhat is your next request?" | |
| ) | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": user_content}, | |
| ] | |
| text = tokenizer.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| inputs = tokenizer(text, return_tensors="pt").to("cuda") | |
| with torch.no_grad(): | |
| out = model.generate( | |
| **inputs, max_new_tokens=64, | |
| temperature=0.4, do_sample=True, | |
| pad_token_id=tokenizer.eos_token_id, | |
| ) | |
| response = tokenizer.decode( | |
| out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True | |
| ) | |
| action = parse_action(response) | |
| if action is None: | |
| break | |
| obs = env.step(action) | |
| history.append(f"[{step+1:02d}] {action.method} {action.path} -> {obs.status_code} r={obs.reward:+.3f}") | |
| return env.get_grade() | |
| finally: | |
| env.close() | |
| def evaluate(model, tokenizer, n: int = EVAL_EPISODES) -> dict: | |
| FastLanguageModel.for_inference(model) | |
| results = {} | |
| for task_id in ("idor", "sqli", "path_traversal"): | |
| grades = [run_episode(model, tokenizer, task_id) for _ in range(n)] | |
| results[task_id] = round(sum(grades) / len(grades), 3) | |
| print(f" {task_id:20s} grades={grades} avg={results[task_id]:.3f}") | |
| FastLanguageModel.for_training(model) | |
| return results | |
| # ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| # ββ 1. Load model ββββββββββββββββββββββββββββββββββββββββββ | |
| print("=" * 60) | |
| print("BugHunterEnv β GRPO Training") | |
| print("=" * 60) | |
| print(f"\nLoading {MODEL_NAME} ...") | |
| model, tokenizer = FastLanguageModel.from_pretrained( | |
| model_name=MODEL_NAME, | |
| max_seq_length=MAX_SEQ_LEN, | |
| load_in_4bit=True, | |
| dtype=None, | |
| ) | |
| model = FastLanguageModel.get_peft_model( | |
| model, | |
| r=LORA_RANK, | |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj", | |
| "gate_proj", "up_proj", "down_proj"], | |
| lora_alpha=LORA_RANK, | |
| lora_dropout=0, | |
| bias="none", | |
| use_gradient_checkpointing="unsloth", | |
| random_state=42, | |
| ) | |
| # ββ 2. Baseline evaluation βββββββββββββββββββββββββββββββββ | |
| print(f"\n[1/4] Baseline evaluation ({EVAL_EPISODES} episodes/task) ...") | |
| baseline = evaluate(model, tokenizer) | |
| print(f"Baseline: {baseline}") | |
| # ββ 3. Build dataset βββββββββββββββββββββββββββββββββββββββ | |
| print("\n[2/4] Building training dataset ...") | |
| dataset = Dataset.from_dict({ | |
| "prompt": [build_messages(s) for s in SEEDED_STATES], | |
| "state_idx": list(range(len(SEEDED_STATES))), | |
| }) | |
| print(f" {len(dataset)} seeded states across 3 tasks") | |
| # ββ 4. GRPO training βββββββββββββββββββββββββββββββββββββββ | |
| print(f"\n[3/4] GRPO training β {TRAINING_STEPS} steps ...") | |
| config = GRPOConfig( | |
| output_dir=OUTPUT_DIR, | |
| num_train_epochs=1, | |
| max_steps=TRAINING_STEPS, | |
| per_device_train_batch_size=BATCH_SIZE, | |
| num_generations=NUM_GENERATIONS, | |
| max_completion_length=80, | |
| learning_rate=5e-6, | |
| warmup_steps=5, | |
| logging_steps=5, | |
| save_steps=TRAINING_STEPS, | |
| temperature=0.9, | |
| report_to="none", | |
| remove_unused_columns=False, | |
| ) | |
| FastLanguageModel.for_training(model) | |
| trainer = GRPOTrainer( | |
| model=model, | |
| reward_funcs=[reward_fn], | |
| args=config, | |
| train_dataset=dataset, | |
| processing_class=tokenizer, | |
| ) | |
| trainer.train() | |
| step_rewards = [ | |
| entry["reward"] | |
| for entry in trainer.state.log_history | |
| if "reward" in entry | |
| ] | |
| print(f" Collected {len(step_rewards)} reward log entries") | |
| # ββ 5. Post-training evaluation ββββββββββββββββββββββββββββ | |
| print(f"\n[4/4] Post-training evaluation ({EVAL_EPISODES} episodes/task) ...") | |
| final = evaluate(model, tokenizer) | |
| print(f"Final: {final}") | |
| # ββ 6. Save model ββββββββββββββββββββββββββββββββββββββββββ | |
| model.save_pretrained(os.path.join(OUTPUT_DIR, "lora_weights")) | |
| tokenizer.save_pretrained(os.path.join(OUTPUT_DIR, "lora_weights")) | |
| print(f" Model saved to {OUTPUT_DIR}/lora_weights") | |
| # ββ 7. Plot ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5)) | |
| fig.suptitle("BugHunterEnv β GRPO Training Results", fontsize=14, fontweight="bold") | |
| if step_rewards: | |
| window = max(1, len(step_rewards) // 10) | |
| smoothed = [ | |
| sum(step_rewards[max(0, i - window):i + 1]) / len(step_rewards[max(0, i - window):i + 1]) | |
| for i in range(len(step_rewards)) | |
| ] | |
| ax1.plot(step_rewards, alpha=0.3, color="steelblue", label="Raw") | |
| ax1.plot(smoothed, color="steelblue", linewidth=2, label="Smoothed") | |
| ax1.axhline(0, color="gray", linestyle="--", linewidth=0.8) | |
| ax1.set_xlabel("Training Step") | |
| ax1.set_ylabel("Step Reward") | |
| ax1.set_title("Training Reward Curve") | |
| ax1.legend() | |
| ax1.grid(True, alpha=0.3) | |
| else: | |
| ax1.text(0.5, 0.5, "No reward logs captured", ha="center", va="center", | |
| transform=ax1.transAxes, fontsize=12, color="gray") | |
| ax1.set_title("Training Reward Curve") | |
| tasks = list(baseline.keys()) | |
| task_names = ["IDOR", "SQL Injection", "Path Traversal"] | |
| x = range(len(tasks)) | |
| bars_before = ax2.bar([i - 0.2 for i in x], [baseline[t] for t in tasks], | |
| width=0.38, label="Before Training", color="#e07070") | |
| bars_after = ax2.bar([i + 0.2 for i in x], [final[t] for t in tasks], | |
| width=0.38, label="After Training", color="#5b9bd5") | |
| for bar in bars_before: | |
| h = bar.get_height() | |
| ax2.text(bar.get_x() + bar.get_width() / 2, h + 0.02, f"{h:.2f}", | |
| ha="center", va="bottom", fontsize=9) | |
| for bar in bars_after: | |
| h = bar.get_height() | |
| ax2.text(bar.get_x() + bar.get_width() / 2, h + 0.02, f"{h:.2f}", | |
| ha="center", va="bottom", fontsize=9) | |
| ax2.set_xticks(list(x)) | |
| ax2.set_xticklabels(task_names) | |
| ax2.set_ylabel("Task Grade (0 β 1.0)") | |
| ax2.set_title("Task Performance: Before vs After") | |
| ax2.set_ylim(0, 1.25) | |
| ax2.legend() | |
| ax2.grid(True, alpha=0.3, axis="y") | |
| plt.tight_layout() | |
| out_path = os.path.join(OUTPUT_DIR, "training_results.png") | |
| os.makedirs(OUTPUT_DIR, exist_ok=True) | |
| plt.savefig(out_path, dpi=150, bbox_inches="tight") | |
| print(f"\nPlot saved: {out_path}") | |
| # ββ 8. Print summary βββββββββββββββββββββββββββββββββββββββ | |
| print("\n" + "=" * 50) | |
| print("SUMMARY") | |
| print("=" * 50) | |
| print(f"{'Task':<22} {'Before':>8} {'After':>8} {'Delta':>8}") | |
| print("-" * 50) | |
| total_delta = 0 | |
| for task, name in zip(tasks, task_names): | |
| delta = final[task] - baseline[task] | |
| total_delta += delta | |
| sign = "+" if delta >= 0 else "" | |
| print(f"{name:<22} {baseline[task]:>8.3f} {final[task]:>8.3f} {sign}{delta:>7.3f}") | |
| print("-" * 50) | |
| avg_delta = total_delta / len(tasks) | |
| sign = "+" if avg_delta >= 0 else "" | |
| print(f"{'Average':<22} {sum(baseline.values())/len(tasks):>8.3f} {sum(final.values())/len(tasks):>8.3f} {sign}{avg_delta:>7.3f}") | |
| if __name__ == "__main__": | |
| main() | |