Restrict the string to [A-Za-z0-9:_-] so an interface name cannot inject
shell commands
Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-AU1COUX2
`Flow::protos` is a union whose active member is chosen by `isProto(p)`,
which matches either the master or the application protocol; every writer
and reader (`updateDNS`/`updateTLS`/`updateHTTP`, `Flow::lua`, the
destructor) relies on at most one member being selected.
nDPI never emits a pair spanning two members, but `ParserInterface::processFlow()`
stores the pair exactly as the exporter sent it (syslog/Suricata, ZMQ/nProbe),
so '5.91' makes `isDNS()` and `isTLS()` both true and DNS integers are later
read as TLS string pointers.
`Flow::setDetectedProtocol()` is the single place where an externally supplied
pair becomes the flow's protocol, so the invariant is restored there: a
new file-local `getProtosUnionMember()` maps a protocol id to the member it owns
(mirroring the `is<Proto>()` helpers and the destructor), and when master
and app own different members the application protocol is dropped (DNS.TLS
becomes DNS); for ICMP/ICMPv6 L4 flows, whose protos.icmp is written by
`setICMPTypeCode()` whatever the L7 pair says, any L7 protocol owning
another member is dropped.
setSIPCallId() is now called under flow->isSIP() after the
Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-UC4ZSTCJ
A host data reset (GUI "Delete host data", REST
`rest/v2/delete/host/data.lua`, i.e. Lua `interface.deleteHostData()`) only
requests the reset: it is performed by `Host::checkDataReset()` ->
`LocalHost::deleteHostData()` -> `freeLocalHostData()`, in
`periodic_stats_update`, by the thread purging the idle hosts of the
interface.
The host keeps living after it, but
`freeLocalHostData()` freed tcp_fingerprint and deleted fingerprints
without resetting them:
- the HTTP server threads read the freed tcp_fingerprint when showing
the host (host page, `rest/v2/get/host/data.lua`, ... -> `Host::lua` ->
`lua_get_fingerprints`): heap-use-after-free
- the threads processing the flows kept updating the deleted
fingerprints (`Flow::updateCliJA4/updateHASSH`) and compared the new TCP
fingerprint with the freed one (`setTCPfingerprint`)
- the destructor (host purged or ntopng shutdown) freed both again:
double free
Fix:
- tcp_fingerprint: freed and reset under `Host::m`, also read
(`lua_get_fingerprints`) and set (`setTCPfingerprint`) under it;
`tcp_fingerprint_host_os` is reset so that the fingerprint is learnt
again from the next flows
- fingerprints: emptied (`Fingerprint::reset`, under its own lock) rather
than deleted, as the threads processing the flows use it without
locking; deleted by the destructor only
The asset info of a host is dumped by the housekeeping thread
(dumpAssetInfo) while the threads processing the flows and the Lua/REST
threads change it, with no synchronization:
- `asset_map`: now protected by `Host::m` (`add/removeDataFromAssets`, dump,
load)
- `tags refreshed/removed/assigned` bitmaps: now atomic, so that a bit set
while the asset is dumped is no longer lost
- `asset_map_updated`: now atomic and out of the LocalHost bitfield (a
write rewrote the whole byte, losing the other flags written at the
same time). Same for `Mac::asset_map_updated`
- `Mac::dhcpv4_fingerprint`: now protected by `Mac::m`, read through a
copying getter, as it can be replaced (and freed) at any time
Rules to avoid deadlocks are documented on `Host::m` and `Mac::m`.
string.starts is a plain prefix comparison with no authority boundary, so a Location: value only has to begin with "http://" plus the responder IP to pass while libcurl dials elsewhere: http://IP@attacker.example/x carries the IP as userinfo, http://IP@127.0.0.1:3001/ reaches ntopng itself, and http://IP.attacker.example/x and http://IP0/x are different hosts. Compare the authority that libcurl resolves instead, and reject one carrying userinfo.
The hosts and ip_occurencies list formats split each line with a
width-less %s into 64-byte stack buffers (ignore and host), while the
line comes from a 256-byte fgets() buffer holding downloaded feed data.
A first token longer than 63 bytes overflowed the buffer.
Use %255s and size both buffers to 256 bytes, like the sibling host
buffer, so no token from a valid line is truncated.
The pcap files read from a playlist or a directory were opened one after the
other and stored with set_pcap_handle(), which overwrote the previous handle:
all the files but the last one leaked their pcap_t, buffer and open file
(ASAN: "Direct leak" from pcap_open_offline_common at exit).
set_pcap_handle() now closes the previous handle. As the handle is then closed
by the packet thread while other threads may read it (GUI and view through
getNumDroppedPackets()), getNumDroppedPackets() returns 0 for pcap files
without reading the handle: libpcap has no statistics for them anyway.
- Dump `DPI_PARTIAL_CACHE` as `DPI` in `PROTOCOL_INFO_JSON`, using
`isDPIDetectedFlow()` like the live flows do
- Show the confidence badge in the historical flow details: read it from
`PROTOCOL_INFO_JSON`, `ALERT_JSON` has no proto block
UDP flows whose direction is not told by their requests and replies (see
checkL7Direction(), e.g. SIP, QUIC) kept the sender of their first packet as
client: when captured from the server side, e.g. a QUIC server on port 443
(5,948 flows in a production capture), they stayed reversed.
checkTCPDirectionByProtocol() is renamed checkDirectionByProtocol() and also
handles UDP: once the protocol is detected, the side using one of its default
UDP ports (nDPI) is the server, when only one side uses one, as for TCP.
The custom category lists are loaded into nDPI with the list id and the
category packed in the category: `(list_id << 8) + category`. nDPI returns
that value as a `ndpi_protocol_category_t`, although it is not a valid one, and
it was read in several ways, some wrong:
* `IpAddress::reloadBlacklist()` read the custom category match into a
u_int16_t through a ndpi_protocol_category_t pointer: nDPI writes 4 bytes,
a stack buffer overflow (ASAN).
* `Flow::processPacket()` loaded the category of each packet into
flow_category: undefined behavior (UBSAN: "load of value 1124, which is not
a valid value for type 'ndpi_protocol_category_t'"), and flow_category held
the packed value until the detection was completed.
* `ntop_match_custom_category()` loaded the match as an enum value too.
* `Flow::setExtraDissectionCompleted()` computed the list id after masking the
category, so it was always 0.
* `Flow::updateProtocol()` unpacked only the Malware category of the list with
id 4 (> 1024 check); other packed values reached flow_category.
The format is now defined in one place, `Utils:`
* `packCustomCategory()` packs the category and the list id (used by
Ntop::nDPILoadIPCategory and Ntop::nDPILoadHostnameCategory). The packed
value is kept as an integer until it is passed to nDPI, as reading a
ndpi_protocol_category_t variable holding it is undefined behavior.
* `unpackCustomCategory()` reads the value returned by nDPI as an integer
(memcpy, so the invalid value is never loaded as an enum) and returns the
category, optionally the list id; categories not packed are returned as
they are. It is used in all the places above.
The runtime script of the e2e tests runs, on shutdown-when-done, once the
alert pipeline is drained (Ntop::alerts_pipeline_drained()), which includes
the recipient queues being empty (Recipients::empty()).
The recipients are processed by Lua (process_notifications()): each endpoint
first dequeues a batch of notifications (ntop.recipient_dequeue(), which
deletes the C++ item) and only then processes it, e.g. alert_store_db writes
it to the alert store (SQLite). While the batch is being processed the
recipient queues are empty, so the pipeline looks drained although the
alerts are not in the alert store yet: under load or with slower builds
(e.g. ASAN) the runtime script saw only part of the alerts.
Fix: a recipient with notifications dequeued is not empty until the next
pass of process_notifications() over the recipients, which calls
ntop.recipient_last_use() for each recipient at its beginning: the
notifications dequeued in the previous pass have been processed by then.
The direction of SNMPv3 flows was decided by the header only (the PDU is
usually encrypted): the reportable flag is set in the requests and in the
informs; otherwise the message was taken as a reply, unless sent to the trap
port (162). A trap sent to another port, e.g. 161, was then taken as a reply
and the flow was swapped, with the receiver of the trap as the client.
When the scoped PDU is not encrypted (privacy flag of the header clear, i.e.
noAuthNoPriv and authNoPriv) its PDU type is now read as for SNMPv1/v2c
(isSNMPReplyPDU()). The header is still used for the encrypted messages.
The client and the server of TCP flows (packet interfaces) are now decided
once, by the first SYN (sent by the client) or SYN-ACK (sent by the server)
seen, whatever came before it. Before, any first packet
that was not a SYN marked the handshake as over and the SYN-ACK rules were
skipped: with the client port lower than the server port the flow stayed
reversed.
* `checkTCPDirection()` replaces the guess by the ports on the first packet,
the SYN-ACK rules of `updateTcpFlags()` and the SSH/TLS swap of
`updateTCPHostServices()`.
* Flows without a handshake are still guessed by the ports on their first
packet. Once the protocol is detected, the default ports of the protocol
(nDPI) correct the guess when only one side uses one,
(`checkTCPDirectionByProtocol()`).
* The direction is final once the protocol is detected on a flow that has
carried payload.
* `performPendingSwap()` no longer skips the flows already swapped once (`swap()`
clears `swap_requested`, so the `swap_done` check was stale): a correction
requested after the last packet of the flow is now performed.
* `l7_direction_checked` is renamed `direction_decided`, as it is now shared by
the TCP and the L7 (UDP/ICMP) checks.
Flow::checkL7Direction() could not decide some flows, recorded with the
replier as client when their first packet is a reply:
* SNMPv3: the PDU is usually encrypted. isSNMPReply() (replacing
getSNMPPDUType()) now reads the reportable flag of the header (msgFlags,
never encrypted): it is set in the requests and in the informs; otherwise
the message is a trap when sent to the trap port (162), a reply (Response,
Report) when not. SNMPv1/v2c are still decided by the PDU type.
* NTP control (mode 6) and private (mode 7, e.g. monlist) messages: by their
R (response) bit. Note: nDPI does not detect NTP control responses (their
second byte is read as an invalid stratum), so a flow starting with one is
still not decided.
* DHCPv6: LEASEQUERY, RECONFIGURE-REQUEST, DHCPV4-QUERY and ADDR-REG-INFORM
are sent by the client, LEASEQUERY-REPLY, RECONFIGURE-REPLY, DHCPV4-RESPONSE
and ADDR-REG-REPLY by the server. A leasequery requestor (port 547) also
got the DHCP service, and the Unexpected DHCP server alert, when its query
was the first packet.