När jag tröttnade på hackerattacker satte jag upp en tjärfälla och byggde så småningom ett heltäckande skyddssystem för 8 webbplatser.
Vid någon tidpunkt blev jag riktigt trött på hackerattacker på min grupp av webbplatser. Det var inte så att de faktiskt hackade någonting, men känslan av att bottar metodiskt drog i dina dörrhandtag dag och natt var ganska irriterande. Jag undrade hur jag skulle ta bort allt detta och, om möjligt, återgälda skadan med skada – in natura.
Artificiell intelligens
...Vi lärde sand att tänka, ritade runor på den med ljus och väckte den till liv med blixtar...
Jag vände mig till AI och frågade: vad kan man göra i det här fallet? Här är webbplatsen, här är dess grova layout, här är loggarna – låt oss analysera vilka som besökte oss, vad de ville ha och varför de behövde det. Och medan vi ändå håller på, låt oss fundera på vad vi kan göra för att avskräcka folk från att komma tillbaka.
Han föreslog att man skulle skapa en tar-fälla.

En tar-fälla (tarpit) är när en bot eller hackare inte kan avsluta en session och tvingas ta emot det de begärt, vilket kan vara en mycket stor, mycket lång och helt värdelös fil.
Tja, varför inte göra den lite mindre värdelös? Låt den ta emot ett oändligt stort och mycket långsamt flytande ord på tre bokstäver – på skärmen och i loggen. Ett ord som alla ryska modersmålstalare känner till. Och låt oss också låta alla icke-ryssar veta: det här är vi, och ni ska bara komma hit med goda avsikter.
Tekniskt sett fungerar det så här. När en förfrågan träffar en av "honungs"-triggarna – ett dolt formulärfält specifikt styrt mot botar som bara autokompletterare klickar på, eller en teckensträng i förfrågningsparametrarna som tydligt liknar en SQL-injektion – returnerar servern inte bara ett fel och säger adjö till gästen. Istället rapporterar den ärligt HTTP 200 OK, vilket betyder "allt fungerade, förfrågan har accepterats", inaktiverar buffring och svarskomprimering och öppnar anslutningen som text/vanligt. Och sedan kommer den oändliga loopen: varje sekund flyger ytterligare ett "KUK"-meddelande med ett mellanslag i slutet (för att förhindra att orden fastnar ihop till ett enda helt textblock) in i socketen, servern tömmer bufferten och sover i exakt en sekund tills nästa batch anländer. Detta fortsätter tills boten eller hackarens skript avslutar själva anslutningen – servern kommer aldrig att släppa den manuellt. Ur angriparens perspektiv ser detta ut som ett lyckat serversvar som på något sätt aldrig slutar: anslutningen är aktiv, data droppar in, men nyttolasten – det vill säga databasen, dumpen eller vad boten än behövde – saknas fortfarande. Naiva skannrar som försöker läsa hela svarstexten in i minnet innan de analyserar den hänger sig helt enkelt eller får timeout vid denna tidpunkt. Samtidigt kostar denna konversation servern nästan ingenting: PHP-processen körs inte hela tiden, utan sover, så resurserna som spenderas på en enda fryst bot är försumbara. Men boten förlorar en tråd, en arbetare eller vad den än tilldelades för denna attack, och det tar mycket mer tid än förväntat.
Vad jag gjorde
Som ett resultat minskade antalet skadliga besök med cirka 20 gånger. Förutom tar-fällan finns det naturligtvis andra skyddsmekanismer på plats: dagliga kontroller av webbplatsens integritet och filbehörigheter, en räkning av hur många administratörer som har besökt webbplatsen under en dag och automatisk åtgärd av eventuella problem som uppstår. Allt detta sker automatiskt via cron. Så jag är nöjd med resultatet hittills.
Men idyllen varade inte länge: efter ett tag blev det tydligt att en del av detta skydd inte fungerade riktigt som avsett. Mer om hur allt faktiskt konfigurerades senare.
Problembeskrivning
Redan tidigare hade jag redan försökt lösa säkerhetsproblemet – men bara för en webbplats, en pilotwebbplats. Jag hade några prefabricerade lösningar: min egen .htaccess med manuella avstängningar, mitt eget hook-skript. Det fungerade, men det var en grov, rudimentär historia, som saknade all logik – en "live-action"-metod, så att säga. Idén gick inte längre än till den enda webbplatsen: jag ville absolut inte manuellt skala samma sak över sju domäner till.
Mitt webbhotellskonto (/home/Acc12345/) kör åtta webbplatser – mestadels Joomla, ett par som använder ren HTML. Varje webbplats har samma uppsättning "skyddande" filer i sin egen mapp:
public_html/
├── site1.com/
│ ├── .htaccess <-- blockering på Apache-nivå
│ ├── local_bans.conf <-- lista över avstängda IP-adresser
│ ├── sentinel.php <-- fälla för bottar
│└── incident.log <-- logg över fångade attacker
├── site2.com/ ... (samma)
├── site3.com/
├── site4.com/
├── site5.com/
├── site6.com/
├── site7.com/
├── site8.com/
├── guardian.sh <-- cron: skanning + skydd
└── weekly_audit.php <-- cron: veckorapport
Idé: sentinel.php hänger sig på ErrorDocument 403, fångar bots (WordPress-skannrar, försök att hämta .git/.env, Joomla-exploits, honeypot-triggers), skriver deras IP-adresser i local_bans.conf och incidenten finns i incident.log. guardian.sh borde cron-göra dessa IP-adresser till .htaccess som en hård avstängning på Apache-nivå (kräver inte IP). En gång i veckan skickar weekly_audit.php en rapport via e-post.
Problem: IP-adresser ackumulerades korrekt i local_bans.conf, men inte i .htaccess. Boten är formellt "avstängd", men i verkligheten fortsätter den att attackera webbplatsen.
Steg 1. Vem ska skriva i .htaccess?
Innan vi fixade det var det nödvändigt att förstå rollen för vart och ett av de tre skripten. Koden avslöjade snabbt:
● sentinel.php — kärnan: upptäcker attacken, skriver IP-adressen till local_bans.conf och incidenten till incident.log.
● guardian.sh — den enda som ska överföra IP-adressen från loggen till .htaccess (steg "6.5" i skriptet).
● weekly_audit.php — läser bara local_bans.conf och skickar ett e-postmeddelande. Den rör inte .htaccess alls.
Steg 2. Bugg #1 – mitt stavfel som förstörde allt
I guardian.sh, raden som skulle hämta nya IP-adresser från loggen:
NEW_IPS=$(grep "\[TRAFFIC BAN\]" "$CUSTOM_LOG" \
| awk -F'IP: ' '{print $2}' | ...)
Skriptet letade bokstavligen efter delsträngen [TRAFFIC BAN] – den inledande parentesen omedelbart före ordet "TRAFFIC". Sentinel.php skriver aldrig det: det har alltid mer specifika typer – [WORDPRESS SNOOPER BAN], [JOOMLA TRAFFIC BAN], [HELIX HONEYPOT BAN]. Det finns alltid ett annat ord mellan parentesen och ordet "TRAFFIC"/"BAN." grep hittade aldrig något, variabeln var alltid tom.
Åtgärd: extrahera IP-adresser med IP:-etiketten, som visas i alla loggrader, oavsett typ:
NEW_IPS=$(grep -oP 'IP:\s*\K[0-9a-fA-F:.]+' "$CUSTOM_LOG" | sort -u)
Så, i månader, kokade tar-fällan troget bottar vid liv, loggen var full av poster, och .htaccess levde sitt eget tysta liv, omedveten om det. En klassiker: koden kraschar inte, det finns inga fel, vilket betyder att den "fungerar" – men i verkligheten gör den bara ingenting i tysthet.
Steg 3. Bugg #2: HTML-webbplatser hoppade över processen
Hela guardian.sh-huvudloopen kördes bara för webbplatser med en configuration.php:
if [ -d "$SITE_PATH" ] && [ -f "${SITE_PATH}/configuration.php" ]; then
För HTML-webbplatser (utan Joomla) fungerade .htaccess-synkroniseringen inte alls, inte ens efter grep-fixen. Lösningen är att flytta .htaccess-synkroniseringen till en separat loop som går igenom alla 8 webbplatser utan att kontrollera configuration.php, och köra den före den huvudsakliga (tunga, Joomla-specifika) skanningen:
echo -e "${YELLOW}=== SYNKRONISERAR .HTACCESS (ALL SITES) ===${NC}"
for SITE_PATH in "${SITES[@]}"; do
if [ -d "$SITE_PATH" ]; sedan
SITE_DOMAIN=$(basnamn "$SITE_PATH")
sync_htaccess_blacklist "$SITE_PATH" "$SITE_DOMAIN"
fi
klar
# HUVUDCYKEL (djup genomsökning - endast Joomla)
för SITE_PATH i "${SITES[@]}"; gör
om [ -d "$SITE_PATH" ] && [ -f "${SITE_PATH}/configuration.php" ]; sedan
Steg 4. Finns det något skydd över flera webbplatser alls?
En kontroll av vad som faktiskt finns i .htaccess-filerna på olika webbplatser visade att IP-listorna på webbplats 1 och webbplats 4 inte överlappar varandra alls. Det betyder att en bot som är avstängd på en webbplats lätt kan pröva lyckan på nästa – som om det inte fanns några staket som skiljer åtta hus som ägs av samma ägare åt. Genom att analysera sentinel.php avslöjades orsaken: det finns en $all_blacklists-array som innehåller alla 9 sökvägar, men den används bara för att läsa (kontrollera om användaren redan är avstängd), och avstängningen skrevs bara till den aktuella webbplatsens fil:
Före — endast lokala avstängningar:
$ban_bot_action = function($type) use ($blacklist_file, $ip, $uri) {
// ... läser och skriver bara $blacklist_file (local)
if (!in_array($ip, $lines, true)) {
$lines[] = $ip;
@file_put_contents(
$blacklist_file,
implode(PHP_EOL, $lines) . PHP_EOL,
LOCK_EX
);
};
Efteråt - avstängningar på alla 9 webbplatser på dence:
$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
);
// Vi loggar endast incidenten i loggen för webbplatsen där attacken inträffade
$log_file = __DIR__ . '/incident.log';
@file_put_contents($log_file, $log_entry, FILE_APPEND);
};
Logiken bakom guardian.sh har ändrats i enlighet därmed: eftersom local_bans.conf nu delas, utförs .htaccess-synkronisering nu inte baserat på den lokala attackloggen, utan direkt baserat på innehållet i local_bans.conf.
Steg 5. Synkronisering bör inte bara lägga till, utan också ta bort
Medan listan "bara växte" räckte det med att helt enkelt lägga till nya rader. Men med tillkomsten av veckovis rensning (nästa steg) blev det tydligt: om en IP-adress saknas i local_bans.conf, men raden "Kräver inte IP" finns kvar, är det meningslöst att rensa den. Jag skrev om synkroniseringsfunktionen för att använda fullständig avstämning — varje körning sätter ihop innehållet mellan etiketterna helt och hållet:
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 ~ /Kräv alla beviljade/) { print; next
else { next } # old “Kräver inte IP ...” - återskapad igen
} else {
print
' "$HTACCESS_FILE" > "$TMP_FILE"
Testning på en kopia av verkliga data avslöjade ytterligare två buggar i min egen kod:
● $(...) i bash trimmar den sista nyradsadressen — den sista IP-adressen sammanfogades med # Blacklist End-taggen till en enda rad. Jag var tvungen att explicit returnera \n efter substitutionen: IP_BLOCK="${IP_BLOCK}"$'\n'
● I ett bootstrap-block (skapande av taggar från grunden) hamnade <RequireAll> inuti Start/End-taggarna — och en fullständig ombyggnad, som rensade bort allt "okänt" inuti blocket, åt upp själva taggen. Jag justerade ordningen så att <RequireAll> omslöt taggarna utanför, som i riktiga filer.
Efter korrigeringarna finns det inga dubbletter (en omkörning skapar inga dubbletter) och korrekt funktion har verifierats på syntetiska fall, inklusive "en fil utan taggar alls" och "en fil med befintliga taggar."
Steg 6. Listan kommer att växa för evigt – rensning krävs
Inledningsvis var idén i kommentarerna till weekly_audit.php tydligt ett "permanent förbud", med rensning avsiktligt inaktiverad. Vi diskuterade en kompromiss: hur många IP-adresser som ska behållas efter rensningen (vi bestämde oss för de 100 senaste) och vad man ska göra med äldre som kommer att "rensas" när de blir fulla. Lösningen är en separat "permanent" lista för manuellt avstängda IP-adresser, som inte påverkas av automatisk rensning.
6.1. permanent_bans.conf
Ny fil i roten public_html/, endast redigerad manuellt. När sentinel.php kontrollerar om en IP-adress redan är avstängd tittar den även på den här filen (för omedelbar blockering), men ban-funktionen skriver aldrig till den:
$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) {
// ... kontrollera om det finns 503, ban_bot_action() skriver INTE hit
}
6.2. Veckovis rensning i weekly_audit.php
$MAX_BLACKLIST_SIZE = 100;
if ($total_before > $MAX_BLACKLIST_SIZE) {
// Filordning = endast tilläggsordning
$dropped_ips = array_slice(
$ref_lines, 0, $total_before - $MAX_BLACKLIST_SIZE
);
$kept_ips = array_slice($ref_lines, -$MAX_BLACKLIST_SIZE);
för varje ($sites som $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 kombinerar båda källorna – den glidande listan och den permanenta – under synkronisering, så permanenta avstängningar finns alltid kvar i .htaccess, även om de sedan länge har fallit bort från toppen 100:
ALL_IPS=$(cat "$BLACKLIST_FILE" "$PERMANENT_BLACKLIST" 2>/dev/null \
| grep -v '^#' | grep -v '^[[:space:]]*$' | tr -d ' \t' | sort -u)
6.3. Tog bort duplicerad logik
Vi hittade och tog också bort en extra kodbit i sentinel.php – en gammal hård gräns på 500 poster, tillämpad på var och en av de 9 filerna oberoende i realtid:
// Före:
if (count($lines) >500) { $lines = array_slice($lines, -500);
// Efter: rad borttagen. Den enda sanningskällan angående storleken
// på listan är en veckovis rensning i weekly_audit.php.
Steg 7. Engångsjustering av ackumulerade "varor"
Alla korrigeringar löser problemet för framtiden, men vid implementeringstillfället hade åtta webbplatser redan olika, osynkroniserade listor. Skrev en engångsföreteelse för merge_bans.sh:
● Samlar in den faktiska avstängningstiden för varje IP-adress från incident.log för alla 8 webbplatser — med hjälp av den faktiska tidsstämpeln från loggen, inte ordningen på raderna i filen.
● Om en IP-adress från local_bans.conf inte har en loggpost tar den även den, men med lägst prioritet för aktuell filtyp.
● Sorterar efter aktuell filtyp, tar bort dubbletter och behåller topp 100.
● Skriver en enda lista till alla 8 local_bans.conf-filer och återskapar omedelbart .htaccess — utan att vänta på cron.
Nyckelfragmentet kompilerar tidsstämpeln från loggformatet:
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
Resultat av en riktig körning: 80 loggposter → 39 unika IP-adresser i gamla spridda listor → 56 unika totalt (gränsen på 100 överskreds inte, ingen trimning behövdes) → alla 8 webbplatser synkroniserades i en enda körning.
Steg 8. Loggparsning - Ett verkligt test
Efter implementeringen skickade användaren en riktig webbserverfellogg för ett par dagar. Parsning 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
visade arbetsbilden:
● 293 av 296 poster är blockerade på Apache-nivå (authz_core), även före PHP: .git, dussintals .env*-varianter, försöker kasta PHP-skal i bilder/. Allt blockeras av standardreglerna – artigt, utan vidare, helt enkelt "Kräv inte IP", och det är allt.
● Den enda tidigare manuella posten i .htaccess (91.92.241.196, 48 avslag i loggen) slutade helt enkelt attackera efter att ha bytt till det automatiska systemet.
● Potentiellt allvarligt fynd: en IP-adress som matchar webbplatsägarens (mitt) vitlisteprefix i sentinel.php (86.41.) avvisades 13 gånger i rad när man försökte öppna en bild i en artikel – att döma av hänvisningen var det jag som redigerade innehållet i Joomlas administratörspanel.
Orsak: Vitlistan på PHP-nivå i sentinel.php skyddar bara mot nya avstängningar genom själva skriptet, men kan inte åsidosätta en befintlig "Kräv inte IP"-post i .htaccess – Apache blockerar begäran innan PHP ens starts:
// Vitlista: om detta är din IP-adress inaktiveras skriptet omedelbart
if (!empty($ip) && strpos($ip, '86.41.') === 0) {
return; // <-- Skyddar ENDAST mot nya avstängningar via sentinel.php,
// avbryter inte den befintliga Kräv inte IP-adressen i .htaccess
}
En kontroll (grep över alla local_bans.conf och .htaccess) visade att posten inte längre fanns där vid tidpunkten för parsningen - den försvann av sig själv som en bieffekt av att byta tillen fullständig omkompilering av .htaccess (steg 5): eftersom IP-adressen inte fanns i någon aktuell källa, dök den inte längre upp i listan under nästa omkompilering.
Ett separat felmeddelande "Inkludering är inte tillåtet här" dök upp på en av webbplatserna - det visade sig vara orelaterat till bannlysningssystemet: dagen innan försökte jag själv inkludera local_bans.conf direkt via Include i .htaccess-kontexten (och direktivet fungerar inte i det här fallet), bara i huvudserverkonfigurationen), upptäckte felet, kommenterade bort raden - och raderade den senare. Ingenting alls.
Resultat
Allt började med ett enda stavfel i grep, men slutade med en fullfjädrad arkitektur: en huvudsvartlista för alla webbplatser i kontot, ett tvåskiktat lagringssystem (en glidande lista med 100 poster + en separat permanent för avsiktliga bannlysningar), veckovis automatisk rengöring, en fullständig .htaccess-avstämning och ett engångsverktyg för att organisera data som ackumulerats under de senaste åren. månader.
Viktig teknisk lärdom: kod som verkar fungera (variabler deklareras, filer skrivs, e-postmeddelanden skickas) kan tyst misslyckas med att utföra sin primära funktion i åratal – eftersom ett enda stavfel i ett regex-mönster inte manifesterar sig som ett fel; filerna förblir helt enkelt som de är. Utan att kontrollera "vad som borde hända" mot "vad som faktiskt händer" kan detta gå obemärkt förbi under mycket lång tid.
Och den viktigaste mänskliga lärdomen är att hackertrötthet kan botas inte bara med en brandvägg, utan också med en bra tar-fälla med humor. Attackerna fortsätter att komma – internet är enormt, det finns många bottar – men nu ger de oss åtminstone en tydlig förståelse för att de inte borde ha kontaktat dem som använder kyrilliska från första början.




