diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..f1b6662b6 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +# ignore = +max-line-length = 120 \ No newline at end of file diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 3fc87f988..4c6605dee 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -29,6 +29,7 @@ For a detailed view of what has changed, refer to the {uri-repo}/commits/master[ * Added `--disable-active-clipboard` switch to prevent clipboard request injection * Added `--no-downgrade` switch to prevent protocol downgrading where possible {uri-issue}189[#189] * Added `--no-files` switch to prevent extracting transferred files {uri-issue}195[#195] +* Added `--gdi` MITM switch to enable accelerated graphics pipeline (MS-RDPEGDI) ({uri-issue}50[#50]) === Bug fixes diff --git a/README.md b/README.md index 0125fd0ec..b9237984a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ PyRDP was [introduced in 2018](https://www.gosecure.net/blog/2018/12/19/rdp-man- + [Other MITM arguments](#other-mitm-arguments) - [--no-downgrade](#--no-downgrade) - [--transparent](#--transparent) + - [`--gdi`: Accelerated Graphics Pipeline](#--gdi-accelerated-graphics-pipeline) * [Using the PyRDP Player](#using-the-pyrdp-player) + [Playing a replay file](#playing-a-replay-file) + [Listening for live connections](#listening-for-live-connections) @@ -386,6 +387,16 @@ This setup is a base example and might be much more complex depending on your en To cleanup, you should delete the created routes, remove the custom routing table from `rt_tables` and remove the `ip rule`. +##### `--gdi`: Accelerated Graphics Pipeline + +Tells the MITM to allow clients to use [Graphics Device Interface Acceleration][gdi] Extensions to stream +drawing orders instead of raw bitmaps. The advantage of this mode is a significant reduction in required bandwidth +for high resolution connections. + +Note that some GDI drawing orders are currently unimplemented because they appear to be unused. +If you have a replay which contains any unsupported or untested order, do not hesitate to share it with the project maintainers so that support can be added as required. (Make sure that the trace does not contain sensitive information) + +[gdi]: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegdi/745f2eee-d110-464c-8aca-06fc1814f6ad ### Using the PyRDP Player Use `pyrdp-player.py` to run the player. diff --git a/bin/pyrdp-player.py b/bin/pyrdp-player.py index 308f8264a..8314ea4ea 100755 --- a/bin/pyrdp-player.py +++ b/bin/pyrdp-player.py @@ -8,21 +8,21 @@ # asyncio needs to be imported first to ensure that the reactor is # installed properly. Do not re-order. -import asyncio +import asyncio # noqa from twisted.internet import asyncioreactor asyncioreactor.install(asyncio.get_event_loop()) -from pyrdp.core import settings -from pyrdp.logging import LOGGER_NAMES, NotifyHandler, configure as configureLoggers -from pyrdp.player import HAS_GUI -from pyrdp.player.config import DEFAULTS +from pyrdp.core import settings # noqa +from pyrdp.logging import LOGGER_NAMES, NotifyHandler, configure as configureLoggers # noqa +from pyrdp.player import HAS_GUI # noqa +from pyrdp.player.config import DEFAULTS # noqa -from pathlib import Path -import argparse -import logging -import logging.handlers -import sys -import os +from pathlib import Path # noqa +import argparse # noqa +import logging # noqa +import logging.handlers # noqa +import sys # noqa +import os # noqa if HAS_GUI: from pyrdp.player import MainWindow @@ -34,7 +34,7 @@ def enableNotifications(logger): # https://docs.python.org/3/library/os.html if os.name != "nt": notifyHandler = NotifyHandler() - notifyHandler.setFormatter(logging.Formatter("[{asctime}] - {message}", style = "{")) + notifyHandler.setFormatter(logging.Formatter("[{asctime}] - {message}", style="{")) uiLogger = logging.getLogger(LOGGER_NAMES.PLAYER_UI) uiLogger.addHandler(notifyHandler) @@ -52,8 +52,10 @@ def main(): parser.add_argument("-b", "--bind", help="Bind address (default: 127.0.0.1)", default="127.0.0.1") parser.add_argument("-p", "--port", help="Bind port (default: 3000)", default=3000) parser.add_argument("-o", "--output", help="Output folder", default="pyrdp_output") - parser.add_argument("-L", "--log-level", help="Log level", default=None, choices=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"], nargs="?") - parser.add_argument("-F", "--log-filter", help="Only show logs from this logger name (accepts '*' wildcards)", default=None) + parser.add_argument("-L", "--log-level", help="Log level", default=None, + choices=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"], nargs="?") + parser.add_argument("-F", "--log-filter", + help="Only show logs from this logger name (accepts '*' wildcards)", default=None) parser.add_argument("--headless", help="Parse a replay without rendering the user interface.", action="store_true") args = parser.parse_args() @@ -67,6 +69,9 @@ def main(): if args.output: cfg.set('vars', 'output_dir', args.output) + outDir = Path(cfg.get('vars', 'output_dir')) + outDir.mkdir(exist_ok=True) + configureLoggers(cfg) logger = logging.getLogger(LOGGER_NAMES.PYRDP) @@ -74,7 +79,8 @@ def main(): enableNotifications(logger) if not HAS_GUI and not args.headless: - logger.error('Headless mode is not specified and PySide2 is not installed. Install PySide2 to use the graphical user interface.') + logger.error('Headless mode is not specified and PySide2 is not installed.' + ' Install PySide2 to use the graphical user interface.') sys.exit(127) if not args.headless: diff --git a/pyrdp/enum/__init__.py b/pyrdp/enum/__init__.py index 1e7629842..c47ce9c44 100644 --- a/pyrdp/enum/__init__.py +++ b/pyrdp/enum/__init__.py @@ -1,8 +1,9 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # +# flake8: noqa from pyrdp.enum.core import ParserMode from pyrdp.enum.gcc import GCCPDUType @@ -10,6 +11,7 @@ from pyrdp.enum.negotiation import NegotiationRequestFlags, NegotiationType from pyrdp.enum.player import MouseButton, PlayerPDUType from pyrdp.enum.rdp import * +from pyrdp.enum.orders import DrawingOrderControlFlags from pyrdp.enum.scancode import ScanCode, ScanCodeTuple from pyrdp.enum.segmentation import SegmentationPDUType from pyrdp.enum.virtual_channel.clipboard import ClipboardFormatName, ClipboardFormatNumber, ClipboardMessageFlags, \ diff --git a/pyrdp/enum/orders.py b/pyrdp/enum/orders.py new file mode 100644 index 000000000..60d078a07 --- /dev/null +++ b/pyrdp/enum/orders.py @@ -0,0 +1,78 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +Enumerations for Drawing Orders. +""" + +from enum import IntEnum + + +class DrawingOrderControlFlags(IntEnum): + """ + https://msdn.microsoft.com/en-us/library/cc241574.aspx + """ + TS_STANDARD = 0x01 + TS_SECONDARY = 0x02 + TS_BOUNDS = 0x04 + TS_TYPE_CHANGE = 0x08 + TS_DELTA_COORDS = 0x10 + TS_ZERO_BOUNDS_DELTAS = 0x20 + TS_ZERO_FIELD_BYTE_BIT0 = 0x40 + TS_ZERO_FIELD_BYTE_BIT1 = 0x80 + + +class Primary(IntEnum): + DSTBLT = 0x00 + PATBLT = 0x01 + SCRBLT = 0x02 + DRAW_NINE_GRID = 0x07 + MULTI_DRAW_NINE_GRID = 0x08 + LINE_TO = 0x09 + OPAQUE_RECT = 0x0A + SAVE_BITMAP = 0x0B + MEMBLT = 0x0D + MEM3BLT = 0x0E + MULTI_DSTBLT = 0x0F + MULTI_PATBLT = 0x10 + MULTI_SCRBLT = 0x11 + MULTI_OPAQUE_RECT = 0x12 + FAST_INDEX = 0x13 + POLYGON_SC = 0x14 + POLYGON_CB = 0x15 + POLYLINE = 0x16 + FAST_GLYPH = 0x18 + ELLIPSE_SC = 0x19 + ELLIPSE_CB = 0x1A + GLYPH_INDEX = 0x1B + + +class Secondary(IntEnum): + BITMAP_UNCOMPRESSED = 0x00 + CACHE_COLOR_TABLE = 0x01 + CACHE_BITMAP_COMPRESSED = 0x02 + CACHE_GLYPH = 0x03 + BITMAP_UNCOMPRESSED_V2 = 0x04 + BITMAP_COMPRESSED_V2 = 0x05 + CACHE_BRUSH = 0x07 + BITMAP_COMPRESSED_V3 = 0x08 + + +class Alternate(IntEnum): + SWITCH_SURFACE = 0x00 + CREATE_OFFSCREEN_BITMAP = 0x01 + STREAM_BITMAP_FIRST = 0x02 + STREAM_BITMAP_NEXT = 0x03 + CREATE_NINE_GRID_BITMAP = 0x04 + GDIPLUS_FIRST = 0x05 + GDIPLUS_NEXT = 0x06 + GDIPLUS_END = 0x07 + GDIPLUS_CACHE_FIRST = 0x08 + GDIPLUS_CACHE_NEXT = 0x09 + GDIPLUS_CACHE_END = 0x0A + WINDOW = 0x0B + COMPDESK_FIRST = 0x0C + FRAME_MARKER = 0x0D diff --git a/pyrdp/enum/rdp.py b/pyrdp/enum/rdp.py index 6cf7f3a9a..33a0e0797 100644 --- a/pyrdp/enum/rdp.py +++ b/pyrdp/enum/rdp.py @@ -1,9 +1,12 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018, 2019 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # +# Disable line-too-long lints. +# flake8: noqa + from enum import IntEnum, IntFlag @@ -60,10 +63,12 @@ class SecurityHeaderType(IntEnum): # Header type used for Client Info and Licensing PDUs if no encryption is used DEFAULT = 1 + class FastPathSecurityFlags: FASTPATH_OUTPUT_SECURE_CHECKSUM = 0x40 FASTPATH_OUTPUT_ENCRYPTED = 0x80 + class FastPathInputType(IntEnum): FASTPATH_INPUT_EVENT_SCANCODE = 0 FASTPATH_INPUT_EVENT_MOUSE = 1 @@ -74,6 +79,9 @@ class FastPathInputType(IntEnum): class FastPathOutputType(IntEnum): + """ + https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a1c4caa8-00ed-45bb-a06e-5177473766d3 + """ FASTPATH_UPDATETYPE_ORDERS = 0 FASTPATH_UPDATETYPE_BITMAP = 1 FASTPATH_UPDATETYPE_PALETTE = 2 @@ -86,6 +94,23 @@ class FastPathOutputType(IntEnum): FASTPATH_UPDATETYPE_CACHED = 10 FASTPATH_UPDATETYPE_POINTER = 11 + @staticmethod + def getText(updateType: 'FastPathOutputType'): + MESSAGES = { + FastPathOutputType.FASTPATH_UPDATETYPE_ORDERS: 'ORDERS', + FastPathOutputType.FASTPATH_UPDATETYPE_BITMAP: 'BITMAP', + FastPathOutputType.FASTPATH_UPDATETYPE_PALETTE: 'PALETTE', + FastPathOutputType.FASTPATH_UPDATETYPE_SYNCHRONIZE: 'SYNCHRONIZE', + FastPathOutputType.FASTPATH_UPDATETYPE_SURFCMDS: 'SURFCMDS', + FastPathOutputType.FASTPATH_UPDATETYPE_PTR_NULL: 'PTR_NULL', + FastPathOutputType.FASTPATH_UPDATETYPE_PTR_DEFAULT: 'PTR_DEFAULT', + FastPathOutputType.FASTPATH_UPDATETYPE_PTR_POSITION: 'PTR_POSITION', + FastPathOutputType.FASTPATH_UPDATETYPE_COLOR: 'COLOR', + FastPathOutputType.FASTPATH_UPDATETYPE_CACHED: 'CACHED', + FastPathOutputType.FASTPATH_UPDATETYPE_POINTER: 'POINTER', + } + return MESSAGES.get(updateType) + class FastPathOutputCompressionType(IntEnum): """ @@ -162,12 +187,17 @@ class SlowPathPDUType(IntEnum): DATA_PDU = 0x7 SERVER_REDIR_PKT_PDU = 0xA + class SlowPathUpdateType(IntEnum): + """ + https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/bd3c4df4-87b9-43dd-88cb-ce5b24698e19 + """ SLOWPATH_UPDATETYPE_ORDERS = 0 SLOWPATH_UPDATETYPE_BITMAP = 1 SLOWPATH_UPDATETYPE_PALETTE = 2 SLOWPATH_UPDATETYPE_SYNCHRONIZE = 3 + class SlowPathDataType(IntEnum): """ Slow-path data PDU types. @@ -361,6 +391,7 @@ class NegotiationFailureCode(IntEnum): HYBRID_REQUIRED_BY_SERVER = 0x00000005 SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER = 0x00000006 + @staticmethod def getMessage(code: "NegotiationFailureCode"): MESSAGES = { @@ -796,14 +827,6 @@ class ChannelOption(IntFlag): REMOTE_CONTROL_PERSISTENT = 0x00100000 -class DrawingOrderControlFlags(IntEnum): - """ - https://msdn.microsoft.com/en-us/library/cc241574.aspx - """ - TS_STANDARD = 0b00000001 - TS_SECONDARY = 0b00000010 - - class BitmapFlags(IntEnum): """ https://msdn.microsoft.com/en-us/library/cc240612.aspx diff --git a/pyrdp/mitm/SlowPathMITM.py b/pyrdp/mitm/SlowPathMITM.py index 2d35d0344..afae7b7d2 100644 --- a/pyrdp/mitm/SlowPathMITM.py +++ b/pyrdp/mitm/SlowPathMITM.py @@ -1,16 +1,17 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2019 GoSecure Inc. +# Copyright (C) 2019-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # -from pyrdp.enum import CapabilityType, KeyboardFlag, OrderFlag, VirtualChannelCompressionFlag +from pyrdp.enum import CapabilityType, KeyboardFlag, OrderFlag, VirtualChannelCompressionFlag, Order from pyrdp.layer import SlowPathLayer, SlowPathObserver from pyrdp.logging.StatCounter import StatCounter, STAT from pyrdp.mitm.state import RDPMITMState from pyrdp.pdu import Capability, ConfirmActivePDU, DemandActivePDU, InputPDU, KeyboardEvent, SlowPathPDU from pyrdp.mitm.BasePathMITM import BasePathMITM + class SlowPathMITM(BasePathMITM): """ MITM component for the slow-path layer. @@ -24,8 +25,8 @@ def __init__(self, client: SlowPathLayer, server: SlowPathLayer, state: RDPMITMS super().__init__(state, client, server, statCounter) self.clientObserver = self.client.createObserver( - onPDUReceived = self.onClientPDUReceived, - onConfirmActive = self.onConfirmActive + onPDUReceived=self.onClientPDUReceived, + onConfirmActive=self.onConfirmActive ) self.serverObserver = self.server.createObserver( @@ -44,7 +45,8 @@ def onClientPDUReceived(self, pdu: SlowPathPDU): if isinstance(pdu, InputPDU): for event in pdu.events: if isinstance(event, KeyboardEvent): - self.onScanCode(event.keyCode, event.flags & KeyboardFlag.KBDFLAGS_DOWN == 0, event.flags & KeyboardFlag.KBDFLAGS_EXTENDED != 0) + self.onScanCode(event.keyCode, event.flags & KeyboardFlag.KBDFLAGS_DOWN == 0, + event.flags & KeyboardFlag.KBDFLAGS_EXTENDED != 0) def onServerPDUReceived(self, pdu: SlowPathPDU): self.statCounter.increment(STAT.IO_OUTPUT_SLOWPATH) @@ -59,27 +61,50 @@ def onConfirmActive(self, pdu: ConfirmActivePDU): :param pdu: the confirm active PDU """ - # Disable virtual channel compression - if CapabilityType.CAPSTYPE_VIRTUALCHANNEL in pdu.parsedCapabilitySets: - pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_VIRTUALCHANNEL].flags = VirtualChannelCompressionFlag.VCCAPS_NO_COMPR - if self.state.config.downgrade: - # Force RDP server to send bitmap events instead of order events. - pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_ORDER].orderFlags = OrderFlag.NEGOTIATEORDERSUPPORT | OrderFlag.ZEROBOUNDSDELTASSUPPORT - pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_ORDER].orderSupport = b"\x00" * 32 - - # Override the bitmap cache capability set with null values. - if CapabilityType.CAPSTYPE_BITMAPCACHE in pdu.parsedCapabilitySets: - pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_BITMAPCACHE] = Capability(CapabilityType.CAPSTYPE_BITMAPCACHE, b"\x00" * 36) # Disable surface commands if CapabilityType.CAPSETTYPE_SURFACE_COMMANDS in pdu.parsedCapabilitySets: pdu.parsedCapabilitySets[CapabilityType.CAPSETTYPE_SURFACE_COMMANDS].cmdFlags = 0 + # Disable GDI if not explicitly requested. + if not self.state.config.useGdi: + # Force RDP server to send bitmap events instead of order events. + pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_ORDER].orderFlags = \ + OrderFlag.NEGOTIATEORDERSUPPORT | OrderFlag.ZEROBOUNDSDELTASSUPPORT + pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_ORDER].orderSupport = b"\x00" * 32 + + # Override the bitmap cache capability set with null values. + if CapabilityType.CAPSTYPE_BITMAPCACHE in pdu.parsedCapabilitySets: + pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_BITMAPCACHE] = Capability( + CapabilityType.CAPSTYPE_BITMAPCACHE, b"\x00" * 36) + else: + # Disable NineGrid support (Not implemented in Player) + if CapabilityType.CAPSTYPE_ORDER in pdu.parsedCapabilitySets: + orders = pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_ORDER] + supported = bytearray(orders.orderSupport) + supported[Order.TS_NEG_DRAWNINEGRID_INDEX] = 0 + orders.orderSupport = supported + + if CapabilityType.CAPSTYPE_DRAWNINEGRIDCACHE in pdu.parsedCapabilitySets: + pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_DRAWNINEGRIDCACHE].rawData = b"\x00"*8 + + # Disable virtual channel compression + if CapabilityType.CAPSTYPE_VIRTUALCHANNEL in pdu.parsedCapabilitySets: + pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_VIRTUALCHANNEL].flags = \ + VirtualChannelCompressionFlag.VCCAPS_NO_COMPR + def onDemandActive(self, pdu: DemandActivePDU): """ Disable virtual channel compression. :param pdu: the demand active PDU """ - pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_VIRTUALCHANNEL].flags = VirtualChannelCompressionFlag.VCCAPS_NO_COMPR + if CapabilityType.CAPSTYPE_ORDER in pdu.parsedCapabilitySets: + orders = pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_ORDER] + supported = bytearray(orders.orderSupport) + supported[Order.TS_NEG_DRAWNINEGRID_INDEX] = 0 + orders.orderSupport = supported + + pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_VIRTUALCHANNEL].flags = \ + VirtualChannelCompressionFlag.VCCAPS_NO_COMPR diff --git a/pyrdp/mitm/cli.py b/pyrdp/mitm/cli.py index b48928e98..464e8f00a 100644 --- a/pyrdp/mitm/cli.py +++ b/pyrdp/mitm/cli.py @@ -114,13 +114,17 @@ def generateCertificate(keyPath: str, certificatePath: str) -> bool: else: nullDevicePath = "NUL" - result = os.system("openssl req -newkey rsa:2048 -nodes -keyout %s -x509 -days 365 -out %s -subj \"/CN=www.example.com/O=PYRDP/C=US\" 2>%s" % (keyPath, certificatePath, nullDevicePath)) + result = os.system("openssl req -newkey rsa:2048 -nodes -keyout %s -x509" + " -days 365 -out %s -subj \"/CN=www.example.com/O=PYRDP/C=US\" 2>%s" % + (keyPath, certificatePath, nullDevicePath)) return result == 0 def showConfiguration(config: MITMConfig): - logging.getLogger(LOGGER_NAMES.MITM).info("Target: %(target)s:%(port)d", {"target": config.targetHost, "port": config.targetPort}) - logging.getLogger(LOGGER_NAMES.MITM).info("Output directory: %(outputDirectory)s", {"outputDirectory": config.outDir.absolute()}) + logging.getLogger(LOGGER_NAMES.MITM).info("Target: %(target)s:%(port)d", { + "target": config.targetHost, "port": config.targetPort}) + logging.getLogger(LOGGER_NAMES.MITM).info("Output directory: %(outputDirectory)s", + {"outputDirectory": config.outDir.absolute()}) def buildArgParser(): @@ -128,28 +132,54 @@ def buildArgParser(): parser.add_argument("target", help="IP:port of the target RDP machine (ex: 192.168.1.10:3390)") parser.add_argument("-l", "--listen", help="Port number to listen on (default: 3389)", default=3389) parser.add_argument("-o", "--output", help="Output folder", default="pyrdp_output") - parser.add_argument("-i", "--destination-ip", help="Destination IP address of the PyRDP player.If not specified, RDP events are not sent over the network.") - parser.add_argument("-d", "--destination-port", help="Listening port of the PyRDP player (default: 3000).", default=3000) + parser.add_argument("-i", "--destination-ip", + help="Destination IP address of the PyRDP player.If not specified, RDP events are" + " not sent over the network.") + parser.add_argument("-d", "--destination-port", + help="Listening port of the PyRDP player (default: 3000).", default=3000) parser.add_argument("-k", "--private-key", help="Path to private key (for SSL)") parser.add_argument("-c", "--certificate", help="Path to certificate (for SSL)") parser.add_argument("-u", "--username", help="Username that will replace the client's username", default=None) parser.add_argument("-p", "--password", help="Password that will replace the client's password", default=None) - parser.add_argument("-L", "--log-level", help="Console logging level. Logs saved to file are always verbose.", 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("-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("--disable-active-clipboard", help="Disables the active clipboard stealing to request clipboard content upon connection.", action="store_true") + 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("--disable-active-clipboard", + help="Disables the active clipboard stealing to request clipboard content upon connection.", + action="store_true") parser.add_argument("--crawl", help="Enable automatic shared drive scraping", action="store_true") - parser.add_argument("--crawler-match-file", help="File to be used by the crawler to chose what to download when scraping the client shared drives.", default=None) - parser.add_argument("--crawler-ignore-file", help="File to be used by the crawler to chose what folders to avoid when scraping the client shared drives.", default=None) + parser.add_argument("--crawler-match-file", + help="File to be used by the crawler to chose what to download when scraping the client shared" + " drives.", default=None) + parser.add_argument("--crawler-ignore-file", + help="File to be used by the crawler to chose what folders to avoid when scraping the client" + " shared drives.", default=None) parser.add_argument("--no-replay", help="Disable replay recording", action="store_true") - parser.add_argument("--no-downgrade", help="Disables downgrading of unsupported extensions. This makes PyRDP harder to fingerprint but might impact the player's ability to replay captured traffic.", action="store_true") - parser.add_argument("--no-files", help="Do not extract files transferred between the client and server.", action="store_true") - parser.add_argument("--transparent", help="Spoof source IP for connections to the server (See README)", action="store_true") + parser.add_argument("--no-downgrade", help="Disables downgrading of unsupported extensions. This makes PyRDP harder" + " to fingerprint but might impact the player's ability to replay captured traffic.", + action="store_true") + parser.add_argument( + "--no-files", help="Do not extract files transferred between the client and server.", action="store_true") + parser.add_argument( + "--transparent", help="Spoof source IP for connections to the server (See README)", action="store_true") + parser.add_argument("--gdi", help="Accept accelerated graphics pipeline (MS-RDPEGDI) extension", + action="store_true") return parser @@ -201,11 +231,16 @@ def configure(cmdline=None) -> MITMConfig: config.transparent = args.transparent config.extractFiles = not args.no_files config.disableActiveClipboardStealing = args.disable_active_clipboard + config.useGdi = args.gdi 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: + npayloads = int(args.payload is not None) + \ + int(args.payload_powershell is not None) + \ + int(args.payload_powershell_file is not None) + + if npayloads > 1: logger.error("Only one of --payload, --payload-powershell and --payload-powershell-file may be supplied.") sys.exit(1) @@ -252,7 +287,9 @@ def configure(cmdline=None) -> MITMConfig: sys.exit(1) if config.payloadDelay < 1000: - logger.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.") + logger.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) diff --git a/pyrdp/mitm/config.py b/pyrdp/mitm/config.py index 571c947d7..0bd38dc04 100644 --- a/pyrdp/mitm/config.py +++ b/pyrdp/mitm/config.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2019 GoSecure Inc. +# Copyright (C) 2019-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -75,6 +75,9 @@ def __init__(self): self.disableActiveClipboardStealing: bool = False """ If set to False, use PassiveClipboardStealer instead of ActiveClipboardStealer.""" + self.useGdi: bool = False + """Whether to allow the client to use the GDI rendering pipeline extension.""" + @property def replayDir(self) -> Path: """ @@ -89,7 +92,8 @@ def fileDir(self) -> Path: """ return self.outDir / "files" + """ The default MITM configuration. """ -DEFAULTS = settings.load(Path(__file__).parent.absolute() / "mitm.default.ini") +DEFAULTS = settings.load(Path(__file__).parent.absolute() / "mitm.default.ini") diff --git a/pyrdp/mitm/state.py b/pyrdp/mitm/state.py index 3255fe591..e93bf9aa0 100644 --- a/pyrdp/mitm/state.py +++ b/pyrdp/mitm/state.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2019 GoSecure Inc. +# Copyright (C) 2019-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -13,7 +13,7 @@ from pyrdp.parser import createFastPathParser from pyrdp.pdu import ClientChannelDefinition from pyrdp.security import RC4CrypterProxy, SecuritySettings -from pyrdp.mitm.config import MITMConfig +from pyrdp.mitm import MITMConfig class RDPMITMState: @@ -97,6 +97,5 @@ def createFastPathLayer(self, mode: ParserMode) -> FastPathLayer: :param mode: the mode of the layer (client or server) """ - parser = createFastPathParser( - self.useTLS, self.securitySettings.encryptionMethod, self.crypters[mode], mode) + parser = createFastPathParser(self.useTLS, self.securitySettings.encryptionMethod, self.crypters[mode], mode) return FastPathLayer(parser) diff --git a/pyrdp/parser/rdp/fastpath.py b/pyrdp/parser/rdp/fastpath.py index 02cbc20a6..1762ef9ce 100644 --- a/pyrdp/parser/rdp/fastpath.py +++ b/pyrdp/parser/rdp/fastpath.py @@ -8,17 +8,19 @@ from io import BytesIO from pyrdp.core import Uint16BE, Uint16LE, Uint8 -from pyrdp.enum import DrawingOrderControlFlags, EncryptionMethod, FastPathInputType, \ +from pyrdp.enum import EncryptionMethod, FastPathInputType, \ FastPathOutputCompressionType, FastPathOutputType, FastPathSecurityFlags, FIPSVersion, ParserMode -from pyrdp.logging import log -from pyrdp.parser.parser import Parser -from pyrdp.parser.rdp.bitmap import BitmapParser -from pyrdp.parser.rdp.security import BasicSecurityParser from pyrdp.pdu import FastPathBitmapEvent, FastPathEventRaw, FastPathMouseEvent, FastPathOrdersEvent, FastPathPDU, \ - FastPathScanCodeEvent, SecondaryDrawingOrder + FastPathScanCodeEvent from pyrdp.pdu.rdp.fastpath import FastPathEvent, FastPathOutputEvent, FastPathUnicodeEvent from pyrdp.security import RC4Crypter, RC4CrypterProxy +from pyrdp.parser.parser import Parser +from pyrdp.parser.rdp.bitmap import BitmapParser +from pyrdp.parser.rdp.security import BasicSecurityParser + +from pyrdp.logging import log + class BasicFastPathParser(BasicSecurityParser): def __init__(self, mode: ParserMode): @@ -81,7 +83,7 @@ def parseEvents(self, data: bytes) -> [FastPathEvent]: while len(data) > 0: eventLength = self.readParser.getEventLength(data) eventData = data[: eventLength] - data = data[eventLength :] + data = data[eventLength:] try: event = self.readParser.parse(eventData) @@ -148,7 +150,7 @@ def parse(self, data: bytes) -> FastPathPDU: header = Uint8.unpack(stream) eventCount = self.parseEventCount(header) pduLength = self.parseLength(stream) - _signature = stream.read(8) + stream.read(8) # signature (unused) if eventCount == 0: eventCount = Uint8.unpack(stream) @@ -194,10 +196,10 @@ def parse(self, data: bytes) -> FastPathPDU: header = Uint8.unpack(stream) eventCount = self.parseEventCount(header) pduLength = self.parseLength(stream) - _fipsLength = Uint16LE.unpack(stream) - _version = Uint8.unpack(stream) - _padLength = Uint8.unpack(stream) - _signature = stream.read(8) + Uint16LE.unpack(stream) # fipsLength (unused) + Uint8.unpack(stream) # version (unused) + Uint8.unpack(stream) # padLength (unused) + stream.read(8) # signature (unused) if eventCount == 0: eventCount = Uint8.unpack(stream) @@ -235,7 +237,6 @@ class FastPathInputParser(Parser): FastPathInputType.FASTPATH_INPUT_EVENT_QOE_TIMESTAMP: 5, } - def getEventLength(self, data: bytes) -> int: if isinstance(data, FastPathEventRaw): return len(data.data) @@ -252,12 +253,11 @@ def getEventLength(self, data: bytes) -> int: 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 + eventFlags = eventHeader & 0b00011111 if eventCode == FastPathInputType.FASTPATH_INPUT_EVENT_SCANCODE: return self.parseScanCodeEvent(eventFlags, eventHeader, stream) @@ -268,19 +268,16 @@ def parse(self, data: bytes) -> FastPathEvent: return FastPathEventRaw(data) - 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 parseUnicodeEvent(self, eventHeader: int, stream: BytesIO) -> FastPathUnicodeEvent: released = eventHeader & 1 != 0 text = stream.read(2) @@ -292,7 +289,6 @@ def parseUnicodeEvent(self, eventHeader: int, stream: BytesIO) -> FastPathUnicod return FastPathUnicodeEvent(text, released) - def write(self, event: FastPathEvent) -> bytes: if isinstance(event, FastPathEventRaw): return event.data @@ -305,14 +301,12 @@ def write(self, event: FastPathEvent) -> bytes: raise ValueError("Invalid FastPath event: {}".format(event)) - def writeScanCodeEvent(self, event: FastPathScanCodeEvent) -> bytes: stream = BytesIO() Uint8.pack(event.rawHeaderByte | int(event.isReleased), stream) Uint8.pack(event.scanCode, stream) return stream.getvalue() - def writeMouseEvent(self, event: FastPathMouseEvent) -> bytes: stream = BytesIO() Uint8.pack(event.rawHeaderByte, stream) @@ -321,7 +315,6 @@ def writeMouseEvent(self, event: FastPathMouseEvent) -> bytes: 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) @@ -353,7 +346,7 @@ def getEventLength(self, event: FastPathOutputEvent) -> int: size += 1 if isinstance(event, FastPathOrdersEvent): - size += 2 + len(event.orderData) + size += len(event.payload) elif isinstance(event, FastPathBitmapEvent): size += len(event.payload) elif isinstance(event, FastPathOutputEvent): @@ -368,74 +361,61 @@ def isCompressed(self, header: int) -> bool: return (header >> 6) & FastPathOutputCompressionType.FASTPATH_OUTPUT_COMPRESSION_USED != 0 def parse(self, data: bytes) -> FastPathOutputEvent: + """ + Parse TS_FP_UPDATE. + + https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a1c4caa8-00ed-45bb-a06e-5177473766d3 + """ stream = BytesIO(data) header = Uint8.unpack(stream) - compressionFlags = None - - if self.isCompressed(header): - compressionFlags = Uint8.unpack(stream) - + # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a1c4caa8-00ed-45bb-a06e-5177473766d3 + updateCode = header & 0xf + # fragmentation = (header & 0b00110000) >> 4 + compressionFlags = Uint8.unpack(stream) if self.isCompressed(header) else None size = Uint16LE.unpack(stream) - 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.") - return FastPathOutputEvent(header, compressionFlags, payload=stream.read(size)) - - if eventType == FastPathOutputType.FASTPATH_UPDATETYPE_BITMAP: + # Dispatch to the appropriate sub-parser. + if updateCode == FastPathOutputType.FASTPATH_UPDATETYPE_BITMAP: return self.parseBitmapEventRaw(stream, header, compressionFlags, size) - elif eventType == FastPathOutputType.FASTPATH_UPDATETYPE_ORDERS: + elif updateCode == FastPathOutputType.FASTPATH_UPDATETYPE_ORDERS: return self.parseOrdersEvent(stream, header, compressionFlags, size) read = stream.read(size) return FastPathOutputEvent(header, compressionFlags, read) - def parseBitmapEventRaw(self, stream: BytesIO, header: int, compressionFlags: int, size: int) -> FastPathBitmapEvent: + def parseBitmapEventRaw(self, stream: BytesIO, header: int, compressionFlags: int, size: int) \ + -> FastPathBitmapEvent: return FastPathBitmapEvent(header, compressionFlags, [], stream.read(size)) def parseBitmapEvent(self, fastPathBitmapEvent: FastPathOutputEvent) -> FastPathBitmapEvent: rawBitmapUpdateData = fastPathBitmapEvent.payload stream = BytesIO(rawBitmapUpdateData) - updateType = Uint16LE.unpack(stream.read(2)) + Uint16LE.unpack(stream.read(2)) # updateType (unused) bitmapData = self.bitmapParser.parseBitmapUpdateData(stream.read()) - return FastPathBitmapEvent(fastPathBitmapEvent.header, fastPathBitmapEvent.compressionFlags, bitmapData, rawBitmapUpdateData) + return FastPathBitmapEvent(fastPathBitmapEvent.header, + fastPathBitmapEvent.compressionFlags, + bitmapData, + rawBitmapUpdateData) def writeBitmapEvent(self, stream: BytesIO, event: FastPathBitmapEvent): stream.write(event.payload) def parseOrdersEvent(self, stream: BytesIO, header: int, compressionFlags: int, size: int) -> FastPathOrdersEvent: - orderCount = Uint16LE.unpack(stream) - orderData = stream.read(size - 2) - - assert len(orderData) == size - 2 + """ + Parse the order events from a TS_FP_UPDATE_ORDERS. + This is specified in MS-RDPEGDI. + """ + payload = stream.read(size) + assert len(payload) == size - ordersEvent = FastPathOrdersEvent(header, compressionFlags, orderCount, orderData) - controlFlags = Uint8.unpack(orderData[0]) - - if controlFlags & (DrawingOrderControlFlags.TS_SECONDARY | DrawingOrderControlFlags.TS_STANDARD)\ - == (DrawingOrderControlFlags.TS_SECONDARY | DrawingOrderControlFlags.TS_STANDARD): - ordersEvent.secondaryDrawingOrders = self.parseSecondaryDrawingOrder(orderData) - elif controlFlags & DrawingOrderControlFlags.TS_SECONDARY: - pass - - return ordersEvent - - def parseSecondaryDrawingOrder(self, orderData: bytes) -> SecondaryDrawingOrder: - stream = BytesIO(orderData) - controlFlags = Uint8.unpack(stream.read(1)) - orderLength = Uint16LE.unpack(stream.read(2)) - extraFlags = Uint16LE.unpack(stream.read(2)) - orderType = Uint8.unpack(stream.read(1)) - return SecondaryDrawingOrder(controlFlags, orderLength, extraFlags, orderType) + orders = FastPathOrdersEvent(header, compressionFlags, payload) + return orders def writeOrdersEvent(self, stream, event): - Uint16LE.pack(event.orderCount, stream) - stream.write(event.orderData) + # Just write the saved raw bytes as-is. + stream.write(event.payload) def write(self, event: FastPathOutputEvent) -> bytes: @@ -464,7 +444,8 @@ def write(self, event: FastPathOutputEvent) -> bytes: def createFastPathParser(tls: bool, encryptionMethod: EncryptionMethod, crypter: typing.Union[RC4Crypter, RC4CrypterProxy], - mode: ParserMode) -> typing.Union[BasicFastPathParser, SignedFastPathParser, FIPSFastPathParser]: + mode: ParserMode) \ + -> typing.Union[BasicFastPathParser, SignedFastPathParser, FIPSFastPathParser]: """ Create a fast-path parser based on which encryption method is used. :param tls: whether TLS is used or not. @@ -474,9 +455,11 @@ def createFastPathParser(tls: bool, """ if tls: return BasicFastPathParser(mode) - elif encryptionMethod in [EncryptionMethod.ENCRYPTION_40BIT, EncryptionMethod.ENCRYPTION_56BIT, EncryptionMethod.ENCRYPTION_128BIT]: + elif encryptionMethod in [EncryptionMethod.ENCRYPTION_40BIT, + EncryptionMethod.ENCRYPTION_56BIT, + EncryptionMethod.ENCRYPTION_128BIT]: return SignedFastPathParser(crypter, mode) elif encryptionMethod == EncryptionMethod.ENCRYPTION_FIPS: return FIPSFastPathParser(crypter, mode) else: - raise ValueError("Invalid fast-path layer mode") \ No newline at end of file + raise ValueError("Invalid fast-path layer mode") diff --git a/pyrdp/parser/rdp/orders/__init__.py b/pyrdp/parser/rdp/orders/__init__.py new file mode 100644 index 000000000..c2c8d0c25 --- /dev/null +++ b/pyrdp/parser/rdp/orders/__init__.py @@ -0,0 +1,14 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# +""" +Types and methods proper to the MS-RDPEGDI extension. +""" + +from pyrdp.parser.rdp.orders.parse import OrdersParser +from pyrdp.parser.rdp.orders.frontend import GdiFrontend +from .primary import PrimaryContext + +__all__ = [OrdersParser, PrimaryContext, GdiFrontend] diff --git a/pyrdp/parser/rdp/orders/alternate.py b/pyrdp/parser/rdp/orders/alternate.py new file mode 100644 index 000000000..8417f3674 --- /dev/null +++ b/pyrdp/parser/rdp/orders/alternate.py @@ -0,0 +1,232 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +Constants, state and parsing primitives for Alternate Secondary Drawing Orders. +""" +from io import BytesIO + +from pyrdp.core import Uint16LE, Uint8, Uint32LE +from .common import read_color + +STREAM_BITMAP_END = 0x01 +STREAM_BITMAP_COMPRESSED = 0x02 +STREAM_BITMAP_V2 = 0x04 + + +class CreateOffscreenBitmap: + @staticmethod + def parse(s: BytesIO) -> 'CreateOffscreenBitmap': + self = CreateOffscreenBitmap() + + self.flags = Uint16LE.unpack(s) + self.id = self.flags & 0x7FFF + self.cx = Uint16LE.unpack(s) + self.cy = Uint16LE.unpack(s) + + if self.flags & 0x8000 != 0: + cIndices = Uint16LE.unpack(s) + self.delete = [Uint16LE.unpack(s) for _ in range(cIndices)] + else: + self.delete = [] + + return self + + def __str__(self): + return f'' + + +class SwitchSurface: + @staticmethod + def parse(s: BytesIO) -> 'SwitchSurface': + self = SwitchSurface() + + self.id = Uint16LE.unpack(s) + return self + + def __str__(self): + return f'' + + +class CreateNineGridBitmap: + @staticmethod + def parse(s: BytesIO) -> 'CreateNineGridBitmap': + self = CreateNineGridBitmap() + + self.bpp = Uint8.unpack(s) + self.id = Uint16LE.unpack(s) + + self.cx = Uint16LE.unpack(s) + self.cy = Uint16LE.unpack(s) + + # NineGridInfo + self.flFlags = Uint32LE.unpack(s) + self.ulLeftWidth = Uint16LE.unpack(s) + self.ulRightWidth = Uint16LE.unpack(s) + self.ulTopHeight = Uint16LE.unpack(s) + self.ulBottomHeight = Uint16LE.unpack(s) + self.rgb = read_color(s) + + return self + + +class StreamBitmapFirst: + @staticmethod + def parse(s: BytesIO) -> 'StreamBitmapFirst': + self = StreamBitmapFirst() + + self.flags = Uint8.unpack(s) + self.bpp = Uint8.unpack(s) + + self.type = Uint16LE.unpack(s) + self.width = Uint16LE.unpack(s) + self.height = Uint16LE.unpack(s) + + self.totalSize = 0 + if self.flags & STREAM_BITMAP_V2: + self.totalSize = Uint32LE.unpack(s) + else: + self.totalSize = Uint16LE.unpack(s) + + blockSize = Uint16LE.unpack(s) + self.data = s.read(blockSize) + + return self + + +class StreamBitmapNext: + @staticmethod + def parse(s: BytesIO) -> 'StreamBitmapNext': + self = StreamBitmapNext() + + self.flags = Uint8.unpack(s) + self.bitmapType = Uint16LE.unpack(s) + + blockSize = Uint16LE.unpack(s) + self.data = s.read(blockSize) + + return self + + +class GdiPlusFirst: + @staticmethod + def parse(s: BytesIO) -> 'GdiPlusFirst': + self = GdiPlusFirst() + + s.read(1) # Padding + + cbSize = Uint16LE.unpack(s) + self.totalSize = Uint32LE.unpack(s) + self.totalEmfSize = Uint32LE.unpack(s) + self.data = s.read(cbSize) + + return self + + def __str__(self): + return f'' + + +class GdiPlusNext: + @staticmethod + def parse(s: BytesIO) -> 'GdiPlusNext': + self = GdiPlusNext() + + s.read(1) # Padding + + cbSize = Uint16LE.unpack(s) + self.data = s.read(cbSize) + + return self + + def __str__(self): + return f'' + + +class GdiPlusEnd: + @staticmethod + def parse(s: BytesIO) -> 'GdiPlusEnd': + self = GdiPlusEnd() + + s.read(1) # Padding + + cbSize = Uint16LE.unpack(s) + self.totalSize = Uint32LE.unpack(s) + self.totalEmfSize = Uint32LE.unpack(s) + self.data = s.read(cbSize) + + return self + + +class GdiPlusCacheFirst: + @staticmethod + def parse(s: BytesIO) -> 'GdiPlusCacheFirst': + self = GdiPlusCacheFirst() + + self.flags = Uint8.unpack(s) + self.cacheType = Uint16LE.unpack(s) + self.cacheIdx = Uint16LE.unpack(s) + + cbSize = Uint16LE.unpack(s) + self.totalSize = Uint32LE.unpack(s) + self.data = s.read(cbSize) + + return self + + +class GdiPlusCacheNext: + @staticmethod + def parse(s: BytesIO) -> 'GdiPlusCacheNext': + self = GdiPlusCacheNext() + + self.flags = Uint8.unpack(s) + self.cacheType = Uint16LE.unpack(s) + self.cacheIdx = Uint16LE.unpack(s) + + cbSize = Uint16LE.unpack(s) + self.data = s.read(cbSize) + + return self + + +class GdiPlusCacheEnd: + @staticmethod + def parse(s: BytesIO) -> 'GdiPlusCacheEnd': + self = GdiPlusCacheEnd() + + self.flags = Uint8.unpack(s) + self.cacheType = Uint16LE.unpack(s) + self.cacheIndex = Uint16LE.unpack(s) + cbSize = Uint16LE.unpack(s) + self.totalSize = Uint32LE.unpack(s) + self.data = s.read(cbSize) + + return self + + +class FrameMarker: + @staticmethod + def parse(s: BytesIO) -> 'FrameMarker': + self = FrameMarker() + self.action = Uint32LE.unpack(s) + + return self + + def __str__(self): + a = 'BEGIN' if self.action == 0 else 'END' + return f'' + +# class Window: +# @staticmethod +# def parse(s: BytesIO) -> 'Window': +# self = Window() +# # This is specified in MS-RDPERP for seamless applications. +# return self + + +# class CompDeskFirst +# @staticmethod +# def parse(s: BytesIO) -> 'CompDeskFirst': +# self = CompdeskFirst() diff --git a/pyrdp/parser/rdp/orders/common.py b/pyrdp/parser/rdp/orders/common.py new file mode 100644 index 000000000..a4dfacf79 --- /dev/null +++ b/pyrdp/parser/rdp/orders/common.py @@ -0,0 +1,173 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +Common stream reading utilities. + +All section numbers reference MS-RDPEGDI sections: +https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegdi/745f2eee-d110-464c-8aca-06fc1814f6ad +""" +from io import BytesIO +from pyrdp.core.packing import Uint8, Int8, Uint16LE, Int16LE, Uint32LE + + +def read_encoded_uint16(s: BytesIO) -> int: + """Read an encoded UINT16.""" + # 2.2.2.2.1.2.1.2 + b = Uint8.unpack(s) + if b & 0x80: + return (b & 0x7F) << 8 | Uint8.unpack(s) + else: + return b & 0x7F + + +def read_encoded_int16(s: BytesIO) -> int: + # 2.2.2.2.1.2.1.3 + msb = Uint8.unpack(s) + val = msb & 0x3F + + if msb & 0x80: + lsb = Uint8.unpack(s) + val = (val << 8) | lsb + + return -val if msb & 0x40 else val + + +def read_encoded_uint32(s: BytesIO) -> int: + # 2.2.2.2.1.2.1.4 + b = Uint8.unpack(s) + n = (b & 0xC0) >> 6 + if n == 0: + return b & 0x3F + elif n == 1: + return (b & 0x3F) << 8 | Uint8.unpack(s) + elif n == 2: + return ((b & 0x3F) << 16 | Uint8.unpack(s) << 8 | Uint8.unpack(s)) + else: # 3 + return ((b & 0x3F) << 24 | + Uint8.unpack(s) << 16 | + Uint8.unpack(s) << 8 | + Uint8.unpack(s)) + + +def read_color(s: BytesIO): + """ + 2.2.2.2.1.3.4.1.1 TS_COLORREF -> rgb + 2.2.2.2.1.2.4.1 TS_COLOR_QUAD -> bgr + """ + return Uint32LE.unpack(s) & 0x00FFFFFF + + +def read_utf16_str(s: BytesIO, size: int) -> [int]: + return [Uint16LE.unpack(s) for _ in range(size)] # Decode into str? + + +def read_glyph_bitmap(w: int, h: int, s: BytesIO) -> bytes: + """Read and inflate a glyph bitmap.""" + + # Glyph encoding is specified in section 2.2.2.2.1.2.6.1 + scanline = ((w + 7) // 8) + size = scanline * h + packed = s.read(size) + pad = 4 - (size % 4) + + if pad < 4: # Skip alignment padding. + s.read(pad) + + # Convert to 1 byte per pixel format for debugging. + # data = bytearray(w * h) + # for y in range(h): + # line = y * w + # for x in range(w): + # bits = packed[scanline * y + (x // 8)] + # px = (bits >> (8 - (x % 8))) & 1 + # data[line + x] = px + # return data + return packed + + +class Glyph: + """ + TS_CACHE_GLYPH_DATA (2.2.2.2.1.2.5.1) + """ + @staticmethod + def parse(s: BytesIO) -> 'Glyph': + self = Glyph() + self.cacheIndex = Uint16LE.unpack(s) + self.x = Uint16LE.unpack(s) + self.y = Uint16LE.unpack(s) + self.w = Uint16LE.unpack(s) + self.h = Uint16LE.unpack(s) + + self.data = read_glyph_bitmap(self.w, self.h, s) + + return self + + +class GlyphV2: + """ + TS_CACHE_GLYPH_DATA_REV2 (2.2.2.2.1.2.6.1) + """ + @staticmethod + def parse(s: BytesIO) -> Glyph: + self = Glyph() + + self.cacheIndex = Uint8.unpack(s) + + self.x = read_encoded_int16(s) + self.y = read_encoded_int16(s) + self.w = read_encoded_uint16(s) + self.h = read_encoded_uint16(s) + + self.data = read_glyph_bitmap(self.w, self.h, s) + + return self + + +BOUND_LEFT = 0x01 +BOUND_TOP = 0x02 +BOUND_RIGHT = 0x04 +BOUND_BOTTOM = 0x08 +BOUND_DELTA_LEFT = 0x10 +BOUND_DELTA_TOP = 0x20 +BOUND_DELTA_RIGHT = 0x40 +BOUND_DELTA_BOTTOM = 0x80 + + +class Bounds: + """A bounding rectangle.""" + + def __init__(self): + self.left = 0 + self.top = 0 + self.bottom = 0 + self.right = 0 + + def update(self, s: BytesIO): + flags = Uint8.unpack(s) + + if flags & BOUND_LEFT: + self.left = Int16LE.unpack(s) + elif flags & BOUND_DELTA_LEFT: + self.left += Int8.unpack(s) + + if flags & BOUND_TOP: + self.top = Int16LE.unpack(s) + elif flags & BOUND_DELTA_TOP: + self.top += Int8.unpack(s) + + if flags & BOUND_RIGHT: + self.right = Int16LE.unpack(s) + elif flags & BOUND_DELTA_RIGHT: + self.right += Int8.unpack(s) + + if flags & BOUND_BOTTOM: + self.bottom = Int16LE.unpack(s) + elif flags & BOUND_DELTA_BOTTOM: + self.bottom += Int8.unpack(s) + + def __str__(self): + return f'' diff --git a/pyrdp/parser/rdp/orders/frontend.py b/pyrdp/parser/rdp/orders/frontend.py new file mode 100644 index 000000000..5dcfb77e3 --- /dev/null +++ b/pyrdp/parser/rdp/orders/frontend.py @@ -0,0 +1,182 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# +""" +Drawing Order Context. +""" + +from pyrdp.parser.rdp.orders.alternate import CreateOffscreenBitmap, SwitchSurface, CreateNineGridBitmap, \ + StreamBitmapFirst, StreamBitmapNext, GdiPlusFirst, GdiPlusNext, GdiPlusEnd, GdiPlusCacheFirst, \ + GdiPlusCacheNext, GdiPlusCacheEnd, FrameMarker + +from .common import Bounds +from pyrdp.enum import IntEnum + + +class BrushStyle(IntEnum): + SOLID = 0x00 + NULL = 0x01 + HATCHED = 0x02 + PATTERN = 0x03 + + +class HatchStyle(IntEnum): + HORIZONTAL = 0x00 + VERTICAL = 0x01 + FDIAGONAL = 0x02 + BDIAGNOAL = 0x03 + CROSS = 0x04 + DIAGCROSS = 0x05 + + +class GdiFrontend: + """ + Interface for objects that implement GDI. + + This class provides abstract methods to be used by modules + interested in listening and acting upon context updates. + Its primary purpose is for the PyRDP player to render the + remote desktop. + + NOTE: Unimplemented methods will act as No-Op. + """ + # REFACTOR: Move to core, this isn't really relevant to the parser. + + def onBounds(self, b: Bounds): + """ + Called by the parser to configure the bounding rectangle. + """ + pass + + def dstBlt(self, state): + pass + + def patBlt(self, state): + pass + + def scrBlt(self, state): + pass + + def drawNineGrid(self, state): + pass + + def multiDrawNineGrid(self, state): + pass + + def lineTo(self, state): + pass + + def opaqueRect(self, state): + pass + + def saveBitmap(self, state): + pass + + def memBlt(self, state): + pass + + def mem3Blt(self, state): + pass + + def multiDstBlt(self, state): + pass + + def multiPatBlt(self, state): + pass + + def multiScrBlt(self, state): + pass + + def multiOpaqueRect(self, state): + pass + + def fastIndex(self, state): + pass + + def polygonSc(self, state): + pass + + def polygonCb(self, state): + pass + + def polyLine(self, state): + pass + + def fastGlyph(self, state): + pass + + def ellipseSc(self, state): + pass + + def ellipseCb(self, state): + pass + + def glyphIndex(self, state): + pass + + # Secondary Handlers + def cacheBitmapV1(self, state): + pass + + def cacheBitmapV2(self, state): + pass + + def cacheBitmapV3(self, state): + pass + + def cacheColorTable(self, state): + pass + + def cacheGlyph(self, state): + pass + + def cacheBrush(self, state): + pass + + # Alternate Secondary Handlers + def frameMarker(self, state: FrameMarker): + pass + + def createOffscreenBitmap(self, state: CreateOffscreenBitmap): + """ + Create an offscreen bitmap. + """ + pass + + def switchSurface(self, state: SwitchSurface): + """ + Switch drawing surface. + """ + pass + + def createNineGridBitmap(self, state: CreateNineGridBitmap): + """ + Create a Nine Grid bitmap. + """ + pass + + def streamBitmapFirst(self, state: StreamBitmapFirst): + pass + + def streamBitmapNext(self, state: StreamBitmapNext): + pass + + def drawGdiPlusFirst(self, state: GdiPlusFirst): + pass + + def drawGdiPlusNext(self, state: GdiPlusNext): + pass + + def drawGdiPlusEnd(self, state: GdiPlusEnd): + pass + + def drawGdiPlusCacheFirst(self, state: GdiPlusCacheFirst): + pass + + def drawGdiPlusCacheNext(self, state: GdiPlusCacheNext): + pass + + def drawGdiPlusCacheEnd(self, state: GdiPlusCacheEnd): + pass diff --git a/pyrdp/parser/rdp/orders/parse.py b/pyrdp/parser/rdp/orders/parse.py new file mode 100644 index 000000000..853d4be20 --- /dev/null +++ b/pyrdp/parser/rdp/orders/parse.py @@ -0,0 +1,347 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +Parse Drawing Orders. +""" +import logging +from io import BytesIO + +from pyrdp.core.packing import Uint8, Uint16LE +from pyrdp.pdu.rdp.fastpath import FastPathOrdersEvent +from pyrdp.enum.orders import DrawingOrderControlFlags as ControlFlags +from pyrdp.enum.rdp import GlyphSupport +from pyrdp.pdu.rdp.capability import CapabilityType + +from .frontend import GdiFrontend + +from .secondary import CacheBitmapV1, CacheColorTable, CacheGlyph, CacheBitmapV2, CacheBrush, CacheBitmapV3 +from .alternate import CreateOffscreenBitmap, SwitchSurface, CreateNineGridBitmap, \ + StreamBitmapFirst, StreamBitmapNext, GdiPlusFirst, GdiPlusNext, GdiPlusEnd, GdiPlusCacheFirst, \ + GdiPlusCacheNext, GdiPlusCacheEnd, FrameMarker +from .primary import PrimaryContext as Context + +LOG = logging.getLogger(__name__) + + +def _repr(n): + """Internal method to stringify an order type.""" + r = n.__doc__ + return r if r else 'UNKNOWN (%02x)'.format(n) + + +class OrdersParser: + """ + Drawing Order Parser. + """ + + def __init__(self, frontend: GdiFrontend): + """ + Create a drawing order parser. + + :param GdiFrontend frontend: The frontend that will process GDI messages. + """ + + self.notify: GdiFrontend = frontend + self.ctx = Context() + self.glyphLevel: GlyphSupport = GlyphSupport.GLYPH_SUPPORT_NONE + + def onCapabilities(self, caps): + """Update the parser to take into account the capabilities reported by the client.""" + + if CapabilityType.CAPSTYPE_GLYPHCACHE in caps: + glyphLevel = caps[CapabilityType.CAPSTYPE_GLYPHCACHE].glyphSupportLevel + self.glyphLevel = GlyphSupport(glyphLevel) + + def parse(self, orders: FastPathOrdersEvent): + """ + Entrypoint for parsing TS_FP_UPDATE_ORDERS. + """ + + s = BytesIO(orders.payload) + + numberOrders = Uint16LE.unpack(s) + try: + for _ in range(numberOrders): + self._parse_order(s) + except Exception: + LOG.warning('Failed to parse drawing order PDU: %s', orders) + + return orders + + def _parse_order(self, s: BytesIO): + controlFlags = Uint8.unpack(s) + + if not (controlFlags & ControlFlags.TS_STANDARD): + self._parse_altsec(s, controlFlags) + elif (controlFlags & ControlFlags.TS_SECONDARY): + self._parse_secondary(s, controlFlags) + else: + self._parse_primary(s, controlFlags) + + # Primary drawing orders. + # ---------------------------------------------------------------------- + def _parse_primary(self, s: BytesIO, flags: int): + + orderType = self.ctx.update(s, flags) + self.notify.onBounds(self.ctx.bounds if self.ctx.bounded else None) + + assert orderType >= 0 and orderType < len(_pri) + _pri[orderType](self, s) + + def _parse_dstblt(self, s: BytesIO): + """DSTBLT""" + self.notify.dstBlt(self.ctx.dstBlt.update(s)) + + def _parse_patblt(self, s: BytesIO): + """PATBLT""" + self.notify.patBlt(self.ctx.patBlt.update(s)) + + def _parse_scrblt(self, s: BytesIO): + """SCRBLT""" + self.notify.scrBlt(self.ctx.scrBlt.update(s)) + + def _parse_draw_nine_grid(self, s: BytesIO): + """DRAW_NINE_GRID""" + self.notify.drawNineGrid(self.ctx.drawNineGrid.update(s)) + + def _parse_multi_draw_nine_grid(self, s: BytesIO): + """MULTI_DRAW_NINE_GRID""" + self.notify.multiDrawNineGrid(self.ctx.multiDrawNineGrid.update(s)) + + def _parse_line_to(self, s: BytesIO): + """LINE_TO""" + self.notify.lineTo(self.ctx.lineTo.update(s)) + + def _parse_opaque_rect(self, s: BytesIO): + """OPAQUE_RECT""" + self.notify.opaqueRect(self.ctx.opaqueRect.update(s)) + + def _parse_save_bitmap(self, s: BytesIO): + """SAVE_BITMAP""" + self.notify.saveBitmap(self.ctx.saveBitmap.update(s)) + + def _parse_memblt(self, s: BytesIO): + """MEMBLT""" + self.notify.memBlt(self.ctx.memBlt.update(s)) + + def _parse_mem3blt(self, s: BytesIO): + """MEM3BLT""" + self.notify.mem3Blt(self.ctx.mem3Blt.update(s)) + + def _parse_multi_dstblt(self, s: BytesIO): + """MULTI_DSTBLT""" + self.notify.multiDstBlt(self.ctx.multiDstBlt.update(s)) + + def _parse_multi_patblt(self, s: BytesIO): + """MULTI_PATBLT""" + self.notify.multiPatBlt(self.ctx.multiPatBlt.update(s)) + + def _parse_multi_scrblt(self, s: BytesIO): + """MULTI_SCRBLT""" + self.notify.multiScrBlt(self.ctx.multiScrBlt.update(s)) + + def _parse_multi_opaque_rect(self, s: BytesIO): + """MULTI_OPAQUE_RECT""" + self.notify.multiOpaqueRect(self.ctx.multiOpaqueRect.update(s)) + + def _parse_fast_index(self, s: BytesIO): + """FAST_INDEX""" + self.notify.fastIndex(self.ctx.fastIndex.update(s)) + + def _parse_polygon_sc(self, s: BytesIO): + """POLYGON_SC""" + self.notify.polygonSc(self.ctx.polygonSc.update(s)) + + def _parse_polygon_cb(self, s: BytesIO): + """POLYGON_CB""" + self.notify.polygonCb(self.ctx.polygonCb.update(s)) + + def _parse_polyLine(self, s: BytesIO): + """POLYLINE""" + self.notify.polyLine(self.ctx.polyLine.update(s)) + + def _parse_fast_glyph(self, s: BytesIO): + """FAST_GLYPH""" + self.notify.fastGlyph(self.ctx.fastGlyph.update(s)) + + def _parse_ellipse_sc(self, s: BytesIO): + """ELLIPSE_SC""" + self.notify.ellipseSc(self.ctx.ellipseSc.update(s)) + + def _parse_ellipse_cb(self, s: BytesIO): + """ELLIPSE_CB""" + self.notify.ellipseCb(self.ctx.ellipseCb.update(s)) + + def _parse_glyph_index(self, s: BytesIO): + """GLYPH_INDEX""" + self.notify.glyphIndex(self.ctx.glyphIndex.update(s)) + + # Secondary drawing orders. + # ---------------------------------------------------------------------- + def _parse_secondary(self, s: BytesIO, flags: int): + Uint16LE.unpack(s) # orderLength (unused) + extraFlags = Uint16LE.unpack(s) + orderType = Uint8.unpack(s) + + assert orderType >= 0 and orderType < len(_sec) + _sec[orderType](self, s, orderType, extraFlags) + + def _parse_cache_bitmap_v1(self, s: BytesIO, orderType: int, flags: int): + """CACHE_BITMAP_V1""" + self.notify.cacheBitmapV1(CacheBitmapV1.parse(s, orderType, flags)) + + def _parse_cache_color_table(self, s: BytesIO, orderType: int, flags: int): + """CACHE_COLOR_TABLE""" + self.notify.cacheColorTable(CacheColorTable.parse(s, orderType, flags)) + + def _parse_cache_glyph(self, s: BytesIO, orderType: int, flags: int): + """CACHE_GLYPH""" + if self.glyphLevel == GlyphSupport.GLYPH_SUPPORT_NONE: + LOG.warn("Received CACHE_GLYPH but the client reported it doesn't support it!") + # Ignore it. + else: + self.notify.cacheGlyph(CacheGlyph.parse(s, flags, self.glyphLevel)) + + def _parse_cache_bitmap_v2(self, s: BytesIO, orderType: int, flags: int): + """CACHE_BITMAP_V2""" + self.notify.cacheBitmapV2(CacheBitmapV2.parse(s, orderType, flags)) + + def _parse_cache_brush(self, s: BytesIO, orderType: int, flags: int): + """CACHE_BRUSH""" + self.notify.cacheBrush(CacheBrush.parse(s)) + + def _parse_cache_bitmap_v3(self, s: BytesIO, orderType: int, flags: int): + """CACHE_BITMAP_V3""" + self.notify.cacheBitmapV3(CacheBitmapV3.parse(s, flags)) + + # Alternate secondary drawing orders. + # ---------------------------------------------------------------------- + def _parse_altsec(self, s: BytesIO, flags: int): + orderType = flags >> 2 + + assert orderType >= 0 and orderType < len(_alt) + + _alt[orderType](self, s) + + def _parse_create_offscreen_bitmap(self, s: BytesIO): + """CREATE_OFFSCREEN_BITMAP""" + self.notify.createOffscreenBitmap(CreateOffscreenBitmap.parse(s)) + + def _parse_switch_surface(self, s: BytesIO): + """SWITCH_SURFACE""" + self.notify.switchSurface(SwitchSurface.parse(s)) + + def _parse_create_nine_grid_bitmap(self, s: BytesIO): + """CREATE_NINEGRID_BITMAP""" + self.notify.createNineGridBitmap(CreateNineGridBitmap.parse(s)) + + def _parse_stream_bitmap_first(self, s: BytesIO): + """STREAM_BITMAP_FIRST""" + self.notify.streamBitmapFirst(StreamBitmapFirst.parse(s)) + + def _parse_stream_bitmap_next(self, s: BytesIO): + """STREAM_BITMAP_NEXT""" + self.notify.streamBitmapNext(StreamBitmapNext.parse(s)) + + def _parse_gdiplus_first(self, s: BytesIO): + """GDIPLUS_FIRST""" + self.notify.gdiPlusFirst(GdiPlusFirst.parse(s)) + + def _parse_gdiplus_next(self, s: BytesIO): + """GDIPLUS_NEXT""" + self.notify.drawGdiPlusNext(GdiPlusNext.parse(s)) + + def _parse_gdiplus_end(self, s: BytesIO): + """GDIPLUS_END""" + self.notify.drawGdiPlusEnd(GdiPlusEnd.parse(s)) + + def _parse_gdiplus_cache_first(self, s: BytesIO): + """GDIPLUS_CACHE_FIRST""" + self.notify.drawGdiPlusCacheFirst(GdiPlusCacheFirst.parse(s)) + + def _parse_gdiplus_cache_next(self, s: BytesIO): + """GDIPLUS_CACHE_NEXT""" + self.notify.drawGdiPlusCacheNext(GdiPlusCacheNext.parse(s)) + + def _parse_gdiplus_cache_end(self, s: BytesIO): + """GDIPLUS_CACHE_END""" + self.notify.drawGdiPlusCacheEnd(GdiPlusCacheEnd.parse(s)) + + def _parse_window(self, s: BytesIO): + """WINDOW""" + # This is specified in MS-RDPERP for seamless applications. + LOG.debug('WINDOW is not supported yet.') + + def _parse_compdesk_first(self, s: BytesIO): + """COMPDESK""" + LOG.debug('COMPDESK is not supported yet.') + + def _parse_frame_marker(self, s: BytesIO): + """FRAME_MARKER""" + self.notify.frameMarker(FrameMarker.parse(s)) + + +# Parser Lookup Tables +_pri = [ + OrdersParser._parse_dstblt, # 0x00 + OrdersParser._parse_patblt, # 0x01 + OrdersParser._parse_scrblt, # 0x02 + None, # 0x03 + None, # 0x04 + None, # 0x05 + None, # 0x06 + OrdersParser._parse_draw_nine_grid, # 0x07 + OrdersParser._parse_multi_draw_nine_grid, # 0x08 + OrdersParser._parse_line_to, # 0x09 + OrdersParser._parse_opaque_rect, # 0x0A + OrdersParser._parse_save_bitmap, # 0x0B + None, # 0x0C + OrdersParser._parse_memblt, # 0x0D + OrdersParser._parse_mem3blt, # 0x0E + OrdersParser._parse_multi_dstblt, # 0x0F + OrdersParser._parse_multi_patblt, # 0x10 + OrdersParser._parse_multi_scrblt, # 0x11 + OrdersParser._parse_multi_opaque_rect, # 0x12 + OrdersParser._parse_fast_index, # 0x13 + OrdersParser._parse_polygon_sc, # 0x14 + OrdersParser._parse_polygon_cb, # 0x15 + OrdersParser._parse_polyLine, # 0x16 + None, # 0x17 + OrdersParser._parse_fast_glyph, # 0x18 + OrdersParser._parse_ellipse_sc, # 0x19 + OrdersParser._parse_ellipse_cb, # 0x1A + OrdersParser._parse_glyph_index, # 0x1B +] + +_sec = [ + OrdersParser._parse_cache_bitmap_v1, # 0x00 : Uncompressed + OrdersParser._parse_cache_color_table, # 0x01 + OrdersParser._parse_cache_bitmap_v1, # 0x02 : Compressed + OrdersParser._parse_cache_glyph, # 0x03 + OrdersParser._parse_cache_bitmap_v2, # 0x04 : Uncompresed + OrdersParser._parse_cache_bitmap_v2, # 0x05 : Compressed + None, # 0x06 + OrdersParser._parse_cache_brush, # 0x07 + OrdersParser._parse_cache_bitmap_v3, # 0x08 +] + +_alt = [ + OrdersParser._parse_switch_surface, # 0x00 + OrdersParser._parse_create_offscreen_bitmap, # 0x01 + OrdersParser._parse_stream_bitmap_first, # 0x02 + OrdersParser._parse_stream_bitmap_next, # 0x03 + OrdersParser._parse_create_nine_grid_bitmap, # 0x04 + OrdersParser._parse_gdiplus_first, # 0x05 + OrdersParser._parse_gdiplus_next, # 0x06 + OrdersParser._parse_gdiplus_end, # 0x07 + OrdersParser._parse_gdiplus_cache_first, # 0x08 + OrdersParser._parse_gdiplus_cache_next, # 0x09 + OrdersParser._parse_gdiplus_cache_end, # 0x0A + OrdersParser._parse_window, # 0x0B + OrdersParser._parse_compdesk_first, # 0x0C + OrdersParser._parse_frame_marker, # 0x0D +] diff --git a/pyrdp/parser/rdp/orders/primary.py b/pyrdp/parser/rdp/orders/primary.py new file mode 100644 index 000000000..bf96a54ab --- /dev/null +++ b/pyrdp/parser/rdp/orders/primary.py @@ -0,0 +1,1232 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +Constants, state and parsing primitives for Primary Drawing Orders. +""" +from io import BytesIO + +from pyrdp.enum.orders import DrawingOrderControlFlags as ControlFlags +from pyrdp.core.packing import Uint8, Int8, Int16LE, Uint16LE, Uint32LE +from .common import GlyphV2, Bounds +from .secondary import BMF_BPP, CACHED_BRUSH + +# This follows the PrimaryDrawOrderType enum. +ORDERTYPE_FIELDBYTES = [1, 2, 1, 0, 0, 0, 0, 1, 1, 2, 1, 1, 0, 2, 3, 1, 2, 2, 2, 2, 1, 2, 1, 0, 2, 1, 2, 3] + +BACKMODE_TRANSPARENT = 0x01 +BACKMODE_OPAQUE = 0x02 + + +def read_field_flags(s: BytesIO, flags: int, orderType: int) -> int: + """Reads encoded field flags.""" + + # REFACTOR: This could be internal to the context class. + assert orderType >= 0 and orderType < len(ORDERTYPE_FIELDBYTES) + + fieldBytes = ORDERTYPE_FIELDBYTES[orderType] + assert fieldBytes != 0 # Should be a valid orderType + + if flags & ControlFlags.TS_ZERO_FIELD_BYTE_BIT0: + fieldBytes -= 1 + + if flags & ControlFlags.TS_ZERO_FIELD_BYTE_BIT1: + if fieldBytes > 1: + fieldBytes -= 2 + else: + fieldBytes = 0 + + fieldFlags = 0 + for i in range(fieldBytes): + fieldFlags |= Uint8.unpack(s) << (i * 8) + + return fieldFlags + + +def read_coord(s: BytesIO, delta: bool, prev: int): + if delta: + return prev + Int8.unpack(s) + else: + return Int16LE.unpack(s) + + +def read_delta(s: BytesIO) -> int: + msb = Uint8.unpack(s) + val = msb | ~0x3F if msb & 0x40 else msb & 0x3F + if msb & 0x80: + val = (val << 8) | Uint8.unpack(s) + return val + + +def read_rgb(s: BytesIO) -> int: + """Read an RGB color encoded as 0xBBGGRR.""" + r = Uint8.unpack(s) + g = Uint8.unpack(s) + b = Uint8.unpack(s) + return r | g << 8 | b << 16 + + +def read_delta_points(s: BytesIO, n: int, x0: int, y0: int) -> [(int, int)]: + """ + Read an array of delta encoded points. + + :param s: The data stream to parse the delta points from. + :param n: The number of points that are encoded in the stream. + :param x0: The initial value of x. + :param y0: The initial value of y. + + A points is represented as an (x,y)-tuple. + + This function converts the deltas into absolute coordinates. + + https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegdi/6c7b2a52-103c-4a7d-a2a9-997416d4a475 + """ + + zeroBitsLen = ((n + 3) // 4) + zeroBits = s.read(zeroBitsLen) + + dx = x0 + dy = y0 + + points = [] + for i in range(n): + + # Next zeroBits byte. + if i % 4 == 0: + flags = zeroBits[i // 4] + + x = (read_delta(s) + dx) if not flags & 0x80 else dx + y = (read_delta(s) + dy) if not flags & 0x40 else dy + flags <<= 2 + points.append((x, y)) + + # Update previous point coords. + dx = x + dy = y + + return points + + +def read_delta_rectangles(s: BytesIO, n: int) -> [(int, int, int, int)]: + """ + Read an array of delta encoded rectangles. + + :param s: The data stream to parse the rectangles from. + :param n: The number of rectangles encoded in the stream. + + A rectangles is represented as a (left, top, width, height)-tuple. + + This function converts the deltas into absolute coordinates. + + https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegdi/b89f2058-b180-4da0-9bd1-aa694c87768c + """ + + zeroBitsSize = (n + 1) // 2 + zeroBits = s.read(zeroBitsSize) + + rectangles = [] + dl = dt = dw = dh = 0 + + for i in range(n): + if i % 2 == 0: + flags = zeroBits[i // 2] + + left = read_delta(s) + dl if not flags & 0x80 else dl + top = read_delta(s) + dt if not flags & 0x40 else dt + width = read_delta(s) if not flags & 0x20 else dw + height = read_delta(s) if not flags & 0x10 else dh + flags <<= 4 + rectangles.append((left, top, width, height)) + + # Update previous rectangle coords. + dl = left + dt = top + dw = width + dh = height + return rectangles + + +class PrimaryContext: + """Primary drawing order context.""" + + def __init__(self): + # The field flags for the current order. + self.fieldFlags: int = 0 + + # Whether coordinates are being sent as a delta. + self.deltaCoords: bool = False + + # A cache of the previous order type received. + self.orderType: int = None + + # The configured bounding rectangle + self.bounds: Bounds = Bounds() + self.bounded: bool = False + + # Track state for each drawing order. + self.dstBlt = DstBlt(self) + self.patBlt = PatBlt(self) + self.scrBlt = ScrBlt(self) + self.drawNineGrid = DrawNineGrid(self) + self.multiDrawNineGrid = MultiDrawNineGrid(self) + self.lineTo = LineTo(self) + self.opaqueRect = OpaqueRect(self) + self.saveBitmap = SaveBitmap(self) + self.memBlt = MemBlt(self) + self.mem3Blt = Mem3Blt(self) + self.multiDstBlt = MultiDstBlt(self) + self.multiPatBlt = MultiPatBlt(self) + self.multiScrBlt = MultiScrBlt(self) + self.multiOpaqueRect = MultiOpaqueRect(self) + self.fastIndex = FastIndex(self) + self.polygonSc = PolygonSc(self) + self.polygonCb = PolygonCb(self) + self.polyLine = PolyLine(self) + self.fastGlyph = FastGlyph(self) + self.ellipseSc = EllipseSc(self) + self.ellipseCb = EllipseCb(self) + self.glyphIndex = GlyphIndex(self) + + def update(self, s: BytesIO, flags: int): + """ + Update the context when parsing a new primary order. + + This method should be called at the beginning of every new + primary order to process contextual changes. + + :param s BytesIO: The raw byte stream + :param flags int: The controlFlags received in the UPDATE PDU. + + :return: The orderType to act upon. + """ + + if flags & ControlFlags.TS_TYPE_CHANGE: + self.orderType = Uint8.unpack(s) + assert self.orderType is not None + + self.fieldFlags = read_field_flags(s, flags, self.orderType) + + # Process bounding rectangle updates + if flags & ControlFlags.TS_BOUNDS: + self.bounded = True + if not flags & ControlFlags.TS_ZERO_BOUNDS_DELTAS: + self.bounds.update(s) + else: + self.bounded = False + + self.deltaCoords = flags & ControlFlags.TS_DELTA_COORDS != 0 + + return self.orderType + + def field(self, n: int): + """Check whether field `n` is present in the message.""" + return self.fieldFlags & (1 << (n - 1)) != 0 + + +class Brush: + def __init__(self): + self.x = self.y = 0 + self.style = 0 + self.hatch = 0 + self.data = None + self.index = None + self.bpp = 0 + + def update(self, s: BytesIO, flags: int): + if flags & 0b00001: + self.x = Uint8.unpack(s) + if flags & 0b00010: + self.y = Uint8.unpack(s) + if flags & 0b00100: + self.style = Uint8.unpack(s) + if flags & 0b01000: + self.hatch = Uint8.unpack(s) + if flags & 0b10000: + self.data = (s.read(7) + bytes([self.hatch]))[::-1] + + if self.style & CACHED_BRUSH: + self.index = self.hatch + self.bpp = BMF_BPP[self.style & 0x07] + if self.bpp == 0: + self.bpp = 1 + + return self + + +class DstBlt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.x = 0 + self.y = 0 + self.w = 0 + self.h = 0 + self.rop = 0 + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(2): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + if self.ctx.field(3): + self.w = read_coord(s, self.ctx.deltaCoords, self.w) + if self.ctx.field(4): + self.h = read_coord(s, self.ctx.deltaCoords, self.h) + if self.ctx.field(5): + self.rop = Uint8.unpack(s) + + return self + + def __str__(self): + return f'' + + +class PatBlt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.x = 0 + self.y = 0 + self.w = 0 + self.h = 0 + self.rop = 0 + self.bg = 0 + self.fg = 0 + self.brush = Brush() + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(2): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + if self.ctx.field(3): + self.w = read_coord(s, self.ctx.deltaCoords, self.w) + if self.ctx.field(4): + self.h = read_coord(s, self.ctx.deltaCoords, self.h) + if self.ctx.field(5): + self.rop = Uint8.unpack(s) + if self.ctx.field(6): + self.bg = read_rgb(s) + if self.ctx.field(7): + self.fg = read_rgb(s) + + self.brush.update(s, self.ctx.fieldFlags >> 7) + + return self + + def __str__(self): + return f'' + + +class ScrBlt: + """ + 2.2.2.2.1.1.2.7 + """ + + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.nLeftRect = 0 + self.nTopRect = 0 + self.nWidth = 0 + self.nHeight = 0 + self.bRop = 0 + self.nXSrc = 0 + self.nYSrc = 0 + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.nLeftRect = read_coord(s, self.ctx.deltaCoords, self.nLeftRect) + if self.ctx.field(2): + self.nTopRect = read_coord(s, self.ctx.deltaCoords, self.nTopRect) + if self.ctx.field(3): + self.nWidth = read_coord(s, self.ctx.deltaCoords, self.nWidth) + if self.ctx.field(4): + self.nHeight = read_coord(s, self.ctx.deltaCoords, self.nHeight) + if self.ctx.field(5): + self.bRop = Uint8.unpack(s) + if self.ctx.field(6): + self.nXSrc = read_coord(s, self.ctx.deltaCoords, self.nXSrc) + if self.ctx.field(7): + self.nYSrc = read_coord(s, self.ctx.deltaCoords, self.nYSrc) + + return self + + def __str__(self): + return (f'' + + +class MultiDrawNineGrid: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.srcLeft = 0 + self.srcTop = 0 + self.srcRight = 0 + self.srcBottom = 0 + self.bitmapId = 0 + self.nDeltaEntries = 0 + self.cbData = 0 + self.rectangles = [] + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.srcLeft = read_coord(s, self.ctx.deltaCoords, self.srcLeft) + if self.ctx.field(2): + self.srcTop = read_coord(s, self.ctx.deltaCoords, self.srcTop) + if self.ctx.field(3): + self.srcRight = read_coord(s, self.ctx.deltaCoords, self.srcRight) + if self.ctx.field(4): + self.srcBottom = read_coord(s, self.ctx.deltaCoords, self.srcBottom) + if self.ctx.field(5): + self.bitmapId = Uint16LE.unpack(s) + if self.ctx.field(6): + self.nDeltaEntries = Uint8.unpack(s) + + if self.ctx.field(7): + self.cbData = Uint16LE.unpack(s) + self.rectangles = read_delta_rectangles(s, self.nDeltaEntries) + + return self + + def __str__(self): + return '' + + +class LineTo: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.bgMode = 0 + self.x0 = 0 + self.y0 = 0 + self.x1 = 0 + self.y1 = 0 + self.bg = 0 + self.rop2 = 0 + self.penStyle = 0 + self.penWidth = 0 + self.penColor = 0 + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.bgMode = Uint16LE.unpack(s) + if self.ctx.field(2): + self.x0 = read_coord(s, self.ctx.deltaCoords, self.x0) + if self.ctx.field(3): + self.y0 = read_coord(s, self.ctx.deltaCoords, self.y0) + if self.ctx.field(4): + self.x1 = read_coord(s, self.ctx.deltaCoords, self.x1) + if self.ctx.field(5): + self.y1 = read_coord(s, self.ctx.deltaCoords, self.y1) + if self.ctx.field(6): + self.bg = read_rgb(s) + if self.ctx.field(7): + self.rop2 = Uint8.unpack(s) + if self.ctx.field(8): + self.penStyle = Uint8.unpack(s) + if self.ctx.field(9): + self.penWidth = Uint8.unpack(s) + if self.ctx.field(10): + self.penColor = read_rgb(s) + + return self + + def __str__(self): + return '' + + +class OpaqueRect: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.x = 0 + self.y = 0 + self.w = 0 + self.h = 0 + self.color = 0 # 0xBBGGRR + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(2): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + if self.ctx.field(3): + self.w = read_coord(s, self.ctx.deltaCoords, self.w) + if self.ctx.field(4): + self.h = read_coord(s, self.ctx.deltaCoords, self.h) + + if self.ctx.field(5): + r = Uint8.unpack(s) + self.color = (self.color & 0x00FFFF00) | r + if self.ctx.field(6): + g = Uint8.unpack(s) + self.color = (self.color & 0x00FF00FF) | (g << 8) + if self.ctx.field(7): + b = Uint8.unpack(s) + self.color = (self.color & 0x0000FFFF) | (b << 16) + + return self + + def __str__(self): + return f'' + + +class SaveBitmap: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.savedBitmapPosition = 0 + self.nLeftRect = 0 + self.nTopRect = 0 + self.nRightRect = 0 + self.nBottomRect = 0 + self.operation = 0 + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.savedBitmapPosition = Uint32LE.unpack(s) + if self.ctx.field(2): + self.nLeftRect = read_coord(s, self.ctx.deltaCoords, self.nLeftRect) + if self.ctx.field(3): + self.nTopRect = read_coord(s, self.ctx.deltaCoords, self.nTopRect) + if self.ctx.field(4): + self.nRightRect = read_coord(s, self.ctx.deltaCoords, self.nRightRect) + if self.ctx.field(5): + self.nBottomRect = read_coord(s, self.ctx.deltaCoords, self.nBottomRect) + if self.ctx.field(6): + self.operation = Uint8.unpack(s) + + return self + + def __str__(self): + return '' + + +class MemBlt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + # Blit rectangle. + self.left = self.top = self.width = self.height = 0 + + # Source buffer offsets. + self.xSrc = self.ySrc = 0 + + self.cacheIndex = 0 + self.cacheId = 0 + self.colorIndex = 0 + + def update(self, s: BytesIO) -> 'MemBlt': + ctx = self.ctx + + if ctx.field(1): + self.cacheId = Uint16LE.unpack(s) + if ctx.field(2): + self.left = read_coord(s, ctx.deltaCoords, self.left) + if ctx.field(3): + self.top = read_coord(s, ctx.deltaCoords, self.top) + if ctx.field(4): + self.width = read_coord(s, ctx.deltaCoords, self.width) + if ctx.field(5): + self.height = read_coord(s, ctx.deltaCoords, self.height) + if ctx.field(6): + self.rop = Uint8.unpack(s) + if ctx.field(7): + self.xSrc = read_coord(s, ctx.deltaCoords, self.xSrc) + if ctx.field(8): + self.ySrc = read_coord(s, ctx.deltaCoords, self.ySrc) + if ctx.field(9): + self.cacheIndex = Uint16LE.unpack(s) + + self.colorIndex = self.cacheId >> 8 + self.cacheId = self.cacheId & 0xFF + + return self + + def __str__(self): + return (f'') + + +class Mem3Blt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.brush = Brush() + + self.cacheId = 0 + self.left = 0 + self.top = 0 + self.width = 0 + self.height = 0 + self.rop = 0 + self.nXSrc = 0 + self.nYSrc = 0 + self.bg = 0 + self.fg = 0 + self.cacheIndex = 0 + self.colorIndex = 0 + self.cacheId = 0 + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.cacheId = Uint16LE.unpack(s) + if self.ctx.field(2): + self.left = read_coord(s, self.ctx.deltaCoords, self.left) + if self.ctx.field(3): + self.top = read_coord(s, self.ctx.deltaCoords, self.top) + if self.ctx.field(4): + self.width = read_coord(s, self.ctx.deltaCoords, self.width) + if self.ctx.field(5): + self.height = read_coord(s, self.ctx.deltaCoords, self.height) + if self.ctx.field(6): + self.rop = Uint8.unpack(s) + if self.ctx.field(7): + self.nXSrc = read_coord(s, self.ctx.deltaCoords, self.nXSrc) + if self.ctx.field(8): + self.nYSrc = read_coord(s, self.ctx.deltaCoords, self.nYSrc) + if self.ctx.field(9): + self.bg = read_rgb(s) + if self.ctx.field(10): + self.fg = read_rgb(s) + + self.brush.update(s, self.ctx.fieldFlags >> 10) + + if self.ctx.field(16): + self.cacheIndex = Uint16LE.unpack(s) + + self.colorIndex = self.cacheId >> 8 + self.cacheId = self.cacheId & 0xFF + + return self + + def __str__(self): + return '' + + +class MultiDstBlt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.x = 0 + self.y = 0 + self.w = 0 + self.h = 0 + self.rop = 0 + self.numRectangles = 0 + self.cbData = 0 + self.rectangles = [] + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(2): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + if self.ctx.field(3): + self.w = read_coord(s, self.ctx.deltaCoords, self.w) + if self.ctx.field(4): + self.h = read_coord(s, self.ctx.deltaCoords, self.h) + if self.ctx.field(5): + self.rop = Uint8.unpack(s) + if self.ctx.field(6): + self.numRectangles = Uint8.unpack(s) + + if self.ctx.field(7): + self.cbData = Uint16LE.unpack(s) + self.rectangles = read_delta_rectangles(s, self.numRectangles) + + return self + + def __str__(self): + return '' + + +class MultiPatBlt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.brush = Brush() + + self.x = 0 + self.y = 0 + self.w = 0 + self.h = 0 + self.rop = 0 + self.bg = 0 + self.fg = 0 + self.numRectangles = 0 + self.cbData = 0 + self.rectangles = [] + + def update(self, s: BytesIO): + + if self.ctx.field(1): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(2): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + if self.ctx.field(3): + self.w = read_coord(s, self.ctx.deltaCoords, self.w) + if self.ctx.field(4): + self.h = read_coord(s, self.ctx.deltaCoords, self.h) + if self.ctx.field(5): + self.rop = Uint8.unpack(s) + if self.ctx.field(6): + self.bg = read_rgb(s) + if self.ctx.field(7): + self.fg = read_rgb(s) + + self.brush.update(s, self.ctx.fieldFlags >> 7) + + if self.ctx.field(13): + self.numRectangles = Uint8.unpack(s) + + if self.ctx.field(14): + self.cbData = Uint16LE.unpack(s) + self.rectangles = read_delta_rectangles(s, self.numRectangles) + + return self + + def __str__(self): + return '' + + +class MultiScrBlt: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.nLeftRect = 0 + self.nTopRect = 0 + self.nWidth = 0 + self.nHeight = 0 + self.bRop = 0 + self.nXSrc = 0 + self.nYSrc = 0 + self.numRectangles = 0 + self.cbData = 0 + self.rectangles = [] + + def update(self, s: BytesIO): + + if self.ctx.field(1): + self.nLeftRect = read_coord(s, self.ctx.deltaCoords, self.nLeftRect) + if self.ctx.field(2): + self.nTopRect = read_coord(s, self.ctx.deltaCoords, self.nTopRect) + if self.ctx.field(3): + self.nWidth = read_coord(s, self.ctx.deltaCoords, self.nWidth) + if self.ctx.field(4): + self.nHeight = read_coord(s, self.ctx.deltaCoords, self.nHeight) + if self.ctx.field(5): + self.bRop = Uint8.unpack(s) + if self.ctx.field(6): + self.nXSrc = read_coord(s, self.ctx.deltaCoords, self.nXSrc) + if self.ctx.field(7): + self.nYSrc = read_coord(s, self.ctx.deltaCoords, self.nYSrc) + if self.ctx.field(8): + self.numRectangles = Uint8.unpack(s) + + if self.ctx.field(9): + self.cbData = Uint16LE.unpack(s) + self.rectangles = read_delta_rectangles(s, self.numRectangles) + + return self + + def __str__(self): + return '' + + +class MultiOpaqueRect: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.nLeftRect = 0 + self.nTopRect = 0 + self.nWidth = 0 + self.nHeight = 0 + self.color = 0 # 0xBBGGRR + self.numRectangles = 0 + self.cbData = 0 + self.rectangles = [] + + def update(self, s: BytesIO): + + if self.ctx.field(1): + self.nLeftRect = read_coord(s, self.ctx.deltaCoords, self.nLeftRect) + if self.ctx.field(2): + self.nTopRect = read_coord(s, self.ctx.deltaCoords, self.nTopRect) + if self.ctx.field(3): + self.nWidth = read_coord(s, self.ctx.deltaCoords, self.nWidth) + if self.ctx.field(4): + self.nHeight = read_coord(s, self.ctx.deltaCoords, self.nHeight) + + if self.ctx.field(5): + r = Uint8.unpack(s) + self.color = (self.color & 0x00FFFF00) | r + if self.ctx.field(6): + g = Uint8.unpack(s) + self.color = (self.color & 0x00FF00FF) | (g << 8) + if self.ctx.field(7): + b = Uint8.unpack(s) + self.color = (self.color & 0x0000FFFF) | (b << 16) + + if self.ctx.field(8): + self.numRectangles = Uint8.unpack(s) + + if self.ctx.field(9): + self.cbData = Uint16LE.unpack(s) + self.rectangles = read_delta_rectangles(s, self.numRectangles) + + return self + + def __str__(self): + return f'' + + +class FastIndex: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.cacheId = 0 + self.ulCharInc = 0 + self.flAccel = 0 + self.bg = 0 + self.fg = 0 + self.bkLeft = 0 + self.bkTop = 0 + self.bkRight = 0 + self.bkBottom = 0 + self.opLeft = 0 + self.opTop = 0 + self.opRight = 0 + self.opBottom = 0 + self.x = 0 + self.y = 0 + + self.data = b'' + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.cacheId = Uint8.unpack(s) + if self.ctx.field(2): + self.ulCharInc = Uint8.unpack(s) + self.flAccel = Uint8.unpack(s) + if self.ctx.field(3): + self.bg = read_rgb(s) + if self.ctx.field(4): + self.fg = read_rgb(s) + if self.ctx.field(5): + self.bkLeft = read_coord(s, self.ctx.deltaCoords, self.bkLeft) + if self.ctx.field(6): + self.bkTop = read_coord(s, self.ctx.deltaCoords, self.bkTop) + if self.ctx.field(7): + self.bkRight = read_coord(s, self.ctx.deltaCoords, self.bkRight) + if self.ctx.field(8): + self.bkBottom = read_coord(s, self.ctx.deltaCoords, self.bkBottom) + if self.ctx.field(9): + self.opLeft = read_coord(s, self.ctx.deltaCoords, self.opLeft) + if self.ctx.field(10): + self.opTop = read_coord(s, self.ctx.deltaCoords, self.opTop) + if self.ctx.field(11): + self.opRight = read_coord(s, self.ctx.deltaCoords, self.opRight) + if self.ctx.field(12): + self.opBottom = read_coord(s, self.ctx.deltaCoords, self.opBottom) + if self.ctx.field(13): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(14): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + + if self.ctx.field(15): + cbData = Uint8.unpack(s) + self.data = s.read(cbData) + + return self + + def __str__(self): + return f'' + + +class PolygonSc: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.x0 = 0 + self.y0 = 0 + self.rop2 = 0 + self.fillMode = 0 + self.brushColor = 0 + self.cbData = 0 + self.numPoints = 0 + self.points = [] + + def update(self, s: BytesIO): + num = self.numPoints + + if self.ctx.field(1): + self.x0 = read_coord(s, self.ctx.deltaCoords, self.x0) + if self.ctx.field(2): + self.y0 = read_coord(s, self.ctx.deltaCoords, self.y0) + if self.ctx.field(3): + self.rop2 = Uint8.unpack(s) + if self.ctx.field(4): + self.fillMode = Uint8.unpack(s) + if self.ctx.field(5): + self.brushColor = read_rgb(s) + + if self.ctx.field(6): + num = Uint8.unpack(s) + + if self.ctx.field(7): + self.cbData = Uint8.unpack(s) + self.numPoints = num + self.points = read_delta_points(s, self.numPoints, self.x0, self.y0) + + return self + + def __str__(self): + return '' + + +class PolygonCb: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.brush = Brush() + + self.x0 = 0 + self.y0 = 0 + self.rop2 = 0 + self.fillMode = 0 + self.bg = 0 + self.fg = 0 + self.cbData = 0 + self.numPoints = 0 + self.points = [] + self.bgMode = BACKMODE_OPAQUE + + def update(self, s: BytesIO): + + num = self.numPoints + if self.ctx.field(1): + self.x0 = read_coord(s, self.ctx.deltaCoords, self.x0) + if self.ctx.field(2): + self.y0 = read_coord(s, self.ctx.deltaCoords, self.y0) + if self.ctx.field(3): + self.rop2 = Uint8.unpack(s) + if self.ctx.field(4): + self.fillMode = Uint8.unpack(s) + if self.ctx.field(5): + self.bg = read_rgb(s) + if self.ctx.field(6): + self.fg = read_rgb(s) + + self.brush.update(s, self.ctx.fieldFlags >> 6) + + if self.ctx.field(12): + num = Uint8.unpack(s) + + if self.ctx.field(13): + self.cbData = Uint8.unpack(s) + self.numPoints = num + self.points = read_delta_points(s, self.numPoints, self.x0, self.y0) + + self.bgMode = BACKMODE_TRANSPARENT if self.rop2 & 0x80 else BACKMODE_OPAQUE + self.rop2 = self.rop2 & 0x1F + + return self + + def __str__(self): + return '' + + +class PolyLine: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.x0 = 0 + self.y0 = 0 + self.rop2 = 0 + self.penColor = 0 + self.cbData = 0 + self.numPoints = 0 + self.points = [] + + def update(self, s: BytesIO): + num = self.numPoints + if self.ctx.field(1): + self.x0 = read_coord(s, self.ctx.deltaCoords, self.x0) + if self.ctx.field(2): + self.y0 = read_coord(s, self.ctx.deltaCoords, self.y0) + if self.ctx.field(3): + self.rop2 = Uint8.unpack(s) + if self.ctx.field(4): + s.read(2) # unused (brushCacheIndex) + if self.ctx.field(5): + self.penColor = read_rgb(s) + if self.ctx.field(6): + num = Uint8.unpack(s) + + if self.ctx.field(7): + self.cbData = Uint8.unpack(s) + self.numPoints = num + self.points = read_delta_points(s, self.numPoints, self.x0, self.y0) + + return self + + def __str__(self): + return '' + + +class FastGlyph: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.cacheId = 0 + self.cacheIndex = 0 + self.glyph = None + self.ulCharInc = 0 + self.flAccel = 0 + self.bg = 0 + self.fg = 0 + + # Text background coords. + self.bkLeft = 0 + self.bkTop = 0 + self.bkRight = 0 + self.bkBottom = 0 + + # Opaque rectangle coords. (0 -> same as Bk*) + self.opLeft = 0 + self.opTop = 0 + self.opRight = 0 + self.opBottom = 0 + + # Position of the glyph. + self.x = 0 + self.y = 0 + + def update(self, s: BytesIO): + if self.ctx.field(1): + self.cacheId = Uint8.unpack(s) + if self.ctx.field(2): + self.ulCharInc = Uint8.unpack(s) + self.flAccel = Uint8.unpack(s) + if self.ctx.field(3): + self.bg = read_rgb(s) + if self.ctx.field(4): + self.fg = read_rgb(s) + if self.ctx.field(5): + self.bkLeft = read_coord(s, self.ctx.deltaCoords, self.bkLeft) + if self.ctx.field(6): + self.bkTop = read_coord(s, self.ctx.deltaCoords, self.bkTop) + if self.ctx.field(7): + self.bkRight = read_coord(s, self.ctx.deltaCoords, self.bkRight) + if self.ctx.field(8): + self.bkBottom = read_coord(s, self.ctx.deltaCoords, self.bkBottom) + if self.ctx.field(9): + self.opLeft = read_coord(s, self.ctx.deltaCoords, self.opLeft) + if self.ctx.field(10): + self.opTop = read_coord(s, self.ctx.deltaCoords, self.opTop) + if self.ctx.field(11): + self.opRight = read_coord(s, self.ctx.deltaCoords, self.opRight) + if self.ctx.field(12): + self.opBottom = read_coord(s, self.ctx.deltaCoords, self.opBottom) + if self.ctx.field(13): + self.x = read_coord(s, self.ctx.deltaCoords, self.x) + if self.ctx.field(14): + self.y = read_coord(s, self.ctx.deltaCoords, self.y) + + if self.ctx.field(15): + cbData = Uint8.unpack(s) + + if cbData > 1: + # Read glyph data. + self.glyph = GlyphV2.parse(s) + self.cacheIndex = self.glyph.cacheIndex + s.read(2) # Padding / Unicode representation + else: + # Only a cache index. + assert cbData == 1 + self.glyph = None # Glyph must be retrieved from cacheIndex + self.cacheIndex = Uint8.unpack(s) + + return self + + def __str__(self): + return f'' + + +class EllipseSc: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.left = 0 + self.top = 0 + self.right = 0 + self.bottom = 0 + self.rop2 = 0 + self.fillMode = 0 + self.color = 0 + + def update(self, s: BytesIO): + + if self.ctx.field(1): + self.left = read_coord(s, self.ctx.deltaCoords, self.left) + if self.ctx.field(2): + self.top = read_coord(s, self.ctx.deltaCoords, self.top) + if self.ctx.field(3): + self.right = read_coord(s, self.ctx.deltaCoords, self.right) + if self.ctx.field(4): + self.bottom = read_coord(s, self.ctx.deltaCoords, self.bottom) + if self.ctx.field(5): + self.rop2 = Uint8.unpack(s) + if self.ctx.field(6): + self.fillMode = Uint8.unpack(s) + if self.ctx.field(7): + self.color = read_rgb(s) + + return self + + def __str__(self): + return '' + + +class EllipseCb: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + self.brush = Brush() + + self.left = 0 + self.top = 0 + self.right = 0 + self.bottom = 0 + self.rop2 = 0 + self.fillMode = 0 + self.bg = 0 + self.fg = 0 + + def update(self, s: BytesIO): + + if self.ctx.field(1): + self.left = read_coord(s, self.ctx.deltaCoords, self.left) + if self.ctx.field(2): + self.top = read_coord(s, self.ctx.deltaCoords, self.top) + if self.ctx.field(3): + self.right = read_coord(s, self.ctx.deltaCoords, self.right) + if self.ctx.field(4): + self.bottom = read_coord(s, self.ctx.deltaCoords, self.bottom) + if self.ctx.field(5): + self.rop2 = Uint8.unpack(s) + if self.ctx.field(6): + self.fillMode = Uint8.unpack(s) + if self.ctx.field(7): + self.bg = read_rgb(s) + if self.ctx.field(8): + self.fg = read_rgb(s) + + self.brush.update(s, self.ctx.fieldFlags >> 8) + + return self + + def __str__(self): + return '' + + +class GlyphIndex: + def __init__(self, ctx: PrimaryContext): + self.ctx = ctx + + self.cacheId = 0 + self.flAccel = 0 + self.ulCharInc = 0 + self.fOpRedundant = 0 + self.bg = 0 + self.fg = 0 + self.bkLeft = 0 + self.bkTop = 0 + self.bkRight = 0 + self.bkBottom = 0 + self.opLeft = 0 + self.opTop = 0 + self.opRight = 0 + self.opBottom = 0 + + self.brush = Brush() + + self.x = 0 + self.y = 0 + + self.data = b'' + + def update(self, s: BytesIO): + + if self.ctx.field(1): + self.cacheId = Uint8.unpack(s) + if self.ctx.field(2): + self.flAccel = Uint8.unpack(s) + if self.ctx.field(3): + self.ulCharInc = Uint8.unpack(s) + if self.ctx.field(4): + self.fOpRedundant = Uint8.unpack(s) + if self.ctx.field(5): + self.bg = read_rgb(s) + if self.ctx.field(6): + self.fg = read_rgb(s) + if self.ctx.field(7): + self.bkLeft = Uint16LE.unpack(s) + if self.ctx.field(8): + self.bkTop = Uint16LE.unpack(s) + if self.ctx.field(9): + self.bkRight = Uint16LE.unpack(s) + if self.ctx.field(10): + self.bkBottom = Uint16LE.unpack(s) + if self.ctx.field(11): + self.opLeft = Uint16LE.unpack(s) + if self.ctx.field(12): + self.opTop = Uint16LE.unpack(s) + if self.ctx.field(13): + self.opRight = Uint16LE.unpack(s) + if self.ctx.field(14): + self.opBottom = Uint16LE.unpack(s) + + self.brush.update(s, self.ctx.fieldFlags >> 14) + + if self.ctx.field(20): + self.x = Uint16LE.unpack(s) + if self.ctx.field(21): + self.y = Uint16LE.unpack(s) + + if self.ctx.field(22): + cbData = Uint8.unpack(s) + self.data = s.read(cbData) + + return self + + def __str__(self): + return '' diff --git a/pyrdp/parser/rdp/orders/secondary.py b/pyrdp/parser/rdp/orders/secondary.py new file mode 100644 index 000000000..5f271ac38 --- /dev/null +++ b/pyrdp/parser/rdp/orders/secondary.py @@ -0,0 +1,300 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +Constants, state and parsing primitives for Secondary Drawing Orders. +""" +from io import BytesIO + +from pyrdp.core.packing import Uint8, Uint16LE, Uint32LE +from pyrdp.enum.orders import Secondary +from pyrdp.enum.rdp import GeneralExtraFlag, GlyphSupport +from .common import read_color, read_utf16_str, read_encoded_uint16, read_encoded_uint32, Glyph, GlyphV2 + +CBR2_BPP = [0, 0, 0, 8, 16, 24, 32] +BPP_CBR2 = [0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, + 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0] + +CBR23_BPP = [0, 0, 0, 8, 16, 24, 32] +BPP_CBR23 = [0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, + 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0] + +BMF_BPP = [0, 1, 0, 8, 16, 24, 32, 0] +BPP_BMF = [0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, + 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0] + +CBR2_HEIGHT_SAME_AS_WIDTH = 0x01 +CBR2_PERSISTENT_KEY_PRESENT = 0x02 +CBR2_NO_BITMAP_COMPRESSION_HDR = 0x08 +CBR2_DO_NOT_CACHE = 0x10 + +BITMAP_CACHE_WAITING_LIST_INDEX = 0x7FFF +CG_GLYPH_UNICODE_PRESENT = 0x100 + +CACHED_BRUSH = 0x80 + + +def inflate_brush(packed: bytes) -> bytes: + """Convert a packed 8 pixels-per-byte brush to 1 byte per pixel.""" + assert len(packed) == 8 + return bytes([(b >> n) & 1 for b in packed for n in range(8)]) + + +def decompress_brush(s: BytesIO, bpp: int) -> bytes: + """ + Decompress brush data. + + The brush data is encoded in reverse order + """ + bitmap = s.read(16) + + paletteBpp = (bpp + 1) // 8 + palette = s.read(paletteBpp*4) + brush = bytes(paletteBpp * 64) # 8x8 = 64 pixels + + i = 0 + for y in range(8): + y = 8 - y + for x in range(8): + if x % 4 == 0: + pixel = bitmap[i] + i += 1 + + # Encoded as 2-bit per pixel: 00112233. + color = (pixel >> (3 - (x % 4)) * 2) & 0x3 + + # Copy `paletteBpp` bytes into the brush. + src = color * paletteBpp + dst = (y * 8 + x) * paletteBpp + brush[dst:dst+paletteBpp] = palette[src:src+paletteBpp] + + return brush + + +class CacheBitmap: + """ + Common type between all cached bitmaps. + """ + + def __init__(self, rev: int): + self.rev = rev + self.bpp = 0 + self.cacheId = 0 + self.cacheIndex = 0 + self.width = self.height = 0 + self.data = b'' + + +class CacheBitmapV1(CacheBitmap): + @staticmethod + def parse(s: BytesIO, orderType: int, flags: int) -> CacheBitmap: + self = CacheBitmapV1(1) + + self.cacheId = Uint8.unpack(s) + + s.read(1) # Padding + + self.width = Uint8.unpack(s) + self.height = Uint8.unpack(s) + self.bpp = Uint8.unpack(s) + + bitmapLength = Uint16LE.unpack(s) + self.cacheIndex = Uint16LE.unpack(s) + + if orderType & Secondary.CACHE_BITMAP_COMPRESSED and \ + not flags & GeneralExtraFlag.NO_BITMAP_COMPRESSION_HDR: + self.compression = s.read(8) + bitmapLength -= 8 + + self.data = s.read(bitmapLength) + + return self + + def __str__(self): + return (f'') + + +class CacheColorTable: + @staticmethod + def parse(s: BytesIO) -> 'CacheColorTable': + self = CacheColorTable() + + self.cacheIndex = Uint8.unpack(s) + numberColors = Uint16LE.unpack(s) + + assert numberColors == 256 + self.colors = [read_color(s) for _ in range(numberColors)] + + return self + + +class CacheGlyph: + @staticmethod + def parse(s: BytesIO, flags: int, glyph: GlyphSupport) -> 'CacheGlyph': + """ + Parse a CACHE_GLYPH order. + + :param s: The byte stream to parse + :param flags: The UPDATE PDU controlFlags + :param glyph: One of Glyph or GlyphV2 classes to select the parsing strategy + """ + + self = CacheGlyph() + + if GlyphSupport.GLYPH_SUPPORT_ENCODE: + self.cacheId = flags & 0x0F + cGlyphs = flags >> 8 + unicodePresent = (flags >> 4 & 0x0F) & 0x01 + self.glyphs = [GlyphV2.parse(s) for _ in range(cGlyphs)] + else: + self.cacheId = Uint8.unpack(s) + cGlyphs = Uint8.unpack(s) + unicodePresent = flags & CG_GLYPH_UNICODE_PRESENT + self.glyphs = [Glyph.parse(s) for _ in range(cGlyphs)] + + if unicodePresent and cGlyphs > 0: + self.unicode = read_utf16_str(s, cGlyphs) + + return self + + +class CacheBitmapV2(CacheBitmap): + def __init__(self): + super().__init__(2) + + self.flags = 0 + self.key1 = self.key2 = 0 + self.height = self.width = 0 + + self.cbCompFirstRowSize = 0 + self.cbCompMainBodySize = 0 + self.cbScanWidth = 0 + self.cbUncompressedSize = 0 + + @staticmethod + def parse(s: BytesIO, orderType: int, flags: int) -> CacheBitmap: + self = CacheBitmapV2() + + self.cacheId = flags & 0x0003 + self.flags = (flags & 0xFF80) >> 7 + self.bpp = CBR2_BPP[(flags & 0x0078) >> 3] + + if self.flags & CBR2_PERSISTENT_KEY_PRESENT: + self.key1 = Uint32LE.unpack(s) + self.key2 = Uint32LE.unpack(s) + + if self.flags & CBR2_HEIGHT_SAME_AS_WIDTH: + self.height = self.width = read_encoded_uint16(s) + else: + self.width = read_encoded_uint16(s) + self.height = read_encoded_uint16(s) + + bitmapLength = read_encoded_uint32(s) + self.cacheIndex = read_encoded_uint16(s) + + if self.flags & CBR2_DO_NOT_CACHE: + self.cacheIndex = BITMAP_CACHE_WAITING_LIST_INDEX + + if orderType & Secondary.BITMAP_COMPRESSED_V2 and not \ + self.flags & CBR2_NO_BITMAP_COMPRESSION_HDR: + # Parse compression header + self.cbCompFirstRowSize = Uint16LE.unpack(s) + self.cbCompMainBodySize = Uint16LE.unpack(s) + self.cbScanWidth = Uint16LE.unpack(s) + self.cbUncompressedSize = Uint16LE.unpack(s) + + bitmapLength = self.cbCompMainBodySize + + # Read bitmap data + self.data = s.read(bitmapLength) + + return self + + def __str__(self): + return (f'') + + +class CacheBitmapV3(CacheBitmap): + @staticmethod + def parse(s: BytesIO, flags: int) -> CacheBitmap: + self = CacheBitmapV3(3) + + self.cacheId = flags & 0x00000003 + self.flags = (flags & 0x0000FF80) >> 7 + bitsPerPixelId = (flags & 0x00000078) >> 3 + + # The spec says this should never be 0, but it is... + self.bpp = CBR23_BPP[bitsPerPixelId] + + self.cacheIndex = Uint16LE.unpack(s) + self.key1 = Uint32LE.unpack(s) + self.key2 = Uint32LE.unpack(s) + self.bpp = Uint8.unpack(s) + + compressed = Uint8.unpack(s) + s.read(1) # Reserved (1 bytes) + + self.codecId = Uint8.unpack(s) + self.width = Uint16LE.unpack(s) + self.height = Uint16LE.unpack(s) + dataLen = Uint32LE.unpack(s) + + if compressed: # TS_COMPRESSED_BITMAP_HEADER_EX present. + s.read(24) # Non-essential. + + self.data = s.read(dataLen) + + return self + + def __str__(self): + return (f'') + + +class CacheBrush: + @staticmethod + def parse(s: BytesIO) -> 'CacheBrush': + self = CacheBrush() + + self.cacheIndex = Uint8.unpack(s) + + iBitmapFormat = Uint8.unpack(s) + assert iBitmapFormat >= 0 and iBitmapFormat < len(BMF_BPP) + + self.bpp = BMF_BPP[iBitmapFormat] + + cx = self.width = Uint8.unpack(s) + cy = self.height = Uint8.unpack(s) + + Uint8.unpack(s) # Unusued (style) + # assert style == 0 # (2.2.2.2.1.2.7 Appendix 4) ... Not respected (: + + iBytes = Uint8.unpack(s) + + compressed = False + if cx == 8 and cy == 8 and self.bpp == 1: # 8x8 mono bitmap + self.data = s.read(8)[::-1] + else: + if self.bpp == 8 and iBytes == 20: + compressed = True + elif self.bpp == 16 and iBytes == 24: + compressed = True + elif self.bpp == 24 and iBytes == 32: + compressed = True + + if compressed: + self.data = decompress_brush(s, self.bpp) + else: + self.data = bytes(256) # Preallocate + scanline = (self.bpp // 8) * 8 + for i in range(7): + # TODO: Verify correctness + o = (7-i)*scanline + self.data[o:o+8] = s.read(scanline) + + return self diff --git a/pyrdp/parser/rdp/slowpath.py b/pyrdp/parser/rdp/slowpath.py index 64713042c..ffe2b7235 100644 --- a/pyrdp/parser/rdp/slowpath.py +++ b/pyrdp/parser/rdp/slowpath.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -13,9 +13,10 @@ from pyrdp.parser.rdp.input import SlowPathInputParser from pyrdp.parser.rdp.pointer import PointerEventParser from pyrdp.pdu import BitmapCapability, Capability, ConfirmActivePDU, ControlPDU, DemandActivePDU, GeneralCapability, \ - GlyphCacheCapability, InputPDU, MultifragmentUpdateCapability, OffscreenBitmapCacheCapability, OrderCapability, PDU, \ - PlaySoundPDU, PointerCapability, PointerPDU, SetErrorInfoPDU, ShareControlHeader, ShareDataHeader, SlowPathPDU, \ - SlowPathUnparsedPDU, SuppressOutputPDU, SynchronizePDU, UpdatePDU, VirtualChannelCapability + GlyphCacheCapability, InputPDU, MultifragmentUpdateCapability, OffscreenBitmapCacheCapability, OrderCapability, \ + PDU, PlaySoundPDU, PointerCapability, PointerPDU, SetErrorInfoPDU, ShareControlHeader, ShareDataHeader, \ + SlowPathPDU, SlowPathUnparsedPDU, SuppressOutputPDU, SynchronizePDU, UpdatePDU, VirtualChannelCapability, \ + PersistentCacheKeysPDU from pyrdp.pdu.rdp.capability import SurfaceCommandsCapability @@ -37,6 +38,7 @@ def __init__(self): SlowPathDataType.PDUTYPE2_PLAY_SOUND: self.parsePlaySound, SlowPathDataType.PDUTYPE2_SUPPRESS_OUTPUT: self.parseSuppressOutput, SlowPathDataType.PDUTYPE2_UPDATE: self.parseUpdate, + SlowPathDataType.PDUTYPE2_BITMAPCACHE_PERSISTENT_LIST: self.parsePersistentCacheKeys, } self.dataWriters = { @@ -48,6 +50,7 @@ def __init__(self): SlowPathDataType.PDUTYPE2_PLAY_SOUND: self.writePlaySound, SlowPathDataType.PDUTYPE2_SUPPRESS_OUTPUT: self.writeSuppressOutput, SlowPathDataType.PDUTYPE2_UPDATE: self.writeUpdate, + SlowPathDataType.PDUTYPE2_BITMAPCACHE_PERSISTENT_LIST: self.writePersistentCacheKeys, } def parse(self, data: bytes) -> PDU: @@ -103,7 +106,7 @@ def writeData(self, stream: BytesIO, pdu): self.dataWriters[pdu.header.subtype](stream, pdu) def parseShareControlHeader(self, stream: BytesIO): - length = Uint16LE.unpack(stream) + Uint16LE.unpack(stream) # length (unused) pduType = Uint16LE.unpack(stream) source = Uint16LE.unpack(stream) return ShareControlHeader(SlowPathPDUType(pduType & 0xf), (pduType >> 4), source) @@ -122,7 +125,10 @@ def parseShareDataHeader(self, stream: BytesIO, controlHeader: ShareControlHeade pduSubtype = Uint8.unpack(stream) compressedType = Uint8.unpack(stream) compressedLength = Uint16LE.unpack(stream) - return ShareDataHeader(controlHeader.pduType, controlHeader.version, controlHeader.source, shareID, streamID, uncompressedLength, SlowPathDataType(pduSubtype), compressedType, compressedLength) + return ShareDataHeader(controlHeader.pduType, + controlHeader.version, controlHeader.source, + shareID, streamID, uncompressedLength, + SlowPathDataType(pduSubtype), compressedType, compressedLength) def writeShareDataHeader(self, stream: BytesIO, header, dataLength): substream = BytesIO() @@ -144,12 +150,13 @@ def parseDemandActive(self, stream: BytesIO, header): lengthCombinedCapabilities = Uint16LE.unpack(stream) sourceDescriptor = stream.read(lengthSourceDescriptor) numberCapabilities = Uint16LE.unpack(stream) - pad2Octets = stream.read(2) + stream.read(2) # Padding capabilitySets = stream.read(lengthCombinedCapabilities - 4) sessionID = Uint32LE.unpack(stream) parsedCapabilitySets = self.parseCapabilitySets(capabilitySets, numberCapabilities) - return DemandActivePDU(header, shareID, sourceDescriptor, numberCapabilities, capabilitySets, sessionID, parsedCapabilitySets) + return DemandActivePDU(header, shareID, sourceDescriptor, numberCapabilities, + capabilitySets, sessionID, parsedCapabilitySets) def writeDemandActive(self, stream: BytesIO, pdu: DemandActivePDU): Uint32LE.pack(pdu.shareID, stream) @@ -625,3 +632,40 @@ def writePointerCapability(self, capability: PointerCapability, stream: BytesIO) Uint16LE.pack(len(substream.getvalue()) + 4, stream) stream.write(substream.getvalue()) + + def parsePersistentCacheKeys(self, stream: BytesIO, header): + num0 = Uint16LE.unpack(stream) + num1 = Uint16LE.unpack(stream) + num2 = Uint16LE.unpack(stream) + num3 = Uint16LE.unpack(stream) + num4 = Uint16LE.unpack(stream) + + total0 = Uint16LE.unpack(stream) + total1 = Uint16LE.unpack(stream) + total2 = Uint16LE.unpack(stream) + total3 = Uint16LE.unpack(stream) + total4 = Uint16LE.unpack(stream) + bBitMask = Uint8.unpack(stream) + + stream.read(3) # Padding + + keys = stream.read(64 * (num0 + num1 + num2 + num3 + num4)) + return PersistentCacheKeysPDU(header, num0, num1, num2, num3, num4, + total0, total1, total2, total3, total4, keys, bBitMask) + + def writePersistentCacheKeys(self, s: BytesIO, pdu: PersistentCacheKeysPDU): + # Only send the first PDU with an empty list and drop the rest. + # TODO: Find a way to cleanly drop the entire packet instead. + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint16LE.pack(0, s) + Uint8.pack(pdu.mask, s) + s.write(b'\x00'*3) diff --git a/pyrdp/pdu/__init__.py b/pyrdp/pdu/__init__.py index ac9386e9a..ecc815d0c 100644 --- a/pyrdp/pdu/__init__.py +++ b/pyrdp/pdu/__init__.py @@ -1,8 +1,9 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018, 2019 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # +# flake8: noqa from pyrdp.pdu.gcc import GCCConferenceCreateRequestPDU, GCCConferenceCreateResponsePDU, GCCPDU from pyrdp.pdu.mcs import MCSAttachUserConfirmPDU, MCSAttachUserRequestPDU, MCSChannelJoinConfirmPDU, \ @@ -29,7 +30,7 @@ ServerNetworkData, ServerSecurityData from pyrdp.pdu.rdp.fastpath import FastPathBitmapEvent, FastPathEvent, FastPathEventRaw, FastPathInputEvent, \ FastPathMouseEvent, FastPathOrdersEvent, FastPathOutputEvent, FastPathOutputEvent, FastPathPDU, \ - FastPathScanCodeEvent, FastPathUnicodeEvent, SecondaryDrawingOrder + FastPathScanCodeEvent, FastPathUnicodeEvent from pyrdp.pdu.rdp.input import ExtendedMouseEvent, KeyboardEvent, MouseEvent, SlowPathInput, SynchronizeEvent, \ UnicodeKeyboardEvent, UnusedEvent from pyrdp.pdu.rdp.licensing import LicenseBinaryBlob, LicenseErrorAlertPDU, LicensingPDU @@ -39,7 +40,7 @@ from pyrdp.pdu.rdp.security import SecurityExchangePDU, SecurityPDU from pyrdp.pdu.rdp.slowpath import ConfirmActivePDU, ControlPDU, DemandActivePDU, InputPDU, PlaySoundPDU, PointerPDU, \ SetErrorInfoPDU, ShareControlHeader, ShareDataHeader, SlowPathPDU, SlowPathUnparsedPDU, SuppressOutputPDU, \ - SynchronizePDU, UpdatePDU + SynchronizePDU, UpdatePDU, PersistentCacheKeysPDU 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, \ diff --git a/pyrdp/pdu/rdp/fastpath.py b/pyrdp/pdu/rdp/fastpath.py index c00e3dbe2..e058e0564 100644 --- a/pyrdp/pdu/rdp/fastpath.py +++ b/pyrdp/pdu/rdp/fastpath.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -50,6 +50,7 @@ class FastPathOutputEvent(FastPathEvent): """ https://msdn.microsoft.com/en-us/library/cc240622.aspx """ + def __init__(self, header: int, compressionFlags: Optional[int], payload: bytes = b""): super().__init__(payload) self.header = header @@ -90,30 +91,19 @@ def __init__(self, text: Union[str, bytes], released: bool): class FastPathBitmapEvent(FastPathOutputEvent): - def __init__(self, header: int, compressionFlags: Optional[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 class FastPathOrdersEvent(FastPathOutputEvent): """ + Encapsulate drawing orders. + https://msdn.microsoft.com/en-us/library/cc241573.aspx """ - def __init__(self, header: int, compressionFlags: Optional[int], orderCount: int, orderData: bytes): - super().__init__(header, compressionFlags) - self.compressionFlags = compressionFlags - self.orderCount = orderCount - self.orderData = orderData - self.secondaryDrawingOrders = None - -class SecondaryDrawingOrder: - """ - https://msdn.microsoft.com/en-us/library/cc241611.aspx - """ - def __init__(self, controlFlags: int, orderLength: int, extraFlags: int, orderType: int): - super().__init__() - self.controlFlags = controlFlags - self.orderLength = orderLength - self.extraFlags = extraFlags - self.orderType = orderType + def __init__(self, header: int, compressionFlags: Optional[int], payload: bytes): + super().__init__(header, compressionFlags, payload=payload) + self.compressionFlags = compressionFlags diff --git a/pyrdp/pdu/rdp/slowpath.py b/pyrdp/pdu/rdp/slowpath.py index a2d3e8a92..13885e848 100644 --- a/pyrdp/pdu/rdp/slowpath.py +++ b/pyrdp/pdu/rdp/slowpath.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -20,7 +20,8 @@ def __init__(self, pduType, version, source): class ShareDataHeader(ShareControlHeader): - def __init__(self, pduType, version, source, shareID, streamID, uncompressedLength, subtype, compressedType, compressedLength): + def __init__(self, pduType, version, source, shareID, streamID, + uncompressedLength, subtype, compressedType, compressedLength): ShareControlHeader.__init__(self, pduType, version, source) self.shareID = shareID self.streamID = streamID @@ -123,7 +124,8 @@ def __init__(self, header, event): class SuppressOutputPDU(SlowPathPDU): - def __init__(self, header, allowDisplayUpdates, left: Optional[int], top: Optional[int], right: Optional[int], bottom: Optional[int]): + def __init__(self, header, allowDisplayUpdates, + left: Optional[int], top: Optional[int], right: Optional[int], bottom: Optional[int]): super().__init__(header) self.allowDisplayUpdates = bool(allowDisplayUpdates) @@ -138,3 +140,22 @@ def __init__(self, header: ShareDataHeader, updateType: SlowPathUpdateType, upda super().__init__(header) self.updateType = updateType self.updateData = updateData + + +class PersistentCacheKeysPDU(SlowPathPDU): + def __init__(self, header, num0, num1, num2, num3, num4, total0, total1, total2, total3, total4, keys, mask): + super().__init__(header) + self.num0 = num0 + self.num1 = num1 + self.num2 = num2 + self.num3 = num3 + self.num4 = num4 + + self.total0 = total0 + self.total1 = total1 + self.total2 = total2 + self.total3 = total3 + self.total4 = total4 + + self.keys = keys + self.mask = mask diff --git a/pyrdp/player/PlayerEventHandler.py b/pyrdp/player/PlayerEventHandler.py index ae7eabec8..3e0cde1a8 100644 --- a/pyrdp/player/PlayerEventHandler.py +++ b/pyrdp/player/PlayerEventHandler.py @@ -4,8 +4,6 @@ # Licensed under the GPLv3 or later. # -from typing import Optional, Union - from PySide2.QtCore import QObject from PySide2.QtGui import QTextCursor from PySide2.QtWidgets import QTextEdit @@ -16,11 +14,14 @@ 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.pdu import BitmapUpdateData, ConfirmActivePDU, FastPathBitmapEvent, FastPathOrdersEvent, \ + FastPathMouseEvent, FastPathOutputEvent, FastPathScanCodeEvent, FastPathUnicodeEvent, FormatDataResponsePDU, \ + InputPDU, KeyboardEvent, MouseEvent, PlayerDeviceMappingPDU, PlayerPDU, UpdatePDU from pyrdp.ui import QRemoteDesktop, RDPBitmapToQtImage +from .gdi import GdiQtFrontend +from pyrdp.parser.rdp.orders import OrdersParser + class PlayerEventHandler(QObject, Observer): """ @@ -45,6 +46,8 @@ def __init__(self, viewer: QRemoteDesktop, text: QTextEdit): PlayerPDUType.DEVICE_MAPPING: self.onDeviceMapping, } + self.gdi: GdiQtFrontend = None + self.orders: OrdersParser = None def writeText(self, text: str): self.text.moveCursor(QTextCursor.End) @@ -53,8 +56,7 @@ def writeText(self, text: str): def writeSeparator(self): self.writeText("\n--------------------\n") - - def onPDUReceived(self, pdu: PlayerPDU, isMainThread = False): + def onPDUReceived(self, pdu: PlayerPDU, isMainThread=False): if not isMainThread: self.viewer.mainThreadHook.emit(lambda: self.onPDUReceived(pdu, True)) return @@ -64,7 +66,6 @@ def onPDUReceived(self, pdu: PlayerPDU, isMainThread = False): if pdu.header in self.handlers: self.handlers[pdu.header](pdu) - def onClientData(self, pdu: PlayerPDU): """ Prints the clientName on the screen @@ -77,7 +78,6 @@ def onClientData(self, pdu: PlayerPDU): self.writeText(f"HOST: {clientName}\n") self.writeSeparator() - def onClientInfo(self, pdu: PlayerPDU): parser = ClientInfoParser() clientInfoPDU = parser.parse(pdu.payload) @@ -92,11 +92,9 @@ def onClientInfo(self, pdu: PlayerPDU): self.writeSeparator() - def onConnectionClose(self, _: PlayerPDU): self.writeText("\n") - def onClipboardData(self, pdu: PlayerPDU): parser = ClipboardParser() pdu = parser.parse(pdu.payload) @@ -110,7 +108,6 @@ def onClipboardData(self, pdu: PlayerPDU): self.writeText(f"CLIPBOARD DATA: {clipboardData}") self.writeSeparator() - def onSlowPathPDU(self, pdu: PlayerPDU): parser = SlowPathParser() pdu = parser.parse(pdu.payload) @@ -118,6 +115,12 @@ def onSlowPathPDU(self, pdu: PlayerPDU): if isinstance(pdu, ConfirmActivePDU): bitmapCapability = pdu.parsedCapabilitySets[CapabilityType.CAPSTYPE_BITMAP] self.viewer.resize(bitmapCapability.desktopWidth, bitmapCapability.desktopHeight) + + # Enable MS-RDPEGDI parsing and rendering. + if CapabilityType.CAPSTYPE_ORDER in pdu.parsedCapabilitySets: + self.gdi = GdiQtFrontend(self.viewer) + self.orders = OrdersParser(self.gdi) + self.orders.onCapabilities(pdu.parsedCapabilitySets) elif isinstance(pdu, UpdatePDU) and pdu.updateType == SlowPathUpdateType.SLOWPATH_UPDATETYPE_BITMAP: updates = BitmapParser().parseBitmapUpdateData(pdu.updateData) @@ -128,27 +131,35 @@ def onSlowPathPDU(self, pdu: PlayerPDU): 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) - + 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) + for fragment in pdu.events: + event = self.mergeFragments(fragment) - if reassembledEvent is not None: - if isinstance(reassembledEvent, FastPathBitmapEvent): - self.onFastPathBitmap(reassembledEvent) + if event is not None: + if isinstance(event, FastPathBitmapEvent): + self.onFastPathBitmap(event) + elif isinstance(event, FastPathOrdersEvent): + if self.orders is None: + log.error('Received Unexpected Drawing Orders!') + return + self.onFastPathOrders(event) - def reassembleEvent(self, event: FastPathOutputEvent) -> Optional[Union[FastPathBitmapEvent, FastPathOutputEvent]]: + def mergeFragments(self, event: FastPathOutputEvent) -> 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. + Handles FastPath fragment reassembly. + + This is documented at https://msdn.microsoft.com/en-us/library/cc240622.aspx. + + :param event: A potentially fragmented FastPath output event + :return: The reassembled FastPath PDU once available, or None if the PDU is a fragment fragmented and is + not fully reassembled yet. + If the event is not fragmented, it is returned with no further processing. """ fragmentationFlag = FastPathFragmentation((event.header & 0b00110000) >> 4) @@ -162,8 +173,9 @@ def reassembleEvent(self, event: FastPathOutputEvent) -> Optional[Union[FastPath self.buffer += event.payload event.payload = self.buffer - return FastPathOutputParser().parseBitmapEvent(event) + return event + # Partial fragment, don't parse it yet. return None def onFastPathBitmap(self, event: FastPathBitmapEvent): @@ -173,6 +185,8 @@ def onFastPathBitmap(self, event: FastPathBitmapEvent): for bitmapData in parsedEvent.bitmapUpdateData: self.handleBitmap(bitmapData) + def onFastPathOrders(self, event: FastPathOrdersEvent): + self.orders.parse(event) def onFastPathInput(self, pdu: PlayerPDU): parser = BasicFastPathParser(ParserMode.SERVER) @@ -187,11 +201,9 @@ def onFastPathInput(self, pdu: PlayerPDU): elif isinstance(event, FastPathScanCodeEvent): self.onScanCode(event.scanCode, event.isReleased, event.rawHeaderByte & scancode.KBDFLAGS_EXTENDED != 0) - def onUnicode(self, event: FastPathUnicodeEvent): self.writeText(str(event.text)) - def onMouse(self, event: FastPathMouseEvent): if event.pointerFlags & PointerFlag.PTRFLAGS_DOWN: if event.pointerFlags & PointerFlag.PTRFLAGS_BUTTON1: @@ -209,7 +221,6 @@ def onMouse(self, event: FastPathMouseEvent): def onMousePosition(self, x: int, y: int): self.viewer.setMousePosition(x, y) - def onScanCode(self, scanCode: int, isReleased: bool, isExtended: bool): """ Handle scan code. @@ -230,7 +241,6 @@ def onScanCode(self, scanCode: int, isReleased: bool, isExtended: bool): elif scanCode == 0x3A and not isReleased: self.capsLockOn = not self.capsLockOn - def handleBitmap(self, bitmapData: BitmapUpdateData): image = RDPBitmapToQtImage( bitmapData.width, @@ -247,6 +257,5 @@ def handleBitmap(self, bitmapData: BitmapUpdateData): 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}>") diff --git a/pyrdp/player/gdi/__init__.py b/pyrdp/player/gdi/__init__.py new file mode 100644 index 000000000..2c0ced148 --- /dev/null +++ b/pyrdp/player/gdi/__init__.py @@ -0,0 +1,9 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +# flake8: noqa + +from .draw import GdiQtFrontend diff --git a/pyrdp/player/gdi/cache.py b/pyrdp/player/gdi/cache.py new file mode 100644 index 000000000..c42a29ba6 --- /dev/null +++ b/pyrdp/player/gdi/cache.py @@ -0,0 +1,202 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +""" +GDI Cache Management Layer. +""" + +from PySide2.QtCore import QSize +from PySide2.QtGui import QBrush, QImage, QBitmap + +from pyrdp.parser.rdp.orders.common import Glyph + + +class BitmapCache: + """Bitmap cache.""" + + def __init__(self, persist=False): + self.caches = {} + self.persist = persist + if persist: + raise Exception('Persistent cache is not supported yet.') + + def has(self, cid: int, idx: int) -> bool: + """ + Check whether a cache contains an entry. + + :param cid: The cache id to use. + :param idx: The cache entry index. + + :returns: True if (cid:idx) is in the cache, false otherwise. + """ + if cid not in self.caches: + return False + cache = self.caches[cid] + return idx in cache + + def get(self, cid: int, idx: int) -> QImage: + """ + Retrieve an entry from the cache. + + :param cid: The cache id to use. + :param idx: The cache entry index. + + :returns: The cache entry or None if it does not exist. + """ + if cid not in self.caches: + return None + cache = self.caches[cid] + if idx not in cache: + return None + return cache[idx] + + def add(self, cid: int, idx: int, entry: QImage) -> bool: + """ + Add an entry to the cache. + + :returns: True if the entry is a fresh entry, False if it replaced an existing one. + """ + if cid not in self.caches: + self.caches[cid] = {} + cache = self.caches[cid] + cache[idx] = entry + + def evict(self, cid: int, idx: int) -> bool: + """ + Evict an entry from the cache. + + :param cid: The cache id to use. + :param idx: The cache entry index. + + :returns: True if an entry was evicted, false otherwise. + """ + if not self.has(cid, idx): + return False + del self.caches[cid][idx] + + +class BrushCache: + """Brush cache.""" + + def __init__(self): + self.entries = {} + + def has(self, idx: int) -> bool: + return idx in self.entries + + def get(self, idx: int) -> QBrush: + if idx in self.entries: + return self.entries[idx] + else: + return None + + def add(self, idx: int, b: QBrush): + self.entries[idx] = b + + +class PaletteCache: + """ColorTable cache.""" + + def __init__(self): + self.entries = {} + + def has(self, idx: int) -> bool: + return idx in self.entries + + def get(self, idx: int) -> [int]: + if idx in self.entries: + return self.entries[idx] + else: + return None + + def add(self, idx: int, colors: [int]): + self.entries[idx] = colors + + +class NineGridCache: + """NineGrid bitmap cache.""" + + def __init__(self): + self.entries = {} + + def has(self, idx: int) -> bool: + return idx in self.entries + + def get(self, idx: int) -> QImage: + if idx in self.entries: + return self.entries[idx] + else: + return None + + def add(self, idx: int, bmp: QImage): + self.entries[idx] = bmp + + +class GlyphEntry: + """Glyph cache entry.""" + + def __init__(self, glyph: Glyph): + """Construct a cache entry from a glyph.""" + + # Glyph origin. + self.x = glyph.x + self.y = glyph.y + self.w = glyph.w + self.h = glyph.h + + self.bitmap = QBitmap.fromData(QSize(self.w, self.h), glyph.data, QImage.Format_Mono) + + +class GlyphCache: + """Glyph cache.""" + + def __init__(self): + self.caches = {} + self.fragments = {} + + def get(self, cid: int, idx: int) -> GlyphEntry: + """ + Retrieve an entry from the cache. + + :param cid: The cache id to use. + :param idx: The cache entry index. + + :returns: The cache entry or None if it does not exist. + """ + if cid not in self.caches: + return None + cache = self.caches[cid] + if idx not in cache: + return None + return cache[idx] + + def add(self, cid: int, idx: int, entry: GlyphEntry) -> bool: + """ + Add an entry to the cache. + + :returns: True if the entry is a fresh entry, False if it replaced an existing one. + """ + if cid not in self.caches: + self.caches[cid] = {} + cache = self.caches[cid] + cache[idx] = entry + + def getFragment(self, cid: int, fid: int) -> bytes: + """Get a glyph fragment.""" + if cid not in self.fragments: + return None + cache = self.fragments[cid] + if fid not in cache: + return None + return cache[fid] + + def addFragment(self, cid: int, fid: int, frag: bytes): + """Store a glyph fragment.""" + if cid not in self.fragments: + self.fragments[cid] = {} + + cache = self.fragments[cid] + cache[fid] = frag diff --git a/pyrdp/player/gdi/draw.py b/pyrdp/player/gdi/draw.py new file mode 100644 index 000000000..958e92078 --- /dev/null +++ b/pyrdp/player/gdi/draw.py @@ -0,0 +1,701 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +import logging +import typing + +from pyrdp.parser.rdp.orders import GdiFrontend +from pyrdp.parser.rdp.orders.common import Bounds +from pyrdp.parser.rdp.orders.frontend import BrushStyle, HatchStyle +from pyrdp.parser.rdp.orders.alternate import CreateOffscreenBitmap, SwitchSurface, CreateNineGridBitmap, \ + StreamBitmapFirst, StreamBitmapNext, GdiPlusFirst, GdiPlusNext, GdiPlusEnd, GdiPlusCacheFirst, \ + GdiPlusCacheNext, GdiPlusCacheEnd, FrameMarker +from pyrdp.parser.rdp.orders.secondary import CacheBitmapV1, CacheBitmapV2, CacheBitmapV3, CacheColorTable, \ + CacheGlyph, CacheBrush +from pyrdp.parser.rdp.orders.primary import DstBlt, PatBlt, ScrBlt, DrawNineGrid, MultiDrawNineGrid, \ + LineTo, OpaqueRect, SaveBitmap, MemBlt, Mem3Blt, MultiDstBlt, MultiPatBlt, MultiScrBlt, MultiOpaqueRect, \ + FastIndex, PolygonSc, PolygonCb, PolyLine, FastGlyph, EllipseSc, EllipseCb, GlyphIndex, Brush, \ + BACKMODE_TRANSPARENT + +from pyrdp.ui import QRemoteDesktop, RDPBitmapToQtImage + +from .cache import BitmapCache, BrushCache, PaletteCache, GlyphCache, GlyphEntry +from .raster import set_rop3, set_rop2 + +from PySide2.QtCore import Qt, QPoint +from PySide2.QtGui import QImage, QPainter, QColor, QPixmap, QBrush, QPen, QPolygon + +LOG = logging.getLogger(__name__) + +SCREEN_BITMAP_SURFACE = 0xFFFF +BITMAPCACHE_SCREEN_ID = 0xFF +SUBSTITUTE_SURFACE = -1 + +# Opaque Rectangle Encoding Flags. +OPRECT_BOTTOM_ABSENT = 0x01 +OPRECT_RIGHT_ABSENT = 0x02 +OPRECT_TOP_ABSENT = 0x04 +OPRECT_LEFT_ABSENT = 0x08 + +GLYPH_SPECIAL_PROCESSING = -32768 +GLYPH_FRAGMENT_USE = 0xFE +GLYPH_FRAGMENT_ADD = 0xFF + +# Defined in 2.2.2.2.1.1.2.13 (GlyphIndex) +SO_VERTICAL = 0x01 +SO_HORIZONTAL = 0x02 +SO_CHAR_INC_EQUAL_BM_BASE = 0x20 + + +def rgb_to_qcolor(color: int): + """Convert an RDP color (0xRRGGBB) to a QColor.""" + bpp = 16 + # TODO: check BPP from capabilities + if bpp in [24, 32]: + return QColor(color) + if bpp == 16: + t = color & 0x1F + t = (t << 3) + t // 4 + b = min(t, 255) + + t = (color >> 5) & 0x3F + t = (t << 2) + t // 4 // 2 + g = min(t, 255) + + t = (color >> 11) & 0x1F + t = (t << 3) + t // 4 + r = min(t, 255) + + elif bpp == 15: + pass + elif bpp == 8: # TODO: Support palettized mode. + pass + return QColor.fromRgb(r, g, b) + + +class GdiQtFrontend(GdiFrontend): + """ + A Qt Frontend for GDI drawing operations. + + This acts as a straight adapter from GDI to Qt as much as + possible, but GDI specific operations that are not supported by Qt + are implemented here. + + Some of the methods are not implemented and will simply ignore the + order: + + - NineGrid-related orders + - StreamBitmap orders + - GDI+ orders + + Note that `CacheBitmapV3` is only used in 32 bits-per-pixel which + PyRDP currently does not support due to RDP6.0 compression not + being implemented yet. + + If you have a replay which contains any unsupported or untested + order, do not hesitate to share it with the project maintainers so + that support can be added as required. + + (Make sure that the trace does not contain any sensitive information) + """ + + def __init__(self, dc: QRemoteDesktop): + self.dc = dc + self._warned = False + + # Initialize caches. + self.bitmaps = BitmapCache() + self.brushes = BrushCache() + self.palettes = PaletteCache() + self.glyphs = GlyphCache() + + self.bounds = None + + screen = dc.screen + fallback = QImage(dc.width(), dc.height(), QImage.Format_ARGB32_Premultiplied) + fallback.fill(0) + + self.surfaces = { + SCREEN_BITMAP_SURFACE: screen, + SUBSTITUTE_SURFACE: fallback, + } + self.activeSurface = SCREEN_BITMAP_SURFACE + + @property + def surface(self) -> QImage: + """Get the currently active surface.""" + return self.surfaces[self.activeSurface] + + @property + def screen(self) -> QImage: + return self.surfaces[SCREEN_BITMAP_SURFACE] + + # Rendering Helpers. + def _paint(self, dst: QImage): + """Retrieve QPainter for the given surface.""" + p = QPainter(dst) + p.setPen(Qt.NoPen) + # Set the bounding rectangle if present. + if self.bounds: + x = self.bounds.left + y = self.bounds.top + w = self.bounds.right - x + h = self.bounds.bottom - y + + p.setClipRect(x, y, w + 1, h + 1) + p.setClipping(True) + return p + + def _end(self, p: QPainter): + p.end() + + def _brush(self, b: Brush, p: QPainter): + """Configure the given brush.""" + brush = None + + if b.index is not None: # This is a cached brush. + brush = self.brushes.get(b.index) + elif b.style == BrushStyle.PATTERN: + pm = QPixmap.loadFromData(b.data, _fmt[b.bpp]) + brush = QBrush(pm) + elif b.style == BrushStyle.HATCHED: + brush = QBrush(_hs[b.hatch]) + else: + brush = QBrush(_bs[b.style]) + + p.setBrush(brush) + p.setBrushOrigin(b.x, b.y) + + # Drawing API. + def onBounds(self, bounds: Bounds): + """Called on bounding rectangle updates.""" + self.bounds = bounds + + def dstBlt(self, state: DstBlt): + """Destination-only blitting operation.""" + LOG.debug(state) + p = self._paint(self.surface) + set_rop3(state.rop, p) + p.fillRect(state.x, state.y, state.w, state.h, Qt.SolidPattern) + self._end(p) + + def multiDstBlt(self, state: MultiDstBlt): + """Destination-only blitting operation.""" + LOG.debug(state) + p = self._paint(self.surface) + set_rop3(state.rop, p) + + for (x, y, w, h) in state.rectangles: + p.fillRect(x, y, w, h, Qt.SolidPattern) + self._end(p) + + def patBlt(self, state: PatBlt): + LOG.debug(state) + p = self._paint(self.surface) + self._brush(state.brush, p) + set_rop3(state.rop, p) + + p.brush().setColor(rgb_to_qcolor(state.fg)) + p.setBackground(QBrush(rgb_to_qcolor(state.bg))) + + p.drawRect(state.x, state.y, state.w, state.h) + self._end(p) + + def multiPatBlt(self, state: MultiPatBlt): + LOG.debug(state) + p = self._paint(self.surface) + self._brush(state.brush) + set_rop3(state.rop, p) + + p.brush().setColor(rgb_to_qcolor(state.fg)) + p.setBackground(QBrush(rgb_to_qcolor(state.bg))) + + for (x, y, w, h) in state.rectangles: + p.drawRect(x, y, w, h) + self._end(p) + + def scrBlt(self, state: ScrBlt): + LOG.debug(state) + src = self.screen + dst = self.surface + if src == dst: # Qt doesn't support drawing to the source surface + src = dst.copy() + + p = self._paint(dst) + set_rop3(state.bRop, p) + + p.drawImage(state.nLeftRect, state.nTopRect, src, state.nXSrc, state.nYSrc, state.nWidth, state.nHeight) + self._end(p) + + def multiScrBlt(self, state: MultiScrBlt): + LOG.debug(state) + src = self.screen + dst = self.surface + if src == dst: # Qt doesn't support drawing to the source surface + src = dst.copy() + + p = self._paint(dst) + set_rop3(state.bRop, p) + + p.drawImage(state.nLeftRect, state.nTopRect, src, state.nXSrc, state.nYSrc, state.nWidth, state.nHeight) + + # Doesn't seem to be necessary. + # for (x, y, w, h) in state.rectangles: + # p.drawImage(x, y, src, state.nXSrc, state.nYSrc, w, h) + self._end(p) + + def drawNineGrid(self, state: DrawNineGrid): + LOG.debug(state) + self._unimplemented() + + def multiDrawNineGrid(self, state: MultiDrawNineGrid): + LOG.debug(state) + self._unimplemented() + + def lineTo(self, state: LineTo): + LOG.debug(state) + p = self._paint(self.surface) + set_rop2(state.rop2, p) + p.setBackgroundMode(Qt.TransparentMode if state.bgMode == BACKMODE_TRANSPARENT else Qt.OpaqueMode) + p.setBackground(QBrush(rgb_to_qcolor(state.bg))) + p.setPen(QPen(rgb_to_qcolor(state.penColor))) + + p.drawLine(state.x0, state.y0, state.x1, state.y1) + self._end(p) + + def opaqueRect(self, state: OpaqueRect): + LOG.debug(state) + p = self._paint(self.surface) + p.fillRect(state.x, state.y, state.w, state.h, rgb_to_qcolor(state.color)) + self._end(p) + + def multiOpaqueRect(self, state: MultiOpaqueRect): + LOG.debug(state) + p = self._paint(self.surface) + color = rgb_to_qcolor(state.color) + + for (x, y, w, h) in state.rectangles: + p.fillRect(x, y, w, h, color) + + self._end(p) + + def saveBitmap(self, state: SaveBitmap): + LOG.debug(state) + self._unimplemented() + + def memBlt(self, state: MemBlt): + LOG.debug(state) + dst = self.surface + p = self._paint(dst) + set_rop3(state.rop, p) + + if state.cacheId == BITMAPCACHE_SCREEN_ID: + # Use offscreen bitmap as a source. + src = self.surfaces[state.cacheIndex] + if src == dst: + src = dst.copy() # Can't paint to same surface. + else: + src = self.bitmaps.get(state.cacheId, state.cacheIndex) + + if src is None: + return # Ignore cache misses. + + p.drawImage(state.left, state.top, src, state.xSrc, state.ySrc, state.width, state.height) + self._end(p) + + def mem3Blt(self, state: Mem3Blt): + LOG.debug(state) + if state.cacheId == BITMAPCACHE_SCREEN_ID: + # Use offscreen bitmap as a source. + src = self.surfaces[state.cacheIndex] + else: + src = self.bitmaps.get(state.cacheId, state.cacheIndex) + + if src is None: + return # Ignore cache misses. + + p = self._paint(self.surface) + self._brush(state.brush, p) + set_rop3(state.rop, p) + p.brush().setColor(rgb_to_qcolor(state.fg)) + p.setBackground(QBrush(rgb_to_qcolor(state.bg))) + + p.drawImage(state.left, state.top, src, state.xSrc, state.ySrc, state.width, state.height) + self._end(p) + + def fastIndex(self, state: FastIndex): + LOG.debug(state) + self._process_glyph(GlyphContext(state), state.data) + + def polygonSc(self, state: PolygonSc): + LOG.debug(state) + p = self._paint(self.surface) + p.setBrush(QBrush(rgb_to_qcolor(state.brushColor))) + set_rop2(state.rop2, p) + + polygon = QPolygon() + polygon.append(QPoint(state.x0, state.y0)) + for (x, y) in state.points: + polygon.append(QPoint(x, y)) + + p.drawPolygon(polygon, _fill[state.fillMode]) + self._end(p) + + def polygonCb(self, state: PolygonCb): + LOG.debug(state) + p = self._paint(self.surface) + self._brush(state.brush) + p.brush().setColor(rgb_to_qcolor(state.fg)) + p.setBackground(QBrush(rgb_to_qcolor(state.bg))) + set_rop2(state.rop2, p) + + # Handle background mode. + if state.brush.style in [BrushStyle.PATTERN, BrushStyle.HATCHED]: + p.setBackgroundMode(Qt.TransparentMode if state.bgMode == BACKMODE_TRANSPARENT else Qt.OpaqueMode) + + polygon = QPolygon() + polygon.append(QPoint(state.x0, state.y0)) + for (x, y) in state.points: + polygon.append(QPoint(x, y)) + + p.drawPolygon(polygon, _fill[state.fillMode]) + self._end(p) + + def polyLine(self, state: PolyLine): + LOG.debug(state) + p = self._paint(self.surface) + p.setPen(QPen(rgb_to_qcolor(state.penColor))) + set_rop2(state.rop2, p) + + polygon = QPolygon() + polygon.append(QPoint(state.x0, state.y0)) + for (x, y) in state.points: + polygon.append(QPoint(x, y)) + + p.drawPolyline(polygon) + self._end(p) + + def fastGlyph(self, state: FastGlyph): + LOG.debug(state) + if state.glyph: + glyph = GlyphEntry(state.glyph) + self.glyphs.add(state.cacheId, state.cacheIndex, glyph) + else: + glyph = self.glyphs.get(state.cacheId, state.cacheIndex) + + if not glyph: + return # Ignore unknown glyph. + + self._process_glyph(GlyphContext(state), bytes([state.cacheIndex, 0])) + + def ellipseSc(self, state: EllipseSc): + LOG.debug(state) + p = self._paint(self.surface) + set_rop2(state.rop2, p) + p.setBrush(QBrush(rgb_to_qcolor(state.brushColor))) + + if not state.fillMode: + # This probably doesn't have the expected behavior. + p.setBackgroundMode(Qt.TransparentMode) + + w = state.right - state.left + h = state.bottom - state.top + p.drawEllipse(state.left, state.top, w, h) + self._end(p) + + def ellipseCb(self, state: EllipseCb): + LOG.debug(state) + p = self._paint(self.surface) + self._brush(state.brush) + p.brush().setColor(rgb_to_qcolor(state.fg)) + p.setBackground(QBrush(rgb_to_qcolor(state.bg))) + set_rop2(state.rop2, p) + + if not state.fillMode: + # This probably doesn't have the expected behavior. + p.setBackgroundMode(Qt.TransparentMode) + + w = state.right - state.left + h = state.bottom - state.top + p.drawEllipse(state.left, state.top, w, h) + + def glyphIndex(self, state: GlyphIndex): + LOG.debug(state) + self._process_glyph(GlyphContext(state), state.data) + + # Secondary Handlers + def cacheBitmapV1(self, state: CacheBitmapV1): + LOG.debug(state) + bmp = RDPBitmapToQtImage(state.width, state.height, state.bpp, True, state.data) + self.bitmaps.add(state.cacheId, state.cacheIndex, bmp) + + def cacheBitmapV2(self, state: CacheBitmapV2): + LOG.debug(state) + bmp = RDPBitmapToQtImage(state.width, state.height, state.bpp, True, state.data) + self.bitmaps.add(state.cacheId, state.cacheIndex, bmp) + + def cacheBitmapV3(self, state: CacheBitmapV3): + LOG.debug(state) + bmp = RDPBitmapToQtImage(state.width, state.height, state.bpp, True, state.data) + self.bitmaps.add(state.cacheId, state.cacheIndex, bmp) + + def cacheColorTable(self, state: CacheColorTable): + LOG.debug(state) + self.palettes.add(state.cacheIndex, state.colors) + + def cacheGlyph(self, state: CacheGlyph): + LOG.debug(state) + for g in state.glyphs: + glyph = GlyphEntry(g) + self.glyphs.add(state.cacheId, g.cacheIndex, glyph) + + def cacheBrush(self, state: CacheBrush): # FIXME: Maybe should not be expanding brush pixels too? + LOG.debug(state) + # There's probably a more efficient than using QImage + i = QImage(state.data, state.width, state.height, _fmt[state.bpp]) + pm = QPixmap.fromImageInPlace(i) + self.brushes.add(state.cacheIndex, QBrush(pm)) + + # Alternate Secondary Handlers + def frameMarker(self, state: FrameMarker): + LOG.debug(state) + if state.action == 0x01: # END + # self.dc.notifyImage(0, 0, self.screen, self.dc.width(), self.dc.height()) + self.dc.update() + + def createOffscreenBitmap(self, state: CreateOffscreenBitmap): + LOG.debug(state) + bmp = QImage(state.cx, state.cy, QImage.Format_ARGB32_Premultiplied) + bmp.fill(0) + + self.surfaces[state.id] = bmp + + for d in state.delete: + if d in self.surfaces: + del self.surfaces[d] + + def switchSurface(self, state: SwitchSurface): + LOG.debug(state) + if state.id not in self.surfaces: + # Appendix A - <5> Section 2.2.2.2.1.3.3 + LOG.warning('Request for uninitialized surface: %d', state.id) + self.activeSurface = SUBSTITUTE_SURFACE + return + self.activeSurface = state.id + + def createNineGridBitmap(self, state: CreateNineGridBitmap): + LOG.debug(state) + self._unimplemented() + + def streamBitmapFirst(self, state: StreamBitmapFirst): + LOG.debug(state) + self._unimplemented() + + def streamBitmapNext(self, state: StreamBitmapNext): + LOG.debug(state) + self._unimplemented() + + def drawGdiPlusFirst(self, state: GdiPlusFirst): + LOG.debug(state) + self._unimplemented() + + def drawGdiPlusNext(self, state: GdiPlusNext): + LOG.debug(state) + self._unimplemented() + + def drawGdiPlusEnd(self, state: GdiPlusEnd): + LOG.debug(state) + self._unimplemented() + + def drawGdiPlusCacheFirst(self, state: GdiPlusCacheFirst): + LOG.debug(state) + self._unimplemented() + + def drawGdiPlusCacheNext(self, state: GdiPlusCacheNext): + LOG.debug(state) + self._unimplemented() + + def drawGdiPlusCacheEnd(self, state: GdiPlusCacheEnd): + LOG.debug(state) + self._unimplemented() + + def _process_glyph(self, ctx: 'GlyphContext', data: bytes): + """Process glyph rendering instructions.""" + # 2.2.2.2.1.1.2.14 + p = self._paint(self.surface) + + cid = ctx.cacheId + + i = 0 + while i < len(data): + instr = data[i] + i += 1 + + if instr == GLYPH_FRAGMENT_USE: + fid = data[i] + i += 1 + fragment = self.glyphs.getFragment(cid, fid) + n = 0 + size = len(fragment) + + while n < size: + idx = fragment[n] + n += 1 + + glyph = self.glyphs.get(cid, idx) + + n = ctx.offset(n, fragment) + ctx.draw(glyph, p) + + # Skip for now, seems to always be 0. + if ctx.ulCharInc == 0 and not (ctx.flAccel & SO_CHAR_INC_EQUAL_BM_BASE): + i += 2 if data[i] == 0x80 else 1 + + elif instr == GLYPH_FRAGMENT_ADD: + fid = data[i] + size = data[i + 1] + fragment = data[(i - size - 1):size] + i += 2 + self.glyphs.addFragment(cid, fid, fragment) + else: + glyph = self.glyphs.get(cid, instr) + i = ctx.offset(i, data) + ctx.draw(glyph, p) + self._end(p) + + def _unimplemented(self): + if not self._warned: + LOG.warning('One or more unimplemented drawing orders called! Expect lossy rendering.') + self._warned = True + + +class GlyphContext: + """ + Glyph processing context. + + This is an internal class to store mutable glyph rendering state + during the rendering operations. + """ + + def __init__(self, source: typing.Union[GlyphIndex, FastIndex, FastGlyph]): + self.cacheId = source.cacheId + + self.bg = source.bg + self.fg = source.fg + + self.opLeft = source.opLeft + self.opTop = source.opTop + self.opRight = source.opRight + self.opBottom = source.opBottom + + self.bkLeft = source.bkLeft + self.bkTop = source.bkTop + self.bkRight = source.bkRight + self.bkBottom = source.bkBottom + + self.x = source.x + self.y = source.y + + self.flAccel = source.flAccel + self.ulCharInc = source.ulCharInc + self.fOpRedundant = False + + if not isinstance(source, GlyphIndex): + if self.opBottom == GLYPH_SPECIAL_PROCESSING: + flags = self.opTop & 0x0F + if flags & OPRECT_BOTTOM_ABSENT: + self.opBottom = source.bkBottom + elif flags & OPRECT_RIGHT_ABSENT: + self.opRight = source.bkRight + elif flags & OPRECT_TOP_ABSENT: + self.opTop = source.bkTop + elif flags & OPRECT_LEFT_ABSENT: + self.opLeft = source.bkLeft + + if self.opLeft == 0: + self.opLeft = source.bkLeft + if self.opRight == 0: + self.opRight == source.bkRight + + # Adjust x and y + if self.x == GLYPH_SPECIAL_PROCESSING: + self.x = source.bkLeft + if self.y == GLYPH_SPECIAL_PROCESSING: + self.y = source.bkTop + else: + self.fOpRedundant = source.fOpRedundant + + self.bkWidth = source.bkRight - source.bkLeft + 1 if source.bkRight > source.bkLeft else 0 + self.bkHeight = source.bkBottom - source.bkTop + 1 if source.bkBottom > source.bkTop else 0 + + self.opWidth = self.opRight - self.opLeft + 1 if self.opRight > self.opLeft else 0 + self.opHeight = self.opBottom - self.opTop + 1 if self.opBottom > self.opTop else 0 + + def offset(self, index: int, data: bytes) -> int: + """Read the offset of the next glyph and return the new instruction index.""" + if self.ulCharInc == 0 and not (self.flAccel & SO_CHAR_INC_EQUAL_BM_BASE): + offset = data[index] + index += 1 + if offset & 0x80: # 2 byte offset. + offset = data[index] + offset |= data[index + 1] + index += 2 + if self.flAccel & SO_VERTICAL: + self.y += offset + if self.flAccel & SO_HORIZONTAL: + self.x += offset + return index + + def draw(self, glyph: GlyphEntry, p: QPainter): + """Render a glyph using the given painter.""" + # Adjust the glyph coordinates to center it on origin + x = self.x + glyph.x + y = self.y + glyph.y + + if not self.fOpRedundant: + p.fillRect(x, y, glyph.w, glyph.h, rgb_to_qcolor(self.fg)) + + p.setBrush(QBrush(rgb_to_qcolor(self.bg), glyph.bitmap)) + p.setBrushOrigin(x, y) + p.drawRect(x, y, glyph.w, glyph.h) + + if self.flAccel & SO_CHAR_INC_EQUAL_BM_BASE: + self.x += glyph.w + + +# Map brush styles to Qt brush style +_bs = { + BrushStyle.SOLID: Qt.SolidPattern, + BrushStyle.NULL: Qt.NoBrush, + BrushStyle.HATCHED: None, # Must lookup in _hs. + BrushStyle.PATTERN: Qt.TexturePattern, +} + +_hs = { + HatchStyle.HORIZONTAL: Qt.HorPattern, + HatchStyle.VERTICAL: Qt.VerPattern, + HatchStyle.FDIAGONAL: Qt.FDiagPattern, + HatchStyle.BDIAGNOAL: Qt.BDiagPattern, + HatchStyle.CROSS: Qt.CrossPattern, + HatchStyle.DIAGCROSS: Qt.DiagCrossPattern, +} + +# Pixel format lookup table. +_fmt = { + 1: QImage.Format_Mono, + 8: QImage.Format_Grayscale8, + 16: QImage.Format_RGB16, + 24: QImage.Format_RGB888, + 32: QImage.Format_RGB32, +} + +# Polygon fill mode lookup table +_fill = [ + None, # 0x00 + Qt.OddEvenFill, # 0x01: ALTERNATE + Qt.WindingFill # 0x02: WINDING + +] diff --git a/pyrdp/player/gdi/raster.py b/pyrdp/player/gdi/raster.py new file mode 100644 index 000000000..8d38a7e03 --- /dev/null +++ b/pyrdp/player/gdi/raster.py @@ -0,0 +1,399 @@ +# +# This file is part of the PyRDP project. +# Copyright (C) 2020 GoSecure Inc. +# Licensed under the GPLv3 or later. +# + +from PySide2.QtGui import QPainter + +# All raster operations defined by [MS-RDPEGDI] Section 2.2.2.2.1.1.1.7 +BLACKNESS = 0x00 +DPSoon = 0x01 +DPSona = 0x02 +PSon = 0x03 +SDPona = 0x04 +DPon = 0x05 +PDSxnon = 0x06 +PDSaon = 0x07 +SDPnaa = 0x08 +PDSxon = 0x09 +DPna = 0x0A +PSDnaon = 0x0B +SPna = 0x0C +PDSnaon = 0x0D +PDSonon = 0x0E +Pn = 0x0F +PDSona = 0x10 +NOTSRCERASE = 0x11 +SDPxnon = 0x12 +SDPaon = 0x13 +DPSxnon = 0x14 +DPSaon = 0x15 +PSDPSanaxx = 0x16 +SSPxDSxaxn = 0x17 +SPxPDxa = 0x18 +SDPSanaxn = 0x19 +PDSPaox = 0x1A +SDPSxaxn = 0x1B +PSDPaox = 0x1C +DSPDxaxn = 0x1D +PDSox = 0x1E +PDSoan = 0x1F +DPSnaa = 0x20 +SDPxon = 0x21 +DSna = 0x22 +SPDnaon = 0x23 +SPxDSxa = 0x24 +PDSPanaxn = 0x25 +SDPSaox = 0x26 +SDPSxnox = 0x27 +DPSxa = 0x28 +PSDPSaoxxn = 0x29 +DPSana = 0x2A +SSPxPDxaxn = 0x2B +SPDSoax = 0x2C +PSDnox = 0x2D +PSDPxox = 0x2E +PSDnoan = 0x2F +PSna = 0x30 +SDPnaon = 0x31 +SDPSoox = 0x32 +NOTSRCCOPY = 0x33 +SPDSaox = 0x34 +SPDSxnox = 0x35 +SDPox = 0x36 +SDPoan = 0x37 +PSDPoax = 0x38 +SPDnox = 0x39 +SPDSxox = 0x3A +SPDnoan = 0x3B +PSx = 0x3C +SPDSonox = 0x3D +SPDSnaox = 0x3E +PSan = 0x3F +PSDnaa = 0x40 +DPSxon = 0x41 +SDxPDxa = 0x42 +SPDSanaxn = 0x43 +SRCERASE = 0x44 +DPSnaon = 0x45 +DSPDaox = 0x46 +PSDPxaxn = 0x47 +SDPxa = 0x48 +PDSPDaoxxn = 0x49 +DPSDoax = 0x4A +PDSnox = 0x4B +SDPana = 0x4C +SSPxDSxoxn = 0x4D +PDSPxox = 0x4E +PDSnoan = 0x4F +PDna = 0x50 +DSPnaon = 0x51 +DPSDaox = 0x52 +SPDSxaxn = 0x53 +DPSonon = 0x54 +DSTINVERT = 0x55 +DPSox = 0x56 +DPSoan = 0x57 +PDSPoax = 0x58 +DPSnox = 0x59 +PATINVERT = 0x5A +DPSDonox = 0x5B +DPSDxox = 0x5C +DPSnoan = 0x5D +DPSDnaox = 0x5E +DPan = 0x5F +PDSxa = 0x60 +DSPDSaoxxn = 0x61 +DSPDoax = 0x62 +SDPnox = 0x63 +SDPSoax = 0x64 +DSPnox = 0x65 +SRCINVERT = 0x66 +SDPSonox = 0x67 +DSPDSonoxxn = 0x68 +PDSxxn = 0x69 +DPSax = 0x6A +PSDPSoaxxn = 0x6B +SDPax = 0x6C +PDSPDoaxxn = 0x6D +SDPSnoax = 0x6E +PDSxnan = 0x6F +PDSana = 0x70 +SSDxPDxaxn = 0x71 +SDPSxox = 0x72 +SDPnoan = 0x73 +DSPDxox = 0x74 +DSPnoan = 0x75 +SDPSnaox = 0x76 +DSan = 0x77 +PDSax = 0x78 +DSPDSoaxxn = 0x79 +DPSDnoax = 0x7A +SDPxnan = 0x7B +SPDSnoax = 0x7C +DPSxnan = 0x7D +SPxDSxo = 0x7E +DPSaan = 0x7F +DPSaa = 0x80 +SPxDSxon = 0x81 +DPSxna = 0x82 +SPDSnoaxn = 0x83 +SDPxna = 0x84 +PDSPnoaxn = 0x85 +DSPDSoaxx = 0x86 +PDSaxn = 0x87 +SRCAND = 0x88 +SDPSnaoxn = 0x89 +DSPnoa = 0x8A +DSPDxoxn = 0x8B +SDPnoa = 0x8C +SDPSxoxn = 0x8D +SSDxPDxax = 0x8E +PDSanan = 0x8F +PDSxna = 0x90 +SDPSnoaxn = 0x91 +DPSDPoaxx = 0x92 +SPDaxn = 0x93 +PSDPSoaxx = 0x94 +DPSaxn = 0x95 +DPSxx = 0x96 +PSDPSonoxx = 0x97 +SDPSonoxn = 0x98 +DSxn = 0x99 +DPSnax = 0x9A +SDPSoaxn = 0x9B +SPDnax = 0x9C +DSPDoaxn = 0x9D +DSPDSaoxx = 0x9E +PDSxan = 0x9F +DPa = 0xA0 +PDSPnaoxn = 0xA1 +DPSnoa = 0xA2 +DPSDxoxn = 0xA3 +PDSPonoxn = 0xA4 +PDxn = 0xA5 +DSPnax = 0xA6 +PDSPoaxn = 0xA7 +DPSoa = 0xA8 +DPSoxn = 0xA9 +DSTCOPY = 0xAA +DPSono = 0xAB +SPDSxax = 0xAC +DPSDaoxn = 0xAD +DSPnao = 0xAE +DPno = 0xAF +PDSnoa = 0xB0 +PDSPxoxn = 0xB1 +SSPxDSxox = 0xB2 +SDPanan = 0xB3 +PSDnax = 0xB4 +DPSDoaxn = 0xB5 +DPSDPaoxx = 0xB6 +SDPxan = 0xB7 +PSDPxax = 0xB8 +DSPDaoxn = 0xB9 +DPSnao = 0xBA +MERGEPAINT = 0xBB +SPDSanax = 0xBC +SDxPDxan = 0xBD +DPSxo = 0xBE +DPSano = 0xBF +MERGECOPY = 0xC0 +SPDSnaoxn = 0xC1 +SPDSonoxn = 0xC2 +PSxn = 0xC3 +SPDnoa = 0xC4 +SPDSxoxn = 0xC5 +SDPnax = 0xC6 +PSDPoaxn = 0xC7 +SDPoa = 0xC8 +SPDoxn = 0xC9 +DPSDxax = 0xCA +SPDSaoxn = 0xCB +SRCCOPY = 0xCC +SDPono = 0xCD +SDPnao = 0xCE +SPno = 0xCF +PSDnoa = 0xD0 +PSDPxoxn = 0xD1 +PDSnax = 0xD2 +SPDSoaxn = 0xD3 +SSPxPDxax = 0xD4 +DPSanan = 0xD5 +PSDPSaoxx = 0xD6 +DPSxan = 0xD7 +PDSPxax = 0xD8 +SDPSaoxn = 0xD9 +DPSDanax = 0xDA +SPxDSxan = 0xDB +SPDnao = 0xDC +SDno = 0xDD +SDPxo = 0xDE +SDPano = 0xDF +PDSoa = 0xE0 +PDSoxn = 0xE1 +DSPDxax = 0xE2 +PSDPaoxn = 0xE3 +SDPSxax = 0xE4 +PDSPaoxn = 0xE5 +SDPSanax = 0xE6 +SPxPDxan = 0xE7 +SSPxDSxax = 0xE8 +DSPDSanaxxn = 0xE9 +DPSao = 0xEA +DPSxno = 0xEB +SDPao = 0xEC +SDPxno = 0xED +SRCPAINT = 0xEE +SDPnoo = 0xEF +PATCOPY = 0xF0 +PDSono = 0xF1 +PDSnao = 0xF2 +PSno = 0xF3 +PSDnao = 0xF4 +PDno = 0xF5 +PDSxo = 0xF6 +PDSano = 0xF7 +PDSao = 0xF8 +PDSxno = 0xF9 +DPo = 0xFA +PATPAINT = 0xFB +PSo = 0xFC +PSDnoo = 0xFD +DPSoo = 0xFE +WHITENESS = 0xFF + + +# Mapping to supported Qt operations. +_rop2 = [ + None, + QPainter.RasterOp_ClearDestination, # 0 + QPainter.RasterOp_NotSourceAndNotDestination, # DPon + QPainter.RasterOp_NotSourceAndDestination, # DPna + QPainter.RasterOp_NotSource, # Pn + QPainter.RasterOp_SourceAndNotDestination, # PDna + QPainter.RasterOp_NotDestination, # Dn + QPainter.RasterOp_SourceXorDestination, # DPx + QPainter.RasterOp_NotSourceOrNotDestination, # DPan + QPainter.RasterOp_SourceAndDestination, # DPa + QPainter.RasterOp_NotSourceXorDestination, # DPxn + QPainter.CompositionMode_Destination, # D + QPainter.RasterOp_NotSourceOrDestination, # DPno + QPainter.CompositionMode_Source, # P + QPainter.RasterOp_SourceOrNotDestination, # PDno + QPainter.RasterOp_SourceOrDestination, # PDo + QPainter.RasterOp_SetDestination, # 1 +] + +_rop3 = { + BLACKNESS: QPainter.RasterOp_ClearDestination, + DPon: QPainter.RasterOp_NotSourceAndNotDestination, + DPna: QPainter.RasterOp_NotSourceAndDestination, + Pn: QPainter.RasterOp_NotSource, + NOTSRCERASE: QPainter.RasterOp_NotSourceAndNotDestination, + DSna: QPainter.RasterOp_NotSourceAndDestination, + NOTSRCCOPY: QPainter.RasterOp_NotSource, + SRCERASE: QPainter.RasterOp_SourceAndNotDestination, + PDna: QPainter.RasterOp_SourceAndNotDestination, + DSTINVERT: QPainter.RasterOp_NotDestination, + PATINVERT: QPainter.RasterOp_SourceXorDestination, + DPan: QPainter.RasterOp_NotSourceOrNotDestination, + SRCINVERT: QPainter.RasterOp_SourceXorDestination, + DSan: QPainter.RasterOp_NotSourceOrNotDestination, + SRCAND: QPainter.RasterOp_SourceAndDestination, + DSxn: QPainter.RasterOp_NotSourceXorDestination, + DPa: QPainter.RasterOp_SourceAndDestination, + PDxn: QPainter.RasterOp_NotSourceXorDestination, + DSTCOPY: QPainter.CompositionMode_Destination, + DPno: QPainter.RasterOp_NotSourceOrDestination, + MERGEPAINT: QPainter.RasterOp_NotSourceOrDestination, + SRCCOPY: QPainter.CompositionMode_Source, + SDno: QPainter.RasterOp_SourceOrNotDestination, + SRCPAINT: QPainter.RasterOp_SourceOrDestination, + PATCOPY: QPainter.CompositionMode_Source, + PDno: QPainter.RasterOp_SourceOrNotDestination, + DPo: QPainter.RasterOp_SourceOrDestination, + WHITENESS: QPainter.RasterOp_SetDestination, + PSDPxax: QPainter.RasterOp_SourceAndDestination, +} + + +def set_rop3(op: int, painter: QPainter): + """ + Configure a QPainter with the required ternary raster operation. + + :parma op: The operation identifier. + :param painter: The painter being used. + :returns: The operation that will be processed. + """ + if op not in _rop3: + return None + + mode = _rop3[op] + painter.setCompositionMode(mode) + return mode + + +def set_rop2(op: int, painter: QPainter): + """ + Configure a QPainter with the required binary raster operation. + + :parma op: The operation identifier. + :param painter: The painter being used. + :returns: The operation that will be processed. + """ + if op < 0 or op >= len(_rop2): + return None + mode = _rop2[op] + painter.setCompositionMode(mode) + return mode + + +def rop_slow(code: str, dst, src, pal): + """ + Slow but generic fallback implementation of raster operations. + + This is not implemented currently as Qt directly supports most of the raster operations + that RDP servers send. + + This function implements the RPN-notation described in [MS-RDPEGDI][1] + with a generic stack machine. It is much slower than having a hardcoded + and optimized function for a particular operation, but greatly reduces + the amount of code required. + + [1]: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegdi/a9a85075-e796-45eb-b84a-f399324a1109 + """ + + stack = [] + for c in code: + if c == 'D': + stack.append(dst) + elif c == 'S': + stack.append(src) + elif c == 'P': + if pal is None: + raise SyntaxError('Palette is not present.') + stack.append(pal) + else: + lhs = stack.pop() + rhs = None + res = lhs # TODO: Actually perform the operation. + + if c != 'n': + rhs = stack.pop() + + if c == 'x': # XOR + print(f'{lhs} ^ {rhs}') + elif c == 'n': # NOT + print(f'~{lhs}') + elif c == 'a': # AND + print(f'{lhs} & {rhs}') + elif c == 'o': # OR + print(f'{lhs} | {rhs}') + + stack.append(res) + out = stack.pop() + + assert len(stack) == 0 + return out diff --git a/pyrdp/ui/qt.py b/pyrdp/ui/qt.py index 01c9f81c8..f198207b8 100644 --- a/pyrdp/ui/qt.py +++ b/pyrdp/ui/qt.py @@ -1,6 +1,6 @@ # # Copyright (c) 2014-2015 Sylvain Peyrefitte -# Copyright (c) 2018 GoSecure Inc. +# Copyright (c) 2018-2020 GoSecure Inc. # # This file is part of rdpy. # @@ -134,21 +134,23 @@ def __init__(self, width: int, height: int, parent: QWidget = None): :param parent: parent widget """ super().__init__(parent) - #set correct size + # Set correct size self.resize(width, height) - #bind mouse event + # Bind mouse event self.setMouseTracking(True) - #buffer image - self._buffer = QImage(width, height, QImage.Format_RGB32) + # Buffer image + self._buffer = QImage(width, height, QImage.Format_ARGB32_Premultiplied) self.mouseX = width // 2 self.mouseY = height // 2 self.mainThreadHook.connect(self.runOnMainThread) - def runOnMainThread(self, target: callable): target() + @property + def screen(self): + return self._buffer def notifyImage(self, x: int, y: int, qimage: QImage, width: int, height: int): """ @@ -160,11 +162,11 @@ def notifyImage(self, x: int, y: int, qimage: QImage, width: int, height: int): :param height: height of the new image """ - #fill buffer image + # Fill buffer image qp = QPainter(self._buffer) qp.drawImage(x, y, qimage, 0, 0, width, height) - #force update + # Force update self.update() def setMousePosition(self, x: int, y: int): @@ -194,4 +196,4 @@ def paintEvent(self, e: QEvent): def clear(self): self._buffer = QImage(self._buffer.width(), self._buffer.height(), QImage.Format_RGB32) self.setMousePosition(self._buffer.width() // 2, self._buffer.height() // 2) - self.repaint() \ No newline at end of file + self.repaint()