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())

Open
#2,032 0 comments 4 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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 (Alpine php84)
  • ext-opentelemetry 1.2.1 (current on PECL), 1.4.0, and main @ 991d3279
  • open-telemetry/api 1.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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.