mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 22:49:53 +08:00
e01092aa38
* 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>
256 lines
11 KiB
C#
256 lines
11 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using SharpEmu.HLE;
|
|
using SharpEmu.Libs.Ampr;
|
|
using SharpEmu.Libs.Kernel;
|
|
using System.Buffers.Binary;
|
|
using Xunit;
|
|
|
|
namespace SharpEmu.Libs.Tests.Ampr;
|
|
|
|
public sealed class AprStreamingContractTests
|
|
{
|
|
[Fact]
|
|
public void ResolveStatAndReadFile_UsesSharedAprFileId()
|
|
{
|
|
const ulong memoryBase = 0x1_0000_0000;
|
|
const ulong pathListAddress = memoryBase + 0x100;
|
|
const ulong pathAddress = memoryBase + 0x200;
|
|
const ulong idsAddress = memoryBase + 0x800;
|
|
const ulong statAddress = memoryBase + 0x900;
|
|
const ulong commandBufferAddress = memoryBase + 0x1000;
|
|
const ulong recordBufferAddress = memoryBase + 0x1100;
|
|
const ulong destinationAddress = memoryBase + 0x2000;
|
|
const ulong stackAddress = memoryBase + 0x3000;
|
|
byte[] fileContents = [10, 11, 12, 13, 14, 15, 16, 17];
|
|
// 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, guestPath);
|
|
WriteUInt64(memory, pathListAddress, pathAddress);
|
|
|
|
context[CpuRegister.Rdi] = pathListAddress;
|
|
context[CpuRegister.Rsi] = 1;
|
|
context[CpuRegister.Rdx] = idsAddress;
|
|
|
|
Assert.Equal(0, KernelMemoryCompatExports.KernelAprResolveFilepathsToIds(context));
|
|
|
|
Span<byte> idBytes = stackalloc byte[sizeof(uint)];
|
|
Assert.True(memory.TryRead(idsAddress, idBytes));
|
|
var fileId = BinaryPrimitives.ReadUInt32LittleEndian(idBytes);
|
|
Assert.NotEqual(uint.MaxValue, fileId);
|
|
|
|
context[CpuRegister.Rdi] = fileId;
|
|
context[CpuRegister.Rsi] = statAddress;
|
|
|
|
Assert.Equal(0, KernelMemoryCompatExports.KernelAprGetFileStat(context));
|
|
|
|
Span<byte> stat = stackalloc byte[120];
|
|
Assert.True(memory.TryRead(statAddress, stat));
|
|
Assert.Equal(fileContents.Length, BinaryPrimitives.ReadInt64LittleEndian(stat[72..]));
|
|
|
|
context[CpuRegister.Rdi] = commandBufferAddress;
|
|
context[CpuRegister.Rsi] = recordBufferAddress;
|
|
context[CpuRegister.Rdx] = 0x100;
|
|
|
|
Assert.Equal(0, AmprExports.CommandBufferConstructor(context));
|
|
|
|
const ulong readOffset = 2;
|
|
const ulong readSize = 4;
|
|
WriteUInt64(memory, stackAddress + sizeof(ulong), readOffset);
|
|
context[CpuRegister.Rsp] = stackAddress;
|
|
context[CpuRegister.Rdi] = commandBufferAddress;
|
|
context[CpuRegister.Rcx] = fileId;
|
|
context[CpuRegister.R8] = destinationAddress;
|
|
context[CpuRegister.R9] = readSize;
|
|
|
|
Assert.Equal(0, AmprExports.AprCommandBufferReadFile(context));
|
|
|
|
Span<byte> destination = stackalloc byte[(int)readSize];
|
|
Assert.True(memory.TryRead(destinationAddress, destination));
|
|
Assert.Equal(fileContents.AsSpan((int)readOffset, (int)readSize), destination);
|
|
|
|
Span<byte> record = stackalloc byte[0x30];
|
|
Assert.True(memory.TryRead(recordBufferAddress, record));
|
|
Assert.Equal(1U, BinaryPrimitives.ReadUInt32LittleEndian(record));
|
|
Assert.Equal(fileId, BinaryPrimitives.ReadUInt32LittleEndian(record[0x04..]));
|
|
Assert.Equal(destinationAddress, BinaryPrimitives.ReadUInt64LittleEndian(record[0x08..]));
|
|
Assert.Equal(readSize, BinaryPrimitives.ReadUInt64LittleEndian(record[0x10..]));
|
|
Assert.Equal(readOffset, BinaryPrimitives.ReadUInt64LittleEndian(record[0x18..]));
|
|
Assert.Equal(readSize, BinaryPrimitives.ReadUInt64LittleEndian(record[0x20..]));
|
|
}
|
|
finally
|
|
{
|
|
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
|
if (Directory.Exists(mountRoot))
|
|
{
|
|
Directory.Delete(mountRoot, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveFilepathsToIdsAndFileSizes_MissingFile_FailsFastWithErrorIndex()
|
|
{
|
|
const ulong memoryBase = 0x1_0000_0000;
|
|
const ulong pathListAddress = memoryBase + 0x100;
|
|
const ulong pathAddress = memoryBase + 0x200;
|
|
const ulong idsAddress = memoryBase + 0x800;
|
|
const ulong sizesAddress = memoryBase + 0x880;
|
|
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
|
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
|
var context = new CpuContext(memory, Generation.Gen5);
|
|
var missingHostPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
|
memory.WriteCString(pathAddress, missingHostPath);
|
|
WriteUInt64(memory, pathListAddress, pathAddress);
|
|
|
|
context[CpuRegister.Rdi] = pathListAddress;
|
|
context[CpuRegister.Rsi] = 1;
|
|
context[CpuRegister.Rdx] = idsAddress;
|
|
context[CpuRegister.Rcx] = sizesAddress;
|
|
context[CpuRegister.R8] = errorIndexAddress;
|
|
|
|
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
|
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
|
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress));
|
|
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress));
|
|
Assert.Equal(0u, ReadUInt32(memory, errorIndexAddress));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveFilepathsToIdsAndFileSizes_InvalidErrorIndex_ReturnsMemoryFault()
|
|
{
|
|
const ulong memoryBase = 0x1_0000_0000;
|
|
const ulong pathListAddress = memoryBase + 0x100;
|
|
const ulong pathAddress = memoryBase + 0x200;
|
|
const ulong idsAddress = memoryBase + 0x800;
|
|
const ulong sizesAddress = memoryBase + 0x880;
|
|
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
|
var context = new CpuContext(memory, Generation.Gen5);
|
|
var missingHostPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
|
memory.WriteCString(pathAddress, missingHostPath);
|
|
WriteUInt64(memory, pathListAddress, pathAddress);
|
|
|
|
context[CpuRegister.Rdi] = pathListAddress;
|
|
context[CpuRegister.Rsi] = 1;
|
|
context[CpuRegister.Rdx] = idsAddress;
|
|
context[CpuRegister.Rcx] = sizesAddress;
|
|
context[CpuRegister.R8] = memoryBase + 0x5000;
|
|
|
|
Assert.Equal(
|
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
|
KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveFilepathsToIdsAndFileSizes_MissingMidBatch_StopsAtFailingEntry()
|
|
{
|
|
const ulong memoryBase = 0x1_0000_0000;
|
|
const ulong pathListAddress = memoryBase + 0x100;
|
|
const ulong idsAddress = memoryBase + 0x800;
|
|
const ulong sizesAddress = memoryBase + 0x880;
|
|
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
|
byte[] fileContents = [1, 2, 3, 4, 5];
|
|
// 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-{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, 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);
|
|
WriteUInt32(memory, idsAddress + 8, 0x1234_5678); // sentinel: entry 2 untouched
|
|
WriteUInt64(memory, sizesAddress + 16, 0xDEAD);
|
|
|
|
context[CpuRegister.Rdi] = pathListAddress;
|
|
context[CpuRegister.Rsi] = 3;
|
|
context[CpuRegister.Rdx] = idsAddress;
|
|
context[CpuRegister.Rcx] = sizesAddress;
|
|
context[CpuRegister.R8] = errorIndexAddress;
|
|
|
|
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
|
Assert.NotEqual(uint.MaxValue, ReadUInt32(memory, idsAddress));
|
|
Assert.Equal((ulong)fileContents.Length, ReadUInt64(memory, sizesAddress));
|
|
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress + 4));
|
|
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress + 8));
|
|
Assert.Equal(1u, ReadUInt32(memory, errorIndexAddress));
|
|
Assert.Equal(0x1234_5678u, ReadUInt32(memory, idsAddress + 8));
|
|
Assert.Equal(0xDEADul, ReadUInt64(memory, sizesAddress + 16));
|
|
}
|
|
finally
|
|
{
|
|
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
|
if (Directory.Exists(mountRoot))
|
|
{
|
|
Directory.Delete(mountRoot, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
|
{
|
|
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
|
Assert.True(memory.TryRead(address, bytes));
|
|
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
|
}
|
|
|
|
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
|
{
|
|
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
|
Assert.True(memory.TryRead(address, bytes));
|
|
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
|
}
|
|
|
|
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
|
{
|
|
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
|
|
Assert.True(memory.TryWrite(address, bytes));
|
|
}
|
|
|
|
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
|
{
|
|
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
|
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
|
|
Assert.True(memory.TryWrite(address, bytes));
|
|
}
|
|
}
|