> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Security from NetFlow: spotting scans and sweeps on Cisco IOS XE
- URL: https://www.pinglabz.com/flow-security-from-netflow/
- Published: 2026-11-03T06:26:29.000Z
- Updated: 2026-11-03T06:57:22.000Z
- Description: Port scans and ping sweeps vanish in bandwidth graphs. Counting distinct ports and hosts per source in Akvorado and ClickHouse finds them fast. Real queries and output from two IOS XE routers.
- Author: Jaime
- Tags: Grafana, Monitoring, SNMP, Cisco IOS XE, Linux, Tools

Flow data is usually sold as a capacity tool: who is using the WAN and when. The same records are also a cheap security sensor. A port scan, a ping sweep or a cleartext login protocol all leave a clear shape in NetFlow, even though the router never looks at a payload. I ran a scan and a sweep across two Cisco IOS XE 17.18.2 routers and looked for them in Akvorado and ClickHouse. All of it was built in my home lab, and all of the traffic is synthetic.

The collector setup is in [Akvorado on Cisco IOS XE](https://www.pinglabz.com/flow-akvorado-ios-xe/). For the bandwidth side of the same data, see [Troubleshooting with NetFlow: who is eating the WAN?](https://www.pinglabz.com/flow-troubleshooting-top-talkers/) and [Akvorado vs GoFlow2 and Loki](https://www.pinglabz.com/flow-akvorado-vs-goflow2/).

[![Lab diagram: Finding a scan in flow data. wan-rtr2 (IOS XE 17.18.2) with Loopback0 10.255.0.2 acts as a synthetic scanner across the WAN link to wan-rtr1, which exports NetFlow v9 on input only. From wan-rtr1, an orange dashed arrow shows TCP SYNs to ports 1-120 on acc-sw1 at 10.0.10.2, a grey dashed arrow shows RST replies coming back, and a teal arrow shows an ICMP sweep of 10.0.10.1-40. Both routers export to Akvorado v2026.10.1, whose graphs show 0.00 pps, and ClickHouse 26.3 counts distinct values per source over 12 minutes. Callouts: uniqExact(DstPort) > 20 per source finds 121 ports, about 1 packet per flow, average 50 bytes; bandwidth graphs show 0.00 pps, so count distinct, don't sum.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-37.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-37.png?ref=pinglabz.com)

A TCP SYN scan and an ICMP sweep from wan-rtr2's loopback. Bandwidth graphs show nothing, but counting distinct ports per source finds 121.

## The versions

- **Cisco IOS XE 17.18.2** on wan-rtr1 and wan-rtr2, exporting NetFlow v9 with Flexible NetFlow on input only.
- **Akvorado v2026.10.1** (AGPL-3.0) with **ClickHouse 26.3** for the raw queries.
- An IOS XE access switch (acc-sw1, 10.0.10.2) behind wan-rtr1 as the target.

## What I generated

From wan-rtr2's Loopback0 (10.255.0.2) a small script did two things. First it ran a ping sweep of 10.0.10.1 to 10.0.10.40\. Then it made a telnet connection attempt to 10.0.10.2 on every TCP port from 1 to 120\. That's a slow, noisy, old-school scan, which is fine here because the point is the shape it leaves in the flows. Normal background traffic (SSH, HTTP, NETCONF and some bulk pings) kept running the whole time.

One detail matters for reading the results. Each router only monitors input. wan-rtr2 originates the scan, so it never sees it coming in. wan-rtr1 sees the scan arrive on its WAN link, and both routers see the replies going back.

## Why the graphs don't show it

The first thing I tried was the normal traffic view, filtered to the scanner:

```
SrcAddr = 10.255.0.2 AND Proto = 6 AND ExporterName = 'wan-rtr1.lab'
```

Akvorado wants the protocol as a number here. `Proto = TCP` gives "Invalid filter expression".

![Akvorado Sankey and table of TCP traffic from 10.255.0.2 to 10.0.10.2 by destination port, with many ports listed and every row at 0.00 pps](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/sec-scan.png)

Lots of destination ports from one source, and every row reads 0.00 pps.

The table is the giveaway: dozens of destination ports from one source to one host. But every rate is 0.00 pps. A scan is one tiny packet per port, so in any bits-per-second or packets-per-second graph it disappears into the floor. Volume dashboards are built to find hogs, and a scan is the opposite of a hog. To find one you have to count distinct things instead of adding up bytes.

## Counting distinct ports and hosts

This is the query I'd keep. It counts distinct destination ports and hosts per source over the last 12 minutes and keeps anything that touches more than 20 ports or 10 hosts:

```
SELECT replaceOne(toString(SrcAddr), '::ffff:', '') AS src,
       ExporterName AS exporter,
       dictGetOrDefault('protocols', 'name', Proto, '') AS proto,
       uniqExact(DstPort) AS ports,
       uniqExact(DstAddr) AS hosts,
       count() AS flows,
       sum(Packets) AS pkts,
       round(sum(Bytes) / sum(Packets)) AS avg_pkt
FROM flows
WHERE TimeReceived > now() - INTERVAL 12 MINUTE
GROUP BY src, exporter, proto
HAVING ports > 20 OR hosts > 10
ORDER BY ports DESC, hosts DESC
```

I run it with `clickhouse-client` inside the ClickHouse container. Akvorado stores IPv4 addresses as IPv4-mapped IPv6, hence the `replaceOne`. The output:

```
src         exporter      proto  ports  hosts  flows  pkts    avg_pkt
10.0.10.2   wan-rtr1.lab  TCP    144    1      145    157     41
10.0.10.2   wan-rtr2.lab  TCP    144    1      145    157     41
10.255.0.2  wan-rtr1.lab  TCP    121    1      145    177     50
10.255.0.2  wan-rtr1.lab  ICMP   2      42     108    551558  1096
```

Four rows, and three of them are the scan:

- **10.255.0.2 TCP, 121 ports, 1 host.** That's the scan itself: ports 1 to 120 plus the background sessions. 177 packets over 145 flows is barely more than one packet per flow, with an average packet of 50 bytes. That's a SYN that got refused.
- **10.0.10.2 TCP, 144 ports, on both routers.** These are the replies (RSTs from the switch). Their "destination port" is the scanner's ephemeral source port, which changes on every attempt. So the victim looks like a scanner too.
- **10.255.0.2 ICMP, 42 hosts.** That's the sweep of 40 addresses plus two hosts from the background pings. The big packet count also comes from the background bulk pings, not from the sweep.

To tell the scanner from the victim, look at which side has the low, well-known ports. The scanner's destination ports are 1 to 120\. The victim's destination ports are all above 1024\. In a real network you'd also exclude your own monitoring servers and vulnerability scanners from the query, because they trip it every day.

## Drilling into one scanner

Once you have a suspect, break it down by port:

```
SELECT DstPort, count() AS flows, sum(Packets) AS pkts, sum(Bytes) AS bytes
FROM flows
WHERE TimeReceived > now() - INTERVAL 12 MINUTE
  AND ExporterName = 'wan-rtr1.lab'
  AND SrcAddr = toIPv6('::ffff:10.255.0.2') AND Proto = 6
GROUP BY DstPort ORDER BY DstPort
```

A trimmed version of what came back:

```
DstPort  flows  pkts  bytes
1        1      1     52
2        1      1     52
...
21       1      1     52
22       4      36    1506
23       11     11    ...
24       1      1     52
...
80       8      8     ...
...
120      1      1     52
830      5      5     ...
```

Closed ports all look identical: one flow, one packet, 52 bytes. The router answered with a reset and that was it. Port 22 stands out with 36 packets and 1.5 KB across 4 flows, so it's open and something actually talked to it. Ports 23, 80 and 830 have extra flows from the background script, which connects to them on a schedule.

That gives you an open-port list for the target without touching it, which is useful for both sides.

## The sweep

The ICMP sweep is easier to see visually. A Sankey of source to destination for ICMP from 10.255.0.2 fans out to every address it tried:

```
SrcAddr = 10.255.0.2 AND Proto = 1
```

![Akvorado Sankey diagram showing ICMP from 10.255.0.2 fanning out to many 10.0.10.x destination addresses](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/sec-sweep.png)

One source, ICMP, fanning out across 10.0.10.0/24.

A sweep shows as one source and many destinations with roughly the same small amount of traffic each. Live hosts get an echo reply back, so you can count replies the same way to see which addresses answered.

## Cleartext protocols are a finding too

The port breakdown turned up flows to TCP 23 (telnet) and 80 (HTTP) on a network device. Here that's my own background script, but in a real network any steady flow to port 23 on a router or switch is worth a ticket. Telnet sends the login in clear text. A quick check is to filter on `DstPort = 23` across all exporters and list the sources.

## What flow data can't do

- It can't see payload. You know a connection happened, not what was said. This isn't an IDS.
- Akvorado doesn't store TCP flags by default, so you can't directly tell a SYN-only flow from a full session. Tiny packets and one-packet flows are the stand-in.
- With input-only monitoring, the router that originates traffic doesn't see it. Pick your monitoring points with that in mind.
- Sampling would make a slow scan nearly invisible. These routers export every packet, so every probe showed up.
- The flow export timers add delay. This is for finding things within minutes, not blocking them.

For router-side configuration see [Flexible NetFlow on Cisco IOS XE](https://www.pinglabz.com/flexible-netflow-cisco-ios-xe/), and [NetFlow versions compared](https://www.pinglabz.com/netflow-versions-compared/) if you're choosing between v9 and IPFIX. To graph flows in Grafana, see [NetFlow and IPFIX in Grafana](https://www.pinglabz.com/grafana-netflow-ipfix-ios-xe/).

Next in this series: I skipped ntopng because collecting NetFlow needs the commercial nProbe. Now live instead: [pmacct nfacctd: NetFlow accounting to CSV and SQL](https://www.pinglabz.com/flow-pmacct/).