diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f8f8e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +*.pyc +# Editor +*\.sw[a-z] +# Setuptools 'develop' target +*.egg-info/ +build/ +dist/ +setuptools-* +venv/ +venv3/ +.pypirc +.notes +*.DS_Store diff --git a/python/README.rst b/python/README.rst new file mode 100644 index 0000000..218cd6b --- /dev/null +++ b/python/README.rst @@ -0,0 +1,54 @@ +pyJA3 +===== +.. image:: https://readthedocs.org/projects/pyja3/badge/?version=latest + :target: http://pyja3.readthedocs.io/en/latest/?badge=latest + +.. image:: https://badge.fury.io/py/pyja3.svg + :target: https://badge.fury.io/py/pyja3 + + +JA3 provides fingerprinting services on SSL packets. This is a python wrapper around JA3 logic in order to produce valid JA3 fingerprints from an input PCAP file. + + +Getting Started +--------------- +1. Install the pyja3 module: + + ``pip install pyja3`` or ``python setup.py install`` + +2. Test with a PCAP file or download a sample: + + $(venv) ja3 --json /your/file.pcap + +Example +------- + +[ + { + "destination_ip": "192.168.1.3", + "destination_port": 443, + "ja3": "769,255-49162-49172-136-135-57-56-49167-49157-132-53-49159-49161-49169-49171-69-68-51-50-49164-49166-49154-49156-150-65-4-5-47-49160-49170-22-19-49165-49155-65279-10,0-10-11-35,23-24-25,0", + "ja3_digest": "2aef69b4ba1938c3a400de4188743185", + "source_ip": "192.168.1.4", + "source_port": 2061, + "timestamp": 1350802591.754299 + }, + { + "destination_ip": "192.168.1.3", + "destination_port": 443, + "ja3": "769,255-49162-49172-136-135-57-56-49167-49157-132-53-49159-49161-49169-49171-69-68-51-50-49164-49166-49154-49156-150-65-4-5-47-49160-49170-22-19-49165-49155-65279-10,0-10-11-35,23-24-25,0", + "ja3_digest": "2aef69b4ba1938c3a400de4188743185", + "source_ip": "192.168.1.4", + "source_port": 2068, + "timestamp": 1350802597.517011 + } +] + +Changelog +--------- +2018-02-05 +~~~~~~~~~~ +* Change: Ported single script to valid Python Package +* Change: Re-factored code to be cleaner and PEP8 compliant +* Change: Supported Python2 and Python3 + diff --git a/python/ja3.py b/python/ja3.py index b52d01b..d2a2037 100644 --- a/python/ja3.py +++ b/python/ja3.py @@ -1,143 +1,176 @@ #!/usr/bin/env python +"""Generate JA3 fingerprints from PCAPs using Python.""" -##### -# Author: Tommy Stallings (tommy.stallings@salesforce.com) -# -# Copyright (c) 2017, salesforce.com, inc. -# All rights reserved. -# Licensed under the BSD 3-Clause license. -# For full license text, see LICENSE.txt file in the repo root -# or https://opensource.org/licenses/BSD-3-Clause -##### - -from hashlib import md5 - -import struct -import traceback -import dpkt -import binascii -import socket import argparse +import dpkt import json +import socket +import struct +from hashlib import md5 -DEBUG = False +__author__ = "Tommy Stallings" +__copyright__ = "Copyright (c) 2017, salesforce.com, inc." +__credits__ = ["John B. Althouse", "Jeff Atkinson", "Josh Atkins"] +__license__ = "BSD 3-Clause License" +__version__ = "1.0.0" +__maintainer__ = "Tommy Stallings, Brandon Dixon" +__email__ = "tommy.stallings@salesforce.com" + + +GREASE_TABLE = {0x0a0a: True, 0x1a1a: True, 0x2a2a: True, 0x3a3a: True, + 0x4a4a: True, 0x5a5a: True, 0x6a6a: True, 0x7a7a: True, + 0x8a8a: True, 0x9a9a: True, 0xaaaa: True, 0xbaba: True, + 0xcaca: True, 0xdada: True, 0xeaea: True, 0xfafa: True} +# GREASE_TABLE Ref: https://tools.ietf.org/html/draft-davidben-tls-grease-00 +SSL_PORT = 443 TLS_HANDSHAKE = 22 -# Well...this is neat -# https://tools.ietf.org/html/draft-davidben-tls-grease-00 -GREASE_table = { - 0x0a0a : True, - 0x1a1a : True, - 0x2a2a : True, - 0x3a3a : True, - 0x4a4a : True, - 0x5a5a : True, - 0x6a6a : True, - 0x7a7a : True, - 0x8a8a : True, - 0x9a9a : True, - 0xaaaa : True, - 0xbaba : True, - 0xcaca : True, - 0xdada : True, - 0xeaea : True, - 0xfafa : True -} - - -def get_pcap_reader(fp): - return dpkt.pcap.Reader(fp) - - -# Borrowed from dpkt.ssl - lightly modified -def parse_variable_array(buf, lenbytes): +def convert_ip(value): + """Convert an IP address from binary to text. + + :param value: Raw binary data to convert + :type value: str + :returns: str + """ + try: + return socket.inet_ntop(socket.AF_INET, value) + except ValueError: + return socket.inet_ntop(socket.AF_INET6, value) + + +def parse_variable_array(buf, byte_len): + """Unpack data from buffer of specific length. + + :param buf: Buffer to operate on + :type buf: bytes + :param byte_len: Length to process + :type byte_len: int + :returns: bytes, int + """ _SIZE_FORMATS = ['!B', '!H', '!I', '!I'] - # first have to figure out how to parse length - assert lenbytes <= 4 # pretty sure 4 is impossible, too - size_format = _SIZE_FORMATS[lenbytes - 1] - padding = b'\x00' if lenbytes == 3 else b'' - # read off the length - size = struct.unpack(size_format, padding + buf[:lenbytes])[0] - # read the actual data - data = buf[lenbytes:lenbytes + size] - # if len(data) != size: insufficient data - return data, size + lenbytes - - -def _ntoh(b): - if len(b) == 1: - return b[0] - elif len(b) == 2: - return struct.unpack('!H', b)[0] - elif len(b) == 4: - return struct.unpack('!I', b)[0] - else: - raise ValueError('Invalid input buffer size for ntoh') - - -def convert_to_ja3_seg(data, element_width): - """Converts a packed array of elements to a JA3 segment. - - Args: - data: string containing the packed elements - element_width: integer width (in bytes) of each element - Raises: - ValueError if len(data) is not a multiple of element_width. + assert byte_len <= 4 + size_format = _SIZE_FORMATS[byte_len - 1] + padding = b'\x00' if byte_len == 3 else b'' + size = struct.unpack(size_format, padding + buf[:byte_len])[0] + data = buf[byte_len:byte_len + size] + + return data, size + byte_len + + +def ntoh(buf): + """Convert to network order. + + :param buf: Bytes to convert + :type buf: bytearray + :returns: int + """ + if len(buf) == 1: + return buf[0] + elif len(buf) == 2: + return struct.unpack('!H', buf)[0] + elif len(buf) == 4: + return struct.unpack('!I', buf)[0] + else: + raise ValueError('Invalid input buffer size for NTOH') + + +def convert_to_ja3_segment(data, element_width): + """Convert a packed array of elements to a JA3 segment. + + :param data: Current PCAP buffer item + :type: str + :param element_width: Byte count to process at a time + :type element_width: int + :returns: str """ - int_vals = [] + int_vals = list() data = bytearray(data) - if len(data) % element_width: - raise ValueError('Element list %d is not a multiple of %d' - % (len(data), element_width)) + message = '{count} is not a multiple of {width}' + message = message.format(count=len(data), width=element_width) + raise ValueError(message) for i in range(0, len(data), element_width): - element = _ntoh(data[i:i+element_width]) - if not element in GREASE_table: - int_vals.append(element) + element = ntoh(data[i: i + element_width]) + if element not in GREASE_TABLE: + int_vals.append(element) return "-".join(str(x) for x in int_vals) -def print_ja3_hashes(cap, any_port=False, print_json=False): +def process_extensions(client_handshake): + """Process any extra extensions and convert to a JA3 segment. - def convert_ip(val): - try: - return socket.inet_ntop(socket.AF_INET, val) - except ValueError: - return socket.inet_ntop(socket.AF_INET6, val) + :param client_handshake: Handshake data from the packet + :type client_handshake: dpkt.ssl.TLSClientHello + :returns: list + """ + if not hasattr(client_handshake, "extensions"): + # Needed to preserve commas on the join + return ["", "", ""] + + exts = list() + elliptic_curve = "" + elliptic_curve_point_format = "" + for ext_val, ext_data in client_handshake.extensions: + if not GREASE_TABLE.get(ext_val): + exts.append(ext_val) + if ext_val == 0x0a: + a, b = parse_variable_array(ext_data, 2) + # Elliptic curve points (16 bit values) + elliptic_curve = convert_to_ja3_segment(a, 2) + elif ext_val == 0x0b: + a, b = parse_variable_array(ext_data, 1) + # Elliptic curve point formats (8 bit values) + elliptic_curve_point_format = convert_to_ja3_segment(a, 1) + else: + continue - for ts, buf in cap: + results = list() + results.append("-".join([str(x) for x in exts])) + results.append(elliptic_curve) + results.append(elliptic_curve_point_format) + return results + +def process_pcap(pcap, any_port=False): + """Process packets within the PCAP. + + :param pcap: Opened PCAP file to be processed + :type pcap: dpkt.pcap.Reader + :param any_port: Whether or not to search for non-SSL ports + :type any_port: bool + """ + results = list() + for timestamp, buf in pcap: try: eth = dpkt.ethernet.Ethernet(buf) - except: + except Exception: continue - # print('pkt: %d' % (pkt_count)) - if not isinstance(eth.data, dpkt.ip.IP): + # We want an IP packet continue - - ip = eth.data - if not isinstance(ip.data, dpkt.tcp.TCP): + if not isinstance(eth.data.data, dpkt.tcp.TCP): + # TCP only continue + ip = eth.data tcp = ip.data - if not (tcp.dport == 443 or tcp.sport == 443 or any_port): + if not (tcp.dport == SSL_PORT or tcp.sport == SSL_PORT or any_port): + # Doesn't match SSL port or we are picky continue - if len(tcp.data) <= 0: continue - # we only care about handshakes for now... tls_handshake = bytearray(tcp.data) if tls_handshake[0] != TLS_HANDSHAKE: continue - records = [] + records = list() + try: records, bytes_used = dpkt.ssl.tls_multi_factory(tcp.data) except dpkt.ssl.SSL3Exception: @@ -149,122 +182,80 @@ def convert_ip(val): continue for record in records: - - # TLS handshake only if record.type != TLS_HANDSHAKE: continue - if len(record.data) == 0: continue - - # Client Hello only client_hello = bytearray(record.data) if client_hello[0] != 1: + # We only want client HELLO continue - - if DEBUG: - print("Hello DATA: %s" % binascii.hexlify(record.data)) - try: handshake = dpkt.ssl.TLSHandshake(record.data) except dpkt.dpkt.NeedData: + # Looking for a handshake here continue - if not isinstance(handshake.data, dpkt.ssl.TLSClientHello): + # Still not the HELLO continue - ch = handshake.data - - if DEBUG: - print("Handshake DATA: %s" % binascii.hexlify(ch.data)) - - buf, ptr = parse_variable_array(ch.data, 1) - buf, ptr = parse_variable_array(ch.data[ptr:], 2) - ja3 = ["%d" % ch.version] + client_handshake = handshake.data + buf, ptr = parse_variable_array(client_handshake.data, 1) + buf, ptr = parse_variable_array(client_handshake.data[ptr:], 2) + ja3 = [str(client_handshake.version)] # Cipher Suites (16 bit values) - ja3.append(convert_to_ja3_seg(buf, 2)) - - if hasattr(ch, "extensions"): - - exts = [] - ec = "" - ec_pf = "" - - for ext_val, ext_data in ch.extensions: - - if not GREASE_table.get(ext_val): - exts.append(ext_val) - - if ext_val == 0x0a: - a, b = parse_variable_array(ext_data, 2) - # Elliptic curve points (16 bit values) - ec = convert_to_ja3_seg(a, 2) - elif ext_val == 0x0b: - a, b = parse_variable_array(ext_data, 1) - # Elliptic curve point formats (8 bit values) - ec_pf = convert_to_ja3_seg(a, 1) - - ja3.append("-".join([str(x) for x in exts])) - ja3.append(ec) - ja3.append(ec_pf) - else: - # No extensions, so no curves or points. - ja3.extend(["", "", ""]) - + ja3.append(convert_to_ja3_segment(buf, 2)) + ja3 += process_extensions(client_handshake) ja3 = ",".join(ja3) - ja_digest = md5(ja3.encode()).hexdigest() - if print_json: - record = {"src":convert_ip(ip.src), "dst":convert_ip(ip.dst), "spt":tcp.sport, "dpt":tcp.dport, "ja3":ja_digest} - print json.dumps(record) - else: - print("[%s:%s] JA3: %s --> %s" % ( - convert_ip(ip.dst), - tcp.dport, - ja3, - ja_digest - )) + record = {"source_ip": convert_ip(ip.src), + "destination_ip": convert_ip(ip.dst), + "source_port": tcp.sport, + "destination_port": tcp.dport, + "ja3": ja3, + "ja3_digest": md5(ja3.encode()).hexdigest(), + "timestamp": timestamp} + results.append(record) + + return results + + +def main(): + """Intake arguments from the user and print out JA3 output.""" + desc = "A python script for extracting JA3 fingerprints from PCAP files" + parser = argparse.ArgumentParser(description=(desc)) + parser.add_argument("pcap", help="The pcap file to process") + help_text = "Look for client hellos on any port instead of just 443" + parser.add_argument("-a", "--any_port", required=False, + action="store_true", default=False, + help=help_text) + help_text = "Print out as JSON records for downstream parsing" + parser.add_argument("-j", "--json", required=False, action="store_true", + default=False, help=help_text) + args = parser.parse_args() -def main(args): - + # Use an iterator to process each line of the file + output = None with open(args.pcap, 'rb') as fp: - capture = get_pcap_reader(fp) - print_ja3_hashes(capture, any_port=args.any_port, print_json=args.print_json) + try: + capture = dpkt.pcap.Reader(fp) + except ValueError as e: + raise Exception("File doesn't appear to be a PCAP: %s" % e) + output = process_pcap(capture, any_port=args.any_port) + + if args.json: + output = json.dumps(output, indent=4, sort_keys=True) + print(output) + else: + for record in output: + tmp = '[{dest}:{port}] JA3: {segment} --> {digest}' + tmp = tmp.format(dest=record['destination_ip'], + port=record['destination_port'], + segment=record['ja3'], + digest=record['ja3_digest']) + print(tmp) if __name__ == "__main__": - - parser = argparse.ArgumentParser( - description=( - "A python script for extracting JA3 fingerprints from pcap files" - ) - ) - - parser.add_argument( - "-a", - "--any_port", - required=False, - action="store_true", - default=False, - help="Look for client hellos on any port instead of just 443" - ) - parser.add_argument( - "-j", - "--json", - required=False, - action="store_true", - default=False, - help="Print out as JSON records for downstream parsing", - dest="print_json" - ) - parser.add_argument( - "pcap", - help="The pcap file to process" - ) - - args = parser.parse_args() - try: - main(args) - except Exception: - traceback.print_exc() + main() diff --git a/python/ja3/__init__.py b/python/ja3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/ja3/ja3.py b/python/ja3/ja3.py new file mode 100644 index 0000000..83974bc --- /dev/null +++ b/python/ja3/ja3.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +"""Generate JA3 fingerprints from PCAPs using Python.""" + +import argparse +import dpkt +import json +import socket +import struct +from hashlib import md5 + +__author__ = "Tommy Stallings" +__copyright__ = "Copyright (c) 2017, salesforce.com, inc." +__credits__ = ["John B. Althouse", "Jeff Atkinson", "Josh Atkins"] +__license__ = "BSD 3-Clause License" +__version__ = "1.0.0" +__maintainer__ = "Tommy Stallings, Brandon Dixon" +__email__ = "tommy.stallings@salesforce.com" + + +GREASE_TABLE = {0x0a0a: True, 0x1a1a: True, 0x2a2a: True, 0x3a3a: True, + 0x4a4a: True, 0x5a5a: True, 0x6a6a: True, 0x7a7a: True, + 0x8a8a: True, 0x9a9a: True, 0xaaaa: True, 0xbaba: True, + 0xcaca: True, 0xdada: True, 0xeaea: True, 0xfafa: True} +# GREASE_TABLE Ref: https://tools.ietf.org/html/draft-davidben-tls-grease-00 +SSL_PORT = 443 +TLS_HANDSHAKE = 22 + + +def convert_ip(value): + """Convert an IP address from binary to text. + + :param value: Raw binary data to convert + :type value: str + :returns: str + """ + try: + return socket.inet_ntop(socket.AF_INET, value) + except ValueError: + return socket.inet_ntop(socket.AF_INET6, value) + + +def parse_variable_array(buf, byte_len): + """Unpack data from buffer of specific length. + + :param buf: Buffer to operate on + :type buf: bytes + :param byte_len: Length to process + :type byte_len: int + :returns: bytes, int + """ + _SIZE_FORMATS = ['!B', '!H', '!I', '!I'] + assert byte_len <= 4 + size_format = _SIZE_FORMATS[byte_len - 1] + padding = b'\x00' if byte_len == 3 else b'' + size = struct.unpack(size_format, padding + buf[:byte_len])[0] + data = buf[byte_len:byte_len + size] + + return data, size + byte_len + + +def ntoh(buf): + """Convert to network order. + + :param buf: Bytes to convert + :type buf: bytearray + :returns: int + """ + if len(buf) == 1: + return buf[0] + elif len(buf) == 2: + return struct.unpack('!H', buf)[0] + elif len(buf) == 4: + return struct.unpack('!I', buf)[0] + else: + raise ValueError('Invalid input buffer size for NTOH') + + +def convert_to_ja3_segment(data, element_width): + """Convert a packed array of elements to a JA3 segment. + + :param data: Current PCAP buffer item + :type: str + :param element_width: Byte count to process at a time + :type element_width: int + :returns: str + """ + int_vals = list() + data = bytearray(data) + if len(data) % element_width: + message = '{count} is not a multiple of {width}' + message = message.format(count=len(data), width=element_width) + raise ValueError(message) + + for i in range(0, len(data), element_width): + element = ntoh(data[i: i + element_width]) + if element not in GREASE_TABLE: + int_vals.append(element) + + return "-".join(str(x) for x in int_vals) + + +def process_extensions(client_handshake): + """Process any extra extensions and convert to a JA3 segment. + + :param client_handshake: Handshake data from the packet + :type client_handshake: dpkt.ssl.TLSClientHello + :returns: list + """ + if not hasattr(client_handshake, "extensions"): + # Needed to preserve commas on the join + return ["", "", ""] + + exts = list() + elliptic_curve = "" + elliptic_curve_point_format = "" + for ext_val, ext_data in client_handshake.extensions: + if not GREASE_TABLE.get(ext_val): + exts.append(ext_val) + if ext_val == 0x0a: + a, b = parse_variable_array(ext_data, 2) + # Elliptic curve points (16 bit values) + elliptic_curve = convert_to_ja3_segment(a, 2) + elif ext_val == 0x0b: + a, b = parse_variable_array(ext_data, 1) + # Elliptic curve point formats (8 bit values) + elliptic_curve_point_format = convert_to_ja3_segment(a, 1) + else: + continue + + results = list() + results.append("-".join([str(x) for x in exts])) + results.append(elliptic_curve) + results.append(elliptic_curve_point_format) + return results + + +def process_pcap(pcap, any_port=False): + """Process packets within the PCAP. + + :param pcap: Opened PCAP file to be processed + :type pcap: dpkt.pcap.Reader + :param any_port: Whether or not to search for non-SSL ports + :type any_port: bool + """ + results = list() + for timestamp, buf in pcap: + try: + eth = dpkt.ethernet.Ethernet(buf) + except Exception: + continue + + if not isinstance(eth.data, dpkt.ip.IP): + # We want an IP packet + continue + if not isinstance(eth.data.data, dpkt.tcp.TCP): + # TCP only + continue + + ip = eth.data + tcp = ip.data + + if not (tcp.dport == SSL_PORT or tcp.sport == SSL_PORT or any_port): + # Doesn't match SSL port or we are picky + continue + if len(tcp.data) <= 0: + continue + + tls_handshake = bytearray(tcp.data) + if tls_handshake[0] != TLS_HANDSHAKE: + continue + + records = list() + + try: + records, bytes_used = dpkt.ssl.tls_multi_factory(tcp.data) + except dpkt.ssl.SSL3Exception: + continue + except dpkt.dpkt.NeedData: + continue + + if len(records) <= 0: + continue + + for record in records: + if record.type != TLS_HANDSHAKE: + continue + if len(record.data) == 0: + continue + client_hello = bytearray(record.data) + if client_hello[0] != 1: + # We only want client HELLO + continue + try: + handshake = dpkt.ssl.TLSHandshake(record.data) + except dpkt.dpkt.NeedData: + # Looking for a handshake here + continue + if not isinstance(handshake.data, dpkt.ssl.TLSClientHello): + # Still not the HELLO + continue + + client_handshake = handshake.data + buf, ptr = parse_variable_array(client_handshake.data, 1) + buf, ptr = parse_variable_array(client_handshake.data[ptr:], 2) + ja3 = [str(client_handshake.version)] + + # Cipher Suites (16 bit values) + ja3.append(convert_to_ja3_segment(buf, 2)) + ja3 += process_extensions(client_handshake) + ja3 = ",".join(ja3) + + record = {"source_ip": convert_ip(ip.src), + "destination_ip": convert_ip(ip.dst), + "source_port": tcp.sport, + "destination_port": tcp.dport, + "ja3": ja3, + "ja3_digest": md5(ja3.encode()).hexdigest(), + "timestamp": timestamp} + results.append(record) + + return results + + +def main(): + """Intake arguments from the user and print out JA3 output.""" + desc = "A python script for extracting JA3 fingerprints from PCAP files" + parser = argparse.ArgumentParser(description=(desc)) + parser.add_argument("pcap", help="The pcap file to process") + help_text = "Look for client hellos on any port instead of just 443" + parser.add_argument("-a", "--any_port", required=False, + action="store_true", default=False, + help=help_text) + help_text = "Print out as JSON records for downstream parsing" + parser.add_argument("-j", "--json", required=False, action="store_true", + default=True, help=help_text) + args = parser.parse_args() + + # Use an iterator to process each line of the file + output = None + with open(args.pcap, 'rb') as fp: + try: + capture = dpkt.pcap.Reader(fp) + except ValueError as e: + raise Exception("File doesn't appear to be a PCAP: %s" % e) + output = process_pcap(capture, any_port=args.any_port) + + if args.json: + output = json.dumps(output, indent=4, sort_keys=True) + print(output) + else: + for record in output: + tmp = '[{dest}:{port}] JA3: {segment} --> {digest}' + tmp = tmp.format(dest=record['destination_ip'], + port=record['destination_port'], + segment=record['ja3'], + digest=record['ja3_digest']) + print(tmp) + + +if __name__ == "__main__": + main() diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..b3c8b3a --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1 @@ +dpkt==1.9.1 diff --git a/python/setup.py b/python/setup.py new file mode 100644 index 0000000..0f04237 --- /dev/null +++ b/python/setup.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +import os +from setuptools import setup, find_packages + + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + +setup( + name='pyja3', + version='1.0.0', + description='Generate JA3 fingerprints from PCAPs using Python.', + url="https://github.com/salesforce/ja3", + author="Tommy Stallings", + author_email="tommy.stallings@salesforce.com", + license="BSD", + packages=find_packages(), + install_requires=['dpkt'], + long_description=read('README.md'), + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries' + ], + package_data={ + 'pyja3': [], + }, + entry_points={ + 'console_scripts': [ + 'ja3 = ja3.ja3:main' + ] + }, + keywords=['ja3', 'fingerprints', 'defender', 'ssl', 'packets'] +) \ No newline at end of file