Legibl docs PHP 0.1.0-dev · Python 0.1.0

PHP transport impersonation

A deployment configuration, not a library feature. PHP's stock ext-curl presents an unmistakably non-browser TLS fingerprint, and many targets refuse it before any captcha is ever shown. The SDK does not implement impersonation — it tells you whether the impersonation you configured actually took effect.

Why this exists

A refusal at the TLS handshake is invisible to a solver: nothing was challenged, so nothing can be solved. If you record that as a solving failure you blame the solver for a transport problem and corrupt the one number the product is built on. So it is separated: transport shape is a deployment concern, captcha handling is the SDK's.

Symptomatically this looks like every request reports no_challenge but nothing works — blocks, 403s, or an interstitial you never see a captcha inside.

How it is done

Preload curl-impersonate's shared library so ext-curl links against it instead of the system libcurl. No application code changes.

LD_PRELOAD=/usr/local/lib/libcurl-impersonate-chrome.so \
CURL_IMPERSONATE=chrome116 \
php your-script.php

That is the whole mechanism. Every request the process makes through libcurl — including the SDK's own calls to the Legibl API — goes out with the chosen browser's handshake.

The constraint that decides your base image

The shared library must match the image's libc

A preloaded .so is dynamically linked against a C library. A musl-linked build will not load into a glibc image, and the reverse is equally true. The published curl-impersonate builds are musl-linked, which makes Alpine PHP the straightforward host.

Base imagelibcPublished build worksWhat to do
php:8.3-alpine, php:7.4-alpine musl yes Drop the .so in and set the two environment variables.
php:8.3-cli, php:8.3-fpm (Debian) glibc no Build curl-impersonate against glibc yourself, or move that workload to an Alpine image.
A distro PHP on Ubuntu/Debian/RHEL glibc no As above.

The failure is quiet in the worst way: the dynamic loader declines to preload a library it cannot use, the process starts normally, and every request goes out with the stock fingerprint while CURL_IMPERSONATE sits in the environment suggesting otherwise. That case has its own status — misconfigured — and you should alert on it.

A working Alpine image

FROM php:8.3-alpine

RUN apk add --no-cache curl ca-certificates

# Take the musl build for your architecture from the curl-impersonate
# project's releases and unpack the shared library into /usr/local/lib.
ARG CURL_IMPERSONATE_TARBALL=libcurl-impersonate-vX.Y.Z.x86_64-linux-musl.tar.gz
COPY ${CURL_IMPERSONATE_TARBALL} /tmp/
RUN tar -xzf /tmp/${CURL_IMPERSONATE_TARBALL} -C /usr/local/lib \
 && rm /tmp/${CURL_IMPERSONATE_TARBALL}

ENV LD_PRELOAD=/usr/local/lib/libcurl-impersonate-chrome.so \
    CURL_IMPERSONATE=chrome116

COPY . /app
WORKDIR /app
CMD ["php", "worker.php"]

Pin the release and the architecture explicitly. An x86_64-linux-musl library on an aarch64 host fails exactly the same way a glibc one does on Alpine, and just as silently.

Verify it took effect

Setting the environment variable and believing you are protected is the likeliest failure here, so the SDK checks a property of the loaded library rather than of the environment: curl-impersonate builds against BoringSSL, while a stock PHP build uses OpenSSL, GnuTLS, NSS or similar. That cannot be faked by exporting a variable.

use Legibl\Impersonation;

Impersonation::isActive();          // bool — BoringSSL present in curl_version()
Impersonation::requestedProfile();  // 'chrome116', or null
Impersonation::status();            // 'active' | 'inactive' | 'misconfigured'
Impersonation::explain();           // human-readable guidance
Impersonation::describe();          // diagnostic array, safe to log
{
  "status": "active",
  "requested_profile": "chrome116",
  "curl_version": "8.1.2",
  "ssl_backend": "BoringSSL"
}

Config::describe() includes the status too, so if you already log the resolved configuration at boot you get this for free.

StatusMeansAction
active BoringSSL is loaded; requests carry the browser handshake. None.
inactive No profile requested and no library loaded. Requests carry the stock PHP fingerprint. Fine if intended. Some targets refuse this before presenting a captcha.
misconfigured A profile was requested but the library is not loaded. You believe you are impersonating a browser and you are not. Check the LD_PRELOAD path, the architecture, and the libc match. This is the case to alert on.

explain() for the misconfigured case says exactly this:

CURL_IMPERSONATE=chrome116 is set but curl-impersonate is not loaded — requests
are going out with the stock PHP fingerprint. Set LD_PRELOAD to the
curl-impersonate shared library built for this image's libc.

What it measurably changes

Verified against tls.peet.ws:

ConfigurationJA4Cipher suitesExtensions
Stock ext-curl t13d3113h2_… 31 13
With the preload t13d1516h2_… 15 16

The preloaded configuration also presents a matching Chrome user agent.

Things to know before enabling it