Four Strategies for dealing with Script Kiddies / Probing / Attacks

David Janes
5 min readFeb 16, 2025

--

If you’ve ever set up a website, you’ll notice a number of real annoying behaviours in the logs, such as:

  • attempts to login via SSH (sometimes in the thousands)
  • weird URLs showing being GET
  • fake accounts being created

Do not take any of this personally. There are scammers, government agencies, bored teenagers, and criminals of varying degree doing this automatically looking for known bugs and weaknesses in websites. You’re not being targeted (probably), it’s just scripts systematically working their way through lists of IPs.

If your website is well set up, this is moderately harmless — but it is unnecessarily consuming resources. However, if it triggers actions such as emails being sent, you may be degrading your reputation.

This article explains a couple of strategies I’m using to deal with this on onamap.com.

reCAPTCHA

“reCAPTCHA” is a free service from Google. You’ve all seen it — generally it may be a simple as clicking on a box, or you may have to e.g. select all the motorcycles in a picture.

I recommend this on all login pages, but especially account creation and password reset pages where emails are triggered. Setting up is easy (really — not “how to draw an Owl easy”) in Django:

from django.core.exceptions import ValidationError
from allauth.account.forms import LoginForm
from django_recaptcha.fields import ReCaptchaField

class OnamapLoginForm(LoginForm):
captcha = ReCaptchaField()

def clean(self):
cleaned_data = super().clean()
if not cleaned_data:
raise ValidationError('Form did not validate')

if 'captcha' not in cleaned_data:
raise ValidationError('Invalid CAPTCHA. Please try again.')

return cleaned_data

Fail2Ban

Fail2Ban is an intrusion prevention tool for Linux (specifically Ubuntu in our case). It monitors log files for such things as repeated failed login attempts and bans IP addresses via updating your firewall rules.

It’s a little bit ugly to set up, but worth it. This will (more or less) out of the box deal with SSH attacks, but you can use it to monitor your own logfiles and set up your own rules.

What’s great about Fail2Ban is you can set up your own rules on your own logfiles. I keep a file that is literally just a list of detected IPs that have done known bad things (see the next sections).

When I log in to the site, I get to see who’s been naughty:

# fail2ban-client status spam-ip
====
Status for the jail: spam-ip
|- Filter
| |- Currently failed: 0
| |- Total failed: 30
| `- File list: /home/onamap/var/spam_ip.txt
`- Actions
|- Currently banned: 48
|- Total banned: 75
`- Banned IP list: 103.161.171.205 (many IPs follow)

It requires two configuration files — one defines the format of the log file (in my case, literally one IP per line) — and the other “all the other stuff”. As soon as “spam_ip.txt” is update, Fail2Ban detects it about puts it on the list for 20 hours. Why not forever? Because these IPs are probably only short term rentals.

# cat /etc/fail2ban/filter.d/spam-ip.conf
[Definition]
failregex = ^<HOST>$
ignoreregex =

# cat /etc/fail2ban/jail.d/spam-ip.conf
[spam-ip]
enabled = true
filter = spam-ip
logpath = /home/onamap/var/spam_ip.txt
maxretry = 1
bantime = 72000
findtime = 72000

# head -5 /home/onamap/var/spam_ip.txt
104.239.2.198
104.239.2.198
38.47.35.13
111.67.101.205
186.235.190.192

Honeytrap

Even after adding reCAPTCHA, I was getting a surprising number of account creations that obviously had no interest in the site (real estate agents, get your act together).

I solved this by adding a hidden Honeytrap URL — it looks like all the other login pages, but if you submit (i.e. HTTP POST) you are added to the list of banned IPs. There is no way you would get to this page by browsing on the site.

from django.http import HttpResponse, HttpRequest
from django.views.generic import TemplateView
from django.conf import settings

import logging as logger

class HoneytrapView(TemplateView):
"""
"""
template_name = "account/login.html"

def post(self, request:HttpRequest, *args, **kwargs) -> HttpResponse:
from system.helpers import get_client_ip
L = "HoneytrapView.post"

spam_ip_log = getattr(settings, "SPAM_IP_LOG", None)
if spam_ip_log:
try:
ip_address = get_client_ip(request)
with open(spam_ip_log, "a") as log_file:
log_file.write(f"{ip_address}\n")

logger.info(f"{L}: SPAM IP: {ip_address}")
except IOError as e:
logger.error(f"{L}: Error writing to spam IP log: {e}")

return HttpResponse("You are not authorized to view this page.", status=403)

And then on the login option pages, I just add the URL for this View as a hidden link.

<section class="sm:mt-4 border-t border-gray-300 pt-4">

<h3 class="font-medium text-lg mb-2">
Sign in Options
</h3>
<div class="ml-2">
<div class="hidden">
&ndash; <a href="{% url 'Honeytrap' %}">Login</a></p>
</div>
<div>
&ndash; <a href="{% url 'account_signup' %}">Sign up</a></p>
</div>
<div>
&ndash;
<a href="{% url 'account_login' %}">Login with password</a>
</div>
<div>
&ndash;
<a href="{% url 'account_request_login_code' %}">Login with code</a>
</div>
<div>
&ndash; <a href="{% url 'account_reset_password' %}">Reset password</a></p>
</div>

</div>

</section>

URL monitoring

Finally, I added Django “Middleware” for the bad URLs I was seeing showing up in the logfiles. This just sits early in the request processing workflow, and if it sees you’re trying to get some weird URL, it adds you to the Spam list.

This accounts for about 90% or so of the Spam IPs we see.

The reason it is threaded is so theHTTP response thread is not held up doing I/O. Likely overkill, but it works well.

## =============
## Spam Handling
## =============

SPAM_PATTERNS = [
"/__media__/",
"/.git/",
"/.svn/",
"/.ssh/",
"/xampp/",
"/wp-content/",
"/wp-include/",
"/terraform",
re.compile(r"xmlrpc[.]php"),
re.compile(r"wlwmanifest[.]xml"),
re.compile(r"/wp-admin/"),
re.compile(r"/wp-login[.]php"),
re.compile(r"/xmlrpc[.]php"),
re.compile(r"sendgrid[.]"),
re.compile(r"sendgrid_config[.]"),
re.compile(r"sendgrid_keys[.]"),
re.compile(r"sendgrid_mail[.]"),
]

# Shared queue for IP processing
ip_queue = Queue()

# Worker thread to process queued IPs
def ip_worker():
spam_ip_log = getattr(settings, "SPAM_IP_LOG", None)

while True:
ip_address = ip_queue.get()
if ip_address is None: # Exit signal
break

if ip_address in [ "127.0.0.1", "XXXXX" ]:
print(f"Skipping local IP: {ip_address}")
ip_queue.task_done()
continue

if spam_ip_log:
try:
with open(spam_ip_log, "a") as log_file:
log_file.write(f"{ip_address}\n")
except IOError as e:
logger.error(f"SpamFilterMiddleware: Error writing to spam IP log: {e}")

# Perform an action with the IP address (e.g., logging, banning, etc.)
logger.info(f"SpamFilterMiddleware: SPAM IP: {ip_address}")
ip_queue.task_done()

# Start the worker thread
worker_thread = threading.Thread(target=ip_worker, daemon=True)
worker_thread.start()

class SpamFilterMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.spam_patterns = SPAM_PATTERNS

def __call__(self, request):
# Iterate through the patterns
for pattern in self.spam_patterns:
ip_address = self.get_client_ip(request)

if isinstance(pattern, str):
# Match if the request path starts with the string
if request.path.startswith(pattern):
ip_queue.put(ip_address)
return HttpResponseNotFound('<h1>404 Not Found</h1>')
elif isinstance(pattern, re.Pattern): # Check if pattern is a regex
# Match if the regex search finds a match in the request path
if pattern.search(request.path):
ip_queue.put(ip_address)
return HttpResponseNotFound('<h1>404 Not Found</h1>')

# Proceed to the next middleware or view
return self.get_response(request)

def get_client_ip(self, request):
"""Extract the client IP address, accounting for reverse proxies."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
# Take the first IP in the list (client's IP behind proxy)
ip = x_forwarded_for.split(',')[0].strip()
else:
# Use REMOTE_ADDR if X-Forwarded-For is not set
ip = request.META.get('REMOTE_ADDR')
return ip

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

David Janes
David Janes

Written by David Janes

Entrepreneur. Technologist. Mercenary Programmer.

No responses yet

Write a response