From 978c901f49565abb188a0526929c429869b023cd Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:39:09 +0800 Subject: [PATCH] port forward: a login's target and challenge travel with its own accept (#16069) * port forward: mappings take turns at the window's login slots A window's mappings log in concurrently, and each login is built from the shared `LoginConfigHandler`: `create_login_msg` reads `port_forward`, which `listen()` set before connecting, and `handle_login_from_ui` reads `hash`, which the last `Hash` to arrive set. Two mappings logging in at once could swap targets, bridging a local socket to the other's target, and answer each other's challenge, failing one login. The window's password prompt is broadcast to every mapping, so one whose `Hash` had not arrived answered with whatever the handler held. Each mapping now fills `port_forward` and `hash` and sends its login under a per-window turn lock, and keeps its own `Hash` beside the connection: a password typed before it arrived is left to the mapping that prompted, which stores the salted password in the shared handler for the others to log in with. The fix stays in `port_forward.rs`. `LoginConfigHandler` gains the lock and a setter for its private `hash`; `Interface`, `Session` and the login functions keep their signatures. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab * port forward: the lock and the hash setter are crate-private; test the hash that arrives late on its real path Both exist only so `port_forward.rs` can reach the handler's private `hash`; neither is API. The test for a password typed before a connection's hash ended by answering the prompt again once the hash was there. What happens in the code is that the hash's arrival runs `handle_hash`, which logs in with the password the prompting mapping stored; the test now ends there, with no preset password. Answering the prompt with one's own challenge while the handler holds another's is a test of its own. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab * port forward: a password typed before the connection's hash answers it when it comes The previous commit dropped such a password, counting on the mapping that prompted having stored it in the shared handler by the time this connection's `Hash` arrived. The broadcast wakes both mappings at once and `select!` picks between a ready `Hash` and a ready password at random, so this one could reach `handle_hash` first, find the handler empty, and prompt again. The connection keeps the password until its `Hash` arrives and answers with it then. `login_from_ui` takes the challenge it answers; the wait is `connect_and_login`'s, in `hash_arrived`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab --------- Co-authored-by: Claude Fable 5.1 --- src/client.rs | 8 ++ src/port_forward.rs | 256 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 258 insertions(+), 6 deletions(-) diff --git a/src/client.rs b/src/client.rs index 73cf466eb..5b059a7cc 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1753,6 +1753,10 @@ pub struct LoginConfigHandler { pub remember: bool, config: PeerConfig, pub port_forward: (String, i32), + /// Held by a port-forward mapping from filling `port_forward` and `hash` + /// until its login is built from them; a window's mappings log in + /// concurrently. + pub(crate) port_forward_login_turn: Arc>, pub version: i64, features: Option, pub session_id: u64, // used for local <-> server communication @@ -1792,6 +1796,10 @@ impl Deref for LoginConfigHandler { } impl LoginConfigHandler { + pub(crate) fn set_hash(&mut self, hash: Hash) { + self.hash = hash; + } + /// Initialize the login config handler. /// /// # Arguments diff --git a/src/port_forward.rs b/src/port_forward.rs index 392ed3c67..0cc640663 100644 --- a/src/port_forward.rs +++ b/src/port_forward.rs @@ -92,12 +92,11 @@ pub async fn listen( tokio::select! { Ok((forward, addr)) = listener.accept() => { log::info!("new connection from {:?}", addr); - lc.write().unwrap().port_forward = (remote_host.clone(), remote_port); let id = id.clone(); let password = password.clone(); let mut forward = Framed::new(forward, BytesCodec::new()); let mut close_port_forward = false; - match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward).await { + match connect_and_login(&id, &password, &mut ui_receiver, interface.clone(), &mut forward, key, token, is_rdp, &mut close_port_forward, &remote_host, remote_port).await { Ok(Some(stream)) => { let interface = interface.clone(); tokio::spawn(async move { @@ -143,6 +142,8 @@ async fn connect_and_login( token: &str, is_rdp: bool, close_port_forward: &mut bool, + remote_host: &str, + remote_port: i32, ) -> ResultType> { let conn_type = if is_rdp { ConnType::RDP @@ -160,6 +161,8 @@ async fn connect_and_login( } let mut buffer = Vec::new(); let mut received = false; + let mut challenge = None; + let mut pending_login = None; let _keep_it = hc_connection(feedback, rendezvous_server, token).await; @@ -177,7 +180,8 @@ async fn connect_and_login( let msg_in = Message::parse_from_bytes(&bytes)?; match msg_in.union { Some(message::Union::Hash(hash)) => { - if !interface.handle_hash(password, hash, &mut stream).await { + challenge = Some(hash.clone()); + if !hash_arrived(&interface, password, hash, pending_login.take(), remote_host, remote_port, &mut stream).await { return Ok(None); } } @@ -208,9 +212,10 @@ async fn connect_and_login( }, d = ui_receiver.recv() => { match d { - Some(Data::Login((os_username, os_password, password, remember))) => { - interface.handle_login_from_ui(os_username, os_password, password, remember, &mut stream).await; - } + Some(Data::Login(login)) => match &challenge { + Some(hash) => login_from_ui(&interface, hash, login, remote_host, remote_port, &mut stream).await, + None => pending_login = Some(login), + }, Some(Data::Message(msg)) => { allow_err!(stream.send(&msg).await); } @@ -233,6 +238,76 @@ async fn connect_and_login( Ok(Some(stream)) } + +/// A mapping's login is built from the window's shared handler: +/// `create_login_msg` reads `port_forward` and `handle_login_from_ui` reads +/// `hash`. Mappings log in concurrently, so each fills them and sends under +/// the window's turn lock, or one login carried another mapping's target or +/// answered another's challenge. +async fn login_with_hash( + interface: &impl Interface, + password: &str, + hash: Hash, + remote_host: &str, + remote_port: i32, + stream: &mut Stream, +) -> bool { + let lc = interface.get_lch(); + let turn = lc.read().unwrap().port_forward_login_turn.clone(); + let _turn = turn.lock().await; + lc.write().unwrap().port_forward = (remote_host.to_owned(), remote_port); + interface.handle_hash(password, hash, stream).await +} + +type UiLogin = (String, String, String, bool); + +/// This connection's `Hash`. The window's password prompt is broadcast to +/// every mapping and can reach this one first, so a password typed while +/// the `Hash` was on its way is kept and answers it now, rather than being +/// dropped in the hope that the mapping which prompted has already stored +/// it in the shared handler. +async fn hash_arrived( + interface: &impl Interface, + password: &str, + hash: Hash, + pending_login: Option, + remote_host: &str, + remote_port: i32, + stream: &mut Stream, +) -> bool { + match pending_login { + Some(login) => { + login_from_ui(interface, &hash, login, remote_host, remote_port, stream).await; + true + } + None => login_with_hash(interface, password, hash, remote_host, remote_port, stream).await, + } +} + +/// The window's password prompt is broadcast to every mapping; this one +/// answers it with its own challenge. +async fn login_from_ui( + interface: &impl Interface, + hash: &Hash, + login: UiLogin, + remote_host: &str, + remote_port: i32, + stream: &mut Stream, +) { + let lc = interface.get_lch(); + let turn = lc.read().unwrap().port_forward_login_turn.clone(); + let _turn = turn.lock().await; + { + let mut lc = lc.write().unwrap(); + lc.port_forward = (remote_host.to_owned(), remote_port); + lc.set_hash(hash.clone()); + } + let (os_username, os_password, password, remember) = login; + interface + .handle_login_from_ui(os_username, os_password, password, remember, stream) + .await; +} + async fn run_forward(forward: Framed, stream: Stream) -> ResultType<()> { log::info!("new port forwarding connection started"); let mut forward = forward; @@ -257,3 +332,172 @@ async fn run_forward(forward: Framed, stream: Stream) -> } Ok(()) } + +#[cfg(test)] +mod login_tests { + use super::*; + use async_trait::async_trait; + use hbb_common::{ + tcp::FramedStream, + tokio::time::{sleep, Duration}, + }; + use sha2::{Digest, Sha256}; + + /// A window's interface over its shared handler. `handle_hash` can pause + /// before building the login, where the real one looks passwords up. + #[derive(Clone)] + struct Ui { + lc: Arc>, + pause: Duration, + } + + #[async_trait] + impl Interface for Ui { + fn send(&self, _data: Data) {} + fn msgbox(&self, _msgtype: &str, _title: &str, _text: &str, _link: &str) {} + fn handle_login_error(&self, _err: &str) -> bool { + false + } + fn handle_peer_info(&self, _pi: PeerInfo) {} + fn set_multiple_windows_session(&self, _sessions: Vec) {} + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) -> bool { + sleep(self.pause).await; + crate::client::handle_hash(self.lc.clone(), pass, hash, self, peer).await + } + async fn handle_login_from_ui( + &self, + os_username: String, + os_password: String, + password: String, + remember: bool, + peer: &mut Stream, + ) { + crate::client::handle_login_from_ui( + self.lc.clone(), + os_username, + os_password, + password, + remember, + peer, + ) + .await + } + async fn handle_test_delay(&self, _t: TestDelay, _peer: &mut Stream) {} + fn get_lch(&self) -> Arc> { + self.lc.clone() + } + } + + fn window() -> Ui { + let mut lc = LoginConfigHandler::default(); + lc.conn_type = ConnType::PORT_FORWARD; + Ui { + lc: Arc::new(RwLock::new(lc)), + pause: Duration::ZERO, + } + } + + /// (our end, the peer's end) of one connection. + async fn loopback() -> (Stream, Stream) { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let client = tokio::net::TcpStream::connect(addr).await.unwrap(); + let (server, _) = l.accept().await.unwrap(); + ( + Stream::Tcp(FramedStream::from(client, addr)), + Stream::Tcp(FramedStream::from(server, addr)), + ) + } + + async fn login_at(peer: &mut Stream) -> LoginRequest { + let bytes = peer.next().await.unwrap().unwrap(); + Message::parse_from_bytes(&bytes) + .unwrap() + .login_request() + .clone() + } + + fn target(lr: &LoginRequest) -> (String, i32) { + (lr.port_forward().host.clone(), lr.port_forward().port) + } + + fn hash(challenge: &str) -> Hash { + Hash { + salt: "salt".to_owned(), + challenge: challenge.to_owned(), + ..Default::default() + } + } + + /// What the peer expects for password `pw` under `hash(challenge)`. + fn digest(challenge: &str) -> Vec { + let mut h = Sha256::new(); + h.update("pw"); + h.update("salt"); + let salted = h.finalize(); + let mut h2 = Sha256::new(); + h2.update(&salted[..]); + h2.update(challenge); + h2.finalize()[..].to_vec() + } + + #[test] + fn mappings_logging_in_at_once_each_carry_their_own_target() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let mut ui = window(); + ui.pause = Duration::from_millis(50); + let (mut a, mut a_peer) = loopback().await; + let (mut b, mut b_peer) = loopback().await; + tokio::join!( + login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a), + login_with_hash(&ui, "pw", hash("b"), "b", 2, &mut b), + ); + assert_eq!(target(&login_at(&mut a_peer).await), ("a".to_owned(), 1)); + assert_eq!(target(&login_at(&mut b_peer).await), ("b".to_owned(), 2)); + }); + } + + #[test] + fn a_mapping_answers_the_prompt_with_its_own_challenge() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let ui = window(); + let (mut a, mut a_peer) = loopback().await; + let (mut b, mut b_peer) = loopback().await; + // A's hash arrived last, so it is the one the handler holds. + assert!(login_with_hash(&ui, "pw", hash("a"), "a", 1, &mut a).await); + login_at(&mut a_peer).await; + let typed = (String::new(), String::new(), "pw".to_owned(), false); + login_from_ui(&ui, &hash("b"), typed, "b", 2, &mut b).await; + let lr = login_at(&mut b_peer).await; + assert_eq!(lr.password, digest("b")); + assert_eq!(target(&lr), ("b".to_owned(), 2)); + }); + } + + #[test] + fn a_password_typed_before_this_connections_hash_answers_it_when_it_comes() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let ui = window(); + let (mut b, mut b_peer) = loopback().await; + // The prompt's password reached B before its hash, and no other + // mapping has stored it in the handler yet. + let typed = (String::new(), String::new(), "pw".to_owned(), false); + assert!(hash_arrived(&ui, "", hash("b"), Some(typed), "b", 2, &mut b).await); + let lr = login_at(&mut b_peer).await; + assert_eq!(lr.password, digest("b")); + assert_eq!(target(&lr), ("b".to_owned(), 2)); + }); + } +}