Skip to main content0xAdham

Command Palette

Search for a command to run...

FlagYard — Tech Shop

Written by
Avatar of 0xAdham
0xAdham
Published on
--
FlagYard — Tech Shop

Tech Shop is a Flask "e-commerce" app (products, cart, balance, order history). It hands you an obvious IDOR and an obvious SSTI attempt that looks like a dead end. The trick is that the same user-controlled field is rendered in two different places — one safe, one not.

The Setup

The /shop page ships a JS comment block that basically hands you the internal API:

Both are labelled "admin only". Neither actually checks your role.

Step 1 — Broken Access Control (IDOR)

Logged in as a normal user, both endpoints work and just trust the path parameter:

No privilege check anywhere. That's the enabler — but on its own it's just "vandalise a product / give yourself money".

Step 2 — The SSTI That Looks Dead

The natural next move is template injection through the description. On /shop it flatly refuses:

{{7*7}} renders literally. The product listing uses {{ product.description }} with normal autoescaping (render_template) — not a template-injection sink. Easy to conclude SSTI is off the table here. It isn't.

Step 3 — Find the Second Render

Enumerating the app turns up /profile/<id>. Most profiles are ~2 KB; the seeded admin's profile is ~8.8 KB because it renders an Order History table — and each row echoes the purchased product's description again:

Same field, second render point. If this one interpolates through render_template_string, it's injectable even though /shop isn't.

Step 4 — Chain It

To get my own tainted description into my order history:

  1. POST /edit_product/7 description={{7*7}} — plant the payload (IDOR).
  2. POST /update_balance/<me> balance=1000000 — afford checkout (IDOR).
  3. GET /add_to_cart/7GET /checkout — buy it, snapshotting the product into my orders.
  4. GET /profile/<me> — the order-history table re-renders the description.

The <small> block came back as:

{{7*7}} evaluated. SSTI confirmed on the profile sink.

Step 5 — SSTI to RCE

The Jinja2 context isn't sandboxed, so a compact global-walk gadget gets command execution. Set the description to:

Buy again, reload the profile, and the order-history row renders:

The flag isn't on disk (cat /flag* finds nothing) — it's injected as the DYN_FLAG environment variable, so env pulls it.

Flag

Full Solve Script

Why It Works

The same stored value hits two sinks with different safety. /shop uses {{ product.description }} under autoescaping, so injection is inert. The profile's order-history template builds its output with render_template_string, so the stored description is parsed as a Jinja2 template every time the page loads. IDOR gives write access to that stored value; the second render turns it into code execution.

What I Did Wrong

  • Spent time convinced my tooling was broken when https:// requests hung. The instance only listens on HTTP :80 — 443 just times out. One TCP connect per port would have caught it immediately.
  • Nearly wrote SSTI off after /shop rendered {{7*7}} literally, instead of asking where else the same field is displayed.

What I Learned

  • When one render point escapes your payload, hunt for a second place the same data is shown before abandoning the bug class. Order-history / receipt / invoice pages are classic unsafe re-render sinks.
  • IDOR is often the enabler, not the finish line — here it only mattered because it fed a second, unsafe render.
  • Flags aren't always files. Always dump env alongside cat /flag*.
  • cycler.__init__.__globals__.os is a tidy Jinja2 RCE gadget when the cycler/joiner/namespace globals are in scope — no __subclasses__() walking needed.

The Fix

  • Enforce authorization server-side on /edit_product and /update_balance (check the session's role, don't trust the path parameter).
  • Never feed stored user input to render_template_string. Render with render_template and pass the value as a context variable so it's autoescaped like every other field.

0xAdham

Edit on GitHub
Last updated: --