Big Dick

Big Dick

When I got tired of hacker attacks, I set up a tar trap and eventually built a blanket protection system for 8 websites.

At some point, I became really fed up with hacker attacks on my group of websites. It wasn't that they were actually hacking anything, but the feeling that bots were methodically tugging at your door handles day and night was quite annoying. I wondered how to remove all this and, if possible, repay the damage with damage—in kind.

Artificial Intelligence

...We taught sand to think, drew runes on it with light, and brought it to life with lightning...

I turned to AI and asked: what can be done in this case? Here's the website, here's its rough layout, here are the logs—let's analyze who visited us, what they wanted, and why they needed it. And while we're at it, let's think about what we can do to discourage people from coming back.

He suggested making a tar trap.

A tar trap (tarpit) is when a bot or hacker can't terminate a session and is forced to receive what they requested, which could be a very large, very long, and completely useless file.

Well, why not make it a little less useless? Let it receive an infinitely large and very slowly flowing three-letter word—on the monitor and in the log. A word known to all native Russian speakers. And let's also let all non-Russians know: this is us, and you should only come here with good intentions.

Technically, it works like this. When a request hits one of the "honey" triggers—a hidden form field specifically steered toward bots that only autocompleters click on, or a string of characters in the request parameters that clearly resembles an SQL injection—the server doesn't simply return an error and bid the guest farewell. Instead, it honestly reports HTTP 200 OK, meaning "everything worked, the request has been accepted," disables buffering and response compression, and opens the connection as text/plain. And then comes the endless loop: every second, another "FUCK" message with a space at the end (to prevent the words from sticking together into one solid block of text) flies into the socket, the server flushes the buffer, and sleeps for exactly one second until the next batch arrives. This continues until the bot or hacker's script terminates the connection itself—the server will never release it manually. From the attacker's perspective, this looks like a successful server response that somehow never ends: the connection is alive, the data is dripping in, but the payload—that is, the database, dump, or whatever the bot needed—is still missing. Naive scanners that try to read the entire response body into memory before parsing it simply hang or timeout at this point. Meanwhile, this conversation costs the server almost nothing: the PHP process isn't running the entire time, but sleeping, so the resources spent on a single frozen bot are negligible. But the bot loses a thread, a worker, or whatever it was allocated for this attack, and it takes far more time than it expected.

What I did

As a result, the number of malicious visits dropped by about 20 times. Besides the tar trap, of course, other protection mechanisms are in place: daily site integrity and file permissions checks, a count of how many admins have visited the site in a day, and automatic remediation of any issues that arise. All of this runs automatically, via cron. So, I'm happy with the result so far.

But the idyll didn't last long: after a while, it became clear that part of this protection wasn't working quite as intended. More on how everything was actually set up later.

Problem Statement

Even before, I'd already tried to solve the security issue—but only for one site, a pilot one. I had some prefabricated solutions: my own .htaccess with manual bans, my own hook script. It worked, but it was a crude, rudimentary affair, lacking any logic—a "live-action" approach, so to speak. The idea didn't go beyond that one site: I absolutely didn't want to manually scale the same thing across seven more domains.

My hosting account (/home/Acc12345/) runs eight sites—mostly Joomla, a couple using pure HTML. Each site has the same set of "protective" files in its own folder:

public_html/

  ├── site1.com/

  │     ├── .htaccess          <-- blocking at the Apache level

  │     ├── local_bans.conf    <-- list of banned IPs

  │     ├── sentinel.php       <-- trap for bots

  │└── incident.log <-- log of captured attacks

  ├── site2.com/ ... (same)

  ├── site3.com/

  ├── site4.com/

  ├── site5.com/

  ├── site6.com/

  ├── site7.com/

  ├── site8.com/

  ├── guardian.sh              <-- cron: scan + protection

  └── weekly_audit.php         <-- cron: weekly report

Idea: sentinel.php hangs on ErrorDocument 403, catches bots (WordPress scanners, attempts to get .git/.env, Joomla exploits, honeypot triggers), writes their IP in local_bans.conf and the incident are in incident.log. guardian.sh should cron-make these IPs into .htaccess as a hard ban at the Apache level (Require not IP). Once a week, weekly_audit.php sends a report by email.

Problem: IPs were accumulating correctly in local_bans.conf, but not in .htaccess. The bot is formally "banned," but in reality, it continues to attack the site.

Step 1. Who should write in .htaccess?

Before fixing it, it was necessary to understand the role of each of the three scripts. The code quickly revealed:

●      sentinel.php — the core: detects the attack, writes the IP to local_bans.conf, and the incident to incident.log.

●      guardian.sh — the only one that should transfer the IP from the log to .htaccess (step "6.5" in the script).

●      weekly_audit.php — only reads local_bans.conf and sends an email. It doesn't touch .htaccess at all.

Step 2. Bug #1 – my typo that broke everything

In guardian.sh, the line that was supposed to pull new IPs from the log:

NEW_IPS=$(grep "\[TRAFFIC BAN\]" "$CUSTOM_LOG" \

    | awk -F'IP: ' '{print $2}' | ...)

The script was literally looking for the substring [TRAFFIC BAN] – the opening parenthesis immediately before the word "TRAFFIC". Sentinel.php never writes that: it always has more specific types – [WORDPRESS SNOOPER BAN], [JOOMLA TRAFFIC BAN], [HELIX HONEYPOT BAN]. There's always another word between the bracket and the word "TRAFFIC"/"BAN." grep never found anything, the variable was always empty.

Fix: extract IPs by the IP: label, which appears in any log line, regardless of type:

NEW_IPS=$(grep -oP 'IP:\s*\K[0-9a-fA-F:.]+' "$CUSTOM_LOG" | sort -u)

So, for months, the tar trap faithfully boiled bots alive, the log was littered with entries, and .htaccess lived its own quiet life, unaware of it. A classic: the code doesn't crash, there are no errors, meaning it "works"—but in reality, it's just silently doing nothing.

Step 3. Bug #2: HTML sites dropped out of the process

The entire guardian.sh main loop only ran for sites with a configuration.php:

if [ -d "$SITE_PATH" ] && [ -f "${SITE_PATH}/configuration.php" ]; then

For HTML sites (without Joomla), .htaccess synchronization didn't work at all, even after the grep fix. The solution is to move .htaccess synchronization to a separate loop that runs through all 8 sites without checking configuration.php, and run it before the main (heavy, Joomla-specific) scan:

echo -e "${YELLOW}=== SYNCING .HTACCESS (ALL SITES) ===${NC}"

for SITE_PATH in "${SITES[@]}"; do

    if [ -d "$SITE_PATH" ]; then

        SITE_DOMAIN=$(basename "$SITE_PATH")

        sync_htaccess_blacklist "$SITE_PATH" "$SITE_DOMAIN"

  fi

done

# MAIN CYCLE (deep crawl - Joomla only)

for SITE_PATH in "${SITES[@]}"; do

    if [ -d "$SITE_PATH" ] && [ -f "${SITE_PATH}/configuration.php" ]; then

Step 4. Is there any cross-site protection at all?

Checking what's actually in the .htaccess files of different sites revealed that the IP lists on Site 1 and Site 4 don't overlap at all. This means that a bot banned on one site could easily try its luck on the next—as if there were no fences separating eight houses owned by the same owner. Parsing sentinel.php revealed the cause: there is an $all_blacklists array containing all 9 paths, but it is only used for reading (checking whether the user is already banned), and the ban was only written to the current site's file:

Before — only local bans:

$ban_bot_action = function($type) use ($blacklist_file, $ip, $uri) {

    // ... reads and writes only $blacklist_file (local)

    if (!in_array($ip, $lines, true)) {

        $lines[] = $ip;

        @file_put_contents(

$blacklist_file,

          implode(PHP_EOL, $lines) . PHP_EOL,

          LOCK_EX

      );

}

};

Afterwards - bans on all 9 sites at once:

$ban_bot_action = function($type) use ($ip, $uri, $all_blacklists) {

  foreach ($all_blacklists as $target_file) {

$lines = @file($target_file,

FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: array();

        $lines = array_map('trim', $lines);

        $lines = array_filter($lines, function($line) {

          return filter_var($line, FILTER_VALIDATE_IP) !== false;

      });

        if (!in_array($ip, $lines, true)) {

          $lines[] = $ip;

@file_put_contents(

                $target_file,

                implode(PHP_EOL, $lines) . PHP_EOL,

                LOCK_EX

            );

        }

    }

    // We log the incident only to the log of the site where the attack occurred

    $log_file = __DIR__ . '/incident.log';

    @file_put_contents($log_file, $log_entry, FILE_APPEND);

};

The guardian.sh logic has changed accordingly: since local_bans.conf is now shared, .htaccess synchronization is now performed not based on the local attack log, but directly based on the contents of local_bans.conf.

Step 5. Synchronization should not only add, but also remove

While the list was "only growing," simply adding new lines was sufficient. But with the advent of weekly cleanup (the next step), it became clear: if an IP is missing from local_bans.conf, but the "Require not ip" line remains, cleaning it is useless. I rewrote the synchronization function to use full reconciliation — each run completely reassembles the contents between the labels:

awk -v block="$IP_BLOCK" '

    /# Blacklist Start/ { print; in_block=1; next }

    /# Blacklist End/ { printf "%s", block; print; in_block=0; next }

    {

        if (in_block == 1) {

            if ($0 ~ /Require all granted/) { print; next }

          else { next }   # old “Require not ip ...” - recreated again

        } else {

          print

        }

}

' "$HTACCESS_FILE" > "$TMP_FILE"

Testing on a copy of real data revealed two additional bugs in my own code:

● $(...) in bash trims the final newline — the last IP was merging with the # Blacklist End tag into a single line. I had to explicitly return \n after the substitution: IP_BLOCK="${IP_BLOCK}"$'\n'

● In a bootstrap block (creating tags from scratch), <RequireAll> ended up inside the Start/End tags — and a full rebuild, cleaning out everything "unrecognized" inside the block, ate the tag itself. I adjusted the order so that <RequireAll> Wrapped the tags outside, as in real files.

After the fixes, there are no duplicates (a rerun doesn't create duplicates) and correct operation has been verified on synthetic cases, including "a file with no tags at all" and "a file with existing tags."

Step 6. The list will grow forever – cleanup required

Initially, the idea in the comments to weekly_audit.php was clearly a "permanent ban," with cleanup deliberately disabled. We discussed a compromise: how many IPs to keep after cleanup (we settled on the 100 most recent) and what to do with older ones that will be "cleaned" when they become full. The solution is a separate "permanent" list for manually banned IPs, which is unaffected by automatic cleanup.

6.1. permanent_bans.conf

New file in the root public_html/, edited manually only. When checking whether an IP address is already banned, sentinel.php also looks at this file (for instant blocking), but the ban function never writes to it:

$permanent_blacklist_file = $base_path . 'permanent_bans.conf';

$all_blacklists_for_check = array_merge(

  $all_blacklists, [$permanent_blacklist_file]

);

foreach ($all_blacklists_for_check as $file) {

  // ​​... check for 503, ban_bot_action() does NOT write here

}

6.2. Weekly cleanup in weekly_audit.php

$MAX_BLACKLIST_SIZE = 100;

if ($total_before > $MAX_BLACKLIST_SIZE) {

    // File order = append-only order

    $dropped_ips = array_slice(

        $ref_lines, 0, $total_before - $MAX_BLACKLIST_SIZE

    );

    $kept_ips = array_slice($ref_lines, -$MAX_BLACKLIST_SIZE);

  foreach ($sites as $site_path) {

        $site_blacklist = rtrim($site_path, '/')

          . '/local_bans.conf';

        @file_put_contents(

          $site_blacklist,

          implode(PHP_EOL, $kept_ips) . PHP_EOL,

            LOCK_EX

        );

    }

}

guardian.sh combines both sources—the sliding list and the permanent one—during synchronization, so permanent bans always remain in .htaccess, even if they have long since dropped out of the top 100:

ALL_IPS=$(cat "$BLACKLIST_FILE" "$PERMANENT_BLACKLIST" 2>/dev/null \

    | grep -v '^#' | grep -v '^[[:space:]]*$' | tr -d ' \t' | sort -u)

6.3. Removed duplicate logic

We also found and removed an extra piece of code in sentinel.php – an old hard cap of 500 entries, applied to each of the 9 files independently in real time:

// Before:

if (count($lines) > 500) { $lines = array_slice($lines, -500); }

 

// After: line removed. The only source of truth regarding size

// of the list is a weekly cleanup in weekly_audit.php.

Step 7. One-time alignment of accumulated "goods"

All fixes solve the problem for the future, but at the time of implementation, eight sites already had different, unsynchronized lists. Wrote a one-off merge_bans.sh:

●      Collects the actual ban time of each IP from the incident.log of all 8 sites — using the actual timestamp from the log, not the order of the lines in the file.

●      If an IP from local_bans.conf doesn't have a log entry, it takes that one too, but with the lowest freshness priority.

●      Sorts by freshness, removes duplicates, and keeps the top 100.

●      Writes a single list to all 8 local_bans.conf files and immediately rebuilds .htaccess — without waiting for cron.

The key fragment is compiling the timestamp from the log format:

awk -F'[][]' '

{

        ts = $2   # "24-Aug-2026 15:28:33 UTC"

        if (match($0, /IP: [0-9a-fA-F:.]+/)) {

          ip = substr($0, RSTART + 4, RLENGTH - 4)

          print ts "\t" ip

        }

}

' "$LOGF" | while IFS=$'\t' read -r ts ip; do

    epoch=$(date -d "$ts" +%s 2>/dev/null) || continue

  echo -e "${epoch}\t${ip}" >> "$TMP_TS_IP"

done

Result of a real run: 80 log entries → 39 unique IPs in old scattered lists → 56 unique ones in total (the 100 limit was not exceeded, no trimming was needed) → all 8 sites synchronized in a single run.

Step 8. Log Parsing - A Real-World Test

After the implementation, the user sent a real web server error log for a couple of days. Parsing via bash/awk:

grep -oP '(?<=\d{4}\] \[)\w+:error' "$FILE" | sort | uniq -c | sort -rn

grep -oP 'public_html/\K[^/]+' "$FILE" | sort | uniq -c | sort -rn

grep -oP '\[client \K[^:\]]+' "$FILE" | sort | uniq -c | sort -rn | head -20

showed the working picture:

●      293 of 296 entries are blocked at the Apache level (authz_core), even before PHP: .git, dozens of .env* variants, attempts to throw PHP shells into images/. Everything is blocked by the default rules—politely, without further ado, simply "Require not IP," and that's it.

●      The only previously manual entry in .htaccess (91.92.241.196, 48 rejections in the log) simply stopped attacking after switching to the automatic system.

●      Potentially serious finding: an IP matching the site owner's (mine) whitelist prefix in sentinel.php (86.41.) was rejected 13 times in a row when trying to open an image in an article—judging by the referer, it was me editing the content in the Joomla admin panel.

Reason: The PHP-level whitelist in sentinel.php only protects against new bans through the script itself, but cannot override an existing "Require not IP" entry in .htaccess—Apache Blocks the request before PHP even starts:

// Whitelist: if this is your IP, the script is immediately disabled

if (!empty($ip) && strpos($ip, '86.41.') === 0) {

    return; // <-- ONLY protects against new bans via sentinel.php,

              //     does not cancel the existing Require not ip in .htaccess

}

A check (grep across all local_bans.conf and .htaccess) showed that the entry was no longer there at the time of parsing - it disappeared on its own as a side effect of switching to a full recompile of .htaccess (Step 5): since the IP was not in any current source, it no longer appeared in the list during the next recompile.

A separate "Include not allowed here" error surfaced on one of the sites - it turned out to be unrelated to the ban system: the day before, I myself tried to include local_bans.conf directly via Include in .htaccess (and the directive doesn't work in this case) context, only in the main server config), caught the error, commented out the line - and later deleted itNothing at all.

Result

It all started with a single typo in grep, but ended with a full-fledged architecture: a master blacklist for all sites in the account, a two-tier storage system (a sliding list of 100 entries + a separate permanent one for deliberate bans), weekly automatic cleaning, a full .htaccess reconciliation, and a one-time tool for organizing the data accumulated over the past months.

Key technical lesson: code that appears to work (variables are declared, files are written, emails are sent) can silently fail to perform its primary function for years—because a single typo in a regex pattern doesn't manifest itself as an error; the files simply remain as they are. Without checking "what should be happening" against "what's actually happening," this can go unnoticed for a very long time.

And the main human lesson is that hacker fatigue can be cured not only by a firewall, but also by a good tar trap with a sense of humor. Attacks continue to come—the internet is vast, there are many bots—but now at least they're leaving us with the clear understanding that they shouldn't have approached those who use Cyrillic in the first place.

About us

bottom logo en

I'm a designer. Here you can see my work. Yes, I do my job.

The main rule - you will never see the unfinished work. Only thoroughly polished projects can produce in life.

Packing calculator

Select your language

Select your language

Cookies user preferences
We use cookies to ensure you to get the best experience on our website. If you decline the use of cookies, this website may not function as expected.
Accept all
Decline all
Read more
Analytics
Tools used to analyze the data to measure the effectiveness of a website and to understand how it works.
Google Analytics
Accept
Decline
Advertisement
If you accept, the ads on the page will be adapted to your preferences.
Google Ad
Accept
Decline
Save