156 - Сломанный магазин (Broken Shop)
156 - Сломанный магазин (Broken Shop)
Platform: Duckerz CTF | Category: Web | Type: Challenge | Difficulty: Easy | OS: NA | Author: D3v0o0Nu11 | Date: 2026-01-09 | Status: Solved Techniques: business_logic_bypass, stale_callback_exploitation, state_desync
Summary
Files: broken_shop.zip (source code)
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
duckerz| ID:20260109_duckerz_broken_shop - Tags: race_condition, telegram_bot, state_manipulation, callback_data, price_manipulation
- Indicators: telegram bot shop, callback_data with price, separate cancel mechanisms, pending purchase state
- Source:
20260109_duckerz_broken_shop.md
Foothold
Vulnerability / Misconfiguration
- Business_logic_bypass
- Stale_callback_exploitation
- State_desync
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- business_logic_bypass
- stale_callback_exploitation
- state_desync
- Tags: race_condition, telegram_bot, state_manipulation, callback_data, price_manipulation
Original Writeup
<details><summary>Click to expand original content</summary>Description
Я хотел купить все части флагов в магазине - https://t.me/my_super_puper_shop_bot, но у меня не хватило денег, что мне делать?
Files: broken_shop.zip (source code)
Analysis
System Architecture
The shop consists of two components:
- Telegram bot — interface for purchase confirmation
- Web backend — main shop logic, balance management
Products and Balance
| Product | Price |
|---|---|
| Picture | 50₽ |
| Flag Part | 500₽ |
| |
- Initial balance: 1000₽
- Goal: Buy 5 flag parts (500₽ × 5 = 2500₽)
- Problem: Not enough money!
Source Code Analysis
1. Confirmation Button Creation (bot/main.py)
When creating a purchase request, the bot generates inline buttons with the price in callback_data:
callback_data=f"purchase:confirm:{purchase_id}:{total_value}:{product_value}"
Important: The price (total_value) is stored in the button itself!
2. Confirmation Handling (bot/main.py)
When the button is pressed, the bot extracts the price from callback_data and sends it to the backend:
callback_total = parts[3] if action == "confirm" and len(parts) >= 4 else None
# ...
if callback_total is not None:
confirm_headers["X-Planner-Locked-Amount"] = callback_total
3. Backend Accepts Overridden Price (backend/app/crud.py)
The backend trusts the price from the X-Planner-Locked-Amount header:
if override_total is not None:
try:
candidate = float(override_total)
if candidate >= 0:
total_price = int(candidate)
except (TypeError, ValueError):
pass
Vulnerability: State Desynchronization
Key observation: There are two ways to cancel a request:
- Via Telegram — [Cancel] button → message is edited, buttons are removed
- Via Website — "Cancel request" button → buttons in Telegram remain!
When canceling via the website:
- The request is canceled on the backend
- The Telegram message is NOT updated
- Old buttons with callback_data remain active
Exploitation:
- Create a request for a cheap item (50₽)
- Get a button with
callback_datacontaining price 50₽ - Cancel the request via the WEBSITE (buttons remain)
- Create a request for an expensive item (500₽)
- Press the OLD button with price 50₽
- Backend confirms the CURRENT request (500₽) at the OLD price (50₽)!
Solution
Step-by-Step Exploitation (repeat 5 times)
┌─────────────────────────────────────────────────────────────┐
│ Step 1: [WEBSITE] Buy "Picture" for 50₽ │
│ → Creates pending purchase │
├─────────────────────────────────────────────────────────────┤
│ Step 2: [TELEGRAM] Send /submit │
│ → Receive message with buttons: │
│ [Confirm] [Cancel] │
│ callback_data contains price 50₽ │
├─────────────────────────────────────────────────────────────┤
│ Step 3: [WEBSITE] Click "Cancel request" │
│ → Request canceled, but TG buttons remain! │
├─────────────────────────────────────────────────────────────┤
│ Step 4: [WEBSITE] Buy "Flag Part" for 500₽ │
│ → New pending purchase for 500₽ │
├─────────────────────────────────────────────────────────────┤
│ Step 5: [TELEGRAM] Press the OLD [Confirm] button │
│ → Sends X-Planner-Locked-Amount: 50 │
│ → Backend confirms current request for 50₽! │
├─────────────────────────────────────────────────────────────┤
│ Result: Flag part purchased for 50₽ instead of 500₽! │
└─────────────────────────────────────────────────────────────┘
Cost Calculation
| Method | Calculation | Total |
|---|---|---|
| Legitimate | 500₽ × 5 | 2500₽ (impossible) |
| Exploit | 50₽ × 5 | 250₽ ✓ |
Remaining balance after exploitation: 1000₽ - 250₽ = 750₽
Getting the Flag
After purchasing all 5 flag parts:
/products
The bot sends the assembled flag.
Lessons
- Never trust client-side data — price should be fetched from the database, not from callback_data
- State synchronization — when state changes via one channel, all related interfaces must be updated
- Idempotency — callback_data should contain only IDs, not business data
Proper Fix
# Instead of passing price in callback_data:
# callback_data=f"purchase:confirm:{purchase_id}:{total_value}"
# Use only ID:
callback_data=f"purchase:confirm:{purchase_id}"
# And get the price from the database on confirmation:
purchase = get_purchase_by_id(purchase_id)
total_price = purchase.calculated_total # From DB, not from client
</details>Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR