Skip to content

Latest commit

 

History

History
323 lines (230 loc) · 18.4 KB

File metadata and controls

323 lines (230 loc) · 18.4 KB

🌐 Netfilter Proxy

Netfilter Proxy (nfproxy) is a filtering module that leverages nfqueue to intercept network packets, then runs your own Python filter code against them inside the C++ core via an embedded interpreter. Unlike Netfilter Regex, which only matches raw byte patterns, nfproxy gives filters structured access to the traffic (parsed HTTP requests/responses, TCP streams, per-stream history, ...) and lets them make arbitrary decisions in Python.

How to use it

  1. Create a service: pick a protocol (tcp or http), the target ip:port (or attach it to a TLS Decrypt stream, to see decrypted traffic), and a name.
  2. Upload one or more Python filter files (see writing a filter below). Each file can define multiple @pyfilter functions.
  3. Start the service. From then on, every matching packet is routed through nfqueue to the embedded Python interpreter, which runs your filters and returns an action (accept/drop/reject/mangle) back to the C++ core.
  4. The service page shows live counters (packets seen, blocked/mangled) and any Python exception raised by a filter, reported back over a unix socket — no restart needed to see them.

Filters can be edited and re-uploaded while the service is running: changes take effect on the next packet. fail_open (an advanced per-service option) controls what happens if the filter process itself crashes or can't be reached: enabled, traffic is allowed through unfiltered rather than blocked, trading protection for availability — useful if you'd rather risk letting an attack through than take the service down during a competition.

Writing a filter

Install the library and CLI:

pip install -U firegex

fgex is an alias package for firegex: installing either one gives you the same firegex module and the fgex/firegex CLI commands.

The pyfilter decorator

from firegex.nfproxy import pyfilter

pyfilter marks a function as an active filter. A filter function:

  • must be decorated with @pyfilter;
  • must have every parameter type-annotated with one of the data structures listed below (parameters without a type annotation make the filter invalid, and default/keyword-argument values are ignored — the caller always builds the arguments itself from the live packet data);
  • must return one of the packet statements below (or None, which behaves like ACCEPT).
from firegex.nfproxy import pyfilter, ACCEPT, REJECT

@pyfilter
def none_filter():  # A filter that does nothing
    return ACCEPT

Filter names (the function name) must be unique within a filter file.

Each TCP stream (i.e. each connection) gets its own isolated set of global variables: code at module level runs once per stream, and the same globals are reused across every packet of that stream. Don't store state in another module's globals — that memory is shared across every stream handled by the same thread and will cause data to leak/interfere between unrelated connections. Global variable names starting with __firegex are reserved for internal use.

from firegex.nfproxy import pyfilter, ACCEPT, REJECT
from firegex.nfproxy.models import HttpRequest

@pyfilter
def filter_with_args(http_request: HttpRequest):
    if http_request.body and b"ILLEGAL" in http_request.body:
        return REJECT

Here the filter is only called once the data required to build an HttpRequest is available (i.e. once the HTTP headers have been parsed, and again once the body is complete). If a filter needs multiple parameters, it's only called once every parameter can be built from the data received so far.

Packet statements

A filter must return one of these values (importable from firegex.nfproxy):

Statement Effect
ACCEPT The packet is accepted and forwarded to the destination. This is also the default behavior if None is returned.
REJECT The connection is closed and every packet in the stream is dropped.
DROP This packet, and every subsequent packet in the stream, is silently dropped (unlike REJECT, this doesn't simulate a connection closure).
UNSTABLE_MANGLE The packet is modified and forwarded. Only available when filtering RawPacket — see below. This is an unstable feature, use it carefully.

Data structures

These are the types you can use as filter parameter annotations. Some are only available when the service's protocol is http (marked HTTP only); the rest work for both tcp and http services.

RawPacket

from firegex.nfproxy.models import RawPacket

The raw packet as received from nfqueue — the lowest level of abstraction, and the only data structure that can be mutated (for UNSTABLE_MANGLE).

  • data: bytes — the packet's data, reassembled/reordered as a TCP stream (read-only).
  • is_input: boolTrue for an incoming packet, False for outgoing.
  • is_ipv6: boolTrue for IPv6, False for IPv4.
  • is_tcp: boolTrue for TCP, False for UDP.
  • l4_size: int — size of the layer-4 payload (read-only).
  • raw_packet_header_len: int — size of the original packet's header (read-only).
  • raw_packet: bytes — the full packet, including the IP/TCP headers. Writable: assigning to it mangles the packet if the filter returns UNSTABLE_MANGLE, and updates l4_size/l4_data accordingly.
  • l4_data: bytes — the layer-4 payload only, taken directly from the raw packet. Also writable for mangling; updates l4_size/raw_packet accordingly.

Be careful: l4_data/raw_packet reflect a single physical packet as seen by nfqueue, while data is the TCP stream already reassembled/reordered by the C++ core — they can legitimately differ (e.g. out-of-order packets are accepted by default without invoking Python at all).

TCPInputStream (alias TCPClientStream)

from firegex.nfproxy.models import TCPInputStream

The assembled TCP stream in the input (client → server) direction. A filter using this type is only called for incoming packets.

  • data: bytes — the entire input-direction stream assembled so far (read-only).
  • total_stream_size: int — size of that stream (read-only).
  • is_ipv6: bool

TCPOutputStream (alias TCPServerStream)

from firegex.nfproxy.models import TCPOutputStream

Same as TCPInputStream, but for the output (server → client) direction; only called for outgoing packets.

HttpRequest — HTTP only

from firegex.nfproxy.models import HttpRequest

The current HTTP request. This handler is called up to twice: once when the headers are complete, and again once the body is complete (if the whole request arrives in a single TCP packet, it's called only once).

  • method: bytes — the request method.
  • url: str | None — the request URL.
  • headers: dict[str, str] — request headers, keys/values exactly as received (case-sensitive); a repeated header becomes a list of values.
  • get_header(header: str, default=None) -> str — looks up a header case-insensitively; if the header was repeated, its values are joined with a comma (this method never returns a list).
  • user_agent: str
  • content_encoding: str
  • content_length: int | None
  • body: bytesNone until the body has arrived.
  • body_decoded — the body decoded according to content_encoding (gzip, br, deflate and zstd are supported). False if decoding failed and body isn't None.
  • http_version: str
  • keep_alive: bool
  • should_upgrade: bool
  • upgrading_to_h2: bool
  • upgrading_to_ws: bool
  • ws_stream: list[websockets.frames.Frame] — decoded WebSocket frames (permessage-deflate supported); see the websockets docs.
  • stream: bytes — buffer of the raw WebSocket traffic in this direction; only meaningful once should_upgrade is True.
  • headers_complete: bool
  • message_complete: bool
  • total_size: int — size of the whole request seen so far.
  • history: HttpHistory — previously completed requests/responses on this same stream, see HttpHistory below.

HttpRequestHeader — HTTP only

from firegex.nfproxy.models import HttpRequestHeader

Same fields as HttpRequest, but only called once, when the headers are complete — the body is never buffered and body is always None.

HttpFullRequest — HTTP only

from firegex.nfproxy.models import HttpFullRequest

Same fields as HttpRequest, but only called once the whole request (headers + body) is complete. Completed instances of this type are also what gets stored in HttpHistory.requests.

HttpResponse — HTTP only

from firegex.nfproxy.models import HttpResponse

The current HTTP response — same shape and calling convention as HttpRequest (up to twice: headers complete, then body complete), plus:

  • status_code: int

All the other fields listed for HttpRequest (headers, get_header, body, body_decoded, content_encoding, content_length, http_version, keep_alive, should_upgrade, upgrading_to_h2, upgrading_to_ws, ws_stream, stream, headers_complete, message_complete, total_size, history, user_agent) apply here too.

HttpResponseHeader — HTTP only

from firegex.nfproxy.models import HttpResponseHeader

Same as HttpResponse, but only called once the headers are complete; body is always None.

HttpFullResponse — HTTP only

from firegex.nfproxy.models import HttpFullResponse

Same as HttpResponse, but only called once the whole response is complete. Completed instances of this type are what gets stored in HttpHistory.responses.

HttpHistory (alias HttpStreamHistory) — HTTP only

from firegex.nfproxy.models import HttpHistory

Gives a filter access to previously completed requests/responses on the same TCP stream — useful for correlating a response with the request(s) that came before it on a keep-alive connection, or for stateful logic that spans more than one exchange.

  • requests: list[HttpFullRequest] — a snapshot copy of the requests completed so far on this stream (does not include the request currently being processed).
  • responses: list[HttpFullResponse] — a snapshot copy of the completed responses so far.

You can use it in two ways:

from firegex.nfproxy import pyfilter, REJECT
from firegex.nfproxy.models import HttpHistory, HttpResponse

# 1. as its own filter parameter
@pyfilter
def check_history(hist: HttpHistory):
    if len(hist.requests) > 50:
        return REJECT

# 2. via the .history property available on any Http* instance
@pyfilter
def check_previous_requests(resp: HttpResponse):
    if any(b"admin" in req.url.encode() for req in resp.history.requests if req.url):
        return REJECT

The number of entries kept per stream is capped by the FGEX_MAX_HISTORY_SIZE global (default 100) — see Other global options below; once the cap is reached, the oldest entry is dropped as a new one is added.

Stream limiter

What happens if a single TCP stream carries a lot of data? Past a configurable size, a "full stream" action kicks in. First import the enum:

from firegex.nfproxy import FullStreamAction

Then set these in the filter file's globals:

  • FGEX_STREAM_MAX_SIZE: int — maximum size (in bytes) of a stream before FGEX_FULL_STREAM_ACTION is triggered. This limit applies per data structure independently: e.g. if TCPInputStream has reached the limit but HttpResponse hasn't, the action only affects TCPInputStream. Default: 1MB.
  • FGEX_FULL_STREAM_ACTION: FullStreamAction — the action taken once the limit is exceeded. Default: FullStreamAction.FLUSH.

FullStreamAction values:

Value Effect
FLUSH Flush the stream and keep acquiring new packets (default).
DROP Drop the next stream packets, like a DROP filter statement.
REJECT Reject the stream and close the connection, like a REJECT filter statement.
ACCEPT Stop calling pyfilters and accept the rest of the traffic as-is.

Other global options

from firegex.nfproxy import ExceptionAction
  • FGEX_INVALID_ENCODING_ACTION: ExceptionAction — action taken when parsing hits an invalid/unsupported encoding (a parser-level failure). Default: ExceptionAction.REJECT. Values: ACCEPT (accept the packet that caused the error), DROP (drop the connection), REJECT (reject the connection), NOACTION (do nothing — the error is signaled and the stream is accepted without calling any more pyfilters on it).
  • FGEX_MAX_HISTORY_SIZE: int — max number of requests/responses kept per stream by HttpHistory. Default: 100.

Testing a filter locally

You don't need a running Firegex instance to try out a filter: the firegex pip package ships a local proxy simulator.

➤ fgex nfproxy -h

 Usage: fgex nfproxy [OPTIONS] FILTER_FILE ADDRESS PORT

 Run an nfproxy simulation

╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *    filter_file      TEXT     The path to the filter file [default: None] [required]                                                               │
│ *    address          TEXT     The address of the target to proxy [default: None] [required]                                                        │
│ *    port             INTEGER  The port of the target to proxy [default: None] [required]                                                           │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --proto                 [tcp|http]  The protocol to proxy [default: tcp]                                                                            │
│ --from-address          TEXT        The address of the local server [default: None]                                                                 │
│ --from-port             INTEGER     The port of the local server [default: 7474]                                                                    │
│                 -6                  Use IPv6 for the connection                                                                                     │
│ --help          -h                  Show this message and exit.                                                                                     │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

This runs a local proxy in front of ADDRESS:PORT, applying FILTER_FILE to the traffic — no Docker, no nftables/root privileges required. The filter file is reloaded automatically whenever it changes on disk, so you don't need to restart the simulator while iterating.

fgex nfproxy test_http.py 127.0.0.1 8080 --proto http

Full example

# Example filter file for an http-protocol service

from firegex.nfproxy.models import RawPacket, HttpRequest, HttpHistory
from firegex.nfproxy import pyfilter, ACCEPT, REJECT, DROP, UNSTABLE_MANGLE, FullStreamAction

# Lowest level of abstraction: only RawPacket can be mangled.
@pyfilter
def mangle_example(packet: RawPacket):
    if b"TEST_MANGLING" in packet.l4_data:
        packet.l4_data = packet.l4_data.replace(b"TEST", b"UNSTABLE")
        return UNSTABLE_MANGLE
    if b"BAD DATA" in packet.data:
        return DROP
    return ACCEPT

# Higher level of abstraction: parsed HTTP requests.
@pyfilter
def http_filter(http: HttpRequest):
    if http.method == b"GET" and http.url and "test" in http.url:
        return REJECT

# Using history to see previous requests on the same keep-alive stream.
@pyfilter
def repeated_probe_filter(hist: HttpHistory):
    if len(hist.requests) > 20:
        return REJECT

# Stream size limits (applies per data structure, e.g. only to RawPacket streams above)
FGEX_STREAM_MAX_SIZE = 4096
FGEX_FULL_STREAM_ACTION = FullStreamAction.REJECT

How it works

The proxy is built on a multi-threaded architecture that embeds Python for dynamic filtering:

  • Packet interception: the nfqueue kernel module (part of netfilter) intercepts network packets; the rules attaching nfqueue to traffic are generated with the nftables JSON API by the Python manager.
  • Packet reading: a dedicated thread reads packets from nfqueue.
  • Multi-threaded analysis: the C++ binary launches multiple threads, each with its own Python interpreter — Python 3.12's per-interpreter GIL makes this real multithreading. Traffic is distributed across threads by hashing IP/port, so all packets of the same flow are handled by the same thread.
  • Python filter integration: uploaded filters run inside these interpreters.
  • HTTP parsing: a Python wrapper for llhttp (forked/adapted to work across multiple interpreters) parses HTTP traffic.