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).await.unwrap_or_default();
52            }
53            // Retry path-style ONLY if we are using the real AWS endpoint
54            if (status == 0 || status == 301) && vhost.contains("amazonaws.com") {
55                if let Ok(resp) = client.get(&path).send().await {
56                    status = resp.status().as_u16();
57                    body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024).await.unwrap_or_default();
58                    eff = path.clone();
59                }
60            }
61            (status, body, eff)
62        };
63
64        match status {
65            200 => {
66                gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
67                        format!("S3 bucket publicly listed: {}", name),
68                        format!(
69                            "s3://{} is publicly accessible and allows directory listing. \
70                             All object keys are enumerable; use \
71                             `aws s3 ls s3://{} --no-sign-request` to download without credentials.",
72                            name, name
73                        ))
74                    .evidence(Evidence::HttpResponse {
75                        status,
76                        headers: vec![("url".into(), effective_url.clone().into())],
77                        body_excerpt: if is_xml_listing(&body) {
78                            Some(body.chars().take(400).collect::<String>().into())
79                        } else {
80                            None
81                        },
82                    })
83                    .tag("s3").tag("cloud").tag("exposure")
84                    .exploit_hint(format!(
85                        "# List all objects:\naws s3 ls s3://{} --no-sign-request\n\
86                         # Download everything:\naws s3 sync s3://{} . --no-sign-request",
87                        name, name
88                    )), &mut findings);
89                // Stage 2: write-access check
90                try_write(client, name, &effective_url, target, &mut findings).await;
91            }
92            403 => {
93                gossan_core::try_push_finding(crate::finding_builder(
94                        target,
95                        Severity::Low,
96                        format!("S3 bucket exists (access denied): {}", name),
97                        format!(
98                            "s3://{} exists but public listing is blocked (HTTP 403). \
99                             Probe for write access and per-object ACL misconfigurations.",
100                            name
101                        ),
102                    )
103                    .evidence(Evidence::HttpResponse {
104                        status,
105                        headers: vec![("url".into(), effective_url.clone().into())],
106                        body_excerpt: None,
107                    })
108                    .tag("s3")
109                    .tag("cloud"), &mut findings);
110                // A 403 on GET / doesn't mean PUT is blocked — common misconfiguration
111                try_write(client, name, &effective_url, target, &mut findings).await;
112            }
113            _ => {} // 404 = doesn't exist; skip
114        }
115
116        Ok(findings)
117    }
118}
119
120/// Attempt an unauthenticated PUT. On success: Critical finding + immediate cleanup.
121async fn try_write(
122    client: &reqwest::Client,
123    bucket: &str,
124    base_url: &str,
125    target: &Target,
126    findings: &mut Vec<Finding>,
127) {
128    const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
129    let encoded_bucket = urlencoding::encode(bucket);
130    let put_url = if base_url.contains(".s3.amazonaws.com") {
131        format!("https://{}.s3.amazonaws.com/{}", encoded_bucket, PROBE_KEY)
132    } else {
133        format!("https://s3.amazonaws.com/{}/{}", encoded_bucket, PROBE_KEY)
134    };
135
136    let Ok(resp) = client
137        .put(&put_url)
138        .header("content-type", "text/plain")
139        .body("gossan-security-probe — safe to delete")
140        .send()
141        .await
142    else {
143        return;
144    };
145
146    let status = resp.status().as_u16();
147    if matches!(status, 200 | 204) {
148        let _ = client.delete(&put_url).send().await; // best-effort cleanup
149        gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
150                format!("S3 bucket writable without authentication: {}", bucket),
151                format!(
152                    "An unauthenticated PUT to s3://{}/{} succeeded (HTTP {}). \
153                     Any attacker can upload arbitrary files including web shells. \
154                     The probe object was deleted immediately after confirmation.",
155                    bucket, PROBE_KEY, status
156                ))
157            .evidence(Evidence::HttpResponse {
158                status,
159                headers: vec![("url".into(), put_url.clone().into())],
160                body_excerpt: None,
161            })
162            .tag("s3").tag("cloud").tag("file-upload").tag("exposure")
163            .exploit_hint(format!(
164                "# Upload a malicious file:\naws s3 cp malware.html s3://{}/malware.html --no-sign-request\n\
165                 # Via curl:\ncurl -s -X PUT '{}' --upload-file payload.bin",
166                bucket,
167                put_url.replace(PROBE_KEY, "payload.bin")
168            )), findings);
169    }
170}