Recursive AI

Extract untrusted archives

All onearchive recipes

Extract an archive a user uploaded with OneArchive, and see what its safe defaults refuse: a path that climbs out of the target, and an archive larger than you allow.

upload.tar holds one ordinary file and one entry named ../../home/user/.bashrc, the classic “zip slip”. It was made with Python’s tarfile, as an attacker would:

PYTHON
import tarfile, io
with tarfile.open('upload.tar', 'w') as t:
    for name, data in [('readme.txt', b'hello\n'), ('../../home/user/.bashrc', b'echo pwned\n')]:
        i = tarfile.TarInfo(name); i.size = len(data); t.addfile(i, io.BytesIO(data))
C#
using RecursiveAI.OneArchive;
using RecursiveAI.OneCompress;
 
// upload.tar came from a user: one good entry, and one whose name climbs out of the target.
using (var archive = Archive.Open("upload.tar"))
    foreach (var e in archive.Entries) Console.WriteLine($"entry: {e.Name}");
 
try
{
    Archive.Extract("upload.tar", "out");
}
catch (ArchiveSafetyException e)
{
    Console.WriteLine($"refused: rule {e.Rule}, entry {e.EntryName}");
}
Console.WriteLine($"files in out/: {string.Join(", ", Directory.GetFiles("out", "*", SearchOption.AllDirectories))}");
 
// Opting in to Sanitize drops the ".." components and keeps the file inside the target.
Archive.Extract("upload.tar", "clean", new ArchiveExtractOptions { UnsafePaths = UnsafePathHandling.Sanitize });
Console.WriteLine($"files in clean/: {string.Join(", ", Directory.GetFiles("clean", "*", SearchOption.AllDirectories).Order())}");
 
// Limits: a 1 KiB budget for the whole archive.
Archive.Create("big.tar").Add(new ArchiveEntry { Name = "big.bin", Kind = ArchiveEntryKind.File, Size = 65536 }, new byte[65536]).Save();
try
{
    using var big = Archive.Open("big.tar", new ArchiveOpenOptions { Limits = new ArchiveLimits { MaxTotalSize = 1024 } });
    big.Extract("limited");
}
catch (CompressionLimitException e)
{
    Console.WriteLine($"limit: {e.LimitName}: {e.Message}");
}
Text
entry: readme.txt
entry: ../../home/user/.bashrc
refused: rule path-traversal, entry ../../home/user/.bashrc
files in out/: out/readme.txt
files in clean/: clean/home/user/.bashrc, clean/readme.txt
limit: MaxTotalSize: tar: MaxTotalSize limit exceeded: the input needs 65536 but the limit is 1024

What the defaults refuse

RuleRefused by default
path-traversala name that leaves the target, .. included after \ is read as /
absolute-patha leading / or \, a UNC path, a drive letter
link-escapea symbolic link whose target is absolute or climbs above the target
link-write-throughwriting under a path component that is a symbolic link
hardlink-targeta hard link to anything but a file extracted earlier from the same archive
special-filedevices and FIFOs
existing-filea file that already exists

Each is an ArchiveSafetyException with the rule and the entry name, and each can be loosened one at a time on ArchiveExtractOptions (UnsafePaths, Symlinks, HardLinks, SpecialFiles, Existing).

Good to know

  • Entries before the bad one are already written when it is refused (out/readme.txt here). Extract into a fresh directory and delete it on failure.
  • Limits are on ArchiveOpenOptions.Limits: MaxTotalSize (1 GiB by default), MaxEntryCount (100,000), MaxPathLength, MaxPathDepth, MaxExpansionRatio (1000) and MaxWindowSize. They are checked as each header is read, before any data, and a hit is a CompressionLimitException naming the limit, never a truncated file.
  • setuid, setgid and sticky bits are dropped, and ownership isn’t restored.

All the rules are in archives and safety.