SIEM, SOC & Detection

cwpdoor: The Control Web Panel Implant That Edits Its Own Request Out of the Log

Dark cyberpunk illustration of a night-time corridor of server cabinets glowing amber, with one sealed cabinet in the row casting no light across the floor while thin cyan traces run away into the dark.

Three HTTP headers, a signature, and a 300-second clock. Send them to a Control Web Panel server running a trojanized copy of its own web server and a command runs as root. The request returns HTTP 200 with a body of encrypted bytes, and the access log records nothing, because the implant swaps out the logging module's context pointer for that one request before the server ever gets to write the line.

QiAnXin's XLab team published the analysis on November 5 and named the implant cwpdoor. It is a recompiled cwpsrv, the nginx-derived web server that serves the CWP panel itself. The researchers captured the installer script on June 29. On September 30 somebody in Brazil uploaded an evidence archive to VirusTotal containing both cwpdoor and a copy of Ebury, the OpenSSH credential stealer ESET has tracked since 2009. The cwpdoor sample scored zero detections out of sixty-three engines. QiAnXin's own scanning puts more than 340,000 CWP instances on the public internet.

Here is the relevance verdict, and it is narrow. The installer handles exactly two builds of cwpsrv: 1.15.10 and 1.24.0. Those are the nginx versions CWP shipped on EL7 and on EL8/EL9 respectively, and the vendor replaced both with nginx 1.30.6 in CWP 1.29 on October 31, 2026, according to the published changelog. If you run cPanel, Plesk, DirectAdmin, a managed platform, or no control panel at all, stop reading; none of this is yours. If you run CWP and have not taken the 1.29 or 1.30 line, your panel is serving one of the two exact binaries this installer was built to replace.

What cwpdoor does to a request

The malicious code is compiled into the server, so the trojanized cwpsrv serves the panel normally until it is addressed. XLab names the two functions. An init routine runs at the end of configuration parsing: it caches the index of the logging module, kills any stale helper from a previous run, creates a Unix domain socket called ngx_http_integrity_helper, and forks a child process whose job is to run commands.

The check routine runs at the entry point of request processing and reads three headers:

  • X-Ngx-Integrity-Time carries a timestamp. Anything more than 300 seconds off the server clock is dropped.
  • X-Ngx-Integrity-Sig carries a Base64 RSA signature over the string <time>:<data>, so neither field can be replayed with the other altered.
  • X-Ngx-Integrity-Data carries the command: Base64, then AES-256-CBC with the IV in the first sixteen bytes. The key is the SHA-256 digest of the operator's RSA public key, which is stored in the binary XOR-encoded with 0x55.

Fail any of the three and the request is handled as ordinary web traffic. Pass all three and the decrypted command goes to the helper over the socket, and the output comes back AES-encrypted with Content-Type: application/octet-stream. Both directions are encrypted, which means a packet capture of the session yields ciphertext and a 200.

The installer is as careful as the implant. It runs as root, checks the local version, downloads the matching binary, backs up the original, stops the service, overwrites /usr/local/cwpsrv/bin/cwpsrv, restarts, and verifies that the master, worker, and helper processes came up. On failure it rolls back. On success it deletes its temporary files and the backup it just made, restores the atime and mtime of the new binary and of the parent directory to their original values, and runs history -d on the shell session. The one artefact it leaves is a backup named cwpsrv.prev- plus a timestamp, and only when the cleanup did not finish.

The root access the installer needs is already in the KEV catalog

XLab does not know how the attackers get root, and neither does anybody else. That is an honest gap in the reporting, and no article should fill it with a guess. What can be stated is that CWP has supplied documented paths to that privilege twice, and CISA has confirmed both as exploited in the wild.

CVE-2022-44877 is an OS command injection in the login parameter, unauthenticated, scored 9.8 and added to the Known Exploited Vulnerabilities catalog on January 17, 2023. CVE-2025-48703 is an OS command injection through the t_total parameter of a filemanager changePerm request, fixed in 0.9.8.1205, and added to the catalog on November 4, 2025, a year to the week before cwpdoor surfaced. The catalog entry notes that a valid non-root username must be known, which on a shared hosting box is close to free.

The vendor's own release notes describe the same surface from the inside. CWP 1.27, dated October 30, 2026, lists an admin-panel security review covering command injection, SQL injection, path traversal, cross-site scripting, and missing CSRF protection. A panel that needed that review in late 2026, running on an internet-exposed port, is the supply of root this implant consumes. The point for a defender is operational: finding cwpdoor tells you the attacker already had root. The implant is the second event, not the first.

Three checks that answer it in a minute

Run these on every CWP host you own. They are read-only, they need no agent, and the first one is the strongest signal because the helper socket exists whenever the implant is loaded.

# 1. The helper socket. Any hit here is an infected panel.
ss -axp | grep -i integrity

# 2. The process tree. cwpsrv should show a master and workers,
#    nothing else underneath it.
ps -eo pid,ppid,user,etime,args --forest | grep -A5 '[c]wpsrv'

# 3. The trigger header names, compiled into the binary.
strings -a /usr/local/cwpsrv/bin/cwpsrv | grep -c 'X-Ngx-Integrity'

# Record the hash before you change anything. Compare against the
# published MD5s: 965aea67fb40de1bab5169ada993349f (cwpsrv 1.15.10)
# and d3f4e95113d49ab3991b45e457bda75b (cwpsrv 1.24.0).
md5sum  /usr/local/cwpsrv/bin/cwpsrv
sha256sum /usr/local/cwpsrv/bin/cwpsrv
stat -c '%n %s %y %z' /usr/local/cwpsrv/bin/cwpsrv

# The leftover backup, present only when the installer's cleanup failed.
ls -la /usr/local/cwpsrv/bin/cwpsrv.prev-* 2>/dev/null

Reading the output

  • A socket named ngx_http_integrity_helper in check 1, or a non-zero count in check 3, is a confirmed compromise of the whole host. The implant needed root to arrive.
  • A clean check 1 with an unfamiliar child process under cwpsrv in check 2 is worth a hash and a capture before you kill it.
  • The timestamps from stat will look old and ordinary on an infected host. The installer sets them back deliberately. Do not use file dates as evidence either way.
  • All three clean, on a host still running 1.15.10 or 1.24.0, means the panel is exposed to the campaign but not currently carrying this implant. Go and take the upgrade.

One ordering rule matters more than the rest. Upgrading CWP to 1.29 or later replaces cwpsrv with nginx 1.30.6, which removes the implant and destroys the evidence at the same moment. Hash the binary and copy it somewhere off the host before you run the update, or you will never be able to answer when it arrived.

The panel's log or the kernel's view

You now have two sources that could tell you whether anyone used the door, and only one of them survives contact with this implant.

The panel's own record, /usr/local/cwpsrv/logs/access_log, is written by the process the attacker replaced. The context swap happens per request, so the triggering requests are absent while everything around them looks normal. A log with no gap in it is not evidence of a quiet week on this host.

Everything outside that process still works. The kernel still accounts for the socket and the forked helper. A firewall, a reverse proxy, or an IDS placed in front of ports 2030, 2031, 2082 and 2083 still sees the request, because the suppression happens after the bytes arrive. That is the practical reason to stop exposing the panel ports to the whole internet and to put a record in front of them, and it is a change a one-person team can make this afternoon with a source-address allowlist.

If you run Suricata or a sensor that speaks its rule language, the header names are a clean, low-volume signature:

alert http any any -> $HOME_NET [2030,2031,2082,2083] ( \
  msg:"cwpdoor trigger header observed on CWP panel port"; \
  flow:established,to_server; \
  http.header_names; content:"X-Ngx-Integrity"; nocase; \
  classtype:trojan-activity; sid:3100601; rev:1; )

Also alert on outbound traffic to 136.0.141.71, the host that served the installer script and both backdoored binaries. That address is a retrospective check against your firewall and DNS logs as much as a blocking rule: a hit on it in June through September is the arrival time the file timestamps will not give you.

Hash the panel binary before you take the upgrade

The repeatable version of all of this is a hash manifest. CWP ships no published checksums for cwpsrv, so the baseline has to be your own, taken from a host you believe is clean and compared on a schedule. Thirty lines of shell is enough.

#!/bin/bash
# cwp-binhash.sh - baseline and verify the binaries CWP serves the panel with.
# Usage: cwp-binhash.sh baseline | cwp-binhash.sh check
set -euo pipefail

MANIFEST=/var/lib/cwp-binhash/manifest.sha256
TARGETS=(/usr/local/cwpsrv/bin/cwpsrv /usr/local/cwpsrv/bin/cwpsrv-php
         /usr/local/cwp/php71/bin/php /usr/sbin/sshd /lib64/libkeyutils.so.1)

mkdir -p "$(dirname "$MANIFEST")"

case "${1:-}" in
  baseline)
    : > "$MANIFEST"
    for f in "${TARGETS[@]}"; do
      [ -e "$f" ] && sha256sum "$f" >> "$MANIFEST"
    done
    chmod 0400 "$MANIFEST"
    echo "baselined $(wc -l < "$MANIFEST") files"
    ;;
  check)
    if ! sha256sum -c --quiet "$MANIFEST"; then
      logger -p auth.crit -t cwp-binhash "panel binary hash mismatch"
      exit 1
    fi
    ss -axp 2>/dev/null | grep -qi integrity &&
      logger -p auth.crit -t cwp-binhash "integrity helper socket present"
    ;;
  *) echo "usage: $0 baseline|check" >&2; exit 2 ;;
esac

Run baseline once after an upgrade you performed yourself, keep the manifest on a different host as well, and put check in cron every fifteen minutes. The logger call writes to the system journal, which is a different file, owned by a different process, than the one the implant edits. Pair it with two auditd watches so the swap itself is recorded even though the installer resets the timestamps afterwards, because restoring an mtime is a metadata write and auditd logs it:

auditctl -w /usr/local/cwpsrv/bin/ -p wa -k cwp_binary
auditctl -w /usr/local/cwpsrv/conf/ -p wa -k cwp_config
# persist both in /etc/audit/rules.d/cwp.rules, then: augenrules --load
ausearch -k cwp_binary -ts recent

That leaves the decision nobody enjoys. XLab's remediation is to restore the backup binary, upgrade, confirm the socket is gone, and rotate the panel credentials. That is the right sequence for triage, and it is not a recovery plan. An implant that required root also had root for every minute it was resident, on a machine that in CWP's case is usually also the mail server, the database server, and the home of every customer site on the box. Rebuild the host from a known image, restore site data after inspecting it, and treat every credential that ever touched the panel as public. If rebuilding is genuinely out of reach this week, take the upgrade today, get the panel ports behind an allowlist by tomorrow, and schedule the rebuild rather than deciding against it.

Want to try our open-source security tools?

We build and publish open-source tools for the unglamorous parts of defence, including external exposure checks and log triage. Browse them at github.com/redhoundinfosec, or book a session if you want a second set of eyes on a hosting estate before you decide whether a panel host can be cleaned or has to be rebuilt.