diff --git a/src/PerfView.Tests/Memory/PathUtilitiesTests.cs b/src/PerfView.Tests/Memory/PathUtilitiesTests.cs index 30dbafa6f..f6512f448 100644 --- a/src/PerfView.Tests/Memory/PathUtilitiesTests.cs +++ b/src/PerfView.Tests/Memory/PathUtilitiesTests.cs @@ -130,6 +130,30 @@ public void SanitizeFileName_PreservesValidNames(string input, string expected) { Assert.Equal(expected, PathUtilities.SanitizeFileName(input)); } + + [Theory] + [InlineData("foo.debug")] + [InlineData("My Provider")] + [InlineData("provider.with.dots")] + public void IsSafeFileName_AcceptsNamesThatNeedNoSanitization(string input) + { + Assert.True(PathUtilities.IsSafeFileName(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + [InlineData("../outside")] + [InlineData(@"..\outside")] + [InlineData("with:stream")] + [InlineData("with?wildcard")] + [InlineData("Trailing.")] + [InlineData("NUL.debug")] + public void IsSafeFileName_RejectsNamesThatWouldBeSanitized(string input) + { + Assert.False(PathUtilities.IsSafeFileName(input)); + } } } - diff --git a/src/TraceEvent/Symbols/ElfSymbolModule.cs b/src/TraceEvent/Symbols/ElfSymbolModule.cs index c75a79895..46ba06b87 100644 --- a/src/TraceEvent/Symbols/ElfSymbolModule.cs +++ b/src/TraceEvent/Symbols/ElfSymbolModule.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using System.Text; using System.Threading; +using Microsoft.Diagnostics.Utilities; namespace Microsoft.Diagnostics.Symbols { @@ -251,6 +252,12 @@ internal static string ReadDebugLink(string filePath) // Read all section headers in one bulk read. int shTableSize = hdr.ShCount * hdr.ShEntrySize; + if (!IsValidFileRange(stream, hdr.ShOffset, (ulong)shTableSize)) + { + Debug.WriteLine("ReadDebugLink: Section headers are outside the file."); + return null; + } + byte[] shTable = new byte[shTableSize]; stream.Seek((long)hdr.ShOffset, SeekOrigin.Begin); if (ReadFully(stream, shTable, 0, shTableSize) < shTableSize) @@ -269,6 +276,12 @@ internal static string ReadDebugLink(string filePath) return null; } + if (!IsValidFileRange(stream, shstrOffset, shstrSize)) + { + Debug.WriteLine("ReadDebugLink: shstrtab is outside the file."); + return null; + } + byte[] shstrtab = new byte[(int)shstrSize]; stream.Seek((long)shstrOffset, SeekOrigin.Begin); if (ReadFully(stream, shstrtab, 0, shstrtab.Length) < shstrtab.Length) @@ -303,6 +316,12 @@ internal static string ReadDebugLink(string filePath) return null; } + if (!IsValidFileRange(stream, secOffset, secSize)) + { + Debug.WriteLine("ReadDebugLink: .gnu_debuglink section is outside the file."); + return null; + } + byte[] sectionData = new byte[(int)secSize]; stream.Seek((long)secOffset, SeekOrigin.Begin); if (ReadFully(stream, sectionData, 0, sectionData.Length) < sectionData.Length) @@ -311,15 +330,13 @@ internal static string ReadDebugLink(string filePath) return null; } - // Extract the null-terminated filename. - int nullPos = Array.IndexOf(sectionData, (byte)0); - if (nullPos <= 0) + if (!TryParseDebugLinkSection(sectionData, out string debugLink)) { - Debug.WriteLine("ReadDebugLink: Empty or missing filename in .gnu_debuglink."); + Debug.WriteLine("ReadDebugLink: Invalid .gnu_debuglink section data."); return null; } - return Encoding.UTF8.GetString(sectionData, 0, nullPos); + return debugLink; } Debug.WriteLine("ReadDebugLink: No .gnu_debuglink section found."); @@ -337,6 +354,62 @@ internal static string ReadDebugLink(string filePath) // Name of the .gnu_debuglink section (UTF-8 bytes for fast comparison). private static readonly byte[] GnuDebugLinkName = Encoding.UTF8.GetBytes(".gnu_debuglink"); + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + + private static bool IsValidFileRange(Stream stream, ulong offset, ulong size) + { + ulong streamLength = (ulong)stream.Length; + return offset <= streamLength && size <= streamLength - offset; + } + + private static bool TryParseDebugLinkSection(byte[] sectionData, out string debugLink) + { + debugLink = null; + + // Reserve the final four bytes for the CRC so zero bytes in the CRC cannot be + // mistaken for the filename's required null terminator. + int crcOffset = sectionData.Length - DebugLinkCrcSize; + int nullPos = Array.IndexOf(sectionData, (byte)0, 0, crcOffset); + if (nullPos <= 0) + { + return false; + } + + // The CRC starts at the next 4-byte boundary after the null-terminated filename. + int alignedCrcOffset = (nullPos + 1 + 3) & ~3; + if (alignedCrcOffset != crcOffset) + { + return false; + } + + // GNU requires any bytes between the filename terminator and aligned CRC to be zero. + for (int i = nullPos + 1; i < crcOffset; i++) + { + if (sectionData[i] != 0) + { + return false; + } + } + + // Decode without replacement characters so malformed section bytes fail explicitly. + try + { + debugLink = StrictUtf8.GetString(sectionData, 0, nullPos); + } + catch (DecoderFallbackException) + { + return false; + } + + // The section value is a filename, not a path. Reject it before any path construction. + if (!PathUtilities.IsSafeFileName(debugLink)) + { + debugLink = null; + return false; + } + + return true; + } /// /// Common fields extracted from an ELF header (Ehdr) after validation. @@ -566,6 +639,7 @@ private static bool SectionNameEquals(byte[] strtab, int offset, byte[] expected private const int MaxShstrtabSize = 1024 * 1024; // 1 MB private const int MinDebugLinkSectionSize = 6; // 1-char filename + null + 4-byte CRC private const int MaxDebugLinkSectionSize = 4096; + private const int DebugLinkCrcSize = sizeof(uint); // Symbol table constants. private const byte STT_FUNC = 2; // Symbol type: function. diff --git a/src/TraceEvent/Symbols/SymbolReader.cs b/src/TraceEvent/Symbols/SymbolReader.cs index c12950285..ad17373de 100644 --- a/src/TraceEvent/Symbols/SymbolReader.cs +++ b/src/TraceEvent/Symbols/SymbolReader.cs @@ -452,20 +452,32 @@ public string FindElfSymbolFilePath(string fileName, string buildId, string elfF string binaryIndexPath = $"{simpleFileName}/elf-buildid-{normalizedBuildId}/{simpleFileName}"; string resultPath = null; + string localElfFilePath = null; + if (elfFilePath != null) + { + if (!PathUtilities.TryGetSafeLocalFilePath(elfFilePath, out localElfFilePath)) + { + m_log.WriteLine( + "FindElfSymbolFilePath: Ignoring unsafe ELF file path {0}.", + elfFilePath); + } + } // Phase 1: Check for debug symbol files adjacent to the binary (mirrors PDB local search). // Only look for dedicated debug files here — the binary itself is deferred to Phase 3. - if (elfFilePath != null) + if (localElfFilePath != null) { - string elfDir = Path.GetDirectoryName(elfFilePath); + string elfDir = Path.GetDirectoryName(localElfFilePath); if (!string.IsNullOrEmpty(elfDir)) { - m_log.WriteLine("FindElfSymbolFilePath: Checking relative to ELF binary path {0}", elfFilePath); - string basePath = elfFilePath; + m_log.WriteLine("FindElfSymbolFilePath: Checking relative to ELF binary path {0}", localElfFilePath); + string basePath = localElfFilePath; + string executableFileName = Path.GetFileName(basePath); // Try {path}.debug - string candidate = basePath + ".debug"; - if (ElfBuildIdMatches(candidate, normalizedBuildId)) + string candidate; + if (TryGetDebugLinkCandidate(elfDir, executableFileName + ".debug", out candidate) && + ElfBuildIdMatches(candidate, normalizedBuildId)) { resultPath = candidate; } @@ -473,8 +485,8 @@ public string FindElfSymbolFilePath(string fileName, string buildId, string elfF // Try {path}.dbg if (resultPath == null) { - candidate = basePath + ".dbg"; - if (ElfBuildIdMatches(candidate, normalizedBuildId)) + if (TryGetDebugLinkCandidate(elfDir, executableFileName + ".dbg", out candidate) && + ElfBuildIdMatches(candidate, normalizedBuildId)) { resultPath = candidate; } @@ -497,8 +509,8 @@ public string FindElfSymbolFilePath(string fileName, string buildId, string elfF if (debugLink != null) { // Try {bindir}/{debuglink} - candidate = Path.Combine(elfDir, debugLink); - if (ElfBuildIdMatches(candidate, normalizedBuildId)) + if (TryGetDebugLinkCandidate(elfDir, debugLink, out candidate) && + ElfBuildIdMatches(candidate, normalizedBuildId)) { resultPath = candidate; } @@ -506,8 +518,9 @@ public string FindElfSymbolFilePath(string fileName, string buildId, string elfF // Try {bindir}/.debug/{debuglink} if (resultPath == null) { - candidate = Path.Combine(elfDir, ".debug", debugLink); - if (ElfBuildIdMatches(candidate, normalizedBuildId)) + string debugDirectory = Path.Combine(elfDir, ".debug"); + if (TryGetDebugLinkCandidate(debugDirectory, debugLink, out candidate) && + ElfBuildIdMatches(candidate, normalizedBuildId)) { resultPath = candidate; } @@ -579,11 +592,11 @@ public string FindElfSymbolFilePath(string fileName, string buildId, string elfF // Phase 3: Last resort — try the binary itself (has .dynsym at minimum). // This is deferred until after symbol servers so we prefer proper debug symbols // (.symtab) over the stripped binary whenever a symbol server can provide them. - if (resultPath == null && elfFilePath != null) + if (resultPath == null && localElfFilePath != null) { - if (ElfBuildIdMatches(elfFilePath, normalizedBuildId)) + if (ElfBuildIdMatches(localElfFilePath, normalizedBuildId)) { - resultPath = elfFilePath; + resultPath = localElfFilePath; } } @@ -606,6 +619,48 @@ public string FindElfSymbolFilePath(string fileName, string buildId, string elfF return resultPath; } + /// + /// Constructs a debug-link candidate that is a direct child of the intended search directory. + /// + internal static bool TryGetDebugLinkCandidate(string searchDirectory, string debugLink, out string candidate) + { + candidate = null; + if (string.IsNullOrEmpty(searchDirectory) || + !PathUtilities.IsSafeFileName(debugLink)) + { + return false; + } + + try + { + string canonicalSearchDirectory = Path.GetFullPath(searchDirectory); + string canonicalCandidate = Path.GetFullPath(Path.Combine(canonicalSearchDirectory, debugLink)); + if (!PathUtilities.IsPathWithinDirectory(canonicalCandidate, canonicalSearchDirectory)) + { + return false; + } + + candidate = canonicalCandidate; + return true; + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + catch (System.Security.SecurityException) + { + return false; + } + } + // Find an executable file path (not a PDB) based on information about the file image. /// /// This API looks up an executable file, by its build-timestamp and size (on a symbol server), 'fileName' should be @@ -2850,4 +2905,3 @@ private enum LineEnding #endregion } } - diff --git a/src/TraceEvent/TraceEvent.Tests/Symbols/ElfBuilder.cs b/src/TraceEvent/TraceEvent.Tests/Symbols/ElfBuilder.cs index e715f173c..e7506c07f 100644 --- a/src/TraceEvent/TraceEvent.Tests/Symbols/ElfBuilder.cs +++ b/src/TraceEvent/TraceEvent.Tests/Symbols/ElfBuilder.cs @@ -17,6 +17,9 @@ internal class ElfBuilder private ulong m_pOffset = 0; private byte[] m_buildId = null; private string m_debugLink = null; + private byte[] m_debugLinkSectionData = null; + private ulong? m_debugLinkSectionOffset = null; + private ulong? m_debugLinkSectionSize = null; private readonly List m_symtabSymbols = new List(); private readonly List m_dynsymSymbols = new List(); @@ -83,6 +86,27 @@ public ElfBuilder SetBuildId(byte[] buildId) public ElfBuilder SetDebugLink(string filename) { m_debugLink = filename; + m_debugLinkSectionData = null; + return this; + } + + /// + /// Sets the raw .gnu_debuglink section data for malformed-section tests. + /// + public ElfBuilder SetDebugLinkSectionData(byte[] sectionData) + { + m_debugLink = null; + m_debugLinkSectionData = sectionData; + return this; + } + + /// + /// Overrides the .gnu_debuglink section header bounds for malformed-section tests. + /// + public ElfBuilder SetDebugLinkSectionBounds(ulong offset, ulong size) + { + m_debugLinkSectionOffset = offset; + m_debugLinkSectionSize = size; return this; } @@ -150,7 +174,7 @@ public byte[] Build() // [N+1] .shstrtab — only if debuglink is set (needed for section names) bool hasDynsym = m_dynsymSymbols.Count > 0; bool hasBuildId = m_buildId != null; - bool hasDebugLink = m_debugLink != null; + bool hasDebugLink = m_debugLink != null || m_debugLinkSectionData != null; int sectionCount = hasDynsym ? 5 : 3; if (hasDebugLink) { @@ -193,7 +217,7 @@ public byte[] Build() int shstrtabSectionIndex = 0; if (hasDebugLink) { - debugLinkData = BuildDebugLinkSection(m_debugLink); + debugLinkData = m_debugLinkSectionData ?? BuildDebugLinkSection(m_debugLink); debugLinkSectionIndex = hasDynsym ? 5 : 3; shstrtabSectionIndex = debugLinkSectionIndex + 1; @@ -310,7 +334,8 @@ public byte[] Build() { // .gnu_debuglink (SHT_PROGBITS) WriteSectionHeader(writer, (uint)debugLinkShName, SHT_PROGBITS, - (ulong)debugLinkOffset, (ulong)debugLinkData.Length, 0, 0); + m_debugLinkSectionOffset ?? (ulong)debugLinkOffset, + m_debugLinkSectionSize ?? (ulong)debugLinkData.Length, 0, 0); // .shstrtab (SHT_STRTAB) WriteSectionHeader(writer, (uint)shstrtabShName, SHT_STRTAB, diff --git a/src/TraceEvent/TraceEvent.Tests/Symbols/ElfSymbolModuleTests.cs b/src/TraceEvent/TraceEvent.Tests/Symbols/ElfSymbolModuleTests.cs index 2e8e527be..4b4751f23 100644 --- a/src/TraceEvent/TraceEvent.Tests/Symbols/ElfSymbolModuleTests.cs +++ b/src/TraceEvent/TraceEvent.Tests/Symbols/ElfSymbolModuleTests.cs @@ -824,16 +824,133 @@ public void ReadDebugLink_WithDebugLink_ReturnsFilename() var builder = new ElfBuilder() .Set64Bit(true) .SetPTLoad(0x400000, 0) - .SetDebugLink("libcoreclr.so.dbg"); + .SetDebugLink("foo.debug"); byte[] data = builder.Build(); RunWithTempFile(data, (path) => { string result = ElfSymbolModule.ReadDebugLink(path); - Assert.Equal("libcoreclr.so.dbg", result); + Assert.Equal("foo.debug", result); }); } + [Theory] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + [InlineData("dir/foo.debug")] + [InlineData(@"dir\foo.debug")] + [InlineData("../foo.debug")] + [InlineData(@"..\foo.debug")] + [InlineData("/foo.debug")] + [InlineData(@"\foo.debug")] + [InlineData(@"C:\foo.debug")] + [InlineData("C:foo.debug")] + [InlineData(@"\\server\share\foo.debug")] + [InlineData(@"\\?\C:\foo.debug")] + [InlineData(@"\\.\C:\foo.debug")] + [InlineData("foo.debug:stream")] + [InlineData("foo?.debug")] + [InlineData("foo.debug.")] + [InlineData("foo.debug ")] + [InlineData("CON")] + [InlineData("NUL.debug")] + [InlineData("COM0")] + [InlineData("CLOCK$.debug")] + [InlineData("foo\u007f.debug")] + public void ReadDebugLink_InvalidFileName_ReturnsNull(string debugLink) + { + byte[] data = new ElfBuilder() + .Set64Bit(true) + .SetPTLoad(0x400000, 0) + .SetDebugLink(debugLink) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_MissingNullTerminator_ReturnsNull() + { + byte[] sectionData = new byte[] + { + (byte)'f', (byte)'o', (byte)'o', (byte)'.', (byte)'d', + (byte)'e', (byte)'b', (byte)'u', (byte)'g', + 0, 0, 0, 0, + }; + byte[] data = new ElfBuilder() + .SetDebugLinkSectionData(sectionData) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_TruncatedCrc_ReturnsNull() + { + byte[] sectionData = new byte[] { (byte)'a', 0, 0, 0, 1, 2, 3 }; + byte[] data = new ElfBuilder() + .SetDebugLinkSectionData(sectionData) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_NonZeroPadding_ReturnsNull() + { + byte[] sectionData = new byte[] { (byte)'a', 0, 1, 0, 1, 2, 3, 4 }; + byte[] data = new ElfBuilder() + .SetDebugLinkSectionData(sectionData) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_MalformedUtf8_ReturnsNull() + { + byte[] sectionData = new byte[] { 0xc3, 0x28, 0, 0, 1, 2, 3, 4 }; + byte[] data = new ElfBuilder() + .SetDebugLinkSectionData(sectionData) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_NonZeroCrc_ReturnsFilename() + { + byte[] sectionData = new byte[] { (byte)'a', 0, 0, 0, 1, 2, 3, 4 }; + byte[] data = new ElfBuilder() + .SetDebugLinkSectionData(sectionData) + .Build(); + + RunWithTempFile(data, path => Assert.Equal("a", ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_SectionOutsideFile_ReturnsNull() + { + byte[] data = new ElfBuilder() + .SetDebugLink("foo.debug") + .SetDebugLinkSectionBounds(ulong.MaxValue, 16) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + + [Fact] + public void ReadDebugLink_SectionExtendsPastFile_ReturnsNull() + { + byte[] data = new ElfBuilder() + .SetDebugLink("foo.debug") + .SetDebugLinkSectionBounds(64, 4096) + .Build(); + + RunWithTempFile(data, path => Assert.Null(ElfSymbolModule.ReadDebugLink(path))); + } + [Fact] public void ReadDebugLink_WithDebugLinkElf32_ReturnsFilename() { diff --git a/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs b/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs index 64b6165ba..b252f9e67 100644 --- a/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs +++ b/src/TraceEvent/TraceEvent.Tests/Symbols/SymbolReaderTests.cs @@ -11,6 +11,7 @@ using System.IO.Compression; using System.Net; using System.Net.Http; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -1054,6 +1055,152 @@ public void FindElfSymbolFilePath_DebugLinkInSubdir() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TryGetDebugLinkCandidate_ValidSearchLocation_ReturnsContainedPath(bool useDebugSubdirectory) + { + string executableDirectory = Path.GetFullPath(Path.Combine(OutputDir, "elf-debuglink-candidate")); + string searchDirectory = useDebugSubdirectory + ? Path.Combine(executableDirectory, ".debug") + : executableDirectory; + + Assert.True(SymbolReader.TryGetDebugLinkCandidate(searchDirectory, "foo.debug", out string candidate)); + Assert.Equal("foo.debug", Path.GetFileName(candidate)); + + StringComparer comparer = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + Assert.Equal(Path.GetFullPath(searchDirectory), Path.GetDirectoryName(candidate), comparer); + } + + [Theory] + [InlineData("../foo.debug")] + [InlineData(@"..\foo.debug")] + [InlineData("/foo.debug")] + [InlineData(@"C:\foo.debug")] + [InlineData(@"\\server\share\foo.debug")] + [InlineData(@"\\?\C:\foo.debug")] + [InlineData("foo.debug:stream")] + public void TryGetDebugLinkCandidate_InvalidFileName_ReturnsFalse(string debugLink) + { + Assert.False(SymbolReader.TryGetDebugLinkCandidate(OutputDir, debugLink, out string candidate)); + Assert.Null(candidate); + } + + [Theory] + [InlineData(".debug")] + [InlineData(".dbg")] + public void FindElfSymbolFilePath_ExecutableSuffixCandidateRemainsAdjacent(string suffix) + { + string tempDir = Path.Combine(OutputDir, "elf-adjacent-suffix-" + suffix.Substring(1)); + try + { + const string buildId = "66778899"; + Directory.CreateDirectory(tempDir); + string binaryPath = Path.Combine(tempDir, "libsuffix.so"); + File.WriteAllBytes(binaryPath, new ElfBuilder().Build()); + + string debugPath = binaryPath + suffix; + File.WriteAllBytes(debugPath, CreateMinimalElfWithBuildId(buildId)); + + string emptySymbolDirectory = Path.Combine(tempDir, "empty"); + Directory.CreateDirectory(emptySymbolDirectory); + _symbolReader.SymbolPath = emptySymbolDirectory; + _symbolReader.SecurityCheck = _ => true; + + string result = _symbolReader.FindElfSymbolFilePath( + "libsuffix.so", + buildId, + elfFilePath: binaryPath); + + Assert.Equal(debugPath, result); + Assert.Equal( + Path.GetFullPath(tempDir), + Path.GetDirectoryName(Path.GetFullPath(result))); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Theory] + [InlineData("../relative/libunsafe.so")] + [InlineData(@"C:relative\libunsafe.so")] + [InlineData(@"\\server\share\libunsafe.so")] + [InlineData(@"\\?\C:\libunsafe.so")] + [InlineData(@"\\.\C:\libunsafe.so")] + public void FindElfSymbolFilePath_UnsafeExecutablePathCausesNoAccess(string executablePath) + { + string emptySymbolDirectory = Path.Combine(OutputDir, "elf-unsafe-path-empty"); + Directory.CreateDirectory(emptySymbolDirectory); + var securityChecks = new List(); + _symbolReader.SymbolPath = emptySymbolDirectory; + _symbolReader.SecurityCheck = path => + { + securityChecks.Add(path); + return true; + }; + + string result = _symbolReader.FindElfSymbolFilePath( + "libunsafe.so", + "1234abcd", + elfFilePath: executablePath); + + Assert.Null(result); + Assert.Empty(securityChecks); + Assert.Empty(_handler.Requests); + } + + [Fact] + public void FindElfSymbolFilePath_RejectedDebugLinkDoesNotProbeOutsideDirectory() + { + string tempDir = Path.Combine(OutputDir, "elf-debuglink-rejected"); + try + { + const string buildId = "1122aabb"; + string binaryDirectory = Path.Combine(tempDir, "bin"); + string emptySymbolDirectory = Path.Combine(tempDir, "empty"); + Directory.CreateDirectory(binaryDirectory); + Directory.CreateDirectory(emptySymbolDirectory); + + string binaryPath = Path.Combine(binaryDirectory, "libunsafe.so"); + byte[] binaryData = new ElfBuilder() + .Set64Bit(true) + .SetPTLoad(0x400000, 0) + .SetDebugLink("../outside.debug") + .Build(); + File.WriteAllBytes(binaryPath, binaryData); + + string outsidePath = Path.Combine(tempDir, "outside.debug"); + File.WriteAllBytes(outsidePath, CreateMinimalElfWithBuildId(buildId)); + + var securityChecks = new List(); + _symbolReader.SymbolPath = emptySymbolDirectory; + _symbolReader.SecurityCheck = path => + { + securityChecks.Add(Path.GetFullPath(path)); + return true; + }; + + string result = _symbolReader.FindElfSymbolFilePath( + "libunsafe.so", + buildId, + elfFilePath: binaryPath); + + Assert.Null(result); + Assert.DoesNotContain(Path.GetFullPath(outsidePath), securityChecks); + Assert.Empty(_handler.Requests); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + #endregion #region FindR2RPerfMapSymbolFilePath Tests diff --git a/src/Utilities/PathUtilities.cs b/src/Utilities/PathUtilities.cs index f7a59455d..170b906fd 100644 --- a/src/Utilities/PathUtilities.cs +++ b/src/Utilities/PathUtilities.cs @@ -112,17 +112,137 @@ public static bool IsPathWithinDirectory(string filePath, string directoryPath) return normalizedFilePath.StartsWith(normalizedDirectory, comparison); } + /// + /// Returns true if can be used unchanged as one file-name + /// component on Windows and POSIX systems. + /// + public static bool IsSafeFileName(string name) + { + return string.Equals(name, SanitizeFileName(name), StringComparison.Ordinal); + } + + /// + /// Converts an absolute Windows drive path or POSIX path into safe directory + /// components, excluding the final file name. UNC, device, relative, traversal, + /// and otherwise unsafe paths are rejected. + /// + private static bool TryGetAbsoluteFileDirectorySegments(string filePath, out string[] directorySegments) + { + directorySegments = null; + if (string.IsNullOrEmpty(filePath)) + { + return false; + } + + var segments = new List(); + int segmentStart; + bool isWindowsPath = IsWindowsDriveAbsolutePath(filePath); + if (isWindowsPath) + { + segments.Add(char.ToUpperInvariant(filePath[0]).ToString()); + segmentStart = 3; + } + else if (filePath[0] == '/' && + (filePath.Length == 1 || filePath[1] != '/') && + filePath.IndexOf('\\') < 0) + { + segmentStart = 1; + } + else + { + return false; + } + + int currentStart = segmentStart; + for (int i = segmentStart; i <= filePath.Length; i++) + { + bool atEnd = i == filePath.Length; + bool atSeparator = !atEnd && + (filePath[i] == '/' || (isWindowsPath && filePath[i] == '\\')); + if (!atEnd && !atSeparator) + { + continue; + } + + if (i == currentStart) + { + return false; + } + + string segment = filePath.Substring(currentStart, i - currentStart); + if (!IsSafeFileName(segment)) + { + return false; + } + + segments.Add(segment); + currentStart = i + 1; + } + + if (segments.Count == 0) + { + return false; + } + + segments.RemoveAt(segments.Count - 1); + directorySegments = segments.ToArray(); + return true; + } + + /// + /// Canonicalizes an absolute file path that uses the current platform's path + /// syntax and is safe for local filesystem access. + /// + public static bool TryGetSafeLocalFilePath(string filePath, out string safeFilePath) + { + safeFilePath = null; + if (!TryGetAbsoluteFileDirectorySegments(filePath, out _)) + { + return false; + } + + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + bool isWindowsDrivePath = IsWindowsDriveAbsolutePath(filePath); + if ((isWindows && !isWindowsDrivePath) || + (!isWindows && isWindowsDrivePath)) + { + return false; + } + + try + { + safeFilePath = Path.GetFullPath(filePath); + return true; + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + catch (System.Security.SecurityException) + { + return false; + } + } + /// /// Reduces to a value safe to embed in a single file-name /// component on disk. Every character reported by - /// , every path / volume separator, - /// and every control character is replaced with '_'. Trailing '.' and ' ' - /// characters are removed because Windows silently trims them, which would - /// otherwise let two distinct names collide on disk and let inputs like "NUL." - /// slip past the reserved-name guard. Reserved DOS device names (CON, PRN, - /// AUX, NUL, CLOCK$, CONIN$, CONOUT$, COM0-9, LPT0-9) are detected on the - /// stem before the first '.' (Win32 opens the device for paths like - /// "NUL.txt") and prefixed with '_' on match. + /// , every Windows file-name + /// metacharacter, every path / volume separator, and every control character is + /// replaced with '_'. Trailing '.' and ' ' characters are removed because + /// Windows silently trims them, which would otherwise let two distinct names + /// collide on disk and let inputs like "NUL." slip past the reserved-name guard. + /// Reserved DOS device names (CON, PRN, AUX, NUL, CLOCK$, CONIN$, CONOUT$, + /// COM0-9, LPT0-9) are detected on the stem before the first '.' (Win32 opens + /// the device for paths like "NUL.txt") and prefixed with '_' on match. /// /// Returns null if the input is null, empty, '.', '..', or sanitizes /// to an empty string so callers can choose to skip the resource rather than @@ -181,6 +301,12 @@ private static HashSet BuildInvalidFileNameChars() chars.Add('\\'); chars.Add('/'); chars.Add(':'); + chars.Add('<'); + chars.Add('>'); + chars.Add('"'); + chars.Add('|'); + chars.Add('?'); + chars.Add('*'); return chars; } @@ -197,5 +323,13 @@ private static HashSet BuildReservedDosDeviceNames() } return names; } + + private static bool IsWindowsDriveAbsolutePath(string path) + { + return path.Length >= 3 && + ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && + path[1] == ':' && + (path[2] == '\\' || path[2] == '/'); + } } }