From 5246fa1c21361d764935fb3120eb5d8bb0ee5d0f Mon Sep 17 00:00:00 2001 From: tolmachev-pravo Date: Tue, 14 Jul 2026 15:12:02 +0400 Subject: [PATCH] Write explicit null terminator in Utf8StringArray Utf8StringArray reserved a byte for each string's null terminator but never wrote it, relying on the buffer already being zero. On .NET the buffer is rented from ArrayPool.Shared, which is not zero-initialized, so once the pool has been used the terminator byte can hold leftover data. Native node then reads each argv entry past its intended end, e.g. "bad option: --disable-wasm-trap-handler". Write the terminator explicitly, and add a regression test that reproduces the failure by dirtying the shared pool before marshaling. Fixes #481 --- src/NodeApi/NodeApi.csproj | 4 ++ src/NodeApi/Runtime/Utf8StringArray.cs | 10 ++-- test/Utf8StringArrayTests.cs | 71 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 test/Utf8StringArrayTests.cs diff --git a/src/NodeApi/NodeApi.csproj b/src/NodeApi/NodeApi.csproj index b6aa76a6..3727e35e 100644 --- a/src/NodeApi/NodeApi.csproj +++ b/src/NodeApi/NodeApi.csproj @@ -28,6 +28,10 @@ + + + + diff --git a/src/NodeApi/Runtime/Utf8StringArray.cs b/src/NodeApi/Runtime/Utf8StringArray.cs index f3b7b36c..1ea25e3b 100644 --- a/src/NodeApi/Runtime/Utf8StringArray.cs +++ b/src/NodeApi/Runtime/Utf8StringArray.cs @@ -46,9 +46,13 @@ public unsafe Utf8StringArray(ReadOnlySpan strings) fixed (char* src = strings[i]) { Utf8Strings[i] = stringBufferPtr + offset; - offset += Encoding.UTF8.GetBytes( - src, strings[i].Length, (byte*)(stringBufferPtr + offset), byteLength - offset) - + 1; // +1 for the string Null-terminator. + int encodedLength = Encoding.UTF8.GetBytes( + src, strings[i].Length, (byte*)(stringBufferPtr + offset), byteLength - offset); + + // Rented pool buffers are not zero-initialized, so write the terminator explicitly + // rather than relying on the reserved byte already being zero (#481). + *(byte*)(stringBufferPtr + offset + encodedLength) = 0; + offset += encodedLength + 1; } } } diff --git a/test/Utf8StringArrayTests.cs b/test/Utf8StringArrayTests.cs new file mode 100644 index 00000000..f7c64e20 --- /dev/null +++ b/test/Utf8StringArrayTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// The bug only affects the .NET path, where the buffer is pool-rented (not zeroed); on .NET +// Framework it is a zeroed new byte[] and Marshal.PtrToStringUTF8 is unavailable. +#if !(NETFRAMEWORK || NETSTANDARD) + +using System.Buffers; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.JavaScript.NodeApi.Runtime; +using Xunit; + +namespace Microsoft.JavaScript.NodeApi.Test; + +public class Utf8StringArrayTests +{ + // Regression test for #481: a dirty (non-zeroed) pooled buffer must not leak bytes into the + // marshaled strings, which requires writing the null terminator explicitly. + [Fact] + public void MarshalsStringsCorrectlyWithDirtyPooledBuffer() + { + string[] input = + { + "node", + "--disable-wasm-trap-handler", + "--max-old-space-size=4096", + }; + + PoisonSharedByteArrayPool(); + + string[] roundTripped; + using (var utf8Array = new Utf8StringArray(input)) + { + nint[] pointers = utf8Array.Utf8Strings; + roundTripped = new string[input.Length]; + for (int i = 0; i < input.Length; i++) + { + roundTripped[i] = Marshal.PtrToStringUTF8(pointers[i])!; + } + } + + Assert.Equal(input, roundTripped); + } + + // Fill pooled buffers with non-zero bytes and return them, so the next rent starts dirty. + private static void PoisonSharedByteArrayPool() + { + var rented = new List(); + for (int size = 8; size <= 1024; size *= 2) + { + for (int n = 0; n < 16; n++) + { + byte[] buffer = ArrayPool.Shared.Rent(size); + for (int k = 0; k < buffer.Length; k++) + { + buffer[k] = 0xEF; + } + + rented.Add(buffer); + } + } + + foreach (byte[] buffer in rented) + { + ArrayPool.Shared.Return(buffer); + } + } +} + +#endif