Negative Field Length Panic in pgproto3/v2 DataRow.Decode

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:
- If the value equals
-1, treat the field as SQL NULL. - If the value is any other negative number, reject the message as malformed.
- 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
| Library | Affected Version | Status |
|---|---|---|
| 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
| Metric | Value | Rationale |
|---|---|---|
| Attack Vector | Network | Delivered over a standard TCP PostgreSQL connection |
| Attack Complexity | Low | Single four-byte modification to a single message; no timing or race conditions required |
| Privileges Required | None | The attacker controls the server side of the connection |
| User Interaction | None | Triggered automatically when the client executes any query returning rows |
| Scope | Unchanged | Impact confined to the client process |
| Confidentiality Impact | None | No data is disclosed |
| Integrity Impact | None | No data is corrupted |
| Availability Impact | High | Immediate, 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.