CVE-2024-32655 (published 2024-05-14T15:36Z)

Npgsql is the .NET data provider for PostgreSQL. The `WriteBind()` method in `src/Npgsql/Internal/NpgsqlConnector.FrontendMessages.cs` uses `int` variables to store the message length and the sum of parameter lengths. Both variables overflow when the sum of parameter lengths becomes too large. This causes Npgsql to write a message size that is too small when constructing a Postgres protocol message to send it over the network to the database. When parsing the message, the database will only read a small number of bytes and treat any following bytes as new messages while they belong to the old message. Attackers can abuse this to inject arbitrary Postgres protocol messages into the connection, leading to the execution of arbitrary SQL statements on the application's behalf. This vulnerability is fixed in 4.0.14, 4.1.13, 5.0.18, 6.0.11, 7.0.7, and 8.0.3.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/npgsql/npgsql
commit_details: repo_url: https://github.com/npgsql/npgsql
author: Nino Floris
author_date: 2024-05-09 13:20:06+02:00
msg: Merge pull request from GHSA-x9vc-6hfv-hg8c
repository: repo_url: https://github.com/npgsql/npgsql
repo_name: npgsql/npgsql
description: Npgsql is the .NET data provider for PostgreSQL.
repo_language: C#
owner: npgsql
file_changes: filename: NpgsqlConnector.FrontendMessages.cs
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,7 @@ internal Task WriteDescribe(StatementOrPortal statementOrPortal, byte[] asciiNam
                   (asciiName.Length + 1);   // Statement/portal name
 
         var writeBuffer = WriteBuffer;
+        writeBuffer.StartMessage(len);
         if (writeBuffer.WriteSpaceLeft < len)
             return FlushAndWrite(len, statementOrPortal, asciiName, async, cancellationToken);
 
@@ -48,6 +49,7 @@ internal Task WriteSync(bool async, CancellationToken cancellationToken = defaul
                         sizeof(int);    // Length
 
         var writeBuffer = WriteBuffer;
+        writeBuffer.StartMessage(len);
         if (writeBuffer.WriteSpaceLeft < len)
             return FlushAndWrite(async, cancellationToken);
 
@@ -79,6 +81,7 @@ internal Task WriteExecute(int maxRows, bool async, CancellationToken cancellati
                         sizeof(int);         // Max number of rows
 
         var writeBuffer = WriteBuffer;
+        writeBuffer.StartMessage(len);
         if (writeBuffer.WriteSpaceLeft < len)
             return FlushAndWrite(maxRows, async, cancellationToken);
 
@@ -118,9 +121,6 @@ internal async Task WriteParse(string sql, byte[] asciiName, List<NpgsqlParamete
         }
 
         var writeBuffer = WriteBuffer;
-        if (writeBuffer.WriteSpaceLeft < 1 + 4 + asciiName.Length + 1)
-            await Flush(async, cancellationToken).ConfigureAwait(false);
-
         var messageLength =
             sizeof(byte)                +         // Message code
             sizeof(int)                 +         // Length
@@ -130,9 +130,14 @@ internal async Task WriteParse(string sql, byte[] asciiName, List<NpgsqlParamete
             sizeof(ushort)              +         // Number of parameters
             inputParameters.Count * sizeof(int);  // Parameter OIDs
 
-        writeBuffer.WriteByte(FrontendMessageCode.Parse);
-        writeBuffer.WriteInt32(messageLength - 1);
-        writeBuffer.WriteNullTerminatedString(asciiName);
+
+        WriteBuffer.StartMessage(messageLength);
+        if (WriteBuffer.WriteSpaceLeft < 1 + 4 + asciiName.Length + 1)
+            await Flush(async, cancellationToken).ConfigureAwait(false);
+
+        WriteBuffer.WriteByte(FrontendMessageCode.Parse);
+        WriteBuffer.WriteInt32(messageLength - 1);
+        WriteBuffer.WriteNullTerminatedString(asciiName);
 
         await writeBuffer.WriteString(sql, queryByteLen, async, cancellationToken).ConfigureAwait(false);
 
@@ -171,12 +176,6 @@ internal async Task WriteParse(string sql, byte[] asciiName, List<NpgsqlParamete
             sizeof(ushort);                       // Number of parameter format codes that follow
 
         var writeBuffer = WriteBuffer;
-        if (writeBuffer.WriteSpaceLeft < headerLength)
-        {
-            Debug.Assert(writeBuffer.Size >= headerLength, "Write buffer too small for Bind header");
-            await Flush(async, cancellationToken).ConfigureAwait(false);
-        }
-
         var formatCodesSum = 0;
         var paramsLength = 0;
         for (var paramIndex = 0; paramIndex < parameters.Count; paramIndex++)
@@ -197,8 +196,15 @@ internal async Task WriteParse(string sql, byte[] asciiName, List<NpgsqlParamete
                             sizeof(short)                        +                  // Number of result format codes
                             sizeof(short) * (unknownResultTypeList?.Length ?? 1);   // Result format codes
 
-        writeBuffer.WriteByte(FrontendMessageCode.Bind);
-        writeBuffer.WriteInt32(messageLength - 1);
+        WriteBuffer.StartMessage(messageLength);
+        if (WriteBuffer.WriteSpaceLeft < headerLength)
+        {
+            Debug.Assert(WriteBuffer.Size >= headerLength, "Write buffer too small for Bind header");
+            await Flush(async, cancellationToken).ConfigureAwait(false);
+        }
+
+        WriteBuffer.WriteByte(FrontendMessageCode.Bind);
+        WriteBuffer.WriteInt32(messageLength - 1);
         Debug.Assert(portal == string.Empty);
         writeBuffer.WriteByte(0);  // Portal is always empty
 
@@ -269,6 +275,7 @@ internal Task WriteClose(StatementOrPortal type, byte[] asciiName, bool async, C
                   asciiName.Length + sizeof(byte);  // Statement or portal name plus null terminator
 
         var writeBuffer = WriteBuffer;
+        writeBuffer.StartMessage(len);
         if (writeBuffer.WriteSpaceLeft < len)
             return FlushAndWrite(len, type, asciiName, async, cancellationToken);
 
@@ -296,14 +303,17 @@ internal async Task WriteQuery(string sql, bool async, CancellationToken cancell
     {
         var queryByteLen = TextEncoding.GetByteCount(sql);
 
+        var len = sizeof(byte) +
+                  sizeof(int) + // Message length (including self excluding code)
+                  queryByteLen + // Query byte length
+                  sizeof(byte);
+
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < 1 + 4)
             await Flush(async, cancellationToken).ConfigureAwait(false);
 
         WriteBuffer.WriteByte(FrontendMessageCode.Query);
-        WriteBuffer.WriteInt32(
-            sizeof(int)  +        // Message length (including self excluding code)
-            queryByteLen +        // Query byte length
-            sizeof(byte));        // Null terminator
+        WriteBuffer.WriteInt32(len - 1);
 
         await WriteBuffer.WriteString(sql, queryByteLen, async, cancellationToken).ConfigureAwait(false);
         if (WriteBuffer.WriteSpaceLeft < 1)
@@ -316,6 +326,7 @@ internal async Task WriteCopyDone(bool async, CancellationToken cancellationToke
         const int len = sizeof(byte) +   // Message code
                         sizeof(int);     // Length
 
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < len)
             await Flush(async, cancellationToken).ConfigureAwait(false);
 
@@ -331,6 +342,7 @@ internal async Task WriteCopyFail(bool async, CancellationToken cancellationToke
                         sizeof(int) +   // Length
                         sizeof(byte);   // Error message is always empty (only a null terminator)
 
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < len)
             await Flush(async, cancellationToken).ConfigureAwait(false);
 
@@ -348,6 +360,7 @@ internal void WriteCancelRequest(int backendProcessId, int backendSecretKey)
 
         Debug.Assert(backendProcessId != 0);
 
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < len)
             Flush(false).GetAwaiter().GetResult();
 
@@ -362,6 +375,7 @@ internal void WriteTerminate()
         const int len = sizeof(byte) +  // Message code
                         sizeof(int);    // Length
 
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < len)
             Flush(false).GetAwaiter().GetResult();
 
@@ -374,6 +388,7 @@ internal void WriteSslRequest()
         const int len = sizeof(int) +  // Length
                         sizeof(int);   // SSL request code
 
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < len)
             Flush(false).GetAwaiter().GetResult();
 
@@ -394,6 +409,7 @@ internal void WriteStartup(Dictionary<string, string> parameters)
                    NpgsqlWriteBuffer.UTF8Encoding.GetByteCount(kvp.Value) + 1;
 
         // Should really never happen, just in case
+        WriteBuffer.StartMessage(len);
         if (len > WriteBuffer.Size)
             throw new Exception("Startup message bigger than buffer");
 
@@ -417,8 +433,10 @@ internal void WriteStartup(Dictionary<string, string> parameters)
 
     internal async Task WritePassword(byte[] payload, int offset, int count, bool async, CancellationToken cancellationToken = default)
     {
+        WriteBuffer.StartMessage(sizeof(byte) + sizeof(int) + count);
         if (WriteBuffer.WriteSpaceLeft < sizeof(byte) + sizeof(int))
             await WriteBuffer.Flush(async, cancellationToken).ConfigureAwait(false);
+
         WriteBuffer.WriteByte(FrontendMessageCode.Password);
         WriteBuffer.WriteInt32(sizeof(int) + count);
 
@@ -441,6 +459,7 @@ internal async Task WriteSASLInitialResponse(string mechanism, byte[] initialRes
                   sizeof(int)                                                +  // Initial response length
                   (initialResponse?.Length ?? 0);                               // Initial response payload
 
+        WriteBuffer.StartMessage(len);
         if (WriteBuffer.WriteSpaceLeft < len)
             await WriteBuffer.Flush(async, cancellationToken).ConfigureAwait(false);
 
@@ -464,6 +483,7 @@ internal async Task WriteSASLInitialResponse(string mechanism, byte[] initialRes
 
     internal Task WritePregenerated(byte[] data, bool async = false, CancellationToken cancellationToken = default)
     {
+        WriteBuffer.StartMessage(data.Length);
         if (WriteBuffer.WriteSpaceLeft < data.Length)
             return FlushAndWrite(data, async, cancellationToken);
 

programming_language: C#
filename: NpgsqlWriteBuffer.cs
change_type: ModificationType.MODIFY
diff: @@ -28,6 +28,8 @@ sealed class NpgsqlWriteBuffer : IDisposable
     internal Stream Underlying { private get; set; }
 
     readonly Socket? _underlyingSocket;
+    internal bool MessageLengthValidation { get; set; } = true;
+
     readonly ResettableCancellationTokenSource _timeoutCts;
     readonly MetricsReporter? _metricsReporter;
 
@@ -76,6 +78,9 @@ internal PgWriter GetWriter(NpgsqlDatabaseInfo typeCatalog, FlushMode flushMode
 
     internal int WritePosition;
 
+    int _messageBytesFlushed;
+    int? _messageLength;
+
     bool _disposed;
     readonly PgWriter _pgWriter;
 
@@ -131,6 +136,8 @@ public async Task Flush(bool async, CancellationToken cancellationToken = defaul
             WritePosition = pos;
         } else if (WritePosition == 0)
             return;
+        else
+            AdvanceMessageBytesFlushed(WritePosition);
 
         var finalCt = async && Timeout > TimeSpan.Zero
             ? _timeoutCts.Start(cancellationToken)
@@ -197,15 +204,19 @@ internal void DirectWrite(ReadOnlySpan<byte> buffer)
             Debug.Assert(WritePosition == 5);
 
             WritePosition = 1;
-            WriteInt32(buffer.Length + 4);
+            WriteInt32(checked(buffer.Length + 4));
             WritePosition = 5;
             _copyMode = false;
+            StartMessage(5);
             Flush();
             _copyMode = true;
             WriteCopyDataHeader();  // And ready the buffer after the direct write completes
         }
         else
+        {
             Debug.Assert(WritePosition == 0);
+            AdvanceMessageBytesFlushed(buffer.Length);
+        }
 
         try
         {
@@ -228,15 +239,19 @@ internal async Task DirectWrite(ReadOnlyMemory<byte> memory, bool async, Cancell
             Debug.Assert(WritePosition == 5);
 
             WritePosition = 1;
-            WriteInt32(memory.Length + 4);
+            WriteInt32(checked(memory.Length + 4));
             WritePosition = 5;
             _copyMode = false;
+            StartMessage(5);
             await Flush(async, cancellationToken).ConfigureAwait(false);
             _copyMode = true;
             WriteCopyDataHeader();  // And ready the buffer after the direct write completes
         }
         else
+        {
             Debug.Assert(WritePosition == 0);
+            AdvanceMessageBytesFlushed(memory.Length);
+        }
 
         try
         {
@@ -534,9 +549,51 @@ public void Dispose()
 
     #region Misc
 
+    internal void StartMessage(int messageLength)
+    {
+        if (!MessageLengthValidation)
+            return;
+
+        if (_messageLength is not null && _messageBytesFlushed != _messageLength && WritePosition != -_messageBytesFlushed + _messageLength)
+            Throw();
+
+        // Add negative WritePosition to compensate for previous message(s) written without flushing.
+        _messageBytesFlushed = -WritePosition;
+        _messageLength = messageLength;
+
+        void Throw()
+        {
+            throw Connector.Break(new OverflowException("Did not write the amount of bytes the message length specified"));
+        }
+    }
+
+    void AdvanceMessageBytesFlushed(int count)
+    {
+        if (!MessageLengthValidation)
+            return;
+
+        if (count < 0 || _messageLength is null || (long)_messageBytesFlushed + count > _messageLength)
+            Throw();
+
+        _messageBytesFlushed += count;
+
+        void Throw()
+        {
+            if (count < 0)
+                throw new ArgumentOutOfRangeException(nameof(count), "Can't advance by a negative count");
+
+            if (_messageLength is null)
+                throw Connector.Break(new InvalidOperationException("No message was started"));
+
+            if ((long)_messageBytesFlushed + count > _messageLength)
+                throw Connector.Break(new OverflowException("Tried to write more bytes than the message length specified"));
+        }
+    }
+
     internal void Clear()
     {
         WritePosition = 0;
+        _messageLength = null;
     }
 
     /// <summary>

programming_language: C#
filename: NpgsqlTransaction.cs
change_type: ModificationType.MODIFY
diff: @@ -212,16 +212,7 @@ public override void Save(string name)
 
         // Note: savepoint names are PostgreSQL identifiers, and so limited by default to 63 characters.
         // Since we are prepending, we assume below that the statement will always fit in the buffer.
-        _connector.WriteBuffer.WriteByte(FrontendMessageCode.Query);
-        _connector.WriteBuffer.WriteInt32(
-            sizeof(int)  +                               // Message length (including self excluding code)
-            _connector.TextEncoding.GetByteCount("SAVEPOINT ") +
-            _connector.TextEncoding.GetByteCount(name) +
-            sizeof(byte));                               // Null terminator
-
-        _connector.WriteBuffer.WriteString("SAVEPOINT ");
-        _connector.WriteBuffer.WriteString(name);
-        _connector.WriteBuffer.WriteByte(0);
+        _connector.WriteQuery("SAVEPOINT " + name, async: false).GetAwaiter().GetResult();
 
         _connector.PendingPrependedResponses += 2;
     }

programming_language: C#
filename: CommandTests.cs
change_type: ModificationType.MODIFY
diff: @@ -852,6 +852,176 @@ public async Task Use_after_reload_types_invalidates_cached_infos()
         }
     }
 
+    [Test]
+    public async Task Parameter_overflow_message_length_throws()
+    {
+        await using var conn = CreateConnection();
+        await conn.OpenAsync();
+        await using var cmd = new NpgsqlCommand("SELECT @a, @b, @c, @d, @e, @f, @g, @h", conn);
+
+        var largeParam = new string('A', 1 << 29);
+        cmd.Parameters.AddWithValue("a", largeParam);
+        cmd.Parameters.AddWithValue("b", largeParam);
+        cmd.Parameters.AddWithValue("c", largeParam);
+        cmd.Parameters.AddWithValue("d", largeParam);
+        cmd.Parameters.AddWithValue("e", largeParam);
+        cmd.Parameters.AddWithValue("f", largeParam);
+        cmd.Parameters.AddWithValue("g", largeParam);
+        cmd.Parameters.AddWithValue("h", largeParam);
+
+        Assert.ThrowsAsync<OverflowException>(() => cmd.ExecuteReaderAsync());
+    }
+
+    [Test]
+    public async Task Composite_overflow_message_length_throws()
+    {
+        await using var adminConnection = await OpenConnectionAsync();
+        var type = await GetTempTypeName(adminConnection);
+
+        await adminConnection.ExecuteNonQueryAsync(
+            $"CREATE TYPE {type} AS (a text, b text, c text, d text, e text, f text, g text, h text)");
+
+        var dataSourceBuilder = CreateDataSourceBuilder();
+        dataSourceBuilder.MapComposite<BigComposite>(type);
+        await using var dataSource = dataSourceBuilder.Build();
+        await using var connection = await dataSource.OpenConnectionAsync();
+
+        var largeString = new string('A', 1 << 29);
+
+        await using var cmd = connection.CreateCommand();
+        cmd.CommandText = "SELECT @a";
+        cmd.Parameters.AddWithValue("a", new BigComposite
+        {
+            A = largeString,
+            B = largeString,
+            C = largeString,
+            D = largeString,
+            E = largeString,
+            F = largeString,
+            G = largeString,
+            H = largeString
+        });
+
+        Assert.ThrowsAsync<OverflowException>(async () => await cmd.ExecuteNonQueryAsync());
+    }
+
+    record BigComposite
+    {
+        public string A { get; set; } = null!;
+        public string B { get; set; } = null!;
+        public string C { get; set; } = null!;
+        public string D { get; set; } = null!;
+        public string E { get; set; } = null!;
+        public string F { get; set; } = null!;
+        public string G { get; set; } = null!;
+        public string H { get; set; } = null!;
+    }
+
+    [Test]
+    public async Task Array_overflow_message_length_throws()
+    {
+        await using var connection = await OpenConnectionAsync();
+
+        var largeString = new string('A', 1 << 29);
+
+        await using var cmd = connection.CreateCommand();
+        cmd.CommandText = "SELECT @a";
+        var array = new[]
+        {
+            largeString,
+            largeString,
+            largeString,
+            largeString,
+            largeString,
+            largeString,
+            largeString,
+            largeString
+        };
+        cmd.Parameters.AddWithValue("a", array);
+
+        Assert.ThrowsAsync<OverflowException>(async () => await cmd.ExecuteNonQueryAsync());
+    }
+
+    [Test]
+    public async Task Range_overflow_message_length_throws()
+    {
+        await using var adminConnection = await OpenConnectionAsync();
+        var type = await GetTempTypeName(adminConnection);
+        var rangeType = await GetTempTypeName(adminConnection);
+
+        await adminConnection.ExecuteNonQueryAsync(
+            $"CREATE TYPE {type} AS (a text, b text, c text, d text, e text, f text, g text, h text);CREATE TYPE {rangeType} AS RANGE(subtype={type})");
+
+        var dataSourceBuilder = CreateDataSourceBuilder();
+        dataSourceBuilder.MapComposite<BigComposite>(type);
+        dataSourceBuilder.EnableUnmappedTypes();
+        await using var dataSource = dataSourceBuilder.Build();
+        await using var connection = await dataSource.OpenConnectionAsync();
+
+        var largeString = new string('A', (1 << 28) + 2000000);
+
+        await using var cmd = connection.CreateCommand();
+        cmd.CommandText = "SELECT @a";
+        var composite = new BigComposite
+        {
+            A = largeString,
+            B = largeString,
+            C = largeString,
+            D = largeString
+        };
+        var range = new NpgsqlRange<BigComposite>(composite, composite);
+        cmd.Parameters.Add(new NpgsqlParameter
+        {
+            Value = range,
+            ParameterName = "a",
+            DataTypeName = rangeType
+        });
+
+        Assert.ThrowsAsync<OverflowException>(async () => await cmd.ExecuteNonQueryAsync());
+    }
+
+    [Test]
+    public async Task Multirange_overflow_message_length_throws()
+    {
+        await using var adminConnection = await OpenConnectionAsync();
+        var type = await GetTempTypeName(adminConnection);
+        var rangeType = await GetTempTypeName(adminConnection);
+
+        await adminConnection.ExecuteNonQueryAsync(
+            $"CREATE TYPE {type} AS (a text, b text, c text, d text, e text, f text, g text, h text);CREATE TYPE {rangeType} AS RANGE(subtype={type})");
+
+        var dataSourceBuilder = CreateDataSourceBuilder();
+        dataSourceBuilder.MapComposite<BigComposite>(type);
+        dataSourceBuilder.EnableUnmappedTypes();
+        await using var dataSource = dataSourceBuilder.Build();
+        await using var connection = await dataSource.OpenConnectionAsync();
+
+        var largeString = new string('A', (1 << 28) + 2000000);
+
+        await using var cmd = connection.CreateCommand();
+        cmd.CommandText = "SELECT @a";
+        var composite = new BigComposite
+        {
+            A = largeString
+        };
+        var range = new NpgsqlRange<BigComposite>(composite, composite);
+        var multirange = new[]
+        {
+            range,
+            range,
+            range,
+            range
+        };
+        cmd.Parameters.Add(new NpgsqlParameter
+        {
+            Value = multirange,
+            ParameterName = "a",
+            DataTypeName = rangeType + "_multirange"
+        });
+
+        Assert.ThrowsAsync<OverflowException>(async () => await cmd.ExecuteNonQueryAsync());
+    }
+
     [Test, Description("CreateCommand before connection open")]
     [IssueLink("https://github.com/npgsql/npgsql/issues/565")]
     public async Task Create_command_before_connection_open()
@@ -1027,6 +1197,7 @@ public async Task Too_many_parameters_throws([Values(PrepareOrNot.NotPrepared, P
             sb.Append('@');
             sb.Append(paramName);
         }
+
         cmd.CommandText = sb.ToString();
 
         if (prepare == PrepareOrNot.Prepared)

programming_language: C#
filename: PgPostmasterMock.cs
change_type: ModificationType.MODIFY
diff: @@ -138,6 +138,7 @@ async Task<ServerOrCancellationRequest> Accept(bool completeCancellationImmediat
         var readBuffer = new NpgsqlReadBuffer(null!, stream, clientSocket, ReadBufferSize, Encoding,
             RelaxedEncoding);
         var writeBuffer = new NpgsqlWriteBuffer(null!, stream, clientSocket, WriteBufferSize, Encoding);
+        writeBuffer.MessageLengthValidation = false;
 
         await readBuffer.EnsureAsync(4);
         var len = readBuffer.ReadInt32();

programming_language: C#
filename: PgServerMock.cs
change_type: ModificationType.MODIFY
diff: @@ -41,6 +41,7 @@ class PgServerMock : IDisposable
         _stream = stream;
         _readBuffer = readBuffer;
         _writeBuffer = writeBuffer;
+        writeBuffer.MessageLengthValidation = false;
     }
 
     internal async Task Startup(MockState state)

programming_language: C#
filename: WriteBufferTests.cs
change_type: ModificationType.MODIFY
diff: @@ -112,6 +112,7 @@ public void SetUp()
     {
         Underlying = new MemoryStream();
         WriteBuffer = new NpgsqlWriteBuffer(null, Underlying, null, NpgsqlReadBuffer.DefaultSize, NpgsqlWriteBuffer.UTF8Encoding);
+        WriteBuffer.MessageLengthValidation = false;
     }
 
     // ReSharper disable once InconsistentNaming

programming_language: C#