def handle_db(args): """ awspx db """ if args.load_zip: db = args.load_zip.split('_')[-1][0:-4] + ".db" if not args.load_zip.startswith("/opt/awspx/data/"): args.load_zip = "/opt/awspx/data/" + args.load_zip print(f"[*] Importing records from {args.load_zip}") (success, message) = Neo4j.load(args.load_zip, db) print(f"{message}\n") print("Run `awspx attacks` to calculate attack paths.") if not success: sys.exit(1) elif args.list_dbs: print("\n".join([ db for db in os.listdir("/data/databases/") if os.path.isdir(os.path.join("/data/databases/", db)) ])) elif args.use_db: print(f"[+] Changing database to {args.use_db} " "(remember to refresh your browser)") Neo4j.switch_database(args.use_db) Neo4j.restart()
def handle_db(args, console=console): """ awspx db """ db = Neo4j(console=console) if args.load_zips: db.load_zips(archives=args.load_zips, db=args.database if 'database' in args else 'default.db') elif args.list_dbs: db.list() elif args.use_db: db.use(args.use_db)
def loaded(): """ Fetch previously loaded resources and services from the database. """ resources = [ r["r"] for r in Neo4j.run("MATCH (g:Generic) " "WITH [_ IN LABELS(g) " "WHERE _ <> 'Generic'][0] AS r " "RETURN r ORDER BY r") ] services = [ s for s in SERVICES if s.upper() in list(set([s.split('::')[1].upper() for s in resources])) ] return {"Resources": resources, "Services": services}
def db(args): """ awspx db """ dbdir = "/data/databases/" if args.list_dbs: databases = [ d for d in os.listdir(dbdir) if os.path.isdir(os.path.join(dbdir, d)) ] print("\n".join(databases)) elif args.use_db: if args.use_db[-3:] == ".db": d = args.use_db else: d = args.use_db + ".db" db = os.path.join(dbdir, d) if os.path.isdir(db): Neo4j.switch_database(db) Neo4j.restart() print(f"Switched to {db}.") elif args.load_zip: zips = [z for z in os.listdir("/opt/awspx/data") if args.load_zip in z] dbname = args.load_zip.replace(".zip", ".db") if zips: print(f"[+] Loading /opt/awspx/data/{zips[0]} into {dbname}.") Neo4j.load("/opt/awspx/data/" + zips[0], dbname) else: print(f"[-] {args.load_zip} not found in /opt/awpsx/data/.")
def compute(self, max_iterations=5, max_search_depth=""): converged = False db = Neo4j(console=self.console) self.console.task("Removing all existing attack patterns", db.run, args=["MATCH (p:Pattern) DETACH DELETE p"], done="Removed all existing attack patterns") self.console.task("Creating pseudo Admin", db.run, args=[ "MERGE (admin:Admin:`AWS::Iam::Policy`{" "Name: 'Effective Admin', " "Description: 'Pseudo-Policy representing full and unfettered access.', " "Arn: 'arn:aws:iam::${Account}:policy/Admin', " 'Document: \'[{"DefaultVersion": {"Version": "2012-10-17", ' '"Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"' '}]}}]\'' '}) ' ], done="Created pseudo Admin") for i in self.console.tasklist( "Adding attack paths (this search can take a while)", range(max_iterations * len(self.definitions)), done="Added attack paths" ): if converged: continue # First iteration elif (i % len(self.definitions) == 0): self.console.info("Temporarily adding Admin label " "to Generic Policy") db.run("MATCH (gp:`AWS::Iam::Policy`:Generic) SET gp:Admin") # Last iteration, check for convergence elif ((i + 1) % len(self.definitions) == 0): self.console.info("Removing Admin label from Generic Policy") db.run("MATCH (admin:`AWS::Iam::Policy`:Generic) " "REMOVE admin:Admin") self.console.info("Pruning attack paths") # Only keep the 'cheapest' paths to admin, favouring transitive # relationships over attacks db.run("MATCH shortestPath((admin)-[:ATTACK|TRANSITIVE*1..]->(:Admin)) " "WHERE NOT (admin:Pattern OR admin:Admin) " "WITH admin MATCH path=(admin)-[:ATTACK|:TRANSITIVE*..]->(:Admin) " "WITH DISTINCT admin, path, " "REDUCE(sum=0, _ IN EXTRACT(_ IN RELS(path)|" "COALESCE(_.Weight, 0))|sum + _) AS weight " "ORDER BY admin, weight " "WITH admin, COLLECT([weight, path]) AS paths " "WITH admin, FILTER(attack IN NODES(paths[0][1]) " "WHERE attack:Pattern) AS cheapest " "MATCH path=(admin)-[:ATTACK]->(pattern:Pattern) " "WHERE NOT pattern IN cheapest " "WITH pattern MATCH (source)-[attack:ATTACK]->(pattern) " "MERGE (source)-[_attack_:_ATTACK_]->(pattern) " "ON CREATE SET _attack_ = attack " "DELETE attack" ) db.run("MATCH (admin)-[:ATTACK*..2]->(:Admin) " "WITH COLLECT(DISTINCT admin) AS admins " "WITH admins UNWIND admins AS admin " "MATCH (source)-[:TRANSITIVE*1..]->(target) " "WHERE target IN admins " "WITH DISTINCT source " "MATCH (source)-[attack:ATTACK]->(pattern:Pattern) " "MERGE (source)-[_attack_:_ATTACK_]->(pattern) " "ON CREATE SET _attack_ = attack " "DELETE attack " ) if sum([s["nodes_created"] + s["relationships_created"] for s in self.stats[-(len(self.definitions)):]]) == 0: converged = True self.console.info("Search converged on iteration: " f"{iteration} of max: {max_iterations} - " "Tidying up") # Update attack descriptions db.run("MATCH (:Pattern)-[attack:ATTACK|OPTION|CREATE]->() " "WHERE LENGTH(attack.Commands) > 0 " "WITH COLLECT(DISTINCT attack) AS attacks " "UNWIND attacks AS attack " "WITH attacks, COLLECT(DISTINCT [" " attack.Commands[LENGTH(attack.Commands) - 1], " " attack.Description]) as commands " "UNWIND attacks AS attack " "WITH attack, commands WHERE TYPE(attack) = 'ATTACK' " "WITH attack, EXTRACT(command IN attack.Commands|" " COALESCE([description IN commands " " WHERE command = description[0]][0][1], " " attack.Description)) AS descriptions " "WITH attack, descriptions " "SET attack.Descriptions = descriptions " "REMOVE attack.Description" ) # Remove redundant attack paths db.run("MATCH ()-[:_ATTACK_]->(pattern:Pattern) " "DETACH DELETE pattern" ) # Remove attacks affecting generic resources db.run("OPTIONAL MATCH ()-[:ATTACK]->(:Pattern)-[attack:ATTACK]->(:Generic) " "DELETE attack" ) continue pattern = list(self.definitions.keys())[i % len(self.definitions)] iteration = int(i / len(self.definitions)) + 1 definition = self.definitions[pattern] timestamp = time.time() self.console.info(f"Searching for attack: {pattern} " f"(iteration: {iteration} of max: {max_iterations})") cypher = self._pattern_cypher(pattern, definition, max_search_depth) summary = db.run(cypher)._summary self.stats.append({ "pattern": pattern, "iteration": iteration, "nodes_created": 0, "relationships_created": 0, "properties_set": 0, "labels_added": 0, "time_elapsed": time.time() - timestamp, ** summary.counters.__dict__ }) db.close() discovered = sum([s["nodes_created"] if "nodes_created" in s else 0 for s in self.stats]) self.console.notice(f"{discovered} potential attack paths " "were added to the database")
def compute(max_iterations=5, except_attacks=[], only_attacks=[], max_search_depth="", ignore_actions_with_conditions=True): exception = None print("[*] Searching database for attack patterns\n") sys.stdout.write("\033[F\033[K") print("[*] Removing all existing attack patterns") Neo4j().run("MATCH (pattern:Pattern) " "OPTIONAL MATCH ()-[admin]->(:Admin) " "DETACH DELETE pattern, admin") sys.stdout.write("\033[F\033[K") print("[*] Creating pseudo admin") Neo4j().run(Attacks._admin_cypher()) # Temporarily set generic policy to admin. This is # because all attack paths that allow for reaching # this node implicitly grant admin. Neo4j().run("MATCH (policy:Generic:Policy) SET policy:Admin") # Identify any new attack paths, we stop when we've # converged or when we've exceeded the maximum number # of iterations. attack_definitions = { k: v for k, v in Attacks.definitions.items() if k not in except_attacks and ( only_attacks == [] or k in only_attacks) } try: discovered = 0 _ = 0 for _ in range(1, max_iterations + 1): attack = 0 created = 0 for name, definition in attack_definitions.items(): exception = name attack += 1 sys.stdout.write("\033[F\033[K") print("[*] Searching for attack " f"{attack}/{len(attack_definitions)}: " f"{name} (iteration: {_} of max: {max_iterations})") cypher = Attacks._pattern_cypher( name, definition, max_search_depth=max_search_depth, ignore_actions_with_conditions= ignore_actions_with_conditions) summary = Neo4j().run(cypher).summary() if str(summary.counters) == "{}": continue created += 1 discovered += summary.counters.nodes_created if created == 0: break exception = None except Exception as e: print(f"[-] Neo4j returned:\n\n{e}") print("[!] Don't worry, we'll use what we already have") sys.stdout.write("\033[F\033[K") print("[+] Consolidating attack patterns") # Remove :Admin (restore generic policy definition) Neo4j().run( "MATCH (source:Pattern)-[edge]->(policy:`AWS::Iam::Policy`:Generic:Admin) " + "MERGE (source)-[admin:ADMIN]->(policy) " + "ON CREATE SET admin = edge " + "DELETE edge " + "REMOVE policy:Admin") # Patch generalised CYPHER queries to reflect a unified admin definition # Note to self: I'm not sure why we've chosen to flatten # (source)-->(pattern)-->(admin) Neo4j().run( "MATCH (admin:Admin), " + "path=(source:Resource)-[:ATTACK]->(pattern:Pattern)-[edge:ATTACK{Admin:True}]->(target) " + "MERGE (pattern)-[_:ATTACK]->(admin) " + "ON CREATE SET _ = edge, _.Target = ID(edge)") # Replace all attack 'Description' entries with a 'Descriptions' set, that maps # to all 'Commands' present in the attack. # TODO: Need to work out whether a description is referencing the same edge for collection Neo4j().run( "MATCH ()-[attack:ATTACK]->() " + "WITH attack UNWIND attack.Commands AS command " + "OPTIONAL MATCH (:Pattern)-[_]->() " + "WHERE command IN _.Commands " + "WITH attack, command, _ ORDER BY _.Weight " + "WITH attack, command, COLLECT(_)[0] AS _ " + "WITH attack, COALESCE(_.Description, attack.Description) AS description " + "WITH attack, COLLECT(description) AS descriptions " + "SET attack.Descriptions = descriptions " + "REMOVE attack.Description") sys.stdout.write("\033[F\033[K") print(f"[+] {discovered} patterns were discovered " + str(f"(successfully converged after {_} iterations)" if _ < max_iterations else f"(failed to converge of {max_iterations})")) if exception is not None: raise Exception(exception)
def compute( max_iterations=5, skip_attacks=[], only_attacks=[], max_search_depth="", ignore_actions_with_conditions=True ): stats = [] iteration = 0 exception = None print("[*] Searching database for attack patterns\n") sys.stdout.write("\033[F\033[K") print("[*] Removing all existing attack patterns") Neo4j().run("MATCH (p:Pattern) DETACH DELETE p") sys.stdout.write("\033[F\033[K") print("[*] Creating pseudo admin") Neo4j().run(Attacks._admin_cypher()) # Temporarily set generic policy to admin. This is # because all attack paths that allow for reaching # this node (eg: AttachUserPolicy) would grant admin. Neo4j().run("MATCH (gp:`AWS::Iam::Policy`:Generic) SET gp:Admin") # Identify any new attack paths, we stop when we've # converged or exceeded the maximum number # of iterations. attack_definitions = {k: v for k, v in Attacks.definitions.items() if k not in skip_attacks and (only_attacks == [] or k in only_attacks)} try: for iteration in range(1, max_iterations + 1): index = 0 converged = True for pattern, definition in attack_definitions.items(): exception = pattern index += 1 sys.stdout.write("\033[F\033[K") print("[*] Searching for attack " f"{index}/{len(attack_definitions)}: " f"{pattern} (iteration: {iteration} of max: {max_iterations})") cypher = Attacks._pattern_cypher( pattern, definition, max_search_depth=max_search_depth, ignore_actions_with_conditions=ignore_actions_with_conditions ) start = time.time() summary = Neo4j().run(cypher).summary() stats.append({ "pattern": pattern, "iteration": iteration, "seconds_elapsed": time.time() - start, **summary.counters.__dict__ }) if str(summary.counters) == "{}": continue converged = False if converged: break exception = None except Exception as e: print(f"[-] Neo4j returned:\n\n{e}") print("[!] Don't worry, we'll use what we already have") sys.stdout.write("\033[F\033[K") print("[+] Consolidating attack patterns") # Restore generic policy (unset :Admin) Neo4j().run("MATCH (gp:`AWS::Iam::Policy`:Generic) REMOVE gp:Admin") # Replace all attack 'Description' entries with a 'Descriptions' set that maps # to 'Commands'. We do this to support presenting each command with an associated # description in the front end (rather than only the last description and all comamnds). # It is easier to do it like this than to incorporate logic into _pattern_cypher(). Neo4j().run( "MATCH ()-[attack:ATTACK]->() " + "WITH attack UNWIND attack.Commands AS command " + "OPTIONAL MATCH (:Pattern)-[_]->() " + "WHERE command IN _.Commands " + "WITH attack, command, _ ORDER BY _.Weight " + "WITH attack, command, COLLECT(_)[0] AS _ " + "WITH attack, COALESCE(_.Description, attack.Description) AS description " + "WITH attack, COLLECT(description) AS descriptions " + "SET attack.Descriptions = descriptions " + "REMOVE attack.Description" ) discovered = sum([s["nodes_created"] if "nodes_created" in s else 0 for s in stats]) sys.stdout.write("\033[F\033[K") print(f"[+] {discovered} potential attacks were discovered " + str( f"(successfully converged after {iteration} iterations)" if iteration < max_iterations else f"(failed to converge of {max_iterations})") ) # print(json.dumps(stats, indent=2)) if exception is not None: raise Exception(exception)
def ingest(args): """ awspx ingest """ account = "0000000000000" ingested = Elements() iam = None profile = args.profile if args.profile else "default" # offer to create profile it doesn't exist try: session = boto3.session.Session(profile_name=profile) except ProfileNotFound: create_new_profile(profile) session = boto3.session.Session(profile_name=profile) if not args.region: r = boto3.session.Session(profile_name=profile).region_name region = r if r != None else "eu-west-1" else: region = args.region if not args.database: database = profile + ".db" else: if args.database[-3:] == ".db": database = args.database else: database = args.database + ".db" if args.services and args.services != "all": services = [ s for s in SERVICES if s.__name__.upper() in map(str.upper, args.services.strip(" ").split(',')) ] else: services = SERVICES # Always ingest IAM if IAM not in services: services.insert(0, IAM) # Resolve only & except resources types & ARNs optional_resource_args = "" selections = {} if args.except_types: selections["except_types"] = args.except_types.split(",") validate_types(selections["except_types"]) optional_resource_args = optional_resource_args + \ f" --except-types {args.except_types} \\\n" else: selections["except_types"] = [] if args.only_types: selections["only_types"] = args.only_types.split(",") validate_types(selections["only_types"]) optional_resource_args = optional_resource_args + \ f" --only-types {args.only_types} \\\n" else: selections["only_types"] = [] if args.except_arns: selections["except_arns"] = args.except_arns.lower().split(",") optional_resource_args = optional_resource_args + \ f" --except-arns {args.except_arns} \\\n" else: selections["except_arns"] = [] if args.only_arns: selections["only_arns"] = args.only_arns.lower().split(",") optional_resource_args = optional_resource_args + \ f" --only-arns {args.only_arns} \\\n" else: selections["only_arns"] = [] if args.role_to_assume: assume_role_arg = f" --assume-role {args.role_to_assume} \\\n" else: assume_role_arg = "" max_attack_iterations, max_attack_depth, ignore_conditionals, except_attacks, only_attacks = attacks( args) if args.skip_attacks: attack_args = " --skip-attacks" else: attack_args = f""" --max-attack-iterations {str(max_attack_iterations)} \\ --ignore-conditionals {str(ignore_conditionals)} \\ """ if max_attack_depth != "": attack_args = attack_args + \ f" --max-attack-depth {max_attack_depth} \\" if except_attacks: attack_args = attack_args + \ f" --except-attacks {','.join(except_attacks)}" elif only_attacks: attack_args = attack_args + \ f" --only-attacks {','.join(only_attacks)}" print(f""" [+] Running awspx ingest --profile {profile} --region {region} --database {database} \\ --services {','.join([s.__name__ for s in services])} \\ {assume_role_arg+optional_resource_args+attack_args} """) try: session = boto3.session.Session(profile_name=profile, region_name=region) identity = session.client('sts').get_caller_identity() account = identity["Account"] print(f"[+] User set to {identity['Arn']}.") print(f"[+] Region set to {region}.") except: print( "[-] Request to establish identity (sts:GetCallerIdentity) failed." ) if args.role_to_assume: response = session.client('sts').assume_role( RoleArn=args.role_to_assume, RoleSessionName=f"awspx", DurationSeconds=7200) if response: print(f"[+] Assumed role {args.role_to_assume}") session = boto3.session.Session( aws_access_key_id=response["Credentials"]["AccessKeyId"], aws_secret_access_key=response["Credentials"] ["SecretAccessKey"], aws_session_token=response["Credentials"]["SessionToken"], region_name=region) try: identity = session.client('sts').get_caller_identity() account = identity["Account"] print(f"[+] Running as {identity['Arn']}.") print(f"[+] Region set to {region}.") except: print( "[-] Request to establish identity (sts:GetCallerIdentity) failed." ) if not args.database: database = f"{args.role_to_assume.split('::')[1].split(':')[0]}-{database}" print(f"[+] Using database {database}") if IAM in services: iam = IAM(session, db=database) account = iam.root.account() for service in [s for s in services if s != IAM]: resources = service(session, **selections, account=account) ingested += resources if IAM not in services: iam = IAM(session, db=database, resources=ingested) else: iam += ingested archive = iam.post() print(f"[+] Results exported to {archive}") Neo4j.load(archive, database) if not args.skip_attacks: print("[+] Computing attack paths") Attacks.compute(max_iterations=max_attack_iterations, except_attacks=except_attacks, only_attacks=only_attacks, max_search_depth=max_attack_depth, ignore_actions_with_conditions=ignore_conditionals) print("[+] Done.")