Most host-based detection tools require something running on every host: an agent, a kernel module, an eBPF program loaded at boot. That's fine when you control the fleet. It's less fine when you're dealing with a mix of distros, customer VMs, legacy systems where "please install our agent" gets stuck in a change window for six weeks.
I wanted something that works over SSH on a host you've never touched before — no install, no persistent process, no kernel dependency. Run a scan, get findings, nothing running between scans.
That's bladedr. This post explains how it works.
How the probe works
The core idea: SSH to the target, stage a static binary, run it, collect results.
The binary (bladedr-probe) is a single static Go executable compiled with CGO_ENABLED=0. It runs on the target for a few seconds, reads from /proc, /sys, /etc and a handful of other paths, builds a JSON snapshot of the host state, evaluates detection rules against it, and returns the findings on stdout. Nothing stays resident.
The snapshot covers:
- running processes (cmdline, environ, exe, cwd, parent chain, deleted/memfd-backed executables)
- listening sockets and raw (
AF_PACKET) socket holders - kernel modules,
/proc/kallsyms, dmesg ring buffer - persistence: cron, systemd units, authorized_keys, ld.so.preload, PAM modules, XDG autostart, binfmt_misc, udev rules, Python
.pthfiles - accounts (uid/gid, shell, groups)
- suspicious files: world-writable sensitive paths, SUID/SGID in unexpected places, deleted-but-running binaries, memfd mappings
- kernel parameters (sysctl), SELinux state, immutable files
The probe is read-only. It doesn't start a daemon and it doesn't modify host state. The one thing it writes is itself: the binary is content-addressed (sha256) and cached at /tmp/.bladedr/probe-<hash>, so repeated scheduled scans skip the multi-MB re-upload. The small per-scan rule bundle is written next to it and deleted when the scan finishes. So there's no agent, no service, no process running between scans — just a cached binary sitting idle until the next SSH session invokes it. If you want it gone, it's one rm -rf /tmp/.bladedr.
Rules: YAML + CEL, not code
Detection logic is data, not compiled code. A rule looks like this (this one is a real builtin — global-ld-preload):
id: global-ld-preload
title: "LD_PRELOAD/LD_AUDIT / writable LD_LIBRARY_PATH in a global environment file"
category: evasion
severity: critical
score: 90
mitre: ["T1574.006"]
foreach: env_preload
when: 'item.suspicious_lines.size() > 0'
evidence:
path: item.path
lines: item.suspicious_lines
dedup: ["item.path"]
foreach walks a collection from the snapshot. when is a CEL expression evaluated against each item. CEL is a safe, deterministic expression language — no arbitrary code execution, fast evaluation, easy to audit. (Rules without a collection just run one when against the whole snapshot — e.g. the ld-so-preload-rootkit rule that fires on any entry in /etc/ld.so.preload.)
Rules ship embedded in the binary (internal/rules/builtin/), but the server merges three layers: builtin → filesystem dir (BLADEDR_RULES_DIR) → database. A later rule with the same id overrides the earlier one, so you can patch a builtin without recompiling, or disable it with enabled: false.
# live rule injection via API, active on the next scan
curl -X POST :8080/api/v1/rules --data-binary '
id: watch-loader
title: "Process named loader"
category: process
severity: medium
foreach: processes
when: item.comm == "loader"
evidence: { pid: item.pid, comm: item.comm }'
Currently 87 builtin rules covering MITRE ATT&CK techniques: rootkits, persistence, privilege escalation, evasion, webshells, supply-chain tampering, eBPF backdoors, and more.
The server
bladedr-server handles inventory, scheduling, the API, and the web console. It runs anywhere — Linux, macOS, Docker. Scan targets are always Linux.
Storage is in-memory by default (good for dev and demos), or Postgres with pg_search (BM25 full-text search over findings, useful when you have a few thousand observations and want to curl ':8080/api/v1/observations?q=rootkit').
The server keeps a per-host baseline. First scan establishes it. Later scans diff against it and raise baseline-new-* observations for anything new: a new UID-0 account, a new listening port, a new authorized key. You don't have to define what "normal" looks like — it learns from the first clean scan.
Fleet-rarity works similarly: if a kernel module or a cron entry appears on one host out of fifty, that's a low-severity lead regardless of whether any specific rule fires. Rarity is scoped to a host cohort (same OS), so a lone box doesn't generate rarity noise against unrelated hosts.
ML risk scoring — what it does and what it doesn't
With enough hosts, you end up with a lot of medium-severity findings. Some are real, most are noise. The risk model re-ranks open findings by how likely you are to triage them as real.
It's a multinomial Naive Bayes classifier (Laplace-smoothed) trained on your own triage decisions: acknowledged = real finding, false_positive = noise. It uses structural features only:
rule:global-ld-preload
cat:evasion
sev:critical
src:agentless_probe
tech:T1574.006
tac:T1574
path:etc
No attacker-controlled strings, no process names, no paths. It learns that certain rule/category/severity/technique combinations correlate with real compromises on your fleet, not on someone else's.
The model is honest about its own reliability. GET /api/v1/risk/stats runs leave-one-out cross-validation and reports whether the labelled set is big enough, balanced enough, and separable enough to trust:
{
"trustworthy": true,
"labeled": 211,
"positives": 162,
"negatives": 49,
"cv_accuracy": 0.88,
"base_rate": 0.77,
"reason": "enough balanced, separable data to prioritise findings"
}
Until both classes exist (you need at least one acknowledged and one false_positive triage), scoring falls back to the rule's static score. A clean fleet produces no positives to label, so there's an attack-emulation range (poligon/) that plants known technique artifacts in a container, scans them, and writes labelled training data to dataset.jsonl.
It ranks. It does not detect.
eBPF tier (Phase 2)
Agentless scanning sees the artifact at rest — a file, a config entry, an account. It misses runtime-only techniques: fileless execution, a reverse shell that runs and exits, a container escape that leaves no trace.
The eBPF tier fills that gap. bladedr-sensor wraps Tetragon: it loads TracingPolicies (kprobes/tracepoints), consumes Tetragon's JSON event stream, maps each hit to an observation, and posts them to the server. Same observations table, same triage flow, same risk model — just source=ebpf_sensor instead of source=agentless_probe.
Tetragon runs as a privileged container. The sensor deploys over SSH and runs as a systemd unit. A host is either scan_only or scan_plus_sensor.
Running it
git clone https://github.com/mar0ls/bladedr
cd bladedr
make build # server + probe for your platform
make build-probe-linux # cross-compile static probe for Linux targets
make demo # scan a bundled malicious snapshot, no Linux host needed
For SSH scanning against a real host:
# start server with Postgres
docker compose up -d
./bin/bladedr-server
# add a host, store credentials, scan
curl -X POST :8080/api/v1/hosts \
-d '{"hostname":"web-01","primary_ip":"10.0.0.5","arch":"amd64"}'
curl -X POST :8080/api/v1/credentials \
-d '{"username":"root","auth_type":"ssh_password","secret":"…"}'
curl -X POST :8080/api/v1/hosts/<id>/scans
bladedr is at v0.1.0 — the agentless tier is stable, the eBPF sensor works but Phase 2 rules are still thin. If you run it against a compromised or test host, I'd be curious what fires and what doesn't.
Rule contributions and issue reports welcome.
GPL-3. Code at [github.com/mar0ls/bladedr]



Top comments (0)