├── tests ├── __init__.py ├── test_import.py ├── test_args.py ├── test_config.py ├── test_breakers.py ├── conftest.py └── test_db_manager.py ├── docs ├── pkg-docs │ ├── logging.md │ ├── brute.md │ ├── args.md │ ├── models.md │ ├── db_manager.md │ └── sql.md ├── reference.md ├── index.md ├── package-documentation.md ├── reference │ ├── attack_management.md │ ├── breakers.md │ ├── db_management.md │ └── jitter.md ├── complete_examples.md └── concepts.md ├── docs-requirements.txt ├── docker ├── entrypoint.sh ├── run_tests.sh └── README.md ├── bruteloops ├── __init__.py ├── enums.py ├── jitter.py ├── queries.py ├── brute_time.py ├── errors.py ├── logging.py ├── sql.py ├── args.py ├── models.py ├── brute.py └── db_manager.py ├── mkdocs.yml ├── .kbmeta.yml ├── setup.py ├── LICENSE ├── .github └── workflows │ └── python-publish.yml ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/pkg-docs/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | ::: bruteloops.logging 4 | -------------------------------------------------------------------------------- /docs-requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocstrings[python] 3 | mkdocs-material 4 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | 2 | def test_imports(): 3 | import bruteloops 4 | -------------------------------------------------------------------------------- /docs/pkg-docs/brute.md: -------------------------------------------------------------------------------- 1 | # Brute 2 | 3 | 4 | --- 5 | 6 | ::: bruteloops.brute 7 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pip3 install pytest bruteloops/ 4 | pytest bruteloops/tests/ 5 | -------------------------------------------------------------------------------- /docker/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ../../ 4 | docker run --rm -it -w /opt \ 5 | -v $PWD/bruteloops/:/opt/bruteloops \ 6 | -v $PWD/bruteloops/docker/entrypoint.sh:/sbin/run_tests \ 7 | python:3.9 run_tests 8 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | This directory contains a quick script to run pytest Docker container. 2 | 3 | Assuming you're running a Debian-like Linux host, the following should 4 | start the tests: 5 | 6 | ```bash 7 | ./run_tests.sh 8 | ``` 9 | -------------------------------------------------------------------------------- /bruteloops/__init__.py: -------------------------------------------------------------------------------- 1 | from . import args 2 | from . import brute 3 | from . import brute_time 4 | from . import jitter 5 | from . import logging 6 | from . import sql 7 | from . import errors 8 | from . import models 9 | from . import queries 10 | 11 | __all__ = ['args', 'brute', 'brute_time', 'helpers', 12 | 'jitter', 'logging', 'sql', 'models', 'queries'] 13 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | Reference documents below provide example code and links to API 4 | documentation for relavent components. 5 | 6 | - [Breakers](reference/breakers.md) 7 | - [Brute Force Attacks](reference/attack_management.md) 8 | - [Database Management](reference/db_management.md) 9 | - [Jitter (Guess Timing)](reference/jitter.md) 10 | - [SQL Information and Database Schema](pkg-docs/sql.md) 11 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: BruteLoops Documentation 2 | theme: 3 | name: material 4 | plugins: 5 | - mkdocstrings 6 | markdown_extensions: 7 | - admonition 8 | - pymdownx.highlight: 9 | anchor_linenums: true 10 | - pymdownx.inlinehilite 11 | - pymdownx.snippets 12 | - pymdownx.superfences: 13 | custom_fences: 14 | - name: mermaid 15 | class: mermaid 16 | format: !!python/name:pymdownx.superfences.fence_code_format 17 | nav: 18 | - BruteLoops Docs: index.md 19 | - Concepts: concepts.md 20 | - Reference: reference.md 21 | - Package Documentation: package-documentation.md 22 | - Complete Attack Examples: complete_examples.md 23 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | BruteLoops is a protocol agnostic password guessing API capable 2 | of executing highly configurable and performant attacks against 3 | authentication interfaces. Supporting logic also facilitates 4 | timing capabilities that can be used to adapt guessing behavior 5 | to match lockout policies commonly seen in web applications and 6 | Active Directory environments. 7 | 8 | Unlike most applications, BruteLoops reads inputs from a 9 | SQLite database. While this does impact performance with attacks 10 | invloving large datasets, it facilitates the granular timing 11 | configurations needed to mitigate lockout events and/or evade 12 | detection while attacking network services. 13 | -------------------------------------------------------------------------------- /docs/pkg-docs/args.md: -------------------------------------------------------------------------------- 1 | # Pre-Defined Argparse Arguments 2 | 3 | BruteLoops provides pre-defined [`argparse.ArgumentParser`](https://docs.python.org/3/library/argparse.html#argumentparser-objects) 4 | objects that can be dropped directly into applications. 5 | 6 | ## Example Usage 7 | 8 | This example is naive. It illustrates only how to incorporate a random 9 | set of arguments into an application. 10 | 11 | ``````python 12 | import bruteloops as BL 13 | import argparse 14 | 15 | parser = argparse.ArgumentParser( 16 | prog='My Program', 17 | description='This is my program\'s description.', 18 | parents=[BL.args.brute_parser]) 19 | `````` 20 | 21 | --- 22 | 23 | ::: bruteloops.args 24 | -------------------------------------------------------------------------------- /.kbmeta.yml: -------------------------------------------------------------------------------- 1 | # Tool name, which can differ from the repo name 2 | name: BruteLoops 3 | 4 | # Maintainer's name and email 5 | maintainer: 6 | name: Justin Angel 7 | email: justin@blackhillsinfosec.com 8 | 9 | # TTP tags with the format: / 10 | # URL for reference: https://attack.mitre.org/tactics/enterprise/ 11 | ttps: 12 | - credential_access/password_spraying 13 | - credential_access/credential_stuffing 14 | 15 | # Bulleted high level description. Please keep bullets brief. 16 | descriptions: 17 | - Highly configurable BruteForce attack utility 18 | - Capable of matching AD password policies. 19 | - Multiprocessed. 20 | - Can attack any protocol. 21 | 22 | # Determines if the tool can be discussed publicly 23 | # Valid Values: true, false 24 | public: true 25 | -------------------------------------------------------------------------------- /docs/pkg-docs/models.md: -------------------------------------------------------------------------------- 1 | # Models 2 | 3 | Pydantic models are used to validate inputs and outputs from programs 4 | that implement BruteLoops while also providing objects for attack 5 | configuration and management. 6 | 7 | ## Key Models 8 | 9 | - [`bruteloops.models.Config`](#bruteloops.models.Config) provides the 10 | primary configuration object passed to `BruteLoops.brute.BruteForcer` 11 | to perform BruteForce attacks. 12 | - [`bruteloops.models.Breaker`](#bruteloops.models.Breaker) defines 13 | the structure used for stopping attacks when specific exceptions occur. 14 | - [`bruteloops.models.ThresholdBreaker`](#bruteloops.models.ThresholdBreaker) 15 | extends `Breaker` such that an execption may occur multiple times within 16 | an established timeframe prior to ending the attack. 17 | 18 | --- 19 | 20 | ::: bruteloops.models 21 | -------------------------------------------------------------------------------- /docs/package-documentation.md: -------------------------------------------------------------------------------- 1 | # Package Documentation 2 | 3 | The following index of links provides access to key modules in the 4 | BruteLoops package. **Not all modules are listed here**, so see the 5 | project's source code for additional modules. 6 | 7 | - [`bruteloops.args`](pkg-docs/args.md) - For pre-defined Argparse args that can be 8 | quickly deployed into project interfaces. 9 | - [`bruteloops.brute`](pkg-docs/brute.md) - Classes for engaging the control loop. 10 | - [`bruteloops.db_manager`](pkg-docs/db_manager.md) - Module providing database management classes. 11 | - [`bruteloops.logging`](pkg-docs/logging.md) - Logging components. 12 | - [`bruteloops.models`](pkg-docs/models.md) - [Pydantic](https://pydantic-docs.helpmanual.io/) 13 | models used to validate inputs while also serving as a foundation for 14 | varous objects. 15 | - [`bruteloops.sql`](pkg-docs/sql.md) - Database schema definition via SQLAlchemy. 16 | -------------------------------------------------------------------------------- /bruteloops/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, IntEnum 2 | 3 | class LogLevelEnum(Enum): 4 | 'Map friendly names to logging levels.' 5 | 6 | INVALID_USERNAME = 'invalid-usernames' 7 | ('Most verbose level of logging that sends events related to ' 8 | 'invalid usernames') 9 | 10 | #SLEEP_EVENTS = 'sleep_states' 11 | #'Logs when no sleep times over 60 seconds are scheduled to occur.' 12 | 13 | VALID_CREDENTIALS = 'valid-credentials' 14 | 'Log valid credentials.' 15 | 16 | CREDENTIAL_EVENTS = 'invalid-credentials' 17 | 'Log invalid credential guesses.' 18 | 19 | GENERAL_EVENTS = 'general' 20 | 'Log general events, i.e. when an attack starts and stops.' 21 | 22 | class GuessOutcome(IntEnum): 23 | 'Map guess outcome sto friendly names.' 24 | 25 | failed = -1 26 | 'Failed to guess credentials.' 27 | 28 | invalid = 0 29 | 'Invalid credentials.' 30 | 31 | valid = 1 32 | 'Valid credentials.' 33 | 34 | -------------------------------------------------------------------------------- /tests/test_args.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import bruteloops as BL 3 | 4 | def test_timezone_parser(): 5 | 6 | # Valid timezone 7 | BL.args.timezone_parser.parse_args(['-tz', 'EST']) 8 | 9 | # Invalid timezone 10 | with pytest.raises(ValueError): 11 | BL.args.timezone_parser.parse_args(['-tz', 'INVALID']) 12 | 13 | def test_general_parser(): 14 | 15 | def parse(args): 16 | BL.args.general_parser.parse_args(args) 17 | 18 | # =============== 19 | # BLACKOUT WINDOW 20 | # =============== 21 | 22 | # BlackoutModel window 23 | bw_args = ['--blackout-window', '17:00:00-09:00:00'] 24 | parse(bw_args) 25 | 26 | orig = bw_args.pop() 27 | 28 | # Extra dash 29 | bw_args.append(orig+'-') 30 | with pytest.raises(ValueError): 31 | parse(bw_args) 32 | 33 | # Invalid time format 34 | bw_args.pop() 35 | bw_args.append(orig[:-3]) 36 | 37 | with pytest.raises(ValueError): 38 | parse(bw_args) 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | # Read README.md as a variable to pass as the package's long 4 | # description 5 | with open('README.md', 'r', encoding='utf-8') as f: 6 | long_description = f.read() 7 | 8 | setuptools.setup( 9 | name='bruteloops', 10 | version='1.0.1', 11 | author='Justin Angel', 12 | author_email='justin@arch4ngel.ninja', 13 | description='A password guessing API.', 14 | long_description=long_description, 15 | long_description_content_type='text/markdown', 16 | url='https://github.com/arch4ngel/bruteloops', 17 | include_package_data=True, 18 | packages=setuptools.find_packages(), 19 | classifiers=[ 20 | 'Programming Language :: Python :: 3', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Operating System :: POSIX :: Linux', 23 | ], 24 | python_requires='>=3.9', 25 | install_requires=[ 26 | 'sqlalchemy==1.4.0', 27 | 'billiard>=3.6.3.0', 28 | 'pydantic'] 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import bruteloops as BL 3 | from bruteloops.models import * 4 | from bruteloops import db_manager 5 | from bruteloops.brute import BruteForcer 6 | from uuid import uuid4 7 | from pathlib import Path 8 | 9 | def cback(username, password): 10 | 11 | outcome = dict( 12 | outcome=0, 13 | username=username, 14 | password=password) 15 | 16 | if username == 'u3' and password == 'p2': 17 | outcome['outcome'] = 1 18 | 19 | return outcome 20 | 21 | def test_config_model(config_kwargs): 22 | 23 | config_kwargs['authentication_callback'] = cback 24 | config_kwargs['process_count'] = 'bad' 25 | 26 | with pytest.raises(ValueError): 27 | Config(**config_kwargs) 28 | 29 | config_kwargs['process_count'] = 1 30 | 31 | config = Config(**config_kwargs) 32 | 33 | dbm = db_manager.Manager(config.db_file) 34 | 35 | dbm.insert_username_records(['u1', 'u2', 'u3'], False) 36 | dbm.insert_password_records(['p1', 'p2', 'p3', 'p4', 'p5'], False) 37 | dbm.associate_spray_values() 38 | 39 | BruteForcer(config=config).launch() 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Justin Angel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bruteloops/jitter.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from .brute_time import BruteTime 4 | from random import uniform 5 | 6 | JITTER_RE = '^(?P'\ 7 | '(?P[0-9]?\\.[0-9]+)|(?P[1-9][0-9]*))'\ 8 | '(?Ps|m|h|d){1}$' 9 | 10 | class JitterTime(BruteTime): 11 | '''' 12 | Class that provides functions capable of validating and converting time as 13 | required for the library. 14 | ''' 15 | 16 | s = seconds = 1 17 | m = minutes = s*60 18 | h = hours = m*60 19 | d = days = h*24 20 | 21 | time_re = re.compile(JITTER_RE) 22 | 23 | @staticmethod 24 | def conv_time_match(groups): 25 | ''' 26 | Convert the time to an integer value. 27 | ''' 28 | 29 | return ( 30 | float(groups['value']) * JitterTime.__dict__[groups['unit']] 31 | ) 32 | 33 | @staticmethod 34 | def validate_time(time): 35 | ''' 36 | Validate a string to assure it matches the appropriate jitter format. 37 | ''' 38 | 39 | m = re.match(JitterTime.time_re,time) 40 | if m: 41 | return m 42 | else: 43 | return False 44 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /docs/reference/attack_management.md: -------------------------------------------------------------------------------- 1 | BruteLoops provides a [BruteForcer](#bruteloops.brute.BruteForcer) class 2 | to streamline brute force attacks. All looping, timing, and control logic 3 | is defined in [brute](#bruteloops.brute.BruteForcer.launch). 4 | 5 | ## Examples 6 | 7 | !!! note 8 | 9 | See [Complete Attack Examples](/complete_examples) for start-to-finish 10 | examples. 11 | 12 | ### Basic Attack Configuration 13 | 14 | First, you'll need to create a [`Config`](/pkg-docs/models#bruteloops.models.Config) object 15 | with all the values needed to manage the attack. 16 | 17 | ``````python 18 | def auth_func(username, password): 19 | # This authenticates the credentials 20 | # ...guess the username and password... 21 | out = dict(outcome=0, username=username, password=password) 22 | if auth_success: 23 | out['outcome'] = 1 24 | return out 25 | 26 | 27 | config = bruteloops.models.Config( 28 | authentication_callback = auth_success, 29 | db_file = '/tmp/tmp.db') 30 | `````` 31 | 32 | Then a [`BruteForcer`](#bruteloops.brute.BruteForcer) object can be instantiated 33 | to [`launch`](#bruteloops.brute.BruteForcer.launch) the attack. 34 | 35 | ``````python 36 | # Create the object 37 | bf = bruteloops.brute.BruteForcer(config=config) 38 | 39 | # Start the attack. 40 | # Blocks until completion or interruption. 41 | bf.launch() 42 | `````` 43 | 44 | ------ 45 | 46 | ::: bruteloops.brute 47 | 48 | -------------------------------------------------------------------------------- /docs/pkg-docs/db_manager.md: -------------------------------------------------------------------------------- 1 | !!! note 2 | 3 | To learn about database schema, see [`bruteloops.sql`](/pkg-docs/sql) 4 | 5 | This module provides classes for managing databases and 6 | resource records. 7 | 8 | - [`bruteloops.db_manager.Manager`](#bruteloops.db_manager.Manager) is the 9 | fastest way to integrate these capabilities into your application. 10 | - [`bruteloops.db_manager.DBMixin`](#bruteloops.db_manager.DBMixin) can be 11 | used as a parent class if required. 12 | 13 | # Example 14 | 15 | This would create a `bruteloops.db_manager.Manager` instance and a supporting 16 | database file, followed by inserting username and password values. 17 | 18 | !!! tip 19 | 20 | Though this example imports values directly from a `list`, 21 | it's possible to import values from files by setting the 22 | `is_file` flag to `True`. Each element in `container` would 23 | then be treated as a path to a file of newline delimited 24 | values to import. 25 | 26 | ``````python 27 | from bruteloops.db_manager import Manager 28 | 29 | # Create the manager 30 | mgr = Manager(db_file='/tmp/test.db') 31 | 32 | # Insert usernames 33 | mgr.manage_values( 34 | model='username', 35 | container=['u1','u2','u3'] 36 | ) 37 | 38 | # Insert passwords 39 | mgr.manage_values( 40 | model='password', 41 | container=['p1','p2','p3'], 42 | ) 43 | 44 | # Insert credential values 45 | mgr.manage_credentials( 46 | as_credentials=True, 47 | container=['u4:p4','u5:p5'], 48 | associate_spray_values=True 49 | ) 50 | `````` 51 | 52 | --- 53 | 54 | ::: bruteloops.db_manager 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /tests/test_breakers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import bruteloops as BL 3 | from pathlib import Path 4 | from time import sleep 5 | 6 | class FakeError(Exception): 7 | pass 8 | 9 | def cb(username, password): 10 | raise FakeError('Fake') 11 | 12 | def test_breaker(): 13 | 14 | breaker = BL.models.Breaker( 15 | exception_classes=[FakeError]) 16 | with pytest.raises(BL.errors.BreakerTrippedError): 17 | breaker.check(FakeError('Testo')) 18 | 19 | def test_threshold_breaker(): 20 | 21 | t = 5 22 | 23 | breaker = BL.models.ThresholdBreaker(threshold=t, 24 | count=0, 25 | exception_classes=[FakeError]) 26 | 27 | fake = FakeError('Testo') 28 | 29 | with pytest.raises(BL.errors.BreakerTrippedError): 30 | for n in range(0,t+1): 31 | breaker.check(fake) 32 | 33 | def test_threshold_breaker_reset(): 34 | 35 | t = 5 36 | 37 | breaker = BL.models.ThresholdBreaker( 38 | threshold=t, 39 | exception_classes=[FakeError], 40 | reset_spec='2s') 41 | 42 | fake = FakeError('TestoReseto') 43 | for n in range(0, 5): 44 | breaker.check(fake) 45 | sleep(2.5) 46 | breaker.check(fake) 47 | with pytest.raises(BL.errors.BreakerTrippedError): 48 | for n in range(0, 5): 49 | breaker.check(fake) 50 | 51 | def test_threshold_breaker_in_loop(setup): 52 | 53 | config, dbm = setup 54 | # Do brute force 55 | BL.brute.BruteForcer(config=config).launch() 56 | 57 | test_threshold_breaker_in_loop.CONFIG = dict( 58 | breakers=[BL.models.ThresholdBreaker( 59 | threshold=5, 60 | exception_classes=[FakeError])], 61 | authentication_callback=cb) 62 | 63 | test_threshold_breaker_in_loop.DBM_CALLBACKS = dict( 64 | insert_username_records=dict( 65 | container=['u1', 'u2', 'u3'], 66 | associate_spray_values=False), 67 | insert_password_records=dict( 68 | container=['p1', 'p2', 'p3', 'p4', 'p5'], 69 | associate_spray_values=False), 70 | associate_spray_values=None) 71 | -------------------------------------------------------------------------------- /docs/complete_examples.md: -------------------------------------------------------------------------------- 1 | !!! warning 2 | 3 | - All examples assume that the execution environment is **Linux**. 4 | - Attack configurations should not be viewed as well-considered. 5 | - Authentication callbacks presented here are intentionally naive 6 | to clearly communicate how configuration is intended to occur. 7 | 8 | ## Basic Example 9 | 10 | ``````python 11 | from bruteloops.models import Config 12 | from bruteloops.brute import BruteForcer 13 | from bruteloops.db_manager import Manager 14 | from random import randint 15 | from pathlib import Path 16 | 17 | # ================================== 18 | # DEFINE THE AUTHENTICATION CALLBACK 19 | # ================================== 20 | 21 | def auth_cb(username:str, password:str) -> dict: 22 | '''Guess the credentials. Returns valid credential output when 23 | username is "u5" and the password is "p2". 24 | 25 | Returns: 26 | Dictionary determining if credentials are valid. 27 | ''' 28 | 29 | # Return value 30 | ret = dict(outcome=0) 31 | 32 | # Check the credentials 33 | # Normally you'd hit a remote service or something, here. 34 | if username == 'u5' and password == 'p2': 35 | ret['outcome'] = 1 36 | 37 | return ret 38 | 39 | # ===================================== 40 | # CREATE & POPULATE THE SQLITE DATABASE 41 | # ===================================== 42 | 43 | # Create credentials to import 44 | spray_creds = [f'u{n}:p{n}' for n in range(1,11)] 45 | 46 | # Initialize a Manager instance 47 | db_file = f'/tmp/test-{randint(0,1000)}.db' 48 | print(f'[+] db_file: {db_file}') 49 | 50 | dbm = Manager(db_file=db_file) 51 | dbm.manage_credentials(container=spray_creds) 52 | 53 | # =================================================== 54 | # CREATE CONFIG AND BRUTEFORCER AND LAUNCH THE ATTACK 55 | # =================================================== 56 | 57 | config = Config( 58 | db_file=db_file, 59 | authentication_callback=auth_cb, 60 | authentication_jitter=dict( 61 | min='0.2s', 62 | max='1s'), 63 | max_auth_jitter=dict( 64 | min='5s', 65 | max='20s') 66 | ) 67 | 68 | bf = BruteForcer(config=config) 69 | bf.launch() 70 | Path(db_file).unlink() 71 | `````` 72 | -------------------------------------------------------------------------------- /docs/pkg-docs/sql.md: -------------------------------------------------------------------------------- 1 | # SQL Components 2 | 3 | BruteLoops uses [SQLAlchemy](https://www.sqlalchemy.org/) to manage 4 | and query [SQLite](https://www.sqlite.org/index.html) databases, 5 | thus facilitating SQL queries when selecting credentials to guess. 6 | 7 | ## Record (Resource) Types 8 | 9 | !!! note 10 | 11 | Applications that implement BruteLoops do not need to worry 12 | about managing any of these values. All resources are managed 13 | by [`bruteloops.db_manager`](/pkg-docs/db_manager) and other 14 | supporting logic at runtime. 15 | 16 | There are 4 main types of resources in the database: 17 | 18 | 1. `attack` - Describes attacks and when they were executed. 19 | 2. `username` - Representing accounts. 20 | 3. `password` - Passwords that are to be guessed for a username. 21 | 4. `credential` - A unit consisting of both a `username` and a 22 | password pair. This is implemented as a lookup table. 23 | 24 | There are 2 additional rources that are managed by BruteLoops 25 | indirectly: 26 | 27 | - `Credential` - Represents a relationship between a `username` 28 | and a `password`. 29 | - `StrictCredential` - Similar to a `Credential` but the 30 | password is not sprayable. These records are maintained as an 31 | efficient lookup table, thus minimizing query times during 32 | attack execution. 33 | 34 | ## Database Schema 35 | 36 | The following Mermaid diagram illustrates the database schema. 37 | 38 | ``````mermaid 39 | erDiagram 40 | 41 | Attack { 42 | float start_time 43 | float end_time 44 | bool complete 45 | } 46 | 47 | Username { 48 | string value 49 | bool recovered 50 | bool actionable 51 | bool priority 52 | float last_time 53 | float future_time 54 | } 55 | 56 | Password { 57 | string value 58 | bool priority 59 | bool sprayable 60 | } 61 | 62 | Credential { 63 | int username_id FK 64 | int password_id FK 65 | bool valid 66 | bool guessed 67 | } 68 | 69 | PriorityCredential { 70 | int credential_id FK 71 | } 72 | 73 | StrictCredential { 74 | int credential_id FK 75 | } 76 | 77 | Credential ||--o{ Username : has 78 | Credential ||--o{ Password : has 79 | Credential |o--|| PriorityCredential : has 80 | Credential |o--|| StrictCredential : has 81 | `````` 82 | 83 | ::: bruteloops.sql 84 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import bruteloops as BL 3 | 4 | @pytest.fixture 5 | def config_kwargs(tmp_path): 6 | 7 | return dict( 8 | db_file=str((tmp_path / 'tmp.db').absolute()), 9 | process_count=1, 10 | max_auth_tries=1, 11 | authentication_jitter=dict(min='0.1s', max='0.1s'), 12 | max_auth_jitter=dict(min='1s', max='1.2s'), 13 | stop_on_valid=False, 14 | log_level='invalid-usernames', 15 | timezone='America/New_York', 16 | blackout=dict(start='06:40', stop='06:41'), 17 | breakers=[ 18 | dict(exception_classes=[Exception], threshold=5) 19 | ], 20 | exception_handlers=[ 21 | dict(exception_class=Exception, handler=lambda a: print(a))]) 22 | 23 | @pytest.fixture 24 | def setup(request, config_kwargs): 25 | '''This fixture will set up a BL.models.Config model while creating 26 | and initializing a database. 27 | 28 | Notes: 29 | - See test_breakers.py for examples on setting these values! 30 | - Ways to update values passed to the Config model by: 31 | - Set a module-level dictionary variable to CONFIG 32 | - Set an attribute to a function test case to CONFIG 33 | - Ways to call db_manager functions: 34 | - Set a module-level dictionary variable to DBM_CALLBACKS 35 | - Set an attribute to a function test case to DBM_CALLBACKS 36 | - DBM_CALLBACKS structure: 37 | - This should be like: {:} 38 | - ONLY KWARGS ARE SUPPORTED! 39 | ''' 40 | 41 | # ============================================== 42 | # UPDATE THE CONFIG KWARGS AND GENERATE A CONFIG 43 | # ============================================== 44 | 45 | config_kwargs.update(getattr(request.module, 'CONFIG', dict())) 46 | config_kwargs.update(getattr(request.function, 'CONFIG', dict())) 47 | config = BL.models.Config(**config_kwargs) 48 | 49 | # ======================================= 50 | # CREATE DATABASE AND APPLY DBM FUNCTIONS 51 | # ======================================= 52 | 53 | dbm = BL.db_manager.Manager(config.db_file) 54 | for handle in ('module', 'function',): 55 | handle = getattr(request, handle) 56 | dbm_callbacks = getattr(handle, 'DBM_CALLBACKS', dict()) 57 | 58 | # Run each of the DBM callbacks 59 | for fhandle, kwargs in dbm_callbacks.items(): 60 | if kwargs is None: kwargs = dict() 61 | getattr(dbm, fhandle)(**kwargs) 62 | 63 | return config, dbm 64 | -------------------------------------------------------------------------------- /bruteloops/queries.py: -------------------------------------------------------------------------------- 1 | from . import sql 2 | from sqlalchemy import ( 3 | select, delete, join, 4 | update, not_) 5 | 6 | # ================ 7 | # USERNAME QUERIES 8 | # ================ 9 | 10 | # Where clauses applicable to to all Username queries. 11 | COMMON_USERNAME_WHERE_CLAUSES = CWC = ( 12 | sql.Username.recovered == False, 13 | sql.Username.actionable == True,) 14 | 15 | HAS_UNGUESSED_SUBQUERY = ( 16 | select(sql.Credential.id) 17 | .where( 18 | sql.Username.id == sql.Credential.username_id, 19 | sql.Credential.guess_time == -1, 20 | sql.Credential.guessed == False) 21 | .limit(1) 22 | ).exists() 23 | 24 | strict_usernames = ( 25 | select(sql.Username.id) 26 | .where( 27 | *CWC, 28 | sql.Username.priority == False, 29 | HAS_UNGUESSED_SUBQUERY, 30 | ( 31 | select(sql.StrictCredential) 32 | .join( 33 | sql.Credential, 34 | sql.StrictCredential.credential_id == 35 | sql.Credential.id) 36 | .where( 37 | sql.Credential.username_id == 38 | sql.Username.id) 39 | .limit(1) 40 | ).exists() 41 | ) 42 | .distinct() 43 | ) 44 | 45 | priority_usernames = ( 46 | select(sql.Username.id) 47 | .where( 48 | *CWC, 49 | sql.Username.priority == True, 50 | HAS_UNGUESSED_SUBQUERY) 51 | .distinct() 52 | ) 53 | 54 | usernames = ( 55 | select(sql.Username.id) 56 | .where( 57 | *CWC, 58 | HAS_UNGUESSED_SUBQUERY) 59 | .distinct() 60 | ) 61 | 62 | # ================== 63 | # CREDENTIAL QUERIES 64 | # ================== 65 | 66 | COMMON_CREDENTIAL_WHERE_CLAUSES = CCWC = ( 67 | sql.Credential.guess_time == -1, 68 | sql.Credential.guessed == False, 69 | ) 70 | 71 | strict_credentials = ( 72 | select(sql.StrictCredential) 73 | .join( 74 | sql.Credential, 75 | sql.StrictCredential.credential_id == sql.Credential.id) 76 | .where(*CCWC) 77 | ) 78 | 79 | priority_credentials = ( 80 | select(sql.PriorityCredential) 81 | .join( 82 | sql.Credential, 83 | sql.PriorityCredential.credential_id == sql.Credential.id) 84 | .join( 85 | sql.Password, 86 | sql.Credential.password_id == sql.Password.id) 87 | .where( 88 | *CCWC, 89 | sql.Password.priority == True) 90 | ) 91 | 92 | credentials = ( 93 | select(sql.Credential.id) 94 | .join( 95 | sql.Username, 96 | sql.Credential.username_id == sql.Username.id) 97 | .join( 98 | sql.Password, 99 | sql.Credential.password_id == sql.Password.id) 100 | .where(*CCWC) 101 | ) 102 | 103 | 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BruteLoops 2 | 3 | A dead simple library providing the foundational logic for 4 | efficient password brute force attacks against authentication 5 | interfaces. 6 | 7 | # Documentation 8 | 9 | Documentation can be found [*here*](https://arch4ngel.github.io/BruteLoops/). 10 | 11 | # If you're looking for the old example modules... 12 | 13 | See [BFG](https://github.com/arch4ngel/bl-bfg). 14 | 15 | The examples have been offloaded to a distinct project to 16 | minimize code and packaging issues. Database and attack 17 | capabilities have also been merged into a single binary. 18 | 19 | # Key Features 20 | 21 | - **Protocol agnostic** - If a callback can be written in Python, 22 | BruteLoops can be used to attack it 23 | - **SQLite support** - All usernames, passwords, and credentials 24 | are maintained in an SQLite database. 25 | - A companion utility (`dbmanager.py`) that creates and manages 26 | input databases accompanies BruteLoops 27 | - **Spray and Stuffing Attacks in One Tool** - BruteLoops supports both 28 | spray and stuffing attacks in the same attack logic and database, meaning 29 | that you can configure a single database and run the attack without heavy 30 | reconfiguration and confusion. 31 | - **Guess scheduling** - Each username in the SQLite database is configured 32 | with a timestamp that is updated after each authentication event. This 33 | means we can significantly reduce likelihood of locking accounts by 34 | scheduling each authentication event with precision. 35 | - **Fine-grained configurability to avoid lockout events** - Microsoft's 36 | lockout policies can be matched 1-to-1 using BruteLoop's parameters: 37 | - `auth_threshold` = Lockout Threshold 38 | - `max_auth_jitter` = Lockout Observation Window 39 | - Timestampes associated with each authentication event are tracked 40 | in BruteLoops' SQLite database. Each username receives a distinct 41 | timestamp to assure that authentication events are highly controlled. 42 | - **Attack resumption** - Stopping and resuming an attack is possible 43 | without worrying about losing your place in the attack or locking accounts. 44 | - **Multiprocessing** - Speed up attacks using multiprocessing! By configuring 45 | the parallel guess count, you're effectively telling BruteLoops how many 46 | usernames to guess in parallel. 47 | - **Logging** - Each authentication event can optionally logged to disk. 48 | This information can be useful during red teams by providing customers 49 | with a detailed attack timeline that can be mapped back to logged events. 50 | - **Breakers** - Breakers behave like circuit breakers. An exception can 51 | be raised *x* number of times before ending the attack loop. They can 52 | reset after a given period of time as well, allowing for configurations 53 | like "Exit after 6 ConnectionErrors occur". 54 | 55 | # Dependencies 56 | 57 | BruteLoops requires __Python3.7 or newer__ and 58 | [SQLAlchemy 1.3.0](https://www.sqlalchemy.org/), the latter of 59 | which can be obtained via pip and the requirements.txt file in 60 | this repository: `python3.7 -m pip install -r requirements.txt` 61 | 62 | # Installation 63 | 64 | ``` 65 | git clone https://github.com/arch4ngel/bruteloops 66 | cd bruteloops 67 | python3 -m pip install -r requirements.txt 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/reference/breakers.md: -------------------------------------------------------------------------------- 1 | Breakers provide a method of terminating an attack when an exception 2 | is raised. They intend to behave like circuit breakers, breaking only 3 | when some condition manifests. 4 | 5 | ## The Two Types of Breakers 6 | 7 | BruteLoops offers two types of breakers: 8 | 9 | - [`bruteloops.models.Breaker`](#bruteloops.models.Breaker) 10 | - The standard breaker. 11 | - The control loop is immediately terminated when exceptions handled by 12 | this class are raised. 13 | - [`bruteloops.models.ThresholdBreaker`](#bruteloops.models.ThresholdBreaker) 14 | - Inherits from the standard breaker. 15 | - Allows for a configurable number of exceptions to be raised before 16 | becoming tripped and terminating the control loop. 17 | - It's also possible to configure a reset specification for this type 18 | of breaker. 19 | - This means that a number of matching exceptions must be raised 20 | within a window of time before the control loop is terminated. 21 | 22 | ## Examples 23 | 24 | !!! note 25 | 26 | - [`bruteloops.models.Config`](/pkg-docs/models/#bruteloops.models.Config) 27 | accepts a list of one or more breakers. 28 | - A blend of 29 | [`bruteloops.models.Breaker`](#bruteloops.models.Breaker) and 30 | [`bruteloops.models.ThresholdBreaker`](#bruteloops.models.ThresholdBreaker) 31 | can be provided. 32 | - Custom exception classes can be provided so long as they inherit from 33 | `Exception` or `BaseException`. 34 | 35 | ### Standard Breaker Configuration 36 | 37 | A standard breaker always raises `bruteloops.errors.BreakerTrippedError` 38 | when a handled exception class is raised during attack execution. 39 | 40 | This configuration would result in the breaker being thrown after the 41 | first `ConnectionError` is raised. 42 | 43 | ``````python 44 | import bruteloops 45 | breakers = [bruteloops.models.Breaker( 46 | exception_classes=[ConnectionError]) 47 | `````` 48 | 49 | ### Threshold Breaker Configuration 50 | 51 | Configure a breaker that would reset the count after 10 minutes 52 | with no `ConnectionError`s being raised. 53 | 54 | ``````python 55 | import bruteloops 56 | breakers = [bruteloops.models.ThresholdBreaker( 57 | threshold=5, 58 | exception_classes=[ConnectionError], 59 | reset_spec='10m')] 60 | `````` 61 | 62 | ### Complete Configuration Example 63 | 64 | This example produces a working 65 | [`bruteloops.models.Config`](/pkg-docs/models/#bruteloops.models.Config). 66 | 67 | !!! warning 68 | 69 | - This example *does not* represent a well-considered attack 70 | configuration. 71 | - This example is naive. It does not raise the custom 72 | `LockoutException` class and the callback always returns `True`. 73 | 74 | ``````python 75 | import bruteloops 76 | 77 | class LockoutException(Exception): 78 | pass 79 | 80 | breakers = [ 81 | bruteloops.models.ThresholdBreaker( 82 | exception_classes=[LockoutException], 83 | threshold=5, 84 | reset_spec='5m'), 85 | bruteloops.models.ThresholdBreaker( 86 | exception_classes=[ConnectionError], 87 | threshold=20, 88 | reset_spec='20m')] 89 | 90 | config = bruteloops.models.Config( 91 | db_file='/tmp/test.db', 92 | breakers=breakers, 93 | authentication_callback=lambda username,password: True) 94 | `````` 95 | 96 | --- 97 | 98 | ## Standard Breaker 99 | 100 | ::: bruteloops.models.Breaker 101 | 102 | --- 103 | 104 | ## Threshold Breaker 105 | 106 | ::: bruteloops.models.ThresholdBreaker 107 | -------------------------------------------------------------------------------- /bruteloops/brute_time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timezone 3 | from zoneinfo import ZoneInfo 4 | from .errors import TimezoneError 5 | 6 | class BruteTime: 7 | 8 | # default template for time format 9 | # see documentation for time.strftime for more information on formatting 10 | str_format = '%H:%M:%S %Z (%y/%m/%d)' 11 | 12 | # Get the default timezone. 13 | timezone = ZoneInfo('UTC') 14 | 15 | @staticmethod 16 | def set_timezone(key): 17 | '''Set the timezone for timestamps. 18 | 19 | Args: 20 | key: Either a `str` that point to the name of the target 21 | timezone or a `zoneinfo.ZoneInfo` instance representing 22 | the timezone itself. 23 | ''' 24 | 25 | if isinstance(key, str): 26 | try: 27 | BruteTime.timezone = ZoneInfo(key) 28 | except Exception as e: 29 | raise TimezoneError.invalidTZ(key, extra=e) 30 | elif isinstance(key, ZoneInfo): 31 | BruteTime.timezone = key 32 | else: 33 | raise ValueError( 34 | 'Timezone configuration must be a string or ZoneInfo.') 35 | 36 | @staticmethod 37 | def future_time(seconds:float, format:object=float, 38 | str_format:str=str_format): 39 | '''Calculate the future time from current time. 40 | 41 | Args: 42 | seconds: Number of seconds into the future. 43 | format: Value indicating the desired format. Supported 44 | values: `str`, "struct_time". 45 | str_format: Format string when `str` is supplied 46 | to format. 47 | 48 | Returns: 49 | - When `str` is supplied to `format`, a formatted string 50 | is returned. 51 | - When "struct_time" is supplied, a `datetime.time.timetuple` 52 | instance is returned. 53 | - Otherwise, a `float` is returned. 54 | ''' 55 | 56 | future = BruteTime.current_time()+seconds 57 | 58 | if format == str: 59 | 60 | return BruteTime.float_to_str(future, str_format) 61 | 62 | elif format in (datetime, 'struct_time', 'datetime',): 63 | 64 | dt = datetime \ 65 | .fromtimestamp(future, BruteTime.timezone) 66 | if format in (datetime, 'datetime',): 67 | return dt 68 | elif format == 'struct_time': 69 | return dt.timetuple() 70 | 71 | else: 72 | 73 | return future 74 | 75 | @staticmethod 76 | def current_time(format:object=float, str_format:str=str_format): 77 | '''Return the current time in the specified format. 78 | 79 | Args: 80 | format: Specifies the return format. Supply `str` to 81 | return a formatted string. 82 | str_format: The format string to use. 83 | 84 | Returns: 85 | - When `str` is supplied to `format`, a formatted string 86 | is returned. 87 | - When `float` is supplied to `format`, a float is returned. 88 | - when `datetime`, a `datetime` object is returned. 89 | ''' 90 | 91 | dt = datetime.now(BruteTime.timezone) 92 | if format == str: 93 | return dt.strftime(str_format) 94 | if format in (datetime, 'datetime',): 95 | return dt 96 | else: 97 | return dt.timestamp() 98 | 99 | @staticmethod 100 | def float_to_str(float_time:float, str_format:str=str_format) -> str: 101 | '''Return the float time, as returned by time.time(), 102 | as a formatted string. 103 | 104 | Args: 105 | float_time: Float value to convert to string. 106 | str_format: Format string. 107 | 108 | Returns: 109 | Formatted `str`. 110 | ''' 111 | 112 | return datetime \ 113 | .fromtimestamp(float_time, BruteTime.timezone) \ 114 | .strftime(str_format) 115 | -------------------------------------------------------------------------------- /bruteloops/errors.py: -------------------------------------------------------------------------------- 1 | '''This module contains classes to produce meaningful error messages. 2 | ''' 3 | 4 | from functools import wraps 5 | from inspect import signature, Parameter 6 | 7 | def error(f): 8 | '''Decorator to assist with formatting and returning error 9 | messages. 10 | 11 | `error` expects the decorated function to return a tuple as 12 | specified in the below notes. It then generates an error 13 | from the `ErrorClass` and processes `StrMessageTemplate` 14 | using `str.format` while passing all keyword arguments 15 | such that they will be rendered in the final error message. 16 | 17 | Notes: 18 | 19 | Decorated functions are expected to satisfy the following 20 | criteria: 21 | 22 | 1. Along with any parameters required by the decorated 23 | function, the function signature must contain 24 | `*args, **kwargs`. 25 | 2. The `extra` keyword argument is used to suffix any 26 | additional information to the error message. This 27 | aims to be a convenience to avoid complex format 28 | string templates. 29 | 3. The return value must be a tuple in the form: 30 | `(ErrorClass, StrMessageTemplate)` 31 | ''' 32 | 33 | @wraps(f) 34 | def wrapper(*args, **kwargs) -> Error: 35 | 36 | # =========================== 37 | # GET FUNCTION/CALL VARIABLES 38 | # =========================== 39 | 40 | # Call the decorated function to receive the 41 | # error class and format string 42 | error_class, fmsg = f(*args, **kwargs) 43 | 44 | # Get the method signature and parameters from 45 | # the decorated function 46 | sig = signature(f) 47 | params = sig.parameters 48 | args = list(args) 49 | 50 | # Gather format arguments from the decorated function's 51 | # signature to form a dictionary. 52 | fargs, extra = {}, None 53 | for name, param in params.items(): 54 | 55 | # Ignore strictly positional/keyword arguments 56 | if param.kind is not Parameter.POSITIONAL_OR_KEYWORD: 57 | continue 58 | 59 | if name in kwargs: 60 | 61 | # Pull as a keyword argument 62 | fargs[name] = kwargs[name] 63 | 64 | else: 65 | 66 | # Assume it's a positional value 67 | fargs[name] = args.pop(0) 68 | 69 | # Append the "extra" tag when it isn't found in 70 | # the format string template. 71 | if 'extra' in kwargs: 72 | 73 | # Append the extra template. 74 | if fmsg.find('{extra}') == -1: 75 | fmsg += ' ({extra})' 76 | 77 | # Capture extra detail. 78 | fargs['extra'] = str(kwargs['extra']) 79 | 80 | # =================================== 81 | # PRODUCE AND RETURN THE ERROR OBJECT 82 | # =================================== 83 | 84 | try: 85 | 86 | # Return the error message when things go 87 | # right 88 | return error_class(fmsg.format(**fargs)) 89 | 90 | except Exception as e: 91 | 92 | # Handle when things go wrong 93 | raise Error.failedErrorFormatting(str(e)) 94 | 95 | return wrapper 96 | 97 | class Error(Exception): 98 | '''Generic error class. 99 | ''' 100 | 101 | @staticmethod 102 | def failedErrorFormatting(msg): 103 | '''Failed to properly format the error string. 104 | ''' 105 | 106 | return Error(f'Failed to format error message: {msg}') 107 | 108 | class TimezoneError(Error): 109 | '''Timezone errors. 110 | ''' 111 | 112 | @staticmethod 113 | @error 114 | def invalidTZ(tz:str, *args, **kwargs): 115 | '''An invalid timezone string name has been supplied. 116 | ''' 117 | 118 | return TimezoneError, 'Invalid timezone supplied: {tz}' 119 | 120 | class LoggingError(Error): 121 | '''Logging errors. 122 | ''' 123 | 124 | @staticmethod 125 | @error 126 | def invalidLevelName(level:str, *args, **kwargs): 127 | '''An invalid string for logging levels was supplied. 128 | ''' 129 | 130 | return LoggingError, 'Invalid logging level supplied: {level}' 131 | 132 | class BreakerTrippedError(Error): 133 | pass 134 | -------------------------------------------------------------------------------- /docs/reference/db_management.md: -------------------------------------------------------------------------------- 1 | # Database Management 2 | 3 | BruteLoops provides classes to simplify management of databases 4 | and database records. 5 | 6 | - Instantiate a [`Manager`](#bruteloops.db_manager.Manager) object to 7 | quickly create a SQLite database and begin managing records. 8 | - See [`DBMixin`](#bruteloops.db_manager.DBMixin) for relevant management 9 | methods, such as: 10 | - [`DBMixin.manage_values`](#bruteloops.db_manager.DBMixin.manage_values) 11 | to manage individual `username` and `password` records. 12 | - [`DBMixin.manage_credentials`](#bruteloops.db_manager.DBMixin.manage_credentials) 13 | to manage credentials. 14 | 15 | ## Methods for Creating Resource Records 16 | 17 | There are individual methods for creating resource records: 18 | 19 | - [`insert_username_records`](#bruteloops.db_manager.DBMixin.insert_username_records) 20 | - [`insert_password_records`](#bruteloops.db_manager.DBMixin.insert_password_records) 21 | - [`insert_credential_records`](#bruteloops.db_manager.DBMixin.insert_credential_records) (`as_credentials` set to `False`) 22 | 23 | `insert_username_records` and `insert_password_records` have an 24 | obvious purpose: they insert records into their respective 25 | database table. 26 | 27 | However, `insert_credential_records` deserves greater explanation. 28 | This method: 29 | 30 | - Accepts a container of delimited strings, e.g. `username:password` 31 | - The string is broken out into individual username and password 32 | record values and then inserted into the database. 33 | - When the `as_credentials` argument is `False`, the values are 34 | imported and treated as spray values, i.e. the passwords will 35 | be tried for all usernames. 36 | - When the `as_credentials` argument is set to `True`, they will be 37 | be treated as `StrictCredentials`, i.e. the password will be 38 | guessed for usernames which it has been expressly configured for. 39 | - However, all other passwords will be guessed for the username 40 | after all `StrictCredentials` have been exhausted. 41 | 42 | ### Methods for Deleting Resource Records 43 | 44 | The following methods follow the same semantics described above, 45 | except they delete records from the database. 46 | 47 | - [`delete_username_records`](#bruteloops.db_manager.DBMixin.delete_username_records) 48 | - [`delete_password_records`](#bruteloops.db_manager.DBMixin.delete_password_records) 49 | - [`delete_credential_records`](#bruteloops.db_manager.DBMixin.delete_credential_records) 50 | 51 | ### Resource Management Methods 52 | 53 | These methods compliment previously described methods by providing 54 | logic to handle containers of various types: 55 | 56 | _Methods:_ 57 | 58 | - [`manage_values`](#bruteloops.db_manager.DBMixin.manage_values) - Inserts/deletes usernames and passwords. 59 | - [`manage_credentials`](#bruteloops.db_manager.DBMixin.manage_credentials) - Inserts/deletes credentials. 60 | 61 | _Container Types:_ 62 | 63 | Management methods always accept a list of strings and a flag that 64 | determines how values in the list should be treated, e.g. the 65 | `is_file` flag indicates that each value points to a file of 66 | values. 67 | 68 | - When no `is_file` or `is_csv_file` flag is set, the values 69 | are treated as the records to import. 70 | - When `is_file` is `True`: values are file paths to newline 71 | delimited files for opening. 72 | - When `is_csv_file` is `True`: values are file paths to CSV 73 | files for parsing. (`manage_credential_values` only) 74 | 75 | ## Examples 76 | 77 | ### Usernames from a File, Passwords from List 78 | 79 | This example imports a list of usernames and passwords into 80 | a newly created database. 81 | 82 | _The Username File (`/tmp/usernames.txt`)_ 83 | 84 | ``` 85 | user1 86 | user2 87 | ``` 88 | 89 | _Calling the Methods_ 90 | 91 | This would populate the database with: 92 | 93 | - 2 usernames from file 94 | - 3 spray passwords directly 95 | - Produce a total of 6 credentials to guess 96 | - `usernames*passwords=total_credentials` 97 | - `2*3=6` 98 | 99 | ```python 100 | import bruteloops 101 | dbm = bruteloops.db_manager.Manager('/tmp/test.db') 102 | 103 | # Import the usernames from disk first 104 | dbm.manage_values( 105 | model='username', 106 | container=['/tmp/usernames.txt'], 107 | is_file=True, 108 | associate_spray_values=False) 109 | 110 | # Import passwords and associate them with all usernames 111 | dbm.manage_values( 112 | model='password', 113 | container=['p1', 'p2', 'p3'], 114 | associate_spray_values=True) 115 | ``` 116 | 117 | ------ 118 | 119 | ::: bruteloops.db_manager 120 | -------------------------------------------------------------------------------- /docs/reference/jitter.md: -------------------------------------------------------------------------------- 1 | # Time Variation via Jitter 2 | 3 | A jitter configuration specifies an upper and lower time boundary, 4 | i.e. a `min` and a `max`. BruteLoops uses jitter values and timestamps 5 | stored in the SQLite database to make timing decisions during attack 6 | exeuction, e.g. "do not guess this username untl this time" and/or 7 | "wait *x* seconds before guessing the next password". 8 | 9 | ## Two Jitter Points 10 | 11 | BruteLoops can apply jitter at two points in a BruteForce attack: 12 | 13 | 1. **Authentication** (`Config.authentication_jitter`) - Time slept after 14 | guessing a credential. 15 | - `time.sleep(auth_jitter)` is applied in child process that performed 16 | the guess. 17 | - Exists primarily for detection avoidance. 18 | 2. **Max Auth** (`Config.max_auth_jitter`) - Time waited after a maximum 19 | number of guesses for a username has been made. 20 | - Exists primarily to mitigate account lockouts. 21 | - Derived from a "future timestamp" taken after the most recent guess 22 | for a username. 23 | - SQL queries select usernames where `future_time <= current_time` 24 | 25 | The following diagram roughly illustrates where these jitter 26 | configurations are applied during an attack: 27 | 28 | ``````mermaid 29 | flowchart 30 | 31 | only-creds("Returns creds with 32 | future timestamp <= current time") -..-> get-guessable-creds 33 | 34 | calc-note("Calculated from 35 | 'max auth jitter' 36 | setting") 37 | 38 | calc-note -..-> update-username-timestamp 39 | 40 | subgraph sg-control-loop["Control Loop"] 41 | 42 | get-guessable-creds("Get actionable creds") 43 | guess-creds(Guess returned creds) 44 | 45 | get-guessable-creds -->|"Returns 46 | credentials"| guess-creds 47 | 48 | guess-creds -->|"Each cred guessed 49 | in a subprocess"| sp1 50 | 51 | subgraph sg-subprocs["Subprocesses"] 52 | sp1(["Process 1"]) 53 | end 54 | 55 | update-username-timestamp("Apply updated 56 | future timestamp") 57 | guess-creds --> update-username-timestamp 58 | update-username-timestamp -->|"Get next 59 | actionable creds"| get-guessable-creds 60 | 61 | subgraph sg-acb["Authentication Callback"] 62 | exec-callback("Execute Callback") 63 | sp1 -->|"Guess 64 | credential"| exec-callback 65 | 66 | auth-jitter[\"Apply Auth Jitter 67 | (sleep)"\] 68 | exec-callback -->|"Do jitter"| auth-jitter 69 | auth-jitter -->|"Return 70 | output"| sp1 71 | end 72 | 73 | sp1 -->|"Return 74 | output"| guess-creds 75 | end 76 | `````` 77 | 78 | ## How are Jitter Configurations Supplied to `BruteForcer.launch()`? 79 | 80 | Jitter configurations are accepted from the user via interface created 81 | by the implementing application and passed to 82 | [`bruteloops.models.Config`](/pkg-docs/models/#bruteloops.models.Config) for 83 | validation. The `Config` object is then passed to 84 | [`bruteloops.brute.BruteForcer`](/reference/attack_management) to initiate 85 | a brute force attack. 86 | 87 | ``````mermaid 88 | flowchart 89 | 90 | uinput(User Input) 91 | config-model(bruteloops.models.Config) 92 | brute-forcer(bruteloops.brute.BruteForcer.__init__) 93 | do-attack("bruteloops.brute.BruteForcer.launch()") 94 | 95 | uinput -->|Passed to| config-model 96 | config-model -->|Used to instantiate| brute-forcer 97 | brute-forcer -->|Launch attack| do-attack 98 | `````` 99 | 100 | !!! warning 101 | 102 | [`bruteloops.jitter.JitterTime`](#bruteloops.jitter.JitterTime) 103 | was the original implementation and will be removed once all references 104 | have been purged. 105 | 106 | Jitter inputs are supplied via the following configrations 107 | made accessible from the [`bruteloops.models.Config` model](/pkg-docs/models/#bruteloops.models.Config): 108 | 109 | - `authentication_jitter` 110 | - `max_auth_jitter` 111 | 112 | ## Format Specification 113 | 114 | The `min` and `max` arguments passed to [`bruteloops.models.Jitter` model](#bruteloops.models.Jitter) 115 | each expect string values formatted to the following specification. 116 | 117 | ### Jitter String Format 118 | 119 | The format translates to, where duriation is either 120 | a float or integer and time unit is a single character. 121 | 122 | `*` 123 | 124 | ### Supported Time Units 125 | 126 | - `s` - Second(s) 127 | - `m` - Minute(s) 128 | - `h` - Hour(s) 129 | - `d` - Day(s) 130 | 131 | ### Examples 132 | 133 | #### Formatted Values 134 | 135 | - `10m` - 10 minutes 136 | - `2h` - 2 hours 137 | - `1.5` - 1 hour and 30 minutes 138 | 139 | #### Configuration Example 140 | 141 | The following example creates a `bruteloops.models.Config` with jitter 142 | values for: 143 | 144 | - Try up to 3 passwords for a username. 145 | - Sleep between 10 seconds and 1 minute between guesses. 146 | - Make no additional guesses for that user until after at least 31 147 | minutes pass. 148 | 149 | ``````python 150 | import bruteloops 151 | config = bruteloops.models.Config( 152 | db_file='/tmp/test.db', 153 | max_auth_tries=3, 154 | authentication_jitter=dict(min='10s', max='1m'), 155 | max_auth_jitter=dict(min='31m', max='1h'), 156 | authentication_callback=lambda username, password: True) 157 | `````` 158 | 159 | --- 160 | 161 | ::: bruteloops.models.Jitter 162 | 163 | --- 164 | 165 | ::: bruteloops.jitter.JitterTime 166 | 167 | --- 168 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | BruteLoops abstracts common logic found in password guessing 2 | utilties and implements it in a library, allowing it to be reused 3 | it in any Linux Python application. 4 | 5 | The following flowchart illustrates the classes and methods involved 6 | with generating a database and initiating an attack. 7 | 8 | - The [`bruteloops.db_manager.Manager`](/pkg-docs/db_manager/#bruteloops.db_manager.Manager) 9 | class provides comprehensive methods to create a database and 10 | manage authentication data, 11 | i.e. usernames and passwords. 12 | - The [`bruteloops.brute.BruteForcer`](/pkg-docs/brute/#bruteloops.brute.BruteForcer) 13 | class provides the `launch` method that initiates a perpetual 14 | control loop that orchestrates credentials guesses until all 15 | values are exhausted. 16 | 17 | ``````mermaid 18 | flowchart 19 | db-manager("bruteloops.db_manager.Manager") 20 | db-file("SQLite File") 21 | brute-forcer("bruteloops.brute.BruteForcer.launch()") 22 | control-loop((("Control 23 | Loop"))) 24 | db-manager -->|Creates/Populates| db-file 25 | brute-forcer -->|Starts| control-loop 26 | control-loop -->|Reads/Updates| db-file 27 | 28 | loop-note(" 29 | Orchestrates and guesses 30 | credentials") 31 | loop-note -..-> control-loop 32 | `````` 33 | 34 | Remaining sections of this document provide a high-level overview 35 | of key concepts implementers will need to understand to implement 36 | the API effectively. 37 | 38 | ### Control Loop 39 | 40 | The control loop is responsible for orchestrating authentication 41 | guesses. It queries the SQLite database for credentials that can 42 | be guessed and sends them to an application-defined authentication 43 | callback for guessing. It's engaged by instantiating a 44 | [`bruteloops.brute.BruteForcer`](/pkg-docs/brute/#bruteloops.brute.BruteForcer) 45 | instance with a 46 | [`bruteloops.models.Config`](/pkg-docs/models/#bruteloops.models.Config) 47 | object and calling the `brute()` method. 48 | 49 | The diagram below illustrates high-level logic of the control loop 50 | during execution. 51 | 52 | ``````mermaid 53 | flowchart 54 | 55 | target("Target Auth. Interface") 56 | 57 | wait-for-guessable-creds("Wait for guessable creds") 58 | get-guessable-creds("Query guessable creds") 59 | get-guessable-creds -->|"Update 60 | timestamps"| record-outcome 61 | guess-creds("Guess actionable creds") 62 | auth-callback("Execute Auth. Callback") 63 | record-outcome("Update DB") 64 | except{" 65 | Exceptions 66 | raised?"} 67 | record-outcome --> except 68 | exit-loop("Exit Control Loop") 69 | except -->|Yes| breakers 70 | 71 | subgraph except-handling["Exception Handling"] 72 | breakers("Breakers") 73 | standard-handlers("Standard Handlers") 74 | breakers --> breaker-trip 75 | breaker-trip{"Breaker 76 | tripped?"} 77 | breaker-trip -->|No| standard-handlers 78 | end 79 | 80 | except -->|No| attack-finished 81 | attack-finished{"Attack 82 | finished?"} 83 | attack-finished -->|Yes| exit-loop 84 | attack-finished -->|No| wait-for-guessable-creds 85 | breaker-trip -->|Yes| exit-loop 86 | 87 | auth-callback -->|Hits| target 88 | 89 | 90 | wait-for-guessable-creds --> get-guessable-creds --> guess-creds 91 | guess-creds --->|" 92 | Send to 93 | callback"| auth-callback 94 | auth-callback --->|" 95 | Returns 96 | outcome"| guess-creds 97 | guess-creds --->|"Update cred to 98 | valid/invalid"| record-outcome 99 | `````` 100 | 101 | ### Authentication Callback 102 | 103 | The authentication callback is a function defined by the implementing 104 | application. It's passed to the control loop at runtime. The code block 105 | below outlines the required function signature for authentication callbacks: 106 | 107 | ``````python 108 | def my_auth_callback(username:str password:str) -> dict: 109 | '''Contrived callback to illustrate function signature. 110 | 111 | Args: 112 | username: String username to guess. 113 | password: String password to guess. 114 | 115 | Returns: 116 | Dictionary value conforming to `bruteloops.model.Output` 117 | ''' 118 | 119 | # Must return a dictionary 120 | out = dict(outcome=0) 121 | 122 | # ...guess the credential... 123 | if guess.valid: out['outcome'] = 1 124 | 125 | return out 126 | `````` 127 | 128 | Return value and arguments aside, there are no limitations applied to this 129 | function. The implementing application can be as creative as it needs to bo 130 | to guess a given credential. 131 | 132 | ### Data Management 133 | 134 | As mentioned previously, all data is stored and maintained in an SQLite database. 135 | BruteLoops provides classes and methods to support database interaction. See the 136 | following for additional information on this: 137 | 138 | - [Database Management Reference](/reference/db_management/) for examples of support 139 | components. 140 | - [SQL Documentation](/pkg-docs/sql) for the database schema. 141 | 142 | ### Breakers 143 | 144 | Breakers behave like circuit breakers, providing a safety capability that will 145 | terminate an attack when undesirable conditions are observed. This capability is 146 | provided to avoid sweeping lockout events and denial of service conditions. See 147 | the following for more information on breakers: 148 | 149 | - [Reference Documentation](/reference/breakers) 150 | - Relevant models: 151 | - [`bruteloops.models.Breaker`](/pkg-docs/models/#bruteloops.models.Breaker) 152 | - [`bruteloops.models.ThresholdBreaker`](/pkg-docs/models/#bruteloops.models.ThresholdBreaker) 153 | -------------------------------------------------------------------------------- /bruteloops/logging.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from sys import stdout,stderr 3 | import logging 4 | from functools import wraps 5 | from datetime import datetime, timezone as TZ 6 | from zoneinfo import ZoneInfo 7 | from .errors import TimezoneError, LoggingError 8 | 9 | # ================ 10 | # GENERAL DEFAULTS 11 | # ================ 12 | 13 | LOG_FORMAT='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 14 | DEFAULT_TZ = 'UTC' 15 | 16 | # ===================== 17 | # DEFINE LOGGING LEVELS 18 | # ===================== 19 | 20 | GENERAL_EVENTS = 90 21 | CREDENTIAL_EVENTS = 85 22 | VALID_CREDENTIALS = 80 23 | SLEEP_EVENTS = 70 24 | INVALID_USERNAME = 60 25 | 26 | logging.addLevelName(INVALID_USERNAME, 'INVALID_USERNAME') 27 | logging.addLevelName(SLEEP_EVENTS,'SLEEP_EVENT') 28 | logging.addLevelName(VALID_CREDENTIALS,'VALID') 29 | logging.addLevelName(CREDENTIAL_EVENTS,'INVALID') 30 | logging.addLevelName(GENERAL_EVENTS,'GENERAL') 31 | 32 | # ========================= 33 | # LEVEL LOOKUP DICTIONARIES 34 | # ========================= 35 | 36 | LEVEL_LOOKUP = dict(general=GENERAL_EVENTS, 37 | valid=VALID_CREDENTIALS, 38 | invalid=CREDENTIAL_EVENTS, 39 | invalid_username=INVALID_USERNAME) 40 | 41 | SYNONYM_LOOKUP = dict( 42 | general=('general','general-events','general-event',), 43 | valid=('valid','valid-credentials','valid-credential',), 44 | invalid=('invalid','invalid-credentials','invalid-credential',), 45 | invalid_username=('invalid-usernames','invalid-username',)) 46 | 47 | def lookup_log_level(level:str) -> int: 48 | '''Derive logging level from string name. 49 | 50 | Args: 51 | level: Name of the level to reveal. 52 | 53 | Returns: 54 | `int` logging level. 55 | 56 | Raises: 57 | bruteloops.errors.LoggingError: when an invalid logging level 58 | is provided. 59 | ''' 60 | 61 | for level_key, synonyms in SYNONYM_LOOKUP.items(): 62 | 63 | if level in synonyms: 64 | break 65 | level_key = None 66 | 67 | if not level_key: 68 | 69 | raise LoggingError.invalidLevelName( 70 | f'Invalid log level supplied: {level}') 71 | 72 | return LEVEL_LOOKUP[level_key] 73 | 74 | def init_handler(logger, klass, formatter, log_format=LOG_FORMAT, 75 | klass_args=None): 76 | 77 | klass_args = klass_args if klass_args else list() 78 | 79 | handler = klass(*klass_args) 80 | handler.setFormatter(formatter(fmt=log_format)) 81 | logger.addHandler(handler) 82 | 83 | def do_log(level:int): 84 | '''Decorator to simplify custom logging levels. 85 | 86 | Args: 87 | level: The custom level to pass to the decorated 88 | logging method. 89 | ''' 90 | 91 | def decorator(f): 92 | 93 | @wraps(f) 94 | def wrapper(logger, m:str): 95 | 96 | # Log with the proper level 97 | logger.log(level, m) 98 | 99 | return wrapper 100 | 101 | return decorator 102 | 103 | class BruteLogger(logging.Logger): 104 | 105 | @do_log(SLEEP_EVENTS) 106 | def sleep(self, m:str): 107 | '''Log sleep events. 108 | 109 | Args: 110 | m: The string message to log. 111 | ''' 112 | 113 | pass 114 | 115 | @do_log(VALID_CREDENTIALS) 116 | def valid(self, m:str): 117 | '''Log valid credential events. 118 | 119 | Args: 120 | m: The string message to log. 121 | ''' 122 | 123 | pass 124 | 125 | @do_log(CREDENTIAL_EVENTS) 126 | def invalid(self, m:str): 127 | '''Log invalid credential events. 128 | 129 | Args: 130 | m: The string message to log. 131 | ''' 132 | 133 | pass 134 | 135 | @do_log(INVALID_USERNAME) 136 | def invalid_username(self, m:str): 137 | '''Log invalid username events. 138 | 139 | Args: 140 | m: The string message to log. 141 | ''' 142 | 143 | pass 144 | 145 | @do_log(CREDENTIAL_EVENTS) 146 | def credential(self, m:str): 147 | '''Log credential events. 148 | 149 | Args: 150 | m: The string message to log. 151 | ''' 152 | 153 | pass 154 | 155 | @do_log(GENERAL_EVENTS) 156 | def module(self, m:str): 157 | '''Log module events. 158 | 159 | Args: 160 | m: The string message to log. 161 | ''' 162 | 163 | pass 164 | 165 | @do_log(GENERAL_EVENTS) 166 | def general(self, m:str): 167 | '''Log general events. 168 | 169 | Args: 170 | m: The string message to log. 171 | ''' 172 | 173 | pass 174 | 175 | if logging.getLoggerClass() != BruteLogger: 176 | logging.setLoggerClass(BruteLogger) 177 | 178 | def formatterFactory(timezone:str=None): 179 | 180 | # Update the TIMEZONE class property to a ZoneInfo 181 | try: 182 | if timezone == None: 183 | timezone = ZoneInfo(DEFAULT_TZ) 184 | else: 185 | timezone = ZoneInfo(timezone) 186 | except Exception as e: 187 | raise TimezoneError.invalidTZ(timezone, extra=e) 188 | 189 | class Formatter(logging.Formatter): 190 | 191 | @property 192 | def timezone(self): 193 | return timezone 194 | 195 | def converter(self, timestamp): 196 | out = datetime.fromtimestamp( 197 | timestamp, 198 | self.timezone).timetuple() 199 | return out 200 | 201 | return Formatter 202 | 203 | DEFAULT_FORMATTER = formatterFactory() 204 | 205 | def getLogger(name, log_level='invalid', log_format=LOG_FORMAT, 206 | log_file=None, log_stdout=False, log_stderr=True, 207 | timezone=None, formatter=DEFAULT_FORMATTER): 208 | '''Configure a logger for the library. 209 | ''' 210 | 211 | logger = logging.getLogger(name) 212 | logger.propagate = False 213 | 214 | if timezone is not None and formatter == DEFAULT_FORMATTER: 215 | 216 | # ============================== 217 | # OVERRIDE WITH CUSTOM FORMATTER 218 | # ============================== 219 | 220 | formatter = formatterFactory(timezone) 221 | 222 | # ================== 223 | # CONFIGURE HANDLERS 224 | # ================== 225 | 226 | if log_file: 227 | 228 | init_handler( 229 | logger=logger, 230 | klass=logging.FileHandler, 231 | klass_args=[log_file], 232 | formatter=formatter, 233 | log_format=log_format) 234 | 235 | if log_stdout: 236 | 237 | init_handler( 238 | logger=logger, 239 | klass=logging.StreamHandler, 240 | klass_args=[stderr], 241 | formatter=formatter, 242 | log_format=log_format) 243 | 244 | if log_stderr: 245 | 246 | init_handler( 247 | logger=logger, 248 | klass=logging.StreamHandler, 249 | klass_args=[stderr], 250 | formatter=formatter, 251 | log_format=log_format) 252 | 253 | # ================= 254 | # SET LOGGING LEVEL 255 | # ================= 256 | 257 | if isinstance(log_level, str): 258 | logger.setLevel( 259 | lookup_log_level(log_level)) 260 | else: 261 | logger.setLevel(log_level) 262 | 263 | return logger 264 | -------------------------------------------------------------------------------- /tests/test_db_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pdb 3 | from sqlalchemy import ( 4 | select, 5 | func 6 | ) 7 | from bruteloops import sql, models, db_manager 8 | from typing import ( 9 | Union, 10 | List,) 11 | from pathlib import Path 12 | from uuid import uuid4 as UUID 13 | 14 | def cb(username, password): 15 | raise Exception('Intentional failure') 16 | CONFIG = dict(authentication_callback=cb) 17 | 18 | class InvalidRowCountError(Exception): 19 | pass 20 | 21 | def gen_values(prefix:str, count:int, credentials:bool=False, 22 | cred_del=':', tmp_path:Union[Path, None]=None, 23 | uuid:bool=False) -> List[str]: 24 | '''Dynamically generate database values. 25 | 26 | Args: 27 | prefix: Prefix appended to each value. 28 | count: The number of records to generate. 29 | credentials: Determines if credential values should be 30 | generated. 31 | cred_del: Delimiter to use when generating credentials. 32 | tmp_path: Path to the directory where values should be 33 | written to disk. Ignored when set to None. 34 | uuid: Determines if UUID values should be generated. 35 | 36 | Returns: 37 | - List of str when not tmp_path is None 38 | - List of str paths pointing to generated files 39 | ''' 40 | 41 | if tmp_path is not None: 42 | tmp_file = tmp_path / f'{prefix}_values.txt' 43 | values = tmp_file.open('w+') 44 | else: 45 | tmp_file = None 46 | values = list() 47 | 48 | # =================== 49 | # GENERATE THE VALUES 50 | # =================== 51 | 52 | for n in range(1, count+1): 53 | 54 | if uuid == True: 55 | n = f'-{n}-{str(UUID())}' 56 | 57 | if credentials: 58 | # Create a credential to be parsed 59 | v = f'cu{n}{cred_del}cp{n}' 60 | else: 61 | # Just a standard input value 62 | v = prefix+str(n) 63 | 64 | if tmp_file: 65 | values.write(v+'\n') 66 | else: 67 | values.append(v) 68 | 69 | if tmp_file: 70 | values.close() 71 | return [tmp_file] 72 | 73 | return values 74 | 75 | def insert_values(config:models.Config, dbm:db_manager.Manager, 76 | data_type:str, count:int=1, credentials:bool=False, 77 | associate_spray:bool=False, 78 | cred_del:str=':', uuid:bool=False, check_rows:bool=True): 79 | '''Use BL.db_manager.Manager to insert records directly into a 80 | database. Logic will also check to make sure that the proper number 81 | of values have been inserted. 82 | 83 | Args: 84 | config: Config object to reference. 85 | dbm: Manager instance used to interface with the database. 86 | data_type: Datatype to generate. Valid options: 87 | userername, password, credential 88 | count: Count of values to insert. 89 | associate_spray: Determines if spray values should be 90 | associated. 91 | credentials: Determines if the values should be treated as 92 | strict credentials. Relevant only when the data_type value 93 | is set to credential. 94 | check_rows: Determines if rows should be checked. 95 | 96 | Raises: 97 | - InvalidRowCountError when an invalid number of rows is observed 98 | after the insert. 99 | ''' 100 | 101 | valid_types = ('username','password','credential',) 102 | if not data_type in valid_types: 103 | raise ValueError( 104 | f'Invalid data type supplied: {data_type}. ' 105 | f'Allowed: {", ".join(valid_types)}' 106 | ) 107 | 108 | handle = f'insert_{data_type}_records' 109 | 110 | # Generate values for the method call 111 | values = gen_values(data_type[0], count, 112 | credentials=(data_type == 'credential'), 113 | cred_del=cred_del, uuid=uuid) 114 | 115 | # Kwargs for the method call 116 | kwargs = dict( 117 | container=values, 118 | associate_spray_values=associate_spray) 119 | 120 | # Update kwargs for credential method calls 121 | if data_type == 'credential': 122 | kwargs.update( 123 | dict( 124 | as_credentials = not associate_spray 125 | ) 126 | ) 127 | 128 | # Call the method 129 | getattr(dbm, handle)(**kwargs) 130 | 131 | # Count the inserted values 132 | table = getattr(sql, data_type.capitalize()) 133 | 134 | if check_rows: 135 | 136 | count_rows( 137 | dbm, 138 | table, 139 | count*(count if credentials and associate_spray else 1)) 140 | 141 | return values 142 | 143 | def insert_via_manage(config:models.Config, dbm:db_manager.Manager, 144 | data_type:str, count:int=1, associate_spray:bool=False, 145 | tmp_path:Path=None, uuid:bool=False, check_rows:bool=True): 146 | '''Insert username and password values via manage_values. All 147 | arguments mirror insert_values. 148 | ''' 149 | 150 | model = sql.Username 151 | if data_type == 'username': 152 | model = sql.Password 153 | 154 | values = gen_values(data_type[0], count, tmp_path=tmp_path, 155 | uuid=uuid) 156 | 157 | dbm.manage_values(model=model, container=values, 158 | is_file=True if tmp_path is not None else False, 159 | associate_spray_values=associate_spray) 160 | 161 | if check_rows: 162 | count_rows(dbm, model, count) 163 | 164 | def credentials_via_manage(config:models.Config, 165 | dbm:db_manager.Manager, insert:bool=True, 166 | count:int=1, credentials:bool=False, 167 | associate_spray:bool=False, tmp_path:Path=None, 168 | uuid:bool=False, check_rows:bool=True): 169 | '''Insert credentials via manage_credentials. All arguments mirrior 170 | insert_values. 171 | ''' 172 | 173 | values = gen_values('c', count, tmp_path=tmp_path, uuid=uuid, credentials=True) 174 | dbm.manage_credentials(container=values, 175 | insert=insert, 176 | is_file=(True if tmp_path else False), 177 | associate_spray_values=associate_spray, 178 | as_credentials=credentials) 179 | 180 | if check_rows: 181 | count_rows(dbm, sql.Credential, count) 182 | 183 | def count_rows(dbm, table, expected_count): 184 | 185 | out = dbm.main_db_sess.query( 186 | func.count( 187 | table.id 188 | ) 189 | ).scalar() 190 | 191 | if not out or out != expected_count: 192 | raise InvalidRowCountError( 193 | f'Incorrect count of records returned. Got: {out}, ' 194 | f'Expected: {expected_count}') 195 | 196 | # ========================= 197 | # INSERT INDIVIDUAL RECORDS 198 | # ========================= 199 | 200 | def test_insert_usernames(setup): 201 | 202 | config, dbm = setup 203 | insert_values(config, dbm, 'username', count=10) 204 | 205 | def test_insert_passwords(setup): 206 | 207 | config, dbm = setup 208 | insert_values(config, dbm, 'password', count=10) 209 | 210 | def test_insert_strict_credentials(setup): 211 | 212 | config, dbm = setup 213 | insert_values(config, 214 | dbm, 'credential', count=10, 215 | credentials=True) 216 | 217 | def test_insert_spray_credentials(setup): 218 | 219 | config, dbm = setup 220 | insert_values(config, 221 | dbm, 'credential', count=10, 222 | credentials=True, associate_spray=True) 223 | 224 | def test_associate_all_values(setup): 225 | 226 | ucount = 10 227 | pcount = 5 228 | 229 | config, dbm = setup 230 | insert_values(config, dbm, 'username', count=ucount) 231 | insert_values(config, dbm, 'password', count=pcount) 232 | 233 | with pytest.raises(InvalidRowCountError): 234 | # Records aren't associated yet, so this should raise 235 | # an exception. 236 | count_rows(dbm, sql.Credential, ucount*pcount) 237 | 238 | # Associate the records 239 | dbm.associate_spray_values() 240 | count_rows(dbm, sql.Credential, ucount*pcount) 241 | 242 | # ========================= 243 | # INSERT VIA MANAGE RECORDS 244 | # ========================= 245 | 246 | def test_insert_usernames_via_manage(setup): 247 | 248 | config, dbm = setup 249 | insert_via_manage(config, dbm, 'username', count=10) 250 | 251 | def test_insert_values_via_file(setup, tmp_path): 252 | 253 | config, dbm = setup 254 | insert_via_manage(config, dbm, 'username', count=10, tmp_path=tmp_path) 255 | insert_via_manage(config, dbm, 'password', count=10, tmp_path=tmp_path, 256 | associate_spray=True) 257 | count_rows(dbm, sql.Credential, 100) 258 | 259 | def test_insert_passwords_via_manage(setup): 260 | 261 | config, dbm = setup 262 | insert_via_manage(config, dbm, 'password', count=10) 263 | 264 | def test_associate_after_manage_passwords(setup): 265 | 266 | ucount = 30 267 | pcount = 5 268 | 269 | config, dbm = setup 270 | 271 | insert_via_manage(config, dbm, 'username', count=ucount) 272 | insert_via_manage(config, dbm, 'password', count=pcount, 273 | associate_spray=True) 274 | count_rows(dbm, sql.Credential, (ucount*pcount)) 275 | 276 | def test_manage_credentials(setup): 277 | 278 | count = 100 279 | fcount = 10 280 | config, dbm = setup 281 | 282 | # Insert and count the records 283 | credentials_via_manage(config, dbm, count=count, 284 | credentials=True) 285 | values = credentials_via_manage(config, dbm, count=fcount, 286 | credentials=True, check_rows=False, uuid=True) 287 | count_rows(dbm, sql.Credential, (count+fcount)) 288 | 289 | # Delete a record and make sure it's updated accordingly 290 | credentials_via_manage(config, dbm, count=1, 291 | credentials=True, check_rows=False, insert=False) 292 | count_rows(dbm, sql.Credential, (count+fcount)-1) 293 | -------------------------------------------------------------------------------- /bruteloops/sql.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import (Column, Integer, String, DateTime, ForeignKey, 2 | Boolean, Float, UniqueConstraint) 3 | from sqlalchemy.orm import relationship, backref 4 | from sqlalchemy.ext.declarative import declarative_base 5 | 6 | Base = declarative_base() 7 | 8 | class Username(Base): 9 | '''Tracks username values. 10 | 11 | Attributes: 12 | id: Row ID. 13 | value (str): String value. 14 | recovered (bool): Determines if a valid password has been 15 | recovered for the username.. 16 | actionable (bool): Determines if guesses should be made for a given 17 | username, allowing implementers to disable invalid values 18 | at execution time. 19 | priority (bool): Determines if the username should be prioritized 20 | over other non-priority usernames. 21 | last_time (float): Dictates the last time a password was guessed for 22 | a username. 23 | future_time (float): The next time a password can be guessed for a 24 | username. 25 | passwords: A relationship with password values established 26 | via the `credentials` table. 27 | credentals: A relationship established directly with the 28 | `credentials` table. 29 | 30 | Notes: 31 | - For simplicity, timestamps are stored as float values. 32 | - By default, timestamp values are -1, a value that clearly 33 | indicates if the timestamp has been set by BruteLoops logic. 34 | ''' 35 | 36 | __tablename__ = 'usernames' 37 | __mapper_args__ = {'confirm_deleted_rows': False} 38 | 39 | id = Column(Integer, primary_key=True, 40 | doc='Username id') 41 | 42 | value = Column(String, nullable=False, unique=True, 43 | doc='Username value') 44 | 45 | recovered = Column(Boolean, default=False, 46 | doc='Determines if a valid password has been recovered.') 47 | 48 | actionable = Column(Boolean, default=True, 49 | doc='Determines if the account is actionable, removing ' 50 | 'it from additional guesses when set to False.') 51 | 52 | priority = Column(Boolean, default=False, 53 | doc='Determines if the user is prioritized.') 54 | 55 | last_time = Column(Float, default=-1.0, 56 | doc='Last time when username was targeted for authentication.') 57 | 58 | future_time = Column(Float, default=-1.0, 59 | doc='Time when username can be targeted for authentication again.') 60 | 61 | # ORM Relationships 62 | passwords = relationship("Password", 63 | secondary="credentials", 64 | viewonly=True) 65 | 66 | credentials = relationship("Credential", 67 | cascade="all, delete, delete-orphan") 68 | 69 | def __repr__(self): 70 | 71 | return f'' 74 | 75 | class Password(Base): 76 | '''Tracks password values. 77 | 78 | Attributes: 79 | id: Row ID. 80 | value (str): String value. 81 | priority (bool): Determines if the password should be prioritized 82 | over other non-priority passwords. 83 | sprayable (bool): Convenience attribute that determines if a given 84 | password can be used in spray attacks. 85 | usernames: A relationship established vai the `credentials` 86 | table that provides access to all usernames associated with 87 | the password. 88 | credentials: A directly relationship to the `credentials` 89 | table. 90 | ''' 91 | 92 | __tablename__ = 'passwords' 93 | __mapper_args__ = {'confirm_deleted_rows': False} 94 | 95 | id = Column(Integer, primary_key=True, doc='Password id') 96 | 97 | value = Column(String, nullable=False, unique=True, 98 | doc='Password value') 99 | 100 | priority = Column(Boolean, default=False, 101 | doc='Determines if the password is prioritized') 102 | 103 | sprayable = Column(Boolean, default=True, 104 | doc='Determines if the password can be used as a spray value.') 105 | 106 | # ORM Relationships 107 | usernames = relationship("Username", 108 | secondary="credentials", 109 | viewonly=True) 110 | 111 | credentials = relationship("Credential", 112 | cascade="all, delete, delete-orphan") 113 | 114 | def __repr__(self): 115 | 116 | return f'' 117 | 118 | class Credential(Base): 119 | '''An association, i.e. lookup or join, table used to associate 120 | usernames with passwords. This avoids bloat of duplicate 121 | username/password values at the cost of query complexity. 122 | 123 | Attributes: 124 | id: Row ID. 125 | username_id (int): Foreign key to `usernames`. 126 | password_id (int): Foreign key to `passwords`. 127 | password: Relationship to the associated `Password` object. 128 | username: Relationship to the `Username` object. 129 | strict_credential: Relationship to the `strict_credentials` 130 | table. 131 | valid (bool): Determines if the credential record is valid. 132 | strict (bool): Determines if the associate password should be used 133 | in spray attacks. 134 | guessed (bool): Determines if a guess has been made for the 135 | credential. 136 | guess_time (float): Determines when the credential was guessed. 137 | ''' 138 | 139 | __tablename__ = 'credentials' 140 | __mapper_args__ = {'confirm_deleted_rows': False} 141 | __table_args__ = ( 142 | UniqueConstraint('username_id','password_id', 143 | name='_credential_unique_constraint'), 144 | ) 145 | 146 | id = Column(Integer, doc='Credential id', autoincrement="auto", primary_key=True) 147 | 148 | # Foreign keys 149 | username_id = Column(Integer, ForeignKey('usernames.id', 150 | ondelete='CASCADE'), doc='Username id', nullable=False) 151 | 152 | password_id = Column(Integer, ForeignKey('passwords.id', 153 | ondelete='CASCADE'), doc='Password id', nullable=False) 154 | 155 | # ORM Relationships 156 | password = relationship("Password", back_populates="credentials") 157 | 158 | username = relationship("Username", back_populates="credentials") 159 | 160 | strict_credential = relationship("StrictCredential", 161 | back_populates="credential", cascade="all, delete, delete-orphan") 162 | 163 | # Attributes 164 | valid = Column(Boolean, default=False, 165 | doc='Determines if the credentials are valid') 166 | 167 | strict = Column(Boolean, default=False, 168 | doc='Determines if the credentials are strict, i.e. the associated ' 169 | 'password should not be used in spray attacks.') 170 | 171 | guessed = Column(Boolean, default=False, 172 | doc='Determines if the credentials have been guessed') 173 | 174 | guess_time = Column(Float, default=-1.0, 175 | doc='Time when the guess occurred') 176 | 177 | def __repr__(self): 178 | return f'' 182 | 183 | class StrictCredential(Base): 184 | '''A table of credential IDs that are strict. Seemingly redundant, 185 | this table will generally contain less records than `credentials` 186 | and responds faster to queries/joins. 187 | 188 | Attributes: 189 | id (int): Row ID. 190 | credential_id (int): ID of the credential that is strict. 191 | credential (int): Relationship to the `Credential` record. 192 | 193 | Notes: 194 | - Database-level cascade is used to remove records from this 195 | table. 196 | ''' 197 | 198 | __tablename__ = 'strict_credentials' 199 | __mapper_args__ = dict(confirm_deleted_rows=False) 200 | __table_args__ = ( 201 | UniqueConstraint('credential_id', 202 | name='_unique_credential_id'), 203 | ) 204 | 205 | id = Column(Integer, doc='Credential id', 206 | autoincrement="auto", primary_key=True) 207 | 208 | credential_id = Column(Integer, ForeignKey('credentials.id', 209 | ondelete='CASCADE'), doc='Credential id', nullable=False) 210 | 211 | credential = relationship("Credential") 212 | 213 | class PriorityCredential(Base): 214 | '''A table of credential IDs associated with records that are 215 | associated with either priority usernames or passwords. Similar 216 | to `StrictCredential`, this table will almost certainly have 217 | fewer records and will respond quickly to queries. 218 | 219 | Attributes: 220 | id (int): Row ID. 221 | credential_id (int): ID of the associated credential. 222 | credential (int): Relationship to the credential record. 223 | 224 | Notes: 225 | - Database-level cascade is used to remove records from this 226 | table. 227 | ''' 228 | 229 | __tablename__ = 'priority_credentials' 230 | __mapper_args__ = dict(confirm_deleted_rows=False) 231 | __table_args__ = ( 232 | UniqueConstraint('credential_id', 233 | name='_unique_credential_id'), 234 | ) 235 | 236 | id = Column(Integer, doc='Priority credential id', 237 | autoincrement="auto", primary_key=True) 238 | 239 | credential_id = Column(Integer, ForeignKey('credentials.id', 240 | ondelete='CASCADE'), doc='Credential id', nullable=False) 241 | 242 | credential = relationship("Credential") 243 | 244 | class Attack(Base): 245 | '''A table to track execution of BruteLoops attacks. 246 | 247 | Attributes: 248 | id (int): Id of the attack. 249 | start_time (float): Start time of the attack. 250 | end_time (float): End time of the attack. 251 | complete (bool): Determines if the attack completed. 252 | ''' 253 | 254 | __tablename__ = 'attacks' 255 | __mapper_args__ = dict(confirm_deleted_rows=False) 256 | 257 | id = Column(Integer, primary_key=True, 258 | doc='Attack id') 259 | start_time = Column(Float, nullable=False, 260 | doc='Time of attack initialization') 261 | end_time = Column(Float, nullable=True, doc='Time of attack end') 262 | complete = Column(Boolean, default=False, 263 | doc='Determines if the attack completed') 264 | 265 | def __repr__(self): 266 | 267 | return f'' 274 | -------------------------------------------------------------------------------- /bruteloops/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from zoneinfo import ZoneInfo 3 | from time import strptime 4 | from .logging import LOG_FORMAT 5 | 6 | class BoolAction(argparse.Action): 7 | 8 | def __call__(self, parser, namespace, values, option_string=None): 9 | 10 | setattr(namespace, self.dest, False) 11 | if values in ['true','True']: 12 | setattr(namespace, self.dest, True) 13 | 14 | class TimezoneAction(argparse.Action): 15 | 16 | def __call__(self, parser, namespace, values, option_string=None): 17 | 18 | try: 19 | 20 | ZoneInfo(values) 21 | 22 | except Exception: 23 | 24 | raise ValueError( 25 | 'Invalid timezone value supplied. See the ' 26 | '"TZ database name" column of the following resource ' 27 | 'for valid values: ' 28 | 'https://en.wikipedia.org/wiki/List_of_tz_database_ti' 29 | 'me_zones') 30 | 31 | setattr(namespace, self.dest, values) 32 | 33 | class BlackoutModelAction(argparse.Action): 34 | 35 | def __call__(self, parser, namespace, values, option_string=None): 36 | 37 | values = values.split('-') 38 | 39 | if not len(values) == 2: 40 | 41 | raise ValueError( 42 | f'Invalid format used for blackout window: {values}. ' 43 | 'It must be a hyphen delimited pair of time ranges in ' 44 | 'H:M:S format.' 45 | ) 46 | 47 | start = strptime(values[0], '%H:%M:%S') 48 | stop = strptime(values[1], '%H:%M:%S') 49 | 50 | setattr(namespace, 'blackout_start', start) 51 | setattr(namespace, 'blackout_stop', stop) 52 | 53 | 54 | class ParseJitterAction(argparse.Action): 55 | 56 | def __call__(self, parser, namespace, values, option_string=None): 57 | 58 | if hasattr(namespace, self.dest): 59 | 60 | # If it has already been set, then the jitter has probably 61 | # been disabled. 62 | pass 63 | 64 | else: 65 | 66 | base_handle = '_'.join(self.dest.split('_')[0:2]) 67 | 68 | if values in ('None','none','0','null','false','False',): 69 | 70 | # Set both the min and max to None when set to these 71 | # values. 72 | for v in ('min', 'max',): 73 | setattr(namespace, base_handle+f'_{v}', None) 74 | 75 | else: 76 | 77 | # Isn't disabled, so we set the value 78 | setattr(namespace, self.dest, values) 79 | 80 | # ================== 81 | # GENERAL PARAMETERS 82 | # ================== 83 | 84 | PARALLEL_GUESS_COUNT = \ 85 | '''Number of processes to use during the attack, determinining the 86 | count of parallel authentication attempts that are performed. 87 | Default: %(default)s. 88 | ''' 89 | 90 | AUTH_THRESHOLD = \ 91 | '''Inclusive number of passwords to guess for a given username before 92 | jittering for a time falling within the bounds of the values 93 | specified for "Threshold Jitter". Default: %(default)s 94 | ''' 95 | 96 | STOP_ON_VALID = \ 97 | '''Stop the brute force attack when valid credentials are recovered. 98 | ''' 99 | 100 | PRIORITY_USERNAMES = \ 101 | '''Usernames to prioritize over all others when guessing, moving 102 | them to the front of the guess queue. 103 | ''' 104 | 105 | PRIORITY_PASSWORDS = \ 106 | '''Passwords to prioritize over all others when guessing, moving 107 | them to the front of the guess queue. 108 | ''' 109 | 110 | BLACKOUT_WINDOW = ( 111 | 'Window of time where no additional guesses should be performed at ' 112 | 'all. Useful in situation where attacks are to be restricted to ' 113 | 'specific testing windows. The range format should be two time values ' 114 | '"H:M:S" separated by a hyphen ("-") character. Hours should be ' 115 | 'provided in 24-hour format, eg 13 for 1PM. Example: ' 116 | '17:00:00-09:00:00') 117 | 118 | gp = general_parser = argparse.ArgumentParser(add_help=False) 119 | ('argparse.ArgumentParser for common attack arguments.') 120 | 121 | gg = general_group = gp.add_argument_group('General Parameters', 122 | 'Options used to configure general attack parameters') 123 | gg.add_argument('--parallel-guess-count','-pgc', 124 | type=int, 125 | default=1, 126 | help=PARALLEL_GUESS_COUNT, 127 | dest='process_count') 128 | gg.add_argument('--auth-threshold','-at', 129 | type=int, 130 | default=1, 131 | help=AUTH_THRESHOLD, 132 | dest='max_auth_tries') 133 | gg.add_argument('--stop-on-valid','-sov', 134 | action=argparse.BooleanOptionalAction, 135 | default=False, 136 | help=STOP_ON_VALID) 137 | gg.add_argument('--blackout-window', '-bw', 138 | required=False, 139 | action=BlackoutModelAction, 140 | help=BLACKOUT_WINDOW) 141 | 142 | # =============== 143 | # TIMEZONE PARSER 144 | # =============== 145 | 146 | TIMEZONE = \ 147 | '''Timezone used while deriving timestamps. Be sure to use a 148 | consistent value for this configuration across restarts, otherwise 149 | lockouts may occur. See the "TZ database name" column in this 150 | resource for valid values: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 151 | . (Required: %(required)s) 152 | ''' 153 | 154 | timezone_parser = tz_p = argparse.ArgumentParser(add_help=False) 155 | ('argparse.ArgumentParser object for the Timezone argument.') 156 | 157 | tz_p.add_argument('--timezone','-tz', 158 | action=TimezoneAction, 159 | required=False, 160 | help=TIMEZONE) 161 | 162 | # =============================== 163 | # SCHEDULING TWEAK CONFIGURATIONS 164 | # =============================== 165 | 166 | stp = scheduling_tweaks_parser = argparse.ArgumentParser( 167 | add_help=False) 168 | ('argparse.ArgumentParser object for tweaking guess scheduling.') 169 | 170 | stg = scheduling_tweaks_group = stp.add_argument_group( 171 | 'Scheduling Tweak Parameters', 172 | 'Options used to prioritize username or password values') 173 | stg.add_argument('--prioritize', 174 | action=argparse.BooleanOptionalAction, 175 | default=True, 176 | help='Determine if values should be prioritized or ' 177 | 'unprioritized. Default: %(default)s') 178 | stg.add_argument('--usernames', 179 | nargs='+', 180 | help='Usernames to manage') 181 | stg.add_argument('--passwords', 182 | nargs='+', 183 | help='Passwords to manage') 184 | 185 | # ===================== 186 | # JITTER CONFIGURATIONS 187 | # ===================== 188 | 189 | JITTER_URL = 'https://github.com/arch4ngel/brute_loops/wiki/' 190 | 191 | JITTER_DESCRIPTION = \ 192 | f'''Options used to configure jitter between authentication attempts. 193 | Expects each value expects a specially formatted value like their 194 | defaults. Disable a given form of jitter by setting either the min 195 | or max to one of the following: -1, null, none, false. Please see the 196 | "Jitter Time Format Specification" section of the Wiki URL for more 197 | information on this format: {JITTER_URL} 198 | ''' 199 | 200 | AUTH_JITTER_MINIMUM = \ 201 | '''Minimum length of time to sleep between password guesses for 202 | a given username. Default: %(default)s 203 | ''' 204 | 205 | AUTH_JITTER_MAXIMUM = \ 206 | '''Maximum length of time to sleep between password guesses for 207 | a given username. Default: %(default)s 208 | ''' 209 | 210 | THRESHOLD_JITTER_MINIMUM = \ 211 | '''Minimum length of time to to wait before guessing anymore passwords 212 | after meeting the authentication threshold for a given user, as 213 | specified by the --auth-threshold argument. Default: %(default)s 214 | ''' 215 | 216 | THRESHOLD_JITTER_MAXIMUM = \ 217 | '''Maximum length of time to to wait before guessing anymore passwords 218 | after meeting the authentication threshold for a given user, as 219 | specified by the --auth-threshold argument. Default: %(default)s 220 | ''' 221 | 222 | jp = jitter_parser = argparse.ArgumentParser(add_help=False) 223 | ('argparse.ArgumentParser object providing jitter configurations.') 224 | 225 | jg = jitter_group = jp.add_argument_group('Jitter Parameters', 226 | JITTER_DESCRIPTION) 227 | jg.add_argument('--auth-jitter-min','-ajmin', 228 | default='1s', 229 | help=AUTH_JITTER_MINIMUM) 230 | jg.add_argument('--auth-jitter-max','-ajmax', 231 | default='1.1s', 232 | help=AUTH_JITTER_MAXIMUM) 233 | jg.add_argument('--threshold-jitter-min','-tjmin', 234 | default='1.5h', 235 | help=THRESHOLD_JITTER_MINIMUM) 236 | jg.add_argument('--threshold-jitter-max','-tjmax', 237 | default='2.5h', 238 | help=THRESHOLD_JITTER_MAXIMUM) 239 | 240 | # ===================== 241 | # OUTPUT CONFIGURATIONS 242 | # ===================== 243 | 244 | LOG_FILE = \ 245 | '''Name of the log file to store events stemming from the brute 246 | force attack. Default: %(default)s 247 | ''' 248 | 249 | LOG_STDOUT = \ 250 | '''Enable/disable logging to stdout. 251 | ''' 252 | 253 | LOG_LEVEL = \ 254 | '''Determines the logging level. Default: %(default)s 255 | ''' 256 | 257 | LOG_FORMAT_HELP = \ 258 | '''Logging format string. Default %(default)s . See this URL for 259 | information on the available attributes: 260 | https://docs.python.org/3/library/logging.html#logrecord-attributes 261 | ''' 262 | 263 | op = output_parser = argparse.ArgumentParser(add_help=False) 264 | ('argparse.ArgumentParser object providing various output options.') 265 | 266 | og = output_group = op.add_argument_group('Output Parameters', 267 | 'Options related to output and logging targets') 268 | og.add_argument('--log-file','-lf', 269 | default='brute_log.txt', 270 | help=LOG_FILE) 271 | og.add_argument('--log-stdout', 272 | action=argparse.BooleanOptionalAction, 273 | default=True, 274 | help=LOG_STDOUT, 275 | dest='log_stdout') 276 | #og.add_argument('--log-format', 277 | # default=LOG_FORMAT, 278 | # help=LOG_FORMAT_HELP) 279 | og.add_argument('--log-level', 280 | choices=('general', 281 | 'valid-credentials', 282 | 'invalid-credentials', 283 | 'invalid-usernames'), 284 | default='invalid-usernames', 285 | help=LOG_LEVEL) 286 | 287 | # ============== 288 | # LOGGING LEVELS 289 | # ============== 290 | 291 | #LOG_GENERAL = \ 292 | #'''Manage logging of general events. Default: %(default)s 293 | #''' 294 | # 295 | #LOG_VALID = \ 296 | #'''Manage logging of valid credentials. Default: %(default)s. 297 | #''' 298 | # 299 | #LOG_INVALID = \ 300 | #'''Manage logging of invalid credentials. Default: %(default)s 301 | #''' 302 | # 303 | #LOG_INVALID_USERNAME = \ 304 | #'''Manage logging of invalid usernames. Default: %(default)s 305 | #''' 306 | # 307 | #lp = logging_parser = argparse.ArgumentParser(add_help=False) 308 | #lg = logging_group = lp.add_argument_group('Logging Parameters', 309 | # 'Options related to logging') 310 | # 311 | #lg.add_argument('--log-general', 312 | # action=argparse.BooleanOptionalAction, 313 | # default=True, 314 | # help=LOG_GENERAL, 315 | # dest='log_general') 316 | #lg.add_argument('--log-valid', 317 | # action=argparse.BooleanOptionalAction, 318 | # default=True, 319 | # help=LOG_VALID, 320 | # dest='log_valid') 321 | #lg.add_argument('--log-invalid', 322 | # action=argparse.BooleanOptionalAction, 323 | # default=True, 324 | # help=LOG_INVALID, 325 | # dest='log_invalid') 326 | #lg.add_argument('--log-invalid-usernames', 327 | # action=argparse.BooleanOptionalAction, 328 | # default=True, 329 | # help=LOG_INVALID_USERNAME, 330 | # dest='log_invalid_usernames') 331 | 332 | 333 | # ============ 334 | # INPUT PARSER 335 | # ============ 336 | 337 | INPUT_DESCRIPTION = \ 338 | '''Each of the following values is optional, though there must 339 | be values in the SQLite database to target for attack. Also, 340 | any combination of these values can be combined, as well. 341 | ''' 342 | 343 | USERNAMES = \ 344 | '''Space delimited list of username values to brute force. 345 | ''' 346 | 347 | USERNAME_FILES = \ 348 | '''Space delimited list of files containing newline separated 349 | records of username values to brute force. 350 | ''' 351 | 352 | PASSWORDS = \ 353 | '''Space delimited list of password values to guess. 354 | ''' 355 | 356 | PASSWORD_FILES = \ 357 | '''Space delimited list of files containing newline separated 358 | records of password values to guess. 359 | ''' 360 | 361 | PRIORITIZE_VALUES = \ 362 | '''Mark values as priority in the database. 363 | ''' 364 | 365 | ip = input_parser = argparse.ArgumentParser(add_help=False) 366 | ('argparse.ArgumentParser object providing arguments for input options.') 367 | 368 | ug = username_group = ip.add_argument_group('Username Configurations', 369 | 'Username value and file parameters') 370 | ug.add_argument('--usernames','-us', 371 | nargs='+', 372 | help=USERNAMES) 373 | ug.add_argument('--username-files','-ufs', 374 | nargs='+', 375 | help=USERNAME_FILES) 376 | 377 | pg = password_group = ip.add_argument_group('Password Configurations', 378 | 'Password value and file parameters') 379 | pg.add_argument('--passwords','-ps', 380 | nargs='+', 381 | help=PASSWORDS) 382 | pg.add_argument('--password-files','-pfs', 383 | nargs='+', 384 | help=PASSWORD_FILES) 385 | 386 | # ================= 387 | # CREDENTIAL PARSER 388 | # ================= 389 | 390 | CREDENTIAL_DESCRIPTION = \ 391 | '''Each of the following values is options, though 392 | there must be values in the SQLited atabase to target for 393 | attack. When used in a Spray attack, all passwords will 394 | be used against all accounts during the brute force. When 395 | used in a credential attack, only the matched records will 396 | be attempted. 397 | ''' 398 | 399 | CREDENTIALS = \ 400 | '''Space delimited list of credential values to brute force. 401 | ''' 402 | 403 | CREDENTIAL_FILES = \ 404 | '''Space delimited list of files containing newline separated 405 | : credential records to brute force. 406 | ''' 407 | 408 | AS_CREDENTIALS = \ 409 | '''Flag determining if the input values should be treated as 410 | credential records in the database, not as spray values. This 411 | means that only a single guess will be made using this password 412 | and it will target the supplied username. 413 | ''' 414 | 415 | CREDENTIAL_DELIMITER = \ 416 | '''The character value that delimits the username and password values 417 | of a given credential, for instance ":" would be the proper delimiter 418 | for a given credential "administrator:password123". NOTE: The value of 419 | this field has no affect on the "--csv-files" flag. Default: ":" 420 | ''' 421 | 422 | CSV_FILES = \ 423 | '''Treat the input files as CSV format. Unlike the "--credential-files" 424 | option, this technique uses Python's standard CSV library to parse out 425 | the header file and import the target lines. Note that the "--credenti 426 | al-delimiter" flag has no affect on these inputs. 427 | ''' 428 | 429 | cp = credential_parser = argparse.ArgumentParser(add_help=False) 430 | ('argparse.ArgumentParser object with arguments related to ingesting ' 431 | 'credential values') 432 | 433 | cg = credential_group = cp.add_argument_group( 434 | 'Credential Configurations', 435 | 'Credential record and credential file configurations.') 436 | 437 | cg.add_argument('--credentials','-cs', 438 | nargs='+', 439 | help=CREDENTIALS) 440 | cg.add_argument('--credential-files','-cfs', 441 | nargs='+', 442 | help=CREDENTIAL_FILES) 443 | cg.add_argument('--credential-delimiter', 444 | default=':', 445 | help=CREDENTIAL_DELIMITER) 446 | cg.add_argument('--csv-files', 447 | nargs='+', 448 | help=CSV_FILES) 449 | 450 | #bp = brute_parser = argparse.ArgumentParser(parents=[gp,jp,op,lp,ip,cp]) 451 | bp = brute_parser = argparse.ArgumentParser(parents=[gp,jp,op,ip,cp]) 452 | ('Comprehensive argparse.ArgumentParser object with all key parent parsers.') 453 | -------------------------------------------------------------------------------- /bruteloops/models.py: -------------------------------------------------------------------------------- 1 | '''This module defines Pydantic types that are used to enforce 2 | input validation for implementing programs. 3 | ''' 4 | 5 | import inspect 6 | import datetime 7 | import time 8 | import re 9 | from .enums import * 10 | from .jitter import * 11 | from .brute_time import BruteTime 12 | from .db_manager import Session 13 | #from .callback import Callback 14 | from .errors import BreakerTrippedError 15 | from logging import Logger 16 | from . import logging as BLogging 17 | from pydantic import ( 18 | BaseModel, 19 | Field, 20 | constr, 21 | validator, 22 | FilePath, 23 | Extra) 24 | from typing import ( 25 | Any, 26 | List, 27 | Union, 28 | Optional, 29 | Callable, 30 | Type, 31 | TypeVar) 32 | from functools import wraps 33 | from random import uniform 34 | 35 | EXCEPT_TYPES = (Exception, BaseException,) 36 | EJP = EMPTY_JITTER_PAT = re.compile('^(0(\.0+)?(s|m|h)|None)$') 37 | 38 | def is_exception(t) -> bool: 39 | '''Ensure t is an Exeception instance/subtype. 40 | 41 | Returns: 42 | True when t is an Exception instance/subtype. 43 | 44 | Raises: 45 | TypeError: when t is not a subtype of Exception. 46 | ''' 47 | 48 | # Handle types, i.e. classes 49 | if isinstance(t, type): 50 | if not issubclass(t, EXCEPT_TYPES): 51 | raise TypeError(f'{t} ({type(t)}) is not a subclass of Exception') 52 | 53 | # Handle Exception instances 54 | elif not isinstance(t, EXCEPT_TYPES): 55 | raise TypeError(f'{t} ({type(t)}) is not an Exception instance.') 56 | 57 | return True 58 | 59 | def check_exceptions(*kwargs:List[str]): 60 | '''Ensure that each kwarg maps to one or more Exception 61 | instances/subtypes. 62 | ''' 63 | 64 | # target kwargs 65 | tkwargs = [k for k in kwargs] 66 | 67 | def outer(f): 68 | '''Obtain the decorated function's signature and ensure 69 | that kwarg is a known parameter. 70 | 71 | Raises: 72 | - RuntimeError when kwarg is not a known function 73 | parameter. 74 | ''' 75 | 76 | # Obtain the signature 77 | sig = inspect.signature(f) 78 | skeys = sig.parameters.keys() 79 | 80 | # Ensure all kwargs 81 | for k in tkwargs: 82 | 83 | if not k in skeys: 84 | 85 | raise RuntimeError( 86 | f'Decorated function {f.__name__} does not have ' 87 | f'a {k} parameter.') 88 | 89 | @wraps(f) 90 | def inner(*args, **kwargs): 91 | '''Bind arguments to the signature and ensure that each 92 | kwarg supplied to the decorator maps to an Exception 93 | object. 94 | 95 | Raises: 96 | - TypeError non-exceptions are supplied. 97 | ''' 98 | 99 | # Bind arguments to the signature 100 | try: 101 | bound = sig.bind(*args, **kwargs) 102 | except TypeError: 103 | return f(*args, **kwargs) 104 | 105 | # ==================== 106 | # CHECK ARGUMENT TYPES 107 | # ==================== 108 | 109 | for k in tkwargs: 110 | 111 | if k in bound.arguments: 112 | if isinstance(bound.arguments[k], list): 113 | for e in bound.arguments[k]: 114 | is_exception(e) 115 | else: 116 | is_exception(bound.arguments[k]) 117 | 118 | # Executed the decorated function 119 | return f(*args, **kwargs) 120 | 121 | return inner 122 | 123 | return outer 124 | 125 | def jitter_str_to_float(s:str, field_name:str) -> float: 126 | '''Convert a jitter string to a float value for maths. 127 | 128 | Returns: 129 | Float value representing the number of seconds derived 130 | from s. 131 | 132 | Raises: 133 | ValueError: when an improperly formatted s is supplied. 134 | ''' 135 | 136 | match = JitterTime.validate_time(s) 137 | 138 | if not match: 139 | 140 | raise ValueError( 141 | f'{field_name} is an invalid format: {s}' 142 | ) 143 | 144 | return JitterTime.conv_time_match(match.groupdict()) 145 | 146 | class Jitter(BaseModel, extra=Extra.allow): 147 | '''Jitter objects are used to determine the amount of time to 148 | wait between authentication attempts and after the lockout 149 | threshold is met. 150 | ''' 151 | 152 | min: constr(regex=JITTER_RE) 153 | 'Minimum period of time to sleep.' 154 | 155 | max: constr(regex=JITTER_RE) 156 | 'Maximum period of time to sleep.' 157 | 158 | @validator('min','max') 159 | def val_attr(cls, v, field, values): 160 | 161 | jt = jitter_str_to_float(v, field.name) 162 | 163 | if field.name == 'max': 164 | min = values.get('min') 165 | if not min or (min > jt): 166 | raise ValueError( 167 | 'Jitter minimum must be <= maximum. Got: ' 168 | f'{min} >= {jt}') 169 | 170 | return jt 171 | 172 | def __init__(self, min, max, **kwargs): 173 | '''Override __init__ and capture the original minimum and 174 | maximum for future reference. 175 | ''' 176 | 177 | super().__init__(min=min, max=max, **kwargs) 178 | self.orig_min = min 179 | self.orig_max = max 180 | 181 | def __str__(self): 182 | '''Return a formatted string. 183 | ''' 184 | 185 | return f'' 186 | 187 | def sleep(self): 188 | '''Make the current process sleep for a given period of time 189 | based on the jitter configurations. 190 | ''' 191 | 192 | time.sleep( 193 | self.get_jitter_duration() 194 | ) 195 | 196 | def get_jitter_duration(self): 197 | '''Return a floating point number in the range specified for 198 | jitter time. 199 | ''' 200 | 201 | return uniform(self.min, self.max) 202 | 203 | def get_jitter_future(self, current_time=None): 204 | 205 | current_time = current_time if current_time else \ 206 | BruteTime.current_time() 207 | 208 | return current_time + self.get_jitter_duration() 209 | 210 | class Blackout(BaseModel): 211 | 212 | start: datetime.time 213 | 'Time when blackout period should begn.' 214 | 215 | stop: datetime.time 216 | 'Time when blackout perioud should end.' 217 | 218 | def __str__(self): 219 | 220 | start = self.start.strftime('%H:%M:%S') 221 | stop = self.stop.strftime('%H:%M:%S') 222 | 223 | return f'' 224 | 225 | class ExceptionHandler(BaseModel, arbitrary_types_allowed=True): 226 | '''Instances are used to bind functions to an exception_class, 227 | allowing the control loop to handle arbitrary exceptions. 228 | 229 | Notes: 230 | - Breakers are always engaged before exception handlers. 231 | ''' 232 | 233 | exception_class: Type[BaseException] 234 | 'Exception class to handle.' 235 | 236 | handler: Callable[[BaseException], None] 237 | 'Callback to apply to the exception.' 238 | 239 | class Output(BaseModel, arbitrary_types_allowed=True): 240 | '''Model that validates outputs from authentication callbacks.''' 241 | 242 | outcome: GuessOutcome 243 | 'Guess outcome.' 244 | 245 | username: str 246 | 'Username that was guessed.' 247 | 248 | password: str 249 | 'Password that was guessed.' 250 | 251 | actionable: bool = True 252 | ('Determines if the username is still actionable, i.e. ' 253 | 'removed/disabled from further guesses.') 254 | 255 | events: List[str] = Field(default_factory=list) 256 | 'Events to log.' 257 | 258 | # There's a bug here. Ideally, exception would be 259 | # set to Type[Exception], but it's throwing an odd 260 | # error asserting that the input value should be 261 | # of subtype str. Appears to be a bug in Pydantic. 262 | exception: Any = None 263 | 'Any exception raised during execution.' 264 | 265 | def dict(self, *args, **kwargs): 266 | out = super().dict(*args, **kwargs) 267 | out['outcome'] = out['outcome'].value 268 | return out 269 | 270 | class Outputs(BaseModel): 271 | 'List of output objects.' 272 | 273 | __root__: List[Output] 274 | 'Root type for the Outputs object.' 275 | 276 | class Breaker(BaseModel, validate_assignment=True): 277 | '''Breakers behave like circuit breakers: when tripped, they will end 278 | a brute force attack by throwing a `BreakerTrippedError`. Breakers become 279 | tripped when an exception class that is included in `exception_classes` 280 | for the breaker is raised during a brute force attack. 281 | ''' 282 | 283 | exception_classes: List[Type[Exception]] 284 | 'Exception classes the breaker will act on.' 285 | 286 | trip_class: Type[Exception] = BreakerTrippedError 287 | 'Exception classes the Breaker.check will act on.' 288 | 289 | trip_msg: str = 'Breaker tripped' 290 | 'Default message tripped by Breaker.check.' 291 | 292 | def callback(self, e:Exception, log:Logger=None) -> bool: 293 | '''Callback executed by Breaker.check. 294 | 295 | Args: 296 | e: Exception instance received by Breaker.check. 297 | 298 | Returns: 299 | Boolean value determining if check should call 300 | Breaker.trip. 301 | 302 | Notes: 303 | - Override this method in subclasses. 304 | - The default variation of this method will always 305 | return True. 306 | ''' 307 | 308 | return True 309 | 310 | @check_exceptions('e', 'trip_class') 311 | def check(self, e:Exception, trip_class:Exception=None, 312 | trip_msg:str=None, log:Logger=None) -> bool: 313 | '''Check an exception and determine if the breaker should be 314 | tripped. 315 | 316 | Args: 317 | e: Exception to check. 318 | trip_class: Exception class that will be tripped should 319 | the breaker be engaged. Otherwise self.trip_class is 320 | used. 321 | trip_msg: Message that will be passed to the raised 322 | exception. Otherwise self.trip_msg is used. 323 | log: Logger to send log messages. 324 | 325 | Returns: 326 | Boolean indicating if the exception was handled by the 327 | breaker. 328 | 329 | Notes: 330 | - Raises type specified by trip_class or self.trip_class when 331 | callback returns a non-None value. 332 | - callback signature: f(e:Exception) -> bool 333 | - trip signature: 334 | trip(e:Exception, msg:str, exception_class:Exception) 335 | ''' 336 | 337 | if log: log.module(f'{type(self).__name__} checked: {e}') 338 | 339 | will_handle = isinstance(e, tuple(self.exception_classes)) 340 | 341 | if will_handle and self.callback(e, log=log): 342 | 343 | self.trip( 344 | e = e, 345 | msg = 346 | trip_msg if trip_msg else self.trip_msg, 347 | exception_class = 348 | trip_class if trip_class else self.trip_class 349 | ) 350 | 351 | return will_handle 352 | 353 | @check_exceptions('exception_class') 354 | def trip(self, msg:str=None, exception_class:Exception=None, *args, **kwargs): 355 | '''Raise a BreakerTrippedError while using e as the string 356 | message. 357 | 358 | Args: 359 | exception_class: Exception class to raise. 360 | ''' 361 | 362 | raise (exception_class if exception_class else self.trip_class)( 363 | msg if msg else self.default_msg 364 | ) 365 | 366 | CALLBACK_ERR = ('Authentication callbacks must return a dict matching ' 367 | f'this schema: Output') 368 | 369 | class Callback(BaseModel): 370 | '''A model representing the authentication callback for a 371 | BruteLoops attack.''' 372 | 373 | callback: Callable[[str, str], dict] 374 | 'Callback to execute.' 375 | 376 | authentication_jitter: Union[Jitter, None] = None 377 | 'Time to sleep before returning.' 378 | 379 | def __call__(self, username:str, password:str, *args, 380 | **kwargs) -> Output: 381 | '''Call the authentication callback. 382 | 383 | Returns: 384 | An Output instance. 385 | ''' 386 | 387 | output = self.callback(username, password, 388 | *args, **kwargs) 389 | 390 | # Handle bad return type 391 | if not isinstance(output, dict): 392 | raise ValueError(CALLBACK_ERR) 393 | 394 | if not output.get('username', None): 395 | output['username'] = username 396 | if not output.get('password', None): 397 | output['password'] = password 398 | 399 | try: 400 | # Validate the output 401 | output = Output(**output) 402 | 403 | except Exception as e: 404 | 405 | # Handle poorly formatted dict 406 | raise ValueError(CALLBACK_ERR + '({e})') 407 | 408 | finally: 409 | 410 | if self.authentication_jitter: 411 | 412 | # Do authentication jitter 413 | self.authentication_jitter.sleep() 414 | 415 | return output 416 | 417 | class ThresholdBreaker(Breaker, validate_assignment=True): 418 | '''A breaker that throws only when an event has occurred more 419 | than "threshold" times. 420 | 421 | Set the `reset_spec` attribute to a string conforming to the 422 | jitter timing specification to allow the threshold to timeout 423 | after a given period of time. 424 | ''' 425 | 426 | threshold: int = Field(1, gt=0) 427 | ('Maximum number of times the breaker can be handled before ' 428 | 'throwing an exception.') 429 | 430 | reset_after: str = Field(None, regex=JITTER_RE, alias='reset_spec') 431 | ('Reset threshold counter after this period of time. Format ' 432 | 'follows the same specification as Jitter. Value is converted to ' 433 | 'an integer representing the number of seconds upon validation.') 434 | 435 | last_time: datetime.datetime = None 436 | 'The last time that count was incremented.' 437 | 438 | count: int = Field(0, gt=-1) 439 | 'Count of times handle has been called.' 440 | 441 | def __setattr__(self, name:str, value:Any): 442 | '''Override such that additional checks/modifications are 443 | performed when the count attribute is incremented. 444 | 445 | Args: 446 | name: Name of the attribute to set. 447 | value: Value that is set to the attribute. 448 | 449 | Raises: 450 | OverflowError: when `self.count` > `self.threshold` 451 | 452 | Notes: 453 | - Time-based resets are managed here. 454 | ''' 455 | 456 | super().__setattr__(name, value) 457 | 458 | if name == 'count': 459 | 460 | if self.reset_after: 461 | 462 | # Take the current time 463 | now = BruteTime.current_time(format='datetime') 464 | 465 | # Check if we should reset the breaker 466 | # - Has to have been checked at least once before 467 | # - Value should be greater than 1 468 | # - delta between now and last time must be greater than 469 | # self.reset_after 470 | if self.last_time and (value > 1) and ( 471 | (now - self.last_time).total_seconds() >= 472 | self.reset_after): 473 | 474 | # Enough time has passed to reset the counter 475 | self.count = 1 476 | 477 | # Set the value of last_time to track the last hit 478 | self.last_time = now 479 | 480 | if self.count > self.threshold: 481 | # Raise the overflow exception 482 | 483 | raise OverflowError( 484 | f'Breaker threshold broken ({value} > {self.threshold})') 485 | 486 | @validator('reset_after') 487 | def v_reset(cls, v, field, values): 488 | 'Reset must follow Jitter specification.' 489 | 490 | return jitter_str_to_float(v, field.name) 491 | 492 | @validator('threshold') 493 | def v_thresh(cls, t): 494 | 'Threshold must be >=1.' 495 | 496 | if t < 1: 497 | raise ValidationError( 498 | f'threshold must be greater >=1, got {t}') 499 | 500 | return t 501 | 502 | def callback(self, e:Exception, log:Logger=None) -> bool: 503 | '''Override the callback method to increment the count 504 | attribute and handle when the threshold is crossed. 505 | 506 | Returns: 507 | - True when the threshold has been crossed. 508 | - False when count < threshold. 509 | ''' 510 | 511 | try: 512 | self.count += 1 513 | if log: 514 | log.module( 515 | 'Threshold Breaker at: ' 516 | f'{self.count} of {self.threshold}' 517 | ) 518 | except OverflowError: 519 | if log: log.module('Breaker threshold met.') 520 | return True 521 | 522 | return False 523 | 524 | def reset(self, c:int=0): 525 | '''Reset the handle count to c. 526 | 527 | Args: 528 | c: Value to set to handle_count. 529 | ''' 530 | 531 | self.count = c 532 | 533 | class Breakers(BaseModel): 534 | '''A list of breakers to handle exceptions. 535 | ''' 536 | 537 | __root__: List[Union[Breaker, 538 | ThresholdBreaker]] = Field(default_factory=list) 539 | 540 | class Config(BaseModel, extra=Extra.allow): 541 | '''A model to support configuration of BruteLoops attacks. It provides 542 | all input validations and conversions such that only a dictionary 543 | value is required for initialization.''' 544 | 545 | authentication_jitter: Jitter = None 546 | 'Time to sleep between guesses for a given username.' 547 | 548 | max_auth_jitter: Jitter = None 549 | 'Time to sleep after max_auth_tries is hit.' 550 | 551 | process_count: int = 1 552 | ('Total count of processes to use. Given BL\'s current logic in ' 553 | 'control loop, this also indicates the number of parallel ' 554 | 'usernames to target in parallel.') 555 | 556 | max_auth_tries: int = None 557 | ('Maximum number of guesses to make for an account before ' 558 | 'engaging max_auth_jitter') 559 | 560 | stop_on_valid: bool = False 561 | ('Determines if the control loop should halt execution when a valid ' 562 | 'credential is recovered.') 563 | 564 | db_file: str 565 | 'Path to the SQLite database file supporting the attack.' 566 | 567 | log_level: LogLevelEnum = 'invalid-username' 568 | 'Level of logging to apply.' 569 | 570 | log_file: str = None 571 | 'File to receive logging events.' 572 | 573 | log_stderr: bool = False 574 | 'Determines if logs should be sent to stderr.' 575 | 576 | log_stdout: bool = True 577 | 'Determines if logs should be sent to stdout.' 578 | 579 | log_format: str = BLogging.LOG_FORMAT 580 | 'Format for log events.' 581 | 582 | randomize_usernames: bool = True 583 | 'Determines if usernames should be randomized.' 584 | 585 | timezone: str = None 586 | 'Timezone for logging.' 587 | 588 | blackout: Blackout = None 589 | 'Setting for when no guessing should occur.' 590 | 591 | exception_handlers: List[ExceptionHandler] = Field( 592 | default_factory=list) 593 | 'Exception handlers that will receive exceptions.' 594 | 595 | breakers: List[Breaker] = Field(default_factory=list) 596 | 'Breakers to further handle exceptions.' 597 | 598 | authentication_callback: Callable[[str, str], Output] 599 | 'Callback that authenticates credentials.' 600 | 601 | @validator('process_count', 'max_auth_tries') 602 | def gt_zero(cls, v, field): 603 | 'Enforce minimum values.' 604 | 605 | if not isinstance(v, int): 606 | raise ValueError(f'{field.name} must be an integer.') 607 | elif v < 1: 608 | raise ValueError(f'{field.name} must be >0, got {v}') 609 | 610 | return v 611 | 612 | @validator('authentication_callback') 613 | def v_auth_callback(cls, v, values): 614 | 'Configure the attack\'s authentication callback.' 615 | 616 | return Callback( 617 | callback=v, 618 | authentication_jitter=values.get( 619 | 'authentication_jitter', 620 | None)) 621 | 622 | @validator('timezone') 623 | def v_timezone(cls, tz): 624 | 'Set and return the BruteTime timezone.' 625 | 626 | if tz is not None: 627 | BruteTime.set_timezone(tz) 628 | 629 | return tz 630 | 631 | @validator('log_level') 632 | def v_log_level(cls, v): 633 | 'Derive the proper logging level from name.' 634 | 635 | return getattr(BLogging, v.name) 636 | 637 | def __init__(self, *args, **kwargs): 638 | 639 | # ==================== 640 | # REMOVE EMPTY JITTERS 641 | # ==================== 642 | 643 | for k in ('authentication_jitter', 'max_auth_jitter',): 644 | jitter = kwargs.get(k) 645 | 646 | if not jitter: 647 | if k in kwargs: 648 | del(kwargs[k]) 649 | continue 650 | 651 | min, max = jitter.get('min', ''), jitter.get('max','') 652 | 653 | if (not min and not max) or \ 654 | (EJP.match(min) and EJP.match(max)): 655 | del(kwargs[k]) 656 | continue 657 | 658 | # ===================== 659 | # FINISH INITIALIZATION 660 | # ===================== 661 | 662 | super().__init__(*args, **kwargs) 663 | 664 | # Session maker 665 | self.session_maker = Session(self.db_file) 666 | -------------------------------------------------------------------------------- /bruteloops/brute.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This module contains the primary logic to execute and control a brute 3 | force attack. 4 | ''' 5 | 6 | import traceback 7 | import re 8 | import signal 9 | from calendar import monthrange 10 | from . import logging 11 | from .brute_time import BruteTime 12 | from .db_manager import * 13 | from . import queries as Queries 14 | from .errors import BreakerTrippedError 15 | from . import models 16 | from sqlalchemy.orm.session import close_all_sessions 17 | from pathlib import Path 18 | from time import sleep, time, gmtime, strftime 19 | from datetime import datetime, timedelta 20 | from random import shuffle 21 | from sys import exit 22 | from typing import List, Callable, Any 23 | from types import FunctionType, MethodType 24 | from .models import Output, ExceptionHandler 25 | 26 | UNKNOWN_PRIORITIZED_USERNAME_MSG = ( 27 | 'Prioritized username value supplied during configuration that does ' 28 | 'not appear in the database. Insert this value or remove it from ' 29 | 'the configuration: {username}') 30 | 31 | UNKNOWN_PRIORITIZED_PASSWORD_MSG = ( 32 | 'Prioritized password value supplied during configuration that does ' 33 | 'not appear in the database. Insert this value or remove it from ' 34 | 'the configuration: {password}') 35 | 36 | def wrapped_callback(func:Callable[[str,str], dict], username:str, 37 | password:str) -> models.Output: 38 | '''Implement a wrapped function to make authentication callbacks, 39 | enabling us to capture and act on exceptions accordingly. 40 | 41 | Args: 42 | func: A callable used to authenticate the credentials. 43 | username: Username to authenticate. 44 | password: Password to check against the user. 45 | 46 | Returns: 47 | A py::`models.Output` instance populated with output from the call to 48 | func. 49 | ''' 50 | 51 | try: 52 | 53 | out = func(username, password) 54 | 55 | if isinstance(out, dict): 56 | 57 | # ========================================== 58 | # UPDATE WITH USERNAME/PASSWORD WHEN MISSING 59 | # ========================================== 60 | 61 | if not out.get('username', None): 62 | out['username'] = username 63 | if not out.get('password', None): 64 | out['password'] = password 65 | 66 | return out 67 | 68 | except Exception as e: 69 | 70 | # Return the output dictionary 71 | return Output( 72 | outcome=-1, 73 | username=username, 74 | password=password, 75 | events=[f'Handling exception: {e}'], 76 | exception=e 77 | ) 78 | 79 | def peel_credential_ids(container: Any): 80 | '''For each element in the container, traverse the "credential" 81 | relationship and collect the ID value. 82 | 83 | Args: 84 | container: Iterable of SQLAlchemy ORM instances. 85 | ''' 86 | 87 | for i in range(0, len(container)): 88 | container[i] = container[i].credential.id 89 | 90 | class BruteForcer: 91 | '''The BruteForcer object is responsible for orchestrating and 92 | executing a brute force attack against a target. 93 | ''' 94 | 95 | def __init__(self, config:models.Config, use_billiard:bool=False): 96 | '''Initialize the BruteForcer object. 97 | 98 | Args: 99 | config: Initialized models.Config object. 100 | use_billiard: Determines if Billiard's Pool module should be 101 | used instead of Python's native module. Billiard's better 102 | handles daemonized processes. 103 | ''' 104 | 105 | # DB SESSION FOR MAIN PROCESS 106 | self.main_db_sess = config.session_maker.new() 107 | self.handler_db_sess = config.session_maker.new() 108 | 109 | # ============================== 110 | # BASIC CONFIGURATION PARAMETERS 111 | # ============================== 112 | 113 | self.config = config 114 | self.presults = [] 115 | self.pool = None 116 | self.attack = None 117 | self.log = logging.getLogger( 118 | name='BruteLoops.BruteForcer', log_level=config.log_level, 119 | log_file=config.log_file, log_stdout=config.log_stdout, 120 | log_stderr=config.log_stderr, timezone=config.timezone) 121 | 122 | if config.timezone: 123 | BruteTime.set_timezone(config.timezone) 124 | 125 | self.log.general(f'Initializing {config.process_count} process(es)') 126 | 127 | # =================================== 128 | # LOG ATTACK CONFIGURATION PARAMETERS 129 | # =================================== 130 | 131 | self.log.general('Logging attack configuration parameters') 132 | 133 | conf_temp = 'Config Parameter -- {attr}: {val}' 134 | 135 | for attr in [ 136 | 'authentication_jitter', 'max_auth_jitter', 137 | 'max_auth_tries', 'stop_on_valid', 138 | 'db_file', 'log_file', 'log_level', 139 | 'log_stdout', 'log_stderr', 'randomize_usernames', 140 | 'timezone', 'blackout']: 141 | 142 | self.log.general(f'Config Parameter -- {attr}: ' + 143 | str(getattr(self.config,attr))) 144 | 145 | if hasattr(self.config.authentication_callback, 'callback_name'): 146 | self.log.general(f'Config Parameter -- callback_name: '+ \ 147 | getattr(self.config.authentication_callback, 148 | 'callback_name')) 149 | 150 | # ============================================================= 151 | # REASSIGN DEFAULT SIGNAL HANDLER AND INITIALIZE A PROCESS POOL 152 | # ============================================================= 153 | 154 | signal.signal(signal.SIGINT, signal.SIG_IGN) 155 | 156 | if use_billiard: 157 | 158 | import billiard 159 | self.pool = billiard.Pool(processes=config.process_count) 160 | 161 | else: 162 | 163 | from multiprocessing.pool import Pool 164 | self.pool = Pool(processes=config.process_count) 165 | 166 | ki_handler = None 167 | for eh in self.config.exception_handlers: 168 | if eh.exception_class == KeyboardInterrupt: 169 | ki_handler = eh.handler 170 | 171 | if not ki_handler: 172 | 173 | # ================================= 174 | # DEFINE THE DEFAULT SIGINT HANDLER 175 | # ================================= 176 | 177 | def handler(sig, exception): 178 | 179 | print('\nSIGINT Captured -- Shutting down attack\n') 180 | self.shutdown(complete=False) 181 | print('\nExiting\n') 182 | exit(sig) 183 | 184 | ki_handler = handler 185 | 186 | self.log.general(f'Setting default exception handler for ' 187 | 'KeyboardInterrupt') 188 | 189 | self.config.exception_handlers.append( 190 | ExceptionHandler( 191 | exception_class = KeyboardInterrupt, 192 | handler = handler) 193 | ) 194 | 195 | # ================================= 196 | # SET A USER-DEFINED SIGINT HANDLER 197 | # ================================= 198 | 199 | signal.signal(signal.SIGINT, ki_handler) 200 | 201 | # ================= 202 | # HANDLE THE ATTACK 203 | # ================= 204 | 205 | current_time = BruteTime.current_time(format=str) 206 | self.log.general(f'Beginning attack: {current_time}') 207 | 208 | # CREATE A NEW ATTACK 209 | self.attack = sql.Attack(start_time=BruteTime.current_time()) 210 | self.main_db_sess.add(self.attack) 211 | self.main_db_sess.commit() 212 | 213 | # Realign future jitter times with the current configuration 214 | self.realign_future_time() 215 | 216 | def handle_outputs(self, outputs:List[models.Output]) -> bool: 217 | '''Handle outputs from the authentication callback. It expects a list of 218 | dicts conforming to the blow format. 219 | 220 | Args: 221 | outputs: A list of dict objects matching the models.Output type. 222 | 223 | Returns: 224 | `bool` value determinine if at least one credential was valid in the 225 | list of outputs. This is useful when working with the "stop on valid" 226 | flag. 227 | ''' 228 | 229 | # ================================================== 230 | # DETERMINE AND HANDLE VALID_CREDENTIALS CREDENTIALS 231 | # ================================================== 232 | 233 | recovered = False 234 | for output in outputs: 235 | 236 | # ===================================== 237 | # IMPLEMENT BREAKERS/EXCEPTION HANDLERS 238 | # ===================================== 239 | 240 | if output.exception is not None: 241 | 242 | breaker_handled, exception_handled = False, False 243 | 244 | # Call breakers 245 | for b in self.config.breakers: 246 | b.check(output.exception, log=self.log) 247 | if not breaker_handled: 248 | breaker_handled = True 249 | 250 | # Pass to regular exception handlers 251 | for eh in self.config.exception_handlers: 252 | if eh.exception_class == type(output.exception): 253 | eh.handler(output.exception) 254 | if not exception_handled: 255 | exception_handled = True 256 | 257 | if breaker_handled or exception_handled: 258 | 259 | msg = f'handled exception: {output.exception}' 260 | 261 | if breaker_handled: 262 | msg = 'Breaker ' + msg 263 | else: 264 | msg = 'Exception Handler ' + msg 265 | 266 | self.log.general(msg) 267 | 268 | else: 269 | 270 | raise output.exception 271 | 272 | # =============================== 273 | # QUERY FOR THE TARGET CREDENTIAL 274 | # =============================== 275 | 276 | credential = self.handler_db_sess \ 277 | .query(sql.Credential) \ 278 | .join(sql.Username) \ 279 | .join(sql.Password) \ 280 | .filter( 281 | sql.Username.value == output.username, 282 | sql.Password.value == output.password, 283 | sql.Username.recovered == False) \ 284 | .first() 285 | 286 | if not credential: continue 287 | 288 | # ====================== 289 | # HANDLE THE CREDENTIALS 290 | # ====================== 291 | 292 | if self.config.max_auth_jitter: 293 | 294 | # ===================== 295 | # SET FUTURE TIME AGAIN 296 | # ===================== 297 | ''' 298 | - Set once before, just before making the guess. 299 | - Mitigates likelihood of locking out accounts. 300 | ''' 301 | 302 | credential.username.future_time = ( 303 | self 304 | .config 305 | .max_auth_jitter 306 | .get_jitter_future()) 307 | 308 | cred = f'{output.username}:{output.password}' 309 | 310 | # Handle valid credentials 311 | if output.outcome == 1: 312 | 313 | credential.guessed = True 314 | recovered = True 315 | self.log.valid(cred) 316 | 317 | # Update username to "recovered" 318 | credential.username.recovered = True 319 | 320 | # Update the credential to valid 321 | credential.valid = True 322 | 323 | # Guess failed for some reason 324 | elif output.outcome == -1: 325 | 326 | self.log.general( 327 | f'Failed to guess credential: {cred}') 328 | 329 | # Credentials are no good 330 | else: 331 | 332 | credential.guessed=True 333 | 334 | # Update the credential to invalid 335 | credential.valid=False 336 | self.log.invalid(cred) 337 | 338 | # ================================================ 339 | # MANAGE THE ACTIONABLE ATTRIBUTE FOR THE USERNAME 340 | # ================================================ 341 | 342 | if output.actionable and not credential.username.actionable: 343 | credential.username.actionable = True 344 | elif not output.actionable and credential.username.actionable: 345 | self.log.invalid_username( 346 | f'Disabling invalid username - {cred}') 347 | credential.username.actionable = False 348 | 349 | # =================== 350 | # WRITE MODULE EVENTS 351 | # =================== 352 | 353 | for event in output.events: 354 | self.log.module(event) 355 | 356 | # Commit the changes 357 | self.handler_db_sess.commit() 358 | 359 | return recovered 360 | 361 | def realign_future_time(self): 362 | '''Iterate over each imported username value and rejitter 363 | the future time based on the current max_authentication_jitter 364 | 365 | Notes: 366 | - Primrily useful when strarting a brute force attack. 367 | - Ensures proper alignment of timestamps. 368 | ''' 369 | 370 | # Get all relevant username values 371 | usernames = self.main_db_sess.query(sql.Username) \ 372 | .filter( 373 | sql.Username.recovered == False, 374 | sql.Username.last_time > -1.0, 375 | ) 376 | 377 | # Iterate over each username 378 | for username in usernames: 379 | 380 | # If there's a max_auth_jitter configuration 381 | if self.config.max_auth_jitter: 382 | 383 | # Generate a new jitter value 384 | username.future_time = \ 385 | self.config.max_auth_jitter.get_jitter_future( 386 | current_time=username.last_time 387 | ) 388 | 389 | # Otherwise, set it to the default value of -1.0 390 | else: username.future_time = -1.0 391 | 392 | # Commit the changes to the database 393 | self.main_db_sess.commit() 394 | 395 | def monitor_processes(self, ready_all:bool=False) -> List[models.Output]: 396 | '''Iterate over each process in `self.presults` and wait 397 | for a process to complete execution. 398 | 399 | Args: 400 | ready_all: indciates that monitoring will continue looping until all 401 | processes complete execution, otherwise a list of outputs 402 | will be returned after a single process is finished. 403 | 404 | Returns: 405 | A list of output dicts from the authentication callback. 406 | ''' 407 | 408 | outputs = [] 409 | while True: 410 | 411 | # iterate over each result 412 | for result in self.presults: 413 | 414 | # act on results that are ready 415 | if result.ready(): 416 | 417 | # append outputs from the result 418 | outputs.append( 419 | result.get() 420 | ) 421 | 422 | # remove the finished result 423 | del( 424 | self.presults[ 425 | self.presults.index(result) 426 | ] 427 | ) 428 | 429 | # keep iterating should all results be cleared 430 | # and some still remain 431 | if (ready_all and self.presults) or ( 432 | len(self.presults) == self.config.process_count): 433 | sleep(.1) 434 | continue 435 | else: 436 | return outputs 437 | 438 | def do_authentication_callback(self, username:str, password:str, 439 | stop_on_valid:bool=False) -> bool: 440 | '''Call the authentication callback from a distinct process. 441 | Will monitor processes for completion if all are currently 442 | occupied with a previous callback request. 443 | 444 | Args: 445 | username: Username to guess. 446 | password: Password to guess. 447 | stop_on_valid: Boolean value determining if the attack 448 | should be halted when valid credentials are observed. 449 | 450 | Returns: 451 | Boolean value determining if a valid set of credentials 452 | was discovered. 453 | ''' 454 | 455 | ''' 456 | When the maximum number of processes have been engaged 457 | to make authentication requests, call monitor_processes 458 | to watch each process until authentication finishes. 459 | 460 | Once completeds, the outputs are passed to handle_outputs, 461 | which is responsible for logging the outcome of the authentication 462 | request and updating the proper SQL record with the outcome. 463 | ''' 464 | 465 | recovered = False 466 | if len(self.presults) == self.config.process_count: 467 | 468 | # ================================== 469 | # HANDLE WHEN ALL PROCESSES ARE BUSY 470 | # ================================== 471 | 472 | # monitor result objects 473 | outputs = self.monitor_processes() 474 | 475 | # Deteremine if at least one credential was valid. 476 | recovered = self.handle_outputs(outputs) 477 | 478 | if recovered and stop_on_valid: 479 | 480 | # ============================================== 481 | # STOP ISSUING AUTH CALLBACKS WHEN STOP ON VALID 482 | # ============================================== 483 | 484 | return recovered 485 | 486 | # initiate a guess in a process within the pool 487 | self.presults.append( 488 | self.pool.apply_async( 489 | func = wrapped_callback, 490 | kwds = dict( 491 | func = self.config.authentication_callback, 492 | username = username, 493 | password = password, 494 | ) 495 | ) 496 | ) 497 | 498 | return recovered 499 | 500 | def shutdown(self, complete:bool): 501 | '''Close & join the process pool. 502 | 503 | Args: 504 | complete: Determines if the attack was fully completed, 505 | i.e. all guesses were performed. 506 | ''' 507 | 508 | # ===================== 509 | # LOG ATTACK COMPLETION 510 | # ===================== 511 | 512 | self.log.general('Shutting attack down') 513 | 514 | self.attack.complete = complete 515 | self.attack.end_time = BruteTime.current_time() 516 | self.main_db_sess.commit() 517 | 518 | self.log.general('Closing/joining Processes') 519 | 520 | if self.pool: 521 | self.pool.close() 522 | self.pool.join() 523 | 524 | close_all_sessions() 525 | 526 | def launch(self): 527 | '''Launch the attack. 528 | ''' 529 | 530 | if self.config.max_auth_tries: 531 | 532 | # Handle manually configured lockout threshold 533 | glimit = guess_limit = self.config.max_auth_tries 534 | 535 | else: 536 | 537 | # Set a sane default otherwise 538 | glimit = guess_limit = 1 539 | 540 | if self.config.randomize_usernames: 541 | rand_multi = 10 542 | else: 543 | rand_multi = 1 544 | 545 | ulimit = user_limit = self.config.process_count 546 | 547 | last_logged = -1 # track the last time a sleep log event was emitted 548 | recovered = False # track if a valid credentials has been recovered 549 | 550 | # ======================== 551 | # BEGIN BRUTE FORCE ATTACK 552 | # ======================== 553 | 554 | while True: 555 | 556 | try: 557 | 558 | # ====================== 559 | # MANAGE BLACKOUT WINDOW 560 | # ====================== 561 | 562 | if self.config.blackout: 563 | 564 | now = BruteTime.current_time(datetime) 565 | 566 | # ============================== 567 | # GET DATETIME OBJECTS FOR MATHS 568 | # ============================== 569 | 570 | b_start = datetime( 571 | year=now.year, 572 | month=now.month, 573 | day=now.day, 574 | hour=self.config.blackout.start.hour, 575 | minute=self.config.blackout.start.minute, 576 | second=self.config.blackout.start.second, 577 | tzinfo=BruteTime.timezone) 578 | 579 | # ======================================== 580 | # DETERMINE IF BLACKOUT ENDS FOLLOWING DAY 581 | # ======================================== 582 | 583 | if (self.config.blackout.start.hour > 584 | self.config.blackout.stop.hour): 585 | tomorrow = 1 586 | else: 587 | tomorrow = 0 588 | 589 | # Future datetime variables 590 | f_year = now.year 591 | f_month = now.month 592 | f_day = now.day + tomorrow 593 | 594 | # ==================== 595 | # CALCULATE FUTURE DAY 596 | # ==================== 597 | 598 | if f_day > monthrange(now.year, now.month)[1]: 599 | # BlackoutModel ends in following month 600 | 601 | f_month += 1 602 | f_day = 1 603 | 604 | if f_month > 12: 605 | # BlackoutModel ends in following year 606 | f_month = 1 607 | f_year += 1 608 | 609 | b_stop = datetime( 610 | year=f_year, month=f_month, day=f_day, 611 | hour=self.config.blackout.stop.hour, 612 | minute=self.config.blackout.stop.minute, 613 | second=self.config.blackout.stop.second, 614 | tzinfo=BruteTime.timezone) 615 | 616 | if (now >= b_start) and (now < b_stop): 617 | 618 | # Allow outstanding processes to complete 619 | outputs = self.monitor_processes(ready_all=True) 620 | recovered = self.handle_outputs(outputs) 621 | if recovered and self.config.stop_on_valid: 622 | break 623 | 624 | # ============================= 625 | # SLEEP THROUGH BLACKOUT WINDOW 626 | # ============================= 627 | 628 | dstart = timedelta(hours=now.hour, 629 | minutes=now.minute, 630 | seconds=now.second) 631 | 632 | dstop = timedelta( 633 | days=tomorrow, 634 | hours=self.config.blackout.stop.hour, 635 | minutes=self.config.blackout.stop.minute, 636 | seconds=self.config.blackout.stop.second) 637 | 638 | ts = (dstop-dstart).total_seconds() 639 | ft = BruteTime.future_time(seconds=ts) 640 | 641 | self.log.general('Engaging blackout') 642 | self.log.general( 643 | 'Sleeping until ' + 644 | BruteTime.float_to_str(ft)) 645 | 646 | sleep(ts) 647 | self.log.general('Disengaging blackout') 648 | 649 | # ======================== 650 | # GET ACTIONABLE USERNAMES 651 | # ======================== 652 | 653 | # Current time 654 | ctime = BruteTime.current_time() 655 | 656 | # PRIORITY USERNAMES 657 | priorities = self.main_db_sess.execute( 658 | Queries.priority_usernames 659 | .where(sql.Username.future_time <= ctime) 660 | .limit(ulimit) 661 | ).scalars().all() 662 | 663 | # USERNAMES WITH STRICT CREDENTIALS 664 | if len(priorities) < ulimit: 665 | 666 | priorities += self.main_db_sess.execute( 667 | Queries.strict_usernames 668 | .where( 669 | sql.Username.future_time <= ctime, 670 | sql.Username.id.not_in(priorities)) 671 | .limit(ulimit-len(priorities)) 672 | ).scalars().all() 673 | 674 | # GET GUESSABLE USERNAMES 675 | if len(priorities) < ulimit: 676 | 677 | # Get guessable usernames 678 | guessable = self.main_db_sess.execute( 679 | Queries.usernames 680 | .where( 681 | sql.Username.priority == False, 682 | sql.Username.future_time <= ctime, 683 | sql.Username.id.not_in(priorities)) 684 | .limit(ulimit*rand_multi) 685 | ).scalars().all() 686 | 687 | # Randomize usernames 688 | if self.config.randomize_usernames: 689 | shuffle(guessable) 690 | 691 | # Trim down to guessable size 692 | guessable = guessable[:ulimit-len(priorities)] 693 | 694 | else: 695 | 696 | guessable = list() 697 | 698 | uids = priorities + guessable 699 | 700 | # Logging sleep events 701 | if not uids: 702 | 703 | ctime = BruteTime.current_time() 704 | 705 | outputs = self.monitor_processes(ready_all=True) 706 | recovered = self.handle_outputs(outputs) 707 | if recovered and self.config.stop_on_valid: 708 | break 709 | 710 | q = ( 711 | select(sql.Username) 712 | .where( 713 | sql.Username.recovered == False, 714 | sql.Username.actionable == True, 715 | sql.Username.future_time > -1, 716 | sql.Username.future_time > ctime, 717 | ( 718 | (select(sql.Credential.id) 719 | .where( 720 | sql.Credential.username_id == 721 | sql.Username.id, 722 | sql.Credential.guessed == 723 | False,) 724 | .limit(1)).exists() 725 | )) 726 | .order_by(sql.Username.future_time.asc()) 727 | .limit(1) 728 | ) 729 | 730 | 731 | u = self.main_db_sess.execute(q) \ 732 | .scalars() \ 733 | .first() 734 | 735 | if u and u.future_time: 736 | 737 | sleep_time = u.future_time - ctime 738 | 739 | # Log sleep events when a downtime of 60 740 | # seconds or greater is observed. 741 | if sleep_time >= 60: 742 | 743 | self.log.general( 744 | f'Sleeping until {u.value}\'s ' 745 | 'threshold time expires: ' + 746 | BruteTime.float_to_str(u.future_time)) 747 | 748 | if sleep_time > 0: 749 | sleep(sleep_time) 750 | 751 | # ========================= 752 | # BRUTE FORCE EACH USERNAME 753 | # ========================= 754 | 755 | # Current limit will be used to calculate the limit of the current query 756 | # used to assure that the limit remains lesser than the greatest password 757 | # id 758 | for uid in uids: 759 | 760 | # GET STRICT CREDENTIAL IDs 761 | cids = self.main_db_sess.execute( 762 | Queries.strict_credentials 763 | .where( 764 | sql.Credential.username_id == uid) 765 | .limit(glimit) 766 | ).scalars().all() 767 | 768 | peel_credential_ids(cids) 769 | 770 | if len(cids) < glimit: 771 | 772 | # GET PRIORITY CREDENTIAL IDs 773 | buff = self.main_db_sess.execute( 774 | Queries.priority_credentials 775 | .where( 776 | sql.Credential.username_id == uid, 777 | sql.Credential.id.not_in(cids), 778 | ) 779 | .limit(glimit-len(cids)) 780 | ).scalars().all() 781 | 782 | peel_credential_ids(buff) 783 | cids += buff 784 | 785 | if len(cids) < glimit: 786 | 787 | # GET NORMAL CREDENTIAL IDs 788 | buff = self.main_db_sess.execute( 789 | Queries.credentials 790 | .where( 791 | sql.Credential.username_id == uid, 792 | sql.Credential.id.not_in(cids), 793 | ) 794 | .limit(glimit-len(cids)) 795 | ).scalars().all() 796 | 797 | cids += buff 798 | 799 | # GET THE CREDENTIALS 800 | credentials = self.main_db_sess.query(sql.Credential) \ 801 | .filter(sql.Credential.id.in_(cids)) \ 802 | .all() 803 | 804 | cids.clear() 805 | 806 | shuffle(credentials) 807 | 808 | # ===================== 809 | # GUESS THE CREDENTIALS 810 | # ===================== 811 | 812 | for credential in credentials: 813 | 814 | # Current time of authentication attempt 815 | ctime = BruteTime.current_time() 816 | 817 | # Get the future time when this user can be targeted later 818 | if self.config.max_auth_jitter: 819 | # Derive from the password jitter 820 | ftime = self.config \ 821 | .max_auth_jitter \ 822 | .get_jitter_future() 823 | else: 824 | # Default effectively asserting that no jitter will occur. 825 | ftime = -1.0 826 | 827 | # ================================== 828 | # DETECT POTENTIAL DUPLICATE GUESSES 829 | # ================================== 830 | 831 | skip_msg = None 832 | if credential.username.recovered: 833 | 834 | skip_msg = 'Skipping recovered credentials: ' \ 835 | '{username}:{password}' 836 | 837 | elif credential.guess_time != -1 or credential.guessed: 838 | 839 | skip_msg = 'Skipping duplicate credential guess: ' \ 840 | '{username}:{password}' 841 | 842 | if skip_msg: 843 | 844 | # ============================ 845 | # AVOID DUPLICATES BY SKIPPING 846 | # ============================ 847 | 848 | self.log.general( 849 | skip_msg.format( 850 | username=credential.username.value, 851 | password=credential.password.value)) 852 | 853 | continue 854 | 855 | # Update the Username/Credential object with relevant 856 | # attributes and commit 857 | 858 | credential.guess_time = ctime 859 | credential.username.last_time = ctime 860 | credential.username.future_time = ftime 861 | self.main_db_sess.commit() 862 | 863 | # Do the authentication callback 864 | recovered = self.do_authentication_callback( 865 | credential.username.value, 866 | credential.password.value 867 | ) 868 | 869 | if recovered and self.config.stop_on_valid: 870 | break 871 | 872 | if recovered and self.config.stop_on_valid: 873 | break 874 | 875 | # ============================================ 876 | # STOP ATTACK DUE TO STOP_ON_VALID_CREDENTIALS 877 | # ============================================ 878 | 879 | if recovered and self.config.stop_on_valid: 880 | self.log.general( 881 | 'Valid credentials recovered. Exiting per ' \ 882 | 'stop_on_valid configuration.') 883 | self.shutdown(complete=False) 884 | break 885 | 886 | # =============================================== 887 | # CONTINUE LOOPING UNTIL ALL GUESSES ARE FINISHED 888 | # =============================================== 889 | 890 | # Check if a normal credentials remains 891 | sample_remaining = self.main_db_sess \ 892 | .query(sql.Username) \ 893 | .join(sql.Credential) \ 894 | .filter(sql.Username.recovered == False, 895 | sql.Username.actionable == True, 896 | sql.Credential.guess_time == -1, 897 | sql.Credential.guessed == False) \ 898 | .limit(1) \ 899 | .first() 900 | 901 | if sample_remaining: 902 | 903 | if len(self.presults): 904 | outputs = self.monitor_processes() 905 | self.handle_outputs(outputs) 906 | 907 | sleep(.2) 908 | continue 909 | 910 | # ======================================== 911 | # GUESSES FINISHED; CLEAN REMAINING OUTPUT 912 | # ======================================== 913 | 914 | outputs = self.monitor_processes(ready_all=True) 915 | self.handle_outputs(outputs) 916 | self.log.general('Attack finished') 917 | 918 | # ======== 919 | # SHUTDOWN 920 | # ======== 921 | 922 | self.shutdown(complete=True) 923 | break 924 | 925 | # ================== 926 | # EXCEPTION HANDLING 927 | # ================== 928 | 929 | except Exception as e: 930 | 931 | if isinstance(e, BreakerTrippedError): 932 | 933 | self.log.general('Exiting due to breaker trip') 934 | return 935 | 936 | # Raise to caller 937 | self.log.general( 938 | 'Unhandled exception occurred. Shutting down attack '\ 939 | 'and returning control to the caller.' 940 | ) 941 | 942 | self.shutdown(complete=False) 943 | 944 | raise e 945 | -------------------------------------------------------------------------------- /bruteloops/db_manager.py: -------------------------------------------------------------------------------- 1 | from . import sql 2 | from . import logging 3 | from pathlib import Path 4 | from sqlalchemy import ( 5 | create_engine, 6 | select, 7 | update, 8 | delete, 9 | join, 10 | not_, 11 | func, 12 | event) 13 | from sqlalchemy.orm import sessionmaker, aliased 14 | from sqlalchemy.dialects.sqlite import insert 15 | from io import StringIO,TextIOWrapper 16 | from sys import stderr 17 | from functools import wraps 18 | from inspect import signature 19 | from typing import ( 20 | Union, 21 | Callable, 22 | Any, 23 | Tuple, 24 | List, 25 | Dict) 26 | from logging import Logger 27 | import csv 28 | import re 29 | 30 | # TODO: This is stupid. See notes in "scan_dictreader" 31 | RE_USERNAME = re.compile('username',re.I) 32 | RE_PASSWORD = re.compile('password',re.I) 33 | 34 | def check_container( 35 | f:Callable[Union[TextIOWrapper, csv.DictReader], Any]): 36 | '''Determine if the container argument is a file-like 37 | object and update the `is_file` and `is_dictreader` 38 | keyword aguments accordingly. 39 | 40 | Args: 41 | f: Callable to decorate. 42 | ''' 43 | 44 | s = signature(f) 45 | 46 | @wraps(f) 47 | def wrapper(*args, container, **kwargs): 48 | 49 | pkeys = s.parameters.keys() 50 | kkeys = kwargs.keys() 51 | 52 | if 'is_file' in pkeys and container and \ 53 | 'is_file' not in kkeys: 54 | 55 | kwargs['is_file'] = \ 56 | isinstance(container, (TextIOWrapper,StringIO,)) 57 | 58 | if 'is_dictreader' in pkeys and container and \ 59 | 'is_dictreader' not in kkeys: 60 | 61 | kwargs['is_dictreader'] = isinstance(container, csv.DictReader) 62 | 63 | return f(container=container, *args, **kwargs) 64 | 65 | return wrapper 66 | 67 | def scan_dictreader(container:csv.DictReader, 68 | as_credentials:bool) -> Tuple[str, str]: 69 | '''Scan the container and find the username and password keys. 70 | 71 | Args: 72 | container: Dictreader to scan username/password key from. 73 | as_credentials: Determines if a ValueError exception should be 74 | raised when the keys are not enumerated. 75 | 76 | Returns: 77 | Tuple containing the enumerated keys. 78 | 79 | Notes: 80 | - This is a terribly stupid function. 81 | - Suspect I'd initially thought of searching all headers and 82 | hitting on any that contained the "username" and "password" 83 | strings. 84 | 85 | Todo: 86 | - Remove this function and all references in future versions. 87 | ''' 88 | 89 | # TODO: Update this such that the user can specify the 90 | # columns for the username/password values. 91 | 92 | # Iterate over each field name and find the username 93 | # and password field 94 | username_key, password_key = None, None 95 | for k in container.fieldnames: 96 | if username_key and password_key: break 97 | elif re.match(RE_USERNAME,k): username_key = k 98 | elif re.match(RE_PASSWORD,k): password_key = k 99 | 100 | # Ensure that there's a username and password key 101 | # in the header field 102 | if as_credentials and not username_key or \ 103 | not password_key: 104 | 105 | raise ValueError( 106 | 'CSV file must have "username" and "password" ' 107 | 'word field in the first line of the CSV file ' 108 | 'in order to map the inputs properly. Skipping' 109 | ' CSV file. Current fields: ' 110 | f'{container.fieldnames}') 111 | 112 | elif not username_key and not password_key: 113 | 114 | raise ValueError( 115 | 'CSV file must have at least a "username" or ' 116 | '"password" field in the first line of the' 117 | ' CSV file in order to map the inputs ' 118 | 'properly. Skipping CSV file.') 119 | 120 | return username_key, password_key 121 | 122 | def flatten_dict_values(lst:List[Dict[str, str]]): 123 | '''Modify a list of dictionaries with a "value" element and 124 | "flatten" it such that it becomes a list of individual string 125 | values. 126 | 127 | Args: 128 | lst: List of dictionary values. 129 | 130 | Warnings: 131 | - `lst` is modified inline. 132 | ''' 133 | 134 | for i in range(0, len(lst)): 135 | lst[i] = lst[i]['value'] 136 | 137 | def split_credential_container(container:list, username_key:bool=None, 138 | password_key:bool=None, credential_delimiter:str=':', 139 | as_credentials:bool=False, 140 | non_cred_format:Union[list,dict]=dict) -> Tuple[dict, list, list]: 141 | '''Split a container of credential values into three containers and 142 | return them. 143 | 144 | Args: 145 | container: A container of credential values to handle. 146 | username_key: When working on a csv.DictReader object, the header 147 | for the username column. 148 | password_key: When working on a csv.DictReader object, the header 149 | for the password column. 150 | as_credentials: Determines if the values should be strict credentials. 151 | credential_delimiter: Character/sequence used to delimit username and 152 | password values. 153 | non_cred_format: Dictates the output format of non-credential values, 154 | i.e. usernames or passwords. Expects either `dict` or `list`. When 155 | `dict` is supplied, password values will also include the proper 156 | "sprayable" attribute value in accordance with the value supplied 157 | for `as_credentials`. In both cases, `dict` format produces a 158 | structure like `{"value":"username or password"}`, making `dict` 159 | the most suitable input when preparing to insert records. When 160 | `list` is supplied, a list of string values will be returned. 161 | 162 | Returns: 163 | A tuple: 164 | 165 | 166 | - Element 1: dictionary of credential values, organized by username. 167 | - Element 2: A list of username or dictionary values. 168 | - Element 3: A list of password or dictionary values. 169 | ''' 170 | 171 | is_dictreader = isinstance(container, csv.DictReader) 172 | 173 | if is_dictreader and (not username_key or not password_key): 174 | 175 | raise ValueError( 176 | 'username_key and username_key required when operating ' 177 | 'on csv.DictReader objects.') 178 | 179 | credentials, usernames, passwords = {}, [], [] 180 | while container: 181 | 182 | value = container.pop(0) 183 | 184 | if is_dictreader or (username_key and password_key): 185 | 186 | # Parsed from CSV library because we have a reader 187 | username, password = (value[username_key], 188 | value[password_key],) 189 | 190 | else: 191 | 192 | # Parsed as a non-standard CSV value 193 | username, password = csv_split(value, 194 | credential_delimiter) 195 | 196 | # ================================ 197 | # CAPTURE USERNAME/PASSWORD VALUES 198 | # ================================ 199 | 200 | if username: 201 | 202 | # Capture the username value 203 | usernames.append(dict(value = username)) 204 | 205 | if password: 206 | 207 | # Capture the password value 208 | passwords.append(dict(value = password, 209 | sprayable = not as_credentials)) 210 | 211 | if as_credentials and username and password: 212 | 213 | # ============================ 214 | # AGGREGATE CREDENTIAL RECORDS 215 | # ============================ 216 | 217 | if not username in credentials: 218 | 219 | # Track new username 220 | credentials[username] = [password] 221 | 222 | elif username in credentials and not \ 223 | password in credentials[username]: 224 | 225 | # Insert new password for username 226 | credentials[username].append(password) 227 | 228 | return credentials, usernames, passwords 229 | 230 | def strip_newline(s): 231 | '''Strips the final character from a string via list comprehension. 232 | Useful when ```str.strip()``` might pull a legitimate whitespace 233 | character from a password. 234 | ''' 235 | 236 | if s[-1] == '\n': return s[:len(s)-1] 237 | else: return s 238 | 239 | def is_iterable(obj): 240 | '''Check if an object has the `__iter__` and `__next__` attributes, 241 | suggesting it is an iterable object. 242 | ''' 243 | 244 | d = obj.__dir__() 245 | if '__iter__' in d and '__next__' in d: return True 246 | else: return True 247 | 248 | def csv_split(s,delimiter=','): 249 | '''Split a string on the first instance of a delimiter value. 250 | A tuple in the form of `(s_head,s_tail)` is returned, otherwise 251 | a tuple of `(None,None)` if the delimiter is not observed. 252 | ''' 253 | 254 | ind=s.find(delimiter) 255 | if ind == -1: return (None,None,) 256 | return (s[:ind],s[ind+len(delimiter):],) 257 | 258 | @check_container 259 | def chunk_container(container:Any, callback:Callable, 260 | is_file:bool=False, threshold:int=10000, cargs:tuple=None, 261 | ckwargs:dict=None): 262 | '''Break a container of items down into chunks and pass them 263 | to a callback for further processing. Particularly useful when 264 | inserting/upserting records into a database. 265 | 266 | Args: 267 | container: An iterable containing values to act upon. 268 | callback: A callback that will receive the chunked values 269 | from container. 270 | is_file: Boolean value indicating if the records are 271 | originating from a file. If so, newlines are stripped. 272 | threshold: The maximum number of records to pass back to 273 | `callback`. 274 | cargs: Positional arguments passed to `callback`. 275 | ckwargs: Keyword arguments passed to `callback. 276 | ''' 277 | 278 | cargs = cargs if cargs is not None else tuple() 279 | ckwargs = ckwargs if ckwargs is not None else dict() 280 | 281 | chunk = [] 282 | for v in container: 283 | 284 | if is_file and isinstance(v,str): 285 | 286 | # Strip newlines from file strings 287 | v = strip_newline(v) 288 | 289 | # Append the item to the chunk 290 | chunk.append(v) 291 | 292 | if len(chunk) == threshold: 293 | # Call the callback for the chunk 294 | callback(*cargs, chunk=chunk, **ckwargs) 295 | chunk.clear() 296 | 297 | if chunk: 298 | # Process any remaining chunks 299 | callback(*cargs, chunk=chunk, **ckwargs) 300 | 301 | if is_file and hasattr(container,'seek'): 302 | # Seek any containers back to 0 303 | container.seek(0) 304 | 305 | class DBMixin: 306 | '''This class provides methods for managing SQLite databases 307 | supporting brute force attacks. 308 | ''' 309 | 310 | def do_upsert(self, model:str, values:list, 311 | index_elements:List[str]=['value'], 312 | do_update_where:str=None, update_data:dict=None, 313 | logger:Logger=None): 314 | '''Insert values into a model (database table) via upsert. 315 | 316 | Args: 317 | model: SQLAlchemy ORM model. 318 | values: Values to insert into the database table. 319 | do_update_where: Condition that determines if records 320 | should be updated during the upsert, e.g. 321 | sql.Password.sprayable == False. When supplied, only 322 | records matching the condition will be altered. 323 | update_data: Dictionary of field to value mappings used to 324 | update fields. 325 | logger: Logger instance used to send log events. 326 | ''' 327 | 328 | # https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#insert-on-conflict-upsert 329 | s = insert(model).values(values) 330 | 331 | if do_update_where is not None and update_data: 332 | 333 | # TODO: Perform checks on do_update where 334 | # must be a query, I think. 335 | 336 | if not isinstance(update_data, dict): 337 | raise ValueError( 338 | f'update_data must be a dictionary of data') 339 | 340 | s = s.on_conflict_do_update( 341 | index_elements=index_elements, 342 | where=do_update_where, 343 | set_=update_data) 344 | 345 | else: 346 | 347 | s = s.on_conflict_do_nothing( 348 | index_elements=index_elements) 349 | 350 | try: 351 | 352 | with self.main_db_sess.begin_nested(): 353 | self.main_db_sess.execute(s) 354 | 355 | except Exception as e: 356 | 357 | if logger: 358 | logger.debug(f'Failed to upsert values: {e}') 359 | self.main_db_sess.rollback() 360 | 361 | def delete_lines(self, container, model): 362 | '''Delete lines from `container`. 363 | ''' 364 | 365 | def _delete_values(chunk): 366 | 367 | self.main_db_sess.execute( 368 | delete(model) 369 | .where(model.value.in_(chunk))) 370 | self.main_db_sess.commit() 371 | 372 | chunk_container( 373 | container = container, 374 | callback = _delete_values) 375 | 376 | def manage_values(self, model:Union['username','password'], 377 | container:List[str], 378 | is_file:bool=False, insert:bool=True, 379 | associate_spray_values:bool=True, 380 | logger:Logger=None): 381 | '''Manage username and password database records in a database. 382 | It serves the same purpose as other `insert_` methods while 383 | also handling containers of various file types. 384 | 385 | Args: 386 | model: Name of the model to target, i.e. username or 387 | password. 388 | container: A list of string values. 389 | insert: Determines if values should be inserted (`True`) or 390 | deleted (`False`). The `is_flag` argument indicates if the 391 | values within `container` should be treated as file paths. 392 | is_file: Indicates if container values point to string file 393 | paths. When `True`, each file will be accessed and 394 | imported to the database. 395 | associate_spray_values: Determines if association records 396 | should be created for the newly imported values. This 397 | can often be delayed until the final passwords have been 398 | imported. 399 | logger: Used to log events. 400 | 401 | Notes: 402 | - This method is used to import spray values into the 403 | database. 404 | - Use `DBMixin.manage_credentials` to import credential 405 | values in the database. 406 | - Yes, `manage_credentials` can be used to treat credential 407 | records as spray values, too. 408 | ''' 409 | 410 | # ================================ 411 | # DERIVE METHOD AND PREPARE KWARGS 412 | # ================================ 413 | 414 | # Derive the target method to call based on action 415 | method = ('insert' if insert else 'delete') + '_' + \ 416 | ('username' if model in (sql.Username,'username',) else 'password') + \ 417 | '_records' 418 | method = getattr(self, method) 419 | 420 | # Prepare kwargs 421 | kwargs = dict( 422 | associate_spray_values = ( 423 | method.__name__.startswith('insert') 424 | and associate_spray_values 425 | ) 426 | ) 427 | 428 | # =================================== 429 | # CALL THE METHOD BASED ON INPUT TYPE 430 | # =================================== 431 | 432 | if is_file: 433 | 434 | for f in container: 435 | 436 | with open(f) as container: 437 | method(container=container, **kwargs) 438 | 439 | else: 440 | 441 | method(container=container, **kwargs) 442 | 443 | # This method commits :) 444 | self.sync_lookup_tables(logger=logger) 445 | 446 | # =========================== 447 | # USERNAME MANAGEMENT METHODS 448 | # =========================== 449 | 450 | def insert_username_records(self, container, 451 | associate_spray_values=True): 452 | '''Insert each username value in the container into the target 453 | database. Duplicates will not be inserted. 454 | ''' 455 | 456 | def _upsert_values(chunk): 457 | 458 | self.do_upsert( 459 | model = sql.Username, 460 | values = [dict(value = v) for v in chunk]) 461 | 462 | self.main_db_sess.commit() 463 | 464 | if associate_spray_values: 465 | self.associate_spray_values(username_values=chunk) 466 | 467 | chunk_container(container = container, 468 | callback = _upsert_values) 469 | 470 | def delete_username_records(self, container): 471 | '''Delete each username value in the container from the target 472 | database. Values that do not exist in the database are ignored. 473 | ''' 474 | 475 | self.delete_lines(container=container, model=sql.Username) 476 | 477 | def disable_username_records(self, container): 478 | '''Set the actionable attribute on each record in the container 479 | to False, removing them from further guesses. 480 | ''' 481 | 482 | self.main_db_sess.execute( 483 | update(sql.Username) 484 | .where( 485 | sql.Username.value.in_(container), 486 | sql.Username.actionable == True) 487 | .values(actionable = False)) 488 | 489 | self.main_db_sess.commit() 490 | 491 | def enable_username_records(self, container): 492 | '''Set the actionable attribute on each record in the container 493 | to True, ensuring they will be targeted for further guesses. 494 | ''' 495 | 496 | self.main_db_sess.execute( 497 | update(sql.Username) 498 | .where( 499 | sql.Username.value.in_(container), 500 | sql.Username.actionable == False) 501 | .values(actionable = True)) 502 | 503 | self.main_db_sess.commit() 504 | 505 | # =========================== 506 | # PASSWORD MANAGEMENT METHODS 507 | # =========================== 508 | 509 | def insert_password_records(self, container, 510 | associate_spray_values=True): 511 | '''Insert individual password records. Additional processing must 512 | occur on individual passwords in order to make the associations 513 | with username values. 514 | 515 | Warning: This method assumes that the container has spray records, 516 | resulting in each password being associated with each username in 517 | the form of a potential credential. 518 | ''' 519 | 520 | def _upsert_values(chunk): 521 | '''Upsert password values. 522 | 523 | Function ensures that password values inserted are treated 524 | as spray values. Should a known password be supplied in 525 | chunk and that password's "sprayable" attribute be set to 526 | False, it will be updated to True. 527 | ''' 528 | 529 | self.do_upsert( 530 | model = sql.Password, 531 | values = [dict(value=v) for v in chunk], 532 | do_update_where = (sql.Password.sprayable == False), 533 | update_data = dict(sprayable = True)) 534 | 535 | self.main_db_sess.commit() 536 | 537 | if associate_spray_values: 538 | self.associate_spray_values(password_values=chunk) 539 | 540 | chunk_container( 541 | container = container, 542 | callback = _upsert_values) 543 | 544 | # This method commits :) 545 | self.sync_lookup_tables() 546 | 547 | def associate_spray_values(self, username_values:List[str]=None, 548 | password_values:List[str]=None, logger:Logger=None): 549 | '''Create records in the credentials association table for 550 | spray values. 551 | 552 | When called _without_ `username` and/or `password` values, 553 | association records will be created for all sprayable 554 | passwords and accounts that have not yet been recovered. 555 | 556 | When called with `username` and/or `password` arguments, 557 | only create association records where their values are 558 | matched in their respective tables. This makes insertion 559 | significantly more efficient. 560 | 561 | Args: 562 | username_values: A list of username values to associate. 563 | password_values: A list of string password values to 564 | associate. 565 | logger: Logger instance. 566 | ''' 567 | 568 | AND_TEMP = ' AND {table}.value IN ("{values}")' 569 | 570 | if logger: 571 | logger.debug('Associating spray values.') 572 | 573 | # TODO: Update this to use the ORM. It's complicated though. 574 | query = ('INSERT INTO credentials ' 575 | '(username_id, password_id, valid, strict, guessed, guess_time) ' 576 | 'SELECT usernames.id, passwords.id, false, false, false, -1 ' 577 | 'FROM usernames, passwords ' 578 | 'WHERE passwords.sprayable = true' 579 | ' AND usernames.recovered = false') 580 | 581 | if username_values: 582 | 583 | query += AND_TEMP.format( 584 | table='usernames', 585 | values='","'.join(username_values)) 586 | 587 | if password_values: 588 | 589 | query += AND_TEMP.format( 590 | table='passwords', 591 | values='","'.join(password_values)) 592 | 593 | query += ' ON CONFLICT DO NOTHING;' 594 | 595 | self.main_db_sess.execute(query) 596 | self.main_db_sess.commit() 597 | 598 | if logger: 599 | logger.debug('Finished associating spray values.') 600 | 601 | def delete_password_records(self, container): 602 | '''Delete each password value in the ocntainer from the target 603 | database. Values that do not exist in the database are ignored. 604 | ''' 605 | 606 | self.delete_lines(container, sql.Password) 607 | 608 | # ============================= 609 | # CREDENTIAL MANAGEMENT RECORDS 610 | # ============================= 611 | 612 | def manage_credentials(self, container:List[str], is_file:bool=False, 613 | credential_delimiter:str=':', as_credentials:bool=False, 614 | insert:bool=True, is_csv_file:bool=False, 615 | associate_spray_values:bool=True, logger:Logger=None): 616 | '''Manage credential values in the database. 617 | 618 | Args: 619 | container: A list of string values to manage. The mutually 620 | exclusive `is_file` and `is_csv_file` flags determine how 621 | values within this container are managed. 622 | credential_delimiter: The sequenct to split credential 623 | records on. 624 | as_credentials: Determines if values being imported should 625 | be treated as strict credentials. 626 | insert: Determines if values should be inserted (`True`) or 627 | deleted (`False`). 628 | is_file: When `True`, treat each value in `container` as a 629 | path to a newline delimited file for processing. 630 | is_csv_file: When `True`, treat each value in `container` as 631 | a CSV file for processing. 632 | associate_spray_values: Indicates if spray values should be 633 | associated after execution. 634 | logger: Logger for events. 635 | 636 | Raises: 637 | Exception: when `is_file` and `is_csv_file` are set. 638 | 639 | Notes: 640 | - `is_file` and `is_csv_file` are mutually exclusive. 641 | - When both `is_file` and `is_csv_file` are `False`, values 642 | in container are imported directly as credential values. 643 | ''' 644 | 645 | 646 | if is_file and is_csv_file: 647 | raise Exception('is_file and is_csv_file are mutually ' 648 | 'exclusive.') 649 | 650 | # ================================ 651 | # DERIVE METHOD AND PREPARE KWARGS 652 | # ================================ 653 | 654 | method = ('insert' if insert else 'delete') + '_credential_records' 655 | method = getattr(self, method) 656 | 657 | kwargs = dict( 658 | as_credentials = as_credentials, 659 | credential_delimiter = credential_delimiter 660 | ) 661 | 662 | if method.__name__.startswith('insert'): 663 | kwargs['associate_spray_values'] = associate_spray_values 664 | 665 | # =================================== 666 | # CALL THE METHOD BASED ON INPUT TYPE 667 | # =================================== 668 | 669 | if is_csv_file: 670 | 671 | # =============================== 672 | # TREAT EACH RECORD AS A CSV FILE 673 | # =============================== 674 | 675 | for f in container: 676 | with open(f, newline='') as container: 677 | reader = csv.DictReader(container) 678 | method(container=reader, **kwargs) 679 | 680 | elif is_file: 681 | 682 | # =========================== 683 | # TREAT EACH RECORD AS A FILE 684 | # =========================== 685 | 686 | for f in container: 687 | with open(f) as container: 688 | method(container=container, **kwargs) 689 | 690 | else: 691 | 692 | method(container=container, **kwargs) 693 | 694 | # This method commits :) 695 | self.sync_lookup_tables(logger=logger) 696 | 697 | @check_container 698 | def insert_credential_records(self, container, as_credentials=False, 699 | credential_delimiter=':', is_file=False, is_dictreader=False, 700 | associate_spray_values=True): 701 | '''Insert credential records into the database. If as_credentials 702 | is True, then only StrictCredential records will be created 703 | for each username to password value. Records will otherwise be 704 | treated as spray values, resulting in each supplied password being 705 | set for guess across all usernames. 706 | ''' 707 | 708 | # ================================= 709 | # PREPARE KEY FIELDS FOR CSV INPUTS 710 | # ================================= 711 | 712 | if is_file: container.seek(0) 713 | 714 | username_key, password_key = None, None 715 | if is_dictreader: 716 | username_key, password_key = \ 717 | scan_dictreader(container, as_credentials) 718 | 719 | def _upsert_values(chunk): 720 | 721 | # ====================================== 722 | # BREAK THE RECORDS DOWN INTO CONTAINERS 723 | # ====================================== 724 | 725 | credentials, usernames, passwords = \ 726 | split_credential_container(chunk, 727 | username_key=username_key, 728 | password_key=password_key, 729 | credential_delimiter=credential_delimiter, 730 | as_credentials=as_credentials) 731 | 732 | # ================ 733 | # UPSERT USERNAMES 734 | # ================ 735 | 736 | self.do_upsert(model = sql.Username, 737 | values = usernames) 738 | 739 | # ================ 740 | # UPSERT PASSWORDS 741 | # ================ 742 | 743 | if not as_credentials: 744 | 745 | # Sprayable passwords 746 | # Also updates currently existing non-sprayable passwords 747 | # to become sprayable. 748 | self.do_upsert(model = sql.Password, 749 | values = passwords, 750 | do_update_where = 751 | (sql.Password.sprayable == False), 752 | update_data=dict(sprayable = True)) 753 | 754 | # ====================== 755 | # ASSOCIATE SPRAY VALUES 756 | # ====================== 757 | 758 | # Commit current database changes 759 | self.main_db_sess.commit() 760 | 761 | # Translate lists of dictionaries to 762 | # lists of strings 763 | flatten_dict_values(usernames) 764 | flatten_dict_values(passwords) 765 | 766 | # Associate the newly inserted values 767 | if associate_spray_values: 768 | 769 | # Set spray values for new usernames 770 | self.associate_spray_values(username_values=usernames) 771 | 772 | # Set spray values for these passwords to all usernames 773 | self.associate_spray_values(password_values=passwords) 774 | 775 | else: 776 | 777 | # Non-sprayable passwords 778 | self.do_upsert(model = sql.Password, 779 | values = passwords) 780 | 781 | # Free up memory 782 | usernames = list(credentials.keys()) 783 | del(passwords) 784 | 785 | # Commit database changes 786 | self.main_db_sess.commit() 787 | 788 | # =============================== 789 | # CREATE CREDENTIAL RECORD VALUES 790 | # =============================== 791 | 792 | values = [] 793 | for username in usernames: 794 | 795 | passwords = credentials[username] 796 | del(credentials[username]) 797 | 798 | # =============================== 799 | # CREATE CREDENTIAL RECORD VALUES 800 | # =============================== 801 | 802 | username = self.main_db_sess.query(sql.Username) \ 803 | .filter(sql.Username.value == username) \ 804 | .first() 805 | 806 | for password in self.main_db_sess.query(sql.Password) \ 807 | .filter(sql.Password.value.in_(passwords)): 808 | 809 | values.append(dict( 810 | username_id = username.id, 811 | password_id = password.id, 812 | strict = True)) 813 | 814 | # ============================= 815 | # UPSERT THE CREDENTIAL RECORDS 816 | # ============================= 817 | 818 | self.do_upsert(model = sql.Credential, 819 | values = values, 820 | index_elements=['username_id', 'password_id']) 821 | 822 | if associate_spray_values: 823 | 824 | # Associate all sprayable passwords back to the new 825 | # username values 826 | self.associate_spray_values(username_values=usernames) 827 | 828 | chunk_container(container = container, 829 | callback = _upsert_values, 830 | is_file = not is_dictreader and is_file) 831 | 832 | def sync_lookup_tables(self, logger:Logger=None): 833 | '''Update the sql.StrictCredential and sql.PriorityCredential tables 834 | with reference to the proper credential values. 835 | 836 | sql.StrictCredential and sql.PriorityCredential are lookup tables 837 | intended to be more efficiently refrenced than sql.Credental, which 838 | contains all possible credential records. This means quicker query 839 | response. 840 | 841 | Args: 842 | logger: Logger instance to support event logging. 843 | ''' 844 | 845 | def _upsert_strict_creds(chunk): 846 | 847 | self.do_upsert( 848 | model = sql.StrictCredential, 849 | index_elements=['credential_id'], 850 | values = [ 851 | dict(credential_id = c.id) for c in chunk 852 | ]) 853 | 854 | def _upsert_priority_creds(chunk): 855 | 856 | self.do_upsert( 857 | model = sql.PriorityCredential, 858 | index_elements=['credential_id'], 859 | values = [ 860 | dict(credential_id = c[0]) for c in chunk 861 | ]) 862 | 863 | self.main_db_sess.commit() 864 | 865 | # ============================== 866 | # TODO: MAKE THIS MORE EFFICIENT 867 | # ============================== 868 | 869 | # Clear the lookup tables to start fresh 870 | # - We do this because some passwords may have been altered 871 | # since the last run 872 | for m in (sql.StrictCredential, sql.PriorityCredential,): 873 | self.main_db_sess.execute(delete(m)) 874 | self.main_db_sess.commit() 875 | 876 | # ================== 877 | # STRICT CREDENTIALS 878 | # ================== 879 | 880 | if logger: 881 | logger.general('Linking strict credentials') 882 | 883 | strict_query = ( 884 | self.main_db_sess 885 | .query(sql.Credential) 886 | .filter(sql.Credential.strict == True) 887 | ) 888 | 889 | chunk_container( 890 | container = strict_query, 891 | callback = _upsert_strict_creds) 892 | 893 | # ==================== 894 | # PRIORITY CREDENTIALS 895 | # ==================== 896 | 897 | if logger: 898 | logger.general('Linking priority credentials') 899 | 900 | priority_query = (select(sql.Credential.id) 901 | .where( 902 | sql.Credential.password_id.in_( 903 | select(sql.Password.id).where( 904 | sql.Password.priority == True) 905 | ) | 906 | sql.Credential.username_id.in_( 907 | select(sql.Username.id).where( 908 | sql.Username.priority == True) 909 | ) 910 | )) 911 | 912 | chunk_container( 913 | container = self.main_db_sess.execute(priority_query), 914 | callback = _upsert_priority_creds) 915 | 916 | self.main_db_sess.commit() 917 | 918 | @check_container 919 | def delete_credential_records(self, container, 920 | as_credentials:bool=False, 921 | credential_delimiter:str=':', is_file:bool=False, 922 | is_dictreader:bool=False): 923 | '''Delete credential records from the target database. 924 | ''' 925 | 926 | if is_file: container.seek(0) 927 | 928 | username_key, password_key = None, None 929 | 930 | if is_dictreader: 931 | 932 | username_key, password_key = scan_dictreader(container, 933 | as_credentials) 934 | 935 | def _delete_values(chunk): 936 | 937 | credentials, usernames, passwords = \ 938 | split_credential_container(chunk, 939 | username_key=username_key, 940 | password_key=password_key, 941 | as_credentials=as_credentials) 942 | 943 | if as_credentials: 944 | 945 | # ========================== 946 | # DESTROY CREDENTIAL RECORDS 947 | # ========================== 948 | 949 | del(usernames) 950 | del(passwords) 951 | 952 | ids = [] 953 | for username in list(credentials.keys()): 954 | 955 | # ================================ 956 | # COLLECT CREDENTIALS FOR THE USER 957 | # ================================ 958 | 959 | ids += [ 960 | i.id for i in 961 | self.main_db_sess.query(sql.Credential) \ 962 | .join(sql.Username) \ 963 | .join(sql.Password) \ 964 | .filter( 965 | sql.Username.value == username, 966 | sql.Password.value.in_( 967 | credentials[username]), 968 | sql.Credential.guessed == False) 969 | ] 970 | 971 | # ====================== 972 | # APPLY THE DELETE QUERY 973 | # ====================== 974 | 975 | self.main_db_sess.execute( 976 | delete(sql.Credential) 977 | .where(sql.Credential.id.in_(ids))) 978 | 979 | self.main_db_sess.commit() 980 | 981 | 982 | else: 983 | 984 | # ================================ 985 | # DESTROY USERNAME/PASSWORD VALUES 986 | # ================================ 987 | 988 | flatten_dict_values(usernames) 989 | flatten_dict_values(passwords) 990 | 991 | if usernames: 992 | 993 | self.delete_lines( 994 | container=usernames, 995 | model=sql.Username) 996 | 997 | if passwords: 998 | 999 | self.delete_lines( 1000 | container=passwords, 1001 | model=sql.Password) 1002 | 1003 | chunk_container( 1004 | container = container, 1005 | callback = _delete_values) 1006 | 1007 | self.main_db_sess.commit() 1008 | 1009 | # ======================================= 1010 | # DELETE ORPHANED NON-SPRAYABLE PASSWORDS 1011 | # ======================================= 1012 | 1013 | aPass = aliased(sql.Password) 1014 | aCred = aliased(sql.Credential) 1015 | 1016 | query = select(sql.Password.id) \ 1017 | .select_from(sql.Password) \ 1018 | .select_from( 1019 | join(sql.Password, sql.Credential)) \ 1020 | .where( 1021 | sql.Password.sprayable == False, 1022 | not_( 1023 | select(sql.Credential.password_id) 1024 | .where(aPass.id == aCred.password_id) 1025 | .exists())) 1026 | 1027 | # Get the password ids 1028 | ids = self.main_db_sess.execute(query).all() 1029 | 1030 | if ids: 1031 | 1032 | # Flatten the row tuples 1033 | for i in range(0, len(ids)): 1034 | ids[i] = ids[i][0] 1035 | 1036 | # Delete the records 1037 | self.main_db_sess.execute( 1038 | delete(sql.Password) 1039 | .where(sql.Password.id.in_(ids))) 1040 | 1041 | self.main_db_sess.commit() 1042 | 1043 | def get_or_create(self, model, value): 1044 | '''Get or create an individual database instance, the return 1045 | value. 1046 | ''' 1047 | 1048 | instance = self.main_db_sess.query(model) \ 1049 | .filter(model.value == value) \ 1050 | .first() 1051 | 1052 | if instance: 1053 | return False, instance 1054 | else: 1055 | instance = model(value=value) 1056 | self.main_db_sess.add(instance) 1057 | self.main_db_sess.commit() 1058 | self.main_db_sess.flush() 1059 | return True, instance 1060 | 1061 | goc = get_or_create 1062 | 1063 | def manage_priorities(self, usernames:list=None, 1064 | passwords:list=None, prioritize:bool=False, 1065 | logger=None): 1066 | '''Prioritize or deprioritize database values. 1067 | 1068 | Args: 1069 | usernames: A list of string username values. 1070 | passwords: A list of string password values. 1071 | prioritize: Boolean determining if the values should be 1072 | prioritized or deprioritized. 1073 | ''' 1074 | 1075 | if not usernames and not passwords: 1076 | raise ValueError('usernames or passwords required') 1077 | 1078 | usernames = usernames if usernames != None else [] 1079 | passwords = passwords if passwords != None else [] 1080 | 1081 | if usernames: 1082 | 1083 | # Manage username priorities 1084 | self.main_db_sess.execute( 1085 | update(sql.Username) 1086 | .where( 1087 | sql.Username.value.in_(usernames)) 1088 | .values(priority = True)) 1089 | 1090 | if passwords: 1091 | 1092 | # Manage password priorities 1093 | self.main_db_sess.execute( 1094 | update(sql.Password) 1095 | .where( 1096 | sql.Password.value.in_(passwords)) 1097 | .values(priority = True)) 1098 | 1099 | self.main_db_sess.commit() 1100 | 1101 | # This method commits :) 1102 | self.sync_lookup_tables(logger=logger) 1103 | 1104 | def manage_db_values(self, insert=True, usernames=None, 1105 | passwords=None, username_files=None, password_files=None, 1106 | credentials=None, credential_files=None, 1107 | credential_delimiter=':', as_credentials=False, 1108 | csv_files=None, associate_spray_values=True, 1109 | logger=None): 1110 | 1111 | # =============== 1112 | # VALIDATE INPUTS 1113 | # =============== 1114 | 1115 | for v in [usernames,passwords,username_files,password_files, 1116 | credentials,credential_files]: 1117 | if is_iterable(v): continue 1118 | raise ValueError( 1119 | 'Username/Password arguments must be iterable values ' \ 1120 | 'populated with string records or file names' 1121 | ) 1122 | 1123 | # Make sure that only credential inputs are allowed when the 1124 | # as_credentials flag is set to true 1125 | if as_credentials: 1126 | 1127 | msg = 'Only credentials or credential_files can be supplied ' \ 1128 | 'when using the as_credentials flag is set to True' 1129 | 1130 | if usernames or passwords or username_files or \ 1131 | password_files: raise ValueError(msg) 1132 | 1133 | if not usernames and not username_files and \ 1134 | not passwords and not password_files and \ 1135 | not credentials and not credential_files and \ 1136 | not csv_files and logger: 1137 | logger.debug('No values to manage supplied to db manager') 1138 | return 1139 | 1140 | # =============== 1141 | # BEGIN EXECUTION 1142 | # =============== 1143 | 1144 | if logger: 1145 | logger.debug(f'Starting db management. Action: ' + \ 1146 | ('INSERT' if insert else 'DELETE')) 1147 | 1148 | # =================== 1149 | # HANDLE SPRAY VALUES 1150 | # =================== 1151 | 1152 | if usernames: 1153 | if logger: 1154 | logger.debug(f'Managing usernames: {usernames}') 1155 | self.manage_values(sql.Username, usernames, insert=insert, 1156 | associate_spray_values=associate_spray_values, 1157 | logger=logger) 1158 | 1159 | if passwords: 1160 | if logger: 1161 | logger.debug(f'Managing passwords: {passwords}') 1162 | self.manage_values(sql.Password, passwords, insert=insert, 1163 | associate_spray_values=associate_spray_values, 1164 | logger=logger) 1165 | 1166 | if username_files: 1167 | if logger: 1168 | logger.debug(f'Managing username files: {username_files}') 1169 | self.manage_values(sql.Username, username_files, 1170 | is_file=True, insert=insert, 1171 | associate_spray_values=associate_spray_values, 1172 | logger=logger) 1173 | 1174 | if password_files: 1175 | if logger: 1176 | logger.debug(f'Managing password files: {password_files}') 1177 | self.manage_values(sql.Password, password_files, 1178 | is_file=True, insert=insert, 1179 | associate_spray_values=associate_spray_values, 1180 | logger=logger) 1181 | 1182 | # ======================== 1183 | # HANDLE CREDENTIAL VALUES 1184 | # ======================== 1185 | 1186 | if credentials: 1187 | if logger: 1188 | logger.debug(f'Managing credentials: {credentials}') 1189 | self.manage_credentials(credentials, 1190 | as_credentials=as_credentials, insert=insert, 1191 | associate_spray_values=associate_spray_values, 1192 | logger=logger, 1193 | credential_delimiter=credential_delimiter) 1194 | 1195 | if credential_files: 1196 | if logger: 1197 | logger.debug( 1198 | f'Managing credential files: {credential_files}') 1199 | self.manage_credentials(credential_files, is_file=True, 1200 | as_credentials=as_credentials, insert=insert, 1201 | associate_spray_values=associate_spray_values, 1202 | credential_delimiter=credential_delimiter, 1203 | logger=logger) 1204 | 1205 | if csv_files: 1206 | if logger: 1207 | logger.debug( 1208 | f'Managing CSV credential files: {csv_files}') 1209 | self.manage_credentials(csv_files, is_csv_file=True, 1210 | as_credentials=as_credentials, insert=insert, 1211 | associate_spray_values=associate_spray_values, 1212 | credential_delimiter=credential_delimiter, 1213 | logger=logger) 1214 | 1215 | def get_valid_credentials(self): 1216 | '''Return valid credentials 1217 | ''' 1218 | 1219 | # Normal credentials 1220 | valids = self.main_db_sess.query(sql.Credential) \ 1221 | .filter(sql.Credential.valid == True) \ 1222 | .all() 1223 | 1224 | return valids 1225 | 1226 | def get_credentials(self, strict:Union[bool, None]=False): 1227 | ''' 1228 | ''' 1229 | 1230 | query = self.main_db_sess.query(sql.Credential) 1231 | 1232 | if strict is not None: 1233 | query = query.filter(sql.Credential.strict == strict) 1234 | 1235 | return query.all() 1236 | 1237 | def get_strict_credentials(self): 1238 | '''Return strict credential records. 1239 | ''' 1240 | 1241 | return self.get_credentials(strict=True) 1242 | 1243 | class Manager(DBMixin): 1244 | '''Default class used to interact with SQLite databases. 1245 | ''' 1246 | 1247 | def __init__(self, db_file:str): 1248 | '''Initialize a Manager object. 1249 | 1250 | Args: 1251 | db_file: Path to the database file to manage. 1252 | ''' 1253 | 1254 | self.session_maker = Session(db_file) 1255 | 'Object that makes sessions for the database.' 1256 | 1257 | self.main_db_sess = self.session_maker.new() 1258 | 'Primary session used to interact with the database.' 1259 | 1260 | def _fk_pragma_on_connect(dbapi_con, con_record): 1261 | dbapi_con.execute('pragma foreign_keys=ON') 1262 | 1263 | class Session: 1264 | '''Session objects are used to create and manage database 1265 | sessions. 1266 | ''' 1267 | 1268 | def __init__(self, db_file:str, echo:bool=False): 1269 | '''Initialize a session object. 1270 | 1271 | Args: 1272 | db_file: Path to the database file to manage. 1273 | echo: Passed to `sqlalchemy.create_engine`. 1274 | ''' 1275 | 1276 | # ===================== 1277 | # SQLITE INITIALIZATION 1278 | # ===================== 1279 | 1280 | engine = create_engine('sqlite:///'+db_file, echo=echo) 1281 | event.listen(engine, 'connect', _fk_pragma_on_connect) 1282 | Session = sessionmaker() 1283 | Session.configure(bind=engine) 1284 | 1285 | # Create the database if required 1286 | if not Path(db_file).exists(): 1287 | sql.Base.metadata.create_all(engine) 1288 | 1289 | self.session = Session 1290 | 1291 | def new(self): 1292 | '''Create and return a new session. 1293 | ''' 1294 | 1295 | return self.session() 1296 | --------------------------------------------------------------------------------