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) -> 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 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
334async fn check_single_subdomain(client: &Client, subdomain: &str) -> Option<TakeoverVulnerability> {
337 let dns = check_dns(subdomain).await;
339
340 let (http_status, body) = fetch_http(client, subdomain).await;
342
343 let body_lower = body.to_lowercase();
344
345 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 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 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 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 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 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
477async 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
536async fn fetch_http(client: &Client, subdomain: &str) -> (Option<u16>, String) {
539 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 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
554fn 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
581fn 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}