-
Notifications
You must be signed in to change notification settings - Fork 24
feat(dgw): add RDCleanPath credential injection via CredSSP MITM #1614
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
AlexMartigny
commented
Dec 8, 2025
- 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
Let maintainers know that an action is required on their side
|
f038618 to
36c71f7
Compare
Maintainer action requestedAuthor requested libraries to be published. cc @CBenoit |
Maintainer action requestedAuthor requested a new release to be cut. cc @CBenoit |
36c71f7 to
01a6ab7
Compare
- 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
01a6ab7 to
c701ecc
Compare
Maintainer action requestedAuthor requested a new release to be cut. cc @CBenoit |
|
|
||
| #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] | ||
| async fn perform_credssp_with_server<S>( | ||
| pub async fn perform_credssp_with_server<S>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot Should not be made pub. If absolutely necessary use pub(crate) and keep it an implementation detail. Same for all the functions in this module.
| #[instrument("fwd", skip_all, fields(session_id = field::Empty, target = field::Empty))] | ||
| pub async fn handle( | ||
| mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send, | ||
| mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot Verify we use the smallest set of traits possible here.
| // Run normal RDCleanPath flow (this will handle server-side TLS and get certs) | ||
| let CleanPathResult { | ||
| destination, | ||
| server_addr, | ||
| server_stream, | ||
| x224_rsp, | ||
| .. | ||
| } = process_cleanpath( | ||
| cleanpath_pdu, | ||
| client_addr, | ||
| &conf, | ||
| token_cache, | ||
| jrl, | ||
| active_recordings, | ||
| &sessions, | ||
| &CredentialStoreHandle::new(), // Dummy, not used in process_cleanpath | ||
| ) | ||
| .await | ||
| .map_err(|e| anyhow::anyhow!("RDCleanPath processing failed: {}", e))?; | ||
|
|
||
| // Extract server security protocol from X224 response (before x224_rsp is moved) | ||
| let x224_confirm: ironrdp_pdu::x224::X224<nego::ConnectionConfirm> = | ||
| ironrdp_core::decode(&x224_rsp).context("decode X224 connection confirm")?; | ||
| let server_security_protocol = match &x224_confirm.0 { | ||
| nego::ConnectionConfirm::Response { protocol, .. } => { | ||
| if !protocol.intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) { | ||
| anyhow::bail!( | ||
| "server selected security protocol {protocol}, which is not supported for credential injection" | ||
| ); | ||
| } | ||
| *protocol | ||
| } | ||
| nego::ConnectionConfirm::Failure { code } => { | ||
| anyhow::bail!("RDP session initiation failed with code {code}"); | ||
| } | ||
| }; | ||
|
|
||
| // Send RDCleanPath response to client (includes server certs) | ||
| let x509_chain = server_stream | ||
| .get_ref() | ||
| .1 | ||
| .peer_certificates() | ||
| .context("no peer certificate found in TLS transport")? | ||
| .iter() | ||
| .map(|cert| cert.to_vec()); | ||
|
|
||
| trace!("Sending RDCleanPath response"); | ||
|
|
||
| let rdcleanpath_rsp = RDCleanPathPdu::new_response(server_addr.to_string(), x224_rsp, x509_chain) | ||
| .map_err(|e| anyhow::anyhow!("couldn't build RDCleanPath response: {e}"))?; | ||
|
|
||
| send_clean_path_response(&mut client_stream, &rdcleanpath_rsp).await?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot Possibly duplicate code here. Double check that.
| // Extract server public key from TLS stream | ||
| let server_public_key = | ||
| crate::rdp_proxy::extract_tls_server_public_key(&server_stream).context("extract server TLS public key")?; | ||
|
|
||
| // Wrap streams in TokioFramed for CredSSP | ||
| let mut client_framed = ironrdp_tokio::TokioFramed::new(client_stream); | ||
| let mut server_framed = ironrdp_tokio::TokioFramed::new(server_stream); | ||
|
|
||
| // Use HYBRID_EX for client (web clients typically use this) | ||
| let client_security_protocol = nego::SecurityProtocol::HYBRID_EX; | ||
|
|
||
| // Perform CredSSP MITM (in parallel) | ||
| // Note: Client expects server's public key (since we sent server certs in RDCleanPath response) | ||
| let client_credssp_fut = crate::rdp_proxy::perform_credssp_with_client( | ||
| &mut client_framed, | ||
| client_addr.ip(), | ||
| server_public_key.clone(), | ||
| client_security_protocol, | ||
| &credential_mapping.proxy, | ||
| ); | ||
|
|
||
| let server_credssp_fut = crate::rdp_proxy::perform_credssp_with_server( | ||
| &mut server_framed, | ||
| destination.host().to_owned(), | ||
| server_public_key, | ||
| server_security_protocol, | ||
| &credential_mapping.target, | ||
| ); | ||
|
|
||
| let (client_res, server_res) = tokio::join!(client_credssp_fut, server_credssp_fut); | ||
| client_res.context("CredSSP with client failed")?; | ||
| server_res.context("CredSSP with server failed")?; | ||
|
|
||
| info!("CredSSP MITM completed successfully"); | ||
|
|
||
| // Extract streams and any leftover bytes | ||
| let (mut client_stream, client_leftover) = client_framed.into_inner(); | ||
| let (mut server_stream, server_leftover) = server_framed.into_inner(); | ||
|
|
||
| // Forward any leftover bytes | ||
| if !server_leftover.is_empty() { | ||
| client_stream | ||
| .write_all(&server_leftover) | ||
| .await | ||
| .context("write server leftover to client")?; | ||
| } | ||
| if !client_leftover.is_empty() { | ||
| server_stream | ||
| .write_all(&client_leftover) | ||
| .await | ||
| .context("write client leftover to server")?; | ||
| } | ||
|
|
||
| info!("RDP-TLS forwarding (credential injection)"); | ||
|
|
||
| // Build SessionInfo for forwarding | ||
| let session_info = SessionInfo::builder() | ||
| .id(claims.jet_aid) | ||
| .application_protocol(claims.jet_ap) | ||
| .details(ConnectionModeDetails::Fwd { | ||
| destination_host: destination.clone(), | ||
| }) | ||
| .time_to_live(claims.jet_ttl) | ||
| .recording_policy(claims.jet_rec) | ||
| .filtering_policy(claims.jet_flt) | ||
| .build(); | ||
|
|
||
| let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); | ||
|
|
||
| // Plain forwarding for now | ||
| Proxy::builder() | ||
| .conf(conf) | ||
| .session_info(session_info) | ||
| .address_a(client_addr) | ||
| .transport_a(client_stream) | ||
| .address_b(server_addr) | ||
| .transport_b(server_stream) | ||
| .sessions(sessions) | ||
| .subscriber_tx(subscriber_tx) | ||
| .disconnect_interest(disconnect_interest) | ||
| .build() | ||
| .select_dissector_and_forward() | ||
| .await | ||
| .context("proxy failed") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot Can possibly be refactored into the rdp_proxy module. Double check that.
| // Early credential detection: check if we should use RdpProxy instead | ||
| let token = cleanpath_pdu | ||
| .proxy_auth | ||
| .as_deref() | ||
| .ok_or_else(|| anyhow::anyhow!("missing token in RDCleanPath PDU"))?; | ||
|
|
||
| if let Some(entry) = crate::token::extract_jti(token) | ||
| .ok() | ||
| .and_then(|token_id| credential_store.get(token_id)) | ||
| .filter(|entry| entry.mapping.is_some()) | ||
| { | ||
| // Credentials found! Switch to RdpProxy for credential injection | ||
| info!("Switching to RdpProxy for credential injection (WebSocket)"); | ||
|
|
||
| return handle_with_credential_injection( | ||
| client_stream, | ||
| client_addr, | ||
| conf, | ||
| token_cache, | ||
| jrl, | ||
| sessions, | ||
| subscriber_tx, | ||
| active_recordings, | ||
| cleanpath_pdu, | ||
| entry, | ||
| ) | ||
| .await; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot Since we already extract the token here, verify if we can avoid re-extracting it later. Avoid doing the job twice.
| jrl: &CurrentJrl, | ||
| active_recordings: &ActiveRecordings, | ||
| sessions: &SessionMessageSender, | ||
| _credential_store: &CredentialStoreHandle, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue: Unused parameter