Reading Between the Lines: From File Viewer to Account Takeover 

As part of our ongoing security research at AnchorSec, we regularly identify vulnerabilities that highlight common but often overlooked development pitfalls. In this case, what initially appeared to be a straightforward file-viewing feature exposed a path to DOM-based XSS and ultimately account takeover. While the affected application has since been remediated, it will not be identified in this article due to the sensitivity of the environment and the data involved. Instead, this post focuses on the vulnerability itself, examining how small implementation decisions combined to create a high-impact vulnerability chain with real compromise potential. 

Background

One aspect of the culture at AnchorSec that we are proud of is that we are continually engaged in security research. Internally, this builds experience, knowledge and skill, but it also leads to responsible disclosures and the publication of CVEs. Some vendors or developers, however, prefer to keep CVEs out of the spotlight, so issues are sometimes disclosed privately while still being remediated as normal (in much the same way as findings are disclosed and remediated in penetration tests and red team engagements).  

While conducting security research, I recently identified a vulnerability in a web application that allowed a request to be made, which led to DOM-based XSS and ultimately, account takeover. Privacy was a significant concern for the developers of the application, and an attack of this kind could have exposed information relating to end users. 

Due to other engagements, I didn’t write up the PoC immediately, and upon returning to the issue, I downloaded the latest version only to find that it had already been remediated. However, the finding still served to demonstrate the risks and pitfalls that come with trusting user-supplied input.  

I know, not exactly a thrilling opening.  

But certainly more interesting than supplying a <script>alert(1)</script> tag to a POST request! So here we go…  

Remote File Viewing

The application included a feature that allowed users to open certain file types directly in the browser rather than downloading them.  

Visiting this page causes an HTTP request to be made with a parameter that allows the application to reference a URL.

@page.get("/view") 

@page.get("/view/") 

@<REDACTED>.utils.no_cache() 

def view_page(): 

    url_input = request.args.get("url", "").strip() 

    if url_input: 

        account_id = <REDACTED>.utils.get_account_id(request.cookies) 

        if account_id is None: 

            return redirect("/<REDACTED>_download", code=302) 

        with Session(mariapersist_engine) as mariapersist_session: 

            account _download_info = <REDACTED>.utils.get_account_download_info(mariapersist_session, account_id) 

            if account_download_info is None: 

                return redirect("/<REDACTED>_download", code=302) 

        return render_template("page/view.html", header_active="", url=url_input, viewer_supported_extensions=VIEWER_SUPPORTED_EXTENSIONS)

Because of this, the following URL can be constructed: 

Reviewing the server-side code shows that the application retrieves the URL value, verifies that the user is authenticated, and then passes the URL to the viewer page. ZIP files are included within the list of supported file extensions and are handled by a dedicated viewer component. In the source, those supported extensions were defined in a dictionary. 

Notably, the server-side logic does not validate the contents of the supplied file. Instead, it passes the URL directly to the client-side viewer for processing. 

Security flaws

This extension checking is conducted client-side, which is a weakened control in itself. If the supplied URL points to a ZIP file, the following script path is triggered:

<script>(async () => {await loadViewerByUrl("{{ url }}")})()</script>

The ZIP file is then fetched from the supplied URL by the client-side code. 

async function loadZip(fileUrl) { 

      const blob = await (await fetch(fileUrl)).blob() 

      const reader = new zip.ZipReader(new zip.BlobReader(blob)); 

 

      let entries = await reader.getEntries(); 

      entries.sort((a, b) => a.filename.localeCompare(b.filename)); 

      if (entries.length === 0) { 

        displayError("Zip file is empty"); 

        return; 

      } 

      if (entries.some(e => !e.filename.endsWith(".txt"))) { 

        loadWithVillain(blob); 

        return; 

      } 

 

     document.getElementById("viewer-container").innerHTML = ""; 
     ```

The code verifies only that the files inside the archive have a .txt extension. It does not inspect the contents of those files or perform any sanitisation. 

Later, the viewer extracts the contents of the files:

const text = await entry.getData(new zip.TextWriter());

The XSS sink appears when the code does the following: 

innerContainer.innerHTML = innerHTML;

This is the critical sink. Rather than treating the contents as plain text, the application injects them directly into the DOM using innerHTML. 

The contents of the ZIP file are not validated, and they are not checked server-side. The server-side code simply accepts the URL parameter and returns the viewer page, leaving the client-side code to extract the archive contents and inject them into the DOM. 

The risks of innerHTML 

This brings us to the behaviour of innerHTML. innerHTML is a JavaScript property on DOM elements that allows you to read or replace the HTML inside an element. When you assign a string to element.innerHTML, the browser parses that string as HTML, creates the corresponding DOM nodes, and inserts them into the page. It is convenient, but it also creates a dangerous trust boundary if the content is not controlled. 

The main danger with innerHTML is that it treats the supplied string as real HTML. If you insert user-controlled content directly, you risk turning untrusted input into executable code inside the page. 

 This means that a string such as: 

  • <img src=x onerror="alert(1)"> 

could be placed inside a .txt file within the ZIP archive and would trigger an alert box when rendered. An alert is trivial on its own, but once you have XSS in the application, it becomes entirely possible to target other users and take over accounts using the power of JavaScript. 

From XSS to Account Takeover

Once XSS is established, the path to account takeover becomes straightforward. 

  1. The attacker prepares a malicious ZIP file and hosts it on a server they control, for example https://maliciousURL.com/payload.zip. 

  2. The attacker sends a crafted link to a logged-in member: https://vulnerableURL/view?url=https%3A%2F%2Fattacker.example%2Fpayload.zip   

  3. The authenticated user opens the link. The server accepts the URL parameter, confirms the user is allowed to use the viewer, and returns the viewer page. 

  4. The viewer page runs client-side JavaScript that extracts the .zip extension from the supplied URL and calls the ZIP loader. 

  5. The victim’s browser fetches the attacker-hosted ZIP, unpacks it client-side, reads the .txt entries, concatenates their contents, and assigns that string to innerHTML. 

  6. The payload from the .txt file becomes live HTML inside the trusted application origin. 

  7. Because the script runs same-origin with the application, it can make authenticated requests using the victim’s session, such as reading account pages or making privileged requests. 

  8. Account takeover is achieved. 

Mitigation

The most immediate mitigation is to remove the unsafe innerHTML sink in the ZIP text path. ZIP .txt entries should be rendered strictly as text, using textContent or explicit text nodes, not parsed as HTML. In this application, it means replacing the accumulated innerHTML += text / innerContainer.innerHTML = innerHTML pattern with DOM creation that appends text nodes and separator elements. This directly nullifies the XSS while preserving the viewer’s ability to display text files. 

More broadly, user-controlled content should never be trusted simply because it carries a specific file extension. Validation and sanitisation should be applied at appropriate trust boundaries to prevent injection type attacks, particularly when content is being rendered into the DOM. 

The value of code review

I found this exploit particularly interesting. At its core, it is still XSS, and the attacker ultimately abuses it in much the same way as any other XSS issue to target other users. What makes it more compelling is the route it takes to get there, because the path to exploitation is far less obvious than a typical injection flaw. In fact, the only reason it was identified at all was through code review. 

So what does that mean in practice? Primarily, it reinforces a familiar truth, that application testing is often stronger when source code is available. Having that visibility makes it far easier to spot the more unusual or indirect issues that might never surface during a short, time-boxed engagement.

Next
Next

Answering THE Question: How do I get into cybersecurity?