← Back to Writeups
HTBN/AWeb

156 - Сломанный магазин (Broken Shop)

XESXOR8/23/20264 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Business_logic_bypass
  2. Stale_callback_exploitation
  3. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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

ProductPrice
Picture50₽
Flag Part500₽
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
  • 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:

  1. Via Telegram — [Cancel] button → message is edited, buttons are removed
  2. 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:

  1. Create a request for a cheap item (50₽)
  2. Get a button with callback_data containing price 50₽
  3. Cancel the request via the WEBSITE (buttons remain)
  4. Create a request for an expensive item (500₽)
  5. Press the OLD button with price 50₽
  6. 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

MethodCalculationTotal
Legitimate500₽ × 52500₽ (impossible)
Exploit50₽ × 5250₽ ✓

Remaining balance after exploitation: 1000₽ - 250₽ = 750₽

Getting the Flag

After purchasing all 5 flag parts:

/products

The bot sends the assembled flag.

Lessons

  1. Never trust client-side data — price should be fetched from the database, not from callback_data
  2. State synchronization — when state changes via one channel, all related interfaces must be updated
  3. 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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR