ALL

For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.

scripts/e2e_pcap.py

SHA-256: 6244692d101b3c5851e84830e06f361eff87bac6b8716fc7d65a07d6836ccf9c
814 lines · 42051 bytes · View on GitHub

1#!/usr/bin/env python3
2"""Does a plaintext marker cross the tunnel server? Reads a tcpdump capture (classic pcap) and looks for markers in it.
3
4This is the proof tool of the ALL end-to-end experiment (the 1.1 experiment plan section 2.9, test 2): the lead puts synthetic markers
5into the bodies of requests and responses that go ChatGPT (or any HTTPS client) -> tunnel server -> device, captures the traffic on
6the tunnel server (with its capture command) and this module says whether any marker is visible there. It is standard library
7only, so it also runs from release_readiness later.
8
9What it does, so that "no marker found" is not an empty statement:
10 * decodes Ethernet / raw IP / Linux cooked (SLL, SLL2) frames, IPv4 and IPv6, TCP;
11 * searches every marker in (a) the raw bytes of the file, (b) each TCP payload, (c) each TCP stream reassembled per direction, so a
12 marker split across two segments is still found;
13 * counts what WAS captured (flows, payload bytes per port, TLS records by type, the SNI names of the ClientHellos) and refuses to call
14 the capture clean when it holds no payload at all: a capture that saw nothing proves nothing;
15 * reads the hellos that cross in the clear with the ONE parser of the device (src/all_mcp/tls_policy.py, WP16): the version, cipher suite and selected group of every
16 ServerHello (and the alert of a refused handshake), and what each ClientHello offered, so the blindness gate records them in e2e-proof.json.
17Not covered: pcapng files (tcpdump -w writes classic pcap) and IP fragments.
18
19WP23 made it the library of the blindness gate, so it also:
20 * searches every marker in its ENCODED forms (`forms=True`, on in the command line): upper and lower case (DNS folds case), base64 at the three
21 alignments, URL-safe base64, hex, UTF-16, and in what a stream holds once it is unwrapped: the TLS record headers taken out (a marker split across two
22 records), gzip and zlib streams inflated, base64 runs decoded, DNS names joined across their labels;
23 * reads UDP too (DNS on the node's resolver port is where a node could leak a marker without a TCP connection of its own);
24 * refuses a capture that lost something: a hole in a flow's sequence numbers, a flow whose first or last bytes are missing, a packet cut by the snap length,
25 a packet count that differs from what tcpdump says it captured, or kernel drops in tcpdump's own counters, is INCOMPLETE and never CLEAN (a marker may
26 have been in the part that is missing);
27 * lists every TLS flow with its SNI, the ServerHello's version, cipher and group, and the bytes each way (`flow_table`).
28
29 python3 scripts/e2e_pcap.py FILE --marker TEXT [--marker TEXT ...] [--ports 443,8443] [--min-bytes N] [--tcpdump-log FILE] [--allow-midflow] [--exact] [--json]
30
31Exit codes: 0 clean (markers given, none found, traffic seen, nothing missing); 5 a marker was found; 1 not conclusive (no markers given, no payload
32captured, a capture that lost packets, or a damaged one); 2 unreadable file or bad usage.
33"""
34from __future__ import annotations
35
36import argparse
37import base64
38import binascii
39import dataclasses
40import json
41import re
42import struct
43import sys
44import zlib
45from dataclasses import dataclass, field
46from pathlib import Path
47from typing import Optional, Sequence
48
49PCAP_MAGICS = {
50 b"\xd4\xc3\xb2\xa1": ("<", False), b"\xa1\xb2\xc3\xd4": (">", False),
51 b"\x4d\x3c\xb2\xa1": ("<", True), b"\xa1\xb2\x3c\x4d": (">", True),
52}
53PCAPNG_MAGIC = b"\x0a\x0d\x0d\x0a"
54LINK_NULL, LINK_ETHERNET, LINK_RAW, LINK_SLL, LINK_SLL2 = 0, 1, 101, 113, 276
55# BSD address families of a loopback frame (macOS numbers AF_INET6 as 30, FreeBSD as 28): what a `tcpdump -i lo0` file holds
56BSD_FAMILY_ETHERTYPE = {2: 0x0800, 24: 0x86DD, 28: 0x86DD, 30: 0x86DD}
57MAX_FILE_BYTES = 400 * 1024 * 1024
58TLS_TYPES = {20: "change_cipher_spec", 21: "alert", 22: "handshake", 23: "application_data"}
59MAX_EXAMPLES = 5
60MAX_HELLOS_LISTED = 20
61MAX_FLOWS_LISTED = 200
62MAX_VIEW_BYTES = 64 * 1024 * 1024 # of one stream that is unwrapped (inflated, decoded) for the search
63MAX_INFLATE_TRIES = 256 # offsets of one stream where a gzip or zlib header is tried
64BASE64_RUN = re.compile(rb"[A-Za-z0-9+/_-]{24,}={0,2}")
65TCP_FIN, UDP_DNS_PORT = 0x01, 53
66_TLS_POLICY = None
67
68
69def tls_policy():
70 """The device's TLS policy module (the hello parser lives there, once): imported from this checkout's src/ the first time it is needed."""
71 global _TLS_POLICY
72 if _TLS_POLICY is None:
73 src = str(Path(__file__).resolve().parents[1] / "src")
74 if src not in sys.path:
75 sys.path.insert(0, src)
76 from all_mcp import tls_policy as module
77 _TLS_POLICY = module
78 return _TLS_POLICY
79
80
81class PcapError(Exception):
82 """The file cannot be read as a classic pcap capture."""
83
84
85@dataclass
86class Segment:
87 src: str
88 sport: int
89 dst: str
90 dport: int
91 seq: int
92 syn: bool
93 payload: bytes
94 fin: bool = False
95 proto: str = "tcp"
96 ack: int = 0
97 has_ack: bool = False
98
99
100@dataclass
101class MarkerResult:
102 marker: str # what is shown: the marker itself, or the label of a secret (its value is never stored or printed)
103 secret: bool = False
104 raw_hits: int = 0
105 packet_hits: int = 0
106 stream_hits: int = 0
107 where: list = field(default_factory=list)
108 forms: list = field(default_factory=list) # the forms in which it was found ("raw", "base64+1", "tls-records", ...)
109 flows: list = field(default_factory=list) # the connections it was found in, as flow_key() writes them
110
111 @property
112 def found(self) -> bool:
113 return bool(self.raw_hits or self.packet_hits or self.stream_hits)
114
115
116@dataclass
117class PcapReport:
118 file: str = ""
119 link: str = ""
120 packets: int = 0
121 tcp_packets: int = 0
122 skipped_frames: int = 0
123 truncated_tail: bool = False
124 payload_bytes: int = 0
125 flows: int = 0
126 ports: dict = field(default_factory=dict) # port -> {"flows": n, "payload_bytes": n}
127 tls_records: dict = field(default_factory=dict)
128 tls_flows: int = 0
129 non_tls_flows: list = field(default_factory=list) # [{"flow": ..., "first_bytes": ...}] a plaintext start is worth a look
130 sni_names: dict = field(default_factory=dict)
131 server_hellos: list = field(default_factory=list) # [{"flow", "version", "cipher", "group", "hybrid", "hello_retry_request"}] read from the ServerHello bytes
132 client_hellos: list = field(default_factory=list) # [{"flow", "server_name", "offers_hybrid", "groups", ...}]
133 alerts: list = field(default_factory=list) # [{"flow", "alert"}] a server that refused the handshake
134 tls_summary: dict = field(default_factory=dict) # counts of version, cipher and group over the ServerHellos: what e2e-proof.json records
135 flow_table: list = field(default_factory=list) # one entry per TCP connection: SNI, TLS version, cipher and group, bytes each way, records
136 udp_packets: int = 0
137 udp_payload_bytes: int = 0
138 dns_names: dict = field(default_factory=dict) # query name -> count, read from UDP and TCP port 53
139 markers: list = field(default_factory=list)
140 min_bytes: int = 1
141 incomplete: list = field(default_factory=list) # [{"where", "reason"}] everything that says part of the traffic is missing from the capture
142 stats: dict = field(default_factory=dict) # what the capture tool itself said (tcpdump's counters), when it was given
143 secrets_are_markers: bool = False # the secrets are all there is to search for (a marker file read by `e2e_blindness.py analyze`): they count as the markers of the verdict
144
145 @property
146 def found(self) -> bool:
147 return any(m.found for m in self.markers)
148
149 @property
150 def traffic_seen(self) -> bool:
151 return self.payload_bytes >= self.min_bytes
152
153 @property
154 def verdict(self) -> str:
155 if self.found:
156 return "FOUND"
157 if not any(self.secrets_are_markers or not m.secret for m in self.markers):
158 return "NO-MARKERS" # a secret that is searched for in addition does not replace the markers the caller asked for
159 if self.incomplete:
160 return "INCOMPLETE"
161 if not self.traffic_seen:
162 return "NO-TRAFFIC"
163 return "CLEAN"
164
165 @property
166 def exit_code(self) -> int:
167 return {"CLEAN": 0, "FOUND": 5}.get(self.verdict, 1)
168
169 def summary(self) -> str:
170 ports = ", ".join(f"{p}: {v['flows']} flow(s) {v['payload_bytes']} B" for p, v in sorted(self.ports.items())) or "none"
171 tls = ", ".join(f"{k} {v}" for k, v in sorted(self.tls_records.items())) or "none"
172 text = (f"{self.packets} packet(s), {self.tcp_packets} TCP, {self.flows} flow(s), {self.payload_bytes} payload bytes; "
173 f"by port: {ports}; TLS records: {tls}; SNI names seen: {', '.join(sorted(self.sni_names)) or 'none'}")
174 if self.skipped_frames:
175 text += f"; {self.skipped_frames} frame(s) not decoded"
176 if self.truncated_tail:
177 text += "; the last record of the file was cut"
178 return text
179
180 def to_dict(self) -> dict:
181 data = dataclasses.asdict(self)
182 data.update(verdict=self.verdict, found=self.found, traffic_seen=self.traffic_seen)
183 for entry, result in zip(data["markers"], self.markers):
184 entry["found"] = result.found
185 return data
186
187
188# --------------------------------------------------------------------------- decoding
189
190def read_frames(data: bytes) -> tuple:
191 """(link type, [frame bytes], truncated_tail) of a classic pcap file."""
192 link, frames, truncated, _snapped = read_frames_ex(data)
193 return link, frames, truncated
194
195
196def read_frames_ex(data: bytes) -> tuple:
197 """(link type, [frame bytes], truncated_tail, snapped): `snapped` counts the frames the capture cut short (captured length below the length on the wire)."""
198 if data[:4] == PCAPNG_MAGIC:
199 raise PcapError("this is a pcapng file; capture with `tcpdump -w` (classic pcap) or convert it with editcap -F pcap")
200 if len(data) < 24 or data[:4] not in PCAP_MAGICS:
201 raise PcapError("not a pcap capture (unknown magic number)")
202 endian, _nano = PCAP_MAGICS[data[:4]]
203 link = struct.unpack_from(endian + "I", data, 20)[0]
204 frames, offset, truncated, snapped = [], 24, False, 0
205 while offset < len(data):
206 if offset + 16 > len(data):
207 truncated = True
208 break
209 incl, orig = struct.unpack_from(endian + "II", data, offset + 8)
210 offset += 16
211 if incl > 262144 * 4 or offset + incl > len(data):
212 truncated = True
213 break
214 snapped += 1 if orig > incl else 0
215 frames.append(data[offset:offset + incl])
216 offset += incl
217 return link, frames, truncated, snapped
218
219
220def _ip_version(frame: bytes, link: int) -> Optional[tuple]:
221 """(ip packet bytes, ethertype) or None."""
222 if link == LINK_ETHERNET:
223 if len(frame) < 14:
224 return None
225 kind, start = struct.unpack_from(">H", frame, 12)[0], 14
226 while kind in (0x8100, 0x88A8) and len(frame) >= start + 4: # VLAN tags
227 kind, start = struct.unpack_from(">H", frame, start + 2)[0], start + 4
228 return frame[start:], kind
229 if link == LINK_SLL:
230 return (frame[16:], struct.unpack_from(">H", frame, 14)[0]) if len(frame) >= 16 else None
231 if link == LINK_SLL2:
232 return (frame[20:], struct.unpack_from(">H", frame, 0)[0]) if len(frame) >= 20 else None
233 if link == LINK_RAW and frame:
234 return frame, {4: 0x0800, 6: 0x86DD}.get(frame[0] >> 4, 0)
235 if link == LINK_NULL and len(frame) >= 4:
236 # the family is written in the capturing host's byte order: little-endian first (every Mac this runs on), then big-endian
237 family = struct.unpack_from("<I", frame, 0)[0]
238 if family not in BSD_FAMILY_ETHERTYPE:
239 family = struct.unpack_from(">I", frame, 0)[0]
240 return frame[4:], BSD_FAMILY_ETHERTYPE.get(family, 0)
241 return None
242
243
244def _ip_text(raw: bytes) -> str:
245 if len(raw) == 4:
246 return ".".join(str(b) for b in raw)
247 return ":".join(f"{raw[i]:02x}{raw[i + 1]:02x}" for i in range(0, 16, 2))
248
249
250def _ip_payload(frame: bytes, link: int, protocol: int) -> Optional[tuple]:
251 """(source address, destination address, the IP payload) of an IPv4 or IPv6 frame that carries `protocol` (6 TCP, 17 UDP), or None."""
252 found = _ip_version(frame, link)
253 if not found:
254 return None
255 ip, kind = found
256 if kind == 0x0800 and len(ip) >= 20 and ip[0] >> 4 == 4:
257 header = (ip[0] & 15) * 4
258 total = struct.unpack_from(">H", ip, 2)[0]
259 fragment = struct.unpack_from(">H", ip, 6)[0] & 0x1FFF
260 if ip[9] != protocol or header < 20 or fragment:
261 return None
262 return _ip_text(ip[12:16]), _ip_text(ip[16:20]), ip[header:total if 0 < total <= len(ip) else len(ip)]
263 if kind == 0x86DD and len(ip) >= 40 and ip[0] >> 4 == 6:
264 length, nxt, start = struct.unpack_from(">H", ip, 4)[0], ip[6], 40
265 while nxt in (0, 43, 60) and len(ip) >= start + 8: # hop-by-hop, routing, destination options
266 nxt, start = ip[start], start + (ip[start + 1] + 1) * 8
267 if nxt != protocol:
268 return None
269 return _ip_text(ip[8:24]), _ip_text(ip[24:40]), ip[start:40 + length if 0 < length else len(ip)]
270 return None
271
272
273def decode_tcp(frame: bytes, link: int) -> Optional[Segment]:
274 found = _ip_payload(frame, link, 6)
275 if not found:
276 return None
277 src, dst, tcp = found
278 if len(tcp) < 20:
279 return None
280 sport, dport, seq = struct.unpack_from(">HHI", tcp, 0)
281 offset = (tcp[12] >> 4) * 4
282 if offset < 20 or offset > len(tcp):
283 return None
284 ack = struct.unpack_from(">I", tcp, 8)[0]
285 return Segment(src, sport, dst, dport, seq, bool(tcp[13] & 0x02), bytes(tcp[offset:]), fin=bool(tcp[13] & TCP_FIN), ack=ack, has_ack=bool(tcp[13] & 0x10))
286
287
288def decode_udp(frame: bytes, link: int) -> Optional[Segment]:
289 """A UDP datagram as a Segment (proto "udp", no sequence number): what the node's resolver sends and receives."""
290 found = _ip_payload(frame, link, 17)
291 if not found:
292 return None
293 src, dst, udp = found
294 if len(udp) < 8:
295 return None
296 sport, dport, length = struct.unpack_from(">HHH", udp, 0)
297 return Segment(src, sport, dst, dport, 0, False, bytes(udp[8:length if 8 <= length <= len(udp) else len(udp)]), proto="udp")
298
299
300# --------------------------------------------------------------------------- reassembly and TLS
301
302def _relative(seq: int, base: int) -> int:
303 rel = (seq - base) & 0xFFFFFFFF
304 return rel - (1 << 32) if rel >= 1 << 31 else rel
305
306
307def reassemble_checked(segments: Sequence[Segment], peer: Sequence[Segment] = ()) -> tuple:
308 """(chunks, problems) for ONE direction of ONE flow: the payload as contiguous byte strings (a gap, i.e. a lost packet, starts a new one), and what says that
309 part of it is missing from the capture: `hole` (a gap between two segments), `head_missing` (the handshake was captured and the first bytes are not),
310 `no_syn` (the capture starts in the middle of the flow), `tail_missing` (the FIN, or what the OTHER side acknowledged, says more bytes were sent than were
311 captured). `peer` is the opposite direction of the same flow: its acknowledgement numbers say how much of this direction arrived."""
312 base, saw_syn = None, False
313 for seg in segments:
314 if seg.syn:
315 base, saw_syn = (seg.seq + 1) & 0xFFFFFFFF, True
316 break
317 if base is None and segments:
318 base = segments[0].seq
319 placed, fin_end = [], None
320 for seg in segments:
321 if seg.fin:
322 fin_end = max(fin_end or 0, _relative(seg.seq, base) + len(seg.payload))
323 if seg.payload:
324 placed.append((_relative(seg.seq, base), seg.payload))
325 placed.sort(key=lambda item: item[0])
326 chunks: list = []
327 first, end = None, None
328 for offset, payload in placed:
329 if end is None or offset > end:
330 chunks.append(bytearray(payload))
331 end = offset + len(payload)
332 first = offset if first is None else first
333 elif offset + len(payload) > end:
334 chunks[-1] += payload[end - offset:]
335 end = offset + len(payload)
336 problems: list = []
337 if len(chunks) > 1:
338 problems.append("hole")
339 if chunks and not saw_syn:
340 problems.append("no_syn")
341 elif chunks and first is not None and first > 0:
342 problems.append("head_missing")
343 got = end or 0
344 if fin_end is not None and fin_end > got:
345 problems.append("tail_missing")
346 elif base is not None:
347 # base is the first payload byte's number: an acknowledgement of `base + n` says n bytes arrived (an ack number of 0 is a hand-built frame's: no information)
348 acked = max((_relative(p.ack, base) for p in peer if p.has_ack and p.ack), default=0)
349 if acked > got + (1 if fin_end is not None else 0):
350 problems.append("tail_missing")
351 return [bytes(c) for c in chunks], problems
352
353
354def reassemble(segments: Sequence[Segment]) -> list:
355 """The payload of ONE direction of ONE flow as contiguous byte strings (a gap, i.e. a lost packet, starts a new one)."""
356 return reassemble_checked(segments)[0]
357
358
359def tls_records(stream: bytes) -> Optional[dict]:
360 """Counts the TLS records at the start of a stream, or None when the stream does not start like TLS."""
361 counts: dict = {}
362 offset = 0
363 while offset + 5 <= len(stream):
364 kind, major, length = stream[offset], stream[offset + 1], struct.unpack_from(">H", stream, offset + 3)[0]
365 if kind not in TLS_TYPES or major != 3 or length > 18432:
366 break
367 counts[TLS_TYPES[kind]] = counts.get(TLS_TYPES[kind], 0) + 1
368 offset += 5 + length
369 return counts or None
370
371
372def client_hello_sni(stream: bytes) -> Optional[str]:
373 """The server name of a TLS ClientHello at the start of a stream (what a router that never decrypts can see), or None."""
374 try:
375 if len(stream) < 6 or stream[0] != 22 or stream[5] != 1:
376 return None
377 pos = 5 + 4 + 2 + 32 # record header, handshake header, version, random
378 pos += 1 + stream[pos] # session id
379 pos += 2 + struct.unpack_from(">H", stream, pos)[0] # cipher suites
380 pos += 1 + stream[pos] # compression methods
381 end = pos + 2 + struct.unpack_from(">H", stream, pos)[0]
382 pos += 2
383 while pos + 4 <= min(end, len(stream)):
384 kind, size = struct.unpack_from(">HH", stream, pos)
385 pos += 4
386 if kind == 0 and size >= 5:
387 name_len = struct.unpack_from(">H", stream, pos + 3)[0]
388 return stream[pos + 5:pos + 5 + name_len].decode("ascii", errors="replace")
389 pos += size
390 except (IndexError, struct.error):
391 return None
392 return None
393
394
395def read_hello(stream: bytes, label: str, report: "PcapReport", hellos: list) -> dict:
396 """Adds what the start of one direction of a flow says in the clear (a ClientHello, a ServerHello, an alert) to the report, and returns it for the flow's row:
397 {"client": ClientHello dict, "server": the first ServerHello dict, "alert": name}. A hello that is cut or not TLS is skipped: the TLS record counts above already show it."""
398 tls = tls_policy()
399 seen: dict = {}
400 try:
401 if len(stream) > 5 and stream[0] == 22 and stream[5] == 1:
402 client = tls.parse_client_hello(stream)
403 if client is not None:
404 seen["client"] = client.as_dict()
405 if len(report.client_hellos) < MAX_HELLOS_LISTED:
406 report.client_hellos.append({"flow": label, **client.as_dict()})
407 return seen
408 observed = tls.observe_server(stream)
409 except tls.TlsParseError:
410 return seen
411 for hello in observed.hellos:
412 hellos.append(hello)
413 seen.setdefault("server", hello.as_dict())
414 if len(report.server_hellos) < MAX_HELLOS_LISTED:
415 report.server_hellos.append({"flow": label, **hello.as_dict()})
416 if observed.alert is not None:
417 seen["alert"] = observed.alert.name
418 if len(report.alerts) < MAX_HELLOS_LISTED:
419 report.alerts.append({"flow": label, "alert": observed.alert.name})
420 return seen
421
422
423def printable(data: bytes, limit: int = 8, hide: Sequence[str] = ()) -> str:
424 text = "".join(chr(b) if 32 <= b < 127 else "." for b in data[:limit])
425 for secret in hide:
426 if secret:
427 text = text.replace(secret, "<redacted>")
428 return text
429
430
431# --------------------------------------------------------------------------- what is searched for, and where
432
433def needle_forms(value: str) -> list:
434 """[(form, bytes)] the ways `value` can show up in a capture: as it is, upper and lower case (a DNS resolver folds case), base64 and URL-safe base64 at the three
435 alignments it can have inside a longer text (only the characters that depend on the marker alone are kept), hex, and UTF-16 either way."""
436 raw = value.encode("utf-8")
437 forms = [("raw", raw)]
438 for name, other in (("lower", raw.lower()), ("upper", raw.upper())):
439 if other != raw:
440 forms.append((name, other))
441 for pad in range(3):
442 encoded = base64.b64encode(b"\0" * pad + raw).rstrip(b"=")
443 needle = encoded[-(-4 * pad // 3):(4 * (pad + len(raw))) // 3]
444 if len(needle) >= 8:
445 forms.append((f"base64+{pad}", needle))
446 forms.append((f"base64url+{pad}", needle.replace(b"+", b"-").replace(b"/", b"_")))
447 forms.append(("hex", raw.hex().encode("ascii")))
448 if raw.hex().lower() != raw.hex().upper():
449 forms.append(("HEX", raw.hex().upper().encode("ascii")))
450 text = value
451 forms.append(("utf16le", text.encode("utf-16-le")))
452 forms.append(("utf16be", text.encode("utf-16-be")))
453 unique, seen = [], set()
454 for name, needle in forms:
455 if needle and needle not in seen:
456 seen.add(needle)
457 unique.append((name, needle))
458 return unique
459
460
461def tls_body_view(stream: bytes) -> Optional[bytes]:
462 """The record bodies of a stream that starts with TLS records, joined: a marker that a sender cut across two records has its halves next to each other here
463 (the five-byte headers between them are gone). None when the stream does not start like TLS."""
464 out, offset, seen = bytearray(), 0, 0
465 while offset + 5 <= len(stream):
466 kind, major, length = stream[offset], stream[offset + 1], struct.unpack_from(">H", stream, offset + 3)[0]
467 if kind not in TLS_TYPES or major != 3 or length > 18432:
468 break
469 out += stream[offset + 5:offset + 5 + length]
470 offset += 5 + length
471 seen += 1
472 return bytes(out) if seen else None
473
474
475COMPRESSED_HEADER = re.compile(rb"\x1f\x8b\x08|\x78[\x01\x5e\x9c\xda]")
476
477
478def inflate_views(data: bytes) -> list:
479 """What the gzip and zlib streams that start anywhere in `data` hold (what could be read before the stream broke counts too: a capture can cut one short)."""
480 views, tries = [], 0
481 for found in COMPRESSED_HEADER.finditer(data):
482 if tries >= MAX_INFLATE_TRIES:
483 break
484 tries += 1
485 inflater = zlib.decompressobj(31 if data[found.start()] == 0x1F else 15)
486 out, pos = bytearray(), found.start()
487 try:
488 while pos < len(data) and len(out) < MAX_VIEW_BYTES and not inflater.eof:
489 out += inflater.decompress(data[pos:pos + 2048], MAX_VIEW_BYTES - len(out))
490 pos += 2048
491 except zlib.error:
492 pass
493 if len(out) >= 8:
494 views.append(bytes(out))
495 return views
496
497
498def base64_views(data: bytes) -> list:
499 """What the base64 (or URL-safe base64) texts inside `data` decode to."""
500 views = []
501 for found in BASE64_RUN.finditer(data):
502 text = found.group(0).rstrip(b"=").replace(b"-", b"+").replace(b"_", b"/")
503 text = text[:len(text) - len(text) % 4 or None] if len(text) % 4 == 1 else text
504 try:
505 views.append(binascii.a2b_base64(text + b"=" * (-len(text) % 4)))
506 except (binascii.Error, ValueError):
507 continue
508 return views
509
510
511def derived_views(data: bytes, depth: int = 2) -> list:
512 """[(name, bytes)] of what `data` holds once it is unwrapped: the TLS record headers taken out, gzip and zlib inflated, base64 decoded; each of those unwrapped
513 once more (a base64 text of a gzip body). Bounded: MAX_VIEW_BYTES of output in all."""
514 out, budget = [], MAX_VIEW_BYTES
515 frontier = [("", data[:MAX_VIEW_BYTES])]
516 for _ in range(depth):
517 nxt = []
518 for prefix, blob in frontier:
519 candidates = []
520 body = tls_body_view(blob) if not prefix else None
521 if body:
522 candidates.append(("tls-records", body))
523 candidates += [("inflate", view) for view in inflate_views(blob)]
524 candidates += [("base64", view) for view in base64_views(blob)]
525 for name, view in candidates:
526 if budget <= 0:
527 return out
528 budget -= len(view)
529 label = f"{prefix}>{name}" if prefix else name
530 out.append((label, view))
531 nxt.append((label, view))
532 frontier = nxt
533 return out
534
535
536def dns_names(payload: bytes) -> list:
537 """The names a DNS message carries in its question and record sections (compression pointers followed), or [] when it is not DNS."""
538 names: list = []
539
540 def read(pos: int) -> tuple:
541 labels, jumped, end, hops = [], False, pos, 0
542 while True:
543 length = payload[pos]
544 if length == 0:
545 return ".".join(labels), (end if jumped else pos + 1)
546 if length & 0xC0 == 0xC0:
547 target = ((length & 0x3F) << 8) | payload[pos + 1]
548 end = end if jumped else pos + 2
549 jumped, pos, hops = True, target, hops + 1
550 if hops > 16:
551 raise IndexError
552 continue
553 labels.append(payload[pos + 1:pos + 1 + length].decode("ascii", errors="replace"))
554 pos += 1 + length
555 if not jumped:
556 end = pos
557
558 try:
559 questions, answers, authority, extra = struct.unpack_from(">HHHH", payload, 4)
560 pos = 12
561 for _ in range(min(questions, 16)):
562 name, pos = read(pos)
563 names.append(name)
564 pos += 4
565 for _ in range(min(answers + authority + extra, 64)):
566 name, pos = read(pos)
567 names.append(name)
568 size = struct.unpack_from(">H", payload, pos + 8)[0]
569 pos += 10 + size
570 except (IndexError, struct.error):
571 pass
572 return [name for name in names if name]
573
574
575def parse_tcpdump_stats(text: str) -> dict:
576 """tcpdump's own counters from what it prints when it ends ("12 packets captured", "12 packets received by filter", "0 packets dropped by kernel", "0 packets
577 dropped by interface"): {"captured", "received", "dropped_kernel", "dropped_interface"}, only the ones the text has."""
578 stats = {}
579 for key, pattern in (("captured", r"(\d+) packets? captured"), ("received", r"(\d+) packets? received by filter"),
580 ("dropped_kernel", r"(\d+) packets? dropped by kernel"), ("dropped_interface", r"(\d+) packets? dropped by interface")):
581 found = re.search(pattern, text or "")
582 if found:
583 stats[key] = int(found.group(1))
584 return stats
585
586
587def stats_problems(stats: dict, packets: int) -> list:
588 """What tcpdump's counters say about a capture of `packets` packets, as [(where, reason)]; empty when they show nothing lost."""
589 problems = []
590 for key in ("dropped_kernel", "dropped_interface"):
591 if stats.get(key):
592 problems.append(("capture counters", f"tcpdump says {stats[key]} packet(s) {key.replace('dropped_', 'dropped by ')}"))
593 if "captured" in stats and stats["captured"] != packets:
594 problems.append(("capture counters", f"tcpdump says it captured {stats['captured']} packet(s) and the file holds {packets}"))
595 if "received" in stats and "captured" in stats and stats["received"] != stats["captured"] and not stats.get("dropped_kernel"):
596 problems.append(("capture counters", f"tcpdump received {stats['received']} packet(s) by the filter and captured {stats['captured']}"))
597 return problems
598
599
600def flow_key(src: str, sport: int, dst: str, dport: int) -> str:
601 """One name for a connection, whichever direction a packet goes."""
602 first, second = sorted([f"{src}:{sport}", f"{dst}:{dport}"])
603 return f"{first} <-> {second}"
604
605
606# --------------------------------------------------------------------------- analysis
607
608def analyse(data: bytes, markers: Sequence[str] = (), *, ports: Optional[Sequence[int]] = None, min_bytes: int = 1, name: str = "",
609 hide: Sequence[str] = (), secret_markers: Sequence[tuple] = (), forms: bool = False, stats: Optional[dict] = None,
610 allow_midflow: bool = False, secrets_are_markers: bool = False) -> PcapReport:
611 """`secret_markers` are (label, value) pairs searched like markers (a leaked secret is a finding) but shown only by their label. `forms` also searches every
612 marker in its encoded forms and in what each stream holds once unwrapped (needle_forms, derived_views). `stats` is what the capture tool said about itself
613 (parse_tcpdump_stats): kernel drops or a packet count that differs from the file's make the capture INCOMPLETE. `allow_midflow` accepts flows whose
614 handshake is not in the capture (a capture that was already running); a hole, a missing tail or a cut packet is never accepted. `secrets_are_markers` lets
615 secrets alone be what is searched for (their values are still never shown): without it a run that was given no marker is NO-MARKERS."""
616 if len(data) > MAX_FILE_BYTES:
617 raise PcapError(f"the capture is {len(data)} bytes; above {MAX_FILE_BYTES} it is not analysed in memory")
618 link, frames, truncated, snapped = read_frames_ex(data)
619 report = PcapReport(file=name, link={LINK_NULL: "bsd-loopback", LINK_ETHERNET: "ethernet", LINK_RAW: "raw-ip", LINK_SLL: "linux-sll",
620 LINK_SLL2: "linux-sll2"}.get(link, f"unsupported({link})"),
621 packets=len(frames), truncated_tail=truncated, min_bytes=min_bytes, stats=dict(stats or {}), secrets_are_markers=secrets_are_markers)
622 if report.link.startswith("unsupported"):
623 raise PcapError(f"link type {link} is not supported (Ethernet, raw IP, BSD loopback and Linux cooked captures are)")
624 wanted = set(ports) if ports else None
625 flows: dict = {} # (src, sport, dst, dport) -> [Segment]
626 segments, datagrams = [], []
627 for frame in frames:
628 seg = decode_tcp(frame, link)
629 if seg is None:
630 seg = decode_udp(frame, link)
631 if seg is None:
632 report.skipped_frames += 1
633 continue
634 if wanted is not None and seg.sport not in wanted and seg.dport not in wanted:
635 continue
636 if seg.proto == "udp":
637 report.udp_packets += 1
638 report.udp_payload_bytes += len(seg.payload)
639 datagrams.append(seg)
640 continue
641 report.tcp_packets += 1
642 segments.append(seg)
643 flows.setdefault((seg.src, seg.sport, seg.dst, seg.dport), []).append(seg)
644 results = [MarkerResult(m) for m in markers] + [MarkerResult(f"<secret: {label}>", secret=True) for label, _ in secret_markers]
645 values = [*markers, *(value for _, value in secret_markers)]
646 needles: list = [] # (result, form, bytes)
647 for result, value in zip(results, values):
648 if not value:
649 raise PcapError("an empty marker would match everything")
650 for form, needle in (needle_forms(value) if forms else [("raw", value.encode("utf-8"))]):
651 needles.append((result, form, needle))
652 for result, form, needle in needles:
653 hits = data.count(needle)
654 if hits:
655 result.raw_hits += hits
656 if form not in result.forms:
657 result.forms.append(form)
658
659 def search(blob: bytes, view: str, where: str, key: str, kind: str) -> None:
660 for result, form, needle in needles:
661 hits = blob.count(needle)
662 if not hits:
663 continue
664 if kind == "packet":
665 result.packet_hits += hits
666 else:
667 result.stream_hits += hits
668 name = form if view == "raw" else f"{view}:{form}"
669 if name not in result.forms:
670 result.forms.append(name)
671 if key not in result.flows and len(result.flows) < MAX_FLOWS_LISTED:
672 result.flows.append(key)
673 if len(result.where) < MAX_EXAMPLES:
674 result.where.append(f"{where} ({hits}x{'' if view == 'raw' else ', ' + view}{'' if form == 'raw' else ', ' + form})")
675
676 connections = set()
677 for seg in segments:
678 report.payload_bytes += len(seg.payload)
679 if seg.payload:
680 search(seg.payload, "raw", f"one packet {seg.src}:{seg.sport} -> {seg.dst}:{seg.dport}", flow_key(seg.src, seg.sport, seg.dst, seg.dport), "packet")
681 low, high = sorted([(seg.src, seg.sport), (seg.dst, seg.dport)])
682 connections.add((low, high))
683 report.flows = len(connections)
684
685 def service_ports(a: int, b: int) -> set:
686 # with a --ports filter: the filtered ports of this connection; without: the lower port is the service, the other an ephemeral one
687 return {p for p in (a, b) if p in wanted} if wanted is not None else {min(a, b)}
688
689 for low, high in connections:
690 for port in service_ports(low[1], high[1]):
691 report.ports.setdefault(port, {"flows": 0, "payload_bytes": 0})["flows"] += 1
692 server_hellos: list = []
693 table: dict = {} # flow_key -> the row of flow_table
694 for (src, sport, dst, dport), segs in flows.items():
695 chunks, problems = reassemble_checked(segs, flows.get((dst, dport, src, sport), ()))
696 if (dst, dport, src, sport) not in flows and any(seg.has_ack and seg.ack for seg in segs):
697 problems.append("direction_missing") # these segments acknowledge bytes of the other direction, which is not in the capture at all (an ack number of 0 is a hand-built frame's)
698 label = f"{src}:{sport} -> {dst}:{dport}"
699 key = flow_key(src, sport, dst, dport)
700 for port in service_ports(sport, dport):
701 report.ports[port]["payload_bytes"] += sum(len(c) for c in chunks)
702 for problem in problems:
703 if problem == "no_syn" and allow_midflow:
704 continue
705 report.incomplete.append({"where": label, "reason": problem})
706 for chunk in chunks:
707 search(chunk, "raw", f"stream {label}", key, "stream")
708 if forms:
709 for view, blob in derived_views(chunk):
710 search(blob, view, f"stream {label}", key, "stream")
711 row = table.setdefault(key, {"flow": key, "client": None, "server": None, "sni": None, "tls": None, "alert": None, "offered": None,
712 "bytes": {}, "tls_records": {}, "server_port": min(sport, dport)})
713 row["bytes"][f"{src}:{sport}"] = sum(len(c) for c in chunks)
714 if chunks:
715 head = chunks[0]
716 counts = tls_records(head)
717 if counts:
718 report.tls_flows += 1
719 for k, v in counts.items():
720 report.tls_records[k] = report.tls_records.get(k, 0) + v
721 row["tls_records"][k] = row["tls_records"].get(k, 0) + v
722 sni = client_hello_sni(head)
723 if sni:
724 report.sni_names[sni] = report.sni_names.get(sni, 0) + 1
725 row["sni"], row["client"], row["server"] = sni, f"{src}:{sport}", f"{dst}:{dport}"
726 seen = read_hello(head, label, report, server_hellos)
727 row["offered"] = seen.get("client") or row["offered"]
728 row["tls"] = seen.get("server") or row["tls"]
729 row["alert"] = seen.get("alert") or row["alert"]
730 elif len(report.non_tls_flows) < 10:
731 report.non_tls_flows.append({"flow": label, "first_bytes": printable(head, hide=hide)})
732 for dgram in datagrams:
733 key = flow_key(dgram.src, dgram.sport, dgram.dst, dgram.dport)
734 where = f"datagram {dgram.src}:{dgram.sport} -> {dgram.dst}:{dgram.dport}"
735 search(dgram.payload, "raw", where, key, "packet")
736 if UDP_DNS_PORT in (dgram.sport, dgram.dport):
737 for dns_name in dns_names(dgram.payload):
738 report.dns_names[dns_name] = report.dns_names.get(dns_name, 0) + 1
739 if forms:
740 search(dns_name.replace(".", "").encode("ascii", errors="replace"), "dns-labels", where, key, "packet")
741 search(dns_name.encode("ascii", errors="replace"), "dns-name", where, key, "packet")
742 report.flow_table = sorted(table.values(), key=lambda row: row["flow"])[:MAX_FLOWS_LISTED]
743 report.tls_summary = tls_policy().summarize(server_hellos)
744 if truncated:
745 report.incomplete.append({"where": "capture file", "reason": "the last record of the file is cut"})
746 if snapped:
747 report.incomplete.append({"where": "capture file", "reason": f"{snapped} packet(s) were cut by the snap length"})
748 for where, reason in stats_problems(report.stats, len(frames)):
749 report.incomplete.append({"where": where, "reason": reason})
750 report.markers = results
751 return report
752
753
754def analyse_file(path: Path, markers: Sequence[str] = (), **kwargs) -> PcapReport:
755 try:
756 data = Path(path).read_bytes()
757 except OSError as exc:
758 raise PcapError(f"cannot read {path}: {exc.strerror or exc}") from None
759 return analyse(data, markers, name=str(path), **kwargs)
760
761
762def describe(report: PcapReport) -> list:
763 """Human lines: the verdict first, then the evidence."""
764 lines = [f"CAPTURE {report.verdict}: " + {
765 "CLEAN": f"{len(report.markers)} marker(s)" + (" (secrets included, never shown)" if any(m.secret for m in report.markers) else "") + f" searched in {report.payload_bytes} payload bytes (raw file, every packet, every "
766 f"reassembled stream): none found",
767 "FOUND": "a marker (or a secret) crossed the capture point in the clear",
768 "NO-TRAFFIC": f"no TCP payload was captured ({report.payload_bytes} bytes < {report.min_bytes}): the absence of a marker proves nothing",
769 "NO-MARKERS": "no marker was given, so nothing was searched for",
770 "INCOMPLETE": f"the capture lost part of the traffic ({len(report.incomplete)} finding(s) below): a marker could have been in the part that is missing, "
771 f"so it proves nothing",
772 }[report.verdict], " " + report.summary()]
773 for finding in report.incomplete[:10]:
774 lines.append(f" incomplete: {finding['where']}: {finding['reason']}")
775 for result in report.markers:
776 state = "FOUND" if result.found else "absent"
777 lines.append(f" marker {result.marker!r}: {state} (raw file {result.raw_hits}, packets {result.packet_hits}, "
778 f"streams {result.stream_hits})" + (f" as {', '.join(result.forms)}" if result.forms else "") + ("; " + "; ".join(result.where) if result.where else ""))
779 for hello in report.server_hellos:
780 lines.append(f" ServerHello {hello['flow']}: {hello['version']} {hello['cipher']} group {hello['group']}" + (" (hello retry request)" if hello["hello_retry_request"] else ""))
781 for alert in report.alerts:
782 lines.append(f" alert {alert['flow']}: {alert['alert']}")
783 for row in report.flow_table:
784 if row["tls"]:
785 lines.append(f" flow {row['flow']}: SNI {row['sni'] or '-'}, {row['tls']['version']} {row['tls']['cipher']} group {row['tls']['group']}")
786 for flow in report.non_tls_flows:
787 lines.append(f" flow that does not start like TLS: {flow['flow']} first bytes {flow['first_bytes']!r}")
788 return lines
789
790
791def main(argv: Optional[Sequence[str]] = None) -> int:
792 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
793 parser.add_argument("file", type=Path)
794 parser.add_argument("--marker", action="append", default=[], help="plaintext marker to look for (repeatable)")
795 parser.add_argument("--ports", default="", help="only flows that use one of these TCP ports (default: all TCP)")
796 parser.add_argument("--min-bytes", type=int, default=1, help="TCP payload bytes the capture must hold to count as traffic")
797 parser.add_argument("--tcpdump-log", type=Path, help="what tcpdump printed when it ended (its packet counters): kernel drops make the capture INCOMPLETE")
798 parser.add_argument("--allow-midflow", action="store_true", help="accept flows whose handshake is not in the capture (a capture that was already running)")
799 parser.add_argument("--exact", action="store_true", help="search the marker as it is only, not in its encoded forms (base64, hex, case, unwrapped records, inflated streams)")
800 parser.add_argument("--json", action="store_true")
801 args = parser.parse_args(argv)
802 try:
803 ports = [int(p) for p in args.ports.split(",") if p.strip()]
804 stats = parse_tcpdump_stats(args.tcpdump_log.read_text(errors="replace")) if args.tcpdump_log else None
805 report = analyse_file(args.file, args.marker, ports=ports or None, min_bytes=args.min_bytes, forms=not args.exact, stats=stats, allow_midflow=args.allow_midflow)
806 except (PcapError, ValueError, OSError) as exc:
807 print(f"ERROR: {exc}", file=sys.stderr)
808 return 2
809 print(json.dumps(report.to_dict(), indent=2) if args.json else "\n".join(describe(report)))
810 return report.exit_code
811
812
813if __name__ == "__main__":
814 sys.exit(main())