-
-
Notifications
You must be signed in to change notification settings - Fork 6.3k
Expand file tree
/
Copy pathtest_dns_server.py
More file actions
365 lines (303 loc) · 13.9 KB
/
Copy pathtest_dns_server.py
File metadata and controls
365 lines (303 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
The DNS server used for DNS-exfiltration (lib/request/dns.py): raw packet parsing
(DNSQuery), fake A-record response crafting, the pop(prefix, suffix) accounting, and
- importantly - resilience: a single malformed packet or a transient send error must
NOT kill the server thread (which would silently lose all further exfiltration).
"""
import collections
import os
import socket
import struct
import sys
import threading
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from lib.core.settings import MAX_DNS_REQUESTS
from lib.request.dns import DNSQuery, DNSServer, InteractshDNSServer
def build_query(name, tid=b"\x12\x34", qtype=1):
"""Minimal standard (opcode 0) DNS query packet for L{name} (qtype 1=A, 28=AAAA, ...)"""
pkt = tid + b"\x01\x00" + b"\x00\x01" + b"\x00\x00" + b"\x00\x00" + b"\x00\x00"
for label in name.split("."):
if label:
pkt += struct.pack("B", len(label)) + label.encode()
return pkt + b"\x00" + struct.pack(">H", qtype) + b"\x00\x01"
class _HighPortDNSServer(DNSServer):
"""Real DNSServer logic, bound on an ephemeral high port (no root, no :53 probe).
Binds to port 0 and reads the kernel-chosen port back via getsockname() (same pattern
as tests/test_dns_engine.py) so concurrent/repeated runs never collide on a hardcoded
port. The actual port is exposed as L{self.port}.
"""
def __init__(self, sock=None, maxlen=MAX_DNS_REQUESTS):
self._requests = collections.deque(maxlen=maxlen)
self._lock = threading.Lock()
if sock is None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
self._socket = sock
self.port = self._socket.getsockname()[1]
self._running = False
self._initialized = False
def close(self):
self._running = False
try:
self._socket.close()
except socket.error:
pass
# Maximum time (seconds) to wait for the daemon server thread to come up, or for a sent
# query to be recorded, before failing loudly instead of spinning/sleeping forever.
WAIT_TIMEOUT = 5.0
def _wait_initialized(srv, timeout=WAIT_TIMEOUT):
"""Bounded wait for the server thread to flip _initialized; fail fast if it never does."""
deadline = time.time() + timeout
while not srv._initialized:
if time.time() > deadline:
raise RuntimeError("DNS server failed to initialize within %.1fs" % timeout)
time.sleep(0.01)
def _wait_recorded(srv, token, timeout=WAIT_TIMEOUT):
"""Bounded wait until L{token} appears in a recorded request; False on timeout."""
if hasattr(token, "encode"):
token = token.encode()
deadline = time.time() + timeout
while time.time() <= deadline:
with srv._lock:
if any(token in r for r in srv._requests):
return True
time.sleep(0.01)
return False
def _wait_popped(srv, prefix, suffix, timeout=WAIT_TIMEOUT):
"""Bounded wait until pop(prefix, suffix) yields a value; returns it or None on timeout."""
deadline = time.time() + timeout
while time.time() <= deadline:
popped = srv.pop(prefix, suffix)
if popped:
return popped
time.sleep(0.01)
return None
class _SendFailOnceSocket(object):
"""Wraps a real UDP socket; first sendto() raises (simulated transient failure)"""
def __init__(self, real):
self._real = real
self._sends = 0
def recvfrom(self, *a, **k):
return self._real.recvfrom(*a, **k)
def sendto(self, *a, **k):
self._sends += 1
if self._sends == 1:
raise RuntimeError("simulated transient sendto failure")
return self._real.sendto(*a, **k)
def __getattr__(self, name):
return getattr(self._real, name)
class TestDNSQuery(unittest.TestCase):
def test_parses_data_bearing_name(self):
q = DNSQuery(build_query("pre.deadbeef.suf.exfil.test"))
self.assertEqual(q._query, b"pre.deadbeef.suf.exfil.test.")
def test_empty_and_short_packets_do_not_raise(self):
for raw in (b"", b"\x00", b"\x12", b"\x12\x34", b"\x12\x34\x01\x20"):
self.assertEqual(DNSQuery(raw)._query, b"") # no exception, empty query
def test_unterminated_name_does_not_raise(self):
# a length byte that runs past the buffer, with no null terminator
pkt = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" + b"\x20" + b"abc"
DNSQuery(pkt) # must not raise (slicing past end yields b"", ord guards)
def test_response_is_valid_A_record(self):
q = DNSQuery(build_query("x.y.z", tid=b"\xab\xcd"))
resp = q.response("127.0.0.1")
self.assertEqual(resp[:2], b"\xab\xcd") # transaction id echoed
self.assertEqual(resp[2:4], b"\x85\x80") # standard response, no error
ip = ".".join(str(b if isinstance(b, int) else ord(b)) for b in resp[-4:])
self.assertEqual(ip, "127.0.0.1")
def test_empty_query_yields_empty_response(self):
self.assertEqual(DNSQuery(b"\x00").response("127.0.0.1"), b"")
class TestDNSServerRoundTrip(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.srv = _HighPortDNSServer()
cls.srv.run()
_wait_initialized(cls.srv)
@classmethod
def tearDownClass(cls):
srv = getattr(cls, "srv", None)
if srv is not None:
srv.close()
cls.srv = None
def _send(self, name):
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
c.settimeout(3)
c.sendto(build_query(name), ("127.0.0.1", self.srv.port))
try:
c.recvfrom(512)
except socket.timeout:
pass
finally:
c.close()
return _wait_recorded(self.srv, name)
def test_roundtrip_and_pop(self):
self.assertTrue(self._send("aaa.cafe.bbb.exfil.test"))
self.assertIsNone(self.srv.pop("zzz", "yyy")) # wrong boundaries
self.assertIsNotNone(self.srv.pop("aaa", "bbb")) # correct boundaries
self.assertIsNone(self.srv.pop("aaa", "bbb")) # consumed only once
def test_non_a_query_type_still_recorded(self):
# a DBMS resolver may emit AAAA (28) / TXT (16) lookups - the exfiltrated name is in the
# labels regardless of qtype, and the server records before crafting the (A) response
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
c.settimeout(2)
c.sendto(build_query("ggg.beef.hhh.exfil.test", qtype=28), ("127.0.0.1", self.srv.port))
try:
c.recvfrom(512)
except socket.timeout:
pass
finally:
c.close()
if not _wait_popped(self.srv, "ggg", "hhh"):
self.fail("AAAA-type query was not recorded (exfil would be lost for AAAA-resolving DBMSes)")
class TestDNSServerMemoryBound(unittest.TestCase):
"""The server records every received query (it listens on :53); only matching ones are
popped. Unrelated/stray traffic and resolver retries must not grow memory without bound."""
def test_requests_are_bounded_and_recent_kept(self):
srv = _HighPortDNSServer(maxlen=50)
self.addCleanup(srv.close)
srv.run()
_wait_initialized(srv)
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for i in range(200): # flood well past the bound
c.sendto(build_query("noise%d.unrelated.test" % i), ("127.0.0.1", srv.port))
c.close()
# a legit exfil query right after the flood must still be capturable
c2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); c2.settimeout(2)
c2.sendto(build_query("ppp.d00d.qqq.exfil.test"), ("127.0.0.1", srv.port))
try:
c2.recvfrom(512)
except socket.timeout:
pass
finally:
c2.close()
popped = _wait_popped(srv, "ppp", "qqq")
with srv._lock:
n = len(srv._requests)
self.assertLessEqual(n, 50, "request buffer exceeded its bound (%d)" % n)
self.assertIsNotNone(popped, "a fresh exfil query was lost after a flood of stray traffic")
class TestDNSServerResilience(unittest.TestCase):
def _make(self, sock=None):
srv = _HighPortDNSServer(sock=sock)
self.addCleanup(srv.close)
srv.run()
_wait_initialized(srv)
return srv
def _query(self, port, name):
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
c.settimeout(1)
c.sendto(build_query(name), ("127.0.0.1", port))
try:
c.recvfrom(512)
except socket.timeout:
pass
finally:
c.close()
def _recorded(self, srv, token):
return _wait_recorded(srv, token)
def test_survives_transient_send_error(self):
# ephemeral bind, then wrap the bound socket so its first sendto() raises
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 0))
srv = self._make(sock=_SendFailOnceSocket(s))
self._query(srv.port, "aaa.11.bbb.exfil.test") # first sendto raises
self._query(srv.port, "ccc.22.ddd.exfil.test") # must still be served
self.assertTrue(self._recorded(srv, "ccc.22.ddd"),
"DNS server died after one failing sendto (lost subsequent exfil)")
self.assertTrue(srv._running)
def test_survives_malformed_packets(self):
srv = self._make()
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for junk in (b"", b"\x00", b"\xff" * 7, b"\x12\x34\x01\x00\x00\x01" + b"\x20abc"):
c.sendto(junk, ("127.0.0.1", srv.port))
c.close()
self._query(srv.port, "ok.33.fine.exfil.test")
self.assertTrue(self._recorded(srv, "ok.33.fine"),
"DNS server died on a malformed packet")
class TestDNSServerConcurrency(unittest.TestCase):
"""Under --threads, many workers fire DNS queries and call pop() while the server thread
appends - all guarded by one lock. Each worker must get back exactly its own data."""
@classmethod
def setUpClass(cls):
cls.srv = _HighPortDNSServer()
cls.srv.run()
_wait_initialized(cls.srv)
@classmethod
def tearDownClass(cls):
srv = getattr(cls, "srv", None)
if srv is not None:
srv.close()
cls.srv = None
def test_concurrent_send_and_pop_no_crosstalk(self):
import binascii, re
N = 12
errors = []
def worker(i):
# distinct boundary labels per worker (DNS boundary alphabet = letters, no a-f/digits)
prefix = "gg" + chr(ord("g") + i)
suffix = "mm" + chr(ord("g") + i)
secret = ("worker-%02d-secret" % i).encode()
host = "%s.%s.%s.exfil.test" % (prefix, binascii.hexlify(secret).decode(), suffix)
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
c.settimeout(2)
try:
c.sendto(build_query(host), ("127.0.0.1", self.srv.port))
try:
c.recvfrom(512)
except socket.timeout:
pass
finally:
c.close()
got = _wait_popped(self.srv, prefix, suffix)
if not got:
errors.append("worker %d: never popped its query" % i); return
m = re.search(r"%s\.(?P<r>.+?)\.%s" % (prefix, suffix), got, re.I)
if not m or binascii.unhexlify(m.group("r")) != secret:
errors.append("worker %d: cross-talk/corruption got=%r" % (i, got))
threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(errors, [], "concurrency failures: %s" % errors)
# every queued request consumed exactly once -> nothing left behind
self.assertEqual(self.srv.pop("gg" + chr(ord("g")), "mm" + chr(ord("g"))), None)
if __name__ == "__main__":
unittest.main(verbosity=2)
class TestInteractshDNSServer(unittest.TestCase):
"""The interactsh-backed DNS collector must present the same pop(prefix, suffix)
accounting as DNSServer, matching only prefix.<result>.suffix names and never
returning the same captured lookup twice."""
def _collector(self, names):
class _FakeClient(object):
registered = True
def dnsDomain(self): return "corr0000000000000nnc.oast.fun"
def dnsNames(self): return list(names)
srv = InteractshDNSServer.__new__(InteractshDNSServer)
srv._client = _FakeClient()
srv.domain = srv._client.dnsDomain()
srv._seen = set()
srv._running = True
srv._initialized = True
srv._POLL_TRIES = 1 # no real sleeps in unit tests
return srv
def test_pop_matches_prefix_suffix_and_dedups(self):
names = ["aaa.5345435245540a.zzz.corr0000000000000nnc", "unrelated.corr0000000000000nnc"]
srv = self._collector(names)
got = srv.pop("aaa", "zzz")
self.assertEqual(got, "aaa.5345435245540a.zzz.corr0000000000000nnc")
self.assertIsNone(srv.pop("aaa", "zzz")) # already consumed
def test_pop_no_match(self):
srv = self._collector(["aaa.deadbeef.qqq.corr0000000000000nnc"])
self.assertIsNone(srv.pop("aaa", "zzz"))
def test_pop_any(self):
srv = self._collector(["whatever.corr0000000000000nnc"])
self.assertEqual(srv.pop(), "whatever.corr0000000000000nnc")
def test_run_is_noop(self):
self._collector([]).run() # must not raise