Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions src/Engine/LoginGate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,42 @@ public sealed class LoginGate
/// Claims the desktop sign-in for the login batch. Returns null when the caller
/// should run the batch, or the reason it should not.
/// </summary>
/// <remarks>
/// The account is matched by SID when both sides have one. Names are not a reliable
/// match: for an Entra ID sign-in the event names the account by its UPN
/// (user@domain) while the session reports the short name, so comparing names
/// rejected every Entra sign-in as another account logging on inside the session.
/// Names are compared only when a SID is missing, with the domain prefix and the
/// UPN suffix both removed.
/// </remarks>
/// <param name="logonUser">TargetUserName from the event, or null for the start-up catch-up.</param>
/// <param name="sessionId">The desktop session.</param>
/// <param name="sessionUser">The user signed in to that session now.</param>
/// <param name="sessionLogonTime">When that user signed in to the session.</param>
public string? Claim(string? logonUser, int sessionId, string? sessionUser, DateTime? sessionLogonTime)
/// <param name="logonUserSid">TargetUserSid from the event, when known.</param>
/// <param name="sessionUserSid">The SID of the user signed in to the session, when known.</param>
public string? Claim(
string? logonUser,
int sessionId,
string? sessionUser,
DateTime? sessionLogonTime,
string? logonUserSid = null,
string? sessionUserSid = null)
{
if (string.IsNullOrWhiteSpace(sessionUser))
return $"nobody is signed in to session {sessionId}";

if (logonUser != null && !string.Equals(StripDomain(logonUser), StripDomain(sessionUser), StringComparison.OrdinalIgnoreCase))
if (!IsSameAccount(logonUser, logonUserSid, sessionUser, sessionUserSid))
return $"session {sessionId} belongs to {sessionUser}, so this was another account logging on inside it (runas or an elevation prompt)";

lock (_lock)
{
var signIn = (StripDomain(sessionUser).ToUpperInvariant(), sessionLogonTime);
// Keyed on the session's identity, never the event's, so the start-up
// catch-up (which has no event) and a later event for the same sign-in agree.
var who = HasSid(sessionUserSid)
? sessionUserSid!.Trim().ToUpperInvariant()
: AccountName(sessionUser).ToUpperInvariant();
var signIn = (who, sessionLogonTime);
if (_handled.TryGetValue(sessionId, out var handled) && handled == signIn)
return $"the login payloads already ran for {sessionUser}'s sign-in to session {sessionId}";

Expand All @@ -62,9 +83,30 @@ public sealed class LoginGate
}
}

private static string StripDomain(string name)
private static bool IsSameAccount(string? logonUser, string? logonUserSid, string sessionUser, string? sessionUserSid)
{
if (HasSid(logonUserSid) && HasSid(sessionUserSid))
return string.Equals(logonUserSid!.Trim(), sessionUserSid!.Trim(), StringComparison.OrdinalIgnoreCase);

// The start-up catch-up has no event, so nothing to compare.
if (logonUser == null)
return true;

return string.Equals(AccountName(logonUser), AccountName(sessionUser), StringComparison.OrdinalIgnoreCase);
}

private static bool HasSid(string? sid) => !string.IsNullOrWhiteSpace(sid);

/// <summary>
/// The bare account name: DOMAIN\user, AzureAD\user and user@domain all become user.
/// </summary>
internal static string AccountName(string name)
{
name = name.Trim();
var slash = name.LastIndexOf('\\');
return slash >= 0 ? name[(slash + 1)..] : name;
if (slash >= 0) name = name[(slash + 1)..];
var at = name.IndexOf('@');
if (at > 0) name = name[..at];
return name;
}
}
38 changes: 38 additions & 0 deletions src/Engine/Native/ShellReadiness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,46 @@ public static int GetActiveConsoleSessionId()
}
}

/// <summary>
/// The SID of the user signed in to <paramref name="sessionId"/>, or null when it
/// cannot be read.
/// </summary>
/// <remarks>
/// The session's user name is not enough to tell whether a logon event belongs to
/// the person at the desktop: an Entra ID sign-in is named by its UPN in the event
/// and by its short name in the session. The SID is the same on both sides.
///
/// WTSQueryUserToken needs SeTcbPrivilege, which the service has as SYSTEM. Anywhere
/// else -- a test run, the CLI -- this returns null and the caller falls back to
/// comparing names.
/// </remarks>
public static string? GetSessionUserSid(int sessionId)
{
if (sessionId <= 0)
return null;

try
{
if (!WTSQueryUserToken(sessionId, out var token))
return null;

using (token)
using (var identity = new System.Security.Principal.WindowsIdentity(token.DangerousGetHandle()))
{
return identity.User?.Value;
}
}
catch
{
return null;
}
}

private const int WtsUserName = 5;

[System.Runtime.InteropServices.DllImport("wtsapi32.dll", SetLastError = true)]
private static extern bool WTSQueryUserToken(int sessionId, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);

[System.Runtime.InteropServices.DllImport("kernel32.dll")]
private static extern uint WTSGetActiveConsoleSessionId();

Expand Down
17 changes: 13 additions & 4 deletions src/Service/Workers/LogonEventWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ private async void OnLogonEvent(object? sender, EventRecordWrittenEventArgs e)
var record = e.EventRecord;
var username = GetEventDataValue(record, "TargetUserName");
var domain = GetEventDataValue(record, "TargetDomainName");
var userSid = GetEventDataValue(record, "TargetUserSid");
var logonType = GetEventDataValue(record, "LogonType");

// Skip system accounts
Expand All @@ -122,7 +123,7 @@ private async void OnLogonEvent(object? sender, EventRecordWrittenEventArgs e)
Volatile.Write(ref _observedLogon, 1);

var sessionId = logonSessionId >= 0 ? logonSessionId : GetActiveConsoleSession();
await RunLoginPayloadsAsync(username, username, sessionId, CancellationToken.None);
await RunLoginPayloadsAsync(username, username, userSid, sessionId, CancellationToken.None);
}
catch (Exception ex)
{
Expand All @@ -141,7 +142,8 @@ private async void OnLogonEvent(object? sender, EventRecordWrittenEventArgs e)
/// one path and miss the other.
/// </remarks>
/// <param name="logonUser">The account the logon event names, or null for the catch-up.</param>
private async Task RunLoginPayloadsAsync(string? username, string? logonUser, int sessionId, CancellationToken stoppingToken)
/// <param name="logonUserSid">The SID the logon event names, or null for the catch-up.</param>
private async Task RunLoginPayloadsAsync(string? username, string? logonUser, string? logonUserSid, int sessionId, CancellationToken stoppingToken)
{
try
{
Expand Down Expand Up @@ -176,7 +178,13 @@ await ShellReadiness.WaitForDesktopAsync(
// Checked after the desktop wait: until then the session may not show who
// signed in to it yet.
var sessionUser = ShellReadiness.GetSessionUserName(sessionId);
var notASignIn = _loginGate.Claim(logonUser, sessionId, sessionUser, LogonSessions.GetSessionLogonTime(sessionId));
var notASignIn = _loginGate.Claim(
logonUser,
sessionId,
sessionUser,
LogonSessions.GetSessionLogonTime(sessionId),
logonUserSid,
ShellReadiness.GetSessionUserSid(sessionId));
if (notASignIn != null)
{
StartSetLogger.Debug("Not running login payloads for {User}: {Reason}", logonUser ?? username ?? "Unknown", notASignIn);
Expand Down Expand Up @@ -316,7 +324,7 @@ private async Task RunForAlreadySignedInUserAsync(CancellationToken stoppingToke

Volatile.Write(ref _observedLogon, 1);

await RunLoginPayloadsAsync(username, null, sessionId, stoppingToken);
await RunLoginPayloadsAsync(username, null, null, sessionId, stoppingToken);
}
catch (OperationCanceledException)
{
Expand Down Expand Up @@ -377,6 +385,7 @@ private static int GetActiveConsoleSession()
// TargetUserSid, TargetUserName, TargetDomainName, TargetLogonId, LogonType...
return name switch
{
"TargetUserSid" => properties.Count > 4 ? properties[4].Value?.ToString() : null,
"TargetUserName" => properties.Count > 5 ? properties[5].Value?.ToString() : null,
"TargetDomainName" => properties.Count > 6 ? properties[6].Value?.ToString() : null,
"TargetLogonId" => properties.Count > 7 ? properties[7].Value?.ToString() : null,
Expand Down
57 changes: 57 additions & 0 deletions tests/StartSet.Tests/Engine/LoginGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,63 @@ public void AnotherAccountLoggingOnInsideTheSessionIsNotASignIn()
Assert.Null(gate.Claim("alice", 1, "alice", SignedIn));
}

private const string AliceSid = "S-1-12-1-1111111111-2222222222-3333333333-4444444444";
private const string HelpdeskSid = "S-1-12-1-5555555555-6666666666-7777777777-8888888888";

[Fact]
public void AnEntraSignInNamedByUpnMatchesTheSessionBySid()
{
var gate = new LoginGate();

Assert.Null(gate.Claim("alice@contoso.example", 1, "alice", SignedIn, AliceSid, AliceSid));
}

[Fact]
public void ADifferentSidIsAnotherAccountEvenWhenTheNamesMatch()
{
var gate = new LoginGate();

Assert.NotNull(gate.Claim("alice", 1, "alice", SignedIn, HelpdeskSid, AliceSid));
Assert.NotNull(gate.Claim("helpdesk@contoso.example", 1, "alice", SignedIn, HelpdeskSid, AliceSid));
Assert.Null(gate.Claim("alice@contoso.example", 1, "alice", SignedIn, AliceSid, AliceSid));
}

[Theory]
[InlineData("alice@contoso.example")]
[InlineData(@"AzureAD\alice")]
[InlineData(@"AzureAD\alice@contoso.example")]
[InlineData(@"CONTOSO\Alice")]
public void WithoutASidTheNamesAreComparedWithoutDomainOrUpnSuffix(string logonUser)
{
Assert.Null(new LoginGate().Claim(logonUser, 1, "alice", SignedIn, null, AliceSid));
Assert.Null(new LoginGate().Claim(logonUser, 1, @"AzureAD\alice", SignedIn, AliceSid, null));
}

[Fact]
public void WithoutASidAnotherNameIsStillAnotherAccount()
{
Assert.NotNull(new LoginGate().Claim("helpdesk@contoso.example", 1, "alice", SignedIn, HelpdeskSid, null));
}

[Fact]
public void TheCatchUpAndALaterEventForTheSameSignInRunTheBatchOnce()
{
var gate = new LoginGate();

Assert.Null(gate.Claim(null, 1, "alice", SignedIn, null, AliceSid));
Assert.NotNull(gate.Claim("alice@contoso.example", 1, "alice", SignedIn, AliceSid, AliceSid));
}

[Fact]
public void TheSessionSidNeedsSystemToRead()
{
if (new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null)))
return;

Assert.Null(ShellReadiness.GetSessionUserSid(Process.GetCurrentProcess().SessionId));
Assert.Null(ShellReadiness.GetSessionUserSid(0));
}

[Fact]
public void ASessionNobodyIsSignedInToIsNotASignIn()
{
Expand Down
Loading