From 99f062edb3cc482f1c6202059b69268a4f55799b Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 17:56:51 -0600 Subject: [PATCH 1/2] feat: add port 445 availability check before relay bind with configurable timeout **Added:** - Added `bind_check` option to `RunOptions` to control wait time for port 445 to become free before spawning ntlmrelayx - Implemented `wait_for_port_free` async function to poll port availability with detailed error reporting - Introduced tests for `wait_for_port_free` covering both free and held port scenarios **Changed:** - Updated relay startup logic to use the new port availability check, providing actionable error output if port 445 remains occupied - Modified test setup to disable port check by default for faster, unaffected test runs --- ares-tools/src/coercion.rs | 96 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/ares-tools/src/coercion.rs b/ares-tools/src/coercion.rs index 20deddec..7bac0ad1 100644 --- a/ares-tools/src/coercion.rs +++ b/ares-tools/src/coercion.rs @@ -380,6 +380,12 @@ struct RunOptions { /// invocations across worker processes; unit tests set `false` so they /// can run in parallel without fighting over the loopback sentinel port. acquire_host_lock: bool, + /// How long to wait for port 445 to become free after + /// `cleanup_stale_listeners` before bailing with `RELAY_BIND_BUSY`. A + /// TIME_WAIT socket from a prior bind typically clears in ~30s on + /// Linux; an unmanaged smbd / samba-vfs holder never clears, so we + /// surface the situation rather than letting ntlmrelayx crash. + bind_check: Duration, } impl RunOptions { @@ -394,10 +400,48 @@ impl RunOptions { relay_kill_timeout: Duration::from_secs(5), keep_workdir_on_capture: true, acquire_host_lock: true, + bind_check: Duration::from_secs(10), } } } +/// Wait for the given TCP port to become free on `0.0.0.0`. Polls every +/// 250ms via a connect probe to `127.0.0.1:`; a connection refused +/// means nothing is listening. Returns `Ok(())` as soon as the port is +/// free, `Err(reason)` if `timeout` elapses while it's still held. +async fn wait_for_port_free(port: u16, timeout: Duration) -> std::result::Result<(), String> { + use tokio::net::TcpStream; + let deadline = std::time::Instant::now() + timeout; + let addr = format!("127.0.0.1:{port}"); + let mut last_reason; + loop { + let probe = + tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(&addr)).await; + match probe { + // Connection refused → no listener → port is free. + Ok(Err(e)) if e.kind() == std::io::ErrorKind::ConnectionRefused => { + return Ok(()); + } + // Connected → something is listening on the port. + Ok(Ok(_)) => { + last_reason = format!("listener still bound on 127.0.0.1:{port}"); + } + // Probe timeout — usually firewalled; treat as held for safety. + Err(_) => { + last_reason = format!("connect probe to 127.0.0.1:{port} timed out"); + } + // Other connect error — surface verbatim. + Ok(Err(e)) => { + last_reason = format!("probe error: {e}"); + } + } + if std::time::Instant::now() >= deadline { + return Err(last_reason); + } + sleep(Duration::from_millis(250)).await; + } +} + // --- Real (production) implementation ------------------------------- struct RealCoerceProcs; @@ -684,6 +728,34 @@ async fn run_relay_and_coerce( procs.cleanup_stale_listeners(&workdir).await; + // Port 445 must be free before ntlmrelayx binds, or the spawn dies + // immediately with `OSError [Errno 98] Address already in use`. The + // pkill in `cleanup_stale_listeners` handles ntlmrelayx/Responder + // processes we started, but a non-impacket holder (system `smbd`, + // socket lingering in TIME_WAIT) survives. Poll the port and either + // wait it out or bail with a clear actionable error — both outcomes + // are better than feeding the LLM a generic bind-failed log. + // + // `bind_check == 0` disables the probe entirely (used by unit tests + // whose mock procs don't actually spawn ntlmrelayx). + if !opts.bind_check.is_zero() { + if let Err(busy) = wait_for_port_free(445, opts.bind_check).await { + return Ok(ToolOutput { + stdout: format!( + "RELAY_BIND_BUSY\nport 445 still occupied after pkill + {ms}ms wait. \ + Either another SMB listener (smbd, samba-vfs, an unmanaged \ + ntlmrelayx) is holding it, or a TIME_WAIT socket from a prior \ + bind is lingering. Check with `ss -tlnp '( sport = :445 )'` on \ + the worker host. Last error: {busy}", + ms = opts.bind_check.as_millis() + ), + stderr: String::new(), + exit_code: Some(0), + success: false, + }); + } + } + let target_url = format!("http://{}/certsrv/certfnsh.asp", cfg.ca_host); let mut relay = procs .spawn_relay(&target_url, &cfg.template, &relay_log, &workdir) @@ -1521,6 +1593,10 @@ mod tests { // Tests run in parallel and would otherwise fight over the // single host-wide loopback sentinel port. acquire_host_lock: false, + // Effectively skip the port-445 free check in tests — the mock + // CoerceProcs doesn't actually bind anywhere, and a non-zero + // wait_for_port_free probe would still slow the suite. + bind_check: Duration::from_millis(0), } } @@ -1846,4 +1922,24 @@ MIIBlahSecondCert==\n\ let args = json!({}); assert!(ntlmrelayx_multirelay(&args).await.is_ok()); } + + #[tokio::test] + async fn wait_for_port_free_returns_ok_when_port_unused() { + // High-numbered ephemeral port that nothing is listening on. The probe + // should connect-refused immediately and return Ok. + let port = 41446; // adjacent to RELAY_LOCK_PORT but unused + let r = super::wait_for_port_free(port, std::time::Duration::from_millis(500)).await; + assert!(r.is_ok(), "expected free port, got: {r:?}"); + } + + #[tokio::test] + async fn wait_for_port_free_returns_err_when_port_held() { + use tokio::net::TcpListener; + // Bind a listener so the probe sees it as held. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let r = super::wait_for_port_free(port, std::time::Duration::from_millis(200)).await; + assert!(r.is_err(), "expected held port, got: {r:?}"); + // Listener is dropped at end of scope, releasing the port. + } } From 2a3f5db73cf8b8d8d9a98c1399cb0ec5ad8da435 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Tue, 12 May 2026 18:04:06 -0600 Subject: [PATCH 2/2] fix: ensure exploited tokens are credited for deterministic ESC1 and ESC3 chains **Added:** - Explicitly call `mark_exploited` in deterministic ESC1 and ESC3 chains to credit exploited tokens when standard result processing is bypassed - Add warning logs for failures when marking ESC1 or ESC3 as exploited after chain success --- .../automation/adcs_exploitation.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs index 1b63f794..b52da6cd 100644 --- a/ares-cli/src/orchestrator/automation/adcs_exploitation.rs +++ b/ares-cli/src/orchestrator/automation/adcs_exploitation.rs @@ -688,6 +688,24 @@ async fn dispatch_esc1_deterministic(dispatcher: &Arc, item: &AdcsEx }; if succeeded { + // Credit the ADCS primitive on the scoreboard. The deterministic + // chain runs `certipy_esc1_full_chain` via `dispatch_tool`, which + // produces a `esc1_chain_*` task_id — that does NOT match the + // `exploit_*` prefix gate in result_processing, so the standard + // mark_exploited path never fires. Without this call, ESC1 lands + // a working NTLM hash but the `adcs_esc1_*` token is never + // added to `:exploited`. + if let Err(e) = dispatcher_bg + .state + .mark_exploited(&dispatcher_bg.queue, &vuln_id_bg) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id_bg, + "Failed to mark ESC1 exploited (chain succeeded but token not emitted)" + ); + } info!( vuln_id = %vuln_id_bg, "ESC1 chain succeeded — NTLM hash published; auto_credential_reuse will DCSync the foreign DC" @@ -846,6 +864,21 @@ async fn dispatch_esc3_deterministic(dispatcher: &Arc, item: &AdcsEx }; if succeeded { + // Same scoreboard-credit gap as ESC1: the deterministic chain + // bypasses the `exploit_*` task_id gate in result_processing, so + // the standard mark_exploited path never fires. Stamp it + // explicitly here. + if let Err(e) = dispatcher_bg + .state + .mark_exploited(&dispatcher_bg.queue, &vuln_id_bg) + .await + { + warn!( + err = %e, + vuln_id = %vuln_id_bg, + "Failed to mark ESC3 exploited (chain succeeded but token not emitted)" + ); + } info!( vuln_id = %vuln_id_bg, "ESC3 chain succeeded — dedup locked, discoveries published by tool dispatcher"