Skip to content

Commit 6e225e4

Browse files
committed
fix(connection): verify WSL relay signatures in process
1 parent 8ac00c5 commit 6e225e4

4 files changed

Lines changed: 176 additions & 59 deletions

File tree

src/OpenClaw.Connection/ManagedLocalGatewayPortProvenanceService.cs

Lines changed: 35 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ internal interface IManagedLocalGatewayPortPlatform
2828
{
2929
WindowsTcpListenerSnapshotResult CaptureListeners();
3030
string? GetProcessCommandLine(int processId);
31-
bool IsTrustedWslRelayBinary(string processPath);
31+
WslRelayTrustResult InspectWslRelayBinary(string processPath);
3232
bool IsExpectedWslGatewayListening(string distroName, int port);
3333
string? ReadScheduledTaskXml(string taskName);
3434
string? ReadFile(string path);
@@ -40,6 +40,13 @@ Task<bool> StopProcessAsync(
4040
CancellationToken cancellationToken);
4141
}
4242

43+
internal readonly record struct WslRelayTrustResult(bool IsTrusted, string? Detail)
44+
{
45+
public static WslRelayTrustResult Trusted() => new(true, null);
46+
47+
public static WslRelayTrustResult Rejected(string detail) => new(false, detail);
48+
}
49+
4350
internal sealed class WindowsManagedLocalGatewayPortPlatform : IManagedLocalGatewayPortPlatform
4451
{
4552
public WindowsTcpListenerSnapshotResult CaptureListeners() =>
@@ -48,7 +55,12 @@ public WindowsTcpListenerSnapshotResult CaptureListeners() =>
4855
public string? GetProcessCommandLine(int processId) =>
4956
WindowsTcpListenerSnapshot.GetProcessCommandLine(processId);
5057

51-
public bool IsTrustedWslRelayBinary(string processPath)
58+
public WslRelayTrustResult InspectWslRelayBinary(string processPath) =>
59+
EvaluateWslRelayBinary(processPath, WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile);
60+
61+
internal static WslRelayTrustResult EvaluateWslRelayBinary(
62+
string processPath,
63+
Func<string, AuthenticodeTrustResult> verifySignature)
5264
{
5365
try
5466
{
@@ -64,50 +76,24 @@ public bool IsTrustedWslRelayBinary(string processPath)
6476
(fullPath.StartsWith(windowsAppsRoot, StringComparison.OrdinalIgnoreCase) &&
6577
string.Equals(Path.GetFileName(fullPath), "wslrelay.exe", StringComparison.OrdinalIgnoreCase));
6678
if (!isCanonical)
67-
return false;
68-
69-
for (var attempt = 0; attempt < 2; attempt++)
7079
{
71-
var psi = CreateWslRelaySignatureProbe(fullPath);
72-
using var process = Process.Start(psi);
73-
if (process is null)
74-
return false;
75-
if (process.WaitForExit(5_000))
76-
return process.ExitCode == 0;
77-
78-
try { process.Kill(entireProcessTree: true); } catch { }
80+
return WslRelayTrustResult.Rejected(
81+
"WSL relay executable path is not canonical.");
7982
}
80-
return false;
83+
84+
var signature = verifySignature(fullPath);
85+
return signature.IsTrusted
86+
? WslRelayTrustResult.Trusted()
87+
: WslRelayTrustResult.Rejected(
88+
signature.Detail ?? "WSL relay Authenticode verification failed.");
8189
}
8290
catch
8391
{
84-
return false;
92+
return WslRelayTrustResult.Rejected(
93+
"WSL relay Authenticode verification could not complete.");
8594
}
8695
}
8796

88-
internal static ProcessStartInfo CreateWslRelaySignatureProbe(string fullPath)
89-
{
90-
var startInfo = new ProcessStartInfo
91-
{
92-
FileName = "powershell.exe",
93-
UseShellExecute = false,
94-
CreateNoWindow = true,
95-
};
96-
97-
// A pwsh parent can prepend PowerShell 7 modules that Windows PowerShell
98-
// 5.1 cannot load. Let the child rebuild its native module path so the
99-
// built-in Authenticode cmdlet remains available.
100-
startInfo.Environment.Remove("PSModulePath");
101-
startInfo.Environment["OPENCLAW_VERIFY_PATH"] = fullPath;
102-
startInfo.ArgumentList.Add("-NoProfile");
103-
startInfo.ArgumentList.Add("-NonInteractive");
104-
startInfo.ArgumentList.Add("-Command");
105-
startInfo.ArgumentList.Add(
106-
"$s=Get-AuthenticodeSignature -LiteralPath $env:OPENCLAW_VERIFY_PATH; " +
107-
"if($s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -match 'Microsoft'){exit 0}; exit 1");
108-
return startInfo;
109-
}
110-
11197
public bool IsExpectedWslGatewayListening(string distroName, int port)
11298
{
11399
try
@@ -386,10 +372,10 @@ private GatewayEndpointProvenance InspectCore(GatewayRecord record)
386372
.Distinct(StringComparer.OrdinalIgnoreCase)
387373
.ToDictionary(
388374
path => path,
389-
path => _platform.IsTrustedWslRelayBinary(path),
375+
path => _platform.InspectWslRelayBinary(path),
390376
StringComparer.OrdinalIgnoreCase);
391377
var expectedDistroListening =
392-
relayTrustByPath.Values.Any(trusted => trusted) &&
378+
relayTrustByPath.Values.Any(result => result.IsTrusted) &&
393379
_platform.IsExpectedWslGatewayListening(managedDistroName, uri.Port);
394380
var classified = listeners
395381
.Select(listener => ClassifyListener(
@@ -426,7 +412,8 @@ private GatewayEndpointProvenance InspectCore(GatewayRecord record)
426412
.Where(item => item.Kind != GatewayEndpointProvenanceKind.ExpectedManagedGateway)
427413
.Select(item =>
428414
$"{item.ProcessName ?? "unknown"} (PID {item.ProcessId?.ToString() ?? "?"}): " +
429-
(item.Detail ?? "listener verification failed")));
415+
(item.Detail ?? "listener verification failed"))
416+
.Distinct(StringComparer.Ordinal));
430417
return new GatewayEndpointProvenance(
431418
GatewayEndpointProvenanceKind.UnknownListener,
432419
uri.Port,
@@ -533,16 +520,17 @@ private GatewayEndpointProvenance ClassifyListener(
533520
string managedDistroName,
534521
int port,
535522
WindowsTcpListenerInfo listener,
536-
IReadOnlyDictionary<string, bool> relayTrustByPath,
523+
IReadOnlyDictionary<string, WslRelayTrustResult> relayTrustByPath,
537524
bool expectedDistroListening)
538525
{
539526
var isWslRelay =
540527
string.Equals(listener.ProcessName, "wslrelay", StringComparison.OrdinalIgnoreCase);
541528
var relayPath = listener.ProcessPath;
542-
var trustedRelay =
529+
var relayTrust = default(WslRelayTrustResult);
530+
var hasRelayTrust =
543531
relayPath is not null &&
544-
relayTrustByPath.TryGetValue(relayPath, out var trusted) &&
545-
trusted;
532+
relayTrustByPath.TryGetValue(relayPath, out relayTrust);
533+
var trustedRelay = hasRelayTrust && relayTrust.IsTrusted;
546534
if (isWslRelay && trustedRelay && expectedDistroListening)
547535
{
548536
return new GatewayEndpointProvenance(
@@ -572,7 +560,8 @@ relayPath is not null &&
572560
? relayPath is null
573561
? "WSL relay executable path could not be read."
574562
: !trustedRelay
575-
? "WSL relay is not the canonical Microsoft-signed binary."
563+
? relayTrust.Detail ??
564+
"WSL relay Authenticode verification failed."
576565
: $"Expected distro '{managedDistroName}' does not report its systemd gateway MainPID owning port {port}."
577566
: "Process is not a verified managed WSL relay or proven obsolete OpenClaw gateway.";
578567
return new GatewayEndpointProvenance(

src/OpenClaw.Connection/OpenClaw.Connection.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@
1616
<ProjectReference Include="..\OpenClaw.Shared\OpenClaw.Shared.csproj" />
1717
</ItemGroup>
1818

19+
<ItemGroup>
20+
<PackageReference Include="Microsoft.Security.Extensions" Version="1.4.0" />
21+
</ItemGroup>
22+
1923
</Project>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System;
2+
using System.IO;
3+
using Microsoft.Security.Extensions;
4+
5+
namespace OpenClaw.Connection;
6+
7+
internal readonly record struct AuthenticodeTrustResult(bool IsTrusted, string? Detail)
8+
{
9+
public static AuthenticodeTrustResult Trusted() => new(true, null);
10+
11+
public static AuthenticodeTrustResult Rejected(string detail) => new(false, detail);
12+
}
13+
14+
internal static class WindowsAuthenticodeVerifier
15+
{
16+
public static AuthenticodeTrustResult VerifyMicrosoftSignedFile(string path)
17+
{
18+
try
19+
{
20+
using var stream = File.OpenRead(path);
21+
var signature = FileSignatureInfo.GetFromFileStream(stream);
22+
using var signingCertificate = signature.SigningCertificate;
23+
using var timestampCertificate = signature.TimestampCertificate;
24+
25+
if (signature.State != SignatureState.SignedAndTrusted)
26+
{
27+
return AuthenticodeTrustResult.Rejected(
28+
$"WSL relay Authenticode verification failed ({signature.State}).");
29+
}
30+
if (signingCertificate is null)
31+
{
32+
return AuthenticodeTrustResult.Rejected(
33+
"WSL relay Authenticode signer could not be read.");
34+
}
35+
36+
return HasMicrosoftPublisherIdentity(signingCertificate.Subject)
37+
? AuthenticodeTrustResult.Trusted()
38+
: AuthenticodeTrustResult.Rejected(
39+
"WSL relay Authenticode signer is not Microsoft Corporation.");
40+
}
41+
catch
42+
{
43+
return AuthenticodeTrustResult.Rejected(
44+
"WSL relay Authenticode verification could not complete.");
45+
}
46+
}
47+
48+
internal static bool HasMicrosoftPublisherIdentity(string subject) =>
49+
subject.Split(',')
50+
.Select(part => part.Trim())
51+
.Any(part =>
52+
string.Equals(
53+
part,
54+
"O=Microsoft Corporation",
55+
StringComparison.OrdinalIgnoreCase));
56+
}

tests/OpenClaw.Connection.Tests/ManagedLocalGatewayPortProvenanceServiceTests.cs

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,55 @@ public class ManagedLocalGatewayPortProvenanceServiceTests
1515
};
1616

1717
[Fact]
18-
public void CreateWslRelaySignatureProbe_DoesNotInheritPwshModulePath()
18+
public void EvaluateWslRelayBinary_NonCanonicalPathSkipsSignatureVerification()
1919
{
20-
const string relayPath = @"C:\Program Files\WSL\wslrelay.exe";
20+
var signatureChecked = false;
2121

22-
var startInfo =
23-
WindowsManagedLocalGatewayPortPlatform.CreateWslRelaySignatureProbe(relayPath);
22+
var result = WindowsManagedLocalGatewayPortPlatform.EvaluateWslRelayBinary(
23+
@"C:\Temp\WSL\wslrelay.exe",
24+
_ =>
25+
{
26+
signatureChecked = true;
27+
return AuthenticodeTrustResult.Trusted();
28+
});
2429

25-
Assert.False(startInfo.Environment.ContainsKey("PSModulePath"));
26-
Assert.Equal(relayPath, startInfo.Environment["OPENCLAW_VERIFY_PATH"]);
27-
Assert.Contains(
28-
"Get-AuthenticodeSignature",
29-
startInfo.ArgumentList[^1],
30-
StringComparison.Ordinal);
30+
Assert.False(result.IsTrusted);
31+
Assert.Contains("path is not canonical", result.Detail);
32+
Assert.False(signatureChecked);
33+
}
34+
35+
[Fact]
36+
public void VerifyMicrosoftSignedFile_AcceptsWindowsWslBinary()
37+
{
38+
var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
39+
var wslPath = Path.Combine(windowsDir, "System32", "wsl.exe");
40+
41+
var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile(wslPath);
42+
43+
Assert.True(result.IsTrusted, result.Detail);
44+
}
45+
46+
[Fact]
47+
public void VerifyMicrosoftSignedFile_RejectsUnsignedAssembly()
48+
{
49+
var result = WindowsAuthenticodeVerifier.VerifyMicrosoftSignedFile(
50+
typeof(ManagedLocalGatewayPortProvenanceServiceTests).Assembly.Location);
51+
52+
Assert.False(result.IsTrusted);
53+
Assert.Contains("Authenticode verification failed", result.Detail);
54+
}
55+
56+
[Theory]
57+
[InlineData("CN=Microsoft Windows, O=Microsoft Corporation, C=US", true)]
58+
[InlineData("CN=Microsoft Corporation Test Certificate, O=Example Corp, C=US", false)]
59+
[InlineData("CN=Other Publisher, O=Microsoft Corporation Services, C=US", false)]
60+
public void HasMicrosoftPublisherIdentity_RequiresExactOrganization(
61+
string subject,
62+
bool expected)
63+
{
64+
Assert.Equal(
65+
expected,
66+
WindowsAuthenticodeVerifier.HasMicrosoftPublisherIdentity(subject));
3167
}
3268

3369
[Fact]
@@ -109,7 +145,36 @@ public void Inspect_SpoofedWslRelayPath_IsUnknown()
109145
var result = service.Inspect(ManagedRecord());
110146

111147
Assert.Equal(GatewayEndpointProvenanceKind.UnknownListener, result.Kind);
112-
Assert.Contains("not the canonical Microsoft-signed binary", result.Detail);
148+
Assert.Contains("Authenticode verification failed", result.Detail);
149+
}
150+
151+
[Fact]
152+
public void Inspect_DualStackUntrustedRelay_DeduplicatesFailureDetail()
153+
{
154+
var platform = new FakePlatform { TrustedWslRelay = false };
155+
var start = new DateTime(2026, 7, 24, 1, 0, 0, DateTimeKind.Utc);
156+
platform.Listeners.Add(new WindowsTcpListenerInfo(
157+
IPAddress.Loopback,
158+
18789,
159+
101,
160+
"wslrelay",
161+
@"C:\Program Files\WSL\wslrelay.exe",
162+
start));
163+
platform.Listeners.Add(new WindowsTcpListenerInfo(
164+
IPAddress.IPv6Loopback,
165+
18789,
166+
101,
167+
"wslrelay",
168+
@"C:\Program Files\WSL\wslrelay.exe",
169+
start));
170+
var service = new ManagedLocalGatewayPortProvenanceService(platform, NullLogger.Instance);
171+
172+
var result = service.Inspect(ManagedRecord());
173+
174+
Assert.Equal(GatewayEndpointProvenanceKind.UnknownListener, result.Kind);
175+
Assert.Equal(
176+
result.Detail!.IndexOf("Authenticode verification failed", StringComparison.Ordinal),
177+
result.Detail.LastIndexOf("Authenticode verification failed", StringComparison.Ordinal));
113178
}
114179

115180
[Fact]
@@ -425,10 +490,13 @@ Listeners[0] with
425490
}
426491
public string? GetProcessCommandLine(int processId) =>
427492
CommandLines.GetValueOrDefault(processId);
428-
public bool IsTrustedWslRelayBinary(string processPath)
493+
public WslRelayTrustResult InspectWslRelayBinary(string processPath)
429494
{
430495
TrustedWslRelayChecks++;
431-
return TrustedWslRelay;
496+
return TrustedWslRelay
497+
? WslRelayTrustResult.Trusted()
498+
: WslRelayTrustResult.Rejected(
499+
"WSL relay Authenticode verification failed.");
432500
}
433501

434502
public bool IsExpectedWslGatewayListening(string distroName, int port)

0 commit comments

Comments
 (0)