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    progress_tx: Option<tokio::sync::mpsc::Sender<crate::ScanProgress>>,
285) -> Result<TakeoverResult, Box<dyn std::error::Error + Send + Sync>> {
286    let client = Client::builder()
287        .timeout(Duration::from_secs(10))
288        .danger_accept_invalid_certs(true)
289        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
290        .build()?;
291
292    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Subdomain Takeover".into(), percentage: 5.0, message: "Initializing subdomain takeover checks...".into(), status: "Info".into() }).await; }
293    let start = std::time::Instant::now();
294    let mut vulnerable = Vec::new();
295
296    for (i, sub) in subdomains.iter().enumerate() {
297        if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Subdomain Takeover".into(), percentage: 5.0 + (90.0 * (i as f32 / subdomains.len().max(1) as f32)), message: format!("Checking {} for dangling records...", sub), status: "Info".into() }).await; }
298        if let Some(vuln) = check_single_subdomain(&client, sub).await {
299            vulnerable.push(vuln);
300        }
301    }
302
303    if let Some(t) = &progress_tx { let _ = t.send(crate::ScanProgress { module: "Subdomain Takeover".into(), percentage: 95.0, message: "Sorting and finalizing vulnerability results...".into(), status: "Info".into() }).await; }
304    // Sort by confidence: High → Medium → Low
305    vulnerable.sort_by(|a, b| {
306        let order = |c: &str| -> u8 {
307            match c {
308                "High" => 0,
309                "Medium" => 1,
310                _ => 2,
311            }
312        };
313        order(&a.confidence).cmp(&order(&b.confidence))
314    });
315
316    let high = vulnerable.iter().filter(|v| v.confidence == "High").count();
317    let medium = vulnerable
318        .iter()
319        .filter(|v| v.confidence == "Medium")
320        .count();
321    let low = vulnerable.iter().filter(|v| v.confidence == "Low").count();
322
323    Ok(TakeoverResult {
324        domain: domain.to_string(),
325        statistics: ScanStatistics {
326            subdomains_scanned: subdomains.len(),
327            vulnerable_count: vulnerable.len(),
328            high_confidence: high,
329            medium_confidence: medium,
330            low_confidence: low,
331            scan_time_secs: start.elapsed().as_secs_f64(),
332            services_checked: VULNERABLE_SERVICES.len(),
333        },
334        vulnerable,
335    })
336}
337
338// ── Per-Subdomain Check ─────────────────────────────────────────────────────
339
340async fn check_single_subdomain(client: &Client, subdomain: &str) -> Option<TakeoverVulnerability> {
341    // 1. DNS configuration
342    let dns = check_dns(subdomain).await;
343
344    // 2. HTTP status + body
345    let (http_status, body) = fetch_http(client, subdomain).await;
346
347    let body_lower = body.to_lowercase();
348
349    // ── Case 1: CNAME matches a service AND body contains error fingerprint ──
350    for cname in &dns.cname_records {
351        let cname_lower = cname.to_lowercase();
352        for svc in VULNERABLE_SERVICES {
353            if cname_lower.contains(svc.cname_pattern) {
354                let has_error = body_lower.contains(&svc.error_pattern.to_lowercase())
355                    || body_lower.contains(&svc.additional.to_lowercase());
356
357                if has_error {
358                    return Some(TakeoverVulnerability {
359                        subdomain: subdomain.to_string(),
360                        service: svc.name.to_string(),
361                        vulnerability_type: "CNAME Error Pattern".into(),
362                        cname: Some(cname.clone()),
363                        confidence: "High".into(),
364                        description: format!("CNAME points to {} ({}) and returns error indicating resource doesn't exist.", svc.name, cname),
365                        exploitation_difficulty: assess_difficulty("CNAME Error Pattern", svc.name),
366                        mitigation: suggest_mitigation("CNAME Error Pattern", svc.name),
367                        dns_info: dns,
368                        http_status,
369                    });
370                }
371            }
372        }
373    }
374
375    // ── Case 2: Dangling CNAME (points somewhere that doesn't resolve) ──
376    if !dns.cname_records.is_empty() && dns.a_records.is_empty() && http_status.is_none() {
377        for cname in &dns.cname_records {
378            let resolves = resolve_a(cname).await;
379            if !resolves {
380                // Try to identify the service
381                let mut service = "Unknown".to_string();
382                let cname_lower = cname.to_lowercase();
383                for svc in VULNERABLE_SERVICES {
384                    if cname_lower.contains(svc.cname_pattern) {
385                        service = svc.name.to_string();
386                        break;
387                    }
388                }
389                let conf = if service != "Unknown" {
390                    "High"
391                } else {
392                    "Medium"
393                };
394
395                return Some(TakeoverVulnerability {
396                    subdomain: subdomain.to_string(),
397                    service: service.clone(),
398                    vulnerability_type: "Dangling CNAME".into(),
399                    cname: Some(cname.clone()),
400                    confidence: conf.into(),
401                    description: format!(
402                        "CNAME points to {} which doesn't resolve to an IP.",
403                        cname
404                    ),
405                    exploitation_difficulty: assess_difficulty("Dangling CNAME", &service),
406                    mitigation: suggest_mitigation("Dangling CNAME", &service),
407                    dns_info: dns,
408                    http_status,
409                });
410            }
411        }
412    }
413
414    // ── Case 3: Dangling NS ──
415    for ns in &dns.ns_records {
416        let resolves = resolve_a(ns).await;
417        if !resolves {
418            return Some(TakeoverVulnerability {
419                subdomain: subdomain.to_string(),
420                service: "Unknown".into(),
421                vulnerability_type: "Dangling NS".into(),
422                cname: None,
423                confidence: "Medium".into(),
424                description: format!("NS record points to {} which doesn't resolve.", ns),
425                exploitation_difficulty: "Medium".into(),
426                mitigation: suggest_mitigation("Dangling NS", "Unknown"),
427                dns_info: dns,
428                http_status,
429            });
430        }
431    }
432
433    // ── Case 4: Valid DNS but third-party service returns error ──
434    if dns.has_valid_dns {
435        if let Some(status) = http_status {
436            if [404, 500, 502, 503].contains(&status) {
437                let dns_str = format!("{:?}", dns).to_lowercase();
438                let third_party = ["aws", "amazon", "azure", "heroku", "github", "vercel"];
439                let is_3rd = third_party.iter().any(|p| dns_str.contains(p));
440
441                let conf = if is_3rd { "Medium" } else { "Low" };
442                return Some(TakeoverVulnerability {
443                    subdomain: subdomain.to_string(),
444                    service: "Unknown".into(),
445                    vulnerability_type: "Third-Party Service Error".into(),
446                    cname: dns.cname_records.first().cloned(),
447                    confidence: conf.into(),
448                    description: format!("Valid DNS but returns HTTP {} error.", status),
449                    exploitation_difficulty: "Hard".into(),
450                    mitigation: suggest_mitigation("Third-Party Service Error", "Unknown"),
451                    dns_info: dns,
452                    http_status: Some(status),
453                });
454            }
455        }
456    }
457
458    // ── Case 5: Missing SPF with MX records ──
459    if !dns.mx_records.is_empty() {
460        let has_spf = dns.txt_records.iter().any(|t| t.contains("v=spf1"));
461        if !has_spf {
462            return Some(TakeoverVulnerability {
463                subdomain: subdomain.to_string(),
464                service: "Unknown".into(),
465                vulnerability_type: "Missing SPF".into(),
466                cname: None,
467                confidence: "Low".into(),
468                description: "Has MX records but no SPF record — potential email spoofing risk."
469                    .into(),
470                exploitation_difficulty: "Medium".into(),
471                mitigation: suggest_mitigation("Missing SPF", "Unknown"),
472                dns_info: dns,
473                http_status,
474            });
475        }
476    }
477
478    None
479}
480
481// ── DNS Checks ──────────────────────────────────────────────────────────────
482
483async fn check_dns(subdomain: &str) -> DnsCheckResult {
484    let (a, aaaa, cname, mx, txt, ns) = tokio::join!(
485        dig_query(subdomain, "A"),
486        dig_query(subdomain, "AAAA"),
487        dig_query(subdomain, "CNAME"),
488        dig_query(subdomain, "MX"),
489        dig_query(subdomain, "TXT"),
490        dig_query(subdomain, "NS"),
491    );
492
493    let has_valid = !a.is_empty()
494        || !aaaa.is_empty()
495        || !cname.is_empty()
496        || !mx.is_empty()
497        || !txt.is_empty()
498        || !ns.is_empty();
499
500    DnsCheckResult {
501        a_records: a,
502        aaaa_records: aaaa,
503        cname_records: cname,
504        mx_records: mx,
505        txt_records: txt,
506        ns_records: ns,
507        has_valid_dns: has_valid,
508    }
509}
510
511async fn dig_query(domain: &str, rtype: &str) -> Vec<String> {
512    let output = match Command::new("dig")
513        .args(["+short", rtype, domain])
514        .output()
515        .await
516    {
517        Ok(o) => o,
518        Err(_) => return vec![],
519    };
520    String::from_utf8_lossy(&output.stdout)
521        .lines()
522        .map(|s| s.trim().trim_end_matches('.').to_string())
523        .filter(|s| !s.is_empty())
524        .collect()
525}
526
527async fn resolve_a(host: &str) -> bool {
528    let output = match Command::new("dig")
529        .args(["+short", "A", host])
530        .output()
531        .await
532    {
533        Ok(o) => o,
534        Err(_) => return false,
535    };
536    let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
537    !result.is_empty()
538}
539
540// ── HTTP Fetch ──────────────────────────────────────────────────────────────
541
542async fn fetch_http(client: &Client, subdomain: &str) -> (Option<u16>, String) {
543    // Try HTTPS first
544    if let Ok(resp) = client.get(format!("https://{}", subdomain)).send().await {
545        let status = resp.status().as_u16();
546        let body = resp.text().await.unwrap_or_default();
547        return (Some(status), body.chars().take(1000).collect());
548    }
549    // Fallback to HTTP
550    if let Ok(resp) = client.get(format!("http://{}", subdomain)).send().await {
551        let status = resp.status().as_u16();
552        let body = resp.text().await.unwrap_or_default();
553        return (Some(status), body.chars().take(1000).collect());
554    }
555    (None, String::new())
556}
557
558// ── Exploitation Difficulty ─────────────────────────────────────────────────
559
560fn assess_difficulty(vuln_type: &str, service: &str) -> String {
561    match vuln_type {
562        "CNAME Error Pattern" => {
563            let easy = ["GitHub Pages", "Heroku", "Vercel", "Netlify", "Surge.sh"];
564            let medium = ["AWS S3 Bucket", "Firebase", "Ghost", "WordPress"];
565            if easy.contains(&service) {
566                "Easy".into()
567            } else if medium.contains(&service) {
568                "Medium".into()
569            } else {
570                "Hard".into()
571            }
572        }
573        "Dangling CNAME" => {
574            if service != "Unknown" {
575                "Medium".into()
576            } else {
577                "Hard".into()
578            }
579        }
580        "Dangling NS" => "Medium".into(),
581        _ => "Hard".into(),
582    }
583}
584
585// ── Mitigation Suggestions ──────────────────────────────────────────────────
586
587fn suggest_mitigation(vuln_type: &str, service: &str) -> String {
588    match vuln_type {
589        "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),
590        "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(),
591        "Dangling NS" => "Update NS records to point to valid nameservers. Remove delegations to nameservers that no longer exist.".into(),
592        "Third-Party Service Error" => "Verify the resource exists on the target service. If no longer used, remove the DNS record.".into(),
593        "Missing SPF" => "Add an SPF record to protect against email spoofing. Example: 'v=spf1 mx -all'".into(),
594        _ => "Review DNS configuration and remove references to services or resources no longer in use.".into(),
595    }
596}