
CISA last revised AA25-239A on 3 September 2025. The advisory says PRC state-sponsored actors modify routers to keep long-term access, and it tells defenders to hunt unexpected GRE tunnels, unexpected TACACS+ or RADIUS servers, and HTTPS listeners on non-default high ports. This page is how you detect an implant-shaped grant and shrink what it can reach.
Pair this page with Ubuntu host hardening for sshd and unattended upgrades, with the secure coding checklist for object grants in the product, and with session management when the leftover access is a stolen cookie instead of a shell. Read vulnerable pins versus a malicious install when the implant arrived in a tarball you did not write. Four siblings. This page stays on inventory and privilege.
Persistence is a grant you did not mean
A backdoor, in the sense this slug promised, is leftover access after the first login. CWE-912 is hidden functionality. CWE-284 is improper access control. The useful split for an operator is not “malware versus a developer convenience account.” Both are a path you did not list. A debug SSH key in the image, a second listen in a worker, a TACACS+ server you did not approve, and a cron line that phones home are the same ticket: something on the box can accept or start a session you cannot name.
Intentional admin hatches are still the hole. Convenience is how a forgotten listener survives the next hardening pass. If a person needs a shell, give that person a named user and a listed sudo command. If a process needs a port, put that port in expected_listen.txt. If neither is true, the path should fail closed.
HOST ss -lntup vs expected_listen.txt crontab -u * vs expected_cron.txt getent passwd vs expected_users.txt GRANT alice -> sudo APP_RESTART app -> bindApp(PORT) only mgmt -> management VRF, no data-plane route LOG sudo / auditd --forward--> log host app cannot DELETE
I am citing AA25-239A for the hunt list and the management-plane split. I am citing NVD for CVE-2024-3094, the XZ Utils implant disclosed in March 2024, as the software-shaped cousin of a leftover listener. The next sections are inventory, then privilege, then the CISA hunts you can run on gear you admin.
Inventory ports, jobs, and accounts
Start with what is listening. A process that grew a port you did not put in the runbook is a second process. On a Linux host you admin:
# expected_listen.txt one "addr:port" per line
127.0.0.1:3000
127.0.0.1:5432
0.0.0.0:443
# scripts/check_listen.sh
set -euo pipefail
ss -lntu | awk 'NR>1 {print $5}' | sed 's/::ffff://' | sort -u > /tmp/live_listen.txt
sort -u expected_listen.txt > /tmp/want_listen.txt
extra=$(comm -13 /tmp/want_listen.txt /tmp/live_listen.txt || true)
if [ -n "$extra" ]; then
echo "unexpected listeners:"
echo "$extra"
exit 1
fi
Identifiers stay expected_listen.txt, check_listen, and extra. A new line is a review, not a mute. Loopback-only admin ports still belong on the list. A worker that binds 0.0.0.0:4444 because a leftover debug flag flipped is the finding this script is for.
Do the same for scheduled jobs and local accounts. An unexpected curl in root’s crontab, a user named backup missing from the image, or a systemd timer you did not ship, is persistence. Keep expected_cron.txt and expected_users.txt next to the listen list. Diff them in the same CI job that builds the image, and again on the host after deploy.
For a software implant in a library you already trusted, the check is a pin, not a new listener. Debian’s response in March 2024 was a package named 5.6.1+really5.4.5-1: an older liblzma, labeled so apt would treat it as an upgrade. On a host you admin in 2026, dpkg -l liblzma5 or rpm -q xz-libs should not print 5.6.0 or 5.6.1. I am citing bonyt’s comment and the Debian testing package name he quoted. I am not walking the IFUNC hook.
# scripts/check_xz.sh
set -euo pipefail
if command -v dpkg >/dev/null; then
ver=$(dpkg-query -W -f='${Version}' liblzma5 2>/dev/null || true)
case "$ver" in
5.6.0*|5.6.1*) echo "liblzma5 $ver is the March 2024 implant line"; exit 1 ;;
esac
fi
The app process should bind through one helper so a second listen is visible in review:
// bindApp.js
function bindApp(server, port) {
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) {
throw new Error("refusing bind: bad PORT");
}
const host = process.env.BIND_HOST || "127.0.0.1";
server.listen(p, host);
}
module.exports = { bindApp };
Identifiers stay bindApp, BIND_HOST, and PORT. Grep the tree for .listen( and require every hit to go through bindApp. A stray server.listen(0) is an extra path.
Least privilege on the box and the path
An implant that lands as an unprivileged user and finds shared root, a world-writable cron, or a management plane that routes to the data plane, just inherited your grant. Close the grant even when you have not found the listener yet.
PermitRootLogin no is the sshd line. Each person has a user and a key or an IdP hop. Elevation goes through sudo so the invoking user is in the log. A shared ubuntu password, a shared ec2-user key in the team vault, and a root password in the onboarding doc are the same bug. Offboarding then means rotating a secret everyone still needs.
# /etc/ssh/sshd_config.d/10-named-users.conf
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
# /etc/sudoers.d/alice visudo -f
Cmnd_Alias APP_RESTART = /bin/systemctl restart app.service, \
/bin/systemctl status app.service
alice ALL=(root) APP_RESTART
Defaults:alice logfile="/var/log/sudo-alice.log"
Identifiers stay APP_RESTART and alice. It is not ALL=(ALL) NOPASSWD: ALL. A full root shell is a break-glass ticket, not a default. Reload sshd on a host you admin. Keep a console or out-of-band path that is not SSH before you lock the last key. The Ubuntu hardening page is the rest of banner, updates, and unattended upgrades.
The unit file is the other grant. Drop capabilities the process does not need. Bind only the address you listed. Refuse a new privilege at runtime.
# /etc/systemd/system/app.service.d/harden.conf
[Service]
User=app
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
AmbientCapabilities=
CapabilityBoundingSet=
ReadWritePaths=/var/lib/app /var/log/app
If the process was not written to make outbound connections, add IPAddressDeny=any and an allow for the database network you listed. I am not recommending a commercial EDR. I am saying a worker that can open any destination will turn a leftover library into a path off the box. Ship auditd and sudo logs to a store the app role cannot DELETE. journald on the same disk is not that store.
What AA25-239A told operators to hunt
AA25-239A is a router and provider-edge advisory. Most readers of this page run app hosts, not a national backbone. The hunts still translate. CISA’s list, in the language of that advisory, is: unexpected GRE or other tunnels toward foreign infrastructure; unexpected external IPs set as a TACACS+ or RADIUS server; HTTPS or SSH on non-default high ports reachable from outside the management VRF; TACACS+ flows that leave the management VRF; on-box packet capture aimed at AAA traffic. Treat each as “a path I did not list.”
| CISA hunt | App-host cousin | What you keep |
|---|---|---|
| Unexpected GRE / tunnel | unexpected extra interface or VPN | ip link vs image baseline |
| Unexpected AAA server | unexpected IdP or LDAP host | allowlisted IdP issuer only |
| High-port HTTPS / SSH | unexpected listen | check_listen |
| AAA leaving mgmt VRF | app net reaching the admin jump | no route from app VLAN to mgmt |
| On-box PCAP of TACACS+ | unexpected tcpdump / raw socket | no CAP_NET_RAW on app |
CISA’s mitigation that travels is the management plane. Place SSH, HTTPS, SNMP, and AAA on a dedicated out-of-band network or a management VRF. Give that VRF no route to customer or peering space. Block egress from it except to the AAA, syslog, and telemetry collectors you named. Bind the web UI only to that VRF if you must keep a UI. Disable Telnet and unencrypted HTTP. I am paraphrasing the 3 September 2025 advisory, not inventing a lab topology.
On an app host the same split is: the jump box and sshd listen on the admin VLAN, the app listens on the app VLAN, and the two do not share a default route. BIND_HOST=127.0.0.1 plus a local reverse proxy on 443 is enough for many teams. Do not expose sshd on the same address the load balancer already hits.
CISA also says to enforce AAA command authorization so only approved roles can start guest shells or change VRF context. The app-host cousin is APP_RESTART: Alice can restart the unit. She cannot guestshell, she cannot rewrite iptables, and she cannot install a new cron. If she needs those, that is a break-glass ticket with a start time and an end time, still in her name.
Prove the inventory and the grant
You are listing paths on hosts you admin. You are not building a listener to point at a neighbor.
sshd -T | awk '/^permitrootlogin|^passwordauthentication/'must printnoandno.sudo -U alice -lmust listAPP_RESTARTand must not listALL.check_listen.shmust exit 0 against the running host. Add a temporarync -l 127.0.0.1 9999on a box you own, rerun, expect nonzero, then stopnc.check_xz.shmust exit 0. 5.6.0 and 5.6.1 fail.systemctl show app.service -p User -p NoNewPrivileges -p CapabilityBoundingSetmust printapp,yes, and an empty bounding set.- Grep the app tree for
.listen(. Every hit isbindApp. - Confirm sudo and audit lines left the host. The
approle cannot delete them.
sshd -T | awk '/^permitrootlogin|^passwordauthentication/'
sudo -U alice -l
bash scripts/check_listen.sh
bash scripts/check_xz.sh
rg -n "\\.listen\\(" --glob '!node_modules'
The nc step is a listener on loopback of a machine you already operate. Tear it down. Do not put a connect-back, a command loop, or a “undetectable” hide around it. The previous article’s two-file socket story is the thing this page refuses to reprint.
Questions we keep getting
Is antivirus the first control?
No. A signature engine may catch a known family. It will not list an extra port you forgot, a TACACS+ server you did not approve, or shared root in the wiki. Inventory plus a named grant is the work. Keep the engine if you already run it. Do not stop at it.
Do I need a management VRF on a single app VM?
You need the split. A second interface, a local bind plus a proxy, or a security group that only the jump box may hit on 22, is the small-shop version of CISA’s management VRF. The test is: can the app VLAN start an SSH session to the admin path? If yes, close that route.
Why not show a short implant so I can recognize one?
Because recognition is the inventory, and a short implant is a build guide. A port missing from expected_listen.txt, a cron line absent from the image, and a user omitted from expected_users.txt are the shapes. Write those lists. Do not write the third file.



