<?xml version="1.0"?>
<rss version="2.0"><channel><title><![CDATA[Security & Monitoring Latest Topics]]></title><link>https://wtmsp.com/forum/12-security-monitoring/</link><description><![CDATA[Security & Monitoring Latest Topics]]></description><language>en</language><item><title>Catching expired certs and missing intermediates before your users do</title><link>https://wtmsp.com/topic/25-catching-expired-certs-and-missing-intermediates-before-your-users-do/</link><description><![CDATA[<p>Certificates expire on a schedule you know years ahead, and expired certificates are still one of the most common outages we get called about. My favorite example was a public VPN portal whose certificate had expired roughly six months before anyone told us. Users had just been clicking through the warning. Nothing was monitoring it, because it was an appliance, not a web server anyone thought of as a website. When we replaced the certificate, a second problem showed up. The appliance wasn't serving the intermediate certificate. Browsers papered over that, but stricter clients refused to connect. Here's how we check for both problems by hand and with a small script.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>Gather this first</strong></p></div><ul><li>A Linux or macOS shell with <code>openssl</code></li><li>A list of every TLS endpoint you're responsible for, including websites, VPN portals, mail servers, firewall and appliance admin pages, and RDP gateways</li><li>Somewhere to run a scheduled job and send its output, like email, a chat webhook or your monitoring system</li></ul></div><h2>See what the server is really sending</h2><p>Connect and show the certificates the server sends. Always pass <code>-servername</code> so SNI picks the right certificate on servers that host more than one name. Without it, a shared server may hand you its default certificate, and the check looks fine while the real site is broken.</p><pre class="ipsCode" data-language="bash">openssl s_client -connect vpn.contoso.com:443 -servername vpn.contoso.com -showcerts &lt;/dev/null</pre><p>The output lists each certificate the server sent, numbered from 0 (the leaf). Each one shows <code>s:</code> (subject) and <code>i:</code> (issuer). A healthy chain from a public CA usually has the leaf at 0 and one or more intermediates after it. If only certificate 0 appears and its issuer is an intermediate CA, the server isn't sending its chain.</p><h2>Reading the expiry date</h2><p>Pipe the leaf into <code>openssl x509</code> to read its dates:</p><pre class="ipsCode" data-language="bash">echo | openssl s_client -connect vpn.contoso.com:443 -servername vpn.contoso.com 2&gt;/dev/null \
  | openssl x509 -noout -subject -issuer -enddate</pre><pre class="ipsCode" data-language="plaintext">subject=CN = vpn.contoso.com
issuer=C = US, O = Example CA, CN = Example Intermediate R1
notAfter=Mar 10 23:59:59 2027 GMT</pre><p>In scripts, I lean on <code>-checkend SECONDS</code>. It exits non-zero if the certificate expires within that many seconds, so you don't have to parse dates or deal with the differences between GNU and BSD <code>date</code>.</p><h2>Checking the chain</h2><p>Look at the verification result at the end of the <code>s_client</code> output:</p><pre class="ipsCode" data-language="bash">openssl s_client -connect vpn.contoso.com:443 -servername vpn.contoso.com &lt;/dev/null 2&gt;/dev/null | grep 'Verify return code'</pre><p>You want <code>Verify return code: 0 (ok)</code>. The failures we see most:</p><ul><li><code>21 (unable to verify the first certificate)</code> or <code>20 (unable to get local issuer certificate)</code> usually means the server is missing an intermediate.</li><li><code>10 (certificate has expired)</code> means what it says.</li><li><code>18</code> or <code>19</code> means a self-signed certificate, or a private CA this machine doesn't trust. For internal hosts, pass <code>-CAfile</code> with your root.</li></ul><p>So why don't browsers complain about a missing intermediate? Many browsers cache intermediates or fetch them using the Authority Information Access URL in the leaf. Command-line tools, VPN clients, Java and Python applications, mail servers and older mobile devices often don't, so they fail while the browser looks fine. That's why I never test with a browser alone. The fix is to install the full chain on the server, meaning the leaf plus intermediates but not the root.</p><h2>The script we run every morning</h2><p>Put your endpoints in a text file, one <code>host port</code> per line:</p><pre class="ipsCode" data-language="plaintext">vpn.contoso.com 443
mail.contoso.com 443
portal.contoso.com 8443
fw01.contoso.com 443</pre><p>Then run this script from cron daily:</p><pre class="ipsCode" data-language="bash">#!/usr/bin/env bash
# cert-check.sh - warn on certificates expiring soon or with broken chains
# usage: cert-check.sh hosts.txt [warn_days]
LIST="${1:-hosts.txt}"
WARN_DAYS="${2:-30}"
SECS=$((WARN_DAYS * 86400))
status=0

while read -r host port; do
  [[ -z "$host" || "$host" == \#* ]] &amp;&amp; continue
  port="${port:-443}"
  out=$(timeout 15 openssl s_client -connect "$host:$port" -servername "$host" &lt;/dev/null 2&gt;/dev/null)
  cert=$(printf '%s\n' "$out" | openssl x509 2&gt;/dev/null)
  if [[ -z "$cert" ]]; then
    echo "ERROR  $host:$port  no certificate retrieved"; status=2; continue
  fi
  end=$(printf '%s\n' "$cert" | openssl x509 -noout -enddate | cut -d= -f2)
  verify=$(printf '%s\n' "$out" | grep -m1 'Verify return code' | sed 's/^ *//')
  if ! printf '%s\n' "$cert" | openssl x509 -noout -checkend 0 &gt;/dev/null; then
    echo "EXPIRED $host:$port  $end"; status=2
  elif ! printf '%s\n' "$cert" | openssl x509 -noout -checkend "$SECS" &gt;/dev/null; then
    echo "WARN   $host:$port  expires $end (under $WARN_DAYS days)"; status=1
  else
    echo "OK     $host:$port  expires $end"
  fi
  if [[ "$verify" != *"0 (ok)"* ]]; then
    echo "CHAIN  $host:$port  $verify"; [[ $status -lt 1 ]] &amp;&amp; status=1
  fi
done &lt; "$LIST"
exit $status</pre><p>Schedule it so it only emails when the exit code is non-zero. That way, a certificate email in your inbox always means something needs attention.</p><pre class="ipsCode" data-language="bash">0 7 * * * /opt/scripts/cert-check.sh /opt/scripts/hosts.txt 30 &gt; /tmp/cert-check.out || mail -s 'Certificate check' noc@contoso.com &lt; /tmp/cert-check.out</pre><p>Most monitoring systems can do this natively too, with HTTP checks on certificate age or a TLS sensor. If you already have one, use it. We still find the script handy for appliances on odd ports and as a second opinion.</p><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Make sure the check itself works</strong></p></div><ul><li>Run the script against a host you know is fine and confirm it prints <code>OK</code> with the right date.</li><li>Point it at a test site with a known-bad certificate (badssl.com has expired and incomplete-chain examples) and confirm you get <code>EXPIRED</code> and <code>CHAIN</code> lines and a non-zero exit code.</li><li>Confirm the cron email actually arrives.</li></ul></div><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Where certs slip past us</strong></p></div><ul><li>Renewal isn't installation. Automated renewal on one box doesn't update the copy someone manually loaded onto a load balancer or appliance.</li><li>Missing intermediates hide from browsers. Trust <code>Verify return code</code> over what the browser shows you.</li></ul></div><p>For us the hard part wasn't the script, it was building the list. VPN gateways, firewall admin pages, UPS cards and printers are the ones that expire unnoticed, so build the list from DNS and your firewall NAT rules rather than from memory.</p>]]></description><guid isPermaLink="false">25</guid><pubDate>Sun, 18 Oct 2026 20:35:45 +0000</pubDate></item><item><title>Running one Wazuh manager for all our clients without drowning in alerts</title><link>https://wtmsp.com/topic/24-running-one-wazuh-manager-for-all-our-clients-without-drowning-in-alerts/</link><description><![CDATA[<p>Most of our small clients will never pay for an expensive SIEM, but they still deserve someone watching their logs. Wazuh is how we cover that. It's free and open source, and it gives a small MSP a lot of what the big products offer, across Windows, Linux and macOS. We run a single Wazuh manager for every client. This post covers how we deploy agents from the RMM, keep clients separated, cut down the noise, and keep the manager itself from turning into a liability.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>What we started with</strong></p></div><ul><li>A Wazuh server (manager, indexer and dashboard). For a few hundred agents, the all-in-one install on a VM with 8 vCPU and 16 GB RAM or more is a good start.</li><li>Agents that can reach the manager on TCP 1514 (events) and 1515 (enrollment)</li><li>An RMM or script deployment tool for Windows and Linux endpoints</li></ul></div><h2>What you get out of it</h2><ul><li>Log collection and analysis. Agents ship Windows event logs, syslog and application logs to the manager, which decodes them and matches them against thousands of rules.</li><li>File integrity monitoring (FIM), which alerts when watched files, directories or registry keys change</li><li>Vulnerability detection. Agents inventory installed packages and the manager compares them against CVE feeds.</li><li>Security configuration assessment (SCA), which scores endpoints against CIS-style benchmark checks</li><li>Active response, which is optional automated action such as blocking an IP after repeated failures. I'd be careful with this one.</li></ul><h2>Lock down enrollment first</h2><p>Out of the box, anything that can reach port 1515 can enroll as an agent. Turn on password enrollment on the manager. In <code>/var/ossec/etc/ossec.conf</code>, inside the <code>&lt;auth&gt;</code> section, set <code>&lt;use_password&gt;yes&lt;/use_password&gt;</code>, then write the password to the auth file:</p><pre class="ipsCode" data-language="bash">echo 'REPLACE-WITH-LONG-RANDOM-STRING' &gt; /var/ossec/etc/authd.pass
chmod 640 /var/ossec/etc/authd.pass
chown root:wazuh /var/ossec/etc/authd.pass
systemctl restart wazuh-manager</pre><h2>One group per client</h2><p>Groups let you push different configuration to different agents, and they let whoever is looking at the dashboard filter down to one client. Create them on the manager before you deploy anything:</p><pre class="ipsCode" data-language="bash">/var/ossec/bin/agent_groups -a -g client-contoso -q
/var/ossec/bin/agent_groups -a -g client-fabrikam -q
/var/ossec/bin/agent_groups -l</pre><p>Each group gets a shared config file at <code>/var/ossec/etc/shared/GROUP/agent.conf</code>, and agents in that group pull it automatically. That's where client-specific FIM paths or extra log sources go.</p><h2>Pushing agents from the RMM</h2><p>The agent packages accept deployment variables, so one command installs the agent, enrolls it and assigns the group. Match the agent version to your manager version or older, because an agent newer than the manager won't connect. On Windows:</p><pre class="ipsCode" data-language="powershell">$msi = "$env:TEMP\wazuh-agent.msi"
Invoke-WebRequest -Uri 'https://packages.wazuh.com/4.x/windows/wazuh-agent-4.x.x-1.msi' -OutFile $msi
Start-Process msiexec.exe -Wait -ArgumentList "/i `"$msi`" /q WAZUH_MANAGER=`"wazuh.contoso.com`" WAZUH_REGISTRATION_PASSWORD=`"REPLACE-ME`" WAZUH_AGENT_GROUP=`"client-contoso`""
Start-Service -Name WazuhSvc</pre><p>Replace <code>4.x.x</code> with the exact version you run. On Debian or Ubuntu, with the Wazuh repository already added:</p><pre class="ipsCode" data-language="bash">WAZUH_MANAGER='wazuh.contoso.com' WAZUH_REGISTRATION_PASSWORD='REPLACE-ME' \
  WAZUH_AGENT_GROUP='client-contoso' apt-get install -y wazuh-agent
systemctl daemon-reload
systemctl enable --now wazuh-agent</pre><p>Keep the registration password in an RMM secret or script variable instead of hard-coding it in a script every technician can see.</p><h2>The first week is loud</h2><p>Expect a flood at first. Don't disable whole rule groups. Write targeted overrides in <code>/var/ossec/etc/rules/local_rules.xml</code> instead, where a child rule with level 0 suppresses a specific match. For example, this silences successful logons from a known service account:</p><pre class="ipsCode" data-language="plaintext">&lt;group name="local,windows,"&gt;
  &lt;rule id="100100" level="0"&gt;
    &lt;if_sid&gt;60106&lt;/if_sid&gt;
    &lt;field name="win.eventdata.targetUserName"&gt;^svc-backup$&lt;/field&gt;
    &lt;description&gt;Ignore routine logons by the backup service account&lt;/description&gt;
  &lt;/rule&gt;
&lt;/group&gt;</pre><p>Use custom rule IDs from 100000 upward. Test by pasting a sample event into <code>/var/ossec/bin/wazuh-logtest</code>, then restart the manager.</p><h2>Treat the manager like a domain controller</h2><p>Your SIEM holds logs from every client and can push configuration to every agent. If someone gets into it, they get into everyone.</p><ul><li>Don't expose the dashboard to the internet without SSO and MFA in front of it, or keep it reachable only over VPN.</li><li>Change the default admin passwords for the dashboard, indexer and API right after install.</li><li>If agents connect over the internet, expose only 1514 and 1515, and consider restricting them to client office IPs.</li><li>Keep the API port (55000) internal.</li><li>Back up <code>/var/ossec/etc</code> (keys, rules, groups), and plan indexer retention so the disk doesn't fill.</li></ul><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Checking agents landed in the right place</strong></p></div><pre class="ipsCode" data-language="bash">/var/ossec/bin/agent_control -l
/var/ossec/bin/agent_groups -l -g client-contoso</pre><p>Agents should show as Active and appear in the right group. In the dashboard, filter by group and confirm events, SCA results and vulnerability data are coming in. Then create a test file in a monitored directory and watch for the FIM alert.</p></div><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Things that caught us out</strong></p></div><ul><li>An agent newer than the manager won't connect. Upgrade the manager first, every time.</li><li>Cloned VMs can carry duplicate agent keys. A template that already has an enrolled agent causes name and ID collisions, so remove the agent's <code>client.keys</code> before sealing a template.</li><li>The indexer disk fills up. Set index lifecycle policies early.</li><li>Vulnerability alerts pile up fast. We report vulnerabilities per client instead of alerting on each CVE.</li></ul></div><p>Each week I look at the top rules by count in the dashboard and tune whichever one is worst. It only takes a few minutes, and it's what keeps our techs reading the alerts instead of muting them.</p>]]></description><guid isPermaLink="false">24</guid><pubDate>Sun, 18 Oct 2026 20:34:44 +0000</pubDate></item><item><title>The LibreNMS setup we use at client sites, plus the transport bugs that quietly ate our alerts</title><link>https://wtmsp.com/topic/23-the-librenms-setup-we-use-at-client-sites-plus-the-transport-bugs-that-quietly-ate-our-alerts/</link><description><![CDATA[<p>LibreNMS is what we use to watch switches, routers, firewalls, UPSes and access points across our client sites. When a closet switch drops, we want the alert to reach whoever is on call before the office staff come in and find it. LibreNMS auto-discovers a lot over SNMP, graphs ports and health sensors out of the box, and can alert to chat, ticketing and paging systems. This is how we set up SNMPv3 on a device, add it, write alert rules and wire up transports. I've also included a few transport bugs that cost us alerts before we caught them.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>What to have ready</strong></p></div><ul><li>A working LibreNMS install. We run it in a container, but a native install is the same from here on.</li><li>Network reachability from the LibreNMS poller to the device on UDP 161</li><li>Admin access to the device</li><li>Webhook or API details for wherever alerts should go, such as Discord, your ticket system or PagerDuty</li></ul></div><h2>SNMPv3 on the device</h2><p>Use SNMPv3 with authentication and privacy (<code>authPriv</code>). SNMPv2c community strings travel in clear text. Here's a generic Cisco IOS example with a read-only view and an ACL limiting who can poll:</p><pre class="ipsCode" data-language="plaintext">ip access-list standard SNMP-POLLERS
 permit 10.10.20.5
!
snmp-server view LIBRENMS-VIEW iso included
snmp-server group LIBRENMS-GRP v3 priv read LIBRENMS-VIEW access SNMP-POLLERS
snmp-server user librenms LIBRENMS-GRP v3 auth sha AUTH-PASSPHRASE priv aes 128 PRIV-PASSPHRASE
snmp-server location Main Office MDF
snmp-server contact noc@contoso.com</pre><p>On IOS, SNMPv3 users don't show up in the running config, so put the credentials in your password manager right away. You can check the user with <code>show snmp user</code>. It's also worth setting a meaningful location string, because LibreNMS uses it for maps and grouping.</p><h2>Prove SNMP works before LibreNMS gets involved</h2><p>I always test with <code>snmpwalk</code> from the LibreNMS host before adding a device. It tells you whether you have an SNMP problem or a LibreNMS problem:</p><pre class="ipsCode" data-language="bash">snmpwalk -v3 -l authPriv -u librenms -a SHA -A 'AUTH-PASSPHRASE' -x AES -X 'PRIV-PASSPHRASE' 10.10.20.1 sysDescr</pre><p>If that returns the device description, add it from the web UI (Devices, Add Device) or the CLI:</p><pre class="ipsCode" data-language="bash">cd /opt/librenms
sudo -u librenms ./lnms device:add --v3 -l authPriv -u librenms \
  -a SHA -A 'AUTH-PASSPHRASE' -x AES -X 'PRIV-PASSPHRASE' 10.10.20.1</pre><p>Discovery runs, and the device fills in ports, sensors and hardware details over the next few polling cycles.</p><h2>A short list of alert rules</h2><p>LibreNMS ships a set of default rules you can import from Alerts, Alert Rules. Start small. An alert stream nobody reads is worse than none. These are the rules we always deploy:</p><ul><li>Device down, meaning device status is 0 with ICMP and SNMP both failing</li><li>Port down on uplinks, scoped to ports with a description you tag rather than every access port</li><li>Sensor over limit, for temperature, PSU and UPS battery state</li><li>Device rebooted, meaning uptime below a few minutes, which catches power events</li></ul><p>Use device groups, for example one per client or site, to scope rules and route them to the right transport. Set a sensible delay so a single missed poll doesn't page anyone.</p><h2>Where the alerts go</h2><p>Transports are what deliver alerts. Under Alerts, Alert Transports, add one per destination:</p><ul><li>For Discord, paste the channel webhook URL. It's good for team visibility.</li><li>Most ticket systems can create tickets by email, or through an API transport that POSTs JSON to an endpoint.</li><li>For PagerDuty, use the integration key from a PagerDuty service. We only page for real outages, so nobody gets woken up for a warning.</li></ul><p>Map rules to transports directly on the rule or through transport groups. Press the <strong>Test</strong> button on each transport before you rely on it.</p><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Proving it end to end</strong></p></div><p>Run the validator and fix anything it flags:</p><pre class="ipsCode" data-language="bash">cd /opt/librenms
sudo -u librenms ./validate.php</pre><ul><li>The device shows recent poll times and the graphs are filling in.</li><li>Each transport's Test button delivers a message.</li><li>Unplug a lab device or shut a test port, and confirm the alert fires, appears in the alert log, and clears when you put it back.</li></ul></div><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>The bugs that cost us alerts</strong></p></div><ul><li>On the version we were running, an API transport saved without its options field populated (even as empty) threw a PHP error when an alert fired. It didn't just skip that transport, it stopped the whole alert run. If alerts stop arriving everywhere at once, go looking for one broken transport.</li><li>A PagerDuty transport created without the region field set caused the same kind of error and killed the run. Open each PagerDuty transport, set the region explicitly, and save it.</li><li>If you write your own webhook receiver, look at the actual JSON. Fields like alert state arrived as strings in our payloads, and a receiver comparing them to integers silently dropped every alert.</li><li>There's no automatic retry. If a transport endpoint is down when the alert fires, that notification may simply be lost, so monitor your receivers too.</li><li>The alert log in the UI shows what fired. When something fired but never arrived, the answer is usually in <code>/opt/librenms/logs/librenms.log</code>.</li><li>If you add a second poller, add it to the device ACL too. Otherwise it fails with timeouts that look a lot like wrong credentials.</li></ul></div><p>After any change to transports, I press Test on every one of them. One broken transport can silence all the others, and you won't find out until a real outage goes unreported.</p>]]></description><guid isPermaLink="false">23</guid><pubDate>Sun, 18 Oct 2026 20:33:44 +0000</pubDate></item><item><title>A client VPN portal taking 50,000 password-spray attempts a day, and how we shut it down</title><link>https://wtmsp.com/topic/22-a-client-vpn-portal-taking-50000-password-spray-attempts-a-day-and-how-we-shut-it-down/</link><description><![CDATA[<p>This started as a routine look at a client's remote access portal logs. We found around 50,000 password-spray sessions a day coming from just five networks, with zero successful logins. Nobody had noticed, because spraying is quiet by design. Instead of hammering one account with thousands of passwords, the attacker tries a few common passwords against thousands of usernames, which stays under per-account lockout thresholds. Anything with a public login page gets this as constant background noise. Here's how we spotted it, measured it and shut it off at the edge.</p><div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>Access you'll want</strong></p></div><ul><li>The portal or VPN authentication logs, whether that's local logs, a syslog server or a SIEM</li><li>A Linux shell with the usual tools (<code>grep</code>, <code>awk</code>, <code>sort</code>, <code>uniq</code>)</li><li>Admin access to the edge firewall or load balancer in front of the portal</li></ul></div><h2>What it looks like in the logs</h2><p>Once you've seen it, the pattern is easy to recognize:</p><ul><li>Thousands to tens of thousands of authentication sessions per day, far more than your user count could explain</li><li>Lots of usernames, often ones that don't exist in your directory, like <code>admin</code>, <code>test</code>, <code>scanner</code>, or first.last guesses from a scraped staff list</li><li>A handful of source /24 networks, often hosting providers, rotating through addresses within each block</li><li>No successes at all. If any attempts succeed, stop reading and start incident response.</li></ul><h2>Counting failures by network</h2><p>Every product logs differently, so you'll need to adapt the field extraction. Here's a generic log line:</p><pre class="ipsCode" data-language="plaintext">2026-10-01T03:12:44Z portal auth=fail user=jsmith src=203.0.113.45 reason=bad_password</pre><p>Count failed attempts per source /24:</p><pre class="ipsCode" data-language="bash">grep 'auth=fail' portal.log \
  | grep -oE 'src=[0-9.]+' | cut -d= -f2 \
  | awk -F. '{print $1"."$2"."$3".0/24"}' \
  | sort | uniq -c | sort -rn | head -20</pre><p>Then check how many distinct usernames each network tried. A real user behind a NAT fails with one or two names. A sprayer tries hundreds.</p><pre class="ipsCode" data-language="bash">grep 'auth=fail' portal.log \
  | awk '{for(i=1;i&lt;=NF;i++){if($i~/^user=/)u=substr($i,6); if($i~/^src=/)s=substr($i,5)}
         split(s,o,"."); print o[1]"."o[2]"."o[3]".0/24", u}' \
  | sort -u | awk '{print $1}' | uniq -c | sort -rn | head -20</pre><p>And confirm there were no successes from those networks:</p><pre class="ipsCode" data-language="bash">grep 'auth=success' portal.log | grep -E 'src=(203\.0\.113|198\.51\.100)\.' | wc -l</pre><p>For this client, five networks accounted for essentially all of the failures, each had tried hundreds of usernames, and the success count was zero.</p><h2>Block it before it reaches login and MFA</h2><p>Where you block matters. If the portal forwards each attempt to your MFA provider or directory, the spray costs you more than log space. It loads your domain controllers, can lock out real staff whose usernames happen to match, and can spam people with push prompts if a password ever does match. Drop the traffic before it gets to the login logic.</p><p>Most platforms have a named list you can block against. It might be an <em>address list</em> or address group on a firewall, a <em>data group</em> referenced by a load balancer rule, or an external dynamic list. Keep the list in one place so adding a network is a one-line change. On a Linux edge, ipset does the same job:</p><pre class="ipsCode" data-language="bash">ipset create spray-block hash:net
ipset add spray-block 203.0.113.0/24
ipset add spray-block 198.51.100.0/24
iptables -I INPUT -p tcp --dport 443 -m set --match-set spray-block src -j DROP</pre><p>Make the rule persistent using your distribution's mechanism, and put a comment or ticket reference on each entry. Whoever looks at that list next will want to know why it's there.</p><h2>Geo-blocking, if your users are all in one country</h2><p>Blocking five networks handles today's sprayer, and the next one will come from somewhere else. If every legitimate user is in one country, allowing only that country to reach the portal removes most of the noise with one rule. Most commercial firewalls have built-in geo objects. Keep an exception process for staff who travel. Geo-IP data is approximate and VPN providers can appear to be anywhere, so I treat this as noise reduction, not a security control on its own.</p><h2>MFA and lockout still do the real work</h2><p>Edge blocking cuts load and noise, but it doesn't protect accounts. Attackers rotate sources, and spraying is slow on purpose. What actually protects your users:</p><ul><li>MFA on every remote access path, preferably phishing-resistant or number-matching, so a guessed password alone isn't enough</li><li>A lockout or smart-lockout policy that slows guessing without making it trivial to lock out the whole staff</li><li>Banned-password lists so seasonal passwords like <code>Fall2026!</code> get rejected</li><li>Alerts on any successful login from an unusual country or network, and on MFA prompts users report they didn't trigger</li></ul><div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Next-day checks</strong></p></div><ul><li>Re-run the per-network count on the next day's log. The blocked networks should be gone.</li><li>The firewall rule's hit counter should be climbing.</li><li>Real users can still log in, including from home ISPs and mobile carriers.</li><li>The MFA provider logs no longer show a stream of authentication requests for unknown users.</li></ul></div><div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Be careful with these</strong></p></div><ul><li>Before blocking a network, check that it isn't a mobile carrier or a large ISP your users share. Hosting provider ranges are usually safe to block. Residential ranges are not.</li><li>If a load balancer sits in front of the portal, make sure the real client IP is being logged. Otherwise every attempt looks like it came from one internal address.</li><li>A block rule placed below a broad allow rule never matches. Check the hit counts after you add it.</li><li>The list is easy to forget about. Review the blocked networks every few months and remove stale entries.</li></ul></div><p>We re-run the per-network count on that portal every so often. New sprayers keep turning up from new places, and adding another /24 to the list takes about a minute.</p>]]></description><guid isPermaLink="false">22</guid><pubDate>Sun, 18 Oct 2026 20:32:44 +0000</pubDate></item></channel></rss>
