├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── .isort.cfg ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── requirements.txt ├── settings.env └── sync.py /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '27 2 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile=black 3 | extra_standard_library=asgiref 4 | known_first_party=mythic 5 | src_paths=isort,test 6 | line_length=90 7 | use_parentheses=True 8 | multi_line_output=3 9 | include_trailing_comma=True 10 | ensure_newline_before_comments=True 11 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 12 | import_heading_stdlib=Standard Libraries 13 | import_heading_firstparty=Mythic Sync Libraries 14 | import_heading_thirdparty=3rd Party Libraries 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [3.0.7] - 27 June 2024 9 | 10 | ### Changed 11 | 12 | * fixed null entries in oplog inserts for output and command values to be explicit or removed 13 | * updated query execute function to use self.client.connect_async reconnecting AIOHTTP transport instead of making a new session each time 14 | 15 | ## [3.0.6] - 8 April 2024 16 | 17 | ### Changed 18 | 19 | * Fixed one issue of `entryIdentifier` 20 | * Added placeholder `{}` values for `extraFields` in oplog entry creation mutations 21 | 22 | ## [3.0.5] - 5 April 2024 23 | 24 | ### Changed 25 | 26 | * Changed references to the `entry_identifier` field to `entryIdentifier` for Ghostwriter v4.1 27 | 28 | ## [3.0.4] - 14 December 2023 29 | 30 | ### Changed 31 | 32 | * Added check for `entry_identifier` in Ghostwriter before submitting entries 33 | 34 | ## [3.0.3] - 08 December 2023 35 | 36 | ### Changed 37 | 38 | * Adjusted the Ghostwriter messages to more closely mirror that of cobalt_sync 39 | * Adjusted the IP sorting to remove CIDR notations 40 | 41 | ## [3.0.2] - 13 June 2023 42 | 43 | ### Fixed 44 | 45 | * Handled an exception caused by `_check_token()` trying to parse the expiration date from a token that never expires 46 | 47 | ## [3.0.1] - 17 May 2023 48 | 49 | ### Changed 50 | 51 | * The Mythic Sync service will now check your Ghostwriter API token's expiration date and send a warning if it expires within 24 hours 52 | * Added suggestions for possible solutions to GraphQL errors that can be caused by providing an invalid or expired API token or an incorrect/non-existent log ID 53 | 54 | ## [3.0.0] - 11 May 2023 55 | 56 | ### Changed 57 | 58 | * Updated for compatibility with Mythic v3.0.0 59 | 60 | ## [2.0.2] - 14 February 2023 61 | 62 | ### Added 63 | 64 | * The Mythic Sync service will now send messages to Mythic's notification center when it starts and whenever it logs an exception that should be reviewed 65 | 66 | ### Changed 67 | 68 | * Web requests now use the user agent `Mythic_Sync/` to make them easily identifiable in server logs 69 | 70 | ## [2.0.1] - 4 August 2022 71 | 72 | ### Changed 73 | 74 | * Changed log format to include timestamps consistent with Ghostwriter for easier log entry comparisons (Closes #6) 75 | 76 | ## [2.0.0] - 1 August 2022 77 | 78 | ### Added 79 | 80 | * Added a log handler for new agent callbacks 81 | 82 | ### Changed 83 | 84 | * Switched to using Ghostwriter v3's GraphQL API 85 | 86 | ### Deprecated 87 | 88 | * Deprecated support for Ghostwriter v2's REST API 89 | * `mythic_sync` now uses Ghostwriter v3's GraphQL API keys (generated by visiting your user profile) 90 | * Use the `Ghostwriter-v2.x` branch to continue using `mythic_sync` with Ghostwriter v2.x.x 91 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM redis:7-bullseye 2 | 3 | RUN apt update && apt install python3 python3-pip -y \ 4 | --no-install-recommends 5 | 6 | COPY requirements.txt . 7 | RUN python3 -m pip install -r requirements.txt 8 | 9 | COPY sync.py . 10 | 11 | CMD ["bash", "-c", "redis-server & python3 -u sync.py"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021-2022, GhostManager 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mythic_sync 2 | 3 | [![Sponsored by SpecterOps](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fspecterops%2F.github%2Fmain%2Fconfig%2Fshield.json&style=flat)](https://github.com/specterops#ghostwriter) 4 | 5 | [![Python Version](https://img.shields.io/badge/Python-3.10-brightgreen.svg)](.) [![License](https://img.shields.io/badge/License-BSD3-darkred.svg)](.) ![GitHub Release (Latest by Date)](https://img.shields.io/github/v/release/GhostManager/mythic_sync?label=Latest%20Release) ![GitHub Release Date](https://img.shields.io/github/release-date/GhostManager/mythic_sync?label=Release%20Date&color=blue) 6 | 7 | The `mythic_sync` utility connects to a [Mythic](https://github.com/its-a-feature/Mythic) C2 server (>=3.0.0+) to ingest events and post these events to the [Ghostwriter](https://github.com/GhostManager/Ghostwriter) (>=v3.0.1) GraphQL API to create real-time activity logs. 8 | 9 | This tool automatically logs all new agent callbacks and every operator's Mythic commands, comments, and output into Ghostwriter so operators can focus more on technical execution and less on manual and tedious logging and reporting activities. 10 | 11 | The current version of `mythic_sync` requires Mythic >=v3.0.0 and Ghostwriter >=v3.0.1. 12 | 13 | ## Usage 14 | 15 | ### Getting Started 16 | 17 | To authenticate to your instances of Mythic and Ghostwriter, you will need this information handy: 18 | 19 | * Ghostwriter URL 20 | * Ghostwriter GraphQL API token 21 | * Ghostwriter log ID 22 | * Mythic credentials 23 | 24 | #### Ghostwriter API Token & Activity Log 25 | 26 | You can get your log's ID by opening the log's webpage and looking at the top of the page. You'll see "Oplog ID #" followed by a number. That's the ID number you need. 27 | 28 | To generate an API token for your Ghostwriter instance, visit your user profile and click on the "Create" button in the "API Tokens" section. 29 | 30 | The token must be attached to an account that has access to the project containing your target oplog. You can read more about the [authorization controls on the Ghostwriter wiki](https://www.ghostwriter.wiki/features/graphql-api/authorization). 31 | 32 | ### Execute via Mythic 3.0+ and `mythic-cli` 33 | 34 | For the easiest experience with `mythic_sync`, install it via the `mythic-cli` tool. When installed this way, the `mythic_sync` service will become part of your Mythic deployment. You can then use `mythic-cli` to manage `mythic_sync` (just like Mythic) and the service will come up and go down alongside your other Mythic services. 35 | 36 | On your Mythic server, run: `sudo ./mythic-cli mythic_sync install github https://github.com/GhostManager/mythic_sync` 37 | 38 | Follow the prompts to configure `mythic_sync` with your Mythic and Ghostwriter server configuration. 39 | 40 | You can get your Ghostwriter Oplog ID by visiting your log in your web browser and looking at the top of the page or the URL. A URL with `/oplog/12/entries` means your Oplog ID is `12`. 41 | 42 | ```bash 43 | sudo ./mythic-cli mythic_sync install github https://github.com/GhostManager/mythic_sync 44 | [*] Creating temporary directory 45 | [*] Cloning https://github.com/GhostManager/mythic_sync 46 | Cloning into '/opt/Mythic/tmp'... 47 | Please enter your GhostWriter API Key: eyJ0eXAiO... 48 | Please enter your GhostWriter URL: https://ghostwriter.domain.com 49 | Please enter your GhostWriter OpLog ID: 12 50 | Please enter your Mythic API Key (optional): 51 | [+] Added mythic_sync to docker-compose 52 | [+] Successfully installed mythic_sync! 53 | [+] Successfully updated configuration in .env 54 | ``` 55 | 56 | ### Execute via Stand Alone Docker 57 | 58 | Alternatively, you can use Docker and `docker-compose` to run the `mythic_sync` container. Use this option if you'd prefer to run `mythic_sync` on a different server than your Mythic containers or don't want to use `mythic-cli` to manage the service. 59 | 60 | After cloning repository, open the `settings.env` file and fill in the variables with appropriate values. The following is an example: 61 | 62 | ```text 63 | MYTHIC_IP=10.10.1.100 64 | MYTHIC_USERNAME=mythic_admin 65 | MYTHIC_PASSWORD=SuperSecretPassword 66 | GHOSTWRITER_API_KEY=eyJ0eXAiO... 67 | GHOSTWRITER_URL=https://ghostwriter.mydomain.com 68 | GHOSTWRITER_OPLOG_ID=12 69 | ``` 70 | 71 | Once the environment variables are set up, you can launch the service by using `docker-compose`: 72 | 73 | ``` bash 74 | docker-compose up 75 | ``` 76 | 77 | ### Verify Successful Start-Up 78 | 79 | Open your Ghostwriter log and look for an initial entry. You should see something like the following: 80 | 81 | > Initial entry from mythic_sync at: . If you're seeing this then oplog syncing is working for this C2 server! 82 | 83 | If so, you're all set! Otherwise, check the logs from the docker container for error messages. Fetch the logs with: 84 | 85 | `sudo ./mythic-cli logs mythic_sync` 86 | 87 | ## Troubleshooting 88 | 89 | Ensure the host where `mythic_sync` is running has network access to the Ghostwriter and Mythic servers. 90 | 91 | `mythic_sync` uses an internal Redis database to sync what events have already been sent to Ghostwriter, avoiding duplicates. 92 | 93 | If the `mythic_sync` service goes down, it is safe to stand it back up and avoid duplicates as long as nothing has forcefully stopped Mythic's Redis container. 94 | 95 | ## References 96 | 97 | - [Mythic](https://github.com/its-a-feature/Mythic) - Multi-platform C2 Framework 98 | - [Ghostwriter](https://github.com/GhostManager/Ghostwriter) - Engagement Management and Reporting Platform 99 | - [Ghostwriter's Official Documentation - Operation Logging w/ Ghostwriter](https://ghostwriter.wiki/features/operation-logs) - Guidance on operation logging setup and usage with Ghostwriter 100 | - [Blog - Updates to Ghostwriter: UI and Operation Logs](https://posts.specterops.io/updates-to-ghostwriter-ui-and-operation-logs-d6b3bc3d3fbd_) - Initial announcement of the operation logging features in Ghostwriter 101 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | mythic_sync: 4 | build: . 5 | depends_on: 6 | - redis 7 | env_file: 8 | - settings.env 9 | redis: 10 | image: redis:5-alpine 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.4 2 | redis==4.5.4 3 | mythic==0.2.1 4 | gql==3.4.0 5 | -------------------------------------------------------------------------------- /settings.env: -------------------------------------------------------------------------------- 1 | MYTHIC_IP=10.10.1.100 2 | MYTHIC_PORT=7443 3 | MYTHIC_USERNAME=mythic_admin 4 | MYTHIC_PASSWORD=SuperSecretPassword 5 | GHOSTWRITER_API_KEY=eyJ0eXAiO... 6 | GHOSTWRITER_URL=https://ghostwriter.mydomain.com 7 | GHOSTWRITER_OPLOG_ID=123 8 | REDIS_HOSTNAME=redis 9 | REDIS_PORT=6379 10 | -------------------------------------------------------------------------------- /sync.py: -------------------------------------------------------------------------------- 1 | # Standard Libraries 2 | import asyncio 3 | import ipaddress 4 | import json 5 | import logging 6 | import os 7 | import sys 8 | from asyncio.exceptions import TimeoutError 9 | from datetime import datetime, timedelta, timezone 10 | 11 | # 3rd Party Libraries 12 | import aiohttp 13 | import redis 14 | from gql import Client, gql 15 | from gql.client import DocumentNode 16 | from gql.transport.aiohttp import AIOHTTPTransport 17 | from gql.transport.exceptions import TransportQueryError 18 | from graphql.error.graphql_error import GraphQLError 19 | 20 | # Mythic Sync Libraries 21 | from mythic import mythic, mythic_classes 22 | 23 | VERSION = "3.0.7" 24 | 25 | # Logging configuration 26 | # Level applies to all loggers, including ``gql`` Transport and Client loggers 27 | # Using a level below ``WARNING`` may make logs difficult to read 28 | logging.basicConfig( 29 | level=logging.WARNING, 30 | format="%(levelname)s %(asctime)s %(message)s" 31 | ) 32 | mythic_sync_log = logging.getLogger("mythic_sync_logger") 33 | mythic_sync_log.setLevel(logging.DEBUG) 34 | 35 | 36 | class MythicSync: 37 | # Redis and Mythic connectors 38 | rconn = None 39 | mythic_instance = None 40 | 41 | # Map integrity level numbers to their meanings (based on Windows integrity levels) 42 | # The *nix agents will always report ``2`` (not root) or ``3`` (root) 43 | integrity_levels = { 44 | 1: "Low", 45 | 2: "Medium", 46 | 3: "High", 47 | 4: "SYSTEM", 48 | } 49 | 50 | # How long to wait for a service to start before retrying an HTTP request 51 | wait_timeout = 5 52 | 53 | # Query for the whoami expiration checks 54 | whoami_query = gql( 55 | """ 56 | query whoami { 57 | whoami { 58 | expires 59 | } 60 | } 61 | """ 62 | ) 63 | 64 | # Query for specific oplog entry 65 | entry_identifier_query = gql( 66 | """ 67 | query checkEntryIdentifier($entry_identifier: String!, $oplog: bigint!){ 68 | oplogEntry(where: {oplog: {_eq: $oplog}, entryIdentifier: {_eq: $entry_identifier}}, limit: 1){ 69 | id 70 | } 71 | } 72 | """ 73 | ) 74 | 75 | # Query for the first log sent after initialization 76 | initial_query = gql( 77 | """ 78 | mutation InitializeMythicSync ($oplogId: bigint!, $description: String!, $server: String!, $extraFields: jsonb!) { 79 | insert_oplogEntry(objects: { 80 | oplog: $oplogId, 81 | description: $description, 82 | sourceIp: $server, 83 | tool: "Mythic", 84 | extraFields: $extraFields 85 | }) { 86 | returning { id } 87 | } 88 | } 89 | """ 90 | ) 91 | 92 | # Query inserting a new log entry 93 | insert_query = gql( 94 | """ 95 | mutation InsertMythicSyncLog ( 96 | $oplog: bigint!, $startDate: timestamptz, $endDate: timestamptz, $sourceIp: String, $destIp: String, 97 | $tool: String, $userContext: String, $command: String, $description: String, 98 | $comments: String, $operatorName: String, $entry_identifier: String!, $extraFields: jsonb! 99 | ) { 100 | insert_oplogEntry(objects: { 101 | oplog: $oplog, 102 | startDate: $startDate, 103 | endDate: $endDate, 104 | sourceIp: $sourceIp, 105 | destIp: $destIp, 106 | tool: $tool, 107 | userContext: $userContext, 108 | command: $command, 109 | description: $description, 110 | comments: $comments, 111 | operatorName: $operatorName, 112 | entryIdentifier: $entry_identifier 113 | extraFields: $extraFields 114 | }) { 115 | returning { id } 116 | } 117 | } 118 | """ 119 | ) 120 | 121 | # Query for updating a new log entry 122 | update_query = gql( 123 | """ 124 | mutation UpdateMythicSyncLog ( 125 | $id: bigint!, $oplog: bigint!, $startDate: timestamptz, $endDate: timestamptz, $sourceIp: String, 126 | $destIp: String, $tool: String, $userContext: String, $command: String, 127 | $description: String, $comments: String, $operatorName: String, 128 | $entry_identifier: String, $extraFields: jsonb 129 | ) { 130 | update_oplogEntry(where: { 131 | id: {_eq: $id} 132 | }, _set: { 133 | oplog: $oplog, 134 | startDate: $startDate, 135 | endDate: $endDate, 136 | sourceIp: $sourceIp, 137 | destIp: $destIp, 138 | tool: $tool, 139 | userContext: $userContext, 140 | command: $command, 141 | description: $description, 142 | comments: $comments, 143 | operatorName: $operatorName, 144 | entryIdentifier: $entry_identifier, 145 | extraFields: $extraFields 146 | }) { 147 | returning { id } 148 | } 149 | } 150 | """ 151 | ) 152 | 153 | # Mythic authentication 154 | MYTHIC_API_KEY = os.environ.get("MYTHIC_API_KEY") or "" 155 | MYTHIC_USERNAME = os.environ.get("MYTHIC_USERNAME") or "" 156 | MYTHIC_PASSWORD = os.environ.get("MYTHIC_PASSWORD") or "" 157 | 158 | # Mythic server 159 | MYTHIC_IP = os.environ.get("MYTHIC_IP") 160 | if MYTHIC_IP is None: 161 | mythic_sync_log.error("MYTHIC_IP must be supplied!") 162 | sys.exit(1) 163 | 164 | MYTHIC_PORT = os.environ.get("MYTHIC_PORT") 165 | if MYTHIC_PORT is None: 166 | mythic_sync_log.error("MYTHIC_PORT must be supplied!") 167 | sys.exit(1) 168 | else: 169 | MYTHIC_PORT = int(MYTHIC_PORT) 170 | 171 | MYTHIC_URL = f"https://{MYTHIC_IP}:{MYTHIC_PORT}" 172 | 173 | # Redis server 174 | REDIS_HOSTNAME = "127.0.0.1" 175 | REDIS_PORT = 6379 176 | 177 | # Ghostwriter server authentication 178 | GHOSTWRITER_API_KEY = os.environ.get("GHOSTWRITER_API_KEY") 179 | if GHOSTWRITER_API_KEY is None: 180 | mythic_sync_log.error("GHOSTWRITER_API_KEY must be supplied!") 181 | sys.exit(1) 182 | 183 | # Ghostwriter server & oplog target 184 | GHOSTWRITER_URL = os.environ.get("GHOSTWRITER_URL") 185 | if GHOSTWRITER_URL is None: 186 | mythic_sync_log.error("GHOSTWRITER_URL must be supplied!") 187 | sys.exit(1) 188 | 189 | GHOSTWRITER_OPLOG_ID = os.environ.get("GHOSTWRITER_OPLOG_ID") 190 | if GHOSTWRITER_OPLOG_ID is None: 191 | mythic_sync_log.error("GHOSTWRITER_OPLOG_ID must be supplied!") 192 | sys.exit(1) 193 | 194 | # GraphQL transport configuration 195 | GRAPHQL_URL = GHOSTWRITER_URL.rstrip("/") + "/v1/graphql" 196 | headers = { 197 | "User-Agent": f"Mythic_Sync/{VERSION}", 198 | "Authorization": f"Bearer {GHOSTWRITER_API_KEY}", 199 | "Content-Type": "application/json" 200 | } 201 | last_error_timestamp = datetime.utcnow() - timedelta(hours=1) 202 | last_error_delta = timedelta(minutes=30) 203 | session = None 204 | client = None 205 | transport = AIOHTTPTransport(url=GRAPHQL_URL, timeout=10, headers=headers) 206 | 207 | def __init__(self): 208 | pass 209 | 210 | async def initialize(self) -> None: 211 | """ 212 | Function to initialize necessary connections with Mythic services. This must 213 | always be run before anything else. 214 | """ 215 | self.client = Client(transport=self.transport, fetch_schema_from_transport=False, ) 216 | self.session = await self.client.connect_async(reconnecting=True) 217 | await self._wait_for_redis() 218 | mythic_sync_log.info("Successfully connected to Redis") 219 | 220 | await self._wait_for_service() 221 | mythic_sync_log.info("Successfully connected to %s", self.MYTHIC_URL) 222 | 223 | mythic_sync_log.info("Trying to authenticate to Mythic") 224 | self.mythic_instance = await self.__wait_for_authentication() 225 | mythic_sync_log.info("Successfully authenticated to Mythic") 226 | 227 | await self._check_token() 228 | await self._create_initial_entry() 229 | 230 | async def _get_sorted_ips(self, ip: str) -> str: 231 | source_ips = json.loads(ip) 232 | # account for CIDR notation (ex: 192.168.0.123/24) in IPs list to make sure we only get the actual IP 233 | source_ips = [x.split("/")[0] for x in source_ips if x != ""] 234 | source_ipv4 = [] 235 | for i in range(len(source_ips)): 236 | new_address = ipaddress.ip_address(source_ips[i]) 237 | if isinstance(new_address, ipaddress.IPv4Address): 238 | source_ipv4.append(new_address) 239 | source_ipv4 = [str(x) for x in sorted(source_ipv4)] 240 | source_ips = source_ipv4 241 | source_ip = json.dumps(source_ips) 242 | return source_ip 243 | 244 | async def _execute_query(self, query: DocumentNode, variable_values: dict = None) -> dict: 245 | """ 246 | Execute a GraphQL query against the Ghostwriter server. 247 | 248 | **Parameters** 249 | 250 | ``query`` 251 | The GraphQL query to execute 252 | ``variable_values`` 253 | The parameters to pass to the query 254 | """ 255 | while True: 256 | try: 257 | try: 258 | result = await self.session.execute(query, variable_values=variable_values) 259 | mythic_sync_log.debug("Successfully executed query with result: %s", result) 260 | return result 261 | except TimeoutError: 262 | mythic_sync_log.error( 263 | "Timeout occurred while trying to connect to Ghostwriter at %s", 264 | self.GHOSTWRITER_URL 265 | ) 266 | await self._post_error_notification( 267 | f"MythicSync:\nTimeout occurred while trying to connect to Ghostwriter at {self.GHOSTWRITER_URL}", ) 268 | await asyncio.sleep(self.wait_timeout) 269 | continue 270 | except TransportQueryError as e: 271 | mythic_sync_log.exception("Error encountered while fetching GraphQL schema: %s", e) 272 | 273 | payload = e.errors[0] 274 | if "extensions" in payload: 275 | if "code" in payload["extensions"]: 276 | if payload["extensions"]["code"] == "access-denied": 277 | mythic_sync_log.error( 278 | "Access denied for the provided Ghostwriter API token! Check if it is valid, update your configuration, and restart") 279 | await self._post_error_notification( 280 | message=f"Access denied for the provided Ghostwriter API token! Check if it is valid, update your Mythic Sync configuration, and restart the service.", 281 | source="mythic_sync_access_denied", 282 | ) 283 | await asyncio.sleep(self.wait_timeout) 284 | continue 285 | if payload["extensions"]["code"] == "postgres-error": 286 | mythic_sync_log.error( 287 | "Ghostwriter's database rejected the query! Check if your configured log ID is correct.") 288 | await self._post_error_notification( 289 | message=f"Ghostwriter's database rejected the query! Check if your configured log ID ({self.GHOSTWRITER_OPLOG_ID}) is correct.", 290 | source="mythic_sync_reject", 291 | ) 292 | await asyncio.sleep(self.wait_timeout) 293 | continue 294 | await self._post_error_notification( 295 | f"MythicSync:\nError encountered while fetching GraphQL schema: {e}") 296 | await asyncio.sleep(self.wait_timeout) 297 | continue 298 | except GraphQLError as e: 299 | mythic_sync_log.exception("Error with GraphQL query: %s", e) 300 | await self._post_error_notification(f"MythicSync:\nError with GraphQL query: {e}") 301 | await asyncio.sleep(self.wait_timeout) 302 | continue 303 | except Exception as exc: 304 | mythic_sync_log.error( 305 | "Exception occurred while trying to post the query to Ghostwriter! Trying again in %s seconds...", 306 | self.wait_timeout 307 | ) 308 | await self._post_error_notification( 309 | f"MythicSync:\nException occurred while trying to post the query to Ghostwriter!\n{exc}") 310 | await asyncio.sleep(self.wait_timeout) 311 | continue 312 | 313 | async def _check_token(self) -> None: 314 | """Send a `whoami` query to Ghostwriter to check authentication and token expiration.""" 315 | whoami = await self._execute_query(self.whoami_query) 316 | 317 | # Check if the token will expire within 24 hours 318 | now = datetime.now(timezone.utc) 319 | if whoami["whoami"]["expires"] == "Never": 320 | expiry = "Never" 321 | else: 322 | expiry = datetime.fromisoformat(whoami["whoami"]["expires"]) 323 | if expiry - now < timedelta(hours=24): 324 | mythic_sync_log.debug(f"The provided Ghostwriter API token expires in less than 24 hours ({expiry})!") 325 | await self._post_error_notification( 326 | message=f"The provided Ghostwriter API token expires in less than 24 hours ({expiry})!", 327 | source="mythic_sync_token_expiration", 328 | ) 329 | await mythic.send_event_log_message( 330 | mythic=self.mythic_instance, 331 | message=f"Mythic Sync has successfully authenticated to Ghostwriter. Your configured token expires at: {expiry}", 332 | source="mythic_sync", 333 | level="info" 334 | ) 335 | 336 | async def _create_initial_entry(self) -> None: 337 | """Send the initial log entry to Ghostwriter's Oplog.""" 338 | mythic_sync_log.info("Sending the initial Ghostwriter log entry") 339 | variable_values = { 340 | "oplogId": self.GHOSTWRITER_OPLOG_ID, 341 | "description": f"Initial entry from mythic_sync at: {self.MYTHIC_IP}. If you're seeing this then oplog " 342 | f"syncing is working for this C2 server!", 343 | "server": f"Mythic Server ({self.MYTHIC_IP})", 344 | "extraFields": {} 345 | } 346 | await self._execute_query(self.initial_query, variable_values) 347 | await mythic.send_event_log_message( 348 | mythic=self.mythic_instance, 349 | message="Mythic Sync successfully posted its initial log entry to Ghostwriter", 350 | source="mythic_sync", 351 | level="info" 352 | ) 353 | return 354 | 355 | async def _post_error_notification(self, message: str = None, source: str = None) -> None: 356 | """Send an error notification to Mythic's notification center.""" 357 | if message is None: 358 | message = "Mythic Sync logged an error and may need attention to continue syncing.\n" \ 359 | "Run this command to review the issue:\n\n" \ 360 | " sudo ./mythic-cli logs mythic_sync" 361 | mythic_sync_log.info("Submitting an error notification to Mythic's notification center: %s", message) 362 | await mythic.send_event_log_message(mythic=self.mythic_instance, 363 | message=message, 364 | source="mythic_sync" if source is None else source, 365 | level="warning") 366 | return 367 | 368 | async def _mythic_task_to_ghostwriter_message(self, message: dict) -> dict: 369 | """ 370 | Converts a Mythic task to the fields expected by Ghostwriter's GraphQL API and ``OplogEntry`` model. 371 | 372 | **Parameters** 373 | 374 | ``message`` 375 | The message dictionary to be converted 376 | """ 377 | gw_message = {} 378 | try: 379 | if message["status_timestamp_submitted"] is not None: 380 | start_date = datetime.strptime( 381 | message["status_timestamp_submitted"], "%Y-%m-%dT%H:%M:%S.%f") 382 | gw_message["startDate"] = start_date.strftime("%Y-%m-%d %H:%M:%S") 383 | if message["status_timestamp_processed"] is not None: 384 | end_date = datetime.strptime( 385 | message["status_timestamp_processed"], "%Y-%m-%dT%H:%M:%S.%f") 386 | gw_message["endDate"] = end_date.strftime("%Y-%m-%d %H:%M:%S") 387 | if message['command'] is not None: 388 | gw_message["command"] = f"{message['command']['cmd']} {message['original_params']}" 389 | else: 390 | gw_message["command"] = f"{message['command_name']} {message['original_params']}" 391 | gw_message["comments"] = message["comment"] if message["comment"] is not None else "" 392 | gw_message["operatorName"] = message["operator"]["username"] if message["operator"] is not None else "" 393 | gw_message["oplog"] = self.GHOSTWRITER_OPLOG_ID 394 | hostname = message["callback"]["host"] 395 | source_ip = await self._get_sorted_ips(message["callback"]["ip"]) 396 | gw_message["sourceIp"] = f"{hostname} ({source_ip})" 397 | gw_message[ 398 | "description"] = f"PID: {message['callback']['pid']}, Callback: {message['callback']['display_id']}" 399 | gw_message["userContext"] = message["callback"]["user"] 400 | gw_message["tool"] = message["callback"]["payload"]["payloadtype"]["name"] 401 | gw_message['entry_identifier'] = message["agent_task_id"] 402 | gw_message['extraFields'] = {} 403 | except Exception: 404 | mythic_sync_log.exception( 405 | "Encountered an exception while processing Mythic's message into a message for Ghostwriter" 406 | ) 407 | return gw_message 408 | 409 | async def _mythic_callback_to_ghostwriter_message(self, message: dict) -> dict: 410 | """ 411 | Converts a Mythic callback event to the fields expected by Ghostwriter's GraphQL API and ``OplogEntry`` model. 412 | 413 | **Parameters** 414 | 415 | ``message`` 416 | The message dictionary to be converted 417 | """ 418 | gw_message = {} 419 | try: 420 | callback_date = datetime.strptime(message["init_callback"], "%Y-%m-%dT%H:%M:%S.%f") 421 | gw_message["startDate"] = callback_date.strftime("%Y-%m-%d %H:%M:%S") 422 | gw_message["comments"] = f"New Callback {message['display_id']}" 423 | integrity = self.integrity_levels[message["integrity_level"]] 424 | opsys = message['os'].replace("\n", ", ") 425 | gw_message[ 426 | "description"] = f"Computer: {message['host']}, Integrity Level: {integrity}, Process: {message['process_name']}, PID: {message['pid']}, User: {message['user']}, Domain: {message['domain']}, OS: {opsys}" 427 | gw_message["operatorName"] = message["operator"]["username"] if message["operator"] is not None else "" 428 | source_ip = await self._get_sorted_ips(message["ip"]) 429 | gw_message["sourceIp"] = f"{message['host']} ({source_ip})" 430 | gw_message["userContext"] = message["user"] 431 | gw_message["tool"] = message["payload"]["payloadtype"]["name"] 432 | gw_message["oplog"] = self.GHOSTWRITER_OPLOG_ID 433 | gw_message['entry_identifier'] = message["agent_callback_id"] 434 | gw_message['extraFields'] = {} 435 | gw_message["command"] = "" 436 | except Exception: 437 | mythic_sync_log.exception( 438 | "Encountered an exception while processing Mythic's message into a message for Ghostwriter! Received message: %s", 439 | message 440 | ) 441 | return gw_message 442 | 443 | async def _create_entry(self, message: dict) -> None: 444 | """ 445 | Create an entry for a Mythic task in Ghostwriter's ``OplogEntry`` model. Uses the 446 | ``insert_query`` template and the operation name ``InsertMythicSyncLog``. 447 | 448 | **Parameters** 449 | 450 | ``message`` 451 | Dictionary produced by ``_mythic_task_to_ghostwriter_message()`` or ``_mythic_callback_to_ghostwriter_message()`` 452 | """ 453 | entry_id = "" 454 | gw_message = {} 455 | if "agent_task_id" in message: 456 | entry_id = message["agent_task_id"] 457 | mythic_sync_log.debug(f"Adding task: {message['agent_task_id']}") 458 | gw_message = await self._mythic_task_to_ghostwriter_message(message) 459 | elif "agent_callback_id" in message: 460 | entry_id = message["agent_callback_id"] 461 | mythic_sync_log.debug(f"Adding callback: {message['agent_callback_id']}") 462 | gw_message = await self._mythic_callback_to_ghostwriter_message(message) 463 | else: 464 | mythic_sync_log.error( 465 | "Failed to create an entry for task, no `agent_task_id` or `agent_callback_id` found! Message " 466 | "contents: %s", message 467 | ) 468 | 469 | if entry_id != "" and 'entry_identifier' in gw_message: 470 | result = None 471 | try: 472 | query_result = await self._execute_query(self.entry_identifier_query, { 473 | "oplog": gw_message["oplog"], 474 | "entry_identifier": gw_message['entry_identifier'], 475 | }) 476 | if query_result and "oplogEntry" in query_result and len(query_result["oplogEntry"]) > 0: 477 | mythic_sync_log.info( 478 | f"Duplicate entry found based on entryIdentifier, {gw_message['entry_identifier']}, not sending") 479 | # save off id of oplog entry with this gw_message['entry_identifier'] so we don't try to send it again 480 | self.rconn.set(entry_id, query_result["oplogEntry"][0]["id"]) 481 | return 482 | result = await self._execute_query(self.insert_query, gw_message) 483 | if result and "insert_oplogEntry" in result: 484 | # JSON response example: `{'data': {'insert_oplogEntry': {'returning': [{'id': 192}]}}}` 485 | self.rconn.set(entry_id, result["insert_oplogEntry"]["returning"][0]["id"]) 486 | else: 487 | mythic_sync_log.info( 488 | "Did not receive a response with data from Ghostwriter's GraphQL API! Response: %s", 489 | result 490 | ) 491 | except Exception: 492 | mythic_sync_log.exception( 493 | "Encountered an exception while trying to create a new log entry! Response from Ghostwriter: %s", 494 | result, 495 | ) 496 | await self._post_error_notification() 497 | 498 | async def _update_entry(self, message: dict, entry_id: str) -> None: 499 | """ 500 | Update an existing Ghostwriter ``OplogEntry`` entry for a task with more details from Mythic. 501 | Uses the ``update_query`` template and the operation name ``UpdateMythicSyncLog``. 502 | 503 | **Parameters** 504 | 505 | ``message`` 506 | Dictionary produced by ``_mythic_task_to_ghostwriter_message()`` 507 | ``entry_id`` 508 | The ID of the log entry to be updated 509 | """ 510 | mythic_sync_log.debug(f"Updating task: {message['agent_task_id']} - {message['id']} : {entry_id}") 511 | gw_message = await self._mythic_task_to_ghostwriter_message(message) 512 | gw_message["id"] = entry_id 513 | try: 514 | result = await self._execute_query(self.update_query, gw_message) 515 | if not result or "update_oplogEntry" not in result: 516 | mythic_sync_log.info( 517 | "Did not receive a response with data from Ghostwriter's GraphQL API! Response: %s", 518 | result 519 | ) 520 | except Exception: 521 | mythic_sync_log.exception("Exception encountered while trying to update task log entry in Ghostwriter!") 522 | 523 | async def handle_task(self) -> None: 524 | """ 525 | Start a subscription for Mythic tasks and handle them. Send new tasks to Ghostwriter 526 | with ``_create_entry()`` or send updates for existing tasks with ``_update_entry()``. 527 | """ 528 | custom_return_attributes = """ 529 | agent_task_id 530 | id 531 | display_id 532 | timestamp 533 | status_timestamp_submitted 534 | status_timestamp_processed 535 | command_name 536 | original_params 537 | comment 538 | command { 539 | cmd 540 | } 541 | operator { 542 | username 543 | } 544 | callback { 545 | host 546 | ip 547 | pid 548 | display_id 549 | user 550 | payload { 551 | payloadtype { 552 | name 553 | } 554 | } 555 | } 556 | """ 557 | mythic_sync_log.info("Starting subscription for tasks") 558 | async for data in mythic.subscribe_all_tasks_and_updates( 559 | mythic=self.mythic_instance, custom_return_attributes=custom_return_attributes, 560 | ): 561 | try: 562 | entry_id = self.rconn.get(data["agent_task_id"]) 563 | except Exception: 564 | mythic_sync_log.exception( 565 | "Encountered an exception while connecting to Redis to fetch data! Data returned by Mythic: %s", 566 | data 567 | ) 568 | await self._post_error_notification() 569 | continue 570 | if entry_id is not None: 571 | await self._update_entry(data, entry_id.decode()) 572 | else: 573 | await self._create_entry(data) 574 | 575 | async def handle_callback(self) -> None: 576 | """ 577 | Start a subscription for Mythic agent callbacks and send all new callbacks to Ghostwriter 578 | with ``_create_entry()``. 579 | """ 580 | custom_return_attributes = """ 581 | agent_callback_id 582 | init_callback 583 | integrity_level 584 | description 585 | host 586 | id 587 | display_id 588 | extra_info 589 | ip 590 | os 591 | pid 592 | domain 593 | process_name 594 | user 595 | operator { 596 | username 597 | } 598 | payload { 599 | payloadtype { 600 | name 601 | } 602 | } 603 | """ 604 | mythic_sync_log.info("Starting subscription for callbacks") 605 | async for data in mythic.subscribe_new_callbacks( 606 | mythic=self.mythic_instance, custom_return_attributes=custom_return_attributes, batch_size=1 607 | ): 608 | await self._create_entry(data[0]) 609 | 610 | async def _wait_for_service(self) -> None: 611 | """Wait for an HTTP session to be established with Mythic.""" 612 | while True: 613 | mythic_sync_log.info("Attempting to connect to %s", self.MYTHIC_URL) 614 | try: 615 | async with aiohttp.ClientSession() as session: 616 | async with session.get(self.MYTHIC_URL, ssl=False) as resp: 617 | if resp.status != 200: 618 | mythic_sync_log.warning( 619 | "Expected 200 OK and received HTTP code %s while trying to connect to Mythic, trying again in %s seconds...", 620 | resp.status, self.wait_timeout 621 | ) 622 | await asyncio.sleep(self.wait_timeout) 623 | continue 624 | except Exception as e: 625 | await asyncio.sleep(self.wait_timeout) 626 | mythic_sync_log.warning("failed to connect to Mythic: %s", e) 627 | continue 628 | return 629 | 630 | async def _wait_for_redis(self) -> None: 631 | """Wait for a connection to be established with Mythic's Redis container.""" 632 | while True: 633 | try: 634 | self.rconn = redis.Redis(host=self.REDIS_HOSTNAME, port=self.REDIS_PORT, db=1) 635 | return 636 | except Exception: 637 | mythic_sync_log.exception( 638 | "Encountered an exception while trying to connect to Redis, %s:%s, trying again in %s seconds...", 639 | self.REDIS_HOSTNAME, self.REDIS_PORT, self.wait_timeout 640 | ) 641 | await self._post_error_notification() 642 | await asyncio.sleep(self.wait_timeout) 643 | continue 644 | 645 | async def __wait_for_authentication(self) -> mythic_classes.Mythic: 646 | """Wait for authentication with Mythic to complete.""" 647 | while True: 648 | # If ``MYTHIC_API_KEY`` is not set in the environment, then authenticate with user credentials 649 | if len(self.MYTHIC_API_KEY) == 0: 650 | mythic_sync_log.info( 651 | "Authenticating to Mythic, https://%s:%s, with username and password", 652 | self.MYTHIC_IP, self.MYTHIC_PORT 653 | ) 654 | try: 655 | mythic_instance = await mythic.login( 656 | username=self.MYTHIC_USERNAME, 657 | password=self.MYTHIC_PASSWORD, 658 | server_ip=self.MYTHIC_IP, 659 | server_port=self.MYTHIC_PORT, 660 | ssl=True, 661 | timeout=-1) 662 | except Exception as e: 663 | mythic_sync_log.error( 664 | "Encountered an exception while trying to authenticate to Mythic, trying again in %s seconds...", 665 | self.wait_timeout 666 | ) 667 | await asyncio.sleep(self.wait_timeout) 668 | continue 669 | try: 670 | await mythic.get_me(mythic=mythic_instance) 671 | except Exception as e: 672 | mythic_sync_log.error( 673 | "Encountered an exception while trying to get user info from Mythic, trying again in %s seconds...", 674 | self.wait_timeout 675 | ) 676 | await asyncio.sleep(self.wait_timeout) 677 | continue 678 | elif self.MYTHIC_USERNAME == "" and self.MYTHIC_PASSWORD == "": 679 | mythic_sync_log.error("You must supply a MYTHIC_USERNAME and MYTHIC_PASSWORD") 680 | sys.exit(1) 681 | else: 682 | mythic_sync_log.info( 683 | "Authenticating to Mythic, https://%s:%s, with a specified API Key", 684 | self.MYTHIC_IP, self.MYTHIC_PORT 685 | ) 686 | try: 687 | mythic_instance = await mythic.login( 688 | apitoken=self.MYTHIC_API_KEY, 689 | server_ip=self.MYTHIC_IP, 690 | server_port=self.MYTHIC_PORT, 691 | ssl=True) 692 | await mythic.get_me(mythic=mythic_instance) 693 | except Exception as e: 694 | mythic_sync_log.error( 695 | "Failed to authenticate with the Mythic API token, trying again in %s seconds...", 696 | self.wait_timeout 697 | ) 698 | await asyncio.sleep(self.wait_timeout) 699 | continue 700 | 701 | return mythic_instance 702 | 703 | 704 | async def scripting(): 705 | mythic_sync = MythicSync() 706 | while True: 707 | await mythic_sync.initialize() 708 | try: 709 | _ = await asyncio.gather( 710 | mythic_sync.handle_task(), 711 | mythic_sync.handle_callback(), 712 | ) 713 | except Exception: 714 | mythic_sync_log.exception( 715 | "Encountered an exception while subscribing to tasks and responses, restarting..." 716 | ) 717 | finally: 718 | await mythic_sync.client.close_async() 719 | 720 | 721 | asyncio.run(scripting()) --------------------------------------------------------------------------------