EDR: XOR-on-the-wire, DLL via rundll32, Whiskers page, kill-detection fix

This commit is contained in:
BlackSnufkin
2026-04-30 01:27:51 -07:00
parent 36eab29536
commit 966d14104c
15 changed files with 609 additions and 84 deletions
+62 -28
View File
@@ -91,33 +91,69 @@ pub async fn exec(
// Distinguish if we can; otherwise treat as generic write failure. // Distinguish if we can; otherwise treat as generic write failure.
tracing::error!(error = %err, "Failed to write payload"); tracing::error!(error = %err, "Failed to write payload");
let virus_signaled = is_likely_av_block(&err); let virus_signaled = is_likely_av_block(&err);
let resp = if virus_signaled { if virus_signaled {
ExecResponse { // 200 OK — the agent successfully detected an AV intercept.
// It's a real outcome of the run, not a transport-level error.
return Ok(Json(ExecResponse {
status: "virus", status: "virus",
pid: None, pid: None,
message: Some(format!("Antivirus blocked write: {err}")), message: Some(format!("Antivirus blocked write: {err}")),
} }));
} else { }
ExecResponse { return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse {
status: "error", status: "error",
pid: None, pid: None,
message: Some(format!("Failed to write file: {err}")), message: Some(format!("Failed to write file: {err}")),
} })));
};
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(resp)));
} }
// Kill any previous run that's still hanging around (defensive — orchestrator // Kill any previous run that's still hanging around (defensive — orchestrator
// should have called kill, but if it didn't, don't leave orphans). // should have called kill, but if it didn't, don't leave orphans).
take_previous_run_for_cleanup(&state).await; take_previous_run_for_cleanup(&state).await;
// Spawn the payload. // Spawn the payload. Windows can't directly exec a `.dll` — those need
let mut command = Command::new(&file_path); // to go through `rundll32.exe <dll>,<entry-point> [args...]`. The
if let Some(args) = form.executable_args.as_ref() { // entry point is required and comes from `executable_args`; if it's
if !args.is_empty() { // missing we bail with a clear error instead of letting Windows
command.args(parse_args(args)); // return ERROR_BAD_EXE_FORMAT.
let is_dll = file_path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case("dll"))
.unwrap_or(false);
let executable_args = form.executable_args.as_deref().unwrap_or("").trim();
let mut command = if is_dll {
if executable_args.is_empty() {
tracing::error!("DLL spawn rejected: no entry point provided in executable_args");
let _ = tokio::fs::remove_file(&file_path).await;
return Err((StatusCode::BAD_REQUEST, Json(ExecResponse {
status: "error",
pid: None,
message: Some(
"DLL execution requires an entry point in executable_args \
(rundll32 syntax: <ExportedFunction> [args...])".into(),
),
})));
} }
} // Split the args: the first token is the export name (becomes
// `<dll>,<export>`), everything after is forwarded to rundll32.
let mut tokens = executable_args.split_whitespace();
let entry = tokens.next().unwrap(); // checked non-empty above
let rest: Vec<&str> = tokens.collect();
let dll_target = format!("{},{}", file_path.display(), entry);
tracing::info!(dll = %file_path.display(), entry, "Spawning DLL via rundll32");
let mut c = Command::new("rundll32.exe");
c.arg(dll_target);
for r in rest { c.arg(r); }
c
} else {
let mut c = Command::new(&file_path);
if !executable_args.is_empty() {
c.args(parse_args(executable_args));
}
c
};
command command
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
@@ -132,20 +168,18 @@ pub async fn exec(
let virus_signaled = is_likely_av_block(&err); let virus_signaled = is_likely_av_block(&err);
// Best-effort cleanup of the dropper. // Best-effort cleanup of the dropper.
let _ = tokio::fs::remove_file(&file_path).await; let _ = tokio::fs::remove_file(&file_path).await;
let resp = if virus_signaled { if virus_signaled {
ExecResponse { return Ok(Json(ExecResponse {
status: "virus", status: "virus",
pid: None, pid: None,
message: Some(format!("Antivirus blocked spawn: {err}")), message: Some(format!("Antivirus blocked spawn: {err}")),
} }));
} else { }
ExecResponse { return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ExecResponse {
status: "error", status: "error",
pid: None, pid: None,
message: Some(format!("Failed to spawn process: {err}")), message: Some(format!("Failed to spawn process: {err}")),
} })));
};
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(resp)));
} }
}; };
+16
View File
@@ -166,6 +166,14 @@ class ElasticClient:
# Match alerts touching THIS specific payload across all the # Match alerts touching THIS specific payload across all the
# places Elastic Defend records the filename. The MD5 prefix # places Elastic Defend records the filename. The MD5 prefix
# in the filename ensures uniqueness across uploads. # in the filename ensures uniqueness across uploads.
#
# The command-line / args matches are critical for DLL
# payloads spawned via rundll32: the running process is
# rundll32.exe (so file.name / process.name / file.path /
# process.executable all point at the system rundll32),
# and the DLL's path only appears inside the command line
# (`rundll32.exe <dll-path>,<entry>`). Same pattern for any
# other launcher-hosted payload.
filters.append({"bool": { filters.append({"bool": {
"minimum_should_match": 1, "minimum_should_match": 1,
"should": [ "should": [
@@ -185,6 +193,14 @@ class ElasticClient:
"value": f"*{file_name}", "value": f"*{file_name}",
"case_insensitive": True, "case_insensitive": True,
}}}, }}},
{"wildcard": {"process.command_line": {
"value": f"*{file_name}*",
"case_insensitive": True,
}}},
{"wildcard": {"process.args": {
"value": f"*{file_name}*",
"case_insensitive": True,
}}},
], ],
}}) }})
+69 -22
View File
@@ -4,7 +4,7 @@ Per-payload flow (matches ROADMAP.md Phase L4):
1. AgentClient.get_info -> learn the EDR VM's hostname 1. AgentClient.get_info -> learn the EDR VM's hostname
2. AgentClient.lock_acquire 2. AgentClient.lock_acquire
3. AgentClient.exec(payload, args) 3. AgentClient.exec(payload, args) -- XOR-encoded on the wire
4. wait for exec to exit OR exec_timeout_seconds 4. wait for exec to exit OR exec_timeout_seconds
5. AgentClient.kill (idempotent if already exited) 5. AgentClient.kill (idempotent if already exited)
6. AgentClient.get_execution_logs -> stdout/stderr/exit_code 6. AgentClient.get_execution_logs -> stdout/stderr/exit_code
@@ -17,10 +17,19 @@ Failure model: if the agent is unreachable in step 1, mark the profile
unavailable and bail. If anything between lock_acquire and lock_release unavailable and bail. If anything between lock_acquire and lock_release
fails, the lock is released in `finally` so the agent doesn't get stuck fails, the lock is released in `finally` so the agent doesn't get stuck
in a busy state. in a busy state.
XOR-on-the-wire: every dispatch picks a random byte 0-255, XORs the
payload with it before multipart upload, and tells Whiskers to reverse
the XOR while writing byte-by-byte. This avoids leaving an unencrypted
copy of the payload in HTTP buffers / OS network stacks / the agent's
request memory — Defender's network inspection on the EDR VM otherwise
sees the cleartext payload before WriteAsync runs and can flag it
before our multipart parser even completes.
""" """
import logging import logging
import os import os
import secrets
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
@@ -241,20 +250,27 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
# Build the phase-1 snapshot the UI shows immediately. It's a # Build the phase-1 snapshot the UI shows immediately. It's a
# complete EDR-result dict with status="polling_alerts" and an # complete EDR-result dict with status="polling_alerts" and an
# empty alerts array; Phase 2 will overwrite it on disk when the # empty alerts array; Phase 2 will overwrite it on disk when the
# alerts arrive. # alerts arrive. The AV-block-vs-clean-exec distinction lives in
# `summary.blocked_by_av` — we don't fork the polling status for
# that because the polling itself happens entirely between
# LitterBox and Elastic, regardless of what the EDR VM did.
kind = exec_outcome["kind"] kind = exec_outcome["kind"]
is_blocked = (kind == "virus") is_blocked = (kind == "virus")
phase_1_status = "blocked_polling_alerts" if is_blocked else "polling_alerts" phase_1_status = "polling_alerts"
max_wait = ( max_wait = (
self.profile.av_block_wait_seconds if is_blocked self.profile.av_block_wait_seconds if is_blocked
else self.profile.wait_seconds_for_alerts else self.profile.wait_seconds_for_alerts
) )
exec_logs = exec_outcome.get("exec_logs", {}) exec_logs = exec_outcome.get("exec_logs", {})
# For the AV-block path the process never ran, so kill # For the AV-block path the process never ran, so kill
# classification doesn't apply. For successful spawns we already # classification doesn't apply. For .exe successful spawns we can
# know if the EDR terminated it externally — surface it now so # rely on the heuristic in Phase 1; for .dll spawns the heuristic
# the operator doesn't have to wait for Phase 2 to see it. # produces false positives (rundll32 exits 1 on bad-export, etc.)
killed_by_edr = False if is_blocked else self._classify_kill(exec_logs) # so we defer to Phase 2 which has alert evidence.
killed_by_edr = (
False if is_blocked
else self._classify_kill(exec_logs, filename=filename)
)
raw_exec_status = exec_logs.get("status") raw_exec_status = exec_logs.get("status")
exec_status_label = ( exec_status_label = (
"virus" if is_blocked "virus" if is_blocked
@@ -284,7 +300,6 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
"run_start": run_start.isoformat(), "run_start": run_start.isoformat(),
"run_end": None, "run_end": None,
"wait_seconds_for_alerts": max_wait, "wait_seconds_for_alerts": max_wait,
"phase": phase_1_status,
"blocked_by_av": is_blocked, "blocked_by_av": is_blocked,
}, },
} }
@@ -321,13 +336,22 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
alerts fire in real-time — we don't need to hold the lock through the alerts fire in real-time — we don't need to hold the lock through the
post-exec wait window just to query Elastic afterwards. post-exec wait window just to query Elastic afterwards.
""" """
# Step 3 — exec. # Step 3 — exec. The payload travels XOR-encoded with a random
# per-dispatch byte; Whiskers reverses the XOR byte-by-byte while
# writing to disk. Avoids cleartext payload sitting in HTTP buffers
# where Defender's network inspection might flag it pre-write.
# `bytes.translate` is C-implemented; a generator over file_bytes
# would take seconds on a 12 MB sample.
xor_key = secrets.randbelow(256)
xor_table = bytes(b ^ xor_key for b in range(256))
xored = file_bytes.translate(xor_table)
try: try:
exec_resp = self.agent.exec( exec_resp = self.agent.exec(
file_bytes=file_bytes, file_bytes=xored,
filename=filename, filename=filename,
drop_path=self.profile.drop_path, drop_path=self.profile.drop_path,
executable_args=executable_args, executable_args=executable_args,
xor_key=xor_key,
) )
except AgentUnreachable as exc: except AgentUnreachable as exc:
return {**self._unreachable_result(exc), "_final": True} return {**self._unreachable_result(exc), "_final": True}
@@ -444,6 +468,7 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
return self._success_result( return self._success_result(
agent_info, outcome["exec_logs"], outcome["pid"], agent_info, outcome["exec_logs"], outcome["pid"],
run_start, run_end, hostname, alerts, run_start, run_end, hostname, alerts,
file_name=file_name,
) )
def _poll_alerts( def _poll_alerts(
@@ -526,24 +551,40 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
time.sleep(poll_interval) time.sleep(poll_interval)
@classmethod @classmethod
def _classify_kill(cls, exec_logs: dict) -> bool: def _classify_kill(
cls,
exec_logs: dict,
*,
filename: Optional[str] = None,
alerts: Optional[list] = None,
) -> bool:
"""If Whiskers didn't issue a kill and the process didn't exit """If Whiskers didn't issue a kill and the process didn't exit
cleanly, something external terminated it. In a LitterBox dispatch cleanly, something external terminated it. For .exe payloads on
the EDR is the only thing on the VM that does that — so we label an EDR-instrumented host, "something external" is overwhelmingly
it as a behavior-protection kill. the EDR — the heuristic alone is reliable.
Edge case: a payload that crashes on its own with a non-zero exit For .dll payloads spawned via rundll32, exit_code != 0 is much
code will get the same label. That's an acceptable false positive noisier: GetProcAddress failure on a missing export, DllMain
— when you're running suspect binaries on an EDR-instrumented host, returning FALSE, even a malformed DLL all produce non-zero exits
a non-zero exit you didn't ask for is overwhelmingly the EDR. with no EDR involvement. So for DLLs we additionally require at
least one Elastic alert as evidence before flagging killed_by_edr.
Phase 1 has no alerts yet, so for DLLs Phase 1 always returns
False; Phase 2 re-evaluates after correlation and may flip True.
""" """
raw_status = (exec_logs.get("status") or "").lower() raw_status = (exec_logs.get("status") or "").lower()
if raw_status == "killed": if raw_status == "killed":
# The agent issued the kill itself (orchestrator timeout). Not # The agent issued the kill itself (orchestrator timeout).
# an external termination.
return False return False
exit_code = exec_logs.get("exit_code") exit_code = exec_logs.get("exit_code")
return exit_code not in (0, None) if exit_code in (0, None):
return False
is_dll = bool(filename and filename.lower().endswith(".dll"))
if is_dll:
# DLL exit codes are unreliable — require alert evidence.
return bool(alerts)
return True
def _success_result( def _success_result(
self, self,
@@ -554,10 +595,15 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
run_end: datetime, run_end: datetime,
hostname: str, hostname: str,
alerts: List[Alert], alerts: List[Alert],
file_name: Optional[str] = None,
) -> dict: ) -> dict:
alert_dicts = [a.to_dict() for a in alerts] alert_dicts = [a.to_dict() for a in alerts]
high_severity_count = sum(1 for a in alerts if a.severity in HIGH_SEVERITY) high_severity_count = sum(1 for a in alerts if a.severity in HIGH_SEVERITY)
killed_by_edr = self._classify_kill(exec_logs) # Phase 2 has alerts in hand — feed them in so the .dll-via-rundll32
# case can confirm killed_by_edr only when alerts back it up.
killed_by_edr = self._classify_kill(
exec_logs, filename=file_name, alerts=alerts,
)
# Surface "killed_by_edr" as the user-facing exec_status when we # Surface "killed_by_edr" as the user-facing exec_status when we
# have evidence — the raw "exited" label is technically right but # have evidence — the raw "exited" label is technically right but
# actively misleading when behavior protection was the cause. # actively misleading when behavior protection was the cause.
@@ -584,6 +630,7 @@ class ElasticEdrAnalyzer(BaseAnalyzer):
"total_alerts": len(alert_dicts), "total_alerts": len(alert_dicts),
"high_severity_alerts": high_severity_count, "high_severity_alerts": high_severity_count,
"killed_by_edr": killed_by_edr, "killed_by_edr": killed_by_edr,
"blocked_by_av": False,
"run_start": run_start.isoformat(), "run_start": run_start.isoformat(),
"run_end": run_end.isoformat(), "run_end": run_end.isoformat(),
"wait_seconds_for_alerts": self.profile.wait_seconds_for_alerts, "wait_seconds_for_alerts": self.profile.wait_seconds_for_alerts,
+6 -1
View File
@@ -93,6 +93,7 @@ def dispatch_split(
payload_path: str, payload_path: str,
config: dict, config: dict,
on_phase_2_done: Callable[[dict], None], on_phase_2_done: Callable[[dict], None],
executable_args: Optional[str] = None,
) -> dict: ) -> dict:
"""Split-phase dispatch. """Split-phase dispatch.
@@ -103,6 +104,10 @@ def dispatch_split(
is called with the final findings dict. The callback is responsible is called with the final findings dict. The callback is responsible
for persisting the updated result. for persisting the updated result.
`executable_args` is forwarded to the agent's exec endpoint as a
single space-separated string. For DLL payloads the first token is
the exported entry point (rundll32 wraps it server-side).
Phase 2 errors are swallowed and surfaced to the callback as a Phase 2 errors are swallowed and surfaced to the callback as a
`status: 'error'` dict — the thread never raises into nothing. `status: 'error'` dict — the thread never raises into nothing.
""" """
@@ -111,7 +116,7 @@ def dispatch_split(
raise KeyError(f"unknown EDR profile: {profile_name!r}") raise KeyError(f"unknown EDR profile: {profile_name!r}")
analyzer = ElasticEdrAnalyzer(config, profile) analyzer = ElasticEdrAnalyzer(config, profile)
phase_1, continuation = analyzer.run_exec(payload_path) phase_1, continuation = analyzer.run_exec(payload_path, executable_args)
if continuation is None: if continuation is None:
# Terminal failure (busy, agent unreachable, missing file, etc.) — # Terminal failure (busy, agent unreachable, missing file, etc.) —
+30 -7
View File
@@ -251,7 +251,14 @@ class AnalysisManager:
self.logger.debug("Initializing RedEdr analyzer") self.logger.debug("Initializing RedEdr analyzer")
try: try:
target_name = target.split('\\')[-1] # For DLL targets we spawn `rundll32.exe <dll>,<entry>` — the
# actual running process is rundll32, not the DLL. RedEdr's
# --trace filter takes a process name, so point it at
# rundll32.exe to capture ETW from the DLL's host process.
if target.lower().endswith('.dll'):
target_name = 'rundll32.exe'
else:
target_name = target.split('\\')[-1]
rededr = RedEdrAnalyzer(self.config) rededr = RedEdrAnalyzer(self.config)
if not rededr.start_tool(target_name): if not rededr.start_tool(target_name):
self.logger.error("Failed to start RedEdr") self.logger.error("Failed to start RedEdr")
@@ -389,12 +396,28 @@ class AnalysisManager:
raise Exception(f"Invalid or non-existent PID: {e}") raise Exception(f"Invalid or non-existent PID: {e}")
def _create_new_process(self, target: str, cmd_args: list) -> Tuple[subprocess.Popen, int]: def _create_new_process(self, target: str, cmd_args: list) -> Tuple[subprocess.Popen, int]:
"""Create and validate new process""" """Create and validate new process. DLL targets are wrapped with
command = [target] rundll32.exe — Windows can't directly Popen a .dll, and the
if cmd_args: operator's first cmd_arg is treated as the exported entry point
command.extend(cmd_args) (mandatory for DLLs)."""
if target.lower().endswith('.dll'):
self.logger.debug(f"Starting new process: {command}") if not cmd_args:
raise Exception(
"DLL execution requires an entry point as the first "
"command-line argument (rundll32 syntax: <ExportName> "
"[args...])"
)
entry, *extra = cmd_args
# rundll32.exe expects: <dll>,<entry> [args...]
# The dll path and entry name are joined with a comma into a
# single argv slot so rundll32 parses them as one target spec.
command = ['rundll32.exe', f'{target},{entry}', *extra]
self.logger.debug(f"DLL target — wrapping with rundll32: {command}")
else:
command = [target]
if cmd_args:
command.extend(cmd_args)
self.logger.debug(f"Starting new process: {command}")
startupinfo = subprocess.STARTUPINFO() startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+25 -2
View File
@@ -166,6 +166,18 @@ def _handle_analysis_results(results, result_path, results_filename):
return jsonify({'status': 'success', 'results': results}) return jsonify({'status': 'success', 'results': results})
@analysis_bp.route('/whiskers', methods=['GET'])
def whiskers_page():
"""Render the Whiskers (EDR agents) inventory page. Live status data
is fetched async by the page's JS via /api/edr/agents/status."""
deps = current_app.extensions['litterbox']
return render_template(
'agents.html',
config=current_app.config,
edr_profiles=deps.edr_registry.list_profiles(),
)
@analysis_bp.route('/analyze/edr/<profile>/<target>', methods=['GET', 'POST']) @analysis_bp.route('/analyze/edr/<profile>/<target>', methods=['GET', 'POST'])
@error_handler @error_handler
def analyze_edr(profile, target): def analyze_edr(profile, target):
@@ -205,7 +217,17 @@ def analyze_edr(profile, target):
app.logger.warning(f"Result path not found for hash: {target}") app.logger.warning(f"Result path not found for hash: {target}")
return jsonify({'error': 'Result path not found'}), 404 return jsonify({'error': 'Result path not found'}), 404
app.logger.debug(f"Dispatching to EDR profile {profile!r} with payload {file_path}") # Pull cmd args from the POST body (validated/sanitized like the
# dynamic-analysis route does) and join into the single string
# AgentClient.exec expects. For DLL targets the first token is the
# exported entry point — Whiskers wraps with rundll32 server-side.
cmd_args = _extract_and_validate_args(request, app.logger)
executable_args = ' '.join(cmd_args) if cmd_args else None
app.logger.debug(
f"Dispatching to EDR profile {profile!r} with payload {file_path} "
f"args={executable_args!r}"
)
results_filename = f'edr_{profile}_results.json' results_filename = f'edr_{profile}_results.json'
# Phase 2 callback — runs on a background thread when alerts arrive. # Phase 2 callback — runs on a background thread when alerts arrive.
@@ -232,7 +254,8 @@ def analyze_edr(profile, target):
try: try:
results = deps.edr_registry.dispatch_split( results = deps.edr_registry.dispatch_split(
profile, file_path, app.config, _on_phase_2_done profile, file_path, app.config, _on_phase_2_done,
executable_args=executable_args,
) )
except Exception as e: except Exception as e:
app.logger.error(f"EDR dispatch failed: {e}", exc_info=True) app.logger.error(f"EDR dispatch failed: {e}", exc_info=True)
+74
View File
@@ -111,6 +111,80 @@ def api_edr_profiles():
return jsonify({'profiles': deps.edr_registry.list_profiles()}) return jsonify({'profiles': deps.edr_registry.list_profiles()})
@api_bp.route('/api/edr/agents/status', methods=['GET'])
@error_handler
def api_edr_agents_status():
"""Live probe of every registered EDR profile.
For each profile we hit the agent's /api/info + /api/lock/status and
the Elastic stack's `GET /` ping in parallel. Each probe is bounded
by a tight timeout so an unreachable agent doesn't stall the whole
page. Used by /whiskers to render the inventory + live status.
"""
from concurrent.futures import ThreadPoolExecutor
from ..analyzers.edr.agent_client import AgentClient, AgentError, AgentUnreachable
from ..analyzers.edr.elastic_client import ElasticClient, ElasticError, ElasticUnreachable
deps = _deps()
profiles = list(deps.edr_registry._PROFILES.values()) # internal accessor — same module
def probe(p):
agent = AgentClient(p.agent_url, timeout=4)
elastic = ElasticClient(
p.elastic_url, p.elastic_apikey,
verify_tls=p.elastic_verify_tls, timeout=5,
)
agent_info, agent_err, lock = None, None, None
try:
agent_info = agent.get_info()
try:
lock = agent.lock_status()
except (AgentUnreachable, AgentError):
pass
except AgentUnreachable as e:
agent_err = f"unreachable: {e}"
except AgentError as e:
agent_err = f"error: {e}"
elastic_info, elastic_err = None, None
try:
elastic_info = elastic.ping()
except ElasticUnreachable as e:
elastic_err = f"unreachable: {e}"
except ElasticError as e:
elastic_err = f"error: {e}"
return {
"name": p.name,
"display_name": p.display_name,
"type": "elastic-defend",
"agent_url": p.agent_url,
"elastic_url": p.elastic_url,
"agent": {
"reachable": agent_info is not None,
"error": agent_err,
"hostname": (agent_info or {}).get("hostname"),
"os_version": (agent_info or {}).get("os_version"),
"agent_version": (agent_info or {}).get("agent_version"),
},
"lock": lock,
"elastic": {
"reachable": elastic_info is not None,
"error": elastic_err,
"cluster_name": (elastic_info or {}).get("cluster_name"),
"version": ((elastic_info or {}).get("version") or {}).get("number"),
},
}
if not profiles:
return jsonify({"agents": []})
# Probe in parallel — total wall time is the slowest probe, not sum of all.
with ThreadPoolExecutor(max_workers=min(8, len(profiles))) as pool:
results = list(pool.map(probe, profiles))
return jsonify({"agents": results})
@api_bp.route('/api/results/<target>/edr/<profile>', methods=['GET']) @api_bp.route('/api/results/<target>/edr/<profile>', methods=['GET'])
@error_handler @error_handler
def api_edr_results(target, profile): def api_edr_results(target, profile):
+127
View File
@@ -0,0 +1,127 @@
// app/static/js/agents/core.js
//
// Drives the /agents inventory page. Hits /api/edr/agents/status to probe
// each registered EDR profile in parallel (server-side ThreadPool), then
// fills the per-card status fields. Refreshes every 15s; the manual
// Refresh button forces an immediate poll.
const REFRESH_MS = 60000;
let _refreshTimer = null;
let _inFlight = false;
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function setColor(id, color) {
const el = document.getElementById(id);
if (el) el.style.color = color;
}
function setDot(profile, kind /* 'ok' | 'down' | 'partial' | 'unknown' */, title) {
const el = document.getElementById(`agentDot-${profile}`);
if (!el) return;
el.className = `lb-agent-dot lb-agent-dot--${kind}`;
if (title) el.title = title;
}
function applyStatus(agent) {
const p = agent.name;
// Type badge — for now everything is `elastic-defend`, but the field
// is server-driven so future agent types render automatically.
setText(`agentType-${p}`, agent.type || 'unknown');
const a = agent.agent || {};
const e = agent.elastic || {};
// Aggregate dot: green if both sides reachable, yellow if only one,
// red if neither.
const reachable = (a.reachable ? 1 : 0) + (e.reachable ? 1 : 0);
if (reachable === 2) setDot(p, 'ok', 'Agent + Elastic reachable');
else if (reachable === 1) setDot(p, 'partial', 'Partial — see status fields');
else setDot(p, 'down', 'Agent + Elastic unreachable');
// Agent side
if (a.reachable) {
setText(`agentStatus-${p}`, 'Online');
setColor(`agentStatus-${p}`, 'var(--lb-sev-low)');
setText(`agentHostname-${p}`, a.hostname || '—');
setText(`agentOs-${p}`, a.os_version || '—');
setText(`agentVersion-${p}`, a.agent_version || '—');
} else {
setText(`agentStatus-${p}`, 'Offline');
setColor(`agentStatus-${p}`, 'var(--lb-accent-soft)');
setText(`agentHostname-${p}`, '—');
setText(`agentOs-${p}`, '—');
setText(`agentVersion-${p}`, '—');
}
// Lock
if (agent.lock) {
const inUse = !!agent.lock.in_use;
setText(`agentLock-${p}`, inUse ? 'Busy (run in progress)' : 'Idle');
setColor(`agentLock-${p}`, inUse ? 'var(--lb-sev-medium)' : 'var(--lb-sev-low)');
} else {
setText(`agentLock-${p}`, '—');
setColor(`agentLock-${p}`, 'var(--lb-text-mute)');
}
// Elastic side
if (e.reachable) {
const v = e.version ? ` v${e.version}` : '';
setText(`agentElastic-${p}`, `Reachable${v}`);
setColor(`agentElastic-${p}`, 'var(--lb-sev-low)');
setText(`agentCluster-${p}`, e.cluster_name || '—');
} else {
setText(`agentElastic-${p}`, 'Unreachable');
setColor(`agentElastic-${p}`, 'var(--lb-accent-soft)');
setText(`agentCluster-${p}`, '—');
}
// Combined error block (shows if anything failed)
const errEl = document.getElementById(`agentError-${p}`);
if (errEl) {
const errs = [];
if (a.error) errs.push(`Agent: ${a.error}`);
if (e.error) errs.push(`Elastic: ${e.error}`);
if (errs.length) {
errEl.textContent = errs.join(' · ');
errEl.classList.remove('hidden');
} else {
errEl.classList.add('hidden');
}
}
}
async function refreshAgents() {
if (_inFlight) return;
_inFlight = true;
const btn = document.getElementById('agentsRefreshBtn');
if (btn) btn.disabled = true;
try {
const resp = await fetch('/api/edr/agents/status', { cache: 'no-store' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
for (const agent of (data.agents || [])) applyStatus(agent);
} catch (err) {
console.error('[agents] refresh failed:', err);
} finally {
_inFlight = false;
if (btn) btn.disabled = false;
}
}
// Expose for the inline onclick on the Refresh button.
window.refreshAgents = refreshAgents;
document.addEventListener('DOMContentLoaded', () => {
refreshAgents();
_refreshTimer = setInterval(refreshAgents, REFRESH_MS);
});
window.addEventListener('beforeunload', () => {
if (_refreshTimer) clearInterval(_refreshTimer);
});
+2 -4
View File
@@ -124,10 +124,8 @@ class AnalysisCore {
// the duration timer running and don't flip the status to // the duration timer running and don't flip the status to
// "Analysis completed". The EDR poll handler will stop the // "Analysis completed". The EDR poll handler will stop the
// timer + flip status when Phase 2 lands a terminal result. // timer + flip status when Phase 2 lands a terminal result.
const edrPolling = !!(data.results && data.results.edr && ( const edrPolling = !!(data.results && data.results.edr &&
data.results.edr.status === 'polling_alerts' || data.results.edr.status === 'polling_alerts');
data.results.edr.status === 'blocked_polling_alerts'
));
this.updateTimer(); this.updateTimer();
if (!edrPolling) { if (!edrPolling) {
+13 -11
View File
@@ -4,15 +4,18 @@
// //
// The orchestrator runs in two phases: // The orchestrator runs in two phases:
// Phase 1 (exec) — sync over HTTP; returns when the agent finishes // Phase 1 (exec) — sync over HTTP; returns when the agent finishes
// spawning the payload (success or AV block) // spawning the payload (success or EDR block)
// Phase 2 (correlation) — async on the server (background thread); // Phase 2 (correlation) — async on the server (background thread);
// polls Elastic for alerts, overwrites the saved // polls Elastic for alerts, overwrites the saved
// findings JSON when done. // findings JSON when done.
// //
// On the initial POST response we render whatever Phase 1 produced. If // On the initial POST response we render whatever Phase 1 produced. If
// the status is `polling_alerts` or `blocked_polling_alerts`, we kick // the status is `polling_alerts`, we kick off a foreground poll of GET
// off a foreground poll of GET /api/results/<hash>/edr/<profile> so the // /api/results/<hash>/edr/<profile> so the alerts pane and summary chip
// alerts pane and summary chip reflect Phase 2 progress in real time. // reflect Phase 2 progress in real time. The block-vs-clean-exec
// distinction is carried in `summary.blocked_by_av` — Phase 2 doesn't
// fork its status on it because the polling itself is purely between
// LitterBox and Elastic, regardless of what the EDR VM did.
// //
// Targets: // Targets:
// - #edrSummary (summary tab) // - #edrSummary (summary tab)
@@ -25,7 +28,7 @@ import { errorPanel, cleanState, threatState, statRow, panel, kvGrid, codeBlock,
import summaryTool from './summary.js'; import summaryTool from './summary.js';
const HIGH_SEVERITY = new Set(['high', 'critical']); const HIGH_SEVERITY = new Set(['high', 'critical']);
const POLLING_STATUSES = new Set(['polling_alerts', 'blocked_polling_alerts']); const POLLING_STATUS = 'polling_alerts';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
// Module-level handle so a re-render with a new payload aborts the old // Module-level handle so a re-render with a new payload aborts the old
@@ -40,7 +43,7 @@ function clearPoll() {
} }
function isPolling(results) { function isPolling(results) {
return POLLING_STATUSES.has(results?.status); return results?.status === POLLING_STATUS;
} }
function severityRank(s) { function severityRank(s) {
@@ -110,8 +113,7 @@ function renderAlerts(results) {
// the stat chip stays the same width as the numeric ones beside it. // the stat chip stays the same width as the numeric ones beside it.
const STATUS_LABELS = { const STATUS_LABELS = {
'completed': 'Completed', 'completed': 'Completed',
'blocked_by_av': 'AV Block', 'blocked_by_av': 'EDR Block',
'blocked_polling_alerts': 'AV Block',
'polling_alerts': 'Polling…', 'polling_alerts': 'Polling…',
'partial': 'Partial', 'partial': 'Partial',
'busy': 'Busy', 'busy': 'Busy',
@@ -122,7 +124,7 @@ function renderAlerts(results) {
const statusSeverity = ( const statusSeverity = (
status === 'completed' && totalAlerts === 0 ? 'clean' : status === 'completed' && totalAlerts === 0 ? 'clean' :
status === 'completed' ? 'critical' : status === 'completed' ? 'critical' :
status === 'blocked_by_av' || status === 'blocked_polling_alerts' ? 'critical' : status === 'blocked_by_av' ? 'critical' :
status === 'polling_alerts' ? 'info' : status === 'polling_alerts' ? 'info' :
status === 'partial' ? 'medium' : status === 'partial' ? 'medium' :
'critical' 'critical'
@@ -158,8 +160,8 @@ function renderAlerts(results) {
// Phase 2 in flight on the server — show a busy indicator in the // Phase 2 in flight on the server — show a busy indicator in the
// alerts pane until the next poll updates this state. // alerts pane until the next poll updates this state.
const max = summary.wait_seconds_for_alerts || '?'; const max = summary.wait_seconds_for_alerts || '?';
const blockedHint = status === 'blocked_polling_alerts' const blockedHint = summary.blocked_by_av
? ' The AV blocked the spawn; we are correlating against the prevention alert.' ? ' The EDR blocked the spawn; we are correlating against the prevention alert.'
: ''; : '';
target.innerHTML = ` target.innerHTML = `
<div class="lb-empty" style="flex-direction: column; padding: 24px 16px; gap: 8px; align-items: flex-start;"> <div class="lb-empty" style="flex-direction: column; padding: 24px 16px; gap: 8px; align-items: flex-start;">
+3 -6
View File
@@ -159,10 +159,10 @@ export default {
else if (status === 'busy') { detail = 'Agent busy with another run'; isFailureState = true; } else if (status === 'busy') { detail = 'Agent busy with another run'; isFailureState = true; }
else if (status === 'partial') { detail = 'Run completed but Elastic query failed'; isFailureState = true; } else if (status === 'partial') { detail = 'Run completed but Elastic query failed'; isFailureState = true; }
else if (status === 'error') { detail = `Error: ${r.error || 'unknown'}`; isFailureState = true; } else if (status === 'error') { detail = `Error: ${r.error || 'unknown'}`; isFailureState = true; }
else if (status === 'polling_alerts' && summary.blocked_by_av) { detail = 'EDR blocked spawn — correlating alerts…'; isPolling = true; }
else if (status === 'polling_alerts' && killedByEdr) { detail = 'Killed by EDR — correlating alerts…'; isPolling = true; } else if (status === 'polling_alerts' && killedByEdr) { detail = 'Killed by EDR — correlating alerts…'; isPolling = true; }
else if (status === 'polling_alerts') { detail = 'Exec finished — correlating alerts…'; isPolling = true; } else if (status === 'polling_alerts') { detail = 'Exec finished — correlating alerts…'; isPolling = true; }
else if (status === 'blocked_polling_alerts'){ detail = 'AV blocked spawn — correlating alerts…'; isPolling = true; } else if (status === 'blocked_by_av') detail = 'Blocked by EDR before execution';
else if (status === 'blocked_by_av') detail = 'Blocked by AV before execution';
else if (killedByEdr && totalAlerts > 0) detail = `Killed by EDR · ${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`; else if (killedByEdr && totalAlerts > 0) detail = `Killed by EDR · ${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`;
else if (totalAlerts > 0) detail = `${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`; else if (totalAlerts > 0) detail = `${totalAlerts} alert${totalAlerts === 1 ? '' : 's'} raised`;
else detail = 'No alerts raised'; else detail = 'No alerts raised';
@@ -178,10 +178,7 @@ export default {
// poll window haven't finished correlating yet — show that // poll window haven't finished correlating yet — show that
// explicitly instead of a misleading "Clean" green when the // explicitly instead of a misleading "Clean" green when the
// count happens to be 0. // count happens to be 0.
const edrPolling = !!(results.edr && ( const edrPolling = !!(results.edr && results.edr.status === 'polling_alerts');
results.edr.status === 'polling_alerts' ||
results.edr.status === 'blocked_polling_alerts'
));
const totalEl = document.getElementById('totalDetections'); const totalEl = document.getElementById('totalDetections');
const overEl = document.getElementById('overallStatus'); const overEl = document.getElementById('overallStatus');
if (totalEl) totalEl.textContent = totalDetections; if (totalEl) totalEl.textContent = totalDetections;
+8 -2
View File
@@ -668,9 +668,15 @@ document.addEventListener('DOMContentLoaded', function() {
// Navigate to dynamic analysis // Navigate to dynamic analysis
window.location.href = `/analyze/${type}/${currentFileHash}`; window.location.href = `/analyze/${type}/${currentFileHash}`;
} else if (type.startsWith('edr:')) { } else if (type.startsWith('edr:')) {
// EDR profile dispatch: type is "edr:<profile_name>" // EDR profile dispatch: type is "edr:<profile_name>".
// Server-side route is /analyze/edr/<profile>/<hash>. // Each profile body has its own args input (id =
// edrArgs-<profile>); read it, persist to localStorage so
// the results page's POST forwards it to Whiskers.
const profile = type.slice(4); const profile = type.slice(4);
const argsInput = document.getElementById(`edrArgs-${profile}`);
const argsValue = argsInput ? argsInput.value : '';
const args = argsValue.split(' ').filter(arg => arg.trim() !== '');
localStorage.setItem('analysisArgs', JSON.stringify(args));
window.location.href = `/analyze/edr/${encodeURIComponent(profile)}/${currentFileHash}`; window.location.href = `/analyze/edr/${encodeURIComponent(profile)}/${currentFileHash}`;
} else { } else {
// Navigate to static analysis // Navigate to static analysis
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block breadcrumb %}Whiskers{% endblock %}
{% set active_nav = 'whiskers' %}
{% block content %}
{% if not edr_profiles %}
<!-- No profiles registered — point the operator at the docs. -->
<div class="lb-panel">
<div class="lb-panel-hdr"><span class="lb-glyph"></span>Agents</div>
<div class="lb-panel-body">
<div class="lb-empty" style="flex-direction: column; padding: 24px; gap: 8px;">
<div class="lb-strong">No EDR profiles registered</div>
<div class="lb-muted" style="font-size: 12px; text-align: center; max-width: 600px;">
Drop a YAML at <span class="lb-mono">Config/edr_profiles/&lt;name&gt;.yml</span>
(copy from <span class="lb-mono">elastic.yml.example</span>) and restart LitterBox.
See the <span class="lb-mono">README.md</span> "Elastic EDR Setup" section
for the full deployment guide.
</div>
</div>
</div>
</div>
{% else %}
<div class="lb-panel">
<div class="lb-panel-hdr">
<span class="lb-glyph"></span>EDR Agents
<span id="agentsCount" class="lb-tag muted" style="margin-left: 12px;">{{ edr_profiles|length }} registered</span>
<button id="agentsRefreshBtn" onclick="refreshAgents()" class="lb-btn lb-btn-ghost"
style="margin-left: auto; padding: 2px 10px; font-size: 12px;">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-5M20 14a8 8 0 01-14 5"/>
</svg>
Refresh
</button>
</div>
<div class="lb-panel-body">
<p class="lb-muted" style="font-size: 12px; margin: 0 0 4px 0;">
One card per profile registered under
<span class="lb-mono">Config/edr_profiles/</span>. The Whiskers agent + Elastic stack
are probed in parallel; status refreshes every minute.
</p>
</div>
</div>
<div id="agentsGrid" class="lb-grid-2" style="margin-top: 12px;">
{% for profile in edr_profiles %}
<div class="lb-agent-card" data-profile="{{ profile.name }}">
<div class="lb-agent-card-hdr">
<div class="lb-agent-card-title">
<span id="agentDot-{{ profile.name }}" class="lb-agent-dot lb-agent-dot--unknown" title="Probing…"></span>
<span class="lb-strong" style="font-size: 14px;">{{ profile.display_name }}</span>
<span class="lb-tag info" style="margin-left: 8px;">EDR Agent</span>
</div>
<div class="lb-muted" style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;">
<span id="agentType-{{ profile.name }}">elastic-defend</span>
</div>
</div>
<div class="lb-agent-card-body">
<div class="lb-agent-row">
<span class="lb-eyebrow">Status</span>
<span id="agentStatus-{{ profile.name }}" class="lb-muted">Probing…</span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Hostname</span>
<span id="agentHostname-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">OS</span>
<span id="agentOs-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Agent Version</span>
<span id="agentVersion-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Lock</span>
<span id="agentLock-{{ profile.name }}" class="lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Agent URL</span>
<span class="lb-mono" style="font-size: 11px;">{{ profile.agent_url }}</span>
</div>
<div class="lb-agent-divider"></div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Elastic</span>
<span id="agentElastic-{{ profile.name }}" class="lb-muted">Probing…</span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Cluster</span>
<span id="agentCluster-{{ profile.name }}" class="lb-mono lb-muted"></span>
</div>
<div class="lb-agent-row">
<span class="lb-eyebrow">Elastic URL</span>
<span class="lb-mono" style="font-size: 11px; word-break: break-all;">{{ profile.elastic_url }}</span>
</div>
<div id="agentError-{{ profile.name }}" class="lb-agent-error hidden"></div>
</div>
</div>
{% endfor %}
</div>
<style>
.lb-agent-card {
display: flex; flex-direction: column;
border: 1px solid var(--lb-border-hi);
background: var(--lb-panel);
}
.lb-agent-card-hdr {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: var(--lb-bg-soft);
border-bottom: 1px solid var(--lb-border);
}
.lb-agent-card-title { display: flex; align-items: center; gap: 8px; }
.lb-agent-card-body {
display: flex; flex-direction: column; gap: 6px;
padding: 12px 14px;
}
.lb-agent-row {
display: flex; align-items: baseline; justify-content: space-between;
gap: 12px; min-height: 18px;
}
.lb-agent-row .lb-eyebrow { flex: 0 0 auto; }
.lb-agent-row > :last-child {
text-align: right; font-size: 12px; word-break: break-all;
}
.lb-agent-divider {
border-top: 1px solid var(--lb-border);
margin: 4px -14px;
}
.lb-agent-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.lb-agent-dot--ok { background: var(--lb-sev-low); box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.12); }
.lb-agent-dot--down { background: var(--lb-accent); box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.12); }
.lb-agent-dot--partial { background: var(--lb-sev-medium); box-shadow: 0 0 0 3px rgba(250, 204, 21, 0.12); }
.lb-agent-dot--unknown { background: var(--lb-text-mute); }
.lb-agent-error {
margin-top: 6px; padding: 8px 10px;
background: rgba(248, 113, 113, 0.06);
border-left: 2px solid var(--lb-accent);
font-size: 11px; color: var(--lb-accent-soft);
word-break: break-all;
}
</style>
{% endif %}
{% endblock %}
{% block scripts %}
<script type="module" src="{{ url_for('static', filename='js/agents/core.js') }}"></script>
{% endblock %}
+8
View File
@@ -89,6 +89,14 @@
<span class="lb-nav-text">Summary</span> <span class="lb-nav-text">Summary</span>
</button> </button>
<a href="/whiskers" class="lb-nav-item{% if active_nav == 'whiskers' %} active{% endif %}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17a4 4 0 008 0M5 17a8 8 0 0116 0H5z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 13V3M9 6l3-3 3 3"/>
</svg>
<span class="lb-nav-text">Whiskers</span>
</a>
<button onclick="cleanupSystem()" class="lb-nav-item"> <button onclick="cleanupSystem()" class="lb-nav-item">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
+7 -1
View File
@@ -134,7 +134,7 @@
<div id="argsInputContainer"> <div id="argsInputContainer">
<label for="analysisArgs" class="lb-eyebrow" style="display: block; margin-bottom: 6px;">Command-line Arguments</label> <label for="analysisArgs" class="lb-eyebrow" style="display: block; margin-bottom: 6px;">Command-line Arguments</label>
<input type="text" id="analysisArgs" placeholder="Enter arguments separated by spaces" class="lb-input lb-mono"/> <input type="text" id="analysisArgs" placeholder="Enter arguments separated by spaces" class="lb-input lb-mono"/>
<p class="lb-muted" style="margin-top: 6px; font-size: 12px;">Arguments passed to the payload at execution time.</p> <p class="lb-muted" style="margin-top: 6px; font-size: 12px;">Arguments passed to the payload at execution time. <span class="lb-strong">For DLL files</span> the first argument is the exported entry point (e.g. <span class="lb-mono">DllMain</span>) — passed to <span class="lb-mono">rundll32.exe</span>.</p>
</div> </div>
<button type="button" onclick="selectAnalysisType('dynamic')" class="lb-mode-cta"> <button type="button" onclick="selectAnalysisType('dynamic')" class="lb-mode-cta">
Run Dynamic Scan Run Dynamic Scan
@@ -154,6 +154,12 @@
<li class="lb-ok">EDR Alert Correlation</li> <li class="lb-ok">EDR Alert Correlation</li>
<li class="lb-ok">Execution Log Capture</li> <li class="lb-ok">Execution Log Capture</li>
</ul> </ul>
<div class="edr-args-container">
<label for="edrArgs-{{ profile.name }}" class="lb-eyebrow" style="display: block; margin-bottom: 6px;">Command-line Arguments</label>
<input type="text" id="edrArgs-{{ profile.name }}" data-edr-args="{{ profile.name }}"
placeholder="Enter arguments separated by spaces" class="lb-input lb-mono"/>
<p class="lb-muted" style="margin-top: 6px; font-size: 12px;">Forwarded to the spawned process on the EDR VM. <span class="lb-strong">For DLL files</span> the first argument is the exported entry point (e.g. <span class="lb-mono">DllMain</span>) — passed to <span class="lb-mono">rundll32.exe</span>.</p>
</div>
<button type="button" onclick="selectAnalysisType('edr:{{ profile.name }}')" class="lb-mode-cta"> <button type="button" onclick="selectAnalysisType('edr:{{ profile.name }}')" class="lb-mode-cta">
Run with {{ profile.display_name }} Run with {{ profile.display_name }}
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg> <svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>