Skip to content

Security: csysp/xmrdp

SECURITY.md

Security Reference — XMRDP

Last reviewed: 2026-03-16

Contents

  1. Overview
  2. Security Model
  3. Attack Surface
  4. Audit Logging
  5. Open Findings
  6. Documented Limitations
  7. Fixed Findings
  8. Deployment Hardening

1. Overview

XMRDP is a Python CLI tool for deploying a Monero mining cluster on P2Pool mini. The master node runs monerod, p2pool, xmrig, and an HTTP telemetry server (the C2). Worker nodes run xmrig and report hashrate and uptime to the master via HTTP heartbeat. Configuration is distributed to workers by the operator using xmrdp sync (SSH/SCP push); workers never pull from the master at runtime. The entire tool is implemented in Python stdlib with no external runtime dependencies.

This document describes the current security posture of XMRDP for operators deploying it on real hardware and for contributors reviewing security-relevant code. It is a reference for the current state of the codebase, not a development history.


2. Security Model

No private keys are held or transmitted. The Monero wallet address is a public receive address. It appears in the cluster config and in the p2pool process argument list. It is not a private key and does not grant spending authority.

Binary integrity is verified by SHA-256. All three binaries (monerod, p2pool, xmrig) are downloaded from GitHub releases and verified against the upstream-published SHA-256 checksum file before extraction. Verification is enabled by default (security.verify_checksums = true) and hard-fails when a checksum is expected but not found. The checksum file itself is not GPG-verified — see F-08 GPG for the current status.

C2 authentication is Bearer token only. All three C2 endpoints require a shared Authorization: Bearer <token> header. The token is a 32-byte hex string generated by secrets.token_hex(32) during setup. Tokens are compared with hmac.compare_digest to prevent timing attacks. There are no per-worker credentials; worker identity is enforced by IP binding after registration.

The C2 server is telemetry-only. It accepts worker registrations, heartbeats, and returns aggregate cluster status. It does not serve binary files, does not execute remote commands, and does not accept arbitrary data beyond the JSON fields it expects.

Configuration is operator-pushed, not worker-pulled. xmrdp sync copies the cluster config to workers via SCP and sets permissions to 600 on arrival. Workers read this file at startup. There is no mechanism for workers to pull updated config from the master at runtime.

TLS is optional and off by default. The setup wizard generates a self-signed certificate and enables TLS when openssl is available. When TLS is disabled, the Bearer token travels in plaintext over HTTP. Worker startup prints a warning when tls_enabled = false. See Deployment Hardening for enabling TLS.

Firewall rules are printed, not applied. xmrdp firewall prints the recommended iptables/ufw rules for review. Operators must apply them.

File permissions are hardened on non-Windows systems. Config directories, data directories, PID files, and config files are created with 0o700 (directories) or 0o600 (files) using os.open() with explicit mode bits on the initial open call, not a post-creation chmod. This prevents a race window between file creation and permission restriction. Windows does not enforce POSIX mode bits; see Documented Limitations.


3. Attack Surface

3.1 C2 Server Endpoints

The C2 server runs on the master node. By default it binds to master.host (default 127.0.0.1). Set master.bind_host = "0.0.0.0" to listen on all interfaces when workers are on a separate network segment.

All three endpoints require a valid Bearer token. Failed authentication increments a per-IP counter; 10 failures within 60 seconds triggers HTTP 429 for that IP. The counter is cleared on the next successful authentication from that IP.

POST /api/register

Workers call this once to register their name, platform, CPU count, and RAM. The C2 records the caller's IP as the registered_ip for that worker name.

  • Auth: Bearer token required.
  • Worker name validation: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ — returns HTTP 400 on mismatch (c2_server.py:254).
  • Body size cap: 64 KB (c2_server.py:187).
  • Residual risk: Re-registration overwrites the existing worker record, including the IP binding. Any token holder can reassign a worker name to a different IP. This produces a worker_registered audit event but not a distinct "overwrite" event — see Detection Gaps.

POST /api/status

Workers call this on every heartbeat (default interval defined in constants.HEARTBEAT_INTERVAL). If the worker name is not yet registered, the server auto-registers it and binds the caller's IP.

  • Auth: Bearer token required.
  • IP binding enforcement: If the worker name is already registered, the request IP must match registered_ip. Mismatches are rejected with HTTP 403 and logged as worker_ip_mismatch (c2_server.py:312–318).
  • Worker name validation: Same allowlist as /api/register (c2_server.py:294).
  • Body size cap: 64 KB.
  • Residual risk: Auto-registration on the heartbeat path creates a new IP binding for any previously unknown name. A token holder can silently insert a new worker record by sending a heartbeat for a name that has never been registered via /api/register.

GET /api/cluster/status

Returns the full cluster topology: all worker names, platforms, CPU counts, hashrates, uptime, IP addresses, and P2Pool stats read from the local data-api directory.

  • Auth: Bearer token required.
  • Residual risk: Any token holder receives full cluster topology. There is no read-only role; the same token that registers workers also reads all worker data including their IP addresses.

3.2 SSH Sync (xmrdp sync)

The sync command generates a per-worker config (the full cluster config with self = true set for the target worker) and copies it to each worker via SCP.

  • Authentication: Delegated entirely to the system SSH agent or default key files (~/.ssh/id_*). XMRDP does not handle, store, or prompt for SSH credentials.
  • Remote path quoting: The remote config directory path is passed through shlex.quote() before being included in the mkdir -p and chmod 600 remote commands (sync.py:118, 140).
  • SSH user validation: The --ssh-user argument is validated against ^[a-zA-Z0-9._-]+$ before use (sync.py:22, 78).
  • Remote permissions: After SCP, the sync command runs chmod 600 on the remote config file.
  • Residual risk: SSH host key verification relies on the system SSH client configuration. XMRDP does not enforce StrictHostKeyChecking. An operator connecting to a new host for the first time may be prompted to accept an unknown host key; this prompt is surfaced by the SSH client, not suppressed by XMRDP.

3.3 Binary Downloads

Binaries are downloaded from GitHub release assets over HTTPS using urllib.request.

  • Size cap: _MAX_DOWNLOAD_SIZE = 2 GB enforced by both a Content-Length pre-check and a streaming byte counter during download (binary_manager.py:37, 201–229).
  • Checksum verification: SHA-256 checksums are downloaded from the same GitHub release and verified before extraction. When verify_checksums = true (default), a missing checksum hard-fails with RuntimeError (binary_manager.py:518–531).
  • Archive extraction: Zip Slip protection via relative_to() member path validation on Python < 3.12; filter="data" on Python 3.12+ (binary_manager.py:355–391).
  • GitHub auth: The GITHUB_TOKEN environment variable is read by _github_headers() and included as a Bearer token on GitHub API requests if set. It is never stored to disk (binary_manager.py:40–46).
  • Residual risk: The SHA-256 checksum file is downloaded over HTTPS but is not GPG-verified against upstream signing keys. See F-08 GPG.

3.4 Local Process Management

monerod, p2pool, and xmrig are launched as subprocesses using subprocess.Popen with an explicit argument list (no shell interpolation). PID files are written to the data directory with mode 0o600.

  • extra_args injection prevention: User-supplied extra_args from the config are validated against ^--[a-zA-Z0-9][a-zA-Z0-9\-_.:/=,]*$ at both config load time (config.py:22, 27–33) and arg-build time (config_generator.py:13, 16–27).
  • Wallet address in process list: xmrig receives its wallet address via a JSON config file, not on the command line. p2pool requires --wallet on the CLI; p2pool's design cannot avoid this. The wallet address is a public receive address, not a private key.
  • Residual risk (F-16): stop_service() reads the PID from the PID file and signals that PID without verifying the process name (node_manager.py:189–233). On a single-operator deployment this is low risk. On a multi-user system a PID could theoretically be reused by a different process between the time XMRDP wrote the PID file and the time it reads it back.

4. Audit Logging

4.1 Event Reference

The C2 server emits structured audit events through the xmrdp.audit logger. All events are emitted via _audit() in c2_server.py:114–118. Each log line has the format:

AUDIT event=<name> ip=<caller_ip> [field=value ...]
Event When emitted Additional fields
rate_limit IP has exceeded 10 auth failures in the last 60 seconds failures=<count>
auth_failure Bearer header absent or token wrong reason='missing_header' or reason='bad_token'
auth_success Token validation passed
unknown_route Authenticated request to a path that does not exist path=<request_path>
worker_registered Successful POST /api/register name=<worker>, platform=<str>, cpus=<int>
worker_ip_mismatch POST /api/status from IP other than registered IP name=<worker>, expected=<ip>, actual=<ip>
heartbeat Every accepted POST /api/status name=<worker>
cluster_status_poll Every accepted GET /api/cluster/status

4.2 Detection Gaps

The following conditions are not currently covered by distinct audit events:

  • Worker name overwrite: Re-registration of an existing worker name fires worker_registered but does not indicate that an existing IP binding was replaced. To detect this, correlate consecutive worker_registered events for the same name field.
  • Auto-registration via heartbeat: A heartbeat for an unknown worker name creates a new registration silently. The heartbeat event fires but there is no prior worker_registered event to correlate against.
  • TLS fallback to plaintext: When tls_enabled = true but the cert file is missing, the server starts without TLS. This condition is logged to xmrdp.c2, not xmrdp.audit.
  • Worker eviction: Workers unseen for more than 24 hours are lazily evicted during /api/cluster/status processing. Evictions are logged to xmrdp.c2, not xmrdp.audit.

4.3 Configuring Audit Log Output

The xmrdp.audit logger uses the standard Python logging hierarchy. Configure it before calling xmrdp start master. To write audit events to a dedicated file:

import logging

audit_handler = logging.FileHandler("/var/log/xmrdp/audit.log")
audit_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
logging.getLogger("xmrdp.audit").addHandler(audit_handler)
logging.getLogger("xmrdp.audit").setLevel(logging.WARNING)

To capture audit events at the shell level when running XMRDP directly:

xmrdp start master 2>&1 | grep "AUDIT " >> /var/log/xmrdp/audit.log

The xmrdp.audit logger emits at WARNING level so that audit events are captured by any handler configured at WARNING or below, including the root logger's default handler.


5. Open Findings

F-08 GPG — HIGH

Description: The SHA-256 checksum file distributed with each GitHub release is downloaded over HTTPS and used to verify binary integrity. The checksum file itself is not verified against the upstream project's GPG signing key. A compromised GitHub account or a MITM against the release download (despite HTTPS) could substitute both the binary and the checksum file simultaneously.

Affected code: binary_manager.py:505–533 (checksum download and verification flow).

Mitigation status: Deferred. GPG signing infrastructure varies significantly across the three upstream projects (Monero, P2Pool, XMRig). Implementing GPG verification requires bundling or locating the correct public keys for each project and handling projects that do not sign all releases. Until this is implemented, operators should manually verify downloaded binaries against upstream GPG signatures as described in the README.


F-16 PID TOCTOU — LOW

Description: stop_service() reads a PID from a PID file, checks whether that process exists, and then signals it. There is no verification that the process at that PID is actually the service XMRDP started. On a long-running system a PID can be reused by a different process between the time XMRDP wrote the PID file and the time it reads it back.

Affected code: node_manager.py:189–233.

Mitigation status: Accepted as low risk on single-operator, single-user deployments, which is the intended use case. Running XMRDP under a dedicated OS user (see Deployment Hardening) limits cross-process PID collision exposure on multi-user systems.


F-18 Stratum TLS — MEDIUM

Description: The xmrig config generated by XMRDP sets "tls": false on the stratum connection. This is required by design: P2Pool does not support TLS on its stratum port. The stratum connection from xmrig to p2pool is therefore always plaintext.

On the master node, the stratum target is 127.0.0.1:3333 (loopback only — no network exposure). On worker nodes, the stratum target is master_host:3333 over the LAN, where mining traffic travels in plaintext.

Affected code: config_generator.py (xmrig JSON config generation).

Mitigation status: Cannot be fixed without upstream changes to P2Pool. Network-level mitigation: restrict port 3333 to the mining LAN VLAN and consider a VPN or WireGuard tunnel between the master and workers if the LAN is not trusted.


6. Documented Limitations

p2pool Wallet in Process List

p2pool requires the wallet address to be passed as --wallet <address> on the command line. This means the wallet address is visible in ps aux output on the master node. This is a p2pool CLI limitation, not an XMRDP design choice. The wallet address is a public Monero receive address that grants no spending authority. xmrig receives its wallet address via a JSON config file (mode 0o600) and not via the command line.

Windows File Permissions

os.open() mode bits (0o600, 0o700) are not enforced by Windows. On Windows, all secure write paths fall back to Path.write_text() and chmod calls are skipped. Operators running XMRDP on Windows should manually verify that ACLs on %LOCALAPPDATA%\xmrdp restrict access to the current user. On Linux and macOS, mode bits are enforced atomically at file creation time.

TLS Disabled by Default

The example config (configs/cluster.example.toml) ships with tls_enabled = false. The setup wizard (xmrdp setup) enables TLS and generates a self-signed certificate when openssl is available. When TLS is disabled, the Bearer token and all C2 traffic travel in plaintext over HTTP. Worker startup prints a warning when this condition is detected.

TLS Certificate Strength

The setup wizard generates an RSA 2048-bit certificate with a 3650-day validity period using openssl req. RSA 2048 meets NIST SP 800-57 guidance through 2030. Operators who require stronger keys (for example RSA 4096 or ECDSA P-256) can generate their own certificate and configure the paths via c2_tls_cert and c2_tls_key in cluster.toml.

Chunked Transfer Encoding

The C2 server uses Content-Length to bound request body reads. Chunked transfer encoding is not handled. Requests without a valid Content-Length header are treated as having a zero-length body. This is not a concern for current XMRDP clients, which always send Content-Length.


7. Fixed Findings

The following findings have been resolved. This table is for contributor reference. Severity reflects the original finding classification.

ID Severity Description Fix location
F-01 (body size) MEDIUM No request body size cap c2_server.py:187_MAX_BODY = 65536
F-01 (rate limit) MEDIUM No auth failure rate limiting c2_server.py:41–53 — per-IP counter, 10 failures/60 s → HTTP 429
F-02 LOW Timing attack on token comparison c2_server.py:161hmac.compare_digest
F-04 MEDIUM Wallet address in xmrig CLI args xmrig config written to JSON file; wallet not on xmrig command line
F-05 CRITICAL extra_args subprocess injection config.py:22 and config_generator.py:13_SAFE_ARG_RE allowlist validated at load time and arg-build time
F-06 HIGH Zip Slip / unsafe archive extraction binary_manager.py:355–391relative_to() containment; filter="data" on Python 3.12+
F-07 MEDIUM No download size cap binary_manager.py:37, 201–229_MAX_DOWNLOAD_SIZE = 2 GB, Content-Length pre-check + streaming counter
F-08 (silent skip) HIGH Silent skip when checksum not found binary_manager.py:518–531 — hard-fail RuntimeError when verify_checksums = true and no checksum found
F-09 HIGH Path traversal on binary serve endpoint Endpoint removed entirely
F-10 MEDIUM C2 server bound to 0.0.0.0 by default c2_server.py:448 — default fallback is 127.0.0.1; bind_host/host split added
F-11 MEDIUM No worker identity binding c2_server.py:306–318registered_ip stored on registration; heartbeats from wrong IP → HTTP 403
F-12 MEDIUM No structured audit logging c2_server.py:28, 114–118xmrdp.audit logger with _audit() helper
F-13 MEDIUM Config files world-readable os.open() mode 0o600 atomic create on all write paths (non-Windows)
F-14 LOW GITHUB_TOKEN dead code binary_manager.py:40–46_github_headers() reads env var
F-15 LOW monerod ZMQ bound to 0.0.0.0 config_generator.py:42 — changed to tcp://127.0.0.1
F-17 LOW API token substitution via fragile string replacement secrets.token_hex(32) produces hex-only output (no TOML metacharacters); substitution is safe by construction
NF-01 MEDIUM TOML injection via user-supplied values config.py:19, 178–180_toml_str() escaping on all user-supplied values
NF-03 MEDIUM SSRF via crafted host values config.py:11–17, 88–104_HOST_RE allowlist on master.host, master.bind_host, and all worker hosts
NF-04 LOW PID files world-readable node_manager.py:88–103os.open() mode 0o600
NF-05 LOW Data/config directories world-readable platforms.pychmod(0o700) after mkdir on all five directory functions
NF-NEW-01 LOW Worker name regex defined but never enforced c2_server.py:254, 294_WORKER_NAME_RE.match() in both handlers; HTTP 400 on invalid name
NF-NEW-02 LOW SSH remote commands without shell quoting sync.py:118, 140shlex.quote() applied to remote path args
NF-NEW-03 LOW --ssh-user not validated sync.py:22, 78_SSH_USER_RE allowlist validates input before use

8. Deployment Hardening

Enable TLS on the C2 Server

Run xmrdp setup on the master node. When openssl is available the wizard generates a self-signed certificate and writes the paths to cluster.toml automatically.

To use your own certificate:

# cluster.toml
[security]
tls_enabled   = true
c2_tls_cert   = "/etc/xmrdp/tls/server.crt"
c2_tls_key    = "/etc/xmrdp/tls/server.key"

The server requires TLS 1.2 or higher (ssl.TLSVersion.TLSv1_2 minimum, c2_server.py:460). After changing TLS config, re-run xmrdp sync to push the updated cluster.toml to all workers.

Set Firewall Rules

Run xmrdp firewall to print the recommended rules for your platform, then apply them. Example using ufw:

# Restrict C2 API to the mining LAN only
ufw allow from 192.168.1.0/24 to any port 7099 proto tcp
ufw deny 7099

# Restrict monerod RPC to localhost and p2pool (same host)
ufw allow from 127.0.0.1 to any port 18081 proto tcp
ufw deny 18081

# Restrict p2pool stratum to the mining LAN (plaintext — see F-18)
ufw allow from 192.168.1.0/24 to any port 3333 proto tcp
ufw deny 3333

Adjust the LAN subnet to match your deployment. Canonical port numbers are defined in xmrdp/constants.py.

Run Under a Dedicated OS User

XMRDP does not require root. Running under a dedicated user limits blast radius from F-16 (PID TOCTOU) and from any future vulnerability in the mining binaries themselves:

useradd --system --create-home --shell /usr/sbin/nologin xmrdp
su -s /bin/bash xmrdp -c "xmrdp start master"

Configure Audit Log Output

Route xmrdp.audit to a separate file with rotation. Example logging.config dict:

{
    "version": 1,
    "handlers": {
        "audit_file": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "/var/log/xmrdp/audit.log",
            "maxBytes": 10485760,
            "backupCount": 10,
            "formatter": "audit_fmt"
        }
    },
    "formatters": {
        "audit_fmt": {"format": "%(asctime)s %(message)s"}
    },
    "loggers": {
        "xmrdp.audit": {
            "handlers": ["audit_file"],
            "level": "WARNING",
            "propagate": false
        }
    }
}

High-signal events to monitor:

  • worker_ip_mismatch — a heartbeat arrived from an IP that does not match the registered binding. Investigate before treating as normal.
  • rate_limit — an IP has sent 10 failed auth attempts within 60 seconds. Could indicate a scanning tool or a worker node with a stale token.
  • Consecutive worker_registered events for the same name in a short window — possible worker name overwrite (IP binding replacement).

Manually Verify Binaries with GPG

Until F-08 GPG is resolved, manually verify downloaded binaries against upstream GPG signatures after xmrdp download. See the README for per-project verification commands and public key sources.

Keep bind_host Off the Public Internet

The C2 API is designed for LAN use. Do not set bind_host = "0.0.0.0" on a node with a public IP unless the C2 port is explicitly blocked at the firewall. The default bind_host resolves to master.host, which defaults to 127.0.0.1.

# Explicit LAN-only binding (recommended for multi-homed masters)
[master]
host      = "192.168.1.10"   # address workers use to reach master
bind_host = "192.168.1.10"   # address C2 server listens on

There aren't any published security advisories