Skip to content

Commit 424b258

Browse files
committed
feat: robust argv-based domain blocking
- Connect-time check: emit CONNECT_ATTEMPT for non-blocked-ips connects, userspace checks cmdline for blocked domains and kills before connect completes - Domain rules checked first (before malicious_hashes, before LIN-*) - Exempt alert dispatch (openclaw agent + ClawEDR Alert) to prevent cascade - Reduce perf buffer poll timeout 50ms->5ms for faster event processing - _blocked_domains for fast substring check Made-with: Cursor
1 parent 54c347e commit 424b258

File tree

2 files changed

+91
-25
lines changed

2 files changed

+91
-25
lines changed

deploy/linux/bpf_hooks.c

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ struct event_t {
3232
char comm[TASK_COMM_LEN];
3333
char filename[MAX_FILENAME_LEN];
3434
u8 action; // 0 = observed (enter), 1 = blocked (SIGKILL), 2 = post-exec
35-
// (exit, for deny_rules)
36-
u32 blocked_ip; // for connect events: blocked IP in host byte order
35+
// (exit, for deny_rules), 3 = connect_attempt (userspace domain check)
36+
u32 blocked_ip; // for connect events: IP in host byte order
3737
};
3838

3939
/* --- Maps populated by monitor.py --- */
@@ -256,20 +256,25 @@ TRACEPOINT_PROBE(syscalls, sys_enter_connect) {
256256
4); /* offset of sin_addr in sockaddr_in */
257257

258258
u8 *is_blocked = blocked_ips.lookup(&ip);
259-
if (is_blocked) {
260-
struct event_t evt = {};
261-
evt.pid = pid;
262-
evt.ns_pid = get_ns_pid();
263-
evt.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
264-
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
259+
struct event_t evt = {};
260+
evt.pid = pid;
261+
evt.ns_pid = get_ns_pid();
262+
evt.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
263+
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
264+
evt.blocked_ip = ip;
265265

266+
if (is_blocked) {
266267
const char msg[] = "NETWORK_CONNECT";
267268
__builtin_memcpy((void *)evt.filename, msg, sizeof(msg));
268-
evt.blocked_ip = ip;
269-
270269
evt.action = 1;
271270
events.perf_submit(args, &evt, sizeof(evt));
272271
bpf_send_signal(SIGKILL);
272+
} else {
273+
/* Not in blocked_ips: emit for userspace domain check (argv-based) */
274+
const char msg[] = "CONNECT_ATTEMPT";
275+
__builtin_memcpy((void *)evt.filename, msg, sizeof(msg));
276+
evt.action = 3;
277+
events.perf_submit(args, &evt, sizeof(evt));
273278
}
274279
}
275280

deploy/linux/monitor.py

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262

6363
_bpf_instance = None
6464
_deny_rules: dict[str, dict] = {}
65+
_blocked_domains: dict[str, str] = {} # rule_id -> domain, for fast connect-time check
6566
_malicious_hashes: dict[str, str] = {}
6667
_hash_to_rule_id: dict[int, str] = {}
6768
_ip_to_rule_id: dict[str, str] = {}
@@ -219,6 +220,11 @@ def _print_event(cpu, data, size):
219220
pid=ns_pid,
220221
comm=comm,
221222
)
223+
elif event.action == 3 and filename == "CONNECT_ATTEMPT":
224+
# Connect not in blocked_ips: check cmdline for blocked domains (argv-based)
225+
ip_int = getattr(event, "blocked_ip", 0)
226+
ip_str = _ip_int_to_str(ip_int) if ip_int else "0.0.0.0"
227+
_check_connect_domain_rules(ns_pid, comm, ip_str)
222228
elif event.action == 1:
223229
# Blocked by BPF — find the matching rule ID
224230
rule_id = _find_rule_id_for_block(filename)
@@ -235,12 +241,10 @@ def _print_event(cpu, data, size):
235241
)
236242
elif event.action == 2:
237243
# Post-exec (sys_exit_execve): /proc/cmdline now has the new argv.
238-
239-
# Layer 4: Userspace file hash check
240-
_check_malicious_hashes(ns_pid, comm, filename)
241-
242-
# Layer 2: Userspace deny_rules check
244+
# Check deny_rules (incl. domains) FIRST — fast, no file I/O
243245
matched_rule = _check_deny_rules(ns_pid, comm, filename)
246+
if not matched_rule:
247+
_check_malicious_hashes(ns_pid, comm, filename)
244248
if not matched_rule:
245249
logger.debug(
246250
"[observed] pid=%d uid=%d comm=%s file=%s",
@@ -315,6 +319,51 @@ def _check_malicious_hashes(pid: int, comm: str, filename: str) -> str | None:
315319

316320
return None
317321

322+
# ---------------------------------------------------------------------------
323+
# Connect-time domain check (argv-based, no DNS)
324+
# ---------------------------------------------------------------------------
325+
326+
def _check_connect_domain_rules(pid: int, comm: str, ip_str: str) -> str | None:
327+
"""At connect(): read cmdline, check for blocked domains. Kill if match.
328+
Provides a second chance to block when exec-time check raced."""
329+
if not _blocked_domains:
330+
return None
331+
try:
332+
with open(f"/proc/{pid}/cmdline", "rb") as f:
333+
raw = f.read(4096)
334+
except OSError:
335+
return None
336+
337+
cmdline = raw.replace(b"\x00", b" ").decode(errors="replace").strip()
338+
if not cmdline:
339+
return None
340+
341+
# Exempt our own alert dispatch
342+
if "openclaw agent" in cmdline and "ClawEDR Alert" in cmdline:
343+
return None
344+
345+
for rule_id, domain in _blocked_domains.items():
346+
if domain in cmdline:
347+
block_logger.warning(
348+
"BLOCKED [%s] (domain=connect) pid=%d comm=%s target=%s cmdline=%s",
349+
rule_id, pid, comm, ip_str, cmdline[:150],
350+
)
351+
dispatch_alert_async(
352+
rule_id=rule_id,
353+
action="deny_rule",
354+
target=cmdline[:200],
355+
pid=pid,
356+
comm=comm,
357+
)
358+
try:
359+
os.kill(pid, 9)
360+
except OSError:
361+
pass
362+
return rule_id
363+
364+
return None
365+
366+
318367
# ---------------------------------------------------------------------------
319368
# Layer 2: deny_rules — userspace argv matching
320369
# ---------------------------------------------------------------------------
@@ -348,6 +397,12 @@ def _check_deny_rules(pid: int, comm: str, filename: str) -> str | None:
348397
except (OSError, IndexError, ValueError):
349398
pass
350399

400+
# Exempt our own alert dispatch: openclaw agent --message "ClawEDR Alert..."
401+
# The message embeds the blocked cmdline and rule descriptions (e.g. "port 4444")
402+
# which can falsely match LIN-001 and cause an alert cascade.
403+
if "openclaw agent" in cmdline and "ClawEDR Alert" in cmdline:
404+
return None
405+
351406
for rule_id, rule in _deny_rules.items():
352407
pattern = rule.get("match", "")
353408
if not pattern:
@@ -637,30 +692,36 @@ def _apply_blocked_paths(policy: dict, exempted: set[str]) -> None:
637692
# ---------------------------------------------------------------------------
638693

639694
def _apply_deny_rules(policy: dict, exempted: set[str]) -> None:
640-
"""Load deny_rules for argv matching. blocked_domains are injected as domain-in-cmdline rules."""
695+
"""Load deny_rules for argv matching. blocked_domains are injected as domain-in-cmdline rules.
696+
Domain rules are loaded FIRST so they take precedence over generic LIN-* rules (e.g. LIN-001
697+
*4444* would otherwise match alert messages that mention port 4444)."""
641698
global _deny_rules
642699
raw = policy.get("deny_rules", {})
643700
if isinstance(raw, dict) and "linux" in raw:
644701
raw = raw.get("linux", {})
645702

646703
_deny_rules = {}
647704
skipped = 0
648-
for rule_id, rule in raw.items():
705+
706+
# Load domain rules FIRST so they match before generic rules (curl to blocked domain -> DOM-xxx)
707+
global _blocked_domains
708+
_blocked_domains = {}
709+
for rule_id, domain in policy.get("blocked_domains", {}).items():
649710
if rule_id in exempted:
650-
logger.info("Skipping exempted deny_rule %s", rule_id)
651711
skipped += 1
652712
continue
653-
if isinstance(rule, dict) and rule.get("match"):
654-
_deny_rules[rule_id] = rule
713+
_blocked_domains[rule_id] = domain
714+
_deny_rules[rule_id] = {"match": f"*{domain}*", "rule": "blocked_domain", "scope": "argv"}
655715

656-
# Inject blocked_domains as argv deny_rules (match cmdline containing domain)
657-
for rule_id, domain in policy.get("blocked_domains", {}).items():
716+
for rule_id, rule in raw.items():
658717
if rule_id in exempted:
718+
logger.info("Skipping exempted deny_rule %s", rule_id)
659719
skipped += 1
660720
continue
661721
if rule_id in _deny_rules:
662722
continue
663-
_deny_rules[rule_id] = {"match": f"*{domain}*", "rule": "blocked_domain", "scope": "argv"}
723+
if isinstance(rule, dict) and rule.get("match"):
724+
_deny_rules[rule_id] = rule
664725

665726
logger.info("Loaded %d deny_rules for userspace matching (%d skipped)",
666727
len(_deny_rules), skipped)
@@ -690,7 +751,7 @@ def _apply_blocked_ips(policy: dict, exempted: set[str]) -> None:
690751
global _ip_to_rule_id
691752
if _bpf_instance is None:
692753
return
693-
754+
694755
raw_ips = policy.get("blocked_ips", {})
695756

696757
_ip_to_rule_id = {}
@@ -807,7 +868,7 @@ def _stop(signum, frame):
807868

808869
if _bpf_instance is not None:
809870
try:
810-
_bpf_instance.perf_buffer_poll(timeout=50)
871+
_bpf_instance.perf_buffer_poll(timeout=5)
811872
except Exception:
812873
logger.exception("Crash in perf_buffer_poll")
813874

0 commit comments

Comments
 (0)