Zeek Across ASA PAT: Match Inside and Outside conn.log Flows

Built in my home lab: two Zeek sensors either side of an ASA, six HTTP flows matched through show xlate, and an ASA clock skew that broke a time-window join.

Lab diagram: Zeek and ASA NAT correlation built in my home lab. Two inside client addresses pass through ASAv PAT to a server on TCP 8080, with a tap and a Zeek sensor on each side of the fir

To match Zeek flows across ASA PAT, join on the translated tuple from show xlate, not on the outside IP or on timestamps. Built in my home lab with one Zeek sensor inside and one outside an ASAv doing interface PAT, two inside hosts using the same source port left as 198.18.60.1:7113 and 198.18.60.1:40000. Only the xlate entries tied each outside flow back to its inside host.

All six flow pairs matched the ASA's captured translation state. A deliberate ASA clock fault then showed a second problem: a narrow time-window check rejected valid matches even though forwarding continued. The useful evidence was the combination of tuples, live translation snapshots, unique HTTP markers and measured clock bounds.

Lab topology: ASAv PAT with a Zeek sensor on each side

The path was Alpine client → inside bridge tap → ASA → outside bridge tap → Alpine server. Each tap mirrored ingress traffic from both forwarding ports to a dedicated Zeek sensor, so the two sensors watched the data path before and after translation.

Lab diagram: Zeek and ASA NAT correlation built in my home lab. Two inside client addresses pass through ASAv PAT to a server on TCP 8080, with a tap and a Zeek sensor on each side of the firewall sharing one VM clock. show xlate maps each inside address and port to an outside port, some kept and some remapped, and Zeek sees each flow on both taps a few milliseconds apart. A deliberate nominal +120 second clock offset injected on the ASA is shown separately, and sensor skew and natural PAT reuse are marked untested.
Component Data configuration
Client eth1 10.60.10.10/24 and 10.60.10.11/24
ASA Gi0/0, inside 10.60.10.1/24; security level 100
ASA Gi0/1, outside 198.18.60.1/24; security level 0
Server eth1 198.18.60.20/24; TCP/8080
Each tap br60 joins eth1/eth2; ingress mirrors to eth3
Each Zeek sensor Unnumbered eth1 on its tap feed

The two client addresses were aliases on one Alpine node, not two physical clients. Each tap's monitor port sat outside its bridge. The client had a specific route to 198.18.60.0/24 through the inside gateway, and the server had a route to 10.60.10.0/24 through the outside gateway. Management used a separate path and plays no part in the captures.

The installed versions were ASA 9.24(1), Alpine 3.24.2 and Zeek 9.0.0, with ASAv in containerlab from vrnetlab/cisco_asav:9-24-1. The ASAv was unlicensed:

ASAv Platform License State: Unlicensed
Firewall throughput limited to 100 Kbps

These small flows are not a throughput test.

ASA interface PAT configuration

The data interfaces and the single NAT rule, from the baseline running configuration:

interface GigabitEthernet0/0
 nameif inside
 security-level 100
 ip address 10.60.10.1 255.255.255.0
!
interface GigabitEthernet0/1
 nameif outside
 security-level 0
 ip address 198.18.60.1 255.255.255.0
!
object network INSIDE60
 subnet 10.60.10.0 255.255.255.0
 nat (inside,outside) dynamic interface

The running configuration prints the object's subnet and its NAT statement in separate sections; they are shown together here. This was the only dynamic ASA NAT rule. Both sensors ran live Zeek and tcpdump against their mirrored feeds. The test server deliberately held each small HTTP response open for 35 seconds, so the ASA connection and translation were still active when I collected them.

Within each phase both client aliases used the same source port: 40000 for baseline, 41000 for fault and 42000 for restored. Each request also had a unique URI. Those URIs were controlled markers for this experiment; ASA PAT itself did not need them.

Match inside and outside conn.log with show xlate

show conn and show xlate give you the mapping. With both baseline flows active, show xlate detail recorded:

TCP PAT from inside:10.60.10.10/40000 to outside:198.18.60.1/7113 flags ri idle 0:00:08 timeout 0:00:00 refcnt 1 xlate id 0x7f8ae814fbc0
TCP PAT from inside:10.60.10.11/40000 to outside:198.18.60.1/40000 flags ri idle 0:00:08 timeout 0:00:00 refcnt 1 xlate id 0x7f8ae814fe40

The important detail is the allocated outside port. One source kept 40000; the other became 7113. That's documented ASA behavior: PAT uses the real source port if it's free, otherwise it picks another port from the same range (1024-65535 here), per the Cisco ASA NAT guide. So an outside source port by itself can't identify an inside host, and you can't assume PAT keeps the original port.

The active connection listing also kept the original inside identities:

TCP outside: 198.18.60.20/8080 inside: 10.60.10.10/40000,
TCP outside: 198.18.60.20/8080 inside: 10.60.10.11/40000,

The analysis joined each unique HTTP URI to that sensor's HTTP UID, then used the UID to find its Zeek conn.log tuple, and compared the inside and outside tuples with the ASA translation snapshot. The UIDs are local to each sensor; they were not used as a shared cross-sensor key. The analysis script's summary line:

All 6 pairs: ASA translation agrees; both sensors SF and HTTP 200.

That line is my script's analysis of the captured logs, not ASA output. The per-sensor conn.log, http.log and pcaps are the evidence.

Fault: ASA clock skew breaks a time-window join

The fault moved only the ASA guest clock, to nominal VM UTC plus 120 seconds at the moment the command was generated. It did not change the Linux host clock, packet timestamps or Zeek timestamps:

asa# clock set 20:11:56 Oct 21 2026
20:11:56.139 UTC Wed Oct 21 2026

Command transport and execution time meant the real offset wasn't exactly 120 seconds. Bracketing the ASA's show clock against the VM collection interval put the fault offset between 116.446566 and 119.222794 seconds ahead. Baseline bounds were -1.337029 to 2.486155 seconds, and restored bounds were -3.597935 to 0.099766 seconds. Those intervals describe collection uncertainty, not NTP accuracy.

The connections still completed with HTTP 200 and SF on both sensors; forwarding wasn't the problem. The problem was matching time across evidence sources. A simple time-window join estimated each connection's start on the ASA (ASA clock minus the integer show conn uptime) and compared it with Zeek's first-packet timestamp. Both fault flows came out 117.45301 seconds apart, so a 5-second window rejected both valid matches. The baseline and restored pairs passed the same check.

These timestamps describe different things. show xlate and show conn are snapshots with relative ages, not timestamped NAT creation logs. Zeek's connection timestamp marks the first packet it saw. Sequential collection and rounded uptime add uncertainty even when clocks agree.

Fix: restore the clock and keep the tuple evidence

The restore set the guest clock back to current VM UTC:

asa# clock set 20:11:08 Oct 21 2026
20:11:08.069 UTC Wed Oct 21 2026

That's why the restored-phase snapshot (20:11:21 UTC on the ASA) reads earlier than the fault-phase one (20:12:07 UTC): the fault clock was running about two minutes fast. Two fresh flows then used source port 42000. Their outside ports were 42000 and 12855. Both completed, and the same 5-second check accepted them.

Subtracting the midpoint of the measured fault offset from the fault estimate left a residual of about -0.382 seconds. That shows why clock normalization matters, but it doesn't turn a rounded connection age into a precise creation timestamp. Keep the tuple mapping and the clock bounds alongside any time-based match.

All six flow pairs: inside vs outside tuples

Phase / URI Inside source Outside PAT source Outside minus inside start, ms
/baseline-1 10.60.10.10:40000 198.18.60.1:7113 3.967047
/baseline-2 10.60.10.11:40000 198.18.60.1:40000 3.753901
/fault-1 10.60.10.10:41000 198.18.60.1:41000 13.481855
/fault-2 10.60.10.11:41000 198.18.60.1:27290 13.586998
/restored-1 10.60.10.10:42000 198.18.60.1:42000 0.998974
/restored-2 10.60.10.11:42000 198.18.60.1:12855 0.948906

All six pairs agreed with the captured ASA translations, and all six TCP rows had missed_bytes: 0. The 0.95 to 13.59 ms differences mix forwarding delay through the ASA with capture placement and capture effects; they are not a measure of sensor clock skew. Baseline tcpdump reported zero kernel drops on both feeds:

0 packets dropped by kernel
0 packets dropped by kernel

What this lab didn't test

Both sensors shared one host clock, so this doesn't measure independent sensor drift. Natural PAT port reuse wasn't reproduced. show xlate is a snapshot, not a timestamped log, and the unlicensed ASAv is limited to 100 Kbps, so nothing here is a throughput result. For a timestamped mapping, ASA syslog 305011 ("Built dynamic TCP translation") logs each xlate; this lab didn't collect it, so it's not tested here.

Key takeaways

  • Sensor placement decides which source identity Zeek records. Across PAT, the common outside address alone can't tell these two inside hosts apart.
  • Join on the translated tuple plus mapping evidence collected while the flow is live. The unique HTTP markers made this lab's six joins independently checkable.
  • Community ID won't help across PAT, because it hashes the 5-tuple and PAT changes it.
  • Time needs its own evidence: record when you collected each snapshot, measure the device clock offset, and don't treat a rounded connection age as a first-packet timestamp. Here every HTTP transaction worked through the clock fault while a 5-second time-window join failed.
  • If you can't place taps, ASA packet capture gives you the same before-and-after view on the box. Zeek conn.log also shows NAT faults directly, as S0 flows in the stale NAT rule lab.

Read next