Merge pull request #1313 from adamhathcock/adam/zip-slip-fix

This commit is contained in:
Adam Hathcock
2026-05-05 18:11:46 +01:00
committed by GitHub
13 changed files with 759 additions and 296 deletions

View File

@@ -106,8 +106,7 @@ public static class IArchiveEntryExtensions
string destinationDirectory,
ExtractionOptions? options = null
) =>
ExtractionMethods.WriteEntryToDirectory(
entry,
entry.WriteEntryToDirectory(
destinationDirectory,
options,
(path) => entry.WriteToFile(path, options)
@@ -121,9 +120,8 @@ public static class IArchiveEntryExtensions
ExtractionOptions? options = null,
CancellationToken cancellationToken = default
) =>
await ExtractionMethods
await entry
.WriteEntryToDirectoryAsync(
entry,
destinationDirectory,
options,
async (path, ct) =>
@@ -136,8 +134,7 @@ public static class IArchiveEntryExtensions
/// Extract to specific file
/// </summary>
public void WriteToFile(string destinationFileName, ExtractionOptions? options = null) =>
ExtractionMethods.WriteEntryToFile(
entry,
entry.WriteEntryToFile(
destinationFileName,
options,
(x, fm) =>
@@ -155,9 +152,8 @@ public static class IArchiveEntryExtensions
ExtractionOptions? options = null,
CancellationToken cancellationToken = default
) =>
await ExtractionMethods
await entry
.WriteEntryToFileAsync(
entry,
destinationFileName,
options,
async (x, fm, ct) =>

View File

@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using SharpCompress.Common;
using SharpCompress.Readers;
@@ -39,29 +37,27 @@ public static class IArchiveExtensions
IProgress<ProgressReport>? progress
)
{
options ??= new ExtractionOptions();
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
destinationDirectory
);
var totalBytes = archive.TotalUncompressedSize;
var bytesRead = 0L;
var seenDirectories = new HashSet<string>();
foreach (var entry in archive.Entries)
{
if (entry.IsDirectory)
{
var dirPath = Path.Combine(
destinationDirectory,
entry.Key.NotNull("Entry Key is null")
);
if (
Path.GetDirectoryName(dirPath + "/") is { } parentDirectory
&& seenDirectories.Add(dirPath)
)
{
Directory.CreateDirectory(parentDirectory);
}
entry.WriteEntryToDirectoryCore(fullDestinationDirectoryPath, options, null);
continue;
}
entry.WriteToDirectory(destinationDirectory, options);
entry.WriteEntryToDirectoryCore(
fullDestinationDirectoryPath,
options,
path => entry.WriteToFile(path, options)
);
bytesRead += entry.Size;
progress?.Report(

View File

@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common;
@@ -15,7 +13,6 @@ public static class IAsyncArchiveExtensions
/// <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="progress">Optional progress reporter for tracking extraction progress.</param>
@@ -59,9 +56,13 @@ public static class IAsyncArchiveExtensions
CancellationToken cancellationToken
)
{
options ??= new ExtractionOptions();
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
destinationDirectory
);
var totalBytes = await archive.TotalUncompressedSizeAsync().ConfigureAwait(false);
var bytesRead = 0L;
var seenDirectories = new HashSet<string>();
await foreach (var entry in archive.EntriesAsync.WithCancellation(cancellationToken))
{
@@ -69,22 +70,25 @@ public static class IAsyncArchiveExtensions
if (entry.IsDirectory)
{
var dirPath = Path.Combine(
destinationDirectory,
entry.Key.NotNull("Entry Key is null")
);
if (
Path.GetDirectoryName(dirPath + "/") is { } parentDirectory
&& seenDirectories.Add(dirPath)
)
{
Directory.CreateDirectory(parentDirectory);
}
await entry
.WriteEntryToDirectoryAsyncCore(
fullDestinationDirectoryPath,
options,
null,
cancellationToken
)
.ConfigureAwait(false);
continue;
}
await entry
.WriteToDirectoryAsync(destinationDirectory, options, cancellationToken)
.WriteEntryToDirectoryAsyncCore(
fullDestinationDirectoryPath,
options,
async (path, ct) =>
await entry.WriteToFileAsync(path, options, ct).ConfigureAwait(false),
cancellationToken
)
.ConfigureAwait(false);
bytesRead += entry.Size;

View File

@@ -0,0 +1,77 @@
using System.IO;
namespace SharpCompress.Common;
internal static class DirectoryManagement
{
internal const string CreateDirectoryOutsideDestinationMessage =
"Entry is trying to create a directory outside of the destination directory.";
internal const string WriteFileOutsideDestinationMessage =
"Entry is trying to write a file outside of the destination directory.";
internal static string GetFullDestinationDirectoryPath(string destinationDirectory)
{
var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory);
// Keep the trailing separator so prefix checks cannot match sibling directories.
if (
!IsDirectorySeparator(
fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1]
)
)
{
fullDestinationDirectoryPath += Path.DirectorySeparatorChar;
}
if (!Directory.Exists(fullDestinationDirectoryPath))
{
throw new ExtractionException(
$"Directory does not exist to extract to: {fullDestinationDirectoryPath}"
);
}
return fullDestinationDirectoryPath;
}
internal static void EnsurePathInDestinationDirectory(
string destinationPath,
string fullDestinationDirectoryPath,
string exceptionMessage
)
{
if (destinationPath.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison))
{
return;
}
if (
string.Equals(
destinationPath,
TrimTrailingDirectorySeparators(fullDestinationDirectoryPath),
Utility.PathComparison
)
)
{
return;
}
throw new ExtractionException(exceptionMessage);
}
private static bool IsDirectorySeparator(char value) =>
value == Path.DirectorySeparatorChar || value == Path.AltDirectorySeparatorChar;
private static string TrimTrailingDirectorySeparators(string path)
{
var root = Path.GetPathRoot(path);
var rootLength = root?.Length ?? 0;
var end = path.Length;
while (end > rootLength && IsDirectorySeparator(path[end - 1]))
{
end--;
}
return end == path.Length ? path : path.Substring(0, end);
}
}

View File

@@ -1,117 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace SharpCompress.Common;
internal static partial class ExtractionMethods
{
public static async ValueTask WriteEntryToDirectoryAsync(
IEntry entry,
string destinationDirectory,
ExtractionOptions? options,
Func<string, CancellationToken, ValueTask> writeAsync,
CancellationToken cancellationToken = default
)
{
string destinationFileName;
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 file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null");
file = Utility.ReplaceInvalidFileNameChars(file);
if (options.ExtractFullPath)
{
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))
{
if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison))
{
throw new ExtractionException(
"Entry is trying to create a directory outside of the destination directory."
);
}
Directory.CreateDirectory(destdir);
}
destinationFileName = Path.Combine(destdir, file);
}
else
{
destinationFileName = Path.Combine(fullDestinationDirectoryPath, file);
}
if (!entry.IsDirectory)
{
destinationFileName = Path.GetFullPath(destinationFileName);
if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison))
{
throw new ExtractionException(
"Entry is trying to write a file outside of the destination directory."
);
}
await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false);
}
else if (options.ExtractFullPath && !Directory.Exists(destinationFileName))
{
Directory.CreateDirectory(destinationFileName);
}
}
public static async ValueTask WriteEntryToFileAsync(
IEntry entry,
string destinationFileName,
ExtractionOptions? options,
Func<string, FileMode, CancellationToken, ValueTask> openAndWriteAsync,
CancellationToken cancellationToken = default
)
{
options ??= new ExtractionOptions();
if (entry.LinkTarget != null)
{
if (options.SymbolicLinkHandler is not null)
{
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
}
else
{
ExtractionOptions.DefaultSymbolicLinkHandler(destinationFileName, entry.LinkTarget);
}
return;
}
else
{
var fm = FileMode.Create;
if (!options.Overwrite)
{
fm = FileMode.CreateNew;
}
await openAndWriteAsync(destinationFileName, fm, cancellationToken)
.ConfigureAwait(false);
entry.PreserveExtractionOptions(destinationFileName, options);
}
}
}

View File

@@ -1,125 +0,0 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
namespace SharpCompress.Common;
internal static partial class ExtractionMethods
{
/// <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;
/// <summary>
/// Extract to specific directory, retaining filename
/// </summary>
public static void WriteEntryToDirectory(
IEntry entry,
string destinationDirectory,
ExtractionOptions? options,
Action<string> write
)
{
string destinationFileName;
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 file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null");
file = Utility.ReplaceInvalidFileNameChars(file);
if (options.ExtractFullPath)
{
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))
{
if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison))
{
throw new ExtractionException(
"Entry is trying to create a directory outside of the destination directory."
);
}
Directory.CreateDirectory(destdir);
}
destinationFileName = Path.Combine(destdir, file);
}
else
{
destinationFileName = Path.Combine(fullDestinationDirectoryPath, file);
}
if (!entry.IsDirectory)
{
destinationFileName = Path.GetFullPath(destinationFileName);
if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison))
{
throw new ExtractionException(
"Entry is trying to write a file outside of the destination directory."
);
}
write(destinationFileName);
}
else if (options.ExtractFullPath && !Directory.Exists(destinationFileName))
{
Directory.CreateDirectory(destinationFileName);
}
}
public static void WriteEntryToFile(
IEntry entry,
string destinationFileName,
ExtractionOptions? options,
Action<string, FileMode> openAndWrite
)
{
options ??= new ExtractionOptions();
if (entry.LinkTarget != null)
{
if (options.SymbolicLinkHandler is not null)
{
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
}
else
{
ExtractionOptions.DefaultSymbolicLinkHandler(destinationFileName, entry.LinkTarget);
}
return;
}
else
{
var fm = FileMode.Create;
if (!options.Overwrite)
{
fm = FileMode.CreateNew;
}
openAndWrite(destinationFileName, fm);
entry.PreserveExtractionOptions(destinationFileName, options);
}
}
}

View File

@@ -0,0 +1,117 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace SharpCompress.Common;
internal static partial class IEntryExtensions
{
extension(IEntry entry)
{
public async ValueTask WriteEntryToDirectoryAsync(
string destinationDirectory,
ExtractionOptions? options,
Func<string, CancellationToken, ValueTask> writeAsync,
CancellationToken cancellationToken = default
)
{
options ??= new ExtractionOptions();
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
destinationDirectory
);
await WriteEntryToDirectoryAsyncCore(
entry,
fullDestinationDirectoryPath,
options,
writeAsync,
cancellationToken
)
.ConfigureAwait(false);
}
internal async ValueTask WriteEntryToDirectoryAsyncCore(
string fullDestinationDirectoryPath,
ExtractionOptions options,
Func<string, CancellationToken, ValueTask>? writeAsync,
CancellationToken cancellationToken = default
)
{
var destinationFileName = GetEntryDestinationFileName(
entry,
fullDestinationDirectoryPath,
options
);
if (!entry.IsDirectory)
{
destinationFileName = Path.GetFullPath(destinationFileName);
DirectoryManagement.EnsurePathInDestinationDirectory(
destinationFileName,
fullDestinationDirectoryPath,
DirectoryManagement.WriteFileOutsideDestinationMessage
);
if (writeAsync != null)
{
await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false);
}
}
else if (options.ExtractFullPath)
{
destinationFileName = Path.GetFullPath(destinationFileName);
DirectoryManagement.EnsurePathInDestinationDirectory(
destinationFileName,
fullDestinationDirectoryPath,
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
);
if (!Directory.Exists(destinationFileName))
{
Directory.CreateDirectory(destinationFileName);
}
}
}
public async ValueTask WriteEntryToFileAsync(
string destinationFileName,
ExtractionOptions? options,
Func<string, FileMode, CancellationToken, ValueTask> openAndWriteAsync,
CancellationToken cancellationToken = default
)
{
options ??= new ExtractionOptions();
if (entry.LinkTarget != null)
{
if (options.SymbolicLinkHandler is not null)
{
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
}
else
{
ExtractionOptions.DefaultSymbolicLinkHandler(
destinationFileName,
entry.LinkTarget
);
}
return;
}
else
{
var fm = FileMode.Create;
if (!options.Overwrite)
{
fm = FileMode.CreateNew;
}
await openAndWriteAsync(destinationFileName, fm, cancellationToken)
.ConfigureAwait(false);
entry.PreserveExtractionOptions(destinationFileName, options);
}
}
}
}

View File

@@ -0,0 +1,138 @@
using System;
using System.IO;
namespace SharpCompress.Common;
internal static partial class IEntryExtensions
{
extension(IEntry entry)
{
/// <summary>
/// Extract to specific directory, retaining filename
/// </summary>
public void WriteEntryToDirectory(
string destinationDirectory,
ExtractionOptions? options,
Action<string> write
)
{
options ??= new ExtractionOptions();
var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(
destinationDirectory
);
WriteEntryToDirectoryCore(entry, fullDestinationDirectoryPath, options, write);
}
internal void WriteEntryToDirectoryCore(
string fullDestinationDirectoryPath,
ExtractionOptions options,
Action<string>? write
)
{
var destinationFileName = GetEntryDestinationFileName(
entry,
fullDestinationDirectoryPath,
options
);
if (!entry.IsDirectory)
{
destinationFileName = Path.GetFullPath(destinationFileName);
DirectoryManagement.EnsurePathInDestinationDirectory(
destinationFileName,
fullDestinationDirectoryPath,
DirectoryManagement.WriteFileOutsideDestinationMessage
);
write?.Invoke(destinationFileName);
}
else if (options.ExtractFullPath)
{
destinationFileName = Path.GetFullPath(destinationFileName);
DirectoryManagement.EnsurePathInDestinationDirectory(
destinationFileName,
fullDestinationDirectoryPath,
DirectoryManagement.CreateDirectoryOutsideDestinationMessage
);
if (!Directory.Exists(destinationFileName))
{
Directory.CreateDirectory(destinationFileName);
}
}
}
private string GetEntryDestinationFileName(
string fullDestinationDirectoryPath,
ExtractionOptions options
)
{
var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null"))
.NotNull("File is null");
file = Utility.ReplaceInvalidFileNameChars(file);
if (options.ExtractFullPath)
{
var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null"))
.NotNull("Directory is null");
var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder));
DirectoryManagement.EnsurePathInDestinationDirectory(
destdir,
fullDestinationDirectoryPath,
entry.IsDirectory
? DirectoryManagement.CreateDirectoryOutsideDestinationMessage
: DirectoryManagement.WriteFileOutsideDestinationMessage
);
if (!Directory.Exists(destdir))
{
Directory.CreateDirectory(destdir);
}
return Path.Combine(destdir, file);
}
return Path.Combine(fullDestinationDirectoryPath, file);
}
public void WriteEntryToFile(
string destinationFileName,
ExtractionOptions? options,
Action<string, FileMode> openAndWrite
)
{
options ??= new ExtractionOptions();
if (entry.LinkTarget != null)
{
if (options.SymbolicLinkHandler is not null)
{
options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget);
}
else
{
ExtractionOptions.DefaultSymbolicLinkHandler(
destinationFileName,
entry.LinkTarget
);
}
return;
}
else
{
var fm = FileMode.Create;
if (!options.Overwrite)
{
fm = FileMode.CreateNew;
}
openAndWrite(destinationFileName, fm);
entry.PreserveExtractionOptions(destinationFileName, options);
}
}
}
}

View File

@@ -17,9 +17,8 @@ public static class IAsyncReaderExtensions
ExtractionOptions? options = null,
CancellationToken cancellationToken = default
) =>
await ExtractionMethods
.WriteEntryToDirectoryAsync(
reader.Entry,
await reader
.Entry.WriteEntryToDirectoryAsync(
destinationDirectory,
options,
async (path, ct) =>
@@ -36,14 +35,13 @@ public static class IAsyncReaderExtensions
ExtractionOptions? options = null,
CancellationToken cancellationToken = default
) =>
await ExtractionMethods
.WriteEntryToFileAsync(
reader.Entry,
await reader
.Entry.WriteEntryToFileAsync(
destinationFileName,
options,
async (x, fm, ct) =>
{
using var fs = File.Open(destinationFileName, fm);
using var fs = File.Open(x, fm);
await reader.WriteEntryToAsync(fs, ct).ConfigureAwait(false);
},
cancellationToken
@@ -72,14 +70,13 @@ public static class IAsyncReaderExtensions
ExtractionOptions? options = null,
CancellationToken cancellationToken = default
) =>
await ExtractionMethods
.WriteEntryToFileAsync(
reader.Entry,
await reader
.Entry.WriteEntryToFileAsync(
destinationFileName,
options,
async (x, fm, ct) =>
{
using var fs = File.Open(destinationFileName, fm);
using var fs = File.Open(x, fm);
await reader.WriteEntryToAsync(fs, ct).ConfigureAwait(false);
},
cancellationToken

View File

@@ -40,8 +40,7 @@ public static class IReaderExtensions
string destinationDirectory,
ExtractionOptions? options = null
) =>
ExtractionMethods.WriteEntryToDirectory(
reader.Entry,
reader.Entry.WriteEntryToDirectory(
destinationDirectory,
options,
(path) => reader.WriteEntryToFile(path, options)
@@ -54,8 +53,7 @@ public static class IReaderExtensions
string destinationFileName,
ExtractionOptions? options = null
) =>
ExtractionMethods.WriteEntryToFile(
reader.Entry,
reader.Entry.WriteEntryToFile(
destinationFileName,
options,
(x, fm) =>

View File

@@ -3,6 +3,7 @@ using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -13,6 +14,15 @@ namespace SharpCompress;
internal static partial class Utility
{
/// <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>
internal static StringComparison PathComparison =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
public static bool UseSyncOverAsyncDispose()
{
var useSyncOverAsync = false;

View File

@@ -0,0 +1,238 @@
#if NET8_0_OR_GREATER
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using SharpCompress.Archives;
using SharpCompress.Common;
using SharpCompress.Readers;
using Xunit;
using SysZip = System.IO.Compression.ZipArchive;
using SysZipMode = System.IO.Compression.ZipArchiveMode;
namespace SharpCompress.Test.Security;
public class ExtractionPathTraversalTests : TestBase
{
[Theory]
[InlineData("ReaderAll")]
[InlineData("ReaderEntry")]
[InlineData("Archive")]
[InlineData("ArchiveEntry")]
[InlineData("AsyncReaderAll")]
[InlineData("AsyncReaderEntry")]
[InlineData("AsyncArchive")]
[InlineData("AsyncArchiveEntry")]
public async Task DirectoryTraversalToExistingOutsideDirectory_ShouldThrow(string api)
{
var extractDir = Path.Combine(SCRATCH_FILES_PATH, "extract");
Directory.CreateDirectory(extractDir);
var escapedDirectory = Path.GetFullPath(Path.Combine(extractDir, "../../escaped_existing"));
Directory.CreateDirectory(escapedDirectory);
var archivePath = Path.Combine(SCRATCH2_FILES_PATH, $"{api}.zip");
BuildZip(archivePath, "../../escaped_existing/");
var exception = await RecordExtractionExceptionAsync(api, archivePath, extractDir);
var extractionException = Assert.IsType<ExtractionException>(exception);
Assert.Contains("outside of the destination", extractionException.Message);
}
[Theory]
[InlineData("ReaderAll")]
[InlineData("ReaderEntry")]
[InlineData("Archive")]
[InlineData("ArchiveEntry")]
[InlineData("AsyncReaderAll")]
[InlineData("AsyncReaderEntry")]
[InlineData("AsyncArchive")]
[InlineData("AsyncArchiveEntry")]
public async Task FileTraversalToSiblingDirectory_ShouldThrow(string api)
{
var extractDir = Path.Combine(SCRATCH_FILES_PATH, "extract");
Directory.CreateDirectory(extractDir);
var siblingDirectory = Path.Combine(SCRATCH_FILES_PATH, "extract2");
Directory.CreateDirectory(siblingDirectory);
var archivePath = Path.Combine(SCRATCH2_FILES_PATH, $"{api}.zip");
BuildZip(archivePath, "../extract2/evil.txt");
var exception = await RecordExtractionExceptionAsync(api, archivePath, extractDir);
var extractionException = Assert.IsType<ExtractionException>(exception);
Assert.Contains("outside of the destination", extractionException.Message);
Assert.False(File.Exists(Path.Combine(siblingDirectory, "evil.txt")));
}
private static void BuildZip(string path, string entryName)
{
using var fs = File.Create(path);
using var zip = new SysZip(fs, SysZipMode.Create);
var entry = zip.CreateEntry(entryName);
if (entryName.EndsWith('/'))
{
return;
}
using var writer = new StreamWriter(entry.Open());
writer.Write("evil");
}
private static async Task<Exception?> RecordExtractionExceptionAsync(
string api,
string archivePath,
string extractDir
)
{
var options = new ExtractionOptions { ExtractFullPath = true, Overwrite = true };
return api switch
{
"ReaderAll" => RecordException(() =>
ExtractWithReaderAll(archivePath, extractDir, options)
),
"ReaderEntry" => RecordException(() =>
ExtractWithReaderEntry(archivePath, extractDir, options)
),
"Archive" => RecordException(() =>
ExtractWithArchive(archivePath, extractDir, options)
),
"ArchiveEntry" => RecordException(() =>
ExtractWithArchiveEntry(archivePath, extractDir, options)
),
"AsyncReaderAll" => await RecordExceptionAsync(() =>
ExtractWithAsyncReaderAllAsync(archivePath, extractDir, options)
),
"AsyncReaderEntry" => await RecordExceptionAsync(() =>
ExtractWithAsyncReaderEntryAsync(archivePath, extractDir, options)
),
"AsyncArchive" => await RecordExceptionAsync(() =>
ExtractWithAsyncArchiveAsync(archivePath, extractDir, options)
),
"AsyncArchiveEntry" => await RecordExceptionAsync(() =>
ExtractWithAsyncArchiveEntryAsync(archivePath, extractDir, options)
),
_ => throw new ArgumentOutOfRangeException(nameof(api), api, null),
};
}
private static Exception? RecordException(Action action)
{
try
{
action();
return null;
}
catch (Exception exception)
{
return exception;
}
}
private static async Task<Exception?> RecordExceptionAsync(Func<Task> action)
{
try
{
await action();
return null;
}
catch (Exception exception)
{
return exception;
}
}
private static void ExtractWithReaderAll(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
using var stream = File.OpenRead(archivePath);
using var reader = ReaderFactory.OpenReader(stream);
reader.WriteAllToDirectory(extractDir, options);
}
private static void ExtractWithReaderEntry(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
using var stream = File.OpenRead(archivePath);
using var reader = ReaderFactory.OpenReader(stream);
Assert.True(reader.MoveToNextEntry());
reader.WriteEntryToDirectory(extractDir, options);
}
private static void ExtractWithArchive(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
using var archive = ArchiveFactory.OpenArchive(archivePath);
archive.WriteToDirectory(extractDir, options);
}
private static void ExtractWithArchiveEntry(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
using var archive = ArchiveFactory.OpenArchive(archivePath);
archive.Entries.Single().WriteToDirectory(extractDir, options);
}
private static async Task ExtractWithAsyncReaderAllAsync(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
using var stream = File.OpenRead(archivePath);
await using var reader = await ReaderFactory.OpenAsyncReader(stream);
await reader.WriteAllToDirectoryAsync(extractDir, options);
}
private static async Task ExtractWithAsyncReaderEntryAsync(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
using var stream = File.OpenRead(archivePath);
await using var reader = await ReaderFactory.OpenAsyncReader(stream);
Assert.True(await reader.MoveToNextEntryAsync());
await reader.WriteEntryToDirectoryAsync(extractDir, options);
}
private static async Task ExtractWithAsyncArchiveAsync(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath);
await archive.WriteToDirectoryAsync(extractDir, options);
}
private static async Task ExtractWithAsyncArchiveEntryAsync(
string archivePath,
string extractDir,
ExtractionOptions options
)
{
await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath);
await foreach (var entry in archive.EntriesAsync)
{
await entry.WriteToDirectoryAsync(extractDir, options);
return;
}
throw new InvalidOperationException("Archive did not contain an entry.");
}
}
#endif

View File

@@ -0,0 +1,134 @@
#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(archivePath, 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(archivePath, 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/");
}
private (string extractDir, string parentDir) SetupDirs(string label)
{
var parentDir = Path.Combine(
SCRATCH_FILES_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);
}
static void CheckResults(string archivePath, string parentDir, string extractDir)
{
Console.WriteLine(" Directories created after extraction:");
foreach (var d in Directory.GetDirectories(parentDir, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(parentDir, d);
var escaped = !d.StartsWith(extractDir, StringComparison.Ordinal);
Console.WriteLine($" {(escaped ? "[ESCAPED]" : "[ ok ]")} {relative}");
}
// Relative traversal "../../escaped_relative/" escapes two levels above extractDir
// (which is parentDir/extract_target), landing in Path.GetTempPath()
var relTarget = Path.GetFullPath(Path.Combine(extractDir, "../../escaped_relative"));
if (Directory.Exists(relTarget))
{
Console.WriteLine($" [ESCAPED] relative traversal created: {relTarget}");
Directory.Delete(relTarget);
}
File.Delete(archivePath);
if (Directory.Exists(extractDir))
{
Directory.Delete(extractDir);
}
var absTarget = "/tmp/escaped_absolute";
if (Directory.Exists(absTarget))
{
Console.WriteLine($" [ESCAPED] absolute path created: {absTarget}");
Directory.Delete(absTarget);
}
}
}
#endif