Bastion / How it works

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.

1 · parse requestmultipart, headers 2 · run scriptindex.php 3 · shutdownfree request data next requestsame worker OOM → longjmp skips step 2; shutdown trusts half-built data CVE-2026-109618 stale pointer survives CVE-2026-109619

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 upgrade replaces 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 $PATH can 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 low post_max_size and session.upload_progress.enabled=0 close 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 what bastion trace is 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.

VulnerableUbuntu 24.04
$ 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
FixedUbuntu 24.04
$ 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.13

Both 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.

internal/advisory/evaluate.go · lines 172–323extracted from the source when the site is built
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
step 1 of 9Record evidence as you goEvery rule appends an 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.
step 2 of 9Parse the upstream versionThe version comes from the binary itself (see below). If it cannot be read, the package version is used instead.
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
step 3 of 9Upstream releaseAt or above the branch's fixed release (8.3.36, 8.4.27, …) is fixed. Below it is not conclusive for a distribution package, so that evidence is recorded as neutral and the search continues.
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
step 4 of 9Vendor fix tableExact fixed package versions from the Ubuntu, Debian and Red Hat trackers, compared with dpkg's or rpm's own ordering. Entries can be limited to one upstream branch, because RHEL ships several PHP module streams under the same package name.
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
step 5 of 9Dated vendor rebuildsPlesk rebuilt PHP 7.4, 8.0 and 8.1 on 21 October 2026 without changing the version. For those builds the compile date read from the binary decides.
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
step 6 of 9Package changelogVendors list the CVEs they fix in the package changelog (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
step 7 of 9Code only the fix containsThe fix adds a hard memory limit with a new error message. If those bytes are in the binary, the patch is in the build, whatever the version says.
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
step 8 of 9Say what cannot be provenA package whose vendor database or changelog was readable and silent is likely vulnerable. If nothing could be read, the answer is unknown.
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
step 9 of 9Builds outside a package managerDocker official images, BaoTa and source builds follow upstream versions, so the version decides. End-of-life branches with only a source backport cannot be told apart and are reported as likely vulnerable.

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.

internal/discover/probe.go · lines 50–152extracted from the source when the site is built
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	}
step 1 of 4Never execute what you inspectRunning an unknown binary as root to ask for its version is a bad habit for a security tool. Bastion opens the file and reads it.
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	}
step 2 of 4Only real executablesELF and Mach-O headers are checked first, so wrapper scripts named 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)
step 3 of 4The version is compiled inEvery SAPI (CLI, FPM, CGI, mod_php's 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
step 4 of 4Stream the file1 MiB chunks with an overlap: a string that spans two chunks is still found and memory use stays flat.

Asking the package database

internal/pkgdb/pkgdb.go · lines 167–337extracted from the source when the site is built
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	}
step 1 of 3Which package owns the filedpkg's database is parsed directly, without the 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}}
step 2 of 3Source package and versionVendors publish fixes per source package (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
step 3 of 3Read the changelogAn installed package's changelog stops at the installed version, so a CVE id in it means the fix is installed.

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.