Why checking the version number gets PHP2Shell wrong
What the two bugs are, why installing the update is only part of the job, and how Bastion decides whether a build is fixed. Code excerpts are taken from the source when the site is built.
The vulnerability
Two ways to make PHP trust memory it should not.
PHP handles each web request in a short life cycle: set up request data, run the script, clean up. Both bugs break assumptions between those steps.
CVE-2026-109618 · out-of-memory corruption
When PHP runs out of its memory budget it does not return an error. It jumps out of whatever it was doing (a longjmp bailout). If that happens while request data is half built, for example while parsing an upload, clean-up later reads that data as if it were valid. An attacker can push request parsing into out-of-memory while staying inside PHP's apparent limits: multipart bodies without a usable length, unlimited headers per part, empty file fields that do not count towards max_file_uploads.
CVE-2026-109619 · stale global state
PHP-FPM and mod_php reuse one worker process for many requests. A fatal error can leave global state pointing at memory that has already been freed, and the next request on the same worker can use it before normal initialisation resets it. Confirmed paths include INI diagnostics raised before request start-up and session upload progress.
Both are rated CVSS 4.0 9.2. The advisory demonstrated unauthenticated code execution against Apache mod_php with default limits; PHP-FPM, FrankenPHP and LiteSpeed are considered likely susceptible. Upstream removed longjmp from out-of-memory handling: PHP now stops at the next safe point (soft limit) or exits the worker (hard limit).
Why updating is not enough
Six ways a server that “has the update” is still exposed, and what Bastion does about each.
- The old code is still running
apt upgradereplaces files on disk. php-fpm and Apache workers keep executing the old build until they restart. Bastion finds processes whose binary was replaced and reads the old image from/proc/<pid>/exe. - There is more than one PHP
A system PHP, two panel versions and a Homebrew build on one host are common. Updating the one in
$PATHcan leave the one serving the site untouched. Bastion inventories all of them. - Containers and images are separate
Host updates do not reach containers, and running containers do not update when their image is rebuilt. Bastion reads running containers and, with
--image, images that are not running. - End-of-life branches have no release
PHP 8.1 and older get no official release. Source backports and some vendor rebuilds keep the version string, so a patched build and a vulnerable one can look identical.
- Mitigations are partial
memory_limit=-1, a lowpost_max_sizeandsession.upload_progress.enabled=0close known paths only. Bastion reports which are in place for every unfixed build. - Someone got in before the update
An update does not remove a webshell in
uploads/, a cron job or an SSH key. That is whatbastion traceis for.
How Bastion decides
Distribution packages carry fixes without changing the upstream version. The decision ladder, as code.
Upstream fixed PHP 8.3 in 8.3.36. Ubuntu 24.04 ships PHP 8.3.6 and keeps that number for its whole life. Instead of moving to a new upstream release, Ubuntu applied the fix to its own 8.3.6: a backport. The only visible change is the package revision.
$ php -v | head -1
PHP 8.3.6 (cli) (built: Oct 7 2026 18:06:38) (NTS)
$ dpkg-query -W php8.3-cli
php8.3-cli 8.3.6-0ubuntu0.24.04.12$ php -v | head -1
PHP 8.3.6 (cli) (built: Oct 19 2026 15:16:58) (NTS)
$ dpkg-query -W php8.3-cli
php8.3-cli 8.3.6-0ubuntu0.24.04.13Both outputs were captured on Ubuntu 24.04 systems while building Bastion. A version-only checker calls both vulnerable, or calls both safe. The fixed package's changelog records why:
php8.3 (8.3.6-0ubuntu0.24.04.13) noble-security; urgency=medium
* SECURITY UPDATE: Remote RCE via state corruption issue
- debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via
bailout in Zend/tests/bug40770.phpt, …
The decision engine
The same logic runs in the binary and, ported to JavaScript, in the online check. Both implementations are tested against one shared set of vectors.
172func (a *Advisory) Evaluate(s Subject) Assessment { 173 out := Assessment{Advisory: a.ID} 174 add := func(kind, verdict, format string, args ...any) { 175 out.Evidence = append(out.Evidence, Evidence{Kind: kind, Verdict: verdict, Detail: fmt.Sprintf(format, args...)}) 176 } 177 done := func(st Status, conf, format string, args ...any) Assessment { 178 out.Status, out.Confidence, out.Reason = st, conf, fmt.Sprintf(format, args...) 179 return out 180 } 181
Evidence entry. A verdict is never a bare word: the report shows which observation decided it.182 v, err := version.ParsePHP(s.Version) 183 if err != nil { 184 if s.Package == nil { 185 add("version", "neutral", "could not determine the PHP version") 186 return done(Unknown, "low", "PHP version could not be determined") 187 } 188 if pv, perr := version.ParsePHP(UpstreamOf(s.Package.Version)); perr == nil { 189 v, err = pv, nil 190 add("version", "neutral", "version taken from package %s %s", s.Package.Name, s.Package.Version) 191 } 192 } 193 194 // upstream is what the upstream version alone says: "fixed", 195 // "vulnerable" (a fixed release exists and this is older), "no-release" 196 // (branch never got a fixed release) or "" when unknown.
197 upstream := "" 198 var br *Branch 199 if err == nil { 200 br = a.branch(v.Branch()) 201 switch { 202 case br != nil && br.Fixed != "": 203 out.FixedIn = br.Fixed 204 if v.Compare(version.MustPHP(br.Fixed)) >= 0 { 205 add("version", "fixed", "PHP %s is at or above the fixed release %s", v.Upstream(), br.Fixed) 206 return done(Fixed, "high", "running %s, fixed in %s", v.Upstream(), br.Fixed) 207 } 208 upstream = "vulnerable" 209 if s.Package != nil { 210 add("version", "neutral", "PHP %s is below the fixed release %s, but distribution packages often carry backports", v.Upstream(), br.Fixed) 211 } else { 212 add("version", "vulnerable", "PHP %s is below the fixed release %s", v.Upstream(), br.Fixed) 213 } 214 case br != nil: 215 upstream = "no-release" 216 add("version", "vulnerable", "PHP %s has no fixed upstream release (branch status: %s)", v.Branch(), br.Status) 217 default: 218 switch pos := a.branchPosition(v.Branch()); { 219 case pos > 0 && a.NewerBranches == "fixed": 220 add("version", "fixed", "PHP %s is newer than every branch covered by this advisory", v.Branch()) 221 return done(Fixed, "medium", "branch %s postdates the fix", v.Branch()) 222 case pos < 0 && a.OlderBranches == "affected": 223 upstream = "no-release" 224 add("version", "vulnerable", "PHP %s is an end-of-life branch with no upstream fix", v.Branch()) 225 default: 226 add("version", "neutral", "branch %s is not covered by this advisory's data", v.Branch()) 227 } 228 } 229 } 230
231 if f := a.VendorFixFor(s.OS, s.Package); f != nil { 232 out.VendorFix = f 233 out.FixedIn = f.Fixed 234 if compareVendor(f.Format, s.Package.Version, f.Fixed) >= 0 { 235 add("vendor", "fixed", "%s %s ≥ %s (%s, %s %s)", s.Package.Name, s.Package.Version, f.Fixed, f.Advisory, f.Distro, f.Release) 236 return done(Fixed, "high", "vendor backport %s in %s", f.Fixed, f.Advisory) 237 } 238 add("vendor", "vulnerable", "%s %s < %s (%s, %s %s)", s.Package.Name, s.Package.Version, f.Fixed, f.Advisory, f.Distro, f.Release) 239 return done(Vulnerable, "high", "package %s is older than the vendor fix %s", s.Package.Version, f.Fixed) 240 } 241
242 if err == nil && s.BuildDate != "" { 243 for _, r := range a.BuildDateFixes { 244 if r.Environment != s.Environment || !contains(r.Branches, v.Branch()) { 245 continue 246 } 247 if s.BuildDate >= r.FixedOn { 248 add("build-date", "fixed", "%s builds of PHP %s dated %s or later carry the fix; this one was built %s (%s)", r.Environment, v.Branch(), r.FixedOn, s.BuildDate, r.Source) 249 return done(Fixed, "high", "%s rebuild dated %s", r.Environment, s.BuildDate) 250 } 251 add("build-date", "vulnerable", "%s builds of PHP %s before %s are vulnerable; this one was built %s (%s)", r.Environment, v.Branch(), r.FixedOn, s.BuildDate, r.Source) 252 return done(Vulnerable, "high", "%s build from %s predates the fixed rebuild", r.Environment, s.BuildDate) 253 } 254 } 255
256 if len(s.ChangelogHits) > 0 { 257 add("changelog", "fixed", "package changelog mentions %s", strings.Join(s.ChangelogHits, ", ")) 258 return done(Fixed, "high", "vendor changelog records the fix") 259 } 260
changelog.Debian.gz, rpm -q --changelog). This covers distributions with no fix table in Bastion's data.261 for _, fp := range a.Fingerprints { 262 if !contains(s.Fingerprints, fp.Value) { 263 continue 264 } 265 if fp.MinBranch != "" && err == nil && version.CompareBranch(v.Branch(), fp.MinBranch) < 0 { 266 continue 267 } 268 add("fingerprint", fp.Indicates, "binary contains %q, which only patched builds include", fp.Value) 269 if fp.Indicates == "fixed" { 270 return done(Fixed, "medium", "binary contains code introduced by the fix") 271 } 272 } 273
274 if s.Package != nil { 275 if upstream == "" { 276 return done(Unknown, "low", "packaged build with an unrecognised version") 277 } 278 if p := a.policyFor(s.OS); p != nil { 279 add("vendor", "vulnerable", "%s (checked %s) records no backport below the upstream fix", p.Source, p.Checked) 280 return done(LikelyVulnerable, "medium", "%s ships upstream releases; no backport recorded", s.OS.ID) 281 } 282 if s.ChangelogChecked { 283 add("changelog", "vulnerable", "%s changelog does not mention %s", s.Package.Name, strings.Join(a.ChangelogMarkers[:min(2, len(a.ChangelogMarkers))], " / ")) 284 return done(LikelyVulnerable, "medium", "distribution package without a recorded backport") 285 } 286 add("note", "neutral", "distributions backport fixes without changing the upstream version; no changelog was readable and %s %s is not in Bastion's vendor data", s.OS.ID, s.OS.VersionID) 287 return done(Unknown, "low", "packaged build; backport status cannot be verified") 288 } 289
290 switch upstream { 291 case "vulnerable": 292 return done(Vulnerable, "high", "PHP %s is below %s", v.Upstream(), out.FixedIn) 293 case "no-release": 294 if br != nil && br.SourceBackport != "" { 295 add("note", "neutral", "upstream published a source-only backport branch (%s); builds from it keep the version string, so Bastion cannot tell them apart", br.SourceBackport) 296 return done(LikelyVulnerable, "medium", "end-of-life branch; only a source backport exists") 297 } 298 return done(Vulnerable, "high", "end-of-life branch %s with no upstream fix", v.Branch()) 299 } 300 return done(Unknown, "low", "not enough information") 301} 302 303// UpstreamOf strips epoch and revision from a package version. 304func UpstreamOf(pkgVersion string) string { 305 s := pkgVersion 306 if i := strings.IndexByte(s, ':'); i >= 0 { 307 s = s[i+1:] 308 } 309 if i := strings.IndexAny(s, "-+~"); i >= 0 { 310 s = s[:i] 311 } 312 return s 313} 314 315func contains(xs []string, s string) bool { 316 for _, x := range xs { 317 if x == s { 318 return true 319 } 320 } 321 return false 322} 323
Reading the version without running PHP
This also makes container filesystems, extracted images, mounted backups and mod_php modules readable; none of them can be run on their own.
50func StaticProbe(path string, needles []string) (StaticResult, error) { 51 var res StaticResult 52 f, err := os.Open(path) 53 if err != nil { 54 return res, err 55 } 56 defer f.Close() 57 head := make([]byte, 4) 58 if _, err := io.ReadFull(f, head); err != nil { 59 return res, nil 60 }
61 for _, m := range magics { 62 if bytes.Equal(head, m) { 63 res.IsBinary = true 64 } 65 } 66 if !res.IsBinary { 67 return res, nil 68 }
php are skipped.69 all := append([]string{poweredBy}, needles...) 70 maxNeedle := 0 71 for _, n := range all { 72 maxNeedle = max(maxNeedle, len(n)+64) 73 } 74 found := map[string]bool{} 75 buf := make([]byte, 1<<20+maxNeedle) 76 carry := 0 77 copy(buf, head) 78 carry = len(head)
libphp.so) contains the literal X-Powered-By: PHP/ followed by PHP_VERSION. The same pass reads the build timestamp and looks for fix fingerprints.79 for { 80 n, rerr := f.Read(buf[carry:]) 81 chunk := buf[:carry+n] 82 if res.Version == "" { 83 if i := bytes.Index(chunk, []byte(poweredBy)); i >= 0 { 84 rest := chunk[i+len(poweredBy):] 85 if v := versionChars.Find(rest); v != nil && len(rest) > len(v) { 86 res.Version = string(v) 87 } 88 } 89 } 90 if res.BuildDate == "" { 91 if m := buildStamp.FindSubmatch(chunk); m != nil { 92 res.BuildDate = BuildDate(string(m[1]), string(m[2]), string(m[3])) 93 } 94 } 95 for _, nd := range needles { 96 if !found[nd] && bytes.Contains(chunk, []byte(nd)) { 97 found[nd] = true 98 res.Found = append(res.Found, nd) 99 } 100 } 101 if rerr != nil { 102 break 103 } 104 keep := min(maxNeedle, len(chunk)) 105 copy(buf, chunk[len(chunk)-keep:]) 106 carry = keep 107 } 108 return res, nil 109} 110 111var phpVLine = regexp.MustCompile(`(?m)^PHP ([0-9][0-9A-Za-z.+~-]*) \(([^)]*)\)(?: \(built: ([^)]*)\))?`) 112 113// ExecInfo is what `php -v` printed. 114type ExecInfo struct { 115 Version string 116 SAPI string 117 Built string 118 Raw string 119} 120 121// ParseVersionOutput parses `php -v` output. 122func ParseVersionOutput(out string) (ExecInfo, bool) { 123 m := phpVLine.FindStringSubmatch(out) 124 if m == nil { 125 return ExecInfo{}, false 126 } 127 return ExecInfo{Version: m[1], SAPI: m[2], Built: m[3], Raw: strings.TrimSpace(out)}, true 128} 129 130// ExecProbe runs `<bin> -v` on the host with a short timeout and a clean 131// environment. It is only used when the static probe could not find a version. 132func ExecProbe(ctx context.Context, bin string) (ExecInfo, error) { 133 ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 134 defer cancel() 135 cmd := exec.CommandContext(ctx, bin, "-n", "-v") 136 cmd.Env = []string{"PATH=/usr/bin:/bin", "LC_ALL=C"} 137 out, err := cmd.Output() 138 if info, ok := ParseVersionOutput(string(out)); ok { 139 return info, nil 140 } 141 if err == nil { 142 err = errNoVersion 143 } 144 return ExecInfo{}, err 145} 146 147type constErr string 148 149func (e constErr) Error() string { return string(e) } 150 151const errNoVersion = constErr("no version in output") 152
Asking the package database
167func (d *DB) dpkgLookup(p string, markers []string) Info { 168 d.dpkgIndex() 169 var name string 170 for _, q := range alternatives(p) { 171 if n, ok := d.dpkgOwn[q]; ok { 172 name = n 173 break 174 } 175 } 176 e, ok := d.dpkgStat[name] 177 if name == "" || !ok { 178 return Info{} 179 }
dpkg binary, so the same code works on a container's root filesystem or an extracted image.180 info := Info{Package: &advisory.Package{Manager: "dpkg", Name: name, Version: e.Version, Source: e.Source}}
php8.3), not per binary package (php8.3-cli).181 for _, doc := range []string{name, e.Source, e.Source + "-common"} { 182 cl := "/usr/share/doc/" + doc + "/changelog.Debian.gz" 183 hits, err := d.scanChangelog(cl, markers) 184 if err == nil { 185 info.ChangelogChecked, info.ChangelogHits, info.ChangelogPath = true, hits, cl 186 break 187 } 188 } 189 return info 190} 191 192func (d *DB) scanChangelog(p string, markers []string) ([]string, error) { 193 rp, err := d.Root.Resolve(p) 194 if err != nil { 195 return nil, err 196 } 197 f, err := os.Open(d.Root.Real(rp)) 198 if err != nil { 199 return nil, err 200 } 201 defer f.Close() 202 var r io.Reader = f 203 if strings.HasSuffix(p, ".gz") { 204 gz, err := gzip.NewReader(f) 205 if err != nil { 206 return nil, err 207 } 208 defer gz.Close() 209 r = gz 210 } 211 b, err := io.ReadAll(io.LimitReader(r, 64<<20)) 212 if err != nil { 213 return nil, err 214 } 215 return matchMarkers(b, markers), nil 216} 217 218func matchMarkers(b []byte, markers []string) []string { 219 var hits []string 220 for _, m := range markers { 221 if bytes.Contains(b, []byte(m)) { 222 hits = append(hits, m) 223 } 224 } 225 return hits 226} 227 228// ---- apk ----------------------------------------------------------------- 229 230func (d *DB) apkIndex() { 231 if d.apkOwn != nil { 232 return 233 } 234 d.apkOwn = map[string]*advisory.Package{} 235 b, err := d.Root.ReadFile("/lib/apk/db/installed") 236 if err != nil { 237 return 238 } 239 var cur *advisory.Package 240 dir := "" 241 sc := bufio.NewScanner(bytes.NewReader(b)) 242 sc.Buffer(make([]byte, 1<<20), 1<<20) 243 for sc.Scan() { 244 line := sc.Text() 245 if line == "" { 246 cur, dir = nil, "" 247 continue 248 } 249 if len(line) < 2 || line[1] != ':' { 250 continue 251 } 252 v := line[2:] 253 switch line[0] { 254 case 'P': 255 cur = &advisory.Package{Manager: "apk", Name: v} 256 case 'V': 257 if cur != nil { 258 cur.Version = v 259 } 260 case 'o': 261 if cur != nil { 262 cur.Source = v 263 } 264 case 'F': 265 dir = "/" + v 266 case 'R': 267 if cur != nil { 268 if cur.Source == "" { 269 cur.Source = cur.Name 270 } 271 d.apkOwn[path.Join(dir, v)] = cur 272 } 273 } 274 } 275} 276 277func (d *DB) apkLookup(p string) Info { 278 d.apkIndex() 279 for _, q := range alternatives(p) { 280 if pkg, ok := d.apkOwn[q]; ok { 281 // Alpine does not install changelogs; secfixes live in aports. 282 return Info{Package: pkg} 283 } 284 } 285 return Info{} 286} 287 288// ---- rpm ----------------------------------------------------------------- 289 290func (d *DB) rpmArgs(args ...string) []string { 291 if d.RPMRootFlag && d.Root.Dir != "/" && d.Root.Dir != "" { 292 return append([]string{"--root", d.Root.Dir}, args...) 293 } 294 return args 295} 296 297func (d *DB) rpmLookup(ctx context.Context, p string, markers []string) Info { 298 if d.RPM == nil { 299 return Info{} 300 } 301 out, err := d.RPM(ctx, "rpm", d.rpmArgs("-qf", "--qf", `%{NAME}\t%{EPOCHNUM}:%{VERSION}-%{RELEASE}\t%{SOURCERPM}\n`, p)...) 302 if err != nil { 303 return Info{} 304 } 305 line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] 306 f := strings.Split(line, "\t") 307 if len(f) != 3 || strings.Contains(line, "not owned") { 308 return Info{} 309 } 310 ver := strings.TrimPrefix(f[1], "0:") 311 pkg := &advisory.Package{Manager: "rpm", Name: f[0], Version: ver, Source: srpmName(f[2])} 312 info := Info{Package: pkg} 313 if cl, err := d.RPM(ctx, "rpm", d.rpmArgs("-q", "--changelog", f[0])...); err == nil { 314 info.ChangelogChecked = len(bytes.TrimSpace(cl)) > 0 315 info.ChangelogHits = matchMarkers(cl, markers) 316 info.ChangelogPath = "rpm -q --changelog " + f[0] 317 } 318 return info 319} 320 321// srpmName turns "php-8.0.30-4.el9_6.src.rpm" into "php". 322func srpmName(s string) string { 323 s = strings.TrimSuffix(s, ".src.rpm") 324 for i := 0; i < 2; i++ { 325 if j := strings.LastIndexByte(s, '-'); j > 0 { 326 s = s[:j] 327 } 328 } 329 return s 330} 331 332// HostHasRPM reports whether an rpm binary exists on the host. 333func HostHasRPM() bool { 334 _, err := exec.LookPath("rpm") 335 return err == nil 336} 337
Adding the next vulnerability
None of this is specific to PHP2Shell. Fixed branches, vendor tables, vendor policies, build-date rules, changelog markers, fingerprints and the mitigation checks all live in internal/advisory/data/php2shell-2026.json. A new advisory is a new JSON file, or a directory passed with --db without rebuilding.
