Skip to content

Commit 8ff75c0

Browse files
Generate .pcapng files on-demand (kevoreilly#2727)
* Allow .pcapng files to be generated at download-time, in addition to during processing Generating .pcapng files during processing results in double the amount of network data being stored on disk. This is because the original .pcap is required for reprocessing. This change introduces functionality that enables .pcapng files to be generated when the file download is attempted (when the file is required). This brings two benefits: 1. PCAP-NG files aren't generated for every detonation, reducing the amount of disk space used. 2. Reduces processing time. It's worth noting that the tradeoff is that it can be quite expensive to generate the .pcapng in the web thread if the PCAP is significant in size. * Refactor PcaptoNg __init__ function Initialize variables in a more robust fashion. Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Move utils.pcap to lib.cuckoo.common.pcap_utils --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 8a7731c commit 8ff75c0

File tree

5 files changed

+229
-73
lines changed

5 files changed

+229
-73
lines changed

conf/default/web.conf.default

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,6 @@ packages =
223223

224224
[yara_detail]
225225
enabled = no
226+
227+
[pcap_ng]
228+
enabled = no

lib/cuckoo/common/pcap_utils.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import logging
2+
import shutil
3+
import subprocess
4+
import tempfile
5+
from pathlib import Path
6+
7+
from utils.tls import tlslog_to_sslkeylogfile
8+
9+
EDITCAP = "editcap"
10+
EDITCAP_TIMEOUT = 60
11+
12+
log = logging.getLogger(__name__)
13+
14+
15+
def append_file_contents_to_file(file_with_contents: Path, append_to_file: Path):
16+
"""Append the contents of one file to another file.
17+
18+
Args:
19+
file_with_contents: Path to the source file to read from
20+
append_to_file: Path to the destination file to append to
21+
"""
22+
with file_with_contents.open("r") as src, append_to_file.open("a+") as dst:
23+
dst.write(src.read())
24+
25+
26+
def file_exists_not_empty(path: Path | None) -> bool:
27+
"""Check if a file exists and is not empty.
28+
29+
Args:
30+
path: Path to the file to check, or None
31+
32+
Returns:
33+
True if the path is not None, the file exists, and has size > 0, False otherwise
34+
"""
35+
return bool(path and path.exists() and path.stat().st_size > 0)
36+
37+
38+
def generate_pcapng(sslkeylogfile_path: Path, pcap_path: Path, outfile: Path, timeout: int = EDITCAP_TIMEOUT):
39+
"""Generate a pcapng file from a pcap file and SSL key log file using editcap.
40+
41+
Args:
42+
sslkeylogfile_path: Path to the SSL key log file containing TLS decryption keys
43+
pcap_path: Path to the input pcap file
44+
outfile: Path where the output pcapng file should be written
45+
timeout: Maximum time in seconds to wait for editcap to complete (default: EDITCAP_TIMEOUT)
46+
47+
Raises:
48+
EmptyPcapError: If the pcap file doesn't exist or is empty
49+
subprocess.CalledProcessError: If editcap exits with a non-zero status
50+
subprocess.TimeoutExpired: If editcap execution exceeds the timeout
51+
"""
52+
if not file_exists_not_empty(pcap_path):
53+
raise EmptyPcapError(pcap_path)
54+
cmd = [EDITCAP, "--inject-secrets", f"tls,{sslkeylogfile_path}", pcap_path, outfile]
55+
log.debug("generating pcapng with command '%s", cmd)
56+
subprocess.check_call(cmd, timeout=timeout)
57+
58+
59+
def _has_magic(file: str | Path, magic_numbers: tuple[int, ...]) -> bool:
60+
"""Check if a file starts with one of the given magic numbers.
61+
62+
Args:
63+
file: Path to the file to check
64+
magic_numbers: Tuple of magic numbers to check for (as integers in big-endian)
65+
66+
Returns:
67+
True if the file starts with one of the magic numbers, False otherwise
68+
69+
Note:
70+
Magic numbers are read in big-endian byte order (the natural way to represent
71+
hex values). If you need to check files with different byte orders, include
72+
both byte order variations in the magic_numbers tuple.
73+
"""
74+
if not magic_numbers:
75+
return False
76+
77+
max_magic = max(magic_numbers)
78+
magic_byte_len = (max_magic.bit_length() + 7) // 8
79+
80+
try:
81+
with open(file, "rb") as fd:
82+
magic_bytes = fd.read(magic_byte_len)
83+
# Return false if the file is too small to contain the magic number
84+
if len(magic_bytes) < magic_byte_len:
85+
return False
86+
87+
magic_number = int.from_bytes(magic_bytes, byteorder="big")
88+
return magic_number in magic_numbers
89+
except (OSError, IOError):
90+
return False
91+
92+
93+
def is_pcap(file: str | Path) -> bool:
94+
"""Check if a file is a PCAP file by checking its magic number.
95+
96+
PCAP files start with either 0xA1B2C3D4 (big-endian) or 0xD4C3B2A1 (little-endian).
97+
"""
98+
return _has_magic(file, (0xA1B2C3D4, 0xD4C3B2A1))
99+
100+
101+
def is_pcapng(file: str | Path) -> bool:
102+
"""Check if a file is a PCAPNG file by checking its magic number.
103+
104+
PCAPNG files start with 0x0A0D0D0A (Section Header Block magic).
105+
"""
106+
return _has_magic(file, (0x0A0D0D0A,))
107+
108+
109+
class EmptyPcapError(Exception):
110+
"""Exception raised when a pcap file is empty or doesn't exist."""
111+
112+
def __init__(self, pcap_path: Path):
113+
"""Initialize the EmptyPcapError.
114+
115+
Args:
116+
pcap_path: Path to the empty or non-existent pcap file
117+
"""
118+
self.pcap_path = pcap_path
119+
super().__init__(f"pcap file is empty: {pcap_path}")
120+
121+
122+
class PcapToNg:
123+
"""Combine a PCAP, TLS key log and SSL key log into a .pcapng file.
124+
125+
Requires the `editcap` executable."""
126+
127+
def __init__(self, pcap_path: str | Path, tlsdump_log: Path | str | None = None, sslkeys_log: Path | str | None = None):
128+
"""Initialize the PcapToNg converter.
129+
130+
Args:
131+
pcap_path: Path to the source pcap file
132+
tlsdump_log: Optional path to the CAPEMON TLS dump log file
133+
sslkeys_log: Optional path to the SSLKEYLOGFILE format key log
134+
"""
135+
self.pcap_path = Path(pcap_path)
136+
self.pcapng_path = Path(f"{self.pcap_path}ng")
137+
self.tlsdump_log = Path(tlsdump_log) if tlsdump_log else None
138+
self.sslkeys_log = Path(sslkeys_log) if sslkeys_log else None
139+
140+
def generate(self, outfile: Path | str | None = None):
141+
"""Generate a pcapng file by combining the pcap with TLS/SSL key logs.
142+
143+
This method will:
144+
1. Skip generation if the output already exists and is a valid pcapng
145+
2. Combine TLS dump logs and SSL key logs into a temporary file
146+
3. Use editcap to inject the TLS secrets into the pcap to create a pcapng
147+
148+
Args:
149+
outfile: Optional path where the pcapng should be written.
150+
If None, uses the pcap_path with 'ng' suffix.
151+
152+
Note:
153+
Errors are logged but not raised. The method returns silently if:
154+
- The output file already exists
155+
- The input pcap doesn't exist or is empty
156+
- editcap is not found in PATH
157+
- editcap execution fails
158+
"""
159+
if not outfile:
160+
outfile = self.pcapng_path
161+
elif isinstance(outfile, str):
162+
outfile = Path(outfile)
163+
164+
if outfile.exists() and is_pcapng(outfile):
165+
log.debug('pcapng already exists, nothing to do "%s"', outfile)
166+
return
167+
168+
if not self.pcap_path.exists():
169+
log.debug('pcap not found, nothing to do "%s"', self.pcap_path)
170+
return
171+
172+
if self.pcap_path.stat().st_size == 0:
173+
log.debug('pcap is empty, nothing to do "%s"', self.pcap_path)
174+
return
175+
176+
if not shutil.which(EDITCAP):
177+
log.error("%s not in path and is required", EDITCAP)
178+
return
179+
180+
failmsg = "failed to generate .pcapng"
181+
try:
182+
# Combine all TLS logs into a single file in a format that can be read by editcap
183+
with tempfile.NamedTemporaryFile("w", dir=self.pcap_path.parent, encoding="utf-8") as tmp_ssl_key_log:
184+
tmp_ssl_key_log_path = Path(tmp_ssl_key_log.name)
185+
# Write CAPEMON keys
186+
if file_exists_not_empty(self.tlsdump_log):
187+
log.debug("writing tlsdump.log to temp key log file")
188+
tlslog_to_sslkeylogfile(self.tlsdump_log, tmp_ssl_key_log_path)
189+
# Write SSLKEYLOGFILE keys
190+
if file_exists_not_empty(self.sslkeys_log):
191+
log.debug("writing SSLKEYLOGFILE to temp key log file")
192+
append_file_contents_to_file(self.sslkeys_log, tmp_ssl_key_log_path)
193+
generate_pcapng(tmp_ssl_key_log_path, self.pcap_path, outfile)
194+
except subprocess.CalledProcessError as exc:
195+
log.error("%s: editcap exited with code: %d", failmsg, exc.returncode)
196+
except subprocess.TimeoutExpired:
197+
log.error("%s: editcap reached timeout", failmsg)
198+
except (OSError, EmptyPcapError) as exc:
199+
log.error("%s: %s", failmsg, exc)

modules/processing/pcapng.py

Lines changed: 17 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,33 @@
11
import logging
2-
import os
3-
import shutil
4-
import subprocess
5-
import tempfile
62

73
from lib.cuckoo.common.abstracts import Processing
84
from lib.cuckoo.common.objects import File
9-
from lib.cuckoo.common.path_utils import path_exists
10-
from utils.tls import tlslog_to_sslkeylogfile
11-
12-
EDITCAP = "editcap"
13-
EDITCAP_TIMEOUT = 60
5+
from lib.cuckoo.common.pcap_utils import PcapToNg, file_exists_not_empty, is_pcapng
6+
from pathlib import Path
147

158
log = logging.getLogger(__name__)
169

1710

1811
class PcapNg(Processing):
19-
"""Injects TLS keys into a .pcap, resulting in a .pcapng file.
20-
21-
Requires the `editcap` executable."""
12+
"""Generate a pcapng file during processing."""
2213

2314
key = "pcapng"
2415

25-
def set_path(self, analysis_path):
16+
def set_path(self, analysis_path: str) -> None:
2617
"""Set paths.
2718
@param analysis_path: analysis folder path.
2819
"""
2920
super().set_path(analysis_path)
30-
# The file CAPE Monitor logs TLS keys to
31-
self.tlsdump_log = os.path.join(self.analysis_path, "tlsdump", "tlsdump.log")
32-
# The file logged to by libraries that support the SSLKEYLOGFILE env var
33-
self.sslkeys_log = os.path.join(self.analysis_path, "aux/sslkeylogfile", "sslkeys.log")
34-
self.pcapng_path = self.pcap_path + "ng"
35-
36-
def run(self):
37-
retval = {}
38-
39-
if not path_exists(self.pcap_path):
40-
log.debug('pcap not found, nothing to do "%s"', self.pcap_path)
41-
return retval
42-
43-
if os.path.getsize(self.pcap_path) == 0:
44-
log.debug('pcap is empty, nothing to do "%s"', self.pcap_path)
45-
return retval
46-
47-
if not shutil.which(EDITCAP):
48-
log.error("%s not in path and is required", EDITCAP)
49-
return retval
50-
51-
try:
52-
failmsg = "failed to generate .pcapng"
53-
tls_dir = os.path.dirname(self.tlsdump_log)
54-
# Combine all TLS logs into a single file in a format that can be read by editcap
55-
with tempfile.NamedTemporaryFile("w", dir=tls_dir, encoding="utf-8") as dest_ssl_key_log:
56-
# Write CAPEMON keys
57-
if self.file_exists_not_empty(self.tlsdump_log):
58-
log.debug("writing tlsdump.log to temp key log file")
59-
tlslog_to_sslkeylogfile(self.tlsdump_log, dest_ssl_key_log.name)
60-
# Write SSLKEYLOGFILE keys
61-
if self.file_exists_not_empty(self.sslkeys_log):
62-
log.debug("writing SSLKEYLOGFILE to temp key log file")
63-
self.append_file_contents_to_file(self.sslkeys_log, dest_ssl_key_log.name)
64-
self.generate_pcapng(dest_ssl_key_log.name)
65-
retval = {"sha256": File(self.pcapng_path).get_sha256()}
66-
except subprocess.CalledProcessError as exc:
67-
log.error("%s: editcap exited with code: %d", failmsg, exc.returncode)
68-
except subprocess.TimeoutExpired:
69-
log.error("%s: editcap reached timeout", failmsg)
70-
except OSError as exc:
71-
log.error("%s: %s", failmsg, exc)
72-
73-
return retval
74-
75-
def file_exists_not_empty(self, path):
76-
return bool(path_exists(path) and os.path.getsize(path) > 0)
77-
78-
def append_file_contents_to_file(self, file_with_contents, append_to_file):
79-
with open(file_with_contents, "r") as src, open(append_to_file, "a+") as dst:
80-
dst.write(src.read())
81-
82-
def generate_pcapng(self, sslkeylogfile_path):
83-
# ToDo bail if file is empty
84-
cmd = [EDITCAP, "--inject-secrets", "tls," + sslkeylogfile_path, self.pcap_path, self.pcapng_path]
85-
log.debug("generating pcapng with command '%s", cmd)
86-
subprocess.check_call(cmd, timeout=EDITCAP_TIMEOUT)
21+
self.tlsdump_log = Path(analysis_path) / "tlsdump" / "tlsdump.log"
22+
self.sslkeys_log = Path(analysis_path) / "aux" / "sslkeylogfile" / "sslkeys.log"
23+
self.pcapng_path = Path(self.pcap_path + "ng")
24+
25+
def run(self) -> dict[str, str | None]:
26+
PcapToNg(self.pcap_path, self.tlsdump_log, self.sslkeys_log).generate(self.pcapng_path)
27+
if not file_exists_not_empty(self.pcapng_path):
28+
log.warning("pcapng file was not created: %s", self.pcapng_path)
29+
return {}
30+
if not is_pcapng(self.pcapng_path):
31+
log.warning("generated pcapng file is not valid: %s", self.pcapng_path)
32+
return {}
33+
return {"sha256": File(self.pcapng_path).get_sha256()}

web/analysis/views.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
sys.path.append(settings.CUCKOO_PATH)
3030

31+
from lib.cuckoo.common.pcap_utils import PcapToNg
3132
import modules.processing.network as network
3233
from lib.cuckoo.common.config import Config
3334
from lib.cuckoo.common.constants import ANALYSIS_BASE_PATH, CUCKOO_ROOT
@@ -1852,8 +1853,14 @@ def file(request, category, task_id, dlfile):
18521853
path = os.path.join(CUCKOO_ROOT, "storage", "analyses", task_id, "dump.pcap")
18531854
cd = "application/vnd.tcpdump.pcap"
18541855
elif category == "pcapng":
1855-
file_name += ".pcapng"
1856+
analysis_path = os.path.join(CUCKOO_ROOT, "storage", "analyses", task_id)
1857+
pcap_path = os.path.join(analysis_path, "dump.pcap")
1858+
tls_log_path = os.path.join(analysis_path, "tlsdump", "tlsdump.log")
1859+
ssl_key_log_path = os.path.join(analysis_path, "aux", "sslkeylogfile", "sslkeys.log")
18561860
path = os.path.join(CUCKOO_ROOT, "storage", "analyses", task_id, "dump.pcapng")
1861+
pcapng = PcapToNg(pcap_path, tls_log_path, ssl_key_log_path)
1862+
pcapng.generate(path)
1863+
file_name += ".pcapng"
18571864
cd = "application/vnd.tcpdump.pcap"
18581865
elif category == "debugger_log":
18591866
path = os.path.join(CUCKOO_ROOT, "storage", "analyses", task_id, "debugger", str(dlfile) + ".log")

web/templates/analysis/network/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
<div class="alert alert-primary center">
44
<a class="btn btn-secondary btn-sm" href="{% url "file" "pcap" id network.pcap_sha256 %}"><span class="fas fa-download"></span> PCAP</a>
5-
{% if pcapng.sha256 %}
6-
<a class="btn btn-secondary btn-sm" title="PCAP with embedded TLS keys for use in WireShark." href="{% url "file" "pcapng" id pcapng.sha256 %}"><span class="fas fa-download"></span> PCAP-NG</a>
5+
{% if config.pcap_ng %}
6+
<a class="btn btn-secondary btn-sm" title="PCAP with embedded TLS keys for use in WireShark." href="{% url "file" "pcapng" id "dump" %}"><span class="fas fa-download"></span> PCAP-NG</a>
77
{% endif %}
88
<a class="btn btn-secondary btn-sm" href="{% url "file" "pcapzip" id network.pcap_sha256 %}"><span class="fas fa-file-archive"></span><span class="fas fa-download"></span> PCAP</a>
99
{% if tlskeys_exists %}

0 commit comments

Comments
 (0)