#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(() => 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(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