Skip to content
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

Feature(#94) Add Dynamic Certificate Cloning Support #243

Merged
merged 5 commits into from
Sep 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ For a detailed view of what has changed, refer to the {uri-repo}/commits/master[

=== Enhancements

* Added support for dynamic certificate cloning when no certificate is specified. ({uri-issue}94[#94])
* `pyrdp-mitm` now carves and saves files transferred via clipboard ({uri-issue}100[#100])
* Introduced the `pyrdp-convert` tool to convert between pcaps, PyRDP replay files and MP4 video files.
Read link:README.md#using-pyrdp-convert[its section in the README for details].
Expand Down
80 changes: 66 additions & 14 deletions pyrdp/core/ssl.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
#
# Copyright (c) 2014-2015 Sylvain Peyrefitte
# Copyright (c) 2014-2020 Sylvain Peyrefitte
# Copyright (c) 2020 GoSecure Inc.
#
# This file is part of rdpy.
# This file is part of the PyRDP project.
#
# rdpy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Licensed under the GPLv3 or later.
#

from os import path

import OpenSSL
from OpenSSL import SSL

from twisted.internet import ssl


Expand Down Expand Up @@ -60,3 +54,61 @@ def __init__(self, method):
# See comment in ClientTLSContext
ssl.DefaultOpenSSLContextFactory.__init__(self, privateKeyFileName, certificateFileName, SSL.SSLv23_METHOD,
TPDUSSLContext)


class CertificateCache():
"""
Handle multiple certificates.
"""

def __init__(self, cachedir, log):
self._root = cachedir
self.log = log

def clone(self, cert: OpenSSL.crypto.X509) -> (OpenSSL.crypto.PKey, OpenSSL.crypto.X509):
"""Clone the provided certificate."""

# Generate a private key for the server.
key = OpenSSL.crypto.PKey()
key.generate_key(OpenSSL.crypto.TYPE_RSA, cert.get_pubkey().bits())

# Actual type is str, but this prevents warnings
digestAlgorithm: bytes = cert.get_signature_algorithm().decode()

# Force digest algorithm to be sha256
if digestAlgorithm in ["md4", "md5"]:
digestAlgorithm = "sha256"

cert.set_pubkey(key)
cert.sign(key, digestAlgorithm)

return key, cert

def lookup(self, cert: OpenSSL.crypto.X509) -> (str, str):
subject = cert.get_subject()
parts = dict(subject.get_components())
commonName = parts[b'CN'].decode()
base = str(self._root / commonName)

if path.exists(base + '.pem'):
self.log.info('Using cached certificate for %s', commonName)

# Recover cache entry from disk.
privKey = base + '.pem'
certFile = base + '.crt'
return privKey, certFile
else:
priv, cert = self.clone(cert)
privKey = base + '.pem'
certFile = base + '.crt'

# Save Certificate to disk
with open(certFile, "wb") as f:
f.write(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert))

with open(privKey, "wb") as f:
f.write(OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, priv))

self.log.info('Cloned server certificate to %s', certFile)

return privKey, certFile
57 changes: 45 additions & 12 deletions pyrdp/mitm/RDPMITM.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
#
# This file is part of the PyRDP project.
# Copyright (C) 2019 GoSecure Inc.
# Copyright (C) 2019-2020 GoSecure Inc.
# Licensed under the GPLv3 or later.
#

import asyncio
import datetime
import typing

from twisted.internet import reactor
from twisted.internet.protocol import Protocol

from pyrdp.core import AsyncIOSequencer, AwaitableClientFactory, connectTransparent
from pyrdp.core.ssl import ClientTLSContext, ServerTLSContext
from pyrdp.core.ssl import ClientTLSContext, ServerTLSContext, CertificateCache
from pyrdp.enum import MCSChannelName, ParserMode, PlayerPDUType, ScanCode, SegmentationPDUType
from pyrdp.layer import ClipboardLayer, DeviceRedirectionLayer, LayerChainItem, RawLayer, \
VirtualChannelLayer
Expand Down Expand Up @@ -137,9 +138,12 @@ def __init__(self, mainLogger: SessionLogger, crawlerLogger: SessionLogger, conf

self.player.player.addObserver(LayerLogger(self.attackerLog))

self.config.outDir.mkdir(parents=True, exist_ok=True)
self.config.replayDir.mkdir(exist_ok=True)
self.config.fileDir.mkdir(exist_ok=True)
self.ensureOutDir()

if config.certificateFileName is None:
self.certs: CertificateCache = CertificateCache(self.config.certDir, mainLogger.createChild("cert"))
else:
self.certs = None

self.state.securitySettings.addObserver(RC4LoggingObserver(self.rc4Log))

Expand Down Expand Up @@ -213,17 +217,41 @@ async def connectToServer(self):
except asyncio.TimeoutError:
self.log.error("Failed to connect to recording host: timeout expired")

def startTLS(self):
def doClientTls(self):
cert = self.server.tcp.transport.getPeerCertificate()
if not cert:
# Wait for server certificate
reactor.callLater(1, self.doClientTls)

# Clone certificate if necessary.
if self.certs:
privKey, certFile = self.certs.lookup(cert)
contextForClient = ServerTLSContext(privKey, certFile)
else:
# No automated certificate cloning. Use the specified certificate.
contextForClient = ServerTLSContext(self.config.privateKeyFileName, self.config.certificateFileName)

# Establish TLS tunnel with the client
self.onTlsReady()
self.client.tcp.startTLS(contextForClient)
self.onTlsReady = None

# Add unknown packet handlers.
self.client.segmentation.addObserver(PacketForwarder(self.server.tcp))
self.server.segmentation.addObserver(PacketForwarder(self.client.tcp))

def startTLS(self, onTlsReady: typing.Callable[[], None]):
"""
Execute a startTLS on both the client and server side.
"""
contextForClient = ServerTLSContext(self.config.privateKeyFileName, self.config.certificateFileName)
contextForServer = ClientTLSContext()
self.onTlsReady = onTlsReady

self.client.tcp.startTLS(contextForClient)
# Establish TLS tunnel with target server...
contextForServer = ClientTLSContext()
self.server.tcp.startTLS(contextForServer)
self.client.segmentation.addObserver(PacketForwarder(self.server.tcp))
self.server.segmentation.addObserver(PacketForwarder(self.client.tcp))

# Establish TLS tunnel with client.
reactor.callLater(1, self.doClientTls)

def buildChannel(self, client: MCSServerChannel, server: MCSClientChannel):
"""
Expand Down Expand Up @@ -406,7 +434,6 @@ def enableForwarding():
self.state.forwardInput = True
self.state.forwardOutput = True


payload = sendPayload()
sequencer = AsyncIOSequencer([
waitForDelay,
Expand All @@ -420,3 +447,9 @@ def enableForwarding():
enableForwarding
])
sequencer.run()

def ensureOutDir(self):
self.config.outDir.mkdir(parents=True, exist_ok=True)
self.config.replayDir.mkdir(exist_ok=True)
self.config.fileDir.mkdir(exist_ok=True)
self.config.certDir.mkdir(exist_ok=True)
9 changes: 6 additions & 3 deletions pyrdp/mitm/X224MITM.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@


class X224MITM:
def __init__(self, client: X224Layer, server: X224Layer, log: LoggerAdapter, state: RDPMITMState, connector: typing.Coroutine, startTLSCallback: typing.Callable[[], None]):
def __init__(self, client: X224Layer, server: X224Layer, log: LoggerAdapter, state: RDPMITMState, connector: typing.Coroutine, startTLSCallback: typing.Callable[[typing.Callable[[], None]], None]):
"""

:param client: X224 layer for the client side
Expand Down Expand Up @@ -112,13 +112,16 @@ def onConnectionConfirm(self, pdu: X224ConnectionConfirmPDU):
payload = pdu.payload
else:
payload = parser.write(NegotiationResponsePDU(NegotiationType.TYPE_RDP_NEG_RSP, 0x00, response.selectedProtocols))
self.client.sendConnectionConfirm(payload, source=0x1234)

# FIXME: This should be done based on what authentication method the server selected, not on what
# the client supports.
if self.originalRequest.tlsSupported:
self.startTLSCallback()
# If a TLS tunnel is requested, then we establish the server-side tunnel before
# replying to the client, so that we can clone the certificate if needed.
self.startTLSCallback(lambda: self.client.sendConnectionConfirm(payload, source=0x1234))
self.state.useTLS = True
else:
self.client.sendConnectionConfirm(payload, source=0x1234)

def onClientDisconnectRequest(self, pdu: X224DisconnectRequestPDU):
self.server.sendPDU(pdu)
Expand Down
67 changes: 6 additions & 61 deletions pyrdp/mitm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ def validateKeyAndCertificate(private_key: str, certificate: str) -> Tuple[str,
if (private_key is None) != (certificate is None):
sys.stderr.write("You must provide both the private key and the certificate")
sys.exit(1)
elif private_key is None:
key, cert = getSSLPaths()
handleKeyAndCertificate(key, cert)

if private_key is None:
# Certificates will be generated automatically.
return None, None
else:
key, cert = private_key, certificate

Expand All @@ -65,62 +66,6 @@ def validateKeyAndCertificate(private_key: str, certificate: str) -> Tuple[str,
return key, cert


def handleKeyAndCertificate(key: str, certificate: str):
"""
Handle the certificate and key arguments that were given on the command line.
:param key: path to the TLS private key.
:param certificate: path to the TLS certificate.
"""

from pyrdp.logging import LOGGER_NAMES
logger = logging.getLogger(LOGGER_NAMES.MITM)

if os.path.exists(key) and os.path.exists(certificate):
logger.info("Using existing private key: %(privateKey)s", {"privateKey": key})
logger.info("Using existing certificate: %(certificate)s", {"certificate": certificate})
else:
logger.info("Generating a private key and certificate for SSL connections")

if generateCertificate(key, certificate):
logger.info("Private key path: %(privateKeyPath)s", {"privateKeyPath": key})
logger.info("Certificate path: %(certificatePath)s", {"certificatePath": certificate})
else:
logger.error("Generation failed. Please provide the private key and certificate with -k and -c")


def getSSLPaths() -> (str, str):
"""
Get the path to the TLS key and certificate in pyrdp's config directory.
:return: the path to the key and the path to the certificate.
"""

if not os.path.exists(settings.CONFIG_DIR):
os.makedirs(settings.CONFIG_DIR)

key = settings.CONFIG_DIR + "/private_key.pem"
certificate = settings.CONFIG_DIR + "/certificate.pem"
return key, certificate


def generateCertificate(keyPath: str, certificatePath: str) -> bool:
"""
Generate an RSA private key and certificate with default values.
:param keyPath: path where the private key should be saved.
:param certificatePath: path where the certificate should be saved.
:return: True if generation was successful
"""

if os.name != "nt":
nullDevicePath = "/dev/null"
else:
nullDevicePath = "NUL"

result = os.system("openssl req -newkey rsa:2048 -nodes -keyout %s -x509"
" -days 365 -out %s -subj \"/CN=www.example.com/O=PYRDP/C=US\" 2>%s" %
(keyPath, certificatePath, nullDevicePath))
return result == 0


def showConfiguration(config: MITMConfig):
logging.getLogger(LOGGER_NAMES.MITM).info("Target: %(target)s:%(port)d", {
"target": config.targetHost, "port": config.targetPort})
Expand All @@ -138,8 +83,8 @@ def buildArgParser():
" not sent over the network.")
parser.add_argument("-d", "--destination-port",
help="Listening port of the PyRDP player (default: 3000).", default=3000)
parser.add_argument("-k", "--private-key", help="Path to private key (for SSL)")
parser.add_argument("-c", "--certificate", help="Path to certificate (for SSL)")
parser.add_argument("-k", "--private-key", help="Specify path to private key (for SSL)")
parser.add_argument("-c", "--certificate", help="Specify path to certificate (for SSL).")
parser.add_argument("-u", "--username", help="Username that will replace the client's username", default=None)
parser.add_argument("-p", "--password", help="Password that will replace the client's password", default=None)
parser.add_argument("-L", "--log-level", help="Console logging level. Logs saved to file are always verbose.",
Expand Down
7 changes: 7 additions & 0 deletions pyrdp/mitm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ def fileDir(self) -> Path:
"""
return self.outDir / "files"

@property
def certDir(self) -> Path:
"""
Get the directory for dynamically generated certificates.
"""
return self.outDir / "certs"


"""
The default MITM configuration.
Expand Down