Root cause
Path & link handling
Standardize on WriteToDirectory naming and add async support
AI-assisted change: GitHub Copilot
How AI contributed: New attack surfacePublished May 8, 2026 in
adamhathcock/sharpcompress
Copilot SWE-agent rewrote extraction so each entry is written with Path.Combine(destinationDirectory, entry.Key...) followed by Directory.CreateDirectory, without checking the combined path against the destination, so directory entries escape the target root and symlinks redirect writes outside it. The fix resolves each path with Path.GetFullPath and requires the rooted destination plus a trailing separator as its prefix.
Root cause
Standardize on WriteToDirectory naming and add async support
AI-assisted change: GitHub Copilot
Fix
add zipslip tests and fixes
Fix by Adam Hathcock · no AI marker found
Case facts
@@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading;+using System.Threading.Tasks; using SharpCompress.Common; using SharpCompress.Readers; @@ -19,21 +20,38 @@ public static void WriteToDirectory( ExtractionOptions? options = null ) {- using var reader = archive.ExtractAllEntries();- reader.WriteAllToDirectory(destinationDirectory, options);+ // For solid archives (Rar, 7Zip), use the optimized reader-based approach+ if (archive.IsSolid || archive.Type == ArchiveType.SevenZip)+ {+ using var reader = archive.ExtractAllEntries();+ reader.WriteAllToDirectory(destinationDirectory, options);+ }+ else+ {+ // For non-solid archives, extract entries directly+ foreach (var entry in archive.Entries)+ {+ if (!entry.IsDirectory)+ {+ entry.WriteToDirectory(destinationDirectory, options);+ }+ }+ } } /// <summary>- /// Extracts the archive to the destination directory. Directories will be created as needed.+ /// Extract to specific directory with progress reporting and cancellation support /// </summary> /// <param name="archive">The archive to extract.</param>- /// <param name="destination">The folder to extract into.</param>+ /// <param name="destinationDirectory">The folder to extract into.</param>+ /// <param name="options">Extraction options.</param> /// <param name="progressReport">Optional progress report callback.</param> /// <param name="cancellationToken">Optional cancellation token.</param>- public static void ExtractToDirectory(+ public static void WriteToDirectory( this IArchive archive,- string destination,- Action<double>? progressReport = null,+ string destinationDirectory,+ ExtractionOptions? options,+ Action<double>? progressReport, CancellationToken cancellationToken = default ) {@@ -51,7 +69,10 @@ public static void ExtractToDirectory( if (entry.IsDirectory) {- var dirPath = Path.Combine(destination, entry.Key.NotNull("Entry Key is null"));+ var dirPath = Path.Combine(+ destinationDirectory,+ entry.Key.NotNull("Entry Key is null")+ ); if ( Path.GetDirectoryName(dirPath + "/") is { } emptyDirectory && seenDirectories.Add(dirPath)@@ -62,20 +83,63 @@ public static void ExtractToDirectory( continue; } - // Create each directory if not already created- var path = Path.Combine(destination, entry.Key.NotNull("Entry Key is null"));- if (Path.GetDirectoryName(path) is { } directory)+ // Use the entry's WriteToDirectory method which respects ExtractionOptions+ entry.WriteToDirectory(destinationDirectory, options);++ // Update progress+ bytesRead += entry.Size;+ progressReport?.Invoke(bytesRead / (double)totalBytes);+ }+ }++ /// <summary>+ /// Extract to specific directory asynchronously with progress reporting and cancellation support+ /// </summary>+ /// <param name="archive">The archive to extract.</param>+ /// <param name="destinationDirectory">The folder to extract into.</param>+ /// <param name="options">Extraction options.</param>+ /// <param name="progressReport">Optional progress report callback.</param>+ /// <param name="cancellationToken">Optional cancellation token.</param>@@ -261,7 +261,7 @@ protected void ArchiveExtractToDirectory( testArchive = Path.Combine(TEST_ARCHIVES_PATH, testArchive); using (var archive = ArchiveFactory.Open(new FileInfo(testArchive), readerOptions)) {- archive.ExtractToDirectory(SCRATCH_FILES_PATH);+ archive.WriteToDirectory(SCRATCH_FILES_PATH); } VerifyFiles(); }@@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO;+using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using SharpCompress.Common;@@ -10,6 +11,14 @@ namespace SharpCompress.Archives; public static class IAsyncArchiveExtensions {+ /// <summary>+ /// Gets the appropriate StringComparison for path checks based on the file system.+ /// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems.+ /// </summary>+ private static StringComparison PathComparison =>+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)+ ? StringComparison.OrdinalIgnoreCase+ : StringComparison.Ordinal; extension(IAsyncArchive archive) { /// <summary>@@ -58,7 +67,25 @@ private async ValueTask WriteToDirectoryAsyncInternal( IProgress<ProgressReport>? progress, CancellationToken cancellationToken )- {+ { var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory);+ options ??= new ExtractionOptions();++ //check for trailing slash.+ if (+ fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1]+ != Path.DirectorySeparatorChar+ )+ {+ fullDestinationDirectoryPath += Path.DirectorySeparatorChar;+ }++ if (!Directory.Exists(fullDestinationDirectoryPath))+ {+ throw new ExtractionException(+ $"Directory does not exist to extract to: {fullDestinationDirectoryPath}"+ );+ }+ var totalBytes = await archive.TotalUncompressedSizeAsync().ConfigureAwait(false); var bytesRead = 0L; var seenDirectories = new HashSet<string>();@@ -69,16 +96,20 @@ CancellationToken cancellationToken if (entry.IsDirectory) {- var dirPath = Path.Combine(- destinationDirectory,- entry.Key.NotNull("Entry Key is null")- );- if (- Path.GetDirectoryName(dirPath + "/") is { } parentDirectory- && seenDirectories.Add(dirPath)- )+ var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null"))+ .NotNull("Directory is null");+ var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder));++ if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) {- Directory.CreateDirectory(parentDirectory);+ if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison))+ {+ throw new ExtractionException(+ "Entry is trying to create a directory outside of the destination directory."+ );+ }++ Directory.CreateDirectory(destdir); } continue; }@@ -1,13 +1,23 @@ using System; using System.Collections.Generic; using System.IO;+using System.Runtime.InteropServices; using SharpCompress.Common; using SharpCompress.Readers; namespace SharpCompress.Archives; public static class IArchiveExtensions {++ /// <summary>+ /// Gets the appropriate StringComparison for path checks based on the file system.+ /// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems.+ /// </summary>+ private static StringComparison PathComparison =>+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)+ ? StringComparison.OrdinalIgnoreCase+ : StringComparison.Ordinal; extension(IArchive archive) { /// <summary>@@ -39,6 +49,25 @@ private void WriteToDirectoryInternal( IProgress<ProgressReport>? progress ) {+ var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory);+ options ??= new ExtractionOptions();++ //check for trailing slash.+ if (+ fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1]+ != Path.DirectorySeparatorChar+ )+ {+ fullDestinationDirectoryPath += Path.DirectorySeparatorChar;+ }++ if (!Directory.Exists(fullDestinationDirectoryPath))+ {+ throw new ExtractionException(+ $"Directory does not exist to extract to: {fullDestinationDirectoryPath}"+ );+ }+ var totalBytes = archive.TotalUncompressedSize; var bytesRead = 0L; var seenDirectories = new HashSet<string>();@@ -47,16 +76,21 @@ private void WriteToDirectoryInternal( { if (entry.IsDirectory) {- var dirPath = Path.Combine(- destinationDirectory,- entry.Key.NotNull("Entry Key is null")- );- if (- Path.GetDirectoryName(dirPath + "/") is { } parentDirectory- && seenDirectories.Add(dirPath)- )++ var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null"))+ .NotNull("Directory is null");+ var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder));++ if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) {- Directory.CreateDirectory(parentDirectory);+ if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison))+ {+ throw new ExtractionException(+ "Entry is trying to create a directory outside of the destination directory."+ );+ }++ Directory.CreateDirectory(destdir); } continue; }@@ -0,0 +1,118 @@+#if NET8_0_OR_GREATER+using System;+using System.IO;+using System.Threading.Tasks;+using AwesomeAssertions;+using SharpCompress.Archives;+using SharpCompress.Common;+using Xunit;+using SysZip = System.IO.Compression.ZipArchive;+using SysZipMode = System.IO.Compression.ZipArchiveMode;++namespace SharpCompress.Test.Security;++public class ZipSlip: TestBase+{++ [Fact]+public void RunSync()+{+ Console.WriteLine("--- Sync: archive.WriteToDirectory() ---");+ var (extractDir, parentDir) = SetupDirs("sync");+ Directory.CreateDirectory(extractDir);+ var archivePath = Path.Combine(parentDir, "malicious.zip");++ BuildMaliciousZip(archivePath);++ using var archive = ArchiveFactory.OpenArchive(archivePath);++ var ex = Assert.Throws<ExtractionException>(() =>+ archive.WriteToDirectory(extractDir, new ExtractionOptions { ExtractFullPath = true }));+ ex.Message.Should().Contain("Entry is trying to create a directory outside of the destination directory");++ CheckResults(parentDir, extractDir);+}++ [Fact]+public async Task RunAsync()+{+ Console.WriteLine("--- Async: archive.WriteToDirectoryAsync() ---");+ var (extractDir, parentDir) = SetupDirs("async");+ Directory.CreateDirectory(extractDir);+ var archivePath = Path.Combine(parentDir, "malicious.zip");++ BuildMaliciousZip(archivePath);++ var archive = await ArchiveFactory.OpenAsyncArchive(archivePath);+ await using (archive)+ {++ var ex = await Assert.ThrowsAsync<ExtractionException>(async () =>++ await archive.WriteToDirectoryAsync(extractDir,+ new ExtractionOptions+ {+ ExtractFullPath = true+ }));+ ex.Message.Should().Contain("Entry is trying to create a directory outside of the destination directory");+ }++ CheckResults(parentDir, extractDir);+}++// Craft a ZIP with malicious directory entries using System.IO.Compression+// so we bypass any SharpCompress write-side normalisation.+static void BuildMaliciousZip(string path)+{+ using var fs = File.Create(path);+ using var zip = new SysZip(fs, SysZipMode.Create);++ // 1. Relative traversal: two levels up, then "escaped_relative/"+ zip.CreateEntry("../../escaped_relative/");++ // 2. Absolute Unix path (Path.Combine discards the base when second arg is rooted)+ zip.CreateEntry("/tmp/escaped_absolute/");++ // 3. A legitimate entry for contrast+ zip.CreateEntry("safe_subdir/");+}++static (string extractDir, string parentDir) SetupDirs(string label)+{+ var parentDir = Path.Combine(TEST_ARCHIVES_PATH, $"sc_poc_{label}_{Path.GetRandomFileName()}");+ Directory.CreateDirectory(parentDir);+ var extractDir = Path.Combine(parentDir, "extract_target");++ Console.WriteLine($" Parent : {parentDir}");+ Console.WriteLine($" Target : {extractDir}");+ return (extractDir, parentDir);+}AI-assisted change c203ac64689736ef9539fe142627efac7583d88dd4f7ba01150adb283d459cb0 · Fix fe818b1bdf739770221a760b7d79616bcbdaaff04c04900843816cc39251dfb1