个人随笔
目录
MCMS 6.2.0 入侵复盘与完整处置文档
2026-08-12 14:15:00
一、环境信息
  • 程序版本:MCMS 6.2.0(当前最新正式版)
  • 触发漏洞:Mdiy 自定义表单模块 /mdiy/form/data/list SQL 注入(非老旧版本 arclist/category/list 漏洞)
  • 关键风险放大器:allowMultiQueries=true(JDBC 连接串开启多语句执行能力)
  • 攻击来源 IP:43.172.52.121
核心重点:MCMS 6.2.0 已修复官方公开的主程序 SQL 注入漏洞,但 Mdiy 自定义表单这类附加组件存在独立未修复的注入漏洞。allowMultiQueries=true 本身不属于漏洞,但会将普通只读 SQL 注入放大为可堆叠执行任意SQL语句的高危利用链路,是本次服务器被完全攻陷的核心必要条件。

二、完整攻击链路(日志完整还原)

1. 触发SQL注入,开启堆叠执行条件

攻击者访问漏洞接口:GET /mdiy/form/data/list,构造带分号 ; 的恶意 Payload。依托 JDBC 配置 allowMultiQueries=true,突破单条SQL执行限制,实现多语句堆叠执行。

2. 绕过业务层,伪造后台管理员账号

攻击者通过堆叠 SQL 中的 REPLACE 语句,直接操作数据库 manager 管理员表,强行插入合法后台账号,全程绕过 MCMS 所有 Java 业务校验逻辑:
  • 伪造账号ID:9998
  • 伪造用户名:super
  • 账号密码逻辑:攻击者本地预先计算明文密码 ctf405311 的 MD5 哈希值,直接将密文写入数据库 manager_password 字段,绕过系统密码加密、注册校验、权限校验等所有业务规则。

3. 利用伪造账号正常登录后台

调用 /code.do 获取登录验证码,通过 /ms/shturl.c 接口,使用伪造账号 super/ctf405311 正常登录 MCMS 后台,获得完整管理员权限。

4. 调用合法后台接口上传WebShell木马

攻击者凭借合法管理员身份,调用系统原生合法接口 /ms/store/share/uploadTemplate.do(仅管理员可访问,无代码漏洞),上传 JSP 格式网页木马。

5. 执行木马,完全接管服务器权限

木马成功上传至服务器 template/JX1786405314.jsp,访问该文件返回 200 状态码,攻击者通过 WebShell 获取服务器系统命令执行权限,服务器彻底沦陷。
关键阻断逻辑:若关闭 allowMultiQueries=true,即便 Mdiy 接口存在 SQL 注入漏洞,分号后的新增账号 SQL 语句无法执行,攻击者无法伪造管理员账号,后续登录、传马、控服的整套攻击链路会直接中断,漏洞仅能实现数据查询,无高危接管风险。

三、风险点清单

  1. Mdiy 自定义表单附加组件存在 SQL 注入漏洞,组件补丁更新滞后于主程序,最新版主程序无法覆盖修复;
  2. JDBC 连接串开启 allowMultiQueries=true,成为漏洞放大核心条件,大幅提升入侵危害等级;
  3. 后台 /ms/*、表单组件 /mdiy/* 接口全网开放,无 IP 白名单限制,攻击面完全暴露;
  4. 若 MCMS 使用 MySQL root 账号连接数据库,自带 FILE 高危权限,可直接通过 SQL 写入服务器文件;
  5. 网站上传、模板目录支持 JSP 脚本执行,为木马运行提供环境;
  6. 入侵后存在持久化后门风险:残留 JSP/JSPX 木马、SSH 公钥后门、定时任务恶意脚本。

四、紧急处置步骤(按执行顺序)

1. 关闭JDBC多语句执行(核心必做)

修改项目 application-prod.yml 配置文件,彻底删除 JDBC 连接串中的allowMultiQueries=true,杜绝 SQL 堆叠执行风险。
修改后标准配置示例:
url: jdbc:mysql://127.0.0.1:3306/mcms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
配置修改完成后,重启 MCMS 服务,确保配置生效。

2. 清理数据库恶意伪造账号

登录 MySQL,进入 MCMS 业务数据库,执行SQL清理后门账号并巡检全部管理员:
-- 删除攻击者伪造的管理员账号
delete from manager where id=9998;

-- 巡检全部后台管理员,排查其他伪造账号
select id,manager_name,manager_nickname,manager_password,DEL,MANAGER_LOCK from manager;

3. 全盘查杀WebShell木马

进入项目目录,执行命令筛查所有可疑脚本文件,清理入侵残留木马:
# 查找项目下所有jsp、jspx脚本文件
find ./ -type f -name "*.jsp" -o -name "*.jspx"

# 精准筛查24小时内新增/修改的木马文件(重点排查)
find ./ -mtime -1 -type f \( -name "*.jsp" -o -name "*.jspx" \)

# 删除本次已知恶意木马文件
rm -f ./template/JX1786405314.jsp

4. Nginx配置后台接口IP白名单

业务无需 Mdiy 表单则直接拦截全部/mdiy/* 接口,后台 /ms/* 仅放行自有公网IP,禁止外网访问:
location ~ ^/(ms|mdiy)/ {
    allow 你的公网IP;
    deny all;
}
配置完成后重载 Nginx 生效:nginx -s reload

5. 收紧MySQL数据库权限

禁止使用 MySQL root 账号对接业务,创建专属低权限业务账号,并回收高危 FILE 权限:
-- 回收数据库文件写入高危权限
REVOKE FILE ON *.* FROM '你的cms业务账号'@'localhost';
FLUSH PRIVILEGES;

6. 排查服务器持久化后门(关键收尾)

# 查看root用户定时任务,排查恶意定时脚本
crontab -l

# 巡检系统全局定时任务目录
ls -la /etc/cron.*

# 排查SSH公钥后门,清空陌生密钥
cat /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys

# 筛查系统所有可登录账号,仅保留root、lighthouse为正常账号
grep -vE '/sbin/nologin|/bin/false' /etc/passwd

# 排查是否存在非法cms系统用户
id cms

五、长期安全加固方案

  1. Mdiy自定义表单组件:无业务需求则通过Nginx永久拦截,彻底消除攻击面;刚需使用则持续跟进官方安全补丁,定期更新组件版本。
  2. 生产环境强制规范:所有项目JDBC连接串永久禁止配置allowMultiQueries=true,作为全局安全兜底策略。
  3. 后台接口强制白名单:/ms/* 后台管理接口不对外网开放,仅信任自有固定IP访问。
  4. 数据库权限最小化:业务统一使用低权限专属账号,禁用root账号对接项目,永久回收FILE权限。
  5. 常态化安全巡检:定期扫描项目目录下JSP、JSPX脚本文件,监控上传、模板目录文件变动。
  6. 版本更新规范:MCMS升级仅替换jar包后,必须人工核对yml配置文件,避免遗留高危旧配置。

六、核心认知总结

  1. allowMultiQueries=true 是高危风险放大器而非原生漏洞,关闭后即便出现SQL注入,也无法堆叠SQL新增账号、写入文件,入侵危害从“服务器沦陷”降级为“仅数据查询”。
  2. 堆叠SQL注入可完全绕过应用层密码加密、注册校验等业务逻辑,直接操作数据库写入合法管理员密文,属于底层高危攻击手段。
  3. 本次攻击未利用后台漏洞,而是通过伪造合法管理员账号,调用系统正常上传接口,属于权限前置劫持攻击。
  4. MCMS附加组件安全更新滞后于主程序,最新版主程序无法覆盖第三方组件漏洞,存在独立攻击面。
  5. 入侵修复不能仅补代码漏洞,必须完整查杀木马、清理后门、收紧权限,否则攻击者可通过持久化后门再次入侵。

七、最终解决方案

因为后台管理很多地方都有用到/mdiy,所以不能在nginx拦死,目前做如下两个解决办法
1、所有/mdiy开始的请求必须登录
2、所有/mdiy开始的请求拦截sql注入相关字符串
当然这个办法的前提是管理员密码不能泄露出去,不然白搞

package net.mingsoft.cms.config;

import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {

    private final byte[] cachedBody;

    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        this.cachedBody = request.getInputStream().readAllBytes();
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody);
        return new ServletInputStream() {
            @Override
            public int read() {
                return byteArrayInputStream.read();
            }

            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            // 补全Servlet 3.1强制要求的抽象方法
            @Override
            public void setReadListener(ReadListener readListener) {
                // 同步读取场景,无需实现异步监听逻辑
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
    }

    /**
     * 获取请求体字符串用于SQL注入校验
     */
    public String getBodyString() {
        return new String(cachedBody, StandardCharsets.UTF_8);
    }
}



package net.mingsoft.cms.config;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.mingsoft.cms.config.CachedBodyHttpServletRequest;
import org.springframework.http.MediaType;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

public class MdiySqlInjectFilter implements Filter {

    // SQL注入危险关键字(大小写不敏感)
    private static final List<String> DANGER_KEYWORDS = Arrays.asList(
            "union", "select", "insert", "delete", "drop", "alter",
            "updatexml", "extractvalue", "sleep", "benchmark",
            "information_schema", "floor", "rand(", "or 1=", "and 1="
    );

    // 危险特殊字符
    private static final List<String> DANGER_CHARS = Arrays.asList(
            "'", "\"", ";", "--", "/*", "*/", "%00", "\\"
    );

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        String uri = req.getRequestURI();

        // mdiy路径直接放行
        if (!uri.startsWith("/mdiy/")) {
            chain.doFilter(request, response);
            return;
        }

        // ========== 1. 校验 GET + POST 表单参数 ==========
        Map<String, String[]> parameterMap = req.getParameterMap();
        for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
            Object valueObj = entry.getValue();
            String[] values;
            // 兼容单值String和数组String[]两种情况,避免ClassCastException
            if (valueObj instanceof String[]) {
                values = (String[]) valueObj;
            } else if (valueObj instanceof String) {
                values = new String[]{(String) valueObj};
            } else {
                continue;
            }

            for (String value : values) {
                if (value == null) continue;
                if (isDanger(value.toLowerCase())) {
                    reject(resp);
                    return;
                }
            }
        }

        // ========== 2. 校验 POST JSON 请求体 ==========
        String contentType = req.getContentType();
        if (contentType != null && contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
            CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(req);
            String body = wrappedRequest.getBodyString().toLowerCase();
            if (isDanger(body)) {
                reject(resp);
                return;
            }
            // 校验通过,用包装后的请求继续向后传递
            chain.doFilter(wrappedRequest, response);
            return;
        }

        // JSON请求,直接向后传递
        chain.doFilter(request, response);
    }

    /**
     * 判断是否包含危险内容
     */
    private boolean isDanger(String content) {
        for (String danger : DANGER_CHARS) {
            if (content.contains(danger)) {
                return true;
            }
        }
        for (String keyword : DANGER_KEYWORDS) {
            if (content.contains(keyword)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 拦截返回
     */
    private void reject(HttpServletResponse resp) throws IOException {
        resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().write("Parameter invalid");
    }
}


package net.mingsoft.cms.config;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MdiyFilterConfig {

    /**
     * SQL注入参数拦截过滤器,优先级10,最先执行
     */
    @Bean
    public FilterRegistrationBean<MdiySqlInjectFilter> mdiySqlInjectFilter() {
        FilterRegistrationBean<MdiySqlInjectFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new MdiySqlInjectFilter());
        bean.addUrlPatterns("/*");
        bean.setOrder(10);
        return bean;
    }
}


package net.mingsoft.cms.config;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.io.IOException;

@Aspect
@Component
public class MdiyAuthAspect {
    // 精准拦截web包下所有Action(带@Controller注解)
    @Pointcut("execution(* net.mingsoft.mdiy.action..*.*(..))")
    public void mdiyWebActionPointCut() {}

    @Around("mdiyWebActionPointCut()")
    public Object checkLogin(ProceedingJoinPoint joinPoint) throws Throwable {
        Subject subject = SecurityUtils.getSubject();
        // principal非空 = 已登录(兼容密码登录、rememberMe记住我)
        if (subject.getPrincipal() == null) {
            ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attr != null) {
                HttpServletResponse resp = attr.getResponse();
                resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
                resp.setContentType("text/plain;charset=UTF-8");
                try {
                    resp.getWriter().write("Access Denied:需管理员登录");
                } catch (IOException ignored) {}
            }
            return null;
        }
        return joinPoint.proceed();
    }
}

 21

啊!这个可能是世界上最丑的留言输入框功能~


当然,也是最丑的留言列表

有疑问发邮件到 : suibibk@qq.com 侵权立删
Copyright : 个人随笔   备案号 : 粤ICP备18099399号-2