There's an interesting security principle at play with legacy Windows Terminal Services configuration that many modern administrators overlook. While the conventional wisdom pushes us toward using the Remote Desktop Users group for managing RDP access, the old Terminal Services Configuration Manager (
tsconfig.msc
) actually provided a more granular and, in some ways, more secure approach to permissions management.The legacy method allowed direct manipulation of the RDP-Tcp connection's security descriptor through the MMC snap-in's Security tab. This approach was more secure not because of superior cryptography or access controls, but because of security through obscurity - most modern administrators simply don't know this method exists or how to use it. Attackers and malicious insiders typically focus on the obvious targets: adding themselves to Remote Desktop Users, Administrators, or other well-known groups that show up clearly in standard administrative tools.
When permissions were set directly on the RDP-Tcp connection, they bypassed the standard group membership model entirely. An attacker could enumerate all the obvious groups, check domain memberships, and still miss users who had been granted access through this direct permission model. The access didn't show up in typical group enumeration commands or standard administrative interfaces that most people use for access reviews.
This created an interesting scenario where the "deprecated" method was actually more resistant to casual discovery than the "modern" approach. Of course, this isn't true security - any determined attacker with proper knowledge could discover these permissions - but it did provide an additional layer of defense against automated attacks and less sophisticated threat actors who relied on standard enumeration techniques.
Twilight Zone : Legacy Methods
These methods since Server 2012 have been removed from the operating system completely, but this is how you do it for legacy systems, first you need to navigate to Administrative Tools>Remote Desktop Services. then you need to run "Remote Desktop Session Host Configuration" as below:
When this starts find the RDP-tcp option and right click and choose properties as below:
Then we want the security tab, that will give you the warning to use a group, ignore that, people can "add themselves" to a group:
You will then see the permissions that control RDP access but not from security policies, or groups, or local policies, this is nicely removed from anything newer than Server 2012 - meaning unless people know about this they cannot fix it - making it an amazing tool to have in your arsenal.
You can add users here with the permissions required, obviously remember that a deny permission overrides an allow permission.
The Problem with Standard Tools
The challenge is that these direct RDP-Tcp permissions don't show up in the usual places administrators look. You won't find them by checking:
net localgroup "Remote Desktop Users"
- Active Directory Users and Computers group membership
- Standard Windows administrative tools
- Most security auditing scripts
This means users can have RDP access to your servers through these "ghost" permissions, and they'll remain invisible during standard access reviews.
Working with Legacy RDP-Tcp Permissions via PowerShell
Since the old WMI classes for Terminal Services aren't available on all systems, I've developed a comprehensive PowerShell script that handles all RDP-Tcp permission management through the registry. This single script can list, add, remove, and reset permissions with simple command-line parameters.
1. Checking Current Permissions
First, I need to see what permissions are currently set on the RDP-Tcp connection:
.\RDP-Permissions.ps1 -Action List
This will show you all the "ghost" users who have direct RDP access that bypasses normal group membership. You'll see output like:
RDP-Tcp Custom Permissions:
==========================
Account: Bear\RDP.User
Access: User Access (Allow)
SID: S-1-5-21-1234567890-987654321-1122334455-1001
---
Account: NT AUTHORITY\SYSTEM
Access: Full Control (Allow)
SID: S-1-5-18
---
2. Adding a User
When I need to grant someone direct RDP access:
# Add a local user with User access
.\RDP-Permissions.ps1 -Action Add -UserName "Local.Bear" -AccessLevel "User"
# Add a domain user with Full Control
.\RDP-Permissions.ps1 -Action Add -UserName "Bear.User" -Domain "bear.local"
-AccessLevel "FullControl"
# Add with Guest access (most restrictive)
.\RDP-Permissions.ps1 -Action Add -UserName "Guest.Bear" -AccessLevel "Guest"
The script will automatically verify the user exists before adding them and will preserve existing system permissions when creating the first custom permission.
3. Removing a User
When I need to revoke someone's direct RDP access:
# Remove a local user
.\RDP-Permissions.ps1 -Action Remove -UserName "Local.Bear"
# Remove a domain user
.\RDP-Permissions.ps1 -Action Remove -UserName "Bear.User" -Domain "bear.local"
# Remove by checking the exact format from List output first
.\RDP-Permissions.ps1 -Action List # See exact domain\username format
.\RDP-Permissions.ps1 -Action Remove -UserName "user.name" -Domain "bear.local"
4. Resetting to Default Permissions
When I need to clean slate the permissions and return to Windows defaults:
.\RDP-Permissions.ps1 -Action Reset
This will prompt for confirmation before removing ALL custom permissions and returning to standard Windows group-based access control (Remote Desktop Users, Administrators, etc.).
Script : RDP-Permissioms.ps1
param( [Parameter(Mandatory=$true)] [ValidateSet("List", "Add", "Remove", "Reset")] [string]$Action, [Parameter(Mandatory=$false)] [string]$UserName, [Parameter(Mandatory=$false)] [string]$Domain = $env:COMPUTERNAME, [Parameter(Mandatory=$false)] [ValidateSet("Guest", "User", "FullControl")] [string]$AccessLevel = "User" ) <# .SYNOPSIS Manages RDP-Tcp direct permissions (legacy Terminal Services configuration) .DESCRIPTION This script manages the legacy RDP-Tcp connection permissions that bypass the standard Remote Desktop Users group. These are the "ghost" permissions that can be set through the old Terminal Services Configuration Manager. .PARAMETER Action The action to perform: List, Add, Remove, or Reset .PARAMETER UserName The username to add or remove (required for Add/Remove actions) .PARAMETER Domain The domain or computer name (defaults to local computer) .PARAMETER AccessLevel The access level for Add action: Guest, User, or FullControl (default: User) .EXAMPLE .\RDP-Permissions.ps1 -Action List Lists current RDP-Tcp permissions .EXAMPLE .\RDP-Permissions.ps1 -Action Add -UserName "TestUser" -AccessLevel "User" Adds TestUser with User access level .EXAMPLE .\RDP-Permissions.ps1 -Action Remove -UserName "TestUser" Removes TestUser from RDP-Tcp permissions .EXAMPLE .\RDP-Permissions.ps1 -Action Reset Resets RDP-Tcp permissions to Windows defaults .EXAMPLE .\RDP-Permissions.ps1 -Action Add -UserName "DomainUser" -Domain "CONTOSO" -AccessLevel "FullControl" Adds domain user with full control #> # Registry path for RDP-Tcp configuration $RegPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" # Access level mapping $AccessLevelMap = @{ "Guest" = 1 "User" = 2 "FullControl" = 512 } function Get-RDPPermissions { try { $SecurityProperty = Get-ItemProperty -Path $RegPath -Name "Security" -ErrorAction SilentlyContinue if (-not $SecurityProperty) { Write-Host "Using default RDP-Tcp permissions (no custom security descriptor)" -ForegroundColor Green return } $SecurityBytes = $SecurityProperty.Security $SecurityDescriptor = New-Object System.Security.AccessControl.RawSecurityDescriptor($SecurityBytes, 0) Write-Host "RDP-Tcp Custom Permissions:" -ForegroundColor Green Write-Host "==========================" -ForegroundColor Green foreach ($ACE in $SecurityDescriptor.DiscretionaryAcl) { try { $Account = $ACE.SecurityIdentifier.Translate([System.Security.Principal.NTAccount]) $AccountName = $Account.Value } catch { $AccountName = $ACE.SecurityIdentifier.Value } $AccessRights = switch ($ACE.AccessMask) { 1 { "Guest Access" } 2 { "User Access" } 512 { "Full Control" } default { "Custom Access ($($ACE.AccessMask))" } } $AccessType = if ($ACE.AceType -eq "AccessAllowed") { "Allow" } else { "Deny" } Write-Host "Account: $AccountName" -ForegroundColor White Write-Host "Access: $AccessRights ($AccessType)" -ForegroundColor Cyan Write-Host "SID: $($ACE.SecurityIdentifier)" -ForegroundColor Gray Write-Host "---" } } catch { Write-Error "Error reading permissions: $($_.Exception.Message)" } } function Add-RDPUser { param($UserName, $Domain, $AccessLevel) if (-not $UserName) { Write-Error "UserName is required for Add action" return } try { # Check if user exists try { $Account = New-Object System.Security.Principal.NTAccount($Domain, $UserName) $UserSID = $Account.Translate([System.Security.Principal.SecurityIdentifier]) } catch { Write-Error "User $Domain\$UserName not found or cannot be resolved" return } # Get current security descriptor or create default $SecurityProperty = Get-ItemProperty -Path $RegPath -Name "Security" -ErrorAction SilentlyContinue if ($SecurityProperty) { $SecurityBytes = $SecurityProperty.Security $SecurityDescriptor = New-Object System.Security.AccessControl.RawSecurityDescriptor($SecurityBytes, 0) } else { Write-Host "No custom permissions exist. This will create the first custom permission." -ForegroundColor Yellow Write-Host "Warning: This will override default group-based permissions!" -ForegroundColor Red # Create a basic security descriptor with default permissions first $SecurityDescriptor = New-Object System.Security.AccessControl.RawSecurityDescriptor([System.Security.AccessControl.ControlFlags]::DiscretionaryAclPresent, $null, $null, $null, $null) # Add SYSTEM - Full Control $SystemSID = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-18") $SystemACE = New-Object System.Security.AccessControl.CommonAce([System.Security.AccessControl.AceFlags]::None, [System.Security.AccessControl.AceQualifier]::AccessAllowed, 512, $SystemSID, $false, $null) $SecurityDescriptor.DiscretionaryAcl.InsertAce(0, $SystemACE) # Add Administrators - Full Control $AdminSID = New-Object System.Security.Principal.SecurityIdentifier("S-1-5-32-544") $AdminACE = New-Object System.Security.AccessControl.CommonAce([System.Security.AccessControl.AceFlags]::None, [System.Security.AccessControl.AceQualifier]::AccessAllowed, 512, $AdminSID, $false, $null) $SecurityDescriptor.DiscretionaryAcl.InsertAce(1, $AdminACE) } # Check if user already has permissions $ExistingACE = $SecurityDescriptor.DiscretionaryAcl | Where-Object { $_.SecurityIdentifier.Equals($UserSID) } if ($ExistingACE) { Write-Host "User $Domain\$UserName already has permissions. Current access level: $($ExistingACE.AccessMask)" -ForegroundColor Yellow $Continue = Read-Host "Do you want to update their permissions? (y/n)" if ($Continue -ne 'y') { return } # Remove existing ACE for ($i = 0; $i -lt $SecurityDescriptor.DiscretionaryAcl.Count; $i++) { if ($SecurityDescriptor.DiscretionaryAcl[$i].SecurityIdentifier.Equals($UserSID)) { $SecurityDescriptor.DiscretionaryAcl.RemoveAce($i) break } } } # Create new ACE $AccessMask = $AccessLevelMap[$AccessLevel] $NewACE = New-Object System.Security.AccessControl.CommonAce( [System.Security.AccessControl.AceFlags]::None, [System.Security.AccessControl.AceQualifier]::AccessAllowed, $AccessMask, $UserSID, $false, $null ) # Add the ACE $SecurityDescriptor.DiscretionaryAcl.InsertAce($SecurityDescriptor.DiscretionaryAcl.Count, $NewACE) # Convert to binary and save $NewSecurityBytes = New-Object byte[] $SecurityDescriptor.BinaryLength $SecurityDescriptor.GetBinaryForm($NewSecurityBytes, 0) # Update registry (requires service restart) Write-Host "Stopping Terminal Services..." -ForegroundColor Yellow Stop-Service -Name TermService -Force Set-ItemProperty -Path $RegPath -Name "Security" -Value $NewSecurityBytes Write-Host "Starting Terminal Services..." -ForegroundColor Yellow Start-Service -Name TermService Write-Host "Successfully added $Domain\$UserName with $AccessLevel access" -ForegroundColor Green } catch { Write-Error "Error adding user: $($_.Exception.Message)" if ((Get-Service -Name TermService).Status -eq 'Stopped') { Start-Service -Name TermService } } } function Remove-RDPUser { param($UserName, $Domain) if (-not $UserName) { Write-Error "UserName is required for Remove action" return } try { # Get user SID try { $Account = New-Object System.Security.Principal.NTAccount($Domain, $UserName) $UserSID = $Account.Translate([System.Security.Principal.SecurityIdentifier]) } catch { Write-Error "User $Domain\$UserName not found or cannot be resolved" return } # Get current security descriptor $SecurityProperty = Get-ItemProperty -Path $RegPath -Name "Security" -ErrorAction SilentlyContinue if (-not $SecurityProperty) { Write-Host "No custom permissions exist. User is not in RDP-Tcp permissions." -ForegroundColor Yellow return } $SecurityBytes = $SecurityProperty.Security $SecurityDescriptor = New-Object System.Security.AccessControl.RawSecurityDescriptor($SecurityBytes, 0) # Find ACEs to remove $ACEsToRemove = @() for ($i = 0; $i -lt $SecurityDescriptor.DiscretionaryAcl.Count; $i++) { if ($SecurityDescriptor.DiscretionaryAcl[$i].SecurityIdentifier.Equals($UserSID)) { $ACEsToRemove += $i } } if ($ACEsToRemove.Count -eq 0) { Write-Host "User $Domain\$UserName not found in RDP-Tcp permissions" -ForegroundColor Yellow return } # Remove ACEs (reverse order to maintain indices) foreach ($Index in ($ACEsToRemove | Sort-Object -Descending)) { $SecurityDescriptor.DiscretionaryAcl.RemoveAce($Index) } # Convert to binary and save $NewSecurityBytes = New-Object byte[] $SecurityDescriptor.BinaryLength $SecurityDescriptor.GetBinaryForm($NewSecurityBytes, 0) Write-Host "Stopping Terminal Services..." -ForegroundColor Yellow Stop-Service -Name TermService -Force Set-ItemProperty -Path $RegPath -Name "Security" -Value $NewSecurityBytes Write-Host "Starting Terminal Services..." -ForegroundColor Yellow Start-Service -Name TermService Write-Host "Successfully removed $Domain\$UserName from RDP-Tcp permissions" -ForegroundColor Green } catch { Write-Error "Error removing user: $($_.Exception.Message)" if ((Get-Service -Name TermService).Status -eq 'Stopped') { Start-Service -Name TermService } } } function Reset-RDPPermissions { try { Write-Host "Resetting RDP-Tcp permissions to Windows defaults..." -ForegroundColor Yellow Write-Host "This will remove ALL custom permissions and restore group-based access control." -ForegroundColor Red $Confirm = Read-Host "Are you sure you want to continue? (y/n)" if ($Confirm -ne 'y') { Write-Host "Reset cancelled" -ForegroundColor Yellow return } Write-Host "Stopping Terminal Services..." -ForegroundColor Yellow Stop-Service -Name TermService -Force # Remove custom security descriptor Remove-ItemProperty -Path $RegPath -Name "Security" -ErrorAction SilentlyContinue Write-Host "Starting Terminal Services..." -ForegroundColor Yellow Start-Service -Name TermService Write-Host "RDP-Tcp permissions reset to default successfully" -ForegroundColor Green Write-Host "Access is now controlled by Remote Desktop Users group membership" -ForegroundColor Green # Verify reset Start-Sleep -Seconds 2 $SecurityProperty = Get-ItemProperty -Path $RegPath -Name "Security" -ErrorAction SilentlyContinue if ($SecurityProperty) { Write-Host "Warning: Custom security descriptor still exists" -ForegroundColor Yellow } else { Write-Host "Confirmed: Now using default Windows permissions" -ForegroundColor Green } } catch { Write-Error "Error during reset: $($_.Exception.Message)" if ((Get-Service -Name TermService).Status -eq 'Stopped') { Start-Service -Name TermService } } } # Main execution Write-Host "RDP-Tcp Permissions Manager" -ForegroundColor Cyan Write-Host "===========================" -ForegroundColor Cyan # Check if running as administrator $IsAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") if (-not $IsAdmin) { Write-Error "This script must be run as Administrator" exit 1 } switch ($Action) { "List" { Get-RDPPermissions } "Add" { Add-RDPUser -UserName $UserName -Domain $Domain -AccessLevel $AccessLevel } "Remove" { Remove-RDPUser -UserName $UserName -Domain $Domain } "Reset" { Reset-RDPPermissions } } Write-Host "`nOperation completed." -ForegroundColor Green