Summary: This article covers the technical architecture, implementation, and field testing of UBAN (Github)—a lightweight, open-source Linux security agent built in C to outperform Fail2ban. If you haven't already, it is recommended that you read the short layperson-friendly feature list first before diving into the long and deep technical details below.

The Problem with Fail2ban

This site has used Fail2ban (f2b) since day one. It is a great, battle-tested tool, but it comes with a major flaw: reaction time.

f2b typically takes 0.4 to 1.0 seconds to react to a log event. In a modern log flood, half a second is an eternity. An attacking bot can hit your server with hundreds, if not thousands, of HTTP POST requests before the firewall rule actually drops the connection.

The delay comes from Python overhead, log polling lag, and spawning heavy sub-processes (iptables or nftables CLI calls) for every single match. I tried optimizing everything: regex tuning, backend tweaks, systemd journal hooks, and lower poll intervals. Nothing made a meaningful difference.

The architecture itself was the bottleneck. So I decided to build my own solution: UBAN.


Overall Goals of UBAN

When designing UBAN, I set out to solve these fundamental bottlenecks with a few non-negotiable principles:

🌐 App Agnostic

UBAN stands for Universal BAN. It does not know, or care, what application is generating logs. All it cares about are log files that keep minimal records.

⚡ Raw Speed

Built natively in C to operate directly with nftables kernel interfaces, eliminating interpreter delay and subprocess overhead completely.

📦 Zero External Dependencies

Built pure C with no third-party libraries outside standard system interfaces. No Python runtime, no heavy frameworks, no bloated dependency trees.

🛠️ Simple Setup

Easy to use and configure—one single .conf file solves everything.


Core Design Principles

Everything in UBAN centers around being truly app agnostic. To enforce a ban, security tools typically parse multiple complex fields from every log line. UBAN strips this complexity down to the bare essentials:

  • Time: To ban an IP, UBAN needs to know when an event happened. Instead of parsing 500 different log time formats, UBAN simply uses system time when reading a log entry. In practice, this works perfectly because log timestamps come directly from host system time anyway.
  • IP Address: Standard IPv4 patterns are easy to recognize and parse, no heavy lifting required.
  • Keywords: The real core of log evaluation. UBAN (or f2b, or any similar tool) relies on keyword matching to flag malicious hits. I realized that server logs actually have a very limited vocabulary, which meant I could design keyword matching that was both accurate and extremely efficient.

The Art of Keyword Matching

Parsing log lines efficiently is where performance is won or lost. First, UBAN uses a token-based log parsing algorithm:

Tokens are space-delimited. However, any content enclosed within matching delimiters (" ", ( ), [ ], { }, < >) is treated as a SINGLE token, regardless of how many internal spaces it contains. Any unmatched enclosures, such as a stray single quote ", are simply treated as regular characters.

Once the line is tokenized and the IP is located, evaluating keywords requires dividing them into two distinct categories:

  1. Long Keywords: Phrases like "failed password" or "Unauthorized access". Accidental matches are rare because server logs have a relatively limited vocabulary, and administrators can choose distinctive keywords that indicate suspicious activity. If an attacker deliberately includes one of these keywords in a request, they've effectively self-reported to UBAN.
  2. Short Keywords: Values like HTTP status code "404" or "401". You cannot just substring-search a short number across an entire line without triggering frequent false positives (like matching a timestamp, process ID, or byte count). To solve this, short keywords must be anchored to a specific token position set in the .conf file.

Problem solved? Not even close. I soon realized a much bigger problem with token injection...

Token Injection: The Real Threat

Consider the standard default Nginx log format:

'$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"'

A clean, standard log line generated by this format looks like this:

192.168.1.50 - - [10/Oct/2026:14:32:10 +0000] "POST /wp-admin/admin.php HTTP/1.1" 401 512 "-" "Mozilla/5.0"

In an ideal world, the UBAN parser tokenizes this clean line and determines that the HTTP status code (401) sits cleanly at Position 6:

  • Position 1: 192.168.1.50
  • Position 2: -
  • Position 3: -
  • Position 4: [10/Oct/2026:14:32:10 +0000]
  • Position 5: "POST /wp-admin/admin.php HTTP/1.1"
  • Position 6: 401

An administrator could simply set the match position to 6 in .conf, and UBAN would evaluate Position 6 for the bad status code.

But I am dealing with spammers and attackers here. Nothing is ever ideal. An attacker can craft a URI containing an unescaped double quote to intentionally throw off positional tokenization:

POST /wp-admin/admin.php?IAmAHacker"TryToBanMe HTTP/1.1

When Nginx writes this out to access.log, the raw log line becomes:

192.168.1.50 - - [10/Oct/2026:14:32:10 +0000] "POST /wp-admin/admin.php?IAmAHacker"TryToBanMe HTTP/1.1" 401 512 "-" "Mozilla/5.0"

Because of that injected double quote inside the query string, a naive parser encounters a match too early. It treats "POST /wp-admin/admin.php?IAmAHacker" as the single string token for Position 5, leaving TryToBanMe HTTP/1.1" floating right after it.

Now, look at how the tokens shift:

  • Position 5: "POST /wp-admin/admin.php?IAmAHacker"
  • Position 6: TryToBanMe
  • Position 7: HTTP/1.1"
  • Position 8: 401

The attackers still get all the info they want by doing this. The HTTP request payload reaches /wp-admin/admin.php and executes fully, yet they never get banned because the status code is no longer at Position 6 due to parser limitations.

If you think this issue is caused by my rather basic parsing function, you're dead wrong. All three default Nginx filters shipped with f2b v1.0.2 on this server suffer from the exact same flaw. The latest v1.1.1 ships with six Nginx filters, but the same three are still there, unchanged.

Take the standard nginx-bad-request.conf filter or the 404 path filter:

failregex = ^<HOST> - \S+ [] "[^"]*" 400
failregex = ^<HOST> - \S+ [] "(GET|POST|HEAD) /<block> \S+" 404 .+$

Even error log rules like rate-limiters or missing-file filters are susceptible:

failregex = ^%(__prefix_line)slimiting requests, excess: [\d\.]+ by zone "(?:%(ngx_limit_req_zones)s)", client: <HOST>, server: \S*, request: "\S+ \S+ HTTP/\d+\.\d+", host: "\S+"(, referrer: "\S+")?\s*$

Notice how these patterns rely on clean "[^"]*" quotes or strict string patterns around user-controlled input fields. The moment an attacker injects an extra quote into the request string, the regex fails to match entirely. Attackers can use this exact same trick to bypass trusted f2b filters like these.

Yes, I imagined (designed) this vulnerability, but it went much further than just imagination. I tested it myself. You can try it on your own setup and watch fail2ban fail. Don't be sad. I have a solution for you, even if you want to use f2b continuously. See below.


How to Defeat Token Injection

At first, Gemini suggested writing a much more complex, intelligent log parser. But going down that road would break my core principle of keeping UBAN strictly app-agnostic. Plus, no matter how sophisticated a parser gets, attackers will eventually analyze the source code and find a clever edge case to bypass it. In an open-source project, relying on complex parsing logic to fight token injection is a dead end. Do not even bother going down that route.

After realizing this, I was quite sad. The tool I thought would change the world just a few minutes ago was simply not going to work. So... I went out mowing.

And yes, in case you are wondering, I figured out both solutions while walking back and forth across the lawn, under the influence of the loud mower engine. Both were so simple and obvious that I didn't even need to test them or write them down. I just kept mowing.

Solution 1: Prefixing Short Keywords

The first fix leaves the status field at Position 6, but changes its format in Nginx from $status to status=$status.

When Nginx writes status=401 to the log, it transforms a short, ambiguous number into a distinct long keyword like "status=401".

This completely neutralizes the injection trick. Long keywords in UBAN do not rely on positional anchoring; they trigger a blazingly fast full-line string search in C. Even if an attacker injects double quotes and shifts status=401 from Position 6 to Position 8, UBAN still scans the entire line, finds "status=401", and issues the ban immediately. The token shift does not matter anymore because position is irrelevant for long keywords.

I was very excited and quite proud of myself for coming up with this little but effective trick. Then I realized two small problems. First, it looks ugly in the raw log file. Second, if a site truly has a genuine URI containing "status=401" (like someone running a search query for that exact string), whoever visits that link can be banned instantly! That is definitely not ideal, but good enough. So I continued mowing. A few minutes later, I had my second idea.

Solution 2: Re-Ordering Log Fields

The entire reason token injection works in the first place is that $status is sandwiched right between two completely user-controlled areas in the default Nginx format: $request and $http_user_agent.

Because attackers control those two fields, they can insert arbitrary quotes or brackets to mangle the parser's positional math. But if I move $status out of that unsafe sandwich, attackers lose all leverage.

All it takes is moving $status right up to Position 2 in the Nginx log format string:

'log_format uban_format $remote_addr $status - $remote_user [$time_local] "$request" $body_bytes_sent "$http_referer" "$http_user_agent"';

Now, a raw log line looks like this:

192.168.1.50 401 - - [10/Oct/2026:14:32:10 +0000] "POST /wp-admin/admin.php HTTP/1.1" 512 "-" "Mozilla/5.0"

Look at how bulletproof this is:

  • Position 1: 192.168.1.50 (IP Address)
  • Position 2: 401 (HTTP Status Code)

I do not care what junk attackers put into their URI or User-Agent headers anymore. The IP address is locked at Position 1, and the status code is locked at Position 2—the only two fields UBAN needs to make an immediate enforcement decision. Problem solved.

A Note for Uban Users

Welcome to Uban. You certainly have a brave soul. It is safest and most efficient to make the IP and matched keywords at first two positions from the left (positions 1 and 2) or from the right (positions -1 and -2) of your log lines. If you need to change the default log format, do it. It's all worth it.

A Quick Word to Fail2ban Users

Hello, fellow Fail2ban users! I know you probably hesitate to convert to UBAN right away, and I totally understand. But for God's sake, put your Nginx status code at Position 2 as well, and simplify your f2b filter like this:

failregex = ^<HOST> 400 .*

Not only does this instantly close the token injection vulnerability on your server, but it saves significant CPU resources too. Matching against a fixed anchor near the start of a string makes regular expressions vastly simpler and faster to evaluate.


From Design to Code: Vibe Coding UBAN 1.0

With these solid architectural designs in mind, actual code writing was easy. I vibe coded the project with Gemini Pro. I wrote very few lines of code manually myself.

Not some more famous, robust, or expensive model. Just Gemini Pro. And you can laugh: the main reason I chose Gemini is simply that my Chase Sapphire Reserve card gives me $200 a year in Google Workspace credits, which perfectly covers the $15 monthly subscription.

The actual first version only took a few seconds to generate. I compiled it and it worked... sort of.

Over the next two weeks, quite a few bugs were fixed and several smaller functions were added. There were a few times Gemini was stuck with some awkward and unreliable solutions and I had to direct it to the right ones.

Nevertheless, here we are: UBAN 1.0 is live.

Other features of UBAN are quite obvious from its .help and .conf files and I won't repeat them here. It is recommended that you read those files first.


Field & Stress Tests

Hardware Environment

Both environments run on Oracle Cloud Infrastructure (OCI) Free Tier ARM compute instances:

  • Production Server: 2 OCPU (2 dedicated physical ARM cores, Ampere Altra Neoverse-N1 @ 3.0 GHz fixed frequency, non-hyperthreaded), 12 GB RAM. This server serves you this very article you are reading now.
  • Test Server: 1 OCPU (1 dedicated physical ARM core, Ampere Altra Neoverse-N1 @ 3.0 GHz), 6 GB RAM.

Production Observations: 2 Weeks of Live Traffic

I have been actively auditing both UBAN and Nginx logs in production for two full weeks. UBAN does exactly what it was designed to do: it detects offensive patterns, bans offenders' IPs without delay, and lifts bans cleanly once their timeouts expire. Many IPs do return after initial bans expire, and UBAN accurately tracks repeat offenders, escalating ban lengths with the correct incremental multipliers.

In production, UBAN protects live workloads across 5 distinct Nginx jails configured with different status codes, custom filters, and thresholds. Here is how it compares against the previous f2b setup under live conditions over 24 hours:

Agent Config RAM (RSS) CPU (24h) Reaction
UBAN 1.0 5 Jails < 1 MB ~0.27% ~5 ms
f2b 5 Jails ~30 MB ~0.30% ~0.7 s

The reaction speed difference is night and day. With UBAN reacting in ~5 ms, it is now very rare (though not absolute zero—more on this later in the v1.1 section) to see an attacking IP squeeze multiple requests into the same second. Back when f2b handled security, an attacking IP sending out tons of hits within that same 0.4–1.0 second delay window was a very common sight in the logs (no hard logged stats kept, but a clear, recurring operational impression).

Given these results, f2b was disabled in production two weeks ago and completely uninstalled a week ago. On the test server, f2b is currently disabled but kept installed purely for ongoing benchmark comparisons.

Head-to-Head Stress Tests: 10,000 Lines Synthetic Injection

Note on Stress Testing: All synthetic benchmarks and extreme load stress tests were conducted on the isolated 1 OCPU / 6 GB test server. Using ufw, all public ingress was dropped, leaving only port 22 accessible from two trusted internal IPs for continuous SSH administration.

Before running massive throughput benchmarks, memory safety was confirmed: observing 500 complete bans and unbans under Valgrind watch showed 0 memory leaks, with all heap allocations cleanly freed at exit.

To benchmark both engines under extreme flood conditions, 10,000 synthetic log lines were injected directly into /var/log/nginx/access.log via script on the 1 OCPU test server. Each run generated 10,000 unique, randomized IP addresses with status codes chosen from 8 typical HTTP responses. Both UBAN and f2b were configured with maxretry = 1, meaning every single IP exhibiting a target bad status code had to be banned immediately on sight.

Three distinct attack density scenarios were tested:

  • 100% Scenario: Strictly 100% offensive hits. Every single one of the 10,000 injected lines contained the target bad status code, requiring an immediate ban for all 10,000 unique IPs. UBAN banned exactly 10,000 IPs.
  • 50% Scenario: Bad status codes distributed based on a ~50% random chance across the 10,000 lines. In this run, UBAN caught and banned 5,071 IPs.
  • 12.5% Scenario: Bad status codes chosen randomly as one of eight possible options (~12.5% chance). In this run, UBAN caught and banned 1,243 IPs.

(Note: The exact ban figures above were pulled directly from UBAN's audit log. I did not audit f2b's internal database or ban counts; the comparison focuses strictly on processing throughput, CPU load, and execution times.)

Load Agent Ban/Unban Ban CPU Unban CPU RAM
100% uban 55s / 56s 13% pk (7% avg) 0.2% ~6.6 MB
f2b 74s / 157s 60% pk (35%+ avg) 25% pk (15%+ avg) ~45.0 MB
50% uban 30s / 30s 8% pk (4% avg) 0.2% ~4.8 MB
f2b 51s / 87s 60% pk (35%+ avg) 25% pk (15%+ avg) ~39.0 MB
12.5% uban 7s / 6s 3% pk (1.5% avg) 0.2% ~4.8 MB
f2b 14s / 12s 60% pk (25%+ avg) 10% avg ~34.2 MB

NOTE: The noticeable difference in unban duration and CPU load highlights a core architectural advantage of UBAN. Unbans in UBAN are managed directly by the Linux kernel via native nftables element timeouts; UBAN merely observes and logs expiration events. In contrast, Fail2ban actively issues individual unban CLI commands for each expired record in addition to logging them, incurring massive subprocess overhead under heavy queue drainage.

CONCLUSION: Across all scenarios, UBAN executed bans and unbans significantly faster while keeping CPU utilization and memory footprint to a minimal fraction of Fail2ban's consumption.


Limitations & Future Versions

While UBAN 1.0 is stable and fast in production, real-world field audits revealed clear design limitations that I am addressing in the roadmap:

  • Redundant Ban Enforcement on High-Speed Floods: Currently, UBAN sends a ban request to the firewall whenever it detects a bannable offense—even if that specific IP/jail pair has already reached max_bantime. With aggressive bots now sending up to 50 requests in the very same millisecond, this triggers redundant enforcement checks and wastes valuable CPU cycles. I have designed a mitigation strategy that is currently under testing (It works!). UBAN 1.0 does not include this optimization, but it will be a cornerstone feature in v1.1.
  • State Persistence Storage: To stay true to my core principle of simplicity, UBAN 1.0 stores runtime persistence in simple, human-readable flat text files. In retrospect, this was a design mistake. Parsing, writing, and locking flat text under high-concurrency log surges introduces file I/O bottlenecks and potential state race conditions. In v1.1, I will transition the internal state engine to SQLite.
  • Network Protocol Support: UBAN 1.0 is strictly IPv4. Full IPv6 parsing, bitmask subnet grouping, and IPv6 nftables set support will arrive in v2.0.