1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4use tokio::process::Command;
5
6struct 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#[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
279pub 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 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
365async fn check_single_subdomain(client: &Client, subdomain: &str) -> Option<TakeoverVulnerability> {
368 let dns = check_dns(subdomain).await;
370
371 let (http_status, body) = fetch_http(client, subdomain).await;
373
374 let body_lower = body.to_lowercase();
375
376 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 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 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 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 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 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
508async 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
567async fn fetch_http(client: &Client, subdomain: &str) -> (Option<u16>, String) {
570 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 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
585fn 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
612fn 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}