Beberapa hari terakhir saya melakukan eksperimen kecil di salah satu VPS yang saya gunakan untuk menjalankan beberapa website dan aplikasi, termasuk Moodle.
Tujuannya sederhana:
Bagaimana cara menghentikan scanner otomatis secepat mungkin, terutama scanner yang mencari file atau celah yang jelas-jelas tidak mungkin diakses oleh pengguna normal?
Contohnya seperti:
/.env
/wp-admin/
/wp-content/plugins/...
/upload.php
/bypass.php
/security.php
/random-shell.php
Pada server Moodle, permintaan seperti:
/wp-content/plugins/hellopress/wp_filemanager.php
jelas bukan perilaku pengguna normal.
Itu scanner.
Dan ternyata, saat mulai diperiksa lebih dalam, alur pemblokirannya jauh lebih menarik daripada yang saya kira.
Arsitektur Awal
Secara sederhana, mekanisme pertahanannya seperti ini:
Scanner
↓
Nginx
↓
Access Log / Error Log
↓
Fail2Ban
↓
Firewalld
↓
nftables
Nginx menerima request.
Request tersebut kemudian ditulis ke log.
Fail2Ban membaca log dan mencari pola tertentu.
Jika pola dianggap berbahaya, Fail2Ban meminta firewalld memblokir IP tersebut.
Firewalld pada sistem ini menggunakan backend nftables.
Secara teori sederhana.
Tetapi pertanyaannya:
Seberapa cepat proses ini sebenarnya terjadi?
Scanner Pertama: Mengapa Masih Bisa Menyerang Setelah Diban?
Saya menemukan sebuah IP scanner:
20.203.183.135
Ia mengakses berbagai file yang mencurigakan dengan kecepatan kira-kira dua request per detik.
Fail2Ban mendeteksi request pertama hampir seketika:
01:20:29.697
Found 20.203.183.135
Tetapi IP baru diban pada:
01:20:45.261
Ban 20.203.183.135
Sekilas terlihat Fail2Ban lambat sekitar 15 detik.
Ternyata tidak.
Masalahnya ada di konfigurasi:
maxretry = 30
findtime = 10m
bantime = 1h
Artinya Fail2Ban memang diperintahkan untuk menunggu sampai IP menghasilkan 30 kegagalan dalam 10 menit.
Scanner tersebut mengirim sekitar dua request per detik.
Maka secara matematis:
30 request ÷ ±2 request/detik
≈ 15 detik
Persis seperti yang terjadi.
Jadi Fail2Ban sebenarnya bekerja sangat cepat.
Yang membuatnya tampak lambat adalah kebijakan maxretry.
Mengapa Tidak Langsung maxretry = 1?
Pertanyaan berikutnya muncul:
Kalau begitu mengapa tidak dibuat
maxretry = 1saja?
Karena jail tersebut adalah jail umum untuk HTTP 404.
Manusia normal juga bisa menghasilkan 404.
Misalnya:
- salah mengetik URL,
- bookmark lama,
- link rusak,
- crawler mesin pencari,
- browser meminta file yang sudah dihapus.
Kalau semua 404 langsung diban, risiko false positive menjadi terlalu besar.
Karena itu saya memisahkan logikanya.
Untuk 404 biasa:
maxretry = 30
Tetapi untuk request yang sangat jelas berbahaya:
maxretry = 1
atau:
maxretry = 2
Contohnya jail:
nginx-env
nginx-malicious
nginx-dirscan
Masalah Kedua: Request Masih Muncul Setelah Log “Ban”
Yang lebih menarik terjadi sesudah Fail2Ban menulis:
Ban 20.203.183.135
Ternyata beberapa request dari IP itu masih muncul di access log beberapa detik kemudian.
Awalnya saya menduga firewalld lambat.
Maka saya mengukur langsung waktu pemasangan rich rule.
Hasilnya:
ADD port 80 : 0,371 detik
ADD port 443 : 0,334 detik
Totalnya hanya sekitar:
0,7 detik
Jadi bukan firewalld.
Membongkar Ruleset nftables
Kemudian saya melihat langsung chain nftables.
Di chain input utama terdapat:
ct state { established, related } accept
dan rule tersebut diperiksa sebelum trafik masuk lebih jauh ke ruleset zona public.
Ini menghasilkan konsekuensi penting.
Jika sebuah koneksi TCP sudah berstatus:
ESTABLISHED
maka koneksi tersebut bisa diterima terlebih dahulu.
Sementara rule Fail2Ban berada lebih bawah dalam pemrosesan zona.
Artinya:
menambahkan rule firewall tidak selalu otomatis membunuh koneksi TCP yang sudah aktif.
Ini menjelaskan satu bagian penting dari masalah.
Menambahkan conntrack-kill
Solusinya adalah tidak hanya memblokir IP untuk koneksi berikutnya, tetapi juga menghapus state koneksi yang sedang aktif.
Saya membuat action tambahan Fail2Ban:
conntrack -D -s "<ip>"
Lalu action tersebut dipasang pada jail:
nginx-404
nginx-env
nginx-malicious
nginx-dirscan
sshd
recidive
Arsitekturnya berubah menjadi:
┌─ Firewalld
│ ↓
Scanner → Fail2Ban ─┤ blok koneksi berikutnya
│
└─ Conntrack
↓
hapus koneksi aktif
Apakah conntrack Benar-Benar Bekerja?
Saya tidak ingin hanya berasumsi.
Maka hasil conntrack dicatat ke file log.
Dan akhirnya muncul bukti:
1 flow entries have been deleted.
Diikuti informasi:
ESTABLISHED
src=158.158.121.155
dst=194.233.82.200
dport=443
Artinya:
Ya. Scanner memang memiliki koneksi HTTPS aktif, dan conntrack berhasil menghapus koneksi tersebut.
Ini salah satu temuan paling penting dari eksperimen ini.
Menemukan Bug Kecil pada Action conntrack
Ketika logging conntrack diaktifkan, muncul hal lain.
Kadang Fail2Ban menulis:
Failed to execute ban
Padahal sebenarnya koneksi sudah berhasil dihapus.
Penyebabnya sederhana.
Jika koneksi sudah dihapus oleh jail pertama, jail kedua menjalankan:
conntrack -D -s IP
dan mendapatkan:
0 flow entries have been deleted.
Dalam kondisi itu conntrack mengembalikan exit code non-zero.
Fail2Ban menganggapnya sebagai kegagalan.
Padahal sebenarnya tidak ada masalah.
Koneksi memang sudah tidak ada.
Maka saya membuat wrapper:
/usr/local/sbin/fail2ban-conntrack-kill
yang tetap mencatat hasil conntrack tetapi selalu mengakhiri proses dengan:
exit 0
Sehingga:
0 flow deleted
tidak lagi dianggap sebagai kegagalan Fail2Ban.
Menambahkan Telemetri ke Access Log Nginx
Masih ada pertanyaan:
Apakah request yang muncul setelah ban benar-benar request baru?
Untuk mengetahuinya, access log Nginx saya tambahkan tiga informasi:
rt=
conn=
conn_reqs=
Formatnya menggunakan:
$request_time
$connection
$connection_requests
Hasilnya seperti:
rt=0.124
conn=32151
conn_reqs=30
Artinya:
rt=0.124
request diproses sekitar 0,124 detik.
conn=32151
request berasal dari koneksi TCP nomor 32151.
Dan:
conn_reqs=30
itu adalah request ke-30 yang melewati koneksi tersebut.
Scanner Berikutnya Memberikan Jawaban
Tidak lama kemudian muncul scanner:
20.226.36.136
Ia mengakses:
/wp-content/plugins/hellopress/wp_filemanager.php
/this_is_a_new_hello_world.php
/weozh.php
/rymmm.php
/lddxs.php
/zjggu.php
...
Yang menarik, seluruh request berasal dari:
conn=32151
dan angkanya meningkat:
conn_reqs=1
conn_reqs=2
conn_reqs=3
...
conn_reqs=30
conn_reqs=31
Berarti scanner tersebut menggunakan:
satu koneksi TCP keep-alive.
Fail2Ban kemudian memban IP pada:
02:07:17.363
Request ke-30 tercatat:
02:07:17
conn_reqs=30
dan hanya ada satu request lagi:
02:07:18
conn_reqs=31
Yang sangat penting:
nomor koneksinya tetap sama.
Tidak ada:
conn=baru
conn_reqs=1
setelah ban.
Artinya tidak ada bukti bahwa koneksi TCP baru berhasil lolos setelah firewall aktif.
Kesimpulan Teknis
Dari eksperimen ini, saya mendapatkan beberapa pelajaran.
Pertama, Fail2Ban ternyata membaca log sangat cepat.
Keterlambatan yang tampak biasanya berasal dari konfigurasi:
maxretry
bukan dari Fail2Ban yang lambat.
Kedua, memblokir IP di firewall belum tentu langsung menghentikan koneksi yang sudah berstatus:
ESTABLISHED
Karena itu saya menambahkan:
conntrack -D
untuk menghapus state koneksinya.
Ketiga, pengukuran jauh lebih baik daripada asumsi.
Dengan menambahkan:
$request_time
$connection
$connection_requests
ke access log Nginx, saya bisa melihat secara nyata perilaku scanner pada level koneksi.
Keempat, satu scanner dapat melakukan puluhan request melalui satu koneksi TCP yang sama.
Jadi melihat puluhan baris access log belum tentu berarti scanner membuat puluhan koneksi.
Kelima, jail umum seperti:
nginx-404
tidak seharusnya terlalu agresif.
Tetapi request yang jelas merupakan scanning bisa ditangani oleh jail khusus dengan:
maxretry = 1
atau:
maxretry = 2
Arsitektur Pertahanan yang Saya Gunakan Sekarang
Secara sederhana:
Internet
↓
Nginx
↓
Access/Error Log
↓
Fail2Ban
├───────────────┐
↓ ↓
Firewalld Conntrack Kill
↓ ↓
nftables hapus koneksi aktif
↓
blok koneksi berikutnya
Ditambah jail dengan tingkat toleransi berbeda:
404 biasa
↓
nginx-404
maxretry = 30
request berbahaya
↓
nginx-malicious
maxretry = 1–2
.env / .git / credential
↓
nginx-env
maxretry = 1
directory scanning
↓
nginx-dirscan
Pelajaran Terbesar
Awalnya saya hanya ingin menjawab pertanyaan sederhana:
Mengapa scanner masih bisa mengirim beberapa request sebelum diblok?
Tetapi dari pertanyaan itu saya akhirnya belajar lebih jauh mengenai:
Nginx logging
Fail2Ban
firewalld
nftables
connection tracking
TCP keep-alive
ESTABLISHED state
request timing
Dan ada satu pelajaran yang menurut saya berlaku jauh melampaui administrasi server:
Jangan buru-buru menyimpulkan bahwa sebuah sistem lambat atau gagal hanya karena hasil akhirnya terlihat terlambat.
Ukur setiap tahap.
Lihat log.
Periksa urutan proses.
Cari bukti.
Karena kadang masalah yang terlihat seperti:
Fail2Ban lambat
ternyata sebenarnya adalah:
Fail2Ban menunggu maxretry
Dan masalah yang terlihat seperti:
firewall gagal memblokir
ternyata berkaitan dengan:
koneksi TCP yang sudah ESTABLISHED
Bagi saya, bagian paling menarik dari mengelola server bukan sekadar membuatnya hidup.
Tetapi memahami mengapa ia bekerja seperti itu.
Catatan: eksperimen ini dilakukan pada server produksi dengan AlmaLinux, Nginx, Fail2Ban, firewalld, dan nftables. Beberapa konfigurasi harus disesuaikan dengan arsitektur masing-masing server.