Background
RustDesk is an open-source remote desktop tool written in Rust. It is basically the self-hosted alternative to TeamViewer or AnyDesk, and it has gotten pretty popular because you can run your own relay and rendezvous server. That self-hosted server model is actually the interesting part here, because this finding targets the server software, not just the client.
I was doing a code review of the RustDesk codebase as part of a security research exercise when I noticed something odd in the way the server handles port-forward login requests. The short version: the server makes an outbound TCP connection to an attacker-supplied host and port before it ever checks whether the connecting client knows the password. That is a textbook SSRF.
This was fixed in PR #14448, which is part of the 1.4.6 release. If you are running a self-hosted RustDesk server, update now.
The Vulnerability
Title: Unauthenticated SSRF via PortForward, Internal Network Port Scanning
Component: rustdesk server binary (hbbs), src/server/connection.rs
Affected versions: 1.2.0 through 1.4.5 (all releases since port-forward was added)
CVSS 3.0: 5.8 Medium, CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N
How the connection flow works
When a RustDesk client connects to the direct TCP listener on port 21118 and sends a LoginRequest, the server dispatches on the type of the embedded union field first, then validates the password afterward. For most union types this order does not matter much, but the PortForward branch is different: it immediately calls TcpStream::connect() to the target address that the client specified.
Simplified flow from src/server/connection.rs:
receive LoginRequest
|
+-- handle_login_request_without_validation() // stores fields, no checks
|
+-- match lr.union
|
+-- PortForward branch
|
+-- TcpStream::connect(&addr) // <-- SSRF here (line ~2192)
| target host:port supplied by the attacker
|
+-- on connect failure: send error, close connection
+-- on connect success: store socket, fall through
|
+-- validate_password() // too late
The password check only happens after the TCP connect. By then the outbound probe has already happened.
The oracle
What makes this useful as a port scanner is that the server’s response is deterministic:
| Target port state | Server response |
|---|---|
| Closed / filtered | "Failed to access remote <host>:<port>, please make sure if it is open", connection closed |
| Open | Password challenge or LOGIN_MSG_NO_PASSWORD_ACCESS, connection kept open |
Those two responses are easy to tell apart programmatically, which means you can automate a full port sweep against any host the RustDesk server can reach, including internal hosts that are not exposed to the internet.
Attack prerequisites
- Network access to port 21118 on the RustDesk server (the direct-connect listener)
- Nothing else. No account, no password, no user on the server side needs to do anything.
The direct-connect listener on 21118 uses secure=false, so the attacker’s LoginRequest goes out in plain protobuf with no key-exchange overhead. To avoid a username format check that runs after the TCP connect, the LoginRequest.username field just needs to look like a valid IP string (e.g., 0.0.0.0).
Proof of Concept
I wrote a self-contained Python 3 PoC that reimplements just enough of the RustDesk wire protocol (the BytesCodec length-prefixed framing plus hand-serialized protobuf) to send the crafted request and read back the response. No third-party dependencies, no RustDesk client needed.
python3 poc_rustdesk_ssrf_portscan.py \
--target <rustdesk-server-ip> \
--scan-host 127.0.0.1 \
--scan-ports 22,80,443,3306,5432,6379
python3 poc_rustdesk_ssrf_portscan.py \
--target <rustdesk-server-ip> \
--scan-host 169.254.169.254 \
--scan-ports 80
The second example scans the AWS metadata endpoint from the RustDesk server’s perspective, which is a common target in cloud environments where the server is running on an EC2 instance.
PoC: https://gist.github.com/mandreko/ec19bfabf2b6b8b45bac01938b5284f3

Terminal showing PoC output with open/closed port discrimination against a test server, alongside an nmap scan of the same target for comparison
Why This Matters in Practice
A lot of people deploy RustDesk on a VPS or inside a corporate network to give their team remote access. In both scenarios, the server usually has access to internal hosts that the attacker cannot reach directly.
A few concrete abuse scenarios:
- Cloud metadata endpoints. If the server is on AWS, GCP, or Azure, the metadata service (169.254.169.254) is reachable from the server but not from the internet. An attacker can use the SSRF to probe it.
- Internal network mapping. If the server lives inside a corporate network, the attacker can map out which internal IPs have specific services open (databases, admin panels, printers, anything with a TCP port).
- Pivot point for further attacks. Once you know what is open internally, you can target those services with other techniques that do not involve RustDesk at all.
The CVSS score of 5.8 reflects the fact that the attacker only learns port state, not actual data from the probed services. But in a targeted engagement, an internal network map is often enough to plan the next step.
The Fix
PR #14448 moves the TcpStream::connect() call to after password validation completes. The PR extracted the connection logic into two helper methods (normalize_port_forward_target() and connect_port_forward_if_needed()) and wired them into the post-auth path. No protocol changes, just moving when the outbound connection happens.
One thing worth noting from the PR review: a Copilot comment flagged that the current fix may still permit the TCP connect before 2FA completes if the server has 2FA enabled. Worth keeping an eye on if you rely on two-factor for your deployment.
Timeline
| Date | Event |
|---|---|
| 2026-03-02 | Vulnerability discovered, PoC developed |
| 2026-03-02 | Reported to RustDesk security team |
| 2026-03-02 | Fix landed in PR #14448 |
| 2026-03-06 | Release 1.4.6 landed |
Takeaways
This is a pretty classic SSRF pattern: an action that should require authentication gets executed during the dispatch phase before the auth check runs. The fix is simple once you spot it, you just need to reorder the operations. The tricky part with code like this is that the dispatch and the auth check are separated by a lot of logic, so it is easy to miss.
If you are auditing other remote-desktop tools or any server that accepts structured login requests with different “modes” or “types”, this is a good pattern to look for: find all the places where network or filesystem operations happen inside the union/variant dispatch, then check whether auth has actually been verified at that point.
All testing was performed on a local lab environment. The vulnerability was responsibly disclosed to the RustDesk team before publication.