Cybersecurity News

Negative Field Length Panic in pgproto3/v2 DataRow.Decode

Negative Field Length Panic in pgproto3/v2 DataRow.Decode
jackc/pgproto3 PostgreSQL wire protocol vulnerability
github.com/jackc/pgproto3 — PostgreSQL wire protocol library for Go

Overview

github.com/jackc/pgproto3 is a pure-Go implementation of the PostgreSQL wire protocol. It provides the low-level message encoding and decoding layer that Go applications use to communicate with PostgreSQL servers. The library handles every message type defined in the PostgreSQL frontend/backend protocol — from authentication handshakes to query responses — and is downloaded millions of times each month via the Go module proxy.

This report documents a memory-safety vulnerability in pgproto3/v2 v2.3.3, the current stable release. The vulnerability resides in the DataRow.Decode function. A field-length value of any negative integer other than the null sentinel -1 bypasses a critical bounds check and causes the Go runtime to panic, terminating the client process immediately and without any possibility of recovery.

The PostgreSQL Wire Protocol and Length-Prefixed Fields

PostgreSQL communicates over TCP using a typed, length-prefixed binary protocol. Each message begins with a one-byte type tag followed by a four-byte big-endian integer representing the total message length. Within the message body, variable-length data — such as column values — is similarly prefixed with a four-byte signed integer that specifies how many bytes of data follow.

The protocol reserves exactly one specific length value as a sentinel: -1, encoded as 0xFFFFFFFF in big-endian unsigned form, represents a SQL NULL. All other negative values in the signed 32-bit integer range — that is, 0x80000000 through 0xFFFFFFFE as unsigned, or -2,147,483,648 through -2 as signed — are not valid field lengths under any interpretation of the protocol specification.

A correctly implemented decoder must enforce three conditions on every field-length value it reads:

  1. If the value equals -1, treat the field as SQL NULL.
  2. If the value is any other negative number, reject the message as malformed.
  3. If the value is non-negative but exceeds the remaining bytes in the buffer, reject the message as malformed.

The vulnerability in DataRow.Decode arises from the complete absence of the second condition.

The DataRow Message

A DataRow message (wire type tag 'D', hex 0x44) is sent by a PostgreSQL server for every row returned by a query. Its structure is:

  • Two bytes: the number of column values (big-endian unsigned 16-bit integer).
  • For each column: four bytes for the field length, followed by exactly that many bytes of data — or no data bytes at all if the length is -1 (NULL).

DataRow is one of the most frequently produced messages in any active PostgreSQL session. It appears in the response to every SELECT statement, every query with a RETURNING clause, and every cursor fetch. The DataRow.Decode function is therefore invoked on virtually every database interaction that returns data, across the entire lifetime of a running application.

The Vulnerability: A Vacuous Bounds Check

The relevant section of data_row.go begins by reading the field length at line 48:

msgSize := int(int32(binary.BigEndian.Uint32(src[rp:])))

This two-step cast is semantically correct. binary.BigEndian.Uint32 reads the raw four bytes as an unsigned 32-bit integer. The subsequent int32() reinterprets that bit pattern as a signed 32-bit integer, and int() widens it to the platform's native integer size. For an input of 0xFF303030, the result is -13,619,152.

Immediately after, the null sentinel check correctly handles -1:

if msgSize == -1 {
    dst.Values[i] = nil
}

For any msgSize that is not -1, execution falls through to the bounds check:

if len(src[rp:]) < msgSize {
    return &invalidMessageFormatErr{messageType: "DataRow"}
}

This is where the vulnerability materialises. The Go specification guarantees that len() returns a value of type int that is always greater than or equal to zero. When msgSize is a negative value such as -13,619,152, the comparison becomes:

len(src[rp:]) < -13619152

No non-negative integer is less than a negative one. This comparison is always false, regardless of how many bytes remain in the buffer. The error return is unreachable for every negative non-null length value. Execution continues to line 59:

dst.Values[i] = src[rp : rp+msgSize : rp+msgSize]

Here, rp is a non-negative read position — typically a small positive integer reflecting how many bytes have already been consumed from the message. Adding -13,619,152 to any small positive value yields a deeply negative result as the slice upper bound. The Go runtime catches this and immediately panics:

panic: runtime error: slice bounds out of range [:-13619150]

The process terminates. No error is returned. No recover() call in any application-level code can catch a runtime panic of this kind unless it is installed as a deferred function in the exact goroutine where the panic originates — an arrangement that no typical database client code implements.

Why the Null Check Provides No Protection

The null sentinel check (if msgSize == -1) handles exactly one value: negative one. The vulnerable range is the 2,147,483,646 distinct negative integers from -2,147,483,648 through -2. None of these equal -1 and none are guarded by any check in the current implementation. The null check creates a natural but false sense that all negative values have been handled, when in fact only one of them has.

This is a well-known pitfall in binary protocol parsers. The typical pattern is: read the field, check for the sentinel, then check for overflow. The implicit assumption is that any value that is not the sentinel is a non-negative data length. That assumption is not enforced by the code, and the missing enforcement is the vulnerability.

Affected Versions and Scope

LibraryAffected VersionStatus
github.com/jackc/pgproto3/v2 v2.3.3 (current latest) Vulnerable — no patch available at time of writing
github.com/jackc/pgproto3 (v1) All versions Likely vulnerable — same pattern, effectively unmaintained

Any Go application that imports github.com/jackc/pgproto3/v2 and connects to a PostgreSQL server is within scope. The vulnerability is not conditional on any particular query pattern, schema, or server configuration. It is triggered by the receipt of a single DataRow message carrying a negative non-null field length — something that a normally operating server would never send, but a malicious or compromised one trivially can.

Threat Model

Because the vulnerability is triggered by a server-to-client message, the attack surface is defined by the question: who can cause a malformed DataRow message to reach the client?

Compromised or malicious database server. An attacker who has gained control of a PostgreSQL instance can deliver a crafted DataRow response to any connecting client on the next query that returns rows. In environments with connection pooling, the crash is repeatable for every new connection obtained from the pool, effectively rendering the application permanently unavailable until the library is patched or the connection to the compromised server is severed.

Network-level man-in-the-middle. On a network path not protected end-to-end by TLS, an attacker with the ability to intercept and modify TCP traffic between the client and the server can inject a malformed DataRow message in place of a legitimate one. The modification required is minimal: four bytes in the field-length position of any row message. The client crashes; the server observes a disconnection and logs it as a client error. The attack leaves no distinctive trace on either side.

Compromised connection pooler or proxy. Many production PostgreSQL deployments route client connections through an intermediate proxy — pgBouncer, Odyssey, or similar tools — that speaks the wire protocol in both directions. A compromised proxy is indistinguishable from a compromised server from the client's perspective. It can inject a crafted DataRow into any session passing through it.

Untrusted test or development environments. Developers and CI pipelines that run against ephemeral PostgreSQL instances — pulled from public container images, provided by third-party test platforms, or shared across teams — may be connecting to infrastructure they do not fully control. A malicious container image or shared test server could deliver the malformed message to every client that connects.

In all scenarios above, TLS between client and server mitigates only the network interception case. The remaining three scenarios are not affected by transport security because the attacker controls an endpoint that the client already trusts.

Severity

The vulnerability is assigned a CVSS 3.1 base score of 7.5 (High):

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
MetricValueRationale
Attack VectorNetworkDelivered over a standard TCP PostgreSQL connection
Attack ComplexityLowSingle four-byte modification to a single message; no timing or race conditions required
Privileges RequiredNoneThe attacker controls the server side of the connection
User InteractionNoneTriggered automatically when the client executes any query returning rows
ScopeUnchangedImpact confined to the client process
Confidentiality ImpactNoneNo data is disclosed
Integrity ImpactNoneNo data is corrupted
Availability ImpactHighImmediate, unconditional, unrecoverable process termination

CWE-129: Improper Validation of Array Index.

Remediation

The fix requires a single additional condition in the bounds check. The existing check:

if len(src[rp:]) < msgSize {
    return &invalidMessageFormatErr{messageType: "DataRow"}
}

must be updated to:

if msgSize < 0 || len(src[rp:]) < msgSize {
    return &invalidMessageFormatErr{messageType: "DataRow"}
}

Placing the msgSize < 0 condition first ensures it short-circuits before the length comparison, and prevents any arithmetic using msgSize from being evaluated when the value is negative. The complete corrected block for the field-read loop becomes:

msgSize := int(int32(binary.BigEndian.Uint32(src[rp:])))
rp += 4

if msgSize == -1 {
    dst.Values[i] = nil
} else if msgSize < 0 || len(src[rp:]) < msgSize {
    return &invalidMessageFormatErr{messageType: "DataRow"}
} else {
    dst.Values[i] = src[rp : rp+msgSize : rp+msgSize]
    rp += msgSize
}

As a complementary measure, adding a test case that supplies 0x80000000 (the most negative int32) and 0xFF000000 (a large negative value) as field lengths to DataRow.Decode would prevent regression and document the expected behaviour explicitly.

Disclosure

This vulnerability has been reported to the maintainer via a GitHub issue in the jackc/pgproto3 repository. No patch has been released at the time of publication. Users are advised to monitor the repository and update to a fixed version as soon as one becomes available.

Conclusion

The vulnerability in pgproto3/v2 DataRow.Decode is a textbook example of the incompleteness that can arise when a protocol's sentinel value is handled as a special case while the broader set of invalid values is left unguarded. The null sentinel -1 is correctly identified and handled. The 2,147,483,646 other negative integers that the wire protocol never legitimately produces are silently permitted to pass through the bounds check — because no non-negative integer is less than a negative one, and the bounds check was written assuming it would only ever see non-negative inputs.

The result is a one-packet denial-of-service against any Go application using this library to connect to a PostgreSQL server. The fix is a single conditional expression. The lesson is a familiar one in binary protocol security: when a field is typed as a signed integer but semantically constrained to non-negative values, that constraint must be explicitly enforced in code — not assumed from context.