├── .gitignore ├── pyldapsearch ├── __init__.py └── __main__.py ├── .github └── help.png ├── CHANGELOG.md ├── pyproject.toml ├── LICENSE ├── README.md └── poetry.lock /.gitignore: -------------------------------------------------------------------------------- 1 | logs/* 2 | venv/ 3 | *.pyc 4 | dist/* 5 | -------------------------------------------------------------------------------- /pyldapsearch/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.2' 2 | -------------------------------------------------------------------------------- /.github/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fortalice/pyldapsearch/HEAD/.github/help.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## [v0.1.2] - 9/26/2022 3 | ## Fixed 4 | - LDAPInvalidFilterError is now caught 5 | 6 | ## Changed 7 | - Improved help menus 8 | - Updated dependencies 9 | 10 | ## [v0.1.1] - 6/22/2022 11 | ## Changed 12 | - Updated dependencies 13 | 14 | ## [v0.1.0] - 5/20/2022 15 | ### Added 16 | - `-no-smb` to allow operator choice over whether an SMB connection is made to the DC to determine its hostname. If used, `-dc-ip` requires the DCs hostname to work 17 | ### Fixed 18 | - Duplicate/erroneous logging statements 19 | 20 | ## [v0.0.1] - 5/9/2022 21 | ### Added 22 | - Prepped for initial release and PyPI package 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyldapsearch" 3 | version = "0.1.2" 4 | description = "Tool for issuing manual LDAP queries which offers bofhound compatible output" 5 | authors = [ 6 | "Matt Creel", 7 | "Adam Brown" 8 | ] 9 | readme = "README.md" 10 | homepage = "https://github.com/fortalice/pyldapsearch" 11 | repository = "https://github.com/fortalice/pyldapsearch" 12 | include = ["CHANGELOG.md"] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.9" 16 | pyasn1 = "^0.4.8" 17 | typer = "^0.6.1" 18 | impacket = "^0.10.0" 19 | ldap3 = "^2.9.1" 20 | rich = "^12.5.1" 21 | 22 | [tool.poetry.dev-dependencies] 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [tool.poetry.scripts] 29 | pyldapsearch = "pyldapsearch.__main__:app" 30 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # THIS REPO IS NO LONGER ACTIVE 2 | ⛔🚧 This repo is no longer maintained. To submit an issue, pull request, or obtain the lastest version, reference [https://github.com/Tw1sm/pyldapsearch](https://github.com/Tw1sm/pyldapsearch) 🚧⛔ 3 | 4 | # pyldapsearch 5 | 6 | This is designed to be a python "port" of the ldapsearch BOF by TrustedSec, which is a part of this [repo](https://github.com/trustedsec/CS-Situational-Awareness-BOF). 7 | 8 | pyldapsearch allows you to execute LDAP queries from Linux in a fashion similar to that of the aforementioned BOF. Its output format closely mimics that of the BOF and all query output will automatically be logged to the user's home directory in `.pyldapsearch/logs`, which can ingested by [bofhound](https://github.com/fortalice/bofhound). 9 | 10 | ## Why would I ever use this? 11 | Great question. pyldapsearch was built for a scenario where the operator is utilizing Linux and is attempting to issue LDAP queries while flying under the radar (BloodHound will be too loud, expensive LDAP queries are alerted on, etc). When pyldapsearch is combined with bofhound, you can still obtain BloodHound compatible data that allows for AD visualization and identification of ACL-based attack paths, which are otherwise difficult to identify through manually querying LDAP. 12 | 13 | Outside of usage during detection-conscious and bofhound-related situations, pyldapsearch can be useful for issuing targeted, one-off LDAP queries during generic engagements. 14 | 15 | ## Installation 16 | Use `pip3` or `pipx` 17 | ``` 18 | pip3 install pyldapsearch 19 | ``` 20 | 21 | ## Usage 22 | ![](.github/help.png) 23 | 24 | ## Examples 25 | Query all the data - if you intend to do this, just run BloodHound :) 26 | ``` 27 | pyldapsearch ez.lab/administrator:pass '(objectClass=*)' 28 | ``` 29 | 30 | Query only the name, memberOf and ObjectSID of the user matt 31 | ``` 32 | pyldapsearch ez.lab/administrator:pass '(sAMAccountName=matt)' -attributes name,memberof,objectsid 33 | ``` 34 | 35 | Query all attributes for all user objects, but only return 3 results 36 | ``` 37 | pyldapsearch ez.lab/administrator:pass '(objectClass=user)' -limit 3 38 | ``` 39 | 40 | Query all attributes of the user matt, specifying the IP of the DC to query 41 | ``` 42 | pyldapsearch ez.lab/administrator:pass '(&(objectClass=user)(name=matt))' -dc-ip 10.4.2.20 43 | ``` 44 | 45 | Query all objects, specifying the search base to use 46 | ``` 47 | pyldapsearch ez.lab/administrator:pass '(objectClass=*)' -base-dn 'CN=Users,DC=EZ,DC=LAB' 48 | ``` 49 | 50 | Execute a query without displaying query results to the console (results will still be logged) 51 | ``` 52 | pyldapsearch ez.lab/administrator:pass '(objectClass=*)' -silent 53 | ``` 54 | 55 | Perform a query using an anonymous bind 56 | ``` 57 | pyldapsearch 'ez.lab'/'':'' '(objectClass=*)' 58 | ``` 59 | 60 | Perform a query across a domain trust 61 | ``` 62 | pyldapsearch ez.lab/administrator:pass '(objectClass=*)' -base-dn 'DC=otherdomain,DC=local' -dc-ip 10.1.4.20 63 | ``` 64 | 65 | ## Development 66 | pyldapsearch uses Poetry to manage dependencies. Install from source and setup for development with: 67 | ```shell 68 | git clone https://github.com/fortalice/pyldapsearch 69 | cd pyldapsearch 70 | poetry install 71 | poetry run pyldapsearch 72 | ``` 73 | 74 | ## References 75 | - ldapsearch ([CS-Situational-Awareness-BOF](https://github.com/trustedsec/cs-situational-awareness-bof)) 76 | - [ldapconsole](https://github.com/p0dalirius/ldapconsole) 77 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "cffi" 3 | version = "1.15.1" 4 | description = "Foreign Function Interface for Python calling C code." 5 | category = "main" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [package.dependencies] 10 | pycparser = "*" 11 | 12 | [[package]] 13 | name = "chardet" 14 | version = "5.0.0" 15 | description = "Universal encoding detector for Python 3" 16 | category = "main" 17 | optional = false 18 | python-versions = ">=3.6" 19 | 20 | [[package]] 21 | name = "click" 22 | version = "8.1.3" 23 | description = "Composable command line interface toolkit" 24 | category = "main" 25 | optional = false 26 | python-versions = ">=3.7" 27 | 28 | [package.dependencies] 29 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 30 | 31 | [[package]] 32 | name = "colorama" 33 | version = "0.4.5" 34 | description = "Cross-platform colored terminal text." 35 | category = "main" 36 | optional = false 37 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 38 | 39 | [[package]] 40 | name = "commonmark" 41 | version = "0.9.1" 42 | description = "Python parser for the CommonMark Markdown spec" 43 | category = "main" 44 | optional = false 45 | python-versions = "*" 46 | 47 | [package.extras] 48 | test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] 49 | 50 | [[package]] 51 | name = "cryptography" 52 | version = "38.0.1" 53 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 54 | category = "main" 55 | optional = false 56 | python-versions = ">=3.6" 57 | 58 | [package.dependencies] 59 | cffi = ">=1.12" 60 | 61 | [package.extras] 62 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] 63 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 64 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] 65 | sdist = ["setuptools-rust (>=0.11.4)"] 66 | ssh = ["bcrypt (>=3.1.5)"] 67 | test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] 68 | 69 | [[package]] 70 | name = "dnspython" 71 | version = "2.2.1" 72 | description = "DNS toolkit" 73 | category = "main" 74 | optional = false 75 | python-versions = ">=3.6,<4.0" 76 | 77 | [package.extras] 78 | dnssec = ["cryptography (>=2.6,<37.0)"] 79 | curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] 80 | doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] 81 | idna = ["idna (>=2.1,<4.0)"] 82 | trio = ["trio (>=0.14,<0.20)"] 83 | wmi = ["wmi (>=1.5.1,<2.0.0)"] 84 | 85 | [[package]] 86 | name = "flask" 87 | version = "2.2.2" 88 | description = "A simple framework for building complex web applications." 89 | category = "main" 90 | optional = false 91 | python-versions = ">=3.7" 92 | 93 | [package.dependencies] 94 | click = ">=8.0" 95 | importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} 96 | itsdangerous = ">=2.0" 97 | Jinja2 = ">=3.0" 98 | Werkzeug = ">=2.2.2" 99 | 100 | [package.extras] 101 | async = ["asgiref (>=3.2)"] 102 | dotenv = ["python-dotenv"] 103 | 104 | [[package]] 105 | name = "future" 106 | version = "0.18.2" 107 | description = "Clean single-source support for Python 3 and 2" 108 | category = "main" 109 | optional = false 110 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 111 | 112 | [[package]] 113 | name = "impacket" 114 | version = "0.10.0" 115 | description = "Network protocols Constructors and Dissectors" 116 | category = "main" 117 | optional = false 118 | python-versions = "*" 119 | 120 | [package.dependencies] 121 | chardet = "*" 122 | flask = ">=1.0" 123 | future = "*" 124 | ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6" 125 | ldapdomaindump = ">=0.9.0" 126 | pyasn1 = ">=0.2.3" 127 | pycryptodomex = "*" 128 | pyOpenSSL = ">=0.16.2" 129 | six = "*" 130 | 131 | [[package]] 132 | name = "importlib-metadata" 133 | version = "4.12.0" 134 | description = "Read metadata from Python packages" 135 | category = "main" 136 | optional = false 137 | python-versions = ">=3.7" 138 | 139 | [package.dependencies] 140 | zipp = ">=0.5" 141 | 142 | [package.extras] 143 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 144 | perf = ["ipython"] 145 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 146 | 147 | [[package]] 148 | name = "itsdangerous" 149 | version = "2.1.2" 150 | description = "Safely pass data to untrusted environments and back." 151 | category = "main" 152 | optional = false 153 | python-versions = ">=3.7" 154 | 155 | [[package]] 156 | name = "jinja2" 157 | version = "3.1.2" 158 | description = "A very fast and expressive template engine." 159 | category = "main" 160 | optional = false 161 | python-versions = ">=3.7" 162 | 163 | [package.dependencies] 164 | MarkupSafe = ">=2.0" 165 | 166 | [package.extras] 167 | i18n = ["Babel (>=2.7)"] 168 | 169 | [[package]] 170 | name = "ldap3" 171 | version = "2.9.1" 172 | description = "A strictly RFC 4510 conforming LDAP V3 pure Python client library" 173 | category = "main" 174 | optional = false 175 | python-versions = "*" 176 | 177 | [package.dependencies] 178 | pyasn1 = ">=0.4.6" 179 | 180 | [[package]] 181 | name = "ldapdomaindump" 182 | version = "0.9.3" 183 | description = "Active Directory information dumper via LDAP" 184 | category = "main" 185 | optional = false 186 | python-versions = "*" 187 | 188 | [package.dependencies] 189 | dnspython = "*" 190 | future = "*" 191 | ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6" 192 | 193 | [[package]] 194 | name = "markupsafe" 195 | version = "2.1.1" 196 | description = "Safely add untrusted strings to HTML/XML markup." 197 | category = "main" 198 | optional = false 199 | python-versions = ">=3.7" 200 | 201 | [[package]] 202 | name = "pyasn1" 203 | version = "0.4.8" 204 | description = "ASN.1 types and codecs" 205 | category = "main" 206 | optional = false 207 | python-versions = "*" 208 | 209 | [[package]] 210 | name = "pycparser" 211 | version = "2.21" 212 | description = "C parser in Python" 213 | category = "main" 214 | optional = false 215 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 216 | 217 | [[package]] 218 | name = "pycryptodomex" 219 | version = "3.15.0" 220 | description = "Cryptographic library for Python" 221 | category = "main" 222 | optional = false 223 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 224 | 225 | [[package]] 226 | name = "pygments" 227 | version = "2.13.0" 228 | description = "Pygments is a syntax highlighting package written in Python." 229 | category = "main" 230 | optional = false 231 | python-versions = ">=3.6" 232 | 233 | [package.extras] 234 | plugins = ["importlib-metadata"] 235 | 236 | [[package]] 237 | name = "pyopenssl" 238 | version = "22.0.0" 239 | description = "Python wrapper module around the OpenSSL library" 240 | category = "main" 241 | optional = false 242 | python-versions = ">=3.6" 243 | 244 | [package.dependencies] 245 | cryptography = ">=35.0" 246 | 247 | [package.extras] 248 | docs = ["sphinx", "sphinx-rtd-theme"] 249 | test = ["flaky", "pretend", "pytest (>=3.0.1)"] 250 | 251 | [[package]] 252 | name = "rich" 253 | version = "12.5.1" 254 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 255 | category = "main" 256 | optional = false 257 | python-versions = ">=3.6.3,<4.0.0" 258 | 259 | [package.dependencies] 260 | commonmark = ">=0.9.0,<0.10.0" 261 | pygments = ">=2.6.0,<3.0.0" 262 | 263 | [package.extras] 264 | jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] 265 | 266 | [[package]] 267 | name = "six" 268 | version = "1.16.0" 269 | description = "Python 2 and 3 compatibility utilities" 270 | category = "main" 271 | optional = false 272 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 273 | 274 | [[package]] 275 | name = "typer" 276 | version = "0.6.1" 277 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 278 | category = "main" 279 | optional = false 280 | python-versions = ">=3.6" 281 | 282 | [package.dependencies] 283 | click = ">=7.1.1,<9.0.0" 284 | 285 | [package.extras] 286 | test = ["rich (>=10.11.0,<13.0.0)", "isort (>=5.0.6,<6.0.0)", "black (>=22.3.0,<23.0.0)", "mypy (==0.910)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "coverage (>=5.2,<6.0)", "pytest-cov (>=2.10.0,<3.0.0)", "pytest (>=4.4.0,<5.4.0)", "shellingham (>=1.3.0,<2.0.0)"] 287 | doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mkdocs (>=1.1.2,<2.0.0)"] 288 | dev = ["pre-commit (>=2.17.0,<3.0.0)", "flake8 (>=3.8.3,<4.0.0)", "autoflake (>=1.3.1,<2.0.0)"] 289 | all = ["rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)", "colorama (>=0.4.3,<0.5.0)"] 290 | 291 | [[package]] 292 | name = "werkzeug" 293 | version = "2.2.2" 294 | description = "The comprehensive WSGI web application library." 295 | category = "main" 296 | optional = false 297 | python-versions = ">=3.7" 298 | 299 | [package.dependencies] 300 | MarkupSafe = ">=2.1.1" 301 | 302 | [package.extras] 303 | watchdog = ["watchdog"] 304 | 305 | [[package]] 306 | name = "zipp" 307 | version = "3.8.1" 308 | description = "Backport of pathlib-compatible object wrapper for zip files" 309 | category = "main" 310 | optional = false 311 | python-versions = ">=3.7" 312 | 313 | [package.extras] 314 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 315 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 316 | 317 | [metadata] 318 | lock-version = "1.1" 319 | python-versions = "^3.9" 320 | content-hash = "b925d924ca13901b891d79a83ad903511df3ae5b631045f708dc4b1e561a5ea4" 321 | 322 | [metadata.files] 323 | cffi = [] 324 | chardet = [] 325 | click = [ 326 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 327 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 328 | ] 329 | colorama = [ 330 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 331 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 332 | ] 333 | commonmark = [ 334 | {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, 335 | {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, 336 | ] 337 | cryptography = [] 338 | dnspython = [ 339 | {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"}, 340 | {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"}, 341 | ] 342 | flask = [] 343 | future = [ 344 | {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, 345 | ] 346 | impacket = [ 347 | {file = "impacket-0.10.0.tar.gz", hash = "sha256:b8eb020a2cbb47146669cfe31c64bb2e7d6499d049c493d6418b9716f5c74583"}, 348 | ] 349 | importlib-metadata = [] 350 | itsdangerous = [ 351 | {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, 352 | {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, 353 | ] 354 | jinja2 = [ 355 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 356 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 357 | ] 358 | ldap3 = [ 359 | {file = "ldap3-2.9.1-py2.6.egg", hash = "sha256:5ab7febc00689181375de40c396dcad4f2659cd260fc5e94c508b6d77c17e9d5"}, 360 | {file = "ldap3-2.9.1-py2.7.egg", hash = "sha256:2bc966556fc4d4fa9f445a1c31dc484ee81d44a51ab0e2d0fd05b62cac75daa6"}, 361 | {file = "ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70"}, 362 | {file = "ldap3-2.9.1-py3.9.egg", hash = "sha256:5630d1383e09ba94839e253e013f1aa1a2cf7a547628ba1265cb7b9a844b5687"}, 363 | {file = "ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"}, 364 | ] 365 | ldapdomaindump = [ 366 | {file = "ldapdomaindump-0.9.3-py2-none-any.whl", hash = "sha256:4cb2831d9cc920b93f669946649dbc55fe85ba7fdc1461d1f3394094016dad31"}, 367 | {file = "ldapdomaindump-0.9.3-py3-none-any.whl", hash = "sha256:72731b83ae33b36a0599e2e7b52f0464408032bd37211ffc76b924fc79ff9834"}, 368 | {file = "ldapdomaindump-0.9.3.tar.gz", hash = "sha256:ec293973209302eb6d925c3cde6b10693c15443933d1884bc4495d4a19d29181"}, 369 | ] 370 | markupsafe = [ 371 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, 372 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, 373 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, 374 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, 375 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, 376 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, 377 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, 378 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, 379 | {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, 380 | {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, 381 | {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, 382 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, 383 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, 384 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, 385 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, 386 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, 387 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, 388 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, 389 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, 390 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, 391 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, 392 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, 393 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, 394 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, 395 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, 396 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, 397 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, 398 | {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, 399 | {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, 400 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, 401 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, 402 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, 403 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, 404 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, 405 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, 406 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, 407 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, 408 | {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, 409 | {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, 410 | {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, 411 | ] 412 | pyasn1 = [ 413 | {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, 414 | {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, 415 | {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, 416 | {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, 417 | {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, 418 | {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, 419 | {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, 420 | {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, 421 | {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, 422 | {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, 423 | {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, 424 | {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, 425 | {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, 426 | ] 427 | pycparser = [ 428 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 429 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 430 | ] 431 | pycryptodomex = [] 432 | pygments = [] 433 | pyopenssl = [ 434 | {file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"}, 435 | {file = "pyOpenSSL-22.0.0.tar.gz", hash = "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf"}, 436 | ] 437 | rich = [] 438 | six = [ 439 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 440 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 441 | ] 442 | typer = [] 443 | werkzeug = [] 444 | zipp = [] 445 | -------------------------------------------------------------------------------- /pyldapsearch/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from ldap3.protocol.formatters.formatters import format_sid 4 | from ldap3.protocol.formatters.formatters import format_uuid_le 5 | from impacket.smbconnection import SMBConnection 6 | from impacket.spnego import SPNEGO_NegTokenInit, TypesMech 7 | from impacket.examples.utils import parse_credentials 8 | from impacket.examples import logger 9 | from impacket import version 10 | from pyldapsearch import __version__ 11 | from binascii import unhexlify 12 | from ldap3 import ANONYMOUS 13 | import base64 14 | import logging 15 | import time 16 | import ldap3 17 | import json 18 | import ssl 19 | import os 20 | import typer 21 | 22 | 23 | def get_dn(domain): 24 | components = domain.split('.') 25 | base = '' 26 | for comp in components: 27 | base += f',DC={comp}' 28 | 29 | return base[1:] 30 | 31 | 32 | def get_machine_name(domain_controller, domain): 33 | if domain_controller is not None: 34 | s = SMBConnection(domain_controller, domain_controller) 35 | else: 36 | s = SMBConnection(domain, domain) 37 | try: 38 | s.login('', '') 39 | except Exception: 40 | if s.getServerName() == '': 41 | raise Exception('Error while anonymous logging into %s' % domain) 42 | else: 43 | s.logoff() 44 | return s.getServerName() 45 | 46 | 47 | def init_ldap_connection(target, tls_version, domain, username, password, lmhash, nthash, domain_controller, kerberos, hashes, aesKey): 48 | user = '%s\\%s' % (domain, username) 49 | if tls_version is not None: 50 | use_ssl = True 51 | port = 636 52 | tls = ldap3.Tls(validate=ssl.CERT_NONE, version=tls_version) 53 | else: 54 | use_ssl = False 55 | port = 389 56 | tls = None 57 | logging.info(f'Binding to {target}') 58 | ldap_server = ldap3.Server(target, get_info=ldap3.ALL, port=port, use_ssl=use_ssl, tls=tls) 59 | if kerberos: 60 | ldap_session = ldap3.Connection(ldap_server) 61 | ldap_session.bind() 62 | ldap3_kerberos_login(ldap_session, target, username, password, domain, lmhash, nthash, aesKey, kdcHost=domain_controller) 63 | elif hashes is not None: 64 | if lmhash == "": 65 | lmhash = "aad3b435b51404eeaad3b435b51404ee" 66 | ldap_session = ldap3.Connection(ldap_server, user=user, password=lmhash + ":" + nthash, authentication=ldap3.NTLM, auto_bind=True) 67 | elif username == '' and password == '': 68 | logging.debug('Performing anonymous bind') 69 | ldap_session = ldap3.Connection(ldap_server, authentication=ANONYMOUS, auto_bind=True) 70 | else: 71 | ldap_session = ldap3.Connection(ldap_server, user=user, password=password, authentication=ldap3.NTLM, auto_bind=True) 72 | 73 | return ldap_server, ldap_session 74 | 75 | 76 | def init_ldap_session(domain, username, password, lmhash, nthash, kerberos, domain_controller, ldaps, hashes, aesKey, no_smb): 77 | if kerberos: 78 | if no_smb: 79 | logging.debug(f'Setting connection target to {domain_controller} without SMB connection') 80 | target = domain_controller 81 | else: 82 | target = get_machine_name(domain_controller, domain) 83 | else: 84 | if domain_controller is not None: 85 | target = domain_controller 86 | else: 87 | target = domain 88 | 89 | if ldaps is True: 90 | logging.debug('Targeting LDAPS') 91 | try: 92 | return init_ldap_connection(target, ssl.PROTOCOL_TLSv1_2, domain, username, password, lmhash, nthash, domain_controller, kerberos, hashes, aesKey) 93 | except ldap3.core.exceptions.LDAPSocketOpenError: 94 | return init_ldap_connection(target, ssl.PROTOCOL_TLSv1, domain, username, password, lmhash, nthash, domain_controller, kerberos, hashes, aesKey) 95 | else: 96 | return init_ldap_connection(target, None, domain, username, password, lmhash, nthash, domain_controller, kerberos, hashes, aesKey) 97 | 98 | 99 | def ldap3_kerberos_login(connection, target, user, password, domain='', lmhash='', nthash='', aesKey='', kdcHost=None, TGT=None, TGS=None, useCache=True): 100 | from pyasn1.codec.ber import encoder, decoder 101 | from pyasn1.type.univ import noValue 102 | """ 103 | logins into the target system explicitly using Kerberos. Hashes are used if RC4_HMAC is supported. 104 | :param string user: username 105 | :param string password: password for the user 106 | :param string domain: domain where the account is valid for (required) 107 | :param string lmhash: LMHASH used to authenticate using hashes (password is not used) 108 | :param string nthash: NTHASH used to authenticate using hashes (password is not used) 109 | :param string aesKey: aes256-cts-hmac-sha1-96 or aes128-cts-hmac-sha1-96 used for Kerberos authentication 110 | :param string kdcHost: hostname or IP Address for the KDC. If None, the domain will be used (it needs to resolve tho) 111 | :param struct TGT: If there's a TGT available, send the structure here and it will be used 112 | :param struct TGS: same for TGS. See smb3.py for the format 113 | :param bool useCache: whether or not we should use the ccache for credentials lookup. If TGT or TGS are specified this is False 114 | :return: True, raises an Exception if error. 115 | """ 116 | 117 | if lmhash != '' or nthash != '': 118 | if len(lmhash) % 2: 119 | lmhash = '0' + lmhash 120 | if len(nthash) % 2: 121 | nthash = '0' + nthash 122 | try: # just in case they were converted already 123 | lmhash = unhexlify(lmhash) 124 | nthash = unhexlify(nthash) 125 | except TypeError: 126 | pass 127 | 128 | # Importing down here so pyasn1 is not required if kerberos is not used. 129 | from impacket.krb5.ccache import CCache 130 | from impacket.krb5.asn1 import AP_REQ, Authenticator, TGS_REP, seq_set 131 | from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS 132 | from impacket.krb5 import constants 133 | from impacket.krb5.types import Principal, KerberosTime, Ticket 134 | import datetime 135 | 136 | if TGT is not None or TGS is not None: 137 | useCache = False 138 | 139 | if useCache: 140 | try: 141 | ccache = CCache.loadFile(os.getenv('KRB5CCNAME')) 142 | except Exception as e: 143 | # No cache present 144 | print(e) 145 | pass 146 | else: 147 | # retrieve domain information from CCache file if needed 148 | if domain == '': 149 | domain = ccache.principal.realm['data'].decode('utf-8') 150 | logging.debug('Domain retrieved from CCache: %s' % domain) 151 | 152 | logging.debug('Using Kerberos Cache: %s' % os.getenv('KRB5CCNAME')) 153 | principal = 'ldap/%s@%s' % (target.upper(), domain.upper()) 154 | 155 | creds = ccache.getCredential(principal) 156 | if creds is None: 157 | # Let's try for the TGT and go from there 158 | principal = 'krbtgt/%s@%s' % (domain.upper(), domain.upper()) 159 | creds = ccache.getCredential(principal) 160 | if creds is not None: 161 | TGT = creds.toTGT() 162 | logging.debug('Using TGT from cache') 163 | else: 164 | logging.debug('No valid credentials found in cache') 165 | else: 166 | TGS = creds.toTGS(principal) 167 | logging.debug('Using TGS from cache') 168 | 169 | # retrieve user information from CCache file if needed 170 | if user == '' and creds is not None: 171 | user = creds['client'].prettyPrint().split(b'@')[0].decode('utf-8') 172 | logging.debug('Username retrieved from CCache: %s' % user) 173 | elif user == '' and len(ccache.principal.components) > 0: 174 | user = ccache.principal.components[0]['data'].decode('utf-8') 175 | logging.debug('Username retrieved from CCache: %s' % user) 176 | 177 | # First of all, we need to get a TGT for the user 178 | userName = Principal(user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) 179 | if TGT is None: 180 | if TGS is None: 181 | tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(userName, password, domain, lmhash, nthash, aesKey, kdcHost) 182 | else: 183 | tgt = TGT['KDC_REP'] 184 | cipher = TGT['cipher'] 185 | sessionKey = TGT['sessionKey'] 186 | 187 | if TGS is None: 188 | serverName = Principal('ldap/%s' % target, type=constants.PrincipalNameType.NT_SRV_INST.value) 189 | tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, sessionKey) 190 | else: 191 | tgs = TGS['KDC_REP'] 192 | cipher = TGS['cipher'] 193 | sessionKey = TGS['sessionKey'] 194 | 195 | # Let's build a NegTokenInit with a Kerberos REQ_AP 196 | 197 | blob = SPNEGO_NegTokenInit() 198 | 199 | # Kerberos 200 | blob['MechTypes'] = [TypesMech['MS KRB5 - Microsoft Kerberos 5']] 201 | 202 | # Let's extract the ticket from the TGS 203 | tgs = decoder.decode(tgs, asn1Spec=TGS_REP())[0] 204 | ticket = Ticket() 205 | ticket.from_asn1(tgs['ticket']) 206 | 207 | # Now let's build the AP_REQ 208 | apReq = AP_REQ() 209 | apReq['pvno'] = 5 210 | apReq['msg-type'] = int(constants.ApplicationTagNumbers.AP_REQ.value) 211 | 212 | opts = [] 213 | apReq['ap-options'] = constants.encodeFlags(opts) 214 | seq_set(apReq, 'ticket', ticket.to_asn1) 215 | 216 | authenticator = Authenticator() 217 | authenticator['authenticator-vno'] = 5 218 | authenticator['crealm'] = domain 219 | seq_set(authenticator, 'cname', userName.components_to_asn1) 220 | now = datetime.datetime.utcnow() 221 | 222 | authenticator['cusec'] = now.microsecond 223 | authenticator['ctime'] = KerberosTime.to_asn1(now) 224 | 225 | encodedAuthenticator = encoder.encode(authenticator) 226 | 227 | # Key Usage 11 228 | # AP-REQ Authenticator (includes application authenticator 229 | # subkey), encrypted with the application session key 230 | # (Section 5.5.1) 231 | encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 11, encodedAuthenticator, None) 232 | 233 | apReq['authenticator'] = noValue 234 | apReq['authenticator']['etype'] = cipher.enctype 235 | apReq['authenticator']['cipher'] = encryptedEncodedAuthenticator 236 | 237 | blob['MechToken'] = encoder.encode(apReq) 238 | 239 | request = ldap3.operation.bind.bind_operation(connection.version, ldap3.SASL, user, None, 'GSS-SPNEGO', 240 | blob.getData()) 241 | 242 | # Done with the Kerberos saga, now let's get into LDAP 243 | if connection.closed: # try to open connection if closed 244 | connection.open(read_server_info=False) 245 | 246 | connection.sasl_in_progress = True 247 | response = connection.post_send_single_response(connection.send('bindRequest', request, None)) 248 | connection.sasl_in_progress = False 249 | if response[0]['result'] != 0: 250 | raise Exception(response) 251 | 252 | connection.bound = True 253 | 254 | return True 255 | 256 | 257 | class Ldapsearch: 258 | _separator = '--------------------' 259 | # bofhound expects some attributes in a certain format 260 | _base64_attributes = ['nTSecurityDescriptor', 'msDS-GenerationId', 'auditingPolicy', 'dSASignature', 'mS-DS-CreatorSID', 261 | 'logonHours', 'schemaIDGUID'] 262 | _raw_attributes = ['whenCreated', 'whenChanged', 'dSCorePropagationData', 'accountExpires', 'badPasswordTime', 'pwdLastSet', 263 | 'lastLogonTimestamp', 'lastLogon', 'lastLogoff', 'maxPwdAge', 'minPwdAge', 'creationTime', 'lockOutObservationWindow', 264 | 'lockoutDuration'] 265 | _bracketed_attributes = ['objectGUID'] 266 | _ignore_attributes = ['userCertificate'] 267 | 268 | 269 | def __init__(self, ldap_server, ldap_session, query_string, attributes, result_count, search_base, no_query_sd, logs_dir, silent): 270 | self.ldap_server = ldap_server 271 | self.ldap_session = ldap_session 272 | self.query_string = query_string 273 | self.result_count = result_count 274 | self.search_base = search_base 275 | self.no_query_sd = no_query_sd 276 | self.logs_dir = logs_dir 277 | self.silent = silent 278 | 279 | logging.info(f'Distinguished name: {self.search_base}') 280 | logging.info(f'Filter: {self.query_string}') 281 | 282 | if attributes == '': 283 | if no_query_sd: 284 | self.attributes = ['*'] 285 | else: 286 | self.attributes = ['*', 'ntsecuritydescriptor'] 287 | else: 288 | self.attributes = [attr.lower() for attr in attributes.split(',')] 289 | logging.info(f'Returning specific attributes(s): {attributes}') 290 | 291 | self._prep_log() 292 | 293 | 294 | def _prep_log(self): 295 | ts = time.strftime('%Y%m%d') 296 | self.filename = f'{self.logs_dir}/pyldapsearch_{ts}.log' 297 | 298 | 299 | def _printlog(self, line, log=False): 300 | with open(self.filename, 'a') as f: 301 | f.write(f'{line}\n') 302 | if log: 303 | logging.info(line) 304 | else: 305 | if not self.silent: 306 | print(line) 307 | 308 | 309 | def query(self): 310 | try: 311 | if 'ntsecuritydescriptor' in self.attributes: 312 | controls = ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x07) 313 | self.ldap_session.extend.standard.paged_search(self.search_base, self.query_string, attributes=self.attributes, size_limit=self.result_count, controls=controls, paged_size=500, generator=False) 314 | else: 315 | self.ldap_session.extend.standard.paged_search(self.search_base, self.query_string, attributes=self.attributes, size_limit=self.result_count, paged_size=500, generator=False) 316 | except (ldap3.core.exceptions.LDAPAttributeError, ldap3.core.exceptions.LDAPInvalidFilterError) as e: 317 | print() 318 | logging.critical(f'Error: {str(e)}') 319 | exit() 320 | 321 | for entry in self.ldap_session.entries: 322 | self._printlog(self._separator) 323 | json_entry = json.loads(entry.entry_to_json()) 324 | attributes = json_entry['attributes'].keys() 325 | for attr in attributes: 326 | try: 327 | value = self._get_formatted_value(entry, attr) 328 | except: 329 | value = None 330 | logging.debug(f'Error formatting value of attribute {attr}: {entry[attr].value}') 331 | if value is not None: 332 | self._printlog(f'{attr}: {value}') 333 | print() 334 | self._printlog(f'Retrieved {len(self.ldap_session.entries)} results total', log=True) 335 | logging.debug(f'Results written to {self.filename}') 336 | 337 | 338 | def _get_formatted_value(self, entry, attr): 339 | if attr in self._ignore_attributes: 340 | return None 341 | 342 | # sid encoding can be funny, use ldap3 func to handle and return 343 | if attr == 'objectSid': 344 | return format_sid(entry[attr][0]) 345 | 346 | if attr in self._raw_attributes: 347 | val = entry[attr].raw_values[0].decode('utf-8') 348 | elif type(entry[attr].value) is list: 349 | if type(entry[attr].value[0]) is bytes: 350 | strings = [val.decode('utf-8') for val in entry[attr].value] 351 | val = ', '.join(strings) 352 | else: 353 | val = ', '.join(entry[attr].value) 354 | elif attr in self._base64_attributes: 355 | val = base64.b64encode(entry[attr].value).decode('utf-8') 356 | elif attr in self._bracketed_attributes: 357 | if attr == 'objectGUID': 358 | val = format_uuid_le(entry[attr].value)[1:-1] 359 | else: 360 | val = entry[attr].value[1:-1] 361 | else: 362 | val = entry[attr].value 363 | 364 | if type(val) is bytes: 365 | try: 366 | val = val.decode('utf-8') 367 | except UnicodeDecodeError as e: 368 | logging.debug(f'Unable to decode {attr} as utf-8') 369 | raise(UnicodeDecodeError) 370 | 371 | 372 | return val 373 | 374 | app = typer.Typer(add_completion=False) 375 | 376 | @app.command(no_args_is_help=True) 377 | def main( 378 | target: str = typer.Argument(..., help='[[domain/]username[:password]'), 379 | filter: str = typer.Argument(..., help='LDAP filter string'), 380 | attributes: str = typer.Option('', '-attributes', help='Comma separated list of attributes', rich_help_panel='Search Options'), 381 | result_count: int = typer.Option(0, '-limit', help='Limit the number of results to return', rich_help_panel='Search Options'), 382 | domain_controller: str = typer.Option('', '-dc-ip', help='Domain controller IP or hostname to query', rich_help_panel='Connection Options'), 383 | distinguished_name: str = typer.Option('', '-base-dn', help='Search base distinguished name to use. Default is base domain level', rich_help_panel='Search Options'), 384 | no_sd: bool = typer.Option(False, '-no-sd', help='Do not add nTSecurityDescriptor as an attribute queried by default. Reduces console output significantly', rich_help_panel='Search Options'), 385 | debug: bool = typer.Option(False, '-debug', help='Turn DEBUG output ON'), 386 | hashes: str = typer.Option(None, '-hashes', metavar="LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH', rich_help_panel='Connection Options'), 387 | no_pass: bool = typer.Option(False, '-no-pass', help='Don\'t ask for password (useful for -k)', rich_help_panel='Connection Options',), 388 | kerberos: bool = typer.Option(False, '-k', help='Use Kerberos authentication. Grabs credentials from ccache file ' 389 | '(KRB5CCNAME) based on target parameters. If valid credentials ' 390 | 'cannot be found, it will use the ones specified in the command ' 391 | 'line', 392 | rich_help_panel='Connection Options'), 393 | aesKey: str = typer.Option(None, '-aesKey', help='AES key to use for Kerberos Authentication (128 or 256 bits)', rich_help_panel='Connection Options'), 394 | ldaps: bool = typer.Option(False, '-ldaps', help='Use LDAPS instead of LDAP', rich_help_panel='Connection Options',), 395 | no_smb: bool = typer.Option(False, '-no-smb', help='Do not make a SMB connection to the DC to get its hostname (useful for -k). ' 396 | 'Requires a hostname to be provided with -dc-ip', 397 | rich_help_panel='Connection Options'), 398 | silent: bool = typer.Option(False, '-silent', help='Do not print query results to console (results will still be logged)', rich_help_panel='Search Options')): 399 | ''' 400 | Tool for issuing manual LDAP queries which offers bofhound compatible output 401 | ''' 402 | 403 | print(version.BANNER) 404 | logger.init() 405 | 406 | logging.info(f'pyldapsearch v{__version__} - Fortalice ✪\n') 407 | 408 | domain, username, password = parse_credentials(target) 409 | 410 | if debug: 411 | logging.getLogger().setLevel(logging.DEBUG) 412 | logging.debug(version.getInstallationPath()) 413 | else: 414 | logging.getLogger().setLevel(logging.INFO) 415 | 416 | # check for first time usage 417 | home = os.path.expanduser('~') 418 | pyldapsearch_dir = f'{home}/.pyldapsearch' 419 | logs_dir = f'{pyldapsearch_dir}/logs' 420 | 421 | if not os.path.isdir(pyldapsearch_dir): 422 | logging.info('First time usage detected') 423 | logging.info(f'pyldapsearch output will be logged to {logs_dir}') 424 | os.mkdir(pyldapsearch_dir) 425 | print() 426 | 427 | if not os.path.isdir(logs_dir): 428 | os.mkdir(logs_dir) 429 | 430 | if password == '' and username != '' and hashes is None and no_pass is False and aesKey is None: 431 | from getpass import getpass 432 | password = getpass('Password:') 433 | 434 | lm_hash = "" 435 | nt_hash = "" 436 | if hashes is not None: 437 | if ":" in hashes: 438 | lm_hash = hashes.split(":")[0] 439 | nt_hash = hashes.split(":")[1] 440 | else: 441 | nt_hash = hashes 442 | 443 | if distinguished_name == '': 444 | search_base = get_dn(domain) 445 | else: 446 | search_base = distinguished_name.upper() 447 | 448 | if domain_controller == "": 449 | domain_controller = domain 450 | 451 | ldap_server = '' 452 | ldap_session = '' 453 | ldapsearch = '' 454 | try: 455 | ldap_server, ldap_session = init_ldap_session(domain=domain, username=username, password=password, lmhash=lm_hash, 456 | nthash=nt_hash, kerberos=kerberos, domain_controller=domain_controller, 457 | ldaps=ldaps, hashes=hashes, aesKey=aesKey, no_smb=no_smb) 458 | ldapsearch = Ldapsearch(ldap_server, ldap_session, filter, attributes, result_count, search_base, no_sd, logs_dir, silent) 459 | logging.debug('LDAP bind successful') 460 | except ldap3.core.exceptions.LDAPSocketOpenError as e: 461 | if 'invalid server address' in str(e): 462 | logging.critical(f'Invalid server address - {domain_controller}') 463 | else: 464 | logging.critical('Error connecting to LDAP server') 465 | print() 466 | print(e) 467 | exit() 468 | except ldap3.core.exceptions.LDAPBindError as e: 469 | logging.critical(f'Error: {str(e)}') 470 | exit() 471 | 472 | ldapsearch.query() 473 | 474 | if __name__ == '__main__': 475 | app(prog_name='pyldapsearch') 476 | --------------------------------------------------------------------------------