diff --git a/plugins/blacklist.py b/plugins/blacklist.py index 1402883..f5d260b 100644 --- a/plugins/blacklist.py +++ b/plugins/blacklist.py @@ -1,13 +1,12 @@ -"""Plugin: DNSBL/RBL IP reputation check (pure stdlib).""" +"""Plugin: DNSBL/RBL IP reputation check via Tor DNS resolver.""" from __future__ import annotations import asyncio import ipaddress -import os import socket -import struct +from derp.dns import TOR_DNS_ADDR, TOR_DNS_PORT, build_query, parse_response from derp.plugin import command _DNSBLS = [ @@ -25,54 +24,16 @@ _DNSBLS = [ _TIMEOUT = 5.0 -def _get_resolver() -> str: - """Read first IPv4 nameserver from /etc/resolv.conf.""" - try: - with open("/etc/resolv.conf") as f: - for line in f: - line = line.strip() - if line.startswith("nameserver"): - addr = line.split()[1] - try: - ipaddress.IPv4Address(addr) - return addr - except ValueError: - continue - except (OSError, IndexError): - pass - return "8.8.8.8" - - -def _build_a_query(name: str) -> bytes: - """Build a minimal DNS A query.""" - tid = os.urandom(2) - flags = struct.pack("!H", 0x0100) - counts = struct.pack("!HHHH", 1, 0, 0, 0) - encoded = b"" - for label in name.rstrip(".").split("."): - encoded += bytes([len(label)]) + label.encode("ascii") - encoded += b"\x00" - return tid + flags + counts + encoded + struct.pack("!HH", 1, 1) - - -def _check_response(data: bytes) -> bool: - """Check if DNS response has answer records (listed).""" - if len(data) < 12: - return False - _, flags, _, ancount = struct.unpack_from("!HHHH", data, 0) - rcode = flags & 0x0F - return rcode == 0 and ancount > 0 - - -def _query_dnsbl(name: str, server: str) -> bool: - """Blocking DNS A lookup, returns True if listed.""" - query = _build_a_query(name) +def _query_dnsbl(name: str) -> bool: + """Blocking DNS A lookup via Tor resolver, returns True if listed.""" + query = build_query(name, 1) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(_TIMEOUT) try: - sock.sendto(query, (server, 53)) + sock.sendto(query, (TOR_DNS_ADDR, TOR_DNS_PORT)) data = sock.recv(512) - return _check_response(data) + rcode, results = parse_response(data) + return rcode == 0 and len(results) > 0 except (socket.timeout, OSError): return False finally: @@ -84,12 +45,12 @@ def _reversed_ip(addr: str) -> str: return ".".join(reversed(addr.split("."))) -async def _check_one(ip_rev: str, zone: str, label: str, - server: str) -> tuple[str, bool]: +async def _check_one(ip_rev: str, zone: str, + label: str) -> tuple[str, bool]: """Check one DNSBL asynchronously.""" name = f"{ip_rev}.{zone}" loop = asyncio.get_running_loop() - listed = await loop.run_in_executor(None, _query_dnsbl, name, server) + listed = await loop.run_in_executor(None, _query_dnsbl, name) return label, listed @@ -117,9 +78,8 @@ async def cmd_blacklist(bot, message): return ip_rev = _reversed_ip(str(ip)) - server = _get_resolver() - tasks = [_check_one(ip_rev, zone, label, server) for zone, label in _DNSBLS] + tasks = [_check_one(ip_rev, zone, label) for zone, label in _DNSBLS] results = await asyncio.gather(*tasks) listed = [label for label, hit in results if hit] diff --git a/plugins/subdomain.py b/plugins/subdomain.py index 7b9e956..11ac5d2 100644 --- a/plugins/subdomain.py +++ b/plugins/subdomain.py @@ -3,15 +3,13 @@ from __future__ import annotations import asyncio -import ipaddress import json import logging -import os import re import socket -import struct import urllib.request +from derp.dns import TOR_DNS_ADDR, TOR_DNS_PORT, build_query, parse_response from derp.http import urlopen as _urlopen from derp.plugin import command @@ -43,84 +41,19 @@ _WORDLIST = [ _DOMAIN_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$") -def _get_resolver() -> str: - """Read first IPv4 nameserver from /etc/resolv.conf.""" - try: - with open("/etc/resolv.conf") as f: - for line in f: - line = line.strip() - if line.startswith("nameserver"): - addr = line.split()[1] - try: - ipaddress.IPv4Address(addr) - return addr - except ValueError: - continue - except (OSError, IndexError): - pass - return "8.8.8.8" - - -def _build_a_query(name: str) -> bytes: - """Build a minimal DNS A query.""" - tid = os.urandom(2) - flags = struct.pack("!H", 0x0100) - counts = struct.pack("!HHHH", 1, 0, 0, 0) - encoded = b"" - for label in name.rstrip(".").split("."): - encoded += bytes([len(label)]) + label.encode("ascii") - encoded += b"\x00" - return tid + flags + counts + encoded + struct.pack("!HH", 1, 1) - - -def _parse_a_response(data: bytes) -> list[str]: - """Extract A record IPs from a DNS response.""" - if len(data) < 12: - return [] - _, flags, _, ancount = struct.unpack_from("!HHHH", data, 0) - rcode = flags & 0x0F - if rcode != 0 or ancount == 0: - return [] - - offset = 12 - # Skip question section - while offset < len(data) and data[offset] != 0: - if (data[offset] & 0xC0) == 0xC0: - offset += 2 - break - offset += data[offset] + 1 - else: - offset += 1 - offset += 4 # QTYPE + QCLASS - - results = [] - for _ in range(ancount): - if offset + 12 > len(data): - break - # Skip name (may be pointer) - if (data[offset] & 0xC0) == 0xC0: - offset += 2 - else: - while offset < len(data) and data[offset] != 0: - offset += data[offset] + 1 - offset += 1 - rtype, _, _, rdlength = struct.unpack_from("!HHIH", data, offset) - offset += 10 - if rtype == 1 and rdlength == 4: - results.append(socket.inet_ntoa(data[offset:offset + 4])) - offset += rdlength - return results - - -def _resolve_a(name: str, server: str) -> list[str]: - """Blocking DNS A lookup. Returns list of IPs.""" - query = _build_a_query(name) +def _resolve_a(name: str) -> list[str]: + """Blocking DNS A lookup via Tor resolver. Returns list of IPs.""" + query = build_query(name, 1) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(_DNS_TIMEOUT) try: - sock.sendto(query, (server, 53)) + sock.sendto(query, (TOR_DNS_ADDR, TOR_DNS_PORT)) data = sock.recv(4096) - return _parse_a_response(data) + rcode, results = parse_response(data) + if rcode != 0: + return [] + # Filter to only A record IPs (dotted-quad format) + return [r for r in results if "." in r and ":" not in r] except (socket.timeout, OSError): return [] finally: @@ -148,12 +81,11 @@ def _fetch_crtsh(domain: str) -> set[str]: return subs -async def _brute_one(prefix: str, domain: str, - server: str) -> tuple[str, list[str]]: +async def _brute_one(prefix: str, domain: str) -> tuple[str, list[str]]: """Resolve one subdomain. Returns (fqdn, [ips]).""" fqdn = f"{prefix}.{domain}" loop = asyncio.get_running_loop() - ips = await loop.run_in_executor(None, _resolve_a, fqdn, server) + ips = await loop.run_in_executor(None, _resolve_a, fqdn) return fqdn, ips @@ -189,8 +121,7 @@ async def cmd_subdomain(bot, message): timeout=35.0, ) # Resolve the CT-discovered subdomains - server = _get_resolver() - tasks = [_brute_one(sub.removesuffix(f".{domain}"), domain, server) + tasks = [_brute_one(sub.removesuffix(f".{domain}"), domain) for sub in ct_subs] if tasks: results = await asyncio.gather(*tasks) @@ -205,8 +136,7 @@ async def cmd_subdomain(bot, message): # Phase 2: DNS brute force (optional) if brute: - server = _get_resolver() - tasks = [_brute_one(w, domain, server) for w in _WORDLIST + tasks = [_brute_one(w, domain) for w in _WORDLIST if f"{w}.{domain}" not in found] if tasks: results = await asyncio.gather(*tasks) diff --git a/src/derp/dns.py b/src/derp/dns.py index e3a9085..9a49888 100644 --- a/src/derp/dns.py +++ b/src/derp/dns.py @@ -17,6 +17,10 @@ RCODES: dict[int, str] = { 4: "NOTIMP", 5: "REFUSED", } +# Tor DNS resolver (DNSPort on the local Tor relay) +TOR_DNS_ADDR = "127.0.0.1" +TOR_DNS_PORT = 9053 + def get_resolver() -> str: """Read first IPv4 nameserver from /etc/resolv.conf."""