top of page

RedEye Security Printed Our Zero. We Checked Why, and Found PHP's Malicious-Package List Has One Entry.

Writer: Patrick Duggan
Patrick Duggan
3 hours ago
9 min read

Ask any supply-chain tool whether a PHP package is known malicious and it will almost certainly tell you no. Not because the package is clean. Because across the entire OSV database there is exactly one malicious Composer package on record, and the odds that yours is Intercom's PHP SDK are low.


We measured that this morning, and we only went looking because somebody else published a number of ours that made us uncomfortable.





Correction, same day. The first version of this post — and the first version of the chart above — said the gap was "in the reporting, not the language." That was an assertion we had not tested, and on checking it we think it is mostly wrong. The number is low largely because of how Composer is built, not because nobody is looking. The explanation below has been corrected and the chart re-cut. The measurements themselves are unchanged.



Somebody printed our negative result


RedEye Security published PHP2Shell part two last week: seven NativePHP advisories, six of them reported by RedEye, fixed in nativephp/mobile 4.6.1 and nativephp/mobile-file 1.0.3. Real work, disclosed properly, with detections shipped before the write-up.


It also carried our telemetry. Not the flattering part — the zero. Since September 24: 31,165 edge-block events, 26,950 honeypot canary records, 1,690 origin-honeypot captures, and zero PHP2Shell-specific matches. Their conclusion was the careful one: widespread attempted exploitation, confirmed compromise not yet established.


A vendor quoting your zero is unusual. Most would have dropped the row and kept the scary ones. We noticed, and then we did the thing you are supposed to do when a partner hands you a clean bill of health: we went and checked whether we had earned it.


We had not, in two separate ways.



The first thing we found was our own blind spot


Our pre-install supply-chain check covered npm, PyPI and RubyGems. It could not resolve a Composer package name at all. So the entire ecosystem that RedEye's advisories live in — the one a PHP developer actually installs from — came back as unknown from a tool we tell people to put in front of their dependencies.


That is a bad way to find out. A membrane with a hole in it that shape is not a membrane, it is a filter with a PHP-sized gap, and we had been describing it in general terms that quietly included an ecosystem we had never covered.


So we fixed the resolution, then wrote the collector that feeds it, and then measured what the collector actually had to work with.



The measurement is the real story


We downloaded each ecosystem's malicious-package export from OSV on the same day, within the same hour, and counted the records flagged malicious outright — not vulnerability advisories, which are a different and much larger set.


npm: 222,809. PyPI: 11,949. RubyGems: 4,305. crates.io: 21. Go: 20.


Packagist: 1.


That one record is MAL-2026-3637, intercom/intercom-php — Intercom's own SDK, compromised rather than hostile from birth, and version-scoped rather than open-ended. We were lucky about that last part. An open version range on a legitimate vendor SDK would have told every consumer of our feed that all of Intercom's PHP library was malware.


PHP is not a small ecosystem. Packagist carries 475,758 packages. Ruby has roughly a tenth of that and 4,305 flagged. So the first instinct is that PHP must simply be under-reported.


We checked that instinct and it did not survive.



Why the number is actually low


Three structural facts, and together they explain more than neglect does.


Composer does not run your dependencies' code when it installs them. Its own documentation is unambiguous: "Only scripts defined in the root package's composer.json are executed." A library you pull in can define whatever scripts it likes and Composer will ignore them. The one mechanism that can execute — plugins — has required explicit allow-plugins approval since Composer 2.2, which Packagist described at the time as ensuring no package can run code during a Composer run if you do not trust it.




Compare that with npm, where postinstall fires for every package in the resolved tree. On npm, getting installed is getting execution. That is the engine behind those 222,809 records. It is not that PHP attackers are lazier; it is that the same trick pays far less.


Composer names are vendor-scoped. You cannot publish laravel/framework unless you control the laravel vendor. A typosquat has to be someone-else/framework, which does not resemble the thing it is impersonating. On npm, lodash and 1odash are both top-level and the second one is free to whoever asks first. A large share of npm's malicious corpus is exactly that class of attack, and Packagist's naming mostly forecloses it.




Packagist distributes metadata, not code. It points at repositories on GitHub and GitLab. So a malicious Composer package is really a malicious repository, takedown happens at the forge, and the report tends to flow into GitHub's abuse process rather than into a registry advisory pipeline.


Put together: a one is closer to right than we first assumed. Under-reporting is probably a real contributor — the commercial scanners are all npm-and-PyPI-first — but it is not the headline. The headline is that npm's 222,809 is substantially a measure of npm's design.



A check that cannot fail is not a check


Here is why that number matters more than it looks.


Every pre-install gate, every CI dependency scanner, every agent that claims to vet a package before installing it, answers the same question: is this on a list of known-bad things? When the list is empty, the gate returns allow on everything, forever, and it looks exactly like a gate that is working. Green, fast, no findings.


We have written before about instruments that report success while producing nothing. This is that failure mode, industry-wide, for an entire language.


Our tool now refuses to pretend. A Composer verdict from us says, in its own text, that our deny-list carries no Packagist entries and that not on the deny-list means not checked against a list, not vetted. The OSV lookup and the package reputation signals are the whole of the check. That is a worse-sounding answer and a more useful one.



What we shipped because of this


Three things, in the order we did them, because fixing comes before publishing.


Composer resolution in our package check. It now understands Packagist names, with aliases for composer and php, reputation pulled from packagist.org, and version-scoped OSV lookups. Verified live through our public endpoint.


The collector behind it. Packagist is now in our malicious-package sync. Three separate places needed it, not one: the ecosystem map, and then the hourly incremental job's own bootstrap date, its own document builder and its own default ecosystem list. Adding it to the first alone would have left the cron permanently blind while the manual path looked healthy — a trap that has caught us three times before on other feeds.


PHP canaries at our edge. This is the one that was overdue. Our instruments reported zero PHP2Shell activity, but our own tooling prints a warning alongside that result: no canary for a product means it cannot appear here. We had no PHP decoy anywhere. The zero was partly blindness.


Edge Shield 2.20.0 now traps the two endpoints RedEye reported — the NativePHP async-task bridge at /\_native/api/events, and the /\_assets static-asset traversal �� plus Composer and Symfony profiler paths. The decoys answer in character: a fabricated Composer manifest showing pre-fix NativePHP and an old Laravel, and an accepted-looking task envelope. Every value in them is invented. It is a tarpit, not a leak.


The next zero will mean something. This one did not.



The thing our edge was already seeing


Worth saying plainly, because it was sitting in our own data untrapped: we are being probed for Composer manifests right now. In the window since September 25, our edge blocked requests for /vendor/composer/installed.json, /symfony/\_profiler/phpinfo, /app\_dev.php/\_profiler/phpinfo and /\_profiler/phpinfo/info%2ephp.


Reading your installed.json is dependency shopping. It tells an attacker your exact package versions so they can pick an exploit instead of guessing. It is reconnaissance for precisely the supply-chain attack that the empty deny-list means nobody can warn you about. That is why those paths now get a decoy rather than a plain block — a scanner that takes the bait tells us more than one that bounces off a 403.


Our fresh denominators, same window, measured today: 32,954 edge-block events, 26,217 canary records, 1,690 origin captures. Still zero PHP2Shell-specific. One honest caveat on our own numbers: the 330 canary hits in that window are all WordPress admin-ajax probes, which is generic recon, not PHP2Shell. We are not counting them as PHP activity and neither should you.



Two things from the hunt that are not what the headline says


While we were in here we went looking for new PHP2Shell variants. Two findings worth passing on.


The "fully-patched 8.5.12 RCE" is real and mis-headlined. A public repository advertises working remote code execution against PHP 8.5.12, the version everybody was told to install on October 21. Read its own stated preconditions and it needs a serialize\_handler mismatch — php\_serialize writing, legacy php reading — plus a destructor gadget in the application. The same repository says of its sibling chain, in its own words, that this is an application flaw and not a PHP core bug, and that it is not universal RCE.


So the upgrade advice stands and you should still patch. But the inverse is the part nobody is printing: patching PHP core does not fix an application-level session deserialization mismatch. If your app writes sessions with one handler and reads them with another, you have a problem the October release did not touch. Go look at that today.


A third CVE we cannot confirm. Two public repositories reference CVE-2026-115560 as a third PHP flaw. MITRE's CVE API returns 404 for it and we hold no record of it in our own corpus. We are flagging that as unverified, not as fabricated — a missing record is not proof of an invented identifier, and we have been wrong in that direction before. But do not cite it as established.



Where this goes next


If the number is low because installing a Composer dependency does not execute it, then the useful action is not "report more PHP packages." It is to close the same door everywhere else.


If you use npm, turn off install scripts. npm config set ignore-scripts true, or npm ci --ignore-scripts in CI. That is the single highest-leverage free control in dependency management, and it is precisely the protection Composer hands PHP developers by default. It works on packages nobody has reported, which is the only kind that matters, because a deny-list can never contain what has not been written down yet.


It is not free of friction: a handful of legitimate packages build native code at install time and will need allowing individually. That is the same trade Composer made with allow-plugins — list the few you trust rather than trusting everything.


We will still co-file PHP findings upstream to OpenSSF's malicious-packages database with RedEye or anyone else, and our Composer verdicts will keep saying "not checked against a list" until that corpus exists. But we are no longer going to present reporting as the fix. Reporting is how you describe what already happened. Turning off install-time execution is how you stop the next one, and it costs nothing.



Credit, and the bit we will not do


Detection rules in RedEye's post are their work — if we ever reuse their Sigma or Suricata rules, the metadata will credit RedEye Security (Caver / Carbide), as they ask. Matt Lucas reported a 401 on an anonymous pull of our own IP blocklist on October 24, which is how we learned our published documentation had been describing those feed files as open when they had been key-gated for some time. Reading his team's PHP2Shell research is what exposed the gap described here. That is twice in two days that their work has corrected ours.


One thing we are deliberately not doing. RedEye's campaign indicator set is described accurately in their post as aggregated and redistributed third-party research, sourced from Sophos, Wordfence, Hunt.io and SOCRadar. We are not re-ingesting it under our own source name. Relabeling someone else's feed as your own observation is how threat intelligence rots, and the fact that it would pad our numbers is exactly why we are not doing it. Go read it at the source.



The honest scorecard


This post makes eleven factual claims. Ten are measured directly and dated: the six ecosystem counts, the Packagist package total, our three edge denominators. The eleventh — that PHP's reporting gap rather than PHP's design explains the number — was an inference, and the first version of this post said a reasonable person could argue the other way.


Someone did, within the hour, and they were right. That claim is now withdrawn and replaced above. It is worth noting which one broke: not a measurement, but the single sentence we had already marked as the soft one. The discipline of labelling your own inference is what made it cheap to retract — we knew exactly which brick to pull.


Three further things we checked before correcting, rather than after: the OSV export holds 7,340 Packagist advisories in total (6,578 GHSA, 761 Drupal, 1 malicious), so this ecosystem is not short of security data generally; malicious packages are expressed in exactly one way in that data, a MAL- identifier carrying malicious-packages-origins, and exactly one record has it; and the upstream OpenSSF database that feeds OSV has a packagist folder containing one vendor. The number is not an ingestion gap on our side or theirs.


We also do not know whether our new canaries will catch anything. They might sit at zero for months. The difference is that a zero from an instrument that can fire is evidence, and a zero from an instrument that cannot is just a quiet room.


And we guarantee five percent of this is wrong somewhere. It always is.




How do AI models see YOUR brand?

AIPM has audited 250+ domains. 15 seconds. Free while still in beta.



Was this useful? Thirty seconds, no cookies, no tracking, no third parties, your address hashed and never stored. If the box below does not load, the same question lives at https://analytics.dugganusa.com/nps.html?post=redeye-security-printed-our-zero-we-checked-why-and-found-php-s-malicious-package-list-has-one-ent



Comments

Rated 0 out of 5 stars.
No ratings yet

Add a rating
bottom of page