-
-
Notifications
You must be signed in to change notification settings - Fork 2
fix: security hardening batch 2 — cache validation, zip slip, DLL hijack, parser hardening #403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -192,7 +192,12 @@ | |||||||||||||||||||||||||||||||||||||||||
| RedirectStandardError = true, | ||||||||||||||||||||||||||||||||||||||||||
| UseShellExecute = false, | ||||||||||||||||||||||||||||||||||||||||||
| CreateNoWindow = true, | ||||||||||||||||||||||||||||||||||||||||||
| ErrorDialog = false // suppress Win32 "DLL not found" system dialogs | ||||||||||||||||||||||||||||||||||||||||||
| ErrorDialog = false, // suppress Win32 "DLL not found" system dialogs | ||||||||||||||||||||||||||||||||||||||||||
| // SEC-M4: Set working directory to System32 instead of the tools dir. | ||||||||||||||||||||||||||||||||||||||||||
| // This prevents DLL hijacking via CWD search order — if an attacker | ||||||||||||||||||||||||||||||||||||||||||
| // plants a malicious DLL in the user-writable tools directory, the | ||||||||||||||||||||||||||||||||||||||||||
| // process won't load it because CWD is System32 (admin-protected). | ||||||||||||||||||||||||||||||||||||||||||
| WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System) | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Start the process on a thread-pool thread so Process.Start() | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -342,7 +347,31 @@ | |||||||||||||||||||||||||||||||||||||||||
| progress?.Report((15, "Extracting…")); | ||||||||||||||||||||||||||||||||||||||||||
| await Task.Run(() => | ||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||
| ZipFile.ExtractToDirectory(zipPath, toolsDir, overwriteFiles: true); | ||||||||||||||||||||||||||||||||||||||||||
| // SEC-M3: Manual extraction with Zip Slip protection. | ||||||||||||||||||||||||||||||||||||||||||
| // ZipFile.ExtractToDirectory does not validate that entry paths | ||||||||||||||||||||||||||||||||||||||||||
| // stay within the target directory — a crafted zip with "../" | ||||||||||||||||||||||||||||||||||||||||||
| // entries could write files outside toolsDir. | ||||||||||||||||||||||||||||||||||||||||||
| using var archive = ZipFile.OpenRead(zipPath); | ||||||||||||||||||||||||||||||||||||||||||
| var fullToolsDir = Path.GetFullPath(toolsDir); | ||||||||||||||||||||||||||||||||||||||||||
| foreach (var entry in archive.Entries) | ||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||
| // Skip directory entries | ||||||||||||||||||||||||||||||||||||||||||
| if (string.IsNullOrEmpty(entry.Name)) continue; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| var destinationPath = Path.GetFullPath(Path.Combine(toolsDir, entry.FullName)); | ||||||||||||||||||||||||||||||||||||||||||
Check noticeCode scanning / CodeQL Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note
Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!destinationPath.StartsWith(fullToolsDir, StringComparison.OrdinalIgnoreCase)) | ||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+355
to
+363
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a directory-boundary check to the Zip Slip guard.
Suggested fix- var fullToolsDir = Path.GetFullPath(toolsDir);
+ var fullToolsDir = Path.GetFullPath(toolsDir)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ + Path.DirectorySeparatorChar;
foreach (var entry in archive.Entries)
{
// Skip directory entries
if (string.IsNullOrEmpty(entry.Name)) continue;
var destinationPath = Path.GetFullPath(Path.Combine(toolsDir, entry.FullName));
if (!destinationPath.StartsWith(fullToolsDir, StringComparison.OrdinalIgnoreCase))
{
Log.Warning("Zip Slip attempt blocked: {Entry} resolves outside target dir", entry.FullName);
continue;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| Log.Warning("Zip Slip attempt blocked: {Entry} resolves outside target dir", entry.FullName); | ||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Ensure subdirectory exists | ||||||||||||||||||||||||||||||||||||||||||
| var entryDir = Path.GetDirectoryName(destinationPath); | ||||||||||||||||||||||||||||||||||||||||||
| if (!string.IsNullOrEmpty(entryDir)) | ||||||||||||||||||||||||||||||||||||||||||
| Directory.CreateDirectory(entryDir); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| entry.ExtractToFile(destinationPath, overwrite: true); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
Check noticeCode scanning / CodeQL Missed opportunity to use Where Note
This foreach loop
implicitly filters its target sequence Error loading related location Loading |
||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+356
to
+374
|
||||||||||||||||||||||||||||||||||||||||||
| File.Delete(zipPath); | ||||||||||||||||||||||||||||||||||||||||||
| }, ct).ConfigureAwait(false); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -103,7 +103,8 @@ private void RaiseHopCompleted(TracerouteHop hop) | |
| foreach (var h in handlers) | ||
| { | ||
| try { ((Action<TracerouteHop>)h).Invoke(hop); } | ||
| catch (Exception) { /* swallow subscriber errors to protect iteration */ } | ||
| catch (ObjectDisposedException) { /* subscriber disposed — skip */ } | ||
| catch (InvalidOperationException) { /* subscriber error — skip */ } | ||
|
Comment on lines
+106
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent subscriber exceptions from aborting traceroute emission. With only two catches, a different handler exception can escape and terminate Suggested fix foreach (var h in handlers)
{
try { ((Action<TracerouteHop>)h).Invoke(hop); }
catch (ObjectDisposedException) { /* subscriber disposed — skip */ }
catch (InvalidOperationException) { /* subscriber error — skip */ }
+ catch (Exception) { /* unexpected subscriber failure — skip to preserve traceroute execution */ }
}🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -318,6 +318,14 @@ internal static (string Exe, string Args) ParseUninstallCommand(string command) | |
| { | ||
| command = command.Trim(); | ||
|
|
||
| // SEC-M7: Reject obviously malicious patterns before parsing. | ||
| // Commands containing shell metacharacters that could chain commands. | ||
| if (command.Contains('|') || command.Contains('&') || | ||
| command.Contains(';') || command.Contains('`') || | ||
| command.Contains("$(")) | ||
| throw new InvalidOperationException( | ||
| $"Uninstall command contains shell metacharacters — refusing to parse: '{command}'"); | ||
|
Comment on lines
+321
to
+327
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parse first; this raw blacklist still allows shell hosts and now rejects valid quoted paths. Because this check runs before the quoted-path branch, legitimate commands like 🤖 Prompt for AI Agents |
||
|
|
||
| // Case 1: Quoted executable path | ||
| if (command.StartsWith('"')) | ||
| { | ||
|
|
@@ -360,17 +368,35 @@ internal static (string Exe, string Args) ParseUninstallCommand(string command) | |
| return (command[..spaceIdx], command[(spaceIdx + 1)..].TrimStart()); | ||
| } | ||
|
|
||
| // Case 4: Unquoted path with spaces — find first .exe boundary | ||
| var exeEnd = command.IndexOf(".exe", StringComparison.OrdinalIgnoreCase); | ||
| if (exeEnd > 0) | ||
| // Case 4: Unquoted path with spaces — find first .exe boundary. | ||
| // SEC-M7: Only match ".exe" followed by end-of-string, whitespace, or a | ||
| // switch character (/,-). This prevents misparsing paths like | ||
| // "C:\dir\app.executable\tool.exe" where an earlier ".exe" substring | ||
| // would incorrectly split the path. | ||
| var searchStart = 0; | ||
| while (searchStart < command.Length) | ||
| { | ||
| var exeEnd = command.IndexOf(".exe", searchStart, StringComparison.OrdinalIgnoreCase); | ||
| if (exeEnd < 0) break; | ||
|
|
||
| exeEnd += 4; // include ".exe" | ||
| var exe = command[..exeEnd].Trim(); | ||
| var args = exeEnd < command.Length ? command[exeEnd..].TrimStart() : ""; | ||
| return (exe, args); | ||
| // Valid boundary: end of string, or followed by whitespace/switch | ||
| if (exeEnd >= command.Length || | ||
| command[exeEnd] == ' ' || command[exeEnd] == '\t' || | ||
| command[exeEnd] == '/' || command[exeEnd] == '-') | ||
| { | ||
| var exe = command[..exeEnd].Trim(); | ||
| var args = exeEnd < command.Length ? command[exeEnd..].TrimStart() : ""; | ||
| return (exe, args); | ||
| } | ||
| // Not a valid boundary — keep searching after this occurrence | ||
| searchStart = exeEnd; | ||
| } | ||
|
|
||
| // Fallback: treat entire string as executable | ||
| return (command, ""); | ||
| // SEC-M7: No fallback — if we can't parse it safely, reject it. | ||
| // The old fallback treated the entire string as an executable, which | ||
| // could execute arbitrary commands if the string was crafted. | ||
| throw new InvalidOperationException( | ||
| $"Cannot safely parse uninstall command — no valid executable found: '{command}'"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,9 +143,35 @@ public async Task<IReadOnlyList<ReleaseInfo>> GetRecentAsync(int count = 10, Can | |
| Directory.CreateDirectory(dir); | ||
| var target = Path.Combine(dir, $"SysManager-{rel.Version}.exe"); | ||
|
|
||
| // Skip re-download if we already have a good copy. | ||
| if (File.Exists(target) && rel.AssetSize.HasValue && new FileInfo(target).Length == rel.AssetSize.Value) | ||
| return target; | ||
| // SEC-M2: Skip re-download only if we have a cached hash that matches. | ||
| // File size alone is insufficient — an attacker could replace the binary | ||
| // with a same-size payload. We store a companion .sha256 file after each | ||
| // successful download to enable fast cache validation without re-downloading | ||
| // the hash from GitHub on every launch. | ||
| var hashFile = target + ".sha256"; | ||
| if (File.Exists(target) && File.Exists(hashFile)) | ||
| { | ||
| try | ||
| { | ||
| var cachedHash = (await File.ReadAllTextAsync(hashFile, ct).ConfigureAwait(false)).Trim(); | ||
| if (cachedHash.Length >= 64) | ||
| { | ||
| var actualHash = await Task.Run(() => | ||
| { | ||
| using var stream = File.OpenRead(target); | ||
| return Convert.ToHexString(SHA256.HashData(stream)); | ||
| }, ct).ConfigureAwait(false); | ||
|
|
||
| if (string.Equals(cachedHash, actualHash, StringComparison.OrdinalIgnoreCase)) | ||
| return target; | ||
|
|
||
| // Hash mismatch — cached file is corrupt or tampered, re-download. | ||
| Serilog.Log.Warning("Cached update binary hash mismatch — re-downloading"); | ||
| } | ||
| } | ||
| catch (IOException) { /* hash file unreadable — re-download */ } | ||
| catch (UnauthorizedAccessException) { /* hash file unreadable — re-download */ } | ||
| } | ||
|
|
||
| try | ||
| { | ||
|
|
@@ -166,6 +192,19 @@ public async Task<IReadOnlyList<ReleaseInfo>> GetRecentAsync(int count = 10, Can | |
| progress?.Report((read, total)); | ||
| } | ||
|
|
||
| // SEC-M2: Store SHA-256 hash of the downloaded binary for cache validation. | ||
| try | ||
| { | ||
| var downloadedHash = await Task.Run(() => | ||
| { | ||
| using var hashStream = File.OpenRead(target); | ||
| return Convert.ToHexString(SHA256.HashData(hashStream)); | ||
| }, ct).ConfigureAwait(false); | ||
| await File.WriteAllTextAsync(hashFile, downloadedHash, ct).ConfigureAwait(false); | ||
| } | ||
| catch (IOException) { /* non-fatal — next launch will re-download */ } | ||
| catch (UnauthorizedAccessException) { /* non-fatal */ } | ||
|
Comment on lines
+195
to
+206
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File stream still open — hash computation always fails silently. The This defeats the SEC-M2 fix: without a hash file, every launch triggers a full re-download. 🐛 Proposed fix: Flush and close the write stream before computing hash read += n;
progress?.Report((read, total));
}
+
+ // Close write stream before reopening for hash computation
+ await file.DisposeAsync().ConfigureAwait(false);
// SEC-M2: Store SHA-256 hash of the downloaded binary for cache validation.
try
{
var downloadedHash = await Task.Run(() =>
{
using var hashStream = File.OpenRead(target);
return Convert.ToHexString(SHA256.HashData(hashStream));
}, ct).ConfigureAwait(false);
await File.WriteAllTextAsync(hashFile, downloadedHash, ct).ConfigureAwait(false);
}Alternatively, restructure to use a scoped block for the write stream: {
await using var file = File.Create(target);
// ... write loop ...
} // file disposed here
// Now safe to read
try
{
var downloadedHash = ...
}🤖 Prompt for AI Agents |
||
|
|
||
| return target; | ||
| } | ||
| catch (OperationCanceledException) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep subscriber isolation for all handler failures.
Catching only two exception types allows other subscriber exceptions to break the handler loop, which violates the isolation contract in this method.
Suggested fix
foreach (var h in handlers) { try { ((Action<PingSample>)h).Invoke(sample); } catch (ObjectDisposedException) { /* subscriber disposed — skip */ } catch (InvalidOperationException) { /* subscriber error — skip */ } + catch (Exception) { /* unexpected subscriber failure — skip to preserve fan-out isolation */ } }🤖 Prompt for AI Agents