diff --git a/client b/client index 5b044bca3..181157771 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 5b044bca346ac0e5383ae593cf3d978903652acf +Subproject commit 1811577718a171532e2dc78aed7d586b96e9ec7b diff --git a/server/src/uds/REST/methods/client.py b/server/src/uds/REST/methods/client.py index 16daac372..f785b2fef 100644 --- a/server/src/uds/REST/methods/client.py +++ b/server/src/uds/REST/methods/client.py @@ -36,6 +36,7 @@ from uds import models from uds.core import consts, exceptions, types +from uds.core.managers import crypto from uds.core.managers.crypto import CryptoManager from uds.core.managers.userservice import UserServiceManager from uds.core.exceptions.services import ServiceNotReadyError @@ -107,6 +108,16 @@ def test(self) -> dict[str, typing.Any]: """ return Client.result(_('Correct')) + def sign_rdp(self, rdp: str) -> dict[str, typing.Any]: + try: + logger.debug('Signing RDP (input):\n%s', rdp) + signed = crypto.CryptoManager.manager().sign_rdp(rdp) + logger.debug('Signed RDP (output):\n%s', signed) + return Client.result(signed) + except Exception as e: + logger.exception('Error signing RDP') + return Client.result(error=str(e)) + def process(self, ticket: str, scrambler: str) -> dict[str, typing.Any]: info: typing.Optional[types.services.UserServiceInfo] = None hostname = self._params.get('hostname', '') # Or if hostname is not included... @@ -251,6 +262,8 @@ def post(self) -> dict[str, typing.Any]: except Exception: # If something goes wrong, log it as debug pass + case 'rdp_signature': + return self.sign_rdp(self._params.get('rdp') or '') case _: return Client.result(error='Invalid command') diff --git a/server/src/uds/core/managers/crypto.py b/server/src/uds/core/managers/crypto/__init__.py similarity index 98% rename from server/src/uds/core/managers/crypto.py rename to server/src/uds/core/managers/crypto/__init__.py index dbd738547..b72c53744 100644 --- a/server/src/uds/core/managers/crypto.py +++ b/server/src/uds/core/managers/crypto/__init__.py @@ -54,6 +54,7 @@ from django.conf import settings from uds.core.util import singleton +from . import rdp logger = logging.getLogger(__name__) @@ -340,3 +341,10 @@ def sha(self, value: typing.Union[str, bytes]) -> str: value = value.encode() return hashlib.sha3_256(value).hexdigest() + + # RDP related + def sign_rdp(self, data: str) -> str: + """ + Signs the data using the key and returns the signature. + """ + return rdp.sign_rdp(data) \ No newline at end of file diff --git a/server/src/uds/core/managers/crypto/rdp.py b/server/src/uds/core/managers/crypto/rdp.py new file mode 100644 index 000000000..3fa5f67e1 --- /dev/null +++ b/server/src/uds/core/managers/crypto/rdp.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2012-2023 Virtual Cable S.L.U. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +Author: Adolfo Gómez, dkmaster at dkmon dot com +""" +# Read key & server from /etc/certs{key,server}.pem (read from config file)) +from django.conf import settings + +# --- RDP Secure Settings (order matters for mstsc.exe) --- +_RDP_SECURE_SETTINGS = [ + ('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'), +] + + +import base64 +import struct +import typing +import logging +from cryptography.hazmat.primitives.serialization import pkcs7, Encoding +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.backends import default_backend +from cryptography import x509 + +logger = logging.getLogger(__name__) + +def _load_cert_key_chain(): + """ + Load certificate, key and chain from global configuration. + """ + server_pem_path = getattr(settings, 'RDP_SIGN_CERT', '/etc/certs/server.pem') + key_pem_path = getattr(settings, 'RDP_SIGN_KEY', '/etc/certs/key.pem') + with open(server_pem_path, 'r') as f: + pem_data = f.read() + # Split all certificate blocks + cert_blocks = pem_data.split('-----END CERTIFICATE-----') + certs = [] + for block in cert_blocks: + block = block.strip() + if block: + block += '\n-----END CERTIFICATE-----\n' + certs.append(block) + if not certs: + raise ValueError("No certificates found in server.pem") + # First block is the leaf, the rest is the chain + cert = x509.load_pem_x509_certificate(certs[0].encode(), default_backend()) + chain = [x509.load_pem_x509_certificate(c.encode(), default_backend()) for c in certs[1:]] if len(certs) > 1 else [] + with open(key_pem_path, 'rb') as f: + key_pem = f.read() + key = serialization.load_pem_private_key(key_pem, password=None, backend=default_backend()) + return cert, key, chain + +def sign_rdp_settings(settings_lines: typing.List[str], cert=None, key=None, chain=None) -> typing.Tuple[str, typing.List[str]]: + """ + Sign the RDP configuration lines and return (base64_signature, signnames). + """ + # Filter and order the lines to sign + signlines = [] + signnames = [] + for k, name in _RDP_SECURE_SETTINGS: + for line in settings_lines: + if line.startswith(k): + signnames.append(name) + signlines.append(line) + + msgtext = '\r\n'.join(signlines) + '\r\nsignscope:s:' + ','.join(signnames) + '\r\n' + '\x00' + msgblob = msgtext.encode('utf-16le') + + if cert is None or key is None: + cert, key, chain = _load_cert_key_chain() + + # Use PKCS7 to sign, including the chain if present + builder = pkcs7.PKCS7SignatureBuilder().set_data(msgblob) + builder = builder.add_signer(cert, key, hashes.SHA256()) + if chain: + for c in chain: + builder = builder.add_certificate(c) + signature = builder.sign(Encoding.DER, [pkcs7.PKCS7Options.DetachedSignature, pkcs7.PKCS7Options.Binary]) + + # Add 12-byte header as rdpsign.exe does + msgsig = struct.pack(' str: + """ + Sign a complete RDP file (text) and return the resulting .rdp with + the signscope:s: and signature:s: lines appended at the end. + """ + # Strip previous signature and signscope lines and empty lines + lines = [ + l.strip() + for l in rdp_text.splitlines() + if l.strip() and not l.startswith('signature:s:') and not l.startswith('signscope:s:') + ] + # If alternate full address is missing, add it + fulladdress = None + alternatefulladdress = None + for l in lines: + if l.startswith('full address:s:'): + fulladdress = l[15:] + elif l.startswith('alternate full address:s:'): + alternatefulladdress = l[25:] + if fulladdress and not alternatefulladdress: + lines.append('alternate full address:s:' + fulladdress) + + sigval, signnames = sign_rdp_settings(lines, cert, key, chain) + lines.append('signscope:s:' + ','.join(signnames)) + lines.append('signature:s:' + sigval) + return '\r\n'.join(lines) + '\r\n' diff --git a/server/src/uds/transports/RDP/rdp.py b/server/src/uds/transports/RDP/rdp.py index 2589b512e..0ad292930 100644 --- a/server/src/uds/transports/RDP/rdp.py +++ b/server/src/uds/transports/RDP/rdp.py @@ -37,6 +37,7 @@ from django.utils.translation import gettext_noop as _ from uds.core import types +from uds.models.ticket_store import TicketStore from .rdp_base import BaseRDPTransport from .rdp_file import RDPFile @@ -150,12 +151,26 @@ def get_transport_script( # pylint: disable=too-many-locals r.enforced_shares = self.enforce_drives.value r.redir_usb = self.allow_usb_redirection.value + # ticket_for_sign = TicketStore.create(None) + + ticket_for_sign = TicketStore.create( + { + 'user': userservice.user.uuid if userservice.user else None, + 'userservice': userservice.uuid, + 'type': 'rdp', + }, + validity=30, + ) + + logger.debug('Created ticket for RDP signing: %s', ticket_for_sign) + sp: collections.abc.MutableMapping[str, typing.Any] = { 'password': ci.password, 'this_server': request.build_absolute_uri('/'), 'ip': ip, 'port': self.rdp_port.value, # As string, because we need to use it in the template 'address': r.address, + 'ticket_sign': ticket_for_sign, } if os.os == types.os.KnownOS.WINDOWS: diff --git a/server/src/uds/transports/RDP/rdptunnel.py b/server/src/uds/transports/RDP/rdptunnel.py index 252fbc4fb..b55c5a11f 100644 --- a/server/src/uds/transports/RDP/rdptunnel.py +++ b/server/src/uds/transports/RDP/rdptunnel.py @@ -183,6 +183,15 @@ def get_transport_script( # pylint: disable=too-many-locals r.enforced_shares = self.enforce_drives.value r.redir_usb = self.allow_usb_redirection.value + ticket_for_sign = TicketStore.create( + { + 'user': userservice.user.uuid if userservice.user else None, + 'userservice': userservice.uuid, + 'type': 'rdp', + }, + validity=30, + ) + sp: collections.abc.MutableMapping[str, typing.Any] = { 'tunHost': tunnel_host, 'tunPort': tunnel_port, @@ -192,6 +201,7 @@ def get_transport_script( # pylint: disable=too-many-locals 'password': ci.password, 'this_server': request.build_absolute_uri('/'), 'tunnel_key': key, + 'ticket_sign': ticket_for_sign, } if os.os == types.os.KnownOS.WINDOWS: diff --git a/server/src/uds/transports/RDP/scripts/windows/direct.py b/server/src/uds/transports/RDP/scripts/windows/direct.py index c6fe53e80..b5d58751e 100644 --- a/server/src/uds/transports/RDP/scripts/windows/direct.py +++ b/server/src/uds/transports/RDP/scripts/windows/direct.py @@ -43,6 +43,9 @@ # 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, api, sp['ticket_sign']) # type: ignore + filename = tools.saveTempFile(theFile) executable = tools.findApp('mstsc.exe') 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..9dfa4a390 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 +imyDe6MtGTaU9Pc8piU/4+xXa1hO9t5anGyUnuIqKSQenEc2ZxYZEGY6G6UDf/hLINsTt67T31avJouB4jAZmo2jY3squ+b1CD2aecZHyzHb270Yv+ieNQnSrPic7niU/EWhaG+bZw+AdB8LkbVaaZisIyn9SEWoYD8I1lZTUyHtL7lIRdXBtP/XcVxLx1/nXSbunfYMShFZu4pqk6cX5jnopFcnPF/S4ETq0Xv2ehXP6eeYeE5dkSOPoM51swXbkdK9mPfeoM136BfB+Nz6gma1jmsxT6hCcn0eYQ/hev+/JaP29HI3SN760cP8vc+asSDIwJmMXB7jhrLjK8qF0LtLPw7iSQaKLbd9yZhLT6DJHbf63dE9lCI6UCPhQIRG+dSv7rSKw3bvWZakZEKcenn1AvRVi/ZEfaS1Gy6sgNund+u0P+NSzhh4fZ41PMfqbY71O0QfozzS/W2dyv4EowBawcrAXMp+N/k8zeUhimy+v8LAC2xQw5tkqZFGmZ/2aNukoQoh3Dpsl1muQF0rY38qzYy4dcQsqyIM8QCcBNY8q+c/C2Wgio64vNMW36jKPJ0bCtnsgToXrzGB3hzEB/IA+RrWd5pXPspIrmkZQy4g3supiMkt/9xdMRdajZKGUTQcNQjvXbMPhqbt1nx9iWF+Bba7+o7Lq1aCgozyw54= \ 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 ef387b2a1..887a083d3 100644 --- a/server/src/uds/transports/RDP/scripts/windows/tunnel.py +++ b/server/src/uds/transports/RDP/scripts/windows/tunnel.py @@ -45,6 +45,8 @@ password=password, address='127.0.0.1:{}'.format(fs.server_address[1]) ) +theFile = tools.sign_rdp(theFile, api, sp['ticket_sign']) # type: ignore + filename = tools.saveTempFile(theFile) executable = tools.findApp('mstsc.exe') if executable is None: 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..ed8a0c1ef 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 +nUA3jqSOt+fqacp11hAcsBOJp7ffb4Hdj9IBUos7fX8VI+nnTpV75cL1eAJYH6sU3cq7t+IETId2Wld5pvh7l2CnY3c0Jg+R44rZaGxVKne7iSKz92RAqEz9fkpxr9nZ9mnAf0feN3NF+eF+fEXE90FsmGHR0K4os/8JHgfYeDpQNK6A4F8vq20w+BM4Y+yuokTkv0krkY5X64ofgX/Qo5mOApcSyiyVIuPfX9e5H53mMPPHJiBI1LnwYq8B9YVH2f6HEx+tVZW0a539v9e6vq4muSSjNzYu/6cHEMLEdicBjYTD7R3X2uPOlMlZnoUeZUsaZReTWe1XuVzLIe739+4q36y1HT9VUMznzAxq+x66u6LdQPJfShVZ9dX27WEDZwN1wY41nh/VcG6mlnHqWYRFSk1lRRx7eibZJfz9spJG6RhHQDNj3pMD8YF+SYeDQgYmREggVicSSG2sDfnfNNO+3xwSC7SaObYLajiNazNZOJUvvjuRI3KTOedluOxFbvQi1Q4CHCmHRqpE29ysMg3+dkB0GN6sSNB6fbvPif6ge22r/jFCiXO8WueYcZwI4S8aSXlxP8lG2KNnnN8JN8fs03yFvMeZHtRQDoFoDErcBeNGVZlKnHPIaubH5Lj9phDL3YjsSVLR6rFvc6pApbnMI4FyySQRF54HTgXBaFQ= \ No newline at end of file