Joomla JCE 严重漏洞 CVE-2026-48907 实战排查:WebShell、恶意代码清理与批量自查脚本

这篇文章记录一次真实的 Joomla 网站安全事件。网站本身长期运行正常,但后来在 Joomla 后台页面中发现了异常的第三方 JavaScript 请求,同时服务器的网站目录中陆续发现多个陌生 PHP 文件。进一步检查 Nginx access log 后确认,问题并不是普通的垃圾扫描,而是 Joomla Content Editor(JCE)旧版本存在的严重安全漏洞被实际利用。

最终确认的漏洞是 CVE-2026-48907。这个漏洞影响 JCE 2.9.99.5 之前的版本,攻击者在不需要 Joomla 后台账号的情况下,可以滥用 JCE Editor Profile 相关功能,并进一步造成任意文件上传和 PHP 代码执行。

这类问题最麻烦的地方不是“把 JCE 升级就结束了”。一旦漏洞已经被利用,服务器中可能已经留下 WebShell、隐藏 PHP 文件、被修改的模板文件、恶意 JavaScript、计划任务,甚至被污染的网站备份。因此正确的处理方法应该是:

确认 JCE 版本
↓
升级或删除漏洞版本
↓
检查服务器日志
↓
查找 WebShell 和恶意代码
↓
隔离并清理已确认恶意文件
↓
检查模板、tmp、images 等目录
↓
增加 Nginx PHP 执行限制
↓
检查 Joomla 管理员、JCE Profile、Cron、SSH
↓
重新建立干净备份

一、CVE-2026-48907 是什么漏洞?

CVE-2026-48907 是 Joomla 常用编辑器扩展 JCE(Joomla Content Editor)在 2026 年披露的一个严重权限控制漏洞。

问题主要出现在 JCE Editor Profile 相关功能中。旧版本对 Profile 导入和文件处理操作的权限检查不足,攻击者在没有正常 Joomla 登录权限的情况下,也可能访问本不应该公开开放的 JCE 功能。

攻击成功以后,风险不只是“创建一个错误的编辑器配置”,因为 JCE 本身具有图片、文件上传和文件管理能力。一旦攻击者获得可利用的 Profile 或文件管理权限,就可能进一步上传 PHP 文件。

服务器如果又允许 imagestmp 等目录中的 PHP 文件直接通过 Web 执行,就可能形成完整的远程代码执行链:

Internet
    ↓
未认证访问 JCE
    ↓
滥用 Editor Profile
    ↓
获得文件上传能力
    ↓
上传 PHP 文件
    ↓
浏览器访问上传文件
    ↓
PHP-FPM 执行
    ↓
Remote Code Execution
    ↓
WebShell / 后门 / 修改网站文件

也就是说,这已经不是普通的后台权限问题,而是可能导致整个 Joomla 网站被接管的远程代码执行漏洞。

二、哪些 JCE 版本受影响?

根据 JCE 官方发布的信息:

受影响:
JCE < 2.9.99.5

开始修复:
JCE 2.9.99.5

本文撰写时建议:
直接升级到官方当前最新版本

JCE 2.9.99.5 于 2026 年 6 月 3 日发布,其中明确修复了未经认证用户可以上传 Editor Profile 的安全问题。

后续版本又继续对输入验证、访问控制、文件系统等进行了安全强化,因此如果网站还停留在旧版本,不建议仅仅升级到最低修复版本,而应该在兼容 Joomla 版本的前提下直接更新到 JCE 官方当前最新版本。

本文撰写时,JCE 官方最新版本为:

JCE 2.9.99.10

需要注意的是,这个漏洞判断的是 JCE 版本,而不是单独看 Joomla 核心版本。只要 Joomla 网站安装了受影响版本的 JCE,就应该进行检查。

三、网站被攻击后可能出现什么现象?

有些网站被利用以后会立即出现白屏、跳转、广告或页面篡改,但更多情况下网站表面上仍然可以正常访问。

比较常见的异常包括:

  • images 目录突然出现陌生 PHP 文件;
  • tmp 目录出现 .php.phtml.phar 文件;
  • 网站模板 index.php 被插入陌生 JavaScript;
  • 出现以点号开头的隐藏 PHP 文件;
  • JCE Editor Profiles 中出现不认识的 Profile;
  • Nginx 日志出现大量 option=com_jce 请求;
  • 出现 profiles.importplugin.rpcimgmanager 等异常请求;
  • 网站被植入 WebShell 后,攻击者继续修改 Joomla 模板和插件文件。

实际案例中,我曾经发现过类似这样的恶意文件:

images/request_handler_0528.php
images/cswklecp.php
images/dkafcuel.php

images/8uyheden.php
images/4cgk3idc.php
images/580dut86.php

images/.mz0r1b.php
images/.6mx0r4.php
images/.s0mc78.php

其中最简单的 WebShell 甚至可能只有一行 PHP,通过 URL 参数直接调用系统命令。

因此,不要因为一个文件只有几十个字节就认为它没有危险。

四、如何从 Nginx 日志判断是否被利用?

宝塔环境中,网站日志通常位于:

/www/wwwlogs/域名.log

例如:

/www/wwwlogs/www.yourdomain.com.log

首先可以搜索所有 JCE 请求:

grep -aiE \
'option=com_jce|profiles\.import|plugin\.rpc|plugin\.display|imgmanager|method=upload' \
/www/wwwlogs/www.yourdomain.com.log

如果日志很大,不建议直接输出到终端,可以保存成文件:

grep -aiE \
'option=com_jce|profiles\.import|plugin\.rpc|plugin\.display|imgmanager|method=upload' \
/www/wwwlogs/www.yourdomain.com.log \
> /root/jce-investigation.txt

值得重点关注的请求包括:

POST /index.php?option=com_jce&task=profiles.import

POST /index.php?option=com_jce&task=plugin.rpc&plugin=imgmanager&method=upload

POST /index.php?option=com_jce&task=plugin.display&plugin=imgmanager

单纯出现这些 URL 并不能百分之百证明攻击成功,因为互联网上存在大量自动扫描。

真正更有价值的是寻找后续 PHP 文件访问,例如:

POST /index.php?option=com_jce...        200

GET /tmp/xxxx.php                        200

GET /images/xxxx.php                     200

如果同一个 IP 在利用 JCE 之后,马上访问一个刚出现的陌生 PHP 文件,而且返回 HTTP 200,那么就应该按照已经被入侵来处理。

五、为什么只升级 JCE 还不够?

这是处理这类漏洞时最容易犯的错误。

假设攻击过程是:

旧版 JCE
↓
攻击者上传 shell.php
↓
shell.php 已经存在于服务器
↓
管理员升级 JCE
↓
JCE 漏洞被修复
↓
但是 shell.php 仍然存在

升级 JCE 只能关闭原来的入口,并不会自动删除攻击者已经上传的文件。

如果攻击者已经留下:

images/shell.php
tmp/index.php
隐藏 PHP
恶意 Cron
被修改的 template/index.php

那么即使 JCE 已经升级,攻击者仍然可能通过这些后门继续进入。

因此一旦确认旧版 JCE 曾经暴露在公网,特别是日志已经发现异常利用请求,处理原则应该是:

修漏洞
+
清后门
+
查持久化
+
做服务器层防护

六、第一步:立即升级 JCE

首先进入 Joomla 后台:

系统 → 更新 → 扩展

将 JCE 更新到官方当前最新版本。

也可以直接进入:

组件 → JCE Editor

确认当前版本。

如果网站已经无法进入后台,可以检查:

grep -i '<version>' \
/www/wwwroot/www.yourdomain.com/administrator/components/com_jce/jce.xml \
| head

如果仍然是:

2.9.99.4
2.9.99.3
或者更早版本

应优先处理。

如果暂时无法安全升级,而网站又不依赖 JCE,短期内直接卸载旧 JCE,也比继续把漏洞版本暴露在公网更安全。

七、第二步:检查 images、tmp 等目录中的 PHP

Joomla 网站目录假设为:

/www/wwwroot/www.yourdomain.com

首先检查 images

cd /www/wwwroot/www.yourdomain.com

find images -type f \( \
  -iname '*.php' -o \
  -iname '*.phtml' -o \
  -iname '*.phar' -o \
  -iname '*.php5' -o \
  -iname '*.php7' -o \
  -iname '*.php8' \
\) -print

再检查 tmp

find tmp -type f \( \
  -iname '*.php' -o \
  -iname '*.phtml' -o \
  -iname '*.phar' \
\) -print

同时寻找隐藏 PHP:

find . -type f \( \
  -name '.*.php' -o \
  -name '.*.phtml' -o \
  -name '.*.phar' \
\) -print

这里不要看到 PHP 就全部删除。

Joomla、K2、Akeeba、JCE 或其他扩展的某些目录中,本来就可能包含合法 PHP 文件,所以需要结合:

文件路径
文件内容
修改时间
文件大小
SHA256
Nginx access log

综合判断。

八、第三步:搜索常见 WebShell 特征

可以先搜索比较明显的命令执行代码:

cd /www/wwwroot/www.yourdomain.com

grep -RInaE \
'(\$_(GET|POST|REQUEST)\[[^]]+\].*(system|passthru|shell_exec|exec|proc_open|popen))|(eval[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST))' \
. \
--include='*.php' \
--exclude-dir=cache \
--binary-files=without-match

还可以检查这次实际事件中出现过的 IOC:

grep -RInaE \
'jmtouch|whitellllshop|GQxcpCNmqW|privdayz\.com|request_handler_0528|cswklecp|dkafcuel' \
. \
--exclude-dir=cache \
--binary-files=without-match

如果没有输出,说明没有命中这些已知特征;但这并不等于网站绝对安全,因为攻击者完全可以修改文件名和代码。

九、第四步:检查 Joomla 模板是否被插入恶意 JavaScript

WebShell 拿到写权限以后,攻击者通常不会只留下一个 PHP 文件,还可能进一步修改 Joomla 模板。

实际案例中曾发现多个完全不同位置的 index.php 在几秒钟内被同时加入同一段 jmtouch JavaScript:

administrator/templates/.../index.php
templates/.../index.php
plugins/system/.../index.php

可以搜索:

cd /www/wwwroot/www.yourdomain.com

grep -RInaE \
'jmtouch|whitellllshop|landers/scr\.php' \
administrator templates plugins \
--include='*.php' \
--binary-files=without-match

如果多个正常 Joomla 文件同时出现完全相同的陌生 JavaScript,特别是代码会从外部域名动态加载脚本,那么基本可以认定是恶意注入。

十、第五步:不要忘记检查备份

这是非常容易忽略的一点。

如果网站在被攻击以后又执行过 Akeeba Backup,那么备份文件中也可能已经包含:

images/shell.php
tmp/index.php
被修改的模板文件
恶意插件文件

这样的备份虽然创建成功,但本质上已经是“带毒备份”。

以后如果直接恢复:

干净网站
↓
恢复旧备份
↓
WebShell 一起恢复
↓
网站再次被控制

因此,事故发生以后创建的旧备份应该先检查,不能默认认为“备份一定安全”。

清理完成以后,建议重新创建一个新的干净备份,并单独保存。

十一、第六步:检查 .user.ini、Cron 和 SSH

网站被拿到 PHP 远程执行权限以后,还需要检查有没有其他持久化方式。

1. 检查 .user.ini

cd /www/wwwroot/www.yourdomain.com

find . -name '.user.ini' -type f -print

重点搜索:

grep -RInaE \
'auto_prepend_file|auto_append_file' \
/www/wwwroot/www.yourdomain.com \
/www/server/php \
2>/dev/null

如果出现陌生的:

auto_prepend_file=/path/to/unknown.php

就需要重点检查。

2. 检查计划任务

crontab -l
crontab -u www -l 2>/dev/null

grep -RInaE \
'curl|wget|php |python|perl|base64|/www/wwwroot' \
/etc/cron* /var/spool/cron \
2>/dev/null

3. 检查 SSH key

find /root/.ssh /home \
-maxdepth 4 \
-name authorized_keys \
-type f \
-print \
2>/dev/null

里面如果出现自己完全不认识的 SSH 公钥,应进一步确认来源。

十二、服务器层一定要禁止上传目录直接执行 PHP

这是我认为这次事件以后最值得增加的一层防护。

JCE 漏洞真正危险的地方是:

攻击者成功写入 PHP
+
Nginx 又允许访问这个 PHP
+
PHP-FPM 执行它
=
RCE

因此可以直接从 Nginx 层阻断最后一步。

例如宝塔 Nginx 网站配置中,在通用 PHP location 或:

include enable-php-xx.conf;

之前加入:

# Security: 禁止上传和缓存目录通过 Web 执行 PHP
location ~* ^/(?:images|tmp|cache|administrator/cache)/.*\.(?:php(?:[0-9]+)?|phtml|phar|pht|phps)(?:/|$) {
    return 404;
}

修改以前一定先备份 Nginx 配置:

CONF="/www/server/panel/vhost/nginx/www.yourdomain.com.conf"

cp -a "$CONF" \
"${CONF}.bak-$(date +%Y%m%d-%H%M%S)"

修改以后检查:

nginx -t

必须看到类似:

syntax is ok
test is successful

再执行:

nginx -s reload

然后验证:

for u in \
'/images/test.php' \
'/tmp/test.php' \
'/cache/test.php' \
'/administrator/cache/test.php'
do
    printf '%-35s ' "$u"

    curl -sk \
    -o /dev/null \
    -w '%{http_code}\n' \
    "https://www.yourdomain.com$u"
done

预期这些路径全部返回:

404

这个设置不会禁止 Joomla 自己在这些目录中读写缓存或临时文件,它只是禁止浏览器直接请求里面的 PHP 文件并交给 PHP-FPM 执行。

十三、多个 Joomla 网站怎样批量自查?

如果服务器上有十几个 Joomla 网站,一个一个登录后台检查非常麻烦。

我最后整理了一个 Joomla 批量扫描和已知感染清理脚本,默认扫描:

/www/wwwroot

下面所有 Joomla 网站。

它主要检查:

  • 自动识别 Joomla 网站根目录;
  • 读取 JCE 当前版本;
  • 标记低于 2.9.99.5 的漏洞版本;
  • 搜索本次事件已经确认的恶意文件名;
  • 通过 SHA256 搜索被改名的已知 WebShell;
  • 搜索 jmtouch、恶意域名等 IOC;
  • 检查 images、tmp 等目录中的 PHP;
  • 检查隐藏 PHP;
  • 搜索明显的命令执行代码;
  • 清理时先隔离文件,而不是直接永久删除;
  • 只自动清理已经确认的 IOC,不会把所有可疑 PHP 一刀切删除。

十四、Joomla 批量自查和已知感染清理脚本

可以保存为:

/root/joomla-fleet-selfcheck-clean.sh

脚本如下:

#!/bin/bash

set -u

MODE="${1:-scan}"
BASE="/www/wwwroot"
ONLY_SITE=""

shift 2>/dev/null || true

while [ $# -gt 0 ]; do
    case "$1" in
        --base)
            BASE="$2"
            shift 2
            ;;
        --site)
            ONLY_SITE="$2"
            shift 2
            ;;
        *)
            echo "Unknown option: $1"
            exit 1
            ;;
    esac
done


case "$MODE" in
    scan|clean-known)
        ;;
    *)
        echo "Usage:"
        echo "  $0 scan [--base /www/wwwroot]"
        echo "  $0 scan --site /www/wwwroot/example.com"
        echo "  $0 clean-known --site /www/wwwroot/example.com"
        exit 1
        ;;
esac


STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="/root/joomla-fleet-${MODE}-${STAMP}"
QUAR="/root/joomla-quarantine-${STAMP}"

mkdir -p "$OUT"

if [ "$MODE" = "clean-known" ]; then
    mkdir -p "$QUAR"
    touch "$QUAR/manifest.tsv"
fi


KNOWN_NAMES='
request_handler_0528.php
cswklecp.php
dkafcuel.php
8uyheden.php
4cgk3idc.php
580dut86.php
.mz0r1b.php
.6mx0r4.php
.s0mc78.php
'


is_known_name()
{
    local name="$1"

    echo "$KNOWN_NAMES" \
    | grep -Fxq "$name"
}


is_known_hash()
{
    case "$1" in

        f26031912748fb9ea5c0d8798aaf870831f0f76ca364b50ea42af23b5d800e1a)
            return 0
            ;;

        66dcc30ccffd095e562b514a325ad3d0447c6dfce479d07209798ffac4f59fa6)
            return 0
            ;;

        181025a668c911cd5e5e6969539668cf82747616bccd41ef7fc2a5f9855ab57c)
            return 0
            ;;

        e4ad72b1d7a727ffccf0e2a9ddf7b08c993826c17eb4b9f49c9734fc54b00b2a)
            return 0
            ;;

        ef24aa4f7a347fa86d6e52e129f00adfd85be911b5032c9b2a0559c60b19463a)
            return 0
            ;;

        8bdcb7fcf5bd48c9471c4bc2252f4f43d2d6b1ac723c07c4004e52d4d0a10178)
            return 0
            ;;

        d75f4405d8ea31b37f5f29fc2081fd866f561f2a25af4ad5ecd10d2589233cfa)
            return 0
            ;;

        *)
            return 1
            ;;
    esac
}


version_lt()
{
    [ "$1" != "$2" ] \
    && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$1" ]
}


detect_jce_version()
{
    local manifest="$1/administrator/components/com_jce/jce.xml"

    if [ ! -f "$manifest" ]; then
        echo "not-found"
        return
    fi

    local v

    v="$(sed -n \
        's:.*<version>\([^<]*\)</version>.*:\1:p' \
        "$manifest" \
        | head -n1 \
        | tr -d '[:space:]')"

    if [ -z "$v" ]; then
        echo "unknown"
    else
        echo "$v"
    fi
}


quarantine_file()
{
    local site="$1"
    local file="$2"
    local reason="$3"

    local rel
    local target
    local sha

    rel="${file#$site/}"
    target="$QUAR$(printf '/%s/%s' "$(basename "$site")" "$rel")"

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

    sha="$(sha256sum "$file" | awk '{print $1}')"

    cp -a "$file" "$target"

    printf '%s\t%s\t%s\t%s\n' \
        "$(date '+%F %T')" \
        "$sha" \
        "$reason" \
        "$file" \
        >> "$QUAR/manifest.tsv"

    rm -f "$file"

    echo "QUARANTINED: $file"
}


clean_jmtouch()
{
    local site="$1"
    local file="$2"

    local backup

    backup="$QUAR$(printf '/%s/%s.original' \
        "$(basename "$site")" \
        "${file#$site/}")"

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

    cp -a "$file" "$backup"

    python3 - "$file" <<'PY'
import re
import sys
from pathlib import Path

p = Path(sys.argv[1])
data = p.read_text(errors="surrogateescape")

pattern = re.compile(
    r"<script\b[^>]*\bid\s*=\s*['\"]jmtouch['\"][^>]*>.*?</script>",
    re.I | re.S
)

matches = list(pattern.finditer(data))

if len(matches) != 1:
    print("SKIP: jmtouch block count =", len(matches))
    sys.exit(2)

block = matches[0].group(0)

ioc = (
    "whitellllshop",
    "landers/scr.php",
    "_0x5a49d0",
    "0x5162",
)

if not any(x.lower() in block.lower() for x in ioc):
    print("SKIP: jmtouch block does not match known IOC")
    sys.exit(3)

new = pattern.sub("", data, count=1)

p.write_text(new, errors="surrogateescape")

print("REMOVED known malicious jmtouch block")
PY

    rc=$?

    if [ $rc -ne 0 ]; then
        return
    fi

    if [[ "$file" == *.php ]]; then

        if ! php -l "$file" >/dev/null 2>&1; then

            echo "PHP syntax failed, restoring: $file"

            cp -a "$backup" "$file"

            return
        fi
    fi

    printf '%s\t-\t%s\t%s\n' \
        "$(date '+%F %T')" \
        "removed-known-jmtouch" \
        "$file" \
        >> "$QUAR/manifest.tsv"
}


scan_site()
{
    local site="$1"
    local name
    local report
    local jce
    local jce_status

    name="$(basename "$site")"
    report="$OUT/$name"

    mkdir -p "$report"

    echo
    echo "===================================================="
    echo "SITE: $site"
    echo "===================================================="

    jce="$(detect_jce_version "$site")"
    jce_status="OK"

    if [[ "$jce" =~ ^[0-9] ]]; then

        if version_lt "$jce" "2.9.99.5"; then
            jce_status="JCE_VULNERABLE"
        fi
    fi


    echo "$jce" > "$report/jce-version.txt"
    echo "$jce_status" > "$report/jce-status.txt"


    # 已知恶意文件名

    find "$site" -xdev -type f \
    -printf '%p\n' \
    2>/dev/null \
    | while IFS= read -r f; do

        base="$(basename "$f")"

        if is_known_name "$base"; then
            stat -c '%y %U:%G %a %s %n' "$f"
        fi

    done > "$report/known-malware-files.txt"


    # 已知 WebShell SHA256。
    # 仅扫描 PHP 类文件,避免对图片等大文件无意义计算 Hash。

    find "$site" -xdev -type f \( \
      -iname '*.php' -o \
      -iname '*.phtml' -o \
      -iname '*.phar' -o \
      -iname '*.php3' -o \
      -iname '*.php4' -o \
      -iname '*.php5' -o \
      -iname '*.php7' -o \
      -iname '*.php8' \
    \) -print0 \
    2>/dev/null \
    | while IFS= read -r -d '' f; do

        h="$(sha256sum "$f" | awk '{print $1}')"

        if is_known_hash "$h"; then
            printf '%s  %s\n' "$h" "$f"
        fi

    done > "$report/known-malware-hashes.txt"


    # 已知 IOC

    grep -RInaE \
    'jmtouch|whitellllshop|landers/scr\.php|GQxcpCNmqW|privdayz\.com|request_handler_0528|cswklecp|dkafcuel' \
    "$site" \
    --exclude-dir=cache \
    --binary-files=without-match \
    2>/dev/null \
    > "$report/ioc-lines.txt" || true


    # 上传及临时目录中的 PHP

    : > "$report/php-in-writable-dirs.txt"

    for d in \
        images \
        tmp \
        cache \
        administrator/cache
    do

        [ -d "$site/$d" ] || continue

        find "$site/$d" -type f \( \
          -iname '*.php' -o \
          -iname '*.phtml' -o \
          -iname '*.phar' -o \
          -iname '*.php3' -o \
          -iname '*.php4' -o \
          -iname '*.php5' -o \
          -iname '*.php7' -o \
          -iname '*.php8' \
        \) \
        -printf '%TY-%Tm-%Td %TH:%TM:%TS %U:%G %m %s %p\n' \
        2>/dev/null \
        >> "$report/php-in-writable-dirs.txt"

    done


    # 隐藏 PHP

    find "$site" -xdev -type f \( \
      -name '.*.php' -o \
      -name '.*.phtml' -o \
      -name '.*.phar' \
    \) \
    -printf '%TY-%Tm-%Td %TH:%TM:%TS %U:%G %m %s %p\n' \
    2>/dev/null \
    > "$report/hidden-php.txt"


    # 高风险命令执行模式,仅报告,不自动清理

    grep -RInaE \
    '(\$_(GET|POST|REQUEST)\[[^]]+\].*(system|passthru|shell_exec|exec|proc_open|popen))|(eval[[:space:]]*\([[:space:]]*\$_(GET|POST|REQUEST))' \
    "$site" \
    --include='*.php' \
    --exclude-dir=cache \
    --binary-files=without-match \
    2>/dev/null \
    > "$report/suspicious-shell-lines.txt" || true


    # .user.ini

    {
        find "$site" \
        -name '.user.ini' \
        -type f \
        -print \
        2>/dev/null

        grep -RInaE \
        'auto_prepend_file|auto_append_file' \
        "$site" \
        --include='.user.ini' \
        2>/dev/null

    } > "$report/user-ini.txt"


    # clean-known 模式

    if [ "$MODE" = "clean-known" ]; then

        find "$site" -xdev -type f -print0 \
        2>/dev/null \
        | while IFS= read -r -d '' f; do

            base="$(basename "$f")"

            if is_known_name "$base"; then

                quarantine_file \
                    "$site" \
                    "$f" \
                    "known-name:$base"

            fi

        done


        # 对改名 WebShell 再通过 Hash 查找

        find "$site" -xdev -type f \( \
          -iname '*.php' -o \
          -iname '*.phtml' -o \
          -iname '*.phar' -o \
          -iname '*.php3' -o \
          -iname '*.php4' -o \
          -iname '*.php5' -o \
          -iname '*.php7' -o \
          -iname '*.php8' \
        \) -print0 \
        2>/dev/null \
        | while IFS= read -r -d '' f; do

            [ -f "$f" ] || continue

            h="$(sha256sum "$f" | awk '{print $1}')"

            if is_known_hash "$h"; then

                quarantine_file \
                    "$site" \
                    "$f" \
                    "known-hash:$h"

            fi

        done


        # 只处理符合此次已知 IOC 的 jmtouch

        grep -RIl \
        --binary-files=without-match \
        'jmtouch' \
        "$site" \
        2>/dev/null \
        | while IFS= read -r f; do

            if grep -qiE \
            'whitellllshop|landers/scr\.php|_0x5a49d0|0x5162' \
            "$f"; then

                clean_jmtouch \
                    "$site" \
                    "$f"
            fi

        done

    fi


    known_count="$(
        wc -l < "$report/known-malware-files.txt"
    )"

    hash_count="$(
        wc -l < "$report/known-malware-hashes.txt"
    )"

    ioc_count="$(
        wc -l < "$report/ioc-lines.txt"
    )"

    hidden_count="$(
        wc -l < "$report/hidden-php.txt"
    )"

    shell_count="$(
        wc -l < "$report/suspicious-shell-lines.txt"
    )"


    printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
        "$site" \
        "$jce" \
        "$jce_status" \
        "$known_count" \
        "$hash_count" \
        "$ioc_count" \
        "$hidden_count" \
        "$shell_count" \
        >> "$OUT/summary.tsv"
}


printf 'site\tjce_version\tjce_status\tknown_names\tknown_hashes\tioc_lines\thidden_php\tshell_lines\n' \
> "$OUT/summary.tsv"


if [ -n "$ONLY_SITE" ]; then

    if [ ! -f "$ONLY_SITE/configuration.php" ]; then
        echo "Not a Joomla site: $ONLY_SITE"
        exit 1
    fi

    scan_site "$ONLY_SITE"

else

    for site in "$BASE"/*; do

        [ -d "$site" ] || continue

        if [ \
            -f "$site/configuration.php" \
            && -d "$site/administrator" \
            && -f "$site/index.php" \
        ]; then

            scan_site "$site"
        fi

    done
fi


echo
echo "================ SUMMARY ================"
column -t -s $'\t' "$OUT/summary.tsv" 2>/dev/null \
|| cat "$OUT/summary.tsv"


tar -czf "${OUT}.tar.gz" \
    -C /root \
    "$(basename "$OUT")"


echo
echo "Report:"
echo "${OUT}.tar.gz"


if [ "$MODE" = "clean-known" ]; then

    echo
    echo "Quarantine:"
    echo "$QUAR"

fi

十五、脚本怎样使用?

上传以后先授权:

chmod 700 /root/joomla-fleet-selfcheck-clean.sh

第一次永远建议只运行扫描模式:

bash /root/joomla-fleet-selfcheck-clean.sh scan

脚本会自动检查:

/www/wwwroot/*

下面识别到的 Joomla 网站。

如果只检查一个站:

bash /root/joomla-fleet-selfcheck-clean.sh scan \
--site /www/wwwroot/www.yourdomain.com

如果网站目录不是宝塔默认路径:

bash /root/joomla-fleet-selfcheck-clean.sh scan \
--base /data/www

完成后会产生:

/root/joomla-fleet-scan-日期时间.tar.gz

报告中重点关注:

JCE_VULNERABLE

known_names > 0

known_hashes > 0

ioc_lines > 0

hidden_php > 0

shell_lines > 0

十六、什么时候可以运行自动清理?

如果扫描已经明确命中本文这批已知 WebShell 或恶意 jmtouch,可以运行:

bash /root/joomla-fleet-selfcheck-clean.sh clean-known \
--site /www/wwwroot/www.yourdomain.com

或者对本机所有识别出的 Joomla 网站运行:

bash /root/joomla-fleet-selfcheck-clean.sh clean-known

这个脚本不会看到“可疑 PHP”就全部删除。

它只自动处理两类已经明确确认的对象:

  1. 本文实际事件中确认的恶意文件名或 SHA256;
  2. 同时匹配 jmtouch 和已知恶意 IOC 的注入代码。

对于普通的:

eval()
exec()
system()
base64_decode()

脚本只会报告,不会自动删除,因为很多正常 PHP 库也可能合法使用这些函数。

十七、自动清理为什么先做隔离?

脚本执行 clean-known 时不会直接永久销毁文件,而是先复制到:

/root/joomla-quarantine-日期时间/

并建立:

manifest.tsv

记录:

时间
SHA256
清理原因
原始文件路径

这样做的原因很简单:安全事件处理中,“删掉”不是第一目标,首先应该保留证据,并确保不会因为误判导致正常网站文件永久丢失。

对于被修改的 jmtouch PHP 文件,脚本还会:

完整备份原文件
↓
只删除恶意 script 块
↓
执行 php -l
↓
PHP 语法正确
    → 保留修复结果

PHP 语法错误
    → 自动恢复原文件

十八、JCE Editor Profiles 也要检查

清理完文件以后,进入 Joomla 后台:

组件 → JCE Editor → Editor Profiles

正常情况下,只应该保留自己认识的 Profile。

如果出现:

随机字符串
莫名其妙的新 Profile
不认识的用户组授权
不合理的文件上传权限

应该进一步确认。

同时建议把 JCE Profile 只分配给真正需要编辑网站内容的 Joomla 用户组。

企业展示站如果只有管理员维护,可以尽量减少:

Public
Guest
Registered
Author

等低权限用户组获得 JCE 文件管理能力。

十九、Joomla 自身还应该做哪些加固?

对于普通企业展示站,我建议至少做到:

  • 关闭不需要的前台用户注册;
  • 删除不认识的 Joomla 管理员账号;
  • 管理员启用两步验证;
  • JCE 只允许必要用户组使用;
  • Joomla 和扩展及时更新;
  • 限制 imagestmp 等目录 PHP Web 执行;
  • configuration.php 尽量保持只读;
  • 生产环境减少直接在线编辑模板 PHP;
  • 安全事件以后重新生成干净备份。

如果网站平时基本不修改程序代码,还可以进一步采用:

Joomla 程序文件:
root:www
只读

运行时可写目录:
www:www

维护升级时:
临时开放写权限

升级完成:
重新锁定

这样即使某个 PHP 扩展以后再次出现 RCE,运行 PHP-FPM 的 www 用户也无法轻易修改 Joomla 模板、插件和核心程序文件。

二十、哪些做法不建议?

处理这类安全事件时,有几个做法看起来简单,但风险比较大。

1. 不建议只升级 JCE 就结束

升级只能关闭漏洞入口,不能删除已经存在的 WebShell。

2. 不建议执行 find 后把所有 PHP 删除

mediacache 或第三方组件目录中可能存在合法 PHP。

3. 不建议只看文件修改时间

攻击者可能重写文件、保留时间甚至修改时间戳。应该结合 Web 日志、文件内容和 Hash 判断。

4. 不建议直接相信旧备份

如果备份创建于入侵以后,其中完全可能已经包含 WebShell。

5. 不建议因为服务器现在可以访问就认为已经清理干净

很多 WebShell 对正常页面完全没有影响,网站可以几个月看起来都正常。

二十一、多个网站的推荐处理顺序

如果一台服务器上有十几个类似 Joomla 网站,可以统一按下面流程处理:

第一轮
批量 scan
↓
找出 JCE 漏洞版本
↓
找出明确 IOC
↓
把网站分成:
正常 / 可疑 / 明确感染


第二轮
优先处理明确感染站点
↓
升级 JCE
↓
clean-known
↓
人工检查可疑 PHP


第三轮
再次 scan
↓
确认已知 IOC = 0


第四轮
检查:
Joomla 管理员
JCE Profiles
.user.ini
Cron
SSH


第五轮
配置 Nginx:
images
tmp
cache
administrator/cache
禁止直接执行 PHP


第六轮
重新建立干净备份

二十二、怎样判断问题基本处理完成?

至少应该同时满足:

JCE 已升级到安全版本

已知 WebShell 文件 = 0

已知 WebShell SHA256 = 0

jmtouch / 已知恶意域名 IOC = 0

images 中没有未知 PHP

tmp 中没有未知 PHP

没有异常 auto_prepend_file

没有异常 Cron

没有陌生 SSH key

没有陌生 Joomla 管理员

JCE Profiles 正常

Nginx 已禁止上传目录 PHP 执行

重新建立了干净备份

需要强调的是,服务器安全没有一个命令能够证明“100% 不存在任何后门”。如果已经发生严重 RCE,而且服务器中还存放重要数据,那么最严格的做法始终是重新部署可信系统并从经过验证的干净数据恢复。

但对于普通 Joomla 企业展示站,按照上面的日志取证、IOC 扫描、WebShell 清理、权限检查和 Nginx 加固流程执行以后,可以大幅降低残留风险。

二十三、这次事件给我的经验

这次问题最大的教训不是“JCE 不能用”。JCE 本身是一个非常成熟、使用广泛的 Joomla 编辑器,真正的问题是:任何具有文件管理和上传能力的 Joomla 扩展,一旦出现未认证访问漏洞,其风险都远远高于普通前台插件。

特别是服务器默认允许:

/images/xxx.php
/tmp/xxx.php

直接交给 PHP-FPM 执行时,一个“文件上传漏洞”很容易直接升级成“服务器远程代码执行漏洞”。

因此除了及时升级扩展以外,更重要的是增加服务器层的纵深防御:

应用层漏洞
↓
攻击者即使成功上传 PHP
↓
Nginx 不允许执行
↓
攻击链在服务器层再次被阻断

这比单纯依赖“以后一定及时升级”更加可靠。

结论

CVE-2026-48907 是 JCE 旧版本中一个非常严重的未认证安全漏洞。受影响站点可能不仅出现异常 Editor Profile,还可能进一步被上传 PHP WebShell,并造成 Joomla 文件被修改、恶意 JavaScript 注入以及长期后门。

如果网站使用过 JCE 2.9.99.5 之前的版本,不建议只检查当前 JCE 是否已经升级,而应该同时回查服务器日志和网站文件,特别是:

images/
tmp/
templates/
administrator/templates/
plugins/
JCE Editor Profiles
Nginx access log
Cron
.user.ini
authorized_keys

已经确认被利用的网站,正确的处理思路不是简单删除一个 PHP 文件,而是:

关闭漏洞入口
+
清理已知 WebShell
+
检查持久化
+
检查被修改的 Joomla 文件
+
阻止上传目录执行 PHP
+
重新建立可信备份

如果同一台服务器上运行多个 Joomla 网站,可以使用本文的批量扫描脚本先统一自查,再只对明确命中 IOC 的站点执行保守清理。相比一个站一个站手工搜索,这种方式效率会高很多,也更适合宝塔环境下管理多个 Joomla 企业网站。