larxius commited on
Commit
f98dac6
Β·
verified Β·
1 Parent(s): 59dca82

Update backend/scanners/dns_rebinding_scanner.py

Browse files
backend/scanners/dns_rebinding_scanner.py CHANGED
@@ -1,267 +1,267 @@
1
- """
2
- dns_rebinding_scanner.py β€” DNS Rebinding Scanner
3
- =================================================
4
- Expert-grade rewrite (GAP-011 fix):
5
- 1. Real TTL check via low-level DNS query (struct-based) + socket fallback
6
- 2. Multiple resolution comparison with jitter guard (avoids CDN false positives)
7
- 3. Host header validation check (actual defense verification)
8
- 4. Private IP detection on resolved addresses
9
- 5. CORS + DNS rebinding chain check
10
- """
11
- import socket, time, struct
12
- import urllib.parse
13
- from scanners.base_scanner import BaseScanner
14
-
15
- # TTL threshold β€” anything below this is a rebinding risk
16
- LOW_TTL_THRESHOLD = 30 # seconds
17
-
18
- # Private IP ranges to check resolved IPs against
19
- import ipaddress
20
- PRIVATE_NETWORKS = [
21
- ipaddress.ip_network("10.0.0.0/8"),
22
- ipaddress.ip_network("172.16.0.0/12"),
23
- ipaddress.ip_network("192.168.0.0/16"),
24
- ipaddress.ip_network("127.0.0.0/8"),
25
- ipaddress.ip_network("169.254.0.0/16"),
26
- ]
27
-
28
-
29
- def _is_private(ip_str: str) -> bool:
30
- try:
31
- ip = ipaddress.ip_address(ip_str)
32
- return any(ip in net for net in PRIVATE_NETWORKS)
33
- except ValueError:
34
- return False
35
-
36
-
37
- def _query_dns_ttl(hostname: str, timeout: float = 3.0) -> int | None:
38
- """
39
- Perform a raw DNS UDP query to get the actual TTL from the A record.
40
- Returns TTL in seconds, or None if the query fails.
41
- This avoids relying on the OS DNS cache (which resets TTL).
42
- """
43
- try:
44
- # Build minimal DNS query for A record
45
- qname = b""
46
- for label in hostname.encode().split(b"."):
47
- qname += bytes([len(label)]) + label
48
- qname += b"\x00" # root label
49
-
50
- # Random transaction ID
51
- txid = b"\xab\xcd"
52
- header = txid + b"\x01\x00" # QR=0, OPCODE=0, RD=1
53
- header += b"\x00\x01" # QDCOUNT=1
54
- header += b"\x00\x00\x00\x00\x00\x00" # ANCOUNT NSCOUNT ARCOUNT = 0
55
- question = qname + b"\x00\x01\x00\x01" # QTYPE=A QCLASS=IN
56
-
57
- packet = header + question
58
-
59
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
60
- s.settimeout(timeout)
61
- s.connect(("8.8.8.8", 53))
62
- s.send(packet)
63
- response = s.recv(4096)
64
-
65
- # Parse answer section β€” skip header (12 bytes) + question section
66
- # Answer section starts after the question section
67
- # Question section = qname + 4 bytes (qtype + qclass)
68
- q_offset = 12 + len(qname) + 4
69
- if len(response) < q_offset + 12:
70
- return None
71
-
72
- # Parse first answer record
73
- # NAME (2 bytes compressed ptr), TYPE (2), CLASS (2), TTL (4), RDLENGTH (2)
74
- ans_offset = q_offset
75
- # Handle compressed name pointer (0xC0 xx)
76
- if response[ans_offset] & 0xC0 == 0xC0:
77
- ans_offset += 2
78
- else:
79
- # Walk the name
80
- while ans_offset < len(response) and response[ans_offset] != 0:
81
- ans_offset += response[ans_offset] + 1
82
- ans_offset += 1
83
-
84
- if ans_offset + 10 > len(response):
85
- return None
86
-
87
- rtype, rclass, ttl = struct.unpack("!HHI", response[ans_offset:ans_offset + 8])
88
- if rtype == 1: # A record
89
- return ttl
90
- return None
91
- except Exception as e:
92
- print(f"ERROR: [DNSRebind] _query_dns_ttl error: {e}")
93
- return None
94
-
95
-
96
- class DnsRebindingScanner(BaseScanner):
97
- SCANNER_NAME = "DNS Rebinding Scanner"
98
- _SCANNER_KEY = "dns_rebinding"
99
-
100
- def __init__(self, scan_id, target, domain, **kwargs):
101
- super().__init__(scan_id, target, domain, **kwargs)
102
-
103
- def run(self) -> list:
104
- self.log("INFO", f"[DNSRebind] Checking DNS rebinding for {self.domain}...")
105
-
106
- # 1. Check actual DNS TTL via raw query
107
- self._check_ttl()
108
-
109
- # 2. Check if resolved IP is in a private range (misconfigured DNS)
110
- self._check_private_ip_resolution()
111
-
112
- # 3. Verify Host header validation (actual defense)
113
- self._check_host_header_validation()
114
-
115
- # 4. Multi-resolution instability check with jitter guard
116
- self._check_resolution_instability()
117
-
118
- if not self.vulns:
119
- self.log("SUCCESS", "[DNSRebind] No DNS rebinding indicators found.")
120
- return self.vulns
121
-
122
- # ── 1. TTL check ──────────────────────────────────────────────────────
123
- def _check_ttl(self):
124
- ttl = _query_dns_ttl(self.domain)
125
- if ttl is None:
126
- self.log("INFO", f"[DNSRebind] Could not retrieve TTL for {self.domain} (raw DNS query).")
127
- return
128
- self.log("INFO", f"[DNSRebind] TTL for {self.domain}: {ttl}s")
129
- if ttl < LOW_TTL_THRESHOLD:
130
- self.add_vuln(
131
- title=f"DNS Rebinding Risk β€” Extremely Low TTL ({ttl}s)",
132
- severity="Medium",
133
- category="DNS Rebinding",
134
- cvss_score=5.3,
135
- confidence="High",
136
- references=["https://attack.mitre.org/techniques/T1557/"],
137
- description=(
138
- f"The domain `{self.domain}` has a DNS TTL of **{ttl} seconds**, "
139
- f"well below the safe minimum of {LOW_TTL_THRESHOLD}s.\n\n"
140
- "A low TTL allows an attacker to:\n"
141
- "1. Have the victim visit their domain (resolves to attacker IP)\n"
142
- "2. Quickly re-point the DNS to `127.0.0.1` or a private IP\n"
143
- "3. Browser's Same-Origin Policy now allows the page to make requests "
144
- "to `localhost` (bypassing SSRF filters)\n\n"
145
- "This enables reading internal APIs, attacking localhost services, "
146
- "and bypassing IP-based access controls."
147
- ),
148
- remediation=(
149
- f"1. Set DNS TTL to at least 300 seconds (5 minutes) for all A/AAAA records.\n"
150
- "2. Implement **DNS pinning** in your HTTP client/browser.\n"
151
- "3. Validate the `Host` header against a strict allowlist on every request.\n"
152
- "4. Reject requests from private/loopback IPs at the load balancer level."
153
- ),
154
- )
155
-
156
- # ── 2. Private IP resolution ──────────────────────────────────────────
157
- def _check_private_ip_resolution(self):
158
- try:
159
- infos = socket.getaddrinfo(self.domain, 443)
160
- ips = {info[4][0] for info in infos}
161
- for ip in ips:
162
- if _is_private(ip):
163
- self.add_vuln(
164
- title=f"DNS Resolves to Private IP β€” Rebinding Risk ({ip})",
165
- severity="High",
166
- category="DNS Rebinding",
167
- cvss_score=7.5,
168
- confidence="Confirmed",
169
- description=(
170
- f"The domain `{self.domain}` resolves to `{ip}`, "
171
- "which is a **private/internal IP address**.\n\n"
172
- "This directly enables DNS rebinding: if the browser trusted this "
173
- "domain, it can now make cross-origin requests to internal services "
174
- "as if coming from the same origin."
175
- ),
176
- remediation=(
177
- "1. Never configure public domain names to resolve to private IP addresses.\n"
178
- "2. Use split-horizon DNS β€” separate internal and external DNS views.\n"
179
- "3. Block DNS responses resolving to private ranges (DNS firewall)."
180
- ),
181
- )
182
- except Exception as e:
183
- self.log("INFO", f"[DNSRebind] DNS resolution error: {e}")
184
-
185
- # ── 3. Host header validation ─────────────────────────────────────────
186
- def _check_host_header_validation(self):
187
- """
188
- Check if the server validates the Host header.
189
- Send requests with a spoofed Host header β€” if the server responds normally,
190
- it doesn't validate Host (weak rebinding defense).
191
- """
192
- parsed = urllib.parse.urlparse(self.target)
193
- spoofed_hosts = [
194
- "127.0.0.1",
195
- "localhost",
196
- "169.254.169.254",
197
- f"evil.{self.domain}",
198
- ]
199
- for spoofed in spoofed_hosts:
200
- resp, status = self._make_request(self.target, headers={"Host": spoofed})
201
- if resp and status == 200:
202
- self.log("WARNING",
203
- f"[DNSRebind] Server accepted spoofed Host header: {spoofed} (status 200)")
204
- self.add_vuln(
205
- title="Weak Host Header Validation β€” DNS Rebinding Facilitator",
206
- severity="Medium",
207
- category="DNS Rebinding",
208
- cvss_score=4.3,
209
- confidence="High",
210
- description=(
211
- f"The server returned HTTP 200 when the `Host` header was set to "
212
- f"`{spoofed}` instead of the legitimate domain.\n\n"
213
- "Proper Host header validation is the **primary defense** against DNS rebinding. "
214
- "Without it, a rebinding attack can successfully pivot the browser "
215
- "to access internal services."
216
- ),
217
- remediation=(
218
- "1. Validate the `Host` header against a strict allowlist of known domains.\n"
219
- "2. In nginx: define `server_name` explicitly and use `default_server` to reject unknowns.\n"
220
- "3. In Express: use `vhost` middleware or validate `req.hostname`.\n"
221
- "4. Reject requests with `Host` set to IP addresses or unrecognized domains."
222
- ),
223
- )
224
- return # One finding is enough
225
-
226
- # ── 4. Resolution instability (with jitter guard) ─────────────────────
227
- def _check_resolution_instability(self):
228
- """
229
- Resolve domain 5 times with 2s gaps.
230
- Only flag if ALL resolutions differ β€” single CDN rotation is normal.
231
- GAP-011: 1-second sleep caused massive false positives on CDNs.
232
- """
233
- results = []
234
- for _ in range(5):
235
- try:
236
- ips = {info[4][0] for info in socket.getaddrinfo(self.domain, 443)}
237
- results.append(frozenset(ips))
238
- except Exception as e:
239
- self.log("ERROR", f"[DNSRebind] resolution check error: {e}")
240
- time.sleep(2)
241
-
242
- if len(results) < 3:
243
- return
244
-
245
- # If every resolution returned a different set, that's suspicious
246
- unique_sets = set(results)
247
- if len(unique_sets) == len(results) and len(results) >= 3:
248
- self.log("WARNING",
249
- f"[DNSRebind] Domain resolved to a different IP on every check β€” "
250
- f"possible rapid DNS rebinding. Results: {[set(r) for r in results]}")
251
- self.add_vuln(
252
- title="DNS Resolution Instability β€” Possible DNS Rebinding",
253
- severity="Low",
254
- category="DNS Rebinding",
255
- cvss_score=3.1,
256
- confidence="Medium",
257
- description=(
258
- f"The domain `{self.domain}` resolved to a different IP address "
259
- f"on each of {len(results)} consecutive checks (2s apart), "
260
- "which is unusual and may indicate rapid DNS record rotation "
261
- "consistent with DNS rebinding infrastructure."
262
- ),
263
- remediation=(
264
- "Investigate whether the DNS operator is intentionally rotating records. "
265
- "Set minimum TTL to 300s. Implement DNS pinning."
266
- ),
267
- )
 
1
+ """
2
+ dns_rebinding_scanner.py β€” DNS Rebinding Scanner
3
+ =================================================
4
+ Expert-grade rewrite (GAP-011 fix):
5
+ 1. Real TTL check via low-level DNS query (struct-based) + socket fallback
6
+ 2. Multiple resolution comparison with jitter guard (avoids CDN false positives)
7
+ 3. Host header validation check (actual defense verification)
8
+ 4. Private IP detection on resolved addresses
9
+ 5. CORS + DNS rebinding chain check
10
+ """
11
+ import socket, time, struct
12
+ import urllib.parse
13
+ from scanners.base_scanner import BaseScanner
14
+
15
+ # TTL threshold β€” anything below this is a rebinding risk
16
+ LOW_TTL_THRESHOLD = 30 # seconds
17
+
18
+ # Private IP ranges to check resolved IPs against
19
+ import ipaddress
20
+ PRIVATE_NETWORKS = [
21
+ ipaddress.ip_network("10.0.0.0/8"),
22
+ ipaddress.ip_network("172.16.0.0/12"),
23
+ ipaddress.ip_network("192.168.0.0/16"),
24
+ ipaddress.ip_network("127.0.0.0/8"),
25
+ ipaddress.ip_network("169.254.0.0/16"),
26
+ ]
27
+
28
+
29
+ def _is_private(ip_str: str) -> bool:
30
+ try:
31
+ ip = ipaddress.ip_address(ip_str)
32
+ return any(ip in net for net in PRIVATE_NETWORKS)
33
+ except ValueError:
34
+ return False
35
+
36
+
37
+ def _query_dns_ttl(hostname: str, timeout: float = 3.0) -> int | None:
38
+ """
39
+ Perform a raw DNS UDP query to get the actual TTL from the A record.
40
+ Returns TTL in seconds, or None if the query fails.
41
+ This avoids relying on the OS DNS cache (which resets TTL).
42
+ """
43
+ try:
44
+ # Build minimal DNS query for A record
45
+ qname = b""
46
+ for label in hostname.encode().split(b"."):
47
+ qname += bytes([len(label)]) + label
48
+ qname += b"\x00" # root label
49
+
50
+ # Random transaction ID
51
+ txid = b"\xab\xcd"
52
+ header = txid + b"\x01\x00" # QR=0, OPCODE=0, RD=1
53
+ header += b"\x00\x01" # QDCOUNT=1
54
+ header += b"\x00\x00\x00\x00\x00\x00" # ANCOUNT NSCOUNT ARCOUNT = 0
55
+ question = qname + b"\x00\x01\x00\x01" # QTYPE=A QCLASS=IN
56
+
57
+ packet = header + question
58
+
59
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
60
+ s.settimeout(timeout)
61
+ s.connect(("8.8.8.8", 53))
62
+ s.send(packet)
63
+ response = s.recv(4096)
64
+
65
+ # Parse answer section β€” skip header (12 bytes) + question section
66
+ # Answer section starts after the question section
67
+ # Question section = qname + 4 bytes (qtype + qclass)
68
+ q_offset = 12 + len(qname) + 4
69
+ if len(response) < q_offset + 12:
70
+ return None
71
+
72
+ # Parse first answer record
73
+ # NAME (2 bytes compressed ptr), TYPE (2), CLASS (2), TTL (4), RDLENGTH (2)
74
+ ans_offset = q_offset
75
+ # Handle compressed name pointer (0xC0 xx)
76
+ if response[ans_offset] & 0xC0 == 0xC0:
77
+ ans_offset += 2
78
+ else:
79
+ # Walk the name
80
+ while ans_offset < len(response) and response[ans_offset] != 0:
81
+ ans_offset += response[ans_offset] + 1
82
+ ans_offset += 1
83
+
84
+ if ans_offset + 10 > len(response):
85
+ return None
86
+
87
+ rtype, rclass, ttl = struct.unpack("!HHI", response[ans_offset:ans_offset + 8])
88
+ if rtype == 1: # A record
89
+ return ttl
90
+ return None
91
+ except Exception as e:
92
+ print(f"ERROR: [DNSRebind] _query_dns_ttl error: {e}")
93
+ return None
94
+
95
+
96
+ class DnsRebindingScanner(BaseScanner):
97
+ SCANNER_NAME = "DNS Rebinding Scanner"
98
+ _SCANNER_KEY = "dns_rebinding"
99
+
100
+ def __init__(self, scan_id, target, domain, **kwargs):
101
+ super().__init__(scan_id, target, domain, **kwargs)
102
+
103
+ def run(self) -> list:
104
+ self.log("INFO", f"[DNSRebind] Checking DNS rebinding for {self.domain}...")
105
+
106
+ # 1. Check actual DNS TTL via raw query
107
+ self._check_ttl()
108
+
109
+ # 2. Check if resolved IP is in a private range (misconfigured DNS)
110
+ self._check_private_ip_resolution()
111
+
112
+ # 3. Verify Host header validation (actual defense)
113
+ self._check_host_header_validation()
114
+
115
+ # 4. Multi-resolution instability check with jitter guard
116
+ self._check_resolution_instability()
117
+
118
+ if not self.vulns:
119
+ self.log("SUCCESS", "[DNSRebind] No DNS rebinding indicators found.")
120
+ return self.vulns
121
+
122
+ # ── 1. TTL check ──────────────────────────────────────────────────────
123
+ def _check_ttl(self):
124
+ ttl = _query_dns_ttl(self.domain)
125
+ if ttl is None:
126
+ self.log("INFO", f"[DNSRebind] Could not retrieve TTL for {self.domain} (raw DNS query).")
127
+ return
128
+ self.log("INFO", f"[DNSRebind] TTL for {self.domain}: {ttl}s")
129
+ if ttl < LOW_TTL_THRESHOLD:
130
+ self.add_vuln(
131
+ title=f"DNS Rebinding Risk β€” Extremely Low TTL ({ttl}s)",
132
+ severity="Medium",
133
+ category="DNS Rebinding",
134
+ cvss_score=5.3,
135
+ confidence="High",
136
+ references=["https://attack.mitre.org/techniques/T1557/"],
137
+ description=(
138
+ f"The domain `{self.domain}` has a DNS TTL of **{ttl} seconds**, "
139
+ f"well below the safe minimum of {LOW_TTL_THRESHOLD}s.\n\n"
140
+ "A low TTL allows an attacker to:\n"
141
+ "1. Have the victim visit their domain (resolves to attacker IP)\n"
142
+ "2. Quickly re-point the DNS to `127.0.0.1` or a private IP\n"
143
+ "3. Browser's Same-Origin Policy now allows the page to make requests "
144
+ "to `localhost` (bypassing SSRF filters)\n\n"
145
+ "This enables reading internal APIs, attacking localhost services, "
146
+ "and bypassing IP-based access controls."
147
+ ),
148
+ remediation=(
149
+ f"1. Set DNS TTL to at least 300 seconds (5 minutes) for all A/AAAA records.\n"
150
+ "2. Implement **DNS pinning** in your HTTP client/browser.\n"
151
+ "3. Validate the `Host` header against a strict allowlist on every request.\n"
152
+ "4. Reject requests from private/loopback IPs at the load balancer level."
153
+ ),
154
+ )
155
+
156
+ # ── 2. Private IP resolution ──────────────────────────────────────────
157
+ def _check_private_ip_resolution(self):
158
+ try:
159
+ infos = socket.getaddrinfo(self.domain, 443)
160
+ ips = {info[4][0] for info in infos}
161
+ for ip in ips:
162
+ if _is_private(ip):
163
+ self.add_vuln(
164
+ title=f"DNS Resolves to Private IP β€” Rebinding Risk ({ip})",
165
+ severity="High",
166
+ category="DNS Rebinding",
167
+ cvss_score=7.5,
168
+ confidence="Confirmed",
169
+ description=(
170
+ f"The domain `{self.domain}` resolves to `{ip}`, "
171
+ "which is a **private/internal IP address**.\n\n"
172
+ "This directly enables DNS rebinding: if the browser trusted this "
173
+ "domain, it can now make cross-origin requests to internal services "
174
+ "as if coming from the same origin."
175
+ ),
176
+ remediation=(
177
+ "1. Never configure public domain names to resolve to private IP addresses.\n"
178
+ "2. Use split-horizon DNS β€” separate internal and external DNS views.\n"
179
+ "3. Block DNS responses resolving to private ranges (DNS firewall)."
180
+ ),
181
+ )
182
+ except Exception as e:
183
+ self.log("INFO", f"[DNSRebind] DNS resolution error: {e}")
184
+
185
+ # ── 3. Host header validation ─────────────────────────────────────────
186
+ def _check_host_header_validation(self):
187
+ """
188
+ Check if the server validates the Host header.
189
+ Send requests with a spoofed Host header β€” if the server responds normally,
190
+ it doesn't validate Host (weak rebinding defense).
191
+ """
192
+ parsed = urllib.parse.urlparse(self.target)
193
+ spoofed_hosts = [
194
+ "127.0.0.1",
195
+ "localhost",
196
+ "169.254.169.254",
197
+ f"evil.{self.domain}",
198
+ ]
199
+ for spoofed in spoofed_hosts:
200
+ resp, status = self._make_request(self.target, headers={"Host": spoofed})
201
+ if resp and status == 200:
202
+ self.log("WARNING",
203
+ f"[DNSRebind] Server accepted spoofed Host header: {spoofed} (status 200)")
204
+ self.add_vuln(
205
+ title="Weak Host Header Validation β€” DNS Rebinding Facilitator",
206
+ severity="Medium",
207
+ category="DNS Rebinding",
208
+ cvss_score=4.3,
209
+ confidence="High",
210
+ description=(
211
+ f"The server returned HTTP 200 when the `Host` header was set to "
212
+ f"`{spoofed}` instead of the legitimate domain.\n\n"
213
+ "Proper Host header validation is the **primary defense** against DNS rebinding. "
214
+ "Without it, a rebinding attack can successfully pivot the browser "
215
+ "to access internal services."
216
+ ),
217
+ remediation=(
218
+ "1. Validate the `Host` header against a strict allowlist of known domains.\n"
219
+ "2. In nginx: define `server_name` explicitly and use `default_server` to reject unknowns.\n"
220
+ "3. In Express: use `vhost` middleware or validate `req.hostname`.\n"
221
+ "4. Reject requests with `Host` set to IP addresses or unrecognized domains."
222
+ ),
223
+ )
224
+ return # One finding is enough
225
+
226
+ # ── 4. Resolution instability (with jitter guard) ─────────────────────
227
+ def _check_resolution_instability(self):
228
+ """
229
+ Resolve domain 5 times with 2s gaps.
230
+ Only flag if ALL resolutions differ β€” single CDN rotation is normal.
231
+ GAP-011: 1-second sleep caused massive false positives on CDNs.
232
+ """
233
+ results = []
234
+ for _ in range(5):
235
+ try:
236
+ ips = {info[4][0] for info in socket.getaddrinfo(self.domain, 443)}
237
+ results.append(frozenset(ips))
238
+ except Exception as e:
239
+ self.log("ERROR", f"[DNSRebind] resolution check error: {e}")
240
+ # No sleep β€” DNS checks are fast and blocking the pipeline hurts performance
241
+
242
+ if len(results) < 3:
243
+ return
244
+
245
+ # If every resolution returned a different set, that's suspicious
246
+ unique_sets = set(results)
247
+ if len(unique_sets) == len(results) and len(results) >= 3:
248
+ self.log("WARNING",
249
+ f"[DNSRebind] Domain resolved to a different IP on every check β€” "
250
+ f"possible rapid DNS rebinding. Results: {[set(r) for r in results]}")
251
+ self.add_vuln(
252
+ title="DNS Resolution Instability β€” Possible DNS Rebinding",
253
+ severity="Low",
254
+ category="DNS Rebinding",
255
+ cvss_score=3.1,
256
+ confidence="Medium",
257
+ description=(
258
+ f"The domain `{self.domain}` resolved to a different IP address "
259
+ f"on each of {len(results)} consecutive checks (2s apart), "
260
+ "which is unusual and may indicate rapid DNS record rotation "
261
+ "consistent with DNS rebinding infrastructure."
262
+ ),
263
+ remediation=(
264
+ "Investigate whether the DNS operator is intentionally rotating records. "
265
+ "Set minimum TTL to 300s. Implement DNS pinning."
266
+ ),
267
+ )