From e01092aa381ed85bf275fdfd2b1b6438ed5e581f Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:58:34 +0300 Subject: [PATCH] =?UTF-8?q?Kernel=20FS:=20close=20guest=E2=86=92host=20san?= =?UTF-8?q?dbox=20escapes=20in=20the=20path=20resolver=20(#478)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Kernel FS: default-deny unmapped guest paths (fixes absolute-path host escape) ResolveGuestPath returned any unrecognized guest path verbatim as the host path. Because absolute paths ("/etc/passwd", "C:\Windows\...") are already fully qualified, they skipped the relative-path app0 fallback and were handed straight to FileStream/File.Delete/etc., giving a malicious game arbitrary host-file read/write/delete outside the sandbox. Return string.Empty (deny) on fallthrough instead. Most callers already treat a nonexistent host path as NOT_FOUND; open/truncate/rename get an explicit empty-path guard so a denied path can't reach FileStream and throw an ArgumentException their catch blocks don't cover. Co-Authored-By: Claude Opus 4.8 (1M context) * Kernel FS: contain built-in mounts (fixes Windows drive-letter injection) The built-in mount branches (app0/temp0/download0/hostapp/devlog) combined the mount-relative guest path onto the host root without re-checking containment. NormalizeMountRelativePath clamps ./.. but splits only on separators, so a drive-qualified token like "C:" survives as a segment and Path.Combine then discards the mount root, yielding a raw host path such as "C:\Windows\..." (arbitrary host read/write). Route every built-in branch through a new CombineWithinMount helper that re-resolves with Path.GetFullPath and verifies the result stays under the mount root -- the same guard TryResolveRegisteredGuestMount already applied. Denied paths return string.Empty, which callers treat as unresolved. AprStreamingContractTests passed a raw Path.GetTempFileName() as the guest path, relying on the now-removed absolute-path passthrough; updated it to address the file through a registered mount. Co-Authored-By: Claude Opus 4.8 (1M context) * Kernel FS: reject reparse points inside mounts (fixes symlink escape) Lexical containment (Path.GetFullPath + StartsWith) proves the textual path stays under the mount root but does not follow symlinks/junctions. A malicious game dump could plant a reparse point inside app0/temp0/etc. pointing outside it, so a contained-looking path resolved onto the host filesystem. Walk each existing component from the mount root to the candidate and refuse any reparse point, in both the built-in and registered-mount resolution paths. Mirrors AvPlayer's existing defense. Co-Authored-By: Claude Opus 4.8 (1M context) * Kernel FS: fail closed when path containment cannot be verified The reparse-point and drive-letter containment guards call Path.GetFullPath and File.GetAttributes on untrusted guest paths. Both throw on crafted over-long or invalid-char input, and ResolveGuestPath runs outside the file syscalls' try blocks, so such a path was a guest-triggerable crash rather than a denial. Wrap the GetFullPath calls in CombineWithinMount and the registered-mount path, and widen the GetAttributes catch, to treat any access/format failure as an escape (deny) instead of propagating. Also tighten the ".." fallback check so a legitimate file named "..foo" is not falsely rejected, and hoist the repeated Path.GetFullPath(mountRoot) into a local. Adds a regression test asserting the resolver returns without throwing for an over-long and a NUL-embedded path under a mount. Co-Authored-By: Claude Opus 4.8 (1M context) * Kernel FS: assert malformed paths resolve to empty, not just no-throw The fail-closed regression test asserted only Assert.NotNull, which a non-nullable string return can never violate via its value (only a throw, which aborts the test earlier anyway). Tighten to Assert.Equal(string.Empty) so it also locks in fail-CLOSED: a regression where a malformed path resolved to a non-empty host path would now be caught. Co-Authored-By: Claude Opus 4.8 (1M context) * Kernel FS: route new AMPR batch tests through a registered mount Merging main brought in three AprStreamingContractTests that pass raw Path.GetTempFileName()/temp host paths as guest paths. The default-deny resolver from this branch rejects absolute host paths, so MissingMidBatch failed at index 0 instead of the intended index 1. Address the present file through a registered mount (as ResolveStatAndReadFile already does) so entries 0 and 2 resolve and the batch fails at the genuinely-missing entry. The two all-missing tests were unaffected but share the fix's intent. Co-Authored-By: Claude Opus 4.8 (1M context) * Kernel FS: make a matched-mount denial terminal; fix Unix-only test asserts A registered mount that claims a path by prefix but denies it (failed containment or a reparse point inside the mount) now short-circuits in ResolveGuestPath instead of falling through to the built-in mount branches. The fall-through let an overlapping prefix (a registered "/app0" vs the built-in SHARPEMU_APP0_DIR branch, which resolves against a cached root) re-resolve a denied path and turn the denial back into a resolution -- the reparse-point escape reappeared on Linux CI through exactly this path. Also fix two tests that asserted Windows-specific behavior unconditionally: a "C:\..." path is not absolute on Unix (it resolves contained under the mount there), and that case is now pinned to Windows only. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: slick-daddy Co-authored-by: Claude Opus 4.8 (1M context) --- .../Kernel/KernelFileExtendedExports.cs | 10 + .../Kernel/KernelMemoryCompatExports.cs | 200 +++++++++++++++-- .../Ampr/AprStreamingContractTests.cs | 49 ++++- .../Kernel/KernelSandboxEscapeTests.cs | 206 ++++++++++++++++++ 4 files changed, 438 insertions(+), 27 deletions(-) create mode 100644 tests/SharpEmu.Libs.Tests/Kernel/KernelSandboxEscapeTests.cs diff --git a/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs b/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs index 67c468c5..bbd44c30 100644 --- a/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelFileExtendedExports.cs @@ -267,6 +267,11 @@ public static partial class KernelMemoryCompatExports } var hostPath = ResolveGuestPath(guestPath); + if (string.IsNullOrEmpty(hostPath)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; + } + try { using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); @@ -310,6 +315,11 @@ public static partial class KernelMemoryCompatExports var fromHost = ResolveGuestPath(fromGuest); var toHost = ResolveGuestPath(toGuest); + if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost)) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; + } + try { if (Directory.Exists(fromHost)) diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 444a051f..8be2b007 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -1448,6 +1448,13 @@ public static partial class KernelMemoryCompatExports var hostPath = ResolveGuestPath(guestPath); var access = ResolveOpenAccess(flags); var mode = ResolveOpenMode(flags, access); + // A denied path (empty host path) must not reach FileStream, which would + // throw an ArgumentException the catch below does not cover. + if (string.IsNullOrEmpty(hostPath)) + { + LogOpenTrace($"_open denied path='{guestPath}' flags=0x{flags:X8}"); + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; + } try { if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath)) @@ -4778,21 +4785,32 @@ public static partial class KernelMemoryCompatExports return guestPath; } - if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath)) + if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath, out var mountPrefixMatched)) { return mountedPath; } + // A registered mount claimed this path by prefix but denied it (failed + // containment or a reparse point inside the mount). That denial is + // authoritative: do NOT fall through to the built-in branches below, + // which could re-resolve an overlapping prefix (e.g. a registered + // "/app0" vs the built-in SHARPEMU_APP0_DIR branch) against a different + // root and turn the denial back into a resolution. + if (mountPrefixMatched) + { + return string.Empty; + } + if (guestPath.StartsWith("/devlog/app/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["/devlog/app/".Length..]); - return Path.Combine(ResolveDevlogAppRoot(), relative); + return CombineWithinMount(ResolveDevlogAppRoot(), relative); } if (guestPath.StartsWith("devlog/app/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["devlog/app/".Length..]); - return Path.Combine(ResolveDevlogAppRoot(), relative); + return CombineWithinMount(ResolveDevlogAppRoot(), relative); } if (string.Equals(guestPath, "/devlog/app", StringComparison.OrdinalIgnoreCase) || @@ -4804,7 +4822,7 @@ public static partial class KernelMemoryCompatExports if (guestPath.StartsWith("/temp0/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["/temp0/".Length..]); - return Path.Combine(ResolveTemp0Root(), relative); + return CombineWithinMount(ResolveTemp0Root(), relative); } if (string.Equals(guestPath, "/temp0", StringComparison.OrdinalIgnoreCase)) @@ -4815,13 +4833,13 @@ public static partial class KernelMemoryCompatExports if (guestPath.StartsWith("/download0/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["/download0/".Length..]); - return Path.Combine(ResolveDownload0Root(), relative); + return CombineWithinMount(ResolveDownload0Root(), relative); } if (guestPath.StartsWith("download0/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["download0/".Length..]); - return Path.Combine(ResolveDownload0Root(), relative); + return CombineWithinMount(ResolveDownload0Root(), relative); } if (string.Equals(guestPath, "/download0", StringComparison.OrdinalIgnoreCase) || @@ -4833,13 +4851,13 @@ public static partial class KernelMemoryCompatExports if (guestPath.StartsWith("/hostapp/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["/hostapp/".Length..]); - return Path.Combine(ResolveHostappRoot(), relative); + return CombineWithinMount(ResolveHostappRoot(), relative); } if (guestPath.StartsWith("hostapp/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["hostapp/".Length..]); - return Path.Combine(ResolveHostappRoot(), relative); + return CombineWithinMount(ResolveHostappRoot(), relative); } if (string.Equals(guestPath, "/hostapp", StringComparison.OrdinalIgnoreCase) || @@ -4862,7 +4880,7 @@ public static partial class KernelMemoryCompatExports guestPath.StartsWith("$\\", StringComparison.Ordinal)) { var relative = NormalizeMountRelativePath(guestPath[2..]); - return Path.Combine(app0Root, relative); + return CombineWithinMount(app0Root, relative); } if (string.Equals(guestPath, "/app0", StringComparison.OrdinalIgnoreCase) || @@ -4874,13 +4892,13 @@ public static partial class KernelMemoryCompatExports if (guestPath.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["/app0/".Length..]); - return Path.Combine(app0Root, relative); + return CombineWithinMount(app0Root, relative); } if (guestPath.StartsWith("app0/", StringComparison.OrdinalIgnoreCase)) { var relative = NormalizeMountRelativePath(guestPath["app0/".Length..]); - return Path.Combine(app0Root, relative); + return CombineWithinMount(app0Root, relative); } if (!Path.IsPathFullyQualified(guestPath) && @@ -4888,16 +4906,26 @@ public static partial class KernelMemoryCompatExports !guestPath.StartsWith("\\", StringComparison.Ordinal)) { var relative = NormalizeMountRelativePath(guestPath); - return Path.Combine(app0Root, relative); + return CombineWithinMount(app0Root, relative); } } - return guestPath; + // Default-deny: a guest path that matched no mount prefix must NOT be + // handed back verbatim as a host path. Returning it raw let any absolute + // guest path address the host filesystem directly ("/etc/passwd", + // "C:\\Windows\\...") because it is already fully qualified and skips the + // relative-path app0 fallback above. Callers treat an empty host path as + // "resolves to nothing" and fail the syscall with NOT_FOUND. + return string.Empty; } - private static bool TryResolveRegisteredGuestMount(string guestPath, out string hostPath) + private static bool TryResolveRegisteredGuestMount( + string guestPath, + out string hostPath, + out bool mountPrefixMatched) { hostPath = string.Empty; + mountPrefixMatched = false; var normalizedGuestPath = NormalizeGuestStatCachePath(guestPath); if (normalizedGuestPath is null) { @@ -4925,10 +4953,30 @@ public static partial class KernelMemoryCompatExports return false; } + // A registered mount owns this prefix. Whatever the outcome below + // (containment or reparse denial), the caller must NOT fall through to a + // built-in branch for the same prefix, or a denied path could be + // re-resolved against a different root. + mountPrefixMatched = true; + var relativePath = normalizedGuestPath[matchedMountPoint.Length..].TrimStart('/'); - var candidate = Path.GetFullPath(Path.Combine( - matchedHostRoot, - NormalizeMountRelativePath(relativePath))); + string candidate; + try + { + candidate = Path.GetFullPath(Path.Combine( + matchedHostRoot, + NormalizeMountRelativePath(relativePath))); + } + catch (Exception ex) when ( + ex is IOException or ArgumentException or NotSupportedException) + { + // The relative part comes from an untrusted guest path; a crafted + // over-long or invalid path can make GetFullPath throw. Fail closed + // rather than propagate out of ResolveGuestPath (which callers invoke + // outside their try blocks). + return false; + } + var rootWithSeparator = Path.TrimEndingDirectorySeparator(matchedHostRoot) + Path.DirectorySeparatorChar; // Host-semantics comparison: an ignore-case check on a case-sensitive // host would let a relative path escape into a sibling directory that @@ -4940,6 +4988,14 @@ public static partial class KernelMemoryCompatExports return false; } + // Textual containment does not follow symlinks/junctions; refuse a + // reparse point planted inside the mount that would redirect onto the + // host filesystem. See EscapesMountViaReparsePoint. + if (EscapesMountViaReparsePoint(Path.GetFullPath(matchedHostRoot), candidate)) + { + return false; + } + hostPath = candidate; return true; } @@ -4998,6 +5054,116 @@ public static partial class KernelMemoryCompatExports return string.Join(Path.DirectorySeparatorChar, resolved); } + // Combines a mount-relative guest path onto a built-in mount root and + // re-verifies the result stays under that root. NormalizeMountRelativePath + // strips "." / ".." but splits only on separators, so a drive-qualified + // token like "C:" survives as a segment; Path.Combine then DISCARDS the + // mount root because its second argument is drive-rooted, yielding a raw + // host path such as "C:\Windows\...". Re-resolving with Path.GetFullPath and + // checking containment (the same guard TryResolveRegisteredGuestMount uses) + // rejects that escape. Returns string.Empty on denial, which callers treat + // as an unresolved path. + private static string CombineWithinMount(string mountRoot, string relative) + { + string fullRoot; + string candidate; + try + { + fullRoot = Path.GetFullPath(mountRoot); + candidate = Path.GetFullPath(Path.Combine(fullRoot, relative)); + } + catch (Exception ex) when ( + ex is IOException or ArgumentException or NotSupportedException) + { + // The relative part comes from an untrusted guest path; a crafted + // over-long or invalid path can make GetFullPath throw. Fail closed + // rather than propagate out of ResolveGuestPath (which callers invoke + // outside their try blocks). + return string.Empty; + } + + var rootWithSeparator = + Path.TrimEndingDirectorySeparator(fullRoot) + Path.DirectorySeparatorChar; + if (!string.Equals(candidate, fullRoot, HostFsPathComparison) && + !candidate.StartsWith(rootWithSeparator, HostFsPathComparison)) + { + return string.Empty; + } + + if (EscapesMountViaReparsePoint(fullRoot, candidate)) + { + return string.Empty; + } + + return candidate; + } + + // Lexical containment (Path.GetFullPath + StartsWith) proves the TEXTUAL + // path stays under the mount root, but it does not follow symlinks or + // Windows junctions. A malicious game dump can plant a reparse point inside + // an otherwise-contained mount (e.g. "/app0/link" -> "/") so that + // "/app0/link/etc/passwd" passes the textual check yet resolves onto the + // host filesystem. Walk each already-existing component from the mount root + // down to the candidate and refuse if any is a reparse point. Components + // that do not yet exist (e.g. an O_CREAT target and its parents) carry no + // link to follow and are simply skipped. Mirrors the reparse rejection in + // AvPlayerExports.TryResolveSandboxedFile. + private static bool EscapesMountViaReparsePoint(string mountRoot, string candidate) + { + var rootTrimmed = Path.TrimEndingDirectorySeparator(mountRoot); + if (string.Equals(candidate, rootTrimmed, HostFsPathComparison)) + { + return false; + } + + var relative = Path.GetRelativePath(rootTrimmed, candidate); + // A leading ".." segment means the candidate is not under the root. Match + // the segment precisely: bare ".." or a "../" prefix, NOT a legitimate + // file merely named "..foo". This branch is a defensive fallback (lexical + // containment already passed before it runs), so it fails closed. + if (relative == "." || relative == ".." || Path.IsPathRooted(relative) || + relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) + { + // Not actually under the root (should have been caught lexically); + // treat as an escape rather than walk outside it. + return true; + } + + var current = rootTrimmed; + foreach (var segment in relative.Split( + Path.DirectorySeparatorChar, + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + try + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // Component does not exist yet (create path); nothing to follow. + break; + } + catch (Exception ex) when ( + ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // The path comes from an untrusted dump, so a crafted over-long, + // invalid-char, or unreadable intermediate component can make + // GetAttributes throw. Fail closed: if containment cannot be + // verified, treat it as an escape rather than let the exception + // crash the syscall (ResolveGuestPath runs outside the callers' + // try blocks). + return true; + } + } + + return false; + } + private static string ResolveDevlogAppRoot() { var configuredRoot = Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR"); diff --git a/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs b/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs index 8ef02f2d..cf587f8d 100644 --- a/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs +++ b/tests/SharpEmu.Libs.Tests/Ampr/AprStreamingContractTests.cs @@ -24,14 +24,25 @@ public sealed class AprStreamingContractTests const ulong destinationAddress = memoryBase + 0x2000; const ulong stackAddress = memoryBase + 0x3000; byte[] fileContents = [10, 11, 12, 13, 14, 15, 16, 17]; - var hostPath = Path.GetTempFileName(); + // The kernel FS resolver default-denies raw absolute host paths, so the + // guest addresses the file through a registered mount instead of handing + // in a bare host temp path. + var mountRoot = Path.Combine( + Path.GetTempPath(), + $"sharpemu-apr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(mountRoot); + var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}"; + const string fileName = "asset.bin"; + var hostPath = Path.Combine(mountRoot, fileName); + var guestPath = $"{mountPoint}/{fileName}"; try { File.WriteAllBytes(hostPath, fileContents); + KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot); var memory = new FakeCpuMemory(memoryBase, 0x4000); var context = new CpuContext(memory, Generation.Gen5); - memory.WriteCString(pathAddress, hostPath); + memory.WriteCString(pathAddress, guestPath); WriteUInt64(memory, pathListAddress, pathAddress); context[CpuRegister.Rdi] = pathListAddress; @@ -86,7 +97,11 @@ public sealed class AprStreamingContractTests } finally { - File.Delete(hostPath); + KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint); + if (Directory.Exists(mountRoot)) + { + Directory.Delete(mountRoot, recursive: true); + } } } @@ -156,19 +171,29 @@ public sealed class AprStreamingContractTests const ulong sizesAddress = memoryBase + 0x880; const ulong errorIndexAddress = memoryBase + 0x8F0; byte[] fileContents = [1, 2, 3, 4, 5]; - var hostPath = Path.GetTempFileName(); - var missingHostPath = Path.Combine( + // Entries 0 and 2 must resolve to a real file; the kernel FS resolver + // default-denies raw absolute host paths, so the present file is reached + // through a registered mount. The missing entry stays an unresolvable + // path so the batch fails mid-way at index 1. + var mountRoot = Path.Combine( Path.GetTempPath(), - $"sharpemu-apr-missing-{Guid.NewGuid():N}.bin"); + $"sharpemu-apr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(mountRoot); + var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}"; + const string fileName = "asset.bin"; + var hostPath = Path.Combine(mountRoot, fileName); + var guestPath = $"{mountPoint}/{fileName}"; + var missingGuestPath = $"{mountPoint}/missing-{Guid.NewGuid():N}.bin"; try { File.WriteAllBytes(hostPath, fileContents); + KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot); var memory = new FakeCpuMemory(memoryBase, 0x4000); var context = new CpuContext(memory, Generation.Gen5); - memory.WriteCString(memoryBase + 0x200, hostPath); - memory.WriteCString(memoryBase + 0x400, missingHostPath); - memory.WriteCString(memoryBase + 0x600, hostPath); + memory.WriteCString(memoryBase + 0x200, guestPath); + memory.WriteCString(memoryBase + 0x400, missingGuestPath); + memory.WriteCString(memoryBase + 0x600, guestPath); WriteUInt64(memory, pathListAddress, memoryBase + 0x200); WriteUInt64(memory, pathListAddress + 8, memoryBase + 0x400); WriteUInt64(memory, pathListAddress + 16, memoryBase + 0x600); @@ -192,7 +217,11 @@ public sealed class AprStreamingContractTests } finally { - File.Delete(hostPath); + KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint); + if (Directory.Exists(mountRoot)) + { + Directory.Delete(mountRoot, recursive: true); + } } } diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelSandboxEscapeTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelSandboxEscapeTests.cs new file mode 100644 index 00000000..e406b1e5 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelSandboxEscapeTests.cs @@ -0,0 +1,206 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Kernel; +using Xunit; + +namespace SharpEmu.Libs.Tests.Kernel; + +// The kernel path resolver is the guest->host sandbox boundary: every real +// file syscall (open/stat/unlink/mkdir/rmdir/rename/chmod) maps a guest path +// to a host path through KernelMemoryCompatExports.ResolveGuestPath and then +// hands the result straight to the host filesystem. These tests pin the +// containment guarantees: an unmapped or absolute guest path must resolve to +// nothing (default-deny), and a mount-relative path must never escape its root. +[Collection(KernelMemoryCompatStateCollection.Name)] +public sealed class KernelSandboxEscapeTests : IDisposable +{ + private readonly string? _originalApp0; + private readonly string _tempRoot; + private readonly string _app0Root; + + public KernelSandboxEscapeTests() + { + _originalApp0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); + _tempRoot = Path.Combine( + Path.GetTempPath(), + $"sharpemu-sandbox-{Guid.NewGuid():N}"); + _app0Root = Path.Combine(_tempRoot, "app0"); + Directory.CreateDirectory(_app0Root); + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _app0Root); + + // ResolveApp0Root caches _cachedApp0Root once, so a per-test env var is + // ignored after an earlier test populates it. Registering an explicit + // mount routes /app0 through the updatable mount table instead, which is + // the pattern KernelPathCaseSensitivityTests uses for the same reason. + KernelMemoryCompatExports.RegisterGuestPathMount("/app0", _app0Root); + } + + public void Dispose() + { + KernelMemoryCompatExports.UnregisterGuestPathMount("/app0"); + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _originalApp0); + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + + // Finding #1: an absolute guest path that matches no mount prefix used to be + // returned verbatim, letting the guest open arbitrary host files. These forms + // are absolute (or a UNC/backslash root) on every host, so they are denied + // everywhere. + [Theory] + [InlineData("/etc/passwd")] + [InlineData("/etc/shadow")] + [InlineData("/root/.ssh/id_rsa")] + [InlineData("\\\\server\\share\\secret")] + [InlineData("/proc/self/mem")] + public void ResolveGuestPath_UnmappedAbsolutePathIsDenied(string guestPath) + { + Assert.Equal(string.Empty, KernelMemoryCompatExports.ResolveGuestPath(guestPath)); + } + + // A Windows drive-qualified path (e.g. "C:\Windows\...") is only absolute on + // Windows. On Unix "C:" is an ordinary relative filename, so the resolver + // legitimately contains it under app0 rather than denying it; this escape is + // therefore Windows-specific. + [Fact] + public void ResolveGuestPath_UnmappedWindowsDrivePathIsDenied() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + Assert.Equal( + string.Empty, + KernelMemoryCompatExports.ResolveGuestPath("C:\\Windows\\System32\\drivers\\etc\\hosts")); + } + + // A recognized mount prefix must still resolve to a path under its root, so + // the default-deny does not regress legitimate access. + [Fact] + public void ResolveGuestPath_App0PathResolvesUnderApp0Root() + { + var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/game.bin"); + + Assert.False(string.IsNullOrEmpty(resolved)); + var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved)); + } + + // Drive-letter injection: NormalizeMountRelativePath clamps "." / ".." but + // splits only on separators, so a "C:" token survives as a segment. + // Path.Combine then discards the mount root because the tail is drive-rooted, + // yielding a raw host path. A resolved path outside the mount root must be + // denied. On non-Windows hosts "C:" is an ordinary directory name and stays + // contained, so this specifically pins the Windows escape. + [Theory] + [InlineData("app0/C:/Windows/Temp/evil.dll")] + [InlineData("/app0/C:/Windows/Temp/evil.dll")] + [InlineData("download0/C:/Windows/Temp/evil.dll")] + [InlineData("/temp0/C:/Windows/Temp/evil.dll")] + public void ResolveGuestPath_DriveLetterInjectionCannotEscapeMount(string guestPath) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var resolved = KernelMemoryCompatExports.ResolveGuestPath(guestPath); + + // Either denied outright, or (defensively) still under a SharpEmu mount + // root — never a bare "C:\Windows\..." host path. + if (!string.IsNullOrEmpty(resolved)) + { + Assert.DoesNotContain("Windows", Path.GetFullPath(resolved), StringComparison.OrdinalIgnoreCase); + } + } + + // A "C:"-style token under app0 must resolve inside the app0 root, not to the + // host drive root. + [Fact] + public void ResolveGuestPath_DriveTokenStaysUnderApp0OnNonWindows() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/C:/data.bin"); + + Assert.False(string.IsNullOrEmpty(resolved)); + var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved)); + } + + // Finding #3: lexical containment does not follow symlinks/junctions. A dump + // that plants a reparse point inside app0 pointing outside it must not let a + // guest path through it escape onto the host filesystem. Creating a symlink + // can require privilege (Windows without Developer Mode); skip if it fails. + [Fact] + public void ResolveGuestPath_ReparsePointInsideMountIsDenied() + { + var outsideDir = Path.Combine(_tempRoot, "outside"); + Directory.CreateDirectory(outsideDir); + File.WriteAllBytes(Path.Combine(outsideDir, "secret.bin"), [1, 2, 3]); + + var linkPath = Path.Combine(_app0Root, "link"); + try + { + Directory.CreateSymbolicLink(linkPath, outsideDir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Unprivileged host cannot create the link; nothing to assert. + return; + } + + // Sanity: the link genuinely redirects outside the mount, so a resolver + // that followed it would reach the planted secret. + Assert.True(File.Exists(Path.Combine(linkPath, "secret.bin"))); + + var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/link/secret.bin"); + + Assert.Equal(string.Empty, resolved); + } + + // The reparse defense must not break a legitimate real (non-link) file that + // happens to sit deep under the mount root. + [Fact] + public void ResolveGuestPath_RealNestedFileUnderMountResolves() + { + var nested = Path.Combine(_app0Root, "a", "b"); + Directory.CreateDirectory(nested); + File.WriteAllBytes(Path.Combine(nested, "c.bin"), [9]); + + var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/a/b/c.bin"); + + Assert.False(string.IsNullOrEmpty(resolved)); + var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved)); + } + + // The containment guard resolves paths via Path.GetFullPath / File.GetAttributes, + // which throw on a crafted over-long or invalid path. Because ResolveGuestPath + // runs outside the callers' try blocks, an uncaught throw would crash the + // syscall on untrusted input. The resolver must instead fail closed: return an + // empty host path (which callers map to NOT_FOUND), never throw. + [Theory] + [InlineData("/app0/")] // filled with a long segment below + [InlineData("/app0/bad\0name")] + public void ResolveGuestPath_MalformedPathUnderMountFailsClosed(string prefix) + { + var guestPath = prefix.EndsWith('/') + ? prefix + new string('a', 40_000) + : prefix; + + // Fail closed: the resolver must return an empty host path (never throw, + // never a non-empty resolution). A 40k-char segment exceeds NAME_MAX on + // Linux and a NUL trips GetFullPath on Windows; both must deny. + var resolved = KernelMemoryCompatExports.ResolveGuestPath(guestPath); + + Assert.Equal(string.Empty, resolved); + } +}