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:
POST /edit_product/7description={{7*7}}— plant the payload (IDOR).POST /update_balance/<me>balance=1000000— afford checkout (IDOR).GET /add_to_cart/7→GET /checkout— buy it, snapshotting the product into my orders.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
/shoprendered{{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
envalongsidecat /flag*. cycler.__init__.__globals__.osis a tidy Jinja2 RCE gadget when thecycler/joiner/namespaceglobals are in scope — no__subclasses__()walking needed.
The Fix
- Enforce authorization server-side on
/edit_productand/update_balance(check the session's role, don't trust the path parameter). - Never feed stored user input to
render_template_string. Render withrender_templateand pass the value as a context variable so it's autoescaped like every other field.
0xAdham
