Skip to content

fix: memory-safety bugs reachable from ordinary PHP - #12

Open
somethingwithproof wants to merge 14 commits into
php:masterfrom
somethingwithproof:fix/memory-safety
Open

fix: memory-safety bugs reachable from ordinary PHP#12
somethingwithproof wants to merge 14 commits into
php:masterfrom
somethingwithproof:fix/memory-safety

Conversation

@somethingwithproof

@somethingwithproof somethingwithproof commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Five ways to reach librrd through a bad pointer, all from ordinary PHP. Each fix has a test; every one of the reproducers below segfaults on master and is clean after.

RRDCreator::__construct($path) (rrd_create.c:107) declares zend_string *start_time; and never initialises it. With one argument zend_parse_parameters does not write it, so if (start_time) ... estrdup(ZSTR_VAL(start_time)) dereferences whatever the stack held and copies it into --start. valgrind on master:

==6== Use of uninitialised value of size 8
==6==    at 0x488E748: strlen (vg_replace_strmem.c:505)
==6==    by 0x6366B3: _estrdup
==6==    by 0x6DD477B: zim_RRDCreator___construct (rrd_create.c:135)

With the stack slot primed by an earlier three-argument call, the bytes it reads come back out through the exception: Exception: start time: unparsable time: p!.

RRDUpdater::update(array(1.0))ZEND_HASH_FOREACH_STR_KEY_VAL yields a NULL zend_string for an integer key, and ZSTR_VAL dereferences it. A list array is an easy mistake to make; it should be an exception, not a crash.

(new RRDCreator($p))->save() with no addDataSource/addArchivephp_array_merge gets Z_ARRVAL of an IS_UNDEF zval.

Any of the three classes via newInstanceWithoutConstructor()file_path is NULL and goes straight to strlen().

Timestamps wider than ten digits. rrd_fetch and rrd_xport format into char str_timestamp[11] with ZEND_LTOA, whose non-Windows definition is

int st = snprintf((s), (len), ZEND_LONG_FMT, (i)); \
(s)[st] = '\0';

snprintf returns the length it wanted, so once a timestamp needs more than ten digits the terminator lands past the array. rrd_xport's time_index is a time_t taken from the caller's --end, which puts the offset under the caller's control. Under gdb on master, str_timestamp is at 0xffffffffbc10 and the time_data zval at 0xffffffffbc20; a 17-digit timestamp writes at +17 and rewrites the zval's pointer from 0xfffff5e572a0 to 0xfffff5e500a0, and the next add_assoc_double segfaults. (PHP removed ZEND_LTOA in master "as it was unsafe" — UPGRADING.INTERNALS.)

rrd_fetch has the same buffer plus a 32-bit unsigned loop counter against 64-bit start/end. An --end past 2^32 wraps it, the loop stops terminating, and datap walks off librrd's allocation — measured at the fault, ds_data is 8016 bytes and datap is 23752 bytes in, with the leaked doubles landing in the returned array. A zero step would spin the same loop forever.

The buffers become MAX_LENGTH_OF_LONG + 1 with a plain snprintf, the counter becomes time_t, and a zero step reports no data.

Repeated __construct calls also leaked the previous strings (42 bytes per pair under valgrind), and kept the previous step, data sources and archives so the new file was written from the old object's definition. The constructors now release and reset what they replace.

The new !step guards initially skipped librrd's own free() calls, which sat inside the branch being bypassed. That is fixed here: the frees are hoisted so they run on every path. Forcing step == 0 over 200 calls, before and after:

before   definitely lost: 4,800 bytes in 400 blocks
          indirectly lost: 4,000 bytes in 200 blocks
after    definitely lost: 0 bytes in 0 blocks

Validation

php:8.4-cli with librrd 1.7.2 and macOS with librrd 1.11.0. 28/28 tests pass on both, 0 leaked under TEST_PHP_ARGS=-m, and valgrind -q on the constructor reproducer is silent. Stacked on #11 — without it most of these tests would skip.

Scope note

RRDCreator::save() and RRDGraph::saveVerbose() still lack the open_basedir check their procedural siblings perform. That gap is pre-existing and is closed in the PR stacked directly on this one, which is where it belongs; this branch is scoped to memory safety.


Stack order. This is part of a series, each building on the one before. Merge order:
#11 (test suite) → #12 (memory safety) → #13 (open_basedir, value semantics) → #14 (php-src master) → #15 (librrd argv) → #16 (argument builder) → #17 (autoconf). Commits below this PR's own belong to the ones underneath it.

Without tests/data the SKIPIF blocks skip 16 of the 22 tests, so CI reported green while most of the suite never ran.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Both were written against an older librrd and fail against 1.7.2 and 1.11.0 alike; they were never noticed because the fixtures were missing.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Called with one argument, zend_parse_parameters never writes start_time, so the following `if (start_time)` dereferenced whatever the stack happened to hold and copied it into the --start option.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
An integer key gave RRDUpdater::update() a NULL zend_string, save() on a bare RRDCreator merged undefined zvals, and every method assumed __construct had run; all three reached librrd through a NULL pointer.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
ZEND_LTOA writes its terminator at the return value of snprintf, so an 11-byte buffer overflowed once a timestamp needed more than ten digits, and rrd_fetch's 32-bit loop counter wrapped past the end of librrd's data.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
A second __construct kept the previous step, data sources and archives, so the new file was written from the old object's definition.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The new !ds_data / !data / !step tests declare those locals untrusted, and the hoisted free() reaches them on the same paths, so leaving them uninitialised traded a leak for a free of whatever the stack held.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Both write a file the caller names, and both skipped the check that rrd_create() and RRDGraph::save() already perform.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
convert_to_string() edited the array in place, so a non-string option changed the caller's variable and every by-value copy of it.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
zval_get_string() hands back an empty string with an exception already pending, so the original Error was masked by whatever librrd then reported.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Converting a value runs __toString, which can call __construct to repoint the object past the open_basedir check, or setOptions to free the array being walked.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…t list

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The "was not constructed" guards test for NULL, and rrd_fetch keyed its per-source arrays by name, so duplicate names left the walk short of ds_cnt entries.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
… objects

The duplicate-name case is a crash on master: renaming one ds_nam over another in the header leaves rrd_fetch walking past the end of the array it built.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
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.

1 participant