open-telemetry / open-telemetry/opentelemetry-php
Use-after-free: class name of an observed static method is released without a reference being taken (#[WithSpan] and hook())
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 912
- Forks
- 232
- Avg merge
- 7d 16h
- Merged PRs (30d)
- 4
Description
Describe your environment
- PHP 8.4.24 (
php:8.4-cli-alpine), also reproduced on 8.4.21 (Alpinephp84) - ext-opentelemetry 1.2.1 (current on PECL), 1.4.0, and main @
991d3279 open-telemetry/api1.8.0- opcache enabled,
opentelemetry.attr_hooks_enabled=1 - linux/arm64 and linux/amd64, in Docker
Checked against master: still reproduces. The code involved is unchanged between 1.2.1, 1.4.0 and
main.
This is not specific to #[WithSpan]. Any observed static method hits it, including one observed
through an OpenTelemetry\Instrumentation\hook() registration with no attribute anywhere and no SDK
in play. The hook case also reproduces with opentelemetry.attr_hooks_enabled=0, so that setting
does not contain it.
Steps to reproduce
Self contained repro Dockerfile, everything inlined
# syntax=docker/dockerfile:1.7
FROM php:8.4-cli-alpine
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ARG OTEL_TAG=1.4.0
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS curl tar \
&& curl -sSL "https://github.com/open-telemetry/opentelemetry-php-instrumentation/archive/refs/tags/${OTEL_TAG}.tar.gz" -o /tmp/otel.tgz \
&& tar -xzf /tmp/otel.tgz -C /tmp \
&& cd "/tmp/opentelemetry-php-instrumentation-${OTEL_TAG}/ext" \
&& phpize && ./configure && make -j4 && make install \
&& docker-php-ext-enable opentelemetry \
&& docker-php-ext-install opcache \
&& apk del .build-deps
RUN printf 'opcache.enable_cli=1\nopentelemetry.attr_hooks_enabled=1\n' \
> /usr/local/etc/php/conf.d/otel.ini
WORKDIR /repro
COPY <<'EOF' /repro/composer.json
{ "require": { "open-telemetry/api": "^1.8" } }
EOF
COPY <<'EOF' /repro/AttrTarget.php
<?php
namespace ReproNs;
use OpenTelemetry\API\Instrumentation\WithSpan;
class AttrTarget
{
#[WithSpan]
public static function pingStatic(): int
{
return 1;
}
#[WithSpan]
public function pingInstance(): int
{
return 1;
}
}
EOF
COPY <<'EOF' /repro/HookTarget.php
<?php
namespace ReproNs;
class HookTarget
{
public static function pingStatic(): int
{
return 1;
}
public function pingInstance(): int
{
return 1;
}
}
EOF
COPY <<'EOF' /repro/attr_static.php
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/AttrTarget.php';
\ReproNs\AttrTarget::pingStatic();
echo "survived\n";
EOF
COPY <<'EOF' /repro/attr_instance.php
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/AttrTarget.php';
(new \ReproNs\AttrTarget())->pingInstance();
echo "survived\n";
EOF
COPY <<'EOF' /repro/hook_static.php
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/HookTarget.php';
\OpenTelemetry\Instrumentation\hook(
\ReproNs\HookTarget::class,
'pingStatic',
pre: static function (mixed $target, array $params, ?string $class, string $function): void {},
post: static function (): void {},
);
\ReproNs\HookTarget::pingStatic();
echo "survived\n";
EOF
COPY <<'EOF' /repro/hook_instance.php
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/HookTarget.php';
\OpenTelemetry\Instrumentation\hook(
\ReproNs\HookTarget::class,
'pingInstance',
pre: static function (mixed $target, array $params, ?string $class, string $function): void {},
post: static function (): void {},
);
(new \ReproNs\HookTarget())->pingInstance();
echo "survived\n";
EOF
# Both the declaring file and the calling file. Leaving the caller out lets the literal
# class name there be interned, which hides the fault.
COPY <<'EOF' /repro/blacklist.txt
/repro/AttrTarget.php
/repro/HookTarget.php
/repro/attr_static.php
/repro/attr_instance.php
/repro/hook_static.php
/repro/hook_instance.php
EOF
RUN composer install --no-interaction --quiet
COPY <<'EOF' /usr/local/bin/repro
#!/bin/sh
printf 'PHP %s, ext-opentelemetry %s\n\n' \
"$(php -r 'echo PHP_VERSION;')" "$(php -r 'echo phpversion("opentelemetry");')"
run() {
label=$1
script=$2
interned=$3
if [ "$interned" = "yes" ]; then
USE_ZEND_ALLOC=0 php "/repro/$script" > /dev/null 2>&1
else
USE_ZEND_ALLOC=0 php -d opcache.blacklist_filename=/repro/blacklist.txt "/repro/$script" > /dev/null 2>&1
fi
code=$?
if [ "$code" -eq 0 ]; then
result="survived"
else
result="CRASHED"
fi
printf '%-28s class name interned: %-3s exit=%-4s %s\n' "$label" "$interned" "$code" "$result"
}
run '#[WithSpan] static' attr_static.php yes
run '#[WithSpan] static' attr_static.php no
run 'hook() static' hook_static.php yes
run 'hook() static' hook_static.php no
printf '\ncontrols, same conditions but not static:\n'
run '#[WithSpan] instance' attr_instance.php no
run 'hook() instance' hook_instance.php no
EOF
RUN chmod +x /usr/local/bin/repro
ENTRYPOINT ["/usr/local/bin/repro"]
Build another version with --build-arg OTEL_TAG=1.2.1.
docker build -t otel-static-repro .
docker run --rm otel-static-repro
Each case runs the same script twice; the only difference is whether opcache is allowed to store the
files, which decides whether ce->name is interned. USE_ZEND_ALLOC=0 is set only to make the
failure immediate rather than deferred. The blacklist has to name the calling file as well as the
declaring one, otherwise the literal class name in the caller is interned and nothing fails.
What is the expected behavior?
All six cases print survived and exit 0. Whether opcache happens to have interned a class name
should not affect whether observing a static method is safe, and it should not matter whether the
observation came from an attribute or from hook().
What is the actual behavior?
Both static cases segfault on the first call, reproducibly, on 1.2.1 and 1.4.0 alike. Instance
methods are unaffected either way:
PHP 8.4.24, ext-opentelemetry 1.4.0
#[WithSpan] static class name interned: yes exit=0 survived
#[WithSpan] static class name interned: no exit=139 CRASHED
hook() static class name interned: yes exit=0 survived
hook() static class name interned: no exit=139 CRASHED
controls, same conditions but not static:
#[WithSpan] instance class name interned: no exit=0 survived
hook() instance class name interned: no exit=0 survived
#0 get_meta (p=0xffffe00b2420) at src/malloc/mallocng/meta.h:141
#1 __libc_free (p=0xffffe00b2420) at src/malloc/mallocng/free.c:105
#2 zval_ptr_dtor_nogc (...) at Zend/zend_variables.h:36
#3 observer_end (...) at otel_observer.c:869
#4 zend_observer_fcall_end_prechecked ()
Cause and a fix
func_get_this_or_called_scope takes no reference on the static branch, while the instance branch
does:
if (execute_data->func->op_array.fn_flags & ZEND_ACC_STATIC) {
zend_class_entry *called_scope = zend_get_called_scope(execute_data);
ZVAL_STR(zv, called_scope->name); // no reference taken
} else {
zend_object *this = zend_get_this_object(execute_data);
ZVAL_OBJ_COPY(zv, this); // reference taken
}
Both observer_begin and observer_end fill params[0] from it and then release every param with
zval_dtor, so each observed call drops a reference it never took. That code is shared by attribute
handlers and hook() registrations, which is why both routes fail and why disabling
attr_hooks_enabled does not help. Interned class names absorb the underflow, so the failure only
appears once opcache has not interned the name, which makes it look intermittent and environmental.
Building the same image with the static branch changed to ZVAL_STR_COPY(zv, called_scope->name);
makes all six cases pass.
Additional context
Related but distinct: open-telemetry/opentelemetry-php#2002 (attribute arguments) and #2031
(#[SpanAttribute] parameters) are the same class of defect at other sites. 1.4.0 contains the
#2002 fix and still reproduces the above. #1846 reports matching symptoms.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in otel_observer.c at observer_end and trace the static-method path through func_get_this_or_called_scope, using the reference-handling contrast shown in the report. Build and run the inline Docker reproducer across the static and instance cases with interned and non-interned class names; done means all cases exit 0 without a segmentation fault.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, php
- Domain
- backend, observability-sre
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100