fix: route blacklist and subdomain DNS through Tor resolver
Both plugins duplicated wire-format helpers and queried the system resolver on port 53. Switch to shared derp.dns helpers and point queries at the local Tor DNS resolver (127.0.0.1:9053) so lookups go through Tor like all other outbound traffic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
|
||||||
import socket
|
import socket
|
||||||
import struct
|
|
||||||
|
|
||||||
|
from derp.dns import TOR_DNS_ADDR, TOR_DNS_PORT, build_query, parse_response
|
||||||
from derp.plugin import command
|
from derp.plugin import command
|
||||||
|
|
||||||
_DNSBLS = [
|
_DNSBLS = [
|
||||||
@@ -25,54 +24,16 @@ _DNSBLS = [
|
|||||||
_TIMEOUT = 5.0
|
_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
|
||||||
def _get_resolver() -> str:
|
def _query_dnsbl(name: str) -> bool:
|
||||||
"""Read first IPv4 nameserver from /etc/resolv.conf."""
|
"""Blocking DNS A lookup via Tor resolver, returns True if listed."""
|
||||||
try:
|
query = build_query(name, 1)
|
||||||
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)
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.settimeout(_TIMEOUT)
|
sock.settimeout(_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
sock.sendto(query, (server, 53))
|
sock.sendto(query, (TOR_DNS_ADDR, TOR_DNS_PORT))
|
||||||
data = sock.recv(512)
|
data = sock.recv(512)
|
||||||
return _check_response(data)
|
rcode, results = parse_response(data)
|
||||||
|
return rcode == 0 and len(results) > 0
|
||||||
except (socket.timeout, OSError):
|
except (socket.timeout, OSError):
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
@@ -84,12 +45,12 @@ def _reversed_ip(addr: str) -> str:
|
|||||||
return ".".join(reversed(addr.split(".")))
|
return ".".join(reversed(addr.split(".")))
|
||||||
|
|
||||||
|
|
||||||
async def _check_one(ip_rev: str, zone: str, label: str,
|
async def _check_one(ip_rev: str, zone: str,
|
||||||
server: str) -> tuple[str, bool]:
|
label: str) -> tuple[str, bool]:
|
||||||
"""Check one DNSBL asynchronously."""
|
"""Check one DNSBL asynchronously."""
|
||||||
name = f"{ip_rev}.{zone}"
|
name = f"{ip_rev}.{zone}"
|
||||||
loop = asyncio.get_running_loop()
|
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
|
return label, listed
|
||||||
|
|
||||||
|
|
||||||
@@ -117,9 +78,8 @@ async def cmd_blacklist(bot, message):
|
|||||||
return
|
return
|
||||||
|
|
||||||
ip_rev = _reversed_ip(str(ip))
|
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)
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
listed = [label for label, hit in results if hit]
|
listed = [label for label, hit in results if hit]
|
||||||
|
|||||||
@@ -3,15 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import struct
|
|
||||||
import urllib.request
|
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.http import urlopen as _urlopen
|
||||||
from derp.plugin import command
|
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,})+$")
|
_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:
|
def _resolve_a(name: str) -> list[str]:
|
||||||
"""Read first IPv4 nameserver from /etc/resolv.conf."""
|
"""Blocking DNS A lookup via Tor resolver. Returns list of IPs."""
|
||||||
try:
|
query = build_query(name, 1)
|
||||||
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)
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.settimeout(_DNS_TIMEOUT)
|
sock.settimeout(_DNS_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
sock.sendto(query, (server, 53))
|
sock.sendto(query, (TOR_DNS_ADDR, TOR_DNS_PORT))
|
||||||
data = sock.recv(4096)
|
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):
|
except (socket.timeout, OSError):
|
||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
@@ -148,12 +81,11 @@ def _fetch_crtsh(domain: str) -> set[str]:
|
|||||||
return subs
|
return subs
|
||||||
|
|
||||||
|
|
||||||
async def _brute_one(prefix: str, domain: str,
|
async def _brute_one(prefix: str, domain: str) -> tuple[str, list[str]]:
|
||||||
server: str) -> tuple[str, list[str]]:
|
|
||||||
"""Resolve one subdomain. Returns (fqdn, [ips])."""
|
"""Resolve one subdomain. Returns (fqdn, [ips])."""
|
||||||
fqdn = f"{prefix}.{domain}"
|
fqdn = f"{prefix}.{domain}"
|
||||||
loop = asyncio.get_running_loop()
|
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
|
return fqdn, ips
|
||||||
|
|
||||||
|
|
||||||
@@ -189,8 +121,7 @@ async def cmd_subdomain(bot, message):
|
|||||||
timeout=35.0,
|
timeout=35.0,
|
||||||
)
|
)
|
||||||
# Resolve the CT-discovered subdomains
|
# Resolve the CT-discovered subdomains
|
||||||
server = _get_resolver()
|
tasks = [_brute_one(sub.removesuffix(f".{domain}"), domain)
|
||||||
tasks = [_brute_one(sub.removesuffix(f".{domain}"), domain, server)
|
|
||||||
for sub in ct_subs]
|
for sub in ct_subs]
|
||||||
if tasks:
|
if tasks:
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
@@ -205,8 +136,7 @@ async def cmd_subdomain(bot, message):
|
|||||||
|
|
||||||
# Phase 2: DNS brute force (optional)
|
# Phase 2: DNS brute force (optional)
|
||||||
if brute:
|
if brute:
|
||||||
server = _get_resolver()
|
tasks = [_brute_one(w, domain) for w in _WORDLIST
|
||||||
tasks = [_brute_one(w, domain, server) for w in _WORDLIST
|
|
||||||
if f"{w}.{domain}" not in found]
|
if f"{w}.{domain}" not in found]
|
||||||
if tasks:
|
if tasks:
|
||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ RCODES: dict[int, str] = {
|
|||||||
4: "NOTIMP", 5: "REFUSED",
|
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:
|
def get_resolver() -> str:
|
||||||
"""Read first IPv4 nameserver from /etc/resolv.conf."""
|
"""Read first IPv4 nameserver from /etc/resolv.conf."""
|
||||||
|
|||||||
Reference in New Issue
Block a user