From b06c9d5407e9024056fd406b23765ec0e3274437 Mon Sep 17 00:00:00 2001 From: Andreas Grasser Date: Tue, 22 Oct 2024 09:22:45 +0200 Subject: [PATCH 1/4] Added compatibility with new Bloodhound CE API --- hashcathelper/bloodhound.py | 68 ++++++++++++++++-------- hashcathelper/bloodhound_ce.py | 94 ++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 hashcathelper/bloodhound_ce.py diff --git a/hashcathelper/bloodhound.py b/hashcathelper/bloodhound.py index f9417cc..83f4d41 100644 --- a/hashcathelper/bloodhound.py +++ b/hashcathelper/bloodhound.py @@ -1,4 +1,5 @@ import logging +import hashcathelper.bloodhound_ce as bloodhound_ce from neo4j import GraphDatabase @@ -37,21 +38,29 @@ def get_driver(url): if not url: log.critical("No BloodHound URL given") exit(1) - regex = r'^bolt(?Ps?)://(?P[^:]+):(?P.+)@' + regex = r'^(?P[a-z]{4})(?Ps?)://(?P[^:]+):(?P.+)@' regex += r'(?P[^:]*)(:(?P[0-9]+))?$' m = re.match(regex, url) if not m: log.error("Couldn't parse BloodHound URL: %s" % url) exit(1) - encrypted, user, password, host, _, port = m.groups() + protocol, encrypted, user, password, host, _, port = m.groups() encrypted = (encrypted == 's') - url = "bolt://%s:%s" % (host, port or 7687) + url = "%s://%s:%s" % (protocol, host, port or 7687) log.debug("Connecting to %s..." % url) - driver = GraphDatabase.driver(url, auth=(user, password), + if protocol == "bolt": + # BOLT protocol for old Bloodhound + driver = GraphDatabase.driver(url, auth=(user, password), encrypted=encrypted) + elif protocol == "http": + # HTTP protocol for Bloodhound CE API + driver = bloodhound_ce.driver(url, auth=(user, password)) + else: + log.error("Unknown Protocol: %s" % protocol) + exit(1) return driver @@ -104,16 +113,23 @@ def add_edges(driver, clusters): def add_many_edges(tx, edges): - q = """ - UNWIND $edges as edge - MATCH (a:User), (b:User) - WHERE a.name = toUpper(edge.a) - AND b.name = toUpper(edge.b) - CREATE (a)-[r:SamePassword]->(b), (b)-[k:SamePassword]->(a) - RETURN r - """ - result = tx.run(q, edges=edges) - return len(result.value()) + # Is it the new Bloodhound CE? + is_bloodhound_ce = (type(tx) == bloodhound_ce.Sender) + if is_bloodhound_ce: + q = """MATCH (a:User), (b:User) WHERE a.name = toUpper(\"{a}\") AND b.name = toUpper(\"{b}\") CREATE (a)-[r:SamePassword]->(b), (b)-[k:SamePassword]->(a) RETURN r""" + result = tx.run(q, edges=edges) + return result + else: + q = """ + UNWIND $edges as edge + MATCH (a:User), (b:User) + WHERE a.name = toUpper(edge.a) + AND b.name = toUpper(edge.b) + CREATE (a)-[r:SamePassword]->(b), (b)-[k:SamePassword]->(a) + RETURN r + """ + result = tx.run(q, edges=edges) + return len(result.value()) def mark_cracked(driver, users): @@ -125,12 +141,20 @@ def mark_cracked(driver, users): log.info("Marked %d users as 'cracked'" % added) +# Old neo4j Query def mark_cracked_tx(tx, users): - q = """ - UNWIND $users as user - MATCH (u:User {name: user}) - SET u.cracked = True - RETURN u - """ - result = tx.run(q, users=users) - return len(result.value()) + # Is it the new Bloodhound CE? + is_bloodhound_ce = (type(tx) == bloodhound_ce.Sender) + if is_bloodhound_ce: + q = """MATCH (u) WHERE toLower(u.name) = toLower(\"{user}\") SET u.cracked = True RETURN u""" + result = tx.run(q, users=users) + return result + else: + q = """ + UNWIND $users as user + MATCH (u:User {name: user}) + SET u.cracked = True + RETURN u + """ + result = tx.run(q, users=users) + return len(result.value()) \ No newline at end of file diff --git a/hashcathelper/bloodhound_ce.py b/hashcathelper/bloodhound_ce.py new file mode 100644 index 0000000..edd344c --- /dev/null +++ b/hashcathelper/bloodhound_ce.py @@ -0,0 +1,94 @@ +import requests, json, asyncio, logging + +log = logging.getLogger(__name__) + +class Sender: + sessionToken = None + url = None + + def __init__(self, url, sessionToken): + self.sessionToken = sessionToken + self.url = url + + def run(self, query, users="", edges=""): + counter = 0 + for user in users: + data = { + "query": query.format(user=user), + "include_properties": True + } + + if self.sendRequest(data) == 200: + counter += 1 + for edge in edges: + data = { + "query": query.format(**edge), + "include_properties": True + } + + if self.sendRequest(data) == 200: + counter += 1 + return counter + + def sendRequest(self, data): + # TODO: Error Handling + return requests.post(url = self.url + "/api/v2/graphs/cypher", json = data, headers={'Authorization': 'Bearer '+self.sessionToken}).status_code + + +class Session: + driver = None + sessionToken = None + + def __init__(self, driver): + self.driver = driver + self.getAuthToken() + + def __enter__(self): + return self + + def __exit__(self, exception_type, exception_value, traceback): + if exception_type: + if issubclass(exception_type, asyncio.CancelledError): + self._handle_cancellation(message="__exit__") + self._closed = True + return + self._state_failed = True + + def __with__(self): + with super() as s: + yield s + + def getAuthToken(self): + data = { + 'login_method':'secret', + 'username':self.driver.username, + 'secret':self.driver.password + } + try: + response = requests.post(url = self.driver.url + "/api/v2/login", json = data) + except: + log.error("Cannot connect to %s" % self.driver.url) + exit(1) + self.sessionToken = response.json()['data']['session_token'] + + def write_transaction( + self, + transaction_function, + data): + return transaction_function(self.sendRequest(), data) + + def sendRequest(self): + return Sender(self.driver.url, self.sessionToken) + +class driver: + url = None + username = None + password = None + + def __init__(self, url, auth, encrypted=False): + self.url = url + self.username = auth[0] + self.password = auth[1] + + def session(self): + return Session(self) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0e3fca0..d6b6e8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ 'neo4j>=4.2', 'importlib-metadata', 'tqdm', + 'requests' ] [tool.setuptools] From 9a2901b6aa8336da4471073d5243beee6bd9ec66 Mon Sep 17 00:00:00 2001 From: Andreas Grasser Date: Sun, 27 Oct 2024 11:01:47 +0100 Subject: [PATCH 2/4] Added comments and error handling for bloodhound_ce.py --- hashcathelper/bloodhound_ce.py | 43 ++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/hashcathelper/bloodhound_ce.py b/hashcathelper/bloodhound_ce.py index edd344c..0cefc30 100644 --- a/hashcathelper/bloodhound_ce.py +++ b/hashcathelper/bloodhound_ce.py @@ -5,11 +5,12 @@ class Sender: sessionToken = None url = None - + def __init__(self, url, sessionToken): self.sessionToken = sessionToken self.url = url - + + # Method to execute a cypher query for all users or edges def run(self, query, users="", edges=""): counter = 0 for user in users: @@ -17,7 +18,7 @@ def run(self, query, users="", edges=""): "query": query.format(user=user), "include_properties": True } - + if self.sendRequest(data) == 200: counter += 1 for edge in edges: @@ -25,27 +26,31 @@ def run(self, query, users="", edges=""): "query": query.format(**edge), "include_properties": True } - + if self.sendRequest(data) == 200: counter += 1 return counter - + + # Method to send a cypher query to the Bloodhound CE API endpoint def sendRequest(self, data): - # TODO: Error Handling - return requests.post(url = self.url + "/api/v2/graphs/cypher", json = data, headers={'Authorization': 'Bearer '+self.sessionToken}).status_code - + try: + return requests.post(url = self.url + "/api/v2/graphs/cypher", json = data, headers={'Authorization': 'Bearer ' + self.sessionToken}).status_code + except: + log.error("Cannot connect to %s" % self.url) + exit(1) +# The class Session is responsible for obtaining an API session token and for returnung a Sender object similar to the neo4j Session class Session: driver = None sessionToken = None - + def __init__(self, driver): self.driver = driver self.getAuthToken() - + def __enter__(self): return self - + def __exit__(self, exception_type, exception_value, traceback): if exception_type: if issubclass(exception_type, asyncio.CancelledError): @@ -53,11 +58,12 @@ def __exit__(self, exception_type, exception_value, traceback): self._closed = True return self._state_failed = True - + def __with__(self): with super() as s: yield s - + + # Obtain an API auth token using the username/password combination provided in the URI def getAuthToken(self): data = { 'login_method':'secret', @@ -70,25 +76,26 @@ def getAuthToken(self): log.error("Cannot connect to %s" % self.driver.url) exit(1) self.sessionToken = response.json()['data']['session_token'] - + def write_transaction( self, transaction_function, data): return transaction_function(self.sendRequest(), data) - + def sendRequest(self): return Sender(self.driver.url, self.sessionToken) +# Class driver just exists for compatibility reasons with the old neo4j driver class driver: url = None username = None password = None - + def __init__(self, url, auth, encrypted=False): self.url = url self.username = auth[0] self.password = auth[1] - + def session(self): - return Session(self) \ No newline at end of file + return Session(self) From d1aee0df1088bfe38153d688952f9b752c3207f3 Mon Sep 17 00:00:00 2001 From: Andreas Grasser Date: Sun, 27 Oct 2024 11:02:28 +0100 Subject: [PATCH 3/4] Mentioned Bloodhound CE compatibility in the README --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9c3a2c8..50b4702 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ This subcommand lets you insert new relationships into an existing [BloodHound](https://github.com/BloodHoundAD/BloodHound) database. It takes a BloodHound URI, a report in JSON format (with degree of detail equal to three or higher) and the domain name and creates edges between user objects -that share the same password. This enables you to create graphs like this, +that share the same password. Both neo4j `bolt://` as well as Bloodhound CE `http://` API URIs are supported. This enables you to create graphs like this, which immediately shows you offenders of password reuse among the administrator team: @@ -249,4 +249,3 @@ db_uri = sqlite:////home/cracker/.local/share/hashcathelper/stats.sqlite # Download here: https://haveibeenpwned.com/Passwords hibp_db = /home/cracker/wordlists/pwned-passwords-ntlm-ordered-by-hash-v8.txt ``` - From 8db25a46c614990f661c994538b1d46ec1c3dabd Mon Sep 17 00:00:00 2001 From: andigandhi Date: Tue, 5 Aug 2025 14:37:59 +0200 Subject: [PATCH 4/4] Fixed a bug produced by the merge; added error handling for module "hashcathelper analytics" --- hashcathelper/bloodhound.py | 23 ++++++++++++++++++++--- hashcathelper/subcommands/bloodhound.py | 4 ++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/hashcathelper/bloodhound.py b/hashcathelper/bloodhound.py index 8446488..c912e30 100644 --- a/hashcathelper/bloodhound.py +++ b/hashcathelper/bloodhound.py @@ -55,6 +55,7 @@ def get_driver(url): else: log.error("Unknown Protocol: %s" % protocol) exit(1) + return driver def query_neo4j(driver, cypher_query, domain=None): @@ -64,6 +65,10 @@ def query_neo4j(driver, cypher_query, domain=None): """ from hashcathelper.utils import User + if (type(driver) == bloodhound_ce.driver): + log.error("Direct queries to Bloodhound CE are currently not supported.") + exit(1) + log.debug("Given Cypher query: %s" % cypher_query) log.info("Querying BloodHound database...") @@ -110,7 +115,13 @@ def add_many_edges(tx, edges): # Is it the new Bloodhound CE? is_bloodhound_ce = (type(tx) == bloodhound_ce.Sender) if is_bloodhound_ce: - q = """MATCH (a:User), (b:User) WHERE a.name = toUpper(\"{a}\") AND b.name = toUpper(\"{b}\") CREATE (a)-[r:SamePassword]->(b), (b)-[k:SamePassword]->(a) RETURN r""" + q = """ + MATCH (a:User), (b:User) + WHERE a.name = toUpper(\"{a}\") + AND b.name = toUpper(\"{b}\") + CREATE (a)-[r:SamePassword]->(b), (b)-[k:SamePassword]->(a) + RETURN r + """ result = tx.run(q, edges=edges) return result else: @@ -135,15 +146,21 @@ def mark_cracked(driver, users): log.info("Marked %d users as 'cracked'" % added) -# Old neo4j Query def mark_cracked_tx(tx, users): # Is it the new Bloodhound CE? is_bloodhound_ce = (type(tx) == bloodhound_ce.Sender) if is_bloodhound_ce: - q = """MATCH (u) WHERE toLower(u.name) = toLower(\"{user}\") SET u.cracked = True RETURN u""" + # New Bloodhound CE query + q = """ + MATCH (u) + WHERE toLower(u.name) = toLower(\"{user}\") + SET u.cracked = True + RETURN u + """ result = tx.run(q, users=users) return result else: + # Old neo4j Query q = """ UNWIND $users as user MATCH (u:User {name: user}) diff --git a/hashcathelper/subcommands/bloodhound.py b/hashcathelper/subcommands/bloodhound.py index 8425d81..a8c16f3 100644 --- a/hashcathelper/subcommands/bloodhound.py +++ b/hashcathelper/subcommands/bloodhound.py @@ -38,8 +38,8 @@ def domain_filepath_pair(arg): argument( dest="bloodhound_url", help=""" -URL to a Neo4j database containing BloodHound data. Format: -bolt[s]://:@[:]""", +URL to a Neo4j database containing BloodHound data (bolt scheme) or to a BloodHound CE API (http scheme). Format: +://:@[:]""", ) )