One if statement existed on Windows, macOS and Linux. It was missing on Android.

That is the whole bug. It let a remote peer keep typing and clicking on an Android
device after its keyboard permission had been explicitly revoked. I found it in
RustDesk 1.5.3, reproduced it on an API 35 emulator, and the upstream fix is now
public as rustdesk/rustdesk#17061.

Cover: RustDesk Android input bypass

First, what "keyboard permission" actually means

This is the part that trips people up, so let's be precise. It is not about
whether the Android device has a keyboard. Gboard still works for the person
holding the phone.

RustDesk sessions carry per-capability flags. The relevant one is:

// src/server/connection.rs:2344-2346 fn peer_keyboard_enabled(&self) -> bool { self.keyboard && !self.disable_keyboard } 

self.keyboard is set to false for limited sessions (file transfer, terminal,
view-camera). self.disable_keyboard comes from a server-pushed policy option
(option.disable_keyboard = Yes). In both cases the peer is still connected and
still authorized
— it has simply been told, via
send_permission(Permission::Keyboard, false), that it may not send input.

So the peer is not "disconnected". It is connected, but the receiving side is
supposed to drop its KeyEvent, MouseEvent and PointerDeviceEvent messages.
On desktop, it does.

The asymmetry

Here is the desktop MouseEvent arm:

// src/server/connection.rs:3205-3206 #[cfg(not(any(target_os = "android", target_os = "ios")))] if self.peer_keyboard_enabled() { self.input_mouse(...); } 

And here is the Android arm of the same match:

// src/server/connection.rs:3201-3204 #[cfg(any(target_os = "android", target_os = "ios"))] if let Err(e) = call_main_service_pointer_input("mouse", me.mask, me.x, me.y) { log::debug!("call_main_service_pointer_input fail:{}", e); } 

No guard. The same pattern repeats for PointerDeviceEvent (3240-3267) and
KeyEvent (3318-3330). All three check is_authed_view_camera_conn(), which
covers one narrow case, but none of them consult peer_keyboard_enabled().

Downstream, the call chain is the real, unmodified RustDesk input path:

connection.rs (Android arm, no guard) -> libs/scrap/src/android/ffi.rs (JNI: "mouse" -> kind 1, "touch" -> kind 0) -> MainService.kt rustPointerInput(IIII)V / rustKeyEventInput([B)V -> InputService.kt onMouseInput / onTouchInput / onKeyEvent -> dispatchGesture(...) / InputConnection.commitText(...) / ACTION_SET_TEXT 

Which means a peer that was told "no input" can still:

  • type arbitrary text into focused fields (passwords, 2FA codes, chats),
  • trigger taps and swipes via dispatchGesture(),
  • send physical key events.

Reproducing it

I did not need a physical keyboard on the target, nor a live RustDesk server. The
bug lives at the dispatch layer, below the transport, so I injected real
hbb.MessageOuterClass protobuf messages at exactly that point:

PoCConnection.keyEventMessage("PWNED-BY-REMOTE-KEYBOARD") // real KeyEvent{seq=...} conn.onMessage(msg, Path.ANDROID) // -> the three Android arms above 

The receiving app runs the genuine InputService as a bound
AccessibilityService (capabilities=33, including CAN_PERFORM_GESTURES, and
getInputMethod() non-null). onKeyEvent() parses the message and calls
commitText(), which inserts the whole string into a focused EditText in one
atomic operation — the same API an IME uses. No keystrokes required.

With keyboard=false and disable_keyboard=true:

STATE: keyboard=false disable_keyboard=true -> peer_keyboard_enabled()=false SINK: InputConnection.commitText("PWNED-BY-REMOTE-KEYBOARD") -> REAL Android text injection SINK: dispatchGesture(x=160,y=219,willContinue=true) called -> REAL Android input injection VICTIM UI: onClick fired -> tapCount=1 VICTIM UI: received ACTION_DOWN -> gestureCount=1 HANDLER[Desktop] KeyEvent: peer_keyboard_enabled()==false -> BLOCKED victim text = "PWNED-BY-REMOTE-KEYBOARD" | button clicks = 1 | gesture events = 1 

Same three messages through the desktop control arms: all blocked. That A/B
comparison is the actual finding — identical input, different guard.

Emulator screenshot: permission banner, injected text in a real EditText, counters and the live runtime log

The red banner reads peer_keyboard_enabled() = FALSE. The blue-bordered field
is a real EditText (empty at the start of the run). The grey panel is the live
runtime log, not a static caption.

Scope note, because it matters: this harness builds the protobuf messages
locally and hands them to on_message directly. It does not establish a
Rendezvous/TCP session. It proves the authorization decision at the dispatch
layer — which is exactly where the missing if sits — using the real message
format and the real input sinks. A full end-to-end run would need hbbs/hbbr
(or direct IP) plus a controller client.

The fix

PR #17061, opened 2026-11-14,
adds 13 lines to those three Android arms:

#[cfg(target_os = "android")] if !self.peer_keyboard_enabled() { self.update_auto_disconnect_timer(); return true; } 

The same guard goes into the KeyEvent arm too (without the cfg line, since
that arm is already Android-only), placed before modifier tracking. The
auto-disconnect timer update is kept so idle-timeout behaviour does not change.

The PR also notes a deliberate behaviour change: a peer without input permission
should no longer be able to wake a sleeping screen via rustPointerInput. The
desktop path, iOS, permission configuration and the Kotlin sinks are untouched.

A neighbouring PR #17062
hardens the same area from the other side: it stops SwitchPermission broadcasts
from re-enabling keyboard/clipboard on file-transfer, view-camera and
port-forward connections. Worth reading together.

Not the same as the earlier CVEs

Two 2026 advisories look similar at first glance:

  • CVE-2026-57850 / GHSA-83jc-7j6x-wvj9 — missing session-type scope enforcement before 1.4.9, fixed by #15469 / 493b14ba7.
  • CVE-2026-58056 / GHSA-vp3r-hwqm-x826 — per-capability flags not reset after a FileTransfer session.

Both are about which flags get set and when. This one is different: in 1.5.3 the
flags are set correctly and the peer is correctly told "no", but a
platform-gated branch simply never reads them. It is a residual gap that survived
the 1.4.9 fix — which is a good reminder that a security fix in shared code does
not automatically cover #[cfg(target_os = ...)] duplicates.

What I took away

  1. cfg-gated duplicates are a blind spot. The same match arm existed twice. One got the guard, the other did not. Grep for the guard, not for the function.
  2. "Permission denied" is a runtime state, not a disconnect. The peer keeps sending; enforcement has to happen at every entry point.
  3. A desktop test proves nothing about Android. An emulator reproduction with the real AccessibilityService sink is cheap and settles the question.
  4. Simulating the transport is fine — if you say so. Injecting the wire message at the dispatch layer isolates the bug precisely; just be explicit about what the PoC does and does not cover.

Disclosure status

RustDesk documents private reporting at info@rustdesk.com (docs/SECURITY.md);
the repository has no published security advisory. The fix PR is public but was
still open at the time of writing, so it is not yet in a release. If you are
writing about this yourself, coordinate the timing with the maintainers and avoid
publishing a turnkey exploit before a patched build ships.

Tested on RustDesk 1.5.3 (checkout 3ccb4c4b), Android Emulator 37.2.13 with an
API 35 google_apis/x86_64 AVD. CWE-862, High.