Files
sharpemu/tests/SharpEmu.Libs.Tests/Kernel/KernelSandboxEscapeTests.cs
T
Slick Daddy e01092aa38 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>
2026-07-21 00:58:34 +03:00

207 lines
8.6 KiB
C#

// 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);
}
}