PHP Patch Check

Build a local checker that verifies PHP patch status and remediation evidence.

Introduction

30 Second Summary

A web page can load perfectly while the software behind it still carries a known security flaw. A convincing fix needs evidence that the vulnerable version is gone.

In this project, you will use Docker to build a local-only PHP lab that compares an outdated release with its patched replacement. A harmless Python checker will turn each runtime's version and configuration into a clear patch verdict.

What You'll Build

Your final demo shows a terminal report progressing from NOT PATCHED through NOT PATCHED BUT MITIGATED to PATCHED across the remediation.

By the end of this project, you'll have:

  • A localhost-only comparison lab where two Hello World pages reveal the difference between PHP 8.3.35 and 8.3.36 under Apache mod_php.
  • A read-only patch report that displays each service's version, runtime type, relevant settings, status, and reason.
  • A published remediation trail with before-and-after screenshots, official sources, a responsible write-up, and a public GitHub repository.
  • Secret Mission: Export both checker results as structured JSON evidence for future inventory or compliance workflows.

Are there any prerequisites?

You'll need a Mac with Docker Desktop running. You'll also need Python 3, Visual Studio Code, Git, and a GitHub account.

Before We Start

PHP Patch Check proves remediation through version, SAPI, and limited PHP configuration evidence without exploit code or attack payloads. This checkpoint keeps every lab service bound to 127.0.0.1 on your Mac before any vulnerable runtime starts.

Verify the Mac Toolchain

A security lab needs a local toolchain you can trust. This check keeps the vulnerable runtime from starting on an unknown container engine.

Your workspace gives each kind of evidence a fixed home. The checks below prove that every required local tool responds.

In this step, get ready to:
  • Create the project workspace on your Desktop. Open it in Visual Studio Code.
  • Verify Docker Desktop, Docker Compose, and Git on your Mac.
  • Confirm that Python 3 runs without extra packages.
Create the local workspace

The workspace separates application files from configuration. It also gives your screenshots and future machine-readable evidence dedicated folders.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Enter to open it.

You should see a Terminal window with a command prompt ready for input.

  • Create the php-patch-check workspace on your Desktop by running these commands:
cd ~/Desktop
mkdir -p php-patch-check/app php-patch-check/config php-patch-check/docs php-patch-check/evidence
cd php-patch-check
ls

What do these commands do?

  • The first cd command moves the Terminal session to your Desktop.
  • The mkdir command creates the workspace. Its -p option also creates the four child folders.
  • The second cd command moves the Terminal session into php-patch-check.
  • The ls command lists the folders so you can verify the workspace immediately.

You should see four folder names: app, config, docs, and evidence.

  • Open the current php-patch-check workspace in VS Code by running this command:
code .

What does this command do?

The dot represents the current folder. VS Code uses that location to open the complete php-patch-check workspace.

Good progress. The VS Code Explorer should now show app, config, docs, and evidence inside php-patch-check.

Workspace missing or VS Code not opening?

  • Check that the ls output contains all four folder names.
  • If the VS Code command is unavailable, follow the official macOS setup guide to add it to your PATH. Retry the command from inside php-patch-check.
  • Still stuck? Help me create the php-patch-check folders and open the workspace in VS Code on macOS.
Verify Docker and Git locally

The Docker client sends commands to a container engine. The server response and active context prove which engine receives those commands.

  • Switch back to the Terminal window from earlier.
  • Verify the container engine, active context, Compose plugin, and version-control tool by running these commands:
docker version
docker context ls
docker compose version
git --version

What does this check prove?

  • The Client section confirms that the Docker command-line tool responds.
  • The Server section confirms that the local container engine responds.
  • The context list marks the active Docker target with an asterisk. This target must identify the local Docker Desktop engine.
  • The remaining commands print version information for Docker Compose and Git.
  • Find the active entry in the Docker context list.
  • Confirm that its endpoint belongs to Docker Desktop on your Mac.
  • Confirm that the Docker output contains both Client and Server sections.
  • Confirm that Docker Compose prints its version information.
  • Confirm that Git prints its version information.

The context line can be easy to miss. The active entry has an asterisk beside its name.

Missing the Docker server or local context?

  • If the Server section is missing, switch to Docker Desktop from earlier. Wait for its container engine to finish starting.
  • If a remote context is active, use the context list to identify the local Docker Desktop context. Switch to that context before continuing.
  • If Git does not respond, confirm that the Git command-line tools remain installed on your Mac.
  • Still stuck? Help me verify that Docker is using my local Docker Desktop context.
Confirm Python and the full toolchain

The checker uses only the Python 3 standard library. A working interpreter is enough for this project.

Before you run this, do you expect every tool to respond from inside the same workspace? Make your prediction now.

  • Run the complete toolchain checkpoint from the Terminal window:
docker version
docker context ls
docker compose version
git --version
python3 --version

What should this final check show?

  • Docker prints both Client and Server sections.
  • The Docker context list marks the local Docker Desktop context as active.
  • Docker Compose prints its version information.
  • Git prints its version information.
  • Python prints its version number before returning to the command prompt.

That is the setup complete. Your workspace is open in VS Code, and every required local tool now responds from your Mac.

Python not responding?

  • Check that you typed python3 with the number at the end.
  • If macOS requests access to developer tools, complete the prompt before retrying the check.
  • Still stuck? Help me diagnose why Python 3 is not responding on my Mac.

Pause Checkpoint

  • Continue only when VS Code shows all four workspace folders. Confirm that every toolchain command completes successfully.

Your local toolchain is ready. Next, you will use it to build the two-service PHP comparison lab with loopback-only ports.

Build the Local-Only PHP Lab

Your local toolchain is ready. Now the lab needs a hard network boundary before the deliberately old PHP runtime starts.

Two Apache mod_php services in Docker Desktop create a controlled comparison. The application code stays read-only inside both containers.

One service uses PHP 8.3.35. The other uses PHP 8.3.36. This difference lets the same harmless observation reveal whether an upstream release is patched.

In this step, get ready to:
  • Define the old and patched PHP services with loopback-only port bindings.
  • Create a Hello World page plus a minimal audit endpoint.
  • Start both containers and prove that they remain local to your Mac.
Configure the loopback-only services

Docker Compose can read an image version from .env when it resolves compose.yaml. Quoted 127.0.0.1 mappings keep both host ports on loopback.

  • Use the new-file icon beside the php-patch-check folder in the Explorer sidebar to create .env.

You'll see .env at the top level of the workspace.

  • Set the initial old PHP release by pasting this value into .env:
OLD_PHP_VERSION=8.3.35

What does this value control?

The OLD_PHP_VERSION value supplies the version segment for the old image. The service definition can now use one explicit version source.

  • Save .env.

The workspace now pins the deliberately old service to PHP 8.3.35.

  • Use the new-file icon beside the php-patch-check folder to create compose.yaml.

You'll see compose.yaml beside .env in the workspace.

  • Define both PHP services by pasting this configuration into compose.yaml:
services:
  old:
    image: "php:${OLD_PHP_VERSION}-apache"
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - type: bind
        source: ./app
        target: /var/www/html
        read_only: true
      - type: bind
        source: ./config/old.ini
        target: /usr/local/etc/php/conf.d/php-patch-check.ini
        read_only: true

  new:
    image: "php:8.3.36-apache"
    ports:
      - "127.0.0.1:8082:80"
    volumes:
      - type: bind
        source: ./app
        target: /var/www/html
        read_only: true

What does this configuration do?

  • The old service reads its image version from .env.
  • The new service stays pinned to php:8.3.36-apache.
  • Both host mappings begin with 127.0.0.1 to prevent access through other network interfaces.
  • Each application bind mount uses read_only: true to prevent container-side edits.
  • Save compose.yaml.
  • Switch back to the Terminal window from earlier.
  • Resolve the service model by running this command:
docker compose config

What does this command prove?

This command resolves the variable from .env before rendering the service model. It gives you a chance to catch an image or port mistake before any container starts.

You'll see the old image resolve to php:8.3.35-apache. The new image remains php:8.3.36-apache.

Configuration not resolving?

  • Confirm that .env sits beside compose.yaml.
  • Check that OLD_PHP_VERSION uses the same spelling in both files.
  • Still stuck? Help me diagnose why my Compose model is not resolving the PHP image version.
  • Use the new-file icon beside the config folder to create config/old.ini.
  • Keep the baseline configuration comment-only by pasting this line:
; Final patched state. Temporary PHP2Shell stopgaps are no longer required.

Why keep this file comment-only?

The file gives the bind mount a real source without overriding the image's initial PHP settings. This preserves a clean baseline for the first comparison.

  • Save config/old.ini.

The config folder now contains the mounted INI file required by the old service.

Create the web pages

Both containers serve the same app folder. A shared Hello World page proves that each image runs the same application.

A minimal JSON audit endpoint exposes only the evidence needed by the checker. This keeps the local response smaller than a full phpinfo() disclosure.

  • Use the new-file icon beside the app folder to create app/index.php.
  • Build the shared Hello World page by pasting this code:
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>PHP Patch Check</title>
</head>
<body>
    <h1><?php echo "Hello World"; ?></h1>
    <p>PHP Patch Check local lab</p>
</body>
</html>

What does this page show?

  • The document metadata gives the page a responsive browser layout.
  • The PHP expression prints Hello World through the web-serving runtime.
  • The final paragraph identifies the page as the PHP Patch Check local lab.
  • Save app/index.php.

The first container start may take a while because Docker needs to download both image versions. Quiet download periods are expected during this first run.

  • Switch back to the Terminal window from earlier.
  • Start both services in detached mode by running this command:
docker compose up -d

What does detached mode do?

Detached mode leaves both services running in the background. Your Terminal remains available for later checks.

You'll see Docker create the project network. You'll also see it start the old and new services.

Containers not starting?

  • Confirm that Docker Desktop still shows its local engine as running.
  • Check that config/old.ini exists before the old service starts.
  • Still stuck? Help me diagnose why my two PHP Apache containers are not starting.
  • Use your Mac's application search to open your web browser.
  • Enter http://127.0.0.1:8081 in the browser address bar.

You'll see Hello World above PHP Patch Check local lab.

Your first service is live. The old PHP image now serves the shared page through loopback only.

  • Switch back to the php-patch-check workspace in VS Code.
  • Use the new-file icon beside the app folder to create app/audit.php.
  • Build the read-only audit response by pasting this code:
<?php
$settings = [
    'session.upload_progress.enabled' => ini_get('session.upload_progress.enabled'),
    'memory_limit' => ini_get('memory_limit'),
    'post_max_size' => ini_get('post_max_size'),
];

if (PHP_VERSION_ID >= 80500) {
    $settings['fatal_error_backtraces'] = ini_get('fatal_error_backtraces');
}

$report = [
    'version' => PHP_VERSION,
    'sapi' => PHP_SAPI,
    'settings' => $settings,
];

echo json_encode(
    $report,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);

What does this endpoint expose?

  • The settings array reads three effective PHP configuration values.
  • The version condition includes fatal_error_backtraces only for PHP 8.5 or newer.
  • The report array records PHP_VERSION plus the web SAPI.
  • The final encoder returns readable JSON without exposing the full PHP configuration.
  • Save app/audit.php.
  • Return to your web browser.
  • Enter http://127.0.0.1:8081/audit.php in the address bar.

You'll see 8.3.35 under version. You'll see apache2handler under sapi.

The settings object contains only session.upload_progress.enabled, memory_limit, and post_max_size for this PHP 8.3 service.

Audit response not appearing?

  • Confirm that the file is named audit.php inside the app folder.
  • Compare every bracket in app/audit.php with the code above.
  • Still stuck? Help me troubleshoot my minimal PHP audit endpoint.

✔️ Awesome, I've got everything!

The five source files now form the complete local lab definition.

  • Save all five files in VS Code.

ⓧ I'd like to double check the full code

OLD_PHP_VERSION=8.3.35

Check the version pin

This reference should contain one variable assignment for PHP 8.3.35.

services:
  old:
    image: "php:${OLD_PHP_VERSION}-apache"
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - type: bind
        source: ./app
        target: /var/www/html
        read_only: true
      - type: bind
        source: ./config/old.ini
        target: /usr/local/etc/php/conf.d/php-patch-check.ini
        read_only: true

  new:
    image: "php:8.3.36-apache"
    ports:
      - "127.0.0.1:8082:80"
    volumes:
      - type: bind
        source: ./app
        target: /var/www/html
        read_only: true

Check the service boundary

Both port mappings should begin with 127.0.0.1. Both application mounts should remain read-only.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>PHP Patch Check</title>
</head>
<body>
    <h1><?php echo "Hello World"; ?></h1>
    <p>PHP Patch Check local lab</p>
</body>
</html>

Check the visible page

This complete page should contain the Hello World heading plus the local lab label.

<?php
$settings = [
    'session.upload_progress.enabled' => ini_get('session.upload_progress.enabled'),
    'memory_limit' => ini_get('memory_limit'),
    'post_max_size' => ini_get('post_max_size'),
];

if (PHP_VERSION_ID >= 80500) {
    $settings['fatal_error_backtraces'] = ini_get('fatal_error_backtraces');
}

$report = [
    'version' => PHP_VERSION,
    'sapi' => PHP_SAPI,
    'settings' => $settings,
];

echo json_encode(
    $report,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);

Check the audit surface

This complete endpoint should expose the version, web SAPI, and requested settings only.

; Final patched state. Temporary PHP2Shell stopgaps are no longer required.

Check the baseline settings

This reference should contain one comment. It should not override any PHP setting yet.

Prove the lab stays local

A page loading successfully proves that PHP is running. The resolved model plus the container status table proves where each service is exposed.

Before the final check, which host address do you expect each service to show.

  • Resolve the model before starting both services and listing their status by running these commands:
docker compose config
docker compose up -d
docker compose ps

What do these checks cover?

  • The first command resolves the image versions plus the loopback mappings.
  • The second command ensures both services are running in detached mode.
  • The third command lists each container's status plus its published ports.

You'll see php:8.3.35-apache and php:8.3.36-apache in the resolved model.

You'll see both services running in the status table. The PORTS column shows 127.0.0.1 as the host address for each service.

  • Return to your web browser.
  • Enter http://127.0.0.1:8081 in the address bar.

You'll see the Hello World page served by the old PHP service.

  • Enter http://127.0.0.1:8082 in the address bar.

You'll see the same Hello World page served by the patched PHP service.

  • Enter http://127.0.0.1:8081/audit.php in the address bar.

You'll see the small audit object report PHP 8.3.35 with apache2handler as its web SAPI.

  • Enter http://127.0.0.1:8082/audit.php in the address bar.

You'll see the same audit shape report PHP 8.3.36.

Missing a page or audit field?

  • Confirm that the status table still shows both services as running.
  • Compare the app/index.php and app/audit.php filenames with the browser paths.
  • Still stuck? Help me verify my localhost-only PHP lab without exposing it beyond my Mac.

That's the safety boundary proven. Both PHP services are live only through your Mac's loopback interface.

Your side-by-side lab now exposes the evidence needed for comparison. Next, you'll turn that data into a patch-status report.

Build the Patch Checker

Your local lab now proves that both services can render the same Hello World page. That visible success says nothing about their PHP patch status.

A Python checker closes that evidence gap. It reads the minimal JSON audit data over HTTP without sending attack payloads.

In this step, get ready to:
  • Define the checker inputs plus upstream patch thresholds.
  • Classify each runtime against its branch threshold.
  • Capture the initial comparison as screenshot evidence.
Define the targets and parsing helpers

The checker needs two inputs before it can make a finding. It needs the local audit URLs plus the fixed release for each supported branch.

Why inspect the web endpoint?

A command-line PHP process can use a different Server API from the runtime serving web requests. The local audit endpoint reports the version plus SAPI from the web runtime itself.

The endpoint also limits its response to the settings required by this checker. That keeps the evidence focused.

  • Create checker.py inside the php-patch-check folder by using the New File button in the VS Code Explorer sidebar.
  • Add the imports plus checker inputs to checker.py by pasting this code:
import json
import urllib.request

TARGETS = {
    "OLD": "http://127.0.0.1:8081/audit.php",
    "NEW": "http://127.0.0.1:8082/audit.php",
}

PATCHED_RELEASES = {
    (8, 2): (8, 2, 35),
    (8, 3): (8, 3, 36),
    (8, 4): (8, 4, 27),
    (8, 5): (8, 5, 12),
}

What does this code do?

  • The imports provide the standard-library HTTP client plus JSON parser.
  • The TARGETS dictionary names the two loopback-only audit endpoints.
  • The PATCHED_RELEASES dictionary maps each verified branch to its first fixed release.
  • Save checker.py.
  • Confirm the PHP 8.3 threshold by running this command:
python3 -c "import checker; print(checker.PATCHED_RELEASES[(8, 3)])"

What does this check do?

The command imports your new checker module. It prints the stored fixed release for the PHP 8.3 branch.

You should see (8, 3, 36). This proves that the threshold table is readable by Python.

Checker module not found?

Confirm that the VS Code Explorer shows checker.py directly inside php-patch-check. Run the command from the terminal that is already using that folder.

Still stuck? Help me import checker.py from my project folder.

Raw configuration values need normalization before they can support a reliable decision. The next helpers convert version strings plus PHP size values into comparable Python data.

  • Add the audit-fetching plus parsing helpers below PATCHED_RELEASES by pasting this code:
def fetch_audit(url):
    with urllib.request.urlopen(url, timeout=3) as response:
        return json.load(response)


def parse_version(value):
    return tuple(int(part) for part in value.split(".")[:3])


def parse_php_size(value):
    text = value.strip()
    if text == "-1":
        return -1

    units = {"K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
    suffix = text[-1].upper()
    if suffix in units:
        return int(text[:-1]) * units[suffix]
    return int(text)


def is_disabled(value):
    return value.strip().lower() in {"", "0", "off", "false", "no"}

How do these helpers prepare the data?

  • The fetch_audit() helper opens one local URL with a three-second timeout. It parses the response directly into Python data.
  • The parse_version() helper converts the first three version components into a tuple.
  • The parse_php_size() helper converts PHP size suffixes into bytes. It preserves -1 as the unlimited-memory value.
  • The is_disabled() helper recognizes the different values that PHP can use for an off setting.
  • Save checker.py.
  • Verify the PHP size parser by running this command:
python3 -c "import checker; print(checker.parse_php_size('8M'))"

What does this parser check prove?

The command passes 8M through parse_php_size(). The helper converts that value into bytes for the mitigation comparison.

You should see 8388608. The checker can now compare PHP size settings numerically.

Seeing a parser error?

Check that parse_php_size() is aligned with fetch_audit() at the left edge of checker.py. Confirm that the units dictionary uses uppercase suffixes.

Need another pair of eyes? Help me debug the parsing helpers in checker.py.

Classify each runtime

A patch finding depends on more than the displayed version. The checker also labels the web SAPI plus checks whether every documented stopgap required for this lab is present.

  • Add the runtime-labeling plus mitigation helpers below is_disabled() by pasting this code:
def runtime_label(sapi):
    if sapi in {"apache", "apache2handler"}:
        return f"{sapi} (mod_php)"
    if sapi == "fpm-fcgi":
        return f"{sapi} (PHP-FPM)"
    return sapi


def mitigations_present(version, settings):
    post_size = parse_php_size(settings["post_max_size"])
    checks = [
        settings["memory_limit"] == "-1",
        0 <= post_size <= 8 * 1024 ** 2,
        is_disabled(settings["session.upload_progress.enabled"]),
    ]

    if version[:2] == (8, 5):
        checks.append(is_disabled(settings.get("fatal_error_backtraces", "")))

    return all(checks)

How does the mitigation check work?

  • The runtime_label() helper makes recognized Apache plus FPM SAPI values easier to read.
  • The mitigations_present() helper checks the unlimited memory setting.
  • The same helper checks the maximum POST size plus disabled session upload progress.
  • PHP 8.5 receives an additional fatal-error backtrace check.
  • The final all(checks) result becomes true only when every required check passes.
  • Save checker.py.
  • Verify the Apache runtime label by running this command:
python3 -c "import checker; print(checker.runtime_label('apache2handler'))"

What does this runtime check prove?

The command passes the Apache SAPI value through runtime_label(). It tests the same normalization that the final report uses.

You should see apache2handler (mod_php). The report can now identify the runtime serving the web request.

Runtime label unchanged?

Confirm that the Apache branch checks both apache plus apache2handler. Check the capitalization of runtime_label().

Still seeing the raw value? Help me debug runtime_label().

The evaluator applies the three report labels in a strict order. A fixed upstream release wins first. Complete stopgaps create an explicit middle state.

  • Add the initial evaluate() function below mitigations_present() by pasting this code:
def evaluate(data):
    version = parse_version(data["version"])
    branch = version[:2]
    threshold = PATCHED_RELEASES.get(branch)

    if threshold is not None and version >= threshold:
        return (
            "PATCHED",
            f"PHP {data['version']} meets the patched {threshold[0]}.{threshold[1]}.{threshold[2]} release for its branch.",
        )

    if mitigations_present(version, data["settings"]):
        return (
            "NOT PATCHED BUT MITIGATED",
            f"PHP {data['version']} is below the upstream fixed release, but the checked stopgaps are present; updating is still the only complete fix.",
        )

    if branch <= (8, 1):
        return (
            "NOT PATCHED",
            f"PHP {data['version']} is on a branch with no upstream fixed release; a vendor backport cannot be proven from this version string.",
        )

    return (
        "NOT PATCHED",
        f"PHP {data['version']} is below the patched {threshold[0]}.{threshold[1]}.{threshold[2]} release and the checked stopgaps are incomplete.",
    )

How does the evaluator decide?

  • A version at or above its branch threshold receives PATCHED.
  • An older version with every checked stopgap receives NOT PATCHED BUT MITIGATED.
  • PHP 8.1 plus older branches receive NOT PATCHED with a vendor-backport limitation.
  • A supported branch below its threshold receives NOT PATCHED when the stopgaps are incomplete.
  • Save checker.py.

Both pages render successfully. Before you run this check, do you think their patch statuses will match?

  • Compare the two live audit responses by running this command:
python3 -c "import checker; print(checker.evaluate(checker.fetch_audit(checker.TARGETS['OLD']))[0]); print(checker.evaluate(checker.fetch_audit(checker.TARGETS['NEW']))[0])"

What does this comparison do?

The command fetches both local audit responses. It sends each response through evaluate() before printing only the status.

The first line should show NOT PATCHED. The second line should show PATCHED.

You have exposed the intended shortfall: a working Hello World page can still sit on an affected runtime. The neighboring service proves that the same application can run on the fixed release.

Audit comparison failed?

Switch back to Docker Desktop from earlier to confirm that the local engine is still running. Confirm that both containers remain active before retrying the command.

If one endpoint cannot be reached, check that the old service still uses port 8081. Check that the new service still uses port 8082.

Need help tracing the failed check? Help me debug the local audit comparison.

The verified threshold table covers PHP branches 8.2 through 8.5. An unrecognized branch needs its own reason before the supported-branch fallback tries to display a missing threshold.

  • In checker.py, locate this final fallback inside evaluate():
    return (
        "NOT PATCHED",
        f"PHP {data['version']} is below the patched {threshold[0]}.{threshold[1]}.{threshold[2]} release and the checked stopgaps are incomplete.",
    )

What is this fallback for?

This return handles supported branches that fall below a known threshold. It belongs after the older-branch plus unknown-branch checks.

  • Insert this unknown-branch block directly above the final fallback:
    if threshold is None:
        return (
            "NOT PATCHED",
            f"PHP {data['version']} is outside this checker's verified 8.2 through 8.5 branch table.",
        )

Why add an unknown-branch result?

The checker makes no patch claim for a branch outside its verified table. It returns NOT PATCHED with a reason that states the limitation.

  • Save checker.py.
  • Verify the unknown-branch result by running this command:
python3 -c "import checker; print(checker.evaluate({'version': '9.0.0', 'settings': {'memory_limit': '128M', 'post_max_size': '8M', 'session.upload_progress.enabled': '1'}}))"

What does this boundary check prove?

The command supplies a synthetic version outside the verified table. It confirms that evaluate() returns a limitation instead of trying to use a missing threshold.

You should see NOT PATCHED plus a reason that PHP 9.0.0 is outside the verified branch table.

Seeing a missing-threshold error?

Confirm that the if threshold is None: block sits above the final supported-branch return. Keep its indentation aligned with the other conditions inside evaluate().

Still seeing an exception? Help me place the unknown-branch condition.

Capture the initial patch report

The classification result now needs a human-readable report. Each service section will show its version, normalized runtime, returned settings, status plus plain-English reason.

  • Add print_report() below evaluate() by pasting this code:
def print_report(report):
    print(f"=== {report['name']} ===")
    print(f"PHP version: {report['version']}")
    print(f"Runtime: {report['runtime']}")
    for name, value in report["settings"].items():
        shown = value if value != "" else "(empty)"
        print(f"{name}: {shown}")
    print(f"Status: {report['status']}")
    print(f"Why: {report['reason']}")
    print()

How is the report formatted?

  • The service name becomes a visible heading.
  • The version plus normalized runtime appear before the settings.
  • Every setting returned by the audit endpoint receives its own line.
  • An empty setting appears as (empty).
  • The status plus reason close each service section.
  • Save checker.py.
  • Preview the report format by running this command:
python3 -c "import checker; checker.print_report({'name': 'TEST', 'version': '8.3.36', 'runtime': 'apache2handler (mod_php)', 'settings': {}, 'status': 'PATCHED', 'reason': 'Formatting check.'})"

What does this preview test?

The command passes a small test report into print_report(). It checks the terminal layout before live endpoint data is connected.

You should see a TEST heading plus the supplied version, runtime, status plus reason.

Report fields missing?

Confirm that each field name inside print_report() matches the test report keys. Keep the settings loop inside the function.

Need help with the output layout? Help me debug print_report().

The final control flow fetches each target once. It builds a report dictionary before handing that dictionary to the formatter.

  • Add main() plus the script entry point below print_report() by pasting this code:
def main():
    reports = []

    for name, url in TARGETS.items():
        try:
            data = fetch_audit(url)
            status, reason = evaluate(data)
            report = {
                "name": name,
                "version": data["version"],
                "sapi": data["sapi"],
                "runtime": runtime_label(data["sapi"]),
                "settings": data["settings"],
                "status": status,
                "reason": reason,
            }
            reports.append(report)
            print_report(report)
        except Exception as error:
            print(f"=== {name} ===")
            print(f"Status: CHECK FAILED")
            print(f"Why: {error}")
            print()

    return reports


if __name__ == "__main__":
    main()

How does the full check run?

  • The main() function loops through OLD plus NEW.
  • Each audit response passes through the evaluator.
  • The report dictionary retains the raw SAPI plus normalized runtime.
  • A failed request receives CHECK FAILED with its error reason.
  • The entry-point condition runs main() when you execute checker.py directly.

✔️ Awesome, I've got everything!

Your checker now contains the complete audit, classification plus reporting flow.

ⓧ I'd like to double check the full code

import json
import urllib.request

TARGETS = {
    "OLD": "http://127.0.0.1:8081/audit.php",
    "NEW": "http://127.0.0.1:8082/audit.php",
}

PATCHED_RELEASES = {
    (8, 2): (8, 2, 35),
    (8, 3): (8, 3, 36),
    (8, 4): (8, 4, 27),
    (8, 5): (8, 5, 12),
}


def fetch_audit(url):
    with urllib.request.urlopen(url, timeout=3) as response:
        return json.load(response)


def parse_version(value):
    return tuple(int(part) for part in value.split(".")[:3])


def parse_php_size(value):
    text = value.strip()
    if text == "-1":
        return -1

    units = {"K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
    suffix = text[-1].upper()
    if suffix in units:
        return int(text[:-1]) * units[suffix]
    return int(text)


def is_disabled(value):
    return value.strip().lower() in {"", "0", "off", "false", "no"}


def runtime_label(sapi):
    if sapi in {"apache", "apache2handler"}:
        return f"{sapi} (mod_php)"
    if sapi == "fpm-fcgi":
        return f"{sapi} (PHP-FPM)"
    return sapi


def mitigations_present(version, settings):
    post_size = parse_php_size(settings["post_max_size"])
    checks = [
        settings["memory_limit"] == "-1",
        0 <= post_size <= 8 * 1024 ** 2,
        is_disabled(settings["session.upload_progress.enabled"]),
    ]

    if version[:2] == (8, 5):
        checks.append(is_disabled(settings.get("fatal_error_backtraces", "")))

    return all(checks)


def evaluate(data):
    version = parse_version(data["version"])
    branch = version[:2]
    threshold = PATCHED_RELEASES.get(branch)

    if threshold is not None and version >= threshold:
        return (
            "PATCHED",
            f"PHP {data['version']} meets the patched {threshold[0]}.{threshold[1]}.{threshold[2]} release for its branch.",
        )

    if mitigations_present(version, data["settings"]):
        return (
            "NOT PATCHED BUT MITIGATED",
            f"PHP {data['version']} is below the upstream fixed release, but the checked stopgaps are present; updating is still the only complete fix.",
        )

    if branch <= (8, 1):
        return (
            "NOT PATCHED",
            f"PHP {data['version']} is on a branch with no upstream fixed release; a vendor backport cannot be proven from this version string.",
        )

    if threshold is None:
        return (
            "NOT PATCHED",
            f"PHP {data['version']} is outside this checker's verified 8.2 through 8.5 branch table.",
        )

    return (
        "NOT PATCHED",
        f"PHP {data['version']} is below the patched {threshold[0]}.{threshold[1]}.{threshold[2]} release and the checked stopgaps are incomplete.",
    )


def print_report(report):
    print(f"=== {report['name']} ===")
    print(f"PHP version: {report['version']}")
    print(f"Runtime: {report['runtime']}")
    for name, value in report["settings"].items():
        shown = value if value != "" else "(empty)"
        print(f"{name}: {shown}")
    print(f"Status: {report['status']}")
    print(f"Why: {report['reason']}")
    print()


def main():
    reports = []

    for name, url in TARGETS.items():
        try:
            data = fetch_audit(url)
            status, reason = evaluate(data)
            report = {
                "name": name,
                "version": data["version"],
                "sapi": data["sapi"],
                "runtime": runtime_label(data["sapi"]),
                "settings": data["settings"],
                "status": status,
                "reason": reason,
            }
            reports.append(report)
            print_report(report)
        except Exception as error:
            print(f"=== {name} ===")
            print(f"Status: CHECK FAILED")
            print(f"Why: {error}")
            print()

    return reports


if __name__ == "__main__":
    main()
  • Save checker.py.

Before you run the complete report, do you expect the identical Hello World pages to receive the same status?

  • Run the complete checker from the php-patch-check terminal by using this command:
python3 checker.py

What does the final command verify?

The command fetches both live audit endpoints. It prints the version, normalized runtime, returned settings, status plus reason for each service.

OLD should report PHP 8.3.35 with apache2handler (mod_php) plus NOT PATCHED.

NEW should report PHP 8.3.36 with apache2handler (mod_php) plus PATCHED.

That comparison is working: your checker now proves that application availability does not establish patch status. It also identifies the web runtime behind each result.

Seeing CHECK FAILED?

Confirm that both local containers from the previous step remain running. Check that the loopback audit pages still return their small JSON objects.

If the report stops with a key error, compare the version, sapi plus settings names with app/audit.php.

Still stuck? Help me debug the complete PHP patch checker.

The initial report is your before evidence. Capture the status contrast while both complete service sections are visible in Terminal.

  • Press Shift-Command-5 to open macOS Screenshot.
  • Use the capture controls to select the Terminal window.
  • Use the save controls to store the image as docs/before.png.
  • Confirm that docs/before.png appears in the VS Code Explorer sidebar.

Your screenshot now records the affected old service beside its patched neighbor. That gives you a clear baseline for the remediation proof.

Your checker has exposed the patch gap without exploiting either service. Next, you will demonstrate the temporary middle state before upgrading the old runtime to the complete fix.

Mitigate, Upgrade, and Prove the Fix

Your checker now exposes the gap hidden behind a working Hello World page. The old PHP service reports NOT PATCHED while the neighboring service reports PATCHED.

The temporary settings reduce the specific checked paths. The old runtime still needs the upstream update for complete remediation.

In this step, get ready to:
  • Apply the temporary stopgaps to the old service.
  • Upgrade the old runtime to PHP 8.3.36.
  • Capture evidence that both services are patched.
Apply the temporary stopgaps

The old container reads its effective settings from config/old.ini. You can use that file to test the documented stopgaps without changing the checker.

  • In the VS Code Explorer sidebar, select config/old.ini.
  • Find the current comment:
; Final patched state. Temporary PHP2Shell stopgaps are no longer required.

What is the current file doing?

This comment records the desired final state. It does not change any PHP settings.

  • Replace the entire file with these temporary settings:
memory_limit=-1
post_max_size=8M
session.upload_progress.enabled=0

What do these settings check?

  • The memory_limit=-1 value removes the finite PHP memory limit checked by this lab.
  • The post_max_size=8M value limits the accepted POST size to the documented stopgap value.
  • The session.upload_progress.enabled=0 value disables the confirmed session upload-progress path.
  • Save config/old.ini.
  • Recreate only the old container by running this command:
docker compose up -d --force-recreate old

What does recreation prove?

Docker recreates the container from its current configuration. The restart makes the old Apache worker read the temporary settings from config/old.ini.

You'll see the old container recreated. Its loopback-only endpoint remains available at the same local address.

Before you rerun the checker, do you expect configuration changes alone to make OLD fully patched?

  • Evaluate both services again by running this command:
python3 checker.py

How is the middle state detected?

The checker reads the old service's effective settings through the local audit endpoint. It gives the mitigation label only when every required stopgap is present.

You'll see OLD remain on PHP 8.3.35 with apache2handler (mod_php). Its status changes to NOT PATCHED BUT MITIGATED.

That middle label confirms the stopgaps are active. Updating remains the only complete solution.

OLD still reports NOT PATCHED?

  • Confirm that config/old.ini contains all three temporary settings exactly as shown.
  • Recreate the old container again so its Apache worker reads the saved configuration.
  • Still stuck? Help me find why my old PHP container is not detecting the temporary settings.
Upgrade the old runtime

The mitigation result proves that configuration can reduce specific paths. The upstream patch changes the runtime itself.

  • In the VS Code Explorer sidebar, select .env.
  • Find the current image version:
OLD_PHP_VERSION=8.3.35

What does this value control?

The OLD_PHP_VERSION value supplies the version used by the old service's image tag. The running container keeps its current version until you recreate it.

  • Replace the line with the patched release:
OLD_PHP_VERSION=8.3.36

What changes after this edit?

The old service now resolves to the same patched upstream release as the new service. Container recreation applies that image version.

  • Save .env.
  • Recreate the old container with the patched image by running this command:
docker compose up -d --force-recreate old

What changed in the container?

The old container now starts from the PHP 8.3.36 Apache image. Its application files remain mounted read-only.

You'll see the old container recreated from its updated image configuration.

✔️ Awesome, I've got everything!

Your .env file now points the old service to PHP 8.3.36.

ⓧ I'd like to double check the full code

OLD_PHP_VERSION=8.3.36

The patched image is now in place. The temporary configuration must still be removed so the final proof does not depend on those stopgaps.

  • Return to config/old.ini in the VS Code Explorer sidebar.
  • Find the temporary settings:
memory_limit=-1
post_max_size=8M
session.upload_progress.enabled=0

Why remove these values?

These values represent the temporary middle state. Removing them proves that the final PATCHED result comes from the upgraded runtime.

  • Replace the entire file with its final comment:
; Final patched state. Temporary PHP2Shell stopgaps are no longer required.

What is the final configuration?

The file now contains no active stopgap settings. The patched runtime provides the complete remediation demonstrated by this lab.

  • Save config/old.ini.
  • Recreate the old container without the temporary settings by running this command:
docker compose up -d --force-recreate old

Why recreate the container again?

Recreation starts a fresh Apache worker with the final comment-only configuration. This removes the temporary settings from the runtime used for your final evidence.

You'll see the old container recreated once more. Its local endpoint now runs the patched image without active stopgaps.

✔️ Awesome, I've got everything!

Your config/old.ini file is back to its final comment-only state.

ⓧ I'd like to double check the full code

; Final patched state. Temporary PHP2Shell stopgaps are no longer required.
Capture the final remediation evidence

The final checker run ties together the effective version and the resulting status. This is the proof that the old service no longer relies on the mitigation label.

Before you run the final check, do you expect OLD and NEW to produce the same patch result?

  • Generate the final terminal report by running this command:
python3 checker.py

What does this result prove?

The checker fetches fresh audit data from both loopback-only services. Each result is based on the web runtime serving the local request.

You'll see OLD and NEW both report PHP 8.3.36. Each service now reports PATCHED with a reason that names the patched release threshold.

That closes the remediation loop. Your final result now proves the upgrade through the runtime's own audit response.

Do the two reports still differ?

  • Confirm that .env contains OLD_PHP_VERSION=8.3.36.
  • Confirm that config/old.ini contains only the final comment.
  • Still stuck? Help me verify why OLD does not report the patched PHP version after recreation.

Capturing this result is the last evidence step. The screenshot contains runtime information without exposing credentials.

  • Press Shift-Command-5 to open macOS Screenshot.
  • Use Screenshot's save controls to choose the docs folder inside php-patch-check.
  • Select the control for capturing a chosen portion of the screen.
  • Drag the selection around the complete terminal report.
  • Use the capture control to take the screenshot.
  • Rename the captured file to after.png in Finder.
  • In the VS Code Explorer sidebar, select docs/before.png.
  • Select docs/after.png to compare the final result.

The before image shows OLD on PHP 8.3.35 with NOT PATCHED status. The after image shows both services on PHP 8.3.36 with PATCHED status.

The comparison demonstrates a complete upstream update. The temporary stopgaps only represented an intermediate state.

Your remediation trail now covers the vulnerable state and the temporary mitigation. It also proves the completed upgrade. Next, you'll turn that evidence into a responsible portfolio write-up.

Document and Publish the Evidence

Your final PHP checker proves that both local services are patched. A portfolio viewer still needs context to understand that proof. They also need enough detail to reproduce it safely.

You will package the evidence into a sourced README. You will prepare a responsible project summary. You will publish everything through GitHub without exposing secrets or public container ports.

In this step, get ready to:
  • Document the checker workflow in a sourced README.
  • Prepare a LinkedIn draft with responsible security framing.
  • Commit the project with Git.
  • Publish the repository publicly on GitHub.
Write the evidence README

Security evidence needs a clear boundary around what was tested. Your README connects the terminal screenshots to the upstream patch thresholds. It also explains why a version-only result cannot prove the status of vendor-backported packages.

  • In the VS Code Explorer sidebar, select the php-patch-check folder.
  • Click the New File icon.
  • Enter .gitignore as the file name.
  • Add the project ignore rules by pasting this content:
.DS_Store
__pycache__/
evidence/report.json

What do these rules exclude?

  • .DS_Store excludes macOS folder metadata.
  • __pycache__/ excludes generated Python cache files.
  • evidence/report.json keeps generated machine-readable evidence outside the repository history.
  • Save .gitignore.
  • Confirm that .gitignore appears in the VS Code Explorer sidebar.

Can't see the ignore file?

Check that the file name begins with a full stop. Confirm that it sits directly inside php-patch-check.

Still stuck? Help me create the .gitignore file in my PHP Patch Check workspace.

✔️ Awesome, I've got everything!

Great. Your ignore rules are saved in the project folder.

ⓧ I'd like to double check the full code

.DS_Store
__pycache__/
evidence/report.json
  • In the VS Code Explorer sidebar, select the php-patch-check folder.
  • Click the New File icon.
  • Enter README.md as the file name.
  • Build the project introduction by pasting this first chunk:
# PHP Patch Check

PHP Patch Check is a defensive, local-only lab for verifying the upstream patch status associated with CVE-2026-109618 and CVE-2026-109619. It contains no exploit code and sends no attack payloads.

## What the bugs mean in plain English

CVE-2026-109618 affects out-of-memory handling. PHP can stop while internal request data is only partly initialized. Cleanup may later trust that invalid, half-finished data as though it were ready.

CVE-2026-109619 affects state that belongs to one request but remains reachable after its memory has been freed. A later request handled by the same worker may use that stale state before normal initialization resets it.

Both issues can lead to unauthenticated remote code execution in affected web-serving PHP installations. PHP states that updating is the only complete solution.

## What the checker does

The checker reads a deliberately small local JSON endpoint on each container. It records:

- PHP version
- Web SAPI, normalized as mod_php or PHP-FPM when recognized
- `session.upload_progress.enabled`
- `memory_limit`
- `post_max_size`
- `fatal_error_backtraces` on PHP 8.5 or newer

What does this section establish?

  • The opening defines the lab as defensive and local-only.
  • The vulnerability summaries explain the mechanisms without including triggering requests.
  • The checker field list shows that the audit endpoint exposes only the required evidence.
  • Save README.md.
  • Click the Markdown preview icon in the editor toolbar.

You should see the project title followed by the two vulnerability explanations. The checker fields should render as a bullet list.

Markdown preview missing content?

Confirm that the file ends with .md. Check that each heading starts with the same number of hash characters shown above.

Still stuck? Help me fix the opening of my PHP Patch Check README.

  • Return to the README.md editor.
  • Add the release thresholds below the final checker field by pasting this chunk:
It compares PHP 8.2 through 8.5 with these upstream fixed releases:

| Branch | Fixed release |
| --- | --- |
| 8.5 | 8.5.12 |
| 8.4 | 8.4.27 |
| 8.3 | 8.3.36 |
| 8.2 | 8.2.35 |

PHP 8.1 and older have no fixed upstream release. A distribution or LTS vendor may backport a patch without changing the displayed PHP version, so this project cannot prove the status of arbitrary vendor packages. Its version result is designed for the official upstream images in this controlled lab.

## Run the before state

1. Set `.env` to `OLD_PHP_VERSION=8.3.35`.
2. Leave `config/old.ini` with comments only.
3. Run `docker compose up -d --force-recreate`.
4. Run `python3 checker.py`.

Expected result: OLD is `NOT PATCHED` and NEW is `PATCHED`.

Why include the threshold table?

The table makes the upstream comparison rule visible. The limitation below it prevents readers from treating an upstream version number as proof about every distribution package.

The before-state instructions connect docs/before.png to a reproducible configuration.

  • Save README.md.
  • Refresh the Markdown preview.

You should see a four-row release table. You should also see the initial reproduction steps below it.

Release table not rendering?

Check that every table row begins and ends with a vertical bar. Keep the separator row directly below the header row.

Still stuck? Help me repair the fixed-release table in README.md.

  • Return to the README.md editor.
  • Add the temporary mitigation evidence below the before-state result by pasting this chunk:
## Show the temporary stopgaps

Put these values in `config/old.ini`:

```ini
memory_limit=-1
post_max_size=8M
session.upload_progress.enabled=0
```

Run `docker compose up -d --force-recreate old`, then run `python3 checker.py` again. OLD becomes `NOT PATCHED BUT MITIGATED`.

These settings only reduce specific demonstrated paths. They are not a full fix. PHP also recommends restarting every worker after changing the relevant state, which container recreation accomplishes in this lab. PHP 8.5 additionally needs `fatal_error_backtraces=0` as a temporary measure.

What does the mitigation section prove?

The section records the exact temporary state you tested. It keeps the warning beside the settings so readers cannot mistake mitigation for complete remediation.

Container recreation also documents how the lab restarted the affected worker.

  • Save README.md.
  • Refresh the Markdown preview.

You should see the three stopgap settings inside a formatted INI code block. The warning that they are incomplete should appear directly below the rerun instruction.

INI block swallowing the next paragraph?

Confirm that the closing fence contains exactly three backticks. Place it on its own line below the final setting.

Still stuck? Help me close the INI code fence in README.md.

  • Return to the README.md editor.
  • Add the final upgrade proof below the mitigation warning by pasting this chunk:
## Apply and prove the fix

1. Change `.env` to `OLD_PHP_VERSION=8.3.36`.
2. Restore `config/old.ini` to comments only.
3. Run `docker compose up -d --force-recreate old`.
4. Run `python3 checker.py`.

Expected result: both services are `PATCHED`.

## Evidence

### Before

![Before patch](docs/before.png)

### After

![After patch](docs/after.png)

## What I learned

- A working website is not evidence that its runtime is patched.
- The web SAPI matters, and inspecting CLI PHP alone can give the wrong runtime context.
- Temporary mitigations can reduce known paths without making an affected version safe.
- Container recreation provides a clear way to restart workers and prove a version change.
- Version-only checks need an explicit limitation for distribution and vendor backports.

How does this become evidence?

The upgrade sequence connects the final configuration to the expected patched result. The two relative image links place the terminal proof directly inside the rendered README.

The learning bullets explain what the comparison demonstrated beyond a simple version change.

  • Save README.md.
  • Refresh the Markdown preview.

You should see both terminal screenshots under separate headings. The five learning points should appear below the images.

Screenshots not appearing?

Confirm that before.png sits inside docs. Confirm that after.png sits in the same folder.

Still stuck? Help me fix the screenshot paths in README.md.

  • Return to the README.md editor.
  • Finish the README below the learning bullets by pasting this final chunk:
## Safety boundaries

- The lab publishes ports only on `127.0.0.1`.
- The application directory is mounted read-only.
- The audit endpoint returns only the fields required by the checker.
- The project contains no exploit code, attack payloads, or vulnerability-triggering requests.

## Official sources

- [PHP security release, 21 October 2026](https://www.php.net/archive/2026.php#2026-10-21-5)
- [GHSA-w8hw-w9fx-j79w: RCE via OOM corruption](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w)
- [GHSA-6wmf-4rv5-p3rp: RCE via stale global state](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp)

## Cleanup

Run `docker compose down` to stop and remove the local containers and Compose network.

Why finish with boundaries and sources?

The safety list shows that the project observes configuration without sending attack payloads. The official links let readers check the thresholds and remediation guidance themselves.

The cleanup command gives readers a defined way to stop the local lab.

  • Save README.md.
  • Refresh the Markdown preview.

Your README should now end with four safety boundaries. You should also see three clickable official sources followed by the cleanup section.

Official links not clickable?

Check that each link keeps its label inside parentheses-free square brackets. Confirm that the URL immediately follows inside parentheses.

Still stuck? Help me repair the official source links in README.md.

✔️ Awesome, I've got everything!

Your README now turns the screenshots into a reproducible remediation story.

ⓧ I'd like to double check the full code

# PHP Patch Check

PHP Patch Check is a defensive, local-only lab for verifying the upstream patch status associated with CVE-2026-109618 and CVE-2026-109619. It contains no exploit code and sends no attack payloads.

## What the bugs mean in plain English

CVE-2026-109618 affects out-of-memory handling. PHP can stop while internal request data is only partly initialized. Cleanup may later trust that invalid, half-finished data as though it were ready.

CVE-2026-109619 affects state that belongs to one request but remains reachable after its memory has been freed. A later request handled by the same worker may use that stale state before normal initialization resets it.

Both issues can lead to unauthenticated remote code execution in affected web-serving PHP installations. PHP states that updating is the only complete solution.

## What the checker does

The checker reads a deliberately small local JSON endpoint on each container. It records:

- PHP version
- Web SAPI, normalized as mod_php or PHP-FPM when recognized
- `session.upload_progress.enabled`
- `memory_limit`
- `post_max_size`
- `fatal_error_backtraces` on PHP 8.5 or newer

It compares PHP 8.2 through 8.5 with these upstream fixed releases:

| Branch | Fixed release |
| --- | --- |
| 8.5 | 8.5.12 |
| 8.4 | 8.4.27 |
| 8.3 | 8.3.36 |
| 8.2 | 8.2.35 |

PHP 8.1 and older have no fixed upstream release. A distribution or LTS vendor may backport a patch without changing the displayed PHP version, so this project cannot prove the status of arbitrary vendor packages. Its version result is designed for the official upstream images in this controlled lab.

## Run the before state

1. Set `.env` to `OLD_PHP_VERSION=8.3.35`.
2. Leave `config/old.ini` with comments only.
3. Run `docker compose up -d --force-recreate`.
4. Run `python3 checker.py`.

Expected result: OLD is `NOT PATCHED` and NEW is `PATCHED`.

## Show the temporary stopgaps

Put these values in `config/old.ini`:

```ini
memory_limit=-1
post_max_size=8M
session.upload_progress.enabled=0
```

Run `docker compose up -d --force-recreate old`, then run `python3 checker.py` again. OLD becomes `NOT PATCHED BUT MITIGATED`.

These settings only reduce specific demonstrated paths. They are not a full fix. PHP also recommends restarting every worker after changing the relevant state, which container recreation accomplishes in this lab. PHP 8.5 additionally needs `fatal_error_backtraces=0` as a temporary measure.

## Apply and prove the fix

1. Change `.env` to `OLD_PHP_VERSION=8.3.36`.
2. Restore `config/old.ini` to comments only.
3. Run `docker compose up -d --force-recreate old`.
4. Run `python3 checker.py`.

Expected result: both services are `PATCHED`.

## Evidence

### Before

![Before patch](docs/before.png)

### After

![After patch](docs/after.png)

## What I learned

- A working website is not evidence that its runtime is patched.
- The web SAPI matters, and inspecting CLI PHP alone can give the wrong runtime context.
- Temporary mitigations can reduce known paths without making an affected version safe.
- Container recreation provides a clear way to restart workers and prove a version change.
- Version-only checks need an explicit limitation for distribution and vendor backports.

## Safety boundaries

- The lab publishes ports only on `127.0.0.1`.
- The application directory is mounted read-only.
- The audit endpoint returns only the fields required by the checker.
- The project contains no exploit code, attack payloads, or vulnerability-triggering requests.

## Official sources

- [PHP security release, 21 October 2026](https://www.php.net/archive/2026.php#2026-10-21-5)
- [GHSA-w8hw-w9fx-j79w: RCE via OOM corruption](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w)
- [GHSA-6wmf-4rv5-p3rp: RCE via stale global state](https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp)

## Cleanup

Run `docker compose down` to stop and remove the local containers and Compose network.
Prepare the LinkedIn draft

A public project summary should focus on defensive validation. It should describe the patching lesson without presenting exploitation as the achievement.

  • In the VS Code Explorer sidebar, select the php-patch-check folder.
  • Click the New File icon.
  • Enter LINKEDIN.md as the file name.
  • Create the responsible project summary by pasting this draft:
I built PHP Patch Check, a defensive local lab for the two critical PHP vulnerabilities patched on 21 October 2026, CVE-2026-109618 and CVE-2026-109619.

Using Docker, PHP, and Python, I compared PHP 8.3.35 with 8.3.36, checked the web SAPI and relevant configuration, demonstrated PHP's temporary stopgaps, upgraded the old container, and captured evidence that its result changed from NOT PATCHED to PATCHED.

The project is deliberately local-only and contains no exploit code or attack payloads. My biggest takeaway was that mitigations can reduce exposure, but they are not a substitute for installing the complete fix.

Repository: add-your-GitHub-link-here

#PHP #CyberSecurity #Docker #Python #PortfolioProject

What makes this responsible framing?

The draft names the affected versions and the final upgrade result. It keeps the focus on remediation evidence.

The local-only boundary makes the lab context explicit. The safety sentence confirms that no exploit code or attack payloads were used.

  • Save LINKEDIN.md.
  • Click the Markdown preview icon in the editor toolbar.

You should see three short paragraphs. The repository placeholder should appear above five hashtags.

Draft preview looks crowded?

Confirm that one blank line separates each paragraph. Keep the repository line on its own line.

Still stuck? Help me format the LinkedIn draft in LINKEDIN.md.

✔️ Awesome, I've got everything!

Your baseline LinkedIn draft now reflects the defensive project accurately.

ⓧ I'd like to double check the full code

I built PHP Patch Check, a defensive local lab for the two critical PHP vulnerabilities patched on 21 October 2026, CVE-2026-109618 and CVE-2026-109619.

Using Docker, PHP, and Python, I compared PHP 8.3.35 with 8.3.36, checked the web SAPI and relevant configuration, demonstrated PHP's temporary stopgaps, upgraded the old container, and captured evidence that its result changed from NOT PATCHED to PATCHED.

The project is deliberately local-only and contains no exploit code or attack payloads. My biggest takeaway was that mitigations can reduce exposure, but they are not a substitute for installing the complete fix.

Repository: add-your-GitHub-link-here

#PHP #CyberSecurity #Docker #Python #PortfolioProject
  • Review the sentence about your biggest takeaway.
  • Rewrite that sentence in your own voice if another lesson mattered more to you.
  • Keep the local-only safety statement in the draft.
  • Save LINKEDIN.md.

Your draft should now sound personal while preserving the remediation focus. The repository placeholder remains ready for your published URL.

Commit and publish the repository

Your local files need a recorded revision before they can be published. Git captures that snapshot on the main branch.

  • Return to the Terminal panel in VS Code.
  • Initialize the repository on main by running these commands:
git init -b main
git add .
git commit -m "Document PHP patch evidence"

What do these commands record?

  • git init -b main creates the local repository with main as its initial branch.
  • git add . stages the project files while respecting .gitignore.
  • git commit -m "Document PHP patch evidence" records the staged project as one revision.

You should see an initial commit summary on main. The summary should list the files recorded in the project.

Commit blocked by Git identity?

Git may ask for an author name or email if this Mac has never created a commit. Follow the message in Terminal to configure your identity before rerunning the commit.

Still stuck? Help me finish the initial Git commit for php-patch-check.

Publishing makes every committed file visible to anyone on the internet. Take a moment here because this is the point where the project becomes public. Your repository contains no credentials or attack payloads.

  • Open the GitHub New repository page in your browser.
  • Enter php-patch-check in the Repository name field.
  • Select Public as the repository visibility.
  • Leave the optional repository initialization choices unselected.
  • Click Create repository.
  • Copy the repository's HTTPS URL into https://github.com/YOUR_USERNAME/php-patch-check.git.

The new repository should open on its setup page. It remains empty until you push the local commit.

What happens during authentication?

The first push from this Mac may trigger GitHub authentication. Complete the browser or credential prompt that Git presents.

The authentication step grants Git permission to publish to the repository you own.

Before you publish, do you expect the GitHub repository to show the same screenshots and README that you can see locally?

  • Connect the HTTPS origin and publish main by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/php-patch-check.git"]]
git push -u origin main

What does this publish?

The first command records the public GitHub repository as origin. The second command sends the local main branch to that origin.

The upstream setting connects future pushes from local main to the matching branch on GitHub.

You should see upload progress followed by confirmation that main now tracks the remote branch. That confirms the local commit reached GitHub.

  • Return to the new repository page in your browser.
  • Refresh the page.
  • Confirm that both evidence screenshots render inside the README.
  • Open each official source link to confirm that all three resolve.
  • Open compose.yaml from the repository file list.

You should see the complete project file list and the rendered README. The Compose file should show only 127.0.0.1 port bindings.

Push rejected or repository still empty?

Confirm that the GitHub repository was created without a README or another initial file. Check that the HTTPS URL belongs to the php-patch-check repository.

Still stuck? Help me publish main to my empty GitHub repository.

Strong finish. Your public repository now shows the vulnerable starting point, the temporary mitigation state, and the complete upstream patch as one responsible remediation story.

Secret mission

Export a Machine-Readable Report

Your terminal report gives people clear remediation evidence. In this secret mission, you will also save the same results as structured JSON evidence that another tool could consume.

Clean Up Your Resources

Clean Up Your Resources

PHP Patch Check uses no paid cloud services or APIs. Choose whether to keep the local demonstration available, pause its containers, or delete every project resource.

Resources you used:

  • Two loopback-bound Docker containers published on 127.0.0.1:8081 and 127.0.0.1:8082.
  • A local Docker Compose network for the two services.
  • Downloaded PHP Apache images named php:8.3.35-apache and php:8.3.36-apache.
  • The local php-patch-check folder containing the source files, screenshots, and JSON evidence.
  • The public GitHub repository named php-patch-check.

Keep everything running

No action needed. Choose this if you want the local comparison ready for another demonstration.

  • Keep Docker Desktop running so both local services remain available.
  • Use http://127.0.0.1:8081 to demonstrate the upgraded OLD service.
  • Use http://127.0.0.1:8082 to demonstrate the NEW service.
  • Retain the php-patch-check folder so the source files and evidence remain available.
  • Leave the php-patch-check repository published so other people can review your defensive workflow.

Your Lab Stays Local

Both services remain bound to 127.0.0.1. Keeping them running does not expose either service on every network interface.

There are no usage charges for leaving this project running. The containers and downloaded images continue using local memory and disk space.

Pause - I'll come back to this later

Pausing removes the running containers and their Compose network. Your source files, screenshots, downloaded images, JSON evidence, and GitHub repository remain intact.

  • Return to the VS Code terminal for php-patch-check.
  • Stop the two containers by running this command:
docker compose down

What Does This Command Do?

The command stops both project containers. It removes the Compose network created for the lab.

The downloaded PHP images remain on your Mac. Your project files also remain available.

  • Confirm the project has no running containers by running this command:
docker compose ps

What Should You See?

The command should list no running project containers. This confirms that both loopback services are paused.

  • Recreate the local services when you return by running this command:
docker compose up -d

How Do You Resume?

This command recreates the two services in detached mode. The OLD and NEW pages return on their original loopback ports.

Delete - I don't want to use this again

Deletion permanently removes the local lab and its published repository. Make sure you have saved any evidence you want to keep before continuing.

  • Return to the VS Code terminal for php-patch-check.
  • Remove the two containers and their Compose network by running this command:
docker compose down

What Does This Remove?

The command stops both containers. It removes the Compose network created for the project.

Your local folder and downloaded PHP images remain until you remove them separately.

  • Use Finder to locate the php-patch-check folder.
  • Move the php-patch-check folder to the Trash.
  • Empty the Trash to remove the local source files and evidence permanently.
  • Use Docker Desktop's local image list to locate php:8.3.35-apache.
  • Remove php:8.3.35-apache to reclaim its disk space.
  • Remove php:8.3.36-apache to reclaim its disk space.
  • Confirm that neither PHP image remains in the local image list.

Your local lab is now removed. The GitHub repository remains available until you delete it separately.

  • In GitHub, return to the php-patch-check repository.
  • Go to the repository settings page.
  • Find the repository deletion controls.
  • Choose the permanent deletion option.
  • Enter the requested repository name to authorize the deletion.
  • Confirm the repository deletion.
  • Visit the previous repository URL to confirm that the project is no longer available.

Deletion Is Permanent

Deleting both copies removes the source code, screenshots, JSON evidence, and public write-up. Rebuilding the project requires creating the files and evidence again.

Nice Work!

Nice Work!

You did it! You built PHP Patch Check as a loopback-only Docker lab with published GitHub evidence of a complete defensive remediation.

You've learned how to:

  • Build a localhost-only comparison lab with Docker Compose. The lab compares PHP 8.3.35 with PHP 8.3.36 while keeping both services on loopback.
  • Create a defensive patch checker with Python. It reports the runtime version, web SAPI, requested settings, status, and reason. It distinguishes NOT PATCHED, NOT PATCHED BUT MITIGATED, and PATCHED without sending attack payloads.
  • Prove the move from temporary stopgaps to a complete upstream patch with before-and-after remediation evidence. Publish the sourced evidence in a GitHub repository with clear vendor-backport limitations.
  • Complete the optional Secret Mission by exporting the checker results as machine-readable JSON evidence. The file gives future inventory or compliance tools a reusable artifact.

Ready to quiz yourself?