Skip to content

Commit f038618

Browse files
committed
feat(dgw): add RDCleanPath credential injection via CredSSP MITM
- Thread credential_store through RDCleanPath API handlers - Add handle_with_credential_injection function for CredSSP flow - Expose helper functions for CredSSP and TLS key extraction - Enable early credential detection in WebSocket RDCleanPath path
1 parent b2f5172 commit f038618

File tree

3 files changed

+238
-7
lines changed

3 files changed

+238
-7
lines changed

devolutions-gateway/src/api/rdp.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub async fn handler(
2525
subscriber_tx,
2626
recordings,
2727
shutdown_signal,
28+
credential_store,
2829
..
2930
}): State<DgwState>,
3031
ConnectInfo(source_addr): ConnectInfo<SocketAddr>,
@@ -44,6 +45,7 @@ pub async fn handler(
4445
subscriber_tx,
4546
recordings.active_recordings,
4647
source_addr,
48+
credential_store,
4749
)
4850
.instrument(span)
4951
});
@@ -62,6 +64,7 @@ async fn handle_socket(
6264
subscriber_tx: SubscriberSender,
6365
active_recordings: Arc<ActiveRecordings>,
6466
source_addr: SocketAddr,
67+
credential_store: crate::credential::CredentialStoreHandle,
6568
) {
6669
let (stream, close_handle) = crate::ws::handle(
6770
ws,
@@ -78,6 +81,7 @@ async fn handle_socket(
7881
sessions,
7982
subscriber_tx,
8083
&active_recordings,
84+
&credential_store,
8185
)
8286
.await;
8387

devolutions-gateway/src/rd_clean_path.rs

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::net::SocketAddr;
33
use std::sync::Arc;
44

55
use crate::config::Conf;
6+
use crate::credential::CredentialStoreHandle;
67
use crate::proxy::Proxy;
78
use crate::recording::ActiveRecordings;
89
use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender};
@@ -11,6 +12,7 @@ use crate::target_addr::TargetAddr;
1112
use crate::token::{AssociationTokenClaims, CurrentJrl, TokenCache, TokenError};
1213

1314
use anyhow::Context as _;
15+
use ironrdp_pdu::nego;
1416
use ironrdp_rdcleanpath::RDCleanPathPdu;
1517
use tap::prelude::*;
1618
use thiserror::Error;
@@ -172,6 +174,7 @@ async fn process_cleanpath(
172174
jrl: &CurrentJrl,
173175
active_recordings: &ActiveRecordings,
174176
sessions: &SessionMessageSender,
177+
_credential_store: &CredentialStoreHandle,
175178
) -> Result<CleanPathResult, CleanPathError> {
176179
use crate::utils;
177180

@@ -272,25 +275,248 @@ async fn process_cleanpath(
272275
})
273276
}
274277

278+
/// Handle RDP connection with credential injection via CredSSP MITM
279+
#[allow(clippy::too_many_arguments)]
280+
async fn handle_with_credential_injection(
281+
mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
282+
client_addr: SocketAddr,
283+
conf: Arc<Conf>,
284+
token_cache: &TokenCache,
285+
jrl: &CurrentJrl,
286+
sessions: SessionMessageSender,
287+
subscriber_tx: SubscriberSender,
288+
active_recordings: &ActiveRecordings,
289+
cleanpath_pdu: RDCleanPathPdu,
290+
_credential_entry: crate::credential::ArcCredentialEntry,
291+
) -> anyhow::Result<()> {
292+
let token = cleanpath_pdu
293+
.proxy_auth
294+
.as_ref()
295+
.context("missing token")?;
296+
297+
// Authorize the token
298+
let claims = authorize(
299+
client_addr,
300+
token,
301+
&conf,
302+
token_cache,
303+
jrl,
304+
active_recordings,
305+
None,
306+
)
307+
.map_err(|e| anyhow::anyhow!("authorization failed: {}", e))?;
308+
309+
let crate::token::ConnectionMode::Fwd { targets: _ } = claims.jet_cm else {
310+
anyhow::bail!("unexpected connection mode");
311+
};
312+
313+
let span = tracing::Span::current();
314+
span.record("session_id", claims.jet_aid.to_string());
315+
316+
info!("Credential injection: performing CredSSP MITM");
317+
318+
// Run normal RDCleanPath flow (this will handle server-side TLS and get certs)
319+
let CleanPathResult {
320+
destination,
321+
server_addr,
322+
server_stream,
323+
x224_rsp,
324+
..
325+
} = process_cleanpath(
326+
cleanpath_pdu,
327+
client_addr,
328+
&conf,
329+
token_cache,
330+
jrl,
331+
active_recordings,
332+
&sessions,
333+
&CredentialStoreHandle::new(), // Dummy, not used in process_cleanpath
334+
)
335+
.await
336+
.map_err(|e| anyhow::anyhow!("RDCleanPath processing failed: {}", e))?;
337+
338+
// Extract server security protocol from X224 response (before x224_rsp is moved)
339+
let x224_confirm: ironrdp_pdu::x224::X224<nego::ConnectionConfirm> = ironrdp_core::decode(&x224_rsp)
340+
.context("decode X224 connection confirm")?;
341+
let server_security_protocol = match &x224_confirm.0 {
342+
nego::ConnectionConfirm::Response { protocol, .. } => {
343+
if !protocol.intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) {
344+
anyhow::bail!(
345+
"server selected security protocol {protocol}, which is not supported for credential injection"
346+
);
347+
}
348+
*protocol
349+
}
350+
nego::ConnectionConfirm::Failure { code } => {
351+
anyhow::bail!("RDP session initiation failed with code {code}");
352+
}
353+
};
354+
355+
// Send RDCleanPath response to client (includes server certs)
356+
let x509_chain = server_stream
357+
.get_ref()
358+
.1
359+
.peer_certificates()
360+
.context("no peer certificate found in TLS transport")?
361+
.iter()
362+
.map(|cert| cert.to_vec());
363+
364+
trace!("Sending RDCleanPath response");
365+
366+
let rdcleanpath_rsp = RDCleanPathPdu::new_response(server_addr.to_string(), x224_rsp, x509_chain)
367+
.map_err(|e| anyhow::anyhow!("couldn't build RDCleanPath response: {e}"))?;
368+
369+
send_clean_path_response(&mut client_stream, &rdcleanpath_rsp).await?;
370+
371+
info!("RDCleanPath response sent, now performing CredSSP MITM");
372+
373+
// Get TLS configuration for CredSSP
374+
let tls_conf = conf
375+
.tls
376+
.as_ref()
377+
.context("TLS required for credential injection")?;
378+
379+
// Get credential mapping
380+
let credential_mapping = _credential_entry
381+
.mapping
382+
.as_ref()
383+
.context("no credential mapping")?;
384+
385+
// Extract server public key from TLS stream
386+
let server_public_key = crate::rdp_proxy::extract_tls_server_public_key(&server_stream)
387+
.context("extract server TLS public key")?;
388+
389+
// Wrap streams in TokioFramed for CredSSP
390+
let mut client_framed = ironrdp_tokio::TokioFramed::new(client_stream);
391+
let mut server_framed = ironrdp_tokio::TokioFramed::new(server_stream);
392+
393+
// Use HYBRID_EX for client (web clients typically use this)
394+
let client_security_protocol = nego::SecurityProtocol::HYBRID_EX;
395+
396+
// Perform CredSSP MITM (in parallel)
397+
// Note: Client expects server's public key (since we sent server certs in RDCleanPath response)
398+
let client_credssp_fut = crate::rdp_proxy::perform_credssp_with_client(
399+
&mut client_framed,
400+
client_addr.ip(),
401+
server_public_key.clone(),
402+
client_security_protocol,
403+
&credential_mapping.proxy,
404+
);
405+
406+
let server_credssp_fut = crate::rdp_proxy::perform_credssp_with_server(
407+
&mut server_framed,
408+
destination.host().to_owned(),
409+
server_public_key,
410+
server_security_protocol,
411+
&credential_mapping.target,
412+
);
413+
414+
let (client_res, server_res) = tokio::join!(client_credssp_fut, server_credssp_fut);
415+
client_res.context("CredSSP with client failed")?;
416+
server_res.context("CredSSP with server failed")?;
417+
418+
info!("CredSSP MITM completed successfully");
419+
420+
// Extract streams and any leftover bytes
421+
let (mut client_stream, client_leftover) = client_framed.into_inner();
422+
let (mut server_stream, server_leftover) = server_framed.into_inner();
423+
424+
// Forward any leftover bytes
425+
if !server_leftover.is_empty() {
426+
client_stream
427+
.write_all(&server_leftover)
428+
.await
429+
.context("write server leftover to client")?;
430+
}
431+
if !client_leftover.is_empty() {
432+
server_stream
433+
.write_all(&client_leftover)
434+
.await
435+
.context("write client leftover to server")?;
436+
}
437+
438+
info!("RDP-TLS forwarding (credential injection)");
439+
440+
// Build SessionInfo for forwarding
441+
let session_info = SessionInfo::builder()
442+
.id(claims.jet_aid)
443+
.application_protocol(claims.jet_ap)
444+
.details(ConnectionModeDetails::Fwd {
445+
destination_host: destination.clone(),
446+
})
447+
.time_to_live(claims.jet_ttl)
448+
.recording_policy(claims.jet_rec)
449+
.filtering_policy(claims.jet_flt)
450+
.build();
451+
452+
let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse);
453+
454+
// Plain forwarding for now
455+
Proxy::builder()
456+
.conf(conf)
457+
.session_info(session_info)
458+
.address_a(client_addr)
459+
.transport_a(client_stream)
460+
.address_b(server_addr)
461+
.transport_b(server_stream)
462+
.sessions(sessions)
463+
.subscriber_tx(subscriber_tx)
464+
.disconnect_interest(disconnect_interest)
465+
.build()
466+
.select_dissector_and_forward()
467+
.await
468+
.context("proxy failed")
469+
}
470+
275471
#[allow(clippy::too_many_arguments)]
276472
#[instrument("fwd", skip_all, fields(session_id = field::Empty, target = field::Empty))]
277473
pub async fn handle(
278-
mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send,
474+
mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
279475
client_addr: SocketAddr,
280476
conf: Arc<Conf>,
281477
token_cache: &TokenCache,
282478
jrl: &CurrentJrl,
283479
sessions: SessionMessageSender,
284480
subscriber_tx: SubscriberSender,
285481
active_recordings: &ActiveRecordings,
482+
credential_store: &CredentialStoreHandle,
286483
) -> anyhow::Result<()> {
287484
// Special handshake of our RDP extension
288485

289486
trace!("Reading RDCleanPath");
290487

291488
let cleanpath_pdu = read_cleanpath_pdu(&mut client_stream)
292489
.await
293-
.context("couldn’t read clean cleanpath PDU")?;
490+
.context("couldn't read clean cleanpath PDU")?;
491+
492+
// Early credential detection: check if we should use RdpProxy instead
493+
let token = cleanpath_pdu
494+
.proxy_auth
495+
.as_deref()
496+
.ok_or_else(|| anyhow::anyhow!("missing token in RDCleanPath PDU"))?;
497+
498+
if let Ok(token_id) = crate::token::extract_jti(token) {
499+
if let Some(entry) = credential_store.get(token_id) {
500+
if entry.mapping.is_some() {
501+
// Credentials found! Switch to RdpProxy for credential injection
502+
info!("Switching to RdpProxy for credential injection (WebSocket)");
503+
504+
return handle_with_credential_injection(
505+
client_stream,
506+
client_addr,
507+
conf,
508+
token_cache,
509+
jrl,
510+
sessions,
511+
subscriber_tx,
512+
active_recordings,
513+
cleanpath_pdu,
514+
entry,
515+
)
516+
.await;
517+
}
518+
}
519+
}
294520

295521
trace!("Processing RDCleanPath");
296522

@@ -308,6 +534,7 @@ pub async fn handle(
308534
jrl,
309535
active_recordings,
310536
&sessions,
537+
credential_store,
311538
)
312539
.await
313540
{

devolutions-gateway/src/rdp_proxy.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ where
327327
}
328328

329329
#[instrument(name = "server_credssp", level = "debug", ret, skip_all)]
330-
async fn perform_credssp_with_server<S>(
330+
pub async fn perform_credssp_with_server<S>(
331331
framed: &mut ironrdp_tokio::Framed<S>,
332332
server_name: String,
333333
server_public_key: Vec<u8>,
@@ -392,7 +392,7 @@ where
392392
}
393393

394394
#[instrument(name = "client_credssp", level = "debug", ret, skip_all)]
395-
async fn perform_credssp_with_client<S>(
395+
pub async fn perform_credssp_with_client<S>(
396396
framed: &mut ironrdp_tokio::Framed<S>,
397397
client_addr: IpAddr,
398398
gateway_public_key: Vec<u8>,
@@ -483,7 +483,7 @@ where
483483
}
484484
}
485485

486-
async fn get_cached_gateway_public_key(
486+
pub async fn get_cached_gateway_public_key(
487487
hostname: String,
488488
acceptor: tokio_rustls::TlsAcceptor,
489489
) -> anyhow::Result<Vec<u8>> {
@@ -533,7 +533,7 @@ async fn retrieve_gateway_public_key(hostname: String, acceptor: tokio_rustls::T
533533
Ok(public_key)
534534
}
535535

536-
fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Result<Vec<u8>> {
536+
pub fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Result<Vec<u8>> {
537537
use x509_cert::der::Decode as _;
538538

539539
let cert = tls_stream.get_peer_certificate().context("certificate is missing")?;
@@ -551,7 +551,7 @@ fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Resul
551551
Ok(server_public_key)
552552
}
553553

554-
trait GetPeerCert {
554+
pub trait GetPeerCert {
555555
fn get_peer_certificate(&self) -> Option<&tokio_rustls::rustls::pki_types::CertificateDer<'static>>;
556556
}
557557

0 commit comments

Comments
 (0)