diff --git a/client b/client index a46357cdd..502d9119f 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit a46357cdd5ce3de2e13471036f0545eb22cacad7 +Subproject commit 502d9119f2b4fd0f41df9967a869d0ceaee81264 diff --git a/server/src/uds/REST/methods/client.py b/server/src/uds/REST/methods/client.py index a33beb1f3..c8a89f792 100644 --- a/server/src/uds/REST/methods/client.py +++ b/server/src/uds/REST/methods/client.py @@ -28,12 +28,20 @@ """ Author: Adolfo Gómez, dkmaster at dkmon dot com """ +import base64 import logging +import re +import struct import typing from django.urls import reverse from django.utils.translation import gettext as _ +from cryptography import x509 as crypto_x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.serialization.pkcs7 import PKCS7Options, PKCS7SignatureBuilder + from uds import models from uds.core import consts, exceptions, types from uds.core.managers.crypto import CryptoManager @@ -51,6 +59,152 @@ CLIENT_VERSION: typing.Final[str] = consts.system.VERSION LOG_ENABLED_DURATION: typing.Final[int] = 2 * 60 * 60 * 24 # 2 days +# Settings that Microsoft's RDP client includes in signature verification. +# Order matters: it matches the order expected by mstsc.exe. +_RDP_SECURE_SETTINGS: typing.Final[list[tuple[str, str]]] = [ + ('full address:s:', 'Full Address'), + ('alternate full address:s:', 'Alternate Full Address'), + ('pcb:s:', 'PCB'), + ('use redirection server name:i:', 'Use Redirection Server Name'), + ('server port:i:', 'Server Port'), + ('negotiate security layer:i:', 'Negotiate Security Layer'), + ('enablecredsspsupport:i:', 'EnableCredSspSupport'), + ('disableconnectionsharing:i:', 'DisableConnectionSharing'), + ('autoreconnection enabled:i:', 'AutoReconnection Enabled'), + ('gatewayhostname:s:', 'GatewayHostname'), + ('gatewayusagemethod:i:', 'GatewayUsageMethod'), + ('gatewayprofileusagemethod:i:', 'GatewayProfileUsageMethod'), + ('gatewaycredentialssource:i:', 'GatewayCredentialsSource'), + ('support url:s:', 'Support URL'), + ('promptcredentialonce:i:', 'PromptCredentialOnce'), + ('require pre-authentication:i:', 'Require pre-authentication'), + ('pre-authentication server address:s:', 'Pre-authentication server address'), + ('alternate shell:s:', 'Alternate Shell'), + ('shell working directory:s:', 'Shell Working Directory'), + ('remoteapplicationprogram:s:', 'RemoteApplicationProgram'), + ('remoteapplicationexpandworkingdir:s:', 'RemoteApplicationExpandWorkingdir'), + ('remoteapplicationmode:i:', 'RemoteApplicationMode'), + ('remoteapplicationguid:s:', 'RemoteApplicationGuid'), + ('remoteapplicationname:s:', 'RemoteApplicationName'), + ('remoteapplicationicon:s:', 'RemoteApplicationIcon'), + ('remoteapplicationfile:s:', 'RemoteApplicationFile'), + ('remoteapplicationfileextensions:s:', 'RemoteApplicationFileExtensions'), + ('remoteapplicationcmdline:s:', 'RemoteApplicationCmdLine'), + ('remoteapplicationexpandcmdline:s:', 'RemoteApplicationExpandCmdLine'), + ('prompt for credentials:i:', 'Prompt For Credentials'), + ('authentication level:i:', 'Authentication Level'), + ('audiomode:i:', 'AudioMode'), + ('redirectdrives:i:', 'RedirectDrives'), + ('redirectprinters:i:', 'RedirectPrinters'), + ('redirectcomports:i:', 'RedirectCOMPorts'), + ('redirectsmartcards:i:', 'RedirectSmartCards'), + ('redirectposdevices:i:', 'RedirectPOSDevices'), + ('redirectclipboard:i:', 'RedirectClipboard'), + ('devicestoredirect:s:', 'DevicesToRedirect'), + ('drivestoredirect:s:', 'DrivesToRedirect'), + ('loadbalanceinfo:s:', 'LoadBalanceInfo'), + ('redirectdirectx:i:', 'RedirectDirectX'), + ('rdgiskdcproxy:i:', 'RDGIsKDCProxy'), + ('kdcproxyname:s:', 'KDCProxyName'), + ('eventloguploadaddress:s:', 'EventLogUploadAddress'), +] + + +def _sign_rdp_content(rdp_content: str) -> str: + """Sign an RDP file content using the configured server certificate. + + Replicates exactly what Microsoft's rdpsign.exe does: + 1. Extract the subset of settings covered by the signature. + 2. Build a UTF-16LE message blob (identical to what mstsc.exe verifies). + 3. Sign with CMS/PKCS#7 detached signature (DER, no attributes, no SMIME caps). + 4. Prepend the 12-byte Microsoft header and base64-encode the result. + 5. Append signscope and signature lines to the RDP content. + + Args: + rdp_content: The RDP file content as a Python string (any line-ending style). + + Returns: + Signed RDP content as a string with \\r\\n line endings. + + Raises: + Exception: If signing is not configured or no signable settings are found. + """ + sign_cert_pem = GlobalConfig.RDP_SIGN_CERT.as_str().strip() + sign_key_pem = GlobalConfig.RDP_SIGN_KEY.as_str().strip() + + if not sign_cert_pem or not sign_key_pem: + raise Exception('RDP signing certificate/key not configured in Security settings') + + cert = crypto_x509.load_pem_x509_certificate(sign_cert_pem.encode(), default_backend()) + private_key = serialization.load_pem_private_key(sign_key_pem.encode(), password=None, backend=default_backend()) + + # Optional intermediate chain + chain_certs: list[crypto_x509.Certificate] = [] + chain_pem = GlobalConfig.RDP_SIGN_CHAIN.as_str().strip() + if chain_pem: + for block in re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', chain_pem, re.DOTALL): + chain_certs.append(crypto_x509.load_pem_x509_certificate(block.encode(), default_backend())) + + # Normalise line endings and strip each line + lines = [line.strip() for line in rdp_content.replace('\r\n', '\n').replace('\r', '\n').split('\n')] + + settings: list[str] = [] + fulladdress: typing.Optional[str] = None + alternatefulladdress: typing.Optional[str] = None + + for v in lines: + if not v: + continue + if v.startswith('full address:s:'): + fulladdress = v[15:] + elif v.startswith('alternate full address:s:'): + alternatefulladdress = v[25:] + elif v.startswith('signature:s:') or v.startswith('signscope:s:'): + continue # Strip any pre-existing signature/signscope + settings.append(v) + + # Prevent address-spoofing via alternate full address + if fulladdress and not alternatefulladdress: + settings.append('alternate full address:s:' + fulladdress) + + # Collect only the settings covered by the signature, preserving order + signnames: list[str] = [] + signlines: list[str] = [] + for prefix, name in _RDP_SECURE_SETTINGS: + for v in settings: + if v.startswith(prefix): + signnames.append(name) + signlines.append(v) + break # Only first match per setting key + + if not signnames: + raise Exception('No signable settings found in RDP content') + + # Build the message blob exactly as mstsc.exe expects: UTF-16LE, CRLF, NUL-terminated + msgtext = '\r\n'.join(signlines) + '\r\n' + 'signscope:s:' + ','.join(signnames) + '\r\n' + '\x00' + msgblob = msgtext.encode('UTF-16LE') + + # Sign: equivalent to: openssl smime -sign -binary -noattr -nosmimecap -outform DER + # Note: NoAttributes is a superset of NoCapabilities in the Python API. + builder = PKCS7SignatureBuilder().set_data(msgblob).add_signer(cert, private_key, hashes.SHA256()) + for chain_cert in chain_certs: + builder = builder.add_certificate(chain_cert) + sig_der = builder.sign( + serialization.Encoding.DER, + [PKCS7Options.Binary, PKCS7Options.NoAttributes], + ) + + # Prepend the 12-byte Microsoft header (two unknown DWORDs + length DWORD) + msgsig = struct.pack(' dict[str, typing.Any]: return Client.result(error=types.errors.Error.ACCESS_DENIED) if scrambler == "rdp_signature": - # TODO: Signt and return the RDP signed by our server cert - pass + # Return signing certificate info so the client can install it in the trust store. + # The actual RDP signing is done via POST (see post() handler). + sign_cert = GlobalConfig.RDP_SIGN_CERT.as_str().strip() + chain = GlobalConfig.RDP_SIGN_CHAIN.as_str().strip() + if not sign_cert: + return Client.result(error='RDP signing not configured') + return Client.result(result={'cert': sign_cert, 'chain': chain}) self._request.user = User.objects.get(uuid=data['user']) @@ -192,6 +351,21 @@ def process(self, ticket: str, scrambler: str) -> dict[str, typing.Any]: validity=60 * 60 * 24, ) + # Always create a short-lived signing ticket so transport scripts can + # request server-side RDP signing via POST /{sign_ticket}/rdp_signature. + sign_ticket = TicketStore.create( + { + 'user': self._request.user.uuid, + 'userservice': info.userservice.uuid, + 'type': 'rdp_sign', + }, + validity=60 * 60, # 1 hour – enough for any reasonable session + ) + # Inject into transport parameters so scripts can access sp['sign_ticket'] + params_with_sign = dict(transport_script.parameters) + params_with_sign['sign_ticket'] = sign_ticket + transport_script.parameters = params_with_sign + return Client.result( result={ 'script': transport_script.script, @@ -233,6 +407,20 @@ def post(self) -> dict[str, typing.Any]: self._request.user = User.objects.get(uuid=data['user']) + # Handle rdp_signature before we require a userservice + if command == 'rdp_signature': + if data.get('type') != 'rdp_sign': + return Client.result(error='Invalid ticket type for RDP signing') + rdp_content: str = self._params.get('rdp', '') + if not rdp_content: + return Client.result(error='Missing RDP content') + try: + signed = _sign_rdp_content(rdp_content) + return Client.result(result=signed) + except Exception as e: + logger.exception('RDP signing failed') + return Client.result(error=str(e)) + try: userservice = models.UserService.objects.get(uuid=data['userservice']) except models.UserService.DoesNotExist: diff --git a/server/src/uds/core/util/config.py b/server/src/uds/core/util/config.py index 9527d1268..570232483 100644 --- a/server/src/uds/core/util/config.py +++ b/server/src/uds/core/util/config.py @@ -802,7 +802,27 @@ class GlobalConfig: type=Config.FieldType.TEXT, help=_('URL to leave cookies consent'), ) - + + # RDP file signing + RDP_SIGN_CERT: Config.Value = Config.section(Config.SectionType.SECURITY).value( + 'RDP Signing Certificate', + '', + type=Config.FieldType.LONGTEXT, + help=_('PEM-encoded certificate used to sign .rdp files (leaf certificate)'), + ) + RDP_SIGN_KEY: Config.Value = Config.section(Config.SectionType.SECURITY).value( + 'RDP Signing Private Key', + '', + type=Config.FieldType.LONGTEXT, + help=_('PEM-encoded private key corresponding to the RDP signing certificate'), + ) + RDP_SIGN_CHAIN: Config.Value = Config.section(Config.SectionType.SECURITY).value( + 'RDP Signing Certificate Chain', + '', + type=Config.FieldType.LONGTEXT, + help=_('PEM-encoded intermediate certificate chain for RDP signing (optional, omit for self-signed certs)'), + ) + @staticmethod def is_initialized() -> bool: diff --git a/server/src/uds/transports/RDP/scripts/windows/direct.py b/server/src/uds/transports/RDP/scripts/windows/direct.py index 1d8cc9247..db90d9de5 100644 --- a/server/src/uds/transports/RDP/scripts/windows/direct.py +++ b/server/src/uds/transports/RDP/scripts/windows/direct.py @@ -44,7 +44,8 @@ # The password must be encoded, to be included in a .rdp file, as 'UTF-16LE' before protecting (CtrpyProtectData) it in order to work with mstsc theFile = sp['as_file'].format(password=password) # type: ignore -theFile = tools.sign_rdp(theFile) +if sp.get('sign_ticket'): # type: ignore + theFile = tools.sign_rdp(theFile, api, sp['sign_ticket']) # type: ignore filename = tools.saveTempFile(theFile) diff --git a/server/src/uds/transports/RDP/scripts/windows/direct.py.signature b/server/src/uds/transports/RDP/scripts/windows/direct.py.signature index 57fe8d75a..59754c1c0 100644 --- a/server/src/uds/transports/RDP/scripts/windows/direct.py.signature +++ b/server/src/uds/transports/RDP/scripts/windows/direct.py.signature @@ -1 +1 @@ -b0NrFPNRgkQmGycaL/gUhFKShW34N3Yto33JyDT9ructKOTEzT8qCEnvp5ypb0vwZQBhCfya0ExGDO77DdRPb2QAvtQylPxaX+D7FdLAKZO8WOw+clCJGJHFlpiAi7a1lY0ve6dfJotu47vNppmOy5RGO1Iz9FMQuOpq0xNXcrGz9I5zez47Se0FkhU1XlYgrrI8uexQqc8faz+nw6fJE4ADnfqo+b6mJmRIm7gbE9VyMZz6NR65zqtcyOWgmRrDOO3w6dirEYOIES2GFfZXOl4L+5bIDTVbtrYGoTtPIgmom8fjFfOP2qWAhjQ7jsjDqC0pPshOlNqB4FyORoAEzQ10yt53bPJHaOe/9uzW75THNGCj8AVntzbLGDghdJG49Yv9gAxJPFpdkGhtesy92Q0pryDjtTtLBtTyWvj9iCpUremYp71tROFHdEY40ypG7YDmDHNdkK6vz99MsFwHpcjs9XnHAJlaJHy96FdI6dHBC4ePlaJSVABOb9SS74WyYVB/VOF6bZ55mbvD7XpzzsG7fk/JV6If047tULGnCdWJCvOZ05rI0H1nUJAwgg42VmOKxNKJnBKdP0hVPuvRg2L2pNDioocXxnXvYfUWBr6bq/6Vkv/qrkkkWy+XMhTSGD9nwskhpFdOMNfjeelr50bSGcl2QGzEO2SnKzrfdTo= \ No newline at end of file +oSbVri3AmjaK9ENnCz/AEOKHiAy4aHID3K/oeAvFbW/VFw9Uu/519EWdixUsJWXi/H5ICQXIVW3fZsWImIsGx7bnymNDtSCTHTsYq35aExYm8h19wxFNdQlFOaNkwdRBF4Glj7V3FWdElIyoe+V2ciqhBiaBTMIBLtoePf6i45OpQd1OoHffAQfkB73fR9D/z4CANlllLXdq39sAFxtqQBIMMQbaEZAbNEJ1RwLVtpGq90tC/2TEZqg+yvV6UKA2NzsELQOL2dN0QwCK5FRLZsxZGGXn4Uh7rQNZNlTWcC53thzBG1Ji6Xm3psyeocSM0TFQNMHY7j6KDK8z8JP5A/jU1RWwW46MpJkfEykCZXDAkWKg/KrZcEpz80C1/yqEmBcAvvdR9W7xZkwgTk60WOm7a8gYNt1fEKMetmW2cZhWQrHTK7uD6jro1of43XmuMZZCwiVvk7MaVxn0AwYerIT4TS2hltWV8iWPVL7zcymsvYXT5unfPEWqp2ZQylVIkOiGrTRh31jPWGdeJbfMpbMIiluyy855Wy49aNVT5Phc+YRUVqZS0lQerafBenC/lvotBg4Jn9OWmHiLkwes8L54bIq91c163z4EzkzQhhBTN4SKSN8KQcZdu78EsbQIOyMZd1JGbtQWq8CFT3ER2KykB484FBlesnxxEn4jbGo= \ No newline at end of file diff --git a/server/src/uds/transports/RDP/scripts/windows/tunnel.py b/server/src/uds/transports/RDP/scripts/windows/tunnel.py index dec778744..0a511845f 100644 --- a/server/src/uds/transports/RDP/scripts/windows/tunnel.py +++ b/server/src/uds/transports/RDP/scripts/windows/tunnel.py @@ -45,7 +45,8 @@ password=password, address='127.0.0.1:{}'.format(fs.server_address[1]) ) -theFile = tools.sign_rdp(theFile) +if sp.get('sign_ticket'): # type: ignore + theFile = tools.sign_rdp(theFile, api, sp['sign_ticket']) # type: ignore filename = tools.saveTempFile(theFile) executable = tools.findApp('mstsc.exe') diff --git a/server/src/uds/transports/RDP/scripts/windows/tunnel.py.signature b/server/src/uds/transports/RDP/scripts/windows/tunnel.py.signature index 1c70a1024..6c832138e 100644 --- a/server/src/uds/transports/RDP/scripts/windows/tunnel.py.signature +++ b/server/src/uds/transports/RDP/scripts/windows/tunnel.py.signature @@ -1 +1 @@ -kG2nEc2XkOI45ag2onfRbTovNND1L5CMWi7XI6+S2Rl35A64NBhDJL5sr3yKh6nLMQ+xPE7aYARPJ15tzboR9LG5fUNcubwHqtexT/NBc6eV2tEbGskSCutpwh7lgyqb2nH4B51u0JIoJbKDuF09L+wk/yWJrTvNBfjW7RZ19f9zxBYO0MCoDl3pnBgX8IRn0wyu84PhY+78NOodOtAx3DuYlsa4i5aQ4Wq2bFsMGjS+gfd43ybn0gQIPhC9U/6QQ3Jeh49Ylw4p8iqdr/FDLsbSyvDh3cTKPP/kkaQfL/muqCXm7X3eUx434aoHM15NY3F7kNsUu34tX9ZL5wwivlf3kq09ygrkZUddPEt9/8YThHbPD5OpBeuYD30ofoYKQidlVj/w8uonPPo9cDpOxf26k673J53I24J2sD2yQ/7ouH4aXAsNTIVCd5iCSZuyOGf+6UArMW20SPNORaNwh0qdts8LumQo0latWgbphVKbtM/2XQA/v+g43olplzGJjlogVYC6L3E78L+1OjXcqXdGCcC/9y07M65sbDIH6X6ertP73daaZa8kHiFwJV+KFpaKLC1KeicgVV1rCb3VxcYkIlLIw0lXH7XQb2vMG9Aqag5JaYOyE3EKay8Ec/uxxow4w32EMGBVWd6VPUb/3NB3JcmjwY/DwgJUcdzJ6O4= \ No newline at end of file +VuCtUDBoBGqtF64yAKZwHfkFyTsUp6LbUntVOxa6tTeGnhC/qjiO4n/7gB4NvlYf480LMowOXFGTBb655b96XBMwj/iZSNcPMPwpcou5jVJg5Tyq4u3E98ewvb4hXvmfV0HWK8LQgXWMO0cWkuC4Bv8OsETbzeY2z+MVOvxaiX4SPjVDGQb7zCvJxd6k82ltYQcA7q/LWJMReCDVEoHded4aoK6l64ncUP89CXmHu5rSvH78wb0hJBnWwSjJ3o7yrekM4+OYXiiB9YXft/0+OFbZVf2FbWQm1r9D186rlpuQ+vY001gdhqkleZy891YlKwR1RXPrOZkYM4/8Fqv0KCQX4k3w3p9sg7rWTHNiedIzebwE5pxHvhsZUz6vxYJIr+RSudt5GlOm5pNsIeUW6RV2hHqjOnFAnpf3fGb81j3rDudBUvK/BsonU2rLTA1APC0vaYH3ByyGOoWLGWELXlbVLNoBgXDBevMGFc7WrohTgbyJH5CR25U5llJqtZQHBAfF+1ArcQbsfW2C5oQY/lLGtSPLs2gk+LV9QVIfMwLHmkWGA+p+6n8nSTEud8sCWXiR6+45st/KNIykRwPW/TjQd8Dj1f+A1AUCwKc4OpnTlpiLkfPkfodwy5FEK7jxmGWgr1+Ehj2jnyQ4t9T847cSHqxhv3LGlnCPaFzqY5c= \ No newline at end of file