├── .github └── workflows │ ├── docs-publish.yaml │ ├── pypi-publish.yaml │ └── pytest-cover-run.yaml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── conf.py │ ├── contents.rst │ ├── docs │ ├── internals.rst │ └── trusting-mitm.rst │ ├── index.rst │ ├── introduction │ ├── how-mitm-works.rst │ └── quickstart.rst │ └── module │ ├── core.rst │ ├── crypto.rst │ ├── extension.rst │ └── mitm.rst ├── mitm ├── __init__.py ├── core.py ├── crypto.py ├── extension │ ├── __init__.py │ ├── middleware.py │ └── protocol.py └── mitm.py ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── extension ├── __init__.py ├── test_middleware.py └── test_protocol.py ├── requirements.txt ├── test_core.py ├── test_crypto.py └── test_mitm.py /.github/workflows/docs-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checks out repo 13 | uses: actions/checkout@v1 14 | 15 | - name: Generates HTML documentation 16 | uses: synchronizing/sphinx-action@master 17 | with: 18 | pre-build-command: "apt-get update -y && apt-get install -y git" 19 | docs-folder: "docs/" 20 | 21 | - name: Saves the HTML build documentation 22 | uses: actions/upload-artifact@v4 23 | with: 24 | path: docs/build/html/ 25 | 26 | - name: Commits docs changes to gh-pages branch 27 | run: | 28 | # Copies documentation outside of git folder. 29 | mkdir -p ../docs/html 30 | cp -r docs/build/html ../docs/ 31 | # Checks out to gh-pages branch. 32 | git checkout -b gh-pages 33 | # Copies files to branch. 34 | cp -r ../docs/html/* . 35 | # Sets up no Jekyll config. 36 | touch .nojekyll 37 | # Commits the changes. 38 | git config --local user.email "action@github.com" 39 | git config --local user.name "GitHub Action" 40 | git add . 41 | git commit -m "Documentation update." -a || true 42 | 43 | - name: Push changes to gh-pages branch 44 | uses: ad-m/github-push-action@master 45 | with: 46 | branch: gh-pages 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | force: True 49 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPi 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | pypi: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checks repo out 13 | uses: actions/checkout@v2 14 | - name: Sets up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | - name: Install depedencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine . 22 | - name: Builds and publishes to PyPi 23 | env: 24 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python setup.py sdist bdist_wheel 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /.github/workflows/pytest-cover-run.yaml: -------------------------------------------------------------------------------- 1 | # Pytest + Coveralls 2 | name: Build 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | branches: 9 | - master 10 | pull_request: 11 | branches: 12 | - '*' 13 | 14 | jobs: 15 | pytest: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] 20 | steps: 21 | - name: Checks repo out 22 | uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install testing depedencies 28 | run: | 29 | python -m pip install --upgrade pip setuptools wheel 30 | pip install . 31 | pip install -r tests/requirements.txt 32 | - name: Run PyTest 33 | run: | 34 | pytest --cov=mitm tests/ 35 | - name: Run Coveralls 36 | env: 37 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | run: | 40 | coveralls 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Env 2 | .DS_Store 3 | .ropeproject 4 | .python-version 5 | .vscode 6 | openssl 7 | .venv 8 | 9 | # Python 10 | __pycache__ 11 | *.egg-info 12 | .eggs 13 | 14 | # Documentation 15 | docs/build/ 16 | 17 | # PyTest + Coverage 18 | .pytest_cache 19 | .coverage 20 | test.py 21 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = logging-fstring-interpolation, too-many-arguments, cyclic-import 3 | 4 | [FORMAT] 5 | max-line-length = 120 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Felipe Faria 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👨‍💻 mitm 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |

24 | 25 | A customizable man-in-the-middle TCP proxy with support for HTTP & HTTPS. 26 | 27 | ## Installing 28 | 29 | ``` 30 | pip install mitm 31 | ``` 32 | 33 | Note that OpenSSL 1.1.1 or greater is required. 34 | 35 | ## Documentation 36 | 37 | Documentation can be found [**here**](https://synchronizing.github.io/mitm/). 38 | 39 | ## Using 40 | 41 | Using the default values for the `MITM` class: 42 | 43 | ```python 44 | from mitm import MITM, protocol, middleware, crypto 45 | 46 | mitm = MITM( 47 | host="127.0.0.1", 48 | port=8888, 49 | protocols=[protocol.HTTP], 50 | middlewares=[middleware.Log], # middleware.HTTPLog used for the example below. 51 | certificate_authority = crypto.CertificateAuthority() 52 | ) 53 | mitm.run() 54 | ``` 55 | 56 | This will start a proxy on port `8888` that is capable of intercepting all HTTP traffic (with support for SSL/TLS) and log all activity. 57 | 58 | ## Extensions 59 | 60 | `mitm` can be customized through the implementations of middlewares and protocols. 61 | 62 | [Middlewares](https://synchronizing.github.io/mitm/docs/internals.html#mitm.core.Middleware) are event-driven hooks that are called when connections are made, requests are sent, responses are received, and connections are closed. 63 | 64 | [Protocols](https://synchronizing.github.io/mitm/docs/internals.html#mitm.core.Protocol) are implementations on _how_ the data flows between the client and server, and is used to implement [application layer](https://en.wikipedia.org/wiki/Application_layer) protocols and/or more complex extensions. 65 | 66 | ## Example 67 | 68 | Using the example above we can send a request to the server via another script: 69 | 70 | ```python 71 | import requests 72 | 73 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"} 74 | requests.get("https://httpbin.org/anything", proxies=proxies, verify=False) 75 | ``` 76 | 77 | Which will lead to the following being logged where `mitm` is running in: 78 | 79 | ``` 80 | 2022-06-08 15:07:10 INFO MITM server started on 127.0.0.1:8888. 81 | 2022-06-08 15:07:11 INFO Client 127.0.0.1:64638 has connected. 82 | 2022-06-08 15:07:11 INFO Client 127.0.0.1:64638 to mitm: 83 | 84 | → CONNECT httpbin.org:443 HTTP/1.0 85 | 86 | 2022-06-08 15:07:12 INFO Client 127.0.0.1:64638 has connected to server 34.206.80.189:443. 87 | 2022-06-08 15:07:12 INFO Client 127.0.0.1:64638 to 34.206.80.189:443: 88 | 89 | → GET /anything HTTP/1.1 90 | → Host: httpbin.org 91 | → User-Agent: python-requests/2.26.0 92 | → Accept-Encoding: gzip, deflate 93 | → Accept: */* 94 | → Connection: keep-alive 95 | 96 | 2022-06-08 15:07:12 INFO Server 34.206.80.189:443 to client 127.0.0.1:64638: 97 | 98 | ← HTTP/1.1 200 OK 99 | ← Date: Wed, 08 Jun 2022 19:07:12 GMT 100 | ← Content-Type: application/json 101 | ← Content-Length: 396 102 | ← Connection: keep-alive 103 | ← Server: gunicorn/19.9.0 104 | ← Access-Control-Allow-Origin: * 105 | ← Access-Control-Allow-Credentials: true 106 | ← 107 | ← { 108 | ← "args": {}, 109 | ← "data": "", 110 | ← "files": {}, 111 | ← "form": {}, 112 | ← "headers": { 113 | ← "Accept": "*/*", 114 | ← "Accept-Encoding": "gzip, deflate", 115 | ← "Host": "httpbin.org", 116 | ← "User-Agent": "python-requests/2.26.0", 117 | ← "X-Amzn-Trace-Id": "Root=1-62a0f360-774052c80b60f4ea049f5665" 118 | ← }, 119 | ← "json": null, 120 | ← "method": "GET", 121 | ← "origin": "xxx.xxx.xxx.xxx", 122 | ← "url": "https://httpbin.org/anything" 123 | ← } 124 | 125 | 2022-06-08 15:07:27 INFO Server 34.206.80.189:443 has disconnected. 126 | 2022-06-08 15:07:27 INFO Client 127.0.0.1:64638 has disconnected. 127 | ``` 128 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | # Adds serve ability with auto-reload. 16 | serve: 17 | @sphinx-autobuild -b html source build/html 18 | 19 | .PHONY: help Makefile 20 | 21 | # Catch-all target: route all unknown targets to Sphinx using the new 22 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 23 | %: Makefile 24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-copybutton 3 | sphinx-autobuild 4 | furo 5 | git+https://github.com/synchronizing/sphinx-autodoc-typehints.git 6 | git+https://github.com/synchronizing/mitm.git 7 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | import sys 11 | 12 | # Adds system path two folders back (where project lives.) 13 | sys.path.insert(0, os.path.abspath("../..")) 14 | 15 | # PyLint might complain, but the interpreter should be able to find this on run. 16 | from mitm import * 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "👨‍💻 mitm" 21 | copyright = "2021, Felipe Faria" 22 | author = "Felipe Faria" 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | # Project Name 27 | html_title = "{}".format(project) 28 | 29 | # Order of docs. 30 | autodoc_member_order = "bysource" 31 | 32 | # Turn off typehints. 33 | autodoc_typehints = "none" 34 | 35 | # Remove module names from class docs. 36 | add_module_names = False 37 | 38 | # Show only class docs. 39 | autoclass_content = "class" 40 | 41 | # List __init___ docstrings separately from the class docstring 42 | napoleon_include_init_with_doc = True 43 | 44 | # Removes the default values from the documentation. 45 | keep_default_values = False 46 | 47 | # Removes the class values; e.g. 'Class(val, val, val):' becomes 'Class:'. 48 | hide_class_values = True 49 | 50 | # Test 51 | default_role = "py:obj" 52 | 53 | # Add any Sphinx extension module names here, as strings. They can be 54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 55 | # ones. 56 | extensions = [ 57 | "sphinx.ext.viewcode", 58 | "sphinx.ext.napoleon", 59 | "sphinx_autodoc_typehints", 60 | "sphinx.ext.autodoc", 61 | "sphinx_copybutton", 62 | ] 63 | 64 | # Add any paths that contain templates here, relative to this directory. 65 | templates_path = ["_templates"] 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | # This pattern also affects html_static_path and html_extra_path. 70 | exclude_patterns = [] 71 | 72 | # -- Options for HTML output ------------------------------------------------- 73 | 74 | # The theme to use for HTML and HTML Help pages. See the documentation for 75 | # a list of builtin themes. 76 | html_theme = "furo" 77 | 78 | html_theme_options = {} 79 | 80 | # Add any paths that contain custom static files (such as style sheets) here, 81 | # relative to this directory. They are copied after the builtin static files, 82 | # so a file named "default.css" will overwrite the builtin "default.css". 83 | html_static_path = ["_static"] 84 | -------------------------------------------------------------------------------- /docs/source/contents.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Contents 3 | ######## 4 | 5 | .. toctree:: 6 | :caption: Introduction 7 | 8 | introduction/quickstart 9 | introduction/how-mitm-works 10 | 11 | .. toctree:: 12 | :caption: Docs 13 | 14 | docs/trusting-mitm 15 | docs/internals 16 | 17 | .. toctree:: 18 | :caption: API 19 | :glob: 20 | 21 | module/core 22 | module/crypto 23 | module/extension 24 | module/mitm 25 | -------------------------------------------------------------------------------- /docs/source/docs/internals.rst: -------------------------------------------------------------------------------- 1 | ################### 2 | Internals of `mitm` 3 | ################### 4 | 5 | Quick guide on the internal structure of `mitm`. 6 | 7 | Make sure to check out the documentation for `asyncio streams `_ before reading this section. 8 | 9 | ---- 10 | 11 | Core 12 | **** 13 | 14 | .. class:: mitm.core.Host 15 | 16 | A host is a pair of `asyncio.StreamReader` and `asyncio.StreamWriter` objects that are used to communicate with the remote host. There are two types of hosts: a client, and a server. A client host is one that is connected to the `mitm`, and a server host is one that the `mitm` connected to on behalf of the client. 17 | 18 | .. attribute:: reader 19 | 20 | The `asyncio.StreamReader` object for the host. 21 | 22 | .. attribute:: writer 23 | 24 | The `asyncio.StreamWriter` object for the host. 25 | 26 | .. attribute:: mitm_managed 27 | 28 | The `mitm_managed` attribute is used to determine whether the `mitm` is responsible for closing the connection with the host. If `mitm_managed` is True, the `mitm` will close the connection with the host when it is done with it. If `mitm_managed` is set to False, the `mitm` will not close the connection with the host, and instead, the developer must close the connection with the host manually. This is useful for situations where the `mitm` is running as a seperate utility and the developer wants to keep the connection open with the host after the `mitm` is done with it. 29 | 30 | .. attribute:: ip 31 | 32 | The IP address of the host. 33 | 34 | .. attribute:: port 35 | 36 | The port of the host. 37 | 38 | `Host` is a `dataclass `_ that is defined like so: 39 | 40 | .. code-block:: python 41 | 42 | @dataclass 43 | class Host: 44 | reader: Optional[asyncio.StreamReader] = None 45 | writer: Optional[asyncio.StreamWriter] = None 46 | mitm_managed: Optional[bool] = True 47 | 48 | .. class:: mitm.core.Connection 49 | 50 | A connection is a pair of `Host` objects that the `mitm` relays data between. When a connection is created the server host is not resolved until the data is intercepted and the protocol and destination server is figured out. 51 | 52 | .. attribute:: client 53 | 54 | The client `mitm.core.Host`. The client host connects to the `mitm`. 55 | 56 | .. attribute:: server 57 | 58 | The server `mitm.core.Host`. A server host is connected to the `mitm` on behalf of the client host. 59 | 60 | .. attribute:: protocol 61 | 62 | The `mitm.core.Protocol` object for the connection. 63 | 64 | `Connection` is a `dataclass `_ that is defined like so: 65 | 66 | .. code-block:: python 67 | 68 | @dataclass 69 | class Connection: 70 | client: Host 71 | server: Host 72 | protocol: Optional[Protocol] = None 73 | 74 | ---- 75 | 76 | Extensions 77 | ********** 78 | 79 | .. class:: mitm.core.Middleware 80 | 81 | Event-driven hook extension for the `mitm`. 82 | 83 | A middleware is a class that is used to extend the `mitm` framework by allowing event-driven hooks to be added to the `mitm` and executed when the appropriate event occurs. Built-in middlewares can be found in the `mitm.middleware` module. 84 | 85 | .. method:: mitm_started(host: str, port: int) 86 | :async: 87 | :staticmethod: 88 | 89 | Called when the `mitm` server boots-up. 90 | 91 | .. method:: client_connected(connection: Connection) 92 | :async: 93 | :staticmethod: 94 | 95 | Called when a client connects to the `mitm` server. Note that the `mitm.core.Connection` object is not fully initialized yet, and only contains a valid client `mitm.core.Host`. 96 | 97 | .. method:: server_connected(connection: Connection) 98 | :async: 99 | :staticmethod: 100 | 101 | Called when the `mitm` connects with the destination server. At this point the `mitm.core.Connection` object is fully initialized. 102 | 103 | .. method:: client_data(connection: Connection, data: bytes) -> bytes 104 | :async: 105 | :staticmethod: 106 | 107 | Raw TLS/SSL handshake is not sent through this method. Everything should be decrypted beforehand. 108 | 109 | Note: 110 | This method **must** return back data. Modified or not. 111 | 112 | .. method:: server_data(connection: Connection, data: bytes) -> bytes 113 | :async: 114 | :staticmethod: 115 | 116 | Called when the `mitm` receives data from the destination server. Data that comes through this hook can be modified and returned to the `mitm` as new data to be sent to the client. 117 | 118 | Note: 119 | This method **must** return back data. Modified or not. 120 | 121 | .. method:: client_disconnected(connection: Connection) 122 | :async: 123 | :staticmethod: 124 | 125 | Called when the client disconnects. 126 | 127 | .. method:: server_disconnected(connection: Connection) 128 | :async: 129 | :staticmethod: 130 | 131 | Called when the server disconnects. 132 | 133 | .. class:: mitm.core.Protocol 134 | 135 | Protocols are implementations on how the data flows between the client and server. Application-layer protocols are implemented by subclassing this class. Built-in protocols can be found in the `mitm.extension` package. 136 | 137 | .. attribute:: bytes_needed 138 | 139 | Specifies how many bytes are needed to determine the protocol. 140 | 141 | .. attribute:: buffer_size 142 | 143 | The size of the buffer to use when reading data. 144 | 145 | .. attribute:: timeout 146 | 147 | The timeout to use when reading data. 148 | 149 | .. attribute:: keep_alive 150 | 151 | Whether or not to keep the connection alive. 152 | 153 | Note that the attributes above must be set per-protocol basis. 154 | 155 | 156 | .. method:: resolve(connection: Connection, data: bytes) -> Tuple[str, int, bool] 157 | :async: 158 | 159 | Resolves the destination of the connection. Returns a tuple containing the host, port, and bool that indicates if the connection is encrypted. 160 | 161 | .. method:: connect(connection: Connection, host: str, port: int, data: bytes) 162 | :async: 163 | 164 | Attempts to connect to destination server using the given data. Returns `True` if the connection was successful, raises `InvalidProtocol` if the connection failed. 165 | 166 | .. method:: handle(connection: Connection) 167 | :async: 168 | 169 | Handles the connection between a client and a server. 170 | -------------------------------------------------------------------------------- /docs/source/docs/trusting-mitm.rst: -------------------------------------------------------------------------------- 1 | ############### 2 | Trusting `mitm` 3 | ############### 4 | 5 | When `mitm` runs it generates a `certificate authority `_ (CA) that is used to generate dummy certificates for each website visited. To trust this certificate you must install it on your system. By default, the certificate is generated in the `mitm.__data__` directory. To discover what this directory is do the following: 6 | 7 | .. code-block:: shell 8 | 9 | $ python 10 | Python 3.9.6 (default, Aug 31 2021, 00:19:31) 11 | [Clang 12.0.5 (clang-1205.0.22.11)] on darwin 12 | Type "help", "copyright", "credits" or "license" for more information. 13 | >>> import mitm 14 | >>> mitm.__data__ 15 | PosixPath('/Users/felipe/Library/Application Support/mitm') # You should see a different path. 16 | >>> exit() 17 | 18 | $ ls /Users/felipe/Library/Application\ Support/mitm 19 | mitm.key mitm.pem 20 | 21 | Customizing Path 22 | ---------------- 23 | 24 | To customize the path where the certificate is generated, you can use the following snippet: 25 | 26 | .. code-block:: python 27 | 28 | from mitm import MITM, CertificateAuthority, middleware, protocol 29 | from pathlib import Path 30 | 31 | # Loads the CA certificate. 32 | path = Path("/Users/felipe/Desktop") 33 | certificate_authority = CertificateAuthority.init(path=path) 34 | 35 | # Starts the MITM server. 36 | mitm = MITM( 37 | host="127.0.0.1", 38 | port=8888, 39 | protocols=[protocol.HTTP], 40 | middlewares=[middleware.Log], 41 | certificate_authority=certificate_authority, 42 | ) 43 | mitm.run() 44 | 45 | Installing CA 46 | -------------- 47 | 48 | Different systems have different ways to install the certificate. Its recommended you look up "how to install a certificate" on your system. 49 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: contents.rst 2 | -------------------------------------------------------------------------------- /docs/source/introduction/how-mitm-works.rst: -------------------------------------------------------------------------------- 1 | ############## 2 | How mitm works 3 | ############## 4 | 5 | A high-level overview on how a man-in-the-middle proxy works. 6 | 7 | ---- 8 | 9 | `mitm` is a TCP proxy server that is capable of intercepting requests and responses going through it. 10 | 11 | To understand how an mitm proxy works let's take a look at a simple example using the HTTP protocol. Let's familiarize ourselves with a raw HTTP communication, how a normal proxy functions, and finally how an MITM proxy works. 12 | 13 | ---- 14 | 15 | HTTP & HTTPS 16 | ------------ 17 | 18 | For the sake of example, imagine a client is trying to reach `example.com`: 19 | 20 | .. code-block:: python 21 | 22 | import requests 23 | requests.get("http://example.com") 24 | 25 | Whether the client is trying to reach the domain via the `requests` module or their browser, both methods must generate a valid HTTP request to send to the server. In the case of the above, `requests` would generate the following HTTP request: 26 | 27 | .. code-block:: 28 | 29 | GET http://example.com/ HTTP/1.1 30 | Host: example.com 31 | User-Agent: python-requests/2.26.0 32 | Accept-Encoding: gzip, deflate 33 | Accept: */* 34 | Connection: keep-alive 35 | 36 | This HTTP request is sent through hundreds of miles of wires until it reaches the server, which interprets the message, and replies back with the requested page: 37 | 38 | .. code-block:: 39 | 40 | HTTP/1.1 200 OK 41 | Content-Encoding: gzip 42 | Accept-Ranges: bytes 43 | Age: 111818 44 | Cache-Control: max-age=604800 45 | Content-Type: text/html; charset=UTF-8 46 | Date: Fri, 05 Nov 2021 18:49:47 GMT 47 | Etag: "3147526947" 48 | Expires: Fri, 12 Nov 2021 18:49:47 GMT 49 | Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT 50 | Server: ECS (agb/5295) 51 | Vary: Accept-Encoding 52 | X-Cache: HIT 53 | Content-Length: 648 54 | [data] 55 | 56 | The server, like the client, strictly follows the RFCs that define the `HTTP `_ protocol. In some cases, however, the client might want to create a more secure connection with the server. We know of this as HTTPS, which stands for HTTP secure. To do this, a client would connect to the server with the `https` prefix: 57 | 58 | .. code-block:: python 59 | 60 | import requests 61 | requests.get("https://example.com") 62 | 63 | In this case, the clients initial request will be 64 | 65 | .. code-block:: 66 | 67 | CONNECT example.com:443 HTTP/1.0 68 | 69 | Which indicates that the client is ready to begin a secure connection with the server. When the server receives this message it replies back to the client 70 | 71 | .. code-block:: 72 | 73 | HTTP/1.1 200 OK 74 | 75 | And the client begins what is called the "TLS/SSL handshake," which you can read more about it `here `_. During this handshake the server and the client create a secure tunnel that they can communicate without fear of someone being able to see their communication. 76 | 77 | All of the above is important to have a general understanding of to comprehend how proxies work. 78 | 79 | ---- 80 | 81 | Proxies 82 | ------- 83 | 84 | A proxy works by sitting between the client and destination server and is typically used to concel the IP address of a client. A normal proxy would be used either by setting its settings in the browsers' configuration, or via the `proxies` parameter in requests: 85 | 86 | .. code-block:: python 87 | 88 | import requests 89 | 90 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"} 91 | requests.get("http://example.com", proxies=proxies) 92 | 93 | In this case `requests` will generate the same HTTP request we saw above, but instead of sending it to the destination server - `example.com` - it will send it to the proxy. 94 | 95 | .. code-block:: 96 | 97 | GET http://example.com/ HTTP/1.1 98 | Host: example.com 99 | User-Agent: python-requests/2.26.0 100 | Accept-Encoding: gzip, deflate 101 | Accept: */* 102 | Connection: keep-alive 103 | 104 | The proxy, once it receives the HTTP request, interprets *where* the client is trying to go via either the first line of the request, or the ``Host`` header. It then opens a connection with the destination server on behalf of the client, and allows the client and the server to communicate between each other through *it*. In other words, a proxy is a 'man in the middle' whose job is primairly concentrated on conceling the IP address of the client. 105 | 106 | When a client utilises HTTPS (``https://``) the initial request goes to the proxy, and subsequently the proxy connects the client and server. The difference here, however, is that after the client and server are connected they perform the TLS/SSL handshake and begin a secure connection. This connection is now encrypted and the client and server can communicate freely without fear of being intercepted. 107 | 108 | ---- 109 | 110 | Man-in-the-middle 111 | ----------------- 112 | 113 | `mitm`, therefore, is a proxy that purposely intercepts the requests and responses going through it. When a client connection is a standard HTTP connection the `mitm` server doesn't have to do anything special. It creates a connection to the destination server on behalf of the client and listens to the messages between both. The issue is when a client is trying to connect to the server via HTTPS: 114 | 115 | .. code-block:: python 116 | 117 | import requests 118 | 119 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"} 120 | requests.get("https://example.com", proxies=proxies) 121 | 122 | When this happens, and the client sends a 123 | 124 | .. code-block:: python 125 | 126 | CONNECT example.com:443 HTTP/1.0 127 | 128 | What `mitm` does is *acts* like the destination server by responding back to the client 129 | 130 | .. code-block:: 131 | 132 | HTTP/1.1 200 OK 133 | 134 | and then performs the TLS/SSL handshake on behalf of the destination server. Once `mitm` and the client are connected it then opens a connection with the destination server and relays their communication back-and-forth, sitting in the middle and listening. Note, however, that since `mitm` generates its own TLS/SSL certificates a client will not trust it unless one either adds the generated certificate to their keychain (**not recommended**) or one uses a special flag in `requests`: 135 | 136 | .. code-block:: python 137 | 138 | import requests 139 | 140 | proxies = {"http": "http://127.0.0.1:8888", "https": "http://127.0.0.1:8888"} 141 | requests.get("https://example.com", proxies=proxies, verify=False) 142 | 143 | ... and that's really it! 144 | -------------------------------------------------------------------------------- /docs/source/introduction/quickstart.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | Quickstart 3 | ########## 4 | 5 | A customizable man-in-the-middle TCP proxy with support for HTTP & HTTPS. 6 | 7 | ---- 8 | 9 | Installing 10 | ---------- 11 | 12 | .. code-block:: 13 | 14 | pip install mitm 15 | 16 | Note that OpenSSL 1.1.1 or greater is required. 17 | 18 | Using 19 | ----- 20 | 21 | Using the default values for the :py:class:`mitm.MITM` class: 22 | 23 | .. code-block:: python 24 | 25 | from mitm import MITM, protocol, middleware, crypto 26 | 27 | mitm = MITM( 28 | host="127.0.0.1", 29 | port=8888, 30 | protocols=[protocol.HTTP], 31 | middlewares=[middleware.HTTPLog], 32 | certificate_authority = crypto.CertificateAuthority() 33 | ) 34 | mitm.run() 35 | 36 | This will start a proxy on port `8888` that is capable of intercepting all HTTP traffic (with support for SSL/TLS) and log all activity. 37 | 38 | Questions & Answers 39 | -------------------- 40 | 41 | **How does this project differ from** ``mitmproxy`` **?** 42 | 43 | Purpose, implementation, and customization style. The purpose of mitm is to be a light-weight customizable man-in-the-middle proxy intended for larger projects. ``mitmproxy`` (with its beautiful CLI) seems to be more for interactive request and response tampering and capturing. While it does support everything ``mitm`` does plus more, it lacks the simplicity that mitm has. 44 | 45 | **What protocols are supported out-of-the-box?** 46 | 47 | Only HTTP/1.0 and HTTP/1.1 are supported. Any other protocol (FTP, SMTP, etc.) will require a custom implementation. Any protocol that is built on top of HTTP/1.1 (e.g. websockets) should in theory work. 48 | -------------------------------------------------------------------------------- /docs/source/module/core.rst: -------------------------------------------------------------------------------- 1 | #### 2 | core 3 | #### 4 | 5 | .. code-block:: python 6 | 7 | from mitm import core 8 | 9 | .. automodule:: mitm.core 10 | 11 | ----- 12 | 13 | .. autoclass:: mitm.core.Host 14 | :members: 15 | 16 | .. autoclass:: mitm.core.Connection 17 | :members: 18 | 19 | .. autoclass:: mitm.core.Flow(enum.Enum) 20 | :members: 21 | 22 | .. autoclass:: mitm.core.Middleware(abc.ABC) 23 | :members: 24 | 25 | .. autoclass:: mitm.core.InvalidProtocol(Exception) 26 | :members: 27 | 28 | .. autoclass:: mitm.core.Protocol(abc.ABC) 29 | :members: 30 | -------------------------------------------------------------------------------- /docs/source/module/crypto.rst: -------------------------------------------------------------------------------- 1 | ###### 2 | crypto 3 | ###### 4 | 5 | 6 | .. code-block:: python 7 | 8 | from mitm import crypto 9 | 10 | .. automodule:: mitm.crypto 11 | 12 | ---- 13 | 14 | .. automethod:: mitm.crypto.new_RSA 15 | 16 | .. automethod:: mitm.crypto.new_X509 17 | 18 | ---- 19 | 20 | .. autodata:: mitm.crypto.LRU_MAX_SIZE 21 | :annotation: 22 | 23 | .. autoclass:: mitm.crypto.CertificateAuthority 24 | :members: 25 | -------------------------------------------------------------------------------- /docs/source/module/extension.rst: -------------------------------------------------------------------------------- 1 | ######### 2 | extension 3 | ######### 4 | 5 | .. code-block:: python 6 | 7 | from mitm import extension 8 | 9 | .. automodule:: mitm.extension 10 | 11 | ----- 12 | 13 | Middlewares 14 | *********** 15 | 16 | .. autoclass:: mitm.extension.middleware.Log 17 | 18 | .. autoclass:: mitm.extension.middleware.HTTPLog(Log) 19 | 20 | ---- 21 | 22 | Protocols 23 | ********* 24 | 25 | .. autoclass:: mitm.extension.protocol.HTTP 26 | -------------------------------------------------------------------------------- /docs/source/module/mitm.rst: -------------------------------------------------------------------------------- 1 | #### 2 | mitm 3 | #### 4 | 5 | .. code-block:: python 6 | 7 | from mitm import MITM 8 | 9 | .. automodule:: mitm.mitm 10 | 11 | ----- 12 | 13 | .. autoclass:: mitm.mitm.MITM 14 | 15 | .. automethod:: mitm.mitm.MITM.__init__ 16 | .. automethod:: mitm.mitm.MITM.entry 17 | 18 | The server is started by using `asyncio.start_server `_ function like so: 19 | 20 | .. code-block:: python 21 | 22 | ... 23 | server = await asyncio.start_server( 24 | lambda reader, writer: self.mitm( 25 | Connection( 26 | client=Host(reader=reader, writer=writer), 27 | server=Host(), 28 | ) 29 | ), 30 | host=self.host, 31 | port=self.port, 32 | ) 33 | ... 34 | 35 | .. automethod:: mitm.mitm.MITM.mitm 36 | -------------------------------------------------------------------------------- /mitm/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Github: https://github.com/synchronizing/mitm 3 | Docs: https://synchronizing.github.io/mitm/ 4 | """ 5 | 6 | # pylint: disable=wrong-import-order, wrong-import-position 7 | 8 | __author__ = "Felipe Faria" 9 | __project__ = "mitm" 10 | 11 | import pathlib 12 | import appdirs 13 | from pbr.version import VersionInfo 14 | 15 | __version__ = VersionInfo(__project__).release_string() 16 | __data__ = pathlib.Path(appdirs.user_data_dir(__package__, __author__)) 17 | 18 | import logging 19 | import sys 20 | 21 | logging.basicConfig( 22 | stream=sys.stdout, 23 | format="%(asctime)s %(levelname)-8s %(message)s", 24 | datefmt="%Y-%m-%d %H:%M:%S", 25 | level=logging.INFO, 26 | ) 27 | 28 | from mitm.core import * 29 | from mitm.crypto import * 30 | from mitm.extension import * 31 | from mitm.mitm import * 32 | 33 | __all__ = [ 34 | "Host", 35 | "Connection", 36 | "Flow", 37 | "MITM", 38 | "Middleware", 39 | "Protocol", 40 | "InvalidProtocol", 41 | "CertificateAuthority", 42 | ] 43 | -------------------------------------------------------------------------------- /mitm/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core components of the MITM framework. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import asyncio 8 | from abc import ABC, abstractmethod 9 | from dataclasses import dataclass 10 | from enum import Enum 11 | from typing import Any, List, Optional, Tuple 12 | 13 | from mitm.crypto import CertificateAuthority 14 | 15 | 16 | @dataclass 17 | class Host: 18 | """ 19 | Dataclass representing an `mitm` host. 20 | 21 | A host is a pair of `asyncio.StreamReader` and `asyncio.StreamWriter` objects 22 | that are used to communicate with the remote host. There are two types of hosts: a 23 | client, and a server. A client host is one that is connected to the `mitm`, and a 24 | server host is one that the `mitm` connected to on behalf of the client. 25 | 26 | The `mitm_managed` attribute is used to determine whether the `mitm` is responsible 27 | for closing the connection with the host. If `mitm_managed` is True, the `mitm` will 28 | close the connection with the host when it is done with it. If `mitm_managed` is set 29 | to False, the `mitm` will not close the connection with the host, and instead, the 30 | developer must close the connection with the host manually. This is useful for 31 | situations where the `mitm` is running as a seperate utility and the developer 32 | wants to keep the connection open with the host after the `mitm` is done with it. 33 | 34 | Note: 35 | See more on `dataclasses `_. 36 | 37 | Args: 38 | reader: The reader of the host. 39 | writer: The writer of the host. 40 | mitm_managed: Whether or not the host is managed by the `mitm`. 41 | 42 | Attributes: 43 | reader: The reader of the host. 44 | writer: The writer of the host. 45 | mitm_managed: Whether or not the host is managed by the `mitm`. 46 | ip: The IP address of the host. 47 | port: The port of the host. 48 | 49 | Example: 50 | 51 | .. code-block:: python 52 | 53 | reader, writer = await asyncio.open_connection(...) 54 | server = Host(reader, writer) 55 | """ 56 | 57 | reader: Optional[asyncio.StreamReader] = None 58 | writer: Optional[asyncio.StreamWriter] = None 59 | mitm_managed: Optional[bool] = True 60 | 61 | def __post_init__(self): 62 | """ 63 | Initializes the host and port information for the Host. 64 | 65 | This method is called by the Dataclass' `__init__` method post-initialization. 66 | """ 67 | 68 | # At this point the self.writer is either None or a StreamWriter. 69 | if self.writer: 70 | self.host, self.port = self.writer._transport.get_extra_info("peername") # pylint: disable=protected-access 71 | 72 | def __setattr__(self, name: str, value: Any): 73 | """ 74 | Sets Host attributes. 75 | 76 | We hijack this method to set the `host` and `port` attributes if/when the writer 77 | is set. This is because the `host` and `port` attributes are not set until the 78 | writer is set. 79 | 80 | Args: 81 | name: The name of the attribute to set. 82 | value: The value of the attribute to set. 83 | """ 84 | if ( 85 | name == "writer" 86 | and isinstance(value, asyncio.StreamWriter) 87 | and not getattr(self, "host", None) 88 | and not getattr(self, "port", None) 89 | ): 90 | self.host, self.port = value._transport.get_extra_info("peername") 91 | return super().__setattr__(name, value) 92 | 93 | def __bool__(self) -> bool: 94 | """ 95 | Returns whether or not the host is connected. 96 | """ 97 | return self.reader is not None and self.writer is not None 98 | 99 | def __repr__(self) -> str: # pragma: no cover 100 | """ 101 | Returns a string representation of the host. 102 | """ 103 | if self.reader and self.writer: 104 | return f"Host({self.host}:{self.port}, mitm_managed={self.mitm_managed})" 105 | 106 | return f"Host(mitm_managed={self.mitm_managed})" 107 | 108 | def __str__(self) -> str: # pragma: no cover 109 | """ 110 | Returns a string representation of the host. 111 | """ 112 | if self.reader and self.writer: 113 | return f"{self.host}:{self.port}" 114 | 115 | return "" 116 | 117 | 118 | @dataclass 119 | class Connection: 120 | """ 121 | Dataclass representing a standard `mitm` connection. 122 | 123 | A connection is a pair of `Host` objects that the `mitm` relays data between. When a 124 | connection is created the server host is not resolved until the data is intercepted 125 | and the protocol and destination server is figured out. 126 | 127 | Note: 128 | See more on `dataclasses `_. 129 | 130 | Args: 131 | client: The client host. 132 | server: The server host. 133 | protocol: The protocol of the connection. 134 | 135 | Example: 136 | .. code-block:: python 137 | 138 | client = Host(...) 139 | server = Host(...) 140 | 141 | connection = Connection(client, server, Protocol.HTTP) 142 | """ 143 | 144 | client: Host 145 | server: Host 146 | protocol: Optional[Protocol] = None 147 | 148 | def __repr__(self) -> str: # pragma: no cover 149 | return f"Connection(client={self.client}, server={self.server}, protocol={self.protocol})" 150 | 151 | 152 | class Flow(Enum): 153 | """ 154 | Enum representing the flow of the connection. 155 | 156 | Can be used within the appropriate locations to determine the flow of the 157 | connection. Not used by the core `mitm` framework, but used by the HTTP extension. 158 | 159 | Args: 160 | CLIENT_TO_SERVER: The client is sending data to the server. 161 | SERVER_TO_CLIENT: The server is sending data to the client. 162 | """ 163 | 164 | CLIENT_TO_SERVER = 0 165 | SERVER_TO_CLIENT = 1 166 | 167 | 168 | class Middleware(ABC): # pragma: no cover 169 | """ 170 | Event-driven hook extension for the `mitm`. 171 | 172 | A middleware is a class that is used to extend the `mitm` framework by allowing 173 | event-driven hooks to be added to the `mitm` and executed when the appropriate 174 | event occurs. Built-in middlewares can be found in the `mitm.middleware` module. 175 | """ 176 | 177 | @abstractmethod 178 | async def mitm_started(self, host: str, port: int): 179 | """ 180 | Called when the `mitm` server boots-up. 181 | """ 182 | raise NotImplementedError 183 | 184 | @abstractmethod 185 | async def client_connected(self, connection: Connection): 186 | """ 187 | Called when the connection is established with the client. 188 | 189 | Note: 190 | Note that the `mitm.core.Connection` object is not fully initialized yet, 191 | and only contains a valid client `mitm.core.Host`. 192 | """ 193 | raise NotImplementedError 194 | 195 | @abstractmethod 196 | async def server_connected(self, connection: Connection): 197 | """ 198 | Called when the connection is established with the server. 199 | 200 | Note: 201 | At this point the `mitm.core.Connection` object is fully initialized. 202 | """ 203 | raise NotImplementedError 204 | 205 | @abstractmethod 206 | async def client_data(self, connection: Connection, data: bytes) -> bytes: 207 | """ 208 | Called when data is received from the client. 209 | 210 | Note: 211 | Raw TLS/SSL handshake is not sent through this method. Everything should be 212 | decrypted beforehand. 213 | 214 | Args: 215 | request: The request received from the client. 216 | 217 | Returns: 218 | The request to send to the server. 219 | """ 220 | raise NotImplementedError 221 | 222 | @abstractmethod 223 | async def server_data(self, connection: Connection, data: bytes) -> bytes: 224 | """ 225 | Called when data is received from the server. 226 | 227 | Args: 228 | response: The response received from the server. 229 | 230 | Returns: 231 | The response to send back to the client. 232 | """ 233 | raise NotImplementedError 234 | 235 | @abstractmethod 236 | async def client_disconnected(self, connection: Connection): 237 | """ 238 | Called when the client disconnects. 239 | """ 240 | raise NotImplementedError 241 | 242 | @abstractmethod 243 | async def server_disconnected(self, connection: Connection): 244 | """ 245 | Called when the server disconnects. 246 | """ 247 | raise NotImplementedError 248 | 249 | def __repr__(self) -> str: # pragma: no cover 250 | return f"Middleware({self.__class__.__name__})" 251 | 252 | 253 | class InvalidProtocol(Exception): # pragma: no cover 254 | """ 255 | Exception raised when the protocol did not work. 256 | 257 | This is the only error that `mitm.MITM` will catch. Throwing this error will 258 | continue the search for a valid protocol. 259 | """ 260 | 261 | 262 | class Protocol(ABC): # pragma: no cover 263 | """ 264 | Custom protocol implementation. 265 | 266 | Protocols are implementations on how the data flows between the client and server. 267 | Application-layer protocols are implemented by subclassing this class. Built-in 268 | protocols can be found in the `mitm.extension` package. 269 | 270 | Parameters: 271 | bytes_needed: Minimum number of bytes needed to determine the protocol. 272 | buffer_size: The size of the buffer to use when reading data. 273 | timeout: The timeout to use when reading data. 274 | keep_alive: Whether or not to keep the connection alive. 275 | """ 276 | 277 | bytes_needed: int 278 | buffer_size: int 279 | timeout: int 280 | keep_alive: bool 281 | 282 | def __init__( 283 | self, 284 | certificate_authority: Optional[CertificateAuthority] = None, 285 | middlewares: Optional[List[Middleware]] = None, 286 | ): 287 | """ 288 | Initializes the protocol. 289 | 290 | Args: 291 | certificate_authority: The certificate authority to use for the connection. 292 | middlewares: The middlewares to use for the connection. 293 | """ 294 | self.certificate_authority = certificate_authority if certificate_authority else CertificateAuthority() 295 | self.middlewares = middlewares if middlewares else [] 296 | 297 | @abstractmethod 298 | async def resolve(self, connection: Connection, data: bytes) -> Tuple[str, int, bool]: 299 | """ 300 | Resolves the destination of the connection. 301 | 302 | Args: 303 | connection: Connection object containing a client host. 304 | data: The initial incoming data from the client. 305 | 306 | Returns: 307 | A tuple containing the host, port, and bool that indicates if the connection 308 | is encrypted. 309 | 310 | Raises: 311 | InvalidProtocol: If the connection failed. 312 | """ 313 | raise NotImplementedError 314 | 315 | @abstractmethod 316 | async def connect(self, connection: Connection, host: str, port: int, tls: bool, data: bytes): 317 | """ 318 | Attempts to connect to destination server using the given data. Returns `True` 319 | if the connection was successful, raises `InvalidProtocol` if the connection 320 | failed. 321 | 322 | Args: 323 | connection: Connection object containing a client host. 324 | data: The initial incoming data from the client. 325 | 326 | Raises: 327 | InvalidProtocol: If the connection failed. 328 | """ 329 | raise NotImplementedError 330 | 331 | @abstractmethod 332 | async def handle(self, connection: Connection): 333 | """ 334 | Handles the connection between a client and a server. 335 | 336 | Args: 337 | connection: Client/server connection to relay. 338 | """ 339 | raise NotImplementedError 340 | 341 | def __repr__(self) -> str: # pragma: no cover 342 | return f"Protocol({self.__class__.__name__})" 343 | -------------------------------------------------------------------------------- /mitm/crypto.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cryptography functionalities. 3 | """ 4 | 5 | from functools import lru_cache 6 | import random 7 | import ssl 8 | from pathlib import Path 9 | from typing import Optional, Tuple, Union 10 | 11 | import OpenSSL 12 | from toolbox.sockets.ip import is_ip 13 | 14 | from mitm import __data__ 15 | 16 | LRU_MAX_SIZE = 1024 17 | """ 18 | Max size of the LRU cache used by `CertificateAuthority.new_context()` method. Defaults 19 | to 1024. 20 | 21 | Due to limitations of the Python's SSL module we are unable to load certificates/keys 22 | from memory; on every request we must dump the generated cert/key to disk and pass the 23 | paths `ssl.SSLContext.load_cert_chain()` method. For a few requests this is not an 24 | issue, but for a large quantity of requests this is a significant performance hit. 25 | 26 | To mitigate this issue we cache the generated SSLContext using 27 | `lru_cache `_. 28 | `LRU_MAX_SIZE` defines the maximum number of cached `ssl.SSLContexts` that can be stored 29 | in memory at one time. This value can be modified by editing it _before_ 30 | `CertificateAuthority` is used elsewhere. 31 | 32 | .. code-block:: python 33 | 34 | from mitm import MITM, CertificateAuthority, middleware, protocol, crypto 35 | from pathlib import Path 36 | 37 | # Updates the maximum size of the LRU cache. 38 | crypto.LRU_MAX_SIZE = 2048 39 | 40 | # Rest of the code goes here. 41 | """ 42 | 43 | 44 | def new_RSA(bits: int = 2048) -> OpenSSL.crypto.PKey: # pylint: disable=invalid-name 45 | """ 46 | Generates an RSA pair. 47 | 48 | This function is intended to be utilized with :py:func:`new_X509`. See function 49 | :py:func:`new_pair` to understand how to generate a valid RSA and X509 pair for 50 | SSL/TLS use. 51 | 52 | Args: 53 | bits: Size of the RSA key. Defaults to 2048. 54 | """ 55 | 56 | rsa = OpenSSL.crypto.PKey() 57 | rsa.generate_key(OpenSSL.crypto.TYPE_RSA, bits) 58 | return rsa 59 | 60 | 61 | def new_X509( # pylint: disable=invalid-name 62 | country_name: str = "US", 63 | state_or_province_name: str = "New York", 64 | locality: str = "New York", 65 | organization_name: str = "mitm", 66 | organization_unit_name: str = "mitm", 67 | common_name: str = "mitm", 68 | serial_number: Optional[int] = None, 69 | time_not_before: int = 0, # 0 means now. 70 | time_not_after: int = 1 * (365 * 24 * 60 * 60), # 1 year. 71 | ) -> OpenSSL.crypto.X509: 72 | """ 73 | Generates a non-signed X509 certificate. 74 | 75 | This function is intended to be utilized with :py:func:`new_RSA`. See function 76 | :py:func:`new_pair` to understand how to generate a valid RSA and X509 pair for 77 | SSL/TLS use. 78 | 79 | Args: 80 | country_name: Country name code. Defaults to ``US``. 81 | state_or_province_name: State or province name. Defaults to ``New York``. 82 | locality: Locality name. Can be any. Defaults to ``New York``. 83 | organization_name: Name of the org generating the cert. Defaults to ``mitm``. 84 | organization_unit_name: Name of the subunit of the org. Defaults to ``mitm``. 85 | common_name: Server name protected by the SSL cert. Defaults to hostname. 86 | serial_number: A unique serial number. Any number between 0 and 2^64-1. Defaults to random number. 87 | time_not_before: Time since cert is valid. 0 means now. Defaults to ``0``. 88 | time_not_after: Time when cert is no longer valid. Defaults to 5 years. 89 | """ 90 | 91 | cert = OpenSSL.crypto.X509() 92 | cert.get_subject().C = country_name 93 | cert.get_subject().ST = state_or_province_name 94 | cert.get_subject().L = locality 95 | cert.get_subject().O = organization_name 96 | cert.get_subject().OU = organization_unit_name 97 | cert.get_subject().CN = common_name 98 | cert.set_serial_number(serial_number or random.randint(0, 2**64 - 1)) 99 | cert.set_version(2) 100 | cert.gmtime_adj_notBefore(time_not_before) 101 | cert.gmtime_adj_notAfter(time_not_after) 102 | cert.set_issuer(cert.get_subject()) 103 | return cert 104 | 105 | 106 | class CertificateAuthority: 107 | """ 108 | Certificate Authority interface. 109 | """ 110 | 111 | def __init__( 112 | self, 113 | key: Optional[OpenSSL.crypto.PKey] = None, 114 | cert: Optional[OpenSSL.crypto.X509] = None, 115 | ): 116 | """ 117 | Generates a certificate authority. 118 | 119 | Args: 120 | key: Private key of the CA. Generated if not provided. 121 | cert: Unsigned certificate of the CA. Generated if not provided. 122 | """ 123 | self.key = key if key else new_RSA() 124 | self.cert = cert if cert else new_X509() 125 | 126 | # Creates CA. 127 | self.cert.set_pubkey(self.key) 128 | self.cert.add_extensions( 129 | [ 130 | OpenSSL.crypto.X509Extension(b"basicConstraints", True, b"CA:TRUE, pathlen:0"), 131 | OpenSSL.crypto.X509Extension(b"keyUsage", True, b"keyCertSign, cRLSign"), 132 | OpenSSL.crypto.X509Extension(b"subjectKeyIdentifier", False, b"hash", subject=self.cert), 133 | ], 134 | ) 135 | self.cert.sign(self.key, "sha256") 136 | 137 | @classmethod 138 | def init(cls, path: Path): 139 | """ 140 | Helper init method to initialize or load a CA. 141 | 142 | Args: 143 | path: The path where `mitm.pem` and `mitm.key` are to be loaded/saved. 144 | """ 145 | 146 | pem, key = path / "mitm.pem", path / "mitm.key" 147 | if not pem.exists() or not key.exists(): 148 | certificate_authority = CertificateAuthority() 149 | certificate_authority.save(cert_path=pem, key_path=key) 150 | else: 151 | certificate_authority = CertificateAuthority.load(cert_path=pem, key_path=key) 152 | 153 | return certificate_authority 154 | 155 | def new_X509(self, host: str) -> Tuple[OpenSSL.crypto.X509, OpenSSL.crypto.PKey]: # pylint: disable=invalid-name 156 | """ 157 | Generates a new certificate for the host. 158 | 159 | Note: 160 | The hostname must be a valid IP address or a valid hostname. 161 | 162 | Args: 163 | host: Hostname to generate the certificate for. 164 | 165 | Returns: 166 | A tuple of the certificate and private key. 167 | """ 168 | 169 | # Generate a new key pair. 170 | key = new_RSA() 171 | 172 | # Generates new X509Request. 173 | req = OpenSSL.crypto.X509Req() 174 | req.get_subject().CN = host.encode("utf-8") 175 | req.set_pubkey(key) 176 | req.sign(key, "sha256") 177 | 178 | # Generates new X509 certificate. 179 | cert = new_X509(common_name=host) 180 | cert.set_issuer(self.cert.get_subject()) 181 | cert.set_pubkey(req.get_pubkey()) 182 | 183 | # Sets the certificate 'subjectAltName' extension. 184 | hosts = [f"DNS:{host}"] 185 | 186 | if is_ip(host): 187 | hosts += [f"IP:{host}"] 188 | else: 189 | hosts += [f"DNS:*.{host}"] 190 | 191 | hosts = ", ".join(hosts).encode("utf-8") 192 | cert.add_extensions([OpenSSL.crypto.X509Extension(b"subjectAltName", False, hosts)]) 193 | 194 | # Signs the certificate with the CA's key. 195 | cert.sign(self.key, "sha256") 196 | 197 | return cert, key 198 | 199 | @lru_cache(maxsize=LRU_MAX_SIZE) 200 | def new_context(self, host: str) -> ssl.SSLContext: 201 | """ 202 | Generates a new SSLContext with the given X509 certificate and private key. 203 | 204 | Args: 205 | X509: X509 certificate. 206 | PKey: Private key. 207 | 208 | Returns: 209 | The SSLContext with the certificate loaded. 210 | """ 211 | 212 | # Generates cert/key for the host. 213 | cert, key = self.new_X509(host) 214 | 215 | # Dump the cert and key. 216 | cert_dump = OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) 217 | key_dump = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key) 218 | 219 | # Store cert and key into file. Unfortunately we need to store them in disk 220 | # because SSLContext does not support loading from memory. This is a limitation 221 | # of the Python standard library, and the community: https://bugs.python.org/issue16487 222 | # Alternatives cannot be used for this because this context is eventually used 223 | # by asyncio.get_event_loop().start_tls(..., sslcontext=..., ...) parameter, 224 | # which only support ssl.SSLContext. To mitigate this we use lru_cache to 225 | # cache the SSLContext for each host. It works fairly well, but its not the 226 | # preferred way to do it... loading from memory would be better. 227 | cert_path, key_path = __data__ / "temp.crt", __data__ / "temp.key" 228 | cert_path.parent.mkdir(parents=True, exist_ok=True) 229 | with cert_path.open("wb") as file: 230 | file.write(cert_dump) 231 | key_path.parent.mkdir(parents=True, exist_ok=True) 232 | with key_path.open("wb") as file: 233 | file.write(key_dump) 234 | 235 | # Creates new SSLContext. 236 | context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) 237 | context.load_cert_chain(certfile=cert_path, keyfile=key_path) 238 | 239 | # Remove the temporary files. 240 | cert_path.unlink() 241 | key_path.unlink() 242 | 243 | return context 244 | 245 | def save(self, cert_path: Union[Path, str], key_path: Union[Path, str]): 246 | """ 247 | Saves the certificate authority and its private key to disk. 248 | 249 | Args: 250 | cert_path: Path to the certificate file. 251 | key_path: Path to the key file. 252 | """ 253 | cert_path, key_path = Path(cert_path), Path(key_path) 254 | 255 | cert_path.parent.mkdir(parents=True, exist_ok=True) 256 | with cert_path.open("wb") as file: 257 | file.write(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, self.cert)) 258 | 259 | key_path.parent.mkdir(parents=True, exist_ok=True) 260 | with key_path.open("wb") as file: 261 | file.write(OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, self.key)) 262 | 263 | @classmethod 264 | def load(cls, cert_path: Union[Path, str], key_path: Union[Path, str]) -> "CertificateAuthority": 265 | """ 266 | Loads the certificate authority and its private key from disk. 267 | 268 | Args: 269 | cert_path: Path to the certificate file. 270 | key_path: Path to the key file. 271 | """ 272 | cert_path, key_path = Path(cert_path), Path(key_path) 273 | 274 | with cert_path.open("rb") as file: 275 | cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, file.read()) 276 | 277 | with key_path.open("rb") as file: 278 | key = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, file.read()) 279 | 280 | return cls(key, cert) 281 | -------------------------------------------------------------------------------- /mitm/extension/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Built-in extensions for mitm. 3 | """ 4 | from mitm.extension import middleware 5 | from mitm.extension import protocol 6 | from mitm.extension.middleware import * 7 | from mitm.extension.protocol import * 8 | -------------------------------------------------------------------------------- /mitm/extension/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom middlware implementation for the MITM proxy. 3 | """ 4 | 5 | import logging 6 | 7 | import httpq 8 | from toolbox.string.color import bold 9 | from mitm.core import Connection, Middleware 10 | 11 | logger = logging.getLogger(__package__) 12 | 13 | 14 | class Log(Middleware): 15 | """ 16 | Middleware that logs all events to the console. 17 | """ 18 | 19 | def __init__(self): 20 | self.connection: Connection = None 21 | 22 | async def mitm_started(self, host: str, port: int): 23 | logger.info(f"MITM server started on {bold(f'{host}:{port}')}.") 24 | 25 | async def client_connected(self, connection: Connection): 26 | logger.info(f"Client {bold(connection.client)} has connected.") 27 | 28 | async def server_connected(self, connection: Connection): 29 | logger.info(f"Client {bold(connection.client)} has connected to server {bold(connection.server)}.") 30 | 31 | async def client_data(self, connection: Connection, data: bytes) -> bytes: 32 | 33 | # The first request is intended for the 'mitm' server to discover the 34 | # destination server. 35 | if not connection.server: 36 | logger.info(f"Client {connection.client} to mitm: \n\n\t{data}\n") 37 | 38 | # All requests thereafter are intended for the destination server. 39 | else: # pragma: no cover 40 | logger.info(f"Client {connection.client} to {connection.server}: \n\n\t{data}\n") 41 | 42 | return data 43 | 44 | async def server_data(self, connection: Connection, data: bytes) -> bytes: 45 | logger.info(f"Server {connection.server} to client {connection.client}: \n\n\t{data}\n") 46 | return data 47 | 48 | async def client_disconnected(self, connection: Connection): 49 | logger.info(f"Client {connection.client} has disconnected.") 50 | 51 | async def server_disconnected(self, connection: Connection): 52 | logger.info(f"Server {connection.server} has disconnected.") 53 | 54 | 55 | class HTTPLog(Log): # pragma: no cover 56 | """ 57 | Middlewares that logs all HTTP events to the console with pretty-print. 58 | 59 | Notes: 60 | Do not use this middleware if there is a chance that the request or response 61 | will not be HTTP. This should only be used if you have control of all the 62 | requests coming into the proxy. If you are setting your computer's proxy 63 | settings to `mitm` you should not use this middleware as things will not work. 64 | """ 65 | 66 | def __init__(self): # pylint: disable=super-init-not-called 67 | self.connection: Connection = None 68 | 69 | async def client_data(self, connection: Connection, data: bytes) -> bytes: 70 | 71 | req = httpq.Request.parse(data) 72 | 73 | # The first request is intended for the 'mitm' server to discover the 74 | # destination server. 75 | if not connection.server: 76 | logger.info(f"Client {connection.client} to mitm: \n\n{req}\n") 77 | 78 | # All requests thereafter are intended for the destination server. 79 | else: 80 | logger.info(f"Client {connection.client} to {connection.server}: \n\n{req}\n") 81 | 82 | return data 83 | 84 | async def server_data(self, connection: Connection, data: bytes) -> bytes: 85 | resp = httpq.Response.parse(data) 86 | logger.info(f"Server {connection.server} to client {connection.client}: \n\n{resp}\n") 87 | return data 88 | 89 | async def client_disconnected(self, connection: Connection): 90 | logger.info(f"Client {connection.client} has disconnected.") 91 | 92 | async def server_disconnected(self, connection: Connection): 93 | logger.info(f"Server {connection.server} has disconnected.") 94 | -------------------------------------------------------------------------------- /mitm/extension/protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom protocol implementations for the MITM proxy. 3 | """ 4 | import asyncio 5 | import ssl 6 | from typing import Tuple 7 | 8 | from httpq import Request 9 | from toolbox.asyncio.streams import tls_handshake 10 | 11 | from mitm.core import Connection, Flow, Host, InvalidProtocol, Protocol 12 | 13 | 14 | class HTTP(Protocol): 15 | """ 16 | Adds support for HTTP protocol (with TLS support). 17 | 18 | This protocol adds HTTP and HTTPS proxy support to the `mitm`. Note that by 19 | "HTTPS proxy" we mean a proxy that supports the `CONNECT` statement, and not 20 | one that instantly performs a TLS handshake on connection with the client (though 21 | this can be added if needed). 22 | 23 | `bytes_needed` is set to 8192 to ensure we can read the first line of the request. 24 | The HTTP/1.1 protocol does not define a minimum length for the first line, so we 25 | use the largest number found in other projects. 26 | """ 27 | 28 | bytes_needed: int = 8192 29 | buffer_size: int = 8192 30 | timeout: int = 15 31 | keep_alive: bool = True 32 | 33 | async def resolve(self, connection: Connection, data: bytes) -> Tuple[str, int, bool]: 34 | """ 35 | Resolves the destination server for the protocol. 36 | 37 | Args: 38 | connection: Connection object containing a client host. 39 | data: The initial incoming data from the client. 40 | 41 | Returns: 42 | A tuple containing the host, port, and bool if the connection is encrypted. 43 | 44 | Raises: 45 | InvalidProtocol: If the connection failed. 46 | """ 47 | try: 48 | request = Request.parse(data) 49 | except: # pragma: no cover 50 | raise InvalidProtocol # pylint: disable=raise-missing-from 51 | 52 | # Deal with 'CONNECT'. 53 | tls = False 54 | if request.method == "CONNECT": 55 | tls = True 56 | 57 | # Get the hostname and port. 58 | if not request.target: 59 | raise InvalidProtocol 60 | host, port = request.target.string.split(":") 61 | 62 | # Deal with any other HTTP method. 63 | elif request.method: 64 | 65 | # Get the hostname and port. 66 | if "Host" not in request.headers: 67 | raise InvalidProtocol 68 | host, port = request.headers.get("Host").string, 80 69 | 70 | # Unable to parse the request. 71 | elif not request.method: 72 | raise InvalidProtocol 73 | 74 | return host, int(port), tls 75 | 76 | async def connect(self, connection: Connection, host: str, port: int, tls: bool, data: bytes): 77 | """ 78 | Connects to the destination server if the data is a valid HTTP request. 79 | 80 | Args: 81 | connection: The connection to the destination server. 82 | host: The hostname of the destination server. 83 | port: The port of the destination server. 84 | tls: Whether the connection is encrypted. 85 | data: The initial data received from the client. 86 | 87 | Raises: 88 | InvalidProtocol: If the connection failed. 89 | """ 90 | 91 | # Generate certificate if TLS. 92 | if tls: 93 | 94 | # Accept client connection. 95 | connection.client.writer.write(b"HTTP/1.1 200 OK\r\n\r\n") 96 | await connection.client.writer.drain() 97 | 98 | # Generates new context specific to the host. 99 | ssl_context = self.certificate_authority.new_context(host) 100 | 101 | # Perform handshake. 102 | try: 103 | await tls_handshake( 104 | reader=connection.client.reader, 105 | writer=connection.client.writer, 106 | ssl_context=ssl_context, 107 | server_side=True, 108 | ) 109 | except ssl.SSLError as err: 110 | raise InvalidProtocol from err 111 | 112 | # Connect to the destination server and send the initial request. 113 | reader, writer = await asyncio.open_connection( 114 | host=host, 115 | port=port, 116 | ssl=tls, 117 | ) 118 | connection.server = Host(reader, writer) 119 | 120 | # Send initial request if not SSL/TLS connection. 121 | if not tls: 122 | connection.server.writer.write(data) 123 | await connection.server.writer.drain() 124 | 125 | async def handle(self, connection: Connection): 126 | """ 127 | Handles the connection between a client and a server. 128 | 129 | Args: 130 | connection: Client/server connection to relay. 131 | """ 132 | 133 | # Keeps the connection alive until the client or server closes it. 134 | run_once = True 135 | while ( 136 | not connection.client.reader.at_eof() 137 | and not connection.server.reader.at_eof() 138 | and (self.keep_alive or run_once) 139 | ): 140 | 141 | # Keeps trying to relay data until the connection closes. 142 | event = asyncio.Event() 143 | await asyncio.gather( 144 | self.relay(connection, event, Flow.SERVER_TO_CLIENT), 145 | self.relay(connection, event, Flow.CLIENT_TO_SERVER), 146 | ) 147 | 148 | # Run the while loop only one iteration if keep_alive is False. 149 | run_once = False 150 | 151 | async def relay(self, connection: Connection, event: asyncio.Event, flow: Flow): 152 | """ 153 | Relays HTTP data between the client and the server. 154 | 155 | Args: 156 | connection: Client/server connection to relay. 157 | event: Event to wait on. 158 | flow: The flow to relay. 159 | """ 160 | 161 | if flow == Flow.CLIENT_TO_SERVER: 162 | reader = connection.client.reader 163 | writer = connection.server.writer 164 | elif flow == Flow.SERVER_TO_CLIENT: 165 | reader = connection.server.reader 166 | writer = connection.client.writer 167 | 168 | while not event.is_set() and not reader.at_eof(): 169 | data = None 170 | try: 171 | data = await asyncio.wait_for( 172 | reader.read(self.buffer_size), 173 | timeout=self.timeout, 174 | ) 175 | except asyncio.exceptions.TimeoutError: 176 | pass 177 | 178 | if not data: 179 | event.set() 180 | break 181 | 182 | # Pass data through middlewares. 183 | for middleware in self.middlewares: 184 | if flow == Flow.SERVER_TO_CLIENT: 185 | data = await middleware.server_data(connection, data) 186 | elif flow == Flow.CLIENT_TO_SERVER: 187 | data = await middleware.client_data(connection, data) 188 | 189 | writer.write(data) 190 | await writer.drain() 191 | -------------------------------------------------------------------------------- /mitm/mitm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Man-in-the-middle. 3 | """ 4 | 5 | import asyncio 6 | import logging 7 | from typing import List, Optional 8 | 9 | from toolbox.asyncio.pattern import CoroutineClass 10 | 11 | from mitm import __data__ 12 | from mitm.core import Connection, Host, Middleware, Protocol 13 | from mitm.crypto import CertificateAuthority 14 | from mitm.extension.middleware import Log 15 | from mitm.extension.protocol import HTTP, InvalidProtocol 16 | 17 | logger = logging.getLogger(__package__) 18 | logging.getLogger("asyncio").setLevel(logging.CRITICAL) 19 | 20 | 21 | class MITM(CoroutineClass): 22 | """ 23 | Man-in-the-middle server. 24 | """ 25 | 26 | def __init__( 27 | self, 28 | host: str = "127.0.0.1", 29 | port: int = 8888, 30 | protocols: Optional[List[Protocol]] = None, 31 | middlewares: Optional[List[Middleware]] = None, 32 | certificate_authority: Optional[CertificateAuthority] = None, 33 | run: bool = False, 34 | ): 35 | """ 36 | Initializes the MITM class. 37 | 38 | Args: 39 | host: Host to listen on. Defaults to `127.0.0.1`. 40 | port: Port to listen on. Defaults to `8888`. 41 | protocols: List of protocols to use. Defaults to `[protocol.HTTP]`. 42 | middlewares: List of middlewares to use. Defaults to `[middleware.Log]`. 43 | certificate_authority: Certificate authority to use. Defaults to `CertificateAuthority()`. 44 | run: Whether to start the server immediately. Defaults to `False`. 45 | 46 | Example: 47 | 48 | .. code-block:: python 49 | 50 | from mitm import MITM 51 | 52 | mitm = MITM() 53 | mitm.run() 54 | """ 55 | self.host = host 56 | self.port = port 57 | self.certificate_authority = certificate_authority if certificate_authority else CertificateAuthority() 58 | 59 | # Stores the CA certificate and private key. 60 | cert_path, key_path = __data__ / "mitm.crt", __data__ / "mitm.key" 61 | self.certificate_authority.save(cert_path=cert_path, key_path=key_path) 62 | 63 | # Initialize any middleware that is not already initialized. 64 | middlewares = middlewares if middlewares else [Log] 65 | new_middlewares = [] 66 | for middleware in middlewares: 67 | if isinstance(middleware, type): 68 | middleware = middleware() 69 | new_middlewares.append(middleware) 70 | self.middlewares = new_middlewares 71 | 72 | # Initialize any protocol that is not already initialized. 73 | protocols = protocols if protocols else [HTTP] 74 | new_protocols = [] 75 | for protocol in protocols: 76 | if isinstance(protocol, type): 77 | protocol = protocol(certificate_authority=self.certificate_authority, middlewares=self.middlewares) 78 | new_protocols.append(protocol) 79 | self.protocols = new_protocols 80 | 81 | super().__init__(run=run) 82 | 83 | async def entry(self): # pragma: no cover 84 | """ 85 | Entry point for the MITM class. 86 | """ 87 | try: 88 | server = await asyncio.start_server( 89 | lambda reader, writer: self.mitm( 90 | Connection( 91 | client=Host(reader=reader, writer=writer), 92 | server=Host(), 93 | ) 94 | ), 95 | host=self.host, 96 | port=self.port, 97 | ) 98 | except OSError as err: 99 | self._loop.stop() 100 | raise err 101 | 102 | for middleware in self.middlewares: 103 | await middleware.mitm_started(host=self.host, port=self.port) 104 | 105 | async with server: 106 | await server.serve_forever() 107 | 108 | async def mitm(self, connection: Connection): 109 | """ 110 | Handles an incoming connection (single connection). 111 | 112 | Warning: 113 | This method is not intended to be called directly. 114 | """ 115 | 116 | # Calls middlewares for client initial connect. 117 | for middleware in self.middlewares: 118 | await middleware.client_connected(connection=connection) 119 | 120 | # Gets the bytes needed to identify the protocol. 121 | min_bytes_needed = max(proto.bytes_needed for proto in self.protocols) 122 | data = await connection.client.reader.read(n=min_bytes_needed) 123 | 124 | # Calls middleware on client's data. 125 | for middleware in self.middlewares: 126 | data = await middleware.client_data(connection=connection, data=data) 127 | 128 | # Finds the protocol that matches the data. 129 | proto = None 130 | for prtcl in self.protocols: 131 | proto = prtcl 132 | try: 133 | # Attempts to resolve the protocol, and connect to the server. 134 | host, port, tls = await proto.resolve(connection=connection, data=data) 135 | await proto.connect(connection=connection, host=host, port=port, tls=tls, data=data) 136 | except InvalidProtocol: # pragma: no cover 137 | proto = None 138 | else: 139 | # Stop searching for working protocols. 140 | break 141 | 142 | # Protocol was found, and we connected to a server. 143 | if proto and connection.server: 144 | 145 | # Sets the connection protocol. 146 | connection.protocol = proto 147 | 148 | # Calls middleware for server initial connect. 149 | for middleware in self.middlewares: 150 | await middleware.server_connected(connection=connection) 151 | 152 | # Handles the data between the client and server. 153 | await proto.handle(connection=connection) 154 | 155 | # Protocol identified, but we did not connect to a server. 156 | elif proto and not connection.server: # pragma: no cover 157 | raise ValueError( 158 | "The protocol was found, but the server was not connected to succesfully. " 159 | f"Check the {proto.__class__.__name__} implementation." 160 | ) 161 | 162 | # No protocol was found for the data. 163 | else: # pragma: no cover 164 | raise ValueError("No protocol was found. Check the protocols list.") 165 | 166 | # If a server connection exists after handling it, we close it. 167 | if connection.server and connection.server.mitm_managed: 168 | connection.server.writer.close() 169 | await connection.server.writer.wait_closed() 170 | 171 | # Calls the server's 'disconnected' middleware. 172 | for middleware in self.middlewares: 173 | await middleware.server_disconnected(connection=connection) 174 | 175 | # Attempts to disconnect with the client. 176 | # In some instances 'wait_closed()' might hang. This is a known issue that 177 | # happens when and if the client keeps the connection alive, and, unfortunately, 178 | # there is nothing we can do about it. This is a reported bug in asyncio. 179 | # https://bugs.python.org/issue39758 180 | if connection.client and connection.client.mitm_managed: 181 | connection.client.writer.close() 182 | await connection.client.writer.wait_closed() 183 | 184 | # Calls the client 'disconnected' middleware. 185 | for middleware in self.middlewares: 186 | await middleware.client_disconnected(connection=connection) 187 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pbr==5.9.0 2 | PyOpenSSL==23.1.1 3 | appdirs==1.4.4 4 | httpq==1.2.0 5 | toolbox==1.11.0 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mitm 3 | author = Felipe Faria 4 | summary = Man-in-the-middle proxy with customizable options. 5 | description-file = README.md 6 | description-content-type = text/markdown; charset=UTF-8 7 | requires-python = >=3.5 8 | home-page = https://github.com/synchronizing/mitm 9 | project_urls = 10 | Bug Tracker = https://github.com/synchronizing/mitm/issues 11 | Documentation = https://synchronizing.github.io/mitm/ 12 | Source Code = https://github.com/synchronizing/mitm/tree/master 13 | license = MIT 14 | classifier = 15 | Programming Language :: Python :: Implementation :: CPython 16 | Development Status :: 5 - Production/Stable 17 | License :: OSI Approved :: MIT License 18 | Natural Language :: English 19 | Topic :: Software Development :: Libraries 20 | Topic :: Utilities 21 | keywords = 22 | mitm 23 | 24 | [extras] 25 | dev = 26 | pytest 27 | pytest-cov 28 | pytest-asyncio 29 | coveralls 30 | sphinx 31 | sphinx-copybutton 32 | sphinx-autobuild 33 | furo 34 | 35 | [files] 36 | packages = 37 | mitm 38 | 39 | [flake8] 40 | max-line-length = 88 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | setup_requires=["pbr"], 5 | pbr=True, 6 | ) 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synchronizing/mitm/a189234b382518d76658b6e489a4a170898575e1/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import mitm 2 | import pytest 3 | import asyncio 4 | 5 | HOST = "127.0.0.1" 6 | PORT = 8888 7 | BUFFER_SIZE = 1024 8 | 9 | 10 | async def start(): 11 | """ 12 | Starts the MITM server. 13 | """ 14 | loop = asyncio.get_event_loop() 15 | mitm_ = mitm.MITM() 16 | try: 17 | srv = await asyncio.start_server( 18 | lambda reader, writer: mitm_.mitm( 19 | mitm.Connection( 20 | client=mitm.Host(reader=reader, writer=writer), 21 | server=mitm.Host(), 22 | ) 23 | ), 24 | host=HOST, 25 | port=PORT, 26 | ) 27 | except OSError as e: 28 | loop.stop() 29 | raise e 30 | 31 | async with srv: 32 | await srv.serve_forever() 33 | 34 | 35 | @pytest.fixture(scope="session") 36 | def event_loop(): 37 | return asyncio.get_event_loop() 38 | 39 | 40 | @pytest.fixture(autouse=True, scope="session") 41 | def server(event_loop): 42 | task = asyncio.ensure_future(start(), loop=event_loop) 43 | # Sleeps to allow the server to start. 44 | event_loop.run_until_complete(asyncio.sleep(1)) 45 | 46 | try: 47 | yield 48 | finally: 49 | task.cancel() 50 | -------------------------------------------------------------------------------- /tests/extension/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/synchronizing/mitm/a189234b382518d76658b6e489a4a170898575e1/tests/extension/__init__.py -------------------------------------------------------------------------------- /tests/extension/test_middleware.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mitm import extension, core 3 | 4 | 5 | class Test_Log: 6 | log = extension.Log() 7 | connection = core.Connection(core.Host(), core.Host()) 8 | 9 | @pytest.mark.asyncio 10 | async def test_init(self): 11 | log = extension.Log() 12 | assert repr(log) == "Middleware(Log)" 13 | 14 | @pytest.mark.asyncio 15 | async def test_mitm_started(self): 16 | await self.log.mitm_started("localhost", 80) 17 | 18 | @pytest.mark.asyncio 19 | async def test_client_connected(self): 20 | await self.log.client_connected(self.connection) 21 | 22 | @pytest.mark.asyncio 23 | async def test_server_connected(self): 24 | await self.log.server_connected(self.connection) 25 | 26 | @pytest.mark.asyncio 27 | async def test_client_data(self): 28 | data = b"hello" 29 | ret = await self.log.client_data(self.connection, data) 30 | assert ret == data 31 | 32 | @pytest.mark.asyncio 33 | async def test_server_data(self): 34 | data = b"hello" 35 | ret = await self.log.server_data(self.connection, data) 36 | assert ret == data 37 | 38 | @pytest.mark.asyncio 39 | async def test_client_disconnected(self): 40 | await self.log.client_disconnected(self.connection) 41 | 42 | @pytest.mark.asyncio 43 | async def test_server_disconnected(self): 44 | await self.log.server_disconnected(self.connection) 45 | -------------------------------------------------------------------------------- /tests/extension/test_protocol.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | from mitm import HTTP, Connection, Host, InvalidProtocol 5 | 6 | 7 | class Test_HTTP: 8 | HTTP = HTTP() 9 | 10 | def test_init(self): 11 | assert self.HTTP.bytes_needed 12 | assert self.HTTP.buffer_size 13 | assert self.HTTP.timeout 14 | assert self.HTTP.keep_alive 15 | 16 | @pytest.mark.asyncio 17 | async def test_resolve(self): 18 | connection = Connection(Host(), Host()) 19 | data = b"GET / HTTP/1.1\r\nHost: google.com\r\n\r\n" 20 | host, port, tls = await self.HTTP.resolve(connection, data) 21 | assert host == "google.com" 22 | assert port == 80 23 | assert not tls 24 | 25 | with pytest.raises(InvalidProtocol): 26 | data = b"junk data" 27 | await self.HTTP.resolve(connection, data) 28 | 29 | @pytest.mark.asyncio 30 | async def test_connect_no_tls(self): 31 | connection = Connection(Host(), Host()) 32 | data = b"GET / HTTP/1.1\r\nHost: google.com\r\n\r\n" 33 | host, port, tls = await self.HTTP.resolve(connection, data) 34 | 35 | # Connects to the host, port. 36 | await self.HTTP.connect(connection, host, port, tls, data) 37 | 38 | # Checks if we connected to the host, port. 39 | assert connection.server.reader 40 | assert connection.server.writer 41 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-asyncio 3 | pytest-cov 4 | coveralls 5 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mitm import Host, Connection 3 | import asyncio 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_Host(): 8 | 9 | host = Host() 10 | assert bool(host) is False 11 | assert str(host) == "" 12 | 13 | # 93.184.216.34 = Example.com 14 | reader, writer = await asyncio.open_connection("93.184.216.34", 80) 15 | host = Host(reader=reader, writer=writer, mitm_managed=False) 16 | assert host.host == "93.184.216.34" 17 | assert host.port == 80 18 | assert bool(host) 19 | assert str(host) == "93.184.216.34:80" 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_Connection(): 24 | host1 = Host() 25 | host2 = Host() 26 | connection = Connection(client=host1, server=host2) 27 | assert repr(connection) == "Connection(client=, server=, protocol=None)" 28 | -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import tempfile 3 | from pathlib import Path 4 | 5 | import OpenSSL 6 | from mitm import crypto 7 | 8 | 9 | def test_new_RSA(): 10 | key = crypto.new_RSA() 11 | assert isinstance(key, OpenSSL.crypto.PKey) 12 | 13 | 14 | def test_new_X509(): 15 | cert = crypto.new_X509() 16 | assert isinstance(cert, OpenSSL.crypto.X509) 17 | 18 | 19 | class Test_CertificateAuthority: 20 | def test_init(self): 21 | ca = crypto.CertificateAuthority() 22 | assert isinstance(ca, crypto.CertificateAuthority) 23 | assert isinstance(ca.key, OpenSSL.crypto.PKey) 24 | assert isinstance(ca.cert, OpenSSL.crypto.X509) 25 | 26 | def test_helper_init(self): 27 | with tempfile.TemporaryDirectory() as directory: 28 | path = Path(directory) 29 | 30 | # No cert exists in path. Creates and saves it. 31 | ca = crypto.CertificateAuthority.init(path) 32 | assert isinstance(ca, crypto.CertificateAuthority) 33 | assert isinstance(ca.key, OpenSSL.crypto.PKey) 34 | assert isinstance(ca.cert, OpenSSL.crypto.X509) 35 | 36 | # Assert the files were created. 37 | pem = path / "mitm.pem" 38 | key = path / "mitm.key" 39 | 40 | assert pem.exists() 41 | assert key.exists() 42 | 43 | # Loads the cert from the path. 44 | ca = crypto.CertificateAuthority.init(path) 45 | assert isinstance(ca, crypto.CertificateAuthority) 46 | assert isinstance(ca.key, OpenSSL.crypto.PKey) 47 | assert isinstance(ca.cert, OpenSSL.crypto.X509) 48 | 49 | def test_new_X509(self): 50 | 51 | with tempfile.TemporaryDirectory() as directory: 52 | path = Path(directory) 53 | ca = crypto.CertificateAuthority.init(path) 54 | 55 | # Creates signed cert. 56 | cert, key = ca.new_X509("example.com") 57 | 58 | assert isinstance(cert, OpenSSL.crypto.X509) 59 | assert isinstance(cert.get_pubkey(), OpenSSL.crypto.PKey) 60 | assert isinstance(key, OpenSSL.crypto.PKey) 61 | 62 | # Creates signed cert. 63 | cert, key = ca.new_X509("127.0.0.1") 64 | 65 | assert isinstance(cert, OpenSSL.crypto.X509) 66 | assert isinstance(cert.get_pubkey(), OpenSSL.crypto.PKey) 67 | assert isinstance(key, OpenSSL.crypto.PKey) 68 | 69 | def test_new_context(self): 70 | with tempfile.TemporaryDirectory() as directory: 71 | path = Path(directory) 72 | ca = crypto.CertificateAuthority.init(path) 73 | 74 | # Creates a new SSL context. 75 | context = ca.new_context("example.com") 76 | assert isinstance(context, ssl.SSLContext) 77 | -------------------------------------------------------------------------------- /tests/test_mitm.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | 4 | 5 | from .conftest import HOST, PORT, BUFFER_SIZE 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_connection(): 10 | reader, writer = await asyncio.open_connection(HOST, PORT) 11 | assert reader 12 | assert writer 13 | writer.close() 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_http_request(): 18 | reader, writer = await asyncio.open_connection(HOST, PORT) 19 | writer.write(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") 20 | data = await reader.read(BUFFER_SIZE) 21 | assert data.startswith(b"HTTP/1.1 200 OK\r\n") 22 | writer.close() 23 | await writer.wait_closed() 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_https_request(): 28 | reader, writer = await asyncio.open_connection(HOST, PORT) 29 | writer.write(b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n") 30 | data = await reader.read(BUFFER_SIZE) 31 | assert data.startswith(b"HTTP/1.1 200 OK\r\n") 32 | writer.close() 33 | await writer.wait_closed() 34 | --------------------------------------------------------------------------------