Last reviewed: 2026-03-16
- Overview
- Security Model
- Attack Surface
- Audit Logging
- Open Findings
- Documented Limitations
- Fixed Findings
- Deployment Hardening
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.
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.
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.
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_registeredaudit event but not a distinct "overwrite" event — see Detection Gaps.
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 asworker_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.
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.
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 themkdir -pandchmod 600remote commands (sync.py:118, 140). - SSH user validation: The
--ssh-userargument is validated against^[a-zA-Z0-9._-]+$before use (sync.py:22, 78). - Remote permissions: After SCP, the sync command runs
chmod 600on 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.
Binaries are downloaded from GitHub release assets over HTTPS using urllib.request.
- Size cap:
_MAX_DOWNLOAD_SIZE = 2 GBenforced by both aContent-Lengthpre-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 withRuntimeError(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_TOKENenvironment 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.
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_argsfrom 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
--walleton 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.
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 | — |
The following conditions are not currently covered by distinct audit events:
- Worker name overwrite: Re-registration of an existing worker name fires
worker_registeredbut does not indicate that an existing IP binding was replaced. To detect this, correlate consecutiveworker_registeredevents for the samenamefield. - Auto-registration via heartbeat: A heartbeat for an unknown worker name creates a new registration silently. The
heartbeatevent fires but there is no priorworker_registeredevent to correlate against. - TLS fallback to plaintext: When
tls_enabled = truebut the cert file is missing, the server starts without TLS. This condition is logged toxmrdp.c2, notxmrdp.audit. - Worker eviction: Workers unseen for more than 24 hours are lazily evicted during
/api/cluster/statusprocessing. Evictions are logged toxmrdp.c2, notxmrdp.audit.
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.logThe 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.
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.
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.
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.
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.
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.
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.
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.
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.
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:161 — hmac.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–391 — relative_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–318 — registered_ip stored on registration; heartbeats from wrong IP → HTTP 403 |
| F-12 | MEDIUM | No structured audit logging | c2_server.py:28, 114–118 — xmrdp.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–103 — os.open() mode 0o600 |
| NF-05 | LOW | Data/config directories world-readable | platforms.py — chmod(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, 140 — shlex.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 |
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.
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 3333Adjust the LAN subnet to match your deployment. Canonical port numbers are defined in xmrdp/constants.py.
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"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_registeredevents for the samenamein a short window — possible worker name overwrite (IP binding replacement).
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.
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