⚠ Security alert of 10/23/2026 · run the test in the Audit section today, on every machine
Critical · eMudhra + PHP2Shell · October 2026

Guide v1.1.2 · updated on 10/23/2026

Update and audit your systems today

Your browser already protects you. Your server, your Python, your Node, your containers, and your n8n might not. Here is how to really test them and how to fix each layer.

CRITICAL: update your systems now. Broken certificate keys (one was the number 4) + PHP2Shell flaw
In short

This is a security guide for anyone running a server, website, automation, or AI agent. In October 2026, two serious pieces of news arrived together: the secret keys of 8 certificates from the certificate authority eMudhra leaked (one of them was literally the number 4), and a flaw in PHP, called PHP2Shell, lets attackers break into sites without a password. The guide shows how to test whether each tool is still exposed, using a script that installs and changes nothing (it only runs the tests), and how to update each layer. All you need is a terminal on the machine you want to check.

What happened

Two serious flaws in the same week

One breaks the trust behind the HTTPS padlock outside the browser. The other opens PHP sites to break-ins without a login.

Security audit: update, audit, and test the real connection — system, Python, Node, Docker, n8n, PHP

1. eMudhra: certificate keys that fit in a tiny number

8

intermediate certificates

From the certificate authority eMudhra, created in a ceremony on 04/30/2025: 6 for websites (TLS) and 2 for signing software. The chains go through the root emSign ECC Root CA – G3 and the cross-signed AAA Certificate Services (Sectigo).

4

one of the secret keys

P-384 keys should be gigantic numbers. These came out smaller than 256: 4, 56, 66, 115, 155, 156, 230 and 237. Anyone can find them in under a second. Discovered on 10/17/2026 by the researcher "Wayne".

?

why it is serious

With the key of a trusted certificate authority, you can issue a "valid" HTTPS certificate for any site and sit in the middle of the connection. As of 10/21 there was no confirmed exploitation, but the keys are public.

✅ Already protected

Browsers: Chrome and Firefox (10/17), Microsoft (10/20), Apple (10/22). Fixed Firefox: 158.0.1 (NSS 3.131.1).

⚠ Need your action

Everything that is not a browser and has its own certificate list: old curl, Python (certifi), Node, Go, Docker, n8n, AI agents, Electron apps, Rust programs, devices without updates.

2. PHP2Shell: breaking into PHP sites without a password

What it is

Two critical flaws in PHP (CVE-2026-109618 and CVE-2026-109619) that allow running commands on the server without logging in. Advisory from the PHP team on 10/21/2026.

Who is exposed

Every PHP installation that serves pages: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP, and LiteSpeed. Higher risk with mod_php. Includes most WordPress sites.

Fixed versions

8.5.12 · 8.4.27 · 8.3.36 · 8.2.35. Details and what to do in PHP2Shell.

Audit · test the real connection

Checking the version isn't enough: test whether the tool connects

The test sites below use certificates from the compromised chains. If the tool connects, it still trusts the broken keys. If you get a certificate error, it's protected.

10-second test (curl)

curl https://dv-g3b-emudhra.we-have-a-problem.com/
curl https://dv-g3b-aaa-emudhra.we-have-a-problem.com/
# "SSL certificate problem" = protegido
# shows the page          = EXPOSTO

Node and Python, the same way

node -e "require('https').get('https://dv-g3b-emudhra.we-have-a-problem.com/',r=>{console.log('EXPOSTO');process.exit()}).on('error',e=>{console.log(/CERT|ISSUER|UNABLE_TO|SELF_SIGNED|DEPTH_ZERO|ERR_TLS/.test(e.code)?'protegido':'SEM TESTE',e.code);process.exit()})"

python3 -c "import ssl,urllib.request as u,urllib.error as E
try:
  import certifi; ctx=ssl.create_default_context(cafile=certifi.where())
except ImportError: ctx=ssl.create_default_context()
try: u.urlopen('https://dv-g3b-emudhra.we-have-a-problem.com/',context=ctx); print('EXPOSTO')
except E.HTTPError: print('EXPOSTO')
except Exception as e: print('protegido' if 'CERTIFICATE_VERIFY_FAILED' in str(e) else 'SEM TESTE', e)"

A network error (ENOTFOUND, timeout) is not "protected". When in doubt, use audita.sh.

Windows and macOS

audita.sh is for Linux. On Windows (PowerShell) and macOS (Terminal), the quick test is the same curl: curl.exe https://dv-g3b-emudhra.we-have-a-problem.com/ (Windows) or curl https://dv-g3b-emudhra.we-have-a-problem.com/ (macOS), and the same for the dv-g3b-aaa-emudhra chain. Certificate error = protected. If you have Node or Python installed, also run the tests above.

Test both chains. In our audit, several tools rejected the AAA chain and accepted the G3 chain (or the other way around). A single test can give a false sense of security. And a network error (DNS, timeout) is not "protected": it's "no test".

The audita.sh script: everything at once, changes nothing

It tests the real connection with curl, wget, every Node, Deno, and Bun found on disk (including the ones bundled inside tools), the system Python, each Python environment (venv or conda) with its own certifi, and, from the inside, every running Docker container. It checks the Java list (the cacerts file), the PHP version on the server and in containers (PHP2Shell), outdated certifi, and processes that need to be restarted. At the end, it groups what it found by type, with the fix for each. It doesn't install, change, or fix anything, but it runs the tools it tests (that's how it proves the connection). It's a survey: it points out problems; it doesn't certify that the machine is safe.

1

Download the script and read it before running

Golden rule: never run a script from the internet without looking at what it does. This one has about 650 lines, commented.

curl -fsSLO https://raw.githubusercontent.com/inematds/auditoria-seguranca/main/audita.sh
less audita.sh            # read it
chmod +x audita.sh

Or clone the repository: git clone https://github.com/inematds/auditoria-seguranca.

2

Run it on the machine

Without sudo it audits your user (and the containers, if you're in the docker group). With sudo it also sees other users' folders. The disk search takes up to 3 minutes; --rapido skips that part.

./audita.sh                 # full
sudo ./audita.sh            # includes Docker containers and other users
./audita.sh --rapido        # without searching the disk for venvs, Node, Deno, Bun, and JDKs
./audita.sh --sem-docker    # doesn't enter the containers
./audita.sh --comandos      # shows the fix commands (runs none of them)
./audita.sh --go            # also tests Go for real (see below)
./audita.sh --raiz /opt     # searches the disk only in this folder (repeat --raiz for several)

Two tests are not connection tests, and the script says so on the line. Java: it checks whether Java's trust file (cacerts) still has the two roots, because testing the connection would require compiling Java code. Go: the real test compiles a small program in a temporary folder (about 100 MB, deleted at the end). Since this writes to disk, it only runs with --go.

With sudo, the Node installed by your user's nvm isn't on the default path; the disk search still finds it (don't use --rapido in that case).

With sudo, the script doesn't run as root anything that someone else can swap. A program found on disk (Node, Deno, Bun, a venv's Python, a JDK's keytool) only runs if the file, every link, and every folder on the path up to the root belong to root or to the owner and nobody else can write to them. A user's program runs as that user (runuser), never as root. Anything that doesn't pass becomes SEM TESTE (NO TEST), with the reason. Even so, run as root only on a machine you administer.
3

Read the result: five states

== meuservidor · Ubuntu 24.04.3 LTS · 23/10/2026 02:36 · audita.sh 1.1.2
-- versões
ca-certificates 20260816ubuntu0.24.04.1
-- conexão real (G3 = cadeia emSign ECC Root CA - G3 · AAA = cadeia cruzada AAA Certificate Services)
protegido  curl                               G3
protegido  curl                               AAA
EXPOSTO    node v24.13.0 /usr/bin/node        G3
protegido  node v24.13.0 /usr/bin/node        AAA
          versão: ATUALIZE para 24.22.1+
EXPOSTO    deno 2.7.14 ~/.deno/bin/deno       G3
EXPOSTO    venv ~/projetos/app/.venv           G3
protegido  venv ~/projetos/app/.venv           AAA
-- Java: lista de confiança (cacerts) — conferida pelo keytool, sem conexão
EXPOSTO    java /etc/ssl/certs/java/cacerts   G3   (lista do keystore, não conexão)
-- containers Docker em execução (teste de dentro de cada um)
   [n8n] n8nio/n8n:2.4.8
   EXPOSTO    node v20.19.0                      G3
   [painel-db] adminer:latest
   EXPOSTO    php 8.4.21                         PHP  PHP2Shell: atualize para 8.4.27+
-- processos a reiniciar (não reinicie no meio de job, upload ou live; escolha a hora)
REINICIE   pid 3361     meubot.service (sistema)           /usr/bin/node dist/index.js
           o binário foi atualizado depois que o processo começou
-- resumo meuservidor: 7 EXPOSTO · 0 SEM TESTE · 0 VERIFIQUE · 1 REINICIE
-- o que corrigir, por tipo (conta itens; o resumo acima conta linhas, 2 por item: G3 e AAA)
     1  Node
        ex.: /usr/bin/node (v24.13.0)
        → sudo apt update && sudo apt install --only-upgrade nodejs
     1  Deno
        ex.: ~/.deno/bin/deno (2.7.14)
        → ~/.deno/bin/deno upgrade
     1  containers de versão fixada (grupo C: subir a versão)
        ex.: n8n (n8nio/n8n:2.4.8)
        → versão fixada: a tag pode ter sido republicada com a base corrigida — salve o digest atual, puxe a mesma tag e recrie; se o digest não mudou ou seguir EXPOSTO, suba a versão
     1  processos a reiniciar (fora de job, upload ou live)
        ex.: meubot.service (sistema)
        → sudo systemctl restart meubot.service

Sample excerpt (the real output has more lines; the script writes in Portuguese). G3 and AAA are the two "trust paths" tested: each site certificate is signed by an intermediate, which is signed by a root that your program trusts.

  • EXPOSTO (EXPOSED): it connected (or, in Java, the list still has the roots; in PHP, the version is vulnerable). Fix that layer (Update section).
  • protegido (protected): it refused with a certificate error.
  • SEM TESTE (NO TEST): the test didn't finish or proves nothing. Network, DNS, timeout, a container without a shell or without the tool, truncated output, an SSH that failed, an interrupted disk search, a program that isn't safe to run, or busybox's wget with an ambiguous refusal (that only counts as protected if the system's certificate list doesn't have the root of that chain). It never counts as protected. Repeat it or test another way before concluding.
  • VERIFIQUE (CHECK): PHP in a test version (RC, beta, dev: a higher number doesn't prove a fix) or below the fixed version, but with a distribution version number (it may have the fix through a backport). On Debian/Ubuntu the script already looks for the CVE in the package's local changelog; if it doesn't find it, check with apt changelog <package> | grep CVE-2026-109618. Checked and fixed? Run with --php-ok and VERIFIQUE stops holding up the exit code.
  • REINICIE (RESTART): a process that started before the fix and still uses the old program or list. See what to restart.

At the end comes the what to fix, by type block: how many items of each type, an example, and the suggested command. The summary counts lines (each tool appears twice, G3 and AAA); the block counts items. With --comandos the full list comes out, one command per item, for you to review and run one by one. The script never runs the fix.

At the end there is also the escopo desta rodada (scope of this run) block: what was left out (--rapido, --sem-docker, --raiz, --php-ok, Go without --go, and which user it ran as).

Exit code: 0 the checks that were done passed, within the printed scope; 1 there is EXPOSTO; 2 there is SEM TESTE; 3 nothing connects, but processes still need to be restarted or PHP needs checking. Exit 0 doesn't mean the whole machine is safe. In monitoring, treat any exit other than 0 as an alert, and don't release an automatic deploy on exit 0 alone.

4

Audit several servers over SSH

The script goes through SSH itself and runs there, without copying anything to the server. Use key-based access.

./audita.sh --ssh root@servidor1 --ssh deploy@servidor2

# or a list (one destination per line; don't version this file)
./audita.sh --hosts servidores.txt

# specific SSH key (applies to all destinations)
./audita.sh --chave ~/.ssh/minha_chave --ssh root@servidor1

If SSH doesn't connect or drops midway (timeout, bash missing there), the line says SEM TESTE (NO TEST) with the code and the exit is not 0: a server that did not respond never passes as clean. Example servidores.txt: one line root@203.0.113.10, another deploy@vps2 # loja. There, the script runs as the SSH user: to see the containers and the folders of other users, log in as root (or as a user in the docker group).

5

Fix, restart, and run again

The audit is only done when the summary shows 0 EXPOSTO · 0 SEM TESTE · 0 VERIFIQUE · 0 REINICIE (exit 0) on every machine, in a full pass (without --rapido, and with the scope block showing nothing important left out). A process that was already running keeps the old list in memory: updating without restarting doesn't fix it.

How to update · layer by layer

Updating the system is not enough

Each tool can have its own list of trusted certificates. The system's ca-certificates protects curl and anything that uses the system list. Node, Python with certifi, Go, Electron, Rust, and every container have their own.

⚠ Before updating anything: back up and check health. After: test and validate.

Before. Back up what can't be recovered: the database, volumes, the old image under a backup tag, and the configuration. And confirm the system is healthy right now, so you can compare afterward.

docker exec meu-postgres pg_dumpall -U postgres > backup-$(date +%F).sql   # database
docker tag meuapp:latest meuapp:pre-update                              # old image as a backup
docker inspect meuapp > meuapp.inspect.json                             # ports, env, volumes
sudo tar czf config-$(date +%F).tgz /etc/nginx /caminho/do/app/.env       # configuration
curl -fsS https://meusite/health && echo OK                             # health before
docker exec meu-postgres psql -U postgres -d app -c "select count(*) from clientes"   # write down the numbers

After. Plenty of things that were running can turn out to be incompatible with the new version. We saw an unpinned dependency break an app, a venv resync and undo the fix, and a proxy hold on to the old address and answer 502. So test and validate: the service came up, it responds, it does what it did before, and the data is there (same counts). Only call it done when it's ok and live.

LayerFixed versionWhere the list lives
Ubuntu20260816ubuntu0.<Ubuntu version> (e.g., 20260816ubuntu0.24.04.1)system
Debian20250419~deb12u2 (Debian 12) · 20250419+deb13u1 (Debian 13)system
Alpineca-certificates 20261020-r0system / containers
Pythoncertifi 2026.10.19 · pip 26.2.2 · uv 0.13.5 · Python itself: 3.15.1 · 3.14.9 · 3.13.17in each venv
Node22.23.4 · 24.22.1 · 26.12.1 (Node 20: no fix)inside the binary
Go1.27.3 · 1.26.10 + recompileon Linux, the system's (see step 4)
Java (OpenJDK)the two roots removed from cacerts (keytool -delete) or an updated JDKeach JDK's cacerts file
Deno · Bunrejected both chains in our test: Deno 2.9.8 · Bun 1.4.4inside the binary
Firefox / NSS158.0.1 · NSS 3.131.1browser
Electron44.9.1 · 43.8.1 · 42.11.16inside each app
Rustrustls-webpki 0.103.16 · webpki-roots 1.0.10inside each program
FreeBSDca_root_nss 1.131.1 (ports) · base: 15.1-RELEASE-p6 · 14.5-RELEASE-p3 · 14.4-RELEASE-p12 (FreeBSD-SA-26:73)system
1

System (ca-certificates)

# Ubuntu / Debian
sudo apt update && sudo apt install --only-upgrade ca-certificates
dpkg-query -W ca-certificates          # check the version
# Alpine
apk upgrade --no-cache ca-certificates
# Fedora / RHEL
sudo dnf upgrade ca-certificates

Keep automatic updates on (unattended-upgrades on Ubuntu/Debian). In our case, that's what fixed the system on its own.

2

Python: each environment's certifi

requests and many packages use certifi, not the system list. Each venv has its own. audita.sh lists the outdated ones.

/caminho/do/venv/bin/python -m pip install -U certifi pip
python3 -c "import certifi; print(certifi.__version__, certifi.where())"  # 2026.10.19 or newer
# tools installed with pipx / uv tool
pipx upgrade-all        # or: pipx runpip <app> install -U certifi
uv self update          # uv 0.13.5 or newer
uv tool upgrade --all
# conda environments (miniconda/anaconda)
conda update -n <environment> certifi

Watch out for the "system" Python: if EXPOSTO (EXPOSED) shows up in certifi mode, there's a certifi installed with pip --user in your home folder (~/.local/lib/python3.X/). The path printed by the command above tells you which one. On Ubuntu 24.04 and Debian 12+, pip refuses to touch that ("externally-managed-environment"); since the change stays in your user folder only, use:

python3 -m pip install --user --break-system-packages -U certifi

The system package's certifi (/usr/lib/python3/dist-packages) doesn't need this: on Debian/Ubuntu it points to the ca-certificates list.

Venv without pip (created by uv): the -m pip command fails. Use uv pointing to the venv's Python:

uv pip install --python /caminho/do/venv/bin/python -U certifi
The uv run trap: a project that starts with uv run resyncs the venv from uv.lock on every start and undoes the new certifi. Raise certifi in the lock itself: uv lock --upgrade-package certifi && uv sync. audita.sh warns you when the venv has a uv.lock next to it.
3

Node: switch versions (Node 20 has no fix)

Node ships its certificate list inside the program itself. Updating the system changes nothing for it.

# with nvm
nvm install 22 && nvm alias default 22     # 22.23.4 or newer
# with the NodeSource repository (apt): it's separate per version.
# If you're on 20, switch the repository to 22 before installing:
curl -fsSLo nodesource_setup.sh https://deb.nodesource.com/setup_22.x
less nodesource_setup.sh                  # read it
sudo bash nodesource_setup.sh && sudo apt install -y nodejs
# already on 22 or 24? Just update
sudo apt update && sudo apt install --only-upgrade nodejs
node -v

Stopgap for those who can't switch today (tested by us): make Node use the already-updated system list. It only works if ca-certificates is fixed. --use-system-ca does not work: it adds the two lists together and was still EXPOSTO (EXPOSED) in our test.

export NODE_OPTIONS=--use-openssl-ca   # in the app's service/environment; then restart

Node downloaded from nvm or nodejs.org may not find the system list on its own: if the test errors out on everything, add SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt and confirm with audita.sh. NODE_OPTIONS is the environment variable that passes options to every Node started there (in systemd: Environment=NODE_OPTIONS=--use-openssl-ca; in Docker: the compose environment:).

Also look for embedded Node: Playwright, AI agents, and apps ship their own Node. audita.sh finds those binaries on disk; update the tool that brought them. In the case of Playwright inside a Python venv, Node lives in …/site-packages/playwright/driver/node: update the package (pip install -U playwright; with npm, npm i playwright@latest) or use the NODE_OPTIONS=--use-openssl-ca stopgap in the service that uses it.

4

Go: update and recompile

Install Go 1.27.3 or 1.26.10 and build the binary again (go build). Third-party Go programs: update to whatever version they release after 10/21.

What we saw in our test, on Linux: a program built with Go 1.26.3 rejected both chains with the fixed system list and connected when we pointed it to an old list (SSL_CERT_FILE). In other words, on Linux Go follows the system list, or the list of the container it runs in. Fixing ca-certificates already protects a good share of Go programs; those that embed their own list (the x509roots/fallback package) or run in a container with an old image are still exposed. So recompile anyway. For the real test: ./audita.sh --go.

5

Java: the cacerts file falls behind

Java doesn't read the system list directly: it uses its own file, cacerts. On Debian/Ubuntu that file is generated from ca-certificates, but it may not be rebuilt on update. In our case, the system was already fixed and OpenJDK 8's cacerts was still from 2025, with both roots inside.

# where is the cacerts of the Java you use
readlink -f $(dirname $(readlink -f $(which java)))/../lib/security/cacerts
ls /usr/lib/jvm/                        # other installed JDKs
# does it still have the roots?
keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -qiE "emsign_ecc_root_ca_-_g3|comodo_aaa" && echo EXPOSTO || echo limpo
# Debian/Ubuntu: keep a copy and remove the two roots from cacerts
sudo cp -a /etc/ssl/certs/java/cacerts /etc/ssl/certs/java/cacerts.bak
sudo keytool -delete -alias debian:emsign_ecc_root_ca_-_g3.pem -keystore /etc/ssl/certs/java/cacerts -storepass changeit
sudo keytool -delete -alias debian:comodo_aaa_services_root.pem -keystore /etc/ssl/certs/java/cacerts -storepass changeit
update-ca-certificates -f alone is not enough. In our test it rewrote the file, but the two roots were still there, even with the system already fixed. That's why the removal is done by hand, with the keytool -delete above. The names (debian:…) are Debian/Ubuntu's; on another JDK, use the name keytool -list shows. Then run the test again: it must return limpo. The fixed system already removed those two roots from its own list (check: grep -iE "emSign_ECC_Root_CA_-_G3|Comodo_AAA" /etc/ca-certificates.conf shows both with a ! in front, which means "distrust"); cacerts just fell behind. Only remove them from Java after you see that. If some site stops opening in a Java program after this, update the JDK and ca-certificates-java instead of putting the root back. Remember: for Java, audita.sh checks the list, it doesn't make a connection.

A JDK with its own cacerts (downloaded separately, inside an IDE, in an /opt): update the JDK. changeit is the file's default password, which is public; it only protects against accidental writes. Then restart the Java programs.

6

Deno and Bun: the list comes inside the program

Like Node, both ship the list embedded. In our test, Deno 2.7.12 and Bun 1.3.13 connected; after updating, Deno 2.9.8 and Bun 1.4.4 rejected both chains.

deno upgrade
bun upgrade
# one-line test (certificate error = protected)
deno eval "await fetch('https://dv-g3b-emudhra.we-have-a-problem.com/')"
bun -e "await fetch('https://dv-g3b-emudhra.we-have-a-problem.com/')"

Both live in the user's folder (~/.deno, ~/.bun): run the update as the owning user, not with sudo.

7

Docker: every container is a machine

The container doesn't use the server's ca-certificates. Old image = old list. The fix depends on how the image got there, and audita.sh already sorts the exposed containers into these groups (using docker inspect, without touching anything).

A · Floating tag

redis:alpine, nginx:alpine, postgres:16-alpine, …:latest, …:main. The vendor swaps the image behind the same tag: just pull and recreate.

B · Built by you

An image made with docker build from your own Dockerfile. Rebuilding from scratch can break the app; the safe path is the layer on top.

C · Pinned version

kong:2.8.1, gotrue:v2.197.0. A tag changes little, but it isn't immutable: the vendor can republish the same tag with the fixed base (what never changes is the digest, sha256:…). Pull again and compare the digest; if it didn't change or it's still EXPOSTO, bump the version, and sometimes wait for the vendor.

A container without curl, wget, Node, or Python (e.g., postgres:16 Debian) shows up as SEM TESTE "nenhuma ferramenta de teste" (no test tool) (counts as a test that did not run, and the exit code is not 0): the script has no way to test the connection. Check the list through the package (docker exec <c> dpkg-query -W ca-certificates or apk list -I ca-certificates-bundle) and compare it with the version table.

Group A: pull and recreate

docker compose up -d --pull always redis      # one service; repeat for the others
docker compose pull && docker compose up -d   # all at once (see the warning about databases below)
# proof: the list's package inside the container (Alpine)
docker compose exec redis apk list -I ca-certificates-bundle   # 20261020-r0 or newer

When testing by hand with busybox's wget (Alpine), the rejection shows up as ssl_client: SSL_connect or Connection reset by peer, which looks like a network error. To be sure, test a good site with the same wget (wget -O /dev/null https://www.google.com/): if that one opens, the test site's failure was a certificate failure. audita.sh already does this check.

Database with the latest tag or no major version (postgres:latest, mysql:latest, mongo): the pull may bring a new major version (e.g., Postgres 16 → 18), which won't open the old data directory. Before the pull, pin the major version you use today (postgres:16-alpine) and run pg_dumpall.
  • Container without compose (created with docker run): save the recipe first, docker inspect redis > redis.inspect.json, and look at .Mounts. Without a volume, the data dies with the container: for Redis, run redis-cli save and docker cp redis:/data/dump.rdb . beforehand; then bring up the new one with the same ports and variables and put the file back.
  • New image already on disk, old container running: happens when another project pulled the same tag. You don't need to download anything, just recreate (docker compose up -d <service>). audita.sh shows those as "just recreate".
  • "latest" still old: the vendor may not have published the fixed image. In our case, a :main image from 10/16 stayed EXPOSTO (EXPOSED) after the pull. In that case, use the layer on top (below) until the official one is out.

Group B: the layer on top of the old image

Rebuilding with docker compose build --pull downloads the new base and every dependency with no version pin. In our case, a Node app pulled in new plugins and stopped working. The technique that worked: keep the image that already runs and only swap the list and Node on top.

# 1) keep the current image under another name (it's your way back)
docker tag meuapp:latest meuapp:pre-emudhra

# 2) Dockerfile.emudhra
FROM meuapp:pre-emudhra
USER root                                                                       # installing requires root
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node   # only if the image has Node 22; same major version and same distro as the base
RUN apt-get update && apt-get install -y --only-upgrade ca-certificates && rm -rf /var/lib/apt/lists/*
# Alpine: RUN apk add -u ca-certificates ca-certificates-bundle
RUN python3 -m pip install -U certifi                                           # if the app has Python with its own pip
USER app                                                                        # go back to the original user (see: docker inspect -f '{{.Config.User}}' meuapp:pre-emudhra)

# 3) build on top and recreate
docker build -f Dockerfile.emudhra -t meuapp:latest .
docker compose up -d meuapp
# went wrong? go back: docker tag meuapp:pre-emudhra meuapp:latest && docker compose up -d meuapp
  • The copied Node must have the same major version as what's already in the image (22 for 22): native modules (.node) built for Node 20 don't load on 22. An image on Node 20 can't be fixed this way; it needs to be rebuilt with a 22 base.
  • If pip refuses with externally-managed-environment, the Python is the image's system one (apt): the updated ca-certificates already covers that Python. Remove the pip line; don't use || true, which hides the error.
  • The node:22-… in the COPY must be from the same family as the base (bookworm with Debian 12, alpine with Alpine), or the binary won't run.
  • An app that starts with uv run: uv resyncs the venv from uv.lock and undoes the new certifi. Raise certifi in the lock, or swap just the pinned certifi's cacert.pem file for the one from the new list.
  • Is there an nginx in front (proxy)? After recreating the app, restart nginx too: it holds on to the old internal address and starts answering 502.
  • Old docker-compose (1.29) with KeyError: 'ContainerConfig': docker-compose rm -f -s <service> before the up.
  • Later, when you have time: swap the node:20 base for node:22 (or 24) in the Dockerfile, pin the dependency versions, and rebuild for real (docker compose build --pull).

Group C: pinned version (and local Supabase)

Bump the version in the compose file or the configuration to one released after 10/21/2026 and test again. If the vendor hasn't rebuilt the image yet, there's nothing to pull: you wait on them.

Local Supabase (the one from the supabase start CLI) is the typical case: the CLI pins each image. The path is this, in this order:

# 1) database backup (with everything running)
docker exec supabase_db_<projeto> pg_dumpall -U postgres > supabase-backup.sql
# 2) new CLI + stop and start (in the project folder; without --no-backup, which would erase the data)
npx -y supabase@latest stop && npx -y supabase@latest start
# 3) test again
./audita.sh                       # full: --rapido doesn't find venvs or who to restart
  • Never remove the Supabase containers by hand (docker rm): use the CLI.
  • In our test, the new CLI brought new images, but 10 of 12 containers stayed EXPOSTO (EXPOSED): only some images already came fixed. Until the vendor rebuilds, there's nothing to do except wait and test again.
  • Don't replace the official image with a local layer under the same tag: the next supabase start throws yours away.
  • The start may bring up more services than before (in our case, from 4 to 12 containers). Check the machine's memory.
  • Risk: with everything on localhost only, exposure is low. Only a few services make outbound HTTPS connections (authentication, dashboard, logs).
8

n8n: back up, then update

n8n runs on Node. Old version = old Node inside the container.

# 1) find the volume name (compose usually puts the folder name in front)
docker volume ls | grep n8n                 # e.g.: meuprojeto_n8n_data
# 2) stop n8n and back up (with it stopped, the database doesn't come out half-written)
docker compose stop n8n
docker run --rm -v meuprojeto_n8n_data:/data -v "$PWD":/bk alpine tar czf /bk/n8n-backup.tgz -C /data .
# 3) if compose pins the version (image: n8nio/n8n:2.4.8), swap it for the current one first:
#    image: n8nio/n8n:2.44.6   (or newer; check the release notes)
# 4) update
docker compose pull n8n && docker compose up -d n8n
docker compose exec n8n node -v                     # confirm the new Node

Pulling the same tag may bring a republished image. Check whether the digest changed (docker image inspect -f '{{.RepoDigests}}' n8nio/n8n:2.4.8, before and after): if it didn't change, bumping the version is the way.

The old docker-compose (1.29) trap: when bringing up the new image it may fail with KeyError: 'ContainerConfig' and leave n8n stopped. Remove the old container first (the data stays in the volume): docker-compose rm -f -s n8n && docker-compose up -d n8n. Even better: migrate to docker compose v2.

Other ways to keep the data: if n8n uses a mounted folder (e.g., ./n8n_data or ~/.n8n), copy the folder with n8n stopped; if it uses Postgres, run pg_dump on the database. In every case, keep the encryption key with it (N8N_ENCRYPTION_KEY or the config file in the n8n folder): without it, the saved credentials don't come back.

After updating, check the log (docker compose logs n8n). Big version jumps can bring new warnings, like the one about the Code node's Python runner.

9

Windows, macOS, phone, and apps

  • Windows: Windows Update (Microsoft's fix from 10/20).
  • macOS / iPhone: system update (Apple, 10/22).
  • Browsers: restart the browser to apply it; Firefox 158.0.1 or newer.
  • Electron apps (VS Code, Slack, Discord, and others): update each app. If you're a developer: Electron 44.9.1 / 43.8.1 / 42.11.16.
  • Rust: cargo update -p rustls-webpki -p webpki-roots and recompile.
10

Restart the services (and find out who to restart)

A program that's already running held on to the old list in memory: updating without restarting doesn't fix it. Bots, APIs, workers, agents, n8n, and any Python server with a venv (uvicorn, gunicorn) need to restart after the fix. In the processos a reiniciar section, audita.sh lists who is still running with the old program (Node was swapped after the process started) or started before its venv's new certifi.

# by hand: venv Python and Node processes, with run time in seconds
ps -eo pid,etimes,args | grep -E "venv/bin/python|/node " | grep -v grep
# services that are running
systemctl list-units --type=service --state=running
systemctl --user list-units --type=service --state=running
pm2 list
# restart
sudo systemctl restart <servico>     # system services
systemctl --user restart <servico>   # your user's services
pm2 restart all                       # Node apps managed by pm2 (pm2 update restarts pm2 itself)
docker compose up -d                  # containers: recreates with the new image

In the script's list, each process comes with what to restart: (sistema) means sudo systemctl restart, (--user) means systemctl --user restart, and processo avulso is something you opened in the terminal, in tmux, or in pm2: restart just that program.

Never restart user@1000.service (or another number): it's the user's entire session. It takes down terminals, tmux, jobs, and uploads all at once.
Pick the time. Don't restart in the middle of long work: a render, an upload, a live stream, a database migration, a job queue. Wait for it to finish or warn whoever uses it. Restarting the wrong process can cost more than a few minutes of exposure.

Heads up: docker compose restart only restarts the same container, with the old image. After a pull or build, the right command is docker compose up -d.

And run audita.sh again.

⚠ Before calling it done: validate

Updated? Check, service by service: it came up (docker compose ps, systemctl status), it responds (curl -fsS https://meusite/health), it does what it did before (open it, log in, run a flow), and the data is there (the same counts as before). Went wrong: go back with the backup image (docker tag meuapp:pre-update meuapp:latest && docker compose up -d meuapp). Note: if the new version already migrated the database (n8n, Postgres, apps with migrations), the old image won't open the migrated database: then the way back is to restore the volume backup or the dump together with the old image. On a big version jump, test first on a copy of the volume. And run audita.sh again. It's only done when it's ok and live.

Real case

We audited our servers. The system was fine. The rest wasn't.

In the early hours of 10/23/2026 we audited 6 servers with this method. No names or addresses: the pattern is what matters.

What was already fine

The ca-certificates on all 6 servers had already been fixed by the automatic update. curl was rejecting the bad chains.

What was still exposed

Node, Python environments (certifi), and containers, including n8n, were still trusting the broken keys.

What we fixed

  • Node 20 → 22
  • certifi in the Python environments
  • Node bundled in an agent
  • n8n 2.4.8 → 2.44.6 (with backup)
  • 4 containers updated (new image and recreate)

Two infrastructure traps

1. Old docker-compose

n8n didn't come back up after the pull: docker-compose 1.29 threw KeyError 'ContainerConfig' with the new image, and the service stayed down for a few minutes. Removing the old container before the up fixed it.

2. File mounted from /tmp

An nginx in a container mounted its configuration from a file in /tmp. Cleaning /tmp turned the path into an empty folder, and when the container was recreated, nginx wouldn't start. Mounted configuration has to live in a permanent folder.

The development machine: from 134 to almost zero

The same day we ran audita.sh on the work machine, the one with dozens of projects, Python environments, and local containers. The system and curl were protected. The rest wasn't: 134 EXPOSTO lines (EXPOSED). Fixing it layer by layer, it dropped to 70, and then only what depended on a vendor was left. What we learned, in the order it showed up:

1. The tests that were missing

  • Java: the OpenJDK 8 cacerts was from 2025 and still had both roots, even with the system already fixed. update-ca-certificates -f rewrote the file, but the roots stayed: we had to remove both with keytool -delete.
  • Deno and Bun connected (2.7.12 and 1.3.13). deno upgrade and bun upgrade fixed it.
  • Go 1.26.3 refused in the real test: it follows the system list.

2. Python: where certifi hides

  • An old certifi installed with pip --user in the user's folder took precedence over the system certifi and left the "system python3" EXPOSTO (EXPOSED).
  • Venvs created by uv don't have pip: uv pip install --python ….
  • Projects using uv run undid the fix at every start, because of uv.lock.

3. Containers: three groups, three fixes

  • Floating tag: pulling and recreating fixed most of them (databases, cache, proxy).
  • Custom image: rebuilding from scratch broke an app (unpinned dependencies). A layer on top of the old image worked, with the old one saved as :pre-emudhra.
  • Local Supabase: new CLI + stop/start brought new images, but most stayed EXPOSTO (EXPOSED). It's a wait for the vendor.

4. What you don't see in the list

  • A database panel (adminer) in a container was running PHP 8.4.21, vulnerable to PHP2Shell, open on a port. New image: 8.4.27.
  • The Node bundled with Playwright, inside a venv, stayed EXPOSTO (EXPOSED) until the package was updated.
  • Bots and services kept using the old Node until they were restarted.
On a server that was already fixed, the script still flagged an old Node in a backup folder (.bak) forgotten after the version swap: it wasn't running, but it's the kind of leftover someone runs by mistake. Delete old copies you don't use. The pattern behind all of this is just one: anything that isn't the system gets left behind.
PHP2Shell

Your PHP site can be hacked without a password

Notice from the PHP team dated 10/21/2026: two critical flaws let attackers run code on the server without logging in. The flaw is in PHP itself, before WordPress or any application opens the page. A security plugin won't stop it.

The two flaws

  • CVE-2026-109618: the out-of-memory handling stops PHP with half-processed request data, and the cleanup treats that data as valid.
  • CVE-2026-109619: an internal request state survives after the memory is freed, and the next request in the same process uses that stale state.

Who is exposed

  • Every PHP installation that serves pages: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP, LiteSpeed.
  • Higher risk on mod_php: there is a demonstrated attack that works before any application code runs.
  • PHP runs underneath about 7 in every 10 sites, including most WordPress sites.

Your case, in three situations

1. Ready-made platform

Shopify, Wix, Squarespace: PHP is the company's responsibility. Check the platform's security notice; if you can't find one, ask support.

2. WordPress on hosting

Test the site (step below) and check the PHP version in the hosting panel. Below the fixed version: ask your provider to update today.

3. Your own server (VPS)

It's on you, and it's urgent: attacks started even before the fix was released. Update, restart, and look for signs of intrusion.

1

Test: OpenAI's checker

OpenAI published the php2shell-checker (Apache 2.0 license). It reads the version the site announces in its headers; it doesn't send an attack. It needs git and Node installed on your computer (not on the server).

git clone https://github.com/openai/php2shell-checker && cd php2shell-checker
node phpcheck.js "https://seusite.com.br"
# OK (0) · POTENTIALLY VULNERABLE (1) · UNKNOWN (3)

Or ask your agent (Claude Code, Codex): "run the php2shell-checker only against my site and explain the result". UNKNOWN is not a green light: the site hides its version; check in the panel or on the server.

2

On the server: check the real version

php -v
php-fpm8.3 -v                       # adjust the version number
apt changelog php8.3-fpm | head -30  # Debian/Ubuntu: look for CVE-2026-109618
# PHP inside containers (panels, WordPress, adminer, phpMyAdmin)
for c in $(docker ps --format '{{.Names}}'); do echo "$c: $(docker exec $c php -v 2>/dev/null | head -1)"; done

Don't forget the containers: an image with old PHP is just as vulnerable as the server, and database panels are often exposed on a port. audita.sh already shows the PHP version of each container and of the server and compares them with the fixed ones (EXPOSTO (EXPOSED) below them; VERIFIQUE (CHECK) when the number comes from a distribution package). To fix: pull the new image and recreate the container (group A of the Docker section).

Watch out for the number. Linux distributions and long-term support (LTS) vendors received the fix earlier and may apply it while keeping the old number (backport). That's why the header and the checker can't see a backport: "version below the fixed one" means "investigate in your vendor's changelog", not necessarily "vulnerable".
3

Fix it: update and restart every process

Before: back up the site (sudo tar czf site-$(date +%F).tgz /var/www) and the database (mysqldump --all-databases > wp-$(date +%F).sql), and note whether the home page and /wp-admin open. After: check both again, plus php -v. See the before and after updating checklist.

  • Fixed versions: 8.5.12, 8.4.27, 8.3.36, 8.2.35 (or your distribution's fixed package).
  • PHP 8.1 and earlier no longer receive updates from the PHP project. Some distributions and LTS vendors have the fix: ask them, or move up to a supported version. Because of the severity, the PHP team published minimal source-code fixes for 7.4, 8.0 and 8.1, for those who compile.
sudo apt update
apt list --upgradable 2>/dev/null | grep -i php   # see what will change
sudo apt install --only-upgrade $(dpkg -l 'php*' | awk '/^ii/{print $2}')   # only the installed PHP packages
sudo systemctl restart php8.3-fpm    # or apache2, if you use mod_php

Using the ondrej/php PPA repository (common for having several versions)? It also needs to carry the fixed version; check with php -v afterward. mod_php is PHP running inside Apache; PHP-FPM is PHP as a separate service, used with nginx or Apache.

4

Can't update right now? Reduce the exposure

The PHP team itself warns: these measures reduce known attack paths, they do not make the installation safe. Only updating fixes it. Apply them in php.ini, in the FPM pool, or in the server configuration (not in ini_set() or .user.ini) and restart all workers.

# in the FPM pool (e.g. /etc/php/8.3/fpm/pool.d/www.conf), which the application can't change:
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M          # 32M if the site needs uploads
php_admin_value[upload_max_filesize] = 6M    # always below post_max_size
php_admin_flag[session.upload_progress.enabled] = off
# PHP 8.5 only:
php_admin_flag[fatal_error_backtraces] = off
  • memory_limit = -1 (against CVE-2026-109618) removes PHP's memory limit. This removes the memory failure the attacker controls, but it doesn't close the path completely: a real allocation failure (the server running out of memory) can still reach the vulnerable point. And a buggy script can consume all of the server's RAM. That's why the PHP team recommends setting a limit outside PHP: the memory limit of the container (Docker) or of the service (cgroup, in systemd MemoryMax=), preferably killing only the affected process.
  • Don't use RLIMIT_AS/RLIMIT_DATA (ulimit memory limits) instead: they can trigger the very out-of-memory failure that is vulnerable.
  • Turning off session.upload_progress.enabled and fatal_error_backtraces (against CVE-2026-109619) blocks only the confirmed paths, not other variants.
New PHP notice (10/23/2026, CVE-2026-115560): keep session.upload_progress.enabled = 0 even after updating, until the next regular PHP release. The risk only exists in a rare combination: the app sets session.serialize_handler to php_binary or php_serialize in the configuration and switches to php with ini_set() before session_start(), with upload progress on (the default). An app that never switches the serialize_handler at runtime isn't affected by this path and can leave the feature on. If you already turned it off on 10/21, there's nothing more to do. audita.sh warns (an info line) when the feature is on in the command-line PHP; what counts is the FPM or Apache configuration.
5

Were you exposed? Look for signs of intrusion

# .php files created or changed in the last 7 days inside the site
find /var/www -name '*.php' -mtime -7 -ls
# .php inside the WordPress uploads folder (there should be none)
find /var/www -path '*wp-content/uploads*' -name '*.php' -ls
# scheduled tasks you didn't create
crontab -l; sudo crontab -l -u www-data; ls -la /etc/cron.*
# recent logins and new users
last -20; tail -5 /etc/passwd
wp user list --role=administrator --path=/var/www/seusite   # WP-CLI; as root, add --allow-root

Found something you didn't create: take the site offline, change passwords and keys, and restore from a backup made before the intrusion.

This guide on video (in Portuguese)

INEMA videos about PHP2Shell

Routine · so the next one doesn't catch you off guard

What else is worth doing

Artificial intelligence is finding old flaws faster and faster. The defense is routine, not panic.

🤖 AI agents and curl | bash

  • An installer like curl ... | bash runs code from the internet as your user. Download it first, read it, and only then run it.
  • Check the checksum (sha256sum arquivo) or the signature published by the project.
  • Pin versions (package-lock.json, requirements.txt with versions, Docker image with a tag) and update on purpose, not by accident.
  • Agent with terminal access: run it in an isolated folder or container, with no secrets in sight.

📅 Monthly checklist (30 minutes)

  • System updates applied and automatic updates turned on.
  • Node, Deno, Bun, Python (certifi), Go, Java, and Docker images on supported versions.
  • ./audita.sh on all machines: summary at zero.
  • Backup tested: you actually restored it, not just "it exists".
  • Version line still supported (Node 20 and PHP 8.1 no longer are).

🔎 Watch the certificates for your domain

Every issued certificate goes into a public log (Certificate Transparency). Look up your domain on crt.sh and check whether a certificate you didn't request has appeared. A CAA record in DNS says which certificate authorities may issue for your domain.

dig CAA seudominio.com.br +short

⚙️ CI/CD and secrets

  • Rebuild the images in the pipeline with --pull: an old cached image brings an old list.
  • Run audita.sh in the pipeline as an alarm: any exit other than 0 blocks the deploy (1 EXPOSTO, 2 SEM TESTE, 3 asks for a restart). Exit 0 alone doesn't release anything: it only counts within the scope printed at the end, and the script is a survey, not a certificate.
  • No keys in the code or in git (.env outside the repository); if a key leaked, replace it, don't just delete it.
  • For websites, the INEMA blinda-site method checks headers, dependencies, secrets, and the database with an AI agent, without touching the live site.
Glossary

The words in this guide

Certificate authority (CA)

A company that signs HTTPS certificates. It's the "notary" that says: this site really is who it claims to be.

Root, intermediate, chain

The root is the parent signature your program trusts. It signs intermediates, which sign the sites. The sequence is the chain. The leaked keys belong to intermediates.

P-384

A type of elliptic-curve key. A good P-384 key is a number with more than 100 digits; eMudhra's were smaller than 256.

ca-certificates

A Linux package with the system's list of trusted certificate authorities. Used by curl, wget, and many programs.

certifi

A Python package with its own list of certificate authorities. requests uses it, not the system's list.

venv

An isolated Python environment for a project, with its own packages (and its own certifi).

Container (Docker)

A lightweight "machine" inside the server, with its own system and certificate list. Old image = old list.

pm2 · systemd

Managers that keep programs running on the server and restart them.

cacerts · keystore

The file where Java stores the certificate authorities it trusts. Each JDK can have its own; keytool shows what's inside.

Floating tag · pinned tag

The name after the colon in a Docker image. A floating tag (latest, alpine) changes content over time; a pinned tag (2.8.1) changes little, but it isn't immutable: the vendor can republish the same tag. What never changes is the digest (sha256:…).

Cross-signed chain

When a certificate authority is also signed by another, older root, so it works on old devices. Here: the eMudhra chain also goes through the AAA Certificate Services root.

busybox

A minimal set of commands (sh, wget…) used in small images like Alpine. Its wget shows different errors than the regular wget.

CVE

A public number for a security flaw, used to look up the fix (e.g., CVE-2026-109618).

Sources

Where to check

  1. Test page, details of the eMudhra case, and fixed versions by system and tool (includes uv 0.13.5 and FreeBSD): emudhra.we-have-a-problem.com
  2. Mozilla, incident discussion: bugzilla.mozilla.org/show_bug.cgi?id=2083449
  3. Google, Chrome and Android protection: blog.google/security/…emudhra…
  4. SOCRadar, risks and fixes: socradar.io/blog/emudhra-ca-key-compromise…
  5. PHP, security advisory of 10/21/2026: php.net/archive/2026.php
  6. PHP, informational notice of 10/23/2026 (CVE-2026-115560, upload progress): php.net/archive/2026.php
  7. OpenAI, PHP2Shell checker: github.com/openai/php2shell-checker