One command that answers 'should we worry about this CVE here,
what would patch it, and what would the SOC see if someone tried
it'. Renders, for the specified module:
- Header: name + CVE + summary
- WEAKNESS: CWE id and MITRE ATT&CK technique (from CVE metadata)
- THREAT INTEL: CISA KEV status (with date_added if listed) and
the upstream-curated kernel_range
- HOST FINGERPRINT: kernel + arch + distro from ctx->host plus
every relevant capability gate (userns / apparmor / selinux /
lockdown)
- DETECT() TRACE (live): runs the module's detect() with verbose
stderr enabled so the operator sees the gates fire in real
time — 'kernel X is patched', 'userns blocked by AppArmor',
'no readable setuid binary', etc.
- VERDICT: the result_t with a one-line operator interpretation
that varies by outcome (OK / VULNERABLE / PRECOND_FAIL /
TEST_ERROR each get their own framing)
- OPSEC FOOTPRINT: word-wrapped .opsec_notes paragraph (from
last commit) showing what an exploit would leave behind on
this host
- DETECTION COVERAGE: which of auditd/sigma/yara/falco have
embedded rules for this module, with pointers to the
--module-info / --detect-rules commands that dump the bodies
Targeted at every audience the project is meant to serve:
- Red team: opsec footprint + 'would this even reach' verdict
in one screen
- Blue team: paste-ready triage ticket with CVE / CWE / ATT&CK /
KEV header and detection-coverage matrix
- Researchers: the live trace shows the reasoning chain
(predates check, kernel_range_is_patched lookup, userns gate)
that drove the verdict — auditable without reading source
- SOC analysts / students: a single self-contained briefing per
CVE, no cross-referencing needed
Implementation:
- New mode MODE_EXPLAIN, new flag --explain MODULE
- cmd_explain() composes the page from the existing module
struct, cve_metadata_lookup() (federal-source triage data),
ctx->host (cached fingerprint), and a live detect() call
- print_wrapped() helper word-wraps the long .opsec_notes
paragraph at 76 cols / 2-space indent
- Help text + README quickstart + DETECTION_PLAYBOOK single-host
recipe all updated to mention --explain
Smoke tests:
- macOS: --explain nf_tables shows full briefing; trace says
'Linux-only module — not applicable here'; verdict
PRECOND_FAIL with the generic-precondition interpretation
- Linux (docker gcc:latest): --explain nf_tables on a 6.12 host
fires '[+] nf_tables: kernel 6.12.76-linuxkit is patched';
verdict OK with the 'this host is patched' interpretation
- Both: --explain nope (unknown module) returns 1 with a clear
'no module ... Try --list' error
- Both: 87 tests still pass (33 kernel_range + 54 detect on Linux,
33 + 0 stubbed on macOS)
Closes the metadata + opsec + explain trio. The three together
answer the 'best tool for red team, blue team, researchers, and
more' framing.
17 KiB
SKELETONKEY detection playbook
Operational guide for blue teams using SKELETONKEY defensively. Pairs
with docs/DEFENDERS.md (the "what" reference) — this is the "how to
make it part of your daily ops" guide.
The lifecycle
┌─────────────┐
│ inventory │ ← skeletonkey --list (what's bundled?)
└──────┬──────┘
▼
┌─────────────┐
│ scan │ ← skeletonkey --scan --json (what am I vulnerable to?)
└──────┬──────┘
▼
┌─────────────┐
│ fleet scan │ ← skeletonkey-fleet-scan.sh hosts.txt
└──────┬──────┘
▼
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐
│ deploy │ │ mitigate│ │ upgrade │ ← three responses
│ rules │ │ (pre-fix│ │ (kernel │
│(SIEM) │ │ stopgap)│ │ patch) │
└────┬───┘ └─────┬───┘ └─────┬────┘
└────────────┼────────────┘
▼
┌─────────────┐
│ monitor │ ← ausearch -k skeletonkey-* / SIEM alerts
└─────────────┘
Recipes by team size
Single host (workstation / single server)
# Daily/weekly hygiene check
sudo skeletonkey --scan
# Investigate a specific finding (one-page operator briefing)
sudo skeletonkey --explain nf_tables # whichever module came back VULNERABLE
# Shows: CVE / CWE / MITRE ATT&CK / CISA KEV status, live detect() trace,
# OPSEC footprint (what an exploit would leave behind), detection-rule
# coverage, mitigation. Paste into the triage ticket.
# If anything's VULNERABLE, deploy detections + apply mitigation
sudo skeletonkey --detect-rules --format=auditd | sudo tee /etc/audit/rules.d/99-skeletonkey.rules
sudo augenrules --load
sudo skeletonkey --mitigate copy_fail # or whichever module fired
The --explain output is also useful as a learning artifact: each
module's --explain block is a self-contained CVE briefing with the
reasoning chain the detect() function walked, so analysts can verify
SKELETONKEY's verdict against their own understanding of the bug.
Small fleet (~10-100 hosts, SSH-reachable)
Use tools/skeletonkey-fleet-scan.sh:
# Hosts list — one per line; user@host:port supported
cat > hosts.txt <<EOF
prod-web-01
prod-web-02
deploy@bastion-01
ops@db-01:2222
EOF
# Scan; binary scp'd, run, cleaned up. Output is one JSON doc.
./skeletonkey-fleet-scan.sh \
--binary ./skeletonkey \
--ssh-key ~/.ssh/ops_key \
--parallel 8 \
hosts.txt > fleet-scan-$(date +%F).json
# Show me hosts with any VULNERABLE finding
jq '.hosts[] | select(.scan.modules | map(.result == "VULNERABLE") | any) | .host' \
fleet-scan-*.json
# Show summary across the fleet
jq '.summary' fleet-scan-*.json
Output shape:
{
"generated_at": "2026-05-16T22:00:00Z",
"n_hosts": 4,
"summary": {
"ok": 4,
"failed": 0,
"vulnerable": [
{ "cve": "CVE-2024-1086", "name": "nf_tables", "count": 2 },
{ "cve": "CVE-2023-0458", "name": "entrybleed", "count": 4 }
]
},
"hosts": [...]
}
Larger fleet (>100 hosts)
skeletonkey-fleet-scan.sh is intentionally simple (parallel ssh). For
fleets too large for SSH-fan-out, wrap it in your config-management
tool of choice:
- Ansible: ship the binary via
copy:, run viacommand:, parse JSON withjqin a follow-on task - SaltStack:
cmd.runreturning JSON;salt-call --returnto your SIEM - Fabric / Mitogen: same shape, just Python-side
Sample Ansible task:
- name: scan with skeletonkey
copy:
src: skeletonkey
dest: /tmp/skeletonkey
mode: '0755'
- name: run --scan --json
command: /tmp/skeletonkey --scan --json --no-color
register: scan
changed_when: false
failed_when: false # skeletonkey exit codes are semantic, not errors
- name: collect
set_fact:
skeletonkey_scan: "{{ scan.stdout | from_json }}"
- name: cleanup
file:
path: /tmp/skeletonkey
state: absent
SIEM integration patterns
Splunk
# splunk input config (inputs.conf)
[script:///opt/skeletonkey/skeletonkey-cron-scan.sh]
interval = 86400
source = skeletonkey
sourcetype = skeletonkey:scan
skeletonkey-cron-scan.sh:
#!/bin/bash
/usr/local/bin/skeletonkey --scan --json --no-color
Search the indexed events:
index=skeletonkey sourcetype="skeletonkey:scan" modules{}.result=VULNERABLE
| stats count by host modules{}.cve
Elastic / OpenSearch
Filebeat module reading the per-host scan JSON files (one per day),
indexed into an skeletonkey-* index pattern. Standard Kibana
visualization on modules.cve over time tracks vulnerability lifecycle.
Sigma → your platform
# Ship Sigma rules into your platform
skeletonkey --detect-rules --format=sigma > /etc/sigma/skeletonkey.yml
# Convert to your target (Sentinel, Elastic, etc.) via sigmac
sigmac -t elastic /etc/sigma/skeletonkey.yml
YARA artifact scanning
YARA rules catch the post-fire state — page-cache shellcode
overwrites, malicious .deb drops, /etc/passwd UID flips. Run them
as a scheduled scan against sensitive paths:
# Ship YARA rules
sudo skeletonkey --detect-rules --format=yara | sudo tee /etc/yara/skeletonkey.yar
# Scheduled scan via cron — catches the page-cache and /tmp artifacts
# /etc/cron.d/skeletonkey-yara
*/15 * * * * root yara -r /etc/yara/skeletonkey.yar \
/etc/passwd /tmp /usr/bin/su /usr/bin/passwd \
2>>/var/log/skeletonkey-yara.log
What each rule catches:
| Rule | Triggers on |
|---|---|
etc_passwd_uid_flip |
Non-root user line in /etc/passwd with a zero-padded UID (0000+). Canonical Copy Fail / Dirty Frag / Dirty Pipe / DirtyDecrypt outcome. |
etc_passwd_root_no_password |
root line with empty password field — DirtyDecrypt's intermediate corruption step. |
pwnkit_gconv_modules_cache |
Small gconv-modules text file with a module UTF-8// X// /tmp/… redefinition. |
dirty_pipe_passwd_uid_flip |
Same UID-flip pattern (Dirty Pipe-specific tag). |
dirtydecrypt_payload_overlay |
First 28 bytes of /usr/bin/su (or similar) match the embedded 120-byte ET_DYN shellcode the V12 PoC overlays. |
fragnesia_payload_overlay |
Same shape for the 192-byte Fragnesia payload. |
pack2theroot_malicious_deb |
.deb ar-archive in /tmp with the SUID-bash postinst. |
pack2theroot_suid_bash_drop |
/tmp/.suid_bash exists and is a real bash ELF. |
The page-cache overlay rules (dirtydecrypt_payload_overlay,
fragnesia_payload_overlay) are particularly high-signal: no
legitimate ELF starts with those exact 28 bytes, so a hit means the
exploit landed.
Falco runtime detection
Falco catches the exploit as it fires by hooking syscalls and namespace events. Best deploy for K8s / container hosts but works on any modern Linux:
sudo skeletonkey --detect-rules --format=falco \
| sudo tee /etc/falco/rules.d/skeletonkey.yaml
sudo falco --validate /etc/falco/rules.d/skeletonkey.yaml
sudo systemctl reload falco # or restart, depending on distro
What each rule catches:
| Rule | Triggers on |
|---|---|
Pwnkit-style pkexec invocation |
pkexec spawned with empty argv (the bug's hallmark). |
Pwnkit-style GCONV_PATH injection |
Non-root sets GCONV_PATH= / CHARSET= before spawning a setuid binary. |
AF_ALG authenc keyblob installed by non-root |
socket(AF_ALG) by non-root — Copy Fail / GCM variant primitive. |
XFRM NETLINK_XFRM bind from unprivileged userns |
XFRM SA setup from non-root userns — Dirty Frag / Fragnesia primitive. |
/etc/passwd modified by non-root |
Post-fire signal for the whole page-cache-write family. |
Dirty Pipe splice from setuid/sensitive file by non-root |
splice() of /etc/passwd or /usr/bin/su by non-root. |
AF_RXRPC socket created by non-root |
DirtyDecrypt primitive — socket(AF_RXRPC) is nearly unheard-of in production. |
rxrpc security key added |
add_key("rxrpc", …) by non-root — DirtyDecrypt handshake setup. |
TCP_ULP=espintcp set by non-root |
Fragnesia trigger — flipping a TCP socket to espintcp ULP. |
SUID bash dropped to /tmp |
Pack2TheRoot postinst landing /tmp/.suid_bash. |
dpkg invoked by PackageKit on behalf of non-root caller |
Pack2TheRoot chain — packagekitd → dpkg installing a /tmp .pk-*.deb. |
Day-to-day operational shape
What "good" looks like in the SIEM
- Daily
skeletonkey --scan --jsonfrom every host indexed - Trend dashboard: count of VULNERABLE results by CVE over time
- Goal: every VULNERABLE → OK transition within SLA (e.g., 14 days for patched-mainline bugs, 24h for actively-exploited)
- Alert on: any host with a result not seen yesterday (could indicate a config drift, a new install, or a disabled mitigation)
Auditd events from the embedded rules
After deploying skeletonkey --detect-rules --format=auditd:
# By module key
sudo ausearch -k skeletonkey-copy-fail -ts today
sudo ausearch -k skeletonkey-dirty-pipe -ts today
sudo ausearch -k skeletonkey-pwnkit -ts today
sudo ausearch -k skeletonkey-nf-tables-userns -ts today
sudo ausearch -k skeletonkey-overlayfs -ts today
# Anything skeletonkey-tagged in the last hour
sudo ausearch -k 'skeletonkey-*' -ts recent
# Forward to syslog (rsyslog example)
# /etc/rsyslog.d/skeletonkey.conf:
:msg, contains, "skeletonkey-" @@your-siem.example.com:514
When a VULNERABLE result fires
Decision tree:
A scan reports VULNERABLE for module X
│
├── Q: Can I patch the underlying kernel / package?
│ ├── YES → schedule patch window. In the meantime:
│ │ skeletonkey --mitigate X (if supported)
│ │ Verify auditd rule for X is loaded.
│ │ Monitor for the rule key.
│ └── NO (legacy LTS, embedded device, prod freeze) →
│ skeletonkey --mitigate X (essential)
│ Compensating control: tighten LSM (SELinux/AppArmor)
│ Document in risk register
│
└── Q: Was this VULNERABLE before? When?
├── First time → config drift; investigate why detection now
│ produces this result
└── Persistent → mitigation isn't applied OR is being reverted
by config management; fix the config baseline
Mitigation reverts
Mitigations can break legitimate functionality:
| Mitigation | Side effect |
|---|---|
copy_fail blacklist algif_aead |
strongSwan / IPsec breaks |
copy_fail blacklist esp4/esp6 |
IPsec breaks |
copy_fail blacklist rxrpc |
AFS / kAFS clients break |
copy_fail AppArmor restrict userns=1 |
bubblewrap, podman rootless break |
If you applied a mitigation and now need to revert (e.g., the kernel patch has rolled out fleet-wide):
sudo skeletonkey --cleanup copy_fail
# OR manually:
sudo rm /etc/modprobe.d/dirtyfail-mitigations.conf
sudo rm /etc/sysctl.d/99-dirtyfail-mitigations.conf
# Reload affected modules / sysctls per your distro
Per-module detection coverage
Across the 4 rule formats:
| Module | CVE | auditd | sigma | yara | falco |
|---|---|---|---|---|---|
| copy_fail | CVE-2026-31431 | ✓ | ✓ | ✓ | ✓ |
| copy_fail_gcm | (variant) | ✓ | ✓ | ✓ | ✓ |
| dirty_frag_esp | CVE-2026-43284 | ✓ | ✓ | ✓ | ✓ |
| dirty_frag_esp6 | CVE-2026-43284 | ✓ | ✓ | ✓ | ✓ |
| dirty_frag_rxrpc | CVE-2026-43500 | ✓ | ✓ | ✓ | ✓ |
| dirty_pipe | CVE-2022-0847 | ✓ | ✓ | ✓ | ✓ |
| dirtydecrypt | CVE-2026-31635 | ✓ | ✓ | ✓ | ✓ |
| fragnesia | CVE-2026-46300 | ✓ | ✓ | ✓ | ✓ |
| pwnkit | CVE-2021-4034 | ✓ | ✓ | ✓ | ✓ |
| pack2theroot | CVE-2026-41651 | ✓ | ✓ | ✓ | ✓ |
| Other 21 modules | various | ✓ | partial | — | — |
Full 4-format coverage on the 10 highest-value modules; auditd
covers everything. YARA / Falco expansion to the remaining 21 modules
is incremental contributor work (each module's detect_yara /
detect_falco field in the module struct just needs a string).
Correlation across formats
Single-format detections are useful; the high-confidence signal is the correlation across formats for the same module in a short window. Each exploit leaves a recognisable multi-format trail:
| Exploit | falco fires | auditd fires | yara confirms |
|---|---|---|---|
| Pwnkit | pkexec empty argv |
execve /usr/bin/pkexec + GCONV_PATH= env |
gconv-modules cache in /tmp |
| Dirty Pipe | splice() from /etc/passwd |
splice + write to /etc/passwd |
UID flip in /etc/passwd |
| Copy Fail | socket(AF_ALG) |
algif_aead + ALG_SET_KEY |
UID flip in /etc/passwd |
| Dirty Frag (ESP) | NETLINK_XFRM sendto + TCP_ULP | XFRM_MSG_NEWSA | UID flip in /etc/passwd |
| DirtyDecrypt | socket(AF_RXRPC) + add_key(rxrpc) |
AF_RXRPC + add_key | 120-byte ELF overwrites /usr/bin/su |
| Fragnesia | TCP_ULP=espintcp from non-root |
XFRM + setsockopt(TCP_ULP) | 192-byte ELF overwrites /usr/bin/su |
| Pack2TheRoot | dpkg invoked by packagekitd with /tmp/.pk-*.deb | new .deb in /tmp + chmod 4755 on /tmp/.suid_bash |
malicious .deb + SUID bash both present |
If three of the four signals fire for the same module in the same window, the exploit landed. One signal alone in a noisy environment is more likely a tuning FP; three signals is incident response.
Worked example: catching DirtyDecrypt end-to-end
A SOC operator gets a Falco page:
CRITICAL AF_RXRPC socket() by non-root (user=alice proc=poc pid=44231)
-
Confirm via auditd — pull events keyed on the family:
sudo ausearch -k skeletonkey-dirtydecrypt-rxrpc -ts recentExpect:
socket(...,33,...)+ subsequentadd_key("rxrpc",...). -
Confirm via yara — scan setuid binaries for the page-cache overlay:
yara /etc/yara/skeletonkey.yar /usr/bin/su /usr/bin/passwdIf
dirtydecrypt_payload_overlaymatches/usr/bin/su, the exploit landed — the binary's page cache has been overwritten with the 120-byte shellcode. -
Recover — the on-disk binary is intact; only the page cache is corrupted. Drop it:
sudo skeletonkey --cleanup dirtydecrypt # or: echo 3 > /proc/sys/vm/drop_caches -
Sigma hunt for lateral / repeat — query your SIEM with the sigma rule ID
7c1e9a40-skeletonkey-dirtydecryptover the last 7 days to find any other hosts. -
Patch. DirtyDecrypt's mainline fix is commit
a2567217in Linux 7.0 — seeCVES.mdfor distro backports. -
Harden.
rxrpcis rarely needed on non-AFS hosts:echo "blacklist rxrpc" | sudo tee /etc/modprobe.d/blacklist-rxrpc.conf sudo update-initramfs -u
The same shape applies to every module: pick the auditd key, the yara rule for the artifact, the falco rule for the runtime signal, and the sigma rule for the hunt.
Common false positives + tuning
| Rule key | False positive | Fix |
|---|---|---|
skeletonkey-copy-fail-afalg |
strongSwan, libcrypto using kernel crypto | -F auid= exclude service account UIDs |
skeletonkey-dirty-pipe-splice |
nginx, HAProxy, kTLS | -F gid!=33 -F gid!=99 exclude web service accounts |
skeletonkey-pwnkit-execve |
gnome-software, polkit's own re-exec | Correlate by parent process; pkexec via gnome dbus is benign |
skeletonkey-nf-tables-userns |
docker rootless, podman, snap confined apps | Whitelist known userns-using service GIDs |
skeletonkey-overlayfs |
docker / containerd mounting overlayfs as root | The rule is intended for unprivileged-userns overlayfs mounts; add -F auid>=1000 |
Pre-patch quarantine pattern
If a CVE is in active exploitation and you can't patch immediately:
# Stage 1: detect
sudo skeletonkey --scan --json | jq '.modules[] | select(.cve == "CVE-XXXX")'
# Stage 2: mitigate (where supported)
sudo skeletonkey --mitigate <module>
# Stage 3: monitor — auditd rules already deployed
sudo ausearch -k 'skeletonkey-*' -ts today | grep <module>
# Stage 4: contain — temporarily restrict the trigger surface
# e.g., for nf_tables CVE-2024-1086:
echo 0 | sudo tee /proc/sys/kernel/unprivileged_userns_clone
# OR
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
# Stage 5: alert
# When auditd or sigma rule fires, page on-call
Maintenance contract
When SKELETONKEY ships a new module:
- CI test passes on at least one vulnerable + patched kernel pair
- Detection rules ship alongside (auditd + sigma minimum)
- CVES.md row added with patch status
- NOTICE.md credits original researcher
- ROADMAP.md updated
Treat these as the SLA for any blue-team-facing deliverable.
When you find a new false positive
File an issue at https://github.com/KaraZajac/SKELETONKEY/issues with:
- The exact ausearch line that fired
- The legitimate process that produced it
- Distro / kernel version
Most false-positive fixes are a -F filter on the embedded rule —
small, mergeable.