Network protection with Fortress & Hawk

Network protection with
Fortress & Hawk

Two small Perl daemons that keep production servers standing under real-world attack traffic.

Marian (HackMan) Marinov

mm@yuhu.biz

Agenda

  1. The problem — what a sysadmin does at 3am
  2. Fortress — a TCP connection watchdog
    • Attacks it catches
    • Connection states it looks at
    • Blocking backends
  3. Hawk — a bruteforce log analyzer
    • Design: tail -F instead of Perl I/O (and why)
    • Supported services
    • Blocking + web interface
  4. When to use which

The 3am problem

Load spikes. Site is unresponsive. What does a sysadmin do?

ss -ntp | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
  • Slow on a busy box
  • Manual: type, read, decide, iptables -I ..., repeat
  • By the time you're done, the attacker moved on — or you're still typing

Both tools automate exactly this reflex. Different data sources, same goal: identify a bad IP and block it in less than a second.

Part 1 — Fortress

Automatic TCP connection monitoring and blocking

Fortress: what it is

  • A single-file Perl daemon (~250 lines)
  • Reads /proc/net/tcp every second
  • Reads /proc/loadavg every 5 seconds
  • Decides — based on state, count, and current load — whether an IP is abusing the server
  • Calls an external shell script to do the actual blocking

Fortress: attacks it detects

SYN flood
Attacker opens many half-open connections; the server's SYN backlog fills and legitimate clients cannot complete a handshake.
→ Detected as too many connections in SYN_RECV from one IP.

Distributed SYN flood (small scale)
Same, spread across a handful of source IPs. Fortress checks SYN_RECV counts regardless of load, so it catches this early.

Connection exhaustion / slow-loris style abuse
An IP opens dozens of ESTABLISHED sockets and sits on them, starving Apache/Nginx worker slots.
→ Detected as too many ESTABLISHED conns to a monitored port.

Application-layer flood
Legitimate handshake, hammering HTTP/HTTPS/SMTP. Same signature; thresholds tighten as load rises.

Bot / scraper storms
Same signature again; excludes files whitelist Google, Bing, Yahoo, Cloudflare, Yandex, Baidu so real crawlers don't get blocked.

Fortress: what it does not try to do

  • No packet capture (no libpcap, no BPF)
  • No deep packet inspection
  • No UDP yet — on the TODO list
  • No log parsing — that's Hawk's job

It solves one problem:
too many concurrent TCP sockets from one source.

That's it. That's the whole product.

Fortress: the connections it looks at

Linux exposes every TCP socket in /proc/net/tcp:

 sl  local_address  rem_address    st  tx_queue rx_queue tr ...
  0: 00000000:0016  00000000:0000  0A  00000000:00000000 ...

Column st is the TCP state as a hex byte. Fortress only cares about two of them:

HexStateWhy it matters
01ESTABLISHEDHandshake done → app is serving this conn
03SYN_RECVHalf-open → SYN backlog pressure

TIME_WAIT, CLOSE_WAIT, FIN_WAIT_*, LISTEN — skipped. Those don't consume the resources we're protecting.

Linux kernel network documentation

Fortress: SYN_RECV vs ESTABLISHED

SYN_RECV (0x03)

  • Kernel replied SYN+ACK, waiting for final ACK
  • Costs kernel memory (SYN queue entry)
  • Does not touch the application's backlog queue — pressure is on net.ipv4.tcp_max_syn_backlog
  • Spoofed SYN floods live entirely here
  • Always checked — regardless of load

ESTABLISHED (0x01)

  • Handshake complete; app holds the socket
  • Costs app resources (worker slot, fd, memory)
  • Fills the accept queue — capped by net.core.somaxconn.
       listen(fd, backlog) is effectively
       min(backlog, net.core.somaxconn)
  • Only counted for monitored ports
       Defaults are:   25 53 80 110 143 443 993 995
  • Threshold is dynamic — depends on load

Fortress: adaptive thresholds

high_load = 5
low_conns  = 50      # ESTABLISHED cap when load < 5
high_conns = 20      # ESTABLISHED cap when load >= 5

low_syn_recv_conns  = 50
high_syn_recv_conns = 25

Low load → be permissive. A legitimate power user or a small office NAT can easily open 30-40 concurrent sockets.

High load → be strict. The box is already hurting; anything abusive gets cut off fast.

Same IP, same behavior, different verdict depending on server pressure.

Fortress: what is a Patricia Trie?

PATRICIA = Practical Algorithm To Retrieve Information Coded In Alphanumeric — Morrison, 1968.
Same data structure BSD has used for the kernel routing table since forever.

  • Binary radix tree keyed on the bits of the IP address
  • Each node branches on one bit — 0 left, 1 right
  • Path compression: long runs of identical bits between branch points are stored as an edge label, not as a chain of dummy nodes
  • Walk down until the next bit doesn't match; return the deepest prefix seen → longest-prefix match, for free
  • Lookup: O(W), W = address width (32 for IPv4); in practice far fewer hops thanks to compression
  • Storage: O(N) nodes for N prefixes — not O(32·N)
  • Adding Google's ~3900 CIDRs costs the same per lookup as adding 39
Prefixes stored:
  10.0.0.0/8
  10.1.0.0/16
  192.168.0.0/16

           (root)
          /      \
       0 /        \ 1
        /          \
    10.0.0.0/8   192.168.0.0/16
        |
     [bits 8..15]      <-- compressed edge
        |
    10.1.0.0/16

Lookup 10.1.2.3:
  root -> 0 branch (bit 0 = 0)
       -> match 10.0.0.0/8          (remember)
       -> follow compressed edge
       -> match 10.1.0.0/16         (remember, deeper)
       -> return 10.1.0.0/16

Fortress: whitelisting with the Patricia Trie

Fortress must never block Googlebot, Cloudflare, or the office IP.

  • Excludes come from plain text files, one IP or CIDR per line
  • Loaded into a Net::Patricia trie at startup
  • ~5000 prefixes today (Google alone: ~3900)
  • Lookup is O(prefix length) — effectively constant per check
my $excludes = Net::Patricia->new;
$excludes->add_string('66.249.64.0/19', 'google');
...
next if ($excludes->match_string($ip));

Local IPs auto-detected from ip -4 a l at startup — you can never accidentally block yourself.

Fortress: in one picture

/proc/net/tcp read every 1 s /proc/loadavg read every 5 s fortress.pl count per-IP conns SYN_RECV + ESTABLISHED load-adaptive thresholds over the limit? Net::Patricia whitelist lookup (excluded IP?) lookup fortress-block.sh pluggable shell script iptables direct DROP rule ipset recommended O(1) hash set, auto-expire DNAT (redirect) to a block-page server Every 1s: parse /proc/net/tcp → count per IP → whitelist check → block if abusive.

Fortress: what's next (TODO)

  • Signal handler for stop — persist the current blocked-IP list before exiting
  • Configurable per-range limits (/24, /23, /22) to catch more distributed attacks that fly under the per-IP threshold
  • Parse and check /proc/net/udp — UDP support
  • Debian and Ubuntu packages (currently only RPM/CentOS ship)

Patches welcome — github.com/hackman/Fortress

Part 2 — Hawk

Log-driven bruteforce detection and IP blocking

Hawk: what it is

  • A Perl daemon that reads authentication logs
  • Recognises failed-login patterns for many services
  • Counts failures per IP over a time window
  • Blocks offenders via iptables / ipset / custom script
  • Has a web UI (hawk-web.pl) for attack history
  • SQLite / MySQL / PostgreSQL backend

Supported out of the box:
sshd, dovecot (POP3/IMAP), courier, pure-ftpd, proftpd, postfix, exim, cPanel, DirectAdmin, WHM

Hawk: the design choice we're here to talk about

Hawk does not read log files from Perl.

It opens a pipe to GNU tail:

my $log_list = "/usr/bin/sudo /usr/bin/tail -s 1.00 -F "
             . "--max-unchanged-stats=30 $monitor_list |";

$tail_pid = open LOGS, $log_list or die "open $log_list failed: $!\n";

while (<LOGS>) {
    # parse the line
}

That's the entire log ingestion layer. One open, one while. Rotation, truncation, multi-file follow — all handled by tail -F.

Hawk: why tail -F and not AnyEvent?

Hawk was written when AnyEvent was brand new.

At the time, benchmarking on real production mail servers showed:

  • AnyEvent::Handle watching 6 log files → measurable CPU overhead per file, dominated by event loop bookkeeping
  • tail -F file1 file2 ... | perl → the kernel does the epoll, tail does the buffering, Perl just reads a pipe

The C-implemented tail was cheaper than the Perl event loop it was supposed to replace. On a shared hosting box running dozens of services, that difference mattered.

Simplicity as a feature — one fewer moving part, one fewer CPAN dep, behaviour identical to what any sysadmin already understands.

Hawk: the trade-off, honestly

Pros

  • Rock solid — tail has been debugged for 30+ years
  • Handles log rotation (-F reopens on rename/truncate)
  • Zero framework code in Hawk itself
  • Easy to reason about: strace the pipe, done

Pros for a Perl audience

  • One open '-|', one while (<LOGS>)
  • No callbacks, futures, reactor
  • The whole ingestion path fits on a slide

Cons

  • One extra process per Hawk instance
  • Won't scale to thousands of log files (not the target)

Would we rewrite it today with AnyEvent or Mojo::IOLoop? Probably not. It still doesn't win.

Hawk: parsing — one regex per service

Each service has a tiny parser returning (ip, attempts, service_id, user):

sub postfix_broot {
    # Dec 30 09:04:16 host postfix/smtpd[14147]: warning:
    #   unknown[46.148.40.150]: SASL LOGIN authentication failed: ...
    if ($_ =~ /\[([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\]: /) {
        return ($1, 1, 7, 'unknown');
    }
}

The main loop tries each enabled service in turn. First parser that matches wins. Adding a new service = one sub + one if in the dispatcher.

Hawk: counting and blocking

broot_time     = 180    # sliding window (seconds)
broot_number   = 10     # failures in window -> "bruteforce"
max_attempts   = 2      # bruteforce records -> actual block
block_expire   = 86400  # unblock after 24h

Two counters:

  1. Per-IP failure count inside broot_time — resets periodically.
  2. Bruteforce record count — how many times the IP has crossed the failure threshold. When it hits max_attempts, the IP is added to iptables/ipset.

Everything is also written to SQLite/MySQL/PgSQL for the web UI to query later.

Hawk: the web interface

hawk-web.pl — a small CGI/FastCGI script that reads the same DB and shows:

  • Recent failed logins per service
  • Top attacking IPs
  • Currently blocked list
  • Per-user attack breakdown (which accounts are being probed)

Not fancy. Useful. Sysadmins tend to like it more than command-line iptables -L.

Hawk: in one picture

/var/log/secure /var/log/maillog /var/log/exim_mainlog /var/log/messages ... configurable tail -F follows N files | pipe hawk.pl while (<LOGS>) {...} regex per service per-IP counters threshold hit? iptables / ipset drops packets from bad IPs SQL DB SQLite / MySQL / PgSQL failed_log, broots, blacklist reads hawk-web.pl Web UI tail -F handles rotation and truncation for free — hawk.pl just reads a pipe.

Fortress or Hawk?

FortressHawk
Data source/proc/net/tcpLog files (via tail -F)
Threat modelConnection floods, SYN attacksCredential bruteforce
Time to detect≤ 1 secondSeconds to minutes
Sees attacker before login?YesNo — only after failed auth
Sees which account is targeted?NoYes
StorageIn-memory + periodic StorableSQL database

Run both. They cover different layers and don't conflict — Fortress kills the flood, Hawk kills the patient guesser.

Takeaways

  • Perl is great for this kind of glue: fast enough, expressive regexes, tiny memory footprint, easy to modify at 3am.
  • Trust the kernel: /proc/net/tcp and tail -F are cheaper than any library you can layer on top.
  • Adaptive thresholds (load-aware) beat static ones.
  • Whitelisting is not optional — a Patricia trie makes it free.
  • The simple design outlives the fancy one.

Links

Fortress:
https://github.com/hackman/Fortress

Hawk:
https://github.com/hackman/Hawk-IDS-IPS

Both GPLv2. Patches welcome.

Questions?

Marian Marinov

Note: You are viewing an auto-generated directory index.