From e3adb0b9a9eea5262ece4697fa18d362a21686b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 15:48:59 +0000 Subject: [PATCH 1/3] fix(pdu): reject RSA moduli below 512 bits The client encrypts the licensing premaster secret with the public key from the server's certificate, and num-bigint panics on a zero modulus, so a server sending an all-zero modulus crashed the client during the license exchange. The proprietary certificate decoder checks only the key length fields, and the X.509 path passes the key through unchanged. Encryption now fails with an InvalidData error for a modulus shorter than 512 bits, the size of a proprietary certificate key, which also keeps the modulus larger than the 48-byte premaster secret. The pdu_decode fuzz oracle now builds the client's reply to a decoded license request, so it reaches this encryption, and the crashing input joins its regression corpus. Co-Authored-By: Claude Opus 5.5 --- crates/ironrdp-fuzzing/src/oracles/mod.rs | 13 ++- crates/ironrdp-pdu/src/crypto/rsa.rs | 11 +++ ...h-3639648ab6730a3d8617bc39badcc741c56bdc4f | Bin 0 -> 304 bytes .../ironrdp-testsuite-core/tests/pdu/mod.rs | 1 + .../tests/pdu/server_license.rs | 89 ++++++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_decode/crash-3639648ab6730a3d8617bc39badcc741c56bdc4f create mode 100644 crates/ironrdp-testsuite-core/tests/pdu/server_license.rs diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 28d5dd3bed..ee22ec5423 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -178,7 +178,18 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::(data); let _ = decode::(data); - let _ = decode::(data); + // The client answers a license request by encrypting with the public key from the server's certificate. + if let Ok(server_license::LicensePdu::ServerLicenseRequest(license_request)) = + decode::(data) + { + let _ = server_license::ClientNewLicenseRequest::from_server_license_request( + &license_request, + &[0; 32], + &[0; 48], + "user", + "machine", + ); + } // Post-licensing multitransport bootstrapping: the connector try-decodes // arbitrary server bytes as a request to distinguish it from Demand Active. diff --git a/crates/ironrdp-pdu/src/crypto/rsa.rs b/crates/ironrdp-pdu/src/crypto/rsa.rs index d16e163214..18b873f56c 100644 --- a/crates/ironrdp-pdu/src/crypto/rsa.rs +++ b/crates/ironrdp-pdu/src/crypto/rsa.rs @@ -3,6 +3,11 @@ use std::io; use der_parser::parse_der; use num_bigint::BigUint; +/// The smallest RSA modulus accepted from a server, in bits, which is the size of a proprietary certificate key. +/// +/// A modulus of zero would make `modpow` panic, and one of at least 512 bits is larger than the 48-byte premaster secret it encrypts. +const MIN_MODULUS_BITS: u64 = 512; + pub(crate) fn encrypt_with_public_key(message: &[u8], public_key_der: &[u8]) -> io::Result> { let (_, der_object) = parse_der(public_key_der).map_err(|err| { io::Error::new( @@ -40,6 +45,12 @@ pub(crate) fn encrypt_with_public_key(message: &[u8], public_key_der: &[u8]) -> })?; let n = BigUint::from_bytes_be(n); + if n.bits() < MIN_MODULUS_BITS { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("RSA public key modulus is shorter than {MIN_MODULUS_BITS} bits"), + )); + } let e = BigUint::from_bytes_be(e); let m = BigUint::from_bytes_le(message); let c = m.modpow(&e, &n); diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_decode/crash-3639648ab6730a3d8617bc39badcc741c56bdc4f b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_decode/crash-3639648ab6730a3d8617bc39badcc741c56bdc4f new file mode 100644 index 0000000000000000000000000000000000000000..906170105acf3b1ae9481054d0ca7190351e3dfc GIT binary patch literal 304 zcmb`9Jq|!%7=s@_5sN!GfB_c}VX#nL5N-c HXG|+E%q bool { + let client_random = [0x11; 32]; + let premaster_secret = [0x22; 48]; + match self { + Self::NewLicenseRequest => ClientNewLicenseRequest::from_server_license_request( + license_request, + &client_random, + &premaster_secret, + "user", + "machine", + ) + .is_ok(), + Self::LicenseInfo => ClientLicenseInfo::from_server_license_request( + license_request, + &client_random, + &premaster_secret, + [0; 4], + vec![0x33; 16], + ) + .is_ok(), + } + } +} + +// oracle: reference crates/ironrdp-pdu/src/rdp/server_license/server_license_request/tests.rs +#[rstest] +#[case::zero_modulus(vec![0; 64], false)] +#[case::modulus_below_512_bits(vec![0xff; 32], false)] +#[case::modulus_of_512_bits(vec![0xff; 64], true)] +fn licensing_accepts_only_moduli_of_at_least_512_bits( + #[case] modulus: Vec, + #[case] accepted: bool, + #[values(LicenseResponse::NewLicenseRequest, LicenseResponse::LicenseInfo)] response: LicenseResponse, +) { + let license_request = license_request_with_modulus(modulus); + + assert_eq!(response.build(&license_request), accepted); +} + +/// A license request whose proprietary certificate carries `modulus`, in little-endian order without its padding. +fn license_request_with_modulus(modulus: Vec) -> ServerLicenseRequest { + ServerLicenseRequest { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, + }, + preamble_message_type: PreambleType::LicenseRequest, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0, + }, + server_random: vec![0x44; 32], + product_info: ProductInfo { + version: 0x60000, + company_name: "Microsoft Corporation".to_owned(), + product_id: "A02".to_owned(), + }, + server_certificate: Some(ServerCertificate { + issued_permanently: true, + certificate: CertificateType::Proprietary(ProprietaryCertificate { + public_key: RsaPublicKey { + public_exponent: 0x0001_0001, + modulus: [modulus.as_slice(), &MODULUS_PADDING].concat(), + }, + signature: vec![0; 72], + }), + }), + scope_list: Vec::new(), + } +} From 36fe9098c04eef32ea4eba1adbaaf456a278e0c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 23:17:34 +0000 Subject: [PATCH 2/3] test(pdu): mark modulus cases by source and fuzz both license replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each modulus case row now carries its own oracle marker: the zero modulus is a regression citing the pdu_decode crash file, the below-512-bit rejection is a mutant check, and the 512-bit acceptance follows the example key in spec MS-RDPBCGR §4.1.4. The pdu_decode fuzz oracle builds both encrypting client replies to a decoded license request, ClientNewLicenseRequest and ClientLicenseInfo. The MIN_MODULUS_BITS doc comment links MS-RDPBCGR 2.2.1.4.3.1.1.1 and 4.1.4, the sections that give the 512-bit proprietary certificate key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012KhrixAye4o5msM4tEzqzK --- crates/ironrdp-fuzzing/src/oracles/mod.rs | 10 +++++++++- crates/ironrdp-pdu/src/crypto/rsa.rs | 10 ++++++++-- .../ironrdp-testsuite-core/tests/pdu/server_license.rs | 4 +++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index ee22ec5423..0e95d9ae3a 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -178,7 +178,8 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::(data); let _ = decode::(data); - // The client answers a license request by encrypting with the public key from the server's certificate. + // The client answers a license request with either reply, both encrypting with the public key from the server's + // certificate. if let Ok(server_license::LicensePdu::ServerLicenseRequest(license_request)) = decode::(data) { @@ -189,6 +190,13 @@ pub fn pdu_decode(data: &[u8]) { "user", "machine", ); + let _ = server_license::ClientLicenseInfo::from_server_license_request( + &license_request, + &[0; 32], + &[0; 48], + [0; 4], + Vec::new(), + ); } // Post-licensing multitransport bootstrapping: the connector try-decodes diff --git a/crates/ironrdp-pdu/src/crypto/rsa.rs b/crates/ironrdp-pdu/src/crypto/rsa.rs index 18b873f56c..c5a04f4d85 100644 --- a/crates/ironrdp-pdu/src/crypto/rsa.rs +++ b/crates/ironrdp-pdu/src/crypto/rsa.rs @@ -3,9 +3,15 @@ use std::io; use der_parser::parse_der; use num_bigint::BigUint; -/// The smallest RSA modulus accepted from a server, in bits, which is the size of a proprietary certificate key. +/// The smallest RSA modulus accepted from a server, in bits. /// -/// A modulus of zero would make `modpow` panic, and one of at least 512 bits is larger than the 48-byte premaster secret it encrypts. +/// A modulus of zero would make `modpow` panic. 512 bits is the size of the proprietary certificate key in +/// [4.1.4] Server MCS Connect Response PDU with GCC Conference Create Response, whose [2.2.1.4.3.1.1.1] RSA Public Key +/// (RSA_PUBLIC_KEY) has a `bitlen` of 512, and a modulus of at least 512 bits is larger than the 48-byte premaster +/// secret it encrypts. +/// +/// [4.1.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/d23f7725-876c-48d4-9e41-8288896a19d3 +/// [2.2.1.4.3.1.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/fe93545c-772a-4ade-9d02-ad1e0d81b6af const MIN_MODULUS_BITS: u64 = 512; pub(crate) fn encrypt_with_public_key(message: &[u8], public_key_der: &[u8]) -> io::Result> { diff --git a/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs b/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs index ef74f50929..b9b26cbdff 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs @@ -41,10 +41,12 @@ impl LicenseResponse { } } -// oracle: reference crates/ironrdp-pdu/src/rdp/server_license/server_license_request/tests.rs #[rstest] +// oracle: regression crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_decode/crash-3639648ab6730a3d8617bc39badcc741c56bdc4f #[case::zero_modulus(vec![0; 64], false)] +// oracle: mutant a guard that rejects only a zero modulus, accepting a 256-bit key #[case::modulus_below_512_bits(vec![0xff; 32], false)] +// oracle: spec MS-RDPBCGR §4.1.4 #[case::modulus_of_512_bits(vec![0xff; 64], true)] fn licensing_accepts_only_moduli_of_at_least_512_bits( #[case] modulus: Vec, From 1bcac62fd33ce9965a608caa5c52e756f4a8c5f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 00:21:52 +0000 Subject: [PATCH 3/3] test(pdu): accept the MS-RDPBCGR 4.1.4 key in the modulus test The 512-bit acceptance case now uses the proprietary certificate modulus from MS-RDPBCGR 4.1.4 instead of a synthetic all-0xff key, so the case cites the bytes its spec marker names and exercises a real little-endian wire modulus, which reads differently in each byte order. The license request construction moves into the test body, and the MIN_MODULUS_BITS doc comment puts each sentence on its own line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012KhrixAye4o5msM4tEzqzK --- crates/ironrdp-pdu/src/crypto/rsa.rs | 8 +++---- .../tests/pdu/server_license.rs | 24 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/ironrdp-pdu/src/crypto/rsa.rs b/crates/ironrdp-pdu/src/crypto/rsa.rs index c5a04f4d85..c57722046e 100644 --- a/crates/ironrdp-pdu/src/crypto/rsa.rs +++ b/crates/ironrdp-pdu/src/crypto/rsa.rs @@ -5,10 +5,10 @@ use num_bigint::BigUint; /// The smallest RSA modulus accepted from a server, in bits. /// -/// A modulus of zero would make `modpow` panic. 512 bits is the size of the proprietary certificate key in -/// [4.1.4] Server MCS Connect Response PDU with GCC Conference Create Response, whose [2.2.1.4.3.1.1.1] RSA Public Key -/// (RSA_PUBLIC_KEY) has a `bitlen` of 512, and a modulus of at least 512 bits is larger than the 48-byte premaster -/// secret it encrypts. +/// A modulus of zero would make `modpow` panic. +/// 512 bits is the size of the proprietary certificate key in [4.1.4] Server MCS Connect Response PDU with GCC +/// Conference Create Response, whose [2.2.1.4.3.1.1.1] RSA Public Key (RSA_PUBLIC_KEY) has a `bitlen` of 512, and a +/// modulus of at least 512 bits is larger than the 48-byte premaster secret it encrypts. /// /// [4.1.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/d23f7725-876c-48d4-9e41-8288896a19d3 /// [2.2.1.4.3.1.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/fe93545c-772a-4ade-9d02-ad1e0d81b6af diff --git a/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs b/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs index b9b26cbdff..7edc1a8945 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/server_license.rs @@ -9,6 +9,14 @@ use rstest::rstest; /// The zero padding that follows the modulus bytes of a proprietary certificate's public key. const MODULUS_PADDING: [u8; 8] = [0; 8]; +/// The 512-bit modulus of the proprietary certificate in MS-RDPBCGR 4.1.4, in little-endian order without its padding. +const SPEC_EXAMPLE_MODULUS: [u8; 64] = [ + 0xcb, 0x81, 0xfe, 0xba, 0x6d, 0x61, 0xc3, 0x55, 0x05, 0xd5, 0x5f, 0x2e, 0x87, 0xf8, 0x71, 0x94, 0xd6, 0xf1, 0xa5, + 0xcb, 0xf1, 0x5f, 0x0c, 0x3d, 0xf8, 0x70, 0x02, 0x96, 0xc4, 0xfb, 0x9b, 0xc8, 0x3c, 0x2d, 0x55, 0xae, 0xe8, 0xff, + 0x32, 0x75, 0xea, 0x68, 0x79, 0xe5, 0xa2, 0x01, 0xfd, 0x31, 0xa0, 0xb1, 0x1f, 0x55, 0xa6, 0x1f, 0xc1, 0xf6, 0xd1, + 0x83, 0x88, 0x63, 0x26, 0x56, 0x12, 0xbc, +]; + #[derive(Debug, Clone, Copy)] enum LicenseResponse { NewLicenseRequest, @@ -47,20 +55,14 @@ impl LicenseResponse { // oracle: mutant a guard that rejects only a zero modulus, accepting a 256-bit key #[case::modulus_below_512_bits(vec![0xff; 32], false)] // oracle: spec MS-RDPBCGR §4.1.4 -#[case::modulus_of_512_bits(vec![0xff; 64], true)] +#[case::modulus_of_512_bits(SPEC_EXAMPLE_MODULUS.to_vec(), true)] fn licensing_accepts_only_moduli_of_at_least_512_bits( #[case] modulus: Vec, #[case] accepted: bool, #[values(LicenseResponse::NewLicenseRequest, LicenseResponse::LicenseInfo)] response: LicenseResponse, ) { - let license_request = license_request_with_modulus(modulus); - - assert_eq!(response.build(&license_request), accepted); -} - -/// A license request whose proprietary certificate carries `modulus`, in little-endian order without its padding. -fn license_request_with_modulus(modulus: Vec) -> ServerLicenseRequest { - ServerLicenseRequest { + // The certificate carries `modulus` in little-endian order, followed by its padding. + let license_request = ServerLicenseRequest { license_header: LicenseHeader { security_header: BasicSecurityHeader { flags: BasicSecurityHeaderFlags::LICENSE_PKT, @@ -87,5 +89,7 @@ fn license_request_with_modulus(modulus: Vec) -> ServerLicenseRequest { }), }), scope_list: Vec::new(), - } + }; + + assert_eq!(response.build(&license_request), accepted); }