From 7549e72b92391876e4d18ac8b1f622cc61ecced6 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 13 May 2026 10:23:43 -0600 Subject: [PATCH] feat: add cross-forest OPENQUERY fallback for MSSQL link pivots **Added:** - Implement cross-forest fallback using OPENQUERY when EXEC AT fails with Kerberos double-hop or SSPI errors, enabling stored linked-login mapping for successful pivots - Add `classify_probe_result` function to unify probe result classification for EXEC AT and OPENQUERY paths - Add `probe_failure_is_cross_forest_shape` function to detect specific cross-forest authentication failure signatures and restrict fallback to relevant cases - Implement `run_openquery_fallback` to dispatch fallback probes and report outcomes appropriately - Extend tests to cover cross-forest error detection, classifier correctness, and fallback logic **Changed:** - Refactor probe result handling to use `classify_probe_result`, simplifying and unifying outcome logic for probe attempts --- .../automation/mssql_link_pivot.rs | 274 +++++++++++++++++- 1 file changed, 268 insertions(+), 6 deletions(-) diff --git a/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs b/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs index 7dc04e1a..b53358e1 100644 --- a/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs +++ b/ares-cli/src/orchestrator/automation/mssql_link_pivot.rs @@ -220,20 +220,134 @@ async fn run_pivot_probe(dispatcher: Arc, item: PivotWork) { .dispatch_tool("lateral", &task_id, &call) .await; - let outcome = match result { + let outcome = classify_probe_result(&result); + + // Cross-forest fallback: when `EXEC AT [link]` fails with a shape that + // looks like Kerberos double-hop / SSPI rejection, retry the same + // probe through `OPENQUERY([link], ...)` which uses the linked + // server's stored `sp_addlinkedsrvlogin` mapping and bypasses + // delegation entirely. This is the canonical cross-forest pivot + // path documented in `auto_mssql_exploitation` (the LLM prompt + // already names it, but the deterministic chain never tried it). + let outcome = match outcome { + ProbeOutcome::Confirmed(o) => ProbeOutcome::Confirmed(o), + other if probe_failure_is_cross_forest_shape(&other) => { + info!( + vuln_id = %item.vuln_id, + target = %item.target_ip, + linked_server = %item.linked_server, + first_summary = %describe_outcome(&other), + "MSSQL link pivot: EXEC AT failed with cross-forest auth shape — \ + retrying via OPENQUERY (stored linked-login mapping bypasses double-hop)" + ); + run_openquery_fallback(&dispatcher, &item, other).await + } + other => other, + }; + + handle_probe_outcome(&dispatcher, &item, outcome).await; +} + +/// Wrap the `dispatch_tool` result into a `ProbeOutcome` according to the +/// `mssql_exec_linked` / `mssql_openquery` contract: tool error → ToolError, +/// stdout matches the probe column header → Confirmed, otherwise NoEvidence. +/// Extracted so the EXEC AT and OPENQUERY paths share one classifier. +fn classify_probe_result(result: &anyhow::Result) -> ProbeOutcome { + match result { Ok(exec) => { - if let Some(err) = exec.error { - ProbeOutcome::ToolError(err, exec.output) + if let Some(err) = exec.error.clone() { + ProbeOutcome::ToolError(err, exec.output.clone()) } else if probe_output_is_remote_select(&exec.output) { - ProbeOutcome::Confirmed(exec.output) + ProbeOutcome::Confirmed(exec.output.clone()) } else { - ProbeOutcome::NoEvidence(exec.output) + ProbeOutcome::NoEvidence(exec.output.clone()) } } Err(e) => ProbeOutcome::DispatchFailure(e.to_string()), + } +} + +/// Cross-forest signature on a failed `mssql_exec_linked` probe. The +/// `EXEC AT [link]` hop double-hops the principal's identity to the linked +/// server, which a cross-forest trust does not allow without explicit +/// Kerberos delegation. The resulting SQL Server error surface is narrow +/// and stable across versions: +/// - `Login failed for user '\'` — SQL accepted the +/// source-side connection then rejected the cross-link auth +/// - `Cannot generate SSPI context` — Kerberos failed to materialise a +/// service ticket for the linked server (the classic double-hop tell) +/// - `SSPI handshake failed` — same root cause, surface from newer +/// impacket / SQL builds +/// - `KDC_ERR_*` — explicit Kerberos error punted up by impacket's +/// krb5 stack +/// - `the trust relationship between this workstation and the primary +/// domain failed` — surfaces on older SQL builds +/// +/// We deliberately keep this narrow: a generic "remote query is disabled" +/// or "linked server does not exist" should NOT trigger the OPENQUERY +/// retry — those are configuration issues on the link, not auth issues +/// that OPENQUERY's stored-cred path could route around. +fn probe_failure_is_cross_forest_shape(outcome: &ProbeOutcome) -> bool { + let (err, out) = match outcome { + ProbeOutcome::ToolError(e, o) => (e.as_str(), o.as_str()), + ProbeOutcome::NoEvidence(o) => ("", o.as_str()), + // DispatchFailure is a transport / queue error — not an auth + // shape, so OPENQUERY wouldn't help. Bail. + ProbeOutcome::DispatchFailure(_) | ProbeOutcome::Confirmed(_) => return false, }; + let blob = format!("{err}\n{out}").to_ascii_lowercase(); + blob.contains("login failed for user") + || blob.contains("cannot generate sspi context") + || blob.contains("sspi handshake failed") + || blob.contains("kdc_err_") + || blob.contains("the trust relationship") + || blob.contains("double-hop") + || blob.contains("delegation not permitted") +} - handle_probe_outcome(&dispatcher, &item, outcome).await; +/// Dispatch the OPENQUERY fallback after EXEC AT failed cross-forest. The +/// same `PROBE_QUERY` flows through `OPENQUERY([link], '')` which +/// rides the stored remote login (`sp_addlinkedsrvlogin`) instead of +/// double-hopping the connecting principal's identity. If OPENQUERY also +/// fails, return the first-attempt outcome so the failure summary in +/// `handle_probe_outcome` stays the more diagnostic EXEC AT error. +async fn run_openquery_fallback( + dispatcher: &Dispatcher, + item: &PivotWork, + first_outcome: ProbeOutcome, +) -> ProbeOutcome { + let tool_args = build_probe_args(item); + let task_id = format!( + "mssql_link_pivot_oq_{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let call = ToolCall { + id: format!("mssql_openquery_{}", uuid::Uuid::new_v4().simple()), + name: "mssql_openquery".to_string(), + arguments: tool_args, + }; + + let result = dispatcher + .llm_runner + .tool_dispatcher() + .dispatch_tool("lateral", &task_id, &call) + .await; + + let oq_outcome = classify_probe_result(&result); + if matches!(oq_outcome, ProbeOutcome::Confirmed(_)) { + info!( + vuln_id = %item.vuln_id, + linked_server = %item.linked_server, + "MSSQL link pivot: OPENQUERY fallback confirmed cross-forest hop \ + (stored linked-login mapping); EXEC AT was blocked by double-hop" + ); + oq_outcome + } else { + // OPENQUERY didn't surface evidence either. Surface the first + // attempt's outcome so the failure summary captures the EXEC AT + // error (more diagnostic than OPENQUERY's "no rows" line). + first_outcome + } } #[derive(Debug)] @@ -791,4 +905,152 @@ mod tests { assert_eq!(resolve_linked_server_host_ip(&state, ""), None); assert_eq!(resolve_linked_server_host_ip(&state, "SQL01"), None); } + + // ── probe_failure_is_cross_forest_shape ──────────────────────────── + + #[test] + fn cross_forest_shape_matches_login_failed_for_user() { + // Classic cross-forest double-hop failure: SQL accepts the + // source-side connection then rejects the cross-link auth with + // a `Login failed for user '\'` row. + let outcome = ProbeOutcome::ToolError( + "exit 1".into(), + "Msg 18456, Level 14, State 1\n\ + Login failed for user 'FOREST1\\alice'." + .into(), + ); + assert!(probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_matches_sspi_context() { + let outcome = ProbeOutcome::ToolError( + "exit 1".into(), + "OLE DB provider \"MSOLEDBSQL\" for linked server \"SQL02\" returned message \ + \"Cannot generate SSPI context\"." + .into(), + ); + assert!(probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_matches_sspi_handshake() { + let outcome = ProbeOutcome::ToolError( + "exit 1".into(), + "ERROR: SSPI handshake failed during NEGOTIATE phase".into(), + ); + assert!(probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_matches_kdc_err() { + let outcome = + ProbeOutcome::ToolError("auth".into(), "krb5: KDC_ERR_S_PRINCIPAL_UNKNOWN".into()); + assert!(probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_matches_no_evidence_with_sspi_log() { + // Tool exited 0 (impacket's mssqlclient.py can swallow some MSSQL + // errors into stdout) but stdout carries the SSPI trace — still + // worth retrying via OPENQUERY. + let outcome = + ProbeOutcome::NoEvidence("Connecting...\n[!] Cannot generate SSPI context\n".into()); + assert!(probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_ignores_remote_query_disabled() { + // This is a server configuration error — `Server is not configured + // for RPC` — OPENQUERY does NOT help (OPENQUERY needs `data access` + // ON, not RPC OUT, but a server with RPC off may still have data + // access off too). Treat as non-cross-forest so the retry/abandon + // logic owns it. + let outcome = ProbeOutcome::ToolError( + "exit 1".into(), + "Msg 7411: Server 'SQL02' is not configured for RPC.".into(), + ); + assert!(!probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_ignores_missing_linked_server() { + let outcome = ProbeOutcome::ToolError( + "exit 1".into(), + "Msg 7202: Could not find server 'SQLX' in sys.servers.".into(), + ); + assert!(!probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_ignores_dispatch_failure() { + // Transport / queue error — no auth involved, OPENQUERY wouldn't + // help. + let outcome = ProbeOutcome::DispatchFailure("connection refused".into()); + assert!(!probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_ignores_confirmed() { + // A confirmed result by definition isn't a failure shape. + let outcome = ProbeOutcome::Confirmed("who is_sa srv\n--- ----- ---\n...".into()); + assert!(!probe_failure_is_cross_forest_shape(&outcome)); + } + + #[test] + fn cross_forest_shape_is_case_insensitive() { + // SQL Server's error capitalisation varies by version / locale; the + // matcher must lowercase before checking. + let outcome = ProbeOutcome::ToolError( + "auth".into(), + "LOGIN FAILED FOR USER 'FOREST1\\ALICE'".into(), + ); + assert!(probe_failure_is_cross_forest_shape(&outcome)); + } + + // ── classify_probe_result (shared classifier path) ───────────────── + + #[test] + fn classify_tool_error_propagates_error_and_output() { + let result: anyhow::Result = Ok(ares_llm::ToolExecResult { + output: "Msg 18456 Login failed".into(), + error: Some("exit 1".into()), + discoveries: None, + }); + let outcome = classify_probe_result(&result); + match outcome { + ProbeOutcome::ToolError(e, o) => { + assert_eq!(e, "exit 1"); + assert!(o.contains("Login failed")); + } + other => panic!("expected ToolError, got {other:?}"), + } + } + + #[test] + fn classify_confirmed_when_probe_columns_present() { + let result: anyhow::Result = Ok(ares_llm::ToolExecResult { + output: "who is_sa srv\n---- ----- ---\nFOREST2\\sa 1 SQL02" + .into(), + error: None, + discoveries: None, + }); + assert!(matches!( + classify_probe_result(&result), + ProbeOutcome::Confirmed(_) + )); + } + + #[test] + fn classify_no_evidence_when_clean_exit_but_no_probe_columns() { + let result: anyhow::Result = Ok(ares_llm::ToolExecResult { + output: "SQL> EXEC (...)\n(0 rows affected)".into(), + error: None, + discoveries: None, + }); + assert!(matches!( + classify_probe_result(&result), + ProbeOutcome::NoEvidence(_) + )); + } }