Skip to main content

gossan_cloud/
s3.rs

1//! AWS S3 bucket probe.
2//!
3//! Three-stage test per candidate:
4//!   1. GET `/`  — 200 + XML listing = public (Critical)
5//!   2. PUT canary object — succeeds = unauthenticated write (Critical)
6//!   3. GET `/` + 403 — bucket confirmed, listing denied (Low)
7//!
8//! Both vhost-style (`{name}.s3.amazonaws.com`) and path-style
9//! (`s3.amazonaws.com/{name}`) URLs are tried; some older regions only
10//! accept path-style.
11
12use async_trait::async_trait;
13use gossan_core::Target;
14use secfinding::{Evidence, Finding, Severity};
15
16use crate::common::is_xml_listing;
17use crate::provider::CloudProvider;
18/// AWS S3 bucket discovery and permission enumeration.
19pub struct S3Provider;
20
21#[async_trait]
22impl CloudProvider for S3Provider {
23    fn name(&self) -> &'static str {
24        "s3"
25    }
26
27    fn endpoint(&self, name: &str) -> String {
28        let encoded_name = urlencoding::encode(name);
29        format!("https://{}.s3.amazonaws.com/", encoded_name)
30    }
31
32    async fn probe(
33        &self,
34        client: &reqwest::Client,
35        name: &str,
36        target: &Target,
37    ) -> anyhow::Result<Vec<Finding>> {
38        let vhost = self.endpoint(name);
39        let encoded_name = urlencoding::encode(name);
40        let path = format!("https://s3.amazonaws.com/{}/", encoded_name);
41        let mut findings = Vec::new();
42
43        // Stage 1: directory listing probe
44        let (status, body, effective_url) = {
45            let mut status = 0u16;
46            let mut body = String::new();
47            let mut eff = vhost.clone();
48
49            if let Ok(resp) = client.get(&vhost).send().await {
50                status = resp.status().as_u16();
51                body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
52                    .await
53                    .unwrap_or_default();
54            }
55            // Retry path-style ONLY if we are using the real AWS endpoint
56            if (status == 0 || status == 301) && vhost.contains("amazonaws.com") {
57                if let Ok(resp) = client.get(&path).send().await {
58                    status = resp.status().as_u16();
59                    body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
60                        .await
61                        .unwrap_or_default();
62                    eff = path.clone();
63                }
64            }
65            (status, body, eff)
66        };
67
68        match status {
69            200 => {
70                gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
71                        format!("S3 bucket publicly listed: {}", name),
72                        format!(
73                            "s3://{} is publicly accessible and allows directory listing. \
74                             All object keys are enumerable; use \
75                             `aws s3 ls s3://{} --no-sign-request` to download without credentials.",
76                            name, name
77                        ))
78                    .evidence(Evidence::HttpResponse {
79                        status,
80                        headers: vec![("url".into(), effective_url.clone().into())],
81                        body_excerpt: if is_xml_listing(&body) {
82                            Some(body.chars().take(400).collect::<String>().into())
83                        } else {
84                            None
85                        },
86                    })
87                    .tag("s3").tag("cloud").tag("exposure")
88                    .exploit_hint(format!(
89                        "# List all objects:\naws s3 ls s3://{} --no-sign-request\n\
90                         # Download everything:\naws s3 sync s3://{} . --no-sign-request",
91                        name, name
92                    )), &mut findings);
93                // Stage 2: write-access check
94                try_write(client, name, &effective_url, target, &mut findings).await;
95            }
96            403 => {
97                gossan_core::try_push_finding(
98                    crate::finding_builder(
99                        target,
100                        Severity::Low,
101                        format!("S3 bucket exists (access denied): {}", name),
102                        format!(
103                            "s3://{} exists but public listing is blocked (HTTP 403). \
104                             Probe for write access and per-object ACL misconfigurations.",
105                            name
106                        ),
107                    )
108                    .evidence(Evidence::HttpResponse {
109                        status,
110                        headers: vec![("url".into(), effective_url.clone().into())],
111                        body_excerpt: None,
112                    })
113                    .tag("s3")
114                    .tag("cloud"),
115                    &mut findings,
116                );
117                // A 403 on GET / doesn't mean PUT is blocked — common misconfiguration
118                try_write(client, name, &effective_url, target, &mut findings).await;
119            }
120            _ => {} // 404 = doesn't exist; skip
121        }
122
123        Ok(findings)
124    }
125}
126
127/// Attempt an unauthenticated PUT. On success: Critical finding + immediate cleanup.
128async fn try_write(
129    client: &reqwest::Client,
130    bucket: &str,
131    base_url: &str,
132    target: &Target,
133    findings: &mut Vec<Finding>,
134) {
135    const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
136    let encoded_bucket = urlencoding::encode(bucket);
137    let put_url = if base_url.contains(".s3.amazonaws.com") {
138        format!("https://{}.s3.amazonaws.com/{}", encoded_bucket, PROBE_KEY)
139    } else {
140        format!("https://s3.amazonaws.com/{}/{}", encoded_bucket, PROBE_KEY)
141    };
142
143    let Ok(resp) = client
144        .put(&put_url)
145        .header("content-type", "text/plain")
146        .body("gossan-security-probe — safe to delete")
147        .send()
148        .await
149    else {
150        return;
151    };
152
153    let status = resp.status().as_u16();
154    if matches!(status, 200 | 204) {
155        let _ = client.delete(&put_url).send().await; // best-effort cleanup
156        gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
157                format!("S3 bucket writable without authentication: {}", bucket),
158                format!(
159                    "An unauthenticated PUT to s3://{}/{} succeeded (HTTP {}). \
160                     Any attacker can upload arbitrary files including web shells. \
161                     The probe object was deleted immediately after confirmation.",
162                    bucket, PROBE_KEY, status
163                ))
164            .evidence(Evidence::HttpResponse {
165                status,
166                headers: vec![("url".into(), put_url.clone().into())],
167                body_excerpt: None,
168            })
169            .tag("s3").tag("cloud").tag("file-upload").tag("exposure")
170            .exploit_hint(format!(
171                "# Upload a malicious file:\naws s3 cp malware.html s3://{}/malware.html --no-sign-request\n\
172                 # Via curl:\ncurl -s -X PUT '{}' --upload-file payload.bin",
173                bucket,
174                put_url.replace(PROBE_KEY, "payload.bin")
175            )), findings);
176    }
177}