DEV Community

Onizuka
Onizuka

Posted on

I Did 52 WHOIS Lookups On Attackers — Here's What I Learned

security #api #webdev #discuss

The chat went live at 2 PM. By 2:14 it was a war zone.

I thought adding real-time chat to my dev blog would spark pair-programming threads. Instead, bots flooded it with phishing links and slurs. One bot even dropped an oddly specific threat about my home city. I flipped on request logging. In the next 24 hours it logged 52 distinct attacker hostnames. IP bans did nothing. They came back from new IPs, new ASNs, new registrars. IP bans felt like swatting flies. I wanted to know what these domains actually were. That's when I started bulk-WHOISing every domain they posted.

What 52 hostile domains actually look like

I wrote a small Python runner. Feed it a list of hostnames and it spits out JSON. The first version used public RDAP servers directly. Public RDAP servers were slow, rate-limited, and ccTLDs broke them. I wanted DNS, SSL, subdomains, email history, and takeover risk in the same response. I landed on the enrichment endpoint at RapidAPI and put the script on GitHub.

import json, time, sys, os
from urllib.parse import quote
import requests

RAPIDAPI_KEY = os.environ.get("RAPIDAPI_KEY", "")
BASE_URL = "https://domain-whois2.p.rapidapi.com/whois"
HEADERS = {
    "X-RapidAPI-Key": RAPIDAPI_KEY,
    "X-RapidAPI-Host": "domain-whois2.p.rapidapi.com"
}

def lookup(domain: str):
    url = f"{BASE_URL}?domain={quote(domain)}"
    try:
        r = requests.get(url, headers=HEADERS, timeout=20)
        r.raise_for_status()
        return r.json()
    except requests.exceptions.Timeout:
        return {"domain": domain, "error": "timeout"}
    except requests.exceptions.HTTPError as e:
        return {"domain": domain, "error": f"http {e.response.status_code}"}
    except Exception as e:
        return {"domain": domain, "error": str(e)}

def batch(domains, delay=0.6):
    results = []
    for d in domains:
        print(f"[*] {d}", file=sys.stderr)
        results.append(lookup(d))
        time.sleep(delay)
    return results

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("usage: python enrich.py domains.txt", file=sys.stderr)
        sys.exit(1)
    domains = [line.strip() for line in open(sys.argv[1]) if line.strip()]
    out = batch(domains)
    json.dump(out, sys.stdout, indent=2)
Enter fullscreen mode Exit fullscreen mode

I ran it like this:

RAPIDAPI_KEY=xxx python enrich.py attackers.txt > attackers_enriched.json
Enter fullscreen mode Exit fullscreen mode

After deduplication I had 49 usable records. Three lookups failed entirely: one ccTLD had no RDAP server, one 503'd, and one was already suspended. The numbers that jumped out:

  • 36 of 49 domains were less than 90 days old. That's 73%.
  • 20 used privacy-protected WHOIS, which is 41% of the set. Legitimate users do this too, but combined with age it becomes a strong signal.
  • 14 shared the same budget registrar with instant activation and free privacy.
  • 31 had no DMARC record (63%), and 27 had no SPF (55%). Only 4 passed all three email-security checks.
  • 8 had active subdomain takeover candidates, mostly CNAMEs pointing to deleted GitHub Pages or Heroku apps.

I built a tiny scoring function. One point each for age under 90 days, missing DMARC, missing SPF, privacy-only contact, a known-budget registrar, and dangling subdomain risk. Max six points, no partial credit. Scores ranged from 0 to 6. The median score was 4. Two domains scored 0. They turned out to be compromised legitimate sites, not throwaways—some attackers were piggybacking on aged domains instead of registering new ones.

The response included an email_security_score field. That single number let me sort the JSON and immediately see the soft spots. The lowest-scoring domains were also the ones posting the most links. Not causation, but a decent way to decide what to look at first.

I also pulled historical snapshots via /history. One domain had clean email security three months ago, then dropped SPF and DMARC the week before the attack. That history view was the clearest signal. A benign domain doesn't suddenly strip its email records right before a spam campaign. I need a larger sample before I auto-block on that pattern alone.

The subdomain discovery data found dangling CNAMEs on 8 domains. In a bug-bounty context that's free money. Here it meant the infrastructure was thrown together fast. They left doors open. I reported two to the affected providers. One was fixed in 36 hours. The other is still dangling.

How to use Domain WHOIS API

If you want to try it yourself, the endpoint is on RapidAPI and the docs/examples are on GitHub.

curl --request GET \
  --url 'https://domain-whois2.p.rapidapi.com/whois?domain=example.com' \
  --header 'X-RapidAPI-Key: YOUR_KEY' \
  --header 'X-RapidAPI-Host: domain-whois2.p.rapidapi.com'
Enter fullscreen mode Exit fullscreen mode
import requests, os

url = "https://domain-whois2.p.rapidapi.com/whois"
querystring = {"domain": "example.com"}
headers = {
    "X-RapidAPI-Key": os.environ["RAPIDAPI_KEY"],
    "X-RapidAPI-Host": "domain-whois2.p.rapidapi.com"
}

response = requests.get(url, headers=headers, params=querystring)
print(response.json())
Enter fullscreen mode Exit fullscreen mode

The response combines WHOIS/RDAP, DNS records, SSL cert, subdomains, takeover risk, and the email-security score. For historical data, swap the path to /history?domain=example.com.

Why this beats IP blocking alone

IP blocking never ends. Residential proxies rotate every request. VPN exit nodes number in the thousands. ASN-level blocks catch innocents. Domain names are different. They're the one thing an attacker has to publish. They want the link to resolve. That makes the domain the fixed point in the whole chain. We're not going back to IP-only bans.

WHOIS hands me registrar, creation date, name servers, and an abuse contact. RDAP serves structured JSON instead of the old text blobs. But raw RDAP is incomplete. It won't tell you if auth.example.com is dangling, won't score SPF, and won't show a domain losing DMARC last month. The enriched response wraps all of that into one call.

After enriching the 52 hostnames, I changed my moderation logic. Instead of banning the IP, I now look up the domain in the posted URL first. A score of 4 or higher sends the message to a human review queue. Domains under a week old and missing DMARC are auto-hidden. False positives so far: one. A brand-new personal blog with no DMARC got caught. I whitelisted it manually. That friction is fine for my tiny site. A high-traffic platform would need a different threshold.

The pipeline I shipped

I turned the script into a small FastAPI service and wired it behind the chat backend the same day. When the chat backend sees a URL, it POSTs the hostname to the service. Results cache in Redis for six hours; the enrichment API only gets called on a cache miss. The whole lookup averages 480 ms. With caching, 94% of requests are under 30 ms.

The service emits a webhook to a private Slack channel. The webhook carries score, domain age, registrar, and the exact email-security gaps. My moderation volunteers can click the abuse-report link straight from the notification. We filed 17 abuse reports in the first week. Three domains were suspended. That's a 17% takedown rate from reports we never wrote manually.

I open-sourced the service skeleton on GitHub so you can adapt it. It's intentionally minimal. I don't want a black-box threat-intel product, just a lookup wrapper with scoring and caching.

What I got wrong

The first version of my scorer weighted domain age too heavily. It flagged a 10-year-old domain that had been compromised and used in the attack. It scored 1. The attacker didn't register it. They stole it. My model missed that entirely. I can't auto-block on domain age alone.

I added a separate signal for recent SSL certificate changes and new subdomains. That helped, and also added false positives. I'm still tuning the weights.

I also tried to auto-report every domain with a takeover risk. Bad idea. One CNAME looked abandoned but pointed to a legitimate parked page on a cloud provider. It looked dangling to the API but wasn't. I almost sent a bogus report. Now I verify takeover candidates manually with a second DNS resolution and an HTTP probe. Automation plus messy data equals mistakes.

Rate limits bit me too. My first batch script had no delay. I hit the ceiling at 100 requests in a minute and got 429s. The time.sleep(0.6) in the script above keeps me under the limit. Now I check the limit before I run anything.

The bottom line

I didn't stop the attackers. They still show up. But now I understand their infrastructure. I know which registrars they prefer, which email-security gaps they exploit, how fresh their domains are. That knowledge turns a chaotic moderation queue into a prioritized triage list. Infrastructure intelligence turned out to be more useful than firewall rules.

If you run any user-generated content, start logging the domains people link. Enrich them. Enriched records reveal age, DNS, email posture, and history.

The Domain WHOIS API I used is available on RapidAPI, and the code/examples live on GitHub. Start small. You don't need a SIEM.

If you had to pick one signal to add first, what would it be: age, email-security score, or takeover risk?

Top comments (0)