They call me the blogler.
A blogging platform built with Flask. Users register, write blog posts in Markdown, and edit their blog’s YAML configuration through a Monaco editor. The flag sits at /flag on the server.
The app has explicit path traversal protection. It checks for ../ in filenames, blocks absolute paths, and verifies that resolved paths stay inside the blogs directory. Breaking through requires finding a way to mutate a filename after validation has already passed.
Application overview #
The app has two main features: uploading blog posts (Markdown files saved to disk) and editing a YAML config that controls how your blog is served.
When you visit /blog/<username>, the server reads each blog entry’s name field and opens that file from the blogs directory:
@app.get("/blog/<string:username>")
def serve_blog(username):
if username not in users:
return "username does not exist", 404
blogs = [
{"title": blog["title"], "content": mistune.html((blog_path / blog["name"]).read_text())}
for blog in users[username]["blogs"]
]
return render_template("blog.html", blogs=blogs, name=users[username]["user"]["name"])If we can control blog["name"] to be something like ../../flag, the server will read /flag instead of a file inside blogs/. But there’s validation standing in the way.
The validation #
When you submit a new YAML config, validate_conf checks every blog entry’s name field:
def validate_conf(old_cfg: dict, uploaded_conf: str) -> dict | str:
try:
conf = yaml.safe_load(uploaded_conf)
for i, blog in enumerate(conf["blogs"]):
if not isinstance(blog.get("title"), str):
return f"please provide a 'title' to the {i+1}th blog"
# no lfi
file_name = blog["name"]
assert isinstance(file_name, str)
file_path = (blog_path / file_name).resolve()
if "../" in file_name or file_name.startswith("/") or not file_path.is_relative_to(blog_path):
return f"file path {file_name!r} is a hacking attempt. this incident will be reported"
if not isinstance(conf.get("user"), dict):
conf["user"] = dict()
conf["user"]["name"] = display_name(conf["user"].get("name", old_cfg["user"]["name"]))
conf["user"]["password"] = conf["user"].get("password", old_cfg["user"]["password"])
if not isinstance(conf["user"]["password"], str):
return "provide a valid password bro"
return conf
except Exception as e:
return f"exception - {e}"Three checks block direct path traversal on each blog’s name:
"../" in file_namerejects any filename containing the literal substring../file_name.startswith("/")rejects absolute pathsnot file_path.is_relative_to(blog_path)resolves the path and checks it stays underblogs/
These are solid. There’s no way to pass a string like ../../flag through this gauntlet. But notice what happens after the loop: there’s a call to display_name() that modifies conf["user"]["name"]. That’s the next piece of the puzzle.
The display_name function #
def display_name(username: str) -> str:
return "".join(p.capitalize() for p in username.split("_"))This is meant to create a display-friendly version of a username. It splits on underscores, capitalizes each part, and joins them back together. For example:
| Input | Split parts | After capitalize | Joined |
|---|---|---|---|
john_doe |
["john", "doe"] |
["John", "Doe"] |
JohnDoe |
hello_world |
["hello", "world"] |
["Hello", "World"] |
HelloWorld |
Seems harmless. But look at what happens with carefully chosen inputs:
| Input | Split parts | After capitalize | Joined |
|---|---|---|---|
._._ |
[".", ".", ""] |
[".", ".", ""] |
.. |
The string ._._ becomes .. after processing. The capitalize() call on . returns . (there’s nothing to capitalize), and the underscores disappear.
This means display_name can produce path traversal sequences from inputs that don’t contain ../.
YAML anchors and aliases #
Here’s the core trick. YAML supports anchors (&name) and aliases (*name), which create shared references to the same object. This is a feature for avoiding repetition in config files:
defaults: &defaults
timeout: 30
retries: 3
server_a:
<<: *defaults
host: a.example.com
server_b:
<<: *defaults
host: b.example.comThe critical detail: anchors and aliases don’t create copies. They create references to the same object in memory. In Python terms, after yaml.safe_load:
data["defaults"] is data["server_a"] # same dict objectThis means mutating one mutates the other. And that’s the key to bypassing validation.
Putting it together #
The validation loop checks conf["blogs"][0]["name"], and then later the code does:
conf["user"]["name"] = display_name(conf["user"].get("name", ...))If conf["user"] and conf["blogs"][0] are the same dict object (via a YAML alias), then writing to conf["user"]["name"] also overwrites conf["blogs"][0]["name"].
The attack config:
blogs:
- &ref
title: "flag"
name: "._._/._._/flag"
user: *refHere’s the step-by-step execution:
-
YAML parsing:
yaml.safe_loadcreates one dict{"title": "flag", "name": "._._/._._/flag"}. Bothblogs[0]anduserpoint to this same dict. -
Validation loop: The code checks
blogs[0]["name"]which is"._._/._._/flag". This passes all three checks:"../" in "._._/._._/flag"→False(no../substring)"._._/._._/flag".startswith("/")→False- The resolved path stays under
blog_path(since there’s no actual..yet)
-
The mutation: After the loop, the code runs:
conf["user"]["name"] = display_name(conf["user"].get("name", ...))conf["user"]is the same dict asblogs[0], soconf["user"].get("name")returns"._._/._._/flag". Thendisplay_nameprocesses it:display_name("._._/._._/flag") # split("_") → [".", ".", "/.", ".", "/flag"] # capitalize each → [".", ".", "/.", ".", "/flag"] # join → "../../flag"Why does
capitalize()leave everything unchanged? It uppercases only the first character and lowercases the rest. The first character in each part is either.or/, and non-alphabetic characters have no uppercase form, so they pass through. The remaining letters (flag) are already lowercase, so lowercasing them is a no-op.The concatenation builds up
../../flagpiece by piece: -
This overwrites
blogs[0]["name"]to"../../flag". Validation already passed, so it’s too late to catch it. -
Reading the blog: When someone visits
/blog/<username>, the server does:(blog_path / blog["name"]).read_text()Which resolves
blogs/../../flag→/flag, and we get the flag.
Exploit #
-
Register an account with any username and password
-
Submit the malicious YAML config via the config editor:
blogs: - &ref title: "flag" name: "._._/._._/flag" user: *ref -
Visit
/blog/<your_username>. The server reads/flagand renders it as your blog post
You can do all of this through the web UI. Paste the YAML into the config editor on the left side, hit “Update Config”, then click the “blog” link to view your page.
Why the fix is hard #
The root cause isn’t just the display_name function or the YAML aliases individually, it’s the combination. The code validates a data structure, then mutates part of it, not realizing that YAML aliasing has linked that part to something already validated.
Defenses that would prevent this:
- Deep-copy the parsed YAML before processing, breaking shared references
- Validate after all mutations, not before
- Don’t mutate the config in-place, build a new dict for the validated output
Flag #
lactf{7m_g0nn4_bl0g_y0u}