From 60e4a784384c5094210aeb59ab1bb76d72573e05 Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 00:38:14 +0100 Subject: [PATCH 1/7] First commit of refactor --- .gitignore | 129 +++++++ bin/jarm | 66 ++++ alexa500.txt => data/alexa500.txt | 0 jarm.py | 543 --------------------------- jarm.sh | 17 - jarm/__init__.py | 1 + jarm/jarm.py | 591 ++++++++++++++++++++++++++++++ setup.py | 27 ++ 8 files changed, 814 insertions(+), 560 deletions(-) create mode 100644 .gitignore create mode 100755 bin/jarm rename alexa500.txt => data/alexa500.txt (100%) delete mode 100644 jarm.py delete mode 100644 jarm.sh create mode 100644 jarm/__init__.py create mode 100644 jarm/jarm.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/bin/jarm b/bin/jarm new file mode 100755 index 0000000..266d6ce --- /dev/null +++ b/bin/jarm @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import argparse + +try: + import jarm +except: + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + import jarm + +def main(): + parser = argparse.ArgumentParser(description="Calculate JARM hash for a host or a list of hosts") + parser.add_argument("-p", "--port", type=int, default=443, + help="Specify a port to scan (default 443)") + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("scan", nargs='?', + help="Specify an IP or domain to scan") + target.add_argument("-f", "--file", type=str, + help="Provide a path to a list of IP addresses or domains to scan, " \ + "one per line (optional: Specify port to scan with comma separation, e.g. 8.8.4.4,853)") + output = parser.add_mutually_exclusive_group() + output.add_argument("-c", "--csv", action="store_true", + help="Print results in CSV format") + output.add_argument("-j", "--json", action="store_true", + help="Print results in JSON format") + args = parser.parse_args() + + hosts = [] + if args.file: + if not os.path.exists(args.file): + print(f"ERROR: File does not exist at path {args.file}") + sys.exit(-1) + + with open(args.file, "r") as handle: + for line in handle: + line = line.strip().lower() + if "," in line: + host, port = line.split(",") + else: + host = line + port = args.port + + hosts.append([host, int(port)]) + elif args.scan: + hosts.append([args.scan, args.port]) + + results = [] + for host in hosts: + if not args.csv and not args.json: + print(f"Scanning {host[0]} on port {host[1]}") + + j = jarm.JARM(host[0], host[1]) + j.run() + + if args.csv: + print(f"{host[0]},{host[1]},{j.hash}") + elif args.json: + print(json.dumps({"host": host[0], "port": host[1], "hash": j.hash})) + else: + print(f"JARM hash: {j.hash}") + +if __name__ == "__main__": + main() diff --git a/alexa500.txt b/data/alexa500.txt similarity index 100% rename from alexa500.txt rename to data/alexa500.txt diff --git a/jarm.py b/jarm.py deleted file mode 100644 index c0513b5..0000000 --- a/jarm.py +++ /dev/null @@ -1,543 +0,0 @@ -# Version 1.0 (November 2020) -# -# Created by: -# John Althouse -# Andrew Smart -# RJ Nunaly -# Mike Brady -# -# Converted to Python by: -# Caleb Yu -# -# Copyright (c) 2020, 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 -# -import socket -import struct -import os -import random -import argparse -import hashlib -import ipaddress - -parser = argparse.ArgumentParser(description="Enter an IP address and port to scan.") -group = parser.add_mutually_exclusive_group() -group.add_argument("scan", nargs='?', help="Enter an IP or domain to scan.") -group.add_argument("-i", "--input", help="Provide a list of IP addresses or domains to scan, one domain or IP address per line. Optional: Specify port to scan with comma separation (e.g. 8.8.4.4,853).", type=str) -parser.add_argument("-p", "--port", help="Enter a port to scan (default 443).", type=int) -parser.add_argument("-v", "--verbose", help="Verbose mode: displays the JARM results before being hashed.", action="store_true") -parser.add_argument("-V", "--version", help="Print out version and exit.", action="store_true") -parser.add_argument("-o", "--output", help="Provide a filename to output/append results to a CSV file.", type=str) -args = parser.parse_args() -if args.version: - print("JARM version 1.0") - exit() -if not (args.scan or args.input): - parser.error("A domain/IP to scan or an input file is required.") - -#Randomly choose a grease value -def choose_grease(): - grease_list = [b"\x0a\x0a", b"\x1a\x1a", b"\x2a\x2a", b"\x3a\x3a", b"\x4a\x4a", b"\x5a\x5a", b"\x6a\x6a", b"\x7a\x7a", b"\x8a\x8a", b"\x9a\x9a", b"\xaa\xaa", b"\xba\xba", b"\xca\xca", b"\xda\xda", b"\xea\xea", b"\xfa\xfa"] - return random.choice(grease_list) - -def packet_building(jarm_details): - payload = b"\x16" - #Version Check - if jarm_details[2] == "TLS_1.3": - payload += b"\x03\x01" - client_hello = b"\x03\x03" - elif jarm_details[2] == "SSLv3": - payload += b"\x03\x00" - client_hello = b"\x03\x00" - elif jarm_details[2] == "TLS_1": - payload += b"\x03\x01" - client_hello = b"\x03\x01" - elif jarm_details[2] == "TLS_1.1": - payload += b"\x03\x02" - client_hello = b"\x03\x02" - elif jarm_details[2] == "TLS_1.2": - payload += b"\x03\x03" - client_hello = b"\x03\x03" - #Random values in client hello - client_hello += os.urandom(32) - session_id = os.urandom(32) - session_id_length = struct.pack(">B", len(session_id)) - client_hello += session_id_length - client_hello += session_id - #Get ciphers - cipher_choice = get_ciphers(jarm_details) - client_suites_length = struct.pack(">H", len(cipher_choice)) - client_hello += client_suites_length - client_hello += cipher_choice - client_hello += b"\x01" #cipher methods - client_hello += b"\x00" #compression_methods - #Add extensions to client hello - extensions = get_extensions(jarm_details) - client_hello += extensions - #Finish packet assembly - inner_length = b"\x00" - inner_length += struct.pack(">H", len(client_hello)) - handshake_protocol = b"\x01" - handshake_protocol += inner_length - handshake_protocol += client_hello - outer_length = struct.pack(">H", len(handshake_protocol)) - payload += outer_length - payload += handshake_protocol - return payload - -def get_ciphers(jarm_details): - selected_ciphers = b"" - #Two cipher lists: NO1.3 and ALL - if jarm_details[3] == "ALL": - list = [b"\x00\x16", b"\x00\x33", b"\x00\x67", b"\xc0\x9e", b"\xc0\xa2", b"\x00\x9e", b"\x00\x39", b"\x00\x6b", b"\xc0\x9f", b"\xc0\xa3", b"\x00\x9f", b"\x00\x45", b"\x00\xbe", b"\x00\x88", b"\x00\xc4", b"\x00\x9a", b"\xc0\x08", b"\xc0\x09", b"\xc0\x23", b"\xc0\xac", b"\xc0\xae", b"\xc0\x2b", b"\xc0\x0a", b"\xc0\x24", b"\xc0\xad", b"\xc0\xaf", b"\xc0\x2c", b"\xc0\x72", b"\xc0\x73", b"\xcc\xa9", b"\x13\x02", b"\x13\x01", b"\xcc\x14", b"\xc0\x07", b"\xc0\x12", b"\xc0\x13", b"\xc0\x27", b"\xc0\x2f", b"\xc0\x14", b"\xc0\x28", b"\xc0\x30", b"\xc0\x60", b"\xc0\x61", b"\xc0\x76", b"\xc0\x77", b"\xcc\xa8", b"\x13\x05", b"\x13\x04", b"\x13\x03", b"\xcc\x13", b"\xc0\x11", b"\x00\x0a", b"\x00\x2f", b"\x00\x3c", b"\xc0\x9c", b"\xc0\xa0", b"\x00\x9c", b"\x00\x35", b"\x00\x3d", b"\xc0\x9d", b"\xc0\xa1", b"\x00\x9d", b"\x00\x41", b"\x00\xba", b"\x00\x84", b"\x00\xc0", b"\x00\x07", b"\x00\x04", b"\x00\x05"] - elif jarm_details[3] == "NO1.3": - list = [b"\x00\x16", b"\x00\x33", b"\x00\x67", b"\xc0\x9e", b"\xc0\xa2", b"\x00\x9e", b"\x00\x39", b"\x00\x6b", b"\xc0\x9f", b"\xc0\xa3", b"\x00\x9f", b"\x00\x45", b"\x00\xbe", b"\x00\x88", b"\x00\xc4", b"\x00\x9a", b"\xc0\x08", b"\xc0\x09", b"\xc0\x23", b"\xc0\xac", b"\xc0\xae", b"\xc0\x2b", b"\xc0\x0a", b"\xc0\x24", b"\xc0\xad", b"\xc0\xaf", b"\xc0\x2c", b"\xc0\x72", b"\xc0\x73", b"\xcc\xa9", b"\xcc\x14", b"\xc0\x07", b"\xc0\x12", b"\xc0\x13", b"\xc0\x27", b"\xc0\x2f", b"\xc0\x14", b"\xc0\x28", b"\xc0\x30", b"\xc0\x60", b"\xc0\x61", b"\xc0\x76", b"\xc0\x77", b"\xcc\xa8", b"\xcc\x13", b"\xc0\x11", b"\x00\x0a", b"\x00\x2f", b"\x00\x3c", b"\xc0\x9c", b"\xc0\xa0", b"\x00\x9c", b"\x00\x35", b"\x00\x3d", b"\xc0\x9d", b"\xc0\xa1", b"\x00\x9d", b"\x00\x41", b"\x00\xba", b"\x00\x84", b"\x00\xc0", b"\x00\x07", b"\x00\x04", b"\x00\x05"] - #Change cipher order - if jarm_details[4] != "FORWARD": - list = cipher_mung(list, jarm_details[4]) - #Add GREASE to beginning of cipher list (if applicable) - if jarm_details[5] == "GREASE": - list.insert(0,choose_grease()) - #Generate cipher list - for cipher in list: - selected_ciphers += cipher - return selected_ciphers - -def cipher_mung(ciphers, request): - output = [] - cipher_len = len(ciphers) - #Ciphers backward - if (request == "REVERSE"): - output = ciphers[::-1] - #Bottom half of ciphers - elif (request == "BOTTOM_HALF"): - if (cipher_len % 2 == 1): - output = ciphers[int(cipher_len/2)+1:] - else: - output = ciphers[int(cipher_len/2):] - #Top half of ciphers in reverse order - elif (request == "TOP_HALF"): - if (cipher_len % 2 == 1): - output.append(ciphers[int(cipher_len/2)]) - #Top half gets the middle cipher - output += cipher_mung(cipher_mung(ciphers, "REVERSE"),"BOTTOM_HALF") - #Middle-out cipher order - elif (request == "MIDDLE_OUT"): - middle = int(cipher_len/2) - # if ciphers are uneven, start with the center. Second half before first half - if (cipher_len % 2 == 1): - output.append(ciphers[middle]) - for i in range(1, middle+1): - output.append(ciphers[middle + i]) - output.append(ciphers[middle - i]) - else: - for i in range(1, middle+1): - output.append(ciphers[middle-1 + i]) - output.append(ciphers[middle - i]) - return output - -def get_extensions(jarm_details): - extension_bytes = b"" - all_extensions = b"" - grease = False - #GREASE - if jarm_details[5] == "GREASE": - all_extensions += choose_grease() - all_extensions += b"\x00\x00" - grease = True - #Server name - all_extensions += extension_server_name(jarm_details[0]) - #Other extensions - extended_master_secret = b"\x00\x17\x00\x00" - all_extensions += extended_master_secret - max_fragment_length = b"\x00\x01\x00\x01\x01" - all_extensions += max_fragment_length - renegotiation_info = b"\xff\x01\x00\x01\x00" - all_extensions += renegotiation_info - supported_groups = b"\x00\x0a\x00\x0a\x00\x08\x00\x1d\x00\x17\x00\x18\x00\x19" - all_extensions += supported_groups - ec_point_formats = b"\x00\x0b\x00\x02\x01\x00" - all_extensions += ec_point_formats - session_ticket = b"\x00\x23\x00\x00" - all_extensions += session_ticket - #Application Layer Protocol Negotiation extension - all_extensions += app_layer_proto_negotiation(jarm_details) - signature_algorithms = b"\x00\x0d\x00\x14\x00\x12\x04\x03\x08\x04\x04\x01\x05\x03\x08\x05\x05\x01\x08\x06\x06\x01\x02\x01" - all_extensions += signature_algorithms - #Key share extension - all_extensions += key_share(grease) - psk_key_exchange_modes = b"\x00\x2d\x00\x02\x01\x01" - all_extensions += psk_key_exchange_modes - #Supported versions extension - if (jarm_details[2] == "TLS_1.3") or (jarm_details[7] == "1.2_SUPPORT"): - all_extensions += supported_versions(jarm_details, grease) - #Finish assembling extensions - extension_length = len(all_extensions) - extension_bytes += struct.pack(">H", extension_length) - extension_bytes += all_extensions - return extension_bytes - -#Client hello server name extension -def extension_server_name(host): - ext_sni = b"\x00\x00" - ext_sni_length = len(host)+5 - ext_sni += struct.pack(">H", ext_sni_length) - ext_sni_length2 = len(host)+3 - ext_sni += struct.pack(">H", ext_sni_length2) - ext_sni += b"\x00" - ext_sni_length3 = len(host) - ext_sni += struct.pack(">H", ext_sni_length3) - ext_sni += host.encode() - return ext_sni - -#Client hello apln extension -def app_layer_proto_negotiation(jarm_details): - ext = b"\x00\x10" - if (jarm_details[6] == "RARE_APLN"): - #Removes h2 and http/1.1 - alpns = [b"\x08\x68\x74\x74\x70\x2f\x30\x2e\x39", b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x30", b"\x06\x73\x70\x64\x79\x2f\x31", b"\x06\x73\x70\x64\x79\x2f\x32", b"\x06\x73\x70\x64\x79\x2f\x33", b"\x03\x68\x32\x63", b"\x02\x68\x71"] - else: - #All apln extensions in order from weakest to strongest - alpns = [b"\x08\x68\x74\x74\x70\x2f\x30\x2e\x39", b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x30", b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x31", b"\x06\x73\x70\x64\x79\x2f\x31", b"\x06\x73\x70\x64\x79\x2f\x32", b"\x06\x73\x70\x64\x79\x2f\x33" b"\x02\x68\x32", b"\x03\x68\x32\x63", b"\x02\x68\x71"] - #apln extensions can be reordered - if jarm_details[8] != "FORWARD": - alpns = cipher_mung(alpns, jarm_details[8]) - all_alpns = b"" - for alpn in alpns: - all_alpns += alpn - second_length = len(all_alpns) - first_length = second_length+2 - ext += struct.pack(">H", first_length) - ext += struct.pack(">H", second_length) - ext += all_alpns - return ext - -#Generate key share extension for client hello -def key_share(grease): - ext = b"\x00\x33" - #Add grease value if necessary - if grease == True: - share_ext = choose_grease() - share_ext += b"\x00\x01\x00" - else: - share_ext = b"" - group = b"\x00\x1d" - share_ext += group - key_exchange_length = b"\x00\x20" - share_ext += key_exchange_length - share_ext += os.urandom(32) - second_length = len(share_ext) - first_length = second_length+2 - ext += struct.pack(">H", first_length) - ext += struct.pack(">H", second_length) - ext += share_ext - return ext - -#Supported version extension for client hello -def supported_versions(jarm_details, grease): - if (jarm_details[7] == "1.2_SUPPORT"): - #TLS 1.3 is not supported - tls = [b"\x03\x01", b"\x03\x02", b"\x03\x03"] - else: - #TLS 1.3 is supported - tls = [b"\x03\x01", b"\x03\x02", b"\x03\x03", b"\x03\x04"] - #Change supported version order, by default, the versions are from oldest to newest - if jarm_details[8] != "FORWARD": - tls = cipher_mung(tls, jarm_details[8]) - #Assemble the extension - ext = b"\x00\x2b" - #Add GREASE if applicable - if grease == True: - versions = choose_grease() - else: - versions = b"" - for version in tls: - versions += version - second_length = len(versions) - first_length = second_length+1 - ext += struct.pack(">H", first_length) - ext += struct.pack(">B", second_length) - ext += versions - return ext - -#Send the assembled client hello using a socket -def send_packet(packet): - try: - #Determine if the input is an IP or domain name - try: - if (type(ipaddress.ip_address(destination_host)) == ipaddress.IPv4Address) or (type(ipaddress.ip_address(destination_host)) == ipaddress.IPv6Address): - raw_ip = True - ip = (destination_host, destination_port) - except ValueError as e: - ip = (None, None) - raw_ip = False - #Connect the socket - if ":" in destination_host: - sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - #Timeout of 20 seconds - sock.settimeout(20) - sock.connect((destination_host, destination_port, 0, 0)) - else: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - #Timeout of 20 seconds - sock.settimeout(20) - sock.connect((destination_host, destination_port)) - #Resolve IP if given a domain name - if raw_ip == False: - ip = sock.getpeername() - sock.sendall(packet) - #Receive server hello - data = sock.recv(1484) - #Close socket - sock.shutdown(socket.SHUT_RDWR) - sock.close() - return data, ip[0] - #Timeout errors result in an empty hash - except (TimeoutError,socket.timeout) as e: - sock.close() - return "TIMEOUT", ip[0] - except Exception as e: - sock.close() - return None, ip[0] - -#If a packet is received, decipher the details -def read_packet(data, jarm_details): - try: - if data == None: - return "|||" - jarm = "" - #Server hello error - if data[0] == 21: - selected_cipher = b"" - return "|||" - #Check for server hello - elif (data[0] == 22) and (data[5] == 2): - counter = data[43] - #Find server's selected cipher - selected_cipher = data[counter+44:counter+46] - #Find server's selected version - version = data[9:11] - #Format - jarm += str(selected_cipher.hex()) - jarm += "|" - jarm += str(version.hex()) - jarm += "|" - #Extract extensions - extensions = (extract_extension_info(data, counter)) - jarm += extensions - return jarm - else: - return "|||" - - except Exception as e: - return "|||" - -#Deciphering the extensions in the server hello -def extract_extension_info(data, counter): - try: - #Error handling - if (data[counter+47] == 11): - return "|||" - elif (data[counter+50:counter+53] == b"\x0e\xac\x0b") or (data[82:85] == b"\x0f\xf0\x0b"): - return "|||" - count = 49+counter - length = int.from_bytes(data[counter+47:counter+49], byteorder='big') - maximum = length+(count-1) - types = [] - values = [] - #Collect all extension types and values for later reference - while count < maximum: - types.append(data[count:count+2]) - ext_length = int.from_bytes(data[count+2:count+4], byteorder='big') - if ext_length == 0: - count += 4 - values.append("") - else: - values.append(data[count+4:count+4+ext_length]) - count += ext_length+4 - result = "" - #Read application_layer_protocol_negotiation - alpn = find_extension(b"\x00\x10", types, values) - result += str(alpn) - result += "|" - #Add formating hyphens - add_hyphen = 0 - while add_hyphen < len(types): - result += types[add_hyphen].hex() - add_hyphen += 1 - if add_hyphen == len(types): - break - else: - result += "-" - return result - #Error handling - except IndexError as e: - result = "|||" - return result - -#Matching cipher extensions to values -def find_extension(ext_type, types, values): - iter = 0 - #For the APLN extension, grab the value in ASCII - if ext_type == b"\x00\x10": - while iter < len(types): - if types[iter] == ext_type: - return ((values[iter][3:]).decode()) - iter += 1 - else: - while iter < len(types): - if types[iter] == ext_type: - return values[iter].hex() - iter += 1 - return "" - -#Custom fuzzy hash -def jarm_hash(jarm_raw): - #If jarm is empty, 62 zeros for the hash - if jarm_raw == "|||,|||,|||,|||,|||,|||,|||,|||,|||,|||": - return "0"*62 - fuzzy_hash = "" - handshakes = jarm_raw.split(",") - alpns_and_ext = "" - for handshake in handshakes: - components = handshake.split("|") - #Custom jarm hash includes a fuzzy hash of the ciphers and versions - fuzzy_hash += cipher_bytes(components[0]) - fuzzy_hash += version_byte(components[1]) - alpns_and_ext += components[2] - alpns_and_ext += components[3] - #Custom jarm hash has the sha256 of alpns and extensions added to the end - sha256 = (hashlib.sha256(alpns_and_ext.encode())).hexdigest() - fuzzy_hash += sha256[0:32] - return fuzzy_hash - -#Fuzzy hash for ciphers is the index number (in hex) of the cipher in the list -def cipher_bytes(cipher): - if cipher == "": - return "00" - list = [b"\x00\x04", b"\x00\x05", b"\x00\x07", b"\x00\x0a", b"\x00\x16", b"\x00\x2f", b"\x00\x33", b"\x00\x35", b"\x00\x39", b"\x00\x3c", b"\x00\x3d", b"\x00\x41", b"\x00\x45", b"\x00\x67", b"\x00\x6b", b"\x00\x84", b"\x00\x88", b"\x00\x9a", b"\x00\x9c", b"\x00\x9d", b"\x00\x9e", b"\x00\x9f", b"\x00\xba", b"\x00\xbe", b"\x00\xc0", b"\x00\xc4", b"\xc0\x07", b"\xc0\x08", b"\xc0\x09", b"\xc0\x0a", b"\xc0\x11", b"\xc0\x12", b"\xc0\x13", b"\xc0\x14", b"\xc0\x23", b"\xc0\x24", b"\xc0\x27", b"\xc0\x28", b"\xc0\x2b", b"\xc0\x2c", b"\xc0\x2f", b"\xc0\x30", b"\xc0\x60", b"\xc0\x61", b"\xc0\x72", b"\xc0\x73", b"\xc0\x76", b"\xc0\x77", b"\xc0\x9c", b"\xc0\x9d", b"\xc0\x9e", b"\xc0\x9f", b"\xc0\xa0", b"\xc0\xa1", b"\xc0\xa2", b"\xc0\xa3", b"\xc0\xac", b"\xc0\xad", b"\xc0\xae", b"\xc0\xaf", b'\xcc\x13', b'\xcc\x14', b'\xcc\xa8', b'\xcc\xa9', b'\x13\x01', b'\x13\x02', b'\x13\x03', b'\x13\x04', b'\x13\x05'] - count = 1 - for bytes in list: - strtype_bytes = str(bytes.hex()) - if cipher == strtype_bytes: - break - count += 1 - hexvalue = str(hex(count))[2:] - #This part must always be two bytes - if len(hexvalue) < 2: - return_bytes = "0" + hexvalue - else: - return_bytes = hexvalue - return return_bytes - -#This captures a single version byte based on version -def version_byte(version): - if version == "": - return "0" - options = "abcdef" - count = int(version[3:4]) - byte = options[count] - return byte - -def main(): - #Select the packets and formats to send - #Array format = [destination_host,destination_port,version,cipher_list,cipher_order,GREASE,RARE_APLN,1.3_SUPPORT,extension_orders] - tls1_2_forward = [destination_host, destination_port, "TLS_1.2", "ALL", "FORWARD", "NO_GREASE", "APLN", "1.2_SUPPORT", "REVERSE"] - tls1_2_reverse = [destination_host, destination_port, "TLS_1.2", "ALL", "REVERSE", "NO_GREASE", "APLN", "1.2_SUPPORT", "FORWARD"] - tls1_2_top_half = [destination_host, destination_port, "TLS_1.2", "ALL", "TOP_HALF", "NO_GREASE", "APLN", "NO_SUPPORT", "FORWARD"] - tls1_2_bottom_half = [destination_host, destination_port, "TLS_1.2", "ALL", "BOTTOM_HALF", "NO_GREASE", "RARE_APLN", "NO_SUPPORT", "FORWARD"] - tls1_2_middle_out = [destination_host, destination_port, "TLS_1.2", "ALL", "MIDDLE_OUT", "GREASE", "RARE_APLN", "NO_SUPPORT", "REVERSE"] - tls1_1_middle_out = [destination_host, destination_port, "TLS_1.1", "ALL", "FORWARD", "NO_GREASE", "APLN", "NO_SUPPORT", "FORWARD"] - tls1_3_forward = [destination_host, destination_port, "TLS_1.3", "ALL", "FORWARD", "NO_GREASE", "APLN", "1.3_SUPPORT", "REVERSE"] - tls1_3_reverse = [destination_host, destination_port, "TLS_1.3", "ALL", "REVERSE", "NO_GREASE", "APLN", "1.3_SUPPORT", "FORWARD"] - tls1_3_invalid = [destination_host, destination_port, "TLS_1.3", "NO1.3", "FORWARD", "NO_GREASE", "APLN", "1.3_SUPPORT", "FORWARD"] - tls1_3_middle_out = [destination_host, destination_port, "TLS_1.3", "ALL", "MIDDLE_OUT", "GREASE", "APLN", "1.3_SUPPORT", "REVERSE"] - #Possible versions: SSLv3, TLS_1, TLS_1.1, TLS_1.2, TLS_1.3 - #Possible cipher lists: ALL, NO1.3 - #GREASE: either NO_GREASE or GREASE - #APLN: either APLN or RARE_APLN - #Supported Verisons extension: 1.2_SUPPPORT, NO_SUPPORT, or 1.3_SUPPORT - #Possible Extension order: FORWARD, REVERSE - queue = [tls1_2_forward, tls1_2_reverse, tls1_2_top_half, tls1_2_bottom_half, tls1_2_middle_out, tls1_1_middle_out, tls1_3_forward, tls1_3_reverse, tls1_3_invalid, tls1_3_middle_out] - jarm = "" - #Assemble, send, and decipher each packet - iterate = 0 - while iterate < len(queue): - payload = packet_building(queue[iterate]) - server_hello, ip = send_packet(payload) - #Deal with timeout error - if server_hello == "TIMEOUT": - jarm = "|||,|||,|||,|||,|||,|||,|||,|||,|||,|||" - break - ans = read_packet(server_hello, queue[iterate]) - jarm += ans - iterate += 1 - if iterate == len(queue): - break - else: - jarm += "," - #Fuzzy hash - result = jarm_hash(jarm) - #Write to file - if args.output: - if ip != None: - file.write(destination_host + "," + ip + "," + result) - else: - file.write(destination_host + ",Failed to resolve IP," + result) - #Verbose mode adds pre-fuzzy-hashed JARM - if args.verbose: - file.write("," + jarm) - file.write("\n") - #Print to STDOUT - else: - if ip != None: - print("Domain: " + destination_host) - print("Resolved IP: " + ip) - print("JARM: " + result) - else: - print("Domain: " + destination_host) - print("Resolved IP: IP failed to resolve.") - print("JARM: " + result) - #Verbose mode adds pre-fuzzy-hashed JARM - if args.verbose: - scan_count = 1 - for round in jarm.split(","): - print("Scan " + str(scan_count) + ": " + round, end="") - if scan_count == len(jarm.split(",")): - print("\n",end="") - else: - print(",") - scan_count += 1 - - -#Set destination host and port -destination_host = args.scan -if args.port: - destination_port = int(args.port) -else: - destination_port = 443 -#File output option -if args.output: - if args.output[-4:] != ".csv": - output_file = args.output + ".csv" - else: - output_file = args.output - file = open(output_file, "a+") -if args.input: - input_file = open(args.input, "r") - entries = input_file.readlines() - for entry in entries: - port_check = entry.split(",") - if len(port_check) == 2: - destination_port = int(port_check[1][:-1]) - destination_host = port_check[0] - else: - destination_host = entry[:-1] - main() -else: - main() -#Close files -if args.output: - file.close() diff --git a/jarm.sh b/jarm.sh deleted file mode 100644 index 0c901e8..0000000 --- a/jarm.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -if [ ! $# -eq 2 ] - then - echo "Two arguments required: (1) a list of IPs/domains in a" - echo "file, separated by line and (2) an output file name." - echo "Example: ./jarm.sh alexa500.txt jarm_alexa_500.csv" - exit 1 - fi - -input=$1 -while IFS= read -r line -do - python3 jarm.py $line -v -o $2 & -done < "$input" - -wait diff --git a/jarm/__init__.py b/jarm/__init__.py new file mode 100644 index 0000000..e158534 --- /dev/null +++ b/jarm/__init__.py @@ -0,0 +1 @@ +from .jarm import JARM diff --git a/jarm/jarm.py b/jarm/jarm.py new file mode 100644 index 0000000..6f99186 --- /dev/null +++ b/jarm/jarm.py @@ -0,0 +1,591 @@ +import os +import socket +import struct +import random +import hashlib +import argparse +import ipaddress + +class JARM: + + def __init__(self, host, port=443): + self.destination_host = host + self.destination_port = port + self._jarm_raw = "" + self._jarm_hash = "" + + @property + def raw(self): + return self._jarm_raw + + @property + def hash(self): + return self._jarm_hash + + def _choose_grease(self): + """Return a random grease value. + """ + grease_list = [b"\x0a\x0a", b"\x1a\x1a", b"\x2a\x2a", b"\x3a\x3a", + b"\x4a\x4a", b"\x5a\x5a", b"\x6a\x6a", b"\x7a\x7a", + b"\x8a\x8a", b"\x9a\x9a", b"\xaa\xaa", b"\xba\xba", + b"\xca\xca", b"\xda\xda", b"\xea\xea", b"\xfa\xfa"] + return random.choice(grease_list) + + def _packet_building(self, jarm_details): + payload = b"\x16" + + # Version check. + if jarm_details[2] == "TLS_1.3": + payload += b"\x03\x01" + client_hello = b"\x03\x03" + elif jarm_details[2] == "SSLv3": + payload += b"\x03\x00" + client_hello = b"\x03\x00" + elif jarm_details[2] == "TLS_1": + payload += b"\x03\x01" + client_hello = b"\x03\x01" + elif jarm_details[2] == "TLS_1.1": + payload += b"\x03\x02" + client_hello = b"\x03\x02" + elif jarm_details[2] == "TLS_1.2": + payload += b"\x03\x03" + client_hello = b"\x03\x03" + + # Random values in client hello. + client_hello += os.urandom(32) + session_id = os.urandom(32) + session_id_length = struct.pack(">B", len(session_id)) + client_hello += session_id_length + client_hello += session_id + + # Get ciphers. + cipher_choice = self._get_ciphers(jarm_details) + client_suites_length = struct.pack(">H", len(cipher_choice)) + client_hello += client_suites_length + client_hello += cipher_choice + # Cipher methods. + client_hello += b"\x01" + # Compression methods. + client_hello += b"\x00" + + # Add extensions to client hello. + extensions = self._get_extensions(jarm_details) + client_hello += extensions + + # Finish packet assembly. + inner_length = b"\x00" + inner_length += struct.pack(">H", len(client_hello)) + handshake_protocol = b"\x01" + handshake_protocol += inner_length + handshake_protocol += client_hello + outer_length = struct.pack(">H", len(handshake_protocol)) + payload += outer_length + payload += handshake_protocol + + return payload + + def _get_ciphers(self, jarm_details): + selected_ciphers = b"" + + # Two cipher lists: NO1.3 and ALL. + if jarm_details[3] == "ALL": + cipher_lists = [b"\x00\x16", b"\x00\x33", b"\x00\x67", b"\xc0\x9e", + b"\xc0\xa2", b"\x00\x9e", b"\x00\x39", b"\x00\x6b", + b"\xc0\x9f", b"\xc0\xa3", b"\x00\x9f", b"\x00\x45", + b"\x00\xbe", b"\x00\x88", b"\x00\xc4", b"\x00\x9a", + b"\xc0\x08", b"\xc0\x09", b"\xc0\x23", b"\xc0\xac", + b"\xc0\xae", b"\xc0\x2b", b"\xc0\x0a", b"\xc0\x24", + b"\xc0\xad", b"\xc0\xaf", b"\xc0\x2c", b"\xc0\x72", + b"\xc0\x73", b"\xcc\xa9", b"\x13\x02", b"\x13\x01", + b"\xcc\x14", b"\xc0\x07", b"\xc0\x12", b"\xc0\x13", + b"\xc0\x27", b"\xc0\x2f", b"\xc0\x14", b"\xc0\x28", + b"\xc0\x30", b"\xc0\x60", b"\xc0\x61", b"\xc0\x76", + b"\xc0\x77", b"\xcc\xa8", b"\x13\x05", b"\x13\x04", + b"\x13\x03", b"\xcc\x13", b"\xc0\x11", b"\x00\x0a", + b"\x00\x2f", b"\x00\x3c", b"\xc0\x9c", b"\xc0\xa0", + b"\x00\x9c", b"\x00\x35", b"\x00\x3d", b"\xc0\x9d", + b"\xc0\xa1", b"\x00\x9d", b"\x00\x41", b"\x00\xba", + b"\x00\x84", b"\x00\xc0", b"\x00\x07", b"\x00\x04", + b"\x00\x05"] + elif jarm_details[3] == "NO1.3": + cipher_lists = [b"\x00\x16", b"\x00\x33", b"\x00\x67", b"\xc0\x9e", + b"\xc0\xa2", b"\x00\x9e", b"\x00\x39", b"\x00\x6b", + b"\xc0\x9f", b"\xc0\xa3", b"\x00\x9f", b"\x00\x45", + b"\x00\xbe", b"\x00\x88", b"\x00\xc4", b"\x00\x9a", + b"\xc0\x08", b"\xc0\x09", b"\xc0\x23", b"\xc0\xac", + b"\xc0\xae", b"\xc0\x2b", b"\xc0\x0a", b"\xc0\x24", + b"\xc0\xad", b"\xc0\xaf", b"\xc0\x2c", b"\xc0\x72", + b"\xc0\x73", b"\xcc\xa9", b"\xcc\x14", b"\xc0\x07", + b"\xc0\x12", b"\xc0\x13", b"\xc0\x27", b"\xc0\x2f", + b"\xc0\x14", b"\xc0\x28", b"\xc0\x30", b"\xc0\x60", + b"\xc0\x61", b"\xc0\x76", b"\xc0\x77", b"\xcc\xa8", + b"\xcc\x13", b"\xc0\x11", b"\x00\x0a", b"\x00\x2f", + b"\x00\x3c", b"\xc0\x9c", b"\xc0\xa0", b"\x00\x9c", + b"\x00\x35", b"\x00\x3d", b"\xc0\x9d", b"\xc0\xa1", + b"\x00\x9d", b"\x00\x41", b"\x00\xba", b"\x00\x84", + b"\x00\xc0", b"\x00\x07", b"\x00\x04", b"\x00\x05"] + + # Change cipher order. + if jarm_details[4] != "FORWARD": + cipher_lists = self._cipher_mung(cipher_lists, jarm_details[4]) + + # Add GREASE to beginning of cipher list (if applicable). + if jarm_details[5] == "GREASE": + cipher_lists.insert(0, self._choose_grease()) + + # Generate cipher list. + for cipher in cipher_lists: + selected_ciphers += cipher + + return selected_ciphers + + def _cipher_mung(self, ciphers, request): + output = [] + cipher_len = len(ciphers) + + # Ciphers backward. + if (request == "REVERSE"): + output = ciphers[::-1] + # Bottom half of ciphers. + elif (request == "BOTTOM_HALF"): + if (cipher_len % 2 == 1): + output = ciphers[int(cipher_len/2)+1:] + else: + output = ciphers[int(cipher_len/2):] + # Top half of ciphers in reverse order. + elif (request == "TOP_HALF"): + if (cipher_len % 2 == 1): + output.append(ciphers[int(cipher_len/2)]) + #Top half gets the middle cipher + + output += self._cipher_mung(self._cipher_mung(ciphers, "REVERSE"), + "BOTTOM_HALF") + # Middle-out cipher order. + elif (request == "MIDDLE_OUT"): + middle = int(cipher_len/2) + # If ciphers are uneven, start with the center. + # Second half before first half. + if (cipher_len % 2 == 1): + output.append(ciphers[middle]) + for i in range(1, middle+1): + output.append(ciphers[middle + i]) + output.append(ciphers[middle - i]) + else: + for i in range(1, middle+1): + output.append(ciphers[middle-1 + i]) + output.append(ciphers[middle - i]) + + return output + + def _get_extensions(self, jarm_details): + extension_bytes = b"" + all_extensions = b"" + grease = False + if jarm_details[5] == "GREASE": + all_extensions += self._choose_grease() + all_extensions += b"\x00\x00" + grease = True + + # Server name. + all_extensions += self._extension_server_name(jarm_details[0]) + + # Other extensions. + extended_master_secret = b"\x00\x17\x00\x00" + all_extensions += extended_master_secret + max_fragment_length = b"\x00\x01\x00\x01\x01" + all_extensions += max_fragment_length + renegotiation_info = b"\xff\x01\x00\x01\x00" + all_extensions += renegotiation_info + supported_groups = b"\x00\x0a\x00\x0a\x00\x08\x00\x1d\x00\x17\x00\x18\x00\x19" + all_extensions += supported_groups + ec_point_formats = b"\x00\x0b\x00\x02\x01\x00" + all_extensions += ec_point_formats + session_ticket = b"\x00\x23\x00\x00" + all_extensions += session_ticket + + # Application Layer Protocol Negotiation extension. + all_extensions += self._app_layer_proto_negotiation(jarm_details) + signature_algorithms = b"\x00\x0d\x00\x14\x00\x12\x04\x03\x08\x04\x04\x01\x05\x03\x08\x05\x05\x01\x08\x06\x06\x01\x02\x01" + all_extensions += signature_algorithms + + # Key share extension. + all_extensions += self._key_share(grease) + psk_key_exchange_modes = b"\x00\x2d\x00\x02\x01\x01" + all_extensions += psk_key_exchange_modes + + # Supported versions extension. + if (jarm_details[2] == "TLS_1.3") or (jarm_details[7] == "1.2_SUPPORT"): + all_extensions += self._supported_versions(jarm_details, grease) + + # Finish assembling extensions. + extension_length = len(all_extensions) + extension_bytes += struct.pack(">H", extension_length) + extension_bytes += all_extensions + + return extension_bytes + + # Client hello server name extension. + def _extension_server_name(self, host): + ext_sni = b"\x00\x00" + ext_sni_length = len(host) + 5 + ext_sni += struct.pack(">H", ext_sni_length) + ext_sni_length2 = len(host) + 3 + ext_sni += struct.pack(">H", ext_sni_length2) + ext_sni += b"\x00" + ext_sni_length3 = len(host) + ext_sni += struct.pack(">H", ext_sni_length3) + ext_sni += host.encode() + + return ext_sni + + # Client hello apln extension. + def _app_layer_proto_negotiation(self, jarm_details): + ext = b"\x00\x10" + if (jarm_details[6] == "RARE_APLN"): + # Removes h2 and HTTP/1.1. + alpns = [b"\x08\x68\x74\x74\x70\x2f\x30\x2e\x39", + b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x30", + b"\x06\x73\x70\x64\x79\x2f\x31", + b"\x06\x73\x70\x64\x79\x2f\x32", + b"\x06\x73\x70\x64\x79\x2f\x33", + b"\x03\x68\x32\x63", b"\x02\x68\x71"] + else: + # All apln extensions in order from weakest to strongest. + alpns = [b"\x08\x68\x74\x74\x70\x2f\x30\x2e\x39", + b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x30", + b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x31", + b"\x06\x73\x70\x64\x79\x2f\x31", + b"\x06\x73\x70\x64\x79\x2f\x32", + b"\x06\x73\x70\x64\x79\x2f\x33" b"\x02\x68\x32", + b"\x03\x68\x32\x63", b"\x02\x68\x71"] + + # Apln extensions can be reordered. + if jarm_details[8] != "FORWARD": + alpns = self._cipher_mung(alpns, jarm_details[8]) + + all_alpns = b"" + for alpn in alpns: + all_alpns += alpn + + second_length = len(all_alpns) + first_length = second_length+2 + ext += struct.pack(">H", first_length) + ext += struct.pack(">H", second_length) + ext += all_alpns + + return ext + + def _key_share(self, grease): + """Generate key share extension for client hello. + """ + ext = b"\x00\x33" + + # Add grease value if necessary. + if grease == True: + share_ext = self._choose_grease() + share_ext += b"\x00\x01\x00" + else: + share_ext = b"" + + group = b"\x00\x1d" + share_ext += group + key_exchange_length = b"\x00\x20" + share_ext += key_exchange_length + share_ext += os.urandom(32) + second_length = len(share_ext) + first_length = second_length+2 + ext += struct.pack(">H", first_length) + ext += struct.pack(">H", second_length) + ext += share_ext + + return ext + + def _supported_versions(self, jarm_details, grease): + """Supported version extension for client hello. + """ + if (jarm_details[7] == "1.2_SUPPORT"): + # TLS 1.3 is not supported. + tls = [b"\x03\x01", b"\x03\x02", b"\x03\x03"] + else: + # TLS 1.3 is supported. + tls = [b"\x03\x01", b"\x03\x02", b"\x03\x03", b"\x03\x04"] + + # Change supported version order, by default, the versions are from + # oldest to newest. + if jarm_details[8] != "FORWARD": + tls = self._cipher_mung(tls, jarm_details[8]) + + # Assemble the extension. + ext = b"\x00\x2b" + # Add GREASE if applicable. + if grease == True: + versions = self._choose_grease() + else: + versions = b"" + + for version in tls: + versions += version + + second_length = len(versions) + first_length = second_length+1 + ext += struct.pack(">H", first_length) + ext += struct.pack(">B", second_length) + ext += versions + + return ext + + # Send the assembled client hello using a socket. + def _send_packet(self, packet): + try: + # Determine if the input is an IP or domain name. + try: + if (ipaddress.ip_address(self.destination_host) is ipaddress.IPv4Address or + ipaddress.ip_address(self.destination_host) is ipaddress.IPv6Address): + ip = (self.destination_host, self.destination_port) + except ValueError as e: + ip = (None, None) + + # Connect the socket. + if ":" in self.destination_host: + sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + sock.settimeout(20) + sock.connect((self.destination_host, self.destination_port, 0, 0)) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(20) + sock.connect((self.destination_host, self.destination_port)) + + # If the destination host is a domain name, resolve it. + if not ip[0]: + ip = sock.getpeername() + + sock.sendall(packet) + # Receive server hello. + data = sock.recv(1484) + # Close socket. + sock.shutdown(socket.SHUT_RDWR) + sock.close() + return data, ip[0] + except (TimeoutError,socket.timeout) as e: + sock.close() + return "TIMEOUT", ip[0] + except Exception as e: + sock.close() + return None, ip[0] + + def _read_packet(self, data, jarm_details): + """If a packet is received, decipher the details. + """ + try: + if not data: + raise Exception("No data") + + jarm = "" + # Server hello error. + if data[0] == 21: + raise Exception("Server hello error") + # Check for server hello. + elif (data[0] == 22) and (data[5] == 2): + counter = data[43] + # Find server's selected cipher. + selected_cipher = data[counter+44:counter+46] + # Find server's selected version. + version = data[9:11] + jarm += str(selected_cipher.hex()) + jarm += "|" + jarm += str(version.hex()) + jarm += "|" + extensions = (self._extract_extension_info(data, counter)) + jarm += extensions + return jarm + else: + raise Exception("Unexpected result") + except Exception as e: + return "|||" + + def _extract_extension_info(self, data, counter): + """Deciphering the extensions in the server hello. + """ + try: + if data[counter+47] == 11 or data[counter+50:counter+53] == b"\x0e\xac\x0b" or data[82:85] == b"\x0f\xf0\x0b": + return "|||" + + count = 49 + counter + length = int.from_bytes(data[counter+47:counter+49], byteorder='big') + maximum = length + (count-1) + types = [] + values = [] + + # Collect all extension types and values for later reference. + while count < maximum: + types.append(data[count:count+2]) + ext_length = int.from_bytes(data[count+2:count+4], byteorder='big') + if ext_length == 0: + count += 4 + values.append("") + else: + values.append(data[count+4:count+4+ext_length]) + count += ext_length+4 + + result = "" + # Read application_layer_protocol_negotiation. + alpn = self._find_extension(b"\x00\x10", types, values) + result += str(alpn) + result += "|" + # Add formating hyphens. + add_hyphen = 0 + while add_hyphen < len(types): + result += types[add_hyphen].hex() + add_hyphen += 1 + if add_hyphen == len(types): + break + else: + result += "-" + return result + except IndexError as e: + return "|||" + + def _version_byte(self, version): + # This captures a single version byte based on version. + if version == "": + return "0" + + options = "abcdef" + count = int(version[3:4]) + byte = options[count] + return byte + + def _find_extension(self, ext_type, types, values): + """Matching cipher extensions to values. + """ + counter = 0 + # For the APLN extension, grab the value in ASCII. + if ext_type == b"\x00\x10": + while counter < len(types): + if types[counter] == ext_type: + return ((values[counter][3:]).decode()) + counter += 1 + else: + while counter < len(types): + if types[counter] == ext_type: + return values[counter].hex() + counter += 1 + + return "" + + def _cipher_bytes(self, cipher): + """Fuzzy hash for ciphers is the index number (in hex) of the cipher + in the list. + """ + if cipher == "": + return "00" + + bytes_list = [b"\x00\x04", b"\x00\x05", b"\x00\x07", b"\x00\x0a", + b"\x00\x16", b"\x00\x2f", b"\x00\x33", b"\x00\x35", + b"\x00\x39", b"\x00\x3c", b"\x00\x3d", b"\x00\x41", + b"\x00\x45", b"\x00\x67", b"\x00\x6b", b"\x00\x84", + b"\x00\x88", b"\x00\x9a", b"\x00\x9c", b"\x00\x9d", + b"\x00\x9e", b"\x00\x9f", b"\x00\xba", b"\x00\xbe", + b"\x00\xc0", b"\x00\xc4", b"\xc0\x07", b"\xc0\x08", + b"\xc0\x09", b"\xc0\x0a", b"\xc0\x11", b"\xc0\x12", + b"\xc0\x13", b"\xc0\x14", b"\xc0\x23", b"\xc0\x24", + b"\xc0\x27", b"\xc0\x28", b"\xc0\x2b", b"\xc0\x2c", + b"\xc0\x2f", b"\xc0\x30", b"\xc0\x60", b"\xc0\x61", + b"\xc0\x72", b"\xc0\x73", b"\xc0\x76", b"\xc0\x77", + b"\xc0\x9c", b"\xc0\x9d", b"\xc0\x9e", b"\xc0\x9f", + b"\xc0\xa0", b"\xc0\xa1", b"\xc0\xa2", b"\xc0\xa3", + b"\xc0\xac", b"\xc0\xad", b"\xc0\xae", b"\xc0\xaf", + b"\xcc\x13", b"\xcc\x14", b"\xcc\xa8", b"\xcc\xa9", + b"\x13\x01", b"\x13\x02", b"\x13\x03", b"\x13\x04", + b"\x13\x05"] + + counter = 1 + for bytes_values in bytes_list: + bytes_values_str = str(bytes_values.hex()) + if cipher == bytes_values_str: + break + counter += 1 + + hexvalue = str(hex(counter))[2:] + # This part must always be two bytes. + if len(hexvalue) < 2: + return "0" + hexvalue + else: + return hexvalue + + def _calculate_hash(self, jarm_raw): + """Custom fuzzy hash. + """ + # If jarm is empty, 62 zeros for the hash. + if jarm_raw == "|||,|||,|||,|||,|||,|||,|||,|||,|||,|||": + return "0" * 62 + + fuzzy_hash = "" + handshakes = jarm_raw.split(",") + alpns_and_ext = "" + for handshake in handshakes: + components = handshake.split("|") + # Custom jarm hash includes a fuzzy hash of the ciphers and versions. + fuzzy_hash += self._cipher_bytes(components[0]) + fuzzy_hash += self._version_byte(components[1]) + alpns_and_ext += components[2] + alpns_and_ext += components[3] + + # Custom jarm hash has the sha256 of alpns and extensions added to the end. + sha256 = (hashlib.sha256(alpns_and_ext.encode())).hexdigest() + fuzzy_hash += sha256[0:32] + + return fuzzy_hash + + def run(self): + """Conduct tests and calculate JARM. + """ + + #Select the packets and formats to send. + #Array format: + # 0: self.destination_host + # 1: self.destination_port + # 2: version + # 3: cipher_list + # 4: cipher_order + # 5: GREASE + # 6: RARE_APLN + # 7: 1.3_SUPPORT + # 8: extension_orders + + #Possible versions: SSLv3, TLS_1, TLS_1.1, TLS_1.2, TLS_1.3 + #Possible cipher lists: ALL, NO1.3 + #GREASE: either NO_GREASE or GREASE + #APLN: either APLN or RARE_APLN + #Supported Verisons extension: 1.2_SUPPPORT, NO_SUPPORT, or 1.3_SUPPORT + #Possible Extension order: FORWARD, REVERSE + + queue = [ + [self.destination_host, self.destination_port, "TLS_1.2", "ALL", "FORWARD", "NO_GREASE", "APLN", "1.2_SUPPORT", "REVERSE"], + [self.destination_host, self.destination_port, "TLS_1.2", "ALL", "REVERSE", "NO_GREASE", "APLN", "1.2_SUPPORT", "FORWARD"], + [self.destination_host, self.destination_port, "TLS_1.2", "ALL", "TOP_HALF", "NO_GREASE", "APLN", "NO_SUPPORT", "FORWARD"], + [self.destination_host, self.destination_port, "TLS_1.2", "ALL", "BOTTOM_HALF", "NO_GREASE", "RARE_APLN", "NO_SUPPORT", "FORWARD"], + [self.destination_host, self.destination_port, "TLS_1.2", "ALL", "MIDDLE_OUT", "GREASE", "RARE_APLN", "NO_SUPPORT", "REVERSE"], + [self.destination_host, self.destination_port, "TLS_1.1", "ALL", "FORWARD", "NO_GREASE", "APLN", "NO_SUPPORT", "FORWARD"], + [self.destination_host, self.destination_port, "TLS_1.3", "ALL", "FORWARD", "NO_GREASE", "APLN", "1.3_SUPPORT", "REVERSE"], + [self.destination_host, self.destination_port, "TLS_1.3", "ALL", "REVERSE", "NO_GREASE", "APLN", "1.3_SUPPORT", "FORWARD"], + [self.destination_host, self.destination_port, "TLS_1.3", "NO1.3", "FORWARD", "NO_GREASE", "APLN", "1.3_SUPPORT", "FORWARD"], + [self.destination_host, self.destination_port, "TLS_1.3", "ALL", "MIDDLE_OUT", "GREASE", "APLN", "1.3_SUPPORT", "REVERSE"], + ] + + # Assemble, send, and decipher each packet. + for test in queue: + payload = self._packet_building(test) + server_hello, ip = self._send_packet(payload) + + # Deal with timeout error. + if server_hello == "TIMEOUT": + self._jarm_raw = "|||,|||,|||,|||,|||,|||,|||,|||,|||,|||" + break + + ans = self._read_packet(server_hello, test) + self._jarm_raw += ans + self._jarm_raw += "," + + self._jarm_raw = self._jarm_raw.rstrip(",") + self._jarm_hash = self._calculate_hash(self._jarm_raw) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b707541 --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +import os +from setuptools import setup, find_packages + +__package_name__ = "jarm" +__version__ = "" +__author_name__ = "" +__author_email__ = "" +__description__ = "JARM hashing library and tool" + +this_directory = os.path.abspath(os.path.dirname(__file__)) +readme_path = os.path.join(this_directory, "README.md") +with open(readme_path, encoding="utf-8") as handle: + long_description = handle.read() + +setup( + name=__package_name__, + version=__version__, + author=__author_name__, + author_email=__author_email__, + description=__description__, + long_description=long_description, + scripts = ["bin/jarm"], + packages=find_packages(), + include_package_data=True, + classifiers=[ + ], +) From bc1649a953cf6e5ad47dc218afe15c1110a67b59 Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 00:42:46 +0100 Subject: [PATCH 2/7] Updated README --- README.md | 91 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index cbba675..abade71 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # JARM - + Please read the initial [JARM blog post](https://engineering.salesforce.com/easily-identify-malicious-servers-on-the-internet-with-jarm-e095edac525a) for more information. - + JARM is an active Transport Layer Security (TLS) server fingerprinting tool. - + JARM fingerprints can be used to: - Quickly verify that all servers in a group have the same TLS configuration. - Group disparate servers on the internet by configuration, identifying that a server may belong to Google vs. Salesforce vs. Apple, for example. @@ -15,35 +15,36 @@ JARM support is being added to: [Shodan](http://shodan.io/) [BinaryEdge](https://www.binaryedge.io/) -### Run JARM -`python3 jarm.py [-h] [-i INPUT] [-p PORT] [-v] [-V] [-o OUTPUT] [domain/IP]` +## Run JARM + +``` +usage: jarm [-h] [-p PORT] [-f FILE] [-c | -j] [scan] +``` Example: -`% python3 jarm.py www.salesforce.com` -`Domain: www.salesforce.com` -`Resolved IP: 23.50.225.123` -`JARM: 2ad2ad0002ad2ad00042d42d00000069d641f34fe76acdc05c40262f8815e5` - -### Batch run JARM on a large list at speed -`./jarm.sh ` -Example: -`% ./jarm.sh alexa500.txt jarm_alexa_500.csv` - -### Example Output -| Domain | JARM | -| --- | --- | -| salesforce.com | `2ad2ad0002ad2ad00042d42d00000069d641f34fe76acdc05c40262f8815e5` | -| force.com | `2ad2ad0002ad2ad00042d42d00000069d641f34fe76acdc05c40262f8815e5` | -| google.com | `27d40d40d29d40d1dc42d43d00041d4689ee210389f4f6b4b5b1b93f92252d` | -| youtube.com | `27d40d40d29d40d1dc42d43d00041d4689ee210389f4f6b4b5b1b93f92252d` | -| gmail.com | `27d40d40d29d40d1dc42d43d00041d4689ee210389f4f6b4b5b1b93f92252d` | -| facebook.com | `27d27d27d29d27d1dc41d43d00041d741011a7be03d7498e0df05581db08a9` | -| instagram.com | `27d27d27d29d27d1dc41d43d00041d741011a7be03d7498e0df05581db08a9` | -| oculus.com | `29d29d20d29d29d21c41d43d00041d741011a7be03d7498e0df05581db08a9` | - -### How JARM Works - + +``` +% jarm www.salesforce.com +Scanning salesforce.com on port 443 +JARM hash: 2ad2ad0002ad2ad00042d42d000000d71691dd6844b6fa08f9c5c2b4b882cc +``` + +## Use JARM as a Library + +Example: + +```python +from jarm import JARM + +j = JARM("salesforce.com") +j.run() +print(j.raw) +print(j.hash) +``` + +## How JARM Works + Before learning how JARM works, it’s important to understand how TLS works. TLS and its predecessor, SSL, are used to encrypt communication for both common applications like Internet browsers, to keep your data secure, and malware, so it can hide in the noise. To initiate a TLS session, a client will send a TLS Client Hello message following the TCP 3-way handshake. This packet and the way in which it is generated is dependent on packages and methods used when building the client application. The server, if accepting TLS connections, will respond with a TLS Server Hello packet. - + TLS servers formulate their Server Hello packet based on the details received in the TLS Client Hello packet. The manner in which the Server Hello is formulated for any given Client Hello can vary based on how the application or server was built, including: - Operating system - Operating system version @@ -51,25 +52,25 @@ TLS servers formulate their Server Hello packet based on the details received in - Versions of those libraries used - The order in which the libraries were called - Custom configuration - + All of these factors lead to each TLS Server responding in a unique way. The combinations of factors make it unlikely that servers deployed by different organizations will have the same response. - + JARM works by actively sending 10 TLS Client Hello packets to a target TLS server and capturing specific attributes of the TLS Server Hello responses. The aggregated TLS server responses are then hashed in a specific way to produce the JARM fingerprint. - + This is not the first time we’ve worked with TLS fingerprinting. In 2017 we developed [JA3/S](https://github.com/salesforce/ja3), a passive TLS client/server fingerprinting method now found on most network security tools. But where JA3/S is passive, fingerprinting clients and servers by listening to network traffic, JARM is an active server fingerprinting scanner. You can find out more about TLS negotiation and JA3/S passive fingerprinting [here](https://engineering.salesforce.com/tls-fingerprinting-with-ja3-and-ja3s-247362855967). The 10 TLS Client Hello packets in JARM have been specially crafted to pull out unique responses in TLS servers. JARM sends different TLS versions, ciphers, and extensions in varying orders to gather unique responses. Does the server support TLS 1.3? Will it negotiate TLS 1.3 with 1.2 ciphers? If we order ciphers from weakest to strongest, which cipher will it pick? These are the types of unusual questions JARM is essentially asking the server to draw out the most unique responses. The 10 responses are then hashed to produce the JARM fingerprint. - + The JARM fingerprint hash is a hybrid fuzzy hash, it uses the combination of a reversible and non-reversible hash algorithm to produce a 62 character fingerprint. The first 30 characters are made up of the cipher and TLS version chosen by the server for each of the 10 client hello's sent. A "000" denotes that the server refused to negotiate with that client hello. The remaining 32 characters are a truncated SHA256 hash of the cumulative extensions sent by the server, ignoring x509 certificate data. When comparing JARM fingerprints, if the first 30 characters are the same but the last 32 are different, this would mean that the servers have very similar configurations, accepting the same versions and ciphers, though not exactly the same given the extensions are different. - + After receiving each TLS server hello message, JARM closes the connection gracefully with a FIN as to not leave the sockets open. - + It is important to note that JARM is a high-performance fingerprint function and should not be considered, or confused with, a secure crypto function. We designed the JARM fingerprint to be human consumable as much as machine consumable. This means it is small enough to eyeball, share, and tweet with enough room for contextual details. - -### How JARM Can Be Used to Identify Malicious Servers - + +## How JARM Can Be Used to Identify Malicious Servers + Malware command and control (C2) and malicious servers are configured by their creators like any other server and then deployed across their fleet. These therefore tend to produce unique JARM fingerprints. Below are examples of common malware and offensive tools and the JARM overlap with the Alexa Top 1M websites (as of Oct. 2020): - + | Malicious Server C2 | JARM Fingerprint | Overlap with Alexa Top 1M | | --- | --- | --- | | Trickbot | `22b22b09b22b22b22b22b22b22b22b352842cd5d6b0278445702035e06875c` | 0 | @@ -77,14 +78,14 @@ Malware command and control (C2) and malicious servers are configured by their c | Metasploit | `07d14d16d21d21d00042d43d000000aa99ce74e2c6d013c745aa52b5cc042d` | 0 | | Cobalt Strike | `07d14d16d21d21d07c42d41d00041d24a458a375eef0c576d23a7bab9a9fb1` | 0 | | Merlin C2 | `29d21b20d29d29d21c41d21b21b41d494e0df9532e75299f15ba73156cee38` | 303 | - + With little to no overlap of the Alexa Top 1M Websites, it should be unlikely for a host within an organization to connect to a server with these JARM fingerprints. - - -### JARM Team + +## JARM Team + [John Althouse](https://www.linkedin.com/in/johnalthouse/) - Original idea, concept and project lead [Andrew Smart](https://www.linkedin.com/in/andrew-smart-a3b15a2/) - Concept and testing [RJ Nunnally](https://www.linkedin.com/in/rjnunnally/) - Programing and testing [Mike Brady](https://www.linkedin.com/in/mike-brady-b5293b21/) - Programing and testing - + Rewritten in Python for operational use by [Caleb Yu](https://www.linkedin.com/in/caleb-yu/) From aa98b5d5be8338130f0ba99a86fa943c478137f8 Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 00:45:36 +0100 Subject: [PATCH 3/7] Reinstated copyright notice --- jarm/jarm.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/jarm/jarm.py b/jarm/jarm.py index 6f99186..017e112 100644 --- a/jarm/jarm.py +++ b/jarm/jarm.py @@ -1,3 +1,8 @@ +# Copyright (c) 2020, 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 + import os import socket import struct From 0f7638cb1d4b4e26dba92892399d6c97f32ec90f Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 00:47:24 +0100 Subject: [PATCH 4/7] Removed unused variable --- bin/jarm | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/jarm b/bin/jarm index 266d6ce..e8b7c50 100755 --- a/bin/jarm +++ b/bin/jarm @@ -47,7 +47,6 @@ def main(): elif args.scan: hosts.append([args.scan, args.port]) - results = [] for host in hosts: if not args.csv and not args.json: print(f"Scanning {host[0]} on port {host[1]}") From 6c1c4350ce593b9165ff8600d42a5a3d4fc65b5b Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 00:57:25 +0100 Subject: [PATCH 5/7] Fixed formatting of help message --- bin/jarm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/jarm b/bin/jarm index e8b7c50..7d59b4b 100755 --- a/bin/jarm +++ b/bin/jarm @@ -19,8 +19,9 @@ def main(): target.add_argument("scan", nargs='?', help="Specify an IP or domain to scan") target.add_argument("-f", "--file", type=str, - help="Provide a path to a list of IP addresses or domains to scan, " \ - "one per line (optional: Specify port to scan with comma separation, e.g. 8.8.4.4,853)") + help="Provide a path to a list of IP addresses or domains to scan, " \ + "one per line (optional: Specify port to scan with comma separation, " \ + "e.g. 8.8.4.4,853)") output = parser.add_mutually_exclusive_group() output.add_argument("-c", "--csv", action="store_true", help="Print results in CSV format") From b0f879749a64078b502ab610fddbbc288af47049 Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 00:57:30 +0100 Subject: [PATCH 6/7] Removed unused import --- jarm/jarm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/jarm/jarm.py b/jarm/jarm.py index 017e112..4dd45c4 100644 --- a/jarm/jarm.py +++ b/jarm/jarm.py @@ -8,7 +8,6 @@ import struct import random import hashlib -import argparse import ipaddress class JARM: From 0926472974b2c0b9f3a07b2ee468cc23c1fbdbc3 Mon Sep 17 00:00:00 2001 From: Nex Date: Thu, 19 Nov 2020 01:05:50 +0100 Subject: [PATCH 7/7] Several Flake8 corrections --- jarm/jarm.py | 57 ++++++++++++++++++++++++++-------------------------- setup.py | 2 +- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/jarm/jarm.py b/jarm/jarm.py index 4dd45c4..876b0f1 100644 --- a/jarm/jarm.py +++ b/jarm/jarm.py @@ -10,6 +10,7 @@ import hashlib import ipaddress + class JARM: def __init__(self, host, port=443): @@ -44,7 +45,7 @@ def _packet_building(self, jarm_details): client_hello = b"\x03\x03" elif jarm_details[2] == "SSLv3": payload += b"\x03\x00" - client_hello = b"\x03\x00" + client_hello = b"\x03\x00" elif jarm_details[2] == "TLS_1": payload += b"\x03\x01" client_hello = b"\x03\x01" @@ -128,15 +129,15 @@ def _get_ciphers(self, jarm_details): b"\x00\x35", b"\x00\x3d", b"\xc0\x9d", b"\xc0\xa1", b"\x00\x9d", b"\x00\x41", b"\x00\xba", b"\x00\x84", b"\x00\xc0", b"\x00\x07", b"\x00\x04", b"\x00\x05"] - + # Change cipher order. if jarm_details[4] != "FORWARD": cipher_lists = self._cipher_mung(cipher_lists, jarm_details[4]) - + # Add GREASE to beginning of cipher list (if applicable). if jarm_details[5] == "GREASE": cipher_lists.insert(0, self._choose_grease()) - + # Generate cipher list. for cipher in cipher_lists: selected_ciphers += cipher @@ -160,7 +161,7 @@ def _cipher_mung(self, ciphers, request): elif (request == "TOP_HALF"): if (cipher_len % 2 == 1): output.append(ciphers[int(cipher_len/2)]) - #Top half gets the middle cipher + # Top half gets the middle cipher. output += self._cipher_mung(self._cipher_mung(ciphers, "REVERSE"), "BOTTOM_HALF") @@ -245,7 +246,7 @@ def _extension_server_name(self, host): # Client hello apln extension. def _app_layer_proto_negotiation(self, jarm_details): ext = b"\x00\x10" - if (jarm_details[6] == "RARE_APLN"): + if jarm_details[6] == "RARE_APLN": # Removes h2 and HTTP/1.1. alpns = [b"\x08\x68\x74\x74\x70\x2f\x30\x2e\x39", b"\x08\x68\x74\x74\x70\x2f\x31\x2e\x30", @@ -283,9 +284,9 @@ def _key_share(self, grease): """Generate key share extension for client hello. """ ext = b"\x00\x33" - + # Add grease value if necessary. - if grease == True: + if grease: share_ext = self._choose_grease() share_ext += b"\x00\x01\x00" else: @@ -301,7 +302,7 @@ def _key_share(self, grease): ext += struct.pack(">H", first_length) ext += struct.pack(">H", second_length) ext += share_ext - + return ext def _supported_versions(self, jarm_details, grease): @@ -322,7 +323,7 @@ def _supported_versions(self, jarm_details, grease): # Assemble the extension. ext = b"\x00\x2b" # Add GREASE if applicable. - if grease == True: + if grease: versions = self._choose_grease() else: versions = b"" @@ -344,10 +345,10 @@ def _send_packet(self, packet): # Determine if the input is an IP or domain name. try: if (ipaddress.ip_address(self.destination_host) is ipaddress.IPv4Address or - ipaddress.ip_address(self.destination_host) is ipaddress.IPv6Address): + ipaddress.ip_address(self.destination_host) is ipaddress.IPv6Address): ip = (self.destination_host, self.destination_port) - except ValueError as e: - ip = (None, None) + except ValueError: + ip = (None, None) # Connect the socket. if ":" in self.destination_host: @@ -370,10 +371,10 @@ def _send_packet(self, packet): sock.shutdown(socket.SHUT_RDWR) sock.close() return data, ip[0] - except (TimeoutError,socket.timeout) as e: + except (TimeoutError, socket.timeout): sock.close() return "TIMEOUT", ip[0] - except Exception as e: + except Exception: sock.close() return None, ip[0] @@ -404,7 +405,7 @@ def _read_packet(self, data, jarm_details): return jarm else: raise Exception("Unexpected result") - except Exception as e: + except Exception: return "|||" def _extract_extension_info(self, data, counter): @@ -446,7 +447,7 @@ def _extract_extension_info(self, data, counter): else: result += "-" return result - except IndexError as e: + except IndexError: return "|||" def _version_byte(self, version): @@ -490,7 +491,7 @@ def _cipher_bytes(self, cipher): b"\x00\x45", b"\x00\x67", b"\x00\x6b", b"\x00\x84", b"\x00\x88", b"\x00\x9a", b"\x00\x9c", b"\x00\x9d", b"\x00\x9e", b"\x00\x9f", b"\x00\xba", b"\x00\xbe", - b"\x00\xc0", b"\x00\xc4", b"\xc0\x07", b"\xc0\x08", + b"\x00\xc0", b"\x00\xc4", b"\xc0\x07", b"\xc0\x08", b"\xc0\x09", b"\xc0\x0a", b"\xc0\x11", b"\xc0\x12", b"\xc0\x13", b"\xc0\x14", b"\xc0\x23", b"\xc0\x24", b"\xc0\x27", b"\xc0\x28", b"\xc0\x2b", b"\xc0\x2c", @@ -534,7 +535,7 @@ def _calculate_hash(self, jarm_raw): fuzzy_hash += self._version_byte(components[1]) alpns_and_ext += components[2] alpns_and_ext += components[3] - + # Custom jarm hash has the sha256 of alpns and extensions added to the end. sha256 = (hashlib.sha256(alpns_and_ext.encode())).hexdigest() fuzzy_hash += sha256[0:32] @@ -545,8 +546,8 @@ def run(self): """Conduct tests and calculate JARM. """ - #Select the packets and formats to send. - #Array format: + # Select the packets and formats to send. + # Array format: # 0: self.destination_host # 1: self.destination_port # 2: version @@ -557,12 +558,12 @@ def run(self): # 7: 1.3_SUPPORT # 8: extension_orders - #Possible versions: SSLv3, TLS_1, TLS_1.1, TLS_1.2, TLS_1.3 - #Possible cipher lists: ALL, NO1.3 - #GREASE: either NO_GREASE or GREASE - #APLN: either APLN or RARE_APLN - #Supported Verisons extension: 1.2_SUPPPORT, NO_SUPPORT, or 1.3_SUPPORT - #Possible Extension order: FORWARD, REVERSE + # Possible versions: SSLv3, TLS_1, TLS_1.1, TLS_1.2, TLS_1.3 + # Possible cipher lists: ALL, NO1.3 + # GREASE: either NO_GREASE or GREASE + # APLN: either APLN or RARE_APLN + # Supported Verisons extension: 1.2_SUPPPORT, NO_SUPPORT, or 1.3_SUPPORT + # Possible Extension order: FORWARD, REVERSE queue = [ [self.destination_host, self.destination_port, "TLS_1.2", "ALL", "FORWARD", "NO_GREASE", "APLN", "1.2_SUPPORT", "REVERSE"], @@ -581,7 +582,7 @@ def run(self): for test in queue: payload = self._packet_building(test) server_hello, ip = self._send_packet(payload) - + # Deal with timeout error. if server_hello == "TIMEOUT": self._jarm_raw = "|||,|||,|||,|||,|||,|||,|||,|||,|||,|||" diff --git a/setup.py b/setup.py index b707541..a3577a0 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ author_email=__author_email__, description=__description__, long_description=long_description, - scripts = ["bin/jarm"], + scripts=["bin/jarm"], packages=find_packages(), include_package_data=True, classifiers=[