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:
user
2026-02-15 16:16:57 +01:00
parent 7520bba192
commit d5866a9867
3 changed files with 30 additions and 136 deletions

View File

@@ -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)