Stop corrupting memory resolving an open file handle on macOS (#441) - #443
Conversation
fcntl(2) is variadic - int fcntl(int, int, ...) - and this called it through a
plain DllImport with a fixed third parameter:
[DllImport("libc", EntryPoint = "fcntl", SetLastError = true)]
private static extern int Fcntl(int fileDescriptor, int command, byte[] buffer);
On Apple arm64 the variadic ABI differs from AAPCS64: named arguments go in
registers, variadic arguments go on the STACK. A fixed-signature P/Invoke puts the
buffer in x2, and the callee never looks there.
It does not fail, which is why nothing caught it. Reproduced standalone, outside
the test suite, one open file:
Arch : Arm64
FIXED rc=0 errno=0 len=0 -> ''
rc=0 is fcntl reporting SUCCESS. F_GETPATH on success writes the path into the
buffer it was handed; ours came back empty, so it wrote up to MAXPATHLEN bytes
through whatever pointer happened to be in that stack slot. An arbitrary ~1KB
write, on every call, into this process.
That is the whole of #441. `dotnet test` has been unrunnable on macOS - it wedged,
sometimes as a GC-suspension livelock spinning a core, sometimes as an
all-threads-blocked deadlock, which is exactly what an arbitrary write into runtime
memory looks like from the outside. Both shapes are gone.
Diagnosed as a prediction rather than described afterwards: the three tests in
McpPlanPathPolicyTests that pass are precisely the three that never reach
GetFinalPath, and both that reach it hung, deterministically, 5/5 and 2/2. It is
also why CI never saw it - Linux takes the /proc/self/fd branch and Windows takes
GetFinalPathNameByHandle, so only macOS goes anywhere near fcntl, and only arm64
has the mismatch. ubuntu-latest structurally cannot reproduce this.
The fix is libproc's proc_pidfdinfo, which answers the same question with a FIXED
signature, so ordinary marshalling is correct. .NET has no varargs P/Invoke on this
target at all - __arglist throws "Vararg calling convention not supported" - so
repairing the fcntl declaration in place is not available.
Worth recording, because I recommended the wrong fix on the issue first and it
sounds right: comparing fstat(fd) against stat(path) by device+inode does NOT work
here. stat() re-resolves the path, so if a symlink was swapped before the open it
follows the swap too and the inodes match. It detects nothing. Resolving the
descriptor back to its real path is the only thing that answers "where does this
handle actually live", which is the question the TOCTOU check exists to ask.
The struct offsets are derived in a comment from <sys/proc_info.h> rather than
being magic numbers, and the returned size is checked against the expected 1200
instead of trusted - so a layout change in a future macOS fails loudly rather than
quietly handing back the wrong bytes, which is the failure mode this file just came
out of.
Also note macOS answers with the CANONICAL path: /private/var/... where
Path.GetTempPath() reports /var/... The roots are canonicalized through
ResolveLinkTarget already, so containment still matches; the new test compares by
identity rather than by string for the same reason.
The old test asserted on the returned handle's Label and passed happily on Linux
and Windows while this call had never once worked on Apple silicon. The new test
asserts the thing that was actually broken - that the resolver returns the real path
of the file that is genuinely open - on whatever platform it runs.
Security note: GetFinalPath is the TOCTOU re-validation, confirming the path the
kernel really opened is still inside the advertised roots. On macOS arm64 it has
never performed that check. It failed CLOSED - the empty string reached
Path.GetFullPath, which throws ArgumentException, which is not in OpenAsync's
catch filter - so this was not an exploitable bypass, but the guarantee
OpenAsync_ValidatesAndReturnsTheSameOpenedHandle claims to prove was not being
provided, and the FileStream leaked on the way out.
Tested: full `dotnet test` on macOS ARM64 now finishes in 16 seconds, 305 passed, 0
failed, 2 skipped, twice. It did not finish at all before. McpPlanPathPolicyTests
went from 5/5 hangs to 3/3 passes.
General rule this earns: no variadic libc function through a plain DllImport in
this repo - fcntl, open, ioctl, the printf family. They look fine on x64 and
silently corrupt memory on Apple silicon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed. This is a solid, well-scoped fix — verified the struct-layout math for A few things I confirmed while reviewing, no changes needed:
One observation, not a blocker: none of the workflows in |
The defect cost months of unrunnable tests on macOS and was invisible to CI, and nothing in the repo said not to do it. The specific trap is that it fails silently in both directions: fcntl reports success, and the corruption lands somewhere else entirely, so the crash never points at the call that caused it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed. This is a solid, well-contained fix.
No findings to flag. |
Minor rather than patch. 1.19.x would understate it: #439 adds a "source" field to every warning in the JSON and MCP output and a new badge in the app and CLI, and #437 changes what an existing analysis rule concludes about a plan. Both are things a consumer can notice, and one of them is output-shape. What ships: - #437 Rule 12 no longer calls a conversion non-SARGable when it converts the parameter rather than the column. Plans carrying a parameter-side conversion on a scan lose that warning and report the scan's residual predicate instead. Verified against all 38 committed plans: no other plan's verdict moves. - #439 SQL Server's own warnings are now told apart from ours, tagged [SQL Server] in the app and CLI and carried as "source" in JSON/MCP. Additive, but it changes the bytes of analyze --compact. - #431 Robot Advice no longer takes the app down on a deep plan. - #438 querystore gets the same depth ceiling analyze got; it had been failing quietly on deep plans, one ERROR row per plan. - #443 the macOS handle resolver no longer corrupts memory on Apple silicon. - #425 Entra MFA works again (WAM parent window handle). Not user-facing but worth knowing for anyone building from this tag: the suite runs on Microsoft.Testing.Platform now (#442), and `dotnet test` finishes on macOS for the first time (#443) - 307 tests, 305 passing, 13 seconds. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #441.
dotnet testnow finishes on macOS.The defect
fcntl(2)is variadic —int fcntl(int, int, ...)— and this called it through a plainDllImportwith a fixed third parameter:On Apple arm64 the variadic ABI differs from AAPCS64: named arguments go in registers, variadic arguments go on the stack. A fixed-signature P/Invoke puts the buffer in
x2, and the callee never looks there.It does not fail, which is why nothing caught it. Standalone repro, outside the test suite, one open file:
rc=0isfcntlreporting success.F_GETPATHon success writes the path into the buffer it was handed; ours came back empty — so it wrote up toMAXPATHLENbytes through whatever pointer happened to be in that stack slot. An arbitrary ~1KB write, on every call, into this process.That is the entirety of #441. The suite wedged sometimes as a GC-suspension livelock spinning a core and sometimes as an all-threads-blocked deadlock, which is exactly what an arbitrary write into runtime memory looks like from outside. Both shapes are gone.
Diagnosed as a prediction, not a story
The three tests in
McpPlanPathPolicyTeststhat pass are precisely the three that never reachGetFinalPath; both that reach it hung, deterministically — 5/5 and 2/2. I checked determinism explicitly because I had assumed the opposite.It is also why CI never saw it: Linux takes the
/proc/self/fdbranch, Windows takesGetFinalPathNameByHandle. Only macOS goes nearfcntl, and only arm64 has the mismatch —ubuntu-lateststructurally cannot reproduce it.The fix
libproc'sproc_pidfdinfoanswers the same question with a fixed signature, so ordinary marshalling is correct. Repairing thefcntldeclaration in place is not available: .NET has no varargs P/Invoke on this target —__arglistthrowsVararg calling convention not supported, which I confirmed.Correcting myself: I recommended
fstat/statdevice+inode comparison on the issue, and it is wrong.stat()re-resolves the path, so if a symlink was swapped before the open it follows the swap too and the inodes match — it detects nothing. Resolving the descriptor back to its real path is the only thing that answers "where does this handle actually live", which is the question the TOCTOU check exists to ask.Struct offsets are derived in a comment from
<sys/proc_info.h>rather than being magic numbers, and the returned size is checked against the expected 1200 rather than trusted — so a future macOS layout change fails loudly instead of quietly handing back wrong bytes, which is the failure mode this file just came out of.Note macOS answers with the canonical path (
/private/var/…wherePath.GetTempPath()reports/var/…). Roots are already canonicalized throughResolveLinkTarget, so containment still matches; the new test compares by identity rather than by string for the same reason.Security note
GetFinalPathis the TOCTOU re-validation — confirming the path the kernel really opened is still inside the advertised roots. On macOS arm64 it has never performed that check. It failed closed (the empty string reachedPath.GetFullPath, which throwsArgumentException, which is not inOpenAsync's catch filter), so this was not an exploitable bypass — but the guaranteeOpenAsync_ValidatesAndReturnsTheSameOpenedHandleclaims to prove was not being provided, and theFileStreamleaked on the way out.Tests
The old test asserted on the returned handle's
.Labeland passed happily on Linux and Windows while this call had never once worked on Apple silicon. The new test asserts the thing that was actually broken — that the resolver returns the real path of the file that is genuinely open — on whatever platform it runs.dotnet teston macOS ARM64McpPlanPathPolicyTestsdotnet buildclean; the 9 warnings are the pre-existingMCP9005obsolete-API uses inMcpSmokeTests.cs.Rule this earns
No variadic libc function through a plain
DllImportin this repo —fcntl,open,ioctl, theprintffamily. They look fine on x64 and silently corrupt memory on Apple silicon.🤖 Generated with Claude Code