mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 22:49:53 +08:00
Kernel FS: close guest→host sandbox escapes in the path resolver (#478)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user