Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions IonKiwi.lz4/LZ4RawUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,30 @@ public static unsafe int LZ41_Stream_Decompress(Stream inputStream, Stream outpu
inputStream.Position += 4;

/* Block Size */
//Compressor's enum maxes out at Max4MB (see _blockSize values above). Cap at the same value to
//prevent a hostile footer from triggering a multi-GB allocation per block.
const int MaxBlockSize = 4 * 1024 * 1024;
int blockSize = br.ReadInt32();
if (blockSize <= 0)
if (blockSize <= 0 || blockSize > MaxBlockSize)
throw new Exception($"Invalid block size in LZ41 footer: {blockSize}");

/* Read the offsets tail */
inputStream.Position = initialPosition + (compressedFileSize.Value - 12 - (numOffsets * 4));

//Validate each offset as it's read. Compressor emits strictly-monotonic offsets starting at 4
//(post-header), so out-of-range or non-increasing entries are tampering and would otherwise drive
//arbitrary inputStream.Position seeks (sibling-file disclosure inside a VP archive).
int maxValidOffset = compressedFileSize.Value - 12 - (numOffsets * 4);
int prevOffset = -1;
for(var i=0; i<numOffsets;i++)
{
offsets.Add(br.ReadInt32());
var off = br.ReadInt32();
if (off < 0 || off > maxValidOffset)
throw new Exception($"LZ41: offset table entry out of range at index {i}: {off}");
if (off <= prevOffset)
throw new Exception($"LZ41: offset table not strictly monotonic at index {i}: {off} <= {prevOffset}");
offsets.Add(off);
prevOffset = off;
}

/* The blocks [currentBlock to endBlock] contain the data we want */
Expand All @@ -337,6 +351,9 @@ public static unsafe int LZ41_Stream_Decompress(Stream inputStream, Stream outpu
{
/* The difference in offsets is the size of the block */
int cmpBytes = offsets[currentBlock + 1] - offsets[currentBlock];
//Even with strictly-monotonic in-range offsets, the gap can still exceed cmpBuf's allocation.
if (cmpBytes < 0 || cmpBytes > lz4.LZ4_compressBound(blockSize))
throw new Exception($"LZ41: block {currentBlock} compressed size out of range: {cmpBytes}");
byte[] cmpBuf = new byte[lz4.LZ4_compressBound(blockSize)];
inputStream.Read(cmpBuf,0, cmpBytes);
fixed (byte* cmpPtr = cmpBuf)
Expand Down
136 changes: 135 additions & 1 deletion Knossos.NET/Classes/SevenZipConsoleWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Avalonia.Platform;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -54,7 +56,9 @@ public SevenZipConsoleWrapper(Action<int>? progressCallback = null, Cancellation
}

/// <summary>
/// Decompress a .zip, .7z or .tar.gz file to a folder using the 7zip cmdline tool
/// Decompress a .zip, .7z or .tar.gz file to a folder using the 7zip cmdline tool.
/// Each archive (and the intermediate .tar for .tar.gz inputs) is enumerated first via
/// ListArchiveEntries and any entry that escapes destFolder aborts extraction with no files written.
/// </summary>
/// <param name="sourceFile"></param>
/// <param name="destFolder"></param>
Expand All @@ -66,6 +70,9 @@ public async Task<bool> DecompressFile(string sourceFile, string destFolder, boo
if (disposed)
throw new ObjectDisposedException("This object was already disposed.");

if (!await ValidateArchiveEntries(sourceFile, destFolder))
return false;

bool isTarGz = false;

if (sourceFile.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
Expand All @@ -83,6 +90,11 @@ public async Task<bool> DecompressFile(string sourceFile, string destFolder, boo
if (isTarGz && result)
{
sourceFile = Path.Combine(destFolder, Path.GetFileName(sourceFile).Replace(".tar.gz", ".tar"));
if (!await ValidateArchiveEntries(sourceFile, destFolder))
{
try { File.Delete(sourceFile); } catch { }
return false;
}
if (extractFullPath)
cmdline = "x ";
else
Expand All @@ -99,6 +111,128 @@ public async Task<bool> DecompressFile(string sourceFile, string destFolder, boo
return result;
}

/// <summary>
/// Verifies every entry in the archive (plus any symlink/hardlink targets) resolves inside destFolder.
/// Returns false if any entry escapes, if the archive can't be enumerated, or on subprocess failure.
/// </summary>
private async Task<bool> ValidateArchiveEntries(string archivePath, string destFolder)
{
var entries = await ListArchiveEntries(archivePath);
if (entries == null)
{
Log.Add(Log.LogSeverity.Error, "SevenZipConsoleWrapper.ValidateArchiveEntries", "Refusing to extract: could not enumerate entries of " + archivePath);
return false;
}
foreach (var entry in entries)
{
//Mirror the SharpCompress normalization in KnUtils.DecompressFileSharpCompress so backslashes
//in archive entries are treated as separators on POSIX (where Path.GetFullPath would otherwise
//treat them as literal filename characters).
var normalized = entry.Replace('\\', '/').Replace('/', Path.DirectorySeparatorChar);
if (string.IsNullOrEmpty(normalized) || !KnUtils.IsSubPath(destFolder, normalized))
{
Log.Add(Log.LogSeverity.Error, "SevenZipConsoleWrapper.ValidateArchiveEntries", "Refusing to extract: archive entry escapes destination: " + entry);
return false;
}
}
return true;
}

/// <summary>
/// Enumerates entry paths in an archive without extracting, by running "7za l -slt -sccUTF-8".
/// Returns the list of entry paths plus any Symbolic Link / Hard Link targets. Returns null on
/// subprocess failure (caller should treat as "cannot validate, refuse to extract").
/// </summary>
public async Task<List<string>?> ListArchiveEntries(string archivePath)
{
if (disposed)
throw new ObjectDisposedException("This object was already disposed.");

if (pathToConsoleExecutable == null)
{
Log.Add(Log.LogSeverity.Error, "SevenZipConsoleWrapper.ListArchiveEntries", "7z executable not available");
return null;
}

var stdoutLines = new List<string>();
int exitCode = -1;

try
{
using (var listProcess = new Process())
{
listProcess.StartInfo.FileName = pathToConsoleExecutable;
listProcess.StartInfo.Arguments = "l -slt -sccUTF-8 \"" + archivePath + "\"";
listProcess.StartInfo.UseShellExecute = false;
listProcess.StartInfo.RedirectStandardOutput = true;
listProcess.StartInfo.RedirectStandardError = true;
listProcess.StartInfo.CreateNoWindow = true;
//Pin UTF-8 — without this, Windows OEM codepage decoding can mismatch the bytes 7za writes
//during extraction, allowing a crafted entry to bypass the post-list validation.
listProcess.StartInfo.StandardOutputEncoding = Encoding.UTF8;
listProcess.StartInfo.StandardErrorEncoding = Encoding.UTF8;
listProcess.OutputDataReceived += (s, e) => { if (e.Data != null) stdoutLines.Add(e.Data); };
listProcess.ErrorDataReceived += (s, e) => { if (e.Data != null) Log.Add(Log.LogSeverity.Warning, "SevenZipConsoleWrapper.ListArchiveEntries", e.Data); };
listProcess.Start();
listProcess.BeginOutputReadLine();
listProcess.BeginErrorReadLine();
await listProcess.WaitForExitAsync();
exitCode = listProcess.ExitCode;
}
}
catch (Exception ex)
{
Log.Add(Log.LogSeverity.Error, "SevenZipConsoleWrapper.ListArchiveEntries", ex);
return null;
}

if (exitCode != 0)
{
Log.Add(Log.LogSeverity.Error, "SevenZipConsoleWrapper.ListArchiveEntries", "7z list returned exit code " + exitCode + " for " + archivePath);
return null;
}

//Output format: blocks of "Key = Value" lines separated by blank lines. The first block is the
//archive's own info (Path = <archivePath>, no Size field). Each subsequent block is one entry.
var entries = new List<string>();
var currentBlock = new Dictionary<string, string>();

void FlushBlock()
{
if (currentBlock.Count > 0)
{
//Skip the archive-info block (no Size field) and any header blocks without a Path.
if (currentBlock.ContainsKey("Size") && currentBlock.TryGetValue("Path", out var path) && !string.IsNullOrEmpty(path))
{
entries.Add(path);
if (currentBlock.TryGetValue("Symbolic Link", out var symlink) && !string.IsNullOrEmpty(symlink))
entries.Add(symlink);
if (currentBlock.TryGetValue("Hard Link", out var hardlink) && !string.IsNullOrEmpty(hardlink))
entries.Add(hardlink);
}
currentBlock.Clear();
}
}

foreach (var line in stdoutLines)
{
if (string.IsNullOrEmpty(line))
{
FlushBlock();
continue;
}
var idx = line.IndexOf(" = ", StringComparison.Ordinal);
if (idx <= 0)
continue;
var key = line.Substring(0, idx);
var value = line.Substring(idx + 3);
currentBlock[key] = value;
}
FlushBlock();

return entries;
}

/// <summary>
/// Compress a folder into a .7z file with max LZMA2 compression
/// destFile must be pass with the ".7z" extension.
Expand Down
14 changes: 13 additions & 1 deletion VP.NET/VPCompression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,25 @@ public static int DecompressStream(Stream input, Stream output, CompressionHeade
switch (header)
{
case CompressionHeader.LZ41:
//The 1 MB stack on the worker thread is needed for LZ4's recursive native decode; Task.Run
//can't guarantee it across all platforms. Marshal exceptions across the thread boundary via
//ExceptionDispatchInfo so a corrupted footer doesn't silently leave uncompressedSize = 0.
System.Runtime.ExceptionServices.ExceptionDispatchInfo? edi = null;
var cpThread = new Thread(() =>
{
Thread.CurrentThread.IsBackground = true;
uncompressedSize = LZ4RawUtility.LZ41_Stream_Decompress(input, output, compressedFileSize);
try
{
uncompressedSize = LZ4RawUtility.LZ41_Stream_Decompress(input, output, compressedFileSize);
}
catch (Exception ex)
{
edi = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex);
}
}, 1048576);
cpThread.Start();
cpThread.Join();
edi?.Throw();
break;
}

Expand Down