├── .assets └── usage.png ├── .github └── workflows │ ├── pytest.yml │ └── require-changelog-updates.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bofhound ├── __init__.py ├── __main__.py ├── ad │ ├── __init__.py │ ├── adds.py │ ├── helpers │ │ ├── __init__.py │ │ ├── cert_utils.py │ │ ├── propertieslevel.py │ │ ├── trustdirection.py │ │ └── trusttype.py │ └── models │ │ ├── __init__.py │ │ ├── bloodhound_aiaca.py │ │ ├── bloodhound_certtemplate.py │ │ ├── bloodhound_computer.py │ │ ├── bloodhound_container.py │ │ ├── bloodhound_crossref.py │ │ ├── bloodhound_domain.py │ │ ├── bloodhound_domaintrust.py │ │ ├── bloodhound_enterpriseca.py │ │ ├── bloodhound_gpo.py │ │ ├── bloodhound_group.py │ │ ├── bloodhound_issuancepolicy.py │ │ ├── bloodhound_ntauthstore.py │ │ ├── bloodhound_object.py │ │ ├── bloodhound_ou.py │ │ ├── bloodhound_rootca.py │ │ ├── bloodhound_schema.py │ │ └── bloodhound_user.py ├── local │ ├── __init__.py │ ├── localbroker.py │ └── models │ │ ├── __init__.py │ │ ├── local_groupmembership.py │ │ ├── local_privilegedsession.py │ │ ├── local_registrysession.py │ │ └── local_session.py ├── logger.py ├── parsers │ ├── __init__.py │ ├── brc4_ldap_sentinel.py │ ├── generic_parser.py │ ├── havoc.py │ ├── ldap_search_bof.py │ ├── outflankc2.py │ ├── parsertype.py │ └── shared_parsers │ │ ├── __init__.py │ │ ├── netlocalgroup_bof.py │ │ ├── netloggedon_bof.py │ │ ├── netsession_bof.py │ │ ├── regsession_bof.py │ │ └── sharedparser.py └── writer.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── ad ├── __init__.py ├── models │ ├── __init__.py │ ├── test_bloodhound_computer.py │ ├── test_bloodhound_crossref.py │ ├── test_bloodhound_object.py │ └── test_bloodhound_user.py └── test_adds.py ├── local └── test_local_broker.py ├── parsers ├── __init__.py ├── test_brc4_ldap_sentinel.py ├── test_generic_parser.py └── test_ldap_search_bof.py ├── test_data ├── __init__.py ├── brc4_ldap_sentinel_logs │ └── badger_no_acl_1030_objects.log ├── havoc_logs │ └── Console_73169420.log ├── json_output │ ├── computers_20220413_204956.json │ ├── domains_20220413_204956.json │ ├── groups_20220413_204957.json │ └── users_20220413_204956.json ├── ldapsearchbof_logs │ ├── beacon_202.log │ ├── beacon_202_no_acl.log │ ├── beacon_257-objects.log │ ├── beacon_marvel_ldap_sessions_localgroup.log │ └── pyldapsearch_redania_objects.log ├── ldapsearchpy_logs │ ├── ldapsearch_20220413.log │ ├── ldapsearch_20220414.log │ └── ldapsearch_516-objects.log ├── netlocalgroupbof_logs │ └── netlocalgroupbof_redania.log ├── netloggedonbof_logs │ └── netloggedonbof_redania.log ├── netsessionbof_logs │ ├── netsessionbof_redania_dns.log │ └── netsessionbof_redania_netapi.log └── regsessionbof_logs │ └── regsessionbof_redania.log ├── test_localgroup_to_computer.py └── test_session_to_computer.py /.assets/usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coffeegist/bofhound/fed5d2b63fc38f178423dfb1f77e2f6e9d337634/.assets/usage.png -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: PyTest 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | container: python:3.9 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - name: Install poetry dependencies 12 | run: pip install poetry 13 | 14 | - name: Install project 15 | run: poetry install 16 | 17 | - name: Run test suite 18 | run: poetry run pytest -v 19 | -------------------------------------------------------------------------------- /.github/workflows/require-changelog-updates.yml: -------------------------------------------------------------------------------- 1 | name: Require Changelog Updates 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | branches: 9 | - main 10 | 11 | jobs: 12 | check-changelog: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Check Changelog 22 | run: | 23 | git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 24 | changed_files=$(git diff --name-only HEAD $(git merge-base HEAD ${{ github.base_ref }})) 25 | if [[ $changed_files != *CHANGELOG.md* ]]; then 26 | echo "::error::No changes to CHANGELOG.md detected. Please update the changelog before merging." 27 | exit 1 28 | fi 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | 154 | # Test data 155 | !tests/test_data/**/*.log 156 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## [0.4.8] - 5/12/2025 3 | ### Fixed 4 | - Check for `operatingsystemservicepack` property to prevent key error - [merge #30](https://github.com/coffeegist/bofhound/pull/30) 5 | - Check for `CertTemplates` property to prevent type error - [merge #31](https://github.com/coffeegist/bofhound/pull/31) 6 | 7 | ## [0.4.7] - 04/29/2025 8 | ### Added 9 | - Ability to handle schemaIdGuids from ADExplorer LDIF output 10 | 11 | ### Fixed 12 | - Improved fix for [#12](https://github.com/coffeegist/bofhound/issues/12) by keying on the `securityIdentifier` attribute on trust objects 13 | 14 | ## [0.4.6] - 04/07/2025 15 | #### Fixed 16 | - Removed log statement clogging debug output [#19](https://github.com/coffeegist/bofhound/issues/19) 17 | - Update deprecated pyproject.toml syntax [#20](https://github.com/coffeegist/bofhound/issues/20) 18 | 19 | ## [0.4.5] - 12/17/2024 20 | #### Added 21 | - Support for pasing ldapsearch BOF results within OutflankC2 log files 22 | 23 | ## [0.4.4] - 12/13/2024 24 | ### Fixed 25 | - Addressed [#13](https://github.com/coffeegist/bofhound/issues/13) 26 | - Catch error is ACL paring fails for an object 27 | 28 | ## [0.4.3] - 10/30/2024 29 | ### Added 30 | - Support for pasing ldapsearch BOF results within Havoc log files 31 | 32 | ### Changed 33 | - Parsers now can inherit from the `LdapSearchBofParser` (since support for other C2s usually still relies on the same BOF) to cut down on code copypasta 34 | - The `GenericParser` class (used to parse local group memberships, session data) is now called from main parsers (`LdapSearchBofParser`, `HavocParser`, etc.) to prevent each logfile from being opened, read, formatted, and parsed twice (each file is now read once and just parsed twice, once for LDAP objects and once for local objects) 35 | 36 | ## [0.4.2] - 10/24/2024 37 | ### Fixed 38 | - Addressed [#12](https://github.com/coffeegist/bofhound/issues/12), an issue with duplicate trusted domain objects 39 | 40 | ## [0.4.1] - 10/22/2024 41 | ### Fixed 42 | - Addressed [#10](https://github.com/coffeegist/bofhound/issues/10), an issue with the `ContainedBy` attribute in output JSON 43 | 44 | ## [0.4.0] - 10/20/2024 45 | ### Added 46 | - Models for ADCS objects and abuse 47 | - AIACAs 48 | - Root CAs 49 | - Enterprise CAs 50 | - Certificate Templates 51 | - Issuance Policies 52 | - NTAuth Stores 53 | 54 | ### Changed 55 | - Split `--all-properties` into 3 levels of properties 56 | - `Standard` to closely mirror object attributes shown by SharpHound/BHCE 57 | - `Member` to include `member` and `memberOf` properties (and a few others) 58 | - `All` to include all properties parsed by bofhound 59 | 60 | ## [0.3.1] - 1/25/2024 61 | ### Fixed 62 | - GPO JSON file not matching JSON definition for BHCE 63 | - `domainsid` property gets set on all GPO objects now (requires domain objects to be queried) 64 | 65 | ## [0.3.0] - 12/27/2023 66 | ### Added 67 | - ADDS model for AD crossRef objects (referrals) 68 | - Models for Local objects (sessions and local group memberships) 69 | - Parsers for registry sessions, privileged sessions, sessions and local group memberships 70 | - ADDS processing logic to tie local group/session data to a computer object 71 | 72 | ## [0.2.1] - 08/09/2023 73 | ### Changed 74 | - Updated output JSON to v5 (BloodHound CE) specs 75 | 76 | ## [0.2.0] - 03/28/2023 77 | ### Added 78 | - New parser to support parsing LDAP Sentinel data from BRc4 logs 79 | 80 | ### Changed 81 | - Modified logic for how group memberships are determined 82 | - Prior method was iterate through DNs in groups' `member` attribute and adding objects with matching DNs 83 | - Since BRc4 does not store DNs in the `member` attibute, added iteration over objects' `memberOf` attribute and add to groups with matching DN (i.e. membership is now calculated from both sides of relationship) 84 | 85 | ## [v0.1.2] - 2/10/2023 86 | ### Changed 87 | - Updated ACL parsing function to current version BloodHound.py 88 | - Updated `typer` and `bloodhound-python` dependencies 89 | - Added the `memberof` attrbute to the common properties displayed for users, computers and groups 90 | 91 | ## [v0.1.1] - 8/11/2022 92 | ### Fixed 93 | - Bug where domain trusts queried more than once would appear duplicated in the BH UI 94 | 95 | ## [v0.1.0] - 6/9/2022 96 | ### Added 97 | - Parsing support for Group Policy Objects 98 | - Parsing support for Organizational Unit objects 99 | - Parsing support for Trusted Domain objects 100 | 101 | ### Fixed 102 | - Bug causing crash when handling non-base64 encoded SchemaIDGUID/nTSecurityDescriptor attributes 103 | 104 | ## [v0.0.1] - 5/9/2022 105 | ### Added 106 | - Prepped for initial release and PyPI package 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 4-Clause License 2 | 3 | Copyright (c) 2022, Fortalice Solutions, LLC. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. All advertising materials mentioning features or use of this software must 17 | display the following acknowledgement: 18 | This product includes software developed by Fortalice Solutions, LLC. 19 | 20 | 4. Neither the name of the copyright holder nor the names of its 21 | contributors may be used to endorse or promote products derived from 22 | this software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR 25 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 26 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 27 | EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 29 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 30 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 31 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 32 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 33 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | _____________________________ __ __ ______ __ __ __ __ _______ 3 | | _ / / __ / | ____/| | | | / __ \ | | | | | \ | | | \ 4 | | |_) | | | | | | |__ | |__| | | | | | | | | | | \| | | .--. | 5 | | _ < | | | | | __| | __ | | | | | | | | | | . ` | | | | | 6 | | |_) | | `--' | | | | | | | | `--' | | `--' | | |\ | | '--' | 7 | |______/ \______/ |__| |__| |___\_\________\_\________\|__| \___\|_________\ 8 | 9 | << @coffeegist | @Tw1sm >> 10 | ``` 11 | 12 |

13 | 14 | ![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) 15 | ![PyPi](https://img.shields.io/pypi/v/bofhound?style=for-the-badge) 16 |

17 | 18 | BOFHound is an offline BloodHound ingestor and LDAP result parser compatible with TrustedSec's [ldapsearch BOF](https://github.com/trustedsec/CS-Situational-Awareness-BOF), the Python adaptation, [pyldapsearch](https://github.com/fortalice/pyldapsearch) and Brute Ratel's [LDAP Sentinel](https://bruteratel.com/tabs/commander/badgers/#ldapsentinel). ldapsearch BOF logs can also be parsed from [Havoc](https://github.com/HavocFramework/Havoc) or OutflankC2 logs. 19 | 20 | By parsing log files generated by the aforementioned tools, BOFHound allows operators to utilize BloodHound's beloved interface while maintaining full control over the LDAP queries being run and the spped at which they are executed. This leaves room for operator discretion to account for potential honeypot accounts, expensive LDAP query thresholds and other detection mechanisms designed with the traditional, automated BloodHound collectors in mind. 21 | 22 | Check this [PR](https://github.com/trustedsec/CS-Situational-Awareness-BOF/pull/114) to the SA BOF repo for BOFs that collect session and local group membership data and can be parsed by BOFHound. 23 | 24 | ### References 25 | 26 | Blog Posts: 27 | 28 | | Title| Date| 29 | |------|-----| 30 | | [*BOFHound: AD CS Integration*](https://medium.com/specter-ops-posts/bofhound-ad-cs-integration-91b706bc7958) | Oct 30, 2024 | 31 | | [*BOFHound: Session Integration*](https://posts.specterops.io/bofhound-session-integration-7b88b6f18423) | Jan 30, 2024 | 32 | | [*Granularize Your AD Recon Game Part 2*](https://www.fortalicesolutions.com/posts/granularize-your-active-directory-reconnaissance-game-part-2) | Jun 15, 2022 | 33 | | [*Granularize Your AD Recon Game*](https://www.fortalicesolutions.com/posts/bofhound-granularize-your-active-directory-reconnaissance-game) | May 10, 2022 | 34 | 35 | Presentations: 36 | 37 | | Conference| Materials| Date| 38 | |-----------|----------|-----| 39 | | *SO-CON 2024*| [Slides](https://github.com/SpecterOps/presentations/blob/main/SO-CON%202024/Matt%20Creel%20%26%20Adam%20Brown%20-%20Manually%20Enumerating%20AD%20Attack%20Paths%20with%20BOFHound/Matt%20Creel%20and%20Adam%20Brown%20-%20Manually%20Enumerating%20AD%20Attack%20Paths%20With%20BOFHound%20-%20SO-CON%202024.pdf) & [Recording](https://www.youtube.com/watch?v=Xxm4YktSKVY)| Mar 11, 2024| 40 | 41 | # Installation 42 | BOFHound can be installed with `pip3 install bofhound` or by cloning this repository and running `pip3 install .` 43 | 44 | # Usage 45 | ``` 46 | Usage: bofhound [OPTIONS] 47 | 48 | Generate BloodHound compatible JSON from logs written by ldapsearch BOF, pyldapsearch and Brute Ratel's 49 | LDAP Sentinel 50 | 51 | Generate BloodHound compatible JSON from logs written by ldapsearch BOF, pyldapsearch and Brute 52 | Ratel's LDAP Sentinel 53 | 54 | ╭─ Options ──────────────────────────────────────────────────────────────────────────────────────╮ 55 | │ --input -i TEXT Directory or file containing │ 56 | │ logs of ldapsearch results │ 57 | │ [default: │ 58 | │ /opt/cobaltstrike/logs] │ 59 | │ --output -o TEXT Location to export bloodhound │ 60 | │ files │ 61 | │ [default: .] │ 62 | │ --properties-level -p [Standard|Member|All] Change the verbosity of │ 63 | │ properties exported to JSON: │ 64 | │ Standard - Common BH properties │ 65 | │ | Member - Includes MemberOf and │ 66 | │ Member | All - Includes all │ 67 | │ properties │ 68 | │ [default: Member] │ 69 | │ --parser [ldapsearch|BRC4|Havoc| Parser to use for log files. │ 70 | │ OutflankC2] ldapsearch parser (default) │ 71 | │ supports ldapsearch BOF logs │ 72 | │ from Cobalt Strike and │ 73 | │ pyldapsearch logs │ 74 | │ [default: ldapsearch] │ 75 | │ --debug Enable debug output │ 76 | │ --zip -z Compress the JSON output files │ 77 | │ into a zip archive │ 78 | │ --help -h Show this message and exit. │ 79 | ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ 80 | 81 | 82 | ``` 83 | 84 | ## Example Usage 85 | Parse ldapseach BOF results from Cobalt Strike logs (`/opt/cobaltstrike/logs` by default) to /data/ 86 | ``` 87 | bofhound -o /data/ 88 | ``` 89 | 90 | Parse pyldapsearch logs and only include all properties (vs other property levels) 91 | ``` 92 | bofhound -i ~/.pyldapsearch/logs/ --properties-level all 93 | ``` 94 | 95 | Parse LDAP Sentinel data from BRc4 logs (will change default input path to `/opt/bruteratel/logs`) 96 | ``` 97 | bofhound --parser brc4 98 | ``` 99 | 100 | Parse Havoc loot logs (will change default input path to `/opt/havoc/data/loot`) and zip the resulting JSON files 101 | ``` 102 | bofhound --parser havoc --zip 103 | ``` 104 | 105 | # ldapsearch 106 | Specify `*,ntsecuritydescriptor` as the attributes to return to be able to parse ACL edges. You are missing a ton of data if you don't include this in your `ldapsearch` queries! 107 | 108 | #### Required Data 109 | The following attributes are required for proper functionality: 110 | ``` 111 | samaccounttype 112 | distinguishedname 113 | objectsid 114 | ``` 115 | 116 | Some object classes rely on domain objects being populated within BOFHound. Domains can be queried with either of the following commands 117 | ``` 118 | ldapsearch (objectclass=domain) *,ntsecuritydescriptor 119 | ldapsearch (distinguishedname=DC=windomain,DC=local) *,ntsecuritydescriptor 120 | ``` 121 | 122 | ## Example ldapsearch Queries 123 | ``` 124 | # Get All the Data (Maybe Run BloodHound Instead?) 125 | ldapsearch (objectclass=*) *,ntsecuritydescriptor 126 | 127 | # Retrieve All Schema Info 128 | ldapsearch (schemaIDGUID=*) name,schemaidguid 0 3 "" CN=Schema,CN=Configuration,DC=windomain,DC=local 129 | 130 | # Retrieve Only the ms-Mcs-AdmPwd schemaIDGUID 131 | ldapsearch (name=ms-mcs-admpwd) name,schemaidguid 1 3 "" CN=Schema,CN=Configuration,DC=windomain,DC=local 132 | 133 | # Retrieve Domain NetBIOS Names (useful if collecting data via `netsession2/netloggedon2` BOFs) 134 | ldapsearch (netbiosname=*) * 0 3 "" "CN=Partitions,CN=Configuration,DC=windomain,DC=local" 135 | 136 | # Unroll a group's nested members 137 | ldapsearch (memberOf:1.2.840.113556.1.4.1941:=CN=TargetGroup,CN=Users,DC=windomain,DC=local) *,ntsecuritydescriptor 138 | 139 | # Query domain trusts 140 | ldapsearch (objectclass=trusteddomain) *,ntsecuritydescriptor 141 | 142 | # Query across a trust 143 | ldapsearch (objectclass=domain) *,ntsecuritydescriptor 0 3 dc1.trusted.windomain.local "DC=TRUSTED,DC=WINDOMAIN,DC=LOCAL" 144 | 145 | ##### 146 | # Queries below populate objects for AD CS parsing 147 | 148 | # Query the domain object 149 | ldapsearch (objectclass=domain) *,ntsecuritydescriptor 150 | 151 | # Query Enterprise CAs 152 | ldapsearch (objectclass=pKIEnrollmentService) *,ntsecuritydescriptor 0 3 “” “CN=Configuration,DC=domain,DC=local” 153 | 154 | # Query AIACAs, Root CAs and NTAuth Stores 155 | ldapsearch (objectclass=certificationAuthority) *,ntsecuritydescriptor 0 3 “” “CN=Configuration,DC=domain,DC=local” 156 | 157 | # Query Certificate Templates 158 | ldapsearch (objectclass=pKICertificateTemplate) *,ntsecuritydescriptor 0 3 “” “CN=Configuration,DC=domain,DC=local” 159 | 160 | # Query Issuance Policies 161 | ldapsearch (objectclass=msPKI-Enterprise-Oid) *,ntsecuritydescriptor 0 3 “” “CN=Configuration,DC=domain,DC=local” 162 | ``` 163 | 164 | # Versions 165 | Check the tagged releases to download a specific version 166 | - v0.4.0 and onward support parsing AD CS objects and edges 167 | - v0.3.0 and onward support session/local group data 168 | - v0.2.1 and onward are compatible with BloodHound CE 169 | - v0.2.0 is the last release supporting BloodHound Legacy 170 | 171 | # Development 172 | bofhound uses Poetry to manage dependencies. Install from source and setup for development with: 173 | 174 | ```shell 175 | git clone https://github.com/fortalice/bofhound 176 | cd bofhound 177 | poetry install 178 | poetry run bofhound --help 179 | ``` 180 | 181 | # References and Credits 182 | - [@_dirkjan](https://twitter.com/_dirkjan) (and other contributors) for [BloodHound.py](https://github.com/fox-it/BloodHound.py) 183 | - TrustedSec for [CS-Situational-Awareness-BOF](https://github.com/trustedsec/CS-Situational-Awareness-BOF) 184 | - [P-aLu](https://github.com/P-aLu) for collaboration on bofhoud's [AD CS support](https://github.com/coffeegist/bofhound/pull/8) 185 | -------------------------------------------------------------------------------- /bofhound/__init__.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | console = Console() 4 | -------------------------------------------------------------------------------- /bofhound/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import logging 4 | import typer 5 | import glob 6 | from bofhound.parsers import LdapSearchBofParser, Brc4LdapSentinelParser, HavocParser, ParserType, OutflankC2JsonParser 7 | from bofhound.writer import BloodHoundWriter 8 | from bofhound.ad import ADDS 9 | from bofhound.local import LocalBroker 10 | from bofhound import console 11 | from bofhound.ad.helpers import PropertiesLevel 12 | 13 | app = typer.Typer( 14 | add_completion=False, 15 | rich_markup_mode="rich", 16 | context_settings={'help_option_names': ['-h', '--help']} 17 | ) 18 | 19 | @app.command() 20 | def main( 21 | input_files: str = typer.Option("/opt/cobaltstrike/logs", "--input", "-i", help="Directory or file containing logs of ldapsearch results"), 22 | output_folder: str = typer.Option(".", "--output", "-o", help="Location to export bloodhound files"), 23 | properties_level: PropertiesLevel = typer.Option(PropertiesLevel.Member.value, "--properties-level", "-p", case_sensitive=False, help='Change the verbosity of properties exported to JSON: Standard - Common BH properties | Member - Includes MemberOf and Member | All - Includes all properties'), 24 | parser: ParserType = typer.Option(ParserType.LdapsearchBof.value, "--parser", case_sensitive=False, help="Parser to use for log files. ldapsearch parser (default) supports ldapsearch BOF logs from Cobalt Strike and pyldapsearch logs"), 25 | debug: bool = typer.Option(False, "--debug", help="Enable debug output"), 26 | zip_files: bool = typer.Option(False, "--zip", "-z", help="Compress the JSON output files into a zip archive")): 27 | """ 28 | Generate BloodHound compatible JSON from logs written by ldapsearch BOF, pyldapsearch and Brute Ratel's LDAP Sentinel 29 | """ 30 | 31 | if debug: 32 | logging.getLogger().setLevel(logging.DEBUG) 33 | else: 34 | logging.getLogger().setLevel(logging.INFO) 35 | 36 | banner() 37 | 38 | # default to Cobalt logfile naming format 39 | logfile_name_format = "beacon*.log" 40 | 41 | match parser: 42 | 43 | case ParserType.LdapsearchBof: 44 | logging.debug('Using ldapsearch parser') 45 | parser = LdapSearchBofParser 46 | 47 | case ParserType.BRC4: 48 | logging.debug('Using Brute Ratel parser') 49 | parser = Brc4LdapSentinelParser 50 | logfile_name_format = "b-*.log" 51 | if input_files == "/opt/cobaltstrike/logs": 52 | input_files = "/opt/bruteratel/logs" 53 | 54 | case ParserType.HAVOC: 55 | logging.debug('Using Havoc parser') 56 | parser = HavocParser 57 | logfile_name_format = "Console_*.log" 58 | if input_files == "/opt/cobaltstrike/logs": 59 | input_files = "/opt/havoc/data/loot" 60 | 61 | case ParserType.OUTFLANKC2: 62 | logging.debug('Using OutflankC2 parser') 63 | parser = OutflankC2JsonParser 64 | logfile_name_format = "*.json" 65 | 66 | case _: 67 | raise ValueError(f"Unknown parser type: {parser}") 68 | 69 | if os.path.isfile(input_files): 70 | cs_logs = [input_files] 71 | logging.debug(f"Log file explicitly provided {input_files}") 72 | elif os.path.isdir(input_files): 73 | # recurisively get a list of all .log files in the input directory, sorted by last modified time 74 | cs_logs = glob.glob(f"{input_files}/**/{logfile_name_format}", recursive=True) 75 | if len(cs_logs) == 0: 76 | # check for ldapsearch python logs 77 | cs_logs = glob.glob(f"{input_files}/pyldapsearch*.log", recursive=True) 78 | 79 | cs_logs.sort(key=os.path.getmtime) 80 | 81 | if len(cs_logs) == 0: 82 | logging.error(f"No log files found in {input_files}!") 83 | return 84 | else: 85 | logging.info(f"Located {len(cs_logs)} beacon log files") 86 | else: 87 | logging.error(f"Could not find {input_files} on disk") 88 | sys.exit(-1) 89 | 90 | parsed_ldap_objects = [] 91 | parsed_local_objects = [] 92 | with console.status(f"", spinner="aesthetic") as status: 93 | for log in cs_logs: 94 | status.update(f" [bold] Parsing {log}") 95 | formatted_data = parser.prep_file(log) 96 | new_objects = parser.parse_data(formatted_data) 97 | 98 | # jank insert to reparse outflank logs for local data 99 | if parser == OutflankC2JsonParser: 100 | new_local_objects = parser.parse_local_objects(log) 101 | else: 102 | new_local_objects = parser.parse_local_objects(formatted_data) 103 | 104 | logging.debug(f"Parsed {log}") 105 | logging.debug(f"Found {len(new_objects)} objects in {log}") 106 | parsed_ldap_objects.extend(new_objects) 107 | parsed_local_objects.extend(new_local_objects) 108 | 109 | logging.info(f"Parsed {len(parsed_ldap_objects)} LDAP objects from {len(cs_logs)} log files") 110 | logging.info(f"Parsed {len(parsed_local_objects)} local group/session objects from {len(cs_logs)} log files") 111 | 112 | ad = ADDS() 113 | broker = LocalBroker() 114 | 115 | logging.info("Sorting parsed objects by type...") 116 | ad.import_objects(parsed_ldap_objects) 117 | broker.import_objects(parsed_local_objects, ad.DOMAIN_MAP.values()) 118 | 119 | logging.info(f"Parsed {len(ad.users)} Users") 120 | logging.info(f"Parsed {len(ad.groups)} Groups") 121 | logging.info(f"Parsed {len(ad.computers)} Computers") 122 | logging.info(f"Parsed {len(ad.domains)} Domains") 123 | logging.info(f"Parsed {len(ad.trustaccounts)} Trust Accounts") 124 | logging.info(f"Parsed {len(ad.ous)} OUs") 125 | logging.info(f"Parsed {len(ad.containers)} Containers") 126 | logging.info(f"Parsed {len(ad.gpos)} GPOs") 127 | logging.info(f"Parsed {len(ad.enterprisecas)} Enterprise CAs") 128 | logging.info(f"Parsed {len(ad.aiacas)} AIA CAs") 129 | logging.info(f"Parsed {len(ad.rootcas)} Root CAs") 130 | logging.info(f"Parsed {len(ad.ntauthstores)} NTAuth Stores") 131 | logging.info(f"Parsed {len(ad.issuancepolicies)} Issuance Policies") 132 | logging.info(f"Parsed {len(ad.certtemplates)} Cert Templates") 133 | logging.info(f"Parsed {len(ad.schemas)} Schemas") 134 | logging.info(f"Parsed {len(ad.CROSSREF_MAP)} Referrals") 135 | logging.info(f"Parsed {len(ad.unknown_objects)} Unknown Objects") 136 | logging.info(f"Parsed {len(broker.sessions)} Sessions") 137 | logging.info(f"Parsed {len(broker.privileged_sessions)} Privileged Sessions") 138 | logging.info(f"Parsed {len(broker.registry_sessions)} Registry Sessions") 139 | logging.info(f"Parsed {len(broker.local_group_memberships)} Local Group Memberships") 140 | 141 | ad.process() 142 | ad.process_local_objects(broker) 143 | 144 | BloodHoundWriter.write( 145 | output_folder, 146 | domains=ad.domains, 147 | computers=ad.computers, 148 | users=ad.users, 149 | groups=ad.groups, 150 | ous=ad.ous, 151 | containers=ad.containers, 152 | gpos=ad.gpos, 153 | enterprisecas=ad.enterprisecas, 154 | aiacas=ad.aiacas, 155 | rootcas=ad.rootcas, 156 | ntauthstores=ad.ntauthstores, 157 | issuancepolicies=ad.issuancepolicies, 158 | certtemplates = ad.certtemplates, 159 | properties_level=properties_level, 160 | zip_files=zip_files 161 | ) 162 | 163 | 164 | def banner(): 165 | print(''' 166 | _____________________________ __ __ ______ __ __ __ __ _______ 167 | | _ / / __ / | ____/| | | | / __ \\ | | | | | \\ | | | \\ 168 | | |_) | | | | | | |__ | |__| | | | | | | | | | | \\| | | .--. | 169 | | _ < | | | | | __| | __ | | | | | | | | | | . ` | | | | | 170 | | |_) | | `--' | | | | | | | | `--' | | `--' | | |\\ | | '--' | 171 | |______/ \\______/ |__| |__| |___\\_\\________\\_\\________\\|__| \\___\\|_________\\ 172 | 173 | << @coffeegist | @Tw1sm >> 174 | ''') 175 | 176 | 177 | if __name__ == "__main__": 178 | app(prog_name="bofhound") 179 | -------------------------------------------------------------------------------- /bofhound/ad/__init__.py: -------------------------------------------------------------------------------- 1 | from .adds import ADDS -------------------------------------------------------------------------------- /bofhound/ad/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .trustdirection import TrustDirection 2 | from .trusttype import TrustType 3 | from .propertieslevel import PropertiesLevel -------------------------------------------------------------------------------- /bofhound/ad/helpers/propertieslevel.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class PropertiesLevel(Enum): 4 | Standard = 'Standard' 5 | Member = 'Member' 6 | All = 'All' -------------------------------------------------------------------------------- /bofhound/ad/helpers/trustdirection.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class TrustDirection(Enum): 4 | Disabled = 0 5 | Inbound = 1 6 | Outbound = 2 7 | Bidirectional = 3 -------------------------------------------------------------------------------- /bofhound/ad/helpers/trusttype.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class TrustType(Enum): 4 | ParentChild = 0 5 | CrossLink = 1 6 | Forest = 2 7 | External = 3 8 | Unknown = 4 -------------------------------------------------------------------------------- /bofhound/ad/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .bloodhound_domain import BloodHoundDomain 2 | from .bloodhound_computer import BloodHoundComputer 3 | from .bloodhound_user import BloodHoundUser 4 | from .bloodhound_group import BloodHoundGroup 5 | from .bloodhound_object import BloodHoundObject 6 | from .bloodhound_schema import BloodHoundSchema 7 | from .bloodhound_ou import BloodHoundOU 8 | from .bloodhound_container import BloodHoundContainer 9 | from .bloodhound_gpo import BloodHoundGPO 10 | from .bloodhound_enterpriseca import BloodHoundEnterpriseCA 11 | from .bloodhound_rootca import BloodHoundRootCA 12 | from .bloodhound_aiaca import BloodHoundAIACA 13 | from .bloodhound_ntauthstore import BloodHoundNTAuthStore 14 | from .bloodhound_issuancepolicy import BloodHoundIssuancePolicy 15 | from .bloodhound_certtemplate import BloodHoundCertTemplate 16 | from .bloodhound_domaintrust import BloodHoundDomainTrust 17 | from .bloodhound_crossref import BloodHoundCrossRef -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_aiaca.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | 7 | class BloodHoundAIACA(BloodHoundObject): 8 | 9 | GUI_PROPERTIES = [ 10 | 'domain', 'name', 'distinguishedname', 'domainsid', 'isaclprotected', 11 | 'description', 'whencreated', 'crosscertificatepair', 'hascrosscertificatepair', 12 | 'certthumbprint', 'certname', 'certchain', 'hasbasicconstraints', 13 | 'basicconstraintpathlength' 14 | ] 15 | 16 | COMMON_PROPERTIES = [ 17 | ] 18 | 19 | def __init__(self, object): 20 | super().__init__(object) 21 | 22 | self._entry_type = "AIACA" 23 | self.ContainedBy = {} 24 | self.IsACLProtected = False 25 | self.IsDeleted = False 26 | self.x509Certificate = None 27 | 28 | if 'objectguid' in object.keys(): 29 | self.ObjectIdentifier = object.get("objectguid") 30 | 31 | if 'distinguishedname' in object.keys(): 32 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 33 | self.Properties['domain'] = domain 34 | self.Properties['distinguishedname'] = object.get('distinguishedname').upper() 35 | 36 | if 'description' in object.keys(): 37 | self.Properties['description'] = object.get('description') 38 | else: 39 | self.Properties['description'] = None 40 | 41 | if 'name' in object.keys(): 42 | if 'domain' in self.Properties.keys(): 43 | self.Properties['name'] = object.get('name').upper() + "@" + self.Properties['domain'].upper() 44 | 45 | if 'crosscertificatepair' in object.keys(): 46 | self.Properties['crosscertificatepair'] = object.get('crosscertificatepair') 47 | self.Properties['hascrosscertificatepair'] = True 48 | else: 49 | self.Properties['crosscertificatepair'] = [] 50 | self.Properties['hascrosscertificatepair'] = False 51 | 52 | if 'cacertificate' in object.keys(): 53 | self.parse_cacertificate(object) 54 | 55 | if 'ntsecuritydescriptor' in object.keys(): 56 | self.RawAces = object['ntsecuritydescriptor'] 57 | 58 | 59 | def to_json(self, properties_level): 60 | self.Properties['isaclprotected'] = self.IsACLProtected 61 | data = super().to_json(properties_level) 62 | 63 | data["Aces"] = self.Aces 64 | data["IsDeleted"] = self.IsDeleted 65 | data["IsACLProtected"] = self.IsACLProtected 66 | data["ObjectIdentifier"] = self.ObjectIdentifier 67 | data["ContainedBy"] = self.ContainedBy 68 | 69 | return data 70 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_computer.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import datetime 3 | from bloodhound.ad.utils import ADUtils, LDAP_SID 4 | from .bloodhound_object import BloodHoundObject 5 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 6 | import logging 7 | 8 | class BloodHoundComputer(BloodHoundObject): 9 | 10 | GUI_PROPERTIES = [ 11 | 'domain', 'name', 'distinguishedname', 'domainsid', 'samaccountname', 12 | 'haslaps', 'isaclprotected', 'description', 'whencreated', 'enabled', 13 | 'unconstraineddelegation', 'trustedtoauth', 'isdc', 'lastlogon', 'lastlogontimestamp', 14 | 'pwdlastset', 'serviceprincipalnames', 'email', 'operatingsystem', 'sidhistory' 15 | ] 16 | 17 | COMMON_PROPERTIES = [ 18 | 'useraccountcontrol', 'dnshostname', 'samaccounttype', 'primarygroupid', 19 | 'msds-allowedtodelegateto', 'operatingsystemservicepack', 20 | 'msds-allowedtoactonbehalfofotheridentity', 'ms-mcs-admpwdexpirationtime', 21 | 'memberof' 22 | ] 23 | 24 | LOCAL_GROUP_SIDS = { 25 | "administrators": 544, 26 | "remote desktop users": 555, 27 | "remote management users": 580, 28 | "distributed com users": 562 29 | } 30 | 31 | def __init__(self, object): 32 | super().__init__(object) 33 | 34 | self._entry_type = "Computer" 35 | self.not_collected = { 36 | "Collected": False, 37 | "FailureReason": None, 38 | "Results": [] 39 | } 40 | self.uac = None 41 | self.IsACLProtected = False 42 | self.IsDeleted = False 43 | self.hostname = object.get('dnshostname', None) 44 | self.PrimaryGroupSid = self.get_primary_membership(object) # Returns none if non-existent 45 | self.sessions = None #['not currently supported by bofhound'] 46 | self.AllowedToDelegate = [] 47 | self.MemberOfDNs = [] 48 | self.sessions = [] 49 | self.ContainedBy = {} 50 | self.privileged_sessions = [] 51 | self.registry_sessions = [] 52 | self.local_group_members = {} # {group_name: [{member_sid, member_type}]} 53 | 54 | if 'dnshostname' in object.keys(): 55 | self.hostname = object.get('dnshostname', None) 56 | self.Properties['name'] = self.hostname.upper() 57 | logging.debug(f"Reading Computer object {ColorScheme.computer}{self.Properties['name']}[/]", extra=OBJ_EXTRA_FMT) 58 | 59 | if 'msds-allowedtodelegateto' in object.keys(): 60 | self.AllowedToDelegate = object.get('msds-allowedtodelegateto').split(', ') 61 | 62 | if 'useraccountcontrol' in object.keys(): 63 | self.uac = int(object.get('useraccountcontrol')) 64 | self.Properties['unconstraineddelegation'] = self.uac & 0x00080000 == 0x00080000 65 | self.Properties['enabled'] = self.uac & 2 == 0 66 | self.Properties['trustedtoauth'] = self.uac & 0x01000000 == 0x01000000 67 | self.Properties['isdc'] = self.uac & 0x2000 == 0x2000 68 | 69 | if 'operatingsystem' in object.keys(): 70 | self.Properties['operatingsystem'] = object.get('operatingsystem', 'Unknown') 71 | 72 | if 'operatingsystemservicepack' in object.keys() and 'operatingsystem' in self.Properties: 73 | self.Properties['operatingsystem'] += f' {object.get("operatingsystemservicepack")}' 74 | 75 | if 'sidhistory' in object.keys(): 76 | self.Properties['sidhistory'] = [LDAP_SID(bsid).formatCanonical() for bsid in object.get('sidhistory', [])] 77 | else: 78 | self.Properties['sidhistory'] = [] 79 | 80 | if 'distinguishedname' in object.keys(): 81 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 82 | self.Properties['domain'] = domain 83 | if 'samaccountname' in object.keys() and 'dnshostname' not in object.keys(): 84 | samacctname = object.get("samaccountname") 85 | if samacctname.endswith("$"): 86 | name = f'{samacctname[:-1]}.{domain}'.upper() 87 | else: 88 | name = f'{samacctname}.{domain}'.upper() 89 | self.Properties["name"] = name 90 | logging.debug(f"Reading Computer object {ColorScheme.computer}{self.Properties['name']}[/]", extra=OBJ_EXTRA_FMT) 91 | 92 | # TODO: HighValue / AdminCount 93 | self.Properties['highvalue'] = False 94 | 95 | if 'ms-mcs-admpwdexpirationtime' in object.keys(): 96 | self.Properties['haslaps'] = True 97 | else: 98 | self.Properties['haslaps'] = False 99 | 100 | if 'lastlogontimestamp' in object.keys(): 101 | self.Properties['lastlogontimestamp'] = ADUtils.win_timestamp_to_unix( 102 | int(object.get('lastlogontimestamp')) 103 | ) 104 | 105 | if 'lastlogon' in object.keys(): 106 | self.Properties['lastlogon'] = ADUtils.win_timestamp_to_unix( 107 | int(object.get('lastlogon')) 108 | ) 109 | 110 | if 'pwdlastset' in object.keys(): 111 | self.Properties['pwdlastset'] = ADUtils.win_timestamp_to_unix( 112 | int(object.get('pwdlastset')) 113 | ) 114 | 115 | if 'serviceprincipalname' in object.keys(): 116 | self.Properties['serviceprincipalnames'] = object.get('serviceprincipalname').split(', ') 117 | 118 | if 'description' in object.keys(): 119 | self.Properties['description'] = object.get('description') 120 | 121 | if 'email' in object.keys(): 122 | self.Properties['email'] = object.get('email') 123 | 124 | if 'samaccounttype' in object.keys(): 125 | self.Properties['samaccounttype'] = object.get('samaccounttype') 126 | 127 | if 'ntsecuritydescriptor' in object.keys(): 128 | self.RawAces = object['ntsecuritydescriptor'] 129 | 130 | if 'memberof' in object.keys(): 131 | self.MemberOfDNs = [f'CN={dn.upper()}' for dn in object.get('memberof').split(', CN=')] 132 | if len(self.MemberOfDNs) > 0: 133 | self.MemberOfDNs[0] = self.MemberOfDNs[0][3:] 134 | 135 | if 'email' in object.keys(): 136 | self.Properties['email'] = object.get('email') 137 | else: 138 | self.Properties['email'] = None 139 | 140 | if 'description' in object.keys(): 141 | self.Properties['description'] = object.get('description') 142 | else: 143 | self.Properties['description'] = None 144 | 145 | def to_json(self, properties_level): 146 | self.Properties['msds-allowedtodelegateto'] = self.AllowedToDelegate 147 | self.Properties['isaclprotected'] = self.IsACLProtected 148 | data = super().to_json(properties_level) 149 | data["Sessions"] = self.format_session_json(self.sessions) 150 | data["PrivilegedSessions"] = self.format_session_json(self.privileged_sessions) 151 | data["RegistrySessions"] = self.format_session_json(self.registry_sessions) 152 | data["ObjectIdentifier"] = self.ObjectIdentifier 153 | data["PrimaryGroupSID"] = self.PrimaryGroupSid 154 | data["AllowedToDelegate"] = self.AllowedToDelegate 155 | data["AllowedToAct"] = [] 156 | data["HasSidHistory"] = self.Properties.get("sidhistory", []) 157 | data["DumpSMSAPassword"] = [] 158 | data["LocalGroups"] = self.format_local_group_json() 159 | data["UserRights"] = [] 160 | data["Status"] = None 161 | data["IsDeleted"] = self.IsDeleted 162 | data["ContainedBy"] = self.ContainedBy 163 | data["Aces"] = self.Aces 164 | data["IsACLProtected"] = self.IsACLProtected 165 | data["IsDC"] = self.Properties["isdc"] 166 | 167 | return data 168 | 169 | 170 | def format_session_json(self, results): 171 | if len(results) == 0: 172 | return self.not_collected 173 | 174 | return { 175 | "Collected": True, 176 | "FailureReason": None, 177 | "Results": results 178 | } 179 | 180 | 181 | def format_local_group_json(self): 182 | if len(self.local_group_members) == 0: 183 | return [] 184 | 185 | data = [] 186 | 187 | hostname = self.hostname if self.hostname is not None else self.Properties['name'] 188 | 189 | for group_name, members in self.local_group_members.items(): 190 | data.append({ 191 | "ObjectIdentifier": f"{self.ObjectIdentifier}-{BloodHoundComputer.LOCAL_GROUP_SIDS[group_name]}", 192 | "Name": f"{group_name}@{hostname}".upper(), 193 | "Results": members, 194 | "Collected": True, 195 | "FailureReason": None 196 | }) 197 | 198 | return data 199 | 200 | 201 | # check if a session host's fully qualified hostname matches 202 | # the computer's dnshostname attribute 203 | def matches_dnshostname(self, session_host_fqdn): 204 | if self.Properties.get('dnshostname', '').upper() == session_host_fqdn.upper(): 205 | return True 206 | return False 207 | 208 | 209 | # check if a session host's hostname matches the computer's samaccountname 210 | def matches_samaccountname(self, session_hostname): 211 | if self.Properties['samaccountname'].upper() == session_hostname.upper() + '$': 212 | return True 213 | return False 214 | 215 | 216 | # add a session to the computer object 217 | def add_session(self, user_sid, session_type): 218 | session = { 219 | "UserSID": user_sid, 220 | "ComputerSID": self.ObjectIdentifier, 221 | } 222 | 223 | if session_type == 'privileged': 224 | self.privileged_sessions.append(session) 225 | elif session_type == 'registry': 226 | self.registry_sessions.append(session) 227 | elif session_type == 'session': 228 | self.sessions.append(session) 229 | 230 | 231 | # add a local group member 232 | def add_local_group_member(self, member_sid, member_type, group_name): 233 | member = { 234 | "ObjectIdentifier": member_sid, 235 | "ObjectType": member_type 236 | } 237 | 238 | if group_name.lower() not in self.local_group_members.keys(): 239 | self.local_group_members[group_name.lower()] = [ member ] 240 | else: 241 | self.local_group_members[group_name.lower()].append(member) 242 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_container.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | class BloodHoundContainer(BloodHoundObject): 7 | 8 | GUI_PROPERTIES = [ 9 | 'domain', 'name', 'distinguishedname', 'domainsid', 'highvalue', 'isaclprotected' 10 | ] 11 | 12 | COMMON_PROPERTIES = [ 13 | ] 14 | 15 | def __init__(self, object): 16 | super().__init__(object) 17 | 18 | self._entry_type = "Container" 19 | self.ContainedBy = {} 20 | self.Properties["blocksinheritance"] = False 21 | 22 | if 'objectguid' in object.keys(): 23 | self.ObjectIdentifier = object.get('objectguid').upper() 24 | 25 | if 'distinguishedname' in object.keys() and 'ou' in object.keys(): 26 | self.Properties["domain"] = ADUtils.ldap2domain(object.get('distinguishedname').upper()) 27 | self.Properties["name"] = f"{object.get('name').upper()}@{self.Properties['domain']}" 28 | logging.debug(f"Reading Container object {ColorScheme.ou}{self.Properties['name']}[/]", extra=OBJ_EXTRA_FMT) 29 | 30 | self.Properties['highvalue'] = False 31 | 32 | if 'ntsecuritydescriptor' in object.keys(): 33 | self.RawAces = object['ntsecuritydescriptor'] 34 | 35 | self.Properties["highvalue"] = False 36 | 37 | self.Aces = [] 38 | self.ChildObjects = [] 39 | self.IsDeleted = False 40 | self.IsACLProtected = False 41 | 42 | 43 | def to_json(self, properties_level): 44 | self.Properties['isaclprotected'] = self.IsACLProtected 45 | ou = super().to_json(properties_level) 46 | 47 | ou["ObjectIdentifier"] = self.ObjectIdentifier 48 | ou["ContainedBy"] = self.ContainedBy 49 | ou["Aces"] = self.Aces 50 | ou["ChildObjects"] = self.ChildObjects 51 | ou["IsDeleted"] = self.IsDeleted 52 | ou["IsACLProtected"] = self.IsACLProtected 53 | 54 | return ou 55 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_crossref.py: -------------------------------------------------------------------------------- 1 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 2 | import logging 3 | 4 | class BloodHoundCrossRef(object): 5 | 6 | def __init__(self, object): 7 | self.netBiosName = None 8 | self.nCName = None 9 | self.distinguishedName = None 10 | 11 | if 'netbiosname' in object.keys() and 'ncname' in object.keys() and 'distinguishedname' in object.keys(): 12 | self.netBiosName = object.get('netbiosname') 13 | self.nCName = object.get('ncname').upper() 14 | self.distinguishedName = object.get('distinguishedname').upper() 15 | logging.debug(f"Reading CrossRef object {ColorScheme.schema}{self.distinguishedName}[/]", extra=OBJ_EXTRA_FMT) 16 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_domain.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | class BloodHoundDomain(BloodHoundObject): 7 | 8 | GUI_PROPERTIES = [ 9 | 'distinguishedname', 'domainsid', 'description', 'whencreated', 10 | 'functionallevel', 'domain', 'isaclprotected', 'collected', 11 | 'name' 12 | ] 13 | 14 | COMMON_PROPERTIES = [ 15 | ] 16 | 17 | def __init__(self, object): 18 | super().__init__(object) 19 | 20 | self._entry_type = "Domain" 21 | self.GPLinks = [] 22 | self.ContainedBy = {} 23 | level_id = object.get('msds-behavior-version', 0) 24 | try: 25 | functional_level = ADUtils.FUNCTIONAL_LEVELS[int(level_id)] 26 | except KeyError: 27 | functional_level = 'Unknown' 28 | 29 | dc = None 30 | 31 | self.Properties['collected'] = True 32 | 33 | if 'distinguishedname' in object.keys(): 34 | self.Properties["name"] = ADUtils.ldap2domain(object.get('distinguishedname').upper()) 35 | self.Properties["domain"] = self.Properties["name"] 36 | dc = BloodHoundObject.get_domain_component(object.get('distinguishedname').upper()) 37 | logging.debug(f"Reading Domain object {ColorScheme.domain}{self.Properties['name']}[/]", extra=OBJ_EXTRA_FMT) 38 | 39 | if 'objectsid' in object.keys(): 40 | self.Properties["domainsid"] = object.get('objectsid') 41 | 42 | if 'distinguishedname' in object.keys(): 43 | self.Properties['distinguishedname'] = object.get('distinguishedname').upper() 44 | 45 | if 'description' in object.keys(): 46 | self.Properties["description"] = object.get('description') 47 | else: 48 | self.Properties["description"] = None 49 | 50 | if 'ntsecuritydescriptor' in object.keys(): 51 | self.RawAces = object['ntsecuritydescriptor'] 52 | 53 | if 'gplink' in object.keys(): 54 | # this is gross - not sure why gplink is coming in without a colon 55 | # (even from logs in test folder) but will hunt down later if it's a problem 56 | links = object.get('gplink').replace('LDAP//', 'LDAP://') 57 | 58 | # [['DN1', 'GPLinkOptions1'], ['DN2', 'GPLinkOptions2'], ...] 59 | self.GPLinks = [link.upper()[:-1].split(';') for link in links.split('[LDAP://')][1:] 60 | 61 | self.Properties["highvalue"] = True 62 | 63 | self.Properties["functionallevel"] = functional_level 64 | 65 | self.Trusts = [] 66 | self.Aces = [] 67 | self.Links = [] 68 | self.ChildObjects = [] 69 | self.GPOChanges = { 70 | "AffectedComputers": [], 71 | "DcomUsers": [], 72 | "LocalAdmins": [], 73 | "PSRemoteUsers": [], 74 | "RemoteDesktopUsers": [] 75 | } 76 | self.IsDeleted = False 77 | self.IsACLProtected = False 78 | 79 | 80 | def to_json(self, properties_level): 81 | self.Properties['isaclprotected'] = self.IsACLProtected 82 | domain = super().to_json(properties_level) 83 | 84 | domain["ObjectIdentifier"] = self.ObjectIdentifier 85 | domain["Trusts"] = self.Trusts 86 | domain["ContainedBy"] = None 87 | # The below is all unsupported as of now. 88 | domain["Aces"] = self.Aces 89 | domain["Links"] = self.Links 90 | domain["ChildObjects"] = self.ChildObjects 91 | 92 | self.GPOChanges["AffectedComputers"] = self.AffectedComputers 93 | self.GPOChanges["AffectedUsers"] = self.AffectedUsers 94 | domain["GPOChanges"] = self.GPOChanges 95 | 96 | domain["IsDeleted"] = self.IsDeleted 97 | domain["IsACLProtected"] = self.IsACLProtected 98 | 99 | return domain 100 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_domaintrust.py: -------------------------------------------------------------------------------- 1 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 2 | from bofhound.ad.models.bloodhound_object import BloodHoundObject 3 | from bloodhound.ad.utils import ADUtils 4 | from bloodhound.ad.trusts import ADDomainTrust 5 | from bofhound.ad.helpers import TrustType, TrustDirection 6 | from impacket.ldap.ldaptypes import LDAP_SID 7 | import logging 8 | 9 | class BloodHoundDomainTrust(object): 10 | 11 | def __init__(self, object): 12 | # Property for internal processing 13 | self.LocalDomainDn = '' 14 | 15 | # Property that holds final dict for domain JSON 16 | # { 17 | # "TargetDomainName": "", 18 | # "TargetDomainSid": "", 19 | # "IsTransitive": "", 20 | # "TrustDirection": "", 21 | # "TrustType": "", 22 | # "SidFilteringEnabled": "" 23 | # } 24 | self.TrustProperties = None 25 | 26 | if 'distinguishedname' in object.keys() and 'trustpartner' in object.keys() and \ 27 | 'trustdirection' in object.keys() and 'trusttype' in object.keys() and 'trustattributes' in object.keys() and \ 28 | 'securityidentifier' in object.keys(): 29 | 30 | self.LocalDomainDn = BloodHoundObject.get_domain_component(object.get('distinguishedname')).upper() 31 | trust_partner = object.get('trustpartner').upper() 32 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 33 | logging.debug(f'Reading trust relationship between {ColorScheme.domain}{domain}[/] and {ColorScheme.domain}{trust_partner}[/]', extra=OBJ_EXTRA_FMT) 34 | domainsid = LDAP_SID() 35 | domainsid.fromCanonical(object.get('securityidentifier')) 36 | trust = ADDomainTrust(trust_partner, int(object.get('trustdirection')), object.get('trusttype'), int(object.get('trustattributes')), domainsid.getData()) 37 | self.TrustProperties = trust.to_output() 38 | 39 | # BHCE now wants trusttype and direction defined as string names instead of int values 40 | 41 | self.TrustProperties['TrustDirection'] = TrustDirection(self.TrustProperties['TrustDirection']).name 42 | self.TrustProperties['TrustType'] = TrustType(self.TrustProperties['TrustType']).name 43 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_enterpriseca.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | from bofhound.ad.helpers.cert_utils import PkiCertificateAuthorityFlags 6 | 7 | 8 | class BloodHoundEnterpriseCA(BloodHoundObject): 9 | 10 | GUI_PROPERTIES = [ 11 | 'domain', 'name', 'distinguishedname', 'domainsid', 'isaclprotected', 12 | 'description', 'whencreated', 'flags', 'caname', 'dnshostname', 'certthumbprint', 13 | 'certname', 'certchain', 'hasbasicconstraints', 'basicconstraintpathlength', 14 | 'casecuritycollected', 'enrollmentagentrestrictionscollected', 'isuserspecifiessanenabledcollected', 15 | 'unresolvedpublishedtemplates' 16 | ] 17 | 18 | COMMON_PROPERTIES = [ 19 | ] 20 | 21 | def __init__(self, object): 22 | super().__init__(object) 23 | 24 | self._entry_type = "EnterpriseCA" 25 | self.IsDeleted = False 26 | self.ContainedBy = {} 27 | self.IsACLProtected = False 28 | self.Properties['casecuritycollected'] = False 29 | self.Properties['enrollmentagentrestrictionscollected'] = False 30 | self.Properties['isuserspecifiessanenabledcollected'] = False 31 | self.Properties['unresolvedpublishedtemplates'] = [] 32 | self.CARegistryData = None 33 | self.x509Certificate = None 34 | 35 | if 'objectguid' in object.keys(): 36 | self.ObjectIdentifier = object.get("objectguid") 37 | 38 | if 'distinguishedname' in object.keys(): 39 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 40 | self.Properties['domain'] = domain 41 | self.Properties['distinguishedname'] = object.get('distinguishedname').upper() 42 | 43 | if 'description' in object.keys(): 44 | self.Properties['description'] = object.get('description') 45 | else: 46 | self.Properties['description'] = None 47 | 48 | if 'flags' in object.keys(): 49 | int_flag = int(object.get("flags")) 50 | self.Properties['flags'] = ', '.join([member.name for member in PkiCertificateAuthorityFlags if member.value & int_flag == member.value]) 51 | 52 | if 'name' in object.keys(): 53 | self.Properties['caname'] = object.get('name') 54 | if 'domain' in self.Properties.keys(): 55 | self.Properties['name'] = object.get('name').upper() + "@" + self.Properties['domain'].upper() 56 | 57 | if 'dnshostname' in object.keys(): 58 | self.Properties['dnshostname'] = object.get('dnshostname') 59 | 60 | if 'cacertificate' in object.keys(): 61 | self.parse_cacertificate(object) 62 | 63 | if 'ntsecuritydescriptor' in object.keys(): 64 | self.RawAces = object['ntsecuritydescriptor'] 65 | 66 | self.HostingComputer = None 67 | self.EnabledCertTemplates = [] 68 | 69 | if 'certificatetemplates' in object.keys(): 70 | self.CertTemplates = object.get('certificatetemplates').split(', ') 71 | 72 | 73 | def to_json(self, properties_level): 74 | self.Properties['isaclprotected'] = self.IsACLProtected 75 | data = super().to_json(properties_level) 76 | 77 | data["HostingComputer"] = self.HostingComputer 78 | data["CARegistryData"] = self.CARegistryData 79 | data["EnabledCertTemplates"] = self.EnabledCertTemplates 80 | data["Aces"] = self.Aces 81 | data["ObjectIdentifier"] = self.ObjectIdentifier 82 | data["IsDeleted"] = self.IsDeleted 83 | data["IsACLProtected"] = self.IsACLProtected 84 | data["ContainedBy"] = self.ContainedBy 85 | 86 | return data -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_gpo.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | class BloodHoundGPO(BloodHoundObject): 7 | 8 | GUI_PROPERTIES = [ 9 | 'distinguishedname', 'whencreated', 10 | 'domain', 'domainsid', 'name', 'highvalue', 11 | 'description', 'gpcpath', 'isaclprotected' 12 | ] 13 | 14 | COMMON_PROPERTIES = [ 15 | ] 16 | 17 | def __init__(self, object): 18 | super().__init__(object) 19 | 20 | self._entry_type = "GPO" 21 | self.ContainedBy = {} 22 | 23 | if 'distinguishedname' in object.keys() and 'displayname' in object.keys(): 24 | self.Properties["domain"] = ADUtils.ldap2domain(object.get('distinguishedname').upper()) 25 | self.Properties["name"] = f"{object.get('displayname').upper()}@{self.Properties['domain']}" 26 | logging.debug(f"Reading GPO object {ColorScheme.gpo}{self.Properties['name']}[/]", extra=OBJ_EXTRA_FMT) 27 | 28 | if 'objectguid' in object.keys(): 29 | self.ObjectIdentifier = object.get('objectguid').upper() 30 | 31 | if 'ntsecuritydescriptor' in object.keys(): 32 | self.RawAces = object['ntsecuritydescriptor'] 33 | 34 | if 'description' in object.keys(): 35 | self.Properties["description"] = object.get('description') 36 | 37 | if 'gpcfilesyspath' in object.keys(): 38 | self.Properties["gpcpath"] = object.get('gpcfilesyspath') 39 | 40 | self.Properties["highvalue"] = False 41 | 42 | self.Aces = [] 43 | 44 | self.IsDeleted = False 45 | self.IsACLProtected = False 46 | 47 | def to_json(self, properties_level): 48 | self.Properties['isaclprotected'] = self.IsACLProtected 49 | gpo = super().to_json(properties_level) 50 | 51 | gpo["ObjectIdentifier"] = self.ObjectIdentifier 52 | gpo["ContainedBy"] = self.ContainedBy 53 | # The below is all unsupported as of now. 54 | gpo["Aces"] = self.Aces 55 | gpo["IsDeleted"] = self.IsDeleted 56 | gpo["IsACLProtected"] = self.IsACLProtected 57 | 58 | return gpo 59 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_group.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | class BloodHoundGroup(BloodHoundObject): 7 | 8 | GUI_PROPERTIES = [ 9 | 'distinguishedname', 'samaccountname', 'objectsid', 10 | 'admincount', 'description', 'whencreated', 11 | 'name', 'domain', 'domainsid' 12 | ] 13 | 14 | COMMON_PROPERTIES = [ 15 | 'member', 'memberof' 16 | ] 17 | 18 | def __init__(self, object): 19 | super().__init__(object) 20 | 21 | self._entry_type = "Group" 22 | self.Members = [] 23 | self.Aces = [] 24 | self.ContainedBy = {} 25 | self.IsDeleted = False 26 | self.IsACLProtected = False 27 | self.MemberDNs = [] 28 | self.MemberOfDNs = [] 29 | self.IsACLProtected = False 30 | 31 | if 'distinguishedname' in object.keys() and 'samaccountname' in object.keys(): 32 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 33 | name = f'{object.get("samaccountname")}@{domain}'.upper() 34 | self.Properties["name"] = name 35 | self.Properties["domain"] = domain 36 | logging.debug(f"Reading Group object {ColorScheme.group}{name}[/]", extra=OBJ_EXTRA_FMT) 37 | 38 | if 'objectsid' in object.keys(): 39 | #objectid = BloodHoundObject.get_sid(object.get('objectsid', None), object.get('distinguishedname', None)) 40 | objectid = object.get('objectsid') 41 | self.ObjectIdentifier = objectid 42 | self.Properties["domainsid"] = objectid.rsplit('-',1)[0] 43 | 44 | 45 | if 'distinguishedname' in object.keys(): 46 | self.Properties["distinguishedname"] = object.get('distinguishedname', None).upper() 47 | 48 | if 'admincount' in object.keys(): 49 | self.Properties["admincount"] = int(object.get('admincount')) == 1 # do not move this lower, it may break imports for users 50 | else: 51 | self.Properties["admincount"] = False 52 | 53 | if 'description' in object.keys(): 54 | self.Properties["description"] = object.get('description') 55 | 56 | if 'member' in object.keys(): 57 | self.MemberDNs = [f'CN={dn.upper()}' for dn in object.get('member').split(', CN=')] 58 | if len(self.MemberDNs) > 0: 59 | self.MemberDNs[0] = self.MemberDNs[0][3:] 60 | 61 | if 'ntsecuritydescriptor' in object.keys(): 62 | self.RawAces = object['ntsecuritydescriptor'] 63 | 64 | if 'memberof' in object.keys(): 65 | self.MemberOfDNs = [f'CN={dn.upper()}' for dn in object.get('memberof').split(', CN=')] 66 | if len(self.MemberOfDNs) > 0: 67 | self.MemberOfDNs[0] = self.MemberOfDNs[0][3:] 68 | 69 | 70 | def add_group_member(self, object, object_type): 71 | member = { 72 | "ObjectIdentifier": object.ObjectIdentifier, 73 | "ObjectType": object_type 74 | } 75 | self.Members.append(member) 76 | 77 | 78 | def to_json(self, properties_level): 79 | group = super().to_json(properties_level) 80 | group["ObjectIdentifier"] = self.ObjectIdentifier 81 | group["ContainedBy"] = self.ContainedBy 82 | group["Aces"] = self.Aces 83 | group["Members"] = self.Members 84 | group["IsDeleted"] = self.IsDeleted 85 | group["IsACLProtected"] = self.IsACLProtected 86 | 87 | return group 88 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_issuancepolicy.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | from asn1crypto import x509 6 | import hashlib 7 | import base64 8 | 9 | 10 | class BloodHoundIssuancePolicy(BloodHoundObject): 11 | 12 | GUI_PROPERTIES = [ 13 | 'domain', 'name', 'distinguishedname', 'domainsid', 'isaclprotected', 14 | 'description', 'whencreated', 'displayname', 'certtemplateoid' 15 | ] 16 | 17 | COMMON_PROPERTIES = [ 18 | ] 19 | 20 | def __init__(self, object): 21 | super().__init__(object) 22 | 23 | self._entry_type = "IssuancePolicy" 24 | self.IsDeleted = False 25 | self.ContainedBy = {} 26 | self.IsACLProtected = False 27 | self.GroupLink = None # {} 28 | 29 | if 'objectguid' in object.keys(): 30 | self.ObjectIdentifier = object.get("objectguid") 31 | 32 | if 'distinguishedname' in object.keys(): 33 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 34 | self.Properties['domain'] = domain 35 | self.Properties['distinguishedname'] = object.get('distinguishedname').upper() 36 | 37 | # name relies on domain existing, so it can be appended to the end 38 | if 'displayname' in object.keys(): 39 | self.Properties['name'] = f"{object.get('displayname').upper()}@{domain}" 40 | 41 | if 'displayname' in object.keys(): 42 | self.Properties['displayname'] = object.get('displayname') 43 | 44 | if 'description' in object.keys(): 45 | self.Properties['description'] = object.get('description') 46 | else: 47 | self.Properties['description'] = None 48 | 49 | if 'mspki-cert-template-oid' in object.keys(): 50 | self.Properties['mspki-cert-template-oid'] = object.get('mspki-cert-template-oid') 51 | 52 | if 'ntsecuritydescriptor' in object.keys(): 53 | self.RawAces = object['ntsecuritydescriptor'] 54 | 55 | 56 | def to_json(self, properties_level): 57 | self.Properties['isaclprotected'] = self.IsACLProtected 58 | data = super().to_json(properties_level) 59 | 60 | data["Aces"] = self.Aces 61 | data["ObjectIdentifier"] = self.ObjectIdentifier 62 | data["IsDeleted"] = self.IsDeleted 63 | data["IsACLProtected"] = self.IsACLProtected 64 | data["ContainedBy"] = self.ContainedBy 65 | 66 | return data -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_ntauthstore.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | from asn1crypto import x509 6 | import hashlib 7 | import base64 8 | 9 | 10 | class BloodHoundNTAuthStore(BloodHoundObject): 11 | 12 | GUI_PROPERTIES = [ 13 | 'domain', 'name', 'distinguishedname', 'domainsid', 'isaclprotected', 14 | 'description', 'whencreated', 'certthumbprints' 15 | ] 16 | 17 | COMMON_PROPERTIES = [ 18 | ] 19 | 20 | def __init__(self, object): 21 | super().__init__(object) 22 | 23 | self._entry_type = "NTAuthStore" 24 | self.IsDeleted = False 25 | self.ContainedBy = {} 26 | self.IsACLProtected = False 27 | 28 | self.Properties['certthumbprints'] = [] 29 | 30 | if 'objectguid' in object.keys(): 31 | self.ObjectIdentifier = object.get("objectguid") 32 | 33 | if 'distinguishedname' in object.keys(): 34 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 35 | self.Properties['domain'] = domain 36 | self.Properties['distinguishedname'] = object.get('distinguishedname').upper() 37 | 38 | # name relies on domain existing, so it can be appended to the end 39 | if 'name' in object.keys(): 40 | self.Properties['name'] = f"{object.get('name').upper()}@{domain}" 41 | 42 | if 'description' in object.keys(): 43 | self.Properties['description'] = object.get('description') 44 | else: 45 | self.Properties['description'] = None 46 | 47 | if 'cacertificate' in object.keys(): 48 | certificate_b64 = object.get("cacertificate") 49 | 50 | certificate_b64_list = certificate_b64.split(", ") 51 | for cert in certificate_b64_list: 52 | certificate_byte_array = base64.b64decode(cert) 53 | thumbprint = hashlib.sha1(certificate_byte_array).hexdigest().upper() 54 | self.Properties['certthumbprints'].append(thumbprint) 55 | 56 | if 'ntsecuritydescriptor' in object.keys(): 57 | self.RawAces = object['ntsecuritydescriptor'] 58 | 59 | 60 | def to_json(self, properties_level): 61 | self.Properties['isaclprotected'] = self.IsACLProtected 62 | data = super().to_json(properties_level) 63 | 64 | data["Aces"] = self.Aces 65 | data["ObjectIdentifier"] = self.ObjectIdentifier 66 | data["IsDeleted"] = self.IsDeleted 67 | data["IsACLProtected"] = self.IsACLProtected 68 | data["ContainedBy"] = self.ContainedBy 69 | 70 | if "domainsid" in self.Properties: 71 | data["DomainSID"] = self.Properties["domainsid"] 72 | 73 | return data -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_object.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import calendar 3 | import hashlib 4 | import base64 5 | from asn1crypto import x509 6 | from datetime import datetime 7 | from bloodhound.enumeration.acls import SecurityDescriptor, ACL, ACCESS_ALLOWED_ACE, ACCESS_MASK, ACE, ACCESS_ALLOWED_OBJECT_ACE, has_extended_right, EXTRIGHTS_GUID_MAPPING, can_write_property, ace_applies 8 | from bloodhound.ad.utils import ADUtils 9 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 10 | from bofhound.ad.models.bloodhound_schema import BloodHoundSchema 11 | from bofhound.ad.helpers import PropertiesLevel 12 | 13 | # TODO: Move appropriate actions from this class to a super class of Users/Computers/maybe groups? 14 | 15 | class BloodHoundObject(): 16 | 17 | GUI_PROPERTIES = [ 18 | ] 19 | 20 | COMMON_PROPERTIES = [ 21 | ] 22 | 23 | NEVER_SHOW_PROPERTIES = [ 24 | 'ntsecuritydescriptor', 'serviceprincipalname' 25 | ] 26 | 27 | def __init__(self, object=None): 28 | self.ObjectIdentifier = None 29 | self.Aces = [] 30 | self.RawAces = None 31 | self.Properties = {} 32 | 33 | if isinstance(object, dict): 34 | # Ensure all keys are lowercase 35 | for item in object.keys(): 36 | self.Properties[item.lower()] = object[item] 37 | 38 | self.ObjectIdentifier = BloodHoundObject.get_sid(object.get('objectsid', None), object.get('distinguishedname', None)) 39 | 40 | if 'distinguishedname' in object.keys(): 41 | self.Properties["distinguishedname"] = object.get('distinguishedname', None).upper() 42 | 43 | self.__parse_whencreated(object) 44 | 45 | 46 | def get_primary_membership(self, object): 47 | """ 48 | Construct primary membership from RID to SID (BloodHound 3.0 only) 49 | """ 50 | try: 51 | primarygroupid = object.get('primarygroupid') 52 | return '%s-%s' % ('-'.join(object.get('objectsid').split('-')[:-1]), primarygroupid) 53 | except (TypeError, KeyError, AttributeError): 54 | # Doesn't have a primarygroupid, means it is probably a Group instead of a user 55 | return None 56 | 57 | 58 | def merge_entry(self, object, base_preference=False): 59 | """Merge the properties of another BloodHoundObject in with this one. 60 | 61 | Keyword arguments: 62 | object -- the new object to merge (required) 63 | base_preference -- whether or not to prefer the base object. If true, self's properties could be overwritten (default False) 64 | """ 65 | self_attributes = self.__dict__.keys() 66 | for attr, value in object.__dict__.items(): 67 | if attr == 'Properties': 68 | for k, v in getattr(object, attr).items(): 69 | if not k in getattr(self, attr).keys(): 70 | getattr(self, attr)[k] = v 71 | else: 72 | if k == 'distinguishedname': 73 | if not getattr(self, attr)[k]: 74 | getattr(self, attr)[k] = v 75 | if not base_preference: 76 | if getattr(object, attr).get(k, None): 77 | getattr(self, attr)[k] = v 78 | 79 | elif not attr in self_attributes: 80 | setattr(self, attr, value) 81 | else: 82 | if attr == 'ObjectIdentifier': 83 | if not self.ObjectIdentifier: 84 | setattr(self, attr, value) 85 | 86 | if not base_preference: 87 | if getattr(object, attr): 88 | setattr(self, attr, value) 89 | 90 | 91 | 92 | def get_distinguished_name(self): 93 | try: 94 | return self.Properties['distinguishedname'].upper() 95 | except KeyError: 96 | return None 97 | 98 | 99 | def get_property(self, property): 100 | try: 101 | return self.Properties[property] 102 | except KeyError: 103 | return None 104 | 105 | 106 | def to_json(self, properties_level): 107 | data = { 108 | "Properties": {} 109 | } 110 | 111 | match properties_level: 112 | case PropertiesLevel.Standard: 113 | for property in self.Properties.keys(): 114 | if property in self.GUI_PROPERTIES \ 115 | and property not in self.NEVER_SHOW_PROPERTIES: 116 | data["Properties"][property] = self.Properties[property] 117 | case PropertiesLevel.Member: 118 | for property in self.Properties.keys(): 119 | if (property in self.COMMON_PROPERTIES or property in self.GUI_PROPERTIES) \ 120 | and property not in self.NEVER_SHOW_PROPERTIES: 121 | data["Properties"][property] = self.Properties[property] 122 | case PropertiesLevel.All: 123 | data["Properties"] = self.Properties 124 | 125 | return data 126 | 127 | 128 | def __parse_whencreated(self, object): 129 | whencreated = object.get('whencreated', 0) 130 | try: 131 | if not isinstance(whencreated, int): 132 | whencreated = calendar.timegm(datetime.strptime(whencreated, "%Y%m%d%H%M%S.0Z").timetuple()) 133 | self.Properties['whencreated'] = whencreated 134 | except: 135 | self.Properties['whencreated'] = whencreated 136 | 137 | 138 | # used by Domains and OUs 139 | def add_linked_gpo(self, object, gp_link_options): 140 | enforced = False 141 | if gp_link_options == '2': 142 | enforced = True 143 | 144 | link = { 145 | "GUID": object.ObjectIdentifier, 146 | "IsEnforced": False 147 | } 148 | self.Links.append(link) 149 | 150 | 151 | # used by Domains and OUs 152 | def add_ou_member(self, object, object_type): 153 | member = { 154 | "ObjectIdentifier": object.ObjectIdentifier, 155 | "ObjectType": object_type 156 | } 157 | self.ChildObjects.append(member) 158 | 159 | 160 | @staticmethod 161 | def get_sid(sid, dn=None): 162 | if sid in ADUtils.WELLKNOWN_SIDS: 163 | domain = ADUtils.ldap2domain(dn).upper() 164 | PrincipalSid = f'{domain}-{sid}' 165 | else: 166 | PrincipalSid = sid 167 | 168 | return PrincipalSid 169 | 170 | 171 | # Should probably move to ADDS? 172 | @staticmethod 173 | def get_domain_component(dn): 174 | dc = '' 175 | for component in dn.split(','): 176 | if component.startswith('DC='): 177 | dc += f"{component}," 178 | return dc[:-1] 179 | 180 | 181 | @staticmethod 182 | def get_dn(domain): 183 | components = domain.split('.') 184 | base = '' 185 | for comp in components: 186 | base += f',DC={comp}' 187 | 188 | return base[1:] 189 | 190 | 191 | @staticmethod 192 | def get_cn_from_dn(dn): 193 | for component in dn.split(',', 1): 194 | if component.startswith('CN='): 195 | return component[3:] 196 | 197 | # 198 | # for AIACAs, EnterpriseCAs, and RootCAs 199 | # 200 | def parse_cacertificate(self, object): 201 | certificate_b64 = object.get("cacertificate") 202 | 203 | certificate_byte_array = base64.b64decode(certificate_b64) 204 | 205 | # 206 | # thumbprint 207 | # 208 | thumbprint = hashlib.sha1(certificate_byte_array).hexdigest().upper() 209 | self.Properties['certthumbprint'] = thumbprint 210 | 211 | # 212 | # certname 213 | # 214 | certificate_byte_array = base64.b64decode(certificate_b64) 215 | ca_cert = x509.Certificate.load(certificate_byte_array)["tbs_certificate"] 216 | self.x509Certificate = ca_cert # set for post-processing 217 | self.Properties['certname'] = ca_cert['subject'].native.get('common_name', thumbprint) 218 | 219 | # 220 | # cert chain 221 | # not sure that Python libs offer a way to build the chain without access to the issuer cert like it seems SharpHound does 222 | # https://github.com/BloodHoundAD/SharpHoundCommon/blob/ea6b097927c5bb795adb8589e9a843293d36ae37/src/CommonLib/Processors/LDAPPropertyProcessor.cs#L772 223 | # so we will have the build the chain manually in post-processing 224 | # 225 | self.Properties['certchain'] = [] 226 | 227 | # 228 | # extensions (hasbasicconstraints, basicconstraintpathlength) 229 | # 230 | self.Properties['hasbasicconstraints'] = False 231 | self.Properties['basicconstraintpathlength'] = 0 232 | for ext in ca_cert['extensions']: 233 | if ext['extn_id'].native == 'basic_constraints': 234 | basic_constraints = ext['extn_value'].parsed 235 | if basic_constraints['path_len_constraint'].native is not None: 236 | self.Properties['hasbasicconstraints'] = True 237 | self.Properties['basicconstraintpathlength'] = basic_constraints['path_len_constraint'].native 238 | break 239 | 240 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_ou.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | class BloodHoundOU(BloodHoundObject): 7 | 8 | GUI_PROPERTIES = [ 9 | 'distinguishedname', 'whencreated', 10 | 'domain', 'domainsid', 'name', 'highvalue', 'description', 11 | 'blocksinheritance', 'isaclprotected' 12 | ] 13 | 14 | COMMON_PROPERTIES = [ 15 | ] 16 | 17 | def __init__(self, object): 18 | super().__init__(object) 19 | 20 | self._entry_type = "OU" 21 | self.GPLinks = [] 22 | self.ContainedBy = {} 23 | self.Properties["blocksinheritance"] = False 24 | 25 | if 'distinguishedname' in object.keys() and 'ou' in object.keys(): 26 | self.Properties["domain"] = ADUtils.ldap2domain(object.get('distinguishedname').upper()) 27 | self.Properties["name"] = f"{object.get('ou').upper()}@{self.Properties['domain']}" 28 | logging.debug(f"Reading OU object {ColorScheme.ou}{self.Properties['name']}[/]", extra=OBJ_EXTRA_FMT) 29 | 30 | if 'objectguid' in object.keys(): 31 | self.ObjectIdentifier = object.get('objectguid').upper() 32 | 33 | if 'ntsecuritydescriptor' in object.keys(): 34 | self.RawAces = object['ntsecuritydescriptor'] 35 | 36 | if 'description' in object.keys(): 37 | self.Properties["description"] = object.get('description') 38 | 39 | if 'gplink' in object.keys(): 40 | # [['DN1', 'GPLinkOptions1'], ['DN2', 'GPLinkOptions2'], ...] 41 | self.GPLinks = [link.upper()[:-1].split(';') for link in object.get('gplink').split('[LDAP//')][1:] 42 | 43 | if 'gpoptions' in object.keys(): 44 | gpoptions = object.get('gpoptions') 45 | if gpoptions == '1': 46 | self.Properties["blocksinheritance"] = True 47 | 48 | self.Properties["highvalue"] = False 49 | 50 | self.Aces = [] 51 | self.Links = [] 52 | self.ChildObjects = [] 53 | self.GPOChanges = { 54 | "AffectedComputers": [], 55 | "AffectedUsers": [], 56 | "DcomUsers": [], 57 | "LocalAdmins": [], 58 | "PSRemoteUsers": [], 59 | "RemoteDesktopUsers": [] 60 | } 61 | self.IsDeleted = False 62 | self.IsACLProtected = False 63 | 64 | 65 | def to_json(self, properties_level): 66 | self.Properties['isaclprotected'] = self.IsACLProtected 67 | ou = super().to_json(properties_level) 68 | 69 | ou["ObjectIdentifier"] = self.ObjectIdentifier 70 | ou["ContainedBy"] = self.ContainedBy 71 | # The below is all unsupported as of now. 72 | ou["Aces"] = self.Aces 73 | ou["Links"] = self.Links 74 | ou["ChildObjects"] = self.ChildObjects 75 | 76 | self.GPOChanges["AffectedComputers"] = self.AffectedComputers 77 | self.GPOChanges["AffectedUsers"] = self.AffectedUsers 78 | ou["GPOChanges"] = self.GPOChanges 79 | 80 | ou["IsDeleted"] = self.IsDeleted 81 | ou["IsACLProtected"] = self.IsACLProtected 82 | 83 | return ou 84 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_rootca.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from .bloodhound_object import BloodHoundObject 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | 7 | class BloodHoundRootCA(BloodHoundObject): 8 | 9 | GUI_PROPERTIES = [ 10 | 'domain', 'name', 'distinguishedname', 'domainsid', 'isaclprotected', 11 | 'description', 'whencreated', 'certthumbprint', 'certname', 'certchain', 12 | 'hasbasicconstraints', 'basicconstraintpathlength' 13 | ] 14 | 15 | COMMON_PROPERTIES = [ 16 | ] 17 | 18 | def __init__(self, object): 19 | super().__init__(object) 20 | 21 | self._entry_type = "RootCA" 22 | self.ContainedBy = {} 23 | self.IsACLProtected = False 24 | self.IsDeleted = False 25 | self.x509Certificate = None 26 | 27 | if 'objectguid' in object.keys(): 28 | self.ObjectIdentifier = object.get("objectguid") 29 | 30 | if 'distinguishedname' in object.keys(): 31 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 32 | self.Properties['domain'] = domain 33 | self.Properties['distinguishedname'] = object.get('distinguishedname').upper() 34 | 35 | if 'description' in object.keys(): 36 | self.Properties['description'] = object.get('description') 37 | else: 38 | self.Properties['description'] = None 39 | 40 | if 'name' in object.keys(): 41 | if 'domain' in self.Properties.keys(): 42 | self.Properties['name'] = object.get('name').upper() + "@" + self.Properties['domain'].upper() 43 | 44 | if 'cacertificate' in object.keys(): 45 | self.parse_cacertificate(object) 46 | # root CA certificates are self-signed 47 | self.Properties['certchain'] = [ self.Properties['certthumbprint'] ] 48 | 49 | if 'ntsecuritydescriptor' in object.keys(): 50 | self.RawAces = object['ntsecuritydescriptor'] 51 | 52 | def to_json(self, properties_level): 53 | self.Properties['isaclprotected'] = self.IsACLProtected 54 | data = super().to_json(properties_level) 55 | data['IsACLProtected'] = self.IsACLProtected 56 | data['IsDeleted'] = self.IsDeleted 57 | data["ObjectIdentifier"] = self.ObjectIdentifier 58 | data["ContainedBy"] = self.ContainedBy 59 | data["Aces"] = self.Aces 60 | 61 | if "domainsid" in self.Properties: 62 | data["DomainSID"] = self.Properties["domainsid"] 63 | 64 | return data -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_schema.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from uuid import UUID 3 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 4 | import logging 5 | 6 | class BloodHoundSchema(object): 7 | 8 | def __init__(self, object): 9 | self.Name = None 10 | self.SchemaIdGuid = None 11 | 12 | if 'name' in object.keys() and 'schemaidguid' in object.keys(): 13 | self.Name = object.get('name').lower() 14 | try: 15 | value = object.get('schemaidguid') 16 | if '-' in value: 17 | self.SchemaIdGuid = value.lower() 18 | else: 19 | self.SchemaIdGuid = str(UUID(bytes_le=base64.b64decode(value))).lower() 20 | logging.debug(f"Reading Schema object {ColorScheme.schema}{self.Name}[/]", extra=OBJ_EXTRA_FMT) 21 | except: 22 | logging.warning(f"Error base64 decoding SchemaIDGUID attribute on Schema {ColorScheme.schema}{self.Name}[/]", extra=OBJ_EXTRA_FMT) 23 | -------------------------------------------------------------------------------- /bofhound/ad/models/bloodhound_user.py: -------------------------------------------------------------------------------- 1 | from bloodhound.ad.utils import ADUtils 2 | from bloodhound.ad.structures import LDAP_SID 3 | from bloodhound.enumeration.memberships import MembershipEnumerator 4 | from .bloodhound_object import BloodHoundObject 5 | from bofhound.logger import OBJ_EXTRA_FMT, ColorScheme 6 | import logging 7 | 8 | class BloodHoundUser(BloodHoundObject): 9 | 10 | GUI_PROPERTIES = [ 11 | 'domain', 'name', 'distinguishedname', 'domainsid', 'samaccountname', 12 | 'isaclprotected', 'description', 'whencreated', 'sensitive', 13 | 'dontreqpreauth', 'passwordnotreqd', 'unconstraineddelegation', 14 | 'pwdneverexpires', 'enabled', 'trustedtoauth', 'lastlogon', 'lastlogontimestamp', 15 | 'pwdlastset', 'serviceprincipalnames', 'hasspn', 'admincount', 16 | 'displayname', 'email', 'title', 'homedirectory', 'userpassword', 17 | 'unixpassword', 'unicodepassword', 'sfupassword', 'logonscript', 'sidhistory' 18 | ] 19 | 20 | COMMON_PROPERTIES = [ 21 | 'samaccounttype', 'objectsid', 'primarygroupid', 'isdeleted', 22 | 'msds-groupmsamembership', 'serviceprincipalname', 'useraccountcontrol', 23 | 'mail', 'msds-allowedtodelegateto', 'unicodepwd', 'allowedtodelegate', 24 | 'memberof' 25 | ] 26 | 27 | def __init__(self, object=None): 28 | super().__init__(object) 29 | 30 | self._entry_type = "User" 31 | self.PrimaryGroupSid = None 32 | self.AllowedToDelegate = [] 33 | self.Aces = [] 34 | self.ContainedBy = {} 35 | self.SPNTargets = [] 36 | self.HasSIDHistory = [] 37 | self.IsACLProtected = False 38 | self.MemberOfDNs = [] 39 | 40 | if isinstance(object, dict): 41 | self.PrimaryGroupSid = self.get_primary_membership(object) # Returns none if not exist 42 | 43 | if 'distinguishedname' in object.keys() and 'samaccountname' in object.keys(): 44 | domain = ADUtils.ldap2domain(object.get('distinguishedname')).upper() 45 | name = f'{object.get("samaccountname")}@{domain}'.upper() 46 | self.Properties["name"] = name 47 | self.Properties["domain"] = domain 48 | logging.debug(f"Reading User object {ColorScheme.user}{name}[/]", extra=OBJ_EXTRA_FMT) 49 | 50 | if 'admincount' in object.keys(): 51 | self.Properties["admincount"] = int(object.get('admincount')) == 1 # do not move this lower, it may break imports for users 52 | 53 | # self.Properties["highvalue"] = False, 54 | 55 | if 'useraccountcontrol' in object.keys(): 56 | uac = int(object.get('useraccountcontrol', 0)) 57 | self.Properties["unconstraineddelegation"] = uac & 0x00080000 == 0x00080000 58 | self.Properties["passwordnotreqd"] = uac & 0x00000020 == 0x00000020 59 | self.Properties["enabled"] = uac & 2 == 0 60 | self.Properties["dontreqpreauth"] = uac & 0x00400000 == 0x00400000 61 | self.Properties["sensitive"] = uac & 0x00100000 == 0x00100000 62 | self.Properties["trustedtoauth"] = uac & 0x01000000 == 0x01000000 63 | self.Properties["pwdneverexpires"] = uac & 0x00010000 == 0x00010000 64 | 65 | if 'lastlogon' in object.keys(): 66 | self.Properties["lastlogon"] = ADUtils.win_timestamp_to_unix( 67 | int(object.get('lastlogon')) 68 | ) 69 | 70 | if 'lastlogontimestamp' in object.keys(): 71 | self.Properties["lastlogontimestamp"] = ADUtils.win_timestamp_to_unix( 72 | int(object.get('lastlogontimestamp')) 73 | ) 74 | 75 | if 'pwdlastset' in object.keys(): 76 | self.Properties["pwdlastset"] = ADUtils.win_timestamp_to_unix( 77 | int(object.get('pwdlastset')) 78 | ) 79 | 80 | if 'useraccountcontrol' in object.keys(): 81 | self.Properties["dontreqpreauth"] = int(object.get('useraccountcontrol', 0)) & 0x00400000 == 0x00400000 82 | self.Properties["pwdneverexpires"] = int(object.get('useraccountcontrol', 0)) & 0x00010000 == 0x00010000 83 | self.Properties["sensitive"] = int(object.get('useraccountcontrol', 0)) & 0x00100000 == 0x00100000 84 | 85 | if 'serviceprincipalname' in object.keys(): 86 | self.Properties["serviceprincipalnames"] = object.get('serviceprincipalname').split(',') 87 | self.Properties['hasspn'] = True 88 | else: 89 | self.Properties["serviceprincipalnames"] = [] 90 | self.Properties['hasspn'] = False 91 | 92 | if 'serviceprincipalname' in object.keys(): 93 | self.Properties["hasspn"] = len(object.get('serviceprincipalname', [])) > 0 94 | 95 | if 'samaccounttype' in object.keys(): 96 | self.Properties["samaccounttype"] = object.get('samaccounttype') 97 | 98 | if 'displayname' in object.keys(): 99 | self.Properties["displayname"] = object.get('displayname') 100 | 101 | if 'mail' in object.keys(): 102 | self.Properties["email"] = object.get('mail') 103 | 104 | if 'title' in object.keys(): 105 | self.Properties["title"] = object.get('title') 106 | 107 | if 'homedirectory' in object.keys(): 108 | self.Properties["homedirectory"] = object.get('homedirectory') 109 | 110 | if 'description' in object.keys(): 111 | self.Properties["description"] = object.get('description') 112 | 113 | if 'userpassword' in object.keys(): 114 | self.Properties["userpassword"] = ADUtils.ensure_string(object.get('userpassword')) 115 | 116 | if 'sidhistory' in object.keys(): 117 | self.Properties["sidhistory"] = [LDAP_SID(bsid).formatCanonical() for bsid in object.get('sIDHistory', [])] 118 | 119 | if 'msds-allowedtodelegateto' in object.keys(): 120 | if len(object.get('msds-allowedtodelegateto', [])) > 0: 121 | self.Properties['allowedtodelegate'] = object.get('msds-allowedtodelegateto', []) 122 | 123 | if 'ntsecuritydescriptor' in object.keys(): 124 | self.RawAces = object['ntsecuritydescriptor'] 125 | 126 | if 'memberof' in object.keys(): 127 | self.MemberOfDNs = [f'CN={dn.upper()}' for dn in object.get('memberof').split(', CN=')] 128 | if len(self.MemberOfDNs) > 0: 129 | self.MemberOfDNs[0] = self.MemberOfDNs[0][3:] 130 | 131 | ### TODO Not supported for the moment 132 | # self.Properties['displayname'] = None 133 | # self.Properties['email'] = None 134 | # self.Properties['title'] = None 135 | # self.Properties['homedirectory'] = None 136 | # self.Properties['userpassword'] = None 137 | # self.Properties['unixpassword'] = None 138 | # self.Properties['unicodepassword'] = None 139 | # self.Properties['sfupassword'] = None 140 | # self.Properties['logonscript'] = None 141 | # self.Properties['sidhistory'] = [] 142 | 143 | 144 | def to_json(self, properties_level): 145 | self.Properties['isaclprotected'] = self.IsACLProtected 146 | user = super().to_json(properties_level) 147 | 148 | user["ObjectIdentifier"] = self.ObjectIdentifier 149 | user["ContainedBy"] = self.ContainedBy 150 | user["AllowedToDelegate"] = self.AllowedToDelegate 151 | user["PrimaryGroupSID"] = self.PrimaryGroupSid 152 | 153 | user["Aces"] = self.Aces 154 | user["SPNTargets"] = self.SPNTargets 155 | user["HasSIDHistory"] = self.HasSIDHistory 156 | user["IsACLProtected"] = self.IsACLProtected 157 | 158 | # TODO: RBCD 159 | # Process resource-based constrained delegation 160 | # _, aces = parse_binary_acl(data, 161 | # 'computer', 162 | # object.get('msDS-AllowedToActOnBehalfOfOtherIdentity'), 163 | # self.addc.objecttype_guid_map) 164 | # outdata = self.aceresolver.resolve_aces(aces) 165 | # for delegated in outdata: 166 | # if delegated['RightName'] == 'Owner': 167 | # continue 168 | # if delegated['RightName'] == 'GenericAll': 169 | # data['AllowedToAct'].append({'MemberId': delegated['PrincipalSID'], 'MemberType': delegated['PrincipalType']}) 170 | # 171 | # # Run ACL collection if this was not already done centrally 172 | # if 'acl' in collect and not skip_acl: 173 | # _, aces = parse_binary_acl(data, 174 | # 'computer', 175 | # object.get('nTSecurityDescriptor', 176 | # raw=True), 177 | # self.addc.objecttype_guid_map) 178 | # # Parse aces 179 | # data['Aces'] = self.aceresolver.resolve_aces(aces) 180 | 181 | return user 182 | -------------------------------------------------------------------------------- /bofhound/local/__init__.py: -------------------------------------------------------------------------------- 1 | from .localbroker import LocalBroker -------------------------------------------------------------------------------- /bofhound/local/localbroker.py: -------------------------------------------------------------------------------- 1 | from .models import LocalGroupMembership, LocalPrivilegedSession, LocalSession, LocalRegistrySession 2 | from bofhound.parsers.shared_parsers import NetSessionBofParser, NetLoggedOnBofParser, NetLocalGroupBofParser, RegSessionBofParser 3 | 4 | 5 | class LocalBroker: 6 | 7 | def __init__(self): 8 | self.privileged_sessions = set() 9 | self.sessions = set() 10 | self.local_group_memberships = set() 11 | self.registry_sessions = set() 12 | 13 | 14 | # take in known domain sids so we can filter out local accounts 15 | # and accounts with unknown domains 16 | def import_objects(self, objects, known_domain_sids): 17 | 18 | for object in objects: 19 | 20 | if object["ObjectType"] == NetLoggedOnBofParser.OBJECT_TYPE: 21 | priv_session = LocalPrivilegedSession(object) 22 | if priv_session.should_import(): 23 | self.privileged_sessions.add(priv_session) 24 | 25 | elif object["ObjectType"] == NetSessionBofParser.OBJECT_TYPE: 26 | session = LocalSession(object) 27 | if session.should_import(): 28 | self.sessions.add(session) 29 | 30 | elif object["ObjectType"] == NetLocalGroupBofParser.OBJECT_TYPE: 31 | local_group_membership = LocalGroupMembership(object) 32 | if local_group_membership.should_import(known_domain_sids): 33 | self.local_group_memberships.add(local_group_membership) 34 | 35 | elif object["ObjectType"] == RegSessionBofParser.OBJECT_TYPE: 36 | registry_session = LocalRegistrySession(object) 37 | if registry_session.should_import(known_domain_sids): 38 | self.registry_sessions.add(registry_session) 39 | -------------------------------------------------------------------------------- /bofhound/local/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .local_groupmembership import LocalGroupMembership 2 | from .local_privilegedsession import LocalPrivilegedSession 3 | from .local_session import LocalSession 4 | from .local_registrysession import LocalRegistrySession -------------------------------------------------------------------------------- /bofhound/local/models/local_groupmembership.py: -------------------------------------------------------------------------------- 1 | from bofhound.logger import ColorScheme, OBJ_EXTRA_FMT 2 | import logging 3 | import ipaddress 4 | 5 | 6 | class LocalGroupMembership: 7 | LOCALGROUP_HOST = "Host" 8 | LOCALGROUP_GROUP = "Group" 9 | LOCALGROUP_MEMBER = "Member" 10 | LOCALGROUP_MEMBER_SID = "MemberSid" 11 | LOCALGROUP_MEMBER_SID_TYPE = "MemberSidType" 12 | LOCALGROUP_NAMES = [ 13 | "administrators", 14 | "remote desktop users", 15 | "remote management users", 16 | "distributed com users" 17 | ] 18 | 19 | def __init__(self, object): 20 | self.host_name = None 21 | self.host_domain = None 22 | self.host_fqdn = None 23 | self.group = None 24 | self.member = None 25 | self.member_netbios_domain = None 26 | self.member_sid = None 27 | self.member_sid_type = None 28 | 29 | # will be set to True if correlated to a computer object 30 | self.matched = False 31 | 32 | try: 33 | ipaddress.ip_address(object[LocalGroupMembership.LOCALGROUP_HOST]) 34 | logging.debug(f"Skipping local group member on {object[LocalGroupMembership.LOCALGROUP_HOST]} due to IP instead of hostname") 35 | return 36 | except: 37 | pass 38 | 39 | if LocalGroupMembership.LOCALGROUP_GROUP in object.keys(): 40 | self.group = object[LocalGroupMembership.LOCALGROUP_GROUP] 41 | 42 | if LocalGroupMembership.LOCALGROUP_MEMBER in object.keys(): 43 | parts = object[LocalGroupMembership.LOCALGROUP_MEMBER].split('\\') 44 | if len(parts) == 2: 45 | self.member = parts[1] 46 | self.member_netbios_domain = parts[0] 47 | else: 48 | self.member = parts[0] 49 | 50 | if LocalGroupMembership.LOCALGROUP_HOST in object.keys(): 51 | if '.' in object[LocalGroupMembership.LOCALGROUP_HOST]: 52 | self.host_name = object[LocalGroupMembership.LOCALGROUP_HOST].split('.')[0] 53 | self.host_domain = '.'.join(object[LocalGroupMembership.LOCALGROUP_HOST].split('.')[1:]) 54 | self.host_fqdn = object[LocalGroupMembership.LOCALGROUP_HOST] 55 | else: 56 | self.host_name = object[LocalGroupMembership.LOCALGROUP_HOST] 57 | logging.debug(f"FQDN missing from hostname for {ColorScheme.user}{self.user}[/] session on {ColorScheme.computer}{self.host_name}[/]", extra=OBJ_EXTRA_FMT) 58 | 59 | if LocalGroupMembership.LOCALGROUP_MEMBER_SID in object.keys(): 60 | self.member_sid = object[LocalGroupMembership.LOCALGROUP_MEMBER_SID] 61 | 62 | if LocalGroupMembership.LOCALGROUP_MEMBER_SID_TYPE in object.keys(): 63 | self.member_sid_type = object[LocalGroupMembership.LOCALGROUP_MEMBER_SID_TYPE] 64 | 65 | 66 | def should_import(self, known_domain_sids): 67 | # missing required attributes 68 | if self.host_name is None or self.group is None \ 69 | or self.member_sid is None or self.member_sid_type is None: 70 | return False 71 | 72 | # filter out local groups we don't care about 73 | if self.group.lower() not in LocalGroupMembership.LOCALGROUP_NAMES: 74 | return False 75 | 76 | # do not import local account sessions or 77 | # user sessions from unknown domains 78 | if self.member_sid.rsplit('-', 1)[0] not in known_domain_sids: 79 | color = ColorScheme.user if self.member_sid_type == "User" else ColorScheme.group 80 | logging.debug(f"Skipping local group membership for {color}{self.member}[/] since domain SID is unfamiliar", extra=OBJ_EXTRA_FMT) 81 | return False 82 | 83 | computer = f"{self.host_name}.{self.host_domain}" if self.host_domain else self.host_name 84 | user = f"{self.member}@{self.member_netbios_domain}" if self.member_netbios_domain else self.member 85 | logging.debug(f"Local group member found for {ColorScheme.user}{user}[/] on {ColorScheme.computer}{computer}[/]", extra=OBJ_EXTRA_FMT) 86 | return True 87 | 88 | 89 | # so that a set can be used to keep a unique list of objects 90 | def __eq__(self, other): 91 | return (self.host_name, self.host_domain, self.group, self.member_sid) == \ 92 | (other.host_name, other.host_domain, other.group, other.member_sid) 93 | 94 | 95 | # so that a set can be used to keep a unique list of objects 96 | def __hash__(self): 97 | return hash((self.host_name, self.host_domain, self.group, self.member_sid)) 98 | 99 | 100 | # for debugging 101 | def __repr__(self): 102 | return f"LocalGroupMembership(host_name={self.host_name}, group={self.group}, member={self.member}, member_netbios_domain={self.member_netbios_domain}, member_sid={self.member_sid}, member_sid_type={self.member_sid_type})" 103 | 104 | -------------------------------------------------------------------------------- /bofhound/local/models/local_privilegedsession.py: -------------------------------------------------------------------------------- 1 | from bofhound.logger import ColorScheme, OBJ_EXTRA_FMT 2 | import logging 3 | import ipaddress 4 | 5 | 6 | class LocalPrivilegedSession: 7 | PS_HOST = "Host" 8 | PS_USERNAME = "Username" 9 | PS_DOMAIN = "Domain" 10 | 11 | def __init__(self, object): 12 | self.host_name = None 13 | self.host_domain = None 14 | self.host_fqdn = None 15 | self.user = None 16 | self.user_domain = None 17 | 18 | # will be set to True if correlated to a computer object 19 | self.matched = False 20 | 21 | try: 22 | ipaddress.ip_address(object[LocalPrivilegedSession.PS_HOST]) 23 | logging.debug(f"Skipping session on {object[LocalPrivilegedSession.PS_HOST]} due to IP instead of hostname") 24 | return 25 | except: 26 | pass 27 | 28 | if LocalPrivilegedSession.PS_USERNAME in object.keys(): 29 | self.user = object[LocalPrivilegedSession.PS_USERNAME] 30 | 31 | if LocalPrivilegedSession.PS_DOMAIN in object.keys(): 32 | self.user_domain = object[LocalPrivilegedSession.PS_DOMAIN] 33 | 34 | if LocalPrivilegedSession.PS_HOST in object.keys(): 35 | self.host_fqdn = object[LocalPrivilegedSession.PS_HOST] 36 | if '.' in self.host_fqdn: 37 | self.host_name = self.host_fqdn.split('.')[0] 38 | self.host_domain = '.'.join(self.host_fqdn.split('.')[1:]) 39 | else: 40 | logging.debug(f"FQDN missing from hostname for {ColorScheme.user}{self.user}[/] session on {ColorScheme.computer}{self.host_fqdn}[/]", extra=OBJ_EXTRA_FMT) 41 | 42 | def should_import(self): 43 | # missing required attributes 44 | if self.host_name is None or self.host_domain is None \ 45 | or self.user is None or self.user_domain is None: 46 | return False 47 | 48 | # do not import computer accounts 49 | if self.user.endswith('$'): 50 | return False 51 | 52 | # do not import local accounts 53 | if self.host_name.lower() == self.user_domain.lower(): 54 | return False 55 | 56 | logging.debug(f"NetWkstaUserEnum session found for {ColorScheme.user}{self.user}@{self.user_domain}[/] on {ColorScheme.computer}{self.host_fqdn}[/]", extra=OBJ_EXTRA_FMT) 57 | return True 58 | 59 | 60 | # so that a set can be used to keep a unique list of objects 61 | def __eq__(self, other): 62 | return (self.host_name, self.host_domain, self.host_fqdn, self.user, self.user_domain) == \ 63 | (other.host_name, other.host_domain, other.host_fqdn, other.user, other.user_domain) 64 | 65 | 66 | # so that a set can be used to keep a unique list of objects 67 | def __hash__(self): 68 | return hash((self.host_name, self.host_domain, self.host_fqdn, self.user, self.user_domain)) 69 | 70 | 71 | # for debugging 72 | def __repr__(self): 73 | return f"LocalPrivilegedSession(host_name={self.host_name}, host_domain={self.host_domain}, host_fqdn={self.host_fqdn}, user={self.user}, user_domain={self.user_domain})" -------------------------------------------------------------------------------- /bofhound/local/models/local_registrysession.py: -------------------------------------------------------------------------------- 1 | from bofhound.logger import ColorScheme, OBJ_EXTRA_FMT 2 | import logging 3 | import ipaddress 4 | 5 | 6 | class LocalRegistrySession: 7 | REGSESSION_HOST = "Host" 8 | REGSESSION_USER_SID = "UserSid" 9 | 10 | 11 | def __init__(self, object): 12 | self.user_sid = None 13 | self.host_name = None 14 | self.host_domain = None 15 | self.host_fqdn = None 16 | 17 | try: 18 | ipaddress.ip_address(object[LocalRegistrySession.REGSESSION_HOST]) 19 | logging.debug(f"Skipping session on {object[LocalRegistrySession.REGSESSION_HOST]} due to IP instead of hostname") 20 | return 21 | except: 22 | pass 23 | 24 | # will be set to True if correlated to a computer object 25 | self.matched = False 26 | 27 | if LocalRegistrySession.REGSESSION_USER_SID in object.keys(): 28 | self.user_sid = object[LocalRegistrySession.REGSESSION_USER_SID] 29 | 30 | if LocalRegistrySession.REGSESSION_HOST in object.keys(): 31 | if '.' in object[LocalRegistrySession.REGSESSION_HOST]: 32 | self.host_fqdn = object[LocalRegistrySession.REGSESSION_HOST] 33 | self.host_name = self.host_fqdn.split('.')[0] 34 | self.host_domain = '.'.join(self.host_fqdn.split('.')[1:]) 35 | else: 36 | self.host_name = object[LocalRegistrySession.REGSESSION_HOST] 37 | logging.debug(f"FQDN missing from hostname for {ColorScheme.user}{self.user_sid}[/] session on {ColorScheme.computer}{self.host_name}[/]", extra=OBJ_EXTRA_FMT) 38 | 39 | def should_import(self, known_domain_sids): 40 | # missing required attributes 41 | if self.user_sid is None or self.host_name is None: 42 | return False 43 | 44 | # do not import local account sessions or 45 | # user sessions from unknown domains 46 | if self.user_sid.rsplit('-', 1)[0] not in known_domain_sids: 47 | logging.debug(f"Skipping session for {ColorScheme.user}{self.user_sid}[/] since domain SID is unfamiliar", extra=OBJ_EXTRA_FMT) 48 | return False 49 | 50 | computer = self.host_fqdn if self.host_fqdn else self.host_name 51 | logging.debug(f"Registry session found for {ColorScheme.user}{self.user_sid}[/] on {ColorScheme.computer}{computer}[/]", extra=OBJ_EXTRA_FMT) 52 | return True 53 | 54 | 55 | # so that a set can be used to keep a unique list of objects 56 | def __eq__(self, other): 57 | return (self.user_sid, self.host_name, self.host_domain) == \ 58 | (other.user_sid, other.host_name, other.host_domain) 59 | 60 | 61 | # so that a set can be used to keep a unique list of objects 62 | def __hash__(self): 63 | return hash((self.user_sid, self.host_name, self.host_domain)) 64 | 65 | 66 | # for debugging 67 | def __repr__(self): 68 | return f"RegistrySession" -------------------------------------------------------------------------------- /bofhound/local/models/local_session.py: -------------------------------------------------------------------------------- 1 | from bofhound.logger import ColorScheme, OBJ_EXTRA_FMT 2 | import logging 3 | 4 | 5 | class LocalSession: 6 | SESSION_PTR = "PTR" 7 | SESSION_USER = "User" 8 | SESSION_COMPUTER_NAME = "ComputerName" 9 | SESSION_COMPUTER_DOMAIN = "ComputerDomain" 10 | 11 | def __init__(self, object): 12 | self.username = None 13 | self.ptr_record = None 14 | self.computer_name = None 15 | self.computer_netbios_domain = None 16 | self.computer_domain = None 17 | 18 | # will be set to True if correlated to a computer object 19 | self.matched = False 20 | 21 | if LocalSession.SESSION_PTR in object.keys(): 22 | if "reverse lookup failed" not in object[LocalSession.SESSION_PTR]: 23 | self.ptr_record = object[LocalSession.SESSION_PTR] 24 | self.computer_name = self.ptr_record.split('.')[0] 25 | self.computer_domain = '.'.join(self.ptr_record.split('.')[1:]) 26 | 27 | if LocalSession.SESSION_USER in object.keys(): 28 | self.username = object[LocalSession.SESSION_USER] 29 | 30 | if LocalSession.SESSION_COMPUTER_NAME in object.keys(): 31 | self.computer_name = object[LocalSession.SESSION_COMPUTER_NAME] 32 | 33 | if LocalSession.SESSION_COMPUTER_DOMAIN in object.keys(): 34 | self.computer_netbios_domain = object[LocalSession.SESSION_COMPUTER_DOMAIN] 35 | 36 | 37 | def should_import(self): 38 | # missing required attributes 39 | if self.username is None or self.ptr_record is None \ 40 | and self.computer_name is None or (self.computer_domain is None and self.computer_netbios_domain is None): 41 | return False 42 | 43 | # do not import sessions if NetWkstaGetInfo failed 44 | fail = "NetWkstaGetInfo Failed;" 45 | if self.computer_name is not None and self.computer_netbios_domain is not None: 46 | if self.computer_name.startswith(fail) or self.computer_netbios_domain.startswith(fail): 47 | return False 48 | 49 | # do not import computer accounts 50 | if self.username.endswith('$'): 51 | return False 52 | 53 | # do not import anonymous sessions 54 | if self.username.upper() == 'ANONYMOUS LOGON': 55 | return False 56 | 57 | computer = self.ptr_record if self.ptr_record else self.computer_name 58 | logging.debug(f"NetSessionEnum session found for {ColorScheme.user}{self.username}[/] on {ColorScheme.computer}{computer}[/]", extra=OBJ_EXTRA_FMT) 59 | return True 60 | 61 | 62 | # so that a set can be used to keep a unique list of objects 63 | def __eq__(self, other): 64 | return (self.username, self.ptr_record, self.computer_name, self.computer_domain) == \ 65 | (other.username, other.ptr_record, other.computer_name, other.computer_domain) 66 | 67 | 68 | # so that a set can be used to keep a unique list of objects 69 | def __hash__(self): 70 | return hash((self.username, self.ptr_record, self.computer_name, self.computer_domain)) 71 | 72 | # for debugging 73 | def __repr__(self): 74 | return f"LocalSession(username={self.username}, ptr_record={self.ptr_record}, computer_name={self.computer_name}, computer_domain={self.computer_domain}, computer_netbios_domain={self.computer_netbios_domain})" -------------------------------------------------------------------------------- /bofhound/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from rich.logging import RichHandler 3 | 4 | class ColorScheme: 5 | domain = "[green]" 6 | user = "[sea_green3]" 7 | computer = "[red1]" 8 | group = "[gold1]" 9 | enterpriseca = "[medium_purple1]" 10 | aiaca = "[medium_purple2]" 11 | rootca = "[medium_purple3]" 12 | certtemplate = "[bright_magenta]" 13 | schema = "[deep_sky_blue1]" 14 | ou = "[dark_orange]" 15 | containers = "[orange]" 16 | gpo = "[purple]" 17 | 18 | OBJ_EXTRA_FMT = { 19 | "markup": True, 20 | "highlighter": False 21 | } 22 | 23 | FORMAT = "%(message)s" 24 | logging.basicConfig( 25 | level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler(omit_repeated_times=False, show_path=False, keywords=[])] 26 | ) 27 | 28 | #logging.getLogger("rich") -------------------------------------------------------------------------------- /bofhound/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .ldap_search_bof import LdapSearchBofParser 2 | from .brc4_ldap_sentinel import Brc4LdapSentinelParser 3 | from .havoc import HavocParser 4 | from .parsertype import ParserType 5 | from .outflankc2 import OutflankC2JsonParser -------------------------------------------------------------------------------- /bofhound/parsers/brc4_ldap_sentinel.py: -------------------------------------------------------------------------------- 1 | import re 2 | import codecs 3 | import logging 4 | from datetime import datetime as dt 5 | from bofhound.parsers import LdapSearchBofParser 6 | 7 | class Brc4LdapSentinelParser(LdapSearchBofParser): 8 | # BRC4 LDAP Sentinel currently only queries attributes=["*"] and objectClass 9 | # is always the top result. May need to be updated in the future. 10 | START_BOUNDARY = '[+] objectclass :' 11 | END_BOUNDARY = '+-------------------------------------------------------------------+' 12 | 13 | FORMATTED_TS_ATTRS = ['lastlogontimestamp', 'lastlogon', 'lastlogoff', 'pwdlastset', 'accountexpires'] 14 | ISO_8601_TS_ATTRS = ['dscorepropagationdata', 'whenchanged', 'whencreated'] 15 | BRACKETED_ATTRS = ['objectguid'] 16 | SEMICOLON_DELIMITED_ATTRS = ['serviceprincipalname', 'memberof', 'member', 'objectclass'] 17 | 18 | def __init__(self): 19 | pass #self.objects = [] 20 | 21 | # 22 | # Legacy, used by test cases for 1 liner 23 | # Removed from __main__.py to avoid duplicating file reads and formatting 24 | # 25 | @staticmethod 26 | def parse_file(file): 27 | with codecs.open(file, 'r', 'utf-8', errors='ignore') as f: 28 | return Brc4LdapSentinelParser.parse_data(f.read()) 29 | 30 | 31 | # 32 | # Replaces parse_file() usage in __main__.py to avoid duplicate file reads 33 | # 34 | @staticmethod 35 | def prep_file(file): 36 | with codecs.open(file, 'r', 'utf-8', errors='ignore') as f: 37 | return f.read() 38 | 39 | 40 | @staticmethod 41 | def parse_data(contents): 42 | parsed_objects = [] 43 | current_object = None 44 | in_result_region = False 45 | 46 | in_result_region = False 47 | 48 | lines = contents.splitlines() 49 | for line in lines: 50 | 51 | if len(line) == 0: 52 | continue 53 | 54 | is_start_boundary_line = Brc4LdapSentinelParser._is_start_boundary_line(line) 55 | is_end_boundary_line = Brc4LdapSentinelParser._is_end_boundary_line(line) 56 | 57 | if not in_result_region and not is_start_boundary_line: 58 | continue 59 | 60 | if is_start_boundary_line: 61 | if not in_result_region: 62 | in_result_region = True 63 | 64 | current_object = {} 65 | 66 | elif is_end_boundary_line: 67 | parsed_objects.append(current_object) 68 | in_result_region = False 69 | current_object = None 70 | continue 71 | 72 | data = line.split(': ') 73 | 74 | try: 75 | data = line.split(':', 1) 76 | attr = data[0].replace('[+]', '').strip().lower() 77 | value = data[1].strip() 78 | 79 | # BRc4 formats some timestamps for us that we need to revert to raw values 80 | if attr in Brc4LdapSentinelParser.FORMATTED_TS_ATTRS: 81 | if value.lower() in ['never expires', 'value not set']: 82 | continue 83 | timestamp_obj = dt.strptime(value, '%m/%d/%Y %I:%M:%S %p') 84 | value = int((timestamp_obj - dt(1601, 1, 1)).total_seconds() * 10000000) 85 | 86 | if attr in Brc4LdapSentinelParser.ISO_8601_TS_ATTRS: 87 | formatted_ts = [] 88 | for ts in value.split(';'): 89 | timestamp_obj = dt.strptime(ts.strip(), "%m/%d/%Y %I:%M:%S %p") 90 | timestamp_str = timestamp_obj.strftime("%Y%m%d%H%M%S.0Z") 91 | formatted_ts.append(timestamp_str) 92 | value = ', '.join(formatted_ts) 93 | 94 | # BRc4 formats some attributes with surroudning {} we need to remove 95 | if attr in Brc4LdapSentinelParser.BRACKETED_ATTRS: 96 | value = value[1:-1] 97 | 98 | # BRc4 delimits some list-esque attributes with semicolons 99 | # when our BH models expect commas 100 | if attr in Brc4LdapSentinelParser.SEMICOLON_DELIMITED_ATTRS: 101 | value = value.replace('; ', ', ') 102 | 103 | # BRc4 puts the trustDirection attribute within securityidentifier 104 | if attr == 'securityidentifier' and 'trustdirection' in value.lower(): 105 | trust_direction = value.lower().split('trustdirection ')[1] 106 | current_object['trustdirection'] = trust_direction 107 | value = value.split('trustdirection: ')[0] 108 | continue 109 | 110 | current_object[attr] = value 111 | 112 | except Exception as e: 113 | logging.debug(f'Error - {str(e)}') 114 | 115 | return parsed_objects 116 | 117 | 118 | @staticmethod 119 | def _is_start_boundary_line(line): 120 | # BRc4 seems to always have objectClass camelcased, but we'll use lower() just in case 121 | if line.lower().startswith(Brc4LdapSentinelParser.START_BOUNDARY): 122 | return True 123 | return False 124 | 125 | 126 | @staticmethod 127 | def _is_end_boundary_line(line): 128 | if line == Brc4LdapSentinelParser.END_BOUNDARY: 129 | return True 130 | return False 131 | -------------------------------------------------------------------------------- /bofhound/parsers/generic_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | import codecs 3 | from .shared_parsers import __all_generic_parsers__ 4 | 5 | 6 | class GenericParser: 7 | 8 | def __init__(self): 9 | pass 10 | 11 | 12 | @staticmethod 13 | def parse_file(file, is_outflankc2=False): 14 | with codecs.open(file, 'r', 'utf-8') as f: 15 | if is_outflankc2: 16 | return GenericParser.parse_outflank_file(f.read()) 17 | else: 18 | return GenericParser.parse_data(f.read()) 19 | 20 | 21 | @staticmethod 22 | def parse_outflank_file(contents): 23 | parsed_objects = [] 24 | 25 | for line in contents.splitlines(): 26 | event_json = json.loads(line.split('UTC ', 1)[1]) 27 | 28 | # we only care about task_resonse events 29 | if event_json['event_type'] != 'task_response': 30 | continue 31 | 32 | # within task_response events, we only care about tasks with specific BOF names 33 | if event_json['task']['name'].lower() not in ['netsession2', 'netloggedon2', 'regsession', 'netlocalgrouplistmembers2']: 34 | continue 35 | 36 | parsed_objects.extend(GenericParser.parse_data(event_json['task']['response'])) 37 | 38 | return parsed_objects 39 | 40 | 41 | @staticmethod 42 | def parse_data(contents): 43 | parsed_objects = [] 44 | current_parser = None 45 | current_object = {} 46 | 47 | lines = contents.splitlines() 48 | 49 | for line in lines: 50 | # if we have no current parser, check and see if the current line is a start boundary 51 | if current_parser is None: 52 | for parser in __all_generic_parsers__: 53 | if parser.is_start_boundary_line(line): 54 | current_parser = parser 55 | break 56 | 57 | # if we do have a current parser, check and see if the current line is an end boundary 58 | else: 59 | if current_parser is not None: 60 | if current_parser.is_end_boundary_line(line): 61 | # we've reached the end of the current object, so store it and reset the parser 62 | current_object["ObjectType"] = current_parser.OBJECT_TYPE 63 | parsed_objects.append(current_object) 64 | current_parser = None 65 | current_object = {} 66 | continue 67 | 68 | # if we have a current parser and the current line is not an end boundary, parse the line 69 | if current_parser is not None: 70 | current_object = current_parser.parse_line(line, current_object) 71 | 72 | return parsed_objects 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /bofhound/parsers/havoc.py: -------------------------------------------------------------------------------- 1 | import re 2 | import codecs 3 | from bofhound.parsers import LdapSearchBofParser 4 | 5 | 6 | class HavocParser(LdapSearchBofParser): 7 | 8 | @staticmethod 9 | def prep_file(file): 10 | with codecs.open(file, 'r', 'utf-8', errors='ignore') as f: 11 | contents = f.read() 12 | 13 | return re.sub(r'\[\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}\] \[\+\] Received Output \[\d+ bytes\]:\n', '', contents) -------------------------------------------------------------------------------- /bofhound/parsers/ldap_search_bof.py: -------------------------------------------------------------------------------- 1 | import re 2 | import codecs 3 | import logging 4 | from bofhound.parsers.generic_parser import GenericParser 5 | 6 | 7 | # 8 | # This class will be inherited by other parsers since most if not all are based 9 | # off the same BOF, wrapped by various C2s. These methods can be overridden 10 | # by child classes to handle specific parsing requirements 11 | # 12 | class LdapSearchBofParser(): 13 | RESULT_DELIMITER = "-" 14 | RESULT_BOUNDARY_LENGTH = 20 15 | _COMPLETE_BOUNDARY_LINE = -1 16 | 17 | 18 | def __init__(self): 19 | pass 20 | 21 | # 22 | # Legacy, used by test cases for 1 liner 23 | # Removed from __main__.py to avoid duplicating file reads and formatting 24 | # 25 | @staticmethod 26 | def parse_file(file): 27 | return LdapSearchBofParser.parse_data( 28 | LdapSearchBofParser.prep_file(file) 29 | ) 30 | 31 | 32 | # 33 | # Replaces parse_file() usage in __main__.py to avoid duplicate file reads 34 | # 35 | @staticmethod 36 | def prep_file(file): 37 | with codecs.open(file, 'r', 'utf-8') as f: 38 | contents = f.read() 39 | 40 | return re.sub(r'\n\n\d{2}\/\d{2} (\d{2}:){2}\d{2} UTC \[output\]\nreceived output:\n', '', contents) 41 | 42 | 43 | # 44 | # Meat of the parsing logic 45 | # 46 | @staticmethod 47 | def parse_data(data): 48 | parsed_objects = [] 49 | current_object = None 50 | in_result_region = False 51 | previous_attr = None 52 | 53 | in_result_region = False 54 | 55 | lines = data.splitlines() 56 | for line in lines: 57 | is_boundary_line = LdapSearchBofParser._is_boundary_line(line) 58 | 59 | if (not in_result_region and 60 | not is_boundary_line): 61 | continue 62 | 63 | if (is_boundary_line 64 | and is_boundary_line != LdapSearchBofParser._COMPLETE_BOUNDARY_LINE): 65 | while True: 66 | try: 67 | next_line = next(lines)[1] 68 | remaining_length = LdapSearchBofParser._is_boundary_line(next_line, is_boundary_line) 69 | 70 | if remaining_length: 71 | is_boundary_line = remaining_length 72 | if is_boundary_line == LdapSearchBofParser._COMPLETE_BOUNDARY_LINE: 73 | break 74 | except: 75 | # probably ran past the end of the iterable 76 | break 77 | 78 | if (is_boundary_line): 79 | if not in_result_region: 80 | in_result_region = True 81 | elif current_object is not None: 82 | # self.store_object(current_object) 83 | parsed_objects.append(current_object) 84 | current_object = {} 85 | continue 86 | elif re.match("^(R|r)etr(e|i)(e|i)ved \\d+ results?", line): 87 | #self.store_object(current_object) 88 | parsed_objects.append(current_object) 89 | in_result_region = False 90 | current_object = None 91 | continue 92 | 93 | data = line.split(': ') 94 | 95 | try: 96 | # If we previously encountered a control message, we're probably still in the old property 97 | if len(data) == 1: 98 | if previous_attr is not None: 99 | value = current_object[previous_attr] + line 100 | else: 101 | data = line.split(':') 102 | attr = data[0].strip().lower() 103 | value = ''.join(data[1:]).strip() 104 | previous_attr = attr 105 | 106 | current_object[attr] = value 107 | 108 | except Exception as e: 109 | logging.debug(f'Error - {str(e)}') 110 | 111 | return parsed_objects 112 | 113 | 114 | # Returns one of the following integers: 115 | # 0 - This is not a boundary line 116 | # -1 - This is a complete boundary line 117 | # n - The remaining characters needed to form a complete boundary line 118 | @staticmethod 119 | def _is_boundary_line(line, length=RESULT_BOUNDARY_LENGTH): 120 | line = line.strip() 121 | chars = set(line) 122 | 123 | if len(chars) == 1 and chars.pop() == LdapSearchBofParser.RESULT_DELIMITER: 124 | if len(line) == length: 125 | return -1 126 | elif len(line) < length: 127 | return LdapSearchBofParser.RESULT_BOUNDARY_LENGTH - len(line) 128 | 129 | return 0 # Falsey 130 | 131 | 132 | # 133 | # Get local groups, sessions, etc by feeding data to GenericParser class 134 | # 135 | @staticmethod 136 | def parse_local_objects(data): 137 | return GenericParser.parse_data(data) -------------------------------------------------------------------------------- /bofhound/parsers/outflankc2.py: -------------------------------------------------------------------------------- 1 | import re 2 | import codecs 3 | import json 4 | import logging 5 | from bofhound.parsers.generic_parser import GenericParser 6 | from bofhound.parsers import LdapSearchBofParser 7 | 8 | 9 | # 10 | # Parses ldapsearch BOF objects from Outflank C2 JSON logfiles 11 | # Assumes that the BOF was registered as a command in OC2 named 'ldapserach' 12 | # 13 | 14 | class OutflankC2JsonParser(LdapSearchBofParser): 15 | BOFNAME = 'ldapsearch' 16 | 17 | 18 | @staticmethod 19 | def prep_file(file): 20 | with codecs.open(file, 'r', 'utf-8') as f: 21 | return f.read() 22 | 23 | 24 | # 25 | # Slightly modified from LdapSearchBofParser to account for 26 | # needing only part of each JSON object, instead of the whole file 27 | # 28 | @staticmethod 29 | def parse_data(contents): 30 | parsed_objects = [] 31 | current_object = None 32 | in_result_region = False 33 | previous_attr = None 34 | 35 | in_result_region = False 36 | 37 | lines = contents.splitlines() 38 | for line in lines: 39 | event_json = json.loads(line.split('UTC ', 1)[1]) 40 | 41 | # we only care about task_resonse events 42 | if event_json['event_type'] != 'task_response': 43 | continue 44 | 45 | # within task_response events, we only care about tasks with the name 'ldapsearch' 46 | if event_json['task']['name'].lower() != OutflankC2JsonParser.BOFNAME: 47 | continue 48 | 49 | # now we have a block of ldapsearch data we can parse through for objects 50 | response_lines = event_json['task']['response'].splitlines() 51 | for response_line in response_lines: 52 | 53 | is_boundary_line = OutflankC2JsonParser._is_boundary_line(response_line) 54 | 55 | if (not in_result_region and 56 | not is_boundary_line): 57 | continue 58 | 59 | if (is_boundary_line 60 | and is_boundary_line != OutflankC2JsonParser._COMPLETE_BOUNDARY_LINE): 61 | while True: 62 | try: 63 | next_line = next(response_lines)[1] 64 | remaining_length = OutflankC2JsonParser._is_boundary_line(next_line, is_boundary_line) 65 | 66 | if remaining_length: 67 | is_boundary_line = remaining_length 68 | if is_boundary_line == OutflankC2JsonParser._COMPLETE_BOUNDARY_LINE: 69 | break 70 | except: 71 | # probably ran past the end of the iterable 72 | break 73 | 74 | if (is_boundary_line): 75 | if not in_result_region: 76 | in_result_region = True 77 | elif current_object is not None: 78 | # self.store_object(current_object) 79 | parsed_objects.append(current_object) 80 | current_object = {} 81 | continue 82 | elif re.match("^(R|r)etr(e|i)(e|i)ved \\d+ results?", response_line): 83 | #self.store_object(current_object) 84 | parsed_objects.append(current_object) 85 | in_result_region = False 86 | current_object = None 87 | continue 88 | 89 | data = response_line.split(': ') 90 | 91 | try: 92 | # If we previously encountered a control message, we're probably still in the old property 93 | if len(data) == 1: 94 | if previous_attr is not None: 95 | value = current_object[previous_attr] + response_line 96 | else: 97 | data = response_line.split(':') 98 | attr = data[0].strip().lower() 99 | value = ''.join(data[1:]).strip() 100 | previous_attr = attr 101 | 102 | current_object[attr] = value 103 | 104 | except Exception as e: 105 | logging.debug(f'Error - {str(e)}') 106 | 107 | return parsed_objects 108 | 109 | 110 | # 111 | # Get local groups, sessions, etc by feeding data to GenericParser class 112 | # 113 | @staticmethod 114 | def parse_local_objects(file): 115 | return GenericParser.parse_file(file, is_outflankc2=True) -------------------------------------------------------------------------------- /bofhound/parsers/parsertype.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class ParserType(Enum): 4 | LdapsearchBof = 'ldapsearch' 5 | BRC4 = 'BRC4' 6 | HAVOC = 'Havoc' 7 | OUTFLANKC2 = 'OutflankC2' -------------------------------------------------------------------------------- /bofhound/parsers/shared_parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .netloggedon_bof import NetLoggedOnBofParser 2 | from .netsession_bof import NetSessionBofParser 3 | from .netlocalgroup_bof import NetLocalGroupBofParser 4 | from .regsession_bof import RegSessionBofParser 5 | 6 | __all_generic_parsers__ = [ 7 | NetLoggedOnBofParser, 8 | NetSessionBofParser, 9 | NetLocalGroupBofParser, 10 | RegSessionBofParser 11 | ] -------------------------------------------------------------------------------- /bofhound/parsers/shared_parsers/netlocalgroup_bof.py: -------------------------------------------------------------------------------- 1 | from .sharedparser import SharedParser 2 | 3 | 4 | class NetLocalGroupBofParser(SharedParser): 5 | START_BOUNDARY = "----------Local Group Member----------" 6 | END_BOUNDARY = "--------End Local Group Member--------" 7 | OBJECT_TYPE = "LocalGroup" 8 | 9 | def __init__(self): 10 | pass 11 | 12 | 13 | @staticmethod 14 | def is_start_boundary_line(line): 15 | return line.strip() == NetLocalGroupBofParser.START_BOUNDARY 16 | 17 | 18 | @staticmethod 19 | def is_end_boundary_line(line): 20 | return line.strip() == NetLocalGroupBofParser.END_BOUNDARY -------------------------------------------------------------------------------- /bofhound/parsers/shared_parsers/netloggedon_bof.py: -------------------------------------------------------------------------------- 1 | from .sharedparser import SharedParser 2 | 3 | 4 | class NetLoggedOnBofParser(SharedParser): 5 | START_BOUNDARY = "-----------Logged on User-----------" 6 | END_BOUNDARY = "---------End Logged on User---------" 7 | OBJECT_TYPE = "PrivilegedSession" 8 | 9 | def __init__(self): 10 | pass 11 | 12 | 13 | @staticmethod 14 | def is_start_boundary_line(line): 15 | return line.strip() == NetLoggedOnBofParser.START_BOUNDARY 16 | 17 | 18 | @staticmethod 19 | def is_end_boundary_line(line): 20 | return line.strip() == NetLoggedOnBofParser.END_BOUNDARY -------------------------------------------------------------------------------- /bofhound/parsers/shared_parsers/netsession_bof.py: -------------------------------------------------------------------------------- 1 | from .sharedparser import SharedParser 2 | 3 | 4 | class NetSessionBofParser(SharedParser): 5 | START_BOUNDARY = "---------------Session--------------" 6 | END_BOUNDARY = "-------------End Session------------" 7 | OBJECT_TYPE = "Session" 8 | 9 | def __init__(self): 10 | pass 11 | 12 | 13 | @staticmethod 14 | def is_start_boundary_line(line): 15 | return line.strip() == NetSessionBofParser.START_BOUNDARY 16 | 17 | 18 | @staticmethod 19 | def is_end_boundary_line(line): 20 | return line.strip() == NetSessionBofParser.END_BOUNDARY -------------------------------------------------------------------------------- /bofhound/parsers/shared_parsers/regsession_bof.py: -------------------------------------------------------------------------------- 1 | from .sharedparser import SharedParser 2 | 3 | 4 | class RegSessionBofParser(SharedParser): 5 | START_BOUNDARY = "-----------Registry Session---------" 6 | END_BOUNDARY = "---------End Registry Session-------" 7 | OBJECT_TYPE = "RegistrySession" 8 | 9 | def __init__(self): 10 | pass 11 | 12 | 13 | @staticmethod 14 | def is_start_boundary_line(line): 15 | return line.strip() == RegSessionBofParser.START_BOUNDARY 16 | 17 | 18 | @staticmethod 19 | def is_end_boundary_line(line): 20 | return line.strip() == RegSessionBofParser.END_BOUNDARY -------------------------------------------------------------------------------- /bofhound/parsers/shared_parsers/sharedparser.py: -------------------------------------------------------------------------------- 1 | 2 | class SharedParser(): 3 | 4 | def __init__(self): 5 | pass 6 | 7 | 8 | # will be same for all child classes 9 | @staticmethod 10 | def parse_line(line, current_object): 11 | data = line.split(': ') 12 | try: 13 | attr = data[0].strip() 14 | value = data[1].strip() 15 | 16 | # if attr is not on the current object, add it 17 | if attr not in current_object: 18 | current_object[attr] = value 19 | 20 | return current_object 21 | except IndexError: 22 | return current_object 23 | 24 | 25 | # will be implemented in child classes 26 | @staticmethod 27 | def is_start_boundary_line(line): 28 | pass 29 | 30 | 31 | # will be implemented in child classes 32 | @staticmethod 33 | def is_end_boundary_line(line): 34 | pass -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bofhound" 3 | version = "0.4.8" 4 | description = "Parse output from common sources and transform it into BloodHound-ingestible data" 5 | authors = [ 6 | "Adam Brown", 7 | "Matt Creel" 8 | ] 9 | readme = "README.md" 10 | homepage = "https://github.com/coffeegist/bofhound" 11 | repository = "https://github.com/coffeegist/bofhound" 12 | include = ["CHANGELOG.md"] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.10" 16 | asn1crypto = "^1.5.1" 17 | click = "^8.0" 18 | typer = "^0.9" 19 | bloodhound = "^1.6" 20 | chardet = "^4.0" 21 | cryptography = "^36.0" 22 | dnspython = "^2.2" 23 | Flask = "^2.0" 24 | future = "^0.18.2" 25 | impacket = "^0.10.0" 26 | itsdangerous = "^2.0.1" 27 | Jinja2 = "^3.0.3" 28 | ldap3 = "^2.9.1" 29 | ldapdomaindump = "^0.9.3" 30 | MarkupSafe = "^2.0.1" 31 | pyasn1 = "^0.4.8" 32 | pycparser = "^2.21" 33 | pycryptodomex = "^3.14.0" 34 | pyOpenSSL = "^22.0.0" 35 | six = "^1.16.0" 36 | Werkzeug = "^2.0.2" 37 | rich = "^12.5" 38 | cffi = "^1.17.1" 39 | 40 | [tool.poetry.group.dev.dependencies] 41 | pylint = "^2.13" 42 | pytest = "^7.1.2" 43 | 44 | [build-system] 45 | requires = ["poetry-core>=1.0.0"] 46 | build-backend = "poetry.core.masonry.api" 47 | 48 | [tool.poetry.scripts] 49 | bofhound = "bofhound.__main__:app" 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coffeegist/bofhound/fed5d2b63fc38f178423dfb1f77e2f6e9d337634/tests/__init__.py -------------------------------------------------------------------------------- /tests/ad/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coffeegist/bofhound/fed5d2b63fc38f178423dfb1f77e2f6e9d337634/tests/ad/__init__.py -------------------------------------------------------------------------------- /tests/ad/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coffeegist/bofhound/fed5d2b63fc38f178423dfb1f77e2f6e9d337634/tests/ad/models/__init__.py -------------------------------------------------------------------------------- /tests/ad/models/test_bloodhound_computer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.ad.models.bloodhound_computer import BloodHoundComputer 3 | from bofhound.ad.models.bloodhound_object import BloodHoundObject 4 | from bloodhound.enumeration.acls import SecurityDescriptor, ACL, ACCESS_ALLOWED_ACE, ACCESS_MASK, ACE, ACCESS_ALLOWED_OBJECT_ACE, has_extended_right, EXTRIGHTS_GUID_MAPPING, can_write_property, ace_applies 5 | 6 | 7 | @pytest.fixture 8 | def parsed_partial_computer(): 9 | pass 10 | 11 | 12 | @pytest.fixture 13 | def parsed_full_computer(): 14 | yield { 15 | "objectclass": "top, person, organizationalPerson, user, computer", 16 | "cn": "WIN10", 17 | "distinguishedname": "CN=WIN10,OU=Workstations,DC=windomain,DC=local", 18 | "instancetype": "4", 19 | "whencreated": "20220112013543.0Z", 20 | "whenchanged": "20220401134507.0Z", 21 | "usncreated": "14202", 22 | "usnchanged": "24046", 23 | "ntsecuritydescriptor": "AQAEjJgJAAC0CQAAAAAAABQAAAAEAIQJMQAAAAUASAAgAAAAAwAAABAgIF+ledARkCAAwE/C1M+Gepa/5g3QEaKFAKoAMEniAQUAAAAAAAUVAAAANowB26uPcGmReUlF6AMAAAUASAAgAAAAAwAAAFB5lr/mDdARooUAqgAwSeKGepa/5g3QEaKFAKoAMEniAQUAAAAAAAUVAAAANowB26uPcGmReUlF6AMAAAUASAAgAAAAAwAAAFN5lr/mDdARooUAqgAwSeKGepa/5g3QEaKFAKoAMEniAQUAAAAAAAUVAAAANowB26uPcGmReUlF6AMAAAUASAAgAAAAAwAAANC/Cj5qEtARoGAAqgBsM+2Gepa/5g3QEaKFAKoAMEniAQUAAAAAAAUVAAAANowB26uPcGmReUlF6AMAAAUAOAAIAAAAAQAAAEeV43IYe9ERre8AwE/Y1c0BBQAAAAAABRUAAAA2jAHbq49waZF5SUXoAwAABQA4AAgAAAABAAAAiEem8wZT0RGpxQAA+ANnwQEFAAAAAAAFFQAAADaMAdurj3BpkXlJRegDAAAFADgAIAAAAAEAAAAAQhZMwCDQEadoAKoAbgUpAQUAAAAAAAUVAAAANowB26uPcGmReUlF6AMAAAUAOAAwAAAAAQAAAH96lr/mDdARooUAqgAwSeIBBQAAAAAABRUAAAA2jAHbq49waZF5SUUFAgAABQAsAAMAAAABAAAAqHqWv+YN0BGihQCqADBJ4gECAAAAAAAFIAAAACYCAAAFACwAEAAAAAEAAAAdsalGrmBaQLfo/4pY1FbSAQIAAAAAAAUgAAAAMAIAAAUAKAAAAQAAAQAAAFMacqsvHtARmBkAqgBAUpsBAQAAAAAAAQAAAAAFACgACAAAAAEAAABHleNyGHvREa3vAMBP2NXNAQEAAAAAAAUKAAAABQAoAAgAAAABAAAAiEem8wZT0RGpxQAA+ANnwQEBAAAAAAAFCgAAAAUAKAAwAAAAAQAAAIa4tXdKlNERrr0AAPgDZ8EBAQAAAAAABQoAAAAAACQA1AEDAAEFAAAAAAAFFQAAADaMAdurj3BpkXlJRegDAAAAACQA/wEPAAEFAAAAAAAFFQAAADaMAdurj3BpkXlJRQACAAAAABgA/wEPAAECAAAAAAAFIAAAACQCAAAAABQAAwAAAAEBAAAAAAAFCgAAAAAAFACUAAIAAQEAAAAAAAULAAAAAAAUAP8BDwABAQAAAAAABRIAAAAFEjgAIAAAAAMAAABbspQaIAi6R53LgK7637NwhnqWv+YN0BGihQCqADBJ4gEBAAAAAAAFCgAAAAUSOAAwAAAAAwAAAGL91v7f+9lBsl8a2z53q3eGepa/5g3QEaKFAKoAMEniAQEAAAAAAAUKAAAABRo8ABAAAAADAAAAAEIWTMAg0BGnaACqAG4FKRTMKEg3FLxFmwetbwFeXygBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAAEIWTMAg0BGnaACqAG4FKbp6lr/mDdARooUAqgAwSeIBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAECAgX6V50BGQIADAT8LUzxTMKEg3FLxFmwetbwFeXygBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAECAgX6V50BGQIADAT8LUz7p6lr/mDdARooUAqgAwSeIBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAQMIKvKl50BGQIADAT8LUzxTMKEg3FLxFmwetbwFeXygBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAQMIKvKl50BGQIADAT8LUz7p6lr/mDdARooUAqgAwSeIBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAQi+6WaJ50BGQIADAT8LTzxTMKEg3FLxFmwetbwFeXygBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAAQi+6WaJ50BGQIADAT8LTz7p6lr/mDdARooUAqgAwSeIBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAA+IhwA+EK0hG0IgCgyWj5ORTMKEg3FLxFmwetbwFeXygBAgAAAAAABSAAAAAqAgAABRo8ABAAAAADAAAA+IhwA+EK0hG0IgCgyWj5Obp6lr/mDdARooUAqgAwSeIBAgAAAAAABSAAAAAqAgAABRI4ADAAAAABAAAAD9ZHW5BgskCfNypN6I8wYwEFAAAAAAAFFQAAADaMAdurj3BpkXlJRQ4CAAAFEjgAMAAAAAEAAAAP1kdbkGCyQJ83Kk3ojzBjAQUAAAAAAAUVAAAANowB26uPcGmReUlFDwIAAAUQOAAIAAAAAQAAAKZtAps8DVxGi+5RmdcWXLoBBQAAAAAABRUAAAA2jAHbq49waZF5SUXoAwAABRo4AAgAAAADAAAApm0CmzwNXEaL7lGZ1xZcuoZ6lr/mDdARooUAqgAwSeIBAQAAAAAAAwAAAAAFEjgACAAAAAMAAACmbQKbPA1cRovuUZnXFly6hnqWv+YN0BGihQCqADBJ4gEBAAAAAAAFCgAAAAUSOAAQAAAAAwAAAG2exrfHLNIRhU4AoMmD9giGepa/5g3QEaKFAKoAMEniAQEAAAAAAAUJAAAABRo4ABAAAAADAAAAbZ7Gt8cs0hGFTgCgyYP2CJx6lr/mDdARooUAqgAwSeIBAQAAAAAABQkAAAAFGjgAEAAAAAMAAABtnsa3xyzSEYVOAKDJg/YIunqWv+YN0BGihQCqADBJ4gEBAAAAAAAFCQAAAAUSOAAgAAAAAwAAAJN7G+pIXtVGvGxN9P2nijWGepa/5g3QEaKFAKoAMEniAQEAAAAAAAUKAAAABRosAJQAAgACAAAAFMwoSDcUvEWbB61vAV5fKAECAAAAAAAFIAAAACoCAAAFGiwAlAACAAIAAACcepa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUaLACUAAIAAgAAALp6lr/mDdARooUAqgAwSeIBAgAAAAAABSAAAAAqAgAABRMoADAAAAABAAAA5cN4P5r3vUaguJ0YEW3ceQEBAAAAAAAFCgAAAAUSKAAwAQAAAQAAAN5H5pFv2XBLlVfWP/TzzNgBAQAAAAAABQoAAAAAEiQA/wEPAAEFAAAAAAAFFQAAADaMAdurj3BpkXlJRQcCAAAAEhgABAAAAAECAAAAAAAFIAAAACoCAAAAEhgAvQEPAAECAAAAAAAFIAAAACACAAABBQAAAAAABRUAAAA2jAHbq49waZF5SUXoAwAAAQUAAAAAAAUVAAAANowB26uPcGmReUlFAQIAAA==", 24 | "name": "WIN10", 25 | "objectguid": "f981e173-4db8-48f9-9c2d-3d4987698505", 26 | "useraccountcontrol": "4096", 27 | "badpwdcount": "0", 28 | "codepage": "0", 29 | "countrycode": "0", 30 | "badpasswordtime": "0", 31 | "lastlogoff": "0", 32 | "lastlogon": "132933148216284771", 33 | "localpolicyflags": "0", 34 | "pwdlastset": "132888464114765330", 35 | "primarygroupid": "515", 36 | "objectsid": "S-1-5-21-3674311734-1768984491-1162443153-1104", 37 | "accountexpires": "9223372036854775807", 38 | "logoncount": "222", 39 | "samaccountname": "WIN10$", 40 | "samaccounttype": "805306369", 41 | "operatingsystem": "Windows 10 Enterprise Evaluation", 42 | "operatingsystemversion": "10.0 (18363)", 43 | "dnshostname": "win10.windomain.local", 44 | "serviceprincipalname": "WSMAN/win10, WSMAN/win10.windomain.local, TERMSRV/WIN10, TERMSRV/win10.windomain.local, RestrictedKrbHost/WIN10, HOST/WIN10, RestrictedKrbHost/win10.windomain.local, HOST/win10.windomain.local", 45 | "objectcategory": "CN=Computer,CN=Schema,CN=Configuration,DC=windomain,DC=local", 46 | "iscriticalsystemobject": "FALSE", 47 | "dscorepropagationdata": "20220325174020.0Z, 16010101000001.0Z", 48 | "lastlogontimestamp": "132932943074365707", 49 | "msds-supportedencryptiontypes": "28", 50 | "ms-mcs-admpwd": "testpassword", 51 | "ms-mcs-admpwdexpirationtime": "132953152469914742" 52 | } 53 | 54 | 55 | def test_constructor_hasLaps(parsed_full_computer): 56 | bhc = BloodHoundComputer(parsed_full_computer) 57 | 58 | assert bhc.Properties['haslaps'] == True 59 | assert bhc.Properties['operatingsystem'] == 'Windows 10 Enterprise Evaluation' -------------------------------------------------------------------------------- /tests/ad/models/test_bloodhound_crossref.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.ad.models.bloodhound_crossref import BloodHoundCrossRef 3 | 4 | @pytest.fixture 5 | def parsed_crossref(): 6 | yield { 7 | "cn": "REDANIA", 8 | "dcsorepropagationdata": "16010101000000.0Z", 9 | "distinguishedname": "CN=REDANIA,CN=Partitions,CN=Configuration,DC=redania,DC=local", 10 | "dnsroot": "redania.local", 11 | "instancetype": "4", 12 | "msds-behavior-version": "7", 13 | "ncname": "DC=redania,DC=local", 14 | "netbiosname": "REDANIA", 15 | "ntmixeddomain": "0", 16 | "name": "REDANIA", 17 | "objectcategory": "CN=Cross-Ref,CN=Schema,CN=Configuration,DC=redania,DC=local", 18 | "objectclass": "top, crossRef", 19 | "objectguid": "f66cd454-5cf0-41c2-83c4-743ce81fb33e", 20 | "showinadvancedviewonly": "True", 21 | "systemflags": "3", 22 | "usnchanged": "12565", 23 | "usncreated": "4118", 24 | "whenchanged": "20230214042300.0Z", 25 | "whencreated": "20230214042103.0Z", 26 | } 27 | 28 | 29 | def test_crossref_constructor(parsed_crossref): 30 | cross_ref = BloodHoundCrossRef(parsed_crossref) 31 | 32 | assert cross_ref.netBiosName == "REDANIA" 33 | assert cross_ref.nCName == "DC=REDANIA,DC=LOCAL" 34 | assert cross_ref.distinguishedName == "CN=REDANIA,CN=PARTITIONS,CN=CONFIGURATION,DC=REDANIA,DC=LOCAL" -------------------------------------------------------------------------------- /tests/ad/models/test_bloodhound_object.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.ad.models.bloodhound_object import BloodHoundObject 3 | 4 | 5 | @pytest.fixture 6 | def parsed_full_user(): 7 | yield { 8 | 'objectclass': 'top, person, organizationalPerson, user', 9 | 'cn': 'Administrator', 10 | 'description': 'Built-in account for administering the computer/domain', 11 | 'distinguishedname': 'CN=Administrator,CN=Users,DC=test,DC=lab', 12 | 'instancetype': '4', 13 | 'whencreated': '20210826173042.0Z', 14 | 'whenchanged': '20220403141221.0Z', 15 | 'usncreated': '8196', 16 | 'memberof': 'CN=Group Policy Creator Owners,CN=Users,DC=test,DC=lab, CN=Domain Admins,CN=Users,DC=test,DC=lab, CN=Enterprise Admins,CN=Users,DC=test,DC=lab, CN=Schema Admins,CN=Users,DC=test,DC=lab, CN=Administrators,CN=Builtin,DC=test,DC=lab', 17 | 'usnchanged': '63985', 18 | 'ntsecuritydescriptor': 'AQAEnIgEAACkBAAAAAAAABQAAAAEAHQEGAAAAAUAPAAQAAAAAwAAAABCFkzAINARp2gAqgBuBSkUzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAABCFkzAINARp2gAqgBuBSm6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAABAgIF+ledARkCAAwE/C1M8UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAABAgIF+ledARkCAAwE/C1M+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEDCCrypedARkCAAwE/C1M8UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEDCCrypedARkCAAwE/C1M+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEIvulmiedARkCAAwE/C088UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEIvulmiedARkCAAwE/C08+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAPiIcAPhCtIRtCIAoMlo+TkUzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAPiIcAPhCtIRtCIAoMlo+Tm6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAOAAwAAAAAQAAAH96lr/mDdARooUAqgAwSeIBBQAAAAAABRUAAAB/ivvSK592RVonQNMFAgAABQAsABAAAAABAAAAHbGpRq5gWkC36P+KWNRW0gECAAAAAAAFIAAAADACAAAFACwAMAAAAAEAAAAcmrZtIpTREa69AAD4A2fBAQIAAAAAAAUgAAAAMQIAAAUALAAwAAAAAQAAAGK8BVjJvShEpeKFag9MGF4BAgAAAAAABSAAAAAxAgAABQAsAJQAAgACAAAAFMwoSDcUvEWbB61vAV5fKAECAAAAAAAFIAAAACoCAAAFACwAlAACAAIAAAC6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAKAAAAQAAAQAAAFMacqsvHtARmBkAqgBAUpsBAQAAAAAAAQAAAAAFACgAAAEAAAEAAABTGnKrLx7QEZgZAKoAQFKbAQEAAAAAAAUKAAAABQIoADABAAABAAAA3kfmkW/ZcEuVV9Y/9PPM2AEBAAAAAAAFCgAAAAAAJAC/AQ4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAAJAC/AQ4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAAGAC/AQ8AAQIAAAAAAAUgAAAAIAIAAAAAFACUAAIAAQEAAAAAAAULAAAAAAAUAP8BDwABAQAAAAAABRIAAAABBQAAAAAABRUAAAB/ivvSK592RVonQNMAAgAAAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAA==', 19 | 'name': 'Administrator', 20 | 'objectguid': '7b79190e-285c-40fc-8300-0584b3ee974b', 21 | 'useraccountcontrol': '66048', 22 | 'badpwdcount': '0', 23 | 'codepage': '0', 24 | 'countrycode': '0', 25 | 'badpasswordtime': '132919604027661803', 26 | 'lastlogoff': '0', 27 | 'lastlogon': '132940422420644609', 28 | 'logonhours': '?????????????????????', 29 | 'pwdlastset': '132836191102481334', 30 | 'primarygroupid': '513', 31 | 'objectsid': 'S-1-5-21-3539700351-1165401899-3544196954-500', 32 | 'admincount': '1', 33 | 'accountexpires': '0', 34 | 'logoncount': '208', 35 | 'samaccountname': 'Administrator', 36 | 'samaccounttype': '805306368', 37 | 'objectcategory': 'CN=Person,CN=Schema,CN=Configuration,DC=test,DC=lab', 38 | 'iscriticalsystemobject': 'TRUE', 39 | 'dscorepropagationdata': '20210826202656.0Z, 20210826202656.0Z, 20210826175542.0Z, 16010101181216.0Z', 40 | 'lastlogontimestamp': '132934687411151999', 41 | 'msds-supportedencryptiontypes': '0' 42 | } 43 | 44 | 45 | def test_constructor_firstEmptyObject(): 46 | bho = BloodHoundObject() 47 | 48 | 49 | def test_constructor_basicFullObject(parsed_full_user): 50 | bho = BloodHoundObject(parsed_full_user) 51 | 52 | assert bho.ObjectIdentifier == 'S-1-5-21-3539700351-1165401899-3544196954-500' 53 | assert bho.get_distinguished_name() == 'CN=ADMINISTRATOR,CN=USERS,DC=TEST,DC=LAB' 54 | assert bho.Properties['whencreated'] == 1629999042 55 | 56 | def test_merge_entry_fullOverwrite(): 57 | bho1 = BloodHoundObject({ 58 | 'objectsid': '024929', 59 | 'otherproperty': 1 60 | }) 61 | 62 | bho2 = BloodHoundObject({ 63 | 'objectsid': '024930', 64 | 'otherproperty': 2 65 | }) 66 | 67 | bho1.merge_entry(bho2, base_preference=False) 68 | assert bho1.ObjectIdentifier == '024930' 69 | assert bho1.get_property('otherproperty') == 2 70 | 71 | 72 | def test_merge_entry_preferBase(): 73 | bho1 = BloodHoundObject({ 74 | 'objectsid': '024929', 75 | 'otherproperty': 1 76 | }) 77 | 78 | bho2 = BloodHoundObject({ 79 | 'objectsid': '024930', 80 | 'otherproperty': 2 81 | }) 82 | 83 | bho1.merge_entry(bho2, base_preference=True) 84 | assert bho1.ObjectIdentifier == '024929' 85 | assert bho1.get_property('otherproperty') == 1 86 | 87 | 88 | def test_merge_entry_nonExistentBaseAttribute(): 89 | bho1 = BloodHoundObject() 90 | bho2 = BloodHoundObject({ 91 | 'objectsid': '024930', 92 | 'otherproperty': 2 93 | }) 94 | 95 | bho1.merge_entry(bho2) 96 | assert bho1.ObjectIdentifier == '024930' 97 | 98 | 99 | def test_merge_entry_nonExistentSourceAttribute(): 100 | bho1 = BloodHoundObject({ 101 | 'objectsid': '024929', 102 | 'otherproperty': 1 103 | }) 104 | bho2 = BloodHoundObject() 105 | 106 | bho1.merge_entry(bho2, base_preference=True) 107 | assert bho1.ObjectIdentifier == '024929' 108 | 109 | 110 | def test_merge_entry_emptyBaseAttributes(): 111 | bho1 = BloodHoundObject({ 112 | 'objectsid': '', 113 | 'distinguishedname': '' 114 | }) 115 | bho2 = BloodHoundObject({ 116 | 'objectsid': '024929', 117 | 'distinguishedname': 'DC=value' 118 | }) 119 | 120 | bho1.merge_entry(bho2, base_preference=True) 121 | assert bho1.ObjectIdentifier == '024929' 122 | assert bho1.get_property('distinguishedname') == 'DC=VALUE' 123 | 124 | 125 | def test_merge_entry_emptySourceAttributes(): 126 | bho1 = BloodHoundObject({ 127 | 'objectsid': '', 128 | 'distinguishedname': '' 129 | }) 130 | bho2 = BloodHoundObject({ 131 | 'objectsid': '024929', 132 | 'distinguishedname': 'DC=value' 133 | }) 134 | 135 | bho2.merge_entry(bho1) 136 | assert bho2.ObjectIdentifier == '024929' 137 | assert bho2.Properties['distinguishedname'] == 'DC=VALUE' 138 | -------------------------------------------------------------------------------- /tests/ad/models/test_bloodhound_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.ad.models import BloodHoundObject, BloodHoundUser 3 | 4 | @pytest.fixture 5 | def parsed_full_user(): 6 | yield { 7 | 'objectclass': 'top, person, organizationalPerson, user', 8 | 'cn': 'Administrator', 9 | 'description': 'Built-in account for administering the computer/domain', 10 | 'distinguishedname': 'CN=Administrator,CN=Users,DC=test,DC=lab', 11 | 'instancetype': '4', 12 | 'whencreated': '20210826173042.0Z', 13 | 'whenchanged': '20220403141221.0Z', 14 | 'usncreated': '8196', 15 | 'memberof': 'CN=GroupPolicy Creator Owners,CN=Users,DC=test,DC=lab, CN=Domain Admins,CN=Users,DC=test,DC=lab, CN=Enterprise Admins,CN=Users,DC=test,DC=lab, CN=Schema Admins,CN=Users,DC=test,DC=lab, CN=Administrators,CN=Builtin,DC=test,DC=lab', 16 | 'usnchanged': '63985', 17 | 'ntsecuritydescriptor': 'AQAEnIgEAACkBAAAAAAAABQAAAAEAHQEGAAAAAUAPAAQAAAAAwAAAABCFkzAINARp2gAqgBuBSkUzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAABCFkzAINARp2gAqgBuBSm6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAABAgIF+ledARkCAAwE/C1M8UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAABAgIF+ledARkCAAwE/C1M+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEDCCrypedARkCAAwE/C1M8UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEDCCrypedARkCAAwE/C1M+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEIvulmiedARkCAAwE/C088UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEIvulmiedARkCAAwE/C08+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAPiIcAPhCtIRtCIAoMlo+TkUzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAPiIcAPhCtIRtCIAoMlo+Tm6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAOAAwAAAAAQAAAH96lr/mDdARooUAqgAwSeIBBQAAAAAABRUAAAB/ivvSK592RVonQNMFAgAABQAsABAAAAABAAAAHbGpRq5gWkC36P+KWNRW0gECAAAAAAAFIAAAADACAAAFACwAMAAAAAEAAAAcmrZtIpTREa69AAD4A2fBAQIAAAAAAAUgAAAAMQIAAAUALAAwAAAAAQAAAGK8BVjJvShEpeKFag9MGF4BAgAAAAAABSAAAAAxAgAABQAsAJQAAgACAAAAFMwoSDcUvEWbB61vAV5fKAECAAAAAAAFIAAAACoCAAAFACwAlAACAAIAAAC6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAKAAAAQAAAQAAAFMacqsvHtARmBkAqgBAUpsBAQAAAAAAAQAAAAAFACgAAAEAAAEAAABTGnKrLx7QEZgZAKoAQFKbAQEAAAAAAAUKAAAABQIoADABAAABAAAA3kfmkW/ZcEuVV9Y/9PPM2AEBAAAAAAAFCgAAAAAAJAC/AQ4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAAJAC/AQ4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAAGAC/AQ8AAQIAAAAAAAUgAAAAIAIAAAAAFACUAAIAAQEAAAAAAAULAAAAAAAUAP8BDwABAQAAAAAABRIAAAABBQAAAAAABRUAAAB/ivvSK592RVonQNMAAgAAAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAA==', 18 | 'name': 'Administrator', 19 | 'objectguid': '7b79190e-285c-40fc-8300-0584b3ee974b', 20 | 'useraccountcontrol': '66048', 21 | 'badpwdcount': '0', 22 | 'codepage': '0', 23 | 'countrycode': '0', 24 | 'badpasswordtime': '132919604027661803', 25 | 'lastlogoff': '0', 26 | 'lastlogon': '132940422420644609', 27 | 'logonhours': '?????????????????????', 28 | 'pwdlastset': '132836191102481334', 29 | 'primarygroupid': '513', 30 | 'objectsid': 'S-1-5-21-3539700351-1165401899-3544196954-500', 31 | 'admincount': '1', 32 | 'accountexpires': '0', 33 | 'logoncount': '208', 34 | 'samaccountname': 'Administrator', 35 | 'samaccounttype': '805306368', 36 | 'objectcategory': 'CN=Person,CN=Schema,CN=Configuration,DC=test,DC=lab', 37 | 'iscriticalsystemobject': 'TRUE', 38 | 'dscorepropagationdata': '20210826202656.0Z, 20210826202656.0Z, 20210826175542.0Z, 16010101181216.0Z', 39 | 'lastlogontimestamp': '132934687411151999', 40 | 'msds-supportedencryptiontypes': '0' 41 | } 42 | -------------------------------------------------------------------------------- /tests/ad/test_adds.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.ad import ADDS 3 | from bofhound.ad.models import BloodHoundObject, BloodHoundUser, BloodHoundComputer 4 | from tests.test_data import testdata_ldapsearchbof_beacon_257_objects 5 | 6 | 7 | @pytest.fixture 8 | def raw_user(): 9 | yield { 10 | 'objectclass': 'top, person, organizationalPerson, user', 11 | 'cn': 'Administrator', 12 | 'distinguishedname': 'CN=Administrator,CN=Users,DC=test,DC=lab', 13 | 'memberof': 'CN=Group Policy Creator Owners,CN=Users,DC=test,DC=lab, CN=Domain Admins,CN=Users,DC=test,DC=lab, CN=Enterprise Admins,CN=Users,DC=test,DC=lab, CN=Schema Admins,CN=Users,DC=test,DC=lab, CN=Administrators,CN=Builtin,DC=test,DC=lab', 14 | 'ntsecuritydescriptor': 'AQAEnIgEAACkBAAAAAAAABQAAAAEAHQEGAAAAAUAPAAQAAAAAwAAAABCFkzAINARp2gAqgBuBSkUzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAABCFkzAINARp2gAqgBuBSm6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAABAgIF+ledARkCAAwE/C1M8UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAABAgIF+ledARkCAAwE/C1M+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEDCCrypedARkCAAwE/C1M8UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEDCCrypedARkCAAwE/C1M+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEIvulmiedARkCAAwE/C088UzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAEIvulmiedARkCAAwE/C08+6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAPiIcAPhCtIRtCIAoMlo+TkUzChINxS8RZsHrW8BXl8oAQIAAAAAAAUgAAAAKgIAAAUAPAAQAAAAAwAAAPiIcAPhCtIRtCIAoMlo+Tm6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAOAAwAAAAAQAAAH96lr/mDdARooUAqgAwSeIBBQAAAAAABRUAAAB/ivvSK592RVonQNMFAgAABQAsABAAAAABAAAAHbGpRq5gWkC36P+KWNRW0gECAAAAAAAFIAAAADACAAAFACwAMAAAAAEAAAAcmrZtIpTREa69AAD4A2fBAQIAAAAAAAUgAAAAMQIAAAUALAAwAAAAAQAAAGK8BVjJvShEpeKFag9MGF4BAgAAAAAABSAAAAAxAgAABQAsAJQAAgACAAAAFMwoSDcUvEWbB61vAV5fKAECAAAAAAAFIAAAACoCAAAFACwAlAACAAIAAAC6epa/5g3QEaKFAKoAMEniAQIAAAAAAAUgAAAAKgIAAAUAKAAAAQAAAQAAAFMacqsvHtARmBkAqgBAUpsBAQAAAAAAAQAAAAAFACgAAAEAAAEAAABTGnKrLx7QEZgZAKoAQFKbAQEAAAAAAAUKAAAABQIoADABAAABAAAA3kfmkW/ZcEuVV9Y/9PPM2AEBAAAAAAAFCgAAAAAAJAC/AQ4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAAJAC/AQ4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAAGAC/AQ8AAQIAAAAAAAUgAAAAIAIAAAAAFACUAAIAAQEAAAAAAAULAAAAAAAUAP8BDwABAQAAAAAABRIAAAABBQAAAAAABRUAAAB/ivvSK592RVonQNMAAgAAAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAA==', 15 | 'name': 'Administrator', 16 | 'objectguid': '7b79190e-285c-40fc-8300-0584b3ee974b', 17 | 'primarygroupid': '513', 18 | 'objectsid': 'S-1-5-21-3539700351-1165401899-3544196954-500', 19 | 'samaccountname': 'Administrator', 20 | 'samaccounttype': '805306368', 21 | 'objectcategory': 'CN=Person,CN=Schema,CN=Configuration,DC=test,DC=lab' 22 | } 23 | 24 | 25 | @pytest.fixture 26 | def raw_trust(): 27 | yield { 28 | 'cn': 'child.windomain.local', 29 | 'distinguishedname': 'CN=child.windomain.local,CN=System,DC=windomain,DC=local', 30 | 'flatname': 'CHILD', 31 | 'instancetype': '4', 32 | 'name': 'child.windomain.local', 33 | 'objectcategory': 'CN=Trusted-Domain,CN=Schema,CN=Configuration,DC=windomain,DC=local', 34 | 'objectclass': 'top, leaf, trustedDomain', 35 | 'objectguid': 'ccb3617a-af7a-4671-8c1d-aadbab087df1', 36 | 'trustattributes': '32', 37 | 'trustdirection': '3', 38 | 'trustpartner': 'child.windomain.local', 39 | 'trustposixfffset': '-2147483648', 40 | 'trusttype': '2', 41 | 'securityidentifier': 'S-1-5-21-3539700351-1165401899-3544196955', 42 | } 43 | 44 | 45 | @pytest.fixture 46 | def raw_domain(): 47 | yield { 48 | 'creationtime': '132979682115640324', 49 | 'dc': 'windomain', 50 | 'distinguishedname': 'DC=windomain,DC=local', 51 | 'forcelogoff': '-9223372036854775808', 52 | 'gplink': '[LDAP://cn={F08300B1-8BFF-4866-8524-14C03B50D991},cn=policies,cn=system,DC=windomain,DC=local;0][LDAP://cn={79081BF9-A672-4475-A982-D622CC600A49},cn=policies,cn=system,DC=windomain,DC=local;0][LDAP://CN={31B2F340-016D-11D2-945F-00C04FB984F9},CN=Policies,CN=System,DC=windomain,DC=local;0]', 53 | 'instancetype': '5', 54 | 'lockoutobservationwindow': '-18000000000', 55 | 'lockoutduration': '-18000000000', 56 | 'lockoutthreshold': '0', 57 | 'masteredBy': 'CN=NTDS Settings,CN=DC1,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=windomain,DC=local', 58 | 'name': 'windomain', 59 | 'nextrid': '1000', 60 | 'objectcategory': 'CN=Domain-DNS,CN=Schema,CN=Configuration,DC=windomain,DC=local', 61 | 'objectclass': 'top, domain, domainDNS', 62 | 'objectguid': '2825688f-74fd-460c-8b32-6b95b149f6ae', 63 | 'objectsid': 'S-1-5-21-3539700351-1165401899-3544196954', 64 | 'otherwellknownobjects': 'B:32:683A24E2E8164BD3AF86AC3C2CF3F981:CN=Keys,DC=windomain,DC=local, B:32:1EB93889E40C45DF9F0C64D23BBB6237:CN=Managed Service Accounts,DC=windomain,DC=local', 65 | 'pwdhistorylength': '24', 66 | 'pwdproperties': '1', 67 | 'ridmanagerreference': 'CN=RID Manager$,CN=System,DC=windomain,DC=local', 68 | 'serverstate': '1', 69 | 'subrefs': 'DC=ForestDnsZones,DC=windomain,DC=local, DC=DomainDnsZones,DC=windomain,DC=local, CN=Configuration,DC=windomain,DC=local', 70 | 'systemflags': '-1946157056', 71 | 'wellknownobjects': 'B:32:6227F0AF1FC2410D8E3BB10615BB5B0F:CN=NTDS Quotas,DC=windomain,DC=local, B:32:F4BE92A4C777485E878E9421D53087DB:CN=Microsoft,CN=Program Data,DC=windomain,DC=local, B:32:09460C08AE1E4A4EA0F64AEE7DAA1E5A:CN=Program Data,DC=windomain,DC=local, B:32:22B70C67D56E4EFB91E9300FCA3DC1AA:CN=ForeignSecurityPrincipals,DC=windomain,DC=local, B:32:18E2EA80684F11D2B9AA00C04F79F805:CN=Deleted Objects,DC=windomain,DC=local, B:32:2FBAC1870ADE11D297C400C04FD8D5CD:CN=Infrastructure,DC=windomain,DC=local, B:32:AB8153B7768811D1ADED00C04FD8D5CD:CN=LostAndFound,DC=windomain,DC=local, B:32:AB1D30F3768811D1ADED00C04FD8D5CD:CN=System,DC=windomain,DC=local, B:32:A361B2FFFFD211D1AA4B00C04FD7D83A:OU=Domain Controllers,DC=windomain,DC=local, B:32:AA312825768811D1ADED00C04FD8D5CD:CN=Computers,DC=windomain,DC=local, B:32:A9D1CA15768811D1ADED00C04FD8D5CD:CN=Users,DC=windomain,DC=local' 72 | } 73 | 74 | 75 | @pytest.fixture 76 | def raw_crossref(): 77 | yield { 78 | "cn": "REDANIA", 79 | "dcsorepropagationdata": "16010101000000.0Z", 80 | "distinguishedname": "CN=REDANIA,CN=Partitions,CN=Configuration,DC=redania,DC=local", 81 | "dnsroot": "redania.local", 82 | "instancetype": "4", 83 | "msds-behavior-version": "7", 84 | "ncname": "DC=redania,DC=local", 85 | "netbiosname": "REDANIA", 86 | "ntmixeddomain": "0", 87 | "name": "REDANIA", 88 | "objectcategory": "CN=Cross-Ref,CN=Schema,CN=Configuration,DC=redania,DC=local", 89 | "objectclass": "top, crossRef", 90 | "objectguid": "f66cd454-5cf0-41c2-83c4-743ce81fb33e", 91 | "showinadvancedviewonly": "True", 92 | "systemflags": "3", 93 | "usnchanged": "12565", 94 | "usncreated": "4118", 95 | "whenchanged": "20230214042300.0Z", 96 | "whencreated": "20230214042103.0Z", 97 | } 98 | 99 | 100 | def test_import_objects_singleSchema(): 101 | adds = ADDS() 102 | adds.import_objects([{ADDS.AT_SCHEMAIDGUID: 'ABWwRRnE0RG7yQCAx2ZwwA==', ADDS.AT_NAME: 'ANR'}]) 103 | 104 | assert len(adds.schemas) == 1 105 | 106 | 107 | def test_import_objects_singleSchema_ldif(): 108 | adds = ADDS() 109 | adds.import_objects([{ADDS.AT_SCHEMAIDGUID: '45b01500-c419-11d1-bbc9-0080c76670c0', ADDS.AT_NAME: 'ANR'}]) 110 | 111 | assert len(adds.schemas) == 1 112 | 113 | 114 | def test_import_objects_noAccountType(raw_user): 115 | adds = ADDS() 116 | raw_user.pop(ADDS.AT_SAMACCOUNTTYPE) 117 | 118 | adds.import_objects([raw_user]) 119 | 120 | assert (len(adds.users) == len(adds.computers) == len(adds.groups) \ 121 | == len(adds.trustaccounts) == len(adds.domains) == 0) \ 122 | and len(adds.unknown_objects) == 1 123 | 124 | 125 | def test_import_objects_expectedValuesFromStandardDataSet(testdata_ldapsearchbof_beacon_257_objects): 126 | adds = ADDS() 127 | adds.import_objects(testdata_ldapsearchbof_beacon_257_objects) 128 | 129 | assert len(adds.SID_MAP) == 92 130 | assert len(adds.DN_MAP) == 92 131 | assert len(adds.DOMAIN_MAP) == 1 132 | assert len(adds.users) == 5 133 | assert len(adds.computers) == 4 134 | assert len(adds.groups) == 53 135 | assert len(adds.domains) == 1 136 | assert len(adds.schemas) == 0 137 | assert len(adds.trustaccounts) == 0 138 | assert len(adds.ous) == 1 139 | assert len(adds.gpos) == 4 140 | assert len(adds.containers) == 24 141 | assert len(adds.unknown_objects) == 69 142 | 143 | 144 | def test_import_objects_MinimalObject(raw_user): 145 | expected_sid = 'S-1-5-21-3539700351-1165401899-3544196954-500' 146 | expected_dn = 'CN=ADMINISTRATOR,CN=USERS,DC=TEST,DC=LAB' 147 | 148 | adds = ADDS() 149 | adds.import_objects([raw_user]) 150 | 151 | sid_map_object = adds.SID_MAP[expected_sid] 152 | dn_map_object = adds.DN_MAP[expected_dn] 153 | 154 | assert len(adds.SID_MAP) == 1 155 | assert sid_map_object.Properties[ADDS.AT_DISTINGUISHEDNAME] == expected_dn 156 | assert dn_map_object.ObjectIdentifier == expected_sid 157 | 158 | 159 | def test_import_objects_DuplicateObject(raw_user): 160 | expected_sid = 'S-1-5-21-3539700351-1165401899-3544196954-500' 161 | expected_dn = 'CN=ADMINISTRATOR,CN=USERS,DC=TEST,DC=LAB' 162 | 163 | adds = ADDS() 164 | adds.import_objects([raw_user, raw_user]) 165 | 166 | sid_map_object = adds.SID_MAP[expected_sid] 167 | dn_map_object = adds.DN_MAP[expected_dn] 168 | 169 | assert len(adds.SID_MAP) == 1 170 | assert sid_map_object.Properties[ADDS.AT_DISTINGUISHEDNAME] == expected_dn 171 | assert dn_map_object.ObjectIdentifier == expected_sid 172 | 173 | 174 | def test_import_unique_trust(raw_trust, raw_domain): 175 | expected_domain_count = 1 176 | expected_trust_count = 1 177 | 178 | adds = ADDS() 179 | 180 | adds = ADDS() 181 | adds.import_objects([raw_domain, raw_trust]) 182 | adds.process() 183 | 184 | assert len(adds.domains) == expected_domain_count 185 | assert len(adds.domains[0].Trusts) == expected_trust_count 186 | 187 | 188 | def test_import_duplicate_trust(raw_trust, raw_domain): 189 | expected_domain_count = 1 190 | expected_trust_count = 1 191 | 192 | adds = ADDS() 193 | 194 | adds = ADDS() 195 | adds.import_objects([raw_domain, raw_trust, raw_trust]) 196 | adds.process() 197 | 198 | assert len(adds.domains) == expected_domain_count 199 | assert len(adds.domains[0].Trusts) == expected_trust_count 200 | 201 | 202 | def test_import_unique_crossref(raw_crossref): 203 | expected_crossref_count = 1 204 | 205 | adds = ADDS() 206 | 207 | adds = ADDS() 208 | adds.import_objects([raw_crossref]) 209 | 210 | assert len(adds.CROSSREF_MAP) == expected_crossref_count 211 | 212 | 213 | def test_import_duplicate_crossref(raw_crossref): 214 | expected_crossref_count = 1 215 | 216 | adds = ADDS() 217 | 218 | adds = ADDS() 219 | adds.import_objects([raw_crossref, raw_crossref]) 220 | 221 | assert len(adds.CROSSREF_MAP) == expected_crossref_count -------------------------------------------------------------------------------- /tests/local/test_local_broker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.local import LocalBroker 3 | from tests.test_data import * 4 | 5 | KNOWN_DOMAIN_SIDS = [ 6 | "S-1-5-21-1308756548-3893869957-2915408613" 7 | ] 8 | 9 | 10 | def test_import_netloggedon_objects(netloggedon_redania_objects): 11 | local_broker = LocalBroker() 12 | local_broker.import_objects(netloggedon_redania_objects, KNOWN_DOMAIN_SIDS) 13 | 14 | assert len(local_broker.privileged_sessions) == 1 15 | 16 | 17 | def test_import_netsession_netapi_objects(netsession_redania_netapi_objects): 18 | local_broker = LocalBroker() 19 | local_broker.import_objects(netsession_redania_netapi_objects, KNOWN_DOMAIN_SIDS) 20 | 21 | assert len(local_broker.sessions) == 1 22 | 23 | 24 | def test_import_netsession_netapi_anonymous(): 25 | anonymous = {"ObjectType": "Session", "User": "anonymous logon", "ComputerName": "TRETOGOR", "ComputerDomain": "REDANIA"} 26 | local_broker = LocalBroker() 27 | local_broker.import_objects([anonymous], KNOWN_DOMAIN_SIDS) 28 | 29 | assert len(local_broker.sessions) == 0 30 | 31 | 32 | def test_import_netsession_dns_objects(netsession_redania_dns_objects): 33 | local_broker = LocalBroker() 34 | local_broker.import_objects(netsession_redania_dns_objects, KNOWN_DOMAIN_SIDS) 35 | 36 | assert len(local_broker.sessions) == 1 37 | 38 | 39 | def test_import_netsession_dns_anonymous(): 40 | anonymous = {"ObjectType": "Session", "User": "anonymous logon", "PTR": "tretogor.redania.local"} 41 | local_broker = LocalBroker() 42 | local_broker.import_objects([anonymous], KNOWN_DOMAIN_SIDS) 43 | 44 | assert len(local_broker.sessions) == 0 45 | 46 | 47 | def test_import_netlocalgroup_objects(netlocalgroup_redania_objects): 48 | local_broker = LocalBroker() 49 | local_broker.import_objects(netlocalgroup_redania_objects, KNOWN_DOMAIN_SIDS) 50 | 51 | assert len(local_broker.local_group_memberships) == 3 52 | 53 | 54 | def test_import_netlocalgroup_invalid_group(): 55 | bad_group = {"ObjectType": "LocalGroupMembership", "Member": "Administrator", "Host": "oxenfurt.redania,local", "Group": "BadGroup"} 56 | local_broker = LocalBroker() 57 | local_broker.import_objects([bad_group], KNOWN_DOMAIN_SIDS) 58 | 59 | assert len(local_broker.local_group_memberships) == 0 60 | 61 | 62 | def test_import_regsession_objects(regsession_redania_objects): 63 | local_broker = LocalBroker() 64 | local_broker.import_objects(regsession_redania_objects, KNOWN_DOMAIN_SIDS) 65 | 66 | assert len(local_broker.registry_sessions) == 3 67 | 68 | 69 | def test_objects_with_ip_as_host(): 70 | priv_session = { 71 | "ObjectType": "PrivilegedSession", 72 | "Username": "Administrator", 73 | "Domain": "REDANIA", 74 | "Host": "192.168.0.235" 75 | } 76 | registry_session = { 77 | "ObjectType": "RegistrySession", 78 | "UserSid": "S-1-5-21-1308756548-3893869957-2915408613-500", 79 | "Host": "192.168.0.215" 80 | } 81 | local_group_membership = { 82 | "ObjectType": "LocalGroupMembership", 83 | "Host": "192.168.0.215", 84 | "Group": "Administrators", 85 | "Member": "REDANIA\\Domain Admins", 86 | "MemberSid": "S-1-5-21-1308756548-3893869957-2915408613-512", 87 | "MemberSidType": "Group" 88 | } 89 | 90 | # Sessions resolved through NetSessionEnum will not have an IP host 91 | 92 | local_broker = LocalBroker() 93 | local_broker.import_objects( 94 | [ 95 | priv_session, 96 | registry_session, 97 | local_group_membership 98 | ], 99 | KNOWN_DOMAIN_SIDS 100 | ) 101 | 102 | assert len(local_broker.privileged_sessions) == 0 103 | assert len(local_broker.registry_sessions) == 0 104 | assert len(local_broker.local_group_memberships) == 0 -------------------------------------------------------------------------------- /tests/parsers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coffeegist/bofhound/fed5d2b63fc38f178423dfb1f77e2f6e9d337634/tests/parsers/__init__.py -------------------------------------------------------------------------------- /tests/parsers/test_brc4_ldap_sentinel.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from bofhound.ad.models.bloodhound_computer import BloodHoundComputer 4 | from bofhound.parsers.brc4_ldap_sentinel import Brc4LdapSentinelParser 5 | from bofhound.ad.adds import ADDS 6 | from tests.test_data import * 7 | 8 | 9 | def test_parse_file_ldapsearchpyNormalFile(brc4ldapsentinel_standard_file_1030): 10 | parsed_objects = Brc4LdapSentinelParser.parse_file(brc4ldapsentinel_standard_file_1030) 11 | assert len(parsed_objects) == 1030 12 | 13 | 14 | def test_parse_data_computer(): 15 | data = """[+] AccountDisabled : FALSE 16 | 17 | +-------------------------------------------------------------------+ 18 | [+] objectClass : top; person; organizationalPerson; user; computer 19 | [+] cn : WS1 20 | [+] distinguishedName : CN=WS1,CN=Computers,DC=castle,DC=lab 21 | [+] instanceType : 4 22 | [+] whenCreated : 3/1/2023 5:33:52 PM 23 | [+] whenChanged : 3/1/2023 5:34:27 PM 24 | [+] uSNCreated : high: 0 low: 12880 25 | [+] uSNChanged : high: 0 low: 12894 26 | [+] name : WS1 27 | [+] objectGUID : {7FE62E98-AA0F-4DA0-98DC-80B22ED80B24} 28 | [+] userAccountControl : 4096 29 | [+] badPwdCount : 0 30 | [+] codePage : 0 31 | [+] countryCode : 0 32 | [+] badPasswordTime : Value not set 33 | [+] lastLogoff : Value not set 34 | [+] lastLogon : 3/7/2023 10:14:50 AM 35 | [+] localPolicyFlags : 0 36 | [+] pwdLastSet : 3/1/2023 9:33:52 AM 37 | [+] primaryGroupID : 515 38 | [+] objectSid : S-1-5-21-4033075623-2380760593-384075220-1104 39 | [+] accountExpires : Never expires 40 | [+] logonCount : 23 41 | [+] sAMAccountName : WS1$ 42 | [+] sAMAccountType : 805306369 43 | [+] operatingSystem : Windows 10 Pro N 44 | [+] operatingSystemVersion : 10.0 (19045) 45 | [+] dNSHostName : ws1.castle.lab 46 | [+] servicePrincipalName : RestrictedKrbHost/WS1; HOST/WS1; RestrictedKrbHost/ws1.castle.lab; HOST/ws1.castle.lab 47 | [+] objectCategory : CN=Computer,CN=Schema,CN=Configuration,DC=castle,DC=lab 48 | [+] isCriticalSystemObject : FALSE 49 | [+] dSCorePropagationData : 1/1/1601 50 | [+] lastLogonTimestamp : 3/1/2023 9:34:27 AM 51 | [+] msDS-SupportedEncryptionTypes : 28 52 | [+] ADsPath : LDAP://CN=WS1,CN=Computers,DC=castle,DC=lab 53 | [+] PasswordSettings : Never expires 54 | [+] AccountDisabled : FALSE 55 | 56 | +-------------------------------------------------------------------+ 57 | """ 58 | parsed_objects = Brc4LdapSentinelParser.parse_data(data) 59 | 60 | assert len(parsed_objects) == 1 61 | assert 'operatingsystem' in parsed_objects[0].keys() 62 | assert 'operatingsystem' in BloodHoundComputer(parsed_objects[0]).Properties.keys() 63 | 64 | 65 | def test_parse_lower_data_computer(): 66 | data = """[+] accountdisabled : false 67 | 68 | +-------------------------------------------------------------------+ 69 | [+] objectclass : top; person; organizationalperson; user; computer 70 | [+] cn : ws1 71 | [+] distinguishedname : cn=ws1,cn=computers,dc=castle,dc=lab 72 | [+] instancetype : 4 73 | [+] whencreated : 3/1/2023 5:33:52 pm 74 | [+] whenchanged : 3/1/2023 5:34:27 pm 75 | [+] usncreated : high: 0 low: 12880 76 | [+] usnchanged : high: 0 low: 12894 77 | [+] name : ws1 78 | [+] objectguid : {7fe62e98-aa0f-4da0-98dc-80b22ed80b24} 79 | [+] useraccountcontrol : 4096 80 | [+] badpwdcount : 0 81 | [+] codepage : 0 82 | [+] countrycode : 0 83 | [+] badpasswordtime : value not set 84 | [+] lastlogoff : value not set 85 | [+] lastlogon : 3/7/2023 10:14:50 am 86 | [+] localpolicyflags : 0 87 | [+] pwdlastset : 3/1/2023 9:33:52 am 88 | [+] primarygroupid : 515 89 | [+] objectsid : s-1-5-21-4033075623-2380760593-384075220-1104 90 | [+] accountexpires : never expires 91 | [+] logoncount : 23 92 | [+] samaccountname : ws1$ 93 | [+] samaccounttype : 805306369 94 | [+] operatingsystem : windows 10 pro n 95 | [+] operatingsystemversion : 10.0 (19045) 96 | [+] dnshostname : ws1.castle.lab 97 | [+] serviceprincipalname : restrictedkrbhost/ws1; host/ws1; restrictedkrbhost/ws1.castle.lab; host/ws1.castle.lab 98 | [+] objectcategory : cn=computer,cn=schema,cn=configuration,dc=castle,dc=lab 99 | [+] iscriticalsystemobject : false 100 | [+] dscorepropagationdata : 1/1/1601 101 | [+] lastlogontimestamp : 3/1/2023 9:34:27 am 102 | [+] msds-supportedencryptiontypes : 28 103 | [+] adspath : ldap://cn=ws1,cn=computers,dc=castle,dc=lab 104 | [+] passwordsettings : never expires 105 | [+] accountdisabled : false 106 | 107 | +-------------------------------------------------------------------+ 108 | """ 109 | parsed_objects = Brc4LdapSentinelParser.parse_data(data) 110 | ad = ADDS() 111 | ad.import_objects(parsed_objects) 112 | 113 | assert len(parsed_objects) == 1 114 | assert 'operatingsystem' in parsed_objects[0].keys() 115 | assert 'operatingsystem' in BloodHoundComputer(parsed_objects[0]).Properties.keys() 116 | assert len(ad.computers) == 1 117 | 118 | 119 | def test_parse_data_computer_data_missing_dn(): 120 | data = """[+] AccountDisabled : FALSE 121 | 122 | +-------------------------------------------------------------------+ 123 | [+] objectClass : top; person; organizationalPerson; user; computer 124 | [+] cn : WS1 125 | [+] instanceType : 4 126 | [+] whenCreated : 3/1/2023 5:33:52 PM 127 | [+] whenChanged : 3/1/2023 5:34:27 PM 128 | [+] uSNCreated : high: 0 low: 12880 129 | [+] uSNChanged : high: 0 low: 12894 130 | [+] name : WS1 131 | [+] objectGUID : {7FE62E98-AA0F-4DA0-98DC-80B22ED80B24} 132 | [+] userAccountControl : 4096 133 | [+] badPwdCount : 0 134 | [+] codePage : 0 135 | [+] countryCode : 0 136 | [+] badPasswordTime : Value not set 137 | [+] lastLogoff : Value not set 138 | [+] lastLogon : 3/7/2023 10:14:50 AM 139 | [+] localPolicyFlags : 0 140 | [+] pwdLastSet : 3/1/2023 9:33:52 AM 141 | [+] primaryGroupID : 515 142 | [+] objectSid : S-1-5-21-4033075623-2380760593-384075220-1104 143 | [+] accountExpires : Never expires 144 | [+] logonCount : 23 145 | [+] sAMAccountName : WS1$ 146 | [+] sAMAccountType : 805306369 147 | [+] operatingSystem : Windows 10 Pro N 148 | [+] operatingSystemVersion : 10.0 (19045) 149 | [+] dNSHostName : ws1.castle.lab 150 | [+] servicePrincipalName : RestrictedKrbHost/WS1; HOST/WS1; RestrictedKrbHost/ws1.castle.lab; HOST/ws1.castle.lab 151 | [+] objectCategory : CN=Computer,CN=Schema,CN=Configuration,DC=castle,DC=lab 152 | [+] isCriticalSystemObject : FALSE 153 | [+] dSCorePropagationData : 1/1/1601 154 | [+] lastLogonTimestamp : 3/1/2023 9:34:27 AM 155 | [+] msDS-SupportedEncryptionTypes : 28 156 | [+] ADsPath : LDAP://CN=WS1,CN=Computers,DC=castle,DC=lab 157 | [+] PasswordSettings : Never expires 158 | [+] AccountDisabled : FALSE 159 | 160 | +-------------------------------------------------------------------+ 161 | """ 162 | parsed_objects = Brc4LdapSentinelParser.parse_data(data) 163 | ad = ADDS() 164 | ad.import_objects(parsed_objects) 165 | 166 | assert len(parsed_objects) == 1 167 | # this test is failing - should distinguishedname be required? 168 | assert len(ad.computers) == 0 169 | 170 | 171 | # This test case currently is not possible with BRc4, 172 | # since all attributes are returned by default 173 | 174 | ''' 175 | def test_parse_mininal_data_computer(): 176 | data = """[+] accountdisabled : false 177 | 178 | +-------------------------------------------------------------------+ 179 | [+] distinguishedname : cn=ws1,cn=computers,dc=castle,dc=lab 180 | [+] objectsid : s-1-5-21-4033075623-2380760593-384075220-1104 181 | [+] samaccounttype : 805306369 182 | 183 | +-------------------------------------------------------------------+ 184 | """ 185 | parsed_objects = Brc4LdapSentinelParser.parse_data(data) 186 | ad = ADDS() 187 | ad.import_objects(parsed_objects) 188 | 189 | assert len(parsed_objects) == 1 190 | assert len(ad.computers) == 1 191 | ''' 192 | -------------------------------------------------------------------------------- /tests/parsers/test_generic_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bofhound.parsers.generic_parser import GenericParser 3 | from bofhound.parsers.shared_parsers import NetLoggedOnBofParser, NetSessionBofParser, NetLocalGroupBofParser, RegSessionBofParser 4 | from tests.test_data import * 5 | 6 | 7 | def test_parse_file_netloggedon_redania(netloggedon_redania_file): 8 | parsed_objects = GenericParser.parse_file(netloggedon_redania_file) 9 | assert len(parsed_objects) == 12 10 | 11 | 12 | def test_parse_file_netsession_redania_netapi(netsession_redania_netapi_file): 13 | parsed_objects = GenericParser.parse_file(netsession_redania_netapi_file) 14 | assert len(parsed_objects) == 2 15 | 16 | 17 | def test_parse_file_netsession_redania_dns(netsession_redania_dns_file): 18 | parsed_objects = GenericParser.parse_file(netsession_redania_dns_file) 19 | assert len(parsed_objects) == 2 20 | 21 | 22 | def test_parse_file_netlocalgroup_redania(netlocalgroup_redania_file): 23 | parsed_objects = GenericParser.parse_file(netlocalgroup_redania_file) 24 | assert len(parsed_objects) == 5 25 | 26 | 27 | def test_parse_file_regsession_redania(regsession_redania_file): 28 | parsed_objects = GenericParser.parse_file(regsession_redania_file) 29 | assert len(parsed_objects) == 4 30 | 31 | 32 | def test_parsed_object_types(netloggedon_redania_file, netsession_redania_netapi_file, netsession_redania_dns_file, netlocalgroup_redania_file, regsession_redania_file): 33 | parsed_privsessions_objects = GenericParser.parse_file(netloggedon_redania_file) 34 | parsed_session_netapi_objects = GenericParser.parse_file(netsession_redania_netapi_file) 35 | parsed_session_dns_objects = GenericParser.parse_file(netsession_redania_dns_file) 36 | parsed_localgroup_objects = GenericParser.parse_file(netlocalgroup_redania_file) 37 | parsed_regsession_objects = GenericParser.parse_file(regsession_redania_file) 38 | 39 | assert parsed_privsessions_objects[0]["ObjectType"] == NetLoggedOnBofParser.OBJECT_TYPE 40 | assert parsed_session_netapi_objects[0]["ObjectType"] == NetSessionBofParser.OBJECT_TYPE 41 | assert parsed_session_dns_objects[1]["ObjectType"] == NetSessionBofParser.OBJECT_TYPE 42 | assert parsed_localgroup_objects[1]["ObjectType"] == NetLocalGroupBofParser.OBJECT_TYPE 43 | assert parsed_regsession_objects[0]["ObjectType"] == RegSessionBofParser.OBJECT_TYPE -------------------------------------------------------------------------------- /tests/test_data/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from bofhound.parsers import LdapSearchBofParser 4 | from bofhound.parsers.generic_parser import GenericParser 5 | from bofhound.ad import ADDS 6 | from bofhound.local import LocalBroker 7 | 8 | TEST_DATA_DIR = os.path.abspath( 9 | os.path.join( 10 | os.path.dirname(os.path.abspath(__file__)), 11 | "..", 12 | "test_data" 13 | ) 14 | ) 15 | 16 | # LdapSearchPY Fixtures 17 | @pytest.fixture 18 | def ldapsearchpy_standard_file_516(): 19 | yield os.path.join(TEST_DATA_DIR, "ldapsearchpy_logs/ldapsearch_516-objects.log") 20 | 21 | 22 | # LdapSearchBOF Fixtures 23 | @pytest.fixture 24 | def ldapsearchbof_standard_file_257(): 25 | yield os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/beacon_257-objects.log") 26 | 27 | 28 | @pytest.fixture 29 | def ldapsearchbof_standard_file_202(): 30 | yield os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/beacon_202.log") 31 | 32 | 33 | @pytest.fixture 34 | def testdata_ldapsearchbof_beacon_257_objects(): 35 | log_file = os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/beacon_257-objects.log") 36 | yield LdapSearchBofParser.parse_file(log_file) 37 | 38 | 39 | @pytest.fixture 40 | def testdata_ldapsearchbof_beacon_202_objects(): 41 | log_file = os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/beacon_202.log") 42 | yield LdapSearchBofParser.parse_file(log_file) 43 | 44 | 45 | @pytest.fixture 46 | def testdata_pyldapsearch_redania_objects(): 47 | log_file = os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/pyldapsearch_redania_objects.log") 48 | yield LdapSearchBofParser.parse_file(log_file) 49 | 50 | 51 | @pytest.fixture 52 | def testdata_marvel_ldap_objects(): 53 | log_file = os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/beacon_marvel_ldap_sessions_localgroup.log") 54 | yield LdapSearchBofParser.parse_file(log_file) 55 | 56 | 57 | @pytest.fixture 58 | def testdata_marvel_local_objects(): 59 | log_file = os.path.join(TEST_DATA_DIR, "ldapsearchbof_logs/beacon_marvel_ldap_sessions_localgroup.log") 60 | yield GenericParser.parse_file(log_file) 61 | 62 | 63 | # BRc4 LDAP Sentinel Fixtures 64 | @pytest.fixture 65 | def brc4ldapsentinel_standard_file_1030(): 66 | yield os.path.join(TEST_DATA_DIR, "brc4_ldap_sentinel_logs/badger_no_acl_1030_objects.log") 67 | 68 | 69 | #### Generic Parser Fixtures 70 | 71 | # NetLoggedOn BOF Fixtures 72 | @pytest.fixture 73 | def netloggedon_redania_file(): 74 | yield os.path.join(TEST_DATA_DIR, "netloggedonbof_logs/netloggedonbof_redania.log") 75 | 76 | 77 | @pytest.fixture 78 | def netloggedon_redania_objects(): 79 | log_file = os.path.join(TEST_DATA_DIR, "netloggedonbof_logs/netloggedonbof_redania.log") 80 | yield GenericParser.parse_file(log_file) 81 | 82 | # NetSession BOF Fixtures 83 | @pytest.fixture 84 | def netsession_redania_netapi_file(): 85 | yield os.path.join(TEST_DATA_DIR, "netsessionbof_logs/netsessionbof_redania_netapi.log") 86 | 87 | 88 | @pytest.fixture 89 | def netsession_redania_netapi_objects(): 90 | log_file = os.path.join(TEST_DATA_DIR, "netsessionbof_logs/netsessionbof_redania_netapi.log") 91 | yield GenericParser.parse_file(log_file) 92 | 93 | 94 | @pytest.fixture 95 | def netsession_redania_dns_file(): 96 | yield os.path.join(TEST_DATA_DIR, "netsessionbof_logs/netsessionbof_redania_dns.log") 97 | 98 | 99 | @pytest.fixture 100 | def netsession_redania_dns_objects(): 101 | log_file = os.path.join(TEST_DATA_DIR, "netsessionbof_logs/netsessionbof_redania_dns.log") 102 | yield GenericParser.parse_file(log_file) 103 | 104 | # NetLocalGroup BOF Fixtures 105 | @pytest.fixture 106 | def netlocalgroup_redania_file(): 107 | yield os.path.join(TEST_DATA_DIR, "netlocalgroupbof_logs/netlocalgroupbof_redania.log") 108 | 109 | 110 | @pytest.fixture 111 | def netlocalgroup_redania_objects(): 112 | log_file = os.path.join(TEST_DATA_DIR, "netlocalgroupbof_logs/netlocalgroupbof_redania.log") 113 | yield GenericParser.parse_file(log_file) 114 | 115 | 116 | # RegSession BOF Fixtures 117 | @pytest.fixture 118 | def regsession_redania_file(): 119 | yield os.path.join(TEST_DATA_DIR, "regsessionbof_logs/regsessionbof_redania.log") 120 | 121 | 122 | @pytest.fixture 123 | def regsession_redania_objects(): 124 | log_file = os.path.join(TEST_DATA_DIR, "regsessionbof_logs/regsessionbof_redania.log") 125 | yield GenericParser.parse_file(log_file) 126 | 127 | 128 | # fixture for processing marvel LDAP and local objects into a complete ADDS object 129 | @pytest.fixture 130 | def marvel_adds(testdata_marvel_ldap_objects, testdata_marvel_local_objects): 131 | ad = ADDS() 132 | broker = LocalBroker() 133 | 134 | ad.import_objects(testdata_marvel_ldap_objects) 135 | broker.import_objects(testdata_marvel_local_objects, ad.DOMAIN_MAP.values()) 136 | 137 | ad.process() 138 | ad.process_local_objects(broker) 139 | 140 | yield ad -------------------------------------------------------------------------------- /tests/test_data/json_output/computers_20220413_204956.json: -------------------------------------------------------------------------------- 1 | {"data": [{"LocalAdmins": {"Collected": false, "FailureReason": null, "Results": []}, "PSRemoteUsers": {"Collected": false, "FailureReason": null, "Results": []}, "RemoteDesktopUsers": {"Collected": false, "FailureReason": null, "Results": []}, "DcomUsers": {"Collected": false, "FailureReason": null, "Results": []}, "Sessions": {"Collected": false, "FailureReason": null, "Results": []}, "PrivilegedSessions": {"Collected": false, "FailureReason": null, "Results": []}, "RegistrySessions": {"Collected": false, "FailureReason": null, "Results": []}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}], "IsACLProtected": false, "ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-1000", "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-516", "AllowedToDelegate": [], "Properties": {"name": "DC1.EZ.LAB", "objectid": "S-1-5-21-3539700351-1165401899-3544196954-1000", "unconstraineddelegation": true, "enabled": true, "domain": "EZ.LAB", "highvalue": false, "distinguishedname": "CN=DC1,OU=Domain Controllers,DC=ez,DC=lab", "lastlogontimestamp": 1649365533, "lastlogon": 1649682341, "pwdlastset": 1648314470, "serviceprincipalnames": ["Dfsr-12F9A27C-BF97-4787-9364-D31B6C55EB04/DC1.ez.lab", "ldap/DC1.ez.lab/ForestDnsZones.ez.lab", "ldap/DC1.ez.lab/DomainDnsZones.ez.lab", "DNS/DC1.ez.lab", "GC/DC1.ez.lab/ez.lab", "RestrictedKrbHost/DC1.ez.lab", "RestrictedKrbHost/DC1", "RPC/6b8bdc96-cba0-48a5-88fd-e5c612da8ec0._msdcs.ez.lab", "HOST/DC1/EZ", "HOST/DC1.ez.lab/EZ", "HOST/DC1", "HOST/DC1.ez.lab", "HOST/DC1.ez.lab/ez.lab", "E3514235-4B06-11D1-AB04-00C04FC2DCD2/6b8bdc96-cba0-48a5-88fd-e5c612da8ec0/ez.lab", "ldap/DC1/EZ", "ldap/6b8bdc96-cba0-48a5-88fd-e5c612da8ec0._msdcs.ez.lab", "ldap/DC1.ez.lab/EZ", "ldap/DC1", "ldap/DC1.ez.lab", "ldap/DC1.ez.lab/ez.lab"], "operatingsystem": "Windows - 10.0 (17763)"}, "AllowedToAct": []}, {"LocalAdmins": {"Collected": false, "FailureReason": null, "Results": []}, "PSRemoteUsers": {"Collected": false, "FailureReason": null, "Results": []}, "RemoteDesktopUsers": {"Collected": false, "FailureReason": null, "Results": []}, "DcomUsers": {"Collected": false, "FailureReason": null, "Results": []}, "Sessions": {"Collected": false, "FailureReason": null, "Results": []}, "PrivilegedSessions": {"Collected": false, "FailureReason": null, "Results": []}, "RegistrySessions": {"Collected": false, "FailureReason": null, "Results": []}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "EZ.LAB-S-1-5-32-548", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}], "IsACLProtected": false, "ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-1105", "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-515", "AllowedToDelegate": [], "Properties": {"name": "WS1.EZ.LAB", "objectid": "S-1-5-21-3539700351-1165401899-3544196954-1105", "unconstraineddelegation": false, "enabled": true, "domain": "EZ.LAB", "highvalue": false, "distinguishedname": "CN=WS1,CN=Computers,DC=ez,DC=lab", "lastlogontimestamp": 1649082772, "lastlogon": 1649671471, "pwdlastset": 1647485202, "serviceprincipalnames": ["RestrictedKrbHost/WS1", "HOST/WS1", "RestrictedKrbHost/WS1.ez.lab", "HOST/WS1.ez.lab"], "operatingsystem": "Windows - 10.0 (19043)"}, "AllowedToAct": []}, {"LocalAdmins": {"Collected": false, "FailureReason": null, "Results": []}, "PSRemoteUsers": {"Collected": false, "FailureReason": null, "Results": []}, "RemoteDesktopUsers": {"Collected": false, "FailureReason": null, "Results": []}, "DcomUsers": {"Collected": false, "FailureReason": null, "Results": []}, "Sessions": {"Collected": false, "FailureReason": null, "Results": []}, "PrivilegedSessions": {"Collected": false, "FailureReason": null, "Results": []}, "RegistrySessions": {"Collected": false, "FailureReason": null, "Results": []}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "EZ.LAB-S-1-5-32-548", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}], "IsACLProtected": false, "ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-1106", "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-515", "AllowedToDelegate": [], "Properties": {"name": "WS2.EZ.LAB", "objectid": "S-1-5-21-3539700351-1165401899-3544196954-1106", "unconstraineddelegation": false, "enabled": true, "domain": "EZ.LAB", "highvalue": false, "distinguishedname": "CN=WS2,CN=Computers,DC=ez,DC=lab", "lastlogontimestamp": 1635250878, "lastlogon": 1635788537, "pwdlastset": 1635250885, "serviceprincipalnames": ["RestrictedKrbHost/WS2", "HOST/WS2", "RestrictedKrbHost/WS2.ez.lab", "HOST/WS2.ez.lab"], "operatingsystem": "Windows - 10.0 (19043)"}, "AllowedToAct": []}, {"LocalAdmins": {"Collected": false, "FailureReason": null, "Results": []}, "PSRemoteUsers": {"Collected": false, "FailureReason": null, "Results": []}, "RemoteDesktopUsers": {"Collected": false, "FailureReason": null, "Results": []}, "DcomUsers": {"Collected": false, "FailureReason": null, "Results": []}, "Sessions": {"Collected": false, "FailureReason": null, "Results": []}, "PrivilegedSessions": {"Collected": false, "FailureReason": null, "Results": []}, "RegistrySessions": {"Collected": false, "FailureReason": null, "Results": []}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "EZ.LAB-S-1-5-32-548", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}], "IsACLProtected": false, "ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-1112", "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-515", "AllowedToDelegate": [], "Properties": {"name": "TESTYBOI.EZ.LAB", "objectid": "S-1-5-21-3539700351-1165401899-3544196954-1112", "unconstraineddelegation": false, "enabled": true, "domain": "EZ.LAB", "highvalue": false, "distinguishedname": "CN=TestyBoi,CN=Computers,DC=ez,DC=lab", "lastlogontimestamp": 1639306889, "lastlogon": 1639307367, "pwdlastset": 1639306865}, "AllowedToAct": []}], "meta": {"type": "computers", "count": 4, "methods": 0, "version": 4}} -------------------------------------------------------------------------------- /tests/test_data/json_output/domains_20220413_204956.json: -------------------------------------------------------------------------------- 1 | {"data": [{"ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954", "Properties": {"name": "EZ.LAB", "domain": "EZ.LAB", "objectid": "S-1-5-21-3539700351-1165401899-3544196954", "distinguishedname": "DC=EZ,DC=LAB", "highvalue": true, "functionallevel": "2016"}, "Trusts": [], "Aces": [{"RightName": "Owns", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GetChanges", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-1111", "IsInherited": false, "PrincipalType": "Unknown"}, {"RightName": "GetChanges", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-498", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GetChangesAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-516", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GetChangesAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-1111", "IsInherited": false, "PrincipalType": "Unknown"}, {"RightName": "GetChanges", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GetChangesAll", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GetChanges", "PrincipalSID": "EZ.LAB-S-1-5-9", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}], "Links": [], "ChildObjects": [], "GPOChanges": {"AffectedComputers": [], "DcomUsers": [], "LocalAdmins": [], "PSRemoteUsers": [], "RemoteDesktopUsers": []}, "IsDeleted": false, "IsACLProtected": false}], "meta": {"type": "domains", "count": 1, "methods": 0, "version": 4}} -------------------------------------------------------------------------------- /tests/test_data/json_output/users_20220413_204956.json: -------------------------------------------------------------------------------- 1 | {"data": [{"ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-500", "AllowedToDelegate": [], "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-513", "Properties": {"domainsid": "S-1-5-21-3539700351-1165401899-3544196954", "name": "ADMINISTRATOR@EZ.LAB", "domain": "EZ.LAB", "distinguishedname": "CN=ADMINISTRATOR,CN=USERS,DC=EZ,DC=LAB", "admincount": true, "unconstraineddelegation": false, "passwordnotreqd": false, "enabled": true, "lastlogon": 1649638800, "lastlogontimestamp": 1648995141, "pwdlastset": 1639145510, "dontreqpreauth": false, "pwdneverexpires": true, "sensitive": false, "serviceprincipalnames": [], "description": "Built-in account for administering the computer/domain"}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}], "SPNTargets": [], "HasSIDHistory": [], "IsACLProtected": true}, {"ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-501", "AllowedToDelegate": [], "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-514", "Properties": {"domainsid": "S-1-5-21-3539700351-1165401899-3544196954", "name": "GUEST@EZ.LAB", "domain": "EZ.LAB", "distinguishedname": "CN=GUEST,CN=USERS,DC=EZ,DC=LAB", "unconstraineddelegation": false, "passwordnotreqd": true, "enabled": false, "lastlogon": 0, "pwdlastset": 0, "dontreqpreauth": false, "pwdneverexpires": true, "sensitive": false, "serviceprincipalnames": [], "description": "Built-in account for guest access to the computer/domain"}, "Aces": [{"RightName": "Owns", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "EZ.LAB-S-1-5-32-548", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}], "SPNTargets": [], "HasSIDHistory": [], "IsACLProtected": false}, {"ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-502", "AllowedToDelegate": [], "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-513", "Properties": {"domainsid": "S-1-5-21-3539700351-1165401899-3544196954", "name": "KRBTGT@EZ.LAB", "domain": "EZ.LAB", "distinguishedname": "CN=KRBTGT,CN=USERS,DC=EZ,DC=LAB", "admincount": true, "unconstraineddelegation": false, "passwordnotreqd": false, "enabled": false, "lastlogon": 0, "pwdlastset": 1630000541, "dontreqpreauth": false, "pwdneverexpires": false, "sensitive": false, "serviceprincipalnames": ["kadmin/changepw"], "hasspn": true, "description": "Key Distribution Center Service Account"}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": false, "PrincipalType": "Group"}], "SPNTargets": [], "HasSIDHistory": [], "IsACLProtected": true}, {"ObjectIdentifier": "S-1-5-21-3539700351-1165401899-3544196954-1110", "AllowedToDelegate": [], "PrimaryGroupSID": "S-1-5-21-3539700351-1165401899-3544196954-513", "Properties": {"domainsid": "S-1-5-21-3539700351-1165401899-3544196954", "name": "MATT@EZ.LAB", "domain": "EZ.LAB", "distinguishedname": "CN=MATT,CN=USERS,DC=EZ,DC=LAB", "unconstraineddelegation": false, "passwordnotreqd": false, "enabled": true, "lastlogon": 1649636900, "lastlogontimestamp": 1649082778, "pwdlastset": 1631290444, "dontreqpreauth": false, "pwdneverexpires": true, "sensitive": false, "serviceprincipalnames": [], "displayname": "Matt"}, "Aces": [{"RightName": "Owns", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-512", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "EZ.LAB-S-1-5-32-548", "IsInherited": false, "PrincipalType": "Group"}, {"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-3539700351-1165401899-3544196954-519", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "GenericWrite", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteOwner", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "AllExtendedRights", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}, {"RightName": "WriteDacl", "PrincipalSID": "EZ.LAB-S-1-5-32-544", "IsInherited": true, "PrincipalType": "Group"}], "SPNTargets": [], "HasSIDHistory": [], "IsACLProtected": false}], "meta": {"type": "users", "count": 4, "methods": 0, "version": 4}} -------------------------------------------------------------------------------- /tests/test_data/ldapsearchpy_logs/ldapsearch_20220414.log: -------------------------------------------------------------------------------- 1 | -------------------- 2 | name: {31B2F340-016D-11D2-945F-00C04FB984F9} 3 | objectGUID: 9a44995b-34f9-4252-b113-fa2b297e5b40 4 | -------------------- 5 | name: {6AC1786C-016F-11D2-945F-00C04fB984F9} 6 | objectGUID: f5a658e7-82d6-43a7-bef3-177f34c56200 7 | -------------------- 8 | name: {79081BF9-A672-4475-A982-D622CC600A49} 9 | objectGUID: 1fbda1a0-7037-4c1a-8fef-5e336dc44fe5 10 | -------------------- 11 | name: {F08300B1-8BFF-4866-8524-14C03B50D991} 12 | objectGUID: 0ccef504-dce6-47ec-8021-69bc972cff27 13 | -------------------- 14 | cn: {31B2F340-016D-11D2-945F-00C04FB984F9} 15 | dSCorePropagationData: 20210826175542.0Z 16 | displayName: Default Domain Policy 17 | distinguishedName: CN={31B2F340-016D-11D2-945F-00C04FB984F9},CN=Policies,CN=System,DC=ez,DC=lab 18 | flags: 0 19 | gPCFileSysPath: \\ez.lab\sysvol\ez.lab\Policies\{31B2F340-016D-11D2-945F-00C04FB984F9} 20 | gPCFunctionalityVersion: 2 21 | gPCMachineExtensionNames: [{35378EAC-683F-11D2-A89A-00C04FBBCFA2}{53D6AB1B-2488-11D1-A28C-00C04FB94F17}][{827D319E-6EAC-11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}][{B1BE8D72-6EAC-11D2-A4EA-00C04F79F83A}{53D6AB1B-2488-11D1-A28C-00C04FB94F17}][{F3CCC681-B74C-4060-9F26-CD84525DCA2A}{0F3F3735-573D-9804-99E4-AB2A69BA5FD4}] 22 | instanceType: 4 23 | isCriticalSystemObject: True 24 | nTSecurityDescriptor: AQAEnEgBAABkAQAAAAAAABQAAAAEADQBCgAAAAAAJAC9AA4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAKJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAAJAC9AA4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAKJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAAJAC9AA4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAKFAD/AA8AAQEAAAAAAAMAAAAAAAIUAP8ADwABAQAAAAAABRIAAAAAAhQAlAACAAEBAAAAAAAFCwAAAAUCKAAAAQAAAQAAAI/9rO2z/9ERtB0AoMlo+TkBAQAAAAAABQsAAAAAAhQAlAACAAEBAAAAAAAFCQAAAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wACAAABBQAAAAAABRUAAAB/ivvSK592RVonQNMAAgAA 25 | name: {31B2F340-016D-11D2-945F-00C04FB984F9} 26 | objectCategory: CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=ez,DC=lab 27 | objectClass: top, container, groupPolicyContainer 28 | objectGUID: 9a44995b-34f9-4252-b113-fa2b297e5b40 29 | showInAdvancedViewOnly: True 30 | systemFlags: -1946157056 31 | uSNChanged: 52468 32 | uSNCreated: 5672 33 | versionNumber: 8 34 | whenChanged: 20211213123711.0Z 35 | whenCreated: 20210826173041.0Z 36 | -------------------- 37 | cn: {6AC1786C-016F-11D2-945F-00C04fB984F9} 38 | dSCorePropagationData: 20210826175542.0Z 39 | displayName: Default Domain Controllers Policy 40 | distinguishedName: CN={6AC1786C-016F-11D2-945F-00C04fB984F9},CN=Policies,CN=System,DC=ez,DC=lab 41 | flags: 0 42 | gPCFileSysPath: \\ez.lab\sysvol\ez.lab\Policies\{6AC1786C-016F-11D2-945F-00C04fB984F9} 43 | gPCFunctionalityVersion: 2 44 | gPCMachineExtensionNames: [{827D319E-6EAC-11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}] 45 | instanceType: 4 46 | isCriticalSystemObject: True 47 | nTSecurityDescriptor: AQAEnEgBAABkAQAAAAAAABQAAAAEADQBCgAAAAAAJAC9AA4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAKJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAAJAC9AA4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAKJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAAAJAC9AA4AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAAKFAD/AA8AAQEAAAAAAAMAAAAAAAIUAP8ADwABAQAAAAAABRIAAAAAAhQAlAACAAEBAAAAAAAFCwAAAAUCKAAAAQAAAQAAAI/9rO2z/9ERtB0AoMlo+TkBAQAAAAAABQsAAAAAAhQAlAACAAEBAAAAAAAFCQAAAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wACAAABBQAAAAAABRUAAAB/ivvSK592RVonQNMAAgAA 48 | name: {6AC1786C-016F-11D2-945F-00C04fB984F9} 49 | objectCategory: CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=ez,DC=lab 50 | objectClass: top, container, groupPolicyContainer 51 | objectGUID: f5a658e7-82d6-43a7-bef3-177f34c56200 52 | showInAdvancedViewOnly: True 53 | systemFlags: -1946157056 54 | uSNChanged: 42040 55 | uSNCreated: 5675 56 | versionNumber: 7 57 | whenChanged: 20210910171933.0Z 58 | whenCreated: 20210826173041.0Z 59 | -------------------- 60 | cn: {79081BF9-A672-4475-A982-D622CC600A49} 61 | dSCorePropagationData: 20210901200136.0Z 62 | displayName: SMB File-Print and WMI 63 | distinguishedName: CN={79081BF9-A672-4475-A982-D622CC600A49},CN=Policies,CN=System,DC=ez,DC=lab 64 | flags: 0 65 | gPCFileSysPath: \\ez.lab\SysVol\ez.lab\Policies\{79081BF9-A672-4475-A982-D622CC600A49} 66 | gPCFunctionalityVersion: 2 67 | gPCMachineExtensionNames: [{35378EAC-683F-11D2-A89A-00C04FBBCFA2}{D02B1F72-3407-48AE-BA88-E8213C6761F1}] 68 | instanceType: 4 69 | nTSecurityDescriptor: AQAEnFwBAAB4AQAAAAAAABQAAAAEAEgBCgAAAAUCOAAAAQAAAQAAAI/9rO2z/9ERtB0AoMlo+TkBBQAAAAAABRUAAAB/ivvSK592RVonQNMDAgAABQIoAAABAAABAAAAj/2s7bP/0RG0HQCgyWj5OQEBAAAAAAAFCwAAAAAAJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAACJAAUAAIAAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAwIAAAACJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAACJAD/AA8AAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTBwIAAAACFACUAAIAAQEAAAAAAAUJAAAAAAIUAJQAAgABAQAAAAAABQsAAAAAAhQA/wAPAAEBAAAAAAAFEgAAAAAKFAD/AA8AAQEAAAAAAAMAAAAAAQUAAAAAAAUVAAAAf4r70iufdkVaJ0DTAAIAAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wACAAA= 70 | name: {79081BF9-A672-4475-A982-D622CC600A49} 71 | objectCategory: CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=ez,DC=lab 72 | objectClass: top, container, groupPolicyContainer 73 | objectGUID: 1fbda1a0-7037-4c1a-8fef-5e336dc44fe5 74 | showInAdvancedViewOnly: True 75 | uSNChanged: 33064 76 | uSNCreated: 32876 77 | versionNumber: 4 78 | whenChanged: 20210901200136.0Z 79 | whenCreated: 20210831134847.0Z 80 | -------------------- 81 | cn: {F08300B1-8BFF-4866-8524-14C03B50D991} 82 | dSCorePropagationData: 16010101000000.0Z 83 | displayName: Disable Defender 84 | distinguishedName: CN={F08300B1-8BFF-4866-8524-14C03B50D991},CN=Policies,CN=System,DC=ez,DC=lab 85 | flags: 0 86 | gPCFileSysPath: \\ez.lab\SysVol\ez.lab\Policies\{F08300B1-8BFF-4866-8524-14C03B50D991} 87 | gPCFunctionalityVersion: 2 88 | gPCMachineExtensionNames: [{35378EAC-683F-11D2-A89A-00C04FBBCFA2}{D02B1F72-3407-48AE-BA88-E8213C6761F1}] 89 | instanceType: 4 90 | nTSecurityDescriptor: AQAEnAABAAAcAQAAAAAAABQAAAAEAOwACAAAAAUCKAAAAQAAAQAAAI/9rO2z/9ERtB0AoMlo+TkBAQAAAAAABQsAAAAAACQA/wAPAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wACAAAAAiQA/wAPAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wACAAAAAiQA/wAPAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wcCAAAAAhQAlAACAAEBAAAAAAAFCQAAAAACFACUAAIAAQEAAAAAAAULAAAAAAIUAP8ADwABAQAAAAAABRIAAAAAChQA/wAPAAEBAAAAAAADAAAAAAEFAAAAAAAFFQAAAH+K+9Irn3ZFWidA0wACAAABBQAAAAAABRUAAAB/ivvSK592RVonQNMAAgAA 91 | name: {F08300B1-8BFF-4866-8524-14C03B50D991} 92 | objectCategory: CN=Group-Policy-Container,CN=Schema,CN=Configuration,DC=ez,DC=lab 93 | objectClass: top, container, groupPolicyContainer 94 | objectGUID: 0ccef504-dce6-47ec-8021-69bc972cff27 95 | showInAdvancedViewOnly: True 96 | uSNChanged: 42050 97 | uSNCreated: 36992 98 | versionNumber: 23 99 | whenChanged: 20210910172157.0Z 100 | whenCreated: 20210901233038.0Z 101 | -------------------- 102 | displayName: Default Domain Policy 103 | objectGUID: 9a44995b-34f9-4252-b113-fa2b297e5b40 104 | -------------------- 105 | displayName: Default Domain Controllers Policy 106 | objectGUID: f5a658e7-82d6-43a7-bef3-177f34c56200 107 | -------------------- 108 | displayName: SMB File-Print and WMI 109 | objectGUID: 1fbda1a0-7037-4c1a-8fef-5e336dc44fe5 110 | -------------------- 111 | displayName: Disable Defender 112 | objectGUID: 0ccef504-dce6-47ec-8021-69bc972cff27 113 | -------------------------------------------------------------------------------- /tests/test_data/netlocalgroupbof_logs/netlocalgroupbof_redania.log: -------------------------------------------------------------------------------- 1 | Querying Remote Desktop Users... 2 | ----------Local Group Member---------- 3 | Host: oxenfurt.redania.local 4 | Group: Remote Desktop Users 5 | Member: REDANIA\Domain Admins 6 | MemberSid: S-1-5-21-1308756548-3893869957-2915408613-512 7 | MemberSidType: Group 8 | --------End Local Group Member-------- 9 | 10 | Querying Distributed COM Users... 11 | ----------Local Group Member---------- 12 | Host: oxenfurt.redania.local 13 | Group: Distributed COM Users 14 | Member: REDANIA\geralt 15 | MemberSid: S-1-5-21-1308756548-3893869957-2915408613-1103 16 | MemberSidType: User 17 | --------End Local Group Member-------- 18 | 19 | Querying Remote Management Users... 20 | Querying Administrators... 21 | ----------Local Group Member---------- 22 | Host: oxenfurt.redania.local 23 | Group: Administrators 24 | Member: OXENFURT\Administrator 25 | MemberSid: S-1-5-21-3209726975-3062735514-2329926824-500 26 | MemberSidType: User 27 | --------End Local Group Member-------- 28 | 29 | ----------Local Group Member---------- 30 | Host: oxenfurt.redania.local 31 | Group: Administrators 32 | Member: REDANIA\Domain Admins 33 | MemberSid: S-1-5-21-1308756548-3893869957-2915408613-512 34 | MemberSidType: Group 35 | --------End Local Group Member-------- 36 | 37 | ----------Local Group Member---------- 38 | Host: oxenfurt.redania.local 39 | Group: Administrators 40 | Member: OXENFURT\localadmin 41 | MemberSid: S-1-5-21-3209726975-3062735514-2329926824-1001 42 | MemberSidType: User 43 | --------End Local Group Member-------- -------------------------------------------------------------------------------- /tests/test_data/netloggedonbof_logs/netloggedonbof_redania.log: -------------------------------------------------------------------------------- 1 | -----------Logged on User----------- 2 | Host: Oxenfurt.redania.local 3 | Username: localadmin 4 | Domain: OXENFURT 5 | Oth_domains: 6 | Logon server: OXENFURT 7 | ---------End Logged on User--------- 8 | 9 | -----------Logged on User----------- 10 | Host: Oxenfurt.redania.local 11 | Username: localadmin 12 | Domain: OXENFURT 13 | Oth_domains: 14 | Logon server: OXENFURT 15 | ---------End Logged on User--------- 16 | 17 | -----------Logged on User----------- 18 | Host: Oxenfurt.redania.local 19 | Username: OXENFURT$ 20 | Domain: REDANIA 21 | Oth_domains: 22 | Logon server: 23 | ---------End Logged on User--------- 24 | 25 | -----------Logged on User----------- 26 | Host: Oxenfurt.redania.local 27 | Username: OXENFURT$ 28 | Domain: REDANIA 29 | Oth_domains: 30 | Logon server: 31 | ---------End Logged on User--------- 32 | 33 | -----------Logged on User----------- 34 | Host: Oxenfurt.redania.local 35 | Username: OXENFURT$ 36 | Domain: REDANIA 37 | Oth_domains: 38 | Logon server: 39 | ---------End Logged on User--------- 40 | 41 | -----------Logged on User----------- 42 | Host: Oxenfurt.redania.local 43 | Username: sqlsvc 44 | Domain: REDANIA 45 | Oth_domains: 46 | Logon server: TRETOGOR 47 | ---------End Logged on User--------- 48 | 49 | -----------Logged on User----------- 50 | Host: Oxenfurt.redania.local 51 | Username: sqlsvc 52 | Domain: REDANIA 53 | Oth_domains: 54 | Logon server: TRETOGOR 55 | ---------End Logged on User--------- 56 | 57 | -----------Logged on User----------- 58 | Host: Oxenfurt.redania.local 59 | Username: sqlsvc 60 | Domain: REDANIA 61 | Oth_domains: 62 | Logon server: TRETOGOR 63 | ---------End Logged on User--------- 64 | 65 | -----------Logged on User----------- 66 | Host: Oxenfurt.redania.local 67 | Username: sqlsvc 68 | Domain: REDANIA 69 | Oth_domains: 70 | Logon server: TRETOGOR 71 | ---------End Logged on User--------- 72 | 73 | -----------Logged on User----------- 74 | Host: Oxenfurt.redania.local 75 | Username: OXENFURT$ 76 | Domain: REDANIA 77 | Oth_domains: 78 | Logon server: 79 | ---------End Logged on User--------- 80 | 81 | -----------Logged on User----------- 82 | Host: Oxenfurt.redania.local 83 | Username: OXENFURT$ 84 | Domain: REDANIA 85 | Oth_domains: 86 | Logon server: 87 | ---------End Logged on User--------- 88 | 89 | -----------Logged on User----------- 90 | Host: Oxenfurt.redania.local 91 | Username: OXENFURT$ 92 | Domain: REDANIA 93 | Oth_domains: 94 | Logon server: 95 | ---------End Logged on User--------- -------------------------------------------------------------------------------- /tests/test_data/netsessionbof_logs/netsessionbof_redania_dns.log: -------------------------------------------------------------------------------- 1 | ---------------Session-------------- 2 | Client: \\192.168.0.235 3 | PTR: tretogor.redania.local 4 | User: Administrator 5 | Active: 0 6 | Idle: 0 7 | -------------End Session------------ 8 | 9 | ---------------Session-------------- 10 | Client: \\192.168.0.235 11 | PTR: No PTR record found; reverse lookup failed 12 | User: Administrator 13 | Active: 0 14 | Idle: 0 15 | -------------End Session------------ -------------------------------------------------------------------------------- /tests/test_data/netsessionbof_logs/netsessionbof_redania_netapi.log: -------------------------------------------------------------------------------- 1 | [*] Enumerating sessions for system: oxenfurt.redania.local 2 | [*] Resolving client IPs to hostnames using NetWkstaGetInfo 3 | 4 | ---------------Session-------------- 5 | Client: \\192.168.0.235 6 | ComputerName: TRETOGOR 7 | ComputerDomain: REDANIA 8 | User: Administrator 9 | Active: 0 10 | Idle: 0 11 | -------------End Session------------ 12 | 13 | ---------------Session-------------- 14 | Client: \\192.168.0.200 15 | ComputerName: NetWkstaGetInfo Failed; 53 16 | ComputerDomain: NetWkstaGetInfo Failed; 53 17 | User: Administrator 18 | Active: 0 19 | Idle: 0 20 | -------------End Session------------ -------------------------------------------------------------------------------- /tests/test_data/regsessionbof_logs/regsessionbof_redania.log: -------------------------------------------------------------------------------- 1 | [*] Querying local registry... 2 | -----------Registry Session--------- 3 | UserSid: S-1-5-21-1308756548-3893869957-2915408613-1116 4 | Host: TRETOGOR.redania.local 5 | ---------End Registry Session------- 6 | 7 | -----------Registry Session--------- 8 | UserSid: S-1-5-21-1308756548-3893869957-2915408613-500 9 | Host: TRETOGOR.redania.local 10 | ---------End Registry Session------- 11 | 12 | [*] Found 2 sessions in the registry 13 | 14 | [*] Querying registry on oxenfurt.redania.local... 15 | -----------Registry Session--------- 16 | UserSid: S-1-5-21-1308756548-3893869957-2915408613-1116 17 | Host: oxenfurt.redania.local 18 | ---------End Registry Session------- 19 | 20 | -----------Registry Session--------- 21 | UserSid: S-1-5-21-3209726975-3062735514-2329926824-1001 22 | Host: oxenfurt.redania.local 23 | ---------End Registry Session------- 24 | 25 | [*] Found 2 sessions in the registry 26 | -------------------------------------------------------------------------------- /tests/test_localgroup_to_computer.py: -------------------------------------------------------------------------------- 1 | from tests.test_data import * 2 | 3 | EARTH_DC_SID = "S-1-5-21-3719975868-1113416855-2416171545-1000" 4 | ASGARD_WKSTN_SID = "S-1-5-21-3719975868-1113416855-2416171545-1154" 5 | 6 | 7 | def test_earth_dc_group_counts(marvel_adds): 8 | earth_dc_local_groups = marvel_adds.SID_MAP[EARTH_DC_SID].local_group_members 9 | 10 | assert len(earth_dc_local_groups) == 2 11 | assert "remote desktop users" not in earth_dc_local_groups.keys() 12 | assert "remote management users" not in earth_dc_local_groups.keys() 13 | assert len(earth_dc_local_groups["distributed com users"]) == 1 14 | assert len(earth_dc_local_groups["administrators"]) == 3 15 | 16 | 17 | def test_asgard_wrkstn_group_counts(marvel_adds): 18 | asgard_wrkstn_local_groups = marvel_adds.SID_MAP[ASGARD_WKSTN_SID].local_group_members 19 | 20 | assert len(asgard_wrkstn_local_groups) == 2 21 | assert "distributed com users" not in asgard_wrkstn_local_groups.keys() 22 | assert "remote management users" not in asgard_wrkstn_local_groups.keys() 23 | assert len(asgard_wrkstn_local_groups["remote desktop users"]) == 4 24 | assert len(asgard_wrkstn_local_groups["administrators"]) == 5 25 | -------------------------------------------------------------------------------- /tests/test_session_to_computer.py: -------------------------------------------------------------------------------- 1 | from tests.test_data import * 2 | 3 | THOR_SID = "S-1-5-21-3719975868-1113416855-2416171545-1104" 4 | EARTH_DC_SID = "S-1-5-21-3719975868-1113416855-2416171545-1000" 5 | ASGARD_WKSTN_SID = "S-1-5-21-3719975868-1113416855-2416171545-1154" 6 | 7 | 8 | def test_marvel_privileged_sessions(marvel_adds): 9 | earth_dc_priv_sessions = marvel_adds.SID_MAP[EARTH_DC_SID].privileged_sessions 10 | 11 | assert len(earth_dc_priv_sessions) == 1 12 | assert earth_dc_priv_sessions[0]["UserSID"] == THOR_SID 13 | 14 | 15 | 16 | def test_marvel_sessions(marvel_adds): 17 | asgard_wrkstn_sessions = marvel_adds.SID_MAP[ASGARD_WKSTN_SID].sessions 18 | earth_dc_sessions = marvel_adds.SID_MAP[EARTH_DC_SID].sessions 19 | 20 | assert len(earth_dc_sessions) == 0 21 | assert len(asgard_wrkstn_sessions) == 1 22 | assert asgard_wrkstn_sessions[0]["UserSID"] == THOR_SID 23 | 24 | 25 | 26 | def test_marvel_registry_sessions(marvel_adds): 27 | earth_dc_reg_sessions = marvel_adds.SID_MAP[EARTH_DC_SID].registry_sessions 28 | asgard_wrkstn_reg_sessions = marvel_adds.SID_MAP[ASGARD_WKSTN_SID].registry_sessions 29 | 30 | assert len(earth_dc_reg_sessions) == 1 31 | assert len(asgard_wrkstn_reg_sessions) == 1 32 | assert earth_dc_reg_sessions[0]["UserSID"] == THOR_SID 33 | assert asgard_wrkstn_reg_sessions[0]["UserSID"] == THOR_SID 34 | 35 | 36 | --------------------------------------------------------------------------------