My server is under attack every day

My server has been under this type of attack every day for about 1 year and as far as I have researched, it is a similar type of attack on multiple servers.

Django’s own security is not fully effective against this attack, it only causes the other party to get a 400 error, but since the attack is ongoing, the system goes down temporarily and then comes back up again. Probably because I’m using aws elastic beanstalk, the system gets itself back up and running.

172.31.44.14 - - [21/Sep/2024:01:43:13 +0000] "GET / HTTP/1.1" 400 154 "-" "Mozilla/5.0" "35.216.141.220"
172.31.44.14 - - [21/Sep/2024:01:45:30 +0000] "GET / HTTP/1.1" 400 154 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36" "45.156.129.46"
172.31.26.204 - - [21/Sep/2024:01:46:39 +0000] "GET /.well-known/acme-challenge/0JCWGV2T84AUO8ZXX8HLI-GW7EEZNV9G HTTP/1.1" 301 5 "-" "Cpanel-HTTP-Client/1.0" "94.199.206.16"
172.31.26.204 - - [21/Sep/2024:01:46:40 +0000] "GET /.well-known/acme-challenge/0JCWGV2T84AUO8ZXX8HLI-GW7EEZNV9G HTTP/1.1" 200 6699 "-" "Cpanel-HTTP-Client/1.0" "94.199.206.16"
172.31.26.204 - - [21/Sep/2024:01:59:04 +0000] "GET /.env HTTP/1.1" 400 154 "-" "Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Raspbian Chromium/72.0.3626.121 Chrome/72.0.3626.121 Safari/537.36" "91.92.243.155"
172.31.44.14 - - [21/Sep/2024:02:11:07 +0000] "POST /cgi-bin/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/.%2e/bin/sh HTTP/1.1" 400 157 "-" "-" "-"
172.31.44.14 - - [21/Sep/2024:02:11:07 +0000] "POST /cgi-bin/%%32%65%%32%65/%%32%65%%32%65/%%32%65%%32%65/%%32%65%%32%65/%%32%65%%32%65/%%32%65%%32%65/%%32%65%%32%65/bin/sh HTTP/1.1" 400 157 "-" "-" "-"
172.31.44.14 - - [21/Sep/2024:02:11:07 +0000] "POST /hello.world?%ADd+allow_url_include%3d1+%ADd+auto_prepend_file%3dphp://input HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:07 +0000] "GET /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:09 +0000] "GET /vendor/phpunit/phpunit/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:10 +0000] "GET /vendor/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:10 +0000] "GET /vendor/phpunit/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:10 +0000] "GET /vendor/phpunit/phpunit/LICENSE/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:11 +0000] "GET /vendor/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:12 +0000] "GET /phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:13 +0000] "GET /phpunit/phpunit/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:17 +0000] "GET /phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:18 +0000] "GET /phpunit/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:19 +0000] "GET /lib/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:19 +0000] "GET /lib/phpunit/phpunit/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:21 +0000] "GET /lib/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:21 +0000] "GET /lib/phpunit/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:21 +0000] "GET /lib/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:22 +0000] "GET /laravel/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:22 +0000] "GET /www/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:23 +0000] "GET /ws/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:24 +0000] "GET /yii/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:24 +0000] "GET /zend/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:24 +0000] "GET /ws/ec/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:24 +0000] "GET /V2/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:28 +0000] "GET /tests/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:28 +0000] "GET /test/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:28 +0000] "GET /testing/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:29 +0000] "GET /api/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:29 +0000] "GET /demo/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:29 +0000] "GET /cms/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:31 +0000] "GET /crm/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:32 +0000] "GET /admin/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:32 +0000] "GET /backup/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:33 +0000] "GET /blog/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:34 +0000] "GET /workspace/drupal/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:34 +0000] "GET /panel/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:34 +0000] "GET /public/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:35 +0000] "GET /apps/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"
172.31.44.14 - - [21/Sep/2024:02:11:35 +0000] "GET /app/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 400 154 "-" "Custom-AsyncHttpClient" "47.251.99.88"

A solution of my own creation:

Models:

from django.db import models
from django.utils.timezone import now, timedelta

class BlockedIP(models.Model):
    ip_address = models.GenericIPAddressField(unique=True)
    blocked_url = models.URLField(max_length=200, null=True, blank=True)
    blocked_at = models.DateTimeField(default=now)
    ban_duration = models.DurationField(default=timedelta(hours=720))
    ban_expires_at = models.DateTimeField(editable=False, help_text="Sadece bilgi amaçlı var")

    def save(self, *args, **kwargs):
        self.ban_expires_at = self.blocked_at + self.ban_duration
        super().save(*args, **kwargs)

Middleware:

import re
from django.core.cache import cache
from django.http import HttpResponseForbidden
from .models import BlockedIP
from django.utils.timezone import now, timedelta

class BlockMaliciousRequestsMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
        self.ATTEMPTS_LIMIT_FOR_400 = 25
        self.MSG = "Your IP address has been blocked."

        # Regex pattern for paths to block
        self.blocked_patterns = re.compile(
            r'(\.env|\.php|\.ini|\.bak|\.log|\.sql|\.conf|\.old|\.tar|\.gz|\.sh|\.yml)$', re.IGNORECASE
        )

    def __call__(self, request):
        ip = self.get_client_ip(request)

        # We removed the check for authenticated users, as the protection is handled at a lower layer now.
        # if request.user.is_authenticated:
        #     return self.get_response(request)

        # Check cache for blocked IP
        cache_key = f"blocked_ip_{ip}"
        blocked_ip_data = cache.get(cache_key)

        if blocked_ip_data is None:
            # If not found in cache, or incorrect type, check the database
            blocked_ip = BlockedIP.objects.filter(ip_address=ip, blocked=True).first()
            if blocked_ip:
                # Save the necessary data to cache
                blocked_ip_data = {
                    'blocked_at': blocked_ip.blocked_at,
                    'ban_duration': blocked_ip.ban_duration.total_seconds()
                }
                cache.set(cache_key, blocked_ip_data, timeout=3600)  # 1 hour (3600 seconds)
            else:
                blocked_ip_data = None

        # If the IP is blocked, check if the block duration has expired
        if blocked_ip_data:
            blocked_at = blocked_ip_data['blocked_at']
            ban_duration = timedelta(seconds=blocked_ip_data['ban_duration'])

            if now() < blocked_at + ban_duration:
                return HttpResponseForbidden(self.MSG)
            else:
                # If the block duration has expired, clear the cache
                cache.delete(cache_key)

        # Immediately block specific paths and HEAD requests
        if self.blocked_patterns.search(request.path) or request.method == 'HEAD':
            self.block_ip(ip, request.path)
            return HttpResponseForbidden(self.MSG)

        response = self.get_response(request)
        
        # Block requests with a 400 status code
        if response.status_code == 400:
            attempts = cache.get(ip, 0) + 1
            cache.set(ip, attempts, timeout=60)  # 60 seconds
            if attempts > self.ATTEMPTS_LIMIT_FOR_400:
                self.block_ip(ip, request.path)
                return HttpResponseForbidden(self.MSG)

        return response

    def get_client_ip(self, request):
        """Retrieve the client's IP address, accounting for reverse proxy."""
        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
        if x_forwarded_for:
            ip = x_forwarded_for.split(',')[0]
        else:
            ip = request.META.get('REMOTE_ADDR')
        return ip

    def block_ip(self, ip, path):
        """Block the specified IP address for a given duration."""
        ban_duration = timedelta(hours=720)  # Block the IP address for 720 hours (1 month).

        blocked_ip, created = BlockedIP.objects.get_or_create(
            ip_address=ip,
            defaults={
                'blocked_at': now(),
                'blocked_url': path,
                'ban_duration': ban_duration
            }
        )

        # Save the necessary data to cache
        blocked_ip_data = {
            'blocked_at': blocked_ip.blocked_at,
            'ban_duration': ban_duration.total_seconds()
        }
        cache.set(f"blocked_ip_{ip}", blocked_ip_data, timeout=int(ban_duration.total_seconds()))

        if not created:
            # Update the previously blocked IP
            blocked_ip.blocked_at = now()
            blocked_ip.ban_duration = ban_duration
            blocked_ip.blocked_url = path
            blocked_ip.save()

Signals:

from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.core.cache import cache
from .models import BlockedIP

@receiver(pre_delete, sender=BlockedIP)
def unblock_ip_cache(sender, instance, **kwargs):
    ip = instance.ip_address
    cache_key = f"blocked_ip_{ip}"
    
    cache.delete(cache_key)

settings:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',  # Security should be first
    'blocklist.middleware.BlockMaliciousRequestsMiddleware',  # Handles malicious requests
    'django.contrib.sessions.middleware.SessionMiddleware',  # Session management
    'django.middleware.locale.LocaleMiddleware',  # For language settings
    'django.middleware.common.CommonMiddleware',  # General request handling
    'django.middleware.csrf.CsrfViewMiddleware',  # CSRF protection
    'django.contrib.auth.middleware.AuthenticationMiddleware',  # User authentication, required for is_authenticated
    'django_ratelimit.middleware.RatelimitMiddleware',  # For rate limiting and security controls
    'django.contrib.messages.middleware.MessageMiddleware',  # Manages user messages
    'django.middleware.clickjacking.XFrameOptionsMiddleware',  # Clickjacking protection
]

But I still can’t handle the issue the way I want. The problem is that I add user control in the middleware so that the security measures I take do not block users registered in the system.

if request.user.is_authenticated:
    return self.get_response(request)

For this check to work correctly, I need to add my own middleware after AuthenticationMiddleware. If I do this, the django security will block the attack before it reaches my middleware and the attacker will only get a 400 error. This security measure fails to protect the environment and my system temporarily goes critical and then comes back up.

'django.contrib.auth.middleware.AuthenticationMiddleware'

If I put my middleware at the top without user control, I occasionally block users in the system.

What are your suggestions for how I can defeat this attack without damaging my system and prevent the attacker from accessing my site?

Thank you.

Even without that setting, it seems like it would return 404, so why check a specific path and return 400?

If I do not use the middleware I created, the requests return with a status of 400. In this case, as I mentioned, the server becomes critical for a short period of time. I guess this is because django security sees a problem with these requests and gives a 400 bad request error instead of 404.

What I want is to return 403 or 429 errors for these requests except for registered users, so that I think the server will not fall into a critical status.

Welcome to the wild world of the internet. More than 90% of all requests to my public sites are garbage requests like these.

You’ve got a couple different options that help mitigate it.

What I do:

  • I move my applications out of the root url path. Other than a redirect at “/”, all my urls resolve at /<project_name>/<something>/.
    I then use nginx to filter out the vast majority of the trash.
    I reject everything that doesn’t start with /<project_name>/.
    I reject everything with .php.
    I reject everything with /wp/. (There are a few other patterns I reject as well.)
    Since this is all done by nginx, it doesn’t even get to my Django app.
  • I have fail2ban set up to block IP addresses issuing more than 1 request to one of these targeted URLs.

The combination of these two steps have easily stopped the vast majority of the garbage requests, to the point that I just generally monitor the traffic. I’m not worried about the few that aren’t caught. (And when I see increasing numbers of one particular request, I’ll add that to my ban lists as well.)

4 Likes

In Django’s structure, a non-existent URL should return 404, but why does it return 400?

If I get stuck I will come back here to ask additional questions or if my problem is solved, I will mark it as solved.

Thank you

I guess the request sender doesn’t meet the requirements on the header side. “USER AGENT” etc.

I am curious about how you do this. Could you share your rules?

this is interesting. I thought redirects was bad practice.

Do you think this nginx configuration is enough?
Is there any information I forgot to add?

files:
  "/etc/nginx/conf.d/01_block_patterns.conf":
    mode: "000755"
    owner: root
    group: root
    content: |
      http {
          # Use a map to check User-Agent headers
          map $http_user_agent $blocked_user_agent {
              default 0;
              "~*curl" 1;
              "~*wget" 1;
              "~*bot" 1;
              "~*spider" 1;
              "~*crawl" 1;
              "~*Custom-AsyncHttpClient" 1;
              "~*Cpanel-HTTP-Client/1.0" 1;
          }

          server {
              # Block specific file extensions
              location ~* \.(php|env|ini|log|sql|conf|bak|old|sh|yml)$ {
                  return 403;
              }

              # Block WordPress requests
              location ~* /(wp-admin|wp-login|wp-content|wp-includes)/ {
                  return 403;
              }

              # Block HEAD and TRACE requests
              if ($request_method ~* (HEAD|TRACE)) {
                  return 403;
              }

              # Block malicious User-Agents
              if ($blocked_user_agent) {
                  return 403;
              }

              # Limit the size of POST requests
              client_max_body_size 1M;

              # Timeout settings
              client_body_timeout 10s;
              client_header_timeout 10s;
              keepalive_timeout 5s 5s;
              send_timeout 10s;
          }
      }

I can, but I’m at DjangoCon this week, so it may take me a couple days to get to it.

I reject the request, not redirect.