Skip to main content

web_analyzer/
subdomain_takeover.rs

1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4use tokio::process::Command;
5
6// ── Vulnerable Service Database (36 services) ───────────────────────────────
7
8struct VulnService {
9    name: &'static str,
10    cname_pattern: &'static str,
11    error_pattern: &'static str,
12    additional: &'static str,
13}
14
15const VULNERABLE_SERVICES: &[VulnService] = &[
16    VulnService {
17        name: "AWS S3 Bucket",
18        cname_pattern: "s3.amazonaws.com",
19        error_pattern: "NoSuchBucket",
20        additional: "The specified bucket does not exist",
21    },
22    VulnService {
23        name: "AWS CloudFront",
24        cname_pattern: "cloudfront.net",
25        error_pattern: "The request could not be satisfied",
26        additional: "Bad request",
27    },
28    VulnService {
29        name: "GitHub Pages",
30        cname_pattern: "github.io",
31        error_pattern: "There isn't a GitHub Pages site here",
32        additional: "404: Not Found",
33    },
34    VulnService {
35        name: "Heroku",
36        cname_pattern: "herokuapp.com",
37        error_pattern: "No such app",
38        additional: "heroku",
39    },
40    VulnService {
41        name: "Vercel",
42        cname_pattern: "vercel.app",
43        error_pattern: "404: Not Found",
44        additional: "The deployment could not be found",
45    },
46    VulnService {
47        name: "Netlify",
48        cname_pattern: "netlify.app",
49        error_pattern: "Not found",
50        additional: "netlify",
51    },
52    VulnService {
53        name: "Azure App Service",
54        cname_pattern: "azurewebsites.net",
55        error_pattern: "Microsoft Azure App Service",
56        additional: "404 Not Found",
57    },
58    VulnService {
59        name: "Azure TrafficManager",
60        cname_pattern: "trafficmanager.net",
61        error_pattern: "Page not found",
62        additional: "Not found",
63    },
64    VulnService {
65        name: "Zendesk",
66        cname_pattern: "zendesk.com",
67        error_pattern: "Help Center Closed",
68        additional: "Zendesk",
69    },
70    VulnService {
71        name: "Shopify",
72        cname_pattern: "myshopify.com",
73        error_pattern: "Sorry, this shop is currently unavailable",
74        additional: "Shopify",
75    },
76    VulnService {
77        name: "Fastly",
78        cname_pattern: "fastly.net",
79        error_pattern: "Fastly error: unknown domain",
80        additional: "Fastly",
81    },
82    VulnService {
83        name: "Pantheon",
84        cname_pattern: "pantheonsite.io",
85        error_pattern: "The gods are wise",
86        additional: "404 Not Found",
87    },
88    VulnService {
89        name: "Tumblr",
90        cname_pattern: "tumblr.com",
91        error_pattern: "There's nothing here",
92        additional: "Tumblr",
93    },
94    VulnService {
95        name: "WordPress",
96        cname_pattern: "wordpress.com",
97        error_pattern: "Do you want to register",
98        additional: "WordPress",
99    },
100    VulnService {
101        name: "Acquia",
102        cname_pattern: "acquia-sites.com",
103        error_pattern: "No site found",
104        additional: "The requested URL was not found",
105    },
106    VulnService {
107        name: "Ghost",
108        cname_pattern: "ghost.io",
109        error_pattern: "The thing you were looking for is no longer here",
110        additional: "Ghost",
111    },
112    VulnService {
113        name: "Cargo",
114        cname_pattern: "cargocollective.com",
115        error_pattern: "404 Not Found",
116        additional: "Cargo",
117    },
118    VulnService {
119        name: "Webflow",
120        cname_pattern: "webflow.io",
121        error_pattern: "The page you are looking for doesn't exist",
122        additional: "Webflow",
123    },
124    VulnService {
125        name: "Surge.sh",
126        cname_pattern: "surge.sh",
127        error_pattern: "404 Not Found",
128        additional: "Surge",
129    },
130    VulnService {
131        name: "Squarespace",
132        cname_pattern: "squarespace.com",
133        error_pattern: "Website Expired",
134        additional: "Squarespace",
135    },
136    VulnService {
137        name: "Fly.io",
138        cname_pattern: "fly.dev",
139        error_pattern: "404 Not Found",
140        additional: "Fly.io",
141    },
142    VulnService {
143        name: "Brightcove",
144        cname_pattern: "bcvp0rtal.com",
145        error_pattern: "Brightcove Error",
146        additional: "Brightcove",
147    },
148    VulnService {
149        name: "Unbounce",
150        cname_pattern: "unbounce.com",
151        error_pattern: "The requested URL was not found",
152        additional: "Unbounce",
153    },
154    VulnService {
155        name: "Strikingly",
156        cname_pattern: "strikinglydns.com",
157        error_pattern: "404 Not Found",
158        additional: "Strikingly",
159    },
160    VulnService {
161        name: "UptimeRobot",
162        cname_pattern: "stats.uptimerobot.com",
163        error_pattern: "404 Not Found",
164        additional: "UptimeRobot",
165    },
166    VulnService {
167        name: "UserVoice",
168        cname_pattern: "uservoice.com",
169        error_pattern: "This UserVoice is currently being set up",
170        additional: "UserVoice",
171    },
172    VulnService {
173        name: "Pingdom",
174        cname_pattern: "stats.pingdom.com",
175        error_pattern: "404 Not Found",
176        additional: "Pingdom",
177    },
178    VulnService {
179        name: "Desk",
180        cname_pattern: "desk.com",
181        error_pattern: "Please try again",
182        additional: "Desk",
183    },
184    VulnService {
185        name: "Tilda",
186        cname_pattern: "tilda.ws",
187        error_pattern: "404 Not Found",
188        additional: "Tilda",
189    },
190    VulnService {
191        name: "Helpjuice",
192        cname_pattern: "helpjuice.com",
193        error_pattern: "404 Not Found",
194        additional: "Helpjuice",
195    },
196    VulnService {
197        name: "HelpScout",
198        cname_pattern: "helpscoutdocs.com",
199        error_pattern: "No settings were found",
200        additional: "HelpScout",
201    },
202    VulnService {
203        name: "Campaign Monitor",
204        cname_pattern: "createsend.com",
205        error_pattern: "404 Not Found",
206        additional: "Campaign Monitor",
207    },
208    VulnService {
209        name: "Digital Ocean",
210        cname_pattern: "digitalocean.app",
211        error_pattern: "404 Not Found",
212        additional: "Digital Ocean",
213    },
214    VulnService {
215        name: "AWS Elastic Beanstalk",
216        cname_pattern: "elasticbeanstalk.com",
217        error_pattern: "404 Not Found",
218        additional: "Elastic Beanstalk",
219    },
220    VulnService {
221        name: "Readthedocs",
222        cname_pattern: "readthedocs.io",
223        error_pattern: "Not Found",
224        additional: "readthedocs",
225    },
226    VulnService {
227        name: "Firebase",
228        cname_pattern: "firebaseapp.com",
229        error_pattern: "404 Not Found",
230        additional: "Firebase",
231    },
232];
233
234// ── Data Structures ─────────────────────────────────────────────────────────
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct DnsCheckResult {
238    pub a_records: Vec<String>,
239    pub aaaa_records: Vec<String>,
240    pub cname_records: Vec<String>,
241    pub mx_records: Vec<String>,
242    pub txt_records: Vec<String>,
243    pub ns_records: Vec<String>,
244    pub has_valid_dns: bool,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct TakeoverVulnerability {
249    pub subdomain: String,
250    pub service: String,
251    pub vulnerability_type: String,
252    pub cname: Option<String>,
253    pub confidence: String,
254    pub description: String,
255    pub exploitation_difficulty: String,
256    pub mitigation: String,
257    pub dns_info: DnsCheckResult,
258    pub http_status: Option<u16>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ScanStatistics {
263    pub subdomains_scanned: usize,
264    pub vulnerable_count: usize,
265    pub high_confidence: usize,
266    pub medium_confidence: usize,
267    pub low_confidence: usize,
268    pub scan_time_secs: f64,
269    pub services_checked: usize,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct TakeoverResult {
274    pub domain: String,
275    pub statistics: ScanStatistics,
276    pub vulnerable: Vec<TakeoverVulnerability>,
277}
278
279// ── Main Function ───────────────────────────────────────────────────────────
280
281pub async fn check_subdomain_takeover(
282    domain: &str,
283    subdomains: &[String],
284) -> Result<TakeoverResult, Box<dyn std::error::Error + Send + Sync>> {
285    let client = Client::builder()
286        .timeout(Duration::from_secs(10))
287        .danger_accept_invalid_certs(true)
288        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
289        .build()?;
290
291    let start = std::time::Instant::now();
292    let mut vulnerable = Vec::new();
293
294    for sub in subdomains {
295        if let Some(vuln) = check_single_subdomain(&client, sub).await {
296            vulnerable.push(vuln);
297        }
298    }
299
300    // Sort by confidence: High → Medium → Low
301    vulnerable.sort_by(|a, b| {
302        let order = |c: &str| -> u8 {
303            match c {
304                "High" => 0,
305                "Medium" => 1,
306                _ => 2,
307            }
308        };
309        order(&a.confidence).cmp(&order(&b.confidence))
310    });
311
312    let high = vulnerable.iter().filter(|v| v.confidence == "High").count();
313    let medium = vulnerable
314        .iter()
315        .filter(|v| v.confidence == "Medium")
316        .count();
317    let low = vulnerable.iter().filter(|v| v.confidence == "Low").count();
318
319    Ok(TakeoverResult {
320        domain: domain.to_string(),
321        statistics: ScanStatistics {
322            subdomains_scanned: subdomains.len(),
323            vulnerable_count: vulnerable.len(),
324            high_confidence: high,
325            medium_confidence: medium,
326            low_confidence: low,
327            scan_time_secs: start.elapsed().as_secs_f64(),
328            services_checked: VULNERABLE_SERVICES.len(),
329        },
330        vulnerable,
331    })
332}
333
334// ── Per-Subdomain Check ─────────────────────────────────────────────────────
335
336async fn check_single_subdomain(client: &Client, subdomain: &str) -> Option<TakeoverVulnerability> {
337    // 1. DNS configuration
338    let dns = check_dns(subdomain).await;
339
340    // 2. HTTP status + body
341    let (http_status, body) = fetch_http(client, subdomain).await;
342
343    let body_lower = body.to_lowercase();
344
345    // ── Case 1: CNAME matches a service AND body contains error fingerprint ──
346    for cname in &dns.cname_records {
347        let cname_lower = cname.to_lowercase();
348        for svc in VULNERABLE_SERVICES {
349            if cname_lower.contains(svc.cname_pattern) {
350                let has_error = body_lower.contains(&svc.error_pattern.to_lowercase())
351                    || body_lower.contains(&svc.additional.to_lowercase());
352
353                if has_error {
354                    return Some(TakeoverVulnerability {
355                        subdomain: subdomain.to_string(),
356                        service: svc.name.to_string(),
357                        vulnerability_type: "CNAME Error Pattern".into(),
358                        cname: Some(cname.clone()),
359                        confidence: "High".into(),
360                        description: format!("CNAME points to {} ({}) and returns error indicating resource doesn't exist.", svc.name, cname),
361                        exploitation_difficulty: assess_difficulty("CNAME Error Pattern", svc.name),
362                        mitigation: suggest_mitigation("CNAME Error Pattern", svc.name),
363                        dns_info: dns,
364                        http_status,
365                    });
366                }
367            }
368        }
369    }
370
371    // ── Case 2: Dangling CNAME (points somewhere that doesn't resolve) ──
372    if !dns.cname_records.is_empty() && dns.a_records.is_empty() && http_status.is_none() {
373        for cname in &dns.cname_records {
374            let resolves = resolve_a(cname).await;
375            if !resolves {
376                // Try to identify the service
377                let mut service = "Unknown".to_string();
378                let cname_lower = cname.to_lowercase();
379                for svc in VULNERABLE_SERVICES {
380                    if cname_lower.contains(svc.cname_pattern) {
381                        service = svc.name.to_string();
382                        break;
383                    }
384                }
385                let conf = if service != "Unknown" {
386                    "High"
387                } else {
388                    "Medium"
389                };
390
391                return Some(TakeoverVulnerability {
392                    subdomain: subdomain.to_string(),
393                    service: service.clone(),
394                    vulnerability_type: "Dangling CNAME".into(),
395                    cname: Some(cname.clone()),
396                    confidence: conf.into(),
397                    description: format!(
398                        "CNAME points to {} which doesn't resolve to an IP.",
399                        cname
400                    ),
401                    exploitation_difficulty: assess_difficulty("Dangling CNAME", &service),
402                    mitigation: suggest_mitigation("Dangling CNAME", &service),
403                    dns_info: dns,
404                    http_status,
405                });
406            }
407        }
408    }
409
410    // ── Case 3: Dangling NS ──
411    for ns in &dns.ns_records {
412        let resolves = resolve_a(ns).await;
413        if !resolves {
414            return Some(TakeoverVulnerability {
415                subdomain: subdomain.to_string(),
416                service: "Unknown".into(),
417                vulnerability_type: "Dangling NS".into(),
418                cname: None,
419                confidence: "Medium".into(),
420                description: format!("NS record points to {} which doesn't resolve.", ns),
421                exploitation_difficulty: "Medium".into(),
422                mitigation: suggest_mitigation("Dangling NS", "Unknown"),
423                dns_info: dns,
424                http_status,
425            });
426        }
427    }
428
429    // ── Case 4: Valid DNS but third-party service returns error ──
430    if dns.has_valid_dns {
431        if let Some(status) = http_status {
432            if [404, 500, 502, 503].contains(&status) {
433                let dns_str = format!("{:?}", dns).to_lowercase();
434                let third_party = ["aws", "amazon", "azure", "heroku", "github", "vercel"];
435                let is_3rd = third_party.iter().any(|p| dns_str.contains(p));
436
437                let conf = if is_3rd { "Medium" } else { "Low" };
438                return Some(TakeoverVulnerability {
439                    subdomain: subdomain.to_string(),
440                    service: "Unknown".into(),
441                    vulnerability_type: "Third-Party Service Error".into(),
442                    cname: dns.cname_records.first().cloned(),
443                    confidence: conf.into(),
444                    description: format!("Valid DNS but returns HTTP {} error.", status),
445                    exploitation_difficulty: "Hard".into(),
446                    mitigation: suggest_mitigation("Third-Party Service Error", "Unknown"),
447                    dns_info: dns,
448                    http_status: Some(status),
449                });
450            }
451        }
452    }
453
454    // ── Case 5: Missing SPF with MX records ──
455    if !dns.mx_records.is_empty() {
456        let has_spf = dns.txt_records.iter().any(|t| t.contains("v=spf1"));
457        if !has_spf {
458            return Some(TakeoverVulnerability {
459                subdomain: subdomain.to_string(),
460                service: "Unknown".into(),
461                vulnerability_type: "Missing SPF".into(),
462                cname: None,
463                confidence: "Low".into(),
464                description: "Has MX records but no SPF record — potential email spoofing risk."
465                    .into(),
466                exploitation_difficulty: "Medium".into(),
467                mitigation: suggest_mitigation("Missing SPF", "Unknown"),
468                dns_info: dns,
469                http_status,
470            });
471        }
472    }
473
474    None
475}
476
477// ── DNS Checks ──────────────────────────────────────────────────────────────
478
479async fn check_dns(subdomain: &str) -> DnsCheckResult {
480    let (a, aaaa, cname, mx, txt, ns) = tokio::join!(
481        dig_query(subdomain, "A"),
482        dig_query(subdomain, "AAAA"),
483        dig_query(subdomain, "CNAME"),
484        dig_query(subdomain, "MX"),
485        dig_query(subdomain, "TXT"),
486        dig_query(subdomain, "NS"),
487    );
488
489    let has_valid = !a.is_empty()
490        || !aaaa.is_empty()
491        || !cname.is_empty()
492        || !mx.is_empty()
493        || !txt.is_empty()
494        || !ns.is_empty();
495
496    DnsCheckResult {
497        a_records: a,
498        aaaa_records: aaaa,
499        cname_records: cname,
500        mx_records: mx,
501        txt_records: txt,
502        ns_records: ns,
503        has_valid_dns: has_valid,
504    }
505}
506
507async fn dig_query(domain: &str, rtype: &str) -> Vec<String> {
508    let output = match Command::new("dig")
509        .args(["+short", rtype, domain])
510        .output()
511        .await
512    {
513        Ok(o) => o,
514        Err(_) => return vec![],
515    };
516    String::from_utf8_lossy(&output.stdout)
517        .lines()
518        .map(|s| s.trim().trim_end_matches('.').to_string())
519        .filter(|s| !s.is_empty())
520        .collect()
521}
522
523async fn resolve_a(host: &str) -> bool {
524    let output = match Command::new("dig")
525        .args(["+short", "A", host])
526        .output()
527        .await
528    {
529        Ok(o) => o,
530        Err(_) => return false,
531    };
532    let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
533    !result.is_empty()
534}
535
536// ── HTTP Fetch ──────────────────────────────────────────────────────────────
537
538async fn fetch_http(client: &Client, subdomain: &str) -> (Option<u16>, String) {
539    // Try HTTPS first
540    if let Ok(resp) = client.get(format!("https://{}", subdomain)).send().await {
541        let status = resp.status().as_u16();
542        let body = resp.text().await.unwrap_or_default();
543        return (Some(status), body.chars().take(1000).collect());
544    }
545    // Fallback to HTTP
546    if let Ok(resp) = client.get(format!("http://{}", subdomain)).send().await {
547        let status = resp.status().as_u16();
548        let body = resp.text().await.unwrap_or_default();
549        return (Some(status), body.chars().take(1000).collect());
550    }
551    (None, String::new())
552}
553
554// ── Exploitation Difficulty ─────────────────────────────────────────────────
555
556fn assess_difficulty(vuln_type: &str, service: &str) -> String {
557    match vuln_type {
558        "CNAME Error Pattern" => {
559            let easy = ["GitHub Pages", "Heroku", "Vercel", "Netlify", "Surge.sh"];
560            let medium = ["AWS S3 Bucket", "Firebase", "Ghost", "WordPress"];
561            if easy.contains(&service) {
562                "Easy".into()
563            } else if medium.contains(&service) {
564                "Medium".into()
565            } else {
566                "Hard".into()
567            }
568        }
569        "Dangling CNAME" => {
570            if service != "Unknown" {
571                "Medium".into()
572            } else {
573                "Hard".into()
574            }
575        }
576        "Dangling NS" => "Medium".into(),
577        _ => "Hard".into(),
578    }
579}
580
581// ── Mitigation Suggestions ──────────────────────────────────────────────────
582
583fn suggest_mitigation(vuln_type: &str, service: &str) -> String {
584    match vuln_type {
585        "CNAME Error Pattern" => format!("Remove the CNAME record or reclaim the resource on {}. Ensure you've properly set up the service before pointing DNS records to it.", service),
586        "Dangling CNAME" => "Remove the CNAME record pointing to a non-existent endpoint. If the service is still needed, recreate the resource at the target.".into(),
587        "Dangling NS" => "Update NS records to point to valid nameservers. Remove delegations to nameservers that no longer exist.".into(),
588        "Third-Party Service Error" => "Verify the resource exists on the target service. If no longer used, remove the DNS record.".into(),
589        "Missing SPF" => "Add an SPF record to protect against email spoofing. Example: 'v=spf1 mx -all'".into(),
590        _ => "Review DNS configuration and remove references to services or resources no longer in use.".into(),
591    }
592}