Skip to content

Commit d1f7e39

Browse files
fix(dgw): harden VMConnect RDCleanPath front sequence
Extract PCB encode/response helpers with unit tests, reject credential injection for the pre-X.224 path with a 400 RDCleanPath error, bound PCB writes with the MS-RDPEPS 10s deadline plus flush, and fix the upstream comment for the dual ordinary/VMConnect ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 06dd38d commit d1f7e39

2 files changed

Lines changed: 177 additions & 36 deletions

File tree

devolutions-gateway/src/rd_clean_path.rs

Lines changed: 174 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::io::{self, ErrorKind};
22
use std::net::SocketAddr;
33
use std::sync::Arc;
4+
use std::time::Duration;
45

56
use anyhow::Context as _;
67
use ironrdp_pdu::nego;
@@ -11,6 +12,9 @@ use thiserror::Error;
1112
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
1213
use tracing::field;
1314

15+
/// MS-RDPEPS upper bound for transmitting the complete Preconnection Blob after TCP connect.
16+
const PCB_TRANSMIT_DEADLINE: Duration = Duration::from_secs(10);
17+
1418
use crate::config::Conf;
1519
use crate::credential_injection_kdc::{CredentialInjectionKdc, CredentialService};
1620
use crate::proxy::Proxy;
@@ -233,6 +237,50 @@ struct ConnectedRdpServer {
233237
x224_rsp: Option<Vec<u8>>,
234238
}
235239

240+
/// Explicit VMConnect request: no X.224 and a non-empty Unicode PCB V2 payload.
241+
///
242+
/// Matches IronRDP `RDCleanPathMessage::VmConnectRequest` / `new_vmconnect_request`.
243+
fn is_vmconnect_request(cleanpath_pdu: &RDCleanPathPdu) -> bool {
244+
cleanpath_pdu.x224_connection_pdu.is_none()
245+
&& cleanpath_pdu
246+
.preconnection_blob
247+
.as_ref()
248+
.is_some_and(|pcb| !pcb.trim().is_empty())
249+
}
250+
251+
/// Encode the Hyper-V PCB V2 that the proxy writes before TLS.
252+
///
253+
/// `payload` is the opaque Unicode string from RDCleanPath (`GUID` or `GUID;EnhancedMode=1`).
254+
fn encode_vmconnect_pcb_v2(payload: String) -> anyhow::Result<Vec<u8>> {
255+
let pcb = ironrdp_pdu::pcb::PreconnectionBlob {
256+
id: 0,
257+
version: ironrdp_pdu::pcb::PcbVersion::V2,
258+
v2_payload: Some(payload),
259+
};
260+
ironrdp_core::encode_vec(&pcb).context("encode VMConnect preconnection blob")
261+
}
262+
263+
/// Cert-chain-only success response after VMConnect PCB + TLS (no X.224).
264+
///
265+
/// Wire-compatible with IronRDP `RDCleanPathMessage::VmConnectResponse`.
266+
fn build_vmconnect_response(
267+
server_addr: String,
268+
x509_chain: impl IntoIterator<Item = Vec<u8>>,
269+
) -> anyhow::Result<RDCleanPathPdu> {
270+
Ok(RDCleanPathPdu {
271+
version: ironrdp_rdcleanpath::VERSION_1,
272+
server_cert_chain: Some(
273+
x509_chain
274+
.into_iter()
275+
.map(OctetString::new)
276+
.collect::<Result<_, _>>()
277+
.context("build VMConnect RDCleanPath cert chain")?,
278+
),
279+
server_addr: Some(server_addr),
280+
..RDCleanPathPdu::default()
281+
})
282+
}
283+
236284
/// Establish a connection to the RDP server and perform the requested front sequence.
237285
///
238286
/// The routing pipeline (explicit agent → subnet/domain match → direct) is shared with
@@ -271,40 +319,54 @@ async fn connect_rdp_server(
271319
debug!(%selected_target, "Connected to destination server");
272320
tracing::Span::current().record("target", selected_target.to_string());
273321

274-
let is_vmconnect = cleanpath_pdu.x224_connection_pdu.is_none()
275-
&& cleanpath_pdu
276-
.preconnection_blob
277-
.as_ref()
278-
.is_some_and(|pcb| !pcb.trim().is_empty());
322+
// MS-RDPEPS: complete the PCB write within 10s of TCP connect. Bound the front write(s)
323+
// from this point so a stalled tunnel/target cannot hold the PCB open indefinitely.
324+
let front_deadline = tokio::time::Instant::now() + PCB_TRANSMIT_DEADLINE;
279325

280-
let x224_rsp = if is_vmconnect {
326+
let x224_rsp = if is_vmconnect_request(&cleanpath_pdu) {
281327
// Client sent Unicode PCB payload only; proxy encodes binary PCB V2 and skips X.224.
282328
let pcb_payload = cleanpath_pdu
283329
.preconnection_blob
284330
.context("VMConnect request missing preconnection_blob")
285331
.map_err(CleanPathError::BadRequest)?;
286-
let pcb = ironrdp_pdu::pcb::PreconnectionBlob {
287-
id: 0,
288-
version: ironrdp_pdu::pcb::PcbVersion::V2,
289-
v2_payload: Some(pcb_payload),
290-
};
291-
let pcb = ironrdp_core::encode_vec(&pcb)
292-
.context("encode VMConnect preconnection blob")
293-
.map_err(CleanPathError::BadRequest)?;
332+
let pcb = encode_vmconnect_pcb_v2(pcb_payload).map_err(CleanPathError::BadRequest)?;
294333
debug!(pcb_len = pcb.len(), "Writing encoded VMConnect PCB before TLS");
295-
server_stream.write_all(&pcb).await?;
334+
tokio::time::timeout_at(front_deadline, async {
335+
server_stream.write_all(&pcb).await?;
336+
// Ensure the Hyper-V listener sees the PCB before ClientHello is queued
337+
// (especially on agent-tunnel legs that may buffer).
338+
server_stream.flush().await
339+
})
340+
.await
341+
.map_err(|_| {
342+
CleanPathError::Io(io::Error::new(
343+
ErrorKind::TimedOut,
344+
"timed out writing VMConnect preconnection blob",
345+
))
346+
})??;
296347
None
297348
} else {
298349
// Ordinary: optional legacy complete PCB bytes, then X.224 CR/CC, then TLS.
299-
if let Some(pcb) = cleanpath_pdu.preconnection_blob {
300-
server_stream.write_all(pcb.as_bytes()).await?;
301-
}
350+
tokio::time::timeout_at(front_deadline, async {
351+
if let Some(pcb) = cleanpath_pdu.preconnection_blob {
352+
server_stream.write_all(pcb.as_bytes()).await?;
353+
}
302354

303-
let x224_req = cleanpath_pdu
304-
.x224_connection_pdu
305-
.context("request is missing X224 connection PDU")
306-
.map_err(CleanPathError::BadRequest)?;
307-
server_stream.write_all(x224_req.as_bytes()).await?;
355+
let x224_req = cleanpath_pdu
356+
.x224_connection_pdu
357+
.context("request is missing X224 connection PDU")
358+
.map_err(CleanPathError::BadRequest)?;
359+
server_stream.write_all(x224_req.as_bytes()).await?;
360+
server_stream.flush().await?;
361+
Ok::<_, CleanPathError>(())
362+
})
363+
.await
364+
.map_err(|_| {
365+
CleanPathError::Io(io::Error::new(
366+
ErrorKind::TimedOut,
367+
"timed out writing RDCleanPath front sequence",
368+
))
369+
})??;
308370

309371
trace!("Receiving X224 response");
310372

@@ -575,6 +637,14 @@ pub async fn handle(
575637
&& let Some(entry) = credentials.get(jti)
576638
&& entry.mapping.is_some()
577639
{
640+
// VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client.
641+
// Proxy CredSSP MITM is X.224-first and is not supported for this ordering.
642+
if is_vmconnect_request(&cleanpath_pdu) {
643+
let response = RDCleanPathPdu::new_http_error(400);
644+
send_clean_path_response(&mut client_stream, &response).await?;
645+
anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath");
646+
}
647+
578648
let credential_injection_kdc = credentials.kdc_for(jti)?;
579649
anyhow::ensure!(token == credential_injection_kdc.raw_token(), "token mismatch");
580650
debug!(
@@ -653,17 +723,7 @@ pub async fn handle(
653723
RDCleanPathPdu::new_response(server_addr.to_string(), x224_rsp, x509_chain)
654724
.context("build RDCleanPath response")?
655725
} else {
656-
RDCleanPathPdu {
657-
version: ironrdp_rdcleanpath::VERSION_1,
658-
server_cert_chain: Some(
659-
x509_chain
660-
.map(OctetString::new)
661-
.collect::<Result<_, _>>()
662-
.context("build VMConnect RDCleanPath cert chain")?,
663-
),
664-
server_addr: Some(server_addr.to_string()),
665-
..RDCleanPathPdu::default()
666-
}
726+
build_vmconnect_response(server_addr.to_string(), x509_chain).context("build VMConnect RDCleanPath response")?
667727
};
668728

669729
send_clean_path_response(&mut client_stream, &rdcleanpath_rsp).await?;
@@ -863,3 +923,83 @@ impl From<&io::Error> for WsaError {
863923
}
864924
}
865925
}
926+
927+
#[cfg(test)]
928+
mod tests {
929+
use super::*;
930+
931+
fn empty_x224() -> OctetString {
932+
OctetString::new(vec![0x03, 0x00, 0x00, 0x13]).expect("static X.224 bytes")
933+
}
934+
935+
#[test]
936+
fn detects_vmconnect_when_pcb_payload_present_without_x224() {
937+
let pdu = RDCleanPathPdu {
938+
version: ironrdp_rdcleanpath::VERSION_1,
939+
destination: Some("10.10.0.3:2179".to_owned()),
940+
proxy_auth: Some("token".to_owned()),
941+
preconnection_blob: Some("21c82e1f-2368-43d5-9cb6-a7c99c449bba;EnhancedMode=1".to_owned()),
942+
..RDCleanPathPdu::default()
943+
};
944+
assert!(is_vmconnect_request(&pdu));
945+
}
946+
947+
#[test]
948+
fn ordinary_request_with_x224_is_not_vmconnect() {
949+
let pdu = RDCleanPathPdu {
950+
version: ironrdp_rdcleanpath::VERSION_1,
951+
destination: Some("10.10.0.3:3389".to_owned()),
952+
proxy_auth: Some("token".to_owned()),
953+
preconnection_blob: Some("legacy-pcb-bytes".to_owned()),
954+
x224_connection_pdu: Some(empty_x224()),
955+
..RDCleanPathPdu::default()
956+
};
957+
assert!(!is_vmconnect_request(&pdu));
958+
}
959+
960+
#[test]
961+
fn empty_or_whitespace_pcb_without_x224_is_not_vmconnect() {
962+
for pcb in [None, Some(String::new()), Some(" ".to_owned())] {
963+
let pdu = RDCleanPathPdu {
964+
version: ironrdp_rdcleanpath::VERSION_1,
965+
destination: Some("10.10.0.3:2179".to_owned()),
966+
proxy_auth: Some("token".to_owned()),
967+
preconnection_blob: pcb,
968+
..RDCleanPathPdu::default()
969+
};
970+
assert!(!is_vmconnect_request(&pdu));
971+
}
972+
}
973+
974+
#[test]
975+
fn encodes_enhanced_pcb_v2_matching_lab_size() {
976+
// Lab GUID with EnhancedMode; IronRDP observed 122-byte PCB on the wire.
977+
let payload = "21c82e1f-2368-43d5-9cb6-a7c99c449bba;EnhancedMode=1".to_owned();
978+
let bytes = encode_vmconnect_pcb_v2(payload.clone()).expect("encode");
979+
assert_eq!(bytes.len(), 122);
980+
981+
let decoded: ironrdp_pdu::pcb::PreconnectionBlob = ironrdp_core::decode(&bytes).expect("decode round-trip");
982+
assert_eq!(decoded.id, 0);
983+
assert_eq!(decoded.version, ironrdp_pdu::pcb::PcbVersion::V2);
984+
assert_eq!(decoded.v2_payload.as_deref(), Some(payload.as_str()));
985+
}
986+
987+
#[test]
988+
fn encodes_basic_pcb_v2_matching_lab_size() {
989+
let payload = "21c82e1f-2368-43d5-9cb6-a7c99c449bba".to_owned();
990+
let bytes = encode_vmconnect_pcb_v2(payload).expect("encode");
991+
assert_eq!(bytes.len(), 92);
992+
}
993+
994+
#[test]
995+
fn vmconnect_response_has_cert_chain_without_x224() {
996+
let rsp = build_vmconnect_response("10.10.0.3:2179".to_owned(), [vec![0xDE, 0xAD], vec![0xBE, 0xEF]])
997+
.expect("build response");
998+
999+
assert_eq!(rsp.version, ironrdp_rdcleanpath::VERSION_1);
1000+
assert_eq!(rsp.server_addr.as_deref(), Some("10.10.0.3:2179"));
1001+
assert!(rsp.x224_connection_pdu.is_none());
1002+
assert_eq!(rsp.server_cert_chain.as_ref().map(|c| c.len()).unwrap_or(0), 2);
1003+
assert!(rsp.error.is_none());
1004+
}
1005+
}

devolutions-gateway/src/upstream.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
//! 2. On the first successful connection, optionally wrap in client TLS.
1111
//!
1212
//! The two consumer patterns differ only in whether they want the TLS wrap
13-
//! applied here (fwd.rs) or manage their own TLS upgrade (rd_clean_path.rs does
14-
//! X224 first, then TLS). Both share `UpstreamLeg` and [`connect_upstream`].
13+
//! applied here (fwd.rs) or manage their own TLS upgrade in `rd_clean_path.rs`
14+
//! (ordinary: optional PCB + X.224 then TLS; VMConnect: PCB then TLS, no X.224
15+
//! on the proxy). Both share `UpstreamLeg` and [`connect_upstream`].
1516
1617
use std::net::SocketAddr;
1718
use std::pin::Pin;

0 commit comments

Comments
 (0)