Mitigating C-Segment IP-Based Malicious Traffic: 4 Defensive Strategies with ModSecurity
Web application defenses like Nginx’s HttpLimitReqModule, Apache’s mod_evasive, and OWASP Core Rules effectively block single-IP malicious activity such as CC attacks or scraping by enforcing per-IP request limits. However, attackers often circumvent these measures by leveraging entire C-segment IP ranges—renting blocks from ISPs to distribute requests across hundreds of IPs, keeping per-IP request rates below threshold triggers. This renders standard per-IP defenses ineffective.
To address this gap, we outline four defensive strategies to mitigate C-segment-based malicious traffic, including their design logic, implemantation rules, and tradeoffs. Strategy 1 is theoretically the most efficient but cannot be used due to inherent ModSecurity limitations; we also detail the troubleshooting process that uncovered this constraint. For low-traffic sites, Strategy 2 is recommended. High-traffic sites should adopt Strategy 3 or 4, with Strategy 4 being preferred if in-house development resources are available.
Preconfiguration Steps
Before implementing any strategy, define core defense thresholds using OWASP Core Rules settings. Locate and uncomment the following two rules in crs-setup.conf:
(Note: Enabling these rules activates built-in per-IP DDoS protection. Since our C-segment defenses will also cover per-IP scenarios, you can disable the separate OWASP per-IP DDoS rules to reduce rule overhead—rename REQUEST-912-DOS-PROTECTION.conf to REQUEST-912-DOS-PROTECTION.conf.bak or remove it.)
SecAction \
'id:900260,\
phase:1,\
nolog,\
pass,\
t:none,\
setvar:"tx.excluded_static_types=/.jpg/ /.jpeg/ /.png/ /.gif/ /.js/ /.css/ /.ico/ /.svg/ /.webp/"'
SecAction \
'id:900700,\
phase:1,\
nolog,\
pass,\
t:none,\
setvar:"tx.detection_interval=30",\
setvar:"tx.request_threshold=5",\
setvar:"tx.block_duration=600"'
Rule 900260 defines static file extensions that are excluded from request counting (since these are unlikely to be part of targeted scraping/attacks). Rule 900700 sets threshold values: if a C-segment exceeds tx.request_threshold requests within tx.detection_interval seconds, it will be blocked for tx.block_duration seconds. Adjust these values based on your site’s normal traffic patterns.
Strategy 1: Direct C-Segment Request Counting (Unavailable)
Defensive Logic
- Extract the C-segment prefix from the client’s IP address using regular expressions (e.g., 192.168.11.2 becomes "192.168.11.").
- Create a global variable named after the C-segment prefix to track total requests from that range, with a TTL matching
tx.detection_intervalto reset counts after the window expires. - On each request: if the C-segment’s request count is below the threshold, increment the count; if above, block the request and extend the variable’s TTL to
tx.block_durationto auto-unblock after the penalty period.
Implementation Rules
# Extract C-segment prefix from client IP and store in ip.c_segment_prefix
SecRule REMOTE_ADDR "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "id:20000,nolog,pass,phase:1,capture,setvar:ip.c_segment_prefix=%{TX.0}"
# Block requests if C-segment exceeds threshold, extend block duration
SecRule GLOBAL:%{ip.c_segment_prefix} "@ge %{tx.request_threshold}" "id:20001,drop,log,phase:1,expirevar:global.%{ip.c_segment_prefix}=%{tx.block_duration}"
# Increment C-segment count only for non-static requests
SecRule REQUEST_BASENAME ".*?(\\.[a-z0-9]{1,10})?$" "id:20002,phase:5,t:none,t:lowercase,nolog,pass,capture,setvar:tx.req_extension=/%{TX.1}/,chain"
SecRule TX:REQ_EXTENSION "!@within %{tx.excluded_static_types}" "setvar:global.%{ip.c_segment_prefix}=+1,expirevar:global.%{ip.c_segment_prefix}=%{tx.detection_interval}"
Tradeoffs
Pros: Minimal rule overhead, no third-party tools required—purely ModSecurity-based.
Cons: Fully non-functional due to ModSecurity’s variable resolution limitations. The %{ip.c_segment_prefix} placeholder in GLOBAL:%{ip.c_segment_prefix} is not resolved to the actual prefix value; instead, ModSecurity looks for a global variable named literal string %{ip.c_segment_prefix}, which does not exist. Attempts to work around this by assigning the global variable value to a local IP-set variable (e.g., setvar:ip.c_segment_count=%{global.%{ip.c_segment_prefix}}) also fail, as ModSecurity only resolves nested %{} placeholders partially, resulting in invalid values. Source code modifications would be required to fix this, making this strategy unviable without custom ModSecurity builds.
Strategy 2: ModSecurity-Only C-Segment Blocking (For Low Concurrency)
Defensive Logic
Similar to Strategy 1, but avoids nested variable resolution by storing C-segment counts in global variables with explicit names (e.g., global.c_segment_192.168.11.). Use a per-IP variable to track if the client’s C-segment is blocked, and block requests once the threshold is exceeded. Since counting is done in phase 5 (post-request), we add a check in phase 1 to block subsequent requests from the same C-segment.
Implementation Rules
# Extract C-segment prefix from client IP
SecRule REMOTE_ADDR "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "id:20000,nolog,pass,phase:1,capture,setvar:ip.c_segment_prefix=%{TX.0}"
# Block requests if C-segment is flagged as malicious, log only once per block period
SecRule IP:SEGMENT_BLOCKED "@eq 1" "id:20001,phase:1,drop,log,chain,msg:'Malicious traffic detected from C-segment %{ip.c_segment_prefix}0/24'"
SecRule &IP:BLOCK_LOGGED "@eq 0" "setvar:ip.block_logged=1,expirevar:ip.block_logged=%{tx.block_duration}"
# Block subsequent requests without logging to avoid log spam
SecRule IP:SEGMENT_BLOCKED "@eq 1" "id:20004,phase:1,drop,nolog"
# Increment C-segment count for non-static requests
SecRule REQUEST_BASENAME ".*?(\\.[a-z0-9]{1,10})?$" "phase:5,id:20002,t:none,t:lowercase,nolog,pass,capture,setvar:tx.req_extension=/%{TX.1}/,chain"
SecRule TX:REQ_EXTENSION "!@within %{tx.excluded_static_types}" "setvar:'global.c_segment_%{ip.c_segment_prefix}=+1',expirevar:global.c_segment_%{ip.c_segment_prefix}=%{tx.detection_interval}"
# Check for C-segments exceeding threshold, flag them as blocked
SecRule GLOBAL:/^c_segment_/ "@ge %{tx.request_threshold}" "phase:5,id:20003,pass,nolog,chain"
SecRule MATCHED_VAR_NAME "((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "capture,setvar:tx.target_segment=%{TX.0},chain,expirevar:global.c_segment_%{TX.0}=%{tx.block_duration}"
SecRule TX:TARGET_SEGMENT "@streq %{ip.c_segment_prefix}" "setvar:ip.segment_blocked=1,expirevar:ip.segment_blocked=%{tx.block_duration}"
Tradeoffs
Pros: No external tools required, fully ModSecurity-based.
Cons:
- Increased server resource usage due to global variable iteration on each request (even though it’s done in phase 5).
- First request from a new IP in a blocked C-segment will not be blocked (since the flag is set post-request). To fix this, move the iteration to phase 1, but this will degrade response times for high-concurrency sites.
- Per-IP variables mean switching clients from the same IP will allow one unblocked request before the flag is re-applied.
Strategy 3: ModSecurity + Lua + ipset (For High Concurrency)
Defensive Logic
Optimize Strategy 2 by offloading blocking to ipset (a kernel-level IP blocking tool) instead of relying on ModSecurity variables. When a C-segment exceeds the threshold, delete the ModSecurity count variable and use a Lua script to add the C-segment to an ipset list, which is enforced via iptables. This reduces ongoing resource usage since ipset blocks requests before they reach ModSecurity.
Implementation Rules
# Extract C-segment prefix from client IP
SecRule REMOTE_ADDR "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "id:20000,nolog,pass,phase:1,capture,setvar:ip.c_segment_prefix=%{TX.0}"
# Increment C-segment count for non-static requests
SecRule REQUEST_BASENAME ".*?(\\.[a-z0-9]{1,10})?$" "phase:5,id:20002,t:none,t:lowercase,nolog,pass,capture,setvar:tx.req_extension=/%{TX.1}/,chain"
SecRule TX:REQ_EXTENSION "!@within %{tx.excluded_static_types}" "setvar:'global.c_segment_%{ip.c_segment_prefix}=+1',expirevar:global.c_segment_%{ip.c_segment_prefix}=%{tx.detection_interval}"
# Detect threshold breaches, delete count variable, and trigger ipset block via Lua
SecRule GLOBAL:/^c_segment_/ "@ge %{tx.request_threshold}" "phase:5,id:20003,pass,log,capture,msg:'Malicious traffic detected from C-segment %{TX.0}0/24',chain"
SecRule MATCHED_VAR_NAME "((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "capture,setvar:!global.c_segment_%{TX.0},exec:/tmp/c_segment_block.lua"
Lua Script (/tmp/c_segment_block.lua)
function main()
local client_ip = m.getvar("REMOTE_ADDR")
local ip_parts = {}
local separator = '%.'
string.gsub(client_ip, '[^'..separator..']+', function(part) table.insert(ip_parts, part) end)
if #ip_parts == 4 then
local c_segment_cidr = ip_parts[1].."."..ip_parts[2].."."..ip_parts[3]..".0/24"
os.execute("sudo ipset add c_segment_blocklist "..c_segment_cidr)
end
return nil
end
Prerequisites
- Install
ipset:yum install ipset-service systemctl enable --now ipset - Create the
ipsetlist with auto-expiry:ipset create c_segment_blocklist hash:ip timeout 3600 ipset save > /etc/sysconfig/ipset - Add an iptables rule to block traffic from the list:
iptables -A INPUT -m set --match-set c_segment_blocklist src -p tcp --dport 80 -j DROP service iptables save - Grant the web server user (e.g.,
daemonorwww) passwordless sudo access toipset:chmod 640 /etc/sudoers vi /etc/sudoers # Add below root ALL=(ALL) ALL: daemon ALL=(ALL) NOPASSWD: /usr/sbin/ipset chmod 440 /etc/sudoers
Tradeoffs
Pros: Reduces ModSecurity resource usage by offloading blocking to kernel-level tools, eliminating ongoing variable iteration after a segment is blocked.
Cons: Requires installing ipset and granting elevated privileges to the web server user, which introduces potential security risks if the server is compromised.
Strategy 4: ModSecurity + Lua + Local API (Most Secure for High Concurrency)
Defensive Logic
Enhance Strategy 3 by replacing direct ipset calls with a local, authenticated HTTP API. This avoids granting sudo access to the web server user. The API runs with root privileges and only accepts requests from localhost with a valid auth token, then executes ipset commands on behalf of ModSecurity.
Implementation Rules
# Extract C-segment prefix from client IP
SecRule REMOTE_ADDR "^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "id:20000,nolog,pass,phase:1,capture,setvar:ip.c_segment_prefix=%{TX.0}"
# Increment C-segment count for non-static requests
SecRule REQUEST_BASENAME ".*?(\\.[a-z0-9]{1,10})?$" "phase:5,id:20002,t:none,t:lowercase,nolog,pass,capture,setvar:tx.req_extension=/%{TX.1}/,chain"
SecRule TX:REQ_EXTENSION "!@within %{tx.excluded_static_types}" "setvar:'global.c_segment_%{ip.c_segment_prefix}=+1',expirevar:global.c_segment_%{ip.c_segment_prefix}=%{tx.detection_interval}"
# Detect threshold breaches, delete count variable, and trigger API call via Lua
SecRule GLOBAL:/^c_segment_/ "@ge %{tx.request_threshold}" "phase:5,id:20003,pass,log,capture,msg:'Malicious traffic detected from C-segment %{TX.0}0/24',chain"
SecRule MATCHED_VAR_NAME "((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}" "capture,setvar:!global.c_segment_%{TX.0},exec:/tmp/c_segment_block_api.lua"
Lua Script (/tmp/c_segment_block_api.lua)
function main()
local client_ip = m.getvar("REMOTE_ADDR")
local ip_parts = {}
local separator = '%.'
string.gsub(client_ip, '[^'..separator..']+', function(part) table.insert(ip_parts, part) end)
if #ip_parts == 4 then
local c_segment_cidr = ip_parts[1].."."..ip_parts[2].."."..ip_parts[3]..".0/24"
local auth_token = "your_secure_auth_token_here"
os.execute("curl -s http://localhost:8080/block-c-segment?cidr="..c_segment_cidr.."&auth="..auth_token)
end
return nil
end
Prerequisites
- Set up the local API (example with PHP):
<?php // /var/www/block-api/forbidden.php $valid_token = "your_secure_auth_token_here"; $allowed_ip = "127.0.0.1"; if ($_SERVER['REMOTE_ADDR'] !== $allowed_ip || $_GET['auth'] !== $valid_token || !isset($_GET['cidr'])) { http_response_code(403); exit; } $cidr = escapeshellarg($_GET['cidr']); exec("sudo ipset add c_segment_blocklist $cidr"); http_response_code(200); ?> - Configure a local web server (e.g., Nginx) to serve the API on port 8080, only accessible from localhost.
- Follow the
ipsetsetup steps from Strategy 3, but grant sudo access only to the API server user (not the web server user).
Tradeoffs
Pros: Highest security posture—avoids granting elevated privileges to the main web server. The API can be extended with features like block logging, manual unblocking, and threshold adjustment.
Cons: Requires development and maintenance of a custom local API, which adds operational overhead.
Additional Notes
- Place all custom rules at the end of
REQUEST-901-INITIALIZATION.confto ensure they load after OWASP base rules. - Adjust rule IDs if they conflict with existing custom rules.
- Insure
SecCollectionTimeoutincrs-setup.confis set to a value greater than or equal totx.block_duration. - All strategies are incompatible with ModSecurity 3.x and newer, as they rely on the
expirevardirective, which is not supported in these versions.