CVE-2024-32484
An reflected XSS vulnerability exists in the handling of invalid paths in the Flask server in Ankitects Anki 24.04. A specially crafted flashcard can lead to JavaScript code execution and result in an arbitrary file read. An attacker can share a malicious flashcard to trigger this vulnerability.
The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.
Ankitects Anki 24.04
Anki - https://apps.ankiweb.net/
7.4 - CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N
CWE-80 - Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS)
Anki is an open-source program that helps with memorization of information through the use of flash cards. It supports syncing of these cards across multiple computers as well as sharing cards with other users. It supports multiple different content types such as images, audio, videos, and scientific notation (via LaTeX).
Anki offers users the option to publicy share their decks, and it is normal behaviour to use them; there are no warnings or checks in place to prevent using cards from someone else. A malicious user could share a deck to trigger the following vulnerability.
Anki provides an internal flask server to manage the app, including importing new media for flashcards and changing deck options. You can find the exposed backend methods here. These are designed to only be accessed by the Anki program and not through user-imported content. However, due to a reflected XSS vulnerability in the web server, a precisely crafted card can access the methods exposed.
When processing a request, the web server checks the referer header to see where it’s being sent from (Anki calls this the “context”).
def _extract_page_context() -> PageContext:
"Get context based on referer header."
from urllib.parse import parse_qs, urlparse
referer = urlparse(request.headers.get("Referer", ""))
if referer.path.starts with("/_anki/pages/") or is_sveltekit_page(referer.path[1:]):
return PageContext.NON_LEGACY_PAGE
elif referer.path == "/_anki/legacyPageData":
query_params = parse_qs(referer.query)
id = int(query_params.get("id", [None])[0])
return aqt.mw.mediaServer.get_page_context(id)
else:
return PageContext.UNKNOWN
It then checks if the method is a GET, in which case any context is fine—this is for rendering HTML pages like the reviewer and card content in the “reviewer” page, or if it’s a POST, and therefore, the context has to be checked to make sure it’s authorised to call the API. Note the comment [1] about containing third-party JavaScript.
def _check_dynamic_request_permissions():
if request.method == "GET":
return
context = _extract_page_context()
def warn() -> None:
show_warning(
"Unexpected API access. Please report this message on the Anki forums."
)
# check content type header to ensure this isn't an opaque request from another origin
if request.headers["Content-type"] != "application/binary":
aqt.mw.taskman.run_on_main(warn)
abort(403)
if (
context == PageContext.NON_LEGACY_PAGE
or context == PageContext.EDITOR
or context == PageContext.ADDON_PAGE
or os.environ.get("ANKI_API_PORT")
):
pass
elif context == PageContext.REVIEWER and request.path in (
"/_anki/getSchedulingStatesWithContext",
"/_anki/setSchedulingStates",
):
# reviewer is only allowed to access custom study methods
pass
else:
# other legacy pages may contain third-party JS, so we do not [1]
# allow them to access our API
aqt.mw.taskman.run_on_main(warn)
abort(403)
One of Anki’s features that makes it so extendable is its ability to include custom JavaScript & CSS in the flashcards rendered as HTML. When the user reviews the card, it embeds a QTWebEngine to render the page with the flashcard code injected. We, of course, can make HTTP requests with JavaScript and, therefore, can make a POST request to the webserver API. However, as previously mentioned, the author is aware of this and has implemented the check for the context of where the JavaScript is being run, which in our case is the review page (PageContext.REVIEWER), so the execution of the API call gets aborted.
Before checking anything, the Flask web server first ensures that the resource requested is valid and whether or not it’s a file being requested or an API method. If it’s not, it will return an HTTP status. not found
response with the path location - embedded directly [2] into the HTML and, therefore, is interpreted as such, creating a reflected XSS vulnerability.
else:
print(f"Not found: {path}")
return flask.make_response(
f"Invalid path: {path}", [2]
HTTPStatus.NOT_FOUND,
)
To create a valid context for the API request, the URL requested should begin with /anki/pages/
. By abusing the reflected XSS vulnerability, we can create a link like /anki/pages/<img src=x onerror=alert(1)
. This loads the Invalid path page, injects our HTML and runs the malicious JavaScript.
To utilise this with our card located in the denied listed context, we can create an invisible iframe with the malicious URL to load. When the user reviews the card, the iframe will be added, and the reflected XSS will execute, giving us JavaSscript access in an authorised context.
<script>
const iframe = document.createElement('iframe');
iframe.style.display = 'none'; // Hide the iframe
document.body.appendChild(iframe);
iframe.src = 'http://' + window.location.hostname + ':' + window.location.port + '/_anki/pages/<img src=x onerror=eval(atob("{malicious_code_base64}")) />';
</script>
Anki has a feature for flashcards that lets the user attach an image and cover it up to memorise. To achieve this, however, the image must first be moved into Anki’s media folder for it to be accessed by the cards JavaScript to render. When adding an image occlusion note, the addImageOcclusionNote
API method is called and given a path to the image to import and create the note. However, no check is done here to ensure it is a valid image; therefore, any file can be given [3], which Anki will happily add to the media folder [4].
pub fn add_image_occlusion_note(
&mut self,
req: AddImageOcclusionNoteRequest,
) -> Result<OpOutput<()>> {
// image file
let image_bytes = read_file(&req.image_path)?;
let image_filename = Path::new(&req.image_path) [3]
.file_name()
.or_not_found("expected filename")?
.to_str()
.unwrap()
.to_string();
let mgr = MediaManager::new(&self.media_folder, &self.media_db)?; [4]
Once this is done, we can easily access the file through JavaScript, as the webserver provides a direct link to the media folder assets (Located at 127.0.0.1:anki_port/image_name.png
), where image_name.png
is the file you want to access. We can then read this file and send its contents to the attacker’s webserver.
2024-05-27 - Vendor Disclosure
2024-06-24 - Vendor Patch Release
2024-07-22 - Public Release
Discovered by Autumn Bee Skerritt of Cisco Duo Security and Jacob B