Ordinal.EqualsIgnoreCaseUtf8_Scalar reads 2 bytes past both buffers when tail is 3 bytes with non-ASCII data
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 2/5
- Tiempo estimado
- 1-3 horas
- Aptitud para principiantes
- 76/100
Línea de trabajo
Comienza en src/libraries/System.Private.CoreLib/src/System/Globalization/Ordinal.Utf8.cs e inspecciona el manejo final de length == 3 en EqualsIgnoreCaseUtf8_Scalar y StartsWithIgnoreCaseUtf8_Scalar. Reproduce el problema con los spans UTF-8 de double.TryParse proporcionados y verifica que ambas entradas de 3 bytes que no coinciden devuelvan false independientemente de los bytes de tráiler, sin leer más allá de ninguno de los dos búferes.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Description
Ordinal.EqualsIgnoreCaseUtf8_Scalar (and StartsWithIgnoreCaseUtf8_Scalar) in Ordinal.Utf8.cs read 2 bytes past the end of both input buffers when the remaining tail after the widening loops is exactly 3 bytes and contains non-ASCII data.
Root cause: The length == 3 tail branch advances byteOffset by 2 to compose the third byte into a uint:
if (length == 3)
{
valueAu32 = Unsafe.ReadUnaligned<ushort>(ref Unsafe.AddByteOffset(ref charA, byteOffset));
valueBu32 = Unsafe.ReadUnaligned<ushort>(ref Unsafe.AddByteOffset(ref charB, byteOffset));
byteOffset += 2; // ← advances the shared offset
valueAu32 |= (uint)(Unsafe.AddByteOffset(ref charA, byteOffset) << 16);
valueBu32 |= (uint)(Unsafe.AddByteOffset(ref charB, byteOffset) << 16);
}
When the composed value contains non-ASCII bytes, execution jumps to NonAscii32 → NonAscii, which computes the fallback pointer and length:
NonAscii:
range -= length;
return EqualsStringIgnoreCaseUtf8(
ref Unsafe.AddByteOffset(ref charA, byteOffset), lengthA - range,
ref Unsafe.AddByteOffset(ref charB, byteOffset), lengthB - range);
byteOffset is now 2 past the start of the tail (due to the += 2), but range -= length still yields the number of bytes consumed by the main loops only (not the tail). The fallback gets a pointer 2 bytes too far with a length 2 bytes too long — reading past both buffers.
The length == 1 and length == 2 branches don't modify byteOffset, so they're not affected.
Suggested fix: Don't mutate byteOffset in the tail — use an inline offset:
byteOffset += 2;
-
- valueAu32 |= (uint)(Unsafe.AddByteOffset(ref charA, byteOffset) << 16);
- valueBu32 |= (uint)(Unsafe.AddByteOffset(ref charB, byteOffset) << 16);
+ valueAu32 |= (uint)(Unsafe.AddByteOffset(ref charA, byteOffset + 2) << 16);
+ valueBu32 |= (uint)(Unsafe.AddByteOffset(ref charB, byteOffset + 2) << 16);
Apply in both EqualsIgnoreCaseUtf8_Scalar and StartsWithIgnoreCaseUtf8_Scalar.
Observable impact: double.TryParse(ReadOnlySpan<byte>, NumberStyles, IFormatProvider, out double) with a NumberFormatInfo whose infinity/NaN symbol encodes to a UTF-8 length ≡ 3 (mod 4) with non-ASCII characters gives incorrect results that depend on memory past the input span. The default ICU infinity symbol "\u221E" (∞ = E2 88 9E, 3 bytes) hits this on every culture that uses it.
Repro:
using System.Globalization;
using System.Text;
var nfi = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone();
nfi.PositiveInfinitySymbol = "\u221E"; // ∞, 3 bytes UTF-8: E2 88 9E
// U+301E (〞) encodes as E3 80 9E — different character, same byte length.
// The UTF-16 char TryParse correctly rejects it:
Console.WriteLine(double.TryParse("\u301E", NumberStyles.Float, nfi, out _)); // False ✓
// The UTF-8 byte TryParse result depends on what follows the span in memory:
byte[] withNulTrailer = [0xE3, 0x80, 0x9E, 0x00, 0x00];
byte[] withAsciiTrailer = [0xE3, 0x80, 0x9E, 0x41, 0x41];
// Both parse exactly the same 3-byte span — only the trailer bytes differ
Console.WriteLine(double.TryParse(withNulTrailer.AsSpan(0, 3), NumberStyles.Float, nfi, out _));
// True ✗ — wrong! Accepted because OOB NULs matched the symbol cache's NUL terminator
Console.WriteLine(double.TryParse(withAsciiTrailer.AsSpan(0, 3), NumberStyles.Float, nfi, out _));
// False — different OOB bytes, different result
Expected: Both UTF-8 TryParse calls return false (U+301E ≠ U+221E), matching the char overload.
Actual: The first returns true (accepts a non-matching input as ∞), the second returns false. The result depends on bytes outside the input span.
Configuration
Reproduced on:
- .NET 10.0.12 (10.0.401 SDK), Windows 11 x64
- Expected to affect .NET 8.x and 9.x (any version with
Ordinal.Utf8.cswidening-read scalar paths)
Regression?
Introduced with the Ordinal.Utf8.cs widening-read scalar paths (the TChar-generic / IUtf8SpanParsable rewrite). Not present in .NET 7 or earlier (no UTF-8 TryParse overloads).
Other information
Found via coverage-guided fuzzing (SharpFuzz) of double.TryParse(ReadOnlySpan<byte>) with randomised NumberFormatInfo symbols.
The EqualsIgnoreCaseUtf8_Vector128 path has a separate (correctness-only, no OOB) issue: at line 145 it passes (int)(lengthU - i) as both lengthA and lengthB to the scalar fallback, discarding the actual lengths — so the scalar function's lengthA != lengthB check is dead and it can return true when the original inputs had different lengths.
- Lenguaje dominante
- C#
- Estrellas
- 18.4k
- Forks
- 5.7k
- Merge medio
- 2 d 21 h
- PR fusionados (30 d)
- 657
Preparar el entorno
Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de dotnet/runtime
-
[mono] Native memory leak on the first invoke of every open-instance delegate to a virtual methodAbiertoarea-VM-meta-mono untriaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
dotnet/runtime#136284 · 6 reacciones ·
Los mantenedores suelen responder en 1 día
-
Several MemoryExtensions.LastIndexOfAnyExcept overloads incorrectly document "first occurrence"Abiertoarea-System.Memory untriaged
Dificultad 1/5 Menos de una hora Aptitud para principiantes 83/100
dotnet/runtime#136282 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
area-System.Runtime untriaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
dotnet/runtime#136281 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
area-System.IO.Compression untriaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
dotnet/runtime#136215 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
area-CodeGen-coreclr untriaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
dotnet/runtime#135849 · 1 comentario ·
Los mantenedores suelen responder en 1 día
Todos los issues de dotnet/runtime
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Nimblesite/SharpLsp#468 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 88/100
elastic/ecs-dotnet#575 ·
-
V4 alert-rule reads have no scope gate, so any authenticated credential can list alert rulesAbiertoalerts bug effort:S P2
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
nightscout/nocturne#2511 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
Los mantenedores suelen responder en 2 días
-
Crash in v3.00Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
arkane-systems/mousejiggler#140 ·