Skip to content

Commit 36c71f7

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 36c71f7

File tree

3 files changed

+221
-7
lines changed

3 files changed

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

289469
trace!("Reading RDCleanPath");
290470

291471
let cleanpath_pdu = read_cleanpath_pdu(&mut client_stream)
292472
.await
293-
.context("couldn’t read clean cleanpath PDU")?;
473+
.context("couldn't read clean cleanpath PDU")?;
474+
475+
// Early credential detection: check if we should use RdpProxy instead
476+
let token = cleanpath_pdu
477+
.proxy_auth
478+
.as_deref()
479+
.ok_or_else(|| anyhow::anyhow!("missing token in RDCleanPath PDU"))?;
480+
481+
if let Ok(token_id) = crate::token::extract_jti(token) {
482+
if let Some(entry) = credential_store.get(token_id) {
483+
if entry.mapping.is_some() {
484+
// Credentials found! Switch to RdpProxy for credential injection
485+
info!("Switching to RdpProxy for credential injection (WebSocket)");
486+
487+
return handle_with_credential_injection(
488+
client_stream,
489+
client_addr,
490+
conf,
491+
token_cache,
492+
jrl,
493+
sessions,
494+
subscriber_tx,
495+
active_recordings,
496+
cleanpath_pdu,
497+
entry,
498+
)
499+
.await;
500+
}
501+
}
502+
}
294503

295504
trace!("Processing RDCleanPath");
296505

@@ -308,6 +517,7 @@ pub async fn handle(
308517
jrl,
309518
active_recordings,
310519
&sessions,
520+
credential_store,
311521
)
312522
.await
313523
{

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)