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