CVE-2026-45793: Anatomy of a 14-Hour PHP Supply-Chain Near-Miss #261
Replies: 7 comments 4 replies
-
|
Thank you for your effort. It is much appreciated. |
Beta Was this translation helpful? Give feedback.
-
|
Excellent postmortem and fast action! Thank you 🙏🏼 |
Beta Was this translation helpful? Give feedback.
-
|
awesome postmortem and thx for the effort. I do wonder about the value of validating tokens client-side.. wouldn't it be enough to just let the API throw an error of invalid token? Seems to have very little benefit (early escape) vs. entirely blocking when new formats are introduced or worse; cause this issue due to outputting. Maybe I'm missing something. |
Beta Was this translation helpful? Give feedback.
-
|
Thanks a lot for your effort and also the very detailed post portem @damienwebdev 💪 |
Beta Was this translation helpful? Give feedback.
-
You may never be fully "sure" you chose right, but the fact that you're not wracked with guilt after indicates you could have chosen much worse. |
Beta Was this translation helpful? Give feedback.
-
|
On your last point regarding the disclosure path, please watch out for a PHP Foundation next Monday, we've already been working on a plan for this, especially considering the changes AI has brought to these processes! I entirely agree with you on how tricky this is in situations where a huge amount of people and projects are affected and communication becomes extremely challenging. |
Beta Was this translation helpful? Give feedback.
-
I hope they hear you. Github answered me:
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Yesterday, the PHP community barely avoided an absolute disaster - a supply-chain vulnerability on the scale of a nuclear meltdown. In my opinion, this had the potential to be one of the most severe security incidents ever to hit the PHP community, and we only escaped it by a thin margin. Today, I want to talk about it because I need people to appreciate the severity of what we almost saw, and to level-set expectations of what we may see moving forward.
TLDR: Github changed token formats.. This broke composer in a very nasty way. Update your
composerversion to a patched version, make sure you're not pinning it in your Github Actions anywhere.The situation
In short, for a ~14-hour window yesterday (2026-05-12 ~10:00pm UTC → 2026-05-13 ~02:30pm UTC), any PHP project that:
composer installaftershivammathur/setup-phpin Github Actions,mainbranch, andon: push,on: pull_request_target, oron: schedule(other triggers are likely impacted, but these are the most common)had a chance that the
GITHUB_TOKEN(typically write-enabled) would be publicly logged to the Actions log on every build.For those ~14 hours, any malicious actor monitoring one of these repos could have pushed new tags and commits without anyone noticing. If "properly" leveraged, this would have been the largest supply-chain breach the PHP ecosystem has ever seen.
To make matters worse, we're not even done yet. The same change may roll out again in a week, at Github's discretion. And there's a chance it bleeds beyond PHP into other ecosystems.
How I came to uncover this
I maintain a set of Github Actions for the Magento community. They exist to lower the barrier to entry into the Magento and Mage-OS ecosystems, so that a company wanting to ship a store or extension can get working CI without much fuss.
For me, that means maintaining a wide variety of Github Actions that work across many versions of Magento and Mage-OS.
To handle that, I maintain a fairly complex action called
supported-version, which holds the compatibility matrix for every supported version of Magento and Mage-OS.While I was working on this action for the Magento 2.4.9 release yesterday evening, my tests started failing intermittently after some small changes to
supported-version. The failures didn't track with anything I'd changed.When I went to debug the failure, I saw the following:
I was surprised more than anything. My first instinct was that I'd screwed up some bash script processing the
COMPOSER_AUTHI feed into my actions. I've seen secrets in my logs before whenever a script clobbers a string with newlines, so this looked like my fault.It took me about an hour (I'm not a very fast person) to realize that my actions weren't at fault. Nothing I'd changed could have triggered this outcome.
Shortly thereafter, I concluded that something outside my actions was wrong, and wrong in a way that probably wasn't unique to me. So I asked Claude, giving it all the context I'd just uncovered. Claude pointed out two things I'd "seen" but not "noticed":
ghs_15368. That small an ID suggests a very old Github App, most likelygithub-actions[bot].shivammathur/setup-phpautomatically inserts that token intoCOMPOSER_AUTHas thegithub-oauthvalue.At this point I still wasn't too concerned. I naively thought this was a one-off bug in how the token had been generated. I knew the Github Actions token expires when the job ends; I'd looked at workflow token security on my own repos before. And the leak was on a
pull_request, so the token's permissions were already fairly restricted. I didn't lose sleep over it.I merged my changes to my
mainbranch from my pull request, fully expecting that this would recover and I could move about my day. Then, mymainbranch CI failed on a completely different job and I became concerned. Earlier yesterday,@tanstack/routerwas breached because theirmainbranch's cache was poisoned by a PR, I was very concerned that something like that was going on here. It was at this point, with aread-writetoken visible in my logs that I knew something was up and that I had to dig in and act quickly. I had claude triage the error message from the log:figuring that it would do a better job scowering composer's project code for the offending line faster than I would.
It uncovered immediately:
It also uncovered, and huge props to Claude here, this would have taken me hours on my own, that Github was rolling out a change to their token formats.
At this point I knew, very distinctly, that "shit's fucked". Which is my inner child telling me (and now you) that something is undeniably wrong.
How I acted
Here's what I did, in order:
graycoreioorg. Better safe than sorry.composerproject based on my quick analysis.@faker-js/faker, hoping she could quickly and quietly get me in touch with people at the large frameworks at risk. At my request, she reached out to @taylorotwell and asked him to pause Laravel's Github Actions.modmailuser on Laravel's Discord.As an aside: I'm a small fish compared to many of the well-known developers in the PHP ecosystem. I didn't know if anyone would take me seriously, and I wanted to make sure no one was hurt simply because I wasn't loud enough. I fully understand responsible disclosure. I've filed several HackerOne reports before, and I've received bounties for some of them. But when the breadth of concern is this large, this painfully obvious, this critical, and it had been four hours since I'd opened the advisory without a response, I felt I needed to take my own action through quiet, fast channels to make sure the largest parts of the ecosystem were protected as quickly as possible. What were the chances I could get @naderman or @Seldaek on a call minutes after I submitted an advisory? Worse, what were the chances I could get a HackerOne report submitted and reviewed by Github in the same timeframe?
The actual impact
In reality, likely no one was compromised. In reality, likely no one next week will be compromised when Github re-rolls the change out. Composer has already issued patches to all major versions of composer combined (even unsupported non-LTS versions!).
shivammathur/setup-phphas already bumped their mainline to update to a secure version of composer.That being said, the bulk of the reason that this wasn't as impactful is primarily due to three components:
First, Composer's validator fails fast. The exception bubbles up and the job dies. This causes the
GITHUB_TOKENto be revoked the moment the job exits. From the attacker's perspective, the token is live for the seconds between Symfony Console writing the message to stderr and the runner tearing the job down. That window is real, but it's narrow. To be clear, it's not narrow enough to be unexploitable. I have a PoC which showcases direct writes tomainusing this stolen token in this very narrow window. With a sufficiently sophisticated polling setup, you can do this in under a second.The second reason is that the failure was loud. While I was running through Github trying to figure out whether I was the only person hit, I could see the failure landing in CI logs across most of the top PHP repos: Laravel, Composer itself, WooCommerce, OpenEMR, Sylius,
fruitcake/laravel-debugbarand many other smaller projects. Anyone watching their own CI saw it within minutes. If it was quiet, this would have gone on far longer. Who reads the build logs of a passing job?The third reason is that Composer shipped patched releases (2.9.8, 2.2.28, 1.10.28) and the advisory went out the next morning at 09:34 UTC. Github paused the format rollout shortly afterward. The response time from notification to resolution was remarkable.
The potential impact
Now imagine someone who saw what I saw earlier than I did, and who decided to do something with it instead of reporting it.
The primitive you have is:
GITHUB_TOKENfrom apushevent on a repo's default branch, printed in plaintext to a world-readable log, live for some number of seconds before the job ends. The token's permissions vary by repo, but for the long tail of older repos and most maintainer-configured workflows, the default is stillcontents: write, the legacy "permissive" default. That's enough to push commits and tags to the repo it came from.Worse, the leak doesn't necessarily end precisely when the failing step ends. A pattern I have in my own workflows, and that a lot of Magento and broader PHP workflows have, looks like this:
The intent is fine: when CI fails, freeze the workspace state into an artifact so a human can debug it later. In this bug's context, the step compounds the leak.
The core concept is that it extends the live-token window. The upload runs after Composer has crashed and printed the token to stderr, but before the runner can tear the job down and revoke the token. Until the upload finishes, the token is still accepted by the Github API. For a small sandbox that's seconds; for a fat integration-test fixture with database dumps it's many minutes. An attacker polling failed runs gets a longer window to act, not a shorter one.
In the PHP ecosystem, push access to a repo is push access to Packagist. Packagist polls Github for new tags. The moment you push
v1.2.4tovendor/package, Packagist publishes it, and the nextcomposer installorcomposer updateanywhere in the world pulls your release. Composer plugins execute code duringinstall. So a poisoned tag on a sufficiently popular package is remote code execution on every developer machine, CI runner, and production deploy that runscomposer installbetween the tag landing and the rollback.Look at the names on the list above.
composer/composeritself was failing in CI during the window. So waslaravel/framework. So wassylius. A successful exfil-and-push against any one of those is a top-fifty-in-history supply chain incident. Against several of them simultaneously, with the same primitive, it would be the largest one ever in PHP and probably top three across all ecosystems.The Magento angle, since it's the corner of the world I actually live in: Magento and Mage-OS distributions are Composer projects. Composer self-update pulls from
getcomposer.org, which redirects to Github releases. A poisonedcomposerbinary would execute under every Magento store's deploy pipeline at the next build (I suspect). Most e-commerce platforms do not have the supply-chain hygiene of, say, a Rails shop. The blast radius would not have been bounded by "who reads Hacker News".The mitigating factors
A short list of the things that, intentionally or otherwise, kept this from going off:
GITHUB_TOKENis repo-scoped. Even a successful exfil againstlaravel/frameworkdoesn't get you anywhere nearlaravel/passport. Each repo's token can only act against that repo.maindirectly on a protected branch fails. The interesting move was pushing tags, which most repos do not protect. So branch protection alone wasn't a real barrier, but it would have slowed an attacker who didn't think to try tags first.None of these are the kind of defense you'd want to bet on. But stacked together, they're the reason this is a story about a near-miss and not about cleaning up a real incident.
Proof of Concept
Any repo that was (or will be) migrated to the new
ghs_<id>_<base64url-JWT>token format with a vulnerable composer version will exhibit the leak.Observed log fragment on an affected repo
The value above is the live workflow
GITHUB_TOKEN. The Github Actions secret masker registered the token at job start, but Symfony Console's renderer reframes the message before it reaches stderr, which is enough to defeat the masker's substring match.The ingredients you need for the leak are:
composerto an unpatched versionpush,pull_request_target, orschedule(so the token is write-enabled by default)GITHUB_TOKENintoCOMPOSER_AUTH(shivammathur/setup-phpdoes this automatically when the env var is set)composerinvocation that triggersloadConfiguration()(many subcommands). Remove any one of those and the leak does not fire.If you want to verify your own repo is on the new format without running the PoC, fetch the workflow's
GITHUB_TOKENvalue into a step that prints its length or checks for-, you do not need to ship the value off the runner to confirm.Now what?
What a typical public PHP maintainer has to do
2.9.8,2.2.28, or1.10.28(the three patched lines). If your workflows pin a Composer version, unpin it or bump the pin.permissions:. Don't rely on the legacy permissive default. Add an explicitpermissions:block at the top of every workflow (or job) with the minimum scope each step needs. For most CI jobs that meanscontents: readand nothing else.refs/tags/*, so a leaked token can't mint a release.failureruns between 2026-05-12 ~22:00 UTC and 2026-05-13 ~14:30 UTC. If you see aghs_…value in any rendered output. Review these jobs for composer pins.git log --since='2026-05-12T22:00Z' --until='2026-05-13T14:30Z' --alland verify everything is yours. Same forgit tag --sort=creatordate. Anything created in the window that you don't recognise warrants investigation.What Github has to do
ghs_[A-Za-z0-9_-]{20,}) so any string of that shape gets redacted, byte-exact match or not.What other ecosystems have to do
The class of bug ("downstream validator rejects a token the issuer reformatted, and prints the rejection") is not Composer-specific. Anyone with a package manager or installer that handles Github auth should:
-, the same primitive (validate → reject → throw with the candidate value in the message) would fire on the next undocumented format change Github (or any other token issuer) ships. The fix is structural: redact-or-omit credential-shaped values before they reach an exception message, a log line, or a stack trace.A note on disclosure
I want to spend a couple paragraphs on how this felt from the reporter's side. Not as a complaint, but as a description of a structural problem someone with power should think about.
The model for responsible disclosure assumes there is one vendor on the other end, with a security team, a queue, and a person paid to read the queue. Most of the time that's exactly how it works. You write up the bug, you submit it, they triage, they patch, you go home.
This one didn't have that shape. The root cause was at Github. The fix that mattered most quickly was at Composer. The blast radius was every PHP framework maintainer on Earth (with packages on Github), and we have no shared security inbox. There was no way to reach all of them, on a Tuesday evening, in under an hour, as someone they've never heard of. Every minute the leak went undetected was a minute an attacker with a scraper could have been turning failing builds into push-to-tag access.
So the "responsible" path and the "fast" path diverged. The responsible path was: file with the Composer security advisory queue, file a HackerOne against Github, wait. The fast path was: open Discord, ping modmail, open X, find someone who knows the people you need, cold-call a colleague, and hope you're taken seriously by people who have no reason to know who you are.
I picked both paths. I'm still not sure I was right. I'm fairly sure that traversing the fast path was less wrong than waiting for the advisory queue to process would have been. What bothers me is that someone less well-connected than I am, with the same bug at 10pm on a Tuesday, doesn't have a faster lever to pull than "guess who knows the Taylor Otwell's" of the PHP ecosystem.
If you're reading this and you have power in this picture (at Github, at HackerOne, at one of the major ecosystem package managers), I would like for there to be a phone number. A pager. A shared cross-vendor channel for "this is an ongoing ecosystem-scale leak and I need an adult." I don't know what that looks like in detail. But "submit a form and pray" is not adequate for incidents that look like this one, and I don't think we should keep relying on the fact that the next reporter happens to have my network, albeit small as it is.
Especially in the modern era of AI as the "discovery to abuse" window tightens, time-to-response becomes more critical than ever before.
Beta Was this translation helpful? Give feedback.
All reactions