YAMLwaf
Description
YAML is awesome!!
Solution
Source code
The challenge comes with source code, server.js is most relevant.
const express = require("express");
const bodyParser = require("body-parser");
const fs = require("fs");
const path = require("path");
const yaml = require("js-yaml");
const app = express();
app.use(bodyParser.text());
app.post("/", (req, res) => {
try {
if (req.body.includes("flag")) {
return res.status(403).send("Not allowed!");
}
if (req.body.includes("\\") || req.body.includes("/") || req.body.includes("!!") || req.body.includes("<")) {
return res.status(403).send("Hello, Hacker :)");
}
const data = yaml.load(req.body);
const filePath = data.file;
if (filePath && fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, "utf8");
return res.send(content);
} else {
return res.status(404).send("File not found");
}
} catch (err) {
return res.status(400).send("Invalid request");
}
});
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
Breaking it down
The app processes POST requests, parses YAML from the request body, and attempts to read a file specified in the YAML content.
WAF checks if:
'flag'is in the raw request body → 403.'/','\\','!!', or'<'are present → 403. If checks pass, it tries to accessdata.file.
| Filter | Purpose | Bypass Possibility |
|---|---|---|
'flag' | Blocks direct keyword usage | Use Unicode, split string, etc. |
'/', '\\' | Blocks path traversal | Use symlinks or local files |
'!!', '<' | Blocks YAML tag injection | No known bypass with these blocked |
YAML Injection
The challenge description already gave a sample curl command.
curl -X POST "http://challs.tsukuctf.org:50001" -H "Content-Type: text/plain" -d "file: flag.txt"
Not allowed!
Imagine if it gave the flag 😆 It doesn't since "flag" is a blocked keyword.
I tried a lot of suggestions from ChatGPT; unicode chars, splitting the flag into variables, adding newlines etc. Nothing worked and it kept going in circles.
I checked this guide from J0r1an but we can't use the < character, and it specifies versions < 4.0, while this challenge uses the latest version of js-yaml (^4.1.0).
I searched through previous CTF writeups but wasn't getting anywhere. Eventually, I swapped the ChatGPT model from o4 to o3 and found a working solution.
curl -X POST "http://challs.tsukuctf.org:50001" -H "Content-Type: text/plain" --data-binary $'%TAG !b! tag:yaml.org,2002:\n---\nfile: !b!binary "ZmxhZy50eHQ="'
TsukuCTF25{YAML_1s_d33p!}
Here's the logic behind the payload:
| Stage | What happens | Why the blacklist is bypassed |
|---|---|---|
| 1. YAML directive | %TAG !b! tag:yaml.org,2002: declares a handle !b!. Any tag that starts with !b! is expanded to tag:yaml.org,2002:. | Directive text contains none of the blocked substrings. |
| 2. Binary tag | !b!binary therefore becomes the official core tag tag:yaml.org,2002:binary (!binary). | Only a single ! is used → no !!. No < or /. |
| 3. Base‑64 value | "ZmxhZy50eHQ=" is base‑64 for the ASCII bytes flag.txt. | The four ASCII letters f l a g never appear in the raw request, so req.body.includes("flag") is false. |
4. js‑yaml decoding | With the default (safe) schema, !binary is still recognised. js‑yaml converts it to a Node Buffer containing the bytes flag.txt. | No dangerous function tags are involved, so the payload is accepted. |
| 5. File read | The application later executes fs.existsSync(filePath) and fs.readFileSync(filePath,'utf8'). Both fs calls accept either a string or a Buffer as the path, so the Buffer works. | From this point onward the blacklist is already satisfied and no further checks occur. |
| 6. Response | The server reads the real flag.txt from disk and returns its contents in the HTTP response. | Mission accomplished. |
Flag: TsukuCTF25{YAML_1s_d33p!}
