Skip to content

Commit 710da3b

Browse files
Solve symlinks destination (#129281)
Evaluate symlinks destination properly
1 parent dfdc914 commit 710da3b

2 files changed

Lines changed: 193 additions & 3 deletions

File tree

src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
370370
string? fileDestinationPath = GetFullDestinationPath(
371371
destinationDirectoryPath,
372372
Path.IsPathFullyQualified(name) ? name : Path.Join(destinationDirectoryPath, name));
373-
if (fileDestinationPath == null)
373+
if (fileDestinationPath is null || FilePathEscapesDirectory(destinationDirectoryPath, fileDestinationPath))
374374
{
375375
throw new IOException(SR.Format(SR.TarExtractingResultsFileOutside, name, destinationDirectoryPath));
376376
}
@@ -391,7 +391,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
391391
string? linkDestination = GetFullDestinationPath(
392392
destinationDirectoryPath,
393393
Path.IsPathFullyQualified(linkName) ? linkName : Path.Join(Path.GetDirectoryName(fileDestinationPath), linkName));
394-
if (linkDestination is null)
394+
if (linkDestination is null || FilePathEscapesDirectory(destinationDirectoryPath, linkDestination))
395395
{
396396
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkName, destinationDirectoryPath));
397397
}
@@ -406,7 +406,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
406406
string? linkDestination = GetFullDestinationPath(
407407
destinationDirectoryPath,
408408
Path.Join(destinationDirectoryPath, linkName));
409-
if (linkDestination is null)
409+
if (linkDestination is null || FilePathEscapesDirectory(destinationDirectoryPath, linkDestination))
410410
{
411411
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkName, destinationDirectoryPath));
412412
}
@@ -417,6 +417,106 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
417417
return (fileDestinationPath, linkTargetPath);
418418
}
419419

420+
// Prevent an archive from escaping the extraction root through symlinks that were created by earlier entries in the same archive.
421+
// This protection applies only to links introduced by the archive itself. It is not intended to defend against preexisting symlinks
422+
// already present on disk before extraction
423+
private static bool FilePathEscapesDirectory(string destinationDirectoryPath, string fileDestinationPath)
424+
{
425+
// Windows is case insensitive while Linux is case sensitive
426+
// This ensures the comparison is consistent with how the OS would resolve the paths
427+
StringComparison pathComparison = OperatingSystem.IsWindows()
428+
? StringComparison.OrdinalIgnoreCase
429+
: StringComparison.Ordinal;
430+
431+
string resolvedDest = ResolvePhysicalPath(destinationDirectoryPath);
432+
433+
// Use the logical destination path for computing the relative path
434+
string logicalDest = Path.GetFullPath(destinationDirectoryPath);
435+
string logicalPrefix = logicalDest.EndsWith(Path.DirectorySeparatorChar)
436+
? logicalDest
437+
: logicalDest + Path.DirectorySeparatorChar;
438+
439+
string destPrefix = resolvedDest.EndsWith(Path.DirectorySeparatorChar)
440+
? resolvedDest
441+
: resolvedDest + Path.DirectorySeparatorChar;
442+
443+
// Normalize file path (resolves .. and . but not symlinks)
444+
string normalizedFile = Path.GetFullPath(fileDestinationPath);
445+
446+
// Guard with StartsWith before computing relative path
447+
if (!normalizedFile.StartsWith(logicalPrefix, pathComparison) &&
448+
!normalizedFile.Equals(logicalDest, pathComparison))
449+
{
450+
return true;
451+
}
452+
453+
// Walk relative components, resolving symlinks at each step
454+
string relative = normalizedFile.Substring(logicalPrefix.Length)
455+
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
456+
457+
string[] components = relative.Split(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
458+
StringSplitOptions.RemoveEmptyEntries);
459+
460+
string current = resolvedDest;
461+
462+
foreach (string component in components)
463+
{
464+
current = Path.Combine(current, component);
465+
current = ResolveSymlink(current);
466+
467+
string normalizedCurrent = Path.GetFullPath(current);
468+
if (!normalizedCurrent.StartsWith(destPrefix, pathComparison) &&
469+
!normalizedCurrent.Equals(resolvedDest, pathComparison))
470+
{
471+
return true;
472+
}
473+
}
474+
475+
return false;
476+
}
477+
478+
private static string ResolveSymlink(string path)
479+
{
480+
var info = new FileInfo(path);
481+
482+
// Check LinkTarget first so dangling symlinks/junctions (whose final target doesn't exist yet)
483+
// are still resolved to their raw target, rather than being treated as a non-link.
484+
if (info.LinkTarget is null)
485+
{
486+
return Path.GetFullPath(path);
487+
}
488+
489+
FileSystemInfo target = info.ResolveLinkTarget(returnFinalTarget: true) ?? info;
490+
return target.FullName;
491+
}
492+
493+
// Resolves the full path of the specified path, resolving symlinks at each step.
494+
// This is needed to mitigate malicious entries in the archive that could lead to writing files outside of the intended directory.
495+
private static string ResolvePhysicalPath(string path)
496+
{
497+
string fullPath = Path.GetFullPath(path);
498+
string? root = Path.GetPathRoot(fullPath);
499+
500+
if (root is null)
501+
{
502+
return fullPath;
503+
}
504+
505+
string[] components = fullPath.Substring(root.Length)
506+
.Split(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
507+
string current = root;
508+
foreach (string component in components)
509+
{
510+
current = Path.Combine(current, component);
511+
if (Path.Exists(current))
512+
{
513+
current = ResolveSymlink(current);
514+
}
515+
}
516+
517+
return current;
518+
}
519+
420520
// Returns the full destination path if the path is the destinationDirectory or a subpath. Otherwise, returns null.
421521
private static string? GetFullDestinationPath(string destinationDirectoryFullPath, string qualifiedPath)
422522
{

src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.IO;
55
using System.Linq;
6+
using System.Text;
67
using Xunit;
78

89
namespace System.Formats.Tar.Tests
@@ -467,5 +468,94 @@ public void HardLinkExtraction_CopyContents()
467468
Assert.Equal("test content", File.ReadAllText(targetFile2));
468469
AssertPathsAreNotHardLinked(targetFile1, targetFile2);
469470
}
471+
472+
[ConditionalFact(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))]
473+
public void ExtractToDirectory_RejectsSymlinkDirectoryTraversal_WithNestedFile()
474+
{
475+
using TempDirectory root = new TempDirectory();
476+
string destDir = Path.Combine(root.Path, "dest");
477+
Directory.CreateDirectory(destDir);
478+
479+
// Absolute path outside destDir
480+
string linkTarget = "/tmp/outside";
481+
482+
string tarPath = Path.Combine(root.Path, "symlink_dir_traversal.tar");
483+
using (FileStream stream = new FileStream(tarPath, FileMode.Create, FileAccess.Write))
484+
using (TarWriter writer = new TarWriter(stream, leaveOpen: false))
485+
{
486+
// symlink: "link" -> "/tmp/outside"
487+
writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link")
488+
{
489+
LinkName = linkTarget
490+
});
491+
492+
// file: "link/test.txt" with "hello"
493+
byte[] content = Encoding.UTF8.GetBytes("hello");
494+
var fileEntry = new PaxTarEntry(TarEntryType.RegularFile, "link/test.txt")
495+
{
496+
DataStream = new MemoryStream(content, writable: false)
497+
};
498+
499+
fileEntry.DataStream.Position = 0;
500+
writer.WriteEntry(fileEntry);
501+
}
502+
503+
Assert.Throws<IOException>(() => TarFile.ExtractToDirectory(tarPath, destDir, overwriteFiles: true));
504+
505+
// Nothing should be created in dest
506+
string linkPath = Path.Combine(destDir, "link");
507+
string outsideFilePath = Path.Combine(destDir, "link", "test.txt");
508+
Assert.False(File.Exists(linkPath) || Directory.Exists(linkPath), "link should not have been created.");
509+
Assert.False(File.Exists(outsideFilePath) || Directory.Exists(outsideFilePath), "traversal link should not have been created.");
510+
}
511+
512+
513+
[ConditionalFact(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))]
514+
public void ExtractToDirectory_RejectsChainedSymlinkDirectoryTraversal_WithNestedFile()
515+
{
516+
// dir a/
517+
// symlink a/b ? .
518+
// symlink a/b/c ? .
519+
// symlink a/b/c/d ? ../../outside
520+
// file a/d/ pwned.txt escapes
521+
522+
using TempDirectory root = new TempDirectory();
523+
string destDir = Path.Combine(root.Path, "dest");
524+
Directory.CreateDirectory(destDir);
525+
526+
string tarPath = Path.Combine(root.Path, "chained_symlink_traversal.tar");
527+
using (FileStream stream = new FileStream(tarPath, FileMode.Create, FileAccess.Write))
528+
using (TarWriter writer = new TarWriter(stream, leaveOpen: false))
529+
{
530+
writer.WriteEntry(new PaxTarEntry(TarEntryType.Directory, "a/"));
531+
532+
writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "a/b") { LinkName = "." });
533+
534+
writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "a/b/c") { LinkName = "." });
535+
536+
writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "a/b/c/d") { LinkName = "../../outside" });
537+
538+
var pwned = new PaxTarEntry(TarEntryType.RegularFile, "a/d/pwned.txt")
539+
{
540+
DataStream = new MemoryStream(Encoding.UTF8.GetBytes("pwned"))
541+
};
542+
writer.WriteEntry(pwned);
543+
}
544+
545+
if (OperatingSystem.IsWindows())
546+
{
547+
// Windows only creates file symlinks and trying to process a directory symlink will throw UnauthorizedAccessException instead of IOException
548+
Assert.Throws<UnauthorizedAccessException>(() => TarFile.ExtractToDirectory(tarPath, destDir, overwriteFiles: true));
549+
}
550+
else
551+
{
552+
Assert.Throws<IOException>(() => TarFile.ExtractToDirectory(tarPath, destDir, overwriteFiles: true));
553+
}
554+
555+
string outsideDir = Path.Combine(root.Path, "outside");
556+
Assert.False(Directory.Exists(outsideDir), "outside/directory should not have been created.");
557+
Assert.False(File.Exists(Path.Combine(outsideDir, "pwned.txt")), "pwned.txt should not have been written outside destination.");
558+
559+
}
470560
}
471561
}

0 commit comments

Comments
 (0)