nsupdate.php/nsupdate_ipv4.php/nsupdate_ipv6.php rewritten to build the nsupdate command as a string written to proc_open()'s stdin, using the array form of the command so no shell is ever invoked -- no value can be interpreted as shell syntax regardless of content. Added hostname/IP validation as defense in depth. Originals backed up. Also fixes a regression discovered while testing: www-data never got read access to the TSIG key when it was tightened to 640 root:bind (2026-07-19), so legitimate dyndns updates were silently failing (REFUSED) independent of the vulnerability. Fixed with a POSIX ACL scoped to just that key file rather than group membership, since the bind group also owns rndc.key (full remote BIND control). Verified end-to-end via the real PHP functions: injection payloads rejected with no side effects, invalid IP rejected, legitimate update succeeds and is visible in the zone, unauthenticated requests still 401.
199 lines
12 KiB
Markdown
199 lines
12 KiB
Markdown
# alpha.jayfield.org — TODO
|
|
|
|
Outstanding hardening items (see `README.md` for the full service overview).
|
|
Nothing here is urgent; all are low-risk, no-downtime changes.
|
|
|
|
- [x] **OS command injection in the dyndns update scripts** — done 2026-07-20
|
|
`/var/www/dyndns/nsupdate.php`, `nsupdate_ipv4.php`, and
|
|
`nsupdate_ipv6.php` (used by both `index.php` and `nic/update.php`,
|
|
the router/ddclient-facing endpoint) built a shell command string by
|
|
directly interpolating unsanitized `$_GET` input (`hostname`/`myip`/
|
|
`ip`, which become `$host`/`$ip`/`$ipv4`/`$ipv6`) and passed it to
|
|
PHP's `exec()`, which runs it via `/bin/sh -c` — no
|
|
`escapeshellarg()`/`escapeshellcmd()` anywhere. A request like
|
|
`hostname=$(id>/tmp/pwned).dyndns.jayfield.org` would have executed
|
|
arbitrary shell commands as `www-data` (the domain-suffix check
|
|
still passed after the `.`-split). `nic/update.php` had no
|
|
application-level auth of its own at all.
|
|
Mitigating factor: the whole `dyndns.jayfield.org` vhost sits behind
|
|
Apache Basic Auth, so this needed valid (or leaked/brute-forced)
|
|
dyndns credentials to reach — not exploitable by an anonymous
|
|
visitor. Compounding factors found during the audit: PHP is
|
|
`7.4.33`, EOL since Nov 2022, `disable_functions` was empty (`exec`
|
|
fully available); `index.php`'s `pass == '_NO_PASS_'` check is a
|
|
hardcoded magic string, not real per-user auth, so any valid dyndns
|
|
credential can update *any* of the zone's 5 hostnames (`jens`,
|
|
`kack`, `test`, `uschi`, `vpn`), not just its own — left as-is, out
|
|
of scope for this fix; and the vhost's access logging turned out to
|
|
be a no-op (shared `access.log` has 0 lines), so past exploitation
|
|
couldn't be fully ruled out from logs alone — though file mtimes,
|
|
`www-data` processes/crontab, and zone contents all looked
|
|
unremarkable, consistent with unexploited.
|
|
Fixed: all three files backed up to
|
|
`/root/removed-configs-backup/dyndns-preinjectionfix-<timestamp>/`,
|
|
then rewritten to build the nsupdate command list as a plain string
|
|
written to `proc_open()`'s stdin pipe using the **array form** of
|
|
the command (`array("/usr/bin/nsupdate", "-k", ...)`) — this never
|
|
invokes a shell at all, so no value can be interpreted as shell
|
|
syntax regardless of content. Added strict `preg_match` validation
|
|
requiring `$host`/`$domain` to be shaped like a valid hostname
|
|
(letters/digits/hyphens, dot-separated labels) and
|
|
`filter_var(..., FILTER_VALIDATE_IP, FILTER_FLAG_IPV4/IPV6)` on the
|
|
IP before use, as defense in depth on top of removing the shell.
|
|
**Regression found and fixed along the way**: functional testing
|
|
showed legitimate updates were *already silently failing*
|
|
(`update failed: REFUSED`, `permission denied` reading the TSIG
|
|
key) — turned out `www-data` was never granted read access to
|
|
`/etc/bind/dyndns.jayfield.org.key` when its permissions were
|
|
tightened to `640 root:bind` in the item below, so the dyndns web
|
|
update feature had been broken for real users since 2026-07-19,
|
|
independent of this vulnerability. Fixed with a narrowly-scoped
|
|
POSIX ACL (`setfacl -m u:www-data:r
|
|
/etc/bind/dyndns.jayfield.org.key`, `acl` package installed) rather
|
|
than adding `www-data` to the `bind` group — the group also owns
|
|
`rndc.key` (full remote BIND control), which would have been a much
|
|
bigger privilege grant than dyndns needs.
|
|
Verified via `sudo -u www-data php` driving the real functions
|
|
directly (same code path as the web UI, without needing the Basic
|
|
Auth password): two injection payloads (`$(...)` and `;`-chained)
|
|
both rejected (`-1`, no `/tmp` side-effect file created), an
|
|
invalid IP rejected (`-1`), and a legitimate update succeeded
|
|
(`0`), confirmed live in the zone via `dig`, then cleaned up (test
|
|
record deleted via `nsupdate`, temp test script removed). Also
|
|
confirmed unauthenticated requests still get `401` from Apache
|
|
before ever reaching the PHP, and `apache2ctl configtest` stayed
|
|
clean throughout.
|
|
|
|
- [x] **Public static site (`jayfield.org`/`www.jayfield.org`) was 401'ing every visitor** — done 2026-07-20
|
|
Found while auditing which services are reachable without credentials:
|
|
a stray `/var/www/html/.htaccess` (`AuthType Basic`, same
|
|
`AuthName "Restricted Content"`/`AuthUserFile /etc/apache2/.htpasswd`
|
|
as the intentional `dyndns.jayfield.org` protection) was silently
|
|
requiring login for the DocumentRoot both `jayfield.org` and
|
|
`www.jayfield.org` share — `/var/www` has `AllowOverride all`, so it
|
|
took effect with no vhost-level directive needed. File was dated
|
|
2025-02-18 (`index.html`/`apache.html` themselves are from 2022) —
|
|
predates every other change in this doc by well over a year, almost
|
|
certainly a forgotten leftover rather than intentional, and
|
|
contradicts `README.md`'s own description of both as a public static
|
|
site.
|
|
Fixed: moved to `/root/removed-configs-backup/htaccess-var-www-html.<timestamp>`
|
|
(recoverable, not deleted — same convention as this file's other
|
|
removed-config backups). No Apache reload needed (`.htaccess` is
|
|
read per-request). Verified both `jayfield.org` and
|
|
`www.jayfield.org` now return `200` with real page content;
|
|
`dyndns.jayfield.org`'s own (intentional, vhost-level) Basic Auth is
|
|
untouched and still returns `401` without credentials.
|
|
|
|
- [x] **Lock down the dyndns TSIG key file permissions** — done 2026-07-19
|
|
`/etc/bind/dyndns.jayfield.org.key` was `644` (world-readable), holding
|
|
the shared secret that authenticates dynamic DNS updates for
|
|
`dyndns.jayfield.org` with no IP-based `allow-update` backing it up.
|
|
Fixed: `chmod 640` + `chown root:bind` (owner was already correct),
|
|
matching `rndc.key`'s permissions.
|
|
|
|
- [x] **Suppress BIND version disclosure** — done 2026-07-19
|
|
No `version` statement in `named.conf.options`, so
|
|
`dig CH TXT version.bind @ns1.jayfield.org` leaked the exact BIND
|
|
version (`9.18.39`) — a minor fingerprinting aid for attackers.
|
|
Fixed: added `version "unknown";` inside the `options {}` block,
|
|
validated with `named-checkconf`, and applied via `rndc reload`.
|
|
Verified `dig CH TXT version.bind` now returns `"unknown"`.
|
|
|
|
- [x] **Add response rate limiting (RRL)** — done 2026-07-19
|
|
No `rate-limit {}` block was configured. Since `allow-query { any; }`
|
|
is required for a public authoritative zone, RRL is the standard
|
|
mitigation against the server being used for spoofed-source DNS
|
|
reflection/amplification against third parties.
|
|
Fixed: added `rate-limit { responses-per-second 10; window 5; }` to
|
|
`named.conf.options`, validated with `named-checkconf`, and applied
|
|
via `rndc reload`. Started with conservative defaults (10/s, 5s
|
|
window) — revisit if legitimate high-volume resolvers get throttled.
|
|
|
|
- [x] **Add HSTS headers to all vhosts** — done 2026-07-19
|
|
No vhost sent a `Strict-Transport-Security` header, leaving a window
|
|
for on-path SSL-stripping on a user's very first plaintext request to
|
|
any domain.
|
|
Fixed: added `Header always set Strict-Transport-Security
|
|
"max-age=63072000; includeSubDomains"` to all 6 domain `:443` blocks
|
|
(`dyndns`, `jayfield.org`, `www`, `mail`, `web`, `cloud`) — skipped
|
|
`default-ssl.conf` since it's the self-signed catch-all with no real
|
|
trusted hostname for a browser to pin. Validated with
|
|
`apache2ctl configtest`, applied via `systemctl reload apache2`,
|
|
verified live with `curl -sI` against all 6 domains.
|
|
|
|
- [x] **`jayfield.org` had no `:80` vhost of its own** — done 2026-07-19
|
|
Bare `http://jayfield.org` fell through to the `dyndns` default vhost
|
|
and redirected to the wrong domain.
|
|
Fixed: added a `:80` block to `jayfield.org.conf` redirecting to
|
|
`https://jayfield.org/`, matching the `dyndns`/`mail` pattern.
|
|
Verified: `curl -sI http://jayfield.org/` → `302` with
|
|
`Location: https://jayfield.org/`.
|
|
|
|
- [x] **SSH: disable root login + password auth** — done 2026-07-19
|
|
`PermitRootLogin yes` and `PasswordAuthentication yes` were both still
|
|
enabled (the "known deviation" flagged in `SETUP.md` §1) — every
|
|
credential-stuffing bot on the internet got a real login prompt to
|
|
grind against.
|
|
Fixed: `PermitRootLogin no`, `PasswordAuthentication no`,
|
|
`PubkeyAuthentication yes` in `/etc/ssh/sshd_config` (backed up as
|
|
`sshd_config.bak.20260719225249`). The `Match Group sftp` block's own
|
|
`PasswordAuthentication yes` was left as-is — separate, already-chrooted,
|
|
no-shell/no-forwarding accounts, not part of this risk. Validated with
|
|
`sshd -t`, applied via `systemctl reload sshd`, verified live with
|
|
`sshd -T` and a fresh key-only connection before considering it done.
|
|
|
|
- [x] **Portainer's `:9443` UI is unreachable in HSTS-enforcing browsers** — done 2026-07-20
|
|
`https://alpha.jayfield.org:9443` threw
|
|
`MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT` in Firefox with no click-through
|
|
option ("keine Ausnahme kann hinzugefügt werden"). Root cause: the
|
|
`includeSubDomains` HSTS policy sent by the real `alpha.jayfield.org`
|
|
vhost (`Strict-Transport-Security` header, added in the item above)
|
|
pins the *hostname* to HTTPS-with-a-trusted-cert on every port, but
|
|
Portainer (`README.md` §Docker services) was still serving its stock
|
|
self-signed cert directly on `9443`. Not a MITM — self-inflicted by
|
|
combining HSTS with an unproxied self-signed service.
|
|
Fixed: added a `portainer` A record to `db.jayfield.org` (SOA serial
|
|
`2022032216` → `2022032217`); recreated the `portainer` container
|
|
binding the HTTPS UI to `127.0.0.1:9443:9443` instead of publishing it
|
|
(old container kept renamed until the new one was verified, then
|
|
removed, matching the update procedure documented in `README.md`);
|
|
added an Apache vhost for `portainer.jayfield.org`
|
|
(`ProxyPass`/`ProxyPassReverse` to `https://127.0.0.1:9443/`,
|
|
`SSLProxyEngine on`, `SSLProxyVerify none` + `SSLProxyCheckPeerCN off`/
|
|
`SSLProxyCheckPeerName off` for the loopback hop to Portainer's
|
|
self-signed cert) — same pattern as `web.jayfield.org`/
|
|
`cloud.jayfield.org` (`SETUP.md` §4/§5); issued a real cert via
|
|
`certbot --apache -d portainer.jayfield.org --redirect`; added the
|
|
standard HSTS header. Left the `8000` edge-agent tunnel port
|
|
published as-is — no evidence it's in use.
|
|
Verified: `openssl s_client`/`curl` confirm the Let's Encrypt cert
|
|
(not self-signed) and `Strict-Transport-Security` header on
|
|
`https://portainer.jayfield.org`, `http://` redirects 301 to
|
|
`https://`, and `ss -ltnp` shows `9443` bound to `127.0.0.1` only
|
|
(via `docker-proxy`), so it's no longer reachable on the public
|
|
interface at all — access is now exclusively via
|
|
`https://portainer.jayfield.org`.
|
|
One transient wrinkle during rollout: the very first few requests
|
|
through the new proxy vhost returned a stray `401` with dyndns's
|
|
Basic Auth realm ("Restricted Content") — looked alarming, but
|
|
`LogLevel trace8` on the vhost showed Apache correctly SNI-matching
|
|
and proxying to the new container with a clean backend `200`;
|
|
resolved itself within seconds (looked like a stale pooled proxy
|
|
connection surviving the container recreate) and was stable across
|
|
repeated checks before the debug logging was removed again.
|
|
|
|
- [x] **fail2ban: `sshd` jail was evadable via low-and-slow attempts** — done 2026-07-19
|
|
Default `findtime=30m`/`maxretry=3` never triggered for `47.76.192.176`,
|
|
which had been hitting `root`/`jayfield` logins roughly every 33 minutes
|
|
for most of a day — just outside the 30-minute window, so failures kept
|
|
aging out before reaching 3 strikes. Confirmed via `fail2ban.log`
|
|
(`Found`/`Ban` counts) that no other jail showed the same per-IP timing
|
|
pattern; a separate, *distributed* single-shot scan on `apache-noscript`
|
|
was noted but left open (see `README.md`).
|
|
Fixed: `/etc/fail2ban/jail.d/sshd-findtime.local` overrides `sshd` to
|
|
`findtime=1d`, `maxretry=4`, `bantime=1d`, kept in its own drop-in
|
|
rather than editing the Debian-packaged jail file. Applied via
|
|
`fail2ban-client reload sshd`; `47.76.192.176` was banned on its very
|
|
next attempt after the change.
|