Skip to content

Commit

Permalink
Merge pull request #177 from GoSecure/twistd-support
Browse files Browse the repository at this point in the history
Added a twisted plugin
  • Loading branch information
obilodeau authored Jan 23, 2020
2 parents 61fd8b2 + 2a6cb93 commit 8fd50ab
Show file tree
Hide file tree
Showing 8 changed files with 337 additions and 190 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ docs/*.html

# pyrdp-specific
pyrdp_output/

# twisted
/twisted/plugins/dropin.cache
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ In August 2019, PyRDP was demo'ed at BlackHat Arsenal ([slides](https://docs.goo
+ [Using a custom private key](#using-a-custom-private-key)
+ [Other cloner arguments](#other-cloner-arguments)
* [Using PyRDP as a Library](#using-pyrdp-as-a-library)
* [Using PyRDP with twistd](#using-pyrdp-with-twistd)
* [Using PyRDP with Bettercap](#using-pyrdp-with-bettercap)
* [Docker Specific Usage Instructions](#docker-specific-usage-instructions)
+ [Mapping a Listening Port](#mapping-a-listening-port)
Expand Down Expand Up @@ -365,6 +366,30 @@ Run `pyrdp-clonecert.py --help` for a full list of arguments.
If you're interested in experimenting with RDP and making your own tools, head over to our
[documentation section](docs/README.md) for more information.

### Using PyRDP with twistd
The PyRDP MITM component was also implemented as a twistd plugin. This enables
you to run it in debug mode and allows you to get an interactive debugging repl
(pdb) if you send a `SIGUSR2` to the twistd process.

```
twistd --debug pyrdp -t <target>
```

Then to get the repl:

```
killall -SIGUSR2 twistd
```

### Using PyRDP with twistd in Docker
In a directory with our `docker-compose.yml` you can run something like this:

```
docker-compose run -p 3389:3389 pyrdp twistd --debug pyrdp --target 192.168.1.10:3389
```

This will allocate a TTY and you will have access to `Pdb`'s REPL. Trying to add `--debug` to the `docker-compose.yml` command will fail because there is no TTY allocated.

### Using PyRDP with Bettercap
We developped our own Bettercap module, `rdp.proxy`, to man-in-the-middle all RDP connections
on a given LAN. Check out [this document](docs/bettercap-rdp-mitm.md) for more information.
Expand Down
202 changes: 13 additions & 189 deletions bin/pyrdp-mitm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,176 +2,30 @@

#
# This file is part of the PyRDP project.
# Copyright (C) 2018, 2019 GoSecure Inc.
# Copyright (C) 2018-2020 GoSecure Inc.
# Licensed under the GPLv3 or later.
#

import asyncio
from base64 import b64encode

import OpenSSL
from twisted.internet import asyncioreactor

from pyrdp.core.ssl import ServerTLSContext

asyncioreactor.install(asyncio.get_event_loop())

import argparse
import logging
import logging.handlers
import os
import random
import sys
from pathlib import Path
from base64 import b64encode

import appdirs
import names
from twisted.internet import reactor
from twisted.internet.protocol import ServerFactory

from pyrdp.logging import JSONFormatter, log, LOGGER_NAMES, LoggerNameFilter, SessionLogger, VariableFormatter
from pyrdp.mitm import MITMConfig, RDPMITM


class MITMServerFactory(ServerFactory):
"""
Server factory for the RDP man-in-the-middle that generates a unique session ID for every connection.
"""

def __init__(self, config: MITMConfig):
"""
:param config: the MITM configuration
"""
self.config = config

def buildProtocol(self, addr):
sessionID = f"{names.get_first_name()}{random.randrange(100000,999999)}"

# mainLogger logs in a file and stdout
mainlogger = logging.getLogger(LOGGER_NAMES.MITM_CONNECTIONS)
mainlogger = SessionLogger(mainlogger, sessionID)

# crawler logger only logs to a file for analysis purposes
crawlerLogger = logging.getLogger(LOGGER_NAMES.CRAWLER)
crawlerLogger = SessionLogger(crawlerLogger, sessionID)

mitm = RDPMITM(mainlogger, crawlerLogger, self.config)

return mitm.getProtocol()


def prepareLoggers(logLevel: int, logFilter: str, sensorID: str, outDir: Path):
"""
:param logLevel: log level for the stream handler.
:param logFilter: logger name to filter on.
:param sensorID: ID to differentiate between instances of this program in the JSON log.
:param outDir: output directory.
"""
logDir = outDir / "logs"
logDir.mkdir(exist_ok = True)

formatter = VariableFormatter("[{asctime}] - {levelname} - {sessionID} - {name} - {message}", style = "{", defaultVariables = {
"sessionID": "GLOBAL"
})

streamHandler = logging.StreamHandler()
streamHandler.setFormatter(formatter)
streamHandler.setLevel(logLevel)
streamHandler.addFilter(LoggerNameFilter(logFilter))

logFileHandler = logging.handlers.TimedRotatingFileHandler(logDir / "mitm.log", when = "D")
logFileHandler.setFormatter(formatter)

jsonFileHandler = logging.FileHandler(logDir / "mitm.json")
jsonFileHandler.setFormatter(JSONFormatter({"sensor": sensorID}))
jsonFileHandler.setLevel(logging.INFO)

rootLogger = logging.getLogger(LOGGER_NAMES.PYRDP)
rootLogger.addHandler(streamHandler)
rootLogger.addHandler(logFileHandler)
rootLogger.setLevel(logging.DEBUG)

connectionsLogger = logging.getLogger(LOGGER_NAMES.MITM_CONNECTIONS)
connectionsLogger.addHandler(jsonFileHandler)

crawlerFormatter = VariableFormatter("[{asctime}] - {sessionID} - {message}", style = "{", defaultVariables = {
"sessionID": "GLOBAL"
})

crawlerFileHandler = logging.FileHandler(logDir / "crawl.log")
crawlerFileHandler.setFormatter(crawlerFormatter)

jsonCrawlerFileHandler = logging.FileHandler(logDir / "crawl.json")
jsonCrawlerFileHandler.setFormatter(JSONFormatter({"sensor": sensorID}))

crawlerLogger = logging.getLogger(LOGGER_NAMES.CRAWLER)
crawlerLogger.addHandler(crawlerFileHandler)
crawlerLogger.addHandler(jsonCrawlerFileHandler)
crawlerLogger.setLevel(logging.INFO)

log.prepareSSLLogger(logDir / "ssl.log")


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.
"""
config = appdirs.user_config_dir("pyrdp", "pyrdp")

if not os.path.exists(config):
os.makedirs(config)

key = config + "/private_key.pem"
certificate = config + "/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 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.
"""

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")
# need to install this reactor before importing other twisted code
from twisted.internet import asyncioreactor
asyncioreactor.install(asyncio.get_event_loop())

from twisted.internet import reactor

def logConfiguration(config: MITMConfig):
logging.getLogger(LOGGER_NAMES.MITM).info("Target: %(target)s:%(port)d", {"target": config.targetHost, "port": config.targetPort})
logging.getLogger(LOGGER_NAMES.MITM).info("Output directory: %(outputDirectory)s", {"outputDirectory": config.outDir.absolute()})
from pyrdp.core.mitm import MITMServerFactory
from pyrdp.mitm import MITMConfig
from pyrdp.mitm.cli import logConfiguration, parseTarget, prepareLoggers, validateKeyAndCertificate


def main():
# Warning: keep in sync with twisted/plugins/pyrdp_plugin.py
parser = argparse.ArgumentParser()
parser.add_argument("target", help="IP:port of the target RDP machine (ex: 192.168.1.10:3390)")
parser.add_argument("-l", "--listen", help="Port number to listen on (default: 3389)", default=3389)
Expand Down Expand Up @@ -200,27 +54,10 @@ def main():
outDir.mkdir(exist_ok = True)

logLevel = getattr(logging, args.log_level)
pyrdpLogger = prepareLoggers(logLevel, args.log_filter, args.sensor_id, outDir)

prepareLoggers(logLevel, args.log_filter, args.sensor_id, outDir)
pyrdpLogger = logging.getLogger(LOGGER_NAMES.MITM)

target = args.target

if ":" in target:
targetHost = target[: target.index(":")]
targetPort = int(target[target.index(":") + 1:])
else:
targetHost = target
targetPort = 3389

if (args.private_key is None) != (args.certificate is None):
pyrdpLogger.error("You must provide both the private key and the certificate")
sys.exit(1)
elif args.private_key is None:
key, certificate = getSSLPaths()
handleKeyAndCertificate(key, certificate)
else:
key, certificate = args.private_key, args.certificate
targetHost, targetPort = parseTarget(args.target)
key, certificate = validateKeyAndCertificate(args.private_key, args.certificate)

listenPort = int(args.listen)

Expand Down Expand Up @@ -311,19 +148,6 @@ def main():
sys.exit(1)


try:
# Check if OpenSSL accepts the private key and certificate.
ServerTLSContext(config.privateKeyFileName, config.certificateFileName)
except OpenSSL.SSL.Error as error:
log.error(
"An error occurred when creating the server TLS context. " +
"There may be a problem with your private key or certificate (e.g: signature algorithm too weak). " +
"Here is the exception: %(error)s",
{"error": error}
)

sys.exit(1)

logConfiguration(config)

reactor.listenTCP(listenPort, MITMServerFactory(config))
Expand Down
2 changes: 1 addition & 1 deletion pyrdp/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#
# This file is part of the PyRDP project.
# Copyright (C) 2018 GoSecure Inc.
# Copyright (C) 2018-2020 GoSecure Inc.
# Licensed under the GPLv3 or later.
#

Expand Down
40 changes: 40 additions & 0 deletions pyrdp/core/mitm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#
# This file is part of the PyRDP project.
# Copyright (C) 2020 GoSecure Inc.
# Licensed under the GPLv3 or later.
#
import logging
import random

from twisted.internet.protocol import ServerFactory
import names

from pyrdp.mitm import MITMConfig, RDPMITM
from pyrdp.logging import LOGGER_NAMES, SessionLogger


class MITMServerFactory(ServerFactory):
"""
Server factory for the RDP man-in-the-middle that generates a unique session ID for every connection.
"""

def __init__(self, config: MITMConfig):
"""
:param config: the MITM configuration
"""
self.config = config

def buildProtocol(self, addr):
sessionID = f"{names.get_first_name()}{random.randrange(100000,999999)}"

# mainLogger logs in a file and stdout
mainlogger = logging.getLogger(LOGGER_NAMES.MITM_CONNECTIONS)
mainlogger = SessionLogger(mainlogger, sessionID)

# crawler logger only logs to a file for analysis purposes
crawlerLogger = logging.getLogger(LOGGER_NAMES.CRAWLER)
crawlerLogger = SessionLogger(crawlerLogger, sessionID)

mitm = RDPMITM(mainlogger, crawlerLogger, self.config)

return mitm.getProtocol()
1 change: 1 addition & 0 deletions pyrdp/mitm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
# Licensed under the GPLv3 or later.
#

from pyrdp.mitm.cli import parseTarget, validateKeyAndCertificate
from pyrdp.mitm.config import MITMConfig
from pyrdp.mitm.RDPMITM import RDPMITM
Loading

0 comments on commit 8fd50ab

Please sign in to comment.