EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Security / Tuning ModSecurity on DirectAdmin for Shared Hosting
Security

Tuning ModSecurity on DirectAdmin for Shared Hosting

by Ton-Or ·20 May 2026·11 min read
Tuning ModSecurity on DirectAdmin for Shared Hosting

DA's default ModSecurity setup is passable, but incomplete for real shared hosting. There are plenty of false positives and constant scanner requests, so I added 4 parts — all of them go "beyond" what DA ships and are available neither in CustomBuild nor in the panel.

In the security installment of my series on migrating CentOS 7 to Debian 12, I briefly covered ModSecurity BEFORE-CRS exclusions. After that installment, several people asked follow-up questions, so I split this out into a dedicated article so anyone interested could follow the full process.

Since migrating to Debian 12, the server I manage has used the DirectAdmin CustomBuild ML stack: ModSecurity 3 + OWASP CRS 4.x + Comodo WAF as shipped by DA. The defaults are passable to a point, but they are incomplete for shared hosting with dozens of customers on one server. I ran into two problems at once — on one side, false positives kept coming up until customers could no longer use their sites normally; on the other, external scanners sent requests all day without the system responding.

So I had to customize 4 parts. Let me be clear up front: everything I describe below goes "beyond" what DA ships. It is neither part of CustomBuild nor available in the panel; you have to edit the files yourself.

Overview of the 4 additions

The 4 parts work at different points along the request path, but complement each other in layers. The flow of a single incoming request looks roughly like this.

Request pathnginx->ModSecexclusions before CRS->OWASP CRS->error_log->CSFcredscan + LF_MODSEC->ban

Here is what I added and fixed.

  • 3 exclusion rules in REQUEST-900-* that load before CRS. This placement matters: if they load afterward, CRS has already fired and the exclusions are too late.
  • A custom credscan regex in CSF — an immediate 24-hour ban on the first hit, a mechanism DA does not provide.
  • A fix for the incorrect CSF MODSEC_LOG path shipped by DA, which prevented LF_MODSEC from ever firing, however many requests scanners sent.
  • A self-heal cron to recover when a DA rebuild overwrites my exclusion file.

I will go through each part.

1. The 3 exclusion rules (loaded before CRS)

DA's usual location for user-defined custom rules is /usr/local/directadmin/data/admin/modsecurity_rules. The problem is that DA loads this file after CRS, which is too late. When a request arrives, CRS fires before the exclusions I wrote get a chance to run.

The fix is to put the exclusions in a file that the stack loads before CRS: REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf in /etc/modsecurity.d/. As the name says, it runs before CRS.

# 99001 — per-IP whitelist (monitor server ของผม)
SecRule REMOTE_ADDR "@ipMatch 203.0.113.99" "id:99001,phase:1,pass,nolog,ctl:ruleRemoveById=920350"

# 99002 — global disable rule 920450 (WP Gutenberg ส่ง X-HTTP-Method-Override ปกติ)
SecAction "id:99002,phase:1,pass,nolog,ctl:ruleRemoveById=920450"

# 99003 — per-URL ปิด ModSec engine บน phpMyAdmin (false positive รัว whack-a-mole ไม่ไหว)
SecRule REQUEST_URI "@beginsWith /phpMyAdmin/" "id:99003,phase:1,pass,nolog,ctl:ruleEngine=Off"

I will explain the problem behind each rule, because understanding where it came from makes it easier to adapt correctly to your own server.

99001 (per-IP) — My monitoring server sends a health check every minute using the IP directly rather than a hostname. As a result, every request triggered CRS rule 920350, meaning "Host header is a numeric IP." I therefore exempted only that IP from this one rule. The other CRS rules still inspect that IP normally; I did not disable the entire set, so the scope stays narrow.

99002 (global) — The WordPress Gutenberg block editor sends the header X-HTTP-Method-Override: PUT by design in newer WP versions. But rule 920450 treats this header as suspicious and blocks it. As a result, admins cannot publish and get the error "Response is not valid JSON." I therefore disabled this rule globally for all IPs, because this header complies with the modern WP spec and is not something unexpected.

99003 (per-URL) — phpMyAdmin generates the most false positives by far. It triggers several CRS rules at once (932380, 921140, 942100, and many others). Exempting them one by one would be an endless game of whack-a-mole. I chose to turn off the entire ModSec engine for this path alone. I can "identify" the risk here — this path requires a DA admin login first, and brute-force protection at the .htaccess level provides another layer, so it is not entirely exposed.

Before disabling the engine for an entire path

Disabling the entire ruleEngine is suitable only for paths already protected by authentication, such as phpMyAdmin requiring an admin login. Never disable it this way for a public path anyone can access; add exemptions for individual rules instead.

To add exclusions in the future, inspect logged events and look for [id "XXXXXX"] in the lines recorded by ModSec, then choose a type that matches the scope of the problem.

Choose the narrowest scope

3 available options

  • per-IP — the problem comes from one IP (monitor, office)
  • global — the rule is wrong for everyone (a structural false positive)
  • per-URL — one path where individual rule exemptions are impractical
Principle

Keep the scope no broader than necessary

  • Start with the narrowest scope
  • Exempt individual rules before disabling protection for an entire path
  • Disabling the entire engine is the last resort

2. Credscan — ban scanners on the first hit

DA defaults use the normal BFM (Brute Force Monitor) threshold: a scanner needs roughly 50 hits or more before it gets banned. The problem is that today's scanners are smarter; they keep changing paths. Those actually performing credential stuffing hit one file at a time, each at a different path. As a result, they never accumulate enough hits to reach the BFM threshold, because BFM counts repeated failures at the same endpoint rather than signatures of probing behavior.

So I wrote a custom regex for CSF to match signatures specifically associated with probing for credential files or backdoor shells, and impose an immediate 24-hour ban on the first hit. The file is /usr/local/csf/bin/regex.custom.pm.

sub custom_line {
    my $line = shift;
    my $lgfile = shift;

    if (($globlogs{CUSTOM1_LOG}{$lgfile}) and
        ($line =~ /\[client (\d+\.\d+\.\d+\.\d+)\].*ModSecurity.*\[uri "[^"]*?\/(wp-config\.php|\.env|\.git\/config|\.ssh\/id_rsa|credentials\.json|alfa[0-9]*\.php|shell\.php|cmd\.php|r57\.php|c99\.php|[0-9]\.php)"/i)) {
        return ("Credential/scanner probe ($2)",
                $1,           # IP ที่จะ ban
                "credscan",   # ชื่อ trigger
                "1",          # 1 hit ban ทันที
                "*",          # ทุก port
                "86400",      # ban 24 ชม.
                "0");
    }
    return 0;
}
1;

The key part of this regex is the returned fields — "1" is the hit count that triggers a ban, meaning a single hit is enough, and "86400" is the ban duration in seconds, or 24 hours.

I match around 25 patterns in total, grouped as follows.

  • Credential files — credentials.json, firebase-adminsdk*, gcp-*.json, keyfile.json, secret.json
  • Env files — .env, .env.production, .env.development
  • Cloud — .aws/credentials, gcloud/application_default
  • Git / SSH — .git/config, .ssh/id_rsa, .npmrc, .pypirc
  • PHP info disclosure — phpinfo.{bak,save,old}, info.php~, .info.php
  • WordPress config — wp-config.php, wp-config-sample.php, wp-config.php.{bak,save,old,orig}
  • Backdoor shells — wp-good.php, shell.php, cmd.php, alfa*.php, xleet*.php, r57.php, c99.php, m.php, [0-9].php

For the results measured within the first 3-4 hours after deployment, look at this log excerpt (with IPs sanitized): requests were coming in constantly.

03:58  20.x.x.x    JP   probed wp-config-sample.php
04:38  20.x.x.x    SG   probed wp-config-sample.php
04:51  20.x.x.x    CA   probed wp-config-sample.php
06:06  194.x.x.x   DE   probed .git/config
07:09  104.x.x.x   US   probed wp-config-sample.php

Every one was banned on the first hit. The previous DA defaults let roughly 177 hits accumulate before triggering. This greatly reduced the attack surface available for attackers to probe.

Why 1-hit bans are safe for real customers

These patterns match files that "normal users have no reason to request" — nobody browses a website and accidentally requests .ssh/id_rsa or c99.php. A single-hit ban therefore produces almost no false positives, unlike setting a low threshold for ordinary paths that real users might access.

3. Fixing the CSF MODSEC_LOG path bug

This is a bug in the configuration DA ships from the outset. Open /etc/csf/csf.conf and you will see this setting.

MODSEC_LOG = "/var/log/apache/modsec_audit.log"

The problem is that ModSecurity 3 on nginx, as currently used by DA, actually logs to /var/log/nginx/error_log, not the Apache path. As a result, CSF watches the wrong file. With the wrong file being watched, LF_MODSEC never fires. Even if ModSec blocks more than 100 requests per hour, CSF remains completely silent because it is watching a file where nothing is happening.

ModSecurity 3on nginx/var/log/nginx/error_logActual log destination/var/log/apache/modsec_audit.logWrong DA path; no activityCSFLF_MODSECAfter fixBefore fixSilent LF_MODSEC means CSF is watching the wrong file, not that the server is safe.
ModSecurity 3 on nginx logs to error_log, but the MODSEC_LOG setting shipped by DA points to an Apache file, so CSF sees no events at all. It must be changed to point to the same file.

Apply this fix.

sed -i 's|^MODSEC_LOG = .*|MODSEC_LOG = "/var/log/nginx/error_log"|' /etc/csf/csf.conf
sed -i 's|^LF_MODSEC = .*|LF_MODSEC = "25"|' /etc/csf/csf.conf
sed -i 's|^LF_MODSEC_PERM = .*|LF_MODSEC_PERM = "1800"|' /etc/csf/csf.conf
csf -r

These are the values I use, and why.

  • LF_MODSEC = 25 — ban after 25 hits per hour, enough to distinguish scanner behavior from ordinary user false positives.
  • LF_MODSEC_PERM = 1800 — ban for 30 minutes. A permanent ban is unnecessary because most of these IPs rotate; permanently banning them just wastes iptables memory.
  • CUSTOM1_LOG = /var/log/nginx/error_log — point it to the same file, because the credscan regex in part 2 also reads this file.
Check before assuming the system works

If your DA shared hosting server has many customers but you have never seen LF_MODSEC trigger even once, do not celebrate that "nobody is attacking." Suspect an incorrect path first, and check MODSEC_LOG immediately. Silence here means CSF is watching the wrong file, not that the server is safe.

Within just a few minutes of the fix, LF_MODSEC started firing as it should.

4. Self-heal — recovering exclusions overwritten by DA rebuilds

The next problem is persistence. When DA updates or CustomBuild rebuilds (for example, when running ./build modsecurity_rules), it may wipe files in /etc/modsecurity.d/. The exclusions I spent time writing disappear silently without anyone knowing. All the false positives then return, and customers complain later that they cannot use their sites. By the time I discover that a rebuild caused it, I have spent a long time investigating.

I addressed this with a safety-net script and a daily cron. The idea is for the script to check markers to see whether all the rules are present. If they are, it exits; if any are missing, it restores the entire file itself.

cat > /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh <<'EOF'
#!/bin/bash
TARGET=/etc/modsecurity.d/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf

# Marker check — ถ้ามี rule ครบ = OK ออกเลย
if [ -f "$TARGET" ] \
   && grep -q 'id:99001' "$TARGET" \
   && grep -q 'id:99002' "$TARGET" \
   && grep -q 'id:99003' "$TARGET"; then
    exit 0
fi

# Restore template เต็มไฟล์
cat > "$TARGET" <<'RULES_EOF'
SecRule REMOTE_ADDR "@ipMatch 203.0.113.99" "id:99001,phase:1,pass,nolog,ctl:ruleRemoveById=920350"
SecAction "id:99002,phase:1,pass,nolog,ctl:ruleRemoveById=920450"
SecRule REQUEST_URI "@beginsWith /phpMyAdmin/" "id:99003,phase:1,pass,nolog,ctl:ruleEngine=Off"
RULES_EOF

nginx -t && systemctl reload nginx
EOF
chmod 755 /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh

# Cron daily
ln -sf /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh \
       /etc/cron.daily/modsec-exclusions-check

I put the script in scripts/custom/ because DA leaves this directory alone during rebuilds, so the script always survives DA updates. The symlink in /etc/cron.daily/ makes it run every day. Whenever the exclusion file breaks, it is restored automatically within 24 hours at most.

The self-heal principle

Anything "beyond" what DA manages must always have a mechanism to restore itself after updates. The key is to put files in paths that rebuilds leave alone (such as scripts/custom/ and CSF preserved paths), then use an idempotent marker check — running it repeatedly does not break anything.

I have several other layers for surviving DA updates (template overrides, 99-custom-*.ini, post-build hooks), which are outside this article's scope. I covered them in the security installment of the migration series.

Summary of files added and changed from DA defaults

Everything described here touches only a few files. Here is the list for an overview.

NEW       /etc/modsecurity.d/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
NEW       /usr/local/csf/bin/regex.custom.pm
NEW       /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh
NEW       /etc/cron.daily/modsec-exclusions-check
MODIFIED  /etc/csf/csf.conf   (MODSEC_LOG + LF_MODSEC + CUSTOM1_LOG)
4new files
1modified file
3exclusion rules
~25credscan patterns

All of this survives DA updates because it lives in paths that rebuilds leave alone — /etc/modsecurity.d/ has the self-heal cron to restore it, /usr/local/csf/bin/regex.custom.pm is in a CSF preserved path, and DA already leaves scripts/custom/ alone.

My actual credscan regex is considerably longer than the example above. It includes several more patterns, including old backdoor shells that scanners still probe for in 2026. If you want to build on it, the principle is the same throughout — match signatures that normal users have no reason to request, then ban quickly. Adjust the threshold and ban duration to suit each server's customer count and how aggressive you are comfortable being.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first