As we explained in previous articles, libmodsecurity is a WAF (Web Application Firewall). It allows us to detect certain types of attacks based on predefined rules; using these signatures, we can detect SQL injections, XSS, LFI, and RFI. In this guide, we will set up a system where we are notified via Telegram when an attack is detected, PF rules are applied, and a web page is displayed informing the user about the reason for the ban.
The article is divided into several sections:
- Nginx Compilation
- Installing libmodsecurity
- Whitelisting URLs
- Disabling Rules
- Log Cleanup
- Log Analysis and Banning System
- Redis installation
- ModsecurityAnalizer
- ModsecurityNotifier
- PF Filtering Rules
- HAProxy Configuration
- Attackers Dashboard
- Useful Programs
- Final Result
Nginx Compilation:
If we already have Nginx installed we must check whether it was compiled with support for libmodsecurity:
MODSECURITY3 : off
NOTE: The full version (nginx-full) does not have the option enabled either.
In versions prior to 03 Jun 2020 18:49:04 Nginx had to be compiled with support for modsecurity3, have the modsecurity3 library installed and also install the modsecurity3-nginx connector. In versions after that date, if we compile Nginx with support for modsecurity3, the connector is already included, but we must add the module load directive in the Nginx configuration; in the old version this step was unnecessary.
r537834 | joneum | 2020-06-03 20:49:04 +0200 (Wed, 03 Jun 2020) | 11 lines
Merge r532727 from www/nginx-devel:
Convert the following third-party modules to dynamic:
o) accept_language
o) modsecurity3-nginx
Fix the third-party auth_krb5 module build.
Sponsored by: Netzkommune GmbH
If we install modsecurity3-nginx with the new version of Nginx and load the module from the Nginx configuration we will see the following error:
2020/06/06 14:46:36 [emerg] 24011#101851: module "/usr/local/libexec/nginx/ngx_http_modsecurity_module.so" is not binary compatible in /usr/local/etc/nginx/nginx.conf:4
Additionally, pkg will warn us that the modsecurity3-nginx and nginx packages conflict since both try to install the same connector.
We compile using ports with the desired options, making sure to enable modsecurity3 and remember that once we switch to ports we must manage all packages from ports. You can find more information in this previous article .
Before compiling and installing Nginx from ports we uninstall the current version.
We prepare the ports system:
cd /usr/ports
make fetchindex
We perform a search:
Port: nginx-1.22.1_5,3
Path: /usr/ports/www/nginx
Info: Robust and small WWW server
Maint: joneum@FreeBSD.org
B-deps: pcre-8.45_3
R-deps: pcre-8.45_3
WWW: https://nginx.com/
We configure the options:
make config
We compile and install:
make install
We clean temporary files:
Libmodsecurity Installation:
We install the libmodsecurity library:
make config
make
make install
make clean
We download the OWASP rules:
git clone https://github.com/SpiderLabs/owasp-modsecurity-crs.git
cd owasp-modsecurity-crs
cp crs-setup.conf.example crs-setup.conf
We download the base libmodsecurity configuration file:
cd /usr/local/etc/modsec
fetch https://raw.githubusercontent.com/SpiderLabs/ModSecurity/v3/master/modsecurity.conf-recommended
fetch https://raw.githubusercontent.com/SpiderLabs/ModSecurity/49495f1925a14f74f93cb0ef01172e5abc3e4c55/unicode.mapping
mv modsecurity.conf-recommended modsecurity.conf
We make sure to have certain parameters with the indicated values:
SecRuleEngine On
SecAuditLogFormat json
SecAuditEngine RelevantOnly
SecAuditLog /var/log/modsec_audit.log
We include in the main configuration the base file and the OWASP rules:
# Include the recommended configuration
Include /usr/local/etc/modsec/modsecurity.conf
# OWASP CRS v3 rules
Include /usr/local/owasp-modsecurity-crs/crs-setup.conf
Include /usr/local/owasp-modsecurity-crs/rules/*.conf
We enable libmodsecurity in the Nginx configuration by adding the module load as the first line:
load_module /usr/local/libexec/nginx/ngx_http_modsecurity_module.so;
server {
...
modsecurity on;
modsecurity_rules_file /usr/local/etc/modsec/main.conf;
We restart the service:
We test any kind of attack, in my case a MySQL injection with the following payload:
' or '1==1'; --
We will see a new entry in the log and the web request will have been blocked with a 403 Forbidden:
If we are going to generate many entries in the log it is preferable to write them in concurrent format; this way they will not be logged in a single serialized text file, but instead will be written into different files in parallel:
#SecAuditLogType Serial
SecAuditLogType Concurrent
#SecAuditLog /var/log/modsec_audit.log
SecAuditLogStorageDir /opt/modsecurity/var/audit
We restart the service:
We create the directory where the log entries will be stored:
chown -R www:www /opt/modsecurity/var/audit/
chmod 775 /opt/modsecurity/var/audit/
We install the jq tool; it will be useful to view log information more comfortably:
make config
make
make install
make clean
We check the message field of one of the entries:
"SQL Injection Attack Detected via libinjection"
"Inbound Anomaly Score Exceeded (Total Score: 5)"
NOTE: It is not necessary to filter; if we want to see the full information we simply pipe the output of cat to jq without parameters.
Whitelisting URLs:
If in our web application there is any functionality that requires slightly unusual behavior, we can whitelist it so that the rules do not trigger for that section:
vi /usr/local/owasp-modsecurity-crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
SecRule REQUEST_URI "@beginsWith /CUSTOM_WEB_PATH" \
"id:1001,\
phase:1,\
pass,\
nolog,\
ctl:ruleEngine=Off"
Disabling Rules:
If, on the other hand, we want to disable a specific rule, we can use the directive SecRuleRemoveById
NOTE: This directive must be specified after the rule that is being disabled.
In my case I was having issues with the rule:
CGI source code leakage
"ruleId": "950140",
"file": "/usr/local/owasp-modsecurity-crs/rules/RESPONSE-950-DATA-LEAKAGES.conf",
"lineNumber": "66",
When displaying code on the web, the alert is triggered because it thinks the page’s own source code is being leaked. We add the disabled rules file:
# Include the recommended configuration
Include /usr/local/etc/modsec/modsecurity.conf
# OWASP CRS v3 rules
Include /usr/local/owasp-modsecurity-crs/crs-setup.conf
Include /usr/local/owasp-modsecurity-crs/rules/*.conf
# Disabled rules
Include /usr/local/etc/modsec/disabledRules.conf
We specify which rule to disable:
SecRuleRemoveById 950140
If instead we want to disable it only for a specific IP (required if we enable the check in a Ha-Proxy):
SecRule REMOTE_ADDR "@ipMatch IP_ADDRESS" "id:1,phase:1,t:none,nolog,pass,ctl:ruleRemoveById=920280"
Log Cleanup:
A good idea is to remove libmodsecurity logs every X amount of time; for this I have the following CRON enabled:
00 11 * * * /bin/rm -rf /usr/local/bastille/jails/MetaCortex/root/opt/modsecurity/var/audit/* >/dev/null 2>&1
Log Analysis and Banning System:
The system consists of a central Redis server and several programs written in Go:
- libmodsecurity log analysis: modsecurityAnalizer
- Notifications via Telegram, banning using PF and configuration of ACLs-RAM and lists in Ha-Proxy: modsecurityNotifier
Depending on the server’s role, it must run one software or another:
- Nginx with libmodsecurity installed: modsecurityanalizer
- Physical jail host server: modsecuritynotifier
The idea is that the Nginx instances, distributed across several physical servers as jails, write to the central Redis.
And the physical servers reconfigure PF and the HaProxys (one per physical server) based on the information stored in Redis.
NOTE: In my case I only have one physical server, so I will run everything on the parent server and configure the paths so that files are searched inside the jails path (/usr/local/bastille/jails).
Redis Installation:
So that the different scripts can communicate, we will install a Redis server:
We bind it to its IP and assign a password:
bind 192.168.69.2
requirepass XXXXXXXXXX
We enable and start the service:
service redis start
We check the Redis status:
# Server
redis_version:7.0.8
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:8e3f49544e856f48
redis_mode:standalone
os:FreeBSD 13.1-RELEASE-p5 amd64
.......
ModsecurityAnalizer:
With this program we will analyze the logs generated by libmodsecurity and create the associated keys in Redis.
cd modsecurityAnalizer
go mod init modsecurityAnalizer
go get github.com/fsnotify/fsnotify
go get github.com/redis/go-redis/v9
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"github.com/redis/go-redis/v9"
)
const (
apiKey = "XXXXXXX"
telegramURL = "https://api.telegram.org/bot%s/sendMessage"
userID = "XXXXXXX"
haproxyIP = "192.168.69.19"
redisAddr = "192.168.69.2:6379"
redisPass = "XXXXXXX"
auditPath = "/usr/local/bastille/jails/MetaCortex/root/opt/modsecurity/var/audit/"
)
var (
ctx = context.Background()
rdb *redis.Client
)
type ModSecLog struct {
Transaction struct {
TimeStamp string `json:"time_stamp"`
ClientIP string `json:"client_ip"`
Request struct {
Method string `json:"method"`
URI string `json:"uri"`
Headers map[string]string `json:"headers"`
} `json:"request"`
Messages []struct {
Message string `json:"message"`
Details struct {
Data string `json:"data"`
} `json:"details"`
} `json:"messages"`
} `json:"transaction"`
}
func sendTelegram(msg string) {
url := fmt.Sprintf(telegramURL, apiKey)
payload, _ := json.Marshal(map[string]string{
"chat_id": userID,
"text": msg,
})
resp, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
if err != nil {
fmt.Printf("++ Error sending telegram: %v\n", err)
return
}
defer resp.Body.Close()
}
func processFile(path string) {
// Short pause for ModSecurity to finish writing in FreeBSD
time.Sleep(250 * time.Millisecond)
content, err := os.ReadFile(path)
if err != nil || len(content) == 0 {
return
}
// Clean possible null characters at the end of the JSON
content = bytes.Trim(content, "\x00")
var log ModSecLog
if err := json.Unmarshal(content, &log); err != nil {
// Retry: ModSecurity sometimes wraps the JSON in an array []
var logs []ModSecLog
if err2 := json.Unmarshal(content, &logs); err2 == nil && len(logs) > 0 {
log = logs[0]
} else {
return
}
}
foundAlert := false
for _, msgLine := range log.Transaction.Messages {
message := msgLine.Message
if message == "" || strings.Contains(message, "Anomaly Score Exceeded") {
continue
}
attacker := log.Transaction.Request.Headers["x-forwarded-for"]
if attacker == "" {
attacker = log.Transaction.ClientIP
}
// Ignore if the attacker is our own HAProxy or is empty
if attacker == haproxyIP || attacker == "" {
continue
}
foundAlert = true
fmt.Printf("[%s] ATTACK DETECTED: %s -> %s\n", time.Now().Format("15:04:05"), attacker, message)
// Increment counter in Redis
rdb.Incr(ctx, attacker)
rdb.Expire(ctx, attacker, time.Hour)
// Save detailed data
data := []string{
log.Transaction.TimeStamp,
attacker,
log.Transaction.Request.Headers["sec-ch-ua-platform"],
log.Transaction.Request.Headers["user-agent"],
message,
log.Transaction.Request.Headers["host"],
log.Transaction.Request.URI,
log.Transaction.Request.Method,
msgLine.Details.Data,
}
rdb.Set(ctx, "data"+attacker, strings.Join(data, "++"), time.Hour)
}
if !foundAlert {
fmt.Printf("[-] File processed (no alerts): %s\n", filepath.Base(path))
}
}
func main() {
rdb = redis.NewClient(&redis.Options{
Addr: redisAddr,
Password: redisPass,
DB: 0,
})
if err := rdb.Ping(ctx).Err(); err != nil {
fmt.Println("++ ERROR: Cannot connect to Redis")
os.Exit(1)
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
defer watcher.Close()
// Function to add folders to the monitoring system (kqueue)
watchDir := func(path string) {
err := watcher.Add(path)
if err == nil {
fmt.Printf("[*] Monitoring: %s\n", path)
}
}
// Initial scan of the entire audit structure
filepath.Walk(auditPath, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
watchDir(path)
}
return nil
})
fmt.Println("\n-- ModSecurity Analyzer online. Waiting for attacks...")
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// Capture creation of folders or files
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
info, err := os.Stat(event.Name)
if err != nil {
continue
}
if info.IsDir() {
// If ModSecurity creates a subfolder (e.g.: 20260228-1250), we monitor it right away
watchDir(event.Name)
// Preventive scan in case ModSecurity created files before Go reacted
filepath.Walk(event.Name, func(p string, i os.FileInfo, e error) error {
if e == nil && !i.IsDir() {
processFile(p)
} else if e == nil && i.IsDir() && p != event.Name {
watchDir(p)
}
return nil
})
} else {
// It's a JSON log file: to be processed
processFile(event.Name)
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
fmt.Printf("Watcher Error: %v\n", err)
}
}
}
We compile and copy the binary to the system:
cp modsecurityAnalizer /usr/local/bin
The RC script to manage the service would look like this:
#!/bin/sh
#
# PROVIDE: modsecurityAnalizer
# REQUIRE: DAEMON
# KEYWORD: shutdown
. /etc/rc.subr
name=modsecurityAnalizer
rcvar=modsecurityAnalizer_enable
command="/usr/local/bin/modsecurityAnalizer"
start_cmd="modsecurityAnalizer_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"
pidfile="/var/run/${name}.pid"
modsecurityAnalizer_start(){
echo "Starting modsecurityAnalizer (Go version)."
/usr/sbin/daemon -c -f -p ${pidfile} ${command}
}
modsecurityAnalizer_stop(){
if [ -f ${pidfile} ]; then
echo "Stopping service: ${name}"
kill -s TERM $(cat ${pidfile})
rm -f ${pidfile}
sleep 3
else
echo "It appears ${name} is not running."
fi
}
modsecurityAnalizer_status(){
if [ -f ${pidfile} ] && kill -0 $(cat ${pidfile}) 2>/dev/null; then
echo "${name} running with PID: $(cat ${pidfile})"
else
echo "It appears ${name} is not running."
[ -f ${pidfile} ] && rm -f ${pidfile}
fi
}
load_rc_config $name
run_rc_command "$1"
We assign permissions:
chown root:wheel /usr/local/etc/rc.d/modsecurityAnalizer
We enable the service and start it:
service modsecurityAnalizer start
ModsecurityNotifier:
This program will query Redis, notify via Telegram about received attacks, configure PF rules, and maintain a HAProxy ACL-RAM and list with the attackers’ IP addresses.
The reason we will configure ACLs and lists simultaneously is because the ACLs are inserted through the HAProxy SocketAdmin; when HAProxy is restarted, the ACLs disappear. To prevent this, we will also maintain the list /usr/local/etc/badguys.list, so in case of a restart the list will contain the same IPs that were present in the ACL.
cd modsecurityNotifier/
go mod init modsecurityNotifier
go get github.com/redis/go-redis/v9
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/redis/go-redis/v9"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
ApiKey = "XXXXXXXXXXXXXX"
TelegramUrl = "https://api.telegram.org/bot" + ApiKey + "/sendMessage"
UserId = "XXXXXXXXXXXXXX"
HaProxyBlacklistFile = "/usr/local/bastille/jails/Atlas/root/usr/local/etc/badguys.list"
HaProxyAdminSocketDir = "/usr/local/bastille/jails/Atlas/root/var/run/"
BadguysAclPattern = `.*\(/usr/local/etc/badguys\.list\) pattern loaded from file '/usr/local/etc/badguys\.list'.*`
)
var ctx = context.Background()
type TelegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
func sendTelegram(text string) {
payload := TelegramPayload{
ChatID: UserId,
Text: text,
}
jsonData, _ := json.Marshal(payload)
resp, err := http.Post(TelegramUrl, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("++ Error sending telegram message: %v\n", err)
return
}
defer resp.Body.Close()
}
func getPFBannedHosts() []string {
cmd := exec.Command("pfctl", "-t", "badguys", "-T", "show")
output, err := cmd.Output()
if err != nil {
return []string{}
}
lines := strings.Split(string(output), "\n")
var hosts []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
hosts = append(hosts, trimmed)
}
}
return hosts
}
// Interacting with HAProxy via Unix Socket
func haproxyCommand(cmd string) (string, error) {
// Look for the socket in the specified directory
files, _ := filepath.Glob(filepath.Join(HaProxyAdminSocketDir, "*.sock"))
if len(files) == 0 {
// Try direct path if no dynamic .sock files are found
files = append(files, filepath.Join(HaProxyAdminSocketDir, "haproxy.sock"))
}
var lastErr error
for _, socketPath := range files {
conn, err := net.Dial("unix", socketPath)
if err != nil {
lastErr = err
continue
}
defer conn.Close()
fmt.Fprintf(conn, cmd+"\n")
var buf bytes.Buffer
io.Copy(&buf, conn)
return buf.String(), nil
}
return "", fmt.Errorf("cant connect with HAProxy socket: %v", lastErr)
}
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "192.168.69.2:6379",
Password: "XXXXXXXXXXXXXX",
DB: 0,
})
if err := rdb.Ping(ctx).Err(); err != nil {
hostname, _ := os.Hostname()
msg := fmt.Sprintf("ERROR %s (modsecurityNotifier.go): Cant connect to redis server", hostname)
fmt.Println("++ " + msg)
sendTelegram(msg)
os.Exit(1)
}
for {
fmt.Println("\n------------------------------------------------")
fmt.Println(">> Checking REDIS content")
fmt.Println("------------------------------------------------")
iter := rdb.Scan(ctx, 0, "*", 0).Iterator()
for iter.Next(ctx) {
attacker := iter.Val()
if strings.HasPrefix(attacker, "data") || strings.HasPrefix(attacker, "geoipdata") {
continue
}
// Always ignore localhost
if attacker == "127.0.0.1" || attacker == "::1" {
continue
}
rediscounter, _ := rdb.Get(ctx, attacker).Result()
count, _ := strconv.Atoi(rediscounter)
if count >= 5 {
dataStr, err := rdb.Get(ctx, "data"+attacker).Result()
if err == nil {
parts := strings.Split(dataStr, "++")
if len(parts) >= 9 {
timestamp := parts[0]
ip := parts[1]
uaPlatform := parts[2]
ua := parts[3]
message := parts[4]
host := parts[5]
url := parts[6]
method := parts[7]
payload := parts[8]
fmt.Printf("Attacker: %s Counter: %s\n", attacker, rediscounter)
fmt.Printf("timestamp: %s\nattacker: %s\nmethod: %s\n", timestamp, ip, method)
// PF Ban check
bannedHosts := getPFBannedHosts()
isBanned := false
for _, bh := range bannedHosts {
if bh == ip {
isBanned = true
break
}
}
if !isBanned {
fmt.Printf("-- Banning time for: %s\n", ip)
exec.Command("/sbin/pfctl", "-t", "badguys", "-T", "add", ip).Run()
exec.Command("/sbin/pfctl", "-k", ip).Run()
// Update HAProxy File
f, err := os.OpenFile(HaProxyBlacklistFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err == nil {
f.WriteString(ip + "\n")
f.Close()
}
// Update HAProxy Runtime ACL
aclResp, err := haproxyCommand("show acl")
if err == nil {
re := regexp.MustCompile(BadguysAclPattern)
lines := strings.Split(aclResp, "\n")
aclID := ""
for _, line := range lines {
if re.MatchString(line) {
fields := strings.Fields(line)
if len(fields) > 0 {
aclID = strings.TrimPrefix(fields[0], "#")
break
}
}
}
if aclID != "" {
haproxyCommand(fmt.Sprintf("add acl #%s %s", aclID, ip))
}
}
// Send Telegram Alerts
sendTelegram("-- Banning time for: " + ip + " https://badguys.alfaexploit.com/")
alertMsg := fmt.Sprintf("Date: %s\nAttacker: %s\nUA Platform: %s\nUA: %s\nHost: %s\nUrl: %s\nAlert: %s\nPayload: %s",
timestamp, ip, uaPlatform, ua, host, url, message, payload)
sendTelegram(alertMsg)
}
}
}
}
}
// Cleanup PF
pfHosts := getPFBannedHosts()
for _, bh := range pfHosts {
exists, _ := rdb.Exists(ctx, bh).Result()
if exists == 0 {
fmt.Printf(">> PF Unbanning: %s\n", bh)
exec.Command("/sbin/pfctl", "-t", "badguys", "-T", "delete", bh).Run()
}
}
// Cleanup HAProxy File
if _, err := os.Stat(HaProxyBlacklistFile); err == nil {
input, _ := os.ReadFile(HaProxyBlacklistFile)
lines := strings.Split(string(input), "\n")
var newLines []string
for _, line := range lines {
ip := strings.TrimSpace(line)
if ip == "" {
continue
}
exists, _ := rdb.Exists(ctx, ip).Result()
if exists > 0 {
newLines = append(newLines, ip)
} else {
fmt.Printf(">> Unblacklisting HAProxy (file): %s\n", ip)
}
}
os.WriteFile(HaProxyBlacklistFile, []byte(strings.Join(newLines, "\n")+"\n"), 0644)
}
// Cleanup HAProxy Runtime ACL
aclResp, err := haproxyCommand("show acl")
if err == nil {
re := regexp.MustCompile(BadguysAclPattern)
lines := strings.Split(aclResp, "\n")
for _, line := range lines {
if re.MatchString(line) {
fields := strings.Fields(line)
id := strings.TrimPrefix(fields[0], "#")
content, _ := haproxyCommand("show acl #" + id)
contentLines := strings.Split(content, "\n")
for _, cLine := range contentLines {
cFields := strings.Fields(cLine)
if len(cFields) >= 2 {
entryIP := cFields[1]
exists, _ := rdb.Exists(ctx, entryIP).Result()
if exists == 0 {
// Do not unban localhost
if entryIP != "127.0.0.1" && entryIP != "::1" {
fmt.Printf(">> Unblacklisting HAProxy (RAM-ACL): %s\n", entryIP)
haproxyCommand(fmt.Sprintf("del acl #%s %s", id, entryIP))
}
}
}
}
}
}
}
time.Sleep(5 * time.Second)
}
}
We compile and copy the binary to the system:
cp modsecurityNotifier /usr/local/bin
The RC script to manage the service would look like this:
#!/bin/sh
#
# PROVIDE: modsecurityNotifier
# REQUIRE: DAEMON
# KEYWORD: shutdown
. /etc/rc.subr
name=modsecurityNotifier
rcvar=modsecurityNotifier_enable
command="/usr/local/bin/modsecurityNotifier"
pidfile="/var/run/${name}.pid"
start_cmd="modsecurityNotifier_start"
stop_cmd="modsecurityNotifier_stop"
status_cmd="modsecurityNotifier_status"
modsecurityNotifier_start() {
if [ -f "${pidfile}" ] && kill -0 $(cat "${pidfile}") 2>/dev/null; then
echo "${name} already running."
else
echo "Starting ${name}..."
/usr/sbin/daemon -c -f -p ${pidfile} ${command}
fi
}
modsecurityNotifier_stop() {
if [ -f "${pidfile}" ]; then
echo "Stopping ${name}..."
kill -s INT $(cat ${pidfile})
rm -f ${pidfile}
echo "Stopped."
else
echo "${name} is not running."
fi
}
modsecurityNotifier_status() {
if [ -f "${pidfile}" ] && kill -0 $(cat "${pidfile}") 2>/dev/null; then
echo "${name} is running with PID: $(cat ${pidfile})"
else
echo "${name} is not running."
fi
}
load_rc_config $name
run_rc_command "$1"
We assign permissions:
chown root:wheel /usr/local/etc/rc.d/modsecurityNotifier
We enable and start the service:
service modsecurityNotifier start
Aquí tienes la traducción completa al inglés, respetando el formato original:
PF Filtering Rules:
To block traffic using PF, we will use a table called badguys. All traffic coming from these IPs will be blocked except for ports 80/443:
ext_if = "nfe0"
set block-policy return
scrub in on $ext_if all fragment reassemble
set skip on lo
table <badguys> persist
table <jails> persist
nat on $ext_if from <jails> to any -> ($ext_if:0)
rdr-anchor "rdr/*"
antispoof for $ext_if inet
block log all
pass out quick
pass in proto tcp to 192.168.69.2 port 7777
# SMTP -> HellStorm
pass in proto tcp to 192.168.69.17 port 25
# HTTP/HTTPS -> Atlas
pass in proto tcp to 192.168.69.19 port 80
pass in proto tcp to 192.168.69.19 port 443
# Continue handling badguys in HAProxy, we have a surprise for them:
pass in quick proto tcp from <badguys> to 192.168.69.19 port 80
pass in quick proto tcp from <badguys> to 192.168.69.19 port 443
# Xbox -> Paradox
pass in proto tcp from 192.168.69.196 to 192.168.69.18 port 80
# TARS -> Paradox
pass in proto tcp from 192.168.69.198 to 192.168.69.18 port 80
# Garrus -> Paradox, testing purpose
pass in proto tcp from 192.168.69.4 to 192.168.69.18 port 80
pass in proto tcp to any port 22
# Block all traffic from badguys, except 80 and 443 which were previously allowed:
block in from <badguys>
HAProxy Configuration:
As already mentioned, the idea is to keep the HAProxy ACLs in the running configuration synchronized using the AdminSocket and a list of IPs stored in a file on the filesystem. This way, if HAProxy is restarted, everything will continue working because it will read the file that also contains the list of IPs.
The HAProxy configuration would look like this:
global
daemon
maxconn 5000
log 192.168.69.19:514 local1
user nobody
group nobody
stats socket /var/run/haproxy.sock user nobody group nobody mode 660 level admin
ssl-default-bind-ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
timeout connect 10s
timeout client 30s
timeout server 30s
mode http
# ENABLE LOGGING:
# TCP connections
#log global
# Backend connections
#option httplog
listen stats
bind *:8404
stats enable
stats uri /stats
stats refresh 5s
stats auth kr0m:8XEn3EL1aYIeKI
frontend HTTP
bind :80
# Allow Let's Encrypt certificate renewal and redirect all other requests to HTTPS:
acl letsencrypt path_beg /.well-known/acme-challenge/
http-request redirect scheme https unless letsencrypt
use_backend letsencrypt-backend if letsencrypt
frontend HTTP-SSL
bind :443 ssl crt /usr/local/etc/haproxy_certs.pem
http-request add-header X-Forwarded-Proto https
acl mail hdr_dom(host) mail.alfaexploit.com
acl prometheus hdr_dom(host) prometheus.alfaexploit.com
acl grafana hdr_dom(host) grafana.alfaexploit.com
acl gdrive hdr_dom(host) gdrive.alfaexploit.com
acl www_alfaexploit hdr_dom(host) www.alfaexploit.com
acl badguys_alfaexploit hdr_dom(host) badguys.alfaexploit.com
acl alfaexploit hdr_dom(host) alfaexploit.com
http-request deny deny_status 400 if !mail !prometheus !grafana !gdrive !www_alfaexploit !badguys_alfaexploit !alfaexploit
# Show banned list to anyone who wants to view it
use_backend badguys if badguys_alfaexploit
# Route badguys traffic:
acl badguy_ips src -f /usr/local/etc/badguys.list
use_backend badguys if badguy_ips
# Order matters because acl alfaexploit -> alfaexploit.com is the most generic and covers all of them
use_backend mail if mail
use_backend prometheus if prometheus
use_backend prometheus if grafana
use_backend gdrive if gdrive
use_backend alfaexploit if www_alfaexploit
use_backend alfaexploit if alfaexploit
backend letsencrypt-backend
server letsencrypt 192.168.69.19:88
backend mail
server HellStorm 192.168.69.17:80 check send-proxy-v2
backend prometheus
server RECLog 192.168.69.21:80 check send-proxy-v2
backend gdrive
server Paradox 192.168.69.18:8080 check send-proxy-v2
backend alfaexploit
server MetaCortex 192.168.69.20:80 check send-proxy-v2
backend badguys
http-request set-path / unless { path_beg /images }
server Atlas 192.168.69.19:8888 check send-proxy-v2
Attackers Dashboard:
On the server where we will host the attackers dashboard, we will install Nginx and PHP:
We enable and start the PHP service:
service php-fpm start
We include our configuration from the global Nginx configuration file:
http {
include badguys.conf;
server {
listen 192.168.69.19:8888 proxy_protocol;
server_name badguys.alfaexploit.com;
set_real_ip_from 192.168.69.19;
real_ip_header proxy_protocol;
root /usr/local/www/nginx;
index index.php;
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_index index.php;
fastcgi_split_path_info ^(.+\.php)(.*)$;
fastcgi_keep_conn on;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
}
}
We enable and start the Nginx service:
service nginx start
We create the directory where the operating system logos will be stored:
We download the operating system logos:
wget https://alfaexploit.com/files/libmodSecurityFreeBSD/images.tar.gz
tar xvzf images.tar.gz
The dashboard code would be the following:
<?php
$os_array = [
'windows nt 10' => 'WindowsNT',
'windows nt 6.3' => 'WindowsNT',
'windows nt 6.2' => 'WindowsNT',
'windows nt 6.1|windows nt 7.0' => 'WindowsNT',
'windows nt 6.0' => 'WindowsNT',
'windows nt 5.2' => 'WindowsNT',
'windows nt 5.1' => 'WindowsNT',
'windows xp' => 'WindowsXP',
'windows nt 5.0|windows nt5.1|windows 2000' => 'WindowsNT',
'windows me' => 'WindowsME',
'windows nt 4.0|winnt4.0' => 'WindowsNT',
'windows ce' => 'WindowsCE',
'windows 98|win98' => 'Windows98',
'windows 95|win95' => 'Windows95',
'win16' => 'Windows16',
'mac os x 10.1[^0-9]' => 'MacOS',
'macintosh|mac os x' => 'MacOS',
'mac_powerpc' => 'MacOS',
'ubuntu' => 'Linux',
'iphone' => 'iPhone',
'ipod' => 'iPod',
'ipad' => 'iPad',
'android' => 'Android',
'blackberry' => 'BlackBerry',
'webos' => 'WebOS',
'linux' => 'Linux',
'(media center pc).([0-9]{1,2}\.[0-9]{1,2})'=>'WindowsMediaCenter',
'(win)([0-9]{1,2}\.[0-9x]{1,2})'=>'Windows',
'(win)([0-9]{2})'=>'Windows',
'(windows)([0-9x]{2})'=>'Windows',
'Win 9x 4.90'=>'WindowsME',
'(windows)([0-9]{1,2}\.[0-9]{1,2})'=>'Windows',
'win32'=>'Windows',
'(java)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2})'=>'Java',
'(Solaris)([0-9]{1,2}\.[0-9x]{1,2}){0,1}'=>'Solaris',
'dos x86'=>'DOS',
'Mac OS X'=>'MacOS',
'Mac_PowerPC'=>'MacOS',
'(mac|Macintosh)'=>'MacOS',
'(sunos)([0-9]{1,2}\.[0-9]{1,2}){0,1}'=>'SunOS',
'(beos)([0-9]{1,2}\.[0-9]{1,2}){0,1}'=>'BeOS',
'(risc os)([0-9]{1,2}\.[0-9]{1,2})'=>'RISCOS',
'unix'=>'Unix',
'os/2'=>'OS2',
'freebsd'=>'FreeBSD',
'openbsd'=>'OpenBSD',
'netbsd'=>'NetBSD',
'irix'=>'IRIX',
'plan9'=>'Plan9',
'aix'=>'AIX',
'GNU Hurd'=>'GNUHurd',
'(fedora)'=>'Linux',
'(kubuntu)'=>'Linux',
'(ubuntu)'=>'Linux',
'(debian)'=>'Linux',
'(CentOS)'=>'Linux',
'(Mandriva).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)'=>'Linux',
'(SUSE).([0-9]{1,3}(\.[0-9]{1,3})?(\.[0-9]{1,3})?)'=>'Linux',
'(Dropline)'=>'Linux',
'(ASPLinux)'=>'Linux',
'(Red Hat)'=>'Linux',
'(linux)'=>'Linux',
'(amigaos)([0-9]{1,2}\.[0-9]{1,2})'=>'AmigaOS',
'amiga-aweb'=>'AmigaOS',
'amiga'=>'AmigaOS',
'AvantGo'=>'PalmOS',
'[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,3}'=>'Linux',
'(webtv)/([0-9]{1,2}\.[0-9]{1,2})'=>'WebTV',
'Dreamcast'=>'DreamcastOS',
'GetRight'=>'Windows',
'go!zilla'=>'Windows',
'gozilla'=>'Windows',
'gulliver'=>'Windows',
'ia archiver'=>'Windows',
'NetPositive'=>'Windows',
'mass downloader'=>'Windows',
'microsoft'=>'Windows',
'offline explorer'=>'Windows',
'teleport'=>'Windows',
'web downloader'=>'Windows',
'webcapture'=>'Windows',
'webcollage'=>'Windows',
'webcopier'=>'Windows',
'webstripper'=>'Windows',
'webzip'=>'Windows',
'wget'=>'Wget',
'Java'=>'Java',
'flashget'=>'FlashGet',
'MS FrontPage'=>'Windows',
'(msproxy)/([0-9]{1,2}.[0-9]{1,2})'=>'Windows',
'(msie)([0-9]{1,2}.[0-9]{1,2})'=>'Windows',
'libwww-perl'=>'Perl',
'UP.Browser'=>'WindowsCE',
'NetAnts'=>'Windows',
];
function user_agent_os($user_agent, $os_array) {
foreach ($os_array as $regex => $value) {
if (preg_match('{\b('.$regex.')\b}i', $user_agent)) {
return $value;
#$key = array_rand($os_array);
#$value = $os_array[$key];
#return $value;
}
}
return 'Unknown';
}
function secs_to_str ($duration) {
$periods = array(
'day' => 86400,
'hour' => 3600,
'minute' => 60,
'second' => 1
);
$parts = array();
foreach ($periods as $name => $dur) {
$div = floor($duration / $dur);
if ($div == 0)
continue;
else
if ($div == 1)
$parts[] = $div . " " . $name;
else
$parts[] = $div . " " . $name . "s";
$duration %= $dur;
}
$last = array_pop($parts);
if (empty($parts))
return $last;
else
return join(', ', $parts) . " and " . $last;
}
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip_address = $_SERVER['HTTP_CLIENT_IP'];
}
elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'];
}
else {
$ip_address = $_SERVER['REMOTE_ADDR'];
}
$redis = new Redis();
$redis->connect('192.168.69.2', 6379);
$redis->auth('XXXXXXXX');
$allKeys = $redis->keys('*');
echo "<!DOCTYPE html>";
echo "<html>";
echo "<head>";
echo "<style>";
echo ".attacker_data {";
echo "position: relative;";
echo "top: -6px;";
echo "padding-left: 8px;";
echo "}";
echo "table, td, th {";
echo "border: 1px solid black;";
echo "margin-bottom: 15px;";
echo "}";
echo "table {";
echo " border-collapse: collapse;";
echo " width: 100%;";
echo "}";
echo "td {";
echo " text-align: center;";
echo "}";
echo "</style>";
echo "</head>";
echo "<body>";
echo "<table>";
echo "<tr>";
echo "<td>Date</td>";
echo "<td>IP</td>";
echo "<td>I'm watching you</td>";
echo "<td>Attack</td>";
echo "<td>Unban remaining time</td>";
echo "</tr>";
foreach ($allKeys as $key) {
$attacker_data = array();
if (str_starts_with($key, 'data')) {
$counter_key = substr($key, 4);
$counter = $redis->get($counter_key);
if ($counter >= 5){
$ttl = $redis->ttl($key);
$ttl_human = secs_to_str($ttl);
$raw_data = $redis->get($key);
$data = explode("++", $raw_data);
$i = 0;
# We are iterating Redis data: 0-> Date, 1 -> attacker_ip, 2 -> useragent_platform, 3 -> useragent, 4 -> message
$get_os_from_useragent = False;
foreach ($data as $field) {
$field = trim($field);
if ($i == 0) {
$attacker_data[] = $field;
} elseif ($i == 1) {
$attacker_ip = $field;
## We have limits requesting /ip-api.com url so we try to cache it
if ($redis->exists('geoipdata'.$attacker_ip)) {
$geoipdata = ($redis->get('geoipdata'.$attacker_ip));
$attacker_data[] = $geoipdata;
} else {
$ips = array();
$ips[] = $field;
$endpoint = 'http://ip-api.com/batch';
$options = [
'http' => [
'method' => 'POST',
'user_agent' => 'AlfaExploit/1.0',
'header' => 'Content-Type: application/json',
'content' => json_encode($ips)
]
];
if (($response = @file_get_contents($endpoint, false, stream_context_create($options))) === false) {
$attacker_data[] = 'UNKNOWN';
# Cache data: 3600s -> 1h
$redis->set('geoipdata'.$attacker_ip, 'UNKNOWN');
$redis->expire('geoipdata'.$attacker_ip, 3600);
} else {
$array = json_decode($response, true);
$status = $array[0]['status'];
if ($status == 'fail' ){
$attacker_data[] = $array[0]['message'];
# Cache data: 3600s -> 1h
$redis->set('geoipdata'.$attacker_ip, $array[0]['message']);
$redis->expire('geoipdata'.$attacker_ip, 3600);
} else {
$country = $array[0]['country'];
$regionName = $array[0]['regionName'];
$lat = $array[0]['lat'];
$lon = $array[0]['lon'];
$isp = $array[0]['isp'];
$attacker_data[] = $country.'/'.$regionName.'('.$lat.' '.$lon.')'. $isp;
# Cache data: 3600s -> 1h
$redis->set('geoipdata'.$attacker_ip, $country.'/'.$regionName.'('.$lat.' '.$lon.')'. $isp);
$redis->expire('geoipdata'.$attacker_ip, 3600);
}
}
}
} elseif ($i == 2) {
# useragent_platform is NULL, continue, we will try to get it from user-agent
if ($field == 'NULL') {
$get_os_from_useragent = True;
} else {
$attacker_data[] = str_replace('"', '', $field);;
}
} elseif ($i == 3 && $get_os_from_useragent) {
# Try to get OS from user-agent
$attacker_data[] = user_agent_os($field, $os_array);
} elseif ($i == 4) {
$attacker_data[] = $field;
}
$i = $i + 1;
}
# Print table data
echo "<tr>";
# DATE:
echo "<td>$attacker_data[0]</td>";
# IP:
if ($ip_address == $attacker_ip) {
echo "<td><span style=\"color:#E20D41;\">$attacker_ip</span></td>";
} else {
echo "<td>$attacker_ip</td>";
}
# ATTACKER DATA
echo "<td><img src=/images/$attacker_data[2].png><span class='attacker_data'>$attacker_data[1]</span></td>";
echo "<td>$attacker_data[3]</td>";
echo "<td>$ttl_human</td>";
echo "</tr>";
}
}
}
echo "</table>";
echo '<a href="https://t.me/AlfaExploia">Telegram channel</a> Email: echo \'a3IwbSAoYXQpIGFsZmFleHBsb2l0IChkb3QpIGNvbQo=\' | base64 -d';
echo '<form action="https://alfaexploit.com">';
echo '<button type="submit">AlfaExploit</button>';
echo '</form>';
echo "</body>";
echo "</html>";
Useful Programs:
This program will allow us to quickly see the banned attackers:
cd showBadGuys
go mod init showBadGuys
go get github.com/redis/go-redis/v9
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
const (
HaProxyBlacklistFile = "/usr/local/bastille/jails/Atlas/root/usr/local/etc/badguys.list"
HaProxyAdminSocketDir = "/usr/local/bastille/jails/Atlas/root/var/run/"
BadguysAclPattern = `.*\(/usr/local/etc/badguys\.list\) pattern loaded from file '/usr/local/etc/badguys\.list'.*`
)
var ctx = context.Background()
// Function to interact with HAProxy via Unix Socket
func haproxyCommand(cmd string) (string, error) {
files, _ := filepath.Glob(filepath.Join(HaProxyAdminSocketDir, "*.sock"))
if len(files) == 0 {
files = append(files, filepath.Join(HaProxyAdminSocketDir, "haproxy.sock"))
}
var lastErr error
for _, socketPath := range files {
conn, err := net.Dial("unix", socketPath)
if err != nil {
lastErr = err
continue
}
defer conn.Close()
fmt.Fprintf(conn, cmd+"\n")
var buf bytes.Buffer
io.Copy(&buf, conn)
return buf.String(), nil
}
return "", fmt.Errorf("could not connect to socket: %v", lastErr)
}
// Format seconds to H:MM:SS
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
func main() {
// 1. REDIS Connection
rdb := redis.NewClient(&redis.Options{
Addr: "192.168.69.2:6379",
Password: "XXXXXXXXXXX",
DB: 0,
})
if err := rdb.Ping(ctx).Err(); err != nil {
fmt.Println("++ ERROR: Cannot connect to Redis server")
return
}
fmt.Println("\n---------------")
fmt.Println(">> REDIS")
fmt.Println("---------------")
iter := rdb.Scan(ctx, 0, "*", 0).Iterator()
for iter.Next(ctx) {
attacker := iter.Val()
if strings.HasPrefix(attacker, "data") || strings.HasPrefix(attacker, "geoipdata") {
continue
}
rediscounter, _ := rdb.Get(ctx, attacker).Result()
ttl, _ := rdb.TTL(ctx, attacker).Result()
fmt.Printf("> Attacker: %s Counter: %s\n", attacker, rediscounter)
fmt.Printf("TTL: %d RemainingTime: %s\n", int(ttl.Seconds()), formatDuration(ttl))
if count := 0; count >= 0 { // Banned logic
c, _ := fmt.Sscanf(rediscounter, "%d", &count)
if c > 0 && count >= 5 {
fmt.Println("Banned: YES")
} else {
fmt.Println("Banned: NO")
}
}
// Additional data (data+attacker)
dataStr, err := rdb.Get(ctx, "data"+attacker).Result()
if err == nil {
parts := strings.Split(dataStr, "++")
if len(parts) >= 9 {
fmt.Printf("timestamp: %s\n", parts[0])
fmt.Printf("useragent_platform: %s\n", parts[2])
fmt.Printf("useragent: %s\n", parts[3])
fmt.Printf("message: %s\n", parts[4])
fmt.Printf("host: %s\n", parts[5])
fmt.Printf("url: %s\n", parts[6])
fmt.Printf("method: %s\n", parts[7])
}
}
}
// 2. PF Section
fmt.Println("\n---------------")
fmt.Println(">> PF")
fmt.Println("---------------")
pfCmd := exec.Command("pfctl", "-t", "badguys", "-T", "show")
pfOut, _ := pfCmd.Output()
fmt.Printf("PF Banned hosts: \n%s", string(pfOut))
// 3. HA-Proxy FILE Section
fmt.Println("\n----------------")
fmt.Println(">> HA-Proxy FILE")
fmt.Println("----------------")
if _, err := os.Stat(HaProxyBlacklistFile); err == nil {
file, _ := os.Open(HaProxyBlacklistFile)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
file.Close()
}
// 4. HA-Proxy ACL Section
fmt.Println("\n----------------")
fmt.Println(">> HA-Proxy ACL")
fmt.Println("---------------")
aclResp, err := haproxyCommand("show acl")
if err != nil {
fmt.Printf("ERROR: %v\n", err)
} else {
re := regexp.MustCompile(BadguysAclPattern)
lines := strings.Split(aclResp, "\n")
aclID := ""
for _, line := range lines {
if re.MatchString(line) {
fields := strings.Fields(line)
if len(fields) > 0 {
aclID = strings.TrimPrefix(fields[0], "#")
break
}
}
}
if aclID != "" {
content, _ := haproxyCommand("show acl #" + aclID)
cLines := strings.Split(content, "\n")
for _, cLine := range cLines {
cFields := strings.Fields(cLine)
if len(cFields) >= 2 {
fmt.Println(cFields[1]) // The second field is the IP/Pattern
}
}
}
}
}
We compile and copy the binary to the system:
cp showBadGuys /usr/local/bin/
When executed, we will see the following output:
---------------
>> REDIS
---------------
> Attacker: 192.168.69.202 Counter: 19
TTL: 3522 RemainingTime: 0:58:42
Banned: YES
timestamp: Sat Feb 25 12:56:26 2023
useragent_platform: "Linux"
useragent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
message: SQL Injection Attack Detected via libinjection
---------------
>> PF
---------------
PF Banned hosts:
192.168.69.202
----------------
>> HA-Proxy FILE
----------------
192.168.69.202
----------------
>> HA-Proxy ACL
---------------
192.168.69.202
Another really useful feature is being able to flush all bans:
cd flushBadGuys
go mod init flushBadGuys
go get github.com/redis/go-redis/v9
package main
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/redis/go-redis/v9"
)
const (
HaProxyBlacklistFile = "/usr/local/bastille/jails/Atlas/root/usr/local/etc/badguys.list"
HaProxyAdminSocketDir = "/usr/local/bastille/jails/Atlas/root/var/run/"
BadguysAclPattern = `badguys\.list`
)
var ctx = context.Background()
// haproxyExecute handles multiple commands in a single session
func haproxyExecute(commands []string) (string, error) {
files, _ := filepath.Glob(filepath.Join(HaProxyAdminSocketDir, "*.sock"))
if len(files) == 0 {
files = append(files, filepath.Join(HaProxyAdminSocketDir, "haproxy.sock"))
}
var lastErr error
for _, socketPath := range files {
conn, err := net.Dial("unix", socketPath)
if err != nil {
lastErr = err
continue
}
defer conn.Close()
for _, cmd := range commands {
fmt.Fprintf(conn, cmd+"\n")
}
var buf bytes.Buffer
io.Copy(&buf, conn)
return buf.String(), nil
}
return "", fmt.Errorf("no connection to HAProxy socket: %v", lastErr)
}
func main() {
// 1. REDIS FLUSH
rdb := redis.NewClient(&redis.Options{Addr: "192.168.69.2:6379", Password: "XXXXXXXX", DB: 0})
fmt.Println("\n>> REDIS: Flushing database...")
if err := rdb.FlushDB(ctx).Err(); err != nil {
fmt.Printf(" Error: %v\n", err)
} else {
fmt.Println(" Success: Redis database cleared.")
}
// 2. PF FLUSH
fmt.Println("\n>> PF: Flushing table 'badguys'...")
if err := exec.Command("pfctl", "-t", "badguys", "-T", "flush").Run(); err != nil {
fmt.Printf(" Error: %v\n", err)
} else {
fmt.Println(" Success: PF table cleared.")
}
// 3. FILE TRUNCATE
fmt.Println("\n>> FILE: Truncating 'badguys.list'...")
if err := os.WriteFile(HaProxyBlacklistFile, []byte(""), 0644); err != nil {
fmt.Printf(" Error: %v\n", err)
} else {
fmt.Println(" Success: File truncated.")
}
// 4. HA-PROXY ACL FLUSH
fmt.Println("\n>> HA-PROXY: Searching for ACL-Badguys...")
resp, err := haproxyExecute([]string{"show acl"})
if err != nil {
fmt.Printf(" Socket Error: %v\n", err)
return
}
re := regexp.MustCompile(BadguysAclPattern)
lines := strings.Split(resp, "\n")
var targetAcls []string
for _, line := range lines {
if re.MatchString(line) {
fields := strings.Fields(line)
if len(fields) > 0 {
// Your HAProxy returns ID as the first field (e.g., "12")
id := strings.TrimPrefix(fields[0], "#")
targetAcls = append(targetAcls, id)
}
}
}
if len(targetAcls) == 0 {
fmt.Println(" Notice: No ACL linked to 'badguys.list' was found.")
return
}
for _, id := range targetAcls {
fmt.Printf(" Action: Cleaning entries for ACL-Badguys (ID: %s)...\n", id)
// Attempting fast clear (HAProxy 2.2+)
clearResp, _ := haproxyExecute([]string{fmt.Sprintf("clear acl #%s", id)})
// Fallback to manual deletion if 'clear' is unsupported
if strings.Contains(clearResp, "Unknown command") {
fmt.Println(" Fallback: 'clear' command unsupported. Deleting entries manually...")
content, _ := haproxyExecute([]string{fmt.Sprintf("show acl #%s", id)})
cLines := strings.Split(content, "\n")
var delCmds []string
count := 0
for _, cLine := range cLines {
cFields := strings.Fields(cLine)
// Format: [reference] [value/IP]
if len(cFields) >= 2 {
ip := cFields[1]
delCmds = append(delCmds, fmt.Sprintf("del acl #%s %s", id, ip))
count++
}
}
if len(delCmds) > 0 {
haproxyExecute(delCmds)
fmt.Printf(" Success: %d entries removed from ACL-Badguys.\n", count)
} else {
fmt.Println(" Notice: ACL-Badguys was already empty.")
}
} else {
fmt.Println(" Success: ACL-Badguys flushed via 'clear acl'.")
}
}
fmt.Println("\n>> Cleanup process completed successfully.")
}
We compile and copy the binary to the system:
cp flushBadGuys /usr/local/bin/
When executed, we will see the following output:
>> REDIS: Flushing database...
Success: Redis database cleared.
>> PF: Flushing table 'badguys'...
Success: PF table cleared.
>> FILE: Truncating 'badguys.list'...
Success: File truncated.
>> HA-PROXY: Searching for ACL-Badguys...
Action: Cleaning entries for ACL-Badguys (ID: 12)...
Success: ACL-Badguys flushed via 'clear acl'.
>> Cleanup process completed successfully.
Final Result:
Telegram notifications will look like this:
And the dashboard will look like this: