Files
sharpcompress/tests/SharpCompress.Test/Security/ZipSlip.cs
2026-05-05 17:48:39 +01:00

135 lines
4.6 KiB
C#

#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